diff --git a/.agent/skill-runs.jsonl b/.agent/skill-runs.jsonl index c4d0017..3fa2a6d 100644 --- a/.agent/skill-runs.jsonl +++ b/.agent/skill-runs.jsonl @@ -51,3 +51,11 @@ {"skill":"/harden","ts":"2026-08-12T17:28:15Z","project":"braid-retained-v2-wt","target":"Braid retained Tangle lifecycle, public proof redaction, replay races, and cleanup n=65 files","operatorPrompt":"","durationMin":null,"verdict":"APPROVE","dispatchedTo":"/critical-audit","operatorOverride":null,"transcriptPath":null,"traceDir":null} {"skill":"/critical-audit","ts":"2026-08-12T17:28:16Z","project":"braid-retained-v2-wt","target":"Braid retained Tangle lifecycle, stress proof, and public evidence n=65 files","operatorPrompt":"","durationMin":null,"verdict":"APPROVE","dispatchedTo":"/verify","operatorOverride":null,"transcriptPath":null,"traceDir":null} {"skill":"/verify","ts":"2026-08-12T17:28:16Z","project":"braid-retained-v2-wt","target":"Braid retained Tangle source, 683 tests, packed terminal, cloud proof contracts, and CLI Bridge matrix","operatorPrompt":"","durationMin":null,"verdict":"PASS","dispatchedTo":"/ship","operatorOverride":null,"transcriptPath":null,"traceDir":null} +{"skill":"/reconcile","ts":"2026-08-13T19:15:27Z","project":"braid-goal-latest-wt","target":"DeepSeek Harness and Cordis","operatorPrompt":"","durationMin":null,"verdict":"ADAPT","dispatchedTo":"/build-with-agent-runtime","operatorOverride":null,"transcriptPath":null,"traceDir":null} +{"skill":"/reconcile","ts":"2026-08-13T20:16:04Z","project":"braid-goal-latest-wt","target":"Cordis as a foundation for Braid","operatorPrompt":"","durationMin":null,"verdict":"REJECT","dispatchedTo":"/build-with-agent-runtime","operatorOverride":null,"transcriptPath":null,"traceDir":null} +{"skill":"/autopsy","ts":"2026-08-13T20:39:54Z","project":"braid-goal-latest-wt","target":"Braid retained Tangle Sandbox follow-up after forced restart","operatorPrompt":"","durationMin":null,"verdict":"DESIGN_FLAW","dispatchedTo":"/build-with-agent-runtime","operatorOverride":null,"transcriptPath":null,"traceDir":null} +{"skill":"/reconcile","ts":"2026-08-13T21:00:22Z","project":"braid-goal-latest-wt","target":"https://github.com/cordiverse/cordis at 8cc9e33fab69e2d0476d126baaf2acb24e6a6ab4","operatorPrompt":"","durationMin":null,"verdict":"REJECT","dispatchedTo":"/build-with-agent-runtime","operatorOverride":null,"transcriptPath":null,"traceDir":null} +{"skill":"/critical-audit","ts":"2026-08-13T21:09:48Z","project":"braid-goal-latest-wt","target":"agent-runtime retained identity and existing-environment start n=11 files","operatorPrompt":"","durationMin":null,"verdict":"APPROVE","dispatchedTo":"/stop","operatorOverride":null,"transcriptPath":null,"traceDir":null} +{"skill":"/reconcile","ts":"2026-08-13T21:45:28Z","project":"braid-goal-latest-wt","target":"cordiverse/cordis as a Braid foundation","operatorPrompt":"","durationMin":null,"verdict":"REJECT","dispatchedTo":"/stop","operatorOverride":null,"transcriptPath":null,"traceDir":null} +{"skill":"/autopsy","ts":"2026-08-13T21:58:28Z","project":"braid-goal-latest-wt","target":"Sandbox isolated package smoke skipped prepack","operatorPrompt":"","durationMin":null,"verdict":"FIXED_INFRA_BUG","dispatchedTo":"/verify","operatorOverride":null,"transcriptPath":null,"traceDir":null} +{"skill":"/deep-clean","ts":"2026-08-15T03:11:57Z","project":"braid-goal-latest-wt","target":"Braid release branch: removed stale generated artifacts and speculative release machinery","operatorPrompt":"","durationMin":null,"verdict":"GREEN","dispatchedTo":"/converge","operatorOverride":null,"transcriptPath":null,"traceDir":null} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 490eb99..8e48483 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -231,18 +231,30 @@ jobs: INDEX_SHA256="$(sha256sum "$ENDORSEMENT/files.sha256" | cut -d ' ' -f 1)" grep -Fx "index_sha256=$INDEX_SHA256" "$ENDORSEMENT/statement.txt" - - name: Publish the approved tarball + - name: Install the pinned npm client + run: | + npm install --global npm@11.18.0 + test "$(npm --version)" = 11.18.0 + + - name: Check the registry version + id: registry env: ARCHIVE: ${{ needs.candidate.outputs.archive }} VERSION: ${{ needs.candidate.outputs.version }} run: | - npm install --global npm@11.18.0 - test "$(npm --version)" = 11.18.0 - if npm view "@tangle-network/braid@$VERSION" version >/dev/null 2>&1; then - echo "@tangle-network/braid@$VERSION already exists; checking it instead of republishing" - else - npm publish "$RUNNER_TEMP/braid-release/candidate/$ARCHIVE" --provenance --access public - fi + RESULT="$(node scripts/release/check-registry-collision.mjs \ + --package "@tangle-network/braid@$VERSION" \ + --tarball "$RUNNER_TEMP/braid-release/candidate/$ARCHIVE" \ + --json)" + printf '%s\n' "$RESULT" + STATUS="$(RESULT="$RESULT" node -e "process.stdout.write(JSON.parse(process.env.RESULT).status)")" + printf 'status=%s\n' "$STATUS" >> "$GITHUB_OUTPUT" + + - name: Publish the approved tarball + if: steps.registry.outputs.status == 'available' + env: + ARCHIVE: ${{ needs.candidate.outputs.archive }} + run: npm publish "$RUNNER_TEMP/braid-release/candidate/$ARCHIVE" --provenance --access public - name: Verify and retain npm provenance env: diff --git a/README.md b/README.md index 167234c..273153a 100644 --- a/README.md +++ b/README.md @@ -233,9 +233,7 @@ Retained lifecycle is an explicit connection configuration option with a bounded Before retained execution creates a sandbox, Braid requires exact control plus provider-backed lookup for an unacknowledged dispatch. -The current published provider does not report that complete contract or lookup, so Braid rejects retained mode without creating a resource. - -When the provider reports both, a fresh Braid process can recover before or after the six-field reference commits. +The current provider reports both, so a fresh Braid process can recover before or after the six-field reference commits. Native follow-up turns remain disabled until the provider also proves that its context boundary matches Braid's recorded boundary. @@ -243,19 +241,25 @@ Checkpoint, environment fork, and interaction response remain unavailable until The user can inspect the requested and verified execution location, but provider-private machine details remain unavailable when they are not reported. -The latest passing production stress proof completed 20 of 20 ephemeral Braid turns through OpenCode, GLM 5.2, and Tangle Sandbox at four-way concurrency. +On 2026-08-15, the default retained production cohort passed 3 of 3 Braid runs through OpenCode, GLM 5.2, and Tangle Sandbox at two-way concurrency. -All 20 remote environments were unique and confirmed deleted, while the account's active Sandbox count returned from four to four. +Each proof killed the first Braid process, reconnected once, continued in the same provider session, cancelled a later turn, replayed the same cancellation, and rejected changed input. + +All three exact sandboxes were confirmed deleted, and the account's active Sandbox count returned to its starting value. -See the [secret-free proof artifact](artifacts/verification/live/tangle-sandbox-braid-execution-stress-production-20260812.json) for every run, token receipt, latency, environment observation, and cleanup result. +Individual proof duration was 82.3 seconds minimum, 89.0 seconds median, and 96.2 seconds maximum. -A later canary found a current platform regression before environment allocation. +Six completed turns reported 9,231 input tokens, 1,992 output tokens, and $0 provider cost; token and cost data were unavailable for three cancelled turns. -Fresh credentials authenticated Sandbox and Router, but the internal model-key step rejected the Sandbox service with HTTP 403. +The provider exposed cgroup v2 memory samples with a 2,048 MB limit and 630–633 MB peak use. -Braid left zero owned environments, while [ADC issue 5277](https://github.com/tangle-network/agent-dev-container/issues/5277) tracks the platform failure. +Machine identity, IP address, effective CPU and storage, placement, region, and per-sandbox cost remained unavailable because the provider did not report them. + +The earlier ephemeral stress proof completed 20 of 20 Braid turns at four-way concurrency. + +All 20 remote environments were unique and confirmed deleted, while the account's active Sandbox count returned from four to four. -[Runtime issue 808](https://github.com/tangle-network/agent-runtime/issues/808) tracks the separate ten-minute retry of that permanent rejection. +See the [ephemeral proof artifact](artifacts/verification/live/tangle-sandbox-braid-execution-stress-production-20260812.json) for every earlier run, token receipt, latency, environment observation, and cleanup result. The [verification record](docs/08-verification.md#current-core-path-observations) keeps the full results, limits, and tracked platform work. diff --git a/artifacts/verification/tui-audit/after/w6/120x40-plain.txt b/artifacts/verification/tui-audit/after/w6/120x40-plain.txt new file mode 100644 index 0000000..c76b4a5 --- /dev/null +++ b/artifacts/verification/tui-audit/after/w6/120x40-plain.txt @@ -0,0 +1,17 @@ +Braid ready — /home/drew/code/braid-goal-latest-wt +status: ready for a message +conversation: New conversation; branch: branch-1 +profile: Braid starter; runner: pi; connection: deterministic fixture +appearance: none; high contrast off; reduced motion off +event 2: draft.changed; status: ready for a message +event 3: run.requested; status: admitted: W6 plain proof +event 4: effect.upserted; status: streaming +event 5: run.provider.event; status: streaming: {"type":"backend_start","task":{"id":"turn-a87a8819-9685-42df-8e1f-11690d0a4e83","intent":"W6 plain proof","metadata":{"identity":{"profileDigest":"sha256:8344a03c82d5cb9207910fe0cb05ef0757b58b8cea9a7cc82f36055694d58983","taskDigest":"sha256:f75eb08b6c6a3120e5297421baed1e5e2553899395ec5981e0fda4c9ce725300"}}},"session":{"id":"1c480acb-01f2-4964-86f6-fe0900712ee0","backend":"executor","status":"active","createdAt":"2026-08-15T00:50:34.906Z","updatedAt":"2026-08-15T00:50:34.906Z","metadata":"[unavailable]"}," +event 6: application.shutdown.requested; status: streaming +event 7: run.control.requested; status: cancelling: requested +event 8: run.cancel.requested; status: cancelling: requested +event 9: effect.upserted; status: cancelling +event 10: effect.upserted; status: cancelling +event 11: run.control.acknowledged; status: cancelling: CONTROL_ACKNOWLEDGED +event 12: run.finished; status: aborted +event 13: effect.upserted; status: cancelled diff --git a/artifacts/verification/tui-audit/after/w6/120x40.png b/artifacts/verification/tui-audit/after/w6/120x40.png new file mode 100644 index 0000000..280a85c Binary files /dev/null and b/artifacts/verification/tui-audit/after/w6/120x40.png differ diff --git a/artifacts/verification/tui-audit/after/w6/120x40.txt b/artifacts/verification/tui-audit/after/w6/120x40.txt new file mode 100644 index 0000000..023edcd --- /dev/null +++ b/artifacts/verification/tui-audit/after/w6/120x40.txt @@ -0,0 +1,40 @@ + › W6 visual proof + + · run result · Ctrl+E details + Fixture response through pi: W6 visual proof + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +› + + +profile Braid starter · pi / fixture/deterministic · deterministic fixture in 8 · out 12 · calls 1 · model 50ms diff --git a/artifacts/verification/tui-audit/after/w6/200x60-plain.txt b/artifacts/verification/tui-audit/after/w6/200x60-plain.txt new file mode 100644 index 0000000..c76b4a5 --- /dev/null +++ b/artifacts/verification/tui-audit/after/w6/200x60-plain.txt @@ -0,0 +1,17 @@ +Braid ready — /home/drew/code/braid-goal-latest-wt +status: ready for a message +conversation: New conversation; branch: branch-1 +profile: Braid starter; runner: pi; connection: deterministic fixture +appearance: none; high contrast off; reduced motion off +event 2: draft.changed; status: ready for a message +event 3: run.requested; status: admitted: W6 plain proof +event 4: effect.upserted; status: streaming +event 5: run.provider.event; status: streaming: {"type":"backend_start","task":{"id":"turn-a87a8819-9685-42df-8e1f-11690d0a4e83","intent":"W6 plain proof","metadata":{"identity":{"profileDigest":"sha256:8344a03c82d5cb9207910fe0cb05ef0757b58b8cea9a7cc82f36055694d58983","taskDigest":"sha256:f75eb08b6c6a3120e5297421baed1e5e2553899395ec5981e0fda4c9ce725300"}}},"session":{"id":"1c480acb-01f2-4964-86f6-fe0900712ee0","backend":"executor","status":"active","createdAt":"2026-08-15T00:50:34.906Z","updatedAt":"2026-08-15T00:50:34.906Z","metadata":"[unavailable]"}," +event 6: application.shutdown.requested; status: streaming +event 7: run.control.requested; status: cancelling: requested +event 8: run.cancel.requested; status: cancelling: requested +event 9: effect.upserted; status: cancelling +event 10: effect.upserted; status: cancelling +event 11: run.control.acknowledged; status: cancelling: CONTROL_ACKNOWLEDGED +event 12: run.finished; status: aborted +event 13: effect.upserted; status: cancelled diff --git a/artifacts/verification/tui-audit/after/w6/200x60.png b/artifacts/verification/tui-audit/after/w6/200x60.png new file mode 100644 index 0000000..6a52583 Binary files /dev/null and b/artifacts/verification/tui-audit/after/w6/200x60.png differ diff --git a/artifacts/verification/tui-audit/after/w6/200x60.txt b/artifacts/verification/tui-audit/after/w6/200x60.txt new file mode 100644 index 0000000..dde327d --- /dev/null +++ b/artifacts/verification/tui-audit/after/w6/200x60.txt @@ -0,0 +1,60 @@ + › W6 visual proof + + · run result · Ctrl+E details + Fixture response through pi: W6 visual proof + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +› + + +profile Braid starter · pi / fixture/deterministic · deterministic fixture in 8 · out 12 · calls 1 · model 50ms diff --git a/artifacts/verification/tui-audit/after/w6/40x12-plain.txt b/artifacts/verification/tui-audit/after/w6/40x12-plain.txt new file mode 100644 index 0000000..c76b4a5 --- /dev/null +++ b/artifacts/verification/tui-audit/after/w6/40x12-plain.txt @@ -0,0 +1,17 @@ +Braid ready — /home/drew/code/braid-goal-latest-wt +status: ready for a message +conversation: New conversation; branch: branch-1 +profile: Braid starter; runner: pi; connection: deterministic fixture +appearance: none; high contrast off; reduced motion off +event 2: draft.changed; status: ready for a message +event 3: run.requested; status: admitted: W6 plain proof +event 4: effect.upserted; status: streaming +event 5: run.provider.event; status: streaming: {"type":"backend_start","task":{"id":"turn-a87a8819-9685-42df-8e1f-11690d0a4e83","intent":"W6 plain proof","metadata":{"identity":{"profileDigest":"sha256:8344a03c82d5cb9207910fe0cb05ef0757b58b8cea9a7cc82f36055694d58983","taskDigest":"sha256:f75eb08b6c6a3120e5297421baed1e5e2553899395ec5981e0fda4c9ce725300"}}},"session":{"id":"1c480acb-01f2-4964-86f6-fe0900712ee0","backend":"executor","status":"active","createdAt":"2026-08-15T00:50:34.906Z","updatedAt":"2026-08-15T00:50:34.906Z","metadata":"[unavailable]"}," +event 6: application.shutdown.requested; status: streaming +event 7: run.control.requested; status: cancelling: requested +event 8: run.cancel.requested; status: cancelling: requested +event 9: effect.upserted; status: cancelling +event 10: effect.upserted; status: cancelling +event 11: run.control.acknowledged; status: cancelling: CONTROL_ACKNOWLEDGED +event 12: run.finished; status: aborted +event 13: effect.upserted; status: cancelled diff --git a/artifacts/verification/tui-audit/after/w6/40x12.png b/artifacts/verification/tui-audit/after/w6/40x12.png new file mode 100644 index 0000000..7656495 Binary files /dev/null and b/artifacts/verification/tui-audit/after/w6/40x12.png differ diff --git a/artifacts/verification/tui-audit/after/w6/40x12.txt b/artifacts/verification/tui-audit/after/w6/40x12.txt new file mode 100644 index 0000000..d85051b --- /dev/null +++ b/artifacts/verification/tui-audit/after/w6/40x12.txt @@ -0,0 +1,12 @@ + › W6 visual proof + + · run result · Ctrl+E details + Fixture response through pi: W6 visual + proof + + + +› + + +Braid starter Ctrl+E detail diff --git a/artifacts/verification/tui-audit/after/w6/80x24-automation.gif b/artifacts/verification/tui-audit/after/w6/80x24-automation.gif new file mode 100644 index 0000000..ce95519 Binary files /dev/null and b/artifacts/verification/tui-audit/after/w6/80x24-automation.gif differ diff --git a/artifacts/verification/tui-audit/after/w6/80x24-flow.gif b/artifacts/verification/tui-audit/after/w6/80x24-flow.gif new file mode 100644 index 0000000..80c74d6 Binary files /dev/null and b/artifacts/verification/tui-audit/after/w6/80x24-flow.gif differ diff --git a/artifacts/verification/tui-audit/after/w6/80x24-plain.txt b/artifacts/verification/tui-audit/after/w6/80x24-plain.txt new file mode 100644 index 0000000..c76b4a5 --- /dev/null +++ b/artifacts/verification/tui-audit/after/w6/80x24-plain.txt @@ -0,0 +1,17 @@ +Braid ready — /home/drew/code/braid-goal-latest-wt +status: ready for a message +conversation: New conversation; branch: branch-1 +profile: Braid starter; runner: pi; connection: deterministic fixture +appearance: none; high contrast off; reduced motion off +event 2: draft.changed; status: ready for a message +event 3: run.requested; status: admitted: W6 plain proof +event 4: effect.upserted; status: streaming +event 5: run.provider.event; status: streaming: {"type":"backend_start","task":{"id":"turn-a87a8819-9685-42df-8e1f-11690d0a4e83","intent":"W6 plain proof","metadata":{"identity":{"profileDigest":"sha256:8344a03c82d5cb9207910fe0cb05ef0757b58b8cea9a7cc82f36055694d58983","taskDigest":"sha256:f75eb08b6c6a3120e5297421baed1e5e2553899395ec5981e0fda4c9ce725300"}}},"session":{"id":"1c480acb-01f2-4964-86f6-fe0900712ee0","backend":"executor","status":"active","createdAt":"2026-08-15T00:50:34.906Z","updatedAt":"2026-08-15T00:50:34.906Z","metadata":"[unavailable]"}," +event 6: application.shutdown.requested; status: streaming +event 7: run.control.requested; status: cancelling: requested +event 8: run.cancel.requested; status: cancelling: requested +event 9: effect.upserted; status: cancelling +event 10: effect.upserted; status: cancelling +event 11: run.control.acknowledged; status: cancelling: CONTROL_ACKNOWLEDGED +event 12: run.finished; status: aborted +event 13: effect.upserted; status: cancelled diff --git a/artifacts/verification/tui-audit/after/w6/80x24-transcript-keyboard.gif b/artifacts/verification/tui-audit/after/w6/80x24-transcript-keyboard.gif new file mode 100644 index 0000000..8a2da53 Binary files /dev/null and b/artifacts/verification/tui-audit/after/w6/80x24-transcript-keyboard.gif differ diff --git a/artifacts/verification/tui-audit/after/w6/80x24.png b/artifacts/verification/tui-audit/after/w6/80x24.png new file mode 100644 index 0000000..365ad22 Binary files /dev/null and b/artifacts/verification/tui-audit/after/w6/80x24.png differ diff --git a/artifacts/verification/tui-audit/after/w6/80x24.txt b/artifacts/verification/tui-audit/after/w6/80x24.txt new file mode 100644 index 0000000..301578c --- /dev/null +++ b/artifacts/verification/tui-audit/after/w6/80x24.txt @@ -0,0 +1,24 @@ + › W6 visual proof + + · run result · Ctrl+E details + Fixture response through pi: W6 visual proof + + + + + + + + + + + + + + + + +› + + +profile Braid starter · pi / deterministic · fixture Ctrl+E detail diff --git a/artifacts/verification/tui-audit/after/w6/braid.gif b/artifacts/verification/tui-audit/after/w6/braid.gif new file mode 100644 index 0000000..1317a35 Binary files /dev/null and b/artifacts/verification/tui-audit/after/w6/braid.gif differ diff --git a/artifacts/verification/tui-audit/after/w6/braid.png b/artifacts/verification/tui-audit/after/w6/braid.png new file mode 100644 index 0000000..7a1c669 Binary files /dev/null and b/artifacts/verification/tui-audit/after/w6/braid.png differ diff --git a/artifacts/verification/tui-audit/after/w6/braid.txt b/artifacts/verification/tui-audit/after/w6/braid.txt new file mode 100644 index 0000000..53ca971 --- /dev/null +++ b/artifacts/verification/tui-audit/after/w6/braid.txt @@ -0,0 +1,18 @@ + › Which coding agent will handle my next task, and where will it run? + + · run result · Ctrl+E details + Pi will run the task through Local CLI Bridge with gpt-5.6-luna. The Release engineer AgentProfile supplies its + instructions and permissions. Braid keeps the transcript, approvals, branches, and trace analysis. + + + + + + + + + +› + + +profile Release engineer · pi / openai-codex/gpt-5.6-luna · Local CLI Bridge · high in 1.3k · out 96 · ~$0.0026 diff --git a/artifacts/verification/tui-audit/after/w6/capture-manifest.json b/artifacts/verification/tui-audit/after/w6/capture-manifest.json new file mode 100644 index 0000000..d420b83 --- /dev/null +++ b/artifacts/verification/tui-audit/after/w6/capture-manifest.json @@ -0,0 +1,773 @@ +{ + "schemaVersion": 3, + "generatedAt": "2026-08-15T00:51:25.282Z", + "command": "pnpm capture:visual", + "binary": "clean npm install from generated tarball", + "binarySha256": "141288e0fe917635d723b4b70d464dc49baff14cc356f54de1d3f8faa5d8254f", + "tarball": "tangle-network-braid-0.1.1.tgz", + "tarballSha256": "b2c0571a7f7d0d6e847d493667dfed6ff11b52eefb5e5f43cb13470eea3562c7", + "fixture": "deterministic", + "terminal": "node-pty/xterm-256color", + "node": "v24.13.0", + "provenance": { + "renderer": { + "package": "@earendil-works/pi-tui@0.84.2", + "pty": "node-pty@1.1.0", + "emulator": "@xterm/headless@6.0.0", + "node": "v24.13.0", + "terminal": "xterm-256color" + }, + "raster": { + "agg": "agg 1.9.0", + "imagemagick": "Version: ImageMagick 6.9.12-98 Q16 x86_64 18038 https://legacy.imagemagick.org", + "fontFamily": "DejaVu Sans Mono", + "font": "DejaVu Sans Mono | Book | /usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", + "colorMode": "sRGB 8-bit" + } + }, + "keyboardFlow": { + "steps": [ + "8 completed turns", + "Page Up", + "Alt+Home", + "Page Down", + "Alt+End" + ], + "artifacts": [ + "raw/transcript-keyboard.cast", + "80x24-transcript-keyboard.gif" + ] + }, + "productDemo": { + "steps": [ + "Inspect AgentProfile", + "Run through Local CLI Bridge", + "Inspect live work and the retained run", + "Analyze the frozen trace with /ask", + "Review the analysis list and details together" + ], + "artifacts": [ + "raw/braid-demo.cast", + "braid.txt", + "braid.png", + "braid.gif" + ] + }, + "states": [ + { + "name": "empty", + "columns": 80, + "rows": 24, + "artifacts": { + "semantic-state": "states/empty.json", + "plain-frame": "states/empty.txt", + "asciicast": "raw/empty.cast", + "ansi": "states/empty.ansi", + "png": "states/empty.png" + } + }, + { + "name": "active-streaming", + "columns": 80, + "rows": 24, + "artifacts": { + "semantic-state": "states/active-streaming.json", + "plain-frame": "states/active-streaming.txt", + "asciicast": "raw/active-streaming.cast", + "ansi": "states/active-streaming.ansi", + "png": "states/active-streaming.png" + } + }, + { + "name": "interaction", + "columns": 80, + "rows": 24, + "artifacts": { + "semantic-state": "states/interaction.json", + "plain-frame": "states/interaction.txt", + "asciicast": "raw/interaction.cast", + "ansi": "states/interaction.ansi", + "png": "states/interaction.png" + } + }, + { + "name": "automation", + "columns": 80, + "rows": 24, + "artifacts": { + "semantic-state": "states/automation.json", + "plain-frame": "states/automation.txt", + "asciicast": "raw/automation.cast", + "ansi": "states/automation.ansi", + "png": "states/automation.png" + } + }, + { + "name": "fork-preview", + "columns": 80, + "rows": 24, + "artifacts": { + "semantic-state": "states/fork-preview.json", + "plain-frame": "states/fork-preview.txt", + "asciicast": "raw/fork-preview.cast", + "ansi": "states/fork-preview.ansi", + "png": "states/fork-preview.png" + } + }, + { + "name": "graph-or-analysis", + "columns": 80, + "rows": 24, + "artifacts": { + "semantic-state": "states/graph-or-analysis.json", + "plain-frame": "states/graph-or-analysis.txt", + "asciicast": "raw/graph-or-analysis.cast", + "ansi": "states/graph-or-analysis.ansi", + "png": "states/graph-or-analysis.png" + } + }, + { + "name": "analysis", + "columns": 80, + "rows": 24, + "artifacts": { + "semantic-state": "states/analysis.json", + "plain-frame": "states/analysis.txt", + "asciicast": "raw/analysis.cast", + "ansi": "states/analysis.ansi", + "png": "states/analysis.png" + } + }, + { + "name": "comparison", + "columns": 80, + "rows": 24, + "artifacts": { + "semantic-state": "states/comparison.json", + "plain-frame": "states/comparison.txt", + "asciicast": "raw/comparison.cast", + "ansi": "states/comparison.ansi", + "png": "states/comparison.png" + } + }, + { + "name": "profile", + "columns": 80, + "rows": 24, + "artifacts": { + "semantic-state": "states/profile.json", + "plain-frame": "states/profile.txt", + "asciicast": "raw/profile.cast", + "ansi": "states/profile.ansi", + "png": "states/profile.png" + } + }, + { + "name": "narrow", + "columns": 40, + "rows": 12, + "artifacts": { + "semantic-state": "states/narrow.json", + "plain-frame": "states/narrow.txt", + "asciicast": "raw/narrow.cast", + "ansi": "states/narrow.ansi", + "png": "states/narrow.png" + } + }, + { + "name": "failure-or-reconnect", + "columns": 80, + "rows": 24, + "artifacts": { + "semantic-state": "states/failure-or-reconnect.json", + "plain-frame": "states/failure-or-reconnect.txt", + "asciicast": "raw/failure-or-reconnect.cast", + "ansi": "states/failure-or-reconnect.ansi", + "png": "states/failure-or-reconnect.png" + } + } + ], + "artifacts": [ + { + "path": "40x12.txt", + "sha256": "06bd2cb4454d314c0fba112343013f35ecb532d7eae36eaf0f81bc5919c7bfa7", + "kind": "terminal-frame", + "columns": 40, + "rows": 12 + }, + { + "path": "40x12-plain.txt", + "sha256": "7b767eb14ac89796a85c48354ff68b147503de4fea65b72b1995533f68b5908c", + "kind": "plain-frame", + "columns": 40, + "rows": 12 + }, + { + "path": "40x12.png", + "sha256": "62e5e41fc40c727b0d5a9d53a0349a8eb158e96b580c2ecd813342177efdde50", + "kind": "png", + "columns": 40, + "rows": 12 + }, + { + "path": "80x24.txt", + "sha256": "118dcec20393df29fac18ec71a9fb3a5ed993bf56ffed3614439e6a15257f858", + "kind": "terminal-frame", + "columns": 80, + "rows": 24 + }, + { + "path": "80x24-plain.txt", + "sha256": "7b767eb14ac89796a85c48354ff68b147503de4fea65b72b1995533f68b5908c", + "kind": "plain-frame", + "columns": 80, + "rows": 24 + }, + { + "path": "80x24.png", + "sha256": "8b7a17df7052450140f1212d9e90dc3152c6df6e7d0674484bed5ca993596076", + "kind": "png", + "columns": 80, + "rows": 24 + }, + { + "path": "120x40.txt", + "sha256": "1a52a383a1fd94baae24f061f064c12dc2db5c1f557ba16deba89eb25832815a", + "kind": "terminal-frame", + "columns": 120, + "rows": 40 + }, + { + "path": "120x40-plain.txt", + "sha256": "7b767eb14ac89796a85c48354ff68b147503de4fea65b72b1995533f68b5908c", + "kind": "plain-frame", + "columns": 120, + "rows": 40 + }, + { + "path": "120x40.png", + "sha256": "3e1112bc41de33ac891b022700159598a3e0bc684a5cad92e327ff62f455f9b2", + "kind": "png", + "columns": 120, + "rows": 40 + }, + { + "path": "200x60.txt", + "sha256": "4bf1a1d0fe5a261a6aa85c80c6f57874e2a11c6946d799dcc12dfef5616a2bae", + "kind": "terminal-frame", + "columns": 200, + "rows": 60 + }, + { + "path": "200x60-plain.txt", + "sha256": "7b767eb14ac89796a85c48354ff68b147503de4fea65b72b1995533f68b5908c", + "kind": "plain-frame", + "columns": 200, + "rows": 60 + }, + { + "path": "200x60.png", + "sha256": "a1cad25525733980535091918a449bba54089ab0c1106b6569f7dcb75b5055f8", + "kind": "png", + "columns": 200, + "rows": 60 + }, + { + "path": "states/empty.json", + "sha256": "e55e826230d84c94e01a38c8d0b61538bc499349d99ef866385fed1fd3cd7bf8", + "kind": "semantic-state", + "state": "empty", + "columns": 80, + "rows": 24 + }, + { + "path": "states/empty.txt", + "sha256": "99e83c9353c45bd1d61b83b98e9b3c2efdf395bd0ba15dd0150c8a5945d45985", + "kind": "plain-frame", + "state": "empty", + "columns": 80, + "rows": 24 + }, + { + "path": "raw/empty.cast", + "sha256": "75d936d53c622b51f52346a0fba2a0f961401fe7d726af92340206f466f51f7d", + "kind": "asciicast", + "state": "empty", + "columns": 80, + "rows": 24 + }, + { + "path": "states/empty.ansi", + "sha256": "77a65566fa4094ce1a49b38dafe142711d0e69f1e67d98ea27bd1adde776a263", + "kind": "ansi", + "state": "empty", + "columns": 80, + "rows": 24 + }, + { + "path": "states/empty.png", + "sha256": "1ddd7ccf3f10d7ee00feb0829b14a43934d2a7187f75f8f398df309c18fb2a8d", + "kind": "png", + "state": "empty", + "columns": 80, + "rows": 24 + }, + { + "path": "states/active-streaming.json", + "sha256": "8698fc44bf1eb9277ab5f5796b4c2a118386137e72d826bf8cbdf4943f45fdfe", + "kind": "semantic-state", + "state": "active-streaming", + "columns": 80, + "rows": 24 + }, + { + "path": "states/active-streaming.txt", + "sha256": "fca88cebc11ef71b95b55deeabc3ffa29551f4bb23a9756eb4739ee3b3618de0", + "kind": "plain-frame", + "state": "active-streaming", + "columns": 80, + "rows": 24 + }, + { + "path": "raw/active-streaming.cast", + "sha256": "5b524cb16b39b38a3dc5cde12a2b28699851e39c4ac18fb5f710ed495b41e411", + "kind": "asciicast", + "state": "active-streaming", + "columns": 80, + "rows": 24 + }, + { + "path": "states/active-streaming.ansi", + "sha256": "e442be8669041fdb3bbde0e972f3ad0c950d9c4568e41d31f4a163b0d233c81f", + "kind": "ansi", + "state": "active-streaming", + "columns": 80, + "rows": 24 + }, + { + "path": "states/active-streaming.png", + "sha256": "e300e88a10bba77fa532692178606434e01374d2be8d805825d5891f812b32d1", + "kind": "png", + "state": "active-streaming", + "columns": 80, + "rows": 24 + }, + { + "path": "states/interaction.json", + "sha256": "b6e192fca7e42aef2d9aa035e67d8e84bdafd7ec04a648bd910e91fece2c9f7f", + "kind": "semantic-state", + "state": "interaction", + "columns": 80, + "rows": 24 + }, + { + "path": "states/interaction.txt", + "sha256": "dbb45344e72b9fa99b4edd977ebde0d919c4234a31dfe33afcabc342ae4c3115", + "kind": "plain-frame", + "state": "interaction", + "columns": 80, + "rows": 24 + }, + { + "path": "raw/interaction.cast", + "sha256": "ba58a2f350abb2ead48f540d734aceb0defb7a0627b9e93e5d0f70244af1ab95", + "kind": "asciicast", + "state": "interaction", + "columns": 80, + "rows": 24 + }, + { + "path": "states/interaction.ansi", + "sha256": "cdae73e341d768c4429ced9617e2c859055efd3e13aeb02806b668e1dfa68528", + "kind": "ansi", + "state": "interaction", + "columns": 80, + "rows": 24 + }, + { + "path": "states/interaction.png", + "sha256": "24e6dce818035070d742fdacae04612b74f378218f01a8e5f87c4fa2c5a3f4cc", + "kind": "png", + "state": "interaction", + "columns": 80, + "rows": 24 + }, + { + "path": "states/automation.json", + "sha256": "0391e893d323ba9b60319d5fc9337158df5ddc6bcaa3d34d78afa4fef297e631", + "kind": "semantic-state", + "state": "automation", + "columns": 80, + "rows": 24 + }, + { + "path": "states/automation.txt", + "sha256": "82d0afcc23d1988502a1b580d44db01a8dd0c9c5f9d16dcca113d31148470f10", + "kind": "plain-frame", + "state": "automation", + "columns": 80, + "rows": 24 + }, + { + "path": "raw/automation.cast", + "sha256": "1c10aaa5457a69c0741ce81f0665bd514410ab54e7c2728f2e82e82e391153d5", + "kind": "asciicast", + "state": "automation", + "columns": 80, + "rows": 24 + }, + { + "path": "states/automation.ansi", + "sha256": "53bbdea4f084e8261896aae2996551a915597af8e330028cce7dcf8c50eb3b35", + "kind": "ansi", + "state": "automation", + "columns": 80, + "rows": 24 + }, + { + "path": "states/automation.png", + "sha256": "5ad769720138889fd44d6b23047c15eab6c0895ae4838ceea9f7871068079192", + "kind": "png", + "state": "automation", + "columns": 80, + "rows": 24 + }, + { + "path": "states/fork-preview.json", + "sha256": "c364c26185ac923631d6e8907921e37de39434b1832a430bab41e8eb4d82063c", + "kind": "semantic-state", + "state": "fork-preview", + "columns": 80, + "rows": 24 + }, + { + "path": "states/fork-preview.txt", + "sha256": "50255443ac4b8706749cdbcc7e37a082aefaaad32c8ece52be7abe6aad1edcb6", + "kind": "plain-frame", + "state": "fork-preview", + "columns": 80, + "rows": 24 + }, + { + "path": "raw/fork-preview.cast", + "sha256": "e231d6372d1d368c7e09b21e4499361f39465db57175ca578a15aae79df217f5", + "kind": "asciicast", + "state": "fork-preview", + "columns": 80, + "rows": 24 + }, + { + "path": "states/fork-preview.ansi", + "sha256": "30fff264d8bba3a0ade3280a66384d0dbaac1e7a7f8197e9dd35677375ac4e4c", + "kind": "ansi", + "state": "fork-preview", + "columns": 80, + "rows": 24 + }, + { + "path": "states/fork-preview.png", + "sha256": "10a68ad2004b268161a05c0c3fd732e3a966cf28a1cfb9509cfa830c6e09fe16", + "kind": "png", + "state": "fork-preview", + "columns": 80, + "rows": 24 + }, + { + "path": "states/graph-or-analysis.json", + "sha256": "dce88e32ae01fcb7010f96d06d94c3a0d887f7d98e3b5bf4e6a40a57761aee0e", + "kind": "semantic-state", + "state": "graph-or-analysis", + "columns": 80, + "rows": 24 + }, + { + "path": "states/graph-or-analysis.txt", + "sha256": "4c19f7d9633c09d5c4c15e4f9b93cc700066248f2e67e4af7d555d5ac2abb2e4", + "kind": "plain-frame", + "state": "graph-or-analysis", + "columns": 80, + "rows": 24 + }, + { + "path": "raw/graph-or-analysis.cast", + "sha256": "fe072df13e38ebbab92739321f24f1d82598342b81bb43bc0239f047a4368a0e", + "kind": "asciicast", + "state": "graph-or-analysis", + "columns": 80, + "rows": 24 + }, + { + "path": "states/graph-or-analysis.ansi", + "sha256": "bf9e9992c4917e72119883e01b54ad57481dff1050eaa60c1f7a93e1f5d441b1", + "kind": "ansi", + "state": "graph-or-analysis", + "columns": 80, + "rows": 24 + }, + { + "path": "states/graph-or-analysis.png", + "sha256": "cb03f3433850ca6eb065ff3326b36962badfed7ab718e9191f91c6a69059306b", + "kind": "png", + "state": "graph-or-analysis", + "columns": 80, + "rows": 24 + }, + { + "path": "states/analysis.json", + "sha256": "89e0427ff92a0c57e19dd91fc34a73bbf4f89a081390c33db639b1fc60fc6c25", + "kind": "semantic-state", + "state": "analysis", + "columns": 80, + "rows": 24 + }, + { + "path": "states/analysis.txt", + "sha256": "431bfb5bcf557948b9442b996b145a133dee824342b14cebc1a81882b75ef2fb", + "kind": "plain-frame", + "state": "analysis", + "columns": 80, + "rows": 24 + }, + { + "path": "raw/analysis.cast", + "sha256": "33ef1a1d4fb6cecf0b95d422b9e041f63c98d99213d4c9c98e972fd73cadb86a", + "kind": "asciicast", + "state": "analysis", + "columns": 80, + "rows": 24 + }, + { + "path": "states/analysis.ansi", + "sha256": "21358442db050aa55c6f02173e47641af3472650b0978c18b8d17559c30a5b3d", + "kind": "ansi", + "state": "analysis", + "columns": 80, + "rows": 24 + }, + { + "path": "states/analysis.png", + "sha256": "2b0cdbba925ed2917940216621e0a061e7fcd906f116b94d0f8d23b8cd41b4e7", + "kind": "png", + "state": "analysis", + "columns": 80, + "rows": 24 + }, + { + "path": "states/comparison.json", + "sha256": "69e41c10d4b45ce42d4540329e21c049eb5b5942bbe1b3668b20f116b210625b", + "kind": "semantic-state", + "state": "comparison", + "columns": 80, + "rows": 24 + }, + { + "path": "states/comparison.txt", + "sha256": "413590db409796eb76c1b74ba2118af194f0c699ec2fe0b139987a4b191830ce", + "kind": "plain-frame", + "state": "comparison", + "columns": 80, + "rows": 24 + }, + { + "path": "raw/comparison.cast", + "sha256": "ae0e2653d369c6ffaa8f85fc0cb7349781a489119049ade5e3a245864ad3ea67", + "kind": "asciicast", + "state": "comparison", + "columns": 80, + "rows": 24 + }, + { + "path": "states/comparison.ansi", + "sha256": "612da7ff27a7cdcbe0a5595072849effeede31137a4a109ce251c815f59373f0", + "kind": "ansi", + "state": "comparison", + "columns": 80, + "rows": 24 + }, + { + "path": "states/comparison.png", + "sha256": "622b32da31079c7c63790eb11d3a363a95469229f85e041110091e8082f6342d", + "kind": "png", + "state": "comparison", + "columns": 80, + "rows": 24 + }, + { + "path": "states/profile.json", + "sha256": "f097c720f8f5eccad8d81bf953fd60685927fd4808bd5e2a679c0ab27c857208", + "kind": "semantic-state", + "state": "profile", + "columns": 80, + "rows": 24 + }, + { + "path": "states/profile.txt", + "sha256": "3dd991ff44359a37a3f8aa09cdf15c4c60602d9a007bdce998ee368ec7100eb9", + "kind": "plain-frame", + "state": "profile", + "columns": 80, + "rows": 24 + }, + { + "path": "raw/profile.cast", + "sha256": "6c6386995fafd1725085af4f61401340c7a88083a79631f25808c5a02e03d16f", + "kind": "asciicast", + "state": "profile", + "columns": 80, + "rows": 24 + }, + { + "path": "states/profile.ansi", + "sha256": "c21993ee593f2a884ad0deaa8243b8905e876bbda6df9c8eeccc6a623b3854b5", + "kind": "ansi", + "state": "profile", + "columns": 80, + "rows": 24 + }, + { + "path": "states/profile.png", + "sha256": "b58e02f593cd031bc5c03b5713bc1886c4fd051171f3472377e1b2f90a5c930f", + "kind": "png", + "state": "profile", + "columns": 80, + "rows": 24 + }, + { + "path": "states/narrow.json", + "sha256": "f484047d9cffd238df85fef676d140971a4a32eb93bd5f93804ac87a21a4ab6f", + "kind": "semantic-state", + "state": "narrow", + "columns": 40, + "rows": 12 + }, + { + "path": "states/narrow.txt", + "sha256": "e01838efb7f91c10849b39be7e9b5283549d7fe5b058bf7a251effdfabfa2117", + "kind": "plain-frame", + "state": "narrow", + "columns": 40, + "rows": 12 + }, + { + "path": "raw/narrow.cast", + "sha256": "199eebe46d21cfeeb58374bd0de0745beae44e1ace1450910662021c415c1990", + "kind": "asciicast", + "state": "narrow", + "columns": 40, + "rows": 12 + }, + { + "path": "states/narrow.ansi", + "sha256": "90d01e9d2ecbc3baeb74b08f0115ba071154de4c2e3fa4be7bea0616b7b3de48", + "kind": "ansi", + "state": "narrow", + "columns": 40, + "rows": 12 + }, + { + "path": "states/narrow.png", + "sha256": "56f8d6915b21ea939bbc200e7273c83decc707489b11fa199bf8827a13176d12", + "kind": "png", + "state": "narrow", + "columns": 40, + "rows": 12 + }, + { + "path": "states/failure-or-reconnect.json", + "sha256": "f0a60cae5a1eb5bfb0963799c9d2e3b30e1e2e1ace05d8219b865bc3647f3607", + "kind": "semantic-state", + "state": "failure-or-reconnect", + "columns": 80, + "rows": 24 + }, + { + "path": "states/failure-or-reconnect.txt", + "sha256": "649a5db60a906d5ed28b556dbc6231c36d11a3ae8e89fa473cdce4c392c4e447", + "kind": "plain-frame", + "state": "failure-or-reconnect", + "columns": 80, + "rows": 24 + }, + { + "path": "raw/failure-or-reconnect.cast", + "sha256": "6275b412986fb227b52c25243b0b9c094fe5ef65534a557fab18d46aaf303a72", + "kind": "asciicast", + "state": "failure-or-reconnect", + "columns": 80, + "rows": 24 + }, + { + "path": "states/failure-or-reconnect.ansi", + "sha256": "75870b54b29b9941e2886b83c4ee6f631a8e1fb3335e83a4e7c2681ce0875a47", + "kind": "ansi", + "state": "failure-or-reconnect", + "columns": 80, + "rows": 24 + }, + { + "path": "states/failure-or-reconnect.png", + "sha256": "55885eae1a6e6e5b0ac5f9f303cc7e701cbd4613170f1a81273a0c3b44fde923", + "kind": "png", + "state": "failure-or-reconnect", + "columns": 80, + "rows": 24 + }, + { + "path": "80x24-flow.gif", + "sha256": "339c96ea488d76bdddab85f6cd3e38e9be40ae2efd3101da8917630c611ba00f", + "kind": "flow", + "columns": 80, + "rows": 24 + }, + { + "path": "80x24-automation.gif", + "sha256": "65aead992d07cb10118a2ab94a2b9cf52d7c83fe7ae66de2a578bc8944c017fe", + "kind": "automation-flow", + "state": "automation", + "columns": 80, + "rows": 24 + }, + { + "path": "raw/braid-demo.cast", + "sha256": "97bd7fe09d06eec34ab1a62fb3f6f86b06ae85117c3ebe1f85c18c08722774fc", + "kind": "product-asciicast", + "columns": 120, + "rows": 18 + }, + { + "path": "braid.txt", + "sha256": "9da6cead048673c48402c4eb40dc9696d39c7bdcb1a2103b755098209f00f53e", + "kind": "product-frame", + "columns": 120, + "rows": 18 + }, + { + "path": "braid.png", + "sha256": "c6914c50b07e34d7586bdd3fbda7d76454a8fd9ecceb0ef91182fa0757c65fa4", + "kind": "product-png", + "columns": 120, + "rows": 18 + }, + { + "path": "braid.gif", + "sha256": "0f3f75a9cc9e29f3b635ebb9c3cda2a0986c766510992b423394d5646ee91a36", + "kind": "product-flow", + "columns": 120, + "rows": 18 + }, + { + "path": "raw/transcript-keyboard.cast", + "sha256": "b6911f50bbab035c219f0f17f5718c260dc91ccd004d9b4f0518a991fdb06034", + "kind": "keyboard-asciicast", + "columns": 80, + "rows": 24 + }, + { + "path": "80x24-transcript-keyboard.gif", + "sha256": "6665c98af8341e5e67d27412e0bc8e996e156d3e3feadd90637409faaec07cc6", + "kind": "keyboard-flow", + "columns": 80, + "rows": 24 + } + ] +} diff --git a/artifacts/verification/tui-audit/after/w6/raw/braid-demo.cast b/artifacts/verification/tui-audit/after/w6/raw/braid-demo.cast new file mode 100644 index 0000000..9f2e965 --- /dev/null +++ b/artifacts/verification/tui-audit/after/w6/raw/braid-demo.cast @@ -0,0 +1,357 @@ +{"version":2,"width":120,"height":18,"timestamp":1786755080,"duration":11.610091,"idle_time_limit":1,"command":"packed braid --fixture deterministic","title":"Braid product demo","env":{"TERM":"xterm-256color"},"stdin":true} +[0.01,"o","\u001b[?2026h\u001b[2J\u001b[H\u001b[3J \u001b[0m\u001b]8;;\u0007\r\r\n \u001b[38;5;145mAsk Release engineer anything.\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n \u001b[0m\u001b]8;;\u0007\r\r\n \u001b[0m\u001b]8;;\u0007 Active profile · Release engineer \u001b[0m\u001b]8;;\u0007 \u001b[0m\u001b]8;;\u0007\r\r\n \u001b[0m\u001b]8;;\u0007 \u001b[38;5;145msource Release engineer · trusted · read-only\u001b[39m \u001b[0m\u001b]8;;\u0007 \u001b[0m\u001b]8;;\u0007\r\r\n \u001b[0m\u001b]8;;\u0007 \u001b[38;5;145mrunner pi · model openai-codex/gpt-5.6-luna\u001b[39m \u001b[0m\u001b]8;;\u0007 \u001b[0m\u001b]8;;\u0007\r\r\n \u001b[0m\u001b]8;;\u0007 \u001b[38;5;145mthinking high · max output provider default\u001b[39m \u001b[0m\u001b]8;;\u0007 \u001b[0m\u001b]8;;\u0007\r\r\n \u001b[0m\u001b]8;;\u0007 \u001b[38;5;145mdigest sha256:0419cd01…31378043\u001b[39m \u001b[0m\u001b]8;;\u0007 \u001b[0m\u001b]8;;\u0007\r\r\n \u001b[0m\u001b]8;;\u0007 \u001b[1m\u001b[38;5;153mprofiles\u001b[39m\u001b[22m \u001b[0m\u001b]8;;\u0007 \u001b[0m\u001b]8;;\u0007\r\r\n \u001b[0m\u001b]8;;\u0007\u001b[38;5;145m─────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[0m\u001b]8;;\u0007 \u001b[0m\u001b]8;;\u0007\r\r\n \u001b[0m\u001b]8;;\u0007> \u001b[7m \u001b[27m \u001b[0m\u001b]8;;\u0007 \u001b[0m\u001b]8;;\u0007\r\r\n \u001b[0m\u001b]8;;\u0007\u001b[1m\u001b[38;5;111m→ ✓ Release engineer trusted · read-only · Release engineer · pi · openai-codex/gpt-5.6-lu\u001b[0m\u001b[39m\u001b[22m \u001b[0m\u001b]8;;\u0007 \u001b[0m\u001b]8;;\u0007\r\r\n \u001b[0m\u001b]8;;\u0007\u001b[38;5;145m─────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[0m\u001b]8;;\u0007 \u001b[0m\u001b]8;;\u0007\r\r\n \u001b[0m\u001b]8;;\u0007 \u001b[38;5;145menter select · ^V validate · esc close\u001b[39m \u001b[0m\u001b]8;;\u0007 \u001b[0m\u001b]8;;\u0007\r\r\n \u001b[0m\u001b]8;;\u0007\r\r\n \u001b[0m\u001b]8;;\u0007\r\r\n \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[38;5;195mprofile Release engineer\u001b[39m · \u001b[38;5;195mpi / openai-codex/gpt-5.6-luna\u001b[39m · \u001b[38;5;195mLocal CLI Bridge\u001b[39m · \u001b[38;5;195mhigh\u001b[39m \u001b[38;5;195mCtrl+P commands\u001b[39m\u001b[0m\u001b]8;;\u0007\u001b[?2026l"] +[0.010082,"o","\u001b[7A\u001b[10G\u001b[?25l"] +[1.007412,"i","\u001b"] +[1.108857,"o","\u001b[?25l"] +[1.11065,"o","\u001b[?2026h\u001b[7A\r\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;111m›\u001b[39m\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l"] +[1.11068,"o","\u001b[2G\u001b[?25l"] +[1.47943,"i","W"] +[1.481075,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mW\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[3G\u001b[?25l"] +[1.499548,"i","h"] +[1.500961,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWh\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[4G\u001b[?25l"] +[1.52033,"i","i"] +[1.521912,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhi\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[5G\u001b[?25l"] +[1.541366,"i","c"] +[1.542955,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhic\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[6G\u001b[?25l"] +[1.562409,"i","h"] +[1.563833,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l"] +[1.56387,"o","\u001b[7G\u001b[?25l"] +[1.583204,"i"," "] +[1.584609,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich \u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[8G"] +[1.584662,"o","\u001b[?25l"] +[1.603022,"i","c"] +[1.60428,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich c\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[9G\u001b[?25l"] +[1.622814,"i","o"] +[1.624077,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich co\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[10G\u001b[?25l"] +[1.642517,"i","d"] +[1.643852,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich cod\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[11G\u001b[?25l"] +[1.663245,"i","i"] +[1.664396,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich codi\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[12G"] +[1.664441,"o","\u001b[?25l"] +[1.683803,"i","n"] +[1.685103,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich codin\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l"] +[1.685153,"o","\u001b[13G\u001b[?25l"] +[1.70352,"i","g"] +[1.705077,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[14G\u001b[?25l"] +[1.723532,"i"," "] +[1.725137,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding \u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[15G\u001b[?25l"] +[1.743586,"i","a"] +[1.744883,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding a\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[16G\u001b[?25l"] +[1.764312,"i","g"] +[1.76559,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding ag\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[17G\u001b[?25l"] +[1.784963,"i","e"] +[1.78623,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding age\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[18G\u001b[?25l"] +[1.804737,"i","n"] +[1.80586,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agen\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[19G\u001b[?25l"] +[1.825249,"i","t"] +[1.826717,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l"] +[1.826777,"o","\u001b[20G\u001b[?25l"] +[1.846263,"i"," "] +[1.847805,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent \u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[21G"] +[1.847859,"o","\u001b[?25l"] +[1.867184,"i","w"] +[1.868357,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent w\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l"] +[1.868399,"o","\u001b[22G\u001b[?25l"] +[1.886873,"i","i"] +[1.888214,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent wi\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[23G"] +[1.88827,"o","\u001b[?25l"] +[1.906643,"i","l"] +[1.907772,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent wil\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[24G"] +[1.907817,"o","\u001b[?25l"] +[1.927372,"i","l"] +[1.928674,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[25G\u001b[?25l"] +[1.948043,"i"," "] +[1.949659,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will \u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[26G\u001b[?25l"] +[1.968036,"i","h"] +[1.969453,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will h\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l"] +[1.969514,"o","\u001b[27G\u001b[?25l"] +[1.9879,"i","a"] +[1.989263,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will ha\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[28G\u001b[?25l"] +[2.007588,"i","n"] +[2.008886,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will han\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[29G\u001b[?25l"] +[2.028246,"i","d"] +[2.029869,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will hand\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[30G"] +[2.029946,"o","\u001b[?25l"] +[2.049384,"i","l"] +[2.050971,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handl\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[31G"] +[2.051041,"o","\u001b[?25l"] +[2.070412,"i","e"] +[2.071767,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[32G\u001b[?25l"] +[2.091373,"i"," "] +[2.092563,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle \u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[33G\u001b[?25l"] +[2.111921,"i","m"] +[2.113214,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle m\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l"] +[2.113253,"o","\u001b[34G\u001b[?25l"] +[2.131623,"i","y"] +[2.132983,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle my\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[35G\u001b[?25l"] +[2.152394,"i"," "] +[2.153591,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle my \u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[36G\u001b[?25l"] +[2.173041,"i","n"] +[2.174539,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle my n\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[37G\u001b[?25l"] +[2.192967,"i","e"] +[2.194566,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle my ne\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[38G"] +[2.194643,"o","\u001b[?25l"] +[2.213055,"i","x"] +[2.214355,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle my nex\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[39G\u001b[?25l"] +[2.232776,"i","t"] +[2.234326,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle my next\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[40G\u001b[?25l"] +[2.252865,"i"," "] +[2.254583,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle my next \u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[41G"] +[2.25465,"o","\u001b[?25l"] +[2.273196,"i","t"] +[2.274323,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle my next t\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[42G\u001b[?25l"] +[2.292764,"i","a"] +[2.294186,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle my next ta\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[43G\u001b[?25l"] +[2.312604,"i","s"] +[2.314022,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle my next tas\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[44G"] +[2.31408,"o","\u001b[?25l"] +[2.332497,"i","k"] +[2.334051,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle my next task\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[45G\u001b[?25l"] +[2.35264,"i",","] +[2.354117,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle my next task,\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[46G\u001b[?25l"] +[2.372543,"i"," "] +[2.373935,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle my next task, \u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[47G\u001b[?25l"] +[2.393361,"i","a"] +[2.394481,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle my next task, a\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[48G\u001b[?25l"] +[2.413858,"i","n"] +[2.415371,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle my next task, an\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l"] +[2.415435,"o","\u001b[49G\u001b[?25l"] +[2.433836,"i","d"] +[2.435329,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle my next task, and\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[50G\u001b[?25l"] +[2.454071,"i"," "] +[2.455416,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle my next task, and \u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[51G\u001b[?25l"] +[2.473853,"i","w"] +[2.475439,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle my next task, and w\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[52G\u001b[?25l"] +[2.49388,"i","h"] +[2.49503,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle my next task, and wh\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[53G\u001b[?25l"] +[2.513685,"i","e"] +[2.515439,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle my next task, and whe\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[54G\u001b[?25l"] +[2.53384,"i","r"] +[2.535374,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle my next task, and wher\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[55G"] +[2.53543,"o","\u001b[?25l"] +[2.553754,"i","e"] +[2.555154,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle my next task, and where\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l"] +[2.555216,"o","\u001b[56G\u001b[?25l"] +[2.573602,"i"," "] +[2.575068,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle my next task, and where \u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[57G"] +[2.575156,"o","\u001b[?25l"] +[2.593566,"i","w"] +[2.594994,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle my next task, and where w\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[58G"] +[2.595049,"o","\u001b[?25l"] +[2.613641,"i","i"] +[2.614997,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle my next task, and where wi\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l"] +[2.615045,"o","\u001b[59G\u001b[?25l"] +[2.634416,"i","l"] +[2.636041,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle my next task, and where wil\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[60G\u001b[?25l"] +[2.65542,"i","l"] +[2.656977,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle my next task, and where will\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[61G"] +[2.657039,"o","\u001b[?25l"] +[2.676391,"i"," "] +[2.6779,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle my next task, and where will \u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[62G\u001b[?25l"] +[2.697323,"i","i"] +[2.698773,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle my next task, and where will i\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[63G"] +[2.698832,"o","\u001b[?25l"] +[2.718208,"i","t"] +[2.719676,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle my next task, and where will it\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[64G\u001b[?25l"] +[2.738071,"i"," "] +[2.739767,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle my next task, and where will it \u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[65G\u001b[?25l"] +[2.758148,"i","r"] +[2.759627,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle my next task, and where will it r\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[66G"] +[2.759707,"o","\u001b[?25l"] +[2.778092,"i","u"] +[2.779312,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle my next task, and where will it ru\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[67G\u001b[?25l"] +[2.79769,"i","n"] +[2.798894,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle my next task, and where will it run\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[68G\u001b[?25l"] +[2.818332,"i","?"] +[2.819581,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle my next task, and where will it run?\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[69G"] +[2.819631,"o","\u001b[?25l"] +[2.838984,"i","\r"] +[2.840911,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[2G\u001b[?25l"] +[2.878288,"o","\u001b[?2026h\u001b[14A\r\u001b[2K \u001b[38;5;111m› \u001b[39mWhich coding agent will handle my next task, and where will it run? \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;222mworking…\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;222m› queue #1\u001b[39m \u001b[7m \u001b[0m \u001b[0m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;195mprofile Release engineer\u001b[39m · \u001b[38;5;195mpi / openai-codex/gpt-5.6-luna\u001b[39m · \u001b[38;5;195mLocal CLI Bridge\u001b[39m \u001b[38;5;222mworking · Ctrl+C cancel\u001b[39m · \u001b[38;5;195mEnter queues\u001b[39m\u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[3A\u001b[12G\u001b[?25l"] +[2.899858,"i","\u001bOQ"] +[2.905559,"o","\u001b[?2026h\u001b[14A\r\u001b[2K \u001b[38;5;111m› \u001b[39mWhich coding agent will handle my next task, and where will it run? \u001b[38;5;145m│\u001b[39m \u001b[1m\u001b[38;5;153mlive work\u001b[39m\u001b[22m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m│\u001b[39m \u001b[38;5;222m> running\u001b[39m run run-000001 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;222mworking…\u001b[39m \u001b[38;5;145m│\u001b[39m \u001b[38;5;222m> running\u001b[39m admitted · pr\u001b[0m...\u001b[0m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m│\u001b[39m \u001b[38;5;222m> running\u001b[39m event backend\u001b[0m...\u001b[0m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m│\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m│\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m│\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m│\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m│\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m│\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m│\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m│\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m│\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m│\u001b[39m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[1B\u001b[12G\u001b[?25l"] +[8.302244,"o","\u001b[?2026h\u001b[14A\r\u001b[2K \u001b[38;5;111m› \u001b[39mWhich coding agent will handle my next task, and where will it run? \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Pi will run the task through Local CLI Bridge with gpt-5.6-luna. The Release engineer AgentProfile supplies its \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K instructions and permissions. Braid keeps the transcript, approvals, branches, and trace analysis. \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;111m›\u001b[39m\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;195mprofile Release engineer\u001b[39m · \u001b[38;5;195mpi / openai-codex/gpt-5.6-luna\u001b[39m · \u001b[38;5;195mLocal CLI Bridge\u001b[39m · \u001b[38;5;195mhigh\u001b[39m \u001b[38;5;195min 1.3k\u001b[39m · \u001b[38;5;195mout 96\u001b[39m · \u001b[38;5;195m~$0.0026\u001b[39m\u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[3A\u001b[2G\u001b[?25l"] +[8.305686,"i","\u001bOQ"] +[8.313806,"o","\u001b[2G\u001b[?25l"] +[9.307422,"i","/"] +[9.309312,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[3G\u001b[?25l"] +[9.325456,"o","\u001b[?2026h\u001b[7A\r\u001b[2K\u001b[38;5;111m›\u001b[39m/\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;145m──────────────────────────────────────────────\u001b[39m\u001b[38;5;111m alt+enter newline · paste \u001b[39m\u001b[38;5;145m───────────────────────────────────────────────\u001b[39m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[1m\u001b[38;5;111m→ new Create an empty conversation\u001b[39m\u001b[22m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K open\u001b[38;5;145m Search and open conversations\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K profile\u001b[38;5;145m Inspect, select, import, or edit a profile\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K connection\u001b[38;5;145m Inspect, create, test, select, or remove a connection\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K runner\u001b[38;5;145m Set the runner for this branch\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m (1/29)\u001b[39m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[9A\u001b[3G\u001b[?25l"] +[9.337826,"i","a"] +[9.339041,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/a\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l"] +[9.339083,"o","\u001b[4G\u001b[?25l"] +[9.355469,"o","\u001b[?2026h\u001b[4B\r\u001b[2K \u001b[1m\u001b[38;5;111m→ ask Analyze a frozen run with citations\u001b[39m\u001b[22m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K analyze\u001b[38;5;145m Run a named trace-analysis recipe\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K approve\u001b[38;5;145m unavailable — Interaction response is not exposed by the current runtime adapter\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K automate\u001b[38;5;145m unavailable — Interaction automation requires the shared response contract\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K activity\u001b[38;5;145m Open run, tool, worker, and usage activity\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m (1/10)\u001b[39m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[9A\u001b[4G\u001b[?25l"] +[9.367753,"i","c"] +[9.369322,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ac\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[5G\u001b[?25l"] +[9.384636,"o","\u001b[?2026h\r\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;111m›\u001b[39m/ac\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;145m──────────────────────────────────────────────\u001b[39m\u001b[38;5;111m alt+enter newline · paste \u001b[39m\u001b[38;5;145m───────────────────────────────────────────────\u001b[39m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[1m\u001b[38;5;111m→ activity Open run, tool, worker, and usage activity\u001b[39m\u001b[22m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K detach\u001b[38;5;145m unavailable — There is no active run\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K cancel\u001b[38;5;145m unavailable — There is no active run to cancel\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K branch\u001b[38;5;145m Create a branch at a message boundary\u001b[39m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[7A\u001b[5G\u001b[?25l"] +[9.398005,"i","t"] +[9.399598,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/act\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l"] +[9.399633,"o","\u001b[6G\u001b[?25l"] +[9.415854,"o","\u001b[?2026h\r\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;111m›\u001b[39m/act\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;145m──────────────────────────────────────────────\u001b[39m\u001b[38;5;111m alt+enter newline · paste \u001b[39m\u001b[38;5;145m───────────────────────────────────────────────\u001b[39m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[1m\u001b[38;5;111m→ activity Open run, tool, worker, and usage activity\u001b[39m\u001b[22m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[4A\u001b[6G\u001b[?25l"] +[9.42827,"i","i"] +[9.429915,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/acti\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[7G\u001b[?25l"] +[9.445989,"o","\u001b[7G\u001b[?25l"] +[9.459369,"i","v"] +[9.460794,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/activ\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[8G\u001b[?25l"] +[9.476869,"o","\u001b[8G\u001b[?25l"] +[9.490241,"i","i"] +[9.491867,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/activi\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[9G\u001b[?25l"] +[9.508206,"o","\u001b[9G\u001b[?25l"] +[9.520517,"i","t"] +[9.522185,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/activit\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[10G"] +[9.522245,"o","\u001b[?25l"] +[9.537759,"o","\u001b[10G\u001b[?25l"] +[9.551285,"i","y"] +[9.552504,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/activity\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[11G\u001b[?25l"] +[9.568506,"o","\u001b[11G\u001b[?25l"] +[9.581795,"i","\r"] +[9.583902,"o","\u001b[?2026h\r\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;111m›\u001b[39m\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\u001b[?2026l"] +[9.583953,"o","\u001b[2A\u001b[2G\u001b[?25l"] +[9.589056,"o","\u001b[?25l"] +[9.602485,"o","\u001b[?2026h\u001b[14A\r\u001b[2K\u001b[0m\u001b]8;;\u0007 \u001b[1m\u001b[38;5;153mactivity\u001b[39m\u001b[22m \u001b[38;5;145m›\u001b[39m \u001b[38;5;111martifact\u001b[39m \u001b[0m\u001b]8;;\u0007\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[0m\u001b]8;;\u0007\u001b[38;5;145m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[0m\u001b]8;;\u0007\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[0m\u001b]8;;\u0007 \u001b[38;5;111m›\u001b[39m \u001b[38;5;115m✓\u001b[39m \u001b[38;5;111martifact\u001b[39m\u001b[38;5;145m · system\u001b[39m \u001b[38;5;145m│\u001b[39m time: 2026-08-15T00:51:15.152Z \u001b[0m\u001b]8;;\u0007\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[0m\u001b]8;;\u0007 \u001b[38;5;115m✓\u001b[39m \u001b[38;5;195mevent backend_start\u001b[39m\u001b[38;5;145m · system · seq\u001b[0m…\u001b[0m\u001b[38;5;145m│\u001b[39m run: run-000001 \u001b[0m\u001b]8;;\u0007\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[0m\u001b]8;;\u0007 \u001b[38;5;115m✓\u001b[39m \u001b[38;5;195madmitted\u001b[39m\u001b[38;5;145m · system · profile b6e862\u001b[0m…\u001b[0m\u001b[38;5;145m│\u001b[39m source event: run-000001:runtime:3 \u001b[0m\u001b]8;;\u0007\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[0m\u001b]8;;\u0007 \u001b[38;5;115m✓\u001b[39m \u001b[38;5;195mrun run-000001\u001b[39m\u001b[38;5;145m · run\u001b[39m \u001b[38;5;145m│\u001b[39m \u001b[0m\u001b]8;;\u0007\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[0m\u001b]8;;\u0007 \u001b[38;5;145m│\u001b[39m \u001b[0m\u001b]8;;\u0007\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[0m\u001b]8;;\u0007 \u001b[38;5;145m│\u001b[39m \u001b[0m\u001b]8;;\u0007\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[0m\u001b]8;;\u0007 \u001b[38;5;145m│\u001b[39m \u001b[0m\u001b]8;;\u0007\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[0m\u001b]8;;\u0007 \u001b[38;5;145m│\u001b[39m \u001b[0m\u001b]8;;\u0007\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[0m\u001b]8;;\u0007 \u001b[38;5;145m│\u001b[39m \u001b[0m\u001b]8;;\u0007\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[0m\u001b]8;;\u0007 \u001b[38;5;145m│\u001b[39m \u001b[0m\u001b]8;;\u0007\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[0m\u001b]8;;\u0007 \u001b[38;5;145m│\u001b[39m \u001b[0m\u001b]8;;\u0007\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[0m\u001b]8;;\u0007 \u001b[38;5;145m│\u001b[39m \u001b[0m\u001b]8;;\u0007\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[0m\u001b]8;;\u0007 \u001b[38;5;145m│\u001b[39m \u001b[0m\u001b]8;;\u0007\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[0m\u001b]8;;\u0007 \u001b[38;5;145m│\u001b[39m \u001b[0m\u001b]8;;\u0007\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[0m\u001b]8;;\u0007\u001b[38;5;145m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[0m\u001b]8;;\u0007\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[0m\u001b]8;;\u0007 \u001b[38;5;145m↑↓ select · PgUp/PgDn detail · home/end jump · ←/esc close · tab filter: all\u001b[39m \u001b[0m\u001b]8;;\u0007\u001b[0m\u001b]8;;\u0007\u001b[?2026l"] +[9.602537,"o","\u001b[?25l"] +[10.482402,"i","\u001b[D"] +[10.485316,"o","\u001b[?25l"] +[10.486099,"o","\u001b[?2026h\u001b[17A\r\u001b[2K \u001b[38;5;111m› \u001b[39mWhich coding agent will handle my next task, and where will it run? \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Pi will run the task through Local CLI Bridge with gpt-5.6-luna. The Release engineer AgentProfile supplies its \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K instructions and permissions. Braid keeps the transcript, approvals, branches, and trace analysis. \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;111m›\u001b[39m\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;195mprofile Release engineer\u001b[39m · \u001b[38;5;195mpi / openai-codex/gpt-5.6-luna\u001b[39m · \u001b[38;5;195mLocal CLI Bridge\u001b[39m · \u001b[38;5;195mhigh\u001b[39m \u001b[38;5;195min 1.3k\u001b[39m · \u001b[38;5;195mout 96\u001b[39m · \u001b[38;5;195m~$0.0026\u001b[39m\u001b[0m\u001b]8;;\u0007\u001b[?2026l"] +[10.486134,"o","\u001b[3A\u001b[2G\u001b[?25l"] +[10.833007,"i","/"] +[10.834319,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[3G\u001b[?25l"] +[10.850285,"o","\u001b[?2026h\u001b[7A\r\u001b[2K\u001b[38;5;111m›\u001b[39m/\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;145m──────────────────────────────────────────────\u001b[39m\u001b[38;5;111m alt+enter newline · paste \u001b[39m\u001b[38;5;145m───────────────────────────────────────────────\u001b[39m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[1m\u001b[38;5;111m→ new Create an empty conversation\u001b[39m\u001b[22m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K open\u001b[38;5;145m Search and open conversations\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K profile\u001b[38;5;145m Inspect, select, import, or edit a profile\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K connection\u001b[38;5;145m Inspect, create, test, select, or remove a connection\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K runner\u001b[38;5;145m Set the runner for this branch\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m (1/29)\u001b[39m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[9A\u001b[3G\u001b[?25l"] +[10.850376,"i","a"] +[10.85118,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/a\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[4G\u001b[?25l"] +[10.866482,"o","\u001b[?2026h\u001b[4B\r\u001b[2K \u001b[1m\u001b[38;5;111m→ ask Analyze a frozen run with citations\u001b[39m\u001b[22m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K analyze\u001b[38;5;145m Run a named trace-analysis recipe\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K approve\u001b[38;5;145m unavailable — Interaction response is not exposed by the current runtime adapter\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K automate\u001b[38;5;145m unavailable — Interaction automation requires the shared response contract\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K activity\u001b[38;5;145m Open run, tool, worker, and usage activity\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m (1/10)\u001b[39m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[9A\u001b[4G\u001b[?25l"] +[10.868811,"i","s"] +[10.869936,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/as\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[5G"] +[10.869973,"o","\u001b[?25l"] +[10.885792,"o","\u001b[?2026h\r\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;111m›\u001b[39m/as\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;145m──────────────────────────────────────────────\u001b[39m\u001b[38;5;111m alt+enter newline · paste \u001b[39m\u001b[38;5;145m───────────────────────────────────────────────\u001b[39m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[1m\u001b[38;5;111m→ ask Analyze a frozen run with citations\u001b[39m\u001b[22m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[4A\u001b[5G\u001b[?25l"] +[10.886958,"i","k"] +[10.888073,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[6G"] +[10.888099,"o","\u001b[?25l"] +[10.903806,"o","\u001b[6G\u001b[?25l"] +[10.90497,"i"," "] +[10.906132,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask \u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[7G\u001b[?25l"] +[10.922255,"o","\u001b[?2026h\r\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;111m›\u001b[39m/ask \u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[2A\u001b[7G\u001b[?25l"] +[10.922346,"i","W"] +[10.923222,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask W\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l"] +[10.923243,"o","\u001b[8G\u001b[?25l"] +[10.939165,"o","\u001b[8G\u001b[?25l"] +[10.940443,"i","h"] +[10.941643,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask Wh\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l"] +[10.941678,"o","\u001b[9G\u001b[?25l"] +[10.958098,"o","\u001b[9G\u001b[?25l"] +[10.959297,"i","a"] +[10.960254,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask Wha\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[10G\u001b[?25l"] +[10.976396,"o","\u001b[10G\u001b[?25l"] +[10.97757,"i","t"] +[10.979015,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask What\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[11G"] +[10.979045,"o","\u001b[?25l"] +[10.995098,"o","\u001b[11G\u001b[?25l"] +[10.99626,"i"," "] +[10.997323,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask What \u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[12G\u001b[?25l"] +[11.014764,"i","s"] +[11.016116,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask What s\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[13G\u001b[?25l"] +[11.032204,"o","\u001b[13G\u001b[?25l"] +[11.033351,"i","h"] +[11.034447,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask What sh\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[14G\u001b[?25l"] +[11.050626,"o","\u001b[14G\u001b[?25l"] +[11.051786,"i","o"] +[11.053438,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask What sho\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l"] +[11.053484,"o","\u001b[15G\u001b[?25l"] +[11.069003,"o","\u001b[15G\u001b[?25l"] +[11.070179,"i","u"] +[11.071957,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask What shou\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[16G\u001b[?25l"] +[11.08741,"o","\u001b[16G\u001b[?25l"] +[11.087493,"i","l"] +[11.088589,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask What shoul\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[17G"] +[11.088785,"o","\u001b[?25l"] +[11.105287,"o","\u001b[17G\u001b[?25l"] +[11.1054,"i","d"] +[11.106878,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask What should\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l"] +[11.106901,"o","\u001b[18G\u001b[?25l"] +[11.123357,"o","\u001b[18G\u001b[?25l"] +[11.123487,"i"," "] +[11.125249,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask What should \u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[19G\u001b[?25l"] +[11.141527,"i","t"] +[11.143183,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask What should t\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[20G\u001b[?25l"] +[11.159323,"o","\u001b[20G\u001b[?25l"] +[11.159435,"i","h"] +[11.16081,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask What should th\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l"] +[11.160837,"o","\u001b[21G\u001b[?25l"] +[11.177013,"o","\u001b[21G\u001b[?25l"] +[11.178221,"i","i"] +[11.179591,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask What should thi\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[22G\u001b[?25l"] +[11.195907,"o","\u001b[22G\u001b[?25l"] +[11.195997,"i","s"] +[11.196958,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask What should this\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[23G"] +[11.197105,"o","\u001b[?25l"] +[11.213242,"o","\u001b[23G\u001b[?25l"] +[11.213363,"i"," "] +[11.214539,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask What should this \u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[24G\u001b[?25l"] +[11.231844,"i","a"] +[11.233259,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask What should this a\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[25G"] +[11.233312,"o","\u001b[?25l"] +[11.249273,"o","\u001b[25G\u001b[?25l"] +[11.249367,"i","g"] +[11.250296,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask What should this ag\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[26G\u001b[?25l"] +[11.265237,"o","\u001b[26G\u001b[?25l"] +[11.267624,"i","e"] +[11.268518,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask What should this age\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l"] +[11.268533,"o","\u001b[27G\u001b[?25l"] +[11.284376,"o","\u001b[27G\u001b[?25l"] +[11.28551,"i","n"] +[11.286272,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask What should this agen\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[28G"] +[11.286282,"o","\u001b[?25l"] +[11.302602,"o","\u001b[28G\u001b[?25l"] +[11.30375,"i","t"] +[11.305097,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask What should this agent\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l"] +[11.305122,"o","\u001b[29G\u001b[?25l"] +[11.320858,"o","\u001b[29G\u001b[?25l"] +[11.321997,"i"," "] +[11.323003,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask What should this agent \u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[30G\u001b[?25l"] +[11.340334,"i","i"] +[11.34172,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask What should this agent i\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[31G"] +[11.341761,"o","\u001b[?25l"] +[11.357829,"o","\u001b[31G\u001b[?25l"] +[11.358991,"i","m"] +[11.360035,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask What should this agent im\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[32G\u001b[?25l"] +[11.376161,"o","\u001b[32G\u001b[?25l"] +[11.377304,"i","p"] +[11.378079,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask What should this agent imp\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[33G\u001b[?25l"] +[11.394082,"o","\u001b[33G\u001b[?25l"] +[11.396337,"i","r"] +[11.397597,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask What should this agent impr\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l"] +[11.397622,"o","\u001b[34G\u001b[?25l"] +[11.414014,"o","\u001b[34G\u001b[?25l"] +[11.41516,"i","o"] +[11.416574,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask What should this agent impro\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[35G"] +[11.416597,"o","\u001b[?25l"] +[11.432167,"o","\u001b[35G\u001b[?25l"] +[11.433334,"i","v"] +[11.434785,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask What should this agent improv\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l"] +[11.434858,"o","\u001b[36G"] +[11.434899,"o","\u001b[?25l"] +[11.45055,"o","\u001b[36G\u001b[?25l"] +[11.451756,"i","e"] +[11.457098,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask What should this agent improve\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[37G\u001b[?25l"] +[11.469591,"i"," "] +[11.471049,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask What should this agent improve \u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[38G\u001b[?25l"] +[11.488418,"i","n"] +[11.489884,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask What should this agent improve n\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[39G\u001b[?25l"] +[11.505985,"o","\u001b[39G\u001b[?25l"] +[11.507209,"i","e"] +[11.508598,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask What should this agent improve ne\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[40G\u001b[?25l"] +[11.52476,"o","\u001b[40G\u001b[?25l"] +[11.52486,"i","x"] +[11.526156,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask What should this agent improve nex\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[41G\u001b[?25l"] +[11.542126,"o","\u001b[41G\u001b[?25l"] +[11.543296,"i","t"] +[11.544963,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask What should this agent improve next\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[42G\u001b[?25l"] +[11.560188,"o","\u001b[42G\u001b[?25l"] +[11.561459,"i","?"] +[11.562618,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask What should this agent improve next?\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[43G\u001b[?25l"] +[11.579937,"i","\r"] +[11.581504,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[2G\u001b[?25l"] +[11.584042,"o","\u001b[?25l"] +[11.587719,"o","\u001b[?25l"] +[11.587768,"o","\u001b[?25l"] +[11.600091,"o","\u001b[?2026h\u001b[14A\r\u001b[2K\u001b[0m\u001b]8;;\u0007 \u001b[1m\u001b[38;5;153manalyses\u001b[39m\u001b[22m \u001b[38;5;145m›\u001b[39m \u001b[38;5;111m/ask · frozen question\u001b[39m \u001b[0m\u001b]8;;\u0007\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[0m\u001b]8;;\u0007\u001b[38;5;145m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[0m\u001b]8;;\u0007\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[0m\u001b]8;;\u0007 \u001b[38;5;145msource run run-000001 · Release engineer · pi · openai-codex/gpt-5.6-luna · Local CLI Bridge\u001b[39m \u001b[0m\u001b]8;;\u0007\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[0m\u001b]8;;\u0007 source: a9ed803bccc9148…26b84d74 · frozen \u001b[0m\u001b]8;;\u0007\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[0m\u001b]8;;\u0007 analyst: profile-trace-reviewer · completed \u001b[0m\u001b]8;;\u0007\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[0m\u001b]8;;\u0007 \u001b[38;5;111m── question\u001b[39m \u001b[0m\u001b]8;;\u0007\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[0m\u001b]8;;\u0007 What should this agent improve next? \u001b[0m\u001b]8;;\u0007\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[0m\u001b]8;;\u0007 \u001b[38;5;111m── findings\u001b[39m \u001b[0m\u001b]8;;\u0007\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[0m\u001b]8;;\u0007 1. [1] The route was explicit, but the final answer did not cite its materialization receipt. · medium · confidence \u001b[0m\u001b]8;;\u0007\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[0m\u001b]8;;\u0007 0.93 \u001b[0m\u001b]8;;\u0007\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[0m\u001b]8;;\u0007 \u001b[38;5;145m↳ [1] runtime.route completed: Local CLI Bridge → Pi · event event-runtime-route\u001b[39m \u001b[0m\u001b]8;;\u0007\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[0m\u001b]8;;\u0007 2. [2] Profile inspection and route resolution ran serially even though their inputs were independent. · low · \u001b[0m\u001b]8;;\u0007\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[0m\u001b]8;;\u0007 confidence 0.86 \u001b[0m\u001b]8;;\u0007\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[0m\u001b]8;;\u0007 \u001b[38;5;145m↳ [2] profile.inspect completed before runtime.route started · event event-profile-b…re-route\u001b[39m \u001b[0m\u001b]8;;\u0007\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[0m\u001b]8;;\u0007 \u001b[38;5;111m── next\u001b[39m \u001b[0m\u001b]8;;\u0007\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[0m\u001b]8;;\u0007 next: ask a narrower question, or use /compare against another frozen run \u001b[0m\u001b]8;;\u0007\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[0m\u001b]8;;\u0007\u001b[38;5;145m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[0m\u001b]8;;\u0007\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[0m\u001b]8;;\u0007 \u001b[38;5;145mPgUp/PgDn detail · ←/esc close · page 1/2 · tab filter: analyses\u001b[39m \u001b[0m\u001b]8;;\u0007\u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[?25l"] +[11.610091,"o","\u001b[0m"] diff --git a/artifacts/verification/tui-audit/after/w6/raw/transcript-keyboard.cast b/artifacts/verification/tui-audit/after/w6/raw/transcript-keyboard.cast new file mode 100644 index 0000000..547b015 --- /dev/null +++ b/artifacts/verification/tui-audit/after/w6/raw/transcript-keyboard.cast @@ -0,0 +1,68 @@ +{"version":2,"width":80,"height":24,"timestamp":1786755084,"duration":2.113788,"idle_time_limit":1,"command":"packed braid --fixture deterministic","title":"Braid transcript keyboard navigation","env":{"TERM":"xterm-256color"},"stdin":true} +[0.433845,"o","\u001b[?2004h"] +[0.434119,"o","\u001b[>7u\u001b[?u\u001b[c"] +[0.434137,"o","\u001b[?1049h"] +[0.434149,"o","\u001b[2J\u001b[H"] +[0.43417,"o","\u001b[?25l"] +[0.447758,"o","\u001b[?2026h\u001b[2J\u001b[H\u001b[3J \u001b[0m\u001b]8;;\u0007\r\r\n Ask Braid starter anything. \u001b[0m\u001b]8;;\u0007\r\r\n \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[38;5;111m›\u001b[39m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[38;5;195mprofile Braid starter\u001b[39m · \u001b[38;5;195mpi / deterministic\u001b[39m · \u001b[38;5;195mno connection\u001b[39m \u001b[38;5;115mready\u001b[39m\u001b[0m\u001b]8;;\u0007\u001b[?2026l"] +[0.447858,"o","\u001b[3A\u001b[2G\u001b[?25l"] +[0.472823,"o","\u001b]0;Braid — /home/drew/code/braid-goal-latest-wt\u0007"] +[0.477651,"o","\u001b[?2026h\u001b[19A\r\u001b[2K \u001b[38;5;145mAsk Braid starter anything.\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;111m›\u001b[39m\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;195mprofile Braid starter\u001b[39m · \u001b[38;5;195mpi / deterministic\u001b[39m · \u001b[38;5;195mfixture\u001b[39m \u001b[38;5;195mCtrl+P commands\u001b[39m\u001b[0m\u001b]8;;\u0007\u001b[?2026l"] +[0.47772,"o","\u001b[3A\u001b[2G\u001b[?25l"] +[0.484416,"i","keyboard-flow-01"] +[0.484514,"i","\r"] +[0.492864,"o","\u001b[2G\u001b[?25l"] +[0.545708,"o","\u001b[?2026h\u001b[20A\r\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-01 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;222mworking…\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;222m› queue #1\u001b[39m \u001b[7m \u001b[0m \u001b[0m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;195mprofile Braid starter\u001b[39m \u001b[38;5;222mworking · Ctrl+C cancel\u001b[39m · \u001b[38;5;195mEnter queues\u001b[39m\u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[3A\u001b[12G\u001b[?25l"] +[0.612266,"o","\u001b[?2026h\u001b[18A\r\u001b[2K \u001b[38;5;145m· run result\u001b[39m\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-01 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;111m›\u001b[39m\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;195mprofile Braid starter\u001b[39m · \u001b[38;5;195mpi / deterministic\u001b[39m · \u001b[38;5;195mfixture\u001b[39m \u001b[38;5;195mCtrl+E detail\u001b[39m\u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[3A\u001b[2G\u001b[?25l"] +[0.706528,"i","keyboard-flow-02"] +[0.706612,"i","\r"] +[0.711774,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mkeyboard-flow-02\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[18G\u001b[?25l"] +[0.714152,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[2G"] +[0.714205,"o","\u001b[?25l"] +[0.732766,"o","\u001b[?2026h\u001b[16A\r\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-02 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;222mworking…\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;222m› queue #1\u001b[39m \u001b[7m \u001b[0m \u001b[0m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;195mprofile Braid starter\u001b[39m \u001b[38;5;222mworking · Ctrl+C cancel\u001b[39m · \u001b[38;5;195mEnter queues\u001b[39m\u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[3A\u001b[12G\u001b[?25l"] +[0.799746,"o","\u001b[?2026h\u001b[18A\r\u001b[2K \u001b[38;5;145m· run result\u001b[39m 1/2\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-01 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-02 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 2/2\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-02 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;111m›\u001b[39m\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;195mprofile Braid starter\u001b[39m · \u001b[38;5;195mpi / deterministic\u001b[39m · \u001b[38;5;195mfixture\u001b[39m \u001b[38;5;195mCtrl+E next detail (2/2)\u001b[39m\u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[3A\u001b[2G\u001b[?25l"] +[0.889264,"i","keyboard-flow-03"] +[0.889358,"i","\r"] +[0.891769,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mkeyboard-flow-03\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[18G\u001b[?25l"] +[0.894557,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[2G\u001b[?25l"] +[0.920097,"o","\u001b[?2026h\u001b[12A\r\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-03 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;222mworking…\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;222m› queue #1\u001b[39m \u001b[7m \u001b[0m \u001b[0m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;195mprofile Braid starter\u001b[39m \u001b[38;5;222mworking · Ctrl+C cancel\u001b[39m · \u001b[38;5;195mEnter queues\u001b[39m\u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[3A\u001b[12G\u001b[?25l"] +[0.986898,"o","\u001b[?2026h\u001b[18A\r\u001b[2K \u001b[38;5;145m· run result\u001b[39m 1/3\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-01 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-02 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 2/3\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-02 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-03 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 3/3\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-03 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;111m›\u001b[39m\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;195mprofile Braid starter\u001b[39m · \u001b[38;5;195mpi / deterministic\u001b[39m · \u001b[38;5;195mfixture\u001b[39m \u001b[38;5;195mCtrl+E next detail (3/3)\u001b[39m\u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[3A\u001b[2G\u001b[?25l"] +[1.071064,"i","keyboard-flow-04"] +[1.071162,"i","\r"] +[1.073321,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mkeyboard-flow-04\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[18G\u001b[?25l"] +[1.075397,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[2G"] +[1.075422,"o","\u001b[?25l"] +[1.095243,"o","\u001b[?2026h\u001b[8A\r\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-04 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;222mworking…\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;222m› queue #1\u001b[39m \u001b[7m \u001b[0m \u001b[0m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;195mprofile Braid starter\u001b[39m \u001b[38;5;222mworking · Ctrl+C cancel\u001b[39m · \u001b[38;5;195mEnter queues\u001b[39m\u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[3A\u001b[12G\u001b[?25l"] +[1.156584,"o","\u001b[?2026h\u001b[18A\r\u001b[2K \u001b[38;5;145m· run result\u001b[39m 1/4\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-01 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-02 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 2/4\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-02 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-03 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 3/4\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-03 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-04 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 4/4\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-04 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;111m›\u001b[39m\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;195mprofile Braid starter\u001b[39m · \u001b[38;5;195mpi / deterministic\u001b[39m · \u001b[38;5;195mfixture\u001b[39m \u001b[38;5;195mCtrl+E next detail (4/4)\u001b[39m\u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[3A\u001b[2G"] +[1.156666,"o","\u001b[?25l"] +[1.251753,"i","keyboard-flow-05"] +[1.251835,"i","\r"] +[1.254114,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mkeyboard-flow-05\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[18G\u001b[?25l"] +[1.256268,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[2G\u001b[?25l"] +[1.273394,"o","\u001b[?2026h\u001b[4A\r\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-05 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;222mworking…\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;222m› queue #1\u001b[39m \u001b[7m \u001b[0m \u001b[0m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;195mprofile Braid starter\u001b[39m \u001b[38;5;222mworking · Ctrl+C cancel\u001b[39m · \u001b[38;5;195mEnter queues\u001b[39m\u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[3A\u001b[12G\u001b[?25l"] +[1.33897,"o","\u001b[?2026h\u001b[18A\r\u001b[2K \u001b[38;5;145m· run result\u001b[39m 1/5\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-01 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-02 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 2/5\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-02 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-03 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 3/5\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-03 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-04 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 4/5\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-04 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-05 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 5/5\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-05 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;111m›\u001b[39m\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;195mprofile Braid starter\u001b[39m · \u001b[38;5;195mpi / deterministic\u001b[39m · \u001b[38;5;195mfixture\u001b[39m \u001b[38;5;195mCtrl+E next detail (5/5)\u001b[39m\u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[3A\u001b[2G\u001b[?25l"] +[1.433072,"i","keyboard-flow-06"] +[1.43317,"i","\r"] +[1.43605,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mkeyboard-flow-06\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[18G\u001b[?25l"] +[1.43867,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[2G\u001b[?25l"] +[1.46195,"o","\u001b[?2026h\u001b[20A\r\u001b[2K Fixture response through pi: keyboard-flow-01 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-02 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 2/5\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-02 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-03 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 3/5\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-03 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-04 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 4/5\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-04 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-05 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 5/5\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-05 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-06 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;222mworking…\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;222m› queue #1\u001b[39m \u001b[7m \u001b[0m \u001b[0m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;195mprofile Braid starter\u001b[39m \u001b[38;5;222mworking · Ctrl+C cancel\u001b[39m · \u001b[38;5;195mEnter queues\u001b[39m\u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[3A\u001b[12G\u001b[?25l"] +[1.528949,"o","\u001b[?2026h\u001b[20A\r\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-02 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 2/6\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-02 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-03 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 3/6\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-03 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-04 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 4/6\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-04 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-05 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 5/6\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-05 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-06 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 6/6\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-06 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;111m›\u001b[39m\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;195mprofile Braid starter\u001b[39m · \u001b[38;5;195mpi / deterministic\u001b[39m · \u001b[38;5;195mfixture\u001b[39m \u001b[38;5;195mCtrl+E next detail (6/6)\u001b[39m\u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[3A\u001b[2G\u001b[?25l"] +[1.614948,"i","keyboard-flow-07"] +[1.615021,"i","\r"] +[1.619161,"o","\u001b[2G\u001b[?25l"] +[1.642087,"o","\u001b[?2026h\u001b[20A\r\u001b[2K Fixture response through pi: keyboard-flow-02 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-03 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 3/6\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-03 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-04 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 4/6\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-04 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-05 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 5/6\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-05 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-06 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 6/6\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-06 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-07 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;222mworking…\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;222m› queue #1\u001b[39m \u001b[7m \u001b[0m \u001b[0m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;195mprofile Braid starter\u001b[39m \u001b[38;5;222mworking · Ctrl+C cancel\u001b[39m · \u001b[38;5;195mEnter queues\u001b[39m\u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[3A\u001b[12G\u001b[?25l"] +[1.710578,"o","\u001b[?2026h\u001b[20A\r\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-03 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 3/7\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-03 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-04 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 4/7\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-04 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-05 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 5/7\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-05 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-06 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 6/7\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-06 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-07 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 7/7\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-07 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;111m›\u001b[39m\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;195mprofile Braid starter\u001b[39m · \u001b[38;5;195mpi / deterministic\u001b[39m · \u001b[38;5;195mfixture\u001b[39m \u001b[38;5;195mCtrl+E next detail (7/7)\u001b[39m\u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[3A\u001b[2G\u001b[?25l"] +[1.797126,"i","keyboard-flow-08"] +[1.797231,"i","\r"] +[1.799404,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mkeyboard-flow-08\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[18G\u001b[?25l"] +[1.803637,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[2G\u001b[?25l"] +[1.83568,"o","\u001b[?2026h\u001b[20A\r\u001b[2K Fixture response through pi: keyboard-flow-03 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-04 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 4/7\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-04 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-05 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 5/7\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-05 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-06 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 6/7\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-06 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-07 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 7/7\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-07 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-08 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;222mworking…\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;222m› queue #1\u001b[39m \u001b[7m \u001b[0m \u001b[0m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;195mprofile Braid starter\u001b[39m \u001b[38;5;222mworking · Ctrl+C cancel\u001b[39m · \u001b[38;5;195mEnter queues\u001b[39m\u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[3A\u001b[12G\u001b[?25l"] +[1.924251,"o","\u001b[?2026h\u001b[20A\r\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-04 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 4/8\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-04 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-05 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 5/8\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-05 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-06 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 6/8\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-06 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-07 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 7/8\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-07 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-08 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 8/8\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-08 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;111m›\u001b[39m\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;195mprofile Braid starter\u001b[39m · \u001b[38;5;195mpi / deterministic\u001b[39m · \u001b[38;5;195mfixture\u001b[39m \u001b[38;5;195mCtrl+E next detail (8/8)\u001b[39m\u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[3A\u001b[2G\u001b[?25l"] +[2.019401,"i","\u001b[5~"] +[2.019492,"i","\u001b[1;3H"] +[2.024301,"o","\u001b[?2026h\u001b[20A\r\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-01 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 1/8\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-01 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-02 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 2/8\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-02 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-03 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 3/8\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-03 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-04 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 4/8\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-04 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-05 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 5/8\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-05 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;111m›\u001b[39m\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;195mprofile Braid starter\u001b[39m · \u001b[38;5;195mpi / deterministic\u001b[39m \u001b[38;5;195mPgUp/PgDn history · End follow\u001b[39m\u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[3A\u001b[2G\u001b[?25l"] +[2.100397,"i","\u001b[6~"] +[2.100471,"i","\u001b[1;3F"] +[2.103746,"o","\u001b[?2026h\u001b[20A\r\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-04 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 4/8\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-04 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-05 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 5/8\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-05 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-06 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 6/8\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-06 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-07 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 7/8\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-07 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-08 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 8/8\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-08 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;111m›\u001b[39m\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;195mprofile Braid starter\u001b[39m · \u001b[38;5;195mpi / deterministic\u001b[39m · \u001b[38;5;195mfixture\u001b[39m \u001b[38;5;195mCtrl+E next detail (8/8)\u001b[39m\u001b[0m\u001b]8;;\u0007\u001b[?2026l"] +[2.103788,"o","\u001b[3A\u001b[2G\u001b[?25l"] +[2.113788,"o","\u001b[0m"] diff --git a/artifacts/verification/tui-audit/after/w6/states/active-streaming.json b/artifacts/verification/tui-audit/after/w6/states/active-streaming.json new file mode 100644 index 0000000..bcb40a5 --- /dev/null +++ b/artifacts/verification/tui-audit/after/w6/states/active-streaming.json @@ -0,0 +1,750 @@ +{ + "schemaVersion": 2, + "state": "active-streaming", + "source": { + "binary": "packed real binary from clean npm install", + "binarySha256": "141288e0fe917635d723b4b70d464dc49baff14cc356f54de1d3f8faa5d8254f", + "tarball": "tangle-network-braid-0.1.1.tgz", + "tarballSha256": "b2c0571a7f7d0d6e847d493667dfed6ff11b52eefb5e5f43cb13470eea3562c7" + }, + "dimensions": { + "columns": 80, + "rows": 24 + }, + "terminal": "node-pty/xterm-256color", + "provenance": { + "renderer": { + "package": "@earendil-works/pi-tui@0.84.2", + "pty": "node-pty@1.1.0", + "emulator": "@xterm/headless@6.0.0", + "node": "v24.13.0", + "terminal": "xterm-256color" + }, + "raster": { + "agg": "agg 1.9.0", + "imagemagick": "Version: ImageMagick 6.9.12-98 Q16 x86_64 18038 https://legacy.imagemagick.org", + "fontFamily": "DejaVu Sans Mono", + "font": "DejaVu Sans Mono | Book | /usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", + "colorMode": "sRGB 8-bit" + } + }, + "capturePhase": "atomic-signal-frame", + "captureRevision": 5, + "frame": " › W6 active streaming\n\n working…\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n› queue #1\n\n\nprofile Braid starter working · Ctrl+C cancel · Enter queues\n", + "packedState": { + "schemaVersion": 2, + "capturePhase": "atomic-signal-frame", + "state": { + "schemaVersion": 2, + "revision": 5, + "sequence": 5, + "workspace": "/home/drew/code/braid-goal-latest-wt", + "conversationId": "conv-1", + "branchId": "branch-1", + "conversations": [ + { + "id": "conv-1", + "title": "New conversation", + "branchId": "branch-1", + "archived": false, + "updatedAt": "2026-08-01T00:00:00.000Z" + } + ], + "profile": { + "name": "Braid starter", + "description": "A portable starter profile for the Braid terminal", + "harness": "pi", + "model": { + "default": "fixture/deterministic", + "provider": "fixture", + "reasoningEffort": "none" + } + }, + "runConfiguration": { + "profileName": "Braid starter", + "runner": "pi", + "model": "fixture/deterministic", + "effort": "none", + "overrides": {} + }, + "draft": "", + "messages": [ + { + "id": "message-000003", + "role": "user", + "text": "W6 active streaming", + "status": "complete", + "runId": "run-000001", + "parts": [ + { + "id": "part-message-000003", + "kind": "text", + "text": "W6 active streaming" + } + ] + }, + { + "id": "message-000004", + "role": "assistant", + "text": "", + "status": "streaming", + "runId": "run-000001", + "parts": [] + } + ], + "runs": [ + { + "id": "run-000001", + "turnId": "turn-000002", + "operationId": "op-terminal-000002", + "status": "streaming", + "inputTokens": 0, + "outputTokens": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "model": "fixture/deterministic", + "provider": "agent-runtime", + "runner": "pi", + "completeness": "incomplete", + "materializationDigest": "46c7162244a3aa789bd2703e2805a3faa0da1a87d83d97484fa26294a0b00671" + } + ], + "sessionUsage": { + "turns": { + "sourceCount": 1, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 1, + "unknownCostSources": 1, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 1, + "unknownLatencySources": 1 + }, + "analyses": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "delegated": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "attribution": "complete" + }, + "environments": [], + "interactions": [], + "queue": [], + "activeRunId": "run-000001", + "lastError": null + }, + "view": { + "revision": 5, + "workspace": "/home/drew/code/braid-goal-latest-wt", + "profileName": "Braid starter", + "profileDigest": "44ed91c937fd4079fe01c49f6bc76413656886e5aa8b2351590d3a888cfa0a14", + "runner": "pi", + "model": "fixture/deterministic", + "effort": "none", + "runOverrides": {}, + "connection": "deterministic fixture", + "conversationId": "conv-1", + "conversationTitle": "New conversation", + "conversations": [ + { + "id": "conv-1", + "title": "New conversation", + "branchId": "branch-1", + "archived": false, + "active": true, + "updatedAt": "2026-08-01T00:00:00.000Z" + } + ], + "branch": "branch-1", + "status": "running", + "statusText": "streaming", + "queueCount": 0, + "queue": [], + "messages": [ + { + "id": "message-000003", + "role": "user", + "text": "W6 active streaming", + "status": "complete", + "runId": "run-000001", + "parts": [ + { + "id": "part-message-000003", + "kind": "text", + "text": "W6 active streaming" + } + ] + }, + { + "id": "message-000004", + "role": "assistant", + "text": "", + "status": "streaming", + "runId": "run-000001", + "parts": [] + } + ], + "hiddenMessageCount": 0, + "runs": [ + { + "id": "run-000001", + "turnId": "turn-000002", + "conversationId": "conv-1", + "branchId": "branch-1", + "operationId": "op-terminal-000002", + "status": "running", + "profileName": "Braid starter", + "profileDigest": "44ed91c937fd4079fe01c49f6bc76413656886e5aa8b2351590d3a888cfa0a14", + "model": "fixture/deterministic", + "effort": "none", + "usage": { + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "model": "fixture/deterministic" + }, + "runner": "pi", + "provider": "agent-runtime", + "completeness": "incomplete" + } + ], + "sessionUsage": { + "turns": { + "sourceCount": 1, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 1, + "unknownCostSources": 1, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 1, + "unknownLatencySources": 1 + }, + "analyses": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "delegated": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "attribution": "complete" + }, + "environments": [], + "activeRunId": "run-000001", + "interactions": [], + "activity": [ + { + "id": "run:run-000001", + "kind": "run", + "title": "run run-000001", + "status": "running", + "startedAt": "2026-08-01T00:00:00.000Z", + "occurredAt": "2026-08-01T00:00:00.000Z", + "runId": "run-000001", + "entityType": "run", + "entityId": "run-000001" + }, + { + "id": "activity:run-000001:admission:local:admitted", + "kind": "system", + "title": "admitted", + "status": "running", + "detail": "profile 44ed91c937fd4079fe01c49f6bc76413656886e5aa8b2351590d3a888cfa0a14", + "occurredAt": "2026-08-01T00:00:00.000Z", + "runId": "run-000001", + "entityType": "run", + "entityId": "run-000001" + }, + { + "id": "event:run-000001:run-000001:runtime:1", + "kind": "system", + "title": "event backend_start", + "status": "running", + "detail": "sequence 1", + "occurredAt": "2026-08-15T00:50:44.958Z", + "sourceEventId": "run-000001:runtime:1", + "runId": "run-000001", + "entityType": "run", + "entityId": "run-000001" + } + ], + "graph": [ + { + "id": "conv-1", + "type": "conversation", + "title": "New conversation", + "status": "complete", + "depth": 0, + "edgeLabel": "attached", + "startedAt": "2026-08-01T00:00:00.000Z" + }, + { + "id": "branch-1", + "type": "branch", + "title": "Main", + "status": "complete", + "depth": 1, + "edgeLabel": "attached", + "parentIds": [ + "conversation:conv-1" + ], + "startedAt": "2026-08-01T00:00:00.000Z" + }, + { + "id": "turn-000002", + "type": "turn", + "title": "Turn", + "status": "running", + "depth": 2, + "edgeLabel": "continued", + "parentIds": [ + "branch:branch-1" + ], + "startedAt": "2026-08-01T00:00:00.000Z" + }, + { + "id": "run-000001", + "type": "run", + "title": "Run", + "status": "running", + "depth": 3, + "edgeLabel": "attached", + "parentIds": [ + "turn:turn-000002" + ], + "runner": "pi", + "startedAt": "2026-08-01T00:00:00.000Z" + } + ], + "graphQuery": "", + "entityDetails": [], + "details": { + "title": "run run-000001", + "fields": [ + { + "label": "status", + "value": "streaming" + }, + { + "label": "input tokens", + "value": "0" + }, + { + "label": "output tokens", + "value": "0" + }, + { + "label": "model", + "value": "fixture/deterministic" + } + ] + }, + "capabilities": { + "conversation.create": { + "available": false, + "source": "application", + "reason": "Finish or cancel the active run first" + }, + "conversation.open": { + "available": false, + "source": "application", + "reason": "Finish or cancel the active run first" + }, + "run.runner": { + "available": false, + "source": "application", + "reason": "Finish or cancel the active run first" + }, + "run.model": { + "available": false, + "source": "application", + "reason": "Finish or cancel the active run first" + }, + "run.effort": { + "available": false, + "source": "application", + "reason": "Finish or cancel the active run first" + }, + "conversation.branch": { + "available": false, + "source": "application", + "reason": "Finish or cancel the active run first" + }, + "conversation.clone": { + "available": false, + "source": "application", + "reason": "Finish or cancel the active run first" + }, + "conversation.fork": { + "available": false, + "source": "application", + "reason": "Finish or cancel the active run first" + }, + "analysis.ask": { + "available": false, + "source": "application", + "reason": "Complete or fail a run before analyzing it" + }, + "analysis.recipe": { + "available": false, + "source": "application", + "reason": "Complete or fail a run before analyzing it" + }, + "analysis.compare": { + "available": false, + "source": "application", + "reason": "Two completed or failed runs are required for comparison" + }, + "interaction.respond": { + "available": false, + "source": "runtime", + "reason": "Interaction response is not exposed by the current runtime adapter" + }, + "interaction.automation": { + "available": false, + "source": "application", + "reason": "Interaction automation requires the shared response contract" + }, + "export.create": { + "available": false, + "source": "application", + "reason": "Finish or cancel the active run first" + }, + "application.quit": { + "available": true, + "source": "local" + }, + "help.read": { + "available": true, + "source": "local" + }, + "profile.select": { + "available": true, + "source": "application" + }, + "connection.select": { + "available": true, + "source": "application" + }, + "settings.open": { + "available": true, + "source": "application" + }, + "activity.read": { + "available": true, + "source": "local" + }, + "graph.read": { + "available": true, + "source": "local" + }, + "details.read": { + "available": true, + "source": "local" + }, + "draft.write": { + "available": true, + "source": "application" + }, + "run.send": { + "available": false, + "source": "application", + "reason": "A run is already active" + }, + "run.cancel": { + "available": true, + "source": "runtime" + }, + "run.queue": { + "available": true, + "source": "provider" + }, + "run.steer": { + "available": false, + "source": "provider", + "reason": "The current runtime does not report steering support" + }, + "run.detach": { + "available": false, + "source": "provider", + "reason": "The current runtime does not report detach support" + }, + "run.reconnect": { + "available": false, + "source": "provider", + "reason": "Detach the active run before reconnecting it" + }, + "run.reconcile": { + "available": false, + "source": "provider", + "reason": "Detach the active run before reconciling it" + } + }, + "draft": "", + "selectedSurface": "transcript", + "appearance": { + "color": "256", + "highContrast": false, + "reducedMotion": false + } + }, + "events": [ + { + "sequence": 1, + "revision": 1, + "kind": "workspace.opened", + "payload": { + "value": { + "kind": "workspace.opened", + "workspace": "/home/drew/code/braid-goal-latest-wt" + } + } + }, + { + "sequence": 2, + "revision": 2, + "kind": "draft.recorded", + "payload": { + "value": { + "kind": "draft.recorded", + "draft": { + "id": "draft-branch-1", + "branchId": "branch-1", + "text": "W6 active streaming", + "updatedAt": "2026-08-01T00:00:00.000Z" + }, + "operation": { + "id": "op-terminal-000001", + "kind": "draft-update", + "requestDigest": "e369d7f9b610550ecb42a6bbf159ab66d5e84156f3d6cd0d693d40047e8e63b5", + "status": "acknowledged", + "target": { + "kind": "branch", + "id": "branch-1" + }, + "createdAt": "2026-08-01T00:00:00.000Z", + "updatedAt": "2026-08-01T00:00:00.000Z", + "acknowledgedAt": "2026-08-01T00:00:00.000Z" + } + } + } + }, + { + "sequence": 3, + "revision": 3, + "kind": "run.requested", + "payload": { + "runId": "run-000001", + "text": "W6 active streaming", + "admission": { + "version": 1, + "runId": "run-000001", + "turnId": "turn-000002", + "operationId": "op-terminal-000002", + "conversationId": "conv-1", + "branchId": "branch-1", + "admittedAt": "2026-08-01T00:00:00.000Z", + "profileDigest": "44ed91c937fd4079fe01c49f6bc76413656886e5aa8b2351590d3a888cfa0a14", + "requested": { + "text": "W6 active streaming", + "profile": { + "name": "Braid starter", + "description": "A portable starter profile for the Braid terminal", + "model": { + "default": "fixture/deterministic", + "provider": "fixture", + "reasoningEffort": "none" + }, + "harness": "pi" + }, + "model": "fixture/deterministic", + "runner": "pi" + }, + "capabilities": { + "streaming": { + "live": true, + "replay": false, + "detach": false, + "turnIdempotency": true + }, + "sessions": { + "continue": false, + "messages": false + }, + "controls": { + "cancel": true, + "steer": false, + "queue": true, + "status": false, + "recreate": false + }, + "events": { + "stableIdentity": false, + "sequence": true, + "cursor": false + }, + "usage": true + }, + "provider": "agent-runtime", + "materializationReceipt": { + "backendKind": "executor", + "provider": "agent-runtime" + }, + "admissionStatus": "admitted", + "requestDigest": "3c7b454961e6fcf430e049567bd0f429517909faa89f8a05f3446046f7a55e39", + "capabilitiesDigest": "93c4c21593f9e2fa1fe68ce73da84f48c173f1e13269633678b512a16a81a001", + "materializationDigest": "46c7162244a3aa789bd2703e2805a3faa0da1a87d83d97484fa26294a0b00671", + "digest": "e0e2a2c27f92cbb77fc1449a50044630d9b08bfb42f04aaf06f2d8a12a32875f" + }, + "status": "admitted" + } + }, + { + "sequence": 4, + "revision": 4, + "kind": "effect.upserted", + "payload": { + "value": { + "kind": "effect.upserted", + "effect": { + "id": "effect-op-terminal-000002-541f881d74096c030fdf1730", + "operationId": "op-terminal-000002", + "effectKind": "run.execute", + "requestDigest": "541f881d74096c030fdf1730e241911a0553029fc36d0a58d227595060e6a47e", + "kind": "send", + "status": "pending", + "attempt": 1, + "createdAt": "2026-08-01T00:00:00.000Z", + "updatedAt": "2026-08-01T00:00:00.000Z" + } + } + } + }, + { + "sequence": 5, + "revision": 5, + "kind": "run.provider.event", + "payload": { + "runId": "run-000001", + "source": { + "eventId": "run-000001:runtime:1", + "providerSequence": 1, + "occurredAt": "2026-08-15T00:50:44.958Z", + "receivedAt": "2026-08-01T00:00:00.000Z" + }, + "unknown": { + "type": "backend_start", + "task": { + "id": "turn-bd010abd-f9a8-4d8d-8b2f-54966627b1a2", + "intent": "W6 active streaming", + "metadata": { + "identity": { + "profileDigest": "sha256:8344a03c82d5cb9207910fe0cb05ef0757b58b8cea9a7cc82f36055694d58983", + "taskDigest": "sha256:3ce61a40a3cd4e7f777080a96e3df6dac9a740154c2d4ea7c6893f65eab607d5" + } + } + }, + "session": { + "id": "6f4d3c33-8485-4915-b3ae-054f9fe66b45", + "backend": "executor", + "status": "active", + "createdAt": "2026-08-15T00:50:44.958Z", + "updatedAt": "2026-08-15T00:50:44.958Z", + "metadata": "[unavailable]" + }, + "backend": "router", + "metadata": { + "identity": { + "profileDigest": "sha256:8344a03c82d5cb9207910fe0cb05ef0757b58b8cea9a7cc82f36055694d58983", + "taskDigest": "sha256:3ce61a40a3cd4e7f777080a96e3df6dac9a740154c2d4ea7c6893f65eab607d5" + }, + "materialization": { + "status": "known", + "authoredProfileDigest": "sha256:8344a03c82d5cb9207910fe0cb05ef0757b58b8cea9a7cc82f36055694d58983", + "effectiveProfileDigest": "sha256:8344a03c82d5cb9207910fe0cb05ef0757b58b8cea9a7cc82f36055694d58983", + "materializationPlanDigest": "sha256:00acc9b6378f74a84629ea6c26f6e800d054bc04588a1daba8d1924b79792478", + "runtime": "router", + "backend": "router", + "model": { + "status": "known", + "id": "fixture/deterministic" + }, + "execution": { + "kind": "request", + "id": "turn-bd010abd-f9a8-4d8d-8b2f-54966627b1a2" + }, + "materializer": "router-prompt-model" + }, + "executionBindings": [ + { + "status": "known", + "attemptId": "turn-bd010abd-f9a8-4d8d-8b2f-54966627b1a2:attempt:4f2c8d0a-f5f1-4ac1-81c3-26c0877fe0b6", + "materializationReceiptDigest": "sha256:1a98867b5e312dc1b940da7353d26997a9cc2cb26d66148b192b5088749be51c", + "bindingDigest": "sha256:c4996bfe068387967b304fe000288433af6c0385a763096943a800215cefb498", + "descriptor": { + "kind": "router-request", + "transport": "http", + "backend": "router" + } + } + ], + "budget": { + "timeoutMs": null + }, + "timing": { + "startedAt": "2026-08-15T00:50:44.956Z" + } + }, + "timestamp": "2026-08-15T00:50:44.958Z" + } + } + } + ] + } +} diff --git a/artifacts/verification/tui-audit/after/w6/states/active-streaming.png b/artifacts/verification/tui-audit/after/w6/states/active-streaming.png new file mode 100644 index 0000000..9f9ade4 Binary files /dev/null and b/artifacts/verification/tui-audit/after/w6/states/active-streaming.png differ diff --git a/artifacts/verification/tui-audit/after/w6/states/active-streaming.txt b/artifacts/verification/tui-audit/after/w6/states/active-streaming.txt new file mode 100644 index 0000000..5aab693 --- /dev/null +++ b/artifacts/verification/tui-audit/after/w6/states/active-streaming.txt @@ -0,0 +1,24 @@ + › W6 active streaming + + working… + + + + + + + + + + + + + + + + + +› queue #1 + + +profile Braid starter working · Ctrl+C cancel · Enter queues diff --git a/artifacts/verification/tui-audit/after/w6/states/analysis.json b/artifacts/verification/tui-audit/after/w6/states/analysis.json new file mode 100644 index 0000000..34dcad5 --- /dev/null +++ b/artifacts/verification/tui-audit/after/w6/states/analysis.json @@ -0,0 +1,422 @@ +{ + "schemaVersion": 2, + "state": "analysis", + "source": { + "binary": "packed real binary from clean npm install", + "binarySha256": "141288e0fe917635d723b4b70d464dc49baff14cc356f54de1d3f8faa5d8254f", + "tarball": "tangle-network-braid-0.1.1.tgz", + "tarballSha256": "b2c0571a7f7d0d6e847d493667dfed6ff11b52eefb5e5f43cb13470eea3562c7" + }, + "dimensions": { + "columns": 80, + "rows": 24 + }, + "terminal": "node-pty/xterm-256color", + "provenance": { + "renderer": { + "package": "@earendil-works/pi-tui@0.84.2", + "pty": "node-pty@1.1.0", + "emulator": "@xterm/headless@6.0.0", + "node": "v24.13.0", + "terminal": "xterm-256color" + }, + "raster": { + "agg": "agg 1.9.0", + "imagemagick": "Version: ImageMagick 6.9.12-98 Q16 x86_64 18038 https://legacy.imagemagick.org", + "fontFamily": "DejaVu Sans Mono", + "font": "DejaVu Sans Mono | Book | /usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", + "colorMode": "sRGB 8-bit" + } + }, + "capturePhase": "atomic-signal-frame", + "captureRevision": 1, + "frame": " analyses › /ask · frozen question Braid starter · pi · fixture/deterministic\n────────────────────────────────────────────────────────────────────────────────\n source: da8ae2345b2c17a…7c9190eb · frozen\n analyst: profile-trace-reviewer · completed\n ── question\n Where did this run waste time, and what should change?\n ── findings\n 1. [1] Profile inspection and route resolution ran serially although their\n inputs were independent. · medium · confidence 0.94\n ↳ [1] profile.inspect completed before runtime.route started · event\n event-runtime-r…te-start\n 2. [2] The answer claimed success before the packaged terminal check completed.\n · high · confidence 0.88\n ↳ [2] package check started; no terminal result was recorded · event\n event-package-c…ck-start\n ── next\n next: ask a narrower question, or use /compare against another\n frozen run\n ── model use\n Model calls were not reported.\n ── run receipt\n source complete: yes\n────────────────────────────────────────────────────────────────────────────────\n ↑↓ previous/next · PgUp/PgDn page · ←/esc back · page 1/2 · tab filter: analys…\n", + "packedState": { + "schemaVersion": 2, + "capturePhase": "atomic-signal-frame", + "state": { + "schemaVersion": 2, + "revision": 1, + "sequence": 1, + "workspace": "/home/drew/code/braid-goal-latest-wt", + "conversationId": "conv-1", + "branchId": "branch-1", + "conversations": [ + { + "id": "conv-1", + "title": "New conversation", + "branchId": "branch-1", + "archived": false, + "updatedAt": "2026-08-01T00:00:00.000Z" + } + ], + "profile": { + "name": "Braid starter", + "description": "A portable starter profile for the Braid terminal", + "harness": "pi", + "model": { + "default": "fixture/deterministic", + "provider": "fixture", + "reasoningEffort": "none" + } + }, + "runConfiguration": { + "profileName": "Braid starter", + "runner": "pi", + "model": "fixture/deterministic", + "effort": "none", + "overrides": {} + }, + "draft": "", + "messages": [], + "runs": [], + "sessionUsage": { + "turns": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "analyses": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "delegated": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "attribution": "complete" + }, + "environments": [], + "interactions": [], + "queue": [], + "activeRunId": null, + "lastError": null + }, + "view": { + "revision": 1, + "workspace": "/home/drew/code/braid-goal-latest-wt", + "profileName": "Braid starter", + "profileDigest": "44ed91c937fd4079fe01c49f6bc76413656886e5aa8b2351590d3a888cfa0a14", + "runner": "pi", + "model": "fixture/deterministic", + "effort": "none", + "runOverrides": {}, + "connection": "deterministic fixture", + "conversationId": "conv-1", + "conversationTitle": "New conversation", + "conversations": [ + { + "id": "conv-1", + "title": "New conversation", + "branchId": "branch-1", + "archived": false, + "active": true, + "updatedAt": "2026-08-01T00:00:00.000Z" + } + ], + "branch": "branch-1", + "status": "empty", + "statusText": "Analysis complete: 2 cited findings", + "queueCount": 0, + "queue": [], + "messages": [], + "hiddenMessageCount": 0, + "runs": [], + "sessionUsage": { + "turns": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "analyses": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "delegated": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "attribution": "complete" + }, + "environments": [], + "interactions": [], + "activity": [ + { + "id": "analysis:analysis-fixture-1", + "kind": "analysis", + "title": "/ask · frozen question", + "status": "complete", + "detail": "Where did this run waste time, and what should change?", + "occurredAt": "2026-08-04T04:00:01.240Z", + "runId": "run-cli-bridge-reconnect", + "entityType": "analysis", + "entityId": "analysis-fixture-1" + } + ], + "graph": [ + { + "id": "conv-1", + "type": "conversation", + "title": "New conversation", + "status": "complete", + "depth": 0, + "edgeLabel": "attached", + "startedAt": "2026-08-01T00:00:00.000Z" + }, + { + "id": "branch-1", + "type": "branch", + "title": "Main", + "status": "complete", + "depth": 1, + "edgeLabel": "attached", + "parentIds": [ + "conversation:conv-1" + ], + "startedAt": "2026-08-01T00:00:00.000Z" + } + ], + "graphQuery": "", + "entityDetails": [ + { + "entityType": "analysis", + "entityId": "analysis-fixture-1", + "title": "/ask · frozen question", + "status": "completed", + "lines": [ + "source: da8ae2345b2c17a…7c9190eb · frozen", + "analyst: profile-trace-reviewer · completed", + "── question", + "Where did this run waste time, and what should change?", + "── findings", + "1. [1] Profile inspection and route resolution ran serially although their inputs were independent. · medium · confidence 0.94", + "↳ [1] profile.inspect completed before runtime.route started · event event-runtime-r…te-start", + "2. [2] The answer claimed success before the packaged terminal check completed. · high · confidence 0.88", + "↳ [2] package check started; no terminal result was recorded · event event-package-c…ck-start", + "── next", + "next: ask a narrower question, or use /compare against another frozen run", + "── model use", + "Model calls were not reported.", + "── run receipt", + "source complete: yes", + "findings: 2", + "analysis cost: $0.0048", + "analysis time: 1240ms" + ], + "analysisFindingCount": 2, + "analysisSupportedFindingCount": 2, + "analysisCitationSupport": "unavailable", + "analysisExecution": { + "observedModels": [], + "wallTimeMs": 1240 + } + } + ], + "capabilities": { + "conversation.create": { + "available": true, + "source": "application" + }, + "conversation.open": { + "available": true, + "source": "application" + }, + "run.runner": { + "available": true, + "source": "application" + }, + "run.model": { + "available": true, + "source": "application" + }, + "run.effort": { + "available": true, + "source": "application" + }, + "conversation.branch": { + "available": true, + "source": "application" + }, + "conversation.clone": { + "available": true, + "source": "application" + }, + "conversation.fork": { + "available": true, + "source": "application" + }, + "analysis.ask": { + "available": true, + "source": "application" + }, + "analysis.recipe": { + "available": false, + "source": "application", + "reason": "Complete or fail a run before analyzing it" + }, + "analysis.compare": { + "available": false, + "source": "application", + "reason": "Two completed or failed runs are required for comparison" + }, + "interaction.respond": { + "available": false, + "source": "runtime", + "reason": "Interaction response is not exposed by the current runtime adapter" + }, + "interaction.automation": { + "available": false, + "source": "application", + "reason": "Interaction automation requires the shared response contract" + }, + "export.create": { + "available": true, + "source": "application" + }, + "application.quit": { + "available": true, + "source": "local" + }, + "help.read": { + "available": true, + "source": "local" + }, + "profile.select": { + "available": true, + "source": "application" + }, + "connection.select": { + "available": true, + "source": "application" + }, + "settings.open": { + "available": true, + "source": "application" + }, + "activity.read": { + "available": true, + "source": "local" + }, + "graph.read": { + "available": true, + "source": "local" + }, + "details.read": { + "available": true, + "source": "local" + }, + "draft.write": { + "available": true, + "source": "application" + }, + "run.send": { + "available": true, + "source": "provider" + }, + "run.cancel": { + "available": false, + "source": "runtime", + "reason": "There is no active run to cancel" + }, + "run.queue": { + "available": false, + "source": "provider", + "reason": "There is no active run" + }, + "run.steer": { + "available": false, + "source": "provider", + "reason": "There is no active run" + }, + "run.detach": { + "available": false, + "source": "provider", + "reason": "There is no active run" + }, + "run.reconnect": { + "available": false, + "source": "provider", + "reason": "There is no detached run to reconnect" + }, + "run.reconcile": { + "available": false, + "source": "provider", + "reason": "There is no detached run to reconcile" + } + }, + "draft": "", + "selectedSurface": "transcript", + "appearance": { + "color": "256", + "highContrast": false, + "reducedMotion": false + }, + "notice": "Analysis complete: 2 cited findings" + }, + "events": [ + { + "sequence": 1, + "revision": 1, + "kind": "workspace.opened", + "payload": { + "value": { + "kind": "workspace.opened", + "workspace": "/home/drew/code/braid-goal-latest-wt" + } + } + } + ] + } +} diff --git a/artifacts/verification/tui-audit/after/w6/states/analysis.png b/artifacts/verification/tui-audit/after/w6/states/analysis.png new file mode 100644 index 0000000..fcfb99c Binary files /dev/null and b/artifacts/verification/tui-audit/after/w6/states/analysis.png differ diff --git a/artifacts/verification/tui-audit/after/w6/states/analysis.txt b/artifacts/verification/tui-audit/after/w6/states/analysis.txt new file mode 100644 index 0000000..ef8599d --- /dev/null +++ b/artifacts/verification/tui-audit/after/w6/states/analysis.txt @@ -0,0 +1,24 @@ + analyses › /ask · frozen question Braid starter · pi · fixture/deterministic +──────────────────────────────────────────────────────────────────────────────── + source: da8ae2345b2c17a…7c9190eb · frozen + analyst: profile-trace-reviewer · completed + ── question + Where did this run waste time, and what should change? + ── findings + 1. [1] Profile inspection and route resolution ran serially although their + inputs were independent. · medium · confidence 0.94 + ↳ [1] profile.inspect completed before runtime.route started · event + event-runtime-r…te-start + 2. [2] The answer claimed success before the packaged terminal check completed. + · high · confidence 0.88 + ↳ [2] package check started; no terminal result was recorded · event + event-package-c…ck-start + ── next + next: ask a narrower question, or use /compare against another + frozen run + ── model use + Model calls were not reported. + ── run receipt + source complete: yes +──────────────────────────────────────────────────────────────────────────────── + ↑↓ previous/next · PgUp/PgDn page · ←/esc back · page 1/2 · tab filter: analys… diff --git a/artifacts/verification/tui-audit/after/w6/states/automation.json b/artifacts/verification/tui-audit/after/w6/states/automation.json new file mode 100644 index 0000000..97a9b1c --- /dev/null +++ b/artifacts/verification/tui-audit/after/w6/states/automation.json @@ -0,0 +1,377 @@ +{ + "schemaVersion": 2, + "state": "automation", + "source": { + "binary": "packed real binary from clean npm install", + "binarySha256": "141288e0fe917635d723b4b70d464dc49baff14cc356f54de1d3f8faa5d8254f", + "tarball": "tangle-network-braid-0.1.1.tgz", + "tarballSha256": "b2c0571a7f7d0d6e847d493667dfed6ff11b52eefb5e5f43cb13470eea3562c7" + }, + "dimensions": { + "columns": 80, + "rows": 24 + }, + "terminal": "node-pty/xterm-256color", + "provenance": { + "renderer": { + "package": "@earendil-works/pi-tui@0.84.2", + "pty": "node-pty@1.1.0", + "emulator": "@xterm/headless@6.0.0", + "node": "v24.13.0", + "terminal": "xterm-256color" + }, + "raster": { + "agg": "agg 1.9.0", + "imagemagick": "Version: ImageMagick 6.9.12-98 Q16 x86_64 18038 https://legacy.imagemagick.org", + "fontFamily": "DejaVu Sans Mono", + "font": "DejaVu Sans Mono | Book | /usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", + "colorMode": "sRGB 8-bit" + } + }, + "capturePhase": "atomic-signal-frame", + "captureRevision": 1, + "frame": "\n Ask Braid starter anything.\n\n\n\n\n\n\n\n automation rules\n ──────────────────────────────────────────────────────────────────────\n No saved automation rules\n ──────────────────────────────────────────────────────────────────────\n create from a pending request with Alt+A · esc close\n\n\n\n\n\n\n\n\n\nNo automation rules\n", + "packedState": { + "schemaVersion": 2, + "capturePhase": "atomic-signal-frame", + "state": { + "schemaVersion": 2, + "revision": 1, + "sequence": 1, + "workspace": "/home/drew/code/braid-goal-latest-wt", + "conversationId": "conv-1", + "branchId": "branch-1", + "conversations": [ + { + "id": "conv-1", + "title": "New conversation", + "branchId": "branch-1", + "archived": false, + "updatedAt": "2026-08-01T00:00:00.000Z" + } + ], + "profile": { + "name": "Braid starter", + "description": "A portable starter profile for the Braid terminal", + "harness": "pi", + "model": { + "default": "fixture/deterministic", + "provider": "fixture", + "reasoningEffort": "none" + } + }, + "runConfiguration": { + "profileName": "Braid starter", + "runner": "pi", + "model": "fixture/deterministic", + "effort": "none", + "overrides": {} + }, + "draft": "", + "messages": [], + "runs": [], + "sessionUsage": { + "turns": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "analyses": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "delegated": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "attribution": "complete" + }, + "environments": [], + "interactions": [], + "queue": [], + "activeRunId": null, + "lastError": null + }, + "view": { + "revision": 1, + "workspace": "/home/drew/code/braid-goal-latest-wt", + "profileName": "Braid starter", + "profileDigest": "44ed91c937fd4079fe01c49f6bc76413656886e5aa8b2351590d3a888cfa0a14", + "runner": "pi", + "model": "fixture/deterministic", + "effort": "none", + "runOverrides": {}, + "connection": "deterministic fixture", + "conversationId": "conv-1", + "conversationTitle": "New conversation", + "conversations": [ + { + "id": "conv-1", + "title": "New conversation", + "branchId": "branch-1", + "archived": false, + "active": true, + "updatedAt": "2026-08-01T00:00:00.000Z" + } + ], + "branch": "branch-1", + "status": "empty", + "statusText": "No automation rules", + "queueCount": 0, + "queue": [], + "messages": [], + "hiddenMessageCount": 0, + "runs": [], + "sessionUsage": { + "turns": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "analyses": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "delegated": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "attribution": "complete" + }, + "environments": [], + "interactions": [], + "activity": [], + "graph": [ + { + "id": "conv-1", + "type": "conversation", + "title": "New conversation", + "status": "complete", + "depth": 0, + "edgeLabel": "attached", + "startedAt": "2026-08-01T00:00:00.000Z" + }, + { + "id": "branch-1", + "type": "branch", + "title": "Main", + "status": "complete", + "depth": 1, + "edgeLabel": "attached", + "parentIds": [ + "conversation:conv-1" + ], + "startedAt": "2026-08-01T00:00:00.000Z" + } + ], + "graphQuery": "", + "entityDetails": [], + "capabilities": { + "conversation.create": { + "available": true, + "source": "application" + }, + "conversation.open": { + "available": true, + "source": "application" + }, + "run.runner": { + "available": true, + "source": "application" + }, + "run.model": { + "available": true, + "source": "application" + }, + "run.effort": { + "available": true, + "source": "application" + }, + "conversation.branch": { + "available": true, + "source": "application" + }, + "conversation.clone": { + "available": true, + "source": "application" + }, + "conversation.fork": { + "available": true, + "source": "application" + }, + "analysis.ask": { + "available": false, + "source": "application", + "reason": "Complete or fail a run before analyzing it" + }, + "analysis.recipe": { + "available": false, + "source": "application", + "reason": "Complete or fail a run before analyzing it" + }, + "analysis.compare": { + "available": false, + "source": "application", + "reason": "Two completed or failed runs are required for comparison" + }, + "interaction.respond": { + "available": false, + "source": "runtime", + "reason": "Interaction response is not exposed by the current runtime adapter" + }, + "interaction.automation": { + "available": false, + "source": "application", + "reason": "Interaction automation requires the shared response contract" + }, + "export.create": { + "available": true, + "source": "application" + }, + "application.quit": { + "available": true, + "source": "local" + }, + "help.read": { + "available": true, + "source": "local" + }, + "profile.select": { + "available": true, + "source": "application" + }, + "connection.select": { + "available": true, + "source": "application" + }, + "settings.open": { + "available": true, + "source": "application" + }, + "activity.read": { + "available": true, + "source": "local" + }, + "graph.read": { + "available": true, + "source": "local" + }, + "details.read": { + "available": true, + "source": "local" + }, + "draft.write": { + "available": true, + "source": "application" + }, + "run.send": { + "available": true, + "source": "provider" + }, + "run.cancel": { + "available": false, + "source": "runtime", + "reason": "There is no active run to cancel" + }, + "run.queue": { + "available": false, + "source": "provider", + "reason": "There is no active run" + }, + "run.steer": { + "available": false, + "source": "provider", + "reason": "There is no active run" + }, + "run.detach": { + "available": false, + "source": "provider", + "reason": "There is no active run" + }, + "run.reconnect": { + "available": false, + "source": "provider", + "reason": "There is no detached run to reconnect" + }, + "run.reconcile": { + "available": false, + "source": "provider", + "reason": "There is no detached run to reconcile" + } + }, + "draft": "", + "selectedSurface": "transcript", + "appearance": { + "color": "256", + "highContrast": false, + "reducedMotion": false + }, + "notice": "No automation rules" + }, + "events": [ + { + "sequence": 1, + "revision": 1, + "kind": "workspace.opened", + "payload": { + "value": { + "kind": "workspace.opened", + "workspace": "/home/drew/code/braid-goal-latest-wt" + } + } + } + ] + } +} diff --git a/artifacts/verification/tui-audit/after/w6/states/automation.png b/artifacts/verification/tui-audit/after/w6/states/automation.png new file mode 100644 index 0000000..f86bba8 Binary files /dev/null and b/artifacts/verification/tui-audit/after/w6/states/automation.png differ diff --git a/artifacts/verification/tui-audit/after/w6/states/automation.txt b/artifacts/verification/tui-audit/after/w6/states/automation.txt new file mode 100644 index 0000000..4bc0c2a --- /dev/null +++ b/artifacts/verification/tui-audit/after/w6/states/automation.txt @@ -0,0 +1,24 @@ + + Ask Braid starter anything. + + + + + + + + automation rules + ────────────────────────────────────────────────────────────────────── + No saved automation rules + ────────────────────────────────────────────────────────────────────── + create from a pending request with Alt+A · esc close + + + + + + + + + +No automation rules diff --git a/artifacts/verification/tui-audit/after/w6/states/comparison.json b/artifacts/verification/tui-audit/after/w6/states/comparison.json new file mode 100644 index 0000000..56cfdef --- /dev/null +++ b/artifacts/verification/tui-audit/after/w6/states/comparison.json @@ -0,0 +1,464 @@ +{ + "schemaVersion": 2, + "state": "comparison", + "source": { + "binary": "packed real binary from clean npm install", + "binarySha256": "141288e0fe917635d723b4b70d464dc49baff14cc356f54de1d3f8faa5d8254f", + "tarball": "tangle-network-braid-0.1.1.tgz", + "tarballSha256": "b2c0571a7f7d0d6e847d493667dfed6ff11b52eefb5e5f43cb13470eea3562c7" + }, + "dimensions": { + "columns": 80, + "rows": 24 + }, + "terminal": "node-pty/xterm-256color", + "provenance": { + "renderer": { + "package": "@earendil-works/pi-tui@0.84.2", + "pty": "node-pty@1.1.0", + "emulator": "@xterm/headless@6.0.0", + "node": "v24.13.0", + "terminal": "xterm-256color" + }, + "raster": { + "agg": "agg 1.9.0", + "imagemagick": "Version: ImageMagick 6.9.12-98 Q16 x86_64 18038 https://legacy.imagemagick.org", + "fontFamily": "DejaVu Sans Mono", + "font": "DejaVu Sans Mono | Book | /usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", + "colorMode": "sRGB 8-bit" + } + }, + "capturePhase": "atomic-signal-frame", + "captureRevision": 1, + "frame": " analyses › /compare · frozen runs Braid starter · pi · fixture/deterministic\n────────────────────────────────────────────────────────────────────────────────\n saved result: replayed from the local journal\n baseline run: run-route-serial\n baseline source: 9ee7d219c8b0092…8c519924\n baseline outcome: completed · cost: 0.014 · source: frozen run field\n run.cost_usd\n candidate run: run-route-parallel\n candidate source: 0328e6889082b14…86b0ff3d\n candidate outcome: completed · cost: 0.009 · source: frozen run field\n run.cost_usd\n sample: 1 paired · 0 baseline-only · 0 candidate-only\n ! One pair is descriptive; it cannot support a reliable general conclusion.\n captured fields · baseline | candidate | availability\n run.status: completed | completed | none\n run.cost_usd: 0.014 | 0.009 | none\n run.wall_time_ms: 18200 | 12100 | none\n run.input_tokens: 7420 | 6910 | none\n paired measurements · candidate minus baseline where applicable\n paired.correctness.b01: 0\n paired.correctness.b10: 0\n paired.correctness.mcnemar.b: 0\n────────────────────────────────────────────────────────────────────────────────\n ↑↓ previous/next · PgUp/PgDn page · ←/esc back · page 1/4 · tab filter: analys…\n", + "packedState": { + "schemaVersion": 2, + "capturePhase": "atomic-signal-frame", + "state": { + "schemaVersion": 2, + "revision": 1, + "sequence": 1, + "workspace": "/home/drew/code/braid-goal-latest-wt", + "conversationId": "conv-1", + "branchId": "branch-1", + "conversations": [ + { + "id": "conv-1", + "title": "New conversation", + "branchId": "branch-1", + "archived": false, + "updatedAt": "2026-08-01T00:00:00.000Z" + } + ], + "profile": { + "name": "Braid starter", + "description": "A portable starter profile for the Braid terminal", + "harness": "pi", + "model": { + "default": "fixture/deterministic", + "provider": "fixture", + "reasoningEffort": "none" + } + }, + "runConfiguration": { + "profileName": "Braid starter", + "runner": "pi", + "model": "fixture/deterministic", + "effort": "none", + "overrides": {} + }, + "draft": "", + "messages": [], + "runs": [], + "sessionUsage": { + "turns": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "analyses": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "delegated": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "attribution": "complete" + }, + "environments": [], + "interactions": [], + "queue": [], + "activeRunId": null, + "lastError": null + }, + "view": { + "revision": 1, + "workspace": "/home/drew/code/braid-goal-latest-wt", + "profileName": "Braid starter", + "profileDigest": "44ed91c937fd4079fe01c49f6bc76413656886e5aa8b2351590d3a888cfa0a14", + "runner": "pi", + "model": "fixture/deterministic", + "effort": "none", + "runOverrides": {}, + "connection": "deterministic fixture", + "conversationId": "conv-1", + "conversationTitle": "New conversation", + "conversations": [ + { + "id": "conv-1", + "title": "New conversation", + "branchId": "branch-1", + "archived": false, + "active": true, + "updatedAt": "2026-08-01T00:00:00.000Z" + } + ], + "branch": "branch-1", + "status": "empty", + "statusText": "Comparison complete: 1 paired run", + "queueCount": 0, + "queue": [], + "messages": [], + "hiddenMessageCount": 0, + "runs": [], + "sessionUsage": { + "turns": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "analyses": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "delegated": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "attribution": "complete" + }, + "environments": [], + "interactions": [], + "activity": [ + { + "id": "analysis:analysis-fixture-comparison", + "kind": "analysis", + "title": "/compare · frozen runs", + "status": "complete", + "entityType": "analysis", + "entityId": "analysis-fixture-comparison" + } + ], + "graph": [ + { + "id": "conv-1", + "type": "conversation", + "title": "New conversation", + "status": "complete", + "depth": 0, + "edgeLabel": "attached", + "startedAt": "2026-08-01T00:00:00.000Z" + }, + { + "id": "branch-1", + "type": "branch", + "title": "Main", + "status": "complete", + "depth": 1, + "edgeLabel": "attached", + "parentIds": [ + "conversation:conv-1" + ], + "startedAt": "2026-08-01T00:00:00.000Z" + } + ], + "graphQuery": "", + "entityDetails": [ + { + "entityType": "analysis", + "entityId": "analysis-fixture-comparison", + "title": "/compare · frozen runs", + "status": "completed", + "lines": [ + "saved result: replayed from the local journal", + "baseline run: run-route-serial", + "baseline source: 9ee7d219c8b0092…8c519924", + "baseline outcome: completed · cost: 0.014 · source: frozen run field run.cost_usd", + "candidate run: run-route-parallel", + "candidate source: 0328e6889082b14…86b0ff3d", + "candidate outcome: completed · cost: 0.009 · source: frozen run field run.cost_usd", + "sample: 1 paired · 0 baseline-only · 0 candidate-only", + "! One pair is descriptive; it cannot support a reliable general conclusion.", + "captured fields · baseline | candidate | availability", + "run.status: completed | completed | none", + "run.cost_usd: 0.014 | 0.009 | none", + "run.wall_time_ms: 18200 | 12100 | none", + "run.input_tokens: 7420 | 6910 | none", + "paired measurements · candidate minus baseline where applicable", + "paired.correctness.b01: 0", + "paired.correctness.b10: 0", + "paired.correctness.mcnemar.b: 0", + "paired.correctness.mcnemar.c: 0", + "paired.correctness.mcnemar.n: 1", + "paired.correctness.mcnemar.nDiscordant: 0", + "paired.correctness.mcnemar.pValue: 1", + "paired.correctness.mcnemar.statistic: 0", + "paired.correctness.riskDifference.b: 0", + "paired.correctness.riskDifference.c: 0", + "paired.correctness.riskDifference.confidence: 0.95", + "paired.correctness.riskDifference.lower: 0", + "paired.correctness.riskDifference.n: 1", + "paired.correctness.riskDifference.riskDifference: 0", + "paired.correctness.riskDifference.upper: 0", + "paired.metricDeltas[0].bootstrapCi.confidence: 0.95", + "paired.metricDeltas[0].bootstrapCi.gateEligible: false", + "paired.metricDeltas[0].bootstrapCi.high: -0.005", + "paired.metricDeltas[0].bootstrapCi.low: -0.005", + "paired.metricDeltas[0].bootstrapCi.mean: -0.005", + "paired.metricDeltas[0].bootstrapCi.median: -0.005", + "paired.metricDeltas[0].bootstrapCi.n: 1", + "paired.metricDeltas[0].bootstrapCi.resamples: 2000", + "paired.metricDeltas[0].meanDelta: -0.005", + "paired.metricDeltas[0].medianDelta: -0.005", + "paired.metricDeltas[0].n: 1", + "paired.metricDeltas[0].name: cost_usd", + "paired.metricDeltas[0].nMissing: 0", + "paired.metricDeltas[0].wilcoxon.method: exact", + "paired.metricDeltas[0].wilcoxon.nNonZero: 1", + "paired.metricDeltas[0].wilcoxon.p: 1", + "paired.metricDeltas[0].wilcoxon.pFloor: 1", + "paired.metricDeltas[0].wilcoxon.w: 0", + "paired.metricDeltas[1].bootstrapCi.confidence: 0.95", + "paired.metricDeltas[1].bootstrapCi.gateEligible: false", + "paired.metricDeltas[1].bootstrapCi.high: -6100", + "paired.metricDeltas[1].bootstrapCi.low: -6100", + "paired.metricDeltas[1].bootstrapCi.mean: -6100", + "paired.metricDeltas[1].bootstrapCi.median: -6100", + "paired.metricDeltas[1].bootstrapCi.n: 1", + "paired.metricDeltas[1].bootstrapCi.resamples: 2000", + "paired.metricDeltas[1].meanDelta: -6100", + "paired.metricDeltas[1].medianDelta: -6100", + "paired.metricDeltas[1].n: 1", + "paired.metricDeltas[1].name: latency_ms", + "paired.metricDeltas[1].nMissing: 0", + "paired.metricDeltas[1].wilcoxon.method: exact", + "paired.metricDeltas[1].wilcoxon.nNonZero: 1", + "paired.metricDeltas[1].wilcoxon.p: 1", + "paired.metricDeltas[1].wilcoxon.pFloor: 1", + "paired.metricDeltas[1].wilcoxon.w: 0", + "paired.nPairs: 1", + "paired.nUnpairedBaseline: 0", + "paired.nUnpairedTreatment: 0", + "semantic review: unavailable · One saved pair is descriptive; semantic review was not requested." + ] + } + ], + "capabilities": { + "conversation.create": { + "available": true, + "source": "application" + }, + "conversation.open": { + "available": true, + "source": "application" + }, + "run.runner": { + "available": true, + "source": "application" + }, + "run.model": { + "available": true, + "source": "application" + }, + "run.effort": { + "available": true, + "source": "application" + }, + "conversation.branch": { + "available": true, + "source": "application" + }, + "conversation.clone": { + "available": true, + "source": "application" + }, + "conversation.fork": { + "available": true, + "source": "application" + }, + "analysis.ask": { + "available": false, + "source": "application", + "reason": "Complete or fail a run before analyzing it" + }, + "analysis.recipe": { + "available": false, + "source": "application", + "reason": "Complete or fail a run before analyzing it" + }, + "analysis.compare": { + "available": true, + "source": "application" + }, + "interaction.respond": { + "available": false, + "source": "runtime", + "reason": "Interaction response is not exposed by the current runtime adapter" + }, + "interaction.automation": { + "available": false, + "source": "application", + "reason": "Interaction automation requires the shared response contract" + }, + "export.create": { + "available": true, + "source": "application" + }, + "application.quit": { + "available": true, + "source": "local" + }, + "help.read": { + "available": true, + "source": "local" + }, + "profile.select": { + "available": true, + "source": "application" + }, + "connection.select": { + "available": true, + "source": "application" + }, + "settings.open": { + "available": true, + "source": "application" + }, + "activity.read": { + "available": true, + "source": "local" + }, + "graph.read": { + "available": true, + "source": "local" + }, + "details.read": { + "available": true, + "source": "local" + }, + "draft.write": { + "available": true, + "source": "application" + }, + "run.send": { + "available": true, + "source": "provider" + }, + "run.cancel": { + "available": false, + "source": "runtime", + "reason": "There is no active run to cancel" + }, + "run.queue": { + "available": false, + "source": "provider", + "reason": "There is no active run" + }, + "run.steer": { + "available": false, + "source": "provider", + "reason": "There is no active run" + }, + "run.detach": { + "available": false, + "source": "provider", + "reason": "There is no active run" + }, + "run.reconnect": { + "available": false, + "source": "provider", + "reason": "There is no detached run to reconnect" + }, + "run.reconcile": { + "available": false, + "source": "provider", + "reason": "There is no detached run to reconcile" + } + }, + "draft": "", + "selectedSurface": "transcript", + "appearance": { + "color": "256", + "highContrast": false, + "reducedMotion": false + }, + "notice": "Comparison complete: 1 paired run" + }, + "events": [ + { + "sequence": 1, + "revision": 1, + "kind": "workspace.opened", + "payload": { + "value": { + "kind": "workspace.opened", + "workspace": "/home/drew/code/braid-goal-latest-wt" + } + } + } + ] + } +} diff --git a/artifacts/verification/tui-audit/after/w6/states/comparison.png b/artifacts/verification/tui-audit/after/w6/states/comparison.png new file mode 100644 index 0000000..446e504 Binary files /dev/null and b/artifacts/verification/tui-audit/after/w6/states/comparison.png differ diff --git a/artifacts/verification/tui-audit/after/w6/states/comparison.txt b/artifacts/verification/tui-audit/after/w6/states/comparison.txt new file mode 100644 index 0000000..0d672f8 --- /dev/null +++ b/artifacts/verification/tui-audit/after/w6/states/comparison.txt @@ -0,0 +1,24 @@ + analyses › /compare · frozen runs Braid starter · pi · fixture/deterministic +──────────────────────────────────────────────────────────────────────────────── + saved result: replayed from the local journal + baseline run: run-route-serial + baseline source: 9ee7d219c8b0092…8c519924 + baseline outcome: completed · cost: 0.014 · source: frozen run field + run.cost_usd + candidate run: run-route-parallel + candidate source: 0328e6889082b14…86b0ff3d + candidate outcome: completed · cost: 0.009 · source: frozen run field + run.cost_usd + sample: 1 paired · 0 baseline-only · 0 candidate-only + ! One pair is descriptive; it cannot support a reliable general conclusion. + captured fields · baseline | candidate | availability + run.status: completed | completed | none + run.cost_usd: 0.014 | 0.009 | none + run.wall_time_ms: 18200 | 12100 | none + run.input_tokens: 7420 | 6910 | none + paired measurements · candidate minus baseline where applicable + paired.correctness.b01: 0 + paired.correctness.b10: 0 + paired.correctness.mcnemar.b: 0 +──────────────────────────────────────────────────────────────────────────────── + ↑↓ previous/next · PgUp/PgDn page · ←/esc back · page 1/4 · tab filter: analys… diff --git a/artifacts/verification/tui-audit/after/w6/states/empty.json b/artifacts/verification/tui-audit/after/w6/states/empty.json new file mode 100644 index 0000000..07c652b --- /dev/null +++ b/artifacts/verification/tui-audit/after/w6/states/empty.json @@ -0,0 +1,376 @@ +{ + "schemaVersion": 2, + "state": "empty", + "source": { + "binary": "packed real binary from clean npm install", + "binarySha256": "141288e0fe917635d723b4b70d464dc49baff14cc356f54de1d3f8faa5d8254f", + "tarball": "tangle-network-braid-0.1.1.tgz", + "tarballSha256": "b2c0571a7f7d0d6e847d493667dfed6ff11b52eefb5e5f43cb13470eea3562c7" + }, + "dimensions": { + "columns": 80, + "rows": 24 + }, + "terminal": "node-pty/xterm-256color", + "provenance": { + "renderer": { + "package": "@earendil-works/pi-tui@0.84.2", + "pty": "node-pty@1.1.0", + "emulator": "@xterm/headless@6.0.0", + "node": "v24.13.0", + "terminal": "xterm-256color" + }, + "raster": { + "agg": "agg 1.9.0", + "imagemagick": "Version: ImageMagick 6.9.12-98 Q16 x86_64 18038 https://legacy.imagemagick.org", + "fontFamily": "DejaVu Sans Mono", + "font": "DejaVu Sans Mono | Book | /usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", + "colorMode": "sRGB 8-bit" + } + }, + "capturePhase": "atomic-signal-frame", + "captureRevision": 1, + "frame": "\n Ask Braid starter anything.\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n›\n\n\nprofile Braid starter · pi / deterministic · fixture Ctrl+P commands\n", + "packedState": { + "schemaVersion": 2, + "capturePhase": "atomic-signal-frame", + "state": { + "schemaVersion": 2, + "revision": 1, + "sequence": 1, + "workspace": "/home/drew/code/braid-goal-latest-wt", + "conversationId": "conv-1", + "branchId": "branch-1", + "conversations": [ + { + "id": "conv-1", + "title": "New conversation", + "branchId": "branch-1", + "archived": false, + "updatedAt": "2026-08-01T00:00:00.000Z" + } + ], + "profile": { + "name": "Braid starter", + "description": "A portable starter profile for the Braid terminal", + "harness": "pi", + "model": { + "default": "fixture/deterministic", + "provider": "fixture", + "reasoningEffort": "none" + } + }, + "runConfiguration": { + "profileName": "Braid starter", + "runner": "pi", + "model": "fixture/deterministic", + "effort": "none", + "overrides": {} + }, + "draft": "", + "messages": [], + "runs": [], + "sessionUsage": { + "turns": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "analyses": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "delegated": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "attribution": "complete" + }, + "environments": [], + "interactions": [], + "queue": [], + "activeRunId": null, + "lastError": null + }, + "view": { + "revision": 1, + "workspace": "/home/drew/code/braid-goal-latest-wt", + "profileName": "Braid starter", + "profileDigest": "44ed91c937fd4079fe01c49f6bc76413656886e5aa8b2351590d3a888cfa0a14", + "runner": "pi", + "model": "fixture/deterministic", + "effort": "none", + "runOverrides": {}, + "connection": "deterministic fixture", + "conversationId": "conv-1", + "conversationTitle": "New conversation", + "conversations": [ + { + "id": "conv-1", + "title": "New conversation", + "branchId": "branch-1", + "archived": false, + "active": true, + "updatedAt": "2026-08-01T00:00:00.000Z" + } + ], + "branch": "branch-1", + "status": "empty", + "statusText": "ready for a message", + "queueCount": 0, + "queue": [], + "messages": [], + "hiddenMessageCount": 0, + "runs": [], + "sessionUsage": { + "turns": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "analyses": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "delegated": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "attribution": "complete" + }, + "environments": [], + "interactions": [], + "activity": [], + "graph": [ + { + "id": "conv-1", + "type": "conversation", + "title": "New conversation", + "status": "complete", + "depth": 0, + "edgeLabel": "attached", + "startedAt": "2026-08-01T00:00:00.000Z" + }, + { + "id": "branch-1", + "type": "branch", + "title": "Main", + "status": "complete", + "depth": 1, + "edgeLabel": "attached", + "parentIds": [ + "conversation:conv-1" + ], + "startedAt": "2026-08-01T00:00:00.000Z" + } + ], + "graphQuery": "", + "entityDetails": [], + "capabilities": { + "conversation.create": { + "available": true, + "source": "application" + }, + "conversation.open": { + "available": true, + "source": "application" + }, + "run.runner": { + "available": true, + "source": "application" + }, + "run.model": { + "available": true, + "source": "application" + }, + "run.effort": { + "available": true, + "source": "application" + }, + "conversation.branch": { + "available": true, + "source": "application" + }, + "conversation.clone": { + "available": true, + "source": "application" + }, + "conversation.fork": { + "available": true, + "source": "application" + }, + "analysis.ask": { + "available": false, + "source": "application", + "reason": "Complete or fail a run before analyzing it" + }, + "analysis.recipe": { + "available": false, + "source": "application", + "reason": "Complete or fail a run before analyzing it" + }, + "analysis.compare": { + "available": false, + "source": "application", + "reason": "Two completed or failed runs are required for comparison" + }, + "interaction.respond": { + "available": false, + "source": "runtime", + "reason": "Interaction response is not exposed by the current runtime adapter" + }, + "interaction.automation": { + "available": false, + "source": "application", + "reason": "Interaction automation requires the shared response contract" + }, + "export.create": { + "available": true, + "source": "application" + }, + "application.quit": { + "available": true, + "source": "local" + }, + "help.read": { + "available": true, + "source": "local" + }, + "profile.select": { + "available": true, + "source": "application" + }, + "connection.select": { + "available": true, + "source": "application" + }, + "settings.open": { + "available": true, + "source": "application" + }, + "activity.read": { + "available": true, + "source": "local" + }, + "graph.read": { + "available": true, + "source": "local" + }, + "details.read": { + "available": true, + "source": "local" + }, + "draft.write": { + "available": true, + "source": "application" + }, + "run.send": { + "available": true, + "source": "provider" + }, + "run.cancel": { + "available": false, + "source": "runtime", + "reason": "There is no active run to cancel" + }, + "run.queue": { + "available": false, + "source": "provider", + "reason": "There is no active run" + }, + "run.steer": { + "available": false, + "source": "provider", + "reason": "There is no active run" + }, + "run.detach": { + "available": false, + "source": "provider", + "reason": "There is no active run" + }, + "run.reconnect": { + "available": false, + "source": "provider", + "reason": "There is no detached run to reconnect" + }, + "run.reconcile": { + "available": false, + "source": "provider", + "reason": "There is no detached run to reconcile" + } + }, + "draft": "", + "selectedSurface": "transcript", + "appearance": { + "color": "256", + "highContrast": false, + "reducedMotion": false + } + }, + "events": [ + { + "sequence": 1, + "revision": 1, + "kind": "workspace.opened", + "payload": { + "value": { + "kind": "workspace.opened", + "workspace": "/home/drew/code/braid-goal-latest-wt" + } + } + } + ] + } +} diff --git a/artifacts/verification/tui-audit/after/w6/states/empty.png b/artifacts/verification/tui-audit/after/w6/states/empty.png new file mode 100644 index 0000000..06bfe56 Binary files /dev/null and b/artifacts/verification/tui-audit/after/w6/states/empty.png differ diff --git a/artifacts/verification/tui-audit/after/w6/states/empty.txt b/artifacts/verification/tui-audit/after/w6/states/empty.txt new file mode 100644 index 0000000..71b7959 --- /dev/null +++ b/artifacts/verification/tui-audit/after/w6/states/empty.txt @@ -0,0 +1,24 @@ + + Ask Braid starter anything. + + + + + + + + + + + + + + + + + + +› + + +profile Braid starter · pi / deterministic · fixture Ctrl+P commands diff --git a/artifacts/verification/tui-audit/after/w6/states/failure-or-reconnect.json b/artifacts/verification/tui-audit/after/w6/states/failure-or-reconnect.json new file mode 100644 index 0000000..d121a46 --- /dev/null +++ b/artifacts/verification/tui-audit/after/w6/states/failure-or-reconnect.json @@ -0,0 +1,836 @@ +{ + "schemaVersion": 2, + "state": "failure-or-reconnect", + "source": { + "binary": "packed real binary from clean npm install", + "binarySha256": "141288e0fe917635d723b4b70d464dc49baff14cc356f54de1d3f8faa5d8254f", + "tarball": "tangle-network-braid-0.1.1.tgz", + "tarballSha256": "b2c0571a7f7d0d6e847d493667dfed6ff11b52eefb5e5f43cb13470eea3562c7" + }, + "dimensions": { + "columns": 80, + "rows": 24 + }, + "terminal": "node-pty/xterm-256color", + "provenance": { + "renderer": { + "package": "@earendil-works/pi-tui@0.84.2", + "pty": "node-pty@1.1.0", + "emulator": "@xterm/headless@6.0.0", + "node": "v24.13.0", + "terminal": "xterm-256color" + }, + "raster": { + "agg": "agg 1.9.0", + "imagemagick": "Version: ImageMagick 6.9.12-98 Q16 x86_64 18038 https://legacy.imagemagick.org", + "fontFamily": "DejaVu Sans Mono", + "font": "DejaVu Sans Mono | Book | /usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", + "colorMode": "sRGB 8-bit" + } + }, + "capturePhase": "atomic-signal-frame", + "captureRevision": 8, + "frame": " › W6 failure state\n\n error · unknown\n RUNTIME_BACKEND_ERROR\n failed\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n›\n\n\nprofile Braid starter · pi / deterministic failed · /activity details · /new\n", + "packedState": { + "schemaVersion": 2, + "capturePhase": "atomic-signal-frame", + "state": { + "schemaVersion": 2, + "revision": 8, + "sequence": 8, + "workspace": "/home/drew/code/braid-goal-latest-wt", + "conversationId": "conv-1", + "branchId": "branch-1", + "conversations": [ + { + "id": "conv-1", + "title": "New conversation", + "branchId": "branch-1", + "archived": false, + "updatedAt": "2026-08-01T00:00:00.000Z" + } + ], + "profile": { + "name": "Braid starter", + "description": "A portable starter profile for the Braid terminal", + "harness": "pi", + "model": { + "default": "fixture/deterministic", + "provider": "fixture", + "reasoningEffort": "none" + } + }, + "runConfiguration": { + "profileName": "Braid starter", + "runner": "pi", + "model": "fixture/deterministic", + "effort": "none", + "overrides": {} + }, + "draft": "", + "messages": [ + { + "id": "message-000003", + "role": "user", + "text": "W6 failure state", + "status": "complete", + "runId": "run-000001", + "parts": [ + { + "id": "part-message-000003", + "kind": "text", + "text": "W6 failure state" + } + ] + }, + { + "id": "message-000004", + "role": "assistant", + "text": "", + "status": "failed", + "runId": "run-000001", + "parts": [ + { + "id": "run-000001:error:run-000001:runtime:2", + "kind": "error", + "text": "RUNTIME_BACKEND_ERROR", + "sourceEventId": "run-000001:runtime:2" + } + ] + } + ], + "runs": [ + { + "id": "run-000001", + "turnId": "turn-000002", + "operationId": "op-terminal-000002", + "status": "failed", + "inputTokens": 0, + "outputTokens": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "model": "fixture/deterministic", + "provider": "agent-runtime", + "runner": "pi", + "error": "RUNTIME_FINAL_ERROR", + "completeness": "failed", + "materializationDigest": "46c7162244a3aa789bd2703e2805a3faa0da1a87d83d97484fa26294a0b00671", + "contentBytes": 60 + } + ], + "sessionUsage": { + "turns": { + "sourceCount": 1, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 1, + "unknownCostSources": 1, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 1, + "unknownLatencySources": 1 + }, + "analyses": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "delegated": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "attribution": "complete" + }, + "environments": [], + "interactions": [], + "queue": [], + "activeRunId": null, + "lastError": "RUNTIME_FINAL_ERROR" + }, + "view": { + "revision": 8, + "workspace": "/home/drew/code/braid-goal-latest-wt", + "profileName": "Braid starter", + "profileDigest": "44ed91c937fd4079fe01c49f6bc76413656886e5aa8b2351590d3a888cfa0a14", + "runner": "pi", + "model": "fixture/deterministic", + "effort": "none", + "runOverrides": {}, + "connection": "deterministic fixture", + "conversationId": "conv-1", + "conversationTitle": "New conversation", + "conversations": [ + { + "id": "conv-1", + "title": "New conversation", + "branchId": "branch-1", + "archived": false, + "active": true, + "updatedAt": "2026-08-01T00:00:00.000Z" + } + ], + "branch": "branch-1", + "status": "failed", + "statusText": "RUNTIME_FINAL_ERROR", + "queueCount": 0, + "queue": [], + "messages": [ + { + "id": "message-000003", + "role": "user", + "text": "W6 failure state", + "status": "complete", + "runId": "run-000001", + "parts": [ + { + "id": "part-message-000003", + "kind": "text", + "text": "W6 failure state" + } + ] + }, + { + "id": "message-000004", + "role": "assistant", + "text": "", + "status": "failed", + "runId": "run-000001", + "parts": [ + { + "id": "run-000001:error:run-000001:runtime:2", + "kind": "error", + "text": "RUNTIME_BACKEND_ERROR", + "sourceEventId": "run-000001:runtime:2" + } + ] + } + ], + "hiddenMessageCount": 0, + "runs": [ + { + "id": "run-000001", + "turnId": "turn-000002", + "conversationId": "conv-1", + "branchId": "branch-1", + "operationId": "op-terminal-000002", + "status": "failed", + "profileName": "Braid starter", + "profileDigest": "44ed91c937fd4079fe01c49f6bc76413656886e5aa8b2351590d3a888cfa0a14", + "model": "fixture/deterministic", + "effort": "none", + "error": "RUNTIME_FINAL_ERROR", + "usage": { + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "model": "fixture/deterministic" + }, + "runner": "pi", + "provider": "agent-runtime", + "completeness": "failed", + "contentBytes": 60 + } + ], + "sessionUsage": { + "turns": { + "sourceCount": 1, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 1, + "unknownCostSources": 1, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 1, + "unknownLatencySources": 1 + }, + "analyses": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "delegated": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "attribution": "complete" + }, + "environments": [], + "interactions": [], + "activity": [ + { + "id": "run:run-000001", + "kind": "run", + "title": "run run-000001", + "status": "failed", + "detail": "RUNTIME_FINAL_ERROR", + "startedAt": "2026-08-01T00:00:00.000Z", + "occurredAt": "2026-08-01T00:00:00.000Z", + "runId": "run-000001", + "entityType": "run", + "entityId": "run-000001" + }, + { + "id": "activity:run-000001:admission:local:admitted", + "kind": "system", + "title": "admitted", + "status": "failed", + "detail": "profile 44ed91c937fd4079fe01c49f6bc76413656886e5aa8b2351590d3a888cfa0a14", + "occurredAt": "2026-08-01T00:00:00.000Z", + "runId": "run-000001", + "entityType": "run", + "entityId": "run-000001" + }, + { + "id": "event:run-000001:run-000001:runtime:1", + "kind": "system", + "title": "event backend_start", + "status": "failed", + "detail": "sequence 1", + "occurredAt": "2026-08-15T00:51:01.887Z", + "sourceEventId": "run-000001:runtime:1", + "runId": "run-000001", + "entityType": "run", + "entityId": "run-000001" + }, + { + "id": "activity:run-000001:run.error:run-000001:runtime:2:error", + "kind": "system", + "title": "error", + "status": "failed", + "detail": "RUNTIME_BACKEND_ERROR", + "occurredAt": "2026-08-15T00:51:01.894Z", + "sourceEventId": "run-000001:runtime:2", + "runId": "run-000001", + "entityType": "run", + "entityId": "run-000001" + } + ], + "graph": [ + { + "id": "conv-1", + "type": "conversation", + "title": "New conversation", + "status": "complete", + "depth": 0, + "edgeLabel": "attached", + "startedAt": "2026-08-01T00:00:00.000Z" + }, + { + "id": "branch-1", + "type": "branch", + "title": "Main", + "status": "complete", + "depth": 1, + "edgeLabel": "attached", + "parentIds": [ + "conversation:conv-1" + ], + "startedAt": "2026-08-01T00:00:00.000Z" + }, + { + "id": "turn-000002", + "type": "turn", + "title": "Turn", + "status": "failed", + "depth": 2, + "edgeLabel": "continued", + "parentIds": [ + "branch:branch-1" + ], + "startedAt": "2026-08-01T00:00:00.000Z" + }, + { + "id": "run-000001", + "type": "run", + "title": "Run", + "status": "failed", + "depth": 3, + "edgeLabel": "attached", + "parentIds": [ + "turn:turn-000002" + ], + "runner": "pi", + "startedAt": "2026-08-01T00:00:00.000Z" + } + ], + "graphQuery": "", + "entityDetails": [], + "details": { + "title": "run run-000001", + "fields": [ + { + "label": "status", + "value": "failed" + }, + { + "label": "input tokens", + "value": "0" + }, + { + "label": "output tokens", + "value": "0" + }, + { + "label": "model", + "value": "fixture/deterministic" + } + ] + }, + "capabilities": { + "conversation.create": { + "available": true, + "source": "application" + }, + "conversation.open": { + "available": true, + "source": "application" + }, + "run.runner": { + "available": true, + "source": "application" + }, + "run.model": { + "available": true, + "source": "application" + }, + "run.effort": { + "available": true, + "source": "application" + }, + "conversation.branch": { + "available": true, + "source": "application" + }, + "conversation.clone": { + "available": true, + "source": "application" + }, + "conversation.fork": { + "available": true, + "source": "application" + }, + "analysis.ask": { + "available": true, + "source": "application" + }, + "analysis.recipe": { + "available": true, + "source": "application" + }, + "analysis.compare": { + "available": false, + "source": "application", + "reason": "Two completed or failed runs are required for comparison" + }, + "interaction.respond": { + "available": false, + "source": "runtime", + "reason": "Interaction response is not exposed by the current runtime adapter" + }, + "interaction.automation": { + "available": false, + "source": "application", + "reason": "Interaction automation requires the shared response contract" + }, + "export.create": { + "available": true, + "source": "application" + }, + "application.quit": { + "available": true, + "source": "local" + }, + "help.read": { + "available": true, + "source": "local" + }, + "profile.select": { + "available": true, + "source": "application" + }, + "connection.select": { + "available": true, + "source": "application" + }, + "settings.open": { + "available": true, + "source": "application" + }, + "activity.read": { + "available": true, + "source": "local" + }, + "graph.read": { + "available": true, + "source": "local" + }, + "details.read": { + "available": true, + "source": "local" + }, + "draft.write": { + "available": true, + "source": "application" + }, + "run.send": { + "available": true, + "source": "provider" + }, + "run.cancel": { + "available": false, + "source": "runtime", + "reason": "There is no active run to cancel" + }, + "run.queue": { + "available": false, + "source": "provider", + "reason": "There is no active run" + }, + "run.steer": { + "available": false, + "source": "provider", + "reason": "There is no active run" + }, + "run.detach": { + "available": false, + "source": "provider", + "reason": "There is no active run" + }, + "run.reconnect": { + "available": false, + "source": "provider", + "reason": "There is no detached run to reconnect" + }, + "run.reconcile": { + "available": false, + "source": "provider", + "reason": "There is no detached run to reconcile" + } + }, + "draft": "", + "selectedSurface": "transcript", + "appearance": { + "color": "256", + "highContrast": false, + "reducedMotion": false + } + }, + "events": [ + { + "sequence": 1, + "revision": 1, + "kind": "workspace.opened", + "payload": { + "value": { + "kind": "workspace.opened", + "workspace": "/home/drew/code/braid-goal-latest-wt" + } + } + }, + { + "sequence": 2, + "revision": 2, + "kind": "draft.recorded", + "payload": { + "value": { + "kind": "draft.recorded", + "draft": { + "id": "draft-branch-1", + "branchId": "branch-1", + "text": "W6 failure state", + "updatedAt": "2026-08-01T00:00:00.000Z" + }, + "operation": { + "id": "op-terminal-000001", + "kind": "draft-update", + "requestDigest": "65a924286ea2c8f6c5ff3e0223a576c834e806ca53e738685ecec2fb6ca9608a", + "status": "acknowledged", + "target": { + "kind": "branch", + "id": "branch-1" + }, + "createdAt": "2026-08-01T00:00:00.000Z", + "updatedAt": "2026-08-01T00:00:00.000Z", + "acknowledgedAt": "2026-08-01T00:00:00.000Z" + } + } + } + }, + { + "sequence": 3, + "revision": 3, + "kind": "run.requested", + "payload": { + "runId": "run-000001", + "text": "W6 failure state", + "admission": { + "version": 1, + "runId": "run-000001", + "turnId": "turn-000002", + "operationId": "op-terminal-000002", + "conversationId": "conv-1", + "branchId": "branch-1", + "admittedAt": "2026-08-01T00:00:00.000Z", + "profileDigest": "44ed91c937fd4079fe01c49f6bc76413656886e5aa8b2351590d3a888cfa0a14", + "requested": { + "text": "W6 failure state", + "profile": { + "name": "Braid starter", + "description": "A portable starter profile for the Braid terminal", + "model": { + "default": "fixture/deterministic", + "provider": "fixture", + "reasoningEffort": "none" + }, + "harness": "pi" + }, + "model": "fixture/deterministic", + "runner": "pi" + }, + "capabilities": { + "streaming": { + "live": true, + "replay": false, + "detach": false, + "turnIdempotency": true + }, + "sessions": { + "continue": false, + "messages": false + }, + "controls": { + "cancel": true, + "steer": false, + "queue": true, + "status": false, + "recreate": false + }, + "events": { + "stableIdentity": false, + "sequence": true, + "cursor": false + }, + "usage": true + }, + "provider": "agent-runtime", + "materializationReceipt": { + "backendKind": "executor", + "provider": "agent-runtime" + }, + "admissionStatus": "admitted", + "requestDigest": "f080a8e87a51f6c8d898986826372cdf1767d2eb5101fad7cfb5df90caf5eabb", + "capabilitiesDigest": "93c4c21593f9e2fa1fe68ce73da84f48c173f1e13269633678b512a16a81a001", + "materializationDigest": "46c7162244a3aa789bd2703e2805a3faa0da1a87d83d97484fa26294a0b00671", + "digest": "b069aefd4ed0b6d1f93b2f14f40d280b3e60c5d45b63cb76294571f32be2444e" + }, + "status": "admitted" + } + }, + { + "sequence": 4, + "revision": 4, + "kind": "effect.upserted", + "payload": { + "value": { + "kind": "effect.upserted", + "effect": { + "id": "effect-op-terminal-000002-426190f687687eca8a86079d", + "operationId": "op-terminal-000002", + "effectKind": "run.execute", + "requestDigest": "426190f687687eca8a86079d434d48982bda620c2645dc2aed2319f2a90121c8", + "kind": "send", + "status": "pending", + "attempt": 1, + "createdAt": "2026-08-01T00:00:00.000Z", + "updatedAt": "2026-08-01T00:00:00.000Z" + } + } + } + }, + { + "sequence": 5, + "revision": 5, + "kind": "run.provider.event", + "payload": { + "runId": "run-000001", + "source": { + "eventId": "run-000001:runtime:1", + "providerSequence": 1, + "occurredAt": "2026-08-15T00:51:01.887Z", + "receivedAt": "2026-08-01T00:00:00.000Z" + }, + "unknown": { + "type": "backend_start", + "task": { + "id": "turn-43089d49-94ce-4175-b09a-a1dce0a28061", + "intent": "W6 failure state", + "metadata": { + "identity": { + "profileDigest": "sha256:8344a03c82d5cb9207910fe0cb05ef0757b58b8cea9a7cc82f36055694d58983", + "taskDigest": "sha256:a18a1b57b75ff55ccdc806e7e56baffcf9c30a9c5db512fdf150524971f262f8" + } + } + }, + "session": { + "id": "471715ff-c92e-4790-ae4c-fe2f39a990ee", + "backend": "executor", + "status": "active", + "createdAt": "2026-08-15T00:51:01.887Z", + "updatedAt": "2026-08-15T00:51:01.887Z", + "metadata": "[unavailable]" + }, + "backend": "router", + "metadata": { + "identity": { + "profileDigest": "sha256:8344a03c82d5cb9207910fe0cb05ef0757b58b8cea9a7cc82f36055694d58983", + "taskDigest": "sha256:a18a1b57b75ff55ccdc806e7e56baffcf9c30a9c5db512fdf150524971f262f8" + }, + "materialization": { + "status": "known", + "authoredProfileDigest": "sha256:8344a03c82d5cb9207910fe0cb05ef0757b58b8cea9a7cc82f36055694d58983", + "effectiveProfileDigest": "sha256:8344a03c82d5cb9207910fe0cb05ef0757b58b8cea9a7cc82f36055694d58983", + "materializationPlanDigest": "sha256:00acc9b6378f74a84629ea6c26f6e800d054bc04588a1daba8d1924b79792478", + "runtime": "router", + "backend": "router", + "model": { + "status": "known", + "id": "fixture/deterministic" + }, + "execution": { + "kind": "request", + "id": "turn-43089d49-94ce-4175-b09a-a1dce0a28061" + }, + "materializer": "router-prompt-model" + }, + "executionBindings": [ + { + "status": "known", + "attemptId": "turn-43089d49-94ce-4175-b09a-a1dce0a28061:attempt:59bdc7b8-01ad-4116-882f-7a47b0553639", + "materializationReceiptDigest": "sha256:c324e7db9ae0f1d70408d68c433c465cd8d5fbc746d2d6b49daf59ce465e2700", + "bindingDigest": "sha256:cfa364401f913c1dfe9ad0b030d78e2a0bd02535ca850e645f85f4d1aa378de0", + "descriptor": { + "kind": "router-request", + "transport": "http", + "backend": "router" + } + } + ], + "budget": { + "timeoutMs": null + }, + "timing": { + "startedAt": "2026-08-15T00:51:01.885Z" + } + }, + "timestamp": "2026-08-15T00:51:01.887Z" + } + } + }, + { + "sequence": 6, + "revision": 6, + "kind": "run.error", + "payload": { + "runId": "run-000001", + "source": { + "eventId": "run-000001:runtime:2", + "providerSequence": 2, + "occurredAt": "2026-08-15T00:51:01.894Z", + "receivedAt": "2026-08-01T00:00:00.000Z" + }, + "error": { + "message": "RUNTIME_BACKEND_ERROR", + "recoverable": true + }, + "text": "RUNTIME_BACKEND_ERROR" + } + }, + { + "sequence": 7, + "revision": 7, + "kind": "run.finished", + "payload": { + "runId": "run-000001", + "source": { + "eventId": "run-000001:runtime:3", + "providerSequence": 3, + "occurredAt": "2026-08-15T00:51:01.898Z", + "receivedAt": "2026-08-01T00:00:00.000Z" + }, + "status": "failed", + "completeness": "complete", + "finalText": "", + "usage": { + "input": 0, + "output": 0, + "tokensKnown": false, + "usdKnown": false + }, + "reason": "RUNTIME_FINAL_REASON", + "error": "RUNTIME_FINAL_ERROR" + } + }, + { + "sequence": 8, + "revision": 8, + "kind": "effect.upserted", + "payload": { + "value": { + "kind": "effect.upserted", + "effect": { + "id": "effect-op-terminal-000002-426190f687687eca8a86079d", + "operationId": "op-terminal-000002", + "effectKind": "run.execute", + "requestDigest": "426190f687687eca8a86079d434d48982bda620c2645dc2aed2319f2a90121c8", + "kind": "send", + "status": "failed", + "attempt": 1, + "outcomeDigest": "2d2acf569882146ba42c49e3553984f716d39e5c29483aa9dd35daa8fb1f60bc", + "createdAt": "2026-08-01T00:00:00.000Z", + "updatedAt": "2026-08-01T00:00:00.000Z" + } + } + } + } + ] + } +} diff --git a/artifacts/verification/tui-audit/after/w6/states/failure-or-reconnect.png b/artifacts/verification/tui-audit/after/w6/states/failure-or-reconnect.png new file mode 100644 index 0000000..3b24f29 Binary files /dev/null and b/artifacts/verification/tui-audit/after/w6/states/failure-or-reconnect.png differ diff --git a/artifacts/verification/tui-audit/after/w6/states/failure-or-reconnect.txt b/artifacts/verification/tui-audit/after/w6/states/failure-or-reconnect.txt new file mode 100644 index 0000000..4f589ca --- /dev/null +++ b/artifacts/verification/tui-audit/after/w6/states/failure-or-reconnect.txt @@ -0,0 +1,24 @@ + › W6 failure state + + error · unknown + RUNTIME_BACKEND_ERROR + failed + + + + + + + + + + + + + + + +› + + +profile Braid starter · pi / deterministic failed · /activity details · /new diff --git a/artifacts/verification/tui-audit/after/w6/states/fork-preview.json b/artifacts/verification/tui-audit/after/w6/states/fork-preview.json new file mode 100644 index 0000000..d972588 --- /dev/null +++ b/artifacts/verification/tui-audit/after/w6/states/fork-preview.json @@ -0,0 +1,409 @@ +{ + "schemaVersion": 2, + "state": "fork-preview", + "source": { + "binary": "packed real binary from clean npm install", + "binarySha256": "141288e0fe917635d723b4b70d464dc49baff14cc356f54de1d3f8faa5d8254f", + "tarball": "tangle-network-braid-0.1.1.tgz", + "tarballSha256": "b2c0571a7f7d0d6e847d493667dfed6ff11b52eefb5e5f43cb13470eea3562c7" + }, + "dimensions": { + "columns": 80, + "rows": 24 + }, + "terminal": "node-pty/xterm-256color", + "provenance": { + "renderer": { + "package": "@earendil-works/pi-tui@0.84.2", + "pty": "node-pty@1.1.0", + "emulator": "@xterm/headless@6.0.0", + "node": "v24.13.0", + "terminal": "xterm-256color" + }, + "raster": { + "agg": "agg 1.9.0", + "imagemagick": "Version: ImageMagick 6.9.12-98 Q16 x86_64 18038 https://legacy.imagemagick.org", + "fontFamily": "DejaVu Sans Mono", + "font": "DejaVu Sans Mono | Book | /usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", + "colorMode": "sRGB 8-bit" + } + }, + "capturePhase": "atomic-signal-frame", + "captureRevision": 1, + "frame": " fork preview workspace\n────────────────────────────────────────────────────────────────────────────────\n will create a new workspace from this source\n source: workspace:/workspace\n destination: workspace:/workspace-fork\n boundary: conv-1 / branch-1 → conv-fork-1 / branch-1\n profile snapshot: digest:fixture-source → digest:fixture-copy\n workspace state: checkpoint:fixture-1 → checkpoint:fixture-1\n operation id: operation-fixture-fork → operation-fixture-fork\n plan digest: digest:fixture-fork-plan → digest:fixture-fork-plan\n\n\n\n\n\n\n\n\n\n\n\n\n────────────────────────────────────────────────────────────────────────────────\n enter/y create fork · ←/esc cancel\n", + "packedState": { + "schemaVersion": 2, + "capturePhase": "atomic-signal-frame", + "state": { + "schemaVersion": 2, + "revision": 1, + "sequence": 1, + "workspace": "/home/drew/code/braid-goal-latest-wt", + "conversationId": "conv-1", + "branchId": "branch-1", + "conversations": [ + { + "id": "conv-1", + "title": "New conversation", + "branchId": "branch-1", + "archived": false, + "updatedAt": "2026-08-01T00:00:00.000Z" + } + ], + "profile": { + "name": "Braid starter", + "description": "A portable starter profile for the Braid terminal", + "harness": "pi", + "model": { + "default": "fixture/deterministic", + "provider": "fixture", + "reasoningEffort": "none" + } + }, + "runConfiguration": { + "profileName": "Braid starter", + "runner": "pi", + "model": "fixture/deterministic", + "effort": "none", + "overrides": {} + }, + "draft": "", + "messages": [], + "runs": [], + "sessionUsage": { + "turns": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "analyses": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "delegated": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "attribution": "complete" + }, + "environments": [], + "interactions": [], + "queue": [], + "activeRunId": null, + "lastError": null + }, + "view": { + "revision": 1, + "workspace": "/home/drew/code/braid-goal-latest-wt", + "profileName": "Braid starter", + "profileDigest": "44ed91c937fd4079fe01c49f6bc76413656886e5aa8b2351590d3a888cfa0a14", + "runner": "pi", + "model": "fixture/deterministic", + "effort": "none", + "runOverrides": {}, + "connection": "deterministic fixture", + "conversationId": "conv-1", + "conversationTitle": "New conversation", + "conversations": [ + { + "id": "conv-1", + "title": "New conversation", + "branchId": "branch-1", + "archived": false, + "active": true, + "updatedAt": "2026-08-01T00:00:00.000Z" + } + ], + "branch": "branch-1", + "status": "empty", + "statusText": "ready for a message", + "queueCount": 0, + "queue": [], + "messages": [], + "hiddenMessageCount": 0, + "runs": [], + "sessionUsage": { + "turns": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "analyses": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "delegated": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "attribution": "complete" + }, + "environments": [], + "interactions": [], + "activity": [], + "graph": [ + { + "id": "conv-1", + "type": "conversation", + "title": "New conversation", + "status": "complete", + "depth": 0, + "edgeLabel": "attached", + "startedAt": "2026-08-01T00:00:00.000Z" + }, + { + "id": "branch-1", + "type": "branch", + "title": "Main", + "status": "complete", + "depth": 1, + "edgeLabel": "attached", + "parentIds": [ + "conversation:conv-1" + ], + "startedAt": "2026-08-01T00:00:00.000Z" + } + ], + "graphQuery": "", + "entityDetails": [], + "capabilities": { + "conversation.create": { + "available": true, + "source": "application" + }, + "conversation.open": { + "available": true, + "source": "application" + }, + "run.runner": { + "available": true, + "source": "application" + }, + "run.model": { + "available": true, + "source": "application" + }, + "run.effort": { + "available": true, + "source": "application" + }, + "conversation.branch": { + "available": true, + "source": "application" + }, + "conversation.clone": { + "available": true, + "source": "application" + }, + "conversation.fork": { + "available": true, + "source": "provider" + }, + "analysis.ask": { + "available": false, + "source": "application", + "reason": "Complete or fail a run before analyzing it" + }, + "analysis.recipe": { + "available": false, + "source": "application", + "reason": "Complete or fail a run before analyzing it" + }, + "analysis.compare": { + "available": false, + "source": "application", + "reason": "Two completed or failed runs are required for comparison" + }, + "interaction.respond": { + "available": false, + "source": "runtime", + "reason": "Interaction response is not exposed by the current runtime adapter" + }, + "interaction.automation": { + "available": false, + "source": "application", + "reason": "Interaction automation requires the shared response contract" + }, + "export.create": { + "available": true, + "source": "application" + }, + "application.quit": { + "available": true, + "source": "local" + }, + "help.read": { + "available": true, + "source": "local" + }, + "profile.select": { + "available": true, + "source": "application" + }, + "connection.select": { + "available": true, + "source": "application" + }, + "settings.open": { + "available": true, + "source": "application" + }, + "activity.read": { + "available": true, + "source": "local" + }, + "graph.read": { + "available": true, + "source": "local" + }, + "details.read": { + "available": true, + "source": "local" + }, + "draft.write": { + "available": true, + "source": "application" + }, + "run.send": { + "available": true, + "source": "provider" + }, + "run.cancel": { + "available": false, + "source": "runtime", + "reason": "There is no active run to cancel" + }, + "run.queue": { + "available": false, + "source": "provider", + "reason": "There is no active run" + }, + "run.steer": { + "available": false, + "source": "provider", + "reason": "There is no active run" + }, + "run.detach": { + "available": false, + "source": "provider", + "reason": "There is no active run" + }, + "run.reconnect": { + "available": false, + "source": "provider", + "reason": "There is no detached run to reconnect" + }, + "run.reconcile": { + "available": false, + "source": "provider", + "reason": "There is no detached run to reconcile" + } + }, + "draft": "", + "selectedSurface": "fork", + "appearance": { + "color": "256", + "highContrast": false, + "reducedMotion": false + }, + "forkPreview": { + "kind": "workspace", + "source": "workspace:/workspace", + "destination": "workspace:/workspace-fork", + "fields": [ + { + "label": "conversation context", + "source": "conv-1 / branch-1", + "destination": "conv-fork-1 / branch-1" + }, + { + "label": "profile snapshot", + "source": "digest:fixture-source", + "destination": "digest:fixture-copy" + }, + { + "label": "workspace state", + "source": "checkpoint:fixture-1", + "destination": "checkpoint:fixture-1" + }, + { + "label": "operation id", + "source": "operation-fixture-fork", + "destination": "operation-fixture-fork" + }, + { + "label": "plan digest", + "source": "digest:fixture-fork-plan", + "destination": "digest:fixture-fork-plan" + } + ], + "allowed": true + } + }, + "events": [ + { + "sequence": 1, + "revision": 1, + "kind": "workspace.opened", + "payload": { + "value": { + "kind": "workspace.opened", + "workspace": "/home/drew/code/braid-goal-latest-wt" + } + } + } + ] + } +} diff --git a/artifacts/verification/tui-audit/after/w6/states/fork-preview.png b/artifacts/verification/tui-audit/after/w6/states/fork-preview.png new file mode 100644 index 0000000..ebcb25b Binary files /dev/null and b/artifacts/verification/tui-audit/after/w6/states/fork-preview.png differ diff --git a/artifacts/verification/tui-audit/after/w6/states/fork-preview.txt b/artifacts/verification/tui-audit/after/w6/states/fork-preview.txt new file mode 100644 index 0000000..8f069ea --- /dev/null +++ b/artifacts/verification/tui-audit/after/w6/states/fork-preview.txt @@ -0,0 +1,24 @@ + fork preview workspace +──────────────────────────────────────────────────────────────────────────────── + will create a new workspace from this source + source: workspace:/workspace + destination: workspace:/workspace-fork + boundary: conv-1 / branch-1 → conv-fork-1 / branch-1 + profile snapshot: digest:fixture-source → digest:fixture-copy + workspace state: checkpoint:fixture-1 → checkpoint:fixture-1 + operation id: operation-fixture-fork → operation-fixture-fork + plan digest: digest:fixture-fork-plan → digest:fixture-fork-plan + + + + + + + + + + + + +──────────────────────────────────────────────────────────────────────────────── + enter/y create fork · ←/esc cancel diff --git a/artifacts/verification/tui-audit/after/w6/states/graph-or-analysis.json b/artifacts/verification/tui-audit/after/w6/states/graph-or-analysis.json new file mode 100644 index 0000000..e7b7753 --- /dev/null +++ b/artifacts/verification/tui-audit/after/w6/states/graph-or-analysis.json @@ -0,0 +1,376 @@ +{ + "schemaVersion": 2, + "state": "graph-or-analysis", + "source": { + "binary": "packed real binary from clean npm install", + "binarySha256": "141288e0fe917635d723b4b70d464dc49baff14cc356f54de1d3f8faa5d8254f", + "tarball": "tangle-network-braid-0.1.1.tgz", + "tarballSha256": "b2c0571a7f7d0d6e847d493667dfed6ff11b52eefb5e5f43cb13470eea3562c7" + }, + "dimensions": { + "columns": 80, + "rows": 24 + }, + "terminal": "node-pty/xterm-256color", + "provenance": { + "renderer": { + "package": "@earendil-works/pi-tui@0.84.2", + "pty": "node-pty@1.1.0", + "emulator": "@xterm/headless@6.0.0", + "node": "v24.13.0", + "terminal": "xterm-256color" + }, + "raster": { + "agg": "agg 1.9.0", + "imagemagick": "Version: ImageMagick 6.9.12-98 Q16 x86_64 18038 https://legacy.imagemagick.org", + "fontFamily": "DejaVu Sans Mono", + "font": "DejaVu Sans Mono | Book | /usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", + "colorMode": "sRGB 8-bit" + } + }, + "capturePhase": "atomic-signal-frame", + "captureRevision": 1, + "frame": " conversation graph · 2 Braid starter · pi · fixture/deterministic\n────────────────────────────────────────────────────────────────────────────────\n ✓ ▾ New conversation · conversation · complete · ─attached→\n › └ ✓ Main · branch · complete · current · ─attached→\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n────────────────────────────────────────────────────────────────────────────────\n ↑↓ move · enter/→ open · ←/esc close · space collapse · w waiting\n", + "packedState": { + "schemaVersion": 2, + "capturePhase": "atomic-signal-frame", + "state": { + "schemaVersion": 2, + "revision": 1, + "sequence": 1, + "workspace": "/home/drew/code/braid-goal-latest-wt", + "conversationId": "conv-1", + "branchId": "branch-1", + "conversations": [ + { + "id": "conv-1", + "title": "New conversation", + "branchId": "branch-1", + "archived": false, + "updatedAt": "2026-08-01T00:00:00.000Z" + } + ], + "profile": { + "name": "Braid starter", + "description": "A portable starter profile for the Braid terminal", + "harness": "pi", + "model": { + "default": "fixture/deterministic", + "provider": "fixture", + "reasoningEffort": "none" + } + }, + "runConfiguration": { + "profileName": "Braid starter", + "runner": "pi", + "model": "fixture/deterministic", + "effort": "none", + "overrides": {} + }, + "draft": "", + "messages": [], + "runs": [], + "sessionUsage": { + "turns": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "analyses": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "delegated": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "attribution": "complete" + }, + "environments": [], + "interactions": [], + "queue": [], + "activeRunId": null, + "lastError": null + }, + "view": { + "revision": 1, + "workspace": "/home/drew/code/braid-goal-latest-wt", + "profileName": "Braid starter", + "profileDigest": "44ed91c937fd4079fe01c49f6bc76413656886e5aa8b2351590d3a888cfa0a14", + "runner": "pi", + "model": "fixture/deterministic", + "effort": "none", + "runOverrides": {}, + "connection": "deterministic fixture", + "conversationId": "conv-1", + "conversationTitle": "New conversation", + "conversations": [ + { + "id": "conv-1", + "title": "New conversation", + "branchId": "branch-1", + "archived": false, + "active": true, + "updatedAt": "2026-08-01T00:00:00.000Z" + } + ], + "branch": "branch-1", + "status": "empty", + "statusText": "ready for a message", + "queueCount": 0, + "queue": [], + "messages": [], + "hiddenMessageCount": 0, + "runs": [], + "sessionUsage": { + "turns": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "analyses": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "delegated": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "attribution": "complete" + }, + "environments": [], + "interactions": [], + "activity": [], + "graph": [ + { + "id": "conv-1", + "type": "conversation", + "title": "New conversation", + "status": "complete", + "depth": 0, + "edgeLabel": "attached", + "startedAt": "2026-08-01T00:00:00.000Z" + }, + { + "id": "branch-1", + "type": "branch", + "title": "Main", + "status": "complete", + "depth": 1, + "edgeLabel": "attached", + "parentIds": [ + "conversation:conv-1" + ], + "startedAt": "2026-08-01T00:00:00.000Z" + } + ], + "graphQuery": "", + "entityDetails": [], + "capabilities": { + "conversation.create": { + "available": true, + "source": "application" + }, + "conversation.open": { + "available": true, + "source": "application" + }, + "run.runner": { + "available": true, + "source": "application" + }, + "run.model": { + "available": true, + "source": "application" + }, + "run.effort": { + "available": true, + "source": "application" + }, + "conversation.branch": { + "available": true, + "source": "application" + }, + "conversation.clone": { + "available": true, + "source": "application" + }, + "conversation.fork": { + "available": true, + "source": "application" + }, + "analysis.ask": { + "available": false, + "source": "application", + "reason": "Complete or fail a run before analyzing it" + }, + "analysis.recipe": { + "available": false, + "source": "application", + "reason": "Complete or fail a run before analyzing it" + }, + "analysis.compare": { + "available": false, + "source": "application", + "reason": "Two completed or failed runs are required for comparison" + }, + "interaction.respond": { + "available": false, + "source": "runtime", + "reason": "Interaction response is not exposed by the current runtime adapter" + }, + "interaction.automation": { + "available": false, + "source": "application", + "reason": "Interaction automation requires the shared response contract" + }, + "export.create": { + "available": true, + "source": "application" + }, + "application.quit": { + "available": true, + "source": "local" + }, + "help.read": { + "available": true, + "source": "local" + }, + "profile.select": { + "available": true, + "source": "application" + }, + "connection.select": { + "available": true, + "source": "application" + }, + "settings.open": { + "available": true, + "source": "application" + }, + "activity.read": { + "available": true, + "source": "local" + }, + "graph.read": { + "available": true, + "source": "local" + }, + "details.read": { + "available": true, + "source": "local" + }, + "draft.write": { + "available": true, + "source": "application" + }, + "run.send": { + "available": true, + "source": "provider" + }, + "run.cancel": { + "available": false, + "source": "runtime", + "reason": "There is no active run to cancel" + }, + "run.queue": { + "available": false, + "source": "provider", + "reason": "There is no active run" + }, + "run.steer": { + "available": false, + "source": "provider", + "reason": "There is no active run" + }, + "run.detach": { + "available": false, + "source": "provider", + "reason": "There is no active run" + }, + "run.reconnect": { + "available": false, + "source": "provider", + "reason": "There is no detached run to reconnect" + }, + "run.reconcile": { + "available": false, + "source": "provider", + "reason": "There is no detached run to reconcile" + } + }, + "draft": "", + "selectedSurface": "graph", + "appearance": { + "color": "256", + "highContrast": false, + "reducedMotion": false + } + }, + "events": [ + { + "sequence": 1, + "revision": 1, + "kind": "workspace.opened", + "payload": { + "value": { + "kind": "workspace.opened", + "workspace": "/home/drew/code/braid-goal-latest-wt" + } + } + } + ] + } +} diff --git a/artifacts/verification/tui-audit/after/w6/states/graph-or-analysis.png b/artifacts/verification/tui-audit/after/w6/states/graph-or-analysis.png new file mode 100644 index 0000000..7d0873f Binary files /dev/null and b/artifacts/verification/tui-audit/after/w6/states/graph-or-analysis.png differ diff --git a/artifacts/verification/tui-audit/after/w6/states/graph-or-analysis.txt b/artifacts/verification/tui-audit/after/w6/states/graph-or-analysis.txt new file mode 100644 index 0000000..89ebec5 --- /dev/null +++ b/artifacts/verification/tui-audit/after/w6/states/graph-or-analysis.txt @@ -0,0 +1,24 @@ + conversation graph · 2 Braid starter · pi · fixture/deterministic +──────────────────────────────────────────────────────────────────────────────── + ✓ ▾ New conversation · conversation · complete · ─attached→ + › └ ✓ Main · branch · complete · current · ─attached→ + + + + + + + + + + + + + + + + + + +──────────────────────────────────────────────────────────────────────────────── + ↑↓ move · enter/→ open · ←/esc close · space collapse · w waiting diff --git a/artifacts/verification/tui-audit/after/w6/states/interaction.json b/artifacts/verification/tui-audit/after/w6/states/interaction.json new file mode 100644 index 0000000..344d1fb --- /dev/null +++ b/artifacts/verification/tui-audit/after/w6/states/interaction.json @@ -0,0 +1,411 @@ +{ + "schemaVersion": 2, + "state": "interaction", + "source": { + "binary": "packed real binary from clean npm install", + "binarySha256": "141288e0fe917635d723b4b70d464dc49baff14cc356f54de1d3f8faa5d8254f", + "tarball": "tangle-network-braid-0.1.1.tgz", + "tarballSha256": "b2c0571a7f7d0d6e847d493667dfed6ff11b52eefb5e5f43cb13470eea3562c7" + }, + "dimensions": { + "columns": 80, + "rows": 24 + }, + "terminal": "node-pty/xterm-256color", + "provenance": { + "renderer": { + "package": "@earendil-works/pi-tui@0.84.2", + "pty": "node-pty@1.1.0", + "emulator": "@xterm/headless@6.0.0", + "node": "v24.13.0", + "terminal": "xterm-256color" + }, + "raster": { + "agg": "agg 1.9.0", + "imagemagick": "Version: ImageMagick 6.9.12-98 Q16 x86_64 18038 https://legacy.imagemagick.org", + "fontFamily": "DejaVu Sans Mono", + "font": "DejaVu Sans Mono | Book | /usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", + "colorMode": "sRGB 8-bit" + } + }, + "capturePhase": "atomic-signal-frame", + "captureRevision": 1, + "frame": " permission Braid starter @ pi · run fixture-run-1\n────────────────────────────────────────────────────────────────────────────────\n Allow the fixture tool to inspect the selected file?\n src/app/application.ts · read-only\n The fixture requests a bounded read for a real interaction preview.\n export class BraidApplication {\n\n › 1. Approve\n Continue this request\n 2. Reject\n 3. Cancel\n\n\n\n\n\n\n\n\n\n\n\n────────────────────────────────────────────────────────────────────────────────\n ↑↓ move · enter confirm · 1-3 quick select · esc cancel · alt+a automate\n", + "packedState": { + "schemaVersion": 2, + "capturePhase": "atomic-signal-frame", + "state": { + "schemaVersion": 2, + "revision": 1, + "sequence": 1, + "workspace": "/home/drew/code/braid-goal-latest-wt", + "conversationId": "conv-1", + "branchId": "branch-1", + "conversations": [ + { + "id": "conv-1", + "title": "New conversation", + "branchId": "branch-1", + "archived": false, + "updatedAt": "2026-08-01T00:00:00.000Z" + } + ], + "profile": { + "name": "Braid starter", + "description": "A portable starter profile for the Braid terminal", + "harness": "pi", + "model": { + "default": "fixture/deterministic", + "provider": "fixture", + "reasoningEffort": "none" + } + }, + "runConfiguration": { + "profileName": "Braid starter", + "runner": "pi", + "model": "fixture/deterministic", + "effort": "none", + "overrides": {} + }, + "draft": "", + "messages": [], + "runs": [], + "sessionUsage": { + "turns": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "analyses": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "delegated": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "attribution": "complete" + }, + "environments": [], + "interactions": [], + "queue": [], + "activeRunId": null, + "lastError": null + }, + "view": { + "revision": 1, + "workspace": "/home/drew/code/braid-goal-latest-wt", + "profileName": "Braid starter", + "profileDigest": "44ed91c937fd4079fe01c49f6bc76413656886e5aa8b2351590d3a888cfa0a14", + "runner": "pi", + "model": "fixture/deterministic", + "effort": "none", + "runOverrides": {}, + "connection": "deterministic fixture", + "conversationId": "conv-1", + "conversationTitle": "New conversation", + "conversations": [ + { + "id": "conv-1", + "title": "New conversation", + "branchId": "branch-1", + "archived": false, + "active": true, + "updatedAt": "2026-08-01T00:00:00.000Z" + } + ], + "branch": "branch-1", + "status": "empty", + "statusText": "ready for a message", + "queueCount": 0, + "queue": [], + "messages": [], + "hiddenMessageCount": 0, + "runs": [], + "sessionUsage": { + "turns": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "analyses": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "delegated": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "attribution": "complete" + }, + "environments": [], + "interactions": [ + { + "runId": "fixture-run-1", + "interactionId": "fixture-interaction-1", + "profileName": "Braid starter", + "runner": "pi", + "kind": "permission", + "prompt": "Allow the fixture tool to inspect the selected file?", + "subject": { + "type": "file", + "title": "src/app/application.ts", + "target": "read-only", + "detail": "The fixture requests a bounded read for a real interaction preview.", + "preview": [ + "export class BraidApplication {", + " cancel(input: CancelInput) { …" + ], + "trustedWorkspace": "inside" + }, + "answerSpec": { + "kind": "boolean", + "required": true + }, + "allowedOutcomes": [ + "accept", + "reject", + "cancel" + ], + "responseScopes": [ + "once", + "session" + ], + "queuePosition": 0, + "queueTotal": 1, + "secret": false + } + ], + "activity": [], + "graph": [ + { + "id": "conv-1", + "type": "conversation", + "title": "New conversation", + "status": "complete", + "depth": 0, + "edgeLabel": "attached", + "startedAt": "2026-08-01T00:00:00.000Z" + }, + { + "id": "branch-1", + "type": "branch", + "title": "Main", + "status": "complete", + "depth": 1, + "edgeLabel": "attached", + "parentIds": [ + "conversation:conv-1" + ], + "startedAt": "2026-08-01T00:00:00.000Z" + } + ], + "graphQuery": "", + "entityDetails": [], + "capabilities": { + "conversation.create": { + "available": true, + "source": "application" + }, + "conversation.open": { + "available": true, + "source": "application" + }, + "run.runner": { + "available": true, + "source": "application" + }, + "run.model": { + "available": true, + "source": "application" + }, + "run.effort": { + "available": true, + "source": "application" + }, + "conversation.branch": { + "available": true, + "source": "application" + }, + "conversation.clone": { + "available": true, + "source": "application" + }, + "conversation.fork": { + "available": true, + "source": "application" + }, + "analysis.ask": { + "available": false, + "source": "application", + "reason": "Complete or fail a run before analyzing it" + }, + "analysis.recipe": { + "available": false, + "source": "application", + "reason": "Complete or fail a run before analyzing it" + }, + "analysis.compare": { + "available": false, + "source": "application", + "reason": "Two completed or failed runs are required for comparison" + }, + "interaction.respond": { + "available": true, + "source": "provider" + }, + "interaction.automation": { + "available": false, + "source": "application", + "reason": "Interaction automation requires the shared response contract" + }, + "export.create": { + "available": true, + "source": "application" + }, + "application.quit": { + "available": true, + "source": "local" + }, + "help.read": { + "available": true, + "source": "local" + }, + "profile.select": { + "available": true, + "source": "application" + }, + "connection.select": { + "available": true, + "source": "application" + }, + "settings.open": { + "available": true, + "source": "application" + }, + "activity.read": { + "available": true, + "source": "local" + }, + "graph.read": { + "available": true, + "source": "local" + }, + "details.read": { + "available": true, + "source": "local" + }, + "draft.write": { + "available": true, + "source": "application" + }, + "run.send": { + "available": true, + "source": "provider" + }, + "run.cancel": { + "available": false, + "source": "runtime", + "reason": "There is no active run to cancel" + }, + "run.queue": { + "available": false, + "source": "provider", + "reason": "There is no active run" + }, + "run.steer": { + "available": false, + "source": "provider", + "reason": "There is no active run" + }, + "run.detach": { + "available": false, + "source": "provider", + "reason": "There is no active run" + }, + "run.reconnect": { + "available": false, + "source": "provider", + "reason": "There is no detached run to reconnect" + }, + "run.reconcile": { + "available": false, + "source": "provider", + "reason": "There is no detached run to reconcile" + } + }, + "draft": "", + "selectedSurface": "transcript", + "appearance": { + "color": "256", + "highContrast": false, + "reducedMotion": false + } + }, + "events": [ + { + "sequence": 1, + "revision": 1, + "kind": "workspace.opened", + "payload": { + "value": { + "kind": "workspace.opened", + "workspace": "/home/drew/code/braid-goal-latest-wt" + } + } + } + ] + } +} diff --git a/artifacts/verification/tui-audit/after/w6/states/interaction.png b/artifacts/verification/tui-audit/after/w6/states/interaction.png new file mode 100644 index 0000000..16ce89f Binary files /dev/null and b/artifacts/verification/tui-audit/after/w6/states/interaction.png differ diff --git a/artifacts/verification/tui-audit/after/w6/states/interaction.txt b/artifacts/verification/tui-audit/after/w6/states/interaction.txt new file mode 100644 index 0000000..c506c18 --- /dev/null +++ b/artifacts/verification/tui-audit/after/w6/states/interaction.txt @@ -0,0 +1,24 @@ + permission Braid starter @ pi · run fixture-run-1 +──────────────────────────────────────────────────────────────────────────────── + Allow the fixture tool to inspect the selected file? + src/app/application.ts · read-only + The fixture requests a bounded read for a real interaction preview. + export class BraidApplication { + + › 1. Approve + Continue this request + 2. Reject + 3. Cancel + + + + + + + + + + + +──────────────────────────────────────────────────────────────────────────────── + ↑↓ move · enter confirm · 1-3 quick select · esc cancel · alt+a automate diff --git a/artifacts/verification/tui-audit/after/w6/states/narrow.json b/artifacts/verification/tui-audit/after/w6/states/narrow.json new file mode 100644 index 0000000..34ed926 --- /dev/null +++ b/artifacts/verification/tui-audit/after/w6/states/narrow.json @@ -0,0 +1,376 @@ +{ + "schemaVersion": 2, + "state": "narrow", + "source": { + "binary": "packed real binary from clean npm install", + "binarySha256": "141288e0fe917635d723b4b70d464dc49baff14cc356f54de1d3f8faa5d8254f", + "tarball": "tangle-network-braid-0.1.1.tgz", + "tarballSha256": "b2c0571a7f7d0d6e847d493667dfed6ff11b52eefb5e5f43cb13470eea3562c7" + }, + "dimensions": { + "columns": 40, + "rows": 12 + }, + "terminal": "node-pty/xterm-256color", + "provenance": { + "renderer": { + "package": "@earendil-works/pi-tui@0.84.2", + "pty": "node-pty@1.1.0", + "emulator": "@xterm/headless@6.0.0", + "node": "v24.13.0", + "terminal": "xterm-256color" + }, + "raster": { + "agg": "agg 1.9.0", + "imagemagick": "Version: ImageMagick 6.9.12-98 Q16 x86_64 18038 https://legacy.imagemagick.org", + "fontFamily": "DejaVu Sans Mono", + "font": "DejaVu Sans Mono | Book | /usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", + "colorMode": "sRGB 8-bit" + } + }, + "capturePhase": "atomic-signal-frame", + "captureRevision": 1, + "frame": "\n Ask Braid starter anything.\n\n\n\n\n\n\n›\n\n\nBraid starter Ctrl+P commands\n", + "packedState": { + "schemaVersion": 2, + "capturePhase": "atomic-signal-frame", + "state": { + "schemaVersion": 2, + "revision": 1, + "sequence": 1, + "workspace": "/home/drew/code/braid-goal-latest-wt", + "conversationId": "conv-1", + "branchId": "branch-1", + "conversations": [ + { + "id": "conv-1", + "title": "New conversation", + "branchId": "branch-1", + "archived": false, + "updatedAt": "2026-08-01T00:00:00.000Z" + } + ], + "profile": { + "name": "Braid starter", + "description": "A portable starter profile for the Braid terminal", + "harness": "pi", + "model": { + "default": "fixture/deterministic", + "provider": "fixture", + "reasoningEffort": "none" + } + }, + "runConfiguration": { + "profileName": "Braid starter", + "runner": "pi", + "model": "fixture/deterministic", + "effort": "none", + "overrides": {} + }, + "draft": "", + "messages": [], + "runs": [], + "sessionUsage": { + "turns": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "analyses": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "delegated": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "attribution": "complete" + }, + "environments": [], + "interactions": [], + "queue": [], + "activeRunId": null, + "lastError": null + }, + "view": { + "revision": 1, + "workspace": "/home/drew/code/braid-goal-latest-wt", + "profileName": "Braid starter", + "profileDigest": "44ed91c937fd4079fe01c49f6bc76413656886e5aa8b2351590d3a888cfa0a14", + "runner": "pi", + "model": "fixture/deterministic", + "effort": "none", + "runOverrides": {}, + "connection": "deterministic fixture", + "conversationId": "conv-1", + "conversationTitle": "New conversation", + "conversations": [ + { + "id": "conv-1", + "title": "New conversation", + "branchId": "branch-1", + "archived": false, + "active": true, + "updatedAt": "2026-08-01T00:00:00.000Z" + } + ], + "branch": "branch-1", + "status": "empty", + "statusText": "ready for a message", + "queueCount": 0, + "queue": [], + "messages": [], + "hiddenMessageCount": 0, + "runs": [], + "sessionUsage": { + "turns": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "analyses": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "delegated": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "attribution": "complete" + }, + "environments": [], + "interactions": [], + "activity": [], + "graph": [ + { + "id": "conv-1", + "type": "conversation", + "title": "New conversation", + "status": "complete", + "depth": 0, + "edgeLabel": "attached", + "startedAt": "2026-08-01T00:00:00.000Z" + }, + { + "id": "branch-1", + "type": "branch", + "title": "Main", + "status": "complete", + "depth": 1, + "edgeLabel": "attached", + "parentIds": [ + "conversation:conv-1" + ], + "startedAt": "2026-08-01T00:00:00.000Z" + } + ], + "graphQuery": "", + "entityDetails": [], + "capabilities": { + "conversation.create": { + "available": true, + "source": "application" + }, + "conversation.open": { + "available": true, + "source": "application" + }, + "run.runner": { + "available": true, + "source": "application" + }, + "run.model": { + "available": true, + "source": "application" + }, + "run.effort": { + "available": true, + "source": "application" + }, + "conversation.branch": { + "available": true, + "source": "application" + }, + "conversation.clone": { + "available": true, + "source": "application" + }, + "conversation.fork": { + "available": true, + "source": "application" + }, + "analysis.ask": { + "available": false, + "source": "application", + "reason": "Complete or fail a run before analyzing it" + }, + "analysis.recipe": { + "available": false, + "source": "application", + "reason": "Complete or fail a run before analyzing it" + }, + "analysis.compare": { + "available": false, + "source": "application", + "reason": "Two completed or failed runs are required for comparison" + }, + "interaction.respond": { + "available": false, + "source": "runtime", + "reason": "Interaction response is not exposed by the current runtime adapter" + }, + "interaction.automation": { + "available": false, + "source": "application", + "reason": "Interaction automation requires the shared response contract" + }, + "export.create": { + "available": true, + "source": "application" + }, + "application.quit": { + "available": true, + "source": "local" + }, + "help.read": { + "available": true, + "source": "local" + }, + "profile.select": { + "available": true, + "source": "application" + }, + "connection.select": { + "available": true, + "source": "application" + }, + "settings.open": { + "available": true, + "source": "application" + }, + "activity.read": { + "available": true, + "source": "local" + }, + "graph.read": { + "available": true, + "source": "local" + }, + "details.read": { + "available": true, + "source": "local" + }, + "draft.write": { + "available": true, + "source": "application" + }, + "run.send": { + "available": true, + "source": "provider" + }, + "run.cancel": { + "available": false, + "source": "runtime", + "reason": "There is no active run to cancel" + }, + "run.queue": { + "available": false, + "source": "provider", + "reason": "There is no active run" + }, + "run.steer": { + "available": false, + "source": "provider", + "reason": "There is no active run" + }, + "run.detach": { + "available": false, + "source": "provider", + "reason": "There is no active run" + }, + "run.reconnect": { + "available": false, + "source": "provider", + "reason": "There is no detached run to reconnect" + }, + "run.reconcile": { + "available": false, + "source": "provider", + "reason": "There is no detached run to reconcile" + } + }, + "draft": "", + "selectedSurface": "transcript", + "appearance": { + "color": "256", + "highContrast": false, + "reducedMotion": false + } + }, + "events": [ + { + "sequence": 1, + "revision": 1, + "kind": "workspace.opened", + "payload": { + "value": { + "kind": "workspace.opened", + "workspace": "/home/drew/code/braid-goal-latest-wt" + } + } + } + ] + } +} diff --git a/artifacts/verification/tui-audit/after/w6/states/narrow.png b/artifacts/verification/tui-audit/after/w6/states/narrow.png new file mode 100644 index 0000000..27f84a0 Binary files /dev/null and b/artifacts/verification/tui-audit/after/w6/states/narrow.png differ diff --git a/artifacts/verification/tui-audit/after/w6/states/narrow.txt b/artifacts/verification/tui-audit/after/w6/states/narrow.txt new file mode 100644 index 0000000..913d8d5 --- /dev/null +++ b/artifacts/verification/tui-audit/after/w6/states/narrow.txt @@ -0,0 +1,12 @@ + + Ask Braid starter anything. + + + + + + +› + + +Braid starter Ctrl+P commands diff --git a/artifacts/verification/tui-audit/after/w6/states/profile.json b/artifacts/verification/tui-audit/after/w6/states/profile.json new file mode 100644 index 0000000..8a979e7 --- /dev/null +++ b/artifacts/verification/tui-audit/after/w6/states/profile.json @@ -0,0 +1,376 @@ +{ + "schemaVersion": 2, + "state": "profile", + "source": { + "binary": "packed real binary from clean npm install", + "binarySha256": "141288e0fe917635d723b4b70d464dc49baff14cc356f54de1d3f8faa5d8254f", + "tarball": "tangle-network-braid-0.1.1.tgz", + "tarballSha256": "b2c0571a7f7d0d6e847d493667dfed6ff11b52eefb5e5f43cb13470eea3562c7" + }, + "dimensions": { + "columns": 80, + "rows": 24 + }, + "terminal": "node-pty/xterm-256color", + "provenance": { + "renderer": { + "package": "@earendil-works/pi-tui@0.84.2", + "pty": "node-pty@1.1.0", + "emulator": "@xterm/headless@6.0.0", + "node": "v24.13.0", + "terminal": "xterm-256color" + }, + "raster": { + "agg": "agg 1.9.0", + "imagemagick": "Version: ImageMagick 6.9.12-98 Q16 x86_64 18038 https://legacy.imagemagick.org", + "fontFamily": "DejaVu Sans Mono", + "font": "DejaVu Sans Mono | Book | /usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", + "colorMode": "sRGB 8-bit" + } + }, + "capturePhase": "atomic-signal-frame", + "captureRevision": 1, + "frame": "\n Ask Braid starter anything.\n\n\n\n\n Active profile · Braid starter\n source Braid starter · trusted · read-only\n runner pi · model fixture/deterministic\n thinking none · max output provider default\n digest sha256:44ed91c9…8cfa0a14\n profiles\n ──────────────────────────────────────────────────────────────────────\n >\n → ✓ Braid starter trusted · read-only · Braid starte\n ──────────────────────────────────────────────────────────────────────\n enter select · ^V validate · esc close\n\n\n\n\n\n\nprofile Braid starter · pi / deterministic · fixture Ctrl+P commands\n", + "packedState": { + "schemaVersion": 2, + "capturePhase": "atomic-signal-frame", + "state": { + "schemaVersion": 2, + "revision": 1, + "sequence": 1, + "workspace": "/home/drew/code/braid-goal-latest-wt", + "conversationId": "conv-1", + "branchId": "branch-1", + "conversations": [ + { + "id": "conv-1", + "title": "New conversation", + "branchId": "branch-1", + "archived": false, + "updatedAt": "2026-08-01T00:00:00.000Z" + } + ], + "profile": { + "name": "Braid starter", + "description": "A portable starter profile for the Braid terminal", + "harness": "pi", + "model": { + "default": "fixture/deterministic", + "provider": "fixture", + "reasoningEffort": "none" + } + }, + "runConfiguration": { + "profileName": "Braid starter", + "runner": "pi", + "model": "fixture/deterministic", + "effort": "none", + "overrides": {} + }, + "draft": "", + "messages": [], + "runs": [], + "sessionUsage": { + "turns": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "analyses": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "delegated": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "attribution": "complete" + }, + "environments": [], + "interactions": [], + "queue": [], + "activeRunId": null, + "lastError": null + }, + "view": { + "revision": 1, + "workspace": "/home/drew/code/braid-goal-latest-wt", + "profileName": "Braid starter", + "profileDigest": "44ed91c937fd4079fe01c49f6bc76413656886e5aa8b2351590d3a888cfa0a14", + "runner": "pi", + "model": "fixture/deterministic", + "effort": "none", + "runOverrides": {}, + "connection": "deterministic fixture", + "conversationId": "conv-1", + "conversationTitle": "New conversation", + "conversations": [ + { + "id": "conv-1", + "title": "New conversation", + "branchId": "branch-1", + "archived": false, + "active": true, + "updatedAt": "2026-08-01T00:00:00.000Z" + } + ], + "branch": "branch-1", + "status": "empty", + "statusText": "ready for a message", + "queueCount": 0, + "queue": [], + "messages": [], + "hiddenMessageCount": 0, + "runs": [], + "sessionUsage": { + "turns": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "analyses": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "delegated": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "attribution": "complete" + }, + "environments": [], + "interactions": [], + "activity": [], + "graph": [ + { + "id": "conv-1", + "type": "conversation", + "title": "New conversation", + "status": "complete", + "depth": 0, + "edgeLabel": "attached", + "startedAt": "2026-08-01T00:00:00.000Z" + }, + { + "id": "branch-1", + "type": "branch", + "title": "Main", + "status": "complete", + "depth": 1, + "edgeLabel": "attached", + "parentIds": [ + "conversation:conv-1" + ], + "startedAt": "2026-08-01T00:00:00.000Z" + } + ], + "graphQuery": "", + "entityDetails": [], + "capabilities": { + "conversation.create": { + "available": true, + "source": "application" + }, + "conversation.open": { + "available": true, + "source": "application" + }, + "run.runner": { + "available": true, + "source": "application" + }, + "run.model": { + "available": true, + "source": "application" + }, + "run.effort": { + "available": true, + "source": "application" + }, + "conversation.branch": { + "available": true, + "source": "application" + }, + "conversation.clone": { + "available": true, + "source": "application" + }, + "conversation.fork": { + "available": true, + "source": "application" + }, + "analysis.ask": { + "available": false, + "source": "application", + "reason": "Complete or fail a run before analyzing it" + }, + "analysis.recipe": { + "available": false, + "source": "application", + "reason": "Complete or fail a run before analyzing it" + }, + "analysis.compare": { + "available": false, + "source": "application", + "reason": "Two completed or failed runs are required for comparison" + }, + "interaction.respond": { + "available": false, + "source": "runtime", + "reason": "Interaction response is not exposed by the current runtime adapter" + }, + "interaction.automation": { + "available": false, + "source": "application", + "reason": "Interaction automation requires the shared response contract" + }, + "export.create": { + "available": true, + "source": "application" + }, + "application.quit": { + "available": true, + "source": "local" + }, + "help.read": { + "available": true, + "source": "local" + }, + "profile.select": { + "available": true, + "source": "application" + }, + "connection.select": { + "available": true, + "source": "application" + }, + "settings.open": { + "available": true, + "source": "application" + }, + "activity.read": { + "available": true, + "source": "local" + }, + "graph.read": { + "available": true, + "source": "local" + }, + "details.read": { + "available": true, + "source": "local" + }, + "draft.write": { + "available": true, + "source": "application" + }, + "run.send": { + "available": true, + "source": "provider" + }, + "run.cancel": { + "available": false, + "source": "runtime", + "reason": "There is no active run to cancel" + }, + "run.queue": { + "available": false, + "source": "provider", + "reason": "There is no active run" + }, + "run.steer": { + "available": false, + "source": "provider", + "reason": "There is no active run" + }, + "run.detach": { + "available": false, + "source": "provider", + "reason": "There is no active run" + }, + "run.reconnect": { + "available": false, + "source": "provider", + "reason": "There is no detached run to reconnect" + }, + "run.reconcile": { + "available": false, + "source": "provider", + "reason": "There is no detached run to reconcile" + } + }, + "draft": "", + "selectedSurface": "transcript", + "appearance": { + "color": "256", + "highContrast": false, + "reducedMotion": false + } + }, + "events": [ + { + "sequence": 1, + "revision": 1, + "kind": "workspace.opened", + "payload": { + "value": { + "kind": "workspace.opened", + "workspace": "/home/drew/code/braid-goal-latest-wt" + } + } + } + ] + } +} diff --git a/artifacts/verification/tui-audit/after/w6/states/profile.png b/artifacts/verification/tui-audit/after/w6/states/profile.png new file mode 100644 index 0000000..99ac72d Binary files /dev/null and b/artifacts/verification/tui-audit/after/w6/states/profile.png differ diff --git a/artifacts/verification/tui-audit/after/w6/states/profile.txt b/artifacts/verification/tui-audit/after/w6/states/profile.txt new file mode 100644 index 0000000..a122d67 --- /dev/null +++ b/artifacts/verification/tui-audit/after/w6/states/profile.txt @@ -0,0 +1,24 @@ + + Ask Braid starter anything. + + + + + Active profile · Braid starter + source Braid starter · trusted · read-only + runner pi · model fixture/deterministic + thinking none · max output provider default + digest sha256:44ed91c9…8cfa0a14 + profiles + ────────────────────────────────────────────────────────────────────── + > + → ✓ Braid starter trusted · read-only · Braid starte + ────────────────────────────────────────────────────────────────────── + enter select · ^V validate · esc close + + + + + + +profile Braid starter · pi / deterministic · fixture Ctrl+P commands diff --git a/docs/03-architecture.md b/docs/03-architecture.md index ae73bba..ea659b7 100644 --- a/docs/03-architecture.md +++ b/docs/03-architecture.md @@ -381,6 +381,10 @@ A run follows the following transition table. An ordinary provider final event therefore moves directly from `starting`, `running`, `waiting`, `detached`, or `reconnecting` to its terminal result without passing through cancellation. +After process restart, an inconclusive status check cannot terminalize a run that supports cursor replay through the active execution path. + +Braid keeps that run `reconnecting` until replay supplies a provider outcome or replay itself fails without conclusive status evidence. + Terminal states never transition back to running under the same run identifier. A retry is a new run linked to the source run through a retry edge. @@ -570,6 +574,8 @@ The headless server is an interface adapter over the same application instance. It receives one versioned JSON command per line and emits versioned acknowledgement, event, error, and state records. +Each accepted send exposes the admitted active run before it later exposes the terminal state. + Every headless request carries a connection-local client request identifier. Every side-effecting headless command also carries a caller-created, globally stable operation identifier that survives reconnect and process restart. diff --git a/docs/04-runtime-contracts.md b/docs/04-runtime-contracts.md index bd720e2..c8a3e9c 100644 --- a/docs/04-runtime-contracts.md +++ b/docs/04-runtime-contracts.md @@ -10,26 +10,26 @@ When a current package blocks a real Braid flow, Braid records the unavailable a ## Evidence baseline -The following published versions were queried from npm and their installed declarations were inspected directly on 2026-08-12. +The following published versions were queried from npm and their installed declarations were inspected directly on 2026-08-14. | Package | Installed version | Braid boundary | | --- | ---: | --- | -| [`@tangle-network/agent-interface`](https://github.com/tangle-network/agent-sdk/tree/main/packages/agent-interface) | `0.47.0` | Canonical profile, capabilities, environment, stream, portable context, and interaction contracts | -| [`@tangle-network/agent-runtime`](https://github.com/tangle-network/agent-runtime) | `0.132.12` | Sole execution layer; exact executor, retained-run, environment-provider, and terminal-monitor exports | -| [`@tangle-network/agent-eval`](https://github.com/tangle-network/agent-eval) | `0.145.3` | Run records, judges, trace analysts, comparisons, and feedback trajectories | -| `@tangle-network/agent-provider-cli-bridge` | `0.6.0` | CLI Bridge environment adapter with live streaming, replay, retry-safe turns, and explicit cancel | -| `@tangle-network/agent-provider-tangle` | `0.6.3` | Tangle environment adapter over the sandbox client | -| `@tangle-network/sandbox` | `0.21.1` | Tangle cloud client used by the provider | +| [`@tangle-network/agent-interface`](https://github.com/tangle-network/agent-sdk/tree/main/packages/agent-interface) | `0.52.0` | Canonical profile, capabilities, environment, stream, portable context, and interaction contracts | +| [`@tangle-network/agent-runtime`](https://github.com/tangle-network/agent-runtime) | `0.134.4` | Sole execution layer; exact executor, retained-run, environment-provider, and terminal-monitor exports | +| [`@tangle-network/agent-eval`](https://github.com/tangle-network/agent-eval) | `0.145.11` | Run records, judges, trace analysts, comparisons, and feedback trajectories | +| `@tangle-network/agent-provider-cli-bridge` | `0.7.1` | CLI Bridge environment adapter with live streaming, replay, retry-safe turns, retained control, and explicit cancel | +| `@tangle-network/agent-provider-tangle` | `0.10.0` | Tangle environment adapter over the sandbox client | +| `@tangle-network/sandbox` | `0.26.1` | Tangle cloud client used by the provider | -The installed runtime publishes `agent-eval >=0.145.2 <0.146.0`, `agent-interface >=0.47.0 <0.48.0`, and `sandbox >=0.21.1 <0.22.0` as peer ranges. +The installed runtime publishes `agent-eval >=0.145.11 <0.146.0`, `agent-interface >=0.52.0 <0.53.0`, and `sandbox >=0.26.1 <0.27.0` as peer ranges. -Braid exercises runtime `0.132.12` with interface `0.47.0`, eval `0.145.3`, CLI Bridge adapter `0.6.0`, Tangle adapter `0.6.3`, and sandbox `0.21.1`. +Braid exercises runtime `0.134.4` with interface `0.52.0`, eval `0.145.11`, CLI Bridge adapter `0.7.1`, Tangle adapter `0.10.0`, and sandbox `0.26.1`. The lockfile pins the registry integrity for every installed package. `pnpm peers check` reports no peer dependency issues for this package graph. -The workspace override resolves every transitive `agent-interface` dependency to `0.47.0` and prevents duplicate contract graphs. +The dependency graph resolves one `agent-interface` `0.52.0` instance without a workspace override. [Agent-runtime issue 803](https://github.com/tangle-network/agent-runtime/issues/803) records the interface peer mismatch fixed in Runtime `0.132.11`. @@ -395,6 +395,8 @@ The retained adapter composes Runtime `startRetainedRun`, `reconnectRetainedRun` Its saved control reference contains provider, environment, session, execution, provider run, and request-digest identity. +Headless run state exposes that secret-free reference so a fresh controller can inspect the exact recovered execution without replaying old events. + Native follow-up turns remain disabled because the current provider does not prove a matching context boundary. An ambiguous retained start failure does not destroy its environment because an idempotent create can return a pre-existing workspace. diff --git a/docs/08-verification.md b/docs/08-verification.md index b62c07e..8c4b02b 100644 --- a/docs/08-verification.md +++ b/docs/08-verification.md @@ -77,6 +77,8 @@ Acknowledgement means Braid accepted and journaled the operation, not that an ex External effect results arrive as events and state transitions. +An accepted send emits its admission state before completion and its terminal state after completion. + Errors use stable machine codes plus concise human text and never include secrets. ### Required commands @@ -357,6 +359,10 @@ The published package is downloaded from the registry after publication and its ## Required live matrix +The CLI Bridge gate uses GLM 5.2 through OpenCode and Pi with Tangle Router, plus the configured Codex default. + +Model discovery does not make every advertised model a release gate. + | ID | Path | Real proof | | --- | --- | --- | | LIVE-01 | CLI Bridge with Pi | Exact profile materialization, text, reasoning, tool, usage, native session continuation, event replay, explicit cancel, and terminal receipt | @@ -436,89 +442,39 @@ Its SHA-256 is `1a38a26e97917073ef760525f7b18abbcca43fcf6faba54cf862e55e4886693c This proves production ephemeral cloud execution, observation, concurrency, and cleanup only. -The retained implementation passes its local lifecycle, restart, CLI Bridge regression, and error-observability tests. - -The post-hardening retained canary used a fresh bounded Sandbox key and stopped before allocation in 4.695 seconds. - -Braid rejected the run because the published provider does not supply lookup after an unacknowledged dispatch. - -Account identity stayed stable, active Sandboxes stayed at four, total Sandboxes stayed at 9,241, and every metered usage delta was zero. - -The exact owner-tag query matched zero resources before and after cleanup. - -The artifact is `artifacts/verification/live/tangle-sandbox-braid-retained-post-hardening-production-20260812.json`. - -Its SHA-256 is `625f3bf9f90bec707485592e5cab2226cefd8cdc6020a82c6d98e4bec2064fe9`. - -This is admissible failure evidence for safe retained admission, but it is not a passing retained-run proof. - -An earlier capability-injected production diagnostic canary stopped after its first attempt in 24.836 seconds. - -The attempt increased total Sandboxes by one, observed zero compute-minute change, and returned active Sandbox count from four to four. - -The owner-tag lookup found no Braid environments afterward. - -Exact deletion remained unconfirmed because Braid never received the resource identity. - -The direct start trace first found that Sandbox SDK `0.19.6` throws for a missing-turn `404` whose retry meaning is not machine-readable. - -Braid now fails closed on every numeric `404` instead of converting an ambiguous response into a cache miss. - -The next direct start reached production and failed because the deployed sidecar rejected `runControlRef` as an unknown field. - -Production serves ADC revision `7437945e19bb`, and staging serves `955e6e767e77`; neither contains exact retained-run support from `f3e09d3bf`. - -The failed secret-free canary artifact is `artifacts/verification/live/tangle-sandbox-braid-retained-canary-production-20260812.json`. - -Its SHA-256 is `c411474b31e51643e724cace10e58cba6695619e67d52640bc3bd5515650a550`. - -The artifact predates the fail-closed provider capability check and is diagnostic evidence, not release acceptance for the current implementation. - -An older preflight rejected retained execution before dispatch in 3.764 seconds because the published provider lacked the required retained contract. - -The vault Sandbox key was revoked, so account usage and cleanup queries were unavailable and that preflight cannot prove a zero-resource delta. - -Its diagnostic artifact is `artifacts/verification/live/tangle-sandbox-braid-retained-safe-preflight-production-20260812.json`. - -Its SHA-256 is `840a6234c26b7efd6f9478fb27bca753ff2726a2bb348d41480dc7ee27bd0cb9`. - -The authenticated post-hardening canary supersedes this older credential-limited preflight. - -A later ephemeral Braid canary selected Sandbox, OpenCode, and `tangle-router/glm-5.2`, but no environment reached creation. - -The canary timed out after 182.581 seconds while Runtime kept the run in `streaming`. +On 2026-08-15, the default retained production cohort passed 3 of 3 proofs at two-way concurrency. -Active and total Sandbox counts stayed unchanged, and the exact cleanup query found zero owned resources. +The cohort used Braid commit `45c641b`, OpenCode, `tangle-router/glm-5.2`, and Tangle Sandbox. -The account-wide compute counter increased by four minutes during the wait, so that delta cannot be attributed to this zero-allocation run. +It completed in 185.238 seconds. -Its artifact is `artifacts/verification/live/tangle-sandbox-braid-execution-canary-post-hardening-production-20260812.json`. +Per-proof duration was 82.255 seconds minimum, 89.016 seconds median, and 96.213 seconds maximum. -Its SHA-256 is `78c22b68221606c9acd6abd6f79094e58774c8a4863fdc7aec7e1f9258011b2c`. +Each proof killed its first Braid process with `SIGKILL`, started a fresh process, and restored one local run from the exact provider reference. -A direct Sandbox SDK request with the same profile failed in 2.177 seconds with outer HTTP 400 `CONFIG_ERROR` and nested HTTP 403. +Every replay had unique fresh event identifiers and no overlap with events acknowledged before the process loss. -The nested service message was `Service "sandbox" is not authorized for this endpoint`. +Each proof completed a follow-up turn in the same provider session. -The same bounded key authenticated Sandbox usage and the Router model list before revocation. +Each cancellation was accepted, replayed for identical input, rejected for changed input, and observed as cancelled by the provider. -[Sandbox issue 5277](https://github.com/tangle-network/agent-dev-container/issues/5277) owns this production authorization failure. +The six completed turns reported 9,231 input tokens, 1,992 output tokens, and $0 provider cost. -Runtime `0.132.12` still retries this permanent error because its text contains `provision failed`. +Token and cost data were unavailable for the three cancelled turns. -[Runtime issue 808](https://github.com/tangle-network/agent-runtime/issues/808) owns the fail-fast correction. +All three exact environment identifiers were unique and absent after cleanup. -Sandbox npm `latest` resolves to `0.21.0`, although Runtime `0.132.12` requires Sandbox `>=0.21.1 <0.22.0`. +The cohort-wide active Sandbox delta was zero. -Braid pins `0.21.1`, and [Sandbox issue 5278](https://github.com/tangle-network/agent-dev-container/issues/5278) owns the incorrect tag. +One overlapping proof sampled an active delta of one while its sibling environment existed; exact identity checks still confirmed both deletions. -[Sandbox issue 5249](https://github.com/tangle-network/agent-dev-container/issues/5249) owns the SDK response mismatch. +The three cgroup v2 samples reported 152–157 MB current memory, 630–633 MB peak memory, and a 2,048 MB limit. -[Sandbox issue 5251](https://github.com/tangle-network/agent-dev-container/issues/5251) owns the blocked platform rollout. +The provider did not report machine identity, machine IP, effective CPU, disk use, storage, verified placement, verified region, or per-sandbox cost. -`LIVE-07` remains blocked until cloud creation works, retained lookup ships, and the default three-proof cohort passes. +This cohort satisfies the retained create, process-loss recovery, replay, cancellation, and cleanup parts of `LIVE-07`. -[Runtime issue 800](https://github.com/tangle-network/agent-runtime/issues/800) owns the shared lookup contract; Braid keeps retained production admission off until a provider supplies it. +The August 12 failure artifacts remain diagnostic history and are superseded by this passing cohort. ## Runner conformance diff --git a/docs/10-upstream-strategy.md b/docs/10-upstream-strategy.md index 8c96e9e..badf072 100644 --- a/docs/10-upstream-strategy.md +++ b/docs/10-upstream-strategy.md @@ -2,7 +2,7 @@ ## Decision -Braid depends on the published Pi TUI library, builds its own application core, selectively adapts presentation and interaction code from Pi and Kimi Code, and treats OpenCode and Hermes Agent as design references. +Braid depends on the published Pi TUI library, builds its own application core, selectively adapts presentation and interaction code from Pi and Kimi Code, and treats OpenCode, Codex, Hermes Agent, and DeepSeek Harness as design references. Braid imports the runtime-owned supervisor read and control surface instead of copying the runtime monitor. @@ -14,6 +14,8 @@ The initial comparison used source, package metadata, licenses, application inst The core terminal references were refreshed from their live default branches on 2026-08-10 before and during the production UX pass. +DeepSeek Harness and Cordis were inspected at their live default branches on 2026-08-13. + The Pi repository head was `cd6852a`, with no newer change below `packages/tui` after `87142a8`. The Codex repository head was `2cc9dbb`, with no newer change below `codex-rs/tui` after `9742cc8`. @@ -37,6 +39,8 @@ The deciding question was how much source can be reused while preserving `AgentP | [OpenCode terminal packages](https://github.com/anomalyco/opencode/tree/dev/packages/tui) | `3a90639cb57619a21e59f544b3e8d23ffed56f48`; npm `1.18.16` | MIT | 50,341 lines across the TUI and run command | OpenTUI alternative and workflow reference | | [Codex terminal application](https://github.com/openai/codex/tree/main/codex-rs/tui) | repository `2cc9dbb`; TUI `9742cc8ed5def37a4575263733f70a01ca22047b`; CLI `0.147.0` | Apache-2.0 | 244,714 Rust lines | Composer, worker navigation, status, and snapshot-test reference | | [Hermes Agent terminal app](https://github.com/NousResearch/hermes-agent/tree/main/ui-tui) | `697f2896bf948731eb6fcb93caa7264478590843` | MIT | No size claim used | Client/runtime and workflow reference | +| [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) | `47f943859bef60e4160492346772ded9b24f765a`; root `0.1.0-rc.5` | MIT | No size claim used | Web, headless, subagent, job, workflow, and event-log reference | +| [Cordis](https://github.com/cordiverse/cordis) | `8cc9e33fab69e2d0476d126baaf2acb24e6a6ab4`; core `4.0.0-rc.8` | MIT | No size claim used | Rejected application-framework candidate | | [`agent-runtime` terminal monitor](https://github.com/tangle-network/agent-runtime/tree/main/src/tui) | repository `9c18cb48`; npm `0.132.11` | Project license | No refreshed size claim | Runtime-owned supervisor source, not app base | The count commands selected the named TypeScript or Rust files from sparse clones and used `wc -l`. @@ -163,6 +167,36 @@ Its React 19, Ink fork, nanostore, and Python JSON-RPC architecture would add a Braid uses Hermes as a workflow comparison and does not port its runtime protocol or application stack. +## DeepSeek Harness and Cordis + +DeepSeek Harness addresses several of the same user workflows as Braid. + +It provides durable session events, forks, approvals, questions, jobs, subagents, workflows, timing, headless execution, JSON-RPC, ACP, and a browser application. + +Its browser views are useful references for stable child identity, completed child review, nested workflow disclosure, background work, and event-derived timing. + +DeepSeek Harness is also a complete harness rather than a shell over external harnesses. + +It owns its model adapters, tool registry, session log, agent loop, provider contracts, and application composition. + +Cordis is the framework beneath that ownership model. + +It provides named services on a shared context, declared dependencies, typed event dispatch, scoped service isolation, reversible registrations, configuration composition, and hot reload. + +Cordis does not provide terminal rendering, `AgentProfile`, `agent-runtime` execution, retained cloud sessions, or cross-harness control. + +Adopting Cordis would add a second application framework and make Braid responsible for dynamic plugin lifecycle semantics that `agent-runtime` does not require. + +It would not remove any required Braid terminal, persistence, connection, or runtime adapter work. + +Braid therefore adapts DeepSeek's observable user behaviors but does not depend on Cordis or DeepSeek Harness packages. + +The rejection test is one representative child-workflow view. + +If Braid can render stable child state, nested progress, completion, timing, and replay from canonical runtime events, Cordis adds no required capability. + +The current Braid event journal, activity graph, and immutable view models satisfy that ownership test without a second plugin tree. + ## Runtime monitor `agent-runtime` package version `0.132.12` exports a diagnostic terminal module and `agent-runtime-top` binary. @@ -192,6 +226,7 @@ The current monitor's unread cancellation request is treated as an upstream defe | Interaction queue and modal arbitration | Kimi Code | Behavioral adaptation and tests | No Kimi session controller | | Searchable selector and narrow-layout edge cases | Pi and Kimi Code | Reuse library primitive and port behavior tests | No duplicate selector systems | | Permission and question workflow comparison | OpenCode and Hermes Agent | Design review only | No source copy or runtime protocol | +| Child work, background jobs, workflows, and timing | DeepSeek Harness | Behavioral reference only | No Cordis plugin tree, DeepSeek loop, session log, or protocol | | Runtime activity and worker tree | `agent-runtime` | Stable data and control import | No supervisor file parsing | | Conversation, branch, run, analysis, and environment graph | Braid | New domain component | No runner-native graph as canonical state | | Profiles, connections, context transfer, replay, and persistence | Tangle packages and Braid core | Canonical contract integration | No upstream terminal application's product model | @@ -273,6 +308,7 @@ Visual parity with a reference is insufficient when the Braid profile, capabilit | Hidden auto-approval copied from a runner | Shared interaction contract and live denial checks | | Whole-app fork temptation | ADR 001 and measured decoupling killer test before reversal | | Runtime monitor state leakage | Stable runtime imports and repository check forbidding file-layout access | +| Dynamic plugin framework duplicates application ownership | Keep Cordis and DeepSeek packages out of production dependencies and render canonical runtime events | ## Upstream-strategy acceptance diff --git a/package.json b/package.json index 94b883c..0ee82f8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tangle-network/braid", - "version": "0.1.1", + "version": "0.1.2", "description": "A universal terminal interface for portable agent profiles", "type": "module", "bin": { @@ -84,7 +84,7 @@ "test:upstream": "node scripts/release/verify-upstream.mjs", "test:package": "pnpm run build && node scripts/verify-package.mjs", "pack:check": "pnpm pack --pack-destination .tmp", - "check:release": "node scripts/check-release.mjs", + "check:release": "node --test scripts/release/registry-collision.test.mjs && node scripts/check-release.mjs", "release:check": "pnpm run check:release", "release:prepare": "node scripts/release/prepare-candidate.mjs", "release:collect": "node scripts/release/collect-release-evidence.mjs", @@ -97,14 +97,14 @@ }, "dependencies": { "@dataiku/uv": "0.12.0", - "@earendil-works/pi-tui": "0.84.1", + "@earendil-works/pi-tui": "0.84.2", "@napi-rs/keyring": "1.3.0", - "@tangle-network/agent-eval": "0.145.3", - "@tangle-network/agent-interface": "0.47.0", - "@tangle-network/agent-provider-cli-bridge": "0.6.0", - "@tangle-network/agent-provider-tangle": "0.6.3", - "@tangle-network/agent-runtime": "0.132.12", - "@tangle-network/sandbox": "0.21.1", + "@tangle-network/agent-eval": "0.145.11", + "@tangle-network/agent-interface": "0.52.0", + "@tangle-network/agent-provider-cli-bridge": "0.7.1", + "@tangle-network/agent-provider-tangle": "0.10.0", + "@tangle-network/agent-runtime": "0.134.5", + "@tangle-network/sandbox": "0.26.1", "better-sqlite3-multiple-ciphers": "13.0.3", "chalk": "6.0.0", "koffi": "3.1.4" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5d76ce2..e683986 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,7 +5,6 @@ settings: excludeLinksFromLockfile: false overrides: - '@tangle-network/agent-interface': 0.47.0 hono: 4.13.0 undici: 7.29.0 @@ -17,29 +16,29 @@ importers: specifier: 0.12.0 version: 0.12.0 '@earendil-works/pi-tui': - specifier: 0.84.1 - version: 0.84.1 + specifier: 0.84.2 + version: 0.84.2 '@napi-rs/keyring': specifier: 1.3.0 version: 1.3.0 '@tangle-network/agent-eval': - specifier: 0.145.3 - version: 0.145.3 + specifier: 0.145.11 + version: 0.145.11 '@tangle-network/agent-interface': - specifier: 0.47.0 - version: 0.47.0 + specifier: 0.52.0 + version: 0.52.0 '@tangle-network/agent-provider-cli-bridge': - specifier: 0.6.0 - version: 0.6.0 + specifier: 0.7.1 + version: 0.7.1 '@tangle-network/agent-provider-tangle': - specifier: 0.6.3 - version: 0.6.3(@tangle-network/sandbox@0.21.1) + specifier: 0.10.0 + version: 0.10.0(@tangle-network/sandbox@0.26.1) '@tangle-network/agent-runtime': - specifier: 0.132.12 - version: 0.132.12(@tangle-network/agent-eval@0.145.3)(@tangle-network/agent-interface@0.47.0)(@tangle-network/sandbox@0.21.1) + specifier: 0.134.5 + version: 0.134.5(@tangle-network/agent-eval@0.145.11)(@tangle-network/agent-interface@0.52.0)(@tangle-network/sandbox@0.26.1) '@tangle-network/sandbox': - specifier: 0.21.1 - version: 0.21.1 + specifier: 0.26.1 + version: 0.26.1 better-sqlite3-multiple-ciphers: specifier: 13.0.3 version: 13.0.3 @@ -178,8 +177,8 @@ packages: resolution: {integrity: sha512-GDjOPA1l3IBxRml8/3j/v4HD4mDbEOHYqYMfjMW8pISpOrq73o1QLGVWw6PC0gYV7ovO5MVAq1Brab56MS5WFQ==} hasBin: true - '@earendil-works/pi-tui@0.84.1': - resolution: {integrity: sha512-udeXFbgEhJ6JiB0uguwNVNkDy2FENfmtQwPcY+/iJ8GWeq18wkal1tKqa5YyeH0IqtX1vG0cGh8zfSYzyzVuLA==} + '@earendil-works/pi-tui@0.84.2': + resolution: {integrity: sha512-ds2TLihOnM5sLJB3VpXV6y0uR5efVuHf4MN7yDpsty6hA2DUO/EDVzjp/0od0G2JslzVLMjT8T8zavtxVb+qbg==} engines: {node: '>=22.19.0'} '@esbuild/aix-ppc64@0.28.2': @@ -504,46 +503,46 @@ packages: resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} engines: {node: ^14.21.3 || >=16} - '@tangle-network/agent-core@0.6.1': - resolution: {integrity: sha512-vPpxnSmXpXA9NBjmbOzEnocMJBdskMTrueCepJnztQsO3Z7CKpPQShcJbA5XRv4KyH3KPF284vgMI5lojErvsw==} + '@tangle-network/agent-core@0.8.0': + resolution: {integrity: sha512-RH7RsFg/n/UOCQL9dvfod6m512oLtlVTk5IpPPttdr3HbcS3FAwDBaImljzX/WMyNQCHpBkC/GiBzUCI2XVtdQ==} - '@tangle-network/agent-eval@0.145.3': - resolution: {integrity: sha512-KPiYjw/SYLFF+mFZF2mjneUmU1Q6SQB6B1PiJRyw3UcnxNxdR8K6M+C7+YGoa/HsEVluhGkD25VPE3T1wTAAww==} + '@tangle-network/agent-eval@0.145.11': + resolution: {integrity: sha512-CGC9BCCKNrhS84uZIQDaCCZmpPr4j1JJ7ZW/QmOir78h5psETh9Q/DF2tl79FXsXBVgyZrWWSQa7DNN+Xg4Ijg==} engines: {node: '>=20'} hasBin: true - '@tangle-network/agent-interface@0.47.0': - resolution: {integrity: sha512-sCkr7FOT+SNut8EYtwh/x2JheI+Eer30V5IQVIrO8Fn6csC9pqlnLRuNDJh//pnk4+fsg4yLiiSW8/hL1l9e1w==} + '@tangle-network/agent-interface@0.52.0': + resolution: {integrity: sha512-jISw0y/VqQxeyuI7N5zsEb2+AMAYoIkPCtDVbWwZ9n2KL45rur//0ih72xPo3M+fiE0eUj9weTAYg4mzZckiHg==} - '@tangle-network/agent-knowledge@7.2.4': - resolution: {integrity: sha512-xzDAH7YuMD3ijO4gnhvtWz92OvrQ5mlvjTco4tea5fBVkQJVZ1YfPVvgnJ0tTmJXSTe9hqpLiKUTZ5jqe9gnxg==} + '@tangle-network/agent-knowledge@7.2.6': + resolution: {integrity: sha512-0JKCWGWfevzSIt2WALhQ6RSk1grn0wB1Yj8vVMhW5XNcMQthmJ4lQRX3pBlk1cPg5yhuk48H9u4iY2YuoKnRKg==} engines: {node: '>=20.19.0'} hasBin: true peerDependencies: - '@tangle-network/agent-eval': '>=0.145.2 <0.146.0' - '@tangle-network/agent-interface': 0.47.0 + '@tangle-network/agent-eval': '>=0.145.11 <0.146.0' + '@tangle-network/agent-interface': '>=0.52.0 <0.53.0' - '@tangle-network/agent-profile-materialize@0.14.0': - resolution: {integrity: sha512-LfuyhtrPFvjMvtmZoFoPQehk+8I2e3urAlozEzm9MGQAstkPhsv6lJ/M7mpJYSwD6UVm42Q2GhqgRASi0b8lww==} + '@tangle-network/agent-profile-materialize@0.14.2': + resolution: {integrity: sha512-TuT8VIaQDEd8pvCDLEkaGWZ0aCA6e0YAUlRDeGQYNjLEqJYfQLePddqjNmf9FIgE3MepCbj/pBJEHIHzcp/C5g==} peerDependencies: - '@tangle-network/agent-interface': 0.47.0 + '@tangle-network/agent-interface': '>=0.47.0 <0.53.0' - '@tangle-network/agent-provider-cli-bridge@0.6.0': - resolution: {integrity: sha512-eQdaQsM+TPwARU+wFGBIg7j1ScPzDJvy2A+YMcwP8NsiVLgH16t3pjao/eJSifKtHNipEdV2I5poNjHLmvTfJw==} + '@tangle-network/agent-provider-cli-bridge@0.7.1': + resolution: {integrity: sha512-pjZDdm5bs5TaBFXdLJeRkueI4oUPyA1M/CuICAO/SIOT4aZTD1Fv2V/S5lmZDmUEfy3h0ezAabwsPfsVj3jAQQ==} - '@tangle-network/agent-provider-tangle@0.6.3': - resolution: {integrity: sha512-OO8HpPvqIdSPCeDLH8++Px2a/CtEu8O8ue18Yl4x7I45FWizaIRfidI7Jp9vIuWR1Os17jh6rgTXDGLKhU5oxw==} + '@tangle-network/agent-provider-tangle@0.10.0': + resolution: {integrity: sha512-rMQ3hIVo2CKUtKJg9b5zlMXbcGv+bI9ywcOKoNqJLymHbCa8aHhT/TQu02ztppPC/2vNLXLNRU4qyDqM+k0crA==} peerDependencies: - '@tangle-network/sandbox': '>=0.17.0 <1.0.0' + '@tangle-network/sandbox': '>=0.23.0 <1.0.0' - '@tangle-network/agent-runtime@0.132.12': - resolution: {integrity: sha512-ugMP/b5I7+viMSAHS54RyQOTCDPuc23z0Dh3lcjZEIQg42RPZBOji5vHvjClpdNyvBb2i7jREsc9JF7vPBixJA==} + '@tangle-network/agent-runtime@0.134.5': + resolution: {integrity: sha512-bEUUmPSj2UDXX4v7W8vUOWmUA875LMeivIUQPXY+loiL5aHnKJUTRAKgOf9QpbgW4xMVOYHxHHem7HRcoLONjQ==} engines: {node: '>=22.13.0'} hasBin: true peerDependencies: - '@tangle-network/agent-eval': '>=0.145.2 <0.146.0' - '@tangle-network/agent-interface': 0.47.0 - '@tangle-network/sandbox': '>=0.21.1 <0.22.0' + '@tangle-network/agent-eval': '>=0.145.11 <0.146.0' + '@tangle-network/agent-interface': '>=0.52.0 <0.53.0' + '@tangle-network/sandbox': '>=0.26.1 <0.27.0' peerDependenciesMeta: '@tangle-network/sandbox': optional: true @@ -551,8 +550,8 @@ packages: '@tangle-network/agent-trace-contract@1.0.2': resolution: {integrity: sha512-v7uMh56jkEp4vckevEU9xKsIatbs5dqzGPp69dFLSSXUVit0RP6VD6EANMXVlTCUk+6wVKBLHJx23XspVCEiIA==} - '@tangle-network/sandbox@0.21.1': - resolution: {integrity: sha512-xlqI9fxq9TLCOnmcxOU2XgOD5Ls6IOxLAbfa3SxVa/wUNnUCHtnwRHYteF7zUCwngVs6HR01+9dyChc0Q8m2tA==} + '@tangle-network/sandbox@0.26.1': + resolution: {integrity: sha512-FuDlGUoPa2PWYma9CGPHFapffY+r8N9UKeBJ3QZgqhqEvZqHx1XViIc69332pW634UAnj+RhWpF8ZhxQFYG7fg==} peerDependencies: '@mastra/core': ^1.36.0 '@modelcontextprotocol/sdk': ^1.29.0 @@ -792,6 +791,7 @@ packages: marked@18.0.5: resolution: {integrity: sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==} engines: {node: '>= 20'} + hasBin: true ml-array-max@2.0.0: resolution: {integrity: sha512-QQZ4kENwpWmyNb98UXRDFXrmtIXuXtt1+bSbda/2KA85+F+rrJP8hZk6QOkCQXM2Th9mUDYdq/PNByPdT9ID4A==} @@ -951,7 +951,7 @@ snapshots: '@dataiku/uv-win32-ia32': 0.12.0 '@dataiku/uv-win32-x64': 0.12.0 - '@earendil-works/pi-tui@0.84.1': + '@earendil-works/pi-tui@0.84.2': dependencies: get-east-asian-width: 1.6.0 marked: 18.0.5 @@ -1136,61 +1136,61 @@ snapshots: '@noble/hashes@1.8.0': {} - '@tangle-network/agent-core@0.6.1': + '@tangle-network/agent-core@0.8.0': dependencies: - '@tangle-network/agent-interface': 0.47.0 + '@tangle-network/agent-interface': 0.52.0 zod: 4.4.3 - '@tangle-network/agent-eval@0.145.3': + '@tangle-network/agent-eval@0.145.11': dependencies: '@asteasolutions/zod-to-openapi': 9.1.0(zod@4.4.3) '@hono/node-server': 2.1.0(hono@4.13.0) - '@tangle-network/agent-core': 0.6.1 - '@tangle-network/agent-interface': 0.47.0 + '@tangle-network/agent-core': 0.8.0 + '@tangle-network/agent-interface': 0.52.0 '@tangle-network/agent-trace-contract': 1.0.2 hono: 4.13.0 linear-sum-assignment: 1.0.9 re2js: 2.8.6 zod: 4.4.3 - '@tangle-network/agent-interface@0.47.0': + '@tangle-network/agent-interface@0.52.0': dependencies: '@noble/hashes': 1.8.0 spdx-expression-parse: 5.0.0 zod: 4.4.3 - '@tangle-network/agent-knowledge@7.2.4(@tangle-network/agent-eval@0.145.3)(@tangle-network/agent-interface@0.47.0)': + '@tangle-network/agent-knowledge@7.2.6(@tangle-network/agent-eval@0.145.11)(@tangle-network/agent-interface@0.52.0)': dependencies: - '@tangle-network/agent-eval': 0.145.3 - '@tangle-network/agent-interface': 0.47.0 + '@tangle-network/agent-eval': 0.145.11 + '@tangle-network/agent-interface': 0.52.0 proper-lockfile: 4.1.2 zod: 4.4.3 - '@tangle-network/agent-profile-materialize@0.14.0(@tangle-network/agent-interface@0.47.0)': + '@tangle-network/agent-profile-materialize@0.14.2(@tangle-network/agent-interface@0.52.0)': dependencies: - '@tangle-network/agent-interface': 0.47.0 + '@tangle-network/agent-interface': 0.52.0 - '@tangle-network/agent-provider-cli-bridge@0.6.0': + '@tangle-network/agent-provider-cli-bridge@0.7.1': dependencies: - '@tangle-network/agent-interface': 0.47.0 + '@tangle-network/agent-interface': 0.52.0 undici: 7.29.0 - '@tangle-network/agent-provider-tangle@0.6.3(@tangle-network/sandbox@0.21.1)': + '@tangle-network/agent-provider-tangle@0.10.0(@tangle-network/sandbox@0.26.1)': dependencies: - '@tangle-network/agent-interface': 0.47.0 - '@tangle-network/sandbox': 0.21.1 + '@tangle-network/agent-interface': 0.52.0 + '@tangle-network/sandbox': 0.26.1 - '@tangle-network/agent-runtime@0.132.12(@tangle-network/agent-eval@0.145.3)(@tangle-network/agent-interface@0.47.0)(@tangle-network/sandbox@0.21.1)': + '@tangle-network/agent-runtime@0.134.5(@tangle-network/agent-eval@0.145.11)(@tangle-network/agent-interface@0.52.0)(@tangle-network/sandbox@0.26.1)': dependencies: - '@tangle-network/agent-core': 0.6.1 - '@tangle-network/agent-eval': 0.145.3 - '@tangle-network/agent-interface': 0.47.0 - '@tangle-network/agent-knowledge': 7.2.4(@tangle-network/agent-eval@0.145.3)(@tangle-network/agent-interface@0.47.0) - '@tangle-network/agent-profile-materialize': 0.14.0(@tangle-network/agent-interface@0.47.0) + '@tangle-network/agent-core': 0.8.0 + '@tangle-network/agent-eval': 0.145.11 + '@tangle-network/agent-interface': 0.52.0 + '@tangle-network/agent-knowledge': 7.2.6(@tangle-network/agent-eval@0.145.11)(@tangle-network/agent-interface@0.52.0) + '@tangle-network/agent-profile-materialize': 0.14.2(@tangle-network/agent-interface@0.52.0) '@tangle-network/agent-trace-contract': 1.0.2 tar-stream: 3.2.0 optionalDependencies: - '@tangle-network/sandbox': 0.21.1 + '@tangle-network/sandbox': 0.26.1 transitivePeerDependencies: - bare-abort-controller - bare-buffer @@ -1198,10 +1198,10 @@ snapshots: '@tangle-network/agent-trace-contract@1.0.2': {} - '@tangle-network/sandbox@0.21.1': + '@tangle-network/sandbox@0.26.1': dependencies: - '@tangle-network/agent-core': 0.6.1 - '@tangle-network/agent-interface': 0.47.0 + '@tangle-network/agent-core': 0.8.0 + '@tangle-network/agent-interface': 0.52.0 zod: 4.4.3 '@types/node@26.2.0': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index bcfec34..0d3c466 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,4 @@ overrides: - '@tangle-network/agent-interface': 0.47.0 hono: 4.13.0 undici: 7.29.0 allowBuilds: @@ -10,14 +9,14 @@ allowBuilds: node-pty: true ignoreScripts: false minimumReleaseAgeExclude: - - '@tangle-network/agent-runtime@0.126.0 || 0.128.0 || 0.131.0 || 0.131.1 || 0.131.2 || 0.131.3 || 0.131.4 || 0.131.5 || 0.131.6 || 0.131.7 || 0.132.0 || 0.132.4 || 0.132.6 || 0.132.9 || 0.132.10 || 0.132.11 || 0.132.12' - - '@tangle-network/agent-eval@0.143.0 || 0.144.0 || 0.144.1 || 0.144.3 || 0.144.4 || 0.144.6 || 0.144.8 || 0.144.10 || 0.144.11 || 0.144.13 || 0.145.0 || 0.145.1 || 0.145.2 || 0.145.3' - - '@tangle-network/agent-interface@0.46.0 || 0.46.1 || 0.47.0' - - '@tangle-network/agent-knowledge@7.0.8 || 7.1.2 || 7.1.3 || 7.2.0 || 7.2.2 || 7.2.3 || 7.2.4' - - '@tangle-network/agent-profile-materialize@0.10.2 || 0.13.1 || 0.14.0' - - '@tangle-network/agent-provider-cli-bridge@0.4.2 || 0.4.3 || 0.5.0 || 0.6.0' - - '@tangle-network/agent-provider-tangle@0.6.0 || 0.6.1 || 0.6.2 || 0.6.3' - - '@tangle-network/sandbox@0.17.3 || 0.18.0 || 0.19.2 || 0.19.3 || 0.19.4 || 0.19.5 || 0.19.6 || 0.21.0 || 0.21.1' + - '@tangle-network/agent-runtime@0.126.0 || 0.128.0 || 0.131.0 || 0.131.1 || 0.131.2 || 0.131.3 || 0.131.4 || 0.131.5 || 0.131.6 || 0.131.7 || 0.132.0 || 0.132.4 || 0.132.6 || 0.132.9 || 0.132.10 || 0.132.11 || 0.132.12 || 0.133.6 || 0.134.2 || 0.134.4 || 0.134.5' + - '@tangle-network/agent-eval@0.143.0 || 0.144.0 || 0.144.1 || 0.144.3 || 0.144.4 || 0.144.6 || 0.144.8 || 0.144.10 || 0.144.11 || 0.144.13 || 0.145.0 || 0.145.1 || 0.145.2 || 0.145.3 || 0.145.9 || 0.145.10 || 0.145.11' + - '@tangle-network/agent-interface@0.46.0 || 0.46.1 || 0.47.0 || 0.51.0 || 0.52.0' + - '@tangle-network/agent-knowledge@7.0.8 || 7.1.2 || 7.1.3 || 7.2.0 || 7.2.2 || 7.2.3 || 7.2.4 || 7.2.6' + - '@tangle-network/agent-profile-materialize@0.10.2 || 0.13.1 || 0.14.0 || 0.14.2' + - '@tangle-network/agent-provider-cli-bridge@0.4.2 || 0.4.3 || 0.5.0 || 0.6.0 || 0.6.4 || 0.7.1' + - '@tangle-network/agent-provider-tangle@0.6.0 || 0.6.1 || 0.6.2 || 0.6.3 || 0.7.0 || 0.9.0 || 0.10.0' + - '@tangle-network/sandbox@0.17.3 || 0.18.0 || 0.19.2 || 0.19.3 || 0.19.4 || 0.19.5 || 0.19.6 || 0.21.0 || 0.21.1 || 0.26.1' - hono@4.13.0 - '@esbuild/aix-ppc64@0.28.2' - '@esbuild/android-arm64@0.28.2' @@ -46,4 +45,5 @@ minimumReleaseAgeExclude: - '@esbuild/win32-ia32@0.28.2' - '@esbuild/win32-x64@0.28.2' - esbuild@0.28.2 - - '@tangle-network/agent-core@0.5.4 || 0.6.0 || 0.6.1' + - '@tangle-network/agent-core@0.5.4 || 0.6.0 || 0.6.1 || 0.7.1 || 0.8.0' + - '@earendil-works/pi-tui@0.84.2' diff --git a/scripts/capture-product-demo.mjs b/scripts/capture-product-demo.mjs index d2d0868..c3a88a4 100644 --- a/scripts/capture-product-demo.mjs +++ b/scripts/capture-product-demo.mjs @@ -118,7 +118,7 @@ export async function captureProductDemo({ spawnTerminal, normalized, castFor }) 'retained activity', ) await pause(900) - terminal.input('\u001b') + terminal.input('\u001b[D') await pause(350) await typeText(terminal, '/ask What should this agent improve next?', 18) diff --git a/scripts/live-bridge/bridge.mjs b/scripts/live-bridge/bridge.mjs index a9fbbb3..3a4b659 100644 --- a/scripts/live-bridge/bridge.mjs +++ b/scripts/live-bridge/bridge.mjs @@ -49,15 +49,6 @@ function bridgeModelIdForDefinition(definition) { return definition.bridgeModelId ?? definition.modelId } -function canonicalTargetModelId(backend, bridgeModelId) { - const prefix = `${backend}/` - if (!bridgeModelId.startsWith(prefix)) return bridgeModelId - const route = bridgeModelId.slice(prefix.length) - const parts = route.split('/') - if (parts.length !== 1 || parts[0].length === 0) return bridgeModelId - return `${backend}/${backend}/${parts[0]}` -} - function targetKeyFor(backend, modelId) { const suffix = modelId .replace(`${backend}/`, '') @@ -68,15 +59,16 @@ function targetKeyFor(backend, modelId) { } function targetDefinitionForAdvertisedModel(backend, bridgeModelId) { - const modelId = canonicalTargetModelId(backend, bridgeModelId) + const modelId = bridgeModelId const route = modelId.slice(`${backend}/`.length) - const separator = route.indexOf('/') - const provider = separator > 0 ? route.slice(0, separator) : backend - const model = separator > 0 ? route.slice(separator + 1) : route - if (provider.length === 0 || model.length === 0) return undefined + const parts = route.split('/') + const provider = parts.length > 1 ? parts.shift() : undefined + const model = parts.join('/') + if (provider === '' || model.length === 0 || parts.some((part) => part.length === 0)) + return undefined return { key: targetKeyFor(backend, modelId), - label: `${backend} ${provider}/${model}`, + label: provider === undefined ? `${backend} ${model}` : `${backend} ${provider}/${model}`, modelId, bridgeModelId, backend, @@ -86,8 +78,8 @@ function targetDefinitionForAdvertisedModel(backend, bridgeModelId) { /** * Selects one canonical target for every ready runner represented by the bridge catalog. * - * Bridge-only routes such as `codex/default` become `codex/codex/default` in the - * profile receipt while `bridgeModelId` keeps the exact route sent to discovery. + * Runner-only routes such as `codex/default` remain unqualified. Braid must not + * invent a model provider that the bridge catalog did not advertise. */ export function releaseTargetDefinitions(definitions, modelsResponse, healthResponse) { const advertised = modelIds(modelsResponse) diff --git a/scripts/live-bridge/config.mjs b/scripts/live-bridge/config.mjs index 42b45b6..ae84432 100644 --- a/scripts/live-bridge/config.mjs +++ b/scripts/live-bridge/config.mjs @@ -11,18 +11,17 @@ import { evidenceValue } from './redaction.mjs' export function profileForBridgeTarget(target) { const parts = target.modelId.split('/') const harness = parts.shift() - const provider = parts.shift() + const provider = parts.length > 1 ? parts.shift() : undefined const model = parts.join('/') if ( harness !== target.backend || - provider === undefined || - provider.length === 0 || + provider === '' || model.length === 0 || parts.some((part) => part.length === 0) ) { throw new LiveBridgeError( 'TARGET_MODEL_ROUTE_INVALID', - `CLI Bridge target ${target.modelId} must be // and agree with backend ${target.backend}`, + `CLI Bridge target ${target.modelId} must be / or // and agree with backend ${target.backend}`, exitCodes.unavailable, { target: target.modelId, backend: target.backend }, ) @@ -32,7 +31,11 @@ export function profileForBridgeTarget(target) { description: 'Opt-in packed CLI Bridge smoke profile', version: '0.1.0', harness, - model: { provider, default: model, reasoningEffort: 'none' }, + model: { + ...(provider === undefined ? {} : { provider }), + default: model, + reasoningEffort: 'none', + }, } } diff --git a/scripts/live-bridge/constants.mjs b/scripts/live-bridge/constants.mjs index df06009..d93dff8 100644 --- a/scripts/live-bridge/constants.mjs +++ b/scripts/live-bridge/constants.mjs @@ -9,7 +9,7 @@ export const liveProofScope = Object.freeze({ claims: Object.freeze([ 'packed startup', 'exact marker response for each required target', - 'advertised reconnect, cancel, and interaction semantics', + 'advertised cancel and interaction semantics', 'bounded process cleanup', ]), excludes: Object.freeze(['LIVE-01..05 full interactive runner conformance']), @@ -45,9 +45,9 @@ export const targetDefinitions = Object.freeze([ backend: 'opencode', }, { - key: 'luna-max', - label: 'Luna Max', - modelId: 'pi/openai-codex/gpt-5.6-luna', + key: 'pi-glm-5.2', + label: 'GLM 5.2 through Pi', + modelId: 'pi/tangle-router/glm-5.2', backend: 'pi', }, ]) @@ -63,5 +63,5 @@ export const livePrompts = Object.freeze({ interactive: (key) => `Ask one user-visible permission question before completing LIVE_BRAID_${key.toUpperCase().replaceAll('.', '_')}_INTERACTIVE_OK.`, restart: (key) => - `Keep LIVE_BRAID_${key.toUpperCase().replaceAll('.', '_')}_RESTART active until the client explicitly reconciles it.`, + `For LIVE_BRAID_${key.toUpperCase().replaceAll('.', '_')}_RESTART, produce a numbered list from 1 to 1000 with one short word per line.`, }) diff --git a/scripts/live-bridge/matrix.mjs b/scripts/live-bridge/matrix.mjs index 1fea818..6ec4ea5 100644 --- a/scripts/live-bridge/matrix.mjs +++ b/scripts/live-bridge/matrix.mjs @@ -21,8 +21,8 @@ import { interactionFromResponse, semanticCommandStatus, } from './protocol.mjs' -import { verifyCancel } from './target-actions.mjs' import { executeReleaseProofs } from './release-proofs.mjs' +import { verifyCancel } from './target-actions.mjs' import { defaultTargetPolicy, readTargetPolicy, targetPolicyEvidence } from './target-policy.mjs' async function withFakeBridge( @@ -72,18 +72,18 @@ async function withFakeBridge( async function runTargetPolicyMatrix() { const both = defaultTargetPolicy.definitions const exactGlm = both[0].modelId - const exactLuna = both[1].modelId + const exactPiGlm = both[1].modelId const readyBackends = [ { name: 'opencode', state: 'ready' }, { name: 'pi', state: 'ready' }, { name: 'claude-code', state: 'starting' }, ] - await withFakeBridge([exactGlm, exactLuna], readyBackends, async (endpoint) => { + await withFakeBridge([exactGlm, exactPiGlm], readyBackends, async (endpoint) => { const evidence = {} const result = await discoverBridge(endpoint, undefined, evidence, process.cwd(), both) assert.deepEqual( result.selected.map(({ modelId }) => modelId), - [exactGlm, exactLuna], + [exactGlm, exactPiGlm], ) }) await withFakeBridge([exactGlm], readyBackends, async (endpoint) => { @@ -94,7 +94,7 @@ async function runTargetPolicyMatrix() { ) assert.deepEqual( evidence.missingTargets.map(({ modelId }) => modelId), - [exactLuna], + [exactPiGlm], ) }) await withFakeBridge( @@ -110,7 +110,7 @@ async function runTargetPolicyMatrix() { { modelsStatus: 503 }, ) await withFakeBridge( - [exactGlm, exactLuna], + [exactGlm, exactPiGlm], readyBackends, async (endpoint) => { const evidence = {} @@ -121,7 +121,7 @@ async function runTargetPolicyMatrix() { }, { capabilities: {} }, ) - await withFakeBridge([exactLuna], readyBackends, async (endpoint) => { + await withFakeBridge([exactPiGlm], readyBackends, async (endpoint) => { const evidence = {} await assert.rejects( discoverBridge(endpoint, undefined, evidence, process.cwd(), both), @@ -133,7 +133,7 @@ async function runTargetPolicyMatrix() { ) }) await withFakeBridge( - [exactGlm, exactLuna], + [exactGlm, exactPiGlm], [{ name: 'opencode', state: 'ready' }], async (endpoint) => { const evidence = {} @@ -177,13 +177,13 @@ async function runTargetPolicyMatrix() { const releaseModels = { ok: true, body: { - data: [{ id: exactGlm }, { id: exactLuna }, { id: 'codex/default' }], + data: [{ id: exactGlm }, { id: exactPiGlm }, { id: 'codex/default' }], }, } const releaseDefinitions = releaseTargetDefinitions(both, releaseModels, releaseHealth) assert.deepEqual( releaseDefinitions.map(({ modelId }) => modelId), - [exactGlm, exactLuna, 'codex/codex/default'], + [exactGlm, exactPiGlm, 'codex/default'], ) assert.equal(releaseDefinitions[2].bridgeModelId, 'codex/default') const releaseEvidence = {} @@ -197,14 +197,14 @@ async function runTargetPolicyMatrix() { releaseTargets.map(({ modelId, bridgeModelId }) => ({ modelId, bridgeModelId })), [ { modelId: exactGlm, bridgeModelId: exactGlm }, - { modelId: exactLuna, bridgeModelId: exactLuna }, - { modelId: 'codex/codex/default', bridgeModelId: 'codex/default' }, + { modelId: exactPiGlm, bridgeModelId: exactPiGlm }, + { modelId: 'codex/default', bridgeModelId: 'codex/default' }, ], ) } async function runConfigurationMatrix() { - const [glm, luna] = defaultTargetPolicy.definitions + const [glm, piGlm] = defaultTargetPolicy.definitions assert.deepEqual(profileForBridgeTarget(glm), { name: `Braid live ${glm.modelId}`, description: 'Opt-in packed CLI Bridge smoke profile', @@ -212,21 +212,40 @@ async function runConfigurationMatrix() { harness: 'opencode', model: { provider: 'zai-coding-plan', default: 'glm-5.2', reasoningEffort: 'none' }, }) - assert.deepEqual(profileForBridgeTarget(luna), { - name: `Braid live ${luna.modelId}`, + assert.deepEqual(profileForBridgeTarget(piGlm), { + name: `Braid live ${piGlm.modelId}`, description: 'Opt-in packed CLI Bridge smoke profile', version: '0.1.0', harness: 'pi', - model: { provider: 'openai-codex', default: 'gpt-5.6-luna', reasoningEffort: 'none' }, + model: { + provider: 'tangle-router', + default: 'glm-5.2', + reasoningEffort: 'none', + }, }) + assert.deepEqual( + profileForBridgeTarget({ + key: 'codex-default', + label: 'codex default', + modelId: 'codex/default', + backend: 'codex', + }), + { + name: 'Braid live codex/default', + description: 'Opt-in packed CLI Bridge smoke profile', + version: '0.1.0', + harness: 'codex', + model: { default: 'default', reasoningEffort: 'none' }, + }, + ) assert.throws( - () => profileForBridgeTarget({ ...luna, backend: 'codex' }), + () => profileForBridgeTarget({ ...piGlm, backend: 'codex' }), (error) => error.code === 'TARGET_MODEL_ROUTE_INVALID' && error.exitCode === 2, ) assert.match(createLiveCredentialId('00000000-0000-0000-0000-000000000000'), /^credential-/u) const endpoint = 'http://127.0.0.1:4567' - const linuxEnvironment = bridgeLaunchEnvironment([luna], endpoint, { + const linuxEnvironment = bridgeLaunchEnvironment([piGlm], endpoint, { environment: { PATH: '/usr/bin' }, platform: 'linux', }) @@ -234,14 +253,14 @@ async function runConfigurationMatrix() { assert.equal(linuxEnvironment.BRIDGE_JAIL_MODE, 'fs-jail') assert.equal(linuxEnvironment.BRIDGE_PORT, '4567') assert.equal( - bridgeLaunchEnvironment([luna], endpoint, { + bridgeLaunchEnvironment([piGlm], endpoint, { environment: { BRIDGE_JAIL_MODE: 'off' }, platform: 'linux', }).BRIDGE_JAIL_MODE, 'off', ) assert.equal( - bridgeLaunchEnvironment([luna], endpoint, { + bridgeLaunchEnvironment([piGlm], endpoint, { environment: {}, platform: 'darwin', }).BRIDGE_JAIL_MODE, @@ -258,10 +277,10 @@ async function runConfigurationMatrix() { const root = await mkdtemp(join(tmpdir(), 'braid-live-config-matrix-')) try { - const written = await writeTargetConfig(root, endpoint, luna, undefined) + const written = await writeTargetConfig(root, endpoint, piGlm, undefined) assert.deepEqual(JSON.parse(await readFile(written.profilePath, 'utf8')), written.profile) - assert.equal(written.profile.model.default, 'gpt-5.6-luna') - assert.equal(written.profile.model.provider, 'openai-codex') + assert.equal(written.profile.model.default, 'glm-5.2') + assert.equal(written.profile.model.provider, 'tangle-router') const credential = await writeTargetConfig(root, endpoint, glm, { recordRef: createLiveCredentialId('11111111-1111-1111-1111-111111111111'), }) @@ -380,6 +399,11 @@ async function runSemanticMatrix() { runId: 'run-cancel-live', admission: { capabilities: { controls: { cancel: true } } }, }, + { + version: 1, + type: 'state', + state: { runs: [{ id: 'run-cancel-live', status: 'streaming' }] }, + }, { version: 1, type: 'ack', diff --git a/scripts/live-bridge/process.mjs b/scripts/live-bridge/process.mjs index 229158e..5336b8d 100644 --- a/scripts/live-bridge/process.mjs +++ b/scripts/live-bridge/process.mjs @@ -1,4 +1,5 @@ import { spawn } from 'node:child_process' +import { randomUUID } from 'node:crypto' import { StreamingRedactor } from './capture.mjs' import { exitCodes } from './constants.mjs' @@ -57,6 +58,8 @@ function terminationResult(child, values) { termSent: values.termSent, killSent: values.killSent, forcedKill: values.killSent, + initialExited: values.initialExited, + initialTree: values.initialTree, exited: values.exited, descendantsExited: tree.supported && tree.gone, descendantsVerified: tree.supported && tree.gone, @@ -71,6 +74,8 @@ export async function observeNaturalExit( child, { naturalExitTimeoutMs = defaultNaturalExitTimeoutMs, treeTimeoutMs = defaultTermTimeoutMs } = {}, ) { + const initialTree = processTreeStatus(child) + const initialExited = hasExited(child) const exited = await waitForExit(child, naturalExitTimeoutMs) if (!exited) return terminationResult(child, { @@ -79,6 +84,8 @@ export async function observeNaturalExit( killTimeoutMs: 0, termSent: false, killSent: false, + initialExited, + initialTree, exited: false, cleanupStatus: 'still-running', }) @@ -89,6 +96,8 @@ export async function observeNaturalExit( killTimeoutMs: treeTimeoutMs, termSent: false, killSent: false, + initialExited, + initialTree, exited: true, cleanupStatus: tree.supported && tree.gone ? 'natural-exit' : 'unsupported', tree, @@ -147,6 +156,8 @@ export async function terminateProcess( killTimeoutMs, termSent, killSent, + initialExited, + initialTree, exited, cleanupStatus, termSignal, @@ -184,11 +195,17 @@ export class RpcSession { } async #start() { + const startedAt = Date.now() this.child = await managedSpawn(process.execPath, [this.binary, 'rpc'], { cwd: this.workspace, env: this.env, stdio: ['pipe', 'pipe', 'pipe'], }) + this.processIdentity = { + instanceId: randomUUID(), + pid: this.child.pid, + startedAt, + } this.exit = new Promise((resolveExit, rejectExit) => { this.child.once('error', rejectExit) this.child.once('close', (code, signal) => { @@ -267,7 +284,7 @@ export class RpcSession { })), defaultKillTimeoutMs, ) - return { termination, natural, exit } + return { processIdentity: this.processIdentity, termination, natural, exit } })() return this.closePromise } @@ -285,6 +302,7 @@ export class RpcSession { defaultKillTimeoutMs, ) return { + processIdentity: this.processIdentity, termination, natural: { cleanupStatus: 'forced-exit', diff --git a/scripts/live-bridge/process.test.mjs b/scripts/live-bridge/process.test.mjs new file mode 100644 index 0000000..3def518 --- /dev/null +++ b/scripts/live-bridge/process.test.mjs @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { managedSpawn, terminateProcess } from './process.mjs' + +function waitForClose(child) { + if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve() + return new Promise((resolve) => child.once('close', resolve)) +} + +test('termination evidence marks a naturally exited process as unsignalled', async () => { + const child = await managedSpawn(process.execPath, ['-e', 'process.exit(0)'], { + stdio: 'ignore', + }) + await waitForClose(child) + + const result = await terminateProcess(child, { termTimeoutMs: 100, killTimeoutMs: 100 }) + + assert.equal(result.initialExited, true) + assert.equal(result.termSent, false) + assert.equal(result.killSent, false) + assert.equal(result.cleanupStatus, 'already-exited') +}) + +test('termination evidence records signal-driven process-tree cleanup', async () => { + const child = await managedSpawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { + stdio: 'ignore', + }) + + const result = await terminateProcess(child, { termTimeoutMs: 1_000, killTimeoutMs: 1_000 }) + + assert.equal(result.initialExited, false) + assert.equal(result.initialTree.supported, true) + assert.equal(result.initialTree.gone, false) + assert.equal(result.termSent || result.killSent, true) + assert.equal(result.exited, true) + assert.equal(result.descendantsExited, true) + assert.equal(result.descendantsVerified, true) + assert.notEqual(result.cleanupStatus, 'already-exited') +}) diff --git a/scripts/live-bridge/release-operations.mjs b/scripts/live-bridge/release-operations.mjs index 988f231..b47c9bd 100644 --- a/scripts/live-bridge/release-operations.mjs +++ b/scripts/live-bridge/release-operations.mjs @@ -14,7 +14,9 @@ import { assertContextTransfer, assertObservedUsage, assertRetainedInteraction, + assertRetainedRestartProof, assertTargetRunIdentity, + terminalReceipts as collectTerminalReceipts, } from './release-proof-validation.mjs' import { initializeTarget, runNormalTurn } from './target-actions.mjs' import { @@ -450,8 +452,9 @@ export async function executeRestartReconciliation( timeoutMs, ) const activeRun = runFromState(activeState.state, started.runId) + const admissionResponseIndex = getSession().responses.indexOf(started.response) if ( - activeState.state?.activeRunId !== started.runId && + activeState.state?.activeRunId !== started.runId || !['running', 'waiting', 'streaming'].includes(activeRun?.status) ) { throw new LiveBridgeError( @@ -461,24 +464,110 @@ export async function executeRestartReconciliation( { run: activeRun, state: activeState }, ) } - const oldSession = getSession() - const forcedProcess = await oldSession.forceStop() + const activeStream = await getSession().waitFor( + 'restart operation streamed active run event', + (response) => + response.type === 'event' && + response.event?.runId === started.runId && + getSession().responses.indexOf(response) > admissionResponseIndex && + [ + 'run.text.delta', + 'run.part.updated', + 'run.reasoning.delta', + 'run.tool.call', + 'run.tool.result', + 'run.artifact', + 'run.proposal', + 'run.interaction', + ].includes(response.event.kind) && + Number.isSafeInteger(response.event?.provider?.providerSequence) && + response.event.provider.providerSequence > 0, + timeoutMs, + ) + const streamedState = await operationState( + getSession(), + result, + operationPrefix, + 'streamed', + timeoutMs, + ) + const streamedRun = runFromState(streamedState.state, started.runId) + const activeStateResponseIndex = getSession().responses.indexOf(activeState) + const activeStreamResponseIndex = getSession().responses.indexOf(activeStream) + const activeModel = { + admission: { + runId: started.runId, + requestId: started.send.requestId, + operationId: started.send.operationId, + responseIndex: admissionResponseIndex, + revision: started.response.revision, + }, + state: { + runId: started.runId, + activeRunId: activeState.state?.activeRunId ?? null, + status: activeRun?.status ?? null, + responseIndex: activeStateResponseIndex, + revision: activeState.revision, + }, + progress: { + runId: started.runId, + kind: activeStream.event.kind, + responseIndex: activeStreamResponseIndex, + sequence: activeStream.sequence, + revision: activeStream.revision, + providerSequence: activeStream.event.provider.providerSequence, + cursor: activeStream.event.provider.cursor ?? null, + }, + } + const savedCursor = streamedRun?.lastCursor + const savedProviderSequence = streamedRun?.lastProviderSequence + const detachRequest = operationRequest(result, operationPrefix, 'detach', 'detach', { + runId: started.runId, + }) + const detachResponse = await sendOperationRequest( + getSession(), + result, + detachRequest, + 'restart operation detach acknowledgement', + timeoutMs, + ) if ( - forcedProcess.termination?.exited !== true || - forcedProcess.termination?.descendantsExited !== true || - forcedProcess.termination?.descendantsVerified !== true + detachResponse.type !== 'ack' || + !['accepted', 'already-applied'].includes(detachResponse.outcome) ) { throw new LiveBridgeError( - 'LIVE_RELEASE_RESTART_CONTAINMENT_FAILED', - 'The forced restart did not prove termination of the packed process tree', + 'LIVE_RELEASE_RESTART_DETACH_FAILED', + 'The restart operation did not acknowledge detaching the active run', exitCodes.failed, - { process: forcedProcess }, + { response: detachResponse }, ) } + const detachedEvent = await getSession().waitFor( + 'restart operation detached event', + (response) => + response.type === 'event' && + response.event?.kind === 'run.detached' && + response.event?.runId === started.runId, + timeoutMs, + ) + const detachedState = await operationState( + getSession(), + result, + operationPrefix, + 'detached', + timeoutMs, + ) + const detachedRun = runFromState(detachedState.state, started.runId) + const conversationId = result.conversationId + const branchId = result.branchId + const oldSession = getSession() + const oldProcess = oldSession.processIdentity + const forcedProcess = await oldSession.forceStop() const requestCountBeforeRestart = result.requests.length setSession(undefined) const restarted = await RpcSession.create(binary, config.workspace, env, timeoutMs) setSession(restarted) + const newProcess = restarted.processIdentity await initializeTarget(restarted, result, classifyPackedStartup, { operationPrefix: `${operationPrefix}-reopened`, }) @@ -497,26 +586,62 @@ export async function executeRestartReconciliation( { runId: started.runId, state: reopenedState }, ) } - const reconcileRequest = operationRequest(result, operationPrefix, 'reconcile', 'reconcile', { + if (result.conversationId !== conversationId || result.branchId !== branchId) { + throw new LiveBridgeError( + 'LIVE_RELEASE_RESTART_STATE_CHANGED', + 'The forced restart reopened a different Braid conversation or branch', + exitCodes.failed, + { + before: { conversationId, branchId }, + after: { conversationId: result.conversationId, branchId: result.branchId }, + }, + ) + } + const reopenedRun = runFromState(reopenedState.state, started.runId) + const responseCountBeforeReconnect = restarted.responses.length + const reconnectRequest = operationRequest(result, operationPrefix, 'reconnect', 'reconnect', { runId: started.runId, }) - const reconcileResponse = await sendOperationRequest( + const reconnectResponse = await sendOperationRequest( restarted, result, - reconcileRequest, - 'restart reconciliation acknowledgement', + reconnectRequest, + 'restart reconnection acknowledgement', + timeoutMs, + ) + const reconnectBoundaryResponse = await restarted.waitFor( + 'restart reconnecting boundary', + (response) => + restarted.responses.indexOf(response) >= responseCountBeforeReconnect && + response.type === 'event' && + response.event?.kind === 'run.reconnecting' && + response.event?.runId === started.runId && + Number.isSafeInteger(response.sequence) && + response.sequence > 0 && + Number.isSafeInteger(response.revision) && + response.revision > 0, + timeoutMs, + ) + const reconnectBoundaryResponseIndex = restarted.responses.indexOf(reconnectBoundaryResponse) + const replayEvent = await restarted.waitFor( + 'restart replayed post-cursor event', + (response) => + restarted.responses.indexOf(response) > reconnectBoundaryResponseIndex && + response.type === 'event' && + response.event?.runId === started.runId && + response.event?.kind !== 'run.reconnecting' && + Number.isSafeInteger(response.sequence) && + response.sequence > reconnectBoundaryResponse.sequence && + Number.isSafeInteger(response.revision) && + response.revision > reconnectBoundaryResponse.revision && + Number.isSafeInteger(response.event?.provider?.providerSequence) && + Number.isSafeInteger(savedProviderSequence) && + savedProviderSequence > 0 && + response.event.provider.providerSequence === savedProviderSequence + 1 && + response.sequence <= reconnectResponse.revision && + response.revision <= reconnectResponse.revision, timeoutMs, ) - if (reconcileResponse.type !== 'ack') { - throw new LiveBridgeError( - 'LIVE_RELEASE_RECONCILIATION_FAILED', - 'The forced restart did not acknowledge explicit run reconciliation', - reconcileResponse.code === 'CAPABILITY_UNAVAILABLE' - ? exitCodes.unavailable - : exitCodes.failed, - { response: reconcileResponse }, - ) - } const terminal = await terminalOperationState( restarted, result, @@ -533,15 +658,46 @@ export async function executeRestartReconciliation( { requests: postRestartRequests }, ) } - if (terminal.run.status === 'cancelled') { - throw new LiveBridgeError( - 'LIVE_RELEASE_RESTART_CANCEL_LABEL_UNSAFE', - 'The restart operation could not prove that the original run was not cancelled', - exitCodes.failed, - { run: terminal.run }, - ) - } + const terminalReceipts = [ + ...collectTerminalReceipts(oldSession.responses, oldProcess.instanceId, started.runId), + ...collectTerminalReceipts(restarted.responses, newProcess.instanceId, started.runId), + ] + const restartProof = assertRetainedRestartProof({ + runId: started.runId, + savedCursor, + savedProviderSequence, + beforeDetach: streamedRun, + detached: detachedRun, + reopened: reopenedRun, + final: terminal.run, + oldProcess, + newProcess, + forcedProcess, + activeModel, + reopenedRevision: reopenedState.revision, + reconnectRequest, + reconnectResponse, + reconnectBoundary: { + runId: reconnectBoundaryResponse.event.runId, + kind: reconnectBoundaryResponse.event.kind, + responseIndex: reconnectBoundaryResponseIndex, + sequence: reconnectBoundaryResponse.sequence, + revision: reconnectBoundaryResponse.revision, + savedCursor: reconnectBoundaryResponse.event.after ?? null, + }, + replayEvent: { + event: replayEvent.event, + kind: replayEvent.event.kind, + responseIndex: restarted.responses.indexOf(replayEvent), + sequence: replayEvent.sequence, + revision: replayEvent.revision, + }, + terminalReceipts, + finalState: terminal.response, + terminalSession: newProcess.instanceId, + }) const targetProof = assertTargetRunIdentity(terminal.run, target) + const usage = assertObservedUsage(terminal.run) return { runId: started.runId, runIds: [started.runId], @@ -549,10 +705,26 @@ export async function executeRestartReconciliation( evidence: { runId: started.runId, forcedProcess: evidenceValue(forcedProcess), - reconciledStatus: terminal.run.status, + oldProcess: evidenceValue(oldProcess), + newProcess: evidenceValue(newProcess), + activeModel: evidenceValue(activeModel), + activeStream: evidenceValue(activeStream), + streamedState: evidenceValue(streamedState), + detachedEvent: evidenceValue(detachedEvent), + detachedState: evidenceValue(detachedState), + savedCursor, + savedProviderSequence, reopenedState: evidenceValue(reopenedState), - reconciliation: evidenceValue(reconcileResponse), + detach: evidenceValue(detachResponse), + reconnect: evidenceValue(reconnectResponse), + reconnectBoundary: evidenceValue(reconnectBoundaryResponse), + replayEvent: evidenceValue(replayEvent), + terminalReceipts: evidenceValue(terminalReceipts), finalState: evidenceValue(terminal.response), + restartProof: evidenceValue(restartProof), + usage, + statePath: env.BRAID_STATE_PATH, + endpoint: result.connection.endpoint, resubmitted: false, }, } diff --git a/scripts/live-bridge/release-proof-validation.mjs b/scripts/live-bridge/release-proof-validation.mjs index 718b008..bf2ca4d 100644 --- a/scripts/live-bridge/release-proof-validation.mjs +++ b/scripts/live-bridge/release-proof-validation.mjs @@ -1,6 +1,18 @@ import { exitCodes } from './constants.mjs' import { LiveBridgeError } from './errors.mjs' +const ACTIVE_RUN_STATUSES = new Set(['running', 'waiting', 'streaming']) +const ACTIVE_PROGRESS_KINDS = new Set([ + 'run.text.delta', + 'run.part.updated', + 'run.reasoning.delta', + 'run.tool.call', + 'run.tool.result', + 'run.artifact', + 'run.proposal', + 'run.interaction', +]) + function fail(code, message, details = {}) { throw new LiveBridgeError(code, message, exitCodes.failed, details) } @@ -12,9 +24,11 @@ function text(value) { function routeParts(route) { if (!text(route)) return undefined const parts = route.split('/') - return parts.length === 3 && parts.every((part) => part.length > 0) - ? { runner: parts[0], provider: parts[1], model: parts[2] } - : undefined + if (parts.length < 2 || parts.some((part) => part.length === 0)) return undefined + const runner = parts.shift() + const provider = parts.length > 1 ? parts.shift() : undefined + const model = parts.join('/') + return { runner, provider, model } } function same(value, expected, label, details) { @@ -213,3 +227,480 @@ export function assertUniqueRunIds( } return runIds } + +function positiveInteger(value) { + return Number.isSafeInteger(value) && value > 0 +} + +function nonNegativeInteger(value) { + return Number.isSafeInteger(value) && value >= 0 +} + +export function terminalReceipts(responses, session, runId) { + if (!Array.isArray(responses)) return [] + return responses.flatMap((response, responseIndex) => { + if ( + response?.type !== 'event' || + response.event?.kind !== 'run.finished' || + response.event?.runId !== runId + ) + return [] + const provider = response.event.provider + return [ + { + session, + responseIndex, + runId, + sequence: response.sequence ?? null, + revision: response.revision ?? null, + status: response.event.status ?? null, + providerEventId: provider?.eventId ?? null, + providerSequence: provider?.providerSequence ?? null, + cursor: provider?.cursor ?? null, + }, + ] + }) +} + +function providerSessionId(run) { + return run?.providerSessionId ?? run?.receipt?.providerSessionId +} + +function canonicalValue(value) { + if (Array.isArray(value)) return value.map((item) => canonicalValue(item)) + if (value !== null && typeof value === 'object') + return Object.fromEntries( + Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, item]) => [key, canonicalValue(item)]), + ) + return value +} + +function assertRecoveryIdentity(before, after, label) { + if (!text(before?.id) || !text(after?.id) || before.id !== after.id) + fail('LIVE_RELEASE_RESTART_IDENTITY_CHANGED', `${label} changed the local run identity`, { + before, + after, + }) + const beforeSession = providerSessionId(before) + const afterSession = providerSessionId(after) + if (!text(beforeSession) || beforeSession !== afterSession) + fail('LIVE_RELEASE_RESTART_SESSION_CHANGED', `${label} changed the provider session identity`, { + before, + after, + }) + if ( + before.controlRef === undefined || + after.controlRef === undefined || + JSON.stringify(canonicalValue(before.controlRef)) !== + JSON.stringify(canonicalValue(after.controlRef)) + ) + fail('LIVE_RELEASE_RESTART_CONTROL_CHANGED', `${label} changed the retained control identity`, { + before, + after, + }) +} + +function assertSavedCursor(run, cursor, providerSequence, label) { + if (run?.lastCursor !== cursor || run?.lastProviderSequence !== providerSequence) + fail( + 'LIVE_RELEASE_RESTART_CURSOR_CHANGED', + `${label} did not retain the saved provider cursor`, + { cursor, providerSequence, run }, + ) +} + +function assertForcedRestartProcess({ oldProcess, newProcess, forcedProcess }) { + const oldPid = oldProcess?.pid + const newPid = newProcess?.pid + const forcedIdentity = forcedProcess?.processIdentity + const termination = forcedProcess?.termination + if ( + !text(oldProcess?.instanceId) || + !text(newProcess?.instanceId) || + oldProcess.instanceId === newProcess.instanceId || + !positiveInteger(oldPid) || + !positiveInteger(newPid) || + oldPid === newPid || + !positiveInteger(oldProcess?.startedAt) || + !positiveInteger(newProcess?.startedAt) + ) + fail( + 'LIVE_RELEASE_RESTART_PROCESS_IDENTITY_INVALID', + 'The forced restart did not prove that a different RPC process was created', + { oldProcess, newProcess }, + ) + if (forcedIdentity?.instanceId !== oldProcess.instanceId || forcedIdentity?.pid !== oldPid) + fail( + 'LIVE_RELEASE_RESTART_PROCESS_IDENTITY_INVALID', + 'The termination receipt does not identify the process that was restarted', + { oldProcess, forcedProcess }, + ) + if (termination?.initialExited !== false) + fail( + 'LIVE_RELEASE_RESTART_PROCESS_NOT_LIVE', + 'The forced restart began after the original RPC process had already exited', + { oldProcess, forcedProcess }, + ) + if (termination?.initialTree?.supported !== true || termination.initialTree.gone !== false) + fail( + 'LIVE_RELEASE_RESTART_PROCESS_NOT_LIVE', + 'The forced restart did not observe the original process tree alive', + { oldProcess, forcedProcess }, + ) + if (termination.termSent !== true && termination.killSent !== true) + fail( + 'LIVE_RELEASE_RESTART_SIGNAL_MISSING', + 'The forced restart did not send a termination signal', + { forcedProcess }, + ) + if ( + forcedProcess?.exit === undefined || + forcedProcess.exit.timeout === true || + (!Number.isSafeInteger(forcedProcess.exit.code) && !text(forcedProcess.exit.signal)) + ) + fail( + 'LIVE_RELEASE_RESTART_EXIT_UNCONFIRMED', + 'The forced restart did not observe the terminated RPC process exit', + { forcedProcess }, + ) + if ( + termination.exited !== true || + termination.descendantsExited !== true || + termination.descendantsVerified !== true || + termination.cleanupStatus === 'already-exited' + ) + fail( + 'LIVE_RELEASE_RESTART_CONTAINMENT_FAILED', + 'The forced restart did not prove signal-driven process-tree termination', + { forcedProcess }, + ) + return { + oldPid, + newPid, + oldProcessInstanceId: oldProcess.instanceId ?? null, + newProcessInstanceId: newProcess.instanceId ?? null, + signal: termination.killSent === true ? 'SIGKILL' : 'SIGTERM', + initialExited: termination.initialExited, + initialTree: termination.initialTree, + cleanupStatus: termination.cleanupStatus, + } +} + +function assertActiveModelTiming(activeModel, runId) { + const admission = activeModel?.admission + const state = activeModel?.state + const progress = activeModel?.progress + if ( + admission?.runId !== runId || + typeof admission.requestId !== 'string' || + typeof admission.operationId !== 'string' || + !nonNegativeInteger(admission.responseIndex) || + !positiveInteger(admission.revision) + ) + fail( + 'LIVE_RELEASE_RESTART_ACTIVE_ADMISSION_INVALID', + 'The restart proof omitted a canonical send acknowledgement boundary', + { activeModel }, + ) + if ( + state?.runId !== runId || + state.activeRunId !== runId || + !ACTIVE_RUN_STATUSES.has(state.status) || + !nonNegativeInteger(state.responseIndex) || + state.responseIndex <= admission.responseIndex || + !positiveInteger(state.revision) || + state.revision < admission.revision + ) + fail( + 'LIVE_RELEASE_RESTART_NOT_ACTIVE', + 'The restart proof did not observe the admitted run active after acknowledgement', + { activeModel }, + ) + if ( + progress?.runId !== runId || + !ACTIVE_PROGRESS_KINDS.has(progress.kind) || + !nonNegativeInteger(progress.responseIndex) || + progress.responseIndex <= admission.responseIndex || + !positiveInteger(progress.providerSequence) || + !positiveInteger(progress.sequence) || + !positiveInteger(progress.revision) + ) + fail( + 'LIVE_RELEASE_RESTART_ACTIVE_PROGRESS_INVALID', + 'The restart proof did not observe post-admission model progress', + { activeModel }, + ) + return { + admission: { + runId, + requestId: admission.requestId, + operationId: admission.operationId, + responseIndex: admission.responseIndex, + revision: admission.revision, + }, + state: { + runId, + activeRunId: state.activeRunId, + status: state.status, + responseIndex: state.responseIndex, + revision: state.revision, + }, + progress: { + runId, + kind: progress.kind, + responseIndex: progress.responseIndex, + sequence: progress.sequence, + revision: progress.revision, + providerSequence: progress.providerSequence, + cursor: progress.cursor ?? null, + }, + } +} + +function assertReconnectBoundary({ + runId, + savedCursor, + savedProviderSequence, + reopenedRevision, + reconnectRequest, + reconnectResponse, + reconnectBoundary, + replayEvent, +}) { + if ( + reconnectRequest?.command !== 'reconnect' || + !text(reconnectRequest.requestId) || + !text(reconnectRequest.operationId) || + !positiveInteger(reopenedRevision) || + reconnectResponse?.type !== 'ack' || + reconnectResponse.requestId !== reconnectRequest.requestId || + reconnectResponse.command !== 'reconnect' || + reconnectResponse.operationId !== reconnectRequest.operationId || + (reconnectResponse.outcome !== undefined && + !['accepted', 'already-applied'].includes(reconnectResponse.outcome)) || + !positiveInteger(reconnectResponse.revision) || + reconnectResponse.revision <= reopenedRevision + ) + fail( + 'LIVE_RELEASE_RECONNECT_FAILED', + 'The restart proof did not retain a causal reconnect acknowledgement', + { reconnectRequest, reconnectResponse, reopenedRevision }, + ) + if ( + reconnectBoundary?.runId !== runId || + reconnectBoundary.kind !== 'run.reconnecting' || + !nonNegativeInteger(reconnectBoundary.responseIndex) || + !positiveInteger(reconnectBoundary.sequence) || + !positiveInteger(reconnectBoundary.revision) || + reconnectBoundary.savedCursor !== savedCursor || + reconnectBoundary.revision <= reopenedRevision || + reconnectBoundary.sequence > reconnectResponse.revision || + reconnectBoundary.revision > reconnectResponse.revision + ) + fail( + 'LIVE_RELEASE_RECONNECT_BOUNDARY_MISSING', + 'The restart proof did not retain the durable reconnect boundary', + { reconnectBoundary, reconnectResponse }, + ) + const provider = replayEvent?.event?.provider + const nextProviderSequence = savedProviderSequence + 1 + if ( + replayEvent?.event?.runId !== runId || + replayEvent.kind !== replayEvent.event.kind || + replayEvent.kind === 'run.reconnecting' || + !nonNegativeInteger(replayEvent.responseIndex) || + replayEvent.responseIndex <= reconnectBoundary.responseIndex || + !positiveInteger(replayEvent.sequence) || + !positiveInteger(replayEvent.revision) || + replayEvent.sequence <= reconnectBoundary.sequence || + replayEvent.revision <= reconnectBoundary.revision || + replayEvent.sequence > reconnectResponse.revision || + replayEvent.revision > reconnectResponse.revision || + typeof provider?.cursor !== 'string' || + provider.cursor.length === 0 || + provider.cursor === reconnectBoundary.savedCursor || + !positiveInteger(provider.providerSequence) || + !Number.isSafeInteger(nextProviderSequence) || + provider.providerSequence !== nextProviderSequence + ) + fail( + 'LIVE_RELEASE_REPLAY_MISSING', + 'The restart proof did not retain a provider event after the reconnect boundary', + { reconnectBoundary, replayEvent, reconnectResponse }, + ) + return { + requestId: reconnectRequest.requestId, + operationId: reconnectRequest.operationId, + ackRevision: reconnectResponse.revision, + boundary: { + runId, + kind: reconnectBoundary.kind, + responseIndex: reconnectBoundary.responseIndex, + sequence: reconnectBoundary.sequence, + revision: reconnectBoundary.revision, + savedCursor: reconnectBoundary.savedCursor ?? null, + }, + replay: { + runId, + kind: replayEvent.kind, + responseIndex: replayEvent.responseIndex, + sequence: replayEvent.sequence, + revision: replayEvent.revision, + providerSequence: provider.providerSequence, + cursor: provider.cursor, + }, + } +} + +function assertTerminalUniqueness(terminalReceiptsValue, finalState, runId, terminalSession) { + if (!Array.isArray(terminalReceiptsValue) || terminalReceiptsValue.length !== 1) + fail( + 'LIVE_RELEASE_RESTART_COMPLETION_DUPLICATED', + 'The retained run did not have exactly one terminal event delivery across sessions', + { terminalReceipts: terminalReceiptsValue }, + ) + const receipt = terminalReceiptsValue[0] + const finalRun = finalState?.state?.runs?.find((run) => run?.id === runId) + if ( + receipt.runId !== runId || + (terminalSession !== undefined && receipt.session !== terminalSession) || + receipt.status !== 'completed' || + !text(receipt.providerEventId) || + !positiveInteger(receipt.providerSequence) || + !text(receipt.cursor) || + !positiveInteger(receipt.sequence) || + !positiveInteger(receipt.revision) || + !positiveInteger(finalState?.revision) || + !positiveInteger(finalState?.state?.revision) || + finalRun?.status !== 'completed' || + finalRun.complete !== true || + receipt.sequence > finalState.state.revision || + receipt.revision > finalState.state.revision + ) + fail( + 'LIVE_RELEASE_RESTART_TERMINAL_NOT_DURABLE', + 'The terminal event receipt is not represented by the durable final state', + { receipt, finalState }, + ) + return { + deliveryCount: terminalReceiptsValue.length, + receipt, + durableRevision: finalState.state.revision, + } +} + +export function assertRetainedRestartProof({ + runId, + savedCursor, + savedProviderSequence, + beforeDetach, + detached, + reopened, + final, + oldProcess, + newProcess, + forcedProcess, + activeModel, + reopenedRevision, + reconnectRequest, + reconnectResponse, + reconnectBoundary, + replayEvent, + terminalReceipts: terminalReceiptsValue, + finalState, + terminalSession, +}) { + if (!text(runId) || beforeDetach?.id !== runId || detached?.id !== runId) + fail( + 'LIVE_RELEASE_RESTART_RUN_ID_MISSING', + 'The restart proof lost the retained run identity', + { + runId, + beforeDetach, + detached, + }, + ) + if (!text(terminalSession)) + fail( + 'LIVE_RELEASE_RESTART_TERMINAL_SESSION_MISSING', + 'The restart proof omitted the session that must deliver the terminal event', + { terminalSession }, + ) + if ( + !text(savedCursor) || + !Number.isSafeInteger(savedProviderSequence) || + savedProviderSequence < 1 + ) + fail( + 'LIVE_RELEASE_RESTART_CURSOR_MISSING', + 'The restart proof did not save a provider cursor from an active stream', + { savedCursor, savedProviderSequence, beforeDetach }, + ) + const activeEvidence = assertActiveModelTiming(activeModel, runId) + if (beforeDetach?.status === undefined || !ACTIVE_RUN_STATUSES.has(beforeDetach.status)) + fail('LIVE_RELEASE_RESTART_NOT_ACTIVE', 'The restart proof did not detach an active run', { + beforeDetach, + }) + if (detached?.status !== 'detached') + fail('LIVE_RELEASE_RESTART_NOT_DETACHED', 'The restart proof did not persist a detached run', { + detached, + }) + if (reopened?.status !== 'detached') + fail('LIVE_RELEASE_RESTART_NOT_REOPENED', 'The restart proof did not reopen the detached run', { + reopened, + }) + const processEvidence = assertForcedRestartProcess({ + oldProcess, + newProcess, + forcedProcess, + }) + assertRecoveryIdentity(beforeDetach, detached, 'detach') + assertRecoveryIdentity(beforeDetach, reopened, 'restart') + assertRecoveryIdentity(beforeDetach, final, 'reconnect') + assertSavedCursor(beforeDetach, savedCursor, savedProviderSequence, 'active stream') + assertSavedCursor(detached, savedCursor, savedProviderSequence, 'detached state') + assertSavedCursor(reopened, savedCursor, savedProviderSequence, 'reopened state') + const reconnectEvidence = assertReconnectBoundary({ + runId, + savedCursor, + savedProviderSequence, + reopenedRevision, + reconnectRequest, + reconnectResponse, + reconnectBoundary, + replayEvent, + }) + if (reconnectEvidence.replay.providerSequence <= savedProviderSequence) + fail( + 'LIVE_RELEASE_REPLAY_MISSING', + 'The restart proof did not replay a provider event after the saved cursor', + { savedCursor, savedProviderSequence, replayEvent }, + ) + if (final?.status !== 'completed' || final.complete !== true) + fail( + 'LIVE_RELEASE_RESTART_NOT_COMPLETED', + 'The retained run did not complete after reconnect', + { + final, + }, + ) + const terminalEvidence = assertTerminalUniqueness( + terminalReceiptsValue, + finalState, + runId, + terminalSession, + ) + return { + runId, + savedCursor, + savedProviderSequence, + providerSessionId: providerSessionId(final), + controlRef: final.controlRef, + process: processEvidence, + activeModel: activeEvidence, + reconnect: reconnectEvidence, + terminal: terminalEvidence, + } +} diff --git a/scripts/live-bridge/release-proof-validation.test.mjs b/scripts/live-bridge/release-proof-validation.test.mjs index 50f7462..753729c 100644 --- a/scripts/live-bridge/release-proof-validation.test.mjs +++ b/scripts/live-bridge/release-proof-validation.test.mjs @@ -6,8 +6,10 @@ import { assertContextTransfer, assertObservedUsage, assertRetainedInteraction, + assertRetainedRestartProof, assertTargetRunIdentity, assertUniqueRunIds, + terminalReceipts, } from './release-proof-validation.mjs' import { assertTargetSemantics } from './target-actions.mjs' @@ -85,15 +87,355 @@ test('identity validation rejects configured-label false positives', () => { } }) +test('identity validation retains nested and runner-only model routes', () => { + const nestedTarget = { + key: 'pi-nested', + modelId: 'pi/tangle-router/openai/gpt-5.6-luna', + definition: { backend: 'pi' }, + } + const nestedRun = run({ + model: 'openai/gpt-5.6-luna', + receipt: { + ...run().receipt, + requested: { + runner: 'pi', + model: 'openai/gpt-5.6-luna', + profile: { + harness: 'pi', + model: { provider: 'tangle-router', default: 'openai/gpt-5.6-luna' }, + }, + }, + materializationReceipt: { + provider: 'cli-bridge', + runner: 'pi', + model: 'openai/gpt-5.6-luna', + route: nestedTarget.modelId, + }, + }, + }) + assert.deepEqual(assertTargetRunIdentity(nestedRun, nestedTarget).provider, 'tangle-router') + + const defaultTarget = { + key: 'codex-default', + modelId: 'codex/default', + definition: { backend: 'codex' }, + } + const defaultRun = run({ + model: 'default', + receipt: { + ...run().receipt, + requested: { + runner: 'codex', + model: 'default', + profile: { harness: 'codex', model: { default: 'default' } }, + }, + materializationReceipt: { + provider: 'cli-bridge', + runner: 'codex', + model: 'default', + route: defaultTarget.modelId, + }, + }, + }) + assert.equal(assertTargetRunIdentity(defaultRun, defaultTarget).provider, undefined) +}) + test('usage, replay, and cancellation unavailable states cannot pass strict conformance', () => { assert.throws(() => assertObservedUsage(run({ tokensKnown: false })), /known token usage/u) assert.throws(() => assertObservedUsage(run({ llmCalls: 0 })), /model-call usage/u) const unavailable = { - reconnect: { status: 'reported-unavailable', advertised: false }, cancel: { status: 'reported-unavailable', advertised: false }, interaction: { status: 'reported-unavailable', advertised: false }, } assert.throws(() => assertTargetSemantics(unavailable, { strict: true }), /verified live proof/u) + assert.throws( + () => + assertTargetSemantics( + { + cancel: { status: 'advertised-but-not-active', advertised: true }, + interaction: { status: 'reported-unavailable', advertised: false }, + }, + { strict: true }, + ), + /cancel/u, + ) +}) + +test('restart proof rejects process, timing, reconnect, replay, and terminal false positives', () => { + const identity = { + id: 'run-1', + providerSessionId: 'session-1', + lastCursor: '1:0', + lastProviderSequence: 1, + controlRef: { + runId: 'provider-run-1', + provider: 'cli-bridge', + environmentId: 'environment-1', + sessionId: 'session-1', + executionId: 'execution-1', + requestDigest: 'sha256:request', + }, + } + const oldProcess = { instanceId: 'process-old', pid: 1001, startedAt: 100 } + const newProcess = { instanceId: 'process-new', pid: 1002, startedAt: 200 } + const reconnectRequest = { + requestId: 'reconnect-request', + operationId: 'op-reconnect', + command: 'reconnect', + } + const proof = { + runId: 'run-1', + savedCursor: '1:0', + savedProviderSequence: 1, + beforeDetach: { ...identity, status: 'streaming' }, + detached: { ...identity, status: 'detached' }, + reopened: { ...identity, status: 'detached' }, + final: { ...identity, status: 'completed', complete: true }, + oldProcess, + newProcess, + forcedProcess: { + processIdentity: oldProcess, + termination: { + initialExited: false, + initialTree: { supported: true, gone: false }, + termSent: true, + killSent: false, + exited: true, + descendantsExited: true, + descendantsVerified: true, + cleanupStatus: 'term', + }, + exit: { code: 0, signal: 'SIGTERM' }, + }, + activeModel: { + admission: { + runId: 'run-1', + requestId: 'send-request', + operationId: 'op-send', + responseIndex: 1, + revision: 5, + }, + state: { + runId: 'run-1', + activeRunId: 'run-1', + status: 'streaming', + responseIndex: 3, + revision: 6, + }, + progress: { + runId: 'run-1', + kind: 'run.text.delta', + responseIndex: 4, + sequence: 7, + revision: 7, + providerSequence: 2, + cursor: '2:0', + }, + }, + reopenedRevision: 12, + reconnectRequest, + reconnectResponse: { + type: 'ack', + requestId: reconnectRequest.requestId, + operationId: reconnectRequest.operationId, + command: 'reconnect', + revision: 16, + }, + reconnectBoundary: { + runId: 'run-1', + kind: 'run.reconnecting', + responseIndex: 20, + sequence: 13, + revision: 13, + savedCursor: '1:0', + }, + replayEvent: { + kind: 'run.text.delta', + responseIndex: 21, + sequence: 14, + revision: 14, + event: { + kind: 'run.text.delta', + runId: 'run-1', + provider: { providerSequence: 2, cursor: '2:0' }, + }, + }, + terminalReceipts: [ + { + session: newProcess.instanceId, + responseIndex: 30, + runId: 'run-1', + sequence: 15, + revision: 15, + status: 'completed', + providerEventId: 'provider-finished', + providerSequence: 3, + cursor: '3:0', + }, + ], + finalState: { + revision: 16, + state: { + revision: 16, + runs: [{ id: 'run-1', status: 'completed', complete: true }], + }, + }, + terminalSession: newProcess.instanceId, + } + assert.equal(assertRetainedRestartProof(proof).terminal.deliveryCount, 1) + + const adversarial = [ + { + name: 'already exited process', + proof: { + ...proof, + forcedProcess: { + ...proof.forcedProcess, + termination: { ...proof.forcedProcess.termination, initialExited: true }, + }, + }, + }, + { + name: 'no termination signal', + proof: { + ...proof, + forcedProcess: { + ...proof.forcedProcess, + termination: { + ...proof.forcedProcess.termination, + termSent: false, + killSent: false, + cleanupStatus: 'already-exited', + }, + }, + }, + }, + { + name: 'same process identity', + proof: { ...proof, newProcess: { ...proof.newProcess, pid: proof.oldProcess.pid } }, + }, + { + name: 'stale active run id', + proof: { + ...proof, + activeModel: { + ...proof.activeModel, + state: { ...proof.activeModel.state, activeRunId: 'other-run' }, + }, + }, + }, + { + name: 'pre-admission progress', + proof: { + ...proof, + activeModel: { + ...proof.activeModel, + progress: { ...proof.activeModel.progress, responseIndex: 1 }, + }, + }, + }, + { + name: 'non-progress event', + proof: { + ...proof, + activeModel: { + ...proof.activeModel, + progress: { ...proof.activeModel.progress, kind: 'run.usage' }, + }, + }, + }, + { + name: 'rejected reconnect acknowledgement', + proof: { + ...proof, + reconnectResponse: { ...proof.reconnectResponse, outcome: 'rejected' }, + }, + }, + { + name: 'replay before reconnect boundary', + proof: { + ...proof, + replayEvent: { ...proof.replayEvent, responseIndex: proof.reconnectBoundary.responseIndex }, + }, + }, + { + name: 'reconnect boundary uses a different saved cursor', + proof: { + ...proof, + reconnectBoundary: { ...proof.reconnectBoundary, savedCursor: '0:0' }, + }, + }, + { + name: 'replay sequence gap after saved cursor', + proof: { + ...proof, + replayEvent: { + ...proof.replayEvent, + event: { + ...proof.replayEvent.event, + provider: { providerSequence: 99, cursor: '99:0' }, + }, + }, + }, + }, + { + name: 'duplicate terminal across sessions', + proof: { + ...proof, + terminalReceipts: [ + ...proof.terminalReceipts, + { ...proof.terminalReceipts[0], session: 'old-session' }, + ], + }, + }, + { + name: 'terminal delivered only by old session', + proof: { + ...proof, + terminalReceipts: [{ ...proof.terminalReceipts[0], session: proof.oldProcess.instanceId }], + }, + }, + { + name: 'terminal not durable', + proof: { ...proof, finalState: { revision: 14, state: { revision: 14 } } }, + }, + { + name: 'terminal completion missing', + proof: { ...proof, terminalReceipts: [] }, + }, + { + name: 'retained run not completed', + proof: { ...proof, final: { ...proof.final, status: 'failed' } }, + }, + ] + for (const { name, proof: broken } of adversarial) { + assert.throws(() => assertRetainedRestartProof(broken), name) + } + + const terminalResponse = { + type: 'event', + sequence: 4, + revision: 4, + event: { + kind: 'run.finished', + runId: 'run-1', + status: 'completed', + provider: { eventId: 'provider-finished', providerSequence: 3, cursor: '3:0' }, + }, + } + assert.deepEqual(terminalReceipts([terminalResponse], 'restarted-session', 'run-1'), [ + { + session: 'restarted-session', + responseIndex: 0, + runId: 'run-1', + sequence: 4, + revision: 4, + status: 'completed', + providerEventId: 'provider-finished', + providerSequence: 3, + cursor: '3:0', + }, + ]) }) test('interaction proof requires durable declined or resolved state', () => { diff --git a/scripts/live-bridge/release-proofs.mjs b/scripts/live-bridge/release-proofs.mjs index 1940157..5ebdcc2 100644 --- a/scripts/live-bridge/release-proofs.mjs +++ b/scripts/live-bridge/release-proofs.mjs @@ -31,11 +31,16 @@ function proofTarget(target, operationResult) { proof?.key !== target.key || proof?.route !== target.modelId || typeof proof?.harness !== 'string' || - typeof proof?.provider !== 'string' || + (proof?.provider !== undefined && typeof proof.provider !== 'string') || typeof proof?.model !== 'string' ) return undefined - return { key: target.key, harness: proof.harness, model: proof.model } + return { + key: target.key, + harness: proof.harness, + ...(proof.provider === undefined ? {} : { provider: proof.provider }), + model: proof.model, + } } function targetRecordMatches(targetRecord, target, descriptor) { @@ -45,7 +50,7 @@ function targetRecordMatches(targetRecord, target, descriptor) { proof?.key === target.key && proof?.route === target.modelId && proof?.harness === descriptor.harness && - proof?.provider === target.modelId.split('/')[1] && + proof?.provider === descriptor.provider && proof?.model === descriptor.model ) } diff --git a/scripts/live-bridge/target-actions.mjs b/scripts/live-bridge/target-actions.mjs index ca182be..e4d5422 100644 --- a/scripts/live-bridge/target-actions.mjs +++ b/scripts/live-bridge/target-actions.mjs @@ -161,66 +161,6 @@ export async function runNormalTurn( return { finalRun, runId, terminal } } -export async function verifyReconnect( - session, - result, - runId, - finalRun, - providerCapabilities, - { operationPrefix = 'live' } = {}, -) { - const reconnect = { - ...requestBase( - requestIdFor(operationPrefix, 'reconnect', result.targetKey), - 'reconnect', - operationIdFor(operationPrefix, 'reconnect', result.targetKey), - ), - params: { runId }, - } - result.requests.push(evidenceValue(reconnect)) - const responseCount = session.responses?.length ?? 0 - session.send(reconnect) - const reconnectResponse = await session.waitFor( - 'reconnect result', - responseForRequest(reconnect.requestId), - 30_000, - ) - const advertisedByRun = Boolean( - finalRun?.capabilities?.streaming?.replay && finalRun?.capabilities?.events?.cursor, - ) - const availability = capabilityAvailability( - providerCapabilities.streaming?.replay, - advertisedByRun, - ) - const replayEvent = await session - .waitFor( - 'replayed run event', - (response) => - (session.responses?.indexOf(response) ?? responseCount) >= responseCount && - response.type === 'event' && - ['run.reconnecting', 'run.reconciled', 'run.finished', 'run.unknown'].includes( - response.event?.kind, - ) && - response.event?.runId === runId, - 15_000, - ) - .catch(() => undefined) - const replayObserved = replayEvent !== undefined - result.reconnect = { - advertisedByProvider: availability.advertisedByProvider, - advertisedByRun, - advertised: availability.advertised, - response: evidenceValue(reconnectResponse), - replayEvent: evidenceValue(replayEvent), - replayObserved, - status: - semanticCommandStatus(reconnectResponse, availability.advertised) === 'verified' && - !replayObserved - ? 'replay-missing' - : semanticCommandStatus(reconnectResponse, availability.advertised), - } -} - export async function verifyCancel( session, result, @@ -302,6 +242,24 @@ export async function verifyCancel( result.cancel.advertisedByRun = advertisedByRun result.cancel.advertised = availability.advertised result.cancel.runId = cancelSendResponse.runId + const activeStateResponse = await session + .waitFor( + 'active cancel test state', + (response) => { + if (response.type !== 'state') return false + const run = runFromState(response.state, cancelSendResponse.runId) + return ['running', 'waiting', 'streaming'].includes(run?.status) + }, + timeoutMs, + ) + .catch(() => undefined) + const activeCancelRun = runFromState(activeStateResponse?.state, cancelSendResponse.runId) + result.cancel.activeRun = evidenceValue(activeCancelRun) + if (availability.advertised && activeCancelRun === undefined) { + result.cancel.attemptedRun = true + result.cancel.status = 'advertised-but-not-active' + return + } await sleep(25) const cancel = { ...requestBase( @@ -398,7 +356,6 @@ export async function verifyInteraction( export function assertTargetSemantics(result, { strict = false } = {}) { for (const [name, capability] of [ - ['reconnect', result.reconnect], ['cancel', result.cancel], ['interaction', result.interaction], ]) { @@ -411,15 +368,7 @@ export function assertTargetSemantics(result, { strict = false } = {}) { { capability }, ) } - if (strict && result.reconnect.replayObserved !== true) - throw new LiveBridgeError( - 'LIVE_RELEASE_REPLAY_MISSING', - 'The reconnect operation did not retain a replay event for the requested run', - exitCodes.failed, - { reconnect: result.reconnect }, - ) result.semanticAssertions = { - reconnect: result.reconnect.status, cancel: result.cancel.status, interaction: result.interaction.status, } diff --git a/scripts/live-bridge/target-flow.mjs b/scripts/live-bridge/target-flow.mjs index 679817e..d02fda7 100644 --- a/scripts/live-bridge/target-flow.mjs +++ b/scripts/live-bridge/target-flow.mjs @@ -30,7 +30,6 @@ import { runNormalTurn, verifyCancel, verifyInteraction, - verifyReconnect, } from './target-actions.mjs' export async function executeTarget( @@ -95,9 +94,6 @@ export async function executeTarget( const { finalRun, runId, terminal } = await runNormalTurn(session, result, target, timeoutMs, { operationPrefix, }) - await verifyReconnect(session, result, runId, finalRun, providerCapabilities, { - operationPrefix, - }) await verifyCancel(session, result, target, finalRun, providerCapabilities, { operationPrefix, timeoutMs, @@ -123,7 +119,6 @@ export async function executeTarget( await finishTarget(session, result, { operationPrefix }) result.operationEvidence = evidenceValue({ normal: result.normal, - reconnect: result.reconnect, cancel: result.cancel, interaction: result.interaction, targetProof: result.targetProof, diff --git a/scripts/live-required/tangle-sandbox-braid-execution-soak.mjs b/scripts/live-required/tangle-sandbox-braid-execution-soak.mjs index 17df00b..eba397b 100644 --- a/scripts/live-required/tangle-sandbox-braid-execution-soak.mjs +++ b/scripts/live-required/tangle-sandbox-braid-execution-soak.mjs @@ -187,10 +187,10 @@ async function runAttempt({ index, proofId, client, binary, values, environment marker, prompt: [ 'Use shell tools in the sandbox.', - `Create /workspace/${connectionName}.txt containing exactly ${marker} followed by a newline.`, - `Read /workspace/${connectionName}.txt and verify its exact contents.`, - 'Run git -C /workspace init if needed.', - 'Verify git -C /workspace rev-parse --is-inside-work-tree prints true.', + `Create ./${connectionName}.txt in the current working directory containing exactly ${marker} followed by a newline.`, + `Read ./${connectionName}.txt and verify its exact contents.`, + 'Run git -C . init if needed.', + 'Verify git -C . rev-parse --is-inside-work-tree prints true.', `Reply with exactly ${marker}.`, ].join(' '), }) diff --git a/scripts/live-required/tangle-sandbox-braid-soak.mjs b/scripts/live-required/tangle-sandbox-braid-soak.mjs index 61bcd47..1ee6495 100644 --- a/scripts/live-required/tangle-sandbox-braid-soak.mjs +++ b/scripts/live-required/tangle-sandbox-braid-soak.mjs @@ -169,12 +169,16 @@ function addUsageFailures(failures, proof, label, maxConcurrentRuns, aggregate = `${label} account usage delta had unknown fields: ${delta?.unknownFields?.join(', ') ?? 'unavailable'}`, ) } - if (delta?.activeSandboxes !== 0) { + if (proof?.cleanup?.activeResourceDeltaRequired === true && delta?.activeSandboxes !== 0) { failures.push( `${label} account active-resource delta was ${delta?.activeSandboxes ?? 'unknown'}, expected 0`, ) } - if (!aggregate && proof?.cleanup?.activeResourceDelta !== 0) { + if ( + !aggregate && + proof?.cleanup?.activeResourceDeltaRequired === true && + proof?.cleanup?.activeResourceDelta !== 0 + ) { failures.push( `${label} cleanup activeResourceDelta was ${proof?.cleanup?.activeResourceDelta ?? 'unknown'}, expected 0`, ) @@ -205,6 +209,7 @@ function addUsageFailures(failures, proof, label, maxConcurrentRuns, aggregate = failures.push(`${label} account ${field} delta was unknown`) continue } + if (proof?.cleanup?.activeResourceDeltaRequired !== true) continue const expectation = cumulativeExpectation(proof, field, maxConcurrentRuns, aggregate) if (expectation === undefined) { failures.push(`${label} account ${field} delta had no measured-work bound`) @@ -257,7 +262,7 @@ function addRunSnapshotFailures(failures, runs, name) { return snapshot } -function proofFailures(proof, { maxConcurrentRuns = 1 } = {}) { +export function proofFailures(proof, { maxConcurrentRuns = 1 } = {}) { if (proof?.status !== 'passed') return ['status was not passed'] const failures = [] const progress = proof.progress @@ -546,7 +551,7 @@ function proofFailures(proof, { maxConcurrentRuns = 1 } = {}) { if (cleanup.exactResource !== true || cleanup.mode !== 'exact-owned-resource-set') { failures.push('exact retained cleanup was not proven') } - if (cleanup.activeResourceDelta !== 0) { + if (cleanup.activeResourceDeltaRequired === true && cleanup.activeResourceDelta !== 0) { failures.push( `cleanup activeResourceDelta was ${cleanup.activeResourceDelta ?? 'unknown'}, expected 0`, ) @@ -554,6 +559,14 @@ function proofFailures(proof, { maxConcurrentRuns = 1 } = {}) { if (typeof cleanup.activeResourceDeltaRequired !== 'boolean') { failures.push('cleanup active-resource requirement was not durable') } + if ( + cleanup.accountUsageScope !== 'account-wide' || + !['exclusive-proof-window', 'unattributed-shared-usage'].includes( + cleanup.accountUsageAttribution, + ) + ) { + failures.push('cleanup account usage attribution was missing') + } if (cleanup.usageObservationComplete !== true) { failures.push('cleanup usage observation was incomplete') } @@ -713,29 +726,16 @@ function cohortUsage(attempts) { } } -function cohortAccountFailures(attempts, usage) { +function cohortAccountFailures(usage) { const failures = [] if (!usage.complete) { failures.push('cohort account usage delta was unavailable or unknown') return failures } - if (usage.delta.activeSandboxes !== 0) { - failures.push(`cohort active-resource delta was ${usage.delta.activeSandboxes}, expected 0`) - } - for (const field of ACCOUNT_USAGE_FIELDS.slice(1)) { + for (const field of ACCOUNT_USAGE_FIELDS) { const value = usage.delta[field] - if (!finiteNonNegative(value)) { + if (typeof value !== 'number' || !Number.isFinite(value)) { failures.push(`cohort account ${field} delta was unknown`) - continue - } - const expectedMax = attempts.reduce((total, attempt) => { - const expectation = cumulativeExpectation(attempt.proof, field, 1, true) - return total + (expectation?.expectedMax ?? 0) - }, 0) - if (value > expectedMax) { - failures.push( - `cohort account ${field} delta ${value} exceeded measured-work bound ${expectedMax}`, - ) } } return failures @@ -783,7 +783,7 @@ function cohortFailures(attempts, requestedRuns, concurrency, usage) { } else if (new Set(accounts).size !== 1) { failures.push('Sandbox account identity changed during the cohort') } - failures.push(...cohortAccountFailures(attempts, usage)) + failures.push(...cohortAccountFailures(usage)) return [...new Set(failures)] } @@ -882,7 +882,7 @@ export async function runBraidSandboxSoak({ } } - const canary = await attempt(0, true) + const canary = await attempt(0, false) attempts.push(canary) if (!proofPassed(canary.proof, requestedConcurrency) || requestedRuns === 1) { return finish({ diff --git a/scripts/live-required/tangle-sandbox-braid-stress-support.mjs b/scripts/live-required/tangle-sandbox-braid-stress-support.mjs index f706942..cc1023f 100644 --- a/scripts/live-required/tangle-sandbox-braid-stress-support.mjs +++ b/scripts/live-required/tangle-sandbox-braid-stress-support.mjs @@ -20,19 +20,19 @@ const TERMINAL_STATUSES = new Set([ 'blocked', 'unknown', ]) -const NON_VISIBLE_KINDS = new Set([ - 'run.admitted', - 'run.started', - 'run.environment.observed', - 'run.reconnecting', - 'run.reconciled', - 'run.finished', - 'run.cancel.requested', - 'run.cancelled', - 'run.aborted', - 'run.failed', - 'run.status.changed', - 'replay.cursor.advanced', +const VISIBLE_PROVIDER_KINDS = new Set([ + 'run.text.delta', + 'run.reasoning.delta', + 'run.part.updated', + 'run.tool.call', + 'run.tool.result', + 'run.artifact', + 'run.proposal', + 'run.warning', + 'run.error', + 'run.interaction', + 'run.interaction.cancelled', + 'run.provider.event', ]) export class MissingIntegrationError extends Error { @@ -65,7 +65,8 @@ function eventParts(response) { function eventRunId(response) { const parts = eventParts(response) - return nonEmptyString(parts?.payload.runId) + const value = record(parts?.payload.value) + return nonEmptyString(parts?.payload.runId) ?? nonEmptyString(value?.runId) } function rawControlRef(parts) { @@ -75,8 +76,7 @@ function rawControlRef(parts) { record(record(parts?.payload.observation)?.controlRef) ?? record(value?.controlRef) ?? record(parts?.event.controlRef) ?? - record(record(parts?.payload.source)?.controlRef) ?? - record(record(parts?.payload.provider)?.controlRef) + record(record(parts?.payload.source)?.controlRef) ) } @@ -107,12 +107,11 @@ export function controlRefFromEvent(response) { } export function eventCursorFromEvent(response) { - const parts = eventParts(response) - return scalarCursor(record(parts?.payload.provider)?.cursor) + return scalarCursor(providerEventMetadata(response)?.cursor) } export function observationFromResponses(responses, runId) { - let controlRef + let controlRef = localControlRefFromResponses(responses, runId) let cursor let observationEvent for (const response of responses) { @@ -173,6 +172,33 @@ export async function waitForVisibleEvents(session, runId, timeoutMs, phase) { } } +export async function waitForWorkspaceToolEvents(session, runId, timeoutMs, phase) { + const deadline = performance.now() + timeoutMs + for (;;) { + const visible = assertUniqueVisibleEvents(session.responses, runId, phase) + const tools = workspaceToolEvents(visible) + if (tools.length > 0) return visible + throwIfRunTerminated(session.responses, runId, 'emitting a provider-bound workspace tool event') + if (performance.now() >= deadline) { + throw new MissingIntegrationError( + `Braid RPC did not expose a provider-bound workspace tool event for run ${runId}`, + { runId, required: ['run.tool.call', 'run.tool.result', 'run.part.updated:tool'] }, + ) + } + await sleep(Math.min(100, Math.max(10, deadline - performance.now()))) + } +} + +export function workspaceToolEvents(observation) { + return observation.events.filter( + (event) => + event.kind === 'run.tool.call' || + event.kind === 'run.tool.result' || + (event.kind === 'run.part.updated' && + ['tool', 'result', 'tool-call', 'tool-result'].includes(event.partKind)), + ) +} + function throwIfRunTerminated(responses, runId, pendingProof) { for (let index = responses.length - 1; index >= 0; index -= 1) { const response = responses[index] @@ -208,6 +234,19 @@ export async function stateRoundTrip(session, projection = 'full') { return { ...result, state: response.state } } +export async function waitForRequestState(session, requestId, runId, timeoutMs) { + const response = await session.waitFor( + `completion state for ${requestId}`, + stateForRequest(requestId), + timeoutMs, + ) + const run = runFromState(response.state, runId) + if (run === undefined) { + throw new Error(`completion state for ${requestId} omitted run ${runId}`) + } + return { response, run } +} + export async function waitForTerminal(session, runId, timeoutMs) { const deadline = performance.now() + timeoutMs for (;;) { @@ -228,7 +267,8 @@ export async function waitForTerminal(session, runId, timeoutMs) { function providerEventMetadata(response) { const parts = eventParts(response) - return record(parts?.payload.provider) + const value = record(parts?.payload.value) + return record(parts?.payload.source) ?? record(value?.provider) } function belongsToRun(response, runId) { @@ -244,17 +284,25 @@ function localControlRefFromResponses(responses, runId) { const controlRef = controlRefFromEvent(response) if (controlRef !== undefined) return controlRef } + for (let index = responses.length - 1; index >= 0; index -= 1) { + const response = responses[index] + if (response?.type !== 'state') continue + const controlRef = exactControlRef(runFromState(response.state, runId)?.controlRef) + if (controlRef !== undefined) return controlRef + } return undefined } function providerIdentityFromEvent(response, kind, eventId) { const parts = eventParts(response) const payload = parts?.payload - const provider = record(payload?.provider) + const provider = providerEventMetadata(response) + const value = record(payload?.value) const sources = [ - ['provider', provider, ['runId', 'providerRunId', 'executionId', 'providerExecutionId']], - ['provider.controlRef', record(provider?.controlRef), ['runId', 'executionId']], + ['source', provider, ['runId', 'providerRunId', 'executionId', 'providerExecutionId']], + ['source.controlRef', record(provider?.controlRef), ['runId', 'executionId']], ['payload.controlRef', record(payload?.controlRef), ['runId', 'executionId']], + ['payload.value.controlRef', record(value?.controlRef), ['runId', 'executionId']], ['payload.providerControlRef', record(payload?.providerControlRef), ['runId', 'executionId']], ['payload', payload, ['executionId', 'providerRunId', 'providerExecutionId']], ] @@ -328,7 +376,7 @@ export function providerEventsForRun(responses, runId) { if (typeof kind !== 'string') continue const provider = providerEventMetadata(response) if (!provider) { - if (!NON_VISIBLE_KINDS.has(kind)) { + if (VISIBLE_PROVIDER_KINDS.has(kind)) { throw new MissingIntegrationError( `Braid emitted visible ${kind} without provider metadata`, { runId, kind }, @@ -351,7 +399,7 @@ export function providerEventsForRun(responses, runId) { { runId, kind, eventId }, ) } - if (!NON_VISIBLE_KINDS.has(kind)) { + if (VISIBLE_PROVIDER_KINDS.has(kind)) { assertProviderIdentity(response, runId, kind, eventId, expectedControlRef) } const payload = eventParts(response)?.payload @@ -370,7 +418,7 @@ export function providerEventsForRun(responses, runId) { export function visibleProviderEvents(responses, runId) { return providerEventsForRun(responses, runId) - .filter((event) => !NON_VISIBLE_KINDS.has(event.kind)) + .filter((event) => VISIBLE_PROVIDER_KINDS.has(event.kind)) .map((event) => { if (!event.cursor) { throw new MissingIntegrationError( diff --git a/scripts/live-required/tangle-sandbox-braid-stress.mjs b/scripts/live-required/tangle-sandbox-braid-stress.mjs index f3a4266..96209ec 100644 --- a/scripts/live-required/tangle-sandbox-braid-stress.mjs +++ b/scripts/live-required/tangle-sandbox-braid-stress.mjs @@ -32,8 +32,10 @@ import { stateRoundTrip, terminalStatus, waitForControlIdentity, + waitForRequestState, waitForTerminal, - waitForVisibleEvents, + waitForWorkspaceToolEvents, + workspaceToolEvents, } from './tangle-sandbox-braid-stress-support.mjs' const scriptPath = fileURLToPath(import.meta.url) @@ -102,15 +104,29 @@ function sandboxConfiguration(environment) { } function workspaceMarkerPath(coordinates) { - return `/workspace/.braid-live/${coordinates.proofId}/marker.txt` + return `.braid-live/${coordinates.proofId}/marker.txt` } function continuityChallengePath(coordinates) { - return `/workspace/.braid-live/${coordinates.proofId}/continuity-challenge.txt` + return `.braid-live/${coordinates.proofId}/continuity-challenge.txt` } function continuityResponsePath(coordinates) { - return `/workspace/.braid-live/${coordinates.proofId}/continuity-response.txt` + return `.braid-live/${coordinates.proofId}/continuity-response.txt` +} + +export function sandboxWorkspaceRelativePath(path) { + const normalized = path.startsWith('./') ? path.slice(2) : path + const segments = normalized.split('/') + if ( + normalized.length === 0 || + path.startsWith('/') || + path.includes('\\') || + segments.some((segment) => segment.length === 0 || segment === '.' || segment === '..') + ) { + throw new Error('Sandbox workspace path must be a contained relative path') + } + return normalized } function sha256(value) { @@ -128,11 +144,11 @@ function proofPrompts(coordinates, holdMs) { const holdSeconds = Math.max(5, Math.ceil(holdMs / 1000)) return { first: [ - 'Use the Tangle Sandbox workspace for every command in this turn.', + 'Use the current Tangle Sandbox working directory for every command in this turn.', `Create ${path} and write exactly ${coordinates.marker} followed by a newline.`, `Read ${path}, print its contents, and prove the workspace is usable with a shell command.`, - 'Run git -C /workspace rev-parse --is-inside-work-tree. If it fails, run git -C /workspace init.', - 'Then run git -C /workspace rev-parse --is-inside-work-tree again and print its result.', + 'Run git -C . rev-parse --is-inside-work-tree. If it fails, run git -C . init.', + 'Then run git -C . rev-parse --is-inside-work-tree again and print its result.', `Execute the shell command sleep ${holdSeconds} before the final response to keep the run active.`, `Reply with exactly ${coordinates.marker}.`, ].join(' '), @@ -172,8 +188,8 @@ async function prepareContinuityChallenge(client, controlRef, coordinates) { const bytes = `${challenge}\n` const path = continuityChallengePath(coordinates) const responsePath = continuityResponsePath(coordinates) - await box.write(path, bytes) - const readBack = await box.read(path) + await box.write(sandboxWorkspaceRelativePath(path), bytes) + const readBack = await box.read(sandboxWorkspaceRelativePath(path)) assert.equal(readBack, bytes, 'continuity challenge did not persist before the follow-up') return { path, @@ -207,11 +223,11 @@ async function verifyRetainedWorkspace(client, controlRef, coordinates, continui const markerPath = workspaceMarkerPath(coordinates) const expectedMarker = `${coordinates.marker}\n` const [readValue, continuityResponse] = await Promise.all([ - box.read(markerPath), - box.read(continuity.responsePath), + box.read(sandboxWorkspaceRelativePath(markerPath)), + box.read(sandboxWorkspaceRelativePath(continuity.responsePath)), ]) const git = await box.exec( - `set -eu; test "$(cat -- ${shellQuote(markerPath)})" = ${shellQuote(coordinates.marker)}; test "$(cat -- ${shellQuote(continuity.responsePath)})" = ${shellQuote(continuity.expectedDigest)}; test "$(git -C /workspace rev-parse --is-inside-work-tree)" = true; git -C /workspace status --short --untracked-files=no`, + `set -eu; test "$(cat -- ${shellQuote(markerPath)})" = ${shellQuote(coordinates.marker)}; test "$(cat -- ${shellQuote(continuity.responsePath)})" = ${shellQuote(continuity.expectedDigest)}; test "$(git -C . rev-parse --is-inside-work-tree)" = true; git -C . status --short --untracked-files=no`, ) const gitExitCode = Number.isInteger(git?.exitCode) ? git.exitCode @@ -310,14 +326,23 @@ export function cloudFailureEventTimeline(responses, runId) { event.payload && typeof event.payload === 'object' && !Array.isArray(event.payload) ? event.payload : event - const eventRunId = typeof payload.runId === 'string' ? payload.runId : undefined + const value = + payload.value && typeof payload.value === 'object' && !Array.isArray(payload.value) + ? payload.value + : undefined + const eventRunId = + typeof payload.runId === 'string' + ? payload.runId + : typeof value?.runId === 'string' + ? value.runId + : undefined if (eventRunId !== undefined && eventRunId !== runId) return [] const kind = diagnosticText(event.kind) if (kind === undefined) return [] const fields = Object.fromEntries( ['status', 'detail', 'error', 'reason', 'code'].flatMap((name) => { - const value = diagnosticText(payload[name]) - return value === undefined ? [] : [[name, value]] + const field = diagnosticText(payload[name] ?? value?.[name]) + return field === undefined ? [] : [[name, field]] }), ) return [ @@ -661,13 +686,7 @@ function assertSameAccount(state, run, directIdentity) { } function toolEvidence(observation, label) { - const events = observation.events.filter( - (event) => - event.kind === 'run.tool.call' || - event.kind === 'run.tool.result' || - (event.kind === 'run.part.updated' && - (event.partKind === 'tool-call' || event.partKind === 'tool-result')), - ) + const events = workspaceToolEvents(observation) if (events.length === 0) { throw new MissingIntegrationError(`${label} exposed no provider-bound workspace tool event`, { required: ['run.tool.call', 'run.tool.result', 'run.part.updated:tool'], @@ -748,6 +767,13 @@ async function verifyRemoteCancellation(client, controlRef, marker, timeoutMs) { } function durationMs(run) { + if ( + typeof run?.durationMs === 'number' && + Number.isFinite(run.durationMs) && + run.durationMs >= 0 + ) { + return run.durationMs + } const start = Date.parse(run?.startedAt ?? '') const end = Date.parse(run?.terminalAt ?? run?.updatedAt ?? '') return Number.isFinite(start) && Number.isFinite(end) && end >= start ? end - start : undefined @@ -985,6 +1011,11 @@ function assistantMarker(state, runId) { .find((message) => message.runId === runId && message.role === 'assistant')?.text } +export function hasSingleMarkerLine(value, marker) { + if (typeof value !== 'string' || typeof marker !== 'string' || marker.length === 0) return false + return value.split(/\r?\n/u).filter((line) => line.trim() === marker).length === 1 +} + export function runIdForOperation(state, operationId) { const matches = (state?.runs ?? []).filter((run) => run.operationId === operationId) if (matches.length > 1) { @@ -1012,7 +1043,7 @@ export async function runBraidSandboxStress({ environment = process.env, repository: suppliedRepository = repository, binary: suppliedBinary, - requireZeroActiveResourceDelta = true, + requireZeroActiveResourceDelta = false, } = {}) { const startedAt = performance.now() const coordinates = proofCoordinates() @@ -1155,29 +1186,25 @@ export async function runBraidSandboxStress({ assertNonTerminalRun(firstRunBeforeVisible, 'first Braid run') assert.equal(localRunCount(firstStateBeforeVisible.state, firstRunId), 1) await phase('firstProcess.waitVisible', () => - waitForVisibleEvents(firstSession, firstRunId, timeoutMs, 'first process'), + waitForWorkspaceToolEvents(firstSession, firstRunId, timeoutMs, 'first process'), ) const firstState = await stateRoundTrip(firstSession) const firstRun = firstState.state.runs?.find((run) => run.id === firstRunId) assertNonTerminalRun(firstRun, 'first Braid run') assert.equal(localRunCount(firstState.state, firstRunId), 1) - const persistedCursor = firstRun?.lastCursor - const persistedCursorRecord = firstState.state.replayCursors?.find( - (record) => record.runId === firstRunId, - ) + const persistedCursor = firstRun?.cursor resumeFromCursor = persistedCursor if ( resumeFromCursor === undefined || - persistedCursorRecord?.cursor !== resumeFromCursor || - !Number.isSafeInteger(persistedCursorRecord.committedSequence) || - persistedCursorRecord.committedSequence < 1 || - persistedCursorRecord.committedSequence > firstState.state.sequence + !Number.isSafeInteger(firstRun?.cursorCommittedSequence) || + firstRun.cursorCommittedSequence < 1 || + firstRun.cursorCommittedSequence > firstState.state.sequence ) { throw new MissingIntegrationError( 'The pre-kill Braid process did not persist one acknowledged provider resume cursor', { runId: firstRunId, - required: ['run.lastCursor', 'state.replayCursors', 'committed journal sequence'], + required: ['run.cursor', 'run.cursorCommittedSequence'], }, ) } @@ -1224,7 +1251,14 @@ export async function runBraidSandboxStress({ 'completed', `reconnected run ended ${freshRun?.status ?? 'missing'}`, ) - assert.equal(assistantMarker(freshTerminal.response.state, firstRunId), coordinates.marker) + assert.equal( + hasSingleMarkerLine( + assistantMarker(freshTerminal.response.state, firstRunId), + coordinates.marker, + ), + true, + 'reconnected response did not contain one exact marker line', + ) assert.equal(localRunCount(freshTerminal.response.state, firstRunId), 1) freshObservation = await phase('freshProcess.observeControl', () => waitForControlIdentity(freshSession, firstRunId, timeoutMs), @@ -1238,7 +1272,7 @@ export async function runBraidSandboxStress({ ) finalCursor = latestCursorFromResponses(freshSession.responses, firstRunId) ?? - freshRun?.lastCursor ?? + freshRun?.cursor ?? freshObservation.cursor freshVisible = assertUniqueVisibleEvents(freshSession.responses, firstRunId, 'fresh process') assertNonVacuousVisibleEvents(freshVisible, 'fresh-process replay') @@ -1278,8 +1312,12 @@ export async function runBraidSandboxStress({ ) assert.equal(followUpTerminal.run?.status, 'completed') assert.equal( - assistantMarker(followUpTerminal.response.state, followUpRunId), - continuity.expectedDigest, + hasSingleMarkerLine( + assistantMarker(followUpTerminal.response.state, followUpRunId), + continuity.expectedDigest, + ), + true, + 'follow-up response did not contain one exact digest line', ) const followUpObservation = await phase('followUp.observeControl', () => waitForControlIdentity(freshSession, followUpRunId, timeoutMs), @@ -1362,8 +1400,8 @@ export async function runBraidSandboxStress({ ) const cancelAck = assertAck(cancel, 'cancel_run') assert.equal(cancelAck.runId, cancelRunId) - const cancelled = await phase('cancel.waitTerminal', () => - waitForTerminal(freshSession, cancelRunId, timeoutMs), + const cancelled = await phase('cancel.waitCompletion', () => + waitForRequestState(freshSession, cancel.request.requestId, cancelRunId, timeoutMs), ) assert.ok( ['aborted', 'cancelled'].includes(cancelled.run?.status), @@ -1483,8 +1521,8 @@ export async function runBraidSandboxStress({ config: configEvidence(config), processes: { first: killed, - cancelled: cancelledProcessCleanup, - retry: retryProcessCleanup, + cancelled: { cleanup: cancelledProcessCleanup }, + retry: { cleanup: retryProcessCleanup }, localRunCountAfterReconnect: firstRunCountAfterReplay, binarySha256, }, @@ -1710,6 +1748,10 @@ export async function runBraidSandboxStress({ identity: cleanupIdentity, activeResourceDelta: usageDelta.activeSandboxes, activeResourceDeltaRequired: requireZeroActiveResourceDelta, + accountUsageScope: 'account-wide', + accountUsageAttribution: requireZeroActiveResourceDelta + ? 'exclusive-proof-window' + : 'unattributed-shared-usage', usageObservationComplete, usageDelta, }, diff --git a/scripts/package-proof-flows.mjs b/scripts/package-proof-flows.mjs index b217837..5831854 100644 --- a/scripts/package-proof-flows.mjs +++ b/scripts/package-proof-flows.mjs @@ -99,9 +99,9 @@ export async function runRpc(binary, cwd) { .map((line) => JSON.parse(line)) if (stderr) throw new Error(`packed RPC wrote stderr: ${stderr}`) const allResponses = responses() - const firstState = allResponses.find( - (response) => response.type === 'state' && response.requestId === 'req-send', - )?.state + const firstState = allResponses + .filter((response) => response.type === 'state' && response.requestId === 'req-send') + .at(-1)?.state if (!firstState) throw new Error('packed RPC did not return send state') const state = allResponses.find( (response) => response.type === 'state' && response.requestId === 'req-cancel', diff --git a/scripts/packed-rpc/deterministic.mjs b/scripts/packed-rpc/deterministic.mjs index fa4d96d..dbd5e24 100644 --- a/scripts/packed-rpc/deterministic.mjs +++ b/scripts/packed-rpc/deterministic.mjs @@ -89,9 +89,9 @@ export async function runDeterministicRpcProof(binary, repository) { if (!normalized.trim()) throw new Error(`packed RPC produced no stdout; stderr=${stderr}`) const lines = normalized.trim().split('\n') const responses = lines.map((line) => JSON.parse(line)) - const state = responses.find( - (response) => response.type === 'state' && response.requestId === 'req-send', - ) + const state = responses + .filter((response) => response.type === 'state' && response.requestId === 'req-send') + .at(-1) if (state?.state.messages.at(-1)?.text !== 'Fixture response through pi: packed rpc proof') throw new Error(`packed RPC semantic proof failed\n${stdout}`) if (lines.some((line) => !line.startsWith('{'))) diff --git a/scripts/packed-rpc/first-run-tui.mjs b/scripts/packed-rpc/first-run-tui.mjs index 06f9956..7d59682 100644 --- a/scripts/packed-rpc/first-run-tui.mjs +++ b/scripts/packed-rpc/first-run-tui.mjs @@ -9,10 +9,13 @@ function sleep(milliseconds) { return new Promise((resolve) => setTimeout(resolve, milliseconds)) } -async function waitFor(predicate, label, timeoutMs = 15_000) { +async function waitFor(predicate, label, timeoutMs = 15_000, diagnostic) { const deadline = Date.now() + timeoutMs while (!predicate()) { - if (Date.now() >= deadline) throw new Error(`Timed out waiting for ${label}`) + if (Date.now() >= deadline) { + const detail = diagnostic?.() + throw new Error(`Timed out waiting for ${label}${detail ? `\n${detail}` : ''}`) + } await sleep(20) } } @@ -87,8 +90,10 @@ export async function runPackedFirstRun(binary, repository) { let completed = false try { await waitFor( - () => output.includes('braid setup') || output.includes('Braid starter'), + () => (expectSetup ? output.includes('braid setup') : output.includes('Ctrl+P commands')), 'packed TUI startup surface', + 15_000, + () => output, ) if (expectSetup) { await waitFor(() => output.includes('braid setup'), 'packed first-run setup') diff --git a/scripts/release/check-registry-collision.mjs b/scripts/release/check-registry-collision.mjs new file mode 100644 index 0000000..69a8b49 --- /dev/null +++ b/scripts/release/check-registry-collision.mjs @@ -0,0 +1,131 @@ +import { createHash } from 'node:crypto' +import { mkdtemp, readdir, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { basename, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { readRegularFileNoFollow } from '../release-files.mjs' +import { executeArgv } from './command-runner.mjs' +import { npmInvocation } from './platform.mjs' + +const NOT_FOUND_PATTERN = /(?:E404|404\s+Not Found|not in this registry|no match found)/iu +const BRAID_PACKAGE_SPEC = /^@tangle-network\/braid@\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u + +function assert(condition, message) { + if (!condition) throw new Error(message) +} + +function sha256(bytes) { + return createHash('sha256').update(bytes).digest('hex') +} + +function option(name) { + const index = process.argv.indexOf(name) + return index === -1 ? undefined : process.argv[index + 1] +} + +function outputText(result) { + return `${Buffer.from(result?.stdout?.bytes ?? []).toString('utf8')}\n${Buffer.from(result?.stderr?.bytes ?? []).toString('utf8')}` +} + +function assertCommandSucceeded(result, label) { + assert(result?.exitCode === 0, `${label} failed: ${outputText(result).trim()}`) + assert(result.signal === null, `${label} terminated by ${result.signal}`) + assert(result.timedOut !== true, `${label} timed out`) + assert(result.spawnError === null, `${label} could not start: ${result.spawnError}`) + assert(result.cleanupConfirmed === true, `${label} did not confirm process cleanup`) +} + +function assertCommandSettled(result, label) { + assert(Number.isInteger(result.exitCode), `${label} did not return an exit code`) + assert(result.signal === null, `${label} terminated by ${result.signal}`) + assert(result.timedOut !== true, `${label} timed out`) + assert(result.spawnError === null, `${label} could not start: ${result.spawnError}`) + assert(result.cleanupConfirmed === true, `${label} did not confirm process cleanup`) +} + +function packedFilename(before, after, packageSpec) { + const created = after.filter((filename) => !before.has(filename) && filename.endsWith('.tgz')) + assert( + created.length === 1, + `npm pack created ${created.length} tarballs for ${packageSpec}; expected one`, + ) + const filename = created[0] + assert( + basename(filename) === filename, + `npm pack returned an unsafe tarball name for ${packageSpec}`, + ) + return filename +} + +export async function assertRegistryVersionAvailable({ + packageSpec, + tarballPath, + environment = process.env, + workingDirectory, + runCommand = executeArgv, +} = {}) { + assert( + typeof packageSpec === 'string' && BRAID_PACKAGE_SPEC.test(packageSpec), + 'Registry package spec must be @tangle-network/braid at one semantic version', + ) + assert(typeof tarballPath === 'string' && tarballPath.length > 0, 'Candidate tarball is required') + const root = workingDirectory ?? (await mkdtemp(join(tmpdir(), 'braid-registry-collision-'))) + const ownsRoot = workingDirectory === undefined + const runNpm = (args) => { + const invocation = npmInvocation(args) + return runCommand({ + ...invocation, + cwd: root, + environment, + }) + } + try { + const candidate = await readRegularFileNoFollow(tarballPath) + const candidateSha256 = sha256(candidate) + const view = await runNpm(['view', packageSpec, 'version', '--json']) + if (view.exitCode !== 0) { + assertCommandSettled(view, `Registry lookup for ${packageSpec}`) + assert( + NOT_FOUND_PATTERN.test(outputText(view)), + `Registry lookup failed for ${packageSpec}: ${outputText(view).trim()}`, + ) + return { packageSpec, status: 'available' } + } + assertCommandSucceeded(view, `Registry lookup for ${packageSpec}`) + + const beforePack = new Set(await readdir(root)) + const packed = await runNpm([ + 'pack', + '--ignore-scripts', + '--silent', + '--pack-destination', + root, + packageSpec, + ]) + assertCommandSucceeded(packed, `Registry tarball download for ${packageSpec}`) + const filename = packedFilename(beforePack, await readdir(root), packageSpec) + const existing = await readRegularFileNoFollow(join(root, filename)) + const existingSha256 = sha256(existing) + if (existingSha256 !== candidateSha256) + throw new Error( + `Registry version collision: ${packageSpec} exists with a different tarball (registry ${existingSha256}, candidate ${candidateSha256})`, + ) + return { packageSpec, status: 'already-published', sha256: candidateSha256 } + } finally { + if (ownsRoot) await rm(root, { recursive: true, force: true }) + } +} + +if (resolve(process.argv[1] ?? '') === fileURLToPath(import.meta.url)) { + const packageSpec = option('--package') + const tarballPath = option('--tarball') + if (!packageSpec || !tarballPath) throw new Error('--package and --tarball are required') + + const outcome = await assertRegistryVersionAvailable({ packageSpec, tarballPath }) + if (process.argv.includes('--json')) process.stdout.write(`${JSON.stringify(outcome)}\n`) + else if (outcome.status === 'available') + process.stdout.write(`Registry version ${packageSpec} is available for publication.\n`) + else + process.stdout.write(`Registry version ${packageSpec} already contains this exact tarball.\n`) +} diff --git a/scripts/release/registry-collision.test.mjs b/scripts/release/registry-collision.test.mjs new file mode 100644 index 0000000..51a76c8 --- /dev/null +++ b/scripts/release/registry-collision.test.mjs @@ -0,0 +1,133 @@ +import assert from 'node:assert/strict' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import test from 'node:test' + +import { assertRegistryVersionAvailable } from './check-registry-collision.mjs' + +function result(exitCode, stdout = '', stderr = '', cleanupConfirmed = true) { + return { + exitCode, + signal: null, + timedOut: false, + spawnError: null, + cleanupConfirmed, + stdout: { bytes: Buffer.from(stdout) }, + stderr: { bytes: Buffer.from(stderr) }, + } +} + +test('a registry 404 is the only lookup failure accepted as an available version', async () => { + const root = await mkdtemp(join(tmpdir(), 'braid-registry-available-')) + try { + await writeFile(join(root, 'candidate.tgz'), 'candidate bytes') + const outcome = await assertRegistryVersionAvailable({ + packageSpec: '@tangle-network/braid@1.0.0', + tarballPath: join(root, 'candidate.tgz'), + workingDirectory: root, + runCommand: async () => result(1, '', 'npm ERR! code E404\nnpm ERR! 404 Not Found'), + }) + assert.deepEqual(outcome, { + packageSpec: '@tangle-network/braid@1.0.0', + status: 'available', + }) + + await assert.rejects( + assertRegistryVersionAvailable({ + packageSpec: '@tangle-network/braid@1.0.0', + tarballPath: join(root, 'candidate.tgz'), + workingDirectory: root, + runCommand: async () => result(1, '', 'npm ERR! code EAI_AGAIN'), + }), + /Registry lookup failed/u, + ) + await assert.rejects( + assertRegistryVersionAvailable({ + packageSpec: '@tangle-network/braid@1.0.0', + tarballPath: join(root, 'candidate.tgz'), + workingDirectory: root, + runCommand: async () => result(1, '', 'npm ERR! code E404\nnpm ERR! 404 Not Found', false), + }), + /did not confirm process cleanup/u, + ) + await assert.rejects( + assertRegistryVersionAvailable({ + packageSpec: '@tangle-network/braid@1.0.0', + tarballPath: join(root, 'candidate.tgz'), + workingDirectory: root, + runCommand: async () => result(null, '', 'npm ERR! code E404\nnpm ERR! 404 Not Found'), + }), + /did not return an exit code/u, + ) + } finally { + await rm(root, { recursive: true, force: true }) + } +}) + +test('a different existing tarball fails closed before publication', async () => { + const root = await mkdtemp(join(tmpdir(), 'braid-registry-different-')) + const candidatePath = join(root, 'candidate.tgz') + const filename = 'example-braid-1.0.0.tgz' + try { + await writeFile(candidatePath, 'candidate bytes') + await assert.rejects( + assertRegistryVersionAvailable({ + packageSpec: '@tangle-network/braid@1.0.0', + tarballPath: candidatePath, + workingDirectory: root, + runCommand: async ({ args, cwd }) => { + if (args[0] === 'view') return result(0, '1.0.0\n') + await writeFile(join(cwd, filename), 'different registry bytes') + return result(0, filename) + }, + }), + /exists with a different tarball/u, + ) + } finally { + await rm(root, { recursive: true, force: true }) + } +}) + +test('an identical existing tarball is accepted as an idempotent publication', async () => { + const root = await mkdtemp(join(tmpdir(), 'braid-registry-identical-')) + const candidatePath = join(root, 'candidate.tgz') + const filename = 'example-braid-1.0.0.tgz' + try { + const bytes = Buffer.from('candidate bytes') + await writeFile(candidatePath, bytes) + const outcome = await assertRegistryVersionAvailable({ + packageSpec: '@tangle-network/braid@1.0.0', + tarballPath: candidatePath, + workingDirectory: root, + runCommand: async ({ args, cwd }) => { + if (args[0] === 'view') return result(0, '1.0.0\n') + await writeFile(join(cwd, filename), bytes) + return result(0, filename) + }, + }) + assert.equal(outcome.status, 'already-published') + assert.equal(outcome.sha256.length, 64) + assert.deepEqual(await readFile(join(root, filename)), bytes) + } finally { + await rm(root, { recursive: true, force: true }) + } +}) + +test('registry options and other package names are rejected before npm runs', async () => { + let calls = 0 + for (const packageSpec of ['--registry=https://attacker.invalid', '@example/braid@1.0.0']) { + await assert.rejects( + assertRegistryVersionAvailable({ + packageSpec, + tarballPath: 'unused.tgz', + runCommand: async () => { + calls += 1 + return result(0) + }, + }), + /must be @tangle-network\/braid/u, + ) + } + assert.equal(calls, 0) +}) diff --git a/scripts/test-pty.mjs b/scripts/test-pty.mjs index 19fc879..191b4f5 100644 --- a/scripts/test-pty.mjs +++ b/scripts/test-pty.mjs @@ -16,10 +16,13 @@ function sleep(milliseconds) { return new Promise((resolve) => setTimeout(resolve, milliseconds)) } -async function waitFor(predicate, label) { +async function waitFor(predicate, label, diagnostic) { const deadline = Date.now() + 5_000 while (!predicate()) { - if (Date.now() >= deadline) throw new Error(`Timed out waiting for ${label}`) + if (Date.now() >= deadline) { + const detail = diagnostic?.() + throw new Error(`Timed out waiting for ${label}${detail ? `\n${detail}` : ''}`) + } await sleep(20) } } @@ -62,6 +65,12 @@ async function run(columns, rows, options = {}) { else environment.BRAID_KEYMAP = keymap const args = [binary, '--fixture', 'deterministic'] if (noColor) args.push('--no-color') + const emulator = new XtermTerminal({ + cols: columns, + rows, + disableStdin: true, + allowProposedApi: true, + }) try { const session = pty.spawn(process.execPath, args, { name: term, @@ -72,9 +81,13 @@ async function run(columns, rows, options = {}) { }) let output = '' let query = '' + let screen = '' const exited = new Promise((resolve) => session.onExit(resolve)) session.onData((chunk) => { output += chunk + emulator.write(chunk, () => { + screen = screenFrom(emulator, rows) + }) if (!kitty) return query = `${query}${chunk}`.slice(-64) if (query.includes('\u001b[>7u')) { @@ -82,33 +95,49 @@ async function run(columns, rows, options = {}) { query = '' } }) - await waitFor(() => output.includes('Braid starter'), `${columns}x${rows} conversation shell`) + await waitFor( + () => normalizeScreen(screen).includes('Braid starter'), + `${columns}x${rows} conversation shell`, + ) if (kitty) session.write('\u001b[112;5u') else if (keymap === undefined) session.write('\u0010') else session.write('\u0011') - await waitFor(() => output.includes('Commands'), `${columns}x${rows} command overlay`) + await waitFor( + () => normalizeScreen(screen).includes('Commands'), + `${columns}x${rows} command overlay`, + ) if (kitty) session.write('\u001b[112;5:3u') session.write('\u001b') - await sleep(100) + await waitFor( + () => !normalizeScreen(screen).includes('Commands'), + `${columns}x${rows} command overlay close`, + () => screen, + ) if (helpDiagnostic) { session.write('/help\r') await waitFor( - () => output.includes('Kitty protocol unavailable'), + () => normalizeScreen(screen).includes('Kitty protocol unavailable'), `${columns}x${rows} keyboard fallback diagnostic`, ) session.write('\u001b') - await sleep(100) + await waitFor( + () => !normalizeScreen(screen).includes('Kitty protocol unavailable'), + `${columns}x${rows} help close`, + () => screen, + ) } if (bracketedPaste) session.write(`\u001b[200~${prompt}\u001b[201~`) else session.write(prompt) session.write('\r') + const expectedResult = normalizeScreen(`Fixture response through pi: ${prompt}`) await waitFor( - () => output.includes(`Fixture response through pi: ${prompt}`), + () => normalizeScreen(screen).includes(expectedResult), `${columns}x${rows} result`, + () => screen, ) session.write('\u0003') await waitFor( - () => output.toLowerCase().includes('ctrl+c again to quit'), + () => normalizeScreen(screen).toLowerCase().includes('ctrl+c again to quit'), `${columns}x${rows} safe exit`, ) session.write('\u0003') @@ -133,6 +162,7 @@ async function run(columns, rows, options = {}) { if (expectNoMetadata) assertAccessibleTerminalOutput(output) return output } finally { + emulator.dispose() await rm(journalPath, { force: true }) } } @@ -198,11 +228,16 @@ async function runAutocompleteRace() { throw new Error('stale slash completion changed /profile before submit') session.write('\u001b') - await sleep(50) + await waitFor( + () => !normalizeScreen(screen).includes('^V valid'), + 'autocomplete race profile close', + () => screen, + ) session.write('\u0003') await waitFor( () => normalizeScreen(screen).toLowerCase().includes('ctrl+c again to quit'), 'autocomplete race safe exit', + () => screen, ) session.write('\u0003') const exit = await Promise.race([ diff --git a/src/adapters/agent-interface/harness-runtime.ts b/src/adapters/agent-interface/harness-runtime.ts index bbf9740..860a516 100644 --- a/src/adapters/agent-interface/harness-runtime.ts +++ b/src/adapters/agent-interface/harness-runtime.ts @@ -2,9 +2,10 @@ import { agentInterfaceModuleUrl } from './module-url.js' type AgentInterfaceModule = typeof import('@tangle-network/agent-interface') -const [capabilities, harness] = (await Promise.all([ +const [capabilities, harness, profile] = (await Promise.all([ import(agentInterfaceModuleUrl('harness-capabilities.js')), import(agentInterfaceModuleUrl('harness.js')), + import(agentInterfaceModuleUrl('profile-schema.js')), ])) as [ Pick< AgentInterfaceModule, @@ -19,6 +20,7 @@ const [capabilities, harness] = (await Promise.all([ | 'snapModelToHarness' >, Pick, + Pick, ] export const { @@ -34,3 +36,5 @@ export const { } = capabilities export const harnessTypeSchema: AgentInterfaceModule['harnessTypeSchema'] = harness.harnessTypeSchema +export const reasoningEffortSchema: AgentInterfaceModule['reasoningEffortSchema'] = + profile.reasoningEffortSchema diff --git a/src/adapters/analysis/eval-analyst.ts b/src/adapters/analysis/eval-analyst.ts index 18b918e..7fa88e3 100644 --- a/src/adapters/analysis/eval-analyst.ts +++ b/src/adapters/analysis/eval-analyst.ts @@ -10,6 +10,7 @@ import type { AnalysisExecutionTarget, AnalysisRecipe } from '../../app/analysis import { AnalysisCapabilityError, type AnalysisCapabilityIssue } from '../../app/analysis-types.js' import { AGENT_EVAL_VERSION } from './agent-eval-version.js' import type { ModelExecutionScope } from './model-execution-scope.js' +import type { RetainedRunAdmissionRecord } from '../../domain/run-contracts.js' import { BRAID_QUESTION_ANALYST_ID } from './question-analyst.js' import type { AnalysisTraceBundle } from './trace-store.js' @@ -38,6 +39,10 @@ export interface EvalAnalystRequest { readonly totalTimeoutMs?: number readonly signal?: AbortSignal readonly executionTarget?: AnalysisExecutionTarget + readonly onRetainedAdmission?: ( + callId: string, + admission: RetainedRunAdmissionRecord, + ) => Promise } export interface EvalAnalystStreamEvent { diff --git a/src/adapters/analysis/production-analysis-analyst.ts b/src/adapters/analysis/production-analysis-analyst.ts index fa48b28..2192928 100644 --- a/src/adapters/analysis/production-analysis-analyst.ts +++ b/src/adapters/analysis/production-analysis-analyst.ts @@ -17,7 +17,7 @@ export class ProductionAnalysisAnalyst implements AnalysisAnalyst { readonly #bootstrap: AnalysisAnalyst readonly #create: ( target: AnalysisExecutionTarget, - request: Pick, + request: Pick, ) => Promise readonly #completed = new Map() @@ -26,7 +26,7 @@ export class ProductionAnalysisAnalyst implements AnalysisAnalyst { readonly connectionOptions?: ProductionConnectionOptions readonly create?: ( target: AnalysisExecutionTarget, - request: Pick, + request: Pick, ) => Promise }) { this.#bootstrap = input.bootstrap @@ -49,6 +49,9 @@ export class ProductionAnalysisAnalyst implements AnalysisAnalyst { : { maxOutputTokens: modelSettings.maxOutputTokens }), ...connectionOptions, managedRuntimeReadiness: 'complete', + ...(request.onRetainedAdmission === undefined + ? {} + : { onRetainedAdmission: request.onRetainedAdmission }), ...(request.signal === undefined ? {} : { signal: request.signal }), ...(request.totalTimeoutMs === undefined ? {} @@ -81,6 +84,9 @@ export class ProductionAnalysisAnalyst implements AnalysisAnalyst { 'The analysis request has no captured execution route.', ) : await this.#create(target, { + ...(request.onRetainedAdmission === undefined + ? {} + : { onRetainedAdmission: request.onRetainedAdmission }), ...(request.signal === undefined ? {} : { signal: request.signal }), ...(request.totalTimeoutMs === undefined ? {} diff --git a/src/adapters/analysis/runtime-model-owner.ts b/src/adapters/analysis/runtime-model-owner.ts index 0c65e3d..2ea9b65 100644 --- a/src/adapters/analysis/runtime-model-owner.ts +++ b/src/adapters/analysis/runtime-model-owner.ts @@ -18,6 +18,7 @@ import type { RuntimeStreamEvent } from '@tangle-network/agent-runtime' import type { RouterTransportConfig } from '@tangle-network/agent-runtime/kernel' import { canonicalDigest } from '../../domain/canonical.js' import type { ConnectionRecord } from '../../domain/entities.js' +import type { RetainedRunAdmissionRecord } from '../../domain/run-contracts.js' import { safePublicIdentifier } from '../../domain/provider-values.js' import { redactProviderError } from '../../domain/redaction.js' import { @@ -78,6 +79,10 @@ export interface RuntimeTraceModelOwnerOptions { readonly complete?: RouterTransportConfig['complete'] readonly retry?: RuntimeRouterRetryPolicy readonly recordExecution?: ModelExecutionRecorder + readonly onRetainedAdmission?: ( + callId: string, + admission: RetainedRunAdmissionRecord, + ) => Promise } export interface RuntimeTraceModelOwner { @@ -676,8 +681,12 @@ export function createRuntimeTraceModelOwner( const messages = textMessages(request.request) const profile = analystCallProfile(options, request.request, retry) executionProfileDigest = canonicalAgentProfileDigestHex(profile) - dispatched = true if (options.connection.kind === 'cli-bridge') { + const onRetainedAdmission = options.onRetainedAdmission + if (onRetainedAdmission === undefined) { + throw new TypeError('CLI Bridge trace analysis requires durable admission storage') + } + dispatched = true const retained = await runRetainedCliBridgeModelCall({ baseUrl: options.baseUrl, bearerToken: options.credential ?? LOCAL_ROUTER_BEARER, @@ -686,10 +695,12 @@ export function createRuntimeTraceModelOwner( messages, callId: request.callId, signal: request.signal, + onAdmission: (admission) => onRetainedAdmission(request.callId, admission), }) controlRef = retained.controlRef summary = summarizeRetainedResult(retained.result, configuredModel, startedAt) } else { + dispatched = true const { createExecutor, streamAgentTurn } = await import( '@tangle-network/agent-runtime/kernel' ) diff --git a/src/adapters/analysis/runtime-retained-model-call.ts b/src/adapters/analysis/runtime-retained-model-call.ts index dc7f405..9933c96 100644 --- a/src/adapters/analysis/runtime-retained-model-call.ts +++ b/src/adapters/analysis/runtime-retained-model-call.ts @@ -3,6 +3,7 @@ import type { AgentTurnResult } from '@tangle-network/agent-interface/environmen import { createCliBridgeProvider } from '@tangle-network/agent-provider-cli-bridge' import { startRetainedRun } from '@tangle-network/agent-runtime/kernel' import { canonicalDigest } from '../../domain/canonical.js' +import type { RetainedRunAdmissionRecorder } from '../../domain/run-contracts.js' export interface RetainedModelCallInput { readonly baseUrl: string @@ -12,6 +13,7 @@ export interface RetainedModelCallInput { readonly messages: ReadonlyArray> readonly callId: string readonly signal: AbortSignal + readonly onAdmission: RetainedRunAdmissionRecorder } export interface RetainedModelCallResult { @@ -66,6 +68,7 @@ export async function runRetainedCliBridgeModelCall( sessionId: `analysis-session-${digest}`, executionId: `analysis-${digest}`, }, + onAdmission: input.onAdmission, }) try { return { diff --git a/src/adapters/analysis/trace-analysis-adapter.ts b/src/adapters/analysis/trace-analysis-adapter.ts index 9b2cdea..4aef07a 100644 --- a/src/adapters/analysis/trace-analysis-adapter.ts +++ b/src/adapters/analysis/trace-analysis-adapter.ts @@ -10,6 +10,7 @@ import type { AgentProfile, HarnessType } from '@tangle-network/agent-interface' import type { AnalysisCapabilityIssue } from '../../app/analysis-types.js' import { ConnectionError } from '../../app/connection-errors.js' import type { ConnectionKind, ConnectionRecord } from '../../domain/entities.js' +import type { RetainedRunAdmissionRecord } from '../../domain/run-contracts.js' import { bridgeRunnerSupportsModel, materializeBridgeModelRoute, @@ -76,6 +77,10 @@ export interface TraceAnalysisAdapterOptions extends ProductionConnectionOptions readonly maxReasoningTokens?: number readonly timeoutMs?: number readonly recordExecution?: (observation: ExternalOptimizerModelExecutionObservation) => void + readonly onRetainedAdmission?: ( + callId: string, + admission: RetainedRunAdmissionRecord, + ) => Promise } export interface TraceAnalysisAdapterUnavailable { @@ -353,6 +358,9 @@ export async function createTraceAnalysisAdapter( modelExecutionScope.record(observation) options.recordExecution?.(observation) }, + ...(options.onRetainedAdmission === undefined + ? {} + : { onRetainedAdmission: options.onRetainedAdmission }), }) const maxOutputTokens = options.maxOutputTokens ?? DEFAULT_ANALYSIS_MAX_OUTPUT_TOKENS const maxReasoningTokens = diff --git a/src/adapters/connections/cli-bridge-model-route.ts b/src/adapters/connections/cli-bridge-model-route.ts index 33ad603..6f54525 100644 --- a/src/adapters/connections/cli-bridge-model-route.ts +++ b/src/adapters/connections/cli-bridge-model-route.ts @@ -16,22 +16,27 @@ export function bridgeRunnerSupportsModel(runner: HarnessType, model: string): b /** Combine split AgentProfile provider/model hints without adding a Bridge runner prefix. */ export function qualifyBridgeProfileModel(model: string, provider?: string): string { - return provider === undefined || model.includes('/') ? model : `${provider}/${model}` + const selectedProvider = provider?.trim() + if (selectedProvider === undefined || selectedProvider.length === 0) return model + return model === selectedProvider || model.startsWith(`${selectedProvider}/`) + ? model + : `${selectedProvider}/${model}` } /** Accept a pre-portability Bridge route without preserving its transport-only runner prefix. */ export function portableBridgeModel(runner: HarnessType, model: string, provider?: string): string { - const qualified = qualifyBridgeProfileModel(model, provider) - return bridgeRouteRunner(qualified) === runner ? qualified.slice(runner.length + 1) : qualified + const portable = bridgeRouteRunner(model) === runner ? model.slice(runner.length + 1) : model + return qualifyBridgeProfileModel(portable, provider) } -/** Materialize the same `//` route used by the CLI Bridge provider. */ +/** Materialize the Bridge `/` or `//` route. */ export function materializeBridgeModelRoute( runner: HarnessType, model: string, provider?: string, ): string { - return `${runner}/${portableBridgeModel(runner, model, provider)}` + const portable = portableBridgeModel(runner, model, provider) + return bridgeRouteRunner(portable) === runner ? portable : `${runner}/${portable}` } export interface BridgeCatalogTarget { diff --git a/src/adapters/connections/production-connection-providers.ts b/src/adapters/connections/production-connection-providers.ts index 3c3e45d..57d47da 100644 --- a/src/adapters/connections/production-connection-providers.ts +++ b/src/adapters/connections/production-connection-providers.ts @@ -17,6 +17,7 @@ import type { ConnectionRuntimeCapabilities, ProductionConnectionOptions, } from './production-connection-types.js' +import { supportsTangleRetainedControlLookup } from './tangle-retained-control-lookup.js' export async function createTangleSandboxClient( record: ConnectionRecord, @@ -73,7 +74,9 @@ export async function capabilitiesForConnection( const environment = tangleConnectionCapabilities( record, reported, - options.tangleRetainedControlLookup !== undefined, + options.tangleRetainedControlLookup !== undefined || + options.sandboxClient === undefined || + supportsTangleRetainedControlLookup(options.sandboxClient), ) const client = options.sandboxClient return capabilityReport(record, 'executor', environment, { diff --git a/src/adapters/connections/tangle-retained-control-lookup.ts b/src/adapters/connections/tangle-retained-control-lookup.ts new file mode 100644 index 0000000..04fdfe7 --- /dev/null +++ b/src/adapters/connections/tangle-retained-control-lookup.ts @@ -0,0 +1,132 @@ +import { AgentExactRunControlRefSchema } from '@tangle-network/agent-interface' +import type { SandboxClientLike, SandboxInstanceLike } from '@tangle-network/agent-provider-tangle' +import type { + TangleRetainedControlLookup, + TangleRetainedControlLookupInput, +} from './production-connection-types.js' + +const PAGE_SIZE = 100 +const MAX_PAGES = 1_000 +const ACTIVE_STATUSES = Object.freeze(['pending', 'provisioning', 'running']) +const INACTIVE_STATUSES = Object.freeze(['stopped', 'failed', 'expired']) + +type RetainedList = (options?: { + readonly scope?: string + readonly status?: readonly string[] + readonly limit?: number + readonly offset?: number + readonly signal?: AbortSignal +}) => Promise + +function safeIdentity(value: string): string { + return value.replace(/[^A-Za-z0-9._:-]/gu, '-').slice(0, 128) || 'run' +} + +function matchesRetainedEnvironment( + box: SandboxInstanceLike, + input: TangleRetainedControlLookupInput, +): boolean { + const metadata = box.metadata + return ( + box.name === `braid-${safeIdentity(input.providerSessionId)}` && + metadata?.owner === 'braid' && + metadata.lifecycle === 'retained' && + metadata.providerSessionId === input.providerSessionId && + metadata.retainedIdempotencyKey === input.environmentIdempotencyKey && + metadata.sessionId === input.providerSessionId && + metadata.executionId === input.executionId + ) +} + +async function matchingEnvironments( + client: SandboxClientLike, + input: TangleRetainedControlLookupInput, + statuses: readonly string[], +): Promise { + const list = client.list as RetainedList + const matches: SandboxInstanceLike[] = [] + const seen = new Set() + let offset = 0 + for (let pageIndex = 0; pageIndex < MAX_PAGES; pageIndex += 1) { + input.signal?.throwIfAborted() + const page = await list({ + scope: 'personal', + status: statuses, + limit: PAGE_SIZE, + offset, + ...(input.signal === undefined ? {} : { signal: input.signal }), + }) + input.signal?.throwIfAborted() + for (const box of page) { + if (seen.has(box.id)) throw new Error('Tangle retained lookup returned a duplicate sandbox') + seen.add(box.id) + if (matchesRetainedEnvironment(box, input)) matches.push(box) + } + if (page.length < PAGE_SIZE) return matches + offset += page.length + } + throw new Error('Tangle retained lookup exceeded its bounded sandbox scan') +} + +async function uniqueEnvironment( + client: SandboxClientLike, + input: TangleRetainedControlLookupInput, +): Promise { + const active = await matchingEnvironments(client, input, ACTIVE_STATUSES) + if (active.length > 1) throw new Error('Tangle retained lookup found multiple active sandboxes') + if (active[0] !== undefined) return active[0] + const inactive = await matchingEnvironments(client, input, INACTIVE_STATUSES) + if (inactive.length > 1) throw new Error('Tangle retained lookup found multiple sandboxes') + return inactive[0] ?? null +} + +export function supportsTangleRetainedControlLookup(client: SandboxClientLike): boolean { + return typeof client.get === 'function' && typeof client.list === 'function' +} + +/** Finds one SDK-issued run reference without creating or changing cloud state. */ +export function createTangleRetainedControlLookup( + client: SandboxClientLike, +): TangleRetainedControlLookup { + if (!supportsTangleRetainedControlLookup(client)) { + throw new Error('Tangle retained lookup requires Sandbox list and get methods') + } + return async (input) => { + const listed = await uniqueEnvironment(client, input) + if (listed === null) return null + input.signal?.throwIfAborted() + const exact = await client.get?.( + listed.id, + input.signal === undefined ? undefined : { signal: input.signal }, + ) + input.signal?.throwIfAborted() + if (exact === null || exact === undefined) return null + if (!matchesRetainedEnvironment(exact, input)) { + throw new Error('Tangle retained sandbox identity changed during exact lookup') + } + const session = exact.session?.( + input.providerSessionId, + input.signal === undefined ? undefined : { signal: input.signal }, + ) + if (session === undefined) return null + const status = await session.status( + input.signal === undefined ? undefined : { signal: input.signal }, + ) + input.signal?.throwIfAborted() + if (status === null || typeof status !== 'object' || Array.isArray(status)) return null + const parsed = AgentExactRunControlRefSchema.safeParse( + (status as Record).runControlRef, + ) + if (!parsed.success) return null + const controlRef = parsed.data + if ( + controlRef.provider !== 'tangle-sandbox' || + controlRef.environmentId !== exact.id || + controlRef.sessionId !== input.providerSessionId || + controlRef.executionId !== input.executionId + ) { + throw new Error('Tangle retained session identified another exact run') + } + return controlRef + } +} diff --git a/src/adapters/runtime/cli-bridge-retained-run.ts b/src/adapters/runtime/cli-bridge-retained-run.ts index 53f7b7b..aebb93e 100644 --- a/src/adapters/runtime/cli-bridge-retained-run.ts +++ b/src/adapters/runtime/cli-bridge-retained-run.ts @@ -95,6 +95,9 @@ export async function startCliBridgeRetainedRun( plan: CliBridgeRetainedPlan, input: ExecuteTurnInput, ): Promise { + if (input.onRetainedAdmission === undefined) { + throw new Error('Retained CLI Bridge execution requires a durable admission recorder') + } return startRetainedRun({ provider: plan.provider, environment: { @@ -112,6 +115,7 @@ export async function startCliBridgeRetainedRun( sessionId: plan.prepared.providerSessionId, executionId: plan.executionId, }, + onAdmission: input.onRetainedAdmission, }) } diff --git a/src/adapters/runtime/production-tangle-sandbox-backend.ts b/src/adapters/runtime/production-tangle-sandbox-backend.ts index 9079e08..31589bb 100644 --- a/src/adapters/runtime/production-tangle-sandbox-backend.ts +++ b/src/adapters/runtime/production-tangle-sandbox-backend.ts @@ -27,6 +27,10 @@ import { safeExecutionId, } from './production-backend-common.js' import { observeSandboxClient } from './sandbox-observation.js' +import { + createTangleRetainedControlLookup, + supportsTangleRetainedControlLookup, +} from '../connections/tangle-retained-control-lookup.js' import { withSandboxResultProjection } from './sandbox-result-projection.js' import { retainedSandboxIdentity, @@ -162,20 +166,22 @@ export async function resolveTangleSandboxRetainedConnection( ) } const identity = retainedSandboxIdentity(providerSessionId) - const retainedControlLookup = options.tangleRetainedControlLookup - if (retainedControlLookup === undefined) { - throw new ConnectionError( - 'CONNECTION_UNSUPPORTED', - 'Retained Tangle execution requires provider-backed lookup after an unacknowledged dispatch', - { connectionId }, - ) - } const lifecycle = retainedSandboxLifecycle(idleTtlSeconds) const [{ createTangleSandboxClient }, { createTangleProvider }] = await Promise.all([ import('../connections/production-connection-providers.js'), import('@tangle-network/agent-provider-tangle'), ]) const rawClient = await createTangleSandboxClient(record, options, input.signal) + if ( + options.tangleRetainedControlLookup === undefined && + !supportsTangleRetainedControlLookup(rawClient) + ) { + throw new ConnectionError( + 'CONNECTION_UNSUPPORTED', + 'Retained Tangle execution requires Sandbox list and get for exact dispatch lookup', + { connectionId }, + ) + } if (rawClient.get === undefined) { throw new ConnectionError( 'CONNECTION_UNSUPPORTED', @@ -185,6 +191,8 @@ export async function resolveTangleSandboxRetainedConnection( } const boundedClient = withRetainedSandboxPolicy(rawClient, idleTtlSeconds) const observedClient = observeSandboxClient(boundedClient, lifecycle) + const retainedControlLookup = + options.tangleRetainedControlLookup ?? createTangleRetainedControlLookup(observedClient.client) const provider = createTangleProvider({ client: observedClient.client, defaultBackend: runner, @@ -207,7 +215,7 @@ export async function resolveTangleSandboxRetainedConnection( { connectionId }, ) } - const capabilities = withoutNativeTangleContinuation(reportedCapabilities) + const capabilities = reportedCapabilities const environmentRequestDigest = canonicalDigest({ kind: 'tangle-retained-environment-request', idempotencyKey: identity.environmentIdempotencyKey, @@ -252,17 +260,6 @@ export async function resolveTangleSandboxRetainedConnection( }) } -function withoutNativeTangleContinuation( - reported: AgentEnvironmentCapabilities, -): AgentEnvironmentCapabilities { - const capabilities = { ...reported } - delete capabilities.nativeContinuation - return Object.freeze({ - ...capabilities, - sessions: Object.freeze({ ...reported.sessions, continue: false }), - }) -} - function runtimeSandboxClient( client: SandboxClientLike, name: string, diff --git a/src/adapters/runtime/retained-execution-projection.ts b/src/adapters/runtime/retained-execution-projection.ts index 3a98517..cc3ec3c 100644 --- a/src/adapters/runtime/retained-execution-projection.ts +++ b/src/adapters/runtime/retained-execution-projection.ts @@ -4,7 +4,7 @@ import type { AgentTurnResult, } from '@tangle-network/agent-interface/environment-provider' import type { TurnUsage } from '../../domain/entities.js' -import type { RuntimeEventEnvelope } from '../../domain/runtime-events.js' +import type { BraidFinalRuntimeEvent, RuntimeEventEnvelope } from '../../domain/runtime-events.js' import type { RunStatus } from '../../domain/state.js' import type { RunCapabilities } from '../../ports/execution.js' @@ -92,34 +92,43 @@ export function finalRetainedEnvelope( ): RuntimeEventEnvelope { const timestamp = new Date().toISOString() const usage = retainedTurnUsage(result.usage, model, modelRequestsFromResult(result)) + const cancelled = !result.success && result.metadata?.status === 'cancelled' + const status: Extract = result.success + ? 'completed' + : cancelled + ? 'cancelled' + : 'failed' + const event: BraidFinalRuntimeEvent = { + type: 'final', + task: { id: runId, intent }, + status, + reason: result.success + ? 'completed' + : (result.error ?? (cancelled ? 'Retained run cancelled' : 'Retained run failed')), + text: result.text, + metadata: { + model, + tokenUsage: { + input: usage.input, + output: usage.output, + ...(usage.reasoning === undefined ? {} : { reasoningTokens: usage.reasoning }), + }, + ...(usage.tokensKnown === false ? { tokensKnown: false } : {}), + ...(usage.costUsd === undefined ? {} : { costUsd: usage.costUsd }), + ...(usage.usdKnown === false ? { usdKnown: false } : {}), + ...(usage.calls === undefined ? {} : { llmCalls: usage.calls }), + }, + ...(result.success || result.error === undefined + ? {} + : { error: { kind: 'backend', message: result.error } }), + timestamp, + } return { runId, eventId: `${runId}:final`, sequence, receivedAt: timestamp, - event: { - type: 'final', - task: { id: runId, intent }, - status: result.success ? 'completed' : 'failed', - reason: result.success ? 'completed' : (result.error ?? 'Retained run failed'), - text: result.text, - metadata: { - model, - tokenUsage: { - input: usage.input, - output: usage.output, - ...(usage.reasoning === undefined ? {} : { reasoningTokens: usage.reasoning }), - }, - ...(usage.tokensKnown === false ? { tokensKnown: false } : {}), - ...(usage.costUsd === undefined ? {} : { costUsd: usage.costUsd }), - ...(usage.usdKnown === false ? { usdKnown: false } : {}), - ...(usage.calls === undefined ? {} : { llmCalls: usage.calls }), - }, - ...(result.success || result.error === undefined - ? {} - : { error: { kind: 'backend', message: result.error } }), - timestamp, - }, + event, } } diff --git a/src/adapters/runtime/retained-execution-state.ts b/src/adapters/runtime/retained-execution-state.ts index 8165bea..0f0bfe0 100644 --- a/src/adapters/runtime/retained-execution-state.ts +++ b/src/adapters/runtime/retained-execution-state.ts @@ -10,11 +10,6 @@ import type { RetainedExecutionPlan } from './retained-execution-contract.js' const MAX_RETAINED_CLIENTS = 128 -interface PreparedRetainedRun { - readonly key: string - readonly plan: RetainedExecutionPlan -} - export function retainedExecutionKey(input: ExecuteTurnInput): string { return canonicalDigest({ runId: input.runId, @@ -28,30 +23,28 @@ export function retainedExecutionKey(input: ExecuteTurnInput): string { }) } -/** Process-local handles and exact provider bindings for one Braid run. */ +/** Process-local coordination around one Braid retained run. */ export class RetainedExecutionState { - readonly #prepared = new Map() + readonly #prepared = new Map() readonly #startingHandles = new Map>() readonly #handles = new Map() readonly #plans = new Map() - readonly #controlRefs = new Map() readonly #readers = new Map() readonly #cancellationRequested = new Set() readonly #detached = new Set() - readonly #lastCursors = new Map() readonly #retainedOrder: string[] = [] rememberPrepared(runId: string, key: string, plan: RetainedExecutionPlan): void { this.#assertAdmissionCapacity(runId) - this.#prepared.set(runId, { key, plan }) + this.#prepared.set(runId, key) this.rememberPlan(runId, plan) } takePrepared(runId: string, key: string): RetainedExecutionPlan | undefined { - const prepared = this.#prepared.get(runId) - if (prepared === undefined || prepared.key !== key) return undefined + const preparedKey = this.#prepared.get(runId) + if (preparedKey === undefined || preparedKey !== key) return undefined this.#prepared.delete(runId) - return prepared.plan + return this.#plans.get(runId) } hasPrepared(runId: string): boolean { @@ -101,38 +94,15 @@ export class RetainedExecutionState { } rememberHandle(runId: string, plan: RetainedExecutionPlan, handle: RetainedRunHandle): void { - const exact = this.validateControlRef(runId, plan, handle.controlRef) - const saved = this.#controlRefs.get(runId) - if (saved !== undefined) this.assertSameControlRef(saved, exact) + this.validateControlRef(plan, handle.controlRef) this.#handles.set(runId, handle) - this.rememberControlRef(runId, plan, exact) this.rememberPlan(runId, plan) } - controlRef(runId: string): AgentExactRunControlRef | undefined { - const controlRef = this.#controlRefs.get(runId) - return controlRef === undefined ? undefined : structuredClone(controlRef) - } - - rememberControlRef( - runId: string, - plan: RetainedExecutionPlan, - controlRef: AgentExactRunControlRef, - ): AgentExactRunControlRef { - const exact = this.validateControlRef(runId, plan, controlRef) - const saved = this.#controlRefs.get(runId) - if (saved !== undefined) this.assertSameControlRef(saved, exact) - this.#controlRefs.set(runId, structuredClone(exact)) - this.#touch(runId) - return exact - } - validateControlRef( - _runId: string, plan: RetainedExecutionPlan, controlRef: AgentExactRunControlRef, ): AgentExactRunControlRef { - // The map key is Braid's local ID. The provider owns controlRef.runId. const exact = AgentExactRunControlRefSchema.parse(controlRef) if (exact.provider !== plan.providerName) { throw new Error('retained control reference names another provider') @@ -185,14 +155,6 @@ export class RetainedExecutionState { return this.#detached.has(runId) } - lastCursor(runId: string): string | undefined { - return this.#lastCursors.get(runId) - } - - rememberCursor(runId: string, cursor: string): void { - this.#lastCursors.set(runId, cursor) - } - #touch(runId: string): void { const index = this.#retainedOrder.indexOf(runId) if (index >= 0) this.#retainedOrder.splice(index, 1) @@ -211,9 +173,7 @@ export class RetainedExecutionState { this.#startingHandles.delete(evicted) this.#handles.delete(evicted) this.#plans.delete(evicted) - this.#controlRefs.delete(evicted) this.#cancellationRequested.delete(evicted) - this.#lastCursors.delete(evicted) this.#detached.delete(evicted) } } diff --git a/src/adapters/runtime/retained-execution-stream.ts b/src/adapters/runtime/retained-execution-stream.ts index 03b8685..87b236a 100644 --- a/src/adapters/runtime/retained-execution-stream.ts +++ b/src/adapters/runtime/retained-execution-stream.ts @@ -48,7 +48,6 @@ export async function* streamRetainedExecution(input: { signal, })) { sequence = envelope.sequence + 1 - if (envelope.cursor !== undefined) input.state.rememberCursor(input.runId, envelope.cursor) yield { ...envelope, runId: input.runId, sequence } } const result = await input.handle.result() diff --git a/src/adapters/runtime/retained-execution.ts b/src/adapters/runtime/retained-execution.ts index 0000919..0859c4d 100644 --- a/src/adapters/runtime/retained-execution.ts +++ b/src/adapters/runtime/retained-execution.ts @@ -226,22 +226,16 @@ export class RetainedExecutionPort implements ExecutionPort { const snapshot = await resolved.handle.status({ ...(input.signal === undefined ? {} : { signal: input.signal }), }) - const snapshotControlRef = this.#state.validateControlRef( - input.runId, - resolved.plan, - snapshot.controlRef, - ) + const snapshotControlRef = this.#state.validateControlRef(resolved.plan, snapshot.controlRef) this.#state.assertSameControlRef(resolved.handle.controlRef, snapshotControlRef) const status = resolved.plan.projectStatus({ status: snapshot.status, detached: this.#state.isDetached(input.runId), }) - const cursor = this.#state.lastCursor(input.runId) const base: ProviderRunSnapshot = { runId: input.runId, status, sessionId: resolved.handle.controlRef.sessionId, - ...(cursor === undefined ? {} : { cursor }), } if (!resolved.plan.isTerminalStatus(status)) return base if (status === 'cancelled' || status === 'expired') return base @@ -279,7 +273,7 @@ export class RetainedExecutionPort implements ExecutionPort { handle = await starting } if (supplied !== undefined) { - const exact = this.#state.validateControlRef(runId, activePlan, supplied) + const exact = this.#state.validateControlRef(activePlan, supplied) this.#state.assertSameControlRef(handle.controlRef, exact) } return { handle, plan: activePlan, wasStarting: starting !== undefined } @@ -305,7 +299,7 @@ export class RetainedExecutionPort implements ExecutionPort { const snapshot = await resolved.handle.status({ ...(signal === undefined ? {} : { signal }), }) - const exact = this.#state.validateControlRef(input.runId, resolved.plan, snapshot.controlRef) + const exact = this.#state.validateControlRef(resolved.plan, snapshot.controlRef) this.#state.assertSameControlRef(resolved.handle.controlRef, exact) return snapshot.status === 'cancelled' } catch { @@ -319,12 +313,10 @@ export class RetainedExecutionPort implements ExecutionPort { supplied: AgentExactRunControlRef | undefined, signal: AbortSignal | undefined, ): Promise { - if (supplied !== undefined) return this.#state.rememberControlRef(runId, plan, supplied) - const cached = this.#state.controlRef(runId) - if (cached !== undefined) return this.#state.rememberControlRef(runId, plan, cached) + if (supplied !== undefined) return this.#state.validateControlRef(plan, supplied) const discovered = await plan.discover(runId, signal) if (discovered === null) return null - return this.#state.rememberControlRef(runId, plan, discovered) + return this.#state.validateControlRef(plan, discovered) } async #planFor( @@ -370,8 +362,8 @@ export class RetainedExecutionPort implements ExecutionPort { if (plan === undefined) return this.#state.assertProviderSession(plan, providerSessionId) if (controlRef === undefined) return - const exact = this.#state.validateControlRef(runId, plan, controlRef) - const saved = this.#state.controlRef(runId) - if (saved !== undefined) this.#state.assertSameControlRef(saved, exact) + const exact = this.#state.validateControlRef(plan, controlRef) + const active = this.#state.handle(runId) + if (active !== undefined) this.#state.assertSameControlRef(active.controlRef, exact) } } diff --git a/src/adapters/runtime/sandbox-observation.ts b/src/adapters/runtime/sandbox-observation.ts index 3fd2adc..8591439 100644 --- a/src/adapters/runtime/sandbox-observation.ts +++ b/src/adapters/runtime/sandbox-observation.ts @@ -96,6 +96,7 @@ export function observeSandboxClient( return box === null || box === undefined ? null : observeBox(box) }, }), + ...(source.fetch === undefined ? {} : { fetch: source.fetch.bind(source) }), ...(source.list === undefined ? {} : { diff --git a/src/adapters/runtime/tangle-retained-run.ts b/src/adapters/runtime/tangle-retained-run.ts index 8413174..1f43a6a 100644 --- a/src/adapters/runtime/tangle-retained-run.ts +++ b/src/adapters/runtime/tangle-retained-run.ts @@ -45,7 +45,7 @@ export function createTangleRetainedPlan( providerSessionId: controlRef?.sessionId ?? prepared.providerSessionId, model: prepared.model, capabilities: retainedCapabilities(prepared.capabilities, { - sessionContinuation: false, + sessionContinuation: prepared.capabilities.sessions.continue, exactStatus: false, }), materializationReceipt: publicMaterializationReceipt({ @@ -87,6 +87,9 @@ export async function startTangleRetainedRun( plan: TangleRetainedPlan, input: ExecuteTurnInput, ): Promise { + if (input.onRetainedAdmission === undefined) { + throw new Error('Retained Tangle execution requires a durable admission recorder') + } // A retained create may return an existing environment for the same key. // Do not destroy it after an ambiguous dispatch failure without a provider-issued creation receipt. return startRetainedRun({ @@ -107,5 +110,6 @@ export async function startTangleRetainedRun( sessionId: plan.prepared.providerSessionId, executionId: plan.executionId, }, + onAdmission: input.onRetainedAdmission, }) } diff --git a/src/adapters/runtime/tangle-sandbox-retention.ts b/src/adapters/runtime/tangle-sandbox-retention.ts index d73442f..e34698b 100644 --- a/src/adapters/runtime/tangle-sandbox-retention.ts +++ b/src/adapters/runtime/tangle-sandbox-retention.ts @@ -35,9 +35,8 @@ export function retainedSandboxLifecycle(idleTtlSeconds: number): SandboxLifecyc return Object.freeze({ mode: 'retained', cleanup: 'explicit', - continuity: 'unavailable', + continuity: 'session', idleTtlSeconds, - reason: 'The deployed provider has not proved retry-safe native session continuation', }) } @@ -49,6 +48,7 @@ export function withRetainedSandboxPolicy( const observable = source as ObservableSandboxClient const get = source.get?.bind(source) const list = source.list?.bind(source) + const fetch = source.fetch?.bind(source) const describePlacement = source.describePlacement?.bind(source) const getIdentity = observable.getIdentity?.bind(source) const usage = observable.usage?.bind(source) @@ -73,6 +73,7 @@ export function withRetainedSandboxPolicy( return box }, }), + ...(fetch === undefined ? {} : { fetch }), ...(list === undefined ? {} : { diff --git a/src/adapters/storage/sqlite-crypto.ts b/src/adapters/storage/sqlite-crypto.ts index 546d3e4..ad8f5dd 100644 --- a/src/adapters/storage/sqlite-crypto.ts +++ b/src/adapters/storage/sqlite-crypto.ts @@ -71,6 +71,14 @@ const SECRET_KEY = /(secret|password|passphrase|token|bearer|authorization|credential|private(?:[_-]?key)?|api[-_]?key)/iu const SAFE_REFERENCE_SUFFIX = /(ref|name|kind)$/iu +function isSafeInteractionCapabilityFlag(path: string, key: string, value: JsonValue): boolean { + return ( + path.endsWith('.capabilities.environment.interactions') && + key === 'secretAnswers' && + typeof value === 'boolean' + ) +} + function secretFieldNames(value: JsonValue): readonly string[] { if (value === null || typeof value !== 'object' || Array.isArray(value)) return [] const fields = (value as Readonly>).fields @@ -186,11 +194,13 @@ export function assertPersistablePayload(value: JsonValue): void { (isSafeNumericTelemetryField(key, child) || isSafeBooleanTelemetryField(key, child) || isSafeTokenUsageRecord(key, child)) + const isSafeCapability = !containsSecret && isSafeInteractionCapabilityFlag(path, key, child) if ( SECRET_KEY.test(key) && !SAFE_REFERENCE_SUFFIX.test(key) && !isSecretMarker && - !isSafeTelemetry + !isSafeTelemetry && + !isSafeCapability ) { throw new StorageError( 'SECRET_PAYLOAD_REJECTED', diff --git a/src/adapters/tui/application-ui-controller.ts b/src/adapters/tui/application-ui-controller.ts index 38d8793..d2fe079 100644 --- a/src/adapters/tui/application-ui-controller.ts +++ b/src/adapters/tui/application-ui-controller.ts @@ -58,6 +58,7 @@ export class ApplicationUiController implements BraidUiController { #intentDispatcher: Promise | undefined readonly #profileConnectionOptions: ProfileConnectionDispatchOptions #selectedSurface: BraidViewModel['selectedSurface'] = 'transcript' + #graphQuery = '' #interactionResolved = false #notice: string | undefined #forkPreview: BraidViewModel['forkPreview'] | undefined @@ -99,6 +100,7 @@ export class ApplicationUiController implements BraidUiController { app.storageFailure(), app.cleanupUncertain(), app.canRespondToInteractions(), + this.#graphQuery, ), state, ) @@ -194,6 +196,10 @@ export class ApplicationUiController implements BraidUiController { this.#profileConnections = undefined this.#viewStateCache = undefined this.#selectedIntelligenceData = undefined + this.#graphQuery = '' + this.#interactionResolved = false + this.#notice = undefined + this.#forkPreview = undefined for (const [subscriber, subscription] of nextSubscriptions) { const registration = this.#subscriptions.get(subscriber) if (registration !== undefined) registration.active = subscription @@ -237,8 +243,9 @@ export class ApplicationUiController implements BraidUiController { markInteractionResolved: () => { this.#interactionResolved = true }, - setSelectedSurface: (surface) => { + setSelectedSurface: (surface, query) => { this.#selectedSurface = surface + if (surface === 'graph' && query !== undefined) this.#graphQuery = query }, setNotice: (notice) => { this.#notice = notice diff --git a/src/adapters/tui/ui-capabilities.ts b/src/adapters/tui/ui-capabilities.ts index b93832f..7c71228 100644 --- a/src/adapters/tui/ui-capabilities.ts +++ b/src/adapters/tui/ui-capabilities.ts @@ -124,6 +124,21 @@ export function capabilityMap( ? 'Initialize a workspace before sending' : 'Configure a connection before sending', } + const runConfigurationAvailable = + state.workspace !== null && !active && (deterministicFixture || configuredConnection) + for (const capability of ['run.runner', 'run.model', 'run.effort']) { + capabilities[capability] = runConfigurationAvailable + ? { available: true, source: 'application' } + : { + available: false, + source: 'application', + reason: active + ? 'Finish or cancel the active run first' + : state.workspace === null + ? 'Initialize a workspace first' + : 'Configure a connection before changing run settings', + } + } capabilities['run.cancel'] = active && canCancel ? { available: true, source: 'runtime' } diff --git a/src/adapters/tui/ui-command-dispatch.ts b/src/adapters/tui/ui-command-dispatch.ts index a054ae8..a94ddf6 100644 --- a/src/adapters/tui/ui-command-dispatch.ts +++ b/src/adapters/tui/ui-command-dispatch.ts @@ -4,8 +4,8 @@ import type { InteractionOutcome, InteractionView } from '../../views/shared/mod import { dispatchAutomationCommand } from './ui-automation-command.js' import { dispatchConversationRunCommand } from './ui-conversation-dispatch.js' import { dispatchCoreIntent } from './ui-core-dispatch.js' -import { dispatchHeadlessCommand } from './ui-headless-dispatch.js' import type { UiDispatchContext } from './ui-dispatch-context.js' +import { dispatchHeadlessCommand } from './ui-headless-dispatch.js' type RunCommandIntent = Extract @@ -74,7 +74,16 @@ export async function dispatchCommandIntent( ) } if (intent.command === 'graph' || intent.command === 'activity') { - return dispatchCoreIntent({ type: 'open-surface', surface: intent.command }, context) + return dispatchCoreIntent( + { + type: 'open-surface', + surface: intent.command, + ...(intent.command === 'graph' && intent.args.length > 0 + ? { query: intent.args.join(' ') } + : {}), + }, + context, + ) } if (intent.command === 'fork' && context.fixture === 'fork') { return dispatchCoreIntent({ type: 'open-surface', surface: 'fork' }, context) diff --git a/src/adapters/tui/ui-conversation-dispatch.ts b/src/adapters/tui/ui-conversation-dispatch.ts index 8e0f65f..3e12599 100644 --- a/src/adapters/tui/ui-conversation-dispatch.ts +++ b/src/adapters/tui/ui-conversation-dispatch.ts @@ -81,6 +81,23 @@ export async function dispatchConversationRunCommand( plan.allowed ? 'Review the fork before creating it' : (plan.reason ?? 'Fork unavailable'), ) } + case 'runner': + case 'model': + case 'effort': { + if (intent.args.length !== 1) throw new Error(`/${intent.command} requires exactly one value`) + const value = intent.args[0] + if (value === undefined) throw new Error(`/${intent.command} requires a value`) + const branch = await context.app.conversations.branches.setRunOverrides({ + operationId, + [intent.command]: value, + }) + return accepted( + context, + operationId, + branch, + `${overrideLabel(intent.command)} set for this branch`, + ) + } case 'export': { const format = exportFormat(intent.args[0]) const result = await context.app.conversations.exports.export({ operationId, format }) @@ -109,6 +126,19 @@ export async function dispatchConversationHeadlessCommand( ): Promise { const params = intent.params switch (intent.command) { + case 'set_run_override': { + const operationId = requiredOperationId(intent.operationId, intent.command) + const clear = optionalBoolean(params, 'clear') + const branch = await context.app.conversations.branches.setRunOverrides({ + operationId, + ...optionalString(params, 'runner'), + ...optionalString(params, 'model'), + ...optionalString(params, 'effort'), + ...optionalString(params, 'mode'), + ...(clear === undefined ? {} : { clear }), + }) + return accepted(context, operationId, branch, 'Run configuration updated') + } case 'new_conversation': { const operationId = requiredOperationId(intent.operationId, intent.command) const conversation = await context.app.conversations.lifecycle.create({ @@ -340,6 +370,22 @@ function optionalStringValue(value: unknown): string | undefined { return typeof value === 'string' && value.length > 0 ? value : undefined } +function optionalBoolean( + params: Readonly>, + key: string, +): boolean | undefined { + const value = params[key] + if (value === undefined) return undefined + if (typeof value !== 'boolean') throw new Error(`${key} must be a boolean`) + return value +} + +function overrideLabel(command: 'runner' | 'model' | 'effort'): string { + return command === 'effort' + ? 'Thinking effort' + : `${command[0]?.toUpperCase()}${command.slice(1)}` +} + function optionalStatus(value: unknown): 'active' | 'archived' | 'all' | undefined { if (value === undefined) return undefined if (value === 'active' || value === 'archived' || value === 'all') return value diff --git a/src/adapters/tui/ui-core-dispatch.ts b/src/adapters/tui/ui-core-dispatch.ts index 0276b1d..16b397e 100644 --- a/src/adapters/tui/ui-core-dispatch.ts +++ b/src/adapters/tui/ui-core-dispatch.ts @@ -39,32 +39,28 @@ export async function dispatchCoreIntent( } } case 'cancel-run': { - if (!context.app.canCancel()) { - return { - kind: 'unavailable', - code: 'CAPABILITY_UNAVAILABLE', - reason: 'The current runtime does not acknowledge provider cancellation', - } - } const receipt = await context.app.cancelRun({ operationId: intent.operationId, ...(intent.runId ? { runId: intent.runId } : {}), ...(intent.reason === undefined ? {} : { reason: intent.reason }), terminalStatus: 'aborted', - legacy: true, }) return { kind: 'accepted', operationId: receipt.operationId, runId: receipt.runId, control: 'cancel', + replayed: receipt.replayed, outcome: receipt.acknowledgement.outcome, revision: context.app.state().revision, completion: receipt.completion.then(() => undefined), } } case 'open-surface': - context.setSelectedSurface(intent.surface === 'settings' ? 'details' : intent.surface) + context.setSelectedSurface( + intent.surface === 'settings' ? 'details' : intent.surface, + intent.query, + ) context.notify() return { kind: 'accepted', revision: context.app.state().revision } case 'shutdown': { @@ -137,6 +133,7 @@ export async function dispatchCoreIntent( operationId: receipt.operationId, runId: receipt.runId, control: 'steer', + replayed: receipt.replayed, outcome: receipt.acknowledgement.outcome, revision: context.app.state().revision, completion: receipt.completion.then(() => undefined), diff --git a/src/adapters/tui/ui-dispatch-context.ts b/src/adapters/tui/ui-dispatch-context.ts index aa4ba0a..376efc0 100644 --- a/src/adapters/tui/ui-dispatch-context.ts +++ b/src/adapters/tui/ui-dispatch-context.ts @@ -12,7 +12,7 @@ export interface UiDispatchContext { readonly notify: () => void readonly interactionResolved: () => boolean markInteractionResolved(): void - setSelectedSurface(surface: BraidViewModel['selectedSurface']): void + setSelectedSurface(surface: BraidViewModel['selectedSurface'], query?: string): void setNotice(notice: string): void setForkPreview(preview: NonNullable): void } diff --git a/src/adapters/tui/ui-entity-details.ts b/src/adapters/tui/ui-entity-details.ts index bd22850..dfa6e7c 100644 --- a/src/adapters/tui/ui-entity-details.ts +++ b/src/adapters/tui/ui-entity-details.ts @@ -7,12 +7,13 @@ import { comparisonLines, comparisonViewForResult, } from '../../views/shared/comparison-presentation.js' -import type { ActivityItemView, EntityDetailView } from '../../views/shared/models.js' -import { sanitizeTerminalText } from '../../views/shared/sanitize.js' import { environmentDetailLines, environmentView, } from '../../views/shared/environment-presentation.js' +import type { ActivityItemView, EntityDetailView } from '../../views/shared/models.js' +import { sanitizeTerminalText } from '../../views/shared/sanitize.js' +import { omitUnreportedCostAndLatency } from '../../views/tui/measurement-display.js' const MAX_DETAIL_LINES = 256 @@ -73,12 +74,13 @@ function analysisDetail(record: AnalysisRecord | undefined): EntityDetailView | record.kind === 'comparison' && record.comparison !== undefined ? comparisonLines(comparisonViewForResult(resultFromComparisonRecord(record))) : analysisLines(analysis) + const visible = omitUnreportedCostAndLatency(rendered) return { entityType: 'analysis', entityId: String(record.id), - title: rendered[0] ?? `analysis ${record.id}`, + title: visible[0] ?? `analysis ${record.id}`, status: record.status, - lines: bounded(rendered.slice(1)), + lines: bounded(visible.slice(1)), analysisFindingCount: analysis.findings.length, analysisSupportedFindingCount: analysis.citationSupport.supportedFindings, analysisCitationSupport: analysis.citationSupport.status, @@ -104,7 +106,7 @@ function supervisorDetail(record: SupervisorRecord | undefined): EntityDetailVie ? [] : [ `tree usage: ${usageLine(record.totalUsage.inputTokens, record.totalUsage.outputTokens, record.totalUsage.spendUsd, record.totalUsage.completeness)}`, - `tree latency: ${record.totalUsage.latencyMs}ms · workers ${record.workerCount ?? 'unknown'}`, + `tree latency: ${record.totalUsage.latencyMs}ms${record.workerCount === undefined ? '' : ` · workers ${record.workerCount}`}`, ]), ...(record.driverUsage === undefined ? [] @@ -172,7 +174,8 @@ function usageLine( completeness: 'complete' | 'observed-floor' | 'unknown', ): string { const prefix = completeness === 'observed-floor' ? '≥' : completeness === 'unknown' ? '?' : '' - return `${prefix}${input} in · ${prefix}${output} out · ${prefix}$${spendUsd.toFixed(4)} · ${completeness}` + const cost = completeness === 'unknown' ? 'cost not reported' : `${prefix}$${spendUsd.toFixed(4)}` + return `${prefix}${input} in · ${prefix}${output} out · ${cost} · ${completeness}` } function bounded(lines: readonly string[]): readonly string[] { diff --git a/src/adapters/tui/ui-headless-dispatch.ts b/src/adapters/tui/ui-headless-dispatch.ts index d2733ab..4c69f55 100644 --- a/src/adapters/tui/ui-headless-dispatch.ts +++ b/src/adapters/tui/ui-headless-dispatch.ts @@ -122,6 +122,7 @@ export async function dispatchHeadlessCommand( operationId: receipt.operationId, runId: receipt.runId, control: 'cancel', + replayed: receipt.replayed, outcome: receipt.acknowledgement.outcome, revision: context.app.state().revision, completion: receipt.completion.then(() => undefined), @@ -137,6 +138,7 @@ export async function dispatchHeadlessCommand( operationId: receipt.operationId, runId: receipt.runId, control: 'detach', + replayed: receipt.replayed, outcome: receipt.acknowledgement.outcome, revision: context.app.state().revision, completion: receipt.completion.then(() => undefined), diff --git a/src/adapters/tui/ui-projection.ts b/src/adapters/tui/ui-projection.ts index d214946..05cd4e9 100644 --- a/src/adapters/tui/ui-projection.ts +++ b/src/adapters/tui/ui-projection.ts @@ -3,9 +3,11 @@ import type { InteractionOutcome as ProtocolInteractionOutcome, } from '@tangle-network/agent-interface' import { messagesVisibleOnBranch } from '../../app/conversation-visibility.js' +import { selectedRunConfiguration } from '../../app/effective-run-configuration.js' import { profileModelSettings } from '../../app/profile-model-settings.js' import { isSensitiveFieldName } from '../../domain/bounded-structured.js' import type { BraidEventEnvelope } from '../../domain/events.js' +import { interactionRemainingMs } from '../../domain/interaction-timeout.js' import type { BraidState } from '../../domain/state.js' import { environmentView } from '../../views/shared/environment-presentation.js' import type { UiEvent } from '../../views/shared/intents.js' @@ -174,10 +176,7 @@ function messagesFor( export function runViews(state: BraidState): RunView[] { return state.runs.slice(-MAX_VISIBLE_RUNS).map((run) => { - const elapsed = - run.terminalAt === undefined - ? undefined - : Date.parse(run.terminalAt) - Date.parse(run.startedAt) + const elapsed = completedRunDurationMs(run) const profile = run.receipt.requested.profile const modelSettings = profileModelSettings(profile) const runner = run.receipt.requested.runner ?? run.receipt.provider @@ -190,6 +189,8 @@ export function runViews(state: BraidState): RunView[] { return Object.freeze({ id: run.id, turnId: run.turnId, + conversationId: String(run.conversationId), + branchId: String(run.branchId), operationId: run.operationId, status: statusForRun(state, run), ...(profile.name === undefined ? {} : { profileName: sanitizeTerminalText(profile.name) }), @@ -206,6 +207,9 @@ export function runViews(state: BraidState): RunView[] { ...(run.providerSessionId ? { providerSessionId: sanitizeTerminalText(run.providerSessionId) } : {}), + ...(run.harnessSessionId + ? { harnessSessionId: sanitizeTerminalText(run.harnessSessionId) } + : {}), ...(run.receipt.requestedSessionId === undefined ? {} : { requestedSessionId: sanitizeTerminalText(run.receipt.requestedSessionId) }), @@ -247,7 +251,7 @@ function completenessFor( return 'complete' } -export function interactionViews(state: BraidState): InteractionView[] { +export function interactionViews(state: BraidState, now = Date.now()): InteractionView[] { const views: Array> = [] for (const run of state.runs) { for (const item of run.interactions) { @@ -272,6 +276,12 @@ export function interactionViews(state: BraidState): InteractionView[] { const requesterProfile = run.receipt.requested.profile.name ?? state.profile.name ?? 'unnamed profile' const requesterRunner = run.receipt.requested.runner ?? run.receipt.requested.profile.harness + const remainingMs = interactionRemainingMs( + request.timeoutMs, + item.source.occurredAt, + run.startedAt, + now, + ) views.push({ runId: run.id, interactionId: request.id, @@ -285,7 +295,7 @@ export function interactionViews(state: BraidState): InteractionView[] { responseScopes: (request.responseScopes ?? ['interaction']).map((scope) => scope === 'interaction' ? 'once' : scope, ), - ...(request.timeoutMs === undefined ? {} : { remainingMs: request.timeoutMs }), + ...(remainingMs === undefined ? {} : { remainingMs }), queuePosition: views.length, secret: fields.some((field) => field.type === 'secret' || isSensitiveFieldName(field.name)), ...(run.providerSessionId === undefined ? {} : { providerSession: run.providerSessionId }), @@ -485,9 +495,14 @@ export function graphFor( ) const incoming: Map = new Map() const semanticIncoming: Map = new Map() + const semanticParents = new Map>() for (const edge of result.edges) { incoming.set(edge.destinationNodeId, edge.kind) - semanticIncoming.set(`${edge.destinationType}:${edge.destination}`, edge.kind) + const destination = `${edge.destinationType}:${edge.destination}` + semanticIncoming.set(destination, edge.kind) + const parents = semanticParents.get(destination) ?? new Set() + parents.add(`${edge.sourceType}:${edge.source}`) + semanticParents.set(destination, parents) } const canonicalIncoming: Map = new Map( state.graphEdges.map((edge) => [edge.destination, edge.kind]), @@ -499,6 +514,7 @@ export function graphFor( (canonicalNode === undefined ? semanticIncoming.get(`${node.type}:${node.id}`) : (canonicalIncoming.get(canonicalNode.id) ?? incoming.get(canonicalNode.id))) ?? undefined + const parentIds = semanticParents.get(`${node.type}:${node.id}`) return { id: node.id, type: node.type, @@ -506,6 +522,7 @@ export function graphFor( status: run ? statusForRun(state, run) : viewStatusForSemanticStatus(node.status), depth: node.depth, ...(edgeLabel === undefined ? {} : { edgeLabel }), + ...(parentIds === undefined ? {} : { parentIds: Object.freeze([...parentIds].sort()) }), ...(node.runner === undefined ? {} : { runner: node.runner }), ...(node.elapsedMs === undefined ? {} : { elapsedMs: node.elapsedMs }), startedAt: node.createdAt, @@ -520,6 +537,10 @@ export function toHeadlessState( cleanupUncertain?: string, ): HeadlessState { const messages = messagesFor(state) + const replayCursorByRun = new Map(state.replayCursors.map((entry) => [entry.runId, entry])) + const configuration = selectedRunConfiguration(state, state.profile) + const modelSettings = profileModelSettings(configuration.profile) + const branch = state.branches.find((candidate) => candidate.id === state.branchId) return freezeView({ schemaVersion: state.schemaVersion, revision: state.revision, @@ -539,48 +560,93 @@ export function toHeadlessState( profile: redactStructuredValue(state.profile, undefined, { maxBytes: 16 * 1024 * 1024, }) as Readonly>, - draft: sanitizeTerminalText(state.draft), - messages, - runs: state.runs.slice(-MAX_VISIBLE_RUNS).map((run) => ({ - id: run.id, - turnId: run.turnId, - operationId: run.operationId, - status: run.status, - inputTokens: run.inputTokens, - outputTokens: run.outputTokens, - tokenStatus: usageForRun(run).tokenStatus ?? 'unknown', - ...(run.reasoningTokens === undefined ? {} : { reasoningTokens: run.reasoningTokens }), - ...(run.costUsd === undefined ? {} : { costUsd: run.costUsd }), - ...(run.estimatedCostUsd === undefined ? {} : { estimatedCostUsd: run.estimatedCostUsd }), - costStatus: usageForRun(run).costStatus ?? 'unknown', - ...(run.promptCache === undefined ? {} : { promptCache: run.promptCache }), - ...(run.llmCalls === undefined ? {} : { llmCalls: run.llmCalls }), - ...(run.llmLatencyMs === undefined ? {} : { llmLatencyMs: run.llmLatencyMs }), - ...(run.model === undefined ? {} : { model: sanitizeTerminalText(run.model) }), - ...(run.receipt.provider === undefined - ? {} - : { provider: sanitizeTerminalText(run.receipt.provider) }), - ...(run.receipt.requested.runner === undefined + runConfiguration: { + profileName: sanitizeTerminalText(configuration.profile.name ?? 'Unnamed profile'), + runner: sanitizeTerminalText(configuration.profile.harness ?? 'automatic'), + model: sanitizeTerminalText(configuration.profile.model?.default ?? 'automatic'), + ...(modelSettings.reasoningEffort === undefined ? {} - : { runner: sanitizeTerminalText(run.receipt.requested.runner) }), - ...(run.connectionId === undefined ? {} : { connectionId: String(run.connectionId) }), - ...(run.receipt.requestedSessionId === undefined + : { effort: sanitizeTerminalText(modelSettings.reasoningEffort) }), + ...(configuration.mode === undefined ? {} - : { requestedSessionId: sanitizeTerminalText(run.receipt.requestedSessionId) }), - ...(run.error === undefined ? {} : { error: sanitizeTerminalText(run.error) }), - completeness: completenessFor(state, run), - ...(run.providerSessionId === undefined ? {} : { providerSessionId: run.providerSessionId }), - ...(run.environmentId === undefined ? {} : { environmentId: run.environmentId }), - ...(run.receipt.materializationDigest === undefined + : { mode: sanitizeTerminalText(configuration.mode) }), + ...(configuration.connectionId === undefined ? {} - : { materializationDigest: run.receipt.materializationDigest }), - ...(run.lastCursor === undefined ? {} : { cursor: run.lastCursor }), - ...(run.contentBytes === undefined ? {} : { contentBytes: run.contentBytes }), - ...(run.contentTruncated ? { contentTruncated: true } : {}), - ...(run.activityTruncated ? { activityTruncated: true } : {}), - ...(run.eventDetailsTruncated ? { eventDetailsTruncated: true } : {}), - ...(run.interactionsTruncated ? { interactionsTruncated: true } : {}), - })), + : { connectionId: String(configuration.connectionId) }), + overrides: { + ...(branch?.overrides.runner === undefined + ? {} + : { runner: sanitizeTerminalText(branch.overrides.runner) }), + ...(branch?.overrides.model === undefined + ? {} + : { model: sanitizeTerminalText(branch.overrides.model) }), + ...(branch?.overrides.effort === undefined + ? {} + : { effort: sanitizeTerminalText(branch.overrides.effort) }), + ...(branch?.overrides.mode === undefined + ? {} + : { mode: sanitizeTerminalText(branch.overrides.mode) }), + }, + }, + draft: sanitizeTerminalText(state.draft), + messages, + runs: state.runs.slice(-MAX_VISIBLE_RUNS).map((run) => { + const replayCursor = replayCursorByRun.get(run.id) + const durationMs = completedRunDurationMs(run) + return { + id: run.id, + turnId: run.turnId, + operationId: run.operationId, + status: run.status, + startedAt: run.startedAt, + updatedAt: run.updatedAt, + ...(run.terminalAt === undefined ? {} : { terminalAt: run.terminalAt }), + ...(durationMs === undefined ? {} : { durationMs }), + inputTokens: run.inputTokens, + outputTokens: run.outputTokens, + tokensKnown: run.tokensKnown !== false, + usdKnown: run.usdKnown !== false, + tokenStatus: usageForRun(run).tokenStatus ?? 'unknown', + ...(run.reasoningTokens === undefined ? {} : { reasoningTokens: run.reasoningTokens }), + ...(run.costUsd === undefined ? {} : { costUsd: run.costUsd }), + ...(run.estimatedCostUsd === undefined ? {} : { estimatedCostUsd: run.estimatedCostUsd }), + costStatus: usageForRun(run).costStatus ?? 'unknown', + ...(run.promptCache === undefined ? {} : { promptCache: run.promptCache }), + ...(run.llmCalls === undefined ? {} : { llmCalls: run.llmCalls }), + ...(run.llmLatencyMs === undefined ? {} : { llmLatencyMs: run.llmLatencyMs }), + ...(run.model === undefined ? {} : { model: sanitizeTerminalText(run.model) }), + ...(run.receipt.provider === undefined + ? {} + : { provider: sanitizeTerminalText(run.receipt.provider) }), + ...(run.receipt.requested.runner === undefined + ? {} + : { runner: sanitizeTerminalText(run.receipt.requested.runner) }), + ...(run.connectionId === undefined ? {} : { connectionId: String(run.connectionId) }), + ...(run.receipt.requestedSessionId === undefined + ? {} + : { requestedSessionId: sanitizeTerminalText(run.receipt.requestedSessionId) }), + ...(run.error === undefined ? {} : { error: sanitizeTerminalText(run.error) }), + completeness: completenessFor(state, run), + ...(run.providerSessionId === undefined + ? {} + : { providerSessionId: run.providerSessionId }), + ...(run.harnessSessionId === undefined ? {} : { harnessSessionId: run.harnessSessionId }), + ...(run.environmentId === undefined ? {} : { environmentId: run.environmentId }), + ...(run.controlRef === undefined ? {} : { controlRef: structuredClone(run.controlRef) }), + ...(run.receipt.materializationDigest === undefined + ? {} + : { materializationDigest: run.receipt.materializationDigest }), + ...(run.lastCursor === undefined ? {} : { cursor: run.lastCursor }), + ...(replayCursor === undefined + ? {} + : { cursorCommittedSequence: replayCursor.committedSequence }), + ...(run.contentBytes === undefined ? {} : { contentBytes: run.contentBytes }), + ...(run.contentTruncated ? { contentTruncated: true } : {}), + ...(run.activityTruncated ? { activityTruncated: true } : {}), + ...(run.eventDetailsTruncated ? { eventDetailsTruncated: true } : {}), + ...(run.interactionsTruncated ? { interactionsTruncated: true } : {}), + } + }), sessionUsage: sessionUsageFor(state), environments: Object.freeze(state.environments.map(environmentView)), interactions: interactionViews(state), @@ -596,6 +662,12 @@ export function toHeadlessState( }) } +function completedRunDurationMs(run: BraidState['runs'][number]): number | undefined { + if (run.terminalAt === undefined) return undefined + const duration = Date.parse(run.terminalAt) - Date.parse(run.startedAt) + return Number.isFinite(duration) && duration >= 0 ? duration : undefined +} + export function toEvent(envelope: BraidEventEnvelope): UiEvent { const payload = projectSemanticEvent(envelope) return freezeView({ diff --git a/src/adapters/tui/ui-view-model.ts b/src/adapters/tui/ui-view-model.ts index 7ad71b9..6ec9336 100644 --- a/src/adapters/tui/ui-view-model.ts +++ b/src/adapters/tui/ui-view-model.ts @@ -1,4 +1,4 @@ -import type { AgentProfile } from '@tangle-network/agent-interface' +import { selectedRunConfiguration } from '../../app/effective-run-configuration.js' import { profileModelSettings } from '../../app/profile-model-settings.js' import { canonicalDigest } from '../../domain/canonical.js' import type { BraidState } from '../../domain/state.js' @@ -35,21 +35,24 @@ interface SemanticViewProjection { readonly hiddenGraphNodeCount: number } -const semanticViewCache = new WeakMap() +const semanticViewCache = new WeakMap< + BraidState, + { readonly graphQuery: string; readonly projection: SemanticViewProjection } +>() -function semanticViewFor(state: BraidState): SemanticViewProjection { +function semanticViewFor(state: BraidState, graphQuery: string): SemanticViewProjection { const cached = semanticViewCache.get(state) - if (cached !== undefined) return cached + if (cached?.graphQuery === graphQuery) return cached.projection const activity = Object.freeze(activityFor(state)) const bounded = uiSemanticState(state) - const semanticGraph = queryGraph(bounded.state) + const semanticGraph = queryGraph(bounded.state, { query: graphQuery }) const projection = Object.freeze({ activity, graph: Object.freeze(graphFor(bounded.state, semanticGraph)), entityDetails: Object.freeze(entityDetailsFor(state, activity)), hiddenGraphNodeCount: bounded.hiddenNodeCount, }) - semanticViewCache.set(state, projection) + semanticViewCache.set(state, { graphQuery, projection }) return projection } @@ -61,19 +64,23 @@ export function buildBraidViewModel( storageFailure?: string, cleanupUncertain?: string, canRespond = false, + graphQuery = '', ): BraidViewModel { const status = storageFailure ? ('storage-failure' as const) : statusFor(state) const latest = state.runs.at(-1) - const profile = state.profile as Readonly + const latestUsage = latest === undefined ? undefined : usageForRun(latest) + const configuration = selectedRunConfiguration(state, state.profile) + const profile = configuration.profile const modelSettings = profileModelSettings(profile) const fixture = profile.model?.default === 'fixture/deterministic' const profileDigest = canonicalDigest(profile) const selectedConnection = state.connections.find( - (connection) => connection.id === state.selectedConnectionId, + (connection) => String(connection.id) === configuration.connectionId, ) const selectedConversation = state.conversations.find( (conversation) => conversation.id === state.conversationId, ) + const selectedBranch = state.branches.find((branch) => branch.id === state.branchId) const statusText = storageFailure ? `storage failure: ${sanitizeTerminalText(storageFailure)}` : cleanupUncertain @@ -92,7 +99,7 @@ export function buildBraidViewModel( const model = profile.model?.default ?? 'automatic' const color = appearance.color === undefined ? ('truecolor' as const) : resolveColorMode(appearance.color) - const semantic = semanticViewFor(state) + const semantic = semanticViewFor(state, graphQuery) return freezeView({ revision: state.revision, workspace: state.workspace ? sanitizeTerminalText(state.workspace) : null, @@ -103,6 +110,20 @@ export function buildBraidViewModel( ...(profile.model?.reasoningEffort ? { effort: sanitizeTerminalText(profile.model.reasoningEffort) } : {}), + runOverrides: Object.freeze({ + ...(selectedBranch?.overrides.runner === undefined + ? {} + : { runner: sanitizeTerminalText(selectedBranch.overrides.runner) }), + ...(selectedBranch?.overrides.model === undefined + ? {} + : { model: sanitizeTerminalText(selectedBranch.overrides.model) }), + ...(selectedBranch?.overrides.effort === undefined + ? {} + : { effort: sanitizeTerminalText(selectedBranch.overrides.effort) }), + ...(selectedBranch?.overrides.mode === undefined + ? {} + : { mode: sanitizeTerminalText(selectedBranch.overrides.mode) }), + }), ...(modelSettings.maxOutputTokens === undefined ? {} : { maxOutputTokens: modelSettings.maxOutputTokens }), @@ -143,6 +164,7 @@ export function buildBraidViewModel( interactions: Object.freeze(interactionViews(state)), activity: semantic.activity, graph: semantic.graph, + graphQuery, entityDetails: semantic.entityDetails, ...(semantic.hiddenGraphNodeCount === 0 ? {} @@ -155,14 +177,12 @@ export function buildBraidViewModel( { label: 'status', value: latest.status }, { label: 'input tokens', value: String(latest.inputTokens) }, { label: 'output tokens', value: String(latest.outputTokens) }, - { - label: 'token measurement', - value: usageForRun(latest).tokenStatus ?? 'unknown', - }, - { - label: 'cost measurement', - value: usageForRun(latest).costStatus ?? 'unknown', - }, + ...(latestUsage?.tokenStatus === undefined || latestUsage.tokenStatus === 'unknown' + ? [] + : [{ label: 'token measurement', value: latestUsage.tokenStatus }]), + ...(latestUsage?.costStatus === undefined || latestUsage.costStatus === 'unknown' + ? [] + : [{ label: 'cost measurement', value: latestUsage.costStatus }]), ...(latest.model ? [{ label: 'model', value: sanitizeTerminalText(latest.model) }] : []), diff --git a/src/app/analysis-execution-session.ts b/src/app/analysis-execution-session.ts index 349bb14..12757bb 100644 --- a/src/app/analysis-execution-session.ts +++ b/src/app/analysis-execution-session.ts @@ -2,6 +2,7 @@ import type { ExactAnalystRunEvent, ExactAnalystRunResult } from '@tangle-networ import type { ExternalOptimizerModelExecutionObservation } from '@tangle-network/agent-eval/campaign' import type { AnalystDescriptor, EvalAnalystRequest } from '../adapters/analysis/eval-analyst.js' import type { AnalysisIdentity } from './analysis-operation.js' +import type { RetainedRunAdmissionRecord } from '../domain/run-contracts.js' import type { AnalysisExecutionTarget, AnalysisRequest, @@ -60,6 +61,10 @@ export class AnalysisExecutionSession { readonly request: AnalysisRequest readonly analystIds: readonly string[] readonly executionTarget: AnalysisExecutionTarget + readonly onRetainedAdmission: ( + callId: string, + admission: RetainedRunAdmissionRecord, + ) => Promise }): AsyncGenerator { const controller = new AbortController() this.#active.set(String(input.identity.analysisId), controller) @@ -78,6 +83,7 @@ export class AnalysisExecutionSession { ? {} : { totalTimeoutMs: input.request.totalTimeoutMs }), signal: controller.signal, + onRetainedAdmission: input.onRetainedAdmission, } try { for await (const item of this.#analyst.stream(request)) yield item diff --git a/src/app/analysis-lifecycle.ts b/src/app/analysis-lifecycle.ts index 2ced096..808606f 100644 --- a/src/app/analysis-lifecycle.ts +++ b/src/app/analysis-lifecycle.ts @@ -1,4 +1,6 @@ import type { AnalysisRecord } from '../domain/entities.js' +import type { RetainedRunAdmissionRecord } from '../domain/run-contracts.js' +import { recordAnalysisRetainedAdmission } from './analysis-retained-admission.js' import { type AnalysisIdentity, type AnalysisOperationReservation, @@ -57,6 +59,14 @@ export class AnalysisLifecycle { return running } + async recordRetainedAdmission( + identity: AnalysisIdentity, + callId: string, + admission: RetainedRunAdmissionRecord, + ): Promise { + await recordAnalysisRetainedAdmission(this.#host, identity, callId, admission) + } + async completed(record: AnalysisRecord): Promise { await commitAnalysisEvent(this.#host, { kind: 'analysis.completed', analysis: record }) } @@ -78,11 +88,14 @@ export class AnalysisLifecycle { terminalOutcome: cancelled ? 'cancelled' : 'failed', failureCode: cancelled ? 'ANALYSIS_CANCELLED' : 'ANALYSIS_FAILED', failureMessage: error.message, - result: operationResult({ - analysisId: String(failed.id), - sourceDigest: String(failed.source.digest), - status: failed.status, - }), + result: { + ...(operation.result ?? {}), + ...operationResult({ + analysisId: String(failed.id), + sourceDigest: String(failed.source.digest), + status: failed.status, + }), + }, }) } return failed @@ -101,11 +114,14 @@ export class AnalysisLifecycle { : record.status === 'cancelled' ? 'cancelled' : 'failed', - result: operationResult({ - analysisId: String(record.id), - sourceDigest: String(record.source.digest), - status: record.status, - }), + result: { + ...(operation.result ?? {}), + ...operationResult({ + analysisId: String(record.id), + sourceDigest: String(record.source.digest), + status: record.status, + }), + }, }) } diff --git a/src/app/analysis-retained-admission.ts b/src/app/analysis-retained-admission.ts new file mode 100644 index 0000000..c4542c2 --- /dev/null +++ b/src/app/analysis-retained-admission.ts @@ -0,0 +1,125 @@ +import { canonicalDigest } from '../domain/canonical.js' +import type { JsonValue } from '../domain/entities-base.js' +import type { RetainedRunAdmissionRecord } from '../domain/run-contracts.js' +import { + type AnalysisIdentity, + AnalysisOperationError, + updateAnalysisOperation, + withAnalysisOperationLock, +} from './analysis-operation.js' +import type { AnalysisApplicationHost } from './analysis-types.js' + +const RESULT_KEY = 'retainedAdmissions' + +function isJsonObject(value: unknown): value is Readonly> { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +function admissionValue(admission: RetainedRunAdmissionRecord): JsonValue { + if (admission.phase === 'environment') { + return { + phase: admission.phase, + provider: admission.provider, + environmentId: admission.environmentId, + idempotencyKey: admission.idempotencyKey, + turnId: admission.turnId, + sessionId: admission.sessionId, + executionId: admission.executionId, + } + } + return { + phase: admission.phase, + idempotencyKey: admission.idempotencyKey, + turnId: admission.turnId, + controlRef: { + provider: admission.controlRef.provider, + environmentId: admission.controlRef.environmentId, + sessionId: admission.controlRef.sessionId, + executionId: admission.controlRef.executionId, + runId: admission.controlRef.runId, + requestDigest: admission.controlRef.requestDigest, + }, + } +} + +function entryIdentity( + value: JsonValue, +): { readonly callId: string; readonly phase: string } | null { + if (!isJsonObject(value)) return null + const callId = value.callId + const admission = value.admission + if ( + typeof callId !== 'string' || + !isJsonObject(admission) || + typeof admission.phase !== 'string' + ) { + return null + } + return { callId, phase: admission.phase } +} + +/** Persist one model-call recovery record before Runtime may continue. */ +export async function recordAnalysisRetainedAdmission( + host: AnalysisApplicationHost, + identity: AnalysisIdentity, + callId: string, + admission: RetainedRunAdmissionRecord, +): Promise { + if (callId.trim().length === 0) { + throw new AnalysisOperationError( + 'ANALYSIS_OPERATION_CONFLICT', + 'A retained analysis admission requires a call identifier', + ) + } + await withAnalysisOperationLock(host, async () => { + const operation = host + .currentState() + .operations.find((candidate) => candidate.id === identity.operationId) + if ( + operation === undefined || + operation.kind !== 'analysis' || + operation.requestDigest !== identity.requestDigest || + operation.status !== 'pending' + ) { + throw new AnalysisOperationError( + 'ANALYSIS_OPERATION_UNAVAILABLE', + `Analysis operation ${String(identity.operationId)} cannot accept retained recovery data`, + ) + } + + const current = operation.result?.[RESULT_KEY] + if (current !== undefined && !Array.isArray(current)) { + throw new AnalysisOperationError( + 'ANALYSIS_OPERATION_CONFLICT', + `Analysis operation ${String(identity.operationId)} has invalid retained recovery data`, + ) + } + const entries: readonly JsonValue[] = current ?? [] + const encodedAdmission = admissionValue(admission) + const duplicate = entries.find((entry) => { + const key = entryIdentity(entry) + return key?.callId === callId && key.phase === admission.phase + }) + if (duplicate !== undefined) { + if ( + !isJsonObject(duplicate) || + canonicalDigest(duplicate.admission) !== canonicalDigest(encodedAdmission) + ) { + throw new AnalysisOperationError( + 'ANALYSIS_OPERATION_CONFLICT', + `Analysis call ${callId} changed its ${admission.phase} admission`, + ) + } + return + } + + const entry: JsonValue = { callId, admission: encodedAdmission } + await updateAnalysisOperation(host, operation, { + status: operation.status, + result: { + ...(operation.result ?? {}), + [RESULT_KEY]: [...entries, entry], + }, + }) + }) +} diff --git a/src/app/analysis-service.ts b/src/app/analysis-service.ts index 2fa8202..513262f 100644 --- a/src/app/analysis-service.ts +++ b/src/app/analysis-service.ts @@ -116,6 +116,8 @@ export class AnalysisService { request, analystIds, executionTarget: prepared.executionTarget, + onRetainedAdmission: (callId, admission) => + this.#lifecycle.recordRetainedAdmission(prepared.identity, callId, admission), })) { if (item.result !== undefined) exactResult = item.result yield { diff --git a/src/app/application-interaction-actions.ts b/src/app/application-interaction-actions.ts index 1f1ca83..78be27a 100644 --- a/src/app/application-interaction-actions.ts +++ b/src/app/application-interaction-actions.ts @@ -13,6 +13,9 @@ import { operationId } from './application-guards.js' import { InteractionAutomationCoordinator } from './interaction-automation-coordinator.js' import { respondInteraction as respondInteractionController } from './interaction-controller.js' import type { RunLedger } from './run-ledger.js' +import { assertAutomationRuleRecord } from '../domain/invariants-runtime.js' +import { DomainInvariantError } from '../domain/invariants-base.js' +import { AppError } from './errors.js' const DEFAULT_INTERACTION_RESPONSE_TIMEOUT_MS = 5_000 @@ -82,6 +85,7 @@ export class ApplicationInteractionActions { respond(input: ApplicationInteractionResponseInput): Promise { return this.#responses.run(`${input.runId}\u0000${input.interactionId}`, () => { + if (input.automationRule !== undefined) assertAutomationRule(input.automationRule) const ports = this.#options.ports() return respondInteractionController({ operationId: operationId(input.operationId, 'respond-interaction'), @@ -112,3 +116,18 @@ export class ApplicationInteractionActions { await this.#responses.whenIdle() } } + +function assertAutomationRule(rule: AutomationRuleRecord): void { + try { + assertAutomationRuleRecord(rule) + } catch (error) { + if (error instanceof DomainInvariantError) { + const secret = /secret|credential|unsupported/iu.test(error.message) + throw new AppError( + secret ? 'AUTOMATION_SECRET_FORBIDDEN' : 'INVALID_AUTOMATION_RULE', + error.message, + ) + } + throw error + } +} diff --git a/src/app/application-support.ts b/src/app/application-support.ts index cdc01cf..5483772 100644 --- a/src/app/application-support.ts +++ b/src/app/application-support.ts @@ -1,11 +1,11 @@ import { canonicalDigest } from '../domain/canonical.js' import type { BraidEvent, BraidEventEnvelope } from '../domain/events.js' import { providerEventKey } from '../domain/events.js' +import type { RunId } from '../domain/ids.js' import type { RunAdmissionReceipt } from '../domain/receipts.js' import { redactBraidEvent } from '../domain/redaction.js' import { reduceEvent } from '../domain/reducer.js' import { usageSnapshotForRun } from '../domain/run-usage.js' -import type { RunId } from '../domain/ids.js' import type { BraidState } from '../domain/state.js' import type { Clock } from '../ports/clock.js' import type { EffectStoragePort } from '../ports/effect-storage.js' @@ -117,6 +117,38 @@ export function restoreApplicationOperations( target: RestoreOperationsTarget, ): void { const acknowledgements = new Map() + for (const operation of target.state().operations) { + if (operation.kind !== 'cancel-run' || operation.target?.kind !== 'run') continue + const run = target.state().runs.find((candidate) => candidate.id === operation.target?.id) + if (run === undefined) continue + const storedOutcome = operation.result?.outcome + const outcome = + storedOutcome === 'accepted' || + storedOutcome === 'already-applied' || + storedOutcome === 'rejected' || + storedOutcome === 'unknown' + ? storedOutcome + : operation.status === 'terminal' + ? 'already-applied' + : operation.status === 'acknowledged' + ? 'accepted' + : operation.status === 'failed' + ? 'rejected' + : 'unknown' + const detail = operation.result?.detail + target.ledger.setControl(operation.id, { + digest: operation.requestDigest, + runId: run.id, + control: 'cancel', + acknowledgement: Promise.resolve({ + operationId: operation.id, + outcome, + ...(typeof detail === 'string' ? { detail } : {}), + }), + completion: Promise.resolve(target.state()), + ...(run.providerSessionId === undefined ? {} : { providerSessionId: run.providerSessionId }), + }) + } for (const envelope of events) { const event = envelope.event if (event.kind === 'run.requested' && event.receipt) { @@ -216,6 +248,7 @@ export function reconcileRestartRun(context: RestartPort): Promise { const run = context.currentState().runs.find((candidate) => candidate.id === runId) if (!run || isProvenTerminalStatus(run.status)) return Promise.resolve() if (!context.execution.status) { + if (canReplayAfterRestart(context, run)) return markRestartReconnecting(context, runId) const unknown = context.commitAndWait({ kind: 'run.finished', runId, @@ -241,13 +274,8 @@ async function reconcileRestartSnapshot(context: RestartPort, runId: string): Pr ...(run.controlRef === undefined ? {} : { controlRef: run.controlRef }), }) } catch { - await commitRequired(context, { - kind: 'run.reconnecting', - runId, - ...(context.findRun(runId).lastCursor === undefined - ? {} - : { after: context.findRun(runId).lastCursor }), - }) + await markRestartReconnecting(context, runId) + if (canReplayAfterRestart(context, context.findRun(runId))) return await commitRequired(context, { kind: 'run.unknown', runId, @@ -261,11 +289,8 @@ async function reconcileRestartSnapshot(context: RestartPort, runId: string): Pr snapshot.runId !== runId || (current.providerSessionId !== undefined && snapshot.sessionId !== current.providerSessionId) ) { - await commitRequired(context, { - kind: 'run.reconnecting', - runId, - ...(current.lastCursor === undefined ? {} : { after: current.lastCursor }), - }) + await markRestartReconnecting(context, runId) + if (canReplayAfterRestart(context, current)) return await commitRequired(context, { kind: 'run.unknown', runId, @@ -273,11 +298,7 @@ async function reconcileRestartSnapshot(context: RestartPort, runId: string): Pr }) return } - await commitRequired(context, { - kind: 'run.reconnecting', - runId, - ...(current.lastCursor === undefined ? {} : { after: current.lastCursor }), - }) + await markRestartReconnecting(context, runId) await reconcileSnapshot(context, runId, snapshot) } @@ -288,6 +309,7 @@ async function reconcileSnapshot( ): Promise { const current = context.currentState().runs.find((candidate) => candidate.id === runId) if (!current) return + if (snapshot.status === 'unknown' && canReplayAfterRestart(context, current)) return const evidence = canonicalDigest({ runId, sessionId: snapshot.sessionId ?? null, @@ -321,6 +343,24 @@ async function reconcileSnapshot( } } +function canReplayAfterRestart(context: RestartPort, run: BraidState['runs'][number]): boolean { + return Boolean( + run.capabilities.streaming.replay && + run.capabilities.events.cursor && + context.execution.reconnect, + ) +} + +async function markRestartReconnecting(context: RestartPort, runId: string): Promise { + const run = context.findRun(runId) + if (run.status === 'reconnecting') return + await commitRequired(context, { + kind: 'run.reconnecting', + runId, + ...(run.lastCursor === undefined ? {} : { after: run.lastCursor }), + }) +} + async function commitRequired(context: JournalWriter, event: BraidEvent): Promise { const result = context.commitAndWait(event) if (result !== undefined) await result diff --git a/src/app/application-types.ts b/src/app/application-types.ts index 59c20fb..7b99972 100644 --- a/src/app/application-types.ts +++ b/src/app/application-types.ts @@ -44,6 +44,7 @@ export interface ControlReceipt { readonly operationId: string readonly runId: string readonly control: BraidControlKind + readonly replayed: boolean readonly acknowledgement: ControlAcknowledgement readonly status: RunStatus readonly completion: Promise diff --git a/src/app/application.ts b/src/app/application.ts index c1553c5..af839e2 100644 --- a/src/app/application.ts +++ b/src/app/application.ts @@ -44,6 +44,7 @@ import { ConversationOperationCoordinator } from './conversation-operation-coord import type { ConversationActions } from './conversations.js' import { createDurableSender } from './durable-send.js' import { SerializedEffectCoordinator } from './effect-coordinator.js' +import { effectiveRunConfiguration } from './effective-run-configuration.js' import { projectEffectRecord } from './effect-projection.js' import { AppError } from './errors.js' import { FailClosedJournal } from './fail-closed-journal.js' @@ -382,11 +383,16 @@ export class BraidApplication { if (Buffer.byteLength(input.text, 'utf8') > MAX_MESSAGE_BYTES) throw new AppError('MESSAGE_TOO_LARGE', 'Message must not exceed 1 MiB') if (!input.text.trim()) throw new AppError('EMPTY_MESSAGE', 'Message must not be empty') + const configuration = effectiveRunConfiguration( + this.#state, + this.runtimeSelection.profile(), + input, + ) const snapshot = snapshotRunExecution( input, this.#state, - this.runtimeSelection.profile(), - this.#state.selectedConnectionId ?? undefined, + configuration.profile, + configuration.connectionId, ) validateNativeProof(this.#portViews.admission, snapshot) if (this.#asynchronousJournal || admissionIsAsync(this.#execution)) { diff --git a/src/app/conversation-branches.ts b/src/app/conversation-branches.ts index 26ae4ab..0149554 100644 --- a/src/app/conversation-branches.ts +++ b/src/app/conversation-branches.ts @@ -35,6 +35,7 @@ import type { CreateBranchInput, ForkPlan, ForkPlanInput, + SetRunOverridesInput, } from './conversation-types.js' import { AppError } from './errors.js' @@ -51,6 +52,65 @@ export class ConversationBranches { this.#host = host } + async setRunOverrides(input: SetRunOverridesInput): Promise { + return coordinateConversationOperation(this.#host, 'set_run_override', input, () => + this.#setRunOverrides(input), + ) + } + + async #setRunOverrides(input: SetRunOverridesInput): Promise { + const state = this.#host.state() + requireIdle(state, 'Changing run configuration') + const source = resolveBranch(state, input) + const operationId = parseOperation(input.operationId, 'set_run_override') + const hasOverride = [input.runner, input.model, input.effort, input.mode].some( + (value) => value !== undefined, + ) + if (input.clear === true && hasOverride) + throw new AppError( + 'INVALID_RUN_OVERRIDE', + 'Clear cannot be combined with a run override value', + ) + if (input.clear !== true && !hasOverride) + throw new AppError('INVALID_RUN_OVERRIDE', 'Set at least one run override or clear all') + const selected = runOverrides({ + ...(input.runner === undefined ? {} : { runner: input.runner }), + ...(input.model === undefined ? {} : { model: input.model }), + ...(input.effort === undefined ? {} : { effort: input.effort }), + ...(input.mode === undefined ? {} : { mode: input.mode }), + }) + const normalized = { + conversationId: source.conversation.id, + branchId: source.branch.id, + clear: input.clear === true, + runner: selected.runner ?? null, + model: selected.model ?? null, + effort: selected.effort ?? null, + mode: selected.mode ?? null, + } + const digest = requestDigest('set_run_override', normalized) + const replay = operationReplay(state, operationId, 'run-override', digest) + if (replay) return branchForOperation(state, replay) + const at = this.#host.now() + const branch: BranchRecord = { + ...source.branch, + overrides: input.clear === true ? {} : { ...source.branch.overrides, ...selected }, + updatedAt: at, + } + await this.#host.commit({ + kind: 'branch.updated', + branch, + operation: acknowledgedOperation({ + id: operationId, + kind: 'run-override', + digest, + at, + target: { kind: 'branch', id: branch.id }, + }), + }) + return branch + } + async create(input: CreateBranchInput): Promise { return coordinateConversationOperation(this.#host, 'branch', input, () => this.#create(input)) } @@ -89,6 +149,7 @@ export class ConversationBranches { ...(input.runner === undefined ? {} : { runner: input.runner }), ...(input.model === undefined ? {} : { model: input.model }), ...(input.effort === undefined ? {} : { effort: input.effort }), + ...(input.mode === undefined ? {} : { mode: input.mode }), }), ...(source.branch.environmentId === undefined ? {} @@ -272,6 +333,26 @@ function resolveSource( state: BraidState, input: Pick, ): ResolvedSource { + const { conversation, branch } = resolveBranch(state, input) + const visible = messagesVisibleOnBranch(state, branch.id) + const boundaryId = input.throughMessageId ?? branch.tipMessageId ?? visible.at(-1)?.id + const through = + boundaryId === undefined + ? undefined + : visible.find((message) => message.id === parseMessageId(boundaryId)) + if (boundaryId !== undefined && through === undefined) { + throw new AppError( + 'UNKNOWN_MESSAGE_BOUNDARY', + `Message ${boundaryId} is not visible on ${branch.id}`, + ) + } + return { conversation, branch, ...(through === undefined ? {} : { through }) } +} + +function resolveBranch( + state: BraidState, + input: Pick, +): Pick { const conversationId = parseConversationId(input.conversationId ?? state.conversationId) const conversation = state.conversations.find( (candidate) => candidate.id === conversationId && candidate.deletedAt === undefined, @@ -284,19 +365,7 @@ function resolveSource( ) if (!branch) throw new AppError('UNKNOWN_BRANCH', `Branch ${branchId} does not belong to ${conversationId}`) - const visible = messagesVisibleOnBranch(state, branchId) - const boundaryId = input.throughMessageId ?? branch.tipMessageId ?? visible.at(-1)?.id - const through = - boundaryId === undefined - ? undefined - : visible.find((message) => message.id === parseMessageId(boundaryId)) - if (boundaryId !== undefined && through === undefined) { - throw new AppError( - 'UNKNOWN_MESSAGE_BOUNDARY', - `Message ${boundaryId} is not visible on ${branchId}`, - ) - } - return { conversation, branch, ...(through === undefined ? {} : { through }) } + return { conversation, branch } } function branchRequest(source: ResolvedSource, input: CreateBranchInput) { @@ -308,6 +377,7 @@ function branchRequest(source: ResolvedSource, input: CreateBranchInput) { runner: input.runner ?? source.branch.overrides.runner ?? null, model: input.model ?? source.branch.overrides.model ?? null, effort: input.effort ?? source.branch.overrides.effort ?? null, + mode: input.mode ?? source.branch.overrides.mode ?? null, } } diff --git a/src/app/conversation-lifecycle.ts b/src/app/conversation-lifecycle.ts index fea59bc..91bb73d 100644 --- a/src/app/conversation-lifecycle.ts +++ b/src/app/conversation-lifecycle.ts @@ -396,16 +396,21 @@ function conversationForOperation( } function deleteBlockers(state: BraidState, conversationId: ConversationId): void { + for (const run of state.runs.filter((candidate) => candidate.conversationId === conversationId)) { + if (run.interactionsTruncated && run.pendingInteractionIds === undefined) { + throw new AppError( + 'DELETE_BLOCKED', + `Interaction history for run ${run.id} is truncated; pending state is not provable`, + ) + } + const pendingId = + run.pendingInteractionIds?.[0] ?? + run.interactions.find((interaction) => interaction.status === 'pending')?.request.id + if (pendingId !== undefined) + throw new AppError('DELETE_BLOCKED', `Interaction ${pendingId} is still pending`) + } const active = state.runs.find((run) => run.conversationId === conversationId && !run.complete) if (active) throw new AppError('DELETE_BLOCKED', `Run ${active.id} is not terminal`) - const pending = state.interactions.find( - (interaction) => - interaction.status === 'pending' && - state.runs.some( - (run) => run.id === interaction.runId && run.conversationId === conversationId, - ), - ) - if (pending) throw new AppError('DELETE_BLOCKED', `Interaction ${pending.id} is still pending`) const descendant = state.branches.find( (branch) => branch.conversationId !== conversationId && branch.source?.conversationId === conversationId, diff --git a/src/app/conversation-records.ts b/src/app/conversation-records.ts index c403fdb..cf892b9 100644 --- a/src/app/conversation-records.ts +++ b/src/app/conversation-records.ts @@ -1,4 +1,8 @@ import type { ReasoningEffort } from '@tangle-network/agent-interface' +import { + harnessTypeSchema, + reasoningEffortSchema, +} from '../adapters/agent-interface/harness-runtime.js' import type { BranchBoundary, BranchRecord, @@ -22,38 +26,61 @@ import type { WorkspaceId, } from '../domain/ids.js' import { redactSensitiveText } from '../domain/redaction.js' +import { containsUnsafeControlCharacter } from '../domain/text.js' import type { BuiltConversation } from './conversation-types.js' import { AppError } from './errors.js' -const EFFORTS = new Set([ - 'none', - 'minimal', - 'low', - 'medium', - 'high', - 'xhigh', - 'ultracode', -]) - export function runOverrides(input: { readonly runner?: string readonly model?: string readonly effort?: string + readonly mode?: string readonly inherited?: RunOverrides }): RunOverrides { + const runner = parseRunner(input.runner) let effort: ReasoningEffort | undefined if (input.effort !== undefined) { - if (!EFFORTS.has(input.effort as ReasoningEffort)) { - throw new AppError('INVALID_EFFORT', `Unknown reasoning effort ${input.effort}`) - } - effort = input.effort as ReasoningEffort + const parsed = reasoningEffortSchema.safeParse(input.effort.trim()) + if (!parsed.success) throw new AppError('INVALID_EFFORT', 'Reasoning effort is not supported') + effort = parsed.data } + const model = parsePublicOverride(input.model, 'model', 512) + const mode = parsePublicOverride(input.mode, 'mode', 256) return { ...(input.inherited ?? {}), - ...(input.runner === undefined ? {} : { runner: redactSensitiveText(input.runner, 256) }), - ...(input.model === undefined ? {} : { model: redactSensitiveText(input.model, 512) }), + ...(runner === undefined ? {} : { runner }), + ...(model === undefined ? {} : { model }), ...(effort === undefined ? {} : { effort }), + ...(mode === undefined ? {} : { mode }), + } +} + +function parseRunner(value: string | undefined): RunOverrides['runner'] { + if (value === undefined) return undefined + const parsed = harnessTypeSchema.safeParse(value.trim()) + if (!parsed.success) throw new AppError('INVALID_RUNNER', 'Runner is not supported') + return parsed.data +} + +function parsePublicOverride( + value: string | undefined, + label: 'model' | 'mode', + maxBytes: number, +): string | undefined { + if (value === undefined) return undefined + const normalized = value.trim() + if ( + normalized.length === 0 || + Buffer.byteLength(normalized, 'utf8') > maxBytes || + containsUnsafeControlCharacter(normalized) || + redactSensitiveText(normalized) !== normalized + ) { + throw new AppError( + label === 'model' ? 'INVALID_MODEL' : 'INVALID_MODE', + `${label === 'model' ? 'Model' : 'Mode'} must be a public identifier`, + ) } + return normalized } export function draftRecord(id: DraftId, branchId: BranchId, at: string, text = ''): DraftRecord { diff --git a/src/app/conversation-types.ts b/src/app/conversation-types.ts index 49691d1..9f222ea 100644 --- a/src/app/conversation-types.ts +++ b/src/app/conversation-types.ts @@ -51,6 +51,18 @@ export interface CreateBranchInput { readonly runner?: string readonly model?: string readonly effort?: string + readonly mode?: string +} + +export interface SetRunOverridesInput { + readonly operationId: string + readonly conversationId?: string + readonly branchId?: string + readonly runner?: string + readonly model?: string + readonly effort?: string + readonly mode?: string + readonly clear?: boolean } export interface CloneConversationInput { diff --git a/src/app/conversations.ts b/src/app/conversations.ts index 8bb02c4..679791e 100644 --- a/src/app/conversations.ts +++ b/src/app/conversations.ts @@ -52,5 +52,6 @@ export type { ForkPlanInput, OpenConversationInput, PlanContextInput, + SetRunOverridesInput, UpdateConversationInput, } from './conversation-types.js' diff --git a/src/app/effective-run-configuration.ts b/src/app/effective-run-configuration.ts new file mode 100644 index 0000000..b8906a0 --- /dev/null +++ b/src/app/effective-run-configuration.ts @@ -0,0 +1,72 @@ +import type { AgentProfile } from '@tangle-network/agent-interface' +import { parseBranchId, parseConversationId } from '../domain/ids.js' +import type { BraidState } from '../domain/state.js' +import type { SendInput } from './application-types.js' +import { AppError } from './errors.js' +import { resolveEffectiveProfile } from './profile-selection.js' + +export interface EffectiveRunConfiguration { + readonly profile: Readonly + readonly connectionId?: string + readonly mode?: string +} + +/** Project the selected configuration before a workspace creates its first conversation. */ +export function selectedRunConfiguration( + state: BraidState, + authoredProfile: Readonly, +): EffectiveRunConfiguration { + const conversation = state.conversations.find( + (candidate) => candidate.id === state.conversationId && candidate.deletedAt === undefined, + ) + const branch = state.branches.find( + (candidate) => candidate.id === state.branchId && candidate.conversationId === conversation?.id, + ) + if (conversation !== undefined && branch !== undefined) { + return effectiveRunConfiguration(state, authoredProfile, {}) + } + return Object.freeze({ + profile: authoredProfile, + ...(state.selectedConnectionId === null + ? {} + : { connectionId: String(state.selectedConnectionId) }), + }) +} + +/** Resolve one branch's durable choices into the immutable profile sent to agent-runtime. */ +export function effectiveRunConfiguration( + state: BraidState, + authoredProfile: Readonly, + input: Pick, +): EffectiveRunConfiguration { + const conversationId = parseConversationId(input.conversationId ?? state.conversationId) + const conversation = state.conversations.find( + (candidate) => candidate.id === conversationId && candidate.deletedAt === undefined, + ) + if (conversation === undefined) + throw new AppError('UNKNOWN_CONVERSATION', 'The requested conversation is unavailable') + const branchId = parseBranchId(input.branchId ?? state.branchId) + const branch = state.branches.find( + (candidate) => candidate.id === branchId && candidate.conversationId === conversation.id, + ) + if (branch === undefined) + throw new AppError('UNKNOWN_BRANCH', 'The requested branch is unavailable') + const resolved = resolveEffectiveProfile({ + profile: { profile: authoredProfile }, + branchOverrides: { + ...(branch.overrides.runner === undefined ? {} : { harness: branch.overrides.runner }), + ...(branch.overrides.model === undefined ? {} : { model: branch.overrides.model }), + ...(branch.overrides.effort === undefined ? {} : { effort: branch.overrides.effort }), + ...(branch.overrides.mode === undefined ? {} : { mode: branch.overrides.mode }), + ...(branch.connectionId === undefined ? {} : { connectionId: branch.connectionId }), + }, + ...(state.selectedConnectionId === null + ? {} + : { userConnectionId: state.selectedConnectionId }), + }) + return Object.freeze({ + profile: resolved.effectiveProfile, + ...(resolved.connectionId === undefined ? {} : { connectionId: resolved.connectionId }), + ...(resolved.mode === undefined ? {} : { mode: resolved.mode }), + }) +} diff --git a/src/app/fail-closed-journal.ts b/src/app/fail-closed-journal.ts index 81a5069..845b240 100644 --- a/src/app/fail-closed-journal.ts +++ b/src/app/fail-closed-journal.ts @@ -1,4 +1,6 @@ import type { BraidEvent, BraidEventEnvelope } from '../domain/events.js' +import { providerMetaForEvent } from '../domain/events.js' +import { parseReplayCursor } from '../domain/ids.js' import type { BraidState } from '../domain/state.js' import type { Clock } from '../ports/clock.js' import type { EffectRecord, EffectStoragePort, JournalPort } from '../ports/effect-storage.js' @@ -12,10 +14,12 @@ export class FailClosedJournal implements JournalPort, EffectStoragePort { } envelope(state: BraidState, event: BraidEvent): BraidEventEnvelope { + const provider = providerMetaForEvent(event) return { sequence: state.sequence + 1, revision: state.revision + 1, occurredAt: this.#clock.now(), + ...(provider?.cursor === undefined ? {} : { cursor: parseReplayCursor(provider.cursor) }), event, } } diff --git a/src/app/interaction-controller.ts b/src/app/interaction-controller.ts index 655ca79..169bd80 100644 --- a/src/app/interaction-controller.ts +++ b/src/app/interaction-controller.ts @@ -1,11 +1,16 @@ import type { InteractionResponse } from '@tangle-network/agent-interface' -import type { BraidEventEnvelope } from '../domain/events.js' import { canonicalDigest } from '../domain/canonical.js' import type { FeedbackDecisionRecord } from '../domain/entities-graph.js' import type { AutomationRuleRecord } from '../domain/entities-runtime.js' +import type { BraidEventEnvelope } from '../domain/events.js' import { createFeedbackDecisionId } from '../domain/ids.js' -import { effectRequestDigest } from './effect-coordinator.js' +import { interactionRemainingMs } from '../domain/interaction-timeout.js' +import type { ExecutionPort } from '../ports/execution.js' +import type { JournalWriter, StateReader } from './application-ports.js' +import type { InteractionReceipt } from './application-types.js' import type { SerializedEffectCoordinator } from './effect-coordinator.js' +import { effectRequestDigest } from './effect-coordinator.js' +import { AppError } from './errors.js' import { executeInteractionEffect, type InteractionEffectRequest } from './interaction-effects.js' import { checkInteractionResponse, @@ -18,12 +23,8 @@ import { recordedInteractionOperation, recordedInteractionOwner, } from './interaction-response-replay.js' -import type { InteractionReceipt } from './application-types.js' -import type { JournalWriter, StateReader } from './application-ports.js' import type { RunLedger } from './run-ledger.js' import { findRun } from './run-status.js' -import type { ExecutionPort } from '../ports/execution.js' -import { AppError } from './errors.js' export interface InteractionControllerInput { readonly operationId: string @@ -231,11 +232,8 @@ export function isInteractionExpired( ): boolean { const timeoutMs = interaction.request.timeoutMs if (timeoutMs === undefined) return false - const startedAt = interaction.source.occurredAt ?? run.startedAt const now = input.now?.() ?? new Date().toISOString() - const start = Date.parse(startedAt) - const current = Date.parse(now) - return Number.isFinite(start) && Number.isFinite(current) && current >= start + timeoutMs + return interactionRemainingMs(timeoutMs, interaction.source.occurredAt, run.startedAt, now) === 0 } function currentInteractionExpired( diff --git a/src/app/journal.ts b/src/app/journal.ts index e70097b..4e33e9b 100644 --- a/src/app/journal.ts +++ b/src/app/journal.ts @@ -1,7 +1,8 @@ import type { BraidEvent, BraidEventEnvelope } from '../domain/events.js' import { eventRunId } from '../domain/events.js' import type { RunId } from '../domain/ids.js' -import { providerEventKey } from '../domain/events.js' +import { providerEventKey, providerMetaForEvent } from '../domain/events.js' +import { parseReplayCursor } from '../domain/ids.js' import type { BraidState } from '../domain/state.js' import type { Clock } from '../ports/clock.js' import type { EffectRecord, EffectStoragePort, JournalPort } from '../ports/effect-storage.js' @@ -23,10 +24,12 @@ export class MemoryJournal implements JournalPort, EffectStoragePort { } envelope(state: BraidState, event: BraidEvent): BraidEventEnvelope { + const provider = providerMetaForEvent(event) return { sequence: state.sequence + 1, revision: state.revision + 1, occurredAt: this.#clock.now(), + ...(provider?.cursor === undefined ? {} : { cursor: parseReplayCursor(provider.cursor) }), event, } } diff --git a/src/app/profile-types.ts b/src/app/profile-types.ts index 6eba1db..cf15b54 100644 --- a/src/app/profile-types.ts +++ b/src/app/profile-types.ts @@ -164,7 +164,7 @@ export interface ProfileRunOverrides { } export interface EffectiveProfileInput { - readonly profile: ProfileRecord + readonly profile: Pick readonly branchOverrides?: ProfileRunOverrides readonly nextRunOverrides?: ProfileRunOverrides readonly workspaceTrusted?: boolean diff --git a/src/app/provider-values.ts b/src/app/provider-values.ts index 7c03e04..06879ce 100644 --- a/src/app/provider-values.ts +++ b/src/app/provider-values.ts @@ -1,11 +1,4 @@ -import type { RuntimeStreamEvent } from '@tangle-network/agent-runtime' -import type { TurnUsage } from '../domain/events.js' -import { - finiteNonNegativeNumber, - optionalFiniteNonNegativeNumber, - safeProviderDiagnostic, - safePublicIdentifier, -} from '../domain/provider-values.js' +import { safeProviderDiagnostic } from '../domain/provider-values.js' export { finiteNonNegativeNumber, @@ -51,19 +44,3 @@ export function safeRuntimeDiagnostic(value: unknown, fallback: string): string } return safeProviderDiagnostic(value, fallback) } - -export function usageFromFinal(event: Extract): TurnUsage { - const metadata = event.metadata ?? {} - const tokenUsage = - metadata.tokenUsage && typeof metadata.tokenUsage === 'object' - ? (metadata.tokenUsage as Record) - : {} - const costUsd = optionalFiniteNonNegativeNumber(metadata.costUsd) - const model = safePublicIdentifier(metadata.model) - return { - input: finiteNonNegativeNumber(tokenUsage.input), - output: finiteNonNegativeNumber(tokenUsage.output), - ...(costUsd === undefined ? {} : { costUsd }), - ...(model === undefined ? {} : { model }), - } -} diff --git a/src/app/run-controls.ts b/src/app/run-controls.ts index c86a839..dae8097 100644 --- a/src/app/run-controls.ts +++ b/src/app/run-controls.ts @@ -146,6 +146,7 @@ async function control( operationId: request.operationId, runId: request.runId, control: controlKind, + replayed: true, acknowledgement, status: context.findRun(request.runId).status, completion: previous.completion, @@ -169,6 +170,7 @@ async function control( operationId: request.operationId, runId: request.runId, control: controlKind, + replayed: true, acknowledgement: reconciled, status: context.findRun(request.runId).status, completion: Promise.resolve(context.currentState()), @@ -178,6 +180,7 @@ async function control( operationId: request.operationId, runId: request.runId, control: controlKind, + replayed: true, acknowledgement, status: context.findRun(request.runId).status, completion: previous.completion, @@ -272,6 +275,7 @@ async function control( operationId: request.operationId, runId: request.runId, control: controlKind, + replayed: false, acknowledgement: ack, status: context.findRun(request.runId).status, completion, @@ -342,6 +346,7 @@ async function control( operationId: request.operationId, runId: request.runId, control: controlKind, + replayed: false, acknowledgement: ack, status: context.findRun(request.runId).status, completion, @@ -514,6 +519,7 @@ async function existingControlReceipt( operationId, runId: record.runId, control, + replayed: true, acknowledgement, status: context.findRun(record.runId).status, completion: record.completion, diff --git a/src/app/run-event-mapper.ts b/src/app/run-event-mapper.ts index 5ee9cb5..caba72f 100644 --- a/src/app/run-event-mapper.ts +++ b/src/app/run-event-mapper.ts @@ -313,6 +313,8 @@ function statusFromCanonical( return 'running' case 'failed': return 'failed' + case 'cancelled': + return 'cancelled' default: { const exhaustive: never = status return exhaustive diff --git a/src/app/run-execution.ts b/src/app/run-execution.ts index bfec1a4..59b7b16 100644 --- a/src/app/run-execution.ts +++ b/src/app/run-execution.ts @@ -29,6 +29,14 @@ export async function executeRun( ...(currentRun?.lastCursor === undefined ? {} : { after: currentRun.lastCursor }), ...(currentRun === undefined ? {} : { afterSequence: currentRun.lastProviderSequence }), ...(input.contextPlan === undefined ? {} : { contextBoundary: input.contextPlan.digest }), + onRetainedAdmission: async (retainedAdmission) => { + const committed = context.commitAndWait({ + kind: 'run.retained.admitted', + runId: admission.runId, + admission: retainedAdmission, + }) + if (committed !== undefined) await committed + }, } if (context.ledger.isDetached(admission.runId)) return for await (const runtimeEvent of context.execution.streamTurn(runtimeInput)) { diff --git a/src/app/storage-journal-support.ts b/src/app/storage-journal-support.ts index a01e393..483e7c0 100644 --- a/src/app/storage-journal-support.ts +++ b/src/app/storage-journal-support.ts @@ -133,6 +133,7 @@ export function runOperationId(event: BraidEvent): OperationId | undefined { case 'conversation.updated': case 'conversation.selected': case 'branch.created': + case 'branch.updated': case 'branch.selected': return event.operation?.id case 'conversation.deleted': diff --git a/src/app/storage-journal.ts b/src/app/storage-journal.ts index 26cd6e5..6a1d5d4 100644 --- a/src/app/storage-journal.ts +++ b/src/app/storage-journal.ts @@ -1,6 +1,17 @@ import { canonicalDigest } from '../domain/canonical.js' -import { type BraidEvent, type BraidEventEnvelope, eventRunId } from '../domain/events.js' -import { type ConversationId, createEventId, type RunId, type WorkspaceId } from '../domain/ids.js' +import { + type BraidEvent, + type BraidEventEnvelope, + eventRunId, + providerMetaForEvent, +} from '../domain/events.js' +import { + type ConversationId, + createEventId, + parseReplayCursor, + type RunId, + type WorkspaceId, +} from '../domain/ids.js' import type { BraidState } from '../domain/state.js' import type { Clock } from '../ports/clock.js' import type { EffectRecord, EffectStoragePort, JournalPort } from '../ports/effect-storage.js' @@ -126,11 +137,13 @@ export class StorageJournal implements JournalPort, EffectStoragePort { const eventId = createEventId( `event-${workspaceId.slice('workspace-'.length)}-${state.sequence + 1}-${canonicalDigest(event).slice(0, 16)}`, ) + const provider = providerMetaForEvent(event) const envelope: BraidEventEnvelope = { eventId, sequence: state.sequence + 1, revision: state.revision + 1, occurredAt: this.#clock.now(), + ...(provider?.cursor === undefined ? {} : { cursor: parseReplayCursor(provider.cursor) }), event, } this.#contexts.set(eventId, { @@ -173,6 +186,7 @@ export class StorageJournal implements JournalPort, EffectStoragePort { const runId = eventRunId(envelope.event) ?? syntheticRunId(context.workspaceId, context.conversationId) const operationId = runOperationId(envelope.event) + const provider = providerMetaForEvent(envelope.event) const sequence = (this.#runSequences.get(runId) ?? 0) + 1 const payload: PersistedEnvelope = { __braidEvent: envelope.event, @@ -193,6 +207,9 @@ export class StorageJournal implements JournalPort, EffectStoragePort { kind: envelope.event.kind, payload: toJson(payload), occurredAt: envelope.occurredAt, + ...(envelope.cursor === undefined ? {} : { cursor: envelope.cursor }), + ...(provider === undefined ? {} : { providerEventId: provider.eventId }), + ...(provider?.receivedAt === undefined ? {} : { receivedAt: provider.receivedAt }), ...(operationId === undefined ? {} : { operationId }), terminal: envelope.event.kind === 'run.finished', } diff --git a/src/app/view-model.ts b/src/app/view-model.ts deleted file mode 100644 index be6f3da..0000000 --- a/src/app/view-model.ts +++ /dev/null @@ -1,167 +0,0 @@ -import type { RuntimeEventSummary } from '../domain/runtime-events.js' -import type { - BraidMessagePart, - BraidState, - MessagePartKind, - MessageRole, - MessageStatus, -} from '../domain/state.js' -import { boundVisibleText, sanitizeTerminalText } from '../views/shared/sanitize.js' - -const MAX_VISIBLE_MESSAGES = 200 -const MAX_VISIBLE_PARTS = 512 -const MAX_VISIBLE_PART_CHARS = 32_000 - -export interface MessageView { - readonly id: string - readonly role: MessageRole - readonly text: string - readonly status: MessageStatus - readonly parts: readonly MessagePartView[] -} - -export interface MessagePartView { - readonly id: string - readonly kind: MessagePartKind - readonly text: string - readonly status?: string - readonly toolName?: string - readonly title?: string -} - -export interface AppView { - readonly revision: number - readonly profileName: string - readonly runner: string - readonly model: string - readonly connection: string - readonly status: - | 'ready' - | 'running' - | 'failed' - | 'blocked' - | 'aborted' - | 'cancelled' - | 'unknown' - | 'expired' - | 'detached' - | 'reconnecting' - | 'cancelling' - readonly statusText: string - readonly messages: readonly MessageView[] - readonly hiddenMessageCount: number - readonly activities: readonly { - readonly id: string - readonly type: string - readonly label: string - readonly detail?: string - }[] - readonly eventDetails: readonly RuntimeEventSummary[] -} - -function visibleTail(text: string): string { - return boundVisibleText(text) -} - -function detailText(value: unknown): string { - if (typeof value === 'string') return value - try { - return JSON.stringify(value) ?? '' - } catch { - return '[unavailable]' - } -} - -function partView(part: BraidMessagePart, visibleText?: string): MessagePartView { - const detail = part.text ?? part.error ?? (part.result === undefined ? part.input : part.result) - return { - id: part.id, - kind: part.kind, - text: visibleText ?? visibleTail(detailText(detail).slice(0, MAX_VISIBLE_PART_CHARS)), - ...(part.status === undefined ? {} : { status: sanitizeTerminalText(part.status) }), - ...(part.toolName === undefined ? {} : { toolName: sanitizeTerminalText(part.toolName) }), - ...(part.title === undefined ? {} : { title: sanitizeTerminalText(part.title) }), - } -} - -export function buildAppView(state: BraidState): AppView { - const hiddenMessageCount = Math.max(0, state.messages.length - MAX_VISIBLE_MESSAGES) - const messages = state.messages.slice(-MAX_VISIBLE_MESSAGES).map((message) => { - const text = visibleTail(message.text) - return { - id: message.id, - role: message.role, - text, - status: message.status, - parts: Object.freeze( - message.parts - .slice(-MAX_VISIBLE_PARTS) - .map((part) => - partView(part, part.kind === 'text' && part.text === message.text ? text : undefined), - ), - ), - } - }) - const fixture = state.profile.model?.default === 'fixture/deterministic' - const latestRun = state.runs.at(-1) - const activities = - latestRun?.activity.slice(-MAX_VISIBLE_PARTS).map((item) => ({ - id: item.id, - type: item.type, - label: sanitizeTerminalText(item.label), - ...(item.detail === undefined ? {} : { detail: sanitizeTerminalText(item.detail) }), - })) ?? [] - const status = state.activeRunId - ? latestRun?.status === 'reconnecting' - ? 'reconnecting' - : latestRun?.status === 'cancelling' - ? 'cancelling' - : 'running' - : latestRun?.status === 'failed' - ? 'failed' - : latestRun?.status === 'blocked' - ? 'blocked' - : latestRun?.status === 'aborted' - ? 'aborted' - : latestRun?.status === 'cancelled' - ? 'cancelled' - : latestRun?.status === 'unknown' - ? 'unknown' - : latestRun?.status === 'expired' - ? 'expired' - : latestRun?.status === 'detached' - ? 'detached' - : 'ready' - const statusText = - status === 'running' || status === 'reconnecting' - ? 'working' - : status === 'cancelling' - ? 'cancelling' - : status === 'failed' - ? (latestRun?.error ?? state.lastError ?? 'failed') - : status === 'blocked' - ? 'blocked' - : status === 'aborted' || status === 'cancelled' - ? 'cancelled' - : status === 'unknown' - ? 'unknown' - : status === 'expired' - ? 'expired' - : status === 'detached' - ? 'background' - : 'ready' - - return Object.freeze({ - revision: state.revision, - profileName: state.profile.name ?? 'Unnamed profile', - runner: state.profile.harness ?? 'automatic', - model: state.profile.model?.default ?? 'automatic', - connection: fixture ? 'deterministic fixture' : 'not connected', - status, - statusText: sanitizeTerminalText(statusText), - messages: Object.freeze(messages), - hiddenMessageCount, - activities: Object.freeze(activities), - eventDetails: Object.freeze(latestRun?.eventDetails.slice(-MAX_VISIBLE_PARTS) ?? []), - }) -} diff --git a/src/domain/entities-core.ts b/src/domain/entities-core.ts index 408d6fa..bce4b40 100644 --- a/src/domain/entities-core.ts +++ b/src/domain/entities-core.ts @@ -1,6 +1,7 @@ import type { AgentProfile, AgentProfileValidationIssue, + HarnessType, ReasoningEffort, } from '@tangle-network/agent-interface' import type { @@ -191,7 +192,7 @@ export interface BranchBoundary { } export interface RunOverrides { - readonly runner?: string + readonly runner?: HarnessType readonly model?: string readonly effort?: ReasoningEffort readonly mode?: string diff --git a/src/domain/entities-graph.ts b/src/domain/entities-graph.ts index 3794ed0..dad9d6a 100644 --- a/src/domain/entities-graph.ts +++ b/src/domain/entities-graph.ts @@ -87,6 +87,7 @@ export type OperationKind = | 'conversation-archive' | 'conversation-delete' | 'branch-create' + | 'run-override' | 'conversation-clone' | 'conversation-fork' | 'context-plan' diff --git a/src/domain/entities-interactions.ts b/src/domain/entities-interactions.ts index 972c085..4aca930 100644 --- a/src/domain/entities-interactions.ts +++ b/src/domain/entities-interactions.ts @@ -1,10 +1,4 @@ -import type { InteractionRequest } from '@tangle-network/agent-interface' -import type { - IsoDateTime, - JsonValue, - MissingHistoryRange, - NonSecretInteractionData, -} from './entities-base.js' +import type { IsoDateTime, JsonValue, MissingHistoryRange } from './entities-base.js' export type { NonSecretInteractionData, @@ -28,64 +22,11 @@ import type { MessagePartId, OperationId, ProfileId, - ProviderSessionId, RuleId, RunId, TraceId, } from './ids.js' -export type BraidInteractionSubject = - | { readonly type: 'tool'; readonly toolName: string } - | { readonly type: 'command'; readonly command: string } - | { readonly type: 'file'; readonly path: string; readonly preview?: string } - | { readonly type: 'resource'; readonly uri: string } - -/** - * The durable interaction request keeps the canonical answer specification and - * public subject summary, but deliberately drops provider-owned subject input - * and default answer values that could contain secrets. - */ -export interface BraidInteractionRequest { - readonly id: InteractionId - readonly kind: string - readonly title: string - readonly body?: string - readonly subject?: BraidInteractionSubject - readonly answerSpec: InteractionRequest['answerSpec'] - readonly timeoutMs?: number - readonly onTimeout?: 'default' | 'fail' | 'wait' -} - -export type InteractionStatus = - | 'pending' - | 'responding' - | 'resolved' - | 'declined' - | 'cancelled' - | 'expired' - | 'unknown' - | 'conflict' - -export interface InteractionResolutionRecord { - readonly outcome: 'accepted' | 'declined' | 'cancelled' - readonly operationId: OperationId - readonly publicData?: NonSecretInteractionData - readonly dataDigest?: Digest - readonly containsSecret: boolean - readonly resolvedAt: IsoDateTime -} - -export interface InteractionRecord { - readonly id: InteractionId - readonly runId: RunId - readonly providerSessionId?: ProviderSessionId - readonly request: BraidInteractionRequest - readonly status: InteractionStatus - readonly resolution?: InteractionResolutionRecord - readonly createdAt: IsoDateTime - readonly updatedAt: IsoDateTime -} - export type AutomationRuleScope = 'once' | 'session' | 'persistent' export type AutomationAuditOutcome = diff --git a/src/domain/entities-messages.ts b/src/domain/entities-messages.ts index a6541b0..959c66b 100644 --- a/src/domain/entities-messages.ts +++ b/src/domain/entities-messages.ts @@ -159,6 +159,8 @@ export interface RunRecord extends RuntimeRunFields { readonly profileSnapshotId?: ProfileSnapshotId readonly connectionId?: ConnectionId readonly providerSessionId?: ProviderSessionId + /** Session identifier reported by the harness inside a retained provider run. */ + readonly harnessSessionId?: string readonly environmentId?: EnvironmentId readonly bindingId?: BindingId readonly receiptId?: ReceiptId diff --git a/src/domain/events.ts b/src/domain/events.ts index 29e4983..12f7ab3 100644 --- a/src/domain/events.ts +++ b/src/domain/events.ts @@ -15,7 +15,6 @@ import type { FeedbackDecisionRecord, GraphEdgeRecord, GraphNodeRecord, - InteractionRecord, MessagePartRecord, MessageRecord, MissingHistoryRange, @@ -49,7 +48,6 @@ import type { FeedbackDecisionId, GraphEdgeId, GraphNodeId, - InteractionId, MessageId, MessagePartId, OperationId, @@ -62,6 +60,7 @@ import type { WorkerId, } from './ids.js' import type { RunStatus } from './state.js' +import type { RetainedRunAdmissionRecord } from './run-contracts.js' export type { TurnUsage } from './entities.js' export type { @@ -71,15 +70,6 @@ export type { RunTerminalStatus, } from './events-legacy.js' -export interface InteractionResponseRequested { - readonly interactionId: InteractionId - readonly operationId: OperationId - readonly outcome: 'accepted' | 'declined' | 'cancelled' - readonly publicData?: Readonly> - readonly dataDigest?: Digest - readonly containsSecret: boolean -} - interface RunReconciledPayload { readonly runId: RunId readonly status: RunStatus @@ -165,7 +155,10 @@ export interface DomainBraidEventMap { readonly graphEdges?: readonly GraphEdgeRecord[] readonly operation?: OperationRecord } - readonly 'branch.updated': { readonly branch: BranchRecord } + readonly 'branch.updated': { + readonly branch: BranchRecord + readonly operation: OperationRecord + } readonly 'branch.selected': { readonly conversationId: ConversationId readonly branchId: BranchId @@ -176,6 +169,10 @@ export interface DomainBraidEventMap { readonly 'message.created': { readonly message: MessageRecord } readonly 'message.part.updated': { readonly part: MessagePartRecord } readonly 'run.bound': { readonly runId: RunId; readonly bindingId: BindingId } + readonly 'run.retained.admitted': { + readonly runId: RunId + readonly admission: RetainedRunAdmissionRecord + } readonly 'run.status.changed': { readonly runId: RunId readonly status: RunStatus @@ -195,17 +192,6 @@ export interface DomainBraidEventMap { readonly provider: ProviderEventMeta } readonly 'history.missing': { readonly range: MissingHistoryRange } - readonly 'interaction.requested': { readonly interaction: InteractionRecord } - readonly 'interaction.response.requested': { readonly response: InteractionResponseRequested } - readonly 'interaction.resolved': { - readonly interactionId: InteractionId - readonly resolution: InteractionRecord['resolution'] - } - readonly 'interaction.cancelled': { - readonly interactionId: InteractionId - readonly operationId: OperationId - } - readonly 'interaction.expired': { readonly interactionId: InteractionId } readonly 'analysis.created': { readonly analysis: AnalysisRecord } readonly 'analysis.updated': { readonly analysis: AnalysisRecord } readonly 'analysis.completed': { readonly analysis: AnalysisRecord } @@ -301,6 +287,7 @@ export function eventRunId(event: BraidEvent): RunId | undefined { case 'run.reconnecting': case 'run.unknown': case 'run.bound': + case 'run.retained.admitted': case 'run.status.changed': case 'run.reconciled': case 'run.environment.observed': @@ -332,11 +319,6 @@ export function eventRunId(event: BraidEvent): RunId | undefined { case 'turn.updated': case 'message.created': case 'message.part.updated': - case 'interaction.requested': - case 'interaction.response.requested': - case 'interaction.resolved': - case 'interaction.cancelled': - case 'interaction.expired': return undefined case 'interaction.automation.audited': return event.audit.runId @@ -390,6 +372,11 @@ export type { } export function providerEventKey(event: BraidEvent): string | undefined { - if (!('provider' in event) || !event.provider) return undefined - return `${event.runId}:${event.provider.eventId}` + const provider = providerMetaForEvent(event) + if (provider === undefined || !('runId' in event)) return undefined + return `${event.runId}:${provider.eventId}` +} + +export function providerMetaForEvent(event: BraidEvent): ProviderEventMeta | undefined { + return 'provider' in event && event.provider ? event.provider : undefined } diff --git a/src/domain/interaction-timeout.ts b/src/domain/interaction-timeout.ts new file mode 100644 index 0000000..44865e7 --- /dev/null +++ b/src/domain/interaction-timeout.ts @@ -0,0 +1,13 @@ +export function interactionRemainingMs( + timeoutMs: number | undefined, + requestedAt: string | undefined, + runStartedAt: string | undefined, + now: string | number = Date.now(), +): number | undefined { + if (timeoutMs === undefined) return undefined + const started = Date.parse(requestedAt ?? runStartedAt ?? '') + const current = typeof now === 'number' ? now : Date.parse(now) + if (!Number.isFinite(started) || !Number.isFinite(current)) return undefined + const elapsed = Math.max(0, current - started) + return Math.max(0, timeoutMs - elapsed) +} diff --git a/src/domain/invariants-base.ts b/src/domain/invariants-base.ts index 82dfed8..857bedf 100644 --- a/src/domain/invariants-base.ts +++ b/src/domain/invariants-base.ts @@ -12,6 +12,7 @@ export const OPERATION_KINDS = new Set([ 'conversation-archive', 'conversation-delete', 'branch-create', + 'run-override', 'conversation-clone', 'conversation-fork', 'context-plan', diff --git a/src/domain/invariants-conversation.ts b/src/domain/invariants-conversation.ts index 8691fab..bfc9132 100644 --- a/src/domain/invariants-conversation.ts +++ b/src/domain/invariants-conversation.ts @@ -1,3 +1,4 @@ +import { harnessTypeSchema, reasoningEffortSchema } from '@tangle-network/agent-interface' import type { BranchRecord, ConversationRecord, @@ -10,11 +11,13 @@ import { assertEntityId, assertJsonValue, assertMissingHistory, + assertPublicReference, assertUniqueIds, fail, failUnsupported, finiteNonNegative, nonEmpty, + objectValue, } from './invariants-base.js' export function assertConversationRecord(record: ConversationRecord): void { @@ -57,6 +60,23 @@ export function assertBranchRecord(record: BranchRecord): void { assertEntityId('environment', record.environmentId, 'branch.environmentId') if (record.tipMessageId !== undefined) assertEntityId('message', record.tipMessageId, 'branch.tipMessageId') + const overrides: unknown = record.overrides + objectValue(overrides, 'branch.overrides') + const overrideKeys = Object.keys(overrides) + if (overrideKeys.some((key) => !['runner', 'model', 'effort', 'mode'].includes(key))) + fail('branch.overrides contains an unsupported field') + if (overrides.runner !== undefined && !harnessTypeSchema.safeParse(overrides.runner).success) + fail('branch.overrides.runner is not supported') + if (overrides.effort !== undefined && !reasoningEffortSchema.safeParse(overrides.effort).success) + fail('branch.overrides.effort is not supported') + if (overrides.model !== undefined) { + if (typeof overrides.model !== 'string') fail('branch.overrides.model must be text') + assertPublicReference(overrides.model, 'branch.overrides.model') + } + if (overrides.mode !== undefined) { + if (typeof overrides.mode !== 'string') fail('branch.overrides.mode must be text') + assertPublicReference(overrides.mode, 'branch.overrides.mode') + } assertDate(record.createdAt, 'branch.createdAt') assertDate(record.updatedAt, 'branch.updatedAt') } diff --git a/src/domain/invariants-retained-admission.ts b/src/domain/invariants-retained-admission.ts new file mode 100644 index 0000000..f3689f4 --- /dev/null +++ b/src/domain/invariants-retained-admission.ts @@ -0,0 +1,46 @@ +import { AgentExactRunControlRefSchema } from '@tangle-network/agent-interface' +import type { RunRecord } from './entities.js' +import { canonicalDigest } from './canonical.js' +import { fail, nonEmpty } from './invariants-base.js' + +function assertControlRef(value: unknown, label: string): void { + if (!AgentExactRunControlRefSchema.safeParse(value).success) fail(`${label} is invalid`) +} + +export function assertRetainedRunAdmission(record: RunRecord): void { + const admission = record.retainedAdmission + if (admission === undefined) return + nonEmpty(admission.idempotencyKey, 'run.retainedAdmission.idempotencyKey') + nonEmpty(admission.turnId, 'run.retainedAdmission.turnId') + + if (admission.phase === 'environment') { + nonEmpty(admission.provider, 'run.retainedAdmission.provider') + nonEmpty(admission.environmentId, 'run.retainedAdmission.environmentId') + nonEmpty(admission.sessionId, 'run.retainedAdmission.sessionId') + nonEmpty(admission.executionId, 'run.retainedAdmission.executionId') + if ( + record.providerSessionId !== undefined && + admission.sessionId !== record.providerSessionId + ) { + fail('run.retainedAdmission.sessionId must match run.providerSessionId') + } + if ( + record.controlRef !== undefined && + (admission.provider !== record.controlRef.provider || + admission.environmentId !== record.controlRef.environmentId || + admission.sessionId !== record.controlRef.sessionId || + admission.executionId !== record.controlRef.executionId) + ) { + fail('run.controlRef must match run.retainedAdmission recovery coordinates') + } + return + } + + assertControlRef(admission.controlRef, 'run.retainedAdmission.controlRef') + if (record.controlRef === undefined) { + fail('run.controlRef is required after retained dispatch admission') + } + if (canonicalDigest(admission.controlRef) !== canonicalDigest(record.controlRef)) { + fail('run.controlRef must match run.retainedAdmission.controlRef') + } +} diff --git a/src/domain/invariants-run.ts b/src/domain/invariants-run.ts index 838faf3..057d59f 100644 --- a/src/domain/invariants-run.ts +++ b/src/domain/invariants-run.ts @@ -1,25 +1,33 @@ -import { AgentExactRunControlRefSchema } from '@tangle-network/agent-interface' +import { + AgentExactRunControlRefSchema, + InteractionBindingSchema, + InteractionRequestSchema, +} from '@tangle-network/agent-interface' import type { AnalysisAttachmentRecord, AnalysisModelCallRecord, AnalysisRecord, - InteractionRecord, RunRecord, } from './entities.js' +import type { AutomationRuleRecord } from './entities-runtime.js' import { isReplayCursor } from './ids.js' +import { isSensitiveFieldName } from './bounded-structured.js' import { assertDate, assertDigest, assertEntityId, assertJsonValue, + assertPublicReference, + assertUniqueIds, fail, - failUnsupported, finiteNonNegative, finiteRatio, nonEmpty, objectValue, } from './invariants-base.js' import { safePublicIdentifier } from './provider-values.js' +import { assertRetainedRunAdmission } from './invariants-retained-admission.js' +import { assertAutomationRuleRecord } from './invariants-runtime.js' export function assertRunRecord(record: RunRecord): void { assertEntityId('run', record.id, 'run.id') @@ -48,6 +56,10 @@ export function assertRunRecord(record: RunRecord): void { assertEntityId('connection', record.connectionId, 'run.connectionId') if (record.providerSessionId !== undefined) assertEntityId('providerSession', record.providerSessionId, 'run.providerSessionId') + if (record.harnessSessionId !== undefined) { + nonEmpty(record.harnessSessionId, 'run.harnessSessionId') + assertPublicReference(record.harnessSessionId, 'run.harnessSessionId') + } if (record.environmentId !== undefined) assertEntityId('environment', record.environmentId, 'run.environmentId') if (record.controlRef !== undefined) { @@ -60,6 +72,8 @@ export function assertRunRecord(record: RunRecord): void { fail('run.controlRef.sessionId must match run.providerSessionId') } } + assertRetainedRunAdmission(record) + assertRunInteractions(record) if (record.bindingId !== undefined) assertEntityId('binding', record.bindingId, 'run.bindingId') if (record.receiptId !== undefined) assertEntityId('receipt', record.receiptId, 'run.receiptId') if (record.replayCursor !== undefined && !isReplayCursor(record.replayCursor)) @@ -80,58 +94,143 @@ export function assertRunRecord(record: RunRecord): void { fail('complete runs must have a terminal status') } -export function assertInteractionRecord(record: InteractionRecord): void { - assertEntityId('interaction', record.id, 'interaction.id') - assertEntityId('run', record.runId, 'interaction.runId') - if (record.providerSessionId !== undefined) - assertEntityId('providerSession', record.providerSessionId, 'interaction.providerSessionId') - if (record.request.id !== record.id) fail('interaction.request.id must match interaction.id') - nonEmpty(record.request.kind, 'interaction.request.kind') - nonEmpty(record.request.title, 'interaction.request.title') - if (!Array.isArray(record.request.answerSpec.fields)) - fail('interaction.answerSpec.fields must be an array') - const fieldNames = new Set() - for (const field of record.request.answerSpec.fields) { - objectValue(field, 'interaction.answerSpec.field') - nonEmpty(field.name, 'interaction.answerSpec.field.name') - nonEmpty(field.label, 'interaction.answerSpec.field.label') - if (fieldNames.has(field.name)) - fail(`interaction.answerSpec contains duplicate field ${field.name}`) - fieldNames.add(field.name) - if (field.type === 'secret' && 'default' in field) - fail('secret interaction fields cannot have defaults') - } - if (record.request.subject !== undefined) { - switch (record.request.subject.type) { - case 'tool': - nonEmpty(record.request.subject.toolName, 'interaction.subject.toolName') - break - case 'command': - nonEmpty(record.request.subject.command, 'interaction.subject.command') - break - case 'file': - nonEmpty(record.request.subject.path, 'interaction.subject.path') - break - case 'resource': - nonEmpty(record.request.subject.uri, 'interaction.subject.uri') - break - default: - failUnsupported(record.request.subject, 'interaction subject type') +function assertRunInteractions(record: RunRecord): void { + if (!Array.isArray(record.interactions)) fail('run.interactions must be an array') + const requestIds = record.interactions.map((interaction, index) => + assertBraidInteraction(interaction, record.id, index), + ) + assertUniqueIds(requestIds, 'run.interactions.request') + if (record.pendingInteractionIds === undefined) return + const pendingIds = record.pendingInteractionIds.map((interactionId, index) => { + assertEntityId('interaction', interactionId, `run.pendingInteractionIds[${index}]`) + return interactionId + }) + assertUniqueIds(pendingIds, 'run.pendingInteractionIds') + const pending = new Set(pendingIds) + for (const interaction of record.interactions) { + const interactionId = interaction.request.id + if (interaction.status === 'pending' && !pending.has(interactionId)) { + fail(`run.pendingInteractionIds must include visible pending interaction ${interactionId}`) + } + if (interaction.status !== 'pending' && pending.has(interactionId)) { + fail(`run.pendingInteractionIds cannot include non-pending interaction ${interactionId}`) } } - if (record.request.timeoutMs !== undefined) - finiteNonNegative(record.request.timeoutMs, 'interaction.request.timeoutMs') - if (record.resolution !== undefined) { - assertEntityId('operation', record.resolution.operationId, 'interaction.resolution.operationId') - if (record.resolution.containsSecret && record.resolution.publicData !== undefined) { - fail('secret interaction resolution cannot contain publicData') + if (!record.interactionsTruncated) { + const visiblePending = record.interactions + .filter((interaction) => interaction.status === 'pending') + .map((interaction) => interaction.request.id) + if ( + visiblePending.length !== pendingIds.length || + visiblePending.some((interactionId) => !pending.has(interactionId)) + ) { + fail('run.pendingInteractionIds must match all interactions when history is complete') } - if (record.resolution.dataDigest !== undefined) - assertDigest(record.resolution.dataDigest, 'interaction.resolution.dataDigest') } - assertNoSecretInteractionData(record) - assertDate(record.createdAt, 'interaction.createdAt') - assertDate(record.updatedAt, 'interaction.updatedAt') +} + +function assertBraidInteraction(value: unknown, runId: string, index: number): string { + const label = `run.interactions[${index}]` + objectValue(value, label) + + const requestResult = InteractionRequestSchema.safeParse(value.request) + if (!requestResult.success) fail(`${label}.request is invalid`) + const request = requestResult.data + + const responseBindingResult = InteractionBindingSchema.safeParse(value.responseBinding) + if (!responseBindingResult.success) fail(`${label}.responseBinding is invalid`) + const responseBinding = responseBindingResult.data + + objectValue(value.source, `${label}.source`) + if (value.source.eventId !== undefined) + assertPublicIdentifier(value.source.eventId, `${label}.source.eventId`) + if ( + value.source.sequence !== undefined && + (typeof value.source.sequence !== 'number' || + !Number.isSafeInteger(value.source.sequence) || + value.source.sequence <= 0) + ) { + fail(`${label}.source.sequence must be a positive safe integer`) + } + if (value.source.cursor !== undefined) + assertPublicIdentifier(value.source.cursor, `${label}.source.cursor`) + if (value.source.occurredAt !== undefined) + assertDate(value.source.occurredAt, `${label}.source.occurredAt`) + + assertEntityId('run', value.runId, `${label}.runId`) + if (value.runId !== runId) fail(`${label}.runId must match run.id`) + if (request.binding.runId !== runId) fail(`${label}.request.binding.runId must match run.id`) + if (responseBinding.runId !== runId) fail(`${label}.responseBinding.runId must match run.id`) + // Redaction recomputes the display request digest but preserves the response binding digest. + if (responseBinding.interactionId !== request.id) + fail(`${label}.responseBinding.interactionId must match request.id`) + for (const key of ['provider', 'environmentId', 'sessionId', 'executionId'] as const) { + if (responseBinding[key] !== request.binding[key]) + fail(`${label}.responseBinding.${key} must match request.binding.${key}`) + } + + const statuses = ['pending', 'responding', 'declined', 'cancelled', 'resolved', 'unknown'] + if (typeof value.status !== 'string' || !statuses.includes(value.status)) + fail(`${label}.status is invalid`) + + if (value.responseOperation === undefined) { + if (value.status === 'responding') + fail(`${label}.responding interactions require responseOperation`) + return request.id + } + + objectValue(value.responseOperation, `${label}.responseOperation`) + const responseOperation = value.responseOperation + assertEntityId( + 'operation', + responseOperation.operationId, + `${label}.responseOperation.operationId`, + ) + if (responseOperation.dataDigest !== undefined) + assertDigest(responseOperation.dataDigest, `${label}.responseOperation.dataDigest`) + if (typeof responseOperation.containsSecret !== 'boolean') + fail(`${label}.responseOperation.containsSecret must be boolean`) + if ( + responseOperation.outcome !== 'accepted' && + responseOperation.outcome !== 'declined' && + responseOperation.outcome !== 'cancelled' && + responseOperation.outcome !== 'unknown' + ) + fail(`${label}.responseOperation.outcome is invalid`) + const expectedStatus = + responseOperation.outcome === 'accepted' + ? 'resolved' + : responseOperation.outcome === 'declined' + ? 'declined' + : responseOperation.outcome === 'cancelled' + ? 'cancelled' + : 'unknown' + if (value.status !== 'responding' && value.status !== expectedStatus) { + fail(`${label}.responseOperation status does not match its outcome`) + } + if (value.status === 'responding' && responseOperation.outcome === 'unknown') { + fail(`${label}.responding responseOperation cannot have unknown outcome`) + } + if ( + responseOperation.outcome !== 'unknown' && + request.allowedOutcomes !== undefined && + !request.allowedOutcomes.includes(responseOperation.outcome) + ) + fail(`${label}.responseOperation.outcome is not allowed by request`) + if (responseOperation.containsSecret && responseOperation.dataDigest !== undefined) + fail(`${label}.secret responseOperation cannot retain dataDigest`) + if ( + !responseOperation.containsSecret && + request.answerSpec.fields.some( + (field) => field.type === 'secret' || isSensitiveFieldName(field.name), + ) + ) { + fail(`${label}.responseOperation must mark secret request data`) + } + if (responseOperation.automationRule !== undefined) { + assertAutomationRuleRecord(responseOperation.automationRule as AutomationRuleRecord) + } + return request.id } export function assertAnalysisRecord(record: AnalysisRecord): void { @@ -247,7 +346,7 @@ function assertAnalysisModelCall(record: AnalysisModelCallRecord): void { } } -function assertPublicIdentifier(value: string, label: string): void { +function assertPublicIdentifier(value: unknown, label: string): void { if (safePublicIdentifier(value) !== value) fail(`${label} must be a safe public identifier`) } @@ -307,34 +406,3 @@ function assertAnalysisCitation( fail('analysis.citation.end must not precede start') } } - -export function assertNoSecretInteractionData(record: InteractionRecord): void { - const hasSecretField = record.request.answerSpec.fields.some((field) => field.type === 'secret') - const publicData = record.resolution?.publicData - if (publicData !== undefined) { - objectValue(publicData, 'interaction.resolution.publicData') - for (const [key, value] of Object.entries(publicData)) { - if ( - /(secret|password|passphrase|token|bearer|authorization|credential|private(?:[_-]?key)?|api[-_]?key)/iu.test( - key, - ) - ) { - fail(`interaction.resolution.publicData.${key} is secret-designated and cannot be retained`) - } - if ( - typeof value !== 'string' && - typeof value !== 'boolean' && - !(typeof value === 'number' && Number.isFinite(value)) && - !(Array.isArray(value) && value.every((entry) => typeof entry === 'string')) - ) { - fail(`interaction.resolution.publicData.${key} contains an unsupported value`) - } - } - } - if (hasSecretField && publicData !== undefined) { - fail('secret interaction data cannot be retained in the domain state') - } - if (record.resolution?.containsSecret && publicData !== undefined) { - fail('secret interaction data cannot be retained in the domain state') - } -} diff --git a/src/domain/invariants-runtime.ts b/src/domain/invariants-runtime.ts index f655f8c..7f80ba5 100644 --- a/src/domain/invariants-runtime.ts +++ b/src/domain/invariants-runtime.ts @@ -27,6 +27,7 @@ import { OPERATION_KINDS, OPERATION_STATUSES, } from './invariants-base.js' +import { isSensitiveFieldName } from './bounded-structured.js' export function assertEnvironmentRecord(record: EnvironmentRecord): void { assertEntityId('environment', record.id, 'environment.id') @@ -309,11 +310,7 @@ export function assertAutomationRuleRecord(record: AutomationRuleRecord): void { if (record.matcher.workspaceId !== undefined) assertEntityId('workspace', record.matcher.workspaceId, 'rule.matcher.workspaceId') for (const [name, value] of Object.entries(record.answer)) { - if ( - /(secret|password|passphrase|token|bearer|authorization|credential|private(?:[_-]?key)?|api[-_]?key)/iu.test( - name, - ) - ) { + if (isSensitiveFieldName(name)) { fail(`rule.answer.${name} is secret-designated and cannot be retained`) } if ( diff --git a/src/domain/invariants-state.ts b/src/domain/invariants-state.ts index 80df3fd..1eea479 100644 --- a/src/domain/invariants-state.ts +++ b/src/domain/invariants-state.ts @@ -33,7 +33,6 @@ import { assertStateRelations } from './invariants-relations.js' import { assertAnalysisAttachment, assertAnalysisRecord, - assertInteractionRecord, assertRunRecord, } from './invariants-run.js' import { @@ -114,10 +113,6 @@ export function assertBraidState(state: BraidState): void { state.runs.map((record) => record.id), 'state.runs', ) - assertUniqueIds( - state.interactions.map((record) => record.id), - 'state.interactions', - ) assertUniqueIds( state.analyses.map((record) => record.id), 'state.analyses', @@ -204,7 +199,6 @@ export function assertBraidState(state: BraidState): void { for (const record of state.messages) assertMessageRecord(record) for (const record of state.messageParts) assertMessagePartRecord(record) for (const record of state.runs) assertRunRecord(record) - for (const record of state.interactions) assertInteractionRecord(record) for (const record of state.analyses) assertAnalysisRecord(record) for (const record of state.analysisAttachments) assertAnalysisAttachment(record) for (const record of state.environments) assertEnvironmentRecord(record) diff --git a/src/domain/legacy-interaction-snapshot.ts b/src/domain/legacy-interaction-snapshot.ts new file mode 100644 index 0000000..3d7f364 --- /dev/null +++ b/src/domain/legacy-interaction-snapshot.ts @@ -0,0 +1,289 @@ +import { + InteractionRequestSchema, + interactionRequestDigest, + type InteractionRequest, + type InteractionRequestBinding, + type InteractionRequestMaterial, +} from '@tangle-network/agent-interface' +import { canonicalDigest } from './canonical.js' +import { MAX_RUN_INTERACTIONS, withPendingInteractionIndex } from './reducer-support.js' +import type { BraidInteraction } from './runtime-projection.js' +import type { BraidState } from './state.js' +import { isCanonicalIsoDateTime } from './text.js' +import type { MaterializedState } from './materialized-state.js' + +interface LegacyInteractionRecord { + readonly id?: unknown + readonly runId?: unknown + readonly providerSessionId?: unknown + readonly request?: unknown + readonly status?: unknown + readonly resolution?: unknown + readonly createdAt?: unknown +} + +type LegacyMaterializedState = MaterializedState & { readonly interactions?: unknown } + +export function migrateLegacyInteractions(state: LegacyMaterializedState): MaterializedState { + if (!Object.hasOwn(state, 'interactions')) return state + if (!Array.isArray(state.interactions)) { + throw new Error('Legacy snapshot interactions must be an array') + } + + const { interactions: _legacyInteractions, ...withoutLegacyInteractions } = state + if (state.interactions.length === 0) return withoutLegacyInteractions + + const seen = new Set() + const runs = [...state.runs] + for (const raw of state.interactions) { + const legacy = legacyInteractionRecord(raw) + const id = requiredString(legacy.id, 'legacy interaction id') + const runId = requiredString(legacy.runId, `legacy interaction ${id} runId`) + if (!seen.add(id)) throw new Error(`Legacy snapshot interaction ${id} is duplicated`) + const runIndex = runs.findIndex((run) => run.id === runId) + if (runIndex === -1) throw new Error(`Legacy snapshot interaction ${id} has no run ${runId}`) + const run = runs[runIndex] + if (!run) throw new Error(`Legacy snapshot interaction ${id} has no run ${runId}`) + const existing = run.interactions.find((interaction) => interaction.request.id === id) + if (existing !== undefined) { + assertLegacyInteractionMatches(legacy, existing) + continue + } + if (run.pendingInteractionIds?.includes(id)) { + throw new Error(`Legacy snapshot interaction ${id} conflicts with canonical pending identity`) + } + const migrated = migrateLegacyInteraction(legacy, run, id) + const allInteractions = [...run.interactions, migrated] + const indexed = withPendingInteractionIndex(run, allInteractions) + runs[runIndex] = { + ...indexed, + interactions: allInteractions.slice(-MAX_RUN_INTERACTIONS), + ...(allInteractions.length > MAX_RUN_INTERACTIONS ? { interactionsTruncated: true } : {}), + } + } + return { ...withoutLegacyInteractions, runs } +} + +function legacyInteractionRecord(value: unknown): LegacyInteractionRecord { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Legacy snapshot interaction is not an object') + } + return value as LegacyInteractionRecord +} + +function migrateLegacyInteraction( + legacy: LegacyInteractionRecord, + run: BraidState['runs'][number], + id: string, +): BraidInteraction { + const legacyRequest = recordValue(legacy.request, `legacy interaction ${id} request`) + const request = legacyRequestWithBinding(legacyRequest, legacy, run, id) + const status = legacyInteractionStatus(legacy.status, id) + const responseOperation = legacyResponseOperation(legacy.resolution, status, id) + if (status === 'responding' && responseOperation === undefined) { + throw new Error(`Legacy snapshot interaction ${id} is responding without a response operation`) + } + return { + request, + responseBinding: { ...request.binding, requestDigest: request.requestDigest }, + runId: run.id, + source: { + ...(isCanonicalIsoDateTime(legacy.createdAt) ? { occurredAt: legacy.createdAt } : {}), + }, + status, + ...(responseOperation === undefined ? {} : { responseOperation }), + } +} + +function legacyRequestWithBinding( + value: Record, + legacy: LegacyInteractionRecord, + run: BraidState['runs'][number], + id: string, +): InteractionRequest { + const existing = InteractionRequestSchema.safeParse(value) + if (existing.success) return existing.data as InteractionRequest + const material = legacyRequestMaterial(value, legacy, run, id) + const parsed = InteractionRequestSchema.safeParse({ + ...material, + requestDigest: interactionRequestDigest(material), + }) + if (!parsed.success) throw new Error(`Legacy snapshot interaction ${id} request is invalid`) + return parsed.data as InteractionRequest +} + +function legacyRequestMaterial( + value: Record, + legacy: LegacyInteractionRecord, + run: BraidState['runs'][number], + id: string, +): InteractionRequestMaterial { + const binding: InteractionRequestBinding = { + runId: run.id, + provider: providerIdentifier(run), + environmentId: + run.controlRef?.environmentId ?? + run.environmentId ?? + run.receipt.environmentId ?? + `legacy-environment-${run.id}`, + sessionId: + (typeof legacy.providerSessionId === 'string' ? legacy.providerSessionId : undefined) ?? + run.controlRef?.sessionId ?? + run.providerSessionId ?? + run.receipt.providerSessionId ?? + `legacy-session-${run.id}`, + executionId: run.controlRef?.executionId ?? run.receipt.runId ?? run.id, + interactionId: id, + } + const body = optionalString(value.body) + const request = { + id, + kind: requiredString(value.kind, `legacy interaction ${id} kind`), + title: requiredString(value.title, `legacy interaction ${id} title`), + ...(body === undefined ? {} : { body }), + ...(value.subject === undefined ? {} : { subject: value.subject }), + answerSpec: value.answerSpec, + ...(value.responseScopes === undefined ? {} : { responseScopes: value.responseScopes }), + ...(value.allowedOutcomes === undefined ? {} : { allowedOutcomes: value.allowedOutcomes }), + ...(value.default === undefined ? {} : { default: value.default }), + ...(value.timeoutMs === undefined ? {} : { timeoutMs: value.timeoutMs }), + ...(value.onTimeout === undefined ? {} : { onTimeout: value.onTimeout }), + binding, + } + return request as InteractionRequestMaterial +} + +function providerIdentifier(run: BraidState['runs'][number]): string { + return run.controlRef?.provider ?? run.receipt.provider ?? 'legacy' +} + +function legacyInteractionStatus(value: unknown, id: string): BraidInteraction['status'] { + switch (value) { + case 'pending': + case 'responding': + case 'declined': + case 'cancelled': + case 'resolved': + case 'unknown': + return value + case 'expired': + case 'conflict': + return 'unknown' + default: + throw new Error(`Legacy snapshot interaction ${id} status is invalid`) + } +} + +function legacyResponseOperation( + value: unknown, + status: BraidInteraction['status'], + id: string, +): NonNullable | undefined { + if (value === undefined) return undefined + const resolution = recordValue(value, `legacy interaction ${id} resolution`) + const operationId = requiredString( + resolution.operationId, + `legacy interaction ${id} resolution operationId`, + ) + const outcome = resolution.outcome + if (outcome !== 'accepted' && outcome !== 'declined' && outcome !== 'cancelled') { + throw new Error(`Legacy snapshot interaction ${id} resolution outcome is invalid`) + } + if (status === 'unknown') { + throw new Error(`Legacy snapshot interaction ${id} has a resolution with unknown status`) + } + if ( + status === 'responding' && + outcome !== 'accepted' && + outcome !== 'declined' && + outcome !== 'cancelled' + ) { + throw new Error(`Legacy snapshot interaction ${id} resolution is invalid`) + } + const containsSecret = resolution.containsSecret + if (typeof containsSecret !== 'boolean') { + throw new Error(`Legacy snapshot interaction ${id} resolution secrecy is invalid`) + } + const dataDigest = resolution.dataDigest + if (dataDigest !== undefined && typeof dataDigest !== 'string') { + throw new Error(`Legacy snapshot interaction ${id} resolution digest is invalid`) + } + return { + operationId: operationId as NonNullable['operationId'], + outcome, + ...(dataDigest === undefined ? {} : { dataDigest: dataDigest as `sha256:${string}` }), + containsSecret, + } +} + +function assertLegacyInteractionMatches( + legacy: LegacyInteractionRecord, + current: BraidInteraction, +): void { + const id = current.request.id + const request = recordValue(legacy.request, `legacy interaction ${id} request`) + for (const key of [ + 'id', + 'kind', + 'title', + 'body', + 'subject', + 'answerSpec', + 'responseScopes', + 'allowedOutcomes', + 'default', + 'timeoutMs', + 'onTimeout', + 'binding', + 'requestDigest', + ] as const) { + if ( + request[key] !== undefined && + canonicalDigest(request[key]) !== canonicalDigest(current.request[key]) + ) { + throw new Error(`Legacy snapshot interaction ${id} conflicts with canonical run state`) + } + } + const status = legacyInteractionStatus(legacy.status, id) + if (status !== current.status) { + throw new Error(`Legacy snapshot interaction ${id} status conflicts with canonical run state`) + } + if ( + legacy.providerSessionId !== undefined && + legacy.providerSessionId !== current.request.binding.sessionId + ) { + throw new Error(`Legacy snapshot interaction ${id} binding conflicts with canonical run state`) + } + if (legacy.resolution !== undefined) { + const migrated = legacyResponseOperation(legacy.resolution, status, id) + if ( + migrated === undefined || + current.responseOperation === undefined || + canonicalDigest(migrated) !== canonicalDigest(current.responseOperation) + ) { + throw new Error( + `Legacy snapshot interaction ${id} resolution conflicts with canonical run state`, + ) + } + } else if (current.responseOperation !== undefined) { + throw new Error( + `Legacy snapshot interaction ${id} resolution conflicts with canonical run state`, + ) + } +} + +function recordValue(value: unknown, label: string): Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`${label} is not an object`) + } + return value as Record +} + +function requiredString(value: unknown, label: string): string { + if (typeof value !== 'string' || value.length === 0) throw new Error(`${label} is invalid`) + return value +} + +function optionalString(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined +} diff --git a/src/domain/materialized-state-snapshot.ts b/src/domain/materialized-state-snapshot.ts index a9617a1..1cb8f28 100644 --- a/src/domain/materialized-state-snapshot.ts +++ b/src/domain/materialized-state-snapshot.ts @@ -1,36 +1,8 @@ -import type { AgentProfile } from '@tangle-network/agent-interface' import { canonicalDigest } from './canonical.js' -import type { - AnalysisAttachmentRecord, - AnalysisRecord, - BindingRecord, - BraidMessage, - BraidRun, - BranchRecord, - CheckpointRecord, - ConnectionRecord, - ConversationRecord, - CredentialReference, - DraftRecord, - EffectRecord, - EnvironmentRecord, - FeedbackDecisionRecord, - GraphEdgeRecord, - GraphNodeRecord, - InteractionRecord, - MessagePartRecord, - OperationRecord, - ProfileRecord, - ProfileSnapshotRecord, - QueueEntryRecord, - QueueRecord, - ReplayCursorRecord, - SupervisorRecord, - TurnRecord, - WorkerRecord, - WorkspaceRecord, -} from './entities.js' -import type { BranchId, ConversationId, EventId, RunId, WorkspaceId } from './ids.js' +import type { EventId } from './ids.js' +import { migrateLegacyInteractions } from './legacy-interaction-snapshot.js' +import type { MaterializedState } from './materialized-state.js' +import { canonicalProjectionChecksum } from './projection-checksum.js' import { assertBraidState } from './invariants.js' import { withHealth } from './reducer-helpers.js' import { type BraidState, initialState } from './state.js' @@ -44,50 +16,7 @@ export const MATERIALIZED_SNAPSHOT_SCHEMA_VERSION = 1 as const * identity history, unknown-event payloads, health, and derived checksums are * reconstructed from the journal tail and database projection metadata. */ -export interface MaterializedState { - readonly schemaVersion: BraidState['schemaVersion'] - readonly workspace: string | null - readonly workspaceId: WorkspaceId | null - readonly conversationId: ConversationId - readonly branchId: BranchId - readonly selectedProfileId: BraidState['selectedProfileId'] - readonly selectedConnectionId: BraidState['selectedConnectionId'] - readonly profile: Readonly - readonly draft: string - readonly messages: readonly BraidMessage[] - readonly messageParts: readonly MessagePartRecord[] - readonly runs: readonly BraidRun[] - readonly activeRunId: RunId | null - readonly queuedInputs: BraidState['queuedInputs'] - readonly lastError: string | null - readonly workspaces: readonly WorkspaceRecord[] - readonly profiles: readonly ProfileRecord[] - readonly profileSnapshots: readonly ProfileSnapshotRecord[] - readonly credentials: readonly CredentialReference[] - readonly connections: readonly ConnectionRecord[] - readonly conversations: readonly ConversationRecord[] - readonly branches: readonly BranchRecord[] - readonly turns: readonly TurnRecord[] - readonly interactions: readonly InteractionRecord[] - readonly analyses: readonly AnalysisRecord[] - readonly analysisAttachments: readonly AnalysisAttachmentRecord[] - readonly environments: readonly EnvironmentRecord[] - readonly checkpoints: readonly CheckpointRecord[] - readonly supervisors: readonly SupervisorRecord[] - readonly workers: readonly WorkerRecord[] - readonly drafts: readonly DraftRecord[] - readonly queues: readonly QueueRecord[] - readonly queueEntries: readonly QueueEntryRecord[] - readonly rules: BraidState['rules'] - readonly bindings: readonly BindingRecord[] - readonly graphNodes: readonly GraphNodeRecord[] - readonly graphEdges: readonly GraphEdgeRecord[] - readonly operations: readonly OperationRecord[] - readonly effects: readonly EffectRecord[] - readonly feedbackDecisions: readonly FeedbackDecisionRecord[] - readonly replayCursors: readonly ReplayCursorRecord[] - readonly missingHistory: readonly BraidState['missingHistory'][number][] -} +export type { MaterializedState } from './materialized-state.js' export interface MaterializedStateSnapshot { readonly kind: 'braid.materialized-state' @@ -126,7 +55,6 @@ function materializedState(state: BraidState): MaterializedState { conversations: state.conversations, branches: state.branches, turns: state.turns, - interactions: state.interactions, analyses: state.analyses, analysisAttachments: state.analysisAttachments, environments: state.environments, @@ -207,9 +135,12 @@ export function restoreMaterializedState(value: unknown): BraidState { conversationId: snapshot.state.conversationId, branchId: snapshot.state.branchId, }) + const stateFields = migrateLegacyInteractions({ ...snapshot.state } as MaterializedState & { + interactions?: unknown + }) const restored: BraidState = { ...base, - ...snapshot.state, + ...stateFields, revision: snapshot.revision, sequence: snapshot.sequence, appliedEvents: [], @@ -225,8 +156,7 @@ export function restoreMaterializedState(value: unknown): BraidState { const healthy = withHealth(restored) const finalized: BraidState = { ...healthy, - // MaterializedState is exactly the canonical projection field set. - projectionChecksum: snapshot.stateChecksum, + projectionChecksum: canonicalProjectionChecksum(healthy), } assertBraidState(finalized) return finalized diff --git a/src/domain/materialized-state.ts b/src/domain/materialized-state.ts new file mode 100644 index 0000000..6296f61 --- /dev/null +++ b/src/domain/materialized-state.ts @@ -0,0 +1,76 @@ +import type { AgentProfile } from '@tangle-network/agent-interface' +import type { + AnalysisAttachmentRecord, + AnalysisRecord, + BindingRecord, + BraidMessage, + BraidRun, + BranchRecord, + CheckpointRecord, + ConnectionRecord, + ConversationRecord, + CredentialReference, + DraftRecord, + EffectRecord, + EnvironmentRecord, + FeedbackDecisionRecord, + GraphEdgeRecord, + GraphNodeRecord, + MessagePartRecord, + OperationRecord, + ProfileRecord, + ProfileSnapshotRecord, + QueueEntryRecord, + QueueRecord, + ReplayCursorRecord, + SupervisorRecord, + TurnRecord, + WorkerRecord, + WorkspaceRecord, +} from './entities.js' +import type { BranchId, ConversationId, RunId, WorkspaceId } from './ids.js' +import type { BraidState } from './state.js' + +export interface MaterializedState { + readonly schemaVersion: BraidState['schemaVersion'] + readonly workspace: string | null + readonly workspaceId: WorkspaceId | null + readonly conversationId: ConversationId + readonly branchId: BranchId + readonly selectedProfileId: BraidState['selectedProfileId'] + readonly selectedConnectionId: BraidState['selectedConnectionId'] + readonly profile: Readonly + readonly draft: string + readonly messages: readonly BraidMessage[] + readonly messageParts: readonly MessagePartRecord[] + readonly runs: readonly BraidRun[] + readonly activeRunId: RunId | null + readonly queuedInputs: BraidState['queuedInputs'] + readonly lastError: string | null + readonly workspaces: readonly WorkspaceRecord[] + readonly profiles: readonly ProfileRecord[] + readonly profileSnapshots: readonly ProfileSnapshotRecord[] + readonly credentials: readonly CredentialReference[] + readonly connections: readonly ConnectionRecord[] + readonly conversations: readonly ConversationRecord[] + readonly branches: readonly BranchRecord[] + readonly turns: readonly TurnRecord[] + readonly analyses: readonly AnalysisRecord[] + readonly analysisAttachments: readonly AnalysisAttachmentRecord[] + readonly environments: readonly EnvironmentRecord[] + readonly checkpoints: readonly CheckpointRecord[] + readonly supervisors: readonly SupervisorRecord[] + readonly workers: readonly WorkerRecord[] + readonly drafts: readonly DraftRecord[] + readonly queues: readonly QueueRecord[] + readonly queueEntries: readonly QueueEntryRecord[] + readonly rules: BraidState['rules'] + readonly bindings: readonly BindingRecord[] + readonly graphNodes: readonly GraphNodeRecord[] + readonly graphEdges: readonly GraphEdgeRecord[] + readonly operations: readonly OperationRecord[] + readonly effects: readonly EffectRecord[] + readonly feedbackDecisions: readonly FeedbackDecisionRecord[] + readonly replayCursors: readonly ReplayCursorRecord[] + readonly missingHistory: readonly BraidState['missingHistory'][number][] +} diff --git a/src/domain/reducer-conversation-events.ts b/src/domain/reducer-conversation-events.ts index fca1634..5b6443c 100644 --- a/src/domain/reducer-conversation-events.ts +++ b/src/domain/reducer-conversation-events.ts @@ -1,4 +1,5 @@ import type { BraidEvent } from './events.js' +import { DomainInvariantError } from './invariants-base.js' import { find, upsert } from './reducer-helpers.js' import type { BraidState } from './state.js' @@ -103,8 +104,23 @@ export function applyConversationEvent(state: BraidState, event: ConversationEve : { operations: upsert(state.operations, event.operation) }), draft: event.draft?.text ?? '', } - case 'branch.updated': - return { ...state, branches: upsert(state.branches, event.branch) } + case 'branch.updated': { + if ( + event.operation.kind !== 'run-override' || + event.operation.status !== 'acknowledged' || + event.operation.target?.kind !== 'branch' || + event.operation.target.id !== event.branch.id + ) { + throw new DomainInvariantError( + `Run override operation ${event.operation.id} does not acknowledge ${event.branch.id}`, + ) + } + return { + ...state, + branches: upsert(state.branches, event.branch), + operations: upsert(state.operations, event.operation), + } + } case 'branch.selected': find(state.branches, event.branchId, 'Branch') return { @@ -217,7 +233,6 @@ function applyConversationDeletion( messages: state.messages.filter((message) => !messageIds.has(message.id)), messageParts: state.messageParts.filter((part) => !messageIds.has(part.messageId)), runs: state.runs.filter((run) => !runIds.has(run.id)), - interactions: state.interactions.filter((interaction) => !runIds.has(interaction.runId)), analyses: state.analyses.filter((analysis) => !analysisIds.has(analysis.id)), analysisAttachments: state.analysisAttachments.filter( (attachment) => !analysisAttachmentIds.has(attachment.id), diff --git a/src/domain/reducer-domain-events.ts b/src/domain/reducer-domain-events.ts index 864e061..2d039b7 100644 --- a/src/domain/reducer-domain-events.ts +++ b/src/domain/reducer-domain-events.ts @@ -3,6 +3,7 @@ import type { BraidEvent, DomainBraidEventMap } from './events.js' import { DomainInvariantError } from './invariants.js' import { applyConversationEvent } from './reducer-conversation-events.js' import { applyExecutionObservation } from './reducer-execution-observation.js' +import { applyRetainedAdmission } from './reducer-retained-admission.js' import { find, updateRun, upsert, upsertBy } from './reducer-helpers.js' import { isCancellationConfirmedReconciliation } from './reducer-support.js' @@ -95,6 +96,8 @@ export function applyDomainEvent( runs: upsert(state.runs, { ...run, bindingId: event.bindingId, updatedAt: at }), } } + case 'run.retained.admitted': + return applyRetainedAdmission(state, event, at) case 'run.status.changed': { const run = find(state.runs, event.runId, 'Run') return updateRun( @@ -139,58 +142,6 @@ export function applyDomainEvent( missingHistory: [...state.missingHistory, event.range], } } - case 'interaction.requested': - return { ...state, interactions: upsert(state.interactions, event.interaction) } - case 'interaction.response.requested': { - const interaction = find(state.interactions, event.response.interactionId, 'Interaction') - if (interaction.status !== 'pending' && interaction.status !== 'responding') - throw new DomainInvariantError(`Interaction ${interaction.id} is not pending`) - return { - ...state, - interactions: upsert(state.interactions, { - ...interaction, - status: 'responding', - updatedAt: at, - }), - } - } - case 'interaction.resolved': { - const interaction = find(state.interactions, event.interactionId, 'Interaction') - if (event.resolution === undefined) - throw new DomainInvariantError(`Interaction ${interaction.id} has no resolution`) - const status = event.resolution.outcome === 'accepted' ? 'resolved' : event.resolution.outcome - return { - ...state, - interactions: upsert(state.interactions, { - ...interaction, - resolution: event.resolution, - status, - updatedAt: at, - }), - } - } - case 'interaction.cancelled': { - const interaction = find(state.interactions, event.interactionId, 'Interaction') - return { - ...state, - interactions: upsert(state.interactions, { - ...interaction, - status: 'cancelled', - updatedAt: at, - }), - } - } - case 'interaction.expired': { - const interaction = find(state.interactions, event.interactionId, 'Interaction') - return { - ...state, - interactions: upsert(state.interactions, { - ...interaction, - status: 'expired', - updatedAt: at, - }), - } - } case 'analysis.created': case 'analysis.updated': case 'analysis.completed': diff --git a/src/domain/reducer-interactions.ts b/src/domain/reducer-interactions.ts index 848c540..5752513 100644 --- a/src/domain/reducer-interactions.ts +++ b/src/domain/reducer-interactions.ts @@ -1,8 +1,12 @@ import type { BraidEvent } from './events.js' import { reserveText } from './content-budget.js' +import { canonicalDigest } from './canonical.js' +import { DomainInvariantError } from './invariants-base.js' import type { BraidInteraction, BraidState } from './state.js' import { createOperationId } from './ids.js' +import { safePublicIdentifier } from './provider-values.js' import { finalizeRunUsage } from './run-usage.js' +import { assertAutomationRuleRecord } from './invariants-runtime.js' import { activity, addActivity, @@ -15,6 +19,7 @@ import { updateRun, upsertPart, withProviderProgress, + withPendingInteractionIndex, MAX_RUN_EVENT_DETAILS, MAX_RUN_INTERACTIONS, TERMINAL_RUN_STATES, @@ -42,7 +47,26 @@ export function reduceInteractionEvent( ): BraidState { switch (event.kind) { case 'run.interaction': { + const run = findRun(state, event.runId) const source = sourceFromProvider(event.provider) ?? {} + const existing = run.interactions.find((item) => item.request.id === event.request.id) + if (existing !== undefined) { + if (!sameInteractionEvent(existing, event.request, event.responseBinding, source)) { + throw interactionInvariant( + `Interaction ${event.request.id} was requested with different data`, + ) + } + return { + ...state, + ...base, + runs: updateRun(state, event.runId, (candidate) => + withProviderProgress(candidate, event.provider), + ), + } + } + if (run.pendingInteractionIds?.includes(event.request.id)) { + throw interactionInvariant(`Interaction ${event.request.id} is not available`) + } const interaction: BraidInteraction = { request: event.request, responseBinding: event.responseBinding, @@ -50,67 +74,123 @@ export function reduceInteractionEvent( source, status: 'pending', } - const interactions = [ - ...(state.runs.find((run) => run.id === event.runId)?.interactions ?? []), - interaction, - ] + const interactions = [...run.interactions, interaction] + const visibleInteractions = interactions.slice(-MAX_RUN_INTERACTIONS) return { ...state, ...base, runs: updateRun(state, event.runId, (run) => addActivity( { - ...withProviderProgress(run, event.provider), - status: 'waiting', - interactions: interactions.slice(-MAX_RUN_INTERACTIONS), - ...(interactions.length > MAX_RUN_INTERACTIONS - ? { interactionsTruncated: true } - : {}), + ...withPendingInteractionIndex( + { + ...withProviderProgress(run, event.provider), + status: 'waiting', + ...(interactions.length > MAX_RUN_INTERACTIONS + ? { interactionsTruncated: true } + : {}), + }, + interactions, + ), + interactions: visibleInteractions, }, activity(event, 'interaction', event.request.kind, event.request.title, source), ), ), } } - case 'run.interaction.cancelled': + case 'run.interaction.cancelled': { + const run = findRun(state, event.runId) + const current = interactionFor(run, event.interactionId) + if (current.status === 'cancelled') { + return providerProgress(state, run, event.provider, base) + } + if (current.status !== 'pending' && current.status !== 'responding') { + throw interactionInvariant( + `Interaction ${event.interactionId} cannot transition from ${current.status} to cancelled`, + ) + } + const next = withoutResponseOperation(current, 'cancelled') return { ...state, ...base, - runs: updateRun(state, event.runId, (run) => ({ - ...withProviderProgress(run, event.provider), - interactions: run.interactions.map((item) => - item.request.id === event.interactionId - ? withoutResponseOperation(item, 'cancelled') - : item, + runs: updateRun(state, event.runId, (candidate) => + withPendingInteractionIndex( + withProviderProgress(candidate, event.provider), + replaceInteraction(candidate, event.interactionId, next), ), - })), + ), + } + } + case 'run.interaction.response.requested': { + const run = findRun(state, event.runId) + const current = interactionFor(run, event.interactionId) + if (event.automationRule !== undefined) assertAutomationRuleRecord(event.automationRule) + if (current.status === 'responding') { + if (sameResponseRequest(current, event)) return { ...state, ...base } + throw interactionInvariant( + `Interaction ${event.interactionId} has a different response operation`, + ) + } + if (current.status !== 'pending') { + throw interactionInvariant( + `Interaction ${event.interactionId} cannot transition from ${current.status} to responding`, + ) + } + const next: BraidInteraction = { + ...current, + status: 'responding', + responseOperation: { + operationId: createOperationId(event.operationId), + outcome: event.outcome, + ...(event.dataDigest === undefined ? {} : { dataDigest: event.dataDigest }), + containsSecret: event.containsSecret, + ...(event.automationRule === undefined ? {} : { automationRule: event.automationRule }), + }, } - case 'run.interaction.response.requested': return { ...state, ...base, runs: updateRun(state, event.runId, (run) => ({ ...run, - interactions: run.interactions.map((item) => - item.request.id === event.interactionId - ? { - ...item, - status: 'responding' as const, - responseOperation: { - operationId: createOperationId(event.operationId), - outcome: event.outcome, - ...(event.dataDigest === undefined ? {} : { dataDigest: event.dataDigest }), - containsSecret: event.containsSecret, - ...(event.automationRule === undefined - ? {} - : { automationRule: event.automationRule }), - }, - } - : item, - ), + ...withPendingInteractionIndex(run, replaceInteraction(run, event.interactionId, next)), })), } - case 'run.interaction.responded': + } + case 'run.interaction.responded': { + const run = findRun(state, event.runId) + const current = interactionFor(run, event.interactionId) + if (current.status !== 'responding') { + if (isTerminalInteraction(current.status)) { + if (sameResponseResult(current, event)) { + return { + ...state, + ...base, + ...(event.outcome === 'unknown' + ? { lastError: event.detail ?? 'Interaction response is unknown' } + : {}), + } + } + throw interactionInvariant( + `Interaction ${event.interactionId} has a different response result`, + ) + } + throw interactionInvariant( + `Interaction ${event.interactionId} cannot be resolved from ${current.status}`, + ) + } + const responseOperation = current.responseOperation + if (responseOperation === undefined) + throw interactionInvariant( + `Interaction ${event.interactionId} has no response operation to resolve`, + ) + assertResponseMatches(responseOperation, event) + const nextStatus = interactionStatusForOutcome(event.outcome) + const next: BraidInteraction = { + ...current, + status: nextStatus, + responseOperation: responseOperationForResult(responseOperation, event), + } return { ...state, ...base, @@ -120,20 +200,10 @@ export function reduceInteractionEvent( : state.lastError, runs: updateRun(state, event.runId, (run) => ({ ...run, - interactions: run.interactions.map((item) => - item.request.id === event.interactionId - ? withoutResponseOperation( - item, - event.outcome === 'accepted' - ? 'resolved' - : event.outcome === 'unknown' - ? 'unknown' - : event.outcome, - ) - : item, - ), + ...withPendingInteractionIndex(run, replaceInteraction(run, event.interactionId, next)), })), } + } case 'run.status.changed': { const current = findRun(state, event.runId) assertTerminalTransition(current.status, event.status) @@ -161,7 +231,10 @@ export function reduceInteractionEvent( runs: updateRun(state, event.runId, (run) => ({ ...withProviderProgress(run, event.provider), ...(event.envelope.event.type === 'session.updated' - ? { providerSessionId: event.envelope.event.sessionId } + ? { + harnessSessionId: + safePublicIdentifier(event.envelope.event.sessionId) ?? run.harnessSessionId, + } : {}), eventDetails: [ ...run.eventDetails, @@ -197,6 +270,132 @@ function withoutResponseOperation( return { ...rest, status } } +function interactionFor(run: BraidState['runs'][number], interactionId: string): BraidInteraction { + const interaction = run.interactions.find((item) => item.request.id === interactionId) + if (interaction === undefined) + throw interactionInvariant(`Interaction ${interactionId} is unknown`) + return interaction +} + +function replaceInteraction( + run: BraidState['runs'][number], + interactionId: string, + replacement: BraidInteraction, +): readonly BraidInteraction[] { + return run.interactions.map((item) => (item.request.id === interactionId ? replacement : item)) +} + +function sameInteractionEvent( + current: BraidInteraction, + request: BraidInteraction['request'], + responseBinding: BraidInteraction['responseBinding'], + source: BraidInteraction['source'], +): boolean { + return ( + canonicalDigest(current.request) === canonicalDigest(request) && + canonicalDigest(current.responseBinding) === canonicalDigest(responseBinding) && + canonicalDigest(current.source) === canonicalDigest(source) + ) +} + +function sameResponseRequest( + current: BraidInteraction, + event: Extract, +): boolean { + const response = current.responseOperation + return ( + response !== undefined && + response.operationId === createOperationId(event.operationId) && + response.outcome === event.outcome && + (response.dataDigest ?? undefined) === (event.dataDigest ?? undefined) && + response.containsSecret === event.containsSecret && + canonicalDigest(response.automationRule ?? null) === + canonicalDigest(event.automationRule ?? null) + ) +} + +function sameResponseResult( + current: BraidInteraction, + event: Extract, +): boolean { + const response = current.responseOperation + return ( + response !== undefined && + response.operationId === createOperationId(event.operationId) && + response.outcome === event.outcome && + (response.dataDigest ?? undefined) === (event.dataDigest ?? undefined) && + response.containsSecret === event.containsSecret && + interactionStatusForOutcome(event.outcome) === current.status + ) +} + +function assertResponseMatches( + response: NonNullable, + event: Extract, +): void { + if ( + response.operationId !== createOperationId(event.operationId) || + (event.outcome !== 'unknown' && response.outcome !== event.outcome) || + (response.dataDigest ?? undefined) !== (event.dataDigest ?? undefined) || + response.containsSecret !== event.containsSecret + ) { + throw interactionInvariant(`Interaction ${event.interactionId} has a different response result`) + } +} + +function responseOperationForResult( + response: NonNullable, + event: Extract, +): NonNullable { + const { dataDigest: _dataDigest, ...withoutDataDigest } = response + return { + ...withoutDataDigest, + outcome: event.outcome, + ...(event.dataDigest === undefined ? {} : { dataDigest: event.dataDigest }), + containsSecret: event.containsSecret, + } +} + +function interactionStatusForOutcome( + outcome: Extract['outcome'], +): BraidInteraction['status'] { + switch (outcome) { + case 'accepted': + return 'resolved' + case 'declined': + return 'declined' + case 'cancelled': + return 'cancelled' + case 'unknown': + return 'unknown' + default: { + const exhaustive: never = outcome + return exhaustive + } + } +} + +function isTerminalInteraction(status: BraidInteraction['status']): boolean { + return status !== 'pending' && status !== 'responding' +} + +function providerProgress( + state: BraidState, + run: BraidState['runs'][number], + provider: Extract['provider'], + base: ReducerBase, +): BraidState { + return { + ...state, + ...base, + runs: updateRun(state, run.id, (candidate) => withProviderProgress(candidate, provider)), + } +} + +function interactionInvariant(message: string): DomainInvariantError { + return new DomainInvariantError(message) +} + function reduceFinishedEvent( state: BraidState, event: Extract, diff --git a/src/domain/reducer-lifecycle.ts b/src/domain/reducer-lifecycle.ts index 8e167af..620d2e6 100644 --- a/src/domain/reducer-lifecycle.ts +++ b/src/domain/reducer-lifecycle.ts @@ -1,6 +1,12 @@ import type { RunRecord, TurnRecord } from './entities.js' import type { BraidEvent } from './events.js' -import { parseMessageId, parseRunId, parseTurnId } from './ids.js' +import { + parseDigestValue, + parseMessageId, + parseOperationId, + parseRunId, + parseTurnId, +} from './ids.js' import { createAdmissionReceipt } from './receipts.js' import { legacyMessage, legacyTextPart, upsert } from './reducer-helpers.js' import { attachRequestedRunToConversation } from './reducer-run-graph.js' @@ -44,11 +50,26 @@ export function reduceLifecycleEvent( switch (event.kind) { case 'run.requested': return reduceRequestedRun(state, event, base, occurredAt) - case 'run.control.requested': - findRun(state, event.runId) + case 'run.control.requested': { + const runId = parseRunId(event.runId) + findRun(state, runId) + const operationId = parseOperationId(event.operationId) + const existing = state.operations.find((operation) => operation.id === operationId) + const operation = + existing ?? + ({ + id: operationId, + kind: event.control === 'cancel' ? 'cancel-run' : 'custom', + requestDigest: parseDigestValue(event.digest), + status: 'pending', + target: { kind: 'run', id: runId }, + createdAt: occurredAt, + updatedAt: occurredAt, + } as const) return { ...state, ...base, + operations: upsert(state.operations, operation), runs: updateRun(state, event.runId, (candidate) => addActivity( { @@ -62,15 +83,44 @@ export function reduceLifecycleEvent( ), ), } - case 'run.control.acknowledged': + } + case 'run.control.acknowledged': { findRun(state, event.runId) + const operationId = parseOperationId(event.operationId) + const operation = state.operations.find((candidate) => candidate.id === operationId) + const status = + event.outcome === 'accepted' + ? 'acknowledged' + : event.outcome === 'already-applied' + ? 'terminal' + : event.outcome === 'rejected' + ? 'failed' + : 'unknown' return { ...state, ...base, + ...(operation === undefined + ? {} + : { + operations: upsert(state.operations, { + ...operation, + status, + result: { + control: event.control, + outcome: event.outcome, + ...(event.detail === undefined ? {} : { detail: event.detail }), + }, + updatedAt: occurredAt, + ...(event.outcome === 'accepted' || event.outcome === 'already-applied' + ? { acknowledgedAt: occurredAt } + : {}), + }), + }), runs: updateRun(state, event.runId, (run) => addActivity(run, activity(event, 'control.ack', event.control, event.outcome)), ), } + } case 'run.queue.added': return { ...state, diff --git a/src/domain/reducer-retained-admission.ts b/src/domain/reducer-retained-admission.ts new file mode 100644 index 0000000..c65e915 --- /dev/null +++ b/src/domain/reducer-retained-admission.ts @@ -0,0 +1,75 @@ +import { canonicalDigest } from './canonical.js' +import type { BraidEvent } from './events.js' +import { DomainInvariantError } from './invariants.js' +import { find, upsert } from './reducer-helpers.js' +import type { BraidState } from './state.js' + +type RetainedAdmissionEvent = Extract +type Admission = RetainedAdmissionEvent['admission'] + +function sameAdmission(left: Admission, right: Admission): boolean { + return canonicalDigest(left) === canonicalDigest(right) +} + +function assertEnvironmentMatchesDispatch( + environment: Extract, + dispatched: Extract, +): void { + const exact = dispatched.controlRef + if ( + environment.provider !== exact.provider || + environment.environmentId !== exact.environmentId || + environment.sessionId !== exact.sessionId || + environment.executionId !== exact.executionId || + environment.idempotencyKey !== dispatched.idempotencyKey || + environment.turnId !== dispatched.turnId + ) { + throw new DomainInvariantError('Retained dispatch conflicts with its environment admission') + } +} + +export function applyRetainedAdmission( + state: BraidState, + event: RetainedAdmissionEvent, + at: string, +): BraidState { + const run = find(state.runs, event.runId, 'Run') + const current = run.retainedAdmission + const next = structuredClone(event.admission) + + if (run.providerSessionId !== undefined) { + const sessionId = next.phase === 'environment' ? next.sessionId : next.controlRef.sessionId + if (sessionId !== run.providerSessionId) { + throw new DomainInvariantError( + `Retained admission for ${run.id} conflicts with its provider session`, + ) + } + } + + if (current?.phase === 'environment' && next.phase === 'dispatched') { + assertEnvironmentMatchesDispatch(current, next) + } else if (current?.phase === 'dispatched' && next.phase === 'environment') { + assertEnvironmentMatchesDispatch(next, current) + return state + } else if (current !== undefined) { + if (!sameAdmission(current, next)) { + throw new DomainInvariantError(`Retained admission for ${run.id} changed after persistence`) + } + return state + } else if (next.phase === 'dispatched') { + throw new DomainInvariantError( + `Retained dispatch for ${run.id} has no durable environment admission`, + ) + } + + return { + ...state, + runs: upsert(state.runs, { + ...run, + providerSessionId: next.phase === 'environment' ? next.sessionId : next.controlRef.sessionId, + retainedAdmission: next, + ...(next.phase === 'dispatched' ? { controlRef: structuredClone(next.controlRef) } : {}), + updatedAt: at, + }), + } +} diff --git a/src/domain/reducer-runtime.ts b/src/domain/reducer-runtime.ts index e9c171f..39dd5bc 100644 --- a/src/domain/reducer-runtime.ts +++ b/src/domain/reducer-runtime.ts @@ -1,6 +1,6 @@ import type { BraidEvent, BraidEventEnvelope } from './events.js' -import { applyExecutionObservation } from './reducer-execution-observation.js' import { reduceContentEvent } from './reducer-content.js' +import { applyExecutionObservation } from './reducer-execution-observation.js' import { reduceInteractionEvent } from './reducer-interactions.js' import { reduceLifecycleEvent } from './reducer-lifecycle.js' import type { BraidState } from './state.js' @@ -83,10 +83,11 @@ export function reduceRuntimeEvent(state: BraidState, envelope: BraidEventEnvelo } } })() - if (!('provider' in event) || !event.provider) return reduced + const timed = withRunTiming(reduced, event.runId, envelope.occurredAt) + if (!('provider' in event) || !event.provider) return timed const runId = event.runId const sequence = event.provider.providerSequence - const missingHistory = reduced.missingHistory.flatMap((range) => { + const missingHistory = timed.missingHistory.flatMap((range) => { const toSequence = range.toSequence ?? range.fromSequence if (range.runId !== runId || sequence < range.fromSequence) return [range] if (sequence > toSequence) return [range] @@ -99,9 +100,7 @@ export function reduceRuntimeEvent(state: BraidState, envelope: BraidEventEnvelo ] }) const next = - missingHistory.length === reduced.missingHistory.length - ? reduced - : { ...reduced, missingHistory } + missingHistory.length === timed.missingHistory.length ? timed : { ...timed, missingHistory } const incompleteRuns = new Set(missingHistory.map((range) => range.runId)) const completedRunIds = new Set( next.runs @@ -139,6 +138,32 @@ export function reduceRuntimeEvent(state: BraidState, envelope: BraidEventEnvelo } } +function withRunTiming(state: BraidState, runId: string, occurredAt: string): BraidState { + const run = state.runs.find((candidate) => candidate.id === runId) + if (run === undefined) return state + const terminal = [ + 'completed', + 'failed', + 'aborted', + 'cancelled', + 'blocked', + 'expired', + 'unknown', + ].includes(run.status) + return { + ...state, + runs: state.runs.map((candidate) => + candidate.id === runId + ? { + ...candidate, + updatedAt: occurredAt, + ...(terminal && candidate.terminalAt === undefined ? { terminalAt: occurredAt } : {}), + } + : candidate, + ), + } +} + export function isRuntimeEvent(event: BraidEvent): event is RuntimeEvent { switch (event.kind) { case 'run.requested': diff --git a/src/domain/reducer-support.ts b/src/domain/reducer-support.ts index b7864c8..d8bb257 100644 --- a/src/domain/reducer-support.ts +++ b/src/domain/reducer-support.ts @@ -14,6 +14,7 @@ import type { MessageStatus, RunStatus, } from './state.js' +import type { BraidInteraction } from './runtime-projection.js' export const TERMINAL_RUN_STATES: readonly RunStatus[] = [ 'completed', @@ -201,6 +202,35 @@ export function addActivity(run: BraidRun, item: BraidActivity): BraidRun { } } +/** + * Keep pending interaction identity separate from the bounded display list. + * An absent index on a truncated legacy run means pending state is unknown. + */ +export function withPendingInteractionIndex( + run: BraidRun, + interactions: readonly BraidInteraction[], +): BraidRun { + const pending = pendingInteractionIds(run, interactions) + return { + ...run, + interactions, + ...(pending === undefined ? {} : { pendingInteractionIds: pending }), + } +} + +function pendingInteractionIds( + run: BraidRun, + interactions: readonly BraidInteraction[], +): readonly string[] | undefined { + if (run.pendingInteractionIds === undefined && run.interactionsTruncated) return undefined + const values = new Set(run.pendingInteractionIds ?? []) + for (const interaction of interactions) { + if (interaction.status === 'pending') values.add(interaction.request.id) + else values.delete(interaction.request.id) + } + return [...values] +} + export function assertTerminalTransition(current: RunStatus, next: RunStatus): void { if (TERMINAL_RUN_STATES.includes(current) && current !== next) { throw new Error(`Run cannot transition from terminal ${current} to ${next}`) diff --git a/src/domain/run-contracts.ts b/src/domain/run-contracts.ts index 6cfbe66..eb30fb7 100644 --- a/src/domain/run-contracts.ts +++ b/src/domain/run-contracts.ts @@ -1,4 +1,32 @@ -import type { AgentEnvironmentCapabilities, AgentProfile } from '@tangle-network/agent-interface' +import type { + AgentEnvironmentCapabilities, + AgentExactRunControlRef, + AgentProfile, +} from '@tangle-network/agent-interface' + +export interface RetainedRunEnvironmentAdmissionRecord { + readonly phase: 'environment' + readonly provider: string + readonly environmentId: string + readonly idempotencyKey: string + readonly turnId: string + readonly sessionId: string + readonly executionId: string +} + +export interface RetainedRunDispatchedAdmissionRecord { + readonly phase: 'dispatched' + readonly controlRef: AgentExactRunControlRef + readonly idempotencyKey: string + readonly turnId: string +} + +/** Product-owned recovery data that Runtime requires Braid to persist before proceeding. */ +export type RetainedRunAdmissionRecord = + | RetainedRunEnvironmentAdmissionRecord + | RetainedRunDispatchedAdmissionRecord + +export type RetainedRunAdmissionRecorder = (admission: RetainedRunAdmissionRecord) => Promise export interface RunControlCapabilities { readonly cancel: boolean diff --git a/src/domain/runtime-events.ts b/src/domain/runtime-events.ts index 36882a4..e08a14f 100644 --- a/src/domain/runtime-events.ts +++ b/src/domain/runtime-events.ts @@ -6,8 +6,16 @@ import type { import type { RuntimeStreamEvent } from '@tangle-network/agent-runtime' import type { ExecutionEnvironmentObservation } from './execution-observation.js' +type RuntimeFinalEvent = Extract + +/** Braid's retained adapters also project provider-confirmed cancellation. */ +export type BraidFinalRuntimeEvent = Omit & { + readonly status: RuntimeFinalEvent['status'] | 'cancelled' +} + export type BraidRuntimeEvent = - | RuntimeStreamEvent + | Exclude + | BraidFinalRuntimeEvent | StreamEvent | { readonly type: 'braid.execution.observed' @@ -58,9 +66,7 @@ export function isInteractionEvent( return event.type === 'interaction' && 'request' in event } -export function isFinalRuntimeEvent( - event: BraidRuntimeEvent, -): event is Extract { +export function isFinalRuntimeEvent(event: BraidRuntimeEvent): event is BraidFinalRuntimeEvent { return event.type === 'final' } diff --git a/src/domain/runtime-projection.ts b/src/domain/runtime-projection.ts index aab2879..3882dae 100644 --- a/src/domain/runtime-projection.ts +++ b/src/domain/runtime-projection.ts @@ -4,8 +4,12 @@ import type { InteractionRequest, } from '@tangle-network/agent-interface' import type { AutomationRuleRecord } from './entities-runtime.js' -import type { Digest, OperationId } from './ids.js' -import type { RunAdmissionReceipt, RunCapabilities } from './run-contracts.js' +import type { Digest, InteractionId, OperationId } from './ids.js' +import type { + RetainedRunAdmissionRecord, + RunAdmissionReceipt, + RunCapabilities, +} from './run-contracts.js' import type { RuntimeEventSummary } from './runtime-events.js' export interface MessagePartSource { @@ -59,7 +63,7 @@ export interface BraidInteraction { readonly status: 'pending' | 'responding' | 'declined' | 'cancelled' | 'resolved' | 'unknown' readonly responseOperation?: { readonly operationId: OperationId - readonly outcome: 'accepted' | 'declined' | 'cancelled' + readonly outcome: 'accepted' | 'declined' | 'cancelled' | 'unknown' readonly dataDigest?: Digest readonly containsSecret: boolean readonly automationRule?: AutomationRuleRecord @@ -86,6 +90,7 @@ export interface RuntimeRunFields { readonly receipt: RunAdmissionReceipt readonly capabilities: RunCapabilities readonly controlRef?: AgentExactRunControlRef + readonly retainedAdmission?: RetainedRunAdmissionRecord readonly reasoningTokens?: number readonly terminalReason?: string readonly lastCursor?: string @@ -97,6 +102,11 @@ export interface RuntimeRunFields { readonly interactions: readonly BraidInteraction[] readonly activity: readonly BraidActivity[] readonly eventDetails: readonly RuntimeEventSummary[] + /** + * Durable pending identities remain complete when the visible interaction + * history is bounded and older records are evicted. + */ + readonly pendingInteractionIds?: readonly InteractionId[] readonly activityTruncated?: boolean readonly eventDetailsTruncated?: boolean readonly interactionsTruncated?: boolean diff --git a/src/domain/state.ts b/src/domain/state.ts index ecfff14..8df67be 100644 --- a/src/domain/state.ts +++ b/src/domain/state.ts @@ -18,7 +18,6 @@ import type { FeedbackDecisionRecord, GraphEdgeRecord, GraphNodeRecord, - InteractionRecord, MessagePartRecord, MissingHistoryRange, OperationRecord, @@ -91,7 +90,6 @@ export interface BraidState { readonly conversations: readonly ConversationRecord[] readonly branches: readonly BranchRecord[] readonly turns: readonly TurnRecord[] - readonly interactions: readonly InteractionRecord[] readonly analyses: readonly AnalysisRecord[] readonly analysisAttachments: readonly AnalysisAttachmentRecord[] readonly environments: readonly EnvironmentRecord[] @@ -147,7 +145,6 @@ export function initialState( conversations: [], branches: [], turns: [], - interactions: [], analyses: [], analysisAttachments: [], environments: [], diff --git a/src/index.ts b/src/index.ts index aadc170..7369d09 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,7 +17,6 @@ export { STARTER_PROFILE, } from './app/composition.js' export { MemoryJournal } from './app/journal.js' -export { buildAppView, type AppView, type MessageView } from './app/view-model.js' export { EffectCoordinator, EffectCoordinatorError, @@ -65,7 +64,6 @@ export { assertBraidState, assertIdKind, assertJsonValue, - assertNoSecretInteractionData, DomainInvariantError, } from './domain/invariants.js' export type { diff --git a/src/ports/execution.ts b/src/ports/execution.ts index 3fbed70..2280d36 100644 --- a/src/ports/execution.ts +++ b/src/ports/execution.ts @@ -4,7 +4,11 @@ import type { AgentProfile, InteractionResponseCommand, } from '@tangle-network/agent-interface' -import type { RunCapabilities } from '../domain/receipts.js' +import type { + RetainedRunAdmissionRecord, + RetainedRunAdmissionRecorder, + RunCapabilities, +} from '../domain/run-contracts.js' import type { BraidRuntimeEvent, RuntimeEventEnvelope } from '../domain/runtime-events.js' import type { TurnUsage } from '../domain/entities.js' import type { RunStatus } from '../domain/state.js' @@ -23,8 +27,11 @@ export interface ExecuteTurnInput { readonly after?: string readonly afterSequence?: number readonly contextBoundary?: string + readonly onRetainedAdmission?: RetainedRunAdmissionRecorder } +export type { RetainedRunAdmissionRecord, RetainedRunAdmissionRecorder } + export interface ExecutionAdmission { readonly capabilities?: RunCapabilities readonly provider?: string diff --git a/src/views/headless/rpc.ts b/src/views/headless/rpc.ts index 2e31407..bd97555 100644 --- a/src/views/headless/rpc.ts +++ b/src/views/headless/rpc.ts @@ -328,6 +328,9 @@ export async function runRpc( await respond(errorResponse(result, request.requestId)) break } + const admissionState = result.completion + ? stateResponse(controller, request.requestId) + : undefined await respond({ version: BRAID_PROTOCOL_VERSION, type: 'ack', @@ -342,6 +345,7 @@ export async function runRpc( }) for (const event of bufferedEvents) await respond(eventResponse(event)) bufferedEvents = undefined + if (admissionState) await respond(admissionState) if (result.completion) { let tracked: Promise tracked = result.completion.finally(() => pendingCompletions.delete(tracked)) diff --git a/src/views/shared/command-registry.ts b/src/views/shared/command-registry.ts index 82f3ceb..94d863f 100644 --- a/src/views/shared/command-registry.ts +++ b/src/views/shared/command-registry.ts @@ -63,7 +63,11 @@ const INTENTS_FOR: Readonly> = { branch: runCommandIntent('branch'), clone: runCommandIntent('clone'), fork: runCommandIntent('fork'), - graph: () => ({ type: 'open-surface', surface: 'graph' }), + graph: (args) => ({ + type: 'open-surface', + surface: 'graph', + ...(args.length === 0 ? {} : { query: args.join(' ') }), + }), ask: runCommandIntent('ask'), analyze: runCommandIntent('analyze'), compare: runCommandIntent('compare'), @@ -110,12 +114,12 @@ const DEFINITIONS: readonly CommandDefinition[] = [ 'connection.select', 'explicit', ], - ['runner', [], 'Set a runner for the next run', '/runner [name]', 'run.runner', 'explicit'], - ['model', [], 'Set a model for the next run', '/model [name]', 'run.model', 'explicit'], + ['runner', [], 'Set the runner for this branch', '/runner [name]', 'run.runner', 'explicit'], + ['model', [], 'Set the model for this branch', '/model [name]', 'run.model', 'explicit'], [ 'effort', [], - 'Set reasoning effort for the next run', + 'Set reasoning effort for this branch', '/effort [level]', 'run.effort', 'explicit', @@ -144,7 +148,7 @@ const DEFINITIONS: readonly CommandDefinition[] = [ 'conversation.fork', 'explicit', ], - ['graph', [], 'Open the conversation and run graph', '/graph', 'graph.read', 'none'], + ['graph', [], 'Open the conversation and run graph', '/graph [query]', 'graph.read', 'none'], ['ask', [], 'Analyze a frozen run with citations', '/ask ', 'analysis.ask', 'explicit'], [ 'analyze', diff --git a/src/views/shared/models.ts b/src/views/shared/models.ts index cabe29c..cb2b6d8 100644 --- a/src/views/shared/models.ts +++ b/src/views/shared/models.ts @@ -1,3 +1,4 @@ +import type { AgentExactRunControlRef } from '@tangle-network/agent-interface' import type { ForkPlan } from '../../app/conversation-types.js' export type ViewStatus = @@ -192,6 +193,8 @@ export interface EnvironmentView { export interface RunView { readonly id: string readonly turnId?: string + readonly conversationId?: string + readonly branchId?: string readonly status: ViewStatus readonly operationId?: string readonly profileName?: string @@ -203,6 +206,7 @@ export interface RunView { readonly error?: string readonly cursor?: string readonly providerSessionId?: string + readonly harnessSessionId?: string readonly requestedSessionId?: string readonly environmentId?: string readonly runner?: string @@ -373,6 +377,7 @@ export interface GraphNodeView { readonly status: ViewStatus | 'complete' readonly depth: number readonly edgeLabel?: string + readonly parentIds?: readonly string[] readonly runner?: string readonly elapsedMs?: number readonly startedAt?: string @@ -535,6 +540,12 @@ export interface BraidViewModel { readonly runner: string readonly model: string readonly effort?: string + readonly runOverrides?: { + readonly runner?: string + readonly model?: string + readonly effort?: string + readonly mode?: string + } readonly maxOutputTokens?: number readonly connection: string readonly conversationId: string @@ -561,6 +572,7 @@ export interface BraidViewModel { readonly interactions: readonly InteractionView[] readonly activity: readonly ActivityItemView[] readonly graph: readonly GraphNodeView[] + readonly graphQuery?: string readonly hiddenGraphNodeCount?: number readonly entityDetails?: readonly EntityDetailView[] readonly details?: DetailsView @@ -611,6 +623,20 @@ export interface HeadlessState { readonly updatedAt: string }[] readonly profile: Readonly> + readonly runConfiguration: { + readonly profileName: string + readonly runner: string + readonly model: string + readonly effort?: string + readonly mode?: string + readonly connectionId?: string + readonly overrides: { + readonly runner?: string + readonly model?: string + readonly effort?: string + readonly mode?: string + } + } readonly draft: string readonly messages: readonly { readonly id: string @@ -626,8 +652,14 @@ export interface HeadlessState { readonly turnId: string readonly operationId: string readonly status: string + readonly startedAt: string + readonly updatedAt: string + readonly terminalAt?: string + readonly durationMs?: number readonly inputTokens: number readonly outputTokens: number + readonly tokensKnown: boolean + readonly usdKnown: boolean readonly tokenStatus: NonNullable readonly reasoningTokens?: number readonly costUsd?: number @@ -644,9 +676,12 @@ export interface HeadlessState { readonly error?: string readonly completeness: RunView['completeness'] readonly providerSessionId?: string + readonly harnessSessionId?: string readonly environmentId?: string + readonly controlRef?: AgentExactRunControlRef readonly materializationDigest?: string readonly cursor?: string + readonly cursorCommittedSequence?: number readonly contentBytes?: number readonly contentTruncated?: boolean readonly activityTruncated?: boolean diff --git a/src/views/shared/semantic-activity.ts b/src/views/shared/semantic-activity.ts index a83e3f1..b1c3add 100644 --- a/src/views/shared/semantic-activity.ts +++ b/src/views/shared/semantic-activity.ts @@ -54,11 +54,7 @@ function includeRun( return (runId === undefined || run.id === runId) && isInScope(state, 'run', run.id, scope) } -function activityForRun( - state: BraidState, - run: BraidState['runs'][number], - output: SemanticActivityItem[], -): void { +function activityForRun(run: BraidState['runs'][number], output: SemanticActivityItem[]): void { const runElapsedMs = elapsedMs(run.startedAt, run.terminalAt) add(output, { id: `run:${run.id}`, @@ -75,6 +71,7 @@ function activityForRun( }) for (const item of run.activity) { + if (item.type === 'session.updated') continue const occurredAt = item.source?.occurredAt ?? run.updatedAt add(output, { id: `activity:${item.id}`, @@ -91,6 +88,7 @@ function activityForRun( } for (const event of run.eventDetails) { + if (event.type === 'session.updated') continue add(output, { id: `event:${run.id}:${event.eventId}`, kind: 'system', @@ -105,25 +103,8 @@ function activityForRun( }) } - const seenInteractionIds = new Set() - for (const interaction of state.interactions) { - if (interaction.runId !== run.id) continue - seenInteractionIds.add(interaction.id) - add(output, { - id: `interaction:${interaction.id}`, - kind: 'interaction', - title: safeTitle(interaction.request.title), - status: interaction.status, - occurredAt: interaction.createdAt, - ...(interaction.request.body === undefined ? {} : { detail: safe(interaction.request.body) }), - runId: run.id, - entityType: 'run', - entityId: run.id, - }) - } for (const interaction of run.interactions) { const id = interaction.request.id - if (seenInteractionIds.has(id)) continue add(output, { id: `interaction:${id}`, kind: 'interaction', @@ -235,7 +216,7 @@ export function queryActivity( if (input.runId !== undefined) assertRunScope(state, input.runId, scope) const output: SemanticActivityItem[] = [] for (const run of state.runs) { - if (includeRun(state, run, scope, input.runId)) activityForRun(state, run, output) + if (includeRun(state, run, scope, input.runId)) activityForRun(run, output) } for (const analysis of state.analyses) { if ( diff --git a/src/views/shared/semantic-details.ts b/src/views/shared/semantic-details.ts index 8668b13..bbeb26d 100644 --- a/src/views/shared/semantic-details.ts +++ b/src/views/shared/semantic-details.ts @@ -173,6 +173,9 @@ function dataFor( ...(run.providerSessionId === undefined ? {} : { providerSessionId: run.providerSessionId }), + ...(run.harnessSessionId === undefined + ? {} + : { harnessSessionId: safe(run.harnessSessionId) }), ...(run.environmentId === undefined ? {} : { environmentId: run.environmentId }), ...(run.bindingId === undefined ? {} : { bindingId: run.bindingId }), ...(run.receiptId === undefined ? {} : { receiptId: run.receiptId }), diff --git a/src/views/shared/semantic-projection.ts b/src/views/shared/semantic-projection.ts index d886195..aac1bb5 100644 --- a/src/views/shared/semantic-projection.ts +++ b/src/views/shared/semantic-projection.ts @@ -33,7 +33,11 @@ export function projectSemanticEvent( ): Readonly> { const event = envelope.event const source = sourceOf(event) - const base = source === undefined ? {} : { source: safe(source) } + const runId = 'runId' in event && typeof event.runId === 'string' ? event.runId : undefined + const base = { + ...(runId === undefined ? {} : { runId }), + ...(source === undefined ? {} : { source: safe(source) }), + } switch (event.kind) { case 'run.text.delta': { const value = text(event.text) ?? '' @@ -160,7 +164,16 @@ export function projectSemanticEvent( interaction: safe({ id: event.interactionId, status: 'cancelled', reason: event.reason }), }) case 'run.provider.event': - return withSource(event, { ...base, unknown: safe(event.envelope.event) }) + return event.envelope.event.type === 'session.updated' + ? withSource(event, { + ...base, + harnessSession: safe({ + id: event.envelope.event.sessionId, + title: event.envelope.event.title, + time: event.envelope.event.time, + }), + }) + : withSource(event, { ...base, unknown: safe(event.envelope.event) }) case 'run.queue.added': return { ...base, diff --git a/src/views/tui/activity-browser.ts b/src/views/tui/activity-browser.ts index fd5ade3..531e97d 100644 --- a/src/views/tui/activity-browser.ts +++ b/src/views/tui/activity-browser.ts @@ -2,13 +2,13 @@ import { matchesKey } from '@earendil-works/pi-tui' import { effectiveElapsedMs, formatDuration } from '../shared/duration.js' import type { BraidViewModel, EntityDetailView, RunView } from '../shared/models.js' import { sanitizeTerminalText } from '../shared/sanitize.js' -import { projectActivityDocument, type ActivityDocumentItem } from './activity-document.js' -import { executionTargetFor } from './execution-target.js' +import { type ActivityDocumentItem, projectActivityDocument } from './activity-document.js' import { EntityBrowser, type EntityBrowserDocument, type EntityBrowserRow, } from './entity-browser.js' +import { executionTargetFor, executionTargetForEntity } from './execution-target.js' import { metricsFor } from './terminal-usage.js' import type { BraidTheme } from './theme.js' @@ -34,13 +34,14 @@ export class ActivityBrowserPanel extends EntityBrowser { constructor(theme: BraidTheme, options: ActivityBrowserOptions) { const scopeState: { scope: ActivityBrowserScope } = { scope: options.scope ?? 'all' } super(theme, { - document: () => + document: (selectedId) => activityDocument( options.view(), scopeState.scope, options.notice?.(), options.emptyMessage, options.pinned, + selectedId, ), rows: options.rows, onClose: options.onClose, @@ -67,6 +68,7 @@ export function activityDocument( notice?: string, emptyMessage?: string, pinned?: string, + selectedId?: string, ): EntityBrowserDocument { const details = new Map( (view.entityDetails ?? []).map((detail) => [detailKey(detail), detail] as const), @@ -76,10 +78,16 @@ export function activityDocument( .items.filter((item) => included(item, scope)) .slice() .reverse() - const target = executionTargetFor(view) + const selected = items.find((item) => item.id === selectedId) + const target = + selected?.runId !== undefined + ? executionTargetFor(view, selected.runId) + : selected?.source?.entityType !== undefined && selected.source.entityId !== undefined + ? executionTargetForEntity(view, selected.source.entityType, selected.source.entityId) + : executionTargetFor(view) const usage = metricsFor(view) return { - title: scope === 'all' ? 'activity' : `activity · ${scope}`, + title: scope === 'all' ? 'activity' : scope, context: [target.profileName, target.runner, target.model, ...usage].join(' · '), filterHint: `tab filter: ${scope}`, ...(pinned === undefined ? {} : { pinned }), @@ -166,9 +174,6 @@ function runContext( ...(run.environmentId === undefined ? [] : [`execution environment: ${sanitizeTerminalText(run.environmentId)}`]), - ...(run.providerSessionId === undefined - ? [] - : [`provider session: ${sanitizeTerminalText(run.providerSessionId)}`]), `model: ${sanitizeTerminalText(target.model)}`, ...(target.effort === undefined ? [] : [`thinking: ${sanitizeTerminalText(target.effort)}`]), ...(target.maxOutputTokens === undefined diff --git a/src/views/tui/activity.ts b/src/views/tui/activity.ts index f0cee4b..5d14376 100644 --- a/src/views/tui/activity.ts +++ b/src/views/tui/activity.ts @@ -1,7 +1,7 @@ -import { Container, Spacer, Text, TruncatedText } from '@earendil-works/pi-tui' +import { Container, Text, TruncatedText } from '@earendil-works/pi-tui' import type { BraidViewModel } from '../shared/models.js' import { sanitizeTerminalText } from '../shared/sanitize.js' -import { projectActivityDocument, type ActivityDocumentItem } from './activity-document.js' +import { type ActivityDocumentItem, projectActivityDocument } from './activity-document.js' import type { BraidTheme } from './theme.js' export class ActivityView extends Container { @@ -16,13 +16,7 @@ export class ActivityView extends Container { const items = projectActivityDocument(view).items.filter(isLiveWork) this.clear() this.addChild(new Text(this.#theme.brand('live work'), 1, 0)) - if (items.length === 0) { - this.addChild(new Spacer(1)) - this.addChild(new Text(this.#theme.muted('No live work.'), 1, 0)) - } else { - this.addChild(new Spacer(1)) - for (const item of items) this.addChild(this.#item(item)) - } + for (const item of items) this.addChild(this.#item(item)) this.invalidate() } @@ -38,6 +32,13 @@ export class ActivityView extends Container { } } +export function hasLiveWork(view: BraidViewModel): boolean { + return ( + view.activeRunId !== undefined || + projectActivityDocument(view).items.some((item) => isLiveWork(item)) + ) +} + const LIVE_WORK_STATUSES = new Set([ 'loading', 'starting', diff --git a/src/views/tui/entity-browser.ts b/src/views/tui/entity-browser.ts index 627b32e..55dd924 100644 --- a/src/views/tui/entity-browser.ts +++ b/src/views/tui/entity-browser.ts @@ -7,6 +7,7 @@ import { wrapTextWithAnsi, } from '@earendil-works/pi-tui' import { sanitizeTerminalText } from '../shared/sanitize.js' +import { omitUnreportedCostAndLatency } from './measurement-display.js' import type { ModalBackTarget } from './modal-coordinator.js' import type { BraidTheme } from './theme.js' @@ -32,7 +33,7 @@ export interface EntityBrowserDocument { } export interface EntityBrowserOptions { - readonly document: () => EntityBrowserDocument + readonly document: (selectedId?: string) => EntityBrowserDocument readonly rows: () => number readonly onClose: () => void readonly selectedId?: string @@ -44,7 +45,7 @@ type BrowserMode = 'list' | 'detail' /** One keyboard model for activity, analyses, workers, and graph entities. */ export class EntityBrowser extends Container implements Focusable, ModalBackTarget { readonly #theme: BraidTheme - readonly #document: () => EntityBrowserDocument + readonly #document: (selectedId?: string) => EntityBrowserDocument readonly #terminalRows: () => number readonly #onClose: () => void #selectedId: string | undefined @@ -176,7 +177,8 @@ export class EntityBrowser extends Container implements Focusable, ModalBackTarg } #resolveDocument(): EntityBrowserDocument { - const document = this.#document() + const previousSelectedId = this.#selectedId + let document = this.#document(previousSelectedId) if (document.rows.length === 0) { this.#selectedId = undefined this.#selectedIndex = 0 @@ -195,6 +197,7 @@ export class EntityBrowser extends Container implements Focusable, ModalBackTarg this.#selectedIndex = selected >= 0 ? selected : Math.min(this.#selectedIndex, document.rows.length - 1) this.#selectedId = document.rows[this.#selectedIndex]?.id + if (this.#selectedId !== previousSelectedId) document = this.#document(this.#selectedId) return document } @@ -278,7 +281,7 @@ export class EntityBrowser extends Container implements Focusable, ModalBackTarg const primary = (!detailVisible && this.#mode === 'list') || selected === undefined ? this.#theme.brand(`${title} · ${document.rows.length}`) - : `${this.#theme.brand(title)} ${this.#theme.muted('›')} ${this.#theme.accent(sanitizeTerminalText(selected.title))} ${this.#theme.muted(`· ${selected.status} · ${this.#selectedIndex + 1}/${document.rows.length}`)}` + : `${this.#theme.brand(title)} ${this.#theme.muted('›')} ${this.#theme.accent(sanitizeTerminalText(selected.title))}` const context = document.context === undefined ? undefined @@ -349,7 +352,7 @@ export class EntityBrowser extends Container implements Focusable, ModalBackTarg #detailVisualLines(selected: EntityBrowserRow, width: number): string[] { const lineWidth = Math.max(1, width - 1) - return selected.detailLines.flatMap((value) => { + return omitUnreportedCostAndLatency(selected.detailLines).flatMap((value) => { const safe = sanitizeTerminalText(value) const styled = safe.startsWith('── ') ? this.#theme.accent(safe) diff --git a/src/views/tui/execution-target.ts b/src/views/tui/execution-target.ts index 2d1489f..0b28e49 100644 --- a/src/views/tui/execution-target.ts +++ b/src/views/tui/execution-target.ts @@ -1,4 +1,4 @@ -import type { BraidViewModel, EnvironmentView, RunView } from '../shared/models.js' +import type { BraidViewModel, EnvironmentView, GraphNodeType, RunView } from '../shared/models.js' export interface ExecutionTargetView { readonly source: 'profile' | 'run' @@ -24,6 +24,72 @@ export function executionTargetFor( return run === undefined ? profileTarget(view) : runTarget(view, run) } +/** Resolves the run behind a historical graph or activity entity. */ +export function executionTargetForEntity( + view: BraidViewModel, + type: GraphNodeType, + id: string, +): ExecutionTargetView { + if (type === 'run') return executionTargetFor(view, id) + + const activityRunId = newestActivityRunId(view, type, id) + if (activityRunId !== undefined) return executionTargetFor(view, activityRunId) + + const matchedRun = newestRun(view.runs, (run) => { + if (type === 'turn') return run.turnId === id + if (type === 'branch') return run.branchId === id + if (type === 'conversation') return run.conversationId === id + if (type === 'environment') return run.environmentId === id + return false + }) + if (matchedRun !== undefined) return executionTargetFor(view, matchedRun.id) + + const graphRunId = graphAncestorRunId(view, `${type}:${id}`) + return graphRunId === undefined ? executionTargetFor(view) : executionTargetFor(view, graphRunId) +} + +function newestActivityRunId( + view: BraidViewModel, + type: GraphNodeType, + id: string, +): string | undefined { + for (let index = view.activity.length - 1; index >= 0; index -= 1) { + const item = view.activity[index] + if (item?.entityType === type && item.entityId === id && item.runId !== undefined) { + return item.runId + } + } + return undefined +} + +function newestRun( + runs: readonly RunView[], + matches: (run: RunView) => boolean, +): RunView | undefined { + for (let index = runs.length - 1; index >= 0; index -= 1) { + const run = runs[index] + if (run !== undefined && matches(run)) return run + } + return undefined +} + +function graphAncestorRunId(view: BraidViewModel, start: string): string | undefined { + const nodes = new Map( + view.graph.map((node) => [`${node.type}:${node.id}`, node]), + ) + const queue = [start] + const seen = new Set() + while (queue.length > 0 && seen.size < 256) { + const key = queue.shift() + if (key === undefined || seen.has(key)) continue + seen.add(key) + const node = nodes.get(key) + if (node?.type === 'run') return node.id + for (const parentId of node?.parentIds ?? []) queue.push(parentId) + } + return undefined +} + function profileTarget(view: BraidViewModel): ExecutionTargetView { return Object.freeze({ source: 'profile', diff --git a/src/views/tui/graph.ts b/src/views/tui/graph.ts index 3326413..7727f80 100644 --- a/src/views/tui/graph.ts +++ b/src/views/tui/graph.ts @@ -1,11 +1,13 @@ -import type { BraidViewModel, EntityDetailView, GraphNodeView } from '../shared/models.js' +import { matchesKey } from '@earendil-works/pi-tui' import { effectiveElapsedMs, formatDuration } from '../shared/duration.js' +import type { BraidViewModel, EntityDetailView, GraphNodeView } from '../shared/models.js' import { sanitizeTerminalText } from '../shared/sanitize.js' import { EntityBrowser, type EntityBrowserDocument, type EntityBrowserRow, } from './entity-browser.js' +import { executionTargetFor, executionTargetForEntity } from './execution-target.js' import type { BraidTheme } from './theme.js' export interface GraphViewOptions { @@ -18,13 +20,24 @@ export interface GraphViewOptions { export class GraphView extends EntityBrowser { readonly #setStaticView: (view: BraidViewModel) => void + readonly #view: () => BraidViewModel | undefined + readonly #collapsed: Set + readonly #localNotice: { value: string | undefined } constructor(theme: BraidTheme, options: GraphViewOptions = {}) { let staticView: BraidViewModel | undefined + const collapsed = new Set() + const localNotice: { value: string | undefined } = { value: undefined } + const currentView = (): BraidViewModel | undefined => options.view?.() ?? staticView super(theme, { - document: () => { - const view = options.view?.() ?? staticView - return view === undefined ? emptyGraphDocument() : graphDocument(view, options.notice?.()) + document: (selectedId) => { + const view = currentView() + const notice = [options.notice?.(), localNotice.value] + .filter((value): value is string => value !== undefined) + .join(' · ') + return view === undefined + ? emptyGraphDocument() + : graphDocument(view, notice || undefined, collapsed, selectedId) }, rows: options.rows ?? (() => 12), onClose: options.onClose ?? (() => {}), @@ -33,15 +46,85 @@ export class GraphView extends EntityBrowser { this.#setStaticView = (view) => { staticView = view } + this.#view = currentView + this.#collapsed = collapsed + this.#localNotice = localNotice } setView(view: BraidViewModel): void { this.#setStaticView(view) this.selectId(`branch:${view.branch}`) } + + override handleInput(data: string): void { + if (matchesKey(data, 'space') || data === ' ') { + this.#toggleSelected() + return + } + if (data.toLowerCase() === 'w') { + this.#selectWaitingRun() + return + } + super.handleInput(data) + } + + #toggleSelected(): void { + const view = this.#view() + const selectedId = this.selectedId + if (view === undefined || selectedId === undefined) return + const hasChildren = view.graph.some((node) => node.parentIds?.includes(selectedId) === true) + if (!hasChildren) { + this.#localNotice.value = 'The selected item has no descendants.' + this.invalidate() + return + } + if (this.#collapsed.has(selectedId)) this.#collapsed.delete(selectedId) + else this.#collapsed.add(selectedId) + this.#localNotice.value = undefined + this.invalidate() + } + + #selectWaitingRun(): void { + const view = this.#view() + if (view === undefined) return + const visible = visibleGraphNodes(view.graph, this.#collapsed) + const visibleIds = new Set(visible.map((node) => `${node.type}:${node.id}`)) + const interactionRun = view.interactions.find((interaction) => + visibleIds.has(`run:${interaction.runId}`), + )?.runId + const waitingRun = visible.find((node) => node.type === 'run' && node.status === 'waiting')?.id + const runId = interactionRun ?? waitingRun + if (runId !== undefined) { + this.#localNotice.value = undefined + this.selectId(`run:${runId}`) + return + } + + const pendingRunId = view.interactions[0]?.runId + const pendingIsFiltered = + pendingRunId !== undefined && + !view.graph.some((node) => node.type === 'run' && node.id === pendingRunId) + const hiddenWaiting = view.graph.some( + (node) => + node.type === 'run' && + (node.status === 'waiting' || node.id === pendingRunId) && + !visibleIds.has(`run:${node.id}`), + ) + this.#localNotice.value = pendingIsFiltered + ? 'A waiting run is hidden by the current graph query.' + : hiddenWaiting + ? 'A waiting run is inside a collapsed item.' + : 'No run is waiting for input.' + this.invalidate() + } } -export function graphDocument(view: BraidViewModel, notice?: string): EntityBrowserDocument { +export function graphDocument( + view: BraidViewModel, + notice?: string, + collapsed: ReadonlySet = new Set(), + selectedId?: string, +): EntityBrowserDocument { const details = new Map( (view.entityDetails ?? []).map((detail) => [detailKey(detail), detail] as const), ) @@ -50,19 +133,60 @@ export function graphDocument(view: BraidViewModel, notice?: string): EntityBrow ? undefined : `${view.hiddenGraphNodeCount} older graph nodes omitted; use get_graph for complete history` const combinedNotice = [notice, hidden].filter((value): value is string => value !== undefined) + const selectedNode = view.graph.find((node) => `${node.type}:${node.id}` === selectedId) + const target = + selectedNode === undefined + ? executionTargetFor(view) + : executionTargetForEntity(view, selectedNode.type, selectedNode.id) + const visible = visibleGraphNodes(view.graph, collapsed) + const parentIds = new Set(view.graph.flatMap((node) => node.parentIds ?? [])) + const graphQuery = view.graphQuery?.trim() return { title: 'conversation graph', - context: `${view.profileName} · ${view.runner} · ${view.model}`, + context: `${target.profileName} · ${target.runner} · ${target.model}`, ...(combinedNotice.length === 0 ? {} : { notice: combinedNotice.join(' · ') }), + filterHint: `${graphQuery ? `filter: ${graphQuery} · ` : ''}space collapse · w waiting`, emptyMessage: 'The first run creates the first graph node.', - rows: view.graph.map((node) => rowFor(node, view.branch, details)), + rows: visible.map((node) => + rowFor(node, view.branch, details, collapsed, parentIds.has(`${node.type}:${node.id}`)), + ), + } +} + +export function visibleGraphNodes( + nodes: readonly GraphNodeView[], + collapsed: ReadonlySet, +): readonly GraphNodeView[] { + if (collapsed.size === 0) return nodes + const children = new Map() + for (const node of nodes) { + const key = `${node.type}:${node.id}` + for (const parentId of node.parentIds ?? []) { + const values = children.get(parentId) ?? [] + values.push(key) + children.set(parentId, values) + } + } + const hidden = new Set() + const queue = [...collapsed] + for (let cursor = 0; cursor < queue.length && cursor < 1024; cursor += 1) { + const parentId = queue[cursor] + if (parentId === undefined) continue + for (const childId of children.get(parentId) ?? []) { + if (hidden.has(childId)) continue + hidden.add(childId) + queue.push(childId) + } } + return nodes.filter((node) => !hidden.has(`${node.type}:${node.id}`)) } function rowFor( node: GraphNodeView, currentBranch: string, details: ReadonlyMap, + collapsed: ReadonlySet, + hasChildren: boolean, ): EntityBrowserRow { const detail = details.get(`${node.type}:${node.id}`) const edge = displayEdge(node) @@ -76,7 +200,7 @@ function rowFor( return { id: `${node.type}:${node.id}`, kind: node.type, - title: displayTitle(node), + title: `${hasChildren ? (collapsed.has(`${node.type}:${node.id}`) ? '▸ ' : '▾ ') : ''}${displayTitle(node)}`, status: node.status, depth: node.depth, current: node.type === 'branch' && node.id === currentBranch, diff --git a/src/views/tui/help.ts b/src/views/tui/help.ts index da7a5c3..59206b4 100644 --- a/src/views/tui/help.ts +++ b/src/views/tui/help.ts @@ -57,7 +57,7 @@ export class HelpViewPanel extends Container { this.addChild( new Text( this.#theme.muted( - 'Ctrl+P commands · Ctrl+O conversations · Ctrl+K profiles · Ctrl+G graph', + 'Ctrl+P commands · Ctrl+O conversations · Ctrl+K run setup · Ctrl+G graph', ), 1, 0, diff --git a/src/views/tui/interaction-presentation.ts b/src/views/tui/interaction-presentation.ts index 28ce726..1fbe577 100644 --- a/src/views/tui/interaction-presentation.ts +++ b/src/views/tui/interaction-presentation.ts @@ -95,26 +95,34 @@ export function isSecretInteraction(interaction: InteractionView): boolean { ) } +export function interactionPrompt(interaction: InteractionView): TruncatedText { + return new TruncatedText( + isSecretInteraction(interaction) + ? 'Secret response requested; value stays hidden.' + : sanitizeTerminalText(interaction.prompt), + 1, + 0, + ) +} + export function interactionHeading(interaction: InteractionView): string { const kind = sanitizeTerminalText(interaction.kind).toLocaleLowerCase() || 'interaction' - const heading = kind === 'plan' ? 'plan review' : kind - return interaction.queueTotal > 1 - ? `${heading} · request ${interaction.queuePosition + 1}/${interaction.queueTotal}` - : heading + const label = kind === 'plan' ? 'plan review' : kind + const heading = + interaction.queueTotal > 1 + ? `${label} · request ${interaction.queuePosition + 1}/${interaction.queueTotal}` + : label + return interaction.remainingMs === undefined + ? heading + : `${heading} · ${Math.max(0, Math.ceil(interaction.remainingMs / 1_000))}s left` } export function runContext(interaction: InteractionView): string { - const timeout = - interaction.remainingMs === undefined - ? 'timeout unknown' - : `timeout ${Math.max(0, Math.ceil(interaction.remainingMs / 1_000))}s` const requester = [interaction.profileName, interaction.runner] .map((value) => (value === undefined ? '' : sanitizeTerminalText(value))) .filter((value) => value.length > 0) .join(' @ ') - return [requester, `run ${shortIdentifier(interaction.runId)}`, timeout] - .filter(Boolean) - .join(' · ') + return [requester, `run ${shortIdentifier(interaction.runId)}`].filter(Boolean).join(' · ') } export function interactionSubjectComponents( diff --git a/src/views/tui/interaction.ts b/src/views/tui/interaction.ts index 0e02311..f03fb46 100644 --- a/src/views/tui/interaction.ts +++ b/src/views/tui/interaction.ts @@ -9,10 +9,9 @@ import { } from '@earendil-works/pi-tui' import type { InteractionResponseValue } from '../shared/intents.js' import type { InteractionOutcome, InteractionView } from '../shared/models.js' -import { sanitizeTerminalText } from '../shared/sanitize.js' +import { focusedSurfaceLines } from './focused-surface.js' import { automationResponseFor } from './interaction-automation-response.js' import { booleanDecisionResponse, InteractionDecisionList } from './interaction-decisions.js' -import { focusedSurfaceLines } from './focused-surface.js' import { interactionInputResponse } from './interaction-input-response.js' import { answerHelp, @@ -20,6 +19,7 @@ import { cancellationOutcome, interactionFooter, interactionHeading, + interactionPrompt, interactionSubjectComponents, isPositiveOutcome, isSecretInteraction, @@ -33,7 +33,7 @@ import type { BraidTheme } from './theme.js' export class InteractionShell extends Container implements Focusable { readonly #theme: BraidTheme - readonly #interaction: InteractionView + #interaction: InteractionView readonly #onRespond: (response: InteractionResponseValue) => void readonly #onAutomate: ((response: InteractionResponseValue) => void) | undefined readonly #input: Input @@ -72,13 +72,7 @@ export class InteractionShell extends Container implements Focusable { this.#input.onEscape = () => this.#cancel() const compactSelector = interaction.answerSpec.kind === 'select' - this.addChild( - this.#line( - isSecretInteraction(interaction) - ? 'Secret response requested; value stays hidden.' - : sanitizeTerminalText(interaction.prompt), - ), - ) + this.addChild(interactionPrompt(interaction)) for (const child of interactionSubjectComponents(interaction, theme, compactSelector)) this.addChild(child) if (interaction.answerSpec.kind === 'boolean') { @@ -117,7 +111,7 @@ export class InteractionShell extends Container implements Focusable { new OutcomeKeys(interaction.allowedOutcomes, theme, () => this.#selectedOutcome), ) } else { - this.addChild(this.#line(answerHelp(interaction))) + this.addChild(new TruncatedText(answerHelp(interaction), 1, 0)) this.addChild(this.#input) if (interaction.allowedOutcomes.length > 0) this.addChild( @@ -136,6 +130,11 @@ export class InteractionShell extends Container implements Focusable { if (this.#selector) this.#selector.focused = value } + setInteraction(interaction: InteractionView): void { + this.#interaction = interaction + this.invalidate() + } + handleInput(data: string): void { if (matchesKey(data, 'alt+a') && this.#onAutomate !== undefined) { if (isSecretInteraction(this.#interaction)) { @@ -156,7 +155,11 @@ export class InteractionShell extends Container implements Focusable { this.#onAutomate(response) return } - if (matchesKey(data, 'escape') || matchesKey(data, 'ctrl+c')) { + if ( + matchesKey(data, 'escape') || + matchesKey(data, 'ctrl+c') || + (matchesKey(data, 'left') && this.#decisions) + ) { this.#cancel() return } @@ -240,8 +243,4 @@ export class InteractionShell extends Container implements Focusable { } this.#respond({ outcome }) } - - #line(value: string): TruncatedText { - return new TruncatedText(value, 1, 0) - } } diff --git a/src/views/tui/keyboard.ts b/src/views/tui/keyboard.ts index cd53cf8..7546476 100644 --- a/src/views/tui/keyboard.ts +++ b/src/views/tui/keyboard.ts @@ -55,7 +55,7 @@ const ACTION_ALIASES: Readonly> = Object.freeze( }) const DEFAULT_KEYMAP: BraidKeymap = Object.freeze({ - closeOverlay: Object.freeze(['escape'] as KeyId[]), + closeOverlay: Object.freeze(['escape', 'left'] as KeyId[]), commandPalette: Object.freeze(['ctrl+p'] as KeyId[]), conversationSelector: Object.freeze(['ctrl+o'] as KeyId[]), graph: Object.freeze(['ctrl+g'] as KeyId[]), diff --git a/src/views/tui/measurement-display.ts b/src/views/tui/measurement-display.ts new file mode 100644 index 0000000..69fff29 --- /dev/null +++ b/src/views/tui/measurement-display.ts @@ -0,0 +1,11 @@ +const UNREPORTED_COST_OR_LATENCY = /^(?:cost|latency) unknown(?: \(\+\d+ unknown\))?$/u + +/** Keeps missing cost and latency out of compact TUI lines without changing source data. */ +export function omitUnreportedCostAndLatency(lines: readonly string[]): readonly string[] { + return lines.flatMap((line) => { + const visibleParts = line + .split(' · ') + .filter((part) => !UNREPORTED_COST_OR_LATENCY.test(part.trim())) + return visibleParts.length === 0 ? [] : [visibleParts.join(' · ')] + }) +} diff --git a/src/views/tui/modal-coordinator.ts b/src/views/tui/modal-coordinator.ts index a5fb7a5..552227b 100644 --- a/src/views/tui/modal-coordinator.ts +++ b/src/views/tui/modal-coordinator.ts @@ -34,7 +34,7 @@ export class ModalCoordinator { const layout = layoutFor(this.#tui.terminal.columns, this.#tui.terminal.rows) const handle = this.#tui.showOverlay(component, { ...overlayOptions, - ...(layout.columns < fullScreenBelow + ...(layout.overlayFullScreen || layout.columns < fullScreenBelow ? { width: '100%', maxHeight: '100%', anchor: 'top-left' as const, margin: 0 } : {}), }) @@ -60,6 +60,13 @@ export class ModalCoordinator { this.closeTop() } + backOrCloseIfPassive(): boolean { + const entry = this.#top() + if (entry === undefined || entry.component.handleInput !== undefined) return false + this.backOrClose() + return true + } + closeAll(): void { while (this.#entries.length > 0) this.closeTop() } diff --git a/src/views/tui/profile-editor.ts b/src/views/tui/profile-editor.ts index 74b4016..e2f7641 100644 --- a/src/views/tui/profile-editor.ts +++ b/src/views/tui/profile-editor.ts @@ -19,7 +19,6 @@ import { profileItemName, readProfileCompatibility, readValidationReport, - saveProfileIntent, selectProfileIntent, validateProfileIntent, } from './profile-editor-actions.js' @@ -96,13 +95,12 @@ export class ProfileEditorViewPanel extends Container implements Focusable { items: [], query, maxVisible: 5, - footer: 'enter · ^V valid · ^S save · esc', + footer: 'enter select · ^V validate · esc close', theme: this.#theme, onSelect: (item) => void this.#select(item), onAction: (key, item) => { if (item === null || this.#busy) return if (key === 'validate') void this.#validate(item) - if (key === 'save') void this.#save(item) }, onCancel: () => this.#onCancel?.(), }) @@ -228,31 +226,6 @@ export class ProfileEditorViewPanel extends Container implements Focusable { } } - async #save(item: SelectItem): Promise { - if (this.#busy) return - const profile = this.#profiles.find((candidate) => candidate.id === item.value) - if (profile === undefined || profile.id !== this.#activeProfileId) { - this.#setStatus('Select the exact profile before saving it') - return - } - if (!profile.source.writable) { - this.#setStatus('This profile source is read-only') - return - } - this.#busy = true - this.#setStatus(`Saving ${safe(profile.name)}…`) - try { - const result = await this.#dispatch(saveProfileIntent(item, this.#operationId())) - this.#setStatus(actionMessage(result, 'Profile saved')) - if (result.kind === 'accepted') - await this.#refresh('Profile saved · active selection preserved') - } catch (error) { - this.#setStatus(profileErrorMessage(error, 'Profile save failed')) - } finally { - this.#busy = false - } - } - #dispatch(intent: BraidIntent): Promise { if (this.#controller === undefined) { return Promise.resolve({ diff --git a/src/views/tui/terminal-app.ts b/src/views/tui/terminal-app.ts index dee767e..a709173 100644 --- a/src/views/tui/terminal-app.ts +++ b/src/views/tui/terminal-app.ts @@ -144,6 +144,7 @@ export class BraidTerminalApp { dispatch: (intent) => this.#dispatch(intent), currentView: () => this.#controller.view(), isStopped: () => this.#stopped, + requestRender: () => this.#tui.requestRender(), rows: () => this.#tui.terminal.rows, openAutomation: (input) => this.#overlays.openAutomation(input), }) @@ -211,6 +212,7 @@ export class BraidTerminalApp { if (this.#stopped) return this.#stopped = true this.#overlays.dispose() + this.#interactions.dispose() this.#input.close() this.#drafts.close() this.#modals.closeAll() diff --git a/src/views/tui/terminal-command-controller.ts b/src/views/tui/terminal-command-controller.ts index 394135b..c12659d 100644 --- a/src/views/tui/terminal-command-controller.ts +++ b/src/views/tui/terminal-command-controller.ts @@ -129,6 +129,13 @@ export class TerminalCommandController { this.#overlays.openConnectionEditor() return } + if ( + (command === 'runner' || command === 'model' || command === 'effort') && + args.length === 0 + ) { + this.#overlays.openSelector(command) + return + } if ( command === 'automate' && (args.length === 0 || (args.length === 1 && args[0] === 'list')) diff --git a/src/views/tui/terminal-identity.ts b/src/views/tui/terminal-identity.ts index 2a3acd5..fb99705 100644 --- a/src/views/tui/terminal-identity.ts +++ b/src/views/tui/terminal-identity.ts @@ -43,7 +43,9 @@ export function renderTerminalContext( const route = terminalValuePart(theme, `${identity.runner} / ${model}`) const connection = terminalValuePart( theme, - mode === 'narrow' ? compactConnectionName(identity.connection) : identity.connection, + mode === 'wide' || mode === 'narrow' + ? identity.connection + : compactConnectionName(identity.connection), ) const effortValue = cleanTerminalField(identity.effort) const effort = diff --git a/src/views/tui/terminal-input-controller.ts b/src/views/tui/terminal-input-controller.ts index 0860aaa..f36d753 100644 --- a/src/views/tui/terminal-input-controller.ts +++ b/src/views/tui/terminal-input-controller.ts @@ -1,13 +1,14 @@ -import type { TUI } from '@earendil-works/pi-tui' +import { matchesKey, type TUI } from '@earendil-works/pi-tui' import { commandAvailability } from '../shared/command-registry.js' import type { BraidIntent, BraidUiController, UiDispatchResult } from '../shared/intents.js' import type { BraidViewModel } from '../shared/models.js' +import { hasLiveWork } from './activity.js' +import type { ComposerMode } from './composer-view.js' import { type BraidKeymap, isTextInputSequence, matchesKeyAction } from './keyboard.js' import type { ModalCoordinator } from './modal-coordinator.js' import type { TerminalDraftController } from './terminal-drafts.js' import type { TerminalOverlayController } from './terminal-overlays.js' import type { BraidShell } from './terminal-shell.js' -import type { ComposerMode } from './composer-view.js' export interface TerminalInputControllerOptions { readonly tui: TUI @@ -26,11 +27,9 @@ export interface TerminalInputControllerOptions { export type ActivityVisibility = 'auto' | 'hidden' | 'visible' -export function activityVisibleFor( - _view: Pick, - visibility: ActivityVisibility, -): boolean { - return visibility === 'visible' +export function activityVisibleFor(view: BraidViewModel, visibility: ActivityVisibility): boolean { + if (visibility !== 'visible') return false + return hasLiveWork(view) } /** Routes non-text terminal keys and owns the short-lived quit/activity state. */ @@ -93,6 +92,10 @@ export class TerminalInputController { if (isTextInputSequence(data)) return undefined if (matchesKeyAction(data, this.#keymap, 'closeOverlay') && this.#tui.hasOverlay()) { if (this.#interactionOpen()) return undefined + if (matchesKey(data, 'left')) { + if (!this.#modals.backOrCloseIfPassive()) return undefined + return { consume: true } + } this.#modals.backOrClose() return { consume: true } } @@ -128,11 +131,13 @@ export class TerminalInputController { return { consume: true } } if (matchesKeyAction(data, this.#keymap, 'graph')) { - this.#overlays.openSurface('graph') + void this.#dispatch({ type: 'open-surface', surface: 'graph', query: '' }).then((result) => { + if (result.kind === 'accepted') this.#overlays.openSurface('graph') + }) return { consume: true } } if (matchesKeyAction(data, this.#keymap, 'switcher')) { - this.#overlays.openSelector('profile') + this.#overlays.openSwitcher() return { consume: true } } if (matchesKeyAction(data, this.#keymap, 'activity')) { diff --git a/src/views/tui/terminal-interaction-controller.ts b/src/views/tui/terminal-interaction-controller.ts index cce14ef..bf9eae0 100644 --- a/src/views/tui/terminal-interaction-controller.ts +++ b/src/views/tui/terminal-interaction-controller.ts @@ -11,6 +11,7 @@ export interface TerminalInteractionControllerOptions { readonly dispatch: (intent: BraidIntent) => Promise readonly currentView: () => BraidViewModel readonly isStopped: () => boolean + readonly requestRender: () => void readonly rows: () => number readonly openAutomation: (input: { readonly interaction: InteractionView @@ -27,11 +28,14 @@ export class TerminalInteractionController { readonly #dispatch: TerminalInteractionControllerOptions['dispatch'] readonly #currentView: () => BraidViewModel readonly #isStopped: () => boolean + readonly #requestRender: () => void readonly #rows: () => number readonly #openAutomation: TerminalInteractionControllerOptions['openAutomation'] #open = false #interactionKey: string | undefined #pendingInteractionKey: string | undefined + #shell: InteractionShell | undefined + #refresh: ReturnType | undefined constructor(options: TerminalInteractionControllerOptions) { this.#theme = options.theme @@ -40,6 +44,7 @@ export class TerminalInteractionController { this.#dispatch = options.dispatch this.#currentView = options.currentView this.#isStopped = options.isStopped + this.#requestRender = options.requestRender this.#rows = options.rows this.#openAutomation = options.openAutomation } @@ -53,13 +58,20 @@ export class TerminalInteractionController { const interactionKey = interaction ? keyFor(interaction) : undefined if (!interaction) { this.#pendingInteractionKey = undefined + this.#stopRefresh() if (this.#open) { this.#open = false this.#interactionKey = undefined + this.#shell = undefined this.#modals.closeTop() } return } + if (this.#open && interactionKey === this.#interactionKey) { + this.#shell?.setInteraction(interaction) + this.#syncRefresh(interaction) + return + } if ( interactionKey !== this.#pendingInteractionKey && (!this.#open || interactionKey !== this.#interactionKey) @@ -78,6 +90,8 @@ export class TerminalInteractionController { this.#open = false this.#pendingInteractionKey = this.#interactionKey this.#interactionKey = undefined + this.#shell = undefined + this.#stopRefresh() this.#modals.closeTop() const pendingKey = this.#pendingInteractionKey void this.#dispatch({ @@ -103,12 +117,14 @@ export class TerminalInteractionController { }), this.#rows, ) + this.#shell = shell this.#modals.open(shell, { anchor: 'top-left', width: '100%', maxHeight: '100%', margin: 0, }) + this.#syncRefresh(interaction) } #resumeInteraction(previous: InteractionView): void { @@ -116,10 +132,39 @@ export class TerminalInteractionController { this.#open = false this.#interactionKey = undefined this.#pendingInteractionKey = undefined + this.#shell = undefined + this.#stopRefresh() const current = this.#currentView().interactions[0] if (current !== undefined && keyFor(current) === keyFor(previous)) this.#openInteraction(current) } + + dispose(): void { + this.#stopRefresh() + this.#shell = undefined + } + + #syncRefresh(interaction: InteractionView): void { + if (interaction.remainingMs === undefined || interaction.remainingMs <= 0) { + this.#stopRefresh() + return + } + if (this.#refresh !== undefined) return + this.#refresh = setInterval(() => { + if (this.#isStopped()) { + this.#stopRefresh() + return + } + this.sync(this.#currentView()) + this.#requestRender() + }, 250) + this.#refresh.unref?.() + } + + #stopRefresh(): void { + if (this.#refresh !== undefined) clearInterval(this.#refresh) + this.#refresh = undefined + } } function keyFor(interaction: InteractionView): string { diff --git a/src/views/tui/terminal-overlays.ts b/src/views/tui/terminal-overlays.ts index 8b32fa6..40a1475 100644 --- a/src/views/tui/terminal-overlays.ts +++ b/src/views/tui/terminal-overlays.ts @@ -195,6 +195,52 @@ export class TerminalOverlayController { this.#modals.open(palette, { anchor: 'center', width: '70%', minWidth: 32, maxHeight: 14 }) } + openSwitcher(): void { + const view = this.#controller.view() + const items: SelectItem[] = [ + { + value: 'profile', + label: 'Profile', + description: `${view.profileName} · selected agent`, + }, + { + value: 'connection', + label: 'Connection', + description: `${view.connection} · execution route`, + }, + { + value: 'runner', + label: 'Runner', + description: `${view.runner} · ${view.runOverrides?.runner === undefined ? 'from profile' : 'branch override'}`, + }, + { + value: 'model', + label: 'Model', + description: `${view.model} · ${view.runOverrides?.model === undefined ? 'from profile' : 'branch override'}`, + }, + { + value: 'effort', + label: 'Thinking', + description: `${view.effort ?? 'runner default'} · ${view.runOverrides?.effort === undefined ? 'from profile' : 'branch override'}`, + }, + ] + const selector = new SearchableSelector({ + title: 'Run configuration', + items, + maxVisible: 5, + theme: this.#theme, + footer: 'enter to change · esc to close', + onSelect: (item) => { + this.#modals.closeTop() + if (item.value === 'profile') this.openProfile() + else if (item.value === 'connection') this.openConnection() + else this.#startOverrideEntry(item.value as 'runner' | 'model' | 'effort') + }, + onCancel: () => this.#modals.closeTop(), + }) + this.#modals.open(selector, { anchor: 'center', width: '72%', minWidth: 34, maxHeight: 14 }) + } + openAnalysisSource(question: readonly string[], sources: readonly ActivityItemView[]): void { const view = this.#controller.view() const selector = new SearchableSelector({ @@ -277,23 +323,15 @@ export class TerminalOverlayController { this.#conversations.openBranchSelector() return } + if (kind === 'runner' || kind === 'model' || kind === 'effort') { + this.#startOverrideEntry(kind) + return + } const view = this.#controller.view() const items: SelectItem[] = kind === 'graph' ? view.graph.map((node) => ({ value: node.id, label: node.title, description: node.type })) - : kind === 'runner' - ? [{ value: view.runner, label: view.runner, description: 'active runner' }] - : kind === 'model' - ? [{ value: view.model, label: view.model, description: 'active model' }] - : kind === 'effort' - ? [ - { - value: view.effort ?? 'default', - label: view.effort ?? 'default', - description: 'active effort', - }, - ] - : commandItems(view.capabilities) + : commandItems(view.capabilities) const selector = new SearchableSelector({ title: kind, items, @@ -302,14 +340,17 @@ export class TerminalOverlayController { this.#modals.closeTop() if (kind === 'graph') this.openSurface('graph') else if (kind === 'help') this.openHelp(item.value) - else if (kind === 'runner' || kind === 'model' || kind === 'effort') - this.#dispatchCommand(kind, [item.value]) }, onCancel: () => this.#modals.closeTop(), }) this.#modals.open(selector, { anchor: 'center', width: '70%', minWidth: 32, maxHeight: 14 }) } + #startOverrideEntry(kind: 'runner' | 'model' | 'effort'): void { + this.#editor.setText(`/${kind} `) + this.#requestRender() + } + openConversationSelector(query = ''): void { this.#conversations.openConversationSelector(query) } diff --git a/src/views/tui/terminal-shell.ts b/src/views/tui/terminal-shell.ts index 58be041..8347e07 100644 --- a/src/views/tui/terminal-shell.ts +++ b/src/views/tui/terminal-shell.ts @@ -8,7 +8,7 @@ import { } from '@earendil-works/pi-tui' import type { BraidViewModel } from '../shared/models.js' import { sanitizeTerminalText } from '../shared/sanitize.js' -import { ActivityView } from './activity.js' +import { ActivityView, hasLiveWork } from './activity.js' import { type ComposerMode, ComposerView } from './composer-view.js' import { layoutFor } from './layout.js' import { TerminalChrome } from './terminal-chrome.js' @@ -121,7 +121,8 @@ export class BraidShell extends Container { override render(width: number): string[] { const view = this.#view if (!view) return super.render(width) - const layout = layoutFor(width, this.#rows(), this.#showActivity) + const showActivity = this.#showActivity && hasLiveWork(view) + const layout = layoutFor(width, this.#rows(), showActivity) const editorLines = this.#modalVisible ? [] : this.#composer.render(width) const topChrome = this.#chrome.renderTop(width) const bottomChrome = this.#chrome.renderBottom(width) @@ -131,7 +132,7 @@ export class BraidShell extends Container { const transcriptLines = this.#transcript.render(layout.transcriptWidth) this.#refreshChrome() const activityLines = - layout.mode === 'wide' && this.#showActivity + layout.mode === 'wide' && showActivity ? tail(this.#activity.render(layout.activityWidth), contentRows) : [] const content: string[] = [] @@ -158,7 +159,7 @@ export class BraidShell extends Container { this.#chrome.setState({ view, quitArmed: this.#quitArmed, - activityVisible: this.#showActivity, + activityVisible: this.#showActivity && hasLiveWork(view), navigationHint: this.#transcript.navigationHint(), composerMode: this.#composerMode, }) diff --git a/test/activity-document.test.ts b/test/activity-document.test.ts index 09f296a..b7c33da 100644 --- a/test/activity-document.test.ts +++ b/test/activity-document.test.ts @@ -7,6 +7,7 @@ import type { BraidViewModel } from '../src/views/shared/models.js' import { ActivityView } from '../src/views/tui/activity.js' import { activityDocument } from '../src/views/tui/activity-browser.js' import { projectActivityDocument } from '../src/views/tui/activity-document.js' +import { omitUnreportedCostAndLatency } from '../src/views/tui/measurement-display.js' import { activityVisibleFor } from '../src/views/tui/terminal-input-controller.js' import { BraidShell } from '../src/views/tui/terminal-shell.js' import { createBraidTheme } from '../src/views/tui/theme.js' @@ -102,10 +103,12 @@ test('one activity document preserves event status, tree identity, duration, usa }) test('the wide live-work rail uses a divider and remains explicitly opt-in', async () => { - assert.equal(activityVisibleFor({ activeRunId: 'run-1' }, 'auto'), false) - assert.equal(activityVisibleFor({}, 'auto'), false) - assert.equal(activityVisibleFor({}, 'visible'), true) - assert.equal(activityVisibleFor({ activeRunId: 'run-1' }, 'hidden'), false) + const live = activityView() + const idle = { ...live, activity: [], messages: [] } + assert.equal(activityVisibleFor({ ...idle, activeRunId: 'run-1' }, 'auto'), false) + assert.equal(activityVisibleFor(idle, 'visible'), false) + assert.equal(activityVisibleFor(live, 'visible'), true) + assert.equal(activityVisibleFor({ ...idle, activeRunId: 'run-1' }, 'hidden'), false) const view = activityView() const terminal = new VirtualTerminal(120, 30) @@ -117,6 +120,9 @@ test('the wide live-work rail uses a divider and remains explicitly opt-in', asy () => {}, () => {}, ) + shell.setView(idle, false) + shell.setActivityVisible(true) + assert.doesNotMatch(shell.render(120).join('\n'), /│/u) shell.setView(view, false) shell.setActivityVisible(true) tui.addChild(shell) @@ -127,6 +133,21 @@ test('the wide live-work rail uses a divider and remains explicitly opt-in', asy tui.stop() }) +test('the TUI omits unreported cost and latency fragments without inventing values', () => { + assert.deepEqual( + omitUnreportedCostAndLatency([ + '#2 glm-5.2 · tokens unknown · cost unknown · latency unknown', + '5 calls · tokens ≥8 in / ≥12 out (+4 unknown) · cost unknown (+4 unknown) · latency unknown (+4 unknown)', + 'model call · cost $0.02 · latency 50ms', + ]), + [ + '#2 glm-5.2 · tokens unknown', + '5 calls · tokens ≥8 in / ≥12 out (+4 unknown)', + 'model call · cost $0.02 · latency 50ms', + ], + ) +}) + function activityView(): BraidViewModel { const app = createBraidApplication({ fixture: 'deterministic' }) app.initialize('/workspace') diff --git a/test/analysis-durable.test.ts b/test/analysis-durable.test.ts index 6144652..e6a20bb 100644 --- a/test/analysis-durable.test.ts +++ b/test/analysis-durable.test.ts @@ -43,6 +43,7 @@ import { import { replayEvents } from '../src/domain/reducer.js' import { type BraidState, initialState } from '../src/domain/state.js' import { FixedClock } from '../src/ports/clock.js' +import type { RetainedRunAdmissionRecord } from '../src/domain/run-contracts.js' const NOW = '2026-08-03T20:00:00.000Z' const PROFILE = {} as Readonly @@ -376,6 +377,65 @@ function singleHost(): MemoryHost { return new MemoryHost(runHistory('run-analysis', 'turn-analysis')) } +function retainedAdmissionAnalyst( + input: { readonly afterEnvironment?: () => void } = {}, +): AnalysisAnalyst { + return { + list: () => [ + { + id: 'retained-admission', + description: 'records retained model-call admission', + version: '1.0.0', + cost: { kind: 'model' }, + }, + ], + resolveAnalystIds: () => ['retained-admission'], + stream: async function* (request) { + const record = request.onRetainedAdmission + if (record === undefined) throw new Error('analysis retained admission callback is missing') + const environment: RetainedRunAdmissionRecord = { + phase: 'environment', + provider: 'cli-bridge', + environmentId: 'analysis-environment-durable', + idempotencyKey: 'analysis-environment-durable', + turnId: 'analysis-turn-durable', + sessionId: 'analysis-session-durable', + executionId: 'analysis-execution-durable', + } + await record('call-analysis-durable', environment) + input.afterEnvironment?.() + await record('call-analysis-durable', { + phase: 'dispatched', + idempotencyKey: environment.idempotencyKey, + turnId: environment.turnId, + controlRef: { + provider: environment.provider, + environmentId: environment.environmentId, + sessionId: environment.sessionId, + executionId: environment.executionId, + runId: 'analysis-provider-run-durable', + requestDigest: `sha256:${'e'.repeat(64)}`, + }, + }) + const result = { + run_id: request.runId, + correlation_id: 'correlation-retained-admission', + started_at: NOW, + ended_at: NOW, + findings: [], + per_analyst: [], + total_cost_usd: 0, + execution_plan: {}, + completion: { status: 'complete' }, + } as unknown as ExactAnalystRunResult + yield { + event: { type: 'run-completed', result } as unknown as ExactAnalystRunEvent, + result, + } + }, + } +} + function combinedHost(): MemoryHost { const baseline = runHistory('run-baseline', 'turn-paired') const candidate = runHistory('run-candidate', 'turn-candidate').map((event) => ({ @@ -718,6 +778,75 @@ test('a reservation crash leaves no external call and retries once after restart assert.equal(calls.count, 1) }) +test('analysis persists both retained admissions and preserves them in its terminal operation', async () => { + const host = singleHost() + const result = await new AnalysisService(host, retainedAdmissionAnalyst()).run({ + runId: 'run-analysis', + recipe: 'cost', + operationId: 'operation-analysis-retained-admission', + }) + + assert.equal(result.status, 'completed') + const operation = host.state.operations.find( + (candidate) => candidate.id === 'operation-analysis-retained-admission', + ) + assert.equal(operation?.status, 'terminal') + assert.equal(operation?.result?.status, 'completed') + const admissions = operation?.result?.retainedAdmissions + assert.ok(Array.isArray(admissions)) + assert.equal(admissions.length, 2) + assert.deepEqual( + admissions.map((entry) => + entry !== null && typeof entry === 'object' && !Array.isArray(entry) + ? entry.admission + : undefined, + ), + [ + { + phase: 'environment', + provider: 'cli-bridge', + environmentId: 'analysis-environment-durable', + idempotencyKey: 'analysis-environment-durable', + turnId: 'analysis-turn-durable', + sessionId: 'analysis-session-durable', + executionId: 'analysis-execution-durable', + }, + { + phase: 'dispatched', + idempotencyKey: 'analysis-environment-durable', + turnId: 'analysis-turn-durable', + controlRef: { + provider: 'cli-bridge', + environmentId: 'analysis-environment-durable', + sessionId: 'analysis-session-durable', + executionId: 'analysis-execution-durable', + runId: 'analysis-provider-run-durable', + requestDigest: `sha256:${'e'.repeat(64)}`, + }, + }, + ], + ) +}) + +test('analysis cannot proceed past retained admission when its durable write fails', async () => { + const host = singleHost() + let proceeded = false + host.failNext('operation.updated') + await assert.rejects( + () => + new AnalysisService( + host, + retainedAdmissionAnalyst({ afterEnvironment: () => (proceeded = true) }), + ).run({ + runId: 'run-analysis', + recipe: 'cost', + operationId: 'operation-analysis-retained-write-failure', + }), + AnalysisPersistenceError, + ) + assert.equal(proceeded, false) +}) + test('result commit crash reconciles to a safe replay without rerunning the analyst', async () => { const calls = { count: 0 } const crashed = singleHost() diff --git a/test/application.test.ts b/test/application.test.ts index 3d81e65..260553e 100644 --- a/test/application.test.ts +++ b/test/application.test.ts @@ -5,17 +5,18 @@ import type { RuntimeStreamEvent } from '@tangle-network/agent-runtime' import { canonicalAgentProfileDigestHex } from '../src/adapters/agent-interface/profile-runtime.js' import { createApplicationUiController } from '../src/adapters/tui/application-ui-controller.js' import { createUiSubscriberDelivery } from '../src/adapters/tui/ui-subscriber-delivery.js' +import { buildBraidViewModel } from '../src/adapters/tui/ui-view-model.js' import { AppError, BraidApplication } from '../src/app/application.js' import { createBraidApplication, DETERMINISTIC_PROFILE } from '../src/app/composition.js' import { effectRequestDigest } from '../src/app/effect-coordinator.js' import { MemoryJournal } from '../src/app/journal.js' -import { safeRuntimeDiagnostic } from '../src/app/provider-values.js' import { createProfileRecord } from '../src/app/profiles.js' +import { safeRuntimeDiagnostic } from '../src/app/provider-values.js' import { runEffectRequest } from '../src/app/run-admission.js' -import { buildAppView } from '../src/app/view-model.js' import type { ConnectionRecord } from '../src/domain/entities.js' import type { BraidEventEnvelope } from '../src/domain/events.js' import { createConnectionId } from '../src/domain/ids.js' +import { assertBraidState } from '../src/domain/invariants.js' import { FixedClock } from '../src/ports/clock.js' import type { JournalPort } from '../src/ports/effect-storage.js' import type { @@ -227,6 +228,62 @@ test('admission snapshots profile and connection before a blocked dispatch', asy } }) +test('branch run configuration changes the immutable AgentProfile admitted to Runtime', async () => { + const authored = defineAgentProfile({ + name: 'authored profile', + harness: 'pi', + model: { default: 'fixture/authored', reasoningEffort: 'medium' }, + }) + let streamedProfile: Readonly | undefined + const execution: ExecutionPort = { + admit: () => ({}), + async *streamTurn(input): AsyncIterable { + streamedProfile = structuredClone(input.profile) + yield { + type: 'final', + status: 'completed', + reason: 'effective profile test completed', + text: 'effective profile reached Runtime', + metadata: { tokenUsage: { input: 1, output: 1 } }, + task: { id: 'effective-profile', intent: 'effective profile admission' }, + timestamp: '2026-08-03T00:00:00.000Z', + } + }, + } + const journal = new MemoryJournal(new FixedClock()) + const app = new BraidApplication({ + profile: authored, + execution, + clock: new FixedClock(), + ids: new SequenceIds(), + journal, + effectStorage: journal, + }) + app.initialize('/workspace') + await app.conversations.branches.setRunOverrides({ + operationId: 'op-effective-profile-override', + runner: 'codex', + model: 'openai/gpt-5.6', + effort: 'xhigh', + }) + + const send = app.send({ + operationId: 'op-effective-profile-send', + text: 'use the branch configuration', + }) + assert.equal(send.admission.requested.profile.harness, 'codex') + assert.equal(send.admission.requested.profile.model?.default, 'openai/gpt-5.6') + assert.equal(send.admission.requested.profile.model?.reasoningEffort, 'xhigh') + await send.completion + + assert.deepEqual(streamedProfile, send.admission.requested.profile) + assert.deepEqual(app.runtimeSelection.profile(), authored) + assert.deepEqual(app.state().profile, authored) + assert.equal(app.state().runs[0]?.receipt.requested.runner, 'codex') + assert.equal(app.state().runs[0]?.model, 'openai/gpt-5.6') + assertBraidState(app.state()) +}) + test('post-startup profile and connection selection reaches the next real resolver run', async () => { const profileA = defineAgentProfile({ name: 'resolver old profile', @@ -601,7 +658,7 @@ test('cancellation remains distinct from failure', async () => { assert.equal(state.runs[0]?.status, 'aborted') assert.match(state.runs[0]?.terminalReason ?? '', /abort|cancel/iu) assert.equal(state.lastError, null) - assert.equal(buildAppView(state).status, 'aborted') + assert.equal(buildBraidViewModel(state).status, 'cancelled') }) test('blocked and unconfigured states remain explicit', async () => { @@ -629,9 +686,9 @@ test('blocked and unconfigured states remain explicit', async () => { }) blocked.initialize('/workspace') const blockedState = await blocked.send({ operationId: 'op-blocked', text: 'wait' }).completion - assert.equal(buildAppView(blockedState).status, 'blocked') + assert.equal(buildBraidViewModel(blockedState).status, 'waiting') - const unconfigured = buildAppView(createBraidApplication().state()) + const unconfigured = buildBraidViewModel(createBraidApplication().state()) assert.equal(unconfigured.connection, 'not connected') assert.equal(unconfigured.model, 'automatic') }) @@ -931,6 +988,29 @@ test('application replacement presents the new profile to frame subscribers', as await Promise.all([oldApp.close(), nextApp.close()]) }) +test('application replacement clears transient notices and fork previews', async () => { + const oldApp = createBraidApplication({ fixture: 'deterministic' }) + const nextApp = createBraidApplication({ fixture: 'deterministic' }) + oldApp.initialize('/old-workspace') + const controller = createApplicationUiController(oldApp) + const preview = await controller.dispatch({ + type: 'run-command', + command: 'fork', + args: [], + operationId: 'op-preview-before-replacement', + }) + + assert.equal(preview.kind, 'accepted') + assert.ok(controller.view().notice) + assert.ok(controller.view().forkPreview) + + await controller.replaceApplication(nextApp, '/new-workspace') + + assert.equal(controller.view().notice, undefined) + assert.equal(controller.view().forkPreview, undefined) + await Promise.all([oldApp.close(), nextApp.close()]) +}) + test('a selected production connection enables terminal sends for non-fixture models', async () => { const profile = defineAgentProfile({ name: 'Production profile', @@ -1775,6 +1855,8 @@ test('concurrent cancellation coalesces one provider call and one durable reques assert.equal(providerCancellationCalls, 1) assert.equal(first.acknowledgement.outcome, 'accepted') assert.equal(second.acknowledgement.outcome, 'accepted') + assert.equal(first.replayed, false) + assert.equal(second.replayed, true) assert.equal( journal.all().filter((entry) => entry.event.kind === 'run.control.requested').length, 1, @@ -2076,6 +2158,7 @@ test('restart reconciles an in-flight cancellation to honest unknown and replays reason: 'user requested cancellation', legacy: true, }) + assert.equal(restoredControl.replayed, true) assert.equal(restoredControl.acknowledgement.outcome, 'unknown') assert.equal( restoredControl.acknowledgement.detail, diff --git a/test/cli-bridge-retained-restart.test.ts b/test/cli-bridge-retained-restart.test.ts index ac99f4d..7eaec88 100644 --- a/test/cli-bridge-retained-restart.test.ts +++ b/test/cli-bridge-retained-restart.test.ts @@ -10,7 +10,9 @@ import { type RetainedExecutionPlan, RetainedExecutionPort, } from '../src/adapters/runtime/retained-execution.js' +import { finalRetainedEnvelope } from '../src/adapters/runtime/retained-execution-projection.js' import { createApplicationUiController } from '../src/adapters/tui/application-ui-controller.js' +import { providerEventFor } from '../src/app/run-event-mapper.js' import { createDurableBraidApplication, type DurableBraidApplication, @@ -21,13 +23,81 @@ import { } from '../src/app/production-composition.js' import type { ConnectionRecord } from '../src/domain/entities.js' import { createConnectionId } from '../src/domain/ids.js' -import { isRuntimeEventEnvelope } from '../src/domain/runtime-events.js' -import { DEFAULT_RUN_CAPABILITIES } from '../src/ports/execution.js' +import { isFinalRuntimeEvent, isRuntimeEventEnvelope } from '../src/domain/runtime-events.js' +import { + DEFAULT_RUN_CAPABILITIES, + type RetainedRunAdmissionRecord, +} from '../src/ports/execution.js' import { RandomIds } from '../src/ports/ids.js' import { startRuntimeBridgeServer } from './support/runtime-bridge-server.js' const now = '2026-08-11T12:00:00.000Z' +test('canonical cancellation status remains cancelled in the Braid projection', () => { + const mapped = providerEventFor( + 'canonical-cancel', + { type: 'status', status: 'cancelled', detail: 'USER_CANCELLED' }, + { + eventId: 'canonical-cancel:status', + providerSequence: 1, + receivedAt: now, + }, + ) + + assert.deepEqual(mapped, { + kind: 'run.status.changed', + runId: 'canonical-cancel', + status: 'cancelled', + detail: 'USER_CANCELLED', + provider: { + eventId: 'canonical-cancel:status', + providerSequence: 1, + receivedAt: now, + }, + }) +}) + +test('retained CLI Bridge cancellation stays cancelled in the final projection', () => { + const envelope = finalRetainedEnvelope( + 'retained-cancel', + 1, + 'openai/gpt-5', + { + text: '', + success: false, + error: 'cli-bridge run ended cancelled', + metadata: { + runId: 'retained-cancel', + executionId: 'retained-cancel', + status: 'cancelled', + }, + }, + 'Execute the retained CLI Bridge turn', + ) + + if (!isRuntimeEventEnvelope(envelope) || !isFinalRuntimeEvent(envelope.event)) { + throw new Error('Retained cancellation did not produce a final runtime event') + } + const final = envelope.event + assert.deepEqual( + { type: final.type, status: final.status, reason: final.reason }, + { type: 'final', status: 'cancelled', reason: 'cli-bridge run ended cancelled' }, + ) + const mapped = providerEventFor('retained-cancel', final, { + eventId: 'retained-cancel:final', + providerSequence: 1, + receivedAt: now, + }) + if (mapped.kind !== 'run.finished') throw new Error('Retained final event was not terminal') + assert.equal(mapped.status, 'cancelled') +}) + +function recordAdmissions(target: RetainedRunAdmissionRecord[]) { + return async (admission: RetainedRunAdmissionRecord): Promise => { + target.push(structuredClone(admission)) + } +} + function production(endpoint: string): ProductionCompositionConfig { const connection: ConnectionRecord = { id: createConnectionId('connection-retained-restart'), @@ -168,6 +238,7 @@ test('durable Braid detaches, restarts, and resumes one retained CLI Bridge job ids: new RandomIds(), }) first.app.initialize(root) + await first.app.whenDurable() const send = first.app.send({ operationId: 'operation-retained-restart-send', text: 'Keep working while Braid restarts.', @@ -260,6 +331,7 @@ test('restart discovery preserves a continued provider session before the contro const runId = 'run/retained-crash-window' const providerSessionId = 'session-existing-conversation' const abort = new AbortController() + const admissions: RetainedRunAdmissionRecord[] = [] try { const first = createProductionComposition({ ...configuration, workspaceRoot: root }) if (first.execution.admit === undefined) throw new Error('Retained admission is unavailable') @@ -272,6 +344,7 @@ test('restart discovery preserves a continued provider session before the contro workspaceRoot: root, sessionId: providerSessionId, signal: abort.signal, + onRetainedAdmission: recordAdmissions(admissions), } const admission = await first.execution.admit(input) assert.equal(admission.providerSessionId, providerSessionId) @@ -286,6 +359,10 @@ test('restart discovery preserves a continued provider session before the contro 'braid.execution.observed', ) assert.equal(bridge.requests[0]?.sessionId, providerSessionId) + assert.deepEqual( + admissions.map((entry) => entry.phase), + ['environment', 'dispatched'], + ) await stream.return?.() const restarted = createProductionComposition({ ...configuration, workspaceRoot: root }) @@ -321,6 +398,7 @@ test('restart discovery rejects a status response for another CLI Bridge run', a const configuration = production(bridge.endpoint) const runId = 'run/retained-foreign-identity' const abort = new AbortController() + const admissions: RetainedRunAdmissionRecord[] = [] try { const first = createProductionComposition({ ...configuration, workspaceRoot: root }) if (first.execution.admit === undefined) throw new Error('Retained admission is unavailable') @@ -332,6 +410,7 @@ test('restart discovery rejects a status response for another CLI Bridge run', a connectionId: configuration.connectionId, workspaceRoot: root, signal: abort.signal, + onRetainedAdmission: recordAdmissions(admissions), } await first.execution.admit(input) const stream = first.execution.streamTurn(input)[Symbol.asyncIterator]() @@ -357,6 +436,7 @@ test('detach during execution-observation persistence stops the retained reader' const bridge = await startRuntimeBridgeServer({ holdStreams: true }) const configuration = production(bridge.endpoint) const abort = new AbortController() + const admissions: RetainedRunAdmissionRecord[] = [] try { const composition = createProductionComposition({ ...configuration, workspaceRoot: root }) if ( @@ -373,6 +453,7 @@ test('detach during execution-observation persistence stops the retained reader' connectionId: configuration.connectionId, workspaceRoot: root, signal: abort.signal, + onRetainedAdmission: recordAdmissions(admissions), } await composition.execution.admit(input) @@ -410,6 +491,7 @@ test('retained control keeps provider-owned identity separate from the Braid run const runId = 'run/provider-owned-identity' const providerSessionId = 'session-provider-owned-identity' const abort = new AbortController() + const admissions: RetainedRunAdmissionRecord[] = [] try { const composition = createProductionComposition({ ...configuration, workspaceRoot: root }) if (composition.execution.admit === undefined) { @@ -424,6 +506,7 @@ test('retained control keeps provider-owned identity separate from the Braid run workspaceRoot: root, sessionId: providerSessionId, signal: abort.signal, + onRetainedAdmission: recordAdmissions(admissions), } await composition.execution.admit(input) const stream = composition.execution.streamTurn(input)[Symbol.asyncIterator]() @@ -475,6 +558,7 @@ test('cancel_requested remains an unconfirmed Braid outcome', async () => { }) const configuration = production(bridge.endpoint) const abort = new AbortController() + const admissions: RetainedRunAdmissionRecord[] = [] try { const composition = createProductionComposition({ ...configuration, workspaceRoot: root }) if ( @@ -491,6 +575,7 @@ test('cancel_requested remains an unconfirmed Braid outcome', async () => { connectionId: configuration.connectionId, workspaceRoot: root, signal: abort.signal, + onRetainedAdmission: recordAdmissions(admissions), } await composition.execution.admit(input) const stream = composition.execution.streamTurn(input)[Symbol.asyncIterator]() diff --git a/test/configuration-product-flow.test.ts b/test/configuration-product-flow.test.ts index 791beec..b2758b0 100644 --- a/test/configuration-product-flow.test.ts +++ b/test/configuration-product-flow.test.ts @@ -276,7 +276,7 @@ test('first-run review keeps exact effective values and credential boundaries le assert.match(wizard.render(80).join('\n'), /selection applied/u) }) -test('profile editor uses canonical summaries, validates by keyboard, and refuses unsafe saves', async () => { +test('profile editor uses canonical summaries and validates without advertising a fake save', async () => { const calls: BraidIntent[] = [] const panel = new ProfileEditorViewPanel(theme, { controller: controllerFor(calls), @@ -291,7 +291,8 @@ test('profile editor uses canonical summaries, validates by keyboard, and refuse assert.doesNotMatch(initial, /secret-canary|secret-profile\.json/u) const narrow = panel.render(40) assert.ok(narrow.length <= 12) - assert.match(narrow.join('\n'), /enter · \^V valid · \^S save · esc/u) + assert.match(narrow.join('\n'), /enter select · \^V validate · esc close/u) + assert.doesNotMatch(narrow.join('\n'), /save/iu) panel.handleInput('\u0016') await settle() @@ -302,18 +303,10 @@ test('profile editor uses canonical summaries, validates by keyboard, and refuse 'validate_profile', ) assert.match(panel.render(80).join('\n'), /Profile valid/u) - - panel.handleInput('\u0013') - await settle() - const save = [...calls].reverse().find((intent) => intent.type === 'run-command') - assert.equal(save?.type, 'run-command') - assert.deepEqual(save?.type === 'run-command' ? save.args : [], ['save', 'profile-reviewer']) - assert.equal(save?.type === 'run-command' ? save.operationId : undefined, 'profile-ui-test') - - panel.handleInput('\u001b[B') - panel.handleInput('\u0013') - await settle() - assert.match(panel.render(80).join('\n'), /Select the exact profile before saving it/u) + assert.equal( + calls.some((intent) => intent.type === 'run-command'), + false, + ) }) test('connection setup shows health and capabilities without endpoints or credentials, then applies exact keyboard actions', async () => { @@ -392,7 +385,7 @@ test('connection setup shows health and capabilities without endpoints or creden assert.equal(cancelled, true) }) -test('profile save requires an exact id when display names collide', async () => { +test('profile selection uses the exact id when display names collide', async () => { const calls: BraidIntent[] = [] const panel = new ProfileEditorViewPanel(theme, { controller: controllerFor(calls, { duplicateProfileName: true }), @@ -401,17 +394,17 @@ test('profile save requires an exact id when display names collide', async () => panel.focused = true await settle() - panel.handleInput('\u0013') - await settle() - assert.match(panel.render(80).join('\n'), /Select the exact profile before saving it/u) - assert.doesNotMatch(calls.map((intent) => intent.type).join(','), /run-command/u) - panel.handleInput('\r') await settle() - panel.handleInput('\u0013') - await settle() - const save = [...calls].reverse().find((intent) => intent.type === 'run-command') - assert.deepEqual(save?.type === 'run-command' ? save.args : [], ['save', 'profile-reviewer']) + const selected = [...calls] + .reverse() + .find((intent) => intent.type === 'headless-command' && intent.command === 'select_profile') + assert.equal(selected?.type, 'headless-command') + assert.equal( + selected?.type === 'headless-command' ? selected.params.ref : undefined, + 'profile-reviewer', + ) + assert.doesNotMatch(calls.map((intent) => intent.type).join(','), /run-command/u) }) test('static profile and connection views mask secret-designated fields and keep narrow rows bounded', () => { diff --git a/test/conversations.test.ts b/test/conversations.test.ts index e79efb6..a426940 100644 --- a/test/conversations.test.ts +++ b/test/conversations.test.ts @@ -3,12 +3,16 @@ import { chmod, mkdtemp, readFile, stat, symlink, unlink, writeFile } from 'node import { tmpdir } from 'node:os' import { join } from 'node:path' import test from 'node:test' +import { createInteractionRequest } from '../src/app/interaction-request.js' import { createApplicationUiController } from '../src/adapters/tui/application-ui-controller.js' import { AppError } from '../src/app/application.js' import { createBraidApplication } from '../src/app/composition.js' import { messagesVisibleOnBranch } from '../src/app/conversation-context.js' +import { MemoryJournal } from '../src/app/journal.js' import { canonicalDigest } from '../src/domain/canonical.js' import { assertBraidState } from '../src/domain/invariants.js' +import { FixedClock } from '../src/ports/clock.js' +import { DEFAULT_RUN_CAPABILITIES, type ExecutionPort } from '../src/ports/execution.js' async function initializedApp() { const app = createBraidApplication({ fixture: 'deterministic' }) @@ -280,6 +284,165 @@ test('branch, clone, context, and fork use one canonical conversation graph', as assertBraidState(app.state()) }) +test('branch run configuration merges, replays, rejects conflicts, and clears atomically', async () => { + const app = await initializedApp() + const conversationId = app.state().conversationId + const branchId = app.state().branchId + const first = await app.conversations.branches.setRunOverrides({ + operationId: 'op-run-override-first', + runner: 'codex', + model: 'openai/gpt-5.6', + effort: 'high', + mode: 'interactive', + }) + const replay = await app.conversations.branches.setRunOverrides({ + operationId: 'op-run-override-first', + runner: 'codex', + model: 'openai/gpt-5.6', + effort: 'high', + mode: 'interactive', + }) + + assert.deepEqual(replay, first) + assert.deepEqual(first.overrides, { + runner: 'codex', + model: 'openai/gpt-5.6', + effort: 'high', + mode: 'interactive', + }) + assert.equal( + app + .events() + .filter( + (entry) => + entry.event.kind === 'branch.updated' && + entry.event.operation.id === 'op-run-override-first', + ).length, + 1, + ) + + const merged = await app.conversations.branches.setRunOverrides({ + operationId: 'op-run-override-merge', + model: 'anthropic/claude-opus-5', + }) + assert.deepEqual(merged.overrides, { + runner: 'codex', + model: 'anthropic/claude-opus-5', + effort: 'high', + mode: 'interactive', + }) + + const beforeFailures = app.state() + const eventCount = app.events().length + await assert.rejects( + () => + app.conversations.branches.setRunOverrides({ + operationId: 'op-run-override-first', + runner: 'pi', + model: 'openai/gpt-5.6', + effort: 'high', + mode: 'interactive', + }), + (error: unknown) => error instanceof AppError && error.code === 'OPERATION_ID_CONFLICT', + ) + await assert.rejects( + () => + app.conversations.branches.setRunOverrides({ + operationId: 'op-run-override-invalid-runner', + runner: 'not-a-runner', + }), + (error: unknown) => error instanceof AppError && error.code === 'INVALID_RUNNER', + ) + await assert.rejects( + () => + app.conversations.branches.setRunOverrides({ + operationId: 'op-run-override-invalid-effort', + effort: 'maximum-ish', + }), + (error: unknown) => error instanceof AppError && error.code === 'INVALID_EFFORT', + ) + await assert.rejects( + () => + app.conversations.branches.setRunOverrides({ + operationId: 'op-run-override-invalid-clear', + clear: true, + runner: 'pi', + }), + (error: unknown) => error instanceof AppError && error.code === 'INVALID_RUN_OVERRIDE', + ) + assert.deepEqual(app.state(), beforeFailures) + assert.equal(app.events().length, eventCount) + + const child = await app.conversations.branches.create({ + operationId: 'op-run-override-child', + conversationId, + branchId, + }) + assert.deepEqual(child.overrides, merged.overrides) + const changedChild = await app.conversations.branches.setRunOverrides({ + operationId: 'op-run-override-child-change', + conversationId, + branchId: child.id, + runner: 'pi', + }) + assert.equal(changedChild.overrides.runner, 'pi') + assert.equal( + app.state().branches.find((branch) => branch.id === branchId)?.overrides.runner, + 'codex', + ) + + const cleared = await app.conversations.branches.setRunOverrides({ + operationId: 'op-run-override-clear', + conversationId, + branchId: child.id, + clear: true, + }) + assert.deepEqual(cleared.overrides, {}) + assertBraidState(app.state()) +}) + +test('branch run configuration survives journal replay and cannot change during a run', async () => { + const journal = new MemoryJournal(new FixedClock()) + const first = createBraidApplication({ fixture: 'deterministic', journal, chunkDelayMs: 100 }) + first.initialize('/workspace') + await first.conversations.branches.setRunOverrides({ + operationId: 'op-run-override-durable', + runner: 'codex', + model: 'openai/gpt-5.6', + effort: 'xhigh', + }) + await first.whenDurable() + + const restarted = createBraidApplication({ fixture: 'deterministic', journal, chunkDelayMs: 100 }) + await restarted.whenDurable() + assert.deepEqual( + restarted.state().branches.find((branch) => branch.id === restarted.state().branchId) + ?.overrides, + { runner: 'codex', model: 'openai/gpt-5.6', effort: 'xhigh' }, + ) + + const active = restarted.send({ + operationId: 'op-run-override-active-send', + text: 'keep configuration stable while this runs', + }) + await active.admissionReady + await assert.rejects( + () => + restarted.conversations.branches.setRunOverrides({ + operationId: 'op-run-override-active-change', + runner: 'pi', + }), + (error: unknown) => error instanceof AppError && error.code === 'RUN_ACTIVE', + ) + await active.completion + assert.equal( + restarted.state().branches.find((branch) => branch.id === restarted.state().branchId)?.overrides + .runner, + 'codex', + ) + assertBraidState(restarted.state()) +}) + test('conversation exports are redacted, bounded, and never overwrite a path', async () => { const app = await initializedApp() const credential = 'sk-proj-exportcanary0123456789abcdefghijklmnopqrstuvwxyz' @@ -845,6 +1008,167 @@ test('conversation deletion purges owned data and retains an honest tombstone', assertBraidState(state) }) +test('conversation deletion blocks on a pending interaction retained by a terminal run', async () => { + const execution: ExecutionPort = { + capabilities: () => DEFAULT_RUN_CAPABILITIES, + async *streamTurn(input) { + const request = createInteractionRequest({ + id: 'interaction-delete-pending', + kind: 'question', + title: 'Confirm deletion', + answerSpec: { + fields: [{ type: 'boolean', name: 'confirm', label: 'Confirm', required: true }], + }, + binding: { + runId: input.runId, + provider: 'fixture', + environmentId: 'environment-delete-test', + sessionId: 'session-delete-test', + executionId: input.runId, + interactionId: 'interaction-delete-pending', + }, + }) + yield { type: 'interaction', request } + yield { + type: 'final', + status: 'completed', + reason: 'complete', + text: 'finished before the interaction was observed', + metadata: { tokenUsage: { input: 1, output: 1 } }, + task: { id: 'task-delete-interaction', intent: 'deletion test' }, + timestamp: '2026-08-01T00:00:00.000Z', + } + }, + } + const app = createBraidApplication({ + fixture: 'deterministic', + execution, + clock: new FixedClock('2026-08-01T00:00:00.000Z'), + }) + app.initialize('/workspace') + const send = app.send({ operationId: 'op-delete-interaction-send', text: 'finish first' }) + await send.completion + + const run = app.state().runs[0] + assert.ok(run) + assert.equal(run.complete, true) + assert.equal(app.state().runs[0]?.interactions[0]?.status, 'pending') + + await assert.rejects( + () => + app.conversations.lifecycle.delete({ + operationId: 'op-delete-interaction-blocked', + conversationId: app.state().conversationId, + }), + (error: unknown) => + error instanceof AppError && + error.code === 'DELETE_BLOCKED' && + error.message.includes('interaction-delete-pending'), + ) +}) + +test('conversation deletion retains pending identity after 257-entry interaction eviction', async () => { + const execution: ExecutionPort = { + capabilities: () => DEFAULT_RUN_CAPABILITIES, + async *streamTurn(input) { + for (let index = 0; index < 257; index += 1) { + const interactionId = `interaction-delete-evicted-${index}` + yield { + type: 'interaction', + request: createInteractionRequest({ + id: interactionId, + kind: 'question', + title: `Confirm eviction ${index}`, + answerSpec: { + fields: [{ type: 'boolean', name: 'confirm', label: 'Confirm', required: true }], + }, + binding: { + runId: input.runId, + provider: 'fixture', + environmentId: 'environment-delete-eviction', + sessionId: 'session-delete-eviction', + executionId: input.runId, + interactionId, + }, + }), + } + } + yield { + type: 'final', + status: 'completed', + reason: 'complete', + text: 'finished after interaction eviction', + metadata: { tokenUsage: { input: 1, output: 1 } }, + task: { id: 'task-delete-eviction', intent: 'deletion eviction test' }, + timestamp: '2026-08-01T00:00:00.000Z', + } + }, + } + const app = createBraidApplication({ + fixture: 'deterministic', + execution, + clock: new FixedClock('2026-08-01T00:00:00.000Z'), + }) + app.initialize('/workspace') + await app.send({ operationId: 'op-delete-eviction-send', text: 'finish after eviction' }) + .completion + + const run = app.state().runs[0] + assert.ok(run) + assert.equal(run.complete, true) + assert.equal(run.interactions.length, 256) + assert.equal( + run.interactions.some((item) => item.request.id === 'interaction-delete-evicted-0'), + false, + ) + assert.equal(run.pendingInteractionIds?.length, 257) + assert.equal(run.pendingInteractionIds?.includes('interaction-delete-evicted-0'), true) + + await assert.rejects( + () => + app.conversations.lifecycle.delete({ + operationId: 'op-delete-eviction-blocked', + conversationId: app.state().conversationId, + }), + (error: unknown) => + error instanceof AppError && + error.code === 'DELETE_BLOCKED' && + error.message.includes('interaction-delete-evicted-0'), + ) + + const state = app.state() + const withoutPendingIndex = state.runs.map((candidate) => { + if (candidate.id !== run.id) return candidate + const { pendingInteractionIds: _pendingInteractionIds, ...legacyRun } = candidate + return legacyRun + }) + const restoredJournal = Object.assign( + new MemoryJournal(new FixedClock('2026-08-01T00:00:00.000Z')), + { + initialState: () => ({ ...state, runs: withoutPendingIndex }), + }, + ) + const restarted = createBraidApplication({ + fixture: 'deterministic', + journal: restoredJournal, + execution: { + capabilities: () => DEFAULT_RUN_CAPABILITIES, + async *streamTurn(): AsyncIterable {}, + }, + }) + await assert.rejects( + () => + restarted.conversations.lifecycle.delete({ + operationId: 'op-delete-eviction-legacy-blocked', + conversationId: state.conversationId, + }), + (error: unknown) => + error instanceof AppError && + error.code === 'DELETE_BLOCKED' && + error.message.includes('pending state is not provable'), + ) +}) + test('deletion refuses to break a cloned conversation ancestry link', async () => { const app = await initializedApp() const sourceId = app.state().conversationId diff --git a/test/domain-invariants.test.ts b/test/domain-invariants.test.ts index 7a11883..a4fc356 100644 --- a/test/domain-invariants.test.ts +++ b/test/domain-invariants.test.ts @@ -2,7 +2,11 @@ import assert from 'node:assert/strict' import test from 'node:test' import type { AgentExactRunControlRef } from '@tangle-network/agent-interface' import { STARTER_PROFILE } from '../src/app/composition.js' -import type { ConnectionRecord, InteractionRecord, RunRecord } from '../src/domain/entities.js' +import { + createInteractionRequest, + interactionResponseBinding, +} from '../src/app/interaction-request.js' +import type { ConnectionRecord, RunRecord } from '../src/domain/entities.js' import type { BraidEvent, JournalEventEnvelope } from '../src/domain/events.js' import { createBranchId, @@ -15,19 +19,18 @@ import { createInteractionId, createOperationId, createProviderSessionId, + createRuleId, createRunId, createTurnId, createWorkspaceId, } from '../src/domain/ids.js' -import { - assertConnectionRecord, - assertInteractionRecord, - assertRunRecord, -} from '../src/domain/invariants.js' +import { assertConnectionRecord, assertRunRecord } from '../src/domain/invariants.js' import { createAdmissionReceipt } from '../src/domain/receipts.js' import { reduceEvent } from '../src/domain/reducer.js' +import type { BraidInteraction } from '../src/domain/runtime-projection.js' import { initialState } from '../src/domain/state.js' import { DEFAULT_RUN_CAPABILITIES } from '../src/ports/execution.js' +import type { Digest } from '../src/domain/ids.js' const at = '2026-08-02T00:00:00.000Z' @@ -206,45 +209,6 @@ test('connection state accepts transport metadata but rejects provider-native op ) }) -test('unknown interaction kinds remain renderable through the canonical answer specification', () => { - const interactionId = createInteractionId('interaction-future') - const base: InteractionRecord = { - id: interactionId, - runId: createRunId('run-interaction'), - request: { - id: interactionId, - kind: 'provider.future.interaction', - title: 'A future provider question', - answerSpec: { fields: [{ type: 'text', name: 'answer', label: 'Answer', required: true }] }, - }, - status: 'pending', - createdAt: at, - updatedAt: at, - } - assert.doesNotThrow(() => assertInteractionRecord(base)) - assert.throws( - () => - assertInteractionRecord({ - ...base, - request: { - ...base.request, - answerSpec: { - fields: [{ type: 'secret', name: 'password', label: 'Password', required: true }], - }, - }, - status: 'resolved', - resolution: { - outcome: 'accepted', - operationId: createOperationId('op-interaction-secret'), - publicData: { password: 'must-not-persist' }, - containsSecret: false, - resolvedAt: at, - }, - }), - /secret-designated|secret interaction/u, - ) -}) - function providerOwnedRun(): RunRecord { const runId = createRunId('run-local-identity') const conversationId = createConversationId('conversation-run-identity') @@ -301,6 +265,232 @@ function providerOwnedRun(): RunRecord { } } +function canonicalRunInteraction( + run: RunRecord, + options: { + readonly idSuffix?: string + readonly kind?: string + readonly bindingRunId?: string + } = {}, +): BraidInteraction { + const interactionId = createInteractionId( + `interaction-run-invariant-${options.idSuffix ?? 'future'}`, + ) + const controlRef = run.controlRef + const request = createInteractionRequest({ + id: interactionId, + kind: options.kind ?? 'provider.future.interaction', + title: 'A future provider question', + answerSpec: { + fields: [{ type: 'text', name: 'answer', label: 'Answer', required: true }], + }, + binding: { + runId: options.bindingRunId ?? run.id, + provider: controlRef?.provider ?? 'test-provider', + environmentId: controlRef?.environmentId ?? 'environment-run-invariant', + sessionId: controlRef?.sessionId ?? 'session-run-invariant', + executionId: controlRef?.executionId ?? 'execution-run-invariant', + interactionId, + }, + }) + return { + request, + responseBinding: interactionResponseBinding(request), + runId: run.id, + source: { occurredAt: at }, + status: 'pending', + } +} + +test('canonical run interactions accept future kinds and reject duplicate request IDs', () => { + const run = providerOwnedRun() + const interaction = canonicalRunInteraction(run) + + assert.doesNotThrow(() => assertRunRecord({ ...run, interactions: [interaction] })) + assert.throws( + () => + assertRunRecord({ + ...run, + interactions: [{ ...interaction, source: { sequence: 0 } }], + }), + /run\.interactions\[0\]\.source\.sequence must be a positive safe integer/u, + ) + assert.throws( + () => + assertRunRecord({ + ...run, + interactions: [{ ...interaction, source: { eventId: 'password=not-persisted' } }], + }), + /run\.interactions\[0\]\.source\.eventId must be a safe public identifier/u, + ) + assert.throws( + () => assertRunRecord({ ...run, interactions: [interaction, interaction] }), + /run\.interactions\.request contains duplicate identifier/u, + ) +}) + +test('canonical run interactions reject malformed secret requests and mismatched bindings', () => { + const run = providerOwnedRun() + const interaction = canonicalRunInteraction(run, { idSuffix: 'invalid' }) + const secretDefault = { + ...interaction, + request: { + ...interaction.request, + answerSpec: { + fields: [{ type: 'secret' as const, name: 'password', label: 'Password', required: true }], + }, + default: { outcome: 'accepted' as const, data: { password: 'must-not-persist' } }, + }, + } + + assert.throws( + () => assertRunRecord({ ...run, interactions: [secretDefault] }), + /run\.interactions\[0\]\.request is invalid/u, + ) + + const mismatchedRequestBinding = canonicalRunInteraction(run, { + idSuffix: 'request-binding', + bindingRunId: createRunId('run-not-containing'), + }) + assert.throws( + () => assertRunRecord({ ...run, interactions: [mismatchedRequestBinding] }), + /request\.binding\.runId must match run\.id/u, + ) + + const mismatchedResponseBinding = { + ...interaction, + responseBinding: { + ...interaction.responseBinding, + executionId: 'tangle-execution-not-matching', + }, + } + assert.throws( + () => assertRunRecord({ ...run, interactions: [mismatchedResponseBinding] }), + /responseBinding\.executionId must match request\.binding\.executionId/u, + ) + + assert.throws( + () => + assertRunRecord({ + ...run, + interactions: [{ ...interaction, runId: createRunId('run-not-containing') }], + }), + /runId must match run\.id/u, + ) +}) + +test('canonical run interaction response operations preserve status and secrecy invariants', () => { + const run = providerOwnedRun() + const interaction = canonicalRunInteraction(run, { idSuffix: 'response-operation' }) + const responding = { + ...interaction, + status: 'responding' as const, + responseOperation: { + operationId: createOperationId('operation-interaction-response-invariant'), + outcome: 'accepted' as const, + dataDigest: 'b'.repeat(64) as Digest, + containsSecret: false, + }, + } + + assert.doesNotThrow(() => assertRunRecord({ ...run, interactions: [responding] })) + const automationRule = { + id: createRuleId('rule-interaction-response-invariant'), + enabled: true, + matcher: { interactionKind: 'provider.future.interaction' }, + answer: { answer: 'approved' }, + responseScope: 'once' as const, + createdAt: at, + uses: 0, + } + assert.doesNotThrow(() => + assertRunRecord({ + ...run, + interactions: [ + { + ...responding, + responseOperation: { ...responding.responseOperation, automationRule }, + }, + ], + }), + ) + assert.throws( + () => + assertRunRecord({ + ...run, + interactions: [ + { + ...responding, + responseOperation: { + ...responding.responseOperation, + automationRule: { ...automationRule, answer: { password: 'must-not-persist' } }, + }, + }, + ], + }), + /rule\.answer\.password is secret-designated and cannot be retained/u, + ) + assert.throws( + () => + assertRunRecord({ + ...run, + interactions: [ + { + ...responding, + responseOperation: { ...responding.responseOperation, containsSecret: true }, + }, + ], + }), + /secret responseOperation cannot retain dataDigest/u, + ) + assert.throws( + () => assertRunRecord({ ...run, interactions: [{ ...responding, status: 'declined' }] }), + /responseOperation status does not match its outcome/u, + ) + assert.throws( + () => + assertRunRecord({ + ...run, + interactions: [ + { + ...responding, + responseOperation: { ...responding.responseOperation, operationId: 'not-an-operation' }, + }, + ], + }), + /responseOperation\.operationId is not a valid operation identifier/u, + ) + assert.throws( + () => + assertRunRecord({ + ...run, + interactions: [ + { + ...responding, + responseOperation: { ...responding.responseOperation, dataDigest: 'not-a-digest' }, + }, + ], + }), + /responseOperation\.dataDigest is not a SHA-256 digest/u, + ) + assert.throws( + () => + assertRunRecord({ + ...run, + interactions: [ + { + ...responding, + responseOperation: { + ...responding.responseOperation, + containsSecret: 'false' as unknown as boolean, + }, + }, + ], + }), + /responseOperation\.containsSecret must be boolean/u, + ) +}) + test('run identity keeps local run IDs separate from provider-owned control references', () => { const run = providerOwnedRun() diff --git a/test/domain-reducer.test.ts b/test/domain-reducer.test.ts index bba7561..08a7830 100644 --- a/test/domain-reducer.test.ts +++ b/test/domain-reducer.test.ts @@ -1,16 +1,35 @@ import assert from 'node:assert/strict' import test from 'node:test' import { STARTER_PROFILE } from '../src/app/composition.js' +import { + createInteractionRequest, + interactionRequestMaterial, + interactionResponseBinding, +} from '../src/app/interaction-request.js' +import { canonicalDigest } from '../src/domain/canonical.js' import type { BraidEvent, JournalEventEnvelope } from '../src/domain/events.js' -import { createEventId, createReplayCursor, createWorkspaceId } from '../src/domain/ids.js' +import { + createEventId, + createInteractionId, + createMessageId, + createOperationId, + createReplayCursor, + createRuleId, + createRunId, + createTurnId, + createWorkspaceId, +} from '../src/domain/ids.js' import { DuplicateEventConflictError, reduceEvent, replayEvents, SequenceGapError, } from '../src/domain/reducer.js' +import { MAX_RUN_INTERACTIONS } from '../src/domain/reducer-support.js' import { initialState } from '../src/domain/state.js' +const at = '2026-08-02T00:00:00.000Z' + function envelope( event: BraidEvent, sequence: number, @@ -55,6 +74,303 @@ function verticalSliceEvents(): readonly JournalEventEnvelope[] { ] } +test('replay rejects malformed persisted interaction sources and automation rules', () => { + const runId = createRunId('run-replay-interaction-invariants') + const interactionId = createInteractionId('interaction-replay-invariants') + const request = createInteractionRequest({ + id: interactionId, + kind: 'question', + title: 'Continue after replay?', + answerSpec: { + fields: [{ type: 'boolean', name: 'continue', label: 'Continue', required: true }], + }, + binding: { + runId, + provider: 'fixture', + environmentId: 'environment-replay-interaction', + sessionId: 'session-replay-interaction', + executionId: 'execution-replay-interaction', + interactionId, + }, + }) + const prefix: readonly JournalEventEnvelope[] = [ + envelope({ kind: 'workspace.opened', workspace: '/workspace' }, 1), + envelope( + { + kind: 'run.requested', + operationId: createOperationId('operation-replay-interaction'), + runId, + turnId: createTurnId('turn-replay-interaction'), + userMessageId: createMessageId('message-replay-interaction-user'), + assistantMessageId: createMessageId('message-replay-interaction-assistant'), + text: 'wait for replay', + }, + 2, + ), + ] + const interaction = (providerSequence: number): JournalEventEnvelope => + envelope( + { + kind: 'run.interaction', + runId, + request, + responseBinding: interactionResponseBinding(request), + provider: { + eventId: 'provider-replay-interaction', + providerSequence, + occurredAt: '2026-08-02T00:00:00.000Z', + }, + }, + 3, + ) + + assert.throws( + () => replayEvents(initialState(STARTER_PROFILE), [...prefix, interaction(0)]), + /run\.interactions\[0\]\.source\.sequence must be a positive safe integer/u, + ) + + for (const field of ['key', 'privateKey', 'secretHandle']) { + const invalidRule = { + id: createRuleId(`rule-replay-interaction-invalid-${field}`), + enabled: true, + matcher: { interactionKind: 'question' }, + answer: { [field]: 'must-not-persist' }, + responseScope: 'once' as const, + createdAt: '2026-08-02T00:00:00.000Z', + uses: 0, + } + assert.throws( + () => + replayEvents(initialState(STARTER_PROFILE), [ + ...prefix, + interaction(1), + envelope( + { + kind: 'run.interaction.response.requested', + runId, + interactionId, + operationId: createOperationId(`operation-replay-interaction-response-${field}`), + outcome: 'accepted', + containsSecret: false, + automationRule: invalidRule, + }, + 4, + ), + ]), + new RegExp(`rule\\.answer\\.${field} is secret-designated and cannot be retained`, 'u'), + ) + } +}) + +test('interaction transitions reject reorder, unknown, conflicting, terminal, and evicted events', () => { + const runId = createRunId('run-interaction-transition-contract') + const interactionId = createInteractionId('interaction-transition-contract') + const request = createInteractionRequest({ + id: interactionId, + kind: 'question', + title: 'Continue?', + answerSpec: { + fields: [{ type: 'boolean', name: 'continue', label: 'Continue', required: true }], + }, + binding: { + runId, + provider: 'fixture', + environmentId: 'environment-transition-contract', + sessionId: 'session-transition-contract', + executionId: 'execution-transition-contract', + interactionId, + }, + }) + const prefix: readonly JournalEventEnvelope[] = [ + envelope({ kind: 'workspace.opened', workspace: '/workspace' }, 1), + envelope( + { + kind: 'run.requested', + operationId: createOperationId('operation-transition-contract'), + runId, + turnId: createTurnId('turn-transition-contract'), + userMessageId: createMessageId('message-transition-contract-user'), + assistantMessageId: createMessageId('message-transition-contract-assistant'), + text: 'wait for an answer', + }, + 2, + ), + ] + const interactionEvent = ( + sequence: number, + id = interactionId, + providerSequence = sequence - 2, + providerEventId = `provider-transition-${sequence}`, + requestOverride?: typeof request, + ): JournalEventEnvelope => { + const eventRequest = + requestOverride ?? + (id === interactionId + ? request + : createInteractionRequest({ + id, + kind: 'question', + title: `Question ${id}`, + answerSpec: { + fields: [{ type: 'boolean', name: 'continue', label: 'Continue', required: true }], + }, + binding: { + ...request.binding, + interactionId: id, + }, + })) + return envelope( + { + kind: 'run.interaction', + runId, + request: eventRequest, + responseBinding: interactionResponseBinding(eventRequest), + provider: { + eventId: providerEventId, + providerSequence, + occurredAt: at, + }, + }, + sequence, + createEventId(`event-transition-${sequence}`), + ) + } + const responseRequested = (sequence: number, operation = 'operation-transition-response') => + envelope( + { + kind: 'run.interaction.response.requested', + runId, + interactionId, + operationId: createOperationId(operation), + outcome: 'accepted', + containsSecret: false, + }, + sequence, + createEventId(`event-transition-response-requested-${sequence}`), + ) + const responded = ( + sequence: number, + operation = 'operation-transition-response', + outcome: 'accepted' | 'unknown' = 'accepted', + ) => + envelope( + { + kind: 'run.interaction.responded', + runId, + interactionId, + operationId: createOperationId(operation), + outcome, + containsSecret: false, + }, + sequence, + createEventId(`event-transition-responded-${sequence}`), + ) + + const beforeInteraction = replayEvents(initialState(STARTER_PROFILE), prefix) + assert.throws(() => reduceEvent(beforeInteraction, responded(3)), /Interaction .* is unknown/u) + assert.throws( + () => reduceEvent(beforeInteraction, responseRequested(3)), + /Interaction .* is unknown/u, + ) + + const afterInteraction = reduceEvent(beforeInteraction, interactionEvent(3)) + assert.throws( + () => reduceEvent(afterInteraction, responded(4)), + /cannot be resolved from pending/u, + ) + const conflictingRequest = createInteractionRequest({ + ...interactionRequestMaterial(request), + title: 'Different request data', + }) + assert.throws( + () => + reduceEvent( + afterInteraction, + interactionEvent(4, interactionId, 1, 'provider-transition-3', conflictingRequest), + ), + /was requested with different data/u, + ) + const duplicateInteraction = reduceEvent( + afterInteraction, + interactionEvent(4, interactionId, 1, 'provider-transition-3'), + ) + assert.equal(duplicateInteraction.runs[0]?.interactions.length, 1) + const afterResponseRequested = reduceEvent(duplicateInteraction, responseRequested(5)) + const duplicateResponseRequested = reduceEvent(afterResponseRequested, responseRequested(6)) + assert.equal(duplicateResponseRequested.runs[0]?.interactions[0]?.status, 'responding') + const afterResponded = reduceEvent(duplicateResponseRequested, responded(7)) + const duplicateResponded = reduceEvent(afterResponded, responded(8)) + assert.equal(duplicateResponded.runs[0]?.interactions[0]?.status, 'resolved') + assert.throws( + () => reduceEvent(duplicateResponded, responseRequested(9)), + /cannot transition from resolved to responding/u, + ) + assert.throws( + () => reduceEvent(afterResponded, responded(8, 'operation-transition-other-response')), + /different response result/u, + ) + assert.throws( + () => + reduceEvent( + duplicateResponded, + envelope( + { + kind: 'run.interaction.cancelled', + runId, + interactionId, + provider: { + eventId: 'provider-transition-cancelled', + providerSequence: 7, + occurredAt: at, + }, + }, + 9, + createEventId('event-transition-cancelled'), + ), + ), + /cannot transition from resolved to cancelled/u, + ) + + let evicted = beforeInteraction + for (let index = 0; index < 257; index += 1) { + evicted = reduceEvent( + evicted, + interactionEvent(index + 3, createInteractionId(`interaction-evicted-${index}`)), + ) + } + const evictedRun = evicted.runs[0] + assert.ok(evictedRun) + assert.equal(evictedRun.interactions.length, MAX_RUN_INTERACTIONS) + assert.equal( + evictedRun.interactions.some( + (interaction) => interaction.request.id === 'interaction-evicted-0', + ), + false, + ) + assert.equal(evictedRun.pendingInteractionIds?.includes('interaction-evicted-0'), true) + assert.throws( + () => + reduceEvent( + evicted, + envelope( + { + kind: 'run.interaction.cancelled', + runId, + interactionId: 'interaction-evicted-0', + provider: { + eventId: 'provider-transition-evicted-cancelled', + providerSequence: 258, + occurredAt: at, + }, + }, + 260, + createEventId('event-transition-evicted-cancelled'), + ), + ), + /Interaction interaction-evicted-0 is unknown/u, + ) +}) + test('incremental reduction and full replay produce the same complete projection', () => { const events = verticalSliceEvents() const initial = initialState(STARTER_PROFILE) @@ -75,6 +391,134 @@ test('incremental reduction and full replay produce the same complete projection assert.equal(incremental.health.status, 'healthy') }) +test('retained admission survives the pre-dispatch crash window and binds one exact run', () => { + const environmentAdmission = { + phase: 'environment' as const, + provider: 'cli-bridge', + environmentId: 'environment-retained-admission', + idempotencyKey: 'environment-retained-admission', + turnId: 'turn-retained-admission', + sessionId: 'session-retained-admission', + executionId: 'execution-retained-admission', + } + const dispatchedAdmission = { + phase: 'dispatched' as const, + idempotencyKey: environmentAdmission.idempotencyKey, + turnId: environmentAdmission.turnId, + controlRef: { + provider: environmentAdmission.provider, + environmentId: environmentAdmission.environmentId, + sessionId: environmentAdmission.sessionId, + executionId: environmentAdmission.executionId, + runId: 'provider-run-retained-admission', + requestDigest: `sha256:${'d'.repeat(64)}` as const, + }, + } + const initialEvents = [ + envelope({ kind: 'workspace.opened', workspace: '/workspace' }, 1), + envelope( + { + kind: 'run.requested', + operationId: 'op-retained-admission', + runId: 'run-retained-admission', + turnId: environmentAdmission.turnId, + userMessageId: 'message-user-retained-admission', + assistantMessageId: 'message-assistant-retained-admission', + text: 'retain this run', + }, + 2, + ), + envelope( + { + kind: 'run.retained.admitted', + runId: 'run-retained-admission', + admission: environmentAdmission, + }, + 3, + ), + ] as const + + const interrupted = replayEvents(initialState(STARTER_PROFILE), initialEvents) + assert.deepEqual(interrupted.runs[0]?.retainedAdmission, environmentAdmission) + assert.equal(interrupted.runs[0]?.controlRef, undefined) + + const dispatched = reduceEvent( + interrupted, + envelope( + { + kind: 'run.retained.admitted', + runId: 'run-retained-admission', + admission: dispatchedAdmission, + }, + 4, + ), + ) + assert.deepEqual(dispatched.runs[0]?.retainedAdmission, dispatchedAdmission) + assert.deepEqual(dispatched.runs[0]?.controlRef, dispatchedAdmission.controlRef) + assert.equal(dispatched.runs[0]?.providerSessionId, environmentAdmission.sessionId) + + const harnessSession = reduceEvent( + dispatched, + envelope( + { + kind: 'run.provider.event', + runId: 'run-retained-admission', + envelope: { + runId: 'run-retained-admission', + eventId: 'provider-event-native-session', + sequence: 5, + cursor: 'provider-cursor-5', + receivedAt: '2026-08-02T00:00:05.000Z', + event: { type: 'session.updated', sessionId: 'ses_native_harness' }, + }, + provider: { + eventId: 'provider-event-native-session', + providerSequence: 5, + cursor: 'provider-cursor-5', + receivedAt: '2026-08-02T00:00:05.000Z', + }, + }, + 5, + ), + ) + assert.equal(harnessSession.runs[0]?.providerSessionId, environmentAdmission.sessionId) + assert.equal(harnessSession.runs[0]?.harnessSessionId, 'ses_native_harness') + assert.deepEqual(harnessSession.runs[0]?.controlRef, dispatchedAdmission.controlRef) + assert.equal(harnessSession.runs[0]?.lastCursor, 'provider-cursor-5') + + const retriedEnvironment = reduceEvent( + harnessSession, + envelope( + { + kind: 'run.retained.admitted', + runId: 'run-retained-admission', + admission: environmentAdmission, + }, + 6, + ), + ) + assert.deepEqual(retriedEnvironment.runs[0]?.retainedAdmission, dispatchedAdmission) + + assert.throws( + () => + reduceEvent( + retriedEnvironment, + envelope( + { + kind: 'run.retained.admitted', + runId: 'run-retained-admission', + admission: { + ...environmentAdmission, + environmentId: 'environment-retained-conflict', + }, + }, + 7, + ), + ), + /conflicts with its environment admission/u, + ) +}) + test('a cancellation request that loses the terminal race preserves the proven result', () => { const completed = replayEvents(initialState(STARTER_PROFILE), verticalSliceEvents()) const operationId = 'op-cancel-after-terminal' @@ -514,3 +958,90 @@ test('legacy event payloads still reject cross-domain identifiers at the reducer /Invalid run identifier/u, ) }) + +test('branch updates require one acknowledged run-override operation for the exact branch', () => { + const opened = reduceEvent( + initialState(STARTER_PROFILE), + envelope({ kind: 'workspace.opened', workspace: '/workspace' }, 1), + ) + const branch = opened.branches[0] + assert(branch) + const updated = { + ...branch, + overrides: { runner: 'codex' as const }, + updatedAt: '2026-08-02T00:00:00.001Z', + } + const operation = { + id: 'op-domain-run-override', + kind: 'run-override' as const, + requestDigest: canonicalDigest({ runner: 'codex' }), + status: 'acknowledged' as const, + target: { kind: 'branch' as const, id: branch.id }, + createdAt: '2026-08-02T00:00:00.001Z', + updatedAt: '2026-08-02T00:00:00.001Z', + acknowledgedAt: '2026-08-02T00:00:00.001Z', + } + + const accepted = reduceEvent( + opened, + envelope({ kind: 'branch.updated', branch: updated, operation }, 2), + ) + assert.equal(accepted.branches[0]?.overrides.runner, 'codex') + assert.equal(accepted.operations[0]?.kind, 'run-override') + + assert.throws( + () => + reduceEvent( + opened, + envelope( + { + kind: 'branch.updated', + branch: updated, + operation: { + ...operation, + target: { kind: 'branch', id: 'branch-other' }, + }, + }, + 2, + ), + ), + /does not acknowledge/u, + ) +}) + +test('branch updates reject unsupported override values during replay', () => { + const opened = reduceEvent( + initialState(STARTER_PROFILE), + envelope({ kind: 'workspace.opened', workspace: '/workspace' }, 1), + ) + const branch = opened.branches[0] + assert(branch) + assert.throws( + () => + reduceEvent( + opened, + envelope( + { + kind: 'branch.updated', + branch: { + ...branch, + overrides: { runner: 'not-a-runner' }, + updatedAt: '2026-08-02T00:00:00.001Z', + } as unknown as typeof branch, + operation: { + id: 'op-domain-invalid-run-override', + kind: 'run-override', + requestDigest: canonicalDigest({ runner: 'not-a-runner' }), + status: 'acknowledged', + target: { kind: 'branch', id: branch.id }, + createdAt: '2026-08-02T00:00:00.001Z', + updatedAt: '2026-08-02T00:00:00.001Z', + acknowledgedAt: '2026-08-02T00:00:00.001Z', + }, + }, + 2, + ), + ), + /branch\.overrides\.runner is not supported/u, + ) +}) diff --git a/test/interactions.test.ts b/test/interactions.test.ts index 489677c..83b01ca 100644 --- a/test/interactions.test.ts +++ b/test/interactions.test.ts @@ -8,6 +8,7 @@ import { permissionAnswerSpec, } from '@tangle-network/agent-interface' import { createApplicationUiController } from '../src/adapters/tui/application-ui-controller.js' +import { interactionViews } from '../src/adapters/tui/ui-projection.js' import { AppError, BraidApplication } from '../src/app/application.js' import type { InteractionReceipt } from '../src/app/application-types.js' import { evaluateAutomation } from '../src/app/automation-matching.js' @@ -22,7 +23,6 @@ import { dryRunAutomation, } from '../src/app/automation-rules.js' import { DETERMINISTIC_PROFILE } from '../src/app/composition.js' -import { checkInteractionResponse } from '../src/app/interaction-response.js' import { createInteractionRequest, interactionRequestMaterial, @@ -30,8 +30,10 @@ import { parseInteractionRequest, rebindInteractionRequest, } from '../src/app/interaction-request.js' +import { checkInteractionResponse } from '../src/app/interaction-response.js' import { MemoryJournal } from '../src/app/journal.js' import type { BraidEventEnvelope } from '../src/domain/events.js' +import { interactionRemainingMs } from '../src/domain/interaction-timeout.js' import { replayEvents } from '../src/domain/reducer.js' import type { BraidRuntimeEvent } from '../src/domain/runtime-events.js' import type { BraidInteraction } from '../src/domain/runtime-projection.js' @@ -541,6 +543,24 @@ test('stale and expired interactions are rejected before provider dispatch', asy provider.release() }) +test('interaction presentation and response validation share the remaining timeout', async () => { + const request = questionRequest('interaction-countdown', { + timeoutMs: 10_000, + onTimeout: 'fail', + }) + const provider = interactionExecution(request) + const app = applicationFor(provider.execution) + const receipt = app.send({ operationId: 'operation-send-countdown', text: 'count down' }) + await waitFor(() => app.state().runs[0]?.interactions[0]?.status === 'pending') + + assert.equal(interactionRemainingMs(10_000, NOW, undefined, Date.parse(NOW) + 2_500), 7_500) + assert.equal(interactionViews(app.state(), Date.parse(NOW) + 2_500)[0]?.remainingMs, 7_500) + assert.equal(interactionViews(app.state(), Date.parse(NOW) + 12_000)[0]?.remainingMs, 0) + + provider.release() + await receipt.completion +}) + test('automation rules persist scopes, audit outcomes, limits, and mutations through one journal', async () => { const store = automationStore() const request = questionRequest('interaction-automation', { diff --git a/test/observability.test.ts b/test/observability.test.ts index 4eaabb0..5d294a1 100644 --- a/test/observability.test.ts +++ b/test/observability.test.ts @@ -296,6 +296,14 @@ test('one run projects honest session usage and a linked execution environment', observedAt: at, unavailable: ['machine-ip:not-exposed-by-provider'], } + const controlRef = { + provider: 'tangle-sandbox', + environmentId: 'sandbox-observed', + sessionId: 'session-observed', + executionId: 'execution-observed', + runId: 'provider-run-observed', + requestDigest: `sha256:${'a'.repeat(64)}`, + } as const const execution: ExecutionPort = { admit: () => ({ provider: 'tangle-sandbox', @@ -314,7 +322,7 @@ test('one run projects honest session usage and a linked execution environment', latencyMs: 240, timestamp: at, } - yield { type: 'braid.execution.observed', observation, timestamp: at } + yield { type: 'braid.execution.observed', observation, controlRef, timestamp: at } yield { type: 'final', status: 'completed', @@ -373,6 +381,7 @@ test('one run projects honest session usage and a linked execution environment', assert.equal(headless.sessionUsage.turns.costStatus, 'observed-floor') assert.equal(headless.sessionUsage.turns.estimatedCostUsd, 0.15) assert.equal(headless.environments[0]?.cleanup, 'delete-after-turn') + assert.deepEqual(headless.runs.at(-1)?.controlRef, controlRef) assert.equal(headless.environments[0]?.resourceSample?.cgroupVersion, 2) assert.equal(headless.environments[0]?.accountUsage?.maximumStorageGB, 200) assert.equal(controller.view().environments[0]?.runtimeEndpointHost, 'runtime.example') diff --git a/test/plain-accessibility.test.ts b/test/plain-accessibility.test.ts index cb34e85..0ae424b 100644 --- a/test/plain-accessibility.test.ts +++ b/test/plain-accessibility.test.ts @@ -51,6 +51,7 @@ test('semantic projection keeps detached state and only public interaction reque }), ) assert.deepEqual(detached, { + runId: 'run-plain', status: 'detached', completeness: 'streaming', cursor: 'cursor-plain', diff --git a/test/production-composition.test.ts b/test/production-composition.test.ts index 4b6ef76..b250e5f 100644 --- a/test/production-composition.test.ts +++ b/test/production-composition.test.ts @@ -21,6 +21,11 @@ import { } from '@tangle-network/agent-interface' import { HeadlessCredentialStore } from '../src/adapters/credentials/headless-store.js' import { MemoryCredentialStore } from '../src/adapters/credentials/memory.js' +import { + materializeBridgeModelRoute, + portableBridgeModel, + qualifyBridgeProfileModel, +} from '../src/adapters/connections/cli-bridge-model-route.js' import { resolveProductionBackend } from '../src/adapters/runtime/production-backend-resolver.js' import { ApplicationUiController } from '../src/adapters/tui/application-ui-controller.js' import { @@ -221,7 +226,7 @@ test('production composition persists acknowledged CLI Bridge cancellation', asy app.initialize('/workspace') const send = app.send({ operationId: 'op-production-cancel', text: 'cancel production' }) await send.admissionReady - await waitForCondition(() => bridge.requests.length === 1) + await waitForCondition(() => app.state().runs[0]?.controlRef !== undefined, 5_000) const cancellation = app.cancel({ operationId: 'op-production-cancel-request', @@ -307,6 +312,7 @@ test('durable composition uses the configured backend on the normal startup path }) try { app.initialize(root) + await app.whenDurable() const state = await app.send({ operationId: 'op-durable-production-turn', text: 'run durable production', @@ -503,6 +509,18 @@ test('CLI routing overrides agree with the canonical profile passed to runtime', } }) +test('CLI Bridge routes preserve nested model ids below the selected provider', () => { + const model = 'openai/gpt-5.6-luna' + const qualified = 'tangle-router/openai/gpt-5.6-luna' + const routed = `pi/${qualified}` + + assert.equal(qualifyBridgeProfileModel(model, 'tangle-router'), qualified) + assert.equal(qualifyBridgeProfileModel(qualified, 'tangle-router'), qualified) + assert.equal(portableBridgeModel('pi', routed, 'tangle-router'), qualified) + assert.equal(materializeBridgeModelRoute('pi', model, 'tangle-router'), routed) + assert.equal(materializeBridgeModelRoute('codex', 'codex/default', 'codex'), 'codex/default') +}) + test('schema-v1 CLI Bridge profiles load as portable models and dispatch one runner prefix', async () => { const cases = [ { @@ -1041,9 +1059,18 @@ test('protected Bridge auth survives setup, restart, and a real turn without per assert.match(String(secondBody.run_id), /^run-/u) assert.notEqual(secondBody.run_id, firstBody.run_id) assert.notEqual(secondBody.session_id, firstBody.session_id) + for (const body of [firstBody, secondBody]) { + const runId = String(body.run_id) + assert.deepEqual(body.metadata, { + retainedIdempotencyKey: `environment-braid-${runId}`, + sessionId: `session-braid-${runId}`, + executionId: runId, + }) + } const stableBody = ({ run_id: _runId, session_id: _sessionId, + metadata: _metadata, ...body }: Record) => body assert.deepEqual(stableBody(secondBody), stableBody(firstBody)) diff --git a/test/profiles.test.ts b/test/profiles.test.ts index 9f5db3b..a5c6b01 100644 --- a/test/profiles.test.ts +++ b/test/profiles.test.ts @@ -217,7 +217,7 @@ test('the installed canonical profile schema round-trips every field and extensi assert.equal(canonicalCandidateJson(imported.profile), json) assert.deepEqual(imported.profile.extensions, fullProfile.extensions) assert.deepEqual(imported.profile.resources, fullProfile.resources) - assert.equal(AGENT_INTERFACE_PACKAGE_VERSION, '0.47.0') + assert.equal(AGENT_INTERFACE_PACKAGE_VERSION, '0.52.0') }) test('unknown canonical fields fail closed while namespaced extensions remain opaque', () => { diff --git a/test/reducer.test.ts b/test/reducer.test.ts index 40be87c..320e09d 100644 --- a/test/reducer.test.ts +++ b/test/reducer.test.ts @@ -1,11 +1,10 @@ import assert from 'node:assert/strict' import test from 'node:test' +import { buildBraidViewModel } from '../src/adapters/tui/ui-view-model.js' import { STARTER_PROFILE } from '../src/app/composition.js' -import { buildAppView } from '../src/app/view-model.js' import type { BraidEvent, BraidEventEnvelope } from '../src/domain/events.js' import { replayEvents } from '../src/domain/reducer.js' import { initialState } from '../src/domain/state.js' -import { MAX_RENDERED_TEXT_CHARS } from '../src/views/shared/sanitize.js' function envelopes(events: readonly BraidEvent[]): BraidEventEnvelope[] { return events.map((event, index) => ({ @@ -46,21 +45,13 @@ test('10,000 streamed events replay without duplication or event loss', () => { ] const state = replayEvents(initialState(STARTER_PROFILE), envelopes(events)) - const view = buildAppView(state) + const view = buildBraidViewModel(state) assert.equal(state.sequence, deltaCount + 4) assert.equal(state.messages[1]?.text.length, delta.length * deltaCount) assert.equal(state.messages[1]?.text, response) assert.equal(view.messages.length, 2) - assert.equal(view.messages[1]?.text.length, MAX_RENDERED_TEXT_CHARS) - assert.equal(view.messages[1]?.text.startsWith('…\n'), true) - assert.equal( - view.messages[1]?.text.endsWith( - Array.from(response) - .slice(-(MAX_RENDERED_TEXT_CHARS - 2)) - .join(''), - ), - true, - ) + assert.equal(view.messages[1]?.text.length, response.length) + assert.equal(view.messages[1]?.text, response) }) test('replay rejects a sequence gap', () => { diff --git a/test/rpc.test.ts b/test/rpc.test.ts index c3389cb..185af45 100644 --- a/test/rpc.test.ts +++ b/test/rpc.test.ts @@ -111,19 +111,38 @@ test('JSONL send acknowledges before events and returns final semantic state', a const firstRunEvent = responses.findIndex( (response) => response.type === 'event' && response.event.kind === 'run.requested', ) - const finalState = responses.find( + const sendStates = responses.filter( (response) => response.type === 'state' && response.requestId === 'req-send', ) + const admissionState = sendStates.find( + (response) => + response.type === 'state' && response.projection === 'full' && response.state.activeRunId, + ) + const finalState = sendStates.at(-1) assert.equal(code, 0) assert.ok(sendAck >= 0) assert.ok(firstRunEvent > sendAck) + const sendResponse = responses[sendAck] + assert.equal(sendResponse?.type, 'ack') + if (sendResponse?.type !== 'ack') assert.fail('missing send acknowledgement') + assert.equal(admissionState?.type, 'state') + if (admissionState?.type !== 'state') assert.fail('missing admission state') + assert.equal(admissionState.projection, 'full') + if (admissionState.projection !== 'full') assert.fail('expected full admission state') + assert.equal(admissionState.state.activeRunId, sendResponse.runId) assert.equal(finalState?.type, 'state') if (finalState?.type !== 'state') assert.fail('missing final state') assert.equal(finalState.projection, 'full') if (finalState.projection !== 'full') assert.fail('expected full state') assert.equal(finalState.state.messages[1]?.text, 'Fixture response through pi: hello Braid') assert.equal(finalState.state.runs[0]?.status, 'completed') + assert.equal(typeof finalState.state.runs[0]?.startedAt, 'string') + assert.equal(typeof finalState.state.runs[0]?.updatedAt, 'string') + assert.equal(typeof finalState.state.runs[0]?.terminalAt, 'string') + assert.equal(typeof finalState.state.runs[0]?.durationMs, 'number') + assert.equal(typeof finalState.state.runs[0]?.tokensKnown, 'boolean') + assert.equal(typeof finalState.state.runs[0]?.usdKnown, 'boolean') }) test('JSONL drives the complete canonical conversation lifecycle', async () => { @@ -361,6 +380,153 @@ test('JSONL drives the complete canonical conversation lifecycle', async () => { ) }) +test('JSONL run configuration is replay safe, visible, and reaches Runtime', async () => { + let streamedProfile: unknown + const execution: ExecutionPort = { + admit: () => ({}), + async *streamTurn(input): AsyncIterable { + streamedProfile = structuredClone(input.profile) + yield { + type: 'final', + status: 'completed', + reason: 'JSONL run configuration completed', + text: 'configured JSONL run completed', + metadata: { tokenUsage: { input: 1, output: 1 } }, + task: { id: 'rpc-run-configuration', intent: 'JSONL run configuration' }, + timestamp: '2026-08-03T00:00:00.000Z', + } + }, + } + const app = createBraidApplication({ fixture: 'deterministic', execution }) + const responses: BraidResponse[] = [] + async function* input(): AsyncGenerator { + yield `${JSON.stringify({ + version: 1, + requestId: 'run-configuration-init', + command: 'initialize', + params: { workspace: '/workspace' }, + })}\n` + const override = { + version: 1, + operationId: 'op-rpc-run-configuration', + command: 'set_run_override', + params: { + runner: 'codex', + model: 'openai/gpt-5.6', + effort: 'xhigh', + mode: 'interactive', + }, + } + yield `${JSON.stringify({ ...override, requestId: 'run-configuration-set' })}\n` + yield `${JSON.stringify({ ...override, requestId: 'run-configuration-replay' })}\n` + yield `${JSON.stringify({ + version: 1, + requestId: 'run-configuration-send', + operationId: 'op-rpc-run-configuration-send', + command: 'send', + params: { text: 'use the JSONL branch configuration' }, + })}\n` + await app.waitForIdle() + yield `${JSON.stringify({ + version: 1, + requestId: 'run-configuration-state', + command: 'get_state', + })}\n` + yield `${JSON.stringify({ + version: 1, + requestId: 'run-configuration-clear', + operationId: 'op-rpc-run-configuration-clear', + command: 'set_run_override', + params: { clear: true }, + })}\n` + yield `${JSON.stringify({ + version: 1, + requestId: 'run-configuration-cleared-state', + command: 'get_state', + })}\n` + yield `${JSON.stringify({ + version: 1, + requestId: 'run-configuration-stop', + operationId: 'op-rpc-run-configuration-stop', + command: 'shutdown', + })}\n` + } + + const code = await runRpc(controllerFor(app), input(), { + write: responseWriter(responses), + }) + const configured = resultFor<{ readonly overrides: Readonly> }>( + responses, + 'run-configuration-set', + ) + const replayed = resultFor<{ readonly overrides: Readonly> }>( + responses, + 'run-configuration-replay', + ) + const cleared = resultFor<{ readonly overrides: Readonly> }>( + responses, + 'run-configuration-clear', + ) + const configuredState = responses.find( + (response) => response.type === 'state' && response.requestId === 'run-configuration-state', + ) + const clearedState = responses.find( + (response) => + response.type === 'state' && response.requestId === 'run-configuration-cleared-state', + ) + + assert.equal(code, 0) + assert.deepEqual(configured.overrides, { + runner: 'codex', + model: 'openai/gpt-5.6', + effort: 'xhigh', + mode: 'interactive', + }) + assert.deepEqual(replayed, configured) + assert.deepEqual(cleared.overrides, {}) + assert.deepEqual(streamedProfile, { + ...DETERMINISTIC_PROFILE, + harness: 'codex', + model: { + ...DETERMINISTIC_PROFILE.model, + default: 'openai/gpt-5.6', + reasoningEffort: 'xhigh', + }, + }) + assert(configuredState?.type === 'state' && configuredState.projection === 'full') + assert.deepEqual(configuredState.state.runConfiguration, { + profileName: DETERMINISTIC_PROFILE.name, + runner: 'codex', + model: 'openai/gpt-5.6', + effort: 'xhigh', + mode: 'interactive', + overrides: { + runner: 'codex', + model: 'openai/gpt-5.6', + effort: 'xhigh', + mode: 'interactive', + }, + }) + assert(clearedState?.type === 'state' && clearedState.projection === 'full') + assert.equal(clearedState.state.runConfiguration.runner, DETERMINISTIC_PROFILE.harness) + assert.equal(clearedState.state.runConfiguration.model, DETERMINISTIC_PROFILE.model?.default) + assert.deepEqual(clearedState.state.runConfiguration.overrides, {}) + assert.equal( + app + .events() + .filter( + (entry) => + entry.event.kind === 'branch.updated' && + entry.event.operation.id === 'op-rpc-run-configuration', + ).length, + 1, + ) + assert.equal( + responses.some((response) => response.type === 'error'), + false, + ) +}) + test('JSONL accepts a valid inline conversation import larger than one MiB', async () => { const source = createBraidApplication({ fixture: 'deterministic' }) source.initialize('/workspace') @@ -465,6 +631,13 @@ test('JSONL cancel interrupts an active send and reports the terminal state', as command: 'cancel_run', params: { runId: 'run-000001', reason: requestedReason }, }, + { + version: 1, + requestId: 'req-cancel-replay', + operationId: 'op-cancel-active', + command: 'cancel_run', + params: { runId: 'run-000001', reason: requestedReason }, + }, { version: 1, requestId: 'req-stop', @@ -510,6 +683,13 @@ test('JSONL cancel interrupts an active send and reports the terminal state', as if (cancelState?.type !== 'state') assert.fail('missing cancellation state') if (cancelState.projection !== 'full') assert.fail('expected full cancellation state') assert.equal(cancelState.state.runs[0]?.status, 'aborted') + const cancelReplay = responses.find( + (response) => response.type === 'ack' && response.requestId === 'req-cancel-replay', + ) + assert.equal(cancelReplay?.type, 'ack') + if (cancelReplay?.type !== 'ack') assert.fail('missing cancellation replay acknowledgement') + assert.equal(cancelReplay.replayed, true) + assert.equal(cancelReplay.outcome, 'accepted') }) test('RPC and plain shutdown exit at the drain deadline for a never-ending iterator', async () => { @@ -1165,12 +1345,14 @@ test('JSONL operation replay returns current state after later sends', async () .trim() .split('\n') .map((line) => JSON.parse(line) as BraidResponse) - .find((response) => response.type === 'state' && response.requestId === 'req-a-replay') + .filter((response) => response.type === 'state' && response.requestId === 'req-a-replay') + .at(-1) const stateAfterSecondSend = output .trim() .split('\n') .map((line) => JSON.parse(line) as BraidResponse) - .find((response) => response.type === 'state' && response.requestId === 'req-b') + .filter((response) => response.type === 'state' && response.requestId === 'req-b') + .at(-1) assert.equal(replayState?.type, 'state') if (replayState?.type !== 'state') assert.fail('missing replay state') @@ -1516,7 +1698,6 @@ test('semantic queries enforce scope, redact details, preserve ordering, and sur branches: [...state.branches].reverse(), turns: [...state.turns].reverse(), runs: [...state.runs].reverse(), - interactions: [...state.interactions].reverse(), analyses: [...state.analyses].reverse(), environments: [...state.environments].reverse(), checkpoints: [...state.checkpoints].reverse(), diff --git a/test/scripts.test.ts b/test/scripts.test.ts index 457990e..4d4c7cf 100644 --- a/test/scripts.test.ts +++ b/test/scripts.test.ts @@ -761,6 +761,11 @@ test('release keys stay isolated while publication uses the installed product', /name: Build and use one immutable candidate[\s\S]*?run: pnpm release:prepare/u, ) + const publish = job('publish', 'post-publish-smoke') + assert.doesNotMatch(publish, /already exists; checking|if npm view/iu) + assert.match(publish, /node scripts\/release\/check-registry-collision\.mjs/u) + assert.match(publish, /if: steps\.registry\.outputs\.status == 'available'/u) + const candidateSmoke = job('platform-smoke', 'publish') const registrySmoke = job('post-publish-smoke', 'finalize') const packageSmoke = await readFile('scripts/release/smoke-package.mjs', 'utf8') diff --git a/test/security.test.ts b/test/security.test.ts index 230dfa9..fd34a8d 100644 --- a/test/security.test.ts +++ b/test/security.test.ts @@ -931,6 +931,29 @@ test('secret-designated interaction values are rejected before journal persisten error.name === 'StorageError' && error.message.includes('Secret-bearing'), ) + assert.doesNotThrow(() => + assertPersistablePayload({ + receipt: { + capabilities: { + environment: { interactions: { secretAnswers: false } }, + }, + }, + }), + ) + assert.throws( + () => + assertPersistablePayload({ + receipt: { + capabilities: { + environment: { interactions: { secretAnswers: 'never persist this' } }, + }, + }, + }), + (error: unknown) => + error instanceof Error && + error.name === 'StorageError' && + error.message.includes('Secret-bearing'), + ) assert.doesNotThrow(() => assertPersistablePayload({ spend: { tokens: { input: 12, output: 7 } } }), ) diff --git a/test/storage-snapshots.test.ts b/test/storage-snapshots.test.ts index a623818..44e00bb 100644 --- a/test/storage-snapshots.test.ts +++ b/test/storage-snapshots.test.ts @@ -13,6 +13,10 @@ import { } from '../src/adapters/storage/sqlite-driver.js' import { applyConnectionPragmas } from '../src/adapters/storage/sqlite-schema.js' import { STARTER_PROFILE } from '../src/app/composition.js' +import { + createInteractionRequest, + interactionResponseBinding, +} from '../src/app/interaction-request.js' import { StorageJournal } from '../src/app/storage-journal.js' import { toJson } from '../src/app/storage-journal-support.js' import { canonicalDigest } from '../src/domain/canonical.js' @@ -20,12 +24,20 @@ import type { BraidEventEnvelope } from '../src/domain/events.js' import { createConversationId, createEventId, + createInteractionId, + createMessageId, createOperationId, + createRuleId, createRunId, + createTurnId, createWorkspaceId, } from '../src/domain/ids.js' -import { createMaterializedStateSnapshot } from '../src/domain/materialized-state-snapshot.js' -import { reduceEvent } from '../src/domain/reducer.js' +import { + createMaterializedStateSnapshot, + restoreMaterializedState, +} from '../src/domain/materialized-state-snapshot.js' +import { canonicalProjectionChecksum } from '../src/domain/projection-checksum.js' +import { reduceEvent, replayEvents } from '../src/domain/reducer.js' import { initialState } from '../src/domain/state.js' import { type CredentialRef, credentialRef } from '../src/ports/credentials.js' import type { JournalEvent } from '../src/ports/storage.js' @@ -105,6 +117,241 @@ function fixture(): SnapshotFixture { return { events, snapshots } } +test('migrates legacy interactions, rejects snapshot conflicts, and recomputes the projection checksum', () => { + const interactionId = createInteractionId('interaction-snapshot') + const runId = createRunId('run-snapshot-interaction') + const request = createInteractionRequest({ + id: interactionId, + kind: 'question', + title: 'Continue after restart?', + answerSpec: { + fields: [{ type: 'boolean', name: 'continue', label: 'Continue', required: true }], + }, + binding: { + runId, + provider: 'fixture', + environmentId: 'environment-snapshot-interaction', + sessionId: 'session-snapshot-interaction', + executionId: 'execution-snapshot-interaction', + interactionId, + }, + }) + const at = '2026-08-03T00:00:00.000Z' + const state = replayEvents(initialState(STARTER_PROFILE, { conversationId }), [ + { + eventId: createEventId('event-snapshot-interaction-workspace'), + sequence: 1, + revision: 1, + occurredAt: at, + event: { kind: 'workspace.opened', workspace: '/workspace' }, + }, + { + eventId: createEventId('event-snapshot-interaction-requested'), + sequence: 2, + revision: 2, + occurredAt: at, + event: { + kind: 'run.requested', + operationId: createOperationId('operation-snapshot-interaction'), + runId, + turnId: createTurnId('turn-snapshot-interaction'), + userMessageId: createMessageId('message-snapshot-interaction-user'), + assistantMessageId: createMessageId('message-snapshot-interaction-assistant'), + text: 'wait for the answer', + }, + }, + { + eventId: createEventId('event-snapshot-interaction'), + sequence: 3, + revision: 3, + occurredAt: at, + event: { + kind: 'run.interaction', + runId, + request, + responseBinding: interactionResponseBinding(request), + provider: { + eventId: 'provider-snapshot-interaction', + providerSequence: 1, + occurredAt: at, + }, + }, + }, + ]) + const snapshot = createMaterializedStateSnapshot({ + scopeId: 'snapshot-legacy-interaction', + generation: state.sequence, + eventId: createEventId('event-snapshot-interaction'), + state, + }) + assert.equal(Object.hasOwn(snapshot.state, 'interactions'), false) + + const legacyState = { + ...snapshot.state, + interactions: [ + { + id: interactionId, + runId, + request: { + id: interactionId, + kind: request.kind, + title: request.title, + answerSpec: request.answerSpec, + }, + status: 'pending', + createdAt: at, + updatedAt: at, + }, + ], + } + const restored = restoreMaterializedState({ + ...snapshot, + state: legacyState, + stateChecksum: canonicalDigest(legacyState), + }) + assert.equal(Object.hasOwn(restored, 'interactions'), false) + assert.deepEqual( + restored.runs.find((run) => run.id === runId)?.interactions.map((item) => item.request.id), + [interactionId], + ) + assert.equal(restored.projectionChecksum, canonicalProjectionChecksum(restored)) + + const topLevelOnlyState = { + ...snapshot.state, + runs: snapshot.state.runs.map((run) => + run.id === runId ? { ...run, interactions: [], pendingInteractionIds: [] } : run, + ), + interactions: legacyState.interactions, + } + const topLevelOnly = restoreMaterializedState({ + ...snapshot, + state: topLevelOnlyState, + stateChecksum: canonicalDigest(topLevelOnlyState), + }) + assert.deepEqual( + topLevelOnly.runs.find((run) => run.id === runId)?.interactions.map((item) => item.request.id), + [interactionId], + ) + assert.equal(topLevelOnly.projectionChecksum, canonicalProjectionChecksum(topLevelOnly)) + + const hiddenPendingState = { + ...snapshot.state, + runs: snapshot.state.runs.map((run) => + run.id === runId + ? { + ...run, + interactions: [], + interactionsTruncated: true, + pendingInteractionIds: [interactionId], + } + : run, + ), + interactions: legacyState.interactions, + } + assert.throws( + () => + restoreMaterializedState({ + ...snapshot, + state: hiddenPendingState, + stateChecksum: canonicalDigest(hiddenPendingState), + }), + /conflicts with canonical pending identity/u, + ) + + const divergentState = { + ...snapshot.state, + interactions: [ + { + ...legacyState.interactions[0], + request: { + ...legacyState.interactions[0]?.request, + title: 'Different title from the canonical run', + }, + }, + ], + } + assert.throws( + () => + restoreMaterializedState({ + ...snapshot, + state: divergentState, + stateChecksum: canonicalDigest(divergentState), + }), + /conflicts with canonical run state/u, + ) + + const conflictingState = { + ...snapshot.state, + interactions: [{ ...legacyState.interactions[0], status: 'resolved' }], + } + assert.throws( + () => + restoreMaterializedState({ + ...snapshot, + state: conflictingState, + stateChecksum: canonicalDigest(conflictingState), + }), + /status conflicts with canonical run state/u, + ) + + const malformedSourceState = { + ...snapshot.state, + runs: snapshot.state.runs.map((run) => ({ + ...run, + interactions: run.interactions.map((item) => ({ + ...item, + source: { ...item.source, sequence: 0 }, + })), + })), + } + assert.throws( + () => + restoreMaterializedState({ + ...snapshot, + state: malformedSourceState, + stateChecksum: canonicalDigest(malformedSourceState), + }), + /run\.interactions\[0\]\.source\.sequence must be a positive safe integer/u, + ) + + for (const field of ['key', 'privateKey', 'secretHandle']) { + const malformedRule = { + id: createRuleId(`rule-snapshot-interaction-invalid-${field}`), + enabled: true, + matcher: { interactionKind: 'question' }, + answer: { [field]: 'must-not-persist' }, + responseScope: 'once' as const, + createdAt: at, + uses: 0, + } + const malformedRuleState = { + ...snapshot.state, + runs: snapshot.state.runs.map((run) => ({ + ...run, + interactions: run.interactions.map((item) => ({ + ...item, + status: 'responding' as const, + responseOperation: { + operationId: createOperationId(`operation-snapshot-interaction-response-${field}`), + outcome: 'accepted' as const, + containsSecret: false, + automationRule: malformedRule, + }, + })), + })), + } + assert.throws( + () => + restoreMaterializedState({ + ...snapshot, + state: malformedRuleState, + stateChecksum: canonicalDigest(malformedRuleState), + }), + new RegExp(`rule\\.answer\\.${field} is secret-designated and cannot be retained`, 'u'), + ) + } +}) + async function withRawDatabase( root: string, credentials: MemoryCredentialStore, diff --git a/test/storage.test.ts b/test/storage.test.ts index 45c15ff..40992df 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -15,6 +15,7 @@ import { import { StorageError } from '../src/adapters/storage/sqlite-errors.js' import { applyConnectionPragmas } from '../src/adapters/storage/sqlite-schema.js' import { StorageJournal } from '../src/app/storage-journal.js' +import { STARTER_PROFILE } from '../src/app/composition.js' import { canonicalDigest } from '../src/domain/canonical.js' import { createConversationId, @@ -24,6 +25,7 @@ import { createWorkspaceId, } from '../src/domain/ids.js' import { FixedClock } from '../src/ports/clock.js' +import { initialState } from '../src/domain/state.js' import type { CredentialPort } from '../src/ports/credentials.js' import { credentialRef } from '../src/ports/credentials.js' import type { JournalEvent, JsonValue, StoragePort } from '../src/ports/storage.js' @@ -322,6 +324,43 @@ test('MemoryStorage remains behind StoragePort for deterministic duplicate and g ) }) +test('storage journal materializes provider replay identity for restart and deduplication', async () => { + const storage = new MemoryStorage() + const clock = new FixedClock('2026-08-02T00:00:00.000Z') + const journal = await StorageJournal.fromStorage(storage, clock) + const state = initialState(STARTER_PROFILE) + const provider = { + eventId: 'provider-event-cursor-7', + providerSequence: 7, + cursor: 'cursor-7', + receivedAt: '2026-08-02T00:00:01.000Z', + } + const envelope = journal.envelope(state, { + kind: 'run.provider.event', + runId: 'run-storage-provider-cursor', + envelope: { + runId: 'run-storage-provider-cursor', + eventId: provider.eventId, + sequence: provider.providerSequence, + cursor: provider.cursor, + receivedAt: provider.receivedAt, + event: { type: 'session.updated', sessionId: 'ses_storage_cursor' }, + }, + provider, + }) + + assert.equal(envelope.cursor, provider.cursor) + assert.equal((await journal.append(envelope)).appended, true) + const stored = (await storage.events())[0] + assert.equal(stored?.cursor, provider.cursor) + assert.equal(stored?.providerEventId, provider.eventId) + assert.equal(stored?.receivedAt, provider.receivedAt) + assert.equal( + (await storage.replay({ runId: createRunId('run-storage-provider-cursor') })).lastCursor, + provider.cursor, + ) +}) + test('MemoryStorage records post-terminal audit events but rejects output mutation', async () => { const storage = new MemoryStorage() await assertPostTerminalJournalPolicy(storage) diff --git a/test/support/tangle-retained-sandbox.ts b/test/support/tangle-retained-sandbox.ts index 876ad26..ce99ce9 100644 --- a/test/support/tangle-retained-sandbox.ts +++ b/test/support/tangle-retained-sandbox.ts @@ -39,7 +39,7 @@ export interface FakeRetainedBox { deleted: boolean } -/** Stateful double for the exact Tangle SDK surface used by provider 0.6.3. */ +/** Stateful double for the exact Tangle SDK surface used by provider 0.10.0. */ export class FakeTangleRetainedSandbox { readonly createCalls: CreateSandboxOptions[] = [] readonly dispatches: Array<{ @@ -64,6 +64,9 @@ export class FakeTangleRetainedSandbox { client(): SandboxClientLike { return { + async fetch() { + throw new Error('The lazy capability probe must not call the Sandbox transport') + }, create: async (options) => { const create = structuredClone(options ?? {}) this.createCalls.push(create) @@ -117,6 +120,14 @@ export class FakeTangleRetainedSandbox { ...(box.name === undefined ? {} : { name: box.name }), ...(box.metadata === undefined ? {} : { metadata: box.metadata }), status: box.deleted ? 'stopped' : 'running', + async capabilities() { + return { + schema: 1, + dispatch: { runControlRef: true, executionIdOnAdmission: true }, + cancel: { canonicalRunCancellation: true, digestBound: true, idempotent: true }, + runs: { executionScopedStatus: true, eventReplay: true }, + } + }, async refresh() {}, async delete() { if (sandbox.failDelete) throw new Error('Injected Tangle delete failure') @@ -206,7 +217,17 @@ export class FakeTangleRetainedSandbox { const execution = [...sandbox.#executions.values()] .filter((candidate) => candidate.sessionId === sessionId) .at(-1) - return { status: execution?.status ?? 'running' } + return { + status: execution?.status ?? 'running', + ...(execution === undefined + ? {} + : { + activeExecutionId: + execution.status === 'running' ? execution.executionId : undefined, + latestExecutionId: execution.executionId, + runControlRef: execution.controlRef, + }), + } }, async *events() {}, async result(options) { @@ -348,11 +369,7 @@ export async function prepareFakeTangleRetainedConnection(input: { name: 'tangle-sandbox', capabilities: declared, }) - const reported = await provider.capabilities() - const capabilities = Object.freeze({ - ...reported, - sessions: Object.freeze({ ...reported.sessions, continue: false }), - }) + const capabilities = await provider.capabilities() return Object.freeze({ profile: input.profile, model, @@ -372,7 +389,7 @@ export async function prepareFakeTangleRetainedConnection(input: { backend: 'environment-provider', lifecycle: 'retained', cleanup: 'explicit', - continuity: 'unavailable', + continuity: 'session', portableContext: 'unavailable', model, runner, diff --git a/test/tangle-retained-lifecycle.test.ts b/test/tangle-retained-lifecycle.test.ts index a37f4e9..48784d2 100644 --- a/test/tangle-retained-lifecycle.test.ts +++ b/test/tangle-retained-lifecycle.test.ts @@ -16,6 +16,7 @@ import type { ObservableSandboxClient } from '../src/adapters/runtime/sandbox-ob import type { ConnectionRecord, ConnectionTransportOptions } from '../src/domain/entities.js' import { createConnectionId } from '../src/domain/ids.js' import { assertConnectionRecord } from '../src/domain/invariants-profile.js' +import type { RetainedRunAdmissionRecord } from '../src/ports/execution.js' import { FakeTangleRetainedSandbox, prepareFakeTangleRetainedConnection, @@ -52,6 +53,7 @@ function setup(sandbox: FakeTangleRetainedSandbox, providerSessionId?: string) { select: () => ({ connection: { connectionId: connection.id } }), sandboxClient: sandbox.client(), } + const admissions: RetainedRunAdmissionRecord[] = [] const input = { operationId: 'operation-tangle-retained', runId: 'run/tangle-retained', @@ -59,10 +61,13 @@ function setup(sandbox: FakeTangleRetainedSandbox, providerSessionId?: string) { profile, connectionId: connection.id, signal: new AbortController().signal, + onRetainedAdmission: async (admission: RetainedRunAdmissionRecord) => { + admissions.push(structuredClone(admission)) + }, ...(providerSessionId === undefined ? {} : { sessionId: providerSessionId }), } const selection = { connection: { connectionId: connection.id } } - return { connection, options, input, selection } + return { admissions, connection, options, input, selection } } test('retained lifecycle configuration is explicit and bounded', () => { @@ -173,15 +178,28 @@ test('retained policy preserves account observation methods from the Sandbox cli assert.deepEqual(await retained.usage?.(), usage) }) -test('retained capability and resolution fail closed without provider-backed lookup', async () => { +test('retained capability and resolution fail closed without exact lookup methods', async () => { const sandbox = new FakeTangleRetainedSandbox() - const { connection, options, input, selection } = setup(sandbox) + const configured = setup(sandbox) + const source = configured.options.sandboxClient + if (source.fetch === undefined || source.get === undefined) { + throw new Error('Fake retained client requires fetch and get') + } + const options = { + ...configured.options, + sandboxClient: { + create: source.create, + fetch: source.fetch, + get: source.get, + }, + } + const { connection, input, selection } = configured const report = await capabilitiesForConnection(connection, options) assert.equal(report.runtime.streaming.live, false) assert.equal(report.actions.stream, false) await assert.rejects( resolveTangleSandboxRetainedConnection(options, input, selection, connection.id), - /requires provider-backed lookup/u, + /requires Sandbox list and get/u, ) assert.equal(sandbox.createCalls.length, 0) @@ -191,31 +209,51 @@ test('retained capability and resolution fail closed without provider-backed loo ) }) -test('retained resolution still requires exact control after lookup is configured', async () => { +test('published Sandbox methods recover the exact retained dispatch without an injected lookup', async () => { const sandbox = new FakeTangleRetainedSandbox() const { connection, options, input, selection } = setup(sandbox) - await assert.rejects( - resolveTangleSandboxRetainedConnection( - { ...options, tangleRetainedControlLookup: async () => null }, - input, - selection, - connection.id, - ), - /did not report exact retained-run control/u, + const report = await capabilitiesForConnection(connection, options) + assert.equal(report.runtime.streaming.live, true) + assert.equal(report.actions.stream, true) + + const prepared = await resolveTangleSandboxRetainedConnection( + options, + input, + selection, + connection.id, + ) + assert.equal(await prepared.discoverControlRef(input.runId), null) + const handle = await startTangleRetainedRun( + createTangleRetainedPlan(prepared, input.runId), + input, ) + assert.deepEqual(await prepared.discoverControlRef(input.runId), handle.controlRef) +}) + +test('retained resolution admits exact control when provider lookup is configured', async () => { + const sandbox = new FakeTangleRetainedSandbox() + const { connection, options, input, selection } = setup(sandbox) + const prepared = await resolveTangleSandboxRetainedConnection( + { ...options, tangleRetainedControlLookup: async () => null }, + input, + selection, + connection.id, + ) + assert.equal(prepared.capabilities.retainedControl?.exactRunIdentity, true) + assert.equal(prepared.capabilities.streaming.detach, true) assert.equal(sandbox.createCalls.length, 0) }) test('one retained plan uses exact tags, bounded idle expiry, replay, and result identity', async () => { const sandbox = new FakeTangleRetainedSandbox() - const { input } = setup(sandbox) + const { admissions, input } = setup(sandbox) const prepared = await prepareFakeTangleRetainedConnection({ sandbox, profile, runId: input.runId, }) const plan = createTangleRetainedPlan(prepared, input.runId) - assert.equal(plan.capabilities.sessions.continue, false) + assert.equal(plan.capabilities.sessions.continue, true) assert.equal(plan.capabilities.controls.status, false) const handle = await startTangleRetainedRun(plan, input) @@ -224,11 +262,20 @@ test('one retained plan uses exact tags, bounded idle expiry, replay, and result assert.equal(sandbox.createCalls[0]?.name, prepared.environmentName) assert.equal(sandbox.createCalls[0]?.idleTimeoutSeconds, 1_800) assert.equal(sandbox.createCalls[0]?.ephemeral, false) - assert.deepEqual(sandbox.createCalls[0]?.metadata, prepared.environmentMetadata) + assert.deepEqual(sandbox.createCalls[0]?.metadata, { + ...prepared.environmentMetadata, + retainedIdempotencyKey: prepared.environmentIdempotencyKey, + sessionId: prepared.providerSessionId, + executionId: 'run-tangle-retained', + }) assert.equal(handle.controlRef.environmentId, sandbox.boxes[0]?.id) assert.equal(handle.controlRef.sessionId, prepared.providerSessionId) assert.equal(handle.controlRef.executionId, 'run-tangle-retained') assert.match(handle.controlRef.requestDigest, /^sha256:[0-9a-f]{64}$/u) + assert.deepEqual( + admissions.map((admission) => admission.phase), + ['environment', 'dispatched'], + ) sandbox.complete(handle.controlRef.executionId, 'RETAINED_OK') const events = [] diff --git a/test/tangle-retained-restart.test.ts b/test/tangle-retained-restart.test.ts index 047ef22..be4e3c7 100644 --- a/test/tangle-retained-restart.test.ts +++ b/test/tangle-retained-restart.test.ts @@ -11,6 +11,7 @@ import { type DurableBraidApplication, } from '../src/app/composition.js' import { isRuntimeEventEnvelope } from '../src/domain/runtime-events.js' +import type { RetainedRunAdmissionRecord } from '../src/ports/execution.js' import { RandomIds } from '../src/ports/ids.js' import { FakeTangleRetainedSandbox, @@ -58,13 +59,14 @@ async function waitFor(predicate: () => boolean, timeoutMs = 5_000): Promise { +test('one retained cloud session survives restart and continues in the same sandbox', async () => { const root = await mkdtemp(join(tmpdir(), 'braid-tangle-retained-')) const sandbox = new FakeTangleRetainedSandbox() const path = join(root, 'braid.db') const credentials = new MemoryCredentialStore() let first: DurableBraidApplication | undefined let restarted: DurableBraidApplication | undefined + let cancellationRestart: DurableBraidApplication | undefined try { first = await createDurableBraidApplication({ path, @@ -75,6 +77,7 @@ test('one retained cloud run survives a Braid restart without claiming native co ids: new RandomIds(), }) first.app.initialize(root) + await first.app.whenDurable() const turn = first.app.send({ operationId: 'operation-tangle-retained-turn', @@ -87,7 +90,7 @@ test('one retained cloud run survives a Braid restart without claiming native co return run?.controlRef !== undefined && (run.lastProviderSequence ?? 0) >= 2 }) const firstRun = first.app.state().runs.find((candidate) => candidate.id === turn.runId) - assert.equal(firstRun?.capabilities.sessions.continue, false) + assert.equal(firstRun?.capabilities.sessions.continue, true) assert.equal(firstRun?.capabilities.controls.status, false) assert.equal(firstRun?.capabilities.streaming.replay, true) assert.equal(firstRun?.capabilities.streaming.detach, true) @@ -140,9 +143,88 @@ test('one retained cloud run survives a Braid restart without claiming native co 1, ) assert.equal(sandbox.dispatches.length, 1) + + const followUp = restarted.app.send({ + operationId: 'operation-tangle-retained-follow-up', + text: 'Continue in the same retained workspace.', + }) + await followUp.admissionReady + await waitFor(() => sandbox.dispatches.length === 2) + const followUpDispatch = sandbox.dispatches[1] + assert.equal(followUpDispatch?.sessionId, beforeRestart?.providerSessionId) + assert.equal(followUpDispatch?.boxId, sandbox.boxes[0]?.id) + assert.notEqual(followUpDispatch?.executionId, sandbox.dispatches[0]?.executionId) + assert.equal(sandbox.boxes.length, 1) + sandbox.complete(followUpDispatch?.executionId ?? '', 'CONTINUED_AFTER_RESTART') + await followUp.completion + + const followUpRun = restarted.app + .state() + .runs.find((candidate) => candidate.id === followUp.runId) + assert.equal(followUpRun?.status, 'completed') + assert.equal(followUpRun?.providerSessionId, beforeRestart?.providerSessionId) + assert.equal(restarted.app.state().messages.at(-1)?.text, 'CONTINUED_AFTER_RESTART') + assert.equal(sandbox.boxes.length, 1) + + const cancellationTarget = restarted.app.send({ + operationId: 'operation-tangle-retained-cancel-target', + text: 'Remain active until this run is cancelled.', + }) + await cancellationTarget.admissionReady + await waitFor(() => sandbox.dispatches.length === 3) + await waitFor( + () => + restarted?.app.state().runs.find((candidate) => candidate.id === cancellationTarget.runId) + ?.controlRef !== undefined, + ) + const cancellation = await restarted.app.cancelRun({ + operationId: 'operation-tangle-retained-cancel', + runId: cancellationTarget.runId, + reason: 'Prove cancellation replay after a state snapshot.', + terminalStatus: 'aborted', + }) + await cancellation.completion + await cancellationTarget.completion + assert.equal(cancellation.replayed, false) + assert.equal(cancellation.acknowledgement.outcome, 'accepted') + assert.equal( + restarted.app.state().runs.find((candidate) => candidate.id === cancellationTarget.runId) + ?.status, + 'aborted', + ) + await restarted.app.close() + restarted = undefined + + cancellationRestart = await createDurableBraidApplication({ + path, + workspaceRoot: root, + credentialStore: credentials, + profile, + execution: retainedExecution(sandbox), + ids: new RandomIds(), + }) + const replay = await cancellationRestart.app.cancelRun({ + operationId: 'operation-tangle-retained-cancel', + runId: cancellationTarget.runId, + reason: 'Prove cancellation replay after a state snapshot.', + terminalStatus: 'aborted', + }) + assert.equal(replay.replayed, true) + assert.equal(replay.acknowledgement.outcome, 'accepted') + await assert.rejects( + cancellationRestart.app.cancelRun({ + operationId: 'operation-tangle-retained-cancel', + runId: cancellationTarget.runId, + reason: 'Changed cancellation body.', + terminalStatus: 'aborted', + }), + /different input/u, + ) + assert.equal(sandbox.cancellations.length, 1) } finally { await first?.app.close().catch(() => undefined) await restarted?.app.close().catch(() => undefined) + await cancellationRestart?.app.close().catch(() => undefined) await rm(root, { recursive: true, force: true }) } }) @@ -155,6 +237,7 @@ test('provider lookup recovers the pre-journal crash window without a saved refe sandbox.providerRunId = providerRunId const providerSessionId = 'session-braid-existing-conversation' const abort = new AbortController() + const admissions: RetainedRunAdmissionRecord[] = [] try { const first = retainedExecution(sandbox) if (first.admit === undefined) throw new Error('Retained admission is unavailable') @@ -166,6 +249,9 @@ test('provider lookup recovers the pre-journal crash window without a saved refe workspaceRoot: root, sessionId: providerSessionId, signal: abort.signal, + onRetainedAdmission: async (admission: RetainedRunAdmissionRecord) => { + admissions.push(structuredClone(admission)) + }, } const admission = await first.admit(input) assert.equal(admission.providerSessionId, providerSessionId) @@ -182,6 +268,10 @@ test('provider lookup recovers the pre-journal crash window without a saved refe const controlRef = event?.type === 'braid.execution.observed' ? event.controlRef : undefined assert.equal(controlRef?.sessionId, providerSessionId) assert.equal(controlRef?.runId, providerRunId) + assert.deepEqual( + admissions.map((admission) => admission.phase), + ['environment', 'dispatched'], + ) await stream.return?.(undefined) const restarted = retainedExecution(sandbox) diff --git a/test/tangle-sandbox-braid-soak-script.test.mjs b/test/tangle-sandbox-braid-soak-script.test.mjs index 372dc6e..d90f797 100644 --- a/test/tangle-sandbox-braid-soak-script.test.mjs +++ b/test/tangle-sandbox-braid-soak-script.test.mjs @@ -203,7 +203,9 @@ function passedProof(index, overrides = {}) { exactResource: true, mode: 'exact-owned-resource-set', activeResourceDelta: 0, - activeResourceDeltaRequired: true, + activeResourceDeltaRequired: false, + accountUsageScope: 'account-wide', + accountUsageAttribution: 'unattributed-shared-usage', usageObservationComplete: true, usageDelta: { activeSandboxes: 0, @@ -364,7 +366,7 @@ test('sandbox soak stops after a failed canary before spending on more runs', as stressRunner: async ({ attemptIndex, requireZeroActiveResourceDelta }) => { calls += 1 assert.equal(attemptIndex, 0) - assert.equal(requireZeroActiveResourceDelta, true) + assert.equal(requireZeroActiveResourceDelta, false) return passedProof(0, { status: 'failed' }) }, }) @@ -428,7 +430,7 @@ test('sandbox soak runs one canary then a bounded parallel cohort with full summ assert.equal(result.status, 'passed') assert.equal(result.attemptedRuns, 5) assert.equal(maximumActive, 2) - assert.deepEqual(strictness, [true, false, false, false, false]) + assert.deepEqual(strictness, [false, false, false, false, false]) assert.deepEqual(result.latency.totalMs, { n: 5, min: 100, @@ -467,7 +469,7 @@ test('sandbox soak rejects a passed proof with missing durable retained fields', assert.ok(result.failures.some((failure) => /replay\.resumeFromCursor/u.test(failure))) }) -test('sandbox soak rejects per-run active leaks hidden by an aggregate zero', async () => { +test('sandbox soak does not attribute shared account churn to exact owned resources', async () => { const result = await runBraidSandboxSoak({ runs: 3, concurrency: 2, @@ -482,18 +484,9 @@ test('sandbox soak rejects per-run active leaks hidden by an aggregate zero', as }, }) - assert.equal(result.status, 'failed') + assert.equal(result.status, 'passed') assert.equal(result.cleanup.activeResourceDelta, 0) - assert.ok( - result.failures.some((failure) => - /run 2: proof account active-resource delta was 1/u.test(failure), - ), - ) - assert.ok( - result.failures.some((failure) => - /run 3: proof account active-resource delta was -1/u.test(failure), - ), - ) + assert.equal(result.cleanup.exactResourcesRemaining, 0) }) test('sandbox soak rejects unknown account deltas instead of treating them as zero', async () => { @@ -507,18 +500,31 @@ test('sandbox soak rejects unknown account deltas instead of treating them as ze assert.ok(result.failures.some((failure) => /unknown fields.*computeMinutes/u.test(failure))) }) -test('sandbox soak rejects arbitrary cumulative account increases', async () => { +test('sandbox soak reports shared cumulative account increases without claiming ownership', async () => { const result = await runBraidSandboxSoak({ runs: 1, stressRunner: async () => proofWithUsage(0, { totalSandboxes: 0 }, { totalSandboxes: 99 }), }) - assert.equal(result.status, 'failed') - assert.ok( - result.failures.some((failure) => - /totalSandboxes delta 99 exceeded retained-resource-creation bound 1/u.test(failure), - ), - ) + assert.equal(result.status, 'passed') + assert.equal(result.accountUsage.delta.totalSandboxes, 99) +}) + +test('sandbox soak enforces account deltas only for an explicit exclusive window', async () => { + const proof = proofWithUsage(0, { activeSandboxes: 0 }, { activeSandboxes: 1 }) + const exclusive = await runBraidSandboxSoak({ + runs: 1, + stressRunner: async () => ({ + ...proof, + cleanup: { + ...proof.cleanup, + activeResourceDeltaRequired: true, + accountUsageAttribution: 'exclusive-proof-window', + }, + }), + }) + assert.equal(exclusive.status, 'failed') + assert.ok(exclusive.failures.some((failure) => /active-resource delta was 1/u.test(failure))) }) test('sandbox soak rejects reused cloud identity despite individually passing proofs', async () => { diff --git a/test/tangle-sandbox-braid-stress-runtime.test.mjs b/test/tangle-sandbox-braid-stress-runtime.test.mjs index 6f0bd90..a26cf57 100644 --- a/test/tangle-sandbox-braid-stress-runtime.test.mjs +++ b/test/tangle-sandbox-braid-stress-runtime.test.mjs @@ -163,6 +163,28 @@ test('every telemetry field is observed, unavailable, provider-default, or expli assert.equal(complete.fields.tokens.status, 'observed') assert.equal(complete.fields.endToEndDuration.status, 'observed') + const { + costUsd: _costUsd, + startedAt: _startedAt, + terminalAt: _terminalAt, + ...headlessRun + } = terminalRun() + const unavailable = telemetryDisclosure( + { + ...headlessRun, + tokensKnown: false, + usdKnown: false, + durationMs: 1_000, + }, + state, + workspaceVerification, + account, + ) + assert.equal(unavailable.completeDisclosure, true) + assert.equal(unavailable.fields.tokens.status, 'unavailable') + assert.equal(unavailable.fields.cost.status, 'unavailable') + assert.equal(unavailable.fields.endToEndDuration.status, 'observed') + const inFlight = telemetryDisclosure( { id: 'run-runtime-proof', diff --git a/test/tangle-sandbox-braid-stress-script.test.mjs b/test/tangle-sandbox-braid-stress-script.test.mjs index 002aaf3..f47b13c 100644 --- a/test/tangle-sandbox-braid-stress-script.test.mjs +++ b/test/tangle-sandbox-braid-stress-script.test.mjs @@ -3,7 +3,9 @@ import test from 'node:test' import { cloudFailureEventTimeline, + hasSingleMarkerLine, runIdForOperation, + sandboxWorkspaceRelativePath, spendDisclosure, } from '../scripts/live-required/tangle-sandbox-braid-stress.mjs' @@ -20,12 +22,15 @@ import { latestCursorFromResponses, MissingIntegrationError, observationFromResponses, + providerEventsForRun, resourceDelta, runObservations, stateRoundTrip, visibleEventKeys, waitForControlIdentity, + waitForRequestState, waitForVisibleEvents, + waitForWorkspaceToolEvents, } from '../scripts/live-required/tangle-sandbox-braid-stress-support.mjs' const controlRef = { @@ -38,7 +43,14 @@ const controlRef = { } function event(kind, payload) { - return { type: 'event', event: { kind, payload } } + const { provider, ...rest } = payload + return { + type: 'event', + event: { + kind, + payload: { ...rest, ...(provider === undefined ? {} : { source: provider }) }, + }, + } } test('get_state waits for the state response instead of an acknowledgement', async () => { @@ -59,6 +71,39 @@ test('get_state waits for the state response instead of an acknowledgement', asy assert.deepEqual(result.state, { runs: [] }) }) +test('model proof permits prose but requires exactly one isolated nonce line', () => { + assert.equal(hasSingleMarkerLine('MARKER', 'MARKER'), true) + assert.equal(hasSingleMarkerLine('Task complete.\n\nMARKER\n', 'MARKER'), true) + assert.equal(hasSingleMarkerLine('Task complete: MARKER', 'MARKER'), false) + assert.equal(hasSingleMarkerLine('MARKER\nMARKER', 'MARKER'), false) +}) + +test('control completion waits for its correlated state after transient terminal output', async () => { + const transient = { + type: 'state', + requestId: 'provider-event', + state: { runs: [{ id: 'run-cancel', status: 'failed' }] }, + } + const completed = { + type: 'state', + requestId: 'cancel-request', + state: { runs: [{ id: 'run-cancel', status: 'cancelled' }] }, + } + const session = { + responses: [transient], + async waitFor(_label, predicate) { + assert.equal(predicate(transient), false) + assert.equal(predicate(completed), true) + return completed + }, + } + + assert.deepEqual(await waitForRequestState(session, 'cancel-request', 'run-cancel', 100), { + response: completed, + run: completed.state.runs[0], + }) +}) + test('cleanup recovers exactly one durable run after a lost send acknowledgement', () => { const state = { runs: [ @@ -83,13 +128,40 @@ test('cleanup recovers exactly one durable run after a lost send acknowledgement ) }) +test('uses the Sandbox file API relative to its declared workspace root', () => { + assert.equal( + sandboxWorkspaceRelativePath('./.braid-live/proof/challenge.txt'), + '.braid-live/proof/challenge.txt', + ) + assert.equal( + sandboxWorkspaceRelativePath('.braid-live/proof/challenge.txt'), + '.braid-live/proof/challenge.txt', + ) + assert.throws( + () => sandboxWorkspaceRelativePath('/workspace/challenge.txt'), + /contained relative path/u, + ) + assert.throws(() => sandboxWorkspaceRelativePath('../challenge.txt'), /contained relative path/u) + assert.throws(() => sandboxWorkspaceRelativePath(''), /contained relative path/u) +}) + test('extracts exact control identity and an explicit provider cursor', () => { const responses = [ - event('run.environment.observed', { - runId: 'local-run-1', - controlRef, - provider: { eventId: 'environment-event-1', providerSequence: 1 }, - }), + { + type: 'event', + event: { + kind: 'run.environment.observed', + payload: { + runId: 'local-run-1', + value: { + kind: 'run.environment.observed', + runId: 'local-run-1', + controlRef, + provider: { eventId: 'environment-event-1', providerSequence: 1 }, + }, + }, + }, + }, event('run.text.delta', { runId: 'local-run-1', provider: { @@ -108,6 +180,46 @@ test('extracts exact control identity and an explicit provider cursor', () => { assert.equal(latestCursorFromResponses(responses, 'local-run-1'), 'cursor-7') }) +test('recovers durable control identity without replaying a pre-cursor observation event', async () => { + const responses = [ + { + type: 'state', + state: { + runs: [{ id: 'local-run-1', status: 'completed', controlRef }], + }, + }, + event('run.text.delta', { + runId: 'local-run-1', + provider: { + cursor: 'cursor-after-restart', + eventId: 'provider-event-after-restart', + providerSequence: 8, + runId: controlRef.runId, + executionId: controlRef.executionId, + }, + }), + ] + const session = { responses } + + assert.deepEqual(await waitForControlIdentity(session, 'local-run-1', 100), { + controlRef, + cursor: 'cursor-after-restart', + event: responses[1], + }) + assert.deepEqual(assertUniqueVisibleEvents(responses, 'local-run-1', 'restart'), { + count: 1, + keys: ['provider-event-after-restart'], + events: [ + { + kind: 'run.text.delta', + eventId: 'provider-event-after-restart', + providerSequence: 8, + cursor: 'cursor-after-restart', + }, + ], + }) +}) + test('rejects visible events without stable provider identity and catches duplicates', () => { assert.throws( () => assertUniqueVisibleEvents([event('run.text.delta', { runId: 'run-1' })], 'run-1', 'test'), @@ -127,6 +239,19 @@ test('rejects visible events without stable provider identity and catches duplic assert.throws(() => assertUniqueVisibleEvents(responses, 'run-1', 'test'), /duplicate visible/iu) }) +test('ignores attributable local lifecycle events without weakening visible event checks', () => { + const local = event('run.requested', { runId: 'run-1', status: 'admitted' }) + assert.deepEqual(providerEventsForRun([local], 'run-1'), []) + assert.throws( + () => + providerEventsForRun( + [local, event('run.text.delta', { runId: 'run-1', text: 'provider output' })], + 'run-1', + ), + /without provider metadata/iu, + ) +}) + test('rejects foreign provider run and execution identities in visible events', () => { const observed = event('run.environment.observed', { runId: 'run-1', @@ -480,6 +605,38 @@ test('waits for the first stable visible event before allowing a restart snapsho }) }) +test('waits for workspace tool evidence instead of stopping on session metadata', async () => { + const session = { + responses: [ + event('run.provider.event', { + runId: 'run-1', + provider: { + eventId: 'provider-session-1', + providerSequence: 1, + cursor: 'cursor-1', + }, + }), + ], + } + setTimeout(() => { + session.responses.push( + event('run.part.updated', { + runId: 'run-1', + part: { kind: 'tool' }, + provider: { + eventId: 'provider-tool-2', + providerSequence: 2, + cursor: 'cursor-2', + }, + }), + ) + }, 15) + + const visible = await waitForWorkspaceToolEvents(session, 'run-1', 200, 'first process') + assert.equal(visible.count, 2) + assert.equal(visible.events[1]?.partKind, 'tool') +}) + test('stops pre-kill waits as soon as the run becomes terminal', async () => { const session = { responses: [ diff --git a/test/terminal-responsive.test.ts b/test/terminal-responsive.test.ts index f32d8df..d8c99f8 100644 --- a/test/terminal-responsive.test.ts +++ b/test/terminal-responsive.test.ts @@ -1,11 +1,19 @@ import assert from 'node:assert/strict' import test from 'node:test' -import { TuiMainScreen, visibleWidth } from '@earendil-works/pi-tui' +import { + type Component, + type OverlayHandle, + type OverlayOptions, + type TUI, + TuiMainScreen, + visibleWidth, +} from '@earendil-works/pi-tui' import { createApplicationUiController } from '../src/adapters/tui/application-ui-controller.js' import { createBraidApplication } from '../src/app/composition.js' import type { BraidViewModel } from '../src/views/shared/models.js' import { composerProjectionFor, composerRowBudget } from '../src/views/tui/composer-view.js' import { layoutFor } from '../src/views/tui/layout.js' +import { ModalCoordinator } from '../src/views/tui/modal-coordinator.js' import { TerminalChrome } from '../src/views/tui/terminal-chrome.js' import { BraidShell } from '../src/views/tui/terminal-shell.js' import { createBraidTheme } from '../src/views/tui/theme.js' @@ -195,6 +203,31 @@ test('layout breakpoints preserve transcript room and short-terminal overlays', assert.equal(layoutFor(200, 60).mode, 'wide') }) +test('modal coordination honors the row breakpoint at standard and wide widths', () => { + const shown: OverlayOptions[] = [] + const handle = { + hide() {}, + focus() {}, + isHidden: () => false, + } as unknown as OverlayHandle + const tui = { + terminal: { columns: 120, rows: 12 }, + showOverlay: (_component: Component, options: OverlayOptions) => { + shown.push(options) + return handle + }, + } as unknown as TUI + const component = { render: () => [], invalidate() {} } as Component + + new ModalCoordinator(tui).open(component, { + anchor: 'center', + width: '80%', + maxHeight: '80%', + }) + + assert.deepEqual(shown, [{ anchor: 'top-left', width: '100%', maxHeight: '100%', margin: 0 }]) +}) + test('published Pi virtual terminals keep the composer and valid cells at all sizes', async () => { const view = viewForChrome() for (const [columns, rows] of sizes) { diff --git a/test/trace-analysis-configuration.test.ts b/test/trace-analysis-configuration.test.ts index c0d3c67..6e5ca51 100644 --- a/test/trace-analysis-configuration.test.ts +++ b/test/trace-analysis-configuration.test.ts @@ -5,6 +5,7 @@ import type { ExternalOptimizerModelExecutionObservation, } from '@tangle-network/agent-eval/campaign' import type { AgentProfile } from '@tangle-network/agent-interface' +import type { RetainedRunAdmissionRecord } from '../src/domain/run-contracts.js' import { AgentEvalAnalystAdapter } from '../src/adapters/analysis/eval-analyst.js' import { MANAGED_AGENT_EVAL_RPC_VERSION, @@ -551,6 +552,12 @@ function runtimeOwner( }) } +function retainAnalysisAdmissions(target: RetainedRunAdmissionRecord[] = []) { + return async (_callId: string, admission: RetainedRunAdmissionRecord): Promise => { + target.push(structuredClone(admission)) + } +} + test('runtime-owned trace model call preserves canonical messages, limits, usage, and safe evidence', async () => { let receivedAuthorization = '' let receivedBody: Record | undefined @@ -615,6 +622,7 @@ test('runtime-owned CLI Bridge analysis uses the harness executor with portable }, }) try { + const admissions: RetainedRunAdmissionRecord[] = [] const selected = connection('cli-bridge', 'runtime-bridge-owner', bridge.endpoint) const owner = createRuntimeTraceModelOwner({ profile: { @@ -631,6 +639,7 @@ test('runtime-owned CLI Bridge analysis uses the harness executor with portable credential: 'credential-never-recorded', model: 'pi/tangle-router/glm-5.2', pricing: PRICING, + onRetainedAdmission: retainAnalysisAdmissions(admissions), }) const result = await owner.call(optimizerRequest({ thinking: 'disabled' })) @@ -655,6 +664,10 @@ test('runtime-owned CLI Bridge analysis uses the harness executor with portable usd: 0.00002, }) assert.equal(bridge.requests.length, 1) + assert.deepEqual( + admissions.map((admission) => admission.phase), + ['environment', 'dispatched'], + ) const body = bridge.requests[0]?.body assert.equal(body?.model, 'pi/tangle-router/glm-5.2') assert.equal(body?.max_tokens, undefined) @@ -719,6 +732,7 @@ test('runtime-owned CLI Bridge analysis cancels a detached run when result retri credential: 'credential-never-recorded', model: 'pi/tangle-router/glm-5.2', pricing: PRICING, + onRetainedAdmission: retainAnalysisAdmissions(), }) const result = await owner.call(optimizerRequest()) @@ -758,6 +772,7 @@ test('runtime-owned Claude analysis accepts a runner alias with exact tool isola credential: 'credential-never-recorded', model: 'claude-code/opus', pricing: PRICING, + onRetainedAdmission: retainAnalysisAdmissions(), }) const result = await owner.call( diff --git a/test/tui-core-workflows.test.ts b/test/tui-core-workflows.test.ts index 2495a74..3b97183 100644 --- a/test/tui-core-workflows.test.ts +++ b/test/tui-core-workflows.test.ts @@ -3,8 +3,8 @@ import { existsSync, readdirSync, readFileSync } from 'node:fs' import { dirname, extname, join, resolve } from 'node:path' import test from 'node:test' import { fileURLToPath } from 'node:url' -import { comparePairedArms } from '@tangle-network/agent-eval' import { TuiMainScreen, visibleWidth } from '@earendil-works/pi-tui' +import { comparePairedArms } from '@tangle-network/agent-eval' import { createApplicationUiController } from '../src/adapters/tui/application-ui-controller.js' import type { AnalysisComparisonResult } from '../src/app/analysis-comparison-contracts.js' import { createBraidApplication } from '../src/app/composition.js' @@ -12,9 +12,9 @@ import type { BraidViewModel, InteractionView } from '../src/views/shared/models import { ActivityBrowserPanel } from '../src/views/tui/activity-browser.js' import { AnalysisViewPanel } from '../src/views/tui/analysis.js' import { + ComparisonViewPanel, comparisonLines, comparisonViewForResult, - ComparisonViewPanel, } from '../src/views/tui/comparison.js' import { ConversationConfirmation } from '../src/views/tui/conversation-dialogs.js' import { ForkPreviewPanel } from '../src/views/tui/fork-preview.js' @@ -124,6 +124,7 @@ function graphView(): BraidViewModel { status: 'completed', depth: 1, edgeLabel: 'continued', + parentIds: ['conversation:conversation-1'], }, { id: 'analysis-1', @@ -132,6 +133,7 @@ function graphView(): BraidViewModel { status: 'completed', depth: 2, edgeLabel: 'analyzed', + parentIds: ['branch:branch-current'], }, { id: 'comparison-1', @@ -140,6 +142,7 @@ function graphView(): BraidViewModel { status: 'completed', depth: 2, edgeLabel: 'compared_left', + parentIds: ['branch:branch-current'], }, ], } @@ -229,6 +232,68 @@ test('core workflow overlays keep mode, consequence, and controls visible at 40x } }) +test('graph collapse and waiting navigation keep the selected run identity visible', () => { + const waitingRun = { + id: 'run-waiting', + turnId: 'turn-waiting', + conversationId: 'conversation-1', + branchId: 'branch-current', + status: 'waiting' as const, + profileName: 'cloud reviewer', + runner: 'claude-code', + model: 'claude-opus-5', + connection: 'Tangle Sandbox', + completeness: 'streaming' as const, + } + const view: BraidViewModel = { + ...graphView(), + graphQuery: 'status:waiting', + runs: [waitingRun], + interactions: [{ ...permission, runId: waitingRun.id }], + graph: [ + ...graphView().graph.slice(0, 2), + { + id: waitingRun.id, + type: 'run', + title: 'Review the deployment', + status: 'waiting', + depth: 2, + parentIds: ['branch:branch-current'], + }, + { + id: 'analysis-child', + type: 'analysis', + title: 'deployment analysis', + status: 'running', + depth: 3, + parentIds: [`run:${waitingRun.id}`], + }, + ], + } + const graph = new GraphView(theme) + graph.setView(view) + + graph.handleInput(' ') + assert.doesNotMatch(graph.render(80).join('\n'), /Review the deployment/u) + graph.handleInput(' ') + assert.match(graph.render(80).join('\n'), /Review the deployment/u) + + graph.handleInput('w') + assert.equal(graph.selectedId, `run:${waitingRun.id}`) + const selected = graph.render(140).join('\n') + assert.match(selected, /cloud reviewer · claude-code · claude-opus-5/u) + assert.match(selected, /filter: status:waiting/u) + + const filtered = new GraphView(theme) + filtered.setView({ + ...view, + graphQuery: 'type:analysis', + graph: view.graph.filter((node) => node.type === 'analysis'), + }) + filtered.handleInput('w') + assert.match(filtered.render(80).join('\n'), /hidden by the current graph query/u) +}) + test('long workflow state preserves the closing key instead of pushing it below 40x12', async () => { const fork = new ForkPreviewPanel(theme) const forkBase = forkView().forkPreview @@ -484,7 +549,7 @@ test('wide activity keeps the list and details together and tabs through bounded browser.handleInput('\u001b[B') assert.match(browser.render(120).join('\n'), /first detail remains visible/u) browser.handleInput('\t') - assert.match(browser.render(120).join('\n'), /activity · workers · 0/u) + assert.match(browser.render(120).join('\n'), /workers · 0/u) browser.handleInput('\t') assert.match(browser.render(120).join('\n'), /tab filter: all/u) browser.handleInput('\u001b[D') @@ -673,6 +738,24 @@ test('approval actions are navigable and secret responses never render values', arrowShell.handleInput('\r') assert.deepEqual(arrowResponses, [{ outcome: 'reject' }]) + const leftResponses: unknown[] = [] + const leftShell = new InteractionShell(permission, theme, (response) => + leftResponses.push(response), + ) + leftShell.handleInput('\u001b[D') + assert.deepEqual(leftResponses, [{ outcome: 'cancel' }]) + + const countdownShell = new InteractionShell( + { ...permission, remainingMs: 5_000 }, + theme, + () => {}, + undefined, + () => 24, + ) + assert.match(countdownShell.render(40).join('\n'), /5s left/u) + countdownShell.setInteraction({ ...permission, remainingMs: 1_000 }) + assert.match(countdownShell.render(40).join('\n'), /1s left/u) + const secret = 'do-not-render' const secretInteraction: InteractionView = { ...permission, @@ -784,9 +867,11 @@ test('digit-leading text, secret, and number answers stay editable', () => { theme, (response) => textResponse.push(response), ) - textShell.handleInput('1text-value') + textShell.handleInput('ab') + textShell.handleInput('\u001b[D') + textShell.handleInput('c') textShell.handleInput('\r') - assert.deepEqual(textResponse, [{ outcome: 'accept', value: '1text-value' }]) + assert.deepEqual(textResponse, [{ outcome: 'accept', value: 'acb' }]) const numberResponse: unknown[] = [] const numberShell = new InteractionShell( diff --git a/test/tui-refresh-lifecycle.test.ts b/test/tui-refresh-lifecycle.test.ts index 12d67d0..7b31024 100644 --- a/test/tui-refresh-lifecycle.test.ts +++ b/test/tui-refresh-lifecycle.test.ts @@ -13,9 +13,10 @@ import { createRunId, } from '../src/domain/ids.js' import type { BraidUiController, UiDispatchResult } from '../src/views/shared/intents.js' -import type { BraidViewModel, HeadlessState } from '../src/views/shared/models.js' +import type { BraidViewModel, HeadlessState, InteractionView } from '../src/views/shared/models.js' import type { ModalCoordinator, ModalOptions } from '../src/views/tui/modal-coordinator.js' import { BraidTerminalApp } from '../src/views/tui/terminal-app.js' +import { TerminalInteractionController } from '../src/views/tui/terminal-interaction-controller.js' import { TerminalSurfaceOverlays } from '../src/views/tui/terminal-surface-overlays.js' import { createBraidTheme } from '../src/views/tui/theme.js' import { VirtualTerminal } from './support/virtual-terminal.js' @@ -223,6 +224,58 @@ test('late live refreshes cannot affect a closed surface and reopening starts a } }) +test('one interaction shell refreshes its countdown and disposal stops the timer', async () => { + const interaction: InteractionView = { + runId: 'run-countdown', + interactionId: 'interaction-countdown', + profileName: 'Review profile', + runner: 'pi', + kind: 'permission', + prompt: 'Allow this read?', + answerSpec: { kind: 'boolean', required: true }, + allowedOutcomes: ['accept', 'reject', 'cancel'], + responseScopes: ['once'], + remainingMs: 5_000, + queuePosition: 0, + queueTotal: 1, + secret: false, + } + let current = { ...viewModel(), interactions: [interaction] } + let renders = 0 + const spy = modalSpy() + const controller = new TerminalInteractionController({ + theme: createBraidTheme(false), + modals: spy.modals, + nextOperationId: () => 'operation-countdown', + dispatch: async () => ({ kind: 'accepted', revision: current.revision }), + currentView: () => current, + isStopped: () => false, + requestRender: () => { + renders += 1 + }, + rows: () => 12, + openAutomation: () => {}, + }) + + controller.sync(current) + const shell = spy.current() + assert.match(shell?.render(80).join('\n') ?? '', /5s left/u) + current = { + ...current, + interactions: [{ ...interaction, remainingMs: 900 }], + } + await new Promise((resolve) => setTimeout(resolve, 300)) + assert.equal(spy.current(), shell) + assert.match(shell?.render(80).join('\n') ?? '', /1s left/u) + assert.ok(renders > 0) + + controller.dispose() + const rendersAfterDispose = renders + await new Promise((resolve) => setTimeout(resolve, 300)) + assert.equal(renders, rendersAfterDispose) + spy.closeTop() +}) + test('analysis surfaces stay frozen and TerminalApp.stop disposes live refreshes', async () => { const view = viewModel() const requests: string[] = [] @@ -393,6 +446,7 @@ test('analysis progress opens immediately, follows live state, and respects dism }, }) assert.match(spy.current()?.render(80).join('\n') ?? '', /analyses › \/ask · frozen question/u) + assert.doesNotMatch(spy.current()?.render(80).join('\n') ?? '', /complete · 1\/1/u) spy.closeTop() const dismissed = surfaces.openIntelligenceProgress('analyze') diff --git a/test/tui.test.ts b/test/tui.test.ts index 839b337..ff737c6 100644 --- a/test/tui.test.ts +++ b/test/tui.test.ts @@ -438,6 +438,55 @@ test('the searchable command overlay restores editor focus after close', async ( await done }) +test('Ctrl+K exposes one five-row run switcher and updates the branch runner', async () => { + const terminal = new VirtualTerminal(100, 30) + const tui = new TuiMainScreen(terminal) + const app = createBraidApplication({ fixture: 'deterministic' }) + app.initialize('/workspace') + const controller = createApplicationUiController(app) + let operation = 0 + const view = new BraidTerminalApp({ + controller, + tui, + theme: createBraidTheme(false), + workspace: '/workspace', + nextOperationId: () => `op-run-switcher-${++operation}`, + }) + const done = view.start() + + terminal.sendInput('\u000b') + await terminal.waitForRender() + const switcher = terminal.getViewport().join('\n') + assert.match(switcher, /Run configuration/u) + for (const label of ['Profile', 'Connection', 'Runner', 'Model', 'Thinking']) { + assert.match(switcher, new RegExp(`\\b${label}\\b`, 'u')) + } + assert.match(switcher, /enter to change · esc to close/u) + + terminal.sendInput('\u001b[B') + terminal.sendInput('\u001b[B') + terminal.sendInput('\r') + await terminal.waitForRender() + assert.equal(view.editor.getText(), '/runner ') + terminal.sendInput('codex') + terminal.sendInput('\r') + await waitUntil( + () => + app.state().branches.find((branch) => branch.id === app.state().branchId)?.overrides + .runner === 'codex', + ) + assert.equal(controller.view().runner, 'codex') + assert.equal(controller.view().runOverrides?.runner, 'codex') + + terminal.sendInput('\u000b') + await terminal.waitForRender() + assert.match(terminal.getViewport().join('\n'), /codex · branch override/u) + terminal.sendInput('\u001b') + + view.stop() + await done +}) + test('global shortcuts cannot replace an open overlay or discard its query', async () => { const terminal = new VirtualTerminal(80, 24) const tui = new TuiMainScreen(terminal) diff --git a/test/w6-contract.test.ts b/test/w6-contract.test.ts index df31584..598e4ac 100644 --- a/test/w6-contract.test.ts +++ b/test/w6-contract.test.ts @@ -140,6 +140,11 @@ test('the command registry owns typed intents for local and headless command pat surface: 'help', query: 'keys', }) + assert.deepEqual(commandIntent('graph', ['type:run', 'status:waiting']), { + type: 'open-surface', + surface: 'graph', + query: 'type:run status:waiting', + }) assert.deepEqual(commandIntent('profile', ['reviewer'], 'op-profile'), { type: 'run-command', command: 'profile', diff --git a/test/w6-ui.test.ts b/test/w6-ui.test.ts index a3ce3b5..a470c45 100644 --- a/test/w6-ui.test.ts +++ b/test/w6-ui.test.ts @@ -11,6 +11,7 @@ import { ActivityView } from '../src/views/tui/activity.js' import { AnalysisViewPanel } from '../src/views/tui/analysis.js' import { ConnectionSetupViewPanel } from '../src/views/tui/connection-setup.js' import { DetailsViewPanel } from '../src/views/tui/details.js' +import { DynamicAutocompleteProvider } from '../src/views/tui/dynamic-autocomplete.js' import { ForkPreviewPanel } from '../src/views/tui/fork-preview.js' import { GraphView } from '../src/views/tui/graph.js' import { HelpViewPanel } from '../src/views/tui/help.js' @@ -20,10 +21,9 @@ import { ProfileEditorViewPanel } from '../src/views/tui/profile-editor.js' import { SearchableSelector } from '../src/views/tui/selector.js' import { BraidTerminalApp } from '../src/views/tui/terminal-app.js' import { TerminalChrome } from '../src/views/tui/terminal-chrome.js' -import { metricsFor } from '../src/views/tui/terminal-usage.js' import { BraidShell } from '../src/views/tui/terminal-shell.js' +import { metricsFor } from '../src/views/tui/terminal-usage.js' import { createBraidTheme } from '../src/views/tui/theme.js' -import { DynamicAutocompleteProvider } from '../src/views/tui/dynamic-autocomplete.js' import { STREAMING_TAIL_BYTES, streamingTailText, @@ -316,7 +316,8 @@ test('stable chrome keeps identity and status outside transcript history', () => const standard = chrome.render(80) assert.equal(standard.length, 1) assert.match(standard[0] ?? '', /reviewer.*pi \/ deterministic/u) - assert.match(standard[0] ?? '', /deterministic fixture/u) + assert.match(standard[0] ?? '', /fixture/u) + assert.doesNotMatch(standard[0] ?? '', /deterministic fixture/u) assert.match(standard[0] ?? '', /Ctrl\+P commands/u) for (const line of standard) assert.ok(visibleWidth(line) <= 80) }) diff --git a/test/w8-runs.test.ts b/test/w8-runs.test.ts index 1bb595d..e9fd807 100644 --- a/test/w8-runs.test.ts +++ b/test/w8-runs.test.ts @@ -2,13 +2,13 @@ import assert from 'node:assert/strict' import test from 'node:test' import { defineAgentProfile } from '@tangle-network/agent-interface' import type { RuntimeStreamEvent } from '@tangle-network/agent-runtime' +import { buildBraidViewModel } from '../src/adapters/tui/ui-view-model.js' import { AppError, BraidApplication } from '../src/app/application.js' import { DETERMINISTIC_PROFILE } from '../src/app/composition.js' import { MemoryJournal } from '../src/app/journal.js' -import { buildAppView } from '../src/app/view-model.js' import { createPortableContextPlan } from '../src/domain/receipts.js' import type { RuntimeEventEnvelope } from '../src/domain/runtime-events.js' -import type { BraidState } from '../src/domain/state.js' +import { type BraidState, initialState } from '../src/domain/state.js' import { FixedClock } from '../src/ports/clock.js' import { DEFAULT_RUN_CAPABILITIES, type ExecutionPort } from '../src/ports/execution.js' import { SequenceIds } from '../src/ports/ids.js' @@ -481,6 +481,104 @@ test('detach stops the iterator and reconnect resumes the same run', async () => assert.equal(reconnected.messages[1]?.text, 'reconnected') }) +test('restart keeps a replayable run reconnecting until replay proves its outcome', async () => { + let markStreamStarted!: () => void + const streamStarted = new Promise((resolve) => { + markStreamStarted = resolve + }) + let releaseStream!: () => void + const heldStream = new Promise((resolve) => { + releaseStream = resolve + }) + const sourceJournal = new MemoryJournal(new FixedClock()) + const first = appFor( + { + capabilities: () => REPLAY_CAPABILITIES, + admit: () => ({ + capabilities: REPLAY_CAPABILITIES, + providerSessionId: 'session-restart-replay', + }), + async *streamTurn(input): AsyncIterable { + yield { + runId: input.runId, + eventId: 'event-before-restart', + sequence: 1, + cursor: 'cursor-before-restart', + receivedAt: '2026-08-01T00:00:00.000Z', + event: { type: 'text_delta', text: 'before restart' }, + } + markStreamStarted() + await heldStream + }, + }, + sourceJournal, + ) + const send = first.send({ operationId: 'op-restart-replay', text: 'survive restart' }) + await send.admissionReady + await streamStarted + + const restartedJournal = new MemoryJournal(new FixedClock()) + for (const envelope of sourceJournal.all()) restartedJournal.append(envelope) + let statusCalls = 0 + let reconnectCalls = 0 + const restarted = appFor( + { + capabilities: () => REPLAY_CAPABILITIES, + async *streamTurn(): AsyncIterable {}, + async status(input) { + statusCalls += 1 + return { + runId: input.runId, + sessionId: 'session-restart-replay', + status: 'unknown', + detail: 'The retained execution has not produced an attributable status yet', + } + }, + reconnect: (input) => ({ + async *[Symbol.asyncIterator](): AsyncIterator { + reconnectCalls += 1 + yield { + runId: input.runId, + eventId: 'event-restart-replay-final', + sequence: 2, + cursor: 'cursor-restart-replay-final', + receivedAt: '2026-08-01T00:00:00.000Z', + event: finalEvent('survived restart replay'), + } + }, + }), + }, + restartedJournal, + ) + + await restarted.whenDurable() + assert.equal(statusCalls, 1) + assert.equal(restarted.state().runs[0]?.status, 'reconnecting') + assert.equal( + restarted + .events() + .some((entry) => entry.event.kind === 'run.finished' && entry.event.status === 'unknown'), + false, + ) + assert.equal( + restarted.events().some((entry) => entry.event.kind === 'run.unknown'), + false, + ) + + const recovered = await restarted.reconnectRun({ + operationId: 'op-restart-replay-reconnect', + runId: send.runId, + }) + assert.equal(reconnectCalls, 1) + assert.equal(recovered.runs[0]?.status, 'completed') + assert.equal(recovered.messages[1]?.text, 'survived restart replay') + + releaseStream() + await send.completion + await first.close() + await restarted.close() +}) + test('reconnect leaves a proven terminal run immutable', async () => { let reconnectCalls = 0 const execution: ExecutionPort = { @@ -530,22 +628,13 @@ for (const count of [10_000, 100_000]) { parts: [{ id: `part-${index}`, kind: 'text' as const, text: `message ${index}` }], })) const state: BraidState = { - ...({} as BraidState), + ...initialState(DETERMINISTIC_PROFILE), revision: count, sequence: count, - schemaVersion: 2, workspace: '/workspace', - conversationId: 'conv-1', - branchId: 'branch-1', - profile: DETERMINISTIC_PROFILE, - draft: '', messages: messages as unknown as BraidState['messages'], - runs: [], - activeRunId: null, - queuedInputs: [], - lastError: null, } - const view = buildAppView(state) + const view = buildBraidViewModel(state) assert.equal(view.messages.length, 200) assert.equal(view.hiddenMessageCount, count - 200) assert.equal(view.messages[0]?.id, `message-${count - 200}`)