From f9bb4be8bbfca48ce8bd236f9db15d23534f8071 Mon Sep 17 00:00:00 2001 From: acossa Date: Sun, 28 Jun 2026 19:03:46 +0200 Subject: [PATCH] Prepare @workit/core 0.4.0 contract hardening release Add explicit contracts and fault subpaths, harden agent authority and receipt ledger evidence, update package-consumer coverage, and bump the package to 0.4.0 without changing the root runtime import. --- CHANGELOG.md | 23 + README.md | 4 +- package-lock.json | 6 +- package.json | 2 +- packages/core/README.md | 54 +- packages/core/evidence/claims.json | 66 +- packages/core/package.json | 15 +- packages/core/scripts/build-cjs.mjs | 2 + packages/core/scripts/check-api-surface.mjs | 29 + .../core/scripts/check-package-consumer.mjs | 514 ++++++++++----- packages/core/src/ai/index.ts | 89 ++- packages/core/src/analysis/index.ts | 52 +- packages/core/src/contracts/index.ts | 125 ++++ packages/core/src/fault/index.ts | 590 ++++++++++++++++++ packages/core/src/ledger/index.ts | 222 ++++++- .../evidence/correctness/agent-authority.mjs | 48 ++ .../evidence/correctness/fault-injection.mjs | 54 ++ .../typed-cancellation-contracts.mjs | 59 ++ .../release/sql-ledger-integration.mjs | 141 +++++ .../evidence/release/sql-receipt-ledger.mjs | 129 ++++ packages/core/tests/evidence/run-all.mjs | 5 + packages/core/tests/types/contracts.ts | 52 ++ packages/core/tests/unit/ai.test.js | 76 +++ packages/core/tests/unit/analysis.test.js | 56 ++ .../core/tests/unit/contracts-subpath.test.js | 91 +++ packages/core/tests/unit/fault.test.js | 177 ++++++ packages/core/tests/unit/ledger.test.js | 287 ++++++++- 27 files changed, 2775 insertions(+), 193 deletions(-) create mode 100644 packages/core/src/contracts/index.ts create mode 100644 packages/core/src/fault/index.ts create mode 100644 packages/core/tests/evidence/correctness/agent-authority.mjs create mode 100644 packages/core/tests/evidence/correctness/fault-injection.mjs create mode 100644 packages/core/tests/evidence/correctness/typed-cancellation-contracts.mjs create mode 100644 packages/core/tests/evidence/release/sql-ledger-integration.mjs create mode 100644 packages/core/tests/evidence/release/sql-receipt-ledger.mjs create mode 100644 packages/core/tests/types/contracts.ts create mode 100644 packages/core/tests/unit/contracts-subpath.test.js create mode 100644 packages/core/tests/unit/fault.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e15e7e..3630841 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,29 @@ SPDX-License-Identifier: Apache-2.0 ## Unreleased +## 0.4.0 + +Add runtime contract and evidence hardening behind explicit subpaths. The root +import remains unchanged. + +- Add `@workit/core/contracts` for declared cancellable and shielded task + composition. This is a compile-time intent contract; it does not prove that + arbitrary task bodies observe `ctx.signal`. +- Add `@workit/core/fault` for bounded in-process lifecycle fault scenarios + over the real WorkIt scope engine. This is not OS fault injection, process + crash recovery, network chaos testing, or deterministic scheduler replay. +- Add declared agent tool capability checks to `@workit/core/ai`, including a + typed `AgentCapabilityError` and denial events before denied tool bodies run. +- Extend `@workit/core/ledger` with caller-owned SQLite and Postgres receipt + ledger adapters. These adapters define a receipt storage contract; they do + not make WorkIt a database framework. +- Add executable evidence for typed cancellation contracts, bounded fault + scenarios, agent authority, SQL receipt ledgers, and resource audit visibility. +- Extend package-consumer coverage for the new subpaths across ESM, CommonJS, + strict TypeScript, framework fixtures, and browser/worker unsupported-runtime + checks. +- Keep the root `@workit/core` import and bundle budget unchanged. + ## 0.3.0 Add the `@workit/core/time-policy` planning subpath for declared async time diff --git a/README.md b/README.md index d53369b..fc90a20 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.3.0}, + version = {0.4.0}, license = {Apache-2.0} } ``` @@ -95,7 +95,9 @@ Stable consumer paths for this release line: @workit/core/ai @workit/core/analysis @workit/core/channel +@workit/core/contracts @workit/core/diagnostics +@workit/core/fault @workit/core/ledger @workit/core/observability @workit/core/otel diff --git a/package-lock.json b/package-lock.json index 5251097..0efdd94 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "workit", - "version": "0.3.0", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "workit", - "version": "0.3.0", + "version": "0.4.0", "license": "Apache-2.0", "workspaces": [ "packages/core" @@ -3453,7 +3453,7 @@ }, "packages/core": { "name": "@workit/core", - "version": "0.3.0", + "version": "0.4.0", "license": "Apache-2.0", "devDependencies": { "@opentelemetry/api": "1.9.1", diff --git a/package.json b/package.json index 9b18ac5..1aae669 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "workit", - "version": "0.3.0", + "version": "0.4.0", "private": true, "description": "WorkIt monorepo.", "type": "module", diff --git a/packages/core/README.md b/packages/core/README.md index 715ddb0..84f5fc0 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -240,7 +240,7 @@ Rules: | Worker-thread hard boundary | `@workit/core/worker` | | Diagnostics and snapshots | `@workit/core/diagnostics` | | OpenTelemetry bridge | `@workit/core/otel` | -| Agent helper contracts | `@workit/core/ai` | +| Agent helper and declared tool authority contracts | `@workit/core/ai` | ### Task Functions And Invocation @@ -286,11 +286,13 @@ 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 | +| Persist receipts in caller-owned stores | `@workit/core/ledger` | memory, file, and caller-owned SQL 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 | | Plan declared retry, hedge, timeout, deadline, series, and parallel time bounds | `@workit/core/time-policy` | conservative planning over declared policies, not wall-clock execution proof | +| Declare cancellable and shielded task intent | `@workit/core/contracts` | compile-time composition contract, not proof of task-body cooperation | +| Run bounded lifecycle fault scenarios | `@workit/core/fault` | in-process evidence harness, not OS/process/network fault injection | ### Time Policy Planning @@ -321,6 +323,48 @@ The planner does not execute JavaScript, inspect provider latency, or guarantee operating-system timer precision. Runtime cancellation still depends on task bodies and providers observing `ctx.signal`. +### Typed Cancellation Contracts + +`@workit/core/contracts` adds typed composition boundaries for codebases that +want task cancellation intent to be explicit at review time. + +```ts +import { cancellable, shielded, typedGroup } from "@workit/core/contracts"; + +const value = await typedGroup(async (task) => { + const child = task(cancellable(async (ctx) => { + ctx.signal.throwIfAborted(); + return "value"; + })); + const flush = task.shielded(shielded(async () => undefined, { timeout: "250ms" })); + await flush; + return await child; +}); +``` + +This is a TypeScript contract over declared task intent. It does not prove that +an arbitrary task body checks `ctx.signal` or that a provider stops after the +signal is aborted. + +### Bounded Fault Evidence + +`@workit/core/fault` runs explicit lifecycle scenarios through the real WorkIt +scope engine and returns receipts plus verifier output. + +```ts +import { cleanupHang, runFaultScenario } from "@workit/core/fault"; + +const report = await runFaultScenario(cleanupHang({ cleanupTimeout: "10ms" })); + +if (report.status !== "pass") { + throw new Error(JSON.stringify(report.findings)); +} +``` + +The harness is for in-process lifecycle evidence such as cancellation storms, +cleanup timeouts, provider timeouts, and retry exhaustion. It is not an OS +chaos tool, process supervisor, distributed fault injector, or security sandbox. + ## Common Use Cases These are short entry points. The full narrative and benchmark discussion live @@ -485,9 +529,9 @@ thresholds, not exact milliseconds. | Evidence | Current result | |---|---:| -| Unit tests | 319 passing | +| Unit tests | 354 passing | | Coverage gate | 100% statements, branches, functions, lines | -| Evidence proof files | 17 passing | +| Evidence proof files | 22 passing | | Runtime dependencies | 0 | | Article benchmark suite | 19/19 passing | | Core group import | 14,175 B minified / 4,835 B gzip | @@ -666,7 +710,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.3.0}, + version = {0.4.0}, license = {Apache-2.0} } ``` diff --git a/packages/core/evidence/claims.json b/packages/core/evidence/claims.json index 2c05f71..7b06ca0 100644 --- a/packages/core/evidence/claims.json +++ b/packages/core/evidence/claims.json @@ -89,13 +89,13 @@ }, { "id": "LIFE-011", - "title": "resource ownership can be audited across cleanup paths", + "title": "resource cleanup audit records success, failure, and timeout 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." + "expectedInvariant": "resource audit instrumentation records acquired/released/pending resources while WorkIt emits cleanup timeout and failure events", + "limitations": "Successful resource acquisition and release entries are explicit audit instrumentation in the evidence harness. WorkIt's current first-class event surface exposes cleanup failures and timeouts, not every successful cleanup start/completion." }, { "id": "CORR-001", @@ -137,6 +137,26 @@ "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-006", + "title": "agent authority denies undeclared capability before tool execution", + "class": "correctness", + "status": "proven", + "proof": "tests/evidence/correctness/agent-authority.mjs", + "command": "npm run test:evidence", + "expectedInvariant": "denied declared tool capability emits agent:tool_denied and the tool body is never called", + "limitations": "This is declared tool policy inside runAgent, not operating-system, filesystem, network, or CPU sandboxing." + }, + { + "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-009", "title": "time-policy planner computes retry upper bounds", @@ -158,14 +178,14 @@ "limitations": "Deadline feasibility is computed from the supplied clock and policy; applications remain responsible for accurate external deadlines." }, { - "id": "CORR-007", - "title": "receipt analysis detects leaked owned work", + "id": "CORR-011", + "title": "fault injection harness records lifecycle evidence through real scopes", "class": "correctness", "status": "proven", - "proof": "tests/evidence/correctness/analysis-verifiers.mjs", + "proof": "tests/evidence/correctness/fault-injection.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." + "expectedInvariant": "bounded cancellation, cleanup timeout, provider timeout, and retry exhaustion scenarios return passing reports with WorkIt receipts", + "limitations": "This is bounded in-process fault injection through explicit scenarios; it is not deterministic replay, SIGKILL recovery, or an operating-system fault injector." }, { "id": "CORR-012", @@ -227,6 +247,16 @@ "expectedInvariant": "generated nested policies match the recursive model for upper bounds, attempts, parallel work, truncation limits, and typed timeout/deadline warnings", "limitations": "This is a bounded executable proof over generated nested policy trees. It is not a theorem over arbitrary TypeScript programs, wall-clock scheduling, provider latency, or completed mechanized proof." }, + { + "id": "CORR-023", + "title": "typed cancellation contracts reject undeclared task composition", + "class": "correctness", + "status": "proven", + "proof": "tests/evidence/correctness/typed-cancellation-contracts.mjs", + "command": "npm run test:evidence", + "expectedInvariant": "tsc accepts declared cancellable/shielded composition and rejects plain or misrouted tasks", + "limitations": "This proves an optional compile-time intent contract at the contracts subpath. It does not prove that arbitrary task bodies observe ctx.signal or change the root WorkIt API." + }, { "id": "SEC-001", "title": "worker offload rejects remote and executable URL schemes", @@ -287,6 +317,26 @@ "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": "REL-006", + "title": "SQL receipt ledger adapters preserve idempotency and conflict detection", + "class": "release", + "status": "proven", + "proof": "tests/evidence/release/sql-receipt-ledger.mjs", + "command": "npm run test:evidence", + "expectedInvariant": "SQLite and Postgres ledger ports return the same record for identical appends and reject conflicting receipt content", + "limitations": "The adapters use caller-owned database clients and validate their SQL identifier input; deployment-level durability, replication, backup, and transaction policy remain database responsibilities." + }, + { + "id": "REL-007", + "title": "SQLite receipt ledger persists through a real file-backed database", + "class": "release", + "status": "proven", + "proof": "tests/evidence/release/sql-ledger-integration.mjs", + "command": "npm run test:evidence", + "expectedInvariant": "a receipt appended through node:sqlite is readable after database close and reopen, while conflicting content is rejected", + "limitations": "This proof uses node:sqlite when the active Node runtime provides it. Postgres remains an environment-gated integration path because WorkIt does not take a runtime pg dependency." + }, { "id": "PERF-001", "title": "article benchmark suite has expected executable coverage", diff --git a/packages/core/package.json b/packages/core/package.json index 2f5b7b9..d19ccc3 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@workit/core", - "version": "0.3.0", + "version": "0.4.0", "description": "Structured concurrency runtime for TypeScript: owned async work, cancellation, budgets, retries, timeouts, worker offload, scopes.", "keywords": [ "structured-concurrency", @@ -82,6 +82,11 @@ "import": "./dist/channel/index.js", "require": "./dist-cjs/channel/index.cjs" }, + "./contracts": { + "types": "./dist/contracts/index.d.ts", + "import": "./dist/contracts/index.js", + "require": "./dist-cjs/contracts/index.cjs" + }, "./observability": { "types": "./dist/observability/index.d.ts", "import": "./dist/observability/index.js", @@ -92,6 +97,14 @@ "import": "./dist/diagnostics/index.js", "require": "./dist-cjs/diagnostics/index.cjs" }, + "./fault": { + "types": "./dist/fault/index.d.ts", + "node": { + "import": "./dist/fault/index.js", + "require": "./dist-cjs/fault/index.cjs" + }, + "default": "./dist/runtime/unsupported.js" + }, "./ledger": { "types": "./dist/ledger/index.d.ts", "node": { diff --git a/packages/core/scripts/build-cjs.mjs b/packages/core/scripts/build-cjs.mjs index 6ca0dee..c20489e 100644 --- a/packages/core/scripts/build-cjs.mjs +++ b/packages/core/scripts/build-cjs.mjs @@ -18,7 +18,9 @@ const ENTRIES = [ { 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/contracts/index.js", outfile: "dist-cjs/contracts/index.cjs" }, { entry: "dist/diagnostics/index.js", outfile: "dist-cjs/diagnostics/index.cjs" }, + { entry: "dist/fault/index.js", outfile: "dist-cjs/fault/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" }, diff --git a/packages/core/scripts/check-api-surface.mjs b/packages/core/scripts/check-api-surface.mjs index ecde1d5..9f019dc 100644 --- a/packages/core/scripts/check-api-surface.mjs +++ b/packages/core/scripts/check-api-surface.mjs @@ -20,7 +20,9 @@ const EXPECTED_EXPORT_MAP = [ "./ai", "./analysis", "./channel", + "./contracts", "./diagnostics", + "./fault", "./ledger", "./observability", "./otel", @@ -58,6 +60,7 @@ const EXPECTED_RUNTIME_EXPORTS = { "runActivity", ], "./ai": [ + "AgentCapabilityError", "AgentToolCalls", "BadBatchError", "OpenAITokens", @@ -74,18 +77,38 @@ const EXPECTED_RUNTIME_EXPORTS = { "verifyReceipt", "verifyScopeProtocol", "verifySourceProtocol", + "verifyTimePolicy", ], "./channel": [ "ChannelClosedError", "createChannel", ], + "./contracts": [ + "cancellable", + "discardCancellation", + "getTaskContract", + "isCancellableTask", + "isShieldedTask", + "shielded", + "typedGroup", + ], "./diagnostics": [ "diagnoseSnapshot", ], + "./fault": [ + "cancellationStorm", + "cleanupHang", + "providerTimeout", + "retryExhaustion", + "runFaultScenario", + "runFaultSuite", + ], "./ledger": [ "ReceiptLedgerConflictError", "createFileReceiptLedger", "createMemoryReceiptLedger", + "createPostgresReceiptLedger", + "createSqliteReceiptLedger", ], "./observability": [ "attachScopeSummaryExporter", @@ -121,7 +144,9 @@ const EXPECTED_EXPORT_CONDITIONS = { "./ai": ["default", "node", "types"], "./analysis": ["import", "require", "types"], "./channel": ["import", "require", "types"], + "./contracts": ["import", "require", "types"], "./diagnostics": ["import", "require", "types"], + "./fault": ["default", "node", "types"], "./ledger": ["default", "node", "types"], "./observability": ["import", "require", "types"], "./otel": ["import", "require", "types"], @@ -137,7 +162,9 @@ const MODULE_PATHS = { "./ai": "../dist/ai/index.js", "./analysis": "../dist/analysis/index.js", "./channel": "../dist/channel/index.js", + "./contracts": "../dist/contracts/index.js", "./diagnostics": "../dist/diagnostics/index.js", + "./fault": "../dist/fault/index.js", "./ledger": "../dist/ledger/index.js", "./observability": "../dist/observability/index.js", "./otel": "../dist/otel/index.js", @@ -153,7 +180,9 @@ const CJS_MODULE_PATHS = { "./ai": "../dist-cjs/ai/index.cjs", "./analysis": "../dist-cjs/analysis/index.cjs", "./channel": "../dist-cjs/channel/index.cjs", + "./contracts": "../dist-cjs/contracts/index.cjs", "./diagnostics": "../dist-cjs/diagnostics/index.cjs", + "./fault": "../dist-cjs/fault/index.cjs", "./ledger": "../dist-cjs/ledger/index.cjs", "./observability": "../dist-cjs/observability/index.cjs", "./replay": "../dist-cjs/replay/index.cjs", diff --git a/packages/core/scripts/check-package-consumer.mjs b/packages/core/scripts/check-package-consumer.mjs index 1d44b33..e67f33a 100644 --- a/packages/core/scripts/check-package-consumer.mjs +++ b/packages/core/scripts/check-package-consumer.mjs @@ -10,22 +10,26 @@ import { execFile } from "node:child_process"; import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { createRequire } from "node:module"; import { homedir, tmpdir } from "node:os"; -import { delimiter, join, resolve } from "node:path"; +import { delimiter, dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; import { build } from "esbuild"; const execFileAsync = promisify(execFile); -const PACKAGE_ROOT = resolve(fileURLToPath(new URL("..", import.meta.url))); -const REPO_ROOT = resolve(fileURLToPath(new URL("../../..", import.meta.url))); -const tscCli = join(REPO_ROOT, "node_modules", "typescript", "bin", "tsc"); +const require = createRequire(import.meta.url); +const ROOT = resolve(fileURLToPath(new URL("..", import.meta.url))); +const tscCli = require.resolve("typescript/bin/tsc"); +const wranglerJsCli = require.resolve("wrangler/bin/wrangler.js"); const bunCli = await findExecutable(["bun.exe", "bun"], [join(homedir(), ".bun", "bin", "bun.exe")]); const denoCli = await findExecutable(["deno.exe", "deno"], [join(homedir(), ".deno", "bin", "deno.exe")]); const wranglerCli = await findExecutable( ["wrangler.cmd", "wrangler"], [ - join(REPO_ROOT, "node_modules", ".bin", "wrangler.cmd"), + wranglerJsCli, + join(ROOT, "node_modules", "wrangler", "bin", "wrangler.js"), + join(ROOT, "node_modules", ".bin", "wrangler.cmd"), join(homedir(), "node_modules", ".bin", "wrangler.cmd"), ] ); @@ -38,7 +42,7 @@ const temp = await mkdtemp(join(tmpdir(), "workit-consumer-")); try { const { stdout } = await runNpm(["pack", "--json", "--pack-destination", temp], { - cwd: PACKAGE_ROOT, + cwd: ROOT, timeout: 120_000, }); const [pack] = JSON.parse(stdout); @@ -105,13 +109,15 @@ 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 { ActivitySerializationError, createFileActivityStore, createMemoryActivityStore, runActivity } from "@workit/core/activity"; + import { analyzeReceipt, verifyReceipt, verifySourceProtocol } from "@workit/core/analysis"; + import { AgentCapabilityError, embedAll, runAgent, streamWithBackpressure } from "@workit/core/ai"; + import { cancellable, getTaskContract, shielded, typedGroup } from "@workit/core/contracts"; + import { cleanupHang, runFaultScenario } from "@workit/core/fault"; + import { createMemoryReceiptLedger, createPostgresReceiptLedger, createSqliteReceiptLedger } from "@workit/core/ledger"; import { attachTelemetryExporter } from "@workit/core/observability"; import { attachOpenTelemetry } from "@workit/core/otel"; - import { buildReceipt, redactReceipt } from "@workit/core/replay"; + import { buildReceipt } from "@workit/core/replay"; import { bracketLazy } from "@workit/core/resources"; import { planTimePolicy } from "@workit/core/time-policy"; import { offload } from "@workit/core/worker"; @@ -119,62 +125,20 @@ try { const result = await run.all([async () => "sdk", async () => "ok"]); const batch = await work([1, 2]).inParallel(2).do(async (item) => item * 2); const embedded = await embedAll(["a"], { embed: async (text) => [text.length] }, { concurrency: 1 }); + let denied = false; + await runAgent(async (agent) => { + try { + await agent.tool("write", undefined, async () => "unexpected", { capability: "repo:write" }); + } catch (err) { + denied = err instanceof AgentCapabilityError; + } + }, { authority: { allowedCapabilities: ["repo:read"] } }); 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 timePlan = planTimePolicy({ - type: "timeout", - timeout: 250, - policy: { - type: "retry", - attempt: { type: "attempt", duration: 100 }, - retry: { times: 4, initialDelay: 50, backoff: "fixed", jitter: false }, - }, - }); - 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; + let lazyAcquired = 0; + const typedValue = await typedGroup(async (spawn) => await spawn(cancellable(async () => "contracts"))); + const typedShield = shielded(async () => "shield", { timeout: 100 }); const tracer = { startSpan: () => ({ setAttribute() { return this; }, addEvent() { return this; }, @@ -194,22 +158,105 @@ try { otel.unsubscribe(); attachment.unsubscribe(); }); + await task(bracketLazy( + async () => { + lazyAcquired++; + return "resource"; + }, + async (resource) => await resource.get(), + async () => undefined, + )); }); if (result.join(":") !== "sdk:ok") throw new Error("root import failed"); 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 (!denied) throw new Error("AI authority 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 (timePlan.upperBoundMs !== 250 || !timePlan.warnings.some((warning) => warning.code === "retry_exceeds_timeout")) throw new Error("time-policy import 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 (typedValue !== "contracts") throw new Error("contracts import failed"); + if (getTaskContract(typedShield).kind !== "shielded") throw new Error("contracts metadata failed"); + const activityStore = createMemoryActivityStore(); + const activity = await group(async (task) => task(runActivity( + activityStore, + { activityId: "consumer-activity", input: { requestId: "r1" } }, + async () => "activity-ok", + ))); + if (activity !== "activity-ok") throw new Error("activity import failed"); + if (typeof createFileActivityStore !== "function") throw new Error("file activity store import failed"); + if (typeof ActivitySerializationError !== "function") throw new Error("activity error import failed"); + let receiptScope; + await run.scope(async (scope) => { + receiptScope = scope; + }); + const receipt = buildReceipt([], receiptScope.status()); + if (receipt.terminal.outcome !== "completed") throw new Error("replay import failed"); + if (analyzeReceipt(receipt).status !== "pass") throw new Error("analysis import failed"); + if (verifyReceipt(receipt).checks.length === 0) throw new Error("receipt verifier import failed"); + if (verifySourceProtocol({ modules: [] }).status !== "pass") throw new Error("source protocol verifier import failed"); + const ledger = createMemoryReceiptLedger(); + if ((await ledger.append(receipt)).receiptId !== receipt.receiptId) throw new Error("ledger import failed"); + const sqliteLedger = createSqliteReceiptLedger({ db: createSqliteConsumerDb() }); + if ((await sqliteLedger.append(receipt)).receiptId !== receipt.receiptId) throw new Error("sqlite ledger import failed"); + const postgresLedger = createPostgresReceiptLedger({ db: createPostgresConsumerDb() }); + if ((await postgresLedger.append(receipt)).receiptId !== receipt.receiptId) throw new Error("postgres ledger import failed"); + const faultReport = await runFaultScenario(cleanupHang({ cleanupTimeout: 1 }), { receiptId: "consumer-fault" }); + if (faultReport.status !== "pass") throw new Error("fault import failed"); + if (lazyAcquired !== 1) throw new Error("resources import failed"); + if (planTimePolicy({ type: "attempt", duration: 1 }).upperBoundMs !== 1) throw new Error("time import failed"); if (typeof offload !== "function") throw new Error("worker import failed"); + + function createSqliteConsumerDb() { + const rows = new Map(); + return { + async exec() {}, + async run(sql, params = []) { + if (!sql.includes("INSERT OR IGNORE")) return; + const [receiptId, checksum, createdAt, storedAt, receiptJson] = params; + if (!rows.has(receiptId)) { + rows.set(receiptId, { + receipt_id: receiptId, + checksum, + created_at: createdAt, + stored_at: storedAt, + receipt_json: receiptJson, + }); + } + }, + async get(_sql, params = []) { + return rows.get(params[0]); + }, + async all() { + return [...rows.values()]; + }, + }; + } + + function createPostgresConsumerDb() { + const rows = new Map(); + return { + async query(sql, params = []) { + if (sql.includes("INSERT INTO")) { + const [receiptId, checksum, createdAt, storedAt, receiptJson] = params; + if (!rows.has(receiptId)) { + const row = { + receipt_id: receiptId, + checksum, + created_at: createdAt, + stored_at: storedAt, + receipt_json: JSON.parse(receiptJson), + }; + rows.set(receiptId, row); + return { rows: [row] }; + } + return { rows: [] }; + } + if (sql.includes("WHERE receipt_id")) return { rows: [rows.get(params[0])].filter(Boolean) }; + return { rows: [...rows.values()] }; + }, + }; + } `, "utf8"); await execFileAsync(process.execPath, ["smoke.mjs"], { @@ -219,57 +266,15 @@ 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"); - const { estimateRetry } = require("@workit/core/time-policy"); + const { cancellable, typedGroup } = require("@workit/core/contracts"); (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 retryPlan = estimateRetry({ - attempt: { type: "attempt", duration: 100 }, - retry: { times: 3, initialDelay: 50, backoff: "fixed", jitter: false }, - }); - 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)); + const typed = await typedGroup(async (spawn) => await spawn(cancellable(async () => "contracts"))); 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 (retryPlan.upperBoundMs !== 400) throw new Error("CommonJS time-policy 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"); + if (typed !== "contracts") throw new Error("CommonJS contracts import failed"); })().catch((err) => { console.error(err); process.exit(1); @@ -307,15 +312,48 @@ try { type CancelledItem, type ItemError, type Settled, + type Scope, + type ScopeSnapshot, 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 { + ActivitySerializationError, + createFileActivityStore, + createMemoryActivityStore, + runActivity, + type ActivityRecord, + type ActivityStore, + } from "@workit/core/activity"; + import { + analyzeReceipt, + verifyReceipt, + verifySourceProtocol, + type AnalysisReport, + type ReceiptVerificationReport, + type SourceProtocolAnalysisReport, + } from "@workit/core/analysis"; + import { AgentCapabilityError, embedAll, runAgent, streamWithBackpressure } from "@workit/core/ai"; + import { + cancellable, + discardCancellation, + getTaskContract, + shielded, + typedGroup, + type CancellableTask, + type ShieldedTask, + } from "@workit/core/contracts"; + import { cleanupHang, runFaultScenario, type FaultReport } from "@workit/core/fault"; + import { + createMemoryReceiptLedger, + createPostgresReceiptLedger, + createSqliteReceiptLedger, + type PostgresReceiptLedgerClient, + type ReceiptLedgerRecord, + type SqliteReceiptLedgerClient, + } from "@workit/core/ledger"; import { buildReceipt, type WorkItReceipt } from "@workit/core/replay"; - import { bracketShared, scopeAcquire, type ResourceRelease } from "@workit/core/resources"; - import { planTimePolicy, type TimePlan, type TimePolicy } from "@workit/core/time-policy"; + import { bracketLazy, type LazyResource } from "@workit/core/resources"; + import { planTimePolicy, type TimePlan } from "@workit/core/time-policy"; const RequestKey = createContextKey<{ requestId: string }>("request"); @@ -338,54 +376,184 @@ 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 declaredPolicy: TimePolicy = { - type: "deadline", - now: 1_000, - deadlineAt: 1_050, - policy: { type: "attempt", duration: 100 }, - }; - const timePlan: TimePlan = planTimePolicy(declaredPolicy); - 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 (timePlan.valid !== false || timePlan.upperBoundMs !== 50) throw new Error("time-policy 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"); + let receiptScope: Scope | undefined; + await run.scope(async (scope) => { + receiptScope = scope; + }); + if (receiptScope === undefined) throw new Error("receipt scope missing"); + const receiptSnapshot: ScopeSnapshot = receiptScope.status(); + const receipt: WorkItReceipt = buildReceipt([], receiptSnapshot); + if (receipt.version !== "workit.receipt.v1") throw new Error("receipt inference failed"); + + const activityStore: ActivityStore = createMemoryActivityStore(); + const activityValue: string = await group(async (task) => await task(runActivity( + activityStore, + { activityId: "types-activity", input: { requestId: "r1" } }, + async () => "typed-activity", + ))); + const activityRecord = await activityStore.get("types-activity") as ActivityRecord | undefined; + if (activityValue !== "typed-activity") throw new Error("activity inference failed"); + if (activityRecord?.status !== "completed" || activityRecord.result !== "typed-activity") { + throw new Error("activity record inference failed"); + } + const fileActivityStore: ActivityStore = createFileActivityStore({ dir: "." }); + void fileActivityStore; + void ActivitySerializationError; + + const timePlan: TimePlan = planTimePolicy({ type: "attempt", duration: "1s" }); + if (timePlan.upperBoundMs !== 1_000) throw new Error("time-policy planner inference failed"); + + const plainTask = async () => "plain"; + const typedTask: CancellableTask = cancellable(async () => "typed-contract"); + const typedShieldedTask: ShieldedTask = shielded(async () => "shielded-contract", { timeout: 100 }); + const discardedTask: ShieldedTask = discardCancellation(typedTask, "types_flush", { timeout: 100 }); + const typedTaskValue: string = await typedGroup(async (spawn) => { + const first = await spawn(typedTask); + const second = await spawn.shielded(typedShieldedTask); + const third = await spawn.shielded(discardedTask); + return first + ":" + second + ":" + third; + }); + if (!typedTaskValue.includes("typed-contract")) throw new Error("contracts inference failed"); + if (getTaskContract(discardedTask)?.kind !== "shielded") throw new Error("contracts metadata inference failed"); + // @ts-expect-error plain tasks must be declared cancellable before typed spawn. + await typedGroup(async (spawn) => await spawn(plainTask)); + // @ts-expect-error shielded tasks must use the explicit shielded boundary. + await typedGroup(async (spawn) => await spawn(typedShieldedTask)); + // @ts-expect-error cancellable tasks are not accepted by the shielded boundary. + await typedGroup(async (spawn) => await spawn.shielded(typedTask)); + // @ts-expect-error discardCancellation requires declared cancellable work. + discardCancellation(plainTask, "bad", { timeout: 100 }); + + const analysisReport: AnalysisReport = analyzeReceipt(receipt); + if (analysisReport.status !== "pass") throw new Error("analysis inference failed"); + const verificationReport: ReceiptVerificationReport = verifyReceipt(receipt); + if (verificationReport.receiptId !== receipt.receiptId) throw new Error("receipt verifier inference failed"); + const sourceReport: SourceProtocolAnalysisReport = verifySourceProtocol({ + modules: [ + { + moduleId: "consumer", + functions: [ + { + functionId: "handler", + uses: [ + { operation: "resource.acquire" }, + { operation: "ctx.defer" }, + ], + }, + ], + }, + ], + }); + if (sourceReport.status !== "pass") throw new Error("source protocol verifier inference failed"); + const ledger = createMemoryReceiptLedger(); + const ledgerRecord: ReceiptLedgerRecord = await ledger.append(receipt); + if (ledgerRecord.receiptId !== receipt.receiptId) throw new Error("ledger inference failed"); + const sqliteRows = new Map(); + const sqliteClient: SqliteReceiptLedgerClient = { + async exec() {}, + async run(sql: string, params: readonly unknown[] = []) { + if (!sql.includes("INSERT OR IGNORE")) return; + const [receiptId, checksum, createdAt, storedAt, receiptJson] = params; + if ( + typeof receiptId === "string" + && typeof checksum === "string" + && typeof createdAt === "number" + && typeof storedAt === "number" + && typeof receiptJson === "string" + && !sqliteRows.has(receiptId) + ) { + sqliteRows.set(receiptId, { + receipt_id: receiptId, + checksum, + created_at: createdAt, + stored_at: storedAt, + receipt_json: receiptJson, + }); + } + }, + async get(_sql: string, params: readonly unknown[] = []) { + return sqliteRows.get(String(params[0])) as T | undefined; + }, + async all() { + return [...sqliteRows.values()] as T[]; + }, + }; + const sqliteRecord: ReceiptLedgerRecord = await createSqliteReceiptLedger({ db: sqliteClient }).append(receipt); + if (sqliteRecord.receiptId !== receipt.receiptId) throw new Error("sqlite ledger inference failed"); + + const postgresRows = new Map(); + const postgresClient: PostgresReceiptLedgerClient = { + async query(sql: string, params: readonly unknown[] = []) { + if (sql.includes("INSERT INTO")) { + const [receiptId, checksum, createdAt, storedAt, receiptJson] = params; + if ( + typeof receiptId === "string" + && typeof checksum === "string" + && typeof createdAt === "number" + && typeof storedAt === "number" + && typeof receiptJson === "string" + && !postgresRows.has(receiptId) + ) { + postgresRows.set(receiptId, { + receipt_id: receiptId, + checksum, + created_at: createdAt, + stored_at: storedAt, + receipt_json: JSON.parse(receiptJson) as WorkItReceipt, + }); + return { rows: [postgresRows.get(receiptId)] as T[] }; + } + return { rows: [] as T[] }; + } + if (sql.includes("WHERE receipt_id")) { + return { rows: [postgresRows.get(String(params[0]))].filter(Boolean) as T[] }; + } + return { rows: [...postgresRows.values()] as T[] }; + }, + }; + const postgresRecord: ReceiptLedgerRecord = await createPostgresReceiptLedger({ db: postgresClient }).append(receipt); + if (postgresRecord.receiptId !== receipt.receiptId) throw new Error("postgres ledger inference failed"); + const faultReport: FaultReport = await runFaultScenario(cleanupHang({ cleanupTimeout: 1 }), { + receiptId: "types-fault", + }); + if (faultReport.status !== "pass") throw new Error("fault inference failed"); + + await runAgent(async (agent) => { + await agent.tool("read", undefined, async () => "ok", { capability: "repo:read" }); + // @ts-expect-error capability must be a string when supplied. + await agent.tool("bad", undefined, async () => "bad", { capability: 1 }); + }, { authority: { allowedCapabilities: ["repo:read"] } }); + void AgentCapabilityError; + + const lazyTask = bracketLazy( + async () => "resource", + async (resource: LazyResource) => await resource.get(), + async () => undefined, + ); + const lazyValue: string = await group(async (task) => await task(lazyTask)); + if (lazyValue !== "resource") throw new Error("resource helper inference failed"); + const inferredVoid: void = await group(async () => {}); void inferredVoid; // @ts-expect-error explicit group bodies must return string. @@ -498,6 +666,7 @@ try { import { run } from "@workit/core"; let disconnectCancelled = false; + let disconnectTaskStarted = false; const app = express(); app.use(express.json()); app.post("/items", async (request, response, next) => { @@ -518,6 +687,7 @@ try { try { await run.group(async (task) => { await task(async (ctx) => { + disconnectTaskStarted = true; const signal = AbortSignal.any([ctx.signal, disconnect.signal]); await new Promise((resolve, reject) => { const timer = setTimeout(resolve, 5_000); @@ -559,7 +729,12 @@ try { }); req.on("error", () => resolve()); req.end(); - setTimeout(() => req.destroy(), 20); + void (async () => { + for (let attempt = 0; attempt < 100 && !disconnectTaskStarted; attempt++) { + await new Promise((innerResolve) => setTimeout(innerResolve, 5)); + } + req.destroy(); + })(); }); for (let attempt = 0; attempt < 100 && !disconnectCancelled; attempt++) { @@ -768,8 +943,12 @@ async function findExecutable(names, fallbacks) { } async function execCli(executable, args, opts) { + if (executable.toLowerCase().endsWith(".js")) { + return await execFileAsync(process.execPath, [executable, ...args], opts); + } if (process.platform === "win32" && executable.toLowerCase().endsWith(".cmd")) { - return await execFileAsync(process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", executable, ...args], opts); + const command = `call ${[executable, ...args].map(quoteCmdArg).join(" ")}`; + return await execFileAsync(process.env.ComSpec ?? "cmd.exe", ["/d", "/c", command], opts); } return await execFileAsync(executable, args, opts); } @@ -779,11 +958,20 @@ async function runNpm(args, opts) { return await execFileAsync(process.execPath, [process.env.npm_execpath, ...args], opts); } + const bundledNpmCli = join(dirname(process.execPath), "node_modules", "npm", "bin", "npm-cli.js"); + if (await exists(bundledNpmCli)) { + return await execFileAsync(process.execPath, [bundledNpmCli, ...args], opts); + } + const npmCli = await findExecutable(["npm.cmd", "npm"], []); if (npmCli === null) throw new Error("npm executable not found on PATH."); return await execCli(npmCli, args, opts); } +function quoteCmdArg(value) { + return `"${String(value).replaceAll("\"", "\"\"")}"`; +} + async function exists(path) { try { await access(path); diff --git a/packages/core/src/ai/index.ts b/packages/core/src/ai/index.ts index 9b6e5e5..81527a0 100644 --- a/packages/core/src/ai/index.ts +++ b/packages/core/src/ai/index.ts @@ -76,6 +76,15 @@ export interface StreamWithBackpressureOptions { export type AgentEvent = | { type: "agent:started"; seq: number; agentId: string; at: number } | { type: "agent:tool_started"; seq: number; agentId: string; tool: string; at: number } + | { + type: "agent:tool_denied"; + seq: number; + agentId: string; + tool: string; + capability: string; + reason: AgentCapabilityDenialReason; + at: number; + } | { type: "agent:tool_succeeded"; seq: number; agentId: string; tool: string; at: number } | { type: "agent:tool_failed"; seq: number; agentId: string; tool: string; error: string; at: number } | { type: "agent:tool_cancelled"; seq: number; agentId: string; tool: string; reason: CancelReason; at: number } @@ -85,12 +94,47 @@ export type AgentEvent = type AgentEventPayload = | { type: "agent:started" } | { type: "agent:tool_started"; tool: string } + | { + type: "agent:tool_denied"; + tool: string; + capability: string; + reason: AgentCapabilityDenialReason; + } | { type: "agent:tool_succeeded"; tool: string } | { type: "agent:tool_failed"; tool: string; error: string } | { type: "agent:tool_cancelled"; tool: string; reason: CancelReason } | { type: "agent:completed" } | { type: "agent:failed"; error: string }; +/** Typed reason for declared agent tool capability denial. */ +export type AgentCapabilityDenialReason = "capability_denied" | "capability_not_allowed"; + +/** Declared tool capability policy for `runAgent()`. This is not an OS sandbox. */ +export interface AgentAuthorityPolicy { + readonly allowedCapabilities?: readonly string[]; + readonly deniedCapabilities?: readonly string[]; +} + +/** Options for an agent run. */ +export interface RunAgentOptions { + readonly authority?: AgentAuthorityPolicy; +} + +/** Error thrown when an agent tool declares a capability denied by policy. */ +export class AgentCapabilityError extends Error { + readonly tool: string; + readonly capability: string; + readonly reason: AgentCapabilityDenialReason; + + constructor(tool: string, capability: string, reason: AgentCapabilityDenialReason) { + super(`Agent tool "${tool}" denied capability "${capability}": ${reason}`); + this.name = "AgentCapabilityError"; + this.tool = tool; + this.capability = capability; + this.reason = reason; + } +} + /** Budget charges and policies for one agent tool call. */ export interface AgentToolOptions { tokens?: number; @@ -98,6 +142,7 @@ export interface AgentToolOptions { toolCalls?: number; retry?: number | RetryOpts; timeout?: Duration; + capability?: string; } /** Agent-scoped execution contract passed to `runAgent()`. */ @@ -164,7 +209,8 @@ export function wrapAI(provider: string, task: TaskFn): TaskFn { /** Runs an agent body with replayable local events and explicit tool contracts. */ export async function runAgent( - body: (agent: AgentScope, ctx: TaskContext) => R | Promise + body: (agent: AgentScope, ctx: TaskContext) => R | Promise, + opts: RunAgentOptions = {}, ): Promise> { const events: AgentEvent[] = []; const agentId = makeAgentId(); @@ -175,7 +221,7 @@ export async function runAgent( const result = await run.group(async (task) => { return await task(async (ctx) => { - const agent = new AgentScopeImpl(agentId, events, emit, ctx); + const agent = new AgentScopeImpl(agentId, events, emit, ctx, opts.authority); emit({ type: "agent:started" }); try { const value = await body(agent, ctx); @@ -345,7 +391,8 @@ class AgentScopeImpl implements AgentScope { readonly id: string, readonly events: readonly AgentEvent[], private readonly emit: (event: AgentEventPayload) => void, - private readonly ctx: TaskContext + private readonly ctx: TaskContext, + private readonly authority: AgentAuthorityPolicy | undefined, ) {} async tool( @@ -354,6 +401,19 @@ class AgentScopeImpl implements AgentScope { fn: (input: I, ctx: TaskContext) => O | Promise, opts: AgentToolOptions = {} ): Promise { + if (opts.capability !== undefined) { + const denied = evaluateCapability(this.authority, name, opts.capability); + if (denied !== undefined) { + this.emit({ + type: "agent:tool_denied", + tool: name, + capability: opts.capability, + reason: denied, + }); + throw new AgentCapabilityError(name, opts.capability, denied); + } + } + this.emit({ type: "agent:tool_started", tool: name }); try { const charge = (key: typeof OpenAITokens | typeof CostBudget | typeof AgentToolCalls, amount?: number): void => { @@ -380,6 +440,29 @@ class AgentScopeImpl implements AgentScope { } } +const MAX_AGENT_CAPABILITY_LENGTH = 128; + +function evaluateCapability( + authority: AgentAuthorityPolicy | undefined, + tool: string, + capability: string, +): AgentCapabilityDenialReason | undefined { + validateAgentLabel("agent tool", tool); + validateAgentLabel("agent capability", capability); + + if (authority?.deniedCapabilities?.includes(capability)) return "capability_denied"; + if (authority?.allowedCapabilities !== undefined && !authority.allowedCapabilities.includes(capability)) { + return "capability_not_allowed"; + } + return undefined; +} + +function validateAgentLabel(label: string, value: string): void { + if (value.length === 0 || value.length > MAX_AGENT_CAPABILITY_LENGTH) { + throw new RangeError(`${label} must be between 1 and ${MAX_AGENT_CAPABILITY_LENGTH} characters`); + } +} + async function* streamLLMGenerator( input: I, provider: LLMStreamProvider, diff --git a/packages/core/src/analysis/index.ts b/packages/core/src/analysis/index.ts index 2cce026..04fd1be 100644 --- a/packages/core/src/analysis/index.ts +++ b/packages/core/src/analysis/index.ts @@ -1,5 +1,5 @@ /** - * Analysis helpers for WorkIt receipts and declared event protocols. + * Analysis helpers for WorkIt receipts, time policies, and event protocols. * * @author Admilson B. F. Cossa * SPDX-License-Identifier: Apache-2.0 @@ -10,6 +10,8 @@ */ import type { ScopeSnapshot, TaskEvent, TaskSnapshot } from "../types/index.js"; +import type { TimePlan, TimePolicy } from "../time-policy/index.js"; +import { planTimePolicy } from "../time-policy/index.js"; import type { WorkItReceipt, WorkItReceiptEvent } from "../replay/index.js"; /** Analysis result status. */ @@ -19,6 +21,7 @@ export type AnalysisStatus = "pass" | "warn" | "fail"; export type AnalysisFindingCode = | "cleanup_evidence_missing" | "cleanup_timeout" + | "deadline_infeasible" | "leaked_tasks" | "receipt_not_terminal" | "receipt_truncated" @@ -31,7 +34,9 @@ export type AnalysisFindingCode = | "task_event_after_terminal" | "terminal_cause_missing" | "terminal_event_missing" - | "terminal_failed"; + | "terminal_failed" + | "time_budget_exceeded" + | "time_policy_warning"; /** Severity assigned to one analysis finding. */ export type AnalysisSeverity = "info" | "warn" | "error"; @@ -48,6 +53,8 @@ export interface AnalysisFinding { readonly operation?: string; readonly count?: number; readonly limit?: number; + readonly estimatedMs?: number; + readonly limitMs?: number; } /** Analysis report returned by verifier helpers. */ @@ -56,6 +63,11 @@ export interface AnalysisReport { readonly findings: readonly AnalysisFinding[]; } +/** Time-policy analysis report with the underlying plan attached. */ +export interface TimePolicyAnalysisReport extends AnalysisReport { + readonly plan: TimePlan; +} + /** Stable receipt verification checks. */ export type ReceiptVerificationCheckCode = | "cleanup_evidence_recorded" @@ -86,6 +98,11 @@ export interface ReceiptVerificationReport extends AnalysisReport { readonly checks: readonly ReceiptVerificationCheck[]; } +/** Options for time-policy verification. */ +export interface TimePolicyAnalysisOptions { + readonly maxUpperBoundMs?: number; +} + /** Bounded source protocol operation understood by `verifySourceProtocol`. */ export type SourceProtocolOperation = | "activity.run" @@ -310,6 +327,37 @@ export function verifyReceipt( }; } +/** Verifies a declared time policy against planner warnings and optional SLA. */ +export function verifyTimePolicy( + policy: TimePolicy, + opts: TimePolicyAnalysisOptions = {}, +): TimePolicyAnalysisReport { + const plan = planTimePolicy(policy); + const findings: AnalysisFinding[] = []; + + for (const warning of plan.warnings) { + findings.push({ + code: warning.code === "deadline_infeasible" ? "deadline_infeasible" : "time_policy_warning", + severity: warning.code === "deadline_infeasible" ? "error" : "warn", + message: warning.message, + ...(warning.estimatedMs !== undefined ? { estimatedMs: warning.estimatedMs } : {}), + ...(warning.limitMs !== undefined ? { limitMs: warning.limitMs } : {}), + }); + } + + if (opts.maxUpperBoundMs !== undefined && plan.upperBoundMs > opts.maxUpperBoundMs) { + findings.push({ + code: "time_budget_exceeded", + severity: "error", + message: "time policy upper bound exceeds configured maximum", + estimatedMs: plan.upperBoundMs, + limitMs: opts.maxUpperBoundMs, + }); + } + + return { ...report(findings), plan }; +} + /** Verifies a caller-provided source protocol contract for ownership gaps. */ export function verifySourceProtocol( spec: SourceProtocolSpec, diff --git a/packages/core/src/contracts/index.ts b/packages/core/src/contracts/index.ts new file mode 100644 index 0000000..8617903 --- /dev/null +++ b/packages/core/src/contracts/index.ts @@ -0,0 +1,125 @@ +/** + * Typed cancellation contract helpers for WorkIt tasks. + * + * @author Admilson B. F. Cossa + * SPDX-License-Identifier: Apache-2.0 + * + * This subpath adds compile-time composition contracts around existing WorkIt + * task functions. It records declared intent at API boundaries; it does not + * prove that a task body observes `ctx.signal` at every await point. + */ + +import { group, type TaskSpawner } from "../engine/scope.js"; +import { run } from "../run/index.js"; +import type { Duration, ScopeOpts, TaskFn, TaskHandle, TaskOpts } from "../types/index.js"; + +const contractBrand: unique symbol = Symbol("workit.contract.task"); +const contractMetadata = new WeakMap, TaskCancellationContract>(); + +/** Declared cancellation contract visible at typed composition boundaries. */ +export type TaskCancellationContract = + | { readonly kind: "cancellable" } + | { readonly kind: "shielded"; readonly timeout: Duration; readonly discardReason?: string }; + +/** Task declared as normal cancellable WorkIt child work. */ +export type CancellableTask = TaskFn & { + readonly [contractBrand]: "cancellable"; +}; + +/** Task declared as timeout-bounded shielded work. */ +export type ShieldedTask = TaskFn & { + readonly [contractBrand]: "shielded"; +}; + +/** Spawner used by `typedGroup` to enforce task cancellation contracts. */ +export interface TypedTaskSpawner { + (task: CancellableTask, opts?: TaskOpts): TaskHandle; + + /** Spawns cancellable background work that remains owned by the scope. */ + background(task: CancellableTask): TaskHandle; + + /** Spawns explicit timeout-bounded shielded work. */ + shielded(task: ShieldedTask, opts?: TaskOpts): TaskHandle; +} + +/** Declares a WorkIt task as ordinary cancellable child work. */ +export function cancellable(task: TaskFn): CancellableTask { + return brandTask(task, "cancellable", { kind: "cancellable" }); +} + +/** Declares a task as shielded by WorkIt's timeout-bounded uncancellable wrapper. */ +export function shielded(task: TaskFn, opts: { timeout: Duration }): ShieldedTask { + return brandTask(run.uncancellable(task, opts), "shielded", { + kind: "shielded", + timeout: opts.timeout, + }); +} + +/** Converts cancellable work into explicit shielded work with a named discard reason. */ +export function discardCancellation( + task: CancellableTask, + discardReason: string, + opts: { timeout: Duration }, +): ShieldedTask { + assertDiscardReason(discardReason); + return brandTask(run.uncancellable(task, opts), "shielded", { + kind: "shielded", + timeout: opts.timeout, + discardReason, + }); +} + +/** Opens a WorkIt group whose normal spawner accepts only declared cancellable tasks. */ +export async function typedGroup( + body: (task: TypedTaskSpawner) => Promise, + opts: ScopeOpts = {}, +): Promise { + return await group(async (task) => body(createTypedSpawner(task)), opts); +} + +/** Returns the declared task cancellation contract, when one was declared. */ +export function getTaskContract(task: TaskFn): TaskCancellationContract | undefined { + return contractMetadata.get(task); +} + +/** Reports whether a task was declared through `cancellable(...)`. */ +export function isCancellableTask(task: TaskFn): task is CancellableTask { + return contractMetadata.get(task)?.kind === "cancellable"; +} + +/** Reports whether a task was declared through `shielded(...)` or `discardCancellation(...)`. */ +export function isShieldedTask(task: TaskFn): task is ShieldedTask { + return contractMetadata.get(task)?.kind === "shielded"; +} + +function createTypedSpawner(task: TaskSpawner): TypedTaskSpawner { + const typed = Object.assign( + (fn: CancellableTask, opts?: TaskOpts) => task(fn, opts), + { + background: (fn: CancellableTask) => task.background(fn), + shielded: (fn: ShieldedTask, opts?: TaskOpts) => task(fn, opts), + }, + ); + return typed; +} + +function brandTask( + task: TaskFn, + kind: K, + metadata: TaskCancellationContract, +): TaskFn & { readonly [contractBrand]: K } { + Object.defineProperty(task, contractBrand, { + configurable: false, + enumerable: false, + value: kind, + writable: false, + }); + contractMetadata.set(task as TaskFn, metadata); + return task as TaskFn & { readonly [contractBrand]: K }; +} + +function assertDiscardReason(reason: string): void { + if (reason.trim().length === 0) { + throw new Error("discardCancellation requires a non-empty reason"); + } +} diff --git a/packages/core/src/fault/index.ts b/packages/core/src/fault/index.ts new file mode 100644 index 0000000..deeb646 --- /dev/null +++ b/packages/core/src/fault/index.ts @@ -0,0 +1,590 @@ +/** + * Fault-injection harness for WorkIt lifecycle evidence. + * + * @author Admilson B. F. Cossa + * SPDX-License-Identifier: Apache-2.0 + * + * The harness runs explicit, bounded failure scenarios through the real WorkIt + * scope engine and returns receipts plus verifier reports. It is evidence + * infrastructure, not a scheduler replay engine or process supervisor. + */ + +import { getCurrentScope, group, type TaskSpawner } from "../engine/scope.js"; +import { parseDuration } from "../engine/duration.js"; +import { run } from "../run/index.js"; +import { type Duration, type Scope } from "../types/index.js"; +import { + createReceiptRecorder, + type ReceiptRecorder, + type WorkItReceipt, + type WorkItReceiptEvent, +} from "../replay/index.js"; +import { analyzeReceipt, verifyScopeProtocol, type AnalysisReport } from "../analysis/index.js"; + +/** Built-in fault scenarios supported by the harness. */ +export type FaultScenarioKind = + | "cancellation_storm" + | "cleanup_hang" + | "provider_timeout" + | "retry_exhaustion"; + +/** Scenario that cancels many owned child tasks under one scope. */ +export interface CancellationStormScenario { + readonly id: string; + readonly kind: "cancellation_storm"; + readonly taskCount: number; + readonly cancelAfterMs: number; + readonly workerDurationMs: number; +} + +/** Scenario that registers a cleanup handler which does not settle before its timeout. */ +export interface CleanupHangScenario { + readonly id: string; + readonly kind: "cleanup_hang"; + readonly cleanupTimeoutMs: number; +} + +/** Scenario that drives a provider-like task through WorkIt's timeout wrapper. */ +export interface ProviderTimeoutScenario { + readonly id: string; + readonly kind: "provider_timeout"; + readonly timeoutMs: number; + readonly providerLatencyMs: number; +} + +/** Scenario that exhausts WorkIt's retry wrapper with a deterministic failing task. */ +export interface RetryExhaustionScenario { + readonly id: string; + readonly kind: "retry_exhaustion"; + readonly attempts: number; + readonly initialDelayMs: number; +} + +/** Built-in fault scenario definition. */ +export type FaultScenario = + | CancellationStormScenario + | CleanupHangScenario + | ProviderTimeoutScenario + | RetryExhaustionScenario; + +/** Options for creating a cancellation storm scenario. */ +export interface CancellationStormOptions { + readonly id?: string; + readonly taskCount?: number; + readonly cancelAfter?: Duration; + readonly workerDuration?: Duration; +} + +/** Options for creating a cleanup hang scenario. */ +export interface CleanupHangOptions { + readonly id?: string; + readonly cleanupTimeout?: Duration; +} + +/** Options for creating a provider timeout scenario. */ +export interface ProviderTimeoutOptions { + readonly id?: string; + readonly timeout?: Duration; + readonly providerLatency?: Duration; +} + +/** Options for creating a retry exhaustion scenario. */ +export interface RetryExhaustionOptions { + readonly id?: string; + readonly attempts?: number; + readonly initialDelay?: Duration; +} + +/** Harness execution options. */ +export interface FaultRunOptions { + readonly receiptId?: string; + readonly clock?: () => number; + readonly maxEvents?: number; +} + +/** Safe error evidence included in fault reports. */ +export interface FaultErrorEvidence { + readonly name: string; + readonly message: string; +} + +/** Typed observation emitted by the fault harness itself. */ +export interface FaultObservation { + readonly type: "fault:injected" | "fault:observed"; + readonly scenarioId: string; + readonly kind: FaultScenarioKind; + readonly at: number; + readonly message: string; + readonly count?: number; + readonly durationMs?: number; + readonly attempts?: number; + readonly error?: FaultErrorEvidence; +} + +/** Stable finding codes returned by fault reports. */ +export type FaultFindingCode = + | "cleanup_timeout_not_observed" + | "expected_cancellation_not_observed" + | "expected_error_not_observed" + | "leaked_tasks" + | "protocol_violation" + | "provider_timeout_not_observed" + | "receipt_not_terminal" + | "retry_exhaustion_not_observed" + | "unexpected_error"; + +/** One finding emitted by the harness verifier. */ +export interface FaultFinding { + readonly code: FaultFindingCode; + readonly severity: "error"; + readonly message: string; + readonly expected?: number | string; + readonly actual?: number | string; +} + +/** Report returned for one fault scenario run. */ +export interface FaultReport { + readonly scenario: FaultScenario; + readonly status: "pass" | "fail"; + readonly durationMs: number; + readonly receipt: WorkItReceipt; + readonly analysis: AnalysisReport; + readonly protocol: AnalysisReport; + readonly observations: readonly FaultObservation[]; + readonly findings: readonly FaultFinding[]; + readonly error?: FaultErrorEvidence; +} + +/** Aggregate report returned for a fault suite. */ +export interface FaultSuiteReport { + readonly reports: readonly FaultReport[]; + readonly passed: number; + readonly failed: number; +} + +interface FaultExecutionContext { + readonly task: TaskSpawner; + readonly scope: Scope; + observe(observation: Omit): void; +} + +const DEFAULT_CANCEL_STORM_TASKS = 8; +const DEFAULT_CANCEL_AFTER_MS = 1; +const DEFAULT_WORKER_DURATION_MS = 1_000; +const DEFAULT_CLEANUP_TIMEOUT_MS = 5; +const DEFAULT_PROVIDER_TIMEOUT_MS = 5; +const DEFAULT_PROVIDER_LATENCY_MS = 50; +const DEFAULT_RETRY_ATTEMPTS = 3; +const DEFAULT_RETRY_DELAY_MS = 1; +const MAX_FAULT_TASKS = 1_000; +const MAX_FAULT_ATTEMPTS = 1_000; + +/** Creates a bounded cancellation-storm scenario. */ +export function cancellationStorm(opts: CancellationStormOptions = {}): CancellationStormScenario { + const taskCount = opts.taskCount ?? DEFAULT_CANCEL_STORM_TASKS; + assertPositiveInteger("taskCount", taskCount, MAX_FAULT_TASKS); + const cancelAfterMs = parseNonNegativeDuration(opts.cancelAfter ?? DEFAULT_CANCEL_AFTER_MS, "cancelAfter"); + const workerDurationMs = parsePositiveDuration(opts.workerDuration ?? DEFAULT_WORKER_DURATION_MS, "workerDuration"); + return { + id: opts.id ?? "fault:cancellation-storm", + kind: "cancellation_storm", + taskCount, + cancelAfterMs, + workerDurationMs, + }; +} + +/** Creates a cleanup-timeout scenario. */ +export function cleanupHang(opts: CleanupHangOptions = {}): CleanupHangScenario { + return { + id: opts.id ?? "fault:cleanup-hang", + kind: "cleanup_hang", + cleanupTimeoutMs: parsePositiveDuration(opts.cleanupTimeout ?? DEFAULT_CLEANUP_TIMEOUT_MS, "cleanupTimeout"), + }; +} + +/** Creates a provider timeout scenario. */ +export function providerTimeout(opts: ProviderTimeoutOptions = {}): ProviderTimeoutScenario { + const timeoutMs = parsePositiveDuration(opts.timeout ?? DEFAULT_PROVIDER_TIMEOUT_MS, "timeout"); + const providerLatencyMs = parsePositiveDuration(opts.providerLatency ?? DEFAULT_PROVIDER_LATENCY_MS, "providerLatency"); + if (providerLatencyMs <= timeoutMs) { + throw new RangeError("providerLatency must be greater than timeout for provider_timeout evidence"); + } + return { + id: opts.id ?? "fault:provider-timeout", + kind: "provider_timeout", + timeoutMs, + providerLatencyMs, + }; +} + +/** Creates a retry-exhaustion scenario. */ +export function retryExhaustion(opts: RetryExhaustionOptions = {}): RetryExhaustionScenario { + const attempts = opts.attempts ?? DEFAULT_RETRY_ATTEMPTS; + assertPositiveInteger("attempts", attempts, MAX_FAULT_ATTEMPTS); + return { + id: opts.id ?? "fault:retry-exhaustion", + kind: "retry_exhaustion", + attempts, + initialDelayMs: parseNonNegativeDuration(opts.initialDelay ?? DEFAULT_RETRY_DELAY_MS, "initialDelay"), + }; +} + +/** Runs one fault scenario through the real WorkIt scope engine and returns executable evidence. */ +export async function runFaultScenario( + scenario: FaultScenario, + opts: FaultRunOptions = {}, +): Promise { + const clock = opts.clock ?? Date.now; + const startedAt = clock(); + const observations: FaultObservation[] = []; + let scope: Scope | undefined; + let recorder: ReceiptRecorder | undefined; + let error: unknown; + + const observe = (observation: Omit): void => { + observations.push({ + scenarioId: scenario.id, + kind: scenario.kind, + at: clock(), + ...observation, + }); + }; + + try { + const cleanupTimeout = cleanupTimeoutFor(scenario); + await group(async (task) => { + const current = getCurrentScope(); + /* v8 ignore next -- group() binds the current scope before invoking its body. */ + if (current === null) throw new Error("fault harness requires an active WorkIt scope"); + scope = current; + recorder = createReceiptRecorder(current, { + clock, + receiptId: opts.receiptId ?? `fault:${scenario.id}`, + ...(opts.maxEvents !== undefined ? { maxEvents: opts.maxEvents } : {}), + }); + await executeScenario(scenario, { task, scope: current, observe }); + }, { + name: scenario.id, + ...(cleanupTimeout !== undefined ? { cleanupTimeout } : {}), + }); + } catch (err) { + error = err; + observe({ + type: "fault:observed", + message: "scenario execution threw", + error: normalizeError(err), + }); + } + + /* v8 ignore next -- the recorder is initialized before any scenario executes inside group(). */ + if (scope === undefined || recorder === undefined) { + throw new Error("fault harness did not initialize a WorkIt scope"); + } + + const receipt = recorder.build(scope.status(), { + clock, + receiptId: opts.receiptId ?? `fault:${scenario.id}`, + limitations: ["fault_injection_is_bounded_runtime_evidence_not_deterministic_replay"], + }); + recorder.unsubscribe(); + + const analysis = analyzeReceipt(receipt); + const protocol = verifyScopeProtocol(receipt.events); + const findings = verifyScenario(scenario, receipt, analysis, protocol, error); + return { + scenario, + status: findings.length === 0 ? "pass" : "fail", + durationMs: Math.max(0, clock() - startedAt), + receipt, + analysis, + protocol, + observations, + findings, + ...(error !== undefined ? { error: normalizeError(error) } : {}), + }; +} + +/** Runs scenarios in order and returns aggregate pass/fail counts. */ +export async function runFaultSuite( + scenarios: readonly FaultScenario[], + opts: FaultRunOptions = {}, +): Promise { + const reports: FaultReport[] = []; + for (const scenario of scenarios) reports.push(await runFaultScenario(scenario, opts)); + const passed = reports.filter((report) => report.status === "pass").length; + return { + reports, + passed, + failed: reports.length - passed, + }; +} + +async function executeScenario(scenario: FaultScenario, ctx: FaultExecutionContext): Promise { + switch (scenario.kind) { + case "cancellation_storm": + return executeCancellationStorm(scenario, ctx); + case "cleanup_hang": + return executeCleanupHang(scenario, ctx); + case "provider_timeout": + return executeProviderTimeout(scenario, ctx); + case "retry_exhaustion": + return executeRetryExhaustion(scenario, ctx); + } +} + +async function executeCancellationStorm( + scenario: CancellationStormScenario, + ctx: FaultExecutionContext, +): Promise { + const handles = Array.from({ length: scenario.taskCount }, () => + ctx.task(async (taskCtx) => { + await sleep(scenario.workerDurationMs, taskCtx.signal); + }, { name: "fault.cancellation.worker", kind: "io" })); + + await sleep(scenario.cancelAfterMs); + ctx.observe({ + type: "fault:injected", + message: "scope cancellation requested", + count: scenario.taskCount, + }); + ctx.scope.cancel({ kind: "manual", tag: "fault_cancellation_storm" }); + await Promise.allSettled(handles); +} + +async function executeCleanupHang(scenario: CleanupHangScenario, ctx: FaultExecutionContext): Promise { + await ctx.task(async (taskCtx) => { + taskCtx.defer(() => new Promise(() => undefined), { timeout: scenario.cleanupTimeoutMs }); + ctx.observe({ + type: "fault:injected", + message: "non-settling cleanup registered", + durationMs: scenario.cleanupTimeoutMs, + }); + }, { name: "fault.cleanup.hang", kind: "io", cleanupTimeout: scenario.cleanupTimeoutMs }); +} + +async function executeProviderTimeout(scenario: ProviderTimeoutScenario, ctx: FaultExecutionContext): Promise { + ctx.observe({ + type: "fault:injected", + message: "provider latency exceeds timeout", + durationMs: scenario.providerLatencyMs, + }); + await ctx.task(run.timeout(async (taskCtx) => { + await sleep(scenario.providerLatencyMs, taskCtx.signal); + }, scenario.timeoutMs), { name: "fault.provider.timeout", kind: "llm" }); +} + +async function executeRetryExhaustion(scenario: RetryExhaustionScenario, ctx: FaultExecutionContext): Promise { + ctx.observe({ + type: "fault:injected", + message: "retrying task will fail every attempt", + attempts: scenario.attempts, + }); + await ctx.task(run.retry(async () => { + throw new Error("fault_retry_exhausted"); + }, { + times: scenario.attempts, + initialDelay: scenario.initialDelayMs, + maxDelay: scenario.initialDelayMs, + jitter: false, + }), { name: "fault.retry.exhaustion", kind: "io" }); +} + +function verifyScenario( + scenario: FaultScenario, + receipt: WorkItReceipt, + analysis: AnalysisReport, + protocol: AnalysisReport, + error: unknown, +): FaultFinding[] { + const findings: FaultFinding[] = []; + const leaked = analysis.findings.find((finding) => finding.code === "leaked_tasks"); + const notTerminal = analysis.findings.find((finding) => finding.code === "receipt_not_terminal"); + /* v8 ignore next -- reports are built after WorkIt closes the observed scope. */ + if (leaked !== undefined) { + findings.push({ + code: "leaked_tasks", + severity: "error", + message: leaked.message, + }); + } + /* v8 ignore next -- reports are built after WorkIt emits terminal scope evidence. */ + if (notTerminal !== undefined) { + findings.push({ + code: "receipt_not_terminal", + severity: "error", + message: notTerminal.message, + }); + } + /* v8 ignore next -- protocol input comes from WorkIt's typed event stream. */ + if (protocol.status === "fail") { + findings.push({ + code: "protocol_violation", + severity: "error", + message: "receipt event stream violates the WorkIt task protocol", + }); + } + + switch (scenario.kind) { + case "cancellation_storm": + verifyCancellationStorm(scenario, receipt, error, findings); + break; + case "cleanup_hang": + verifyCleanupHang(receipt, error, findings); + break; + case "provider_timeout": + verifyProviderTimeout(receipt, error, findings); + break; + case "retry_exhaustion": + verifyRetryExhaustion(scenario, receipt, error, findings); + break; + } + + return findings; +} + +function verifyCancellationStorm( + scenario: CancellationStormScenario, + receipt: WorkItReceipt, + error: unknown, + findings: FaultFinding[], +): void { + const cancelled = countEvents(receipt.events, (event) => event.type === "task:cancelled"); + if (cancelled < scenario.taskCount) { + findings.push({ + code: "expected_cancellation_not_observed", + severity: "error", + message: "not every storm worker emitted task cancellation evidence", + expected: scenario.taskCount, + actual: cancelled, + }); + } + if (receipt.terminal.outcome !== "cancelled") { + findings.push({ + code: "expected_cancellation_not_observed", + severity: "error", + message: "scope terminal outcome was not cancelled", + expected: "cancelled", + actual: receipt.terminal.outcome, + }); + } + /* v8 ignore next -- cancellation storm settles through child cancellation evidence. */ + if (error !== undefined) pushUnexpectedError(findings, error); +} + +function verifyCleanupHang(receipt: WorkItReceipt, error: unknown, findings: FaultFinding[]): void { + const cleanupTimeouts = countEvents(receipt.events, (event) => + event.type === "task:cleanup_timeout" || event.type === "scope:cleanup_timeout"); + if (cleanupTimeouts < 1) { + findings.push({ + code: "cleanup_timeout_not_observed", + severity: "error", + message: "cleanup timeout evidence was not emitted", + expected: 1, + actual: cleanupTimeouts, + }); + } + /* v8 ignore next -- cleanup timeout scenarios complete through cleanup evidence. */ + if (error !== undefined) pushUnexpectedError(findings, error); +} + +function verifyProviderTimeout(receipt: WorkItReceipt, error: unknown, findings: FaultFinding[]): void { + const timeoutFailures = countEvents(receipt.events, (event) => + event.type === "task:failed" && event.error?.name === "TimeoutError"); + if (timeoutFailures < 1) { + findings.push({ + code: "provider_timeout_not_observed", + severity: "error", + message: "timeout wrapper did not emit task failure evidence", + expected: 1, + actual: timeoutFailures, + }); + } + if (error === undefined) pushExpectedErrorMissing(findings); +} + +function verifyRetryExhaustion( + scenario: RetryExhaustionScenario, + receipt: WorkItReceipt, + error: unknown, + findings: FaultFinding[], +): void { + const retryEvents = countEvents(receipt.events, (event) => event.type === "task:retrying"); + const expectedRetries = scenario.attempts - 1; + if (retryEvents !== expectedRetries) { + findings.push({ + code: "retry_exhaustion_not_observed", + severity: "error", + message: "retry exhaustion evidence did not match configured attempts", + expected: expectedRetries, + actual: retryEvents, + }); + } + /* v8 ignore next -- retry exhaustion uses a task body that always throws. */ + if (error === undefined) pushExpectedErrorMissing(findings); +} + +function countEvents( + events: readonly WorkItReceiptEvent[], + predicate: (event: WorkItReceiptEvent) => boolean, +): number { + return events.filter(predicate).length; +} + +function cleanupTimeoutFor(scenario: FaultScenario): Duration | undefined { + return scenario.kind === "cleanup_hang" ? scenario.cleanupTimeoutMs : undefined; +} + +function pushUnexpectedError(findings: FaultFinding[], error: unknown): void { + findings.push({ + code: "unexpected_error", + severity: "error", + message: "scenario threw although the injected behavior should settle through scope evidence", + actual: normalizeError(error).name, + }); +} + +function pushExpectedErrorMissing(findings: FaultFinding[]): void { + findings.push({ + code: "expected_error_not_observed", + severity: "error", + message: "scenario did not throw although the injected behavior should fail the owned task", + }); +} + +function normalizeError(error: unknown): FaultErrorEvidence { + if (error instanceof Error) { + return { name: error.name, message: error.message }; + } + return { name: typeof error, message: String(error) }; +} + +function parsePositiveDuration(value: Duration, field: string): number { + const ms = parseDuration(value); + if (ms <= 0) throw new RangeError(`${field} must be greater than 0ms`); + return ms; +} + +function parseNonNegativeDuration(value: Duration, field: string): number { + void field; + const ms = parseDuration(value); + return ms; +} + +function assertPositiveInteger(field: string, value: number, max: number): void { + if (!Number.isInteger(value) || value < 1 || value > max) { + throw new RangeError(`${field} must be an integer between 1 and ${max}`); + } +} + +function sleep(ms: number, signal?: AbortSignal): Promise { + /* v8 ignore next -- built-in scenarios pass live signals before cancellation is injected. */ + 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/src/ledger/index.ts b/packages/core/src/ledger/index.ts index eaa9f2e..fcf325d 100644 --- a/packages/core/src/ledger/index.ts +++ b/packages/core/src/ledger/index.ts @@ -14,6 +14,8 @@ import { mkdir, readdir, readFile, rename, writeFile } from "node:fs/promises"; import { join } from "node:path"; import type { WorkItReceipt } from "../replay/index.js"; +type MaybePromise = T | Promise; + /** Stored receipt metadata returned by ledger appends and listings. */ export interface ReceiptLedgerRecord { readonly receiptId: string; @@ -41,12 +43,51 @@ export interface FileReceiptLedgerOptions { readonly clock?: () => number; } +/** Minimal SQLite client port used by the receipt ledger adapter. */ +export interface SqliteReceiptLedgerClient { + exec(sql: string): MaybePromise; + run(sql: string, params?: readonly unknown[]): MaybePromise; + get(sql: string, params?: readonly unknown[]): MaybePromise; + all(sql: string, params?: readonly unknown[]): MaybePromise; +} + +/** Options for SQLite-backed receipt ledgers. */ +export interface SqliteReceiptLedgerOptions { + readonly db: SqliteReceiptLedgerClient; + readonly tableName?: string; + readonly clock?: () => number; +} + +/** Minimal Postgres client port used by the receipt ledger adapter. */ +export interface PostgresReceiptLedgerClient { + query( + sql: string, + params?: readonly unknown[], + ): MaybePromise<{ readonly rows: readonly T[] }>; +} + +/** Options for Postgres-backed receipt ledgers. */ +export interface PostgresReceiptLedgerOptions { + readonly db: PostgresReceiptLedgerClient; + readonly tableName?: string; + readonly clock?: () => number; +} + interface StoredReceipt { readonly record: ReceiptLedgerRecord; readonly receipt: WorkItReceipt; } -const DEFAULT_MAX_MEMORY_RECEIPTS = 10_000; +interface SqlReceiptRow { + readonly receipt_id: unknown; + readonly checksum: unknown; + readonly created_at: unknown; + readonly stored_at: unknown; + readonly receipt_json: unknown; +} + +const DEFAULT_SQL_LEDGER_TABLE = "workit_receipts"; +const SQL_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/u; /** Error thrown when the same receipt id is appended with different content. */ export class ReceiptLedgerConflictError extends Error { @@ -61,7 +102,7 @@ export class ReceiptLedgerConflictError extends Error { /** 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; + const maxReceipts = opts.maxReceipts ?? 10_000; if (!Number.isInteger(maxReceipts) || maxReceipts < 1) { throw new RangeError("maxReceipts must be a positive integer"); } @@ -99,6 +140,128 @@ export function createMemoryReceiptLedger(opts: MemoryReceiptLedgerOptions = {}) }; } +/** Creates a SQLite-backed append-only receipt ledger through a caller-owned database client. */ +export function createSqliteReceiptLedger(opts: SqliteReceiptLedgerOptions): ReceiptLedger { + const table = quoteSqlIdentifierPath(opts.tableName ?? DEFAULT_SQL_LEDGER_TABLE); + const clock = opts.clock ?? Date.now; + let initialized = false; + + const ensureInitialized = async (): Promise => { + if (initialized) return; + await opts.db.exec([ + `CREATE TABLE IF NOT EXISTS ${table} (`, + "receipt_id TEXT PRIMARY KEY,", + "checksum TEXT NOT NULL,", + "created_at INTEGER NOT NULL,", + "stored_at INTEGER NOT NULL,", + "receipt_json TEXT NOT NULL", + ")", + ].join(" ")); + initialized = true; + }; + + const readStored = async (receiptId: string): Promise => { + const row = await opts.db.get( + `SELECT receipt_id, checksum, created_at, stored_at, receipt_json FROM ${table} WHERE receipt_id = ?`, + [receiptId], + ); + return row === undefined ? undefined : sqlRowToStoredReceipt(row); + }; + + return { + async append(receipt) { + await ensureInitialized(); + const checksum = checksumReceipt(receipt); + const storedAt = clock(); + await opts.db.run( + [ + `INSERT OR IGNORE INTO ${table}`, + "(receipt_id, checksum, created_at, stored_at, receipt_json)", + "VALUES (?, ?, ?, ?, ?)", + ].join(" "), + [receipt.receiptId, checksum, receipt.createdAt, storedAt, JSON.stringify(receipt)], + ); + + const stored = await readStored(receipt.receiptId); + return finalizeStoredReceipt(receipt.receiptId, checksum, stored); + }, + async get(receiptId) { + await ensureInitialized(); + return (await readStored(receiptId))?.receipt; + }, + async list() { + await ensureInitialized(); + const rows = await opts.db.all( + `SELECT receipt_id, checksum, created_at, stored_at, receipt_json FROM ${table} ORDER BY created_at ASC, receipt_id ASC`, + ); + return rows.map(sqlRowToStoredReceipt).map((stored) => stored.record); + }, + }; +} + +/** Creates a Postgres-backed append-only receipt ledger through a caller-owned database client. */ +export function createPostgresReceiptLedger(opts: PostgresReceiptLedgerOptions): ReceiptLedger { + const table = quoteSqlIdentifierPath(opts.tableName ?? DEFAULT_SQL_LEDGER_TABLE); + const clock = opts.clock ?? Date.now; + let initialized = false; + + const ensureInitialized = async (): Promise => { + if (initialized) return; + await opts.db.query([ + `CREATE TABLE IF NOT EXISTS ${table} (`, + "receipt_id TEXT PRIMARY KEY,", + "checksum TEXT NOT NULL,", + "created_at BIGINT NOT NULL,", + "stored_at BIGINT NOT NULL,", + "receipt_json JSONB NOT NULL", + ")", + ].join(" ")); + initialized = true; + }; + + const readStored = async (receiptId: string): Promise => { + const result = await opts.db.query( + `SELECT receipt_id, checksum, created_at, stored_at, receipt_json FROM ${table} WHERE receipt_id = $1`, + [receiptId], + ); + return result.rows[0] === undefined ? undefined : sqlRowToStoredReceipt(result.rows[0]); + }; + + return { + async append(receipt) { + await ensureInitialized(); + const checksum = checksumReceipt(receipt); + const storedAt = clock(); + const result = await opts.db.query( + [ + `INSERT INTO ${table}`, + "(receipt_id, checksum, created_at, stored_at, receipt_json)", + "VALUES ($1, $2, $3, $4, $5::jsonb)", + "ON CONFLICT (receipt_id) DO NOTHING", + "RETURNING receipt_id, checksum, created_at, stored_at, receipt_json", + ].join(" "), + [receipt.receiptId, checksum, receipt.createdAt, storedAt, JSON.stringify(receipt)], + ); + const stored = result.rows[0] === undefined + ? await readStored(receipt.receiptId) + : sqlRowToStoredReceipt(result.rows[0]); + + return finalizeStoredReceipt(receipt.receiptId, checksum, stored); + }, + async get(receiptId) { + await ensureInitialized(); + return (await readStored(receiptId))?.receipt; + }, + async list() { + await ensureInitialized(); + const result = await opts.db.query( + `SELECT receipt_id, checksum, created_at, stored_at, receipt_json FROM ${table} ORDER BY created_at ASC, receipt_id ASC`, + ); + return result.rows.map(sqlRowToStoredReceipt).map((stored) => stored.record); + }, + }; +} + /** Creates a file-backed receipt ledger rooted at one directory. */ export function createFileReceiptLedger(opts: FileReceiptLedgerOptions): ReceiptLedger { const clock = opts.clock ?? Date.now; @@ -153,6 +316,50 @@ function checksumReceipt(receipt: WorkItReceipt): string { return createHash("sha256").update(stableStringify(receipt)).digest("hex"); } +function finalizeStoredReceipt( + receiptId: string, + checksum: string, + stored: StoredReceipt | undefined, +): ReceiptLedgerRecord { + if (stored === undefined) throw new Error(`Receipt ledger append did not store receipt id "${receiptId}"`); + if (stored.record.checksum !== checksum) throw new ReceiptLedgerConflictError(receiptId); + return stored.record; +} + +function sqlRowToStoredReceipt(row: SqlReceiptRow): StoredReceipt { + const receiptId = readSqlString(row.receipt_id, "receipt_id"); + const checksum = readSqlString(row.checksum, "checksum"); + const createdAt = readSqlNumber(row.created_at, "created_at"); + const storedAt = readSqlNumber(row.stored_at, "stored_at"); + const receipt = readSqlReceipt(row.receipt_json); + return { + record: { + receiptId, + checksum, + createdAt, + storedAt, + }, + receipt, + }; +} + +function readSqlString(value: unknown, field: string): string { + if (typeof value !== "string") throw new TypeError(`receipt ledger SQL field "${field}" must be a string`); + return value; +} + +function readSqlNumber(value: unknown, field: string): number { + const parsed = typeof value === "number" ? value : typeof value === "bigint" ? Number(value) : Number(value); + if (!Number.isFinite(parsed)) throw new TypeError(`receipt ledger SQL field "${field}" must be numeric`); + return parsed; +} + +function readSqlReceipt(value: unknown): WorkItReceipt { + if (typeof value === "string") return JSON.parse(value) as WorkItReceipt; + if (typeof value === "object" && value !== null) return value as WorkItReceipt; + throw new TypeError("receipt ledger SQL field \"receipt_json\" must be JSON"); +} + async function readStoredReceipt(file: string): Promise { try { return JSON.parse(await readFile(file, "utf8")) as StoredReceipt; @@ -168,6 +375,17 @@ function receiptPath(dir: string, receiptId: string): string { return join(dir, `${Buffer.from(receiptId, "utf8").toString("base64url")}.json`); } +function quoteSqlIdentifierPath(input: string): string { + const parts = input.split("."); + + return parts.map((part) => { + if (!SQL_IDENTIFIER_RE.test(part)) { + throw new RangeError(`SQL identifier "${input}" contains an unsafe segment`); + } + return `"${part}"`; + }).join("."); +} + function compareRecords(a: ReceiptLedgerRecord, b: ReceiptLedgerRecord): number { return a.createdAt - b.createdAt || a.receiptId.localeCompare(b.receiptId); } diff --git a/packages/core/tests/evidence/correctness/agent-authority.mjs b/packages/core/tests/evidence/correctness/agent-authority.mjs new file mode 100644 index 0000000..ad5f0d3 --- /dev/null +++ b/packages/core/tests/evidence/correctness/agent-authority.mjs @@ -0,0 +1,48 @@ +/** + * Correctness evidence: declared agent tool authority. + * + * @author Admilson B. F. Cossa + * SPDX-License-Identifier: Apache-2.0 + */ + +import { AgentCapabilityError, runAgent } from "../../../dist/ai/index.js"; +import { createSuite } from "../harness.mjs"; + +const suite = createSuite("correctness"); + +await suite.proof( + "CORR-006", + "agent authority denies undeclared capability before tool execution", + "denied declared tool capability emits agent:tool_denied and the tool body is never called", + async () => { + let called = false; + const result = await runAgent(async (agent) => { + try { + await agent.tool("write", undefined, async () => { + called = true; + return "unexpected"; + }, { capability: "repo:write" }); + } catch (error) { + if (!(error instanceof AgentCapabilityError)) throw error; + } + return "handled"; + }, { + authority: { allowedCapabilities: ["repo:read"] }, + }); + const denied = result.events.find((event) => event.type === "agent:tool_denied"); + + return { + ok: result.result === "handled" + && !called + && denied?.capability === "repo:write" + && denied.reason === "capability_not_allowed", + called, + events: result.events.map((event) => event.type), + denied, + }; + }, +); + +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/fault-injection.mjs b/packages/core/tests/evidence/correctness/fault-injection.mjs new file mode 100644 index 0000000..151b013 --- /dev/null +++ b/packages/core/tests/evidence/correctness/fault-injection.mjs @@ -0,0 +1,54 @@ +/** + * Correctness evidence: bounded fault injection through real WorkIt scopes. + * + * @author Admilson B. F. Cossa + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createSuite } from "../harness.mjs"; +import { + cancellationStorm, + cleanupHang, + providerTimeout, + retryExhaustion, + runFaultSuite, +} from "../../../dist/fault/index.js"; + +const suite = createSuite("correctness"); + +await suite.proof( + "CORR-011", + "fault injection harness records lifecycle evidence through real scopes", + "bounded cancellation, cleanup timeout, provider timeout, and retry exhaustion scenarios return passing reports with WorkIt receipts", + async () => { + const report = await runFaultSuite([ + cancellationStorm({ taskCount: 2, cancelAfter: 1, workerDuration: 50 }), + cleanupHang({ cleanupTimeout: 5 }), + providerTimeout({ timeout: 5, providerLatency: 50 }), + retryExhaustion({ attempts: 3, initialDelay: 1 }), + ]); + const cleanup = report.reports.find((item) => item.scenario.kind === "cleanup_hang"); + const retry = report.reports.find((item) => item.scenario.kind === "retry_exhaustion"); + const timeout = report.reports.find((item) => item.scenario.kind === "provider_timeout"); + + return { + ok: report.failed === 0 + && cleanup?.receipt.summary.cleanupTimeouts === 1 + && retry?.receipt.events.filter((event) => event.type === "task:retrying").length === 2 + && timeout?.receipt.events.some((event) => event.type === "task:failed" && event.error?.name === "TimeoutError") === true, + passed: report.passed, + failed: report.failed, + scenarios: report.reports.map((item) => ({ + id: item.scenario.id, + kind: item.scenario.kind, + status: item.status, + outcome: item.receipt.terminal.outcome, + findings: item.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/typed-cancellation-contracts.mjs b/packages/core/tests/evidence/correctness/typed-cancellation-contracts.mjs new file mode 100644 index 0000000..f7c5909 --- /dev/null +++ b/packages/core/tests/evidence/correctness/typed-cancellation-contracts.mjs @@ -0,0 +1,59 @@ +/** + * Evidence proof for typed cancellation contract boundaries. + * + * @author Admilson B. F. Cossa + * SPDX-License-Identifier: Apache-2.0 + */ + +import { execFile } from "node:child_process"; +import { createRequire } from "node:module"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +import { createSuite } from "../harness.mjs"; + +const execFileAsync = promisify(execFile); +const require = createRequire(import.meta.url); +const suite = createSuite("correctness"); +const root = new URL("../../../", import.meta.url); +const tscCli = require.resolve("typescript/bin/tsc"); + +await suite.proof( + "CORR-023", + "typed cancellation contracts reject undeclared task composition", + "tsc accepts declared cancellable/shielded composition and rejects plain or misrouted tasks", + async () => { + const result = await execFileAsync( + process.execPath, + [ + tscCli, + "--noEmit", + "--ignoreConfig", + "--target", + "ES2022", + "--module", + "NodeNext", + "--moduleResolution", + "NodeNext", + "--strict", + "--exactOptionalPropertyTypes", + "--noUncheckedIndexedAccess", + "--skipLibCheck", + join("tests", "types", "contracts.ts"), + ], + { cwd: fileURLToPath(root), timeout: 120_000 }, + ); + + return { + ok: result.stdout.trim().length === 0 && result.stderr.trim().length === 0, + fixture: "tests/types/contracts.ts", + compiler: "tsc --noEmit", + claimBoundary: "compile-time intent contract, not body-level cancellation proof", + }; + }, +); + +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/sql-ledger-integration.mjs b/packages/core/tests/evidence/release/sql-ledger-integration.mjs new file mode 100644 index 0000000..62eedf2 --- /dev/null +++ b/packages/core/tests/evidence/release/sql-ledger-integration.mjs @@ -0,0 +1,141 @@ +/** + * Release evidence: real SQLite receipt ledger integration. + * + * @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 { buildReceipt } from "../../../dist/replay/index.js"; +import { + ReceiptLedgerConflictError, + createSqliteReceiptLedger, +} from "../../../dist/ledger/index.js"; +import { createSuite } from "../harness.mjs"; + +const suite = createSuite("release"); + +await suite.proof( + "REL-007", + "SQLite receipt ledger persists through a real file-backed database", + "a receipt appended through node:sqlite is readable after database close and reopen, while conflicting content is rejected", + async () => { + const sqlite = await loadNodeSqlite(); + if (sqlite === null) { + return { + ok: true, + mode: "skipped_node_sqlite_unavailable", + limitation: "Real SQLite integration runs when node:sqlite is present in the active Node runtime.", + }; + } + + const temp = await mkdtemp(join(tmpdir(), "workit-sqlite-ledger-")); + const dbPath = join(temp, "receipts.sqlite"); + try { + const receipt = makeReceipt("sqlite-integration", 1); + const conflict = makeReceipt("sqlite-integration", 2); + const firstClient = createNodeSqliteReceiptClient(new sqlite.DatabaseSync(dbPath)); + const firstLedger = createSqliteReceiptLedger({ db: firstClient, clock: () => 10 }); + const firstRecord = await firstLedger.append(receipt); + firstClient.close(); + + const secondClient = createNodeSqliteReceiptClient(new sqlite.DatabaseSync(dbPath)); + const secondLedger = createSqliteReceiptLedger({ db: secondClient, clock: () => 20 }); + const restored = await secondLedger.get("sqlite-integration"); + const secondRecord = await secondLedger.append(receipt); + const conflictRejected = await catchesConflict(secondLedger.append(conflict)); + const listed = await secondLedger.list(); + secondClient.close(); + + return { + ok: restored?.receiptId === receipt.receiptId + && firstRecord.checksum === secondRecord.checksum + && listed.length === 1 + && listed[0]?.receiptId === receipt.receiptId + && conflictRejected, + mode: "real_node_sqlite", + dbPath: "temporary-file", + restoredOutcome: restored?.terminal.outcome, + firstRecord, + secondRecord, + conflictRejected, + postgres: { + mode: "gated_by_external_database", + env: "WORKIT_POSTGRES_INTEGRATION_URL", + limitation: "Postgres integration remains a separate environment-gated check because this package has no runtime pg dependency.", + }, + }; + } finally { + await rm(temp, { recursive: true, force: true }); + } + }, +); + +const summary = suite.summary(); +process.stdout.write(JSON.stringify(summary, null, 2) + "\n"); +process.exit(summary.failed > 0 ? 1 : 0); + +async function loadNodeSqlite() { + const originalEmitWarning = process.emitWarning; + process.emitWarning = (warning, ...args) => { + const text = typeof warning === "string" ? warning : warning?.message ?? ""; + if (text.includes("SQLite is an experimental feature")) return; + return originalEmitWarning.call(process, warning, ...args); + }; + try { + return await import("node:sqlite"); + } catch { + return null; + } finally { + process.emitWarning = originalEmitWarning; + } +} + +function createNodeSqliteReceiptClient(db) { + return { + exec(sql) { + db.exec(sql); + }, + run(sql, params = []) { + db.prepare(sql).run(...params); + }, + get(sql, params = []) { + return db.prepare(sql).get(...params); + }, + all(sql, params = []) { + return db.prepare(sql).all(...params); + }, + close() { + db.close(); + }, + }; +} + +function makeReceipt(receiptId, completedCount) { + return buildReceipt([], { + id: `scope-${receiptId}`, + status: "closed", + startedAt: 1, + pendingCount: 0, + completedCount, + failedCount: 0, + cancelledCount: 0, + tasks: [], + scopes: [], + }, { + clock: () => 2, + receiptId, + }); +} + +async function catchesConflict(promise) { + try { + await promise; + return false; + } catch (error) { + return error instanceof ReceiptLedgerConflictError; + } +} diff --git a/packages/core/tests/evidence/release/sql-receipt-ledger.mjs b/packages/core/tests/evidence/release/sql-receipt-ledger.mjs new file mode 100644 index 0000000..79d08e2 --- /dev/null +++ b/packages/core/tests/evidence/release/sql-receipt-ledger.mjs @@ -0,0 +1,129 @@ +/** + * Release evidence: SQL receipt ledger port adapters. + * + * @author Admilson B. F. Cossa + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createSuite } from "../harness.mjs"; +import { buildReceipt } from "../../../dist/replay/index.js"; +import { + ReceiptLedgerConflictError, + createPostgresReceiptLedger, + createSqliteReceiptLedger, +} from "../../../dist/ledger/index.js"; + +const suite = createSuite("release"); + +await suite.proof( + "REL-006", + "SQL receipt ledger adapters preserve idempotency and conflict detection", + "SQLite and Postgres ledger ports return the same record for identical appends and reject conflicting receipt content", + async () => { + const sqlite = createSqliteReceiptLedger({ db: createSqliteEvidenceClient(), clock: () => 10 }); + const postgres = createPostgresReceiptLedger({ db: createPostgresEvidenceClient(), clock: () => 10 }); + const receipt = makeReceipt("sql-ledger", 1); + const conflict = makeReceipt("sql-ledger", 2); + + const sqliteFirst = await sqlite.append(receipt); + const sqliteSecond = await sqlite.append(receipt); + const postgresFirst = await postgres.append(receipt); + const postgresSecond = await postgres.append(receipt); + const sqliteConflict = await catchesConflict(sqlite.append(conflict)); + const postgresConflict = await catchesConflict(postgres.append(conflict)); + + return { + ok: sqliteFirst.checksum === sqliteSecond.checksum + && postgresFirst.checksum === postgresSecond.checksum + && sqliteConflict + && postgresConflict + && (await sqlite.get("sql-ledger"))?.terminal.outcome === "completed" + && (await postgres.get("sql-ledger"))?.terminal.outcome === "completed", + sqliteRecord: sqliteFirst, + postgresRecord: postgresFirst, + sqliteConflict, + postgresConflict, + }; + }, +); + +const summary = suite.summary(); +process.stdout.write(JSON.stringify(summary, null, 2) + "\n"); +process.exit(summary.failed > 0 ? 1 : 0); + +function makeReceipt(receiptId, completedCount) { + return buildReceipt([], { + id: `scope-${receiptId}`, + status: "closed", + startedAt: 1, + pendingCount: 0, + completedCount, + failedCount: 0, + cancelledCount: 0, + tasks: [], + scopes: [], + }, { + clock: () => 2, + receiptId, + }); +} + +async function catchesConflict(promise) { + try { + await promise; + return false; + } catch (error) { + return error instanceof ReceiptLedgerConflictError; + } +} + +function createSqliteEvidenceClient() { + const rows = new Map(); + return { + async exec() {}, + async run(sql, params = []) { + if (!sql.includes("INSERT OR IGNORE")) return; + const [receiptId, checksum, createdAt, storedAt, receiptJson] = params; + if (!rows.has(receiptId)) { + rows.set(receiptId, { + receipt_id: receiptId, + checksum, + created_at: createdAt, + stored_at: storedAt, + receipt_json: receiptJson, + }); + } + }, + async get(_sql, params = []) { + return rows.get(params[0]); + }, + async all() { + return [...rows.values()]; + }, + }; +} + +function createPostgresEvidenceClient() { + const rows = new Map(); + return { + async query(sql, params = []) { + if (sql.includes("INSERT INTO")) { + const [receiptId, checksum, createdAt, storedAt, receiptJson] = params; + if (!rows.has(receiptId)) { + const row = { + receipt_id: receiptId, + checksum, + created_at: createdAt, + stored_at: storedAt, + receipt_json: JSON.parse(receiptJson), + }; + rows.set(receiptId, row); + return { rows: [row] }; + } + return { rows: [] }; + } + if (sql.includes("WHERE receipt_id")) return { rows: [rows.get(params[0])].filter(Boolean) }; + return { rows: [...rows.values()] }; + }, + }; +} diff --git a/packages/core/tests/evidence/run-all.mjs b/packages/core/tests/evidence/run-all.mjs index 31671b2..6489819 100644 --- a/packages/core/tests/evidence/run-all.mjs +++ b/packages/core/tests/evidence/run-all.mjs @@ -16,17 +16,22 @@ const files = [ "lifecycle/resource-audit.mjs", "lifecycle/resource-ownership.mjs", "lifecycle/replay-receipts.mjs", + "correctness/agent-authority.mjs", "correctness/analysis-verifiers.mjs", "correctness/activity-boundary.mjs", + "correctness/fault-injection.mjs", "correctness/resource-ownership-model.mjs", "correctness/runtime-contracts.mjs", "correctness/source-protocol-analysis.mjs", "correctness/time-policy-planner.mjs", "correctness/formal-time-policy-model.mjs", "correctness/nested-time-policy-composition.mjs", + "correctness/typed-cancellation-contracts.mjs", "security/worker-boundary.mjs", "release/release-integrity.mjs", "release/receipt-ledger.mjs", + "release/sql-receipt-ledger.mjs", + "release/sql-ledger-integration.mjs", "performance/benchmark-contracts.mjs", ]; diff --git a/packages/core/tests/types/contracts.ts b/packages/core/tests/types/contracts.ts new file mode 100644 index 0000000..9f64481 --- /dev/null +++ b/packages/core/tests/types/contracts.ts @@ -0,0 +1,52 @@ +/** + * Type-level cancellation contract fixture for the WorkIt contracts subpath. + * + * @author Admilson B. F. Cossa + * SPDX-License-Identifier: Apache-2.0 + * + * This fixture proves compile-time composition boundaries. It intentionally + * does not claim to prove that a task body observes `ctx.signal`. + */ + +import type { TaskFn } from "../../dist/index.js"; +import { + cancellable, + discardCancellation, + getTaskContract, + shielded, + typedGroup, + type CancellableTask, + type ShieldedTask, + type TypedTaskSpawner, +} from "../../dist/contracts/index.js"; + +const plainTask: TaskFn = async () => "plain"; +const ownedTask: CancellableTask = cancellable(async () => "owned"); +const shieldedTask: ShieldedTask = shielded(async () => "shielded", { timeout: 50 }); +const discardedTask: ShieldedTask = discardCancellation(ownedTask, "flush_audit", { timeout: 50 }); + +const value: string = await typedGroup(async (spawn: TypedTaskSpawner) => { + const owned = await spawn(ownedTask); + const background = spawn.background(ownedTask); + const shieldedValue = await spawn.shielded(shieldedTask); + const discardedValue = await spawn.shielded(discardedTask); + + await background; + return `${owned}:${shieldedValue}:${discardedValue}`; +}); + +if (value.length === 0) throw new Error("typed contract fixture failed"); +if (getTaskContract(discardedTask)?.kind !== "shielded") throw new Error("contract metadata missing"); + +// @ts-expect-error plain tasks must be declared before entering a typed scope. +await typedGroup(async (spawn) => await spawn(plainTask)); + +// @ts-expect-error shielded tasks must use the explicit shielded spawn boundary. +await typedGroup(async (spawn) => await spawn(shieldedTask)); + +// @ts-expect-error cancellable tasks are not accepted by the shielded boundary. +await typedGroup(async (spawn) => await spawn.shielded(ownedTask)); + +// @ts-expect-error discardCancellation only accepts declared cancellable tasks. +discardCancellation(plainTask, "missing_contract", { timeout: 50 }); + diff --git a/packages/core/tests/unit/ai.test.js b/packages/core/tests/unit/ai.test.js index 2f382d1..622a4a6 100644 --- a/packages/core/tests/unit/ai.test.js +++ b/packages/core/tests/unit/ai.test.js @@ -10,6 +10,7 @@ import assert from "node:assert/strict"; import { CancellationError, ContextBagImpl, group } from "../../dist/index.js"; import { AgentToolCalls, + AgentCapabilityError, BadBatchError, OpenAITokens, embedAll, @@ -575,6 +576,81 @@ test("runAgent composes tool retry timeout and cancellation events", async () => assert.equal(cancelRun.events.some((event) => event.type === "agent:tool_cancelled"), true); }); +test("runAgent enforces declared tool capabilities before execution", async () => { + let called = false; + + const allowedRun = await runAgent(async (agent) => { + return await agent.tool("search", { q: "workit" }, async (input) => { + called = true; + return input.q; + }, { capability: "repo:read" }); + }, { + authority: { allowedCapabilities: ["repo:read"] }, + }); + + assert.equal(allowedRun.result, "workit"); + assert.equal(called, true); + assert.equal(allowedRun.events.some((event) => event.type === "agent:tool_succeeded"), true); + + let deniedCalled = false; + const deniedRun = await runAgent(async (agent) => { + await assert.rejects( + agent.tool("write", { q: "workit" }, async () => { + deniedCalled = true; + return "unexpected"; + }, { capability: "repo:write" }), + AgentCapabilityError, + ); + return "denied"; + }, { + authority: { allowedCapabilities: ["repo:read"] }, + }); + + assert.equal(deniedRun.result, "denied"); + assert.equal(deniedCalled, false); + assert.deepEqual(deniedRun.events.map((event) => event.type), [ + "agent:started", + "agent:tool_denied", + "agent:completed", + ]); + assert.equal(deniedRun.events[1].capability, "repo:write"); + assert.equal(deniedRun.events[1].reason, "capability_not_allowed"); +}); + +test("runAgent authority denial happens before retry and timeout wrappers", async () => { + let attempts = 0; + const deniedRun = await runAgent(async (agent) => { + await assert.rejects( + agent.tool("write", undefined, async () => { + attempts++; + return "unexpected"; + }, { + capability: "repo:write", + retry: { times: 3, initialDelay: 1, maxDelay: 1, jitter: false }, + timeout: 100, + }), + AgentCapabilityError, + ); + return "checked"; + }, { + authority: { deniedCapabilities: ["repo:write"] }, + }); + + assert.equal(deniedRun.result, "checked"); + assert.equal(attempts, 0); + assert.equal(deniedRun.events.some((event) => event.type === "agent:tool_started"), false); + assert.equal(deniedRun.events.some((event) => event.type === "agent:tool_denied"), true); +}); + +test("runAgent rejects malformed declared tool capability labels", async () => { + await runAgent(async (agent) => { + await assert.rejects( + agent.tool("empty", undefined, async () => "unexpected", { capability: "" }), + /agent capability/, + ); + }); +}); + test("runAgent surfaces body failures", async () => { await assert.rejects( runAgent(async () => { diff --git a/packages/core/tests/unit/analysis.test.js b/packages/core/tests/unit/analysis.test.js index ec8de18..c68337c 100644 --- a/packages/core/tests/unit/analysis.test.js +++ b/packages/core/tests/unit/analysis.test.js @@ -12,6 +12,7 @@ import { verifyReceipt, verifyScopeProtocol, verifySourceProtocol, + verifyTimePolicy, } from "../../dist/analysis/index.js"; import { buildReceipt } from "../../dist/replay/index.js"; @@ -418,6 +419,61 @@ test("Given ordered task events and scope events, verifyScopeProtocol passes", ( assert.deepEqual(report.findings, []); }); +test("Given declared time policy within budget, verifyTimePolicy passes", () => { + const report = verifyTimePolicy( + { type: "attempt", duration: 20 }, + { maxUpperBoundMs: 25 }, + ); + + assert.equal(report.status, "pass"); + assert.equal(report.plan.upperBoundMs, 20); + assert.deepEqual(report.findings, []); +}); + +test("Given timeout truncation, verifyTimePolicy reports a warning finding", () => { + const report = verifyTimePolicy({ + type: "timeout", + timeout: 250, + policy: { + type: "retry", + attempt: { type: "attempt", duration: 100 }, + retry: { times: 4, initialDelay: 50, backoff: "fixed", jitter: false }, + }, + }); + const finding = report.findings.find((item) => item.code === "time_policy_warning"); + + assert.equal(report.status, "warn"); + assert.equal(finding?.severity, "warn"); + assert.equal(finding?.estimatedMs, 550); + assert.equal(finding?.limitMs, 250); +}); + +test("Given structural time-policy warning without bounds, verifyTimePolicy keeps optional fields absent", () => { + const report = verifyTimePolicy({ type: "series", policies: [] }); + const finding = report.findings.find((item) => item.code === "time_policy_warning"); + + assert.equal(report.status, "warn"); + assert.equal(finding?.estimatedMs, undefined); + assert.equal(finding?.limitMs, undefined); +}); + +test("Given infeasible deadline and max bound, verifyTimePolicy reports errors", () => { + const report = verifyTimePolicy({ + type: "deadline", + now: 1_000, + deadlineAt: 1_050, + policy: { type: "attempt", duration: 100 }, + }, { + maxUpperBoundMs: 40, + }); + const codes = report.findings.map((finding) => finding.code).sort(); + + assert.equal(report.status, "fail"); + assert.deepEqual(codes, ["deadline_infeasible", "time_budget_exceeded"]); + assert.equal(report.findings.find((finding) => finding.code === "deadline_infeasible")?.severity, "error"); + assert.equal(report.findings.find((finding) => finding.code === "time_budget_exceeded")?.estimatedMs, 50); +}); + test("Given owned source protocol, verifySourceProtocol passes with checked counts", () => { const report = verifySourceProtocol({ version: "workit.source-protocol.v1", diff --git a/packages/core/tests/unit/contracts-subpath.test.js b/packages/core/tests/unit/contracts-subpath.test.js new file mode 100644 index 0000000..04cca57 --- /dev/null +++ b/packages/core/tests/unit/contracts-subpath.test.js @@ -0,0 +1,91 @@ +/** + * Typed cancellation contract 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 } from "../../dist/index.js"; +import { + cancellable, + discardCancellation, + getTaskContract, + isCancellableTask, + isShieldedTask, + shielded, + typedGroup, +} from "../../dist/contracts/index.js"; + +const sleep = (ms, signal) => + new Promise((resolve, reject) => { + if (signal?.aborted === true) return reject(signal.reason); + const timer = setTimeout(resolve, ms); + signal?.addEventListener("abort", () => { + clearTimeout(timer); + reject(signal.reason); + }, { once: true }); + }); + +test("contracts mark cancellable tasks without changing task behavior", async () => { + const task = cancellable(async () => "ok"); + + assert.equal(isCancellableTask(task), true); + assert.equal(isShieldedTask(task), false); + assert.deepEqual(getTaskContract(task), { kind: "cancellable" }); + assert.equal(await typedGroup(async (spawn) => await spawn(task)), "ok"); +}); + +test("typedGroup can spawn explicitly shielded work through shielded boundary", async () => { + const task = shielded(async () => "shielded", { timeout: 100 }); + const result = await typedGroup(async (spawn) => await spawn.shielded(task)); + + assert.equal(result, "shielded"); + assert.equal(isShieldedTask(task), true); + assert.deepEqual(getTaskContract(task), { kind: "shielded", timeout: 100 }); +}); + +test("typedGroup background accepts declared cancellable work", async () => { + const task = cancellable(async () => "background"); + const result = await typedGroup(async (spawn) => { + const handle = spawn.background(task); + return await handle; + }); + + assert.equal(result, "background"); +}); + +test("discardCancellation requires a named reason and records shield metadata", async () => { + const task = cancellable(async () => "audit"); + const shield = discardCancellation(task, "audit_flush", { timeout: 100 }); + + assert.deepEqual(getTaskContract(shield), { + kind: "shielded", + timeout: 100, + discardReason: "audit_flush", + }); + assert.equal(await typedGroup(async (spawn) => await spawn.shielded(shield)), "audit"); + assert.throws(() => discardCancellation(task, " ", { timeout: 100 }), /non-empty reason/); +}); + +test("shielded task remains timeout bounded", async () => { + const task = shielded(async (ctx) => { + await sleep(1_000, ctx.signal); + return "never"; + }, { timeout: 5 }); + + await assert.rejects( + typedGroup(async (spawn) => await spawn.shielded(task)), + CancellationError, + ); +}); + +test("contracts subpath is not exported from the root runtime", async () => { + const root = await import("../../dist/index.js"); + + assert.equal("cancellable" in root, false); + assert.equal("typedGroup" in root, false); + assert.equal("shielded" in root, false); +}); diff --git a/packages/core/tests/unit/fault.test.js b/packages/core/tests/unit/fault.test.js new file mode 100644 index 0000000..9580a03 --- /dev/null +++ b/packages/core/tests/unit/fault.test.js @@ -0,0 +1,177 @@ +/** + * Fault-injection harness tests. + * + * @author Admilson B. F. Cossa + * SPDX-License-Identifier: Apache-2.0 + */ + +import { test } from "vitest"; +import assert from "node:assert/strict"; +import { + cancellationStorm, + cleanupHang, + providerTimeout, + retryExhaustion, + runFaultScenario, + runFaultSuite, +} from "../../dist/fault/index.js"; + +test("Given cancellation storm scenario, harness records cancelled owned tasks", async () => { + const report = await runFaultScenario(cancellationStorm({ + taskCount: 3, + cancelAfter: 1, + workerDuration: 50, + }), { receiptId: "fault-cancel" }); + + assert.equal(report.status, "pass"); + assert.equal(report.receipt.terminal.outcome, "cancelled"); + assert.equal(report.findings.length, 0); + assert.equal(report.receipt.events.filter((event) => event.type === "task:cancelled").length >= 3, true); + assert.equal(report.observations.some((event) => event.type === "fault:injected"), true); +}); + +test("Given cleanup hang scenario, harness records bounded cleanup timeout evidence", async () => { + const report = await runFaultScenario(cleanupHang({ cleanupTimeout: "5ms" }), { + receiptId: "fault-cleanup", + }); + + assert.equal(report.status, "pass"); + assert.equal(report.receipt.summary.cleanupTimeouts, 1); + assert.equal(report.analysis.status, "warn"); + assert.equal(report.protocol.status, "pass"); +}); + +test("Given provider timeout scenario, harness treats the expected timeout as observed evidence", async () => { + const report = await runFaultScenario(providerTimeout({ + timeout: 5, + providerLatency: 50, + }), { receiptId: "fault-provider-timeout" }); + + assert.equal(report.status, "pass"); + assert.equal(report.error.name, "TimeoutError"); + assert.equal( + report.receipt.events.some((event) => event.type === "task:failed" && event.error?.name === "TimeoutError"), + true, + ); +}); + +test("Given retry exhaustion scenario, harness records every retry attempt before failure", async () => { + const report = await runFaultScenario(retryExhaustion({ + attempts: 3, + initialDelay: 1, + }), { receiptId: "fault-retry" }); + + assert.equal(report.status, "pass"); + assert.equal(report.error.name, "Error"); + assert.equal(report.receipt.events.filter((event) => event.type === "task:retrying").length, 2); +}); + +test("Given a fault suite, harness aggregates pass and fail counts", async () => { + const suite = await runFaultSuite([ + cleanupHang({ cleanupTimeout: 5 }), + retryExhaustion({ attempts: 2, initialDelay: 1 }), + ]); + + assert.equal(suite.passed, 2); + assert.equal(suite.failed, 0); + assert.deepEqual(suite.reports.map((report) => report.status), ["pass", "pass"]); +}); + +test("Given default and custom fault options, builders normalize stable scenario contracts", () => { + assert.equal(cancellationStorm().taskCount, 8); + assert.equal(cancellationStorm({ id: "storm-custom" }).id, "storm-custom"); + assert.equal(cleanupHang().id, "fault:cleanup-hang"); + assert.equal(cleanupHang({ id: "cleanup-custom" }).id, "cleanup-custom"); + assert.equal(providerTimeout().id, "fault:provider-timeout"); + assert.equal(providerTimeout({ id: "provider-custom" }).id, "provider-custom"); + assert.equal(retryExhaustion().attempts, 3); + assert.equal(retryExhaustion({ id: "retry-custom" }).id, "retry-custom"); +}); + +test("Given truncated fault evidence, harness returns an explicit failing report", async () => { + const report = await runFaultScenario(cancellationStorm({ + taskCount: 1, + cancelAfter: 1, + workerDuration: 50, + }), { + maxEvents: 1, + receiptId: "fault-truncated", + }); + + assert.equal(report.status, "fail"); + assert.equal(report.findings.some((finding) => finding.code === "expected_cancellation_not_observed"), true); +}); + +test("Given truncated cleanup evidence, harness reports the missing cleanup timeout", async () => { + const report = await runFaultScenario(cleanupHang({ cleanupTimeout: 5 }), { + maxEvents: 1, + receiptId: "fault-cleanup-truncated", + }); + + assert.equal(report.status, "fail"); + assert.equal(report.findings.some((finding) => finding.code === "cleanup_timeout_not_observed"), true); +}); + +test("Given provider timeout scenario that does not time out, harness reports missing timeout evidence", async () => { + const report = await runFaultScenario({ + id: "fault-provider-no-timeout", + kind: "provider_timeout", + timeoutMs: 50, + providerLatencyMs: 1, + }); + + assert.equal(report.status, "fail"); + assert.equal(report.findings.some((finding) => finding.code === "provider_timeout_not_observed"), true); + assert.equal(report.findings.some((finding) => finding.code === "expected_error_not_observed"), true); +}); + +test("Given malformed manual cancellation scenario, harness reports unexpected task error", async () => { + const badDuration = { + [Symbol.toPrimitive]() { + throw "invalid-duration"; + }, + }; + const report = await runFaultScenario({ + id: "fault-cancel-malformed", + kind: "cancellation_storm", + taskCount: 1, + cancelAfterMs: badDuration, + workerDurationMs: 50, + }); + + assert.equal(report.status, "fail"); + assert.equal(report.error.name, "string"); + assert.equal(report.findings.some((finding) => finding.code === "unexpected_error"), true); +}); + +test("Given truncated retry evidence, harness reports retry mismatch", async () => { + const report = await runFaultScenario(retryExhaustion({ + attempts: 3, + initialDelay: 1, + }), { + maxEvents: 1, + receiptId: "fault-retry-truncated", + }); + + assert.equal(report.status, "fail"); + assert.equal(report.findings.some((finding) => finding.code === "retry_exhaustion_not_observed"), true); +}); + +test("Given invalid fault scenario options, builders reject before execution", () => { + assert.throws(() => cancellationStorm({ taskCount: 0 }), /taskCount/); + assert.throws(() => cancellationStorm({ cancelAfter: -1 }), /Invalid duration/); + assert.throws(() => cleanupHang({ cleanupTimeout: 0 }), /cleanupTimeout/); + assert.throws(() => providerTimeout({ timeout: 10, providerLatency: 10 }), /providerLatency/); + assert.throws(() => retryExhaustion({ attempts: 0 }), /attempts/); +}); + +test("Given the root import, fault harness helpers are not exported from the root runtime", async () => { + const root = await import("../../dist/index.js"); + + assert.equal("cancellationStorm" in root, false); + assert.equal("cleanupHang" in root, false); + assert.equal("providerTimeout" in root, false); + assert.equal("retryExhaustion" in root, false); + assert.equal("runFaultScenario" in root, false); + assert.equal("runFaultSuite" in root, false); +}); diff --git a/packages/core/tests/unit/ledger.test.js b/packages/core/tests/unit/ledger.test.js index 530736b..54f37ee 100644 --- a/packages/core/tests/unit/ledger.test.js +++ b/packages/core/tests/unit/ledger.test.js @@ -15,6 +15,8 @@ import { ReceiptLedgerConflictError, createFileReceiptLedger, createMemoryReceiptLedger, + createPostgresReceiptLedger, + createSqliteReceiptLedger, } from "../../dist/ledger/index.js"; function makeReceipt(receiptId, completedCount = 1) { @@ -35,14 +37,13 @@ function makeReceipt(receiptId, completedCount = 1) { } test("Given memory ledger, append is idempotent for identical receipt content", async () => { - const ledger = createMemoryReceiptLedger({ clock: () => 10 }); + const ledger = createMemoryReceiptLedger(); 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"]); @@ -85,7 +86,7 @@ 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 firstLedger = createFileReceiptLedger({ dir }); const firstRecord = await firstLedger.append(receipt); const secondLedger = createFileReceiptLedger({ dir }); @@ -95,7 +96,6 @@ test("Given file ledger, receipts persist across ledger instances", async () => 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 }); } @@ -144,9 +144,288 @@ test("Given malformed stored receipt file, file ledger surfaces parse failure", } }); +test("Given SQLite ledger client, receipts append idempotently and list in stable order", async () => { + const db = createSqliteTestClient(); + const ledger = createSqliteReceiptLedger({ db, clock: () => 10 }); + const receiptB = makeReceipt("receipt-sqlite-b"); + const receiptA = makeReceipt("receipt-sqlite-a"); + + const first = await ledger.append(receiptB); + const second = await ledger.append(receiptB); + await ledger.append(receiptA); + + assert.equal(first.checksum, second.checksum); + assert.deepEqual(await ledger.get("receipt-sqlite-b"), receiptB); + assert.deepEqual((await ledger.list()).map((record) => record.receiptId), [ + "receipt-sqlite-a", + "receipt-sqlite-b", + ]); + assert.equal(db.statements.some((statement) => statement.includes("CREATE TABLE IF NOT EXISTS")), true); +}); + +test("Given SQLite ledger existing receipt, conflicting content is rejected", async () => { + const db = createSqliteTestClient(); + const ledger = createSqliteReceiptLedger({ db }); + + await ledger.append(makeReceipt("receipt-sqlite-conflict", 1)); + + await assert.rejects( + ledger.append(makeReceipt("receipt-sqlite-conflict", 2)), + ReceiptLedgerConflictError, + ); +}); + +test("Given SQL ledger missing ids, get returns undefined after initialization", async () => { + const sqliteLedger = createSqliteReceiptLedger({ db: createSqliteTestClient() }); + const postgresLedger = createPostgresReceiptLedger({ db: createPostgresTestClient() }); + + assert.equal(await sqliteLedger.get("missing-sqlite"), undefined); + assert.equal(await postgresLedger.get("missing-postgres"), undefined); +}); + +test("Given SQL ledger insert does not persist a row, append surfaces storage failure", async () => { + const sqliteLedger = createSqliteReceiptLedger({ db: createSqliteNoStoreClient() }); + const postgresLedger = createPostgresReceiptLedger({ db: createPostgresNoStoreClient() }); + + await assert.rejects( + sqliteLedger.append(makeReceipt("receipt-sqlite-lost-write")), + /did not store/, + ); + await assert.rejects( + postgresLedger.append(makeReceipt("receipt-postgres-lost-write")), + /did not store/, + ); +}); + +test("Given Postgres ledger client, receipts append idempotently and list in stable order", async () => { + const db = createPostgresTestClient(); + const ledger = createPostgresReceiptLedger({ db, clock: () => 10 }); + const receiptB = makeReceipt("receipt-postgres-b"); + const receiptA = makeReceipt("receipt-postgres-a"); + + const first = await ledger.append(receiptB); + const second = await ledger.append(receiptB); + await ledger.append(receiptA); + + assert.equal(first.checksum, second.checksum); + assert.deepEqual(await ledger.get("receipt-postgres-b"), receiptB); + assert.deepEqual((await ledger.list()).map((record) => record.receiptId), [ + "receipt-postgres-a", + "receipt-postgres-b", + ]); + assert.equal(db.statements.some((statement) => statement.includes("CREATE TABLE IF NOT EXISTS")), true); +}); + +test("Given Postgres ledger existing receipt, conflicting content is rejected", async () => { + const db = createPostgresTestClient(); + const ledger = createPostgresReceiptLedger({ db }); + + await ledger.append(makeReceipt("receipt-postgres-conflict", 1)); + + await assert.rejects( + ledger.append(makeReceipt("receipt-postgres-conflict", 2)), + ReceiptLedgerConflictError, + ); +}); + +test("Given SQL ledger rows with supported database number types, parser normalizes records", async () => { + const receipt = makeReceipt("receipt-sql-row"); + const ledger = createSqliteReceiptLedger({ + db: createSqliteStaticRowsClient([makeSqlRow(receipt, { + created_at: 2n, + stored_at: "10", + receipt_json: receipt, + })]), + }); + + const [record] = await ledger.list(); + + assert.equal(record.receiptId, "receipt-sql-row"); + assert.equal(record.createdAt, 2); + assert.equal(record.storedAt, 10); +}); + +test("Given malformed SQL ledger rows, parser rejects invalid storage contracts", async () => { + const receipt = makeReceipt("receipt-malformed-sql"); + + await assert.rejects( + createSqliteReceiptLedger({ + db: createSqliteStaticRowsClient([makeSqlRow(receipt, { receipt_id: 1 })]), + }).list(), + /receipt_id.*string/, + ); + await assert.rejects( + createSqliteReceiptLedger({ + db: createSqliteStaticRowsClient([makeSqlRow(receipt, { created_at: "not-number" })]), + }).list(), + /created_at.*numeric/, + ); + await assert.rejects( + createPostgresReceiptLedger({ + db: createPostgresStaticRowsClient([makeSqlRow(receipt, { receipt_json: 1 })]), + }).list(), + /receipt_json.*JSON/, + ); +}); + +test("Given unsafe SQL ledger table names, constructors reject before issuing SQL", () => { + assert.throws( + () => createSqliteReceiptLedger({ db: createSqliteTestClient(), tableName: "receipts;drop" }), + /SQL identifier/, + ); + assert.throws( + () => createPostgresReceiptLedger({ db: createPostgresTestClient(), tableName: "bad-name" }), + /SQL identifier/, + ); +}); + 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); + assert.equal("createSqliteReceiptLedger" in root, false); + assert.equal("createPostgresReceiptLedger" in root, false); }); + +function createSqliteTestClient() { + const rows = new Map(); + const statements = []; + return { + statements, + async exec(sql) { + statements.push(sql); + }, + async run(sql, params = []) { + statements.push(sql); + if (sql.includes("INSERT OR IGNORE")) { + const [receiptId, checksum, createdAt, storedAt, receiptJson] = params; + if (!rows.has(receiptId)) { + rows.set(receiptId, { + receipt_id: receiptId, + checksum, + created_at: createdAt, + stored_at: storedAt, + receipt_json: receiptJson, + }); + } + } + }, + async get(sql, params = []) { + statements.push(sql); + return rows.get(params[0]); + }, + async all(sql) { + statements.push(sql); + return [...rows.values()].sort(compareSqlRows); + }, + }; +} + +function createSqliteNoStoreClient() { + const statements = []; + return { + statements, + async exec(sql) { + statements.push(sql); + }, + async run(sql) { + statements.push(sql); + }, + async get(sql) { + statements.push(sql); + return undefined; + }, + async all(sql) { + statements.push(sql); + return []; + }, + }; +} + +function createSqliteStaticRowsClient(rows) { + const statements = []; + return { + statements, + async exec(sql) { + statements.push(sql); + }, + async run(sql) { + statements.push(sql); + }, + async get(sql, params = []) { + statements.push(sql); + return rows.find((row) => row.receipt_id === params[0]); + }, + async all(sql) { + statements.push(sql); + return rows; + }, + }; +} + +function createPostgresTestClient() { + const rows = new Map(); + const statements = []; + return { + statements, + async query(sql, params = []) { + statements.push(sql); + if (sql.includes("INSERT INTO")) { + const [receiptId, checksum, createdAt, storedAt, receiptJson] = params; + if (!rows.has(receiptId)) { + const row = { + receipt_id: receiptId, + checksum, + created_at: createdAt, + stored_at: storedAt, + receipt_json: JSON.parse(receiptJson), + }; + rows.set(receiptId, row); + return { rows: [row] }; + } + return { rows: [] }; + } + if (sql.includes("WHERE receipt_id")) return { rows: [rows.get(params[0])].filter(Boolean) }; + return { rows: [...rows.values()].sort(compareSqlRows) }; + }, + }; +} + +function createPostgresNoStoreClient() { + const statements = []; + return { + statements, + async query(sql) { + statements.push(sql); + return { rows: [] }; + }, + }; +} + +function createPostgresStaticRowsClient(rows) { + const statements = []; + return { + statements, + async query(sql, params = []) { + statements.push(sql); + if (sql.includes("WHERE receipt_id")) return { rows: rows.filter((row) => row.receipt_id === params[0]) }; + return { rows }; + }, + }; +} + +function makeSqlRow(receipt, overrides = {}) { + return { + receipt_id: receipt.receiptId, + checksum: "test-checksum", + created_at: receipt.createdAt, + stored_at: 10, + receipt_json: JSON.stringify(receipt), + ...overrides, + }; +} + +function compareSqlRows(a, b) { + return a.created_at - b.created_at || a.receipt_id.localeCompare(b.receipt_id); +}