From c32d967b4d2c88545ee070a2e2d43dcac74f3700 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 21 Sep 2026 22:23:42 -0700 Subject: [PATCH 1/2] test: add repeatable native runtime manual review --- fixtures/react-parity/runtime/README.md | 68 ++ fixtures/react-parity/runtime/angular-app.ts | 172 ++++- fixtures/react-parity/runtime/evidence.json | 710 ++++++++----------- fixtures/react-parity/runtime/react-app.tsx | 178 ++++- fixtures/react-parity/runtime/review.css | 138 ++++ fixtures/react-parity/runtime/scenarios.ts | 57 +- scripts/react-parity/review-runtime.mjs | 392 ++++++++++ scripts/react-parity/review-runtime.spec.mjs | 411 +++++++++++ scripts/react-parity/runtime-consumer.mjs | 11 +- 9 files changed, 1644 insertions(+), 493 deletions(-) create mode 100644 fixtures/react-parity/runtime/review.css create mode 100644 scripts/react-parity/review-runtime.mjs create mode 100644 scripts/react-parity/review-runtime.spec.mjs diff --git a/fixtures/react-parity/runtime/README.md b/fixtures/react-parity/runtime/README.md index 944b4b96a..5ad81ccb6 100644 --- a/fixtures/react-parity/runtime/README.md +++ b/fixtures/react-parity/runtime/README.md @@ -1,5 +1,73 @@ # Installed native runtime consumers +## Manual review + +From the repository root, install dependencies and build the artifacts used by +the review runner: + +```sh +npm ci +NX_DAEMON=false npx nx run-many -t build -p core,angular,react --skip-nx-cache +npx playwright install chromium +node scripts/react-parity/review-runtime.mjs +``` + +On Linux, install browser system dependencies with +`npx playwright install --with-deps chromium`. The runner requires the existing +`dist/libs/{core,angular,react}` artifacts and reports the build command if they +are missing. `node scripts/react-parity/review-runtime.mjs --help` prints the +prerequisite and review sequence. + +The runner packs those artifacts, installs and strictly type-checks isolated +React and Angular consumers, builds each app, and runs all eleven browser +scenarios on fresh fixture servers. Only after those checks pass does it print +two new, untouched loopback URLs. Open each URL manually; no browser opens +automatically. The review servers have made no SDK requests at that point. +Separate preparation processes keep each framework's install/build environment +isolated. The shared stylesheet and labelled panels are fixture review aids. + +The printed source HEAD and dirty source paths identify the checkout used for +the private runtime bundle and fixture UI. They do **not** establish which commit +built the preexisting package artifacts. Printed SHA-256 identifiers separately +identify the actual packed bytes installed in each consumer. Rebuild the +prerequisites when changing core or either native binding. + +Use this order once per fresh server, waiting for each expected state: + +| Action | Expected visible state | +| --- | --- | +| Open | Idle; zero loads, submissions and handler calls; values `unobserved`; interrupts `[]`. Mount performs no SDK I/O. | +| Load | Loads finished `1`, empty Load error, saved transcript and profile, two saved interrupt payloads, delivery `complete:paused`. No tool handler executes. | +| Load again | Loads finished `2`, empty Load error; the same saved content, values, interrupts and paused delivery. | +| Load a third time | Loads finished `3`, empty Load error; empty transcript, values `unobserved`, interrupts `[]`. | +| Send | `Hello ๐ŸŒ.`, idle, delivery `complete:success`; values show the completed stage. | +| Tool | `20 degrees`, a completed weather result for Paris, handler calls `1`; delivery `complete:success`. | +| Error | Status `error` and a protected error message without the private backend diagnostic. Prior delivery remains `complete:success`; this fixture error does not replace it. | +| Hold | `Held partial` appears while delivery is `streaming`. | +| Stop | Delivery becomes `complete:aborted`; the native held response closes and partial text remains visible. | +| Pause | `Waiting for approvals`, both live interrupt payloads, idle, delivery `complete:paused`. | +| Stop again | Both interrupts and `complete:paused` remain; no extra request is made. | +| Send again | Another successful greeting, interrupts `[]`, delivery `complete:success`; submissions `6`, handler calls `1`. | +| Unmount | Component panels disappear; the separate owner controls report `unmounted`. | +| Dispose | Owner reports `disposed`. | +| Send after dispose | Owner reports `aborted`; no request is made. | + +This sequence makes three history reads with `{ limit: 10 }` and seven run +requests, including the tool continuation. Each server permits only three Load +requests. Restart the CLI for a fresh sequence; reloading the page does not reset +server history. Unmount releases the framework observer, while the application +owns the session and explicitly disposes it. + +Keep the CLI running during review. Ctrl+C or SIGTERM closes its servers and +connections, stops and awaits preparation process groups, and removes this +invocation's temporary consumers. Startup failures use the same cleanup path; +if a preparation process group cannot be stopped, its temporary files are +retained and an error is reported. Process-group cleanup targets POSIX macOS and +Linux. The local HTTP/SSE fixture is not a production backend, interrupt resume +API, SSR demonstration, or complete React migration. + +## Automated installed-consumer verification + Run the owned commands from the repository root. On a fresh checkout, install dependencies, build the private artifacts, and install Playwright Chromium: diff --git a/fixtures/react-parity/runtime/angular-app.ts b/fixtures/react-parity/runtime/angular-app.ts index e6a8eb23b..dcc185825 100644 --- a/fixtures/react-parity/runtime/angular-app.ts +++ b/fixtures/react-parity/runtime/angular-app.ts @@ -2,50 +2,166 @@ import { Component, signal } from '@angular/core'; import { bootstrapApplication } from '@angular/platform-browser'; import { observeAgent } from '@threadplane/angular'; import { createFixtureSession } from './runtime-entry.js'; -import { attachOwner, display } from './scenarios'; +import { attachOwner, display, reviewInstructions } from './scenarios'; let handlerCalls = 0; let submissions = 0; -const session = createFixtureSession('/api', 'fixture-thread', () => { handlerCalls += 1; }); -const submit = (input: string) => { submissions += 1; return session.submit(input); }; +const session = createFixtureSession('/api', 'fixture-thread', () => { + handlerCalls += 1; +}); +const submit = (input: string) => { + submissions += 1; + return session.submit(input); +}; @Component({ selector: 'app-root', standalone: true, template: ` -
- - - - - - - - {{ snapshot().status }} - {{ view().text }} - {{ view().transcript }} - {{ view().values }} - {{ view().interrupts }} - {{ loadsFinished() }} - {{ loadError() }} - {{ view().error }} - {{ view().tool }} - {{ view().delivery }} - {{ handlerCalls() }} - {{ submissions() }} +
+
+

Installed package review ยท Angular

+

Session runtime

+

+ Observe the application-owned session through the native Angular + binding. +

+
+
+

Review sequence

+

{{ reviewInstructions }}

+
+
+

Session controls

+
+ +
+
+
+
+

Request state

+
+
+

Status

+ {{ + snapshot().status + }} +
+
+

Delivery

+ {{ + view().delivery + }} +
+
+

Loads finished

+ {{ loadsFinished() }} +
+
+

Submissions

+ {{ + submissions() + }} +
+
+

Handler calls

+ {{ + handlerCalls() + }} +
+
+

Load error

+ {{ + loadError() + }} +
+
+

Error

+ {{ + view().error + }} +
+
+
+
+

Conversation

+
+
+

Text

+ {{ + view().text + }} +
+
+

Transcript

+ {{ + view().transcript + }} +
+
+
+
+

Application values

+
+
+

Application values

+ {{ + view().values + }} +
+
+
+
+

Interrupts

+
+
+

Interrupts

+ {{ + view().interrupts + }} +
+
+
+
+

Tools

+
+
+

Tool result

+ {{ + view().tool + }} +
+
+
+
`, }) class App { + readonly reviewInstructions = reviewInstructions; readonly canLoad = !!session.load; readonly loadsFinished = signal(0); readonly loadError = signal(''); async load() { if (!session.load) return; this.loadError.set(''); - try { await session.load(); } - catch { this.loadError.set('History unavailable'); } - finally { this.loadsFinished.update((count) => count + 1); } + try { + await session.load(); + } catch { + this.loadError.set('History unavailable'); + } finally { + this.loadsFinished.update((count) => count + 1); + } } readonly snapshot = observeAgent(session); readonly view = () => display(this.snapshot()); @@ -55,4 +171,6 @@ class App { readonly stop = () => session.stop(); } -void bootstrapApplication(App).then((application) => attachOwner(session, () => application.destroy())); +void bootstrapApplication(App).then((application) => + attachOwner(session, () => application.destroy()) +); diff --git a/fixtures/react-parity/runtime/evidence.json b/fixtures/react-parity/runtime/evidence.json index 7708b9b70..78d7f4373 100644 --- a/fixtures/react-parity/runtime/evidence.json +++ b/fixtures/react-parity/runtime/evidence.json @@ -1,14 +1,14 @@ { "schemaVersion": 1, "status": "verified-local", - "increment": "Readonly interrupt batch observation (partial T09/T10)", + "increment": "Repeatable installed runtime manual review and readable fixture UI (M01/M02)", "observedOn": "2026-09-21", - "recordedAt": "2026-09-22T04:36:01.807Z", + "recordedAt": "2026-09-22T05:21:42.217Z", "source": { - "branch": "codex/langgraph-interrupt-observation", - "baseCommit": "db867f83be6cb6f9735de41f6aadddf64a0d9937", - "verificationHead": "db867f83be6cb6f9735de41f6aadddf64a0d9937", - "workingTree": "Verified uncommitted interrupt projection/session integration, fixtures, tests and metadata on the merged values-observation HEAD. The fingerprint identifies the actual selected working-tree bytes, not a future commit. I04 changed documentation/inventory/evidence only after the final I03 code checks.", + "branch": "codex/runtime-manual-review", + "baseCommit": "e4862cfaa93e3ae6a8b6c100ec9737e763bc9e23", + "verificationHead": "e4862cfaa93e3ae6a8b6c100ec9737e763bc9e23", + "workingTree": "Verified uncommitted manual-review CLI, fixture styling/layout and helper changes on the interrupt-observation PR head. The selected fingerprint identifies current source bytes, not a future commit or prebuilt package provenance. M02 changes only this evidence and the runtime README after final M01 code checks.", "fingerprint": { "algorithm": "SHA-256 of a UTF-8 manifest: one line per selected file, lowercase SHA-256(file bytes), two ASCII spaces, repo-relative path, LF; unique paths sorted by JavaScript default string ordering.", "pathspecs": [ @@ -38,46 +38,23 @@ "excludedPaths": [ "fixtures/react-parity/runtime/evidence.json" ], - "fileCount": 780, - "sha256": "431e03e9ee0e38c14a267b205962f8bb300dbb01767c0892139f1b4d3047800e", - "selection": "git ls-files -z --cached --others --exclude-standard -- ; keep existing files and remove excludedPaths. Includes tracked and non-ignored untracked files. Evidence itself is excluded to avoid self-reference; nine local research/planning reports are outside all selected paths.", + "fileCount": 783, + "sha256": "d8d095dace5e97be52d19b841b81c6183b7bedc12969bfea927f3645c3ca2098", + "selection": "git ls-files -z --cached --others --exclude-standard -- ; keep existing files and remove excludedPaths. Includes tracked and non-ignored untracked files. Evidence itself is excluded to avoid self-reference; ten local research/planning reports are outside all selected paths.", "reproduce": "node --input-type=module <<'JS'\nimport {createHash} from 'node:crypto';\nimport {execFileSync} from 'node:child_process';\nimport {readFileSync,existsSync} from 'node:fs';\nconst {fingerprint:f}=JSON.parse(readFileSync('fixtures/react-parity/runtime/evidence.json')).source;\nconst sha=value=>createHash('sha256').update(value).digest('hex');\nconst paths=[...new Set(execFileSync('git',['ls-files','-z','--cached','--others','--exclude-standard','--',...f.pathspecs],{encoding:'utf8'}).split('\\0').filter(Boolean))].filter(path=>!f.excludedPaths.includes(path)&&existsSync(path)).sort();\nconst actual=sha(paths.map(path=>sha(readFileSync(path))+' '+path+'\\n').join(''));\nif(paths.length!==f.fileCount||actual!==f.sha256) throw new Error('Source fingerprint mismatch');\nconsole.log(paths.length+' files: '+actual);\nJS" }, "sourceState": { "modified": [ - "fixtures/react-parity/README.md", "fixtures/react-parity/runtime/README.md", "fixtures/react-parity/runtime/angular-app.ts", "fixtures/react-parity/runtime/react-app.tsx", "fixtures/react-parity/runtime/scenarios.ts", - "libs/angular/src/observe-agent.spec.ts", - "libs/angular/src/observe-agent.type-test.ts", - "libs/langgraph/src/runtime/create-session.ts", - "libs/langgraph/src/runtime/history-projection.spec.ts", - "libs/langgraph/src/runtime/history-projection.ts", - "libs/langgraph/src/runtime/langgraph-snapshot.ts", - "libs/langgraph/src/runtime/ownership.ts", - "libs/langgraph/src/runtime/publication.spec.ts", - "libs/langgraph/src/runtime/session-lifecycle.spec.ts", - "libs/langgraph/src/runtime/stream-projection.spec.ts", - "libs/langgraph/src/runtime/stream-projection.ts", - "libs/langgraph/src/runtime/testing/binding-fixture.ts", - "libs/langgraph/src/runtime/transport.integration.spec.ts", - "libs/langgraph/src/runtime/values.spec.ts", - "libs/langgraph/src/runtime/wire-message.ts", - "libs/react/src/use-agent.spec.tsx", - "libs/react/src/use-agent.type-test.ts", - "scripts/react-parity/baseline.json", - "scripts/react-parity/dispositions.json", - "scripts/react-parity/runtime-consumer.mjs", - "scripts/react-parity/runtime-consumer.spec.mjs" + "scripts/react-parity/runtime-consumer.mjs" ], "untracked": [ - "libs/langgraph/src/runtime/interrupt-projection.spec.ts", - "libs/langgraph/src/runtime/interrupt-projection.ts", - "libs/langgraph/src/runtime/interrupt-projection.type-test.ts", - "libs/langgraph/src/runtime/interrupts.spec.ts", - "libs/langgraph/src/runtime/interrupts.type-test.ts" + "fixtures/react-parity/runtime/review.css", + "scripts/react-parity/review-runtime.mjs", + "scripts/react-parity/review-runtime.spec.mjs" ] } }, @@ -112,460 +89,339 @@ { "command": "NX_DAEMON=false node --test scripts/ci-scope.spec.mjs scripts/ci-workflow.spec.mjs scripts/react-parity/*.spec.mjs fixtures/react-parity/traces.spec.mjs", "exitCode": 0, - "verificationPhase": "I04 fresh", - "testsPassed": 414, + "verificationPhase": "Parent final M02 code check; unchanged after this run", + "testsPassed": 425, "testsFailed": 0, - "log": "/tmp/i04-final-focused.log", - "logSha256": "11fadd56f031e36e2944058eb2d629e9ac773cf588d79424da892093abdc3ed5" + "log": "/tmp/m02-focused.log", + "logSha256": "a770c553e08e0ee62b5df8672a3141360658e802eb62da831f6fcc1e92dfd131" }, { - "command": "NX_DAEMON=false npx nx run-many -t runtime-quality,runtime-type-tests,lint -p langgraph --skip-nx-cache", + "command": "node scripts/react-parity/review-runtime.mjs", "exitCode": 0, - "verificationPhase": "I03 final unchanged-code result reused", - "testFiles": 15, - "testsPassed": 359, - "typeTestsPassed": true, - "lintErrors": 0, - "existingLintWarnings": 68, - "log": "/tmp/i03-runtime-green.log", - "logSha256": "cc20283ffd737eeba07fa9ceb9bccb9eb601503ba9592d343dd7108b93ae6312" - }, - { - "command": "NX_DAEMON=false npx nx run-many -t test,type-tests,lint,build -p core,content,angular,react --skip-nx-cache", - "exitCode": 0, - "verificationPhase": "I03 final unchanged-code result reused", - "projects": 4, - "targetConfigurations": 16, - "behaviorTests": { - "core": 2, - "angular": 7, - "react": 7 + "verificationPhase": "Parent final M02 actual CLI smoke, then SIGTERM cleanup", + "strictInstalledChecks": [ + "React contracts and app", + "Angular contracts" + ], + "productionConsumerBuilds": [ + "React Vite", + "Angular CLI/APF" + ], + "browserScenariosPassed": { + "react": 11, + "angular": 11 }, - "content": "Empty scaffold with passWithNoTests; no content behavior count claimed.", - "typeProbeProvenance": "This final foundation run includes the strengthened native inferred-payload probes; it supersedes the earlier native-only run.", - "log": "/tmp/i03-foundations.log", - "logSha256": "5a8e68f36a6544272ea242b556cec01b47834eed8e7e86dd7026e2ef3beb0dd5" - }, - { - "command": "NX_DAEMON=false npx nx run-many -t build --projects=chat,langgraph,ag-ui,render,a2ui,telemetry --configuration=production --skip-nx-cache", - "exitCode": 0, - "verificationPhase": "I03 final unchanged-code result reused", - "productionProjects": 6, - "log": "/tmp/i03-legacy-builds.log", - "logSha256": "384a43124ce92b6f105142a4c2c0a4edaa293872f43783453ec37d8ef7cf6271" + "freshReviewServers": "A separate untouched pair opened only after both e2e runs passed; zero SDK I/O at startup.", + "log": "/tmp/m02-cli.log", + "logSha256": "49672973797ea7ecc05f10fa309e3cde8c26511adec51d137488de1113e23f21" }, { "command": "node scripts/react-parity/verify-boundaries.mjs", "exitCode": 0, - "verificationPhase": "I03 final unchanged-code result reused", - "log": "/tmp/i03-source-boundaries.log", + "verificationPhase": "Parent M02 final source check", + "log": "/tmp/m02-boundaries.log", "logSha256": "995db896b38cf7de5ca9db6590dba47e082d26abdf69bab04b92eee484696063" }, { "command": "node scripts/react-parity/verify-boundaries.mjs --built", "exitCode": 0, - "verificationPhase": "I03 final unchanged-code result reused", - "order": "After successful foundation and six legacy production builds.", - "log": "/tmp/i03-built-boundaries.log", + "verificationPhase": "Parent M02 final emitted check against existing successful production builds; production source unchanged", + "log": "/tmp/m02-built-boundaries.log", "logSha256": "c983a97d17ff7ced7aa9113afa031f464103f566f1bc94eec8160558ec2553f7" }, { - "command": "node scripts/react-parity/verify-packages.mjs", - "exitCode": 0, - "verificationPhase": "I03 final unchanged-code result reused", - "privatePlainTarballs": 3, - "esmTypeExports": 9, - "isolatedCoreExports": 3, - "browserScenariosPassed": 11, - "log": "/tmp/i03-react-browser-green.log", - "logSha256": "575f95d4bba603551c85a5910e07ce9a9e51dc41cb85225718cf60030e7db18e" - }, - { - "command": "node scripts/react-parity/verify-angular-package.mjs", + "command": "node scripts/react-parity/inventory.mjs --check", "exitCode": 0, - "verificationPhase": "I03 final unchanged-code result reused", - "angularAPFExports": 1, - "browserScenariosPassed": 11, - "log": "/tmp/i03-angular-browser-green.log", - "logSha256": "0c123f648eb1b3fb2a59447021945f01c0eecaeec20a99b9bb784b6fb88669a8" - }, - { - "command": "node scripts/react-parity/inventory.mjs --write-baseline; node scripts/react-parity/inventory.mjs --check", - "exitCode": 0, - "verificationPhase": "I04 fresh", + "verificationPhase": "M02 metadata final check", "inventoryRows": 1456, - "reviewedChange": "One private production projector added; seven existing private source hashes changed. No legacy public export drift or prior disposition reassignment.", - "log": "/tmp/i04-final-inventory.log", - "logSha256": "1f2afedb4b61388b4e9880a65639cb508bf28c9af2491e96c10d0b9719f4da3b" + "log": "/tmp/m02-final-inventory.log", + "logSha256": "d09feb53da89b1708c15d8212a3e9a429ddcb7bcce121917360327aa65bf7dae" }, { "command": "git diff --check", "exitCode": 0, - "verificationPhase": "I04 fresh", - "log": "/tmp/i04-final-diff-check.log", + "verificationPhase": "M02 metadata final check", + "log": "/tmp/m02-final-diff-check.log", "logSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" } ], - "scope": { - "privateSnapshot": "LangGraphSnapshot adds readonly interrupts: readonly LangGraphInterrupt[], where LangGraphInterrupt is DeepReadonly with owned plain payload data. Metadata and payloads are observations, not schema inference or resume/targeting instructions.", - "liveProjection": "Root values/updates with own array-valued __interrupt__ controls accumulate dynamic batches; first string ID wins and anonymous entries remain distinct. Empty __interrupt__ replaces the batch with { when: breakpoint }; the next dynamic batch replaces that sentinel. Explicit empty standalone interrupts clears the batch.", - "authoritativeProjection": "Root checkpoints/latest history replace interrupts. A valid values control takes precedence; otherwise all top-level task interrupt arrays contribute. Child namespaces, nested task state and next-only metadata do not infer a root pause.", - "publication": "Interrupts, values and messages are one owned immutable aggregate. History pause delivery uses the same candidate batch; equal reads retain identity and values-only changes share unchanged interrupts. Getter/stale-candidate guards prevent partial publication.", - "lifecycle": "Accepted new submissions clear prior interrupts. Pre-aborted/disposed submissions are inert; stop, disposal and failures retain the last observed batch. Recovery correlation, tool handoff and retained next-user input remain covered.", - "nativeAndInstalledTypes": "Observer implementations are unchanged. Native concrete inference and actual compiler-emitted installed factory declarations retain readonly metadata, namespace arrays, broad plain payloads and tool inference; fixture snapshot structure uses installed core only.", - "unchanged": "Existing values observation, transport normalization and borrowed native lifetime behavior are unchanged. Observation adds no implicit I/O, resume, target selection, state writes, application schema, public factory/root cutover or SSR." - }, - "acceptanceMatrix": { - "status": "Both final I03 production-built installed browser verifiers passed against unchanged code; evidence reused during metadata-only I04.", + "manualBrowserReview": { + "performedBy": "Parent agent using Chrome DevTools MCP and Codex in-app browser; separate fresh server session per framework/surface", "frameworks": [ - "Angular installed APF production app", - "React installed Vite production app" + "React", + "Angular" ], - "scenariosPerFramework": 11, - "scenarios": [ + "surfaces": [ + "Chrome DevTools MCP", + "Codex in-app browser" + ], + "verified": [ "inert mount", - "explicit history load", - "equal history refresh", - "empty history replacement", - "text success", - "weather tool roundtrip", - "protected visible error", - "held partial DOM update and Stop/native response close", - "full interrupt batch and retained pause after Stop", - "reuse after Stop with next accepted submission clearing interrupts", - "unmount/app-owned disposal/post-disposal submit" + "saved/equal/empty history", + "text", + "tool roundtrip", + "protected error", + "held partial text and stop abort", + "two observed interrupts retained after Stop", + "reuse clears interrupts", + "unmount", + "dispose", + "post-disposal aborted submission" ], - "observedPerFramework": { + "perSession": { "historyReads": 3, - "historyRequestBodies": [ - { - "limit": 10 - }, - { - "limit": 10 - }, - { - "limit": 10 - } - ], - "historyRunRequests": 0, - "historyHandlerCalls": 0, - "componentSubmissions": 6, - "toolContinuations": 1, "runRequests": 7, - "toolHandlerCalls": 1, - "postDisposalSubmissions": 1, - "postDisposalRequests": 0, - "pageErrors": 0, - "unexpectedRequests": 0, - "loadedAssistantText": "Saved tool request\nSaved final answer", - "historicalResultText": "Raw historical weather result", - "loadedPendingTool": { - "id": "saved-count", - "name": "count", - "args": { - "values": [ - "saved" - ] - }, - "status": "pending" + "handlerCalls": 1, + "componentSubmissions": 6, + "consoleWarnings": 0, + "consoleErrors": 0, + "fixtureErrors": 0 + }, + "serverSessions": [ + { + "kind": "react", + "surface": "chrome", + "runs": 7, + "history": 3, + "errors": [] }, - "equalRefreshLoadError": "", - "emptyReplacementTranscript": "", - "emptyReplacementToolCalls": [], - "text": "Hello ๐ŸŒ.", - "toolResult": { - "city": "Paris", - "temperature": 20 + { + "kind": "react", + "surface": "iab", + "runs": 7, + "history": 3, + "errors": [] }, - "toolAnswerContains": "20 degrees", - "heldTextContains": "Held partial", - "stopDelivery": "complete:aborted", - "postDisposalOutcome": "aborted", - "values": { - "inertMount": "unobserved", - "historyLoad": { - "stage": "saved", - "profile": { - "name": "Saved user" - } - }, - "equalRefresh": { - "stage": "saved", - "profile": { - "name": "Saved user" - } - }, - "emptyHistoryReplacement": "unobserved", - "textSuccess": { - "stage": "complete" - }, - "toolRoundtrip": {}, - "protectedError": {}, - "heldAndStopped": { - "stage": "held", - "transient": true - }, - "reuseAfterStop": { - "stage": "complete" - }, - "pause": { - "stage": "approval" - } + { + "kind": "angular", + "surface": "chrome", + "runs": 7, + "history": 3, + "errors": [] }, - "loadedDelivery": "complete:paused", - "interrupts": { - "inertMount": [], - "historyLoad": [ - { - "id": "saved-approval", - "value": { - "question": "Approve saved request?", - "choices": [ - "yes", - "no" - ] - }, - "namespace": [ - "review", - "task-1" - ], - "when": "during", - "resumable": true, - "ns": [ - "legacy-review" - ] - }, - { - "id": "saved-confirmation", - "value": 0, - "namespace": [], - "when": "during", - "resumable": false, - "ns": [] - } - ], - "equalRefresh": [ - { - "id": "saved-approval", - "value": { - "question": "Approve saved request?", - "choices": [ - "yes", - "no" - ] - }, - "namespace": [ - "review", - "task-1" - ], - "when": "during", - "resumable": true, - "ns": [ - "legacy-review" - ] - }, - { - "id": "saved-confirmation", - "value": 0, - "namespace": [], - "when": "during", - "resumable": false, - "ns": [] - } - ], - "emptyHistoryReplacement": [], - "pause": [ - { - "id": "live-approval", - "value": { - "question": "Approve action?", - "choices": [ - "yes", - "no" - ] - }, - "namespace": [ - "review", - "live" - ], - "when": "during", - "resumable": true, - "ns": [ - "legacy-live" - ] - }, - { - "id": "live-confirmation", - "value": false, - "namespace": [], - "when": "during", - "resumable": false, - "ns": [] - } - ], - "afterStopAtCompletedPause": [ - { - "id": "live-approval", - "value": { - "question": "Approve action?", - "choices": [ - "yes", - "no" - ] - }, - "namespace": [ - "review", - "live" - ], - "when": "during", - "resumable": true, - "ns": [ - "legacy-live" - ] - }, - { - "id": "live-confirmation", - "value": false, - "namespace": [], - "when": "during", - "resumable": false, - "ns": [] - } - ], - "nextAcceptedSend": [], - "pauseDelivery": "complete:paused", - "pauseRunRequestCount": 6, - "stopAfterPauseAdditionalRequests": 0 + { + "kind": "angular", + "surface": "iab", + "runs": 7, + "history": 3, + "errors": [] } + ], + "expectedDisplay": { + "pause": "complete:paused", + "heldStop": "complete:aborted", + "protectedError": "Status error and protected diagnostic; previous complete:success delivery remains.", + "afterDisposedSubmit": "Owner aborted, no I/O" }, - "notificationAssertions": { - "source": "libs/langgraph/src/runtime/history.spec.ts", - "pendingLoad": 0, - "successfulLoadCumulative": 1, - "equalRefreshCumulative": 1, - "emptyReplacementCumulative": 2, - "failedRefreshAdditional": 0, - "limit": "Unit-test behavioral counts, not render/paint or performance measurements." - }, - "nativeHistoryObservation": { - "readsPerFrameworkTest": 4, - "coverage": "Two task payloads with paused history delivery; equal refresh preserves snapshot/batch identity; values-only refresh shares the batch; empty history clears it.", - "lifetime": "No mount reads; observer teardown does not own session execution/disposal." - }, - "valuesUnitEvidence": { - "sources": [ - "libs/langgraph/src/runtime/values-projection.spec.ts", - "libs/langgraph/src/runtime/values.spec.ts", - "libs/langgraph/src/runtime/publication.spec.ts" - ], - "coverage": "Root/checkpoint/history/recovery replacement and deletion; nested sharing/no-op identity; atomic publication; stale candidate guards; immutable caller isolation; unobserved versus empty; ignored child/control envelopes; no token-time values traversal. These are behavioral assertions, not performance measurements." - }, - "interruptUnitEvidence": { - "sources": [ - "libs/langgraph/src/runtime/interrupt-projection.spec.ts", - "libs/langgraph/src/runtime/interrupts.spec.ts", - "libs/langgraph/src/runtime/history-projection.spec.ts", - "libs/langgraph/src/runtime/transport.integration.spec.ts" - ], - "coverage": "Root control/standalone event projection, batch ID rules, static breakpoints, checkpoint/task precedence, plain-data ownership, atomic getter guards, retention/clearing, correlated recovery, tool handoff and next-user input. No assertion count is a performance measurement." - } + "visualReview": "Parent inspected React populated desktop screenshot, Angular populated narrow screenshot (actual Chrome viewport 500px, despite requested 390px), and React in-app screenshot. M01 separately captured both frameworks at 390px with no horizontal overflow.", + "notes": "A parent Chrome assertion initially expected paused instead of the existing complete:paused display contract; both apps already showed the correct batch. Corrected the assertion and completed Stop/reuse/disposal checks without replaying requests. Screenshot file export was unavailable in Chrome MCP; inline screenshots were inspected. No product defect found.", + "records": [ + { + "log": "/tmp/m02-browser-verification.json", + "logSha256": "da34168bc592eaae450245ec36ff9cc083cf0da47644cac23bb1e90ccd0f53e6" + }, + { + "log": "/tmp/m02-browser-state.json", + "logSha256": "402e3b793773159c68b670604df910f6e5659abf8fdb9d38a59f3d1cc847319f" + }, + { + "log": "/tmp/m02-chrome-final-states.json", + "logSha256": "9b60bbab4fe95fcb776d4c6524ee0c978388014aa1ba74bcc3949ce644a1da3f" + } + ] }, - "cleanup": { - "assertions": "Unmount removes component controls; app disposal resolves; post-disposal submit resolves aborted without extra I/O. Three explicit browser loads read history. Held SSE awaits native response close; Stop after a completed pause retains both payloads and adds no request.", - "resources": "Browser contexts, browsers, held responses, server connections and temporary install/bundle artifacts close in finally, including assertion failures.", - "processes": "Both final I03 verifier processes exited 0 after awaited cleanup; I04 independently reconfirmed their temporary directories are absent.", - "temporaryDirectories": [ + "packageArtifacts": { + "provenance": "Actual tarballs packed and installed by the M02 CLI. SHA-256 identifies these bytes independently of the printed source HEAD; no claim that the prebuilt dist was produced by this commit.", + "sha256": [ { - "path": "/var/folders/_b/0t5_pyt94n7dlqkv1gmt29300000gn/T/threadplane-consumer-acRfSz", - "existsAfterExit": false + "framework": "react", + "name": "@threadplane/react", + "sha256": "b71db72347e6ac0777304265d611968232b36e0dffe2171f7fb660f2e8958bc9" }, { - "path": "/var/folders/_b/0t5_pyt94n7dlqkv1gmt29300000gn/T/threadplane-angular-consumer-owMIZi", - "existsAfterExit": false + "framework": "react", + "name": "@threadplane/core", + "sha256": "651fe687bf2909572eeb543b97b94361a91afbb93c8d74c92ab02cfe092b855c" + }, + { + "framework": "angular", + "name": "@threadplane/angular", + "sha256": "632956c2279a700c9b8ba4b7762d58a6617b8e9b089776eec5fa8091397e15c5" + }, + { + "framework": "angular", + "name": "@threadplane/core", + "sha256": "651fe687bf2909572eeb543b97b94361a91afbb93c8d74c92ab02cfe092b855c" } + ] + }, + "packageDiagnostics": { + "reactConsumer": { + "installedPackages": 23, + "fileBytes": 51236188, + "lockLocationsIncludingOptionalPlatforms": 73, + "productionAppModulesTransformed": 35, + "productionAppJavaScriptRawReported": "419.47 kB", + "productionAppJavaScriptGzipReported": "126.15 kB", + "productionAppCSSRawReported": "1.77 kB" + }, + "angularConsumer": { + "installedPackages": 411, + "fileBytes": 196916646, + "lockLocationsIncludingOptionalPlatforms": 516, + "productionAppJavaScriptRawReported": "320.27 kB", + "productionAppJavaScriptEstimatedTransferReported": "84.82 kB", + "productionAppCSSRawReported": "1.78 kB", + "includes": "Angular CLI/compiler/build tooling" + }, + "limits": "Actual CLI installation footprints and app build diagnostics; includes the private runtime/SDK bundle. Not framework overhead comparisons or performance benchmarks." + }, + "cleanup": { + "result": "Parent sent SIGTERM to the actual CLI; it exited 0. The invocation temporary directory was removed and all six review/manual-session ports closed. This record does not claim a later user handoff pair is running.", + "temporaryDirectory": "/var/folders/_b/0t5_pyt94n7dlqkv1gmt29300000gn/T/threadplane-runtime-review-FCAULj", + "existsAfterExit": false, + "closedPorts": [ + 58749, + 58750, + 58759, + 58760, + 58761, + 58762 ], - "runtimeBundleDirectoriesRemaining": [], - "capturedI03Cleanup": { - "log": "/tmp/i03-cleanup.log", - "logSha256": "02f4832739d9d112a2f5c35e29a0f83d895f24a852e308d19ce1e35b6b61d195", - "newArtifacts": [] - } + "log": "/tmp/m02-cleanup.log", + "logSha256": "95d0dadd4191a5b4e36f6a1ba160d627b1b473f6b47c57c7848539ec247d5d6b", + "supportedPlatform": "Process-group cleanup targets POSIX macOS/Linux; exercised locally on macOS." }, "inventory": { - "historicalFoundationRows": 1438, - "previousValuesRows": 1455, "currentRows": 1456, - "newInterruptSourceFile": "libs/langgraph/src/runtime/interrupt-projection.ts", "sourceFiles": 478, "legacyExports": 550, "uniqueLocalDefinitions": 514, - "legacyExportRecordChangesThisIncrement": 0, - "existingAssignmentsPreserved": 1455, - "dispositions": "All prior IDs, task assignments, treatments, reasons and statuses preserved. Seven scoped notes updated; one internal in-progress row added under T09/T10. No whole-task completion claim.", - "scope": "Existing 16-library inventory only; core/native contracts separately checked. Historical baseline-evidence.json untouched; prior runtime evidence retained in Git history." + "changesThisIncrement": 0, + "dispositions": "All inventory baseline and disposition bytes unchanged. No new task completion claim. Historical baseline-evidence.json untouched." }, + "productionRuntimeEvidence": { + "provenance": "Prior I03 final production runtime/native checks, recorded by I04 and included in merged PR #1119. Production libraries have no diff against the current verification HEAD; these checks are reused, not fresh M02 runs. Earlier packed-consumer logs describe the pre-M01 fixture UI; current UI/build/browser evidence is the M02 CLI above.", + "previousRecord": { + "gitCommit": "e4862cfaa93e3ae6a8b6c100ec9737e763bc9e23", + "path": "fixtures/react-parity/runtime/evidence.json", + "increment": "Readonly interrupt batch observation (partial T09/T10)", + "recordedAt": "2026-09-22T04:36:01.807Z", + "verificationHead": "db867f83be6cb6f9735de41f6aadddf64a0d9937", + "selectedSourceFileCount": 780, + "selectedSourceSha256": "431e03e9ee0e38c14a267b205962f8bb300dbb01767c0892139f1b4d3047800e" + }, + "executed": [ + { + "command": "NX_DAEMON=false npx nx run-many -t runtime-quality,runtime-type-tests,lint -p langgraph --skip-nx-cache", + "exitCode": 0, + "verificationPhase": "Historical I03 unchanged production-code evidence; not rerun for M02", + "testFiles": 15, + "testsPassed": 359, + "typeTestsPassed": true, + "lintErrors": 0, + "existingLintWarnings": 68, + "log": "/tmp/i03-runtime-green.log", + "logSha256": "cc20283ffd737eeba07fa9ceb9bccb9eb601503ba9592d343dd7108b93ae6312" + }, + { + "command": "NX_DAEMON=false npx nx run-many -t test,type-tests,lint,build -p core,content,angular,react --skip-nx-cache", + "exitCode": 0, + "verificationPhase": "Historical I03 unchanged production-code evidence; not rerun for M02", + "projects": 4, + "targetConfigurations": 16, + "behaviorTests": { + "core": 2, + "angular": 7, + "react": 7 + }, + "content": "Empty scaffold with passWithNoTests; no content behavior count claimed.", + "typeProbeProvenance": "This final foundation run includes the strengthened native inferred-payload probes; it supersedes the earlier native-only run.", + "log": "/tmp/i03-foundations.log", + "logSha256": "5a8e68f36a6544272ea242b556cec01b47834eed8e7e86dd7026e2ef3beb0dd5" + }, + { + "command": "NX_DAEMON=false npx nx run-many -t build --projects=chat,langgraph,ag-ui,render,a2ui,telemetry --configuration=production --skip-nx-cache", + "exitCode": 0, + "verificationPhase": "Historical I03 unchanged production-code evidence; not rerun for M02", + "productionProjects": 6, + "log": "/tmp/i03-legacy-builds.log", + "logSha256": "384a43124ce92b6f105142a4c2c0a4edaa293872f43783453ec37d8ef7cf6271" + }, + { + "command": "node scripts/react-parity/verify-boundaries.mjs", + "exitCode": 0, + "verificationPhase": "Historical I03 unchanged production-code evidence; not rerun for M02", + "log": "/tmp/i03-source-boundaries.log", + "logSha256": "995db896b38cf7de5ca9db6590dba47e082d26abdf69bab04b92eee484696063" + }, + { + "command": "node scripts/react-parity/verify-boundaries.mjs --built", + "exitCode": 0, + "verificationPhase": "Historical I03 unchanged production-code evidence; not rerun for M02", + "order": "After successful foundation and six legacy production builds.", + "log": "/tmp/i03-built-boundaries.log", + "logSha256": "c983a97d17ff7ced7aa9113afa031f464103f566f1bc94eec8160558ec2553f7" + }, + { + "command": "node scripts/react-parity/verify-packages.mjs", + "exitCode": 0, + "verificationPhase": "Historical I03 unchanged production-code evidence; not rerun for M02", + "privatePlainTarballs": 3, + "esmTypeExports": 9, + "isolatedCoreExports": 3, + "browserScenariosPassed": 11, + "log": "/tmp/i03-react-browser-green.log", + "logSha256": "575f95d4bba603551c85a5910e07ce9a9e51dc41cb85225718cf60030e7db18e" + }, + { + "command": "node scripts/react-parity/verify-angular-package.mjs", + "exitCode": 0, + "verificationPhase": "Historical I03 unchanged production-code evidence; not rerun for M02", + "angularAPFExports": 1, + "browserScenariosPassed": 11, + "log": "/tmp/i03-angular-browser-green.log", + "logSha256": "0c123f648eb1b3fb2a59447021945f01c0eecaeec20a99b9bb784b6fb88669a8" + } + ] + }, + "scope": "Fixture readability and an explicit local contributor review command only. Production runtime, core/native implementations, protocol counters, inventory dispositions and public API are unchanged. Both apps still use the private staged runtime and real SDK against deterministic local HTTP/SSE routes.", "runtimePolicy": { "ownedSDKDefaultMaxRetries": 0, "explicitOptIn": "Positive clientOptions.maxRetries enables SDK retries. Custom transports own their retry policy; no global no-retry guarantee is claimed.", "recovery": "Uncertain failures use read-only reconciliation without implicit resubmission.", "canonicalTools": "Canonical updates remove or replace pending tool references for the same assistant message, retain calls belonging to other messages, and preserve completed results; ownership is rechecked across pending async execution boundaries." }, - "packageDiagnostics": { - "core": { - "installedPackages": 1, - "fileBytes": 26099, - "lockLocations": 1 - }, - "reactConsumer": { - "installedPackages": 24, - "fileBytes": 51240541, - "lockLocationsIncludingOptionalPlatforms": 74, - "productionAppModulesTransformed": 34, - "productionAppJavaScriptRawReported": "416.16 kB", - "productionAppJavaScriptGzipReported": "125.45 kB", - "developmentRootImportProbe": { - "inputs": 5, - "bytes": 47778, - "contentParserInputs": 0 - } - }, - "angularConsumer": { - "installedPackages": 411, - "fileBytes": 196916614, - "lockLocationsIncludingOptionalPlatforms": 516, - "bundleInputs": 257, - "contentParserInputs": 0, - "productionAppRawReported": "317.59 kB", - "productionAppEstimatedTransferReported": "84.10 kB", - "includes": "Angular CLI/compiler/build tooling" - }, - "limits": "Installed files, optional lock locations, development probes and production apps are separate diagnostics. App bundles include the staged SDK. These are not performance benchmarks or comparable framework overhead measurements." - }, "ci": { - "definition": "Existing library lane covers runtime, source/built boundaries and packed browser checks; it installs Chromium with --with-deps before browser verification.", - "result": "No new interrupt-observation PR CI result is claimed by this local record. Prior merged values/history/runtime CI does not verify these uncommitted changes." + "priorInterruptPR": { + "number": 1119, + "head": "e4862cfaa93e3ae6a8b6c100ec9737e763bc9e23", + "requiredRun": 35687735742, + "requiredConclusion": "SUCCESS", + "mergeCommit": "e9159b21c265adb66c510dbae3d1b3cc697430a5", + "mergedAt": "2026-09-22T05:14:25Z", + "log": "/tmp/m02-pr1119-ci.json", + "logSha256": "c7f493818367f41bbf2eb5ecfdb0c3a29ed7d9e3bddf61530ae6a30216913a20" + }, + "result": "Prior PR CI verifies the interrupt increment only. No CI result for the uncommitted manual-review increment or future follow-up PR is claimed." }, "limits": [ - "Read-only interrupt observation only: no resume, target selection, implicit I/O, state writes, application-schema inference, thread switching, pagination or branching.", - "Private staged runtime/factory and snapshot extension remain fixture-only; no neutral LangGraph tarball or public root cutover.", - "Compiler-emitted installed factory declarations are checked against installed core; metadata and payload types remain readonly and broad, not inferred application shapes.", - "No core public API or observer implementation changes in this increment; session lifetime remains app-owned.", - "HTTP/SSE proves incremental DOM updates and native abort, not compositor timing.", - "Production React StrictMode does not replay development effects; native unit tests separately exercise replay.", - "No full T09/T10/product parity, renderer reuse, SSR, calibrated latency, retained-heap or performance claim.", - "CI Node22/Linux and Angular20/22 consumer lanes were not run locally.", - "Test-only imports still add Nx legacy build dependencies although production package graphs remain isolated.", - "No production backend, release, deployment or publication was exercised." + "The CLI is a local review aid around installed core/native packages and a private source-composed LangGraph fixture; no neutral backend tarball, public root cutover or complete React migration.", + "No production backend, state writes, interrupt resume/target selection, application schema inference, SSR or remote deployment is exercised.", + "Only three explicit history reads per server; restart the CLI to reset. Page reload does not reset server history.", + "Six component submissions make seven wire runs, including one tool continuation. Post-disposal submission resolves aborted with no I/O.", + "Actual HTTP/SSE held-response tests prove incremental DOM updates and native abort, not compositor timing, latency or performance.", + "Production React StrictMode does not replay development effects; prior native tests separately cover replay.", + "No new local Node22/Linux or Angular20/22 consumer run; process cleanup was exercised on macOS.", + "The source fingerprint is distinct from prebuilt package identity. Temporary packed artifacts were deleted during verified cleanup." ], "warnings": [ - "LangGraph lint passes with 68 existing warnings and no errors.", - "Nx reports NO_COLOR/FORCE_COLOR overlap; legacy builds report stale Browserslist data, ng-packagr export-condition overrides and keepLifecycleScripts notices.", - "Vite ignores use client directives in the client-only production consumer bundle; no SSR claim is made." + "The client-only Vite production build ignores use client directives; no SSR claim is made.", + "The reused LangGraph lint result has 68 existing warnings and zero errors." ], "documentation": { "generatorsRun": [], - "reason": "Registered API roots are legacy public entries, narrative generation uses website content, and agent context uses its templates. Private runtime and fixture README guidance are outside those inputs; no generator or native README update was needed." + "reason": "Only private fixture review guidance and evidence changed. Registered API roots, website narrative inputs and public agent-context templates are unchanged; no generator is needed." }, - "verificationProvenance": "I03 implementer confirmed exact commands and successful exits. Logs were inspected and hashed here. Runtime code was unchanged after its combined check; the later foundation run checked final strengthened native type probes, and final packed browsers compiled the strengthened installed probes. No relevant code changed after those final builds/scans/browser runs. I04 changed only fixture README guidance, inventory metadata and this evidence; it reran the full focused suite, inventory and diff checks. No older V04 results or retry metadata are used." + "verificationProvenance": "Parent performed the final M02 code suite, actual CLI smoke, both manual browser surfaces, boundary scans and signal cleanup. Their outputs were inspected and hashed for this record. M02 metadata work verified inventory/diff and reproduced the final selected-byte fingerprint without changing code. Unchanged earlier production checks are explicitly archived above; no future commit, CI result or live review handoff is presumed." } diff --git a/fixtures/react-parity/runtime/react-app.tsx b/fixtures/react-parity/runtime/react-app.tsx index 1f24b5e74..bdaa0674a 100644 --- a/fixtures/react-parity/runtime/react-app.tsx +++ b/fixtures/react-parity/runtime/react-app.tsx @@ -2,13 +2,19 @@ import { StrictMode, useState } from 'react'; import { createRoot } from 'react-dom/client'; import { useAgent } from '@threadplane/react'; import { createFixtureSession } from './runtime-entry.js'; -import { attachOwner, display } from './scenarios'; +import './review.css'; +import { attachOwner, display, reviewInstructions } from './scenarios'; // Application ownership is outside StrictMode and the component lifetime. let handlerCalls = 0; let submissions = 0; -const session = createFixtureSession('/api', 'fixture-thread', () => { handlerCalls += 1; }); -const submit = (input: string) => { submissions += 1; return session.submit(input); }; +const session = createFixtureSession('/api', 'fixture-thread', () => { + handlerCalls += 1; +}); +const submit = (input: string) => { + submissions += 1; + return session.submit(input); +}; function App() { const snapshot = useAgent(session); @@ -17,36 +23,152 @@ function App() { const load = async () => { if (!session.load) return; setLoadError(''); - try { await session.load(); } - catch { setLoadError('History unavailable'); } - finally { setLoadsFinished((count) => count + 1); } + try { + await session.load(); + } catch { + setLoadError('History unavailable'); + } finally { + setLoadsFinished((count) => count + 1); + } }; const view = display(snapshot); - return
- - - - - - - - {snapshot.status} - {view.text} - {view.transcript} - {view.values} - {view.interrupts} - {loadsFinished} - {loadError} - {view.error} - {view.tool} - {view.delivery} - {handlerCalls} - {submissions} -
; + return ( +
+
+

Installed package review ยท React

+

Session runtime

+

+ Observe the application-owned session through the native React + binding. +

+
+
+

Review sequence

+

{reviewInstructions}

+
+
+

Session controls

+
+ + + + + + + +
+
+
+
+

Request state

+
+
+

Status

+ + {snapshot.status} + +
+
+

Delivery

+ + {view.delivery} + +
+
+

Loads finished

+ + {loadsFinished} + +
+
+

Submissions

+ + {submissions} + +
+
+

Handler calls

+ + {handlerCalls} + +
+
+

Load error

+ + {loadError} + +
+
+

Error

+ + {view.error} + +
+
+
+
+

Conversation

+
+
+

Text

+ + {view.text} + +
+
+

Transcript

+ + {view.transcript} + +
+
+
+
+

Application values

+
+
+

Application values

+ + {view.values} + +
+
+
+
+

Interrupts

+
+
+

Interrupts

+ + {view.interrupts} + +
+
+
+
+

Tools

+
+
+

Tool result

+ + {view.tool} + +
+
+
+
+
+ ); } const container = document.getElementById('root'); if (!container) throw new Error('Missing fixture root'); const root = createRoot(container); -root.render(); +root.render( + + + +); attachOwner(session, () => root.unmount()); diff --git a/fixtures/react-parity/runtime/review.css b/fixtures/react-parity/runtime/review.css new file mode 100644 index 000000000..6aae732af --- /dev/null +++ b/fixtures/react-parity/runtime/review.css @@ -0,0 +1,138 @@ +:root { + color-scheme: light; + font-family: system-ui, sans-serif; + color: #182a3b; + background: #f1f5f8; + line-height: 1.5; +} +* { + box-sizing: border-box; +} +body { + margin: 0; + padding: 28px 20px; +} +.review-shell, +.owner-panel { + max-width: 1120px; + margin: 0 auto; +} +header { + margin-bottom: 24px; +} +h1 { + font-size: clamp(1.8rem, 4vw, 2.6rem); + line-height: 1.15; + margin: 4px 0 12px; +} +h2 { + font-size: 1.15rem; + margin: 0 0 14px; +} +h3 { + font-size: 0.8rem; + color: #43566a; + margin: 0 0 6px; +} +p { + margin: 0 0 12px; +} +.eyebrow { + color: #335e7d; + font-size: 0.85rem; + font-weight: 700; +} +.panel { + background: white; + border: 1px solid #c6d3df; + border-radius: 12px; + padding: 20px; + margin-bottom: 18px; + min-width: 0; +} +.instructions { + border-left: 4px solid #1d6b94; +} +.instructions p, +.owner-panel p { + max-width: 90ch; +} +.controls { + display: flex; + flex-wrap: wrap; + gap: 10px; +} +button { + font: inherit; + font-weight: 600; + color: #174361; + background: #eff7fc; + border: 1px solid #6489a3; + border-radius: 6px; + padding: 9px 16px; + cursor: pointer; +} +button:hover { + background: #daeaf5; +} +button:disabled { + opacity: 0.55; + cursor: default; +} +button:focus-visible { + outline: 3px solid #975100; + outline-offset: 3px; +} +.review-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0 18px; +} +.review-grid > :first-child, +.review-grid > :nth-child(2), +.review-grid > :last-child { + grid-column: 1 / -1; +} +.state-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 16px; +} +.fields { + display: grid; + gap: 18px; +} +.field { + min-width: 0; +} +output { + display: block; + min-height: 2.4rem; + padding: 10px 12px; + border-radius: 6px; + background: #f1f5f8; + font: 0.86rem/1.55 ui-monospace, SFMono-Regular, Consolas, monospace; + white-space: pre-wrap; + overflow-wrap: anywhere; +} +output:empty::before { + content: 'โ€”'; + color: #64778a; +} +.owner-panel { + border-top: 3px solid #476c87; +} +.owner-panel h3 { + margin-top: 18px; +} +@media (max-width: 640px) { + body { + padding: 18px 12px; + } + .panel { + padding: 16px; + } + .review-grid { + grid-template-columns: minmax(0, 1fr); + } +} diff --git a/fixtures/react-parity/runtime/scenarios.ts b/fixtures/react-parity/runtime/scenarios.ts index 27621aaad..8e3a059c7 100644 --- a/fixtures/react-parity/runtime/scenarios.ts +++ b/fixtures/react-parity/runtime/scenarios.ts @@ -1,7 +1,17 @@ -import type { AgentSession, AgentSnapshot, PlainValue } from '@threadplane/core'; +import type { + AgentSession, + AgentSnapshot, + PlainValue, +} from '@threadplane/core'; + +export const reviewInstructions = + 'Click Load three times: saved history, equal refresh, then empty history. Continue with Send โ†’ Tool โ†’ Error โ†’ Hold โ†’ Stop โ†’ Pause โ†’ Stop โ†’ Send. Finish with Unmount โ†’ Dispose โ†’ Send after dispose in the owner controls below. Only three Load requests are available per server; restart the review command to reset. Reloading the page does not reset history.'; export interface FixtureTools { - weather: { args: { city: string }; result: { city: string; temperature: number } }; + weather: { + args: { city: string }; + result: { city: string; temperature: number }; + }; count: { args: { values: readonly string[] }; result: number }; } @@ -19,7 +29,9 @@ export type FixtureSnapshot = AgentSnapshot & { }; export function display(snapshot: FixtureSnapshot) { - const assistant = snapshot.messages.filter((message) => message.role === 'assistant'); + const assistant = snapshot.messages.filter( + (message) => message.role === 'assistant' + ); const delivery = assistant.at(-1)?.delivery; return { text: assistant.map((message) => message.content).join('\n'), @@ -28,14 +40,29 @@ export function display(snapshot: FixtureSnapshot) { interrupts: JSON.stringify(snapshot.interrupts), error: snapshot.error?.message ?? '', tool: JSON.stringify(snapshot.toolCalls), - delivery: delivery?.phase === 'complete' ? `complete:${delivery.outcome}` : delivery?.phase ?? '', + delivery: + delivery?.phase === 'complete' + ? `complete:${delivery.outcome}` + : delivery?.phase ?? '', }; } /** Owner controls deliberately survive framework teardown. */ -export function attachOwner(session: AgentSession, unmount: () => void) { +export function attachOwner( + session: AgentSession, + unmount: () => void +) { const owner = document.createElement('section'); owner.setAttribute('aria-label', 'Session owner'); + owner.className = 'panel owner-panel'; + const heading = document.createElement('h2'); + heading.textContent = 'Application owner'; + const explanation = document.createElement('p'); + explanation.textContent = + 'These controls survive observer unmount. Unmount removes the framework view; Dispose closes the application-owned session.'; + const controls = document.createElement('div'); + controls.className = 'controls'; + owner.append(heading, explanation, controls); const status = document.createElement('output'); status.setAttribute('data-testid', 'owner'); status.textContent = 'mounted'; @@ -43,11 +70,21 @@ export function attachOwner(session: AgentSession, unmount: () => const control = document.createElement('button'); control.textContent = label; control.addEventListener('click', () => void action()); - owner.append(control); + controls.append(control); }; - button('Unmount', () => { unmount(); status.textContent = 'unmounted'; }); - button('Dispose', async () => { await session.dispose(); status.textContent = 'disposed'; }); - button('Send after dispose', async () => { status.textContent = await session.submit('Send'); }); - owner.append(status); + button('Unmount', () => { + unmount(); + status.textContent = 'unmounted'; + }); + button('Dispose', async () => { + await session.dispose(); + status.textContent = 'disposed'; + }); + button('Send after dispose', async () => { + status.textContent = await session.submit('Send'); + }); + const label = document.createElement('h3'); + label.textContent = 'Owner state'; + owner.append(label, status); document.body.append(owner); } diff --git a/scripts/react-parity/review-runtime.mjs b/scripts/react-parity/review-runtime.mjs new file mode 100644 index 000000000..21d4a051e --- /dev/null +++ b/scripts/react-parity/review-runtime.mjs @@ -0,0 +1,392 @@ +import { execFileSync, spawn } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const script = fileURLToPath(import.meta.url); +const buildCommand = + 'NX_DAEMON=false npx nx run-many -t build -p core,angular,react --skip-nx-cache'; +const manualOrder = + 'Use three Load clicks (saved, equal refresh, empty); Send โ†’ Tool โ†’ Error โ†’ Hold โ†’ Stop โ†’ Pause โ†’ Stop โ†’ Send โ†’ Unmount โ†’ Dispose โ†’ Send after dispose. History allows only three Load requests per server; restart this command for a fresh review. Reloading the page does not reset server history.'; + +function prerequisites(root) { + const missing = ['core', 'angular', 'react'].filter( + (kind) => !existsSync(join(root, 'dist/libs', kind, 'package.json')) + ); + if (missing.length) + throw new Error( + `Prebuilt artifacts missing: ${missing.join( + ', ' + )}. Build them first:\n${buildCommand}` + ); +} + +function sourceProvenance(root) { + const git = (...args) => + execFileSync('git', args, { cwd: root, encoding: 'utf8' }).trim(); + const paths = [ + 'libs/langgraph/src/runtime', + 'libs/langgraph/src/lib/transport', + 'fixtures/react-parity/runtime', + 'scripts/react-parity/runtime-consumer.mjs', + 'scripts/react-parity/review-runtime.mjs', + ]; + return { + head: git('rev-parse', 'HEAD'), + trackedRuntimeFixtureStatus: git( + 'status', + '--short', + '--untracked-files=no', + '--', + ...paths + ), + untrackedRuntimeFixtureFiles: git( + 'ls-files', + '--others', + '--exclude-standard', + '--', + ...paths + ) + .split('\n') + .filter(Boolean), + }; +} + +/** Worker-only preparation. Vite may change NODE_ENV; each framework gets a + * fresh process so npm always installs its locked compiler/build dev tooling. */ +async function prepare(kind, root, temporary) { + if (!['react', 'angular'].includes(kind)) + throw new Error('Unknown review framework'); + const { packLocalArtifacts, installConsumer, runConsumer } = await import( + './verify-packages.mjs' + ); + const { lockedAngularManifest, angularBuildCommand } = await import( + './verify-angular-package.mjs' + ); + const { lockedReactManifest, prepareInstalledTypes, prepareRuntimeConsumer } = + await import('./runtime-consumer.mjs'); + const directory = join(temporary, kind); + const packed = join(directory, 'packed'); + const consumer = join(directory, 'consumer'); + mkdirSync(packed, { recursive: true }); + mkdirSync(consumer); + const tarballs = packLocalArtifacts(root, packed, [kind]); + const lock = JSON.parse( + readFileSync(join(root, 'package-lock.json'), 'utf8') + ); + let manifest; + if (kind === 'angular') { + cpSync(join(root, 'fixtures/react-parity/consumers/angular'), consumer, { + recursive: true, + }); + manifest = lockedAngularManifest( + JSON.parse(readFileSync(join(consumer, 'package.json'), 'utf8')), + lock + ); + } else manifest = lockedReactManifest(lock); + installConsumer( + consumer, + manifest, + tarballs, + kind === 'react' ? 'plain' : 'angular' + ); + prepareInstalledTypes(root, consumer, kind); + await prepareRuntimeConsumer(root, consumer, kind); + for (const config of [ + 'tsconfig.contracts.json', + ...(kind === 'react' ? ['tsconfig.app.json'] : []), + ]) { + runConsumer( + process.execPath, + [join(consumer, 'node_modules/typescript/bin/tsc'), '-p', config], + consumer + ); + } + console.log( + runConsumer( + process.execPath, + kind === 'angular' + ? angularBuildCommand(consumer) + : [join(consumer, 'node_modules/vite/bin/vite.js'), 'build'], + consumer + ) + ); + const artifacts = Object.entries(tarballs).map(([name, uri]) => { + const path = uri.slice('file:'.length); + return { + name, + path, + sha256: createHash('sha256').update(readFileSync(path)).digest('hex'), + }; + }); + writeFileSync( + join(temporary, `${kind}.json`), + JSON.stringify({ + directory: join( + consumer, + kind === 'angular' ? 'dist/consumer/browser' : 'dist' + ), + artifacts, + }) + ); +} + +function preparationChild(worker, kind, root, temporary, output) { + const env = { + ...process.env, + NG_CLI_ANALYTICS: 'false', + npm_config_include: 'dev', + TMPDIR: temporary, + }; + for (const key of Object.keys(env)) { + if ( + key === 'NODE_ENV' || + /^npm_config_(omit|production|only|include)$/i.test(key) + ) + delete env[key]; + } + env.npm_config_include = 'dev'; + const child = spawn( + process.execPath, + [worker, '--prepare', kind, root, temporary], + { + cwd: root, + detached: true, + stdio: ['ignore', 'pipe', 'pipe'], + env, + } + ); + child.stdout.on('data', (chunk) => output(String(chunk))); + child.stderr.on('data', (chunk) => output(String(chunk))); + let failure; + child.once('error', (error) => { + failure = error; + }); + const done = new Promise((resolve, reject) => + child.once('close', (code, signal) => { + if (failure || code !== 0) + reject( + new Error(`${kind} preparation failed (${signal ?? code})`, { + cause: failure, + }) + ); + else resolve(); + }) + ); + // Observe a late failure even when an abort has already taken ownership. + void done.catch(() => undefined); + return { child, done }; +} + +async function terminate(preparation) { + if (!preparation) return; + const { child, done } = preparation; + const kill = (signal) => { + if (!child.pid) return; + try { + process.kill(-child.pid, signal); + } catch (error) { + if (error.code !== 'ESRCH') throw error; + } + }; + kill('SIGTERM'); + const escalation = setTimeout(() => kill('SIGKILL'), 2000); + try { + await done.catch(() => undefined); + // Closing the worker's pipes does not prove ignored-stdio descendants have + // exited. Keep the group owned until the OS reports that it no longer exists. + const deadline = Date.now() + 5000; + while (child.pid) { + try { + process.kill(-child.pid, 0); + } catch (error) { + if (error.code === 'ESRCH') break; + throw error; + } + if (Date.now() >= deadline) { + throw new Error( + 'Preparation process group did not exit; temporary files retained' + ); + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } + } finally { + clearTimeout(escalation); + } +} + +/** Bounded review lifecycle. Worker/verify/serve seams let tests exercise real + * process cleanup without reinstalling frameworks for every failure case. */ +export async function startRuntimeReview({ + root = process.cwd(), + signal, + signals = process, + log = console.log, + worker = script, + onWorkerOutput = (text) => log(text.trimEnd()), + verify = async (directory, kind) => + (await import('./runtime-consumer.mjs')).runRuntimeScenarios( + directory, + kind + ), + serve = async (directory) => + (await import('./runtime-consumer.mjs')).serveRuntimeConsumer(directory), +} = {}) { + root = resolve(root); + const temporary = mkdtempSync(join(tmpdir(), 'threadplane-runtime-review-')); + const servers = []; + let preparation; + let activePhase; + let stopping = false; + let closing; + let resolveClosed; + const closed = new Promise((resolve) => { + resolveClosed = resolve; + }); + const checkActive = () => { + if (stopping) throw new Error('Review startup aborted'); + }; + const close = () => { + stopping = true; + if (!closing) + closing = (async () => { + try { + await terminate(preparation); + await activePhase; + const results = await Promise.allSettled( + servers.map((server) => server.close()) + ); + rmSync(temporary, { recursive: true, force: true }); + const failures = results.filter( + (result) => result.status === 'rejected' + ); + if (failures.length) + throw new AggregateError( + failures.map((result) => result.reason), + 'Review servers failed to close' + ); + } finally { + signal?.removeEventListener('abort', stop); + signals.removeListener('SIGINT', stop); + signals.removeListener('SIGTERM', stop); + resolveClosed(); + } + })(); + return closing; + }; + const stop = () => { + void close().catch((error) => { + log(String(error)); + process.exitCode = 1; + }); + }; + // Register cleanup before calling any preparation or serving effect. + signal?.addEventListener('abort', stop, { once: true }); + signals.on('SIGINT', stop); + signals.on('SIGTERM', stop); + if (signal?.aborted) stop(); + async function phase(work) { + checkActive(); + let complete; + const pending = new Promise((resolve) => { + complete = resolve; + }); + activePhase = pending; + try { + return await work(); + } finally { + complete(); + if (activePhase === pending) activePhase = undefined; + } + } + try { + checkActive(); + prerequisites(root); + const provenance = sourceProvenance(root); + const prepared = {}; + for (const kind of ['react', 'angular']) { + await phase(async () => { + preparation = preparationChild( + worker, + kind, + root, + temporary, + onWorkerOutput + ); + await preparation.done; + await terminate(preparation); + preparation = undefined; + }); + checkActive(); + prepared[kind] = JSON.parse( + readFileSync(join(temporary, `${kind}.json`), 'utf8') + ); + await phase(() => verify(prepared[kind].directory, kind)); + } + const urls = {}; + for (const kind of ['react', 'angular']) { + await phase(async () => { + const server = await serve(prepared[kind].directory, kind); + servers.push(server); + urls[kind] = server.url; + }); + } + checkActive(); + const artifacts = Object.entries(prepared).flatMap(([framework, value]) => + value.artifacts.map((artifact) => ({ framework, ...artifact })) + ); + log(`Source checkout: ${JSON.stringify(provenance)}`); + log( + 'Source HEAD is checkout provenance only; prebuilt artifacts are not proven to have been built from this HEAD. Packed SHA256 identifiers describe the actual installed bytes.' + ); + log(`Packed artifacts: ${JSON.stringify(artifacts)}`); + log(`Review ready: React ${urls.react} | Angular ${urls.angular}`); + log(manualOrder); + log( + 'These untouched loopback servers have performed no SDK I/O. Open either URL manually. Ctrl+C stops both servers and removes temporary consumers.' + ); + checkActive(); + return { temporary, urls, provenance, artifacts, close, closed }; + } catch (error) { + const aborted = stopping; + await close(); + if (aborted) throw new Error('Review startup aborted', { cause: error }); + throw error; + } +} + +if (process.argv[1] && resolve(process.argv[1]) === script) { + try { + if (process.argv[2] === '--prepare') + await prepare( + process.argv[3], + resolve(process.argv[4]), + resolve(process.argv[5]) + ); + else if (process.argv[2] === '--help') + console.log( + `Installed runtime review\nPrerequisite: build the private package artifacts first:\n${buildCommand}\nRun: node scripts/react-parity/review-runtime.mjs\n${manualOrder}` + ); + else { + const review = await startRuntimeReview(); + await review.closed; + } + } catch (error) { + if (error.message === 'Review startup aborted') + console.log('Review stopped.'); + else { + console.error(error); + process.exitCode = 1; + } + } +} diff --git a/scripts/react-parity/review-runtime.spec.mjs b/scripts/react-parity/review-runtime.spec.mjs new file mode 100644 index 000000000..6550aafc0 --- /dev/null +++ b/scripts/react-parity/review-runtime.spec.mjs @@ -0,0 +1,411 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { EventEmitter } from 'node:events'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import test from 'node:test'; + +const script = resolve('scripts/react-parity/review-runtime.mjs'); +function fixtureRoot(t) { + const root = mkdtempSync(join(tmpdir(), 'review-source-test-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + for (const kind of ['core', 'angular', 'react']) { + const directory = join(root, 'dist/libs', kind); + mkdirSync(directory, { recursive: true }); + writeFileSync(join(directory, 'package.json'), '{}'); + } + const git = (...args) => + execFileSync('git', args, { cwd: root, stdio: 'pipe' }); + git('init'); + git( + '-c', + 'user.name=Fixture', + '-c', + 'user.email=fixture@example.invalid', + '-c', + 'commit.gpgsign=false', + 'commit', + '--allow-empty', + '-m', + 'Fixture' + ); + return root; +} +const deferred = () => { + let resolve; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +}; + +function worker(t, body = '') { + const directory = mkdtempSync(join(tmpdir(), 'manual-review-test-')); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const path = join(directory, 'worker.mjs'); + writeFileSync( + path, + `import { mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { createHash } from 'node:crypto'; +const [, , mode, kind, root, temporary] = process.argv; +if (mode !== '--prepare') throw new Error('Expected preparation child'); +${body} +const directory = join(temporary, kind, 'dist'); +mkdirSync(directory, { recursive: true }); +writeFileSync(join(directory, 'index.html'), kind); +const tarball = join(temporary, kind + '.tgz'); +writeFileSync(tarball, kind); +writeFileSync(join(temporary, kind + '.json'), JSON.stringify({ directory, observedEnv: process.env.NODE_ENV, artifacts: [{ name: '@threadplane/' + kind, path: tarball, sha256: createHash('sha256').update(kind).digest('hex') }] })); +` + ); + return { path, directory }; +} + +test('importing the runner is inert even without artifacts or a project cwd', () => { + const output = execFileSync( + process.execPath, + [ + '--input-type=module', + '-e', + `const before = process.env.NODE_ENV; await import(${JSON.stringify( + pathToFileURL(script).href + )}); if (process.env.NODE_ENV !== before) throw new Error('environment changed'); console.log('imported');`, + ], + { cwd: tmpdir(), encoding: 'utf8', timeout: 5000 } + ); + assert.equal(output, 'imported\n'); +}); + +test('missing prebuilt artifacts fail with a build command before preparation', async (t) => { + const { startRuntimeReview } = await import('./review-runtime.mjs'); + const fake = worker(t, "throw new Error('must not prepare');"); + await assert.rejects( + startRuntimeReview({ root: fake.directory, worker: fake.path, log() {} }), + /Prebuilt.*nx run-many.*core,angular,react/s + ); +}); + +test('workers have isolated unset build environments, e2e runs before fresh manual servers, and close is idempotent', async (t) => { + const { startRuntimeReview } = await import('./review-runtime.mjs'); + const root = fixtureRoot(t); + const fake = worker( + t, + "if (process.env.NODE_ENV !== undefined) throw new Error('inherited NODE_ENV'); if (process.env.npm_config_include !== 'dev' || process.env.npm_config_omit || process.env.NPM_CONFIG_PRODUCTION) throw new Error('dev tools omitted'); if (process.env.TMPDIR !== temporary) throw new Error('unowned worker temporary directory'); if (kind === 'react') process.env.NODE_ENV = 'production';" + ); + const original = { + NODE_ENV: process.env.NODE_ENV, + npm_config_omit: process.env.npm_config_omit, + NPM_CONFIG_PRODUCTION: process.env.NPM_CONFIG_PRODUCTION, + }; + t.after(() => { + for (const [key, value] of Object.entries(original)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + }); + process.env.NODE_ENV = 'production'; + process.env.npm_config_omit = 'dev'; + process.env.NPM_CONFIG_PRODUCTION = 'true'; + const before = process.env.NODE_ENV; + const events = []; + const logs = []; + const review = await startRuntimeReview({ + root, + worker: fake.path, + log: (line) => logs.push(line), + verify: async (_directory, kind) => events.push(`verify ${kind}`), + serve: async (_directory, kind) => { + events.push(`serve ${kind}`); + return { + url: `http://127.0.0.1:${kind === 'react' ? 1234 : 1235}`, + close: async () => events.push(`close ${kind}`), + }; + }, + }); + t.after(() => review.close()); + assert.equal(process.env.NODE_ENV, before); + assert.equal(process.env.npm_config_omit, 'dev'); + assert.equal(process.env.NPM_CONFIG_PRODUCTION, 'true'); + assert.equal( + JSON.parse(readFileSync(join(review.temporary, 'angular.json'))) + .observedEnv, + undefined + ); + assert.deepEqual(events, [ + 'verify react', + 'verify angular', + 'serve react', + 'serve angular', + ]); + assert.equal( + review.provenance.head, + execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: root, + encoding: 'utf8', + }).trim() + ); + assert.equal(review.artifacts.length, 2); + assert.match(review.artifacts[0].sha256, /^[a-f0-9]{64}$/); + assert.ok( + logs.some( + (line) => line.includes('prebuilt artifacts') && line.includes('HEAD') + ) + ); + assert.ok( + logs.some((line) => line.includes('three Load') && line.includes('restart')) + ); + await Promise.all([review.close(), review.close()]); + assert.deepEqual(events.slice(-2), ['close react', 'close angular']); + assert.equal(existsSync(review.temporary), false); +}); + +test('failed second preparation removes the first preparation and starts no servers', async (t) => { + const { startRuntimeReview } = await import('./review-runtime.mjs'); + const root = fixtureRoot(t); + const marker = join(tmpdir(), `review-failure-${process.pid}.json`); + t.after(() => rmSync(marker, { force: true })); + const fake = worker( + t, + `writeFileSync(${JSON.stringify( + marker + )}, temporary); if (kind === 'angular') throw new Error('second preparation failed');` + ); + let served = false; + await assert.rejects( + startRuntimeReview({ + root, + worker: fake.path, + log() {}, + verify: async () => undefined, + serve: async () => { + served = true; + }, + }), + /angular.*preparation.*failed/i + ); + assert.equal(served, false); + assert.equal(existsSync(readFileSync(marker, 'utf8')), false); +}); + +test('a second server failure closes the first server before deleting prepared files', async (t) => { + const { startRuntimeReview } = await import('./review-runtime.mjs'); + const root = fixtureRoot(t); + const fake = worker(t); + let firstDirectory; + let closed = false; + await assert.rejects( + startRuntimeReview({ + root, + worker: fake.path, + log() {}, + verify: async () => undefined, + serve: async (directory, kind) => { + if (kind === 'angular') throw new Error('second server failed'); + firstDirectory = directory; + return { + url: 'http://127.0.0.1:1234', + close: async () => { + assert.ok(existsSync(directory)); + closed = true; + }, + }; + }, + }), + /second server failed/ + ); + assert.equal(closed, true); + assert.equal(existsSync(firstDirectory), false); +}); + +test('abort during e2e waits for its cleanup and never starts manual servers', async (t) => { + const { startRuntimeReview } = await import('./review-runtime.mjs'); + const root = fixtureRoot(t); + const fake = worker(t); + const entered = deferred(); + const finish = deferred(); + const controller = new AbortController(); + let directory; + let served = false; + const starting = startRuntimeReview({ + root, + worker: fake.path, + signal: controller.signal, + log() {}, + verify: async (path) => { + directory = path; + entered.resolve(); + await finish.promise; + assert.ok(existsSync(path)); + }, + serve: async () => { + served = true; + }, + }); + const rejected = assert.rejects(starting, /abort/i); + await entered.promise; + controller.abort(); + assert.ok(existsSync(directory)); + finish.resolve(); + await rejected; + assert.equal(served, false); + assert.equal(existsSync(directory), false); +}); + +test('abort terminates preparation descendants and awaits their close before deleting owned files', async (t) => { + const { startRuntimeReview } = await import('./review-runtime.mjs'); + const root = fixtureRoot(t); + const fake = worker(t); + const finished = join(fake.directory, 'descendant-finished'); + const ready = deferred(); + const controller = new AbortController(); + const code = `import { spawn } from 'node:child_process'; +import { writeFileSync, existsSync } from 'node:fs'; +const temporary = process.argv[5]; +const child = spawn(process.execPath, ['--input-type=module', '-e', ${JSON.stringify( + `import { writeFileSync, existsSync } from 'node:fs'; process.on('SIGTERM', () => setTimeout(() => { writeFileSync(${JSON.stringify( + finished + )}, String(existsSync(process.argv[1]))); process.exit(0); }, 30)); console.log('DESCENDANT_READY'); setInterval(() => {}, 1000);` + )}, temporary], { stdio: ['ignore', 'inherit', 'inherit'] }); +setInterval(() => {}, 1000);`; + writeFileSync(fake.path, code); + const starting = startRuntimeReview({ + root, + worker: fake.path, + signal: controller.signal, + log() {}, + onWorkerOutput: (text) => { + if (text.split('\n').includes('DESCENDANT_READY')) ready.resolve(); + }, + verify: async () => undefined, + }); + const rejected = assert.rejects(starting, /abort/i); + await ready.promise; + controller.abort(); + await rejected; + assert.equal(readFileSync(finished, 'utf8'), 'true'); +}); + +for (const signal of ['SIGINT', 'SIGTERM']) { + test(`${signal} closes a ready review through registered handlers`, async (t) => { + const { startRuntimeReview } = await import('./review-runtime.mjs'); + const root = fixtureRoot(t); + const fake = worker(t); + const signals = new EventEmitter(); + let closed = 0; + const review = await startRuntimeReview({ + root, + worker: fake.path, + signals, + log() {}, + verify: async () => undefined, + serve: async () => ({ + url: 'http://127.0.0.1:1234', + close: async () => { + closed++; + }, + }), + }); + signals.emit(signal); + await review.closed; + assert.equal(closed, 2); + assert.equal(existsSync(review.temporary), false); + assert.equal(signals.listenerCount('SIGINT'), 0); + assert.equal(signals.listenerCount('SIGTERM'), 0); + }); +} + +test('abort awaits ignored-stdio descendants after the worker has exited', async (t) => { + const { startRuntimeReview } = await import('./review-runtime.mjs'); + const root = fixtureRoot(t); + const fake = worker(t); + const readyFile = join(fake.directory, 'ready'); + const finished = join(fake.directory, 'finished'); + const ready = deferred(); + const controller = new AbortController(); + const descendant = `import { writeFileSync, existsSync } from 'node:fs'; +process.on('SIGTERM', () => setTimeout(() => { writeFileSync(${JSON.stringify( + finished + )}, String(existsSync(process.argv[1]))); process.exit(0); }, 150)); +writeFileSync(${JSON.stringify( + readyFile + )}, 'ready'); setInterval(() => {}, 1000);`; + writeFileSync( + fake.path, + `import { spawn } from 'node:child_process'; +import { existsSync } from 'node:fs'; +spawn(process.execPath, ['--input-type=module', '-e', ${JSON.stringify( + descendant + )}, process.argv[5]], { stdio: 'ignore' }); +const timer = setInterval(() => { if (existsSync(${JSON.stringify( + readyFile + )})) { clearInterval(timer); console.log('DESCENDANT_READY'); } }, 10); +setInterval(() => {}, 1000);` + ); + const starting = startRuntimeReview({ + root, + worker: fake.path, + signal: controller.signal, + log() {}, + onWorkerOutput: (text) => { + if (text.split('\n').includes('DESCENDANT_READY')) ready.resolve(); + }, + verify: async () => undefined, + }); + const rejected = assert.rejects(starting, /abort/i); + await ready.promise; + controller.abort(); + await rejected; + // Wait for the independent marker even when a broken runner returns early. + const deadline = Date.now() + 2000; + while (!existsSync(finished) && Date.now() < deadline) + await new Promise((resolve) => setTimeout(resolve, 10)); + assert.equal(readFileSync(finished, 'utf8'), 'true'); +}); + +test('failed workers retain ownership until surviving descendants exit', async (t) => { + const { startRuntimeReview } = await import('./review-runtime.mjs'); + const root = fixtureRoot(t); + const fake = worker(t); + const readyFile = join(fake.directory, 'ready'); + const finished = join(fake.directory, 'finished'); + const descendant = `import { writeFileSync, existsSync } from 'node:fs'; +const finish = () => { writeFileSync(${JSON.stringify( + finished + )}, String(existsSync(process.argv[1]))); process.exit(0); }; +process.on('SIGTERM', () => setTimeout(finish, 50)); +writeFileSync(${JSON.stringify(readyFile)}, 'ready'); setTimeout(finish, 500);`; + writeFileSync( + fake.path, + `import { spawn } from 'node:child_process'; +import { existsSync } from 'node:fs'; +spawn(process.execPath, ['--input-type=module', '-e', ${JSON.stringify( + descendant + )}, process.argv[5]], { stdio: 'ignore' }); +setInterval(() => { if (existsSync(${JSON.stringify( + readyFile + )})) process.exit(1); }, 10);` + ); + await assert.rejects( + startRuntimeReview({ + root, + worker: fake.path, + log() {}, + verify: async () => undefined, + }), + /preparation failed/ + ); + const deadline = Date.now() + 2000; + while (!existsSync(finished) && Date.now() < deadline) + await new Promise((resolve) => setTimeout(resolve, 10)); + assert.equal(readFileSync(finished, 'utf8'), 'true'); +}); diff --git a/scripts/react-parity/runtime-consumer.mjs b/scripts/react-parity/runtime-consumer.mjs index 753424d90..c5d9b19de 100644 --- a/scripts/react-parity/runtime-consumer.mjs +++ b/scripts/react-parity/runtime-consumer.mjs @@ -172,10 +172,19 @@ export async function prepareRuntimeConsumer(root, consumer, kind) { cpSync(join(temporary, 'bundle/runtime-entry.js'), join(destination, 'runtime-entry.js')); cpSync(join(temporary, 'types/fixtures/react-parity/runtime/runtime-entry.d.ts'), join(destination, 'runtime-entry.d.ts')); cpSync(join(fixture, 'scenarios.ts'), join(destination, 'scenarios.ts')); + cpSync(join(fixture, 'review.css'), join(destination, 'review.css')); cpSync(join(fixture, `${kind}-app.${kind === 'react' ? 'tsx' : 'ts'}`), join(destination, kind === 'react' ? 'main.tsx' : 'main.ts')); + if (kind === 'angular') { + const configPath = join(consumer, 'angular.json'); + const config = JSON.parse(readFileSync(configPath, 'utf8')); + config.projects.consumer.architect.build.options.styles = ['src/review.css']; + writeFileSync(configPath, JSON.stringify(config)); + const index = join(destination, 'index.html'); + writeFileSync(index, readFileSync(index, 'utf8').replace('', '')); + } if (kind === 'react') { cpSync(join(fixture, 'vite.config.mts'), join(consumer, 'vite.config.mts')); - writeFileSync(join(consumer, 'index.html'), 'React installed consumer
'); + writeFileSync(join(consumer, 'index.html'), 'React installed consumer
'); writeFileSync(join(consumer, 'tsconfig.app.json'), JSON.stringify({ compilerOptions: { target: 'ES2022', module: 'ESNext', moduleResolution: 'Bundler', lib: ['ES2022', 'DOM'], types: [], strict: true, skipLibCheck: false, jsx: 'react-jsx', noEmit: true }, files: ['main.tsx'] })); } } finally { rmSync(temporary, { recursive: true, force: true }); } From 1a11e06118e5d2366c719ff72506bff3ee48b2a5 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 21 Sep 2026 22:40:10 -0700 Subject: [PATCH 2/2] fix: make runtime review cleanup tolerate exiting process groups --- fixtures/react-parity/runtime/evidence.json | 65 +++++-- scripts/react-parity/review-runtime.mjs | 72 ++++--- scripts/react-parity/review-runtime.spec.mjs | 193 +++++++++++++++---- 3 files changed, 246 insertions(+), 84 deletions(-) diff --git a/fixtures/react-parity/runtime/evidence.json b/fixtures/react-parity/runtime/evidence.json index 78d7f4373..b48c00d60 100644 --- a/fixtures/react-parity/runtime/evidence.json +++ b/fixtures/react-parity/runtime/evidence.json @@ -3,12 +3,12 @@ "status": "verified-local", "increment": "Repeatable installed runtime manual review and readable fixture UI (M01/M02)", "observedOn": "2026-09-21", - "recordedAt": "2026-09-22T05:21:42.217Z", + "recordedAt": "2026-09-22T05:38:24.924Z", "source": { "branch": "codex/runtime-manual-review", "baseCommit": "e4862cfaa93e3ae6a8b6c100ec9737e763bc9e23", - "verificationHead": "e4862cfaa93e3ae6a8b6c100ec9737e763bc9e23", - "workingTree": "Verified uncommitted manual-review CLI, fixture styling/layout and helper changes on the interrupt-observation PR head. The selected fingerprint identifies current source bytes, not a future commit or prebuilt package provenance. M02 changes only this evidence and the runtime README after final M01 code checks.", + "verificationHead": "55781ffdb710a3610c07858b2685d27671e5934f", + "workingTree": "Verified the uncommitted PR #1120 review follow-up after merging the #1119 main merge. Changes are limited to review-runner process cleanup, its tests and this evidence. Fixture UI, private runtime and installed binding source bytes are unchanged from the M02 browser review. The fingerprint identifies selected working-tree bytes, not a future commit or prebuilt artifact provenance.", "fingerprint": { "algorithm": "SHA-256 of a UTF-8 manifest: one line per selected file, lowercase SHA-256(file bytes), two ASCII spaces, repo-relative path, LF; unique paths sorted by JavaScript default string ordering.", "pathspecs": [ @@ -39,23 +39,16 @@ "fixtures/react-parity/runtime/evidence.json" ], "fileCount": 783, - "sha256": "d8d095dace5e97be52d19b841b81c6183b7bedc12969bfea927f3645c3ca2098", + "sha256": "2309fc7b8dca28fffc7a13fc6edb8d198402a04f5e5d4f59d6756b7649d122f7", "selection": "git ls-files -z --cached --others --exclude-standard -- ; keep existing files and remove excludedPaths. Includes tracked and non-ignored untracked files. Evidence itself is excluded to avoid self-reference; ten local research/planning reports are outside all selected paths.", "reproduce": "node --input-type=module <<'JS'\nimport {createHash} from 'node:crypto';\nimport {execFileSync} from 'node:child_process';\nimport {readFileSync,existsSync} from 'node:fs';\nconst {fingerprint:f}=JSON.parse(readFileSync('fixtures/react-parity/runtime/evidence.json')).source;\nconst sha=value=>createHash('sha256').update(value).digest('hex');\nconst paths=[...new Set(execFileSync('git',['ls-files','-z','--cached','--others','--exclude-standard','--',...f.pathspecs],{encoding:'utf8'}).split('\\0').filter(Boolean))].filter(path=>!f.excludedPaths.includes(path)&&existsSync(path)).sort();\nconst actual=sha(paths.map(path=>sha(readFileSync(path))+' '+path+'\\n').join(''));\nif(paths.length!==f.fileCount||actual!==f.sha256) throw new Error('Source fingerprint mismatch');\nconsole.log(paths.length+' files: '+actual);\nJS" }, "sourceState": { "modified": [ - "fixtures/react-parity/runtime/README.md", - "fixtures/react-parity/runtime/angular-app.ts", - "fixtures/react-parity/runtime/react-app.tsx", - "fixtures/react-parity/runtime/scenarios.ts", - "scripts/react-parity/runtime-consumer.mjs" - ], - "untracked": [ - "fixtures/react-parity/runtime/review.css", "scripts/react-parity/review-runtime.mjs", "scripts/react-parity/review-runtime.spec.mjs" - ] + ], + "untracked": [] } }, "environment": { @@ -89,11 +82,19 @@ { "command": "NX_DAEMON=false node --test scripts/ci-scope.spec.mjs scripts/ci-workflow.spec.mjs scripts/react-parity/*.spec.mjs fixtures/react-parity/traces.spec.mjs", "exitCode": 0, - "verificationPhase": "Parent final M02 code check; unchanged after this run", - "testsPassed": 425, + "verificationPhase": "M03 final review-fix verification", + "testsPassed": 429, "testsFailed": 0, - "log": "/tmp/m02-focused.log", - "logSha256": "a770c553e08e0ee62b5df8672a3141360658e802eb62da831f6fcc1e92dfd131" + "log": "/tmp/m03-final-focused.log", + "logSha256": "d4972fae5116e9b0d500f675502e98317730de67c927e7dc8847d2f3aeb71ce8" + }, + { + "command": "node scripts/react-parity/review-runtime.mjs", + "exitCode": 0, + "verificationPhase": "M03 final runner: installed declarations, builds, eleven scenarios per framework, then fresh servers; SIGTERM exited successfully", + "browserScenariosPerFramework": 11, + "log": "/tmp/m03-final-cli.log", + "logSha256": "45d80e8822b1be2aa86bbe9bca31dbc9a70829ee0c9950180be00d79fbf9c08a" }, { "command": "node scripts/react-parity/review-runtime.mjs", @@ -403,7 +404,7 @@ "log": "/tmp/m02-pr1119-ci.json", "logSha256": "c7f493818367f41bbf2eb5ecfdb0c3a29ed7d9e3bddf61530ae6a30216913a20" }, - "result": "Prior PR CI verifies the interrupt increment only. No CI result for the uncommitted manual-review increment or future follow-up PR is claimed." + "result": "Prior PR CI verifies the interrupt increment only. PR #1120 checks on c32d967 do not verify this uncommitted review follow-up; new-head CI and CodeQL confirmation are still required." }, "limits": [ "The CLI is a local review aid around installed core/native packages and a private source-composed LangGraph fixture; no neutral backend tarball, public root cutover or complete React migration.", @@ -423,5 +424,31 @@ "generatorsRun": [], "reason": "Only private fixture review guidance and evidence changed. Registered API roots, website narrative inputs and public agent-context templates are unchanged; no generator is needed." }, - "verificationProvenance": "Parent performed the final M02 code suite, actual CLI smoke, both manual browser surfaces, boundary scans and signal cleanup. Their outputs were inspected and hashed for this record. M02 metadata work verified inventory/diff and reproduced the final selected-byte fingerprint without changing code. Unchanged earlier production checks are explicitly archived above; no future commit, CI result or live review handoff is presumed." + "verificationProvenance": "Parent performed the final M02 code suite, actual CLI smoke, both manual browser surfaces, boundary scans and signal cleanup. Their outputs were inspected and hashed for this record. M02 metadata work verified inventory/diff and reproduced the final selected-byte fingerprint without changing code. Unchanged earlier production checks are explicitly archived above; no future commit, CI result or live review handoff is presumed. The M03 review follow-up reran all 429 focused tests and the actual CLI after fixing the reproduced process-group race; SIGTERM removed its owned directory and closed both ports. M02 manual browser output and earlier production evidence are reused only for unchanged UI/runtime source.", + "reviewFollowup": { + "finding": "CodeQL js/bad-code-sanitization alert 90 on PR #1120: replace nested generated child JavaScript with static child scripts and JSON/argv path data, including quote/backslash coverage.", + "cleanup": "The broader verification reproduced macOS EPERM while probing an exiting process group. Cleanup now uses one memoized bounded awaited loop, retries transient permission errors, requires both group ESRCH and worker close, and retains files with the permission-error cause on persistent denial. No timer callback can throw outside the cleanup promise.", + "primaryReference": "https://github.com/apple-oss-distributions/xnu/blob/main/bsd/kern/kern_sig.c", + "regressions": "Three deterministic transient-denial cases cover SIGTERM, exit probe and SIGKILL; persistent denial verifies bounded rejection and retained files. The 28 focused runner/HTTP tests and 429 full focused tests pass.", + "records": [ + { + "log": "/tmp/m03-codeql-focused.log", + "logSha256": "576406444f697b9a3b68f7a79d93f173ab1a1aa74c64786de5199d3d38965cd5" + }, + { + "log": "/tmp/m03-eperm-red.log", + "logSha256": "394a4a9b696ab7c9dde4ac191c2373bb9bd019652e51e33a5605ef4b32ac23d4" + }, + { + "log": "/tmp/m03-eperm-green.log", + "logSha256": "f826d2579cd099fa117ae1a445065cb308a424f1f4cd8b71a48230bef9513a3f" + }, + { + "log": "/tmp/m03-final-cleanup.log", + "logSha256": "30fadac3450d40529380f3e8a7dd6f3a6cb0a93f86d34544b3f5efa0bc986745" + } + ], + "priorManualReviewCommit": "c32d967b4d2c88545ee070a2e2d43dcac74f3700", + "browserProvenance": "M02 Chrome MCP/in-app interactions remain applicable to byte-identical UI/runtime. M03 reran actual CLI preparation, installed strict declarations, both browser suites and signal cleanup. CodeQL closure awaits the pushed review-fix head; no future CI result is claimed." + } } diff --git a/scripts/react-parity/review-runtime.mjs b/scripts/react-parity/review-runtime.mjs index 21d4a051e..d6bc286be 100644 --- a/scripts/react-parity/review-runtime.mjs +++ b/scripts/react-parity/review-runtime.mjs @@ -188,41 +188,56 @@ function preparationChild(worker, kind, root, temporary, output) { return { child, done }; } -async function terminate(preparation) { +async function terminate(preparation, killProcessGroup) { if (!preparation) return; - const { child, done } = preparation; - const kill = (signal) => { - if (!child.pid) return; - try { - process.kill(-child.pid, signal); - } catch (error) { - if (error.code !== 'ESRCH') throw error; - } - }; - kill('SIGTERM'); - const escalation = setTimeout(() => kill('SIGKILL'), 2000); - try { - await done.catch(() => undefined); - // Closing the worker's pipes does not prove ignored-stdio descendants have - // exited. Keep the group owned until the OS reports that it no longer exists. - const deadline = Date.now() + 5000; - while (child.pid) { + // Abort and startup failure can reach the same preparation concurrently. + // Share one bounded cleanup attempt, including its failure. + preparation.termination ??= Promise.resolve().then(async () => { + const { child, done } = preparation; + let closed = false; + void done.then( + () => { + closed = true; + }, + () => { + closed = true; + } + ); + let groupGone = !child.pid; + let permissionError; + const signal = (value) => { + if (groupGone) return; try { - process.kill(-child.pid, 0); + killProcessGroup(-child.pid, value); } catch (error) { - if (error.code === 'ESRCH') break; - throw error; + if (error.code === 'ESRCH') groupGone = true; + // macOS can report EPERM while a zombie-only group is being reaped. + // Retain ownership until a later ESRCH; denial is never exit proof. + else if (error.code === 'EPERM') permissionError = error; + else throw error; + } + }; + const started = Date.now(); + let escalated = false; + signal('SIGTERM'); + while (!groupGone || !closed) { + const elapsed = Date.now() - started; + if (!groupGone && !escalated && elapsed >= 2000) { + escalated = true; + signal('SIGKILL'); } - if (Date.now() >= deadline) { + if (!groupGone) signal(0); + if (groupGone && closed) break; + if (elapsed >= 5000) { throw new Error( - 'Preparation process group did not exit; temporary files retained' + 'Preparation process group did not exit; temporary files retained', + { cause: permissionError } ); } await new Promise((resolve) => setTimeout(resolve, 25)); } - } finally { - clearTimeout(escalation); - } + }); + return preparation.termination; } /** Bounded review lifecycle. Worker/verify/serve seams let tests exercise real @@ -234,6 +249,7 @@ export async function startRuntimeReview({ log = console.log, worker = script, onWorkerOutput = (text) => log(text.trimEnd()), + killProcessGroup = (pid, signal) => process.kill(pid, signal), verify = async (directory, kind) => (await import('./runtime-consumer.mjs')).runRuntimeScenarios( directory, @@ -261,7 +277,7 @@ export async function startRuntimeReview({ if (!closing) closing = (async () => { try { - await terminate(preparation); + await terminate(preparation, killProcessGroup); await activePhase; const results = await Promise.allSettled( servers.map((server) => server.close()) @@ -324,7 +340,7 @@ export async function startRuntimeReview({ onWorkerOutput ); await preparation.done; - await terminate(preparation); + await terminate(preparation, killProcessGroup); preparation = undefined; }); checkActive(); diff --git a/scripts/react-parity/review-runtime.spec.mjs b/scripts/react-parity/review-runtime.spec.mjs index 6550aafc0..4b168d70f 100644 --- a/scripts/react-parity/review-runtime.spec.mjs +++ b/scripts/react-parity/review-runtime.spec.mjs @@ -265,19 +265,33 @@ test('abort terminates preparation descendants and awaits their close before del const { startRuntimeReview } = await import('./review-runtime.mjs'); const root = fixtureRoot(t); const fake = worker(t); - const finished = join(fake.directory, 'descendant-finished'); + const finished = join(fake.directory, `descendant-'quote"-\\backslash`); const ready = deferred(); const controller = new AbortController(); - const code = `import { spawn } from 'node:child_process'; -import { writeFileSync, existsSync } from 'node:fs'; -const temporary = process.argv[5]; -const child = spawn(process.execPath, ['--input-type=module', '-e', ${JSON.stringify( - `import { writeFileSync, existsSync } from 'node:fs'; process.on('SIGTERM', () => setTimeout(() => { writeFileSync(${JSON.stringify( - finished - )}, String(existsSync(process.argv[1]))); process.exit(0); }, 30)); console.log('DESCENDANT_READY'); setInterval(() => {}, 1000);` - )}, temporary], { stdio: ['ignore', 'inherit', 'inherit'] }); -setInterval(() => {}, 1000);`; - writeFileSync(fake.path, code); + writeFileSync( + join(fake.directory, 'paths.json'), + JSON.stringify({ finished }) + ); + writeFileSync( + join(fake.directory, 'descendant.mjs'), + `import { writeFileSync, existsSync } from 'node:fs'; +const [, , temporary, finished] = process.argv; +process.on('SIGTERM', () => setTimeout(() => { + writeFileSync(finished, String(existsSync(temporary))); + process.exit(0); +}, 30)); +console.log('DESCENDANT_READY'); +setInterval(() => {}, 1000);` + ); + writeFileSync( + fake.path, + `import { spawn } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +const { finished } = JSON.parse(readFileSync(new URL('./paths.json', import.meta.url), 'utf8')); +spawn(process.execPath, [fileURLToPath(new URL('./descendant.mjs', import.meta.url)), process.argv[5], finished], { stdio: ['ignore', 'inherit', 'inherit'] }); +setInterval(() => {}, 1000);` + ); const starting = startRuntimeReview({ root, worker: fake.path, @@ -332,23 +346,29 @@ test('abort awaits ignored-stdio descendants after the worker has exited', async const finished = join(fake.directory, 'finished'); const ready = deferred(); const controller = new AbortController(); - const descendant = `import { writeFileSync, existsSync } from 'node:fs'; -process.on('SIGTERM', () => setTimeout(() => { writeFileSync(${JSON.stringify( - finished - )}, String(existsSync(process.argv[1]))); process.exit(0); }, 150)); -writeFileSync(${JSON.stringify( - readyFile - )}, 'ready'); setInterval(() => {}, 1000);`; + writeFileSync( + join(fake.directory, 'paths.json'), + JSON.stringify({ finished, readyFile }) + ); + writeFileSync( + join(fake.directory, 'descendant.mjs'), + `import { writeFileSync, existsSync } from 'node:fs'; +const [, , temporary, finished, readyFile] = process.argv; +process.on('SIGTERM', () => setTimeout(() => { + writeFileSync(finished, String(existsSync(temporary))); + process.exit(0); +}, 150)); +writeFileSync(readyFile, 'ready'); +setInterval(() => {}, 1000);` + ); writeFileSync( fake.path, `import { spawn } from 'node:child_process'; -import { existsSync } from 'node:fs'; -spawn(process.execPath, ['--input-type=module', '-e', ${JSON.stringify( - descendant - )}, process.argv[5]], { stdio: 'ignore' }); -const timer = setInterval(() => { if (existsSync(${JSON.stringify( - readyFile - )})) { clearInterval(timer); console.log('DESCENDANT_READY'); } }, 10); +import { readFileSync, existsSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +const { finished, readyFile } = JSON.parse(readFileSync(new URL('./paths.json', import.meta.url), 'utf8')); +spawn(process.execPath, [fileURLToPath(new URL('./descendant.mjs', import.meta.url)), process.argv[5], finished, readyFile], { stdio: 'ignore' }); +const timer = setInterval(() => { if (existsSync(readyFile)) { clearInterval(timer); console.log('DESCENDANT_READY'); } }, 10); setInterval(() => {}, 1000);` ); const starting = startRuntimeReview({ @@ -378,22 +398,30 @@ test('failed workers retain ownership until surviving descendants exit', async ( const fake = worker(t); const readyFile = join(fake.directory, 'ready'); const finished = join(fake.directory, 'finished'); - const descendant = `import { writeFileSync, existsSync } from 'node:fs'; -const finish = () => { writeFileSync(${JSON.stringify( - finished - )}, String(existsSync(process.argv[1]))); process.exit(0); }; + writeFileSync( + join(fake.directory, 'paths.json'), + JSON.stringify({ finished, readyFile }) + ); + writeFileSync( + join(fake.directory, 'descendant.mjs'), + `import { writeFileSync, existsSync } from 'node:fs'; +const [, , temporary, finished, readyFile] = process.argv; +const finish = () => { + writeFileSync(finished, String(existsSync(temporary))); + process.exit(0); +}; process.on('SIGTERM', () => setTimeout(finish, 50)); -writeFileSync(${JSON.stringify(readyFile)}, 'ready'); setTimeout(finish, 500);`; +writeFileSync(readyFile, 'ready'); +setTimeout(finish, 500);` + ); writeFileSync( fake.path, `import { spawn } from 'node:child_process'; -import { existsSync } from 'node:fs'; -spawn(process.execPath, ['--input-type=module', '-e', ${JSON.stringify( - descendant - )}, process.argv[5]], { stdio: 'ignore' }); -setInterval(() => { if (existsSync(${JSON.stringify( - readyFile - )})) process.exit(1); }, 10);` +import { readFileSync, existsSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +const { finished, readyFile } = JSON.parse(readFileSync(new URL('./paths.json', import.meta.url), 'utf8')); +spawn(process.execPath, [fileURLToPath(new URL('./descendant.mjs', import.meta.url)), process.argv[5], finished, readyFile], { stdio: 'ignore' }); +setInterval(() => { if (existsSync(readyFile)) process.exit(1); }, 10);` ); await assert.rejects( startRuntimeReview({ @@ -409,3 +437,94 @@ setInterval(() => { if (existsSync(${JSON.stringify( await new Promise((resolve) => setTimeout(resolve, 10)); assert.equal(readFileSync(finished, 'utf8'), 'true'); }); + +for (const deniedSignal of ['SIGTERM', 0, 'SIGKILL']) { + test(`cleanup retries transient EPERM at ${deniedSignal} until ESRCH`, async (t) => { + const { startRuntimeReview } = await import('./review-runtime.mjs'); + const root = fixtureRoot(t); + const fake = worker(t); + const groups = new Map(); + const review = await startRuntimeReview({ + root, + worker: fake.path, + log() {}, + verify: async () => undefined, + serve: async () => ({ + url: 'http://127.0.0.1:1234', + close: async () => undefined, + }), + killProcessGroup: (pid, signal) => { + assert.ok(pid < 0); + const group = groups.get(pid) ?? { denied: false, confirmed: false }; + groups.set(pid, group); + if (signal === deniedSignal && !group.denied) { + group.denied = true; + throw Object.assign(new Error('transient group denial'), { + code: 'EPERM', + }); + } + // Keep the group present until the selected signal/probe was denied. + if (signal === 0 && group.denied) { + group.confirmed = true; + throw Object.assign(new Error('group exited'), { code: 'ESRCH' }); + } + }, + }); + t.after(() => review.close()); + assert.equal(groups.size, 2); + assert.ok( + [...groups.values()].every((group) => group.denied && group.confirmed) + ); + await review.close(); + assert.equal(existsSync(review.temporary), false); + }); +} + +test('persistent group permission denial rejects and retains owned files', async (t) => { + const { startRuntimeReview } = await import('./review-runtime.mjs'); + const root = fixtureRoot(t); + const fake = worker(t, "console.log('OWNED_ROOT ' + temporary);"); + let temporary; + t.after(() => { + if (temporary) rmSync(temporary, { recursive: true, force: true }); + }); + const signals = []; + await assert.rejects( + startRuntimeReview({ + root, + worker: fake.path, + log() {}, + onWorkerOutput: (text) => { + if (text.startsWith('OWNED_ROOT ')) + temporary = text.trim().slice('OWNED_ROOT '.length); + }, + verify: async () => undefined, + serve: async () => ({ + url: 'http://127.0.0.1:1234', + close: async () => undefined, + }), + killProcessGroup: (_pid, signal) => { + signals.push(signal); + assert.ok(existsSync(temporary)); + throw Object.assign(new Error('persistent group denial'), { + code: 'EPERM', + }); + }, + }).then((review) => { + t.after(() => review.close()); + return review; + }), + (error) => { + assert.match( + error.message, + /process group did not exit; temporary files retained/ + ); + assert.equal(error.cause.code, 'EPERM'); + return true; + } + ); + assert.ok(signals.includes('SIGTERM')); + assert.ok(signals.includes('SIGKILL')); + assert.ok(signals.includes(0)); + assert.ok(existsSync(temporary)); +});