From 5fd7fac179a6862bd39711ceb3f260a71f4f052d Mon Sep 17 00:00:00 2001 From: 0thernet Date: Sun, 6 Sep 2026 11:52:39 -0400 Subject: [PATCH 1/2] Add opt-in Effect driver with deadline testing --- README.md | 54 ++++ bun.lock | 11 + examples/effect/document-controller.ts | 45 ++++ kb/plans/effect-driver.md | 37 +++ package.json | 29 ++- scripts/check-effect-architecture.test.ts | 87 +++++++ scripts/check-effect-architecture.ts | 245 ++++++++++++++++++ scripts/effect-policy.ts | 13 + scripts/package-artifact.ts | 3 +- scripts/package-smoke.ts | 30 ++- skills/direct/references/adoption.md | 11 + src/effect/boundary.test.ts | 26 ++ src/effect/deadline.ts | 151 +++++++++++ src/effect/driver.test.ts | 290 ++++++++++++++++++++++ src/effect/driver.ts | 213 ++++++++++++++++ src/effect/example.test.ts | 32 +++ src/effect/index.ts | 7 + src/effect/supervisor.ts | 43 ++++ tsconfig.json | 3 + 19 files changed, 1321 insertions(+), 9 deletions(-) create mode 100644 examples/effect/document-controller.ts create mode 100644 kb/plans/effect-driver.md create mode 100644 scripts/check-effect-architecture.test.ts create mode 100644 scripts/check-effect-architecture.ts create mode 100644 scripts/effect-policy.ts create mode 100644 src/effect/boundary.test.ts create mode 100644 src/effect/deadline.ts create mode 100644 src/effect/driver.test.ts create mode 100644 src/effect/driver.ts create mode 100644 src/effect/example.test.ts create mode 100644 src/effect/index.ts create mode 100644 src/effect/supervisor.ts diff --git a/README.md b/README.md index 6d3ee40..90a4b35 100644 --- a/README.md +++ b/README.md @@ -312,6 +312,7 @@ A quiet probe means the declared deterministic work settled. It does not prove t | `@hraness/direct/core` | Advanced catalog, parser, store, runtime, effect, resource, ID, and `Result` mechanics | Framework-free | | `@hraness/direct/react` | Typed context, provider, and external-store hooks for React DOM or React Native | Optional React peer | | `@hraness/direct/testing` | Sessions, manifest and probe parsers, evidence classification, activity scopes, and exact scripted transports | Development and verification | +| `@hraness/direct/effect` | Scoped asynchronous operations, generation-bound commits and deadline testing | Optional Effect 3.22.1 peer; development and verification | | `@hraness/direct/web` | Atomic browser installation, with low-level bridge and firewall escape hatches | Browser only | | `@hraness/direct/tooling/browser-verification` | Protocol-bound bridge reads, product-owned named-box layout contracts, bounded agent-browser commands, local server leases, and artifact writes | Bun 1.3.14 with Node APIs | | `@hraness/direct/tooling/bombadil-campaign` | Direct property and conservative action factories for a Bombadil specification | Bombadil 0.7.2 specification compiler | @@ -340,6 +341,59 @@ for containment, selected no-overlap pairs, alignment, viewport clipping, minimum size, and two-sample stability. It does not inspect the DOM or compare every box pair. +### Test an Effect workflow + +Install `effect@3.22.1` when you use `@hraness/direct/effect`. Existing entry +points do not import Effect. Keep Direct in the development graph and run the +same application workflow with a product-owned test Layer, as shown in the +[document controller example](examples/effect/document-controller.ts). + +Construct `createDirectEffectDriver({ context, layer, clock: "deadline" })` +inside `createDirectSession`'s `create` callback and return `driver.observation` +from `observe`. Call `driver.runExit("operation", operation => program)` to +admit a scoped operation. Commit world changes through `operation.transact`; +it checks the captured generation and rejects closed or settled owners. + +`driver.advance(milliseconds)` advances the existing Direct clock through +absolute deadlines and drains owned continuations. Concurrent 10/20 ms sleeps +finish by 20 ms. Existing FIFO waits still sum to 30 ms. Use only the driver's +advance operation in deadline mode; mixing it with `session.clock.wait` or +manual clock advancement is unsupported. Positive fractional Effect sleeps +round up to the next millisecond; infinite sleeps remain interruptible. Finite +time must stay within the safe integer range. Tied deadlines wake in admission +order, followed by Effect scheduler priority and insertion order. Synchronize +application races explicitly when their winner matters. + +Call `session.dispose()` to fence admission and request interruption +synchronously, then await `driver.close()` to join operations and close Layer +resources. The close report retains operation and background failure Causes, activity +errors and the runtime teardown Exit separately. A Supervisor keeps suspended +Layer workers visible to the probe even after a root operation finishes. +`childFailures` retains observed operation-descendant Causes even when the +root succeeds; joins, races or retries may have handled these exits, so they +do not independently increment the violation counter. Background failure +records are observed Layer-fiber exits, not proof of application failure. +Inspect these results; expected +domain failures and interruption are not interchangeable with defects. A reset +interrupts old-generation operations but retains the Layer: recreate the +session when the product requires new services. Pending old-generation +finalizers remain visible in the probe after the store resets its ledger. + +Deadline mode controls Effect timers and cooperative continuations. Native +callbacks and foreign Promises still execute on their real host. A Promise +that ignores cancellation can continue after its Effect fiber exits; the +product adapter must track that work and prevent late external writes. +Guarded world transactions do not revoke external authority. Async finalizers +that sleep need the test owner to keep advancing time while close is pending. +The default 10,000-step drain budget reports excessive cooperative work; +`drain()` can resume a paused queue during recovery. It cannot preempt a +synchronous infinite loop. + +`bun run check:effect` enforces declared adapter/runtime ownership and rejects +typed floating Effects, unsafe channel assertions and failure-erasing +shortcuts. Its paired fixtures run in the aggregate check. These rules do not +prove domain correctness or linear resource lifetimes. + ### Fuzz one Direct scenario Bombadil can explore a rendered Direct scenario with four recurring bounded diff --git a/bun.lock b/bun.lock index b21eb06..2cf1f32 100644 --- a/bun.lock +++ b/bun.lock @@ -13,6 +13,7 @@ "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.3", + "effect": "3.22.1", "eslint": "^9.39.2", "expo": "~57.0.9", "fast-check": "^4.8.0", @@ -27,11 +28,13 @@ "peerDependencies": { "@antithesishq/bombadil": "0.7.2", "agent-browser": "0.32.3", + "effect": "3.22.1", "react": ">=18 <20", }, "optionalPeers": [ "@antithesishq/bombadil", "agent-browser", + "effect", "react", ], }, @@ -347,6 +350,8 @@ "@sinclair/typebox": ["@sinclair/typebox@0.27.12", "", {}, "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], @@ -533,6 +538,8 @@ "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + "effect": ["effect@3.22.1", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-TNoXushmPOBAjJlthF5d2QwnX2xBPEtcNJr5XKNKbRLbDvBcOYkXlYDfvGfSA0zriwLFuCll5MDtNMAdZL17PQ=="], + "electron-to-chromium": ["electron-to-chromium@1.5.398", "", {}, "sha512-AsvhAxopJGh6museTDMIjn6JpDYOfgu4RLlygomt87MUwBUqTfd/1EiPtx10/LZE8xpTvkP2E9Gafq7lkLtodQ=="], "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], @@ -1155,6 +1162,8 @@ "connect/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + "effect/fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="], + "expo-modules-autolinking/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], "fbjs/promise": ["promise@7.3.1", "", { "dependencies": { "asap": "~2.0.3" } }, "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg=="], @@ -1227,6 +1236,8 @@ "connect/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + "effect/fast-check/pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], + "finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], "glob/minimatch/brace-expansion": ["brace-expansion@5.0.8", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg=="], diff --git a/examples/effect/document-controller.ts b/examples/effect/document-controller.ts new file mode 100644 index 0000000..c10802f --- /dev/null +++ b/examples/effect/document-controller.ts @@ -0,0 +1,45 @@ +import { Context, Data, Effect, Layer, Schema } from "effect"; +import { defineDirect } from "@hraness/direct"; +import { createDirectEffectDriver, type DirectEffectDriverError, type DirectEffectOperation } from "@hraness/direct/effect"; +import { createDirectSession } from "@hraness/direct/testing"; + +const DocumentWorld = Schema.Struct({ text: Schema.String }); +type DocumentWorld = typeof DocumentWorld.Type; +const DocumentResponse = Schema.Struct({ text: Schema.String }); + +export class DocumentUnavailable extends Data.TaggedError("DocumentUnavailable")<{ + readonly reason: unknown; +}> {} + +/** The application owns this port. Direct knows nothing about documents. */ +export class DocumentSource extends Context.Tag("@hraness/direct/example/DocumentSource")< + DocumentSource, { readonly read: Effect.Effect } +>() {} + +/** A complete application workflow whose boundary, time and commit are testable. */ +export function loadDocument( + operation: DirectEffectOperation, +): Effect.Effect { + return Effect.gen(function* () { + const source = yield* DocumentSource; + const response = yield* source.read; + const document = yield* Schema.decodeUnknown(DocumentResponse)(response).pipe( + Effect.mapError(reason => new DocumentUnavailable({ reason })), + ); + yield* operation.transact(() => ({ text: document.text })); + }); +} + +export function createDocumentTestSession(source: Layer.Layer) { + return createDirectSession({ + definition: defineDirect({ + parseWorld: Schema.decodeUnknownSync(DocumentWorld), + defaultScenario: "document.empty", + scenarios: [{ id: "document.empty", title: "Empty document", route: "/", world: { text: "" } }], + coverage: [], + }), + activation: { kind: "query", source: "" }, + create: context => createDirectEffectDriver({ context, layer: source, clock: "deadline" }), + observe: driver => driver.observation, + }); +} diff --git a/kb/plans/effect-driver.md b/kb/plans/effect-driver.md new file mode 100644 index 0000000..8e26848 --- /dev/null +++ b/kb/plans/effect-driver.md @@ -0,0 +1,37 @@ +--- +type: plan +area: effect-driver +status: in-progress +--- + +# Optional Effect driver + +Design issue: https://github.com/hraness/direct/issues/43. Base: `bbd15adaa29c14c0e04856d729817816f5ee2c59`. + +## Outcome and invariants + +Add `@hraness/direct/effect` as an opt-in async owner and deadline test adapter. Keep the existing default/core/testing/web graph free of Effect, FIFO waits additive, session construction and disposal synchronous, and all wire manifests unchanged. Public examples remain neutral and application-owned. + +Use Effect 3.22.1 through an optional exact peer plus development pin, with the existing compiler retained. One Layer and ManagedRuntime belong to each driver. The existing logical runtime stores the single clock value. Effect sleeps use absolute deadlines; mixing FIFO waits or manual logical advancement with this mode is unsupported and detected when possible. Deadline ties wake in admission order; resumed continuations obey Effect scheduler priority then insertion order. Never imply that this controls native audio, browser callbacks or foreign Promise work. + +## Work and verification + +1. Open design issue and publish the public API proposal. Complete. +2. Add typed opt-in driver, scope ownership, captured-generation commit guards, activity/probe integration, bounded deadline scheduling and async close. Implemented; review pending. +3. Exercise independent deadline/property oracles, existing FIFO compatibility, cancellation/reset/finalizer failure and a neutral application controller. Complete focused checks. +4. Add actual CLI architecture checks with paired fixtures, export/package consumption checks, and current adoption documentation. +5. Run focused tests, typecheck, package and repository aggregate through the installed host scheduler; independently review before publishing or merging. + +## Recovery and limitations + +The existing API requires no migration and the new export can be removed before release without changing existing callers. A user-supplied synchronous infinite loop cannot be preempted; the continuation budget bounds cooperative scheduler work only. Foreign callbacks must honor cancellation or use a guarded commit and tracked completion. Async cleanup requiring simulated time needs the test owner to keep advancing time while awaiting close. A completed close report retains operation Causes, activity settlement failures and runtime teardown failure separately. + +## Evidence + +Focused driver, public-controller and browser graph tests pass: `bun test src/effect` reports 18 tests and 175 assertions, including 30 generated deadline schedules. `bun test src/core/runtime.property.test.ts src/testing/activity.test.ts src/testing/session.test.ts src/exports.test.ts` reports 28 compatibility tests and 1,323 assertions. `bun run typecheck`, targeted ESLint, and `bun run check:effect` pass; the copied v1.2.1 policy has 7 tests and 30 assertions. Knowledge-base refresh, check and agent-guide check pass with one advisory orphan plan. + +Independent review found two issues, each reproduced before its fix: a suspended Layer worker was omitted from quiescence, and an unjoined child failure disappeared when its root succeeded. The Supervisor now counts suspended workers and retains operation, child and background failure evidence separately. + +The first admitted aggregate stopped in `test:npm-release`: 10 tests passed and 13 failed because the old file-count budget rejected the added files and historical smoke tests incorrectly required the new export. The file ceiling now allows exactly six additional files, while existing byte/path/mode guards remain. Effect-specific smoke runs only when the governed source manifest declares that export. These package corrections still require focused recovery and installed-consumer proof. + +The integration owner takes the queued final rebuild, focused package/recovery checks, final aggregate, independent review and release. Generated `dist` output must be regenerated for the final source before it is committed for delivery. Package and final-gate success are not yet claimed. diff --git a/package.json b/package.json index 646e862..be1134a 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "./src/index.ts", "./src/react.ts", "./src/testing/index.ts", - "./src/web.ts" + "./src/web.ts", + "./src/effect/index.ts" ], "packageManager": "bun@1.3.14", "repository": { @@ -63,6 +64,10 @@ "./tooling/bundle-boundary": { "types": "./src/tooling/bundle-boundary.ts", "import": "./dist/tooling/bundle-boundary.js" + }, + "./effect": { + "types": "./src/effect/index.ts", + "import": "./dist/effect/index.js" } }, "imports": { @@ -114,12 +119,16 @@ "src/web/fetch-firewall.ts", "skills", "README.md", - "LICENSE" + "LICENSE", + "src/effect/index.ts", + "src/effect/driver.ts", + "src/effect/deadline.ts", + "src/effect/supervisor.ts" ], "scripts": { "build": "bun run build:clean && bun run build:runtime && bun run build:tooling", "build:clean": "bun -e 'await (await import(\"node:fs/promises\")).rm(\"./dist\", { recursive: true, force: true })'", - "build:runtime": "bun build ./src/index.ts ./src/core/index.ts ./src/react.ts ./src/testing/index.ts ./src/web.ts --outdir ./dist --root ./src --target browser --format esm --splitting --packages external", + "build:runtime": "bun build ./src/index.ts ./src/core/index.ts ./src/react.ts ./src/testing/index.ts ./src/web.ts ./src/effect/index.ts --outdir ./dist --root ./src --target browser --format esm --splitting --packages external", "build:tooling": "bun build ./src/tooling/browser-verification-entry.ts ./src/tooling/bundle-boundary.ts --outdir ./dist/tooling --root ./src/tooling --target bun --format esm --splitting --packages external && bun build ./src/tooling/bombadil.ts --outdir ./dist/tooling --root ./src/tooling --target bun --format esm --splitting --packages external", "typecheck": "tsc --noEmit", "test": "bun test ./src", @@ -142,13 +151,15 @@ "kb:check:lane": "bunx --bun github:hraness/kb#v0.15.2 check --root kb --no-catalog", "kb:check": "bunx --bun github:hraness/kb#v0.15.2 check --root kb && bunx --bun github:hraness/kb#v0.15.2 agents check --root kb --repo .", "kb:catalog": "bunx --bun github:hraness/kb#v0.15.2 catalog --root kb", - "check": "bun run typecheck && bun run test:npm-release && bun run build && bun run test:package && bun run test && bun run lint && bun run example:test && bun run example:typecheck && bun run example:verify && bun run example:react-native:test && bun run example:react-native:typecheck && bun run example:react-native:verify", - "prepack": "bun run check" + "check": "bun run typecheck && bun run check:effect && bun run test:npm-release && bun run build && bun run test:package && bun run test && bun run lint && bun run example:test && bun run example:typecheck && bun run example:verify && bun run example:react-native:test && bun run example:react-native:typecheck && bun run example:react-native:verify", + "prepack": "bun run check", + "check:effect": "bun run scripts/effect-policy.ts && bun test scripts/check-effect-architecture.test.ts" }, "peerDependencies": { "@antithesishq/bombadil": "0.7.2", "agent-browser": "0.32.3", - "react": ">=18 <20" + "react": ">=18 <20", + "effect": "3.22.1" }, "peerDependenciesMeta": { "@antithesishq/bombadil": { @@ -159,6 +170,9 @@ }, "react": { "optional": true + }, + "effect": { + "optional": true } }, "devDependencies": { @@ -179,7 +193,8 @@ "react-native-web": "~0.21.2", "typescript": "^6.0.3", "typescript-eslint": "^8.53.0", - "vite": "^8.1.5" + "vite": "^8.1.5", + "effect": "3.22.1" }, "publishConfig": { "access": "public", diff --git a/scripts/check-effect-architecture.test.ts b/scripts/check-effect-architecture.test.ts new file mode 100644 index 0000000..e8a487d --- /dev/null +++ b/scripts/check-effect-architecture.test.ts @@ -0,0 +1,87 @@ +import { expect, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import ts from "typescript"; +import { inspectEffectArchitecture } from "./check-effect-architecture.js"; + +const directory = dirname(fileURLToPath(import.meta.url)); +const fixtureRoot = mkdtempSync(join(directory, ".architecture-fixtures-")); +const fixtures: Readonly> = { + "program.ts": `import {Effect} from "effect"; export const program = Effect.gen(function*(){ yield* Effect.log("ok"); return 1; });`, + "floating.ts": `import {Effect} from "effect"; export const program = Effect.gen(function*(){ Effect.log("forgotten"); return 1; });`, + "voided.ts": `import {Effect} from "effect"; void Effect.succeed(1);`, + "runner.ts": `import {runPromise as execute, succeed} from "effect/Effect"; export const x=execute(succeed(1));`, + "aliased-runner.ts": `import {Effect} from "effect"; const execute=Effect.runPromise; export const x=execute(Effect.succeed(1));`, + "computed-runner.ts": `import {Effect} from "effect"; export const x=Effect["runPromise"](Effect.succeed(1));`, + "runtime-construction.ts": `import {ManagedRuntime,Layer} from "effect"; export const x=ManagedRuntime.make(Layer.empty);`, + "destructured-runner.ts": `import {Effect} from "effect"; const {runPromise:execute}=Effect; export const x=execute(Effect.succeed(1));`, + "local-destructured.ts": `import {Effect} from "effect"; const local={runPromise:()=>Effect.succeed(1)}; const {runPromise:execute}=local; export const x=execute();`, + "unused.ts": `import {Effect} from "effect"; export const x=Effect.gen(function*(){ const dropped=Effect.log("forgotten"); return 1; });`, + "shorthand.ts": `import {Effect} from "effect"; export function make(){ const effect=Effect.succeed(1); return {effect}; }`, + "assigned.ts": `import {Effect,Exit} from "effect"; export function make(){ let result:Exit.Exit=Exit.succeed(1); result=Exit.fail("failure"); return result; }`, + "assigned-unused.ts": `import {Effect} from "effect"; export function make(){ let task:Effect.Effect; task=Effect.log("forgotten"); return 1; }`, + "root.ts": `import {Effect} from "effect"; export const run=(program:Effect.Effect)=>Effect.runPromise(program);`, + "ambient.ts": `import {Effect} from "effect"; export const x=Effect.sync(()=>Date.now()+Math.random());`, + "shadowed.ts": `import {Effect} from "effect"; const fetch=()=>Effect.succeed(1); export const x=fetch();`, + "adapter.ts": `import {Effect} from "effect"; import {readFile} from "node:fs/promises"; export const read=Effect.tryPromise({try:()=>readFile("sample"),catch:()=>({ _tag:"ReadFailed" as const })});`, + "native.ts": `import {Effect} from "effect"; import {readFile} from "node:fs/promises"; export const read=Effect.tryPromise({try:()=>readFile("sample"),catch:()=>({ _tag:"ReadFailed" as const })});`, + "native-dynamic.ts": `import {Effect} from "effect"; export const read=Effect.tryPromise({try:()=>import("node:fs/promises"),catch:()=>({ _tag:"ReadFailed" as const })});`, + "adapter-dynamic.ts": `import {Effect} from "effect"; export const read=Effect.tryPromise({try:()=>import("node:fs/promises"),catch:()=>({ _tag:"ReadFailed" as const })});`, + "unknown-error.ts": `import {Effect} from "effect"; export const x:Effect.Effect=Effect.fail("bad");`, + "any-error.ts": `import {Effect} from "effect"; export const x:Effect.Effect=Effect.succeed(1);`, + "asserted.ts": `import {Effect} from "effect"; export const x=Effect.fail("bad") as Effect.Effect;`, + "erased.ts": `import {Effect} from "effect"; export const x=Effect.orDie(Effect.fail("bad"));`, + "suppressed.ts": `import {Effect} from "effect"; // @ts-ignore\nexport const x=Effect.succeed(1);`, + "suppressed-error.ts": `import {Effect} from "effect"; // @ts-expect-error\nexport const x:Effect.Effect=Effect.fail("bad");`, + "unclassified.ts": `import {Effect} from "effect"; export const x=Effect.succeed(1);`, + "unclassified-dynamic.ts": `export const load=()=>import("effect");`, +}; +for(const [name, source] of Object.entries(fixtures)) writeFileSync(join(fixtureRoot,name),source); +const program=ts.createProgram({rootNames:Object.keys(fixtures).map(name=>join(fixtureRoot,name)),options:{ + strict:true,noEmit:true,skipLibCheck:true,target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.NodeNext, + moduleResolution:ts.ModuleResolutionKind.NodeNext,types:[], +}}); +const findings=inspectEffectArchitecture(program,{ + root:fixtureRoot,modules:Object.keys(fixtures).filter(n=>!n.startsWith("unclassified")), + adapters:["adapter.ts","adapter-dynamic.ts"],runtimeRoots:["root.ts"], +}); +const rules=(file:string)=>findings.filter(f=>f.file===file).map(f=>f.rule); +try { + test("accepts composed programs, real root and adapter, and a local fetch service",()=>{ + for(const file of ["program.ts","root.ts","adapter.ts","adapter-dynamic.ts","shadowed.ts","shorthand.ts","assigned.ts","local-destructured.ts"]) expect(rules(file)).toEqual([]); + }); + test("finds real typed floating Effects even when voided",()=>{ + expect(rules("floating.ts")).toContain("floating-effect"); + expect(rules("voided.ts")).toContain("floating-effect"); + expect(rules("unused.ts")).toContain("unused-effect"); + expect(rules("assigned-unused.ts")).toContain("floating-effect"); + }); + test("recognizes namespace, named, local alias and computed runtime entrypoints",()=>{ + for(const file of ["runner.ts","aliased-runner.ts","computed-runner.ts","runtime-construction.ts","destructured-runner.ts"]) expect(rules(file)).toContain("runtime-owner"); + }); + test("requires adapter ownership for native I/O and ambient clocks",()=>{ + expect(rules("ambient.ts")).toContain("ambient-io"); + expect(rules("native.ts")).toContain("native-import"); + expect(rules("native-dynamic.ts")).toContain("native-import"); + }); + test("rejects erased channels, unsafe assertions and defect conversion",()=>{ + expect(rules("unknown-error.ts")).toContain("explicit-channel"); + expect(rules("any-error.ts")).toContain("explicit-channel"); + expect(rules("any-error.ts")).toContain("explicit-any"); + expect(rules("asserted.ts")).toContain("effect-assertion"); + expect(rules("erased.ts")).toContain("erased-failure"); + }); + test("requires roles for new production Effect modules and refuses suppressions",()=>{ + expect(rules("unclassified.ts")).toContain("unclassified-module"); + expect(rules("unclassified-dynamic.ts")).toContain("unclassified-module"); + expect(rules("suppressed.ts")).toContain("suppression"); + expect(rules("suppressed-error.ts")).toContain("suppression"); + }); + test("fails closed when a governed file is not in the compilation",()=>{ + expect(inspectEffectArchitecture(program,{root:fixtureRoot,modules:["missing.ts"],adapters:[],runtimeRoots:[]}) + .some(f=>f.rule==="policy-source")).toBe(true); + }); +} finally { + rmSync(fixtureRoot,{recursive:true,force:true}); +} diff --git a/scripts/check-effect-architecture.ts b/scripts/check-effect-architecture.ts new file mode 100644 index 0000000..5501e8e --- /dev/null +++ b/scripts/check-effect-architecture.ts @@ -0,0 +1,245 @@ +/** + * Effect architecture checks v1.2.1. Copy with its tests into the owning repository. + * Build-time only: use that repository's TypeScript, no compiler patch/plugin. + * This constrains reviewed modules; it is not a purity or security proof. + */ +import { resolve, relative } from "node:path"; +import ts from "typescript"; + +export interface EffectArchitecturePolicy { + readonly root: string; + /** Every production file importing Effect must declare its role here. */ + readonly modules: readonly string[]; + readonly adapters: readonly string[]; + readonly runtimeRoots: readonly string[]; + /** Exact test/tool directories, never application-wide exclusions. */ + readonly ignoredDirectories?: readonly string[]; +} + +export interface ArchitectureFinding { + readonly file: string; + readonly line: number; + readonly rule: string; + readonly message: string; +} + +const effectPath = /[/\\]effect[/\\](?:dist[/\\]dts|src)[/\\]/; +const effectModule = /^(?:effect(?:\/|$)|@effect\/)/; +const runtimeFunctions = /^(?:run(?:Sync|SyncExit|Promise|PromiseExit|Fork|Callback)(?:With)?|forkDaemon|unsafeMake)$/; +const erasedFailures = new Set(["orDie", "orDieWith", "ignore", "ignoreLogged"]); +const nativeModules = /^(?:node:|bun$|fs(?:\/|$)|child_process$|worker_threads$|http$|https$|net$|tls$|dgram$)/; +const ambientCalls = new Set(["fetch", "setTimeout", "setInterval", "clearTimeout", "clearInterval", "requestAnimationFrame", "cancelAnimationFrame"]); + +/** Use the genuine Effect variance symbol, including aliases and unions. */ +function effectVariants(type: ts.Type): readonly ts.Symbol[] { + if (type.isUnionOrIntersection()) return type.types.flatMap(effectVariants); + return type.getProperties().filter(symbol => + symbol.getName().startsWith("__@EffectTypeId@") && + symbol.declarations?.some(d => effectPath.test(d.getSourceFile().fileName)), + ); +} + +export function inspectEffectArchitecture( + program: ts.Program, + policy: EffectArchitecturePolicy, +): readonly ArchitectureFinding[] { + const checker = program.getTypeChecker(); + const root = resolve(policy.root); + const modules = new Set(policy.modules.map(f => resolve(root, f))); + const adapters = new Set(policy.adapters.map(f => resolve(root, f))); + const runtimeRoots = new Set(policy.runtimeRoots.map(f => resolve(root, f))); + const ignored = (policy.ignoredDirectories ?? []).map(f => resolve(root, f)); + const findings: ArchitectureFinding[] = []; + const sourceByPath = new Map(program.getSourceFiles().map(s => [resolve(s.fileName), s])); + for (const file of [...modules, ...adapters, ...runtimeRoots]) { + if (!sourceByPath.has(file)) findings.push({ file: relative(root, file), line: 1, + rule: "policy-source", message: "Declared architecture file is absent from this TypeScript program." }); + } + for (const file of [...adapters, ...runtimeRoots]) { + if (!modules.has(file)) findings.push({ file: relative(root, file), line: 1, + rule: "policy-role", message: "An adapter/runtime root must also be a governed module." }); + } + const report = (node: ts.Node, rule: string, message: string): void => { + const source = node.getSourceFile(); + findings.push({ file: relative(root, source.fileName).replaceAll("\\", "/"), + line: source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1, + rule, message }); + }; + const actualSymbol = (node: ts.Node): ts.Symbol | undefined => { + let symbol = checker.getSymbolAtLocation(node); + if (symbol && symbol.flags & ts.SymbolFlags.Alias) symbol = checker.getAliasedSymbol(symbol); + return symbol; + }; + const externalEffectSymbol = (node: ts.Expression, seen = new Set()): ts.Symbol | undefined => { + while (ts.isParenthesizedExpression(node)) node = node.expression; + const symbol = ts.isElementAccessExpression(node) && ts.isStringLiteral(node.argumentExpression) + ? checker.getTypeAtLocation(node.expression).getProperty(node.argumentExpression.text) + : actualSymbol(ts.isPropertyAccessExpression(node) ? node.name : node); + if (!symbol || seen.has(symbol)) return undefined; + seen.add(symbol); + if (symbol.declarations?.some(d => effectPath.test(d.getSourceFile().fileName))) return symbol; + for (const declaration of symbol.declarations ?? []) { + if (ts.isVariableDeclaration(declaration) && declaration.initializer) { + const result = externalEffectSymbol(declaration.initializer, seen); + if (result) return result; + } + if (ts.isBindingElement(declaration) && ts.isObjectBindingPattern(declaration.parent)) { + const container = declaration.parent.parent; + const property = declaration.propertyName ?? declaration.name; + if (ts.isVariableDeclaration(container) && container.initializer && + (ts.isIdentifier(property) || ts.isStringLiteral(property))) { + const member = checker.getTypeAtLocation(container.initializer).getProperty(property.text); + if (member?.declarations?.some(d => effectPath.test(d.getSourceFile().fileName))) return member; + } + } + } + return undefined; + }; + const isAmbient = (node: ts.Node): boolean => { + const symbol = actualSymbol(node); + return !!symbol?.declarations?.some(d => d.getSourceFile().isDeclarationFile); + }; + const isEffect = (node: ts.Node): boolean => effectVariants(checker.getTypeAtLocation(node)).length > 0; + const checkChannels = (node: ts.Node): void => { + const type = checker.getTypeAtLocation(node); + for (const variance of effectVariants(type)) { + const fields = checker.getTypeOfSymbolAtLocation(variance, node); + for (const channel of ["_E", "_R"]) { + const field = fields.getProperty(channel); + if (!field) continue; + const signature = checker.getTypeOfSymbolAtLocation(field, node).getCallSignatures()[0]; + if (!signature) continue; + const value = checker.getReturnTypeOfSignature(signature); + if (value.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) { + report(node, "explicit-channel", `${channel === "_E" ? "Error" : "Requirement"} channel must not be any/unknown.`); + } + } + } + }; + for (const source of program.getSourceFiles()) { + const file = resolve(source.fileName); + if (source.isDeclarationFile || program.isSourceFileFromExternalLibrary(source)) continue; + const local = relative(root, file).replaceAll("\\", "/"); + if (local.startsWith("../") || /(?:^|\/)[^/]+\.(?:test|spec)\.[cm]?tsx?$/.test(local)) continue; + if (ignored.some(dir => file === dir || file.startsWith(dir + "/"))) continue; + const findEffectImports = (node: ts.Node): boolean => { + if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && + node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier) && effectModule.test(node.moduleSpecifier.text)) return true; + if (ts.isCallExpression(node) && (node.expression.kind === ts.SyntaxKind.ImportKeyword || + (ts.isIdentifier(node.expression) && node.expression.text === "require" && isAmbient(node.expression)))) { + const argument = node.arguments[0]; + if (argument && ts.isStringLiteral(argument) && effectModule.test(argument.text)) return true; + } + return ts.forEachChild(node, findEffectImports) ?? false; + }; + const importsEffect = findEffectImports(source); + if (!modules.has(file)) { + if (importsEffect) report(source, "unclassified-module", "Production Effect import/export needs an explicit reviewed architecture role."); + continue; + } + const adapter = adapters.has(file); + const runtimeRoot = runtimeRoots.has(file); + const references = new Map(); + const countReferences = (node: ts.Node): void => { + if (ts.isIdentifier(node)) { + const parent = node.parent; + const declarationName = ts.isVariableDeclaration(parent) && parent.name === node; + const assigned = ts.isBinaryExpression(parent) && parent.left === node && + parent.operatorToken.kind === ts.SyntaxKind.EqualsToken; + const symbol = ts.isShorthandPropertyAssignment(node.parent) + ? checker.getShorthandAssignmentValueSymbol(node.parent) + : checker.getSymbolAtLocation(node); + if (symbol && !declarationName && !assigned) references.set(symbol, (references.get(symbol) ?? 0) + 1); + } + ts.forEachChild(node, countReferences); + }; + countReferences(source); + // Source suppressions must not disable a policy inside application code. + const suppression = /@ts-(?:ignore|nocheck|expect-error)|@effect-diagnostics[^\n]*(?:off|disable)/g; + for (const match of source.text.matchAll(suppression)) { + findings.push({ file: local, line: source.getLineAndCharacterOfPosition(match.index).line + 1, + rule: "suppression", message: "Architecture/typing suppression requires removal or an explicit owner-reviewed policy change." }); + } + const visit = (node: ts.Node): void => { + if (ts.isExpressionStatement(node)) { + let expression = node.expression; + while (ts.isParenthesizedExpression(expression) || ts.isVoidExpression(expression)) expression = expression.expression; + const assignedSymbol = ts.isBinaryExpression(expression) && ts.isIdentifier(expression.left) + ? checker.getSymbolAtLocation(expression.left) : undefined; + const storesValue = ts.isBinaryExpression(expression) && expression.operatorToken.kind === ts.SyntaxKind.EqualsToken && + (!ts.isIdentifier(expression.left) || (assignedSymbol !== undefined && (references.get(assignedSymbol) ?? 0) > 0)); + if (isEffect(expression) && !storesValue) report(expression, "floating-effect", "Effect work must be composed, returned or executed by its owner."); + } + if (ts.isAsExpression(node) || ts.isTypeAssertionExpression(node)) { + if (isEffect(node) || isEffect(node.expression)) report(node, "effect-assertion", "Do not assert Effect success/error/requirement channels; provide and handle them."); + } + if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer && isEffect(node.initializer)) { + const symbol = checker.getSymbolAtLocation(node.name); + const statement = node.parent.parent; + const exported = ts.isVariableStatement(statement) && statement.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword); + if (!exported && symbol && (references.get(symbol) ?? 0) === 0) { + report(node, "unused-effect", "An assigned Effect is never consumed or exported."); + } + } + if (node.kind === ts.SyntaxKind.AnyKeyword) report(node, "explicit-any", "Explicit any is not allowed in governed Effect modules."); + if (ts.isTypeReferenceNode(node) || ts.isVariableDeclaration(node) || ts.isPropertySignature(node)) checkChannels(node); + if (!adapter && ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier) && nativeModules.test(node.moduleSpecifier.text)) { + report(node, "native-import", "Native I/O imports belong in an adapter."); + } + if (ts.isCallExpression(node)) { + if (!adapter && (node.expression.kind === ts.SyntaxKind.ImportKeyword || + (ts.isIdentifier(node.expression) && node.expression.text === "require" && isAmbient(node.expression)))) { + const argument = node.arguments[0]; + if (argument && ts.isStringLiteral(argument) && nativeModules.test(argument.text)) { + report(node, "native-import", "Native I/O imports belong in an adapter."); + } + } + const symbol = externalEffectSymbol(node.expression); + const constructsRuntime = symbol?.getName() === "make" && symbol.declarations?.some(d => + /[/\\](?:ManagedRuntime|Runtime)\.(?:d\.)?ts$/.test(d.getSourceFile().fileName)); + if (symbol && (runtimeFunctions.test(symbol.getName()) || constructsRuntime) && !runtimeRoot) { + report(node, "runtime-owner", `${symbol.getName()} belongs in a declared runtime owner.`); + } + if (symbol && erasedFailures.has(symbol.getName())) { + report(node, "erased-failure", "Handle expected failures explicitly; do not erase them or turn them into defects."); + } + if (!adapter) { + const expression = node.expression; + if (ts.isIdentifier(expression) && ambientCalls.has(expression.text) && isAmbient(expression)) { + report(node, "ambient-io", "Use a declared service for ambient I/O and time."); + } + if (ts.isPropertyAccessExpression(expression)) { + const owner = expression.expression; + if (ts.isIdentifier(owner) && isAmbient(owner) && + ((owner.text === "Date" && expression.name.text === "now") || + (owner.text === "Math" && expression.name.text === "random") || + (["globalThis", "window"].includes(owner.text) && ambientCalls.has(expression.name.text)))) { + report(node, "ambient-io", "Use a declared service for ambient I/O and time."); + } + } + } + } + if (!adapter && ts.isNewExpression(node) && ts.isIdentifier(node.expression) && + ["Promise", "Date", "Worker", "AbortController"].includes(node.expression.text) && isAmbient(node.expression)) { + report(node, "native-constructor", "Foreign asynchronous resources belong in an adapter."); + } + if (!adapter && ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.expression) && + node.expression.text === "process" && node.name.text === "env" && isAmbient(node.expression)) { + report(node, "ambient-config", "Configuration belongs in an injected service."); + } + ts.forEachChild(node, visit); + }; + visit(source); + } + const unique = new Map(findings.map(f => [`${f.file}:${f.line}:${f.rule}:${f.message}`, f])); + return [...unique.values()].sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.rule.localeCompare(b.rule)); +} + +/** Parse the real project config; callers separately retain ordinary tsc. */ +export function createArchitectureProgram(configFile: string): ts.Program { + const config = ts.readConfigFile(configFile, file => ts.sys.readFile(file)); + if (config.error) throw new Error(ts.flattenDiagnosticMessageText(config.error.messageText, "\n")); + const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, resolve(configFile, "..")); + if (parsed.errors.length) throw new Error(parsed.errors.map(e => ts.flattenDiagnosticMessageText(e.messageText, "\n")).join("\n")); + return ts.createProgram({ rootNames: parsed.fileNames, options: { ...parsed.options, noEmit: true } }); +} diff --git a/scripts/effect-policy.ts b/scripts/effect-policy.ts new file mode 100644 index 0000000..f826c2e --- /dev/null +++ b/scripts/effect-policy.ts @@ -0,0 +1,13 @@ +import { resolve } from "node:path"; +import { createArchitectureProgram, inspectEffectArchitecture } from "./check-effect-architecture.js"; + +const root = resolve(import.meta.dir, ".."); +const findings = inspectEffectArchitecture(createArchitectureProgram(resolve(root, "tsconfig.json")), { + root, + modules: ["src/effect/driver.ts", "src/effect/deadline.ts", "src/effect/index.ts", "src/effect/supervisor.ts", "examples/effect/document-controller.ts"], + adapters: ["src/effect/driver.ts", "src/effect/deadline.ts"], + runtimeRoots: ["src/effect/driver.ts"], + ignoredDirectories: ["scripts"], +}); +for (const finding of findings) console.error(`${finding.file}:${String(finding.line)} ${finding.rule}: ${finding.message}`); +if (findings.length > 0) process.exitCode = 1; diff --git a/scripts/package-artifact.ts b/scripts/package-artifact.ts index 3e9044a..eef2b92 100644 --- a/scripts/package-artifact.ts +++ b/scripts/package-artifact.ts @@ -8,7 +8,8 @@ const maximumTarBytes = 2_000_000; const packageBudget = Object.freeze({ entryCount: { min: 50, max: 120 }, - fileCount: { min: 50, max: 60 }, + // Four optional Effect source files, one entry and one possible split chunk. + fileCount: { min: 50, max: 66 }, packedBytes: { min: 140_000, max: 260_000 }, unpackedBytes: { min: 650_000, max: 1_200_000 }, }); diff --git a/scripts/package-smoke.ts b/scripts/package-smoke.ts index f98bb3d..6bce71a 100644 --- a/scripts/package-smoke.ts +++ b/scripts/package-smoke.ts @@ -28,7 +28,7 @@ const toolingTypeImportSpecifiers = [ ]; const importSpecifiers = [...runtimeImportSpecifiers, ...toolingRuntimeImportSpecifiers]; const binNames: readonly string[] = []; -const verificationPackages = ["@antithesishq/bombadil@0.7.2","@eslint/js@^9.39.2","@expo/metro-runtime@~57.0.6","@types/bun@^1.3.14","@types/node@^24.10.0","@types/react@^19.2.14","@types/react-dom@^19.2.3","@vitejs/plugin-react@^6.0.3","eslint@^9.39.2","expo@~57.0.9","fast-check@^4.8.0","react@19.2.3","react-dom@19.2.3","react-native@0.86.2","react-native-web@~0.21.2","typescript@^6.0.3","typescript-eslint@^8.53.0","vite@^8.1.5"]; +const verificationPackages = ["@antithesishq/bombadil@0.7.2","@eslint/js@^9.39.2","@expo/metro-runtime@~57.0.6","@types/bun@^1.3.14","@types/node@^24.10.0","@types/react@^19.2.14","@types/react-dom@^19.2.3","@vitejs/plugin-react@^6.0.3","effect@3.22.1","eslint@^9.39.2","expo@~57.0.9","fast-check@^4.8.0","react@19.2.3","react-dom@19.2.3","react-native@0.86.2","react-native-web@~0.21.2","typescript@^6.0.3","typescript-eslint@^8.53.0","vite@^8.1.5"]; type PackageInput = Readonly<{ archive?: string; @@ -576,6 +576,11 @@ if ( ) { throw new Error("package.json must declare a string version"); } +const supportsEffectDriver = Object.hasOwn(record(packageManifest.exports, "package exports"), "./effect"); +if (supportsEffectDriver) { + runtimeImportSpecifiers.push("@hraness/direct/effect"); + importSpecifiers.push("@hraness/direct/effect"); +} const bombadilFeatureProfile = selectBombadilFeatureProfile(packageManifest.version); const supportsBombadilBoaNamedSnapshots = Bun.semver.order( packageManifest.version, @@ -658,6 +663,29 @@ try { tooling: false, })); await run([process.execPath, "x", "tsc", "-p", "./tsconfig.nodenext.json"], consumer); + if (supportsEffectDriver) { + await writeFile(join(consumer, "document-controller.ts"), await readFile( + join(repository, "examples/effect/document-controller.ts"), "utf8", + )); + await writeFile(join(consumer, "effect-smoke.ts"), ` + import { Effect, Exit, Layer } from "effect"; + import { createDocumentTestSession, DocumentSource, loadDocument } from "./document-controller.ts"; + const created = createDocumentTestSession(Layer.succeed(DocumentSource, { + read: Effect.sleep(20).pipe(Effect.as({ text: "Installed package" })), + })); + if (!created.ok) throw new Error(created.error.message); + const session = created.value; + const result = session.harness.runExit("document", loadDocument); + const advanced = session.harness.advance(20); + if (!advanced.ok || advanced.value !== 20) throw new Error("Deadline did not advance"); + if (!Exit.isSuccess(await result)) throw new Error("Controller failed"); + if (session.store.getSnapshot().world.text !== "Installed package") throw new Error("Commit missing"); + session.dispose(); + const closed = await session.harness.close(); + if (!Exit.isSuccess(closed.runtime) || closed.settlementErrors.length) throw new Error("Cleanup failed"); + `); + await run([process.execPath, "run", "./effect-smoke.ts"], consumer); + } await writeFile(join(consumer, "tsconfig.tooling-bundler.json"), typeScriptConfig({ include: "tooling-index.ts", module: "Preserve", diff --git a/skills/direct/references/adoption.md b/skills/direct/references/adoption.md index a2b4151..a34cba7 100644 --- a/skills/direct/references/adoption.md +++ b/skills/direct/references/adoption.md @@ -33,6 +33,17 @@ Reject a design that conditionally imports fixtures from a query string, build f Treat the shared world store as a scenario seed and activity ledger. Let product adapters own mutable repositories or event streams after construction. +For an Effect application workflow, use the optional `@hraness/direct/effect` +entry with an exact `effect@3.22.1` peer. Construct one driver inside the session +factory, expose its `observation`, and run the production workflow against a +product-owned Layer. Select `clock: "deadline"` explicitly and advance only +through that driver; do not mix this mode with FIFO waits. Use the operation's +generation-bound `transact` for world commits. Keep the session's synchronous +disposal and separately await `driver.close()`, inspecting its operation, +activity and runtime cleanup results. Recreate the session when reset requires +fresh Layer services. Foreign callbacks and native work need their own +cancellation and pending-work proof. + A scenario contains initial world, route, and optional logical-runtime state. Product-verifier actions, semantic assertions, and evidence policy do not belong in the scenario catalog. ## Add the development entry diff --git a/src/effect/boundary.test.ts b/src/effect/boundary.test.ts new file mode 100644 index 0000000..6e161a3 --- /dev/null +++ b/src/effect/boundary.test.ts @@ -0,0 +1,26 @@ +import { expect, test } from "bun:test"; +import * as effect from "@hraness/direct/effect"; +import { isRecord } from "../core/result.js"; + +function inputs(metafile: unknown): readonly string[] { + const parsed: unknown = typeof metafile === "string" ? JSON.parse(metafile) : metafile; + if (!isRecord(parsed) || !isRecord(parsed.inputs)) throw new Error("Invalid bundle input metadata"); + return Object.keys(parsed.inputs); +} + +test("Effect is opt-in and existing browser graphs exclude its implementation", async () => { + expect(Object.keys(effect)).toEqual(["createDirectEffectDriver"]); + const built = await Bun.build({ + entrypoints: ["./src/index.ts", "./src/core/index.ts", "./src/testing/index.ts", "./src/react.ts", "./src/web.ts"], + target: "browser", external: ["react"], metafile: true, + }); + expect(built.success).toBeTrue(); + if (built.metafile === undefined) throw new Error("missing bundle input evidence"); + const paths = inputs(built.metafile); + expect(paths.length).toBeGreaterThan(5); + expect(paths.filter(path => /(?:node_modules\/effect\/|src\/effect\/)/u.test(path))).toEqual([]); + const optedIn = await Bun.build({ entrypoints: ["./src/effect/index.ts"], target: "browser", metafile: true }); + expect(optedIn.success).toBeTrue(); + if (optedIn.metafile === undefined) throw new Error("missing Effect bundle input evidence"); + expect(inputs(optedIn.metafile).some(path => path.includes("node_modules/effect/"))).toBeTrue(); +}); diff --git a/src/effect/deadline.ts b/src/effect/deadline.ts new file mode 100644 index 0000000..5f62837 --- /dev/null +++ b/src/effect/deadline.ts @@ -0,0 +1,151 @@ +import { Clock, Duration, Effect, Scheduler } from "effect"; +import type { LogicalRuntime } from "../core/runtime.js"; +import { err, ok, type Result } from "../core/result.js"; + +export interface DirectEffectDriverError { + readonly _tag: "DirectEffectDriverError"; + readonly code: "closed" | "stale-generation" | "operation-settled" | "activity-failed" + | "store-failed" | "invalid-duration" | "clock-conflict" | "step-limit" | "reentrant-advance"; + readonly message: string; + readonly reason: unknown; +} + +export function driverError( + code: DirectEffectDriverError["code"], message: string, reason: unknown = null, +): DirectEffectDriverError { + return Object.freeze({ _tag: "DirectEffectDriverError", code, message, reason }); +} + +interface Sleeper { + readonly deadline: number; + readonly sequence: number; + readonly wake: () => void; +} + +/** Internal clock and scheduler. All time is stored in the existing logical runtime. */ +export function createDeadlineControl( + runtime: LogicalRuntime, + maxSteps: number, + report: (error: DirectEffectDriverError) => void, +) { + let expectedTime = runtime.now(); + let sequence = 0; + let draining = false; + let advancing = false; + let automaticQueued = false; + let paused = false; + let turnSteps = 0; + const sleepers = new Set(); + const tasks: { readonly task: Scheduler.Task; readonly priority: number; readonly sequence: number }[] = []; + + const drain = (): Result => { + if (draining) return ok(0); + draining = true; + let steps = 0; + try { + while (tasks.length > 0) { + if ((advancing ? turnSteps : steps) >= maxSteps) { + paused = true; + const failure = driverError("step-limit", `Deadline continuation budget exceeded ${String(maxSteps)} steps`); + report(failure); + return err(failure); + } + tasks.sort((left, right) => left.priority - right.priority || left.sequence - right.sequence); + const next = tasks.shift(); + if (next === undefined) break; + steps += 1; + turnSteps += 1; + next.task(); + } + paused = false; + return ok(steps); + } finally { + draining = false; + } + }; + + const scheduler: Scheduler.Scheduler = { + shouldYield: fiber => Scheduler.defaultScheduler.shouldYield(fiber), + scheduleTask: (task, priority) => { + tasks.push({ task, priority, sequence: sequence++ }); + if (!automaticQueued && !paused && !draining && !advancing) { + automaticQueued = true; + queueMicrotask(() => { + automaticQueued = false; + if (!paused) drain(); + }); + } + }, + }; + + const clock: Clock.Clock = { + [Clock.ClockTypeId]: Clock.ClockTypeId, + unsafeCurrentTimeMillis: () => runtime.now(), + unsafeCurrentTimeNanos: () => BigInt(runtime.now()) * 1_000_000n, + currentTimeMillis: Effect.sync(() => runtime.now()), + currentTimeNanos: Effect.sync(() => BigInt(runtime.now()) * 1_000_000n), + sleep: input => Effect.suspend(() => { + const decoded = Duration.toMillis(input); + if (decoded === Infinity) return Effect.never; + const duration = Math.ceil(decoded); + const deadline = runtime.now() + duration; + if (!Number.isSafeInteger(duration) || duration < 0 || !Number.isSafeInteger(deadline)) { + return Effect.die(driverError("invalid-duration", "Deadline sleep requires safe non-negative integer milliseconds")); + } + if (duration === 0) return Effect.void; + return Effect.async(resume => { + const sleeper: Sleeper = { + deadline, + sequence: sequence++, + wake: () => resume(Effect.void), + }; + sleepers.add(sleeper); + return Effect.sync(() => { sleepers.delete(sleeper); }); + }); + }), + }; + + const advance = (milliseconds: number): Result => { + if (advancing || draining) return err(driverError("reentrant-advance", "Advance must run outside an Effect continuation")); + if (!Number.isSafeInteger(milliseconds) || milliseconds < 0 || !Number.isSafeInteger(expectedTime + milliseconds)) { + return err(driverError("invalid-duration", "Advance requires safe non-negative integer milliseconds")); + } + if (runtime.now() !== expectedTime) { + return err(driverError("clock-conflict", "Do not mix FIFO waits or direct clock advancement with deadline mode")); + } + advancing = true; + turnSteps = 0; + const target = expectedTime + milliseconds; + try { + const initial = drain(); + if (!initial.ok) return initial; + while (true) { + const next = [...sleepers] + .filter(sleeper => sleeper.deadline <= target) + .sort((left, right) => left.deadline - right.deadline || left.sequence - right.sequence)[0]; + if (next === undefined) break; + if (turnSteps >= maxSteps) { + const failure = driverError("step-limit", `Deadline wake budget exceeded ${String(maxSteps)} steps`); + report(failure); + return err(failure); + } + turnSteps += 1; + const advanced = runtime.advance(next.deadline - runtime.now()); + if (!advanced.ok) return err(driverError("invalid-duration", advanced.error.message, advanced.error)); + expectedTime = advanced.value; + sleepers.delete(next); + next.wake(); + const drained = drain(); + if (!drained.ok) return drained; + } + const advanced = runtime.advance(target - runtime.now()); + if (!advanced.ok) return err(driverError("invalid-duration", advanced.error.message, advanced.error)); + expectedTime = advanced.value; + return ok(expectedTime); + } finally { + advancing = false; + } + }; + + return { clock, scheduler, advance, drain, pending: () => tasks.length, sleeps: () => sleepers.size }; +} diff --git a/src/effect/driver.test.ts b/src/effect/driver.test.ts new file mode 100644 index 0000000..343dbfd --- /dev/null +++ b/src/effect/driver.test.ts @@ -0,0 +1,290 @@ +import { describe, expect, test } from "bun:test"; +import { Cause, Context, Effect, Exit, Fiber, Layer, Schedule } from "effect"; +import fc from "fast-check"; +import { defineDirect } from "../core/definition.js"; +import { createLogicalRuntime } from "../core/runtime.js"; +import { parseTestWorld, type TestWorld } from "../core/test-support.js"; +import { createDirectSession } from "../testing/session.js"; +import { createDirectEffectDriver, type DirectEffectOperation } from "./index.js"; + +function session(layer: Layer.Layer, maxSteps = 10_000) { + const created = createDirectSession({ + definition: defineDirect({ + parseWorld: parseTestWorld, defaultScenario: "counter.empty", + scenarios: [{ id: "counter.empty", title: "Empty counter", route: "/", world: { count: 0, messages: [] } }], + coverage: [], + }), + activation: { kind: "query", source: "" }, + create: context => createDirectEffectDriver({ context, layer, clock: "deadline", maxSteps }), + observe: driver => driver.observation, + }); + if (!created.ok) throw new Error(created.error.message); + return created.value; +} + +describe("opt-in Effect driver", () => { + test("deadline 10/20 completes at 20 while existing FIFO still sums to 30", async () => { + const fifo = createLogicalRuntime(undefined, async () => undefined); + await Promise.all([fifo.wait(10), fifo.wait(20)]); + expect(fifo.now()).toBe(30); + const owner = session(Layer.empty); + const trace: number[] = []; + const work = owner.harness.runExit("waits", () => Effect.all([ + Effect.sleep(10).pipe(Effect.zipRight(Effect.sync(() => { trace.push(owner.clock.now()); }))), + Effect.sleep(20).pipe(Effect.zipRight(Effect.sync(() => { trace.push(owner.clock.now()); }))), + ], { concurrency: "unbounded" })); + expect(owner.harness.advance(9)).toEqual({ ok: true, value: 9 }); + expect(trace).toEqual([]); + expect(owner.harness.advance(11)).toEqual({ ok: true, value: 20 }); + expect(Exit.isSuccess(await work)).toBeTrue(); + expect(trace).toEqual([10, 20]); + expect(owner.probe.isQuiescent()).toEqual({ ok: true, value: true }); + owner.dispose(); + await owner.harness.close(); + }); + + test("arbitrary concurrent deadlines follow independent sorted elapsed-time oracle", async () => { + await fc.assert(fc.asyncProperty(fc.array(fc.integer({ min: 1, max: 100 }), { minLength: 1, maxLength: 12 }), async durations => { + const owner = session(Layer.empty); + const trace: number[] = []; + const work = owner.harness.runExit("law", () => Effect.all(durations.map(duration => + Effect.sleep(duration).pipe(Effect.zipRight(Effect.sync(() => { trace.push(owner.clock.now()); }))), + ), { concurrency: "unbounded" })); + expect(owner.harness.advance(Math.max(...durations)).ok).toBeTrue(); + expect(Exit.isSuccess(await work)).toBeTrue(); + expect(trace).toEqual([...durations].sort((a, b) => a - b)); + owner.dispose(); + await owner.harness.close(); + }), { numRuns: 30 }); + }); + + test("retries and timeout use the same deadline clock; no duplicate post-timeout mutation", async () => { + const owner = session(Layer.empty); + let attempts = 0; + const retry = owner.harness.runExit("retry", op => Effect.gen(function* () { + attempts += 1; + if (attempts < 3) return yield* Effect.fail("temporary"); + return yield* op.transact(world => { world.count += 1; }); + }).pipe(Effect.retry(Schedule.spaced(5)))); + expect(owner.harness.advance(10).ok).toBeTrue(); + expect(Exit.isSuccess(await retry)).toBeTrue(); + expect(attempts).toBe(3); + const timeout = owner.harness.runExit("timeout", op => Effect.sleep(20).pipe( + Effect.zipRight(op.transact(world => { world.count += 10; })), Effect.timeout(5), + )); + expect(owner.harness.advance(30).ok).toBeTrue(); + expect(Exit.isFailure(await timeout)).toBeTrue(); + expect(owner.store.getSnapshot().world.count).toBe(1); + owner.dispose(); + await owner.harness.close(); + }); + + test("reset interrupts old work and fences a retained transaction without hiding finalizers", async () => { + const owner = session(Layer.empty); + let retained: DirectEffectOperation | undefined; + let released = false; + const work = owner.harness.runExit("reset", op => Effect.gen(function* () { + retained = op; + yield* Effect.addFinalizer(() => Effect.sleep(5).pipe(Effect.zipRight(Effect.sync(() => { released = true; })))); + yield* Effect.never; + })); + expect(owner.store.reset({ count: 7, messages: [] }).ok).toBeTrue(); + owner.harness.drain(); + expect(owner.store.getSnapshot().activity.active).toBe(0); + expect(owner.probe.isQuiescent()).toEqual({ ok: true, value: false }); + expect(owner.harness.advance(5).ok).toBeTrue(); + const interrupted = await work; + expect(Exit.isFailure(interrupted)).toBeTrue(); + if (Exit.isFailure(interrupted)) expect(Cause.isInterrupted(interrupted.cause)).toBeTrue(); + expect(released).toBeTrue(); + if (retained === undefined) throw new Error("missing operation"); + const oldOperation = retained; + const stale = await owner.harness.runExit("stale", () => oldOperation.transact(world => { world.count = 99; })); + expect(stale).toMatchObject({ _tag: "Failure", cause: { _tag: "Fail", error: { code: "stale-generation" } } }); + expect(owner.store.getSnapshot().world.count).toBe(7); + owner.dispose(); + await owner.harness.close(); + }); + + test("dispose fences synchronously; close joins scoped children and is idempotent", async () => { + const owner = session(Layer.empty); + let released = 0; + const work = owner.harness.runExit("scope", () => Effect.gen(function* () { + yield* Effect.never.pipe(Effect.ensuring(Effect.sync(() => { released += 1; })), Effect.forkScoped); + yield* Effect.never; + })); + expect(owner.dispose()).toBeUndefined(); + const denied = await owner.harness.runExit("denied", () => Effect.succeed(1)); + expect(denied).toMatchObject({ _tag: "Failure", cause: { error: { code: "closed" } } }); + const close = owner.harness.close(); + expect(owner.harness.close()).toBe(close); + await work; + const report = await close; + expect(released).toBe(1); + expect(Exit.isSuccess(report.runtime)).toBeTrue(); + expect(owner.harness.snapshot().state).toBe("closed"); + expect(owner.probe.isQuiescent()).toEqual({ ok: true, value: true }); + expect(owner.disposalErrors()).toEqual([]); + }); + + test("one layer lifetime retains primary and cleanup failures separately", async () => { + class Resource extends Context.Tag("@hraness/direct/example/Resource")() {} + let acquired = 0; + const layer = Layer.scoped(Resource, Effect.acquireRelease( + Effect.sync(() => { acquired += 1; return { value: 5 }; }), + () => Effect.die("cleanup-defect"), + )); + const owner = session(layer); + expect(await owner.harness.runExit("first", () => Effect.map(Resource, resource => resource.value))).toEqual(Exit.succeed(5)); + const failed = await owner.harness.runExit("failed", () => Effect.fail({ _tag: "DomainFailure" })); + expect(Exit.isFailure(failed)).toBeTrue(); + expect(acquired).toBe(1); + owner.dispose(); + const report = await owner.harness.close(); + expect(report.operationFailures).toHaveLength(1); + expect(Exit.isFailure(report.runtime)).toBeTrue(); + if (Exit.isFailure(report.runtime)) expect([...Cause.defects(report.runtime.cause)]).toEqual(["cleanup-defect"]); + }); + + test("guarded transactions expire at operation completion and direct clock mutation is rejected", async () => { + const owner = session(Layer.empty); + const completed = await owner.harness.runExit("capture", op => Effect.succeed(op)); + if (Exit.isFailure(completed)) throw new Error("capture failed"); + const expired = await owner.harness.runExit("expired", () => completed.value.transact(world => { world.count = 1; })); + expect(expired).toMatchObject({ _tag: "Failure", cause: { error: { code: "operation-settled" } } }); + owner.clock.advance(1); + expect(owner.harness.advance(1)).toMatchObject({ ok: false, error: { code: "clock-conflict" } }); + expect(owner.harness.advance(-1)).toMatchObject({ ok: false, error: { code: "invalid-duration" } }); + owner.dispose(); + await owner.harness.close(); + }); + + test("bounded drain reports endless cooperative work instead of hanging", async () => { + const owner = session(Layer.empty, 20); + const work = owner.harness.runExit("loop", () => Effect.forever(Effect.yieldNow())); + expect(owner.harness.snapshot().failures).toBeGreaterThan(0); + owner.dispose(); + owner.harness.drain(); + await work; + await owner.harness.close(); + }); + + test("a scoped race cancels its losing sleeper", async () => { + const owner = session(Layer.empty); + const work = owner.harness.runExit("race", () => Effect.race(Effect.sleep(5), Effect.sleep(50))); + owner.harness.advance(5); + expect(Exit.isSuccess(await work)).toBeTrue(); + expect(owner.harness.snapshot().pendingSleeps).toBe(0); + const nested = owner.harness.runExit("join", () => Effect.gen(function* () { + const child = yield* Effect.sleep(5).pipe(Effect.as(42), Effect.forkScoped); + return yield* Fiber.join(child); + })); + owner.harness.advance(5); + expect(await nested).toEqual(Exit.succeed(42)); + owner.dispose(); + await owner.harness.close(); + }); + + test("finite fractional sleeps round up and infinite sleeps remain interruptible", async () => { + const owner = session(Layer.empty); + const trace: number[] = []; + const finite = owner.harness.runExit("fraction", () => Effect.gen(function* () { + yield* Effect.sleep(0.1); + trace.push(owner.clock.now()); + yield* Effect.sleep(2.1); + trace.push(owner.clock.now()); + })); + owner.harness.advance(4); + expect(Exit.isSuccess(await finite)).toBeTrue(); + expect(trace).toEqual([1, 4]); + const infinite = owner.harness.runExit("infinity", () => Effect.sleep(Infinity)); + owner.dispose(); + await owner.harness.close(); + const interrupted = await infinite; + expect(Exit.isFailure(interrupted)).toBeTrue(); + if (Exit.isFailure(interrupted)) expect(Cause.isInterrupted(interrupted.cause)).toBeTrue(); + }); + + test("async Layer teardown remains pending until it finishes and retains its defect", async () => { + let enter: () => void = () => undefined; + const entered = new Promise(resolve => { enter = resolve; }); + let release: () => void = () => undefined; + const layer = Layer.scopedDiscard(Effect.addFinalizer(() => Effect.async(resume => { + release = () => { resume(Effect.die("async-cleanup")); }; + enter(); + }))); + const owner = session(layer); + await owner.harness.runExit("initialize", () => Effect.void); + owner.dispose(); + const closing = owner.harness.close(); + await entered; + expect(owner.probe.isQuiescent()).toEqual({ ok: true, value: false }); + release(); + const report = await closing; + expect(Exit.isFailure(report.runtime)).toBeTrue(); + expect(owner.harness.snapshot().failures).toBe(1); + expect(owner.probe.isQuiescent()).toEqual({ ok: true, value: true }); + }); + + test("failed Layer construction is preserved and close before first run is safe", async () => { + const owner = session(Layer.effectDiscard(Effect.fail({ _tag: "AcquireFailed" }))); + const failed = await owner.harness.runExit("init", () => Effect.void); + expect(failed).toMatchObject({ _tag: "Failure", cause: { error: { _tag: "AcquireFailed" } } }); + owner.dispose(); + expect((await owner.harness.close()).operationFailures).toHaveLength(1); + const unused = session(Layer.empty); + unused.dispose(); + expect(Exit.isSuccess((await unused.harness.close()).runtime)).toBeTrue(); + }); + + test("a Layer-owned suspended fiber keeps the probe nonquiescent after a root operation finishes", async () => { + let finalized = false; + const layer = Layer.scopedDiscard(Effect.never.pipe( + Effect.ensuring(Effect.sync(() => { finalized = true; })), Effect.forkScoped, + )); + const owner = session(layer); + await owner.harness.runExit("initialize", () => Effect.void); + expect(owner.harness.snapshot().pendingOperations).toBe(0); + expect(owner.harness.snapshot().pendingSleeps).toBe(0); + expect(owner.probe.isQuiescent()).toEqual({ ok: true, value: false }); + owner.dispose(); + await owner.harness.close(); + expect(finalized).toBeTrue(); + expect(owner.probe.isQuiescent()).toEqual({ ok: true, value: true }); + }); + + test("a failed Layer worker leaves visible failure evidence after becoming quiescent", async () => { + const layer = Layer.scopedDiscard(Effect.sleep(5).pipe( + Effect.zipRight(Effect.fail({ _tag: "WorkerFailed" })), Effect.forkScoped, + )); + const owner = session(layer); + await owner.harness.runExit("initialize", () => Effect.void); + expect(owner.probe.isQuiescent()).toEqual({ ok: true, value: false }); + owner.harness.advance(5); + expect(owner.probe.snapshot()).toMatchObject({ + ok: true, value: { isQuiescent: true, violations: { "effect.failures": 1 } }, + }); + owner.dispose(); + const report = await owner.harness.close(); + expect(report.backgroundFailures).toHaveLength(1); + expect([...Cause.failures(report.backgroundFailures[0] ?? Cause.empty)]).toEqual([{ _tag: "WorkerFailed" }]); + }); + + test("a successful root retains a failed scoped child's observed Cause", async () => { + const owner = session(Layer.empty); + const root = await owner.harness.runExit("parent", () => Effect.gen(function* () { + yield* Effect.fail({ _tag: "ChildFailed" }).pipe(Effect.forkScoped); + yield* Effect.yieldNow(); + return "parent completed"; + })); + expect(root).toEqual(Exit.succeed("parent completed")); + owner.dispose(); + const report = await owner.harness.close(); + expect(report.operationFailures).toHaveLength(0); + expect(report).toHaveProperty("childFailures"); + expect(report.childFailures).toHaveLength(1); + expect(report.childFailures[0]).toMatchObject({ _tag: "Fail", error: { _tag: "ChildFailed" } }); + // An observed child exit does not establish whether the application handled it. + expect(owner.harness.snapshot().failures).toBe(0); + }); +}); diff --git a/src/effect/driver.ts b/src/effect/driver.ts new file mode 100644 index 0000000..ce41e66 --- /dev/null +++ b/src/effect/driver.ts @@ -0,0 +1,213 @@ +import { Cause, Effect, Exit, FiberId, Layer, ManagedRuntime, Scope, Supervisor } from "effect"; +import type { OperationId } from "../core/ids.js"; +import type { JsonValue } from "../core/json-value.js"; +import type { Result } from "../core/result.js"; +import type { DirectStoreSnapshot, StoreGeneration } from "../core/store.js"; +import type { DirectActivityScopeError } from "../testing/activity.js"; +import type { DirectSessionContext, DirectSessionObservation } from "../testing/session.js"; +import { createDeadlineControl, driverError, type DirectEffectDriverError } from "./deadline.js"; +import { DriverSupervisor } from "./supervisor.js"; + +export interface DirectEffectOperation { + readonly generation: StoreGeneration; + readonly operation: OperationId; + readonly transact: (update: (draft: World) => World | void) => Effect.Effect, DirectEffectDriverError>; +} + +export interface DirectEffectCloseReport { + readonly runtime: Exit.Exit; + readonly operationFailures: readonly Cause.Cause[]; + readonly backgroundFailures: readonly Cause.Cause[]; + /** Observed operation descendants; join/race/retry may have handled these exits. */ + readonly childFailures: readonly Cause.Cause[]; + readonly settlementErrors: readonly DirectActivityScopeError[]; + readonly diagnostics: readonly DirectEffectDriverError[]; +} + +export interface DirectEffectDriverSnapshot { + readonly clock: "deadline"; + readonly nowMs: number; + readonly pendingOperations: number; + readonly pendingContinuations: number; + readonly pendingSleeps: number; + readonly pendingFibers: number; + readonly state: "open" | "closing" | "closed"; + readonly failures: number; +} + +export interface DirectEffectDriver { + readonly runExit: (namespace: string, program: (operation: DirectEffectOperation) => Effect.Effect) => Promise>; + readonly advance: (milliseconds: number) => Result; + readonly drain: () => Result; + readonly snapshot: () => DirectEffectDriverSnapshot; + readonly observation: DirectSessionObservation; + /** Synchronously fence admission and request interruption; await close for completion. */ + readonly interrupt: () => undefined; + readonly close: () => Promise; +} + +export interface DirectEffectDriverOptions { + readonly context: DirectSessionContext; + readonly layer: Layer.Layer; + readonly clock: "deadline"; + readonly maxSteps?: number; +} + +interface ActiveOperation { + readonly generation: StoreGeneration; + interrupt: () => void; + readonly done: Promise; +} + +/** An opt-in asynchronous owner around one Direct session and one Effect Layer. */ +export function createDirectEffectDriver( + options: DirectEffectDriverOptions, +): DirectEffectDriver { + const { context } = options; + const maxSteps = options.maxSteps ?? 10_000; + if (options.clock !== "deadline" || !Number.isSafeInteger(maxSteps) || maxSteps < 1) { + throw new TypeError("Effect driver requires deadline mode and a positive safe step budget"); + } + let state: DirectEffectDriverSnapshot["state"] = "open"; + const active = new Set(); + const operationFailures: Cause.Cause[] = []; + const settlementErrors: DirectActivityScopeError[] = []; + const diagnostics: DirectEffectDriverError[] = []; + let runtimeFailure: Cause.Cause | undefined; + const control = createDeadlineControl(context.clock, maxSteps, failure => { diagnostics.push(failure); }); + const supervisor = new DriverSupervisor(); + const runtime = ManagedRuntime.make(options.layer.pipe( + Layer.provide(Layer.mergeAll( + Layer.setClock(control.clock), Layer.setScheduler(control.scheduler), Supervisor.addSupervisor(supervisor), + )), + )); + + const interrupt = (): undefined => { + if (state === "closed") return; + state = "closing"; + for (const operation of active) operation.interrupt(); + }; + const unsubscribe = context.store.subscribe(() => { + const current = context.store.getSnapshot().generation; + for (const operation of active) { + if (operation.generation !== current) operation.interrupt(); + } + }); + context.signal.addEventListener("abort", interrupt, { once: true }); + context.onDispose(interrupt); + if (context.signal.aborted) interrupt(); + + const runExit = ( + namespace: string, + program: (operation: DirectEffectOperation) => Effect.Effect, + ): Promise> => { + if (state !== "open" || context.signal.aborted) { + return Promise.resolve(Exit.fail(driverError("closed", "Effect driver is closed to new operations"))); + } + const begun = context.activity.begin(namespace); + if (!begun.ok) return Promise.resolve(Exit.fail(driverError("activity-failed", begun.error.message, begun.error))); + const lease = begun.value; + let settled = false; + let resolveDone: () => void = () => undefined; + const entry: ActiveOperation = { + generation: lease.generation, + interrupt: () => undefined, + done: new Promise(resolve => { resolveDone = resolve; }), + }; + active.add(entry); + const operation: DirectEffectOperation = Object.freeze({ + generation: lease.generation, + operation: lease.operation, + transact: (update: (draft: World) => World | void) => Effect.suspend(() => { + let failure: DirectEffectDriverError | undefined; + if (context.store.getSnapshot().generation !== lease.generation) { + failure = driverError("stale-generation", "Operation belongs to an earlier Direct generation"); + } else if (settled) { + failure = driverError("operation-settled", "Operation has already settled"); + } else if (state !== "open" || context.signal.aborted) { + failure = driverError("closed", "Operation owner is closing"); + } + if (failure !== undefined) { + diagnostics.push(failure); + return Effect.fail(failure); + } + const result = context.store.transact(lease.generation, lease.operation, update); + return result.ok ? Effect.succeed(result.value) : Effect.fail(driverError("store-failed", result.error.message, result.error)); + }), + }); + const fiber = runtime.runFork(Effect.scoped(Effect.suspend(() => program(operation))), { + scheduler: control.scheduler, + immediate: false, + }); + supervisor.registerRoot(fiber); + entry.interrupt = () => { fiber.unsafeInterruptAsFork(FiberId.none); }; + if (state !== "open" || context.signal.aborted || context.store.getSnapshot().generation !== lease.generation) entry.interrupt(); + return new Promise(resolve => { + fiber.addObserver(exit => { + settled = true; + if (Exit.isFailure(exit)) operationFailures.push(exit.cause); + const released = lease.release(); + // Reset deliberately clears the old ledger; this is an expected stale settlement. + const settlementError = !released.ok && released.error.storeError?.code !== "stale-generation" + ? released.error : undefined; + if (settlementError !== undefined) settlementErrors.push(settlementError); + const failure = settlementError === undefined ? undefined + : Cause.fail(driverError("activity-failed", settlementError.message, settlementError)); + const result: Exit.Exit = failure === undefined ? exit + : Exit.failCause(Exit.isFailure(exit) ? Cause.sequential(exit.cause, failure) : failure); + active.delete(entry); + supervisor.forgetRoot(fiber); + resolveDone(); + resolve(result); + }); + control.drain(); + }); + }; + + let closing: Promise | undefined; + const close = (): Promise => { + if (closing !== undefined) return closing; + interrupt(); + closing = (async () => { + control.drain(); + await Promise.all([...active].map(operation => operation.done)); + const runtimeExit = await Effect.runPromiseExit(runtime.disposeEffect.pipe( + Effect.withClock(control.clock), Effect.withScheduler(control.scheduler), + )); + if (Exit.isFailure(runtimeExit)) runtimeFailure = runtimeExit.cause; + unsubscribe(); + context.signal.removeEventListener("abort", interrupt); + state = "closed"; + return Object.freeze({ + runtime: runtimeExit, + operationFailures: Object.freeze([...operationFailures]), + backgroundFailures: Object.freeze(supervisor.backgroundFailures()), + childFailures: Object.freeze(supervisor.childFailures()), + settlementErrors: Object.freeze([...settlementErrors]), + diagnostics: Object.freeze([...diagnostics]), + }); + })(); + return closing; + }; + const failures = (): number => diagnostics.length + settlementErrors.length + + operationFailures.filter(cause => Cause.defects(cause).length > 0).length + + supervisor.backgroundFailureCount() + + (runtimeFailure === undefined ? 0 : 1); + const snapshot = (): DirectEffectDriverSnapshot => Object.freeze({ + clock: "deadline", nowMs: context.clock.now(), pendingOperations: active.size, + pendingContinuations: control.pending(), pendingSleeps: control.sleeps(), state, failures: failures(), + pendingFibers: supervisor.pending(), + }); + return Object.freeze({ + runExit, advance: control.advance, drain: control.drain, snapshot, interrupt, close, + observation: Object.freeze({ + pending: [ + { name: "effect.operations", read: () => active.size }, + { name: "effect.continuations", read: control.pending }, + { name: "effect.fibers", read: () => supervisor.pending() }, + { name: "effect.close", read: () => state === "closing" ? 1 : 0 }, + ], + violations: [{ name: "effect.failures", read: failures }], + }), + }); +} diff --git a/src/effect/example.test.ts b/src/effect/example.test.ts new file mode 100644 index 0000000..b57d929 --- /dev/null +++ b/src/effect/example.test.ts @@ -0,0 +1,32 @@ +import { expect, test } from "bun:test"; +import { Effect, Exit, Layer } from "effect"; +import { createDocumentTestSession, DocumentSource, loadDocument } from "../../examples/effect/document-controller.js"; + +test("neutral document controller decodes a delayed response and commits through Direct", async () => { + const source = Layer.succeed(DocumentSource, { read: Effect.sleep(20).pipe(Effect.as({ text: "A public example" })) }); + const created = createDocumentTestSession(source); + if (!created.ok) throw new Error(created.error.message); + const session = created.value; + const run = session.harness.runExit("document", loadDocument); + expect(session.store.getSnapshot().world.text).toBe(""); + expect(session.probe.isQuiescent()).toEqual({ ok: true, value: false }); + expect(session.harness.advance(20)).toEqual({ ok: true, value: 20 }); + expect(Exit.isSuccess(await run)).toBeTrue(); + expect(session.store.getSnapshot().world.text).toBe("A public example"); + expect(session.probe.isQuiescent()).toEqual({ ok: true, value: true }); + session.dispose(); + expect(Exit.isSuccess((await session.harness.close()).runtime)).toBeTrue(); +}); + +test("neutral controller projects invalid boundary data without committing", async () => { + const source = Layer.succeed(DocumentSource, { read: Effect.succeed({ text: 1 }) }); + const created = createDocumentTestSession(source); + if (!created.ok) throw new Error(created.error.message); + const session = created.value; + const exit = await session.harness.runExit("document", loadDocument); + expect(exit).toMatchObject({ _tag: "Failure", cause: { error: { _tag: "DocumentUnavailable" } } }); + expect(session.store.getSnapshot().world.text).toBe(""); + session.dispose(); + const report = await session.harness.close(); + expect(report.operationFailures).toHaveLength(1); +}); diff --git a/src/effect/index.ts b/src/effect/index.ts new file mode 100644 index 0000000..92adba7 --- /dev/null +++ b/src/effect/index.ts @@ -0,0 +1,7 @@ +/** Optional Effect integration. Existing Direct entry points do not import Effect. */ +export { createDirectEffectDriver } from "./driver.js"; +export type { + DirectEffectCloseReport, DirectEffectDriver, DirectEffectDriverOptions, + DirectEffectDriverSnapshot, DirectEffectOperation, +} from "./driver.js"; +export type { DirectEffectDriverError } from "./deadline.js"; diff --git a/src/effect/supervisor.ts b/src/effect/supervisor.ts new file mode 100644 index 0000000..c93e3a0 --- /dev/null +++ b/src/effect/supervisor.ts @@ -0,0 +1,43 @@ +import { Cause, Context, Effect, Exit, Fiber, Option, Supervisor } from "effect"; + +/** Observe Layer workers as well as operation children, including suspended work. */ +export class DriverSupervisor extends Supervisor.AbstractSupervisor { + private readonly roots = new Set(); + private readonly active = new Map(); + private readonly failures: Cause.Cause[] = []; + private readonly children: Cause.Cause[] = []; + + override value: Effect.Effect = Effect.sync(() => this.active.size); + + registerRoot(fiber: Fiber.RuntimeFiber): void { + this.roots.add(fiber.id().id); + if (this.active.has(fiber.id().id)) this.active.set(fiber.id().id, true); + } + + forgetRoot(fiber: Fiber.RuntimeFiber): void { + this.roots.delete(fiber.id().id); + } + + pending(): number { return this.active.size; } + backgroundFailures(): readonly Cause.Cause[] { return [...this.failures]; } + childFailures(): readonly Cause.Cause[] { return [...this.children]; } + backgroundFailureCount(): number { return this.failures.filter(cause => !Cause.isInterruptedOnly(cause)).length; } + + override onStart( + _context: Context.Context, _effect: Effect.Effect, + parent: Parameters["onStart"]>[2], fiber: Fiber.RuntimeFiber, + ): void { + const parentId = Option.isSome(parent) ? parent.value.id().id : undefined; + const operationOwned = this.roots.has(fiber.id().id) || (parentId !== undefined + && (this.roots.has(parentId) || this.active.get(parentId) === true)); + this.active.set(fiber.id().id, operationOwned); + } + + override onEnd(exit: Exit.Exit, fiber: Fiber.RuntimeFiber): void { + if (Exit.isFailure(exit) && !this.roots.has(fiber.id().id)) { + if (this.active.get(fiber.id().id) === false) this.failures.push(exit.cause); + else if (this.active.get(fiber.id().id) === true) this.children.push(exit.cause); + } + this.active.delete(fiber.id().id); + } +} diff --git a/tsconfig.json b/tsconfig.json index fd72317..11f501c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -37,6 +37,9 @@ ], "@hraness/direct/web": [ "./src/web.ts" + ], + "@hraness/direct/effect": [ + "./src/effect/index.ts" ] }, "jsx": "react-jsx", From 4f67db5470bba8af48b5a15c691be69e6d5ff5bb Mon Sep 17 00:00:00 2001 From: 0thernet Date: Sun, 6 Sep 2026 12:08:12 -0400 Subject: [PATCH 2/2] build: finalize optional Effect package and bounded inventory --- .github/workflows/npm-publish.yml | 4 +- dist/core/index.js | 12 +- dist/effect/index.js | 351 ++++++++++++++++++ dist/{index-1csg00w4.js => index-h8hb53n1.js} | 17 +- dist/index-mm9mqmyc.js | 12 + dist/{index-6mdfd2ey.js => index-w3zjggfc.js} | 8 +- dist/{index-7n1h75n6.js => index-y9mbv6h0.js} | 10 +- dist/index.js | 10 +- dist/testing/index.js | 14 +- dist/web.js | 10 +- kb/plans/effect-driver.md | 6 +- scripts/npm-publish-workflow.test.ts | 4 +- scripts/package-artifact.ts | 3 +- 13 files changed, 416 insertions(+), 45 deletions(-) create mode 100644 dist/effect/index.js rename dist/{index-1csg00w4.js => index-h8hb53n1.js} (97%) create mode 100644 dist/index-mm9mqmyc.js rename dist/{index-6mdfd2ey.js => index-w3zjggfc.js} (99%) rename dist/{index-7n1h75n6.js => index-y9mbv6h0.js} (99%) diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 34b0af2..05c4c8d 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -271,11 +271,11 @@ jobs: const { readFileSync, statSync } = require("node:fs"); const minimumFiles = 50; - const maximumFiles = 60; + const maximumFiles = 66; const minimumPackedBytes = 140_000; const maximumPackedBytes = 260_000; const minimumUnpackedBytes = 650_000; - const maximumUnpackedBytes = 1_200_000; + const maximumUnpackedBytes = 1_250_000; const maximumMetadataBytes = 250_000; const expectedName = "@hraness/direct"; const expectedVersion = process.env.EXPECTED_VERSION; diff --git a/dist/core/index.js b/dist/core/index.js index be5bc62..d3ab784 100644 --- a/dist/core/index.js +++ b/dist/core/index.js @@ -2,7 +2,7 @@ import { DIRECT_STORE_MAX_REPLACEMENTS, DIRECT_STORE_MAX_REPLACEMENT_PATH_DEPTH, createDirectStore -} from "../index-6mdfd2ey.js"; +} from "../index-w3zjggfc.js"; import { DEFAULT_JSON_LIMITS, DEFAULT_LOGICAL_RUNTIME_SNAPSHOT, @@ -27,11 +27,8 @@ import { createFixtureEnvelope, createLogicalRuntime, createScenarioCatalog, - err, freezeJson, - isRecord, maximumFixtureQueryBytes, - ok, operationId, parseAndCloneWorld, parseCoverageCatalogSnapshot, @@ -51,7 +48,12 @@ import { stableHash, tagStableHash, utf8ByteLength -} from "../index-1csg00w4.js"; +} from "../index-h8hb53n1.js"; +import { + err, + isRecord, + ok +} from "../index-mm9mqmyc.js"; // src/core/effects.ts function ownQueuedEffect(entry) { const id = parseOperationId(entry.id); diff --git a/dist/effect/index.js b/dist/effect/index.js new file mode 100644 index 0000000..957e722 --- /dev/null +++ b/dist/effect/index.js @@ -0,0 +1,351 @@ +import { + err, + ok +} from "../index-mm9mqmyc.js"; + +// src/effect/driver.ts +import { Cause as Cause2, Effect as Effect3, Exit as Exit2, FiberId, Layer, ManagedRuntime, Supervisor as Supervisor2 } from "effect"; + +// src/effect/deadline.ts +import { Clock, Duration, Effect, Scheduler } from "effect"; +function driverError(code, message, reason = null) { + return Object.freeze({ _tag: "DirectEffectDriverError", code, message, reason }); +} +function createDeadlineControl(runtime, maxSteps, report) { + let expectedTime = runtime.now(); + let sequence = 0; + let draining = false; + let advancing = false; + let automaticQueued = false; + let paused = false; + let turnSteps = 0; + const sleepers = new Set; + const tasks = []; + const drain = () => { + if (draining) + return ok(0); + draining = true; + let steps = 0; + try { + while (tasks.length > 0) { + if ((advancing ? turnSteps : steps) >= maxSteps) { + paused = true; + const failure = driverError("step-limit", `Deadline continuation budget exceeded ${String(maxSteps)} steps`); + report(failure); + return err(failure); + } + tasks.sort((left, right) => left.priority - right.priority || left.sequence - right.sequence); + const next = tasks.shift(); + if (next === undefined) + break; + steps += 1; + turnSteps += 1; + next.task(); + } + paused = false; + return ok(steps); + } finally { + draining = false; + } + }; + const scheduler = { + shouldYield: (fiber) => Scheduler.defaultScheduler.shouldYield(fiber), + scheduleTask: (task, priority) => { + tasks.push({ task, priority, sequence: sequence++ }); + if (!automaticQueued && !paused && !draining && !advancing) { + automaticQueued = true; + queueMicrotask(() => { + automaticQueued = false; + if (!paused) + drain(); + }); + } + } + }; + const clock = { + [Clock.ClockTypeId]: Clock.ClockTypeId, + unsafeCurrentTimeMillis: () => runtime.now(), + unsafeCurrentTimeNanos: () => BigInt(runtime.now()) * 1000000n, + currentTimeMillis: Effect.sync(() => runtime.now()), + currentTimeNanos: Effect.sync(() => BigInt(runtime.now()) * 1000000n), + sleep: (input) => Effect.suspend(() => { + const decoded = Duration.toMillis(input); + if (decoded === Infinity) + return Effect.never; + const duration = Math.ceil(decoded); + const deadline = runtime.now() + duration; + if (!Number.isSafeInteger(duration) || duration < 0 || !Number.isSafeInteger(deadline)) { + return Effect.die(driverError("invalid-duration", "Deadline sleep requires safe non-negative integer milliseconds")); + } + if (duration === 0) + return Effect.void; + return Effect.async((resume) => { + const sleeper = { + deadline, + sequence: sequence++, + wake: () => resume(Effect.void) + }; + sleepers.add(sleeper); + return Effect.sync(() => { + sleepers.delete(sleeper); + }); + }); + }) + }; + const advance = (milliseconds) => { + if (advancing || draining) + return err(driverError("reentrant-advance", "Advance must run outside an Effect continuation")); + if (!Number.isSafeInteger(milliseconds) || milliseconds < 0 || !Number.isSafeInteger(expectedTime + milliseconds)) { + return err(driverError("invalid-duration", "Advance requires safe non-negative integer milliseconds")); + } + if (runtime.now() !== expectedTime) { + return err(driverError("clock-conflict", "Do not mix FIFO waits or direct clock advancement with deadline mode")); + } + advancing = true; + turnSteps = 0; + const target = expectedTime + milliseconds; + try { + const initial = drain(); + if (!initial.ok) + return initial; + while (true) { + const next = [...sleepers].filter((sleeper) => sleeper.deadline <= target).sort((left, right) => left.deadline - right.deadline || left.sequence - right.sequence)[0]; + if (next === undefined) + break; + if (turnSteps >= maxSteps) { + const failure = driverError("step-limit", `Deadline wake budget exceeded ${String(maxSteps)} steps`); + report(failure); + return err(failure); + } + turnSteps += 1; + const advanced2 = runtime.advance(next.deadline - runtime.now()); + if (!advanced2.ok) + return err(driverError("invalid-duration", advanced2.error.message, advanced2.error)); + expectedTime = advanced2.value; + sleepers.delete(next); + next.wake(); + const drained = drain(); + if (!drained.ok) + return drained; + } + const advanced = runtime.advance(target - runtime.now()); + if (!advanced.ok) + return err(driverError("invalid-duration", advanced.error.message, advanced.error)); + expectedTime = advanced.value; + return ok(expectedTime); + } finally { + advancing = false; + } + }; + return { clock, scheduler, advance, drain, pending: () => tasks.length, sleeps: () => sleepers.size }; +} + +// src/effect/supervisor.ts +import { Cause, Effect as Effect2, Exit, Option, Supervisor } from "effect"; + +class DriverSupervisor extends Supervisor.AbstractSupervisor { + roots = new Set; + active = new Map; + failures = []; + children = []; + value = Effect2.sync(() => this.active.size); + registerRoot(fiber) { + this.roots.add(fiber.id().id); + if (this.active.has(fiber.id().id)) + this.active.set(fiber.id().id, true); + } + forgetRoot(fiber) { + this.roots.delete(fiber.id().id); + } + pending() { + return this.active.size; + } + backgroundFailures() { + return [...this.failures]; + } + childFailures() { + return [...this.children]; + } + backgroundFailureCount() { + return this.failures.filter((cause) => !Cause.isInterruptedOnly(cause)).length; + } + onStart(_context, _effect, parent, fiber) { + const parentId = Option.isSome(parent) ? parent.value.id().id : undefined; + const operationOwned = this.roots.has(fiber.id().id) || parentId !== undefined && (this.roots.has(parentId) || this.active.get(parentId) === true); + this.active.set(fiber.id().id, operationOwned); + } + onEnd(exit, fiber) { + if (Exit.isFailure(exit) && !this.roots.has(fiber.id().id)) { + if (this.active.get(fiber.id().id) === false) + this.failures.push(exit.cause); + else if (this.active.get(fiber.id().id) === true) + this.children.push(exit.cause); + } + this.active.delete(fiber.id().id); + } +} + +// src/effect/driver.ts +function createDirectEffectDriver(options) { + const { context } = options; + const maxSteps = options.maxSteps ?? 1e4; + if (options.clock !== "deadline" || !Number.isSafeInteger(maxSteps) || maxSteps < 1) { + throw new TypeError("Effect driver requires deadline mode and a positive safe step budget"); + } + let state = "open"; + const active = new Set; + const operationFailures = []; + const settlementErrors = []; + const diagnostics = []; + let runtimeFailure; + const control = createDeadlineControl(context.clock, maxSteps, (failure) => { + diagnostics.push(failure); + }); + const supervisor = new DriverSupervisor; + const runtime = ManagedRuntime.make(options.layer.pipe(Layer.provide(Layer.mergeAll(Layer.setClock(control.clock), Layer.setScheduler(control.scheduler), Supervisor2.addSupervisor(supervisor))))); + const interrupt = () => { + if (state === "closed") + return; + state = "closing"; + for (const operation of active) + operation.interrupt(); + }; + const unsubscribe = context.store.subscribe(() => { + const current = context.store.getSnapshot().generation; + for (const operation of active) { + if (operation.generation !== current) + operation.interrupt(); + } + }); + context.signal.addEventListener("abort", interrupt, { once: true }); + context.onDispose(interrupt); + if (context.signal.aborted) + interrupt(); + const runExit = (namespace, program) => { + if (state !== "open" || context.signal.aborted) { + return Promise.resolve(Exit2.fail(driverError("closed", "Effect driver is closed to new operations"))); + } + const begun = context.activity.begin(namespace); + if (!begun.ok) + return Promise.resolve(Exit2.fail(driverError("activity-failed", begun.error.message, begun.error))); + const lease = begun.value; + let settled = false; + let resolveDone = () => { + return; + }; + const entry = { + generation: lease.generation, + interrupt: () => { + return; + }, + done: new Promise((resolve) => { + resolveDone = resolve; + }) + }; + active.add(entry); + const operation = Object.freeze({ + generation: lease.generation, + operation: lease.operation, + transact: (update) => Effect3.suspend(() => { + let failure; + if (context.store.getSnapshot().generation !== lease.generation) { + failure = driverError("stale-generation", "Operation belongs to an earlier Direct generation"); + } else if (settled) { + failure = driverError("operation-settled", "Operation has already settled"); + } else if (state !== "open" || context.signal.aborted) { + failure = driverError("closed", "Operation owner is closing"); + } + if (failure !== undefined) { + diagnostics.push(failure); + return Effect3.fail(failure); + } + const result = context.store.transact(lease.generation, lease.operation, update); + return result.ok ? Effect3.succeed(result.value) : Effect3.fail(driverError("store-failed", result.error.message, result.error)); + }) + }); + const fiber = runtime.runFork(Effect3.scoped(Effect3.suspend(() => program(operation))), { + scheduler: control.scheduler, + immediate: false + }); + supervisor.registerRoot(fiber); + entry.interrupt = () => { + fiber.unsafeInterruptAsFork(FiberId.none); + }; + if (state !== "open" || context.signal.aborted || context.store.getSnapshot().generation !== lease.generation) + entry.interrupt(); + return new Promise((resolve) => { + fiber.addObserver((exit) => { + settled = true; + if (Exit2.isFailure(exit)) + operationFailures.push(exit.cause); + const released = lease.release(); + const settlementError = !released.ok && released.error.storeError?.code !== "stale-generation" ? released.error : undefined; + if (settlementError !== undefined) + settlementErrors.push(settlementError); + const failure = settlementError === undefined ? undefined : Cause2.fail(driverError("activity-failed", settlementError.message, settlementError)); + const result = failure === undefined ? exit : Exit2.failCause(Exit2.isFailure(exit) ? Cause2.sequential(exit.cause, failure) : failure); + active.delete(entry); + supervisor.forgetRoot(fiber); + resolveDone(); + resolve(result); + }); + control.drain(); + }); + }; + let closing; + const close = () => { + if (closing !== undefined) + return closing; + interrupt(); + closing = (async () => { + control.drain(); + await Promise.all([...active].map((operation) => operation.done)); + const runtimeExit = await Effect3.runPromiseExit(runtime.disposeEffect.pipe(Effect3.withClock(control.clock), Effect3.withScheduler(control.scheduler))); + if (Exit2.isFailure(runtimeExit)) + runtimeFailure = runtimeExit.cause; + unsubscribe(); + context.signal.removeEventListener("abort", interrupt); + state = "closed"; + return Object.freeze({ + runtime: runtimeExit, + operationFailures: Object.freeze([...operationFailures]), + backgroundFailures: Object.freeze(supervisor.backgroundFailures()), + childFailures: Object.freeze(supervisor.childFailures()), + settlementErrors: Object.freeze([...settlementErrors]), + diagnostics: Object.freeze([...diagnostics]) + }); + })(); + return closing; + }; + const failures = () => diagnostics.length + settlementErrors.length + operationFailures.filter((cause) => Cause2.defects(cause).length > 0).length + supervisor.backgroundFailureCount() + (runtimeFailure === undefined ? 0 : 1); + const snapshot = () => Object.freeze({ + clock: "deadline", + nowMs: context.clock.now(), + pendingOperations: active.size, + pendingContinuations: control.pending(), + pendingSleeps: control.sleeps(), + state, + failures: failures(), + pendingFibers: supervisor.pending() + }); + return Object.freeze({ + runExit, + advance: control.advance, + drain: control.drain, + snapshot, + interrupt, + close, + observation: Object.freeze({ + pending: [ + { name: "effect.operations", read: () => active.size }, + { name: "effect.continuations", read: control.pending }, + { name: "effect.fibers", read: () => supervisor.pending() }, + { name: "effect.close", read: () => state === "closing" ? 1 : 0 } + ], + violations: [{ name: "effect.failures", read: failures }] + }) + }); +} +export { + createDirectEffectDriver +}; diff --git a/dist/index-1csg00w4.js b/dist/index-h8hb53n1.js similarity index 97% rename from dist/index-1csg00w4.js rename to dist/index-h8hb53n1.js index 0667748..20c755d 100644 --- a/dist/index-1csg00w4.js +++ b/dist/index-h8hb53n1.js @@ -1,13 +1,8 @@ -// src/core/result.ts -function ok(value) { - return { ok: true, value }; -} -function err(error) { - return { ok: false, error }; -} -function isRecord(value) { - return typeof value === "object" && value !== null && !Array.isArray(value); -} +import { + err, + isRecord, + ok +} from "./index-mm9mqmyc.js"; // src/core/ids.ts var IDENTIFIER_PATTERN = /^[a-z][a-z0-9]*(?:[._/-][a-z0-9]+)*$/u; @@ -1164,4 +1159,4 @@ function createScenarioCatalog(inputs, parseWorld) { })); } -export { ok, err, isRecord, parseScenarioId, parseOperationId, parseCoverageKey, scenarioId, operationId, coverageKey, renderUnknownReason, DEFAULT_JSON_LIMITS, utf8ByteLength, parseExactJsonSource, parseJsonValue, canonicalJson, cloneJson, freezeJson, STABLE_HASH_ALGORITHM, tagStableHash, parseTaggedStableHash, stableHash, parseAndCloneWorld, DIRECT_COVERAGE_SCHEMA, MAX_DIRECT_COVERAGE_ENTRIES, EMPTY_COVERAGE_CATALOG_SNAPSHOT, createCoverageCatalogSnapshot, parseCoverageCatalogSnapshot, createCoverageCatalog, LOGICAL_RUNTIME_SCHEMA, MAX_HOST_TIMER_MILLISECONDS, DEFAULT_LOGICAL_RUNTIME_SNAPSHOT, parseLogicalRuntimeSnapshot, createLogicalRuntime, FIXTURE_SCHEMA, DEFAULT_MAX_FIXTURE_BYTES, parseFixtureEnvelope, parseFixtureJson, createFixtureEnvelope, serializeFixtureJson, SCENARIO_QUERY_KEY, FIXTURE_QUERY_KEY, maximumFixtureQueryBytes, DEFAULT_MAX_QUERY_BYTES, activateDirectScenario, parseDirectQuery, MAX_DIRECT_SCENARIOS, createScenarioCatalog }; +export { parseScenarioId, parseOperationId, parseCoverageKey, scenarioId, operationId, coverageKey, renderUnknownReason, DEFAULT_JSON_LIMITS, utf8ByteLength, parseExactJsonSource, parseJsonValue, canonicalJson, cloneJson, freezeJson, STABLE_HASH_ALGORITHM, tagStableHash, parseTaggedStableHash, stableHash, parseAndCloneWorld, DIRECT_COVERAGE_SCHEMA, MAX_DIRECT_COVERAGE_ENTRIES, EMPTY_COVERAGE_CATALOG_SNAPSHOT, createCoverageCatalogSnapshot, parseCoverageCatalogSnapshot, createCoverageCatalog, LOGICAL_RUNTIME_SCHEMA, MAX_HOST_TIMER_MILLISECONDS, DEFAULT_LOGICAL_RUNTIME_SNAPSHOT, parseLogicalRuntimeSnapshot, createLogicalRuntime, FIXTURE_SCHEMA, DEFAULT_MAX_FIXTURE_BYTES, parseFixtureEnvelope, parseFixtureJson, createFixtureEnvelope, serializeFixtureJson, SCENARIO_QUERY_KEY, FIXTURE_QUERY_KEY, maximumFixtureQueryBytes, DEFAULT_MAX_QUERY_BYTES, activateDirectScenario, parseDirectQuery, MAX_DIRECT_SCENARIOS, createScenarioCatalog }; diff --git a/dist/index-mm9mqmyc.js b/dist/index-mm9mqmyc.js new file mode 100644 index 0000000..86d8a1c --- /dev/null +++ b/dist/index-mm9mqmyc.js @@ -0,0 +1,12 @@ +// src/core/result.ts +function ok(value) { + return { ok: true, value }; +} +function err(error) { + return { ok: false, error }; +} +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export { ok, err, isRecord }; diff --git a/dist/index-6mdfd2ey.js b/dist/index-w3zjggfc.js similarity index 99% rename from dist/index-6mdfd2ey.js rename to dist/index-w3zjggfc.js index fad0763..686bed6 100644 --- a/dist/index-6mdfd2ey.js +++ b/dist/index-w3zjggfc.js @@ -1,13 +1,15 @@ import { DEFAULT_JSON_LIMITS, cloneJson, - err, - ok, parseAndCloneWorld, parseOperationId, renderUnknownReason, utf8ByteLength -} from "./index-1csg00w4.js"; +} from "./index-h8hb53n1.js"; +import { + err, + ok +} from "./index-mm9mqmyc.js"; // src/core/store.ts var DIRECT_STORE_MAX_REPLACEMENTS = 32; diff --git a/dist/index-7n1h75n6.js b/dist/index-y9mbv6h0.js similarity index 99% rename from dist/index-7n1h75n6.js rename to dist/index-y9mbv6h0.js index ce75b24..a1c9381 100644 --- a/dist/index-7n1h75n6.js +++ b/dist/index-y9mbv6h0.js @@ -5,10 +5,7 @@ import { STABLE_HASH_ALGORITHM, cloneJson, createCoverageCatalogSnapshot, - err, freezeJson, - isRecord, - ok, parseCoverageCatalogSnapshot, parseJsonValue, parseScenarioId, @@ -16,7 +13,12 @@ import { renderUnknownReason, stableHash, tagStableHash -} from "./index-1csg00w4.js"; +} from "./index-h8hb53n1.js"; +import { + err, + isRecord, + ok +} from "./index-mm9mqmyc.js"; // src/testing/manifest.ts var DIRECT_SESSION_MANIFEST_SCHEMA = "direct.session-manifest/v1"; diff --git a/dist/index.js b/dist/index.js index cd5dde9..48f642e 100644 --- a/dist/index.js +++ b/dist/index.js @@ -6,10 +6,7 @@ import { createCoverageCatalog, createFixtureEnvelope, createScenarioCatalog, - err, - isRecord, maximumFixtureQueryBytes, - ok, parseCoverageCatalogSnapshot, parseDirectQuery, parseFixtureEnvelope, @@ -18,7 +15,12 @@ import { parseLogicalRuntimeSnapshot, renderUnknownReason, serializeFixtureJson -} from "./index-1csg00w4.js"; +} from "./index-h8hb53n1.js"; +import { + err, + isRecord, + ok +} from "./index-mm9mqmyc.js"; // src/core/definition.ts function definitionError(code, message, causes = {}) { diff --git a/dist/testing/index.js b/dist/testing/index.js index fd5145b..922e289 100644 --- a/dist/testing/index.js +++ b/dist/testing/index.js @@ -1,6 +1,6 @@ import { createDirectStore -} from "../index-6mdfd2ey.js"; +} from "../index-w3zjggfc.js"; import { DIRECT_CATALOG_HASH_ALGORITHM, DIRECT_PROBE_SCHEMA, @@ -10,21 +10,23 @@ import { createDirectSessionManifest, parseDirectProbeSnapshot, parseDirectSessionManifest -} from "../index-7n1h75n6.js"; +} from "../index-y9mbv6h0.js"; import { canonicalJson, createCoverageCatalogSnapshot, createLogicalRuntime, - err, - isRecord, - ok, parseAndCloneWorld, parseCoverageCatalogSnapshot, parseJsonValue, parseLogicalRuntimeSnapshot, parseTaggedStableHash, renderUnknownReason -} from "../index-1csg00w4.js"; +} from "../index-h8hb53n1.js"; +import { + err, + isRecord, + ok +} from "../index-mm9mqmyc.js"; // src/testing/activity.ts function storeErrorMessage(cause) { diff --git a/dist/web.js b/dist/web.js index bd43e2b..7e42214 100644 --- a/dist/web.js +++ b/dist/web.js @@ -1,12 +1,14 @@ import { parseDirectProbeSnapshot, parseDirectSessionManifest -} from "./index-7n1h75n6.js"; +} from "./index-y9mbv6h0.js"; import { - err, - ok, renderUnknownReason -} from "./index-1csg00w4.js"; +} from "./index-h8hb53n1.js"; +import { + err, + ok +} from "./index-mm9mqmyc.js"; // src/web/browser-bridge.ts var DIRECT_BROWSER_BRIDGE_SCHEMA = "direct.browser-bridge/v2"; diff --git a/kb/plans/effect-driver.md b/kb/plans/effect-driver.md index 8e26848..cbd7bb4 100644 --- a/kb/plans/effect-driver.md +++ b/kb/plans/effect-driver.md @@ -17,7 +17,7 @@ Use Effect 3.22.1 through an optional exact peer plus development pin, with the ## Work and verification 1. Open design issue and publish the public API proposal. Complete. -2. Add typed opt-in driver, scope ownership, captured-generation commit guards, activity/probe integration, bounded deadline scheduling and async close. Implemented; review pending. +2. Add typed opt-in driver, scope ownership, captured-generation commit guards, activity/probe integration, bounded deadline scheduling and async close. Implemented and independently reviewed. 3. Exercise independent deadline/property oracles, existing FIFO compatibility, cancellation/reset/finalizer failure and a neutral application controller. Complete focused checks. 4. Add actual CLI architecture checks with paired fixtures, export/package consumption checks, and current adoption documentation. 5. Run focused tests, typecheck, package and repository aggregate through the installed host scheduler; independently review before publishing or merging. @@ -32,6 +32,6 @@ Focused driver, public-controller and browser graph tests pass: `bun test src/ef Independent review found two issues, each reproduced before its fix: a suspended Layer worker was omitted from quiescence, and an unjoined child failure disappeared when its root succeeded. The Supervisor now counts suspended workers and retains operation, child and background failure evidence separately. -The first admitted aggregate stopped in `test:npm-release`: 10 tests passed and 13 failed because the old file-count budget rejected the added files and historical smoke tests incorrectly required the new export. The file ceiling now allows exactly six additional files, while existing byte/path/mode guards remain. Effect-specific smoke runs only when the governed source manifest declares that export. These package corrections still require focused recovery and installed-consumer proof. +The first admitted aggregate stopped in `test:npm-release`: 10 tests passed and 13 failed because the old file-count budget rejected the added files and historical smoke tests incorrectly required the new export. The file ceiling now allows exactly six additional files. The measured artifact contains 66 files and 1,202,846 unpacked bytes; both the local parser and minimal publisher retain a bounded 1,250,000-byte unpacked ceiling and the existing packed/path/type/mode guards. Effect-specific smoke runs only when the governed source manifest declares that export. Historical recovery tests passed for 0.7.5 through 0.7.19. The current canonical-identity and publication-separation regressions pass (2 tests, 135 assertions), and the installed-consumer package gate passes, including Bundler and NodeNext type resolution and isolated Effect imports. -The integration owner takes the queued final rebuild, focused package/recovery checks, final aggregate, independent review and release. Generated `dist` output must be regenerated for the final source before it is committed for delivery. Package and final-gate success are not yet claimed. +The final source rebuild and independent review are complete. Generated `dist` output contains the reviewed child-Cause and Supervisor fixes; only the optional Effect entry imports the external Effect peer. The integration owner owns the remaining aggregate gate, pull request, required CI and immutable 0.7.20 release. Final-gate and release success are not yet claimed. diff --git a/scripts/npm-publish-workflow.test.ts b/scripts/npm-publish-workflow.test.ts index e43b6be..04dfdf4 100644 --- a/scripts/npm-publish-workflow.test.ts +++ b/scripts/npm-publish-workflow.test.ts @@ -481,11 +481,11 @@ import { isUtf8ByteLengthAtMost } from "./utf8-byte-boundary.js"; 'expected_tarball_name="hraness-direct-$EXPECTED_VERSION.tgz"', 'const expectedName = "@hraness/direct"', "const minimumFiles = 50", - "const maximumFiles = 60", + "const maximumFiles = 66", "const minimumPackedBytes = 140_000", "const maximumPackedBytes = 260_000", "const minimumUnpackedBytes = 650_000", - "const maximumUnpackedBytes = 1_200_000", + "const maximumUnpackedBytes = 1_250_000", "record.files.length !== record.entryCount", "unpackedSize !== record.unpackedSize", 'createHash("sha1")', diff --git a/scripts/package-artifact.ts b/scripts/package-artifact.ts index eef2b92..e9f4dc9 100644 --- a/scripts/package-artifact.ts +++ b/scripts/package-artifact.ts @@ -11,7 +11,8 @@ const packageBudget = Object.freeze({ // Four optional Effect source files, one entry and one possible split chunk. fileCount: { min: 50, max: 66 }, packedBytes: { min: 140_000, max: 260_000 }, - unpackedBytes: { min: 650_000, max: 1_200_000 }, + // The optional driver ships its source types plus its external-peer bundle. + unpackedBytes: { min: 650_000, max: 1_250_000 }, }); const requiredPaths = Object.freeze([