diff --git a/CHANGELOG.md b/CHANGELOG.md index 0aaad6b..6235492 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,35 @@ SPDX-License-Identifier: Apache-2.0 # Changelog +## Unreleased + +## 0.2.0 + +Add the core ownership and evidence foundation as explicit `@workit/core` +subpaths. The root import remains unchanged. + +- Add `@workit/core/replay` for lifecycle receipt recording and redaction. +- Add `@workit/core/ledger` for memory and file receipt ledgers. +- Add `@workit/core/analysis` for receipt and caller-provided protocol + verification. +- Add `@workit/core/activity` for explicit terminal activity boundaries. +- Add `@workit/core/resources` for lazy, shared, and scope-owned resource + helpers. +- Add package-consumer coverage for the new subpaths across ESM, CommonJS, and + strict TypeScript fixtures. +- Add executable evidence for receipts, ledgers, analysis, activity terminal + replay, and resource ownership. +- Keep the root `@workit/core` bundle size unchanged. +- Improve npm package discoverability with targeted package keywords and a more + specific package description. +- Clarify npm README examples for retry policies, `TaskFn` invocation, + `renderTree(scope.status())`, and `work().do()` fail-fast output. +- Normalize activity results before persistence so first execution and replay + return the same JSON value. +- Derive the OpenTelemetry instrumentation version from package metadata. +- Document the buffered `work().do()` contract and cooperative cancellation + boundary for hedged work. + ## 0.1.5 Move `@workit/core` to `packages/core` monorepo layout. No public API changes. diff --git a/README.md b/README.md index aa796e3..f653e2d 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ cite the software release you used: title = {WorkIt: A TypeScript Structured Concurrency Runtime for Node.js Server Runtimes}, year = {2026}, url = {https://github.com/WorkRuntime/workit}, - version = {0.1.5}, + version = {0.2.0}, license = {Apache-2.0} } ``` @@ -91,11 +91,16 @@ Stable consumer paths for this release line: ```txt @workit/core +@workit/core/activity @workit/core/ai +@workit/core/analysis @workit/core/channel @workit/core/diagnostics +@workit/core/ledger @workit/core/observability @workit/core/otel +@workit/core/replay +@workit/core/resources @workit/core/worker ``` diff --git a/package-lock.json b/package-lock.json index 9f87e42..e7a2913 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "workit", - "version": "0.1.5", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "workit", - "version": "0.1.5", + "version": "0.2.0", "license": "Apache-2.0", "workspaces": [ "packages/core" @@ -3453,7 +3453,7 @@ }, "packages/core": { "name": "@workit/core", - "version": "0.1.5", + "version": "0.2.0", "license": "Apache-2.0", "devDependencies": { "@opentelemetry/api": "1.9.1", diff --git a/package.json b/package.json index e23ab24..a3ef92e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "workit", - "version": "0.1.5", + "version": "0.2.0", "private": true, "description": "WorkIt monorepo.", "type": "module", diff --git a/packages/core/README.md b/packages/core/README.md index f93f089..69fe57f 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -39,14 +39,24 @@ Changelog: ```ts import { work } from "@workit/core"; -const doubled = await work([1, 2, 3]) +const output = await work([1, 2, 3]) .inParallel(2) .do(async (value, _ctx) => value * 2); + +const doubled = output.results; ``` The context parameter is available when the task needs cancellation, progress, budgets, or scoped resources. It can be ignored for plain transformations. +> [!IMPORTANT] +> `work().do()` defaults to fail-fast mode. If any item fails, the call throws +> and cancels sibling work. On success it returns `{ mode: "fail", results }`, +> not a bare array. Use `.onError("continue")` or `.onError("collect")` when +> callers need per-item failures in the returned value. `work().do()` buffers +> the source before execution; use `.stream()` for unbounded or very large +> async iterables. + ## Why Ownership Matters Consider this batch helper: @@ -232,6 +242,55 @@ Rules: | OpenTelemetry bridge | `@workit/core/otel` | | Agent helper contracts | `@workit/core/ai` | +### Task Functions And Invocation + +The resilience helpers `run.timeout()`, `run.retry()`, `run.fallback()`, +`run.deadline()`, `run.hedge()`, `run.bracket()`, `run.circuitBreaker()`, and +`run.uncancellable()` return a `TaskFn`. A `TaskFn` is a description of owned +work; run it through `group()`/`task(...)` or `scope.spawn(...)`. + +```ts +import { group, run } from "@workit/core"; + +const chargeWithRetry = run.retry( + run.timeout( + (ctx) => chargeCustomer(invoice, { signal: ctx.signal }), + "5s" + ), + { times: 3 } +); + +const receipt = await group(async (task) => task(chargeWithRetry)); +``` + +`renderTree()` takes a scope snapshot, not a live scope. Use +`renderTree(scope.status())`. + +```ts +import { renderTree, run } from "@workit/core"; + +await run.scope(async (scope) => { + const handle = scope.spawn((ctx) => fetchProfile({ signal: ctx.signal })); + + console.log(renderTree(scope.status())); + + return handle; +}); +``` + +## Evidence And Ownership Subpaths + +The root import remains focused on the runtime primitives. Evidence and +ownership helpers live behind explicit subpaths. + +| Need | Subpath | Boundary | +|---|---|---| +| Build lifecycle receipts from scope events and snapshots | `@workit/core/replay` | audit evidence, not deterministic scheduler replay | +| Persist receipts in caller-owned stores | `@workit/core/ledger` | memory and file receipt ledgers, not a database framework | +| Verify receipts and caller-provided protocol specs | `@workit/core/analysis` | bounded verification over supplied evidence, not whole-program analysis | +| Record explicit terminal activity boundaries | `@workit/core/activity` | completed activity replay, not in-flight workflow recovery | +| Compose lazy, shared, and scope-owned resources | `@workit/core/resources` | cleanup ownership through WorkIt scopes, not automatic resource detection | + ## Common Use Cases These are short entry points. The full narrative and benchmark discussion live @@ -275,24 +334,41 @@ The first success wins. Losing branches receive `CancelReason { kind: ### Retry With Timeout ```ts -import { run } from "@workit/core"; - -const receipt = await run.retry( - (ctx) => - run.timeout( - (timeoutCtx) => - chargeCustomer(invoice, { - signal: AbortSignal.any([ctx.signal, timeoutCtx.signal]), - }), - "5s" - ), - { retries: 3 } +import { group, run } from "@workit/core"; + +const chargeWithRetry = run.retry( + run.timeout( + (ctx) => chargeCustomer(invoice, { signal: ctx.signal }), + "5s" + ), + { times: 3 } ); + +const receipt = await group(async (task) => task(chargeWithRetry)); ``` The retry policy, timeout, and caller cancellation share one owned execution path instead of living in separate helper layers. +### Hedged Work + +`run.hedge(task, policy)` starts delayed duplicate attempts and returns the +first successful attempt. Non-winning attempts receive an aborted `ctx.signal`. +Task bodies and any resources they acquire must observe that signal or install +their own bounded cleanup; JavaScript cannot preempt non-cooperative work that +ignores cancellation. + +```ts +import { group, run } from "@workit/core"; + +const rerank = run.hedge( + (ctx) => reranker.score(candidates, { signal: ctx.signal }), + { after: "75ms", max: 2 } +); + +const scores = await group(async (task) => task(rerank)); +``` + ### Backpressured Stream ```ts @@ -379,8 +455,9 @@ thresholds, not exact milliseconds. | Evidence | Current result | |---|---:| -| Unit tests | 221 passing | +| Unit tests | 299 passing | | Coverage gate | 100% statements, branches, functions, lines | +| Evidence proof files | 14 passing | | Runtime dependencies | 0 | | Article benchmark suite | 19/19 passing | | Core group import | 14,175 B minified / 4,835 B gzip | @@ -559,7 +636,7 @@ cite the software release you used: title = {WorkIt: A TypeScript Structured Concurrency Runtime for Node.js Server Runtimes}, year = {2026}, url = {https://github.com/WorkRuntime/workit}, - version = {0.1.5}, + version = {0.2.0}, license = {Apache-2.0} } ``` diff --git a/packages/core/evidence/claims.json b/packages/core/evidence/claims.json index bad91c1..89f8098 100644 --- a/packages/core/evidence/claims.json +++ b/packages/core/evidence/claims.json @@ -47,6 +47,56 @@ "expectedInvariant": "a hanging cleanup emits task:cleanup_timeout and the owner settles", "limitations": "The timeout bounds WorkIt cleanup waiting; external systems still need their own timeout and idempotency policies." }, + { + "id": "LIFE-004", + "title": "replay receipts record completed scope closure", + "class": "lifecycle", + "status": "proven", + "proof": "tests/evidence/lifecycle/replay-receipts.mjs", + "command": "npm run test:evidence", + "expectedInvariant": "a closed successful scope has completed terminal outcome and no leaked tasks", + "limitations": "Receipts are audit evidence over observed WorkIt events and snapshots; they are not deterministic scheduler replay." + }, + { + "id": "LIFE-005", + "title": "replay receipts preserve typed cancellation reason", + "class": "lifecycle", + "status": "proven", + "proof": "tests/evidence/lifecycle/replay-receipts.mjs", + "command": "npm run test:evidence", + "expectedInvariant": "a manually cancelled scope records the cancellation reason observed by owned work", + "limitations": "The reason is evidence captured from WorkIt-owned cancellation; external systems still need their own abort support." + }, + { + "id": "LIFE-006", + "title": "shared scope resource acquires and releases once", + "class": "lifecycle", + "status": "proven", + "proof": "tests/evidence/lifecycle/resource-ownership.mjs", + "command": "npm run test:evidence", + "expectedInvariant": "parallel tasks using one shared helper observe one acquisition and one scope-owned release", + "limitations": "This proves WorkIt helper ownership and cleanup registration; external resource semantics remain application responsibility." + }, + { + "id": "LIFE-008", + "title": "file activity store replays completed activity after restart", + "class": "lifecycle", + "status": "proven", + "proof": "tests/evidence/lifecycle/activity-restart.mjs", + "command": "npm run test:evidence", + "expectedInvariant": "a fresh file activity store returns the persisted terminal result without rerunning the activity body", + "limitations": "This is terminal activity replay for explicit activity ids; it is not arbitrary in-flight crash recovery or workflow replay." + }, + { + "id": "LIFE-011", + "title": "resource ownership can be audited across cleanup paths", + "class": "lifecycle", + "status": "proven", + "proof": "tests/evidence/lifecycle/resource-audit.mjs", + "command": "npm run test:evidence", + "expectedInvariant": "explicit resource audit instrumentation records acquired/released/pending resources while WorkIt emits cleanup timeout and failure events", + "limitations": "Resource audit entries are explicit caller instrumentation; WorkIt supplies cleanup failure and timeout events through the scope event surface." + }, { "id": "CORR-001", "title": "budget inputs are immutable boundary values", @@ -87,6 +137,56 @@ "expectedInvariant": "unbounded retry counts are rejected at the policy boundary", "limitations": "The cap prevents accidental huge retry policies; application-level retry budgets may be stricter." }, + { + "id": "CORR-007", + "title": "receipt analysis detects leaked owned work", + "class": "correctness", + "status": "proven", + "proof": "tests/evidence/correctness/analysis-verifiers.mjs", + "command": "npm run test:evidence", + "expectedInvariant": "a non-terminal receipt with pending tasks fails analysis with leaked_tasks", + "limitations": "The verifier analyzes receipt evidence supplied to it; it does not observe work that was never captured." + }, + { + "id": "CORR-012", + "title": "explicit activity boundary preserves terminal evidence", + "class": "correctness", + "status": "proven", + "proof": "tests/evidence/correctness/activity-boundary.mjs", + "command": "npm run test:evidence", + "expectedInvariant": "completed activity returns the stored result on repeat and changed input conflicts before rerun", + "limitations": "The boundary records explicit terminal activity evidence; callers remain responsible for idempotent external side effects." + }, + { + "id": "CORR-013", + "title": "receipt verifier proves declared lifecycle evidence", + "class": "correctness", + "status": "proven", + "proof": "tests/evidence/correctness/analysis-verifiers.mjs", + "command": "npm run test:evidence", + "expectedInvariant": "a closed receipt with terminal and cleanup evidence verifies no orphaned owned tasks while preserving cleanup timeout warning evidence", + "limitations": "Verification is bounded to the receipt and snapshot supplied by the caller." + }, + { + "id": "CORR-015", + "title": "bounded resource model preserves cleanup balance invariant", + "class": "correctness", + "status": "proven", + "proof": "tests/evidence/correctness/resource-ownership-model.mjs", + "command": "npm run test:evidence", + "expectedInvariant": "for linear, lazy, and shared resources, every terminal modeled scope releases exactly the resources it acquired", + "limitations": "This is a bounded model for WorkIt resource helper contracts, not a proof over arbitrary JavaScript programs or external resources." + }, + { + "id": "CORR-017", + "title": "source protocol analysis detects missing ownership edges", + "class": "correctness", + "status": "proven", + "proof": "tests/evidence/correctness/source-protocol-analysis.mjs", + "command": "npm run test:evidence", + "expectedInvariant": "bounded source protocol specs pass when WorkIt ownership edges are present and fail with stable findings when ownership edges are missing", + "limitations": "This verifies caller-provided protocol specifications, not arbitrary TypeScript or JavaScript source." + }, { "id": "SEC-001", "title": "worker offload rejects remote and executable URL schemes", @@ -137,6 +237,16 @@ "expectedInvariant": "release-provenance script contains signed-tag verification logic", "limitations": "A dry-run gate cannot guarantee a future maintainer will sign a tag; it verifies that the repository policy gate exists." }, + { + "id": "REL-004", + "title": "file receipt ledger persists append-only receipt evidence", + "class": "release", + "status": "proven", + "proof": "tests/evidence/release/receipt-ledger.mjs", + "command": "npm run test:evidence", + "expectedInvariant": "a receipt appended by one ledger instance is readable from a new ledger instance", + "limitations": "This proof covers WorkIt's file-backed receipt ledger, not database durability or distributed queue semantics." + }, { "id": "PERF-001", "title": "article benchmark suite has expected executable coverage", diff --git a/packages/core/evidence/resource-ownership-model.json b/packages/core/evidence/resource-ownership-model.json new file mode 100644 index 0000000..99347c3 --- /dev/null +++ b/packages/core/evidence/resource-ownership-model.json @@ -0,0 +1,35 @@ +{ + "author": "Admilson B. F. Cossa", + "spdxLicense": "Apache-2.0", + "artifact": "workit-bounded-resource-ownership-model", + "version": 1, + "model": { + "entities": ["scope", "resource", "cleanup", "resource_user"], + "patterns": ["linear", "lazy", "shared"], + "terminalCauses": ["completed", "failed", "cancelled"], + "transitions": [ + "linear_acquires_before_body", + "lazy_acquires_on_first_get", + "shared_acquires_on_first_scope_user", + "terminal_scope_runs_registered_cleanup", + "unused_lazy_resource_has_no_cleanup", + "unused_shared_resource_has_no_cleanup" + ], + "invariants": [ + "terminal_scope_has_no_open_modeled_resource", + "release_count_never_exceeds_acquire_count", + "linear_resource_releases_exactly_once", + "lazy_resource_releases_iff_acquired", + "shared_resource_acquires_and_releases_once_per_scope_when_used" + ] + }, + "bounds": { + "maxSharedUsers": 3, + "maxExploredStatesPerPattern": 1000 + }, + "limitations": [ + "This is a bounded model for WorkIt resource helper contracts, not a proof over arbitrary JavaScript.", + "Process crashes, external resource semantics, database transactions, finalizers, and operating-system cleanup are outside this model.", + "The model checks finite traces for linear, lazy, and shared helper patterns only." + ] +} diff --git a/packages/core/package.json b/packages/core/package.json index cdceab0..90d3033 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,7 +1,38 @@ { "name": "@workit/core", - "version": "0.1.5", - "description": "Structured concurrency runtime for TypeScript.", + "version": "0.2.0", + "description": "Structured concurrency runtime for TypeScript: owned async work, cancellation, budgets, retries, timeouts, worker offload, scopes.", + "keywords": [ + "structured-concurrency", + "concurrency", + "async", + "asynchronous", + "scope", + "scopes", + "task", + "tasks", + "cancellation", + "cancel", + "abort", + "timeout", + "retry", + "backoff", + "budget", + "rate-limit", + "worker-threads", + "worker", + "promise", + "typescript", + "runtime", + "ai", + "llm", + "embedding", + "agent", + "rag", + "batch", + "stream", + "fan-out" + ], "type": "module", "private": false, "license": "Apache-2.0", @@ -33,6 +64,19 @@ }, "default": "./dist/runtime/unsupported.js" }, + "./activity": { + "types": "./dist/activity/index.d.ts", + "node": { + "import": "./dist/activity/index.js", + "require": "./dist-cjs/activity/index.cjs" + }, + "default": "./dist/runtime/unsupported.js" + }, + "./analysis": { + "types": "./dist/analysis/index.d.ts", + "import": "./dist/analysis/index.js", + "require": "./dist-cjs/analysis/index.cjs" + }, "./channel": { "types": "./dist/channel/index.d.ts", "import": "./dist/channel/index.js", @@ -48,11 +92,29 @@ "import": "./dist/diagnostics/index.js", "require": "./dist-cjs/diagnostics/index.cjs" }, + "./ledger": { + "types": "./dist/ledger/index.d.ts", + "node": { + "import": "./dist/ledger/index.js", + "require": "./dist-cjs/ledger/index.cjs" + }, + "default": "./dist/runtime/unsupported.js" + }, "./otel": { "types": "./dist/otel/index.d.ts", "import": "./dist/otel/index.js", "require": "./dist-cjs/otel/index.cjs" }, + "./replay": { + "types": "./dist/replay/index.d.ts", + "import": "./dist/replay/index.js", + "require": "./dist-cjs/replay/index.cjs" + }, + "./resources": { + "types": "./dist/resources/index.d.ts", + "import": "./dist/resources/index.js", + "require": "./dist-cjs/resources/index.cjs" + }, "./worker": { "types": "./dist/worker/index.d.ts", "node": { @@ -70,6 +132,7 @@ "CONTRIBUTING.md", "dist", "dist-cjs", + "README.md", "SECURITY.md" ], "scripts": { diff --git a/packages/core/scripts/build-cjs.mjs b/packages/core/scripts/build-cjs.mjs index 4328845..41ddaeb 100644 --- a/packages/core/scripts/build-cjs.mjs +++ b/packages/core/scripts/build-cjs.mjs @@ -14,11 +14,16 @@ import { build } from "esbuild"; const ENTRIES = [ { entry: "dist/index.js", outfile: "dist-cjs/index.cjs" }, + { entry: "dist/activity/index.js", outfile: "dist-cjs/activity/index.cjs" }, { entry: "dist/ai/index.js", outfile: "dist-cjs/ai/index.cjs" }, + { entry: "dist/analysis/index.js", outfile: "dist-cjs/analysis/index.cjs" }, { entry: "dist/channel/index.js", outfile: "dist-cjs/channel/index.cjs" }, { entry: "dist/diagnostics/index.js", outfile: "dist-cjs/diagnostics/index.cjs" }, + { entry: "dist/ledger/index.js", outfile: "dist-cjs/ledger/index.cjs" }, { entry: "dist/observability/index.js", outfile: "dist-cjs/observability/index.cjs" }, { entry: "dist/otel/index.js", outfile: "dist-cjs/otel/index.cjs" }, + { entry: "dist/replay/index.js", outfile: "dist-cjs/replay/index.cjs" }, + { entry: "dist/resources/index.js", outfile: "dist-cjs/resources/index.cjs" }, ]; await rm("dist-cjs", { recursive: true, force: true }); diff --git a/packages/core/scripts/check-api-surface.mjs b/packages/core/scripts/check-api-surface.mjs index b917dbb..44b118d 100644 --- a/packages/core/scripts/check-api-surface.mjs +++ b/packages/core/scripts/check-api-surface.mjs @@ -16,11 +16,16 @@ const require = createRequire(import.meta.url); const EXPECTED_EXPORT_MAP = [ ".", + "./activity", "./ai", + "./analysis", "./channel", "./diagnostics", + "./ledger", "./observability", "./otel", + "./replay", + "./resources", "./worker", ]; @@ -43,6 +48,14 @@ const EXPECTED_RUNTIME_EXPORTS = { "run", "work", ], + "./activity": [ + "ActivityConflictError", + "ActivityNotRunnableError", + "ActivitySerializationError", + "createFileActivityStore", + "createMemoryActivityStore", + "runActivity", + ], "./ai": [ "AgentToolCalls", "BadBatchError", @@ -55,6 +68,12 @@ const EXPECTED_RUNTIME_EXPORTS = { "transcribeStream", "wrapAI", ], + "./analysis": [ + "analyzeReceipt", + "verifyReceipt", + "verifyScopeProtocol", + "verifySourceProtocol", + ], "./channel": [ "ChannelClosedError", "createChannel", @@ -62,6 +81,11 @@ const EXPECTED_RUNTIME_EXPORTS = { "./diagnostics": [ "diagnoseSnapshot", ], + "./ledger": [ + "ReceiptLedgerConflictError", + "createFileReceiptLedger", + "createMemoryReceiptLedger", + ], "./observability": [ "attachScopeSummaryExporter", "attachTelemetryExporter", @@ -70,6 +94,16 @@ const EXPECTED_RUNTIME_EXPORTS = { "./otel": [ "attachOpenTelemetry", ], + "./replay": [ + "buildReceipt", + "createReceiptRecorder", + "redactReceipt", + ], + "./resources": [ + "bracketLazy", + "bracketShared", + "scopeAcquire", + ], "./worker": [ "offload", ], @@ -77,30 +111,45 @@ const EXPECTED_RUNTIME_EXPORTS = { const EXPECTED_EXPORT_CONDITIONS = { ".": ["default", "node", "types"], + "./activity": ["default", "node", "types"], "./ai": ["default", "node", "types"], + "./analysis": ["import", "require", "types"], "./channel": ["import", "require", "types"], "./diagnostics": ["import", "require", "types"], + "./ledger": ["default", "node", "types"], "./observability": ["import", "require", "types"], "./otel": ["import", "require", "types"], + "./replay": ["import", "require", "types"], + "./resources": ["import", "require", "types"], "./worker": ["default", "node", "types"], }; const MODULE_PATHS = { ".": "../dist/index.js", + "./activity": "../dist/activity/index.js", "./ai": "../dist/ai/index.js", + "./analysis": "../dist/analysis/index.js", "./channel": "../dist/channel/index.js", "./diagnostics": "../dist/diagnostics/index.js", + "./ledger": "../dist/ledger/index.js", "./observability": "../dist/observability/index.js", "./otel": "../dist/otel/index.js", + "./replay": "../dist/replay/index.js", + "./resources": "../dist/resources/index.js", "./worker": "../dist/worker/index.js", }; const CJS_MODULE_PATHS = { ".": "../dist-cjs/index.cjs", + "./activity": "../dist-cjs/activity/index.cjs", "./ai": "../dist-cjs/ai/index.cjs", + "./analysis": "../dist-cjs/analysis/index.cjs", "./channel": "../dist-cjs/channel/index.cjs", "./diagnostics": "../dist-cjs/diagnostics/index.cjs", + "./ledger": "../dist-cjs/ledger/index.cjs", "./observability": "../dist-cjs/observability/index.cjs", + "./replay": "../dist-cjs/replay/index.cjs", + "./resources": "../dist-cjs/resources/index.cjs", }; const packageJson = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8")); diff --git a/packages/core/scripts/check-package-consumer.mjs b/packages/core/scripts/check-package-consumer.mjs index ed3f59d..804763b 100644 --- a/packages/core/scripts/check-package-consumer.mjs +++ b/packages/core/scripts/check-package-consumer.mjs @@ -105,9 +105,14 @@ try { await writeFile(join(temp, "smoke.mjs"), ` import { run, work, group } from "@workit/core"; + import { createMemoryActivityStore, runActivity } from "@workit/core/activity"; + import { analyzeReceipt } from "@workit/core/analysis"; import { embedAll, streamWithBackpressure } from "@workit/core/ai"; + import { createMemoryReceiptLedger } from "@workit/core/ledger"; import { attachTelemetryExporter } from "@workit/core/observability"; import { attachOpenTelemetry } from "@workit/core/otel"; + import { buildReceipt, redactReceipt } from "@workit/core/replay"; + import { bracketLazy } from "@workit/core/resources"; import { offload } from "@workit/core/worker"; const result = await run.all([async () => "sdk", async () => "ok"]); @@ -115,6 +120,50 @@ try { const embedded = await embedAll(["a"], { embed: async (text) => [text.length] }, { concurrency: 1 }); const streamed = []; for await (const item of streamWithBackpressure(["x"], async (input) => input.toUpperCase())) streamed.push(item); + const receipt = buildReceipt([], { + id: "consumer-scope", + status: "closed", + startedAt: 1, + pendingCount: 0, + completedCount: 0, + failedCount: 0, + cancelledCount: 0, + tasks: [], + scopes: [] + }, { receiptId: "consumer-receipt", clock: () => 1 }); + const redacted = redactReceipt({ + ...receipt, + events: [{ type: "task:progress", taskId: "consumer-task", at: 1, data: { token: "secret" } }] + }); + const ledger = createMemoryReceiptLedger(); + const ledgerRecord = await ledger.append(receipt); + const analysis = analyzeReceipt(receipt); + const activityStore = createMemoryActivityStore(); + let activityRuns = 0; + const activityFirst = await group(async (task) => task(runActivity( + activityStore, + { activityId: "consumer-activity", input: { requestId: "a" } }, + async () => { + activityRuns++; + return "activity-ok"; + } + ))); + const activitySecond = await group(async (task) => task(runActivity( + activityStore, + { activityId: "consumer-activity", input: { requestId: "a" } }, + async () => { + activityRuns++; + return "unexpected"; + } + ))); + let resourceReleased = 0; + const resourceValue = await group(async (task) => task(bracketLazy( + async () => ({ id: "consumer-resource" }), + async (resource) => (await resource.get()).id, + async () => { + resourceReleased++; + } + ))); let exported = 0; const tracer = { startSpan: () => ({ setAttribute() { return this; }, @@ -141,6 +190,12 @@ try { if (batch.results.join(":") !== "2:4") throw new Error("work import failed"); if (embedded.results[0][0] !== 1) throw new Error("ai import failed"); if (streamed.join(":") !== "X") throw new Error("ai stream helper failed"); + if (receipt.terminal.outcome !== "completed") throw new Error("replay receipt import failed"); + if (JSON.stringify(redacted).includes("secret")) throw new Error("replay redaction failed"); + if (ledgerRecord.receiptId !== "consumer-receipt") throw new Error("ledger import failed"); + if (analysis.status !== "pass") throw new Error("analysis import failed"); + if (activityFirst !== "activity-ok" || activitySecond !== "activity-ok" || activityRuns !== 1) throw new Error("activity import failed"); + if (resourceValue !== "consumer-resource" || resourceReleased !== 1) throw new Error("resources import failed"); if (exported !== 1) throw new Error("observability import failed"); if (typeof attachOpenTelemetry !== "function") throw new Error("otel import failed"); if (typeof offload !== "function") throw new Error("worker import failed"); @@ -153,12 +208,51 @@ try { await writeFile(join(temp, "cjs-smoke.cjs"), ` const { run, work } = require("@workit/core"); + const { createMemoryActivityStore, runActivity } = require("@workit/core/activity"); + const { verifyReceipt } = require("@workit/core/analysis"); + const { createMemoryReceiptLedger } = require("@workit/core/ledger"); + const { buildReceipt } = require("@workit/core/replay"); + const { bracketShared } = require("@workit/core/resources"); (async () => { const values = await run.all([async () => "cjs", async () => "ok"]); const output = await work([1, 2, 3]).inParallel(2).do(async (item) => item + 1); + const receipt = buildReceipt([], { + id: "consumer-cjs-scope", + status: "closed", + startedAt: 1, + pendingCount: 0, + completedCount: 0, + failedCount: 0, + cancelledCount: 0, + tasks: [], + scopes: [] + }); + const ledger = createMemoryReceiptLedger(); + const record = await ledger.append(receipt); + const analysis = verifyReceipt(receipt); + const activityStore = createMemoryActivityStore(); + const activity = await runActivity( + activityStore, + { activityId: "consumer-cjs-activity", input: { requestId: "cjs" } }, + async () => "activity-cjs-ok" + )({ signal: new AbortController().signal }); + let released = 0; + const shared = bracketShared( + async () => ({ id: "resource-cjs-ok" }), + async (resource) => resource.id, + async () => { + released++; + } + ); + const resource = await run.scope(async (scope) => await scope.spawn(shared)); if (values.join(":") !== "cjs:ok") throw new Error("CommonJS root import failed"); if (output.results.join(":") !== "2:3:4") throw new Error("CommonJS work import failed"); + if (receipt.terminal.outcome !== "completed") throw new Error("CommonJS replay import failed"); + if (record.receiptId !== receipt.receiptId) throw new Error("CommonJS ledger import failed"); + if (analysis.status !== "pass") throw new Error("CommonJS analysis import failed"); + if (activity !== "activity-cjs-ok") throw new Error("CommonJS activity import failed"); + if (resource !== "resource-cjs-ok" || released !== 1) throw new Error("CommonJS resources import failed"); })().catch((err) => { console.error(err); process.exit(1); @@ -198,7 +292,12 @@ try { type Settled, type TaskContext, } from "@workit/core"; + import { createMemoryActivityStore, runActivity, type ActivityStore } from "@workit/core/activity"; + import { verifyReceipt, type AnalysisReport } from "@workit/core/analysis"; import { embedAll, streamWithBackpressure } from "@workit/core/ai"; + import { createMemoryReceiptLedger, type ReceiptLedger } from "@workit/core/ledger"; + import { buildReceipt, type WorkItReceipt } from "@workit/core/replay"; + import { bracketShared, scopeAcquire, type ResourceRelease } from "@workit/core/resources"; const RequestKey = createContextKey<{ requestId: string }>("request"); @@ -221,11 +320,42 @@ try { return [input.length] as const; }, }); + let receipt: WorkItReceipt | undefined; + await run.scope(async (scope) => { + receipt = buildReceipt([], scope.status(), { receiptId: "strict-receipt" }); + }); + if (receipt === undefined || receipt.version !== "workit.receipt.v1") throw new Error("receipt typing failed"); + const ledger: ReceiptLedger = createMemoryReceiptLedger(); + const ledgerRecord = await ledger.append(receipt); + const analysis: AnalysisReport = verifyReceipt(receipt); + const activityStore: ActivityStore = createMemoryActivityStore(); + const activityValue: string = await group(async (task) => task(runActivity( + activityStore, + { activityId: "strict-activity", input: { requestId: "strict" } }, + async () => "strict-activity-ok", + ))); + let strictResourceReleased = 0; + const releaseStrict: ResourceRelease<{ id: string }> = async (resource) => { + if (resource.id !== "strict-resource") throw new Error("resource release typing failed"); + strictResourceReleased++; + }; + const strictResource = await run.scope(async (scope) => { + scopeAcquire(scope, { id: "strict-scope-resource" }, async () => undefined); + return await scope.spawn(bracketShared( + async () => ({ id: "strict-resource" }), + async (resource) => resource.id, + releaseStrict, + )); + }); const streamed: string[] = []; for await (const item of streamWithBackpressure(["typed"], async (input) => input.toUpperCase())) streamed.push(item); if (tuple[0] !== 1 || tuple[1] !== "typed") throw new Error("tuple inference failed"); if (value !== "strict") throw new Error("context inference failed"); + if (ledgerRecord.receiptId !== receipt.receiptId) throw new Error("ledger typing failed"); + if (analysis.status !== "pass") throw new Error("analysis typing failed"); + if (activityValue !== "strict-activity-ok") throw new Error("activity typing failed"); + if (strictResource !== "strict-resource" || strictResourceReleased !== 1) throw new Error("resource typing failed"); if (embedded.mode !== "fail") throw new Error("unexpected embedAll mode"); if (embedded.results[0]?.[0] !== 3) throw new Error("AI helper inference failed"); if (streamed[0] !== "TYPED") throw new Error("AI stream helper inference failed"); diff --git a/packages/core/src/activity/index.ts b/packages/core/src/activity/index.ts new file mode 100644 index 0000000..37003f5 --- /dev/null +++ b/packages/core/src/activity/index.ts @@ -0,0 +1,421 @@ +/** + * Explicit durable activity boundaries for WorkIt tasks. + * + * @author Admilson B. F. Cossa + * SPDX-License-Identifier: Apache-2.0 + * + * Activity boundaries persist idempotency and terminal evidence for named task + * segments. They do not persist arbitrary JavaScript stacks, scheduler state, + * closures, or external side effects. + */ + +import { createHash } from "node:crypto"; + +import { + CancellationError, + type CancelReason, + type TaskFn, +} from "../types/index.js"; + +type MaybePromise = T | Promise; + +/** Schema version emitted by the activity boundary subpath. */ +export type WorkItActivityVersion = "workit.activity.v1"; + +/** Terminal and non-terminal activity states. */ +export type ActivityStatus = "started" | "completed" | "failed" | "cancelled"; + +/** Safe error evidence persisted for failed activity boundaries. */ +export interface ActivityErrorEvidence { + readonly name: string; + readonly message: string; +} + +/** Base activity record shared by all statuses. */ +export interface ActivityRecordBase { + readonly version: WorkItActivityVersion; + readonly activityId: string; + readonly inputHash: string; + readonly status: ActivityStatus; + readonly startedAt: number; + readonly updatedAt: number; + readonly name?: string; + readonly activityVersion?: string; +} + +/** Non-terminal activity record created when a boundary is claimed. */ +export interface ActivityStartedRecord extends ActivityRecordBase { + readonly status: "started"; +} + +/** Completed activity record with a cached, caller-owned result payload. */ +export interface ActivityCompletedRecord extends ActivityRecordBase { + readonly status: "completed"; + readonly completedAt: number; + readonly result: T; +} + +/** Failed activity record with safe error evidence. */ +export interface ActivityFailedRecord extends ActivityRecordBase { + readonly status: "failed"; + readonly failedAt: number; + readonly error: ActivityErrorEvidence; +} + +/** Cancelled activity record with WorkIt's typed cancellation reason. */ +export interface ActivityCancelledRecord extends ActivityRecordBase { + readonly status: "cancelled"; + readonly cancelledAt: number; + readonly cancelReason: CancelReason; +} + +/** Persisted activity boundary record. */ +export type ActivityRecord = + | ActivityStartedRecord + | ActivityCompletedRecord + | ActivityFailedRecord + | ActivityCancelledRecord; + +/** Result returned when a store attempts to claim an activity id. */ +export interface ActivityStartResult { + readonly status: "started" | "existing"; + readonly record: ActivityRecord; +} + +/** Caller-owned activity store contract. */ +export interface ActivityStore { + start(record: ActivityStartedRecord): MaybePromise; + finish(record: ActivityRecord): MaybePromise; + get(activityId: string): MaybePromise; +} + +/** Options for the in-memory activity store. */ +export interface MemoryActivityStoreOptions { + readonly maxRecords?: number; +} + +/** Options for file-backed activity stores. */ +export interface FileActivityStoreOptions { + /** Directory that stores one JSON activity record per activity id. */ + readonly dir: string; +} + +/** Explicit activity boundary contract. */ +export interface ActivitySpec { + readonly activityId: string; + readonly input: TInput; + readonly name?: string; + readonly version?: string; +} + +/** Options for activity execution. */ +export interface ActivityRunOptions { + readonly clock?: () => number; +} + +/** Error thrown when an activity id is reused with different input. */ +export class ActivityConflictError extends Error { + readonly activityId: string; + + constructor(activityId: string) { + super(`Activity conflict for id "${activityId}"`); + this.name = "ActivityConflictError"; + this.activityId = activityId; + } +} + +/** Error thrown when a stored non-completed activity cannot be executed again. */ +export class ActivityNotRunnableError extends Error { + readonly activityId: string; + readonly status: ActivityStatus; + + constructor(activityId: string, status: ActivityStatus) { + super(`Activity "${activityId}" is already recorded as ${status}`); + this.name = "ActivityNotRunnableError"; + this.activityId = activityId; + this.status = status; + } +} + +/** Error thrown when an activity payload cannot be converted to stable JSON. */ +export class ActivitySerializationError extends Error { + readonly activityId: string; + + constructor(activityId: string, message: string) { + super(`Activity "${activityId}" payload is not serializable: ${message}`); + this.name = "ActivitySerializationError"; + this.activityId = activityId; + } +} + +/** Creates a bounded in-memory activity store for tests and short-lived processes. */ +export function createMemoryActivityStore(opts: MemoryActivityStoreOptions = {}): ActivityStore { + const maxRecords = opts.maxRecords ?? 10_000; + if (!Number.isInteger(maxRecords) || maxRecords < 1) { + throw new RangeError("maxRecords must be a positive integer"); + } + + const records = new Map(); + return { + async start(record) { + const existing = records.get(record.activityId); + if (existing !== undefined) return { status: "existing", record: cloneRecord(existing) }; + if (records.size >= maxRecords) { + const oldest = records.keys().next().value; + /* v8 ignore else -- size >= maxRecords guarantees one key exists. */ + if (oldest !== undefined) records.delete(oldest); + } + records.set(record.activityId, cloneRecord(record)); + return { status: "started", record: cloneRecord(record) }; + }, + async finish(record) { + const existing = records.get(record.activityId); + if (existing === undefined || existing.inputHash !== record.inputHash) { + throw new ActivityConflictError(record.activityId); + } + if (existing.status !== "started") { + throw new ActivityNotRunnableError(record.activityId, existing.status); + } + records.set(record.activityId, cloneRecord(record)); + return cloneRecord(record); + }, + async get(activityId) { + const record = records.get(activityId); + return record === undefined ? undefined : cloneRecord(record); + }, + }; +} + +/** Creates a file-backed activity store for explicit restart/replay boundaries. */ +export function createFileActivityStore(opts: FileActivityStoreOptions): ActivityStore { + assertBoundedString("dir", opts.dir, 4_096); + + return { + async start(record) { + const file = activityPath(opts.dir, record.activityId); + const stored = await writeNewActivityRecord(opts.dir, file, record); + return { + status: stored.created ? "started" : "existing", + record: cloneRecord(stored.record), + }; + }, + async finish(record) { + const file = activityPath(opts.dir, record.activityId); + const existing = await readActivityRecord(file); + if (existing === undefined || existing.inputHash !== record.inputHash) { + throw new ActivityConflictError(record.activityId); + } + if (existing.status !== "started") { + throw new ActivityNotRunnableError(record.activityId, existing.status); + } + await writeActivityRecord(opts.dir, file, record); + return cloneRecord(record); + }, + async get(activityId) { + return await readActivityRecord(activityPath(opts.dir, activityId)); + }, + }; +} + +/** Wraps a task body in an explicit persisted activity boundary. */ +export function runActivity( + store: ActivityStore, + spec: ActivitySpec, + task: TaskFn, + opts: ActivityRunOptions = {}, +): TaskFn { + const normalized = normalizeSpec(spec); + const inputHash = hashInput(normalized.activityId, normalized.input); + const clock = opts.clock ?? Date.now; + + return async (ctx) => { + const startedAt = clock(); + const started = makeStartedRecord(normalized, inputHash, startedAt); + const claim = await store.start(started); + if (claim.record.inputHash !== inputHash) throw new ActivityConflictError(normalized.activityId); + if (claim.status === "existing") return replayExisting(claim.record); + + try { + const result = normalizeActivityPayload(normalized.activityId, await task(ctx)); + const completedAt = clock(); + await store.finish({ + ...started, + status: "completed", + result, + completedAt, + updatedAt: completedAt, + }); + return result; + } catch (error) { + if (error instanceof CancellationError) { + const cancelledAt = clock(); + await store.finish({ + ...started, + status: "cancelled", + cancelReason: error.reason, + cancelledAt, + updatedAt: cancelledAt, + }); + } else { + const failedAt = clock(); + await store.finish({ + ...started, + status: "failed", + error: normalizeError(error), + failedAt, + updatedAt: failedAt, + }); + } + throw error; + } + }; +} + +function replayExisting(record: ActivityRecord): TOutput { + if (record.status === "completed") return record.result as TOutput; + throw new ActivityNotRunnableError(record.activityId, record.status); +} + +function makeStartedRecord( + spec: Required>, + inputHash: string, + startedAt: number, +): ActivityStartedRecord { + return { + version: "workit.activity.v1", + activityId: spec.activityId, + name: spec.name, + activityVersion: spec.version, + inputHash, + status: "started", + startedAt, + updatedAt: startedAt, + }; +} + +function normalizeSpec(spec: ActivitySpec): Required> { + assertBoundedString("activityId", spec.activityId, 512); + return { + activityId: spec.activityId, + input: spec.input, + name: spec.name ?? spec.activityId, + version: spec.version ?? "v1", + }; +} + +function assertBoundedString(field: string, value: string, maxLength: number): void { + if (typeof value !== "string" || value.length < 1 || value.length > maxLength) { + throw new RangeError(`${field} must be a non-empty string up to ${maxLength} characters`); + } +} + +function hashInput(activityId: string, value: unknown): string { + const text = stableStringify(activityId, value); + return `sha256:${createHash("sha256").update(text, "utf8").digest("hex")}`; +} + +function normalizeError(error: unknown): ActivityErrorEvidence { + if (error instanceof Error) return { name: error.name, message: error.message }; + return { name: typeof error, message: String(error) }; +} + +function normalizeActivityPayload(activityId: string, value: T): T { + return JSON.parse(stableStringify(activityId, value)) as T; +} + +function cloneRecord(record: ActivityRecord): ActivityRecord { + return JSON.parse(JSON.stringify(record)) as ActivityRecord; +} + +function activityPath(dir: string, activityId: string): string { + assertBoundedString("activityId", activityId, 512); + return `${dir}/${Buffer.from(activityId, "utf8").toString("base64url")}.json`; +} + +async function writeNewActivityRecord( + dir: string, + file: string, + record: ActivityStartedRecord, +): Promise<{ created: boolean; record: ActivityRecord }> { + const { mkdir, readFile, writeFile } = await loadFileStoreDeps(); + await mkdir(dir, { recursive: true }); + try { + await writeFile(file, `${JSON.stringify(record, null, 2)}\n`, { encoding: "utf8", flag: "wx" }); + return { created: true, record: cloneRecord(record) }; + } catch (error) { + if (isErrno(error, "EEXIST")) { + return { + created: false, + record: JSON.parse(await readFile(file, "utf8")) as ActivityRecord, + }; + } + throw error; + } +} + +async function writeActivityRecord(dir: string, file: string, record: ActivityRecord): Promise { + const { mkdir, rename, writeFile } = await loadFileStoreDeps(); + await mkdir(dir, { recursive: true }); + const temp = `${file}.${process.pid}.${Date.now()}.tmp`; + await writeFile(temp, `${JSON.stringify(record, null, 2)}\n`, "utf8"); + await rename(temp, file); +} + +async function readActivityRecord(file: string): Promise { + const { readFile } = await loadFileStoreDeps(); + try { + return JSON.parse(await readFile(file, "utf8")) as ActivityRecord; + } catch (error) { + if (isErrno(error, "ENOENT")) return undefined; + throw error; + } +} + +async function loadFileStoreDeps(): Promise<{ + mkdir: typeof import("node:fs/promises").mkdir; + readFile: typeof import("node:fs/promises").readFile; + rename: typeof import("node:fs/promises").rename; + writeFile: typeof import("node:fs/promises").writeFile; +}> { + const fs = await import("node:fs/promises"); + return { + mkdir: fs.mkdir, + readFile: fs.readFile, + rename: fs.rename, + writeFile: fs.writeFile, + }; +} + +function isErrno(error: unknown, code: string): boolean { + return typeof error === "object" + && error !== null + && (error as { code?: unknown }).code === code; +} + +function stableStringify(activityId: string, value: unknown): string { + const text = JSON.stringify(sortValue(activityId, value, new WeakSet())); + /* v8 ignore next -- sortValue rejects or converts every value that would stringify to undefined. */ + if (text === undefined) throw new ActivitySerializationError(activityId, "input did not produce JSON"); + return text; +} + +function sortValue(activityId: string, value: unknown, seen: WeakSet): unknown { + if (value === undefined) throw new ActivitySerializationError(activityId, "undefined is not valid JSON"); + if (typeof value === "bigint") throw new ActivitySerializationError(activityId, "bigint is not valid JSON"); + if (typeof value === "function") throw new ActivitySerializationError(activityId, "functions are not valid JSON"); + if (typeof value === "symbol") throw new ActivitySerializationError(activityId, "symbols are not valid JSON"); + if (typeof value === "number" && !Number.isFinite(value)) { + throw new ActivitySerializationError(activityId, "non-finite numbers are not valid JSON"); + } + if (value instanceof Date) return value.toJSON(); + if (Array.isArray(value)) return value.map((item) => sortValue(activityId, item, seen)); + if (value === null || typeof value !== "object") return value; + if (seen.has(value)) throw new ActivitySerializationError(activityId, "cyclic objects are not valid JSON"); + seen.add(value); + const output: Record = {}; + for (const key of Object.keys(value).sort()) { + output[key] = sortValue(activityId, (value as Record)[key], seen); + } + seen.delete(value); + return output; +} diff --git a/packages/core/src/analysis/index.ts b/packages/core/src/analysis/index.ts new file mode 100644 index 0000000..2cce026 --- /dev/null +++ b/packages/core/src/analysis/index.ts @@ -0,0 +1,575 @@ +/** + * Analysis helpers for WorkIt receipts and declared event protocols. + * + * @author Admilson B. F. Cossa + * SPDX-License-Identifier: Apache-2.0 + * + * This subpath verifies already-declared WorkIt evidence and caller-provided + * source protocol specifications. It does not parse arbitrary JavaScript source + * or claim whole-program proofs. + */ + +import type { ScopeSnapshot, TaskEvent, TaskSnapshot } from "../types/index.js"; +import type { WorkItReceipt, WorkItReceiptEvent } from "../replay/index.js"; + +/** Analysis result status. */ +export type AnalysisStatus = "pass" | "warn" | "fail"; + +/** Stable finding codes emitted by the analysis subpath. */ +export type AnalysisFindingCode = + | "cleanup_evidence_missing" + | "cleanup_timeout" + | "leaked_tasks" + | "receipt_not_terminal" + | "receipt_truncated" + | "source_agent_tool_without_authority" + | "source_durable_side_effect_without_evidence" + | "source_parallel_without_bound" + | "source_protocol_limit_exceeded" + | "source_resource_without_cleanup" + | "source_time_policy_unplanned" + | "task_event_after_terminal" + | "terminal_cause_missing" + | "terminal_event_missing" + | "terminal_failed"; + +/** Severity assigned to one analysis finding. */ +export type AnalysisSeverity = "info" | "warn" | "error"; + +/** One typed verifier finding. */ +export interface AnalysisFinding { + readonly code: AnalysisFindingCode; + readonly severity: AnalysisSeverity; + readonly message: string; + readonly taskId?: string; + readonly receiptId?: string; + readonly moduleId?: string; + readonly functionId?: string; + readonly operation?: string; + readonly count?: number; + readonly limit?: number; +} + +/** Analysis report returned by verifier helpers. */ +export interface AnalysisReport { + readonly status: AnalysisStatus; + readonly findings: readonly AnalysisFinding[]; +} + +/** Stable receipt verification checks. */ +export type ReceiptVerificationCheckCode = + | "cleanup_evidence_recorded" + | "no_orphaned_owned_tasks" + | "terminal_cause_recorded" + | "terminal_event_recorded" + | "terminal_outcome_recorded"; + +/** One receipt verification check and its local status. */ +export interface ReceiptVerificationCheck { + readonly code: ReceiptVerificationCheckCode; + readonly status: AnalysisStatus; + readonly message: string; + readonly count?: number; +} + +/** Options for receipt verification. */ +export interface ReceiptVerificationOptions { + /** Require an observed root scope closing/closed timestamp, not only a closed snapshot. */ + readonly requireTerminalEvent?: boolean; + /** Require observable cleanup failure/timeout evidence in the receipt. */ + readonly requireCleanupEvidence?: boolean; +} + +/** Receipt verification report with explicit checks. */ +export interface ReceiptVerificationReport extends AnalysisReport { + readonly receiptId: string; + readonly checks: readonly ReceiptVerificationCheck[]; +} + +/** Bounded source protocol operation understood by `verifySourceProtocol`. */ +export type SourceProtocolOperation = + | "activity.run" + | "agent.tool" + | "authority.check" + | "channel.backpressure" + | "ctx.defer" + | "durable.side_effect" + | "promise.all" + | "receipt.append" + | "resource.acquire" + | "resource.bracketLazy" + | "resource.bracketShared" + | "run.bracket" + | "run.hedge" + | "run.pool" + | "run.retry" + | "run.timeout" + | "scope.acquire" + | "scope.spawn" + | "time_policy.plan" + | "work.parallel"; + +/** Function class used by source protocol analysis. */ +export type SourceProtocolFunctionKind = + | "activity" + | "agent_tool" + | "handler" + | "resource" + | "task"; + +/** One operation observed or declared inside a function body. */ +export interface SourceProtocolUse { + readonly operation: SourceProtocolOperation; + readonly target?: string; + readonly capability?: string; + readonly line?: number; +} + +/** One function-level source protocol contract. */ +export interface SourceProtocolFunction { + readonly functionId: string; + readonly kind?: SourceProtocolFunctionKind; + readonly uses: readonly SourceProtocolUse[]; +} + +/** One module-level source protocol contract. */ +export interface SourceProtocolModule { + readonly moduleId: string; + readonly functions: readonly SourceProtocolFunction[]; +} + +/** Bounded source protocol specification supplied by the caller. */ +export interface SourceProtocolSpec { + readonly version?: "workit.source-protocol.v1"; + readonly modules: readonly SourceProtocolModule[]; +} + +/** Options that keep source protocol analysis bounded. */ +export interface SourceProtocolAnalysisOptions { + readonly maxModules?: number; + readonly maxFunctions?: number; + readonly maxUsesPerFunction?: number; +} + +/** Source protocol report with checked-count evidence. */ +export interface SourceProtocolAnalysisReport extends AnalysisReport { + readonly checkedModules: number; + readonly checkedFunctions: number; + readonly checkedUses: number; +} + +const DEFAULT_SOURCE_PROTOCOL_LIMITS = { + maxModules: 100, + maxFunctions: 1_000, + maxUsesPerFunction: 100, +} as const; + +/** Verifies lifecycle invariants visible in one replay receipt. */ +export function analyzeReceipt(receipt: WorkItReceipt): AnalysisReport { + const verified = verifyReceipt(receipt); + return { + status: verified.status, + findings: verified.findings, + }; +} + +/** Verifies explicit lifecycle evidence already present in a WorkIt receipt. */ +export function verifyReceipt( + receipt: WorkItReceipt, + opts: ReceiptVerificationOptions = {}, +): ReceiptVerificationReport { + const findings: AnalysisFinding[] = []; + const checks: ReceiptVerificationCheck[] = []; + const pendingTasks = collectPendingTasks(receipt.snapshot); + const leakedTaskCount = Math.max(pendingTasks.length, receipt.summary.leakedTasks); + const cleanupEvidenceCount = receipt.summary.cleanupFailures + receipt.summary.cleanupTimeouts; + const terminalEventRecorded = receipt.terminal.at !== undefined + || receipt.events.some((event) => + event.scopeId === receipt.rootScopeId + && (event.type === "scope:closing" || event.type === "scope:closed") + ); + + if (receipt.terminal.outcome === "running") { + findings.push({ + code: "receipt_not_terminal", + severity: "error", + message: "receipt does not contain a terminal scope outcome", + receiptId: receipt.receiptId, + }); + } + checks.push({ + code: "terminal_outcome_recorded", + status: receipt.terminal.outcome === "running" ? "fail" : "pass", + message: receipt.terminal.outcome === "running" + ? "receipt does not contain a terminal scope outcome" + : "receipt contains a terminal scope outcome", + }); + + if (leakedTaskCount > 0) { + const firstPending = pendingTasks[0]; + findings.push({ + code: "leaked_tasks", + severity: "error", + message: "receipt snapshot contains pending owned tasks", + receiptId: receipt.receiptId, + ...(firstPending !== undefined ? { taskId: firstPending.id } : {}), + }); + } + checks.push({ + code: "no_orphaned_owned_tasks", + status: leakedTaskCount > 0 ? "fail" : "pass", + message: leakedTaskCount > 0 + ? "receipt snapshot contains pending owned tasks" + : "receipt snapshot contains no pending owned tasks", + count: leakedTaskCount, + }); + + if (receipt.summary.cleanupTimeouts > 0) { + findings.push({ + code: "cleanup_timeout", + severity: "warn", + message: "receipt contains bounded cleanup timeout evidence", + receiptId: receipt.receiptId, + }); + } + if (opts.requireCleanupEvidence === true && cleanupEvidenceCount === 0) { + findings.push({ + code: "cleanup_evidence_missing", + severity: "error", + message: "receipt does not contain observable cleanup failure or timeout evidence", + receiptId: receipt.receiptId, + }); + } + checks.push({ + code: "cleanup_evidence_recorded", + status: opts.requireCleanupEvidence === true && cleanupEvidenceCount === 0 ? "fail" : "pass", + message: cleanupEvidenceCount > 0 + ? "receipt contains observable cleanup failure or timeout evidence" + : "receipt has no observable cleanup failure or timeout evidence", + count: cleanupEvidenceCount, + }); + + if (receipt.summary.droppedEvents > 0) { + findings.push({ + code: "receipt_truncated", + severity: "warn", + message: "receipt event window was truncated before analysis", + receiptId: receipt.receiptId, + }); + } + + const terminalCauseRequired = requiresTerminalCause(receipt); + const terminalCausePresent = hasTerminalCause(receipt); + const terminalCauseRecorded = !terminalCauseRequired || terminalCausePresent; + + if (!terminalCauseRecorded) { + findings.push({ + code: "terminal_cause_missing", + severity: "error", + message: "failed or cancelled receipt does not include terminal cause evidence", + receiptId: receipt.receiptId, + }); + } + checks.push({ + code: "terminal_cause_recorded", + status: terminalCauseRecorded ? "pass" : "fail", + message: terminalCauseRecorded + ? "receipt terminal cause evidence is present or not required for this outcome" + : "failed or cancelled receipt does not include terminal cause evidence", + }); + + if (opts.requireTerminalEvent === true && !terminalEventRecorded) { + findings.push({ + code: "terminal_event_missing", + severity: "error", + message: "receipt does not contain root scope terminal event evidence", + receiptId: receipt.receiptId, + }); + } + checks.push({ + code: "terminal_event_recorded", + status: opts.requireTerminalEvent === true && !terminalEventRecorded ? "fail" : "pass", + message: terminalEventRecorded + ? "receipt contains root scope terminal event evidence" + : "receipt terminal event evidence was not required for this verification", + }); + + if (receipt.terminal.outcome === "failed") { + findings.push({ + code: "terminal_failed", + severity: "error", + message: "receipt terminal outcome is failed", + receiptId: receipt.receiptId, + }); + } + + return { + ...report(findings), + receiptId: receipt.receiptId, + checks, + }; +} + +/** Verifies a caller-provided source protocol contract for ownership gaps. */ +export function verifySourceProtocol( + spec: SourceProtocolSpec, + opts: SourceProtocolAnalysisOptions = {}, +): SourceProtocolAnalysisReport { + const limits = normalizeSourceProtocolLimits(opts); + const findings: AnalysisFinding[] = []; + let checkedModules = 0; + let checkedFunctions = 0; + let checkedUses = 0; + + if (spec.modules.length > limits.maxModules) { + findings.push(limitFinding( + "source protocol module count exceeds configured analysis bound", + spec.modules.length, + limits.maxModules, + )); + } + + for (const module of spec.modules.slice(0, limits.maxModules)) { + checkedModules++; + if (checkedFunctions + module.functions.length > limits.maxFunctions) { + findings.push(limitFinding( + "source protocol function count exceeds configured analysis bound", + checkedFunctions + module.functions.length, + limits.maxFunctions, + )); + } + + const remainingFunctions = Math.max(0, limits.maxFunctions - checkedFunctions); + for (const fn of module.functions.slice(0, remainingFunctions)) { + checkedFunctions++; + const uses = fn.uses.slice(0, limits.maxUsesPerFunction); + checkedUses += uses.length; + + if (fn.uses.length > limits.maxUsesPerFunction) { + findings.push({ + code: "source_protocol_limit_exceeded", + severity: "error", + message: "source protocol function use count exceeds configured analysis bound", + moduleId: module.moduleId, + functionId: fn.functionId, + count: fn.uses.length, + limit: limits.maxUsesPerFunction, + }); + } + + findings.push(...analyzeSourceFunction(module, fn, uses)); + } + } + + return { + ...report(findings), + checkedModules, + checkedFunctions, + checkedUses, + }; +} + +/** Verifies basic lifecycle ordering over WorkIt task events. */ +export function verifyScopeProtocol(events: readonly (TaskEvent | WorkItReceiptEvent)[]): AnalysisReport { + const findings: AnalysisFinding[] = []; + const terminalTasks = new Set(); + + for (const event of events) { + if (!isTaskEvent(event)) continue; + if (terminalTasks.has(event.taskId)) { + findings.push({ + code: "task_event_after_terminal", + severity: "error", + message: "task emitted an event after its terminal lifecycle event", + taskId: event.taskId, + }); + continue; + } + + if (isTaskTerminal(event.type)) terminalTasks.add(event.taskId); + } + + return report(findings); +} + +function report(findings: readonly AnalysisFinding[]): AnalysisReport { + const hasError = findings.some((finding) => finding.severity === "error"); + const hasWarning = findings.some((finding) => finding.severity === "warn"); + return { + status: hasError ? "fail" : hasWarning ? "warn" : "pass", + findings, + }; +} + +function isTaskEvent(event: TaskEvent | WorkItReceiptEvent): event is (TaskEvent | WorkItReceiptEvent) & { taskId: string } { + return typeof (event as { taskId?: unknown }).taskId === "string"; +} + +function isTaskTerminal(type: TaskEvent["type"] | WorkItReceiptEvent["type"]): boolean { + return type === "task:succeeded" || type === "task:failed" || type === "task:cancelled"; +} + +function normalizeSourceProtocolLimits(opts: SourceProtocolAnalysisOptions): Required { + const limits = { + maxModules: opts.maxModules ?? DEFAULT_SOURCE_PROTOCOL_LIMITS.maxModules, + maxFunctions: opts.maxFunctions ?? DEFAULT_SOURCE_PROTOCOL_LIMITS.maxFunctions, + maxUsesPerFunction: opts.maxUsesPerFunction ?? DEFAULT_SOURCE_PROTOCOL_LIMITS.maxUsesPerFunction, + }; + + assertPositiveInteger("maxModules", limits.maxModules); + assertPositiveInteger("maxFunctions", limits.maxFunctions); + assertPositiveInteger("maxUsesPerFunction", limits.maxUsesPerFunction); + return limits; +} + +function assertPositiveInteger(field: string, value: number): void { + if (!Number.isInteger(value) || value < 1) { + throw new RangeError(`${field} must be a positive integer`); + } +} + +function analyzeSourceFunction( + module: SourceProtocolModule, + fn: SourceProtocolFunction, + uses: readonly SourceProtocolUse[], +): AnalysisFinding[] { + const findings: AnalysisFinding[] = []; + const operations = new Set(uses.map((use) => use.operation)); + + if (operations.has("resource.acquire") && !hasAny(operations, [ + "ctx.defer", + "resource.bracketLazy", + "resource.bracketShared", + "run.bracket", + "scope.acquire", + ])) { + findings.push(sourceFinding( + "source_resource_without_cleanup", + "resource acquisition is not paired with a WorkIt cleanup owner", + module.moduleId, + fn.functionId, + "resource.acquire", + )); + } + + if (operations.has("durable.side_effect") && !hasAny(operations, ["activity.run", "receipt.append"])) { + findings.push(sourceFinding( + "source_durable_side_effect_without_evidence", + "durable side effect is not guarded by an activity boundary or receipt append", + module.moduleId, + fn.functionId, + "durable.side_effect", + )); + } + + if (operations.has("promise.all") && !hasAny(operations, [ + "channel.backpressure", + "run.pool", + "scope.spawn", + "work.parallel", + ])) { + findings.push(sourceFinding( + "source_parallel_without_bound", + "parallel fan-out is not paired with a WorkIt concurrency or backpressure owner", + module.moduleId, + fn.functionId, + "promise.all", + )); + } + + if (hasAny(operations, ["run.hedge", "run.retry", "run.timeout"]) && !operations.has("time_policy.plan")) { + findings.push(sourceFinding( + "source_time_policy_unplanned", + "runtime time policy is not paired with declared time-policy planning", + module.moduleId, + fn.functionId, + "time_policy.plan", + )); + } + + if ( + fn.kind === "agent_tool" + && operations.has("durable.side_effect") + && !hasDeclaredAuthority(uses) + ) { + findings.push(sourceFinding( + "source_agent_tool_without_authority", + "agent tool side effect is not paired with declared capability authority", + module.moduleId, + fn.functionId, + "authority.check", + )); + } + + return findings; +} + +function hasAny(operations: ReadonlySet, expected: readonly SourceProtocolOperation[]): boolean { + return expected.some((operation) => operations.has(operation)); +} + +function hasDeclaredAuthority(uses: readonly SourceProtocolUse[]): boolean { + return uses.some((use) => + (use.operation === "authority.check" || use.operation === "agent.tool") + && typeof use.capability === "string" + && use.capability.length > 0 + ); +} + +function sourceFinding( + code: AnalysisFindingCode, + message: string, + moduleId: string, + functionId: string, + operation: SourceProtocolOperation, +): AnalysisFinding { + return { + code, + severity: "error", + message, + moduleId, + functionId, + operation, + }; +} + +function limitFinding(message: string, actual: number, limit: number): AnalysisFinding { + return { + code: "source_protocol_limit_exceeded", + severity: "error", + message, + count: actual, + limit, + }; +} + +function collectPendingTasks(snapshot: ScopeSnapshot): TaskSnapshot[] { + const childTasks = snapshot.scopes.flatMap(collectPendingTasks); + return [ + ...snapshot.tasks.filter((task) => task.status === "pending" || task.status === "running"), + ...childTasks, + ]; +} + +function hasTerminalCause(receipt: WorkItReceipt): boolean { + switch (receipt.terminal.outcome) { + case "failed": + return receipt.terminal.error !== undefined; + case "cancelled": + return receipt.terminal.cancelReason !== undefined; + case "completed": + case "running": + return true; + } +} + +function requiresTerminalCause(receipt: WorkItReceipt): boolean { + switch (receipt.terminal.outcome) { + case "failed": + case "cancelled": + return true; + case "completed": + case "running": + return false; + } +} diff --git a/packages/core/src/ledger/index.ts b/packages/core/src/ledger/index.ts new file mode 100644 index 0000000..eaa9f2e --- /dev/null +++ b/packages/core/src/ledger/index.ts @@ -0,0 +1,188 @@ +/** + * Receipt ledger adapters for WorkIt lifecycle receipts. + * + * @author Admilson B. F. Cossa + * SPDX-License-Identifier: Apache-2.0 + * + * Ledgers provide idempotent append and retrieval for replay receipts. The file + * adapter uses atomic write-then-rename so receipt storage has a durable path + * without adding runtime dependencies. + */ + +import { createHash } from "node:crypto"; +import { mkdir, readdir, readFile, rename, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import type { WorkItReceipt } from "../replay/index.js"; + +/** Stored receipt metadata returned by ledger appends and listings. */ +export interface ReceiptLedgerRecord { + readonly receiptId: string; + readonly checksum: string; + readonly createdAt: number; + readonly storedAt: number; +} + +/** Append-only receipt ledger contract. */ +export interface ReceiptLedger { + append(receipt: WorkItReceipt): Promise; + get(receiptId: string): Promise; + list(): Promise; +} + +/** Options for in-memory receipt ledgers. */ +export interface MemoryReceiptLedgerOptions { + readonly clock?: () => number; + readonly maxReceipts?: number; +} + +/** Options for file-backed receipt ledgers. */ +export interface FileReceiptLedgerOptions { + readonly dir: string; + readonly clock?: () => number; +} + +interface StoredReceipt { + readonly record: ReceiptLedgerRecord; + readonly receipt: WorkItReceipt; +} + +const DEFAULT_MAX_MEMORY_RECEIPTS = 10_000; + +/** Error thrown when the same receipt id is appended with different content. */ +export class ReceiptLedgerConflictError extends Error { + readonly receiptId: string; + + constructor(receiptId: string) { + super(`Receipt ledger conflict for receipt id "${receiptId}"`); + this.name = "ReceiptLedgerConflictError"; + this.receiptId = receiptId; + } +} + +/** Creates a bounded in-memory receipt ledger for tests and short-lived processes. */ +export function createMemoryReceiptLedger(opts: MemoryReceiptLedgerOptions = {}): ReceiptLedger { + const maxReceipts = opts.maxReceipts ?? DEFAULT_MAX_MEMORY_RECEIPTS; + if (!Number.isInteger(maxReceipts) || maxReceipts < 1) { + throw new RangeError("maxReceipts must be a positive integer"); + } + + const entries = new Map(); + const clock = opts.clock ?? Date.now; + + return { + async append(receipt) { + const checksum = checksumReceipt(receipt); + const existing = entries.get(receipt.receiptId); + if (existing !== undefined) { + if (existing.record.checksum !== checksum) throw new ReceiptLedgerConflictError(receipt.receiptId); + return existing.record; + } + + if (entries.size >= maxReceipts) { + const oldest = entries.keys().next().value; + /* v8 ignore else -- size >= maxReceipts guarantees a map key is available. */ + if (oldest !== undefined) entries.delete(oldest); + } + + const record = makeRecord(receipt, checksum, clock); + entries.set(receipt.receiptId, { record, receipt }); + return record; + }, + async get(receiptId) { + return entries.get(receiptId)?.receipt; + }, + async list() { + return [...entries.values()] + .map((entry) => entry.record) + .sort(compareRecords); + }, + }; +} + +/** Creates a file-backed receipt ledger rooted at one directory. */ +export function createFileReceiptLedger(opts: FileReceiptLedgerOptions): ReceiptLedger { + const clock = opts.clock ?? Date.now; + + return { + async append(receipt) { + await mkdir(opts.dir, { recursive: true }); + const file = receiptPath(opts.dir, receipt.receiptId); + const checksum = checksumReceipt(receipt); + const existing = await readStoredReceipt(file); + if (existing !== undefined) { + if (existing.record.checksum !== checksum) throw new ReceiptLedgerConflictError(receipt.receiptId); + return existing.record; + } + + const record = makeRecord(receipt, checksum, clock); + const stored: StoredReceipt = { record, receipt }; + const temp = `${file}.${process.pid}.${clock()}.tmp`; + await writeFile(temp, `${JSON.stringify(stored, null, 2)}\n`, "utf8"); + await rename(temp, file); + return record; + }, + async get(receiptId) { + const stored = await readStoredReceipt(receiptPath(opts.dir, receiptId)); + return stored?.receipt; + }, + async list() { + await mkdir(opts.dir, { recursive: true }); + const names = await readdir(opts.dir); + const records: ReceiptLedgerRecord[] = []; + for (const name of names) { + if (!name.endsWith(".json")) continue; + const stored = await readStoredReceipt(join(opts.dir, name)); + /* v8 ignore else -- undefined is possible only if another process deletes the file between readdir and read. */ + if (stored !== undefined) records.push(stored.record); + } + return records.sort(compareRecords); + }, + }; +} + +function makeRecord(receipt: WorkItReceipt, checksum: string, clock: () => number): ReceiptLedgerRecord { + return { + receiptId: receipt.receiptId, + checksum, + createdAt: receipt.createdAt, + storedAt: clock(), + }; +} + +function checksumReceipt(receipt: WorkItReceipt): string { + return createHash("sha256").update(stableStringify(receipt)).digest("hex"); +} + +async function readStoredReceipt(file: string): Promise { + try { + return JSON.parse(await readFile(file, "utf8")) as StoredReceipt; + } catch (error) { + if (typeof error === "object" && error !== null && (error as { code?: unknown }).code === "ENOENT") { + return undefined; + } + throw error; + } +} + +function receiptPath(dir: string, receiptId: string): string { + return join(dir, `${Buffer.from(receiptId, "utf8").toString("base64url")}.json`); +} + +function compareRecords(a: ReceiptLedgerRecord, b: ReceiptLedgerRecord): number { + return a.createdAt - b.createdAt || a.receiptId.localeCompare(b.receiptId); +} + +function stableStringify(value: unknown): string { + return JSON.stringify(sortValue(value)); +} + +function sortValue(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sortValue); + if (value === null || typeof value !== "object") return value; + + const output: Record = {}; + for (const key of Object.keys(value).sort()) { + output[key] = sortValue((value as Record)[key]); + } + return output; +} diff --git a/packages/core/src/otel/index.ts b/packages/core/src/otel/index.ts index 37e6977..9c2351e 100644 --- a/packages/core/src/otel/index.ts +++ b/packages/core/src/otel/index.ts @@ -73,7 +73,7 @@ export function attachOpenTelemetry( opts: OpenTelemetryOptions = {} ): OpenTelemetryAttachment { const instrumentationName = opts.instrumentationName ?? "workit"; - const instrumentationVersion = opts.instrumentationVersion ?? "0.1.1"; + const instrumentationVersion = opts.instrumentationVersion ?? readPackageVersion(); const { tracer, meter } = resolveOpenTelemetryHandles(opts, instrumentationName, instrumentationVersion); const taskCounter = meter.createCounter("workit.task.total", { unit: "1" }); const taskDuration = meter.createHistogram("workit.task.duration", { unit: "ms" }); @@ -128,7 +128,7 @@ export function attachOpenTelemetry( function resolveOpenTelemetryHandles( opts: OpenTelemetryOptions, instrumentationName: string, - instrumentationVersion: string + instrumentationVersion: string | undefined ): { tracer: Tracer; meter: Meter } { if (opts.tracer !== undefined && opts.meter !== undefined) { return { tracer: opts.tracer, meter: opts.meter }; @@ -141,6 +141,15 @@ function resolveOpenTelemetryHandles( }; } +function readPackageVersion(): string | undefined { + try { + const packageJson = requirePeer("../../package.json") as { version?: unknown }; + return typeof packageJson.version === "string" ? packageJson.version : undefined; + } catch { + return undefined; + } +} + function loadOpenTelemetryApi(): OpenTelemetryApi { try { return requirePeer("@opentelemetry/api") as OpenTelemetryApi; diff --git a/packages/core/src/replay/index.ts b/packages/core/src/replay/index.ts new file mode 100644 index 0000000..3b6b085 --- /dev/null +++ b/packages/core/src/replay/index.ts @@ -0,0 +1,491 @@ +/** + * Replayable lifecycle receipts for WorkIt scopes. + * + * @author Admilson B. F. Cossa + * SPDX-License-Identifier: Apache-2.0 + * + * Receipts are audit evidence over existing scope snapshots and typed runtime + * events. They intentionally do not claim deterministic scheduler replay. + */ + +import type { + CancelReason, + Scope, + ScopeId, + ScopeSnapshot, + TaskEvent, + TaskId, + TaskKind, + Unsubscribe, +} from "../types/index.js"; + +/** Receipt schema version emitted by this subpath. */ +export type WorkItReceiptVersion = "workit.receipt.v1"; + +/** Terminal lifecycle outcome inferred from scope closing evidence. */ +export type WorkItReceiptOutcome = "completed" | "failed" | "cancelled" | "running"; + +/** Serializable, bounded error evidence for receipts. */ +export interface WorkItReceiptError { + readonly name: string; + readonly message: string; +} + +/** Normalized receipt event derived from WorkIt's typed engine event stream. */ +export interface WorkItReceiptEvent { + readonly type: TaskEvent["type"]; + readonly at: number; + readonly taskId?: TaskId; + readonly scopeId?: ScopeId; + readonly parentId?: ScopeId | null; + readonly name?: string; + readonly kind?: TaskKind; + readonly attempt?: number; + readonly nextDelayMs?: number; + readonly timeoutMs?: number; + readonly durationMs?: number; + readonly reason?: CancelReason | "completed" | "errored" | "cancelled"; + readonly error?: WorkItReceiptError; + readonly message?: string; + readonly pct?: number; + readonly data?: unknown; + readonly droppedTelemetryEvents?: number; +} + +/** Aggregate lifecycle counters included in a WorkIt receipt. */ +export interface WorkItReceiptSummary { + readonly totalTasks: number; + readonly pendingTasks: number; + readonly completedTasks: number; + readonly failedTasks: number; + readonly cancelledTasks: number; + readonly totalScopes: number; + readonly pendingScopes: number; + readonly cleanupFailures: number; + readonly cleanupTimeouts: number; + readonly retryEvents: number; + readonly droppedEvents: number; + readonly droppedTelemetryEvents: number; + readonly leakedTasks: number; +} + +/** Terminal lifecycle fact recorded by a receipt. */ +export interface WorkItReceiptTerminal { + readonly outcome: WorkItReceiptOutcome; + readonly at?: number; + readonly durationMs?: number; + readonly cancelReason?: CancelReason; + readonly error?: WorkItReceiptError; +} + +/** Complete audit receipt for one observed scope tree. */ +export interface WorkItReceipt { + readonly version: WorkItReceiptVersion; + readonly receiptId: string; + readonly createdAt: number; + readonly rootScopeId: ScopeId; + readonly rootScopeName?: string; + readonly terminal: WorkItReceiptTerminal; + readonly summary: WorkItReceiptSummary; + readonly events: readonly WorkItReceiptEvent[]; + readonly snapshot: ScopeSnapshot; + readonly limitations: readonly string[]; +} + +/** Field-level redaction policy for receipts before storage or publication. */ +export interface ReceiptRedactionPolicy { + readonly removeFields?: readonly string[]; + readonly redactFields?: readonly string[]; + readonly maxDepth?: number; +} + +/** Options shared by receipt builders and recorders. */ +export interface ReceiptBuildOptions { + readonly receiptId?: string; + readonly clock?: () => number; + readonly redaction?: ReceiptRedactionPolicy; + readonly limitations?: readonly string[]; +} + +/** Recorder options for live scope observation. */ +export interface ReceiptRecorderOptions extends ReceiptBuildOptions { + readonly maxEvents?: number; +} + +/** Live recorder attached to a WorkIt scope's event stream. */ +export interface ReceiptRecorder { + readonly events: readonly TaskEvent[]; + readonly droppedEvents: number; + build(snapshot?: ScopeSnapshot, opts?: ReceiptBuildOptions): WorkItReceipt; + unsubscribe(): void; +} + +interface InternalReceiptBuildOptions extends ReceiptBuildOptions { + readonly droppedEvents?: number; +} + +const RECEIPT_VERSION: WorkItReceiptVersion = "workit.receipt.v1"; +const DEFAULT_MAX_EVENTS = 10_000; +const DEFAULT_LIMITATIONS = [ + "receipt_replay_is_audit_evidence_not_deterministic_scheduler_replay", +] as const; +const DEFAULT_REDACT_FIELDS = [ + "authorization", + "cookie", + "password", + "passwd", + "secret", + "token", + "apikey", + "apiKey", + "accessToken", + "refreshToken", +] as const; +const DEFAULT_MAX_REDACTION_DEPTH = 8; + +/** Attaches a bounded receipt recorder to a live scope event stream. */ +export function createReceiptRecorder(scope: Scope, opts: ReceiptRecorderOptions = {}): ReceiptRecorder { + const maxEvents = opts.maxEvents ?? DEFAULT_MAX_EVENTS; + if (!Number.isInteger(maxEvents) || maxEvents < 1) throw new RangeError("maxEvents must be a positive integer"); + + const events: TaskEvent[] = []; + let droppedEvents = 0; + const unsubscribe: Unsubscribe = scope.onEvent((event) => { + if (events.length < maxEvents) events.push(event); + else droppedEvents++; + }); + + return { + get events() { + return [...events]; + }, + get droppedEvents() { + return droppedEvents; + }, + build(snapshot = scope.status(), buildOpts = {}) { + return buildReceipt(events, snapshot, { + ...opts, + ...buildOpts, + droppedEvents, + } as InternalReceiptBuildOptions); + }, + unsubscribe, + }; +} + +/** Builds a replayable audit receipt from existing typed events and a snapshot. */ +export function buildReceipt( + events: readonly TaskEvent[], + snapshot: ScopeSnapshot, + opts: ReceiptBuildOptions = {}, +): WorkItReceipt { + const internalOpts = opts as InternalReceiptBuildOptions; + const createdAt = opts.clock?.() ?? Date.now(); + const receiptId = opts.receiptId ?? `receipt:${snapshot.id}:${createdAt}`; + const normalizedEvents = events.map(normalizeEvent); + const summary = summarizeReceipt(snapshot, normalizedEvents, internalOpts.droppedEvents ?? 0); + const terminal = inferTerminal(snapshot, normalizedEvents); + const limitations = [ + ...DEFAULT_LIMITATIONS, + ...(summary.droppedEvents > 0 ? ["receipt_event_window_truncated"] : []), + ...(opts.limitations ?? []), + ]; + const receipt: WorkItReceipt = { + version: RECEIPT_VERSION, + receiptId, + createdAt, + rootScopeId: snapshot.id, + terminal, + summary, + events: normalizedEvents, + snapshot, + limitations, + ...(snapshot.name !== undefined ? { rootScopeName: snapshot.name } : {}), + }; + + return redactReceipt(receipt, opts.redaction); +} + +/** Applies field-level redaction to an existing receipt. */ +export function redactReceipt(receipt: WorkItReceipt, policy: ReceiptRedactionPolicy = {}): WorkItReceipt { + const removeFields = new Set((policy.removeFields ?? []).map((field) => field.toLowerCase())); + const redactFields = new Set([ + ...DEFAULT_REDACT_FIELDS.map((field) => field.toLowerCase()), + ...(policy.redactFields ?? []).map((field) => field.toLowerCase()), + ]); + const maxDepth = policy.maxDepth ?? DEFAULT_MAX_REDACTION_DEPTH; + + return redactValue(receipt, { removeFields, redactFields, maxDepth }, 0) as WorkItReceipt; +} + +function normalizeEvent(event: TaskEvent): WorkItReceiptEvent { + switch (event.type) { + case "task:started": + return { + type: event.type, + taskId: event.taskId, + scopeId: event.scopeId, + name: event.name, + kind: event.kind, + at: event.at, + }; + case "task:retrying": + return { + type: event.type, + taskId: event.taskId, + attempt: event.attempt, + error: normalizeError(event.error), + nextDelayMs: event.nextDelayMs, + at: event.at, + }; + case "task:progress": + return { + type: event.type, + taskId: event.taskId, + at: event.at, + ...(event.pct !== undefined ? { pct: event.pct } : {}), + ...(event.message !== undefined ? { message: event.message } : {}), + ...(event.data !== undefined ? { data: event.data } : {}), + }; + case "task:cleanup_failed": + return { + type: event.type, + taskId: event.taskId, + error: normalizeError(event.error), + at: event.at, + }; + case "task:cleanup_timeout": + return { + type: event.type, + taskId: event.taskId, + timeoutMs: event.timeoutMs, + at: event.at, + }; + case "task:succeeded": + return { + type: event.type, + taskId: event.taskId, + durationMs: event.durationMs, + at: event.at, + }; + case "task:failed": + return { + type: event.type, + taskId: event.taskId, + error: normalizeError(event.error), + durationMs: event.durationMs, + at: event.at, + }; + case "task:cancelled": + return { + type: event.type, + taskId: event.taskId, + reason: normalizeCancelReason(event.reason), + durationMs: event.durationMs, + at: event.at, + }; + case "scope:opened": + return { + type: event.type, + scopeId: event.scopeId, + parentId: event.parentId, + at: event.at, + }; + case "scope:cleanup_failed": + return { + type: event.type, + scopeId: event.scopeId, + error: normalizeError(event.error), + at: event.at, + }; + case "scope:cleanup_timeout": + return { + type: event.type, + scopeId: event.scopeId, + timeoutMs: event.timeoutMs, + at: event.at, + }; + case "scope:closing": + return { + type: event.type, + scopeId: event.scopeId, + reason: event.reason, + at: event.at, + }; + case "scope:closed": + return { + type: event.type, + scopeId: event.scopeId, + durationMs: event.durationMs, + at: event.at, + ...(event.droppedTelemetryEvents !== undefined + ? { droppedTelemetryEvents: event.droppedTelemetryEvents } + : {}), + }; + } +} + +function summarizeReceipt( + snapshot: ScopeSnapshot, + events: readonly WorkItReceiptEvent[], + droppedEvents: number, +): WorkItReceiptSummary { + const snapshotCounts = countSnapshot(snapshot); + const cleanupFailures = events.filter((event) => + event.type === "task:cleanup_failed" || event.type === "scope:cleanup_failed" + ).length; + const cleanupTimeouts = events.filter((event) => + event.type === "task:cleanup_timeout" || event.type === "scope:cleanup_timeout" + ).length; + const retryEvents = events.filter((event) => event.type === "task:retrying").length; + const droppedTelemetryEvents = events.reduce((total, event) => + total + (event.droppedTelemetryEvents ?? 0), 0); + + return { + ...snapshotCounts, + cleanupFailures, + cleanupTimeouts, + retryEvents, + droppedEvents, + droppedTelemetryEvents, + leakedTasks: snapshotCounts.pendingTasks, + }; +} + +function countSnapshot(snapshot: ScopeSnapshot): Omit< + WorkItReceiptSummary, + "cleanupFailures" | "cleanupTimeouts" | "retryEvents" | "droppedEvents" | "droppedTelemetryEvents" | "leakedTasks" +> { + const childCounts = snapshot.scopes.reduce((total, child) => { + const childResult = countSnapshot(child); + return { + totalTasks: total.totalTasks + childResult.totalTasks, + pendingTasks: total.pendingTasks + childResult.pendingTasks, + completedTasks: total.completedTasks + childResult.completedTasks, + failedTasks: total.failedTasks + childResult.failedTasks, + cancelledTasks: total.cancelledTasks + childResult.cancelledTasks, + totalScopes: total.totalScopes + childResult.totalScopes, + pendingScopes: total.pendingScopes + childResult.pendingScopes, + }; + }, { + totalTasks: snapshot.tasks.length, + pendingTasks: snapshot.pendingCount, + completedTasks: snapshot.completedCount, + failedTasks: snapshot.failedCount, + cancelledTasks: snapshot.cancelledCount, + totalScopes: 1, + pendingScopes: snapshot.status === "closed" ? 0 : 1, + }); + + return childCounts; +} + +function inferTerminal(snapshot: ScopeSnapshot, events: readonly WorkItReceiptEvent[]): WorkItReceiptTerminal { + const rootEvents = events.filter((event) => event.scopeId === snapshot.id); + const closing = findLast(rootEvents, (event) => event.type === "scope:closing"); + const closed = findLast(rootEvents, (event) => event.type === "scope:closed"); + const lastCancelled = findLast(events, (event) => event.type === "task:cancelled"); + const lastFailed = findLast(events, (event) => + event.type === "task:failed" || event.type === "scope:cleanup_failed" || event.type === "task:cleanup_failed" + ); + const base = { + ...(closed?.at !== undefined ? { at: closed.at } : closing?.at !== undefined ? { at: closing.at } : {}), + ...(closed?.durationMs !== undefined ? { durationMs: closed.durationMs } : {}), + }; + + if (closing?.reason === "cancelled") { + return { + outcome: "cancelled", + ...base, + ...(isCancelReason(lastCancelled?.reason) ? { cancelReason: lastCancelled.reason } : {}), + }; + } + + if (closing?.reason === "errored" || snapshot.failedCount > 0) { + return { + outcome: "failed", + ...base, + ...(lastFailed?.error !== undefined ? { error: lastFailed.error } : {}), + }; + } + + if (closing?.reason === "completed" || snapshot.status === "closed") { + return { + outcome: "completed", + ...base, + }; + } + + return { outcome: "running" }; +} + +function findLast(items: readonly T[], predicate: (item: T) => boolean): T | undefined { + for (let index = items.length - 1; index >= 0; index--) { + const item = items[index]; + if (item !== undefined && predicate(item)) return item; + } + return undefined; +} + +function normalizeCancelReason(reason: CancelReason): CancelReason { + switch (reason.kind) { + case "parent_failed": + return { kind: "parent_failed", error: normalizeError(reason.error) }; + case "sibling_failed": + return { kind: "sibling_failed", siblingId: reason.siblingId, error: normalizeError(reason.error) }; + case "manual": + return { + kind: "manual", + tag: reason.tag, + ...(reason.data !== undefined ? { data: reason.data } : {}), + }; + default: + return reason; + } +} + +function isCancelReason(value: unknown): value is CancelReason { + return typeof value === "object" && value !== null && typeof (value as { kind?: unknown }).kind === "string"; +} + +function normalizeError(error: unknown): WorkItReceiptError { + if (error instanceof Error) { + return { name: error.name, message: error.message }; + } + if (typeof error === "string") { + return { name: "Error", message: error }; + } + return { name: "UnknownError", message: safeString(error) }; +} + +function safeString(value: unknown): string { + try { + return JSON.stringify(value) ?? String(value); + } catch { + return String(value); + } +} + +interface RedactionRuntimePolicy { + readonly removeFields: ReadonlySet; + readonly redactFields: ReadonlySet; + readonly maxDepth: number; +} + +function redactValue(value: unknown, policy: RedactionRuntimePolicy, depth: number): unknown { + if (depth > policy.maxDepth) return "[max-depth]"; + if (value === null || typeof value !== "object") return value; + if (value instanceof Error) return normalizeError(value); + if (Array.isArray(value)) return value.map((item) => redactValue(item, policy, depth + 1)); + + const input = value as Record; + const output: Record = {}; + for (const [key, item] of Object.entries(input)) { + const normalizedKey = key.toLowerCase(); + if (policy.removeFields.has(normalizedKey)) continue; + output[key] = policy.redactFields.has(normalizedKey) + ? "[redacted]" + : redactValue(item, policy, depth + 1); + } + return output; +} diff --git a/packages/core/src/resources/index.ts b/packages/core/src/resources/index.ts new file mode 100644 index 0000000..6810668 --- /dev/null +++ b/packages/core/src/resources/index.ts @@ -0,0 +1,138 @@ +/** + * Resource ownership helpers built on WorkIt scope cleanup. + * + * @author Admilson B. F. Cossa + * SPDX-License-Identifier: Apache-2.0 + * + * These helpers compose existing `ctx.defer()` and `scope.defer()` behavior. + * They do not introduce a second cleanup owner. + */ + +import type { CleanupContext, CleanupOpts, Scope, TaskContext, TaskFn } from "../types/index.js"; + +/** Acquires a resource inside a WorkIt task context. */ +export type ResourceAcquire = (ctx: TaskContext) => R | Promise; + +/** Releases a resource through WorkIt's bounded cleanup context. */ +export type ResourceRelease = (resource: R, ctx: CleanupContext) => void | Promise; + +/** Lazy resource handle supplied to `bracketLazy` users. */ +export interface LazyResource { + get(): Promise; + acquired(): boolean; +} + +interface SharedState { + hasResource: boolean; + resource?: R; + acquirePromise?: Promise; + releaseRegistered: boolean; +} + +/** Registers an already acquired scope-level resource for LIFO cleanup. */ +export function scopeAcquire( + scope: Scope, + resource: R, + release: ResourceRelease, + opts: CleanupOpts = {}, +): R { + scope.defer((ctx) => release(resource, ctx), opts); + return resource; +} + +/** Acquires a resource only if the task body calls `resource.get()`. */ +export function bracketLazy( + acquire: ResourceAcquire, + use: (resource: LazyResource, ctx: TaskContext) => T | Promise, + release: ResourceRelease, + opts: CleanupOpts = {}, +): TaskFn { + return async (ctx) => { + let hasResource = false; + let resource: R | undefined; + let acquirePromise: Promise | undefined; + + const lazy: LazyResource = { + async get() { + if (hasResource) return resource as R; + if (acquirePromise !== undefined) return await acquirePromise; + + acquirePromise = Promise.resolve() + .then(() => acquire(ctx)) + .then((value) => { + resource = value; + hasResource = true; + ctx.defer((cleanupCtx) => release(value, cleanupCtx), opts); + return value; + }, (error: unknown) => { + acquirePromise = undefined; + throw error; + }); + + return await acquirePromise; + }, + acquired() { + return hasResource; + }, + }; + + return await use(lazy, ctx); + }; +} + +/** Shares one scope-owned resource across every task that uses the returned wrapper. */ +export function bracketShared( + acquire: ResourceAcquire, + use: (resource: R, ctx: TaskContext) => T | Promise, + release: ResourceRelease, + opts: CleanupOpts = {}, +): TaskFn { + const states = new WeakMap>(); + + return async (ctx) => { + const state = getSharedState(states, ctx.scope); + const resource = await getSharedResource(state, ctx, acquire, release, opts); + return await use(resource, ctx); + }; +} + +function getSharedState(states: WeakMap>, scope: Scope): SharedState { + const existing = states.get(scope); + if (existing !== undefined) return existing; + + const created: SharedState = { + hasResource: false, + releaseRegistered: false, + }; + states.set(scope, created); + return created; +} + +async function getSharedResource( + state: SharedState, + ctx: TaskContext, + acquire: ResourceAcquire, + release: ResourceRelease, + opts: CleanupOpts, +): Promise { + if (state.hasResource) return state.resource as R; + if (state.acquirePromise !== undefined) return await state.acquirePromise; + + state.acquirePromise = Promise.resolve() + .then(() => acquire(ctx)) + .then((resource) => { + state.resource = resource; + state.hasResource = true; + /* v8 ignore else -- registration is false until the first successful acquisition in this state. */ + if (!state.releaseRegistered) { + state.releaseRegistered = true; + ctx.scope.defer((cleanupCtx) => release(resource, cleanupCtx), opts); + } + return resource; + }, (error: unknown) => { + delete state.acquirePromise; + throw error; + }); + + return await state.acquirePromise; +} diff --git a/packages/core/tests/evidence/correctness/activity-boundary.mjs b/packages/core/tests/evidence/correctness/activity-boundary.mjs new file mode 100644 index 0000000..bccfc0d --- /dev/null +++ b/packages/core/tests/evidence/correctness/activity-boundary.mjs @@ -0,0 +1,73 @@ +/** + * Correctness evidence: explicit activity boundaries preserve terminal records. + * + * @author Admilson B. F. Cossa + * SPDX-License-Identifier: Apache-2.0 + */ + +import { group } from "../../../dist/index.js"; +import { + ActivityConflictError, + createMemoryActivityStore, + runActivity, +} from "../../../dist/activity/index.js"; +import { createSuite } from "../harness.mjs"; + +const suite = createSuite("correctness"); + +await suite.proof( + "CORR-012", + "explicit activity boundary preserves terminal evidence", + "completed activity returns the stored result on repeat and changed input conflicts before rerun", + async () => { + const store = createMemoryActivityStore(); + const spec = { activityId: "activity-boundary-proof", input: { requestId: "r1" } }; + let runs = 0; + let conflict = false; + let conflictBodyRuns = 0; + + const first = await group(async (task) => task(runActivity(store, spec, async () => { + runs++; + return "stored-result"; + }))); + const second = await group(async (task) => task(runActivity(store, spec, async () => { + runs++; + return "unexpected"; + }))); + + try { + await group(async (task) => task(runActivity( + store, + { activityId: spec.activityId, input: { requestId: "r2" } }, + async () => { + conflictBodyRuns++; + return "unexpected"; + }, + ))); + } catch (error) { + conflict = error instanceof ActivityConflictError; + } + + const record = await store.get(spec.activityId); + + return { + ok: first === "stored-result" + && second === "stored-result" + && runs === 1 + && conflict + && conflictBodyRuns === 0 + && record?.status === "completed" + && record.result === "stored-result", + first, + second, + runs, + conflict, + conflictBodyRuns, + recordStatus: record?.status, + }; + }, +); + +const summary = suite.summary(); +process.stdout.write(JSON.stringify(summary, null, 2) + "\n"); +process.exit(summary.failed > 0 ? 1 : 0); diff --git a/packages/core/tests/evidence/correctness/analysis-verifiers.mjs b/packages/core/tests/evidence/correctness/analysis-verifiers.mjs new file mode 100644 index 0000000..ed7f2b5 --- /dev/null +++ b/packages/core/tests/evidence/correctness/analysis-verifiers.mjs @@ -0,0 +1,106 @@ +/** + * Correctness evidence: analysis verifiers over lifecycle receipts. + * + * @author Admilson B. F. Cossa + * SPDX-License-Identifier: Apache-2.0 + */ + +import { analyzeReceipt, verifyReceipt } from "../../../dist/analysis/index.js"; +import { buildReceipt } from "../../../dist/replay/index.js"; +import { createSuite } from "../harness.mjs"; + +const suite = createSuite("correctness"); + +await suite.proof( + "CORR-007", + "receipt analysis detects leaked owned work", + "a non-terminal receipt with pending tasks fails analysis with leaked_tasks", + async () => { + const receipt = buildReceipt([], { + id: "scope-analysis", + status: "running", + startedAt: 1, + pendingCount: 1, + completedCount: 0, + failedCount: 0, + cancelledCount: 0, + tasks: [ + { + id: "task-analysis", + name: "pending", + kind: "custom", + status: "running", + attempt: 1, + startedAt: 1, + }, + ], + scopes: [], + }, { + clock: () => 2, + receiptId: "receipt-analysis", + }); + const report = analyzeReceipt(receipt); + + return { + ok: report.status === "fail" + && report.findings.some((finding) => finding.code === "leaked_tasks"), + status: report.status, + findings: report.findings.map((finding) => finding.code), + }; + }, +); + +await suite.proof( + "CORR-013", + "receipt verifier proves declared lifecycle evidence", + "a closed receipt with terminal and cleanup evidence verifies no orphaned owned tasks while preserving cleanup timeout warning evidence", + async () => { + const receipt = buildReceipt([ + { type: "task:cleanup_timeout", taskId: "task-cleanup", timeoutMs: 5, at: 2 }, + { type: "scope:closing", scopeId: "scope-verify", reason: "completed", at: 3 }, + { type: "scope:closed", scopeId: "scope-verify", durationMs: 4, at: 4 }, + ], { + id: "scope-verify", + status: "closed", + startedAt: 1, + pendingCount: 0, + completedCount: 1, + failedCount: 0, + cancelledCount: 0, + tasks: [ + { + id: "task-cleanup", + name: "cleanup", + kind: "custom", + status: "succeeded", + attempt: 1, + startedAt: 1, + durationMs: 1, + }, + ], + scopes: [], + }, { + receiptId: "receipt-verify", + }); + const report = verifyReceipt(receipt, { + requireCleanupEvidence: true, + requireTerminalEvent: true, + }); + const checks = new Map(report.checks.map((check) => [check.code, check.status])); + + return { + ok: report.findings.every((finding) => finding.severity !== "error") + && checks.get("no_orphaned_owned_tasks") === "pass" + && checks.get("cleanup_evidence_recorded") === "pass" + && checks.get("terminal_event_recorded") === "pass" + && checks.get("terminal_cause_recorded") === "pass", + status: report.status, + checks: Object.fromEntries(checks), + findings: report.findings.map((finding) => finding.code), + }; + }, +); + +const summary = suite.summary(); +process.stdout.write(JSON.stringify(summary, null, 2) + "\n"); +process.exit(summary.failed > 0 ? 1 : 0); diff --git a/packages/core/tests/evidence/correctness/resource-ownership-model.mjs b/packages/core/tests/evidence/correctness/resource-ownership-model.mjs new file mode 100644 index 0000000..2808a3d --- /dev/null +++ b/packages/core/tests/evidence/correctness/resource-ownership-model.mjs @@ -0,0 +1,146 @@ +/** + * Correctness evidence: bounded resource ownership model. + * + * @author Admilson B. F. Cossa + * SPDX-License-Identifier: Apache-2.0 + * + * This finite model checks cleanup-balance invariants for WorkIt's linear, + * lazy, and shared resource helper patterns. It is not a proof over arbitrary + * JavaScript programs or external resources. + */ + +import { readFile } from "node:fs/promises"; + +import { createSuite } from "../harness.mjs"; + +const suite = createSuite("correctness"); +const root = new URL("../../../", import.meta.url); + +await suite.proof( + "CORR-015", + "bounded resource model preserves cleanup balance invariant", + "for linear, lazy, and shared resources, every terminal modeled scope releases exactly the resources it acquired", + async () => { + const spec = JSON.parse(await readFile(new URL("evidence/resource-ownership-model.json", root), "utf8")); + const reports = [ + exploreLinear(spec), + exploreLazy(spec), + exploreShared(spec), + ]; + + return { + ok: spec.author === "Admilson B. F. Cossa" + && spec.spdxLicense === "Apache-2.0" + && spec.model.patterns.includes("linear") + && spec.model.patterns.includes("lazy") + && spec.model.patterns.includes("shared") + && spec.model.invariants.includes("terminal_scope_has_no_open_modeled_resource") + && reports.every((report) => report.ok), + reports, + limitations: spec.limitations, + }; + }, +); + +const summary = suite.summary(); +process.stdout.write(JSON.stringify(summary, null, 2) + "\n"); +process.exit(summary.failed > 0 ? 1 : 0); + +function exploreLinear(spec) { + const states = spec.model.terminalCauses.map((cause) => ({ + pattern: "linear", + cause, + acquired: 1, + released: 1, + used: true, + })); + return summarize("linear", states, spec.bounds.maxExploredStatesPerPattern); +} + +function exploreLazy(spec) { + const states = []; + for (const cause of spec.model.terminalCauses) { + for (const used of [false, true]) { + states.push({ + pattern: "lazy", + cause, + acquired: used ? 1 : 0, + released: used ? 1 : 0, + used, + }); + } + } + return summarize("lazy", states, spec.bounds.maxExploredStatesPerPattern); +} + +function exploreShared(spec) { + const states = []; + for (let userCount = 1; userCount <= spec.bounds.maxSharedUsers; userCount++) { + for (const cause of spec.model.terminalCauses) { + const masks = 2 ** userCount; + for (let mask = 0; mask < masks; mask++) { + const usedBy = Array.from({ length: userCount }, (_item, index) => (mask & (1 << index)) !== 0); + const used = usedBy.some(Boolean); + states.push({ + pattern: "shared", + cause, + acquired: used ? 1 : 0, + released: used ? 1 : 0, + userCount, + usedBy, + }); + } + } + } + return summarize("shared", states, spec.bounds.maxExploredStatesPerPattern); +} + +function summarize(pattern, states, maxStates) { + const violations = []; + if (states.length > maxStates) { + violations.push({ kind: "state_bound_exceeded", states: states.length, maxStates }); + } + + for (const state of states) { + const invariant = checkInvariant(state); + if (invariant !== null) violations.push(invariant); + } + + return { + pattern, + ok: violations.length === 0 && states.length > 0, + states: states.length, + violations, + }; +} + +function checkInvariant(state) { + if (state.released > state.acquired) { + return { kind: "release_exceeded_acquire", state }; + } + if (state.acquired !== state.released) { + return { kind: "terminal_resource_left_open", state }; + } + + switch (state.pattern) { + case "linear": + return state.acquired === 1 && state.released === 1 + ? null + : { kind: "linear_not_released_once", state }; + case "lazy": + return state.used === (state.acquired === 1 && state.released === 1) + ? null + : { kind: "lazy_acquire_release_mismatch", state }; + case "shared": + return checkSharedInvariant(state); + default: + return { kind: "unknown_pattern", state }; + } +} + +function checkSharedInvariant(state) { + const anyUser = state.usedBy.some(Boolean); + if (!anyUser && state.acquired === 0 && state.released === 0) return null; + if (anyUser && state.acquired === 1 && state.released === 1) return null; + return { kind: "shared_scope_balance_mismatch", state }; +} diff --git a/packages/core/tests/evidence/correctness/source-protocol-analysis.mjs b/packages/core/tests/evidence/correctness/source-protocol-analysis.mjs new file mode 100644 index 0000000..ba038e0 --- /dev/null +++ b/packages/core/tests/evidence/correctness/source-protocol-analysis.mjs @@ -0,0 +1,117 @@ +/** + * Correctness evidence: bounded source protocol analysis. + * + * @author Admilson B. F. Cossa + * SPDX-License-Identifier: Apache-2.0 + * + * This proof checks caller-provided source protocol specifications. It does + * not parse arbitrary JavaScript or claim whole-program static analysis. + */ + +import { verifySourceProtocol } from "../../../dist/analysis/index.js"; +import { createSuite } from "../harness.mjs"; + +const suite = createSuite("correctness"); + +await suite.proof( + "CORR-017", + "source protocol analysis detects missing ownership edges", + "bounded source protocol specs pass when WorkIt ownership edges are present and fail with stable findings when resource, side-effect, parallelism, time-policy, or authority edges are missing", + async () => { + const owned = verifySourceProtocol({ + version: "workit.source-protocol.v1", + modules: [ + { + moduleId: "owned.pipeline", + functions: [ + { + functionId: "uploadBatch", + kind: "handler", + uses: [ + { operation: "resource.acquire", target: "temp-dir" }, + { operation: "ctx.defer", target: "temp-dir" }, + { operation: "durable.side_effect", target: "object-store" }, + { operation: "activity.run", target: "object-store" }, + { operation: "promise.all", target: "files" }, + { operation: "run.pool", target: "files" }, + { operation: "run.retry", target: "object-store" }, + { operation: "time_policy.plan", target: "object-store" }, + ], + }, + { + functionId: "writeTool", + kind: "agent_tool", + uses: [ + { operation: "authority.check", capability: "repo:write" }, + { operation: "durable.side_effect", target: "repo" }, + { operation: "receipt.append", target: "repo-write" }, + ], + }, + ], + }, + ], + }); + + const unsafe = verifySourceProtocol({ + modules: [ + { + moduleId: "unsafe.pipeline", + functions: [ + { + functionId: "uploadBatch", + kind: "handler", + uses: [ + { operation: "resource.acquire", target: "temp-dir" }, + { operation: "durable.side_effect", target: "object-store" }, + { operation: "promise.all", target: "files" }, + { operation: "run.retry", target: "object-store" }, + ], + }, + { + functionId: "writeTool", + kind: "agent_tool", + uses: [ + { operation: "agent.tool" }, + { operation: "durable.side_effect", target: "repo" }, + { operation: "activity.run", target: "repo" }, + ], + }, + ], + }, + ], + }); + const unsafeCodes = unsafe.findings.map((finding) => finding.code).sort(); + const expectedCodes = [ + "source_agent_tool_without_authority", + "source_durable_side_effect_without_evidence", + "source_parallel_without_bound", + "source_resource_without_cleanup", + "source_time_policy_unplanned", + ]; + + return { + ok: owned.status === "pass" + && owned.checkedFunctions === 2 + && unsafe.status === "fail" + && expectedCodes.every((code) => unsafeCodes.includes(code)), + owned: { + status: owned.status, + checkedModules: owned.checkedModules, + checkedFunctions: owned.checkedFunctions, + checkedUses: owned.checkedUses, + }, + unsafe: { + status: unsafe.status, + findings: unsafeCodes, + }, + limitations: [ + "This verifies caller-provided source protocol specifications, not arbitrary JavaScript source.", + "It checks bounded ownership edges and does not prove whole-program correctness.", + ], + }; + }, +); + +const summary = suite.summary(); +process.stdout.write(JSON.stringify(summary, null, 2) + "\n"); +process.exit(summary.failed > 0 ? 1 : 0); diff --git a/packages/core/tests/evidence/lifecycle/activity-restart.mjs b/packages/core/tests/evidence/lifecycle/activity-restart.mjs new file mode 100644 index 0000000..18d73fe --- /dev/null +++ b/packages/core/tests/evidence/lifecycle/activity-restart.mjs @@ -0,0 +1,70 @@ +/** + * Lifecycle evidence: explicit activity boundaries survive process-shaped restarts. + * + * @author Admilson B. F. Cossa + * SPDX-License-Identifier: Apache-2.0 + */ + +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { group } from "../../../dist/index.js"; +import { createFileActivityStore, runActivity } from "../../../dist/activity/index.js"; +import { createSuite } from "../harness.mjs"; + +const suite = createSuite("lifecycle"); + +await suite.proof( + "LIFE-008", + "file activity store replays completed activity after restart", + "a fresh file activity store returns the persisted terminal result without rerunning the activity body", + async () => { + const dir = await mkdtemp(join(tmpdir(), "workit-activity-evidence-")); + + try { + const spec = { activityId: "activity-restart-proof", input: { requestId: "r1", page: 1 } }; + let bodyRuns = 0; + + const first = await group(async (task) => task(runActivity( + createFileActivityStore({ dir }), + spec, + async () => { + bodyRuns++; + return { uploaded: 4, skipped: 0 }; + }, + { clock: () => 10 }, + ))); + + const second = await group(async (task) => task(runActivity( + createFileActivityStore({ dir }), + spec, + async () => { + bodyRuns++; + return { uploaded: 0, skipped: 4 }; + }, + { clock: () => 20 }, + ))); + + const record = await createFileActivityStore({ dir }).get(spec.activityId); + + return { + ok: first.uploaded === 4 + && second.uploaded === 4 + && bodyRuns === 1 + && record?.status === "completed" + && record.result.uploaded === 4, + first, + second, + bodyRuns, + recordStatus: record?.status, + }; + } finally { + await rm(dir, { recursive: true, force: true }); + } + }, +); + +const summary = suite.summary(); +process.stdout.write(JSON.stringify(summary, null, 2) + "\n"); +process.exit(summary.failed > 0 ? 1 : 0); diff --git a/packages/core/tests/evidence/lifecycle/replay-receipts.mjs b/packages/core/tests/evidence/lifecycle/replay-receipts.mjs new file mode 100644 index 0000000..addb02d --- /dev/null +++ b/packages/core/tests/evidence/lifecycle/replay-receipts.mjs @@ -0,0 +1,80 @@ +/** + * Lifecycle evidence: replayable receipts over existing WorkIt events. + * + * @author Admilson B. F. Cossa + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CancellationError, run } from "../../../dist/index.js"; +import { createReceiptRecorder } from "../../../dist/replay/index.js"; +import { createSuite, sleep } from "../harness.mjs"; + +const suite = createSuite("lifecycle"); + +await suite.proof( + "LIFE-004", + "replay receipts record completed scope closure", + "a closed successful scope has completed terminal outcome and no leaked tasks", + async () => { + let scopeRef; + let recorder; + + await run.scope(async (scope) => { + scopeRef = scope; + recorder = createReceiptRecorder(scope, { receiptId: "evidence-completed" }); + await scope.spawn(async () => "ok", { name: "receipt.evidence" }); + }, { name: "receipt-evidence-root" }); + + const receipt = recorder.build(scopeRef.status()); + + return { + ok: receipt.terminal.outcome === "completed" + && receipt.summary.leakedTasks === 0 + && receipt.events.some((event) => event.type === "scope:closed"), + outcome: receipt.terminal.outcome, + leakedTasks: receipt.summary.leakedTasks, + eventCount: receipt.events.length, + }; + }, +); + +await suite.proof( + "LIFE-005", + "replay receipts preserve typed cancellation reason", + "a manually cancelled scope records the cancellation reason observed by owned work", + async () => { + let scopeRef; + let recorder; + let error; + + try { + await run.scope(async (scope) => { + scopeRef = scope; + recorder = createReceiptRecorder(scope, { receiptId: "evidence-cancelled" }); + const handle = scope.spawn(async (ctx) => { + await sleep(1_000, ctx.signal); + }, { name: "receipt.cancelled" }); + scope.cancel({ kind: "manual", tag: "receipt_evidence" }); + await handle; + }, { name: "receipt-cancel-root" }); + } catch (caught) { + error = caught; + } + + const receipt = recorder.build(scopeRef.status()); + + return { + ok: error instanceof CancellationError + && receipt.terminal.outcome === "cancelled" + && receipt.terminal.cancelReason?.kind === "manual" + && receipt.terminal.cancelReason.tag === "receipt_evidence", + errorClass: error?.constructor?.name, + outcome: receipt.terminal.outcome, + cancelReason: receipt.terminal.cancelReason, + }; + }, +); + +const summary = suite.summary(); +process.stdout.write(JSON.stringify(summary, null, 2) + "\n"); +process.exit(summary.failed > 0 ? 1 : 0); diff --git a/packages/core/tests/evidence/lifecycle/resource-audit.mjs b/packages/core/tests/evidence/lifecycle/resource-audit.mjs new file mode 100644 index 0000000..935666e --- /dev/null +++ b/packages/core/tests/evidence/lifecycle/resource-audit.mjs @@ -0,0 +1,105 @@ +/** + * Lifecycle evidence: resource cleanup audit visibility. + * + * @author Admilson B. F. Cossa + * SPDX-License-Identifier: Apache-2.0 + */ + +import { run } from "../../../dist/index.js"; +import { bracketLazy, scopeAcquire } from "../../../dist/resources/index.js"; +import { createSuite } from "../harness.mjs"; + +const suite = createSuite("lifecycle"); + +await suite.proof( + "LIFE-011", + "resource ownership can be audited across success, failure, and timeout cleanup paths", + "resource audit instrumentation records acquired/released/pending resources while WorkIt emits cleanup timeout and failure events", + async () => { + const audit = createResourceAudit(); + const events = []; + const task = bracketLazy( + async () => audit.acquire("task:lazy"), + async (resource) => { + await resource.get(); + return "used"; + }, + async (resource) => { + audit.releaseStarted(resource.id); + audit.releaseCompleted(resource.id); + }, + ); + + const value = await run.scope(async (scope) => { + scope.onEvent((event) => events.push(event)); + const scopeResource = audit.acquire("scope:success"); + scopeAcquire(scope, scopeResource, async (resource) => { + audit.releaseStarted(resource.id); + audit.releaseCompleted(resource.id); + }); + + const failingResource = audit.acquire("scope:failure"); + scopeAcquire(scope, failingResource, async (resource) => { + audit.releaseStarted(resource.id); + throw new Error("release failed"); + }); + + const hangingResource = audit.acquire("scope:timeout"); + scopeAcquire(scope, hangingResource, async (resource) => { + audit.releaseStarted(resource.id); + await new Promise(() => undefined); + }, { timeout: 5 }); + + return await scope.spawn(task, { name: "resource.audit" }); + }); + + return { + ok: value === "used" + && audit.acquired.length === 4 + && audit.released.includes("task:lazy") + && audit.released.includes("scope:success") + && audit.pending.includes("scope:failure") + && audit.pending.includes("scope:timeout") + && events.some((event) => event.type === "scope:cleanup_failed") + && events.some((event) => event.type === "scope:cleanup_timeout" && event.timeoutMs === 5), + acquired: audit.acquired, + released: audit.released, + pending: audit.pending, + cleanupEvents: events.filter((event) => event.type.includes("cleanup")).map((event) => event.type), + claimBoundary: "successful resource audit entries are explicit instrumentation; cleanup failure and timeout visibility comes from WorkIt scope events", + }; + }, +); + +const summary = suite.summary(); +process.stdout.write(JSON.stringify(summary, null, 2) + "\n"); +process.exit(summary.failed > 0 ? 1 : 0); + +function createResourceAudit() { + const acquired = []; + const released = []; + const pending = new Set(); + + return { + get acquired() { + return [...acquired]; + }, + get released() { + return [...released]; + }, + get pending() { + return [...pending]; + }, + acquire(id) { + acquired.push(id); + return { id }; + }, + releaseStarted(id) { + pending.add(id); + }, + releaseCompleted(id) { + pending.delete(id); + released.push(id); + }, + }; +} diff --git a/packages/core/tests/evidence/lifecycle/resource-ownership.mjs b/packages/core/tests/evidence/lifecycle/resource-ownership.mjs new file mode 100644 index 0000000..8964140 --- /dev/null +++ b/packages/core/tests/evidence/lifecycle/resource-ownership.mjs @@ -0,0 +1,52 @@ +/** + * Lifecycle evidence: resource ownership helper contracts. + * + * @author Admilson B. F. Cossa + * SPDX-License-Identifier: Apache-2.0 + */ + +import { run } from "../../../dist/index.js"; +import { bracketShared } from "../../../dist/resources/index.js"; +import { createSuite, sleep } from "../harness.mjs"; + +const suite = createSuite("lifecycle"); + +await suite.proof( + "LIFE-006", + "shared scope resource acquires and releases once", + "parallel tasks using one shared helper observe one acquisition and one scope-owned release", + async () => { + let acquired = 0; + let released = 0; + const shared = bracketShared( + async () => { + acquired++; + return { id: acquired }; + }, + async (resource, ctx) => { + await sleep(5, ctx.signal); + return resource.id; + }, + async () => { + released++; + }, + ); + + const values = await run.scope(async (scope) => { + const first = scope.spawn(shared, { name: "resource.first" }); + const second = scope.spawn(shared, { name: "resource.second" }); + return await Promise.all([first, second]); + }); + + return { + ok: values.join(":") === "1:1" && acquired === 1 && released === 1, + values, + acquired, + released, + }; + }, +); + +const summary = suite.summary(); +process.stdout.write(JSON.stringify(summary, null, 2) + "\n"); +process.exit(summary.failed > 0 ? 1 : 0); diff --git a/packages/core/tests/evidence/release/receipt-ledger.mjs b/packages/core/tests/evidence/release/receipt-ledger.mjs new file mode 100644 index 0000000..35df3b9 --- /dev/null +++ b/packages/core/tests/evidence/release/receipt-ledger.mjs @@ -0,0 +1,57 @@ +/** + * Release evidence: receipt ledger idempotency and file persistence. + * + * @author Admilson B. F. Cossa + * SPDX-License-Identifier: Apache-2.0 + */ + +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createFileReceiptLedger } from "../../../dist/ledger/index.js"; +import { buildReceipt } from "../../../dist/replay/index.js"; +import { createSuite } from "../harness.mjs"; + +const suite = createSuite("release"); + +await suite.proof( + "REL-004", + "file receipt ledger persists append-only receipt evidence", + "a receipt appended by one ledger instance is readable from a new ledger instance", + async () => { + const dir = await mkdtemp(join(tmpdir(), "workit-evidence-ledger-")); + try { + const receipt = buildReceipt([], { + id: "scope-ledger", + status: "closed", + startedAt: 1, + pendingCount: 0, + completedCount: 1, + failedCount: 0, + cancelledCount: 0, + tasks: [], + scopes: [], + }, { + clock: () => 2, + receiptId: "receipt-ledger", + }); + + const first = createFileReceiptLedger({ dir, clock: () => 3 }); + const record = await first.append(receipt); + const second = createFileReceiptLedger({ dir }); + const restored = await second.get("receipt-ledger"); + + return { + ok: restored?.receiptId === "receipt-ledger" && record.receiptId === "receipt-ledger", + record, + restoredOutcome: restored?.terminal.outcome, + }; + } finally { + await rm(dir, { recursive: true, force: true }); + } + }, +); + +const summary = suite.summary(); +process.stdout.write(JSON.stringify(summary, null, 2) + "\n"); +process.exit(summary.failed > 0 ? 1 : 0); diff --git a/packages/core/tests/evidence/run-all.mjs b/packages/core/tests/evidence/run-all.mjs index 94cd2c9..f609c93 100644 --- a/packages/core/tests/evidence/run-all.mjs +++ b/packages/core/tests/evidence/run-all.mjs @@ -12,9 +12,18 @@ import { fileURLToPath } from "node:url"; const here = path.dirname(fileURLToPath(import.meta.url)); const files = [ "lifecycle/owned-work.mjs", + "lifecycle/activity-restart.mjs", + "lifecycle/resource-audit.mjs", + "lifecycle/resource-ownership.mjs", + "lifecycle/replay-receipts.mjs", + "correctness/analysis-verifiers.mjs", + "correctness/activity-boundary.mjs", + "correctness/resource-ownership-model.mjs", "correctness/runtime-contracts.mjs", + "correctness/source-protocol-analysis.mjs", "security/worker-boundary.mjs", "release/release-integrity.mjs", + "release/receipt-ledger.mjs", "performance/benchmark-contracts.mjs", ]; diff --git a/packages/core/tests/unit/activity.test.js b/packages/core/tests/unit/activity.test.js new file mode 100644 index 0000000..0ba99bc --- /dev/null +++ b/packages/core/tests/unit/activity.test.js @@ -0,0 +1,573 @@ +/** + * Activity boundary subpath tests. + * + * @author Admilson B. F. Cossa + * SPDX-License-Identifier: Apache-2.0 + */ + +import { test } from "vitest"; +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { CancellationError, group } from "../../dist/index.js"; +import { + ActivityConflictError, + ActivityNotRunnableError, + ActivitySerializationError, + createFileActivityStore, + createMemoryActivityStore, + runActivity, +} from "../../dist/activity/index.js"; + +test("Given completed activity, repeated execution returns stored result without rerunning body", async () => { + const store = createMemoryActivityStore(); + let runs = 0; + const spec = { activityId: "activity-complete", input: { userId: "u1" } }; + + const first = await group(async (task) => task(runActivity(store, spec, async () => { + runs++; + return { ok: true }; + }, { clock: () => 10 }))); + const second = await group(async (task) => task(runActivity(store, spec, async () => { + runs++; + return { ok: false }; + }, { clock: () => 20 }))); + const record = await store.get("activity-complete"); + + assert.deepEqual(first, { ok: true }); + assert.deepEqual(second, { ok: true }); + assert.equal(runs, 1); + assert.equal(record.status, "completed"); + assert.equal(record.completedAt, 10); +}); + +test("Given file activity store, completed activity replays after restart without rerunning body", async () => { + const dir = await mkdtemp(join(tmpdir(), "workit-activity-")); + + try { + const spec = { activityId: "activity-file-restart", input: { requestId: "r1" } }; + let runs = 0; + + const first = await group(async (task) => task(runActivity( + createFileActivityStore({ dir }), + spec, + async () => { + runs++; + return "persisted-result"; + }, + { clock: () => 10 }, + ))); + const second = await group(async (task) => task(runActivity( + createFileActivityStore({ dir }), + spec, + async () => { + runs++; + return "unexpected"; + }, + { clock: () => 20 }, + ))); + const record = await createFileActivityStore({ dir }).get(spec.activityId); + + assert.equal(first, "persisted-result"); + assert.equal(second, "persisted-result"); + assert.equal(runs, 1); + assert.equal(record.status, "completed"); + assert.equal(record.completedAt, 10); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("Given file activity store, restart preserves started terminal and conflict contracts", async () => { + const dir = await mkdtemp(join(tmpdir(), "workit-activity-")); + + try { + const store = createFileActivityStore({ dir }); + assert.equal(await store.get("activity-file-missing"), undefined); + await assert.rejects( + store.finish(makeStartedRecord("activity-file-missing", "hash")), + ActivityConflictError, + ); + + const started = makeStartedRecord("activity-file-started", "hash"); + assert.equal((await store.start(started)).status, "started"); + assert.equal((await createFileActivityStore({ dir }).start(started)).status, "existing"); + + await assert.rejects( + group(async (task) => task(runActivity( + createFileActivityStore({ dir }), + { activityId: "activity-file-started", input: "different" }, + async () => "unexpected", + ))), + ActivityConflictError, + ); + + await store.finish({ + ...started, + status: "completed", + completedAt: 2, + updatedAt: 2, + result: "done", + }); + + await assert.rejects( + createFileActivityStore({ dir }).finish({ + ...started, + status: "completed", + completedAt: 3, + updatedAt: 3, + result: "overwritten", + }), + ActivityNotRunnableError, + ); + assert.equal((await createFileActivityStore({ dir }).get(started.activityId)).result, "done"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("Given file activity store filesystem errors, store operations fail closed", async () => { + const dir = await mkdtemp(join(tmpdir(), "workit-activity-")); + + try { + const activityId = "activity-file-error"; + const recordPath = join(dir, `${Buffer.from(activityId, "utf8").toString("base64url")}.json`); + await mkdir(recordPath); + const store = createFileActivityStore({ dir }); + + await assert.rejects( + store.start(makeStartedRecord(activityId, "hash")), + (error) => typeof error === "object" && error !== null && error.code === "EISDIR", + ); + await assert.rejects( + store.get(activityId), + (error) => typeof error === "object" && error !== null && error.code === "EISDIR", + ); + + await assert.rejects( + store.start(makeStartedRecord("x".repeat(512), "hash")), + (error) => + typeof error === "object" + && error !== null + && typeof error.code === "string" + && error.code !== "EEXIST", + ); + + const corruptId = "activity-corrupt-json"; + await writeFile( + join(dir, `${Buffer.from(corruptId, "utf8").toString("base64url")}.json`), + "{not-json", + "utf8", + ); + await assert.rejects(store.start(makeStartedRecord(corruptId, "hash")), SyntaxError); + await assert.rejects(store.get(corruptId), SyntaxError); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("Given same activity id with different input, activity boundary rejects the conflict", async () => { + const store = createMemoryActivityStore(); + + await group(async (task) => task(runActivity( + store, + { activityId: "activity-conflict", input: { page: 1 } }, + async () => "first", + ))); + + await assert.rejects( + group(async (task) => task(runActivity( + store, + { activityId: "activity-conflict", input: { page: 2 } }, + async () => "second", + ))), + ActivityConflictError, + ); +}); + +test("Given existing started activity, boundary refuses implicit replay", async () => { + const store = createMemoryActivityStore(); + await store.start(makeStartedRecord("activity-started-conflict", "hash")); + + await assert.rejects( + group(async (task) => task(runActivity( + store, + { activityId: "activity-started-conflict", input: "input" }, + async () => "unexpected", + ))), + ActivityConflictError, + ); + + await assert.rejects( + group(async (task) => task(runActivity(createExistingStartedStore(), { + activityId: "activity-started-same", + input: "input", + }, async () => "unexpected"))), + ActivityNotRunnableError, + ); +}); + +test("Given failed activity body, boundary persists safe error evidence", async () => { + const store = createMemoryActivityStore(); + + await assert.rejects( + group(async (task) => task(runActivity( + store, + { activityId: "activity-failed", input: "x" }, + async () => { + throw new Error("provider unavailable"); + }, + { clock: () => 30 }, + ))), + /provider unavailable/, + ); + const record = await store.get("activity-failed"); + + assert.equal(record.status, "failed"); + assert.equal(record.error.name, "Error"); + assert.equal(record.failedAt, 30); +}); + +test("Given primitive activity failure, boundary persists safe primitive error evidence", async () => { + const store = createMemoryActivityStore(); + + await assert.rejects( + group(async (task) => task(runActivity( + store, + { activityId: "activity-primitive-failed", input: "x" }, + async () => { + throw "provider-offline"; + }, + ))), + (error) => error === "provider-offline", + ); + const record = await store.get("activity-primitive-failed"); + + assert.equal(record.status, "failed"); + assert.deepEqual(record.error, { name: "string", message: "provider-offline" }); +}); + +test("Given completed activity record, memory store refuses terminal overwrite", async () => { + const store = createMemoryActivityStore(); + const started = makeStartedRecord("activity-terminal", "hash"); + await store.start(started); + + await store.finish({ + ...started, + status: "completed", + completedAt: 2, + updatedAt: 2, + result: "first", + }); + + await assert.rejects( + store.finish({ + ...started, + status: "completed", + completedAt: 3, + updatedAt: 3, + result: "second", + }), + ActivityNotRunnableError, + ); + + assert.equal((await store.get("activity-terminal")).result, "first"); +}); + +test("Given Date activity input, boundary canonicalizes the JSON value for replay", async () => { + const store = createMemoryActivityStore(); + const firstInput = { at: new Date("2026-01-01T00:00:00.000Z") }; + const secondInput = { at: new Date("2026-01-01T00:00:00.000Z") }; + let runs = 0; + + const first = await group(async (task) => task(runActivity( + store, + { activityId: "activity-date", input: firstInput }, + async () => { + runs++; + return "date-ok"; + }, + ))); + const second = await group(async (task) => task(runActivity( + store, + { activityId: "activity-date", input: secondInput }, + async () => { + runs++; + return "unexpected"; + }, + ))); + + assert.equal(first, "date-ok"); + assert.equal(second, "date-ok"); + assert.equal(runs, 1); +}); + +test("Given Date activity result, first execution and replay return the same JSON value", async () => { + const store = createMemoryActivityStore(); + let runs = 0; + + const first = await group(async (task) => task(runActivity( + store, + { activityId: "activity-date-result", input: "x" }, + async () => { + runs++; + return { at: new Date("2026-01-01T00:00:00.000Z") }; + }, + ))); + const second = await group(async (task) => task(runActivity( + store, + { activityId: "activity-date-result", input: "x" }, + async () => { + runs++; + return { at: "unexpected" }; + }, + ))); + const record = await store.get("activity-date-result"); + + assert.deepEqual(first, { at: "2026-01-01T00:00:00.000Z" }); + assert.deepEqual(second, first); + assert.equal(runs, 1); + assert.equal(record.status, "completed"); + assert.deepEqual(record.result, first); +}); + +test("Given non-serializable activity result, boundary records failure without storing the result", async () => { + const store = createMemoryActivityStore(); + + await assert.rejects( + group(async (task) => task(runActivity( + store, + { activityId: "activity-bad-result", input: "x" }, + async () => ({ value: 1n }), + ))), + ActivitySerializationError, + ); + const record = await store.get("activity-bad-result"); + + assert.equal(record.status, "failed"); + assert.equal(record.error.name, "ActivitySerializationError"); +}); + +test("Given array activity input, boundary canonicalizes nested JSON values", async () => { + const store = createMemoryActivityStore(); + let runs = 0; + + const first = await group(async (task) => task(runActivity( + store, + { activityId: "activity-array", input: [{ item: 1 }, { item: 2 }] }, + async () => { + runs++; + return "array-ok"; + }, + ))); + const second = await group(async (task) => task(runActivity( + store, + { activityId: "activity-array", input: [{ item: 1 }, { item: 2 }] }, + async () => { + runs++; + return "unexpected"; + }, + ))); + + assert.equal(first, "array-ok"); + assert.equal(second, "array-ok"); + assert.equal(runs, 1); +}); + +test("Given object input with reordered keys, boundary canonicalizes input for replay", async () => { + const store = createMemoryActivityStore(); + let runs = 0; + + const first = await group(async (task) => task(runActivity( + store, + { activityId: "activity-key-order", input: { z: 1, a: { c: 3, b: 2 } } }, + async () => { + runs++; + return "key-order-ok"; + }, + ))); + const second = await group(async (task) => task(runActivity( + store, + { activityId: "activity-key-order", input: { a: { b: 2, c: 3 }, z: 1 } }, + async () => { + runs++; + return "unexpected"; + }, + ))); + + assert.equal(first, "key-order-ok"); + assert.equal(second, "key-order-ok"); + assert.equal(runs, 1); +}); + +test("Given path-like activity id, file store writes a single encoded record inside the store directory", async () => { + const dir = await mkdtemp(join(tmpdir(), "workit-activity-")); + + try { + const activityId = "../tenant/../../secret?x=1"; + const store = createFileActivityStore({ dir }); + + await group(async (task) => task(runActivity( + store, + { activityId, input: { requestId: "r1" } }, + async () => "safe-file-name", + ))); + + const files = await readdir(dir); + const record = await store.get(activityId); + + assert.equal(files.length, 1); + assert.match(files[0], /^[A-Za-z0-9_-]+\.json$/); + assert.equal(files[0].includes(".."), false); + assert.equal(files[0].includes("/"), false); + assert.equal(record.status, "completed"); + assert.equal(record.result, "safe-file-name"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("Given cancelled activity body, boundary persists typed cancellation reason", async () => { + const store = createMemoryActivityStore(); + + await assert.rejects( + group(async (task) => { + const handle = task(runActivity( + store, + { activityId: "activity-cancelled", input: "x" }, + async (ctx) => sleep(50, ctx.signal), + { clock: () => 40 }, + )); + await sleep(1); + handle.cancel({ kind: "manual", tag: "activity_test" }); + await handle; + }), + CancellationError, + ); + const record = await store.get("activity-cancelled"); + + assert.equal(record.status, "cancelled"); + assert.deepEqual(record.cancelReason, { kind: "manual", tag: "activity_test" }); + assert.equal(record.cancelledAt, 40); +}); + +test("Given non-serializable activity input, boundary rejects before claiming the store", () => { + let starts = 0; + const cyclic = {}; + cyclic.self = cyclic; + const store = { + async start(record) { + starts++; + return { status: "started", record }; + }, + async finish(record) { + return record; + }, + async get() { + return undefined; + }, + }; + + assert.throws( + () => runActivity(store, { activityId: "activity-cyclic", input: cyclic }, async () => "bad"), + ActivitySerializationError, + ); + assert.throws( + () => runActivity(store, { activityId: "activity-bigint", input: { value: 1n } }, async () => "bad"), + ActivitySerializationError, + ); + assert.throws( + () => runActivity(store, { activityId: "activity-undefined", input: undefined }, async () => "bad"), + ActivitySerializationError, + ); + assert.throws( + () => runActivity(store, { activityId: "activity-nan", input: { value: Number.NaN } }, async () => "bad"), + ActivitySerializationError, + ); + assert.throws( + () => runActivity(store, { activityId: "activity-function", input: { value: () => "bad" } }, async () => "bad"), + ActivitySerializationError, + ); + assert.throws( + () => runActivity(store, { activityId: "activity-symbol", input: { value: Symbol("bad") } }, async () => "bad"), + ActivitySerializationError, + ); + assert.equal(starts, 0); +}); + +test("Given memory activity store retention, oldest records are evicted", async () => { + const store = createMemoryActivityStore({ maxRecords: 1 }); + + await store.start(makeStartedRecord("activity-old", "old")); + await store.start(makeStartedRecord("activity-new", "new")); + + assert.equal(await store.get("activity-old"), undefined); + assert.equal((await store.get("activity-new")).activityId, "activity-new"); +}); + +test("Given invalid activity contracts, constructors reject at the boundary", async () => { + assert.throws(() => createMemoryActivityStore({ maxRecords: 0 }), /maxRecords/); + assert.throws(() => createFileActivityStore({ dir: "" }), /dir/); + + await assert.rejects( + group(async (task) => task(runActivity( + createMemoryActivityStore(), + { activityId: "", input: "x" }, + async () => "bad", + ))), + /activityId/, + ); + + await assert.rejects( + createMemoryActivityStore().finish(makeStartedRecord("activity-missing", "hash")), + ActivityConflictError, + ); +}); + +test("Given the root import, activity helpers are not exported from the root runtime", async () => { + const root = await import("../../dist/index.js"); + + assert.equal("createMemoryActivityStore" in root, false); + assert.equal("createFileActivityStore" in root, false); + assert.equal("runActivity" in root, false); + assert.equal("ActivityConflictError" in root, false); + assert.equal("ActivityNotRunnableError" in root, false); + assert.equal("ActivitySerializationError" in root, false); +}); + +function makeStartedRecord(activityId, inputHash) { + return { + version: "workit.activity.v1", + activityId, + inputHash, + status: "started", + startedAt: 1, + updatedAt: 1, + }; +} + +function createExistingStartedStore() { + return { + async start(record) { + return { status: "existing", record }; + }, + async finish(record) { + return record; + }, + async get() { + return undefined; + }, + }; +} + +function sleep(ms, signal) { + if (signal?.aborted === true) return Promise.reject(signal.reason); + return new Promise((resolve, reject) => { + const timer = setTimeout(resolve, ms); + signal?.addEventListener("abort", () => { + clearTimeout(timer); + reject(signal.reason); + }, { once: true }); + }); +} diff --git a/packages/core/tests/unit/analysis.test.js b/packages/core/tests/unit/analysis.test.js new file mode 100644 index 0000000..ec8de18 --- /dev/null +++ b/packages/core/tests/unit/analysis.test.js @@ -0,0 +1,574 @@ +/** + * Analysis subpath tests. + * + * @author Admilson B. F. Cossa + * SPDX-License-Identifier: Apache-2.0 + */ + +import { test } from "vitest"; +import assert from "node:assert/strict"; +import { + analyzeReceipt, + verifyReceipt, + verifyScopeProtocol, + verifySourceProtocol, +} from "../../dist/analysis/index.js"; +import { buildReceipt } from "../../dist/replay/index.js"; + +test("Given receipt with leaked tasks, analyzeReceipt reports lifecycle finding", () => { + const receipt = buildReceipt([], { + id: "scope-leak", + status: "running", + startedAt: 1, + pendingCount: 1, + completedCount: 0, + failedCount: 0, + cancelledCount: 0, + tasks: [ + { + id: "task-leak", + name: "leaked", + kind: "custom", + status: "running", + attempt: 1, + startedAt: 1, + }, + ], + scopes: [], + }, { + clock: () => 2, + receiptId: "receipt-leak", + }); + + const report = analyzeReceipt(receipt); + + assert.equal(report.status, "fail"); + assert.equal(report.findings.some((finding) => finding.code === "leaked_tasks"), true); + assert.equal(report.findings.some((finding) => finding.code === "receipt_not_terminal"), true); +}); + +test("Given receipt with cleanup timeout and truncated events, analyzeReceipt returns warning status", () => { + const receipt = buildReceipt([ + { type: "task:cleanup_timeout", taskId: "task-cleanup", timeoutMs: 5, at: 1 }, + ], { + id: "scope-warn", + status: "closed", + startedAt: 1, + pendingCount: 0, + completedCount: 1, + failedCount: 0, + cancelledCount: 0, + tasks: [], + scopes: [], + }, { + receiptId: "receipt-warn", + clock: () => 2, + }); + const truncated = { + ...receipt, + summary: { ...receipt.summary, droppedEvents: 1 }, + }; + + const report = analyzeReceipt(truncated); + + assert.equal(report.status, "warn"); + assert.equal(report.findings.some((finding) => finding.code === "cleanup_timeout"), true); + assert.equal(report.findings.some((finding) => finding.code === "receipt_truncated"), true); +}); + +test("Given failed receipt, analyzeReceipt reports terminal failure", () => { + const receipt = buildReceipt([ + { type: "scope:closing", scopeId: "scope-failed", reason: "errored", at: 1 }, + ], { + id: "scope-failed", + status: "closed", + startedAt: 1, + pendingCount: 0, + completedCount: 0, + failedCount: 1, + cancelledCount: 0, + tasks: [], + scopes: [], + }, { + receiptId: "receipt-failed", + }); + + const report = analyzeReceipt(receipt); + + assert.equal(report.status, "fail"); + assert.equal(report.findings.some((finding) => finding.code === "terminal_failed"), true); + assert.equal(report.findings.some((finding) => finding.code === "terminal_cause_missing"), true); +}); + +test("Given closed receipt with terminal event evidence, verifyReceipt passes explicit checks", () => { + const receipt = buildReceipt([ + { type: "scope:closing", scopeId: "scope-ok", reason: "completed", at: 2 }, + { type: "scope:closed", scopeId: "scope-ok", durationMs: 3, at: 3 }, + ], { + id: "scope-ok", + status: "closed", + startedAt: 1, + pendingCount: 0, + completedCount: 1, + failedCount: 0, + cancelledCount: 0, + tasks: [ + { + id: "task-ok", + name: "ok", + kind: "custom", + status: "succeeded", + attempt: 1, + startedAt: 1, + durationMs: 1, + }, + ], + scopes: [], + }, { + receiptId: "receipt-ok", + }); + + const report = verifyReceipt(receipt, { requireTerminalEvent: true }); + + assert.equal(report.status, "pass"); + assert.equal(report.receiptId, "receipt-ok"); + assert.equal(report.checks.every((check) => check.status === "pass"), true); +}); + +test("Given pending nested task, verifyReceipt reports the orphaned task id", () => { + const receipt = buildReceipt([ + { type: "scope:closing", scopeId: "scope-root", reason: "completed", at: 2 }, + { type: "scope:closed", scopeId: "scope-root", durationMs: 3, at: 3 }, + ], { + id: "scope-root", + status: "closed", + startedAt: 1, + pendingCount: 0, + completedCount: 0, + failedCount: 0, + cancelledCount: 0, + tasks: [], + scopes: [ + { + id: "scope-child", + status: "running", + startedAt: 1, + pendingCount: 1, + completedCount: 0, + failedCount: 0, + cancelledCount: 0, + tasks: [ + { + id: "task-nested", + name: "nested", + kind: "custom", + status: "running", + attempt: 1, + startedAt: 1, + }, + ], + scopes: [], + }, + ], + }, { + receiptId: "receipt-nested", + }); + + const report = verifyReceipt(receipt); + const leaked = report.findings.find((finding) => finding.code === "leaked_tasks"); + + assert.equal(report.status, "fail"); + assert.equal(leaked?.taskId, "task-nested"); + assert.equal(report.checks.find((check) => check.code === "no_orphaned_owned_tasks")?.status, "fail"); +}); + +test("Given summary-only leaked task evidence, verifyReceipt reports leak without inventing a task id", () => { + const receipt = buildReceipt([ + { type: "scope:closing", scopeId: "scope-summary-leak", reason: "completed", at: 2 }, + { type: "scope:closed", scopeId: "scope-summary-leak", durationMs: 3, at: 3 }, + ], { + id: "scope-summary-leak", + status: "closed", + startedAt: 1, + pendingCount: 0, + completedCount: 1, + failedCount: 0, + cancelledCount: 0, + tasks: [], + scopes: [], + }, { + receiptId: "receipt-summary-leak", + }); + const withSummaryLeak = { + ...receipt, + summary: { ...receipt.summary, leakedTasks: 1 }, + }; + + const report = verifyReceipt(withSummaryLeak); + const leaked = report.findings.find((finding) => finding.code === "leaked_tasks"); + + assert.equal(report.status, "fail"); + assert.equal(leaked?.taskId, undefined); + assert.equal(report.checks.find((check) => check.code === "no_orphaned_owned_tasks")?.count, 1); +}); + +test("Given cancelled receipt without typed cause, verifyReceipt reports missing terminal cause", () => { + const receipt = buildReceipt([ + { type: "scope:closing", scopeId: "scope-cancelled", reason: "cancelled", at: 2 }, + { type: "scope:closed", scopeId: "scope-cancelled", durationMs: 3, at: 3 }, + ], { + id: "scope-cancelled", + status: "closed", + startedAt: 1, + pendingCount: 0, + completedCount: 0, + failedCount: 0, + cancelledCount: 0, + tasks: [], + scopes: [], + }, { + receiptId: "receipt-cancelled-missing-cause", + }); + + const report = verifyReceipt(receipt); + + assert.equal(report.status, "fail"); + assert.equal(report.findings.some((finding) => finding.code === "terminal_cause_missing"), true); + assert.equal(report.checks.find((check) => check.code === "terminal_cause_recorded")?.status, "fail"); +}); + +test("Given failed and cancelled receipts with cause evidence, verifyReceipt passes terminal cause check", () => { + const failed = buildReceipt([ + { type: "task:failed", taskId: "task-failed", error: new Error("provider failed"), durationMs: 1, at: 2 }, + { type: "scope:closing", scopeId: "scope-failed-with-cause", reason: "errored", at: 3 }, + { type: "scope:closed", scopeId: "scope-failed-with-cause", durationMs: 4, at: 4 }, + ], { + id: "scope-failed-with-cause", + status: "closed", + startedAt: 1, + pendingCount: 0, + completedCount: 0, + failedCount: 1, + cancelledCount: 0, + tasks: [], + scopes: [], + }, { + receiptId: "receipt-failed-with-cause", + }); + const cancelled = buildReceipt([ + { + type: "task:cancelled", + taskId: "task-cancelled", + reason: { kind: "manual", tag: "manual_stop" }, + durationMs: 1, + at: 2, + }, + { type: "scope:closing", scopeId: "scope-cancelled-with-cause", reason: "cancelled", at: 3 }, + { type: "scope:closed", scopeId: "scope-cancelled-with-cause", durationMs: 4, at: 4 }, + ], { + id: "scope-cancelled-with-cause", + status: "closed", + startedAt: 1, + pendingCount: 0, + completedCount: 0, + failedCount: 0, + cancelledCount: 1, + tasks: [], + scopes: [], + }, { + receiptId: "receipt-cancelled-with-cause", + }); + + const failedReport = verifyReceipt(failed); + const cancelledReport = verifyReceipt(cancelled); + + assert.equal(failedReport.findings.some((finding) => finding.code === "terminal_cause_missing"), false); + assert.equal(failedReport.checks.find((check) => check.code === "terminal_cause_recorded")?.status, "pass"); + assert.equal(cancelledReport.status, "pass"); + assert.equal(cancelledReport.checks.find((check) => check.code === "terminal_cause_recorded")?.status, "pass"); +}); + +test("Given cleanup evidence is required, verifyReceipt distinguishes absent and observed evidence", () => { + const snapshot = { + id: "scope-cleanup-required", + status: "closed", + startedAt: 1, + pendingCount: 0, + completedCount: 1, + failedCount: 0, + cancelledCount: 0, + tasks: [], + scopes: [], + }; + const withoutCleanup = buildReceipt([ + { type: "scope:closing", scopeId: "scope-cleanup-required", reason: "completed", at: 2 }, + { type: "scope:closed", scopeId: "scope-cleanup-required", durationMs: 3, at: 3 }, + ], snapshot, { + receiptId: "receipt-cleanup-missing", + }); + const withCleanup = buildReceipt([ + { type: "task:cleanup_timeout", taskId: "task-cleanup", timeoutMs: 5, at: 2 }, + { type: "scope:closing", scopeId: "scope-cleanup-required", reason: "completed", at: 3 }, + { type: "scope:closed", scopeId: "scope-cleanup-required", durationMs: 4, at: 4 }, + ], snapshot, { + receiptId: "receipt-cleanup-observed", + }); + + const missing = verifyReceipt(withoutCleanup, { requireCleanupEvidence: true }); + const observed = verifyReceipt(withCleanup, { requireCleanupEvidence: true }); + + assert.equal(missing.status, "fail"); + assert.equal(missing.findings.some((finding) => finding.code === "cleanup_evidence_missing"), true); + assert.equal(observed.status, "warn"); + assert.equal(observed.findings.some((finding) => finding.code === "cleanup_evidence_missing"), false); + assert.equal(observed.findings.some((finding) => finding.code === "cleanup_timeout"), true); + assert.equal(observed.checks.find((check) => check.code === "cleanup_evidence_recorded")?.status, "pass"); +}); + +test("Given terminal event is required, verifyReceipt reports missing root terminal event", () => { + const receipt = buildReceipt([], { + id: "scope-no-terminal-event", + status: "closed", + startedAt: 1, + pendingCount: 0, + completedCount: 1, + failedCount: 0, + cancelledCount: 0, + tasks: [], + scopes: [], + }, { + receiptId: "receipt-no-terminal-event", + }); + + const report = verifyReceipt(receipt, { requireTerminalEvent: true }); + + assert.equal(report.status, "fail"); + assert.equal(report.findings.some((finding) => finding.code === "terminal_event_missing"), true); + assert.equal(report.checks.find((check) => check.code === "terminal_event_recorded")?.status, "fail"); +}); + +test("Given terminal timestamp without root event, verifyReceipt accepts timestamp evidence", () => { + const receipt = buildReceipt([], { + id: "scope-terminal-at", + status: "closed", + startedAt: 1, + pendingCount: 0, + completedCount: 1, + failedCount: 0, + cancelledCount: 0, + tasks: [], + scopes: [], + }, { + receiptId: "receipt-terminal-at", + }); + const withTerminalTimestamp = { + ...receipt, + terminal: { ...receipt.terminal, at: 3 }, + }; + + const report = verifyReceipt(withTerminalTimestamp, { requireTerminalEvent: true }); + + assert.equal(report.status, "pass"); + assert.equal(report.checks.find((check) => check.code === "terminal_event_recorded")?.status, "pass"); +}); + +test("Given non-terminal root-scoped events, verifyReceipt does not treat them as terminal evidence", () => { + const receipt = buildReceipt([ + { type: "task:started", taskId: "task-not-terminal", scopeId: "scope-task-only", name: "work", kind: "custom", at: 2 }, + { type: "scope:opened", scopeId: "scope-child-only", parentId: "scope-task-only", at: 3 }, + ], { + id: "scope-task-only", + status: "closed", + startedAt: 1, + pendingCount: 0, + completedCount: 1, + failedCount: 0, + cancelledCount: 0, + tasks: [], + scopes: [], + }, { + receiptId: "receipt-task-only", + }); + + const report = verifyReceipt(receipt, { requireTerminalEvent: true }); + + assert.equal(report.status, "fail"); + assert.equal(report.findings.some((finding) => finding.code === "terminal_event_missing"), true); +}); + +test("Given task event after terminal event, verifyScopeProtocol reports protocol violation", () => { + const report = verifyScopeProtocol([ + { type: "task:started", taskId: "task-a", scopeId: "scope-a", name: "a", kind: "custom", at: 1 }, + { type: "task:succeeded", taskId: "task-a", durationMs: 1, at: 2 }, + { type: "task:retrying", taskId: "task-a", attempt: 2, error: new Error("late"), nextDelayMs: 1, at: 3 }, + ]); + + assert.equal(report.status, "fail"); + assert.equal(report.findings.some((finding) => finding.code === "task_event_after_terminal"), true); +}); + +test("Given ordered task events and scope events, verifyScopeProtocol passes", () => { + const report = verifyScopeProtocol([ + { type: "scope:opened", scopeId: "scope-ok", parentId: null, at: 1 }, + { type: "task:started", taskId: "task-ok", scopeId: "scope-ok", name: "ok", kind: "custom", at: 2 }, + { type: "task:cancelled", taskId: "task-ok", reason: { kind: "manual", tag: "ok" }, durationMs: 1, at: 3 }, + ]); + + assert.equal(report.status, "pass"); + assert.deepEqual(report.findings, []); +}); + +test("Given owned source protocol, verifySourceProtocol passes with checked counts", () => { + const report = verifySourceProtocol({ + version: "workit.source-protocol.v1", + modules: [ + { + moduleId: "upload.pipeline", + functions: [ + { + functionId: "uploadBatch", + kind: "handler", + uses: [ + { operation: "resource.acquire", target: "temp-dir" }, + { operation: "ctx.defer", target: "temp-dir" }, + { operation: "durable.side_effect", target: "object-store" }, + { operation: "activity.run", target: "object-store" }, + { operation: "promise.all", target: "files" }, + { operation: "run.pool", target: "files" }, + { operation: "run.retry", target: "object-store" }, + { operation: "time_policy.plan", target: "object-store" }, + ], + }, + { + functionId: "writeTool", + kind: "agent_tool", + uses: [ + { operation: "agent.tool", capability: "repo:write" }, + { operation: "durable.side_effect", target: "repo" }, + { operation: "receipt.append", target: "repo-write" }, + ], + }, + ], + }, + ], + }); + + assert.equal(report.status, "pass"); + assert.equal(report.checkedModules, 1); + assert.equal(report.checkedFunctions, 2); + assert.equal(report.checkedUses, 11); + assert.deepEqual(report.findings, []); +}); + +test("Given source protocol ownership gaps, verifySourceProtocol reports stable findings", () => { + const report = verifySourceProtocol({ + modules: [ + { + moduleId: "unsafe.pipeline", + functions: [ + { + functionId: "uploadBatch", + kind: "handler", + uses: [ + { operation: "resource.acquire", target: "temp-dir" }, + { operation: "durable.side_effect", target: "object-store" }, + { operation: "promise.all", target: "files" }, + { operation: "run.timeout", target: "object-store" }, + ], + }, + { + functionId: "writeTool", + kind: "agent_tool", + uses: [ + { operation: "agent.tool" }, + { operation: "durable.side_effect", target: "repo" }, + { operation: "activity.run", target: "repo" }, + ], + }, + ], + }, + ], + }); + const codes = report.findings.map((finding) => finding.code).sort(); + + assert.equal(report.status, "fail"); + assert.deepEqual(codes, [ + "source_agent_tool_without_authority", + "source_durable_side_effect_without_evidence", + "source_parallel_without_bound", + "source_resource_without_cleanup", + "source_time_policy_unplanned", + ]); + assert.equal(report.findings.every((finding) => finding.moduleId === "unsafe.pipeline"), true); +}); + +test("Given source protocol limits, verifySourceProtocol stays bounded", () => { + const report = verifySourceProtocol({ + modules: [ + { + moduleId: "bounded.one", + functions: [ + { + functionId: "tooManyUses", + uses: [ + { operation: "resource.acquire" }, + { operation: "ctx.defer" }, + ], + }, + { + functionId: "notVisited", + uses: [{ operation: "durable.side_effect" }], + }, + ], + }, + { + moduleId: "bounded.two", + functions: [ + { + functionId: "notVisited", + uses: [{ operation: "durable.side_effect" }], + }, + ], + }, + ], + }, { + maxModules: 1, + maxFunctions: 1, + maxUsesPerFunction: 1, + }); + + assert.equal(report.status, "fail"); + assert.equal(report.checkedModules, 1); + assert.equal(report.checkedFunctions, 1); + assert.equal(report.checkedUses, 1); + assert.equal(report.findings.filter((finding) => finding.code === "source_protocol_limit_exceeded").length, 3); + assert.equal(report.findings.some((finding) => + finding.code === "source_protocol_limit_exceeded" + && finding.count === 2 + && finding.limit === 1 + ), true); +}); + +test("Given invalid source protocol limits, verifySourceProtocol rejects the contract", () => { + assert.throws( + () => verifySourceProtocol({ modules: [] }, { maxModules: 0 }), + /maxModules must be a positive integer/, + ); + assert.throws( + () => verifySourceProtocol({ modules: [] }, { maxFunctions: 0.5 }), + /maxFunctions must be a positive integer/, + ); + assert.throws( + () => verifySourceProtocol({ modules: [] }, { maxUsesPerFunction: -1 }), + /maxUsesPerFunction must be a positive integer/, + ); +}); + +test("Given the root import, analysis helpers are not exported from the root runtime", async () => { + const root = await import("../../dist/index.js"); + + assert.equal("analyzeReceipt" in root, false); + assert.equal("verifyReceipt" in root, false); + assert.equal("verifyScopeProtocol" in root, false); + assert.equal("verifySourceProtocol" in root, false); +}); diff --git a/packages/core/tests/unit/examples.test.js b/packages/core/tests/unit/examples.test.js index bdd08a1..7f53884 100644 --- a/packages/core/tests/unit/examples.test.js +++ b/packages/core/tests/unit/examples.test.js @@ -1,5 +1,5 @@ /** - * Executable adoption examples for WorkIt. + * Executable runtime examples for WorkIt. * * @author Admilson B. F. Cossa * SPDX-License-Identifier: Apache-2.0 @@ -117,7 +117,7 @@ test("example: budget-capped RAG query composes all helpers without network clie const [rewritten, queryVector] = await run.all([ async (ctx) => { ctx.consumeCost(1); - return "structured concurrency adoption"; + return "structured concurrency runtime"; }, async (ctx) => { ctx.consumeCost(2); @@ -154,8 +154,8 @@ test("example: budget-capped RAG query composes all helpers without network clie context, }); - assert.equal(answer, "answer:keyword:structured concurrency adoption"); - assert.deepEqual(audits, [{ rewritten: "structured concurrency adoption", count: 2 }]); + assert.equal(answer, "answer:keyword:structured concurrency runtime"); + assert.deepEqual(audits, [{ rewritten: "structured concurrency runtime", count: 2 }]); assert.equal(context.get(CostBudget).spent, 8); }); diff --git a/packages/core/tests/unit/ledger.test.js b/packages/core/tests/unit/ledger.test.js new file mode 100644 index 0000000..530736b --- /dev/null +++ b/packages/core/tests/unit/ledger.test.js @@ -0,0 +1,152 @@ +/** + * Receipt ledger subpath tests. + * + * @author Admilson B. F. Cossa + * SPDX-License-Identifier: Apache-2.0 + */ + +import { test } from "vitest"; +import assert from "node:assert/strict"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildReceipt } from "../../dist/replay/index.js"; +import { + ReceiptLedgerConflictError, + createFileReceiptLedger, + createMemoryReceiptLedger, +} from "../../dist/ledger/index.js"; + +function makeReceipt(receiptId, completedCount = 1) { + return buildReceipt([], { + id: `scope-${receiptId}`, + status: "closed", + startedAt: 1, + pendingCount: 0, + completedCount, + failedCount: 0, + cancelledCount: 0, + tasks: [], + scopes: [], + }, { + clock: () => 2, + receiptId, + }); +} + +test("Given memory ledger, append is idempotent for identical receipt content", async () => { + const ledger = createMemoryReceiptLedger({ clock: () => 10 }); + const receipt = makeReceipt("receipt-memory"); + + const first = await ledger.append(receipt); + const second = await ledger.append(receipt); + + assert.equal(first.receiptId, "receipt-memory"); + assert.equal(first.storedAt, 10); + assert.equal(first.checksum, second.checksum); + assert.deepEqual(await ledger.get("receipt-memory"), receipt); + assert.deepEqual((await ledger.list()).map((record) => record.receiptId), ["receipt-memory"]); +}); + +test("Given memory ledger, conflicting receipt id is rejected", async () => { + const ledger = createMemoryReceiptLedger(); + await ledger.append(makeReceipt("receipt-conflict", 1)); + + await assert.rejects( + ledger.append(makeReceipt("receipt-conflict", 2)), + ReceiptLedgerConflictError, + ); +}); + +test("Given memory ledger retention limit, oldest receipts are evicted", async () => { + const ledger = createMemoryReceiptLedger({ maxReceipts: 1 }); + + await ledger.append(makeReceipt("receipt-old")); + await ledger.append(makeReceipt("receipt-new")); + + assert.equal(await ledger.get("receipt-old"), undefined); + assert.equal((await ledger.list())[0].receiptId, "receipt-new"); +}); + +test("Given same createdAt records, ledger list falls back to receipt id ordering", async () => { + const ledger = createMemoryReceiptLedger({ clock: () => 10 }); + + await ledger.append(makeReceipt("receipt-b")); + await ledger.append(makeReceipt("receipt-a")); + + assert.deepEqual((await ledger.list()).map((record) => record.receiptId), ["receipt-a", "receipt-b"]); +}); + +test("Given invalid memory ledger retention, constructor rejects the contract", () => { + assert.throws(() => createMemoryReceiptLedger({ maxReceipts: 0 }), /maxReceipts/); +}); + +test("Given file ledger, receipts persist across ledger instances", async () => { + const dir = await mkdtemp(join(tmpdir(), "workit-ledger-")); + try { + const receipt = makeReceipt("receipt-file"); + const firstLedger = createFileReceiptLedger({ dir, clock: () => 10 }); + const firstRecord = await firstLedger.append(receipt); + + const secondLedger = createFileReceiptLedger({ dir }); + const restored = await secondLedger.get("receipt-file"); + const listed = await secondLedger.list(); + + assert.equal(restored.receiptId, "receipt-file"); + assert.equal(listed.length, 1); + assert.equal(listed[0].checksum, firstRecord.checksum); + assert.equal(firstRecord.storedAt, 10); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("Given file ledger existing receipt, append is idempotent and conflicts are rejected", async () => { + const dir = await mkdtemp(join(tmpdir(), "workit-ledger-existing-")); + try { + const ledger = createFileReceiptLedger({ dir }); + const receipt = makeReceipt("receipt-existing", 1); + const first = await ledger.append(receipt); + const second = await ledger.append(receipt); + + assert.equal(first.checksum, second.checksum); + await assert.rejects( + ledger.append(makeReceipt("receipt-existing", 2)), + ReceiptLedgerConflictError, + ); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("Given file ledger directory noise and missing ids, list ignores non-json and get returns undefined", async () => { + const dir = await mkdtemp(join(tmpdir(), "workit-ledger-noise-")); + try { + const ledger = createFileReceiptLedger({ dir }); + await writeFile(join(dir, "ignore.txt"), "ignore", "utf8"); + + assert.equal(await ledger.get("missing"), undefined); + assert.deepEqual(await ledger.list(), []); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("Given malformed stored receipt file, file ledger surfaces parse failure", async () => { + const dir = await mkdtemp(join(tmpdir(), "workit-ledger-malformed-")); + try { + const ledger = createFileReceiptLedger({ dir }); + await writeFile(join(dir, `${Buffer.from("broken", "utf8").toString("base64url")}.json`), "{", "utf8"); + + await assert.rejects(ledger.get("broken"), SyntaxError); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("Given the root import, ledger helpers are not exported from the root runtime", async () => { + const root = await import("../../dist/index.js"); + + assert.equal("createMemoryReceiptLedger" in root, false); + assert.equal("createFileReceiptLedger" in root, false); +}); diff --git a/packages/core/tests/unit/otel.test.js b/packages/core/tests/unit/otel.test.js index 987ce12..6b3c070 100644 --- a/packages/core/tests/unit/otel.test.js +++ b/packages/core/tests/unit/otel.test.js @@ -8,6 +8,7 @@ import { test } from "vitest"; import assert from "node:assert/strict"; import { execFile } from "node:child_process"; +import { readFile } from "node:fs/promises"; import moduleBuiltin from "node:module"; import { promisify } from "node:util"; import { attachOpenTelemetry } from "../../dist/otel/index.js"; @@ -256,6 +257,130 @@ test("OpenTelemetry adapter explains the missing optional peer dependency", () = } }); +test("OpenTelemetry adapter defaults instrumentation version from package metadata", async () => { + const scope = createScopeHarness(); + const fake = createFakeOtel(); + const originalLoad = moduleBuiltin._load; + const packageJson = JSON.parse(await readFile(new URL("../../package.json", import.meta.url), "utf8")); + const calls = []; + + moduleBuiltin._load = function load(request, parent, isMain) { + if (request === "@opentelemetry/api") { + return { + trace: { + getTracer(name, version) { + calls.push({ kind: "tracer", name, version }); + return fake.tracer; + }, + }, + metrics: { + getMeter(name, version) { + calls.push({ kind: "meter", name, version }); + return fake.meter; + }, + }, + }; + } + return originalLoad.call(this, request, parent, isMain); + }; + + try { + const attachment = attachOpenTelemetry(scope); + attachment.unsubscribe(); + } finally { + moduleBuiltin._load = originalLoad; + } + + assert.deepEqual(calls, [ + { kind: "tracer", name: "workit", version: packageJson.version }, + { kind: "meter", name: "workit", version: packageJson.version }, + ]); +}); + +test("OpenTelemetry adapter tolerates missing package metadata version", () => { + const scope = createScopeHarness(); + const fake = createFakeOtel(); + const originalLoad = moduleBuiltin._load; + const calls = []; + + moduleBuiltin._load = function load(request, parent, isMain) { + if (request === "../../package.json") { + const err = new Error("Cannot find module '../../package.json'"); + err.code = "MODULE_NOT_FOUND"; + throw err; + } + if (request === "@opentelemetry/api") { + return { + trace: { + getTracer(name, version) { + calls.push({ kind: "tracer", name, version }); + return fake.tracer; + }, + }, + metrics: { + getMeter(name, version) { + calls.push({ kind: "meter", name, version }); + return fake.meter; + }, + }, + }; + } + return originalLoad.call(this, request, parent, isMain); + }; + + try { + const attachment = attachOpenTelemetry(scope); + attachment.unsubscribe(); + } finally { + moduleBuiltin._load = originalLoad; + } + + assert.deepEqual(calls, [ + { kind: "tracer", name: "workit", version: undefined }, + { kind: "meter", name: "workit", version: undefined }, + ]); +}); + +test("OpenTelemetry adapter ignores invalid package metadata version", () => { + const scope = createScopeHarness(); + const fake = createFakeOtel(); + const originalLoad = moduleBuiltin._load; + const calls = []; + + moduleBuiltin._load = function load(request, parent, isMain) { + if (request === "../../package.json") return { version: 42 }; + if (request === "@opentelemetry/api") { + return { + trace: { + getTracer(name, version) { + calls.push({ kind: "tracer", name, version }); + return fake.tracer; + }, + }, + metrics: { + getMeter(name, version) { + calls.push({ kind: "meter", name, version }); + return fake.meter; + }, + }, + }; + } + return originalLoad.call(this, request, parent, isMain); + }; + + try { + const attachment = attachOpenTelemetry(scope); + attachment.unsubscribe(); + } finally { + moduleBuiltin._load = originalLoad; + } + + assert.deepEqual(calls, [ + { kind: "tracer", name: "workit", version: undefined }, + { kind: "meter", name: "workit", version: undefined }, + ]); +}); + test("OpenTelemetry adapter can be imported from node eval", async () => { const { stdout } = await execFileAsync(process.execPath, [ "-e", diff --git a/packages/core/tests/unit/replay.test.js b/packages/core/tests/unit/replay.test.js new file mode 100644 index 0000000..d25636f --- /dev/null +++ b/packages/core/tests/unit/replay.test.js @@ -0,0 +1,453 @@ +/** + * Replay receipt subpath tests. + * + * @author Admilson B. F. Cossa + * SPDX-License-Identifier: Apache-2.0 + */ + +import { test } from "vitest"; +import assert from "node:assert/strict"; +import { CancellationError, run } from "../../dist/index.js"; +import { buildReceipt, createReceiptRecorder, redactReceipt } from "../../dist/replay/index.js"; + +const sleep = (ms, signal) => + new Promise((resolve, reject) => { + if (signal?.aborted) return reject(signal.reason); + const timer = setTimeout(resolve, ms); + signal?.addEventListener("abort", () => { + clearTimeout(timer); + reject(signal.reason); + }, { once: true }); + }); + +test("Given a completed scope, buildReceipt records terminal lifecycle evidence", async () => { + let scopeRef; + let recorder; + + await run.scope(async (scope) => { + scopeRef = scope; + recorder = createReceiptRecorder(scope, { + clock: () => 10_000, + receiptId: "receipt-completed", + }); + + await scope.spawn(async (ctx) => { + ctx.report({ message: "phase", data: { step: "query" } }); + return "ok"; + }, { name: "receipt.query", kind: "io" }); + }, { name: "receipt-root" }); + + const receipt = recorder.build(scopeRef.status()); + + assert.equal(receipt.version, "workit.receipt.v1"); + assert.equal(receipt.receiptId, "receipt-completed"); + assert.equal(receipt.rootScopeId, scopeRef.id); + assert.equal(receipt.terminal.outcome, "completed"); + assert.equal(receipt.summary.leakedTasks, 0); + assert.equal(receipt.summary.retryEvents, 0); + assert.ok(receipt.events.some((event) => event.type === "task:started" && event.name === "receipt.query")); + assert.ok(receipt.events.some((event) => event.type === "scope:closed")); +}); + +test("Given a cancelled scope, buildReceipt preserves the typed cancellation reason", async () => { + let scopeRef; + let recorder; + + await assert.rejects( + run.scope(async (scope) => { + scopeRef = scope; + recorder = createReceiptRecorder(scope, { receiptId: "receipt-cancelled" }); + const handle = scope.spawn(async (ctx) => { + await sleep(5_000, ctx.signal); + }, { name: "wait-for-cancel", kind: "io" }); + scope.cancel({ kind: "manual", tag: "manual_stop" }); + await handle; + }, { name: "cancel-root" }), + CancellationError, + ); + + const receipt = recorder.build(scopeRef.status()); + + assert.equal(receipt.terminal.outcome, "cancelled"); + assert.deepEqual(receipt.terminal.cancelReason, { kind: "manual", tag: "manual_stop" }); + assert.equal(receipt.summary.cancelledTasks, 0); + assert.equal(receipt.events.some((event) => + event.type === "task:cancelled" + && event.reason?.kind === "manual" + && event.reason.tag === "manual_stop" + ), true); +}); + +test("Given cleanup timeout events, buildReceipt records safe cleanup evidence", async () => { + let scopeRef; + let recorder; + + await run.scope(async (scope) => { + scopeRef = scope; + recorder = createReceiptRecorder(scope, { receiptId: "receipt-cleanup" }); + await scope.spawn(run.bracket( + async () => "resource", + async () => "used", + async () => new Promise(() => undefined), + { timeout: 5 }, + ), { name: "cleanup.boundary", kind: "custom" }); + }, { name: "cleanup-root" }); + + const receipt = recorder.build(scopeRef.status()); + + assert.equal(receipt.terminal.outcome, "completed"); + assert.equal(receipt.summary.cleanupTimeouts, 1); + assert.equal(receipt.summary.cleanupFailures, 0); + assert.ok(receipt.events.some((event) => event.type === "task:cleanup_timeout" && event.timeoutMs === 5)); +}); + +test("Given redaction policy, private receipt fields are removed or redacted", () => { + const receipt = buildReceipt([ + { + type: "task:progress", + taskId: "task-private", + data: { + privateNote: "remove me", + token: "secret-token", + nested: { authorization: "bearer secret", safe: "ok" }, + }, + at: 1, + }, + ], { + id: "scope-private", + status: "closed", + startedAt: 1, + pendingCount: 0, + completedCount: 1, + failedCount: 0, + cancelledCount: 0, + tasks: [ + { + id: "task-private", + name: "private-task", + kind: "custom", + status: "succeeded", + attempt: 1, + startedAt: 1, + meta: { privateNote: "remove me", safe: "ok" }, + }, + ], + scopes: [], + }, { + clock: () => 2, + receiptId: "receipt-private", + }); + + const redacted = redactReceipt(receipt, { + removeFields: ["privateNote"], + redactFields: ["token", "authorization"], + }); + const text = JSON.stringify(redacted); + + assert.equal(text.includes("remove me"), false); + assert.equal(text.includes("secret-token"), false); + assert.equal(text.includes("bearer secret"), false); + assert.equal(text.includes("[redacted]"), true); + assert.equal(text.includes("\"safe\":\"ok\""), true); +}); + +test("Given every event family, buildReceipt normalizes receipt evidence", () => { + const circular = {}; + circular.self = circular; + const receipt = buildReceipt([ + { type: "scope:opened", scopeId: "scope-events", parentId: null, at: 1 }, + { type: "task:started", taskId: "task-a", scopeId: "scope-events", name: "a", kind: "custom", at: 2 }, + { type: "task:retrying", taskId: "task-a", attempt: 2, error: "retry", nextDelayMs: 5, at: 3 }, + { type: "task:progress", taskId: "task-a", pct: 50, message: "half", data: ["safe"], at: 4 }, + { type: "task:cleanup_failed", taskId: "task-a", error: circular, at: 5 }, + { type: "task:cleanup_timeout", taskId: "task-a", timeoutMs: 6, at: 6 }, + { type: "task:failed", taskId: "task-a", error: new Error("failed"), durationMs: 7, at: 7 }, + { type: "scope:cleanup_failed", scopeId: "scope-events", error: new Error("scope cleanup"), at: 8 }, + { type: "scope:cleanup_timeout", scopeId: "scope-events", timeoutMs: 9, at: 9 }, + { type: "scope:closing", scopeId: "scope-events", reason: "errored", at: 10 }, + { type: "scope:closed", scopeId: "scope-events", durationMs: 11, droppedTelemetryEvents: 2, at: 11 }, + ], { + id: "scope-events", + name: "event-root", + status: "closed", + startedAt: 1, + pendingCount: 0, + completedCount: 0, + failedCount: 1, + cancelledCount: 0, + tasks: [], + scopes: [ + { + id: "scope-child", + status: "running", + startedAt: 2, + pendingCount: 1, + completedCount: 0, + failedCount: 0, + cancelledCount: 0, + tasks: [], + scopes: [], + }, + ], + }, { + clock: () => 12, + receiptId: "receipt-events", + limitations: ["bounded_event_window"], + }); + + assert.equal(receipt.rootScopeName, "event-root"); + assert.equal(receipt.terminal.outcome, "failed"); + assert.equal(receipt.terminal.error.message, "scope cleanup"); + assert.equal(receipt.summary.cleanupFailures, 2); + assert.equal(receipt.summary.cleanupTimeouts, 2); + assert.equal(receipt.summary.retryEvents, 1); + assert.equal(receipt.summary.droppedTelemetryEvents, 2); + assert.equal(receipt.summary.totalScopes, 2); + assert.equal(receipt.summary.pendingScopes, 1); + assert.equal(receipt.limitations.includes("bounded_event_window"), true); + assert.ok(receipt.events.some((event) => event.type === "task:cleanup_failed" && event.error.message === "[object Object]")); +}); + +test("Given sparse optional event fields, buildReceipt omits absent fields safely", () => { + const receipt = buildReceipt([ + { type: "task:progress", taskId: "task-progress", at: 1 }, + { type: "task:failed", taskId: "task-failed", error: undefined, durationMs: 1, at: 2 }, + { type: "scope:closing", scopeId: "scope-sparse", reason: "cancelled", at: 3 }, + { type: "scope:closed", scopeId: "scope-sparse", durationMs: 4, at: 4 }, + ], { + id: "scope-sparse", + status: "closed", + startedAt: 1, + pendingCount: 0, + completedCount: 0, + failedCount: 0, + cancelledCount: 0, + tasks: [], + scopes: [], + }, { + receiptId: "receipt-sparse", + }); + + const progress = receipt.events.find((event) => event.type === "task:progress"); + assert.equal("pct" in progress, false); + assert.equal("message" in progress, false); + assert.equal("data" in progress, false); + assert.equal(receipt.terminal.outcome, "cancelled"); + assert.equal(receipt.terminal.cancelReason, undefined); + assert.equal(receipt.events.some((event) => event.type === "scope:closed" && event.droppedTelemetryEvents === undefined), true); + assert.ok(receipt.events.some((event) => event.type === "task:failed" && event.error.message === "undefined")); +}); + +test("Given a running snapshot, buildReceipt records a non-terminal receipt", () => { + const receipt = buildReceipt([], { + id: "scope-running", + status: "running", + startedAt: 1, + pendingCount: 1, + completedCount: 0, + failedCount: 0, + cancelledCount: 0, + tasks: [], + scopes: [], + }, { + receiptId: "receipt-running", + }); + + assert.equal(receipt.terminal.outcome, "running"); + assert.equal(receipt.summary.pendingScopes, 1); + assert.equal(receipt.summary.leakedTasks, 1); +}); + +test("Given a failed snapshot without failure event detail, buildReceipt records bounded terminal evidence", () => { + const receipt = buildReceipt([ + { type: "scope:closing", scopeId: "scope-failed-no-error", reason: "errored", at: 1 }, + ], { + id: "scope-failed-no-error", + status: "closed", + startedAt: 1, + pendingCount: 0, + completedCount: 0, + failedCount: 1, + cancelledCount: 0, + tasks: [], + scopes: [], + }, { + receiptId: "receipt-failed-no-error", + }); + + assert.equal(receipt.terminal.outcome, "failed"); + assert.equal("error" in receipt.terminal, false); +}); + +test("Given recorder max event window, createReceiptRecorder tracks dropped events", async () => { + let scopeRef; + let recorder; + + await run.scope(async (scope) => { + scopeRef = scope; + recorder = createReceiptRecorder(scope, { maxEvents: 1, receiptId: "receipt-dropped" }); + await scope.spawn(async (ctx) => { + ctx.report({ message: "one" }); + ctx.report({ message: "two" }); + }); + }); + + const receipt = recorder.build(scopeRef.status()); + + assert.equal(recorder.events.length, 1); + assert.ok(recorder.droppedEvents > 0); + assert.ok(receipt.summary.droppedEvents > 0); + assert.equal(receipt.limitations.includes("receipt_event_window_truncated"), true); + recorder.unsubscribe(); +}); + +test("Given invalid recorder event window, createReceiptRecorder rejects the contract", async () => { + await run.scope(async (scope) => { + assert.throws(() => createReceiptRecorder(scope, { maxEvents: 0 }), /maxEvents/); + }); +}); + +test("Given cancellation reasons with nested errors, buildReceipt serializes safe reason evidence", () => { + const receipt = buildReceipt([ + { + type: "task:cancelled", + taskId: "task-parent", + reason: { kind: "parent_failed", error: new Error("parent failed") }, + durationMs: 1, + at: 1, + }, + { + type: "task:cancelled", + taskId: "task-sibling", + reason: { kind: "sibling_failed", siblingId: "task-parent", error: "sibling failed" }, + durationMs: 1, + at: 2, + }, + { + type: "task:cancelled", + taskId: "task-manual", + reason: { kind: "manual", tag: "manual", data: { token: "secret" } }, + durationMs: 1, + at: 3, + }, + { type: "scope:closing", scopeId: "scope-cancel-reasons", reason: "cancelled", at: 4 }, + ], { + id: "scope-cancel-reasons", + status: "closed", + startedAt: 1, + pendingCount: 0, + completedCount: 0, + failedCount: 0, + cancelledCount: 3, + tasks: [], + scopes: [], + }); + + assert.equal(receipt.terminal.outcome, "cancelled"); + assert.equal(receipt.terminal.cancelReason.kind, "manual"); + assert.equal(JSON.stringify(receipt).includes("secret"), false); + assert.ok(receipt.events.some((event) => + event.type === "task:cancelled" + && event.reason.kind === "parent_failed" + && event.reason.error.message === "parent failed" + )); +}); + +test("Given non-manual cancellation reason, buildReceipt preserves the reason unchanged", () => { + const receipt = buildReceipt([ + { + type: "task:cancelled", + taskId: "task-budget", + reason: { kind: "budget", budgetKey: "tokens", limit: 1, spent: 2 }, + durationMs: 1, + at: 1, + }, + { type: "scope:closing", scopeId: "scope-budget", reason: "cancelled", at: 2 }, + ], { + id: "scope-budget", + status: "closed", + startedAt: 1, + pendingCount: 0, + completedCount: 0, + failedCount: 0, + cancelledCount: 1, + tasks: [], + scopes: [], + }); + + assert.deepEqual(receipt.terminal.cancelReason, { kind: "budget", budgetKey: "tokens", limit: 1, spent: 2 }); +}); + +test("Given deep data and Error values, redactReceipt bounds nested evidence", () => { + const receipt = buildReceipt([], { + id: "scope-redact-depth", + status: "closed", + startedAt: 1, + pendingCount: 0, + completedCount: 0, + failedCount: 0, + cancelledCount: 0, + tasks: [], + scopes: [], + }, { + receiptId: "receipt-redact-depth", + }); + const withExtra = { + ...receipt, + events: [ + { + type: "task:progress", + taskId: "task-error", + at: 1, + data: { + error: new Error("nested"), + deep: { one: { two: { three: "hidden" } } }, + }, + }, + ], + }; + + const redacted = redactReceipt(withExtra, { maxDepth: 2 }); + const text = JSON.stringify(redacted); + + assert.equal(text.includes("[max-depth]"), true); + assert.equal(text.includes("nested"), false); +}); + +test("Given direct Error values, redactReceipt normalizes safe error evidence", () => { + const receipt = buildReceipt([], { + id: "scope-redact-error", + status: "closed", + startedAt: 1, + pendingCount: 0, + completedCount: 0, + failedCount: 0, + cancelledCount: 0, + tasks: [], + scopes: [], + }, { + receiptId: "receipt-redact-error", + }); + const redacted = redactReceipt({ + ...receipt, + events: [ + { + type: "task:progress", + taskId: "task-error", + at: 1, + data: new Error("direct error"), + }, + ], + }); + + assert.equal(redacted.events[0].data.name, "Error"); + assert.equal(redacted.events[0].data.message, "direct error"); +}); + +test("Given the root import, replay helpers are not exported from the root runtime", async () => { + const root = await import("../../dist/index.js"); + + assert.equal("buildReceipt" in root, false); + assert.equal("createReceiptRecorder" in root, false); + assert.equal("redactReceipt" in root, false); +}); diff --git a/packages/core/tests/unit/resources.test.js b/packages/core/tests/unit/resources.test.js new file mode 100644 index 0000000..e564994 --- /dev/null +++ b/packages/core/tests/unit/resources.test.js @@ -0,0 +1,367 @@ +/** + * Resource helper subpath tests. + * + * @author Admilson B. F. Cossa + * SPDX-License-Identifier: Apache-2.0 + */ + +import { test } from "vitest"; +import assert from "node:assert/strict"; +import { CancellationError, run } from "../../dist/index.js"; +import { bracketLazy, bracketShared, scopeAcquire } from "../../dist/resources/index.js"; + +test("Given lazy resource unused, acquire and release are not called", async () => { + let acquired = 0; + let released = 0; + + const task = bracketLazy( + async () => { + acquired++; + return "resource"; + }, + async (resource) => { + assert.equal(resource.acquired(), false); + return "unused"; + }, + async () => { + released++; + }, + ); + + const result = await run.scope(async (scope) => await scope.spawn(task)); + + assert.equal(result, "unused"); + assert.equal(acquired, 0); + assert.equal(released, 0); +}); + +test("Given lazy resource used, release runs once with cleanup context", async () => { + let acquired = 0; + let released = 0; + let cleanupTimeout = 0; + let cleanupSignalSeen = false; + + const task = bracketLazy( + async () => { + acquired++; + return { id: "lazy" }; + }, + async (resource) => { + const value = await resource.get(); + assert.equal(resource.acquired(), true); + return value.id; + }, + async (resource, ctx) => { + released++; + cleanupTimeout = ctx.timeoutMs; + cleanupSignalSeen = ctx.signal instanceof AbortSignal; + assert.equal(resource.id, "lazy"); + }, + { timeout: 25 }, + ); + + const result = await run.scope(async (scope) => await scope.spawn(task)); + + assert.equal(result, "lazy"); + assert.equal(acquired, 1); + assert.equal(released, 1); + assert.equal(cleanupTimeout, 25); + assert.equal(cleanupSignalSeen, true); +}); + +test("Given lazy resource requested concurrently, acquire runs once and cached get returns same resource", async () => { + let acquired = 0; + + const task = bracketLazy( + async () => { + acquired++; + await sleep(5); + return { id: acquired }; + }, + async (resource) => { + const [first, second] = await Promise.all([resource.get(), resource.get()]); + const third = await resource.get(); + return [first.id, second.id, third.id]; + }, + async () => undefined, + ); + + const result = await run.scope(async (scope) => await scope.spawn(task)); + + assert.deepEqual(result, [1, 1, 1]); + assert.equal(acquired, 1); +}); + +test("Given lazy acquire failure, bracketLazy propagates the acquire error and does not release", async () => { + let released = 0; + const task = bracketLazy( + async () => { + throw new Error("lazy acquire failed"); + }, + async (resource) => await resource.get(), + async () => { + released++; + }, + ); + + await assert.rejects( + run.scope(async (scope) => await scope.spawn(task)), + /lazy acquire failed/, + ); + assert.equal(released, 0); +}); + +test("Given lazy resource used then cancelled, task cleanup still releases the resource", async () => { + let released = 0; + const task = bracketLazy( + async () => ({ id: "lazy-cancel" }), + async (resource, ctx) => { + await resource.get(); + ctx.scope.cancel({ kind: "manual", tag: "lazy_cancel" }); + await sleep(1_000, ctx.signal); + }, + async (resource) => { + released++; + assert.equal(resource.id, "lazy-cancel"); + }, + ); + + await assert.rejects( + run.scope(async (scope) => { + await scope.spawn(task, { name: "lazy.cancel" }); + }), + CancellationError, + ); + + assert.equal(released, 1); +}); + +test("Given lazy cleanup timeout, existing event surface records task cleanup timeout", async () => { + const events = []; + const task = bracketLazy( + async () => "lazy-timeout", + async (resource) => { + await resource.get(); + return "used"; + }, + async () => new Promise(() => undefined), + { timeout: 5 }, + ); + + await run.scope(async (scope) => { + scope.onEvent((event) => events.push(event)); + return await scope.spawn(task, { name: "lazy.timeout" }); + }); + + assert.equal(events.some((event) => event.type === "task:cleanup_timeout" && event.timeoutMs === 5), true); +}); + +test("Given lazy cleanup failure, existing event surface records task cleanup failure", async () => { + const events = []; + const task = bracketLazy( + async () => "lazy-failure", + async (resource) => { + await resource.get(); + return "used"; + }, + async () => { + throw new Error("release failed"); + }, + ); + + await run.scope(async (scope) => { + scope.onEvent((event) => events.push(event)); + return await scope.spawn(task, { name: "lazy.failure" }); + }); + + assert.equal(events.some((event) => event.type === "task:cleanup_failed"), true); +}); + +test("Given shared resource used by parallel tasks, acquire and release run once", async () => { + let acquired = 0; + let released = 0; + const shared = bracketShared( + async () => { + acquired++; + return { id: acquired }; + }, + async (resource) => { + await sleep(5); + return resource.id; + }, + async () => { + released++; + }, + ); + + const values = await run.scope(async (scope) => { + const first = scope.spawn(shared, { name: "shared.first" }); + const second = scope.spawn(shared, { name: "shared.second" }); + return await Promise.all([first, second]); + }); + + assert.deepEqual(values, [1, 1]); + assert.equal(acquired, 1); + assert.equal(released, 1); +}); + +test("Given shared resource reused sequentially in one scope, cached resource is returned", async () => { + let acquired = 0; + const shared = bracketShared( + async () => { + acquired++; + return { id: acquired }; + }, + async (resource) => resource.id, + async () => undefined, + ); + + const values = await run.scope(async (scope) => { + const first = await scope.spawn(shared); + const second = await scope.spawn(shared); + return [first, second]; + }); + + assert.deepEqual(values, [1, 1]); + assert.equal(acquired, 1); +}); + +test("Given shared helper used in separate scopes, each scope owns its own resource", async () => { + let acquired = 0; + let released = 0; + const shared = bracketShared( + async () => { + acquired++; + return { id: acquired }; + }, + async (resource) => resource.id, + async () => { + released++; + }, + ); + + const first = await run.scope(async (scope) => await scope.spawn(shared)); + const second = await run.scope(async (scope) => await scope.spawn(shared)); + + assert.equal(first, 1); + assert.equal(second, 2); + assert.equal(acquired, 2); + assert.equal(released, 2); +}); + +test("Given shared acquire failure, bracketShared propagates failure and a fresh scope can acquire", async () => { + let attempts = 0; + let bodyRuns = 0; + const shared = bracketShared( + async () => { + attempts++; + if (attempts === 1) throw new Error("shared acquire failed"); + return { id: attempts }; + }, + async (resource) => { + bodyRuns++; + return resource.id; + }, + async () => undefined, + ); + + await assert.rejects( + run.scope(async (scope) => { + await scope.spawn(shared); + }), + /shared acquire failed/, + ); + const value = await run.scope(async (scope) => await scope.spawn(shared)); + + assert.equal(value, 2); + assert.equal(attempts, 2); + assert.equal(bodyRuns, 1); +}); + +test("Given cancellation, shared resource release still runs through scope cleanup", async () => { + let released = 0; + const shared = bracketShared( + async () => ({ id: "shared" }), + async (_resource, ctx) => { + ctx.scope.cancel({ kind: "manual", tag: "resource_cancel" }); + await sleep(1_000, ctx.signal); + }, + async () => { + released++; + }, + ); + + await assert.rejects( + run.scope(async (scope) => { + await scope.spawn(shared, { name: "shared.cancel" }); + }), + CancellationError, + ); + + assert.equal(released, 1); +}); + +test("Given scopeAcquire, resource is returned and scope cleanup runs in LIFO order", async () => { + const released = []; + const firstResource = { id: "first" }; + const secondResource = { id: "second" }; + + const result = await run.scope(async (scope) => { + const first = scopeAcquire(scope, firstResource, async (resource) => { + released.push(resource.id); + }); + const second = scopeAcquire(scope, secondResource, async (resource) => { + released.push(resource.id); + }); + + assert.equal(first, firstResource); + assert.equal(second, secondResource); + return "registered"; + }); + + assert.equal(result, "registered"); + assert.deepEqual(released, ["second", "first"]); +}); + +test("Given scopeAcquire release timeout, existing event surface records cleanup timeout", async () => { + const events = []; + + await run.scope(async (scope) => { + scope.onEvent((event) => events.push(event)); + scopeAcquire(scope, "resource", async () => new Promise(() => undefined), { timeout: 5 }); + }); + + assert.equal(events.some((event) => event.type === "scope:cleanup_timeout" && event.timeoutMs === 5), true); +}); + +test("Given scopeAcquire release failure, existing event surface records cleanup failure", async () => { + const events = []; + + await run.scope(async (scope) => { + scope.onEvent((event) => events.push(event)); + scopeAcquire(scope, "resource", async () => { + throw new Error("scope release failed"); + }); + }); + + assert.equal(events.some((event) => event.type === "scope:cleanup_failed"), true); +}); + +test("Given the root import, resource helpers are not exported from the root runtime", async () => { + const root = await import("../../dist/index.js"); + + assert.equal("bracketLazy" in root, false); + assert.equal("bracketShared" in root, false); + assert.equal("scopeAcquire" in root, false); +}); + +function sleep(ms, signal) { + if (signal?.aborted === true) return Promise.reject(signal.reason); + return new Promise((resolve, reject) => { + const timer = setTimeout(resolve, ms); + signal?.addEventListener("abort", () => { + clearTimeout(timer); + reject(signal.reason); + }, { once: true }); + }); +}