From 64a758dca1cf6de9fc1b247593d25ecc0fd409e5 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Mon, 3 Aug 2026 23:58:52 -0600 Subject: [PATCH 1/2] feat(tui): ship production Braid workspace --- .gitignore | 4 +- README.md | 48 +- THIRD_PARTY_LICENSES.json | 172 ++ THIRD_PARTY_NOTICES.md | 10 +- artifacts/verification/live-core/latest.json | 1224 ++++++++++++ artifacts/verification/performance/smoke.json | 1608 +++++++++++++++ artifacts/verification/w0/120x40.png | Bin 8461 -> 8461 bytes artifacts/verification/w0/200x60.png | Bin 9606 -> 9606 bytes artifacts/verification/w0/80x24-flow.gif | Bin 28190 -> 19419 bytes .../verification/w0/capture-manifest.json | 8 +- artifacts/verification/w0/package-proof.json | 12 +- artifacts/verification/w6/120x40-plain.txt | 15 + artifacts/verification/w6/120x40.png | Bin 0 -> 10042 bytes artifacts/verification/w6/120x40.txt | 40 + artifacts/verification/w6/200x60-plain.txt | 15 + artifacts/verification/w6/200x60.png | Bin 0 -> 11536 bytes artifacts/verification/w6/200x60.txt | 60 + artifacts/verification/w6/40x12-plain.txt | 15 + artifacts/verification/w6/40x12.png | Bin 0 -> 13728 bytes artifacts/verification/w6/40x12.txt | 12 + artifacts/verification/w6/80x24-plain.txt | 15 + artifacts/verification/w6/80x24.png | Bin 0 -> 21623 bytes artifacts/verification/w6/80x24.txt | 24 + .../verification/w6/capture-manifest.json | 643 ++++++ artifacts/verification/w6/package-proof.json | 67 + .../w6/states/active-streaming.json | 512 +++++ .../w6/states/active-streaming.png | Bin 0 -> 20071 bytes .../w6/states/active-streaming.txt | 24 + .../verification/w6/states/analysis.json | 277 +++ artifacts/verification/w6/states/analysis.png | Bin 0 -> 46975 bytes artifacts/verification/w6/states/analysis.txt | 24 + .../verification/w6/states/comparison.json | 277 +++ .../verification/w6/states/comparison.png | Bin 0 -> 45771 bytes .../verification/w6/states/comparison.txt | 24 + artifacts/verification/w6/states/empty.json | 277 +++ artifacts/verification/w6/states/empty.png | Bin 0 -> 20286 bytes artifacts/verification/w6/states/empty.txt | 24 + .../w6/states/failure-or-reconnect.json | 594 ++++++ .../w6/states/failure-or-reconnect.png | Bin 0 -> 22931 bytes .../w6/states/failure-or-reconnect.txt | 24 + .../verification/w6/states/fork-preview.json | 310 +++ .../verification/w6/states/fork-preview.png | Bin 0 -> 44159 bytes .../verification/w6/states/fork-preview.txt | 24 + .../w6/states/graph-or-analysis.json | 277 +++ .../w6/states/graph-or-analysis.png | Bin 0 -> 33293 bytes .../w6/states/graph-or-analysis.txt | 24 + .../verification/w6/states/interaction.json | 307 +++ .../verification/w6/states/interaction.png | Bin 0 -> 47608 bytes .../verification/w6/states/interaction.txt | 24 + artifacts/verification/w6/states/narrow.json | 277 +++ artifacts/verification/w6/states/narrow.png | Bin 0 -> 11213 bytes artifacts/verification/w6/states/narrow.txt | 12 + artifacts/verification/w6/states/profile.json | 277 +++ artifacts/verification/w6/states/profile.png | Bin 0 -> 37804 bytes artifacts/verification/w6/states/profile.txt | 24 + docs/02-experience-specification.md | 3 + docs/03-architecture.md | 64 +- docs/04-runtime-contracts.md | 62 +- docs/06-conversations-forks-and-analysis.md | 9 +- docs/08-verification.md | 187 +- docs/09-delivery-plan.md | 20 +- docs/decisions/003-local-event-journal.md | 12 +- .../004-application-effect-coordination.md | 53 + ...rypted-sqlite-and-credential-boundaries.md | 63 + package.json | 54 +- pnpm-lock.yaml | 893 ++++++++- pnpm-workspace.yaml | 16 +- release/execution-public-key.fingerprint | 1 + release/execution-public-key.pem | 3 + scripts/accessibility-output.mjs | 13 + scripts/build-startup.mjs | 34 + scripts/capture-visual-definitions.mjs | 171 ++ scripts/capture-visual-support.mjs | 134 ++ scripts/capture-visual.mjs | 501 +++++ scripts/capture-w0.mjs | 6 +- scripts/check-boundaries.mjs | 13 +- scripts/check-dependencies.mjs | 18 +- scripts/check-module-cycles.mjs | 322 +++ scripts/check-release.mjs | 44 + scripts/check-scope.mjs | 11 + scripts/clean-tests.mjs | 19 +- scripts/eval/candidate.mjs | 30 + scripts/eval/run.mjs | 53 + scripts/live-bridge.mjs | 3 + scripts/live-bridge/bridge.mjs | 188 ++ scripts/live-bridge/capture.mjs | 101 + scripts/live-bridge/command.mjs | 89 + scripts/live-bridge/config.mjs | 169 ++ scripts/live-bridge/constants.mjs | 37 + scripts/live-bridge/endpoint.mjs | 131 ++ scripts/live-bridge/errors.mjs | 9 + scripts/live-bridge/evidence.mjs | 54 + scripts/live-bridge/main.mjs | 265 +++ scripts/live-bridge/matrix-adversarial.mjs | 193 ++ scripts/live-bridge/matrix.mjs | 154 ++ scripts/live-bridge/pack.mjs | 91 + scripts/live-bridge/process-tree.mjs | 113 ++ scripts/live-bridge/process.mjs | 272 +++ scripts/live-bridge/protocol.mjs | 118 ++ scripts/live-bridge/redaction.mjs | 49 + scripts/live-bridge/target-actions.mjs | 298 +++ scripts/live-bridge/target-flow.mjs | 119 ++ scripts/live-bridge/target-policy.mjs | 46 + scripts/live-core/discovery.mjs | 144 ++ scripts/live-core/evidence.mjs | 44 + scripts/live-core/rpc-session.mjs | 171 ++ scripts/live-core/run.mjs | 376 ++++ scripts/live-core/setup-tui.mjs | 136 ++ scripts/live-core/turn-proof.mjs | 91 + scripts/live-required.mjs | 5 + scripts/native-install-environment.mjs | 6 + scripts/package-proof-flows.mjs | 396 ++++ scripts/package-proof-parity.mjs | 214 ++ scripts/package-proof-runtime.mjs | 162 ++ scripts/packed-binary.mjs | 53 + scripts/packed-rpc/deterministic.mjs | 104 + scripts/packed-rpc/first-run-tui.mjs | 194 ++ scripts/packed-rpc/production-startup.mjs | 120 ++ scripts/performance/application-probe.mjs | 249 +++ scripts/performance/compile-cache-primer.mjs | 35 + scripts/performance/file-credential-store.mjs | 61 + scripts/performance/headless-terminal.mjs | 110 ++ scripts/performance/lifecycle.mjs | 54 + scripts/performance/memory-child.mjs | 49 + .../packed-production-tui-child.mjs | 111 ++ scripts/performance/packed-runtime.mjs | 39 + scripts/performance/process-measurements.mjs | 480 +++++ scripts/performance/process-probes.mjs | 433 ++++ scripts/performance/reporting.mjs | 236 +++ scripts/performance/resize-probe.mjs | 344 ++++ scripts/performance/run.mjs | 267 +++ scripts/performance/runtime-measurement.mjs | 91 + scripts/performance/statistics.mjs | 158 ++ scripts/performance/storage-measurements.mjs | 155 ++ scripts/performance/storage-probes.mjs | 630 ++++++ scripts/release-check-catalog.mjs | 71 + scripts/release-evidence.mjs | 483 +++++ scripts/release-files.mjs | 144 ++ scripts/release-visual-proof.mjs | 190 ++ scripts/release/artifact-store.mjs | 65 + scripts/release/atomic-storage.mjs | 85 + scripts/release/bindings.mjs | 105 + scripts/release/build-identity.mjs | 309 +++ scripts/release/collect-release-evidence.mjs | 44 + scripts/release/collection-contract.mjs | 311 +++ scripts/release/collector-validation.mjs | 381 ++++ scripts/release/collector.mjs | 396 ++++ scripts/release/command-runner.mjs | 227 +++ scripts/release/package-archive.mjs | 268 +++ scripts/release/process-tree.mjs | 80 + scripts/release/redaction.mjs | 311 +++ scripts/release/release-collector.test.mjs | 552 ++++++ scripts/release/verification-artifacts.mjs | 70 + scripts/release/verification-checks.mjs | 311 +++ scripts/release/verification-flow.mjs | 64 + scripts/release/verification-mappings.mjs | 127 ++ scripts/release/verification-options.mjs | 25 + scripts/release/verification-plan.mjs | 37 + scripts/release/verification-report.mjs | 88 + scripts/release/verification-source.mjs | 140 ++ scripts/release/verification-support.mjs | 57 + scripts/run-tests.mjs | 128 +- scripts/scan-secret-artifacts.mjs | 61 + scripts/test-pty.mjs | 265 +++ scripts/test-release-evidence.mjs | 240 +++ scripts/test-rpc-packed.mjs | 18 + scripts/test.mjs | 22 + scripts/verify-package.mjs | 589 +++--- scripts/verify-release.mjs | 3 + src/adapters/analysis/agent-eval-version.ts | 1 + src/adapters/analysis/citations.ts | 236 +++ src/adapters/analysis/eval-analyst.ts | 179 ++ src/adapters/analysis/python-runner.ts | 212 ++ src/adapters/analysis/runtime-model-owner.ts | 451 +++++ .../analysis/trace-analysis-adapter.ts | 351 ++++ src/adapters/analysis/trace-store.ts | 254 +++ src/adapters/connections/cli-bridge-health.ts | 159 ++ .../connections/cli-bridge-model-route.ts | 42 + .../production-connection-credentials.ts | 81 + .../production-connection-endpoints.ts | 133 ++ .../production-connection-health.ts | 339 ++++ .../production-connection-providers.ts | 137 ++ .../production-connection-types.ts | 104 + .../connections/production-connections.ts | 43 + src/adapters/credentials/headless-key.ts | 287 +++ src/adapters/credentials/headless-store.ts | 277 +++ src/adapters/credentials/memory.ts | 80 + src/adapters/credentials/os.ts | 212 ++ .../persistence/safe-file-descriptor.ts | 304 +++ src/adapters/persistence/safe-file-io.ts | 304 +++ src/adapters/persistence/safe-file.ts | 121 ++ .../runtime/agent-runtime-execution.ts | 286 ++- src/adapters/runtime/agent-runtime-version.ts | 1 + src/adapters/runtime/prepared-execution.ts | 40 + .../runtime/production-backend-resolver.ts | 385 ++++ src/adapters/runtime/supervisor-control.ts | 126 ++ src/adapters/runtime/supervisor-watch.ts | 66 + src/adapters/runtime/unavailable-execution.ts | 31 + src/adapters/storage/memory-base.ts | 148 ++ src/adapters/storage/memory-effects.ts | 58 + src/adapters/storage/memory-journal.ts | 188 ++ src/adapters/storage/memory-maintenance.ts | 66 + src/adapters/storage/memory-operations.ts | 227 +++ src/adapters/storage/memory-retention.ts | 180 ++ src/adapters/storage/memory.ts | 4 + src/adapters/storage/sqlite-base.ts | 229 +++ src/adapters/storage/sqlite-bound-open.ts | 194 ++ src/adapters/storage/sqlite-content.ts | 155 ++ src/adapters/storage/sqlite-crypto.ts | 202 ++ src/adapters/storage/sqlite-driver.ts | 142 ++ src/adapters/storage/sqlite-effects.ts | 379 ++++ src/adapters/storage/sqlite-errors.ts | 9 + src/adapters/storage/sqlite-initialization.ts | 145 ++ src/adapters/storage/sqlite-journal.ts | 406 ++++ src/adapters/storage/sqlite-lifecycle.ts | 357 ++++ src/adapters/storage/sqlite-maintenance.ts | 218 +++ src/adapters/storage/sqlite-operations.ts | 271 +++ src/adapters/storage/sqlite-paths.ts | 208 ++ src/adapters/storage/sqlite-projection.ts | 417 ++++ src/adapters/storage/sqlite-queue.ts | 59 + src/adapters/storage/sqlite-recovery.ts | 212 ++ src/adapters/storage/sqlite-redaction.ts | 438 +++++ src/adapters/storage/sqlite-rows.ts | 277 +++ src/adapters/storage/sqlite-schema.ts | 309 +++ .../storage/sqlite-state-snapshot-codec.ts | 122 ++ .../storage/sqlite-state-snapshot-keys.ts | 97 + .../sqlite-state-snapshot-lifecycle.ts | 226 +++ .../storage/sqlite-state-snapshot-read.ts | 178 ++ .../sqlite-state-snapshot-transaction.ts | 30 + .../storage/sqlite-state-snapshot-types.ts | 35 + .../storage/sqlite-state-snapshot-write.ts | 119 ++ .../storage/sqlite-state-snapshots.ts | 73 + src/adapters/storage/sqlite-types.ts | 97 + src/adapters/storage/sqlite.ts | 203 ++ src/adapters/storage/storage-validation.ts | 152 ++ src/adapters/tui/alternate-screen-terminal.ts | 8 + src/adapters/tui/application-ui-controller.ts | 266 +++ .../tui/profile-connection-dispatch.ts | 306 +++ src/adapters/tui/ui-capabilities.ts | 182 ++ src/adapters/tui/ui-command-dispatch.ts | 125 ++ src/adapters/tui/ui-conversation-dispatch.ts | 352 ++++ src/adapters/tui/ui-core-dispatch.ts | 230 +++ src/adapters/tui/ui-dispatch-context.ts | 18 + src/adapters/tui/ui-dispatch-error.ts | 21 + src/adapters/tui/ui-dispatch.ts | 39 + src/adapters/tui/ui-fixtures.ts | 251 +++ src/adapters/tui/ui-headless-dispatch.ts | 240 +++ src/adapters/tui/ui-intelligence-dispatch.ts | 458 +++++ src/adapters/tui/ui-intelligence-fixtures.ts | 28 + src/adapters/tui/ui-interaction-receipt.ts | 37 + src/adapters/tui/ui-projection.ts | 409 ++++ src/adapters/tui/ui-view-model.ts | 127 ++ src/app/action-host.ts | 9 + src/app/analysis-comparison-contracts.ts | 50 + src/app/analysis-comparison-evidence.ts | 161 ++ src/app/analysis-comparison-facts.ts | 134 ++ src/app/analysis-comparison-graph.ts | 59 + src/app/analysis-comparison-lifecycle.ts | 76 + src/app/analysis-comparison-request.ts | 49 + src/app/analysis-comparison.ts | 120 ++ src/app/analysis-execution-session.ts | 76 + src/app/analysis-graph-projector.ts | 68 + src/app/analysis-lifecycle.ts | 131 ++ src/app/analysis-operation.ts | 225 +++ src/app/analysis-persistence.ts | 22 + src/app/analysis-promotion.ts | 221 +++ src/app/analysis-request.ts | 44 + src/app/analysis-result-mapper.ts | 277 +++ src/app/analysis-service.ts | 206 ++ src/app/analysis-source.ts | 295 +++ src/app/analysis-types.ts | 161 ++ src/app/application-guards.ts | 23 + src/app/application-options.ts | 32 + src/app/application-port-builder.ts | 128 ++ src/app/application-port-runtime.ts | 85 + src/app/application-ports.ts | 162 ++ src/app/application-runtime-wiring.ts | 85 + src/app/application-support.ts | 485 +++++ src/app/application-transition.ts | 191 ++ src/app/application-types.ts | 93 + src/app/application.ts | 650 ++++-- src/app/automation-matching.ts | 181 ++ src/app/automation-rule-audit.ts | 276 +++ src/app/automation-rule-persistence.ts | 195 ++ src/app/automation-rule-types.ts | 103 + src/app/automation-rule-validation.ts | 267 +++ src/app/automation-rules.ts | 28 + src/app/composition.ts | 224 ++- src/app/configuration-action-transition.ts | 102 + src/app/configuration-session.ts | 331 ++++ src/app/connection-action-support.ts | 251 +++ src/app/connection-action-types.ts | 44 + src/app/connection-actions.ts | 258 +++ src/app/connection-errors.ts | 38 + src/app/connection-probe.ts | 22 + src/app/connections.ts | 178 ++ src/app/control-effects.ts | 193 ++ src/app/conversation-branches.ts | 364 ++++ src/app/conversation-composition.ts | 52 + src/app/conversation-context.ts | 254 +++ src/app/conversation-drafts.ts | 80 + src/app/conversation-exports.ts | 255 +++ src/app/conversation-import-builder.ts | 293 +++ src/app/conversation-import-document.ts | 223 +++ src/app/conversation-import-graph.ts | 186 ++ src/app/conversation-import-messages.ts | 355 ++++ src/app/conversation-import-runs.ts | 399 ++++ src/app/conversation-import-validation.ts | 185 ++ src/app/conversation-import-values.ts | 131 ++ src/app/conversation-imports.ts | 167 ++ src/app/conversation-lifecycle.ts | 419 ++++ src/app/conversation-operation-coordinator.ts | 30 + src/app/conversation-records.ts | 149 ++ src/app/conversation-support.ts | 167 ++ src/app/conversation-types.ts | 109 ++ src/app/conversation-visibility.ts | 51 + src/app/conversations.ts | 56 + src/app/durable-send.ts | 140 ++ src/app/effect-coordinator.ts | 378 ++++ src/app/effect-projection.ts | 38 + src/app/errors.ts | 9 + src/app/fail-closed-journal.ts | 54 + src/app/intelligence-actions.ts | 46 + src/app/interaction-controller.ts | 310 +++ src/app/interaction-effects.ts | 96 + src/app/interaction-response.ts | 115 ++ src/app/journal.ts | 79 +- src/app/legacy-cancel.ts | 71 + src/app/operation-fingerprint.ts | 34 + src/app/operation-ledger.ts | 82 + src/app/production-composition.ts | 173 ++ src/app/profile-action-support.ts | 316 +++ src/app/profile-actions.ts | 389 ++++ src/app/profile-draft.ts | 115 ++ src/app/profile-persistence.ts | 439 +++++ src/app/profile-save-recovery.ts | 302 +++ src/app/profile-selection.ts | 152 ++ src/app/profile-snapshots.ts | 86 + src/app/profile-sources.ts | 202 ++ src/app/profile-types.ts | 299 +++ src/app/profile-validation.ts | 410 ++++ src/app/profiles.ts | 254 +++ src/app/provider-snapshot.ts | 37 + src/app/provider-values.ts | 44 + src/app/run-admission-continuation.ts | 41 + src/app/run-admission-dispatch.ts | 174 ++ src/app/run-admission-receipt.ts | 142 ++ src/app/run-admission-request.ts | 44 + src/app/run-admission-validation.ts | 226 +++ src/app/run-admission.ts | 5 + src/app/run-continuation.ts | 64 + src/app/run-controls.ts | 307 +++ src/app/run-event-mapper.ts | 416 ++++ src/app/run-execution-snapshot.ts | 76 + src/app/run-execution.ts | 241 +++ src/app/run-ingestion.ts | 113 ++ src/app/run-ledger.ts | 109 ++ src/app/run-replay.ts | 192 ++ src/app/run-status.ts | 45 + src/app/runtime-selection.ts | 36 + src/app/shutdown-controller.ts | 82 + src/app/storage-journal-support.ts | 289 +++ src/app/storage-journal.ts | 287 +++ src/app/supervisor-service.ts | 276 +++ src/app/unavailable-analyst.ts | 28 + src/app/view-model.ts | 120 +- src/bin/args.ts | 103 +- src/bin/braid-runtime.ts | 190 ++ src/bin/braid.ts | 117 +- src/bin/interface-runner.ts | 257 +++ src/bin/plain.ts | 176 ++ src/bin/production-application.ts | 160 ++ src/bin/production-bridge-client.ts | 242 +++ src/bin/production-bridge-discovery.ts | 146 ++ src/bin/production-credential-context.ts | 91 + src/bin/production-key-path.ts | 18 + src/bin/production-profile-projection.ts | 243 +++ src/bin/production-setup-credentials.ts | 331 ++++ src/bin/production-setup-discovery.ts | 100 + src/bin/production-setup-persistence.ts | 267 +++ src/bin/production-setup-transition.ts | 139 ++ src/bin/production-setup-types.ts | 44 + src/bin/production-setup-validation.ts | 208 ++ src/bin/production-setup.ts | 30 + src/bin/production-startup.ts | 368 ++++ src/bin/state-path.ts | 41 + src/domain/bounded-structured.ts | 186 ++ src/domain/canonical.ts | 53 +- src/domain/code-unit-order.ts | 10 + src/domain/content-budget.ts | 48 + src/domain/entities-base.ts | 12 + src/domain/entities-core.ts | 250 +++ src/domain/entities-graph.ts | 193 ++ src/domain/entities-interactions.ts | 261 +++ src/domain/entities-messages.ts | 165 ++ src/domain/entities-runtime.ts | 174 ++ src/domain/entities.ts | 6 + src/domain/events-legacy.ts | 227 +++ src/domain/events.ts | 392 +++- src/domain/graph-records.ts | 53 + src/domain/ids-core.ts | 109 ++ src/domain/ids-types.ts | 131 ++ src/domain/ids-values.ts | 377 ++++ src/domain/ids.ts | 17 + src/domain/invariants-base.ts | 201 ++ src/domain/invariants-conversation.ts | 156 ++ src/domain/invariants-profile.ts | 104 + src/domain/invariants-relations.ts | 110 ++ src/domain/invariants-run.ts | 253 +++ src/domain/invariants-runtime.ts | 251 +++ src/domain/invariants-state.ts | 256 +++ src/domain/invariants.ts | 6 + src/domain/materialized-state-snapshot.ts | 232 +++ src/domain/projection-checksum.ts | 20 + src/domain/provider-values.ts | 49 + src/domain/public-metadata.ts | 47 + src/domain/receipts.ts | 222 +++ src/domain/redaction.ts | 57 + src/domain/reducer-content.ts | 331 ++++ src/domain/reducer-conversation-events.ts | 283 +++ src/domain/reducer-domain-events.ts | 249 +++ src/domain/reducer-helpers.ts | 482 +++++ src/domain/reducer-interactions.ts | 242 +++ src/domain/reducer-legacy.ts | 256 +++ src/domain/reducer-lifecycle.ts | 277 +++ src/domain/reducer-replay.ts | 212 ++ src/domain/reducer-run-graph.ts | 75 + src/domain/reducer-runtime.ts | 178 ++ src/domain/reducer-support.ts | 188 ++ src/domain/reducer.ts | 143 +- src/domain/run-contracts.ts | 144 ++ src/domain/runtime-events.ts | 62 + src/domain/runtime-projection.ts | 96 + src/domain/secret-sanitizer.ts | 303 +++ src/domain/state.ts | 184 +- src/domain/terminal-sanitizer.ts | 93 + src/domain/text.ts | 18 + src/eval/calibration.ts | 323 +++ src/eval/campaigns.ts | 309 +++ src/eval/cases.ts | 317 +++ src/eval/execution.ts | 298 +++ src/eval/fixtures.ts | 741 +++++++ src/eval/product-path.ts | 329 ++++ src/eval/record-builder.ts | 250 +++ src/eval/records.ts | 380 ++++ src/eval/release-decision.ts | 114 ++ src/eval/release-markers.ts | 104 + src/eval/runner.ts | 461 +++++ src/eval/types.ts | 224 +++ src/index.ts | 193 +- src/ports/credentials.ts | 45 + src/ports/effect-storage.ts | 63 + src/ports/execution.ts | 163 +- src/ports/ids.ts | 2 +- src/ports/storage.ts | 295 +++ src/startup/durable-runtime.ts | 1 + src/startup/terminal-runtime.ts | 4 + src/testing/deterministic-backend.ts | 28 +- src/testing/runtime-contract-fixtures.ts | 99 + src/views/headless/bounded-output.ts | 70 + src/views/headless/protocol-limits.ts | 73 + src/views/headless/protocol.ts | 105 +- src/views/headless/rpc-errors.ts | 11 + src/views/headless/rpc-parser.ts | 474 +++++ src/views/headless/rpc-types.ts | 16 + src/views/headless/rpc.ts | 486 +++-- src/views/shared/appearance.ts | 66 + src/views/shared/canonical.ts | 24 + src/views/shared/command-registry.ts | 403 ++++ src/views/shared/command-table.ts | 168 ++ src/views/shared/headless-commands.ts | 121 ++ src/views/shared/intents.ts | 112 ++ src/views/shared/models.ts | 506 +++++ src/views/shared/plain-accessibility.ts | 57 + src/views/shared/sanitize.ts | 154 +- src/views/shared/semantic-activity.ts | 219 +++ src/views/shared/semantic-details.ts | 387 ++++ src/views/shared/semantic-graph-edges.ts | 244 +++ src/views/shared/semantic-graph-filters.ts | 63 + src/views/shared/semantic-graph-nodes.ts | 285 +++ src/views/shared/semantic-graph.ts | 146 ++ src/views/shared/semantic-projection.ts | 320 +++ src/views/shared/semantic-query-scope.ts | 174 ++ src/views/shared/semantic-query-types.ts | 123 ++ src/views/tui/activity.ts | 50 + src/views/tui/analysis.ts | 210 ++ src/views/tui/autocomplete-guard.ts | 97 + src/views/tui/command-palette.ts | 78 +- src/views/tui/comparison.ts | 240 +++ src/views/tui/configuration-presenters.ts | 239 +++ .../tui/configuration-responsive-text.ts | 33 + src/views/tui/configuration-review.ts | 80 + .../tui/configuration-wizard-presentation.ts | 196 ++ src/views/tui/configuration-wizard.ts | 238 +++ src/views/tui/connection-setup-actions.ts | 59 + src/views/tui/connection-setup-rendering.ts | 47 + src/views/tui/connection-setup.ts | 242 +++ src/views/tui/conversation-dialogs.ts | 155 ++ src/views/tui/conversation-overlay-actions.ts | 263 +++ src/views/tui/conversation-overlay-helpers.ts | 107 + src/views/tui/conversation-overlays.ts | 79 + src/views/tui/details.ts | 34 + src/views/tui/fork-preview.ts | 136 ++ src/views/tui/graph.ts | 80 + src/views/tui/help.ts | 80 + src/views/tui/interaction-presentation.ts | 180 ++ src/views/tui/interaction.ts | 244 +++ src/views/tui/keyboard.ts | 283 +++ src/views/tui/layout.ts | 64 + src/views/tui/modal-coordinator.ts | 46 + src/views/tui/profile-compatibility.ts | 146 ++ src/views/tui/profile-editor-actions.ts | 81 + src/views/tui/profile-editor-rendering.ts | 40 + src/views/tui/profile-editor.ts | 293 +++ src/views/tui/safe-markdown.ts | 91 + src/views/tui/selector.ts | 188 ++ src/views/tui/terminal-app.ts | 342 ++-- src/views/tui/terminal-chrome.ts | 245 +++ src/views/tui/terminal-command-controller.ts | 147 ++ src/views/tui/terminal-compatibility.ts | 129 ++ src/views/tui/terminal-drafts.ts | 123 ++ src/views/tui/terminal-input-controller.ts | 188 ++ .../tui/terminal-interaction-controller.ts | 91 + src/views/tui/terminal-overlays.ts | 344 ++++ src/views/tui/terminal-shell.ts | 164 ++ src/views/tui/theme.ts | 73 +- src/views/tui/transcript.ts | 315 +++ test/analysis-durable.test.ts | 640 ++++++ test/analysis-supervision.test.ts | 529 +++++ test/application.test.ts | 974 ++++++++- test/cli-bridge-profile-contract.test.ts | 239 +++ test/cli-startup.test.ts | 74 + test/configuration-product-flow.test.ts | 449 +++++ test/configuration-session.test.ts | 152 ++ test/configuration-wizard.test.ts | 449 +++++ test/connections.test.ts | 373 ++++ test/conversation-storage.test.ts | 390 ++++ test/conversations.test.ts | 867 ++++++++ test/coordination.test.ts | 306 +++ test/domain-ids.test.ts | 113 ++ test/domain-invariants.test.ts | 226 +++ test/domain-reducer.test.ts | 247 +++ test/domain-text.test.ts | 67 + test/effect-admission-child.ts | 57 + test/effect-admission.test.ts | 71 + test/eval.test.ts | 316 +++ test/intelligence-dispatch.test.ts | 351 ++++ test/interactions.test.ts | 713 +++++++ test/keyboard.test.ts | 174 ++ test/live-w10.test.ts | 54 + test/performance.test.ts | 209 ++ test/production-composition.test.ts | 1741 +++++++++++++++++ test/profile-compatibility-ui.test.ts | 184 ++ test/profile-connection-actions.test.ts | 365 ++++ test/profile-save-recovery.test.ts | 91 + test/profiles.test.ts | 544 +++++ test/reducer.test.ts | 12 +- test/rpc.test.ts | 741 ++++++- test/run-admission-architecture.test.ts | 240 +++ test/sanitize.test.ts | 128 +- test/scripts.test.ts | 328 ++++ test/security.test.ts | 853 ++++++++ test/storage-crash-child.ts | 246 +++ test/storage-crash.test.ts | 512 +++++ test/storage-performance.test.ts | 80 + test/storage-snapshots.test.ts | 576 ++++++ test/storage.test.ts | 731 +++++++ test/support/file-credentials.ts | 96 + test/support/storage-snapshot-fixture.ts | 46 + test/support/virtual-terminal.ts | 3 +- test/terminal-responsive.test.ts | 178 ++ test/trace-analysis-configuration.test.ts | 425 ++++ test/tui-autocomplete.test.ts | 254 +++ test/tui-conversations.test.ts | 209 ++ test/tui-core-workflows.test.ts | 680 +++++++ test/tui.test.ts | 186 +- test/w6-contract.test.ts | 226 +++ test/w6-ui.test.ts | 684 +++++++ test/w8-runs.test.ts | 404 ++++ 579 files changed, 104016 insertions(+), 1596 deletions(-) create mode 100644 artifacts/verification/live-core/latest.json create mode 100644 artifacts/verification/performance/smoke.json create mode 100644 artifacts/verification/w6/120x40-plain.txt create mode 100644 artifacts/verification/w6/120x40.png create mode 100644 artifacts/verification/w6/120x40.txt create mode 100644 artifacts/verification/w6/200x60-plain.txt create mode 100644 artifacts/verification/w6/200x60.png create mode 100644 artifacts/verification/w6/200x60.txt create mode 100644 artifacts/verification/w6/40x12-plain.txt create mode 100644 artifacts/verification/w6/40x12.png create mode 100644 artifacts/verification/w6/40x12.txt create mode 100644 artifacts/verification/w6/80x24-plain.txt create mode 100644 artifacts/verification/w6/80x24.png create mode 100644 artifacts/verification/w6/80x24.txt create mode 100644 artifacts/verification/w6/capture-manifest.json create mode 100644 artifacts/verification/w6/package-proof.json create mode 100644 artifacts/verification/w6/states/active-streaming.json create mode 100644 artifacts/verification/w6/states/active-streaming.png create mode 100644 artifacts/verification/w6/states/active-streaming.txt create mode 100644 artifacts/verification/w6/states/analysis.json create mode 100644 artifacts/verification/w6/states/analysis.png create mode 100644 artifacts/verification/w6/states/analysis.txt create mode 100644 artifacts/verification/w6/states/comparison.json create mode 100644 artifacts/verification/w6/states/comparison.png create mode 100644 artifacts/verification/w6/states/comparison.txt create mode 100644 artifacts/verification/w6/states/empty.json create mode 100644 artifacts/verification/w6/states/empty.png create mode 100644 artifacts/verification/w6/states/empty.txt create mode 100644 artifacts/verification/w6/states/failure-or-reconnect.json create mode 100644 artifacts/verification/w6/states/failure-or-reconnect.png create mode 100644 artifacts/verification/w6/states/failure-or-reconnect.txt create mode 100644 artifacts/verification/w6/states/fork-preview.json create mode 100644 artifacts/verification/w6/states/fork-preview.png create mode 100644 artifacts/verification/w6/states/fork-preview.txt create mode 100644 artifacts/verification/w6/states/graph-or-analysis.json create mode 100644 artifacts/verification/w6/states/graph-or-analysis.png create mode 100644 artifacts/verification/w6/states/graph-or-analysis.txt create mode 100644 artifacts/verification/w6/states/interaction.json create mode 100644 artifacts/verification/w6/states/interaction.png create mode 100644 artifacts/verification/w6/states/interaction.txt create mode 100644 artifacts/verification/w6/states/narrow.json create mode 100644 artifacts/verification/w6/states/narrow.png create mode 100644 artifacts/verification/w6/states/narrow.txt create mode 100644 artifacts/verification/w6/states/profile.json create mode 100644 artifacts/verification/w6/states/profile.png create mode 100644 artifacts/verification/w6/states/profile.txt create mode 100644 docs/decisions/004-application-effect-coordination.md create mode 100644 docs/decisions/005-encrypted-sqlite-and-credential-boundaries.md create mode 100644 release/execution-public-key.fingerprint create mode 100644 release/execution-public-key.pem create mode 100644 scripts/accessibility-output.mjs create mode 100644 scripts/build-startup.mjs create mode 100644 scripts/capture-visual-definitions.mjs create mode 100644 scripts/capture-visual-support.mjs create mode 100644 scripts/capture-visual.mjs create mode 100644 scripts/check-module-cycles.mjs create mode 100644 scripts/check-release.mjs create mode 100644 scripts/check-scope.mjs create mode 100644 scripts/eval/candidate.mjs create mode 100644 scripts/eval/run.mjs create mode 100644 scripts/live-bridge.mjs create mode 100644 scripts/live-bridge/bridge.mjs create mode 100644 scripts/live-bridge/capture.mjs create mode 100644 scripts/live-bridge/command.mjs create mode 100644 scripts/live-bridge/config.mjs create mode 100644 scripts/live-bridge/constants.mjs create mode 100644 scripts/live-bridge/endpoint.mjs create mode 100644 scripts/live-bridge/errors.mjs create mode 100644 scripts/live-bridge/evidence.mjs create mode 100644 scripts/live-bridge/main.mjs create mode 100644 scripts/live-bridge/matrix-adversarial.mjs create mode 100644 scripts/live-bridge/matrix.mjs create mode 100644 scripts/live-bridge/pack.mjs create mode 100644 scripts/live-bridge/process-tree.mjs create mode 100644 scripts/live-bridge/process.mjs create mode 100644 scripts/live-bridge/protocol.mjs create mode 100644 scripts/live-bridge/redaction.mjs create mode 100644 scripts/live-bridge/target-actions.mjs create mode 100644 scripts/live-bridge/target-flow.mjs create mode 100644 scripts/live-bridge/target-policy.mjs create mode 100644 scripts/live-core/discovery.mjs create mode 100644 scripts/live-core/evidence.mjs create mode 100644 scripts/live-core/rpc-session.mjs create mode 100644 scripts/live-core/run.mjs create mode 100644 scripts/live-core/setup-tui.mjs create mode 100644 scripts/live-core/turn-proof.mjs create mode 100644 scripts/live-required.mjs create mode 100644 scripts/native-install-environment.mjs create mode 100644 scripts/package-proof-flows.mjs create mode 100644 scripts/package-proof-parity.mjs create mode 100644 scripts/package-proof-runtime.mjs create mode 100644 scripts/packed-binary.mjs create mode 100644 scripts/packed-rpc/deterministic.mjs create mode 100644 scripts/packed-rpc/first-run-tui.mjs create mode 100644 scripts/packed-rpc/production-startup.mjs create mode 100644 scripts/performance/application-probe.mjs create mode 100644 scripts/performance/compile-cache-primer.mjs create mode 100644 scripts/performance/file-credential-store.mjs create mode 100644 scripts/performance/headless-terminal.mjs create mode 100644 scripts/performance/lifecycle.mjs create mode 100644 scripts/performance/memory-child.mjs create mode 100644 scripts/performance/packed-production-tui-child.mjs create mode 100644 scripts/performance/packed-runtime.mjs create mode 100644 scripts/performance/process-measurements.mjs create mode 100644 scripts/performance/process-probes.mjs create mode 100644 scripts/performance/reporting.mjs create mode 100644 scripts/performance/resize-probe.mjs create mode 100644 scripts/performance/run.mjs create mode 100644 scripts/performance/runtime-measurement.mjs create mode 100644 scripts/performance/statistics.mjs create mode 100644 scripts/performance/storage-measurements.mjs create mode 100644 scripts/performance/storage-probes.mjs create mode 100644 scripts/release-check-catalog.mjs create mode 100644 scripts/release-evidence.mjs create mode 100644 scripts/release-files.mjs create mode 100644 scripts/release-visual-proof.mjs create mode 100644 scripts/release/artifact-store.mjs create mode 100644 scripts/release/atomic-storage.mjs create mode 100644 scripts/release/bindings.mjs create mode 100644 scripts/release/build-identity.mjs create mode 100644 scripts/release/collect-release-evidence.mjs create mode 100644 scripts/release/collection-contract.mjs create mode 100644 scripts/release/collector-validation.mjs create mode 100644 scripts/release/collector.mjs create mode 100644 scripts/release/command-runner.mjs create mode 100644 scripts/release/package-archive.mjs create mode 100644 scripts/release/process-tree.mjs create mode 100644 scripts/release/redaction.mjs create mode 100644 scripts/release/release-collector.test.mjs create mode 100644 scripts/release/verification-artifacts.mjs create mode 100644 scripts/release/verification-checks.mjs create mode 100644 scripts/release/verification-flow.mjs create mode 100644 scripts/release/verification-mappings.mjs create mode 100644 scripts/release/verification-options.mjs create mode 100644 scripts/release/verification-plan.mjs create mode 100644 scripts/release/verification-report.mjs create mode 100644 scripts/release/verification-source.mjs create mode 100644 scripts/release/verification-support.mjs create mode 100644 scripts/scan-secret-artifacts.mjs create mode 100644 scripts/test-pty.mjs create mode 100644 scripts/test-release-evidence.mjs create mode 100644 scripts/test-rpc-packed.mjs create mode 100644 scripts/test.mjs create mode 100644 scripts/verify-release.mjs create mode 100644 src/adapters/analysis/agent-eval-version.ts create mode 100644 src/adapters/analysis/citations.ts create mode 100644 src/adapters/analysis/eval-analyst.ts create mode 100644 src/adapters/analysis/python-runner.ts create mode 100644 src/adapters/analysis/runtime-model-owner.ts create mode 100644 src/adapters/analysis/trace-analysis-adapter.ts create mode 100644 src/adapters/analysis/trace-store.ts create mode 100644 src/adapters/connections/cli-bridge-health.ts create mode 100644 src/adapters/connections/cli-bridge-model-route.ts create mode 100644 src/adapters/connections/production-connection-credentials.ts create mode 100644 src/adapters/connections/production-connection-endpoints.ts create mode 100644 src/adapters/connections/production-connection-health.ts create mode 100644 src/adapters/connections/production-connection-providers.ts create mode 100644 src/adapters/connections/production-connection-types.ts create mode 100644 src/adapters/connections/production-connections.ts create mode 100644 src/adapters/credentials/headless-key.ts create mode 100644 src/adapters/credentials/headless-store.ts create mode 100644 src/adapters/credentials/memory.ts create mode 100644 src/adapters/credentials/os.ts create mode 100644 src/adapters/persistence/safe-file-descriptor.ts create mode 100644 src/adapters/persistence/safe-file-io.ts create mode 100644 src/adapters/persistence/safe-file.ts create mode 100644 src/adapters/runtime/agent-runtime-version.ts create mode 100644 src/adapters/runtime/prepared-execution.ts create mode 100644 src/adapters/runtime/production-backend-resolver.ts create mode 100644 src/adapters/runtime/supervisor-control.ts create mode 100644 src/adapters/runtime/supervisor-watch.ts create mode 100644 src/adapters/runtime/unavailable-execution.ts create mode 100644 src/adapters/storage/memory-base.ts create mode 100644 src/adapters/storage/memory-effects.ts create mode 100644 src/adapters/storage/memory-journal.ts create mode 100644 src/adapters/storage/memory-maintenance.ts create mode 100644 src/adapters/storage/memory-operations.ts create mode 100644 src/adapters/storage/memory-retention.ts create mode 100644 src/adapters/storage/memory.ts create mode 100644 src/adapters/storage/sqlite-base.ts create mode 100644 src/adapters/storage/sqlite-bound-open.ts create mode 100644 src/adapters/storage/sqlite-content.ts create mode 100644 src/adapters/storage/sqlite-crypto.ts create mode 100644 src/adapters/storage/sqlite-driver.ts create mode 100644 src/adapters/storage/sqlite-effects.ts create mode 100644 src/adapters/storage/sqlite-errors.ts create mode 100644 src/adapters/storage/sqlite-initialization.ts create mode 100644 src/adapters/storage/sqlite-journal.ts create mode 100644 src/adapters/storage/sqlite-lifecycle.ts create mode 100644 src/adapters/storage/sqlite-maintenance.ts create mode 100644 src/adapters/storage/sqlite-operations.ts create mode 100644 src/adapters/storage/sqlite-paths.ts create mode 100644 src/adapters/storage/sqlite-projection.ts create mode 100644 src/adapters/storage/sqlite-queue.ts create mode 100644 src/adapters/storage/sqlite-recovery.ts create mode 100644 src/adapters/storage/sqlite-redaction.ts create mode 100644 src/adapters/storage/sqlite-rows.ts create mode 100644 src/adapters/storage/sqlite-schema.ts create mode 100644 src/adapters/storage/sqlite-state-snapshot-codec.ts create mode 100644 src/adapters/storage/sqlite-state-snapshot-keys.ts create mode 100644 src/adapters/storage/sqlite-state-snapshot-lifecycle.ts create mode 100644 src/adapters/storage/sqlite-state-snapshot-read.ts create mode 100644 src/adapters/storage/sqlite-state-snapshot-transaction.ts create mode 100644 src/adapters/storage/sqlite-state-snapshot-types.ts create mode 100644 src/adapters/storage/sqlite-state-snapshot-write.ts create mode 100644 src/adapters/storage/sqlite-state-snapshots.ts create mode 100644 src/adapters/storage/sqlite-types.ts create mode 100644 src/adapters/storage/sqlite.ts create mode 100644 src/adapters/storage/storage-validation.ts create mode 100644 src/adapters/tui/application-ui-controller.ts create mode 100644 src/adapters/tui/profile-connection-dispatch.ts create mode 100644 src/adapters/tui/ui-capabilities.ts create mode 100644 src/adapters/tui/ui-command-dispatch.ts create mode 100644 src/adapters/tui/ui-conversation-dispatch.ts create mode 100644 src/adapters/tui/ui-core-dispatch.ts create mode 100644 src/adapters/tui/ui-dispatch-context.ts create mode 100644 src/adapters/tui/ui-dispatch-error.ts create mode 100644 src/adapters/tui/ui-dispatch.ts create mode 100644 src/adapters/tui/ui-fixtures.ts create mode 100644 src/adapters/tui/ui-headless-dispatch.ts create mode 100644 src/adapters/tui/ui-intelligence-dispatch.ts create mode 100644 src/adapters/tui/ui-intelligence-fixtures.ts create mode 100644 src/adapters/tui/ui-interaction-receipt.ts create mode 100644 src/adapters/tui/ui-projection.ts create mode 100644 src/adapters/tui/ui-view-model.ts create mode 100644 src/app/action-host.ts create mode 100644 src/app/analysis-comparison-contracts.ts create mode 100644 src/app/analysis-comparison-evidence.ts create mode 100644 src/app/analysis-comparison-facts.ts create mode 100644 src/app/analysis-comparison-graph.ts create mode 100644 src/app/analysis-comparison-lifecycle.ts create mode 100644 src/app/analysis-comparison-request.ts create mode 100644 src/app/analysis-comparison.ts create mode 100644 src/app/analysis-execution-session.ts create mode 100644 src/app/analysis-graph-projector.ts create mode 100644 src/app/analysis-lifecycle.ts create mode 100644 src/app/analysis-operation.ts create mode 100644 src/app/analysis-persistence.ts create mode 100644 src/app/analysis-promotion.ts create mode 100644 src/app/analysis-request.ts create mode 100644 src/app/analysis-result-mapper.ts create mode 100644 src/app/analysis-service.ts create mode 100644 src/app/analysis-source.ts create mode 100644 src/app/analysis-types.ts create mode 100644 src/app/application-guards.ts create mode 100644 src/app/application-options.ts create mode 100644 src/app/application-port-builder.ts create mode 100644 src/app/application-port-runtime.ts create mode 100644 src/app/application-ports.ts create mode 100644 src/app/application-runtime-wiring.ts create mode 100644 src/app/application-support.ts create mode 100644 src/app/application-transition.ts create mode 100644 src/app/application-types.ts create mode 100644 src/app/automation-matching.ts create mode 100644 src/app/automation-rule-audit.ts create mode 100644 src/app/automation-rule-persistence.ts create mode 100644 src/app/automation-rule-types.ts create mode 100644 src/app/automation-rule-validation.ts create mode 100644 src/app/automation-rules.ts create mode 100644 src/app/configuration-action-transition.ts create mode 100644 src/app/configuration-session.ts create mode 100644 src/app/connection-action-support.ts create mode 100644 src/app/connection-action-types.ts create mode 100644 src/app/connection-actions.ts create mode 100644 src/app/connection-errors.ts create mode 100644 src/app/connection-probe.ts create mode 100644 src/app/connections.ts create mode 100644 src/app/control-effects.ts create mode 100644 src/app/conversation-branches.ts create mode 100644 src/app/conversation-composition.ts create mode 100644 src/app/conversation-context.ts create mode 100644 src/app/conversation-drafts.ts create mode 100644 src/app/conversation-exports.ts create mode 100644 src/app/conversation-import-builder.ts create mode 100644 src/app/conversation-import-document.ts create mode 100644 src/app/conversation-import-graph.ts create mode 100644 src/app/conversation-import-messages.ts create mode 100644 src/app/conversation-import-runs.ts create mode 100644 src/app/conversation-import-validation.ts create mode 100644 src/app/conversation-import-values.ts create mode 100644 src/app/conversation-imports.ts create mode 100644 src/app/conversation-lifecycle.ts create mode 100644 src/app/conversation-operation-coordinator.ts create mode 100644 src/app/conversation-records.ts create mode 100644 src/app/conversation-support.ts create mode 100644 src/app/conversation-types.ts create mode 100644 src/app/conversation-visibility.ts create mode 100644 src/app/conversations.ts create mode 100644 src/app/durable-send.ts create mode 100644 src/app/effect-coordinator.ts create mode 100644 src/app/effect-projection.ts create mode 100644 src/app/errors.ts create mode 100644 src/app/fail-closed-journal.ts create mode 100644 src/app/intelligence-actions.ts create mode 100644 src/app/interaction-controller.ts create mode 100644 src/app/interaction-effects.ts create mode 100644 src/app/interaction-response.ts create mode 100644 src/app/legacy-cancel.ts create mode 100644 src/app/operation-fingerprint.ts create mode 100644 src/app/operation-ledger.ts create mode 100644 src/app/production-composition.ts create mode 100644 src/app/profile-action-support.ts create mode 100644 src/app/profile-actions.ts create mode 100644 src/app/profile-draft.ts create mode 100644 src/app/profile-persistence.ts create mode 100644 src/app/profile-save-recovery.ts create mode 100644 src/app/profile-selection.ts create mode 100644 src/app/profile-snapshots.ts create mode 100644 src/app/profile-sources.ts create mode 100644 src/app/profile-types.ts create mode 100644 src/app/profile-validation.ts create mode 100644 src/app/profiles.ts create mode 100644 src/app/provider-snapshot.ts create mode 100644 src/app/provider-values.ts create mode 100644 src/app/run-admission-continuation.ts create mode 100644 src/app/run-admission-dispatch.ts create mode 100644 src/app/run-admission-receipt.ts create mode 100644 src/app/run-admission-request.ts create mode 100644 src/app/run-admission-validation.ts create mode 100644 src/app/run-admission.ts create mode 100644 src/app/run-continuation.ts create mode 100644 src/app/run-controls.ts create mode 100644 src/app/run-event-mapper.ts create mode 100644 src/app/run-execution-snapshot.ts create mode 100644 src/app/run-execution.ts create mode 100644 src/app/run-ingestion.ts create mode 100644 src/app/run-ledger.ts create mode 100644 src/app/run-replay.ts create mode 100644 src/app/run-status.ts create mode 100644 src/app/runtime-selection.ts create mode 100644 src/app/shutdown-controller.ts create mode 100644 src/app/storage-journal-support.ts create mode 100644 src/app/storage-journal.ts create mode 100644 src/app/supervisor-service.ts create mode 100644 src/app/unavailable-analyst.ts create mode 100644 src/bin/braid-runtime.ts create mode 100644 src/bin/interface-runner.ts create mode 100644 src/bin/plain.ts create mode 100644 src/bin/production-application.ts create mode 100644 src/bin/production-bridge-client.ts create mode 100644 src/bin/production-bridge-discovery.ts create mode 100644 src/bin/production-credential-context.ts create mode 100644 src/bin/production-key-path.ts create mode 100644 src/bin/production-profile-projection.ts create mode 100644 src/bin/production-setup-credentials.ts create mode 100644 src/bin/production-setup-discovery.ts create mode 100644 src/bin/production-setup-persistence.ts create mode 100644 src/bin/production-setup-transition.ts create mode 100644 src/bin/production-setup-types.ts create mode 100644 src/bin/production-setup-validation.ts create mode 100644 src/bin/production-setup.ts create mode 100644 src/bin/production-startup.ts create mode 100644 src/bin/state-path.ts create mode 100644 src/domain/bounded-structured.ts create mode 100644 src/domain/code-unit-order.ts create mode 100644 src/domain/content-budget.ts create mode 100644 src/domain/entities-base.ts create mode 100644 src/domain/entities-core.ts create mode 100644 src/domain/entities-graph.ts create mode 100644 src/domain/entities-interactions.ts create mode 100644 src/domain/entities-messages.ts create mode 100644 src/domain/entities-runtime.ts create mode 100644 src/domain/entities.ts create mode 100644 src/domain/events-legacy.ts create mode 100644 src/domain/graph-records.ts create mode 100644 src/domain/ids-core.ts create mode 100644 src/domain/ids-types.ts create mode 100644 src/domain/ids-values.ts create mode 100644 src/domain/ids.ts create mode 100644 src/domain/invariants-base.ts create mode 100644 src/domain/invariants-conversation.ts create mode 100644 src/domain/invariants-profile.ts create mode 100644 src/domain/invariants-relations.ts create mode 100644 src/domain/invariants-run.ts create mode 100644 src/domain/invariants-runtime.ts create mode 100644 src/domain/invariants-state.ts create mode 100644 src/domain/invariants.ts create mode 100644 src/domain/materialized-state-snapshot.ts create mode 100644 src/domain/projection-checksum.ts create mode 100644 src/domain/provider-values.ts create mode 100644 src/domain/public-metadata.ts create mode 100644 src/domain/receipts.ts create mode 100644 src/domain/redaction.ts create mode 100644 src/domain/reducer-content.ts create mode 100644 src/domain/reducer-conversation-events.ts create mode 100644 src/domain/reducer-domain-events.ts create mode 100644 src/domain/reducer-helpers.ts create mode 100644 src/domain/reducer-interactions.ts create mode 100644 src/domain/reducer-legacy.ts create mode 100644 src/domain/reducer-lifecycle.ts create mode 100644 src/domain/reducer-replay.ts create mode 100644 src/domain/reducer-run-graph.ts create mode 100644 src/domain/reducer-runtime.ts create mode 100644 src/domain/reducer-support.ts create mode 100644 src/domain/run-contracts.ts create mode 100644 src/domain/runtime-events.ts create mode 100644 src/domain/runtime-projection.ts create mode 100644 src/domain/secret-sanitizer.ts create mode 100644 src/domain/terminal-sanitizer.ts create mode 100644 src/domain/text.ts create mode 100644 src/eval/calibration.ts create mode 100644 src/eval/campaigns.ts create mode 100644 src/eval/cases.ts create mode 100644 src/eval/execution.ts create mode 100644 src/eval/fixtures.ts create mode 100644 src/eval/product-path.ts create mode 100644 src/eval/record-builder.ts create mode 100644 src/eval/records.ts create mode 100644 src/eval/release-decision.ts create mode 100644 src/eval/release-markers.ts create mode 100644 src/eval/runner.ts create mode 100644 src/eval/types.ts create mode 100644 src/ports/credentials.ts create mode 100644 src/ports/effect-storage.ts create mode 100644 src/ports/storage.ts create mode 100644 src/startup/durable-runtime.ts create mode 100644 src/startup/terminal-runtime.ts create mode 100644 src/testing/runtime-contract-fixtures.ts create mode 100644 src/views/headless/bounded-output.ts create mode 100644 src/views/headless/protocol-limits.ts create mode 100644 src/views/headless/rpc-errors.ts create mode 100644 src/views/headless/rpc-parser.ts create mode 100644 src/views/headless/rpc-types.ts create mode 100644 src/views/shared/appearance.ts create mode 100644 src/views/shared/canonical.ts create mode 100644 src/views/shared/command-registry.ts create mode 100644 src/views/shared/command-table.ts create mode 100644 src/views/shared/headless-commands.ts create mode 100644 src/views/shared/intents.ts create mode 100644 src/views/shared/models.ts create mode 100644 src/views/shared/plain-accessibility.ts create mode 100644 src/views/shared/semantic-activity.ts create mode 100644 src/views/shared/semantic-details.ts create mode 100644 src/views/shared/semantic-graph-edges.ts create mode 100644 src/views/shared/semantic-graph-filters.ts create mode 100644 src/views/shared/semantic-graph-nodes.ts create mode 100644 src/views/shared/semantic-graph.ts create mode 100644 src/views/shared/semantic-projection.ts create mode 100644 src/views/shared/semantic-query-scope.ts create mode 100644 src/views/shared/semantic-query-types.ts create mode 100644 src/views/tui/activity.ts create mode 100644 src/views/tui/analysis.ts create mode 100644 src/views/tui/autocomplete-guard.ts create mode 100644 src/views/tui/comparison.ts create mode 100644 src/views/tui/configuration-presenters.ts create mode 100644 src/views/tui/configuration-responsive-text.ts create mode 100644 src/views/tui/configuration-review.ts create mode 100644 src/views/tui/configuration-wizard-presentation.ts create mode 100644 src/views/tui/configuration-wizard.ts create mode 100644 src/views/tui/connection-setup-actions.ts create mode 100644 src/views/tui/connection-setup-rendering.ts create mode 100644 src/views/tui/connection-setup.ts create mode 100644 src/views/tui/conversation-dialogs.ts create mode 100644 src/views/tui/conversation-overlay-actions.ts create mode 100644 src/views/tui/conversation-overlay-helpers.ts create mode 100644 src/views/tui/conversation-overlays.ts create mode 100644 src/views/tui/details.ts create mode 100644 src/views/tui/fork-preview.ts create mode 100644 src/views/tui/graph.ts create mode 100644 src/views/tui/help.ts create mode 100644 src/views/tui/interaction-presentation.ts create mode 100644 src/views/tui/interaction.ts create mode 100644 src/views/tui/keyboard.ts create mode 100644 src/views/tui/layout.ts create mode 100644 src/views/tui/modal-coordinator.ts create mode 100644 src/views/tui/profile-compatibility.ts create mode 100644 src/views/tui/profile-editor-actions.ts create mode 100644 src/views/tui/profile-editor-rendering.ts create mode 100644 src/views/tui/profile-editor.ts create mode 100644 src/views/tui/safe-markdown.ts create mode 100644 src/views/tui/selector.ts create mode 100644 src/views/tui/terminal-chrome.ts create mode 100644 src/views/tui/terminal-command-controller.ts create mode 100644 src/views/tui/terminal-compatibility.ts create mode 100644 src/views/tui/terminal-drafts.ts create mode 100644 src/views/tui/terminal-input-controller.ts create mode 100644 src/views/tui/terminal-interaction-controller.ts create mode 100644 src/views/tui/terminal-overlays.ts create mode 100644 src/views/tui/terminal-shell.ts create mode 100644 src/views/tui/transcript.ts create mode 100644 test/analysis-durable.test.ts create mode 100644 test/analysis-supervision.test.ts create mode 100644 test/cli-bridge-profile-contract.test.ts create mode 100644 test/cli-startup.test.ts create mode 100644 test/configuration-product-flow.test.ts create mode 100644 test/configuration-session.test.ts create mode 100644 test/configuration-wizard.test.ts create mode 100644 test/connections.test.ts create mode 100644 test/conversation-storage.test.ts create mode 100644 test/conversations.test.ts create mode 100644 test/coordination.test.ts create mode 100644 test/domain-ids.test.ts create mode 100644 test/domain-invariants.test.ts create mode 100644 test/domain-reducer.test.ts create mode 100644 test/domain-text.test.ts create mode 100644 test/effect-admission-child.ts create mode 100644 test/effect-admission.test.ts create mode 100644 test/eval.test.ts create mode 100644 test/intelligence-dispatch.test.ts create mode 100644 test/interactions.test.ts create mode 100644 test/keyboard.test.ts create mode 100644 test/live-w10.test.ts create mode 100644 test/performance.test.ts create mode 100644 test/production-composition.test.ts create mode 100644 test/profile-compatibility-ui.test.ts create mode 100644 test/profile-connection-actions.test.ts create mode 100644 test/profile-save-recovery.test.ts create mode 100644 test/profiles.test.ts create mode 100644 test/run-admission-architecture.test.ts create mode 100644 test/scripts.test.ts create mode 100644 test/security.test.ts create mode 100644 test/storage-crash-child.ts create mode 100644 test/storage-crash.test.ts create mode 100644 test/storage-performance.test.ts create mode 100644 test/storage-snapshots.test.ts create mode 100644 test/storage.test.ts create mode 100644 test/support/file-credentials.ts create mode 100644 test/support/storage-snapshot-fixture.ts create mode 100644 test/terminal-responsive.test.ts create mode 100644 test/trace-analysis-configuration.test.ts create mode 100644 test/tui-autocomplete.test.ts create mode 100644 test/tui-conversations.test.ts create mode 100644 test/tui-core-workflows.test.ts create mode 100644 test/w6-contract.test.ts create mode 100644 test/w6-ui.test.ts create mode 100644 test/w8-runs.test.ts diff --git a/.gitignore b/.gitignore index a740e68..ba0988f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,9 @@ -node_modules/ +node_modules dist/ .test-dist/ coverage/ .tmp/ +.braid/ *.log *.tgz .env @@ -10,6 +11,7 @@ coverage/ !.env.example artifacts/verification/**/raw/ artifacts/verification/**/*.cast +artifacts/verification/**/*.ansi artifacts/verification/**/*.gif !artifacts/verification/w0/*.gif artifacts/verification/**/*.mp4 diff --git a/README.md b/README.md index a5f738e..9544611 100644 --- a/README.md +++ b/README.md @@ -12,11 +12,18 @@ Provider packages own transport to CLI Bridge and Tangle. ## Status -The W0 vertical slice is implemented: one `braid` binary, one reducer, one JSONL control interface, and one real Pi terminal transcript and composer all drive `agent-runtime`. -The packed binary has deterministic keyboard and JSONL proof, but live CLI Bridge and Tangle connections are not implemented yet. -The contract is based on current source inspection of `agent-runtime`, `agent-interface`, `cli-bridge`, `agent-eval`, Pi, Kimi Code, OpenCode, and Hermes Agent on 2026-08-01. - -![Braid terminal at 80×24](artifacts/verification/w0/80x24.png) +Braid's production CLI/TUI, encrypted local state, profile and connection setup, runtime dispatch, conversations, branches, graphs, interactions, and analysis commands are implemented. +The terminal and headless surfaces share one command registry, one view model, and one durable operation ledger. +The CLI Bridge path is implemented and was proven from a clean packed install on 2026-08-04 with Pi/GLM-5.2 and Codex through first-run setup, two-turn session continuity, normalized events, process restart, transcript recovery, and a post-restart turn. +The Tangle inference and sandbox paths are implemented against the current provider packages but still require protected live-deployment proof before they are advertised as release-complete. +`/ask`, `/analyze`, `/compare`, trace citations, and analysis promotion are implemented; the semantic evaluation command performs pilot, calibration, and release-case checks when a judge model is configured. +Generalized interaction responses remain capability-disabled because the installed runtime and providers do not expose a run-bound response operation. +The deterministic `MemoryJournal` remains fixture-only; production startup fails closed if the pinned encrypted SQLite binding or credential facility is unavailable. +The storage binding is pinned to `better-sqlite3-multiple-ciphers@12.11.1`, operating-system credentials use `@napi-rs/keyring@1.3.0`, and raw database, WAL, shared-memory, backup, wrong-key, restore-recovery, two-process admission, and forced-kill checks run against the native implementations. +Tangle, supervisor-control, full live-analysis, multi-platform installation, and signed release evidence remain required before the complete release contract is satisfied. +The contract is based on current source inspection of `agent-runtime`, `agent-interface`, `cli-bridge`, `agent-eval`, Pi, Kimi Code, OpenCode, and Hermes Agent through 2026-08-04. + +![Braid terminal at 80×24](artifacts/verification/w6/80x24.png) Run the deterministic slice locally: @@ -26,7 +33,35 @@ pnpm run build node dist/bin/braid.js --fixture deterministic ``` -Run `pnpm check`, `pnpm run test:package`, and `pnpm run capture:w0` to reproduce the current checks and terminal captures. +Run the checks with the stable command map below. +Commands marked unavailable fail with exit code 2 and a plain explanation; they do not substitute a narrower test and do not create a release claim. + +| Check | Command | Behavior | +| --- | --- | --- | +| repository | `pnpm check` | Runs local format, lint, types, boundaries, dependency/license metadata, deterministic tests, and the release manifest check | +| unit | `pnpm test:unit` | Runs the local unit suite | +| contract | `pnpm test:contract` | Runs the local contract suite | +| coordination | `pnpm test:coordination` | Runs durable effect admission and serialization checks | +| rpc | `pnpm test:rpc` | Runs the JSONL protocol suite | +| rpc (packed) | `pnpm test:rpc:packed` | Runs the packed JSONL protocol suite | +| virtual-terminal | `pnpm test:virtual-terminal` | Runs virtual-terminal state, keyboard, and layout checks | +| pty | `pnpm test:pty` | Runs packed real-terminal checks | +| storage | `pnpm test:storage` | Runs encrypted SQLite journal, projection, and retention checks | +| crash | `pnpm test:crash` | Runs forced-kill, restore-recovery, and two-process admission checks | +| security | `pnpm test:security` | Runs redaction, credential-boundary, and dependency-boundary checks | +| performance | `pnpm test:performance` | Runs the reducer, coordination, and storage performance checks | +| live | `pnpm test:live` | Unavailable without protected live credentials and evidence | +| live-bridge | `pnpm test:live:bridge` | Runs the opt-in packed CLI Bridge and runner flow with `BRAID_LIVE_BRIDGE=1` | +| live-tangle | `pnpm test:live:tangle` | Unavailable without protected live credentials and evidence | +| live-supervisor | `pnpm test:live:supervisor` | Unavailable without protected live credentials and evidence | +| live-analysis | `pnpm test:live:analysis` | Unavailable without protected live credentials and evidence | +| eval | `pnpm test:eval` | Runs pilot, judge calibration, and semantic release cases against `BRAID_EVAL_MODEL` | +| install | `pnpm test:install` | Runs packed install, storage, and keyboard/RPC proof | +| capture | `pnpm test:capture` | Captures the baseline terminal artifacts from the packed binary | +| visual | `pnpm capture:visual` | Captures the required W6 state artifacts from the packed binary | +| release | `pnpm check:release` | Checks the release manifest and evidence set | +| verify:release | `pnpm verify:release` | Runs only in an isolated clean tracked checkout with external signing key and complete evidence | + The complete implementation goal remains every required check in [the delivery plan](docs/09-delivery-plan.md) and [the verification plan](docs/08-verification.md), including real local and cloud runs. ## The central decision @@ -68,6 +103,7 @@ That boundary gives Braid a polished interface quickly without creating a second | [Renderer decision](docs/decisions/001-pi-tui-renderer.md) | Why Braid depends on Pi TUI instead of cloning a whole app | | [Runtime boundary decision](docs/decisions/002-runtime-boundary.md) | Why execution and interaction control stay upstream | | [Persistence decision](docs/decisions/003-local-event-journal.md) | What Braid stores and which system remains authoritative | +| [Encrypted storage decision](docs/decisions/005-encrypted-sqlite-and-credential-boundaries.md) | SQLite cipher, content keys, credential facilities, and headless key boundaries | The ranked source-reuse hypothesis is recorded in [`.agent/hypotheses/2026-08-01-terminal-ui-base.md`](.agent/hypotheses/2026-08-01-terminal-ui-base.md). diff --git a/THIRD_PARTY_LICENSES.json b/THIRD_PARTY_LICENSES.json index 88c719e..66b8efa 100644 --- a/THIRD_PARTY_LICENSES.json +++ b/THIRD_PARTY_LICENSES.json @@ -13,6 +13,14 @@ "license": "MIT", "name": "@hono/node-server" }, + { + "license": "MIT", + "name": "@napi-rs/keyring-linux-x64-gnu" + }, + { + "license": "MIT", + "name": "@napi-rs/keyring" + }, { "license": "MIT", "name": "@noble/hashes" @@ -37,10 +45,22 @@ "license": "Unknown", "name": "@tangle-network/agent-profile-materialize" }, + { + "license": "MIT", + "name": "@tangle-network/agent-provider-cli-bridge" + }, + { + "license": "MIT", + "name": "@tangle-network/agent-provider-tangle" + }, { "license": "MIT", "name": "@tangle-network/agent-runtime" }, + { + "license": "MIT", + "name": "@tangle-network/agent-trace-contract" + }, { "license": "MIT", "name": "@tangle-network/sandbox" @@ -69,10 +89,30 @@ "license": "Apache-2.0", "name": "bare-url" }, + { + "license": "MIT", + "name": "base64-js" + }, + { + "license": "MIT", + "name": "better-sqlite3-multiple-ciphers" + }, { "license": "CC0-1.0", "name": "binary-search" }, + { + "license": "MIT", + "name": "bindings" + }, + { + "license": "MIT", + "name": "bl" + }, + { + "license": "MIT", + "name": "buffer" + }, { "license": "MIT", "name": "chalk" @@ -81,10 +121,34 @@ "license": "MIT", "name": "cheminfo-types" }, + { + "license": "ISC", + "name": "chownr" + }, + { + "license": "MIT", + "name": "decompress-response" + }, + { + "license": "MIT", + "name": "deep-extend" + }, + { + "license": "Apache-2.0", + "name": "detect-libc" + }, + { + "license": "MIT", + "name": "end-of-stream" + }, { "license": "Apache-2.0", "name": "events-universal" }, + { + "license": "(MIT OR WTFPL)", + "name": "expand-template" + }, { "license": "MIT", "name": "fast-fifo" @@ -93,10 +157,22 @@ "license": "MIT", "name": "fft.js" }, + { + "license": "MIT", + "name": "file-uri-to-path" + }, + { + "license": "MIT", + "name": "fs-constants" + }, { "license": "MIT", "name": "get-east-asian-width" }, + { + "license": "MIT", + "name": "github-from-package" + }, { "license": "ISC", "name": "graceful-fs" @@ -105,6 +181,18 @@ "license": "MIT", "name": "hono" }, + { + "license": "BSD-3-Clause", + "name": "ieee754" + }, + { + "license": "ISC", + "name": "inherits" + }, + { + "license": "ISC", + "name": "ini" + }, { "license": "MIT", "name": "is-any-array" @@ -117,6 +205,18 @@ "license": "MIT", "name": "marked" }, + { + "license": "MIT", + "name": "mimic-response" + }, + { + "license": "MIT", + "name": "minimist" + }, + { + "license": "MIT", + "name": "mkdirp-classic" + }, { "license": "MIT", "name": "ml-array-max" @@ -141,26 +241,70 @@ "license": "MIT", "name": "ml-xsadd" }, + { + "license": "MIT", + "name": "napi-build-utils" + }, + { + "license": "MIT", + "name": "node-abi" + }, + { + "license": "ISC", + "name": "once" + }, { "license": "MIT", "name": "openapi3-ts" }, + { + "license": "MIT", + "name": "prebuild-install" + }, { "license": "MIT", "name": "proper-lockfile" }, + { + "license": "MIT", + "name": "pump" + }, + { + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "name": "rc" + }, { "license": "MIT", "name": "re2js" }, + { + "license": "MIT", + "name": "readable-stream" + }, { "license": "MIT", "name": "retry" }, + { + "license": "MIT", + "name": "safe-buffer" + }, + { + "license": "ISC", + "name": "semver" + }, { "license": "ISC", "name": "signal-exit" }, + { + "license": "MIT", + "name": "simple-concat" + }, + { + "license": "MIT", + "name": "simple-get" + }, { "license": "CC-BY-3.0", "name": "spdx-exceptions" @@ -177,6 +321,18 @@ "license": "MIT", "name": "streamx" }, + { + "license": "MIT", + "name": "string_decoder" + }, + { + "license": "MIT", + "name": "strip-json-comments" + }, + { + "license": "MIT", + "name": "tar-fs" + }, { "license": "MIT", "name": "tar-stream" @@ -189,6 +345,22 @@ "license": "Apache-2.0", "name": "text-decoder" }, + { + "license": "Apache-2.0", + "name": "tunnel-agent" + }, + { + "license": "MIT", + "name": "undici" + }, + { + "license": "MIT", + "name": "util-deprecate" + }, + { + "license": "ISC", + "name": "wrappy" + }, { "license": "ISC", "name": "yaml" diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 89bec7c..e458326 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -6,11 +6,13 @@ The implementation is expected to depend on and adapt behavior from the followin | Project | Planned use | Source inspected | License | | --- | --- | --- | --- | -| [`@earendil-works/pi-tui`](https://github.com/earendil-works/pi/tree/master/packages/tui) | Runtime dependency for rendering, layout, input, and overlays; planned test-only adaptation of `test/virtual-terminal.ts` if no public testing export exists | `earendil-works/pi@a6f7317dfca61e357aee65faafe012a1be6c3734` | MIT | +| [`@earendil-works/pi-tui`](https://github.com/earendil-works/pi/tree/master/packages/tui) | Runtime dependency for rendering, layout, input, and overlays; test-only adaptation of `test/virtual-terminal.ts` | `earendil-works/pi@a6f7317dfca61e357aee65faafe012a1be6c3734` | MIT | | [Pi coding agent](https://github.com/earendil-works/pi/tree/master/packages/coding-agent) | Behavioral reference and possible selective adaptation of selectors, transcript components, and session-tree presentation | `earendil-works/pi@a6f7317dfca61e357aee65faafe012a1be6c3734` | MIT | | [Kimi Code](https://github.com/MoonshotAI/kimi-code/tree/main/apps/kimi-code/src/tui) | Behavioral reference and possible selective adaptation of approval, question, queue, and modal coordination components | `MoonshotAI/kimi-code@e22479a62eed9c3b78a67b313f4332c2c0ba9670` | MIT | | [OpenCode](https://github.com/anomalyco/opencode) | Architecture and interaction reference only | `anomalyco/opencode@32f278b48f1a495611165d8a9f1ace0b512933e2` | MIT | | [Hermes Agent](https://github.com/NousResearch/hermes-agent/tree/main/ui-tui) | Client/runtime separation and workflow reference only | `NousResearch/hermes-agent@f88ed6c71768cdc7ea3bfa8cf62d16654792fd2a` | MIT | +| [`better-sqlite3-multiple-ciphers`](https://github.com/m4heshd/better-sqlite3-multiple-ciphers) | Pinned production SQLite binding with SQLCipher-compatible encryption | `better-sqlite3-multiple-ciphers@12.11.1` | MIT | +| [`@napi-rs/keyring`](https://github.com/Brooooooklyn/keyring-node) | Native macOS Keychain, Linux Secret Service, and Windows Credential Manager access without passing secrets through command arguments | `@napi-rs/keyring@1.3.0` | MIT | ## Attribution rule @@ -24,4 +26,10 @@ The original copyright and license notice must remain with every substantial cop A dependency declared in `package.json` does not require a file-level source header, but its license must remain represented in the generated release license inventory. +W5 adds the pinned `better-sqlite3-multiple-ciphers@12.11.1` and `@napi-rs/keyring@1.3.0` production dependencies and no new copied source. + +Its coordinator, storage port, credential port, and release scripts use Braid code and Node.js platform APIs around that binding. + +The generated `THIRD_PARTY_LICENSES.json` inventory must include both native bindings and all of their transitive production dependencies. + The release process must fail if a copied-source header has no matching notice row or if a production dependency has an unknown or disallowed license. diff --git a/artifacts/verification/live-core/latest.json b/artifacts/verification/live-core/latest.json new file mode 100644 index 0000000..1c1b5a5 --- /dev/null +++ b/artifacts/verification/live-core/latest.json @@ -0,0 +1,1224 @@ +{ + "claim": "packed-public-configure-dispatch-restart", + "command": "node scripts/live-core/run.mjs", + "endpoint": "http://127.0.0.1:3344", + "startedAt": "2026-08-04T03:19:03.912Z", + "targets": [ + { + "runner": "pi", + "model": "pi/tangle-router/glm-5.2", + "profile": { + "harness": "pi", + "model": "pi/tangle-router/glm-5.2" + }, + "connection": { + "kind": "cli-bridge", + "endpoint": "http://127.0.0.1:3344" + }, + "commands": [], + "status": "passed", + "configure": { + "status": "completed", + "profile": { + "description": "Advertised by the CLI Bridge model catalog at http://127.0.0.1:3344.", + "harness": "pi", + "model": { + "default": "pi/tangle-router/glm-5.2" + }, + "name": "CLI Bridge · pi · pi/tangle-router/glm-5.2" + }, + "connectionId": "connection-local-cli-bridge", + "configDigest": "2580ce0738c14667f97c6ff9cd013467d0c01a0ade2cda18c13e7742760519fc" + }, + "select": { + "profileRef": "profile-dd7185bc5a4f7182326ae290f2edb1be4aeac1f7c50f141c1202d77583ac2c32", + "connectionId": "connection-local-cli-bridge", + "profileAck": { + "profile": { + "id": "profile-dd7185bc5a4f7182326ae290f2edb1be4aeac1f7c50f141c1202d77583ac2c32", + "name": "CLI Bridge · pi · pi/tangle-router/glm-5.2", + "description": "Advertised by the CLI Bridge model catalog at http://127.0.0.1:3344.", + "tags": [], + "source": { + "kind": "inline", + "reference": "braid:active", + "label": "CLI Bridge · pi · pi/tangle-router/glm-5.2", + "writable": false, + "trusted": true + }, + "digest": "sha256:3227e1b3f58ac466dbbfe10eccc5b43366d05e2afa74cedc6e94f958a64116ae", + "runner": "pi", + "model": "pi/tangle-router/glm-5.2", + "tools": [], + "skills": [], + "connections": [] + }, + "reason": "command-line", + "revision": 5, + "replayed": false + }, + "connectionAck": { + "connection": { + "id": "connection-local-cli-bridge", + "name": "Local CLI Bridge", + "kind": "cli-bridge", + "endpoint": "http://127.0.0.1:3344", + "credentialConfigured": false, + "health": { + "checkedAt": "2026-08-04T03:19:10.398Z", + "status": "healthy" + }, + "capabilityHints": [ + "stream", + "usage" + ], + "ready": false + }, + "revision": 8, + "replayed": false + } + }, + "dispatch": { + "conversationId": "conv-1", + "branchId": "branch-1", + "nonce": "BraidCoreNonce-PI-7Q4M", + "turns": [ + { + "label": "turn 1", + "operationId": "op-live-core-send-1", + "runId": "run-be321ee6-62ee-497d-bd7f-33b3fefe6463", + "prompt": "Remember this exact nonce for our next turn and do not print it until asked: BraidCoreNonce-PI-7Q4M. Reply only ACK_NONCE_STORED.", + "terminalState": { + "id": "run-be321ee6-62ee-497d-bd7f-33b3fefe6463", + "turnId": "turn-2589c42e-470b-41bb-a61f-f471cab6fa0d", + "operationId": "op-live-core-send-1", + "status": "completed", + "inputTokens": 16924, + "outputTokens": 111, + "costUsd": 0, + "model": "pi/tangle-router/glm-5.2", + "completeness": "unavailable", + "providerSessionId": "session-braid-run-be321ee6-62ee-497d-bd7f-33b3fefe6463", + "contentBytes": 54 + }, + "output": "ACK_NONCE_STORED", + "transcript": [ + { + "role": "user", + "text": "Remember this exact nonce for our next turn and do not print it until asked: BraidCoreNonce-PI-7Q4M. Reply only ACK_NONCE_STORED.", + "status": "complete" + }, + { + "role": "assistant", + "text": "ACK_NONCE_STORED", + "status": "complete" + } + ], + "eventTypes": [ + "draft.changed", + "run.requested", + "effect.upserted", + "run.tool.call", + "run.usage", + "run.text.delta", + "run.finished" + ], + "elapsedMs": 23081, + "providerSessionId": "session-braid-run-be321ee6-62ee-497d-bd7f-33b3fefe6463", + "continuation": true, + "receipt": { + "profileDigest": "3227e1b3f58ac466dbbfe10eccc5b43366d05e2afa74cedc6e94f958a64116ae", + "requestDigest": "216ef8f1c9676e1864acbf5fa77500ebf5f36c06591e8c5683bd5b91fcb95838", + "capabilitiesDigest": "fecf56f909d35629e7107c28983f573b88df34e7c4afe0395fdaec172e7778b8", + "admittedAt": "2026-08-04T03:19:25.760Z", + "provider": "agent-runtime", + "providerSessionId": "session-braid-run-be321ee6-62ee-497d-bd7f-33b3fefe6463", + "environmentId": "env-braid-run-be321ee6-62ee-497d-bd7f-33b3fefe6463", + "admissionStatus": "admitted", + "digest": "968fe1d5db5ad9766f3c1cab74ee37f089627e0946bda9f8347e4b17711dbece", + "materializationDigest": "c4675364adf71a192691b90deae5640bfc85be69a6420d76243dc8a5f58d4963" + } + }, + { + "label": "turn 2", + "operationId": "op-live-core-send-2", + "runId": "run-598a526f-a071-4384-9303-89b39bcfdaf9", + "prompt": "What exact nonce did I ask you to remember? Reply with only the nonce.", + "terminalState": { + "id": "run-598a526f-a071-4384-9303-89b39bcfdaf9", + "turnId": "turn-397d112f-37ae-47f1-883d-8bfc0c2d2ebf", + "operationId": "op-live-core-send-2", + "status": "completed", + "inputTokens": 8942, + "outputTokens": 13, + "costUsd": 0, + "model": "pi/tangle-router/glm-5.2", + "completeness": "unavailable", + "providerSessionId": "session-braid-run-be321ee6-62ee-497d-bd7f-33b3fefe6463", + "contentBytes": 64 + }, + "output": "BraidCoreNonce-PI-7Q4M", + "transcript": [ + { + "role": "user", + "text": "What exact nonce did I ask you to remember? Reply with only the nonce.", + "status": "complete" + }, + { + "role": "assistant", + "text": "BraidCoreNonce-PI-7Q4M", + "status": "complete" + } + ], + "eventTypes": [ + "draft.changed", + "run.requested", + "effect.upserted", + "run.text.delta", + "run.usage", + "run.finished" + ], + "elapsedMs": 18445, + "providerSessionId": "session-braid-run-be321ee6-62ee-497d-bd7f-33b3fefe6463", + "continuation": true, + "receipt": { + "profileDigest": "3227e1b3f58ac466dbbfe10eccc5b43366d05e2afa74cedc6e94f958a64116ae", + "requestDigest": "7183e57749c67bc543d5e6218a3361e5c757b39e7f8e1ec24fc30f78b7a3bd20", + "capabilitiesDigest": "fecf56f909d35629e7107c28983f573b88df34e7c4afe0395fdaec172e7778b8", + "admittedAt": "2026-08-04T03:19:48.733Z", + "provider": "agent-runtime", + "providerSessionId": "session-braid-run-be321ee6-62ee-497d-bd7f-33b3fefe6463", + "environmentId": "env-braid-run-598a526f-a071-4384-9303-89b39bcfdaf9", + "admissionStatus": "admitted", + "digest": "9cef1b8a5f9d04a506e8d7113396f80f5b5c81ea12db48200ac7fa3e580424e1", + "materializationDigest": "0725441ec1880f37f7cdfa73fb0235d7ea236657b118dbd7cd9f23389d12b6bc" + } + } + ], + "sameProviderSessionId": true + }, + "restart": { + "preRestartTranscript": [ + { + "id": "message-2cba3a3a-b420-4a30-b602-022cfd3339e0", + "role": "user", + "text": "Remember this exact nonce for our next turn and do not print it until asked: BraidCoreNonce-PI-7Q4M. Reply only ACK_NONCE_STORED.", + "status": "complete", + "runId": "run-be321ee6-62ee-497d-bd7f-33b3fefe6463", + "parts": [ + { + "id": "part-message-2cba3a3a-b420-4a30-b602-022cfd3339e0", + "kind": "text", + "text": "Remember this exact nonce for our next turn and do not print it until asked: BraidCoreNonce-PI-7Q4M. Reply only ACK_NONCE_STORED." + } + ] + }, + { + "id": "message-7939ff0a-26ae-4cfe-b2a3-608ea5fd06e8", + "role": "assistant", + "text": "ACK_NONCE_STORED", + "status": "complete", + "runId": "run-be321ee6-62ee-497d-bd7f-33b3fefe6463", + "parts": [ + { + "id": "call_d9c6942687064369a85e89bf", + "kind": "tool", + "text": "", + "sourceEventId": "run-be321ee6-62ee-497d-bd7f-33b3fefe6463:runtime:1", + "toolName": "memory_write", + "callId": "call_d9c6942687064369a85e89bf", + "input": {} + }, + { + "id": "run-be321ee6-62ee-497d-bd7f-33b3fefe6463:text", + "kind": "text", + "text": "ACK_NONCE_STORED", + "sourceEventId": "run-be321ee6-62ee-497d-bd7f-33b3fefe6463:runtime:7" + } + ] + }, + { + "id": "message-b8d73021-b09c-4e8a-8f9f-c2112f2dbfb0", + "role": "user", + "text": "What exact nonce did I ask you to remember? Reply with only the nonce.", + "status": "complete", + "runId": "run-598a526f-a071-4384-9303-89b39bcfdaf9", + "parts": [ + { + "id": "part-message-b8d73021-b09c-4e8a-8f9f-c2112f2dbfb0", + "kind": "text", + "text": "What exact nonce did I ask you to remember? Reply with only the nonce." + } + ] + }, + { + "id": "message-9e02dc7d-0e25-4452-8433-02cf656c1fd9", + "role": "assistant", + "text": "BraidCoreNonce-PI-7Q4M", + "status": "complete", + "runId": "run-598a526f-a071-4384-9303-89b39bcfdaf9", + "parts": [ + { + "id": "run-598a526f-a071-4384-9303-89b39bcfdaf9:text", + "kind": "text", + "text": "BraidCoreNonce-PI-7Q4M", + "sourceEventId": "run-598a526f-a071-4384-9303-89b39bcfdaf9:runtime:11" + } + ] + } + ], + "state": [ + { + "id": "run-be321ee6-62ee-497d-bd7f-33b3fefe6463", + "turnId": "turn-2589c42e-470b-41bb-a61f-f471cab6fa0d", + "operationId": "op-live-core-send-1", + "status": "completed", + "inputTokens": 16924, + "outputTokens": 111, + "costUsd": 0, + "model": "pi/tangle-router/glm-5.2", + "completeness": "unavailable", + "providerSessionId": "session-braid-run-be321ee6-62ee-497d-bd7f-33b3fefe6463", + "contentBytes": 54 + }, + { + "id": "run-598a526f-a071-4384-9303-89b39bcfdaf9", + "turnId": "turn-397d112f-37ae-47f1-883d-8bfc0c2d2ebf", + "operationId": "op-live-core-send-2", + "status": "completed", + "inputTokens": 8942, + "outputTokens": 13, + "costUsd": 0, + "model": "pi/tangle-router/glm-5.2", + "completeness": "unavailable", + "providerSessionId": "session-braid-run-be321ee6-62ee-497d-bd7f-33b3fefe6463", + "contentBytes": 64 + } + ], + "transcript": [ + { + "id": "message-2cba3a3a-b420-4a30-b602-022cfd3339e0", + "role": "user", + "text": "Remember this exact nonce for our next turn and do not print it until asked: BraidCoreNonce-PI-7Q4M. Reply only ACK_NONCE_STORED.", + "status": "complete", + "runId": "run-be321ee6-62ee-497d-bd7f-33b3fefe6463", + "parts": [ + { + "id": "part-message-2cba3a3a-b420-4a30-b602-022cfd3339e0", + "kind": "text", + "text": "Remember this exact nonce for our next turn and do not print it until asked: BraidCoreNonce-PI-7Q4M. Reply only ACK_NONCE_STORED." + } + ] + }, + { + "id": "message-7939ff0a-26ae-4cfe-b2a3-608ea5fd06e8", + "role": "assistant", + "text": "ACK_NONCE_STORED", + "status": "complete", + "runId": "run-be321ee6-62ee-497d-bd7f-33b3fefe6463", + "parts": [ + { + "id": "call_d9c6942687064369a85e89bf", + "kind": "tool", + "text": "", + "sourceEventId": "run-be321ee6-62ee-497d-bd7f-33b3fefe6463:runtime:1", + "toolName": "memory_write", + "callId": "call_d9c6942687064369a85e89bf", + "input": {} + }, + { + "id": "run-be321ee6-62ee-497d-bd7f-33b3fefe6463:text", + "kind": "text", + "text": "ACK_NONCE_STORED", + "sourceEventId": "run-be321ee6-62ee-497d-bd7f-33b3fefe6463:runtime:7" + } + ] + }, + { + "id": "message-b8d73021-b09c-4e8a-8f9f-c2112f2dbfb0", + "role": "user", + "text": "What exact nonce did I ask you to remember? Reply with only the nonce.", + "status": "complete", + "runId": "run-598a526f-a071-4384-9303-89b39bcfdaf9", + "parts": [ + { + "id": "part-message-b8d73021-b09c-4e8a-8f9f-c2112f2dbfb0", + "kind": "text", + "text": "What exact nonce did I ask you to remember? Reply with only the nonce." + } + ] + }, + { + "id": "message-9e02dc7d-0e25-4452-8433-02cf656c1fd9", + "role": "assistant", + "text": "BraidCoreNonce-PI-7Q4M", + "status": "complete", + "runId": "run-598a526f-a071-4384-9303-89b39bcfdaf9", + "parts": [ + { + "id": "run-598a526f-a071-4384-9303-89b39bcfdaf9:text", + "kind": "text", + "text": "BraidCoreNonce-PI-7Q4M", + "sourceEventId": "run-598a526f-a071-4384-9303-89b39bcfdaf9:runtime:11" + } + ] + } + ], + "reconnect": { + "version": 1, + "type": "error", + "requestId": "live-core-5", + "code": "CAPABILITY_UNAVAILABLE", + "message": "The selected execution path does not report replay with a stable cursor", + "retryable": false + }, + "safeFollowUp": { + "status": "resumed", + "operationId": "op-live-core-send-after-restart", + "runId": "run-5212eb7b-9ab2-4311-9aed-f090b7c71517", + "output": "LIVE_CORE_FOLLOWUP_OK", + "terminalState": { + "id": "run-5212eb7b-9ab2-4311-9aed-f090b7c71517", + "turnId": "turn-6252acd2-3b67-4205-9355-ee801b630d9d", + "operationId": "op-live-core-send-after-restart", + "status": "completed", + "inputTokens": 8920, + "outputTokens": 8, + "costUsd": 0, + "model": "pi/tangle-router/glm-5.2", + "completeness": "unavailable", + "providerSessionId": "session-braid-run-be321ee6-62ee-497d-bd7f-33b3fefe6463", + "contentBytes": 62 + }, + "eventTypes": [ + "draft.changed", + "run.requested", + "effect.upserted", + "run.text.delta", + "run.usage", + "run.finished" + ], + "elapsedMs": 19569, + "providerSessionId": "session-braid-run-be321ee6-62ee-497d-bd7f-33b3fefe6463", + "receipt": { + "profileDigest": "3227e1b3f58ac466dbbfe10eccc5b43366d05e2afa74cedc6e94f958a64116ae", + "requestDigest": "e9240b96d6f0d713212798516c935f0f8c21fb8c98a12ecf6e9ce193d269bf9f", + "capabilitiesDigest": "fecf56f909d35629e7107c28983f573b88df34e7c4afe0395fdaec172e7778b8", + "admittedAt": "2026-08-04T03:20:07.817Z", + "provider": "agent-runtime", + "providerSessionId": "session-braid-run-be321ee6-62ee-497d-bd7f-33b3fefe6463", + "environmentId": "env-braid-run-5212eb7b-9ab2-4311-9aed-f090b7c71517", + "admissionStatus": "admitted", + "digest": "6c4e6e75f70bc027ed043c3d76cd9d0ac1a0ffef105d858df287de43af3fbd67", + "materializationDigest": "0aec37ab3dd8d523e02e48973c8612d770a8b374135c8edbbb4321c682671a89" + } + }, + "commands": [ + { + "version": 1, + "requestId": "live-core-1", + "command": "initialize", + "params": { + "workspace": "/tmp/braid-live-core-lCBvqG/target-pi-FBlehk/workspace", + "subscribe": true + } + }, + { + "version": 1, + "requestId": "live-core-2", + "command": "get_state", + "params": { + "projection": "full" + } + }, + { + "version": 1, + "requestId": "live-core-3", + "command": "get_details", + "params": { + "entityType": "run", + "entityId": "run-be321ee6-62ee-497d-bd7f-33b3fefe6463" + } + }, + { + "version": 1, + "requestId": "live-core-4", + "command": "get_details", + "params": { + "entityType": "run", + "entityId": "run-598a526f-a071-4384-9303-89b39bcfdaf9" + } + }, + { + "version": 1, + "requestId": "live-core-5", + "command": "reconnect", + "params": { + "runId": "run-598a526f-a071-4384-9303-89b39bcfdaf9" + }, + "operationId": "op-live-core-reconnect-1" + }, + { + "version": 1, + "requestId": "live-core-6", + "command": "send", + "params": { + "conversationId": "conv-1", + "branchId": "branch-1", + "text": "Reply with exactly LIVE_CORE_FOLLOWUP_OK." + }, + "operationId": "op-live-core-send-after-restart" + }, + { + "version": 1, + "requestId": "live-core-7", + "command": "get_details", + "params": { + "entityType": "run", + "entityId": "run-5212eb7b-9ab2-4311-9aed-f090b7c71517" + } + }, + { + "version": 1, + "requestId": "live-core-8", + "command": "get_state", + "params": { + "projection": "full" + } + }, + { + "version": 1, + "requestId": "live-core-9", + "command": "shutdown", + "params": {}, + "operationId": "op-live-core-shutdown-1785813627289" + } + ], + "postRestartSendCount": 1 + }, + "elapsedMs": 77828 + }, + { + "runner": "codex", + "model": "codex/default", + "profile": { + "harness": "codex", + "model": "codex/default" + }, + "connection": { + "kind": "cli-bridge", + "endpoint": "http://127.0.0.1:3344" + }, + "commands": [], + "status": "passed", + "configure": { + "status": "completed", + "profile": { + "description": "Advertised by the CLI Bridge model catalog at http://127.0.0.1:3344.", + "harness": "codex", + "model": { + "default": "codex/default" + }, + "name": "CLI Bridge · codex · codex/default" + }, + "connectionId": "connection-local-cli-bridge", + "configDigest": "d4777a701f8d20c63058a5cfd38bd31d8668f15d305af706b6c325eef4b4399f" + }, + "select": { + "profileRef": "profile-9a8c8e8682dcca6be7a72c6b55dc0e734f7250186f2a169b8eb66e26f28bef23", + "connectionId": "connection-local-cli-bridge", + "profileAck": { + "profile": { + "id": "profile-9a8c8e8682dcca6be7a72c6b55dc0e734f7250186f2a169b8eb66e26f28bef23", + "name": "CLI Bridge · codex · codex/default", + "description": "Advertised by the CLI Bridge model catalog at http://127.0.0.1:3344.", + "tags": [], + "source": { + "kind": "inline", + "reference": "braid:active", + "label": "CLI Bridge · codex · codex/default", + "writable": false, + "trusted": true + }, + "digest": "sha256:f1dfe7e681d971d797f4d934e3ab173e27928fbef688536918638fbd92b801a3", + "runner": "codex", + "model": "codex/default", + "tools": [], + "skills": [], + "connections": [] + }, + "reason": "command-line", + "revision": 5, + "replayed": false + }, + "connectionAck": { + "connection": { + "id": "connection-local-cli-bridge", + "name": "Local CLI Bridge", + "kind": "cli-bridge", + "endpoint": "http://127.0.0.1:3344", + "credentialConfigured": false, + "health": { + "checkedAt": "2026-08-04T03:20:28.268Z", + "status": "healthy" + }, + "capabilityHints": [ + "stream", + "usage" + ], + "ready": false + }, + "revision": 8, + "replayed": false + } + }, + "dispatch": { + "conversationId": "conv-1", + "branchId": "branch-1", + "nonce": "BraidCoreNonce-CODEX-7Q4M", + "turns": [ + { + "label": "turn 1", + "operationId": "op-live-core-send-1", + "runId": "run-e1f8173c-3e39-444f-8dbb-eb979c001f04", + "prompt": "Remember this exact nonce for our next turn and do not print it until asked: BraidCoreNonce-CODEX-7Q4M. Reply only ACK_NONCE_STORED.", + "terminalState": { + "id": "run-e1f8173c-3e39-444f-8dbb-eb979c001f04", + "turnId": "turn-36a5edcb-903b-4bfa-ace4-ee59f910d131", + "operationId": "op-live-core-send-1", + "status": "completed", + "inputTokens": 21278, + "outputTokens": 39, + "costUsd": 0, + "model": "codex/default", + "completeness": "unavailable", + "providerSessionId": "session-braid-run-e1f8173c-3e39-444f-8dbb-eb979c001f04", + "contentBytes": 52 + }, + "output": "ACK_NONCE_STORED", + "transcript": [ + { + "role": "user", + "text": "Remember this exact nonce for our next turn and do not print it until asked: BraidCoreNonce-CODEX-7Q4M. Reply only ACK_NONCE_STORED.", + "status": "complete" + }, + { + "role": "assistant", + "text": "ACK_NONCE_STORED", + "status": "complete" + } + ], + "eventTypes": [ + "draft.changed", + "run.requested", + "effect.upserted", + "run.text.delta", + "run.usage", + "run.finished" + ], + "elapsedMs": 8279, + "providerSessionId": "session-braid-run-e1f8173c-3e39-444f-8dbb-eb979c001f04", + "continuation": true, + "receipt": { + "profileDigest": "f1dfe7e681d971d797f4d934e3ab173e27928fbef688536918638fbd92b801a3", + "requestDigest": "f112c863bb018da88375c15a91c1e175eea290966f79574cdb6e3a6a9ca68fdb", + "capabilitiesDigest": "fecf56f909d35629e7107c28983f573b88df34e7c4afe0395fdaec172e7778b8", + "admittedAt": "2026-08-04T03:20:33.873Z", + "provider": "agent-runtime", + "providerSessionId": "session-braid-run-e1f8173c-3e39-444f-8dbb-eb979c001f04", + "environmentId": "env-braid-run-e1f8173c-3e39-444f-8dbb-eb979c001f04", + "admissionStatus": "admitted", + "digest": "0b21f4e65faa41c419b6096f604629d854d895e3c0d35a195c6bcd6824a6a74a", + "materializationDigest": "adf710b42bdb7f69f117f3b5e366e2b2a80e5b0db86b8b4e43be005491cdd94a" + } + }, + { + "label": "turn 2", + "operationId": "op-live-core-send-2", + "runId": "run-76029991-2d08-47ce-a7d6-87acfcb1156c", + "prompt": "What exact nonce did I ask you to remember? Reply with only the nonce.", + "terminalState": { + "id": "run-76029991-2d08-47ce-a7d6-87acfcb1156c", + "turnId": "turn-9e99a93b-da53-43bd-847e-7ec93c3b3512", + "operationId": "op-live-core-send-2", + "status": "completed", + "inputTokens": 42617, + "outputTokens": 68, + "costUsd": 0, + "model": "codex/default", + "completeness": "unavailable", + "providerSessionId": "session-braid-run-e1f8173c-3e39-444f-8dbb-eb979c001f04", + "contentBytes": 70 + }, + "output": "BraidCoreNonce-CODEX-7Q4M", + "transcript": [ + { + "role": "user", + "text": "What exact nonce did I ask you to remember? Reply with only the nonce.", + "status": "complete" + }, + { + "role": "assistant", + "text": "BraidCoreNonce-CODEX-7Q4M", + "status": "complete" + } + ], + "eventTypes": [ + "draft.changed", + "run.requested", + "effect.upserted", + "run.text.delta", + "run.usage", + "run.finished" + ], + "elapsedMs": 4042, + "providerSessionId": "session-braid-run-e1f8173c-3e39-444f-8dbb-eb979c001f04", + "continuation": true, + "receipt": { + "profileDigest": "f1dfe7e681d971d797f4d934e3ab173e27928fbef688536918638fbd92b801a3", + "requestDigest": "d4241bd74ac467639f784e3d3af9e8f1279d005e97f7602796e8289b7d509949", + "capabilitiesDigest": "fecf56f909d35629e7107c28983f573b88df34e7c4afe0395fdaec172e7778b8", + "admittedAt": "2026-08-04T03:20:42.054Z", + "provider": "agent-runtime", + "providerSessionId": "session-braid-run-e1f8173c-3e39-444f-8dbb-eb979c001f04", + "environmentId": "env-braid-run-76029991-2d08-47ce-a7d6-87acfcb1156c", + "admissionStatus": "admitted", + "digest": "26fb02d2b206f9eb51ece4ce4a2b9e599e91cb7c2e75e07ed6f6ece5fd9fb472", + "materializationDigest": "5b46a130ef6244d8a410d5b5088ac6d6906886f9250e1bf2ad2d3e575b059ee0" + } + } + ], + "sameProviderSessionId": true + }, + "restart": { + "preRestartTranscript": [ + { + "id": "message-fb0b6128-11d9-41ed-ac4a-245c607a208b", + "role": "user", + "text": "Remember this exact nonce for our next turn and do not print it until asked: BraidCoreNonce-CODEX-7Q4M. Reply only ACK_NONCE_STORED.", + "status": "complete", + "runId": "run-e1f8173c-3e39-444f-8dbb-eb979c001f04", + "parts": [ + { + "id": "part-message-fb0b6128-11d9-41ed-ac4a-245c607a208b", + "kind": "text", + "text": "Remember this exact nonce for our next turn and do not print it until asked: BraidCoreNonce-CODEX-7Q4M. Reply only ACK_NONCE_STORED." + } + ] + }, + { + "id": "message-b5a26f71-3cb7-4992-b8bf-d25563f633c3", + "role": "assistant", + "text": "ACK_NONCE_STORED", + "status": "complete", + "runId": "run-e1f8173c-3e39-444f-8dbb-eb979c001f04", + "parts": [ + { + "id": "run-e1f8173c-3e39-444f-8dbb-eb979c001f04:text", + "kind": "text", + "text": "ACK_NONCE_STORED", + "sourceEventId": "run-e1f8173c-3e39-444f-8dbb-eb979c001f04:runtime:3" + } + ] + }, + { + "id": "message-f5c43155-854f-4878-8c96-c3474e6aa8b5", + "role": "user", + "text": "What exact nonce did I ask you to remember? Reply with only the nonce.", + "status": "complete", + "runId": "run-76029991-2d08-47ce-a7d6-87acfcb1156c", + "parts": [ + { + "id": "part-message-f5c43155-854f-4878-8c96-c3474e6aa8b5", + "kind": "text", + "text": "What exact nonce did I ask you to remember? Reply with only the nonce." + } + ] + }, + { + "id": "message-abfc2a72-71b5-406d-835a-7a6cea060356", + "role": "assistant", + "text": "BraidCoreNonce-CODEX-7Q4M", + "status": "complete", + "runId": "run-76029991-2d08-47ce-a7d6-87acfcb1156c", + "parts": [ + { + "id": "run-76029991-2d08-47ce-a7d6-87acfcb1156c:text", + "kind": "text", + "text": "BraidCoreNonce-CODEX-7Q4M", + "sourceEventId": "run-76029991-2d08-47ce-a7d6-87acfcb1156c:runtime:3" + } + ] + } + ], + "state": [ + { + "id": "run-e1f8173c-3e39-444f-8dbb-eb979c001f04", + "turnId": "turn-36a5edcb-903b-4bfa-ace4-ee59f910d131", + "operationId": "op-live-core-send-1", + "status": "completed", + "inputTokens": 21278, + "outputTokens": 39, + "costUsd": 0, + "model": "codex/default", + "completeness": "unavailable", + "providerSessionId": "session-braid-run-e1f8173c-3e39-444f-8dbb-eb979c001f04", + "contentBytes": 52 + }, + { + "id": "run-76029991-2d08-47ce-a7d6-87acfcb1156c", + "turnId": "turn-9e99a93b-da53-43bd-847e-7ec93c3b3512", + "operationId": "op-live-core-send-2", + "status": "completed", + "inputTokens": 42617, + "outputTokens": 68, + "costUsd": 0, + "model": "codex/default", + "completeness": "unavailable", + "providerSessionId": "session-braid-run-e1f8173c-3e39-444f-8dbb-eb979c001f04", + "contentBytes": 70 + } + ], + "transcript": [ + { + "id": "message-fb0b6128-11d9-41ed-ac4a-245c607a208b", + "role": "user", + "text": "Remember this exact nonce for our next turn and do not print it until asked: BraidCoreNonce-CODEX-7Q4M. Reply only ACK_NONCE_STORED.", + "status": "complete", + "runId": "run-e1f8173c-3e39-444f-8dbb-eb979c001f04", + "parts": [ + { + "id": "part-message-fb0b6128-11d9-41ed-ac4a-245c607a208b", + "kind": "text", + "text": "Remember this exact nonce for our next turn and do not print it until asked: BraidCoreNonce-CODEX-7Q4M. Reply only ACK_NONCE_STORED." + } + ] + }, + { + "id": "message-b5a26f71-3cb7-4992-b8bf-d25563f633c3", + "role": "assistant", + "text": "ACK_NONCE_STORED", + "status": "complete", + "runId": "run-e1f8173c-3e39-444f-8dbb-eb979c001f04", + "parts": [ + { + "id": "run-e1f8173c-3e39-444f-8dbb-eb979c001f04:text", + "kind": "text", + "text": "ACK_NONCE_STORED", + "sourceEventId": "run-e1f8173c-3e39-444f-8dbb-eb979c001f04:runtime:3" + } + ] + }, + { + "id": "message-f5c43155-854f-4878-8c96-c3474e6aa8b5", + "role": "user", + "text": "What exact nonce did I ask you to remember? Reply with only the nonce.", + "status": "complete", + "runId": "run-76029991-2d08-47ce-a7d6-87acfcb1156c", + "parts": [ + { + "id": "part-message-f5c43155-854f-4878-8c96-c3474e6aa8b5", + "kind": "text", + "text": "What exact nonce did I ask you to remember? Reply with only the nonce." + } + ] + }, + { + "id": "message-abfc2a72-71b5-406d-835a-7a6cea060356", + "role": "assistant", + "text": "BraidCoreNonce-CODEX-7Q4M", + "status": "complete", + "runId": "run-76029991-2d08-47ce-a7d6-87acfcb1156c", + "parts": [ + { + "id": "run-76029991-2d08-47ce-a7d6-87acfcb1156c:text", + "kind": "text", + "text": "BraidCoreNonce-CODEX-7Q4M", + "sourceEventId": "run-76029991-2d08-47ce-a7d6-87acfcb1156c:runtime:3" + } + ] + } + ], + "reconnect": { + "version": 1, + "type": "error", + "requestId": "live-core-5", + "code": "CAPABILITY_UNAVAILABLE", + "message": "The selected execution path does not report replay with a stable cursor", + "retryable": false + }, + "safeFollowUp": { + "status": "resumed", + "operationId": "op-live-core-send-after-restart", + "runId": "run-c7012edf-a505-4bfd-a96d-c7de77d3d152", + "output": "LIVE_CORE_FOLLOWUP_OK", + "terminalState": { + "id": "run-c7012edf-a505-4bfd-a96d-c7de77d3d152", + "turnId": "turn-a1440f35-4df2-4bbc-88e6-a4bae8b39f70", + "operationId": "op-live-core-send-after-restart", + "status": "completed", + "inputTokens": 64001, + "outputTokens": 78, + "costUsd": 0, + "model": "codex/default", + "completeness": "unavailable", + "providerSessionId": "session-braid-run-e1f8173c-3e39-444f-8dbb-eb979c001f04", + "contentBytes": 62 + }, + "eventTypes": [ + "draft.changed", + "run.requested", + "effect.upserted", + "run.text.delta", + "run.usage", + "run.finished" + ], + "elapsedMs": 4216, + "providerSessionId": "session-braid-run-e1f8173c-3e39-444f-8dbb-eb979c001f04", + "receipt": { + "profileDigest": "f1dfe7e681d971d797f4d934e3ab173e27928fbef688536918638fbd92b801a3", + "requestDigest": "c0cadafef42e44fa1387ba934e72b848e010c7ecf7ef833347b4ca9eeaef5c12", + "capabilitiesDigest": "fecf56f909d35629e7107c28983f573b88df34e7c4afe0395fdaec172e7778b8", + "admittedAt": "2026-08-04T03:20:46.787Z", + "provider": "agent-runtime", + "providerSessionId": "session-braid-run-e1f8173c-3e39-444f-8dbb-eb979c001f04", + "environmentId": "env-braid-run-c7012edf-a505-4bfd-a96d-c7de77d3d152", + "admissionStatus": "admitted", + "digest": "92c8e44c5dea39cca1d2a43b227e55995e2ffaab4ceac59af80c73620bdc905f", + "materializationDigest": "91f8405f135ddfd78fbce3c3b9d79a55a69a04484e17b3cb4aa569ed16253368" + } + }, + "commands": [ + { + "version": 1, + "requestId": "live-core-1", + "command": "initialize", + "params": { + "workspace": "/tmp/braid-live-core-lCBvqG/target-codex-lreZhn/workspace", + "subscribe": true + } + }, + { + "version": 1, + "requestId": "live-core-2", + "command": "get_state", + "params": { + "projection": "full" + } + }, + { + "version": 1, + "requestId": "live-core-3", + "command": "get_details", + "params": { + "entityType": "run", + "entityId": "run-e1f8173c-3e39-444f-8dbb-eb979c001f04" + } + }, + { + "version": 1, + "requestId": "live-core-4", + "command": "get_details", + "params": { + "entityType": "run", + "entityId": "run-76029991-2d08-47ce-a7d6-87acfcb1156c" + } + }, + { + "version": 1, + "requestId": "live-core-5", + "command": "reconnect", + "params": { + "runId": "run-76029991-2d08-47ce-a7d6-87acfcb1156c" + }, + "operationId": "op-live-core-reconnect-1" + }, + { + "version": 1, + "requestId": "live-core-6", + "command": "send", + "params": { + "conversationId": "conv-1", + "branchId": "branch-1", + "text": "Reply with exactly LIVE_CORE_FOLLOWUP_OK." + }, + "operationId": "op-live-core-send-after-restart" + }, + { + "version": 1, + "requestId": "live-core-7", + "command": "get_details", + "params": { + "entityType": "run", + "entityId": "run-c7012edf-a505-4bfd-a96d-c7de77d3d152" + } + }, + { + "version": 1, + "requestId": "live-core-8", + "command": "get_state", + "params": { + "projection": "full" + } + }, + { + "version": 1, + "requestId": "live-core-9", + "command": "shutdown", + "params": {}, + "operationId": "op-live-core-shutdown-1785813650878" + } + ], + "postRestartSendCount": 1 + }, + "elapsedMs": 23594 + } + ], + "bridge": { + "health": { + "status": "ok", + "cached_verdicts": false, + "oldest_probed_at": "2026-08-04T03:19:03.926Z", + "backends": [ + { + "name": "codex", + "state": "ready", + "version": "codex-cli 0.146.0", + "probed_at": "2026-08-04T03:19:03.926Z", + "cached": false + }, + { + "name": "pi", + "state": "ready", + "version": "0.83.0", + "probed_at": "2026-08-04T03:19:03.926Z", + "cached": false + } + ], + "admission": { + "active": 1, + "queued": 0, + "maxActive": 8, + "maxQueue": 16 + }, + "ts": "2026-08-04T03:19:03.926Z" + }, + "models": [ + { + "id": "codex/default", + "backend": "codex" + }, + { + "id": "codex/gpt-5-codex", + "backend": "codex" + }, + { + "id": "codex/gpt-5.4", + "backend": "codex" + }, + { + "id": "codex/gpt-5.5", + "backend": "codex" + }, + { + "id": "pi/deepseek/deepseek-v4-flash", + "backend": "pi" + }, + { + "id": "pi/deepseek/deepseek-v4-pro", + "backend": "pi" + }, + { + "id": "pi/kimi-for-coding-oauth/kimi-for-coding", + "backend": "pi" + }, + { + "id": "pi/moonshot/kimi-k2-thinking", + "backend": "pi" + }, + { + "id": "pi/moonshot/kimi-k2.5", + "backend": "pi" + }, + { + "id": "pi/openai-codex/gpt-5.3-codex-spark", + "backend": "pi" + }, + { + "id": "pi/openai-codex/gpt-5.4", + "backend": "pi" + }, + { + "id": "pi/openai-codex/gpt-5.4-mini", + "backend": "pi" + }, + { + "id": "pi/openai-codex/gpt-5.5", + "backend": "pi" + }, + { + "id": "pi/openai-codex/gpt-5.6-luna", + "backend": "pi" + }, + { + "id": "pi/openai-codex/gpt-5.6-sol", + "backend": "pi" + }, + { + "id": "pi/openai-codex/gpt-5.6-terra", + "backend": "pi" + }, + { + "id": "pi/tangle-router/claude-haiku-4-5-20251001", + "backend": "pi" + }, + { + "id": "pi/tangle-router/claude-opus-4-6", + "backend": "pi" + }, + { + "id": "pi/tangle-router/claude-sonnet-4-6", + "backend": "pi" + }, + { + "id": "pi/tangle-router/deepseek-v4-flash", + "backend": "pi" + }, + { + "id": "pi/tangle-router/deepseek-v4-pro", + "backend": "pi" + }, + { + "id": "pi/tangle-router/deepseek/deepseek-chat", + "backend": "pi" + }, + { + "id": "pi/tangle-router/gemini-2.5-flash-lite", + "backend": "pi" + }, + { + "id": "pi/tangle-router/gemini-3-pro-preview", + "backend": "pi" + }, + { + "id": "pi/tangle-router/glm-5.2", + "backend": "pi" + }, + { + "id": "pi/tangle-router/gpt-4o-mini", + "backend": "pi" + }, + { + "id": "pi/tangle-router/gpt-5-mini", + "backend": "pi" + }, + { + "id": "pi/tangle-router/gpt-5.4", + "backend": "pi" + }, + { + "id": "pi/tangle-router/gpt-5.4-mini", + "backend": "pi" + }, + { + "id": "pi/tangle-router/groq/llama-3.1-8b-instant", + "backend": "pi" + }, + { + "id": "pi/tangle-router/moonshotai/kimi-k2.5", + "backend": "pi" + }, + { + "id": "pi/tangle-router/openrouter/auto", + "backend": "pi" + }, + { + "id": "pi/zai-coding-paas/glm-5", + "backend": "pi" + }, + { + "id": "pi/zai-coding-paas/glm-5-code", + "backend": "pi" + }, + { + "id": "pi/zai-coding-paas/glm-5-turbo", + "backend": "pi" + }, + { + "id": "pi/zai-coding-paas/glm-5.1", + "backend": "pi" + }, + { + "id": "pi/zai-glm/glm-4.5", + "backend": "pi" + }, + { + "id": "pi/zai-glm/glm-4.5-air", + "backend": "pi" + }, + { + "id": "pi/zai-glm/glm-4.5-airx", + "backend": "pi" + }, + { + "id": "pi/zai-glm/glm-4.5-flash", + "backend": "pi" + }, + { + "id": "pi/zai-glm/glm-4.5-x", + "backend": "pi" + }, + { + "id": "pi/zai-glm/glm-4.6", + "backend": "pi" + }, + { + "id": "pi/zai-glm/glm-4.7", + "backend": "pi" + }, + { + "id": "pi/zai-glm/glm-4.7-flash", + "backend": "pi" + }, + { + "id": "pi/zai-glm/glm-4.7-flashx", + "backend": "pi" + }, + { + "id": "pi/zai-glm/glm-5", + "backend": "pi" + }, + { + "id": "pi/zai-glm/glm-5-code", + "backend": "pi" + }, + { + "id": "pi/zai-glm/glm-5-turbo", + "backend": "pi" + }, + { + "id": "pi/zai-glm/glm-5.1", + "backend": "pi" + } + ], + "inventory": [ + { + "runner": "pi", + "model": "pi/openai-codex/gpt-5.6-luna", + "status": "unavailable", + "code": "MODEL_NOT_CONFIGURED", + "detail": "Prior live probe returned HTTP 501 in 4.51s because the local Pi OpenAI refresh token was reused; the model is retained as unavailable evidence", + "observation": { + "httpStatus": 501, + "elapsedMs": 4510, + "source": "prior-live-probe" + } + } + ] + }, + "artifact": { + "tarball": "/tmp/braid-live-core-lCBvqG/package/tangle-network-braid-0.1.0.tgz", + "binary": "/tmp/braid-live-core-lCBvqG/install/node_modules/@tangle-network/braid/dist/bin/braid.js" + }, + "status": "passed" +} diff --git a/artifacts/verification/performance/smoke.json b/artifacts/verification/performance/smoke.json new file mode 100644 index 0000000..7a18694 --- /dev/null +++ b/artifacts/verification/performance/smoke.json @@ -0,0 +1,1608 @@ +{ + "schemaVersion": 1, + "kind": "braid-performance-proof", + "mode": "smoke", + "status": "passed", + "generatedAt": "2026-08-04T05:55:28.877Z", + "command": "pnpm run test:performance", + "thresholds": { + "PERF-01": { + "metric": "process-start-to-first-visible-frame", + "percentile": "p95", + "operator": "<=", + "value": 250 + }, + "PERF-02": { + "metric": "process-start-to-first-visible-frame", + "percentile": "p95", + "operator": "<=", + "value": 1000 + }, + "PERF-03": { + "metric": "idle-key-to-updated-frame", + "percentile": "p95", + "operator": "<=", + "value": 50 + }, + "PERF-04": { + "metric": "runtime-event-to-updated-frame", + "percentile": "p95", + "operator": "<=", + "value": 50 + }, + "PERF-05": { + "metric": "replay-reduce-10000-events", + "percentile": "p95", + "operator": "<=", + "value": 2000 + }, + "PERF-06": { + "metric": "open-100000-event-conversation", + "percentile": "p95", + "operator": "<=", + "value": 2000 + }, + "PERF-07": { + "metric": "resize-during-stream-frame", + "percentile": "p95", + "operator": "<=", + "value": 100 + }, + "PERF-08": { + "metric": "idle-cpu", + "percentile": "median", + "operator": "<=", + "value": 1 + }, + "PERF-09": { + "metric": "resident-memory-10000-event-conversation", + "percentile": "p95", + "operator": "<=", + "value": 150 + }, + "PERF-10": { + "metric": "database-growth-10000-events", + "percentile": "maximum", + "operator": "<=", + "value": 50 + } + }, + "releaseValidation": { + "passed": false, + "reason": "smoke report has no release validation" + }, + "provenance": { + "repository": "/home/drew/code/.worktrees/braid-integration", + "revision": "0efdd4e2bf3621aa77096dec191b44cfd723bfdc", + "packages": { + "braid": "0.1.0", + "piTui": "0.83.0", + "nodePty": "1.1.0", + "xtermHeadless": "6.0.0", + "sqlite": "12.11.1" + }, + "machine": "drew-GTR-Pro (AMD RYZEN AI MAX+ 395 w/ Radeon 8060S; x64; 32 logical CPUs)", + "os": "linux 7.0.0-28-generic", + "node": "v24.13.0", + "arch": "x64", + "packedPackageRoot": "/tmp/braid-install-giSGkm/node_modules/@tangle-network/braid", + "packedTarballSha256": "392fc9277f267753fa86e3965d58e07f2728b4c8f55b1913fb1071fe51e8d2b2", + "compileCache": { + "status": "ENABLED", + "modules": [ + "dist/startup/durable-runtime.js", + "dist/startup/terminal-runtime.js" + ], + "flushed": true + }, + "referenceDimensions": "40x12,80x24,120x40,200x60", + "seed": "fixed 2026-08-03T00:00:00.000Z; receipt-backed complete run; batch size 1000", + "externalResources": "none; local packed candidate, Pi terminal, and encrypted SQLite only" + }, + "limits": { + "repetitions": 1, + "noBillableResources": true, + "smoke": true + }, + "failures": [], + "measurements": [ + { + "kind": "distribution", + "name": "PERF-01", + "unit": "ms", + "n": 1, + "minimum": 228.81477099999756, + "median": 228.81477099999756, + "p90": 228.81477099999756, + "p95": 228.81477099999756, + "p99": 228.81477099999756, + "maximum": 228.81477099999756, + "target": { + "metric": "process-start-to-first-visible-frame", + "percentile": "p95", + "operator": "<=", + "value": 250 + }, + "environment": { + "machine": "drew-GTR-Pro (AMD RYZEN AI MAX+ 395 w/ Radeon 8060S; x64; 32 logical CPUs)", + "os": "linux 7.0.0-28-generic", + "node": "v24.13.0", + "terminal": "node-pty@1.1.0 + @xterm/headless@6.0.0 + @earendil-works/pi-tui@0.83.0", + "dimensions": "80x24", + "database": "warm-10000-event-encrypted-sqlite", + "eventCount": 10000 + }, + "state": "warm", + "repetitions": 1, + "rawSamples": [ + 228.81477099999756 + ], + "command": "pnpm run test:performance [PERF-01; smoke]", + "passed": true, + "observations": { + "readyFrames": { + "value": 1, + "reason": "Every sample waited for the seeded useful ready frame" + }, + "invalidCells": { + "value": 0, + "reason": "All parsed frames had valid terminal cells" + }, + "startupErrors": { + "value": 0, + "reason": "No startup or encrypted-storage error appeared before readiness" + }, + "compileCacheFrames": { + "value": 1, + "reason": "Every repeat launch used the primed Node compile cache" + }, + "compileCacheStatuses": [ + "ALREADY_ENABLED" + ], + "startupStages": [ + { + "spawnMs": 14.832, + "scriptMs": 14.02, + "compileCacheMs": 0.012, + "applicationImportsMs": 52.345, + "applicationOpenMs": 122.839, + "terminalImportsMs": 24.856, + "parallelStageMs": 122.839, + "overlapSavedMs": 24.856, + "terminalMs": 4.631, + "initializeMs": 0.179, + "firstRenderMs": 19.957 + } + ] + }, + "details": {}, + "failureReasons": [], + "provenance": { + "seed": "10000-event-encrypted-sqlite-headless-production", + "readyMarker": "Completed Braid performance conversation (10000 committed events)", + "frameReadiness": "unique seeded final-content marker plus braid shell and no startup error", + "compileCache": "primed exact packed startup modules; one shared cache across repeat launches", + "packedPackageRoot": "/tmp/braid-install-giSGkm/node_modules/@tangle-network/braid", + "packedTarballSha256": "392fc9277f267753fa86e3965d58e07f2728b4c8f55b1913fb1071fe51e8d2b2" + } + }, + { + "kind": "distribution", + "name": "PERF-02", + "unit": "ms", + "n": 1, + "minimum": 271.8521089999995, + "median": 271.8521089999995, + "p90": 271.8521089999995, + "p95": 271.8521089999995, + "p99": 271.8521089999995, + "maximum": 271.8521089999995, + "target": { + "metric": "process-start-to-first-visible-frame", + "percentile": "p95", + "operator": "<=", + "value": 1000 + }, + "environment": { + "machine": "drew-GTR-Pro (AMD RYZEN AI MAX+ 395 w/ Radeon 8060S; x64; 32 logical CPUs)", + "os": "linux 7.0.0-28-generic", + "node": "v24.13.0", + "terminal": "node-pty@1.1.0 + @xterm/headless@6.0.0 + @earendil-works/pi-tui@0.83.0", + "dimensions": "80x24", + "database": "cold-100000-event-encrypted-sqlite", + "eventCount": 100000 + }, + "state": "cold", + "repetitions": 1, + "rawSamples": [ + 271.8521089999995 + ], + "command": "pnpm run test:performance [PERF-02; smoke]", + "passed": true, + "observations": { + "readyFrames": { + "value": 1, + "reason": "Every sample waited for the seeded useful ready frame" + }, + "invalidCells": { + "value": 0, + "reason": "All parsed frames had valid terminal cells" + }, + "startupErrors": { + "value": 0, + "reason": "No startup or encrypted-storage error appeared before readiness" + }, + "compileCacheFrames": { + "value": 1, + "reason": "Every first launch started with its own empty Node compile-cache directory" + }, + "compileCacheStatuses": [ + "ALREADY_ENABLED" + ], + "startupStages": [ + { + "spawnMs": 14.237, + "scriptMs": 14.398, + "compileCacheMs": 0.014, + "applicationImportsMs": 82.229, + "applicationOpenMs": 137.687, + "terminalImportsMs": 31.171, + "parallelStageMs": 137.687, + "overlapSavedMs": 31.171, + "terminalMs": 4.793, + "initializeMs": 0.19, + "firstRenderMs": 18.304 + } + ] + }, + "details": {}, + "failureReasons": [], + "provenance": { + "seed": "100000-event-encrypted-sqlite-headless-production", + "readyMarker": "Completed Braid performance conversation (100000 committed events)", + "frameReadiness": "unique seeded final-content marker plus braid shell and no startup error", + "compileCache": "fresh empty directory for every launch; no compiled startup modules reused", + "packedPackageRoot": "/tmp/braid-install-giSGkm/node_modules/@tangle-network/braid", + "packedTarballSha256": "392fc9277f267753fa86e3965d58e07f2728b4c8f55b1913fb1071fe51e8d2b2" + } + }, + { + "kind": "distribution", + "name": "PERF-03", + "unit": "ms", + "n": 1, + "minimum": 8.186878000000434, + "median": 8.186878000000434, + "p90": 8.186878000000434, + "p95": 8.186878000000434, + "p99": 8.186878000000434, + "maximum": 8.186878000000434, + "target": { + "metric": "idle-key-to-updated-frame", + "percentile": "p95", + "operator": "<=", + "value": 50 + }, + "environment": { + "machine": "drew-GTR-Pro (AMD RYZEN AI MAX+ 395 w/ Radeon 8060S; x64; 32 logical CPUs)", + "os": "linux 7.0.0-28-generic", + "node": "v24.13.0", + "terminal": "node-pty@1.1.0 + @xterm/headless@6.0.0 + @earendil-works/pi-tui@0.83.0", + "dimensions": "80x24", + "database": "warm-10000-event-encrypted-sqlite", + "eventCount": 10000 + }, + "state": "warm", + "repetitions": 1, + "rawSamples": [ + 8.186878000000434 + ], + "command": "pnpm run test:performance [PERF-03; smoke]", + "passed": true, + "observations": { + "keyCount": { + "value": 1, + "reason": "Every unique composer edit reached a matching frame" + }, + "eraseFrames": { + "value": 1, + "reason": "Every edit awaited a later composer-empty frame before the next edit" + }, + "invalidCells": { + "value": 0, + "reason": "The final parsed frame had valid terminal cells" + }, + "editBindings": [ + { + "index": 1, + "token": "perf-key-00001", + "keyFrameVersion": 6, + "eraseFrameVersion": 7, + "keyFrame": [ + "braid cwd braid-performance-10000-SqNJhb · session New conversation", + " Performance seed conversation with normalized text events. ", + " ", + " Completed Braid performance conversation (10000 committed events); recent ", + " event 9997. ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + "────────────────────────────────────────────────────────────────────────────────", + " perf-key-00001 ", + "────────────────────────────────────────────────────────────────────────────────", + "completed automatic", + "Braid performance profile · runner pi · not connected Ctrl+P commands" + ], + "eraseFrame": [ + "braid cwd braid-performance-10000-SqNJhb · session New conversation", + " Performance seed conversation with normalized text events. ", + " ", + " Completed Braid performance conversation (10000 committed events); recent ", + " event 9997. ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + "────────────────────────────────────────────────────────────────────────────────", + " ", + "────────────────────────────────────────────────────────────────────────────────", + "completed automatic", + "Braid performance profile · runner pi · not connected Ctrl+P commands" + ] + } + ] + }, + "details": {}, + "failureReasons": [], + "provenance": { + "seed": "10000-event-encrypted-sqlite-headless-production", + "inputBinding": "unique perf-key-N text accepted by the real PTY composer", + "eraseBinding": "Ctrl-U followed by a later frame with no perf-key-N token", + "packedPackageRoot": "/tmp/braid-install-giSGkm/node_modules/@tangle-network/braid", + "packedTarballSha256": "392fc9277f267753fa86e3965d58e07f2728b4c8f55b1913fb1071fe51e8d2b2" + } + }, + { + "kind": "distribution", + "name": "PERF-04", + "unit": "ms", + "n": 100, + "minimum": 1.2899299999990035, + "median": 8.974247000005562, + "p90": 14.93423199999961, + "p95": 15.856877999998687, + "p99": 18.85931800000253, + "maximum": 20.472194000001764, + "target": { + "metric": "runtime-event-to-updated-frame", + "percentile": "p95", + "operator": "<=", + "value": 50 + }, + "environment": { + "machine": "drew-GTR-Pro (AMD RYZEN AI MAX+ 395 w/ Radeon 8060S; x64; 32 logical CPUs)", + "os": "linux 7.0.0-28-generic", + "node": "v24.13.0", + "terminal": "@xterm/headless@6.0.0 + @earendil-works/pi-tui@0.83.0", + "dimensions": "80x24", + "database": "memory-journal-runtime-frame-path", + "eventCount": 100 + }, + "state": "warm", + "repetitions": 100, + "rawSamples": [ + 8.005440000000817, + 2.373792999998841, + 10.295016999996733, + 4.727546999994956, + 15.565062999994552, + 10.257914999994682, + 5.124679000000469, + 15.856877999998687, + 10.7044240000032, + 5.247211000001698, + 16.786948000000848, + 12.662078000001202, + 7.171348000003491, + 1.7929799999983516, + 12.923000999995566, + 8.492528999995557, + 2.038792999999714, + 11.759932000000845, + 7.593700000004901, + 1.8321980000037001, + 12.16612200000236, + 7.314149999998335, + 1.7521799999958603, + 10.60624000000098, + 4.681526000000304, + 14.375992000001133, + 10.076266999996733, + 5.002598999999464, + 16.656622000002244, + 11.449651000002632, + 6.5746540000036475, + 1.5920039999982691, + 12.108007000002544, + 6.6234889999977895, + 1.5691290000031586, + 12.543574999996054, + 7.135226999998849, + 1.2899299999990035, + 12.21222200000193, + 6.979240000000573, + 1.495033000006515, + 11.85771399999794, + 6.535957000000053, + 1.3904269999984535, + 12.391514999995707, + 7.269470999999612, + 1.5763430000006338, + 13.407586000001174, + 8.49224900000263, + 1.8420760000008158, + 11.6105979999993, + 6.578751999993983, + 2.147908999999345, + 9.948105000003125, + 4.444931000005454, + 15.173612999999023, + 10.364755000002333, + 4.748969000000216, + 14.72425800000201, + 9.405136000001221, + 4.211231000001135, + 14.325683000002755, + 8.974247000005562, + 4.439139000001887, + 14.931755999998131, + 9.757789999996021, + 4.611858999996912, + 14.93423199999961, + 9.669135999996797, + 4.32818099999713, + 14.910434000004898, + 9.794783000004827, + 4.73665500000061, + 14.39632200000051, + 8.97634200000175, + 3.5902990000031423, + 14.604151999999885, + 10.020497999998042, + 4.876471000003221, + 18.85931800000253, + 13.623661000005086, + 8.70162100000016, + 3.0969570000015665, + 10.46448099999543, + 5.173364999995101, + 16.051620999998704, + 11.23703299999761, + 5.765521000001172, + 20.472194000001764, + 15.445087000000058, + 11.23235399999976, + 3.3086939999993774, + 11.58632999999827, + 4.186541999995825, + 15.407201999994868, + 9.58168399999704, + 3.5127159999974538, + 12.493174999995972, + 8.083934999995108, + 2.4093729999949574 + ], + "command": "pnpm run test:performance [PERF-04; smoke]", + "passed": true, + "observations": { + "missingEvents": { + "value": 0, + "reason": "No runtime events were missing" + }, + "duplicateEvents": { + "value": 0, + "reason": "No duplicate runtime events were accepted" + }, + "invalidCells": { + "value": 0, + "reason": "The Pi terminal contained no invalid cells" + }, + "uniqueProviderEventIds": { + "value": 100, + "reason": "Every provider event identifier was unique" + }, + "acceptedEvents": 100, + "producerElapsedMs": 525.9477180000031, + "achievedEventsPerSecond": { + "value": 190.13296679043566, + "reason": "The final event frame completed within the required 100 events/s interval" + }, + "finalFrameCompleted": { + "value": 1, + "reason": "Elapsed time ends after the final event matched a completed Pi frame" + }, + "eventFrameBindings": "Pi TUI doRender completion + current view revision + unique event marker" + }, + "details": { + "achievedEventsPerSecond": 190.13296679043566, + "minimumEventsPerSecond": 100, + "elapsedMs": 525.9477180000031, + "finalFrameCompleted": true, + "accepted": 100, + "renderedEvents": 100, + "producerIntervalMs": 5 + }, + "failureReasons": [], + "provenance": { + "packageRoot": "/tmp/braid-install-giSGkm/node_modules/@tangle-network/braid", + "frameBinding": "Pi TUI doRender completion + current view revision + unique event marker", + "unrelatedWritesExcluded": true, + "rateInterval": "producer start through the final event matching Pi frame completion", + "packedPackageRoot": "/tmp/braid-install-giSGkm/node_modules/@tangle-network/braid", + "packedTarballSha256": "392fc9277f267753fa86e3965d58e07f2728b4c8f55b1913fb1071fe51e8d2b2" + } + }, + { + "kind": "distribution", + "name": "PERF-05", + "unit": "ms", + "n": 1, + "minimum": 1279.1244089999964, + "median": 1279.1244089999964, + "p90": 1279.1244089999964, + "p95": 1279.1244089999964, + "p99": 1279.1244089999964, + "maximum": 1279.1244089999964, + "target": { + "metric": "replay-reduce-10000-events", + "percentile": "p95", + "operator": "<=", + "value": 2000 + }, + "environment": { + "machine": "drew-GTR-Pro (AMD RYZEN AI MAX+ 395 w/ Radeon 8060S; x64; 32 logical CPUs)", + "os": "linux 7.0.0-28-generic", + "node": "v24.13.0", + "terminal": "none; production StorageJournal + reducer", + "dimensions": "none", + "database": "warm-10000-event-encrypted-sqlite", + "eventCount": 10000 + }, + "state": "warm", + "repetitions": 1, + "rawSamples": [ + 1279.1244089999964 + ], + "command": "pnpm run test:performance [PERF-05; smoke]", + "passed": true, + "observations": { + "replayedEvents": { + "value": 10000, + "reason": "Every committed event was replayed" + }, + "projectionChecksum": "6c2de87e0f4426333f6d6d8db5bb9ec79569ed5af45429625528d5dcb8bbfd50" + }, + "details": {}, + "failureReasons": [], + "provenance": { + "seed": "packed encrypted SQLite production storage; complete receipt-backed run", + "packedPackageRoot": "/tmp/braid-install-giSGkm/node_modules/@tangle-network/braid", + "packedTarballSha256": "392fc9277f267753fa86e3965d58e07f2728b4c8f55b1913fb1071fe51e8d2b2" + } + }, + { + "kind": "distribution", + "name": "PERF-06", + "unit": "ms", + "n": 1, + "minimum": 93.51295600000594, + "median": 93.51295600000594, + "p90": 93.51295600000594, + "p95": 93.51295600000594, + "p99": 93.51295600000594, + "maximum": 93.51295600000594, + "target": { + "metric": "open-100000-event-conversation", + "percentile": "p95", + "operator": "<=", + "value": 2000 + }, + "environment": { + "machine": "drew-GTR-Pro (AMD RYZEN AI MAX+ 395 w/ Radeon 8060S; x64; 32 logical CPUs)", + "os": "linux 7.0.0-28-generic", + "node": "v24.13.0", + "terminal": "node-pty@1.1.0 + @xterm/headless@6.0.0 + @earendil-works/pi-tui@0.83.0", + "dimensions": "80x24", + "database": "cold-100000-event-encrypted-sqlite", + "eventCount": 100000 + }, + "state": "cold", + "repetitions": 1, + "rawSamples": [ + 93.51295600000594 + ], + "command": "pnpm run test:performance [PERF-06; smoke]", + "passed": true, + "observations": { + "openedEvents": { + "value": 100000, + "reason": "Every committed event was opened from encrypted SQLite" + }, + "loadedTailEvents": [ + 0 + ], + "renderedRows": [ + 4 + ], + "viewportBound": 200 + }, + "details": {}, + "failureReasons": [], + "provenance": { + "seed": "packed encrypted SQLite production storage; recent useful content asserted", + "packedPackageRoot": "/tmp/braid-install-giSGkm/node_modules/@tangle-network/braid", + "packedTarballSha256": "392fc9277f267753fa86e3965d58e07f2728b4c8f55b1913fb1071fe51e8d2b2" + } + }, + { + "kind": "distribution", + "name": "PERF-07", + "unit": "ms", + "n": 10, + "minimum": 10.629837000000407, + "median": 15.846467000003031, + "p90": 17.80722799999785, + "p95": 28.26690999999846, + "p99": 28.26690999999846, + "maximum": 28.26690999999846, + "target": { + "metric": "resize-during-stream-frame", + "percentile": "p95", + "operator": "<=", + "value": 100 + }, + "environment": { + "machine": "drew-GTR-Pro (AMD RYZEN AI MAX+ 395 w/ Radeon 8060S; x64; 32 logical CPUs)", + "os": "linux 7.0.0-28-generic", + "node": "v24.13.0", + "terminal": "node-pty@1.1.0 + @xterm/headless@6.0.0 + @earendil-works/pi-tui@0.83.0", + "dimensions": "40x12,80x24,120x40,200x60", + "database": "packed headless 100-events-per-second stream", + "eventCount": 29 + }, + "state": "warm", + "repetitions": 10, + "rawSamples": [ + { + "index": 1, + "requested": { + "columns": 40, + "rows": 12 + }, + "dimensions": { + "columns": 40, + "rows": 12 + }, + "elapsedMs": 10.629837000000407, + "frameCompletions": 2, + "frameCompletedAt": 36290.581429, + "acceptedEvents": 3, + "active": true, + "invalidCells": 0 + }, + { + "index": 2, + "requested": { + "columns": 80, + "rows": 24 + }, + "dimensions": { + "columns": 80, + "rows": 24 + }, + "elapsedMs": 28.26690999999846, + "frameCompletions": 3, + "frameCompletedAt": 36320.315196, + "acceptedEvents": 5, + "active": true, + "invalidCells": 0 + }, + { + "index": 3, + "requested": { + "columns": 120, + "rows": 40 + }, + "dimensions": { + "columns": 120, + "rows": 40 + }, + "elapsedMs": 13.368759999997565, + "frameCompletions": 4, + "frameCompletedAt": 36333.920611, + "acceptedEvents": 8, + "active": true, + "invalidCells": 0 + }, + { + "index": 4, + "requested": { + "columns": 200, + "rows": 60 + }, + "dimensions": { + "columns": 200, + "rows": 60 + }, + "elapsedMs": 16.98407700000098, + "frameCompletions": 5, + "frameCompletedAt": 36351.730374, + "acceptedEvents": 11, + "active": true, + "invalidCells": 0 + }, + { + "index": 5, + "requested": { + "columns": 40, + "rows": 12 + }, + "dimensions": { + "columns": 40, + "rows": 12 + }, + "elapsedMs": 15.846467000003031, + "frameCompletions": 6, + "frameCompletedAt": 36366.761826, + "acceptedEvents": 14, + "active": true, + "invalidCells": 0 + }, + { + "index": 6, + "requested": { + "columns": 80, + "rows": 24 + }, + "dimensions": { + "columns": 80, + "rows": 24 + }, + "elapsedMs": 16.87568299999839, + "frameCompletions": 7, + "frameCompletedAt": 36382.782426, + "acceptedEvents": 17, + "active": true, + "invalidCells": 0 + }, + { + "index": 7, + "requested": { + "columns": 120, + "rows": 40 + }, + "dimensions": { + "columns": 120, + "rows": 40 + }, + "elapsedMs": 13.48747299999377, + "frameCompletions": 8, + "frameCompletedAt": 36397.240701, + "acceptedEvents": 20, + "active": true, + "invalidCells": 0 + }, + { + "index": 8, + "requested": { + "columns": 200, + "rows": 60 + }, + "dimensions": { + "columns": 200, + "rows": 60 + }, + "elapsedMs": 17.80722799999785, + "frameCompletions": 9, + "frameCompletedAt": 36415.01865, + "acceptedEvents": 23, + "active": true, + "invalidCells": 0 + }, + { + "index": 9, + "requested": { + "columns": 40, + "rows": 12 + }, + "dimensions": { + "columns": 40, + "rows": 12 + }, + "elapsedMs": 13.820958999996947, + "frameCompletions": 10, + "frameCompletedAt": 36428.916742, + "acceptedEvents": 26, + "active": true, + "invalidCells": 0 + }, + { + "index": 10, + "requested": { + "columns": 80, + "rows": 24 + }, + "dimensions": { + "columns": 80, + "rows": 24 + }, + "elapsedMs": 17.19083399999363, + "frameCompletions": 11, + "frameCompletedAt": 36445.102507, + "acceptedEvents": 29, + "active": true, + "invalidCells": 0 + } + ], + "command": "pnpm run test:performance [PERF-07; smoke]", + "passed": true, + "observations": { + "producedEvents": { + "value": 29, + "reason": "Generated events were counted inside the packed execution stream" + }, + "acceptedEvents": { + "value": 29, + "reason": "Accepted events were counted from the packed application journal" + }, + "renderedFrames": { + "value": 11, + "reason": "Pi render completions were counted after xterm flush" + }, + "renderedResizes": { + "value": 10, + "reason": "Every resize received a completed Pi render" + }, + "elapsedMs": { + "value": 176.8542360000065, + "reason": "The interval starts at the first generated event and ends after the final resize-bound frame" + }, + "offeredEventsPerSecond": { + "value": 163.97684701201578, + "reason": "Offered rate uses generated events over the measured stream interval" + }, + "acceptedEventsPerSecond": { + "value": 163.97684701201578, + "reason": "Accepted rate met the required 100 events/s" + }, + "observedEventsPerSecond": { + "value": 163.97684701201578, + "reason": "The pass rate is the accepted stream rate through the final resize-bound frame" + }, + "producerEventsPerSecond": { + "value": 165.1976861181604, + "reason": "Producer cadence uses the first through last generated event timestamps" + }, + "minimumEventsPerSecond": { + "value": 100, + "reason": "The accepted stream must sustain at least this rate" + }, + "minimumExpectedEvents": { + "value": 18, + "reason": "Exact event-count equivalent of the 100 events/s requirement over this interval" + }, + "ratePassed": { + "value": 1, + "reason": "Accepted stream rate passed the exact lower bound" + }, + "duplicateEvents": { + "value": 0, + "reason": "No duplicate stream sequence markers were committed" + }, + "duplicateProviderEventIds": { + "value": 0, + "reason": "No duplicate provider event IDs were committed" + }, + "missingEvents": { + "value": 0, + "reason": "No generated stream marker was absent from the accepted journal" + }, + "unexpectedEvents": { + "value": 0, + "reason": "No accepted event had an unexpected stream marker" + }, + "invalidCells": { + "value": 0, + "reason": "Every reference-size frame contained valid cells" + }, + "activeDuringEveryResize": { + "value": 1, + "reason": "The run stayed active through every resize" + }, + "staticTranscript": { + "value": 0, + "reason": "Accepted event counts changed while resizing" + }, + "perResize": [ + { + "index": 1, + "requested": { + "columns": 40, + "rows": 12 + }, + "dimensions": { + "columns": 40, + "rows": 12 + }, + "elapsedMs": 10.629837000000407, + "frameCompletions": 2, + "frameCompletedAt": 36290.581429, + "acceptedEvents": 3, + "active": true, + "invalidCells": 0 + }, + { + "index": 2, + "requested": { + "columns": 80, + "rows": 24 + }, + "dimensions": { + "columns": 80, + "rows": 24 + }, + "elapsedMs": 28.26690999999846, + "frameCompletions": 3, + "frameCompletedAt": 36320.315196, + "acceptedEvents": 5, + "active": true, + "invalidCells": 0 + }, + { + "index": 3, + "requested": { + "columns": 120, + "rows": 40 + }, + "dimensions": { + "columns": 120, + "rows": 40 + }, + "elapsedMs": 13.368759999997565, + "frameCompletions": 4, + "frameCompletedAt": 36333.920611, + "acceptedEvents": 8, + "active": true, + "invalidCells": 0 + }, + { + "index": 4, + "requested": { + "columns": 200, + "rows": 60 + }, + "dimensions": { + "columns": 200, + "rows": 60 + }, + "elapsedMs": 16.98407700000098, + "frameCompletions": 5, + "frameCompletedAt": 36351.730374, + "acceptedEvents": 11, + "active": true, + "invalidCells": 0 + }, + { + "index": 5, + "requested": { + "columns": 40, + "rows": 12 + }, + "dimensions": { + "columns": 40, + "rows": 12 + }, + "elapsedMs": 15.846467000003031, + "frameCompletions": 6, + "frameCompletedAt": 36366.761826, + "acceptedEvents": 14, + "active": true, + "invalidCells": 0 + }, + { + "index": 6, + "requested": { + "columns": 80, + "rows": 24 + }, + "dimensions": { + "columns": 80, + "rows": 24 + }, + "elapsedMs": 16.87568299999839, + "frameCompletions": 7, + "frameCompletedAt": 36382.782426, + "acceptedEvents": 17, + "active": true, + "invalidCells": 0 + }, + { + "index": 7, + "requested": { + "columns": 120, + "rows": 40 + }, + "dimensions": { + "columns": 120, + "rows": 40 + }, + "elapsedMs": 13.48747299999377, + "frameCompletions": 8, + "frameCompletedAt": 36397.240701, + "acceptedEvents": 20, + "active": true, + "invalidCells": 0 + }, + { + "index": 8, + "requested": { + "columns": 200, + "rows": 60 + }, + "dimensions": { + "columns": 200, + "rows": 60 + }, + "elapsedMs": 17.80722799999785, + "frameCompletions": 9, + "frameCompletedAt": 36415.01865, + "acceptedEvents": 23, + "active": true, + "invalidCells": 0 + }, + { + "index": 9, + "requested": { + "columns": 40, + "rows": 12 + }, + "dimensions": { + "columns": 40, + "rows": 12 + }, + "elapsedMs": 13.820958999996947, + "frameCompletions": 10, + "frameCompletedAt": 36428.916742, + "acceptedEvents": 26, + "active": true, + "invalidCells": 0 + }, + { + "index": 10, + "requested": { + "columns": 80, + "rows": 24 + }, + "dimensions": { + "columns": 80, + "rows": 24 + }, + "elapsedMs": 17.19083399999363, + "frameCompletions": 11, + "frameCompletedAt": 36445.102507, + "acceptedEvents": 29, + "active": true, + "invalidCells": 0 + } + ] + }, + "details": { + "streamElapsedMs": 176.8542360000065, + "firstGeneratedAt": 36268.248271, + "lastGeneratedAt": 36443.795524, + "finalResizeFrameCompletedAt": 36445.102507, + "intendedEventsPerSecond": 250, + "producerEventsPerSecond": 165.1976861181604, + "offeredEventsPerSecond": 163.97684701201578, + "acceptedEventsPerSecond": 163.97684701201578, + "observedEventsPerSecond": 163.97684701201578, + "minimumEventsPerSecond": 100, + "minimumExpectedEvents": 18, + "ratePassed": true + }, + "failureReasons": [], + "provenance": { + "packageRoot": "/tmp/braid-install-giSGkm/node_modules/@tangle-network/braid", + "stream": "packed Braid application + packed Pi terminal headless path", + "eventReceipt": "application committed uniquely marked run.text.delta events", + "resizeReceipt": "Pi doRender completion followed by xterm flush at each reference size and resize epoch", + "rateInterval": "first generated stream event through the final resize-bound Pi frame", + "referenceDimensions": [ + "40x12", + "80x24", + "120x40", + "200x60" + ], + "packedPackageRoot": "/tmp/braid-install-giSGkm/node_modules/@tangle-network/braid", + "packedTarballSha256": "392fc9277f267753fa86e3965d58e07f2728b4c8f55b1913fb1071fe51e8d2b2" + } + }, + { + "kind": "distribution", + "name": "PERF-08", + "unit": "% of one core", + "n": 1, + "minimum": 0, + "median": 0, + "p90": 0, + "p95": 0, + "p99": 0, + "maximum": 0, + "target": { + "metric": "idle-cpu", + "percentile": "median", + "operator": "<=", + "value": 1 + }, + "environment": { + "machine": "drew-GTR-Pro (AMD RYZEN AI MAX+ 395 w/ Radeon 8060S; x64; 32 logical CPUs)", + "os": "linux 7.0.0-28-generic", + "node": "v24.13.0", + "terminal": "node-pty@1.1.0 + @xterm/headless@6.0.0 + @earendil-works/pi-tui@0.83.0", + "dimensions": "80x24", + "database": "deterministic-offline-fixture-idle", + "eventCount": 0 + }, + "state": "warm", + "repetitions": 1, + "rawSamples": [ + 0 + ], + "command": "pnpm run test:performance [PERF-08; smoke]", + "passed": true, + "observations": { + "durationSeconds": [ + 2.0022956110000014 + ], + "cpuSeconds": [ + 0 + ], + "requiredDurationSeconds": 2 + }, + "details": {}, + "failureReasons": [], + "provenance": { + "settlingSeconds": 0.25, + "measuredSeconds": 2, + "cpuMeter": "/proc//stat", + "commandPath": "packed deterministic Braid process", + "packedPackageRoot": "/tmp/braid-install-giSGkm/node_modules/@tangle-network/braid", + "packedTarballSha256": "392fc9277f267753fa86e3965d58e07f2728b4c8f55b1913fb1071fe51e8d2b2" + } + }, + { + "kind": "distribution", + "name": "PERF-09", + "unit": "MiB RSS", + "n": 1, + "minimum": 108.12890625, + "median": 108.12890625, + "p90": 108.12890625, + "p95": 108.12890625, + "p99": 108.12890625, + "maximum": 108.12890625, + "target": { + "metric": "resident-memory-10000-event-conversation", + "percentile": "p95", + "operator": "<=", + "value": 150 + }, + "environment": { + "machine": "drew-GTR-Pro (AMD RYZEN AI MAX+ 395 w/ Radeon 8060S; x64; 32 logical CPUs)", + "os": "linux 7.0.0-28-generic", + "node": "v24.13.0", + "terminal": "packed production view model; child process RSS", + "dimensions": "80x24", + "database": "warm-10000-event-encrypted-sqlite", + "eventCount": 10000 + }, + "state": "warm", + "repetitions": 1, + "rawSamples": [ + 108.12890625 + ], + "command": "pnpm run test:performance [PERF-09; smoke]", + "passed": true, + "observations": { + "residentMemory": [ + { + "baselineRssMiB": 102.6640625, + "openedRssMiB": 108.12890625, + "loadedTailEventCount": 0, + "renderedRows": 4 + } + ], + "boundedViewport": { + "value": 200, + "reason": "The child process enforces a maximum of 200 rendered rows" + } + }, + "details": {}, + "failureReasons": [], + "provenance": { + "seed": "packed encrypted SQLite production storage; one fresh child per repetition", + "packedPackageRoot": "/tmp/braid-install-giSGkm/node_modules/@tangle-network/braid", + "packedTarballSha256": "392fc9277f267753fa86e3965d58e07f2728b4c8f55b1913fb1071fe51e8d2b2" + } + }, + { + "kind": "distribution", + "name": "PERF-10", + "unit": "MiB", + "n": 1, + "minimum": 8.1328125, + "median": 8.1328125, + "p90": 8.1328125, + "p95": 8.1328125, + "p99": 8.1328125, + "maximum": 8.1328125, + "target": { + "metric": "database-growth-10000-events", + "percentile": "maximum", + "operator": "<=", + "value": 50 + }, + "environment": { + "machine": "drew-GTR-Pro (AMD RYZEN AI MAX+ 395 w/ Radeon 8060S; x64; 32 logical CPUs)", + "os": "linux 7.0.0-28-generic", + "node": "v24.13.0", + "terminal": "none; packed production encrypted SQLite artifacts", + "dimensions": "none", + "database": "cold-10000-event-encrypted-sqlite", + "eventCount": 10000 + }, + "state": "cold", + "repetitions": 1, + "rawSamples": [ + 8.1328125 + ], + "command": "pnpm run test:performance [PERF-10; smoke]", + "passed": true, + "observations": { + "databaseFiles": [ + { + "database": 8495104, + "wal": 0, + "sharedMemory": 32768, + "total": 8527872 + } + ], + "providerArtifacts": { + "value": 0, + "reason": "The normalized local text seed writes no provider payload artifact files" + } + }, + "details": {}, + "failureReasons": [], + "provenance": { + "bytes": "database + WAL + shared-memory SQLite artifacts only", + "providerArtifacts": "reported separately and excluded from samples", + "packedPackageRoot": "/tmp/braid-install-giSGkm/node_modules/@tangle-network/braid", + "packedTarballSha256": "392fc9277f267753fa86e3965d58e07f2728b4c8f55b1913fb1071fe51e8d2b2" + } + } + ], + "releaseMeasurements": [ + { + "kind": "distribution", + "name": "PERF-01", + "unit": "ms", + "n": 1, + "minimum": 228.81477099999756, + "median": 228.81477099999756, + "p90": 228.81477099999756, + "p95": 228.81477099999756, + "p99": 228.81477099999756, + "maximum": 228.81477099999756, + "target": { + "metric": "process-start-to-first-visible-frame", + "percentile": "p95", + "operator": "<=", + "value": 250 + }, + "environment": { + "machine": "drew-GTR-Pro (AMD RYZEN AI MAX+ 395 w/ Radeon 8060S; x64; 32 logical CPUs)", + "os": "linux 7.0.0-28-generic", + "node": "v24.13.0", + "terminal": "node-pty@1.1.0 + @xterm/headless@6.0.0 + @earendil-works/pi-tui@0.83.0", + "dimensions": "80x24", + "database": "warm-10000-event-encrypted-sqlite", + "eventCount": 10000 + }, + "state": "warm", + "repetitions": 1 + }, + { + "kind": "distribution", + "name": "PERF-02", + "unit": "ms", + "n": 1, + "minimum": 271.8521089999995, + "median": 271.8521089999995, + "p90": 271.8521089999995, + "p95": 271.8521089999995, + "p99": 271.8521089999995, + "maximum": 271.8521089999995, + "target": { + "metric": "process-start-to-first-visible-frame", + "percentile": "p95", + "operator": "<=", + "value": 1000 + }, + "environment": { + "machine": "drew-GTR-Pro (AMD RYZEN AI MAX+ 395 w/ Radeon 8060S; x64; 32 logical CPUs)", + "os": "linux 7.0.0-28-generic", + "node": "v24.13.0", + "terminal": "node-pty@1.1.0 + @xterm/headless@6.0.0 + @earendil-works/pi-tui@0.83.0", + "dimensions": "80x24", + "database": "cold-100000-event-encrypted-sqlite", + "eventCount": 100000 + }, + "state": "cold", + "repetitions": 1 + }, + { + "kind": "distribution", + "name": "PERF-03", + "unit": "ms", + "n": 1, + "minimum": 8.186878000000434, + "median": 8.186878000000434, + "p90": 8.186878000000434, + "p95": 8.186878000000434, + "p99": 8.186878000000434, + "maximum": 8.186878000000434, + "target": { + "metric": "idle-key-to-updated-frame", + "percentile": "p95", + "operator": "<=", + "value": 50 + }, + "environment": { + "machine": "drew-GTR-Pro (AMD RYZEN AI MAX+ 395 w/ Radeon 8060S; x64; 32 logical CPUs)", + "os": "linux 7.0.0-28-generic", + "node": "v24.13.0", + "terminal": "node-pty@1.1.0 + @xterm/headless@6.0.0 + @earendil-works/pi-tui@0.83.0", + "dimensions": "80x24", + "database": "warm-10000-event-encrypted-sqlite", + "eventCount": 10000 + }, + "state": "warm", + "repetitions": 1 + }, + { + "kind": "distribution", + "name": "PERF-04", + "unit": "ms", + "n": 100, + "minimum": 1.2899299999990035, + "median": 8.974247000005562, + "p90": 14.93423199999961, + "p95": 15.856877999998687, + "p99": 18.85931800000253, + "maximum": 20.472194000001764, + "target": { + "metric": "runtime-event-to-updated-frame", + "percentile": "p95", + "operator": "<=", + "value": 50 + }, + "environment": { + "machine": "drew-GTR-Pro (AMD RYZEN AI MAX+ 395 w/ Radeon 8060S; x64; 32 logical CPUs)", + "os": "linux 7.0.0-28-generic", + "node": "v24.13.0", + "terminal": "@xterm/headless@6.0.0 + @earendil-works/pi-tui@0.83.0", + "dimensions": "80x24", + "database": "memory-journal-runtime-frame-path", + "eventCount": 100 + }, + "state": "warm", + "repetitions": 100 + }, + { + "kind": "distribution", + "name": "PERF-05", + "unit": "ms", + "n": 1, + "minimum": 1279.1244089999964, + "median": 1279.1244089999964, + "p90": 1279.1244089999964, + "p95": 1279.1244089999964, + "p99": 1279.1244089999964, + "maximum": 1279.1244089999964, + "target": { + "metric": "replay-reduce-10000-events", + "percentile": "p95", + "operator": "<=", + "value": 2000 + }, + "environment": { + "machine": "drew-GTR-Pro (AMD RYZEN AI MAX+ 395 w/ Radeon 8060S; x64; 32 logical CPUs)", + "os": "linux 7.0.0-28-generic", + "node": "v24.13.0", + "terminal": "none; production StorageJournal + reducer", + "dimensions": "none", + "database": "warm-10000-event-encrypted-sqlite", + "eventCount": 10000 + }, + "state": "warm", + "repetitions": 1 + }, + { + "kind": "distribution", + "name": "PERF-06", + "unit": "ms", + "n": 1, + "minimum": 93.51295600000594, + "median": 93.51295600000594, + "p90": 93.51295600000594, + "p95": 93.51295600000594, + "p99": 93.51295600000594, + "maximum": 93.51295600000594, + "target": { + "metric": "open-100000-event-conversation", + "percentile": "p95", + "operator": "<=", + "value": 2000 + }, + "environment": { + "machine": "drew-GTR-Pro (AMD RYZEN AI MAX+ 395 w/ Radeon 8060S; x64; 32 logical CPUs)", + "os": "linux 7.0.0-28-generic", + "node": "v24.13.0", + "terminal": "node-pty@1.1.0 + @xterm/headless@6.0.0 + @earendil-works/pi-tui@0.83.0", + "dimensions": "80x24", + "database": "cold-100000-event-encrypted-sqlite", + "eventCount": 100000 + }, + "state": "cold", + "repetitions": 1 + }, + { + "kind": "distribution", + "name": "PERF-07", + "unit": "ms", + "n": 10, + "minimum": 10.629837000000407, + "median": 15.846467000003031, + "p90": 17.80722799999785, + "p95": 28.26690999999846, + "p99": 28.26690999999846, + "maximum": 28.26690999999846, + "target": { + "metric": "resize-during-stream-frame", + "percentile": "p95", + "operator": "<=", + "value": 100 + }, + "environment": { + "machine": "drew-GTR-Pro (AMD RYZEN AI MAX+ 395 w/ Radeon 8060S; x64; 32 logical CPUs)", + "os": "linux 7.0.0-28-generic", + "node": "v24.13.0", + "terminal": "node-pty@1.1.0 + @xterm/headless@6.0.0 + @earendil-works/pi-tui@0.83.0", + "dimensions": "40x12,80x24,120x40,200x60", + "database": "packed headless 100-events-per-second stream", + "eventCount": 29 + }, + "state": "warm", + "repetitions": 10 + }, + { + "kind": "distribution", + "name": "PERF-08", + "unit": "% of one core", + "n": 1, + "minimum": 0, + "median": 0, + "p90": 0, + "p95": 0, + "p99": 0, + "maximum": 0, + "target": { + "metric": "idle-cpu", + "percentile": "median", + "operator": "<=", + "value": 1 + }, + "environment": { + "machine": "drew-GTR-Pro (AMD RYZEN AI MAX+ 395 w/ Radeon 8060S; x64; 32 logical CPUs)", + "os": "linux 7.0.0-28-generic", + "node": "v24.13.0", + "terminal": "node-pty@1.1.0 + @xterm/headless@6.0.0 + @earendil-works/pi-tui@0.83.0", + "dimensions": "80x24", + "database": "deterministic-offline-fixture-idle", + "eventCount": 0 + }, + "state": "warm", + "repetitions": 1 + }, + { + "kind": "distribution", + "name": "PERF-09", + "unit": "MiB RSS", + "n": 1, + "minimum": 108.12890625, + "median": 108.12890625, + "p90": 108.12890625, + "p95": 108.12890625, + "p99": 108.12890625, + "maximum": 108.12890625, + "target": { + "metric": "resident-memory-10000-event-conversation", + "percentile": "p95", + "operator": "<=", + "value": 150 + }, + "environment": { + "machine": "drew-GTR-Pro (AMD RYZEN AI MAX+ 395 w/ Radeon 8060S; x64; 32 logical CPUs)", + "os": "linux 7.0.0-28-generic", + "node": "v24.13.0", + "terminal": "packed production view model; child process RSS", + "dimensions": "80x24", + "database": "warm-10000-event-encrypted-sqlite", + "eventCount": 10000 + }, + "state": "warm", + "repetitions": 1 + }, + { + "kind": "distribution", + "name": "PERF-10", + "unit": "MiB", + "n": 1, + "minimum": 8.1328125, + "median": 8.1328125, + "p90": 8.1328125, + "p95": 8.1328125, + "p99": 8.1328125, + "maximum": 8.1328125, + "target": { + "metric": "database-growth-10000-events", + "percentile": "maximum", + "operator": "<=", + "value": 50 + }, + "environment": { + "machine": "drew-GTR-Pro (AMD RYZEN AI MAX+ 395 w/ Radeon 8060S; x64; 32 logical CPUs)", + "os": "linux 7.0.0-28-generic", + "node": "v24.13.0", + "terminal": "none; packed production encrypted SQLite artifacts", + "dimensions": "none", + "database": "cold-10000-event-encrypted-sqlite", + "eventCount": 10000 + }, + "state": "cold", + "repetitions": 1 + } + ] +} diff --git a/artifacts/verification/w0/120x40.png b/artifacts/verification/w0/120x40.png index be39a961320c1c3ae63bf778c7f3559b6ff6a1bb..8854015bde0f40d46f9853006211e2a9dd3b6624 100644 GIT binary patch delta 21 ccmeBm>UG-ihlzuk(?V=(h>PE57G_R408ldqD*ylh delta 21 ccmeBm>UG-ihlztxT!!ln&;GE@EX}F$r=L^` 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 delta 26006 zcmb5#XH*ki)HwP{PpA`mC-fpEG(l+zMM6`W0t!-Z4%e^1lCd*S#O^m%G-?*Eut@_gOQ0pWj(7gIswDVd$Bt005wNZ~{Uw zJTygETxRRbcM%C$VR2beNqHeLoUnwP$p0KNqLOmL5;DRPvLfO#LSix^67oXgGMy3p z5Cg>7HY`qt_N2-n;=@aKHN_LDChpS~;RK6mF1U-rAo+Xq^Ja&qqHfsB#r8z=`lidw zL)YeRHi#A7gFL6utVT5IHk9rBLLZuJW$j zzclDwMPO6wyUNhJoA0W~V=C{fFV6+NuZelLIaU^T@!@+zkn%%igPAH6+)_@ZlK!&CGPw4=oL)!5>;%n%X|Jwcq_vWDB#SXoPaM z|1O3Kc7<5heV*g|R4B+cOuqM{X@{lp=f`6w7F0NNd)Q^0zCDsU@_73`Xr;C@D&QKr z^FSn^eP>J}dTZz5{B(cjxG-a&ZK9FcZcbONBH+jIFdE^>i%4h5uf-1zi7c&LjHMfAiiM>OU6zAI@Jc z(t(@U)R)KZt7u-*uzr8V92&Qgmvj86RMY!-h&#FIbInIp)>+)gsfjDJt-H2_{8P-pH#lDq~+cjyIO*}b&^jpPLPLpo4_0!J7W*qw^D!QGMTPwsV?|-djJ5`=JB{WcJ_)`2VCf z2fztHVfK&=D+jxS?2c}t>}M1-9wd3$z5TU!+vM0R|Im2FLprLO`oR&YXU|6_=P(V7 zNrw$BshQOdZvF{rKYq4|*E5YvDQ#%IB_^#HQM(tJ#7NDn zwR7?gPQM*e`Y!AGhw|#ip20B-k4$?P9}OKN_e&**@JATk|50lfZ3AnL5^N1DJ){*5 zg)>H!4(ZxBdY(yQSx`=f$*3ASx}9HGd8e&!oL^L3*L0I~!XvlyiWYBa&{#+JmuVlq;TecddC9E)jKMB_Fe(el5!f!4KGjokYmfAI9zNl>HlMQ z;^fRcTSi51cx;k#Wlraa+1ccAL&q2c6YCgiQ6QP>;GeFhrGJz}@d}H}HY2&n>S~?K zo>M%kr(sW!(;!MKXe((N8(W+RxI``N**zI~+d2HYl9suZPoa*v-;2f9ORF1Qz4sHU zXU<=u1W>PQ8akYfz8dnl;9|v=Ur4-vAo*_psKycdw3aunXZ%v=`ErL0PKM+MCg05K z`bu<9Gq5Ea7@9R*yG?occ=l>T`^&YDw6wh9idy#DFY&2m{z)x%XOm7{xoqK?<5+UZ ze}GP~unDE#&CDycAf0Hv-c4`_Hn%x>#OL@i*UKL5iD%DUj%@fFd@(98^m1X@RWs*{ zS`HRAY3JU5_~MiC%swY5Xe`gfC24>)apIKUA-!WdrY^qm4MsuEu9TP_V&oYQpTLXZ zv9XD1_Kt3()6r20DFGwdQH}p}PFYzdc%9FG=@l5AmQ@f>r9Yi{c?f^_!eo&zeOiTh zB&d)rt)U(nOLY#+A?M!n>ZB>DYSq+Tt75jE4++;Io|VzTdv~Q=%XHrG} zW2O+_KheS9B(yegsp6txqgEz7=jyD5d_}M@RD-U(g{?`dyt%z^Dow<}$zYmNr1Z5^ z)=rbhpq|(!!k)0^J=WsLkgBzIE#UJBDydSaz*AvT!xfDe_Il0j`TfZ$Qv!&<*T=D6 zMK32Cmg-KYD%=whNK5#7Cs0{2;n&Q$94{y{8S+46odz)&@J$z85m|>BUk!A$I&@yl zg$d!7-CxHXx{*-FozJ`DG$v1Z#|ob$77zStLp0U3sa``RihXElgo5oINRacqa8Y3A zBc6$5Rn=BeO8_Ebq)SGk(cIoQO5{DKtUw9*ur=Zl959RuoFzNJ-n}zqVUQ0DFAbtq z@Bu=6BqjvFaN#GpFU86_xkRMQ%xRY_zt=>ll3xS4vX?NlqHH;&hX5xY=j95EfbVHScYhpBH-LH}(z z{);#SwUi7BNse+yCYq(=r zxA_-%V?%YvB|gSjz1NbJ!JL5rR6Ic@70AU%5Jq+N0Dc^wjG71)Cn{^2!ZiUG1d}a) zxi+R6F`>NpvmXJRDS`r}(vn=prMO=_I6w|C2``g{x5WA?OWSqfolq6& z2?9P0=MHXX-K8|4$^}c9h@Z?NHT^Y57q`sVBi#9g2SlTS(KKL0@EnUc0e-arEFvnA zE$4Nvx<8Os@aKC-K9=TjK#rz)923J<-C=_G)YY@klEqX^y00>V2cj?@as{Kshq+i3xv z>%P`vA2btrlO0?Y6;B}dmV^l{n6v>%=vUDZ&A)yO!gxl2)nKiqe=2xt)W)4-I$fxP z*ltg0WL2UwAFOl9W?j^Pqe|$OYN}yl&wBY~PQPty;@W<3{xwzRMCvKpX|3-5aCxXS zfrO0Wa4K}ciJ@pCUxE(<0#jWcdJ*m4{zNk}q0BmfNkvP=f>nySw^3m%w5kIQtL9W2 z3;$&4d1J5{NkDM(pjDYUqE}cr9w@ z!`T!&UjeQ?mzaJ%AmPLF!av{XaEYT1azyqJN9S{zxNHXs2nPi2w46Wraj}9DtjjW? zLV2}q9?c5Q_ zP76%Ut9k4K^bp5ylkZE1F5LZj%J&{-8ghgudGv2ywBU)uXudOo1yMb+G9Kd>i<{fe z?H!I7LSb&3o%R_`+nC?K;;TzR3nDw{-%8f`F6ojaa#NFybQ)m#B+^w;BLHP9Fj7{s zecseVYgq&A2BJvW{LqauF?f+`6FuZYhKa~H3p&IIGE=PqOZc90_LOuo|9ZT+Dnvb) zkYjmg%Kb>!?OX30Q&j=*oFqNp3r^S3Cuy!cT0~J94}1oPnnTV}O1V3E9u7!yWf(jN z+iUk2e}Hj;UV-p6O)Cd~g-o2nN}$30Ur$-wexj+1py;HfQr|Yoy(G4XWi^7#FH+1hS91 zaSohIpF6~*eeLwhhb4|3npPeYAxR!CK<^A|Jpr==DWa*LE_xweK6?TV^RI4u2d6EY z`sB>k?#Zs!QPoB*HBwl$=g~jL5!x(`Xi*H{N1$<;uxODmHiEAO2yq_zHHX^T$jBp~ z*Z&<`Y~3*eYz*G}euM@~prRz%y;uCMYOlTjIK)qON3tK9mdTFpy<4Ip{0R&E<8|GS z%LWzptx+<`$X#Ni)>xuh;Ip`S{l9LC46`aaW_YIW%kv%Mey&nVe zv4V5_62pdblFQ*B0ECAl0dfTOq3-dbAgKsVJwK;^vU*r8w8S<fMOBCG}r!i}TAxNwN>d0j+N5Z9VI zdn8s)H2*K39;%ms*<|1s8E$L4wh=%0UE|5@KP$RsKUVRoYRUj=| zh?*`s!H-q{rmf`%+F%N0-HoA}crc}etG*-~UhsD}KQ~=V?3*LN)M)%^C@EZ|7n_mY zp_woR=l~SJo9#OZBR+SIxK|E7awbaL1$n0+;eOCShhm-r{Ctt|@^fSUE+Zj-<&V!z zzQ8KB-k8jy0NRoAv>?#L8KJ}mph^J(qefz#P$B?RVgmXMD_#NP*iG=KqG4A=e2lem zqIG4lO2urmNy(84a2S$dVJ-E;;m%&sN;ya&Fs;!$xkwoZr$eIr`O22o%Fx+LtN`}E z2-CgWrZch7*&vXfX}q+5-}psQ`I|-Kiq^a;cda!Ylegw2ef;F1`&E4t8Hexy2MD<^ z1z60#O*wt0;F-B9mCh6!G& z3=e5sKyJw5KmQENZ*=CSAh6yb^q!xF(j%}m&0XXi$o0S(qu?a}AcA_@i9@S7va$h& z^o3ix0sB+VA`p1Zlo^Z>6nYf<^D?+MTYL-MoD>YEsJ6&iw(y13b30m>^noUAAfc~% zZv-ECssRmYEo}2`R>i75a2gkFZPIRiQ6C>)*(`LY`LcEM9woev{oE18(gGA5hQ75@ zlp#Yn$RQBvi-)>syK@t>rN-RY-JmrUqRP_jY|Q113(IQK9uPcTfAVy*!s&){r%^lC z*EoPT96&~iTehIPs0>7t3KGfJd(XAfBEgi#R{62(AAa5V)OTb1SJklfnfnvETm@~P z#@$4`!6&Vhp!J37zj5Gx)^%cQ0>HZY&qjgO7e81TsC?+=>#TTI^i5=~Tl4!HTT2<7 z`*<$leslm1I>c;UZ!3d+@XoaXhwmVLWYUodr#JyO@tEMp8M=flzySubBPPGyRyY=n)(R9D zh5@#)#Hx7Js<11+!J;Oxl@nmbgLNOh#p1xL4+CBsPV{>HY54 zL%^HMf!&R|F+xqvRs&JJ8M+bIwef($?Lo2m2-SH_Z1?4+7Lbg)u7rntTOJTjBDD|qm*p%m61&TN=>Iamu9#MkKK&zOX?cE}n2 z8XLe25A&Dx$O;ehO5EpB!{VvgeKEiSedv;5ADSP2a=U(}|1`C^Q(kdIK6HfFlEpn8 zt|=S=P)AqYN7z9(lnw6{zH-nzZrra_q+A0FK#pjq#|LI-~IEbI5Z^w zbu_pY5YM{?E6RohGJ7(D9*Abdwaq7?9TOgwC7@)+#{-;x?|A;V+5!ET%no5+oWR_~5Id01rfay)Dcj68&m z&aNnLpRf(ZwizYLJ5)Y)&3xciJ)XUFOCs92zO1x4V7#Th_52n+3WAhCVgH@Kt2|7Z z+R9M!hRL(_6TQ3m3gF!25!#N9GZIygFbC}L1>n$LJz zz;jwCaayEiT5NDyVr5$D?=((wM%H*n-g8DVaYngjMs;vTZDmH|?+jk@xwi3h-F?sJ zYJaB;YMvh*d~USz+~n_bg5<23@vMdC?6JgIQq8Q*;OuQ8aQyGA!_(O;^a~ezz^R6Q zQu2jsBHjJ(?3t%8JTJWP@}$sx)aU$+>E{yXQp@Lp)#;%tbJX2A@~4b&XSmj`NQ z+W#)IBwvmgzZ~^^IhOcxyyoSj!Iw`~UQYgfIVHI=W4to!xiY6NIN<u3*vA5xeRY&;W=dO0uu4+xM^2b;6LNHpXybTQW zFvw-%`09tTNV)@b0_35rvbpEUVEPpAWC!MsH!m8(HPV5ZBy)b`23Sv<|21_*CMf@jjy)!(v2#aDaXQfZsSS)HE0E-^UDZ@CB7;JrryA;cnjNy5GjwQU{=k z2}@Cc)l4jip*gTd|;miULXGqc$+CVn3*pM^@WRabLCs-jvp#GIN z0QoC|@fCmbCnfsT&s)!a8mD_a((_Ng1g7!=#)rRKUi#WKg^s|#%fBRCa24z(Y;mq} z7i(_#d2v4B{VT}>ba(Lfbo?heJ;=G=Il%M?hOs9()?OV%pTR)864;X8c`_N8kT;*q z?)}s{@q5SgcLh$`RLH+i(|o`SfG7C_j^3qJrU;HM3b5T=tSim9C$Nnoytc>^R6jdf z?)c_do}_UST-0fuRH$Ho#iGLLtxc)A=e<v|+bEcFng zbU}pzqF7Z>lMky;`@5-C4rFPb0)qxXG1HO*UStq*p*{m$#!{c67u!wN7*mjvr@sfz zG}>Q1wo=gaw5q%M0^g~p8T%G0PX{ivm24>k-aS*8{vTa+-HED7seud`>j%Pos$2$I zj%w~X!rDgplRGxmik2-;5$tDtZ)LLN|2?2wE4%5{zlr$GIQ-V7z_btxb9f>`^&1CX zas?%G`EMJH51Fb3z0Ri8k**U_(y4eej8a2~8Klv=w-HJ#xcq0^-e2k3S65clOcbLB$scYZLLt|f6H3wYAQwB zMy0vomd&A?quRFW-Rrk(HHY9jc3KbRZ`u4!_uFKI%I!)KfTB)(i$yW1$uCu-V+b-u5rU*S3 zM=OOpF3u+`^-i92J$L7%t5=%dDffWJJCsvrEy?f@;nU`}5mmVqzkCJQE^*tjMjG{fGUvXnH zr0!y^1x}Ur)=NS+0H$ZmK4Mq)WFC%?5%qe9RB~=yO9o-{uylOIB=~;Z&dvS1Z(D3!ZPYQD2%q^Ib(b6!dRl>r3{(yn7hte5F;S);D z1L2P~9~wnG)qg(_@yrxue0kbRY4Gy%6IRBNFI)o#Bj>#`jH4C`}k=5=I~I8<*kgY3r%7McL zMz2wB<>MHM0;FtyF`2WnyUax}T)m{*UvDPu#>1RlQ5Wbgu(03nT{?gD;dGo1waCe2)v7&vcY zjH%emJcWZxM?mx#ke;b%)8PyepQ2qavYmMd^t%)BhOXmP2B%oCm% z{9}tc+dbf4AXA`*3!3QwgGnLuJUNeyh;^eA&G;XYe1qk&K^;iTyGm3I=(mbOI8_&7 z^Yr3a_@&M9CM9PQ)}kE?oB#nG`LA3Ma7}c)c-$hB$mPg^qdrS z3ChM7E(*l+O{9KT4R&}d$jB6UixxOzP+)T0LV`g_eJ79b9u1PZQ(u`Nu4EZ(=|JK& zY5`eC75Oly`D6r3{AIPss#Kw++T4~LKXZlf7s#W&vXJW;-`gL}uAdtb$}n!1gELX) z=hxx;AsuFxihZ-$!BgB~+#43{z52@kSlq(a_en}lPJK4;MpcDPog@O6h27;GoK~wC z97k~l*6+wN7+2IymX8fZl_V7%bV7radB(!w*RLBvIG$#lBB|a5FM4aZ<}TR)?Qg_4 zXO~e3Z-fP7D)r;|c|Qo-o}A?@+d_xSCs65DV%YMRo?mqSRTStcj+AxXxo*L`krK!3 z=zd;97cb`Y44472uOHBP>C~kx{0wb`f?%mPTG`6YbkaQGRNv>Qxm(0(YD+mirBUDF zWK(`I-ws(WOxXwKTXxUxdr*=JboD#r`Awn2I3CL_(#Q-br*_Ae2evsU4JGh|xb0hc zcOPAC_7&kg<(8s+#N+Tz!LtNN5?npAHC^o@_WqQ;mDn70?9c}v?jU6g`KArZqhncb zFzLlR7C6YcA zc_VT#W&>es(-=acXmXFWgjk?u4N`+OY zFRKkxMeuwVvK_nuk-fVGGfzb$LgJTK4nOU4^i75s0P>&i(FZabE|s&FS6qV$clHrH zlF|emRDfuXmgj?@sWcR~0Sv8N2!j*Yu)r3G71V^hVK<792&8g};3$YZH0{mg6*{ZY z6H7Va{|5Gq=Ye`&SQh;O#GOHf>jL~0UPf>D7KfoY97N2GDLX_sj+CW(XAgL=7a^>N z<|g6!IO8L>&7{W)vXKQv39>}#xM`)cVORIhSp7=PIn)NAC=wO%XDTDp0RlbT zK^0gc{LI^qM-VC0VT=G7784;4`A_QSquWWJ6NJyoMm_9b-P6E!=HNJmI#|fF(7k~# zp0Y|*g2-`lvZY#ptS<{VWd|T{kVn5zg^kt>ljC08PTCRNi)W*}_G6~9uY7Mp0q+$q zMTu=z!C`^MBFT&zG}r%dlVQLGhZWyuF_cJ&uOu}o1wJHiSzZw;=h5((c(2r#Y5@b5 z-#!0+47mCg=50Y5y_J~TvHxu`U>G<>#`3!m(6VdYpMT%mUZ(&1ZJIN66jF@d<~Ie# zhyLD1|Aqlu{lM!YY!WBUhe5}7&@ux}vdSS(VN_8jBnu4@nKH=+l8^6RgDf1mZ*KI> zd4Q5R)t5P?&!e0*|Dr6i2GzUgqe}in^tu5!y`-z6lQTC= zL$}b0e;&lZ0i+s>13#YG^DtF{g=|Of)2h+btz)7)S?}x7!Xi`-JS{w-zsE0?tDG*u z0t{Lp(hkWy(a~C z0GZ0;29X}8fj#i74vzMzthr_S)oOZ0`$W|`z3oZXbtd{s1Nuge@lAi5=2V+j_@f4i zM>&@Z-m!SH7=URDI=vonry68d(X|K=(OrOtP0VLdY^^;=j4DW*_>f37+Q8t*X*lq) z+%`-3$=0?lOW;Z6H>=5>CoBm&pqvWZ1*A5pnnro+fJt70k`9DnJJ9JmlW#F89B2qy?cwXtXgI|17%7IZifGY=}g_c=%;8J?;jeq?7OA(YRV&~-L85ry6aq$4PoIV#tvU56+ zF|_O}tkFli1Mc|4rgKYcSL>SX-9vnWW9!?x98aDxuyjuiGU~X?8X2FKmQ_xqSAFU_ zC9k4+U|$Z5%AH=;j%Q-K-DmcD$?k=#pTQc zlyZ&I(K9{wQkQs&U}3-Tl5J1asERXtHoJ5-C^omW{`@7Xt2O>f_35%vn9JDX=ZUoR1JQGUWAe!+)3qlWX%>I(oj$-L z2gv5YsKh4FY>qo;FlsI*=8Qj{I`A3q76J4=06|NTI+(*LXns|X z@hN6|dJ!*1isCVGtMPFe-tTU;%H4eNDUz0iJ|2 zMJ=3tp%ueZIqr*Srwpgqby4f1rKx_ovO+=rXL~HapR{nblwZg|YPfrw4~hiN=UeUc z$dp<|ZN4r0|1yfs|8Jz&`uQLC=b`rhrH7FZ9Lek~fq1*l z(Wggv|J>(({dpKJZ;ZCT+;>dQ{>IE+UBS3yVD)~L-aSKLnEb-E`L{lcM!CNGI!8ZA zo4cCA$mUo&dRtus0hb_u+VaT<{SSM+La&qKz-_+@$Pt0+VBJq0n_g(qmipjIvw*@}19V z_?)%ZU)eNf$sw3K+k*E8nS+qS$?0C6EU>Sf@uFGiJb1$7SeF>4gAM91eLcM3q#lz2q*>La70n;fQ3w z2`BaYB0e3)$3}nDPQy$n96Mg_?QvmSzHf{G2#%h%byZU;&YZgBr&EM15!kpr4^xzkx zDlj5Fd_lNaF#vTY5F4!wLi^lQHIzenBkB&#i-~}l`;xrKBkIDa*UB1v11~((@kAj5 z0!;h{XZ5)|ey%#UH)i*5+`W^;wb3VZP=FwDw}MMT0SaHX*;3ZEU@kOKXi9Xb zAF(H6$_fhF{S~uq_s4gtn~Oh*E?||3J^vy<=TIIXrc`-%JpvC4dz)`$Z539GMl@>W z2|wK!yfOmU2wdmo8@9zgf59+sPnQ2R2Ssl{YWw|!ZGUcKprMd79-(=;QZ`+iX~uI( zYNuy}0B}}oYi*$GDrzABu)}ep{Mnm3jz`ZJ+Cl6`PgX^#OGTgIppE zC>`9RQ4vaks{r}A;JHAEe26 zRKb&|GhBjS@4&$@yCdTNw&^DZUJF0l9W(q#`QJNH zI|4^1K_apxyhek1A2!{h^Kfa*+~Zl(zZCSypHMDFQC1Gi+w zm=r!YD!)h)jufxCks{e6AxNiTIulNn$7#i+g6JfVa*75$&9#ThwVQI*E!|}!?mdb! zACMl!q79M>2I+>%>HaP0$J{brpprukQeEj8O1mj`F&P&(D4v1o?jmWuT50aP z8N_`9Y7{zC#vq+WqR_nqQ+#&g5&|;|$`eA%>G>P+7nQRbM3Rfpw3Eu|TIGq8%_(sr zS(ii(ma{Tk4Cw00bg%LhsvAAFg`Q1MX(eSN7c#{c(r>zDYNAsh3rTm%(;q2kM<^#J z_9US;a_*z43mch{yIIQyIlVEI>*&nqq+HI0_-+yU-SIR^cT7ggZf5gFcI$9z)k5xT zboQKn-feo$s9W;*!;C6)ZjwP3(=C4{FekMoBi?|4w9X<1X0(?xM#poxg6MpZ4C+GK zdwRj-M*iGJ8WxiuEt4-9l#iHT{On=u#n5jpq^x&m({~G$TB&%^Y`NaTw%r1b{lLQd z1;%%JK4l%9Ezp}zs3_=ZDcC}%Yh)Gzm_lo8v9f#N^YR?4-Ms#o%p)-+e@T=R8z~%n zIk!m(O$%Ap^b$`@j*&_Me6#4x0>k}Ck(5fIKxSU}UXE8xfxk+rU4`(!pCyGNrP@ar zPS%+jEjfvcsp&!V3)ZPXE5&oO#Bi_lMR|EPWwY#?TZ-usEP0`ny<1jB&wlDw+_zgO zYLJ;*QFcwc;^HFKHmI^)RK=mERIRh}`jM*K*ICA`e7CHt`g*Gd7OU>t|IR*zRyKkBW1vRIv|4f$&h@R@;D_?mnt2p7P=uvv}6)Yvn@7b;kTeWpyL_7x3;_+gRnTknH1FgOkh zh13K%a3oHlitu0-3ogZk#}YWWhnYMawR|?ULRqyiM@}m?L?1-O;J_$0WXK`Xiiw6p zP_74zlL}MtW^$2{wJbo9KoN(^)X4|e!L@1y<7&CIm|M#V+f&>89)^Mb+{zq*5ZYH>94I-ydkpSS&L>Umk9rwmX6%gIV1b`Y> z2aXsjDjP4T!iEGpz>!Q&lD9%kTXVvPW~ypS>VBo51)BniB)}D@Xop}_yCp}=6bA_p zCg6~f+W<$Q;e&$X#DRks*CAyvB?ujc!MBAy#RPK* zzzPKT9(?ml+zn{KjgOV!%IOYhSO;eL26E>nOs0db)ebOF>!PT$!A!V!2l{tAdn5_U zu2DTx^TrjGi9^bQ@oz*E7WRAND3LaCYU1%yAgVn88p*tM)E2M@6O4b~CLRJ}a7ZgM z(w`N#Tgh?lJ*1HkcajCaCJcw;F;GI=HLIJsggD#ft`m>rtoq{!hq@YEZ+E`G(}22b zex|E4`i^`5ordV!4Q023`{L;A#^@@9mo4CP?QZIsp7i)0dR0$We@_l&xhL;;4@11S z(6G1oOmAs?Z+TTOhhr1U@j6_F6YdQawY?I46kLmIhLe#@=15?(Lur2_ZqWTLpHI1)pe zTkJr7CNi7}cb$T#bBrS#pHk+lpSl-t*b`tj03wvY3V>Zuw#cc^;em{OHQ_ z%OlTCBxf%{XU#lkgUx1-)y$qtp0!z-^&Xi$F8SgZ^o5h>3vsg-r)pm48>gLKdGScN z{H)~M6D`2sbMAcNTwu*y@Zj75r@8odjx0HUz-caf&POHAA8?wu!TE%h`2$Wvm0U6F9;`rr9h^qlX?!W!*Gp5)@t_(GxQ;?DSs(wfB<$YSNnqJ8G#70D%=;l(=7 zB_rZeQ_Ye-ZK-W#2|v7aLvr~LWcilovf^;xqTh0#B!ACSo&LYe>eI_`;N@ry--zeS zaZkR7H7}oV^F3a9IW>rVCb=?u0sGu@WkC`ekBY=fA7llCQoRzj{{#zT#fpRR@38ykZZ&`tx+<-``gYl5FVl75G{9R3aNa z`I6%vdwhk>bNnSJwKinDCXloya%D~I-kQYfn$*Rm#!bG#o&}jaK81g4_rqU{%2e6z zRqEAJUMusc;ghgxCPl@P{5n!^s-05b8B~(oau5A`V`7q`?UvHJOS9Rantx7pX?;^$ zxbEkkb7rr+e3OdaOfqvXC%0xgB$b_-cx(T8{c2&woAI{;ySafI4E@%3?n$|}3vcc6 z)=vjj`F>UrI#NK=U_@HK;ms@!immw1H9gj3gK}u0B8(>`a(`7Nb8sVPbtCWJ219DI z&}6gt>}F}wX8D!P1n!NaneS63Du|QsE-UA(i&VT)e&3>zU+11PCh{)xhD!AD>`x0H zj@-)%xsjGs{^88=vJX8a%{=dq{QHnoSaEjsqfU8Ms7g_NQfV*GJG0f~zNC`6y{%!5 zt*j9mCC#MtPkE(0PeDn=*3*A)t$04oYJAdl|2%g??ZuVPxr3j#q~_%Qeg10l<@?z$ zKa#%eUitFt-j_eCU;h330`L}k;Q<#W=sksqgFq1-+h|!BVjGCA-R645$L9s8b^?g0 zDLuy>4%&`bJ{HFiH8b8uTm?}>{32w4zZMXpP;flFJNgA*5ADDDsxkak6#^|_@E^Vk z8Vq4&D4_PG=kh}WL}{#{CQdV--;x(=^@`8li|?=lpu+s7d%$2!|C8(dK6Zq&)Eo5B z1iKy3dU$vj$O&IVz{u!&JlvcO1MuGgZ*&Ef6XrOFHU&)_U>OWfJu=3F0QOMWNIeI% z2_Cq_z*aIL7;gw4LlD!k&F})$eE|dxcAN@To&w;th?7Gg9|L}xfWiP!y(KVnXg7Di z?F<7jqe9fb9y}(rcpENEKnZyR9h$$pF8xO0L3GD2GbXH&7f@pW+ISS^;QE{Z+8cuD zKpflNhWWs73<_L%8?wy7*ZgcZZ(n+k^VLl8rM>c-d(fTlO*eO6QvNA1{=I#*2lW9C zDw%_=7kEvuvIC(6{<$yxuk7j`5{m$K;4DINrLKGju9jWOZ+5vDH!e%dIijR%fJ3uz zW|&rVDXw7Mq8KF#QC9ZW_7k9qT0XUA`Z&{gU#-(U_xHISX07^@%K+ z3WqY-r$;-jMkpksP`=GsC`hmao@Jgz4 zV+Es5r{1Ne@}$BM%nXeuzt0w+k(378Pab}cZ=I``U&%~^7Qz5$`{yr5TRft<>*goz zbTKeroi?~RAWw;LoEAYn3TmbW`+u9o&7bQ&AT?=as-}|?uMhGT!$tMDwRI7YW=gOa zDk!&efL-2*t>!7XI5nI0L4j1F(`?2B=y|6jnU6K-{L4AM3h0+P%3)@$S(=A2f^KM= zMk?~+Z4oL=U}+Tk5?3iot;EcjrHH1U#+i$1@ySxAX{zt?a?k`ut#Y&7g%AEG_g`3c z0C^Yw+x0VrFqT?xbe_sA?@VQC3RVRxHa|CZOwaQavuP1U7P3=w)V9S{R&vc`VgXt9*7*uS zCpKa;;zklqjt^ho@_6p71U3As>xPp9mLi*szT#c_@U2!}^(SZoN_>~iDRMTT?n<0j zW7f$&F9;8c`YG~6Qb<47Ul*rL;TAZB=eLeE#Q3YR2)8|@|1k+b88G(GU{h(TwdP;t zBb&E#^Kto)PyNy8HWRu#m1YXPFRiu)(;x6bw~5fjcLm`T$d_T4AbQ8Y&*rr z2Ru-nG!j>|Q>)u=Ty))?7nhEo;TI*6&fkKP3UI1^6_35sbw0Rjo?i%I)>A-NU^~|~ zLC1`?$T(lu)$eoZIdFyuTg-O8Xw;dbpOzAI?gC*1XBRw4n^Ga8l~2Qk2?_OmpQo8JwJz1^xUM zUO>|j}(N@lcE2=n+vjLVwJ#~!UxY(^=7<;;UE=I@(1$%Xh<$Wi>iLB%-SK!T z@XJ+M3d5pC1e5M8U$a>RVY@OP+q(;e0*@?-^a4|2!beR^~?E&3lO_?fqwZ%qK`Xah2U! zog#Vna^7fmA$>j3zS!b`m3+YZ?ixBA&qOVO=K0gEu3>Ow8Ji%EGZU2ip zQUcDAsQKMfCiFvtw&;eKHy?}C4M0CZ@`J-~90llh9?whk>%E>}Py3HFiKB`wmSPHU z2YnED`wyO4Nt%b5fo;b=MZK`P=bh9+QBh)pC7@wBDvKpGp#k$2H^*k*K=z&!=$t@1t!96yl}FXo*_nL_KoMCaR0bQslk&?-Y}gX38~~iqQiEAyU=N{$HT>$|_aF&to4I8l{_TzlnBE!MUD1j7uzs|wbKl0-)nB<~plxYx6(nBek@wP~bgEWUk&5BJD z$lNk{ZtWzkW`TWC`ah&b*a+LSNKpmp|}aqVM`W@vt+p-s{b>qtx%`?FXXnU%zD{CE5ORvNNh_H|nEQ=*77=2Ywd5 zll>GicyYe<-;e5#uRb%KVrF2_0b+&cdXm+pWtF(|{rl%PGA>_QQ8K&m`0)If>w}lx z>UCXs8a@B@4kmWh!YpR|`aJVng;nf_LtQab&GX+IF2}C5c$HT7JPtgm17BF%A z?uDR@4CbWQ9oye`?t~Q%e*E;_s>^0MG>-Oc*Xys%W*aXT;?Dca{o8t$vc)Vl`y|49 zfS~+sZ-*1InvY7NWO@0UZME$No$W^3<~&4S*Eu{zFv3u%u`Qy2mz&UD{eXvgB%5>s z_br>3S4v^4wk?lz{W?k&(xjGpQ`+&}9L4UR`=}#^l;TruM89qly<13=wquJbB}S88 zp~`p~3HNnlb9$85sMR%0stTQYVT7s}(M>Sv!6SP#=V-EPH1(V=C0Bv%2Zq~PTixoO zJjq9`KMH8twZ@7jG|ab-9XY*w(tA`RcrxE;!k)dP2=ku_=9(tG@2Wi3Uaua8z(XtvT-;$ptrwbOadoJcp`H^-#hDxjOK&~07KoDy4h z+tW4uXmYw1CUYIgNBzgQnl$YDj@tLSk_T*a`rYn#d6w}kFZC^4q)`(Gg3|jBi4Ayf z^>bbNPk0swtkKVW8_0j&A1Y>b89#Vdckmp2Fv4{((r+*-VlX;!@O;kTg|fkz#=(ny zgO?@-V^;>_z6~;&$RU>4P&|2vtvi%JA4+r`O7a^@ju^U}IFynzlv*~loz^&XrEe&G zV(9A1(6w(v8OULd*l;F!I7@dpn?9W5I-FF~=Tg=mTQHF8H+;RZN6v<3h3xTtZHdSk zPMftM{kE|y8}=Es@x?!`Hh6sd(f%K^6nC-S{D{X6$bM!Zl2`3Xv(X(mlRdx~wGEb~ zSC00X$_^CGQn{|SL9ZY0jd;x6vZ+~nd{6W7-3Z#Lh~aRgv1?+_>9wAFx+6bdKdvLw z+D+{I|JWJH_PV8ybgT{N*pCXGwm;A~5;oW4XK$OO__)8ee{haIN+0bm)USycz5KXuMdEyu3k+x5w!n8V%n z;St@Zm5mNXk8Ej=x?Z2QgxTBmubHpRIm}1UZML4guy>g1GdDGHoNlxqxMaTU_q3wu z$&}c$|HK@WPPcs^>?icu&gGs*AYm8#=Tw69K{+-d2{!~V9m@qLW_hxM8p$-<19k08K3CSKv`9Nd`GCfVL zT@dSmsxgrAsj9sX2_DGz=o*+oZ5l}VK(Yq{KoIDGf-;Z+g7gosZb1nB6hP1iqC5&J+Lz*!_Zm=F z-hHxRctW5@-^AkWhtK1avkVD#Aplu$YB;jN^;2dca*zg?5wrS##yFOZkX z&A$zy7EU(v#@xaiNcQOH8bQ&S-HueKZDVH1fEqRsjDc(sgo9GAW5uD)8R7m zDqg-p8Cln%{)(cCmWxNwE+zH$&fb`WTu8n^Sc$Br&2aUAe2?3)(<&PK>bOnLd7)5W z=jqrK)avOS9Nn|utg~kj5;W|jE0EP;o{PSuXGDj}El{xK)JPU2k!I#!9}hfVS#ys+ z@e+bQs~=GpiQNbNvv8WWlWRAJGwGlNPk{++4x3&#vkI6^s_vdTL4m7?9c z2OJN&Lu$z+*d?(03RG=zEj)L)C<@B7ob115mlAmDF$c=>KtY|IimHXoJLN|_eW7{{ z6wiTbFed`fgq^!^B_lg@Jdb+L!zRJ+L~C;2z|+%_NiOckhM$b@_p%Qj%7|lMCTVMx z+^w0IUdYV7860x1v85yN@>NFm87TQ;8Ru;rcu>h;pPafnq<^3q%mKQS&%;E^Snt5z zbaST@1{NMRhfZo6GgS3dZTC?QF^i@ll@yfFkkR-hJfpj^p*6McJ(Sjg%u#aH3e@EB z4NZc?kn@9_{U9v+7+jF~Vz>5E^Ic(HADRZU)m@|>u7yj7!iZHd?;~3z>OIb@HUlh@vSvCf5%4ZxE!%tO+*MCo>N4rPa*r+TF`z= zXk_;|p_9b#9a)Qd-`ARBL!LKA zTc|fov{<>A6-bhPJm4i$2NxDD>Ac>swD?!`~k# zj)+Enp35FZIx`uH1XV1YwtAZwup){Lir>LrLdNUzU`oR<7?z8WUMWi2u}7Xpp#Y#j zB9S(oqOpBG9to!hlZ;GoLRc<4kxcY(I*mmW_VriO&njq4Q>BW$lYLUUlXS>7LIEUD z5?PAU9+DRj?lIp52U27%Ahm20`}B^}k%v5onIZ)#9E})c2Ji7}Z{)CqvS&Q$0dRsa ztO`PBz68aSC#Z}0nOfkYfFg`+_v4p{!}#zu_G&sK(u;9e+9EwU;iD2>_zVWlKPnnJsS{ z)J+PjvSW{OfTBWcGChnV$tDrHYvI%bFq~H!3kA6_EJ493Dg7+Tkwnpyw%G>2bQW5d z@_oPmr#CQ=xbraTfP= zB?&FA5*!RUG4tWK*CJGyCK!=%Cfb!$h~({%jjzrS=__1aOE5yg9WXjRrIWhWwug+0 zB!vx?94-5LH@XA4K{ii0%6ZM^HS@%^sVf@CKEPSo_bBYD5ANXH zhp9$*W}*c3Ps*3QbZ9lIrWC0kila62EW+1A+7rytl75n|zW~6~7<_4&C^|cdO>Z>@ z-M>Z2r+es7UDxEAf@(@epxl1IOHv zLeMM(-9gHb(AnKsA-2D%WFJ3eCpKC9%UgDz6z=3sY{A7fYb#N|j0Y4rbC(zX@zt!C zqXIqVJ2AH=61?4efyH%KQ&Z4o*YDBgjT46xtlW#l$&wO2a$K+WD@oI}0;ke*DV0SC z6^V9IPh3ji4MC!xF#V-)JUONEFGuxyCF(2rp{4g8xH8BplzQ7l#_G79noR+ywKVzz z{v@SQt<}G;M=@-5Xpbz@4~(r`CtRF6W;_nyAwyV9T!xcL-jX+LQ%a=dyx_muNrKE@ zWy-g}3z(F7fV&@<>rg!J^nLlxwlYr~OG2dWcYn2p=5>p9t|vY+O27AMpPBRVQ~AQ; zCUJx})W%DoeMsLb=mUQ`-)76E_vB%U``xeC6H8=}?{S%%OxiS_W&N;C-q!jy<@Tp{ z*L2ybQUk5E{_|hrNz{)AIVkdu;%S3MQ~95=)*{xc*0uN*$%EFtEPx7#U%5y!Ufje5 zKea6X%o6Ym80uTXkK5_r#${xf00y@?EzdeWX;pN;g026k+WgByl6n+mH_nN^@c1v* zaf{;Feg3d0Q)KEChO!Wf|D(ek%!u`7iz&~1Oaw1I!KBvE30~kE#YAz36v^qvp=xhFbIqU6!PoRLqzA~z)$}EYd=AMhJq9qLCs;Z z;h85-Spqxv!b4dwGzA?@0ooIz)5)wNo|Ab#(gOn|M6sg8Fwv@DtpssTevrud^N4^1 zoO24o2j#O35KpfrhpApJs0aYKCtdUs3%>ywIx&acf;%ukwLX|I4PJ8x1Fe+&DGBnw zT#9$>jO()^@UZS8_(%F+2$$(g3j(dyu}F7C#t>*eeWeQvFn%H3Smv#)q}cD~XZnxy zG=f8w>GqcC#=hx7K37JrgZv<{b=5;34Ir}i5K=sBb_AQs~M zI#$3p8DOBUtsm_czX||wAV& zhCU6zwK$o`%q%1kagBlw0R%F6X^K_WGI^=t4ARnbfb z|Mx(kO`5s|u;iBTsRb3x!_zs)LT|wS#yKBL4093CBEioK0Oz;}ECxtrB}M#j$lxI> z_hzGs$T|)xgM~q>rd*Z^xyokJm)Gf8&ja3n_p)Dy08k)NaY5R5V~9h zQs=t#@(nogCc@`Ne$|as>+I42Pi>u`F2ftAUSQJ~flzK5Yr7R3yiqu!2F6@K1xf~V z58rZZrKb83U(z)nu+Hu36FE6~G;=Wz+;gW>1X)-F6JJFBxo}7O9=rz`FTz~9)5$Ip z;$mzWFtkyzm`AZhO!1DJ#nNrXvNOdyH;YNqB??9*WRDW%m=e{SCF&n`!uciIn}SdxH_I5(<<@N_ zt{&xXG3D+z%RSo4J!i^~Z!*ihrSJM0-SzXh8xV6h=;qzvw!5ci?uKmM4U?`2H>!y6 zsECTGIDfMurmf=AOhw#g1xvb;ZB&`)QJEZ5nR2r-t*tVBd#3W*W+g|uD$A%U$5!@< zTUGwes+%#p4$f5F-mEH;t}ZdEF7v3q8&h3*v%0#i+M=zhezTe@U31@vS<~oI(-c$F za)28P3L9}PrA0JS^~zXg*#($d<4p;R@VoPSg#d!t{r_WCa(j40C8Mh^eL`R zNWKonAj!>&LR;2a@wd)hFFh>#0w6XuLf6)KxG!3SX{qFs~@VaMaDPmT!P_A zL;+I$`wwE1F?Gz>n{^}@m$--fvzjZl#6{T=l}T7(84-~JP`tesya@jDXux!Hch25J zEH!MDGz?dZDBy`wvvuSGF-$@I?slTaM^I~f7F4z)?jHdSN5DNgfLj)KhaDHY@ql{t z{yq)x>tj&w$^#o2t$t|~j)A>F!j*XAaqWb%MLhE|1^1K!?SJUYVYmx?#DX+%lZ7wk zqMJDPgE+VwoVqe^-1>*Q55dhJY+*`a0GR|63qh5Ecd~>$rT824e6)>`?_hZv>_Yvy*cL)B(~K5p5J0RKfHHfC^?oTg_}+ zgA8EA6)35}n1x~b)iD?l9vKEGabaM)02u)Ed5Ed%#y#F}O8}w9Rmi&1xG@99j5Nn- zfGmxcgrhB&3tFyh&$eV7?cmsTzWCEB>a1Xl2X?dIx$QVc5x2#-t=$gXz21>F(sES> zsPRUCBdr&&bi(&^wK1=#!k60RNnr2ADj>Z>SOoa*Pp7jXcSWP_@9aIE#v==$5m;;= zH3n|Mf%q%%m9~y}JdkMD`R7#gq{u@Y7kvXD@Hhm)aYQEv!{ZSKp-FfQRSBY8ScEc` zfC~uZ6#>PI=<8uvH9U~a5{Mijl!CZ7#tm8cduO;zqy$gVgMmPdHO`+xq^v6*>` zfrqouqv|j@E&zf^cG#o8w)bJ2{-cGkW11io{)yt@L>U;^#kOz&h2a9?T$~xuj@Z^2 zO1dRH2}s41j6|+whktw2(MDaP1J!t5v-aoFw(AQ$sY_^Czx=VJ(I>A z1fN0AwSF&IF3P9H*M0!{vj!FpjH-{Ew;!=S1se4zqV7G2_C}h4$dDo|nSa`<2%cp> zTn}$(F&WUg-{l?F;$GXIpEbxOKgsqSMq~~b%njcmH1G&dhqn+?{L#O*-7D&Kv$AmT z4_uCgtz{r$c-=caYt3fWnByJ|Uu6M8d!zE~1O9SPIkyX9UUW?`Bn;a@=>Ql4IEDbg zz?~x1CG-1soDt}+CX{lnpz65KZR@3=ViybamIacH1+YE?ux-`=N*;z-#4J39BRJ$< zd(d=zoTCBm1Q3X`Fa!l#fk*0TDII6P)6(lzyfLu#38wk6+S@=+z6s#XsLg@kfdW=v z2UG*v;XXdd@u3dc8&#Xo%agp(M*!($IXdG#v#@2kbS7XWuD`_}y0$5-ejgc@*9iKGMoi|~K77kr-} zfMqp7l-xS%W$-da{xu@x<@K~hrMOp`(9!Dg`@4@f>(;#{_SE9n5d$8~y6RY9d}byR z4>ZIs-FtOU;BO;C<6#nO7QSaL;K1C4^}41o>~+qSgR>}rfj@h+_Bwa2n1c)ABXGdG zBL`lcs^iMox1cx1lM8tAj$rjRP{(dZ!sTIZ^;|N4`LHG!|L-~94wU1ezBnxWxCQu@ zE_{z&@D&A2_yRc$CJe@XBFP%YKtWPXP>uu}sKyEJ1mwLDa(tLRe=>G{`Vir5&V0wq zj@qR9<&^r>_>t9kN2sd=vUg6SikX9R}jT;hdc>JURSVWid@i&0}@mJFl+MvvVwZ4!~0|y3|Ou^T6+_$Fd zVAk^5fAjD?sP*lR_r;&yrxBdC&V1FYgGU~%tLdC^51BwLEn-jAx)kBUC~&{0FDH(! z%kTceoOSqe*>PRPYen`p_^Wze$qcmQVRm1HtLwZuxG?puvz8hLyn6N8G6eMA{lour zJ8pKy$*KNW=vaU7K%nq4(iwj6U!AKEV8Q!btjS-%tw(CCn?1QVBLZNY>MwB7gi**f z69zWN!k&%={uz@H1_l|p7LGuw7awnikz*EhL8-V%(oFKvA84Ofx>FrQ7DR-_?MvIXd20AW0k!sP5&tbZ00t zJjqohLOlsWW{zY5m$&ve_0G)q-Eev5c-u7gA5z+Nl~H1ZpgZ$XO5Ofd3`27lfk`EH zCH+u%>6+_7AWtkY{M>sEd#7jzbBgwM{LRRfGhXn;a2~vdBo?e&#_!G;6+md)AF&1D z*7<0sp?J08UO1MjoT<9iCxZ?ZL84;@4;triVp%ew+MzLfM;XM|igy6&01_gK5=SLUZ%(?3JKmo5CH zEltk0y&2lu3YOtTX_(KVUYgD@|Ho+YYJivLQ2Zl_Ju@p(>n&h7rx5h$!>22C@V{FM z*!>B2r_YpKUMaZ-vX8eo9dP=f5!9g&fdy~?Y{$A8aC{ZW#3l316@jAFB0*gf`S|^L zaGm$9z?ZhOC3y;0nDwgvPe?|NR*lb7_E64n5jtWnl?r(Gb6gI}mxJ~2Rn&FoDQ;5j zTY^ZrSWX;)VD!<}fQ8)YZ3{~lLg!y4ZO5Hn#SZ~P*~Vq8sx9y=_+h~)pZj8#KuwG#+ zle7Jj|35l1cP<}@d0$b{$&C5fFnHkNf6ebYFMjUA{7>{+rR&nyXI5sh-(LiF#r|Bl zY!>(XT}4;ipU;D4%)dY1buqWMG2Q^mfC87`voLgTxOfW%tu4<(#$D-^8P*_p~x2Voj>@O z&5*uek^x;oVl2gq?kkepLRZ!wOLa>06)QKeP}qJ@3E4LWRz_1}*P{DQ?EKzhb>PQX1{3K=LK#}qB*r3$00 zt=5+M^n$*RSQGggL&gQ6?A{~L#?)M|Epg7 zHT@0kJf_C;n*02Tc<;g7HrKoQlT}Vh!LH>-hpOEss}J`FyFF|>R39~2^+m&v(|iTt(i673Vp7r!gPWC0s{jBlj^gl zIt&b_kqiu{63(5X_gLNw+@wElT0GOyU|{g(XJB~yo`KJ|euloX%F4+%dufrEm*)~3`cOkV zEHfLswFQurizzHF>BpsDYWRd76m<7mxO=;Wf5c2p4IUp0i^kxD$m!WlZkX4$ z@OYozX!kztrF$f*nas^AsAg>M7L}jcNYpVfiHVI%N=~zSW0#tW($X>f_$kKN-uVZC zApKayCpN+5LwHedU+><2``UV9d8MfQOK&cxxszsxt~{&hNt3;%C@6Zzq(N z$Ci9o(lKy)AMzafTH%?-`_wNLW8?OY&aYovCL|_b=i(KY1{D+()Xy)NIlJDIkbh=i zY-nv4n4I?L=`$A>I3lUiz{bzgH~t#?O}MMOjFPIl2^4H(DyOKlvcB`l*t8M(cjG;Ae@$W79V$ycA>J$$LJ?q;d`*1<3Y?plRZb1^sh=#kmnLZi_jpvq8s zVEpz9tn&0I-Bxmv-e7c9)lgzwI(Pca@4s93M{zSS@P1Z(`bf`ne02)(h1K$%;s&Om zMG2cR%ledQ_3>Mi8WRtb%PmNTc)g#GAO2CBephH=I1IQL5Vn5%dYoEKjQ2@7COJ9j z8{6d#3D)?lXe>^0ShN6JhIF7EcBjZ#TlFZwFR@iMv4T*FJ*h|76l>xQ(!mxsWgZbg z*XKWeqoG4?%X@|sN7vt`VgXa2=XP$ANYD$P0img7pM`|=@zT8`_oL_4wLa!idxnnX znuP^{d&e7A_LKP~hLrJ={R&0w3sUNrX@sQH5#n(81twQGrx3vbyRbh&;|=ml9g>$F zJ5oe4OFM5Hr;Je%pTIm!6<&Zk0fC$@f{q34H=bCq=`eunQnEul*hn9v=O7Hr?OK*_ z?weUn0;guv2QA*j3|a@zI!Ym#71PZtc{5sgFV^r3<&RhgRR7kj z5+Q$>n3MDTZX7ilVDN3L3^qhlA|OV^Wg-L_L}NmZLHnxP5&ex4A#s)cYE4t;od=vnK!v{0|B zX~&$-Jc@XRzm-iI8Mj;dVLA^Hl3M$aF(*Mlef!OtQ)($kOiV=eGfsN7;7yR|n78CVIMYajRF0(ZE+0n9q!(4yyKZk_njU44KE5G= z9Py8k`^zpZ@!J7AsC|nps6s32RK7Gs!DE<_aBZp)7*qc)cd;3FLqqq%3qb( zEl}D{hp4f83=vvb6)x0ROHR~~=30w6cZSr#xU2{=Pik_L$QVmp3bK`4Dfv<1*uuQS z2NB>saw*BPGxJGCYHtoozUh zgDd?H=M1S|Cq~jChqwlDWhH-Fv5I8m$;(Rr$b@+kxEs2(M z9Uo3^3G0zGjX&|RyD(zyvCp0MWm?GPv3Iz24U2_4Q#G;5cODG9O#ij zCMg$NPkusJW2h_4c{Ni%7FBf3N9+r|Y+Y+w>wTt&^&sq0x4gMT760@Ti18nF!$bQY zTQ8VDPKBWS+i-Ta&buLhy2#_%#2{WLrh19{r9*Q~p=fj_pN+0n&3cl^cWCxrAo;`t>9!0gU7ou2!*kzw-a>%`N{31+C>*xLJGU?U^1#wt@5Ie|C{mYF=Hvi{SIgmN}gAK<#6=CM%z6%1w4;bE=B)nH88fG&QMo&oUACxa-SM#wNE2TN za_uS#*rJ4}@4rfg{rB3FntRN^u-s*#NTjQcKCEfXL1o3w@;cEk4?Q|3Ublf=;>WlK*_rLV072PHF;6nu zZp{O7gz{eGu>&1$BLm){LR-PA^X@gfR0JdU_IjIDg)xNUXd@_QruJF1vTxmDU|C4> zxwlzUA&J|CK-Zmh9z7AJbTQ#{zlOgOfNw-+6_V$62HlpJf^=s#0l1mAkie)9kMG8U zX}BFjXeSX@pdk-kGBswG^%{-1NDO(hsTv!|41!i?yl{QGqe{WVV_eCkK=xiFPS~L* za`SUW2(nTT1pRCr4Nl^UtGoo`M%ilsf@q`rOzR3gJnlXd>0xMTq-Hn@I5qqZB5S9e z2)3F3SUvY+ow{Avk!q5O|6&RTCy~Iax;@A3H(xL?_UuoTSxJl{N%;ej&2QZnoeq6oxU$biJDh7u&>zvV8Ou%JhIK=8_P=Z(VT_H z3*40fj&x({$v?$f?E_i#HoYJzU~n6La_2{!fi-YIbb7 z<$jQ73-Wl~`=CXeGDx}g%(d%V)zT4|+=-1hkSZTpnHzDa_t|N`+>BIWTb@&5GEC8@ zsPQ9lTz4j{_gqOE!DPmra~0_m-SVC<@K2CeQIS|_e>J@MB`o$T8VsHf(R$MS4Odb> zuZD+SuqEj@yZy2=yEXogd&m0^;wMCoii4H#d0l09CgS8S)Xk-+YCS)w9b}Sr{&^YB zUfFnlX^Ql2=LqZqNR=^HL#aBf&Bxf~x&wx)|2n5JAqb;yZWF8io93jl0`u8n!+-S2 zd@dd@CLe0UEB4J9GKt)HXTphw3G~vh%qzvg~eT!|Sy+OcPE@OGK!;K{gVbsSf_MLf)l?mG>CJhZU z{j6Om6ZXQw9yG{~*%b52tqk_KEA3LDsT?2V^HoRM&Y7_wK@p|&^X|+NaGMuheF}ym zP6~Ihi9GVq2H%!>mhVn=NBtX?HeDriz)i$tpTzDH;C0a=iDWRFqV(($c+Kp_R4`!y zcTwwJ5{*(ld>6@$ctbtja9mAiL-EMYMhh4Z1R2cpjphzw(c#AYJ6={pF(FM87=m3| z&C8CZcqz+i**l!d<(fv9*;+8-xrB6Fr#LdK2M8c6=yZjE$0Wb<`6Uh=?sRSt<~D{O z7`=%Hm+0mufu+hJ$D;u%Aqp&3JzK0#G8i?Uh&t8Tv`~hJk!hj+p>P{&-Voa*5o%fo z`U%uqsP=sL93K#w?lQPVKe3GeU^|(?%%x>#UMz&2cNZ#eXee`U93NOhK~7%VEmtGc z@lfBpN7L5aOd+!9+PetuN>T=}e&fxKLx4E4weY&6xO!Q*2^?;tv{UVf*gVj&K`m#D z=xb&DJs;d=cR<)Uq|off$Jg9Z@La9Ex>%Eh2otkI3g{s1heoWSa#VP$3Ox5Xo|$)4HJm&g;r|+8BeQ*ieszb*=6od& z4*}Qt=0|CLqU;l6N88e)m2GtR{Ow-kOKU?pWLDYxuiq;y{p^;{bfIcqQkUG~p^m=Z zmMQFo1jzVyW4NR7q5TenPe-`cv@5lmZa0Y6c;Ax*HeKtE@cD_AdiuU&pWAx-^e0t0D9rORrit*))w*< z<2rCi(3;}Hx0N#+?LY47vuEnxM;gS_cNZ7Tu!>;|)I{yt!j%=hifvlugmw2G=(mT+ zU*V+;_|3H2y@&6u5bXS-?ZTD5p>}%ko&S$Z)PFa=bk*de7eiX0Y1G+0P>KenKdfh} ze(G$o{2eNWNcci-0~UG`2fK@J*JyYKSC)0`7nu#g))VS)OrzFpz4%Vh)lnx4YpaO2 ztRdgJVPp(rxT2D}K7FLa zK@&(zW99u>dlx|W8%zm{`=xbY5=02!Gew=5w?5nG1!QJ2_$lSpjb^4sCV`%Sx$t;lcb+uB&+ zR$e!^@&OY$tux;8qst00JvZv2g)Jy3v?U1IK|$!vG*xpg-`TZf0TAV9)6FV}p|0%7a@m3d z6<`tS!CsO|Al3jglob+PduvQr$H+T^RJSp{coE447aRjcb1RR}lvGI0H%*Il?-#xB zIl{3vW&kglV2F*e!owgMgaU(u*VS27PvOLNe+1MlS;^C$ZAuo%B5}=krT83FtFNxL zc!s8Gkue_fnRh(~7|rB8Ct14tb<6-QQDy<4sP`FpM2RQDcKOC$X1QxKLkZhWgQ2q9 zTEjt8n==W(_s#EgGtMPOj|o+d@7#}(y5plJ1diG~c(eQ@3=?OMdUlC7CmLK?KDoP8 z#VSSfbSr^5puUNv`H`_J<38!^{?)aDx=OgWJL;^aSAhB?9q;RetV|JA^f76= zIiWaA&cF;1KV7nC-M?%BBn>ANrBp4E89!%0%^X6NneF^JPe&=&*w`M}NT(QG9~xIE z)psmU+SEB<>PU;}#92Vt>J9=tYxSmo!S#Ips~f!QRByu;WZgya_u--&Tng5C?HxvT znim7w+AQ;*v4847pN|4w;#E{b)nGbZE8_{cGkSJ`nWaCU|KHQ)k4XHuKka;h*P zM941R%xe}--_Ib@_FirpWyG<~MR&*&-4v*5chN>*NoTIvT-IAq^tFn?qsc2yW&!SvCVbZO4bzU8o}R6lYpTU`F{6 zCXQPfBWMH0@nL>y3Q8fDxP2VEwZg%Khn^x!4$u#$W@oB$#24P^noM_Q`9QsI``5-r zMtaia!{NDQ*{=4{oEBEKaj{@7cHo)b2x#d>+Agr}NPt41?L(>I^OZDFwSY*V`5NRb+MwYsdO-qp!J=G?>t^Rj$9? zsin8p0fOd5M82U*jBRf3K<^l-!y@QetfHM1vNPoJU?&FHmVxT&BEWD@3XZQMLa);IDe-tW}d^gXY(1tU)#BtIc0pqcek+A5OXZh;ByCvNxhZQG?U6J$teXtiTGGO*k zX}M>X9qr7jML|8uhIJJs=>b^txa63MJEk$v`vZcqG$4LK_NSjy96;I(UQMn`wnuZZf9xSp}GbwOmqusext=E z^ZRIrLrvP~Bq=6qP3zw1bV$Ea&)L3uhf$VwgnbK3aavMCQTXY{^nw3j(1}zHfC=r*HEAor6U}{!z`2IqfR(?`haS??AtjwEy1$ z|0aw<;#lg3ds&^>AOGI@>-|eG%4xLZYsLh%H+ZCSfA(?q!CvIR1v~(-y7yJ2*s{23 zce80NNTDJpASHD~K21b^6v1IT=mIrgGg$u;dy+1T^gB?V4zBFtbH6F(vu0{&T(B0( z$T`|?>t0h^)+dRSHCAea2^KntS!5iya!Dd1kfYH%!jKVaCo>5*7F3*&P$7<#wFPA-x!N_%c`BIYg2g@6%_xRdxo0ScmT&IhjCfau{|ro91!eDSk!T% z_BQ!Fa(->TpW(fGs7_f9BfMfx5A$kc{oPTEXg2xtX{8;J19RJ`fVje z8;@O)pB(s$HUF;EI98*fNSbVXR8iq-d4DEiUYX0yYSqfhM5jnGmsdoHhC=Ta?y30>I&aX1xJ}xp3Pw6 zdVVkWx@87AMQ?p8Qe%A}V0~mhOqgUD#-5EIm)_fm(&v2com}qG+Y$+tS~BT^e<>fM{18H`=5`2d}dSBAO9ohg&nr*Zr8Yj7D|qE)9z1uALgfXqAme1tJnl$rUMo~G-1h;%cxis7WQ}SNvCSt=3;5NWg2jG{Hs+0zA6;*nS*lxZu_a` zJH^+=Rt^&ff~tjyGWBe?MXpOmo#=0QP1bfog#5VKm_D1~DGe>bH0o+YtsfELAI2R- zvz>P$>R&sM|H!4G4?xHrmK;9`PmkIQv>YfB+zX36yQ;9fqRonNebsxSWh-m0S+%;D zhF0l?if|5c`g0?T)brY}I#>ZrrAUWaEWOuRUSGPq?KSx)D><=1av?}LiXsDkLjxa_ zn|f)?j`!DSISC*&uU|!QdmJ~Z(;@P5qz$2@>JLJa#@2yoOq`#;pyz%!Q;UjrgAb{a z15eSvsxL;{GB`d#m$mOBM;qhdHnzvo3vz)f%Dr|Ry@DXN+~jdP_suSqQ`flm{lkS} zf|Rv>UX|aW25hH!j-J7D-?(g@pkG@WD;evhf(fbC)!xQ>QZqj;t6)QUc;jushQXoR zP^R#D{mNXcgiE~Lk(nPQF;^pQ$VfX`mUwD6vs$y($K;BaZuQJE4P08(){c|(gr6L) zuKH^KI5Hh*U$;l_t+REpUvmtsqf-=5{>34-6wz6W2kr73MMP**az7oJ~FoCGk=34N*CyDbM%-6|%aDrp~LxS9A za3dBT>{Lzsxby~qC@#4t)~r7Dc9vI}8=+n%nY6~2YR(@mCZ~Sblli?i^bHPUZ!6@K zK(%@A8iRDrwe?O`XWG|NT>L&yU9S&I7}QgU*39~C$fpyVIq*xz;a@ri2XBc$yhSVz zHT?i*3^}!pjO%T?Snl&7ZVd?HD~Y>!8QRq-7i z=*Q;Xw6GUy#pACZzsm%^mR0)9s9OB2=0$~}o-n-(Q@Qwr?w*ZBf0fa8_8*2%-KN`~ z*V;XCCsiful4HI=y3F@s-6=h`uo{m~9K>0@djy5y)~Wf=Zlp z8-sLm0ME#jHfOG@)37c*PsNks%j2ZL0*xK2Ilu6hd|;LrBNweHYL+*xs{*$vrHgw{ z7l%&?-pL2s0H(A7o&NQM%CMmdE};H`&aZXmJd4RR)C-5(%#g%F+<#8bc46qXnV$Q) zx!o)G$#P<=1!*6w#NdLdrv(I%dD)d6cR^6uEN9OwK8b%~Dw_sQrdw2iZqb81VLw(u zYTCmC-#QPW~a&WM8bY-x%u>!#D VZ1ji)!@owWDrr6~di*lr{{SxE34{Ou literal 0 HcmV?d00001 diff --git a/artifacts/verification/w6/120x40.txt b/artifacts/verification/w6/120x40.txt new file mode 100644 index 0000000..e146b83 --- /dev/null +++ b/artifacts/verification/w6/120x40.txt @@ -0,0 +1,40 @@ +braid cwd braid-integration · session New conversation · branch branch-1 + W6 visual proof + + Fixture response through pi: W6 visual proof + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +completed fixture/deterministic · thinking none +Braid starter · runner pi · deterministic fixture Ctrl+P commands diff --git a/artifacts/verification/w6/200x60-plain.txt b/artifacts/verification/w6/200x60-plain.txt new file mode 100644 index 0000000..a7e8807 --- /dev/null +++ b/artifacts/verification/w6/200x60-plain.txt @@ -0,0 +1,15 @@ +Braid ready — /home/drew/code/.worktrees/braid-integration +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: W6 plain proof; status: streaming +event 4: effect.upserted; status: streaming +event 5: application.shutdown.requested; status: streaming +event 6: run.text.delta: Fixture resp; status: streaming +event 7: run.text.delta: onse through; status: streaming +event 8: run.text.delta: pi: W6 plai; status: streaming +event 9: run.text.delta: n proof; status: streaming +event 10: run.finished: Fixture response through pi: W6 plain proof; status: completed +event 11: effect.upserted; status: completed diff --git a/artifacts/verification/w6/200x60.png b/artifacts/verification/w6/200x60.png new file mode 100644 index 0000000000000000000000000000000000000000..fb67e4ff24c863036422bed0a02b94cf766b63aa GIT binary patch literal 11536 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

Xg$FTs{5HHp*V|WJ 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 literal 0 HcmV?d00001 diff --git a/artifacts/verification/w6/states/active-streaming.txt b/artifacts/verification/w6/states/active-streaming.txt new file mode 100644 index 0000000..95b7809 --- /dev/null +++ b/artifacts/verification/w6/states/active-streaming.txt @@ -0,0 +1,24 @@ +braid cwd braid-integration · session New conversation + W6 active streaming + + working… + + + + + + + + + + + + + + + +──────────────────────────────────────────────────────────────────────────────── + +──────────────────────────────────────────────────────────────────────────────── +streaming · Ctrl+C cancel fixture/deterministic · thinking none +Braid starter · runner pi · deterministic fixture Enter queues input diff --git a/artifacts/verification/w6/states/analysis.json b/artifacts/verification/w6/states/analysis.json new file mode 100644 index 0000000..031e294 --- /dev/null +++ b/artifacts/verification/w6/states/analysis.json @@ -0,0 +1,277 @@ +{ + "schemaVersion": 2, + "state": "analysis", + "source": { + "binary": "packed real binary from clean npm install", + "binarySha256": "abbc7d58b48a991469ec8dc103bd1e9479d22466f8ac77687aea24cf7b77a562", + "tarball": "tangle-network-braid-0.1.0.tgz", + "tarballSha256": "b1d5ba5b3c81034647fe9247d94a6f4662c7a22e6a501f6f226c36208e64d046" + }, + "dimensions": { + "columns": 80, + "rows": 24 + }, + "terminal": "node-pty/xterm-256color", + "provenance": { + "renderer": { + "package": "@earendil-works/pi-tui@0.83.0", + "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": "braid cwd braid-integration · session New conversation\n\n Write a message, or press Ctrl+P for commands.\n\n\n\n\n /ask · frozen question\n source: aaaaaaaaaaaaaaa…aaaaaaaa · frozen\n analyst: profile-trace-analyst · completed\n • [citation-repeat-read] The same file was read twice without an int...\n ↳ [citation-repeat-read] read src/app/application.ts\n • [citation-missing-proof] The answer claimed success before the ter...\n ↳ [citation-missing-proof] test started; no terminal result was reco...\n source complete: yes · findings: 2\n analysis cost: $0.0048 · analysis time: 1240ms\n esc close\n\n\n────────────────────────────────────────────────────────────────────────────────\n\n────────────────────────────────────────────────────────────────────────────────\nready for a message fixture/deterministic · thinking none\nBraid starter · runner 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/.worktrees/braid-integration", + "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", + "reasoningEffort": "none" + } + }, + "draft": "", + "messages": [], + "runs": [], + "interactions": [], + "queue": [], + "activeRunId": null, + "lastError": null + }, + "view": { + "revision": 1, + "workspace": "/home/drew/code/.worktrees/braid-integration", + "profileName": "Braid starter", + "profileDigest": "02a2ebddee943473ae42c9fa387abaa956799ab7791b68033006dd4029866660", + "runner": "pi", + "model": "fixture/deterministic", + "effort": "none", + "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": [], + "interactions": [], + "activity": [], + "graph": [ + { + "id": "conv-1", + "type": "conversation", + "title": "New conversation", + "status": "complete", + "depth": 0, + "edgeLabel": "attached" + }, + { + "id": "branch-1", + "type": "branch", + "title": "Main", + "status": "complete", + "depth": 1, + "edgeLabel": "attached" + } + ], + "capabilities": { + "conversation.create": { + "available": true, + "source": "application" + }, + "conversation.open": { + "available": true, + "source": "application" + }, + "run.runner": { + "available": false, + "source": "application", + "reason": "Runner overrides require the current profile compatibility helpers" + }, + "run.model": { + "available": false, + "source": "application", + "reason": "Model overrides require provider capabilities from the current connection" + }, + "run.effort": { + "available": false, + "source": "application", + "reason": "Effort overrides require provider capabilities from the current connection" + }, + "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": "application", + "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 active run" + }, + "run.reconcile": { + "available": false, + "source": "provider", + "reason": "There is no active run" + } + }, + "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/.worktrees/braid-integration" + } + } + } + ] + } +} diff --git a/artifacts/verification/w6/states/analysis.png b/artifacts/verification/w6/states/analysis.png new file mode 100644 index 0000000000000000000000000000000000000000..16e333c79e4f112a43961707c89d663b8dbead7d GIT binary patch literal 46975 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%0Ap2B^KBrJPU28h@Duzk z5@~L1lC{6)CO=fNM_HXAx2}k9ZS^F4DyA#C6gu;n z-!0G0%`LCoLf0zdf5e`WuBnoJ2BV0RfKf%bZ(y0XD_-9bD0A+k}`N7YY=`N0(7GGAT@9lbPa zZP3`!*>9QkFw=)yHI1&%#W4$u)Jv)1fy*-#xh?Fe*j*95rGxoxm9g%i-9RML#ib~W z8u}F$)eS(8{gz#&aqTO`#r0{o|aG zq4jZ<|0eopKpJJ=qN@UtE#0&cj5e>e*bnPp8Sc7Z?iXMrl(Hj$e@Y>ylcCU8;Z4=~ z=F?T#7TZ>f#F-USQtG!nl<+2eZQ=l@k$f{RQPpDI$%SXq+)_y7C%MW7CBZU%tjesu zZPSWVx5rvgJAiF}_>!g9|MFcYAXrvR{FQE*fFO!;;a39YKVhzkg7_EUamoCr^uxbv z@DTpxdqMDOK>h#y*+|GW^Yt74FJGiWf&PGC;771$9#E)*JB+}|#ZkiB)#V|&Z066d N>VoyPDm0#i{tIxRgPH&U literal 0 HcmV?d00001 diff --git a/artifacts/verification/w6/states/comparison.txt b/artifacts/verification/w6/states/comparison.txt new file mode 100644 index 0000000..4a21bad --- /dev/null +++ b/artifacts/verification/w6/states/comparison.txt @@ -0,0 +1,24 @@ +braid cwd braid-integration · session New conversation + + Write a message, or press Ctrl+P for commands. + + + + + /compare · frozen runs + saved result: replayed from the local journal + baseline run: run-fixture-baseline + baseline source: bbbbbbbbbbbbbbb…bbbbbbbb + baseline outcome: completed · cost: 0.014 · source: frozen run field... + candidate run: run-fixture-candidate + candidate source: ccccccccccccccc…cccccccc + candidate outcome: completed · cost: 0.009 · source: frozen run fiel... + sample: 1 paired · 0 baseline-only · 0 candidate-only + PgUp/PgDn · page 1/9 · esc close + + +──────────────────────────────────────────────────────────────────────────────── + +──────────────────────────────────────────────────────────────────────────────── +ready for a message fixture/deterministic · thinking none +Braid starter · runner pi · deterministic fixture Ctrl+P commands diff --git a/artifacts/verification/w6/states/empty.json b/artifacts/verification/w6/states/empty.json new file mode 100644 index 0000000..1841e4b --- /dev/null +++ b/artifacts/verification/w6/states/empty.json @@ -0,0 +1,277 @@ +{ + "schemaVersion": 2, + "state": "empty", + "source": { + "binary": "packed real binary from clean npm install", + "binarySha256": "abbc7d58b48a991469ec8dc103bd1e9479d22466f8ac77687aea24cf7b77a562", + "tarball": "tangle-network-braid-0.1.0.tgz", + "tarballSha256": "b1d5ba5b3c81034647fe9247d94a6f4662c7a22e6a501f6f226c36208e64d046" + }, + "dimensions": { + "columns": 80, + "rows": 24 + }, + "terminal": "node-pty/xterm-256color", + "provenance": { + "renderer": { + "package": "@earendil-works/pi-tui@0.83.0", + "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": "braid cwd braid-integration · session New conversation\n\n Write a message, or press Ctrl+P for commands.\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n────────────────────────────────────────────────────────────────────────────────\n\n────────────────────────────────────────────────────────────────────────────────\nready for a message fixture/deterministic · thinking none\nBraid starter · runner 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/.worktrees/braid-integration", + "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", + "reasoningEffort": "none" + } + }, + "draft": "", + "messages": [], + "runs": [], + "interactions": [], + "queue": [], + "activeRunId": null, + "lastError": null + }, + "view": { + "revision": 1, + "workspace": "/home/drew/code/.worktrees/braid-integration", + "profileName": "Braid starter", + "profileDigest": "02a2ebddee943473ae42c9fa387abaa956799ab7791b68033006dd4029866660", + "runner": "pi", + "model": "fixture/deterministic", + "effort": "none", + "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": [], + "interactions": [], + "activity": [], + "graph": [ + { + "id": "conv-1", + "type": "conversation", + "title": "New conversation", + "status": "complete", + "depth": 0, + "edgeLabel": "attached" + }, + { + "id": "branch-1", + "type": "branch", + "title": "Main", + "status": "complete", + "depth": 1, + "edgeLabel": "attached" + } + ], + "capabilities": { + "conversation.create": { + "available": true, + "source": "application" + }, + "conversation.open": { + "available": true, + "source": "application" + }, + "run.runner": { + "available": false, + "source": "application", + "reason": "Runner overrides require the current profile compatibility helpers" + }, + "run.model": { + "available": false, + "source": "application", + "reason": "Model overrides require provider capabilities from the current connection" + }, + "run.effort": { + "available": false, + "source": "application", + "reason": "Effort overrides require provider capabilities from the current connection" + }, + "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": "application", + "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 active run" + }, + "run.reconcile": { + "available": false, + "source": "provider", + "reason": "There is no active run" + } + }, + "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/.worktrees/braid-integration" + } + } + } + ] + } +} diff --git a/artifacts/verification/w6/states/empty.png b/artifacts/verification/w6/states/empty.png new file mode 100644 index 0000000000000000000000000000000000000000..d8fef60ae9108c5ad42f51e9b4d0a3f8176e8d72 GIT binary patch literal 20286 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 S=@{@4orbE;(=wG8Z~q4&)jrDr literal 0 HcmV?d00001 diff --git a/artifacts/verification/w6/states/empty.txt b/artifacts/verification/w6/states/empty.txt new file mode 100644 index 0000000..c36dbed --- /dev/null +++ b/artifacts/verification/w6/states/empty.txt @@ -0,0 +1,24 @@ +braid cwd braid-integration · session New conversation + + Write a message, or press Ctrl+P for commands. + + + + + + + + + + + + + + + + +──────────────────────────────────────────────────────────────────────────────── + +──────────────────────────────────────────────────────────────────────────────── +ready for a message fixture/deterministic · thinking none +Braid starter · runner pi · deterministic fixture Ctrl+P commands diff --git a/artifacts/verification/w6/states/failure-or-reconnect.json b/artifacts/verification/w6/states/failure-or-reconnect.json new file mode 100644 index 0000000..4bf782b --- /dev/null +++ b/artifacts/verification/w6/states/failure-or-reconnect.json @@ -0,0 +1,594 @@ +{ + "schemaVersion": 2, + "state": "failure-or-reconnect", + "source": { + "binary": "packed real binary from clean npm install", + "binarySha256": "abbc7d58b48a991469ec8dc103bd1e9479d22466f8ac77687aea24cf7b77a562", + "tarball": "tangle-network-braid-0.1.0.tgz", + "tarballSha256": "b1d5ba5b3c81034647fe9247d94a6f4662c7a22e6a501f6f226c36208e64d046" + }, + "dimensions": { + "columns": 80, + "rows": 24 + }, + "terminal": "node-pty/xterm-256color", + "provenance": { + "renderer": { + "package": "@earendil-works/pi-tui@0.83.0", + "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": 7, + "frame": "braid cwd braid-integration · session New conversation\n 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────────────────────────────────────────────────────────────────────────────────\noutcome failed · operation op-terminal-000002 fixture/deterministic\nBraid starter · runner pi /export preserve · /new continue\n", + "packedState": { + "schemaVersion": 2, + "capturePhase": "atomic-signal-frame", + "state": { + "schemaVersion": 2, + "revision": 7, + "sequence": 7, + "workspace": "/home/drew/code/.worktrees/braid-integration", + "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", + "reasoningEffort": "none" + } + }, + "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:1", + "kind": "error", + "text": "RUNTIME_BACKEND_ERROR", + "sourceEventId": "run-000001:runtime:1" + } + ] + } + ], + "runs": [ + { + "id": "run-000001", + "turnId": "turn-000002", + "operationId": "op-terminal-000002", + "status": "failed", + "inputTokens": 0, + "outputTokens": 0, + "costUsd": 0, + "error": "RUNTIME_FINAL_ERROR", + "completeness": "failed", + "contentBytes": 60 + } + ], + "interactions": [], + "queue": [], + "activeRunId": null, + "lastError": "RUNTIME_FINAL_ERROR" + }, + "view": { + "revision": 7, + "workspace": "/home/drew/code/.worktrees/braid-integration", + "profileName": "Braid starter", + "profileDigest": "02a2ebddee943473ae42c9fa387abaa956799ab7791b68033006dd4029866660", + "runner": "pi", + "model": "fixture/deterministic", + "effort": "none", + "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:1", + "kind": "error", + "text": "RUNTIME_BACKEND_ERROR", + "sourceEventId": "run-000001:runtime:1" + } + ] + } + ], + "hiddenMessageCount": 0, + "runs": [ + { + "id": "run-000001", + "turnId": "turn-000002", + "operationId": "op-terminal-000002", + "status": "failed", + "error": "RUNTIME_FINAL_ERROR", + "completeness": "failed", + "contentBytes": 60 + } + ], + "interactions": [], + "activity": [ + { + "id": "run:run-000001", + "kind": "run", + "title": "run run-000001", + "status": "failed", + "detail": "RUNTIME_FINAL_ERROR" + }, + { + "id": "activity:run-000001:admission:local:admitted", + "kind": "system", + "title": "admitted", + "status": "failed", + "detail": "profile 02a2ebddee943473ae42c9fa387abaa956799ab7791b68033006dd4029866660" + }, + { + "id": "activity:run-000001:run.error:run-000001:runtime:1:error", + "kind": "system", + "title": "error", + "status": "failed", + "detail": "RUNTIME_BACKEND_ERROR" + } + ], + "graph": [ + { + "id": "conv-1", + "type": "conversation", + "title": "New conversation", + "status": "complete", + "depth": 0, + "edgeLabel": "attached" + }, + { + "id": "branch-1", + "type": "branch", + "title": "Main", + "status": "complete", + "depth": 1, + "edgeLabel": "attached" + }, + { + "id": "turn-000002", + "type": "turn", + "title": "Turn", + "status": "failed", + "depth": 2, + "edgeLabel": "continued" + }, + { + "id": "run-000001", + "type": "run", + "title": "Run", + "status": "failed", + "depth": 3, + "edgeLabel": "attached" + } + ], + "details": { + "title": "run run-000001", + "fields": [ + { + "label": "status", + "value": "failed" + }, + { + "label": "input tokens", + "value": "0" + }, + { + "label": "output tokens", + "value": "0" + } + ] + }, + "capabilities": { + "conversation.create": { + "available": true, + "source": "application" + }, + "conversation.open": { + "available": true, + "source": "application" + }, + "run.runner": { + "available": false, + "source": "application", + "reason": "Runner overrides require the current profile compatibility helpers" + }, + "run.model": { + "available": false, + "source": "application", + "reason": "Model overrides require provider capabilities from the current connection" + }, + "run.effort": { + "available": false, + "source": "application", + "reason": "Effort overrides require provider capabilities from the current connection" + }, + "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": "application", + "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 active run" + }, + "run.reconcile": { + "available": false, + "source": "provider", + "reason": "There is no active run" + } + }, + "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/.worktrees/braid-integration" + } + } + }, + { + "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": { + "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": "02a2ebddee943473ae42c9fa387abaa956799ab7791b68033006dd4029866660", + "requested": { + "text": "W6 failure state", + "profile": { + "name": "Braid starter", + "description": "A portable starter profile for the Braid terminal", + "model": { + "default": "fixture/deterministic", + "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": "chat", + "provider": "agent-runtime" + }, + "admissionStatus": "admitted", + "requestDigest": "053e061d0a0b70a433bd2cf2deb4df1845c53bda37021413b058d44300b63037", + "capabilitiesDigest": "93c4c21593f9e2fa1fe68ce73da84f48c173f1e13269633678b512a16a81a001", + "materializationDigest": "d463202424c67663b3863a357a670e72b7339dce890e476b8594a5c076fff1a5", + "digest": "3345dc168242a32cf8d96f886ba076c5bff7745e238377dfb5de9f5d598950cd" + }, + "status": "admitted" + } + }, + { + "sequence": 4, + "revision": 4, + "kind": "effect.upserted", + "payload": { + "value": { + "kind": "effect.upserted", + "effect": { + "id": "effect-op-terminal-000002-f848681efc4ac5a6ab54fda1", + "operationId": "op-terminal-000002", + "effectKind": "run.execute", + "requestDigest": "f848681efc4ac5a6ab54fda1b52d2e68bd7eab4b2dde7d754f35f5a37943e7f2", + "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.error", + "payload": { + "source": { + "eventId": "run-000001:runtime:1", + "providerSequence": 1, + "occurredAt": "2026-08-04T05:12:23.981Z", + "receivedAt": "2026-08-01T00:00:00.000Z" + }, + "error": { + "message": "RUNTIME_BACKEND_ERROR", + "recoverable": true + }, + "text": "RUNTIME_BACKEND_ERROR" + } + }, + { + "sequence": 6, + "revision": 6, + "kind": "run.finished", + "payload": { + "source": { + "eventId": "run-000001:runtime:2", + "providerSequence": 2, + "occurredAt": "2026-08-04T05:12:23.985Z", + "receivedAt": "2026-08-01T00:00:00.000Z" + }, + "status": "failed", + "completeness": "complete", + "finalText": "", + "usage": { + "input": 0, + "output": 0, + "costUsd": 0 + }, + "reason": "RUNTIME_FINAL_REASON", + "error": "RUNTIME_FINAL_ERROR" + } + }, + { + "sequence": 7, + "revision": 7, + "kind": "effect.upserted", + "payload": { + "value": { + "kind": "effect.upserted", + "effect": { + "id": "effect-op-terminal-000002-f848681efc4ac5a6ab54fda1", + "operationId": "op-terminal-000002", + "effectKind": "run.execute", + "requestDigest": "f848681efc4ac5a6ab54fda1b52d2e68bd7eab4b2dde7d754f35f5a37943e7f2", + "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/w6/states/failure-or-reconnect.png b/artifacts/verification/w6/states/failure-or-reconnect.png new file mode 100644 index 0000000000000000000000000000000000000000..7c4dcce873cdd77b6d8e070afcb952778632e88d GIT binary patch literal 22931 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®9V150Y!$FD2L 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 jGY303SUS34fo!a}T_=Q1r7!V#zQ$-Ni__N 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�^19W}b{C2zm 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ðqkXrx&gMf~!#_PQf`Qh4EMJBCr3a{3m{$hjKjE^j zipB!#G3 zaallsnU>c2?uV4dbDBq*8o}z`jSUltgn!cbTwV56$Cs(Md(G}UGp)$$FC^kiD1k9R zIMCrg=CQQ>ygstOo4>`|_|3IAt6;YxdPfZs!im9T-!#^i8MJ&= z<<@+eDrcku76tj^;~lB2-ka;Q@@X`dr$v9N92WV?3U_Ch3{)vzes`j}vib8Ea=Zv? z&=5%>lH9H;;ItZSDosasc7eTOF_1p*@Ctq8w3LwHPs+fCHXk{1&`y7ODC2N0s-CaZ z$HBOXSkO%f2WEig9pHi)m+K^=%GOuc6AnUEGLC^F9tp}QtADMT0V&J;GRYwjQKI{_ zi;9Y{Apc=$B~?LH^)L%P-qx0-CMtGtd*{v_-#_)-;Dm&{#+2<6p>t4Iy7%|%ckf1S zzWu&{Ny5q3a=v~0e!9VCwFM-t&i*LJ=IH3?$=|PIFtP6D3c2k9(hdAwnq9LaOQ+gp zRQynmzQ)Eevd;m(HRJ!b6}tXKZcX&=SbNKL<$W`Usi*jprzC6 zdp~zlg~fwQYuwDuwI;}@23otoJWV}P;pIDi!@EG~}o!GnWxV{Sw8>R(N? z3tjcVW&Hc)h=hv|f9eP7&yoWE!TSRq9~KvPuwA6$OTPA;SPLs)jfSFQg?!UmmO3gi z@!YXAi91g@MfA9lx9Ag104S=13LY7Up@s(PO-8Rgp{xF$gdGV9HiUvg4;Gb*LR~dB z?}ZbCa)gC)MxG%kp?Tt+#kBrWYqaQq!K$f&qTuv0qr9eO^=1aA`nZ_b7Slh=2a&-Z zukEaDtb08KMKe^eMush!2(UygxCQnok9Mi9N zGBY<{uioa*HTNW-6rSpv{kfo}sbrTr#CR_1YbvY_%&I3gKTjz`07$%Dza#*4`-+dW z>-K1fRT>!CEpGCfxS^-#ivQupZ=k% zaql6K#rj}6{R;UFI~43IS9j;rQ*u>}etc}ttBW26^ft?<3a(b%jR;Bsaga3@Pgl}q zU=$5;wA!kxsR_Dle&-4zBBiJ?A1It}Jn!}B9MG@H$)~&a<9&CU?0WCsrN3d*dr%EU zJ*@ZM0EGU?V%a`{cm$2m(J_aRs4FJ0WFJA+&l99d&_9Oq!91^?eHB~;1`p(az8?>8 zOQUR{Uk=dxh}OK)KL0Qo|MNw^d0Kw~5M0jo7fOqY#t;v$&RD;bp@cWCST_Co z`!)ety(}`6A) zoa7p=5Iu$CPpGVr6CJdsVl! zy|uQw&gVLYb{0L>xx@2pfER|GZqKc?!T0r@UtW&+eqh0GKTi@O2_w*dVzZxVSfWp^ zrKQpNUj!WtuuUY=_QKC~suBw*=G}FtAu8>G9$p$6T6D%J=^8>JI9@^mIz9oNZ zh}vH5(=HA1{?G#9fuD4YM$+ekI?;a<*5!8H#DhUOCjHK}m35U*Y&1E^O$DKl^+~~! zN!Jz7f|{F|E#TGFd!r$$QukatJ&)ICXJ-@Ru|IwOT#G_Ezk5d*Z+3;~sodz-Z?YVT z$yHEPR8&`sX*H^42X#e)Ke{ou>raJ_Pe?A4^VnYD+k4wgm9L=qy zO`!rd36cgxc&?7VKJ@H7gWFp<2yd`mCY%oX<9pYa}-X*Oe*c z{OjNyR@g{l?VCp@$v0J_$2&FYH3iH^TAykZH|4=RypzWbT`rW+mi`J_#L(( zok{*-C9a912RyHiM_I{^r15A3tuLZ}c)wEHg(-6!JkwTj}LeK9YF^ zRnLtTMpw-RH1vz?P{zXIi>3Sw?UMO7?5svFEdoD%qKy~yKdh}>XY-_>NKd#mpJ<{( zh%xVc8@jlho~`EOwD(;`Om{36FVvZ(e9CIex>spxh2_iaTh5a$r~2m2=F0ljY|H1O z33~!94aM{q$_e)WZp$1pcWseX?{>GBh~ai~cfkT#Wp#P)UfPThkvQd!9X_@2Q_+|&v~cm}VxTkBspeSHD?ez!>gdb8kRW0Svl`{6>CTb)Qq zsPPGZsahGxjsTNzM9tT4^83fpXqE&fzMrt1`TsjX8Zajk2UW+t*~atnBJCSYJW4(v zkEyY)Y#F@li2Z*zdBTbhBN#pOM?1S%d!Hi56FE5}l&BiX*93r0p{QZh0^f&!C*I{GrR-QA3|w1q$K%>OnY#jg@JQQ{s};{C_MbC06L z|LEG>`6u<=_X1#nxOYE+6zTf)&qM4GSd`yh^95=Ym+S^)M&{-Tiy4Unib^}ScE3DL zRvzIT;Q|=}ks$+6o{Wu-K6v=x7o3%JyD7up(9jR`VeMQhcr^%8&D6%~Wk3w?^aZSS zsiZEE9D>HG*nAF4Yb&=c#L}2q4%itTP2u*x7#Ihgk`CXC7Ti;mqv3<;YxB+WG&kl0rto&NbL1% zZ+BKVH-R4PW!a}sZ8dhYT-=r5GL=(bnu8xxQ(3zC2G45WYq#Zrk9RP!dVBXUMV`Z0 z^{Rr0HaPUlxrBuBpSJ#Pq~YJ%+Qz3^)-5vr`}I}?)Vre68T|Q8w`|`~P4i6v{n2D8 zlxc~CjFc2w?`AV}D}gw~rxCe4>;gquIiP>z=C(n9ypfic#=|>vil`O_+d8-Jc4kIU z#JdgaXs+`MGROm>af3-0AjVXGhNn&yG^OMlL`M0teU2%D9!BbiWe(k4VFuc)vH2>_0 zI&6!5qmv#RYjMQH?I#G7@!Dk<=3N|OyNk`f9sI!iF;hm&<&Kgla3{z8H;qwH>;0tU z$N8w{1sN!HGH}8-d?uW#w5nJ4qVL2VG7E zrKxw=R2XCdB5m;%dfWfCy!vc%Qi_9?7JU7{fFgF->E^qG+P*|0@fktEoZQ^_gU~0! z!$&-^=HJ%HZLU7r$CM{neETaodi|U=4y+C&CkKXhj0p{uXKIyfVtNz;q)53b$w~5%yISu;2p^~ zM;t)wuL;9oXJ-qDjrz6rr}MoI#KbuqW~130e>Mpi=V}K3BOM$ZHE$Kocos%Bl6?*m ztOafJSBvjxZfesBxOTV85?Yms%Zv~*5)xqqF|kHnkKu)dN1>j}N}1mc7>kE3;p_|Q zx=b;tP#8hNGUzV=&Bg7%-wypu??PX+&&~A@4%(mZfhXIH;YCG}k41fc9W2F=kByDl zq=|5F?5{AJS!}QxR#|rTCpDh$(8DC9q{@uYD?m;q-rY|QP+rF6e6r@xNO8c>2R5dkVfL^&`89xb-`s5mQ$6C+qmtf!o`gO1aC+!wVFIUgvw&Ya1E6l&Kja z{_LlcJYcoBx2Y^?Cw0ai@K|M)Cpd%24+DI|;iZpwaGQt*xoq#Dtff5g5oopDRlZ1j-Co5uQgpiq{`W9}ET&YXp+u+N`uc1){c|%GBt6|wyUehe zp8EnquM0uDDll?#0pqD4XchE?>nc}$_6%er8-wZOqQ*H%aJfde!P#27crRMuBr20! z@vEu=Wec5)x1U&!+ICTV)3x-&^*k&i0*G=(ZjO&kK}n&OhnrCBj{R6?C_zkeGQ0N@ zsejoO$Q&K(*%r)AgJXN|nt|@{3a1iQ$HNeyII=9AybF_fav>3Rx0_Z!eNIkK# z)NyrnHFB?wjLpi*nyGds6+_L|t7D4GYQz;U-hJS8QJ0frIt<8&`(6w6zUob{E?0YE z2ceC90&+nA)_DF&IYZRW(ozyi1w7H(C;SXI0HOR-72ardx{zxRDo1tHJ8#&CXef_K zMRTMBr*pnJzHw`WzjtqeTF5OmFK<1p+Y)Y(Np2~SqPzGiw)63JBxW+tM*01VBT!c; z{QiB@@Xf)~NI&-Z2B&(1he}_72-()I7#ni_>C-quLne~4bIs{X_yd`T=gJEH$IMIB z;dGOru0_tPsI4sn&0d@0eCBkFn<0iin6ff<0*EwTe`v3|CC`0r=2R>2s>MXcX4|rI z+Ll{A)=@OuT15YN-7fIs$MpEz08>Ls z-hLAsw9u$!Ps4)bE2kqSCJgI+$^-KPtE&}%5jpv;&m0pm1Ny(lm>3&sHK@iQhqNS-pa zq(HKZe|F^dy%xy8pKNgVHmhVrR<=e&R#sK*AmG`oiwe2j)7TXUtxmf7`Z6dHpDXsz z;NXhdT5SL(6%}!s&_BAm@(T(WIhl0R|5)_t!+&Ns175{{wfGD;Kq3j2-M5_FluH{^ zipqw9vyA;@yT`gz#IYfO)$yD4w%jBbVtX>FsTG=K)S24q63~rmZK$X?iW)vJ3)^t6 zXNyrAb>A0xTA!McnCQQZsQ;Nx4W{Z{xsF0YS>{AKi0U=AvukStmg5y2^1brp`xVr{ zf-i^ATcUoCtnJOYOprMVY~#ADq^Fv{ILUCK51W_P`{GuEAz(% z^9BP!&6KUCWh6&t(Hmr6UAHeqfEo%`JDC#RpYoUcw$I&A0$R@}uSipBZRJ!H&Tg#R zki6SCI7jFvkEJ##e{oqC#hsn$%5@GM53%c$&2r`|fpmG!|S=i4~}GdulzG|P!k^3i&CR)QczuEAIGAmq0jtgV~8<_q3skrQ~us9e?a~7TS00JA8V}4oY``lArMjqFK?!Q zKcZJTf!6yts@L8Tt^a7WPg#pPvJhpu*=d`zk&Y8cwNFFKG*b`PMt& zVjog7kq~Oz0fXgmld+SolD&~@y=jn=lMC@;GsHCbXJ1MN;V(*Pr(k2m7+=K_nK^w6 zmWP)~itKYF!rx7M=$<#}ta{huWOvHzFN@Pg(q_4$`DdGtbyakHuZKipzs$Ng?}!Ex z90?!1zew{vk<%#LKH$B|vx|Q@7e1-7pP~P=ZzwCKAl6XmWuM)NATL|?r+(P=hX1$0 zm*FDrWr?TdI5MjOwR0SXHU);ObvBwT=XmrWVbNLsWkWY8BlO+e7rrIK%G#1d4 zzL=X;kkJ-mVAw}Y--Pq7|Jw?JVf>o?7((YVSLvn(Wp#Ek{8SK@m_85CxGM=^!P70@9^- zP^xt4J%|E|G?gYbbO=>?Crat{e$4zBmrF2fC4~Jv z+55h)`znUF=;tvjVFcG{>CZ0ROuc^))rOI|xfcB`e}DhYk%9;8C&a+6<*(2N^_`#; zDPlUIq2anZE>_lE=bxREMnHMrkp>os{BFa;2KG*uFD#++~w_CQ4f?hD`jo8A^Qg zx0uqi$DKDhIr;P-n^hV&mDzIhS&f>S_KlTDm6cHQA-$q_-##_4vk0VMnXGgit996l zp@U`R-Ry)5Jv-LDKBrd@PD{=QHdZDs)e^T@97tbd?VzzsNiC#xG?~m+* zdWn|II|;j`WrZ|KSjgVa9$o1bTGUcLoezQ&(qo;i<$`qJ0#43o{%n4m(e%X3A8&rK zX_-=<0Su{l*&tG2&(PD{xmV^z06>d-Iho&uU&jeho`jeFt1k~!P)JJ7uRa>HH-J#d zxPDjQB}3K((iT4dluZ0jK)_#3N(55A-Sjy2MHiWNZ}KO4yH&hx?}o}!lpn`Rv?KN0 zW*g{gCXeLe`Ph+(i7Fmu0n*}8QB8rFQJ_&`YKC3Jp^z>bTmiME03)D@9|QN;pqAB# zwTtI4pcPC@OIz7obYJVL`O@>&=jltJiiSQC5T*Z0+V@wI5dm8(U2!p(n3&jWAd7gd ztYnp05{n2nc?0$m`n2P zoq*u&Ss8Tef4Z%#p8t9!L!wB0Emdcc2iY#)I8Y5W$^>!rt36g?NfIzUGmF?+DglMhlRx+&4sQ72L)AM zCix}|Ty-W3EES!d6@Z2oGVY>ebh6zWM(3>y|NfofW&nT{uyiA~#>Vs0uOJ)_U_t~? z2|%7_Qnkk0)5QDSEe+hEG5#9+_KtUlP01+^+V=-rHWnThIoaja54zPiXL+=};?TBu} zugsS4A%d(Y2^fQv%mL%L=R_%H(UeFT~# z%w4Zz?uIxJ;{g!mhPlevK*Isz`AB$p93bZNixa*s82Nl!en>@y^_*Ha_a#MWz%`%G zADR66)BMYMvj0`xfnB|S@MB)?xzaac8%hiI2+A|Gftrw>L5L}oG&wsrJ~~>0aOJ-s ziaj;|hxw{N%sO7R^;Ap@W~F=l4u4v%B&DL`zB52@?hOy19m&fFlPW9MoeD@xNTh`u z+@HD?@EicM%2Otqber{8fS3lFDtqaIlasln;y+wTwxHMWkzui?2rfK3C#Q7eNLnxN zK`K~ZrC}Rh$DyPTtazq&G>{dt4-^y9n^e0H=dmLng^Aq^Y*g#F-xTbsSQ2QR^ba1KzG(I+a`9%$18W(|GglO~#@N}@@w3B9PUfrdxAp}E49)Jm`FA!n z8NF}4(&Agy2BmO+e@SOr#hrI;GQG_e!Xhz7;247{dinQuTTsX=E<7gE#@zHi?D9l{)98fZ2g`$BdFE;4MccK)~2S#5RaBvvw0*R@AEU7q+Jcz zax1aaXfDooaPG|jfQLgsZwkC`#g>b$qq8AaJ#xzceB>A(K9t5gI2U^F@T||mHH@K7 zACa-I46Skyh&yz2OuT_vSr!%3_41bcwI^w&Nnj1L^YYSRUeiV9Z>`Ty_nQOWCm6QZ zBu*{_Y(3=L!QbgQP?UZPyq-&oUF0~v>@1sCXR zb3eXDRd+sq+*4Fo{H23cw#l3LwN?QNn{-JF5o8~+V_$DWx4c^Mv)6+xTn#pFH z;*B1arkjQR$%Dl~*E)sTcJvgq{$(8NK$D&6)Wrd0frjQBobDJE+`qVuIOXKW6)izH zKZ7czJ<{9K)FL@Qqs)E*5DYpjhizp#ckT(e{)j63P?MV#GX~^Tn_jpU*F11kgfS_~ z$BH_DQg4-to%kB2`7mg8D2*)TD)->@*oB`N{h)gyq=!Xum@Um$R#o6|L62A#Kl%!L z9WwyeH?Y)YH6Q)2qbCaO^i0wo;Y>OXWi^bw?bC8{gc*1|M`sUpZ30unF_Ru7FGQb$ zDgD2+#*u=5(Hh@53?;||>K))S7tQIU+#b2gMiGd!rq$xYTYmC(3P4C{cyqMLJ2=xU z@tNvrNyAjP5eJW<;)D<=>+&&)9zmxiYH1CW{qs};)y6d`b_`i z;U2V&br1XM#yxElANur$I0I6EnMh148s+C86>>KTMrkp$K3W*Q-3}QCAl`MFCpjH6 zzJC6>T16K7n>AbscDs@6E9R}W2J)IUrg~yxcsM0Qw|p7W{j^b$Bi6Ph5@Fg7tviE&-VgfRH-Ysya)V3uU*`W|L||k%*JV(t+_u!NtP^7j=I~GZmCtnd15U3b4HRvGYQ3mvYYkT<$eM4{8qLbk_fh{olAs}A% z*cJz`jeI*l z0l^^wCPkxQf@LH{63b~aTe%mDVECWJn%nV3zwTV2|BcPB&dT$}=BB2q;#>sxhSarx zzg=@)q+j(P#~emcE%14AX6CZT{)dv162d@4x+*$I15WlWP2OnE%9*LYzV%6_pQ)gU0q#A z>SOI@ja#*$&(p+=450zbp7Ed#z=BY10_sExc5RHqR_9y6En_oKMb>n9>lOvhphk=^3Am|p$GU?RJNNF# zHEkUpCz{*bTwIu)yT;>9%KzoC1GY!N&{qC!+0j@c#Sgqr`uh5oqwJ#cr@rWVfs@g0 zW3&eFG@f0o`2*67J?{fbZRPGqvQ0L6Pl&OYp21$z-yr1;_;A;d=`yKe*pTEOraZGK zzi-;(xR}v_$uldPPVV1M({Hppx3tbSl6Mb3{vSJ0|6fy8f%pI4vd;ehZ}|UhwcP)| zu4gOoXZ!aNOlzPs6TcJj*)bLKGsG>D2k{47{tP_u&h#>o+u76pJ6`gC`alFtPRDUV zQp}YBpad9WXRj(sKH&$B>;KH6{!cIapSauqM^F8`?ECC}c3%6-iNJsS?)kiS0FVq+ zOW>LB9{w;&V3cRUOP*C_=ZrL4F8;@__ut?aV$*zIQcT{Zn4HY(mB}cOq4s_=IA}WuKA;s`jEIlP@O*MeTjlP|Ks-p2Fy33~K{nW!UJNulf z0;6nzCz_x}ev{YrrNb6n(Pwgi{Ea7| z;$75$kvp(r(CY@E4mc|xYjwe+sEtXty@r*Qi4MX&@oxGly zY1|VRi{X;?x<&PkP~P$-Xzv%^s_5-#>Ye-*x^&~KNyVMSOtktix6aoWb;!)}H+{eS zaPYYSBgssqRUx1j(qZ{Ay3$QXyS}?r)9!7+6Kl<%7}h^+^JGyn;{8sUl5}W@E@j3| z@i^fsC+tS^pmMijryOyx2V7Rm4d>@{BZ}7dE(s}}pz(<+@w1Jv{<1z*WihdW$xpO3 zX2dCcHT-fZ`nviK0*nli?$$VHtWDVERjrb|Tj3eLO(J97dM}ml%W|){ztrYlOYN00 z+o8)Q!@Iv8YUP8n3t`pY?6-bk@r#HWzRU5V^X70yZ7r|MkClUxDxX|wHNQuUJRF#1 zYkhs6^0zUnxUn(JN{`X%tGq~e8|9`o0*HGzm9yilq#cXA*SQ!PR7H_#j;=z@l;b+- zo?&P4D2eSQQgLvlRXl(pMBV;B^ME2~k>LIe*6*+!MQtTd@Be|9}KUUGq&rezFT3~20{HVb>-IGjh!cl{~K zU{YIal{GS*9(cVPJkySjjfc5Hx7$kcY-&`H{IJ49`sl< z?T9YG={z6{<8Ng~vKgESaYWylwq#BvRC=0IOMSfh`mjHd;A5Fpdw1Knv}jD@O>zIA zeVQjTtYor*@8jZrwrhlJjmXOfDT-NGU!mvlnp|7IALIXGV_bVfuYBgHQ#Nw4#!)N5 z@F;jYq0q4)FKK4&`-s%}GP4}r&GDk{ssWz+#9jq7?v8Nm2ERJi8ALbq9ElylMqYj~ zxM##|Qqf@XdYetv3&Ig)B<-*Y(ZLV0V6r|hInS@Cnl%rjT|`gg^Gq5HYUQCG_Mku zr8j=@B>yEq*o@yr#K#sa#LME*O1-M|HcgS=;BS?K4Fp0-xxBvYW5lLb*>wsdBCgN_ zZ*45j0F?7_^P4kseMRBjDA=NF?*1JI0_vz*FNMvq2NgxnLN`%1{F_*jiM zuqXY7;A!mRe;$b&7$p^NSzC_kzkI#Ke0->V*zB9m>AVKIcGon}p%){p6Ch$gHq81^ z4r{l<%tn@TxS>&J*!oTBnTpXa`a!E*e(ma%WmZ*l(ey|CT2ZbLN|2iHB|2N9L%*tR zYkNyHUXLOa&EC;&J6;i1tx^P22K}W3hB#?UhjS}nj?(4;R)5^6dOR>~IPSbs?Y@L% zWl{`XA6b~s6B$tbs^`^MYG*&;zB-ak`St9zf~hPw=F3+alv@3JcvK>pPdG2rhi6vb zpLab6$$uB6vHc2V^7X}l(dzWhx5DvQh25R2GOmq2$haj7gwDQmdWfQTl9cn-oMhwa_bI5A`)!V5Tp)B6eezjBzTc=P_^^f)kgRul4$n z-lQX?_fT7#f1Io%n2(w9u>=PG?+0=Sjv&-yF`v7|`{i(kE3wiLru*^x9PoXPt;zOV zK2Nv#c1lVS=Odo?m*E~t-W{ZnE_-l^kr6!C_vi*4n1pINB8p3_g}Qi`_Qna31Q&en zF*5Msr=U;bz@3B_a&@~DO97$A@A-m5EYfP~rHWdK(cqXQ8?w0kU^^YYTU5AHR0y1> zS*BVAAh9m8MeLK!!)7SrnCATcz+dH~`+T8zmZmCON+Vy1KCUL$if3fqk#|=01 zwi0lSuh%*66P5oWy-nFF-ndoM>=_~bjnHR>ef@>pT>Ab{0BbbYe&Q)A3{NuyU7&M5 z>PtL2BuZo2W&V3Wnx7c!11+wY@o_|nD$)zBsDC3Lbp4xY?agId;ohm&w99s)1vloq zCeKrH0KVy&NA3wsc(&K*PLa-U3aqHhX3jD1S7e}Zs{ru8KZNF;*lpG&W)Cmqgn~2q zN-v~)Q%JLp6=u)BjgPPl?(5*>PN$%x*gS+?%>NX16aHhrIk*&Xd@8VC(xQp0C~o_{ zydnm%*x+0-sZNob8SPek-+ixpyhFrNfE?W*U{zZQ;owF`sXJnUPO9C zDJ~F|1}W2~)XxSPObiQY#$%N(seI!Y3;4~O^~*>*C$1lyt)3D3dU_JpwSfwZzQy*1 zU&iDRZ(^iF4b;U6Z>XuN6Sg5lWY`6ik4hgiGi)A=(B<3udMz-UMhtR1i475|i;m&bMom7W^#JXy<9y%OWJl$Fiz3aH7b zNnjT+GZQZqtd@98H6fwQeVqns^DA`i!ZC+PIZOqQJIfZhcKVpyz+c2N+;ptOmJ(zW z9H@w{nm;O0i8SR~*M7Av;ASp{hL&ovg;)?tg>r<@>M^)brkbfhD&=<$P`CGmka6-v z0c4R8DvDGABr3fx$(xDkC~3*Ii^u;KdL#rvtY($jKSp9UV3H>~OnK?r&UMu2^vvXB zWlcm!E{*YkO*#*z)9T(>?ZN{2gr;|Oku8!QssIc`Yh;jBC)Ed6Sa4u-?hF1@3^;4~ z6>h!VW{btcoOX6Mz6R^q>#J|H%ZQ07<)&3;biYxA_kKGQ>L^L)1{Mr)d(z*6aYmWy*g&N=6oTH-4;5>toaEeZ(E0Cd92Zv4<)>$cq!uLm4R- zQnVYIudK=_wBMvH3Z2ARWM}0yv9jHwd&tI08Ss3Sib|QA??C$%skoXN-a$G$T~bq< z0u-aaYTKP?Z72H$H=Q->PWIQtsQkyiAr`8YTel_Ds?v-tJfA)X)fT&lfJN-;ZsFBF zIOF40)RQZp4_MgMpvAgcS^+5xcX$RS<;<63N1yiL_`uEss=C}9tG?bb>nXx5;{K2j z)IC`idFvI)=h|N9@G8J1E!+Eqckz&j)k6s7R@A8I*$;*skrS$jwTGPa&l$3g9~qG& z%jZ`S+ys0!dE9}(;qzy23ikMi*fDxrUta#TzCJJ;Lx)6aQx#I`ct1041B)Be6ZX>s zn9fcc8O0Mce3HmCjI zXR<&H33S$OODXovPfBB)nlFC&x@eS9cqOHO+EOeJJ|ssJYP(=8=4Vf*V)5&SnrFFh zj@~JHLT)kJ@!1gx$+bIzF|z4bWn`4a4dX65m29B|Hx<55(8v-7M#OJ7wRB{=qI-h~#03!p|@AP6`B(6CWKNu~eGTx!F@~u4vXB`whL@*6C1cy5= zH)yEqd(qNIVwFe^`{j|Oq4Oped9Cxw^s~kQxJaQC*}HYpZSjz_6Ssul_s=EiCLhkyhCr5st8sjl zbCPr0x3bqN+Igfb^9|PQMm4p8lAv6B&B|I<%H?7p(3akP8U)^uX8i7?5+!Vuz@`6d$^ zx6%lV4*2-jdE13pMMX{yEd`0vZ5K0^b-)(6NhDQL%4NpNY8gxY#NAQeJK-t*y3MkR zxdpY?!;zjLRpI-kn>5BbBtN}B%OYzZWXO$`8alz$hX%tkfDN{HnM;(w<79@$TCRfP zH{)qV^GlBN{cpd%m>06W9@Xx&Jo0*^0bK~kv_Qr?tj5?ztAF(j1oexbIbnb6q|T%Z zQXhUJX#74OHIpL#nuh=KzcH|9F)H98{>nH<+4%1r+jm0rygE~(;?bL6KX2}a3$>UP z0}5$K(hSJu0tNHwK&QE;HFaavK(QAT&*Qp&Td*0oj{g8ouI8?UUuqgID{E8ZMRukk zsINxsCB=&{q~tS|`nn<0uCc*{`+h0jKByY+cEN58d+kgg4|JF< zZ4KfmytG}b6Mry37*%#CL+8JK4PQm>yxM0@2X*JDrh-uHB}}J8)VHcA#t^8MM)>JYGH-M_El)+ce#^ zi(E779v2WgdHbp?GLIv$F&S3D#42Ka)W%Cj(M5;fYlxbeX?8g--5Jj1QfC&SH{bkn z!vLb^IE{nfC65R{IG{Fz2-lLmAiTrrWM7imb{eYUWE$j(aqXWq38UzQm$;iK>rS+y zuyQ)Js*#4~RQF|@B)8n$xco1jq*=b%?(Bv}mzb;Qs@}fodfKw+jq&vA4A+Kxt$Djg zMT)wQ%{M}GySqDUoTj!G-Jm-GgBs(Cy#;ht?mLeAc&{j%soT43hHdZqwPZ?)O}TYGv~aiv};xYChgmPA@Ir^h^wB~0=9PL}=5 z=nq2Mf=;&wvybL~c~q;n(3xEL$y8^aWG{+A5B8f;Z)*{5``zX=>B|{pB`CEE=bSjS z_hj}qEvEov_0Um$MnQ=H?^fJAv?T!C{kv5G>3r$~PxOvn*UZ{{q=#KN_y$x>HRI0D zkN?(51UjEd{N!~Sp|=7or5kZl^6{Q)?+uCq;Vn^z{5$TQSR3d>$vyTXY-LzyfnqE( z;7CoxZ|k>9zA5e_zZKaqd4&k-fJCqY$AeQoQ0+cF%?{+Zeq2=A*4}ot+H+xEA-Z-5 zmv(4R`J2*Ot7)+Od(`(c>VFF$dnn>N=6L*~XM8@ftaTs7p!eCea^>JFpF&8m=7&F! zpL3n(L5uqb*b`Rq;@y5~SDs>Oh1}TX{cfh!5k=J;u$f40U7C=WCj^O)4vA8^1L-jg z+jL8UHXTXt*2?sofn+M7)Lt;dui9JiV3^1(lf-hBB)7$;qr-eQC@KZMg+*>R;w$PU z2Oe$paZUQQwRSjim#!Nb@_KzKODx6=?`Ak%BaW>+BrbLz#$M#-;Eyt#LC!(B{D6`f zkP(pMRooc7!Npvfi6C~msjFX*9S_Y3k7GM@YYe+wvK2R`);0$07QzvFlkWR8*cr%E zek*5?9NE=5vQxG-%%3A8s^?L8M@M13A9AR697=$74Wb<#h#$E2_{eDolqO+9F8egA zgC&aj)Mj1$GQtU!Spzus1s6>Ig72`(KwGN-rgfTF2dO z?uw~(I&1@l_!W9Y!4=fO!C0~F@W9@EUsBLmgs82#yJP71%sl~$BCi(@p_wyF8j+Fl zC1qqZvFNWGdbn&|2$DO@S)&X-ySg*aBDRkAz4PKaO+7-Wi?+t#U~Tn7VEFo9H6@$h z1GetMJSn}tbT9XZzPDkj8FSfCWR>z5&0ZR)=+CF@(hUr*zJ{Qc;N-O2*Ooc>}8d?~Y%(VDe7JLOSZ}SA!(4(4e2#v=ntb!Q5jZ}?q zB6l!+qqRmJ7GW|@D{C-}{?FXuf{z#S#!DS2=;Yus{TGQheG%IMM&n_ltbZUg3Vx6u_MhzuREa`6?{UN<=Uk-##I%ky}XUFCu7gYs=@~LIbujGar+$iH}ql_ zH?l=L#2*$5Igw$0DzWGk=I2ky{oL-8&jG;mTY-i?Bmh9OKqAWKp4{59LmsK zgUj;BM{Lq|zo&CQ#C=jB&>XF}qcc@8Pe(^CA3p}t6JILt;8Xi7bPTvx09v+orZbe=hI>xdBKj8GXzKg`! zp;?tN&6zg>C68bWtNM&t6S!v1Ge=_!uc9@wBzQ3<{wcTL{CdrBFC>Hbu0?-oZd86Z zLm9p`f7WZMGcwKlO;gOn6k*DHUS5BZai~nNfJrz3kW8(}5vjPkLX2d*xIKM-ZCKvF zLvuczN-3a7wFU(Bxk7l4i3A+2PWc429au4Jer09PZT%}sIC zT31sm_{IeUZA6^b2LoTgyEpw8ILq}EU#B(nm$jxJXhL*Tgg-~sg= zT8UGOsho1kuaqQond77$1QHh6e|jptg7NGo;X z3!B1_xBo5eIG8AM-pmBObG0;IGbJ}4SOvz#sHR2D@4Ed3TNri5rk;;^Yf@h z{bZd@YhYSGu}jHy7+1&>$piy@CID8GSjOyg;9OSxYrHsJ<0d*s?2R)v0rk>W(5-l= zvtU}MY^|lqF!(#y%Dxc44MX)c_t@(FHVJV;4SBWIl^hxxLpYMuwn^B7V{QZ~FHIKk zyl5!Lab=CeZAs-J*Ffo}Y2WG}paA-qB0RiWTA{#vjlry7$MUB%FoNkfU{uQAEpi~F zkEyIwK3mw!F5(lrgsT#g$}Gt8hDs$QYa8K7YD58<=tHwD^kJhf>3KM0I*L@>*TjTH zH;=6|iEa(o;&gr}-^+y(uzWwdZ1Sl46cQ4-zn$sXg5I52<8$r79R>n1vm{$}7usT* z!DlhEvQjc(9X_iv2W~+z+b~6ua@&RJ?UVJx0S9f&a=v^Ob_4uknR$81d?^Y3%0dWv zuw0d@WB0$6u-Hb= zq?+`H4;UMBf=lK_zh>^tT|Bx@?S3@NXvdJvI=6LiVBpr`LJ9bH>kXpuRTfBk+h^FO z&G3_1nyR79F*vIC5vdiKFm8SHdyRtsImb@U;dZ|vbt4F7``z4sJ(7|!-tzHxdM`sn z&KJublB{iEPQF0t*{6_Axse)CLndmr<#nI9_MG|l-=pWM?`xg%GeS!XcUBF&gWd3T zZBtz?Q|tZ6jR?*ZwqYeb4TFnJS9qlI4Vtdq75QARgxIxvr1iLR9@If^xnanw&a-0E zoeq==TcfB);1uMOl|2BLd{$oJ>>cRUh3&K89g`$3PSsNFtj&WDKV|#2etK4(x;hJ~ zZRK^Udq#%=oOaGP4YnMQ4W!a}Z$(BxtI6N4?9btHb)-cNro}ij19xS^J>065C|gBEZ7IUt8Z5 zCxs+a9&ZNT=?kgoS#%gc+GuG{pk`#I`C156+nbFbK>%1hHd(p<5os1P;LmgL#3J2@ znE35(HJnRo)Oz_9nid?D0NQPcB`%k1IH)!R`#~wAhhVQk>>v4c5E#MRv-CRVC26SB z8?jyN9;YdNcxebWm61rEjU7aJJlLE5^7H6K$`uSa=d-ds6z`&p-sNn)GsDt2u8+VP z!liYc_7VUg+MNU}Jt%r*`E1|J&9}^Cejn4^qgkZEYWz4zpk-vnMg*iujB~_WI>~>q zlr>oY_aSYb(a>Y6Lmm)9dDsHhCK%iNNX3K+IJxTv3mz}a$TwNR5y@yK~#Ut-0Q zRn4n0or$57hHG{$E%3VTX;@0~x0`g{bL)Q1E*DH7hefS2isQBRoxXm%AH7A;Cawo) z8f5OVQ}%HqtdjN-`wXgf9ywQME^i(Gedz|ulUJ-dQ;NGvRB5YMQCmy&Zs^gubLVxX zpNpwJN$O(81i0t_I(Y^!_(hpGfLqr&?XN1v^!7i5;LW{YHk-M-F#hf%uVjUT-DNsf z*rfa1gdf4Mt+KRE6bID(6w9x}pC5~!A#h!-ny2=p;Z51U`UPcc>HR|9^<*#kFx%OR zwWFJ)(p{wIw4Soz%=XhYDyGzFv`sp2v0HbKJ+N`ddG%gJ8()$Js>zzLvjhw`OAKej zK{2lHiY*p@2=Jo#n$8@lQ(~IlUL`_d~ sLW{u*k~m6eIvUwpg96v_oSC^Phl8cL>SE5&*;|m7P\n keys: alt+1 approve · alt+2 reject · alt+3 cancel\n enter submit · esc cancel\n\n\n────────────────────────────────────────────────────────────────────────────────\n\n────────────────────────────────────────────────────────────────────────────────\nready for a message fixture/deterministic · thinking none\nBraid starter · runner 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/.worktrees/braid-integration", + "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", + "reasoningEffort": "none" + } + }, + "draft": "", + "messages": [], + "runs": [], + "interactions": [], + "queue": [], + "activeRunId": null, + "lastError": null + }, + "view": { + "revision": 1, + "workspace": "/home/drew/code/.worktrees/braid-integration", + "profileName": "Braid starter", + "profileDigest": "02a2ebddee943473ae42c9fa387abaa956799ab7791b68033006dd4029866660", + "runner": "pi", + "model": "fixture/deterministic", + "effort": "none", + "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": [], + "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" + ], + "queuePosition": 0, + "secret": false + } + ], + "activity": [], + "graph": [ + { + "id": "conv-1", + "type": "conversation", + "title": "New conversation", + "status": "complete", + "depth": 0, + "edgeLabel": "attached" + }, + { + "id": "branch-1", + "type": "branch", + "title": "Main", + "status": "complete", + "depth": 1, + "edgeLabel": "attached" + } + ], + "capabilities": { + "conversation.create": { + "available": true, + "source": "application" + }, + "conversation.open": { + "available": true, + "source": "application" + }, + "run.runner": { + "available": false, + "source": "application", + "reason": "Runner overrides require the current profile compatibility helpers" + }, + "run.model": { + "available": false, + "source": "application", + "reason": "Model overrides require provider capabilities from the current connection" + }, + "run.effort": { + "available": false, + "source": "application", + "reason": "Effort overrides require provider capabilities from the current connection" + }, + "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 active run" + }, + "run.reconcile": { + "available": false, + "source": "provider", + "reason": "There is no active run" + } + }, + "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/.worktrees/braid-integration" + } + } + } + ] + } +} diff --git a/artifacts/verification/w6/states/interaction.png b/artifacts/verification/w6/states/interaction.png new file mode 100644 index 0000000000000000000000000000000000000000..4925de57ab4c82d5147cf7925fd7e95076f6137c GIT binary patch literal 47608 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 FzW^64HiQ5G literal 0 HcmV?d00001 diff --git a/artifacts/verification/w6/states/interaction.txt b/artifacts/verification/w6/states/interaction.txt new file mode 100644 index 0000000..d2f5924 --- /dev/null +++ b/artifacts/verification/w6/states/interaction.txt @@ -0,0 +1,24 @@ +braid cwd braid-integration · session New conversation + + Write a message, or press Ctrl+P for commands. + + + + permission · approve or reject + run: fixture-run-1 · Braid starter @ pi · queue 1 · timeout not rep... + Allow the fixture tool to inspect the selected file? + will: approve · reject · cancel + request: src/app/application.ts · read-only + detail: The fixture requests a bounded read for a real interaction ... + export class BraidApplication { + answer: y approve · n reject + > + keys: alt+1 approve · alt+2 reject · alt+3 cancel + enter submit · esc cancel + + +──────────────────────────────────────────────────────────────────────────────── + +──────────────────────────────────────────────────────────────────────────────── +ready for a message fixture/deterministic · thinking none +Braid starter · runner pi · deterministic fixture Ctrl+P commands diff --git a/artifacts/verification/w6/states/narrow.json b/artifacts/verification/w6/states/narrow.json new file mode 100644 index 0000000..750a4c4 --- /dev/null +++ b/artifacts/verification/w6/states/narrow.json @@ -0,0 +1,277 @@ +{ + "schemaVersion": 2, + "state": "narrow", + "source": { + "binary": "packed real binary from clean npm install", + "binarySha256": "abbc7d58b48a991469ec8dc103bd1e9479d22466f8ac77687aea24cf7b77a562", + "tarball": "tangle-network-braid-0.1.0.tgz", + "tarballSha256": "b1d5ba5b3c81034647fe9247d94a6f4662c7a22e6a501f6f226c36208e64d046" + }, + "dimensions": { + "columns": 40, + "rows": 12 + }, + "terminal": "node-pty/xterm-256color", + "provenance": { + "renderer": { + "package": "@earendil-works/pi-tui@0.83.0", + "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": "braid cwd braid-integration\n\n Write a message, or press Ctrl+P for\n commands.\n\n\n\n────────────────────────────────────────\n\n────────────────────────────────────────\nready for a message\nBraid starter\n", + "packedState": { + "schemaVersion": 2, + "capturePhase": "atomic-signal-frame", + "state": { + "schemaVersion": 2, + "revision": 1, + "sequence": 1, + "workspace": "/home/drew/code/.worktrees/braid-integration", + "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", + "reasoningEffort": "none" + } + }, + "draft": "", + "messages": [], + "runs": [], + "interactions": [], + "queue": [], + "activeRunId": null, + "lastError": null + }, + "view": { + "revision": 1, + "workspace": "/home/drew/code/.worktrees/braid-integration", + "profileName": "Braid starter", + "profileDigest": "02a2ebddee943473ae42c9fa387abaa956799ab7791b68033006dd4029866660", + "runner": "pi", + "model": "fixture/deterministic", + "effort": "none", + "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": [], + "interactions": [], + "activity": [], + "graph": [ + { + "id": "conv-1", + "type": "conversation", + "title": "New conversation", + "status": "complete", + "depth": 0, + "edgeLabel": "attached" + }, + { + "id": "branch-1", + "type": "branch", + "title": "Main", + "status": "complete", + "depth": 1, + "edgeLabel": "attached" + } + ], + "capabilities": { + "conversation.create": { + "available": true, + "source": "application" + }, + "conversation.open": { + "available": true, + "source": "application" + }, + "run.runner": { + "available": false, + "source": "application", + "reason": "Runner overrides require the current profile compatibility helpers" + }, + "run.model": { + "available": false, + "source": "application", + "reason": "Model overrides require provider capabilities from the current connection" + }, + "run.effort": { + "available": false, + "source": "application", + "reason": "Effort overrides require provider capabilities from the current connection" + }, + "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": "application", + "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 active run" + }, + "run.reconcile": { + "available": false, + "source": "provider", + "reason": "There is no active run" + } + }, + "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/.worktrees/braid-integration" + } + } + } + ] + } +} diff --git a/artifacts/verification/w6/states/narrow.png b/artifacts/verification/w6/states/narrow.png new file mode 100644 index 0000000000000000000000000000000000000000..369bd705966706387c536c49ef761443433b8dc5 GIT binary patch literal 11213 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 Pfj-D8zblqD{Oex;\n\n → ✓ Braid starter trusted · read-only · Braid starte\n\n enter · ^V valid · ^S save · esc\n\n\n────────────────────────────────────────────────────────────────────────────────\n\n────────────────────────────────────────────────────────────────────────────────\nready for a message fixture/deterministic · thinking none\nBraid starter · runner 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/.worktrees/braid-integration", + "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", + "reasoningEffort": "none" + } + }, + "draft": "", + "messages": [], + "runs": [], + "interactions": [], + "queue": [], + "activeRunId": null, + "lastError": null + }, + "view": { + "revision": 1, + "workspace": "/home/drew/code/.worktrees/braid-integration", + "profileName": "Braid starter", + "profileDigest": "02a2ebddee943473ae42c9fa387abaa956799ab7791b68033006dd4029866660", + "runner": "pi", + "model": "fixture/deterministic", + "effort": "none", + "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": [], + "interactions": [], + "activity": [], + "graph": [ + { + "id": "conv-1", + "type": "conversation", + "title": "New conversation", + "status": "complete", + "depth": 0, + "edgeLabel": "attached" + }, + { + "id": "branch-1", + "type": "branch", + "title": "Main", + "status": "complete", + "depth": 1, + "edgeLabel": "attached" + } + ], + "capabilities": { + "conversation.create": { + "available": true, + "source": "application" + }, + "conversation.open": { + "available": true, + "source": "application" + }, + "run.runner": { + "available": false, + "source": "application", + "reason": "Runner overrides require the current profile compatibility helpers" + }, + "run.model": { + "available": false, + "source": "application", + "reason": "Model overrides require provider capabilities from the current connection" + }, + "run.effort": { + "available": false, + "source": "application", + "reason": "Effort overrides require provider capabilities from the current connection" + }, + "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": "application", + "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 active run" + }, + "run.reconcile": { + "available": false, + "source": "provider", + "reason": "There is no active run" + } + }, + "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/.worktrees/braid-integration" + } + } + } + ] + } +} diff --git a/artifacts/verification/w6/states/profile.png b/artifacts/verification/w6/states/profile.png new file mode 100644 index 0000000000000000000000000000000000000000..3e1ad390b5d01a94ada123bcc993606fdf6e426d GIT binary patch literal 37804 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%0A#t^%s>a8cSE^^F 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 literal 0 HcmV?d00001 diff --git a/artifacts/verification/w6/states/profile.txt b/artifacts/verification/w6/states/profile.txt new file mode 100644 index 0000000..502cea2 --- /dev/null +++ b/artifacts/verification/w6/states/profile.txt @@ -0,0 +1,24 @@ +braid cwd braid-integration · session New conversation + + Write a message, or press Ctrl+P for commands. + + + + Active profile · Braid starter + source Braid starter · trusted · read-only + runner pi · model fixture/deterministic + digest sha256:02a2ebdd…29866660 + profiles + + > + + → ✓ Braid starter trusted · read-only · Braid starte + + enter · ^V valid · ^S save · esc + + +──────────────────────────────────────────────────────────────────────────────── + +──────────────────────────────────────────────────────────────────────────────── +ready for a message fixture/deterministic · thinking none +Braid starter · runner pi · deterministic fixture Ctrl+P commands diff --git a/docs/02-experience-specification.md b/docs/02-experience-specification.md index 998e7d7..6ba1faf 100644 --- a/docs/02-experience-specification.md +++ b/docs/02-experience-specification.md @@ -222,6 +222,7 @@ An unknown slash command is not sent to the agent and opens a correction list. | `/cancel` | Request explicit cancellation and wait for a confirmed terminal or honest unknown state | | `/activity` | Open run events, tools, workers, usage, receipts, and logs | | `/export` | Export selected conversation, branch, trace, analysis, or redacted diagnostic bundle | +| `/import ` | Restore a redacted Braid conversation as an offline local copy | | `/settings` | Open user, workspace, appearance, retention, keymap, and update settings | | `/help [query]` | Search commands, keys, concepts, and current capability explanations | | `/quit` | Persist drafts, leave durable runs detached unless explicitly cancelled, and exit | @@ -443,6 +444,8 @@ Mouse selection and wheel scrolling are optional enhancements and no operation r `--plain` and headless state output provide a cursor-control-free route for assistive technology and log capture. +Plain output emits no terminal control metadata, and accessibility-configured TUI output suppresses titles, hyperlinks, and other OSC metadata. + Animation can be disabled, and reduced-motion mode replaces spinners with stable state text. ## Loading, empty, and error states diff --git a/docs/03-architecture.md b/docs/03-architecture.md index 8e3a5b1..68f8d0e 100644 --- a/docs/03-architecture.md +++ b/docs/03-architecture.md @@ -95,7 +95,7 @@ src/ tangle/ cloud provider construction and connection health eval/ agent-eval analysis adapter supervisor/ runtime supervisor adapter - sqlite/ journal, projections, migration, and integrity + storage/ encrypted journal, projections, migration, and integrity credentials/ operating-system credential references commands/ typed built-in command registry and parsers views/ @@ -163,6 +163,42 @@ If idempotency is unavailable, the controller reports unknown outcome and requir All long-running effects accept `AbortSignal` and report cancellation independently from provider-run cancellation. +### W5 durable coordination and storage + +The application uses `JournalPort` from `src/ports/effect-storage.ts` for event envelopes and `EffectStoragePort` from the same file for operation records. + +`SerializedEffectCoordinator` computes a canonical SHA-256 digest from `effectKind` and request data, atomically reserves the operation in durable storage, and only then schedules the external handler. + +The coordinator serializes handlers across operations, so two dispatches cannot overlap through the same coordinator. + +An identical operation and digest returns the existing record without a second dispatch. + +A changed digest writes a separate `conflict` audit record and never dispatches. + +Dispatch handlers return only provider-neutral `acknowledged`, `failed`, `unknown`, or `terminal` outcomes. + +A dispatch exception becomes `unknown` because the external boundary may have accepted the request before the exception was observed. + +An old `pending` record is reconciled only through an explicit handler query; if no reconciliation result exists, Braid leaves it pending rather than guessing. + +`StorageJournal` rebuilds the application state from the encrypted SQLite journal on startup and queues application events through the asynchronous `StoragePort`. + +`SqliteStorage` enables WAL, foreign keys, full synchronous commits, bounded serialized writes, resumable first-time initialization, schema migrations, transactionally consistent encrypted backups, projection checksums, integrity checks, retention, redaction rewrite, and content-key destruction. + +Backup and restore operations take an exclusive filesystem lock, enforce an approved workspace root, open source files through no-follow descriptors, reject hard-linked inputs, sync files and directories, and publish backups without clobbering an existing destination. + +Restore writes a durable manifest before moving the live database and replays that manifest during startup so every forced-kill point either keeps the old database or finishes with a verified candidate. + +The exact `better-sqlite3-multiple-ciphers@12.11.1` binding is required and must expose SQLCipher key and rekey operations; a plain SQLite binding is rejected. + +Each conversation has a random content key stored only through `CredentialPort`, separate from the encrypted database key. + +The production credential implementation calls the operating-system stores through `@napi-rs/keyring@1.3.0`; it does not place secret bytes in a child process, command argument, or environment variable. + +`MemoryJournal` and `MemoryStorage` implement the same ports only for deterministic tests and are not selected by non-fixture composition. + +An unavailable content key is surfaced as an unreadable retained ciphertext state and prevents silent journal reconstruction. + ## Application ports The following interfaces describe Braid boundaries, not new provider protocols. @@ -211,6 +247,25 @@ interface CredentialPort { } ``` +The W5 implementation uses the following narrow application ports. + +```ts +interface JournalPort { + envelope(state: BraidState, event: BraidEvent): BraidEventEnvelope + append(envelope: BraidEventEnvelope): void + all(): readonly BraidEventEnvelope[] +} + +interface EffectStoragePort { + current(operationId: string): EffectRecord | undefined + latest(operationId: string, requestDigest: string): EffectRecord | undefined + appendEffect(record: EffectRecord): void + history(operationId: string): readonly EffectRecord[] +} +``` + +The production SQLite implementation makes `appendEffect` durable before it returns and records the canonical request digest with every effect status. + The execution adapter must be thin enough that contract tests can run directly against both CLI Bridge and Tangle providers. ## Event path @@ -329,6 +384,8 @@ The journal stores event identifier, schema version, event kind, Braid entity id A unique `(run_id, event_id)` constraint prevents duplicate runtime events, while the original provider event identifier remains separately available when one exists. +The local event identifier is scoped to its run, while `providerEventId` records the provider's separate session-scoped identity and is never used as Braid's global event key. + A unique operation identifier prevents duplicate user side effects. Events are logically immutable during normal operation. @@ -349,6 +406,8 @@ Every projection can be discarded and rebuilt from the journal at the current sc Projection checksums are compared in tests after incremental reduction and full replay. +SQLite stores compact projection metadata and advances event and run digests incrementally instead of rewriting all event identifiers for every append. + ### SQLite operation SQLite uses write-ahead logging, foreign-key enforcement, a bounded busy timeout, one serialized writer, and concurrent read connections. @@ -359,6 +418,9 @@ Startup performs a quick integrity check and release verification exercises full The database driver is isolated behind `StoragePort` and cannot leak driver objects into controllers. +W5 provides that driver as `SqliteStorage`, and non-fixture composition opens it before the application can accept work. +Deterministic tests use `MemoryJournal` behind the same interfaces; production composition never selects that adapter. + ### Provider authority A stored provider session identifier proves only that Braid once received it. diff --git a/docs/04-runtime-contracts.md b/docs/04-runtime-contracts.md index ee6520f..0a08e17 100644 --- a/docs/04-runtime-contracts.md +++ b/docs/04-runtime-contracts.md @@ -2,27 +2,38 @@ ## Purpose -This document separates capabilities that exist today from capabilities Braid requires upstream. +This document separates capabilities available in Braid's installed packages from capabilities that must remain disabled. -Braid implementation must not turn a planned or partially wired capability into a product claim. +Braid must not turn a type declaration, capability flag, or planned method into a product claim without a real Braid flow proving it. + +When a current package blocks a real Braid flow, Braid records the unavailable action, preserves the rest of the product, and files an upstream issue with the exact reproduction. ## Evidence baseline -The following versions and commits were read directly on 2026-08-01. +The following published versions were queried from npm and their installed declarations were inspected directly on 2026-08-04. + +| Package | Installed version | Braid boundary | +| --- | ---: | --- | +| [`@tangle-network/agent-interface`](https://github.com/tangle-network/agent-sdk/tree/main/packages/agent-interface) | `0.43.0` | Canonical profile, capabilities, environment, stream, portable context, and interaction contracts | +| [`@tangle-network/agent-runtime`](https://github.com/tangle-network/agent-runtime) | `0.128.0` | Sole execution layer; public box, executor, chat, environment-provider, and terminal-monitor exports | +| [`@tangle-network/agent-eval`](https://github.com/tangle-network/agent-eval) | `0.144.1` | Run records, judges, trace analysts, comparisons, and feedback trajectories | +| `@tangle-network/agent-provider-cli-bridge` | `0.3.4` | CLI Bridge environment adapter with live streaming, replay, retry-safe turns, and explicit cancel | +| `@tangle-network/agent-provider-tangle` | `0.4.10` | Tangle environment adapter over the sandbox client | +| `@tangle-network/sandbox` | `0.18.0` | Tangle cloud client used by the provider | + +The installed runtime publishes `agent-eval >=0.143.0 <0.144.0`, `agent-interface >=0.43.0 <0.44.0`, and optional `sandbox >=0.17.2 <0.18.0` as peer ranges. + +Braid exercises runtime `0.128.0` with eval `0.144.1` and sandbox `0.18.0`, explicitly allows those tested combinations in pnpm, and tracks the stale runtime peer declarations in [agent-runtime issue 734](https://github.com/tangle-network/agent-runtime/issues/734) and [agent-runtime issue 737](https://github.com/tangle-network/agent-runtime/issues/737). + +### Installed package boundary + +Braid composes the current provider packages through `agent-runtime` and keeps all provider-specific construction in adapters. -| Package or repository | Published version | Source commit inspected | Relevant fact | -| --- | ---: | --- | --- | -| [`@tangle-network/agent-interface`](https://github.com/tangle-network/agent-sdk/tree/main/packages/agent-interface) | `0.40.0` | `agent-sdk@32acb32ec43d0040a734a81ddbf516ee5079bf0f` | Canonical profile, capabilities, environment, stream, and interaction types | -| [`@tangle-network/agent-runtime`](https://github.com/tangle-network/agent-runtime) | `0.117.0` | `agent-runtime@9b2005d43186144cb7cbf606b98637434bee3c8f` on `origin/main` | Source is versioned `0.118.0` and exports an unreleased `./tui` surface | -| [`@tangle-network/agent-eval`](https://github.com/tangle-network/agent-eval) | `0.140.1` | `agent-eval@0eb2e324c12eb0d8b1e937ce00d345fa446d52ce` | Run records, judges, trace analysts, comparisons, and feedback trajectories | -| [`cli-bridge`](https://github.com/drewstone/cli-bridge) | Server repository | `cli-bridge@8dcff3e29f0afb73358ad7ba27a9301acbffa8f7` | Local runner materialization, OpenAI-compatible streaming, durable run routes, and backend adapters | -| `@tangle-network/agent-provider-cli-bridge` | `0.3.0` | Published tarball inspected | Environment adapter over CLI Bridge with replay and explicit cancel, but no detach or interaction response | -| `@tangle-network/agent-provider-tangle` | `0.4.6` | Published tarball inspected | Environment adapter over `@tangle-network/sandbox` with sessions, replay, workspace, checkpoint, and fork | -| `@tangle-network/sandbox` | `0.15.2` | Published version checked | Tangle cloud sandbox client used by the provider | +The CLI Bridge and Tangle providers remain transport implementations rather than alternate application shells. -The source `agent-runtime@0.118.0` peer range for `agent-eval` is `>=0.139.2 <0.140.0`, while the published current `agent-eval` is `0.140.1`. +The current interface exposes optional interaction-response methods, but neither installed provider declaration exposes that operation and the runtime turn API does not add one. -That mismatch is an upstream integration item and Braid must not bypass it with an ignored peer warning. +Braid therefore renders interactions but disables response actions for those providers until a real run proves support. At implementation start and before every release, rerun `npm view @tangle-network/ version` and inspect the installed declarations because these packages change frequently. @@ -115,15 +126,15 @@ Raw `deliver` can support live steering only after a typed runtime adapter defin ### Runtime supervisor interface -The current source exports `@tangle-network/agent-runtime/tui` and a diagnostic `agent-runtime-top` binary from the unreleased `0.118.0` package source. +The published runtime exports an experimental `@tangle-network/agent-runtime/tui` module and an `agent-runtime-top` binary. -The module owns snapshot loading and presentation types for `.agent/supervisor/` state, including workers, spend, tokens, latency, logs, and steering inboxes. +The module owns snapshot loading and presentation types for runtime supervisor state, including workers, spend, tokens, latency, and logs. That read model belongs in runtime because the on-disk layout is runtime-owned. -The current monitor writes `cancel.request.json`, but a repository-wide search at `9b2005d` found no reader for that file. +Braid may adapt `loadTopSnapshot` into its own worker view, but it must not copy the runtime file layout or embed the separate terminal application. -The current monitor's file cancellation is therefore not a functional cancellation path and cannot be used by Braid. +The module does not export its write-side steer and cancellation operations as a reusable API, so Braid does not advertise those controls through this surface. ## Existing CLI Bridge server contract @@ -163,7 +174,7 @@ These behaviors may be suitable for isolated benchmark automation under an expli ## Existing CLI Bridge provider contract -The published `@tangle-network/agent-provider-cli-bridge@0.3.0` resolves the bridge model from a turn override, provider default, or profile harness and model. +The published `@tangle-network/agent-provider-cli-bridge@0.3.4` resolves the bridge model from a turn override, provider default, or profile harness and model. It sends stable `executionId` values as bridge run identifiers when they satisfy the bridge identifier rules. @@ -183,7 +194,7 @@ It exposes no generalized interaction response. ## Existing Tangle provider contract -The published `@tangle-network/agent-provider-tangle@0.4.6` wraps `@tangle-network/sandbox` as an `AgentEnvironmentProvider`. +The published `@tangle-network/agent-provider-tangle@0.4.10` wraps `@tangle-network/sandbox` as an `AgentEnvironmentProvider`. Its default capabilities report full canonical profile dimensions, live and replay streaming, detach, turn idempotency, session continuation, session list and messages, workspace read/write/exec/git/upload/download, checkpoint and fork, placement, usage, and confidentiality. @@ -203,6 +214,17 @@ Paired comparisons retain outcomes and costs across two frozen candidates. `analyzeTraces`, `buildDefaultAnalystRegistry`, `runExact`, and `runExactStream` provide bounded trace-analysis workflows with exact citations, findings, cost, and latency. +The current DSPy RLM engine accepts a caller-owned model function, stable public call reference, and execution recorder instead of a provider URL or credential. +Braid gives each analyst invocation one explicit runtime transport attempt by default so its recorded usage and cost cannot hide additional paid retries. + +Braid binds that function to the selected profile, connection, effective model, and runtime package version. + +`agent-runtime` executes each canonical text-message request through `runAgentTaskStream`; Braid returns normalized output, measured token usage, priced cost, terminal status, and finite redacted execution evidence to `agent-eval`. + +The callback rejects multimodal and request-level thinking controls because runtime `0.128.0` does not expose those fields on its OpenAI-compatible backend. + +Reasoning remains an `AgentProfile` setting, and unsupported callback shapes fail before a provider call rather than being silently dropped. + Default analysis can combine deterministic checks with failure-mode, knowledge-gap, knowledge-poisoning, and improvement analysts when an engine is configured. `FeedbackTrajectory` can represent user approvals, rejections, edits, and selections as evaluation data. diff --git a/docs/06-conversations-forks-and-analysis.md b/docs/06-conversations-forks-and-analysis.md index ec0065b..ee70d3f 100644 --- a/docs/06-conversations-forks-and-analysis.md +++ b/docs/06-conversations-forks-and-analysis.md @@ -273,7 +273,14 @@ Export never includes credential values or secret interaction answers. Import validates schema, identifiers, graph acyclicity, checksums, redaction markers, and version migration before writing. -Imported external provider references are historical by default and cannot be controlled until reauthenticated and reconciled. +`/import ` and the `import_conversation` JSONL command accept only canonical Braid JSON up to 2 MiB. + +Import remaps every durable identifier into a deterministic local namespace, writes the complete conversation in one event, and leaves drafts and queues empty. + +Imported runs retain historical text, normalized part provenance, outcomes, usage, cost, citations, completeness, and the source export digest, while provider sessions, connections, bindings, environments, live run replay cursors, pending interactions, and write capabilities are removed. +Event-only citation identifiers remain visible as historical provenance but are labeled unsupported because source journal events are not reintroduced as live events; message- and part-backed citations remain resolvable. + +Continuing an imported conversation creates a fresh run through the currently selected profile and connection; it never resumes or controls the source provider session. ## `/ask` contract diff --git a/docs/08-verification.md b/docs/08-verification.md index f0b2dbd..aa92c99 100644 --- a/docs/08-verification.md +++ b/docs/08-verification.md @@ -89,7 +89,7 @@ Errors use stable machine codes plus concise human text and never include secret | `list_profiles` / `select_profile` / `validate_profile` / `save_profile` | Drive canonical profile workflows | | `list_connections` / `test_connection` / `select_connection` | Drive connection workflows without exposing secret values | | `set_run_override` | Set runner, model, effort, or mode for the next run | -| `new_conversation` / `list_conversations` / `open_conversation` | Drive conversation navigation | +| `new_conversation` / `list_conversations` / `open_conversation` / `rename_conversation` / `archive_conversation` / `delete_conversation` | Drive conversation navigation and lifecycle | | `set_draft` / `send` / `queue` / `remove_queued` / `steer` | Drive input and active-run behavior | | `respond_interaction` | Submit a typed canonical response with stable operation identity | | `cancel_run` | Request and await explicit cancellation state events | @@ -97,12 +97,12 @@ Errors use stable machine codes plus concise human text and never include secret | `ask` / `analyze` / `compare` / `promote_analysis` | Drive `agent-eval` workflows | | `get_graph` / `get_activity` / `get_details` | Query semantic product views | | `steer_worker` / `cancel_worker` | Drive typed runtime supervisor controls | -| `export` | Produce a redacted export and digest | +| `export` / `import_conversation` | Produce or safely restore a redacted conversation with a verified digest | | `shutdown` | Persist state and apply configured detach or cancel behavior | The published protocol schema marks every mutating command as operation-bearing and rejects it when `operationId` is absent. -This includes profile and connection changes, conversation and branch changes, send and queue changes, interaction response, cancellation, fork execution, analysis and promotion, worker control, export creation, and deletion. +This includes profile and connection changes, conversation and branch changes, conversation import, send and queue changes, interaction response, cancellation, fork execution, analysis and promotion, worker control, export creation, and deletion. ### Protocol behavior @@ -120,6 +120,38 @@ The protocol schema is published in the npm package and checked for backward com End of input performs the same safe shutdown as `shutdown` with the configured default and returns a meaningful process exit code. +### Cancellation, restart, and evidence rules + +Cancellation is a two-party operation. + +Braid first records `run.cancel.requested`, then asks the runtime port for provider acknowledgement while the run is shown as `cancelling`. + +Local stream abortion is only cleanup and never proves that the provider stopped. + +An acknowledgement records `aborted`; a rejected, missing, or timed-out acknowledgement records `unknown` with the reason. + +Production uses encrypted SQLite behind `StoragePort`, including the event journal, canonical projection, operation ledger, immutable run receipts, snapshots, backups, recovery markers, and content-key lifecycle. + +It loads and verifies durable state before any dispatch, so a restart reconciles an existing operation identifier instead of starting the provider twice. + +The deterministic memory journal remains fixture-only and composes through the same application ports. + +`shutdown` is operation-bearing in JSONL, plain mode, command-key paths, signal handling, and the terminal command palette. + +All of those paths commit one `application.shutdown.requested` event before waiting for idle or cancellation completion. + +Assistant message text and every rendered part pass through the same sanitized character and line bounds before Pi TUI receives them. + +The packed proof runs send, graph, unavailable command, retry, cancellation, and shutdown through terminal, RPC, and plain mode. + +RPC and plain mode use separate stdout and stderr pipes; only terminal mode uses a pseudoterminal. + +Visual state captures use one signal-triggered semantic record with a matching revision, and interaction and fork captures contain real fixture `answerSpec` and fork-preview data. + +Every raster manifest records the Pi TUI, PTY, emulator, Node, `agg`, ImageMagick, font, color mode, packed binary, and tarball provenance. + +Package proof builds an isolated copy, records a digest of the exact source copy, packs that build, and installs the resulting tarball before running the binary. + ## Deterministic test adapter The deterministic provider implements the production execution, profile validation, interaction, session, workspace, analysis, and supervisor ports without network or subprocesses. @@ -146,8 +178,45 @@ The fake adapter cannot satisfy any check labeled live. Static checks include formatting, lint, strict type checking, dependency boundaries, exhaustive event handling, generated schema freshness, package exports, license inventory, vulnerability policy, and build. +The repository-owned module-cycle check walks every `src` TypeScript module, includes type-only imports and re-exports, resolves relative `.js` specifiers to `.ts`, reports exact strongly connected components, rejects a zero-module scan, and runs in `pnpm check` with a deterministic synthetic-cycle self-test. + The package test installs the packed tarball in a clean directory and runs `braid --version`, `braid --help`, one headless deterministic turn, and one virtual-terminal deterministic turn. +### W5 application-core, storage, and release checks + +W5 has stable package entry points for `test:unit`, `test:contract`, `test:coordination`, `test:rpc`, `test:virtual-terminal`, `test:pty`, `test:storage`, `test:crash`, `test:security`, `test:performance`, `test:live`, `test:install`, `test:capture`, and `check:release`. + +`test:storage` exercises the coordinator, the deterministic storage adapter, and the production SQLite adapter for atomic pending admission, serialized execution, duplicate reconciliation, conflict recording, encrypted payloads, WAL, foreign keys, replay cursors, missing history, projections, backups, approved-root and no-clobber enforcement, restore, retention, redaction, key destruction, migration interruption, lock handling, and commit failure. + +`test:crash` runs a compiled child process that is killed before and after every SQLite commit boundary and every backup, restore-manifest, copy, move, install, cleanup, and publication boundary, then reopens the database and checks integrity and durable outcome state. + +`test:security` checks protected headless key sources, operating-system credential availability, secret canaries, secret-designated interaction values, and production fail-closed behavior. + +`test:install` and `test:pty` run the packed-package proof, while `test:capture` runs the deterministic terminal capture. + +`test:live` exits nonzero with a precise external prerequisite message because live provider services and credentials are not available in this repository's deterministic test environment. + +`test:coordination` includes a two-process native SQLite race that proves one external dispatch for one operation identifier. + +`test:performance` records native SQLite append measurements at 10,000 and 100,000 events and verifies the resulting event count and integrity report. + +The native storage test commands fail with an explicit prerequisite when the exact encrypted SQLite package is absent; they never convert missing production coverage into a passing or silently skipped result. + +The reducer property test generates 1,000 histories and compares incremental reduction with full replay by canonical projection checksum. + +The production adapter, not `MemoryStorage`, is the proof source for encryption, crash recovery, backup, restore, content-key destruction, and concurrent reader/writer behavior. + +### W5 requirement mapping + +| Requirement | Proof in this repository | +| --- | --- | +| `AR-03`–`AR-07`, `AR-10` | `test/domain-ids.test.ts`, `test/domain-reducer.test.ts`, `scripts/check-boundaries.mjs`, `scripts/check-dependencies.mjs`, `test/scripts.test.ts` | +| `PR-09` | Restarted SQLite projection checksum and `StorageJournal.fromStorage` replay in `test/storage.test.ts` | +| `PC-08`–`PC-10` | `test/security.test.ts`, headless key validation, credential-port availability failure, and package metadata checks | +| `CF-01`, `CF-08` | Branded graph identifiers, operation/effect records, duplicate-event and conflict tests in `test/domain-ids.test.ts`, `test/domain-reducer.test.ts`, and `test/coordination.test.ts` | +| `SE-01`, `SE-02`, `SE-06`, `SE-07` | Secret rejection, raw-byte canaries, wrong-key rejection, protected key-source tests, and dependency/license checks | +| `ST-01`–`ST-10` | Production SQLite encryption, atomic commit, duplicate/gap/replay, forced-kill, migration, integrity, retention/redaction, provider-state non-guessing, and concurrent-writer tests | + ### Layer 2: unit and property checks Unit checks cover parsers, canonicalization, digests, redaction, reducers, commands, selectors, view-model builders, layout, capability decisions, state machines, and storage queries. @@ -202,6 +271,8 @@ The required flow types a prompt, edits multiline input, selects profile and run Tests cover alternate-screen and inline modes, legacy and Kitty keyboard modes, `NO_COLOR`, 16-color, high-contrast, reduced-motion, and plain output. +The accessibility proof rejects every OSC title, hyperlink, progress, or equivalent metadata sequence; it does not allowlist individual OSC forms. + Forced `SIGINT`, `SIGTERM`, stream failure, and process kill verify terminal restoration and database recovery. ### Layer 7: live integrations @@ -249,7 +320,7 @@ The published package is downloaded from the registry after publication and its | LIVE-09 | Tangle workspace fork | Checkpoint, destination fork, independent destination file change, unchanged source file, and explicit cleanup of both environments | | LIVE-10 | Confidential Tangle path | Requested placement remains unverified until valid attestation is checked; negative nonce and measurement tests fail | | LIVE-11 | Runtime supervisor | Real root and worker stream, spend and status update, typed steering effect, typed cancellation effect, and reconnectable control | -| LIVE-12 | `agent-eval` trace analysis | Real source run freezes, analyst executes, citations resolve, source remains unchanged, and selected finding promotion records provenance | +| LIVE-12 | `agent-eval` trace analysis | Real source run freezes, the selected profile and connection execute analyst model calls through `agent-runtime`, usage and cost receipts settle, citations resolve, source remains unchanged, and selected finding promotion records provenance | If a required live provider is unavailable, the release is blocked and the manifest reports the unavailable check rather than marking it skipped or simulated. @@ -300,8 +371,8 @@ Every report includes hardware, operating system, Node version, terminal, dimens | ID | Boundary | Target | | --- | --- | --- | -| PERF-01 | Process start to first visible frame, warm database, 20 runs | p95 ≤ 250 ms | -| PERF-02 | Process start to first visible frame, cold 100,000-event database, 20 runs | p95 ≤ 1,000 ms | +| PERF-01 | Process start to first visible frame, warm database and primed Node compile cache, 20 runs | p95 ≤ 250 ms | +| PERF-02 | Process start to first visible frame, cold 100,000-event database and a fresh empty Node compile cache for every run, 20 runs | p95 ≤ 1,000 ms | | PERF-03 | Key byte received to updated frame while idle, 1,000 keys | p95 ≤ 50 ms and p99 ≤ 100 ms | | PERF-04 | Runtime event received to updated frame at 100 events/s, 10,000 events | p95 ≤ 50 ms with zero missing or duplicate event | | PERF-05 | Replay and reduce 10,000 committed events | p95 ≤ 2 seconds | @@ -349,7 +420,7 @@ Each case runs on at least three representative source fixtures and includes a s ## Release evidence manifest -The release process writes `artifacts/verification//manifest.json` and a readable `report.md`. +The release process validates staged results from `artifacts/verification/release/checks.json`, then writes `artifacts/verification//manifest.json` and `artifacts/verification//report.md`. Large recordings, raw traces, and live logs may live in immutable CI or release storage, while the manifest stores content hashes and authenticated links. @@ -363,25 +434,63 @@ The manifest contains the following top-level data. "packageIntegrity": "sha512-…", "startedAt": "", "finishedAt": "", - "sourceState": {}, - "dependencies": [], - "environments": [], + "sourceState": { + "clean": true, + "commit": "", + "treeSha256": "", + "tarballSha256": "", + "tarballArtifactId": "package-tarball", + "specificationDigests": [] + }, + "dependencies": [ + { "name": "@tangle-network/agent-runtime", "version": "", "integrity": "sha512-…" } + ], + "environments": [ + { "id": "linux-release", "kind": "ci", "details": {} } + ], "checks": [], - "requirements": {}, + "requirements": { + "UX-01": { "checks": ["virtual-terminal"], "artifacts": ["80x24-frame"] } + }, "artifacts": [], "liveResources": [], "cleanup": [], - "signatures": [] + "signatures": [ + { + "algorithm": "ed25519", + "keyId": "sha256:…", + "payloadSha256": "", + "signature": "" + } + ] } ``` Each check records identifier, category, required status, command, working directory, environment identifier, start and end, exit code, attempt count, measured fields, result, stdout and stderr artifact hashes, and failure details. +Each check also carries an Ed25519 receipt over every check field, including the exact command, build digest, exit code, and stdout and stderr digests. + +The verifier accepts only the public key pinned in `release/execution-public-key.pem`; the private key is supplied through `BRAID_RELEASE_SIGNING_KEY_PATH`, must have owner-only permissions, and is never stored in the repository or evidence. + +The verifier rejects check identifiers outside the fixed command list and the requirement identifiers extracted from these specification documents. + +Every accepted check command must be one of the fixed commands below and its category must match that command. + +Timestamps use canonical millisecond UTC form such as `2026-08-02T07:00:00.000Z`, and the recorded duration must equal their difference. + +Measurements are typed scalar values, full distributions, or explicit unavailable or uncaptured records with a reason. + +A distribution records unit, sample count, minimum, median, p90, p95, p99, and maximum as finite ordered numbers. + +Passing checks set `failureDetails` to `null` and identify stdout and stderr as `{ "artifactId": "…", "sha256": "…" }`, including zero-byte output artifacts rather than omitting either stream. + +The release verifier requires every stable command row below, rejects every unreferenced check, and requires `UP-*`, `LIVE-*`, `PERF-*`, and `EVAL-*` requirements to cite an identically named check record of the appropriate category. + Each requirement maps to one or more check identifiers and artifact identifiers. A zero, null, unavailable, or uncaptured measured field remains in the manifest with its reason. -The verifier fails when a required identifier from any specification document is absent, duplicated, skipped, stale, run against another build digest, or linked only to an inadmissible proof type. +The verifier fails when a required identifier from any specification document is absent, duplicated, skipped, stale, unsigned, run against another build digest, or linked only to an inadmissible proof type. Live resource cleanup records each environment, checkpoint, session, temporary repository, and credential with confirmed or unresolved state. @@ -391,27 +500,37 @@ The release cannot complete with an unresolved externally billable test resource Implementation must provide the following stable scripts. -| Command | Scope | -| --- | --- | -| `pnpm check` | Format, lint, strict types, boundaries, schemas, licenses, and build | -| `pnpm test:unit` | Unit and normal property tests | -| `pnpm test:contract` | Shared port and capability conformance | -| `pnpm test:rpc` | Packed-binary JSONL protocol tests | -| `pnpm test:virtual-terminal` | Cell, layout, Unicode, and state snapshots | -| `pnpm test:pty` | Packed-binary real terminal keyboard and lifecycle tests | -| `pnpm test:storage` | Encryption, journal, migration, integrity, and crash recovery | -| `pnpm test:security` | Secret canaries, terminal attacks, paths, fuzzing, and static analysis | -| `pnpm test:performance` | All required Braid overhead measurements | -| `pnpm test:live:bridge` | Required CLI Bridge and runner matrix | -| `pnpm test:live:tangle` | Required inference, sandbox, interaction, fork, and confidential matrix | -| `pnpm test:live:supervisor` | Runtime worker observation and control | -| `pnpm test:live:analysis` | Real frozen trace and analyst path | -| `pnpm test:eval` | Judge calibration and semantic release cases | -| `pnpm test:install` | Packed package across supported release platforms | -| `pnpm capture:visual` | Deterministic real-binary captures and manifests | -| `pnpm verify:release` | Validate and assemble every required result into one signed evidence manifest | - -These commands do not exist in the planning-only repository yet and are implementation deliverables, not claims of current functionality. +| Check ID | Command | Scope | +| --- | --- | --- | +| `repository` | `pnpm check` | Format, lint, strict types, boundaries, dependency/license metadata, deterministic checks, and the release manifest check | +| `unit` | `pnpm test:unit` | Unit and normal property tests | +| `contract` | `pnpm test:contract` | Shared port and capability conformance | +| `coordination` | `pnpm test:coordination` | Durable effect admission, digest conflict, and dispatch serialization | +| `rpc` | `pnpm test:rpc` | JSONL protocol tests | +| `rpc-packed` | `pnpm test:rpc:packed` | Packed-binary JSONL protocol tests | +| `virtual-terminal` | `pnpm test:virtual-terminal` | Cell, layout, Unicode, keyboard, and state snapshots | +| `pty` | `pnpm test:pty` | Packed-binary real terminal keyboard and lifecycle tests | +| `storage` | `pnpm test:storage` | Encrypted production journal, migration, integrity, replay, retention, redaction, backups, and concurrent access | +| `crash` | `pnpm test:crash` | Production SQLite forced-kill recovery at every durable commit boundary | +| `security` | `pnpm test:security` | Secret canaries, terminal attacks, paths, fuzzing, and static analysis | +| `performance` | `pnpm test:performance` | Reducer, coordination, and storage overhead measurements; the full PERF-01..10 matrix lands in W12 | +| `live` | `pnpm test:live` | Aggregate live scope guard | +| `live-bridge` | `pnpm test:live:bridge` | Required CLI Bridge and runner matrix | +| `live-tangle` | `pnpm test:live:tangle` | Required inference, sandbox, interaction, fork, and confidential matrix | +| `live-supervisor` | `pnpm test:live:supervisor` | Runtime worker observation and control | +| `live-analysis` | `pnpm test:live:analysis` | Real frozen trace and analyst path | +| `eval` | `pnpm test:eval` | Judge calibration and semantic release cases | +| `install` | `pnpm test:install` | Packed package across supported release platforms | +| `capture` | `pnpm test:capture` | Deterministic baseline real-binary captures | +| `visual` | `pnpm capture:visual` | Deterministic real-binary state captures and manifests | +| `release` | `pnpm check:release` | Release manifest and evidence-set check | +| `verify:release` | `pnpm verify:release` | Validate and assemble every required result into one signed evidence manifest from an isolated clean tracked checkout | + +The deterministic local commands are implemented in this repository. + +The opt-in CLI Bridge flow and semantic evaluation implementation are present and execute when their configured runners are available. + +Tangle, supervisor, and live-analysis commands return a typed unavailable result until protected credentials, deployments, and evidence stores are supplied. ## Verification acceptance diff --git a/docs/09-delivery-plan.md b/docs/09-delivery-plan.md index 22a4862..ba90b32 100644 --- a/docs/09-delivery-plan.md +++ b/docs/09-delivery-plan.md @@ -201,16 +201,26 @@ The live cleanup report must confirm every test environment and checkpoint is de `tangle-network/braid` +### W5 delivered 2026-08-02 + +W5 adds the complete domain graph, exhaustive reducer, atomic durable effect admission, encrypted SQLite journal, operating-system credential port, protected headless key sources, durable restore recovery, and release checks. + +The coordinator records `pending` before a handler can dispatch, binds an operation to a canonical request digest, reconciles identical retries, records `acknowledged`, `failed`, `unknown`, `terminal`, and `conflict` outcomes, and fails closed when a pending external operation cannot be reconciled. + +Production composition opens `better-sqlite3-multiple-ciphers@12.11.1` through `StoragePort` and the operating-system credential port; the deterministic in-memory adapter remains available through the same ports for the fixture. + +Stable unit, contract, coordination, RPC, virtual-terminal, PTY, storage, crash, security, performance, live-prerequisite, install, capture, and release-check scripts are included. + ### Deliverables - Implement branded identifiers, domain invariants, graph entities, typed intents, journal events, pure reducer, and effect descriptions. -- Implement one serialized effect coordinator with operation identifiers bound to canonical request digests and explicit acknowledged, failed, unknown, conflict, and terminal outcomes. -- Implement the SQLite journal, transactional projections, WAL, SQLCipher-equivalent encryption, per-conversation content keys held outside SQLite, integrity checks, backups, migrations, rebuild, retention, key destruction, and verified redaction rewrite. +- Implement one serialized effect coordinator with atomic operation admission, operation identifiers bound to canonical request digests, and explicit acknowledged, failed, unknown, conflict, and terminal outcomes. +- Implement the SQLite journal, incremental transactional projections, WAL, SQLCipher-equivalent encryption, per-conversation content keys held outside SQLite, integrity checks, no-clobber backups, manifest-based restore recovery, migrations, rebuild, retention, tombstoned deletion, key destruction, and verified redaction rewrite. - Store profile snapshots, connection references, conversations, branches, turns, runs, messages, parts, interactions, analyses, graph edges, drafts, queues, rules, and bindings. - Implement duplicate-event, sequence-gap, cursor, missing-history, and projection-checksum behavior. - Implement the operating-system credential port and accept headless database keys only through a protected file descriptor or mode-0600 file outside the workspace. - Implement startup recovery and non-terminal run reconciliation hooks. -- Add deterministic forced-kill tests at every external and storage boundary. +- Add production-adapter forced-kill tests at every SQLite durable commit and filesystem transition boundary, plus a two-process effect-admission test and native 10k/100k measurements. ### Done when @@ -218,6 +228,10 @@ The live cleanup report must confirm every test environment and checkpoint is de Incremental projection and full journal replay must produce matching canonical checksums for the release property-test corpus. +W5 is complete in source when the focused coordinator, reducer, security, storage, crash, package, and release-contract checks pass against the exact production dependencies. + +The external live-provider prerequisite remains a later release check and is reported as an external blocker rather than simulated by the W5 fixture. + ## W6: Terminal and headless interfaces ### Repository diff --git a/docs/decisions/003-local-event-journal.md b/docs/decisions/003-local-event-journal.md index 68094ad..06d3c91 100644 --- a/docs/decisions/003-local-event-journal.md +++ b/docs/decisions/003-local-event-journal.md @@ -22,7 +22,9 @@ The journal is authoritative for Braid's product graph and user decisions. The provider remains authoritative for the live process, native session, cloud environment, and provider-specific replay cursor. -Every ingested provider event uses a unique `(run_id, event_id)` key. +Every ingested local event uses a unique `(run_id, event_id)` key. + +Provider event identity is stored separately because a provider's event identifier has provider-session scope and must not become Braid's global identity. Every user operation uses a stable operation identifier bound to a canonical request digest so retry after a crash cannot submit a turn, response, cancel, or fork twice and changed input cannot reuse the identity. @@ -34,7 +36,9 @@ Conversation payloads are encrypted inside the encrypted database with a separat Deleting a conversation destroys its content key and leaves only non-sensitive tombstones. -Redacting one event is an explicit maintenance operation that rewrites the remaining conversation payloads under a new content key, verifies full replay, atomically installs the result, and destroys the old key. +Redacting one event is an explicit maintenance operation that rewrites the remaining conversation payloads under a new content key, records a verified rotation phase, verifies full replay, atomically installs the result, and destroys the old key only after verification. + +Restoring a backup is an explicit manifest-driven operation protected by an exclusive lock and directory synchronization, so startup can recover a forced death between any filesystem transition. Normal application operation never mutates a committed event; deletion and redaction maintenance are the only physical rewrites. @@ -48,7 +52,9 @@ Schema migrations and event upcasters become release responsibilities. The SQLite implementation must use write-ahead logging, foreign keys, bounded transactions, backups before destructive migrations, and integrity checks after abnormal termination. -The initial implementation will use a current maintained SQLite binding with proven SQLCipher-compatible encryption after install smoke tests on every release platform, behind a storage port so the driver is replaceable. +The W5 implementation uses the maintained `better-sqlite3-multiple-ciphers@12.11.1` binding behind `StoragePort` and verifies key activation, encrypted database artifacts, WAL, backups, and wrong-key rejection in the production-adapter test suite. + +The deterministic `MemoryJournal` is test-only behavior behind the same application ports; non-fixture composition fails closed unless a durable encrypted adapter is available. ## Rejected alternatives diff --git a/docs/decisions/004-application-effect-coordination.md b/docs/decisions/004-application-effect-coordination.md new file mode 100644 index 0000000..8ca8d68 --- /dev/null +++ b/docs/decisions/004-application-effect-coordination.md @@ -0,0 +1,53 @@ +# ADR 004: Coordinate durable effects before external dispatch + +Status: accepted + +Date: 2026-08-02 + +## Context + +Braid's terminal and headless interfaces can retry after a response window, process restart, or transport failure. + +The existing vertical slice protected one process with an operation map, but that map was not a storage boundary and could not distinguish a changed retry from the original request. + +The application must not dispatch a durable or external mutation until its caller operation identifier and canonical request digest are durably recorded. + +## Decision + +Braid uses `SerializedEffectCoordinator` over `EffectStoragePort` for retry-safe external effects. + +The coordinator atomically admits `pending` before dispatch, serializes handlers, and appends one provider-neutral outcome record for `acknowledged`, `failed`, `unknown`, or `terminal` results. + +The first request digest owns an operation identifier through a durable admission row with a uniqueness constraint. + +An identical operation and digest returns the existing record without dispatching again. + +A changed digest writes a conflict audit record and dispatches nothing. + +An exception crossing the dispatch boundary becomes `unknown` because the provider may have accepted the request before the client observed the exception. + +Provider-supplied diagnostics are reduced to bounded machine-safe details before an effect record is persisted. + +If a local terminal event cannot be committed after provider dispatch, the effect remains `unknown` and the run remains eligible for explicit reconciliation rather than being acknowledged. + +A previously pending operation is resolved only through an explicit reconciliation handler. + +Without reconciliation evidence the coordinator leaves it pending and never guesses that repeating the mutation is safe. + +`JournalPort` and `EffectStoragePort` are application seams rather than new provider protocols. + +## Consequences + +Controllers can use one operation identity across terminal retries, headless reconnects, and process recovery. + +Storage failures before the pending append prevent dispatch entirely. + +The deterministic fixture can use `MemoryJournal` through the same ports without changing the reducer or event union. + +The SQLite implementation and credential facilities satisfy this contract through the production `EffectStoragePort`; the deterministic memory adapter remains fixture-only. + +## Verification + +`test:coordination`, `test:storage`, and the native two-process admission test prove pending-before-dispatch ordering, same-digest replay, changed-input conflict, serialized handlers, explicit failure states, unknown exceptions, terminal outcomes, pending reconciliation, and one dispatch across two SQLite processes. + +`check:release` proves the stable script names, port/coordinator artifacts, and composition boundary are present. diff --git a/docs/decisions/005-encrypted-sqlite-and-credential-boundaries.md b/docs/decisions/005-encrypted-sqlite-and-credential-boundaries.md new file mode 100644 index 0000000..6f5c182 --- /dev/null +++ b/docs/decisions/005-encrypted-sqlite-and-credential-boundaries.md @@ -0,0 +1,63 @@ +# ADR 005: Encrypt the journal and keep keys outside SQLite + +Status: accepted + +Date: 2026-08-02 + +## Context + +Braid retains conversation events, provider-neutral operation records, projection state, and user decisions locally. + +The database, its WAL and shared-memory files, and backups must remain unreadable if copied from disk. + +Conversation deletion must remain effective for retained ciphertext even when a backup still exists. + +Headless deployments cannot rely on an interactive credential prompt and must not accept secret material from environment variables or loose workspace files. + +## Decision + +Braid pins `better-sqlite3-multiple-ciphers@12.11.1` and rejects bindings that do not expose SQLCipher-compatible key operations. + +SQLite uses WAL, foreign keys, FULL synchronous commits, bounded serialized write transactions, schema versioning, encrypted pre-migration backups, integrity checks, and projection checksums. + +Backups use SQLite `VACUUM INTO` to produce a transactionally consistent encrypted snapshot, then Braid reopens that snapshot with the database key, runs quick and full integrity checks, synchronizes it, and publishes it through a no-clobber hard link followed by directory synchronization. + +Backup and restore paths must remain under the approved workspace root, source files are opened through `O_NOFOLLOW` descriptors with one-link identity checks, and restore uses an exclusive lock plus a durable manifest for every move, install, cleanup, and manifest-removal transition. + +First-time database creation writes a protected initialization marker before the encrypted file and removes it only after schema and projection initialization complete, so a forced process death can safely resume or replace an unexposed partial database. + +The database key and one random 32-byte content key per conversation are held through `CredentialPort` and never stored in SQLite. + +Redaction rewrites the conversation ciphertext under a new content key, records prepared and rewritten phases, verifies every rewritten row, installs the new reference transactionally, and destroys the old key only after verified recovery state is durable. + +Conversation destruction writes a non-sensitive tombstone in the same transaction as the destroyed-key marker and destroys its content key; Braid never treats unreadable content as an empty conversation and restart can rebuild the journal through the tombstone. + +Post-commit key-cleanup failures are recorded as unknown outcomes and repaired only through startup reconciliation; they are never replayed blindly under a new operation identifier. + +The production credential adapters use `@napi-rs/keyring@1.3.0` to access macOS Keychain, Linux Secret Service, and Windows Credential Manager through native APIs. + +Credential bytes never enter shell commands, process arguments, or environment variables, and temporary native buffers are erased after each operation. + +Headless key material is accepted only from an inherited protected file descriptor or an external mode-0600 file with a non-symlink path, one filesystem link, and matching ownership where the platform exposes ownership. + +File-backed headless keys are opened once without following the final symlink, validated through that descriptor, read with a fixed byte bound, and never reopened by path. + +## Consequences + +The application cannot start in production when the encrypted SQLite binding or credential facility is unavailable. + +Raw credential values and secret-designated interaction answers are rejected before a journal transaction. + +Provider diagnostics and credential-bearing connection references are rejected or reduced before durable storage. + +Storage tests must use the production adapter for encryption, crash, backup, restore, key destruction, and concurrent-access claims. + +The deterministic memory adapter remains useful for reducer and coordinator tests but cannot establish encryption or crash behavior. + +## Verification + +`test/storage` covers encrypted artifacts, duplicate and gap handling, replay cursors, projections, backups, restore, approved roots, descriptor identity, no-clobber publication, retention, redaction, migration interruption, integrity failure, wrong-key byte preservation, and commit failure. + +`test/crash` kills a child process before and after each SQLite durable commit and filesystem transition boundary and reopens the same encrypted database. + +`test/security` covers protected headless sources, environment rejection, credential-facility failure, secret canaries, and secret-designated payload rejection. diff --git a/package.json b/package.json index 8c23628..d476139 100644 --- a/package.json +++ b/package.json @@ -33,36 +33,66 @@ "url": "git+https://github.com/tangle-network/braid.git" }, "scripts": { - "build": "node scripts/clean.mjs && tsc -p tsconfig.build.json", + "build": "node scripts/clean.mjs && tsc -p tsconfig.build.json && node scripts/build-startup.mjs", "capture:w0": "pnpm run build && node scripts/capture-w0.mjs", - "check": "pnpm run format:check && pnpm run lint && pnpm run typecheck && pnpm run boundaries && pnpm run dependencies:check && pnpm run attribution && pnpm run licenses:check && pnpm run test", + "capture:visual": "pnpm run build && node scripts/capture-visual.mjs", + "check": "pnpm run format:check && pnpm run lint && pnpm run typecheck && pnpm run boundaries && pnpm run dependencies:check && pnpm run attribution && pnpm run licenses:check && pnpm run test && pnpm run check:release", "clean": "node scripts/clean.mjs", "format": "biome format --write .", "format:check": "biome format .", "lint": "biome lint .", "typecheck": "tsc -p tsconfig.json --noEmit", - "boundaries": "node scripts/check-boundaries.mjs", + "boundaries": "node scripts/check-boundaries.mjs && node scripts/check-module-cycles.mjs --self-test", "dependencies:check": "node scripts/check-dependencies.mjs", "attribution": "node scripts/check-attribution.mjs", "licenses:generate": "node scripts/generate-licenses.mjs", "licenses:check": "node scripts/generate-licenses.mjs --check", - "test": "node scripts/clean-tests.mjs && tsc -p tsconfig.test.json && node scripts/run-tests.mjs", + "test": "node scripts/test.mjs", + "test:unit": "pnpm run test -- --scope unit", + "test:contract": "pnpm run test -- --scope contract", + "test:coordination": "pnpm run test -- --scope coordination", + "test:rpc": "pnpm run test -- --scope rpc", + "test:rpc:packed": "pnpm run build && node scripts/test-rpc-packed.mjs", + "test:virtual-terminal": "pnpm run test -- --scope virtual-terminal", + "test:pty": "pnpm run build && node scripts/test-pty.mjs", + "test:storage": "pnpm run test -- --scope storage", + "test:crash": "pnpm run test -- --scope crash", + "test:security": "pnpm run test -- --scope security && pnpm run boundaries && pnpm run dependencies:check && pnpm run attribution", + "test:performance": "pnpm run build && pnpm run test -- --scope performance && node scripts/performance/run.mjs", + "test:live": "node scripts/check-scope.mjs live", + "test:live:bridge": "node scripts/live-bridge.mjs", + "test:live:bridge:matrix": "node scripts/live-bridge/matrix.mjs", + "test:live:tangle": "node scripts/live-required.mjs live-tangle", + "test:live:supervisor": "node scripts/live-required.mjs live-supervisor", + "test:live:analysis": "node scripts/live-required.mjs live-analysis", + "test:eval": "pnpm run build && node scripts/eval/run.mjs", + "test:install": "pnpm run test:package", + "test:capture": "pnpm run capture:w0", "test:package": "pnpm run build && node scripts/verify-package.mjs", "pack:check": "pnpm pack --pack-destination .tmp", - "proof:w0": "pnpm run build && node scripts/verify-package.mjs --record artifacts/verification/w0/package-proof.json && node scripts/capture-w0.mjs" + "check:release": "node scripts/check-release.mjs", + "release:check": "pnpm run check:release", + "verify:release": "node scripts/verify-release.mjs", + "proof:w0": "pnpm run build && node scripts/verify-package.mjs --record artifacts/verification/w0/package-proof.json && node scripts/capture-w0.mjs", + "proof:w6": "pnpm run build && node scripts/verify-package.mjs --record artifacts/verification/w6/package-proof.json && node scripts/capture-visual.mjs" }, "dependencies": { "@earendil-works/pi-tui": "0.83.0", - "@tangle-network/agent-eval": "0.139.2", - "@tangle-network/agent-interface": "0.40.0", - "@tangle-network/agent-runtime": "0.117.0", - "@tangle-network/sandbox": "0.15.2", - "chalk": "5.6.2" + "@napi-rs/keyring": "1.3.0", + "@tangle-network/agent-eval": "0.144.3", + "@tangle-network/agent-interface": "0.43.0", + "@tangle-network/agent-provider-cli-bridge": "0.3.4", + "@tangle-network/agent-provider-tangle": "0.4.10", + "@tangle-network/agent-runtime": "0.128.0", + "@tangle-network/sandbox": "0.18.0", + "better-sqlite3-multiple-ciphers": "12.11.1", + "chalk": "6.0.0" }, "devDependencies": { "@biomejs/biome": "2.5.6", - "@types/node": "24.10.1", - "@xterm/headless": "5.5.0", + "@types/node": "26.1.2", + "@xterm/headless": "6.0.0", + "esbuild": "0.28.1", "node-pty": "1.1.0", "typescript": "7.0.2" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9877c9b..2d141ab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,10 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + hono: 4.13.0 + undici: 7.29.0 + importers: .: @@ -11,31 +15,46 @@ importers: '@earendil-works/pi-tui': specifier: 0.83.0 version: 0.83.0 + '@napi-rs/keyring': + specifier: 1.3.0 + version: 1.3.0 '@tangle-network/agent-eval': - specifier: 0.139.2 - version: 0.139.2 + specifier: 0.144.3 + version: 0.144.3 '@tangle-network/agent-interface': - specifier: 0.40.0 - version: 0.40.0 + specifier: 0.43.0 + version: 0.43.0 + '@tangle-network/agent-provider-cli-bridge': + specifier: 0.3.4 + version: 0.3.4 + '@tangle-network/agent-provider-tangle': + specifier: 0.4.10 + version: 0.4.10(@tangle-network/sandbox@0.18.0) '@tangle-network/agent-runtime': - specifier: 0.117.0 - version: 0.117.0(@tangle-network/agent-eval@0.139.2)(@tangle-network/agent-interface@0.40.0)(@tangle-network/sandbox@0.15.2) + specifier: 0.128.0 + version: 0.128.0(@tangle-network/agent-eval@0.144.3)(@tangle-network/agent-interface@0.43.0)(@tangle-network/sandbox@0.18.0) '@tangle-network/sandbox': - specifier: 0.15.2 - version: 0.15.2 + specifier: 0.18.0 + version: 0.18.0 + better-sqlite3-multiple-ciphers: + specifier: 12.11.1 + version: 12.11.1 chalk: - specifier: 5.6.2 - version: 5.6.2 + specifier: 6.0.0 + version: 6.0.0 devDependencies: '@biomejs/biome': specifier: 2.5.6 version: 2.5.6 '@types/node': - specifier: 24.10.1 - version: 24.10.1 + specifier: 26.1.2 + version: 26.1.2 '@xterm/headless': - specifier: 5.5.0 - version: 5.5.0 + specifier: 6.0.0 + version: 6.0.0 + esbuild: + specifier: 0.28.1 + version: 0.28.1 node-pty: specifier: 1.1.0 version: 1.1.0 @@ -111,51 +130,295 @@ packages: resolution: {integrity: sha512-IoYrb0rORjELmEpNtoCA/U8je3KopMkRAVJRdSzvXRvgb+Huo1gNh8Q5CSZvNOiYtDxJdj2tYZZHZ4B3+IN3hA==} engines: {node: '>=22.19.0'} + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@hono/node-server@2.0.12': resolution: {integrity: sha512-eWpQYr67tqJLeaSUl0Q+TquuYfUdTibpOJlUMV2FfUP7+KqCC5TufnwnlXL6mobZBJbGAYRd7ZvEBDCbLInjhg==} engines: {node: '>=20'} peerDependencies: - hono: ^4 + hono: 4.13.0 + + '@napi-rs/keyring-darwin-arm64@1.3.0': + resolution: {integrity: sha512-pl76hJvdYUBn6I24bXiOBMA9nbDapo3I5B+f3OorjDU4dUMSypXeKbOVehJe8fhgTiH24flMyTS3aAIy43xegQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/keyring-darwin-x64@1.3.0': + resolution: {integrity: sha512-YcJtEV5LA3cvA4z3BurgxH5IhTsW1JfIvcAAcqcecwk06Si9F9NqkxbZVIfDwQ8oRHgaBmT3zZJnLAotCrVahw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/keyring-freebsd-x64@1.3.0': + resolution: {integrity: sha512-vlLf31TGhfRAaxLDBhg8b89ss0HHD/lyNmL5F3UjSaz5CUXElsJmKYq9fqA/B+cZKUEUcLHHGhF0I/CqcFdaVw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@napi-rs/keyring-linux-arm-gnueabihf@1.3.0': + resolution: {integrity: sha512-KiWdMMu/Inz/bHHIAGrnF7r54FZDYXuHO6UFF/rhIrshUsxbMG1Rl9lEymNtqqsVo927G0VYcb02FzWQ3iBQRQ==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/keyring-linux-arm64-gnu@1.3.0': + resolution: {integrity: sha512-eyKGpY40lm9Jvs1aD294XRH4y7+TlJM0YVAryZeXA6TX0mb4gMkxVXwSQv7MCwgah7raeUd0dKUb4BPAYIgcMg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@napi-rs/keyring-linux-arm64-musl@1.3.0': + resolution: {integrity: sha512-iIK6JWHXAJqDrEyLY3TmswwloVyt2vj+04TZnew+uSJ9gnDO8EwRbp3/iw3LpWaXiDO7VomGO6y8I0Id8uBZSw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@napi-rs/keyring-linux-riscv64-gnu@1.3.0': + resolution: {integrity: sha512-/PGqrwn6EwgtK6vccASSXJRfOSP4vN1F4ASsIQ+7MdrK6hNvAJ1FZPrIuD5gGGdxezo3F++To2Wq7DbuGIeuNQ==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@napi-rs/keyring-linux-x64-gnu@1.3.0': + resolution: {integrity: sha512-2PDK1WKWTu9lBGq9VvNEkSlQD3O7YwVpmnyN2M3cy4v7NJ/8gDMd9GXv3G+FVXN13uhp4gnnPBS+ScefmEeD2A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@napi-rs/keyring-linux-x64-musl@1.3.0': + resolution: {integrity: sha512-oJ2HkX8YUo46QBkn0pG+HuIKQNqr523q6vBobCn+P95s4C4K6/kLBqHY/1bg5J4ap31DzsznhnFKcfBNBsjCnw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@napi-rs/keyring-win32-arm64-msvc@1.3.0': + resolution: {integrity: sha512-tOd3c/uAaeoE4ycVlmAdSvygz0Zt3zdca6Y7gokBeIbaRDWpjDIUOpU3MvML59XAaqyuKGsVVu0F/DZb1lHPmw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/keyring-win32-ia32-msvc@1.3.0': + resolution: {integrity: sha512-sPSqeAFZMGqP1R++M2JTza7GQJJ/TpCo6JU6Vcd4jnebvOaEDs9b7eipakU1PJdSvhpC2yXMCNRk9gXfrhuwHQ==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@napi-rs/keyring-win32-x64-msvc@1.3.0': + resolution: {integrity: sha512-4DnCWXwDc0HRKwyRlG5y0VhKZW2tNRQfKKfyj6IX/KWfDNyq9hn4n+GL1auyDcOO/v8PwnhmYo2+rOOqCkvvOg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/keyring@1.3.0': + resolution: {integrity: sha512-WrOw/bcXm0f9qHkumlT1QlArXSTWqaY9sunsDpOk+yCCorCKMxvWT/a3xko4EYHVdeZoh00yI2TydXn6eyICDA==} + engines: {node: '>= 10'} '@noble/hashes@1.8.0': resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} engines: {node: ^14.21.3 || >=16} - '@tangle-network/agent-core@0.4.25': - resolution: {integrity: sha512-3hfIs64b/1/C1ezYhSPu6NC449lO4XAq79uaol0z0eLXcXDM+2znXThTeMTEZUPJ4iAsR514xcHL2OrP3dsE3g==} - - '@tangle-network/agent-core@0.4.29': - resolution: {integrity: sha512-8RaZSTYU/HXZt48Mr0KfwLla0j3K6EH8H8xc0lI5qFggAMsNBic8Tk951CnFaNX680VSXGOma499zS1NgowqfA==} + '@tangle-network/agent-core@0.4.33': + resolution: {integrity: sha512-if3DeIo4e2c9wELJjrWOu4SsKv3WnWdDrp+kezP6JrpiQ1DXValRRlN/aTppicFHepw67OD5JQI9i351Z/hvDQ==} - '@tangle-network/agent-eval@0.139.2': - resolution: {integrity: sha512-4o0ubPLz+G59AHdvr7xAEbjcwKHCMFWF/VXdd8LafxMN37BjdjH/Z9klC8Jfm4HI3fFSZXF7itnRC62b3mvPqA==} + '@tangle-network/agent-eval@0.143.0': + resolution: {integrity: sha512-Vx26rz0+qhb1QYd8YkSfoTgvdcoWtqAkR+4H7de4iu4mXKjLlefdWm5iRd4Nwqxgrd4R3Zrgm17N/b282mMCLQ==} engines: {node: '>=20'} hasBin: true - '@tangle-network/agent-interface@0.36.0': - resolution: {integrity: sha512-l48SiAY5Atx6ZTL86/XP2wSUPfJJ1sga82/F6V0KqwVkE5MHWiFql6wsLoqk0lc7s4EJe6+cKky+2zzxFuxDpg==} + '@tangle-network/agent-eval@0.144.3': + resolution: {integrity: sha512-gkyeie5CCzUuEzTfuQKe6iikSsu0S9h6w8yRtfnvjDZopkJJ7Nn+VDzkWZrsLDQ31LtLaHqJ7I2FgYbPNvjW0Q==} + engines: {node: '>=20'} + hasBin: true - '@tangle-network/agent-interface@0.40.0': - resolution: {integrity: sha512-4A6+bb5Ub22WJsVQRNuNIr6BSsfQG4ZZT436GZ2qUcZocjJ7186MEbN7kOmYq9TQ9qeMSUh/HbyYw2nsok5b+w==} + '@tangle-network/agent-interface@0.43.0': + resolution: {integrity: sha512-t35nGOJ3sWouHoUM/5A8eUmsR+8JcuRF6VFQbT9cPzxIMUV3NsFMJgYXPGmyIj65vtvs73O6Mvijp47FAAGbuQ==} - '@tangle-network/agent-knowledge@7.0.3': - resolution: {integrity: sha512-0okwiDr/EE3DbHdlLiIYFDWNU2dvBcNGfPpehpT5l7VjSd3QxXqBeUfWXPRvRBZQhrUq35PbwEEyKsxApFtBuA==} + '@tangle-network/agent-knowledge@7.0.8': + resolution: {integrity: sha512-FXA8KGCraUwX+2aNGWI8hZYAJ8f2T0iLBPbtzwbUoYl4t6h87munYoDQOh9YjarrAmkp6tcuGIGPKnw0FdCydg==} engines: {node: '>=20.19.0'} hasBin: true - '@tangle-network/agent-profile-materialize@0.9.4': - resolution: {integrity: sha512-LCKfohFcjzeDz+WrPx6lXyZvn0CYyw0Y3/sBCaJ6fqj2Sgg7KwG178eg/LhlqNPz6xJzBh0g4iHxPge1kNn4nw==} + '@tangle-network/agent-profile-materialize@0.10.2': + resolution: {integrity: sha512-u3MtUy8BD5odTWu/nK0b2XuieBSZMMx1kR9izwqIoR+c8pQaIRQiENNms9qeyQFLqqm7ewC1/Qq5oTtuihs/5g==} + peerDependencies: + '@tangle-network/agent-interface': '>=0.38.0 <0.44.0' + + '@tangle-network/agent-provider-cli-bridge@0.3.4': + resolution: {integrity: sha512-jQQqs6i5bAXj2sqBVISnEGvrrwN38zYSxNcQ8Nq2WQEE3CaeOJWe8wva9ltz0m7phg8rQ4HKdlJPypxRyvx/tA==} + + '@tangle-network/agent-provider-tangle@0.4.10': + resolution: {integrity: sha512-C68a6iN987Sn5xwV42apPTRHz7d3584B5e38J3v1ZspxJqSzcaxIifewgv2l93g0xQix9RJPd4YqR6gaOSKTaA==} peerDependencies: - '@tangle-network/agent-interface': '>=0.38.0 <0.41.0' + '@tangle-network/sandbox': '>=0.17.0 <1.0.0' - '@tangle-network/agent-runtime@0.117.0': - resolution: {integrity: sha512-NW7jNpN3OPzJN/VAh/1QP3MXz7L9VGEDHprom+97UKJsnTWBehuf65Hnf/iLysZhY8cGoqiPPMra5nRe9TQdbg==} + '@tangle-network/agent-runtime@0.128.0': + resolution: {integrity: sha512-JrF9/R5oBRxqVyBx8lRj71BXiJluWb2ZY3APIRzovk/YxuhEASWnGz8J/8T+nGX2YE+mEvgpxVF+hnOcZRYvDg==} engines: {node: '>=22.13.0'} hasBin: true peerDependencies: - '@tangle-network/agent-eval': '>=0.139.2 <0.140.0' - '@tangle-network/agent-interface': '>=0.40.0 <0.41.0' - '@tangle-network/sandbox': '>=0.15.0 <0.16.0' + '@tangle-network/agent-eval': '>=0.143.0 <0.144.0' + '@tangle-network/agent-interface': '>=0.43.0 <0.44.0' + '@tangle-network/sandbox': '>=0.17.2 <0.18.0' playwright: ^1.40.0 peerDependenciesMeta: '@tangle-network/sandbox': @@ -163,8 +426,11 @@ packages: playwright: optional: true - '@tangle-network/sandbox@0.15.2': - resolution: {integrity: sha512-haEdX9shY5jXQABwfipxmkrnz0Po42Dvtk41VTNCh88uw4iXcSgJdGBTBLYoriHZZZfJEenQPjlPjpgj8sm3NQ==} + '@tangle-network/agent-trace-contract@1.0.2': + resolution: {integrity: sha512-v7uMh56jkEp4vckevEU9xKsIatbs5dqzGPp69dFLSSXUVit0RP6VD6EANMXVlTCUk+6wVKBLHJx23XspVCEiIA==} + + '@tangle-network/sandbox@0.18.0': + resolution: {integrity: sha512-APEBH14CYqCii22pKBHb5WQ9DqkV+GIxT70iC6Jb3gJ9qxpMeHGf3ZUsHq3Y82f356yz/jUg0tZ6ZgSaYPxbDw==} peerDependencies: '@mastra/core': ^1.36.0 '@modelcontextprotocol/sdk': ^1.29.0 @@ -183,8 +449,8 @@ packages: viem: optional: true - '@types/node@24.10.1': - resolution: {integrity: sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==} + '@types/node@26.1.2': + resolution: {integrity: sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==} '@typescript/typescript-aix-ppc64@7.0.2': resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} @@ -306,8 +572,8 @@ packages: cpu: [x64] os: [win32] - '@xterm/headless@5.5.0': - resolution: {integrity: sha512-5xXB7kdQlFBP82ViMJTwwEc3gKCLGKR/eoxQm4zge7GPBl86tCdI0IdPJjoKd8mUSFXz5V7i/25sfsEkP4j46g==} + '@xterm/headless@6.0.0': + resolution: {integrity: sha512-5Yj1QINYCyzrZtf8OFIHi47iQtI+0qYFPHmouEfG8dHNxbZ9Tb9YGSuLcsEwj9Z+OL75GJqPyJbyoFer80a2Hw==} b4a@1.8.1: resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==} @@ -354,36 +620,97 @@ packages: bare-url@2.4.6: resolution: {integrity: sha512-iQxPClE07hETVpbRoX7JXX3v/ZQViCxe/SYCxylRLzdEx1xJAufPptfiOqR8tqiCtmbtMDANKWszzjLu1PMAZQ==} + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + better-sqlite3-multiple-ciphers@12.11.1: + resolution: {integrity: sha512-pG72+3VkUipnmYx4LwQqoOXNG2CJ1HlmDrlvqgr7xQHgsE+cz5rAZEiaJHnUKTHaGfGifggA/C0Sv9qZlUrfow==} + engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x || 26.x} + binary-search@1.3.6: resolution: {integrity: sha512-nbE1WxOTTrUWIfsfZ4aHGYu5DOuNkbxGokjV6Z2kxfJK3uaAb8zNK1muzOeipoLHZjInT4Br88BHpzevc681xA==} - chalk@5.6.2: - resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} - engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + bindings@1.5.0: + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + chalk@6.0.0: + resolution: {integrity: sha512-2uNTXIuTTxk7ciZgAU1BQcgnchcG0xXnrs6jzkQfj9SsRa9M2s5zE8WT96hS6KmG4MzWHSrvH43DF1m4XRkrFg==} + engines: {node: '>=22'} cheminfo-types@1.15.0: resolution: {integrity: sha512-shv45WN2u0yN9EHH1bisNrv+fy4Cw+eLM5lOoriP67mePrwbHZ1kJqg90C8GEU7K1A8gJsicEoVZHcuBbuul/w==} + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + events-universal@1.0.1: resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + fast-fifo@1.3.2: resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} fft.js@4.0.4: resolution: {integrity: sha512-f9c00hphOgeQTlDyavwTtu6RiK8AIFjD6+jvXkNkpeQ7rirK3uFWVpalkoS4LAwbdX7mfZ8aoBfFVQX1Re/8aw==} + file-uri-to-path@1.0.0: + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + get-east-asian-width@1.6.0: resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} engines: {node: '>=18'} + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - hono@4.12.33: - resolution: {integrity: sha512-+SwvkaiJtxsiPjhy9LivY/1m7UsNqCJetM1BrZl9A5DkQhlbHQDU730mMiDPWjnoCYOM8Chf3WrCJw27kNTPFQ==} + hono@4.13.0: + resolution: {integrity: sha512-jhunvfHWxd7J5EFfSgH4xsYJzSe/lfqbUCxiyyeaQasUsXeEHXtzVid+7EOGByc5JnFa23SSFL3Y2RV/z1T+eQ==} engines: {node: '>=16.9.0'} + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + is-any-array@3.0.0: resolution: {integrity: sha512-o4h+tylWykC4BD1vaejp6gDxoM13bwW8FGuNs4yIKpj8xbBJcRxJx8vZpq0dCr7ZDEfeKjmsi/euolKhX6f/ww==} @@ -395,6 +722,16 @@ packages: engines: {node: '>= 20'} hasBin: true + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + ml-array-max@2.0.0: resolution: {integrity: sha512-QQZ4kENwpWmyNb98UXRDFXrmtIXuXtt1+bSbda/2KA85+F+rrJP8hZk6QOkCQXM2Th9mUDYdq/PNByPdT9ID4A==} @@ -413,29 +750,70 @@ packages: ml-xsadd@3.0.1: resolution: {integrity: sha512-Fz2q6dwgzGM8wYKGArTUTZDGa4lQFA2Vi6orjGeTVRy22ZnQFKlJuwS9n8NRviqz1KHAHAzdKJwbnYhdo38uYg==} + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + + node-abi@3.94.0: + resolution: {integrity: sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==} + engines: {node: '>=10'} + node-addon-api@7.1.1: resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} node-pty@1.1.0: resolution: {integrity: sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==} + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + openapi3-ts@4.6.1: resolution: {integrity: sha512-XW9MOldkhoICNeXVzzmXzmOW5G73ppOEGmh7fLCqHjgfdEYCGGN+00MlVCeUZgovjjfC56j9tvtDt1zGabNjjA==} + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. + hasBin: true + proper-lockfile@4.1.2: resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + re2js@2.8.6: resolution: {integrity: sha512-xLgQil4kIUCrAzVk9fRSkxkFNwmygLFjVxXrLc65aE1F0+Zsb8rxumFBy4XKyvgMCTL6kilDq3EZ0piE2dP/Dg==} engines: {node: '>=18.0.0'} + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + retry@0.12.0: resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} engines: {node: '>= 4'} + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + spdx-exceptions@2.5.0: resolution: {integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==} @@ -448,6 +826,20 @@ packages: streamx@2.28.0: resolution: {integrity: sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==} + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + + tar-fs@2.1.5: + resolution: {integrity: sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + tar-stream@3.2.0: resolution: {integrity: sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==} @@ -457,13 +849,26 @@ packages: text-decoder@1.2.7: resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + typescript@7.0.2: resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} engines: {node: '>=16.20.0'} hasBin: true - undici-types@7.16.0: - resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + + undici@7.29.0: + resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==} + engines: {node: '>=20.18.1'} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} yaml@2.9.0: resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} @@ -520,78 +925,223 @@ snapshots: get-east-asian-width: 1.6.0 marked: 18.0.5 - '@hono/node-server@2.0.12(hono@4.12.33)': - dependencies: - hono: 4.12.33 + '@esbuild/aix-ppc64@0.28.1': + optional: true - '@noble/hashes@1.8.0': {} + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true - '@tangle-network/agent-core@0.4.25': + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + + '@hono/node-server@2.0.12(hono@4.13.0)': dependencies: - '@tangle-network/agent-interface': 0.36.0 - zod: 4.4.3 + hono: 4.13.0 + + '@napi-rs/keyring-darwin-arm64@1.3.0': + optional: true + + '@napi-rs/keyring-darwin-x64@1.3.0': + optional: true + + '@napi-rs/keyring-freebsd-x64@1.3.0': + optional: true + + '@napi-rs/keyring-linux-arm-gnueabihf@1.3.0': + optional: true + + '@napi-rs/keyring-linux-arm64-gnu@1.3.0': + optional: true - '@tangle-network/agent-core@0.4.29': + '@napi-rs/keyring-linux-arm64-musl@1.3.0': + optional: true + + '@napi-rs/keyring-linux-riscv64-gnu@1.3.0': + optional: true + + '@napi-rs/keyring-linux-x64-gnu@1.3.0': + optional: true + + '@napi-rs/keyring-linux-x64-musl@1.3.0': + optional: true + + '@napi-rs/keyring-win32-arm64-msvc@1.3.0': + optional: true + + '@napi-rs/keyring-win32-ia32-msvc@1.3.0': + optional: true + + '@napi-rs/keyring-win32-x64-msvc@1.3.0': + optional: true + + '@napi-rs/keyring@1.3.0': + optionalDependencies: + '@napi-rs/keyring-darwin-arm64': 1.3.0 + '@napi-rs/keyring-darwin-x64': 1.3.0 + '@napi-rs/keyring-freebsd-x64': 1.3.0 + '@napi-rs/keyring-linux-arm-gnueabihf': 1.3.0 + '@napi-rs/keyring-linux-arm64-gnu': 1.3.0 + '@napi-rs/keyring-linux-arm64-musl': 1.3.0 + '@napi-rs/keyring-linux-riscv64-gnu': 1.3.0 + '@napi-rs/keyring-linux-x64-gnu': 1.3.0 + '@napi-rs/keyring-linux-x64-musl': 1.3.0 + '@napi-rs/keyring-win32-arm64-msvc': 1.3.0 + '@napi-rs/keyring-win32-ia32-msvc': 1.3.0 + '@napi-rs/keyring-win32-x64-msvc': 1.3.0 + + '@noble/hashes@1.8.0': {} + + '@tangle-network/agent-core@0.4.33': dependencies: - '@tangle-network/agent-interface': 0.40.0 + '@tangle-network/agent-interface': 0.43.0 zod: 4.4.3 - '@tangle-network/agent-eval@0.139.2': + '@tangle-network/agent-eval@0.143.0': dependencies: '@asteasolutions/zod-to-openapi': 9.1.0(zod@4.4.3) - '@hono/node-server': 2.0.12(hono@4.12.33) - '@tangle-network/agent-core': 0.4.29 - '@tangle-network/agent-interface': 0.40.0 - hono: 4.12.33 + '@hono/node-server': 2.0.12(hono@4.13.0) + '@tangle-network/agent-core': 0.4.33 + '@tangle-network/agent-interface': 0.43.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.36.0': + '@tangle-network/agent-eval@0.144.3': dependencies: - '@noble/hashes': 1.8.0 + '@asteasolutions/zod-to-openapi': 9.1.0(zod@4.4.3) + '@hono/node-server': 2.0.12(hono@4.13.0) + '@tangle-network/agent-core': 0.4.33 + '@tangle-network/agent-interface': 0.43.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.40.0': + '@tangle-network/agent-interface@0.43.0': dependencies: '@noble/hashes': 1.8.0 spdx-expression-parse: 5.0.0 zod: 4.4.3 - '@tangle-network/agent-knowledge@7.0.3': + '@tangle-network/agent-knowledge@7.0.8': dependencies: - '@tangle-network/agent-eval': 0.139.2 - '@tangle-network/agent-interface': 0.40.0 + '@tangle-network/agent-eval': 0.143.0 + '@tangle-network/agent-interface': 0.43.0 proper-lockfile: 4.1.2 zod: 4.4.3 - '@tangle-network/agent-profile-materialize@0.9.4(@tangle-network/agent-interface@0.40.0)': + '@tangle-network/agent-profile-materialize@0.10.2(@tangle-network/agent-interface@0.43.0)': dependencies: - '@tangle-network/agent-interface': 0.40.0 + '@tangle-network/agent-interface': 0.43.0 - '@tangle-network/agent-runtime@0.117.0(@tangle-network/agent-eval@0.139.2)(@tangle-network/agent-interface@0.40.0)(@tangle-network/sandbox@0.15.2)': + '@tangle-network/agent-provider-cli-bridge@0.3.4': dependencies: - '@tangle-network/agent-eval': 0.139.2 - '@tangle-network/agent-interface': 0.40.0 - '@tangle-network/agent-knowledge': 7.0.3 - '@tangle-network/agent-profile-materialize': 0.9.4(@tangle-network/agent-interface@0.40.0) + '@tangle-network/agent-interface': 0.43.0 + undici: 7.29.0 + + '@tangle-network/agent-provider-tangle@0.4.10(@tangle-network/sandbox@0.18.0)': + dependencies: + '@tangle-network/agent-interface': 0.43.0 + '@tangle-network/sandbox': 0.18.0 + + '@tangle-network/agent-runtime@0.128.0(@tangle-network/agent-eval@0.144.3)(@tangle-network/agent-interface@0.43.0)(@tangle-network/sandbox@0.18.0)': + dependencies: + '@tangle-network/agent-eval': 0.144.3 + '@tangle-network/agent-interface': 0.43.0 + '@tangle-network/agent-knowledge': 7.0.8 + '@tangle-network/agent-profile-materialize': 0.10.2(@tangle-network/agent-interface@0.43.0) + '@tangle-network/agent-trace-contract': 1.0.2 tar-stream: 3.2.0 optionalDependencies: - '@tangle-network/sandbox': 0.15.2 + '@tangle-network/sandbox': 0.18.0 transitivePeerDependencies: - bare-abort-controller - bare-buffer - react-native-b4a - '@tangle-network/sandbox@0.15.2': + '@tangle-network/agent-trace-contract@1.0.2': {} + + '@tangle-network/sandbox@0.18.0': dependencies: - '@tangle-network/agent-core': 0.4.25 - '@tangle-network/agent-interface': 0.36.0 + '@tangle-network/agent-core': 0.4.33 + '@tangle-network/agent-interface': 0.43.0 zod: 4.4.3 - '@types/node@24.10.1': + '@types/node@26.1.2': dependencies: - undici-types: 7.16.0 + undici-types: 8.3.0 '@typescript/typescript-aix-ppc64@7.0.2': optional: true @@ -653,7 +1203,7 @@ snapshots: '@typescript/typescript-win32-x64@7.0.2': optional: true - '@xterm/headless@5.5.0': {} + '@xterm/headless@6.0.0': {} b4a@1.8.1: {} @@ -686,27 +1236,106 @@ snapshots: dependencies: bare-path: 3.1.1 + base64-js@1.5.1: {} + + better-sqlite3-multiple-ciphers@12.11.1: + dependencies: + bindings: 1.5.0 + prebuild-install: 7.1.3 + binary-search@1.3.6: {} - chalk@5.6.2: {} + bindings@1.5.0: + dependencies: + file-uri-to-path: 1.0.0 + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + chalk@6.0.0: {} cheminfo-types@1.15.0: {} + chownr@1.1.4: {} + + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + + deep-extend@0.6.0: {} + + detect-libc@2.1.2: {} + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + events-universal@1.0.1: dependencies: bare-events: 2.9.1 transitivePeerDependencies: - bare-abort-controller + expand-template@2.0.3: {} + fast-fifo@1.3.2: {} fft.js@4.0.4: {} + file-uri-to-path@1.0.0: {} + + fs-constants@1.0.0: {} + get-east-asian-width@1.6.0: {} + github-from-package@0.0.0: {} + graceful-fs@4.2.11: {} - hono@4.12.33: {} + hono@4.13.0: {} + + ieee754@1.2.1: {} + + inherits@2.0.4: {} + + ini@1.3.8: {} is-any-array@3.0.0: {} @@ -718,6 +1347,12 @@ snapshots: marked@18.0.5: {} + mimic-response@3.1.0: {} + + minimist@1.2.8: {} + + mkdirp-classic@0.5.3: {} + ml-array-max@2.0.0: dependencies: is-any-array: 3.0.0 @@ -748,28 +1383,83 @@ snapshots: ml-xsadd@3.0.1: {} + napi-build-utils@2.0.0: {} + + node-abi@3.94.0: + dependencies: + semver: 7.8.5 + node-addon-api@7.1.1: {} node-pty@1.1.0: dependencies: node-addon-api: 7.1.1 + once@1.4.0: + dependencies: + wrappy: 1.0.2 + openapi3-ts@4.6.1: dependencies: yaml: 2.9.0 + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.94.0 + pump: 3.0.4 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.5 + tunnel-agent: 0.6.0 + proper-lockfile@4.1.2: dependencies: graceful-fs: 4.2.11 retry: 0.12.0 signal-exit: 3.0.7 + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + re2js@2.8.6: {} + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + retry@0.12.0: {} + safe-buffer@5.2.1: {} + + semver@7.8.5: {} + signal-exit@3.0.7: {} + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + spdx-exceptions@2.5.0: {} spdx-expression-parse@5.0.0: @@ -788,6 +1478,27 @@ snapshots: - bare-abort-controller - react-native-b4a + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-json-comments@2.0.1: {} + + tar-fs@2.1.5: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.4 + tar-stream: 2.2.0 + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + tar-stream@3.2.0: dependencies: b4a: 1.8.1 @@ -812,6 +1523,10 @@ snapshots: transitivePeerDependencies: - react-native-b4a + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + typescript@7.0.2: optionalDependencies: '@typescript/typescript-aix-ppc64': 7.0.2 @@ -835,7 +1550,13 @@ snapshots: '@typescript/typescript-win32-arm64': 7.0.2 '@typescript/typescript-win32-x64': 7.0.2 - undici-types@7.16.0: {} + undici-types@8.3.0: {} + + undici@7.29.0: {} + + util-deprecate@1.0.2: {} + + wrappy@1.0.2: {} yaml@2.9.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 67fc983..20b861a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,4 +1,18 @@ +overrides: + hono: 4.13.0 + undici: 7.29.0 +peerDependencyRules: + allowedVersions: + '@tangle-network/agent-eval': 0.144.1 allowBuilds: + better-sqlite3-multiple-ciphers: true + esbuild: true node-pty: true +ignoreScripts: false minimumReleaseAgeExclude: - - '@tangle-network/agent-runtime@0.117.0' + - '@tangle-network/agent-runtime@0.126.0 || 0.128.0' + - '@tangle-network/agent-eval@0.143.0 || 0.144.0 || 0.144.1 || 0.144.3' + - '@tangle-network/agent-knowledge@7.0.8' + - '@tangle-network/agent-profile-materialize@0.10.2' + - '@tangle-network/sandbox@0.17.3 || 0.18.0' + - hono@4.13.0 diff --git a/release/execution-public-key.fingerprint b/release/execution-public-key.fingerprint new file mode 100644 index 0000000..4ee5378 --- /dev/null +++ b/release/execution-public-key.fingerprint @@ -0,0 +1 @@ +sha256:cc0e443fcaef75fb4374ac61e1f2c4abee1337edfa099ae4537d37537fa93270 diff --git a/release/execution-public-key.pem b/release/execution-public-key.pem new file mode 100644 index 0000000..bbf62bd --- /dev/null +++ b/release/execution-public-key.pem @@ -0,0 +1,3 @@ +-----BEGIN PUBLIC KEY----- +MCowBQYDK2VwAyEABMl/ns/xglaTg0qLX1AxVONWttFmAySA1As6wK6r4sM= +-----END PUBLIC KEY----- diff --git a/scripts/accessibility-output.mjs b/scripts/accessibility-output.mjs new file mode 100644 index 0000000..b8544ba --- /dev/null +++ b/scripts/accessibility-output.mjs @@ -0,0 +1,13 @@ +// biome-ignore lint/complexity/useRegexLiterals: terminal control sequences must remain readable as escaped text +const OSC_SEQUENCE = new RegExp( + String.raw`(?:\u001b\]|\u009d)[\s\S]*?(?:\u0007|\u001b\\|\u009c)`, + 'u', +) + +export function assertAccessibleTerminalOutput(output) { + const starts = [output.indexOf('\u001b]'), output.indexOf('\u009d')].filter((index) => index >= 0) + const start = starts.length === 0 ? -1 : Math.min(...starts) + if (start === -1) return + const sequence = OSC_SEQUENCE.exec(output.slice(start))?.[0] ?? 'incomplete OSC sequence' + throw new Error(`accessibility proof emitted terminal metadata: ${JSON.stringify(sequence)}`) +} diff --git a/scripts/build-startup.mjs b/scripts/build-startup.mjs new file mode 100644 index 0000000..091b2c6 --- /dev/null +++ b/scripts/build-startup.mjs @@ -0,0 +1,34 @@ +import { build } from 'esbuild' + +const root = new URL('../', import.meta.url).pathname + +await build({ + absWorkingDir: root, + entryPoints: { + 'startup/durable-runtime': 'src/startup/durable-runtime.ts', + 'startup/terminal-runtime': 'src/startup/terminal-runtime.ts', + }, + outdir: 'dist', + bundle: true, + splitting: true, + format: 'esm', + platform: 'node', + target: 'node22.19', + conditions: ['node', 'import'], + entryNames: '[dir]/[name]', + chunkNames: 'startup/chunks/[name]-[hash]', + sourcemap: true, + sourcesContent: false, + legalComments: 'eof', + treeShaking: true, + external: [ + '@napi-rs/keyring', + '@tangle-network/agent-eval', + '@tangle-network/agent-provider-*', + '@tangle-network/agent-runtime', + '@tangle-network/agent-runtime/*', + '@tangle-network/sandbox', + 'better-sqlite3-multiple-ciphers', + 'keytar', + ], +}) diff --git a/scripts/capture-visual-definitions.mjs b/scripts/capture-visual-definitions.mjs new file mode 100644 index 0000000..491cc93 --- /dev/null +++ b/scripts/capture-visual-definitions.mjs @@ -0,0 +1,171 @@ +export function createStateDefinitions(normalized) { + return [ + { + name: 'empty', + columns: 80, + rows: 24, + run: async (terminal) => { + await terminal.waitFor(() => terminal.screen().includes('braid'), 'header') + const { point, record } = await terminal.captureState() + await terminal.closeNormally() + return { point, record } + }, + }, + { + name: 'active-streaming', + columns: 80, + rows: 24, + environment: { BRAID_FIXTURE_CHUNK_DELAY_MS: '1000' }, + run: async (terminal) => { + await terminal.waitFor(() => terminal.screen().includes('braid'), 'header') + terminal.input('W6 active streaming') + terminal.input('\r') + await terminal.waitFor( + () => normalized(terminal.screen()).includes('streaming'), + 'streaming', + ) + const { point, record } = await terminal.captureState() + terminal.input('/cancel') + terminal.input('\r') + await terminal.waitFor( + () => + normalized(terminal.screen()).includes('cancelled') || + normalized(terminal.screen()).includes('completed'), + 'cancellation', + ) + await terminal.closeNormally() + return { point, record } + }, + }, + { + name: 'interaction', + columns: 80, + rows: 24, + uiFixture: 'interaction', + run: async (terminal) => { + await terminal.waitFor(() => terminal.screen().includes('braid'), 'header') + await terminal.waitFor( + () => normalized(terminal.screen()).includes('Allow the fixture tool'), + 'interaction fixture', + ) + const { point, record } = await terminal.captureState() + await terminal.closeNormally() + return { point, record } + }, + }, + { + name: 'fork-preview', + columns: 80, + rows: 24, + uiFixture: 'fork', + run: async (terminal) => { + await terminal.waitFor(() => terminal.screen().includes('braid'), 'header') + terminal.input('/fork') + await new Promise((resolve) => setTimeout(resolve, 50)) + terminal.input('\r') + await terminal.waitFor( + () => normalized(terminal.screen()).includes('enter/y create fork'), + `fork fixture screen=${normalized(terminal.screen())}`, + ) + const { point, record } = await terminal.captureState() + await terminal.closeNormally() + return { point, record } + }, + }, + { + name: 'graph-or-analysis', + columns: 80, + rows: 24, + run: async (terminal) => { + await terminal.waitFor(() => terminal.screen().includes('braid'), 'header') + terminal.input('\u0007') + await terminal.waitFor( + () => normalized(terminal.screen()).includes('conversation graph'), + 'graph', + ) + const { point, record } = await terminal.captureState() + await terminal.closeNormally() + return { point, record } + }, + }, + { + name: 'analysis', + columns: 80, + rows: 24, + uiFixture: 'analysis', + run: async (terminal) => { + await terminal.waitFor(() => terminal.screen().includes('braid'), 'header') + terminal.input('/ask Where did this run waste time?') + terminal.input('\r') + await terminal.waitFor( + () => normalized(terminal.screen()).includes('/ask · frozen question'), + 'analysis result', + ) + const { point, record } = await terminal.captureState() + await terminal.closeNormally() + return { point, record } + }, + }, + { + name: 'comparison', + columns: 80, + rows: 24, + uiFixture: 'comparison', + run: async (terminal) => { + await terminal.waitFor(() => terminal.screen().includes('braid'), 'header') + terminal.input('/compare run-fixture-baseline run-fixture-candidate') + terminal.input('\r') + await terminal.waitFor( + () => normalized(terminal.screen()).includes('/compare · frozen runs'), + 'comparison result', + ) + const { point, record } = await terminal.captureState() + await terminal.closeNormally() + return { point, record } + }, + }, + { + name: 'profile', + columns: 80, + rows: 24, + run: async (terminal) => { + await terminal.waitFor(() => terminal.screen().includes('braid'), 'header') + terminal.input('/profile') + terminal.input('\r') + await terminal.waitFor( + () => normalized(terminal.screen()).includes('Active profile'), + 'profile editor', + ) + const { point, record } = await terminal.captureState() + await terminal.closeNormally() + return { point, record } + }, + }, + { + name: 'narrow', + columns: 40, + rows: 12, + run: async (terminal) => { + await terminal.waitFor(() => terminal.screen().includes('braid'), 'header') + const { point, record } = await terminal.captureState() + await terminal.closeNormally() + return { point, record } + }, + }, + { + name: 'failure-or-reconnect', + columns: 80, + rows: 24, + environment: { BRAID_FIXTURE_FAILURE: '1' }, + run: async (terminal) => { + await terminal.waitFor(() => terminal.screen().includes('braid'), 'header') + terminal.input('W6 failure state') + terminal.input('\r') + await terminal.waitFor(() => normalized(terminal.screen()).includes('failed'), 'failure') + const { point, record } = await terminal.captureState() + await terminal.closeNormally() + return { point, record } + }, + }, + ] +} diff --git a/scripts/capture-visual-support.mjs b/scripts/capture-visual-support.mjs new file mode 100644 index 0000000..371a01b --- /dev/null +++ b/scripts/capture-visual-support.mjs @@ -0,0 +1,134 @@ +import { execFile } from 'node:child_process' +import { mkdtemp, readdir, rm } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join, relative } from 'node:path' +import { promisify } from 'node:util' + +const run = promisify(execFile) +const require = createRequire(import.meta.url) +const MAX_FLOW_FRAME_PEAK_ERROR = 0.03 + +function packageVersion(name) { + const packageJson = require(`${name}/package.json`) + if (typeof packageJson.version !== 'string' || packageJson.version.length === 0) + throw new Error(`Cannot determine installed version for ${name}`) + return `${name}@${packageJson.version}` +} + +export async function writeRaster(frameCastPath, pngPath, gifPath) { + const fontFamily = 'DejaVu Sans Mono' + await run('agg', [ + '--quiet', + '--theme', + 'github-dark', + '--font-size', + '16', + '--idle-time-limit', + '1', + '--last-frame-duration', + '1', + '--select', + '100%', + '--no-loop', + '--font-family', + fontFamily, + frameCastPath, + gifPath, + ]) + await run('convert', [`${gifPath}[0]`, '-colorspace', 'sRGB', '-depth', '8', pngPath]) + await rm(gifPath, { force: true }) +} + +export function assertFlowFrameIntegrity(metric, label) { + const match = /\(([0-9]+(?:\.[0-9]+)?)\)/u.exec(metric) + const normalizedPeakError = match ? Number(match[1]) : Number.NaN + if (!Number.isFinite(normalizedPeakError) || normalizedPeakError > MAX_FLOW_FRAME_PEAK_ERROR) { + throw new Error(`${label} differs from its source frame: ${metric || 'missing metric'}`) + } + return normalizedPeakError +} + +async function peakError(expectedPath, actualPath, label) { + let output = '' + try { + const result = await run('compare', ['-metric', 'PAE', expectedPath, actualPath, 'null:']) + output = `${result.stdout}${result.stderr}`.trim() + } catch (error) { + output = `${error.stdout ?? ''}${error.stderr ?? ''}`.trim() + } + return assertFlowFrameIntegrity(output, label) +} + +export async function writeFlowGif(startPath, endPath, gifPath) { + const verificationRoot = await mkdtemp(join(tmpdir(), 'braid-flow-')) + try { + await run('convert', [ + '-dispose', + 'Background', + '-delay', + '5', + startPath, + '-dispose', + 'Background', + '-delay', + '200', + endPath, + '-loop', + '0', + gifPath, + ]) + await run('convert', [gifPath, '-coalesce', join(verificationRoot, 'frame-%02d.png')]) + const frames = (await readdir(verificationRoot)).sort() + if (frames.length !== 2) + throw new Error(`flow GIF contains ${frames.length} frames, expected 2`) + await peakError(startPath, join(verificationRoot, frames[0]), 'flow start') + await peakError(endPath, join(verificationRoot, frames[1]), 'flow finish') + } finally { + await rm(verificationRoot, { force: true, recursive: true }) + } +} + +async function toolVersion(command, args) { + try { + const result = await run(command, args) + return (result.stdout || result.stderr || '').trim().split('\n')[0] || 'unknown' + } catch (error) { + return `unavailable: ${error.message}` + } +} + +export async function captureProvenance() { + return { + renderer: { + package: packageVersion('@earendil-works/pi-tui'), + pty: packageVersion('node-pty'), + emulator: packageVersion('@xterm/headless'), + node: process.version, + terminal: 'xterm-256color', + }, + raster: { + agg: await toolVersion('agg', ['--version']), + imagemagick: await toolVersion('convert', ['-version']), + fontFamily: 'DejaVu Sans Mono', + font: await toolVersion('fc-match', [ + '--format=%{family} | %{style} | %{file}', + 'DejaVu Sans Mono', + ]), + colorMode: 'sRGB 8-bit', + }, + } +} + +export function createArtifactFor(outputRoot, sha256) { + return async function artifactFor(path, kind, columns, rows, state) { + return { + path: relative(outputRoot, path), + sha256: await sha256(path), + kind, + ...(state ? { state } : {}), + columns, + rows, + } + } +} diff --git a/scripts/capture-visual.mjs b/scripts/capture-visual.mjs new file mode 100644 index 0000000..2afff2f --- /dev/null +++ b/scripts/capture-visual.mjs @@ -0,0 +1,501 @@ +import { execFile, spawn } from 'node:child_process' +import { createHash, randomUUID } from 'node:crypto' +import { createReadStream } from 'node:fs' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { promisify } from 'node:util' +import xterm from '@xterm/headless' +import * as pty from 'node-pty' +import { createStateDefinitions } from './capture-visual-definitions.mjs' +import { + captureProvenance, + createArtifactFor, + writeFlowGif, + writeRaster, +} from './capture-visual-support.mjs' +import { installPackedBraid } from './packed-binary.mjs' + +const run = promisify(execFile) +const repository = new URL('../', import.meta.url).pathname +const packed = await installPackedBraid(repository) +const binary = packed.binary +const outputRoot = join(repository, 'artifacts', 'verification', 'w6') +const rawRoot = join(outputRoot, 'raw') +const stateRoot = join(outputRoot, 'states') +const XtermTerminal = xterm.Terminal +const sizes = [ + [40, 12], + [80, 24], + [120, 40], + [200, 60], +] + +function sleep(milliseconds) { + return new Promise((resolve) => setTimeout(resolve, milliseconds)) +} + +async function waitFor(predicate, label) { + const deadline = Date.now() + 5_000 + while (!predicate()) { + if (Date.now() >= deadline) throw new Error(`Timed out waiting for ${label}`) + await sleep(20) + } +} + +async function sha256(path) { + return createHash('sha256') + .update(await readFile(path)) + .digest('hex') +} + +function shellArgument(value) { + return `'${value.replaceAll("'", "'\\''")}'` +} + +function screenFrom(emulator, rows) { + const buffer = emulator.buffer.active + return Array.from( + { length: rows }, + (_, index) => buffer.getLine(buffer.viewportY + index)?.translateToString(true) ?? '', + ).join('\n') +} + +function normalized(screen) { + return screen.replace(/\s+/gu, ' ').trim() +} + +const STATE_DEFINITIONS = createStateDefinitions(normalized) + +async function spawnTerminal(name, columns, rows, extraEnvironment = {}, uiFixture) { + const emulator = new XtermTerminal({ + cols: columns, + rows, + disableStdin: true, + allowProposedApi: true, + }) + const environment = { ...process.env, TERM: 'xterm-256color', ...extraEnvironment } + delete environment.NO_COLOR + delete environment.FORCE_COLOR + const recordPath = join(rawRoot, `${name}-${randomUUID()}.json`) + const args = [binary, '--fixture', 'deterministic', '--record-state', recordPath] + if (uiFixture) args.push('--ui-fixture', uiFixture) + const session = pty.spawn(process.execPath, args, { + name: 'xterm-256color', + cols: columns, + rows, + cwd: repository, + env: { ...environment, BRAID_JOURNAL_PATH: `${recordPath}.journal` }, + }) + const startedAt = performance.now() + const events = [] + let output = '' + let screen = '' + let exited = false + let pendingWrites = 0 + let lastOutputAt = performance.now() + const refresh = () => { + screen = screenFrom(emulator, rows) + } + const exitPromise = new Promise((resolve) => + session.onExit((event) => { + exited = true + resolve(event) + }), + ) + const waitForExit = async (label) => { + let timeout + try { + return await Promise.race([ + exitPromise, + new Promise((_, reject) => { + timeout = setTimeout(() => reject(new Error(`${name} did not exit ${label}`)), 5_000) + }), + ]) + } finally { + clearTimeout(timeout) + } + } + session.onData((data) => { + output += data + events.push([Number(((performance.now() - startedAt) / 1_000).toFixed(6)), 'o', data]) + lastOutputAt = performance.now() + pendingWrites += 1 + emulator.write(data, () => { + refresh() + pendingWrites -= 1 + }) + }) + const input = (data) => { + events.push([Number(((performance.now() - startedAt) / 1_000).toFixed(6)), 'i', data]) + session.write(data) + } + const snapshot = () => ({ + screen: `${screen.replace(/[ \t]+$/gmu, '').replace(/\n+$/u, '')}\n`, + output, + eventCount: events.length, + }) + const closeNormally = async () => { + if (exited) return + if (name === 'state-failure-or-reconnect') { + await closeWithSignal() + return + } + // A captured state may contain nested screens (for example the profile + // selector inside the profile editor). Escape each possible layer before + // exercising the shell's normal two-step Ctrl+C exit. + for (let layer = 0; layer < 3; layer += 1) { + input('\u001b') + await sleep(75) + } + input('\u0003') + await waitFor(() => normalized(screen).includes('ctrl+c again to quit'), `${name} safe exit`) + input('\u0003') + const event = await waitForExit('normally') + if (event.exitCode !== 0) throw new Error(`${name} exited ${event.exitCode}`) + } + const closeWithSignal = async () => { + if (exited) return + process.kill(session.pid, 'SIGINT') + const event = await waitForExit('after SIGINT') + if (event.exitCode !== 130) throw new Error(`${name} SIGINT exited ${event.exitCode}`) + } + const readRecord = async (suffix = '') => { + const path = `${recordPath}${suffix}` + const deadline = Date.now() + 5_000 + while (true) { + try { + const data = await readFile(path, 'utf8') + if (data.length > 0) return JSON.parse(data) + } catch { + // The packed process may still be flushing its final state file. + } + if (Date.now() >= deadline) throw new Error(`Timed out waiting for ${name} semantic state`) + await sleep(20) + } + } + const waitForStable = (label = 'settled terminal frame') => + waitFor(() => pendingWrites === 0 && performance.now() - lastOutputAt >= 75, `${name} ${label}`) + const captureState = async () => { + await waitForStable() + const point = snapshot() + process.kill(session.pid, 'SIGUSR2') + return { point, record: await readRecord('.frame') } + } + const dispose = async () => { + if (!exited) { + session.kill() + await Promise.race([exitPromise, sleep(1_000)]) + } + emulator.dispose() + await rm(recordPath, { force: true }) + await rm(`${recordPath}.signal`, { force: true }) + await rm(`${recordPath}.frame`, { force: true }) + await rm(`${recordPath}.journal`, { force: true }) + } + return { + columns, + rows, + events, + input, + output: () => output, + screen: () => screen, + snapshot, + waitFor: (predicate, label) => waitFor(predicate, `${name} ${label}`), + waitForStable, + closeNormally, + closeWithSignal, + readRecord, + captureState, + dispose, + } +} + +function castFor(result, events, title) { + const header = { + version: 2, + width: result.columns, + height: result.rows, + timestamp: Math.floor(Date.now() / 1_000), + duration: events.at(-1)?.[0] ?? 0, + idle_time_limit: 1, + command: 'packed braid --fixture deterministic', + title, + env: { TERM: 'xterm-256color' }, + stdin: true, + } + return [JSON.stringify(header), ...events.map((event) => JSON.stringify(event)), ''].join('\n') +} + +async function plainFrame() { + const environment = { ...process.env, NO_COLOR: '1', NODE_NO_WARNINGS: '1' } + const fifoRoot = await mkdtemp(join(tmpdir(), 'braid-plain-capture-')) + const stdoutPath = join(fifoRoot, 'stdout') + const stderrPath = join(fifoRoot, 'stderr') + await run('mkfifo', [stdoutPath, stderrPath]) + const output = { value: '' } + const error = { value: '' } + const readFifo = (path, sink) => + new Promise((resolve, reject) => { + const stream = createReadStream(path, { encoding: 'utf8' }) + stream.on('data', (chunk) => { + sink.value += chunk + }) + stream.on('error', reject) + stream.on('end', resolve) + }) + const session = spawn( + '/bin/sh', + [ + '-c', + `{ printf '%s\\n' 'W6 plain proof'; } | exec ${shellArgument(binary)} --plain --fixture deterministic --no-color > ${shellArgument(stdoutPath)} 2> ${shellArgument(stderrPath)}`, + ], + { + cwd: repository, + env: { ...environment, BRAID_JOURNAL_PATH: join(rawRoot, `plain-${randomUUID()}.journal`) }, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ) + let wrapperStderr = '' + session.stdout.resume() + session.stderr.setEncoding('utf8') + session.stderr.on('data', (chunk) => { + wrapperStderr += chunk + }) + const exited = new Promise((resolve, reject) => { + session.on('error', reject) + session.on('close', (code) => resolve({ exitCode: code })) + }) + const timeout = setTimeout(() => session.kill(), 5_000) + const [exit] = await Promise.all([ + exited, + readFifo(stdoutPath, output), + readFifo(stderrPath, error), + ]) + clearTimeout(timeout) + await rm(fifoRoot, { force: true, recursive: true }) + if (exit.exitCode !== 0) throw new Error(`plain capture exited ${exit.exitCode}`) + const stdout = output.value + const stderr = `${wrapperStderr}${error.value}` + const normalizedOutput = stdout.replace(/\r\n/gu, '\n').replace(/\r/gu, '') + if ([0x1b, 0x9b].some((code) => normalizedOutput.includes(String.fromCharCode(code)))) + throw new Error('plain output contains terminal controls') + if (stderr) throw new Error(`plain capture wrote stderr: ${stderr}`) + return normalizedOutput +} + +async function baselineCapture(columns, rows) { + const terminal = await spawnTerminal(`baseline-${columns}x${rows}`, columns, rows) + try { + await terminal.waitFor(() => terminal.screen().includes('braid'), 'header') + terminal.input('W6 visual proof') + terminal.input('\r') + await terminal.waitFor( + () => normalized(terminal.screen()).includes('Fixture response through pi: W6 visual proof'), + 'response', + ) + await terminal.waitFor( + () => + normalized(terminal.screen()).includes('completed') || + normalized(terminal.screen()).includes('ready for a message'), + 'final state', + ) + await terminal.waitForStable('baseline final frame') + const point = terminal.snapshot() + await terminal.closeNormally() + return { + columns, + rows, + finalScreen: point.screen, + cast: castFor(terminal, terminal.events, `Braid W6 ${columns}x${rows}`), + frameCast: castFor( + terminal, + terminal.events.slice(0, point.eventCount), + `Braid W6 ${columns}x${rows}`, + ), + } + } finally { + await terminal.dispose() + } +} + +const artifactFor = createArtifactFor(outputRoot, sha256) + +try { + await mkdir(rawRoot, { recursive: true }) + await mkdir(stateRoot, { recursive: true }) + const artifacts = [] + const plain = await plainFrame() + for (const [columns, rows] of sizes) { + const result = await baselineCapture(columns, rows) + const name = `${columns}x${rows}` + const castPath = join(rawRoot, `${name}.cast`) + const frameCastPath = join(rawRoot, `${name}-frame.cast`) + const textPath = join(outputRoot, `${name}.txt`) + const plainPath = join(outputRoot, `${name}-plain.txt`) + const gifPath = join(rawRoot, `${name}.gif`) + const pngPath = join(outputRoot, `${name}.png`) + await writeFile(castPath, result.cast) + await writeFile(frameCastPath, result.frameCast) + await writeFile(textPath, result.finalScreen) + await writeFile(plainPath, plain) + await writeRaster(frameCastPath, pngPath, gifPath) + artifacts.push(await artifactFor(textPath, 'terminal-frame', columns, rows)) + artifacts.push(await artifactFor(plainPath, 'plain-frame', columns, rows)) + artifacts.push(await artifactFor(pngPath, 'png', columns, rows)) + } + + const stateManifests = [] + for (const definition of STATE_DEFINITIONS) { + const terminal = await spawnTerminal( + `state-${definition.name}`, + definition.columns, + definition.rows, + definition.environment, + definition.uiFixture, + ) + try { + const result = await definition.run(terminal) + const provenance = await captureProvenance() + if (definition.name === 'active-streaming') { + if (result.record.view?.status !== 'running') + throw new Error('active-streaming frame and semantic state disagree') + if (!normalized(result.point.screen).includes('streaming')) + throw new Error('active-streaming frame is not streaming') + } + if ( + result.record.capturePhase !== 'atomic-signal-frame' || + result.record.state?.revision !== result.record.view?.revision + ) + throw new Error('frame and semantic state were not captured at one revision') + if (definition.name === 'interaction') { + if (result.record.view?.interactions?.length !== 1) + throw new Error('interaction capture did not contain an interaction fixture') + } + if (definition.name === 'fork-preview' && result.record.view?.forkPreview?.allowed !== true) + throw new Error('fork capture did not contain an allowed fork preview') + if ( + definition.name === 'analysis' && + !normalized(result.point.screen).includes('/ask · frozen question') + ) + throw new Error('analysis capture did not contain a saved analysis result') + if ( + definition.name === 'comparison' && + !normalized(result.point.screen).includes('/compare · frozen runs') + ) + throw new Error('comparison capture did not contain a saved comparison result') + if ( + definition.name === 'profile' && + !normalized(result.point.screen).includes('Active profile') + ) + throw new Error('profile capture did not contain the active profile editor') + const stateRootName = definition.name.replaceAll('/', '-') + const semanticPath = join(stateRoot, `${stateRootName}.json`) + const plainPath = join(stateRoot, `${stateRootName}.txt`) + const ansiPath = join(stateRoot, `${stateRootName}.ansi`) + const castPath = join(rawRoot, `${stateRootName}.cast`) + const frameCastPath = join(rawRoot, `${stateRootName}-frame.cast`) + const gifPath = join(rawRoot, `${stateRootName}.gif`) + const pngPath = join(stateRoot, `${stateRootName}.png`) + const semantic = { + schemaVersion: 2, + state: definition.name, + source: { + binary: 'packed real binary from clean npm install', + binarySha256: await sha256(binary), + tarball: packed.tarballName, + tarballSha256: packed.tarballSha256, + }, + dimensions: { columns: definition.columns, rows: definition.rows }, + terminal: 'node-pty/xterm-256color', + provenance, + capturePhase: 'atomic-signal-frame', + captureRevision: result.record.view.revision, + frame: result.point.screen, + packedState: result.record, + } + const cast = castFor(terminal, terminal.events, `Braid W6 state ${definition.name}`) + const frameCast = castFor( + terminal, + terminal.events.slice(0, result.point.eventCount), + `Braid W6 state ${definition.name}`, + ) + await writeFile(semanticPath, `${JSON.stringify(semantic, null, 2)}\n`) + await writeFile(plainPath, result.point.screen) + await writeFile(ansiPath, result.point.output) + await writeFile(castPath, cast) + await writeFile(frameCastPath, frameCast) + await writeRaster(frameCastPath, pngPath, gifPath) + const stateArtifacts = [ + await artifactFor( + semanticPath, + 'semantic-state', + definition.columns, + definition.rows, + definition.name, + ), + await artifactFor( + plainPath, + 'plain-frame', + definition.columns, + definition.rows, + definition.name, + ), + await artifactFor( + castPath, + 'asciicast', + definition.columns, + definition.rows, + definition.name, + ), + await artifactFor(ansiPath, 'ansi', definition.columns, definition.rows, definition.name), + await artifactFor(pngPath, 'png', definition.columns, definition.rows, definition.name), + ] + artifacts.push(...stateArtifacts) + stateManifests.push({ + name: definition.name, + columns: definition.columns, + rows: definition.rows, + artifacts: Object.fromEntries( + stateArtifacts.map((artifact) => [artifact.kind, artifact.path]), + ), + }) + } finally { + await terminal.dispose() + } + } + + const flowGif = join(outputRoot, '80x24-flow.gif') + await writeFlowGif(join(stateRoot, 'empty.png'), join(outputRoot, '80x24.png'), flowGif) + artifacts.push(await artifactFor(flowGif, 'flow', 80, 24)) + + const manifestPath = join(outputRoot, 'capture-manifest.json') + const provenance = await captureProvenance() + await writeFile( + manifestPath, + `${JSON.stringify( + { + schemaVersion: 3, + generatedAt: new Date().toISOString(), + command: 'pnpm capture:visual', + binary: 'clean npm install from generated tarball', + binarySha256: await sha256(binary), + tarball: packed.tarballName, + tarballSha256: packed.tarballSha256, + fixture: 'deterministic', + terminal: 'node-pty/xterm-256color', + node: process.version, + provenance, + states: stateManifests, + artifacts, + }, + null, + 2, + )}\n`, + ) + process.stdout.write( + `Wrote ${artifacts.length} W6 visual artifacts and ${stateManifests.length} required states to ${outputRoot}\n`, + ) +} finally { + await packed.cleanup() +} diff --git a/scripts/capture-w0.mjs b/scripts/capture-w0.mjs index b7923c3..aa0af18 100644 --- a/scripts/capture-w0.mjs +++ b/scripts/capture-w0.mjs @@ -1,10 +1,10 @@ -import { createHash } from 'node:crypto' import { execFile } from 'node:child_process' +import { createHash } from 'node:crypto' import { mkdir, readFile, rm, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import { promisify } from 'node:util' -import * as pty from 'node-pty' import xterm from '@xterm/headless' +import * as pty from 'node-pty' const run = promisify(execFile) const repository = new URL('../', import.meta.url).pathname @@ -73,7 +73,7 @@ async function capture(columns, rows) { await waitFor(() => screen.includes('braid'), `${columns}x${rows} header`) input('\u0010') - await waitFor(() => screen.includes('Commands') && screen.includes('/quit'), 'command overlay') + await waitFor(() => screen.includes('Commands') && screen.includes('/help'), 'command overlay') input('q') await waitFor(() => screen.includes('/quit') && !screen.includes('/help'), 'command search') input('\u001b') diff --git a/scripts/check-boundaries.mjs b/scripts/check-boundaries.mjs index 9d4b6ee..fc2f67f 100644 --- a/scripts/check-boundaries.mjs +++ b/scripts/check-boundaries.mjs @@ -40,8 +40,19 @@ for (const file of await filesUnder(sourceRoot.pathname)) { } if (path.startsWith('src/views/')) { + const imports = [...source.matchAll(/(?:from|import\s*\()\s*['"]([^'"]+)['"]/gu)].map( + (match) => match[1], + ) for (const forbidden of forbiddenViewImports) { - if (source.includes(forbidden)) violations.push(`${path}: view imports ${forbidden}`) + if (imports.some((specifier) => specifier?.includes(forbidden))) { + violations.push(`${path}: view imports ${forbidden}`) + } + } + if ( + path !== 'src/views/tui/safe-markdown.ts' && + /import\s*\{[^}]*\bMarkdown\b[^}]*\}\s*from\s*['"]@earendil-works\/pi-tui['"]/su.test(source) + ) { + violations.push(`${path}: bypasses the safe Markdown wrapper`) } } } diff --git a/scripts/check-dependencies.mjs b/scripts/check-dependencies.mjs index 03160c3..ac3b403 100644 --- a/scripts/check-dependencies.mjs +++ b/scripts/check-dependencies.mjs @@ -34,8 +34,20 @@ if (!packages.has('@earendil-works/pi-tui')) { if (violations.length > 0) { process.stderr.write(`Forbidden dependency graph entries:\n${violations.join('\n')}\n`) process.exitCode = 1 -} else { + process.exit() +} + +try { + execFileSync('pnpm', ['audit', '--prod', '--audit-level', 'high'], { + cwd: new URL('../', import.meta.url), + stdio: 'inherit', + }) +} catch { + process.stderr.write('Production dependency audit found a high or critical vulnerability\n') + process.exitCode = 1 +} + +if (process.exitCode !== 1) process.stdout.write( - `Production dependency graph: ${packages.size} packages; Pi TUI present; no copied agent application\n`, + `Production dependency graph: ${packages.size} packages; Pi TUI present; no copied agent application; no high or critical audit finding\n`, ) -} diff --git a/scripts/check-module-cycles.mjs b/scripts/check-module-cycles.mjs new file mode 100644 index 0000000..55b295c --- /dev/null +++ b/scripts/check-module-cycles.mjs @@ -0,0 +1,322 @@ +import { readdir } from 'node:fs/promises' +import { dirname, extname, join, relative, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { createVirtualFileSystem } from 'typescript/unstable/fs' +import { API } from 'typescript/unstable/sync' + +const repositoryRoot = fileURLToPath(new URL('../', import.meta.url)) +const sourceRoot = join(repositoryRoot, 'src') +const sourceExtensions = ['.ts', '.tsx', '.mts', '.cts'] +const javascriptSpecifierExtensions = new Map([ + ['.js', sourceExtensions], + ['.jsx', ['.tsx']], + ['.mjs', ['.mts']], + ['.cjs', ['.cts']], +]) + +async function sourceFiles(directory) { + const entries = await readdir(directory, { withFileTypes: true }) + const nested = await Promise.all( + entries.map(async (entry) => { + const path = join(directory, entry.name) + return entry.isDirectory() ? sourceFiles(path) : [path] + }), + ) + return nested + .flat() + .filter((path) => sourceExtensions.includes(extname(path))) + .sort() +} + +function isRelativeSpecifier(specifier) { + return ( + specifier === '.' || + specifier === '..' || + specifier.startsWith('./') || + specifier.startsWith('../') + ) +} + +function candidatesFor(raw) { + const extension = extname(raw) + if (sourceExtensions.includes(extension)) return [raw] + + const mappedExtensions = + javascriptSpecifierExtensions.get(extension) ?? + (extension === '' ? sourceExtensions : undefined) + if (!mappedExtensions) return [] + + const base = extension === '' ? raw : raw.slice(0, -extension.length) + return [ + ...mappedExtensions.map((candidateExtension) => `${base}${candidateExtension}`), + ...mappedExtensions.map((candidateExtension) => join(base, `index${candidateExtension}`)), + ] +} + +function resolveSpecifier(importer, specifier, modules, root) { + if (!isRelativeSpecifier(specifier)) return undefined + const raw = resolve(dirname(importer), specifier) + const target = candidatesFor(raw).find((candidate) => modules.has(candidate)) + if (!target) { + throw new Error( + `Unresolved relative TypeScript import: ${displayPath(root, importer)} -> ${specifier}`, + ) + } + return target +} + +function displayPath(root, path) { + const value = relative(root, path) + return value === '' ? '.' : value +} + +function moduleSpecifiers(sourceFile) { + return sourceFile.imports.map((specifier) => specifier.text) +} + +function graphFromFiles(files, root, specifiersForFile) { + if (files.length === 0) throw new Error('Module graph scan found zero TypeScript modules') + const modules = new Set(files) + const graph = new Map(files.map((file) => [file, new Set()])) + for (const file of files) { + for (const specifier of specifiersForFile(file)) { + const target = resolveSpecifier(file, specifier, modules, root) + if (target) graph.get(file).add(target) + } + } + return graph +} + +async function moduleGraph() { + const files = await sourceFiles(sourceRoot) + const api = new API({ cwd: repositoryRoot }) + let snapshot + try { + snapshot = api.updateSnapshot({ openProjects: [join(repositoryRoot, 'tsconfig.json')] }) + const project = snapshot + .getProjects() + .find((candidate) => candidate.program.getSourceFileNames().includes(files[0])) + if (!project) throw new Error('TypeScript project does not include the src tree') + return graphFromFiles(files, repositoryRoot, (file) => { + const sourceFile = project.program.getSourceFile(file) + if (!sourceFile) + throw new Error(`TypeScript project did not parse ${displayPath(repositoryRoot, file)}`) + return moduleSpecifiers(sourceFile) + }) + } finally { + snapshot?.dispose() + api.close() + } +} + +function cyclicComponents(graph) { + let nextIndex = 0 + const indices = new Map() + const lowLinks = new Map() + const stack = [] + const onStack = new Set() + const components = [] + + function visit(node) { + indices.set(node, nextIndex) + lowLinks.set(node, nextIndex) + nextIndex += 1 + stack.push(node) + onStack.add(node) + for (const target of graph.get(node)) { + if (!indices.has(target)) { + visit(target) + lowLinks.set(node, Math.min(lowLinks.get(node), lowLinks.get(target))) + } else if (onStack.has(target)) { + lowLinks.set(node, Math.min(lowLinks.get(node), indices.get(target))) + } + } + if (lowLinks.get(node) !== indices.get(node)) return + const component = [] + let member + do { + member = stack.pop() + onStack.delete(member) + component.push(member) + } while (member !== node) + component.sort() + if (component.length > 1 || graph.get(component[0]).has(component[0])) { + components.push(component) + } + } + + for (const node of graph.keys()) if (!indices.has(node)) visit(node) + return components.sort((left, right) => left[0].localeCompare(right[0])) +} + +function edgeCount(graph) { + return [...graph.values()].reduce((total, targets) => total + targets.size, 0) +} + +function cycleReport(graph, components, root) { + const lines = [ + `Module graph: modules=${graph.size}; edges=${edgeCount(graph)}; cyclicSccs=${components.length}`, + ] + for (const [index, component] of components.entries()) { + lines.push(`SCC ${index + 1} (size=${component.length}):`) + for (const member of component) lines.push(` ${displayPath(root, member)}`) + const members = new Set(component) + lines.push(' internal edges:') + for (const source of component) { + for (const target of graph.get(source)) { + if (members.has(target)) { + lines.push(` ${displayPath(root, source)} -> ${displayPath(root, target)}`) + } + } + } + } + return lines.join('\n') +} + +function assertAcyclic(graph, label, root) { + const components = cyclicComponents(graph) + if (components.length > 0) { + throw new Error(`${label} contains module cycles\n${cycleReport(graph, components, root)}`) + } + return components +} + +function assertGraphEdges(graph, expected, label) { + const actual = new Set() + for (const [source, targets] of graph) { + for (const target of targets) actual.add(`${source}->${target}`) + } + const expectedSet = new Set(expected) + if (actual.size !== expectedSet.size || [...expectedSet].some((edge) => !actual.has(edge))) { + throw new Error( + `${label} parsed edges differ\nexpected=${[...expectedSet].sort().join(',')}\nactual=${[ + ...actual, + ] + .sort() + .join(',')}`, + ) + } +} + +async function selfTestGraph() { + const root = '/braid-module-cycle-self-test' + const source = new Map([ + [ + join(root, 'entry.ts'), + ` + import { value as component } from './component.js' + import { value as commonjsSource } from './commonjs-source.js' + import { value as esmSource } from './esm-source.js' + import './side-effect.js' + import type { TypeOnly } from './types.js' + export { value as reexported } from './reexport.js' + export type { ExportedType } from './export-type.js' + type Imported = import('./type-expression.js').TypeValue + void import('./dynamic.js') + import './directory.js' + import './extensionless' + const fakeString = "import './fake-string.js'" + /* export { fakeComment } from './fake-comment.js' */ + void component + void commonjsSource + void esmSource + void (null as unknown as TypeOnly as Imported) + `, + ], + [join(root, 'component.tsx'), "import './entry.js'\nexport const value = 1"], + [join(root, 'commonjs-source.cts'), 'export const value = 1'], + [join(root, 'esm-source.mts'), 'export const value = 1'], + [join(root, 'side-effect.ts'), 'export const side = true'], + [join(root, 'types.ts'), 'export type TypeOnly = string'], + [join(root, 'reexport.ts'), 'export const value = 1'], + [join(root, 'export-type.ts'), 'export type ExportedType = string'], + [join(root, 'type-expression.ts'), 'export type TypeValue = string'], + [join(root, 'dynamic.ts'), 'export const dynamic = true'], + [join(root, 'directory/index.ts'), 'export const directory = true'], + [join(root, 'extensionless/index.cts'), 'export const extensionless = true'], + ]) + const unresolvedFile = join(root, 'unresolved.ts') + const files = [...source.keys()] + const virtualFiles = Object.fromEntries([...source, [unresolvedFile, "import './missing.js'"]]) + const api = new API({ cwd: root, fs: createVirtualFileSystem(virtualFiles) }) + let snapshot + try { + snapshot = api.updateSnapshot({ openFiles: [...files, unresolvedFile] }) + const project = snapshot.getDefaultProjectForFile(files[0]) + if (!project) throw new Error('Self-test TypeScript project was not created') + const graph = graphFromFiles(files, root, (file) => { + const sourceFile = project.program.getSourceFile(file) + if (!sourceFile) throw new Error(`Self-test source file was not parsed: ${file}`) + return moduleSpecifiers(sourceFile) + }) + const entry = join(root, 'entry.ts') + const component = join(root, 'component.tsx') + assertGraphEdges( + graph, + [ + `${entry}->${component}`, + `${entry}->${join(root, 'commonjs-source.cts')}`, + `${entry}->${join(root, 'esm-source.mts')}`, + `${entry}->${join(root, 'side-effect.ts')}`, + `${entry}->${join(root, 'types.ts')}`, + `${entry}->${join(root, 'reexport.ts')}`, + `${entry}->${join(root, 'export-type.ts')}`, + `${entry}->${join(root, 'type-expression.ts')}`, + `${entry}->${join(root, 'dynamic.ts')}`, + `${entry}->${join(root, 'directory/index.ts')}`, + `${entry}->${join(root, 'extensionless/index.cts')}`, + `${component}->${entry}`, + ], + 'Module-cycle self-test', + ) + let rejected = false + try { + assertAcyclic(graph, 'synthetic source graph', root) + } catch (error) { + rejected = + error instanceof Error && + error.message.includes('SCC 1 (size=2)') && + error.message.includes('entry.ts') && + error.message.includes('component.tsx') + } + if (!rejected) { + throw new Error( + 'Module-cycle self-test did not reject the parsed and resolved synthetic cycle', + ) + } + let unresolvedRejected = false + try { + graphFromFiles([unresolvedFile], root, (file) => { + const sourceFile = project.program.getSourceFile(file) + if (!sourceFile) throw new Error(`Self-test source file was not parsed: ${file}`) + return moduleSpecifiers(sourceFile) + }) + } catch (error) { + unresolvedRejected = + error instanceof Error && error.message.includes('Unresolved relative TypeScript import') + } + if (!unresolvedRejected) throw new Error('Module-cycle self-test accepted an unresolved import') + } finally { + snapshot?.dispose() + api.close() + } +} + +async function main() { + const selfTest = process.argv.includes('--self-test') + if (selfTest) await selfTestGraph() + const graph = await moduleGraph() + const components = assertAcyclic(graph, 'src', repositoryRoot) + process.stdout.write( + `${cycleReport(graph, components, repositoryRoot)}${ + selfTest ? '; selfTest=parsed-resolved-cycle-rejected; real-graph=accepted' : '' + }\n`, + ) +} + +try { + await main() +} catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`) + process.exitCode = 1 +} diff --git a/scripts/check-release.mjs b/scripts/check-release.mjs new file mode 100644 index 0000000..15feffa --- /dev/null +++ b/scripts/check-release.mjs @@ -0,0 +1,44 @@ +import { readFile } from 'node:fs/promises' +import { REQUIRED_CHECKS } from './release-check-catalog.mjs' + +const packageJson = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')) +const composition = await readFile(new URL('../src/app/composition.ts', import.meta.url), 'utf8') +const requiredScripts = [ + ...new Set( + [...REQUIRED_CHECKS.values()].map(({ command }) => { + if (!command.startsWith('pnpm ')) throw new Error(`Invalid release command: ${command}`) + return command.slice('pnpm '.length) + }), + ), +] +const missingScripts = requiredScripts.filter( + (name) => typeof packageJson.scripts?.[name] !== 'string', +) +if (missingScripts.length > 0) { + throw new Error(`Missing stable release scripts: ${missingScripts.join(', ')}`) +} +if (composition.includes('new MemoryJournal')) { + throw new Error('Production composition must depend on JournalPort, not MemoryJournal') +} +if (packageJson.dependencies?.['better-sqlite3-multiple-ciphers'] !== '12.11.1') { + throw new Error('Production storage must pin better-sqlite3-multiple-ciphers@12.11.1') +} + +const requiredFiles = [ + 'src/app/effect-coordinator.ts', + 'src/ports/effect-storage.ts', + 'src/domain/entities.ts', + 'src/domain/invariants.ts', + 'src/adapters/storage/sqlite.ts', + 'src/adapters/storage/sqlite-driver.ts', + 'src/adapters/credentials/os.ts', + 'docs/decisions/004-application-effect-coordination.md', + 'docs/decisions/005-encrypted-sqlite-and-credential-boundaries.md', +] +for (const path of requiredFiles) { + await readFile(new URL(`../${path}`, import.meta.url)) +} + +process.stdout.write( + `W5 release contract: ${requiredScripts.length} stable scripts, ${requiredFiles.length} durable-core artifacts, live checks explicitly external\n`, +) diff --git a/scripts/check-scope.mjs b/scripts/check-scope.mjs new file mode 100644 index 0000000..c3ea2c8 --- /dev/null +++ b/scripts/check-scope.mjs @@ -0,0 +1,11 @@ +const scope = process.argv[2] + +if (scope !== 'live') { + process.stderr.write(`Unknown external check scope: ${scope ?? '(missing)'}\n`) + process.exit(1) +} + +process.stderr.write( + 'Live provider checks require the published provider adapters and credentials; they are not part of the W5 coordination track.\n', +) +process.exitCode = 2 diff --git a/scripts/clean-tests.mjs b/scripts/clean-tests.mjs index 0da017f..ff28f21 100644 --- a/scripts/clean-tests.mjs +++ b/scripts/clean-tests.mjs @@ -1,3 +1,20 @@ -import { rm } from 'node:fs/promises' +import { cp, mkdir, readdir, rm } from 'node:fs/promises' await rm(new URL('../.test-dist', import.meta.url), { force: true, recursive: true }) + +const sourceScripts = new URL('../scripts/', import.meta.url) +const testScripts = new URL('../.test-dist/scripts/', import.meta.url) + +async function copyModuleScripts(source, target) { + await mkdir(target, { recursive: true }) + for (const entry of await readdir(source, { withFileTypes: true })) { + if (entry.isDirectory()) { + await copyModuleScripts(new URL(`${entry.name}/`, source), new URL(`${entry.name}/`, target)) + continue + } + if (!entry.isFile() || !entry.name.endsWith('.mjs')) continue + await cp(new URL(entry.name, source), new URL(entry.name, target)) + } +} + +await copyModuleScripts(sourceScripts, testScripts) diff --git a/scripts/eval/candidate.mjs b/scripts/eval/candidate.mjs new file mode 100644 index 0000000..06c6768 --- /dev/null +++ b/scripts/eval/candidate.mjs @@ -0,0 +1,30 @@ +import { join } from 'node:path' +import { installPackedBraid } from '../packed-binary.mjs' + +function configured(value) { + return typeof value === 'string' && value.trim().length > 0 +} + +export async function prepareEvalCandidate(repository, environment = process.env) { + if ( + configured(environment.BRAID_EVAL_PACKAGE_ROOT) || + configured(environment.BRAID_EVAL_TARBALL_PATH) + ) { + return { + environment, + generated: false, + cleanup: async () => undefined, + } + } + + const packed = await installPackedBraid(repository) + return { + environment: { + ...environment, + BRAID_EVAL_PACKAGE_ROOT: join(packed.installRoot, 'node_modules', '@tangle-network', 'braid'), + BRAID_EVAL_TARBALL_PATH: packed.tarball, + }, + generated: true, + cleanup: packed.cleanup, + } +} diff --git a/scripts/eval/run.mjs b/scripts/eval/run.mjs new file mode 100644 index 0000000..86742b7 --- /dev/null +++ b/scripts/eval/run.mjs @@ -0,0 +1,53 @@ +import { semanticEvalMeasurements } from '../../dist/eval/release-markers.js' +import { runSemanticEvaluation } from '../../dist/eval/runner.js' +import { prepareEvalCandidate } from './candidate.mjs' + +const repository = new URL('../../', import.meta.url).pathname +const candidate = await prepareEvalCandidate(repository) +let result +try { + result = await runSemanticEvaluation({ env: candidate.environment }) +} finally { + await candidate.cleanup() +} +const summary = { + status: result.record.status, + releaseAdmissible: result.record.releaseAdmissible, + outputDir: result.outputDir, + pilot: result.record.pilot, + calibration: { + passed: result.record.calibration.passed, + pairedExamples: result.record.calibration.pairedExamples, + goodPreferred: result.record.calibration.goodPreferred, + minimumGoodPreferred: result.record.calibration.minimumGoodPreferred, + ties: result.record.calibration.ties, + reversals: result.record.calibration.reversals, + perCategory: result.record.calibration.perCategory, + }, + cases: result.record.cases.map((entry) => ({ + id: entry.id, + result: entry.result, + passedFixtures: entry.passedFixtures, + failedFixtures: entry.failedFixtures, + productFailures: entry.productFailures, + disagreements: entry.disagreements, + })), + artifactHash: result.record.artifactHash, +} +process.stdout.write(`${JSON.stringify(summary)}\n`) +const reason = result.record.releaseFailureReasons.join('; ') || result.record.unavailableReason +process.stdout.write( + `BRAID_RELEASE_RESULT_JSON=${JSON.stringify({ + status: result.record.status, + ...(reason === null || reason.length === 0 ? {} : { reason }), + })}\n`, +) +if (result.record.status === 'passed' && result.record.releaseAdmissible === true) { + process.stdout.write( + `BRAID_RELEASE_MEASUREMENTS_JSON=${JSON.stringify({ + measurements: semanticEvalMeasurements(result.record), + })}\n`, + ) +} else { + process.exitCode = 1 +} diff --git a/scripts/live-bridge.mjs b/scripts/live-bridge.mjs new file mode 100644 index 0000000..83d6c9c --- /dev/null +++ b/scripts/live-bridge.mjs @@ -0,0 +1,3 @@ +import { main } from './live-bridge/main.mjs' + +await main() diff --git a/scripts/live-bridge/bridge.mjs b/scripts/live-bridge/bridge.mjs new file mode 100644 index 0000000..96e9949 --- /dev/null +++ b/scripts/live-bridge/bridge.mjs @@ -0,0 +1,188 @@ +import { access } from 'node:fs/promises' +import { join, resolve } from 'node:path' + +import { StreamingRedactor } from './capture.mjs' +import { exitCodes } from './constants.mjs' +import { + healthBackendsReady, + healthIsStructurallyValid, + modelIds, + requestJson, +} from './endpoint.mjs' +import { LiveBridgeError } from './errors.mjs' +import { managedSpawn, sleep, terminateProcess } from './process.mjs' +import { redactString } from './redaction.mjs' + +export async function launchBridgeIfRequested(endpoint, token, evidence, repository) { + const initialHealth = await requestJson(endpoint, '/health', token) + evidence.initialHealth = initialHealth + if (healthIsStructurallyValid(initialHealth)) return { health: initialHealth } + if (initialHealth.status !== undefined) { + throw new LiveBridgeError( + 'BRIDGE_NOT_READY', + `CLI Bridge responded at ${endpoint} but is not fully ready; the driver will not start a second bridge`, + exitCodes.unavailable, + { health: initialHealth }, + ) + } + if (process.env.BRAID_CLI_BRIDGE_START !== '1') { + throw new LiveBridgeError( + 'BRIDGE_NOT_READY', + `CLI Bridge is not ready at ${endpoint}; set BRAID_CLI_BRIDGE_START=1 to launch the local bridge explicitly`, + exitCodes.unavailable, + { health: initialHealth }, + ) + } + const bridgeDirectory = resolve( + process.env.BRAID_CLI_BRIDGE_DIR ?? join(repository, '..', '..', 'cli-bridge'), + ) + try { + await access(join(bridgeDirectory, 'package.json')) + } catch (error) { + throw new LiveBridgeError( + 'BRIDGE_SOURCE_NOT_FOUND', + `CLI Bridge launch directory is not available: ${bridgeDirectory}`, + exitCodes.unavailable, + { cause: error instanceof Error ? error.message : String(error) }, + ) + } + const childEnv = { ...process.env } + const parsedEndpoint = new URL(endpoint) + if (parsedEndpoint.port && childEnv.BRIDGE_PORT === undefined) + childEnv.BRIDGE_PORT = parsedEndpoint.port + const child = managedSpawn('pnpm', ['start'], { + cwd: bridgeDirectory, + env: childEnv, + stdio: ['ignore', 'pipe', 'pipe'], + }) + const stdoutCapture = new StreamingRedactor() + const stderrCapture = new StreamingRedactor() + child.stdout.setEncoding('utf8') + child.stderr.setEncoding('utf8') + child.stdout.on('data', (chunk) => stdoutCapture.push(chunk)) + child.stderr.on('data', (chunk) => stderrCapture.push(chunk)) + let exited + const exit = new Promise((resolveExit) => { + let spawnError + child.once('error', (error) => { + spawnError = error instanceof Error ? error.message : String(error) + }) + child.once('close', (code, signal) => resolveExit({ code, signal, error: spawnError })) + }) + const stopChild = async () => terminateProcess(child) + const deadline = Date.now() + Number(process.env.BRAID_CLI_BRIDGE_START_TIMEOUT_MS ?? 30_000) + let health = initialHealth + while (Date.now() < deadline) { + await sleep(250) + health = await requestJson(endpoint, '/health', token) + if (healthIsStructurallyValid(health)) break + if (child.exitCode !== null || child.signalCode !== null) { + exited = await Promise.race([exit, sleep(1_000).then(() => ({ timeout: true }))]) + } + if (exited !== undefined) break + } + evidence.launch = { + requested: true, + directory: bridgeDirectory, + command: 'pnpm start', + health, + process: { + stdout: stdoutCapture.finish(), + stderr: stderrCapture.finish(), + exited, + }, + } + if (!healthIsStructurallyValid(health)) { + evidence.launch.cleanup = { stopped: await stopChild() } + throw new LiveBridgeError( + 'BRIDGE_START_FAILED', + `CLI Bridge did not become ready at ${endpoint}`, + exitCodes.unavailable, + { + health, + process: { + stdout: stdoutCapture.finish(), + stderr: stderrCapture.finish(), + exited, + }, + }, + ) + } + return { + health, + cleanup: stopChild, + } +} + +export async function discoverBridge(endpoint, token, evidence, repository, definitions) { + const bridge = await launchBridgeIfRequested(endpoint, token, evidence, repository) + try { + const modelsResponse = await requestJson(endpoint, '/v1/models', token) + evidence.models = modelsResponse + const ids = modelIds(modelsResponse) + const selected = definitions + .filter((definition) => ids.includes(definition.modelId)) + .map((definition) => ({ + definition, + key: definition.key, + modelId: definition.modelId, + backend: definition.backend, + })) + const missing = definitions.filter((definition) => !ids.includes(definition.modelId)) + evidence.advertisedModels = ids + evidence.missingTargets = missing.map(({ key, label, modelId, backend }) => ({ + key, + label, + modelId, + backend, + })) + evidence.selectedTargets = selected.map(({ definition, modelId }) => ({ + key: definition.key, + label: definition.label, + modelId, + })) + if (missing.length > 0) { + throw new LiveBridgeError( + 'TARGET_MODEL_NOT_ADVERTISED', + `CLI Bridge does not advertise every required target: ${missing.map(({ modelId }) => modelId).join(', ')}`, + exitCodes.unavailable, + { + requiredTargets: definitions.map(({ modelId }) => modelId), + missingTargets: missing.map(({ modelId }) => modelId), + advertisedModels: ids, + }, + ) + } + const health = await requestJson(endpoint, '/health', token) + evidence.requiredHealth = health + const unavailableBackends = selected.filter( + (target) => !healthBackendsReady(health, [target.backend]), + ) + if (unavailableBackends.length > 0) { + throw new LiveBridgeError( + 'TARGET_BACKEND_NOT_READY', + `CLI Bridge does not report every selected target backend ready: ${unavailableBackends.map((target) => target.modelId).join(', ')}`, + exitCodes.unavailable, + { + targets: unavailableBackends.map((target) => target.modelId), + health, + }, + ) + } + return { ...bridge, health, selected } + } catch (error) { + if (bridge.cleanup !== undefined) { + try { + evidence.discoveryCleanup = { stopped: await bridge.cleanup() } + } catch (cleanupError) { + evidence.discoveryCleanup = { + stopped: false, + error: redactString( + cleanupError instanceof Error ? cleanupError.message : String(cleanupError), + ), + } + } + } + throw error + } +} diff --git a/scripts/live-bridge/capture.mjs b/scripts/live-bridge/capture.mjs new file mode 100644 index 0000000..e83c695 --- /dev/null +++ b/scripts/live-bridge/capture.mjs @@ -0,0 +1,101 @@ +import { redactString } from './redaction.mjs' + +const defaultHoldChars = 512 +const maxPendingChars = 8_192 +const sensitiveBoundaryPattern = + /(?:bearer\s+[A-Za-z0-9._~+/=-]*|(?:token|access[-_]?token|refresh[-_]?token|api[-_]?key|secret|client[-_]?secret|password|authorization|credential)\s*[:=]\s*[^,\s;&#]*|https?:\/\/[^/\s@]*|\b(?:sk|pk|rk)-[A-Za-z0-9_-]*|\bgh[pousr]_[A-Za-z0-9_]*)$/iu +const sensitiveMarkers = Object.freeze([ + 'http://', + 'https://', + 'bearer', + 'token=', + 'token:', + 'access_token=', + 'access-token=', + 'access_token:', + 'access-token:', + 'refresh_token=', + 'refresh-token=', + 'api_key=', + 'api-key=', + 'secret=', + 'secret:', + 'password=', + 'password:', + 'authorization=', + 'authorization:', + 'credential=', + 'credential:', + 'sk-', + 'pk-', + 'rk-', + 'ghp_', + 'gho_', + 'ghu_', + 'ghs_', + 'ghr_', +]) + +export function appendBounded(current, chunk, maxBytes = 256_000) { + const next = Buffer.from(`${current}${chunk}`, 'utf8') + if (next.byteLength <= maxBytes) return next.toString('utf8') + if (maxBytes <= 0) return '' + let start = next.byteLength - maxBytes + while (start < next.byteLength && (next[start] & 0xc0) === 0x80) start += 1 + return next.subarray(start).toString('utf8') +} + +function sensitiveStartAtBoundary(value, boundary) { + const prefix = value.slice(0, boundary) + const match = prefix.match(sensitiveBoundaryPattern) + if (match?.index !== undefined) return match.index + const lowerPrefix = prefix.toLowerCase() + for (const marker of sensitiveMarkers) { + for (let length = 1; length < marker.length; length += 1) { + if (lowerPrefix.endsWith(marker.slice(0, length))) return boundary - length + } + } + return undefined +} + +export class StreamingRedactor { + constructor(maxBytes = 256_000, holdChars = defaultHoldChars) { + this.maxBytes = maxBytes + this.holdChars = holdChars + this.pending = '' + this.retained = '' + this.finished = false + } + + push(chunk) { + if (this.finished) return this.retained + const value = `${this.pending}${chunk}` + if (value.length <= this.holdChars) { + this.pending = value + return this.retained + } + let boundary = value.length - this.holdChars + const sensitiveStart = sensitiveStartAtBoundary(value, boundary) + if (sensitiveStart !== undefined) boundary = sensitiveStart + if (value.length - boundary > maxPendingChars) { + this.retained = appendBounded(this.retained, '[redacted-stream-overflow]', this.maxBytes) + this.pending = '' + return this.retained + } + this.retained = appendBounded( + this.retained, + redactString(value.slice(0, boundary)), + this.maxBytes, + ) + this.pending = value.slice(boundary) + return this.retained + } + + finish() { + if (this.finished) return this.retained + this.retained = appendBounded(this.retained, redactString(this.pending), this.maxBytes) + this.pending = '' + this.finished = true + return this.retained + } +} diff --git a/scripts/live-bridge/command.mjs b/scripts/live-bridge/command.mjs new file mode 100644 index 0000000..27822d7 --- /dev/null +++ b/scripts/live-bridge/command.mjs @@ -0,0 +1,89 @@ +import { StreamingRedactor } from './capture.mjs' +import { managedSpawn, observeNaturalExit, terminateProcess } from './process.mjs' + +export async function runCommand(command, args, options) { + const startedAt = Date.now() + const maxOutputBytes = options.maxOutputBytes ?? 256_000 + return await new Promise((resolveResult) => { + const child = managedSpawn(command, args, { + cwd: options.cwd, + env: { + ...process.env, + ...options.env, + NO_COLOR: '1', + NODE_NO_WARNINGS: '1', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }) + let stdout = '' + let stderr = '' + const stdoutCapture = new StreamingRedactor(maxOutputBytes) + const stderrCapture = new StreamingRedactor(maxOutputBytes) + let timedOut = false + let closeResult + let spawnError + let termination + let settled = false + let closeSeen = false + let timer + const finish = () => { + if (settled || (timedOut && termination === undefined)) return + if (!timedOut && !closeSeen && spawnError === undefined) return + settled = true + clearTimeout(timer) + stdout = stdoutCapture.finish() + stderr = stderrCapture.finish() + resolveResult({ + command, + args, + code: closeResult?.code, + signal: closeResult?.signal, + error: spawnError, + stdout, + stderr, + durationMs: Date.now() - startedAt, + timedOut, + cleanupOk: + termination?.exited === true && + termination.descendantsExited === true && + termination.descendantsVerified === true, + termination: termination ?? { strategy: 'spawn-error', cleanupStatus: 'unsupported' }, + }) + } + child.stdout.setEncoding('utf8') + child.stderr.setEncoding('utf8') + child.stdout.on('data', (chunk) => { + stdoutCapture.push(chunk) + }) + child.stderr.on('data', (chunk) => { + stderrCapture.push(chunk) + }) + child.once('error', (error) => { + if (settled) return + spawnError = error.message + closeSeen = true + finish() + }) + child.once('close', async (code, signal) => { + if (settled) return + closeResult = { code, signal } + closeSeen = true + if (!timedOut) { + clearTimeout(timer) + const natural = await observeNaturalExit(child) + termination = + natural.cleanupStatus === 'natural-exit' ? natural : await terminateProcess(child) + } + finish() + }) + timer = setTimeout( + async () => { + if (settled) return + timedOut = true + termination = await terminateProcess(child) + finish() + }, + options.timeoutMs ?? 10 * 60_000, + ) + }) +} diff --git a/scripts/live-bridge/config.mjs b/scripts/live-bridge/config.mjs new file mode 100644 index 0000000..e24a30d --- /dev/null +++ b/scripts/live-bridge/config.mjs @@ -0,0 +1,169 @@ +import { randomUUID } from 'node:crypto' +import { mkdir, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' + +import { exitCodes } from './constants.mjs' +import { bridgeAuthToken } from './endpoint.mjs' +import { LiveBridgeError } from './errors.mjs' +import { errorEvidence } from './evidence.mjs' +import { evidenceValue } from './redaction.mjs' + +export async function installBridgeCredential(evidence, repository) { + const token = bridgeAuthToken() + if (token === undefined) return undefined + const credentialId = `live-bridge-${randomUUID().replaceAll('-', '')}` + const credentialRef = `cred:v1:${credentialId}` + try { + const module = await import( + pathToFileURL(join(repository, 'dist', 'adapters', 'credentials', 'os.js')).href + ) + const store = module.createOperatingSystemCredentialStore() + await store.store({ + ref: credentialRef, + value: Buffer.from(token), + label: 'Braid live CLI Bridge smoke', + }) + evidence.credentialState = { configured: true, stored: true, facility: process.platform } + return { store, credentialRef, recordRef: credentialId } + } catch (error) { + throw new LiveBridgeError( + 'CREDENTIAL_STORE_UNAVAILABLE', + 'A bridge bearer token was provided, but Braid could not install it in the operating-system credential store', + exitCodes.unavailable, + { cause: error instanceof Error ? error.message : String(error) }, + ) + } +} + +export async function probePackedAnalysisReadiness(installRoot, endpoint, modelId) { + const distRoot = join(installRoot, 'node_modules', '@tangle-network', 'braid', 'dist') + try { + const [runnerModule, adapterModule] = await Promise.all([ + import(pathToFileURL(join(distRoot, 'adapters', 'analysis', 'python-runner.js')).href), + import( + pathToFileURL(join(distRoot, 'adapters', 'analysis', 'trace-analysis-adapter.js')).href + ), + ]) + const runner = await runnerModule.resolvePythonRunner() + const profile = { + name: `Braid live ${modelId}`, + harness: modelId.split('/')[0], + model: { default: modelId }, + } + const connection = { + id: 'connection-live-cli-bridge', + kind: 'cli-bridge', + name: 'Live local CLI Bridge', + endpoint, + providerOptions: { transport: 'local' }, + createdAt: new Date(0).toISOString(), + updatedAt: new Date(0).toISOString(), + lastHealth: { status: 'unknown' }, + } + const configuration = await adapterModule.createTraceAnalysisAdapter({ connection, profile }) + const unavailableStatuses = new Set([ + 'missing-python', + 'missing-python-package', + 'missing-model', + 'missing-credential', + 'unsupported-connection', + 'unavailable', + ]) + return { + claim: 'packed-analysis-readiness-only', + python: evidenceValue({ + status: runner.status, + readiness: runner.status === 'ready' ? 'ready' : 'unavailable', + command: runner.command, + source: runner.source, + message: runner.message, + }), + ask: evidenceValue({ + readiness: + configuration.status === 'engine-configured' + ? 'ready' + : unavailableStatuses.has(configuration.status) + ? 'unavailable' + : 'probe-failed', + status: configuration.status, + diagnostics: configuration.diagnostics, + }), + } + } catch (error) { + return { + claim: 'packed-analysis-readiness-only', + python: { status: 'probe-failed' }, + ask: { status: 'probe-failed' }, + error: errorEvidence(error), + } + } +} + +export async function loadProviderCapabilities(installRoot) { + const module = await import( + pathToFileURL( + join( + installRoot, + 'node_modules', + '@tangle-network', + 'agent-provider-cli-bridge', + 'dist', + 'index.js', + ), + ).href + ) + return module.defaultCliBridgeCapabilities() +} + +export async function writeTargetConfig(root, endpoint, modelId, credential) { + const key = modelId + .replaceAll(/[^a-z0-9]+/giu, '-') + .replace(/^-|-$/gu, '') + .toLowerCase() + const workspace = join(root, `workspace-${key}`) + const configDirectory = join(workspace, '.braid') + const profileDirectory = join(configDirectory, 'profiles') + await mkdir(profileDirectory, { recursive: true, mode: 0o700 }) + const profileFile = `profile-${key}.json` + const profilePath = join(profileDirectory, profileFile) + const harness = modelId.split('/')[0] + const profile = { + name: `Braid live ${modelId}`, + description: 'Opt-in packed CLI Bridge smoke profile', + version: '0.1.0', + harness, + model: { default: modelId, reasoningEffort: 'none' }, + } + const connectionId = 'connection-live-cli-bridge' + const now = new Date().toISOString() + const connection = { + id: connectionId, + kind: 'cli-bridge', + name: 'Live local CLI Bridge', + endpoint, + ...(credential === undefined ? {} : { credentialRef: credential.recordRef }), + providerOptions: { transport: 'local' }, + createdAt: now, + updatedAt: now, + lastHealth: { status: 'healthy', checkedAt: now }, + } + const configPath = join(configDirectory, 'config.json') + await writeFile(profilePath, `${JSON.stringify(profile, null, 2)}\n`, { mode: 0o600 }) + await writeFile( + configPath, + `${JSON.stringify( + { + format: 'braid-startup-config', + schemaVersion: 1, + profile: `profiles/${profileFile}`, + connectionId, + connections: [connection], + }, + null, + 2, + )}\n`, + { mode: 0o600 }, + ) + return { workspace, configPath, profilePath, profile, connection, key } +} diff --git a/scripts/live-bridge/constants.mjs b/scripts/live-bridge/constants.mjs new file mode 100644 index 0000000..4131eb2 --- /dev/null +++ b/scripts/live-bridge/constants.mjs @@ -0,0 +1,37 @@ +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +export const repository = resolve(fileURLToPath(new URL('../../', import.meta.url))) +export const exitCodes = Object.freeze({ passed: 0, failed: 1, unavailable: 2 }) +export const defaultTimeoutMs = 120_000 +export const liveProofScope = Object.freeze({ + name: 'one-shot-production-target-proof', + claims: Object.freeze([ + 'packed startup', + 'exact marker response for each required target', + 'advertised reconnect, cancel, and interaction semantics', + 'bounded process cleanup', + ]), + excludes: Object.freeze(['LIVE-01..05 full interactive runner conformance']), +}) + +export const targetDefinitions = Object.freeze([ + { + key: 'glm-5.2', + label: 'GLM 5.2', + modelId: 'opencode/zai-coding-plan/glm-5.2', + backend: 'opencode', + }, + { + key: 'luna-max', + label: 'Luna Max', + modelId: 'pi/openai-codex/gpt-5.6-luna', + backend: 'pi', + }, +]) + +export const livePrompts = Object.freeze({ + normal: (key) => `Reply with exactly LIVE_BRAID_${key.toUpperCase().replaceAll('.', '_')}_OK.`, + cancel: (key) => + `For LIVE_BRAID_${key.toUpperCase().replaceAll('.', '_')}_CANCEL, produce a numbered list from 1 to 1000 with one short word per line.`, +}) diff --git a/scripts/live-bridge/endpoint.mjs b/scripts/live-bridge/endpoint.mjs new file mode 100644 index 0000000..e57ed61 --- /dev/null +++ b/scripts/live-bridge/endpoint.mjs @@ -0,0 +1,131 @@ +import { exitCodes } from './constants.mjs' +import { LiveBridgeError } from './errors.mjs' +import { evidenceValue, redactString } from './redaction.mjs' + +export function safeEndpoint(raw) { + let parsed + try { + parsed = new URL(raw) + } catch (error) { + throw new LiveBridgeError( + 'BRIDGE_URL_INVALID', + 'CLI Bridge URL must be an absolute http(s) URL without embedded credentials', + exitCodes.unavailable, + { cause: error instanceof Error ? error.message : String(error) }, + ) + } + if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password) { + throw new LiveBridgeError( + 'BRIDGE_URL_INVALID', + 'CLI Bridge URL must be an absolute http(s) URL without embedded credentials', + exitCodes.unavailable, + ) + } + if (parsed.search || parsed.hash) { + throw new LiveBridgeError( + 'BRIDGE_URL_INVALID', + 'CLI Bridge URL must not contain query or fragment data', + exitCodes.unavailable, + ) + } + return parsed.toString().replace(/\/$/u, '') +} + +export function endpointForEvidence(endpoint) { + const parsed = new URL(endpoint) + return `${parsed.protocol}//${parsed.host}${parsed.pathname.replace(/\/$/u, '')}` +} + +export function unknownEndpointForEvidence(raw) { + try { + return endpointForEvidence(safeEndpoint(raw)) + } catch { + return redactString(raw) + .replace(/(https?:\/\/)([^/@:]+):([^/@]+)@/iu, '$1[redacted]@') + .replace( + /([?&](?:token|api[-_]?key|secret|password|authorization|credential)=)[^&\s]+/giu, + '$1[redacted]', + ) + } +} + +export function isLoopback(endpoint) { + const hostname = new URL(endpoint).hostname.toLowerCase() + return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' +} + +export function bridgeAuthToken() { + return ( + process.env.BRAID_CLI_BRIDGE_BEARER ?? + process.env.CLI_BRIDGE_BEARER ?? + process.env.BRIDGE_BEARER + ) +} + +export async function requestJson(endpoint, path, token, timeoutMs = 10_000) { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeoutMs) + try { + const response = await fetch(`${endpoint}${path}`, { + headers: { + accept: 'application/json', + ...(token === undefined ? {} : { authorization: `Bearer ${token}` }), + }, + signal: controller.signal, + }) + const text = await response.text() + let body + try { + body = text.length === 0 ? undefined : JSON.parse(text) + } catch { + body = undefined + } + return { + status: response.status, + ok: response.ok, + body: evidenceValue(body), + text: redactString(text).slice(0, 64_000), + } + } catch (error) { + return { + status: undefined, + ok: false, + error: redactString(error instanceof Error ? error.message : String(error)), + } + } finally { + clearTimeout(timer) + } +} + +export function healthIsStructurallyValid(response) { + if (!response.ok || !response.body || typeof response.body !== 'object') return false + if (response.body.status !== 'ok') return false + const backends = response.body.backends + return ( + Array.isArray(backends) && + backends.every( + (item) => + item !== null && + typeof item === 'object' && + typeof item.name === 'string' && + typeof item.state === 'string', + ) + ) +} + +export function healthBackendsReady(response, requiredBackends) { + if (!healthIsStructurallyValid(response)) return false + return requiredBackends.every((required) => + response.body.backends.some( + (backend) => backend.name === required && backend.state === 'ready', + ), + ) +} + +export function modelIds(response) { + if (!response.ok || !response.body || typeof response.body !== 'object') return [] + if (!Array.isArray(response.body.data)) return [] + return response.body.data + .map((item) => (item && typeof item.id === 'string' ? item.id : undefined)) + .filter((id) => id !== undefined) +} diff --git a/scripts/live-bridge/errors.mjs b/scripts/live-bridge/errors.mjs new file mode 100644 index 0000000..d2165e9 --- /dev/null +++ b/scripts/live-bridge/errors.mjs @@ -0,0 +1,9 @@ +export class LiveBridgeError extends Error { + constructor(code, message, exitCode, details = {}) { + super(message) + this.name = 'LiveBridgeError' + this.code = code + this.exitCode = exitCode + this.details = details + } +} diff --git a/scripts/live-bridge/evidence.mjs b/scripts/live-bridge/evidence.mjs new file mode 100644 index 0000000..45f1fc9 --- /dev/null +++ b/scripts/live-bridge/evidence.mjs @@ -0,0 +1,54 @@ +import { mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' + +import { evidenceValue, redactString } from './redaction.mjs' + +export function errorEvidence(error) { + if (error?.code !== undefined) + return evidenceValue({ + code: error.code, + message: error.message, + exitCode: error.exitCode, + details: error.details, + }) + return evidenceValue({ + code: 'UNEXPECTED_ERROR', + message: error instanceof Error ? error.message : String(error), + }) +} + +export async function removeTemp(paths) { + const results = await Promise.all( + paths.filter(Boolean).map(async (path) => { + try { + await rm(path, { recursive: true, force: true }) + return { path, removed: true } + } catch (error) { + return { + path, + removed: false, + error: safeErrorMessage(error), + } + } + }), + ) + return { + ok: results.every((result) => result.removed), + results, + } +} + +export async function writeEvidence(evidence) { + const destination = + process.env.BRAID_LIVE_BRIDGE_EVIDENCE ?? join(tmpdir(), `braid-live-bridge-${Date.now()}.json`) + await mkdir(dirname(destination), { recursive: true, mode: 0o700 }) + await writeFile(destination, `${JSON.stringify(evidenceValue(evidence), null, 2)}\n`, { + mode: 0o600, + }) + return destination +} + +export function safeErrorMessage(error) { + return redactString(error instanceof Error ? error.message : String(error)) +} diff --git a/scripts/live-bridge/main.mjs b/scripts/live-bridge/main.mjs new file mode 100644 index 0000000..e44fbcb --- /dev/null +++ b/scripts/live-bridge/main.mjs @@ -0,0 +1,265 @@ +import { mkdtemp } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { discoverBridge } from './bridge.mjs' +import { + installBridgeCredential, + loadProviderCapabilities, + probePackedAnalysisReadiness, +} from './config.mjs' +import { defaultTimeoutMs, exitCodes, liveProofScope, repository } from './constants.mjs' +import { + bridgeAuthToken, + endpointForEvidence, + isLoopback, + safeEndpoint, + unknownEndpointForEvidence, +} from './endpoint.mjs' +import { LiveBridgeError } from './errors.mjs' +import { errorEvidence, removeTemp, safeErrorMessage, writeEvidence } from './evidence.mjs' +import { buildPackedBinary } from './pack.mjs' +import { evidenceValue } from './redaction.mjs' +import { executeTarget } from './target-flow.mjs' +import { defaultTargetPolicy, readTargetPolicy, targetPolicyEvidence } from './target-policy.mjs' + +function createEvidence(policy) { + const rawEndpoint = + process.env.BRAID_CLI_BRIDGE_URL ?? + process.env.CLI_BRIDGE_URL ?? + `http://127.0.0.1:${process.env.BRIDGE_PORT ?? '3344'}` + return { + schemaVersion: 1, + command: 'pnpm test:live:bridge', + scope: liveProofScope, + optIn: process.env.BRAID_LIVE_BRIDGE === '1', + startedAt: new Date().toISOString(), + runtime: { + node: process.version, + platform: process.platform, + arch: process.arch, + cwd: repository, + }, + environment: { + endpoint: unknownEndpointForEvidence(rawEndpoint), + bearerConfigured: bridgeAuthToken() !== undefined, + launchRequested: process.env.BRAID_CLI_BRIDGE_START === '1', + }, + targetPolicy: targetPolicyEvidence(policy), + targets: [], + } +} + +function targetFailure(error, target) { + const unavailable = error instanceof LiveBridgeError && error.exitCode === exitCodes.unavailable + return { + label: target.definition.label, + target: target.modelId, + status: unavailable ? 'unavailable' : 'failed', + error: errorEvidence(error), + } +} + +function markCleanupFailure(evidence, label, details, setStatus) { + const failure = errorEvidence( + new LiveBridgeError( + 'CLEANUP_FAILED', + `${label} did not provide complete bounded cleanup proof`, + exitCodes.failed, + details, + ), + ) + evidence.cleanupError = { label, ...failure } + if (evidence.error === undefined) evidence.error = failure + setStatus() +} + +export async function main() { + let policy = defaultTargetPolicy + let policyError + try { + policy = readTargetPolicy() + } catch (error) { + policyError = error + } + const evidence = createEvidence(policy) + let status = 'failed' + let exitCode = exitCodes.failed + let evidencePath + let bridgeCleanup + let credential + const tempPaths = [] + try { + if (policyError !== undefined) throw policyError + if (!evidence.optIn) { + throw new LiveBridgeError( + 'OPT_IN_REQUIRED', + 'Live CLI Bridge smoke is opt-in; set BRAID_LIVE_BRIDGE=1 to execute it', + exitCodes.unavailable, + ) + } + const rawEndpoint = + process.env.BRAID_CLI_BRIDGE_URL ?? + process.env.CLI_BRIDGE_URL ?? + `http://127.0.0.1:${process.env.BRIDGE_PORT ?? '3344'}` + const endpoint = safeEndpoint(rawEndpoint) + evidence.environment.endpoint = endpointForEvidence(endpoint) + const token = bridgeAuthToken() + if (!isLoopback(endpoint) && token === undefined) { + throw new LiveBridgeError( + 'BRIDGE_CREDENTIAL_REQUIRED', + 'A non-loopback CLI Bridge endpoint requires BRAID_CLI_BRIDGE_BEARER for the secure packed configuration', + exitCodes.unavailable, + ) + } + const bridgeEvidence = {} + evidence.bridge = bridgeEvidence + const bridge = await discoverBridge( + endpoint, + token, + bridgeEvidence, + repository, + policy.definitions, + ) + bridgeCleanup = bridge.cleanup + const selected = bridge.selected + evidence.selectedTargets = bridgeEvidence.selectedTargets + const packed = await buildPackedBinary(evidence, repository, (path) => tempPaths.push(path)) + const providerCapabilities = await loadProviderCapabilities(packed.installRoot) + evidence.provider = { + package: '@tangle-network/agent-provider-cli-bridge', + capabilities: evidenceValue(providerCapabilities), + } + evidence.analysis = await probePackedAnalysisReadiness( + packed.installRoot, + endpoint, + selected[0].modelId, + ) + credential = await installBridgeCredential(evidence, repository) + const targetRoot = await mkdtemp(join(tmpdir(), 'braid-live-targets-')) + tempPaths.push(targetRoot) + let firstTargetFailure + for (const target of selected) { + let targetEvidence + try { + targetEvidence = await executeTarget( + packed.binary, + targetRoot, + endpoint, + providerCapabilities, + target, + credential, + Number(process.env.BRAID_LIVE_BRIDGE_TIMEOUT_MS ?? defaultTimeoutMs), + ) + } catch (error) { + targetEvidence = targetFailure(error, target) + } + evidence.targets.push(targetEvidence) + if (targetEvidence.status !== 'passed' && firstTargetFailure === undefined) + firstTargetFailure = targetEvidence + } + if (firstTargetFailure !== undefined) { + throw new LiveBridgeError( + firstTargetFailure.status === 'unavailable' + ? 'LIVE_TARGET_UNAVAILABLE' + : 'LIVE_TARGET_FAILED', + `At least one packed live target was ${firstTargetFailure.status}`, + firstTargetFailure.status === 'unavailable' ? exitCodes.unavailable : exitCodes.failed, + { target: firstTargetFailure }, + ) + } + status = 'passed' + exitCode = exitCodes.passed + } catch (error) { + status = + error instanceof LiveBridgeError && error.exitCode === exitCodes.unavailable + ? 'unavailable' + : 'failed' + exitCode = error instanceof LiveBridgeError ? error.exitCode : exitCodes.failed + evidence.error = errorEvidence(error) + } finally { + if (credential !== undefined) { + try { + await credential.store.remove(credential.credentialRef) + evidence.credentialState = { ...(evidence.credentialState ?? {}), removed: true } + } catch (error) { + evidence.credentialState = { + ...(evidence.credentialState ?? {}), + removed: false, + error: errorEvidence(error), + } + if (status === 'passed') { + status = 'failed' + exitCode = exitCodes.failed + } + } + } + if (bridgeCleanup !== undefined) { + try { + const cleanup = await bridgeCleanup() + evidence.bridgeCleanup = { stopped: evidenceValue(cleanup) } + if ( + cleanup?.termination?.exited !== true || + cleanup.termination.descendantsExited !== true || + cleanup.termination.descendantsVerified !== true + ) { + markCleanupFailure(evidence, 'CLI Bridge process', { cleanup }, () => { + status = 'failed' + exitCode = exitCodes.failed + }) + } + } catch (error) { + evidence.bridgeCleanup = { stopped: false, error: errorEvidence(error) } + markCleanupFailure(evidence, 'CLI Bridge process', { error }, () => { + status = 'failed' + exitCode = exitCodes.failed + }) + } + } + try { + const cleanup = await removeTemp(tempPaths) + evidence.cleanup = evidenceValue(cleanup) + if (cleanup.ok !== true) { + markCleanupFailure(evidence, 'temporary directories', { cleanup }, () => { + status = 'failed' + exitCode = exitCodes.failed + }) + } + } catch (error) { + evidence.cleanup = { ok: false, error: errorEvidence(error) } + markCleanupFailure(evidence, 'temporary directories', { error }, () => { + status = 'failed' + exitCode = exitCodes.failed + }) + } + evidence.status = status + evidence.exitCode = exitCode + evidence.finishedAt = new Date().toISOString() + if (evidence.optIn || process.env.BRAID_LIVE_BRIDGE_EVIDENCE !== undefined) { + try { + evidencePath = await writeEvidence(evidence) + } catch (error) { + evidencePath = undefined + process.stderr.write(`could not write live bridge evidence: ${safeErrorMessage(error)}\n`) + status = 'failed' + exitCode = exitCodes.failed + } + } + } + const summary = { + status, + exitCode, + scope: evidence.scope.name, + selectedTargets: evidence.selectedTargets ?? [], + evidence: evidencePath, + error: + evidence.error === undefined + ? undefined + : { + code: evidence.error.code, + message: evidence.error.message, + exitCode: evidence.error.exitCode, + }, + } + process.stdout.write(`${JSON.stringify(evidenceValue(summary))}\n`) + process.exitCode = exitCode +} diff --git a/scripts/live-bridge/matrix-adversarial.mjs b/scripts/live-bridge/matrix-adversarial.mjs new file mode 100644 index 0000000..3bfc9b6 --- /dev/null +++ b/scripts/live-bridge/matrix-adversarial.mjs @@ -0,0 +1,193 @@ +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 { runCommand } from './command.mjs' +import { appendBounded, RpcSession, sleep } from './process.mjs' +import { evidenceValue, redactString } from './redaction.mjs' + +async function runRedactionMatrix() { + const value = evidenceValue({ + output: 'safe output with no credential material', + tokenCount: 17, + tokenizer: 'keep', + secretSauce: 'keep', + apiKeyId: 'keep', + credentialConfigured: true, + credentialRef: 'cred:v1:opaque', + }) + assert.equal(value.tokenCount, 17) + assert.equal(value.tokenizer, 'keep') + assert.equal(value.secretSauce, 'keep') + assert.equal(value.apiKeyId, 'keep') + assert.equal(value.credentialConfigured, true) + assert.equal(value.credentialRef, 'cred:v1:opaque') +} + +async function runProcessMatrix() { + const root = await mkdtemp(join(tmpdir(), 'braid-live-process-proof-')) + const pidPath = join(root, 'descendant.pid') + const script = [ + "import { spawn } from 'node:child_process'", + "import { writeFileSync } from 'node:fs'", + "const child = spawn(process.execPath, ['-e', \"process.on('SIGTERM', () => {}); setInterval(() => {}, 1000)\"], { stdio: 'ignore' })", + 'writeFileSync(process.env.PID_PATH, String(child.pid))', + "process.on('SIGTERM', () => {})", + 'setInterval(() => {}, 1000)', + ].join(';') + let descendantPid + try { + const result = await runCommand(process.execPath, ['-e', script], { + cwd: root, + timeoutMs: 250, + env: { PID_PATH: pidPath }, + }) + descendantPid = Number(await readFile(pidPath, 'utf8')) + assert.equal(result.timedOut, true) + assert.equal(result.termination.forcedKill, true) + assert.equal( + result.termination.strategy, + process.platform === 'win32' ? 'windows-taskkill-tree' : 'process-group', + ) + if (process.platform === 'win32') { + assert.equal(result.cleanupOk, false) + assert.equal(result.termination.cleanupStatus, 'unsupported') + } else { + assert.equal(result.cleanupOk, true) + let alive = true + for (let attempt = 0; attempt < 30; attempt += 1) { + try { + process.kill(descendantPid, 0) + } catch { + alive = false + break + } + await sleep(50) + } + assert.equal(alive, false) + } + + const naturalPidPath = join(root, 'natural-descendant.pid') + const naturalScript = [ + "import { spawn } from 'node:child_process'", + "import { writeFileSync } from 'node:fs'", + "const child = spawn(process.execPath, ['-e', \"process.on('SIGTERM', () => {}); setInterval(() => {}, 1000)\"], { stdio: 'ignore' })", + 'writeFileSync(process.env.PID_PATH, String(child.pid))', + 'setTimeout(() => process.exit(0), 20)', + ].join(';') + const natural = await runCommand(process.execPath, ['-e', naturalScript], { + cwd: root, + timeoutMs: 5_000, + env: { PID_PATH: naturalPidPath }, + }) + const naturalDescendantPid = Number(await readFile(naturalPidPath, 'utf8')) + assert.equal(natural.code, 0) + if (process.platform === 'win32') { + assert.equal(natural.cleanupOk, false) + assert.equal(natural.termination.cleanupStatus, 'unsupported') + } else { + assert.equal(natural.cleanupOk, true) + assert.equal(natural.termination.termSent || natural.termination.killSent, true) + let alive = true + for (let attempt = 0; attempt < 30; attempt += 1) { + try { + process.kill(naturalDescendantPid, 0) + } catch { + alive = false + break + } + await sleep(50) + } + assert.equal(alive, false) + } + + const rpcScript = join(root, 'rpc-natural.mjs') + const rpcPidPath = join(root, 'rpc-descendant.pid') + const rpcSource = [ + "import { spawn } from 'node:child_process'", + "import { writeFileSync } from 'node:fs'", + 'process.stdin.resume()', + "process.stdin.on('end', () => { const child = spawn(process.execPath, ['-e', \"process.on('SIGTERM', () => {}); setInterval(() => {}, 1000)\"], { stdio: 'ignore' }); writeFileSync(process.env.PID_PATH, String(child.pid)); setTimeout(() => process.exit(0), 20) })", + ].join('\n') + await writeFile(rpcScript, rpcSource) + const rpc = new RpcSession( + rpcScript, + root, + { + ...process.env, + PID_PATH: rpcPidPath, + }, + 1_000, + ) + const rpcResult = await rpc.close() + assert.equal(rpcResult.termination.exited, true) + if (process.platform === 'win32') { + assert.equal(rpcResult.termination.cleanupStatus, 'unsupported') + } else { + assert.equal(rpcResult.termination.cleanupStatus, 'kill') + assert.equal(rpcResult.termination.termSent, true) + assert.equal(rpcResult.termination.descendantsExited, true) + assert.equal(rpcResult.termination.descendantsVerified, true) + } + + const canary = 'chunked-boundary-canary-3e6a' + const chunks = [ + `${'🙂'.repeat(600)}https://operator:`, + canary.slice(0, 12), + `${canary.slice(12)}@bridge.example/v1/chat?access_`, + `token=${canary}&safe=keep`, + ] + const chunkScript = [ + `const chunks = ${JSON.stringify(chunks)}`, + 'let index = 0', + 'const emit = () => { if (index === chunks.length) process.exit(0); process.stdout.write(chunks[index]); process.stderr.write(chunks[index++]); setTimeout(emit, 10) }', + 'emit()', + ].join(';') + const captured = await runCommand(process.execPath, ['-e', chunkScript], { + cwd: root, + maxOutputBytes: 180, + }) + const redacted = redactString(captured.stdout) + const redactedStderr = redactString(captured.stderr) + assert.equal(captured.code, 0) + assert.equal(captured.cleanupOk, true) + assert.equal(Buffer.byteLength(captured.stdout, 'utf8') <= 180, true) + assert.equal( + Buffer.byteLength(captured.stdout, 'utf8') < Buffer.byteLength(chunks.join(''), 'utf8'), + true, + ) + assert.equal(redacted.includes(canary), false) + assert.equal(redacted.includes(canary.slice(0, 10)), false) + assert.equal(redacted.includes(canary.slice(-10)), false) + assert.equal(redacted.includes('Bearer chunked'), false) + assert.equal(redacted.includes('operator:'), false) + assert.equal(redacted.includes('safe=keep'), true) + assert.equal(redactedStderr.includes(canary), false) + assert.equal(redactedStderr.includes(canary.slice(0, 10)), false) + assert.equal(redactedStderr.includes(canary.slice(-10)), false) + assert.equal(redactedStderr.includes('Bearer chunked'), false) + assert.equal(redactedStderr.includes('operator:'), false) + assert.equal(redactedStderr.includes('safe=keep'), true) + assert.equal(appendBounded('🙂🙂', '', 5).includes('\ufffd'), false) + assert.equal(Buffer.byteLength(appendBounded('🙂🙂', '', 5), 'utf8') <= 5, true) + } finally { + if (process.platform === 'win32') + for (const pid of [ + descendantPid, + Number(await readFile(join(root, 'natural-descendant.pid'), 'utf8').catch(() => '0')), + ]) { + if (!Number.isInteger(pid) || pid <= 0) continue + await runCommand('taskkill', ['/PID', String(pid), '/T', '/F'], { + cwd: root, + timeoutMs: 2_000, + }) + } + await rm(root, { force: true, recursive: true }) + } +} + +export async function runAdversarialMatrix() { + await runRedactionMatrix() + await runProcessMatrix() +} diff --git a/scripts/live-bridge/matrix.mjs b/scripts/live-bridge/matrix.mjs new file mode 100644 index 0000000..8725b8c --- /dev/null +++ b/scripts/live-bridge/matrix.mjs @@ -0,0 +1,154 @@ +import assert from 'node:assert/strict' +import { createServer } from 'node:http' + +import { discoverBridge } from './bridge.mjs' +import { runAdversarialMatrix } from './matrix-adversarial.mjs' +import { + assertSemanticOutcome, + cancelSemanticStatus, + exactMarker, + semanticCommandStatus, +} from './protocol.mjs' +import { defaultTargetPolicy, readTargetPolicy, targetPolicyEvidence } from './target-policy.mjs' + +async function withFakeBridge(models, backends, callback) { + const server = createServer((request, response) => { + if (request.method === 'GET' && request.url === '/health') { + response.writeHead(200, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ status: 'ok', backends })) + return + } + if (request.method === 'GET' && request.url === '/v1/models') { + response.writeHead(200, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ object: 'list', data: models.map((id) => ({ id })) })) + return + } + response.writeHead(404) + response.end() + }) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', resolve) + }) + const address = server.address() + if (!address || typeof address === 'string') throw new Error('fake bridge did not expose a port') + try { + return await callback(`http://127.0.0.1:${address.port}`) + } finally { + await new Promise((resolve) => server.close(resolve)) + } +} + +async function runTargetPolicyMatrix() { + const both = defaultTargetPolicy.definitions + const exactGlm = both[0].modelId + const exactLuna = 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) => { + const evidence = {} + const result = await discoverBridge(endpoint, undefined, evidence, process.cwd(), both) + assert.deepEqual( + result.selected.map(({ modelId }) => modelId), + [exactGlm, exactLuna], + ) + }) + await withFakeBridge([exactGlm], readyBackends, async (endpoint) => { + const evidence = {} + await assert.rejects( + discoverBridge(endpoint, undefined, evidence, process.cwd(), both), + (error) => error.code === 'TARGET_MODEL_NOT_ADVERTISED' && error.exitCode === 2, + ) + assert.deepEqual( + evidence.missingTargets.map(({ modelId }) => modelId), + [exactLuna], + ) + }) + await withFakeBridge([exactLuna], readyBackends, async (endpoint) => { + const evidence = {} + await assert.rejects( + discoverBridge(endpoint, undefined, evidence, process.cwd(), both), + (error) => error.code === 'TARGET_MODEL_NOT_ADVERTISED' && error.exitCode === 2, + ) + assert.deepEqual( + evidence.missingTargets.map(({ modelId }) => modelId), + [exactGlm], + ) + }) + await withFakeBridge( + [exactGlm, exactLuna], + [{ name: 'opencode', state: 'ready' }], + async (endpoint) => { + const evidence = {} + await assert.rejects( + discoverBridge(endpoint, undefined, evidence, process.cwd(), both), + (error) => error.code === 'TARGET_BACKEND_NOT_READY' && error.exitCode === 2, + ) + }, + ) + const pilot = readTargetPolicy('glm-5.2') + assert.deepEqual(targetPolicyEvidence(pilot).required, [ + { key: 'glm-5.2', label: 'GLM 5.2', modelId: exactGlm, backend: 'opencode' }, + ]) + await withFakeBridge([exactGlm], readyBackends, async (endpoint) => { + const evidence = {} + const result = await discoverBridge( + endpoint, + undefined, + evidence, + process.cwd(), + pilot.definitions, + ) + assert.deepEqual( + result.selected.map(({ modelId }) => modelId), + [exactGlm], + ) + }) +} + +async function runSemanticMatrix() { + for (const name of ['reconnect', 'cancel', 'interaction']) { + assert.doesNotThrow(() => assertSemanticOutcome(name, 'verified', true)) + assert.doesNotThrow(() => assertSemanticOutcome(name, 'reported-unavailable', false)) + for (const [status, advertised] of [ + ['unexpected', false], + ['advertised-but-rejected', true], + ['advertised-but-not-terminal-cancelled', true], + ]) { + assert.throws( + () => assertSemanticOutcome(name, status, advertised), + (error) => error.code === 'LIVE_CAPABILITY_CONTRADICTION' && error.exitCode === 1, + ) + } + } + assert.equal(semanticCommandStatus({ type: 'ack' }, true), 'verified') + assert.equal(semanticCommandStatus({ type: 'error' }, true), 'advertised-but-rejected') + assert.equal( + semanticCommandStatus({ type: 'error', code: 'CAPABILITY_UNAVAILABLE' }, false), + 'reported-unavailable', + ) + assert.equal(semanticCommandStatus({ type: 'ack' }, false), 'unexpected') + assert.equal(cancelSemanticStatus({ type: 'ack' }, { status: 'aborted' }, true), 'verified') + assert.equal( + cancelSemanticStatus({ type: 'error' }, { status: 'aborted' }, true), + 'advertised-but-rejected', + ) + assert.equal( + cancelSemanticStatus({ type: 'ack' }, { status: 'running' }, true), + 'advertised-but-not-terminal-cancelled', + ) + assert.equal( + cancelSemanticStatus({ type: 'error', code: 'CAPABILITY_UNAVAILABLE' }, undefined, false), + 'reported-unavailable', + ) + assert.equal(exactMarker(' LIVE_BRAID_GLM_5_2_OK\n', 'LIVE_BRAID_GLM_5_2_OK'), true) + assert.equal(exactMarker('LIVE_BRAID_GLM_5_2_OK extra', 'LIVE_BRAID_GLM_5_2_OK'), false) +} + +await runTargetPolicyMatrix() +await runSemanticMatrix() +await runAdversarialMatrix() +process.stdout.write('Live Bridge adversarial matrix passed\n') diff --git a/scripts/live-bridge/pack.mjs b/scripts/live-bridge/pack.mjs new file mode 100644 index 0000000..59f3fd0 --- /dev/null +++ b/scripts/live-bridge/pack.mjs @@ -0,0 +1,91 @@ +import { createHash } from 'node:crypto' +import { mkdtemp, readdir, readFile, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, relative } from 'node:path' + +import { runCommand } from './command.mjs' +import { exitCodes } from './constants.mjs' +import { LiveBridgeError } from './errors.mjs' +import { nativeInstallEnvironment } from '../native-install-environment.mjs' + +export async function buildPackedBinary(evidence, repository, registerTemp) { + const packRoot = await mkdtemp(join(tmpdir(), 'braid-live-pack-')) + registerTemp(packRoot) + const installRoot = await mkdtemp(join(tmpdir(), 'braid-live-install-')) + registerTemp(installRoot) + evidence.temp = { packRoot, installRoot } + const build = await runCommand('pnpm', ['run', 'build'], { cwd: repository }) + evidence.build = build + if (build.code !== 0 || build.cleanupOk !== true) { + throw new LiveBridgeError( + 'PACK_BUILD_FAILED', + 'Braid build failed before the live packed run', + exitCodes.failed, + { build }, + ) + } + const pack = await runCommand('pnpm', ['pack', '--pack-destination', packRoot], { + cwd: repository, + }) + evidence.packCommand = pack + if (pack.code !== 0 || pack.cleanupOk !== true) { + throw new LiveBridgeError( + 'PACK_FAILED', + 'Braid packaging failed before the live packed run', + exitCodes.failed, + { pack }, + ) + } + const tarballName = (await readdir(packRoot)).find((name) => name.endsWith('.tgz')) + if (!tarballName) + throw new LiveBridgeError('PACK_MISSING', 'pnpm pack produced no tarball', exitCodes.failed) + const tarball = join(packRoot, tarballName) + await writeFile( + join(installRoot, 'package.json'), + '{"name":"braid-live-install","private":true}\n', + { mode: 0o600 }, + ) + const installArgs = [ + 'install', + ...(process.env.BRAID_LIVE_BRIDGE_OFFLINE === '1' ? ['--offline'] : []), + '--no-audit', + '--no-fund', + tarball, + ] + const install = await runCommand('npm', installArgs, { + cwd: installRoot, + env: nativeInstallEnvironment(), + }) + evidence.install = install + if (install.code !== 0 || install.cleanupOk !== true) { + throw new LiveBridgeError( + 'PACK_INSTALL_FAILED', + 'The packed Braid artifact could not be installed in isolation', + exitCodes.failed, + { install }, + ) + } + const binary = join( + installRoot, + 'node_modules', + '.bin', + process.platform === 'win32' ? 'braid.cmd' : 'braid', + ) + const version = await runCommand(process.execPath, [binary, '--version'], { cwd: installRoot }) + evidence.version = version + if (version.code !== 0 || version.cleanupOk !== true) + throw new LiveBridgeError( + 'PACK_BINARY_FAILED', + 'The packed Braid binary did not start', + exitCodes.failed, + { version }, + ) + evidence.artifact = { + tarball: tarballName, + sha256: createHash('sha256') + .update(await readFile(tarball)) + .digest('hex'), + binary: relative(installRoot, binary), + } + return { packRoot, installRoot, binary } +} diff --git a/scripts/live-bridge/process-tree.mjs b/scripts/live-bridge/process-tree.mjs new file mode 100644 index 0000000..d389127 --- /dev/null +++ b/scripts/live-bridge/process-tree.mjs @@ -0,0 +1,113 @@ +import { spawn } from 'node:child_process' + +const pollMs = 25 +const taskkillTimeoutMs = 2_000 + +function childHasExited(child) { + return child.exitCode !== null || child.signalCode !== null +} + +function processGroupPresent(pid) { + if (!Number.isInteger(pid) || pid <= 0) return undefined + try { + process.kill(-pid, 0) + return true + } catch (error) { + if (error?.code === 'ESRCH') return false + if (error?.code === 'EPERM') return true + return undefined + } +} + +export function processTreeStatus(pid) { + if (process.platform === 'win32') + return { + supported: false, + gone: false, + reason: 'Windows process-tree verification is not available without tasklist ancestry data', + } + const present = processGroupPresent(pid) + if (present === undefined) + return { + supported: false, + gone: false, + reason: 'The POSIX process group could not be inspected', + } + return { supported: true, gone: !present, present } +} + +export async function waitForTreeGone(pid, timeoutMs) { + const deadline = Date.now() + timeoutMs + while (true) { + const status = processTreeStatus(pid) + if (!status.supported || status.gone || Date.now() >= deadline) return status + await new Promise((resolve) => setTimeout(resolve, Math.min(pollMs, deadline - Date.now()))) + } +} + +function waitForTaskkill(child, timeoutMs) { + return new Promise((resolve) => { + let settled = false + const timer = setTimeout(() => { + if (settled) return + settled = true + try { + child.kill('SIGKILL') + } catch {} + resolve({ sent: false, timedOut: true }) + }, timeoutMs) + child.once('error', (error) => { + if (settled) return + settled = true + clearTimeout(timer) + resolve({ sent: false, error: error instanceof Error ? error.message : String(error) }) + }) + child.once('close', (code) => { + if (settled) return + settled = true + clearTimeout(timer) + resolve({ sent: code === 0, code }) + }) + }) +} + +async function sendWindowsTreeSignal(pid, force) { + if (!Number.isInteger(pid) || pid <= 0) + return { method: 'windows-taskkill-unavailable', sent: false } + const killer = spawn('taskkill', ['/PID', String(pid), '/T', ...(force ? ['/F'] : [])], { + stdio: 'ignore', + windowsHide: true, + }) + const result = await waitForTaskkill(killer, taskkillTimeoutMs) + return { + method: 'windows-taskkill-tree', + sent: result.sent, + timedOut: result.timedOut, + error: result.error, + } +} + +export async function sendTreeSignal(child, signal) { + if (process.platform === 'win32') return sendWindowsTreeSignal(child.pid, signal === 'SIGKILL') + if (!Number.isInteger(child.pid) || child.pid <= 0) return { method: 'unavailable', sent: false } + try { + process.kill(-child.pid, signal) + return { method: 'process-group', sent: true } + } catch (error) { + if (error?.code === 'ESRCH') + return { + method: childHasExited(child) ? 'already-exited' : 'process-group-gone', + sent: false, + } + try { + child.kill(signal) + return { method: 'child-fallback', sent: true } + } catch (fallbackError) { + return { + method: 'failed', + sent: false, + error: fallbackError instanceof Error ? fallbackError.message : String(fallbackError), + } + } + } +} diff --git a/scripts/live-bridge/process.mjs b/scripts/live-bridge/process.mjs new file mode 100644 index 0000000..3069674 --- /dev/null +++ b/scripts/live-bridge/process.mjs @@ -0,0 +1,272 @@ +import { spawn } from 'node:child_process' + +import { StreamingRedactor } from './capture.mjs' +import { exitCodes } from './constants.mjs' +import { LiveBridgeError } from './errors.mjs' +import { processTreeStatus, sendTreeSignal, waitForTreeGone } from './process-tree.mjs' + +const defaultNaturalExitTimeoutMs = 2_000 +const defaultTermTimeoutMs = 2_000 +const defaultKillTimeoutMs = 2_000 + +export { appendBounded } from './capture.mjs' + +export function sleep(ms) { + return new Promise((resolveSleep) => setTimeout(resolveSleep, ms)) +} + +export function managedSpawn(command, args, options) { + return spawn(command, args, { + ...options, + detached: process.platform !== 'win32', + }) +} + +function hasExited(child) { + return child.exitCode !== null || child.signalCode !== null +} + +function waitForExit(child, timeoutMs) { + if (hasExited(child)) return Promise.resolve(true) + return new Promise((resolveExit) => { + let settled = false + const finish = (exited) => { + if (settled) return + settled = true + clearTimeout(timer) + child.off('close', onClose) + resolveExit(exited) + } + const onClose = () => finish(true) + const timer = setTimeout(() => finish(false), timeoutMs) + child.once('close', onClose) + }) +} + +function terminationResult(child, values) { + const tree = values.tree ?? processTreeStatus(child.pid) + return { + strategy: values.strategy, + termTimeoutMs: values.termTimeoutMs, + killTimeoutMs: values.killTimeoutMs, + termSent: values.termSent, + killSent: values.killSent, + forcedKill: values.killSent, + exited: values.exited, + descendantsExited: tree.supported && tree.gone, + descendantsVerified: tree.supported && tree.gone, + cleanupStatus: values.cleanupStatus, + termSignal: values.termSignal, + killSignal: values.killSignal, + tree, + } +} + +export async function observeNaturalExit( + child, + { naturalExitTimeoutMs = defaultNaturalExitTimeoutMs, treeTimeoutMs = defaultTermTimeoutMs } = {}, +) { + const exited = await waitForExit(child, naturalExitTimeoutMs) + if (!exited) + return terminationResult(child, { + strategy: 'grace-period', + termTimeoutMs: 0, + killTimeoutMs: 0, + termSent: false, + killSent: false, + exited: false, + cleanupStatus: 'still-running', + }) + const tree = await waitForTreeGone(child.pid, treeTimeoutMs) + return terminationResult(child, { + strategy: tree.supported ? 'natural-exit' : 'unsupported', + termTimeoutMs: naturalExitTimeoutMs, + killTimeoutMs: treeTimeoutMs, + termSent: false, + killSent: false, + exited: true, + cleanupStatus: tree.supported && tree.gone ? 'natural-exit' : 'unsupported', + tree, + }) +} + +export async function terminateProcess( + child, + { termTimeoutMs = defaultTermTimeoutMs, killTimeoutMs = defaultKillTimeoutMs } = {}, +) { + const initialTree = processTreeStatus(child.pid) + const initialExited = hasExited(child) + let termSignal + let killSignal + let termSent = false + let killSent = false + let exited = initialExited + let tree = initialTree + if (!tree.supported || !tree.gone) { + termSignal = await sendTreeSignal(child, 'SIGTERM') + termSent = termSignal.sent + exited = exited || (await waitForExit(child, termTimeoutMs)) + tree = await waitForTreeGone(child.pid, termTimeoutMs) + } + if (!tree.supported || !tree.gone) { + killSignal = await sendTreeSignal(child, 'SIGKILL') + killSent = killSignal.sent + exited = exited || (await waitForExit(child, killTimeoutMs)) + tree = await waitForTreeGone(child.pid, killTimeoutMs) + } + const strategy = + termSignal?.method ?? + killSignal?.method ?? + (initialTree.supported ? 'already-exited' : 'unsupported') + const cleanupStatus = !tree.supported + ? 'unsupported' + : tree.gone && !termSent && !killSent + ? 'already-exited' + : tree.gone && !killSent + ? 'term' + : tree.gone + ? 'kill' + : 'descendants-still-running' + return terminationResult(child, { + strategy, + termTimeoutMs, + killTimeoutMs, + termSent, + killSent, + exited, + cleanupStatus, + termSignal, + killSignal, + tree, + }) +} + +async function boundedExit(exit, timeoutMs) { + return await Promise.race([exit, sleep(timeoutMs).then(() => ({ timeout: true }))]) +} + +export class RpcSession { + constructor(binary, workspace, env, timeoutMs) { + this.binary = binary + this.workspace = workspace + this.env = env + this.timeoutMs = timeoutMs + this.responses = [] + this.stdout = '' + this.stderr = '' + this.stdoutCapture = new StreamingRedactor() + this.stderrCapture = new StreamingRedactor() + this.buffer = '' + this.waiters = new Set() + this.child = managedSpawn(process.execPath, [binary, 'rpc'], { + cwd: workspace, + env, + stdio: ['pipe', 'pipe', 'pipe'], + }) + this.exit = new Promise((resolveExit, rejectExit) => { + this.child.once('error', rejectExit) + this.child.once('close', (code, signal) => { + this.closed = true + for (const waiter of this.waiters) + waiter.reject( + new LiveBridgeError( + 'RPC_PROCESS_EXITED', + `packed Braid RPC exited before ${waiter.label} completed`, + exitCodes.failed, + { code, signal }, + ), + ) + this.waiters.clear() + resolveExit({ code, signal }) + }) + }) + this.child.stdout.setEncoding('utf8') + this.child.stderr.setEncoding('utf8') + this.child.stdout.on('data', (chunk) => { + this.#onStdout(chunk) + this.stdout = this.stdoutCapture.push(chunk) + }) + this.child.stderr.on('data', (chunk) => { + this.stderr = this.stderrCapture.push(chunk) + }) + } + + send(request) { + if (this.closed || this.child.stdin.destroyed) + throw new LiveBridgeError( + 'RPC_INPUT_CLOSED', + 'packed Braid RPC input closed before the request was sent', + exitCodes.failed, + ) + this.child.stdin.write(`${JSON.stringify(request)}\n`) + } + + async waitFor(label, predicate, timeoutMs = this.timeoutMs) { + const existing = this.responses.find(predicate) + if (existing) return existing + return new Promise((resolveWait, rejectWait) => { + const waiter = { label, predicate, resolve: resolveWait, reject: rejectWait } + const timer = setTimeout(() => { + this.waiters.delete(waiter) + rejectWait( + new LiveBridgeError('RPC_TIMEOUT', `timed out waiting for ${label}`, exitCodes.failed, { + timeoutMs, + }), + ) + }, timeoutMs) + waiter.resolve = (value) => { + clearTimeout(timer) + resolveWait(value) + } + waiter.reject = (error) => { + clearTimeout(timer) + rejectWait(error) + } + this.waiters.add(waiter) + }) + } + + async close() { + if (this.closePromise !== undefined) return this.closePromise + this.closePromise = (async () => { + if (!this.child.stdin.destroyed) this.child.stdin.end() + const natural = await observeNaturalExit(this.child) + const termination = + natural.cleanupStatus === 'natural-exit' ? natural : await terminateProcess(this.child) + this.stdout = this.stdoutCapture.finish() + this.stderr = this.stderrCapture.finish() + const exit = await boundedExit( + this.exit.catch((error) => ({ + error: error instanceof Error ? error.message : String(error), + })), + defaultKillTimeoutMs, + ) + return { termination, natural, exit } + })() + return this.closePromise + } + + #onStdout(chunk) { + this.buffer += chunk + let newline = this.buffer.indexOf('\n') + while (newline >= 0) { + const line = this.buffer.slice(0, newline).replace(/\r$/u, '') + this.buffer = this.buffer.slice(newline + 1) + if (line.trim()) { + try { + const response = JSON.parse(line) + this.responses.push(response) + for (const waiter of this.waiters) { + if (!waiter.predicate(response)) continue + this.waiters.delete(waiter) + waiter.resolve(response) + break + } + } catch { + this.responses.push({ type: 'malformed', line }) + } + } + newline = this.buffer.indexOf('\n') + } + } +} diff --git a/scripts/live-bridge/protocol.mjs b/scripts/live-bridge/protocol.mjs new file mode 100644 index 0000000..9bd27d3 --- /dev/null +++ b/scripts/live-bridge/protocol.mjs @@ -0,0 +1,118 @@ +import { exitCodes } from './constants.mjs' +import { LiveBridgeError } from './errors.mjs' + +export function requestBase(requestId, command, operationId) { + return { + version: 1, + requestId, + ...(operationId === undefined ? {} : { operationId }), + command, + } +} + +export function responseForRequest(requestId) { + return (response) => + response.requestId === requestId && (response.type === 'ack' || response.type === 'error') +} + +export function stateForRequest(requestId) { + return (response) => response.requestId === requestId && response.type === 'state' +} + +export function stateForRun(response, runId) { + if (response.type !== 'state' || !response.state) return false + const run = response.state.runs?.find((candidate) => candidate.id === runId) + return ( + run !== undefined && + ['completed', 'failed', 'aborted', 'cancelled', 'unknown'].includes(run.status) + ) +} + +export function runFromState(state, runId) { + return state?.runs?.find((run) => run.id === runId) +} + +export function terminalMessage(state, runId) { + return ( + state?.messages?.findLast?.( + (message) => message.runId === runId && message.role === 'assistant', + ) ?? + [...(state?.messages ?? [])] + .reverse() + .find((message) => message.runId === runId && message.role === 'assistant') + ) +} + +export function targetCapabilities(stateResponse, terminalState) { + const view = stateResponse?.view ?? {} + const run = terminalState?.runs?.at(-1) + return { + view: view.capabilities ?? {}, + analysisAsk: view.capabilities?.['analysis.ask'], + run: run?.capabilities ?? {}, + } +} + +export function exactMarker(value, marker) { + return typeof value === 'string' && value.trim() === marker +} + +export function capabilityAdvertised(value) { + if (value === true) return true + if (value === false || value === undefined || value === null) return false + return typeof value === 'object' && value.available !== false +} + +export function assertSemanticOutcome(name, status, advertised, details = {}) { + const expected = advertised ? 'verified' : 'reported-unavailable' + if (status === expected) return + throw new LiveBridgeError( + 'LIVE_CAPABILITY_CONTRADICTION', + `${name} capability reported ${status}; expected ${expected}`, + exitCodes.failed, + { name, status, advertised, ...details }, + ) +} + +export function classifyPackedStartup(response, stderr) { + const message = `${response.message ?? ''} ${stderr}` + if ( + /CREDENTIAL_STORE_UNAVAILABLE|operating-system credential facility|credential store/iu.test( + message, + ) + ) { + return new LiveBridgeError( + 'CREDENTIAL_STORE_UNAVAILABLE', + 'The packed Braid production path cannot access its operating-system credential store', + exitCodes.unavailable, + { response, stderr }, + ) + } + return new LiveBridgeError( + 'PACKED_STARTUP_FAILED', + 'The packed Braid production path rejected initialization', + exitCodes.failed, + { response, stderr }, + ) +} + +export function semanticCommandStatus(response, advertised) { + if (advertised) return response.type === 'ack' ? 'verified' : 'advertised-but-rejected' + return response.type === 'error' && response.code === 'CAPABILITY_UNAVAILABLE' + ? 'reported-unavailable' + : 'unexpected' +} + +export function cancelSemanticStatus(response, run, advertised) { + if (advertised) { + if (response.type !== 'ack') return 'advertised-but-rejected' + return ['aborted', 'cancelled'].includes(run?.status) + ? 'verified' + : 'advertised-but-not-terminal-cancelled' + } + if (response.type === 'error' && response.code === 'CAPABILITY_UNAVAILABLE') + return 'reported-unavailable' + return ['aborted', 'cancelled'].includes(run?.status) + ? 'observed-without-advertisement' + : 'unexpected' +} diff --git a/scripts/live-bridge/redaction.mjs b/scripts/live-bridge/redaction.mjs new file mode 100644 index 0000000..8490c97 --- /dev/null +++ b/scripts/live-bridge/redaction.mjs @@ -0,0 +1,49 @@ +const secretKeys = new Set([ + 'authorization', + 'bearer', + 'token', + 'apikey', + 'accesstoken', + 'refreshtoken', + 'secret', + 'secretvalue', + 'password', + 'cookie', + 'credential', + 'credentialvalue', +]) + +function isSecretKey(key) { + const normalized = key.replaceAll(/[-_]/gu, '').toLowerCase() + return secretKeys.has(normalized) +} + +export function redactString(value) { + return value + .replace(/(https?:\/\/)([^/\s:@]+):([^/\s@]+)@/giu, '$1[redacted]@') + .replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/giu, 'Bearer [redacted]') + .replace(/\b(?:sk|pk|rk)-[A-Za-z0-9_-]{16,}\b/gu, '[redacted]') + .replace(/\bgh[pousr]_[A-Za-z0-9_]{20,}\b/gu, '[redacted]') + .replace( + /((?:token|access[-_]?token|refresh[-_]?token|api[-_]?key|secret|client[-_]?secret|password|authorization|credential)\s*[:=]\s*)[^,\s;&#]+/giu, + '$1[redacted]', + ) + .replace( + /([?&](?:token|access[-_]?token|refresh[-_]?token|api[-_]?key|secret|client[-_]?secret|password|authorization|credential)=)[^&\s#]+/giu, + '$1[redacted]', + ) +} + +export function evidenceValue(value, key = '', depth = 0) { + if (isSecretKey(key)) return '[redacted]' + if (typeof value === 'string') return redactString(value) + if (value === null || typeof value !== 'object') return value + if (depth > 8) return '[depth-limited]' + if (Array.isArray(value)) return value.map((item) => evidenceValue(item, key, depth + 1)) + return Object.fromEntries( + Object.entries(value).map(([entryKey, entryValue]) => [ + entryKey, + evidenceValue(entryValue, entryKey, depth + 1), + ]), + ) +} diff --git a/scripts/live-bridge/target-actions.mjs b/scripts/live-bridge/target-actions.mjs new file mode 100644 index 0000000..45a7c04 --- /dev/null +++ b/scripts/live-bridge/target-actions.mjs @@ -0,0 +1,298 @@ +import { exitCodes, livePrompts } from './constants.mjs' +import { LiveBridgeError } from './errors.mjs' +import { sleep } from './process.mjs' +import { + assertSemanticOutcome, + cancelSemanticStatus, + capabilityAdvertised, + exactMarker, + requestBase, + responseForRequest, + runFromState, + semanticCommandStatus, + stateForRequest, + stateForRun, + targetCapabilities, + terminalMessage, +} from './protocol.mjs' +import { evidenceValue, redactString } from './redaction.mjs' + +export async function initializeTarget(session, result, classifyStartup) { + const initialize = { + ...requestBase(`init-${result.targetKey}`, 'initialize'), + params: { workspace: result.workspace, subscribe: true }, + } + result.requests.push(evidenceValue(initialize)) + session.send(initialize) + const initializeResponse = await session.waitFor( + 'initialize acknowledgement', + responseForRequest(initialize.requestId), + ) + result.initialize = evidenceValue(initializeResponse) + if (initializeResponse.type === 'error') { + throw classifyStartup(initializeResponse, session.stderr) + } + const initialState = await session.waitFor( + 'initialize state', + (response) => response.type === 'state' && response.requestId === initialize.requestId, + 15_000, + ) + result.initialCapabilities = evidenceValue(targetCapabilities(initialState, initialState.state)) +} + +export async function runNormalTurn(session, result, target, timeoutMs) { + const normalPrompt = livePrompts.normal(target.key) + const marker = `LIVE_BRAID_${target.key.toUpperCase().replaceAll('.', '_')}_OK` + const send = { + ...requestBase(`send-${target.key}`, 'send', `op-live-send-${target.key}`), + params: { + conversationId: `conversation-${target.key}`, + branchId: `branch-${target.key}`, + text: normalPrompt, + }, + } + result.requests.push(evidenceValue(send)) + session.send(send) + const sendResponse = await session.waitFor( + 'normal send acknowledgement', + responseForRequest(send.requestId), + ) + result.send = evidenceValue(sendResponse) + if (sendResponse.type === 'error' || typeof sendResponse.runId !== 'string') { + throw new LiveBridgeError( + 'LIVE_SEND_FAILED', + `Packed Braid did not admit a live ${target.definition.label} turn`, + exitCodes.failed, + { response: sendResponse }, + ) + } + const runId = sendResponse.runId + const activeState = await session + .waitFor( + 'active run state', + (response) => + response.type === 'state' && + response.requestId === send.requestId && + response.state?.activeRunId === runId, + 15_000, + ) + .catch(() => undefined) + result.activeCapabilities = + activeState === undefined + ? undefined + : evidenceValue(targetCapabilities(activeState, activeState.state)) + const terminal = await session.waitFor( + 'normal final state', + (response) => response.requestId === send.requestId && stateForRun(response, runId), + timeoutMs, + ) + const finalRun = runFromState(terminal.state, runId) + const finalMessage = terminalMessage(terminal.state, runId) + const markerObserved = exactMarker(finalMessage?.text, marker) + result.normal = { + status: finalRun?.status, + runId, + run: evidenceValue(finalRun), + assistant: evidenceValue(finalMessage), + finalText: finalMessage?.text === undefined ? undefined : redactString(finalMessage.text), + marker, + markerObserved, + prompt: normalPrompt, + } + if ( + finalRun?.status !== 'completed' || + typeof finalMessage?.text !== 'string' || + finalMessage.text.trim() === '' || + !markerObserved + ) { + throw new LiveBridgeError( + markerObserved ? 'LIVE_FINAL_OUTPUT_MISSING' : 'LIVE_FINAL_OUTPUT_MISMATCH', + markerObserved + ? `Packed Braid ${target.definition.label} turn did not produce a completed assistant message` + : `Packed Braid ${target.definition.label} turn completed without the expected response marker`, + exitCodes.failed, + { run: finalRun, assistant: finalMessage }, + ) + } + return { finalRun, runId, terminal } +} + +export async function verifyReconnect(session, result, runId, finalRun, providerCapabilities) { + const reconnect = { + ...requestBase( + `reconnect-${result.targetKey}`, + 'reconnect', + `op-live-reconnect-${result.targetKey}`, + ), + params: { runId }, + } + result.requests.push(evidenceValue(reconnect)) + session.send(reconnect) + const reconnectResponse = await session.waitFor( + 'reconnect result', + responseForRequest(reconnect.requestId), + 30_000, + ) + const advertisedByProvider = + capabilityAdvertised(providerCapabilities.streaming?.replay) || + capabilityAdvertised(providerCapabilities.sessions?.continue) + const advertisedByRun = Boolean( + finalRun?.capabilities?.streaming?.replay && finalRun?.capabilities?.events?.cursor, + ) + result.reconnect = { + advertisedByProvider, + advertisedByRun, + advertised: advertisedByProvider || advertisedByRun, + response: evidenceValue(reconnectResponse), + status: semanticCommandStatus(reconnectResponse, advertisedByProvider || advertisedByRun), + } +} + +export async function verifyCancel(session, result, target, finalRun, providerCapabilities) { + const cancelPrompt = livePrompts.cancel(target.key) + const cancelSend = { + ...requestBase(`cancel-send-${target.key}`, 'send', `op-live-cancel-send-${target.key}`), + params: { + conversationId: `conversation-${target.key}`, + branchId: `branch-${target.key}`, + text: cancelPrompt, + }, + } + result.requests.push(evidenceValue(cancelSend)) + session.send(cancelSend) + const cancelSendResponse = await session.waitFor( + 'cancel test send acknowledgement', + responseForRequest(cancelSend.requestId), + ) + result.cancel = { prompt: cancelPrompt, send: evidenceValue(cancelSendResponse) } + if (cancelSendResponse.type !== 'ack' || typeof cancelSendResponse.runId !== 'string') { + result.cancel.status = 'not-admitted' + return + } + await sleep(25) + const cancel = { + ...requestBase(`cancel-${target.key}`, 'cancel_run', `op-live-cancel-${target.key}`), + params: { runId: cancelSendResponse.runId, reason: 'live packed smoke cancellation' }, + } + result.requests.push(evidenceValue(cancel)) + session.send(cancel) + const cancelResponse = await session.waitFor( + 'cancel acknowledgement', + responseForRequest(cancel.requestId), + 30_000, + ) + result.cancel.response = evidenceValue(cancelResponse) + const cancelStateResponse = await session + .waitFor( + 'cancel terminal state', + (response) => + response.requestId === cancel.requestId && stateForRun(response, cancelSendResponse.runId), + 30_000, + ) + .catch(() => undefined) + const cancelledRun = runFromState(cancelStateResponse?.state, cancelSendResponse.runId) + const advertisedByProvider = capabilityAdvertised(providerCapabilities.controls?.cancel) + const advertisedByRun = finalRun?.capabilities?.controls?.cancel === true + result.cancel.run = evidenceValue(cancelledRun) + result.cancel.advertisedByProvider = advertisedByProvider + result.cancel.advertisedByRun = advertisedByRun + result.cancel.advertised = advertisedByProvider || advertisedByRun + result.cancel.status = cancelSemanticStatus( + cancelResponse, + cancelledRun, + advertisedByProvider || advertisedByRun, + ) +} + +export async function verifyInteraction(session, result, providerCapabilities, terminal) { + const interaction = terminal.view?.interactions?.[0] + const interactionCapability = terminal.view?.capabilities?.['interaction.respond'] + const advertisedByProvider = capabilityAdvertised(providerCapabilities.interactions) + const advertisedByBraid = capabilityAdvertised(interactionCapability) + const advertised = advertisedByProvider || advertisedByBraid + if (interaction === undefined) { + result.interaction = { + status: advertised ? 'advertised-but-not-emitted' : 'reported-unavailable', + advertisedByProvider, + advertisedByBraid, + advertised, + provider: evidenceValue(providerCapabilities.interactions), + braid: evidenceValue(interactionCapability), + attempted: false, + } + return + } + const response = { + ...requestBase( + `interaction-${result.targetKey}`, + 'respond_interaction', + `op-live-interaction-${result.targetKey}`, + ), + params: { + runId: interaction.runId, + interactionId: interaction.interactionId, + response: { id: interaction.interactionId, outcome: 'declined' }, + }, + } + result.requests.push(evidenceValue(response)) + session.send(response) + const interactionResponse = await session.waitFor( + 'interaction response', + responseForRequest(response.requestId), + 30_000, + ) + result.interaction = { + status: semanticCommandStatus(interactionResponse, advertised), + advertisedByProvider, + advertisedByBraid, + advertised, + provider: evidenceValue(providerCapabilities.interactions), + braid: evidenceValue(interactionCapability), + attempted: true, + response: evidenceValue(interactionResponse), + } +} + +export function assertTargetSemantics(result) { + for (const [name, capability] of [ + ['reconnect', result.reconnect], + ['cancel', result.cancel], + ['interaction', result.interaction], + ]) { + assertSemanticOutcome(name, capability.status, capability.advertised, { capability }) + } + result.semanticAssertions = { + reconnect: result.reconnect.status, + cancel: result.cancel.status, + interaction: result.interaction.status, + } +} + +export async function finishTarget(session, result) { + const finalStateRequest = { + ...requestBase(`final-state-${result.targetKey}`, 'get_state'), + params: { projection: 'full' }, + } + result.requests.push(evidenceValue(finalStateRequest)) + session.send(finalStateRequest) + result.finalState = evidenceValue( + await session.waitFor('final state', stateForRequest(finalStateRequest.requestId), 15_000), + ) + const shutdown = { + ...requestBase( + `shutdown-${result.targetKey}`, + 'shutdown', + `op-live-shutdown-${result.targetKey}`, + ), + params: { mode: 'wait' }, + } + result.requests.push(evidenceValue(shutdown)) + session.send(shutdown) + result.shutdown = evidenceValue( + await session.waitFor( + 'shutdown acknowledgement', + responseForRequest(shutdown.requestId), + 15_000, + ), + ) +} diff --git a/scripts/live-bridge/target-flow.mjs b/scripts/live-bridge/target-flow.mjs new file mode 100644 index 0000000..2ffcc1f --- /dev/null +++ b/scripts/live-bridge/target-flow.mjs @@ -0,0 +1,119 @@ +import { join, relative } from 'node:path' +import { writeTargetConfig } from './config.mjs' +import { exitCodes } from './constants.mjs' +import { endpointForEvidence } from './endpoint.mjs' +import { LiveBridgeError } from './errors.mjs' +import { errorEvidence } from './evidence.mjs' +import { RpcSession } from './process.mjs' +import { classifyPackedStartup } from './protocol.mjs' +import { evidenceValue, redactString } from './redaction.mjs' +import { + assertTargetSemantics, + finishTarget, + initializeTarget, + runNormalTurn, + verifyCancel, + verifyInteraction, + verifyReconnect, +} from './target-actions.mjs' + +export async function executeTarget( + binary, + root, + endpoint, + providerCapabilities, + target, + credential, + timeoutMs, +) { + const config = await writeTargetConfig(root, endpoint, target.modelId, credential) + const statePath = join(config.workspace, 'braid.sqlite') + const env = { + ...process.env, + NO_COLOR: '1', + NODE_NO_WARNINGS: '1', + BRAID_STATE_PATH: statePath, + XDG_DATA_HOME: join(config.workspace, '.xdg-data'), + XDG_CONFIG_HOME: join(config.workspace, '.xdg-config'), + } + const session = new RpcSession(binary, config.workspace, env, timeoutMs) + const result = { + targetKey: target.key, + workspace: config.workspace, + label: target.definition.label, + target: target.modelId, + profile: { + path: relative(config.workspace, config.profilePath), + harness: config.profile.harness, + model: config.profile.model.default, + }, + connection: { + id: config.connection.id, + kind: config.connection.kind, + endpoint: endpointForEvidence(config.connection.endpoint), + credentialConfigured: credential !== undefined, + }, + providerCapabilities: evidenceValue(providerCapabilities), + requests: [], + } + try { + await initializeTarget(session, result, classifyPackedStartup) + const { finalRun, runId, terminal } = await runNormalTurn(session, result, target, timeoutMs) + await verifyReconnect(session, result, runId, finalRun, providerCapabilities) + await verifyCancel(session, result, target, finalRun, providerCapabilities) + await verifyInteraction(session, result, providerCapabilities, terminal) + assertTargetSemantics(result) + await finishTarget(session, result) + result.status = 'passed' + } catch (error) { + result.stderr = redactString(session.stderr) + result.stdout = redactString(session.stdout) + const normalizedError = + error instanceof LiveBridgeError && + error.code === 'RPC_PROCESS_EXITED' && + /CREDENTIAL_STORE_UNAVAILABLE|operating-system credential facility|credential store/iu.test( + session.stderr, + ) + ? new LiveBridgeError( + 'CREDENTIAL_STORE_UNAVAILABLE', + 'The packed Braid production path cannot access its operating-system credential store', + exitCodes.unavailable, + { error, stderr: session.stderr }, + ) + : error + result.error = errorEvidence(normalizedError) + result.status = + normalizedError instanceof LiveBridgeError && + normalizedError.exitCode === exitCodes.unavailable + ? 'unavailable' + : 'failed' + } finally { + result.stderr = redactString(session.stderr) + result.stdout = redactString(session.stdout) + const processResult = await session.close().catch((error) => ({ + error: error instanceof Error ? error.message : String(error), + })) + result.process = evidenceValue(processResult) + const cleanShutdown = + processResult.termination?.cleanupStatus === 'natural-exit' && + processResult.termination.termSent === false && + processResult.termination.killSent === false && + processResult.termination.exited === true && + processResult.termination.descendantsExited === true && + processResult.termination.descendantsVerified === true + if (result.status === 'passed' && (!cleanShutdown || processResult.error !== undefined)) { + result.status = 'failed' + result.error = errorEvidence( + new LiveBridgeError( + 'RPC_SHUTDOWN_TIMEOUT', + 'Packed Braid RPC did not exit cleanly after shutdown', + exitCodes.failed, + { process: processResult }, + ), + ) + } + delete result.workspace + delete result.targetKey + } + return evidenceValue(result) +} diff --git a/scripts/live-bridge/target-policy.mjs b/scripts/live-bridge/target-policy.mjs new file mode 100644 index 0000000..8d51fd1 --- /dev/null +++ b/scripts/live-bridge/target-policy.mjs @@ -0,0 +1,46 @@ +import { exitCodes, targetDefinitions } from './constants.mjs' +import { LiveBridgeError } from './errors.mjs' + +export const defaultTargetPolicy = Object.freeze({ + source: 'default', + definitions: targetDefinitions, +}) + +export function readTargetPolicy(raw = process.env.BRAID_LIVE_BRIDGE_TARGETS) { + if (raw === undefined || raw.trim() === '') return defaultTargetPolicy + const keys = raw + .split(',') + .map((key) => key.trim()) + .filter(Boolean) + const definitionsByKey = new Map( + targetDefinitions.map((definition) => [definition.key, definition]), + ) + const unknown = keys.filter((key) => !definitionsByKey.has(key)) + const duplicates = keys.filter((key, index) => keys.indexOf(key) !== index) + if (keys.length === 0 || unknown.length > 0 || duplicates.length > 0) { + throw new LiveBridgeError( + 'TARGET_POLICY_INVALID', + 'BRAID_LIVE_BRIDGE_TARGETS must list each supported target key once', + exitCodes.unavailable, + { requested: keys, supported: targetDefinitions.map(({ key }) => key), unknown, duplicates }, + ) + } + return { + source: 'environment', + requested: keys, + definitions: keys.map((key) => definitionsByKey.get(key)), + } +} + +export function targetPolicyEvidence(policy) { + return { + source: policy.source, + ...(policy.requested === undefined ? {} : { requested: policy.requested }), + required: policy.definitions.map(({ key, label, modelId, backend }) => ({ + key, + label, + modelId, + backend, + })), + } +} diff --git a/scripts/live-core/discovery.mjs b/scripts/live-core/discovery.mjs new file mode 100644 index 0000000..47904a2 --- /dev/null +++ b/scripts/live-core/discovery.mjs @@ -0,0 +1,144 @@ +import assert from 'node:assert/strict' + +const DEFAULT_ENDPOINT = 'http://127.0.0.1:3344' +const PI_PREFERENCES = [ + 'pi/tangle-router/glm-5.2', + 'pi/openai-codex/gpt-5.6-luna', + 'pi/openai-codex/gpt-5.6-sol', + 'pi/openai-codex/gpt-5.5', +] +const CODEX_PREFERENCES = ['codex/default', 'codex/gpt-5.6', 'codex/gpt-5.5'] +const KNOWN_UNAVAILABLE_MODELS = new Map([ + [ + 'pi/openai-codex/gpt-5.6-luna', + { + code: 'MODEL_NOT_CONFIGURED', + detail: + 'Prior live probe returned HTTP 501 in 4.51s because the local Pi OpenAI refresh token was reused; the model is retained as unavailable evidence', + observation: { httpStatus: 501, elapsedMs: 4510, source: 'prior-live-probe' }, + }, + ], +]) + +export async function readJson(endpoint, path, timeoutMs = 15_000) { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeoutMs) + try { + const response = await fetch(`${endpoint}${path}`, { + headers: { Accept: 'application/json' }, + signal: controller.signal, + }) + const body = await response.text() + if (!response.ok) throw new Error(`${path} returned HTTP ${response.status}: ${body}`) + return JSON.parse(body) + } finally { + clearTimeout(timer) + } +} + +function unavailable(runner, model, code, detail, observation) { + return { + runner, + ...(model === undefined ? {} : { model }), + status: 'unavailable', + code, + detail, + ...(observation === undefined ? {} : { observation }), + } +} + +function modelFor(models, backend, preferences) { + const candidates = models.filter((model) => model.backend === backend) + for (const preferred of preferences) { + const found = candidates.find((model) => model.id === preferred) + if (found) return found + } + return candidates[0] +} + +function backendStatus(health, backend) { + return health.backends?.find((candidate) => candidate?.name === backend) +} + +export function chooseRunnerTargets(health, catalog) { + const models = Array.isArray(catalog?.data) + ? catalog.data.filter( + (model) => model && typeof model.id === 'string' && typeof model.backend === 'string', + ) + : [] + const backends = [...new Set(models.map((model) => model.backend))].sort() + const preferred = [] + const pi = modelFor(models, 'pi', PI_PREFERENCES) + const codex = modelFor(models, 'codex', CODEX_PREFERENCES) + if (pi) preferred.push({ runner: 'pi', model: pi.id, selection: 'preferred-pi' }) + if (codex) preferred.push({ runner: 'codex', model: codex.id, selection: 'preferred-codex' }) + + const selected = + preferred.length > 0 + ? preferred + : backends.map((runner) => { + const model = modelFor(models, runner, []) + return { runner, ...(model ? { model: model.id } : {}), selection: 'advertised-fallback' } + }) + const targets = selected.map((candidate) => { + const status = backendStatus(health, candidate.runner) + if (!status) + return unavailable( + candidate.runner, + candidate.model, + 'RUNNER_NOT_READY', + 'The bridge catalog advertised this runner, but /health did not report it', + ) + if (status.state !== 'ready') + return unavailable( + candidate.runner, + candidate.model, + 'RUNNER_NOT_READY', + `The bridge reported ${status.state ?? 'unknown'} for this runner`, + ) + if (!candidate.model) + return unavailable( + candidate.runner, + undefined, + 'MODEL_NOT_ADVERTISED', + 'The runner has no model in the bridge catalog', + ) + return { ...candidate, status: 'selected', backendVersion: status.version } + }) + + const selectedRunners = new Set(selected.map((candidate) => candidate.runner)) + const inventory = backends + .map((runner) => { + if (selectedRunners.has(runner)) return undefined + const status = backendStatus(health, runner) + const model = modelFor(models, runner, []) + return unavailable( + runner, + model?.id, + status?.state === 'ready' ? 'RUNNER_NOT_SELECTED' : 'RUNNER_NOT_READY', + status?.state === 'ready' + ? 'A preferred Pi or Codex target was available; this advertised runner was not needed for this proof' + : `The bridge reported ${status?.state ?? 'unknown'} for this runner`, + ) + }) + .filter(Boolean) + const selectedModels = new Set(selected.map((candidate) => candidate.model).filter(Boolean)) + for (const model of models) { + if (selectedModels.has(model.id)) continue + const known = KNOWN_UNAVAILABLE_MODELS.get(model.id) + if (!known) continue + inventory.push( + unavailable(model.backend, model.id, known.code, known.detail, known.observation), + ) + } + return { targets, inventory, models: models.map(({ id, backend }) => ({ id, backend })) } +} + +export async function discover(endpoint = DEFAULT_ENDPOINT) { + const health = await readJson(endpoint, '/health') + assert.equal(health?.status, 'ok', `CLI Bridge health is not ok: ${JSON.stringify(health)}`) + const catalog = await readJson(endpoint, '/v1/models') + return { endpoint, health, catalog, ...chooseRunnerTargets(health, catalog) } +} + +export { DEFAULT_ENDPOINT } diff --git a/scripts/live-core/evidence.mjs b/scripts/live-core/evidence.mjs new file mode 100644 index 0000000..6aca32c --- /dev/null +++ b/scripts/live-core/evidence.mjs @@ -0,0 +1,44 @@ +import { createHash } from 'node:crypto' +import { mkdir, writeFile } from 'node:fs/promises' +import { dirname } from 'node:path' + +const SECRET_KEY = + /(?:secret|token|password|passphrase|authorization|credential|api[_-]?key|private[_-]?key|cookie)/iu +const SECRET_VALUE = /(?:bearer|basic)\s+[A-Za-z0-9._~+/=-]{16,}/iu + +export function redact(value, key = '') { + if (typeof value === 'string') { + if (SECRET_KEY.test(key) || SECRET_VALUE.test(value)) return '[redacted]' + return value.replaceAll(/(Bearer\s+)[A-Za-z0-9._~+/=-]+/giu, '$1[redacted]') + } + if (Array.isArray(value)) return value.map((item) => redact(item, key)) + if (!value || typeof value !== 'object') return value + return Object.fromEntries( + Object.entries(value).map(([name, child]) => [name, redact(child, name)]), + ) +} + +export function digest(value) { + return createHash('sha256').update(JSON.stringify(value)).digest('hex') +} + +export function eventKinds(responses) { + return [ + ...new Set( + responses + .filter((response) => response.type === 'event') + .map((response) => response.event?.kind) + .filter(Boolean), + ), + ] +} + +export function redactedReceipt(details) { + const receipt = details?.data?.receipt + return receipt ? redact(receipt) : undefined +} + +export async function writeEvidence(path, value) { + await mkdir(dirname(path), { recursive: true, mode: 0o700 }) + await writeFile(path, `${JSON.stringify(redact(value), null, 2)}\n`, { mode: 0o600 }) +} diff --git a/scripts/live-core/rpc-session.mjs b/scripts/live-core/rpc-session.mjs new file mode 100644 index 0000000..6ff804e --- /dev/null +++ b/scripts/live-core/rpc-session.mjs @@ -0,0 +1,171 @@ +import { spawn } from 'node:child_process' + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +export class RpcSession { + constructor(binary, workspace, keyFile, endpoint, timeoutMs = 180_000) { + this.binary = binary + this.workspace = workspace + this.keyFile = keyFile + this.endpoint = endpoint + this.timeoutMs = timeoutMs + this.responses = [] + this.commands = [] + this.waiters = new Set() + this.buffer = '' + this.stdout = '' + this.stderr = '' + this.child = spawn( + process.execPath, + [binary, 'rpc', '--workspace', workspace, '--database-key-file', keyFile], + { + cwd: workspace, + env: { + ...process.env, + NO_COLOR: '1', + NODE_NO_WARNINGS: '1', + BRAID_STATE_PATH: `${keyFile}.state.sqlite`, + BRAID_CLI_BRIDGE_ENDPOINT: endpoint, + XDG_DATA_HOME: `${keyFile}.data`, + XDG_CONFIG_HOME: `${keyFile}.config`, + }, + stdio: ['pipe', 'pipe', 'pipe'], + }, + ) + this.exit = new Promise((resolve, reject) => { + this.child.once('error', reject) + this.child.once('close', (code, signal) => { + this.closed = true + for (const waiter of this.waiters) + waiter.reject(new Error(`RPC exited before ${waiter.label}: ${code ?? signal}`)) + this.waiters.clear() + resolve({ code, signal }) + }) + }) + this.child.stdout.setEncoding('utf8') + this.child.stderr.setEncoding('utf8') + this.child.stdout.on('data', (chunk) => this.#stdout(chunk)) + this.child.stderr.on('data', (chunk) => { + this.stderr += chunk + }) + } + + #stdout(chunk) { + this.stdout += chunk + this.buffer += chunk + let newline = this.buffer.indexOf('\n') + while (newline >= 0) { + const line = this.buffer.slice(0, newline).trim() + this.buffer = this.buffer.slice(newline + 1) + if (line) { + let response + try { + response = JSON.parse(line) + } catch { + response = { type: 'malformed', line } + } + this.responses.push(response) + for (const waiter of this.waiters) { + if (!waiter.predicate(response)) continue + this.waiters.delete(waiter) + waiter.resolve(response) + break + } + } + newline = this.buffer.indexOf('\n') + } + } + + send(command, params = {}, operationId) { + if (this.closed) throw new Error('RPC process is closed') + const requestId = `live-core-${this.commands.length + 1}` + const request = { + version: 1, + requestId, + command, + params, + ...(operationId === undefined ? {} : { operationId }), + } + this.commands.push(structuredClone(request)) + this.child.stdin.write(`${JSON.stringify(request)}\n`) + return request + } + + async waitFor(label, predicate, timeoutMs = this.timeoutMs) { + const existing = this.responses.find(predicate) + if (existing) return existing + return new Promise((resolve, reject) => { + const waiter = { label, predicate, resolve, reject } + const timer = setTimeout(() => { + this.waiters.delete(waiter) + reject(new Error(`Timed out waiting for ${label}`)) + }, timeoutMs) + waiter.resolve = (value) => { + clearTimeout(timer) + resolve(value) + } + waiter.reject = (error) => { + clearTimeout(timer) + reject(error) + } + this.waiters.add(waiter) + }) + } + + async request(command, params = {}, operationId, timeoutMs = this.timeoutMs) { + const request = this.send(command, params, operationId) + const response = await this.waitFor( + `${command} acknowledgement`, + (candidate) => + candidate.requestId === request.requestId && + (candidate.type === 'ack' || candidate.type === 'error'), + timeoutMs, + ) + if (response.type === 'error') throw Object.assign(new Error(response.message), { response }) + return { request, response } + } + + async state(projection = 'full', timeoutMs = this.timeoutMs) { + const request = this.send('get_state', { projection }) + return this.waitFor( + `${projection} state`, + (response) => response.requestId === request.requestId && response.type === 'state', + timeoutMs, + ) + } + + async close() { + if (this.closed) return this.exit + if (!this.child.stdin.destroyed) this.child.stdin.end() + return this.exit + } + + async shutdown() { + if (this.closed) return this.exit + const { request, response } = await this.request( + 'shutdown', + {}, + `op-live-core-shutdown-${Date.now()}`, + ) + await this.waitFor( + 'RPC exit after shutdown', + (candidate) => candidate.requestId === request.requestId && candidate.type === 'ack', + ) + await this.exit + return { request, response } + } + + async forceStop() { + if (this.closed) return this.exit + this.child.kill('SIGKILL') + await this.exit + await sleep(20) + return { forced: true } + } +} + +export function stateFor(response, runId) { + return response?.type === 'state' && response.state?.runs?.some((run) => run.id === runId) +} diff --git a/scripts/live-core/run.mjs b/scripts/live-core/run.mjs new file mode 100644 index 0000000..dbdaaaa --- /dev/null +++ b/scripts/live-core/run.mjs @@ -0,0 +1,376 @@ +import assert from 'node:assert/strict' +import { execFile } from 'node:child_process' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { promisify } from 'node:util' + +import { discover } from './discovery.mjs' +import { digest, redactedReceipt, writeEvidence } from './evidence.mjs' +import { RpcSession } from './rpc-session.mjs' +import { configureWithPublicTui } from './setup-tui.mjs' +import { nativeInstallEnvironment } from '../native-install-environment.mjs' +import { dispatchTurn, providerIdentity } from './turn-proof.mjs' + +const exec = promisify(execFile) +const repository = new URL('../../', import.meta.url).pathname.replace(/\/$/u, '') +const endpoint = process.env.BRAID_LIVE_CORE_ENDPOINT ?? 'http://127.0.0.1:3344' +const outputPath = + process.env.BRAID_LIVE_CORE_EVIDENCE ?? 'artifacts/verification/live-core/latest.json' +const timeoutMs = Number(process.env.BRAID_LIVE_CORE_TIMEOUT_MS ?? 180_000) +const runnerFilter = process.env.BRAID_LIVE_CORE_RUNNER + +function turnEvidence(turn) { + return { + label: turn.label, + operationId: turn.operationId, + runId: turn.runId, + prompt: turn.prompt, + terminalState: turn.terminalState, + output: turn.output, + transcript: turn.transcript, + eventTypes: turn.eventTypes, + elapsedMs: turn.elapsedMs, + providerSessionId: turn.providerSessionId, + continuation: turn.continuation, + receipt: turn.receipt, + } +} + +async function packInstall(root) { + const packageRoot = join(root, 'package') + const installRoot = join(root, 'install') + const { stdout } = await exec('pnpm', ['pack', '--pack-destination', packageRoot], { + cwd: repository, + }) + const tarball = stdout.trim().split('\n').at(-1) + assert.ok(tarball?.endsWith('.tgz'), `pnpm pack did not return a tarball: ${stdout}`) + await mkdir(installRoot, { recursive: true }) + await writeFile( + join(installRoot, 'package.json'), + '{"name":"braid-live-core","private":true}\n', + { mode: 0o600 }, + ) + const tarballPath = tarball.startsWith('/') ? tarball : join(packageRoot, tarball) + await exec('npm', ['install', '--no-audit', '--no-fund', '--package-lock=false', tarballPath], { + cwd: installRoot, + env: nativeInstallEnvironment({ ...process.env, npm_config_loglevel: 'error' }), + }) + return { + tarball: tarballPath, + binary: join(installRoot, 'node_modules/@tangle-network/braid/dist/bin/braid.js'), + installRoot, + } +} + +async function runTarget(binary, root, target) { + if (target.status !== 'selected') return { ...target, status: 'unavailable' } + const targetRoot = await mkdtemp(join(root, `target-${target.runner}-`)) + const workspace = join(targetRoot, 'workspace') + const keyFile = join(targetRoot, 'database.key') + await mkdir(workspace, { recursive: true, mode: 0o700 }) + await writeFile(keyFile, `${'a'.repeat(64)}\n`, { mode: 0o600 }) + const startedAt = Date.now() + const result = { + runner: target.runner, + model: target.model, + profile: { harness: target.runner, model: target.model }, + connection: { kind: 'cli-bridge', endpoint }, + commands: [], + status: 'failed', + } + let first + let restarted + try { + const setup = await configureWithPublicTui( + binary, + workspace, + keyFile, + endpoint, + target.runner, + target.model, + ) + result.configure = { + status: 'completed', + profile: setup.config.profile, + connectionId: setup.config.connectionId, + configDigest: digest(setup.config), + } + first = new RpcSession(binary, workspace, keyFile, endpoint, timeoutMs) + const init = first.send('initialize', { workspace, subscribe: true }) + await first.waitFor( + 'initialize acknowledgement', + (response) => response.requestId === init.requestId && response.type === 'ack', + ) + const initialState = await first.waitFor( + 'initialize state', + (response) => response.type === 'state' && response.requestId === init.requestId, + ) + const profiles = await first.request('list_profiles', {}) + const profileList = profiles.response.result?.profiles ?? [] + const selectedProfile = profileList.find((profile) => profile.model === target.model) + assert.ok( + selectedProfile?.id, + `configured AgentProfile ${target.model} was not returned by list_profiles`, + ) + const selectedProfileRef = selectedProfile.id + const selectProfile = await first.request( + 'select_profile', + { ref: selectedProfileRef, expectedRevision: initialState.revision }, + 'op-live-core-select-profile', + ) + const connections = await first.request('list_connections', {}) + assert.ok( + connections.response.result?.connections?.some( + (connection) => connection.id === setup.config.connectionId, + ), + 'configured connection was not returned by list_connections', + ) + const connectionId = setup.config.connectionId + const selectedConnection = await first.request( + 'select_connection', + { connectionId, expectedRevision: selectProfile.response.revision }, + 'op-live-core-select-connection', + ) + result.select = { + profileRef: selectedProfileRef, + connectionId, + profileAck: selectProfile.response.result, + connectionAck: selectedConnection.response.result, + } + const conversationId = initialState.state.conversationId + const branchId = initialState.state.branchId + const nonce = `BraidCoreNonce-${target.runner.toUpperCase()}-7Q4M` + const firstPrompt = `Remember this exact nonce for our next turn and do not print it until asked: ${nonce}. Reply only ACK_NONCE_STORED.` + const secondPrompt = 'What exact nonce did I ask you to remember? Reply with only the nonce.' + assert.equal(secondPrompt.includes(nonce), false) + const firstTurn = await dispatchTurn(first, { + conversationId, + branchId, + prompt: firstPrompt, + operationId: 'op-live-core-send-1', + label: 'turn 1', + timeoutMs, + }) + result.dispatch = { + conversationId, + branchId, + nonce, + turns: [turnEvidence(firstTurn)], + } + assert.equal( + firstTurn.output.trim(), + 'ACK_NONCE_STORED', + `turn 1 terminal output was ${JSON.stringify(firstTurn.output)}`, + ) + assert.equal(firstTurn.output.includes(nonce), false, 'turn 1 printed the hidden nonce') + assert.ok(firstTurn.providerSessionId, 'turn 1 did not record the CLI Bridge session identity') + const secondTurn = await dispatchTurn(first, { + conversationId, + branchId, + prompt: secondPrompt, + operationId: 'op-live-core-send-2', + label: 'turn 2', + timeoutMs, + }) + result.dispatch = { + conversationId, + branchId, + nonce, + turns: [turnEvidence(firstTurn), turnEvidence(secondTurn)], + sameProviderSessionId: secondTurn.providerSessionId === firstTurn.providerSessionId, + } + assert.equal( + secondTurn.output.trim(), + nonce, + `turn 2 did not recover the nonce: ${JSON.stringify(secondTurn.output)}`, + ) + assert.equal( + secondTurn.providerSessionId, + firstTurn.providerSessionId, + 'the two turns used different CLI Bridge session identities', + ) + assert.notEqual( + secondTurn.receipt?.digest, + firstTurn.receipt?.digest, + 'two turns shared an immutable receipt', + ) + result.dispatch = { + conversationId, + branchId, + nonce, + turns: [turnEvidence(firstTurn), turnEvidence(secondTurn)], + sameProviderSessionId: true, + } + const preRestartState = await first.state('full', 30_000) + await first.shutdown() + restarted = new RpcSession(binary, workspace, keyFile, endpoint, timeoutMs) + const restartInit = restarted.send('initialize', { workspace, subscribe: true }) + await restarted.waitFor( + 'restart acknowledgement', + (response) => response.requestId === restartInit.requestId && response.type === 'ack', + ) + await restarted.waitFor( + 'restart state', + (response) => response.type === 'state' && response.requestId === restartInit.requestId, + ) + const restartState = await restarted.state('full', 30_000) + for (const turn of [firstTurn, secondTurn]) { + const restartDetails = restarted.send('get_details', { + entityType: 'run', + entityId: turn.runId, + }) + const restartDetailsAck = await restarted.waitFor( + `${turn.label} restart receipt`, + (response) => response.requestId === restartDetails.requestId && response.type === 'ack', + ) + const restartedReceipt = redactedReceipt(restartDetailsAck.result) + assert.equal( + restartedReceipt?.digest, + turn.receipt?.digest, + `${turn.label} receipt digest changed after restart`, + ) + assert.equal( + providerIdentity(restartDetailsAck.result, restartState.state, turn.runId), + turn.providerSessionId, + `${turn.label} provider identity changed after restart`, + ) + } + const restartMessages = restartState.state.messages.filter( + (message) => message.runId === firstTurn.runId || message.runId === secondTurn.runId, + ) + assert.ok( + restartMessages.some((message) => message.role === 'user' && message.text === firstPrompt), + 'turn 1 prompt was not durable after restart', + ) + assert.ok( + restartMessages.some( + (message) => message.role === 'assistant' && message.text === firstTurn.output, + ), + 'turn 1 output was not durable after restart', + ) + assert.ok( + restartMessages.some((message) => message.role === 'user' && message.text === secondPrompt), + 'turn 2 prompt was not durable after restart', + ) + assert.ok( + restartMessages.some( + (message) => message.role === 'assistant' && message.text === secondTurn.output, + ), + 'turn 2 output was not durable after restart', + ) + const reconnect = restarted.send( + 'reconnect', + { runId: secondTurn.runId }, + 'op-live-core-reconnect-1', + ) + const reconnectResponse = await restarted.waitFor( + 'reconnect response', + (response) => + response.requestId === reconnect.requestId && + (response.type === 'ack' || response.type === 'error'), + 30_000, + ) + let safeFollowUp + if (secondTurn.providerSessionId && secondTurn.continuation) { + const followUp = await dispatchTurn(restarted, { + conversationId, + branchId, + prompt: 'Reply with exactly LIVE_CORE_FOLLOWUP_OK.', + operationId: 'op-live-core-send-after-restart', + label: 'post-restart follow-up', + timeoutMs, + }) + assert.equal(followUp.output.trim(), 'LIVE_CORE_FOLLOWUP_OK') + assert.equal( + followUp.providerSessionId, + secondTurn.providerSessionId, + 'post-restart follow-up did not preserve the proven session identity', + ) + safeFollowUp = { + status: 'resumed', + operationId: followUp.operationId, + runId: followUp.runId, + output: followUp.output, + terminalState: followUp.terminalState, + eventTypes: followUp.eventTypes, + elapsedMs: followUp.elapsedMs, + providerSessionId: followUp.providerSessionId, + receipt: followUp.receipt, + } + } else { + safeFollowUp = { + status: 'refused', + reason: secondTurn.providerSessionId + ? 'Braid did not advertise sessions.continue after restart' + : 'Braid did not record a provider session identity after restart', + } + } + const postRestartSends = restarted.commands.filter((command) => command.command === 'send') + if (safeFollowUp.status === 'refused') + assert.equal( + postRestartSends.length, + 0, + 'restart path resubmitted an outward send without proven identity', + ) + else + assert.deepEqual( + postRestartSends.map((command) => command.operationId), + ['op-live-core-send-after-restart'], + ) + result.restart = { + preRestartTranscript: preRestartState.state.messages.filter( + (message) => message.runId === firstTurn.runId || message.runId === secondTurn.runId, + ), + state: restartState.state.runs.filter( + (run) => run.id === firstTurn.runId || run.id === secondTurn.runId, + ), + transcript: restartMessages, + reconnect: reconnectResponse, + safeFollowUp, + commands: restarted.commands, + postRestartSendCount: postRestartSends.length, + } + await restarted.shutdown() + result.status = 'passed' + } catch (error) { + result.error = { + message: error instanceof Error ? error.message : String(error), + response: error.response, + } + } finally { + if (first && !first.closed) await first.forceStop().catch(() => undefined) + if (restarted && !restarted.closed) await restarted.forceStop().catch(() => undefined) + result.elapsedMs = Date.now() - startedAt + await rm(targetRoot, { force: true, recursive: true }) + } + return result +} + +const root = await mkdtemp(join(tmpdir(), 'braid-live-core-')) +const evidence = { + claim: 'packed-public-configure-dispatch-restart', + command: 'node scripts/live-core/run.mjs', + endpoint, + startedAt: new Date().toISOString(), + targets: [], +} +try { + const live = await discover(endpoint) + evidence.bridge = { health: live.health, models: live.models, inventory: live.inventory } + const packed = await packInstall(root) + evidence.artifact = { tarball: packed.tarball, binary: packed.binary } + const targets = runnerFilter + ? live.targets.filter((target) => target.runner === runnerFilter) + : live.targets + assert.ok(targets.length > 0, `No selected live target matched runner ${runnerFilter}`) + for (const target of targets) + evidence.targets.push(await runTarget(packed.binary, root, target, live)) + const passed = evidence.targets.filter((target) => target.status === 'passed').length + evidence.status = passed === evidence.targets.length && passed > 0 ? 'passed' : 'failed' + await writeEvidence(outputPath, evidence) + process.stdout.write(`${JSON.stringify(evidence, null, 2)}\n`) + process.exitCode = evidence.status === 'passed' ? 0 : 1 +} finally { + await rm(root, { force: true, recursive: true }) +} diff --git a/scripts/live-core/setup-tui.mjs b/scripts/live-core/setup-tui.mjs new file mode 100644 index 0000000..cf6dd8a --- /dev/null +++ b/scripts/live-core/setup-tui.mjs @@ -0,0 +1,136 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import * as pty from 'node-pty' + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +async function waitFor(getOutput, text, label, timeoutMs = 120_000) { + const deadline = Date.now() + timeoutMs + while (!getOutput().includes(text)) { + if (Date.now() >= deadline) + throw new Error(`Timed out waiting for ${label}; output:\n${getOutput()}`) + await sleep(25) + } +} + +async function waitForFile(path, label, timeoutMs = 120_000) { + const deadline = Date.now() + timeoutMs + while (true) { + try { + return JSON.parse(await readFile(path, 'utf8')) + } catch (error) { + if (Date.now() >= deadline) + throw new Error( + `Timed out waiting for ${label}: ${error instanceof Error ? error.message : String(error)}`, + ) + await sleep(25) + } + } +} + +async function waitForSelection(getOutput, start, timeoutMs = 120_000) { + const deadline = Date.now() + timeoutMs + while (true) { + const current = getOutput().slice(start) + if (current.includes('selection applied')) return + const applying = current.indexOf('applying selection') + const retry = applying < 0 ? -1 : current.indexOf('→ Apply and start', applying) + if (retry >= 0) { + throw new Error(`Selection activation failed; output:\n${current.slice(applying)}`) + } + if (Date.now() >= deadline) { + throw new Error(`Timed out waiting for selection activation; output:\n${getOutput()}`) + } + await sleep(25) + } +} + +export async function configureWithPublicTui(binary, workspace, keyFile, endpoint, runner, model) { + const output = { value: '' } + const session = pty.spawn( + process.execPath, + [ + binary, + '--workspace', + workspace, + '--no-color', + '--database-key-file', + keyFile, + '--runner', + runner, + '--model', + model, + ], + { + name: 'xterm-256color', + cols: 120, + rows: 36, + cwd: workspace, + env: { + ...process.env, + TERM: 'xterm-256color', + NO_COLOR: '1', + NODE_NO_WARNINGS: '1', + BRAID_CLI_BRIDGE_ENDPOINT: endpoint, + BRAID_MODEL_VALIDATION_TIMEOUT_MS: '120000', + BRAID_STATE_PATH: `${keyFile}.state.sqlite`, + XDG_DATA_HOME: `${keyFile}.data`, + XDG_CONFIG_HOME: `${keyFile}.config`, + }, + }, + ) + let exited = false + const exit = new Promise((resolve) => + session.onExit((value) => { + exited = true + resolve(value) + }), + ) + session.onData((chunk) => { + output.value += chunk + }) + try { + await waitFor(() => output.value, 'braid setup', 'setup heading') + await sleep(50) + session.write('\r') + await waitFor(() => output.value, 'choose a connection', 'connection selector') + await sleep(50) + session.write('\r') + await waitFor(() => output.value, 'review and start', 'confirmation') + for (const field of [ + 'runner:', + 'model:', + 'effort:', + 'workdir:', + 'verification:', + 'unsupported:', + ]) { + assert.ok(output.value.includes(field), `setup confirmation omitted ${field}`) + } + await sleep(50) + const activationOutputStart = output.value.length + session.write('\r') + await waitForSelection(() => output.value, activationOutputStart) + await waitFor(() => output.value, '→ Close', 'selection close action') + const config = await waitForFile(`${workspace}/.braid/config.json`, 'persisted setup config') + await sleep(50) + session.write('\r') + await sleep(250) + session.write('\u0003') + await waitFor(() => output.value, 'ctrl+c again to quit', 'safe exit prompt', 15_000) + session.write('\u0003') + const result = await Promise.race([ + exit, + sleep(15_000).then(() => { + session.kill() + throw new Error('TUI did not exit') + }), + ]) + assert.equal(result.exitCode, 0, `TUI exited ${result.exitCode}`) + return { output: output.value, config } + } finally { + if (!exited) session.kill() + } +} diff --git a/scripts/live-core/turn-proof.mjs b/scripts/live-core/turn-proof.mjs new file mode 100644 index 0000000..a364eca --- /dev/null +++ b/scripts/live-core/turn-proof.mjs @@ -0,0 +1,91 @@ +import assert from 'node:assert/strict' + +import { eventKinds, redactedReceipt } from './evidence.mjs' + +export function runState(state, runId) { + return state?.runs?.find((run) => run.id === runId) +} + +export function transcriptFor(state, runId) { + return (state?.messages ?? []) + .filter((message) => message.runId === runId) + .map((message) => ({ role: message.role, text: message.text, status: message.status })) +} + +export function assistantText(state, runId) { + return transcriptFor(state, runId).findLast((message) => message.role === 'assistant')?.text ?? '' +} + +export function providerIdentity(details, state, runId) { + return ( + details?.data?.providerSessionId ?? + details?.data?.receipt?.providerSessionId ?? + runState(state, runId)?.providerSessionId + ) +} + +export function continuationCapability(details, state, runId) { + return Boolean( + details?.data?.capabilities?.sessions?.continue ?? + details?.data?.receipt?.capabilities?.sessions?.continue ?? + state?.runs?.find((run) => run.id === runId)?.capabilities?.sessions?.continue, + ) +} + +export async function dispatchTurn(session, input) { + const responseStart = session.responses.length + const startedAt = Date.now() + const send = session.send( + 'send', + { conversationId: input.conversationId, branchId: input.branchId, text: input.prompt }, + input.operationId, + ) + const sendAck = await session.waitFor( + `${input.label} acknowledgement`, + (response) => response.requestId === send.requestId && response.type === 'ack', + input.timeoutMs, + ) + assert.equal(sendAck.type, 'ack') + assert.equal(typeof sendAck.runId, 'string') + await session.waitFor( + `${input.label} terminal state`, + (response) => { + const run = response.type === 'state' ? runState(response.state, sendAck.runId) : undefined + return Boolean( + response.requestId === send.requestId && + run && + !['running', 'streaming', 'starting', 'reconnecting', 'cancelling'].includes(run.status), + ) + }, + input.timeoutMs, + ) + const detailsRequest = session.send('get_details', { entityType: 'run', entityId: sendAck.runId }) + const detailsAck = await session.waitFor( + `${input.label} details`, + (response) => response.requestId === detailsRequest.requestId && response.type === 'ack', + 30_000, + ) + const state = await session.state('full', 30_000) + const run = runState(state.state, sendAck.runId) + const details = detailsAck.result + const receipt = redactedReceipt(details) + assert.ok(receipt?.digest, `${input.label} did not expose an immutable receipt digest`) + assert.ok(run, `${input.label} did not remain in the full state`) + return { + label: input.label, + operationId: send.operationId, + runId: sendAck.runId, + prompt: input.prompt, + ack: sendAck, + terminalState: run, + eventTypes: eventKinds(session.responses.slice(responseStart)), + output: assistantText(state.state, sendAck.runId), + transcript: transcriptFor(state.state, sendAck.runId), + providerSessionId: providerIdentity(details, state.state, sendAck.runId), + continuation: continuationCapability(details, state.state, sendAck.runId), + receipt, + details, + elapsedMs: Date.now() - startedAt, + state: state.state, + } +} diff --git a/scripts/live-required.mjs b/scripts/live-required.mjs new file mode 100644 index 0000000..d5682d9 --- /dev/null +++ b/scripts/live-required.mjs @@ -0,0 +1,5 @@ +const scope = process.argv[2] ?? 'live' +process.stderr.write( + `${scope} requires protected live-provider credentials and environment evidence; no live claim is made by this branch\n`, +) +process.exitCode = 2 diff --git a/scripts/native-install-environment.mjs b/scripts/native-install-environment.mjs new file mode 100644 index 0000000..a29ae48 --- /dev/null +++ b/scripts/native-install-environment.mjs @@ -0,0 +1,6 @@ +/** Ensures clean-install proofs exercise required native dependency builds. */ +export function nativeInstallEnvironment(environment = process.env) { + const prepared = { ...environment, npm_config_ignore_scripts: 'false' } + delete prepared.NPM_CONFIG_IGNORE_SCRIPTS + return prepared +} diff --git a/scripts/package-proof-flows.mjs b/scripts/package-proof-flows.mjs new file mode 100644 index 0000000..6c685b3 --- /dev/null +++ b/scripts/package-proof-flows.mjs @@ -0,0 +1,396 @@ +import { readFile, rm, symlink, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import xterm from '@xterm/headless' +import * as pty from 'node-pty' +import { baselineEventEnd } from './package-proof-parity.mjs' +import { + cleanEnvironment, + runFifoCommand, + shellArgument, + sleep, + waitFor, +} from './package-proof-runtime.mjs' + +const XtermTerminal = xterm.Terminal + +export async function runRpc(binary, cwd) { + const request = (value) => `printf '%s\\n' ${shellArgument(JSON.stringify(value))}` + const script = [ + request({ + version: 1, + requestId: 'req-init', + command: 'initialize', + params: { workspace: cwd, subscribe: true }, + }), + request({ + version: 1, + requestId: 'req-send', + operationId: 'op-rpc-000001', + command: 'send', + params: { + conversationId: 'conv-1', + branchId: 'branch-1', + text: 'hello from package proof', + }, + }), + request({ + version: 1, + requestId: 'req-unavailable', + operationId: 'op-rpc-steer-000001', + command: 'steer', + params: { runId: 'run-000001', text: 'steer from package proof' }, + }), + 'sleep 1', + request({ version: 1, requestId: 'req-graph', command: 'get_graph', params: {} }), + request({ + version: 1, + requestId: 'req-retry', + operationId: 'op-rpc-000001', + command: 'send', + params: { + conversationId: 'conv-1', + branchId: 'branch-1', + text: 'hello from package proof', + }, + }), + request({ + version: 1, + requestId: 'req-cancel-send', + operationId: 'op-rpc-cancel-send', + command: 'send', + params: { text: 'cancel from package proof' }, + }), + request({ + version: 1, + requestId: 'req-cancel', + operationId: 'op-rpc-cancel', + command: 'cancel_run', + params: { runId: 'run-000005', reason: 'package proof cancellation' }, + }), + request({ + version: 1, + requestId: 'req-stop', + operationId: 'op-rpc-shutdown', + command: 'shutdown', + }), + ].join('; ') + const result = await Promise.race([ + runFifoCommand( + (stdoutPath, stderrPath) => + `{ ${script}; } | exec ${shellArgument(binary)} rpc --fixture deterministic > ${shellArgument(stdoutPath)} 2> ${shellArgument(stderrPath)}`, + cwd, + cleanEnvironment({ + NO_COLOR: '1', + NODE_NO_WARNINGS: '1', + BRAID_FIXTURE_CHUNK_DELAY_MS: '100', + BRAID_JOURNAL_PATH: join(cwd, 'rpc-events.jsonl'), + }), + ), + sleep(5_000).then(() => { + throw new Error('packed RPC did not exit') + }), + ]) + const { stdout, stderr } = result + const responses = () => + stdout + .trim() + .split('\n') + .filter(Boolean) + .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 + if (!firstState) throw new Error('packed RPC did not return send state') + const state = allResponses.find( + (response) => response.type === 'state' && response.requestId === 'req-cancel', + )?.state + if (!state) + throw new Error( + `packed RPC did not return cancellation state; responses=${allResponses.map((response) => `${response.type}:${response.requestId ?? ''}:${response.code ?? ''}`).join(',')}`, + ) + const events = allResponses + .filter((response) => response.type === 'event') + .map((response) => response.event) + const baselineEvents = events.slice(0, baselineEventEnd(events)) + const retryAck = allResponses.find( + (response) => response.type === 'ack' && response.requestId === 'req-retry', + ) + const graphAck = allResponses.find( + (response) => response.type === 'ack' && response.requestId === 'req-graph', + ) + const unavailable = allResponses.find( + (response) => response.type === 'error' && response.requestId === 'req-unavailable', + ) + const cancelState = allResponses.find( + (response) => response.type === 'state' && response.requestId === 'req-cancel', + ) + const shutdownAck = allResponses.find( + (response) => response.type === 'ack' && response.requestId === 'req-stop', + ) + assert(retryAck?.replayed === true, 'packed RPC retry did not replay the operation') + assert( + Array.isArray(graphAck?.result?.nodes) && + graphAck.result.nodes.some((node) => node?.type === 'conversation'), + 'packed RPC graph command did not return the semantic graph', + ) + assert( + unavailable?.code === 'CAPABILITY_UNAVAILABLE' && + /steering.*supported by this run/u.test(unavailable.message ?? ''), + 'packed RPC deterministic steering capability changed behavior', + ) + assert( + cancelState?.state?.runs?.at(-1)?.status === 'aborted', + 'packed RPC cancel did not abort the active run', + ) + assert( + shutdownAck?.operationId === 'op-rpc-shutdown', + 'packed RPC shutdown was not operation-bound', + ) + return { + responses: allResponses, + state, + firstState, + events, + baselineEvents, + stderr, + flows: ['send', 'graph', 'unavailable', 'retry', 'cancel', 'shutdown'], + } +} + +export async function runPlain(binary, cwd) { + const recordPath = join(cwd, 'plain-final-state.json') + const line = (value) => `printf '%s\\n' ${shellArgument(value)}` + const script = [ + line('plain package proof'), + 'sleep 1.5', + line('/graph'), + line('/open'), + line('plain package proof'), + line('/graph'), + line('/open'), + line('/cancel'), + line('/quit'), + ].join('; ') + const { stdout, stderr } = await Promise.race([ + runFifoCommand( + (stdoutPath, stderrPath) => + `{ ${script}; } | exec ${shellArgument(binary)} --plain --fixture deterministic --no-color --workspace ${shellArgument(cwd)} --record-state ${shellArgument(recordPath)} > ${shellArgument(stdoutPath)} 2> ${shellArgument(stderrPath)}`, + cwd, + cleanEnvironment({ + NO_COLOR: '1', + NODE_NO_WARNINGS: '1', + BRAID_FIXTURE_CHUNK_DELAY_MS: '100', + BRAID_JOURNAL_PATH: join(cwd, 'plain-events.jsonl'), + }), + ), + sleep(5_000).then(() => { + throw new Error('plain proof did not exit') + }), + ]) + const evidence = JSON.parse(await readFile(recordPath, 'utf8')) + const result = { stdout, stderr } + assert(evidence.view?.selectedSurface === 'graph', 'plain graph command did not open graph') + assert( + evidence.state.runs.some((run) => run.status === 'aborted'), + 'plain cancel did not abort a run', + ) + assert( + evidence.state.messages.some( + (message) => + message.role === 'assistant' && + message.status === 'complete' && + message.text === 'Fixture response through pi: plain package proof', + ), + 'plain retry did not complete', + ) + return { + ...result, + evidence, + flows: ['send', 'graph', 'unavailable', 'retry', 'cancel', 'shutdown'], + } +} + +export async function runTerminal(binary, cwd, options) { + const recordPath = join( + cwd, + `terminal-${options.columns}x${options.rows}-${options.inline ? 'inline' : 'alt'}-${options.highContrast ? 'high-contrast' : 'default'}-${options.reducedMotion ? 'reduced-motion' : 'motion'}.json`, + ) + const args = [ + '--fixture', + 'deterministic', + '--no-color', + '--workspace', + cwd, + '--record-state', + recordPath, + ] + if (options.inline) args.push('--inline') + if (options.highContrast) args.push('--high-contrast') + if (options.reducedMotion) args.push('--reduced-motion') + const session = pty.spawn(binary, args, { + name: 'xterm-256color', + cols: options.columns, + rows: options.rows, + cwd, + env: cleanEnvironment({ + NO_COLOR: '1', + TERM: 'xterm-256color', + BRAID_FIXTURE_CHUNK_DELAY_MS: '100', + BRAID_JOURNAL_PATH: `${recordPath}.journal`, + }), + }) + const victimPath = `${recordPath}.victim` + const formerPredictableTemporary = `${recordPath}.${session.pid}.tmp` + await writeFile(victimPath, 'unchanged\n') + await symlink(victimPath, formerPredictableTemporary) + const emulator = new XtermTerminal({ + cols: options.columns, + rows: options.rows, + disableStdin: true, + allowProposedApi: true, + }) + let output = '' + let screen = '' + const exited = new Promise((resolve) => { + session.onExit(resolve) + }) + session.onData((chunk) => { + output += chunk + emulator.write(chunk, () => { + const buffer = emulator.buffer.active + screen = Array.from( + { length: emulator.rows }, + (_, index) => buffer.getLine(buffer.viewportY + index)?.translateToString(true) ?? '', + ).join('\n') + }) + }) + const normalizedScreen = () => screen.replace(/\s+/gu, ' ').trim() + + await waitFor(() => screen.includes('braid'), 'terminal header') + if (!options.inline) { + session.write('\u0010') + await waitFor( + () => screen.includes('Commands') && screen.includes('/new'), + 'searchable command overlay', + ) + session.write('q') + await waitFor( + () => screen.includes('/quit') && !screen.includes('/help'), + 'filtered command overlay', + ) + session.write('\u001b') + await waitFor(() => !screen.includes('Commands'), 'closed command overlay') + } + session.write('hello from package proof') + await sleep(30) + session.write('\r') + if (!options.inline) { + const resizedColumns = Math.max(40, options.columns - 10) + const resizedRows = Math.max(12, options.rows - 4) + emulator.resize(resizedColumns, resizedRows) + session.resize(resizedColumns, resizedRows) + await sleep(30) + emulator.resize(options.columns, options.rows) + session.resize(options.columns, options.rows) + } + await waitFor( + () => + normalizedScreen().includes('Fixture response through pi: hello from package proof') && + normalizedScreen().includes('completed'), + 'completed fixture response', + ) + const screenBeforeExit = screen + session.write('\u0007') + await waitFor(() => normalizedScreen().includes('conversation graph'), 'terminal graph') + session.write('\u001b') + await sleep(30) + session.write('hello from package proof') + session.write('\r') + await waitFor(() => normalizedScreen().includes('streaming'), 'terminal retry start') + // Deterministic execution intentionally does not advertise live steering. + session.write('/steer deterministic package proof') + session.write('\r') + await waitFor( + () => normalizedScreen().includes('steering'), + 'terminal unavailable steering capability', + ) + session.write('\u001b') + await sleep(30) + await waitFor( + () => + normalizedScreen().includes('Fixture response through pi: hello from package proof') && + normalizedScreen().includes('completed') && + !normalizedScreen().includes('streaming'), + 'terminal retry completion', + ) + session.write('cancel terminal proof') + session.write('\r') + await waitFor(() => normalizedScreen().includes('streaming'), 'terminal cancellation start') + session.write('/cancel') + session.write('\r') + await waitFor(() => normalizedScreen().includes('cancelled'), 'terminal cancellation') + session.write('\u0003') + await waitFor( + () => screen.includes('press ctrl+c again to quit') || screen.includes('ctrl+c again to quit'), + 'armed terminal exit', + ) + session.write('\u0003') + let timeout + const timedOut = new Promise((_, reject) => { + timeout = setTimeout(() => { + session.kill() + reject(new Error('Packed terminal did not exit after Ctrl+C')) + }, 5_000) + }) + const exit = await Promise.race([exited, timedOut]).finally(() => clearTimeout(timeout)) + if (exit.exitCode !== 0) throw new Error(`Packed terminal exited ${exit.exitCode}`) + const evidence = JSON.parse(await readFile(recordPath, 'utf8')) + assert( + (await readFile(victimPath, 'utf8')) === 'unchanged\n', + 'state write followed a temp symlink', + ) + await rm(formerPredictableTemporary, { force: true }) + emulator.dispose() + return { + output, + screenBeforeExit, + evidence, + flows: ['send', 'graph', 'unavailable', 'retry', 'cancel', 'shutdown'], + } +} + +export async function runSignalTerminal(binary, cwd) { + const session = pty.spawn(binary, ['--fixture', 'deterministic', '--no-color'], { + name: 'xterm-256color', + cols: 80, + rows: 24, + cwd, + env: cleanEnvironment({ + NO_COLOR: '1', + TERM: 'xterm-256color', + BRAID_JOURNAL_PATH: join(cwd, 'signal-events.jsonl'), + }), + }) + let output = '' + const exited = new Promise((resolve) => session.onExit(resolve)) + session.onData((chunk) => { + output += chunk + }) + await waitFor(() => output.includes('braid'), 'signal terminal header') + process.kill(session.pid, 'SIGINT') + let timeout + const timedOut = new Promise((_, reject) => { + timeout = setTimeout(() => { + session.kill() + reject(new Error('Packed terminal did not exit after SIGINT')) + }, 5_000) + }) + const exit = await Promise.race([exited, timedOut]).finally(() => clearTimeout(timeout)) + return { output, exit } +} + +function assert(condition, message) { + if (!condition) throw new Error(message) +} diff --git a/scripts/package-proof-parity.mjs b/scripts/package-proof-parity.mjs new file mode 100644 index 0000000..60b6928 --- /dev/null +++ b/scripts/package-proof-parity.mjs @@ -0,0 +1,214 @@ +import { canonicalDigest } from '../dist/domain/canonical.js' + +export function baselineEventEnd(events) { + const finishIndex = events.findIndex( + (event) => event.kind === 'run.finished' && event.payload?.status === 'completed', + ) + if (finishIndex < 0) throw new Error('proof has no completed baseline run') + const requested = events.slice(0, finishIndex).find((event) => event.kind === 'run.requested') + const operationId = + requested?.payload?.operationId ?? + requested?.payload?.admission?.operationId ?? + requested?.payload?.receipt?.operationId + let end = finishIndex + 1 + while ( + typeof operationId === 'string' && + events[end]?.kind === 'effect.upserted' && + (events[end]?.payload?.effect?.operationId === operationId || + events[end]?.payload?.value?.effect?.operationId === operationId) + ) { + end += 1 + } + return end +} + +function sortParityValue(value) { + if (Array.isArray(value)) return value.map((item) => sortParityValue(item)) + if (!value || typeof value !== 'object') return value + + return Object.fromEntries( + Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, child]) => [ + key, + key === 'occurredAt' || key === 'receivedAt' ? '' : sortParityValue(child), + ]), + ) +} + +function normalizeDraftEvent(event) { + if (event?.kind !== 'draft.changed' && event?.kind !== 'draft.recorded') return undefined + const value = event.payload?.value + const text = event.kind === 'draft.changed' ? value?.text : value?.draft?.text + return { + ...event, + kind: 'draft.changed', + payload: { + value: { + kind: 'draft.changed', + text: typeof text === 'string' ? text : '', + }, + }, + } +} + +export function parityEvidence(state, events) { + const operationIds = new Map() + const effects = new Map() + const normalizeCallerOperationId = (value) => { + if (typeof value !== 'string') return value + let normalized = operationIds.get(value) + if (!normalized) { + normalized = `` + operationIds.set(value, normalized) + } + return normalized + } + const normalizeDerivedOperationId = (value) => { + if (typeof value !== 'string') return value + for (const [operationId, normalized] of operationIds) { + if (value.includes(operationId)) return value.replaceAll(operationId, normalized) + } + return value + } + const normalizeEffect = (effect) => { + if (!effect || typeof effect !== 'object') return effect + const key = `${String(effect.operationId)}\u0000${String(effect.effectKind)}\u0000${String(effect.requestDigest)}` + let ordinal = effects.get(key) + if (ordinal === undefined) { + ordinal = effects.size + 1 + effects.set(key, ordinal) + } + return { + ...effect, + id: + typeof effect.id === 'string' + ? `` + : normalizeDerivedOperationId(effect.id), + operationId: normalizeCallerOperationId(effect.operationId), + ...(typeof effect.requestDigest === 'string' + ? { requestDigest: `` } + : {}), + } + } + const normalizeReceipt = (receipt) => { + if (!receipt || typeof receipt !== 'object') return receipt + const operationId = normalizeCallerOperationId(receipt.operationId) + const normalizedRequestDigest = canonicalDigest({ + runId: receipt.runId, + turnId: receipt.turnId, + operationId, + conversationId: receipt.conversationId, + branchId: receipt.branchId, + text: receipt.requested?.text, + profileDigest: receipt.profileDigest, + contextPlanDigest: receipt.requested?.contextPlanDigest ?? null, + }) + const normalized = { + ...receipt, + operationId, + requestDigest: normalizedRequestDigest, + } + const { digest: _digest, ...base } = normalized + return { ...normalized, digest: canonicalDigest(base) } + } + const normalizeRun = (run) => + run && typeof run === 'object' + ? { + ...run, + operationId: normalizeCallerOperationId(run.operationId), + ...(run.receipt === undefined ? {} : { receipt: normalizeReceipt(run.receipt) }), + } + : run + const normalizedState = { + ...state, + runs: Array.isArray(state?.runs) ? state.runs.map(normalizeRun) : state?.runs, + effects: Array.isArray(state?.effects) + ? state.effects.map((effect) => normalizeEffect(effect)) + : state?.effects, + } + const normalizedEvents = events.map((event) => { + const normalizedDraft = normalizeDraftEvent(event) + if (normalizedDraft !== undefined) return normalizedDraft + if (event?.kind === 'run.requested' && event.payload && typeof event.payload === 'object') { + return { + ...event, + payload: { + ...event.payload, + operationId: normalizeCallerOperationId(event.payload.operationId), + ...(event.payload.admission === undefined + ? {} + : { admission: normalizeReceipt(event.payload.admission) }), + ...(event.payload.receipt === undefined + ? {} + : { receipt: normalizeReceipt(event.payload.receipt) }), + }, + } + } + if (event?.kind === 'effect.upserted' && event.payload && typeof event.payload === 'object') { + return { + ...event, + payload: { + ...event.payload, + ...(event.payload.effect === undefined + ? {} + : { effect: normalizeEffect(event.payload.effect) }), + ...(event.payload.value && typeof event.payload.value === 'object' + ? { + value: { + ...event.payload.value, + effect: normalizeEffect(event.payload.value.effect), + }, + } + : {}), + }, + } + } + return event + }) + return sortParityValue({ events: normalizedEvents, state: normalizedState }) +} + +export function firstDifference(left, right, path = '$') { + if (Object.is(left, right)) return undefined + if (Array.isArray(left) && Array.isArray(right)) { + if (left.length !== right.length) + return { path: `${path}.length`, left: left.length, right: right.length } + for (let index = 0; index < left.length; index += 1) { + const difference = firstDifference(left[index], right[index], `${path}[${index}]`) + if (difference) return difference + } + return undefined + } + if (left && right && typeof left === 'object' && typeof right === 'object') { + const keys = new Set([...Object.keys(left), ...Object.keys(right)].sort()) + for (const key of keys) { + const difference = firstDifference(left[key], right[key], `${path}.${key}`) + if (difference) return difference + } + return undefined + } + return { path, left, right } +} + +export function firstTerminalTrace(evidence) { + const finishIndex = evidence.events.findIndex( + (event) => event.kind === 'run.finished' && event.payload?.status === 'completed', + ) + if (finishIndex < 0) throw new Error('terminal proof has no completed baseline run') + const finish = evidence.events[finishIndex] + const baselineEnd = baselineEventEnd(evidence.events) + const lastBaselineEvent = evidence.events[baselineEnd - 1] ?? finish + return { + state: { + ...evidence.state, + revision: lastBaselineEvent.revision, + sequence: lastBaselineEvent.sequence, + messages: evidence.state.messages.slice(0, 2), + runs: evidence.state.runs.slice(0, 1), + activeRunId: null, + lastError: null, + }, + events: evidence.events.slice(0, baselineEnd), + } +} diff --git a/scripts/package-proof-runtime.mjs b/scripts/package-proof-runtime.mjs new file mode 100644 index 0000000..22d328c --- /dev/null +++ b/scripts/package-proof-runtime.mjs @@ -0,0 +1,162 @@ +import { createHash } from 'node:crypto' +import { execFileSync, spawn } from 'node:child_process' +import { createReadStream } from 'node:fs' +import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, relative } from 'node:path' +import * as pty from 'node-pty' +import { nativeInstallEnvironment } from './native-install-environment.mjs' + +export const repository = new URL('../', import.meta.url).pathname +const SOURCE_EXCLUSIONS = new Set(['.git', 'node_modules', 'dist', '.test-dist', 'artifacts']) + +export async function sourceDigest(root) { + const files = [] + async function walk(directory) { + for (const entry of await readdir(directory, { withFileTypes: true })) { + if (SOURCE_EXCLUSIONS.has(entry.name)) continue + const path = join(directory, entry.name) + if (entry.isDirectory()) await walk(path) + else if (entry.isFile()) files.push(path) + } + } + await walk(root) + files.sort() + const digest = createHash('sha256') + for (const path of files) { + digest.update(relative(root, path)) + digest.update('\0') + digest.update(await readFile(path)) + digest.update('\0') + } + return digest.digest('hex') +} + +export const gitValue = (...args) => { + if (args.join(' ') === 'rev-parse HEAD' && process.env.BRAID_PACKAGE_PROOF_COMMIT) + return process.env.BRAID_PACKAGE_PROOF_COMMIT + if (args.join(' ') === 'rev-parse HEAD^{tree}' && process.env.BRAID_PACKAGE_PROOF_TREE) + return process.env.BRAID_PACKAGE_PROOF_TREE + try { + return execFileSync('git', args, { cwd: repository, encoding: 'utf8' }).trim() + } catch (error) { + if (error?.status === 0 && typeof error.stdout === 'string') return error.stdout.trim() + throw error + } +} + +export function cleanEnvironment(extra = {}) { + const environment = { ...process.env, ...extra } + delete environment.FORCE_COLOR + return environment +} + +export function installEnvironment() { + return nativeInstallEnvironment(cleanEnvironment()) +} + +export async function run(file, args, options = {}) { + return await new Promise((resolve, reject) => { + const child = spawn(file, args, { + cwd: options.cwd, + env: options.env ?? cleanEnvironment(), + stdio: ['ignore', 'pipe', 'pipe'], + }) + let stdout = '' + let stderr = '' + child.stdout.setEncoding('utf8') + child.stderr.setEncoding('utf8') + child.stdout.on('data', (chunk) => { + stdout += chunk + }) + child.stderr.on('data', (chunk) => { + stderr += chunk + }) + child.on('error', reject) + child.on('close', (code) => { + if (code === 0) resolve({ stdout, stderr }) + else reject(new Error(`${file} ${args.join(' ')} exited ${code}\n${stdout}\n${stderr}`)) + }) + }) +} + +export function shellArgument(value) { + return `'${value.replaceAll("'", "'\\''")}'` +} + +export async function runFifoCommand(command, cwd, env) { + const fifoRoot = await mkdtemp(join(tmpdir(), 'braid-fifo-')) + const stdoutPath = join(fifoRoot, 'stdout') + const stderrPath = join(fifoRoot, 'stderr') + await run('mkfifo', [stdoutPath, stderrPath]) + const readFifo = (path, sink) => + new Promise((resolve, reject) => { + const stream = createReadStream(path, { encoding: 'utf8' }) + stream.on('data', (chunk) => { + sink.value += chunk + }) + stream.on('error', reject) + stream.on('end', resolve) + }) + const stdoutRead = { value: '' } + const stderrRead = { value: '' } + const child = spawn('/bin/sh', ['-c', command(stdoutPath, stderrPath)], { + cwd, + env, + stdio: ['ignore', 'pipe', 'pipe'], + }) + let wrapperStderr = '' + child.stderr.setEncoding('utf8') + child.stderr.on('data', (chunk) => { + wrapperStderr += chunk + }) + child.stdout.resume() + const close = new Promise((resolve, reject) => { + child.on('error', reject) + child.on('close', (code) => resolve(code)) + }) + const timeout = setTimeout(() => child.kill(), 5_000) + try { + const [exitCode] = await Promise.all([ + close, + readFifo(stdoutPath, stdoutRead), + readFifo(stderrPath, stderrRead), + ]) + if (exitCode !== 0) + throw new Error(`fifo command exited ${exitCode}\n${wrapperStderr}\n${stderrRead.value}`) + return { stdout: stdoutRead.value, stderr: `${wrapperStderr}${stderrRead.value}` } + } finally { + clearTimeout(timeout) + await rm(fifoRoot, { force: true, recursive: true }) + } +} + +export async function runPty(file, args, options = {}) { + const session = pty.spawn(file, args, { + name: 'xterm-256color', + cols: 240, + rows: 80, + cwd: options.cwd, + env: options.env ?? cleanEnvironment({ NODE_NO_WARNINGS: '1' }), + }) + let stdout = '' + session.onData((chunk) => { + stdout += chunk + }) + const exit = await new Promise((resolve) => session.onExit(resolve)) + if (exit.exitCode !== 0) + throw new Error(`${file} ${args.join(' ')} exited ${exit.exitCode}\n${stdout}`) + return { stdout: stdout.replace(/\r\n/gu, '\n').replace(/\r/gu, ''), stderr: '' } +} + +export function sleep(milliseconds) { + return new Promise((resolve) => setTimeout(resolve, milliseconds)) +} + +export async function waitFor(predicate, label, timeoutMs = 5_000) { + const deadline = Date.now() + timeoutMs + while (!predicate()) { + if (Date.now() >= deadline) throw new Error(`Timed out waiting for ${label}`) + await sleep(20) + } +} diff --git a/scripts/packed-binary.mjs b/scripts/packed-binary.mjs new file mode 100644 index 0000000..6f2814e --- /dev/null +++ b/scripts/packed-binary.mjs @@ -0,0 +1,53 @@ +import { createHash } from 'node:crypto' +import { execFile } from 'node:child_process' +import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { promisify } from 'node:util' +import { nativeInstallEnvironment } from './native-install-environment.mjs' + +const run = promisify(execFile) + +export async function installPackedBraid(repository) { + const packRoot = await mkdtemp(join(tmpdir(), 'braid-pack-')) + const installRoot = await mkdtemp(join(tmpdir(), 'braid-install-')) + const cleanup = async () => { + await Promise.all([ + rm(packRoot, { force: true, recursive: true }), + rm(installRoot, { force: true, recursive: true }), + ]) + } + try { + await run('pnpm', ['pack', '--pack-destination', packRoot], { cwd: repository }) + const tarballName = (await readdir(packRoot)).find((name) => name.endsWith('.tgz')) + if (!tarballName) throw new Error('pnpm pack did not produce a tarball') + const tarball = join(packRoot, tarballName) + await writeFile( + join(installRoot, 'package.json'), + `${JSON.stringify({ name: 'braid-packed-binary-proof', private: true })}\n`, + ) + await run('npm', ['install', '--no-audit', '--no-fund', '--package-lock=false', tarball], { + cwd: installRoot, + env: nativeInstallEnvironment(), + }) + const binary = join( + installRoot, + 'node_modules', + '.bin', + process.platform === 'win32' ? 'braid.cmd' : 'braid', + ) + return { + binary, + installRoot, + tarball, + tarballName, + tarballSha256: createHash('sha256') + .update(await readFile(tarball)) + .digest('hex'), + cleanup, + } + } catch (error) { + await cleanup() + throw error + } +} diff --git a/scripts/packed-rpc/deterministic.mjs b/scripts/packed-rpc/deterministic.mjs new file mode 100644 index 0000000..fa4d96d --- /dev/null +++ b/scripts/packed-rpc/deterministic.mjs @@ -0,0 +1,104 @@ +import { execFile, spawn } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import { createReadStream } from 'node:fs' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { promisify } from 'node:util' + +const execFileAsync = promisify(execFile) + +function shellArgument(value) { + return `'${value.replaceAll("'", "'\\''")}'` +} + +export async function runDeterministicRpcProof(binary, repository) { + const requests = [ + { + version: 1, + requestId: 'req-init', + command: 'initialize', + params: { workspace: repository, subscribe: true }, + }, + { + version: 1, + requestId: 'req-send', + operationId: 'op-packed-rpc', + command: 'send', + params: { text: 'packed rpc proof' }, + }, + { + version: 1, + requestId: 'req-stop', + operationId: 'op-packed-shutdown', + command: 'shutdown', + }, + ] + const fifoRoot = await mkdtemp(join(tmpdir(), 'braid-rpc-proof-')) + const stdoutPath = join(fifoRoot, 'stdout') + const stderrPath = join(fifoRoot, 'stderr') + const journalPath = join(repository, `.braid/test-rpc-${randomUUID()}.jsonl`) + try { + await execFileAsync('mkfifo', [stdoutPath, stderrPath]) + const requestLine = (request) => `printf '%s\\n' ${shellArgument(JSON.stringify(request))}` + const shellCommand = `{ ${requestLine(requests[0])}; ${requestLine(requests[1])}; sleep 1; ${requestLine(requests[2])}; } | exec ${shellArgument(binary)} rpc --fixture deterministic > ${shellArgument(stdoutPath)} 2> ${shellArgument(stderrPath)}` + const output = { value: '' } + const error = { value: '' } + const readFifo = (path, sink) => + new Promise((resolve, reject) => { + const stream = createReadStream(path, { encoding: 'utf8' }) + stream.on('data', (chunk) => { + sink.value += chunk + }) + stream.on('error', reject) + stream.on('end', resolve) + }) + const session = spawn('/bin/sh', ['-c', shellCommand], { + cwd: repository, + env: { + ...process.env, + NO_COLOR: '1', + NODE_NO_WARNINGS: '1', + BRAID_JOURNAL_PATH: journalPath, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }) + let wrapperStderr = '' + session.stdout.resume() + session.stderr.setEncoding('utf8') + session.stderr.on('data', (chunk) => { + wrapperStderr += chunk + }) + const exited = new Promise((resolve, reject) => { + session.on('error', reject) + session.on('close', (code, signal) => resolve({ code, signal })) + }) + const timeout = setTimeout(() => session.kill(), 5_000) + const [exit] = await Promise.all([ + exited, + readFifo(stdoutPath, output), + readFifo(stderrPath, error), + ]) + clearTimeout(timeout) + if (exit.code !== 0) + throw new Error(`packed RPC exited ${exit.code}\n${wrapperStderr}${error.value}`) + const stdout = output.value + const stderr = `${wrapperStderr}${error.value}` + if (stderr) throw new Error(`packed RPC wrote stderr: ${stderr}`) + const normalized = stdout.replace(/\r\n/gu, '\n').replace(/\r/gu, '') + 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', + ) + 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('{'))) + throw new Error('packed RPC wrote non-JSONL stdout') + process.stdout.write(`Packed RPC proof passed: ${lines.length} JSONL responses\n`) + } finally { + await rm(fifoRoot, { force: true, recursive: true }) + await rm(journalPath, { force: true }) + } +} diff --git a/scripts/packed-rpc/first-run-tui.mjs b/scripts/packed-rpc/first-run-tui.mjs new file mode 100644 index 0000000..466673a --- /dev/null +++ b/scripts/packed-rpc/first-run-tui.mjs @@ -0,0 +1,194 @@ +import assert from 'node:assert/strict' +import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, relative } from 'node:path' + +import * as pty from 'node-pty' + +function sleep(milliseconds) { + return new Promise((resolve) => setTimeout(resolve, milliseconds)) +} + +async function waitFor(predicate, label, timeoutMs = 15_000) { + const deadline = Date.now() + timeoutMs + while (!predicate()) { + if (Date.now() >= deadline) throw new Error(`Timed out waiting for ${label}`) + await sleep(20) + } +} + +const LIVE_BRIDGE_ENDPOINT = 'http://127.0.0.1:3344' +const LIVE_GLM_MODEL = 'opencode/zai-coding-plan/glm-5.2' + +async function readLiveJson(path) { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 15_000) + try { + const response = await fetch(`${LIVE_BRIDGE_ENDPOINT}${path}`, { + headers: { Accept: 'application/json' }, + signal: controller.signal, + }) + const body = await response.text() + if (!response.ok) throw new Error(`GET ${path} returned HTTP ${response.status}: ${body}`) + return JSON.parse(body) + } finally { + clearTimeout(timeout) + } +} + +async function assertLiveGlmBridge() { + const health = await readLiveJson('/health') + assert.equal(health.status, 'ok', `live Bridge health is not ok: ${JSON.stringify(health)}`) + assert.ok( + Array.isArray(health.backends) && + health.backends.some((backend) => backend?.name === 'opencode' && backend?.state === 'ready'), + `live Bridge has no ready opencode backend: ${JSON.stringify(health)}`, + ) + const models = await readLiveJson('/v1/models') + assert.ok( + Array.isArray(models.data) && models.data.some((model) => model?.id === LIVE_GLM_MODEL), + `live Bridge does not advertise ${LIVE_GLM_MODEL}: ${JSON.stringify(models)}`, + ) +} + +export async function runPackedFirstRun(binary, repository) { + const workspace = await mkdtemp(join(tmpdir(), 'braid-packed-first-run-')) + const keyDirectory = await mkdtemp(join(tmpdir(), 'braid-packed-key-')) + const keyPath = join(keyDirectory, 'database.key') + const statePath = join(workspace, 'braid.sqlite') + await writeFile(keyPath, Buffer.alloc(32, 7), { mode: 0o600 }) + await chmod(keyPath, 0o600) + await assertLiveGlmBridge() + const environment = { + ...process.env, + TERM: 'xterm-256color', + NO_COLOR: '1', + NODE_NO_WARNINGS: '1', + BRAID_STATE_PATH: statePath, + BRAID_CLI_BRIDGE_ENDPOINT: LIVE_BRIDGE_ENDPOINT, + BRAID_MODEL_VALIDATION_TIMEOUT_MS: '120000', + } + delete environment.FORCE_COLOR + + async function runTui(args, expectedResponse, expectSetup, prompt) { + const session = pty.spawn(process.execPath, [binary, ...args], { + name: 'xterm-256color', + cols: 100, + rows: 30, + cwd: repository, + env: environment, + }) + let output = '' + const exited = new Promise((resolve) => session.onExit(resolve)) + session.onData((chunk) => { + output += chunk + }) + let completed = false + try { + await waitFor(() => output.includes('braid'), 'packed TUI header') + if (expectSetup) { + await waitFor(() => output.includes('braid setup'), 'packed first-run setup') + session.write('\r') + await waitFor(() => output.includes('choose a connection'), 'packed profile selection') + session.write('\r') + await waitFor(() => output.includes('review and start'), 'packed setup confirmation') + for (const value of [ + 'runner:', + 'model:', + 'effort:', + 'workdir:', + 'verification:', + 'unsupported:', + ]) { + if (!output.includes(value)) + throw new Error(`packed confirmation omitted ${value}\n${output}`) + } + session.write('\r') + try { + await waitFor( + () => output.includes('selection applied'), + 'packed in-process activation', + 120_000, + ) + } catch (error) { + throw new Error(`packed setup did not activate\n${output}`, { cause: error }) + } + } + session.write(`${prompt}\r`) + await waitFor(() => output.includes(expectedResponse), 'packed real response') + session.write('\u0003') + await waitFor( + () => + output.includes('press ctrl+c again to quit') || output.includes('ctrl+c again to quit'), + 'packed safe exit', + ) + session.write('\u0003') + const exit = await Promise.race([ + exited, + sleep(10_000).then(() => { + session.kill() + throw new Error('packed TUI did not exit') + }), + ]) + if (exit.exitCode !== 0) throw new Error(`packed TUI exited ${exit.exitCode}\n${output}`) + completed = true + return output + } finally { + if (!completed) session.kill() + } + } + + try { + const firstPrompt = 'Reply with exactly BRAID_LIVE_GLM_OK and nothing else.' + const restartPrompt = 'Reply with exactly BRAID_LIVE_GLM_RESTART_OK and nothing else.' + const firstOutput = await runTui( + [ + '--workspace', + workspace, + '--no-color', + '--runner', + 'opencode', + '--model', + LIVE_GLM_MODEL, + '--database-key-file', + keyPath, + ], + 'BRAID_LIVE_GLM_OK', + true, + firstPrompt, + ) + const configPath = join(workspace, '.braid', 'config.json') + const saved = JSON.parse(await readFile(configPath, 'utf8')) + const savedProfile = saved.profile + const savedConnection = saved.connections?.[0] + if (savedProfile?.harness !== 'opencode') + throw new Error(`packed setup saved the wrong runner\n${firstOutput}`) + if (savedProfile?.model?.default !== LIVE_GLM_MODEL) + throw new Error(`packed setup did not persist the discovered model\n${JSON.stringify(saved)}`) + if (savedConnection?.endpoint !== LIVE_BRIDGE_ENDPOINT) + throw new Error( + `packed setup did not persist the selected bridge endpoint\n${JSON.stringify(saved)}`, + ) + if (saved.databaseKeyFile !== keyPath) + throw new Error( + `packed setup did not persist the external database key path\n${JSON.stringify(saved)}`, + ) + if (/secret|token|api[_-]?key/iu.test(JSON.stringify(saved))) + throw new Error(`packed setup persisted credential material\n${JSON.stringify(saved)}`) + const configDirectory = join(workspace, '.braid') + saved.databaseKeyFile = relative(configDirectory, keyPath) + await writeFile(configPath, `${JSON.stringify(saved)}\n`, { mode: 0o600 }) + await runTui( + ['--workspace', workspace, '--no-color'], + 'BRAID_LIVE_GLM_RESTART_OK', + false, + restartPrompt, + ) + return { model: LIVE_GLM_MODEL, workspace } + } finally { + await Promise.all([ + rm(workspace, { force: true, recursive: true }), + rm(keyDirectory, { force: true, recursive: true }), + ]) + } +} diff --git a/scripts/packed-rpc/production-startup.mjs b/scripts/packed-rpc/production-startup.mjs new file mode 100644 index 0000000..0d47fcd --- /dev/null +++ b/scripts/packed-rpc/production-startup.mjs @@ -0,0 +1,120 @@ +import { spawn } from 'node:child_process' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +export function runBinary(binaryPath, args, input, cwd, env) { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [binaryPath, ...args], { + cwd, + env, + stdio: ['pipe', 'pipe', 'pipe'], + }) + let stdout = '' + let stderr = '' + child.stdout.setEncoding('utf8') + child.stderr.setEncoding('utf8') + child.stdout.on('data', (chunk) => { + stdout += chunk + }) + child.stderr.on('data', (chunk) => { + stderr += chunk + }) + child.on('error', reject) + child.on('close', (code, signal) => resolve({ code, signal, stdout, stderr })) + child.stdin.end(input) + }) +} + +export async function runBuiltStartupProof(binary, repository) { + const sourceWorkspace = await mkdtemp(join(tmpdir(), 'braid-production-startup-')) + const sourceState = join(sourceWorkspace, 'braid.sqlite') + try { + const sourceBinary = join(repository, 'dist', 'bin', 'braid.js') + const empty = await runBinary( + sourceBinary, + ['rpc', '--workspace', sourceWorkspace], + '', + repository, + { + ...process.env, + NO_COLOR: '1', + NODE_NO_WARNINGS: '1', + BRAID_STATE_PATH: sourceState, + }, + ) + if (empty.code !== 0) + throw new Error( + `built RPC startup failed on a fresh workspace: exit=${empty.code} signal=${empty.signal ?? 'none'}\n${empty.stderr}`, + ) + if (empty.stderr.includes('PRODUCTION_CONFIGURATION_NOT_FOUND')) + throw new Error(`built RPC startup still rejected first-run setup\n${empty.stderr}`) + + const machineWorkspace = await mkdtemp(join(tmpdir(), 'braid-production-rpc-')) + try { + const machine = await runBinary( + sourceBinary, + ['rpc', '--workspace', machineWorkspace], + `${[ + { + version: 1, + requestId: 'setup-init', + command: 'initialize', + params: { workspace: machineWorkspace }, + }, + { + version: 1, + requestId: 'setup-state', + command: 'get_state', + params: { projection: 'summary' }, + }, + { + version: 1, + requestId: 'setup-stop', + operationId: 'setup-stop-operation', + command: 'shutdown', + }, + ] + .map((request) => JSON.stringify(request)) + .join('\n')}\n`, + repository, + { + ...process.env, + NO_COLOR: '1', + NODE_NO_WARNINGS: '1', + BRAID_STATE_PATH: join(machineWorkspace, 'braid.sqlite'), + }, + ) + if (machine.code !== 0 || machine.stderr) + throw new Error( + `built RPC setup protocol failed: exit=${machine.code} stderr=${machine.stderr}\n${machine.stdout}`, + ) + const responses = machine.stdout + .trim() + .split('\n') + .map((line) => JSON.parse(line)) + if ( + !responses.some( + (response) => response.type === 'ack' && response.requestId === 'setup-init', + ) + ) + throw new Error( + `built RPC setup protocol omitted initialize acknowledgement\n${machine.stdout}`, + ) + if ( + !responses.some( + (response) => response.type === 'state' && response.requestId === 'setup-state', + ) + ) + throw new Error( + `built RPC setup protocol omitted machine-readable state\n${machine.stdout}`, + ) + } finally { + await rm(machineWorkspace, { force: true, recursive: true }) + } + process.stdout.write('Built fresh-workspace RPC startup proof passed\n') + } finally { + await rm(sourceWorkspace, { force: true, recursive: true }) + } + return binary +} diff --git a/scripts/performance/application-probe.mjs b/scripts/performance/application-probe.mjs new file mode 100644 index 0000000..5a0792c --- /dev/null +++ b/scripts/performance/application-probe.mjs @@ -0,0 +1,249 @@ +import { performance } from 'node:perf_hooks' +import { setTimeout as sleep } from 'node:timers/promises' +import { TUI } from '@earendil-works/pi-tui' +import { HeadlessTerminal } from './headless-terminal.mjs' +import { loadPackedRuntime } from './packed-runtime.mjs' + +const FIXED_TIME = '2026-08-03T00:00:00.000Z' + +function providerEnvelope(runId, sequence) { + const marker = `perf-event-${String(sequence).padStart(5, '0')}` + return { + runId, + eventId: `event-perf-stream-${sequence}`, + sequence, + receivedAt: FIXED_TIME, + occurredAt: FIXED_TIME, + event: { type: 'text_delta', text: `${marker} ` }, + } +} + +function pendingExecution() { + return { + admissionMode: 'sync', + capabilities: { cancel: true }, + async *streamTurn(input) { + await new Promise((resolve) => { + if (input.signal.aborted) { + resolve() + return + } + input.signal.addEventListener('abort', resolve, { once: true }) + }) + }, + async cancelRun(input) { + return { operationId: input.operationId, outcome: 'accepted' } + }, + } +} + +function invalidCellCount(terminal) { + const buffer = terminal.xterm.buffer.active + let invalid = 0 + for (let row = 0; row < terminal.rows; row += 1) { + const line = buffer.getLine(buffer.viewportY + row) + for (let column = 0; column < terminal.columns; column += 1) { + const cell = line?.getCell(column) + if (!cell || typeof cell.getChars !== 'function') invalid += 1 + } + } + return invalid +} + +async function waitFor(predicate, label, signal, timeoutMs = 5_000) { + const deadline = Date.now() + timeoutMs + while (!predicate()) { + if (signal?.aborted) throw new Error(`Interrupted while waiting for ${label}`) + if (Date.now() >= deadline) throw new Error(`Timed out waiting for ${label}`) + await sleep(2) + } +} + +export function evaluateRuntimeEventRate({ count, elapsedMs, minimumEventsPerSecond = 100 }) { + const achievedEventsPerSecond = count / (elapsedMs / 1_000) + return { + achievedEventsPerSecond, + minimumEventsPerSecond, + passed: + Number.isFinite(achievedEventsPerSecond) && achievedEventsPerSecond >= minimumEventsPerSecond, + } +} + +export async function measureRuntimeEventsToFrames({ + packageRoot, + count = 10_000, + intervalMs = 10, + signal, +} = {}) { + const runtime = await loadPackedRuntime(packageRoot) + const terminal = new HeadlessTerminal(80, 24) + const tui = new TUI(terminal) + const app = runtime.index.createBraidApplication({ + execution: pendingExecution(), + profile: { name: 'Braid performance profile', harness: 'pi' }, + clock: new runtime.clock.FixedClock(FIXED_TIME), + journal: new runtime.index.MemoryJournal(new runtime.clock.FixedClock(FIXED_TIME)), + }) + const controller = runtime.tui.createApplicationUiController(app, { + color: 'none', + reducedMotion: true, + }) + const terminalApp = new runtime.terminal.BraidTerminalApp({ + controller, + tui, + theme: runtime.theme.createBraidTheme({ colors: false, reducedMotion: true }), + workspace: '/performance', + nextOperationId: (() => { + let next = 0 + return () => `op-perf-ui-${++next}` + })(), + }) + app.initialize('/performance') + + const pending = [] + const samplesBySequence = new Map() + const completedFrames = [] + const originalDoRender = tui.doRender.bind(tui) + tui.doRender = () => { + originalDoRender() + void terminal.flush().then(() => { + const view = controller.view() + const renderedText = view.messages.map((message) => message.text).join('\n') + const completedAt = performance.now() + let resolved = 0 + for (let index = pending.length - 1; index >= 0; index -= 1) { + const event = pending[index] + if (view.revision < event.revision || !renderedText.includes(event.marker)) continue + samplesBySequence.set(event.sequence, completedAt - event.receivedAt) + pending.splice(index, 1) + resolved += 1 + } + completedFrames.push({ + revision: view.revision, + completedAt, + resolved, + invalidCells: invalidCellCount(terminal), + }) + }) + } + + let producer + let runId + let terminalStarted = false + try { + void terminalApp.start() + terminalStarted = true + await terminal.flush() + const receipt = app.send({ operationId: 'op-perf-stream-start', text: 'performance stream' }) + await receipt.admissionReady + runId = receipt.runId + await sleep(20) + + let producerError + let nextSequence = 1 + let startedAt + producer = setInterval(() => { + if (signal?.aborted) { + producerError = new Error('Interrupted during PERF-04 event production') + clearInterval(producer) + return + } + if (nextSequence > count) return + const sequence = nextSequence + nextSequence += 1 + try { + const receivedAt = performance.now() + startedAt ??= receivedAt + const result = app.ingestRuntimeEvent(providerEnvelope(runId, sequence)) + if (result && typeof result.then === 'function') + throw new Error('PERF-04 smoke path unexpectedly became asynchronous') + const view = controller.view() + if (result.accepted !== true) + throw new Error(`PERF-04 event ${sequence} was not accepted by the application`) + pending.push({ + sequence, + marker: `perf-event-${String(sequence).padStart(5, '0')}`, + revision: view.revision, + receivedAt, + }) + } catch (error) { + producerError = error + clearInterval(producer) + } + if (sequence === count) clearInterval(producer) + }, intervalMs) + while (nextSequence <= count) { + if (producerError) throw producerError + if (signal?.aborted) throw new Error('Interrupted during PERF-04 event production') + await sleep(5) + } + clearInterval(producer) + producer = undefined + await waitFor(() => pending.length === 0, 'PERF-04 event-tagged Pi render completions', signal) + await terminal.flush() + const elapsedMs = performance.now() - startedAt + const rate = evaluateRuntimeEventRate({ count, elapsedMs }) + + const results = app.events().filter((envelope) => envelope.event.kind === 'run.text.delta') + const eventIds = results.map((envelope) => envelope.event.provider?.eventId) + const uniqueEventIds = new Set(eventIds) + const invalidCells = completedFrames.reduce( + (maximum, frame) => Math.max(maximum, frame.invalidCells), + 0, + ) + const samples = Array.from({ length: count }, (_, index) => samplesBySequence.get(index + 1)) + const accepted = results.length + const duplicateEvents = eventIds.length - uniqueEventIds.size + const missingEvents = samples.filter((sample) => sample === undefined).length + const failureReasons = [] + if (accepted !== count) failureReasons.push(`accepted ${accepted}/${count} events`) + if (duplicateEvents !== 0) + failureReasons.push(`found ${duplicateEvents} duplicate event identifiers`) + if (missingEvents !== 0) failureReasons.push(`found ${missingEvents} unrendered events`) + if (invalidCells !== 0) failureReasons.push(`found ${invalidCells} invalid terminal cells`) + if (!rate.passed) + failureReasons.push( + `achieved ${rate.achievedEventsPerSecond.toFixed(2)} events/s, below ${rate.minimumEventsPerSecond}`, + ) + + const cancel = await app.cancel({ operationId: 'op-perf-stream-cancel', runId }) + await cancel.completion + return { + samples, + rawSamples: samples, + rawEventCount: count, + accepted, + duplicateEvents, + missingEvents, + invalidCells, + uniqueProviderEventIds: uniqueEventIds.size, + renderedEvents: samples.length - missingEvents, + frameCompletions: completedFrames.length, + maxRenderedRevision: Math.max(...completedFrames.map((frame) => frame.revision)), + elapsedMs, + achievedEventsPerSecond: rate.achievedEventsPerSecond, + minimumEventsPerSecond: rate.minimumEventsPerSecond, + finalFrameCompleted: true, + qualityPassed: failureReasons.length === 0, + failureReasons, + provenance: { + packageRoot, + frameBinding: 'Pi TUI doRender completion + current view revision + unique event marker', + unrelatedWritesExcluded: true, + rateInterval: 'producer start through the final event matching Pi frame completion', + }, + } + } finally { + if (producer) clearInterval(producer) + if (runId && app.state().activeRunId === runId) { + try { + const receipt = app.cancel({ operationId: 'op-perf-stream-cleanup', runId }) + await receipt.completion + } catch { + // Cleanup is best effort after the measured run has already settled. + } + } + if (terminalStarted) terminalApp.stop() + await app.close().catch(() => undefined) + } +} diff --git a/scripts/performance/compile-cache-primer.mjs b/scripts/performance/compile-cache-primer.mjs new file mode 100644 index 0000000..18de6eb --- /dev/null +++ b/scripts/performance/compile-cache-primer.mjs @@ -0,0 +1,35 @@ +import { constants as moduleConstants, enableCompileCache, flushCompileCache } from 'node:module' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' + +const [packageRoot, cacheDirectory] = process.argv.slice(2) +if (!packageRoot || !cacheDirectory) + throw new Error('Compile-cache primer requires packed package and cache directories') + +const result = enableCompileCache(cacheDirectory) +const statusName = + Object.entries(moduleConstants.compileCacheStatus).find( + ([, value]) => value === result.status, + )?.[0] ?? `UNKNOWN_${result.status}` +if ( + result.status === moduleConstants.compileCacheStatus.FAILED || + result.status === moduleConstants.compileCacheStatus.DISABLED +) { + throw new Error( + `Could not prime Node compile cache: ${statusName}: ${result.message ?? 'unknown'}`, + ) +} + +await Promise.all([ + import(pathToFileURL(join(packageRoot, 'dist/startup/durable-runtime.js')).href), + import(pathToFileURL(join(packageRoot, 'dist/startup/terminal-runtime.js')).href), +]) +flushCompileCache() + +process.stdout.write( + `${JSON.stringify({ + status: statusName, + modules: ['dist/startup/durable-runtime.js', 'dist/startup/terminal-runtime.js'], + flushed: true, + })}\n`, +) diff --git a/scripts/performance/file-credential-store.mjs b/scripts/performance/file-credential-store.mjs new file mode 100644 index 0000000..7e83ca8 --- /dev/null +++ b/scripts/performance/file-credential-store.mjs @@ -0,0 +1,61 @@ +import { createHash, randomUUID } from 'node:crypto' +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' + +function credentialPath(root, ref) { + return join(root, createHash('sha256').update(String(ref)).digest('hex')) +} + +function missingCredential(ref, cause) { + const error = new Error(`Credential ${ref} was not found`, { cause }) + error.code = 'CREDENTIAL_NOT_FOUND' + return error +} + +class FileSecretHandle { + constructor(ref, value) { + this.ref = ref + this.value = Buffer.from(value) + } + + read() { + if (!this.value) throw new Error('Secret handle is closed') + return Buffer.from(this.value) + } + + dispose() { + this.value?.fill(0) + this.value = undefined + } +} + +export class FileCredentialStore { + constructor(root) { + this.root = root + } + + async store(input) { + const ref = input.ref ?? `cred:v1:performance-file-${randomUUID()}` + await mkdir(this.root, { recursive: true, mode: 0o700 }) + const target = credentialPath(this.root, ref) + await writeFile(target, Buffer.from(input.value), { mode: 0o600 }) + return ref + } + + async resolve(ref) { + try { + return new FileSecretHandle(ref, await readFile(credentialPath(this.root, ref))) + } catch (error) { + throw missingCredential(ref, error) + } + } + + async remove(ref) { + await rm(credentialPath(this.root, ref), { force: true }) + } + + async available() { + await mkdir(this.root, { recursive: true, mode: 0o700 }) + return true + } +} diff --git a/scripts/performance/headless-terminal.mjs b/scripts/performance/headless-terminal.mjs new file mode 100644 index 0000000..945f99d --- /dev/null +++ b/scripts/performance/headless-terminal.mjs @@ -0,0 +1,110 @@ +import xterm from '@xterm/headless' + +const XtermTerminal = xterm.Terminal + +export class HeadlessTerminal { + #xterm + #inputHandler + #resizeHandler + #columns + #rows + + constructor(columns = 80, rows = 24) { + this.#columns = columns + this.#rows = rows + this.#xterm = new XtermTerminal({ + cols: columns, + rows, + disableStdin: true, + allowProposedApi: true, + }) + } + + get xterm() { + return this.#xterm + } + + start(onInput, onResize) { + this.#inputHandler = onInput + this.#resizeHandler = onResize + this.#xterm.write('\u001b[?2004h') + } + + stop() { + this.#xterm.write('\u001b[?2004l') + this.#inputHandler = undefined + this.#resizeHandler = undefined + } + + write(data) { + this.#xterm.write(data) + } + + get columns() { + return this.#columns + } + + get rows() { + return this.#rows + } + + get kittyProtocolActive() { + return true + } + + moveBy(lines) { + if (lines > 0) this.#xterm.write(`\u001b[${lines}B`) + else if (lines < 0) this.#xterm.write(`\u001b[${-lines}A`) + } + + hideCursor() { + this.#xterm.write('\u001b[?25l') + } + + showCursor() { + this.#xterm.write('\u001b[?25h') + } + + clearLine() { + this.#xterm.write('\u001b[K') + } + + clearFromCursor() { + this.#xterm.write('\u001b[J') + } + + clearScreen() { + this.#xterm.write('\u001b[2J\u001b[H') + } + + setTitle(title) { + this.#xterm.write(`\u001b]0;${title}\u0007`) + } + + setProgress() {} + + sendInput(data) { + this.#inputHandler?.(data) + } + + resize(columns, rows) { + this.#columns = columns + this.#rows = rows + this.#xterm.resize(columns, rows) + this.#resizeHandler?.() + } + + async flush() { + await new Promise((resolve) => this.#xterm.write('', resolve)) + } + + getViewport() { + const lines = [] + const buffer = this.#xterm.buffer.active + for (let index = 0; index < this.#xterm.rows; index += 1) { + const line = buffer.getLine(buffer.viewportY + index) + lines.push(line?.translateToString(true) ?? '') + } + return lines + } +} diff --git a/scripts/performance/lifecycle.mjs b/scripts/performance/lifecycle.mjs new file mode 100644 index 0000000..936b80a --- /dev/null +++ b/scripts/performance/lifecycle.mjs @@ -0,0 +1,54 @@ +import { cleanupProcessProbes } from './process-probes.mjs' + +export function createPerformanceLifecycle() { + const controller = new AbortController() + const cleanups = new Set() + const lateCleanups = [] + let abortCleanupPromise = Promise.resolve() + let closePromise + + const addCleanup = (cleanup) => { + if (controller.signal.aborted) { + lateCleanups.push(abortCleanupPromise.then(() => cleanup()).catch(() => undefined)) + return cleanup + } + cleanups.add(cleanup) + return cleanup + } + + const abort = (reason = 'performance run interrupted') => { + if (controller.signal.aborted) return + controller.abort(reason) + abortCleanupPromise = cleanupProcessProbes().catch(() => undefined) + } + + const throwIfAborted = () => { + if (controller.signal.aborted) { + throw new Error( + `Performance run aborted: ${String(controller.signal.reason ?? 'interrupted')}`, + ) + } + } + + const close = () => { + if (closePromise) return closePromise + closePromise = (async () => { + await abortCleanupPromise + await cleanupProcessProbes() + const pending = [...cleanups].reverse() + cleanups.clear() + await Promise.allSettled(pending.map((cleanup) => cleanup())) + await Promise.all(lateCleanups.splice(0)) + await cleanupProcessProbes() + })() + return closePromise + } + + return Object.freeze({ + signal: controller.signal, + addCleanup, + abort, + close, + throwIfAborted, + }) +} diff --git a/scripts/performance/memory-child.mjs b/scripts/performance/memory-child.mjs new file mode 100644 index 0000000..6ed9139 --- /dev/null +++ b/scripts/performance/memory-child.mjs @@ -0,0 +1,49 @@ +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { FileCredentialStore } from './file-credential-store.mjs' + +const [databasePath, workspaceRoot, keyPath, credentialRoot, packageRoot] = process.argv.slice(2) +if (!databasePath || !workspaceRoot || !keyPath || !credentialRoot || !packageRoot) + throw new Error( + 'PERF-09 memory child requires database, workspace, key, credentials, and packed package paths', + ) + +const indexUrl = (relativePath) => pathToFileURL(join(packageRoot, relativePath)).href +const [tui, index] = await Promise.all([ + import(indexUrl('dist/adapters/tui/application-ui-controller.js')), + import(indexUrl('dist/index.js')), +]) +const profile = { name: 'Braid performance profile', harness: 'pi' } +const credentials = new FileCredentialStore(credentialRoot) +if (typeof globalThis.gc === 'function') globalThis.gc() +const baselineRssMiB = process.memoryUsage().rss / (1024 * 1024) +const durable = await index.createDurableBraidApplication({ + path: databasePath, + workspaceRoot, + credentialStore: credentials, + databaseKeySource: { type: 'file', path: keyPath, workspaceRoot }, + profile, +}) +try { + const view = tui.buildBraidViewModel(durable.app.state(), 'transcript', { color: 'none' }, false) + if (typeof globalThis.gc === 'function') globalThis.gc() + const state = durable.app.state() + process.stdout.write( + `${JSON.stringify({ + baselineRssMiB, + rssMiB: process.memoryUsage().rss / (1024 * 1024), + eventCount: state.sequence, + loadedTailEventCount: durable.app.events().length, + renderedRows: view.messages.length + (view.activity?.length ?? 0), + recentContent: view.messages.at(-1)?.text ?? state.messages.at(-1)?.text ?? null, + stateMessageCount: state.messages.length, + viewMessageCount: view.messages.length, + stateRunCount: state.runs.length, + stateRunEventCount: state.runs.at(-1)?.eventCount ?? null, + stateLastError: state.lastError, + sequence: state.sequence, + })}\n`, + ) +} finally { + await durable.app.close() +} diff --git a/scripts/performance/packed-production-tui-child.mjs b/scripts/performance/packed-production-tui-child.mjs new file mode 100644 index 0000000..c2612a2 --- /dev/null +++ b/scripts/performance/packed-production-tui-child.mjs @@ -0,0 +1,111 @@ +import { writeFile } from 'node:fs/promises' +import { constants as moduleConstants, enableCompileCache } from 'node:module' +import { pathToFileURL } from 'node:url' +import { FileCredentialStore } from './file-credential-store.mjs' + +const epochNow = () => performance.timeOrigin + performance.now() +const startup = { + processStartEpochMs: performance.timeOrigin, + scriptReadyEpochMs: epochNow(), +} + +const compileCache = enableCompileCache() +startup.compileCacheReadyEpochMs = epochNow() +startup.compileCacheStatus = + Object.entries(moduleConstants.compileCacheStatus).find( + ([, value]) => value === compileCache.status, + )?.[0] ?? `UNKNOWN_${compileCache.status}` +startup.compileCacheEnabled = + compileCache.status === moduleConstants.compileCacheStatus.ENABLED || + compileCache.status === moduleConstants.compileCacheStatus.ALREADY_ENABLED + +const [databasePath, workspaceRoot, keyPath, credentialRoot, packageRoot] = process.argv.slice(2) +if (!databasePath || !workspaceRoot || !keyPath || !credentialRoot || !packageRoot) + throw new Error( + 'Packed production TUI child requires database, workspace, key, credentials, and package paths', + ) + +const applicationRuntime = await import( + pathToFileURL(`${packageRoot}/dist/startup/durable-runtime.js`).href +) +startup.applicationModulesReadyEpochMs = epochNow() + +const profile = { name: 'Braid performance profile', harness: 'pi' } +const connection = { + id: 'connection-performance-local', + kind: 'cli-bridge', + name: 'Performance local bridge', + endpoint: 'http://127.0.0.1:9', + providerOptions: { transport: 'local' }, + createdAt: '2026-08-03T00:00:00.000Z', + updatedAt: '2026-08-03T00:00:00.000Z', + lastHealth: { status: 'unknown' }, +} +const credentials = new FileCredentialStore(credentialRoot) +const application = applicationRuntime + .createDurableBraidApplication({ + path: databasePath, + workspaceRoot, + credentialStore: credentials, + databaseKeySource: { type: 'file', path: keyPath, workspaceRoot }, + profile, + production: { + profile, + connections: [connection], + connectionId: connection.id, + connectionOptions: { credentials }, + }, + }) + .then((value) => { + startup.applicationReadyEpochMs = epochNow() + return value + }) +const terminalModules = import( + pathToFileURL(`${packageRoot}/dist/startup/terminal-runtime.js`).href +).then((value) => { + startup.terminalModulesReadyEpochMs = epochNow() + return value +}) +const [durable, terminalRuntime] = await Promise.all([application, terminalModules]) + +const controller = terminalRuntime.createApplicationUiController(durable.app, { + color: 'none', + reducedMotion: true, +}) +const terminal = new terminalRuntime.ProcessTerminal() +const tui = new terminalRuntime.TUI(terminal) +const terminalApp = new terminalRuntime.BraidTerminalApp({ + controller, + tui, + theme: terminalRuntime.createBraidTheme({ colors: false, reducedMotion: true }), + workspace: workspaceRoot, + nextOperationId: (() => { + let next = 0 + return () => `op-perf-production-ui-${++next}` + })(), +}) +startup.terminalReadyEpochMs = epochNow() + +let started = false +const stop = () => { + if (started) terminalApp.stop() +} +process.once('SIGINT', stop) +process.once('SIGTERM', stop) +try { + const initialized = await controller.initialize(workspaceRoot) + if (initialized.kind !== 'accepted') throw new Error(initialized.reason ?? initialized.message) + startup.initializedEpochMs = epochNow() + if (process.env.BRAID_STARTUP_TIMING_PATH) { + await writeFile(process.env.BRAID_STARTUP_TIMING_PATH, `${JSON.stringify(startup)}\n`, { + mode: 0o600, + }) + } + started = true + await terminalApp.start() +} finally { + process.off('SIGINT', stop) + process.off('SIGTERM', stop) + if (started) terminalApp.stop() + await durable.app.close().catch(() => undefined) +} diff --git a/scripts/performance/packed-runtime.mjs b/scripts/performance/packed-runtime.mjs new file mode 100644 index 0000000..7db776f --- /dev/null +++ b/scripts/performance/packed-runtime.mjs @@ -0,0 +1,39 @@ +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' + +function moduleUrl(packageRoot, relativePath) { + return pathToFileURL(join(packageRoot, relativePath)).href +} + +export function installedPackageRoot(packed) { + return join(packed.installRoot, 'node_modules', '@tangle-network', 'braid') +} + +export async function loadPackedRuntime(packageRoot) { + if (typeof packageRoot !== 'string' || packageRoot.length === 0) + throw new Error('PERF_PREREQUISITE: packed Braid package root is required') + const [index, clock, journal, journalSupport, materializedState, state, tui, terminal, theme] = + await Promise.all([ + import(moduleUrl(packageRoot, 'dist/index.js')), + import(moduleUrl(packageRoot, 'dist/ports/clock.js')), + import(moduleUrl(packageRoot, 'dist/app/storage-journal.js')), + import(moduleUrl(packageRoot, 'dist/app/storage-journal-support.js')), + import(moduleUrl(packageRoot, 'dist/domain/materialized-state-snapshot.js')), + import(moduleUrl(packageRoot, 'dist/domain/state.js')), + import(moduleUrl(packageRoot, 'dist/adapters/tui/application-ui-controller.js')), + import(moduleUrl(packageRoot, 'dist/views/tui/terminal-app.js')), + import(moduleUrl(packageRoot, 'dist/views/tui/theme.js')), + ]) + return Object.freeze({ + index, + clock, + journal, + journalSupport, + materializedState, + state, + tui, + terminal, + theme, + packageRoot, + }) +} diff --git a/scripts/performance/process-measurements.mjs b/scripts/performance/process-measurements.mjs new file mode 100644 index 0000000..d1514d5 --- /dev/null +++ b/scripts/performance/process-measurements.mjs @@ -0,0 +1,480 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + measureFirstVisibleFrame, + measureIdleCpu, + measureIdleKeyFrames, +} from './process-probes.mjs' +import { environment, fixtureOptions, packedOptions } from './reporting.mjs' +import { measurePackedResizeStream } from './resize-probe.mjs' +import { assertFullDuration, mergeObservations, observation } from './statistics.mjs' + +const READY_ERROR = + /(?:PRODUCTION_|STORAGE_|CREDENTIAL_STORE_UNAVAILABLE|startup error|encrypted storage)/iu +// Linux child CPU accounting advances in 10 ms ticks on the release machine. +// Two seconds gives the 1%-of-one-core target at least two observable ticks; +// the former 250 ms smoke could only distinguish 0% from 4% and produced false failures. +const SMOKE_IDLE_DURATION_MS = 2_000 +const SMOKE_IDLE_SETTLE_MS = 250 + +function readyFramePredicate(marker) { + return (lines, output) => + lines.some((line) => line.includes(marker)) && + lines.some((line) => /braid/iu.test(line)) && + !READY_ERROR.test(output) +} + +function composerKeyPredicate(snapshot, token) { + const keyLines = snapshot.lines.filter((line) => line.includes(token)) + const otherTokens = snapshot.lines.filter( + (line) => /perf-key-\d+/u.test(line) && !line.includes(token), + ) + return keyLines.length === 1 && otherTokens.length === 0 +} + +function composerEmptyPredicate(snapshot) { + return !snapshot.lines.some((line) => /perf-key-\d+/u.test(line)) +} + +function seedMarker(eventCount) { + return `Completed Braid performance conversation (${eventCount} committed events)` +} + +function startupTimingPath(fixture, state, index) { + return join(fixture.root, `.braid-startup-${state}-${index}.json`) +} + +function startupStages(result) { + const timing = result.startup + if (timing === undefined) throw new Error('Packed startup timing was not captured') + const duration = (end, start) => Number((end - start).toFixed(3)) + const compileCacheReadyEpochMs = timing.compileCacheReadyEpochMs ?? timing.scriptReadyEpochMs + const parallelReadyEpochMs = Math.max( + timing.applicationReadyEpochMs, + timing.terminalModulesReadyEpochMs, + ) + const applicationOpenMs = duration( + timing.applicationReadyEpochMs, + timing.applicationModulesReadyEpochMs, + ) + const terminalImportsMs = duration( + timing.terminalModulesReadyEpochMs, + timing.applicationModulesReadyEpochMs, + ) + const parallelStageMs = duration(parallelReadyEpochMs, timing.applicationModulesReadyEpochMs) + return Object.freeze({ + spawnMs: duration(timing.processStartEpochMs, result.startedAtEpochMs), + scriptMs: duration(timing.scriptReadyEpochMs, timing.processStartEpochMs), + compileCacheMs: duration(compileCacheReadyEpochMs, timing.scriptReadyEpochMs), + applicationImportsMs: duration(timing.applicationModulesReadyEpochMs, compileCacheReadyEpochMs), + applicationOpenMs, + terminalImportsMs, + parallelStageMs, + overlapSavedMs: Number( + Math.max(0, applicationOpenMs + terminalImportsMs - parallelStageMs).toFixed(3), + ), + terminalMs: duration(timing.terminalReadyEpochMs, parallelReadyEpochMs), + initializeMs: duration(timing.initializedEpochMs, timing.terminalReadyEpochMs), + firstRenderMs: duration(result.startedAtEpochMs + result.value, timing.initializedEpochMs), + }) +} + +export async function runProcessMeasurements(context, capture) { + const { + packed, + warmProcessFixture, + coldProcessFixture, + repetitions, + mode, + signal, + repeatCompileCachePath, + } = context + const count = mode === 'smoke' ? 1 : repetitions + const keyCount = mode === 'smoke' ? 1 : 1_000 + const resizeCount = mode === 'smoke' ? 10 : 1_000 + + await capture('PERF-01', async () => { + if (!packed) throw new Error(context.packedError ?? 'Packed candidate unavailable') + if (!warmProcessFixture) + throw new Error(context.processPrerequisiteError ?? 'Warm fixture unavailable') + if (!repeatCompileCachePath) + throw new Error(context.compileCacheError ?? 'Primed Node compile cache unavailable') + const results = [] + for (let index = 0; index < count; index += 1) { + const timingPath = startupTimingPath(warmProcessFixture, 'warm', index) + results.push( + await measureFirstVisibleFrame({ + ...packedOptions(packed, warmProcessFixture, { + environment: { + BRAID_STARTUP_TIMING_PATH: timingPath, + NODE_COMPILE_CACHE: repeatCompileCachePath, + }, + }), + signal, + startupTimingPath: timingPath, + readyFramePredicate: readyFramePredicate(seedMarker(10_000)), + }), + ) + } + const invalidCells = Math.max(...results.map((result) => result.frame.invalidCells), 0) + const readyFrames = results.filter((result) => result.value !== undefined).length + const compileCacheFrames = results.filter( + (result) => result.startup?.compileCacheEnabled === true, + ).length + return { + samples: results.map((result) => result.value), + unit: 'ms', + state: 'warm', + repetitions: results.length, + observations: { + readyFrames: observation( + readyFrames, + 'Every sample waited for the seeded useful ready frame', + ), + invalidCells: observation( + invalidCells, + invalidCells === 0 + ? 'All parsed frames had valid terminal cells' + : 'Invalid cells were observed', + ), + startupErrors: observation( + 0, + 'No startup or encrypted-storage error appeared before readiness', + ), + compileCacheFrames: observation( + compileCacheFrames, + 'Every repeat launch used the primed Node compile cache', + ), + compileCacheStatuses: results.map((result) => result.startup?.compileCacheStatus ?? null), + startupStages: results.map(startupStages), + }, + qualityPassed: + readyFrames === results.length && + compileCacheFrames === results.length && + invalidCells === 0, + provenance: { + seed: warmProcessFixture.database, + readyMarker: seedMarker(10_000), + frameReadiness: 'unique seeded final-content marker plus braid shell and no startup error', + compileCache: + 'primed exact packed startup modules; one shared cache across repeat launches', + }, + environment: environment({ + dimensions: '80x24', + database: 'warm-10000-event-encrypted-sqlite', + eventCount: 10_000, + }), + } + }) + + await capture('PERF-02', async () => { + if (!packed) throw new Error(context.packedError ?? 'Packed candidate unavailable') + if (!coldProcessFixture) + throw new Error(context.processPrerequisiteError ?? 'Cold fixture unavailable') + const results = [] + for (let index = 0; index < count; index += 1) { + const timingPath = startupTimingPath(coldProcessFixture, 'cold', index) + const compileCachePath = await mkdtemp(join(tmpdir(), 'braid-empty-compile-cache-')) + try { + results.push( + await measureFirstVisibleFrame({ + ...packedOptions(packed, coldProcessFixture, { + environment: { + BRAID_STARTUP_TIMING_PATH: timingPath, + NODE_COMPILE_CACHE: compileCachePath, + }, + }), + signal, + startupTimingPath: timingPath, + readyFramePredicate: readyFramePredicate(seedMarker(100_000)), + }), + ) + } finally { + await rm(compileCachePath, { force: true, recursive: true }) + } + } + const invalidCells = Math.max(...results.map((result) => result.frame.invalidCells), 0) + const readyFrames = results.filter((result) => result.value !== undefined).length + const compileCacheFrames = results.filter( + (result) => result.startup?.compileCacheEnabled === true, + ).length + return { + samples: results.map((result) => result.value), + unit: 'ms', + state: 'cold', + repetitions: results.length, + observations: { + readyFrames: observation( + readyFrames, + 'Every sample waited for the seeded useful ready frame', + ), + invalidCells: observation( + invalidCells, + invalidCells === 0 + ? 'All parsed frames had valid terminal cells' + : 'Invalid cells were observed', + ), + startupErrors: observation( + 0, + 'No startup or encrypted-storage error appeared before readiness', + ), + compileCacheFrames: observation( + compileCacheFrames, + 'Every first launch started with its own empty Node compile-cache directory', + ), + compileCacheStatuses: results.map((result) => result.startup?.compileCacheStatus ?? null), + startupStages: results.map(startupStages), + }, + qualityPassed: + readyFrames === results.length && + compileCacheFrames === results.length && + invalidCells === 0, + provenance: { + seed: coldProcessFixture.database, + readyMarker: seedMarker(100_000), + frameReadiness: 'unique seeded final-content marker plus braid shell and no startup error', + compileCache: 'fresh empty directory for every launch; no compiled startup modules reused', + }, + environment: environment({ + dimensions: '80x24', + database: 'cold-100000-event-encrypted-sqlite', + eventCount: 100_000, + }), + } + }) + + await capture('PERF-03', async () => { + if (!packed) throw new Error(context.packedError ?? 'Packed candidate unavailable') + if (!warmProcessFixture) + throw new Error(context.processPrerequisiteError ?? 'Warm fixture unavailable') + const result = await measureIdleKeyFrames({ + ...packedOptions(packed, warmProcessFixture), + signal, + count: keyCount, + readyFramePredicate: readyFramePredicate(seedMarker(10_000)), + keyFramePredicate: composerKeyPredicate, + emptyFramePredicate: composerEmptyPredicate, + }) + const invalidCells = result.frame.invalidCells + const allEditsBound = + result.edits.length === keyCount && + result.edits.every((edit) => edit.eraseFrameVersion > edit.keyFrameVersion) + return { + samples: result.samples, + rawSamples: result.samples, + unit: 'ms', + state: 'warm', + repetitions: result.samples.length, + observations: { + keyCount: observation( + result.edits.length, + 'Every unique composer edit reached a matching frame', + ), + eraseFrames: observation( + result.edits.filter((edit) => edit.eraseFrameVersion > edit.keyFrameVersion).length, + 'Every edit awaited a later composer-empty frame before the next edit', + ), + invalidCells: observation( + invalidCells, + invalidCells === 0 + ? 'The final parsed frame had valid terminal cells' + : 'Invalid cells were observed', + ), + editBindings: result.edits, + }, + qualityPassed: allEditsBound && invalidCells === 0, + provenance: { + seed: warmProcessFixture.database, + inputBinding: 'unique perf-key-N text accepted by the real PTY composer', + eraseBinding: 'Ctrl-U followed by a later frame with no perf-key-N token', + }, + environment: environment({ + dimensions: '80x24', + database: 'warm-10000-event-encrypted-sqlite', + eventCount: 10_000, + }), + } + }) + + await capture('PERF-07', async () => { + if (!packed) throw new Error(context.packedError ?? 'Packed candidate unavailable') + const measured = await measurePackedResizeStream({ + packageRoot: context.packageRoot, + resizeCount, + eventIntervalMs: mode === 'smoke' ? 4 : 8, + signal, + }) + const qualityPassed = + measured.qualityPassed && + measured.ratePassed && + measured.observedEventsPerSecond >= measured.minimumEventsPerSecond && + measured.renderedResizes === resizeCount && + measured.duplicateEvents === 0 && + measured.duplicateProviderEventIds === 0 && + measured.missingEvents === 0 && + measured.unexpectedEvents === 0 && + measured.invalidCells === 0 && + measured.activeDuringEveryResize && + !measured.staticTranscript + return { + samples: measured.samples, + rawSamples: measured.rawSamples, + unit: 'ms', + state: 'warm', + repetitions: measured.samples.length, + observations: mergeObservations( + { + producedEvents: observation( + measured.produced, + 'Generated events were counted inside the packed execution stream', + ), + acceptedEvents: observation( + measured.accepted, + 'Accepted events were counted from the packed application journal', + ), + renderedFrames: observation( + measured.rendered, + 'Pi render completions were counted after xterm flush', + ), + renderedResizes: observation( + measured.renderedResizes, + 'Every resize received a completed Pi render', + ), + elapsedMs: observation( + measured.elapsedMs, + 'The interval starts at the first generated event and ends after the final resize-bound frame', + ), + offeredEventsPerSecond: observation( + measured.offeredEventsPerSecond, + 'Offered rate uses generated events over the measured stream interval', + ), + acceptedEventsPerSecond: observation( + measured.acceptedEventsPerSecond, + measured.acceptedEventsPerSecond >= measured.minimumEventsPerSecond + ? 'Accepted rate met the required 100 events/s' + : 'Accepted rate was below the required 100 events/s', + ), + observedEventsPerSecond: observation( + measured.observedEventsPerSecond, + 'The pass rate is the accepted stream rate through the final resize-bound frame', + ), + producerEventsPerSecond: observation( + measured.producerEventsPerSecond, + 'Producer cadence uses the first through last generated event timestamps', + ), + minimumEventsPerSecond: observation( + measured.minimumEventsPerSecond, + 'The accepted stream must sustain at least this rate', + ), + minimumExpectedEvents: observation( + measured.minimumExpectedEvents, + 'Exact event-count equivalent of the 100 events/s requirement over this interval', + ), + ratePassed: observation( + measured.ratePassed ? 1 : 0, + 'Accepted stream rate passed the exact lower bound', + ), + duplicateEvents: observation( + measured.duplicateEvents, + 'No duplicate stream sequence markers were committed', + ), + duplicateProviderEventIds: observation( + measured.duplicateProviderEventIds, + 'No duplicate provider event IDs were committed', + ), + missingEvents: observation( + measured.missingEvents, + 'No generated stream marker was absent from the accepted journal', + ), + unexpectedEvents: observation( + measured.unexpectedEvents, + 'No accepted event had an unexpected stream marker', + ), + invalidCells: observation( + measured.invalidCells, + 'Every reference-size frame contained valid cells', + ), + activeDuringEveryResize: observation( + measured.activeDuringEveryResize ? 1 : 0, + 'The run stayed active through every resize', + ), + staticTranscript: observation( + measured.staticTranscript ? 1 : 0, + 'Accepted event counts changed while resizing', + ), + perResize: measured.perResize, + }, + {}, + ), + qualityPassed, + failureReasons: measured.failureReasons, + details: { + streamElapsedMs: measured.streamElapsedMs, + firstGeneratedAt: measured.firstGeneratedAt, + lastGeneratedAt: measured.lastGeneratedAt, + finalResizeFrameCompletedAt: measured.finalResizeFrameCompletedAt, + intendedEventsPerSecond: measured.intendedEventsPerSecond, + producerEventsPerSecond: measured.producerEventsPerSecond, + offeredEventsPerSecond: measured.offeredEventsPerSecond, + acceptedEventsPerSecond: measured.acceptedEventsPerSecond, + observedEventsPerSecond: measured.observedEventsPerSecond, + minimumEventsPerSecond: measured.minimumEventsPerSecond, + minimumExpectedEvents: measured.minimumExpectedEvents, + ratePassed: measured.ratePassed, + }, + provenance: measured.provenance, + environment: environment({ + dimensions: '40x12,80x24,120x40,200x60', + database: 'packed headless 100-events-per-second stream', + eventCount: measured.accepted, + }), + } + }) + + await capture('PERF-08', async () => { + if (!packed) throw new Error(context.packedError ?? 'Packed candidate unavailable') + const durationMs = + mode === 'smoke' + ? SMOKE_IDLE_DURATION_MS + : Number(process.env.BRAID_PERFORMANCE_IDLE_DURATION_MS ?? 60_000) + const settleMs = + mode === 'smoke' + ? SMOKE_IDLE_SETTLE_MS + : Number(process.env.BRAID_PERFORMANCE_IDLE_SETTLE_MS ?? 2_000) + const result = [] + for (let index = 0; index < (mode === 'smoke' ? 1 : 2); index += 1) { + const sample = await measureIdleCpu({ + ...fixtureOptions(packed, { signal }), + durationMs, + settleMs, + }) + assertFullDuration(sample.elapsedSeconds * 1_000, durationMs) + result.push(sample) + } + return { + samples: result.map((sample) => sample.value), + rawSamples: result.map((sample) => sample.value), + unit: '% of one core', + state: 'warm', + repetitions: result.length, + observations: { + durationSeconds: result.map((sample) => sample.elapsedSeconds), + cpuSeconds: result.map((sample) => sample.cpuSeconds), + requiredDurationSeconds: durationMs / 1_000, + }, + qualityPassed: result.every((sample) => sample.elapsedSeconds * 1_000 >= durationMs), + provenance: { + settlingSeconds: settleMs / 1_000, + measuredSeconds: durationMs / 1_000, + cpuMeter: process.platform === 'linux' ? '/proc//stat' : 'ps', + commandPath: 'packed deterministic Braid process', + }, + environment: environment({ + dimensions: '80x24', + database: 'deterministic-offline-fixture-idle', + eventCount: 0, + }), + } + }) +} diff --git a/scripts/performance/process-probes.mjs b/scripts/performance/process-probes.mjs new file mode 100644 index 0000000..b47a558 --- /dev/null +++ b/scripts/performance/process-probes.mjs @@ -0,0 +1,433 @@ +import { spawn } from 'node:child_process' +import { readFile } from 'node:fs/promises' +import { performance } from 'node:perf_hooks' +import { setTimeout as sleep } from 'node:timers/promises' +import xterm from '@xterm/headless' +import * as pty from 'node-pty' + +const XtermTerminal = xterm.Terminal +const REFERENCE_DIMENSIONS = Object.freeze([ + Object.freeze([40, 12]), + Object.freeze([80, 24]), + Object.freeze([120, 40]), + Object.freeze([200, 60]), +]) +const FRAME_TIMEOUT_MS = 2_000 +const activeSessions = new Set() + +function assert(condition, message) { + if (!condition) throw new Error(message) +} + +async function waitFor(predicate, label, timeoutMs = FRAME_TIMEOUT_MS, signal) { + const deadline = Date.now() + timeoutMs + while (!predicate()) { + if (signal?.aborted) throw new Error(`Interrupted while waiting for ${label}`) + if (Date.now() >= deadline) throw new Error(`Timed out waiting for ${label}`) + await sleep(2) + } +} + +function visibleText(terminal) { + const lines = [] + const buffer = terminal.buffer.active + for (let index = 0; index < terminal.rows; index += 1) { + const line = buffer.getLine(buffer.viewportY + index) + lines.push(line?.translateToString(true) ?? '') + } + return lines +} + +function hasVisibleFrame(terminal) { + return visibleText(terminal).some((line) => line.includes('braid')) +} + +function hasStartupError(output) { + return /(?:PRODUCTION_|STORAGE_|CREDENTIAL_STORE_UNAVAILABLE|startup error|encrypted storage)/iu.test( + output, + ) +} + +function invalidCellCount(terminal) { + const buffer = terminal.buffer.active + let invalid = 0 + for (let row = 0; row < terminal.rows; row += 1) { + const line = buffer.getLine(buffer.viewportY + row) + if (!line) { + invalid += terminal.cols + continue + } + for (let column = 0; column < terminal.cols; column += 1) { + try { + const cell = line.getCell(column) + if (!cell || typeof cell.getChars !== 'function') invalid += 1 + } catch { + invalid += 1 + } + } + } + return invalid +} + +function readLinuxCpuSeconds(pid) { + return readFile(`/proc/${pid}/stat`, 'utf8').then((value) => { + const closingParenthesis = value.lastIndexOf(')') + if (closingParenthesis < 0) throw new Error(`Could not parse /proc/${pid}/stat`) + const fields = value + .slice(closingParenthesis + 2) + .trim() + .split(/\s+/u) + const userTicks = Number(fields[11]) + const systemTicks = Number(fields[12]) + if (!Number.isFinite(userTicks) || !Number.isFinite(systemTicks)) { + throw new Error(`Could not read CPU ticks for process ${pid}`) + } + return (userTicks + systemTicks) / 100 + }) +} + +async function readCpuSeconds(pid) { + if (process.platform === 'linux') return readLinuxCpuSeconds(pid) + if (process.platform === 'darwin') { + return new Promise((resolve, reject) => { + const child = spawn('ps', ['-o', 'time=', '-p', String(pid)], { + stdio: ['ignore', 'pipe', 'pipe'], + }) + let stdout = '' + let stderr = '' + child.stdout.setEncoding('utf8') + child.stderr.setEncoding('utf8') + child.stdout.on('data', (chunk) => { + stdout += chunk + }) + child.stderr.on('data', (chunk) => { + stderr += chunk + }) + child.once('error', reject) + child.once('close', (code) => { + if (code !== 0) { + reject(new Error(`ps could not read process CPU: ${stderr}`)) + return + } + const value = stdout.trim() + const [dayPart, clockPart] = value.includes('-') ? value.split('-', 2) : [undefined, value] + const clock = clockPart?.split(':').map(Number) ?? [] + if ( + clock.some((part) => !Number.isFinite(part)) || + (clock.length !== 2 && clock.length !== 3) + ) { + reject(new Error(`Could not parse ps CPU time: ${stdout}`)) + return + } + const days = dayPart === undefined ? 0 : Number(dayPart) + const seconds = clock.at(-1) ?? 0 + const minutes = clock.at(-2) ?? 0 + const hours = clock.length === 3 ? (clock.at(-3) ?? 0) : 0 + if (!Number.isFinite(days)) { + reject(new Error(`Could not parse ps CPU days: ${stdout}`)) + return + } + resolve(days * 86_400 + hours * 3_600 + minutes * 60 + seconds) + }) + }) + } + throw new Error( + `PERF-08 requires a supported child-process CPU meter; ${process.platform} is unsupported`, + ) +} + +export function assertProcessPrerequisites() { + if (!['linux', 'darwin', 'win32'].includes(process.platform)) { + throw new Error(`Performance proof is unsupported on ${process.platform}`) + } + if (process.platform === 'win32') { + throw new Error('PERF-08 child-process CPU measurement is not implemented on win32') + } + assert(typeof pty.spawn === 'function', 'node-pty is required for packed process measurements') + assert(typeof XtermTerminal === 'function', '@xterm/headless is required for frame measurements') +} + +export async function openPackedTui(options) { + const columns = options.columns ?? 80 + const rows = options.rows ?? 24 + const terminal = new XtermTerminal({ + cols: columns, + rows, + disableStdin: true, + allowProposedApi: true, + }) + const startedAt = performance.now() + const startedAtEpochMs = performance.timeOrigin + startedAt + const environment = { + ...process.env, + TERM: 'xterm-256color', + NO_COLOR: '1', + NODE_NO_WARNINGS: '1', + ...(options.environment ?? {}), + } + delete environment.FORCE_COLOR + const args = [options.binary, ...(options.args ?? [])] + const session = pty.spawn(process.execPath, args, { + name: 'xterm-256color', + cols: columns, + rows, + cwd: options.cwd, + env: environment, + }) + let output = '' + let frameVersion = 0 + let firstVisibleFrameMs + let exited = false + let exitValue + const exitPromise = new Promise((resolve) => { + session.onExit((value) => { + exited = true + exitValue = value + resolve(value) + }) + }) + session.onData((chunk) => { + output = `${output}${chunk}`.slice(-2_000_000) + terminal.write(chunk, () => { + frameVersion += 1 + const lines = visibleText(terminal) + const ready = options.readyFramePredicate + ? options.readyFramePredicate(lines, output) + : hasVisibleFrame(terminal) + if (firstVisibleFrameMs === undefined && ready) { + firstVisibleFrameMs = performance.now() - startedAt + } + }) + }) + + const handle = { + get pid() { + return session.pid + }, + get output() { + return output + }, + get exited() { + return exited + }, + get exitValue() { + return exitValue + }, + get frameVersion() { + return frameVersion + }, + get firstVisibleFrameMs() { + return firstVisibleFrameMs + }, + get startedAtEpochMs() { + return startedAtEpochMs + }, + get terminal() { + return terminal + }, + get dimensions() { + return { columns: terminal.cols, rows: terminal.rows } + }, + send(value) { + session.write(value) + }, + resize(nextColumns, nextRows) { + session.resize(nextColumns, nextRows) + }, + async waitForInitialFrame(timeoutMs = 10_000) { + await waitFor( + () => firstVisibleFrameMs !== undefined, + 'packed TUI first visible frame', + timeoutMs, + options.signal, + ) + assert(!hasStartupError(output), 'Packed TUI emitted a startup or storage error') + return firstVisibleFrameMs + }, + async waitForNextFrame(previousVersion, timeoutMs = FRAME_TIMEOUT_MS) { + await waitFor( + () => frameVersion > previousVersion && !exited, + 'updated terminal frame', + timeoutMs, + options.signal, + ) + return frameVersion + }, + snapshot() { + return Object.freeze({ + lines: Object.freeze(visibleText(terminal)), + invalidCells: invalidCellCount(terminal), + frameVersion, + dimensions: { columns: terminal.cols, rows: terminal.rows }, + }) + }, + async stop() { + if (exited) return exitValue + session.write('\u0003') + await waitFor( + () => + output.includes('press ctrl+c again to quit') || output.includes('ctrl+c again to quit'), + 'packed TUI safe-exit prompt', + 5_000, + ) + session.write('\u0003') + await Promise.race([ + exitPromise, + sleep(5_000).then(() => { + session.kill() + throw new Error('Packed TUI process did not exit after safe shutdown') + }), + ]) + if (exitValue?.exitCode !== 0) + throw new Error(`Packed TUI exited with ${exitValue?.exitCode}`) + return exitValue + }, + async kill() { + if (!exited) session.kill() + await Promise.race([exitPromise, sleep(1_000)]) + }, + } + activeSessions.add(handle) + exitPromise.finally(() => activeSessions.delete(handle)).catch(() => undefined) + return handle +} + +export async function cleanupProcessProbes() { + await Promise.all([...activeSessions].map((session) => session.kill())) +} + +export async function measureFirstVisibleFrame(options) { + const session = await openPackedTui(options) + try { + const firstVisibleFrameMs = await session.waitForInitialFrame() + const startup = + options.startupTimingPath === undefined + ? undefined + : JSON.parse(await readFile(options.startupTimingPath, 'utf8')) + await session.stop() + return { + value: firstVisibleFrameMs, + frame: session.snapshot(), + exited: session.exited, + startedAtEpochMs: session.startedAtEpochMs, + ...(startup === undefined ? {} : { startup }), + } + } finally { + await session.kill() + } +} + +export async function measureIdleKeyFrames(options) { + const session = await openPackedTui(options) + const samples = [] + const edits = [] + const tokens = [] + const keyFramePredicate = + options.keyFramePredicate ?? + ((snapshot, token) => snapshot.lines.filter((line) => line.includes(token)).length === 1) + const emptyFramePredicate = + options.emptyFramePredicate ?? + ((snapshot, token) => !snapshot.lines.some((line) => line.includes(token))) + try { + await session.waitForInitialFrame() + for (let index = 0; index < options.count; index += 1) { + const token = `perf-key-${String(index + 1).padStart(5, '0')}` + tokens.push(token) + const previousVersion = session.frameVersion + const startedAt = performance.now() + session.send(token) + await waitFor( + () => + session.frameVersion > previousVersion && keyFramePredicate(session.snapshot(), token), + `composer frame containing ${token}`, + FRAME_TIMEOUT_MS, + options.signal, + ) + const keyFrame = session.snapshot() + samples.push(performance.now() - startedAt) + const eraseVersion = session.frameVersion + session.send('\u0015') + await waitFor( + () => session.frameVersion > eraseVersion && emptyFramePredicate(session.snapshot(), token), + `empty composer frame after ${token}`, + FRAME_TIMEOUT_MS, + options.signal, + ) + const eraseFrame = session.snapshot() + edits.push({ + index: index + 1, + token, + keyFrameVersion: keyFrame.frameVersion, + eraseFrameVersion: eraseFrame.frameVersion, + keyFrame: keyFrame.lines, + eraseFrame: eraseFrame.lines, + }) + } + const frame = session.snapshot() + await session.stop() + return { samples, frame, edits, tokens } + } finally { + await session.kill() + } +} + +export async function measureResizeStream(options) { + const session = await openPackedTui(options) + const samples = [] + let crashes = 0 + try { + await session.waitForInitialFrame() + session.send(`${'resize stream '.repeat(1_000)}\r`) + for (let index = 0; index < options.count; index += 1) { + const [columns, rows] = REFERENCE_DIMENSIONS[index % REFERENCE_DIMENSIONS.length] + const previousVersion = session.frameVersion + const startedAt = performance.now() + session.resize(columns, rows) + try { + await session.waitForNextFrame(previousVersion) + } catch (error) { + if (session.exited) crashes += 1 + throw error + } + samples.push(performance.now() - startedAt) + const remaining = 10 - (performance.now() - startedAt) + if (remaining > 0) await sleep(remaining) + } + const frame = session.snapshot() + await session.stop() + return { samples, frame, crashes } + } finally { + await session.kill() + } +} + +export async function measureIdleCpu(options) { + const session = await openPackedTui(options) + try { + await session.waitForInitialFrame(10_000) + await sleep(options.settleMs ?? 2_000, undefined, { signal: options.signal }) + const startedAt = performance.now() + const cpuStart = await readCpuSeconds(session.pid) + await sleep(options.durationMs ?? 60_000, undefined, { signal: options.signal }) + const cpuEnd = await readCpuSeconds(session.pid) + const elapsedSeconds = (performance.now() - startedAt) / 1_000 + const cpuSeconds = cpuEnd - cpuStart + assert( + elapsedSeconds >= (options.durationMs ?? 60_000) / 1_000 - 0.25, + 'PERF-08 did not run its full duration', + ) + assert(cpuSeconds >= 0, 'PERF-08 child CPU time moved backwards') + const frame = session.snapshot() + await session.stop() + return { + value: (cpuSeconds / elapsedSeconds) * 100, + elapsedSeconds, + cpuSeconds, + frame, + } + } finally { + await session.kill() + } +} + +export { REFERENCE_DIMENSIONS } diff --git a/scripts/performance/reporting.mjs b/scripts/performance/reporting.mjs new file mode 100644 index 0000000..632a194 --- /dev/null +++ b/scripts/performance/reporting.mjs @@ -0,0 +1,236 @@ +import { execFile as execFileCallback } from 'node:child_process' +import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises' +import os from 'node:os' +import { promisify } from 'node:util' +import { + REQUIRED_PERFORMANCE_IDS, + REQUIRED_PERFORMANCE_TARGETS, + validatePerformanceMatrix, +} from '../release-evidence.mjs' +import { createPerformanceMeasurement, releaseMeasurement } from './statistics.mjs' + +const execFile = promisify(execFileCallback) + +export const repository = new URL('../../', import.meta.url).pathname.replace(/\/$/u, '') +export const outputDirectory = `${repository}/artifacts/verification/performance` +export const rawReportPath = `${outputDirectory}/raw.json` +export const smokeReportPath = `${outputDirectory}/smoke.json` +export const releaseReportPath = `${outputDirectory}/release-measurements.json` +export const command = process.env.BRAID_PERFORMANCE_COMMAND ?? 'pnpm run test:performance' +export const FULL_REPETITIONS = 20 +export const processTerminal = + 'node-pty@1.1.0 + @xterm/headless@6.0.0 + @earendil-works/pi-tui@0.83.0' +export const virtualTerminal = '@xterm/headless@6.0.0 + @earendil-works/pi-tui@0.83.0' +const packedProductionTuiChild = new URL('./packed-production-tui-child.mjs', import.meta.url) + +export function errorReason(error) { + if (error instanceof Error) return `${error.name}: ${error.message}` + return String(error) +} + +export async function gitRevision() { + try { + const result = await execFile('git', ['rev-parse', 'HEAD'], { cwd: repository }) + return result.stdout.trim() + } catch (error) { + return `unavailable: ${errorReason(error)}` + } +} + +export async function packageVersions() { + try { + const value = JSON.parse(await readFile(`${repository}/package.json`, 'utf8')) + return Object.freeze({ + braid: value.version, + piTui: value.dependencies?.['@earendil-works/pi-tui'], + nodePty: value.devDependencies?.['node-pty'], + xtermHeadless: value.devDependencies?.['@xterm/headless'], + sqlite: value.dependencies?.['better-sqlite3-multiple-ciphers'], + }) + } catch (error) { + return Object.freeze({ error: errorReason(error) }) + } +} + +export function hardwareDescription() { + const cpu = os.cpus()[0] + return `${os.hostname()} (${cpu?.model ?? 'unknown CPU'}; ${os.arch()}; ${os.cpus().length} logical CPUs)` +} + +export function environment({ dimensions, database, eventCount, terminal = processTerminal }) { + return Object.freeze({ + machine: hardwareDescription(), + os: `${process.platform} ${os.release()}`, + node: process.version, + terminal, + dimensions, + database, + eventCount, + }) +} + +export function packedOptions(packed, fixture, extra = {}) { + const { environment: extraEnvironment, ...remaining } = extra + if (fixture?.processMode === 'isolated-production-pty') { + return { + binary: packedProductionTuiChild.pathname, + cwd: repository, + args: [ + fixture.path, + fixture.root, + fixture.keyPath, + fixture.credentialRoot, + fixture.packageRoot, + ], + environment: { + BRAID_PERFORMANCE_PROCESS_MODE: 'isolated-production-pty', + ...extraEnvironment, + }, + ...remaining, + } + } + return { + binary: packed.binary, + cwd: repository, + ...(fixture === undefined + ? {} + : { + args: ['--workspace', fixture.root, '--config', fixture.configPath, '--no-color'], + environment: { BRAID_STATE_PATH: fixture.path, ...extraEnvironment }, + }), + ...(fixture === undefined && extraEnvironment !== undefined + ? { environment: extraEnvironment } + : {}), + ...remaining, + } +} + +export function fixtureOptions(packed, extra = {}) { + return { + binary: packed.binary, + cwd: repository, + args: ['--fixture', 'deterministic', '--no-color'], + environment: { + BRAID_FIXTURE_CHUNK_DELAY_MS: String(extra.chunkDelayMs ?? 10), + ...(extra.environment ?? {}), + }, + ...(extra.signal === undefined ? {} : { signal: extra.signal }), + } +} + +export function probeCommand(name, mode = 'full') { + return `${command} [${name}; ${mode}]` +} + +export function mergePackedProvenance(input, context) { + return { + ...(input.provenance ?? {}), + packedPackageRoot: context.packageRoot, + packedTarballSha256: context.tarballSha256, + } +} + +export function pushUnavailable(measurements, name, reason) { + const entry = { kind: 'unavailable', name, reason: errorReason(reason) } + measurements.push(entry) + return null +} + +export async function capture(measurements, name, build, context) { + try { + context.throwIfAborted() + const input = await build() + context.throwIfAborted() + const measurement = createPerformanceMeasurement({ + ...input, + name, + command: probeCommand(name, context.mode), + provenance: mergePackedProvenance(input, context), + allowSingleSample: context.mode === 'smoke', + }) + measurements.push(measurement) + return measurement + } catch (error) { + if (context.signal.aborted) throw error + return pushUnavailable(measurements, name, error) + } +} + +export function expectedPerformanceNames(measurements) { + const expected = new Set(REQUIRED_PERFORMANCE_IDS) + for (const measurement of measurements) expected.delete(measurement.name) + return [...expected] +} + +export function assertSmokeMeasurements(measurements, expectedNames = REQUIRED_PERFORMANCE_IDS) { + const expected = new Set(expectedNames) + for (const measurement of measurements) expected.delete(measurement.name) + if (expected.size > 0) throw new Error(`Smoke missed rows: ${[...expected].join(', ')}`) + for (const measurement of measurements) { + if (measurement.kind !== 'distribution') + throw new Error(`${measurement.name} was ${measurement.kind}, not measured`) + if (!(measurement.n >= 1)) throw new Error(`${measurement.name} has no smoke samples`) + if (!Array.isArray(measurement.rawSamples) || measurement.rawSamples.length === 0) + throw new Error(`${measurement.name} has no raw smoke samples`) + const failureReasons = Array.isArray(measurement.failureReasons) + ? measurement.failureReasons.filter( + (reason) => typeof reason === 'string' && reason.trim().length > 0, + ) + : [] + if (measurement.passed !== true) { + if (failureReasons.length === 0) + throw new Error(`${measurement.name} failed without a concrete failure reason`) + throw new Error(`${measurement.name} failed its smoke target: ${failureReasons.join('; ')}`) + } + if (failureReasons.length > 0) + throw new Error( + `${measurement.name} passed with failure reasons: ${failureReasons.join('; ')}`, + ) + } + return measurements +} + +export async function writeReports(measurements, report, mode) { + await mkdir(outputDirectory, { recursive: true, mode: 0o700 }) + await chmod(outputDirectory, 0o700) + const complete = + measurements.length === REQUIRED_PERFORMANCE_IDS.length && + measurements.every( + (measurement) => measurement.kind === 'distribution' && measurement.passed === true, + ) + const releaseMeasurements = measurements + .filter((measurement) => measurement.kind === 'distribution') + .map(releaseMeasurement) + let releaseValidation = { passed: false, reason: 'smoke report has no release validation' } + if (mode === 'full') { + try { + validatePerformanceMatrix(releaseMeasurements, 'performance release projection') + releaseValidation = { passed: true } + } catch (error) { + releaseValidation = { passed: false, reason: errorReason(error) } + } + } + const raw = { + schemaVersion: 1, + kind: 'braid-performance-proof', + mode, + status: complete && (mode === 'smoke' || releaseValidation.passed) ? 'passed' : 'failed', + generatedAt: new Date().toISOString(), + command, + thresholds: REQUIRED_PERFORMANCE_TARGETS, + releaseValidation, + ...report, + measurements, + releaseMeasurements, + } + const targetPath = mode === 'smoke' ? smokeReportPath : rawReportPath + await writeFile(targetPath, `${JSON.stringify(raw, null, 2)}\n`, { mode: 0o600 }) + if (mode === 'full') { + await writeFile( + releaseReportPath, + `${JSON.stringify({ schemaVersion: 1, measurements: releaseMeasurements }, null, 2)}\n`, + { mode: 0o600 }, + ) + } + return { complete, releaseValidation, path: targetPath } +} diff --git a/scripts/performance/resize-probe.mjs b/scripts/performance/resize-probe.mjs new file mode 100644 index 0000000..e77822d --- /dev/null +++ b/scripts/performance/resize-probe.mjs @@ -0,0 +1,344 @@ +import { performance } from 'node:perf_hooks' +import { setTimeout as sleep } from 'node:timers/promises' +import { TUI } from '@earendil-works/pi-tui' +import { HeadlessTerminal } from './headless-terminal.mjs' +import { loadPackedRuntime } from './packed-runtime.mjs' + +const REFERENCE_DIMENSIONS = Object.freeze([ + Object.freeze([40, 12]), + Object.freeze([80, 24]), + Object.freeze([120, 40]), + Object.freeze([200, 60]), +]) +const PROFILE = Object.freeze({ name: 'Braid performance profile', harness: 'pi' }) +const FIXED_TIME = '2026-08-03T00:00:00.000Z' + +function assert(condition, message) { + if (!condition) throw new Error(message) +} + +function invalidCellCount(terminal) { + const buffer = terminal.xterm.buffer.active + let invalid = 0 + for (let row = 0; row < terminal.rows; row += 1) { + const line = buffer.getLine(buffer.viewportY + row) + for (let column = 0; column < terminal.columns; column += 1) { + const cell = line?.getCell(column) + if (!cell || typeof cell.getChars !== 'function') invalid += 1 + } + } + return invalid +} + +async function waitFor(predicate, label, signal, timeoutMs = 2_000) { + const deadline = Date.now() + timeoutMs + while (!predicate()) { + if (signal?.aborted) throw new Error(`Interrupted while waiting for ${label}`) + if (Date.now() >= deadline) throw new Error(`Timed out waiting for ${label}`) + await sleep(2) + } +} + +function streamingExecution({ intervalMs, totalEvents, generated, capabilities }) { + return { + admissionMode: 'sync', + capabilities: () => capabilities, + async *streamTurn(input) { + for (let sequence = 1; sequence <= totalEvents; sequence += 1) { + if (input.signal.aborted) return + if (sequence > 1) await sleep(intervalMs, undefined, { signal: input.signal }) + const generatedAt = performance.now() + generated.events.push({ sequence, generatedAt }) + generated.firstGeneratedAt ??= generatedAt + generated.lastGeneratedAt = generatedAt + yield { + type: 'text_delta', + text: `resize-event-${String(sequence).padStart(6, '0')} `, + timestamp: FIXED_TIME, + } + } + if (!input.signal.aborted) { + yield { + type: 'final', + status: 'completed', + reason: 'resize performance stream complete', + text: 'resize performance stream complete', + timestamp: FIXED_TIME, + metadata: { inputTokens: 1, outputTokens: totalEvents, model: 'fixture/resize' }, + } + } + }, + async cancelRun(input) { + return { operationId: input.operationId, outcome: 'accepted' } + }, + } +} + +export function evaluateResizeStreamRate({ + produced, + accepted, + streamElapsedMs, + minimumEventsPerSecond = 100, +}) { + const elapsedSeconds = streamElapsedMs / 1_000 + const offeredEventsPerSecond = produced / elapsedSeconds + const acceptedEventsPerSecond = accepted / elapsedSeconds + const minimumExpectedEvents = Math.ceil(minimumEventsPerSecond * elapsedSeconds) + return { + streamElapsedMs, + offeredEventsPerSecond, + acceptedEventsPerSecond, + observedEventsPerSecond: acceptedEventsPerSecond, + minimumEventsPerSecond, + minimumExpectedEvents, + passed: + Number.isFinite(acceptedEventsPerSecond) && acceptedEventsPerSecond >= minimumEventsPerSecond, + } +} + +export async function measurePackedResizeStream({ + packageRoot, + resizeCount = 1_000, + eventIntervalMs = 10, + signal, +} = {}) { + const runtime = await loadPackedRuntime(packageRoot) + const generated = { + events: [], + firstGeneratedAt: undefined, + lastGeneratedAt: undefined, + } + const terminal = new HeadlessTerminal(80, 24) + const tui = new TUI(terminal) + const app = runtime.index.createBraidApplication({ + execution: streamingExecution({ + intervalMs: eventIntervalMs, + totalEvents: resizeCount * 20, + generated, + capabilities: runtime.index.DEFAULT_RUN_CAPABILITIES, + }), + profile: PROFILE, + clock: new runtime.clock.FixedClock(FIXED_TIME), + journal: new runtime.index.MemoryJournal(new runtime.clock.FixedClock(FIXED_TIME)), + }) + const controller = runtime.tui.createApplicationUiController(app, { + color: 'none', + reducedMotion: true, + }) + const terminalApp = new runtime.terminal.BraidTerminalApp({ + controller, + tui, + theme: runtime.theme.createBraidTheme({ colors: false, reducedMotion: true }), + workspace: '/performance-resize', + nextOperationId: (() => { + let next = 0 + return () => `op-perf-resize-ui-${++next}` + })(), + }) + const completedFrames = [] + let resizeEpoch = 0 + let runId + let terminalStarted = false + let renderFailure + const originalDoRender = tui.doRender.bind(tui) + tui.doRender = () => { + const renderEpoch = resizeEpoch + try { + originalDoRender() + void terminal.flush().then(() => { + completedFrames.push({ + epoch: renderEpoch, + completedAt: performance.now(), + columns: terminal.columns, + rows: terminal.rows, + invalidCells: invalidCellCount(terminal), + }) + }) + } catch (error) { + renderFailure = error + } + } + + const perResize = [] + try { + if (signal?.aborted) throw new Error('Interrupted before PERF-07 started') + app.initialize('/performance-resize') + void terminalApp.start() + terminalStarted = true + await terminal.flush() + const receipt = app.send({ + operationId: 'op-perf-resize-start', + text: 'keep the resize stream active while the terminal changes size', + }) + await receipt.admissionReady + runId = receipt.runId + await waitFor(() => app.state().activeRunId === runId, 'PERF-07 active run admission', signal) + await waitFor( + () => generated.firstGeneratedAt !== undefined, + 'PERF-07 first generated stream event', + signal, + ) + + for (let index = 0; index < resizeCount; index += 1) { + if (signal?.aborted) throw new Error('Interrupted during PERF-07 resize stream') + if (renderFailure) throw renderFailure + const [columns, rows] = REFERENCE_DIMENSIONS[index % REFERENCE_DIMENSIONS.length] + const epoch = ++resizeEpoch + const resizeStartedAt = performance.now() + terminal.resize(columns, rows) + let resizeFrame + await waitFor( + () => { + resizeFrame = completedFrames.find((frame) => frame.epoch === epoch) + return resizeFrame + }, + `Pi render after resize ${index + 1}`, + signal, + ) + if (!resizeFrame) throw new Error(`PERF-07 resize ${index + 1} had no completed frame`) + const state = app.state() + const run = state.runs.find((candidate) => candidate.id === runId) + const active = state.activeRunId === runId && run?.complete === false + const cells = invalidCellCount(terminal) + const dimensions = { columns: terminal.columns, rows: terminal.rows } + assert( + dimensions.columns === columns && dimensions.rows === rows, + 'Resize dimensions changed', + ) + perResize.push({ + index: index + 1, + requested: { columns, rows }, + dimensions, + elapsedMs: performance.now() - resizeStartedAt, + frameCompletions: completedFrames.length, + frameCompletedAt: resizeFrame.completedAt, + acceptedEvents: run?.eventCount ?? 0, + active, + invalidCells: cells, + }) + if (!active) throw new Error(`PERF-07 stream completed before resize ${index + 1}`) + if (cells !== 0) + throw new Error(`PERF-07 found ${cells} invalid cells at resize ${index + 1}`) + const remaining = eventIntervalMs - (performance.now() - resizeStartedAt) + if (remaining > 0) await sleep(remaining, undefined, { signal }) + } + + const finalResizeFrame = perResize.at(-1) + const streamElapsedMs = + finalResizeFrame?.frameCompletedAt === undefined || generated.firstGeneratedAt === undefined + ? Number.NaN + : finalResizeFrame.frameCompletedAt - generated.firstGeneratedAt + const events = app.events().filter((envelope) => envelope.event.kind === 'run.text.delta') + const eventIds = events.map((envelope) => envelope.event.provider?.eventId) + const uniqueEventIds = new Set(eventIds) + const acceptedSequences = events.flatMap((envelope) => { + const match = /^resize-event-(\d{6}) /u.exec(envelope.event.text) + return match ? [Number(match[1])] : [] + }) + const acceptedSequenceSet = new Set(acceptedSequences) + const expectedSequences = generated.events.map((event) => event.sequence) + const produced = expectedSequences.length + const accepted = acceptedSequences.length + const missingEvents = expectedSequences.filter( + (sequence) => !acceptedSequenceSet.has(sequence), + ).length + const duplicateEvents = acceptedSequences.length - acceptedSequenceSet.size + const duplicateProviderEventIds = eventIds.length - uniqueEventIds.size + const unexpectedEvents = acceptedSequences.filter( + (sequence) => !expectedSequences.includes(sequence), + ).length + const rate = evaluateResizeStreamRate({ + produced, + accepted, + streamElapsedMs, + }) + const staticSamples = new Set(perResize.map((sample) => sample.acceptedEvents)).size <= 1 + const failureReasons = [] + if (perResize.length !== resizeCount) + failureReasons.push(`only ${perResize.length}/${resizeCount} resizes completed`) + if (!Number.isFinite(streamElapsedMs) || streamElapsedMs <= 0) + failureReasons.push( + 'the stream interval did not span generated input through the final resize frame', + ) + if (produced < rate.minimumExpectedEvents) + failureReasons.push( + `only ${produced} events offered; expected at least ${rate.minimumExpectedEvents} at 100 events/s`, + ) + if (accepted < rate.minimumExpectedEvents) + failureReasons.push( + `only ${accepted} events accepted; expected at least ${rate.minimumExpectedEvents} at 100 events/s`, + ) + if (!rate.passed) + failureReasons.push( + `accepted stream rate ${rate.acceptedEventsPerSecond.toFixed(2)} events/s is below 100`, + ) + if (duplicateEvents !== 0) + failureReasons.push(`found ${duplicateEvents} duplicate stream events`) + if (duplicateProviderEventIds !== 0) + failureReasons.push(`found ${duplicateProviderEventIds} duplicate provider event IDs`) + if (missingEvents !== 0) failureReasons.push(`found ${missingEvents} missing stream events`) + if (unexpectedEvents !== 0) + failureReasons.push(`found ${unexpectedEvents} unexpected stream event markers`) + if (perResize.some((sample) => !sample.active)) + failureReasons.push('the run was not active for every resize') + if (staticSamples) failureReasons.push('accepted event count was static during resizing') + if (perResize.some((sample) => sample.invalidCells !== 0)) + failureReasons.push('invalid terminal cells were observed') + return { + samples: perResize.map((sample) => sample.elapsedMs), + rawSamples: perResize.map((sample) => ({ ...sample })), + produced, + accepted, + rendered: completedFrames.length, + renderedResizes: perResize.length, + elapsedMs: streamElapsedMs, + streamElapsedMs, + firstGeneratedAt: generated.firstGeneratedAt, + lastGeneratedAt: generated.lastGeneratedAt, + finalResizeFrameCompletedAt: finalResizeFrame?.frameCompletedAt, + offeredEventsPerSecond: rate.offeredEventsPerSecond, + acceptedEventsPerSecond: rate.acceptedEventsPerSecond, + observedEventsPerSecond: rate.observedEventsPerSecond, + producerEventsPerSecond: + generated.lastGeneratedAt === undefined || generated.firstGeneratedAt === undefined + ? Number.NaN + : produced / ((generated.lastGeneratedAt - generated.firstGeneratedAt) / 1_000), + intendedEventsPerSecond: 1_000 / eventIntervalMs, + minimumEventsPerSecond: rate.minimumEventsPerSecond, + minimumExpectedEvents: rate.minimumExpectedEvents, + ratePassed: rate.passed, + duplicateEvents, + duplicateProviderEventIds, + missingEvents, + unexpectedEvents, + invalidCells: Math.max(...perResize.map((sample) => sample.invalidCells), 0), + activeDuringEveryResize: perResize.every((sample) => sample.active), + staticTranscript: staticSamples, + perResize, + qualityPassed: failureReasons.length === 0, + failureReasons, + provenance: { + packageRoot, + stream: 'packed Braid application + packed Pi terminal headless path', + eventReceipt: 'application committed uniquely marked run.text.delta events', + resizeReceipt: + 'Pi doRender completion followed by xterm flush at each reference size and resize epoch', + rateInterval: 'first generated stream event through the final resize-bound Pi frame', + referenceDimensions: REFERENCE_DIMENSIONS.map(([columns, rows]) => `${columns}x${rows}`), + }, + } + } finally { + if (runId && app.state().activeRunId === runId) { + try { + const receipt = app.cancel({ operationId: 'op-perf-resize-cleanup', runId }) + await receipt.completion + } catch { + // Cleanup is best effort after the measured run has already settled. + } + } + if (terminalStarted) terminalApp.stop() + await app.close().catch(() => undefined) + } +} + +export { REFERENCE_DIMENSIONS } diff --git a/scripts/performance/run.mjs b/scripts/performance/run.mjs new file mode 100644 index 0000000..ef8e795 --- /dev/null +++ b/scripts/performance/run.mjs @@ -0,0 +1,267 @@ +import { execFile as execFileCallback } from 'node:child_process' +import { mkdtemp, rm } from 'node:fs/promises' +import os from 'node:os' +import { join } from 'node:path' +import { promisify } from 'node:util' +import { installPackedBraid } from '../packed-binary.mjs' +import { createPerformanceLifecycle } from './lifecycle.mjs' +import { installedPackageRoot } from './packed-runtime.mjs' +import { runProcessMeasurements } from './process-measurements.mjs' +import { assertProcessPrerequisites } from './process-probes.mjs' +import { + assertSmokeMeasurements, + capture, + errorReason, + expectedPerformanceNames, + FULL_REPETITIONS, + gitRevision, + hardwareDescription, + packageVersions, + repository, + writeReports, +} from './reporting.mjs' +import { runRuntimeMeasurement } from './runtime-measurement.mjs' +import { prepareStorageFixtures, runStorageMeasurements } from './storage-measurements.mjs' +import { + createHeadlessProductionProcessFixture, + createProductionProcessFixture, +} from './storage-probes.mjs' + +const smokeOnly = process.argv.includes('--smoke-only') +const execFile = promisify(execFileCallback) +const compileCachePrimer = new URL('./compile-cache-primer.mjs', import.meta.url) + +function assert(condition, message) { + if (!condition) throw new Error(message) +} + +function isCredentialUnavailable(error) { + return /(?:CREDENTIAL_STORE_UNAVAILABLE|operating-system credential facility|credential store)/iu.test( + errorReason(error), + ) +} + +async function createProcessFixture(eventCount, packageRoot) { + try { + return await createProductionProcessFixture(eventCount, { packageRoot }) + } catch (error) { + if (!isCredentialUnavailable(error)) throw error + return createHeadlessProductionProcessFixture(eventCount, { packageRoot }) + } +} + +async function createContext(mode, lifecycle) { + const context = { + mode, + lifecycle, + signal: lifecycle.signal, + throwIfAborted: lifecycle.throwIfAborted, + repetitions: FULL_REPETITIONS, + repository, + processPrerequisiteError: undefined, + packedError: undefined, + } + try { + context.packed = await installPackedBraid(repository) + lifecycle.throwIfAborted() + lifecycle.addCleanup(context.packed.cleanup) + const sqlitePackage = join( + context.packed.installRoot, + 'node_modules', + 'better-sqlite3-multiple-ciphers', + ) + await execFile('npm', ['run', 'install', '--prefix', sqlitePackage], { + cwd: context.packed.installRoot, + }) + lifecycle.throwIfAborted() + context.nativeDependencyPreparation = + 'native prebuild install for better-sqlite3-multiple-ciphers in extracted install' + context.packageRoot = installedPackageRoot(context.packed) + context.tarballSha256 = context.packed.tarballSha256 + } catch (error) { + if (lifecycle.signal.aborted) throw error + context.packedError = errorReason(error) + } + if (context.packageRoot) { + try { + context.repeatCompileCachePath = await mkdtemp( + join(os.tmpdir(), 'braid-repeat-compile-cache-'), + ) + lifecycle.addCleanup(() => + rm(context.repeatCompileCachePath, { force: true, recursive: true }), + ) + const primed = await execFile( + process.execPath, + [compileCachePrimer.pathname, context.packageRoot, context.repeatCompileCachePath], + { cwd: repository }, + ) + context.compileCachePreparation = JSON.parse(primed.stdout) + } catch (error) { + if (lifecycle.signal.aborted) throw error + context.compileCacheError = errorReason(error) + } + } + lifecycle.throwIfAborted() + try { + assertProcessPrerequisites() + } catch (error) { + context.processPrerequisiteError = errorReason(error) + } + if (context.packed && !context.processPrerequisiteError) { + lifecycle.throwIfAborted() + try { + context.warmProcessFixture = await createProcessFixture(10_000, context.packageRoot) + lifecycle.throwIfAborted() + lifecycle.addCleanup(context.warmProcessFixture.cleanup) + } catch (error) { + if (lifecycle.signal.aborted) throw error + context.processPrerequisiteError = errorReason(error) + } + if (!context.processPrerequisiteError) { + try { + context.coldProcessFixture = await createProcessFixture(100_000, context.packageRoot) + lifecycle.throwIfAborted() + lifecycle.addCleanup(context.coldProcessFixture.cleanup) + } catch (error) { + if (lifecycle.signal.aborted) throw error + context.processPrerequisiteError = errorReason(error) + } + } + } + return context +} + +function provenance(context, revision, packages) { + return { + repository, + revision, + packages, + machine: hardwareDescription(), + os: `${process.platform} ${os.release()}`, + node: process.version, + arch: process.arch, + packedPackageRoot: context.packageRoot ?? null, + packedTarballSha256: context.tarballSha256 ?? null, + compileCache: context.compileCachePreparation ?? { + status: 'unavailable', + reason: context.compileCacheError ?? 'packed candidate unavailable', + }, + referenceDimensions: '40x12,80x24,120x40,200x60', + seed: 'fixed 2026-08-03T00:00:00.000Z; receipt-backed complete run; batch size 1000', + externalResources: 'none; local packed candidate, Pi terminal, and encrypted SQLite only', + } +} + +async function runRows(context, measurements) { + const collect = (name, build) => capture(measurements, name, build, context) + await runProcessMeasurements(context, collect) + await runRuntimeMeasurement(context, collect) + const fixtures = await prepareStorageFixtures(context) + await runStorageMeasurements(context, collect, fixtures) + const missing = expectedPerformanceNames(measurements) + for (const name of missing) + measurements.push({ + kind: 'unavailable', + name, + reason: 'Runner did not capture this requirement', + }) + measurements.sort((left, right) => left.name.localeCompare(right.name)) +} + +async function run() { + const lifecycle = createPerformanceLifecycle() + let interrupted = false + const onSignal = (signal) => { + interrupted = true + lifecycle.abort(signal) + } + process.once('SIGINT', onSignal) + process.once('SIGTERM', onSignal) + try { + const mode = 'smoke' + const context = await createContext(mode, lifecycle) + const smokeMeasurements = [] + await runRows(context, smokeMeasurements) + const smokeReport = await writeReports( + smokeMeasurements, + { + provenance: provenance(context, await gitRevision(), await packageVersions()), + limits: { repetitions: 1, noBillableResources: true, smoke: true }, + failures: smokeMeasurements + .filter( + (measurement) => + measurement.kind !== 'distribution' || measurement.failureReasons?.length, + ) + .map((measurement) => ({ + name: measurement.name, + reason: measurement.reason ?? measurement.failureReasons, + })), + }, + 'smoke', + ) + process.stdout.write(`Performance smoke report: ${smokeReport.path}\n`) + process.stdout.write( + `${JSON.stringify( + smokeMeasurements.map((measurement) => ({ + name: measurement.name, + kind: measurement.kind, + n: measurement.n ?? null, + minimum: measurement.minimum ?? null, + median: measurement.median ?? null, + p95: measurement.p95 ?? null, + maximum: measurement.maximum ?? null, + passed: measurement.passed ?? false, + reason: measurement.reason ?? measurement.failureReasons ?? null, + })), + null, + 2, + )}\n`, + ) + assertSmokeMeasurements(smokeMeasurements) + if (smokeOnly) return + + context.mode = 'full' + const fullMeasurements = [] + await runRows(context, fullMeasurements) + const fullReport = await writeReports( + fullMeasurements, + { + provenance: provenance(context, await gitRevision(), await packageVersions()), + limits: { + repetitions: FULL_REPETITIONS, + idleDurationSeconds: + Number(process.env.BRAID_PERFORMANCE_IDLE_DURATION_MS ?? 60_000) / 1_000, + idleSettleSeconds: Number(process.env.BRAID_PERFORMANCE_IDLE_SETTLE_MS ?? 2_000) / 1_000, + noBillableResources: true, + }, + failures: fullMeasurements + .filter( + (measurement) => measurement.kind === 'unavailable' || measurement.passed === false, + ) + .map((measurement) => ({ + name: measurement.name, + reason: + measurement.reason ?? measurement.failureReasons ?? 'target or invariant failed', + })), + }, + 'full', + ) + assert( + fullReport.complete && fullReport.releaseValidation.passed, + `Performance proof failed; see ${fullReport.path}`, + ) + process.stdout.write(`Performance proof passed; raw report: ${fullReport.path}\n`) + } finally { + process.removeListener('SIGINT', onSignal) + process.removeListener('SIGTERM', onSignal) + await lifecycle.close() + if (interrupted) process.exitCode = 130 + } +} + +try { + await run() +} catch (error) { + process.stderr.write(`Performance proof failed: ${errorReason(error)}\n`) + process.exitCode = process.exitCode || 1 +} diff --git a/scripts/performance/runtime-measurement.mjs b/scripts/performance/runtime-measurement.mjs new file mode 100644 index 0000000..c893395 --- /dev/null +++ b/scripts/performance/runtime-measurement.mjs @@ -0,0 +1,91 @@ +import { measureRuntimeEventsToFrames } from './application-probe.mjs' +import { environment, virtualTerminal } from './reporting.mjs' +import { mergeObservations, observation } from './statistics.mjs' + +export async function runRuntimeMeasurement(context, capture) { + await capture('PERF-04', async () => { + const count = context.mode === 'smoke' ? 100 : 10_000 + const intervalMs = 5 + const measured = await measureRuntimeEventsToFrames({ + packageRoot: context.packageRoot, + count, + intervalMs, + signal: context.signal, + }) + const qualityPassed = + measured.missingEvents === 0 && + measured.duplicateEvents === 0 && + measured.invalidCells === 0 && + measured.uniqueProviderEventIds === count && + measured.renderedEvents === count && + measured.finalFrameCompleted === true && + measured.achievedEventsPerSecond >= 100 + return { + ...measured, + unit: 'ms', + state: 'warm', + repetitions: measured.samples.length, + allowSingleSample: false, + qualityPassed, + failureReasons: measured.failureReasons, + details: { + achievedEventsPerSecond: measured.achievedEventsPerSecond, + minimumEventsPerSecond: measured.minimumEventsPerSecond, + elapsedMs: measured.elapsedMs, + finalFrameCompleted: measured.finalFrameCompleted, + accepted: measured.accepted, + renderedEvents: measured.renderedEvents, + producerIntervalMs: intervalMs, + }, + environment: environment({ + dimensions: '80x24', + database: 'memory-journal-runtime-frame-path', + eventCount: count, + terminal: virtualTerminal, + }), + observations: mergeObservations( + { + missingEvents: observation( + measured.missingEvents, + measured.missingEvents === 0 + ? 'No runtime events were missing' + : 'Runtime events were missing', + ), + duplicateEvents: observation( + measured.duplicateEvents, + measured.duplicateEvents === 0 + ? 'No duplicate runtime events were accepted' + : 'Duplicate runtime events were accepted', + ), + invalidCells: observation( + measured.invalidCells, + measured.invalidCells === 0 + ? 'The Pi terminal contained no invalid cells' + : 'The Pi terminal contained invalid cells', + ), + uniqueProviderEventIds: observation( + measured.uniqueProviderEventIds, + measured.uniqueProviderEventIds === count + ? 'Every provider event identifier was unique' + : 'Provider event identifiers were not unique', + ), + acceptedEvents: measured.accepted, + frameWrites: measured.frameWrites, + producerElapsedMs: measured.elapsedMs, + achievedEventsPerSecond: observation( + measured.achievedEventsPerSecond, + measured.achievedEventsPerSecond >= 100 + ? 'The final event frame completed within the required 100 events/s interval' + : 'The producer plus final-frame interval was slower than 100 events/s', + ), + finalFrameCompleted: observation( + measured.finalFrameCompleted ? 1 : 0, + 'Elapsed time ends after the final event matched a completed Pi frame', + ), + eventFrameBindings: measured.provenance?.frameBinding, + }, + {}, + ), + } + }) +} diff --git a/scripts/performance/statistics.mjs b/scripts/performance/statistics.mjs new file mode 100644 index 0000000..328266a --- /dev/null +++ b/scripts/performance/statistics.mjs @@ -0,0 +1,158 @@ +import { REQUIRED_PERFORMANCE_TARGETS } from '../release-evidence.mjs' + +const PERCENTILES = Object.freeze(['minimum', 'median', 'p90', 'p95', 'p99', 'maximum']) + +function assert(condition, message) { + if (!condition) throw new Error(message) +} + +function finite(value, label) { + assert(typeof value === 'number' && Number.isFinite(value), `${label} must be finite`) + return value +} + +function percentile(ordered, fraction) { + const index = Math.min(ordered.length - 1, Math.ceil(ordered.length * fraction) - 1) + return ordered[index] +} + +export function summarizeSamples(samples, label = 'samples', { allowSingle = false } = {}) { + assert( + Array.isArray(samples) && samples.length >= (allowSingle ? 1 : 2), + `${label} requires at least ${allowSingle ? 'one' : 'two'} samples`, + ) + const ordered = samples + .map((value, index) => finite(value, `${label}[${index}]`)) + .sort((a, b) => a - b) + const distribution = { + n: ordered.length, + minimum: ordered[0], + median: percentile(ordered, 0.5), + p90: percentile(ordered, 0.9), + p95: percentile(ordered, 0.95), + p99: percentile(ordered, 0.99), + maximum: ordered.at(-1), + } + for (const field of PERCENTILES) finite(distribution[field], `${label}.${field}`) + return Object.freeze(distribution) +} + +function passesTarget(distribution, target) { + const observed = distribution[target.percentile] + switch (target.operator) { + case '<': + return observed < target.value + case '<=': + return observed <= target.value + case '>': + return observed > target.value + case '>=': + return observed >= target.value + default: + throw new Error(`Unsupported performance target operator: ${target.operator}`) + } +} + +function freezeCopy(value) { + return value === undefined ? undefined : structuredClone(value) +} + +/** + * Creates the raw performance record and its exact release-verifier projection. + * The raw record deliberately retains every sample and invariant; the release + * projection is kept compatible with scripts/release-evidence.mjs. + */ +export function createPerformanceMeasurement(input) { + const target = REQUIRED_PERFORMANCE_TARGETS[input.name] + if (!target) throw new Error(`Unknown performance requirement ${input.name}`) + const distribution = summarizeSamples(input.samples, `${input.name} samples`, { + allowSingle: input.allowSingleSample === true, + }) + const targetPassed = passesTarget(distribution, target) + const qualityPassed = input.qualityPassed !== false + const failureReasons = [...(input.failureReasons ?? [])] + if (!targetPassed) { + const observed = distribution[target.percentile] + failureReasons.push( + `${target.metric} ${target.percentile}=${observed} ${target.operator} ${target.value} target was missed`, + ) + } + if (!qualityPassed && failureReasons.length === 0) + failureReasons.push('measurement quality invariants did not pass') + const rawSamples = input.rawSamples ?? input.samples + assert(Array.isArray(rawSamples) && rawSamples.length > 0, `${input.name} has no raw samples`) + assert(input.repetitions === distribution.n, `${input.name} repetitions must equal n`) + assert(input.state === 'warm' || input.state === 'cold', `${input.name} has invalid state`) + assert( + input.environment && typeof input.environment === 'object', + `${input.name} has no environment`, + ) + assert( + typeof input.command === 'string' && input.command.length > 0, + `${input.name} has no command`, + ) + return Object.freeze({ + kind: 'distribution', + name: input.name, + unit: input.unit, + ...distribution, + target: structuredClone(target), + environment: structuredClone(input.environment), + state: input.state, + repetitions: input.repetitions, + rawSamples: freezeCopy(rawSamples), + command: input.command, + passed: targetPassed && qualityPassed, + observations: freezeCopy(input.observations ?? {}), + details: freezeCopy(input.details ?? {}), + failureReasons: freezeCopy(failureReasons), + provenance: freezeCopy(input.provenance ?? {}), + }) +} + +export function releaseMeasurement(measurement) { + return Object.freeze({ + kind: measurement.kind, + name: measurement.name, + unit: measurement.unit, + n: measurement.n, + minimum: measurement.minimum, + median: measurement.median, + p90: measurement.p90, + p95: measurement.p95, + p99: measurement.p99, + maximum: measurement.maximum, + target: structuredClone(measurement.target), + environment: structuredClone(measurement.environment), + state: measurement.state, + repetitions: measurement.repetitions, + }) +} + +export function assertPerformancePass(measurement) { + assert(measurement.passed === true, `${measurement.name} missed its target`) + return measurement +} + +export function observation(value, reason) { + assert( + value === null || (typeof value === 'number' && Number.isFinite(value)), + 'observation value must be numeric or null', + ) + assert(typeof reason === 'string' && reason.length > 0, 'observation reason is required') + return Object.freeze({ value, reason }) +} + +export function assertFullDuration(durationMs, requiredMs = 60_000) { + finite(durationMs, 'durationMs') + finite(requiredMs, 'requiredMs') + assert( + durationMs >= requiredMs, + `Measured duration ${durationMs}ms is shorter than ${requiredMs}ms`, + ) + return durationMs +} + +export function mergeObservations(...groups) { + return Object.freeze(Object.assign({}, ...groups)) +} diff --git a/scripts/performance/storage-measurements.mjs b/scripts/performance/storage-measurements.mjs new file mode 100644 index 0000000..5dfc1e6 --- /dev/null +++ b/scripts/performance/storage-measurements.mjs @@ -0,0 +1,155 @@ +import { environment } from './reporting.mjs' +import { observation } from './statistics.mjs' +import { + createEncryptedStorageFixture, + measureDatabaseGrowth, + measureOpenViewport, + measureReplayReduce, + measureResidentMemory, +} from './storage-probes.mjs' + +export async function prepareStorageFixtures(context) { + const fixtures = {} + try { + context.throwIfAborted() + fixtures.storage10k = await createEncryptedStorageFixture(10_000, { + packageRoot: context.packageRoot, + }) + context.throwIfAborted() + context.lifecycle.addCleanup(fixtures.storage10k.cleanup) + } catch (error) { + if (context.signal.aborted) throw error + context.storage10kError = error + } + try { + context.throwIfAborted() + fixtures.storage100k = await createEncryptedStorageFixture(100_000, { + packageRoot: context.packageRoot, + }) + context.throwIfAborted() + context.lifecycle.addCleanup(fixtures.storage100k.cleanup) + } catch (error) { + if (context.signal.aborted) throw error + context.storage100kError = error + } + return fixtures +} + +export async function runStorageMeasurements(context, capture, fixtures) { + const repetitions = context.mode === 'smoke' ? 1 : context.repetitions + await capture('PERF-05', async () => { + if (!fixtures.storage10k) + throw context.storage10kError ?? new Error('10,000-event encrypted fixture unavailable') + const measured = await measureReplayReduce(fixtures.storage10k, repetitions) + return { + ...measured, + unit: 'ms', + state: 'warm', + repetitions: measured.samples.length, + environment: environment({ + dimensions: 'none', + database: 'warm-10000-event-encrypted-sqlite', + eventCount: 10_000, + terminal: 'none; production StorageJournal + reducer', + }), + observations: { + replayedEvents: observation(measured.eventCount, 'Every committed event was replayed'), + projectionChecksum: measured.projectionChecksum, + }, + provenance: { + seed: 'packed encrypted SQLite production storage; complete receipt-backed run', + }, + } + }) + + await capture('PERF-06', async () => { + if (!fixtures.storage100k) + throw context.storage100kError ?? new Error('100,000-event encrypted fixture unavailable') + const measured = await measureOpenViewport(fixtures.storage100k, repetitions) + return { + ...measured, + unit: 'ms', + state: 'cold', + repetitions: measured.samples.length, + environment: environment({ + dimensions: '80x24', + database: 'cold-100000-event-encrypted-sqlite', + eventCount: 100_000, + }), + observations: { + openedEvents: observation( + measured.eventCount, + 'Every committed event was opened from encrypted SQLite', + ), + loadedTailEvents: measured.loadedTailEvents, + renderedRows: measured.renderedRows, + viewportBound: 200, + }, + provenance: { + seed: 'packed encrypted SQLite production storage; recent useful content asserted', + }, + } + }) + + await capture('PERF-09', async () => { + if (!fixtures.storage10k) + throw context.storage10kError ?? new Error('10,000-event encrypted fixture unavailable') + const measured = await measureResidentMemory( + fixtures.storage10k, + repetitions, + new URL('./memory-child.mjs', import.meta.url).pathname, + ) + return { + samples: measured.samples, + rawSamples: measured.samples, + unit: 'MiB RSS', + state: 'warm', + repetitions: measured.samples.length, + environment: environment({ + dimensions: '80x24', + database: 'warm-10000-event-encrypted-sqlite', + eventCount: 10_000, + terminal: 'packed production view model; child process RSS', + }), + observations: { + residentMemory: measured.observations, + boundedViewport: observation( + 200, + 'The child process enforces a maximum of 200 rendered rows', + ), + }, + provenance: { + seed: 'packed encrypted SQLite production storage; one fresh child per repetition', + }, + } + }) + + await capture('PERF-10', async () => { + const measured = await measureDatabaseGrowth(10_000, repetitions, { + packageRoot: context.packageRoot, + }) + return { + ...measured, + unit: 'MiB', + state: 'cold', + repetitions: measured.samples.length, + environment: environment({ + dimensions: 'none', + database: 'cold-10000-event-encrypted-sqlite', + eventCount: 10_000, + terminal: 'none; packed production encrypted SQLite artifacts', + }), + observations: { + databaseFiles: measured.observations, + providerArtifacts: observation( + 0, + 'The normalized local text seed writes no provider payload artifact files', + ), + }, + provenance: { + bytes: 'database + WAL + shared-memory SQLite artifacts only', + providerArtifacts: 'reported separately and excluded from samples', + }, + } + }) +} diff --git a/scripts/performance/storage-probes.mjs b/scripts/performance/storage-probes.mjs new file mode 100644 index 0000000..9b8b8f9 --- /dev/null +++ b/scripts/performance/storage-probes.mjs @@ -0,0 +1,630 @@ +import { createHash, randomBytes } from 'node:crypto' +import { chmod, mkdtemp, rm, stat, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { performance } from 'node:perf_hooks' +import { FileCredentialStore } from './file-credential-store.mjs' +import { loadPackedRuntime } from './packed-runtime.mjs' + +const require = createRequire(import.meta.url) +const FIXED_TIME = '2026-08-03T00:00:00.000Z' +const BATCH_SIZE = 1_000 +const PROFILE = Object.freeze({ name: 'Braid performance profile', harness: 'pi' }) + +function assert(condition, message) { + if (!condition) throw new Error(message) +} + +function requireNativeSqlite() { + try { + require.resolve('better-sqlite3-multiple-ciphers') + } catch { + throw new Error( + 'PERF_PREREQUISITE: better-sqlite3-multiple-ciphers@12.11.1 is required for encrypted SQLite performance proof', + ) + } +} + +async function assertCredentialFacility(credentials) { + const expected = randomBytes(32) + let ref + let handle + try { + ref = await credentials.store({ value: expected }) + handle = await credentials.resolve(ref) + const observed = handle.read() + assert( + Buffer.from(observed).equals(expected), + 'Credential facility returned a different secret during the performance probe', + ) + } catch (error) { + throw new Error('PERF_PREREQUISITE: the operating-system credential facility is unavailable', { + cause: error, + }) + } finally { + handle?.dispose() + expected.fill(0) + if (ref !== undefined) await credentials.remove(ref).catch(() => undefined) + } +} + +const runtimeCache = new Map() +async function runtime(packageRoot) { + if (!packageRoot) + throw new Error('PERF_PREREQUISITE: storage probes require a packed package root') + if (!runtimeCache.has(packageRoot)) runtimeCache.set(packageRoot, loadPackedRuntime(packageRoot)) + try { + return await runtimeCache.get(packageRoot) + } catch (error) { + throw new Error( + `PERF_PREREQUISITE: build Braid before running performance proof (${String(error)})`, + { + cause: error, + }, + ) + } +} + +function seedContext(count, index, workspace) { + const runId = `run-perf-${count}` + const turnId = `turn-perf-${count}` + const operationId = `op-perf-${count}` + const text = 'Performance seed conversation with normalized text events.' + const receipt = index.createAdmissionReceipt({ + runId, + turnId, + operationId, + conversationId: 'conv-1', + branchId: 'branch-1', + admittedAt: FIXED_TIME, + profile: PROFILE, + connectionId: 'connection-performance-local', + text, + capabilities: index.DEFAULT_RUN_CAPABILITIES, + provider: 'local-performance', + environmentId: `environment-performance-${count}`, + providerSessionId: `session-performance-${count}`, + }) + return { operationId, receipt, runId, text, turnId, workspace } +} + +function createPersistedEvent(count, index, context) { + const eventId = `event-perf-${count}-${String(index).padStart(6, '0')}` + const { operationId, receipt, runId, text, turnId } = context + const appSequence = index + let event + let kind + if (index === 1) { + kind = 'workspace.opened' + event = { kind, workspace: context.workspace } + } else if (index === 2) { + kind = 'run.requested' + event = { + kind, + operationId, + runId, + turnId, + userMessageId: `message-perf-user-${count}`, + assistantMessageId: `message-perf-assistant-${count}`, + text, + requestDigest: receipt.requestDigest, + receipt, + } + } else if (index === count) { + kind = 'run.finished' + event = { + kind, + runId, + status: 'completed', + finalText: `Completed Braid performance conversation (${count} committed events); recent event ${count - 3}.`, + usage: { input: 1, output: count - 3, model: 'fixture/model' }, + reason: 'performance fixture complete', + provider: { + eventId: `provider-perf-${count}-final`, + providerSequence: count - 2, + occurredAt: FIXED_TIME, + receivedAt: FIXED_TIME, + }, + } + } else { + kind = 'run.text.delta' + const deltaSequence = index - 2 + event = { + kind, + runId, + text: + deltaSequence === count - 2 + ? `Recent seeded event ${deltaSequence} remains observable. ` + : 'x', + provider: { + eventId: `provider-perf-${count}-${String(deltaSequence).padStart(6, '0')}`, + providerSequence: deltaSequence, + occurredAt: FIXED_TIME, + receivedAt: FIXED_TIME, + }, + } + } + return { + workspaceId: `workspace-perf-${count}`, + conversationId: `conversation-perf-${count}`, + runId, + eventId, + sequence: index, + kind, + payload: { + __braidEvent: event, + __braidEnvelope: { + eventId, + sequence: appSequence, + revision: appSequence, + occurredAt: FIXED_TIME, + }, + }, + occurredAt: FIXED_TIME, + receivedAt: FIXED_TIME, + terminal: index === count, + } +} + +export function assertValidConversationSeed(state, eventCount, workspace) { + assert(state && typeof state === 'object', 'Seed replay did not produce a state') + assert( + state.sequence === eventCount, + `Seed sequence is ${state.sequence}, expected ${eventCount}`, + ) + assert(state.workspace === workspace, 'Seed workspace does not match the launched workspace') + assert( + Array.isArray(state.missingHistory) && state.missingHistory.length === 0, + 'Seed replay contains missing provider history', + ) + assert(state.projectionChecksum, 'Seed replay has no projection checksum') + const run = state.runs?.find((candidate) => candidate.id === `run-perf-${eventCount}`) + assert(run, 'Seed replay did not produce the expected run') + assert(run.status === 'completed' && run.complete, 'Seed run is not a complete successful run') + assert( + run.receipt.digest && run.receipt.requestDigest, + 'Seed run has no admission receipt digests', + ) + assert(run.eventCount === eventCount - 2, `Seed run event count is ${run.eventCount}`) + assert(run.lastProviderSequence === eventCount - 2, 'Seed provider sequence is incomplete') + const assistant = state.messages?.find( + (message) => message.role === 'assistant' && message.runId === run.id, + ) + assert( + assistant?.status === 'complete' && assistant.complete, + 'Seed assistant message is incomplete', + ) + assert( + assistant.text.includes( + `Completed Braid performance conversation (${eventCount} committed events)`, + ), + 'Seed assistant message has no useful recent content', + ) + return state +} + +export function assertUsefulViewport(view, eventCount) { + assert(view && Array.isArray(view.messages), 'Viewport is missing transcript messages') + assert(view.messages.length > 0, 'Viewport is empty for a seeded conversation') + assert( + view.messages.some((message) => + message.text.includes( + `Completed Braid performance conversation (${eventCount} committed events)`, + ), + ), + 'Viewport does not show the seeded recent conversation content', + ) + assert( + view.messages.length <= 200, + 'Viewport rendered more than the bounded recent message window', + ) + return view +} + +async function fileBytes(path) { + try { + return (await stat(path)).size + } catch { + return 0 + } +} + +async function storageBytes(storage) { + const artifacts = storage.artifacts() + const database = await fileBytes(artifacts.database) + const wal = await fileBytes(artifacts.wal) + const sharedMemory = await fileBytes(artifacts.sharedMemory) + return { + database, + wal, + sharedMemory, + total: database + wal + sharedMemory, + } +} + +export async function createEncryptedStorageFixture(eventCount, { packageRoot } = {}) { + requireNativeSqlite() + const { index, clock, journal, journalSupport, materializedState, state } = + await runtime(packageRoot) + const root = await mkdtemp(join(tmpdir(), `braid-performance-${eventCount}-`)) + const keyRoot = await mkdtemp(join(tmpdir(), 'braid-performance-key-')) + const keyPath = join(keyRoot, 'database.key') + try { + await writeFile(keyPath, randomBytes(32), { mode: 0o600 }) + await chmod(keyPath, 0o600) + const credentialRoot = join(root, '.credentials') + const credentials = new FileCredentialStore(credentialRoot) + const path = join(root, 'braid.sqlite') + const databaseKeySource = { type: 'file', path: keyPath, workspaceRoot: root } + let seeded = false + const fixture = { + eventCount, + root, + path, + keyPath, + credentialRoot, + credentials, + databaseKeySource, + packageRoot, + async open() { + const storage = await index.openSqliteStorage({ + path, + workspaceRoot: root, + credentialStore: credentials, + databaseKeySource, + maxEventsPerTransaction: BATCH_SIZE, + maxPayloadBytesPerTransaction: 8 * 1024 * 1024, + }) + return storage + }, + async seed() { + if (seeded) return + const storage = await fixture.open() + try { + const context = seedContext(eventCount, index, root) + for (let offset = 1; offset <= eventCount; offset += BATCH_SIZE) { + const events = [] + const end = Math.min(eventCount, offset + BATCH_SIZE - 1) + for (let eventIndex = offset; eventIndex <= end; eventIndex += 1) { + events.push(createPersistedEvent(eventCount, eventIndex, context)) + } + await storage.append(events) + } + const projection = await storage.projection() + assert( + projection.eventCount === eventCount, + `Seeded ${eventCount} events but projection reports ${projection.eventCount}`, + ) + const integrity = await storage.integrity() + assert( + integrity.ok && integrity.encryption === 'verified', + `Encrypted SQLite integrity failed for ${eventCount} events`, + ) + const stored = await storage.events() + const envelopes = journalSupport.envelopesFromStored(stored) + const replayed = index.replayEvents(state.initialState(PROFILE), envelopes) + assertValidConversationSeed(replayed, eventCount, root) + const last = stored.at(-1) + assert(last !== undefined, 'Performance seed has no final stored event') + await storage.writeStateSnapshot( + materializedState.createMaterializedStateSnapshot({ + scopeId: storage.snapshotScopeId(), + generation: replayed.sequence, + eventId: last.eventId, + state: replayed, + }), + ) + const storageJournal = await journal.StorageJournal.fromStorage( + storage, + new clock.SystemClock(), + ) + assert( + storageJournal.initialState()?.sequence === eventCount, + 'Performance snapshot did not restore the complete state', + ) + assert( + storageJournal.replay().length === 0, + 'Final performance snapshot unexpectedly retained a journal tail', + ) + seeded = true + } finally { + await storage.close() + } + }, + async bytes() { + await fixture.seed() + const storage = await fixture.open() + try { + const bytes = await storageBytes(storage) + const integrity = await storage.integrity() + assert( + integrity.ok && integrity.encryption === 'verified', + 'Database integrity changed before byte measurement', + ) + return bytes + } finally { + await storage.close() + } + }, + async cleanup() { + await rm(root, { force: true, recursive: true }) + await rm(keyRoot, { force: true, recursive: true }) + }, + } + return fixture + } catch (error) { + await rm(root, { force: true, recursive: true }).catch(() => undefined) + await rm(keyRoot, { force: true, recursive: true }).catch(() => undefined) + throw error + } +} + +export async function createHeadlessProductionProcessFixture(eventCount, { packageRoot } = {}) { + const fixture = await createEncryptedStorageFixture(eventCount, { packageRoot }) + return prepareHeadlessProductionProcessFixture(fixture) +} + +export async function prepareHeadlessProductionProcessFixture(fixture) { + await fixture.seed() + return { + ...fixture, + processMode: 'isolated-production-pty', + database: `${fixture.eventCount}-event-encrypted-sqlite-headless-production`, + } +} + +export async function measureReplayReduce(fixture, repetitions) { + const { index, journalSupport, state } = await runtime(fixture.packageRoot) + await fixture.seed() + const samples = [] + let finalState + let eventCount = 0 + for (let repetition = 0; repetition < repetitions; repetition += 1) { + const startedAt = performance.now() + const storage = await fixture.open() + try { + const envelopes = journalSupport.envelopesFromStored(await storage.events()) + finalState = index.replayEvents(state.initialState(PROFILE), envelopes) + eventCount = envelopes.length + samples.push(performance.now() - startedAt) + } finally { + await storage.close() + } + } + assert( + eventCount === fixture.eventCount, + `Replay loaded ${eventCount} events instead of ${fixture.eventCount}`, + ) + assert( + finalState?.sequence === fixture.eventCount, + 'Replay sequence did not reach the seeded event count', + ) + assert(finalState?.projectionChecksum, 'Replay did not produce a projection checksum') + return { samples, eventCount, projectionChecksum: finalState.projectionChecksum } +} + +export async function measureOpenViewport(fixture, repetitions) { + const { index, tui } = await runtime(fixture.packageRoot) + await fixture.seed() + const samples = [] + const renderedRows = [] + const loadedTailEvents = [] + let eventCount = 0 + for (let repetition = 0; repetition < repetitions; repetition += 1) { + const startedAt = performance.now() + const durable = await index.createDurableBraidApplication({ + path: fixture.path, + workspaceRoot: fixture.root, + credentialStore: fixture.credentials, + databaseKeySource: fixture.databaseKeySource, + profile: PROFILE, + }) + try { + const view = tui.buildBraidViewModel( + durable.app.state(), + 'transcript', + { color: 'none' }, + false, + ) + samples.push(performance.now() - startedAt) + eventCount = durable.app.state().sequence + loadedTailEvents.push(durable.app.events().length) + renderedRows.push(view.messages.length + (view.activity?.length ?? 0)) + assertUsefulViewport(view, fixture.eventCount) + assert( + view.messages.length <= 200, + `Viewport rendered ${view.messages.length} messages from ${fixture.eventCount} events`, + ) + } finally { + await durable.app.close() + } + } + assert( + eventCount === fixture.eventCount, + `Viewport opened ${eventCount} events instead of ${fixture.eventCount}`, + ) + assert( + loadedTailEvents.every((count) => count <= 256), + 'Viewport retained more than the bounded post-snapshot journal tail', + ) + return { samples, eventCount, renderedRows, loadedTailEvents } +} + +export async function measureResidentMemory(fixture, repetitions, childPath) { + await fixture.seed() + const { spawn } = await import('node:child_process') + const samples = [] + const observations = [] + for (let repetition = 0; repetition < repetitions; repetition += 1) { + const result = await new Promise((resolve, reject) => { + const child = spawn( + process.execPath, + [ + '--expose-gc', + childPath, + fixture.path, + fixture.root, + fixture.keyPath, + fixture.credentialRoot, + fixture.packageRoot, + ], + { + cwd: process.cwd(), + env: { ...process.env, NO_COLOR: '1', NODE_NO_WARNINGS: '1' }, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ) + let stdout = '' + let stderr = '' + child.stdout.setEncoding('utf8') + child.stderr.setEncoding('utf8') + child.stdout.on('data', (chunk) => { + stdout += chunk + }) + child.stderr.on('data', (chunk) => { + stderr += chunk + }) + child.once('error', reject) + child.once('close', (code) => { + if (code !== 0) { + reject(new Error(`PERF-09 memory child failed with ${code}: ${stderr}`)) + return + } + try { + resolve(JSON.parse(stdout.trim())) + } catch (error) { + reject( + new Error(`PERF-09 memory child emitted invalid JSON: ${stdout}`, { cause: error }), + ) + } + }) + }) + assert( + result.eventCount === fixture.eventCount, + 'PERF-09 child did not open the complete database', + ) + assert( + result.loadedTailEventCount <= 256, + 'PERF-09 child retained more than the bounded post-snapshot journal tail', + ) + assert(result.renderedRows <= 200, 'PERF-09 child rendered more than the bounded viewport') + assert( + result.recentContent?.includes( + `Completed Braid performance conversation (${fixture.eventCount} committed events)`, + ), + 'PERF-09 child did not render useful recent seeded content', + ) + samples.push(result.rssMiB) + observations.push({ + baselineRssMiB: result.baselineRssMiB, + openedRssMiB: result.rssMiB, + loadedTailEventCount: result.loadedTailEventCount, + renderedRows: result.renderedRows, + }) + } + return { samples, observations } +} + +export async function measureDatabaseGrowth(eventCount, repetitions, { packageRoot } = {}) { + const samples = [] + const observations = [] + for (let repetition = 0; repetition < repetitions; repetition += 1) { + const fixture = await createEncryptedStorageFixture(eventCount, { packageRoot }) + try { + const bytes = await fixture.bytes() + samples.push(bytes.total / (1024 * 1024)) + observations.push(bytes) + } finally { + await fixture.cleanup() + } + } + return { samples, observations } +} + +export async function createProductionProcessFixture(eventCount, { packageRoot } = {}) { + requireNativeSqlite() + const { index, clock, journal, state } = await runtime(packageRoot) + const credentials = index.createOperatingSystemCredentialStore() + await assertCredentialFacility(credentials) + const root = await mkdtemp(join(tmpdir(), `braid-packed-performance-${eventCount}-`)) + const configDirectory = join(root, '.braid') + const configPath = join(configDirectory, 'config.json') + const path = join(root, 'braid.sqlite') + const credentialRef = `cred:v1:database-${createHash('sha256').update(path).digest('hex')}` + try { + const connection = { + id: 'connection-performance-local', + kind: 'cli-bridge', + name: 'Performance local bridge', + endpoint: 'http://127.0.0.1:9', + providerOptions: { transport: 'local' }, + createdAt: FIXED_TIME, + updatedAt: FIXED_TIME, + lastHealth: { status: 'unknown' }, + } + const config = { + format: 'braid-startup-config', + schemaVersion: 1, + profile: PROFILE, + connectionId: connection.id, + connections: [connection], + } + const { mkdir } = await import('node:fs/promises') + await mkdir(configDirectory, { recursive: true, mode: 0o700 }) + await writeFile(configPath, `${JSON.stringify(config)}\n`, { mode: 0o600 }) + const storage = await index.openSqliteStorage({ + path, + workspaceRoot: root, + credentialStore: credentials, + maxEventsPerTransaction: BATCH_SIZE, + maxPayloadBytesPerTransaction: 8 * 1024 * 1024, + }) + try { + const context = seedContext(eventCount, index, root) + for (let offset = 1; offset <= eventCount; offset += BATCH_SIZE) { + const events = [] + const end = Math.min(eventCount, offset + BATCH_SIZE - 1) + for (let eventIndex = offset; eventIndex <= end; eventIndex += 1) + events.push(createPersistedEvent(eventCount, eventIndex, context)) + await storage.append(events) + } + const projection = await storage.projection() + assert( + projection.eventCount === eventCount, + `Packed process database seed reports ${projection.eventCount} events`, + ) + const integrity = await storage.integrity() + assert( + integrity.ok && integrity.encryption === 'verified', + `Packed process encrypted SQLite integrity failed for ${eventCount} events`, + ) + const storageJournal = await journal.StorageJournal.fromStorage( + storage, + new clock.SystemClock(), + ) + const replayed = index.replayEvents(state.initialState(PROFILE), storageJournal.all()) + assertValidConversationSeed(replayed, eventCount, root) + } finally { + await storage.close() + } + return { + eventCount, + root, + path, + configPath, + packageRoot, + database: eventCount === 0 ? 'warm-empty' : `${eventCount}-event`, + async cleanup() { + await credentials.remove(credentialRef).catch(() => undefined) + await rm(root, { force: true, recursive: true }) + }, + } + } catch (error) { + await credentials.remove(credentialRef).catch(() => undefined) + await rm(root, { force: true, recursive: true }).catch(() => undefined) + throw error + } +} + +export { FIXED_TIME, PROFILE } diff --git a/scripts/release-check-catalog.mjs b/scripts/release-check-catalog.mjs new file mode 100644 index 0000000..2e89a4a --- /dev/null +++ b/scripts/release-check-catalog.mjs @@ -0,0 +1,71 @@ +export const REQUIREMENT_PATTERN = /\b[A-Z]{2,4}-[0-9]{2}\b/gu +export const SHA256_PATTERN = /^[a-f0-9]{64}$/u + +export const SHA512_INTEGRITY_PATTERN = + /^sha512-(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u + +export const CHECK_CATEGORIES = new Set([ + 'unit', + 'contract', + 'subprocess', + 'terminal', + 'live', + 'performance', + 'security', + 'eval', + 'release', +]) + +export const REQUIRED_CHECKS = new Map([ + ['repository', { category: 'release', command: 'pnpm check' }], + ['unit', { category: 'unit', command: 'pnpm test:unit' }], + ['contract', { category: 'contract', command: 'pnpm test:contract' }], + ['coordination', { category: 'contract', command: 'pnpm test:coordination' }], + ['rpc', { category: 'subprocess', command: 'pnpm test:rpc' }], + ['rpc-packed', { category: 'subprocess', command: 'pnpm test:rpc:packed' }], + ['virtual-terminal', { category: 'terminal', command: 'pnpm test:virtual-terminal' }], + ['pty', { category: 'terminal', command: 'pnpm test:pty' }], + ['storage', { category: 'contract', command: 'pnpm test:storage' }], + ['crash', { category: 'subprocess', command: 'pnpm test:crash' }], + ['security', { category: 'security', command: 'pnpm test:security' }], + ['performance', { category: 'performance', command: 'pnpm test:performance' }], + ['live', { category: 'live', command: 'pnpm test:live' }], + ['live-bridge', { category: 'live', command: 'pnpm test:live:bridge' }], + ['live-tangle', { category: 'live', command: 'pnpm test:live:tangle' }], + ['live-supervisor', { category: 'live', command: 'pnpm test:live:supervisor' }], + ['live-analysis', { category: 'live', command: 'pnpm test:live:analysis' }], + ['eval', { category: 'eval', command: 'pnpm test:eval' }], + ['install', { category: 'release', command: 'pnpm test:install' }], + ['capture', { category: 'terminal', command: 'pnpm test:capture' }], + ['visual', { category: 'terminal', command: 'pnpm capture:visual' }], + ['release', { category: 'release', command: 'pnpm check:release' }], + ['verify:release', { category: 'release', command: 'pnpm verify:release' }], +]) + +export const EXACT_REQUIREMENT_CHECK_CATEGORIES = new Map([ + ['UP', new Set(['contract', 'live'])], + ['LIVE', new Set(['live'])], + ['PERF', new Set(['performance'])], + ['EVAL', new Set(['eval'])], +]) + +export const ADMISSIBLE_CATEGORIES = new Map([ + ['AN', new Set(['unit', 'contract', 'subprocess', 'live', 'security', 'eval'])], + ['AR', new Set(['unit', 'contract', 'subprocess', 'security', 'release'])], + ['CF', new Set(['unit', 'contract', 'subprocess', 'live', 'security'])], + ['EVAL', new Set(['eval'])], + ['LIVE', new Set(['live'])], + ['PC', new Set(['unit', 'contract', 'subprocess', 'live', 'security'])], + ['PERF', new Set(['performance'])], + [ + 'PR', + new Set(['unit', 'contract', 'subprocess', 'terminal', 'live', 'security', 'eval', 'release']), + ], + ['SE', new Set(['contract', 'subprocess', 'live', 'security', 'release'])], + ['ST', new Set(['unit', 'contract', 'security', 'performance'])], + ['UP', new Set(['contract', 'live'])], + ['US', new Set(['contract', 'security', 'release'])], + ['UX', new Set(['unit', 'subprocess', 'terminal', 'live', 'security', 'performance'])], + ['VR', new Set(['terminal', 'live', 'performance', 'security', 'eval', 'release'])], + ['VT', new Set(['subprocess', 'terminal', 'release'])], +]) diff --git a/scripts/release-evidence.mjs b/scripts/release-evidence.mjs new file mode 100644 index 0000000..26436dd --- /dev/null +++ b/scripts/release-evidence.mjs @@ -0,0 +1,483 @@ +import { + createHash, + createPrivateKey, + createPublicKey, + sign as signBytes, + verify as verifyBytes, +} from 'node:crypto' + +const SHA256_PATTERN = /^[a-f0-9]{64}$/u +const BASE64_PATTERN = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u + +export function assert(condition, message) { + if (!condition) throw new Error(message) +} + +export function assertExactKeys(value, required, optional, label) { + assert(value && typeof value === 'object' && !Array.isArray(value), `${label} is not an object`) + const keys = new Set(Object.keys(value)) + for (const key of required) assert(keys.delete(key), `${label} has no ${key}`) + for (const key of optional) keys.delete(key) + assert(keys.size === 0, `${label} has unknown field ${[...keys].sort()[0]}`) +} + +export function compareCanonicalKeys(left, right) { + return left < right ? -1 : left > right ? 1 : 0 +} + +export function canonicalJson(value) { + if (value === null || typeof value === 'boolean' || typeof value === 'string') { + return JSON.stringify(value) + } + if (typeof value === 'number') { + assert(Number.isFinite(value), 'Signed payload contains a non-finite number') + return JSON.stringify(value) + } + if (Array.isArray(value)) return `[${value.map((entry) => canonicalJson(entry)).join(',')}]` + assert(value && typeof value === 'object', 'Signed payload contains a non-JSON value') + const entries = Object.entries(value).sort(([left], [right]) => compareCanonicalKeys(left, right)) + return `{${entries + .map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`) + .join(',')}}` +} + +export function sha256Text(value) { + return createHash('sha256').update(value).digest('hex') +} + +export function publicKeyId(key) { + const publicKey = key?.type === 'public' ? key : createPublicKey(key) + const der = publicKey.export({ format: 'der', type: 'spki' }) + return `sha256:${createHash('sha256').update(der).digest('hex')}` +} + +export function checkSigningPayload(check) { + const { receipt: _receipt, ...unsignedCheck } = check + return { + schema: 'braid.release-check.v1', + check: unsignedCheck, + } +} + +export function manifestSigningPayload(manifest) { + return { + schema: 'braid.release-manifest.v1', + manifest: { ...manifest, signatures: [] }, + } +} + +export function signPayload(payload, privateKey) { + const key = privateKey?.type === 'private' ? privateKey : createPrivateKey(privateKey) + const publicKey = createPublicKey(key) + const bytes = Buffer.from(canonicalJson(payload)) + return { + algorithm: 'ed25519', + keyId: publicKeyId(publicKey), + payloadSha256: sha256Text(bytes), + signature: signBytes(null, bytes, key).toString('base64'), + } +} + +export function verifyPayload(payload, receipt, publicKey, label) { + assertExactKeys( + receipt, + ['algorithm', 'keyId', 'payloadSha256', 'signature'], + [], + `${label} receipt`, + ) + assert(receipt.algorithm === 'ed25519', `${label} receipt does not use Ed25519`) + assert(receipt.keyId === publicKeyId(publicKey), `${label} receipt uses an untrusted key`) + assert(SHA256_PATTERN.test(receipt.payloadSha256), `${label} receipt has an invalid payload hash`) + assert( + typeof receipt.signature === 'string' && + BASE64_PATTERN.test(receipt.signature) && + Buffer.from(receipt.signature, 'base64').toString('base64') === receipt.signature, + `${label} receipt has an invalid signature encoding`, + ) + const bytes = Buffer.from(canonicalJson(payload)) + assert(sha256Text(bytes) === receipt.payloadSha256, `${label} receipt payload changed`) + assert( + verifyBytes(null, bytes, publicKey, Buffer.from(receipt.signature, 'base64')), + `${label} receipt signature is invalid`, + ) +} + +export function signCheck(check, privateKey) { + return { + ...check, + receipt: signPayload(checkSigningPayload(check), privateKey), + } +} + +export function verifyCheckReceipt(check, publicKey) { + verifyPayload(checkSigningPayload(check), check.receipt, publicKey, `Check ${check.id}`) +} + +export function signManifest(manifest, privateKey) { + return { + ...manifest, + signatures: [signPayload(manifestSigningPayload(manifest), privateKey)], + } +} + +export function verifyManifestSignature(manifest, publicKey) { + assert( + Array.isArray(manifest.signatures) && manifest.signatures.length === 1, + 'Manifest must have one signature', + ) + verifyPayload(manifestSigningPayload(manifest), manifest.signatures[0], publicKey, 'Manifest') +} + +export function strictIsoTimestamp(value, label) { + assert( + typeof value === 'string' && + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u.test(value) && + Number.isFinite(Date.parse(value)) && + new Date(value).toISOString() === value, + `${label} is not a canonical UTC timestamp`, + ) + return Date.parse(value) +} + +export function validateReleaseInputEnvelope(evidence) { + assertExactKeys( + evidence, + [ + 'schemaVersion', + 'braidVersion', + 'gitCommit', + 'packageIntegrity', + 'startedAt', + 'finishedAt', + 'sourceState', + 'dependencies', + 'environments', + 'checks', + 'requirements', + 'artifacts', + 'liveResources', + 'cleanup', + 'signatures', + ], + [], + 'Release evidence', + ) + assert(evidence.schemaVersion === 1, 'Unsupported release evidence schema') + assert( + typeof evidence.braidVersion === 'string' && evidence.braidVersion.length > 0, + 'Release evidence has no version', + ) + assert(/^[a-f0-9]{40}$/u.test(evidence.gitCommit), 'Release evidence has no full Git commit') + assert( + /^sha512-(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test( + evidence.packageIntegrity, + ), + 'Package integrity is not SHA-512 SRI', + ) + const startedAt = strictIsoTimestamp(evidence.startedAt, 'Release start') + const finishedAt = strictIsoTimestamp(evidence.finishedAt, 'Release finish') + assert(finishedAt >= startedAt, 'Release finished before it started') + assert( + Array.isArray(evidence.signatures) && evidence.signatures.length === 0, + 'Input evidence must be unsigned', + ) + assertExactKeys( + evidence.sourceState, + ['clean', 'commit', 'treeSha256', 'tarballSha256', 'tarballArtifactId'], + [], + 'Source state', + ) + assert(typeof evidence.sourceState.clean === 'boolean', 'Source clean state is not boolean') + assert(/^[a-f0-9]{40}$/u.test(evidence.sourceState.commit), 'Source commit is not a full SHA') + assert(/^[a-f0-9]{40}$/u.test(evidence.sourceState.treeSha256), 'Source tree is not a full SHA') + assert(SHA256_PATTERN.test(evidence.sourceState.tarballSha256), 'Source tarball has no SHA-256') + assert(Array.isArray(evidence.dependencies), 'Release dependencies are not an array') + assert(Array.isArray(evidence.environments), 'Release environments are not an array') + assert(Array.isArray(evidence.checks), 'Release checks are not an array') + assert( + evidence.requirements && + typeof evidence.requirements === 'object' && + !Array.isArray(evidence.requirements), + 'Requirement mappings are not an object', + ) + assert(Array.isArray(evidence.artifacts), 'Release artifacts are not an array') + assert(Array.isArray(evidence.liveResources), 'Release live resources are not an array') + assert(Array.isArray(evidence.cleanup), 'Release cleanup is not an array') + return { startedAt, finishedAt } +} + +function finiteNumber(value, label) { + assert(typeof value === 'number' && Number.isFinite(value), `${label} is not a finite number`) +} + +export function validateMeasurements(measurements, label, requireDistribution = false) { + assert(Array.isArray(measurements) && measurements.length > 0, `${label} has no measurements`) + const names = new Set() + let distributions = 0 + for (const measurement of measurements) { + assert( + measurement && typeof measurement === 'object' && !Array.isArray(measurement), + `${label} has an invalid measurement`, + ) + assert( + typeof measurement.name === 'string' && measurement.name.length > 0, + `${label} measurement has no name`, + ) + assert(!names.has(measurement.name), `${label} repeats measurement ${measurement.name}`) + names.add(measurement.name) + if (measurement.kind === 'scalar') { + assertExactKeys( + measurement, + ['kind', 'name', 'unit', 'value'], + [], + `${label} measurement ${measurement.name}`, + ) + assert( + typeof measurement.unit === 'string' && measurement.unit.length > 0, + `${label} measurement ${measurement.name} has no unit`, + ) + finiteNumber(measurement.value, `${label} measurement ${measurement.name}`) + continue + } + if (measurement.kind === 'distribution') { + assertExactKeys( + measurement, + ['kind', 'name', 'unit', 'n', 'minimum', 'median', 'p90', 'p95', 'p99', 'maximum'], + [], + `${label} measurement ${measurement.name}`, + ) + assert( + typeof measurement.unit === 'string' && measurement.unit.length > 0, + `${label} measurement ${measurement.name} has no unit`, + ) + assert( + Number.isInteger(measurement.n) && measurement.n > 0, + `${label} measurement ${measurement.name} has no sample count`, + ) + for (const field of ['minimum', 'median', 'p90', 'p95', 'p99', 'maximum']) { + finiteNumber(measurement[field], `${label} measurement ${measurement.name}.${field}`) + } + assert( + measurement.minimum <= measurement.median && + measurement.median <= measurement.p90 && + measurement.p90 <= measurement.p95 && + measurement.p95 <= measurement.p99 && + measurement.p99 <= measurement.maximum, + `${label} measurement ${measurement.name} distribution is not ordered`, + ) + distributions += 1 + continue + } + if (measurement.kind === 'unavailable' || measurement.kind === 'uncaptured') { + assertExactKeys( + measurement, + ['kind', 'name', 'reason'], + [], + `${label} measurement ${measurement.name}`, + ) + assert( + typeof measurement.reason === 'string' && measurement.reason.length > 0, + `${label} measurement ${measurement.name} has no reason`, + ) + continue + } + throw new Error(`${label} measurement ${measurement.name} has invalid kind`) + } + if (requireDistribution) { + assert(distributions > 0, `${label} has no measured distribution`) + } +} + +const PERFORMANCE_PERCENTILES = new Set(['minimum', 'median', 'p90', 'p95', 'p99', 'maximum']) +export const REQUIRED_PERFORMANCE_IDS = Object.freeze( + Array.from({ length: 10 }, (_, index) => `PERF-${String(index + 1).padStart(2, '0')}`), +) +export const REQUIRED_PERFORMANCE_TARGETS = Object.freeze({ + 'PERF-01': Object.freeze({ + metric: 'process-start-to-first-visible-frame', + percentile: 'p95', + operator: '<=', + value: 250, + }), + 'PERF-02': Object.freeze({ + metric: 'process-start-to-first-visible-frame', + percentile: 'p95', + operator: '<=', + value: 1_000, + }), + 'PERF-03': Object.freeze({ + metric: 'idle-key-to-updated-frame', + percentile: 'p95', + operator: '<=', + value: 50, + }), + 'PERF-04': Object.freeze({ + metric: 'runtime-event-to-updated-frame', + percentile: 'p95', + operator: '<=', + value: 50, + }), + 'PERF-05': Object.freeze({ + metric: 'replay-reduce-10000-events', + percentile: 'p95', + operator: '<=', + value: 2_000, + }), + 'PERF-06': Object.freeze({ + metric: 'open-100000-event-conversation', + percentile: 'p95', + operator: '<=', + value: 2_000, + }), + 'PERF-07': Object.freeze({ + metric: 'resize-during-stream-frame', + percentile: 'p95', + operator: '<=', + value: 100, + }), + 'PERF-08': Object.freeze({ + metric: 'idle-cpu', + percentile: 'median', + operator: '<=', + value: 1, + }), + 'PERF-09': Object.freeze({ + metric: 'resident-memory-10000-event-conversation', + percentile: 'p95', + operator: '<=', + value: 150, + }), + 'PERF-10': Object.freeze({ + metric: 'database-growth-10000-events', + percentile: 'maximum', + operator: '<=', + value: 50, + }), +}) + +function validatePerformanceTarget(name, target, label) { + assertExactKeys(target, ['metric', 'percentile', 'operator', 'value'], [], `${label} target`) + assert( + typeof target.metric === 'string' && target.metric.length > 0, + `${label} target has no metric`, + ) + assert(PERFORMANCE_PERCENTILES.has(target.percentile), `${label} target has no valid percentile`) + assert(['<', '<=', '>', '>='].includes(target.operator), `${label} target has no valid operator`) + finiteNumber(target.value, `${label} target value`) + const required = REQUIRED_PERFORMANCE_TARGETS[name] + assert(required, `${label} has no required target definition`) + assert( + canonicalJson(target) === canonicalJson(required), + `${label} target differs from the required target`, + ) +} + +function validatePerformanceEnvironment(environment, label) { + assertExactKeys( + environment, + ['machine', 'os', 'node', 'terminal', 'dimensions', 'database', 'eventCount'], + [], + `${label} environment`, + ) + for (const field of ['machine', 'os', 'node', 'terminal', 'dimensions', 'database']) + assert( + typeof environment[field] === 'string' && environment[field].length > 0, + `${label} environment has no ${field}`, + ) + assert( + Number.isInteger(environment.eventCount) && environment.eventCount >= 0, + `${label} environment has no event count`, + ) +} + +export function validatePerformanceMeasurements(measurements, label) { + assert(Array.isArray(measurements) && measurements.length > 0, `${label} has no measurements`) + for (const measurement of measurements) { + assert(measurement.kind === 'distribution', `${label} requires distributions only`) + assertExactKeys( + measurement, + [ + 'kind', + 'name', + 'unit', + 'n', + 'minimum', + 'median', + 'p90', + 'p95', + 'p99', + 'maximum', + 'target', + 'environment', + 'state', + 'repetitions', + ], + [], + `${label} measurement ${measurement.name ?? ''}`, + ) + assert(/^PERF-\d{2}$/u.test(measurement.name), `${label} has an unbound performance name`) + assert( + typeof measurement.unit === 'string' && measurement.unit.length > 0, + `${label} measurement ${measurement.name} has no unit`, + ) + assert( + Number.isInteger(measurement.n), + `${label} measurement ${measurement.name} has invalid n`, + ) + assert(measurement.n >= 2, `${label} measurement ${measurement.name} rejects n=1`) + for (const field of ['minimum', 'median', 'p90', 'p95', 'p99', 'maximum']) + finiteNumber(measurement[field], `${label} measurement ${measurement.name}.${field}`) + assert( + measurement.minimum <= measurement.median && + measurement.median <= measurement.p90 && + measurement.p90 <= measurement.p95 && + measurement.p95 <= measurement.p99 && + measurement.p99 <= measurement.maximum, + `${label} measurement ${measurement.name} distribution is not ordered`, + ) + assert( + Number.isInteger(measurement.repetitions) && measurement.repetitions >= 2, + `${label} measurement ${measurement.name} has invalid repetitions`, + ) + assert( + measurement.n === measurement.repetitions, + `${label} measurement ${measurement.name} n differs from repetitions`, + ) + assert( + measurement.state === 'warm' || measurement.state === 'cold', + `${label} measurement ${measurement.name} has no warm/cold state`, + ) + validatePerformanceTarget( + measurement.name, + measurement.target, + `${label} measurement ${measurement.name}`, + ) + validatePerformanceEnvironment( + measurement.environment, + `${label} measurement ${measurement.name}`, + ) + const observed = measurement[measurement.target.percentile] + const target = measurement.target.value + const passes = + measurement.target.operator === '<' + ? observed < target + : measurement.target.operator === '<=' + ? observed <= target + : measurement.target.operator === '>' + ? observed > target + : observed >= target + assert(passes, `${label} measurement ${measurement.name} is outside its target`) + } +} + +export function validatePerformanceMatrix(measurements, label) { + validatePerformanceMeasurements(measurements, label) + const names = new Set(measurements.map((measurement) => measurement.name)) + assert( + measurements.length === REQUIRED_PERFORMANCE_IDS.length && + names.size === REQUIRED_PERFORMANCE_IDS.length, + `${label} must contain PERF-01 through PERF-10`, + ) + for (const name of REQUIRED_PERFORMANCE_IDS) + assert(names.has(name), `${label} is missing ${name}`) +} diff --git a/scripts/release-files.mjs b/scripts/release-files.mjs new file mode 100644 index 0000000..e9fe7aa --- /dev/null +++ b/scripts/release-files.mjs @@ -0,0 +1,144 @@ +import { constants } from 'node:fs' +import { randomUUID } from 'node:crypto' +import { open, lstat, realpath, unlink, link, mkdir } from 'node:fs/promises' +import { dirname, isAbsolute, relative, resolve, sep } from 'node:path' + +function fail(message) { + throw new Error(message) +} + +function inside(root, target) { + const location = relative(root, target) + return location !== '..' && !location.startsWith(`..${sep}`) && !isAbsolute(location) +} + +async function assertNoSymlinkComponents(root, target) { + const rootPath = await realpath(root) + const targetPath = resolve(target) + if (!inside(rootPath, targetPath)) fail(`Path leaves release root: ${target}`) + const location = relative(rootPath, targetPath) + let current = rootPath + for (const component of location ? location.split(sep) : []) { + current = resolve(current, component) + const info = await lstat(current).catch(() => undefined) + if (info?.isSymbolicLink()) fail(`Symlink is not allowed in release path: ${target}`) + } + return targetPath +} + +async function assertNoSymlinkAncestors(path) { + let current = resolve(path) + while (true) { + const info = await lstat(current).catch(() => undefined) + if (info) { + if (info.isSymbolicLink()) fail(`Symlink is not allowed in release path: ${path}`) + if ((await realpath(current)) !== current) + fail(`Release path resolves through a symlink: ${path}`) + return + } + const parent = dirname(current) + if (parent === current) return + current = parent + } +} + +export async function containedArtifactPath(repository, artifactPath) { + if (typeof artifactPath !== 'string' || artifactPath.length === 0 || isAbsolute(artifactPath)) + fail(`Artifact path must be relative: ${String(artifactPath)}`) + const root = await realpath(repository) + const target = resolve(root, artifactPath) + await assertNoSymlinkComponents(root, target) + const resolvedTarget = await realpath(target).catch(() => + fail(`Artifact is missing: ${artifactPath}`), + ) + if (!inside(root, resolvedTarget)) fail(`Artifact leaves repository: ${artifactPath}`) + if (resolvedTarget !== target) fail(`Artifact path resolves through a symlink: ${artifactPath}`) + return target +} + +export async function containedOutputPath(repository, outputPath) { + if (typeof outputPath !== 'string' || outputPath.length === 0 || isAbsolute(outputPath)) + fail(`Release output path must be relative: ${String(outputPath)}`) + const root = await realpath(repository) + const target = resolve(root, outputPath) + await assertNoSymlinkComponents(root, target) + await assertNoSymlinkAncestors(target) + if (!inside(root, target)) fail(`Release output leaves repository: ${outputPath}`) + return target +} + +function sameFile(left, right) { + if (!left.isFile() || !right.isFile()) return false + if (typeof left.dev === 'number' && typeof right.dev === 'number' && left.dev !== right.dev) + return false + if (typeof left.ino === 'number' && typeof right.ino === 'number' && left.ino !== right.ino) + return false + return true +} + +export async function readRegularFileNoFollow(path) { + const before = await lstat(path) + if (!before.isFile() || before.isSymbolicLink()) + fail(`Regular non-symlink file required: ${path}`) + const noFollow = constants.O_NOFOLLOW ?? 0 + const handle = await open(path, constants.O_RDONLY | noFollow) + try { + const after = await handle.stat() + if (!sameFile(before, after)) fail(`Release file changed while opening: ${path}`) + const bytes = await handle.readFile() + const resolvedPath = await realpath(path) + if (resolvedPath !== resolve(path)) fail(`Release file resolved through a symlink: ${path}`) + return bytes + } finally { + await handle.close() + } +} + +export async function readContainedFile(repository, artifactPath) { + const path = await containedArtifactPath(repository, artifactPath) + return readRegularFileNoFollow(path) +} + +export async function fsyncDirectory(path) { + const handle = await open(path, constants.O_RDONLY | (constants.O_DIRECTORY ?? 0)) + try { + await handle.sync() + } finally { + await handle.close() + } +} + +export async function writeExclusiveAtomic(path, data, mode = 0o600) { + const parent = dirname(path) + await assertNoSymlinkAncestors(parent) + await mkdir(parent, { recursive: true, mode: 0o700 }) + const assertParent = async () => { + const parentInfo = await lstat(parent) + if (!parentInfo.isDirectory() || parentInfo.isSymbolicLink()) + fail(`Release output parent is not a real directory: ${parent}`) + if ((await realpath(parent)) !== resolve(parent)) + fail(`Release output parent resolves through a symlink: ${parent}`) + } + await assertParent() + const temporary = resolve(parent, `.${randomUUID()}.tmp`) + const noFollow = constants.O_NOFOLLOW ?? 0 + let handle + try { + handle = await open( + temporary, + constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | noFollow, + mode, + ) + await handle.writeFile(data) + await handle.sync() + await handle.close() + handle = undefined + await assertParent() + await link(temporary, path) + await unlink(temporary) + await fsyncDirectory(parent) + } finally { + await handle?.close().catch(() => {}) + await unlink(temporary).catch(() => {}) + } +} diff --git a/scripts/release-visual-proof.mjs b/scripts/release-visual-proof.mjs new file mode 100644 index 0000000..da71dda --- /dev/null +++ b/scripts/release-visual-proof.mjs @@ -0,0 +1,190 @@ +import { createHash } from 'node:crypto' +import { join } from 'node:path' +import { assert } from './release-evidence.mjs' +import { containedArtifactPath, readRegularFileNoFollow } from './release-files.mjs' + +const SHA256_PATTERN = /^[a-f0-9]{64}$/u + +async function sha256(path) { + return createHash('sha256') + .update(await readRegularFileNoFollow(path)) + .digest('hex') +} + +export async function validateVisualProof({ packageProof, visualProof, artifactRoot }) { + const packageJson = JSON.parse( + (await readRegularFileNoFollow(join(artifactRoot, '..', '..', 'package.json'))).toString( + 'utf8', + ), + ) + const expectedRenderer = { + package: `@earendil-works/pi-tui@${packageJson.dependencies?.['@earendil-works/pi-tui'] ?? ''}`, + pty: `node-pty@${packageJson.devDependencies?.['node-pty'] ?? ''}`, + emulator: `@xterm/headless@${packageJson.devDependencies?.['@xterm/headless'] ?? ''}`, + } + assert(SHA256_PATTERN.test(packageProof.sha256), 'Package proof has no valid tarball SHA-256') + assert(SHA256_PATTERN.test(packageProof.sourceDigest), 'Package proof has no exact source digest') + assert(packageProof.isolatedBuild === true, 'Package proof was not built in isolation') + assert( + packageProof.sourceCheckout === 'isolated-copy-of-worktree', + 'Package proof does not identify its isolated source checkout', + ) + assert(visualProof.tarballSha256 === packageProof.sha256, 'Visual proof used another tarball') + assert( + visualProof.binary === 'clean npm install from generated tarball', + 'Visual proof is not packed', + ) + assert(visualProof.schemaVersion === 3, 'Visual proof schema is not current') + assert(SHA256_PATTERN.test(visualProof.binarySha256), 'Visual proof has no binary SHA-256') + assert(Array.isArray(visualProof.artifacts), 'Visual proof has no artifact list') + assert( + visualProof.provenance?.renderer?.package === expectedRenderer.package, + 'Visual proof renderer package differs from package.json', + ) + assert( + visualProof.provenance?.renderer?.pty === expectedRenderer.pty, + 'Visual proof PTY differs from package.json', + ) + assert( + visualProof.provenance?.renderer?.emulator === expectedRenderer.emulator, + 'Visual proof emulator differs from package.json', + ) + assert( + typeof visualProof.provenance?.renderer?.node === 'string', + 'Visual proof has no Node provenance', + ) + assert( + visualProof.provenance?.raster?.colorMode === 'sRGB 8-bit', + 'Visual proof color mode is not pinned', + ) + assert( + visualProof.provenance?.raster?.fontFamily === 'DejaVu Sans Mono', + 'Visual proof font family is not pinned', + ) + assert( + typeof visualProof.provenance?.raster?.font === 'string', + 'Visual proof has no font provenance', + ) + assert( + typeof visualProof.provenance?.raster?.agg === 'string', + 'Visual proof has no agg provenance', + ) + assert( + typeof visualProof.provenance?.raster?.imagemagick === 'string', + 'Visual proof has no ImageMagick provenance', + ) + for (const [columns, rows] of [ + [40, 12], + [80, 24], + [120, 40], + [200, 60], + ]) { + for (const suffix of ['.txt', '-plain.txt', '.png']) { + assert( + visualProof.artifacts.some( + (artifact) => + artifact.columns === columns && + artifact.rows === rows && + artifact.path === `${columns}x${rows}${suffix}`, + ), + `Visual proof is missing ${columns}x${rows}${suffix}`, + ) + } + } + assert( + visualProof.artifacts.some((artifact) => artifact.path === '80x24-flow.gif'), + 'Visual proof is missing 80x24-flow.gif', + ) + const artifactPaths = new Set() + for (const artifact of visualProof.artifacts) { + assert( + typeof artifact.path === 'string' && artifact.path.length > 0, + 'Visual artifact has no path', + ) + assert(!artifactPaths.has(artifact.path), `Visual proof repeats ${artifact.path}`) + artifactPaths.add(artifact.path) + } + const requiredVisualStates = [ + 'empty', + 'active-streaming', + 'interaction', + 'fork-preview', + 'graph-or-analysis', + 'narrow', + 'failure-or-reconnect', + ] + assert(Array.isArray(visualProof.states), 'Visual proof has no required state matrix') + const visualStates = new Map(visualProof.states.map((state) => [state.name, state])) + assert(visualStates.size === visualProof.states.length, 'Visual proof repeats a required state') + for (const name of requiredVisualStates) { + const state = visualStates.get(name) + assert(state, `Visual proof is missing ${name} state`) + assert( + Number.isInteger(state.columns) && Number.isInteger(state.rows), + `${name} has no dimensions`, + ) + assert( + state.artifacts && typeof state.artifacts === 'object' && !Array.isArray(state.artifacts), + `${name} has no artifact map`, + ) + for (const kind of ['semantic-state', 'plain-frame', 'asciicast', 'ansi', 'png']) { + const path = state.artifacts[kind] + assert(typeof path === 'string' && path.length > 0, `${name} is missing ${kind}`) + const artifact = visualProof.artifacts.find((candidate) => candidate.path === path) + assert(artifact, `${name} names an unknown ${kind} artifact`) + assert(artifact.kind === kind, `${name} ${kind} artifact kind differs`) + assert( + artifact.columns === state.columns && artifact.rows === state.rows, + `${name} ${kind} dimensions differ`, + ) + } + const semanticPath = await containedArtifactPath( + join(artifactRoot, 'w6'), + state.artifacts['semantic-state'], + ) + const semantic = JSON.parse((await readRegularFileNoFollow(semanticPath)).toString('utf8')) + assert(semantic.schemaVersion === 2, `${name} semantic state schema differs`) + assert(semantic.capturePhase === 'atomic-signal-frame', `${name} capture phase differs`) + assert( + semantic.captureRevision === semantic.packedState?.view?.revision, + `${name} frame revision differs`, + ) + assert( + semantic.packedState?.capturePhase === 'atomic-signal-frame', + `${name} packed state phase differs`, + ) + assert( + semantic.packedState?.state?.revision === semantic.packedState?.view?.revision, + `${name} packed state revision differs`, + ) + assert( + semantic.source?.binarySha256 === visualProof.binarySha256, + `${name} binary provenance differs`, + ) + assert( + JSON.stringify(semantic.provenance) === JSON.stringify(visualProof.provenance), + `${name} renderer provenance differs`, + ) + if (name === 'interaction') { + assert(semantic.packedState?.view?.interactions?.length === 1, 'Interaction state is empty') + assert( + semantic.packedState.view.interactions[0].answerSpec?.kind === 'boolean', + 'Interaction answer spec is not real', + ) + } + if (name === 'fork-preview') { + assert(semantic.packedState?.view?.forkPreview?.allowed === true, 'Fork state is unavailable') + assert( + typeof semantic.packedState.view.forkPreview.destination === 'string', + 'Fork destination is missing', + ) + } + } + for (const artifact of visualProof.artifacts) { + const path = await containedArtifactPath(join(artifactRoot, 'w6'), artifact.path) + assert( + (await sha256(path)) === artifact.sha256, + `Visual artifact hash changed: ${artifact.path}`, + ) + } +} diff --git a/scripts/release/artifact-store.mjs b/scripts/release/artifact-store.mjs new file mode 100644 index 0000000..813ae50 --- /dev/null +++ b/scripts/release/artifact-store.mjs @@ -0,0 +1,65 @@ +import { createHash } from 'node:crypto' +import { join, relative, resolve } from 'node:path' + +import { + containedArtifactPath, + readRegularFileNoFollow, + writeExclusiveAtomic, +} from '../release-files.mjs' + +const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u + +function assert(condition, message) { + if (!condition) throw new Error(message) +} + +function sha256(bytes) { + return createHash('sha256').update(bytes).digest('hex') +} + +function artifactPath(root, id, extension) { + assert(SAFE_ID.test(id), `Invalid release artifact identifier: ${id}`) + return join(root, `${id}${extension}`) +} + +async function preserveOrWrite(path, bytes) { + try { + const existing = await readRegularFileNoFollow(path) + assert(existing.equals(bytes), `Release artifact changed: ${path}`) + } catch (error) { + if (error?.code !== 'ENOENT' && !/missing|no such file/iu.test(String(error))) throw error + await writeExclusiveAtomic(path, bytes) + } +} + +export function createArtifactStore({ + repository, + relativeRoot = 'artifacts/verification/release/logs', +}) { + const root = resolve(repository, relativeRoot) + return { + root, + async put({ id, bytes, mediaType = 'text/plain; charset=utf-8', extension = '.log' }) { + assert(Buffer.isBuffer(bytes), `Artifact ${id} must be bytes`) + const path = artifactPath(root, id, extension) + await preserveOrWrite(path, bytes) + return { + id, + path: relative(repository, path), + sha256: sha256(bytes), + mediaType, + } + }, + async register({ id, path, mediaType = 'application/octet-stream' }) { + assert(SAFE_ID.test(id), `Invalid release artifact identifier: ${id}`) + const absolute = await containedArtifactPath(repository, path) + const bytes = await readRegularFileNoFollow(absolute) + return { id, path: relative(repository, absolute), sha256: sha256(bytes), mediaType } + }, + } +} + +export function digestBytes(bytes) { + assert(Buffer.isBuffer(bytes), 'Digest input must be bytes') + return sha256(bytes) +} diff --git a/scripts/release/atomic-storage.mjs b/scripts/release/atomic-storage.mjs new file mode 100644 index 0000000..7236c1d --- /dev/null +++ b/scripts/release/atomic-storage.mjs @@ -0,0 +1,85 @@ +import { randomUUID } from 'node:crypto' +import { constants } from 'node:fs' +import { lstat, mkdir, open, readdir, realpath, rename, unlink } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' + +function assert(condition, message) { + if (!condition) throw new Error(message) +} + +async function assertDirectory(path) { + const info = await lstat(path) + assert(info.isDirectory() && !info.isSymbolicLink(), `Release path is not a directory: ${path}`) + assert( + (await realpath(path)) === resolve(path), + `Release path resolves through a symlink: ${path}`, + ) +} + +async function syncDirectory(path) { + const handle = await open(path, constants.O_RDONLY | (constants.O_DIRECTORY ?? 0)) + try { + await handle.sync() + } finally { + await handle.close() + } +} + +export async function writeAtomic(path, data, { mode = 0o600, beforeRename } = {}) { + const parent = dirname(resolve(path)) + await mkdir(parent, { recursive: true, mode: 0o700 }) + await assertDirectory(parent) + const temporary = resolve(parent, `.${randomUUID()}.tmp`) + let handle + let renamed = false + try { + handle = await open( + temporary, + constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), + mode, + ) + await handle.writeFile(data) + await handle.sync() + await handle.close() + handle = undefined + await beforeRename?.() + await assertDirectory(parent) + await rename(temporary, resolve(path)) + renamed = true + await syncDirectory(parent) + } finally { + await handle?.close().catch(() => {}) + if (!renamed) await unlink(temporary).catch(() => {}) + } +} + +export async function writeJsonAtomic(path, value, options = {}) { + await writeAtomic(path, `${JSON.stringify(value, null, 2)}\n`, options) +} + +export async function readJson(path, readFile) { + const bytes = await readFile(path) + try { + return JSON.parse(bytes.toString('utf8')) + } catch (error) { + throw new Error(`Invalid release JSON at ${path}`, { cause: error }) + } +} + +export async function cleanTemporaryFiles(directory) { + const info = await lstat(directory).catch((error) => { + if (error?.code === 'ENOENT') return undefined + throw error + }) + if (!info) return [] + await assertDirectory(directory) + const entries = await readdir(directory, { withFileTypes: true }) + const removed = [] + for (const entry of entries) { + if (!entry.isFile() || !/^\.[0-9a-f-]+\.tmp$/u.test(entry.name)) continue + const path = resolve(directory, entry.name) + await unlink(path) + removed.push(path) + } + return removed +} diff --git a/scripts/release/bindings.mjs b/scripts/release/bindings.mjs new file mode 100644 index 0000000..698a819 --- /dev/null +++ b/scripts/release/bindings.mjs @@ -0,0 +1,105 @@ +import { REQUIRED_CHECKS } from '../release-check-catalog.mjs' + +const REQUIREMENT_ID = /^[A-Z]{2,4}-[0-9]{2}$/u + +function assert(condition, message) { + if (!condition) throw new Error(message) +} + +function asEntries(input) { + if (Array.isArray(input)) return input.map((entry) => [entry?.id, entry]) + assert(input && typeof input === 'object', 'Requirement bindings must be an array or object') + return Object.entries(input).map(([id, value]) => [id, { id, ...value }]) +} + +export function normalizeRequirementBindings(input, requirementIds, checkIds) { + const knownRequirements = new Set(requirementIds) + const knownChecks = new Set(checkIds ?? REQUIRED_CHECKS.keys()) + const entries = asEntries(input) + const bindings = new Map() + for (const [id, value] of entries) { + assert( + typeof id === 'string' && REQUIREMENT_ID.test(id), + `Invalid requirement identifier: ${id}`, + ) + assert(knownRequirements.has(id), `Unknown requirement identifier: ${id}`) + assert(!bindings.has(id), `Duplicate requirement identifier: ${id}`) + assert(value && typeof value === 'object' && !Array.isArray(value), `Invalid binding for ${id}`) + assert(Array.isArray(value.checks) && value.checks.length > 0, `${id} has no check bindings`) + assert( + Array.isArray(value.artifacts) && value.artifacts.length > 0, + `${id} has no artifact bindings`, + ) + const checks = [...value.checks] + const artifacts = [...value.artifacts] + assert(new Set(checks).size === checks.length, `${id} repeats a check binding`) + assert(new Set(artifacts).size === artifacts.length, `${id} repeats an artifact binding`) + for (const check of checks) assert(knownChecks.has(check), `${id} names unknown check ${check}`) + for (const artifact of artifacts) + assert( + typeof artifact === 'string' && artifact.length > 0, + `${id} has an invalid artifact binding`, + ) + bindings.set(id, { checks, artifacts }) + } + for (const id of knownRequirements) assert(bindings.has(id), `Requirement ${id} has no binding`) + return bindings +} + +export function normalizeRequirementCheckBindings(input, requirementIds, checkIds) { + const knownRequirements = new Set(requirementIds) + const knownChecks = new Set(checkIds ?? REQUIRED_CHECKS.keys()) + const entries = asEntries(input) + const bindings = new Map() + for (const [id, value] of entries) { + assert( + typeof id === 'string' && REQUIREMENT_ID.test(id), + `Invalid requirement identifier: ${id}`, + ) + assert(knownRequirements.has(id), `Unknown requirement identifier: ${id}`) + assert(!bindings.has(id), `Duplicate requirement identifier: ${id}`) + assert(value && typeof value === 'object' && !Array.isArray(value), `Invalid binding for ${id}`) + assert(Array.isArray(value.checks) && value.checks.length > 0, `${id} has no check bindings`) + const checks = [...value.checks] + assert(new Set(checks).size === checks.length, `${id} repeats a check binding`) + for (const check of checks) assert(knownChecks.has(check), `${id} names unknown check ${check}`) + bindings.set(id, { checks }) + } + for (const id of knownRequirements) assert(bindings.has(id), `Requirement ${id} has no binding`) + return bindings +} + +export function materializeRequirementBindings( + checkBindings, + { artifactsByCheck = new Map(), additionalArtifacts = [] } = {}, +) { + assert(checkBindings instanceof Map, 'Requirement check bindings must be a map') + const extra = [...new Set(additionalArtifacts)] + return new Map( + [...checkBindings.entries()].map(([id, binding]) => { + const artifacts = [ + ...extra, + ...binding.checks.flatMap((check) => artifactsByCheck.get(check) ?? []), + ] + assert(artifacts.length > 0, `${id} has no materialized artifact bindings`) + assert( + new Set(artifacts).size === artifacts.length, + `${id} repeats a materialized artifact binding`, + ) + return [id, { checks: [...binding.checks], artifacts }] + }), + ) +} + +export function requirementsObject(bindings) { + return Object.fromEntries( + [...bindings.entries()].sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)), + ) +} + +export function requirementIdsForCheck(bindings, checkId) { + return [...bindings.entries()] + .filter(([, binding]) => binding.checks.includes(checkId)) + .map(([id]) => id) + .sort() +} diff --git a/scripts/release/build-identity.mjs b/scripts/release/build-identity.mjs new file mode 100644 index 0000000..7a28977 --- /dev/null +++ b/scripts/release/build-identity.mjs @@ -0,0 +1,309 @@ +import { execFileSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { readdir } from 'node:fs/promises' +import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path' + +import { REQUIREMENT_PATTERN } from '../release-check-catalog.mjs' +import { canonicalJson } from '../release-evidence.mjs' +import { readRegularFileNoFollow } from '../release-files.mjs' +import { + assertPackageFileManifestMatches, + packageFileBytesFromTarball, + packageFileManifestFromTarball, + readPackageProof, + sourceDigest, +} from './package-archive.mjs' + +const INTEGRITY_PATTERN = /sha512-(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?/u +const GIT_TREE_ALGORITHM = 'git-tree-object-sha1' + +function assert(condition, message) { + if (!condition) throw new Error(message) +} + +function sha256(bytes) { + return createHash('sha256').update(bytes).digest('hex') +} + +function git(repository, ...args) { + return execFileSync('git', args, { cwd: repository, encoding: 'utf8' }).trim() +} + +function isTracked(repository, path) { + try { + execFileSync('git', ['ls-files', '--error-unmatch', '--', path], { + cwd: repository, + stdio: 'ignore', + }) + return true + } catch { + return false + } +} + +function sourceTreeChanges(repository, allowedGeneratedPath) { + const changed = new Set() + for (const path of git(repository, 'diff', '--name-only', 'HEAD').split('\n')) { + if (path) changed.add(path) + } + for (const path of git(repository, 'ls-files', '--others', '--exclude-standard').split('\n')) { + if (path) changed.add(path) + } + return [...changed] + .filter( + (path) => + path !== allowedGeneratedPath && !path.startsWith('artifacts/verification/release/'), + ) + .sort() +} + +async function filesBelow(root) { + const files = [] + for (const entry of await readdir(root, { withFileTypes: true })) { + const path = join(root, entry.name) + if (entry.isDirectory()) files.push(...(await filesBelow(path))) + else if (entry.isFile()) files.push(path) + } + return files +} + +function lockfileIntegrity(lockText, name, version) { + const candidates = [` '${name}@`, ` ${name}@`] + const lines = lockText.split('\n') + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index] + if (!candidates.some((prefix) => line.startsWith(prefix)) || !line.endsWith(':')) continue + if (!line.includes(`@${version}`)) continue + for (let next = index + 1; next < Math.min(lines.length, index + 12); next += 1) { + if (/^ {2}\S.*:$/.test(lines[next]) && !lines[next].startsWith(' ')) break + const match = lines[next].match(/integrity:\s*(sha512-[A-Za-z0-9+/=]+)/u) + if (match?.[1] && INTEGRITY_PATTERN.test(match[1])) return match[1] + } + } + throw new Error(`pnpm-lock.yaml has no integrity for ${name}@${version}`) +} + +export async function readDependencyRecords({ repository, packageJson, lockfileText } = {}) { + const root = resolve(repository) + const packageValue = + packageJson ?? + JSON.parse((await readRegularFileNoFollow(join(root, 'package.json'))).toString('utf8')) + const lockText = + lockfileText ?? (await readRegularFileNoFollow(join(root, 'pnpm-lock.yaml'))).toString('utf8') + const records = [] + for (const [name, version] of Object.entries(packageValue.dependencies ?? {})) { + assert( + typeof version === 'string' && version.length > 0, + `Dependency ${name} has no exact version`, + ) + records.push({ name, version, integrity: lockfileIntegrity(lockText, name, version) }) + } + return records.sort((left, right) => + left.name < right.name ? -1 : left.name > right.name ? 1 : 0, + ) +} + +export async function readRequirementIds(repository) { + const root = resolve(repository) + const docsRoot = join(root, 'docs') + const occurrences = new Map() + const definitions = new Map() + for (const path of (await filesBelow(docsRoot)).filter((candidate) => + candidate.endsWith('.md'), + )) { + const text = (await readRegularFileNoFollow(path)).toString('utf8') + for (const match of text.matchAll(REQUIREMENT_PATTERN)) { + const id = match[0] + const list = occurrences.get(id) ?? [] + list.push({ path: relative(root, path), offset: match.index ?? 0 }) + occurrences.set(id, list) + } + let section = '' + for (const [lineNumber, line] of text.split('\n').entries()) { + const heading = line.match(/^#{1,6}\s+(.+)$/u) + if (heading) section = heading[1].replace(/[`*_]/gu, '').trim().toLowerCase() + const definition = line.match(/^\s*\|\s*`?([A-Z]{2,4}-[0-9]{2})`?\s*\|/u) + if (!definition || !isRequirementDefinitionSection(section)) continue + const id = definition[1] + const list = definitions.get(id) ?? [] + list.push({ path: relative(root, path), line: lineNumber + 1 }) + definitions.set(id, list) + } + } + const duplicates = [...definitions.entries()].filter(([, locations]) => locations.length > 1) + assert( + duplicates.length === 0, + `Duplicate requirement definitions in specifications: ${duplicates + .map( + ([id, locations]) => + `${id} (${locations.map(({ path, line }) => `${path}:${line}`).join(', ')})`, + ) + .join('; ')}`, + ) + assert(occurrences.size > 0, 'No release requirement identifiers found in docs') + return [...occurrences.keys()].sort() +} + +function isRequirementDefinitionSection(section) { + if (/mapping|ownership|admissible evidence|requirement range/iu.test(section)) return false + return /acceptance|completion checks|vertical-slice checks|required live matrix|performance targets|semantic release cases/iu.test( + section, + ) +} + +export function dependencyDigest(dependencies) { + return sha256(Buffer.from(canonicalJson(dependencies))) +} + +export function identityDigest(identity) { + return sha256( + Buffer.from( + canonicalJson({ + braidVersion: identity.braidVersion, + gitCommit: identity.gitCommit, + gitTree: identity.gitTree, + treeSha256: identity.treeSha256, + tarballSha256: identity.tarballSha256, + packageIntegrity: identity.packageIntegrity, + packageFileManifestDigest: identity.packageFileManifestDigest, + dependencyDigest: identity.dependencyDigest, + dependencies: identity.dependencies, + requirementIds: identity.requirementIds, + }), + ), + ) +} + +export function bindingForCheck(identity, requirementIds) { + assert(Array.isArray(requirementIds), 'Check requirement identifiers must be an array') + assert(requirementIds.length > 0, 'A check must bind to at least one requirement') + assert( + new Set(requirementIds).size === requirementIds.length, + 'Check requirement identifiers are duplicated', + ) + assert(Array.isArray(identity.requirementIds), 'Build identity has no requirement identifiers') + assert( + new Set(identity.requirementIds).size === identity.requirementIds.length, + 'Build identity requirement identifiers are duplicated', + ) + const ids = [...requirementIds].sort() + for (const id of ids) + assert(identity.requirementIds.includes(id), `Unknown requirement identifier: ${id}`) + return { + schemaVersion: 1, + tarballSha256: identity.tarballSha256, + gitCommit: identity.gitCommit, + gitTree: identity.gitTree, + dependencyDigest: identity.dependencyDigest, + packageFileManifestDigest: identity.packageFileManifestDigest, + dependencies: identity.dependencies.map(({ name, version, integrity }) => ({ + name, + version, + integrity, + })), + requirementIds: ids, + } +} + +function packageManifestFromProof(proof) { + const candidates = [proof.packageFileManifest, proof.fileManifest].filter( + (value) => value !== undefined, + ) + assert(candidates.length === 1, 'Package proof must contain exactly one package file manifest') + return candidates[0] +} + +export async function readBuildIdentity({ + repository, + tarballPath, + packageProofPath, + packageProof, + requirementIds, +} = {}) { + const root = resolve(repository) + assert( + typeof tarballPath === 'string' && tarballPath.length > 0, + 'Packed tarball path is required', + ) + const tarball = resolve(tarballPath) + const tarballRelative = relative(root, tarball) + assert( + tarballRelative !== '' && + !isAbsolute(tarballRelative) && + tarballRelative !== '..' && + !tarballRelative.startsWith(`..${sep}`), + 'Packed tarball must be inside the release checkout', + ) + assert( + !isTracked(root, tarballRelative), + 'Packed tarball path must be an untracked generated file', + ) + const tarballBytes = await readRegularFileNoFollow(tarball) + const packageJson = JSON.parse( + (await readRegularFileNoFollow(join(root, 'package.json'))).toString('utf8'), + ) + const proof = await readPackageProof({ repository: root, packageProofPath, packageProof }) + const dependencies = await readDependencyRecords({ repository: root, packageJson }) + const ids = requirementIds ?? (await readRequirementIds(root)) + assert(Array.isArray(ids), 'Requirement identifiers are not an array') + assert(new Set(ids).size === ids.length, 'Requirement identifiers are duplicated') + const sourceChanges = sourceTreeChanges(root, tarballRelative) + assert( + sourceChanges.length === 0, + `Source tree is not clean; cannot bind tarball to HEAD: ${sourceChanges.join(', ')}`, + ) + const gitCommit = git(root, 'rev-parse', 'HEAD') + const treeSha256 = git(root, 'rev-parse', 'HEAD^{tree}') + assert(/^[a-f0-9]{40}$/u.test(gitCommit), 'Git commit is not a full SHA') + assert(/^[a-f0-9]{40}$/u.test(treeSha256), 'Git tree is not a full SHA') + const gitTree = { algorithm: GIT_TREE_ALGORITHM, value: treeSha256 } + const tarballSha256 = sha256(tarballBytes) + const packageIntegrity = `sha512-${createHash('sha512').update(tarballBytes).digest('base64')}` + const cleanSourceDigest = await sourceDigest(root, new Set([tarball])) + assert( + proof && typeof proof === 'object' && !Array.isArray(proof), + 'Package proof is not an object', + ) + assert(proof.isolatedBuild === true, 'Package proof was not created in an isolated build') + assert( + proof.sourceCheckout === 'isolated-copy-of-worktree', + 'Package proof source checkout is not isolated', + ) + assert(proof.gitCommit === gitCommit, 'Package proof was built from another Git commit') + assert(proof.treeSha256 === treeSha256, 'Package proof was built from another Git tree') + assert(proof.sha256 === tarballSha256, 'Package proof tarball digest differs') + assert(proof.version === packageJson.version, 'Package proof version differs from package.json') + assert( + proof.sourceDigest === cleanSourceDigest, + 'Package proof source digest differs from the clean tree', + ) + if (typeof proof.tarball === 'string') + assert(basename(tarball) === proof.tarball, 'Tarball filename differs from package proof') + const actualManifest = packageFileManifestFromTarball(tarballBytes) + const packageFileManifest = assertPackageFileManifestMatches( + packageManifestFromProof(proof), + actualManifest, + ) + const packedPackageJson = JSON.parse( + packageFileBytesFromTarball(tarballBytes, 'package/package.json').toString('utf8'), + ) + assert( + canonicalJson(packedPackageJson) === canonicalJson(packageJson), + 'Packed package.json differs from HEAD', + ) + return { + braidVersion: packageJson.version, + gitCommit, + gitTree, + treeSha256, + clean: true, + tarballSha256, + packageIntegrity, + dependencies, + dependencyDigest: dependencyDigest(dependencies), + packageFileManifest, + packageFileManifestDigest: packageFileManifest.digest, + requirementIds: [...ids].sort(), + tarballPath: tarballRelative, + } +} diff --git a/scripts/release/collect-release-evidence.mjs b/scripts/release/collect-release-evidence.mjs new file mode 100644 index 0000000..976f14f --- /dev/null +++ b/scripts/release/collect-release-evidence.mjs @@ -0,0 +1,44 @@ +import { createPrivateKey, createPublicKey } from 'node:crypto' +import { lstat, readFile } from 'node:fs/promises' +import { resolve } from 'node:path' + +import { collectReleaseEvidence } from './collector.mjs' + +function option(name) { + const index = process.argv.indexOf(name) + return index === -1 ? undefined : process.argv[index + 1] +} + +const repository = resolve( + option('--repository') ?? + process.env.BRAID_RELEASE_CHECKOUT ?? + new URL('../', import.meta.url).pathname, +) +const tarballPath = option('--tarball') +const packageProofPath = option('--package-proof') ?? 'artifacts/verification/w6/package-proof.json' +const requirementBindingsPath = option('--requirements') +const privateKeyPath = option('--signing-key') ?? process.env.BRAID_RELEASE_SIGNING_KEY_PATH +const publicKeyPath = + option('--public-key') ?? resolve(repository, 'release/execution-public-key.pem') +if (!tarballPath || !requirementBindingsPath || !privateKeyPath) { + throw new Error('--tarball, --requirements, and --signing-key are required') +} +const privateInfo = await lstat(privateKeyPath) +if (!privateInfo.isFile() || privateInfo.isSymbolicLink() || (privateInfo.mode & 0o077) !== 0) + throw new Error('Release signing key must be a non-symlink owner-only file') +const signingKey = createPrivateKey(await readFile(privateKeyPath, 'utf8')) +const publicKey = createPublicKey(await readFile(publicKeyPath, 'utf8')) +const requirementBindings = JSON.parse( + await readFile(resolve(repository, requirementBindingsPath), 'utf8'), +) +const result = await collectReleaseEvidence({ + repository, + tarballPath: resolve(repository, tarballPath), + packageProofPath, + requirementBindings, + signingKey, + publicKey, +}) +process.stdout.write( + `Collected ${result.envelope.checks.length} release checks with result ${result.result}.\n`, +) diff --git a/scripts/release/collection-contract.mjs b/scripts/release/collection-contract.mjs new file mode 100644 index 0000000..af20a40 --- /dev/null +++ b/scripts/release/collection-contract.mjs @@ -0,0 +1,311 @@ +import { createHash } from 'node:crypto' + +import { + canonicalJson, + validateMeasurements, + validatePerformanceMatrix, +} from '../release-evidence.mjs' +import { bindingForCheck } from './build-identity.mjs' +import { redactText } from './command-runner.mjs' + +const STRUCTURED_CATEGORIES = new Set(['eval', 'live', 'performance']) + +function assert(condition, message) { + if (!condition) throw new Error(message) +} + +function sha256(value) { + return createHash('sha256') + .update(Buffer.from(canonicalJson(value))) + .digest('hex') +} + +function unavailableMeasurement(category, reason) { + return { + kind: 'uncaptured', + name: `${category}-evidence`, + reason: redactText(reason), + } +} + +function markers(text, prefix) { + return String(text) + .split('\n') + .filter((line) => line.startsWith(prefix)) + .map((line) => { + try { + return JSON.parse(line.slice(prefix.length)) + } catch (error) { + throw new Error(`Invalid ${prefix} release marker`, { cause: error }) + } + }) +} + +function unavailableEvidence(category, reason, result = 'uncaptured') { + const safeReason = redactText(reason) + return { + result, + measurements: [unavailableMeasurement(category, safeReason)], + reason: safeReason, + } +} + +function resultMarkerValue(category, values) { + if (values.length === 0) return undefined + if (values.length !== 1) + throw new Error(`${category} emitted duplicate BRAID_RELEASE_RESULT_JSON markers`) + const value = values[0] + assert( + value && typeof value === 'object' && !Array.isArray(value), + `${category} result marker is not an object`, + ) + const keys = Object.keys(value) + assert( + keys.every((key) => key === 'status' || key === 'reason'), + `${category} result marker has unknown fields`, + ) + assert(typeof value.status === 'string', `${category} result marker has no status`) + if (value.reason !== undefined) + assert(typeof value.reason === 'string', `${category} result marker reason is not text`) + return value +} + +export function structuredChildEvidence(category, stdoutBytes, durationMs) { + const text = Buffer.from(stdoutBytes).toString('utf8') + let resultMarker + let measurementMarkers + try { + resultMarker = resultMarkerValue(category, markers(text, 'BRAID_RELEASE_RESULT_JSON=')) + measurementMarkers = markers(text, 'BRAID_RELEASE_MEASUREMENTS_JSON=') + } catch (error) { + return unavailableEvidence(category, error.message) + } + if (!STRUCTURED_CATEGORIES.has(category)) { + if (measurementMarkers.length > 0) { + return unavailableEvidence(category, `${category} emitted an unexpected measurement marker`) + } + if (!resultMarker) { + return { + result: 'passed', + measurements: [{ kind: 'scalar', name: 'duration', unit: 'ms', value: durationMs }], + reason: null, + } + } + if (resultMarker.status === 'passed') + return { + result: 'passed', + measurements: [{ kind: 'scalar', name: 'duration', unit: 'ms', value: durationMs }], + reason: null, + } + if (resultMarker.status === 'unavailable') + return unavailableEvidence( + category, + resultMarker.reason ?? 'The check reported unavailable', + 'unavailable', + ) + if (resultMarker.status === 'failed') + return unavailableEvidence( + category, + resultMarker.reason ?? 'The check reported failed', + 'failed', + ) + return unavailableEvidence( + category, + `Unknown ${category} result status: ${resultMarker.status}`, + ) + } + if (!resultMarker) + return unavailableEvidence( + category, + `${category} command completed without BRAID_RELEASE_RESULT_JSON`, + ) + if (resultMarker.status === 'unavailable') { + if (measurementMarkers.length > 0) + return unavailableEvidence( + category, + `${category} reported unavailable and measurements together`, + ) + return unavailableEvidence( + category, + resultMarker.reason ?? 'The check reported unavailable', + 'unavailable', + ) + } + if (resultMarker.status === 'failed') { + if (measurementMarkers.length > 0) + return unavailableEvidence(category, `${category} reported failed and measurements together`) + return unavailableEvidence( + category, + resultMarker.reason ?? 'The check reported failed', + 'failed', + ) + } + if (resultMarker.status !== 'passed') + return unavailableEvidence( + category, + `Unknown ${category} result status: ${resultMarker.status}`, + ) + if (measurementMarkers.length !== 1) + return unavailableEvidence(category, `${category} must emit exactly one measurement marker`) + let evidence + try { + evidence = measurementMarkers[0] + } catch (error) { + return unavailableEvidence(category, error.message) + } + const measurements = Array.isArray(evidence) ? evidence : evidence.measurements + try { + if (category === 'performance') validatePerformanceMatrix(measurements, 'Performance evidence') + else validateMeasurements(measurements, `${category} evidence`) + } catch (error) { + return unavailableEvidence(category, error.message) + } + if ( + measurements.some( + (measurement) => measurement.kind === 'unavailable' || measurement.kind === 'uncaptured', + ) + ) { + return unavailableEvidence( + category, + `${category} evidence contains an unavailable or uncaptured measurement`, + ) + } + return { result: 'passed', measurements, reason: null } +} + +export function environmentRecord({ cwd, argv, environment, boundary }) { + const details = { schemaVersion: 1, cwd, argv, environment, boundary } + return { + id: `env-${sha256(details).slice(0, 32)}`, + kind: 'child-process', + details, + } +} + +export function boundaryForCheck({ cwd, processResult, identity, requirementIds }) { + return { + schemaVersion: 1, + shell: false, + cwd, + processTreeStrategy: processResult.processTreeStrategy, + cleanupConfirmed: processResult.cleanupConfirmed, + tarballSha256: identity.tarballSha256, + gitCommit: identity.gitCommit, + dependencyDigest: identity.dependencyDigest, + requirementIds: [...requirementIds].sort(), + } +} + +export function buildCheckRecord({ + checkId, + category, + command, + cwd, + attempt, + identity, + requirementIds, + processResult, + sanitizedArgv, + sanitizedEnvironment, + environmentId, +}) { + const evidence = structuredChildEvidence( + category, + processResult.stdout.bytes, + processResult.durationMs, + ) + const outputRedactionComplete = + processResult.stdout.redactionFailClosed === false && + processResult.stderr.redactionFailClosed === false + const processPassed = + processResult.exitCode === 0 && + processResult.signal === null && + processResult.timedOut === false && + processResult.spawnError === null && + processResult.cleanupConfirmed === true && + outputRedactionComplete + const result = processPassed ? evidence.result : processResult.timedOut ? 'failed' : 'failed' + const failureReason = processPassed + ? evidence.reason + : !outputRedactionComplete + ? 'output redaction failed closed' + : processResult.timedOut + ? 'command timed out' + : processResult.spawnError + ? `command could not start: ${processResult.spawnError}` + : processResult.signal + ? `command terminated by ${processResult.signal}` + : `command exited with status ${String(processResult.exitCode)}` + const failureDetails = + result === 'passed' + ? null + : { + reason: redactText(failureReason ?? 'release evidence was not captured'), + exitCode: processResult.exitCode, + signal: processResult.signal, + timedOut: processResult.timedOut, + spawnError: processResult.spawnError, + processTreeStrategy: processResult.processTreeStrategy, + cleanupConfirmed: processResult.cleanupConfirmed, + } + const boundary = boundaryForCheck({ cwd, processResult, identity, requirementIds }) + return { + id: checkId, + category, + required: true, + command, + cwd, + environment: environmentId, + startedAt: processResult.startedAt, + completedAt: processResult.completedAt, + durationMs: processResult.durationMs, + attempt, + exitCode: processResult.exitCode, + result, + buildSha256: identity.tarballSha256, + measurements: evidence.measurements, + stdout: undefined, + stderr: undefined, + failureDetails, + argv: sanitizedArgv, + environmentSnapshot: sanitizedEnvironment, + boundary, + binding: bindingForCheck(identity, requirementIds), + logs: { + stdout: { + rawSha256: processResult.stdout.rawSha256, + rawByteLength: processResult.stdout.rawByteLength, + redactedSha256: processResult.stdout.redactedSha256, + redactedByteLength: processResult.stdout.redactedByteLength, + redactedTruncated: processResult.stdout.redactedTruncated, + redactionFailClosed: processResult.stdout.redactionFailClosed, + }, + stderr: { + rawSha256: processResult.stderr.rawSha256, + rawByteLength: processResult.stderr.rawByteLength, + redactedSha256: processResult.stderr.redactedSha256, + redactedByteLength: processResult.stderr.redactedByteLength, + redactedTruncated: processResult.stderr.redactedTruncated, + redactionFailClosed: processResult.stderr.redactionFailClosed, + }, + }, + __outputBytes: { stdout: processResult.stdout.bytes, stderr: processResult.stderr.bytes }, + } +} + +export function verifyBinding(check, identity, requirementIds) { + assert(check.binding && typeof check.binding === 'object', `Check ${check.id} has no binding`) + assert( + canonicalJson(check.binding) === canonicalJson(bindingForCheck(identity, requirementIds)), + `Check ${check.id} binding differs`, + ) + assert(check.buildSha256 === identity.tarballSha256, `Check ${check.id} uses another tarball`) + assert( + check.boundary?.tarballSha256 === identity.tarballSha256, + `Check ${check.id} boundary uses another tarball`, + ) + assert( + check.boundary?.gitCommit === identity.gitCommit, + `Check ${check.id} boundary uses another commit`, + ) +} diff --git a/scripts/release/collector-validation.mjs b/scripts/release/collector-validation.mjs new file mode 100644 index 0000000..067d6bb --- /dev/null +++ b/scripts/release/collector-validation.mjs @@ -0,0 +1,381 @@ +import { createHash } from 'node:crypto' +import { REQUIRED_CHECKS, SHA256_PATTERN } from '../release-check-catalog.mjs' +import { + assert, + assertExactKeys, + canonicalJson, + publicKeyId, + strictIsoTimestamp, + validateReleaseInputEnvelope, + verifyCheckReceipt, +} from '../release-evidence.mjs' +import { readContainedFile } from '../release-files.mjs' +import { normalizeRequirementBindings } from './bindings.mjs' +import { identityDigest } from './build-identity.mjs' +import { verifyBinding } from './collection-contract.mjs' + +export const CHECKPOINT_SCHEMA = 'braid.release-checkpoint.v1' +export const COLLECTION_MANIFEST_SCHEMA = 'braid.release-collection.v1' + +function sha256(bytes) { + return createHash('sha256').update(bytes).digest('hex') +} + +function uniqueBy(items, key, label) { + assert(Array.isArray(items), `${label} is not an array`) + const values = new Map() + for (const item of items) { + const value = item?.[key] + assert(typeof value === 'string' && value.length > 0, `${label} has no ${key}`) + assert(!values.has(value), `Duplicate ${label} ${value}`) + values.set(value, item) + } + return values +} + +export function checkpointBuild(identity) { + return { + schemaVersion: 1, + identityDigest: identityDigest(identity), + braidVersion: identity.braidVersion, + gitCommit: identity.gitCommit, + gitTree: identity.gitTree, + treeSha256: identity.treeSha256, + tarballSha256: identity.tarballSha256, + packageIntegrity: identity.packageIntegrity, + packageFileManifestDigest: identity.packageFileManifestDigest, + dependencyDigest: identity.dependencyDigest, + dependencies: identity.dependencies, + requirementIds: identity.requirementIds, + } +} + +export function checkpointPlan({ checkIds, requirementBindings, publicKey }) { + return { + schemaVersion: 1, + checkIds: [...checkIds], + requirements: Object.fromEntries( + [...requirementBindings.entries()].sort(([left], [right]) => + left < right ? -1 : left > right ? 1 : 0, + ), + ), + publicKeyId: publicKeyId(publicKey), + } +} + +function validateEnvironmentSnapshot(snapshot, label) { + assertExactKeys(snapshot, ['variables', 'omittedCount'], [], label) + assert(Array.isArray(snapshot.variables), `${label} variables are not an array`) + assert( + Number.isInteger(snapshot.omittedCount) && snapshot.omittedCount >= 0, + `${label} omitted count is invalid`, + ) + const names = new Set() + for (const variable of snapshot.variables) { + assertExactKeys(variable, ['name', 'value', 'byteLength'], [], `${label} variable`) + assert( + typeof variable.name === 'string' && variable.name.length > 0, + `${label} variable has no name`, + ) + assert(!names.has(variable.name), `${label} repeats ${variable.name}`) + names.add(variable.name) + assert( + typeof variable.value === 'string', + `${label} variable ${variable.name} is not sanitized text`, + ) + assert( + Number.isSafeInteger(variable.byteLength) && variable.byteLength >= 0, + `${label} variable ${variable.name} has invalid length`, + ) + assert(!Object.hasOwn(variable, 'sha256'), `${label} publishes a value digest`) + assert(!Object.hasOwn(variable, 'digest'), `${label} publishes a value digest`) + } +} + +function validateLog(log, label) { + assertExactKeys( + log, + [ + 'rawSha256', + 'rawByteLength', + 'redactedSha256', + 'redactedByteLength', + 'redactedTruncated', + 'redactionFailClosed', + ], + [], + label, + ) + assert(SHA256_PATTERN.test(log.rawSha256), `${label} has an invalid raw digest`) + assert(SHA256_PATTERN.test(log.redactedSha256), `${label} has an invalid redacted digest`) + assert( + Number.isSafeInteger(log.rawByteLength) && log.rawByteLength >= 0, + `${label} has an invalid raw length`, + ) + assert( + Number.isSafeInteger(log.redactedByteLength) && log.redactedByteLength >= 0, + `${label} has an invalid redacted length`, + ) + assert(typeof log.redactedTruncated === 'boolean', `${label} has an invalid truncation flag`) + assert(typeof log.redactionFailClosed === 'boolean', `${label} has an invalid fail-closed flag`) +} + +function validateCheckShape(check, { identity, requirementIds, expectedCategory, publicKey }) { + assertExactKeys( + check, + [ + 'id', + 'category', + 'required', + 'command', + 'cwd', + 'environment', + 'startedAt', + 'completedAt', + 'durationMs', + 'attempt', + 'exitCode', + 'result', + 'buildSha256', + 'measurements', + 'stdout', + 'stderr', + 'failureDetails', + 'argv', + 'environmentSnapshot', + 'boundary', + 'binding', + 'logs', + 'receipt', + ], + [], + `Check ${check.id}`, + ) + assert(check.category === expectedCategory, `Check ${check.id} has category ${check.category}`) + assert(check.required === true, `Check ${check.id} is not required`) + assert( + check.command === REQUIRED_CHECKS.get(check.id)?.command, + `Check ${check.id} command differs from catalog`, + ) + assert(typeof check.cwd === 'string' && check.cwd.length > 0, `Check ${check.id} has no cwd`) + assert( + Array.isArray(check.argv) && check.argv.length > 0, + `Check ${check.id} has no sanitized argv`, + ) + assert( + check.argv.every( + (value) => + typeof value === 'string' && + !value.includes('\0') && + !value.includes('\n') && + !value.includes('\r'), + ), + `Check ${check.id} has unsafe argv text`, + ) + validateEnvironmentSnapshot(check.environmentSnapshot, `Check ${check.id} environment`) + assertExactKeys( + check.boundary, + [ + 'schemaVersion', + 'shell', + 'cwd', + 'processTreeStrategy', + 'cleanupConfirmed', + 'tarballSha256', + 'gitCommit', + 'dependencyDigest', + 'requirementIds', + ], + [], + `Check ${check.id} boundary`, + ) + assert(check.boundary.shell === false, `Check ${check.id} used a shell`) + assert(check.boundary.cwd === check.cwd, `Check ${check.id} boundary cwd differs`) + assert( + check.boundary.tarballSha256 === identity.tarballSha256, + `Check ${check.id} boundary tarball differs`, + ) + assert( + check.boundary.gitCommit === identity.gitCommit, + `Check ${check.id} boundary commit differs`, + ) + assert( + check.boundary.dependencyDigest === identity.dependencyDigest, + `Check ${check.id} boundary dependencies differ`, + ) + assert( + canonicalJson(check.boundary.requirementIds) === canonicalJson([...requirementIds].sort()), + `Check ${check.id} boundary requirements differ`, + ) + assert( + typeof check.boundary.processTreeStrategy === 'string', + `Check ${check.id} has no process tree strategy`, + ) + assert( + typeof check.boundary.cleanupConfirmed === 'boolean', + `Check ${check.id} has no cleanup result`, + ) + assert( + ['passed', 'failed', 'unavailable', 'uncaptured'].includes(check.result), + `Check ${check.id} has invalid result`, + ) + assert(check.buildSha256 === identity.tarballSha256, `Check ${check.id} build differs`) + assert( + Number.isInteger(check.attempt) && check.attempt > 0, + `Check ${check.id} has invalid attempt`, + ) + const startedAt = strictIsoTimestamp(check.startedAt, `Check ${check.id} start`) + const completedAt = strictIsoTimestamp(check.completedAt, `Check ${check.id} completion`) + assert(completedAt >= startedAt, `Check ${check.id} completed before it started`) + assert( + check.durationMs === completedAt - startedAt, + `Check ${check.id} duration differs from timestamps`, + ) + assert( + check.exitCode === null || Number.isInteger(check.exitCode), + `Check ${check.id} has invalid exit code`, + ) + assert( + check.stdout && typeof check.stdout === 'object', + `Check ${check.id} has no stdout artifact`, + ) + assert( + check.stderr && typeof check.stderr === 'object', + `Check ${check.id} has no stderr artifact`, + ) + assertExactKeys(check.stdout, ['artifactId', 'sha256'], [], `Check ${check.id} stdout`) + assertExactKeys(check.stderr, ['artifactId', 'sha256'], [], `Check ${check.id} stderr`) + assert(SHA256_PATTERN.test(check.stdout.sha256), `Check ${check.id} stdout digest is invalid`) + assert(SHA256_PATTERN.test(check.stderr.sha256), `Check ${check.id} stderr digest is invalid`) + assertExactKeys(check.logs, ['stdout', 'stderr'], [], `Check ${check.id} logs`) + validateLog(check.logs.stdout, `Check ${check.id} stdout log`) + validateLog(check.logs.stderr, `Check ${check.id} stderr log`) + assert( + check.logs.stdout.redactedSha256 === check.stdout.sha256, + `Check ${check.id} stdout redacted digest differs`, + ) + assert( + check.logs.stderr.redactedSha256 === check.stderr.sha256, + `Check ${check.id} stderr redacted digest differs`, + ) + verifyBinding(check, identity, requirementIds) + verifyCheckReceipt(check, publicKey) + return { startedAt, completedAt } +} + +async function validateArtifacts(envelope, repository, identity) { + const artifacts = uniqueBy(envelope.artifacts, 'id', 'artifact') + for (const artifact of artifacts.values()) { + assertExactKeys(artifact, ['id', 'path', 'sha256', 'mediaType'], [], `Artifact ${artifact.id}`) + assert(SHA256_PATTERN.test(artifact.sha256), `Artifact ${artifact.id} has an invalid digest`) + const bytes = await readContainedFile(repository, artifact.path) + assert(sha256(bytes) === artifact.sha256, `Artifact ${artifact.id} changed`) + } + const tarballArtifact = artifacts.get('package-tarball') + assert(tarballArtifact, 'Checkpoint is missing the package tarball artifact') + assert( + tarballArtifact.path === identity.tarballPath, + 'Checkpoint package tarball path differs from the build identity', + ) + assert( + tarballArtifact.sha256 === identity.tarballSha256, + 'Checkpoint package tarball digest differs from the build identity', + ) + return artifacts +} + +export async function validateCheckpoint(checkpoint, { repository, identity, plan, publicKey }) { + assertExactKeys(checkpoint, ['schema', 'build', 'plan', 'envelope'], [], 'Release checkpoint') + assert(checkpoint.schema === CHECKPOINT_SCHEMA, 'Unsupported release checkpoint schema') + assert( + canonicalJson(checkpoint.build) === canonicalJson(checkpointBuild(identity)), + 'Release checkpoint build binding differs', + ) + assert( + canonicalJson(checkpoint.plan) === canonicalJson(plan), + 'Release checkpoint plan binding differs', + ) + validateReleaseInputEnvelope(checkpoint.envelope) + const artifacts = await validateArtifacts(checkpoint.envelope, repository, identity) + const checks = uniqueBy(checkpoint.envelope.checks, 'id', 'check') + const environments = uniqueBy(checkpoint.envelope.environments, 'id', 'environment') + for (const environment of environments.values()) { + assertExactKeys(environment, ['id', 'kind', 'details'], [], `Environment ${environment.id}`) + assert( + environment.details && + typeof environment.details === 'object' && + !Array.isArray(environment.details), + `Environment ${environment.id} has no details`, + ) + } + for (const id of checks.keys()) + assert(plan.checkIds.includes(id), `Checkpoint has an unexpected check ${id}`) + for (const [id, expected] of REQUIRED_CHECKS) { + if (!plan.checkIds.includes(id)) continue + const check = checks.get(id) + if (!check) continue + const requirementIds = requirementIdsForPlan(plan, id) + validateCheckShape(check, { + identity, + requirementIds, + expectedCategory: expected.category, + publicKey, + }) + assert( + check.environment && typeof check.environment === 'string', + `Check ${id} has no environment`, + ) + assert(environments.has(check.environment), `Check ${id} names an unknown environment`) + for (const field of ['stdout', 'stderr']) { + const artifact = artifacts.get(check[field].artifactId) + assert(artifact, `Check ${id} names missing ${field} artifact`) + assert(artifact.sha256 === check[field].sha256, `Check ${id} ${field} artifact differs`) + } + } + const expectedArtifactIds = new Set(['package-tarball']) + for (const check of checks.values()) { + expectedArtifactIds.add(check.stdout.artifactId) + expectedArtifactIds.add(check.stderr.artifactId) + } + for (const id of artifacts.keys()) + assert(expectedArtifactIds.has(id), `Checkpoint has an unexpected artifact ${id}`) + for (const id of expectedArtifactIds) + assert(artifacts.has(id), `Checkpoint is missing expected artifact ${id}`) + const referencedEnvironmentIds = new Set([...checks.values()].map((check) => check.environment)) + for (const id of environments.keys()) + assert(referencedEnvironmentIds.has(id), `Checkpoint has an unreferenced environment ${id}`) + const mappings = normalizeRequirementBindings( + checkpoint.envelope.requirements, + identity.requirementIds, + plan.checkIds, + ) + for (const [id, binding] of mappings) { + const expected = plan.requirements[id] + assert(expected, `Checkpoint is missing requirement plan ${id}`) + assert( + canonicalJson(binding.checks) === canonicalJson(expected.checks), + `Checkpoint requirement ${id} checks differ`, + ) + const expectedArtifacts = [ + 'package-tarball', + ...binding.checks.flatMap((checkId) => { + const check = checks.get(checkId) + return check ? [check.stdout.artifactId, check.stderr.artifactId] : [] + }), + ] + assert( + canonicalJson(binding.artifacts) === canonicalJson(expectedArtifacts), + `Checkpoint requirement ${id} artifacts differ`, + ) + } + return { artifacts, checks } +} + +function requirementIdsForPlan(plan, checkId) { + return Object.entries(plan.requirements) + .filter(([, binding]) => binding.checks.includes(checkId)) + .map(([id]) => id) + .sort() +} + +export { requirementIdsForPlan } diff --git a/scripts/release/collector.mjs b/scripts/release/collector.mjs new file mode 100644 index 0000000..b2608bb --- /dev/null +++ b/scripts/release/collector.mjs @@ -0,0 +1,396 @@ +import { createHash, createPrivateKey, createPublicKey } from 'node:crypto' +import { join, relative, resolve } from 'node:path' + +import { REQUIRED_CHECKS } from '../release-check-catalog.mjs' +import { + canonicalJson, + signCheck, + signManifest, + verifyManifestSignature, +} from '../release-evidence.mjs' +import { readRegularFileNoFollow } from '../release-files.mjs' +import { createArtifactStore } from './artifact-store.mjs' +import { cleanTemporaryFiles, readJson, writeJsonAtomic } from './atomic-storage.mjs' +import { + materializeRequirementBindings, + normalizeRequirementCheckBindings, + requirementsObject, +} from './bindings.mjs' +import { readBuildIdentity, readRequirementIds } from './build-identity.mjs' +import { boundaryForCheck, buildCheckRecord, environmentRecord } from './collection-contract.mjs' +import { + CHECKPOINT_SCHEMA, + COLLECTION_MANIFEST_SCHEMA, + checkpointBuild, + checkpointPlan, + requirementIdsForPlan, + validateCheckpoint, +} from './collector-validation.mjs' +import { + collectRedactionSecrets, + executeCatalogCheck, + sanitizeArgv, + sanitizeEnvironment, +} from './command-runner.mjs' + +function assert(condition, message) { + if (!condition) throw new Error(message) +} + +function timestamp(milliseconds) { + return new Date(Math.trunc(milliseconds)).toISOString() +} + +function sha256(bytes) { + return createHash('sha256').update(bytes).digest('hex') +} + +function keyPair(signingKey, publicKey) { + const privateObject = signingKey?.type === 'private' ? signingKey : createPrivateKey(signingKey) + assert(privateObject.asymmetricKeyType === 'ed25519', 'Release signing key must be Ed25519') + const publicObject = publicKey + ? publicKey?.type === 'public' + ? publicKey + : createPublicKey(publicKey) + : createPublicKey(privateObject) + assert(publicObject.asymmetricKeyType === 'ed25519', 'Release public key must be Ed25519') + assert( + canonicalJson(publicObject.export({ format: 'jwk' })) === + canonicalJson(createPublicKey(privateObject).export({ format: 'jwk' })), + 'Release public key does not match signing key', + ) + return { privateObject, publicObject } +} + +async function optionalJson(path) { + try { + return await readJson(path, readRegularFileNoFollow) + } catch (error) { + if (error?.code === 'ENOENT') return undefined + throw error + } +} + +function selectedChecks(checkIds) { + const ids = checkIds ? [...checkIds] : [...REQUIRED_CHECKS.keys()] + assert(ids.length > 0, 'At least one release check is required') + assert(new Set(ids).size === ids.length, 'Release check identifiers are duplicated') + for (const id of ids) assert(REQUIRED_CHECKS.has(id), `Unknown release check: ${id}`) + return ids +} + +function artifactId(checkId, attempt, stream) { + return `check-${checkId.replaceAll('/', '_')}-attempt-${attempt}-${stream}` +} + +function artifactMap(artifacts) { + return new Map(artifacts.map((artifact) => [artifact.id, artifact])) +} + +function stateEnvelope({ + identity, + startedAt, + finishedAt, + checks, + environments, + artifacts, + requirementBindings, +}) { + const artifactsByCheck = new Map( + [...checks.values()].map((check) => [ + check.id, + [check.stdout.artifactId, check.stderr.artifactId], + ]), + ) + const requirements = materializeRequirementBindings(requirementBindings, { + artifactsByCheck, + additionalArtifacts: ['package-tarball'], + }) + return { + schemaVersion: 1, + braidVersion: identity.braidVersion, + gitCommit: identity.gitCommit, + packageIntegrity: identity.packageIntegrity, + startedAt, + finishedAt, + sourceState: { + clean: identity.clean, + commit: identity.gitCommit, + treeSha256: identity.treeSha256, + tarballSha256: identity.tarballSha256, + tarballArtifactId: 'package-tarball', + }, + dependencies: identity.dependencies, + environments: [...environments.values()].sort((left, right) => + left.id < right.id ? -1 : left.id > right.id ? 1 : 0, + ), + checks: [...checks.values()].sort((left, right) => + left.id < right.id ? -1 : left.id > right.id ? 1 : 0, + ), + requirements: requirementsObject(requirements), + artifacts: [...artifacts.values()].sort((left, right) => + left.id < right.id ? -1 : left.id > right.id ? 1 : 0, + ), + liveResources: [], + cleanup: [], + signatures: [], + } +} + +function checkpoint({ identity, plan, envelope }) { + return { schema: CHECKPOINT_SCHEMA, build: checkpointBuild(identity), plan, envelope } +} + +async function writeCheckpoint(path, value, options = {}) { + await writeJsonAtomic(path, value, options) +} + +async function preserveSignedManifest(path, manifest, publicKey) { + const existing = await optionalJson(path) + if (existing) { + verifyManifestSignature(existing, publicKey) + assert( + canonicalJson(existing) === canonicalJson(manifest), + 'Existing collection manifest differs', + ) + return existing + } + await writeJsonAtomic(path, manifest) + return manifest +} + +export async function collectReleaseEvidence({ + repository, + tarballPath, + packageProofPath, + packageProof, + requirementBindings, + signingKey, + publicKey, + checkIds, + environment = process.env, + redactionSecrets = [], + timeoutMs, + maxLogBytes, + partialPath, + checksPath, + manifestPath, + now = () => Date.now(), + runCheck = executeCatalogCheck, + writeOptions, +} = {}) { + const root = resolve(repository) + assert( + typeof tarballPath === 'string' && tarballPath.length > 0, + 'Packed tarball path is required', + ) + assert(requirementBindings, 'Requirement check bindings are required') + const keys = keyPair(signingKey, publicKey) + const identity = await readBuildIdentity({ + repository: root, + tarballPath, + packageProofPath, + packageProof, + }) + const documentedRequirementIds = await readRequirementIds(root) + assert( + canonicalJson(documentedRequirementIds) === canonicalJson(identity.requirementIds), + 'Build identity requirement list differs from docs', + ) + const selected = selectedChecks(checkIds) + const checkBindings = normalizeRequirementCheckBindings( + requirementBindings, + identity.requirementIds, + selected, + ) + for (const id of selected) + assert( + requirementIdsForPlan({ requirements: Object.fromEntries(checkBindings) }, id).length > 0, + `Release check ${id} has no requirement binding`, + ) + const plan = checkpointPlan({ + checkIds: selected, + requirementBindings: checkBindings, + publicKey: keys.publicObject, + }) + const outputRoot = join(root, 'artifacts', 'verification', 'release') + const paths = { + partial: partialPath ?? join(outputRoot, 'checks.partial.json'), + checks: checksPath ?? join(outputRoot, 'checks.json'), + manifest: manifestPath ?? join(outputRoot, 'collection-manifest.json'), + } + await cleanTemporaryFiles(outputRoot) + const store = createArtifactStore({ repository: root }) + const tarballArtifact = await store.register({ + id: 'package-tarball', + path: identity.tarballPath, + mediaType: 'application/gzip', + }) + const artifacts = artifactMap([tarballArtifact]) + const checks = new Map() + const environments = new Map() + const partial = await optionalJson(paths.partial) + const finalInput = await optionalJson(paths.checks) + let startedAt = timestamp(now()) + if (partial) { + await validateCheckpoint(partial, { + repository: root, + identity, + plan, + publicKey: keys.publicObject, + }) + startedAt = partial.envelope.startedAt + for (const artifact of partial.envelope.artifacts) artifacts.set(artifact.id, artifact) + for (const check of partial.envelope.checks) checks.set(check.id, check) + for (const environmentRecordValue of partial.envelope.environments) + environments.set(environmentRecordValue.id, environmentRecordValue) + } + if (finalInput) { + const finalCheckpoint = checkpoint({ identity, plan, envelope: finalInput }) + await validateCheckpoint(finalCheckpoint, { + repository: root, + identity, + plan, + publicKey: keys.publicObject, + }) + if (partial && canonicalJson(partial.envelope) !== canonicalJson(finalInput)) + throw new Error('Partial and final release evidence differ') + startedAt = finalInput.startedAt + for (const artifact of finalInput.artifacts) artifacts.set(artifact.id, artifact) + for (const check of finalInput.checks) checks.set(check.id, check) + for (const environmentRecordValue of finalInput.environments) + environments.set(environmentRecordValue.id, environmentRecordValue) + } + for (const checkId of selected) { + if (checks.has(checkId)) continue + const entry = REQUIRED_CHECKS.get(checkId) + const requirementIds = requirementIdsForPlan(plan, checkId) + const result = await runCheck({ + checkId, + cwd: root, + environment, + timeoutMs, + maxLogBytes, + redactionSecrets, + }) + assert(result.checkId === checkId, `Runner returned another check: ${result.checkId}`) + assert( + result.command === entry.command && result.category === entry.category, + `Runner drifted from catalog for ${checkId}`, + ) + const secrets = collectRedactionSecrets(environment, redactionSecrets) + const sanitizedArgv = result.sanitizedArgv ?? sanitizeArgv(result.argv, secrets) + const sanitizedEnvironment = result.sanitizedEnvironment ?? sanitizeEnvironment(environment) + const boundary = boundaryForCheck({ + cwd: root, + processResult: result, + identity, + requirementIds, + }) + const environmentValue = environmentRecord({ + cwd: root, + argv: sanitizedArgv, + environment: sanitizedEnvironment, + boundary, + }) + environments.set(environmentValue.id, environmentValue) + const attempt = 1 + const record = buildCheckRecord({ + checkId, + category: result.category, + command: result.command, + cwd: root, + attempt, + identity, + requirementIds, + processResult: result, + sanitizedArgv, + sanitizedEnvironment, + environmentId: environmentValue.id, + }) + const outputBytes = record.__outputBytes + const stdoutArtifact = await store.put({ + id: artifactId(checkId, attempt, 'stdout'), + bytes: outputBytes.stdout, + mediaType: 'text/plain; charset=utf-8', + }) + const stderrArtifact = await store.put({ + id: artifactId(checkId, attempt, 'stderr'), + bytes: outputBytes.stderr, + mediaType: 'text/plain; charset=utf-8', + }) + artifacts.set(stdoutArtifact.id, stdoutArtifact) + artifacts.set(stderrArtifact.id, stderrArtifact) + record.stdout = { artifactId: stdoutArtifact.id, sha256: stdoutArtifact.sha256 } + record.stderr = { artifactId: stderrArtifact.id, sha256: stderrArtifact.sha256 } + delete record.__outputBytes + checks.set(checkId, signCheck(record, keys.privateObject)) + const envelope = stateEnvelope({ + identity, + startedAt, + finishedAt: timestamp(now()), + checks, + environments, + artifacts, + requirementBindings: checkBindings, + }) + const value = checkpoint({ identity, plan, envelope }) + await validateCheckpoint(value, { + repository: root, + identity, + plan, + publicKey: keys.publicObject, + }) + await writeCheckpoint(paths.partial, value, writeOptions) + } + const envelope = stateEnvelope({ + identity, + startedAt, + finishedAt: timestamp(now()), + checks, + environments, + artifacts, + requirementBindings: checkBindings, + }) + const complete = selected.every((id) => checks.has(id)) + const passed = complete && selected.every((id) => checks.get(id).result === 'passed') + const final = await optionalJson(paths.checks) + if (final) + assert( + canonicalJson(final) === canonicalJson(envelope), + 'Existing final release evidence differs', + ) + else await writeJsonAtomic(paths.checks, envelope, writeOptions) + const checksBytes = Buffer.from(`${JSON.stringify(envelope, null, 2)}\n`) + const collectionManifest = signManifest( + { + schema: COLLECTION_MANIFEST_SCHEMA, + schemaVersion: 1, + braidVersion: identity.braidVersion, + gitCommit: identity.gitCommit, + gitTree: identity.gitTree, + treeSha256: identity.treeSha256, + tarballSha256: identity.tarballSha256, + packageIntegrity: identity.packageIntegrity, + packageFileManifestDigest: identity.packageFileManifestDigest, + dependencyDigest: identity.dependencyDigest, + requirementIds: identity.requirementIds, + checkIds: selected, + checkCount: checks.size, + result: passed ? 'passed' : complete ? 'failed' : 'incomplete', + startedAt, + finishedAt: envelope.finishedAt, + checksPath: relative(root, paths.checks), + checksSha256: await sha256(checksBytes), + }, + keys.privateObject, + ) + await preserveSignedManifest(paths.manifest, collectionManifest, keys.publicObject) + return { + identity, + result: passed ? 'passed' : complete ? 'failed' : 'incomplete', + envelope, + manifest: collectionManifest, + paths, + } +} diff --git a/scripts/release/command-runner.mjs b/scripts/release/command-runner.mjs new file mode 100644 index 0000000..ffafd5e --- /dev/null +++ b/scripts/release/command-runner.mjs @@ -0,0 +1,227 @@ +import { spawn } from 'node:child_process' + +import { REQUIRED_CHECKS } from '../release-check-catalog.mjs' +import { PROCESS_TREE_STRATEGY, reapChildTree, terminateChildTree } from './process-tree.mjs' +import { + BoundedCapture, + collectRedactionSecrets, + redactText, + sanitizeArgv, + sanitizeEnvironment, +} from './redaction.mjs' + +export { + BoundedCapture, + collectRedactionSecrets, + PROCESS_TREE_STRATEGY, + redactText, + sanitizeArgv, + sanitizeEnvironment, +} + +export const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000 +export const DEFAULT_MAX_LOG_BYTES = 64 * 1024 +const MAX_SETTLEMENT_GRACE_MS = 1_500 + +function assert(condition, message) { + if (!condition) throw new Error(message) +} + +function timestamp(milliseconds) { + return new Date(Math.trunc(milliseconds)).toISOString() +} + +function emptyCapture(maxLogBytes, secrets) { + return new BoundedCapture(maxLogBytes, secrets).finish() +} + +async function boundedAwait(promise, timeoutMs, fallback) { + let timer + try { + return await Promise.race([ + promise, + new Promise((resolve) => { + timer = setTimeout(() => resolve(fallback), timeoutMs) + }), + ]) + } finally { + clearTimeout(timer) + } +} + +function settledSpawnError({ startedMilliseconds, maxLogBytes, secrets, error }) { + const completedMilliseconds = Date.now() + return { + startedAt: timestamp(startedMilliseconds), + completedAt: timestamp(completedMilliseconds), + durationMs: Math.max(0, completedMilliseconds - startedMilliseconds), + exitCode: null, + signal: null, + timedOut: false, + settlementTimedOut: false, + spawnError: redactText(error instanceof Error ? error.message : String(error), secrets), + processTreeStrategy: PROCESS_TREE_STRATEGY, + cleanupConfirmed: true, + stdout: emptyCapture(maxLogBytes, secrets), + stderr: emptyCapture(maxLogBytes, secrets), + } +} + +export function catalogCommandArgv(command) { + const expected = [...REQUIRED_CHECKS.values()].some((entry) => entry.command === command) + assert(expected, `Command is not in the release catalog: ${command}`) + const parts = command.trim().split(/\s+/u) + assert(parts[0] === 'pnpm' && parts.length > 1, `Catalog command is not a pnpm argv: ${command}`) + assert( + parts.every((part) => !part.includes('\0') && !part.includes('\n') && !part.includes('\r')), + 'Catalog command contains control input', + ) + return Object.freeze({ file: parts[0], args: Object.freeze(parts.slice(1)) }) +} + +export async function executeArgv({ + file, + args, + cwd, + environment, + timeoutMs = DEFAULT_TIMEOUT_MS, + settlementGraceMs = MAX_SETTLEMENT_GRACE_MS, + maxLogBytes = DEFAULT_MAX_LOG_BYTES, + redactionSecrets = [], + spawnProcess = spawn, +}) { + assert(typeof file === 'string' && file.length > 0, 'Executable is required') + assert(Array.isArray(args), 'Executable arguments must be an array') + assert(typeof cwd === 'string' && cwd.length > 0, 'Command cwd is required') + assert(Number.isInteger(timeoutMs) && timeoutMs > 0, 'Command timeout must be positive') + assert( + Number.isInteger(settlementGraceMs) && settlementGraceMs > 0, + 'Settlement grace must be positive', + ) + const startedMilliseconds = Date.now() + const secrets = collectRedactionSecrets(environment, redactionSecrets) + const stdout = new BoundedCapture(maxLogBytes, secrets) + const stderr = new BoundedCapture(maxLogBytes, secrets) + let child + try { + child = spawnProcess(file, args, { + cwd, + env: environment, + shell: false, + detached: process.platform !== 'win32', + stdio: ['ignore', 'pipe', 'pipe'], + }) + } catch (error) { + return settledSpawnError({ startedMilliseconds, maxLogBytes, secrets, error }) + } + + let timedOut = false + let spawnError + let timeout + let settlementTimer + let settlementDeadline + let terminationPromise + let parentExitCleanup + return new Promise((resolve) => { + let settled = false + const finish = async (exitCode, signal, settlementTimedOut = false) => { + if (settled) return + settled = true + clearTimeout(timeout) + clearTimeout(settlementTimer) + const waitForCleanup = async (promise, fallback) => { + if (settlementTimedOut) return fallback + const remaining = + settlementDeadline === undefined ? 800 : Math.max(0, settlementDeadline - Date.now()) + return boundedAwait(promise, remaining, fallback) + } + const terminationConfirmed = terminationPromise + ? await waitForCleanup(terminationPromise, false) + : !settlementTimedOut + const reapPromise = + parentExitCleanup ?? + Promise.resolve() + .then(() => reapChildTree(child, 800)) + .catch(() => false) + const reapConfirmed = settlementTimedOut ? false : await waitForCleanup(reapPromise, false) + const settlementExpired = settlementDeadline !== undefined && Date.now() >= settlementDeadline + const cleanupConfirmed = + terminationConfirmed && reapConfirmed && !settlementTimedOut && !settlementExpired + child.stdout?.destroy() + child.stderr?.destroy() + const completedMilliseconds = Date.now() + resolve({ + startedAt: timestamp(startedMilliseconds), + completedAt: timestamp(completedMilliseconds), + durationMs: Math.max(0, completedMilliseconds - startedMilliseconds), + exitCode: typeof exitCode === 'number' ? exitCode : null, + signal: signal ?? null, + timedOut, + settlementTimedOut: settlementTimedOut || settlementExpired, + spawnError: spawnError ? redactText(spawnError.message, secrets) : null, + processTreeStrategy: PROCESS_TREE_STRATEGY, + cleanupConfirmed, + stdout: stdout.finish(), + stderr: stderr.finish(), + }) + } + const settleAfterBound = () => { + void finish(null, 'SIGKILL', true) + } + child.stdout?.on('data', (chunk) => stdout.push(chunk)) + child.stderr?.on('data', (chunk) => stderr.push(chunk)) + child.once('error', (error) => { + spawnError = error instanceof Error ? error : new Error(String(error)) + void finish(null, null) + }) + child.once('exit', () => { + parentExitCleanup = Promise.resolve() + .then(() => reapChildTree(child, 800)) + .catch(() => false) + }) + child.once('close', (exitCode, signal) => { + void finish(exitCode, signal) + }) + timeout = setTimeout(() => { + timedOut = true + settlementDeadline = Date.now() + settlementGraceMs + terminationPromise = Promise.resolve() + .then(() => terminateChildTree(child, 900)) + .catch(() => false) + settlementTimer = setTimeout(settleAfterBound, settlementGraceMs) + }, timeoutMs) + }) +} + +export async function executeCatalogCheck({ + checkId, + cwd, + environment, + timeoutMs, + maxLogBytes, + redactionSecrets = [], +}) { + const entry = REQUIRED_CHECKS.get(checkId) + assert(entry, `Unknown release check: ${checkId}`) + const command = catalogCommandArgv(entry.command) + const processResult = await executeArgv({ + ...command, + cwd, + environment, + timeoutMs, + maxLogBytes, + redactionSecrets, + }) + return { + checkId, + category: entry.category, + command: entry.command, + argv: [command.file, ...command.args], + sanitizedArgv: sanitizeArgv( + [command.file, ...command.args], + collectRedactionSecrets(environment, redactionSecrets), + ), + sanitizedEnvironment: sanitizeEnvironment(environment), + ...processResult, + } +} diff --git a/scripts/release/package-archive.mjs b/scripts/release/package-archive.mjs new file mode 100644 index 0000000..e9a63b7 --- /dev/null +++ b/scripts/release/package-archive.mjs @@ -0,0 +1,268 @@ +import { createHash } from 'node:crypto' +import { lstat, readdir, realpath } from 'node:fs/promises' +import { join, relative, resolve } from 'node:path' +import { gunzipSync } from 'node:zlib' + +import { canonicalJson } from '../release-evidence.mjs' +import { readRegularFileNoFollow } from '../release-files.mjs' + +const SOURCE_EXCLUSIONS = new Set(['.git', 'node_modules', 'dist', '.test-dist', 'artifacts']) +const HEX_SHA256 = /^[a-f0-9]{64}$/u + +function assert(condition, message) { + if (!condition) throw new Error(message) +} + +function sha256(bytes) { + return createHash('sha256').update(bytes).digest('hex') +} + +export async function sourceDigest(root, excludedPaths = new Set()) { + const files = [] + async function walk(directory) { + for (const entry of await readdir(directory, { withFileTypes: true })) { + if (SOURCE_EXCLUSIONS.has(entry.name)) continue + const path = join(directory, entry.name) + if (excludedPaths.has(path)) continue + if (entry.isDirectory()) await walk(path) + else if (entry.isFile()) files.push(path) + } + } + await walk(root) + files.sort() + const hash = createHash('sha256') + for (const path of files) { + hash.update(relative(root, path)) + hash.update('\0') + hash.update(await readRegularFileNoFollow(path)) + hash.update('\0') + } + return hash.digest('hex') +} + +function tarField(header, start, length, label) { + const field = header.subarray(start, start + length) + const terminator = field.indexOf(0) + const end = terminator === -1 ? field.length : terminator + const value = field.subarray(0, end).toString('utf8') + assert(!value.includes('\uFFFD'), `Tar ${label} is not valid UTF-8`) + if (terminator !== -1) { + for (const byte of field.subarray(terminator + 1)) + assert(byte === 0, `Tar ${label} has bytes after its NUL terminator`) + } + return value +} + +function tarOctal(header, start, length, label) { + const field = header.subarray(start, start + length) + const text = field.toString('ascii').replace(/\0/gu, '').trim() + assert(/^[0-7]*$/u.test(text), `Tar ${label} is not octal`) + const value = text.length === 0 ? 0 : Number.parseInt(text, 8) + assert(Number.isSafeInteger(value) && value >= 0, `Tar ${label} is out of range`) + return value +} + +function tarChecksum(header) { + const expected = tarOctal(header, 148, 8, 'checksum') + let actual = 0 + for (let index = 0; index < header.length; index += 1) + actual += index >= 148 && index < 156 ? 0x20 : header[index] + assert(actual === expected, `Tar header checksum differs for ${tarField(header, 0, 100, 'path')}`) +} + +function safePackagePath(path) { + assert(path.length > 0, 'Tar entry has no path') + if (path === 'package/') return path + assert(path.startsWith('package/'), `Tar entry is outside package/: ${path}`) + assert( + !path.startsWith('/') && !path.includes('\\') && !path.includes('\0'), + `Unsafe tar path: ${path}`, + ) + const segments = path.split('/') + assert( + segments.every((segment) => segment.length > 0 && segment !== '.' && segment !== '..'), + `Unsafe tar path: ${path}`, + ) + assert(segments.join('/') === path, `Non-canonical tar path: ${path}`) + return path +} + +function archiveEntries(bytes) { + let tar + try { + tar = gunzipSync(bytes) + } catch (error) { + throw new Error('Packed artifact is not valid gzip', { cause: error }) + } + const entries = [] + const paths = new Set() + let offset = 0 + let ended = false + while (offset + 512 <= tar.length) { + const header = tar.subarray(offset, offset + 512) + if (header.every((byte) => byte === 0)) { + ended = true + assert( + tar.subarray(offset).every((byte) => byte === 0), + 'Tar has data after its end marker', + ) + break + } + tarChecksum(header) + const name = tarField(header, 0, 100, 'path') + const prefix = tarField(header, 345, 155, 'path prefix') + const path = safePackagePath(prefix ? `${prefix}/${name}` : name) + assert(!paths.has(path), `Tar contains duplicate entry: ${path}`) + paths.add(path) + const size = tarOctal(header, 124, 12, `size for ${path}`) + const type = String.fromCharCode(header[156] || 0) + assert( + type === '\0' || type === '0' || type === '5', + `Tar entry type is unsafe for ${path}: ${JSON.stringify(type)}`, + ) + const bodyStart = offset + 512 + const bodyEnd = bodyStart + size + assert(bodyEnd <= tar.length, `Tar entry is truncated: ${path}`) + const body = Buffer.from(tar.subarray(bodyStart, bodyEnd)) + entries.push({ path, type: type === '5' ? 'directory' : 'file', size, bytes: body }) + offset = bodyStart + Math.ceil(size / 512) * 512 + } + assert(ended, 'Tar has no complete end marker') + return entries +} + +export function packageFileManifestFromTarball(bytes) { + assert(Buffer.isBuffer(bytes), 'Packed artifact must be bytes') + const entries = archiveEntries(bytes) + .filter((entry) => entry.type === 'file') + .map(({ path, size, bytes: body }) => ({ path, size, sha256: sha256(body) })) + return packageFileManifest(entries) +} + +function packageFileManifest(entries) { + entries.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0)) + assert(entries.length > 0, 'Package has no files') + return { + algorithm: 'sha256-path-size', + entries, + digest: sha256(Buffer.from(canonicalJson({ algorithm: 'sha256-path-size', entries }))), + } +} + +async function assertRealDirectory(path) { + const info = await lstat(path) + assert( + info.isDirectory() && !info.isSymbolicLink(), + `Package directory must be a real directory: ${path}`, + ) + assert( + (await realpath(path)) === resolve(path), + `Package directory resolves through a symlink: ${path}`, + ) +} + +/** + * Build the exact npm package file manifest from an installed package directory. + * Dependency directories are excluded because they are installed beside or beneath + * the package and are not bytes from the candidate tarball. + */ +export async function packageFileManifestFromDirectory(packageRoot) { + assert( + typeof packageRoot === 'string' && packageRoot.trim().length > 0, + 'Installed package root is required', + ) + const root = resolve(packageRoot) + await assertRealDirectory(root) + const entries = [] + + async function walk(directory, segments) { + await assertRealDirectory(directory) + const children = await readdir(directory, { withFileTypes: true }) + children.sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0)) + for (const child of children) { + const path = join(directory, child.name) + const info = await lstat(path) + if (info.isSymbolicLink()) + throw new Error(`Symlink is not allowed in package directory: ${path}`) + if (info.isDirectory()) { + if (child.name === 'node_modules') continue + await walk(path, [...segments, child.name]) + continue + } + if (!info.isFile()) + throw new Error(`Special file is not allowed in package directory: ${path}`) + const body = await readRegularFileNoFollow(path) + const manifestPath = safePackagePath(`package/${[...segments, child.name].join('/')}`) + entries.push({ path: manifestPath, size: body.length, sha256: sha256(body) }) + } + } + + await walk(root, []) + return packageFileManifest(entries) +} + +export function packageFileBytesFromTarball(bytes, path) { + const entry = archiveEntries(bytes).find((candidate) => candidate.path === path) + assert(entry?.type === 'file', `Tar has no regular ${path}`) + return Buffer.from(entry.bytes) +} + +function normalizeManifest(value) { + assert( + value && typeof value === 'object' && !Array.isArray(value), + 'Package file manifest is not an object', + ) + assert( + value.algorithm === 'sha256-path-size', + 'Package file manifest has an unsupported algorithm', + ) + assert( + Array.isArray(value.entries) && value.entries.length > 0, + 'Package file manifest has no entries', + ) + const paths = new Set() + const entries = value.entries.map((entry, index) => { + assert( + entry && typeof entry === 'object' && !Array.isArray(entry), + `Package file manifest entry ${index} is invalid`, + ) + assert( + Object.keys(entry).sort().join(',') === 'path,sha256,size', + `Package file manifest entry ${index} has unexpected fields`, + ) + const path = safePackagePath(entry.path) + assert(!paths.has(path), `Package file manifest repeats ${path}`) + paths.add(path) + assert(HEX_SHA256.test(entry.sha256), `Package file manifest has invalid digest for ${path}`) + assert( + Number.isSafeInteger(entry.size) && entry.size >= 0, + `Package file manifest has invalid size for ${path}`, + ) + return { path, size: entry.size, sha256: entry.sha256 } + }) + entries.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0)) + const digest = sha256(Buffer.from(canonicalJson({ algorithm: value.algorithm, entries }))) + assert(value.digest === digest, 'Package file manifest digest differs') + return { algorithm: value.algorithm, entries, digest } +} + +export function assertPackageFileManifestMatches(expected, actual) { + const normalized = normalizeManifest(expected) + const normalizedActual = normalizeManifest(actual) + assert( + canonicalJson(normalized) === canonicalJson(normalizedActual), + 'Tar package file manifest differs from package proof', + ) + return normalized +} + +export async function readPackageProof({ repository, packageProofPath, packageProof }) { + if (packageProof) return packageProof + assert( + typeof packageProofPath === 'string' && packageProofPath.length > 0, + 'Package proof is required', + ) + return JSON.parse( + (await readRegularFileNoFollow(join(repository, packageProofPath))).toString('utf8'), + ) +} diff --git a/scripts/release/process-tree.mjs b/scripts/release/process-tree.mjs new file mode 100644 index 0000000..5bce3de --- /dev/null +++ b/scripts/release/process-tree.mjs @@ -0,0 +1,80 @@ +import { spawn } from 'node:child_process' +import { setTimeout as sleep } from 'node:timers/promises' + +export const PROCESS_TREE_STRATEGY = + process.platform === 'win32' ? 'windows-taskkill-tree' : 'posix-process-group' + +function groupExists(pid) { + try { + process.kill(-pid, 0) + return true + } catch (error) { + return error?.code !== 'ESRCH' + } +} + +function signalGroup(child, signal) { + if (typeof child.pid !== 'number' || child.pid <= 0) return false + try { + process.kill(-child.pid, signal) + return true + } catch (error) { + if (error?.code === 'ESRCH') return false + try { + return child.kill(signal) + } catch { + return false + } + } +} + +function taskkill(child, force, timeoutMs) { + if (process.platform !== 'win32' || typeof child.pid !== 'number') return Promise.resolve(false) + return new Promise((resolve) => { + let settled = false + const killer = spawn('taskkill', ['/PID', String(child.pid), '/T', ...(force ? ['/F'] : [])], { + shell: false, + windowsHide: true, + stdio: 'ignore', + }) + const timer = setTimeout(() => { + if (settled) return + settled = true + killer.kill() + resolve(false) + }, timeoutMs) + const finish = (value) => { + if (settled) return + settled = true + clearTimeout(timer) + resolve(value) + } + killer.once('error', () => finish(false)) + killer.once('close', (code) => finish(code === 0)) + }) +} + +async function waitForGroupGone(pid, timeoutMs) { + const deadline = Date.now() + timeoutMs + while (groupExists(pid) && Date.now() < deadline) await sleep(25) + return !groupExists(pid) +} + +export async function terminateChildTree(child, timeoutMs = 800) { + if (process.platform === 'win32') { + const graceful = await taskkill(child, false, Math.max(100, timeoutMs / 2)) + const forced = await taskkill(child, true, Math.max(100, timeoutMs / 2)) + return graceful || forced + } + if (typeof child.pid !== 'number' || child.pid <= 0 || !groupExists(child.pid)) return true + signalGroup(child, 'SIGTERM') + if (await waitForGroupGone(child.pid, Math.floor(timeoutMs / 2))) return true + signalGroup(child, 'SIGKILL') + return waitForGroupGone(child.pid, Math.ceil(timeoutMs / 2)) +} + +export async function reapChildTree(child, timeoutMs = 800) { + if (process.platform === 'win32') return taskkill(child, false, timeoutMs) + if (typeof child.pid !== 'number' || child.pid <= 0 || !groupExists(child.pid)) return true + return terminateChildTree(child, timeoutMs) +} diff --git a/scripts/release/redaction.mjs b/scripts/release/redaction.mjs new file mode 100644 index 0000000..b66370a --- /dev/null +++ b/scripts/release/redaction.mjs @@ -0,0 +1,311 @@ +import { createHash } from 'node:crypto' +import { StringDecoder } from 'node:string_decoder' + +const SENSITIVE_FLAG = /(?:auth|api[-_]?key|credential|password|private[-_]?key|secret|token)/iu +const SECRET_ASSIGNMENT = + /((?:authorization|cookie|credential|api[-_]?key|password|private[-_]?key|secret|token)\s*[:=]\s*)(["']?)[^\s,;}"']+\2/giu +const BEARER = /\bBearer\s+[A-Za-z0-9._~+/=-]+/giu +const URL_USERINFO = /(https?:\/\/)([^\s/@:]+)(?::[^\s/@]*)?@/giu +const URL_QUERY_SECRET = + /([?&](?:authorization|cookie|credential|api[-_]?key|password|private[-_]?key|secret|token)[^=]*=)[^&#\s]+/giu +const ASSIGNMENT_START = + /(?:authorization|cookie|credential|api[-_]?key|password|private[-_]?key|secret|token)\s*[:=]\s*["']?/giu +const BEARER_START = /\bBearer\s+/giu +const URL_USERINFO_START = /https?:\/\/[^\s/@:]+(?::[^\s/@]*)?/giu +const URL_QUERY_START = + /[?&](?:authorization|cookie|credential|api[-_]?key|password|private[-_]?key|secret|token)[^=&#\s]*=/giu +const SAFE_ENVIRONMENT_NAMES = new Set([ + 'CI', + 'FORCE_COLOR', + 'LANG', + 'LC_ALL', + 'NO_COLOR', + 'NODE_ENV', + 'TERM', + 'TZ', +]) +const MAX_REDACTION_HOLD_CHARS = 1024 * 1024 +const SENSITIVE_PREFIX_WINDOW = 256 +const MAX_REDACTION_PENDING_CHARS = 1024 * 1024 +export const REDACTION_INPUT_CHUNK_CHARS = 64 * 1024 + +function assert(condition, message) { + if (!condition) throw new Error(message) +} + +function boundedText(value, maximum = 512) { + const text = String(value) + return text.length > maximum ? `${text.slice(0, maximum)}…` : text +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&') +} + +function secretPattern(secret) { + return new RegExp(escapeRegExp(secret), 'gu') +} + +function matches(pattern, text) { + return [...text.matchAll(new RegExp(pattern.source, pattern.flags))] +} + +function longestSecretPrefixSuffix(text, secret) { + const prefixTable = new Array(secret.length).fill(0) + for (let index = 1, length = 0; index < secret.length; index += 1) { + while (length > 0 && secret[index] !== secret[length]) length = prefixTable[length - 1] + if (secret[index] === secret[length]) length += 1 + prefixTable[index] = length + } + let length = 0 + for (let index = 0; index < text.length; index += 1) { + const character = text[index] + while (length > 0 && character !== secret[length]) length = prefixTable[length - 1] + if (character === secret[length]) length += 1 + if (length === secret.length) length = prefixTable[length - 1] + } + return length +} + +function safeRedactionBoundary(text, desired, secrets) { + let boundary = Math.max(0, desired - SENSITIVE_PREFIX_WINDOW) + const fullPatterns = [SECRET_ASSIGNMENT, BEARER, URL_USERINFO, URL_QUERY_SECRET] + const startPatterns = [ASSIGNMENT_START, BEARER_START, URL_USERINFO_START, URL_QUERY_START] + const fullMatches = fullPatterns.map((pattern) => matches(pattern, text)) + const startMatches = startPatterns.map((pattern) => matches(pattern, text)) + for (let pass = 0; pass < 8; pass += 1) { + const previous = boundary + for (const secretValue of secrets) { + const secret = String(secretValue) + if (secret.length === 0) continue + for ( + let index = text.indexOf(secret); + index !== -1; + index = text.indexOf(secret, index + 1) + ) { + if (index < boundary && index + secret.length > boundary) boundary = index + } + const suffixLength = longestSecretPrefixSuffix(text, secret) + const suffixStart = text.length - suffixLength + if (suffixLength > 0 && suffixStart < boundary) boundary = suffixStart + } + for (let index = 0; index < fullMatches.length; index += 1) { + for (const match of fullMatches[index]) { + const start = match.index ?? 0 + if (start < boundary && start + match[0].length > boundary) boundary = start + } + for (const match of startMatches[index]) { + const start = match.index ?? 0 + if (start >= boundary) continue + const complete = fullMatches[index].find((candidate) => (candidate.index ?? 0) === start) + if (!complete || start + complete[0].length > boundary) boundary = start + } + } + if (boundary === previous) return boundary + } + return boundary +} + +export function redactText(value, secrets = []) { + let redacted = String(value) + .replace(URL_USERINFO, '$1[REDACTED]@') + .replace(URL_QUERY_SECRET, '$1[REDACTED]') + .replace(BEARER, 'Bearer [REDACTED]') + .replace(SECRET_ASSIGNMENT, '$1$2[REDACTED]$2') + for (const secret of [...new Set(secrets)].filter((candidate) => String(candidate).length > 0)) { + redacted = redacted.replace(secretPattern(String(secret)), '[REDACTED]') + } + return redacted +} + +export function sanitizeArgv(argv, secrets = []) { + assert(Array.isArray(argv) && argv.length > 0, 'Command argv must be non-empty') + let redactNext = false + return argv.map((value, index) => { + const argument = String(value) + const flag = index > 0 && SENSITIVE_FLAG.test(argument) + if (redactNext) { + redactNext = false + return '[REDACTED]' + } + if (flag && index + 1 < argv.length) { + redactNext = true + return redactText(argument, secrets) + } + return boundedText(redactText(argument, secrets)) + }) +} + +function isProvablySafeEnvironmentValue(name, value) { + const text = String(value) + if (name === 'CI' || name === 'FORCE_COLOR' || name === 'NO_COLOR') + return /^(?:0|1|true|false)?$/iu.test(text) + if (name === 'NODE_ENV') return /^(?:development|production|test)$/u.test(text) + if (name === 'LANG' || name === 'LC_ALL') + return /^[A-Za-z]{2,12}(?:[_-][A-Za-z0-9]{2,12})?(?:\.[A-Za-z0-9_-]+)?$/u.test(text) + if (name === 'TERM') return /^[A-Za-z0-9._-]{1,64}$/u.test(text) + if (name === 'TZ') return /^[A-Za-z0-9_+./-]{1,64}$/u.test(text) + return false +} + +export function collectRedactionSecrets(environment, explicitSecrets = []) { + const environmentValues = Object.entries(environment ?? {}) + .filter(([name, value]) => !isProvablySafeEnvironmentValue(name, value)) + .map(([, value]) => value) + return [ + ...new Set( + [...explicitSecrets, ...environmentValues] + .map((value) => String(value)) + .filter((value) => value.length > 0), + ), + ] +} + +function safeEnvironmentValue(name, value) { + return SAFE_ENVIRONMENT_NAMES.has(name) && isProvablySafeEnvironmentValue(name, value) + ? boundedText(redactText(value)) + : '[REDACTED]' +} + +export function sanitizeEnvironment(environment, maximumEntries = 512) { + const entries = Object.entries(environment ?? {}).sort(([left], [right]) => + left < right ? -1 : left > right ? 1 : 0, + ) + const variables = entries.slice(0, maximumEntries).map(([name, value]) => ({ + name, + value: safeEnvironmentValue(name, value), + byteLength: Buffer.byteLength(String(value)), + })) + return { + variables, + omittedCount: Math.max(0, entries.length - variables.length), + } +} + +function validUtf8Prefix(bytes, maximum) { + let end = Math.min(bytes.length, maximum) + if (end === bytes.length) return bytes + while (end > 0 && (bytes[end] & 0xc0) === 0x80) end -= 1 + if (end > 0) { + let start = end - 1 + while (start > 0 && (bytes[start] & 0xc0) === 0x80) start -= 1 + const lead = bytes[start] + const expected = lead < 0x80 ? 1 : lead >= 0xf0 ? 4 : lead >= 0xe0 ? 3 : 2 + if (expected > end - start) end = start + } + return bytes.subarray(0, end) +} + +export class BoundedCapture { + #rawHash = createHash('sha256') + #rawByteLength = 0 + #redactedChunks = [] + #redactedBytes = 0 + #maximum + #redactor + #finished = false + #redactedTruncated = false + + constructor(maximum, secrets = []) { + assert(Number.isInteger(maximum) && maximum > 0, 'Maximum log bytes must be positive') + this.#maximum = maximum + this.#redactor = new StreamingRedactor(secrets, (text) => this.#retain(text)) + } + + #retain(text) { + if (text.length === 0) return + const bytes = Buffer.from(text.normalize('NFC')) + if (this.#redactedBytes >= this.#maximum) { + this.#redactedTruncated = true + return + } + const remaining = this.#maximum - this.#redactedBytes + const retained = validUtf8Prefix(bytes, remaining) + this.#redactedChunks.push(retained) + this.#redactedBytes += retained.length + if (retained.length < bytes.length) this.#redactedTruncated = true + } + + push(chunk) { + assert(!this.#finished, 'Cannot append to a finished output capture') + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)) + this.#rawHash.update(bytes) + this.#rawByteLength += bytes.length + this.#redactor.push(bytes) + } + + finish() { + assert(!this.#finished, 'Output capture was finished twice') + this.#finished = true + this.#redactor.finish() + const redacted = Buffer.concat(this.#redactedChunks) + this.#redactedChunks = [] + return Object.freeze({ + bytes: redacted, + rawSha256: this.#rawHash.digest('hex'), + rawByteLength: this.#rawByteLength, + redactedSha256: createHash('sha256').update(redacted).digest('hex'), + redactedByteLength: redacted.length, + redactedTruncated: this.#redactedTruncated, + redactionFailClosed: this.#redactor.redactionFailClosed, + }) + } +} + +class StreamingRedactor { + #decoder = new StringDecoder('utf8') + #pending = '' + #secrets + #holdChars + #emit + #redactionFailClosed = false + + constructor(secrets, emit) { + this.#secrets = [...new Set(secrets.map((secret) => String(secret)).filter(Boolean))].sort( + (left, right) => right.length - left.length, + ) + this.#holdChars = Math.min( + MAX_REDACTION_HOLD_CHARS, + Math.max(256, ...this.#secrets.map((secret) => secret.length + 256)), + ) + this.#emit = emit + } + + push(bytes) { + const text = this.#decoder.write(bytes) + for (let offset = 0; offset < text.length; offset += REDACTION_INPUT_CHUNK_CHARS) + this.#consume(text.slice(offset, offset + REDACTION_INPUT_CHUNK_CHARS), false) + } + + finish() { + this.#consume(this.#decoder.end(), false) + this.#consume('', true) + this.#pending = '' + this.#secrets = [] + } + + get redactionFailClosed() { + return this.#redactionFailClosed + } + + #consume(text, final) { + if (this.#redactionFailClosed) return + const combined = `${this.#pending}${text}` + const splitAt = final + ? combined.length + : safeRedactionBoundary( + combined, + Math.max(0, combined.length - this.#holdChars), + this.#secrets, + ) + if (!final && combined.length - splitAt > MAX_REDACTION_PENDING_CHARS) { + if (splitAt > 0) this.#emit(redactText(combined.slice(0, splitAt), this.#secrets)) + this.#emit('[REDACTED]') + this.#pending = '' + this.#redactionFailClosed = true + return + } + this.#emit(redactText(combined.slice(0, splitAt), this.#secrets)) + this.#pending = combined.slice(splitAt) + } +} diff --git a/scripts/release/release-collector.test.mjs b/scripts/release/release-collector.test.mjs new file mode 100644 index 0000000..92f4eb6 --- /dev/null +++ b/scripts/release/release-collector.test.mjs @@ -0,0 +1,552 @@ +import { strict as assert } from 'node:assert' +import { execFileSync } from 'node:child_process' +import { createHash, generateKeyPairSync } from 'node:crypto' +import { EventEmitter } from 'node:events' +import { chmod, mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { PassThrough } from 'node:stream' +import { test } from 'node:test' +import { gzipSync } from 'node:zlib' + +import { verifyManifestSignature } from '../release-evidence.mjs' +import { writeJsonAtomic } from './atomic-storage.mjs' +import { bindingForCheck, readBuildIdentity, readRequirementIds } from './build-identity.mjs' +import { structuredChildEvidence } from './collection-contract.mjs' +import { collectReleaseEvidence } from './collector.mjs' +import { executeArgv } from './command-runner.mjs' +import { packageFileManifestFromTarball, sourceDigest } from './package-archive.mjs' +import { + BoundedCapture, + collectRedactionSecrets, + REDACTION_INPUT_CHUNK_CHARS, + redactText, + sanitizeEnvironment, +} from './redaction.mjs' + +function octal(value, width) { + return `${value.toString(8).padStart(width - 1, '0')}\0` +} + +function tarArchive(entries) { + const blocks = [] + for (const entry of entries) { + const header = Buffer.alloc(512) + header.write(entry.name, 0, 'utf8') + header.write(octal(0o644, 8), 100, 'ascii') + header.write(octal(0, 8), 108, 'ascii') + header.write(octal(0, 8), 116, 'ascii') + header.write(octal(entry.body?.length ?? 0, 12), 124, 'ascii') + header.write(octal(0, 12), 136, 'ascii') + header.fill(0x20, 148, 156) + header[156] = entry.type === 'directory' ? 0x35 : entry.type === 'symlink' ? 0x32 : 0x30 + header.write('ustar\0', 257, 'ascii') + header.write('00', 263, 'ascii') + const checksum = header.reduce((sum, byte) => sum + byte, 0) + header.write(`${checksum.toString(8).padStart(6, '0')}\0 `, 148, 'ascii') + blocks.push(header) + const body = entry.body ?? Buffer.alloc(0) + blocks.push(body) + if (body.length % 512 !== 0) blocks.push(Buffer.alloc(512 - (body.length % 512))) + } + blocks.push(Buffer.alloc(1024)) + return gzipSync(Buffer.concat(blocks)) +} + +function git(root, ...args) { + return execFileSync('git', args, { cwd: root, encoding: 'utf8' }).trim() +} + +async function makeRepo() { + const root = await mkdtemp(join(tmpdir(), 'braid-release-repo-')) + await mkdir(join(root, 'docs'), { recursive: true }) + await writeFile( + join(root, 'package.json'), + `${JSON.stringify({ name: '@example/braid', version: '1.0.0', dependencies: {} })}\n`, + ) + await writeFile(join(root, 'pnpm-lock.yaml'), 'lockfileVersion: 9.0\n') + await writeFile( + join(root, 'docs', 'requirements.md'), + '## Product acceptance\n\n| PR-01 | local proof |\n', + ) + git(root, 'init', '-q') + git(root, 'config', 'core.hooksPath', '/dev/null') + git(root, 'config', 'user.email', 'release-test@example.invalid') + git(root, 'config', 'user.name', 'Release Test') + git(root, 'add', 'package.json', 'pnpm-lock.yaml', 'docs/requirements.md') + git(root, 'commit', '-qm', 'fixture') + const packageJson = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')) + const packageJsonBytes = Buffer.from(`${JSON.stringify(packageJson)}\n`) + const tarballPath = join(root, 'example-braid-1.0.0.tgz') + const tarballBytes = tarArchive([ + { name: 'package/', type: 'directory' }, + { name: 'package/package.json', body: packageJsonBytes }, + { name: 'package/index.js', body: Buffer.from('export const ok = true\n') }, + ]) + await writeFile(tarballPath, tarballBytes) + const manifest = packageFileManifestFromTarball(tarballBytes) + const proof = { + tarball: 'example-braid-1.0.0.tgz', + sha256: createHash('sha256').update(tarballBytes).digest('hex'), + version: '1.0.0', + gitCommit: git(root, 'rev-parse', 'HEAD'), + treeSha256: git(root, 'rev-parse', 'HEAD^{tree}'), + sourceDigest: await sourceDigest(root, new Set([tarballPath])), + isolatedBuild: true, + sourceCheckout: 'isolated-copy-of-worktree', + packageFileManifest: manifest, + } + return { root, tarballPath, tarballBytes, proof } +} + +async function withRepo(action) { + const fixture = await makeRepo() + try { + return await action(fixture) + } finally { + await rm(fixture.root, { recursive: true, force: true }) + } +} + +function expectReject(action, pattern) { + assert.throws(action, pattern) +} + +async function expectRejectAsync(action, pattern) { + await assert.rejects(action, pattern) +} + +function alive(pid) { + try { + process.kill(pid, 0) + return true + } catch (error) { + return error?.code !== 'ESRCH' + } +} + +async function waitFor(predicate, timeoutMs = 2_000) { + const deadline = Date.now() + timeoutMs + while (!predicate()) { + if (Date.now() >= deadline) return false + await new Promise((resolve) => setTimeout(resolve, 20)) + } + return true +} + +test('redaction hashes complete raw output and preserves a valid UTF-8 bounded prefix', () => { + const left = new BoundedCapture(32, ['secret-value']) + const right = new BoundedCapture(32, ['secret-value']) + left.push(Buffer.from(`${'same prefix '.repeat(20)}A`)) + right.push(Buffer.from(`${'same prefix '.repeat(20)}B`)) + const leftResult = left.finish() + const rightResult = right.finish() + assert.notEqual(leftResult.rawSha256, rightResult.rawSha256) + assert.equal(leftResult.rawByteLength, rightResult.rawByteLength) + assert(!leftResult.bytes.toString('utf8').includes('\uFFFD')) + assert(!rightResult.bytes.toString('utf8').includes('\uFFFD')) + + const utf8 = new BoundedCapture(5) + utf8.push(Buffer.from('ab🙂z')) + const utf8Result = utf8.finish() + assert.equal(utf8Result.bytes.toString('utf8'), 'ab') + assert.equal(utf8Result.redactedTruncated, true) +}) + +test('redaction catches chunk splits, truncation-boundary splits, and >1 MiB internal flush splits', () => { + const secret = 'split-secret-7f3e9d' + const capture = new BoundedCapture(4_096, [secret]) + for (const chunk of ['prefix split-', 'secret-', '7f3e9d suffix']) + capture.push(Buffer.from(chunk)) + const chunkResult = capture.finish() + assert(!chunkResult.bytes.toString('utf8').includes(secret)) + + const boundary = new BoundedCapture(12, [secret]) + boundary.push(Buffer.from(`123456789${secret}after`)) + const boundaryResult = boundary.finish() + assert(!boundaryResult.bytes.toString('utf8').includes(secret)) + assert(!boundaryResult.bytes.toString('utf8').includes('\uFFFD')) + assert(!boundaryResult.bytes.toString('utf8').includes(secret.slice(0, 8))) + assert(!boundaryResult.bytes.toString('utf8').includes(secret.slice(-8))) + + const cases = [ + { token: secret, sensitive: secret, secrets: [secret] }, + { token: `TOKEN=${'q'.repeat(400)}`, sensitive: 'q'.repeat(400), secrets: [] }, + { token: `Bearer ${'b'.repeat(400)}`, sensitive: 'b'.repeat(400), secrets: [] }, + { + token: `https://user:${'p'.repeat(400)}@example.invalid/path`, + sensitive: 'p'.repeat(400), + secrets: [], + }, + { + token: `https://example.invalid/?token=${'u'.repeat(400)}`, + sensitive: 'u'.repeat(400), + secrets: [], + }, + ] + for (const { token, sensitive, secrets } of cases) { + const separator = token.startsWith(secret) ? '' : ' ' + const marker = 'before-sentinel-' + const splitBoundary = REDACTION_INPUT_CHUNK_CHARS * 16 + const tokenStart = splitBoundary - 4 + const prefix = `${marker}${'x'.repeat(tokenStart - marker.length - separator.length)}` + const after = ' after-sentinel-tail' + const bytes = Buffer.from(prefix + separator + token + after) + assert.equal(bytes.indexOf(token), tokenStart) + assert(tokenStart < splitBoundary) + assert(tokenStart + Buffer.byteLength(token) > splitBoundary) + const large = new BoundedCapture(bytes.length + 128, secrets) + for (let offset = 0; offset < bytes.length; offset += 7_777) + large.push(bytes.subarray(offset, offset + 7_777)) + const result = large.finish() + const retained = result.bytes.toString('utf8') + assert(!retained.includes(token), `credential leaked: ${token.slice(0, 24)}`) + assert(retained.includes('before-sentinel')) + assert(retained.includes('after-sentinel')) + assert(!retained.includes(sensitive.slice(0, 32))) + assert(!retained.includes(sensitive.slice(-32))) + assert(!retained.includes('\uFFFD')) + } + + for (const token of [ + `Bearer ${'b'.repeat(3 * 1024 * 1024)}`, + `TOKEN=${'q'.repeat(3 * 1024 * 1024)}`, + ]) { + const bytes = Buffer.from(`before-unterminated-${token}\n-after-unterminated`) + const failClosed = new BoundedCapture(bytes.length + 128) + for (let offset = 0; offset < bytes.length; offset += 64 * 1024) + failClosed.push(bytes.subarray(offset, offset + 64 * 1024)) + const result = failClosed.finish() + const retained = result.bytes.toString('utf8') + assert.equal(result.redactionFailClosed, true) + assert(retained.includes('before-unterminated')) + assert(!retained.includes(token.slice(0, 128))) + assert(!retained.includes(token.slice(-128))) + assert(!retained.includes('after-unterminated')) + assert(result.redactedByteLength < 1024 * 1024) + } +}) + +test('environment sanitization unions explicit and innocent-name canaries without stable value fingerprints', () => { + const environment = { + AWS_TOKEN: 'obvious-canary-Ω', + BUILD_LABEL: 'innocent-canary-Δ', + SERVICE_URL: 'https://user:password@example.invalid/?token=query-canary', + NODE_ENV: 'test', + } + const secrets = collectRedactionSecrets(environment, ['explicit-canary-秘密']) + assert(secrets.includes('obvious-canary-Ω')) + assert(secrets.includes('innocent-canary-Δ')) + assert(secrets.includes('explicit-canary-秘密')) + const sanitized = sanitizeEnvironment(environment) + const serialized = JSON.stringify(sanitized) + for (const canary of [ + 'obvious-canary-Ω', + 'innocent-canary-Δ', + 'explicit-canary-秘密', + 'password', + 'query-canary', + ]) + assert(!serialized.includes(canary)) + assert(!serialized.includes('sha256')) + assert(!serialized.includes('digest')) + const text = redactText( + 'https://user:password@example.invalid/?token=query-canary Bearer bearer-canary', + [...secrets, 'bearer-canary'], + ) + for (const canary of ['password', 'query-canary', 'bearer-canary']) assert(!text.includes(canary)) +}) + +test('structured child results require an unambiguous passed marker and one measurement', () => { + const measurement = JSON.stringify([{ kind: 'scalar', name: 'count', unit: 'count', value: 1 }]) + const passed = structuredChildEvidence( + 'live', + Buffer.from( + `BRAID_RELEASE_RESULT_JSON={"status":"passed"}\nBRAID_RELEASE_MEASUREMENTS_JSON=${measurement}\n`, + ), + 3, + ) + assert.equal(passed.result, 'passed') + for (const output of [ + `BRAID_RELEASE_MEASUREMENTS_JSON=${measurement}\n`, + `BRAID_RELEASE_RESULT_JSON={"status":"failed","reason":"no"}\nBRAID_RELEASE_MEASUREMENTS_JSON=${measurement}\n`, + `BRAID_RELEASE_RESULT_JSON={"status":"mystery"}\nBRAID_RELEASE_MEASUREMENTS_JSON=${measurement}\n`, + `BRAID_RELEASE_RESULT_JSON={"status":"passed"}\nBRAID_RELEASE_RESULT_JSON={"status":"passed"}\nBRAID_RELEASE_MEASUREMENTS_JSON=${measurement}\n`, + `BRAID_RELEASE_RESULT_JSON={"status":"passed"}\nBRAID_RELEASE_MEASUREMENTS_JSON=${measurement}\nBRAID_RELEASE_MEASUREMENTS_JSON=${measurement}\n`, + ]) { + assert.notEqual(structuredChildEvidence('live', Buffer.from(output), 3).result, 'passed') + } + assert.equal( + structuredChildEvidence( + 'unit', + Buffer.from('BRAID_RELEASE_RESULT_JSON={"status":"failed"}\n'), + 3, + ).result, + 'failed', + ) +}) + +test('command execution settles spawn, nonzero, signal, timeout, and parent-exit cleanup paths', async () => { + const environment = { PATH: process.env.PATH ?? '', NODE_ENV: 'test' } + const success = await executeArgv({ + file: process.execPath, + args: ['-e', 'process.stdout.write("ok")'], + cwd: process.cwd(), + environment, + }) + assert.equal(success.exitCode, 0) + assert.equal(success.cleanupConfirmed, true) + assert.equal(success.stdout.bytes.toString(), 'ok') + + const nonzero = await executeArgv({ + file: process.execPath, + args: ['-e', 'process.stderr.write("bad"); process.exit(7)'], + cwd: process.cwd(), + environment, + }) + assert.equal(nonzero.exitCode, 7) + + const signal = await executeArgv({ + file: process.execPath, + args: ['-e', 'process.kill(process.pid, "SIGTERM")'], + cwd: process.cwd(), + environment, + }) + assert.equal(signal.signal, 'SIGTERM') + + const timedOut = await executeArgv({ + file: process.execPath, + args: ['-e', 'setInterval(() => {}, 1000)'], + cwd: process.cwd(), + environment, + timeoutMs: 30, + settlementGraceMs: 300, + }) + assert.equal(timedOut.timedOut, true) + assert.equal(timedOut.cleanupConfirmed, true) + + const pidFile = join(await mkdtemp(join(tmpdir(), 'braid-child-cleanup-')), 'grandchild.pid') + const parentCode = [ + "const { spawn } = require('node:child_process')", + "const { writeFileSync } = require('node:fs')", + "const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' })", + 'child.unref()', + 'writeFileSync(process.argv[1], String(child.pid))', + ].join(';') + const parentExit = await executeArgv({ + file: process.execPath, + args: ['-e', parentCode, pidFile], + cwd: process.cwd(), + environment, + }) + assert.equal(parentExit.exitCode, 0) + assert.equal(parentExit.cleanupConfirmed, true) + const grandchildPid = Number.parseInt(await readFile(pidFile, 'utf8'), 10) + assert(Number.isInteger(grandchildPid) && grandchildPid > 0) + const cleaned = await waitFor(() => !alive(grandchildPid)) + if (!cleaned && alive(grandchildPid)) process.kill(grandchildPid, 'SIGKILL') + assert.equal(cleaned, true) + + const noClose = new EventEmitter() + noClose.pid = 0 + noClose.stdout = new PassThrough() + noClose.stderr = new PassThrough() + const bounded = await executeArgv({ + file: 'fixture', + args: [], + cwd: process.cwd(), + environment, + timeoutMs: 10, + settlementGraceMs: 40, + spawnProcess: () => noClose, + }) + assert.equal(bounded.settlementTimedOut, true) + assert.equal(bounded.cleanupConfirmed, false) + + const spawnFailure = await executeArgv({ + file: 'not-found', + args: [], + cwd: process.cwd(), + environment: { ...environment, PRIVATE_TOKEN: 'spawn-secret' }, + spawnProcess: () => { + throw new Error('cannot spawn spawn-secret') + }, + }) + assert.equal(spawnFailure.cleanupConfirmed, true) + assert(!spawnFailure.spawnError.includes('spawn-secret')) +}) + +test('archive identity rejects duplicate, unsafe, and symlink tar entries', () => { + const packageJson = Buffer.from('{"name":"x"}\n') + const valid = tarArchive([{ name: 'package/package.json', body: packageJson }]) + assert.equal(packageFileManifestFromTarball(valid).entries.length, 1) + expectReject( + () => + packageFileManifestFromTarball( + tarArchive([ + { name: 'package/x', body: Buffer.from('1') }, + { name: 'package/x', body: Buffer.from('2') }, + ]), + ), + /duplicate/iu, + ) + expectReject( + () => + packageFileManifestFromTarball( + tarArchive([{ name: 'package/../x', body: Buffer.from('1') }]), + ), + /unsafe|outside/iu, + ) + expectReject( + () => packageFileManifestFromTarball(tarArchive([{ name: 'package/link', type: 'symlink' }])), + /unsafe/iu, + ) +}) + +test('requirements and bindings reject duplicate definitions before canonicalization', async () => { + await withRepo(async ({ root }) => { + await writeFile( + join(root, 'docs', 'duplicate.md'), + '## Product acceptance\n\n| PR-01 | second definition |\n', + ) + await expectRejectAsync( + () => readRequirementIds(root), + /Duplicate requirement definitions.*PR-01/iu, + ) + }) + expectReject( + () => + bindingForCheck( + { + requirementIds: ['PR-01'], + tarballSha256: 'a'.repeat(64), + gitCommit: 'b'.repeat(40), + gitTree: { algorithm: 'git-tree-object-sha1', value: 'c'.repeat(40) }, + dependencyDigest: 'd'.repeat(64), + packageFileManifestDigest: 'e'.repeat(64), + dependencies: [], + }, + ['PR-01', 'PR-01'], + ), + /duplicated/iu, + ) +}) + +test('build identity binds clean HEAD, explicit Git-tree algorithm, tarball digest, and package manifest', async () => { + await withRepo(async ({ root, tarballPath, proof }) => { + const identity = await readBuildIdentity({ repository: root, tarballPath, packageProof: proof }) + assert.deepEqual(identity.gitTree, { + algorithm: 'git-tree-object-sha1', + value: identity.treeSha256, + }) + assert.equal(identity.packageFileManifestDigest, proof.packageFileManifest.digest) + await writeFile( + join(root, 'docs', 'requirements.md'), + '## Product acceptance\n\n| PR-01 | modified |\n', + ) + await expectRejectAsync( + () => readBuildIdentity({ repository: root, tarballPath, packageProof: proof }), + /Source tree is not clean/iu, + ) + }) + await withRepo(async ({ root, tarballPath, tarballBytes, proof }) => { + await writeFile(tarballPath, Buffer.concat([tarballBytes, Buffer.from('changed')])) + await expectRejectAsync( + () => readBuildIdentity({ repository: root, tarballPath, packageProof: proof }), + /tarball digest differs/iu, + ) + }) +}) + +test('atomic interruption leaves no partial file and permits a clean retry', async () => { + const root = await mkdtemp(join(tmpdir(), 'braid-atomic-')) + try { + const path = join(root, 'release.json') + await expectRejectAsync( + () => + writeJsonAtomic( + path, + { value: 'interrupted' }, + { + beforeRename: () => { + throw new Error('interrupt') + }, + }, + ), + /interrupt/u, + ) + assert.equal(await readFile(path).catch(() => undefined), undefined) + assert.deepEqual(await readdir(root), []) + await writeJsonAtomic(path, { value: 'recovered' }) + assert.equal(JSON.parse(await readFile(path, 'utf8')).value, 'recovered') + } finally { + await rm(root, { recursive: true, force: true }) + } +}) + +test('one local catalog check produces redacted artifacts and a signed collection manifest', async () => { + await withRepo(async ({ root, tarballPath, proof }) => { + const bin = await mkdtemp(join(tmpdir(), 'braid-fake-pnpm-')) + const pnpmPath = join(bin, 'pnpm') + await writeFile( + pnpmPath, + '#!/usr/bin/env node\nprocess.stdout.write(process.argv.slice(2).join(" "))\n', + ) + await chmod(pnpmPath, 0o755) + const { privateKey, publicKey } = generateKeyPairSync('ed25519') + try { + const collectionOptions = { + repository: root, + tarballPath, + packageProof: proof, + requirementBindings: { 'PR-01': { checks: ['repository'] } }, + signingKey: privateKey, + publicKey, + checkIds: ['repository'], + environment: { + PATH: `${bin}:${process.env.PATH ?? ''}`, + NODE_ENV: 'test', + INNOCENT_CANARY: 'secret-value', + }, + } + const result = await collectReleaseEvidence(collectionOptions) + assert.equal(result.result, 'passed') + assert.equal(result.envelope.checks.length, 1) + assert.equal(result.envelope.checks[0].result, 'passed') + verifyManifestSignature(result.manifest, publicKey) + const serialized = JSON.stringify(result) + assert(!serialized.includes('secret-value')) + + const partialPath = result.paths.partial + const originalPartial = await readFile(partialPath, 'utf8') + const tamperedReceipt = JSON.parse(originalPartial) + tamperedReceipt.envelope.checks[0].receipt.signature = '0'.repeat(128) + await writeFile(partialPath, `${JSON.stringify(tamperedReceipt)}\n`) + await expectRejectAsync( + () => collectReleaseEvidence(collectionOptions), + /receipt|signature/iu, + ) + + const wrongBuild = JSON.parse(originalPartial) + wrongBuild.build.tarballSha256 = '0'.repeat(64) + await writeFile(partialPath, `${JSON.stringify(wrongBuild)}\n`) + await expectRejectAsync( + () => collectReleaseEvidence(collectionOptions), + /checkpoint build binding differs/iu, + ) + + await writeFile(partialPath, originalPartial) + const stdoutArtifact = result.envelope.artifacts.find((artifact) => + artifact.id.endsWith('-stdout'), + ) + assert(stdoutArtifact) + await writeFile(join(root, stdoutArtifact.path), 'tampered') + await expectRejectAsync( + () => collectReleaseEvidence(collectionOptions), + /Artifact .* changed/iu, + ) + } finally { + await rm(bin, { recursive: true, force: true }) + } + }) +}) diff --git a/scripts/release/verification-artifacts.mjs b/scripts/release/verification-artifacts.mjs new file mode 100644 index 0000000..1a0013f --- /dev/null +++ b/scripts/release/verification-artifacts.mjs @@ -0,0 +1,70 @@ +import { createHash } from 'node:crypto' +import { SHA256_PATTERN } from '../release-check-catalog.mjs' +import { assert, assertExactKeys, canonicalJson } from '../release-evidence.mjs' +import { readContainedFile, readRegularFileNoFollow } from '../release-files.mjs' +import { + assertPackageFileManifestMatches, + packageFileBytesFromTarball, + packageFileManifestFromTarball, +} from './package-archive.mjs' +import { artifactPath, uniqueBy } from './verification-support.mjs' + +function sha256(bytes) { + return createHash('sha256').update(bytes).digest('hex') +} + +function sha512Integrity(bytes) { + return `sha512-${createHash('sha512').update(bytes).digest('base64')}` +} + +export async function validateReleaseArtifacts({ + evidence, + repository, + packageProof, + packageJson, +}) { + const artifacts = uniqueBy(evidence.artifacts, 'id', 'artifact') + for (const artifact of artifacts.values()) { + assertExactKeys(artifact, ['id', 'path', 'sha256', 'mediaType'], [], `Artifact ${artifact.id}`) + assert(SHA256_PATTERN.test(artifact.sha256), `Artifact ${artifact.id} has invalid SHA-256`) + assert( + typeof artifact.mediaType === 'string' && artifact.mediaType.length > 0, + `Artifact ${artifact.id} has no media type`, + ) + const bytes = await readContainedFile(repository, artifact.path) + assert(sha256(bytes) === artifact.sha256, `Artifact ${artifact.id} digest changed`) + } + + const tarballArtifact = artifacts.get(evidence.sourceState.tarballArtifactId) + assert(tarballArtifact, 'Source state names an unknown tarball artifact') + assert(tarballArtifact.sha256 === packageProof.sha256, 'Tarball artifact digest differs') + const tarballBytes = await readRegularFileNoFollow(artifactPath(repository, tarballArtifact.path)) + assert( + sha512Integrity(tarballBytes) === evidence.packageIntegrity, + 'Tarball artifact integrity differs', + ) + assert(packageProof.packageFileManifest, 'Package proof has no immutable package file manifest') + const actualPackageFileManifest = packageFileManifestFromTarball(tarballBytes) + const packageFileManifest = assertPackageFileManifestMatches( + packageProof.packageFileManifest, + actualPackageFileManifest, + ) + assert( + packageFileManifest.digest === packageProof.packageFileManifest.digest, + 'Package proof package file manifest digest differs', + ) + const packedPackageJson = JSON.parse( + packageFileBytesFromTarball(tarballBytes, 'package/package.json').toString('utf8'), + ) + assert( + canonicalJson(packedPackageJson) === canonicalJson(packageJson), + 'Packed package.json differs from HEAD', + ) + return { + artifacts, + tarballArtifact, + tarballBytes, + packageFileManifest, + packageFileManifestDigest: packageFileManifest.digest, + } +} diff --git a/scripts/release/verification-checks.mjs b/scripts/release/verification-checks.mjs new file mode 100644 index 0000000..9cc6df0 --- /dev/null +++ b/scripts/release/verification-checks.mjs @@ -0,0 +1,311 @@ +import { CHECK_CATEGORIES, REQUIRED_CHECKS, SHA256_PATTERN } from '../release-check-catalog.mjs' +import { + assert, + assertExactKeys, + canonicalJson, + strictIsoTimestamp, + validateMeasurements, + validatePerformanceMeasurements, + verifyCheckReceipt, +} from '../release-evidence.mjs' + +export function validateReleaseChecks({ + checks, + artifacts, + mappings, + environments, + allowedCheckIds, + allowedCommands, + packageProof, + evidence, + sourceTree, + releaseWindow, + publicKey, + dependencyDigest, + packageFileManifestDigest, +}) { + const performanceMeasurements = [] + for (const [id, expected] of REQUIRED_CHECKS) { + const check = checks.get(id) + assert(check, `Required check ${id} is missing`) + assert( + check.category === expected.category, + `Required check ${id} has category ${check.category}`, + ) + assert(check.command === expected.command, `Required check ${id} has command ${check.command}`) + } + + for (const check of checks.values()) { + assertExactKeys( + check, + [ + 'id', + 'category', + 'required', + 'command', + 'cwd', + 'environment', + 'startedAt', + 'completedAt', + 'durationMs', + 'attempt', + 'exitCode', + 'result', + 'buildSha256', + 'measurements', + 'stdout', + 'stderr', + 'failureDetails', + 'argv', + 'environmentSnapshot', + 'boundary', + 'binding', + 'logs', + 'receipt', + ], + [], + `Check ${check.id}`, + ) + assert(allowedCheckIds.has(check.id), `Check ${check.id} is outside the closed check catalog`) + assert(CHECK_CATEGORIES.has(check.category), `Check ${check.id} has invalid category`) + assert(allowedCommands.has(check.command), `Check ${check.id} uses an unregistered command`) + assert( + allowedCommands.get(check.command) === check.category, + `Check ${check.id} command category differs`, + ) + assert(check.result === 'passed', `Check ${check.id} did not pass`) + assert(check.result !== 'unavailable', `Required check ${check.id} is unavailable`) + assert(check.required === true, `Check ${check.id} is not marked required`) + assert(check.buildSha256 === packageProof.sha256, `Check ${check.id} used another build`) + assert(typeof check.cwd === 'string' && check.cwd.length > 0, `Check ${check.id} has no cwd`) + assert(Array.isArray(check.argv) && check.argv.length > 0, `Check ${check.id} has no argv`) + assert(check.argv[0] === 'pnpm', `Check ${check.id} does not use pnpm argv`) + assert( + check.argv.every( + (value) => + typeof value === 'string' && + !value.includes('\0') && + !value.includes('\n') && + !value.includes('\r'), + ), + `Check ${check.id} has unsafe argv`, + ) + assertExactKeys( + check.environmentSnapshot, + ['variables', 'omittedCount'], + [], + `Check ${check.id} environment snapshot`, + ) + assert( + Array.isArray(check.environmentSnapshot.variables), + `Check ${check.id} environment variables are not an array`, + ) + const environmentNames = new Set() + for (const variable of check.environmentSnapshot.variables) { + assertExactKeys( + variable, + ['name', 'value', 'byteLength'], + [], + `Check ${check.id} environment variable`, + ) + assert( + typeof variable.name === 'string' && variable.name.length > 0, + `Check ${check.id} environment variable has no name`, + ) + assert( + !environmentNames.has(variable.name), + `Check ${check.id} repeats environment ${variable.name}`, + ) + environmentNames.add(variable.name) + assert( + typeof variable.value === 'string', + `Check ${check.id} environment variable ${variable.name} is not sanitized`, + ) + assert( + Number.isSafeInteger(variable.byteLength) && variable.byteLength >= 0, + `Check ${check.id} environment variable ${variable.name} has invalid length`, + ) + assert( + !Object.hasOwn(variable, 'sha256'), + `Check ${check.id} publishes an environment value digest`, + ) + assert( + !Object.hasOwn(variable, 'digest'), + `Check ${check.id} publishes an environment value digest`, + ) + } + assertExactKeys( + check.boundary, + [ + 'schemaVersion', + 'shell', + 'cwd', + 'processTreeStrategy', + 'cleanupConfirmed', + 'tarballSha256', + 'gitCommit', + 'dependencyDigest', + 'requirementIds', + ], + [], + `Check ${check.id} boundary`, + ) + assert(check.boundary.shell === false, `Check ${check.id} used a shell`) + assert(check.boundary.cwd === check.cwd, `Check ${check.id} boundary cwd differs`) + assert( + check.boundary.tarballSha256 === packageProof.sha256, + `Check ${check.id} boundary tarball differs`, + ) + assert( + check.boundary.gitCommit === evidence.gitCommit, + `Check ${check.id} boundary commit differs`, + ) + assert( + check.boundary.dependencyDigest === dependencyDigest, + `Check ${check.id} boundary dependency digest differs`, + ) + assert( + typeof check.boundary.processTreeStrategy === 'string', + `Check ${check.id} has no process-tree strategy`, + ) + assert( + typeof check.boundary.cleanupConfirmed === 'boolean', + `Check ${check.id} has no cleanup result`, + ) + assertExactKeys( + check.binding, + [ + 'schemaVersion', + 'tarballSha256', + 'gitCommit', + 'gitTree', + 'dependencyDigest', + 'packageFileManifestDigest', + 'dependencies', + 'requirementIds', + ], + [], + `Check ${check.id} binding`, + ) + assert( + check.binding.tarballSha256 === packageProof.sha256, + `Check ${check.id} binding tarball differs`, + ) + assert( + check.binding.gitCommit === evidence.gitCommit, + `Check ${check.id} binding commit differs`, + ) + assertExactKeys(check.binding.gitTree, ['algorithm', 'value'], [], `Check ${check.id} Git tree`) + assert( + check.binding.gitTree.algorithm === 'git-tree-object-sha1', + `Check ${check.id} Git tree algorithm differs`, + ) + assert(check.binding.gitTree.value === sourceTree, `Check ${check.id} binding tree differs`) + assert( + check.binding.dependencyDigest === dependencyDigest, + `Check ${check.id} binding dependency digest differs`, + ) + assert( + check.binding.packageFileManifestDigest === packageFileManifestDigest, + `Check ${check.id} binding package manifest digest differs`, + ) + assert( + canonicalJson(check.binding.dependencies) === canonicalJson(evidence.dependencies), + `Check ${check.id} binding dependencies differ`, + ) + const expectedRequirementIds = [...mappings.entries()] + .filter(([, mapping]) => Array.isArray(mapping.checks) && mapping.checks.includes(check.id)) + .map(([requirement]) => requirement) + .sort() + assert( + canonicalJson(check.binding.requirementIds) === canonicalJson(expectedRequirementIds), + `Check ${check.id} binding requirements differ`, + ) + assert( + canonicalJson(check.boundary.requirementIds) === canonicalJson(expectedRequirementIds), + `Check ${check.id} boundary requirements differ`, + ) + assertExactKeys(check.logs, ['stdout', 'stderr'], [], `Check ${check.id} logs`) + for (const field of ['stdout', 'stderr']) { + assertExactKeys( + check.logs[field], + [ + 'rawSha256', + 'rawByteLength', + 'redactedSha256', + 'redactedByteLength', + 'redactedTruncated', + 'redactionFailClosed', + ], + [], + `Check ${check.id} ${field} log`, + ) + assert( + SHA256_PATTERN.test(check.logs[field].rawSha256), + `Check ${check.id} ${field} raw digest is invalid`, + ) + assert( + SHA256_PATTERN.test(check.logs[field].redactedSha256), + `Check ${check.id} ${field} redacted digest is invalid`, + ) + assert( + Number.isSafeInteger(check.logs[field].rawByteLength) && + check.logs[field].rawByteLength >= 0, + `Check ${check.id} ${field} raw length is invalid`, + ) + assert( + Number.isSafeInteger(check.logs[field].redactedByteLength) && + check.logs[field].redactedByteLength >= 0, + `Check ${check.id} ${field} redacted length is invalid`, + ) + assert( + typeof check.logs[field].redactedTruncated === 'boolean', + `Check ${check.id} ${field} truncation is invalid`, + ) + assert( + check.logs[field].redactionFailClosed === false, + `Check ${check.id} ${field} redaction failed closed`, + ) + } + assert( + environments.has(check.environment), + `Check ${check.id} names unknown environment ${check.environment}`, + ) + const startedAt = strictIsoTimestamp(check.startedAt, `Check ${check.id} start`) + const completedAt = strictIsoTimestamp(check.completedAt, `Check ${check.id} completion`) + assert(startedAt >= releaseWindow.startedAt, `Check ${check.id} started before the release`) + assert(completedAt <= releaseWindow.finishedAt, `Check ${check.id} ended after the release`) + assert(completedAt >= startedAt, `Check ${check.id} completed before it started`) + assert( + check.durationMs === completedAt - startedAt, + `Check ${check.id} duration differs from its timestamps`, + ) + assert(check.exitCode === 0, `Check ${check.id} has a nonzero exit code`) + assert(Number.isInteger(check.attempt) && check.attempt > 0, `Check ${check.id} has no attempt`) + if (check.category === 'performance') { + validatePerformanceMeasurements(check.measurements, `Check ${check.id}`) + performanceMeasurements.push(...check.measurements) + } else validateMeasurements(check.measurements, `Check ${check.id}`) + assert( + check.measurements.every( + (measurement) => measurement.kind !== 'unavailable' && measurement.kind !== 'uncaptured', + ), + `Required check ${check.id} contains unavailable measurements`, + ) + assert(check.failureDetails === null, `Passed check ${check.id} has failure details`) + for (const field of ['stdout', 'stderr']) { + const output = check[field] + assertExactKeys(output, ['artifactId', 'sha256'], [], `Check ${check.id} ${field}`) + assert(SHA256_PATTERN.test(output.sha256), `Check ${check.id} has invalid ${field} SHA-256`) + const artifact = artifacts.get(output.artifactId) + assert(artifact, `Check ${check.id} names unknown ${field} artifact ${output.artifactId}`) + assert(artifact.sha256 === output.sha256, `Check ${check.id} ${field} digest differs`) + assert( + artifact.sha256 === check.logs[field].redactedSha256, + `Check ${check.id} ${field} log digest differs`, + ) + } + verifyCheckReceipt(check, publicKey) + } + return performanceMeasurements +} diff --git a/scripts/release/verification-flow.mjs b/scripts/release/verification-flow.mjs new file mode 100644 index 0000000..7bda924 --- /dev/null +++ b/scripts/release/verification-flow.mjs @@ -0,0 +1,64 @@ +import { validatePerformanceMatrix } from '../release-evidence.mjs' +import { dependencyDigest } from './build-identity.mjs' +import { validateReleaseArtifacts } from './verification-artifacts.mjs' +import { validateReleaseChecks } from './verification-checks.mjs' +import { validateLiveResources, validateRequirementMappings } from './verification-mappings.mjs' +import { readVerificationOptions } from './verification-options.mjs' +import { buildDocumentationPlan, buildEvidencePlan } from './verification-plan.mjs' +import { writeVerificationOutputs } from './verification-report.mjs' +import { assertIsolatedCheckout, loadReleaseSource } from './verification-source.mjs' +import { createGit } from './verification-support.mjs' + +export async function verifyRelease(options = readVerificationOptions()) { + const documentation = await buildDocumentationPlan(options) + const git = createGit(options.repository) + assertIsolatedCheckout({ options, git }) + const source = await loadReleaseSource({ options, git }) + const plan = buildEvidencePlan(source.evidence, documentation.requirements) + const artifactResult = await validateReleaseArtifacts({ + evidence: source.evidence, + repository: options.repository, + packageProof: source.packageProof, + packageJson: source.packageJson, + }) + const environments = new Map( + source.evidence.environments.map((environment) => [environment.id, environment]), + ) + const performanceMeasurements = validateReleaseChecks({ + checks: plan.checks, + artifacts: artifactResult.artifacts, + mappings: plan.mappings, + environments, + allowedCheckIds: plan.allowedCheckIds, + allowedCommands: plan.allowedCommands, + packageProof: source.packageProof, + evidence: source.evidence, + sourceTree: source.sourceTree, + releaseWindow: source.releaseWindow, + publicKey: source.publicKey, + dependencyDigest: dependencyDigest(source.evidence.dependencies), + packageFileManifestDigest: artifactResult.packageFileManifestDigest, + }) + validatePerformanceMatrix(performanceMeasurements, 'Release performance matrix') + validateRequirementMappings({ + requirements: documentation.requirements, + mappings: plan.mappings, + checks: plan.checks, + artifacts: artifactResult.artifacts, + tarballArtifactId: source.evidence.sourceState.tarballArtifactId, + }) + validateLiveResources({ + evidence: source.evidence, + environments, + }) + const output = await writeVerificationOutputs({ + options, + evidence: source.evidence, + specificationDigests: documentation.specificationDigests, + publicKey: source.publicKey, + }) + process.stdout.write( + `Validated ${documentation.requirements.size} requirements, ${plan.checks.size} signed checks, and ${artifactResult.artifacts.size} artifacts for @tangle-network/braid@${source.evidence.braidVersion}\n`, + ) + return { ...source, ...artifactResult, ...output } +} diff --git a/scripts/release/verification-mappings.mjs b/scripts/release/verification-mappings.mjs new file mode 100644 index 0000000..3eb212f --- /dev/null +++ b/scripts/release/verification-mappings.mjs @@ -0,0 +1,127 @@ +import { + ADMISSIBLE_CATEGORIES, + EXACT_REQUIREMENT_CHECK_CATEGORIES, +} from '../release-check-catalog.mjs' +import { assert, assertExactKeys, strictIsoTimestamp } from '../release-evidence.mjs' + +export function validateRequirementMappings({ + requirements, + mappings, + checks, + artifacts, + tarballArtifactId, +}) { + for (const requirement of requirements) { + const mapping = mappings.get(requirement) + assert(mapping, `Requirement ${requirement} has no evidence mapping`) + assertExactKeys(mapping, ['checks', 'artifacts'], [], `Requirement ${requirement}`) + assert( + Array.isArray(mapping.checks) && mapping.checks.length > 0, + `${requirement} has no checks`, + ) + assert( + Array.isArray(mapping.artifacts) && mapping.artifacts.length > 0, + `${requirement} has no artifacts`, + ) + assert(new Set(mapping.checks).size === mapping.checks.length, `${requirement} repeats a check`) + assert( + new Set(mapping.artifacts).size === mapping.artifacts.length, + `${requirement} repeats an artifact`, + ) + for (const check of mapping.checks) + assert(checks.has(check), `${requirement} names unknown check ${check}`) + for (const artifact of mapping.artifacts) + assert(artifacts.has(artifact), `${requirement} names unknown artifact ${artifact}`) + const prefix = requirement.slice(0, requirement.indexOf('-')) + const admissibleCategories = ADMISSIBLE_CATEGORIES.get(prefix) + assert(admissibleCategories, `Requirement ${requirement} has no category policy`) + assert( + mapping.checks.some((id) => admissibleCategories.has(checks.get(id).category)), + `${requirement} is linked only to inadmissible check categories`, + ) + const exactCategories = EXACT_REQUIREMENT_CHECK_CATEGORIES.get(prefix) + if (exactCategories) { + const exactCheck = checks.get(requirement) + assert(exactCheck, `Requirement ${requirement} requires its own check record`) + assert( + exactCategories.has(exactCheck.category), + `Check ${requirement} has inadmissible category`, + ) + assert( + mapping.checks.includes(requirement), + `${requirement} does not cite its own check record`, + ) + } + } + for (const requirement of mappings.keys()) + assert(requirements.has(requirement), `Evidence maps unknown requirement ${requirement}`) + + const referencedChecks = new Set( + [...mappings.values()].flatMap((mapping) => + Array.isArray(mapping.checks) ? mapping.checks : [], + ), + ) + for (const id of checks.keys()) + assert(referencedChecks.has(id), `Check ${id} is not linked to a requirement`) + const referencedArtifacts = new Set([ + tarballArtifactId, + ...[...checks.values()].flatMap((check) => [check.stdout.artifactId, check.stderr.artifactId]), + ...[...mappings.values()].flatMap((mapping) => + Array.isArray(mapping.artifacts) ? mapping.artifacts : [], + ), + ]) + for (const id of artifacts.keys()) + assert(referencedArtifacts.has(id), `Artifact ${id} is unreferenced`) +} + +export function validateLiveResources({ evidence, environments }) { + const liveResources = new Map() + for (const resource of evidence.liveResources) { + assert(typeof resource?.id === 'string' && resource.id.length > 0, 'Live resource has no id') + assert(!liveResources.has(resource.id), `Duplicate live resource ${resource.id}`) + liveResources.set(resource.id, resource) + assertExactKeys( + resource, + ['id', 'type', 'environment', 'billable'], + [], + `Live resource ${resource.id}`, + ) + assert( + environments.has(resource.environment), + `Live resource ${resource.id} names an unknown environment`, + ) + assert( + typeof resource.type === 'string' && resource.type.length > 0, + `Live resource ${resource.id} has no type`, + ) + assert( + typeof resource.billable === 'boolean', + `Live resource ${resource.id} has invalid billable state`, + ) + } + const cleanup = new Map() + for (const record of evidence.cleanup) { + assert( + typeof record?.resourceId === 'string' && record.resourceId.length > 0, + 'Cleanup record has no resource id', + ) + assert(!cleanup.has(record.resourceId), `Duplicate cleanup record ${record.resourceId}`) + cleanup.set(record.resourceId, record) + assertExactKeys( + record, + ['resourceId', 'status', 'completedAt'], + ['reason'], + `Cleanup ${record.resourceId}`, + ) + assert( + record.status === 'confirmed', + `Live resource ${record.resourceId} cleanup is unresolved`, + ) + strictIsoTimestamp(record.completedAt, `Cleanup ${record.resourceId} completion`) + } + for (const resource of liveResources.values()) { + assert(cleanup.has(resource.id), `Live resource ${resource.id} has no cleanup record`) + } + for (const resourceId of cleanup.keys()) + assert(liveResources.has(resourceId), `Cleanup names unknown live resource ${resourceId}`) +} diff --git a/scripts/release/verification-options.mjs b/scripts/release/verification-options.mjs new file mode 100644 index 0000000..e923f1a --- /dev/null +++ b/scripts/release/verification-options.mjs @@ -0,0 +1,25 @@ +import { join, resolve } from 'node:path' + +export function readVerificationOptions( + environment = process.env, + workingDirectory = process.cwd(), +) { + const repository = resolve( + environment.BRAID_RELEASE_CHECKOUT ?? new URL('../../', import.meta.url).pathname, + ) + const artifactRoot = join(repository, 'artifacts', 'verification') + const releaseInputRoot = join(artifactRoot, 'release') + return { + repository, + workingDirectory: resolve(workingDirectory), + docsRoot: join(repository, 'docs'), + artifactRoot, + checksPath: join(releaseInputRoot, 'checks.json'), + packageProofPath: join(artifactRoot, 'w6', 'package-proof.json'), + visualProofPath: join(artifactRoot, 'w6', 'capture-manifest.json'), + publicKeyPath: join(repository, 'release', 'execution-public-key.pem'), + publicKeyFingerprintPath: join(repository, 'release', 'execution-public-key.fingerprint'), + isolatedCheckout: environment.BRAID_RELEASE_ISOLATED_CHECKOUT, + signingKeyPath: environment.BRAID_RELEASE_SIGNING_KEY_PATH, + } +} diff --git a/scripts/release/verification-plan.mjs b/scripts/release/verification-plan.mjs new file mode 100644 index 0000000..c7ed9b5 --- /dev/null +++ b/scripts/release/verification-plan.mjs @@ -0,0 +1,37 @@ +import { createHash } from 'node:crypto' +import { relative } from 'node:path' + +import { REQUIRED_CHECKS } from '../release-check-catalog.mjs' +import { assert } from '../release-evidence.mjs' +import { readRegularFileNoFollow } from '../release-files.mjs' +import { readRequirementIds } from './build-identity.mjs' +import { filesBelow, uniqueBy } from './verification-support.mjs' + +export async function buildDocumentationPlan({ repository, docsRoot }) { + const docFiles = (await filesBelow(docsRoot)).filter((path) => path.endsWith('.md')) + const requirementIds = await readRequirementIds(repository) + assert(requirementIds.length > 0, 'No requirement identifiers found in docs') + const specificationDigests = [] + for (const path of docFiles) { + const text = (await readRegularFileNoFollow(path)).toString('utf8') + specificationDigests.push({ + path: relative(repository, path), + sha256: createHash('sha256').update(text).digest('hex'), + }) + } + return { + requirements: new Set(requirementIds), + specificationDigests, + } +} + +export function buildEvidencePlan(evidence, requirements) { + const checks = uniqueBy(evidence.checks, 'id', 'check') + const artifacts = uniqueBy(evidence.artifacts, 'id', 'artifact') + const mappings = new Map(Object.entries(evidence.requirements)) + const allowedCheckIds = new Set([...REQUIRED_CHECKS.keys(), ...requirements]) + const allowedCommands = new Map( + [...REQUIRED_CHECKS.values()].map((check) => [check.command, check.category]), + ) + return { checks, artifacts, mappings, allowedCheckIds, allowedCommands } +} diff --git a/scripts/release/verification-report.mjs b/scripts/release/verification-report.mjs new file mode 100644 index 0000000..ea76211 --- /dev/null +++ b/scripts/release/verification-report.mjs @@ -0,0 +1,88 @@ +import { lstat, mkdir } from 'node:fs/promises' +import { join, resolve } from 'node:path' + +import { + assert, + compareCanonicalKeys, + signManifest, + verifyManifestSignature, +} from '../release-evidence.mjs' +import { + containedOutputPath, + readRegularFileNoFollow, + writeExclusiveAtomic, +} from '../release-files.mjs' + +export function renderVerificationReport(manifest) { + const lines = [ + `# Braid ${manifest.braidVersion} release evidence`, + '', + `Commit: \`${manifest.gitCommit}\``, + '', + `Package integrity: \`${manifest.packageIntegrity}\``, + '', + `Checks: ${manifest.checks.length}/${manifest.checks.length} passed.`, + '', + `Requirements: ${Object.keys(manifest.requirements).length}/${Object.keys(manifest.requirements).length} linked.`, + '', + `Artifacts: ${manifest.artifacts.length}.`, + '', + '## Checks', + '', + '| ID | Category | Command | Environment | Duration |', + '| --- | --- | --- | --- | ---: |', + ...manifest.checks.map( + (check) => + `| \`${check.id}\` | ${check.category} | \`${check.command}\` | ${check.environment} | ${check.durationMs} ms |`, + ), + '', + 'Every row above has a valid Ed25519 execution receipt from the pinned release key.', + '', + ] + return `${lines.join('\n')}\n` +} + +export async function writeVerificationOutputs({ + options, + evidence, + specificationDigests, + publicKey, +}) { + assert( + options.signingKeyPath, + 'BRAID_RELEASE_SIGNING_KEY_PATH is required to sign the release manifest', + ) + const signingKeyInfo = await lstat(options.signingKeyPath) + assert(signingKeyInfo.isFile(), 'Release signing key is not a file') + assert(!signingKeyInfo.isSymbolicLink(), 'Release signing key may not be a symlink') + assert( + (signingKeyInfo.mode & 0o077) === 0, + 'Release signing key permissions are broader than 0600', + ) + assert( + !resolve(options.signingKeyPath).startsWith(`${options.repository}/`), + 'Release signing key must be outside checkout', + ) + const signingKey = (await readRegularFileNoFollow(options.signingKeyPath)).toString('utf8') + const unsignedManifest = { + ...evidence, + sourceState: { + ...evidence.sourceState, + specificationDigests: specificationDigests.sort((left, right) => + compareCanonicalKeys(left.path, right.path), + ), + }, + } + const manifest = signManifest(unsignedManifest, signingKey) + verifyManifestSignature(manifest, publicKey) + const outputRoot = await containedOutputPath( + options.repository, + join('artifacts', 'verification', evidence.braidVersion), + ) + const outputPath = join(outputRoot, 'manifest.json') + const reportPath = join(outputRoot, 'report.md') + await mkdir(outputRoot, { recursive: true }) + await writeExclusiveAtomic(outputPath, `${JSON.stringify(manifest, null, 2)}\n`) + await writeExclusiveAtomic(reportPath, renderVerificationReport(manifest)) + return { manifest, outputPath, reportPath } +} diff --git a/scripts/release/verification-source.mjs b/scripts/release/verification-source.mjs new file mode 100644 index 0000000..316b6ac --- /dev/null +++ b/scripts/release/verification-source.mjs @@ -0,0 +1,140 @@ +import { join } from 'node:path' + +import { SHA512_INTEGRITY_PATTERN } from '../release-check-catalog.mjs' +import { + assert, + assertExactKeys, + canonicalJson, + publicKeyId, + validateReleaseInputEnvelope, +} from '../release-evidence.mjs' +import { readRegularFileNoFollow } from '../release-files.mjs' +import { validateVisualProof } from '../release-visual-proof.mjs' +import { readDependencyRecords } from './build-identity.mjs' +import { uniqueBy } from './verification-support.mjs' + +function parseJson(bytes, label) { + try { + return JSON.parse(bytes.toString('utf8')) + } catch (error) { + throw new Error(`${label} is not valid JSON`, { cause: error }) + } +} + +export function assertIsolatedCheckout({ options, git }) { + assert( + options.isolatedCheckout === '1', + 'Release verification requires BRAID_RELEASE_ISOLATED_CHECKOUT=1', + ) + assert( + options.workingDirectory === options.repository, + 'Release verification must run from its isolated checkout', + ) + assert(git('rev-parse', '--is-inside-work-tree') === 'true', 'Release path is not a Git checkout') + assert( + git('status', '--porcelain=v1', '--untracked-files=all', '--ignored=matching') === '', + 'Release checkout contains tracked, untracked, or ignored files', + ) + assert( + git('ls-files', '--error-unmatch', 'release/execution-public-key.pem') === + 'release/execution-public-key.pem', + 'Release public key must be tracked', + ) + assert( + git('ls-files', '--error-unmatch', 'release/execution-public-key.fingerprint') === + 'release/execution-public-key.fingerprint', + 'Release public-key fingerprint must be tracked', + ) +} + +export async function loadReleaseSource({ options, git }) { + const packageProof = parseJson( + await readRegularFileNoFollow(options.packageProofPath), + 'Package proof', + ) + const visualProof = parseJson( + await readRegularFileNoFollow(options.visualProofPath), + 'Visual proof', + ) + await validateVisualProof({ + packageProof, + visualProof, + artifactRoot: options.artifactRoot, + }) + const evidenceBytes = await readRegularFileNoFollow(options.checksPath).catch(() => { + throw new Error( + `Release evidence is incomplete: ${options.checksPath.replace(`${options.repository}/`, '')} is missing`, + ) + }) + const evidence = parseJson(evidenceBytes, 'Release evidence') + const releaseWindow = validateReleaseInputEnvelope(evidence) + assert(evidence.braidVersion === packageProof.version, 'Release evidence version differs') + const evidenceCommit = git('rev-parse', 'HEAD') + assert(evidence.gitCommit === evidenceCommit, 'Release evidence commit differs') + const sourceTree = git('rev-parse', 'HEAD^{tree}') + assert(packageProof.gitCommit === evidence.gitCommit, 'Package proof source commit differs') + assert(packageProof.treeSha256 === sourceTree, 'Package proof source tree differs') + assert(evidence.sourceState.commit === evidence.gitCommit, 'Release source commit differs') + assert(evidence.sourceState.treeSha256 === sourceTree, 'Release source tree differs') + assert(evidence.sourceState.clean === true, 'Source state is not clean') + assert(evidence.sourceState.commit === evidence.gitCommit, 'Source state commit differs') + assert( + evidence.sourceState.tarballSha256 === packageProof.sha256, + 'Source tarball digest differs', + ) + + const packageJson = parseJson( + await readRegularFileNoFollow(join(options.repository, 'package.json')), + 'package.json', + ) + assert(packageJson.version === evidence.braidVersion, 'package.json version differs') + const dependencies = uniqueBy(evidence.dependencies, 'name', 'dependency') + for (const dependency of dependencies.values()) { + assertExactKeys( + dependency, + ['name', 'version', 'integrity'], + [], + `Dependency ${dependency.name}`, + ) + assert( + typeof dependency.version === 'string' && dependency.version.length > 0, + `Dependency ${dependency.name} has no version`, + ) + assert( + SHA512_INTEGRITY_PATTERN.test(dependency.integrity), + `Dependency ${dependency.name} has invalid integrity`, + ) + } + const expectedDependencies = await readDependencyRecords({ + repository: options.repository, + packageJson, + }) + assert( + canonicalJson(expectedDependencies) === canonicalJson(evidence.dependencies), + 'Release dependency inventory differs from package.json and pnpm-lock.yaml', + ) + for (const [name, version] of Object.entries(packageJson.dependencies ?? {})) { + const dependency = dependencies.get(name) + assert(dependency, `Runtime dependency ${name} is absent from release evidence`) + assert(dependency.version === version, `Runtime dependency ${name} version differs`) + } + + const publicKey = (await readRegularFileNoFollow(options.publicKeyPath)).toString('utf8') + const publicKeyFingerprint = (await readRegularFileNoFollow(options.publicKeyFingerprintPath)) + .toString('utf8') + .trim() + assert( + publicKeyFingerprint === publicKeyId(publicKey), + 'Release public key fingerprint is not pinned', + ) + return { + packageProof, + visualProof, + evidence, + releaseWindow, + sourceTree, + packageJson, + dependencies, + publicKey, + } +} diff --git a/scripts/release/verification-support.mjs b/scripts/release/verification-support.mjs new file mode 100644 index 0000000..6477775 --- /dev/null +++ b/scripts/release/verification-support.mjs @@ -0,0 +1,57 @@ +import { execFileSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { readdir } from 'node:fs/promises' +import { join } from 'node:path' + +import { assert } from '../release-evidence.mjs' +import { containedArtifactPath, readRegularFileNoFollow } from '../release-files.mjs' + +export async function filesBelow(root) { + const files = [] + for (const entry of await readdir(root, { withFileTypes: true })) { + const path = join(root, entry.name) + if (entry.isDirectory()) files.push(...(await filesBelow(path))) + else if (entry.isFile()) files.push(path) + } + return files +} + +export function uniqueBy(items, key, label) { + assert(Array.isArray(items), `${label} collection is not an array`) + const values = new Map() + for (const item of items) { + const value = item?.[key] + assert(typeof value === 'string' && value.length > 0, `${label} has no ${key}`) + assert(!values.has(value), `Duplicate ${label} ${value}`) + values.set(value, item) + } + return values +} + +export function createGit(repository) { + return (...args) => { + try { + return execFileSync('git', args, { cwd: repository, encoding: 'utf8' }).trim() + } catch (error) { + if (error?.status === 0 && typeof error.stdout === 'string') return error.stdout.trim() + throw error + } + } +} + +export async function sha256File(path) { + return createHash('sha256') + .update(await readRegularFileNoFollow(path)) + .digest('hex') +} + +export async function sha512IntegrityFile(path) { + return `sha512-${createHash('sha512') + .update(await readRegularFileNoFollow(path)) + .digest('base64')}` +} + +export function artifactPath(repository, path) { + assert(typeof path === 'string' && path.length > 0, 'Evidence artifact has no path') + return containedArtifactPath(repository, path) +} diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs index d4b90dd..e2aa93d 100644 --- a/scripts/run-tests.mjs +++ b/scripts/run-tests.mjs @@ -1,6 +1,7 @@ import { spawnSync } from 'node:child_process' import { readdir } from 'node:fs/promises' -import { extname, join } from 'node:path' +import { createRequire } from 'node:module' +import { extname, join, relative } from 'node:path' const root = new URL('../.test-dist/test/', import.meta.url) @@ -22,5 +23,128 @@ if (tests.length === 0) { process.exit(1) } -const result = spawnSync(process.execPath, ['--test', ...tests], { stdio: 'inherit' }) +const scopeIndex = process.argv.indexOf('--scope') +const scope = scopeIndex === -1 ? undefined : process.argv[scopeIndex + 1] +const listOnly = process.argv.includes('--list') +if (scopeIndex !== -1 && !scope) { + process.stderr.write('--scope requires a test scope\n') + process.exit(1) +} + +const scopeFiles = { + unit: [ + 'application.test.js', + 'cli-startup.test.js', + 'conversations.test.js', + 'coordination.test.js', + 'domain-ids.test.js', + 'domain-invariants.test.js', + 'domain-reducer.test.js', + 'domain-text.test.js', + 'eval.test.js', + 'reducer.test.js', + 'sanitize.test.js', + 'scripts.test.js', + 'w6-ui.test.js', + ], + contract: [ + 'application.test.js', + 'cli-bridge-profile-contract.test.js', + 'conversations.test.js', + 'coordination.test.js', + 'domain-invariants.test.js', + 'domain-reducer.test.js', + 'reducer.test.js', + 'scripts.test.js', + 'w6-contract.test.js', + ], + coordination: [ + 'analysis-durable.test.js', + 'coordination.test.js', + 'effect-admission.test.js', + 'run-admission-architecture.test.js', + ], + rpc: ['profile-connection-actions.test.js', 'rpc.test.js', 'w6-contract.test.js'], + 'virtual-terminal': [ + 'configuration-product-flow.test.js', + 'keyboard.test.js', + 'terminal-responsive.test.js', + 'tui-autocomplete.test.js', + 'tui-conversations.test.js', + 'tui-core-workflows.test.js', + 'tui.test.js', + 'w6-ui.test.js', + ], + storage: [ + 'conversation-storage.test.js', + 'coordination.test.js', + 'domain-reducer.test.js', + 'effect-admission.test.js', + 'storage-crash.test.js', + 'storage-snapshots.test.js', + 'storage.test.js', + ], + security: [ + 'cli-startup.test.js', + 'configuration-product-flow.test.js', + 'conversations.test.js', + 'coordination.test.js', + 'profile-connection-actions.test.js', + 'profile-save-recovery.test.js', + 'sanitize.test.js', + 'security.test.js', + 'storage-snapshots.test.js', + 'storage.test.js', + 'tui-core-workflows.test.js', + 'w6-contract.test.js', + ], + crash: [ + 'conversation-storage.test.js', + 'profile-save-recovery.test.js', + 'storage-crash.test.js', + 'storage.test.js', + ], + performance: [ + 'coordination.test.js', + 'performance.test.js', + 'reducer.test.js', + 'storage-performance.test.js', + ], +} +const selectedTests = + scope === undefined + ? tests + : tests.filter((path) => scopeFiles[scope]?.includes(relative(root.pathname, path))) +if (scope !== undefined && selectedTests.length === 0) { + process.stderr.write(`No compiled tests registered for scope ${scope}\n`) + process.exit(1) +} + +if (listOnly) { + process.stdout.write( + `${JSON.stringify(selectedTests.map((path) => relative(root.pathname, path)))}\n`, + ) + process.exit(0) +} + +const nativeStorageRequired = selectedTests.some( + (path) => + path.endsWith('/conversation-storage.test.js') || + path.endsWith('/storage.test.js') || + path.endsWith('/storage-crash.test.js') || + path.endsWith('/storage-performance.test.js') || + path.endsWith('/effect-admission.test.js'), +) +if (nativeStorageRequired) { + try { + createRequire(import.meta.url).resolve('better-sqlite3-multiple-ciphers') + } catch { + process.stderr.write( + 'W5_NATIVE_STORAGE_BLOCKED: better-sqlite3-multiple-ciphers@12.11.1 is not installed; install dependencies with lifecycle scripts before running storage or crash tests\n', + ) + process.exit(2) + } +} + +const result = spawnSync(process.execPath, ['--test', ...selectedTests], { stdio: 'inherit' }) process.exit(result.status ?? 1) diff --git a/scripts/scan-secret-artifacts.mjs b/scripts/scan-secret-artifacts.mjs new file mode 100644 index 0000000..fe580c1 --- /dev/null +++ b/scripts/scan-secret-artifacts.mjs @@ -0,0 +1,61 @@ +import { lstat, readdir, readFile } from 'node:fs/promises' +import { relative, resolve } from 'node:path' + +const DEFAULT_MAX_FILE_BYTES = 16 * 1024 * 1024 + +function fail(message) { + throw new Error(message) +} + +/** + * Scans a generated evidence tree without following links or reading special files. + * The caller supplies deterministic canaries so this never guesses at user secrets. + */ +export async function scanSecretArtifacts(root, canaries, options = {}) { + const rootPath = resolve(root) + const values = [...new Set(canaries)] + if (values.length === 0) fail('Secret artifact scan requires at least one canary') + for (const value of values) { + if (typeof value !== 'string' || value.length === 0) + fail('Secret artifact scan canaries must be non-empty strings') + } + const maxFileBytes = options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES + if (!Number.isSafeInteger(maxFileBytes) || maxFileBytes <= 0) + fail('Secret artifact scan maxFileBytes must be a positive safe integer') + + const matches = [] + async function visit(path) { + const info = await lstat(path) + if (info.isSymbolicLink()) + fail(`Secret artifact scan refuses symlink: ${relative(rootPath, path)}`) + if (info.isDirectory()) { + const entries = await readdir(path, { withFileTypes: true }) + entries.sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0)) + for (const entry of entries) await visit(resolve(path, entry.name)) + return + } + if (!info.isFile()) + fail(`Secret artifact scan refuses special file: ${relative(rootPath, path)}`) + if (info.size > maxFileBytes) + fail(`Secret artifact scan file exceeds ${maxFileBytes} bytes: ${relative(rootPath, path)}`) + const bytes = await readFile(path) + for (const canary of values) { + if (bytes.includes(Buffer.from(canary, 'utf8'))) + matches.push({ path: relative(rootPath, path), canaryBytes: Buffer.byteLength(canary) }) + } + } + + await visit(rootPath) + return matches +} + +export async function assertNoSecretArtifacts(root, canaries, options = {}) { + const matches = await scanSecretArtifacts(root, canaries, options) + if (matches.length > 0) { + throw new Error( + `Secret canary found in generated artifact(s): ${matches + .map((match) => `${match.path} (${match.canaryBytes} bytes)`) + .join(', ')}`, + ) + } +} diff --git a/scripts/test-pty.mjs b/scripts/test-pty.mjs new file mode 100644 index 0000000..98c61d0 --- /dev/null +++ b/scripts/test-pty.mjs @@ -0,0 +1,265 @@ +import { randomUUID } from 'node:crypto' +import { rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import xterm from '@xterm/headless' +import * as pty from 'node-pty' +import { assertAccessibleTerminalOutput } from './accessibility-output.mjs' +import { installPackedBraid } from './packed-binary.mjs' + +const repository = new URL('../', import.meta.url).pathname +const packed = await installPackedBraid(repository) +const binary = packed.binary +const XtermTerminal = xterm.Terminal + +function sleep(milliseconds) { + return new Promise((resolve) => setTimeout(resolve, milliseconds)) +} + +async function waitFor(predicate, label) { + const deadline = Date.now() + 5_000 + while (!predicate()) { + if (Date.now() >= deadline) throw new Error(`Timed out waiting for ${label}`) + await sleep(20) + } +} + +function screenFrom(emulator, rows) { + const buffer = emulator.buffer.active + return Array.from( + { length: rows }, + (_, index) => buffer.getLine(buffer.viewportY + index)?.translateToString(true) ?? '', + ).join('\n') +} + +function normalizeScreen(screen) { + return screen.replace(/\s+/gu, ' ').trim() +} + +async function run(columns, rows, options = {}) { + const { + term = 'xterm-256color', + noColor = true, + noColorEnvironment = true, + keymap, + kitty = false, + helpDiagnostic = false, + expectNoExtendedColor = false, + expectNoMetadata = false, + bracketedPaste = false, + prompt = 'pty proof', + } = options + const journalPath = join(tmpdir(), `braid-pty-${randomUUID()}.jsonl`) + const environment = { + ...process.env, + TERM: term, + BRAID_JOURNAL_PATH: journalPath, + } + delete environment.FORCE_COLOR + if (noColorEnvironment) environment.NO_COLOR = '1' + else delete environment.NO_COLOR + if (keymap === undefined) delete environment.BRAID_KEYMAP + else environment.BRAID_KEYMAP = keymap + const args = [binary, '--fixture', 'deterministic'] + if (noColor) args.push('--no-color') + try { + const session = pty.spawn(process.execPath, args, { + name: term, + cols: columns, + rows, + cwd: repository, + env: environment, + }) + let output = '' + let query = '' + const exited = new Promise((resolve) => session.onExit(resolve)) + session.onData((chunk) => { + output += chunk + if (!kitty) return + query = `${query}${chunk}`.slice(-64) + if (query.includes('\u001b[>7u')) { + session.write('\u001b[?7u') + query = '' + } + }) + await waitFor(() => output.includes('braid'), `${columns}x${rows} header`) + 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`) + if (kitty) session.write('\u001b[112;5:3u') + session.write('\u001b') + await sleep(100) + if (helpDiagnostic) { + session.write('/help\r') + await waitFor( + () => output.includes('Kitty protocol unavailable'), + `${columns}x${rows} keyboard fallback diagnostic`, + ) + session.write('\u001b') + await sleep(100) + } + if (bracketedPaste) session.write(`\u001b[200~${prompt}\u001b[201~`) + else session.write(prompt) + session.write('\r') + await waitFor( + () => output.includes(`Fixture response through pi: ${prompt}`), + `${columns}x${rows} result`, + ) + session.write('\u0003') + await waitFor( + () => + output.includes('press ctrl+c again to quit') || output.includes('ctrl+c again to quit'), + `${columns}x${rows} safe exit`, + ) + session.write('\u0003') + const exit = await Promise.race([ + exited, + sleep(5_000).then(() => { + session.kill() + throw new Error('PTY process did not exit') + }), + ]) + if (exit.exitCode !== 0) throw new Error(`PTY exited ${exit.exitCode}`) + if (!output.includes('\u001b[?1049l')) + throw new Error('alternate-screen cleanup sequence missing') + if ( + expectNoExtendedColor && + (output.includes('\u001b[38;2;') || + output.includes('\u001b[38;5;') || + output.includes('\u001b[48;2;') || + output.includes('\u001b[48;5;')) + ) + throw new Error(`${term} PTY emitted extended-color SGR despite the terminal limit`) + if (expectNoMetadata) assertAccessibleTerminalOutput(output) + return output + } finally { + await rm(journalPath, { force: true }) + } +} + +async function runAutocompleteRace() { + const columns = 120 + const rows = 36 + const journalPath = join(tmpdir(), `braid-pty-autocomplete-${randomUUID()}.jsonl`) + const emulator = new XtermTerminal({ + cols: columns, + rows, + disableStdin: true, + allowProposedApi: true, + }) + const environment = { + ...process.env, + TERM: 'xterm-256color', + NO_COLOR: '1', + BRAID_JOURNAL_PATH: journalPath, + } + delete environment.FORCE_COLOR + const session = pty.spawn( + process.execPath, + [binary, '--fixture', 'deterministic', '--no-color'], + { + name: 'xterm-256color', + cols: columns, + rows, + cwd: repository, + env: environment, + }, + ) + let output = '' + let screen = '' + const exited = new Promise((resolve) => session.onExit(resolve)) + session.onData((chunk) => { + output += chunk + emulator.write(chunk, () => { + screen = screenFrom(emulator, rows) + }) + }) + try { + await waitFor(() => normalizeScreen(screen).includes('braid'), 'autocomplete race header') + session.write('/') + await waitFor( + () => normalizeScreen(screen).includes('Create an empty conversation'), + 'autocomplete race stale menu', + ) + + // One PTY write is intentionally split into printable keys plus Enter by Pi's + // StdinBuffer. Enter must not accept the menu created for the older "/" draft. + session.write('profile\r') + await waitFor( + () => + normalizeScreen(screen).includes('profiles') && + normalizeScreen(screen).includes('^V valid'), + 'exact /profile overlay', + ) + if (/unknown command \/profil(?:new|enew)/iu.test(output)) + throw new Error('stale slash completion changed /profile before submit') + + session.write('\u001b') + await sleep(50) + session.write('\u0003') + await waitFor( + () => normalizeScreen(screen).includes('ctrl+c again to quit'), + 'autocomplete race safe exit', + ) + session.write('\u0003') + const exit = await Promise.race([ + exited, + sleep(5_000).then(() => { + session.kill() + throw new Error('Autocomplete race PTY process did not exit') + }), + ]) + if (exit.exitCode !== 0) throw new Error(`Autocomplete race PTY exited ${exit.exitCode}`) + if (!output.includes('\u001b[?1049l')) + throw new Error('autocomplete race alternate-screen cleanup sequence missing') + } finally { + emulator.dispose() + session.kill() + await rm(journalPath, { force: true }) + } +} + +try { + for (const [columns, rows] of [ + [40, 12], + [80, 24], + [120, 40], + [200, 60], + ]) + await run(columns, rows, { expectNoMetadata: true }) + await run(80, 24, { + noColor: false, + noColorEnvironment: true, + expectNoExtendedColor: true, + }) + await run(80, 24, { + noColor: false, + noColorEnvironment: false, + term: 'ansi', + expectNoExtendedColor: true, + }) + await run(80, 24, { + noColor: false, + noColorEnvironment: false, + keymap: 'commandPalette=ctrl+q', + }) + await run(80, 24, { + noColor: false, + noColorEnvironment: false, + helpDiagnostic: true, + }) + await run(80, 24, { + noColor: false, + noColorEnvironment: false, + kitty: true, + }) + await run(80, 24, { + bracketedPaste: true, + prompt: 'pty proof 漢字 é 👩🏽‍💻', + }) + await runAutocompleteRace() + process.stdout.write('Packed PTY proof passed at 40x12, 80x24, 120x40, and 200x60\n') +} finally { + await packed.cleanup() +} diff --git a/scripts/test-release-evidence.mjs b/scripts/test-release-evidence.mjs new file mode 100644 index 0000000..0ca351f --- /dev/null +++ b/scripts/test-release-evidence.mjs @@ -0,0 +1,240 @@ +import { generateKeyPairSync } from 'node:crypto' +import { execFileSync } from 'node:child_process' +import { + signCheck, + strictIsoTimestamp, + validateMeasurements, + validatePerformanceMatrix, + validatePerformanceMeasurements, + REQUIRED_PERFORMANCE_TARGETS, + validateReleaseInputEnvelope, + verifyCheckReceipt, +} from './release-evidence.mjs' +import { + containedArtifactPath, + containedOutputPath, + readRegularFileNoFollow, + writeExclusiveAtomic, +} from './release-files.mjs' +import { mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +function rejects(action, pattern) { + try { + action() + } catch (error) { + if (pattern.test(String(error))) return + throw error + } + throw new Error(`Expected rejection matching ${pattern}`) +} + +async function rejectsAsync(action, pattern) { + try { + await action() + } catch (error) { + if (pattern.test(String(error))) return + throw error + } + throw new Error(`Expected async rejection matching ${pattern}`) +} + +const { privateKey, publicKey } = generateKeyPairSync('ed25519') +const check = signCheck( + { + id: 'unit', + category: 'unit', + required: true, + command: 'pnpm test:unit', + cwd: '.', + environment: 'local-linux', + startedAt: '2026-08-02T07:00:00.000Z', + completedAt: '2026-08-02T07:00:01.000Z', + durationMs: 1000, + attempt: 1, + exitCode: 0, + result: 'passed', + buildSha256: 'a'.repeat(64), + measurements: [{ kind: 'scalar', name: 'tests', unit: 'count', value: 61 }], + stdout: { artifactId: 'unit-stdout', sha256: 'b'.repeat(64) }, + stderr: { artifactId: 'unit-stderr', sha256: 'c'.repeat(64) }, + failureDetails: null, + }, + privateKey, +) +verifyCheckReceipt(check, publicKey) +rejects(() => verifyCheckReceipt({ ...check, exitCode: 1 }, publicKey), /payload changed/u) +strictIsoTimestamp(check.startedAt, 'start') +rejects(() => strictIsoTimestamp(0, 'start'), /canonical UTC/u) +rejects(() => strictIsoTimestamp('2026-08-02', 'start'), /canonical UTC/u) +validateMeasurements(check.measurements, 'check') +rejects( + () => + validateMeasurements( + [ + { + kind: 'distribution', + name: 'latency', + unit: 'ms', + n: 2, + minimum: 1, + median: 2, + p90: 3, + p95: 4, + p99: 5, + maximum: 'fabricated', + }, + ], + 'check', + true, + ), + /finite number/u, +) +const performanceMeasurement = { + kind: 'distribution', + name: 'PERF-01', + unit: 'ms', + n: 20, + minimum: 10, + median: 20, + p90: 30, + p95: 40, + p99: 45, + maximum: 50, + target: REQUIRED_PERFORMANCE_TARGETS['PERF-01'], + environment: { + machine: 'dedicated-linux-x64', + os: 'linux', + node: '22.19.0', + terminal: 'xterm-256color', + dimensions: '80x24', + database: 'warm-empty', + eventCount: 0, + }, + state: 'warm', + repetitions: 20, +} +validatePerformanceMeasurements([performanceMeasurement], 'performance') +validatePerformanceMatrix( + Array.from({ length: 10 }, (_, index) => { + const name = `PERF-${String(index + 1).padStart(2, '0')}` + return { + ...performanceMeasurement, + name, + target: REQUIRED_PERFORMANCE_TARGETS[name], + ...(name === 'PERF-08' + ? { + unit: '% cpu', + minimum: 0.1, + median: 0.5, + p90: 0.7, + p95: 0.8, + p99: 0.9, + maximum: 1, + } + : {}), + } + }), + 'performance matrix', +) +rejects( + () => + validatePerformanceMeasurements( + [{ ...performanceMeasurement, n: 1, repetitions: 1 }], + 'performance', + ), + /n=1/u, +) +rejects( + () => + validatePerformanceMeasurements( + [{ ...performanceMeasurement, target: { ...performanceMeasurement.target, value: 1 } }], + 'performance', + ), + /target differs from the required target/u, +) +rejects( + () => + validatePerformanceMeasurements( + [{ ...performanceMeasurement, kind: 'unavailable', reason: 'not implemented' }], + 'performance', + ), + /requires distributions/u, +) +const docsShapedEvidence = { + schemaVersion: 1, + braidVersion: '0.1.0', + gitCommit: 'd'.repeat(40), + packageIntegrity: `sha512-${Buffer.alloc(64).toString('base64')}`, + startedAt: '2026-08-02T07:00:00.000Z', + finishedAt: '2026-08-02T07:00:01.000Z', + sourceState: { + clean: true, + commit: 'd'.repeat(40), + treeSha256: 'f'.repeat(40), + tarballSha256: 'e'.repeat(64), + tarballArtifactId: 'package-tarball', + }, + dependencies: [], + environments: [], + checks: [], + requirements: {}, + artifacts: [], + liveResources: [], + cleanup: [], + signatures: [], +} +validateReleaseInputEnvelope(docsShapedEvidence) +rejects( + () => validateReleaseInputEnvelope({ ...docsShapedEvidence, requirements: [] }), + /Requirement mappings/u, +) + +const hostileRoot = await mkdtemp(join(tmpdir(), 'braid-release-evidence-')) +try { + await writeFile(join(hostileRoot, 'safe.txt'), 'safe') + if ((await readRegularFileNoFollow(join(hostileRoot, 'safe.txt'))).toString() !== 'safe') + throw new Error('Safe release read changed its content') + await rejectsAsync( + () => containedArtifactPath(hostileRoot, '../outside.txt'), + /leaves (?:repository|release root)/u, + ) + await symlink(join(hostileRoot, 'safe.txt'), join(hostileRoot, 'link.txt')) + await rejectsAsync(() => containedArtifactPath(hostileRoot, 'link.txt'), /symlink/iu) + await rejectsAsync( + () => containedOutputPath(hostileRoot, '../outside'), + /leaves (?:repository|release root)/iu, + ) + await rejectsAsync(() => readRegularFileNoFollow(join(hostileRoot, 'link.txt')), /non-symlink/iu) + const target = join(hostileRoot, 'target.txt') + await writeFile(target, 'original') + await rejectsAsync(() => writeExclusiveAtomic(target, 'overwrite'), /EEXIST|exist/u) + if ((await readFile(target)).toString() !== 'original') + throw new Error('Release output was overwritten') + const outputLink = join(hostileRoot, 'output-link.txt') + await symlink(target, outputLink) + await rejectsAsync(() => writeExclusiveAtomic(outputLink, 'overwrite'), /EEXIST|exist/u) + if ((await readFile(target)).toString() !== 'original') + throw new Error('Symlink release output was overwritten') +} finally { + await rm(hostileRoot, { recursive: true, force: true }) +} +let verifierOutput = '' +try { + execFileSync(process.execPath, ['scripts/verify-release.mjs'], { + cwd: process.cwd(), + env: { + ...process.env, + BRAID_RELEASE_CHECKOUT: process.cwd(), + BRAID_RELEASE_ISOLATED_CHECKOUT: '1', + }, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }) +} catch (error) { + verifierOutput = String(error) +} +if (!/untracked|isolated|clean/iu.test(verifierOutput)) + throw new Error('verify-release did not reject the current non-clean checkout') +process.stdout.write('Release evidence contract self-test passed.\n') diff --git a/scripts/test-rpc-packed.mjs b/scripts/test-rpc-packed.mjs new file mode 100644 index 0000000..1285249 --- /dev/null +++ b/scripts/test-rpc-packed.mjs @@ -0,0 +1,18 @@ +import { installPackedBraid } from './packed-binary.mjs' +import { runDeterministicRpcProof } from './packed-rpc/deterministic.mjs' +import { runPackedFirstRun } from './packed-rpc/first-run-tui.mjs' +import { runBuiltStartupProof } from './packed-rpc/production-startup.mjs' + +const repository = new URL('../', import.meta.url).pathname +const packed = await installPackedBraid(repository) + +try { + await runBuiltStartupProof(packed.binary, repository) + const startupProof = await runPackedFirstRun(packed.binary, repository) + process.stdout.write( + `Packed first-run setup, immediate send, restart, and persisted ${startupProof.model} send passed\n`, + ) + await runDeterministicRpcProof(packed.binary, repository) +} finally { + await packed.cleanup() +} diff --git a/scripts/test.mjs b/scripts/test.mjs new file mode 100644 index 0000000..601acc8 --- /dev/null +++ b/scripts/test.mjs @@ -0,0 +1,22 @@ +import { spawnSync } from 'node:child_process' + +const repository = new URL('../', import.meta.url).pathname +const args = process.argv.slice(2).filter((argument) => argument !== '--') + +function run(command, commandArgs) { + const result = spawnSync(command, commandArgs, { + cwd: repository, + stdio: 'inherit', + }) + if (result.error) throw result.error + if (result.status !== 0) process.exit(result.status ?? 1) +} + +if (args.includes('--list')) { + run(process.execPath, ['scripts/run-tests.mjs', ...args]) +} else { + run(process.execPath, ['scripts/clean-tests.mjs']) + run('pnpm', ['exec', 'tsc', '-p', 'tsconfig.test.json']) + run(process.execPath, ['scripts/run-tests.mjs', ...args]) + run(process.execPath, ['scripts/test-release-evidence.mjs']) +} diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs index 1749f58..652706f 100644 --- a/scripts/verify-package.mjs +++ b/scripts/verify-package.mjs @@ -1,273 +1,150 @@ import { createHash } from 'node:crypto' -import { spawn } from 'node:child_process' -import { mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises' +import { + cp, + lstat, + mkdir, + mkdtemp, + readdir, + readFile, + rm, + symlink, + writeFile, +} from 'node:fs/promises' import { tmpdir } from 'node:os' -import { delimiter, dirname, join, resolve } from 'node:path' -import * as pty from 'node-pty' -import xterm from '@xterm/headless' +import { delimiter, dirname, join, resolve, sep } from 'node:path' +import { assertAccessibleTerminalOutput } from './accessibility-output.mjs' +import { runPlain, runRpc, runSignalTerminal, runTerminal } from './package-proof-flows.mjs' +import { + baselineEventEnd, + firstDifference, + firstTerminalTrace, + parityEvidence, +} from './package-proof-parity.mjs' +import { + cleanEnvironment, + gitValue, + installEnvironment, + repository, + run, + runPty, + sourceDigest, +} from './package-proof-runtime.mjs' +import { assertNoSecretArtifacts } from './scan-secret-artifacts.mjs' -const repository = new URL('../', import.meta.url).pathname -const XtermTerminal = xterm.Terminal const recordIndex = process.argv.indexOf('--record') const recordPath = recordIndex === -1 ? undefined : process.argv[recordIndex + 1] if (recordIndex !== -1 && !recordPath) throw new Error('--record requires a path') -function cleanEnvironment(extra = {}) { - const environment = { ...process.env, ...extra } - delete environment.FORCE_COLOR - return environment +function assert(condition, message) { + if (!condition) throw new Error(message) } -async function run(file, args, options = {}) { - return await new Promise((resolve, reject) => { - const child = spawn(file, args, { - cwd: options.cwd, - env: options.env ?? cleanEnvironment(), - stdio: ['ignore', 'pipe', 'pipe'], - }) - let stdout = '' - let stderr = '' - child.stdout.setEncoding('utf8') - child.stderr.setEncoding('utf8') - child.stdout.on('data', (chunk) => { - stdout += chunk - }) - child.stderr.on('data', (chunk) => { - stderr += chunk - }) - child.on('error', reject) - child.on('close', (code) => { - if (code === 0) resolve({ stdout, stderr }) - else reject(new Error(`${file} ${args.join(' ')} exited ${code}\n${stdout}\n${stderr}`)) - }) - }) -} +assert( + JSON.stringify( + parityEvidence({ runs: [], profile: { metadata: { operationId: 'profile-a' } } }, []), + ) !== + JSON.stringify( + parityEvidence({ runs: [], profile: { metadata: { operationId: 'profile-b' } } }, []), + ), + 'package parity must preserve non-caller operationId fields', +) -async function runRpc(binary, cwd) { - return await new Promise((resolve, reject) => { - const child = spawn(binary, ['rpc', '--fixture', 'deterministic'], { - cwd, - env: cleanEnvironment({ NO_COLOR: '1' }), - stdio: ['pipe', 'pipe', 'pipe'], - }) - let stdout = '' - let stderr = '' - child.stdout.setEncoding('utf8') - child.stderr.setEncoding('utf8') - child.stdout.on('data', (chunk) => { - stdout += chunk - }) - child.stderr.on('data', (chunk) => { - stderr += chunk - }) - child.on('error', reject) - child.on('close', (code) => { - if (code !== 0) { - reject(new Error(`packed braid rpc exited ${code}\n${stdout}\n${stderr}`)) - return - } - try { - const responses = stdout - .trim() - .split('\n') - .map((line) => JSON.parse(line)) - const state = responses.find( - (response) => response.type === 'state' && response.requestId === 'req-send', - )?.state - if (!state) throw new Error('packed RPC did not return send state') - resolve({ responses, state, stderr }) - } catch (error) { - reject(error) - } - }) - const requests = [ - { - version: 1, - requestId: 'req-init', - command: 'initialize', - params: { workspace: cwd, subscribe: true }, - }, - { - version: 1, - requestId: 'req-send', - operationId: 'op-rpc-000001', - command: 'send', - params: { - conversationId: 'conv-1', - branchId: 'branch-1', - text: 'hello from package proof', +function parityFixture(operationId) { + const admission = { + version: 1, + runId: 'run-1', + turnId: 'turn-1', + operationId, + conversationId: 'conversation-1', + branchId: 'branch-1', + admittedAt: '2026-08-01T00:00:00.000Z', + profileDigest: 'profile-digest', + requested: { text: 'same prompt' }, + capabilities: {}, + admissionStatus: 'admitted', + requestDigest: `request-${operationId}`, + capabilitiesDigest: 'capabilities-digest', + digest: `admission-${operationId}`, + } + return [ + { sequence: 1, revision: 1, kind: 'run.requested', payload: { admission } }, + { + sequence: 2, + revision: 2, + kind: 'run.finished', + payload: { status: 'completed' }, + }, + { + sequence: 3, + revision: 3, + kind: 'effect.upserted', + payload: { + value: { + effect: { + id: `effect-${operationId}-same`, + operationId, + status: 'terminal', + }, }, }, - { version: 1, requestId: 'req-stop', command: 'shutdown' }, - ] - child.stdin.end(`${requests.map((request) => JSON.stringify(request)).join('\n')}\n`) - }) + }, + ] } -function sleep(milliseconds) { - return new Promise((resolve) => setTimeout(resolve, milliseconds)) -} +const rpcParityFixture = parityFixture('op-rpc-1') +const terminalParityFixture = parityFixture('op-terminal-1') +assert(baselineEventEnd(rpcParityFixture) === 3, 'package parity omitted a terminal effect') +assert( + JSON.stringify(parityEvidence({ runs: [] }, rpcParityFixture)) === + JSON.stringify(parityEvidence({ runs: [] }, terminalParityFixture)), + 'package parity failed to normalize caller identity and dependent digests', +) -async function waitFor(predicate, label, timeoutMs = 5_000) { - const deadline = Date.now() + timeoutMs - while (!predicate()) { - if (Date.now() >= deadline) throw new Error(`Timed out waiting for ${label}`) - await sleep(20) - } -} - -async function runTerminal(binary, cwd, options) { - const recordPath = join( - cwd, - `terminal-${options.columns}x${options.rows}-${options.inline ? 'inline' : 'alt'}.json`, - ) - const args = [ - '--fixture', - 'deterministic', - '--no-color', - '--workspace', - cwd, - '--record-state', - recordPath, - ] - if (options.inline) args.push('--inline') - const session = pty.spawn(binary, args, { - name: 'xterm-256color', - cols: options.columns, - rows: options.rows, - cwd, - env: cleanEnvironment({ NO_COLOR: '1', TERM: 'xterm-256color' }), - }) - const victimPath = `${recordPath}.victim` - const formerPredictableTemporary = `${recordPath}.${session.pid}.tmp` - await writeFile(victimPath, 'unchanged\n') - await symlink(victimPath, formerPredictableTemporary) - const emulator = new XtermTerminal({ - cols: options.columns, - rows: options.rows, - disableStdin: true, - allowProposedApi: true, - }) - let output = '' - let screen = '' - const exited = new Promise((resolve) => { - session.onExit(resolve) - }) - session.onData((chunk) => { - output += chunk - emulator.write(chunk, () => { - const buffer = emulator.buffer.active - screen = Array.from( - { length: emulator.rows }, - (_, index) => buffer.getLine(buffer.viewportY + index)?.translateToString(true) ?? '', - ).join('\n') +if (process.env.BRAID_PACKAGE_PROOF_ISOLATED !== '1') { + const isolatedRoot = await mkdtemp(join(tmpdir(), 'braid-package-source-')) + try { + await cp(repository, isolatedRoot, { + recursive: true, + filter: (source) => + !['.git', 'node_modules', 'dist', '.test-dist', 'artifacts'].some( + (excluded) => + source === join(repository, excluded) || + source.startsWith(`${join(repository, excluded)}${sep}`), + ), }) - }) - const normalizedScreen = () => screen.replace(/\s+/gu, ' ').trim() - - await waitFor(() => screen.includes('braid'), 'terminal header') - if (!options.inline) { - session.write('\u0010') - await waitFor( - () => screen.includes('Commands') && screen.includes('/quit'), - 'searchable command overlay', - ) - session.write('q') - await waitFor( - () => screen.includes('/quit') && !screen.includes('/help'), - 'filtered command overlay', + await symlink(join(repository, 'node_modules'), join(isolatedRoot, 'node_modules')) + await run(process.execPath, [join(isolatedRoot, 'scripts', 'clean.mjs')], { + cwd: isolatedRoot, + }) + await run( + process.execPath, + [join(repository, 'node_modules', 'typescript', 'bin', 'tsc'), '-p', 'tsconfig.build.json'], + { cwd: isolatedRoot }, ) - session.write('\u001b') - await waitFor(() => !screen.includes('Commands'), 'closed command overlay') - } - session.write('hello from package proof') - await sleep(30) - session.write('\r') - if (!options.inline) { - const resizedColumns = Math.max(40, options.columns - 10) - const resizedRows = Math.max(12, options.rows - 4) - emulator.resize(resizedColumns, resizedRows) - session.resize(resizedColumns, resizedRows) - await sleep(30) - emulator.resize(options.columns, options.rows) - session.resize(options.columns, options.rows) - } - await waitFor( - () => - normalizedScreen().includes('Fixture response through pi: hello from package proof') && - normalizedScreen().includes('ready'), - 'completed fixture response', - ) - const screenBeforeExit = screen - session.write('\u0003') - await waitFor(() => screen.includes('press ctrl+c again to quit'), 'armed terminal exit') - session.write('\u0003') - let timeout - const timedOut = new Promise((_, reject) => { - timeout = setTimeout(() => { - session.kill() - reject(new Error('Packed terminal did not exit after Ctrl+C')) - }, 5_000) - }) - const exit = await Promise.race([exited, timedOut]).finally(() => clearTimeout(timeout)) - if (exit.exitCode !== 0) throw new Error(`Packed terminal exited ${exit.exitCode}`) - const evidence = JSON.parse(await readFile(recordPath, 'utf8')) - assert( - (await readFile(victimPath, 'utf8')) === 'unchanged\n', - 'state write followed a temp symlink', - ) - await rm(formerPredictableTemporary, { force: true }) - emulator.dispose() - return { output, screenBeforeExit, evidence } -} - -async function runSignalTerminal(binary, cwd) { - const session = pty.spawn(binary, ['--fixture', 'deterministic', '--no-color'], { - name: 'xterm-256color', - cols: 80, - rows: 24, - cwd, - env: cleanEnvironment({ NO_COLOR: '1', TERM: 'xterm-256color' }), - }) - let output = '' - const exited = new Promise((resolve) => session.onExit(resolve)) - session.onData((chunk) => { - output += chunk - }) - await waitFor(() => output.includes('braid'), 'signal terminal header') - process.kill(session.pid, 'SIGINT') - let timeout - const timedOut = new Promise((_, reject) => { - timeout = setTimeout(() => { - session.kill() - reject(new Error('Packed terminal did not exit after SIGINT')) - }, 5_000) - }) - const exit = await Promise.race([exited, timedOut]).finally(() => clearTimeout(timeout)) - return { output, exit } -} - -function semanticState(state) { - return { - conversationId: state.conversationId, - branchId: state.branchId, - draft: state.draft, - messages: state.messages.map((message) => ({ - role: message.role, - text: message.text, - status: message.status, - })), - runs: state.runs.map((run) => ({ status: run.status })), + const childArgs = [join(isolatedRoot, 'scripts', 'verify-package.mjs')] + if (recordPath) childArgs.push('--record', resolve(repository, recordPath)) + const child = await run(process.execPath, childArgs, { + cwd: isolatedRoot, + env: { + ...cleanEnvironment({ NODE_NO_WARNINGS: '1' }), + BRAID_PACKAGE_PROOF_ISOLATED: '1', + BRAID_PACKAGE_PROOF_COMMIT: gitValue('rev-parse', 'HEAD'), + BRAID_PACKAGE_PROOF_TREE: gitValue('rev-parse', 'HEAD^{tree}'), + BRAID_PACKAGE_PROOF_SOURCE_DIGEST: await sourceDigest(repository), + }, + }) + process.stdout.write(child.stdout) + process.stderr.write(child.stderr) + } finally { + await rm(isolatedRoot, { force: true, recursive: true }) } -} - -function assert(condition, message) { - if (!condition) throw new Error(message) + process.exit(0) } const packRoot = await mkdtemp(join(tmpdir(), 'braid-pack-')) const installRoot = await mkdtemp(join(tmpdir(), 'braid-install-')) try { + const sourcePackageJson = JSON.parse(await readFile(join(repository, 'package.json'), 'utf8')) await run('pnpm', ['pack', '--pack-destination', packRoot], { cwd: repository }) const tarballName = (await readdir(packRoot)).find((name) => name.endsWith('.tgz')) if (!tarballName) throw new Error('pnpm pack did not produce a tarball') @@ -276,9 +153,150 @@ try { join(installRoot, 'package.json'), `${JSON.stringify({ name: 'braid-clean-install-proof', private: true })}\n`, ) - await run('npm', ['install', '--ignore-scripts', '--no-audit', '--no-fund', tarball], { + await run('npm', ['install', '--no-audit', '--no-fund', tarball], { cwd: installRoot, + env: installEnvironment(), }) + const installedPackageRoot = join(installRoot, 'node_modules', '@tangle-network', 'braid') + const installedPackageJson = JSON.parse( + await readFile(join(installedPackageRoot, 'package.json'), 'utf8'), + ) + assert(installedPackageJson.name === sourcePackageJson.name, 'installed package name mismatch') + assert( + installedPackageJson.version === sourcePackageJson.version, + 'installed package version mismatch', + ) + assert( + installedPackageJson.bin?.braid === './dist/bin/braid.js', + 'installed package lost the braid binary declaration', + ) + const packageOwnedEntries = new Set([ + 'package.json', + 'dist', + 'LICENSE', + 'README.md', + 'THIRD_PARTY_LICENSES.json', + 'THIRD_PARTY_NOTICES.md', + ]) + const declaredPackageFiles = new Set(installedPackageJson.files ?? []) + const expectedDeclaredFiles = new Set( + [...packageOwnedEntries].filter((entry) => entry !== 'package.json'), + ) + assert( + declaredPackageFiles.size === expectedDeclaredFiles.size && + [...expectedDeclaredFiles].every((entry) => declaredPackageFiles.has(entry)), + 'packed package files allowlist does not match the audited package contents', + ) + const installedEntries = await readdir(installedPackageRoot, { withFileTypes: true }) + for (const entry of installedEntries) { + assert( + packageOwnedEntries.has(entry.name) || entry.name === 'node_modules', + `packed package contains unexpected ${entry.name}`, + ) + if (entry.name !== 'node_modules') + assert(!entry.isSymbolicLink(), `packed package contains symlink ${entry.name}`) + } + for (const required of packageOwnedEntries) { + assert( + installedEntries.some((entry) => entry.name === required), + `packed package is missing ${required}`, + ) + } + const binaryInfo = await lstat(join(installedPackageRoot, 'dist', 'bin', 'braid.js')) + assert(binaryInfo.isFile() && !binaryInfo.isSymbolicLink(), 'packed binary is not a regular file') + if (process.platform !== 'win32') + assert((binaryInfo.mode & 0o111) !== 0, 'packed binary is not executable') + const installedSourceNames = new Set(['src', 'test', 'docs', '.git', '.npmrc', '.env']) + assert( + installedEntries.every((entry) => !installedSourceNames.has(entry.name)), + 'packed package contains source or credential configuration', + ) + async function assertNoPackageLinks(path) { + const entries = await readdir(path, { withFileTypes: true }) + for (const entry of entries) { + const entryPath = join(path, entry.name) + assert(!entry.isSymbolicLink(), `packed package contains symlink ${entryPath}`) + if (entry.isDirectory()) await assertNoPackageLinks(entryPath) + } + } + const secretCanaries = [ + 'W12_SECRET_ARTIFACT_CANARY', + 'PACKED_W5_RAW_BYTE_CANARY', + 'SECRET_TYPED_INTERACTION_CANARY', + ] + for (const entry of packageOwnedEntries) { + const ownedPath = join(installedPackageRoot, entry) + const info = await lstat(ownedPath) + assert(!info.isSymbolicLink(), `packed package contains symlink ${entry}`) + if (info.isDirectory()) await assertNoPackageLinks(ownedPath) + await assertNoSecretArtifacts(ownedPath, secretCanaries) + } + const storageSmoke = join(installRoot, 'storage-smoke.mjs') + await writeFile( + storageSmoke, + ` +import assert from 'node:assert/strict' +import { readFile, mkdtemp, stat } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + MemoryCredentialStore, + openSqliteStorage, + canonicalDigest, + createConversationId, + createEventId, + createOperationId, + createRunId, + createWorkspaceId, + credentialRef, +} from '@tangle-network/braid' + +const root = await mkdtemp(join(tmpdir(), 'braid-packed-storage-')) +const database = join(root, 'braid.sqlite') +const backup = join(root, 'braid.backup') +const credentials = new MemoryCredentialStore() +const canary = 'PACKED_W5_RAW_BYTE_CANARY' +const storage = await openSqliteStorage({ + path: database, + workspaceRoot: root, + credentialStore: credentials, + databaseKeyRef: credentialRef('cred:v1:packed-database'), +}) +try { + const event = { + workspaceId: createWorkspaceId('workspace-packed'), + conversationId: createConversationId('conversation-packed'), + runId: createRunId('run-packed'), + eventId: createEventId('event-packed'), + sequence: 1, + kind: 'run.finished', + payload: { text: canary }, + occurredAt: '2026-08-02T00:00:00.000Z', + terminal: true, + } + await storage.append([event]) + assert.equal((await storage.replay({ runId: event.runId })).events[0]?.payloadState, 'available') + assert.equal((await storage.integrity()).ok, true) + const backupRequest = { path: backup } + await storage.backup({ + path: backup, + operation: { + operationId: createOperationId('op-packed-backup'), + kind: 'backup', + request: backupRequest, + requestDigest: canonicalDigest(backupRequest), + }, + }) + for (const path of [database, backup, database + '-wal', database + '-shm']) { + assert.equal((await readFile(path).catch(() => Buffer.alloc(0))).includes(Buffer.from(canary)), false, path) + } + assert.ok((await stat(backup)).size > 0) +} finally { + await storage.close() +} +`, + ) + await run(process.execPath, [storageSmoke], { cwd: installRoot }) const binary = join( installRoot, 'node_modules', @@ -287,12 +305,19 @@ try { ) const path = `${join(installRoot, 'node_modules', '.bin')}${delimiter}${process.env.PATH ?? ''}` const environment = cleanEnvironment({ PATH: path, NO_COLOR: '1' }) - const version = await run(binary, ['--version'], { cwd: installRoot, env: environment }) - const help = await run(binary, ['--help'], { cwd: installRoot, env: environment }) - assert(version.stdout.trim() === '0.1.0', 'packed --version mismatch') + const version = await runPty(binary, ['--version'], { + cwd: installRoot, + env: { ...environment, NODE_NO_WARNINGS: '1' }, + }) + const help = await runPty(binary, ['--help'], { + cwd: installRoot, + env: { ...environment, NODE_NO_WARNINGS: '1' }, + }) + assert(version.stdout.trim() === sourcePackageJson.version, 'packed --version mismatch') assert(help.stdout.includes('braid rpc'), 'packed --help omitted RPC mode') const rpc = await runRpc(binary, installRoot) + const plain = await runPlain(binary, installRoot) const terminal80 = await runTerminal(binary, installRoot, { columns: 80, rows: 24, @@ -313,23 +338,47 @@ try { rows: 60, inline: false, }) + const accessibility = await runTerminal(binary, installRoot, { + columns: 80, + rows: 24, + inline: false, + highContrast: true, + reducedMotion: true, + }) const inline = await runTerminal(binary, installRoot, { columns: 80, rows: 24, inline: true, }) const signal = await runSignalTerminal(binary, installRoot) + const terminalBaseline = firstTerminalTrace(terminal80.evidence) + const rpcParity = parityEvidence(rpc.firstState, rpc.baselineEvents) + const terminalParity = parityEvidence(terminalBaseline.state, terminalBaseline.events) + const keyboardMatchesRpc = JSON.stringify(rpcParity) === JSON.stringify(terminalParity) + const expectedFlows = ['send', 'graph', 'unavailable', 'retry', 'cancel', 'shutdown'] + const flowsMatch = (flows) => JSON.stringify(flows) === JSON.stringify(expectedFlows) assert( - JSON.stringify(semanticState(rpc.state)) === - JSON.stringify(semanticState(terminal80.evidence.state)), - 'keyboard and RPC semantic states differ', + keyboardMatchesRpc, + `keyboard and RPC normalized event ledgers or semantic states differ at ${JSON.stringify(firstDifference(rpcParity, terminalParity))}`, ) + assert(flowsMatch(rpc.flows), 'RPC proof did not exercise the complete flow') + assert(flowsMatch(plain.flows), 'plain proof did not exercise the complete flow') + assert(flowsMatch(terminal80.flows), 'terminal proof did not exercise the complete flow') assert(terminal80.output.includes('\u001b[?1049h'), 'alternate screen was not entered') assert(terminal80.output.includes('\u001b[?1049l'), 'alternate screen was not restored') for (const terminal of [terminal40, terminal80, terminal120, terminal200]) { assert(terminal.output.includes('\u001b[?1049l'), 'reference terminal did not restore screen') } + assert( + accessibility.evidence.view?.appearance?.highContrast === true, + 'packed high-contrast flag did not reach semantic state', + ) + assert( + accessibility.evidence.view?.appearance?.reducedMotion === true, + 'packed reduced-motion flag did not reach semantic state', + ) + assertAccessibleTerminalOutput(accessibility.output) assert(!inline.output.includes('\u001b[?1049h'), 'inline mode entered alternate screen') assert(signal.output.includes('\u001b[?1049l'), 'SIGINT did not restore alternate screen') assert(signal.output.includes('\u001b[?2004l'), 'SIGINT did not disable bracketed paste') @@ -348,12 +397,28 @@ try { `--no-color emitted unexpected SGR sequences: ${[...new Set(unexpectedSgr)].join(', ')}`, ) assert(rpc.stderr === '', 'RPC wrote human logs to stderr during a successful run') + assert(plain.stderr === '', 'plain mode wrote stderr during a successful run') + assert(!plain.stdout.includes('\u001b'), 'plain mode emitted terminal controls') + assert( + plain.evidence.state.messages.some( + (message) => + message.role === 'assistant' && + message.status === 'complete' && + message.text === 'Fixture response through pi: plain package proof', + ), + 'plain --record-state did not persist final semantic state', + ) const tarballBytes = await readFile(tarball) const proof = { tarball: tarballName, sha256: createHash('sha256').update(tarballBytes).digest('hex'), version: version.stdout.trim(), + gitCommit: gitValue('rev-parse', 'HEAD'), + treeSha256: gitValue('rev-parse', 'HEAD^{tree}'), + sourceDigest: process.env.BRAID_PACKAGE_PROOF_SOURCE_DIGEST ?? (await sourceDigest(repository)), + isolatedBuild: true, + sourceCheckout: 'isolated-copy-of-worktree', rpcRecords: rpc.responses.length, referenceSizes: [ { columns: 40, rows: 12, events: terminal40.evidence.events.length }, @@ -365,7 +430,21 @@ try { sigintRestored: true, stateWriteSymlinkSafe: true, inlineStayedInMainScreen: true, - keyboardMatchesRpc: true, + keyboardMatchesRpc, + eventLedgerMatchesRpc: keyboardMatchesRpc, + flowParity: { + rpc: rpc.flows, + terminal: terminal80.flows, + plain: plain.flows, + allFlowsMatch: + flowsMatch(rpc.flows) && flowsMatch(terminal80.flows) && flowsMatch(plain.flows), + }, + plainRecordState: true, + accessibility: { + highContrast: accessibility.evidence.view?.appearance?.highContrast === true, + reducedMotion: accessibility.evidence.view?.appearance?.reducedMotion === true, + terminalModesRestored: accessibility.output.includes('\u001b[?1049l'), + }, } const proofJson = `${JSON.stringify(proof, null, 2)}\n` if (recordPath) { diff --git a/scripts/verify-release.mjs b/scripts/verify-release.mjs new file mode 100644 index 0000000..b9e2518 --- /dev/null +++ b/scripts/verify-release.mjs @@ -0,0 +1,3 @@ +import { verifyRelease } from './release/verification-flow.mjs' + +await verifyRelease() diff --git a/src/adapters/analysis/agent-eval-version.ts b/src/adapters/analysis/agent-eval-version.ts new file mode 100644 index 0000000..ae9e8ae --- /dev/null +++ b/src/adapters/analysis/agent-eval-version.ts @@ -0,0 +1 @@ +export const AGENT_EVAL_VERSION = '0.144.1' diff --git a/src/adapters/analysis/citations.ts b/src/adapters/analysis/citations.ts new file mode 100644 index 0000000..452ef7d --- /dev/null +++ b/src/adapters/analysis/citations.ts @@ -0,0 +1,236 @@ +import type { AnalystFinding, EvidenceRef } from '@tangle-network/agent-eval' +import type { FrozenAnalysisEvent, FrozenAnalysisEvidence } from '../../app/analysis-types.js' +import { AnalysisCitationError } from '../../app/analysis-types.js' +import { canonicalDigest } from '../../domain/canonical.js' +import type { AnalysisCitation, AnalysisFinding } from '../../domain/entities.js' +import type { EventId } from '../../domain/ids.js' +import { + createCitationId, + createEventId, + isEventId, + isMessagePartId, +} from '../../domain/ids-values.js' +import type { AnalysisTraceBundle, AnalysisTraceSpanReference } from './trace-store.js' + +function decodePart(value: string, label: string): string { + try { + return decodeURIComponent(value) + } catch { + throw new AnalysisCitationError(`Citation ${label} is not valid URI encoding`) + } +} + +function eventId(value: string): EventId { + if (isEventId(value)) return value + return createEventId(`event-${canonicalDigest(value).slice(0, 40)}`) +} + +function sourceEvent(evidence: FrozenAnalysisEvidence, id: string): FrozenAnalysisEvent { + const event = evidence.events.find((candidate) => String(candidate.id) === id) + if (event === undefined) + throw new AnalysisCitationError(`Citation references missing event ${id}`) + return event +} + +function eventText(event: FrozenAnalysisEvent): string { + if (event.event.kind === 'run.text.delta' || event.event.kind === 'run.reasoning.delta') { + return event.event.text + } + if (event.event.kind === 'run.warning' || event.event.kind === 'run.error') { + return event.event.message + } + try { + return JSON.stringify(event.event) + } catch { + return '' + } +} + +function messageText(evidence: FrozenAnalysisEvidence, messageId: string): string { + const message = evidence.messages.find((candidate) => String(candidate.id) === messageId) + if (message === undefined) + throw new AnalysisCitationError(`Citation references missing message ${messageId}`) + return message.text +} + +function partText(evidence: FrozenAnalysisEvidence, partId: string): string { + const part = evidence.messageParts.find((candidate) => String(candidate.id) === partId) + if (part === undefined) + throw new AnalysisCitationError(`Citation references missing message part ${partId}`) + switch (part.kind) { + case 'text': + case 'reasoning': + return part.text + case 'tool-result': + return part.summary + case 'warning': + case 'error': + return part.message + case 'artifact': + case 'unknown': + return part.summary + case 'tool-call': + return JSON.stringify(part.arguments) + case 'file': + return part.path ?? part.filename ?? '' + case 'image': + return part.altText ?? '' + } +} + +function assertExcerpt(event: FrozenAnalysisEvent, excerpt: string | undefined): void { + if (excerpt === undefined) return + if (excerpt.length === 0 || !eventText(event).includes(excerpt)) { + throw new AnalysisCitationError( + `Citation excerpt does not occur in frozen event ${String(event.id)}`, + ) + } +} + +function spanReference(bundle: AnalysisTraceBundle, spanId: string): AnalysisTraceSpanReference { + const reference = bundle.spans.find((candidate) => candidate.spanId === spanId) + if (reference === undefined) + throw new AnalysisCitationError(`Citation references missing span ${spanId}`) + return reference +} + +function traceMatches(evidence: FrozenAnalysisEvidence, traceId: string): boolean { + return ( + traceId === String(evidence.source.runId) || + traceId === String(evidence.source.trace?.id) || + traceId === `braid://run/${String(evidence.source.runId)}` + ) +} + +function citationForEvent( + evidence: FrozenAnalysisEvidence, + ref: EvidenceRef, + sourceId: string, + partId?: string, +): AnalysisCitation { + const source = sourceEvent(evidence, sourceId) + assertExcerpt(source, ref.excerpt) + const digest = canonicalDigest({ sourceId, partId, ref }) + const citationPartId = partId !== undefined && isMessagePartId(partId) ? partId : undefined + return { + id: createCitationId(`citation-${digest.slice(0, 40)}`), + eventId: eventId(sourceId), + ...(citationPartId === undefined ? {} : { partId: citationPartId }), + ...(ref.excerpt === undefined ? {} : { quote: ref.excerpt }), + } +} + +export function resolveEvidenceRef( + evidence: FrozenAnalysisEvidence, + bundle: AnalysisTraceBundle, + ref: EvidenceRef, +): AnalysisCitation { + if (ref.kind === 'span') { + const match = /^trace:\/\/([^/]+)\/span\/([^/?#]+)$/u.exec(ref.uri) + if (match === null) throw new AnalysisCitationError(`Citation span URI is invalid: ${ref.uri}`) + const traceId = decodePart(match[1] ?? '', 'trace id') + const spanId = decodePart(match[2] ?? '', 'span id') + if (!traceMatches(evidence, traceId)) { + throw new AnalysisCitationError( + `Citation references trace ${traceId} outside the frozen source`, + ) + } + const span = spanReference(bundle, spanId) + return citationForEvent(evidence, ref, span.eventId, span.partId) + } + + if (ref.kind === 'event') { + const match = /^(?:event:\/\/|braid:\/\/event\/)([^/?#]+)$/u.exec(ref.uri) + if (match === null) throw new AnalysisCitationError(`Citation event URI is invalid: ${ref.uri}`) + return citationForEvent(evidence, ref, decodePart(match[1] ?? '', 'event id')) + } + + if (ref.kind === 'artifact') { + const match = /^artifact:\/\/([^/?#]+)$/u.exec(ref.uri) + if (match === null) + throw new AnalysisCitationError(`Citation artifact URI is invalid: ${ref.uri}`) + const artifactId = decodePart(match[1] ?? '', 'artifact id') + const event = evidence.events.find( + (candidate) => + candidate.event.kind === 'run.artifact' && candidate.event.artifactId === artifactId, + ) + if (event === undefined) + throw new AnalysisCitationError(`Citation references missing artifact ${artifactId}`) + return citationForEvent(evidence, ref, String(event.id)) + } + + throw new AnalysisCitationError(`Citation kind '${ref.kind}' has no resolvable frozen source`) +} + +function severity(value: AnalystFinding['severity']): AnalysisFinding['severity'] | undefined { + return value === undefined ? undefined : value +} + +export function mapAnalystFinding( + evidence: FrozenAnalysisEvidence, + bundle: AnalysisTraceBundle, + finding: AnalystFinding, +): AnalysisFinding { + const refs = finding.evidence_refs ?? [] + const citations = refs.map((ref) => resolveEvidenceRef(evidence, bundle, ref)) + const findingSeverity = severity(finding.severity) + return { + id: finding.finding_id, + text: finding.claim, + ...(findingSeverity === undefined ? {} : { severity: findingSeverity }), + ...(finding.confidence === undefined ? {} : { confidence: finding.confidence }), + citations, + supported: refs.length > 0 && citations.length === refs.length, + } +} + +export function mapAnalystFindings( + evidence: FrozenAnalysisEvidence, + bundle: AnalysisTraceBundle, + findings: readonly AnalystFinding[], +): readonly AnalysisFinding[] { + return findings.map((finding) => mapAnalystFinding(evidence, bundle, finding)) +} + +/** Re-checks a persisted citation against the exact evidence that produced it. */ +export function validateAnalysisCitation( + evidence: FrozenAnalysisEvidence, + citation: AnalysisCitation, +): void { + let text: string | undefined + if (citation.eventId !== undefined) { + text = eventText(sourceEvent(evidence, String(citation.eventId))) + } + if (citation.messageId !== undefined) { + text = messageText(evidence, String(citation.messageId)) + } + if (citation.partId !== undefined) { + const part = evidence.messageParts.find((candidate) => candidate.id === citation.partId) + if (part === undefined) { + if (citation.eventId === undefined) { + throw new AnalysisCitationError( + `Citation references missing message part ${String(citation.partId)}`, + ) + } + } else { + text = partText(evidence, String(citation.partId)) + } + } + if (text === undefined) { + throw new AnalysisCitationError(`Citation ${String(citation.id)} has no frozen source target`) + } + if (citation.start !== undefined && citation.start > text.length) { + throw new AnalysisCitationError(`Citation ${String(citation.id)} starts outside its source`) + } + if (citation.end !== undefined && citation.end > text.length) { + throw new AnalysisCitationError(`Citation ${String(citation.id)} ends outside its source`) + } + if (citation.start !== undefined && citation.end !== undefined && citation.end < citation.start) { + throw new AnalysisCitationError(`Citation ${String(citation.id)} has an inverted range`) + } + if (citation.quote !== undefined && !text.includes(citation.quote)) { + throw new AnalysisCitationError( + `Citation ${String(citation.id)} quote is absent from frozen source`, + ) + } +} diff --git a/src/adapters/analysis/eval-analyst.ts b/src/adapters/analysis/eval-analyst.ts new file mode 100644 index 0000000..2c7294f --- /dev/null +++ b/src/adapters/analysis/eval-analyst.ts @@ -0,0 +1,179 @@ +import { + type AnalystRegistry, + type AnalystRunInputs, + buildDefaultAnalystRegistry, + type ExactAnalystRunEvent, + type ExactAnalystRunResult, + type ExactRegistryRunOpts, +} from '@tangle-network/agent-eval' +import type { AnalysisRecipe } from '../../app/analysis-types.js' +import { AnalysisCapabilityError, type AnalysisCapabilityIssue } from '../../app/analysis-types.js' +import { AGENT_EVAL_VERSION } from './agent-eval-version.js' +import type { AnalysisTraceBundle } from './trace-store.js' + +export { AGENT_EVAL_VERSION } + +export interface AnalystDescriptor { + readonly id: string + readonly description: string + readonly version: string + readonly cost: unknown +} + +export interface AnalystRegistryPort { + readonly list: () => ReadonlyArray + readonly runExactStream: AnalystRegistry['runExactStream'] +} + +export interface EvalAnalystRequest { + readonly runId: string + readonly sourceDigest: string + readonly trace: AnalysisTraceBundle + readonly question?: string + readonly recipe?: AnalysisRecipe + readonly analystIds?: readonly string[] + readonly budgetUsd?: number + readonly totalTimeoutMs?: number + readonly signal?: AbortSignal +} + +export interface EvalAnalystStreamEvent { + readonly event: ExactAnalystRunEvent + readonly result?: ExactAnalystRunResult +} + +const RECIPE_ALIASES: Readonly> = { + failure: ['failure-mode', 'failure'], + cost: ['cost', 'efficiency-behavioral'], + tools: ['tools', 'tool-use', 'efficiency-behavioral'], + improvement: ['improvement'], +} + +function unavailable( + recipe: string, + available: readonly AnalystDescriptor[], +): AnalysisCapabilityError { + const issue: AnalysisCapabilityIssue = { + capability: `analysis.recipe.${recipe}`, + packageName: '@tangle-network/agent-eval', + packageVersion: AGENT_EVAL_VERSION, + reason: `No registered analyst implements the '${recipe}' recipe. Available analyst ids: ${available.map((entry) => entry.id).join(', ') || '(none)'}`, + reproduction: `node --input-type=module -e "import { buildDefaultAnalystRegistry } from '@tangle-network/agent-eval'; console.log(buildDefaultAnalystRegistry().list())"`, + } + return new AnalysisCapabilityError(issue) +} + +function assertBudget(value: number | undefined): void { + if (value !== undefined && (!Number.isFinite(value) || value < 0)) { + throw new RangeError('Analysis budget must be a finite non-negative number') + } +} + +function assertTimeout(value: number | undefined): void { + if (value !== undefined && (!Number.isFinite(value) || value <= 0)) { + throw new RangeError('Analysis timeout must be a positive finite number') + } +} + +export class AgentEvalAnalystAdapter { + readonly #registry: AnalystRegistryPort + readonly #unavailableIssue: AnalysisCapabilityIssue | undefined + + constructor(registry?: AnalystRegistryPort, unavailableIssue?: AnalysisCapabilityIssue) { + this.#registry = registry ?? buildDefaultAnalystRegistry() + this.#unavailableIssue = unavailableIssue + } + + list(): ReadonlyArray { + if (this.#unavailableIssue !== undefined) return [] + return this.#registry.list() + } + + resolveAnalystIds(request: Pick): readonly string[] { + if (this.#unavailableIssue !== undefined) { + throw new AnalysisCapabilityError(this.#unavailableIssue) + } + const available = this.#registry.list() + const availableIds = new Set(available.map((entry) => entry.id)) + if (request.analystIds !== undefined) { + const unknown = request.analystIds.filter((id) => !availableIds.has(id)) + if (unknown.length > 0) throw unavailable(unknown[0] ?? 'custom', available) + if (request.analystIds.length === 0) throw unavailable('custom', available) + return [...request.analystIds] + } + + const recipe = request.recipe ?? 'ask' + if (recipe === 'ask') { + if (available.length === 0) throw unavailable(recipe, available) + return available.map((entry) => entry.id) + } + + const candidates = RECIPE_ALIASES[recipe] ?? [recipe] + const selected = candidates.find((candidate) => availableIds.has(candidate)) + if (selected === undefined) throw unavailable(recipe, available) + return [selected] + } + + async *stream(request: EvalAnalystRequest): AsyncGenerator { + assertBudget(request.budgetUsd) + assertTimeout(request.totalTimeoutMs) + const analystIds = this.resolveAnalystIds(request) + const inputs: AnalystRunInputs = { + traceStore: request.trace.store, + custom: Object.fromEntries( + analystIds.map((id) => [ + id, + { + question: request.question, + recipe: request.recipe ?? 'ask', + sourceDigest: request.sourceDigest, + }, + ]), + ), + } + const options: ExactRegistryRunOpts = { + analystIds, + budget: + request.budgetUsd === undefined ? null : { kind: 'equal', totalUsd: request.budgetUsd }, + totalTimeoutMs: request.totalTimeoutMs ?? null, + signal: request.signal ?? null, + costLedger: null, + costLedgerIdentity: null, + costPhase: null, + tags: Object.freeze({ + braid_source_digest: request.sourceDigest, + braid_recipe: request.recipe ?? 'ask', + }), + priorFindings: null, + chainFindings: false, + missingInputMode: 'abort', + applyRegistryHooks: false, + useRegistryChat: false, + } + + for await (const event of this.#registry.runExactStream(request.runId, inputs, options)) { + if (event.type === 'run-completed') { + yield { event, result: event.result } + } else { + yield { event } + } + } + } +} + +/** Keeps production analysis honest when its real prerequisites are unavailable. */ +export function createUnavailableAgentEvalAnalystAdapter( + issue: AnalysisCapabilityIssue, +): AgentEvalAnalystAdapter { + const registry: AnalystRegistryPort = { + list: () => [], + runExactStream: async function* ( + _runId: string, + _inputs: AnalystRunInputs, + _options: ExactRegistryRunOpts, + ) { + // resolveAnalystIds throws before the registry can be reached. + }, + } + return new AgentEvalAnalystAdapter(registry, issue) +} diff --git a/src/adapters/analysis/python-runner.ts b/src/adapters/analysis/python-runner.ts new file mode 100644 index 0000000..5549245 --- /dev/null +++ b/src/adapters/analysis/python-runner.ts @@ -0,0 +1,212 @@ +import { spawn } from 'node:child_process' + +const PACKAGE_PROBE = [ + 'import importlib.util', + "required = ('agent_eval_rpc.dspy_rlm_bridge', 'dspy')", + 'missing = []', + 'for name in required:', + ' try:', + ' if importlib.util.find_spec(name) is None:', + ' missing.append(name)', + ' except (ImportError, ModuleNotFoundError):', + ' missing.append(name)', + 'if missing:', + ' raise SystemExit(13)', +].join('\n') + +const PACKAGE_MISSING_EXIT_CODE = 13 +const DEFAULT_PROBE_TIMEOUT_MS = 2_000 + +export interface PythonRunnerSpec { + readonly command: string + readonly args?: readonly string[] +} + +export interface PythonRunnerIdentity extends PythonRunnerSpec { + readonly source: 'explicit' | 'detected' +} + +export type PythonCommandProbeStatus = 'ok' | 'not-found' | 'failed' | 'timed-out' + +export interface PythonCommandProbeResult { + readonly status: PythonCommandProbeStatus + readonly exitCode?: number +} + +export type PythonCommandProbe = ( + command: string, + args: readonly string[], + timeoutMs: number, +) => Promise + +export interface ResolvePythonRunnerOptions { + /** An explicit executable is preferred and is passed as an argv entry point. */ + readonly runner?: PythonRunnerSpec + /** Detected candidates are executable names, never shell command strings. */ + readonly candidates?: readonly string[] + readonly probe?: PythonCommandProbe + readonly timeoutMs?: number +} + +export type PythonRunnerResolution = + | { + readonly status: 'ready' + readonly runner: PythonRunnerIdentity + } + | { + readonly status: 'missing-python' + readonly message: string + } + | { + readonly status: 'missing-python-package' + readonly runner: PythonRunnerIdentity + readonly message: string + } + | { + readonly status: 'python-probe-failed' + readonly runner: PythonRunnerIdentity + readonly message: string + } + +function defaultCandidates(): readonly string[] { + const configured = process.env.BRAID_PYTHON?.trim() + return [...new Set([configured, 'python3', 'python'].filter((value): value is string => !!value))] +} + +function normalizedRunner( + runner: PythonRunnerSpec, + source: PythonRunnerIdentity['source'], +): PythonRunnerIdentity | undefined { + const command = runner.command.trim() + if (command.length === 0) return undefined + return { + command, + source, + ...(runner.args === undefined ? {} : { args: [...runner.args] }), + } +} + +async function defaultProbe( + command: string, + args: readonly string[], + timeoutMs: number, +): Promise { + return new Promise((resolve) => { + let settled = false + const child = spawn(command, [...args], { + shell: false, + stdio: 'ignore', + windowsHide: true, + }) + const timer = setTimeout(() => { + if (settled) return + settled = true + child.kill('SIGKILL') + resolve({ status: 'timed-out' }) + }, timeoutMs) + + const finish = (result: PythonCommandProbeResult): void => { + if (settled) return + settled = true + clearTimeout(timer) + resolve(result) + } + + child.once('error', (error: NodeJS.ErrnoException) => { + finish({ status: error.code === 'ENOENT' ? 'not-found' : 'failed' }) + }) + child.once('exit', (exitCode) => { + finish( + exitCode === 0 + ? { status: 'ok', exitCode: 0 } + : { status: 'failed', exitCode: exitCode ?? -1 }, + ) + }) + }) +} + +async function probeRunner( + runner: PythonRunnerIdentity, + probe: PythonCommandProbe, + timeoutMs: number, +): Promise { + const prefix = runner.args ?? [] + const version = await probe(runner.command, [...prefix, '--version'], timeoutMs) + if (version.status !== 'ok') { + if (version.status === 'not-found') { + return { + status: 'missing-python', + message: 'The configured Python executable was not found.', + } + } + return { + status: 'python-probe-failed', + runner, + message: 'The configured Python executable could not be started for readiness checks.', + } + } + + const packageCheck = await probe(runner.command, [...prefix, '-c', PACKAGE_PROBE], timeoutMs) + if (packageCheck.status === 'ok') return { status: 'ready', runner } + if (packageCheck.exitCode === PACKAGE_MISSING_EXIT_CODE) { + return { + status: 'missing-python-package', + runner, + message: + 'The selected Python executable is available, but agent-eval-rpc[dspy] is not installed.', + } + } + return { + status: 'python-probe-failed', + runner, + message: 'The selected Python executable could not import the trace-analysis packages.', + } +} + +/** Resolve and verify a Python executable without invoking a shell. */ +export async function resolvePythonRunner( + options: ResolvePythonRunnerOptions = {}, +): Promise { + const probe = options.probe ?? defaultProbe + const timeoutMs = options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS + if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) { + throw new RangeError('Python readiness probe timeout must be a positive safe integer') + } + + if (options.runner !== undefined) { + const runner = normalizedRunner(options.runner, 'explicit') + if (runner === undefined) { + return { + status: 'python-probe-failed', + runner: { command: '', source: 'explicit' }, + message: 'The explicit Python executable is empty.', + } + } + return probeRunner(runner, probe, timeoutMs) + } + + let packageMissing: PythonRunnerResolution | undefined + let probeFailed: PythonRunnerResolution | undefined + for (const candidate of options.candidates ?? defaultCandidates()) { + const runner = normalizedRunner({ command: candidate }, 'detected') + if (runner === undefined) continue + const result = await probeRunner(runner, probe, timeoutMs) + if (result.status === 'ready') return result + if (result.status === 'missing-python-package') { + packageMissing ??= result + continue + } + if (result.status === 'python-probe-failed') { + probeFailed ??= result + } + } + + if (packageMissing !== undefined) return packageMissing + if (probeFailed !== undefined) return probeFailed + return { + status: 'missing-python', + message: 'No usable Python executable was found. Set BRAID_PYTHON or install python3.', + } +} + +export const TRACE_ANALYSIS_PYTHON_PACKAGE_PROBE = PACKAGE_PROBE diff --git a/src/adapters/analysis/runtime-model-owner.ts b/src/adapters/analysis/runtime-model-owner.ts new file mode 100644 index 0000000..44a673c --- /dev/null +++ b/src/adapters/analysis/runtime-model-owner.ts @@ -0,0 +1,451 @@ +import { + type ChatResponse, + type CustomTokenPricing, + costForTokenPricing, + resolveModelPricing, +} from '@tangle-network/agent-eval' +import type { + ExternalOptimizerModelCall, + ExternalOptimizerModelCallRequest, + ExternalOptimizerModelExecutionObservation, +} from '@tangle-network/agent-eval/campaign' +import { type AgentProfile, snapshotAgentProfile } from '@tangle-network/agent-interface' +import { + type BackendRetryPolicy, + createOpenAICompatibleBackend, + type OpenAIChatResponseFormat, + type RuntimeStreamEvent, + runAgentTaskStream, +} from '@tangle-network/agent-runtime' +import { canonicalDigest } from '../../domain/canonical.js' +import type { ConnectionRecord } from '../../domain/entities.js' +import { redactProfile, redactProviderError } from '../../domain/redaction.js' +import { AGENT_RUNTIME_VERSION } from '../runtime/agent-runtime-version.js' + +const LOCAL_BRIDGE_API_KEY_MARKER = 'braid-local-cli-bridge' +const MAX_RETAINED_EXECUTIONS = 256 + +type ModelExecutionRecorder = (observation: ExternalOptimizerModelExecutionObservation) => void + +export interface RuntimeTraceModelOwnerOptions { + readonly profile: Readonly + readonly connection: Readonly> + readonly baseUrl: string + readonly credential?: string + readonly model: string + readonly pricing?: CustomTokenPricing + readonly fetch?: typeof fetch + readonly retry?: BackendRetryPolicy + readonly recordExecution?: ModelExecutionRecorder +} + +export interface RuntimeTraceModelOwner { + readonly call: ExternalOptimizerModelCall + readonly callRef: string + readonly recordExecution: ModelExecutionRecorder + readonly pricing?: CustomTokenPricing + readonly executions: () => readonly ExternalOptimizerModelExecutionObservation[] +} + +interface RuntimeCallSummary { + readonly events: readonly RuntimeStreamEvent[] + readonly status: 'completed' | 'failed' | 'aborted' | 'blocked' + readonly reason: string + readonly content: string + readonly durationMs: number + readonly inputTokens: number + readonly outputTokens: number + readonly usageCaptured: boolean + readonly reportedModel?: string + readonly finishReason?: string + readonly errorKind?: string + readonly errorStatus?: number +} + +function catalogPricing(model: string): CustomTokenPricing | undefined { + const pricing = resolveModelPricing(model) + if (pricing === null) return undefined + return { + inputUsdPerMillion: pricing.input * 1_000, + outputUsdPerMillion: pricing.output * 1_000, + } +} + +function responseFormat( + request: ExternalOptimizerModelCallRequest['request'], +): OpenAIChatResponseFormat | undefined { + if (request.jsonSchema !== undefined) { + return { + type: 'json_schema', + json_schema: { + name: request.jsonSchema.name, + schema: request.jsonSchema.schema, + strict: true, + }, + } + } + return request.jsonMode === true ? { type: 'json_object' } : undefined +} + +function textMessages( + request: ExternalOptimizerModelCallRequest['request'], +): Array<{ role: string; content: string }> { + return request.messages.map((message, index) => { + if (typeof message.content !== 'string') { + throw new TypeError( + `Runtime trace analysis supports text messages only; message ${index} is multimodal`, + ) + } + return { role: message.role, content: message.content } + }) +} + +function assertRequestSupported(request: ExternalOptimizerModelCallRequest['request']): void { + if (request.thinking !== undefined) { + throw new TypeError( + 'agent-runtime 0.128.0 does not expose a provider-neutral thinking field for chat requests; configure reasoning on the AgentProfile instead', + ) + } +} + +function eventFacts(events: readonly RuntimeStreamEvent[]): Readonly> { + const counts = new Map() + for (const event of events) counts.set(event.type, (counts.get(event.type) ?? 0) + 1) + return Object.freeze( + Object.fromEntries([...counts].sort(([left], [right]) => left.localeCompare(right))), + ) +} + +function summarizeRuntimeEvents( + events: readonly RuntimeStreamEvent[], + startedAt: number, +): RuntimeCallSummary { + const final = events.at(-1) + if (final?.type !== 'final') { + throw new Error( + `agent-runtime model call ended without a final event (last: ${final?.type ?? 'none'})`, + ) + } + const calls = events.filter( + (event): event is Extract => + event.type === 'llm_call', + ) + const usageCaptured = + calls.length > 0 && + calls.every( + (event) => + Number.isSafeInteger(event.tokensIn) && + (event.tokensIn ?? -1) >= 0 && + Number.isSafeInteger(event.tokensOut) && + (event.tokensOut ?? -1) >= 0, + ) + const lastCall = calls.at(-1) + return { + events, + status: final.status, + reason: final.reason, + content: + final.text ?? + events + .filter( + (event): event is Extract => + event.type === 'text_delta', + ) + .map((event) => event.text) + .join(''), + durationMs: Math.max(0, Date.now() - startedAt), + inputTokens: usageCaptured + ? calls.reduce((total, event) => total + (event.tokensIn ?? 0), 0) + : 0, + outputTokens: usageCaptured + ? calls.reduce((total, event) => total + (event.tokensOut ?? 0), 0) + : 0, + usageCaptured, + ...(lastCall?.model === undefined ? {} : { reportedModel: lastCall.model }), + ...(lastCall?.finishReason === undefined ? {} : { finishReason: lastCall.finishReason }), + ...(final.error?.kind === undefined ? {} : { errorKind: final.error.kind }), + ...(final.error?.status === undefined ? {} : { errorStatus: final.error.status }), + } +} + +function receiptFor( + model: string, + pricing: CustomTokenPricing | undefined, + usage: Pick, + knownNoExecution = false, +): import('@tangle-network/agent-eval').CostReceiptInput { + if (!usage.usageCaptured && !knownNoExecution) { + return { + model, + inputTokens: 0, + outputTokens: 0, + usageUnknown: true, + costUnknown: true, + } + } + const receipt = { + model, + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, + } + if (pricing !== undefined) return { ...receipt, customTokenPricing: pricing } + if (usage.inputTokens === 0 && usage.outputTokens === 0) { + return { ...receipt, estimatedCostUsd: 0 } + } + return { ...receipt, costUnknown: true } +} + +function responseFor( + request: ExternalOptimizerModelCallRequest, + summary: RuntimeCallSummary, + pricing: CustomTokenPricing | undefined, +): ChatResponse { + const receipt = receiptFor(request.request.model, pricing, summary) + const costUsd = + summary.usageCaptured && pricing !== undefined ? costForTokenPricing(pricing, receipt) : null + const raw = { + source: '@tangle-network/agent-runtime', + version: AGENT_RUNTIME_VERSION, + eventDigest: String( + canonicalDigest({ + types: eventFacts(summary.events), + status: summary.status, + output: String(canonicalDigest(summary.content)), + }), + ), + } + return { + content: summary.content, + usage: { + promptTokens: summary.inputTokens, + completionTokens: summary.outputTokens, + totalTokens: summary.inputTokens + summary.outputTokens, + captured: summary.usageCaptured, + }, + costUsd, + model: request.request.model, + durationMs: summary.durationMs, + ...(summary.finishReason === undefined ? {} : { finishReason: summary.finishReason }), + contentEmpty: summary.content.trim().length === 0, + raw, + } +} + +function executionEvidence(input: { + readonly request: ExternalOptimizerModelCallRequest + readonly callRef: string + readonly profileDigest: string + readonly connection: RuntimeTraceModelOwnerOptions['connection'] + readonly retry: BackendRetryPolicy + readonly summary?: RuntimeCallSummary + readonly dispatched: boolean + readonly partialEvents?: readonly RuntimeStreamEvent[] + readonly startedAt: number + readonly failure?: string +}): Readonly> { + const summary = input.summary + return Object.freeze({ + schema: 'braid.runtime-model-execution.v1', + runtime: { + package: '@tangle-network/agent-runtime', + version: AGENT_RUNTIME_VERSION, + operation: 'runAgentTaskStream', + }, + callId: input.request.callId, + callRef: input.callRef, + profileDigest: input.profileDigest, + connection: { + id: String(input.connection.id), + kind: input.connection.kind, + updatedAt: input.connection.updatedAt, + }, + transport: { + maxAttempts: input.retry.maxAttempts ?? 1, + ...(input.retry.requestTimeoutMs === undefined + ? {} + : { requestTimeoutMs: input.retry.requestTimeoutMs }), + }, + endpointFormat: input.request.endpointFormat ?? 'chat-completions', + model: input.request.request.model, + requestDigest: String(canonicalDigest(input.request.request)), + durationMs: summary?.durationMs ?? Math.max(0, Date.now() - input.startedAt), + dispatched: input.dispatched, + ...(summary === undefined + ? {} + : { + terminal: { + status: summary.status, + reason: summaryFailure(summary), + ...(summary.errorKind === undefined ? {} : { errorKind: summary.errorKind }), + ...(summary.errorStatus === undefined ? {} : { errorStatus: summary.errorStatus }), + }, + usage: { + captured: summary.usageCaptured, + inputTokens: summary.inputTokens, + outputTokens: summary.outputTokens, + ...(summary.reportedModel === undefined + ? {} + : { reportedModel: summary.reportedModel }), + }, + events: eventFacts(summary.events), + outputDigest: String(canonicalDigest(summary.content)), + }), + ...(summary !== undefined || input.partialEvents === undefined + ? {} + : { events: eventFacts(input.partialEvents) }), + ...(input.failure === undefined ? {} : { failure: input.failure }), + }) +} + +function summaryFailure(summary: RuntimeCallSummary): string { + if (summary.status === 'completed') return 'Agent Runtime model execution completed' + if (summary.errorStatus !== undefined) { + return `Agent Runtime model execution failed with HTTP ${summary.errorStatus}` + } + if (summary.status === 'aborted') return 'Agent Runtime model execution was cancelled' + return `Agent Runtime model execution failed (${summary.errorKind ?? summary.status})` +} + +function publicError(value: unknown, dispatched: boolean): string { + if (!dispatched && value instanceof Error) return value.message + return redactProviderError(value instanceof Error ? value.message : String(value)) +} + +/** + * Owns one trace-analysis model route without giving Agent Eval a credential. + * Agent Runtime performs the request and supplies the normalized evidence. + */ +export function createRuntimeTraceModelOwner( + options: RuntimeTraceModelOwnerOptions, +): RuntimeTraceModelOwner { + // Analyst calls settle one receipt per invocation. Keep retries explicit so a + // single recorded invocation cannot conceal multiple paid provider requests. + const retry = Object.freeze({ maxAttempts: 1, ...options.retry }) + const profileDigest = String( + canonicalDigest(redactProfile(snapshotAgentProfile(options.profile))), + ) + const callRef = `braid-agent-runtime:${String( + canonicalDigest({ + version: 1, + runtimeVersion: AGENT_RUNTIME_VERSION, + profileDigest, + connection: { + id: String(options.connection.id), + kind: options.connection.kind, + updatedAt: options.connection.updatedAt, + }, + endpoint: options.baseUrl, + model: options.model, + retry, + }), + )}` + const pricing = options.pricing ?? catalogPricing(options.model) + const retained: ExternalOptimizerModelExecutionObservation[] = [] + const recordExecution: ModelExecutionRecorder = (observation) => { + const snapshot = structuredClone(observation) + retained.push(snapshot) + if (retained.length > MAX_RETAINED_EXECUTIONS) retained.shift() + options.recordExecution?.(structuredClone(snapshot)) + } + + const call: ExternalOptimizerModelCall = async (request) => { + const startedAt = Date.now() + const events: RuntimeStreamEvent[] = [] + let dispatched = false + let summary: RuntimeCallSummary | undefined + try { + if (request.request.model !== options.model) { + throw new TypeError( + `Runtime model route expected '${options.model}', received '${request.request.model}'`, + ) + } + assertRequestSupported(request.request) + const messages = textMessages(request.request) + const format = responseFormat(request.request) + const backend = createOpenAICompatibleBackend({ + apiKey: options.credential ?? LOCAL_BRIDGE_API_KEY_MARKER, + baseUrl: options.baseUrl, + model: options.model, + kind: `braid-analysis:${options.connection.kind}`, + ...(request.request.maxTokens === undefined + ? {} + : { maxTokens: request.request.maxTokens }), + ...(request.request.temperature === undefined + ? {} + : { temperature: request.request.temperature }), + ...(format === undefined ? {} : { responseFormat: format }), + ...(options.fetch === undefined ? {} : { fetchImpl: options.fetch }), + retry, + }) + dispatched = true + for await (const event of runAgentTaskStream({ + task: { + id: `braid-analysis-${request.callId}`, + intent: 'Execute one bounded trace-analysis model call', + }, + backend, + input: { messages }, + signal: request.signal, + })) { + events.push(event) + } + summary = summarizeRuntimeEvents(events, startedAt) + const evidence = executionEvidence({ + request, + callRef, + profileDigest, + connection: options.connection, + retry, + summary, + dispatched, + startedAt, + }) + if (summary.status !== 'completed') { + return { + succeeded: false, + error: summaryFailure(summary), + receipt: receiptFor(options.model, pricing, summary), + execution: evidence, + } + } + return { + succeeded: true, + response: responseFor(request, summary, pricing), + receipt: receiptFor(options.model, pricing, summary), + execution: evidence, + } + } catch (error) { + const message = publicError(error, dispatched) + return { + succeeded: false, + error: message, + receipt: receiptFor( + options.model, + pricing, + summary ?? { inputTokens: 0, outputTokens: 0, usageCaptured: false }, + !dispatched, + ), + execution: executionEvidence({ + request, + callRef, + profileDigest, + connection: options.connection, + retry, + ...(summary === undefined ? {} : { summary }), + dispatched, + ...(events.length === 0 ? {} : { partialEvents: events }), + startedAt, + failure: message, + }), + } + } + } + + return Object.freeze({ + call, + callRef, + recordExecution, + ...(pricing === undefined ? {} : { pricing }), + executions: () => retained.map((observation) => structuredClone(observation)), + }) +} diff --git a/src/adapters/analysis/trace-analysis-adapter.ts b/src/adapters/analysis/trace-analysis-adapter.ts new file mode 100644 index 0000000..f2ceba2 --- /dev/null +++ b/src/adapters/analysis/trace-analysis-adapter.ts @@ -0,0 +1,351 @@ +import { + type AnalystRegistry, + buildDefaultAnalystRegistry, + type CustomTokenPricing, + createDspyRlmTraceEngine, + type TraceAnalysisEngine, +} from '@tangle-network/agent-eval' +import type { ExternalOptimizerModelExecutionObservation } from '@tangle-network/agent-eval/campaign' +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 { + bridgeRunnerSupportsModel, + materializeBridgeModelRoute, +} from '../connections/cli-bridge-model-route.js' +import { + connectionEndpoint, + normalizeCliBridgeRuntimeBaseUrl, + type ProductionConnectionOptions, + readConnectionCredential, +} from '../connections/production-connections.js' +import { AGENT_EVAL_VERSION } from './agent-eval-version.js' +import { + AgentEvalAnalystAdapter, + createUnavailableAgentEvalAnalystAdapter, +} from './eval-analyst.js' +import { + type PythonCommandProbe, + type PythonRunnerIdentity, + type PythonRunnerSpec, + resolvePythonRunner, +} from './python-runner.js' +import { createRuntimeTraceModelOwner } from './runtime-model-owner.js' + +export type TraceAnalysisDiagnosticKind = + | 'missing-python' + | 'missing-python-package' + | 'missing-model' + | 'missing-credential' + | 'unsupported-connection' + | 'python-probe-failed' + | 'connection-configuration-failed' + | 'engine-configuration-failed' + +export interface TraceAnalysisDiagnostic { + readonly kind: TraceAnalysisDiagnosticKind + readonly code: string + readonly message: string + readonly connectionId?: string +} + +export interface TraceAnalysisAdapterOptions extends ProductionConnectionOptions { + readonly connection: ConnectionRecord + readonly profile: Readonly + readonly model?: string + readonly runner?: HarnessType + readonly python?: PythonRunnerSpec + readonly pythonCandidates?: readonly string[] + readonly pythonProbe?: PythonCommandProbe + readonly pythonProbeTimeoutMs?: number + readonly pricing?: CustomTokenPricing + readonly maxCostUsd?: number + readonly maxOutputTokens?: number + readonly maxReasoningTokens?: number + readonly timeoutMs?: number + readonly recordExecution?: (observation: ExternalOptimizerModelExecutionObservation) => void +} + +export interface TraceAnalysisAdapterUnavailable { + readonly status: + | 'missing-python' + | 'missing-python-package' + | 'missing-model' + | 'missing-credential' + | 'unsupported-connection' + | 'unavailable' + readonly diagnostics: readonly [TraceAnalysisDiagnostic] +} + +export interface TraceAnalysisAdapterReady { + readonly status: 'engine-configured' + readonly diagnostics: readonly [] + readonly engine: TraceAnalysisEngine + readonly registry: AnalystRegistry + readonly connection: Readonly<{ + readonly id: string + readonly kind: ConnectionKind + readonly endpoint: string + }> + readonly model: string + readonly runner?: HarnessType + readonly python: PythonRunnerIdentity + readonly modelExecutions: () => readonly ExternalOptimizerModelExecutionObservation[] + /** The credential value itself is deliberately absent from this result. */ + readonly credentialState: 'provided' | 'not-required' +} + +export type TraceAnalysisConfiguration = TraceAnalysisAdapterReady | TraceAnalysisAdapterUnavailable + +function traceAnalysisIssue(reason: string): AnalysisCapabilityIssue { + return { + capability: 'trace-analysis', + packageName: '@tangle-network/agent-eval', + packageVersion: AGENT_EVAL_VERSION, + reason, + reproduction: + 'Check the selected AgentProfile model, connection credential, and Python agent-eval package before retrying /ask.', + } +} + +/** Converts the production configuration result into the analyst consumed by /ask. */ +export function createTraceAnalysisAnalyst( + configuration: TraceAnalysisConfiguration, +): AgentEvalAnalystAdapter { + if (configuration.status === 'engine-configured') { + return new AgentEvalAnalystAdapter(configuration.registry) + } + const diagnostic = configuration.diagnostics[0] + return createUnavailableAgentEvalAnalystAdapter( + traceAnalysisIssue( + diagnostic?.message ?? 'The selected trace-analysis configuration is unavailable.', + ), + ) +} + +/** Used while first-run setup is open; it never substitutes a deterministic analyst. */ +export function createUnavailableTraceAnalysisAnalyst( + reason = 'Complete production setup before using trace analysis.', +): AgentEvalAnalystAdapter { + return createUnavailableAgentEvalAnalystAdapter(traceAnalysisIssue(reason)) +} + +function unavailable( + status: TraceAnalysisAdapterUnavailable['status'], + diagnostic: TraceAnalysisDiagnostic, +): TraceAnalysisAdapterUnavailable { + return { status, diagnostics: [diagnostic] } +} + +function connectionId(record: ConnectionRecord): string { + return String(record.id) +} + +function connectionDiagnostic( + error: unknown, + record: ConnectionRecord, +): TraceAnalysisAdapterUnavailable { + const id = connectionId(record) + if (error instanceof ConnectionError) { + if (error.code === 'CONNECTION_UNSUPPORTED') { + return unavailable('unsupported-connection', { + kind: 'unsupported-connection', + code: error.code, + message: error.message, + connectionId: id, + }) + } + if (error.code.startsWith('CONNECTION_CREDENTIAL_')) { + return unavailable('missing-credential', { + kind: 'missing-credential', + code: error.code, + message: error.message, + connectionId: id, + }) + } + return unavailable('unavailable', { + kind: 'connection-configuration-failed', + code: error.code, + message: error.message, + connectionId: id, + }) + } + return unavailable('unavailable', { + kind: 'connection-configuration-failed', + code: 'TRACE_ANALYSIS_CONNECTION_CONFIGURATION_FAILED', + message: 'The selected connection could not be prepared for trace analysis.', + connectionId: id, + }) +} + +function modelFor( + profile: Readonly, + override: string | undefined, +): string | undefined { + const model = (override ?? profile.model?.default)?.trim() + return model === undefined || model.length === 0 ? undefined : model +} + +function engineConfigurationMessage(error: unknown): string { + const detail = error instanceof Error ? error.message : '' + if (/pricing/u.test(detail)) { + return 'The selected model has no recognized token pricing; provide pricing for this profile model.' + } + return 'The published agent-eval trace engine rejected the selected configuration.' +} + +/** + * Configure the model-backed trace analysts for one exact profile/connection pair. + * + * The returned registry is intentionally absent whenever the real engine cannot be + * configured. Callers must surface the diagnostic instead of substituting a + * deterministic-only registry. + */ +export async function createTraceAnalysisAdapter( + options: TraceAnalysisAdapterOptions, +): Promise { + const { connection, profile } = options + const id = connectionId(connection) + + if (connection.kind === 'tangle-sandbox') { + return unavailable('unsupported-connection', { + kind: 'unsupported-connection', + code: 'TRACE_ANALYSIS_SANDBOX_UNSUPPORTED', + message: + 'Tangle sandbox connections execute profiles as workspaces, but trace analysis requires a direct model endpoint. Select a cli-bridge or tangle-inference connection for /ask.', + connectionId: id, + }) + } + + const authoredModel = modelFor(profile, options.model) + if (authoredModel === undefined) { + return unavailable('missing-model', { + kind: 'missing-model', + code: 'TRACE_ANALYSIS_MODEL_REQUIRED', + message: 'The selected AgentProfile has no non-empty model.default for trace analysis.', + connectionId: id, + }) + } + const runner = options.runner ?? profile.harness + if ( + connection.kind === 'cli-bridge' && + runner !== undefined && + !bridgeRunnerSupportsModel(runner, authoredModel) + ) { + return unavailable('unavailable', { + kind: 'connection-configuration-failed', + code: 'CONNECTION_MODEL_HARNESS_MISMATCH', + message: `Profile runner=${runner} does not support model=${authoredModel}; choose a matching runner or model before using /ask.`, + connectionId: id, + }) + } + const model = + connection.kind === 'cli-bridge' && runner !== undefined + ? materializeBridgeModelRoute(runner, authoredModel, profile.model?.provider) + : authoredModel + + let endpoint: string + try { + endpoint = connectionEndpoint(connection, options) + } catch (error) { + return connectionDiagnostic(error, connection) + } + + const python = await resolvePythonRunner({ + ...(options.python === undefined ? {} : { runner: options.python }), + ...(options.pythonCandidates === undefined ? {} : { candidates: options.pythonCandidates }), + ...(options.pythonProbe === undefined ? {} : { probe: options.pythonProbe }), + ...(options.pythonProbeTimeoutMs === undefined + ? {} + : { timeoutMs: options.pythonProbeTimeoutMs }), + }) + if (python.status !== 'ready') { + const diagnostic: TraceAnalysisDiagnostic = { + kind: python.status, + code: + python.status === 'missing-python' + ? 'TRACE_ANALYSIS_PYTHON_REQUIRED' + : python.status === 'missing-python-package' + ? 'TRACE_ANALYSIS_PYTHON_PACKAGE_REQUIRED' + : 'TRACE_ANALYSIS_PYTHON_PROBE_FAILED', + message: python.message, + connectionId: id, + } + return unavailable( + python.status === 'missing-python' || python.status === 'missing-python-package' + ? python.status + : 'unavailable', + diagnostic, + ) + } + + let credential: string | undefined + try { + credential = await readConnectionCredential(connection, options, endpoint) + } catch (error) { + return connectionDiagnostic(error, connection) + } + + const baseUrl = + connection.kind === 'cli-bridge' + ? normalizeCliBridgeRuntimeBaseUrl(endpoint, connection.id) + : endpoint + try { + const owner = createRuntimeTraceModelOwner({ + profile, + connection, + baseUrl, + ...(credential === undefined ? {} : { credential }), + model, + ...(options.pricing === undefined ? {} : { pricing: { ...options.pricing } }), + ...(options.fetch === undefined ? {} : { fetch: options.fetch }), + ...(options.recordExecution === undefined + ? {} + : { recordExecution: options.recordExecution }), + }) + const engine = createDspyRlmTraceEngine({ + call: owner.call, + callRef: owner.callRef, + recordExecution: owner.recordExecution, + model, + ...(owner.pricing === undefined ? {} : { pricing: { ...owner.pricing } }), + ...(options.maxCostUsd === undefined ? {} : { maxCostUsd: options.maxCostUsd }), + ...(options.maxOutputTokens === undefined + ? {} + : { maxOutputTokens: options.maxOutputTokens }), + ...(options.maxReasoningTokens === undefined + ? {} + : { maxReasoningTokens: options.maxReasoningTokens }), + ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }), + runner: { + command: python.runner.command, + ...(python.runner.args === undefined ? {} : { args: [...python.runner.args] }), + }, + }) + const registry = buildDefaultAnalystRegistry({ engine }) + return { + status: 'engine-configured', + diagnostics: [], + engine, + registry, + connection: { id, kind: connection.kind, endpoint }, + model, + python: { + command: python.runner.command, + source: python.runner.source, + ...(python.runner.args === undefined ? {} : { args: [...python.runner.args] }), + }, + credentialState: credential === undefined ? 'not-required' : 'provided', + ...(runner === undefined ? {} : { runner }), + modelExecutions: owner.executions, + } + } catch (error) { + return unavailable('unavailable', { + kind: 'engine-configuration-failed', + code: 'TRACE_ANALYSIS_ENGINE_CONFIGURATION_FAILED', + message: engineConfigurationMessage(error), + connectionId: id, + }) + } +} diff --git a/src/adapters/analysis/trace-store.ts b/src/adapters/analysis/trace-store.ts new file mode 100644 index 0000000..affd0fa --- /dev/null +++ b/src/adapters/analysis/trace-store.ts @@ -0,0 +1,254 @@ +import { + createBoundedTraceAnalysisStore, + otlpTextToTraceAnalysisStore, + type ToolSpan, + type TraceAnalysisStore, + toolSpansToTraceAnalysisStore, +} from '@tangle-network/agent-eval' +import { verifyFrozenAnalysisSource } from '../../app/analysis-source.js' +import type { FrozenAnalysisEvent, FrozenAnalysisEvidence } from '../../app/analysis-types.js' +import { AnalysisSourceError } from '../../app/analysis-types.js' +import { canonicalDigest } from '../../domain/canonical.js' +import type { BraidEvent } from '../../domain/events.js' +import { redactSensitiveText, redactStructuredValue } from '../../domain/redaction.js' + +export interface AnalysisTraceSpanReference { + readonly spanId: string + readonly eventId: string + readonly partId?: string + readonly toolName?: string +} + +export interface AnalysisTraceBundle { + readonly traceId: string + readonly sourceDigest: string + readonly store: TraceAnalysisStore + readonly spans: readonly AnalysisTraceSpanReference[] +} + +interface ToolSpanEntry { + readonly key: string + readonly event: FrozenAnalysisEvent + readonly span: ToolSpan +} + +function eventTime(event: FrozenAnalysisEvent): number { + const parsed = Date.parse(event.occurredAt) + return Number.isFinite(parsed) ? parsed : event.sequence +} + +function safeValue(value: unknown): unknown { + try { + return redactStructuredValue(value) + } catch { + return '[redacted value]' + } +} + +function safeText(value: unknown): string { + if (typeof value === 'string') return redactSensitiveText(value) + try { + return redactSensitiveText(JSON.stringify(safeValue(value)) ?? String(value)) + } catch { + return '[redacted value]' + } +} + +function toolKey( + event: Extract, +): string { + return event.callId ?? `${event.partId}:${event.toolName}` +} + +function spanIdFor(event: FrozenAnalysisEvent): string { + return `span-${canonicalDigest({ eventId: event.id, sequence: event.sequence }).slice(0, 40)}` +} + +function createToolSpan( + evidence: FrozenAnalysisEvidence, + event: FrozenAnalysisEvent, + source: Extract, +): ToolSpan { + const startedAt = eventTime(event) + return { + spanId: spanIdFor(event), + runId: String(evidence.source.runId), + kind: 'tool', + name: source.toolName, + toolName: source.toolName, + args: safeValue(source.input ?? null), + ...(source.input === undefined ? {} : { argsCaptured: true }), + startedAt, + attributes: { + 'braid.event_id': String(event.id), + 'braid.part_id': source.partId, + 'braid.source_digest': evidence.source.digest, + }, + } +} + +function sourceEventError(event: BraidEvent): string | undefined { + if (event.kind === 'run.error') return event.message + if (event.kind === 'run.tool.result' && event.error !== undefined) return event.error + return undefined +} + +function buildToolSpans(evidence: FrozenAnalysisEvidence): { + readonly spans: readonly ToolSpan[] + readonly references: readonly AnalysisTraceSpanReference[] +} { + const entries = new Map() + const references = new Map() + + for (const event of evidence.events) { + if (event.event.kind === 'run.tool.call') { + const span = createToolSpan(evidence, event, event.event) + const key = toolKey(event.event) + entries.set(key, { key, event, span }) + references.set(span.spanId, { + spanId: span.spanId, + eventId: String(event.id), + partId: event.event.partId, + toolName: event.event.toolName, + }) + continue + } + if (event.event.kind !== 'run.tool.result') continue + + const key = toolKey(event.event) + const existing = entries.get(key) + if (existing === undefined) { + const startedAt = eventTime(event) + const span: ToolSpan = { + spanId: spanIdFor(event), + runId: String(evidence.source.runId), + kind: 'tool', + name: event.event.toolName, + toolName: event.event.toolName, + args: null, + startedAt, + endedAt: startedAt, + ...(event.event.error === undefined + ? { status: 'ok' as const } + : { status: 'error' as const, error: safeText(event.event.error) }), + ...(event.event.result === undefined ? {} : { result: safeValue(event.event.result) }), + attributes: { + 'braid.event_id': String(event.id), + 'braid.part_id': event.event.partId, + 'braid.source_digest': evidence.source.digest, + }, + } + entries.set(key, { key, event, span }) + references.set(span.spanId, { + spanId: span.spanId, + eventId: String(event.id), + partId: event.event.partId, + toolName: event.event.toolName, + }) + continue + } + + const endedAt = eventTime(event) + const latencyMs = Math.max(0, endedAt - existing.span.startedAt) + const span: ToolSpan = { + ...existing.span, + endedAt, + latencyMs, + ...(event.event.error === undefined + ? { status: 'ok' as const } + : { status: 'error' as const, error: safeText(event.event.error) }), + ...(event.event.result === undefined ? {} : { result: safeValue(event.event.result) }), + attributes: { + ...existing.span.attributes, + 'braid.result_event_id': String(event.id), + }, + } + entries.set(key, { key, event: existing.event, span }) + } + + return { + spans: [...entries.values()].map((entry) => entry.span), + references: [...references.values()], + } +} + +function eventAttributes( + event: FrozenAnalysisEvent, + sourceDigest: string, +): Record { + const attributes: Record = { + 'braid.event_id': String(event.id), + 'braid.kind': event.event.kind, + 'braid.source_digest': sourceDigest, + 'openinference.span.kind': event.event.kind === 'run.text.delta' ? 'LLM' : 'AGENT', + } + if (event.event.kind === 'run.text.delta' || event.event.kind === 'run.reasoning.delta') { + attributes['output.value'] = safeText(event.event.text) + } + const error = sourceEventError(event.event) + if (error !== undefined) attributes['error.message'] = safeText(error) + return attributes +} + +function genericTraceText(evidence: FrozenAnalysisEvidence): { + readonly text: string + readonly references: readonly AnalysisTraceSpanReference[] +} { + const lines = evidence.events.map((event) => { + const time = new Date(eventTime(event)).toISOString() + const spanId = spanIdFor(event) + const error = sourceEventError(event.event) + return JSON.stringify({ + trace_id: String(evidence.source.runId), + span_id: spanId, + parent_span_id: null, + name: `braid.${event.event.kind}`, + kind: 'SPAN_KIND_INTERNAL', + start_time: time, + end_time: time, + status: + error === undefined + ? { code: 'STATUS_CODE_UNSET' } + : { code: 'STATUS_CODE_ERROR', message: safeText(error) }, + resource: { attributes: { 'braid.source_digest': evidence.source.digest } }, + attributes: eventAttributes(event, evidence.source.digest), + }) + }) + return { + text: `${lines.join('\n')}\n`, + references: evidence.events.map((event) => ({ + spanId: spanIdFor(event), + eventId: String(event.id), + ...(event.event.kind === 'run.part.updated' ? { partId: event.event.part.id } : {}), + })), + } +} + +export function buildAnalysisTraceStore(evidence: FrozenAnalysisEvidence): AnalysisTraceBundle { + verifyFrozenAnalysisSource(evidence) + if (evidence.source.runId === undefined) { + throw new AnalysisSourceError('Trace analysis requires a frozen run source') + } + if (evidence.events.length === 0) { + throw new AnalysisSourceError(`Run ${evidence.source.runId} has no frozen events to analyze`) + } + + const toolEvidence = buildToolSpans(evidence) + let store: TraceAnalysisStore + let references: readonly AnalysisTraceSpanReference[] + if (toolEvidence.spans.length > 0) { + store = toolSpansToTraceAnalysisStore(toolEvidence.spans) + references = toolEvidence.references + } else { + const generic = genericTraceText(evidence) + store = otlpTextToTraceAnalysisStore(generic.text) + references = generic.references + } + + return { + traceId: String(evidence.source.runId), + sourceDigest: String(evidence.source.digest), + store: createBoundedTraceAnalysisStore(store), + spans: references, + } +} diff --git a/src/adapters/connections/cli-bridge-health.ts b/src/adapters/connections/cli-bridge-health.ts new file mode 100644 index 0000000..20f90a3 --- /dev/null +++ b/src/adapters/connections/cli-bridge-health.ts @@ -0,0 +1,159 @@ +import type { ConnectionHealth, IsoDateTime } from '../../domain/entities.js' + +export interface CliBridgeHealthParse { + readonly health: ConnectionHealth + readonly diagnostic?: string +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +function statusHealth(status: number, checkedAt: IsoDateTime): ConnectionHealth { + if (status === 401 || status === 403) { + return { + status: 'unauthorized', + checkedAt, + message: 'The bridge rejected optional authentication', + } + } + if (status === 429) { + return { + status: 'rate-limited', + checkedAt, + message: 'The bridge rate-limited the health check', + } + } + if (status >= 400 && status < 500) { + return { + status: 'incompatible', + checkedAt, + message: 'The bridge health endpoint is incompatible', + } + } + return { status: 'unreachable', checkedAt, message: 'The bridge health endpoint was unavailable' } +} + +function statusDiagnostic(endpoint: string, status: number): string { + return `CLI Bridge health at ${endpoint}/health returned HTTP ${status}; backend readiness is unavailable` +} + +function contractCondition(serviceStatus: 'ok' | 'degraded', readyNames: string): string { + if (readyNames.length > 0) { + return `${serviceStatus} service with ready backends: ${readyNames}` + } + return `${serviceStatus} service with no ready backend` +} + +/** Parses the CLI Bridge /health contract without treating HTTP success as readiness. */ +export function parseCliBridgeHealth( + endpoint: string, + status: number, + body: string, + checkedAt: IsoDateTime, +): CliBridgeHealthParse { + if (status < 200 || status >= 300) { + let parsed: unknown + try { + parsed = JSON.parse(body) + } catch { + parsed = undefined + } + const backends = isRecord(parsed) && Array.isArray(parsed.backends) ? parsed.backends : [] + const serviceStatus = + isRecord(parsed) && (parsed.status === 'ok' || parsed.status === 'degraded') + ? parsed.status + : undefined + const readyNames = backends + .filter((backend) => isRecord(backend) && backend.state === 'ready') + .map((backend) => (isRecord(backend) ? String(backend.name).trim() : '')) + .filter((name) => name.length > 0) + .join(', ') + return { + health: statusHealth(status, checkedAt), + diagnostic: + serviceStatus === undefined + ? statusDiagnostic(endpoint, status) + : `CLI Bridge health at ${endpoint}/health returned HTTP ${status} and reported ${contractCondition(serviceStatus, readyNames)}; selected-model validation remains required`, + } + } + + let parsed: unknown + try { + parsed = JSON.parse(body) + } catch { + return { + health: { + status: 'incompatible', + checkedAt, + message: 'The bridge health response was not JSON', + }, + diagnostic: `CLI Bridge health at ${endpoint}/health returned HTTP ${status} with malformed JSON; backend readiness is unknown`, + } + } + if (!isRecord(parsed) || (parsed.status !== 'ok' && parsed.status !== 'degraded')) { + return { + health: { + status: 'incompatible', + checkedAt, + message: 'The bridge health response is malformed', + }, + diagnostic: `CLI Bridge health at ${endpoint}/health returned HTTP ${status} without a valid service status; backend readiness is unknown`, + } + } + if (!Array.isArray(parsed.backends) || parsed.backends.length === 0) { + return { + health: { + status: 'incompatible', + checkedAt, + message: 'The bridge health response has no backend states', + }, + diagnostic: `CLI Bridge health at ${endpoint}/health returned HTTP ${status} without backend readiness states`, + } + } + const validBackendStates = parsed.backends.every( + (backend) => + isRecord(backend) && + typeof backend.name === 'string' && + backend.name.trim().length > 0 && + (backend.state === 'ready' || backend.state === 'unavailable' || backend.state === 'error'), + ) + if (!validBackendStates) { + return { + health: { + status: 'incompatible', + checkedAt, + message: 'The bridge backend states are malformed', + }, + diagnostic: `CLI Bridge health at ${endpoint}/health returned HTTP ${status} with malformed backend readiness states`, + } + } + + const readyBackends = parsed.backends.filter( + (backend): backend is Record => isRecord(backend) && backend.state === 'ready', + ) + const readyNames = readyBackends.map((backend) => String(backend.name).trim()).join(', ') + if (parsed.status === 'ok' && readyBackends.length > 0) { + return { health: { status: 'healthy', checkedAt } } + } + + if (parsed.status === 'degraded' && readyBackends.length > 0) { + return { + health: { + status: 'healthy', + checkedAt, + message: `The bridge reports degraded service; ready backends: ${readyNames}`, + }, + diagnostic: `CLI Bridge health at ${endpoint}/health reported degraded service with ready backends: ${readyNames}; selected-model validation remains required`, + } + } + + const condition = + parsed.status === 'degraded' + ? 'reported degraded service with no ready backend' + : 'reported ok service with no ready backend' + return { + health: { status: 'unreachable', checkedAt, message: `The bridge ${condition}` }, + diagnostic: `CLI Bridge health at ${endpoint}/health returned HTTP ${status} and ${condition}; selected-model validation cannot proceed until a backend is ready`, + } +} diff --git a/src/adapters/connections/cli-bridge-model-route.ts b/src/adapters/connections/cli-bridge-model-route.ts new file mode 100644 index 0000000..91c76b7 --- /dev/null +++ b/src/adapters/connections/cli-bridge-model-route.ts @@ -0,0 +1,42 @@ +import { + type HarnessType, + harnessSupportsModel, + harnessTypeSchema, +} from '@tangle-network/agent-interface' + +/** Returns the runner encoded by a CLI Bridge `/` route. */ +export function bridgeRouteRunner(model: string): HarnessType | undefined { + const separator = model.indexOf('/') + if (separator <= 0) return undefined + const parsed = harnessTypeSchema.safeParse(model.slice(0, separator)) + return parsed.success ? parsed.data : undefined +} + +/** Validates a model against either an explicit Bridge route or a canonical model id. */ +export function bridgeRunnerSupportsModel(runner: HarnessType, model: string): boolean { + const routedRunner = bridgeRouteRunner(model) + return routedRunner === undefined ? harnessSupportsModel(runner, model) : routedRunner === runner +} + +/** Materialize the same `//` route used by the CLI Bridge provider. */ +export function materializeBridgeModelRoute( + runner: HarnessType, + model: string, + provider?: string, +): string { + if (model === runner || model.startsWith(`${runner}/`)) return model + if (model.includes('/')) return `${runner}/${model}` + return provider === undefined ? `${runner}/${model}` : `${runner}/${provider}/${model}` +} + +/** Accepts a catalog entry only when its backend agrees with its encoded route. */ +export function bridgeCatalogRunner( + model: string, + backend: string | undefined, +): HarnessType | undefined { + const routedRunner = bridgeRouteRunner(model) + if (backend === undefined) return routedRunner + const parsed = harnessTypeSchema.safeParse(backend) + if (!parsed.success || routedRunner !== parsed.data) return undefined + return parsed.data +} diff --git a/src/adapters/connections/production-connection-credentials.ts b/src/adapters/connections/production-connection-credentials.ts new file mode 100644 index 0000000..12da66d --- /dev/null +++ b/src/adapters/connections/production-connection-credentials.ts @@ -0,0 +1,81 @@ +import { ConnectionError } from '../../app/connection-errors.js' +import type { ConnectionRecord } from '../../domain/entities.js' +import type { CredentialPort, CredentialRef } from '../../ports/credentials.js' +import { credentialRef } from '../../ports/credentials.js' +import { isLoopbackEndpoint } from './production-connection-endpoints.js' +import type { ProductionConnectionOptions } from './production-connection-types.js' + +export async function readConnectionCredential( + record: ConnectionRecord, + options: ProductionConnectionOptions, + endpoint: string, +): Promise { + const required = + record.kind !== 'cli-bridge' || + !isLoopbackEndpoint(endpoint) || + record.credentialRef !== undefined + if (record.credentialRef === undefined) { + if (required) { + throw new ConnectionError( + 'CONNECTION_CREDENTIAL_REQUIRED', + 'This connection requires a credential in the operating-system credential store', + { connectionId: record.id }, + ) + } + return undefined + } + if (!options.credentials || !options.credentialRefResolver) { + throw new ConnectionError( + 'CONNECTION_CREDENTIAL_REF_UNMAPPED', + 'The durable credential reference has no secure credential-store mapping; configure the connection again', + { connectionId: record.id }, + ) + } + let portRef: CredentialRef + try { + portRef = credentialRef(await options.credentialRefResolver(record.credentialRef)) + } catch { + throw new ConnectionError( + 'CONNECTION_CREDENTIAL_REF_UNMAPPED', + 'The durable credential reference could not be mapped', + { connectionId: record.id }, + ) + } + let handle: Awaited> + try { + handle = await options.credentials.resolve(portRef) + } catch { + throw new ConnectionError( + 'CONNECTION_CREDENTIAL_UNAVAILABLE', + 'The referenced credential is unavailable from the operating-system credential store; add it there and retry', + { connectionId: record.id }, + ) + } + let bytes: Uint8Array | undefined + try { + bytes = handle.read() + const value = new TextDecoder('utf-8', { fatal: true }).decode(bytes).trim() + if (value.length === 0 || value.includes('\u0000')) { + throw new ConnectionError( + 'CONNECTION_CREDENTIAL_INVALID', + 'The referenced credential in the operating-system credential store is empty or malformed', + { connectionId: record.id }, + ) + } + return value + } catch (error) { + if (error instanceof ConnectionError) throw error + throw new ConnectionError( + 'CONNECTION_CREDENTIAL_INVALID', + 'The referenced credential in the operating-system credential store is not valid UTF-8 text', + { connectionId: record.id }, + ) + } finally { + bytes?.fill(0) + try { + handle.dispose() + } catch { + // A secret handle must not prevent the caller from failing closed. + } + } +} diff --git a/src/adapters/connections/production-connection-endpoints.ts b/src/adapters/connections/production-connection-endpoints.ts new file mode 100644 index 0000000..4308e9a --- /dev/null +++ b/src/adapters/connections/production-connection-endpoints.ts @@ -0,0 +1,133 @@ +import { ConnectionError } from '../../app/connection-errors.js' +import type { ConnectionRecord } from '../../domain/entities.js' +import type { ConnectionId } from '../../domain/ids.js' +import { + DEFAULT_TANGLE_SANDBOX_ENDPOINT, + type ProductionConnectionOptions, +} from './production-connection-types.js' + +// Keep this narrow helper independent of agent-runtime so composition can cold-load +// connection configuration without materializing the runtime kernel. +const DEFAULT_ROUTER_BASE_URL = 'https://router.tangle.tools' + +export function connectionEndpoint( + record: ConnectionRecord, + options: Pick< + ProductionConnectionOptions, + 'defaultInferenceEndpoint' | 'defaultSandboxEndpoint' + > = {}, +): string { + const recordEndpoint = record.endpoint + const optionEndpoint = record.providerOptions.endpoint + if ( + recordEndpoint !== undefined && + optionEndpoint !== undefined && + recordEndpoint !== optionEndpoint + ) { + throw new ConnectionError( + 'CONNECTION_ENDPOINT_CONFLICT', + 'The connection has conflicting endpoint references', + { connectionId: record.id }, + ) + } + const endpoint = + recordEndpoint ?? + optionEndpoint ?? + (record.kind === 'tangle-inference' + ? (options.defaultInferenceEndpoint ?? DEFAULT_ROUTER_BASE_URL) + : record.kind === 'tangle-sandbox' + ? (options.defaultSandboxEndpoint ?? DEFAULT_TANGLE_SANDBOX_ENDPOINT) + : undefined) + if (!endpoint) { + throw new ConnectionError( + 'CONNECTION_ENDPOINT_REQUIRED', + 'This connection requires an HTTP endpoint', + { connectionId: record.id }, + ) + } + validateHttpEndpoint(endpoint, record.id) + return endpoint.replace(/\/$/u, '') +} + +export function normalizeCliBridgeRuntimeBaseUrl( + endpoint: string, + connectionId?: ConnectionId, +): string { + const url = parseHttpEndpoint(endpoint, connectionId) + const path = url.pathname.replace(/\/+$/u, '') + if (path === '' || path === '/') url.pathname = '/v1' + else if (!path.endsWith('/v1')) url.pathname = `${path}/v1` + url.search = '' + url.hash = '' + return url.toString().replace(/\/$/u, '') +} + +/** The provider owns `/v1`; pass only the server root so it cannot duplicate the API prefix. */ +export function normalizeCliBridgeProviderBaseUrl( + endpoint: string, + connectionId?: ConnectionId, +): string { + return stripCliBridgeVersion(normalizeCliBridgeRuntimeBaseUrl(endpoint, connectionId)) +} + +export function appendHealthPath(endpoint: string): string { + const url = new URL(endpoint) + url.pathname = `${url.pathname.replace(/\/+$/u, '')}/health` + return url.toString() +} + +export function stripCliBridgeVersion(endpoint: string): string { + const url = new URL(endpoint) + const path = url.pathname.replace(/\/+$/u, '') + if (path.endsWith('/v1')) url.pathname = path.slice(0, -3) || '/' + return url.toString().replace(/\/$/u, '') +} + +export function isLoopbackEndpoint(endpoint: string): boolean { + try { + const hostname = new URL(endpoint).hostname.toLowerCase() + return ( + hostname === 'localhost' || + hostname === '127.0.0.1' || + hostname === '[::1]' || + hostname === '::1' + ) + } catch { + return false + } +} + +function validateHttpEndpoint(endpoint: string, connectionId: ConnectionId): void { + parseHttpEndpoint(endpoint, connectionId) +} + +function parseHttpEndpoint(endpoint: string, connectionId?: ConnectionId): URL { + let url: URL + try { + url = new URL(endpoint) + } catch { + throw new ConnectionError( + 'CONNECTION_ENDPOINT_INVALID', + 'The connection endpoint is not an HTTP URL', + { + ...(connectionId ? { connectionId } : {}), + }, + ) + } + if ( + !['http:', 'https:'].includes(url.protocol) || + url.username || + url.password || + url.search || + url.hash + ) { + throw new ConnectionError( + 'CONNECTION_ENDPOINT_INVALID', + 'The connection endpoint is not a safe HTTP URL', + { + ...(connectionId ? { connectionId } : {}), + }, + ) + } + return url +} diff --git a/src/adapters/connections/production-connection-health.ts b/src/adapters/connections/production-connection-health.ts new file mode 100644 index 0000000..a06d258 --- /dev/null +++ b/src/adapters/connections/production-connection-health.ts @@ -0,0 +1,339 @@ +import { ConnectionError } from '../../app/connection-errors.js' +import type { + ConnectionHealth, + ConnectionModelVerification, + ConnectionRecord, + IsoDateTime, +} from '../../domain/entities.js' +import { parseCliBridgeHealth } from './cli-bridge-health.js' +import { readConnectionCredential } from './production-connection-credentials.js' +import { + appendHealthPath, + connectionEndpoint, + normalizeCliBridgeRuntimeBaseUrl, + stripCliBridgeVersion, +} from './production-connection-endpoints.js' +import { createTangleSandboxClient } from './production-connection-providers.js' +import type { + ConnectionHealthOptions, + ConnectionModelVerificationOptions, + ProductionConnectionOptions, +} from './production-connection-types.js' + +export async function healthForConnection( + record: ConnectionRecord, + options: ProductionConnectionOptions, + healthOptions: ConnectionHealthOptions, +): Promise { + const checkedAt = (healthOptions.now ?? options.now ?? nowIso)() + try { + const endpoint = connectionEndpoint(record, options) + if (record.kind === 'tangle-sandbox') { + const client = (await createTangleSandboxClient( + record, + options, + healthOptions.signal, + )) as SandboxHealthClient + return await probeSandboxHealth(client, checkedAt, healthOptions.signal) + } + const credential = await readConnectionCredential(record, options, endpoint) + const healthUrl = + record.kind === 'cli-bridge' + ? appendHealthPath(stripCliBridgeVersion(endpoint)) + : appendHealthPath(endpoint) + return await probeHttpHealth( + healthUrl, + credential, + checkedAt, + options.fetch, + healthOptions.signal, + record.kind === 'cli-bridge' ? stripCliBridgeVersion(endpoint) : undefined, + ) + } catch (error) { + return healthFromError(error, checkedAt) + } +} + +export async function verifyModelForConnection( + record: ConnectionRecord, + options: ProductionConnectionOptions, + model: string, + verificationOptions: ConnectionModelVerificationOptions, +): Promise { + const checkedAt = (verificationOptions.now ?? options.now ?? nowIso)() + const normalizedModel = model.trim() + if (normalizedModel.length === 0) { + return { + model: '', + status: 'unverified', + checkedAt, + message: 'No model.default is selected for this connection', + } + } + if (record.kind === 'tangle-sandbox') { + return { + model: normalizedModel, + status: 'unverified', + checkedAt, + message: 'Sandbox model verification requires an admitted environment', + } + } + try { + const endpoint = connectionEndpoint(record, options) + const credential = await readConnectionCredential(record, options, endpoint) + const request = options.fetch ?? globalThis.fetch + if (typeof request !== 'function') { + return { + model: normalizedModel, + status: 'unreachable', + checkedAt, + message: 'Model verification is unavailable in this runtime', + } + } + const chatUrl = + record.kind === 'cli-bridge' + ? `${normalizeCliBridgeRuntimeBaseUrl(endpoint)}/chat/completions` + : `${endpoint.replace(/\/+$/u, '')}/chat/completions` + const response = await request(chatUrl, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...(credential ? { Authorization: `Bearer ${credential}` } : {}), + }, + body: JSON.stringify({ + model: normalizedModel, + messages: [{ role: 'user', content: 'Braid connection verification. Reply with OK.' }], + stream: false, + max_tokens: 1, + }), + ...(verificationOptions.signal ? { signal: verificationOptions.signal } : {}), + }) + const body = await response.text().catch(() => '') + return modelVerificationFromResponse(normalizedModel, response.status, body, checkedAt) + } catch (error) { + return modelVerificationFromError(normalizedModel, checkedAt, error) + } +} + +interface SandboxHealthClient { + fetch?: (path: string, options?: RequestInit) => Promise + health?: () => Promise +} + +async function probeSandboxHealth( + client: SandboxHealthClient, + checkedAt: IsoDateTime, + signal?: AbortSignal, +): Promise { + if (typeof client.fetch === 'function') { + try { + const response = await client.fetch('/health', { + method: 'GET', + ...(signal ? { signal } : {}), + }) + return healthFromStatus(response.status, checkedAt) + } catch { + return { + status: 'unreachable', + checkedAt, + message: 'Sandbox health endpoint was unreachable', + } + } + } + if (typeof client.health === 'function') { + try { + return (await client.health()) + ? { status: 'healthy', checkedAt } + : { status: 'unreachable', checkedAt, message: 'Sandbox health endpoint returned not-ok' } + } catch { + return { + status: 'unreachable', + checkedAt, + message: 'Sandbox health endpoint was unreachable', + } + } + } + return { + status: 'incompatible', + checkedAt, + message: 'The published sandbox client exposes no health operation', + } +} + +async function probeHttpHealth( + url: string, + credential: string | undefined, + checkedAt: IsoDateTime, + fetcher: typeof fetch | undefined, + signal?: AbortSignal, + cliBridgeEndpoint?: string, +): Promise { + const request = fetcher ?? globalThis.fetch + if (typeof request !== 'function') { + return { status: 'unreachable', checkedAt, message: 'HTTP health checks are unavailable' } + } + try { + const response = await request(url, { + method: 'GET', + headers: { + Accept: 'application/json', + ...(credential ? { Authorization: `Bearer ${credential}` } : {}), + }, + ...(signal ? { signal } : {}), + }) + if (cliBridgeEndpoint !== undefined) { + const body = await response.text().catch(() => '') + return parseCliBridgeHealth(cliBridgeEndpoint, response.status, body, checkedAt).health + } + return healthFromStatus(response.status, checkedAt) + } catch { + return { status: 'unreachable', checkedAt, message: 'Health endpoint was unreachable' } + } +} + +function healthFromStatus(status: number, checkedAt: IsoDateTime): ConnectionHealth { + if (status >= 200 && status < 300) return { status: 'healthy', checkedAt } + if (status === 401 || status === 403) { + return { status: 'unauthorized', checkedAt, message: 'The connection rejected its credential' } + } + if (status === 429) { + return { + status: 'rate-limited', + checkedAt, + message: 'The connection rate-limited the health check', + } + } + if (status === 404 || status === 405 || (status >= 400 && status < 500)) { + return { + status: 'incompatible', + checkedAt, + message: 'The connection health endpoint is incompatible', + } + } + return { status: 'unreachable', checkedAt, message: 'The connection returned a server failure' } +} + +function healthFromError(error: unknown, checkedAt: IsoDateTime): ConnectionHealth { + if (error instanceof ConnectionError) { + if ( + error.code === 'CONNECTION_CREDENTIAL_REQUIRED' || + error.code === 'CONNECTION_CREDENTIAL_REF_UNMAPPED' || + error.code === 'CONNECTION_CREDENTIAL_UNAVAILABLE' || + error.code === 'CONNECTION_CREDENTIAL_INVALID' + ) { + return { + status: 'unauthorized', + checkedAt, + message: 'A usable connection credential is unavailable', + } + } + if ( + error.code === 'CONNECTION_ENDPOINT_REQUIRED' || + error.code === 'CONNECTION_ENDPOINT_INVALID' || + error.code === 'CONNECTION_ENDPOINT_CONFLICT' + ) { + return { status: 'incompatible', checkedAt, message: 'The connection endpoint is invalid' } + } + } + return { status: 'unreachable', checkedAt, message: 'The connection health check failed' } +} + +function modelVerificationFromResponse( + model: string, + statusCode: number, + body: string, + checkedAt: IsoDateTime, +): ConnectionModelVerification { + if (statusCode >= 200 && statusCode < 300) { + return { model, status: 'verified', checkedAt, httpStatus: statusCode } + } + if (statusCode === 501 && /not_configured/iu.test(body)) { + return { + model, + status: 'not-configured', + checkedAt, + code: 'not_configured', + httpStatus: statusCode, + message: `The bridge advertises ${model} but its backend is not configured; sign in to that backend and retry model verification`, + } + } + if (statusCode === 401 || statusCode === 403) { + return { + model, + status: 'unauthorized', + checkedAt, + httpStatus: statusCode, + message: `The connection rejected model ${model}; configure its credential and retry`, + } + } + if (statusCode === 429) { + return { + model, + status: 'rate-limited', + checkedAt, + httpStatus: statusCode, + message: `The connection rate-limited model ${model}; retry later`, + } + } + if (statusCode === 404 || statusCode === 405 || (statusCode >= 400 && statusCode < 500)) { + return { + model, + status: 'incompatible', + checkedAt, + httpStatus: statusCode, + message: `The connection does not expose a compatible model operation for ${model}`, + } + } + return { + model, + status: 'unreachable', + checkedAt, + httpStatus: statusCode, + message: `The connection returned a server failure while verifying model ${model}`, + } +} + +function modelVerificationFromError( + model: string, + checkedAt: IsoDateTime, + error: unknown, +): ConnectionModelVerification { + if (error instanceof ConnectionError) { + if ( + error.code === 'CONNECTION_CREDENTIAL_REQUIRED' || + error.code === 'CONNECTION_CREDENTIAL_REF_UNMAPPED' || + error.code === 'CONNECTION_CREDENTIAL_UNAVAILABLE' || + error.code === 'CONNECTION_CREDENTIAL_INVALID' + ) { + return { + model, + status: 'unauthorized', + checkedAt, + message: 'A usable connection credential is unavailable for model verification', + } + } + if ( + error.code === 'CONNECTION_ENDPOINT_REQUIRED' || + error.code === 'CONNECTION_ENDPOINT_INVALID' || + error.code === 'CONNECTION_ENDPOINT_CONFLICT' + ) { + return { + model, + status: 'incompatible', + checkedAt, + message: 'The connection endpoint is invalid for model verification', + } + } + } + return { + model, + status: 'unreachable', + checkedAt, + message: 'The model verification request could not reach the connection', + } +} + +function nowIso(): IsoDateTime { + return new Date().toISOString() +} diff --git a/src/adapters/connections/production-connection-providers.ts b/src/adapters/connections/production-connection-providers.ts new file mode 100644 index 0000000..b41f0cf --- /dev/null +++ b/src/adapters/connections/production-connection-providers.ts @@ -0,0 +1,137 @@ +import type { AgentEnvironmentCapabilities } from '@tangle-network/agent-interface/environment-provider' +import { createCliBridgeProvider } from '@tangle-network/agent-provider-cli-bridge' +import { + createTangleProvider, + defaultTangleSandboxCapabilities, + type SandboxClientLike, +} from '@tangle-network/agent-provider-tangle' +import { Sandbox } from '@tangle-network/sandbox' +import { ConnectionError } from '../../app/connection-errors.js' +import type { ConnectionRecord } from '../../domain/entities.js' +import { readConnectionCredential } from './production-connection-credentials.js' +import { connectionEndpoint } from './production-connection-endpoints.js' +import type { + ConnectionCapabilityAction, + ConnectionCapabilityReport, + ConnectionProviderMethods, + ConnectionRuntimeCapabilities, + ProductionConnectionOptions, +} from './production-connection-types.js' + +export async function createTangleSandboxClient( + record: ConnectionRecord, + options: ProductionConnectionOptions, + signal?: AbortSignal, +): Promise { + if (options.sandboxClient) return options.sandboxClient + const endpoint = connectionEndpoint(record, options) + const apiKey = await readConnectionCredential(record, options, endpoint) + if (!apiKey) { + throw new ConnectionError( + 'CONNECTION_CREDENTIAL_REQUIRED', + 'Tangle sandbox requires an API credential', + { connectionId: record.id }, + ) + } + if (options.sandboxClientFactory) { + return options.sandboxClientFactory({ record, endpoint, apiKey, ...(signal ? { signal } : {}) }) + } + // SandboxInstance exposes `name: string | undefined` in its declaration, + // while SandboxClientLike uses exact-optional fields; the published runtime + // objects are structurally compatible for the provider methods we consume. + return new Sandbox({ baseUrl: endpoint, apiKey }) as unknown as SandboxClientLike +} + +export async function capabilitiesForConnection( + record: ConnectionRecord, + options: ProductionConnectionOptions, +): Promise { + switch (record.kind) { + case 'cli-bridge': { + const endpoint = connectionEndpoint(record, options) + const provider = createCliBridgeProvider({ baseUrl: endpoint }) + const environment = await provider.capabilities() + return capabilityReport(record, 'chat', environment, { + create: typeof provider.create === 'function', + get: typeof provider.get === 'function', + list: typeof provider.list === 'function', + respondToInteraction: 'respondToInteraction' in provider, + }) + } + case 'tangle-inference': + connectionEndpoint(record, options) + return capabilityReport(record, 'chat', undefined, { + create: true, + get: false, + list: false, + respondToInteraction: false, + }) + case 'tangle-sandbox': { + const environment = options.sandboxClient + ? await createTangleProvider({ client: options.sandboxClient }).capabilities() + : defaultTangleSandboxCapabilities() + const client = options.sandboxClient + return capabilityReport(record, 'executor', environment, { + create: true, + get: client === undefined ? 'unknown' : typeof client.get === 'function', + list: client === undefined ? 'unknown' : typeof client.list === 'function', + respondToInteraction: environment.interactions === undefined ? false : 'unknown', + }) + } + } +} + +function capabilityReport( + record: ConnectionRecord, + backend: ConnectionRuntimeCapabilities['backend'], + environment: AgentEnvironmentCapabilities | undefined, + providerMethods: ConnectionProviderMethods, +): ConnectionCapabilityReport { + const streaming = environment?.streaming ?? { + live: true, + replay: false, + detach: false, + turnIdempotency: true, + } + const sessions = environment?.sessions ?? { + continue: false, + list: false, + messages: false, + } + const runtime: ConnectionRuntimeCapabilities = { + backend, + streaming, + sessions, + interactions: { + originate: environment?.interactions !== undefined, + respond: + environment?.interactions !== undefined && providerMethods.respondToInteraction === true, + }, + } + const actions: Record = { + stream: runtime.streaming.live && providerMethods.create, + replay: runtime.streaming.replay, + detach: runtime.streaming.detach, + 'continue-session': runtime.sessions.continue, + 'list-sessions': runtime.sessions.list, + 'session-messages': runtime.sessions.messages, + checkpoint: environment?.branching.checkpoint === true, + fork: environment?.branching.fork === true, + placement: environment?.placement === true, + usage: environment?.usage === true, + 'respond-interaction': runtime.interactions.respond, + } + return Object.freeze({ + connectionId: record.id, + kind: record.kind, + runtime: Object.freeze({ + ...runtime, + streaming: Object.freeze(runtime.streaming), + sessions: Object.freeze(runtime.sessions), + interactions: Object.freeze(runtime.interactions), + }), + ...(environment ? { environment } : {}), + providerMethods: Object.freeze(providerMethods), + actions: Object.freeze(actions), + }) +} diff --git a/src/adapters/connections/production-connection-types.ts b/src/adapters/connections/production-connection-types.ts new file mode 100644 index 0000000..403b8c9 --- /dev/null +++ b/src/adapters/connections/production-connection-types.ts @@ -0,0 +1,104 @@ +import type { AgentEnvironmentCapabilities } from '@tangle-network/agent-interface/environment-provider' +import type { SandboxClientLike } from '@tangle-network/agent-provider-tangle' +import type { + ConnectionHealth, + ConnectionKind, + ConnectionModelVerification, + ConnectionRecord, + IsoDateTime, +} from '../../domain/entities.js' +import type { ConnectionId, CredentialRefId } from '../../domain/ids.js' +import type { CredentialPort, CredentialRef } from '../../ports/credentials.js' + +export const DEFAULT_TANGLE_SANDBOX_ENDPOINT = 'https://sandbox.tangle.tools' + +export type ConnectionCapabilityAction = + | 'stream' + | 'replay' + | 'detach' + | 'continue-session' + | 'list-sessions' + | 'session-messages' + | 'checkpoint' + | 'fork' + | 'placement' + | 'usage' + | 'respond-interaction' + +export interface ConnectionRuntimeCapabilities { + readonly backend: 'chat' | 'executor' + readonly streaming: Readonly<{ + live: boolean + replay: boolean + detach: boolean + turnIdempotency: boolean + }> + readonly sessions: Readonly<{ + continue: boolean + list: boolean + messages: boolean + }> + readonly interactions: Readonly<{ + originate: boolean + respond: boolean + }> +} + +export interface ConnectionProviderMethods { + readonly create: boolean + readonly get: boolean | 'unknown' + readonly list: boolean | 'unknown' + readonly respondToInteraction: boolean | 'unknown' +} + +export interface ConnectionCapabilityReport { + readonly connectionId: ConnectionId + readonly kind: ConnectionKind + readonly runtime: ConnectionRuntimeCapabilities + readonly environment?: AgentEnvironmentCapabilities + readonly providerMethods: ConnectionProviderMethods + readonly actions: Readonly> +} + +export interface SandboxClientFactoryInput { + readonly record: ConnectionRecord + readonly endpoint: string + readonly apiKey: string + readonly signal?: AbortSignal +} + +export type SandboxClientFactory = ( + input: SandboxClientFactoryInput, +) => SandboxClientLike | Promise + +export interface ProductionConnectionOptions { + readonly credentials?: CredentialPort + /** Map Braid's durable credential id to the credential-port's opaque ref. */ + readonly credentialRefResolver?: (ref: CredentialRefId) => CredentialRef | Promise + readonly fetch?: typeof fetch + readonly defaultInferenceEndpoint?: string + readonly defaultSandboxEndpoint?: string + readonly sandboxClient?: SandboxClientLike + readonly sandboxClientFactory?: SandboxClientFactory + readonly now?: () => IsoDateTime +} + +export interface ConnectionHealthOptions { + readonly signal?: AbortSignal + readonly now?: () => IsoDateTime +} + +export interface ConnectionModelVerificationOptions { + readonly signal?: AbortSignal + readonly now?: () => IsoDateTime +} + +export interface ProductionConnectionAdapter { + readonly record: ConnectionRecord + capabilities(): Promise + health(options?: ConnectionHealthOptions): Promise + verifyModel?( + model: string, + options?: ConnectionModelVerificationOptions, + ): Promise +} diff --git a/src/adapters/connections/production-connections.ts b/src/adapters/connections/production-connections.ts new file mode 100644 index 0000000..ce91906 --- /dev/null +++ b/src/adapters/connections/production-connections.ts @@ -0,0 +1,43 @@ +import type { ConnectionRecord } from '../../domain/entities.js' +import { healthForConnection, verifyModelForConnection } from './production-connection-health.js' +import { capabilitiesForConnection } from './production-connection-providers.js' +import type { + ConnectionHealthOptions, + ConnectionModelVerificationOptions, + ProductionConnectionAdapter, + ProductionConnectionOptions, +} from './production-connection-types.js' + +export { readConnectionCredential } from './production-connection-credentials.js' +export { + connectionEndpoint, + normalizeCliBridgeRuntimeBaseUrl, +} from './production-connection-endpoints.js' +export { createTangleSandboxClient } from './production-connection-providers.js' +export { + type ConnectionCapabilityAction, + type ConnectionCapabilityReport, + type ConnectionHealthOptions, + type ConnectionModelVerificationOptions, + type ConnectionProviderMethods, + type ConnectionRuntimeCapabilities, + DEFAULT_TANGLE_SANDBOX_ENDPOINT, + type ProductionConnectionAdapter, + type ProductionConnectionOptions, + type SandboxClientFactory, + type SandboxClientFactoryInput, +} from './production-connection-types.js' + +export function createProductionConnectionAdapter( + record: ConnectionRecord, + options: ProductionConnectionOptions = {}, +): ProductionConnectionAdapter { + return Object.freeze({ + record, + capabilities: () => capabilitiesForConnection(record, options), + health: (healthOptions: ConnectionHealthOptions = {}) => + healthForConnection(record, options, healthOptions), + verifyModel: (model: string, verificationOptions: ConnectionModelVerificationOptions = {}) => + verifyModelForConnection(record, options, model, verificationOptions), + }) +} diff --git a/src/adapters/credentials/headless-key.ts b/src/adapters/credentials/headless-key.ts new file mode 100644 index 0000000..7124b5b --- /dev/null +++ b/src/adapters/credentials/headless-key.ts @@ -0,0 +1,287 @@ +import type { Stats } from 'node:fs' +import { + closeSync, + constants, + fstatSync, + lstatSync, + openSync, + readlinkSync, + readSync, + realpathSync, + statSync, +} from 'node:fs' +import { isAbsolute, join, parse, relative, resolve, sep } from 'node:path' +import { CredentialError } from '../../ports/credentials.js' + +export interface HeadlessKeyFileSource { + readonly type: 'file' + readonly path: string + readonly workspaceRoot: string +} + +export interface HeadlessKeyFdSource { + readonly type: 'fd' + readonly fd: number + readonly workspaceRoot: string +} + +export type HeadlessKeySource = HeadlessKeyFileSource | HeadlessKeyFdSource + +const MAX_KEY_BYTES = 128 + +function isInside(path: string, root: string): boolean { + const child = relative(resolve(root), resolve(path)) + return child === '' || (child !== '..' && !child.startsWith(`..${sep}`) && !isAbsolute(child)) +} + +function assertWorkspaceExternal(path: string, workspaceRoot: string): void { + if (isInside(path, workspaceRoot)) { + throw new CredentialError( + 'HEADLESS_KEY_IN_WORKSPACE', + 'Headless key files must be outside the workspace', + ) + } +} + +/** Resolves a configured key relative to its protected Braid config directory. */ +export function resolveHeadlessKeyPath( + path: string, + configDirectory: string, + workspaceRoot: string, +): string { + if (path.trim().length === 0) { + throw new CredentialError('HEADLESS_KEY_SOURCE', 'A protected headless key file is required') + } + const absolute = isAbsolute(path) ? resolve(path) : resolve(configDirectory, path) + assertWorkspaceExternal(absolute, workspaceRoot) + return absolute +} + +function canonicalWorkspaceRoot(path: string): string { + try { + return realpathSync(path) + } catch (error) { + throw new CredentialError( + 'HEADLESS_KEY_WORKSPACE', + 'The workspace root could not be resolved', + { cause: error }, + ) + } +} + +function assertNoSymlinkComponents(path: string): void { + const absolute = resolve(path) + const root = parse(absolute).root + let current = root + for (const component of absolute.slice(root.length).split(sep).filter(Boolean)) { + current = join(current, component) + let stat: Stats + try { + stat = lstatSync(current) + } catch (error) { + throw new CredentialError('HEADLESS_KEY_UNREADABLE', `Cannot inspect key path ${current}`, { + cause: error, + }) + } + if (stat.isSymbolicLink()) { + throw new CredentialError('HEADLESS_KEY_SYMLINK', 'Symlinked headless key paths are rejected') + } + } +} + +function assertOwnerAndMode(stat: Stats): void { + if (!stat.isFile()) { + throw new CredentialError('HEADLESS_KEY_NOT_FILE', 'Headless key source must be a regular file') + } + if ((stat.mode & 0o777) !== 0o600) { + throw new CredentialError('HEADLESS_KEY_PERMISSIONS', 'Headless key files must have mode 0600') + } + if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) { + throw new CredentialError( + 'HEADLESS_KEY_OWNER', + 'Headless key file ownership does not match the process', + ) + } + if (stat.nlink !== 1) { + throw new CredentialError( + 'HEADLESS_KEY_LINK_COUNT', + 'Headless key files must have exactly one filesystem link', + ) + } +} + +function parseKey(bytes: Buffer): Buffer { + if (bytes.length === 32) return Buffer.from(bytes) + if (bytes.length > MAX_KEY_BYTES) { + throw new CredentialError('HEADLESS_KEY_SIZE', 'Headless key input is too large') + } + const text = bytes.toString('utf8').trim() + if (!/^[0-9a-f]{64}$/iu.test(text)) { + throw new CredentialError( + 'HEADLESS_KEY_FORMAT', + 'Headless keys must be 32 bytes or 64 hexadecimal characters', + ) + } + return Buffer.from(text, 'hex') +} + +function descriptorPath(fd: number): string | undefined { + try { + const path = readlinkSync(`/proc/self/fd/${fd}`) + if (path.includes(' (deleted)')) { + throw new CredentialError( + 'HEADLESS_KEY_FD_DELETED', + 'Deleted headless key files are rejected', + ) + } + return isAbsolute(path) ? resolve(path) : undefined + } catch (error) { + if (error instanceof CredentialError) throw error + return undefined + } +} + +function assertDescriptorPath( + fd: number, + sourcePath: string, + workspaceRoot: string, + descriptorStat: Stats, +): void { + const canonicalWorkspace = canonicalWorkspaceRoot(workspaceRoot) + const actualPath = descriptorPath(fd) + if (actualPath) { + assertWorkspaceExternal(actualPath, canonicalWorkspace) + return + } + + let canonicalPath: string + let pathStat: Stats + try { + canonicalPath = realpathSync(sourcePath) + pathStat = statSync(canonicalPath) + } catch (error) { + throw new CredentialError( + 'HEADLESS_KEY_PATH_CHANGED', + 'The headless key path changed while it was being opened', + { cause: error }, + ) + } + if (pathStat.dev !== descriptorStat.dev || pathStat.ino !== descriptorStat.ino) { + throw new CredentialError( + 'HEADLESS_KEY_PATH_CHANGED', + 'The headless key path changed while it was being opened', + ) + } + assertWorkspaceExternal(canonicalPath, canonicalWorkspace) +} + +function inspectFd(fd: number): Stats { + if (!Number.isInteger(fd) || fd < 3) { + throw new CredentialError( + 'HEADLESS_KEY_FD', + 'Headless key file descriptors must be integers >= 3', + ) + } + let stat: Stats + try { + stat = fstatSync(fd) + } catch (error) { + throw new CredentialError( + 'HEADLESS_KEY_FD', + 'The inherited headless key descriptor is invalid', + { + cause: error, + }, + ) + } + if (stat.nlink === 0) { + throw new CredentialError('HEADLESS_KEY_FD_DELETED', 'Deleted headless key files are rejected') + } + assertOwnerAndMode(stat) + if (stat.size > MAX_KEY_BYTES) { + throw new CredentialError('HEADLESS_KEY_SIZE', 'Headless key input is too large') + } + return stat +} + +function readFd(fd: number, workspaceRoot: string): Buffer { + inspectFd(fd) + const actualPath = descriptorPath(fd) + if (!actualPath) { + throw new CredentialError( + 'HEADLESS_KEY_FD_PATH', + 'The inherited headless key descriptor path could not be verified', + ) + } + assertWorkspaceExternal(actualPath, canonicalWorkspaceRoot(workspaceRoot)) + const buffer = Buffer.alloc(MAX_KEY_BYTES + 1) + let length = 0 + while (length < buffer.length) { + const count = readSync(fd, buffer, length, buffer.length - length, length) + if (count === 0) break + length += count + } + if (length > MAX_KEY_BYTES) { + buffer.fill(0) + throw new CredentialError('HEADLESS_KEY_SIZE', 'Headless key input is too large') + } + try { + return parseKey(buffer.subarray(0, length)) + } finally { + buffer.fill(0) + } +} + +export function readHeadlessKey(source: HeadlessKeySource): Buffer { + if (!source || typeof source !== 'object') { + throw new CredentialError('HEADLESS_KEY_SOURCE', 'A protected file or descriptor is required') + } + if (source.type === 'fd') { + if (!source.workspaceRoot) { + throw new CredentialError( + 'HEADLESS_KEY_SOURCE', + 'Headless key descriptors require a workspace root', + ) + } + return readFd(source.fd, source.workspaceRoot) + } + if (source.type !== 'file' || !isAbsolute(source.path) || !source.workspaceRoot) { + throw new CredentialError( + 'HEADLESS_KEY_SOURCE', + 'Headless keys require an absolute mode-0600 file path and workspace root', + ) + } + const fd = openHeadlessKeyFile(source.path, source.workspaceRoot) + try { + return readFd(fd, source.workspaceRoot) + } finally { + closeHeadlessKeyFile(fd) + } +} + +export function openHeadlessKeyFile(path: string, workspaceRoot: string): number { + if (!isAbsolute(path)) + throw new CredentialError('HEADLESS_KEY_SOURCE', 'Headless key paths must be absolute') + assertNoSymlinkComponents(path) + const fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)) + try { + const stat = fstatSync(fd) + assertOwnerAndMode(stat) + assertDescriptorPath(fd, path, workspaceRoot, stat) + return fd + } catch (error) { + closeSync(fd) + throw error + } +} + +export function closeHeadlessKeyFile(fd: number): void { + closeSync(fd) +} + +export function rejectEnvironmentKeySource(value: unknown): never { + throw new CredentialError( + 'HEADLESS_KEY_ENV_REJECTED', + `Environment key sources are rejected (${typeof value === 'string' ? 'string' : 'unknown'})`, + ) +} diff --git a/src/adapters/credentials/headless-store.ts b/src/adapters/credentials/headless-store.ts new file mode 100644 index 0000000..a1c04fd --- /dev/null +++ b/src/adapters/credentials/headless-store.ts @@ -0,0 +1,277 @@ +import { + createCipheriv, + createDecipheriv, + createHash, + hkdfSync, + randomBytes, + randomUUID, +} from 'node:crypto' +import { statSync } from 'node:fs' +import { join } from 'node:path' +import type { + CredentialPort, + CredentialRef, + CredentialStoreInput, + SecretHandle, +} from '../../ports/credentials.js' +import { CredentialError, credentialRef } from '../../ports/credentials.js' +import { + assertSafeDirectory, + ensurePrivateDirectory, + readNoFollow, + removePrivateFile, + replacePrivateFile, + writePrivateFile, +} from '../persistence/safe-file.js' +import { type HeadlessKeySource, readHeadlessKey } from './headless-key.js' + +const CREDENTIAL_FILE_VERSION = 1 +const NONCE_BYTES = 12 +const TAG_BYTES = 16 +const MAX_CREDENTIAL_BYTES = 64 * 1024 +const MAX_ENCODED_BYTES = 1 + NONCE_BYTES + TAG_BYTES + MAX_CREDENTIAL_BYTES +const KEY_DERIVATION_PURPOSE = 'braid-headless-credential-encryption:v1' +const AAD_PURPOSE = 'braid-headless-credential-aad:v1' +const STORE_IDENTITY_PATTERN = /^[0-9a-f]{64}$/u + +class HeadlessSecretHandle implements SecretHandle { + readonly ref: CredentialRef + #value: Buffer | undefined + + constructor(ref: CredentialRef, value: Uint8Array) { + this.ref = ref + this.#value = Buffer.from(value) + } + + read(): Uint8Array { + if (this.#value === undefined) { + throw new CredentialError('SECRET_HANDLE_CLOSED', 'The headless secret handle is closed') + } + return Buffer.from(this.#value) + } + + dispose(): void { + this.#value?.fill(0) + this.#value = undefined + } +} + +/** + * Stores credential-port values encrypted with a key derived from the explicit headless + * database key. The key file remains the only external secret input; credential values + * never enter config. + */ +export class HeadlessCredentialStore implements CredentialPort { + readonly #root: string + readonly #storeIdentity: string + #key: Buffer | undefined + + constructor(options: { + readonly root: string + readonly keySource: HeadlessKeySource + /** Stable workspace/config identity, normally the credential-directory digest. */ + readonly storeIdentity: string + }) { + if (!STORE_IDENTITY_PATTERN.test(options.storeIdentity)) { + throw new CredentialError( + 'CREDENTIAL_STORE_IDENTITY', + 'Headless credential stores require a canonical workspace/config identity', + ) + } + this.#root = options.root + this.#storeIdentity = options.storeIdentity + const sourceKey = readHeadlessKey(options.keySource) + try { + this.#key = deriveCredentialKey(sourceKey, this.#storeIdentity) + } finally { + sourceKey.fill(0) + } + } + + async available(): Promise { + this.#assertOpen() + ensurePrivateDirectory(this.#root) + assertSafeDirectory(this.#root) + const stat = statSync(this.#root) + if ((stat.mode & 0o777) !== 0o700) { + throw new CredentialError( + 'CREDENTIAL_STORE_PERMISSIONS', + 'The headless credential directory must have mode 0700', + ) + } + return true + } + + async store(input: CredentialStoreInput): Promise { + const key = this.#assertOpen() + await this.available() + if (input.value.length === 0) { + throw new CredentialError('EMPTY_SECRET', 'Credential values must not be empty') + } + if (input.value.length > MAX_CREDENTIAL_BYTES) { + throw new CredentialError('SECRET_TOO_LARGE', 'Credential values exceed the headless limit') + } + const ref = input.ref ?? credentialRef(`cred:v1:headless-${randomUUID()}`) + const encoded = encryptCredential(key, this.#storeIdentity, ref, input.value) + try { + const path = this.#path(ref) + const previous = readNoFollow(path, MAX_ENCODED_BYTES) + if (previous === undefined) writePrivateFile(path, encoded) + else { + replacePrivateFile(path, encoded, { + overwrite: true, + expected: (current) => { + if (current === undefined || !current.equals(previous)) { + throw new CredentialError( + 'CREDENTIAL_CONFLICT', + 'The headless credential changed while it was being written', + ) + } + }, + maxExistingBytes: MAX_ENCODED_BYTES, + }) + } + } finally { + encoded.fill(0) + } + return ref + } + + async resolve(ref: CredentialRef): Promise { + const key = this.#assertOpen() + await this.available() + const encoded = readNoFollow(this.#path(ref), MAX_ENCODED_BYTES) + if (encoded === undefined) { + throw new CredentialError('CREDENTIAL_NOT_FOUND', `Credential ${ref} was not found`) + } + let value: Buffer | undefined + try { + value = decryptCredential(key, this.#storeIdentity, ref, encoded) + return new HeadlessSecretHandle(ref, value) + } catch (error) { + throw new CredentialError( + 'CREDENTIAL_INVALID', + 'The encrypted headless credential is invalid', + { + cause: error, + }, + ) + } finally { + encoded.fill(0) + value?.fill(0) + } + } + + async remove(ref: CredentialRef): Promise { + this.#assertOpen() + await this.available() + removePrivateFile(this.#path(ref)) + } + + dispose(): void { + this.#key?.fill(0) + this.#key = undefined + } + + #assertOpen(): Buffer { + if (this.#key === undefined) { + throw new CredentialError( + 'CREDENTIAL_STORE_UNAVAILABLE', + 'The headless credential store is closed', + ) + } + return this.#key + } + + #path(ref: CredentialRef): string { + const digest = createHash('sha256').update(ref).digest('hex') + return join(this.#root, `${digest}.credential`) + } +} + +function deriveCredentialKey(sourceKey: Buffer, storeIdentity: string): Buffer { + const identity = Buffer.from(storeIdentity, 'utf8') + const salt = createHash('sha256') + .update(`${KEY_DERIVATION_PURPOSE}:salt\u0000`, 'utf8') + .update(identity) + .digest() + const info = Buffer.concat([Buffer.from(`${KEY_DERIVATION_PURPOSE}\u0000`, 'utf8'), identity]) + try { + return Buffer.from(hkdfSync('sha256', sourceKey, salt, info, 32)) + } finally { + identity.fill(0) + salt.fill(0) + info.fill(0) + } +} + +function encodeAadSegment(value: string): Buffer { + const bytes = Buffer.from(value, 'utf8') + const length = Buffer.from(`${bytes.length}:`, 'utf8') + const encoded = Buffer.concat([length, bytes]) + length.fill(0) + bytes.fill(0) + return encoded +} + +function associatedData(storeIdentity: string, ref: CredentialRef): Buffer { + const prefix = Buffer.from(`${AAD_PURPOSE}\u0000`, 'utf8') + const identity = encodeAadSegment(storeIdentity) + const credential = encodeAadSegment(ref) + const aad = Buffer.concat([prefix, identity, credential]) + prefix.fill(0) + identity.fill(0) + credential.fill(0) + return aad +} + +function encryptCredential( + key: Buffer, + storeIdentity: string, + ref: CredentialRef, + value: Uint8Array, +): Buffer { + const nonce = randomBytes(NONCE_BYTES) + const cipher = createCipheriv('aes-256-gcm', key, nonce) + const aad = associatedData(storeIdentity, ref) + let ciphertext: Buffer | undefined + try { + cipher.setAAD(aad) + ciphertext = Buffer.concat([cipher.update(value), cipher.final()]) + const tag = cipher.getAuthTag() + return Buffer.concat([Buffer.from([CREDENTIAL_FILE_VERSION]), nonce, tag, ciphertext]) + } finally { + aad.fill(0) + ciphertext?.fill(0) + } +} + +function decryptCredential( + key: Buffer, + storeIdentity: string, + ref: CredentialRef, + encoded: Buffer, +): Buffer { + if (encoded.length < 1 + NONCE_BYTES + TAG_BYTES || encoded[0] !== CREDENTIAL_FILE_VERSION) { + throw new Error('Unsupported encrypted credential format') + } + const nonce = encoded.subarray(1, 1 + NONCE_BYTES) + const tag = encoded.subarray(1 + NONCE_BYTES, 1 + NONCE_BYTES + TAG_BYTES) + const ciphertext = encoded.subarray(1 + NONCE_BYTES + TAG_BYTES) + const decipher = createDecipheriv('aes-256-gcm', key, nonce) + const aad = associatedData(storeIdentity, ref) + let value: Buffer | undefined + try { + decipher.setAAD(aad) + decipher.setAuthTag(tag) + value = Buffer.concat([decipher.update(ciphertext), decipher.final()]) + if (value.length > MAX_CREDENTIAL_BYTES) { + value.fill(0) + value = undefined + throw new Error('Encrypted credential exceeds the headless limit') + } + return value + } finally { + aad.fill(0) + } +} diff --git a/src/adapters/credentials/memory.ts b/src/adapters/credentials/memory.ts new file mode 100644 index 0000000..ff8cb67 --- /dev/null +++ b/src/adapters/credentials/memory.ts @@ -0,0 +1,80 @@ +import { randomUUID } from 'node:crypto' +import type { + CredentialPort, + CredentialRef, + CredentialStoreInput, + SecretHandle, +} from '../../ports/credentials.js' +import { CredentialError, credentialRef } from '../../ports/credentials.js' + +class MemorySecretHandle implements SecretHandle { + readonly ref: CredentialRef + #value: Buffer | undefined + + constructor(ref: CredentialRef, value: Uint8Array) { + this.ref = ref + this.#value = Buffer.from(value) + } + + read(): Uint8Array { + if (!this.#value) throw new CredentialError('SECRET_HANDLE_CLOSED', 'Secret handle is closed') + return Buffer.from(this.#value) + } + + dispose(): void { + this.#value?.fill(0) + this.#value = undefined + } +} + +export class MemoryCredentialStore implements CredentialPort { + readonly #values = new Map() + #available = true + + setAvailable(available: boolean): void { + this.#available = available + } + + async store(input: CredentialStoreInput): Promise { + this.#assertAvailable() + const ref = input.ref ?? credentialRef(`cred:v1:memory-${randomUUID()}`) + const value = Buffer.from(input.value) + if (value.length === 0) + throw new CredentialError('EMPTY_SECRET', 'Credential values must not be empty') + const previous = this.#values.get(ref) + previous?.fill(0) + this.#values.set(ref, value) + return ref + } + + async resolve(ref: CredentialRef): Promise { + this.#assertAvailable() + const value = this.#values.get(ref) + if (!value) throw new CredentialError('CREDENTIAL_NOT_FOUND', `Credential ${ref} was not found`) + return new MemorySecretHandle(ref, value) + } + + async remove(ref: CredentialRef): Promise { + this.#assertAvailable() + const value = this.#values.get(ref) + if (value) value.fill(0) + this.#values.delete(ref) + } + + async available(): Promise { + return this.#available + } + + has(ref: CredentialRef): boolean { + return this.#values.has(ref) + } + + #assertAvailable(): void { + if (!this.#available) { + throw new CredentialError( + 'CREDENTIAL_STORE_UNAVAILABLE', + 'The in-memory credential store has been disabled', + ) + } + } +} diff --git a/src/adapters/credentials/os.ts b/src/adapters/credentials/os.ts new file mode 100644 index 0000000..fada366 --- /dev/null +++ b/src/adapters/credentials/os.ts @@ -0,0 +1,212 @@ +import { randomBytes, randomUUID, timingSafeEqual } from 'node:crypto' +import type { + CredentialPort, + CredentialRef, + CredentialStoreInput, + SecretHandle, +} from '../../ports/credentials.js' +import { CredentialError, credentialRef } from '../../ports/credentials.js' + +const SERVICE_NAME = 'Braid' + +export interface NativeKeyringEntry { + setSecret(secret: Uint8Array): Promise + getSecret(): Promise + deleteCredential(): Promise +} + +export type NativeKeyringEntryFactory = (service: string, account: string) => NativeKeyringEntry + +class LazyNativeKeyringEntry implements NativeKeyringEntry { + readonly #service: string + readonly #account: string + #entry: Promise | undefined + + constructor(service: string, account: string) { + this.#service = service + this.#account = account + } + + async setSecret(secret: Uint8Array): Promise { + await (await this.load()).setSecret(secret) + } + + async getSecret(): Promise { + return (await this.load()).getSecret() + } + + async deleteCredential(): Promise { + return (await this.load()).deleteCredential() + } + + private load(): Promise { + this.#entry ??= import('@napi-rs/keyring').then( + ({ AsyncEntry }) => new AsyncEntry(this.#service, this.#account), + ) + return this.#entry + } +} + +const createNativeEntry: NativeKeyringEntryFactory = (service, account) => + new LazyNativeKeyringEntry(service, account) + +class ProcessSecretHandle implements SecretHandle { + readonly ref: CredentialRef + #value: Buffer | undefined + + constructor(ref: CredentialRef, value: Uint8Array) { + this.ref = ref + this.#value = Buffer.from(value) + } + + read(): Uint8Array { + if (!this.#value) throw new CredentialError('SECRET_HANDLE_CLOSED', 'Secret handle is closed') + return Buffer.from(this.#value) + } + + dispose(): void { + this.#value?.fill(0) + this.#value = undefined + } +} + +function errorText(error: unknown): string { + if (error instanceof Error) return error.message + return String(error) +} + +function errorCode(error: unknown): string | undefined { + if (!error || typeof error !== 'object' || !('code' in error)) return undefined + return typeof error.code === 'string' ? error.code : undefined +} + +function isMissingCredential(error: unknown): boolean { + const code = errorCode(error) + ?.replaceAll(/[-_\s]/gu, '') + .toLowerCase() + if (code === 'noentry' || code === 'notfound' || code === 'credentialnotfound') return true + return /(?:no|missing)\s+(?:matching\s+)?(?:credential|entry)|not found/iu.test(errorText(error)) +} + +function facilityUnavailable(platform: string, cause: unknown): CredentialError { + return new CredentialError( + 'CREDENTIAL_STORE_UNAVAILABLE', + `${platform} credential facility is unavailable`, + { cause }, + ) +} + +abstract class NativeCredentialStore implements CredentialPort { + abstract readonly platform: string + readonly #entryFactory: NativeKeyringEntryFactory + + constructor(entryFactory: NativeKeyringEntryFactory = createNativeEntry) { + this.#entryFactory = entryFactory + } + + #entry(account: string): NativeKeyringEntry { + return this.#entryFactory(SERVICE_NAME, account) + } + + async store(input: CredentialStoreInput): Promise { + const ref = input.ref ?? credentialRef(`cred:v1:${this.platform}-${randomUUID()}`) + if (input.value.length === 0) { + throw new CredentialError('EMPTY_SECRET', 'Credential values must not be empty') + } + const secret = Buffer.from(input.value) + try { + await this.#entry(ref).setSecret(secret) + } catch (error) { + throw new CredentialError('CREDENTIAL_WRITE_FAILED', 'The credential could not be stored', { + cause: error, + }) + } finally { + secret.fill(0) + } + return ref + } + + async resolve(ref: CredentialRef): Promise { + let secret: Uint8Array | undefined + try { + secret = await this.#entry(ref).getSecret() + } catch (error) { + if (isMissingCredential(error)) { + throw new CredentialError('CREDENTIAL_NOT_FOUND', `Credential ${ref} was not found`) + } + throw facilityUnavailable(this.platform, error) + } + if (!secret || secret.length === 0) { + secret?.fill(0) + throw new CredentialError('CREDENTIAL_NOT_FOUND', `Credential ${ref} was not found`) + } + try { + return new ProcessSecretHandle(ref, secret) + } finally { + secret.fill(0) + } + } + + async remove(ref: CredentialRef): Promise { + try { + await this.#entry(ref).deleteCredential() + } catch (error) { + if (isMissingCredential(error)) return + throw new CredentialError('CREDENTIAL_REMOVE_FAILED', 'The credential could not be removed', { + cause: error, + }) + } + } + + async available(): Promise { + const entry = this.#entry(`__braid_availability_probe_v1__-${randomUUID()}`) + const expected = randomBytes(32) + let observed: Uint8Array | undefined + let cleanupAttempted = false + try { + await entry.setSecret(expected) + observed = await entry.getSecret() + const matches = + observed !== undefined && + observed.length === expected.length && + timingSafeEqual(observed, expected) + cleanupAttempted = true + const removed = await entry.deleteCredential() + return matches && removed + } catch { + if (!cleanupAttempted) await entry.deleteCredential().catch(() => false) + return false + } finally { + expected.fill(0) + observed?.fill(0) + } + } +} + +export class MacOsKeychainCredentialStore extends NativeCredentialStore { + readonly platform = 'macos-keychain' +} + +export class LinuxSecretServiceCredentialStore extends NativeCredentialStore { + readonly platform = 'linux-secret-service' +} + +export class WindowsCredentialManagerStore extends NativeCredentialStore { + readonly platform = 'windows-credential-manager' +} + +export function createOperatingSystemCredentialStore(): CredentialPort { + switch (process.platform) { + case 'darwin': + return new MacOsKeychainCredentialStore() + case 'linux': + return new LinuxSecretServiceCredentialStore() + case 'win32': + return new WindowsCredentialManagerStore() + default: + throw new CredentialError( + 'CREDENTIAL_STORE_UNAVAILABLE', + `No maintained credential adapter is available for ${process.platform}`, + ) + } +} diff --git a/src/adapters/persistence/safe-file-descriptor.ts b/src/adapters/persistence/safe-file-descriptor.ts new file mode 100644 index 0000000..3e2dec4 --- /dev/null +++ b/src/adapters/persistence/safe-file-descriptor.ts @@ -0,0 +1,304 @@ +import { closeSync, constants, fstatSync, mkdirSync, openSync, unlinkSync } from 'node:fs' +import { join, parse, resolve, sep } from 'node:path' + +const DIRECTORY_FLAGS = constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW +const LEAF_FLAGS = constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW + +// Node does not expose openat/unlinkat/renameat directly. Linux procfs and macOS devfs +// provide descriptor-relative path namespaces, so every component is opened below the +// descriptor acquired for its parent; unsupported platforms reject before path I/O. + +export type SafeFileErrorCode = + | 'SAFE_FILE_INVALID_LIMIT' + | 'SAFE_FILE_INVALID_PATH' + | 'SAFE_FILE_LOCK_NOT_OWNED' + | 'SAFE_FILE_NOT_DIRECTORY' + | 'SAFE_FILE_NOT_REGULAR' + | 'SAFE_FILE_PATH_RACE_UNSUPPORTED' + | 'SAFE_FILE_PUBLISHED_INVALID' + | 'SAFE_FILE_SYMLINK' + +/** A fail-closed error from the private filesystem boundary. */ +export class SafeFileError extends Error { + readonly code: SafeFileErrorCode + + constructor(code: SafeFileErrorCode, message: string) { + super(message) + this.name = 'SafeFileError' + this.code = code + } +} + +export interface SafePath { + readonly absolute: string + readonly root: string + readonly components: readonly string[] +} + +export interface OpenParent { + readonly path: SafePath + readonly fd: number + readonly leaf: string + readonly leafPath: string +} + +function descriptorRoot(): string { + if (process.platform === 'linux') return '/proc/self/fd' + if (process.platform === 'darwin') return '/dev/fd' + throw new SafeFileError( + 'SAFE_FILE_PATH_RACE_UNSUPPORTED', + `Descriptor-relative private-file access is unavailable on ${process.platform}; refusing the unsafe path operation`, + ) +} + +export function errorCode(error: unknown): string | undefined { + if (typeof error !== 'object' || error === null || !('code' in error)) return undefined + const code = (error as { readonly code?: unknown }).code + return typeof code === 'string' ? code : undefined +} + +export function safePath(path: string): SafePath { + descriptorRoot() + if (path.includes('\u0000')) { + throw new SafeFileError( + 'SAFE_FILE_INVALID_PATH', + 'Private storage paths must not contain NUL bytes', + ) + } + const absolute = resolve(path) + const root = parse(absolute).root + const components = absolute.slice(root.length).split(sep).filter(Boolean) + if (components.some((component) => component === '.' || component === '..')) { + throw new SafeFileError( + 'SAFE_FILE_INVALID_PATH', + `Private storage path is not canonical: ${path}`, + ) + } + return { absolute, root, components } +} + +export function componentPath(path: SafePath, count: number): string { + return join(path.root, ...path.components.slice(0, count)) +} + +export function childPath(directoryFd: number, leaf: string): string { + if ( + leaf.length === 0 || + leaf === '.' || + leaf === '..' || + leaf.includes('/') || + leaf.includes('\\') + ) { + throw new SafeFileError( + 'SAFE_FILE_INVALID_PATH', + 'Private storage path contains an invalid component', + ) + } + return `${descriptorRoot()}/${directoryFd}/${leaf}` +} + +export function normalizePathError(error: unknown, path: string): unknown { + if (errorCode(error) === 'ELOOP') { + return new SafeFileError( + 'SAFE_FILE_SYMLINK', + `Private storage path must not contain a symbolic link: ${path}`, + ) + } + return error +} + +export function requireDirectory(handle: number, path: string): void { + if (!fstatSync(handle).isDirectory()) { + throw new SafeFileError( + 'SAFE_FILE_NOT_DIRECTORY', + `Private storage path is not a directory: ${path}`, + ) + } +} + +export function requireRegularFile(handle: number, path: string): void { + if (!fstatSync(handle).isFile()) { + throw new SafeFileError( + 'SAFE_FILE_NOT_REGULAR', + `Private storage path is not a regular file: ${path}`, + ) + } +} + +export function openDirectoryComponents(path: SafePath, count: number): number { + let currentFd: number | undefined + try { + currentFd = openSync(path.root, DIRECTORY_FLAGS) + requireDirectory(currentFd, path.root) + let currentPath = path.root + for (const component of path.components.slice(0, count)) { + const nextPath = join(currentPath, component) + let nextFd: number + try { + nextFd = openSync(childPath(currentFd, component), DIRECTORY_FLAGS) + } catch (error) { + throw normalizePathError(error, nextPath) + } + try { + requireDirectory(nextFd, nextPath) + } catch (error) { + closeSync(nextFd) + throw error + } + const previousFd = currentFd + currentFd = nextFd + currentPath = nextPath + closeSync(previousFd) + } + const result = currentFd + currentFd = undefined + return result + } finally { + if (currentFd !== undefined) closeSync(currentFd) + } +} + +export function openParent(path: string): OpenParent { + const parsed = safePath(path) + if (parsed.components.length === 0) { + throw new SafeFileError( + 'SAFE_FILE_NOT_REGULAR', + `Private storage path is not a regular file: ${parsed.absolute}`, + ) + } + const parentCount = parsed.components.length - 1 + const directoryFd = openDirectoryComponents(parsed, parentCount) + const leaf = parsed.components[parsed.components.length - 1] + if (leaf === undefined) { + closeSync(directoryFd) + throw new SafeFileError( + 'SAFE_FILE_INVALID_PATH', + `Private storage path has no file name: ${path}`, + ) + } + return { + path: parsed, + fd: directoryFd, + leaf, + leafPath: join(componentPath(parsed, parentCount), leaf), + } +} + +export function openLeaf(parent: OpenParent, flags: number, mode?: number): number { + try { + return mode === undefined + ? openSync(childPath(parent.fd, parent.leaf), flags) + : openSync(childPath(parent.fd, parent.leaf), flags, mode) + } catch (error) { + throw normalizePathError(error, parent.leafPath) + } +} + +export function openExistingLeaf(path: string): number { + const parsed = safePath(path) + if (parsed.components.length === 0) { + try { + return openSync(parsed.root, LEAF_FLAGS) + } catch (error) { + throw normalizePathError(error, parsed.absolute) + } + } + const parent = openParent(path) + let handle: number | undefined + let parentClosed = false + try { + handle = openLeaf(parent, LEAF_FLAGS) + closeSync(parent.fd) + parentClosed = true + return handle + } catch (error) { + if (handle !== undefined) closeSync(handle) + throw error + } finally { + if (!parentClosed) { + try { + closeSync(parent.fd) + } catch { + // Preserve the operation error; the descriptor is already on the failure path. + } + } + } +} + +export function unlinkAt(parentFd: number, leaf: string, path: string): void { + try { + unlinkSync(childPath(parentFd, leaf)) + } catch (error) { + if (errorCode(error) !== 'ENOENT') throw normalizePathError(error, path) + } +} + +/** Performs a one-shot descriptor-relative symlink check; it is not a later path-operation proof. */ +export function assertNoSymlinkPath(path: string): void { + const parsed = safePath(path) + if (parsed.components.length === 0) return + let parent: OpenParent | undefined + try { + parent = openParent(path) + let handle: number | undefined + try { + handle = openLeaf(parent, LEAF_FLAGS) + } catch (error) { + if (errorCode(error) === 'ENOENT') return + throw error + } finally { + if (handle !== undefined) closeSync(handle) + } + } catch (error) { + if (errorCode(error) === 'ENOENT') return + throw error + } finally { + if (parent !== undefined) closeSync(parent.fd) + } +} + +/** Verifies that an existing path is a real directory through a stable descriptor. */ +export function assertSafeDirectory(path: string): void { + const parsed = safePath(path) + const handle = openDirectoryComponents(parsed, parsed.components.length) + closeSync(handle) +} + +/** Creates missing private directories without resolving a renamed parent by path. */ +export function ensurePrivateDirectory(path: string, mode = 0o700): void { + const parsed = safePath(path) + let currentFd: number | undefined + try { + currentFd = openSync(parsed.root, DIRECTORY_FLAGS) + requireDirectory(currentFd, parsed.root) + let currentPath = parsed.root + for (const component of parsed.components) { + const nextPath = join(currentPath, component) + let nextFd: number | undefined + for (;;) { + try { + nextFd = openSync(childPath(currentFd, component), DIRECTORY_FLAGS) + break + } catch (error) { + if (errorCode(error) !== 'ENOENT') throw normalizePathError(error, nextPath) + try { + mkdirSync(childPath(currentFd, component), { mode }) + } catch (mkdirError) { + if (errorCode(mkdirError) !== 'EEXIST') { + throw normalizePathError(mkdirError, nextPath) + } + } + } + } + requireDirectory(nextFd, nextPath) + const previousFd = currentFd + currentFd = nextFd + currentPath = nextPath + closeSync(previousFd) + } + } finally { + if (currentFd !== undefined) closeSync(currentFd) + } +} + +export { DIRECTORY_FLAGS, LEAF_FLAGS } diff --git a/src/adapters/persistence/safe-file-io.ts b/src/adapters/persistence/safe-file-io.ts new file mode 100644 index 0000000..0a36496 --- /dev/null +++ b/src/adapters/persistence/safe-file-io.ts @@ -0,0 +1,304 @@ +import { randomUUID } from 'node:crypto' +import { + closeSync, + constants, + fchmodSync, + fstatSync, + fsyncSync, + linkSync, + openSync, + readSync, + renameSync, + writeSync, +} from 'node:fs' +import { join } from 'node:path' + +import { + childPath, + componentPath, + errorCode, + normalizePathError, + type OpenParent, + openDirectoryComponents, + openExistingLeaf, + openLeaf, + openParent, + requireRegularFile, + SafeFileError, + safePath, + unlinkAt, +} from './safe-file-descriptor.js' + +const CREATE_FLAGS = + // O_EXCL makes the directory entry creation no-clobber; the temporary publication is anchored below. + constants.O_WRONLY | + constants.O_CREAT | + constants.O_EXCL | + constants.O_NONBLOCK | + constants.O_NOFOLLOW + +export interface PrivateFileWriteOptions { + readonly overwrite: boolean + readonly expected?: (current: Buffer | undefined) => void + readonly maxExistingBytes?: number + readonly verify?: (bytes: Buffer) => void + readonly onPhase?: (phase: PrivateFileWritePhase) => void +} + +export type PrivateFileWritePhase = + | 'temporary-written' + | 'temporary-fsynced' + | 'renamed' + | 'directory-fsynced' + +function validateMaxBytes(maxBytes: number): void { + if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) { + throw new SafeFileError( + 'SAFE_FILE_INVALID_LIMIT', + 'Private storage byte limits must be non-negative integers', + ) + } +} + +function readHandle(handle: number, path: string, maxBytes: number): Buffer { + const stats = fstatSync(handle) + if (!stats.isFile()) { + throw new SafeFileError( + 'SAFE_FILE_NOT_REGULAR', + `Private storage path is not a regular file: ${path}`, + ) + } + if (stats.size > maxBytes) throw new Error('Private storage file is too large') + const chunks: Buffer[] = [] + let total = 0 + const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, maxBytes)) + for (;;) { + const count = readSync(handle, buffer, 0, buffer.byteLength, null) + if (count === 0) break + total += count + if (total > maxBytes) throw new Error('Private storage file is too large') + chunks.push(Buffer.from(buffer.subarray(0, count))) + } + return Buffer.concat(chunks, total) +} + +export function readAt( + directoryFd: number, + leaf: string, + path: string, + maxBytes: number, +): Buffer | undefined { + let handle: number | undefined + try { + try { + handle = openSync(childPath(directoryFd, leaf), safeLeafFlags()) + } catch (error) { + if (errorCode(error) === 'ENOENT') return undefined + throw normalizePathError(error, path) + } + return readHandle(handle, path, maxBytes) + } finally { + if (handle !== undefined) closeSync(handle) + } +} + +function safeLeafFlags(): number { + return constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW +} + +function assertExistingRegularAt(parent: OpenParent): void { + let handle: number | undefined + try { + try { + handle = openLeaf(parent, safeLeafFlags()) + } catch (error) { + if (errorCode(error) === 'ENOENT') return + throw error + } + requireRegularFile(handle, parent.leafPath) + } finally { + if (handle !== undefined) closeSync(handle) + } +} + +function writeAll(handle: number, bytes: Buffer): void { + let offset = 0 + while (offset < bytes.byteLength) { + offset += writeSync(handle, bytes, offset, bytes.byteLength - offset) + } +} + +function writeTemporary( + parent: OpenParent, + temporary: string, + bytes: Buffer, + onPhase?: PrivateFileWriteOptions['onPhase'], +): void { + let handle: number | undefined + try { + try { + handle = openSync(childPath(parent.fd, temporary), CREATE_FLAGS, 0o600) + } catch (error) { + throw normalizePathError( + error, + join(componentPath(parent.path, parent.path.components.length - 1), temporary), + ) + } + requireRegularFile(handle, `${parent.leafPath}.${temporary}`) + writeAll(handle, bytes) + onPhase?.('temporary-written') + fsyncSync(handle) + onPhase?.('temporary-fsynced') + } finally { + if (handle !== undefined) closeSync(handle) + } +} + +export function readNoFollow(path: string, maxBytes: number): Buffer | undefined { + validateMaxBytes(maxBytes) + let handle: number | undefined + try { + handle = openExistingLeaf(path) + return readHandle(handle, safePath(path).absolute, maxBytes) + } catch (error) { + if (errorCode(error) === 'ENOENT') return undefined + throw error + } finally { + if (handle !== undefined) closeSync(handle) + } +} + +/** Removes one private regular file through its opened parent directory. */ +export function removePrivateFile(path: string): void { + const parent = openParent(path) + let handle: number | undefined + try { + try { + handle = openLeaf(parent, safeLeafFlags()) + requireRegularFile(handle, parent.leafPath) + } catch (error) { + if (errorCode(error) === 'ENOENT') return + throw normalizePathError(error, parent.leafPath) + } finally { + if (handle !== undefined) closeSync(handle) + } + unlinkAt(parent.fd, parent.leaf, parent.leafPath) + fsyncSync(parent.fd) + } finally { + closeSync(parent.fd) + } +} + +export function ensurePrivateFile(path: string): void { + let handle: number | undefined + try { + handle = openExistingLeaf(path) + requireRegularFile(handle, safePath(path).absolute) + fchmodSync(handle, 0o600) + } catch (error) { + if (errorCode(error) !== 'ENOENT') throw error + } finally { + if (handle !== undefined) closeSync(handle) + } +} + +export function fsyncDirectory(path: string): void { + const parsed = safePath(path) + const handle = openDirectoryComponents(parsed, parsed.components.length) + try { + fsyncSync(handle) + } finally { + closeSync(handle) + } +} + +/** + * Publishes a private file with a stable parent descriptor. + * No-follow and regular-file checks happen on descriptors; unsupported platforms reject before any path I/O. + * Node core has no renameat/linkat, so publication uses the platform descriptor namespace + * and verifies the published bytes afterward; that closes the parent race without claiming + * a stronger conditional-replacement guarantee than the available primitives provide. + */ +export function replacePrivateFile( + path: string, + value: string | Buffer, + options: PrivateFileWriteOptions, +): void { + const parent = openParent(path) + const bytes = Buffer.isBuffer(value) ? value : Buffer.from(value, 'utf8') + const temporary = `.${parent.leaf}.${randomUUID()}.tmp` + const temporaryPath = join( + componentPath(parent.path, parent.path.components.length - 1), + temporary, + ) + let temporaryExists = false + let failure: unknown + let cleanupFailure: unknown + try { + if (options.expected !== undefined) { + const current = readAt( + parent.fd, + parent.leaf, + parent.leafPath, + options.maxExistingBytes ?? bytes.byteLength, + ) + options.expected(current) + } + assertExistingRegularAt(parent) + temporaryExists = true + writeTemporary(parent, temporary, bytes, options.onPhase) + const written = readAt(parent.fd, temporary, temporaryPath, bytes.byteLength) + if (written === undefined || !written.equals(bytes)) { + throw new SafeFileError( + 'SAFE_FILE_PUBLISHED_INVALID', + `Private storage temporary file could not be verified: ${temporaryPath}`, + ) + } + options.verify?.(written) + const source = childPath(parent.fd, temporary) + const target = childPath(parent.fd, parent.leaf) + try { + if (options.overwrite) renameSync(source, target) + else linkSync(source, target) + } catch (error) { + throw normalizePathError(error, parent.leafPath) + } + if (options.overwrite) { + temporaryExists = false + } else { + unlinkAt(parent.fd, temporary, temporaryPath) + temporaryExists = false + } + options.onPhase?.('renamed') + fsyncSync(parent.fd) + options.onPhase?.('directory-fsynced') + const published = readAt(parent.fd, parent.leaf, parent.leafPath, bytes.byteLength) + if (published === undefined || !published.equals(bytes)) { + throw new SafeFileError( + 'SAFE_FILE_PUBLISHED_INVALID', + `Private storage file could not be verified after publication: ${parent.leafPath}`, + ) + } + } catch (error) { + failure = error + } finally { + if (temporaryExists) { + try { + unlinkAt(parent.fd, temporary, temporaryPath) + } catch (error) { + cleanupFailure = error + } + } + try { + closeSync(parent.fd) + } catch (error) { + cleanupFailure ??= error + } + } + if (failure !== undefined) throw failure + if (cleanupFailure !== undefined) throw cleanupFailure +} + +export function writePrivateFile(path: string, value: string | Buffer): void { + replacePrivateFile(path, value, { overwrite: false }) +} diff --git a/src/adapters/persistence/safe-file.ts b/src/adapters/persistence/safe-file.ts new file mode 100644 index 0000000..bf9cdf9 --- /dev/null +++ b/src/adapters/persistence/safe-file.ts @@ -0,0 +1,121 @@ +import { closeSync, constants, fsyncSync, openSync, writeSync } from 'node:fs' +import { + childPath, + errorCode, + normalizePathError, + openParent, + requireRegularFile, + SafeFileError, + type SafeFileErrorCode, + unlinkAt, +} from './safe-file-descriptor.js' +import { readAt } from './safe-file-io.js' + +export { + assertNoSymlinkPath, + assertSafeDirectory, + ensurePrivateDirectory, + SafeFileError, + type SafeFileErrorCode, +} from './safe-file-descriptor.js' +export { + ensurePrivateFile, + fsyncDirectory, + removePrivateFile, + type PrivateFileWriteOptions, + readNoFollow, + replacePrivateFile, + writePrivateFile, +} from './safe-file-io.js' + +const CREATE_FLAGS = + constants.O_WRONLY | + constants.O_CREAT | + constants.O_EXCL | + constants.O_NONBLOCK | + constants.O_NOFOLLOW + +const journalLocks = new Map< + number, + { readonly directoryFd: number; readonly leaf: string; readonly leafPath: string } +>() + +/** A small crash-recovery lock; stale owners are safe to reclaim within the opened parent directory. */ +export function acquireJournalLock(path: string): number { + const parent = openParent(path) + for (;;) { + let handle: number | undefined + try { + try { + handle = openSync(childPath(parent.fd, parent.leaf), CREATE_FLAGS, 0o600) + } catch (error) { + if (errorCode(error) !== 'EEXIST') throw normalizePathError(error, parent.leafPath) + const bytes = readAt(parent.fd, parent.leaf, parent.leafPath, 128) + const ownerText = bytes?.toString('utf8').trim() + if (!ownerText || !/^\d+$/.test(ownerText)) { + throw new Error('Private conversation storage lock is corrupt or incomplete') + } + const owner = Number(ownerText) + if (!Number.isSafeInteger(owner) || owner <= 0) { + throw new Error('Private conversation storage lock has an invalid owner') + } + try { + process.kill(owner, 0) + throw new Error('Private conversation storage is busy in another process') + } catch (probeError) { + if (errorCode(probeError) !== 'ESRCH') throw probeError + unlinkAt(parent.fd, parent.leaf, parent.leafPath) + } + continue + } + requireRegularFile(handle, parent.leafPath) + let offset = 0 + const ownerBytes = Buffer.from(`${process.pid}\n`, 'utf8') + while (offset < ownerBytes.byteLength) { + offset += writeSync(handle, ownerBytes, offset, ownerBytes.byteLength - offset) + } + fsyncSync(handle) + journalLocks.set(handle, { + directoryFd: parent.fd, + leaf: parent.leaf, + leafPath: parent.leafPath, + }) + return handle + } catch (error) { + if (handle !== undefined) { + closeSync(handle) + journalLocks.delete(handle) + } + closeSync(parent.fd) + throw error + } + } +} + +export function releaseJournalLock(path: string, handle: number): void { + const lock = journalLocks.get(handle) + if (lock === undefined) { + throw new SafeFileError( + 'SAFE_FILE_LOCK_NOT_OWNED' satisfies SafeFileErrorCode, + `Private conversation storage lock is not owned by this process: ${path}`, + ) + } + journalLocks.delete(handle) + let failure: unknown + try { + closeSync(handle) + } catch (error) { + failure = error + } + try { + unlinkAt(lock.directoryFd, lock.leaf, lock.leafPath) + } catch (error) { + failure ??= error + } + try { + closeSync(lock.directoryFd) + } catch (error) { + failure ??= error + } + if (failure !== undefined) throw failure +} diff --git a/src/adapters/runtime/agent-runtime-execution.ts b/src/adapters/runtime/agent-runtime-execution.ts index 567506e..8a2e9b5 100644 --- a/src/adapters/runtime/agent-runtime-execution.ts +++ b/src/adapters/runtime/agent-runtime-execution.ts @@ -1,23 +1,289 @@ -import { streamAgentTurn, type AgentTurnBackend } from '@tangle-network/agent-runtime/kernel' -import type { ExecuteTurnInput, ExecutionPort } from '../../ports/execution.js' +import { snapshotAgentProfile } from '@tangle-network/agent-interface' +import type { RuntimeStreamEvent } from '@tangle-network/agent-runtime' +import type { AgentTurnBackend } from '@tangle-network/agent-runtime/kernel' +import type { SandboxInstance } from '@tangle-network/sandbox' +import { canonicalDigest } from '../../domain/canonical.js' +import { redactProfile } from '../../domain/redaction.js' +import type { + CancelRunInput, + CancelRunResult, + ControlAcknowledgement, + ExecuteTurnInput, + ExecutionAdmission, + ExecutionPort, +} from '../../ports/execution.js' +import { + DEFAULT_RUN_CAPABILITIES, + capabilitiesFromEnvironment, + type RunCapabilities, + UNKNOWN_RUN_CAPABILITIES, +} from '../../ports/execution.js' +import { isPreparedSandboxExecution, type PreparedExecution } from './prepared-execution.js' export type AgentTurnBackendResolver = ( input: ExecuteTurnInput, -) => AgentTurnBackend | Promise +) => PreparedExecution | Promise + +export type AgentTurnCancelResolver = ( + input: CancelRunInput, +) => CancelRunResult | Promise + +export interface AgentRuntimeExecutionOptions { + readonly admissionMode?: 'sync' | 'async' +} export class AgentRuntimeExecutionPort implements ExecutionPort { + readonly admissionMode: 'sync' | 'async' readonly #resolveBackend: AgentTurnBackendResolver + readonly #cancel: AgentTurnCancelResolver | undefined + readonly #active = new Map() + readonly #prepared = new Map< + string, + { readonly key: string; readonly execution: PreparedExecution } + >() + readonly #preparedOrder: string[] = [] + readonly #capabilitySnapshot: RunCapabilities + + static readonly #MAX_PREPARED = 128 - constructor(resolveBackend: AgentTurnBackendResolver) { + constructor( + resolveBackend: AgentTurnBackendResolver, + cancelOrCapabilities?: AgentTurnCancelResolver | RunCapabilities, + options: AgentRuntimeExecutionOptions = {}, + ) { this.#resolveBackend = resolveBackend + this.admissionMode = options.admissionMode ?? 'async' + if (typeof cancelOrCapabilities === 'function') { + this.#cancel = cancelOrCapabilities + this.#capabilitySnapshot = { + ...DEFAULT_RUN_CAPABILITIES, + controls: { ...DEFAULT_RUN_CAPABILITIES.controls, cancel: true }, + } + } else { + this.#capabilitySnapshot = cancelOrCapabilities ?? { + ...UNKNOWN_RUN_CAPABILITIES, + streaming: { ...UNKNOWN_RUN_CAPABILITIES.streaming, live: true }, + } + } + } + + capabilities(): RunCapabilities { + return this.#capabilitySnapshot + } + + admit(input: ExecuteTurnInput): ExecutionAdmission | Promise { + const backend = this.#resolveBackend(input) + if (isPromiseLike(backend)) + return backend.then((resolved) => this.#prepareAdmission(input, resolved)) + return this.#prepareAdmission(input, backend) } - async *streamTurn(input: ExecuteTurnInput) { - const backend = await this.#resolveBackend(input) - yield* streamAgentTurn(backend, input.text, { - signal: input.signal, - timeoutMs: 30_000, - preserveToolParts: true, + #prepareAdmission(input: ExecuteTurnInput, execution: PreparedExecution): ExecutionAdmission { + const profileDigest = canonicalDigest(redactProfile(snapshotAgentProfile(input.profile))) + const prepared = isPreparedSandboxExecution(execution) + const providerSessionId = prepared ? execution.providerSessionId : undefined + const capabilities = prepared + ? capabilitiesFromEnvironment(execution.capabilities) + : this.#capabilitySnapshot + const materializationReceipt = prepared + ? execution.materializationReceipt + : { backendKind: execution.kind, provider: 'agent-runtime' } + this.#rememberPrepared(input.runId, { + key: admissionKey(input, profileDigest), + execution, }) + return { + capabilities, + provider: 'agent-runtime', + ...(prepared ? { environmentId: execution.environmentId } : {}), + ...(providerSessionId === undefined ? {} : { providerSessionId }), + materializationReceipt, + profileDigest, + capabilitiesDigest: canonicalDigest(capabilities), + materializationDigest: canonicalDigest(materializationReceipt), + } + } + + async cancelRun(input: { + readonly runId: string + readonly operationId: string + readonly reason?: string + }): Promise { + if (this.#cancel) { + const result = await this.#cancel({ + runId: input.runId, + operationId: input.operationId, + reason: input.reason ?? 'Cancelled by user', + }) + return result.status === 'cancelled' + ? { operationId: input.operationId, outcome: 'accepted' } + : { operationId: input.operationId, outcome: 'unknown', detail: result.reason } + } + const controller = this.#active.get(input.runId) + if (!controller) + return { + operationId: input.operationId, + outcome: 'unknown', + detail: 'The provider did not acknowledge cancellation', + } + controller.abort(new Error('Cancelled by user')) + return { + operationId: input.operationId, + outcome: 'unknown', + detail: 'Local abort occurred without provider acknowledgement', + } + } + + async respondInteraction(input: { + readonly runId: string + readonly operationId: string + readonly response: import('@tangle-network/agent-interface').InteractionResponse + readonly signal?: AbortSignal + }): Promise { + // agent-runtime 0.128 owns the live session but exposes no interaction + // response operation. Keep this capability explicitly unknown until the + // shared runtime contract provides one; never emulate it with a local + // abort or a provider-private call. + void input.runId + void input.response + void input.signal + return { + operationId: input.operationId, + outcome: 'unknown', + detail: 'The current agent-runtime release has no response channel', + } + } + + async *streamTurn(input: ExecuteTurnInput): AsyncGenerator { + const localAbort = new AbortController() + const stop = () => localAbort.abort(input.signal.reason) + if (input.signal.aborted) stop() + else input.signal.addEventListener('abort', stop, { once: true }) + this.#active.set(input.runId, localAbort) + let ownedBox: SandboxInstance | undefined + try { + localAbort.signal.throwIfAborted() + const profileDigest = canonicalDigest(redactProfile(snapshotAgentProfile(input.profile))) + const prepared = this.#prepared.get(input.runId) + const execution = + prepared?.key === admissionKey(input, profileDigest) + ? prepared.execution + : await this.#resolveBackend(input) + this.#forgetPrepared(input.runId) + const materialized = await materializeExecution(execution) + ownedBox = materialized.ownedBox + const { streamAgentTurn } = await import('@tangle-network/agent-runtime/kernel') + let terminal: Extract | undefined + for await (const event of streamAgentTurn(materialized.backend, input.text, { + signal: localAbort.signal, + timeoutMs: 30_000, + preserveToolParts: true, + })) { + if (event.type === 'final') terminal = event + else yield event + } + const cleanupError = ownedBox === undefined ? undefined : await deleteBox(ownedBox) + ownedBox = undefined + if (cleanupError !== undefined) { + if (terminal === undefined) throw cleanupError + const failedTerminal: Extract = { + ...terminal, + status: 'failed', + reason: 'Sandbox deletion was not acknowledged after retry; resource state is unknown', + error: { kind: 'backend', message: cleanupError.message }, + } + yield failedTerminal + } else if (terminal !== undefined) { + yield terminal + } + } catch (error) { + if (ownedBox !== undefined) { + const cleanupError = await deleteBox(ownedBox) + ownedBox = undefined + if (cleanupError !== undefined) { + throw new AggregateError( + [error, cleanupError], + 'Agent execution failed and sandbox deletion was not acknowledged', + ) + } + } + throw error + } finally { + if (ownedBox !== undefined) { + // Consumer-driven stream closure has no remaining event channel. + // Cleanup is still attempted, but it must not replace the caller's return. + await deleteBox(ownedBox) + ownedBox = undefined + } + input.signal.removeEventListener('abort', stop) + this.#active.delete(input.runId) + } } + + #rememberPrepared( + runId: string, + binding: { readonly key: string; readonly execution: PreparedExecution }, + ): void { + if (!this.#prepared.has(runId)) this.#preparedOrder.push(runId) + this.#prepared.set(runId, binding) + while (this.#preparedOrder.length > AgentRuntimeExecutionPort.#MAX_PREPARED) { + const expired = this.#preparedOrder.shift() + if (expired !== undefined) this.#prepared.delete(expired) + } + } + + #forgetPrepared(runId: string): void { + this.#prepared.delete(runId) + const index = this.#preparedOrder.indexOf(runId) + if (index >= 0) this.#preparedOrder.splice(index, 1) + } +} + +async function materializeExecution(execution: PreparedExecution): Promise<{ + readonly backend: AgentTurnBackend + readonly ownedBox?: SandboxInstance +}> { + if (!isPreparedSandboxExecution(execution)) return { backend: execution } + const box = await execution.client.create() + return { + backend: { + kind: 'box', + box, + options: execution.turnOptions, + agentRunName: execution.agentRunName, + }, + ...(execution.lifecycle.cleanup === 'delete-after-turn' ? { ownedBox: box } : {}), + } +} + +async function deleteBox(box: SandboxInstance): Promise { + let failure: unknown + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + await box.delete() + return undefined + } catch (error) { + failure = error + } + } + return new Error( + 'Sandbox deletion was not acknowledged after retry; resource state is unknown', + failure === undefined ? undefined : { cause: failure }, + ) +} + +function isPromiseLike(value: T | Promise): value is Promise { + return typeof value === 'object' && value !== null && 'then' in value +} + +function admissionKey(input: ExecuteTurnInput, profileDigest: string): string { + return canonicalDigest({ + operationId: input.operationId, + runId: input.runId, + text: input.text, + profileDigest, + connectionId: input.connectionId ?? null, + sessionId: input.sessionId ?? null, + contextBoundary: input.contextBoundary ?? null, + }) } diff --git a/src/adapters/runtime/agent-runtime-version.ts b/src/adapters/runtime/agent-runtime-version.ts new file mode 100644 index 0000000..481e852 --- /dev/null +++ b/src/adapters/runtime/agent-runtime-version.ts @@ -0,0 +1 @@ +export const AGENT_RUNTIME_VERSION = '0.128.0' diff --git a/src/adapters/runtime/prepared-execution.ts b/src/adapters/runtime/prepared-execution.ts new file mode 100644 index 0000000..752645c --- /dev/null +++ b/src/adapters/runtime/prepared-execution.ts @@ -0,0 +1,40 @@ +import type { + AgentEnvironmentCapabilities, + CreateAgentEnvironmentInput, +} from '@tangle-network/agent-interface/environment-provider' +import type { AgentTurnBackend, SandboxClient } from '@tangle-network/agent-runtime/kernel' +import type { PromptOptions } from '@tangle-network/sandbox' + +export interface SandboxLifecyclePolicy { + readonly mode: 'ephemeral' | 'retained' + readonly cleanup: 'delete-after-turn' | 'explicit' + readonly continuity: 'session' | 'unavailable' + readonly reason?: string +} + +/** + * An admission-time provider binding with no live remote environment. + * `client.create()` is deliberately deferred until after `run.requested` is + * durable, so a journal failure cannot orphan a billable sandbox. + */ +export interface PreparedSandboxExecution { + readonly kind: 'sandbox-plan' + readonly client: SandboxClient + readonly createInput: Readonly + readonly prompt: string + readonly turnOptions: Readonly> + readonly agentRunName: string + readonly capabilities: AgentEnvironmentCapabilities + readonly environmentId: string + readonly lifecycle: SandboxLifecyclePolicy + readonly providerSessionId?: string + readonly materializationReceipt: Readonly> +} + +export type PreparedExecution = AgentTurnBackend | PreparedSandboxExecution + +export function isPreparedSandboxExecution( + value: PreparedExecution, +): value is PreparedSandboxExecution { + return value.kind === 'sandbox-plan' +} diff --git a/src/adapters/runtime/production-backend-resolver.ts b/src/adapters/runtime/production-backend-resolver.ts new file mode 100644 index 0000000..1a4b59d --- /dev/null +++ b/src/adapters/runtime/production-backend-resolver.ts @@ -0,0 +1,385 @@ +import { + type AgentProfile, + type HarnessType, + harnessSupportsModel, + snapHarnessToModel, +} from '@tangle-network/agent-interface' +import type { AgentEnvironmentCapabilities } from '@tangle-network/agent-interface/environment-provider' +import type { ResolveAgentBackendOptions } from '@tangle-network/agent-runtime' +import type { AgentTurnBackend } from '@tangle-network/agent-runtime/kernel' +import type { BackendType, PromptOptions } from '@tangle-network/sandbox' +import { ConnectionError } from '../../app/connection-errors.js' +import type { ConnectionCatalog, ConnectionSelectionInput } from '../../app/connections.js' +import type { ExecuteTurnInput } from '../../ports/execution.js' +import { + bridgeRouteRunner, + bridgeRunnerSupportsModel, +} from '../connections/cli-bridge-model-route.js' +import { readConnectionCredential } from '../connections/production-connection-credentials.js' +import { + connectionEndpoint, + normalizeCliBridgeProviderBaseUrl, +} from '../connections/production-connection-endpoints.js' +import type { ProductionConnectionOptions } from '../connections/production-connection-types.js' +import type { + PreparedExecution, + PreparedSandboxExecution, + SandboxLifecyclePolicy, +} from './prepared-execution.js' + +const TURN_TIMEOUT_MS = 30_000 +export interface ProductionExecutionSelection { + readonly connection: ConnectionSelectionInput + readonly model?: string + readonly runner?: HarnessType +} + +export type ProductionExecutionSelector = ( + input: ExecuteTurnInput, +) => ProductionExecutionSelection | Promise + +export interface ProductionBackendResolverOptions extends ProductionConnectionOptions { + readonly connections: ConnectionCatalog + readonly select: ProductionExecutionSelector + readonly retry?: ResolveAgentBackendOptions['retry'] + readonly defaultSandboxBackend?: BackendType + readonly workspaceCwd?: string +} + +/** + * Adapt the application selector to Braid's existing runtime-port resolver. + * The selector is the only place that chooses a connection; this adapter never + * searches by name, endpoint, or provider kind. + */ +export function createProductionBackendResolver( + options: ProductionBackendResolverOptions, +): (input: ExecuteTurnInput) => Promise { + return async (input) => { + const selected = await options.select(input) + const selection = + input.connectionId === undefined + ? selected + : { ...selected, connection: { connectionId: input.connectionId } } + return resolveProductionBackend(options, input, selection) + } +} + +export async function resolveProductionBackend( + options: ProductionBackendResolverOptions, + input: ExecuteTurnInput, + selection: ProductionExecutionSelection, +): Promise { + const selected = options.connections.select(selection.connection) + const record = selected.record + const endpoint = connectionEndpoint(record, options) + + switch (record.kind) { + case 'cli-bridge': + return resolveCliBridgeBackend(options, input, selection, record.id, endpoint) + case 'tangle-inference': + return resolveTangleInferenceBackend(options, input, selection, record.id, endpoint) + case 'tangle-sandbox': + return resolveTangleSandboxBackend(options, input, selection, record.id) + } +} + +async function resolveCliBridgeBackend( + options: ProductionBackendResolverOptions, + input: ExecuteTurnInput, + selection: ProductionExecutionSelection, + connectionId: Parameters[0]['id'], + endpoint: string, +): Promise { + const { createCliBridgeProvider } = await import('@tangle-network/agent-provider-cli-bridge') + const model = await requiredModel(input.profile, selection.model, connectionId) + const runner = selection.runner ?? input.profile.harness + if (runner !== undefined && !bridgeRunnerSupportsModel(runner, model)) { + const suggestedRunner = bridgeRouteRunner(model) ?? snapHarnessToModel(runner, model) + const runnerChoice = + suggestedRunner === runner + ? 'choose a runner compatible with this model' + : `choose runner=${suggestedRunner} to keep model=${model}` + throw new ConnectionError( + 'CONNECTION_MODEL_HARNESS_MISMATCH', + `Profile field harness=${runner} does not support model=${model}. The authored profile was not changed; ${runnerChoice}, or choose a model advertised for runner=${runner}.`, + { connectionId }, + ) + } + const credential = await readConnectionCredential( + inputRecord(connectionId, options), + options, + endpoint, + ) + const provider = createCliBridgeProvider({ + baseUrl: normalizeCliBridgeProviderBaseUrl(endpoint, connectionId), + defaultModel: model, + ...(credential === undefined ? {} : { bearerToken: credential }), + ...(options.fetch ? { fetch: options.fetch } : {}), + }) + return createSandboxPlan({ + provider, + input, + connectionId, + model, + ...(runner === undefined ? {} : { runner }), + providerName: 'cli-bridge', + recordName: inputRecord(connectionId, options).name, + ...(options.workspaceCwd === undefined ? {} : { workspaceCwd: options.workspaceCwd }), + }) +} + +async function resolveTangleInferenceBackend( + options: ProductionBackendResolverOptions, + input: ExecuteTurnInput, + selection: ProductionExecutionSelection, + connectionId: Parameters[0]['id'], + endpoint: string, +): Promise { + const { resolveAgentBackend } = await import('@tangle-network/agent-runtime') + const model = await requiredModel(input.profile, selection.model, connectionId) + const credential = await readConnectionCredential( + inputRecord(connectionId, options), + options, + endpoint, + ) + const backend = resolveAgentBackend({ + kind: 'tcloud', + apiKey: credential ?? '', + baseUrl: endpoint, + model, + label: 'tangle-inference', + ...(options.fetch ? { fetchImpl: options.fetch } : {}), + ...(options.retry ? { retry: options.retry } : {}), + }) + return { kind: 'chat', backend } +} + +async function resolveTangleSandboxBackend( + options: ProductionBackendResolverOptions, + input: ExecuteTurnInput, + selection: ProductionExecutionSelection, + connectionId: Parameters[0]['id'], +): Promise { + if (input.sessionId !== undefined) { + throw new ConnectionError( + 'CONNECTION_UNSUPPORTED', + 'Tangle sandbox session continuity is unavailable until retained environment recovery is supported', + { connectionId }, + ) + } + const [{ createTangleSandboxClient }, { createTangleProvider }] = await Promise.all([ + import('../connections/production-connection-providers.js'), + import('@tangle-network/agent-provider-tangle'), + ]) + const { cleanModelId } = await import('@tangle-network/agent-runtime') + const backend = await sandboxBackendType( + input.profile, + selection.runner, + options.defaultSandboxBackend, + connectionId, + ) + const model = cleanModelId(selection.model ?? input.profile.model?.default) + if (model !== undefined && !harnessSupportsModel(backend, model)) { + const suggestedRunner = snapHarnessToModel(backend, model) + const runnerChoice = + suggestedRunner === backend + ? 'choose a runner compatible with this model' + : `choose runner=${suggestedRunner} to keep model=${model}` + throw new ConnectionError( + 'CONNECTION_MODEL_HARNESS_MISMATCH', + `Profile field harness=${backend} does not support model=${model}. The authored profile was not changed; ${runnerChoice}, or choose a model advertised for runner=${backend}.`, + { connectionId }, + ) + } + const record = inputRecord(connectionId, options) + const client = await createTangleSandboxClient(record, options, input.signal) + const provider = createTangleProvider({ + client, + defaultBackend: backend, + name: 'tangle-sandbox', + }) + return createSandboxPlan({ + provider, + input, + connectionId, + ...(model === undefined ? {} : { model }), + runner: backend, + providerName: 'tangle-sandbox', + recordName: record.name, + ...(options.workspaceCwd === undefined ? {} : { workspaceCwd: options.workspaceCwd }), + }) +} + +async function createSandboxPlan(input: { + readonly provider: import('@tangle-network/agent-interface/environment-provider').AgentEnvironmentProvider + readonly input: ExecuteTurnInput + readonly connectionId: string + readonly model?: string + readonly runner?: string + readonly providerName: string + readonly recordName: string + readonly workspaceCwd?: string +}): Promise { + const { providerAsSandboxClient } = await import( + '@tangle-network/agent-runtime/environment-provider' + ) + const providerCapabilities = await input.provider.capabilities() + const lifecycle = lifecycleForProvider(input.providerName) + const capabilities = capabilitiesForLifecycle(providerCapabilities, lifecycle) + const { snapshotAgentProfile } = await import('@tangle-network/agent-interface') + const profile = snapshotAgentProfile(input.input.profile) + const providerSessionId = providerSessionFor(input.input, capabilities) + const environmentId = `env-braid-${safeId(input.input.runId)}` + const createInput = freezeDeep({ + profile, + ...(input.runner === undefined ? {} : { backend: input.runner }), + workspace: { cwd: requiredWorkspaceCwd(input.input.workspaceRoot, input.workspaceCwd) }, + name: input.recordName, + idempotencyKey: environmentId, + }) + const turnOptions = freezeDeep({ + ...(providerSessionId === undefined ? {} : { sessionId: providerSessionId }), + ...(input.model === undefined ? {} : { model: input.model }), + executionId: input.input.runId, + turnId: input.input.operationId, + timeoutMs: TURN_TIMEOUT_MS, + } satisfies Omit) + const materializationReceipt = freezeDeep({ + provider: input.providerName, + backend: 'box', + connectionId: input.connectionId, + environmentId, + lifecycle: lifecycle.mode, + cleanup: lifecycle.cleanup, + continuity: lifecycle.continuity, + ...(lifecycle.reason === undefined ? {} : { continuityReason: lifecycle.reason }), + portableContext: 'unavailable', + ...(input.model === undefined ? {} : { model: input.model }), + ...(input.runner === undefined ? {} : { runner: input.runner }), + }) + const client = providerAsSandboxClient(input.provider, { + defaults: createInput, + requireTerminalEvent: true, + }) + return Object.freeze({ + kind: 'sandbox-plan' as const, + client, + createInput, + prompt: input.input.text, + turnOptions, + agentRunName: input.model ?? input.recordName, + capabilities, + environmentId, + lifecycle, + ...(providerSessionId === undefined ? {} : { providerSessionId }), + materializationReceipt, + }) +} + +function lifecycleForProvider(providerName: string): SandboxLifecyclePolicy { + if (providerName === 'tangle-sandbox') { + return { + mode: 'ephemeral', + cleanup: 'delete-after-turn', + continuity: 'unavailable', + reason: 'Retained Tangle environment recovery is not exposed by the current Braid port', + } + } + return { mode: 'ephemeral', cleanup: 'delete-after-turn', continuity: 'session' } +} + +function capabilitiesForLifecycle( + capabilities: AgentEnvironmentCapabilities, + lifecycle: SandboxLifecyclePolicy, +): AgentEnvironmentCapabilities { + if (lifecycle.continuity === 'session') return capabilities + return { + ...capabilities, + sessions: { ...capabilities.sessions, continue: false, list: false, messages: false }, + branching: { ...capabilities.branching, checkpoint: false, fork: false }, + } +} + +function providerSessionFor( + input: ExecuteTurnInput, + capabilities: AgentEnvironmentCapabilities, +): string | undefined { + if (!capabilities.sessions.continue) return undefined + return input.sessionId ?? `session-braid-${safeId(input.runId)}` +} + +function safeId(value: string): string { + const safe = value.replace(/[^A-Za-z0-9._:-]/gu, '-') + return safe.slice(0, 128) || 'run' +} + +async function requiredModel( + profile: Readonly, + override: string | undefined, + connectionId: Parameters[0]['id'], +): Promise { + const { cleanModelId } = await import('@tangle-network/agent-runtime') + const model = cleanModelId(override ?? profile.model?.default) + if (!model) { + throw new ConnectionError( + override === undefined ? 'CONNECTION_MODEL_REQUIRED' : 'CONNECTION_MODEL_INVALID', + 'The selected connection requires a non-empty model id', + { connectionId }, + ) + } + return model +} + +function requiredWorkspaceCwd( + inputRoot: string | undefined, + configuredRoot: string | undefined, +): string { + const workspace = inputRoot ?? configuredRoot + if (workspace === undefined || workspace.length === 0) { + throw new ConnectionError( + 'CONNECTION_WORKSPACE_REQUIRED', + 'The production execution path requires the canonical workspace root', + ) + } + return workspace +} + +async function sandboxBackendType( + profile: Readonly, + override: HarnessType | undefined, + configured: BackendType | undefined, + connectionId: Parameters[0]['id'], +): Promise { + const requested = override ?? profile.harness ?? configured ?? 'opencode' + try { + const { parseBackendType } = await import('@tangle-network/sandbox') + return parseBackendType(requested) + } catch { + throw new ConnectionError( + 'CONNECTION_UNSUPPORTED', + 'The selected harness is not supported by the published sandbox package', + { connectionId }, + ) + } +} + +function freezeDeep(value: T): T { + if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) { + for (const child of Object.values(value)) freezeDeep(child) + Object.freeze(value) + } + return value +} + +function inputRecord( + connectionId: Parameters[0]['id'], + options: ProductionBackendResolverOptions, +) { + const record = options.connections.get(connectionId) + if (!record) { + throw new ConnectionError('CONNECTION_NOT_FOUND', 'The selected connection does not exist', { + connectionId, + }) + } + return record +} diff --git a/src/adapters/runtime/supervisor-control.ts b/src/adapters/runtime/supervisor-control.ts new file mode 100644 index 0000000..fddbf6e --- /dev/null +++ b/src/adapters/runtime/supervisor-control.ts @@ -0,0 +1,126 @@ +import { type RootHandle, writeWorkerSteer } from '@tangle-network/agent-runtime/kernel' +import type { WorkerView } from '@tangle-network/agent-runtime/tui' +import { AGENT_RUNTIME_VERSION } from './agent-runtime-version.js' +import { type RuntimeSupervisorSnapshotPort, RuntimeSupervisorWatcher } from './supervisor-watch.js' + +export { AGENT_RUNTIME_VERSION } + +export interface SupervisorCapabilityIssue { + readonly capability: string + readonly packageName: string + readonly packageVersion: string + readonly reason: string + readonly reproduction: string +} + +export interface SupervisorWorkerSteerResult { + readonly status: 'queued' | 'unavailable' + readonly worker: string + readonly requestId?: string + readonly file?: string + readonly issue?: SupervisorCapabilityIssue +} + +export interface SupervisorWorkerCancelResult { + readonly status: 'unavailable' + readonly worker: string + readonly issue: SupervisorCapabilityIssue +} + +export interface SupervisorCancelResult { + readonly status: 'accepted' | 'unavailable' + readonly issue?: SupervisorCapabilityIssue +} + +export const WORKER_CANCEL_UNAVAILABLE: SupervisorCapabilityIssue = { + capability: 'supervisor.worker.cancel', + packageName: '@tangle-network/agent-runtime', + packageVersion: AGENT_RUNTIME_VERSION, + reason: + 'The published runtime exposes RootHandle.abort for the whole supervisor and writeWorkerSteer for a worker inbox, but no worker-scoped cancellation method.', + reproduction: + "import { writeWorkerSteer } from '@tangle-network/agent-runtime/kernel'; import { loadTopSnapshot } from '@tangle-network/agent-runtime/tui'; console.log(Object.keys({ writeWorkerSteer, loadTopSnapshot }));", +} + +function missingWorkerIssue(worker: string): SupervisorCapabilityIssue { + return { + capability: 'supervisor.worker.resolve', + packageName: '@tangle-network/agent-runtime', + packageVersion: AGENT_RUNTIME_VERSION, + reason: `Runtime snapshot contains no worker with id or label '${worker}'`, + reproduction: + "import { loadTopSnapshot } from '@tangle-network/agent-runtime/tui'; console.log(loadTopSnapshot(rootDir).supervisors.flatMap((supervisor) => supervisor.workers));", + } +} + +function externalCancelIssue(): SupervisorCapabilityIssue { + return { + capability: 'supervisor.cancel.external', + packageName: '@tangle-network/agent-runtime', + packageVersion: AGENT_RUNTIME_VERSION, + reason: + 'The published runtime monitor can reload persisted state and enqueue worker steering, but it does not expose an external root cancellation operation.', + reproduction: + "import * as runtime from '@tangle-network/agent-runtime/kernel'; console.log('RootHandle.abort exists only on an in-process handle', runtime);", + } +} + +function findWorker(workers: readonly WorkerView[], target: string): WorkerView | undefined { + return workers.find((worker) => worker.id === target || worker.label === target) +} + +export class RuntimeSupervisorController { + readonly #watcher: RuntimeSupervisorSnapshotPort + readonly #rootHandle: RootHandle | undefined + readonly #write: typeof writeWorkerSteer + + constructor( + options: { + readonly watcher?: RuntimeSupervisorSnapshotPort + readonly rootHandle?: RootHandle + readonly write?: typeof writeWorkerSteer + } = {}, + ) { + this.#watcher = options.watcher ?? new RuntimeSupervisorWatcher() + this.#rootHandle = options.rootHandle + this.#write = options.write ?? writeWorkerSteer + } + + steerWorker( + rootDir: string, + supervisorId: string, + workerIdOrLabel: string, + message: string, + source = 'braid', + ): SupervisorWorkerSteerResult { + const snapshot = this.#watcher.snapshot(rootDir) + const supervisor = snapshot.supervisors.find((candidate) => candidate.id === supervisorId) + const worker = + supervisor === undefined ? undefined : findWorker(supervisor.workers, workerIdOrLabel) + if (worker === undefined) { + return { + status: 'unavailable', + worker: workerIdOrLabel, + issue: missingWorkerIssue(workerIdOrLabel), + } + } + const result = this.#write(rootDir, supervisorId, worker.label, message, source) + return { + status: 'queued', + worker: result.worker, + requestId: result.request.id, + file: result.file, + } + } + + cancelWorker(worker: string): SupervisorWorkerCancelResult { + return { status: 'unavailable', worker, issue: WORKER_CANCEL_UNAVAILABLE } + } + + cancelSupervisor(reason = 'cancelled by user'): SupervisorCancelResult { + if (this.#rootHandle === undefined) + return { status: 'unavailable', issue: externalCancelIssue() } + this.#rootHandle.abort(reason) + return { status: 'accepted' } + } +} diff --git a/src/adapters/runtime/supervisor-watch.ts b/src/adapters/runtime/supervisor-watch.ts new file mode 100644 index 0000000..c6d47d0 --- /dev/null +++ b/src/adapters/runtime/supervisor-watch.ts @@ -0,0 +1,66 @@ +import { + loadTopSnapshot, + type SupervisorView, + type TopSnapshot, + type WorkerView, +} from '@tangle-network/agent-runtime/tui' + +export interface SupervisorWatchOptions { + readonly intervalMs?: number + readonly signal?: AbortSignal + readonly now?: () => number +} + +export interface RuntimeSupervisorSnapshotPort { + readonly snapshot: (rootDir: string, now?: number) => TopSnapshot + readonly watch: ( + rootDir: string, + options?: SupervisorWatchOptions, + ) => AsyncGenerator + readonly reconnect: (rootDir: string, now?: number) => TopSnapshot +} + +function waitForNextSnapshot(ms: number, signal: AbortSignal | undefined): Promise { + if (signal?.aborted) return Promise.resolve(false) + return new Promise((resolve) => { + const timer = setTimeout(() => { + signal?.removeEventListener('abort', onAbort) + resolve(true) + }, ms) + const onAbort = () => { + clearTimeout(timer) + signal?.removeEventListener('abort', onAbort) + resolve(false) + } + signal?.addEventListener('abort', onAbort, { once: true }) + }) +} + +export class RuntimeSupervisorWatcher implements RuntimeSupervisorSnapshotPort { + readonly #load: (rootDir: string, now?: number) => TopSnapshot + + constructor(load: (rootDir: string, now?: number) => TopSnapshot = loadTopSnapshot) { + this.#load = load + } + + snapshot(rootDir: string, now?: number): TopSnapshot { + return this.#load(rootDir, now) + } + + reconnect(rootDir: string, now?: number): TopSnapshot { + return this.snapshot(rootDir, now) + } + + async *watch( + rootDir: string, + options: SupervisorWatchOptions = {}, + ): AsyncGenerator { + const intervalMs = Math.max(25, options.intervalMs ?? 250) + while (!options.signal?.aborted) { + yield this.snapshot(rootDir, options.now?.()) + if (!(await waitForNextSnapshot(intervalMs, options.signal))) return + } + } +} + +export type { SupervisorView, TopSnapshot, WorkerView } diff --git a/src/adapters/runtime/unavailable-execution.ts b/src/adapters/runtime/unavailable-execution.ts new file mode 100644 index 0000000..2384918 --- /dev/null +++ b/src/adapters/runtime/unavailable-execution.ts @@ -0,0 +1,31 @@ +import type { ExecuteTurnInput, ExecutionAdmission, ExecutionPort } from '../../ports/execution.js' + +export class ExecutionUnavailableError extends Error { + readonly code = 'CONNECTION_REQUIRED' + + constructor() { + super('Configure a connection before starting a run') + this.name = 'ExecutionUnavailableError' + } +} + +export class UnavailableExecutionPort implements ExecutionPort { + readonly admissionMode = 'sync' as const + readonly capabilities = { cancel: false as const } + + admit(_input: ExecuteTurnInput): ExecutionAdmission { + throw new ExecutionUnavailableError() + } + + streamTurn(_input: ExecuteTurnInput): AsyncIterable { + return { + [Symbol.asyncIterator](): AsyncIterator { + return { + next: async () => { + throw new ExecutionUnavailableError() + }, + } + }, + } + } +} diff --git a/src/adapters/storage/memory-base.ts b/src/adapters/storage/memory-base.ts new file mode 100644 index 0000000..f8f8cc3 --- /dev/null +++ b/src/adapters/storage/memory-base.ts @@ -0,0 +1,148 @@ +import { createHash } from 'node:crypto' +import { canonicalDigest, canonicalJson } from '../../domain/canonical.js' +import type { + ConversationId, + JsonValue, + MissingHistory, + OperationId, + OperationRecord, + ProjectionRun, + ProjectionSnapshot, + RunId, + StoredJournalEvent, +} from '../../ports/storage.js' +import { PROJECTION_SCHEMA_VERSION } from '../../ports/storage.js' +import { StorageError } from './sqlite-errors.js' + +export interface MemoryCursor { + readonly runId: RunId + readonly conversationId: ConversationId + lastSequence: number + lastCursor: string | null + missingFrom: number | null + missingTo: number | null + terminal: boolean +} + +export interface MemorySnapshot { + readonly events: readonly StoredJournalEvent[] + readonly cursors: readonly MemoryCursor[] + readonly operations: readonly OperationRecord[] + readonly effects: readonly [string, import('../../ports/effect-storage.js').EffectRecord[]][] +} + +export function clone(value: T): T { + return structuredClone(value) +} + +export function jsonValue(value: unknown): JsonValue { + return JSON.parse(canonicalJson(value)) as JsonValue +} + +export function missing(cursor: MemoryCursor): MissingHistory | null { + if (cursor.missingFrom === null || cursor.missingTo === null) return null + return { runId: cursor.runId, fromSequence: cursor.missingFrom, toSequence: cursor.missingTo } +} + +export function refreshCursor(cursor: MemoryCursor, events: readonly StoredJournalEvent[]): void { + const runEvents = events + .filter((event) => event.runId === cursor.runId) + .sort((left, right) => left.sequence - right.sequence) + const sequences = runEvents.map((event) => event.sequence) + const maximum = Math.max(...sequences, 0) + const present = new Set(sequences) + let contiguous = 0 + while (present.has(contiguous + 1)) contiguous += 1 + const contiguousEvent = runEvents.find((event) => event.sequence === contiguous) + cursor.lastSequence = contiguous + cursor.lastCursor = contiguousEvent?.cursor ?? null + cursor.missingFrom = contiguous < maximum ? contiguous + 1 : null + cursor.missingTo = contiguous < maximum ? maximum : null +} + +export function projectionOf( + events: readonly StoredJournalEvent[], + cursors: readonly MemoryCursor[], +): ProjectionSnapshot { + const runs: ProjectionRun[] = cursors + .map((cursor) => ({ + runId: cursor.runId, + conversationId: cursor.conversationId, + lastSequence: cursor.lastSequence, + lastCursor: cursor.lastCursor, + missingFrom: cursor.missingFrom, + missingTo: cursor.missingTo, + terminal: cursor.terminal, + })) + .sort((left, right) => left.runId.localeCompare(right.runId)) + const base = { + schemaVersion: PROJECTION_SCHEMA_VERSION, + eventCount: events.length, + revision: events.length, + eventIds: events.map((event) => event.eventId), + runs, + } + let eventIdsDigest = createHash('sha256').update('braid-projection-events-v1').digest('hex') + for (const eventId of base.eventIds) { + eventIdsDigest = createHash('sha256').update(`${eventIdsDigest}\u0000${eventId}`).digest('hex') + } + const runsDigest = (() => { + const aggregate = Buffer.alloc(32) + for (const run of base.runs) { + const digest = Buffer.from(canonicalDigest(run), 'hex') + for (let index = 0; index < aggregate.length; index += 1) { + aggregate[index] = (aggregate[index] ?? 0) ^ (digest[index] ?? 0) + } + } + return aggregate.toString('hex') + })() + return { + ...base, + checksum: canonicalDigest({ + schemaVersion: base.schemaVersion, + eventCount: base.eventCount, + revision: base.revision, + eventIdsDigest, + runsDigest, + }), + } +} + +export class MemoryStorageBase { + protected readonly eventStore: StoredJournalEvent[] = [] + protected readonly cursorStore = new Map() + protected readonly operationStore = new Map() + protected readonly snapshotStore = new Map() + protected readonly effectStore = new Map< + string, + import('../../ports/effect-storage.js').EffectRecord[] + >() + protected projectionState: ProjectionSnapshot = projectionOf([], []) + protected isClosed = false + + protected snapshot(): MemorySnapshot { + return { + events: clone(this.eventStore), + cursors: clone([...this.cursorStore.values()]), + operations: clone([...this.operationStore.values()]), + effects: clone([...this.effectStore.entries()]), + } + } + + protected restoreSnapshot(snapshot: MemorySnapshot): void { + this.eventStore.splice(0, this.eventStore.length, ...clone(snapshot.events)) + this.cursorStore.clear() + for (const cursor of snapshot.cursors) this.cursorStore.set(cursor.runId, clone(cursor)) + this.operationStore.clear() + for (const operation of snapshot.operations) + this.operationStore.set(operation.operationId, clone(operation)) + this.effectStore.clear() + for (const [operationId, records] of snapshot.effects) + this.effectStore.set(operationId, clone(records)) + this.projectionState = projectionOf(this.eventStore, [...this.cursorStore.values()]) + } + + protected assertOpen(): void { + if (this.isClosed) throw new StorageError('STORAGE_CLOSED', 'Memory storage is closed') + } +} diff --git a/src/adapters/storage/memory-effects.ts b/src/adapters/storage/memory-effects.ts new file mode 100644 index 0000000..bcf1174 --- /dev/null +++ b/src/adapters/storage/memory-effects.ts @@ -0,0 +1,58 @@ +import { assertEffectRecordInput } from './storage-validation.js' +import { clone } from './memory-base.js' + +import { MemoryRetentionStorage } from './memory-retention.js' + +export class MemoryEffectsStorage extends MemoryRetentionStorage { + reserveEffect(record: import('../../ports/effect-storage.js').EffectRecord): { + readonly record: import('../../ports/effect-storage.js').EffectRecord + readonly created: boolean + } { + this.assertOpen() + assertEffectRecordInput(record) + const current = this.current(record.operationId) + if (current !== undefined) { + if (current.requestDigest === record.requestDigest) { + return { record: current, created: false } + } + const conflict: import('../../ports/effect-storage.js').EffectRecord = { + ...record, + status: 'conflict', + detail: `Operation is already bound to request digest ${current.requestDigest}`, + conflictWithDigest: current.requestDigest, + } + this.appendEffect(conflict) + return { record: clone(conflict), created: false } + } + this.appendEffect(record) + return { record: clone(record), created: true } + } + + current(operationId: string): import('../../ports/effect-storage.js').EffectRecord | undefined { + const records = this.effectStore.get(operationId) ?? [] + const record = [...records].reverse().find((candidate) => candidate.status !== 'conflict') + return record === undefined ? undefined : clone(record) + } + + latest( + operationId: string, + requestDigest: string, + ): import('../../ports/effect-storage.js').EffectRecord | undefined { + const records = this.effectStore.get(operationId) ?? [] + const record = [...records] + .reverse() + .find((candidate) => candidate.requestDigest === requestDigest) + return record === undefined ? undefined : clone(record) + } + + appendEffect(record: import('../../ports/effect-storage.js').EffectRecord): void { + assertEffectRecordInput(record) + const records = this.effectStore.get(record.operationId) ?? [] + records.push(clone(record)) + this.effectStore.set(record.operationId, records) + } + + history(operationId: string): readonly import('../../ports/effect-storage.js').EffectRecord[] { + return clone(this.effectStore.get(operationId) ?? []) + } +} diff --git a/src/adapters/storage/memory-journal.ts b/src/adapters/storage/memory-journal.ts new file mode 100644 index 0000000..ebc775c --- /dev/null +++ b/src/adapters/storage/memory-journal.ts @@ -0,0 +1,188 @@ +import type { + AppendResult, + ConversationId, + EventId, + JournalEvent, + MissingHistory, + ProjectionSnapshot, + ReplayResult, + RunId, + StoredJournalEvent, + WorkspaceId, +} from '../../ports/storage.js' +import { assertPersistablePayload, payloadChecksum } from './sqlite-crypto.js' +import { StorageError } from './sqlite-errors.js' +import { assertJournalEventInput } from './storage-validation.js' +import { + MemoryStorageBase, + clone, + missing, + projectionOf, + refreshCursor, + type MemoryCursor, +} from './memory-base.js' + +export class MemoryJournalStorage extends MemoryStorageBase { + async append(events: readonly JournalEvent[]): Promise { + this.assertOpen() + const acceptedEventIds: EventId[] = [] + const duplicateEventIds: EventId[] = [] + const missingRuns = new Set() + for (const event of events) { + assertJournalEventInput(event) + assertPersistablePayload(event.payload) + const checksum = payloadChecksum(event.payload) + const existing = this.eventStore.find( + (candidate) => candidate.runId === event.runId && candidate.eventId === event.eventId, + ) + if (existing) { + const sameImmutableInput = + existing.workspaceId === event.workspaceId && + existing.conversationId === event.conversationId && + existing.runId === event.runId && + existing.eventId === event.eventId && + (existing.providerEventId ?? null) === (event.providerEventId ?? null) && + existing.sequence === event.sequence && + existing.kind === event.kind && + (existing.cursor ?? null) === (event.cursor ?? null) && + (existing.operationId ?? null) === (event.operationId ?? null) && + existing.payloadChecksum === checksum && + existing.occurredAt === event.occurredAt && + (event.receivedAt === undefined || existing.receivedAt === event.receivedAt) && + existing.terminal === (event.terminal === true) + if (!sameImmutableInput) { + throw new StorageError( + 'EVENT_ID_CONFLICT', + `Event ${event.eventId} changed its durable input on retry`, + ) + } + duplicateEventIds.push(event.eventId) + continue + } + const sequenceExisting = this.eventStore.find( + (candidate) => candidate.runId === event.runId && candidate.sequence === event.sequence, + ) + if (sequenceExisting && sequenceExisting.eventId !== event.eventId) { + throw new StorageError( + 'SEQUENCE_CONFLICT', + `Sequence ${event.sequence} is already assigned for run ${event.runId}`, + ) + } + const cursor = this.cursorStore.get(event.runId) + const lastSequence = cursor?.lastSequence ?? 0 + if (cursor && cursor.conversationId !== event.conversationId) { + throw new StorageError( + 'RUN_CONVERSATION_CONFLICT', + `Run ${event.runId} belongs to another conversation`, + ) + } + const isMissingSequence = + cursor?.missingFrom !== null && + cursor?.missingFrom !== undefined && + cursor?.missingTo !== null && + cursor?.missingTo !== undefined && + event.sequence >= cursor.missingFrom && + event.sequence <= cursor.missingTo + if (cursor?.terminal && !isMissingSequence) { + throw new StorageError('TERMINAL_RUN_MUTATION', `Run ${event.runId} is terminal`) + } + const next: MemoryCursor = cursor ?? { + runId: event.runId, + conversationId: event.conversationId, + lastSequence: 0, + lastCursor: null, + missingFrom: null, + missingTo: null, + terminal: false, + } + const stored: StoredJournalEvent = { + workspaceId: event.workspaceId, + conversationId: event.conversationId, + runId: event.runId, + eventId: event.eventId, + ...(event.providerEventId === undefined ? {} : { providerEventId: event.providerEventId }), + sequence: event.sequence, + kind: event.kind, + payload: clone(event.payload), + payloadState: 'available', + payloadChecksum: checksum, + occurredAt: event.occurredAt, + receivedAt: event.receivedAt ?? new Date().toISOString(), + ...(event.cursor === undefined ? {} : { cursor: event.cursor }), + ...(event.operationId === undefined ? {} : { operationId: event.operationId }), + terminal: event.terminal === true, + redacted: false, + } + this.eventStore.push(stored) + next.lastCursor = + event.sequence >= lastSequence ? (event.cursor ?? next.lastCursor) : next.lastCursor + next.terminal ||= event.terminal === true + refreshCursor(next, this.eventStore) + this.cursorStore.set(event.runId, next) + if (next.missingFrom !== null) missingRuns.add(event.runId) + acceptedEventIds.push(event.eventId) + } + this.projectionState = projectionOf(this.eventStore, [...this.cursorStore.values()]) + return { + acceptedEventIds, + duplicateEventIds, + missingHistory: [...missingRuns] + .map((runId) => this.cursorStore.get(runId)) + .filter((cursor): cursor is MemoryCursor => cursor !== undefined) + .map(missing) + .filter((range): range is MissingHistory => range !== null), + projectionChecksum: this.projectionState.checksum, + } + } + + async replay(input: { + readonly runId: RunId + readonly afterSequence?: number + }): Promise { + this.assertOpen() + const afterSequence = input.afterSequence ?? 0 + const cursor = this.cursorStore.get(input.runId) + const events = this.eventStore + .filter((event) => event.runId === input.runId && event.sequence > afterSequence) + .sort((left, right) => left.sequence - right.sequence) + const range = cursor === undefined ? null : missing(cursor) + return { + events: clone(events), + complete: range === null, + missingHistory: range === null ? [] : [range], + lastSequence: cursor?.lastSequence ?? 0, + ...(cursor?.lastCursor === null || cursor?.lastCursor === undefined + ? {} + : { lastCursor: cursor.lastCursor }), + } + } + + async events( + input: { + readonly workspaceId?: WorkspaceId + readonly conversationId?: ConversationId + readonly runId?: RunId + readonly afterStorageId?: number + } = {}, + ): Promise { + this.assertOpen() + return clone( + this.eventStore.filter( + (event) => + (input.workspaceId === undefined || event.workspaceId === input.workspaceId) && + (input.conversationId === undefined || event.conversationId === input.conversationId) && + (input.runId === undefined || event.runId === input.runId), + ), + ) + } + + async projection(): Promise { + this.assertOpen() + return clone(this.projectionState) + } + + async projectionChecksum(): Promise { + this.assertOpen() + return this.projectionState.checksum + } +} diff --git a/src/adapters/storage/memory-maintenance.ts b/src/adapters/storage/memory-maintenance.ts new file mode 100644 index 0000000..ea8f404 --- /dev/null +++ b/src/adapters/storage/memory-maintenance.ts @@ -0,0 +1,66 @@ +import { canonicalJson } from '../../domain/canonical.js' +import type { + BackupReport, + MigrationReport, + OperationIntent, + RestoreReport, +} from '../../ports/storage.js' +import { PROJECTION_SCHEMA_VERSION } from '../../ports/storage.js' +import { StorageError } from './sqlite-errors.js' +import { assertOperationRequestDigest } from './storage-validation.js' +import { jsonValue } from './memory-base.js' + +import { MemoryOperationStorage } from './memory-operations.js' + +export class MemoryMaintenanceStorage extends MemoryOperationStorage { + async migrate(operation: OperationIntent): Promise { + this.assertOpen() + assertOperationRequestDigest(operation, {}) + const replay = await this.reuseMutation(operation) + if (replay !== undefined) return replay + const result = { + fromVersion: PROJECTION_SCHEMA_VERSION, + toVersion: PROJECTION_SCHEMA_VERSION, + migrated: false, + } + await this.completeMutation(operation, 'terminal', result) + return result + } + + async backup(input: { + readonly path: string + readonly operation: OperationIntent + }): Promise { + this.assertOpen() + assertOperationRequestDigest(input.operation, { path: input.path }) + const replay = await this.reuseMutation(input.operation) + if (replay !== undefined) return replay + this.snapshotStore.set(input.path, this.snapshot()) + const result = { + path: input.path, + bytes: Buffer.byteLength(canonicalJson(this.snapshot())), + encrypted: false, + } + await this.completeMutation(input.operation, 'terminal', jsonValue(result)) + return result + } + + async restore(input: { + readonly path: string + readonly operation: OperationIntent + }): Promise { + this.assertOpen() + assertOperationRequestDigest(input.operation, { path: input.path }) + const replay = await this.reuseMutation(input.operation) + if (replay !== undefined) return replay + const snapshot = this.snapshotStore.get(input.path) + if (!snapshot) { + await this.completeMutation(input.operation, 'failed', { code: 'BACKUP_NOT_FOUND' }) + throw new StorageError('BACKUP_NOT_FOUND', `Memory backup ${input.path} was not found`) + } + this.restoreSnapshot(snapshot) + const result = { path: input.path, restored: true, integrity: await this.integrity() } + await this.completeMutation(input.operation, 'terminal', jsonValue(result)) + return result + } +} diff --git a/src/adapters/storage/memory-operations.ts b/src/adapters/storage/memory-operations.ts new file mode 100644 index 0000000..860163a --- /dev/null +++ b/src/adapters/storage/memory-operations.ts @@ -0,0 +1,227 @@ +import { parseOperationId } from '../../domain/ids.js' +import { isCanonicalIsoDateTime } from '../../domain/text.js' +import type { + IntegrityReport, + JsonValue, + OperationId, + OperationIntent, + OperationRecord, + OperationReservation, + ProjectionSnapshot, +} from '../../ports/storage.js' +import { PROJECTION_SCHEMA_VERSION } from '../../ports/storage.js' +import { assertPersistablePayload } from './sqlite-crypto.js' +import { StorageError } from './sqlite-errors.js' +import { assertOperationIntentInput, assertOperationRequestDigest } from './storage-validation.js' +import { clone, jsonValue, projectionOf } from './memory-base.js' + +import { MemoryJournalStorage } from './memory-journal.js' + +export class MemoryOperationStorage extends MemoryJournalStorage { + async rebuild(operation: OperationIntent): Promise { + this.assertOpen() + assertOperationRequestDigest(operation, {}) + const replay = await this.reuseMutation(operation) + if (replay !== undefined) return replay + this.projectionState = projectionOf(this.eventStore, [...this.cursorStore.values()]) + const result = clone(this.projectionState) + await this.completeMutation(operation, 'terminal', jsonValue(result)) + return result + } + + async reserveOperation(intent: OperationIntent): Promise { + this.assertOpen() + assertOperationIntentInput(intent) + assertPersistablePayload(intent.request) + const existing = this.operationStore.get(intent.operationId) + if (existing) { + if (existing.requestDigest !== intent.requestDigest) { + const conflict = { + ...existing, + status: 'conflict' as const, + updatedAt: new Date().toISOString(), + } + return { record: clone(conflict), created: false } + } + return { record: clone(existing), created: false } + } + const createdAt = intent.createdAt ?? new Date().toISOString() + const record: OperationRecord = { + ...intent, + status: 'pending', + createdAt, + updatedAt: createdAt, + } + this.operationStore.set(intent.operationId, record) + return { record: clone(record), created: true } + } + + async completeOperation(input: { + readonly operationId: OperationId + readonly requestDigest: string + readonly status: Exclude + readonly result?: JsonValue + readonly updatedAt?: string + }): Promise { + this.assertOpen() + parseOperationId(input.operationId) + if (!/^[0-9a-f]{64}$/u.test(input.requestDigest)) { + throw new StorageError( + 'OPERATION_INVALID', + 'Operation request digest must be a SHA-256 digest', + ) + } + if (input.updatedAt !== undefined && !isCanonicalIsoDateTime(input.updatedAt)) { + throw new StorageError( + 'OPERATION_INVALID', + 'Operation updatedAt must be a canonical ISO date', + ) + } + const current = this.operationStore.get(input.operationId) + if (!current) + throw new StorageError( + 'OPERATION_NOT_FOUND', + `Operation ${input.operationId} was not reserved`, + ) + if (current.requestDigest !== input.requestDigest) { + const conflict = { + ...current, + status: 'conflict' as const, + updatedAt: input.updatedAt ?? new Date().toISOString(), + } + return clone(conflict) + } + if (current.status !== 'pending' && current.status !== 'unknown') return clone(current) + if (input.result !== undefined) assertPersistablePayload(input.result) + const next: OperationRecord = { + ...current, + status: input.status, + updatedAt: input.updatedAt ?? new Date().toISOString(), + ...(input.result === undefined ? {} : { result: clone(input.result) }), + } + this.operationStore.set(input.operationId, next) + return clone(next) + } + + async recordOperationConflict(input: { + readonly operationId: OperationId + readonly requestDigest: string + readonly attemptedDigest: string + readonly occurredAt?: string + }): Promise { + this.assertOpen() + parseOperationId(input.operationId) + if ( + !/^[0-9a-f]{64}$/u.test(input.requestDigest) || + !/^[0-9a-f]{64}$/u.test(input.attemptedDigest) + ) { + throw new StorageError( + 'OPERATION_INVALID', + 'Operation conflict digests must be SHA-256 digests', + ) + } + if (input.requestDigest === input.attemptedDigest) { + throw new StorageError('OPERATION_INVALID', 'A conflict requires a different digest') + } + if (input.occurredAt !== undefined && !isCanonicalIsoDateTime(input.occurredAt)) { + throw new StorageError( + 'OPERATION_INVALID', + 'Operation conflict occurredAt must be a canonical ISO date', + ) + } + const current = this.operationStore.get(input.operationId) + if (!current) { + throw new StorageError( + 'OPERATION_NOT_FOUND', + `Operation ${input.operationId} was not reserved`, + ) + } + if (current.requestDigest !== input.requestDigest) { + throw new StorageError( + 'OPERATION_CONFLICT', + `Operation ${input.operationId} is bound to another request digest`, + ) + } + } + + async operation(operationId: OperationId): Promise { + this.assertOpen() + const record = this.operationStore.get(operationId) + return record === undefined ? null : clone(record) + } + + async integrity(): Promise { + this.assertOpen() + return { + ok: true, + encryption: 'not-applicable', + quickCheck: true, + fullCheck: true, + foreignKeys: true, + wal: false, + schemaVersion: PROJECTION_SCHEMA_VERSION, + errors: [], + } + } + + async reuseMutation(intent: OperationIntent): Promise { + const reservation = await this.reserveOperation(intent) + if (reservation.record.status === 'conflict') { + throw new StorageError( + 'OPERATION_CONFLICT', + `Operation ${intent.operationId} was reused with a different request digest`, + ) + } + if (!reservation.created && reservation.record.status === 'failed') { + throw new StorageError( + 'OPERATION_FAILED_REPLAY', + `Operation ${intent.operationId} already failed; use a new operation identifier to retry`, + ) + } + if (!reservation.created && reservation.record.status === 'unknown') { + throw new StorageError( + 'OPERATION_OUTCOME_UNKNOWN', + `Operation ${intent.operationId} has an unknown outcome and requires reconciliation`, + ) + } + if ( + !reservation.created && + (reservation.record.status === 'terminal' || reservation.record.status === 'acknowledged') + ) { + if (reservation.record.result === undefined) + throw new StorageError( + 'OPERATION_RESULT_MISSING', + `Operation ${intent.operationId} has no stored result`, + ) + return clone(reservation.record.result) as T + } + return undefined + } + + async completeMutation( + intent: OperationIntent, + status: 'terminal' | 'failed' | 'unknown', + result: JsonValue, + ): Promise { + await this.completeOperation({ + operationId: intent.operationId, + requestDigest: intent.requestDigest, + status, + result, + }) + } + + async completeMutationFailure(intent: OperationIntent, error: unknown): Promise { + const result: JsonValue = { + code: error instanceof StorageError ? error.code : 'STORAGE_MUTATION_FAILED', + message: error instanceof StorageError ? error.code : 'Storage mutation failed', + } + try { + await this.completeMutation(intent, 'failed', result) + } catch { + await this.completeMutation(intent, 'unknown', { + code: 'STORAGE_MUTATION_OUTCOME_UNKNOWN', + }).catch(() => undefined) + } + } +} diff --git a/src/adapters/storage/memory-retention.ts b/src/adapters/storage/memory-retention.ts new file mode 100644 index 0000000..e9e1b31 --- /dev/null +++ b/src/adapters/storage/memory-retention.ts @@ -0,0 +1,180 @@ +import { canonicalDigest } from '../../domain/canonical.js' +import { credentialRef } from '../../ports/credentials.js' +import type { + ConversationId, + DestructionReport, + EventId, + NonTerminalRun, + OperationIntent, + RedactionReport, + RetentionReport, + StorageArtifacts, +} from '../../ports/storage.js' +import { payloadChecksum, tombstone } from './sqlite-crypto.js' +import { StorageError } from './sqlite-errors.js' +import { assertOperationRequestDigest } from './storage-validation.js' +import { missing, projectionOf } from './memory-base.js' + +import { MemoryMaintenanceStorage } from './memory-maintenance.js' + +export class MemoryRetentionStorage extends MemoryMaintenanceStorage { + async applyRetention(input: { + readonly before: string + readonly conversationId?: ConversationId + readonly operation: OperationIntent + }): Promise { + this.assertOpen() + assertOperationRequestDigest(input.operation, { + before: input.before, + ...(input.conversationId === undefined ? {} : { conversationId: input.conversationId }), + }) + const replay = await this.reuseMutation(input.operation) + if (replay !== undefined) return replay + let redactedEvents = 0 + for (let index = 0; index < this.eventStore.length; index += 1) { + const event = this.eventStore[index] + if ( + event && + !event.redacted && + event.receivedAt < input.before && + (input.conversationId === undefined || event.conversationId === input.conversationId) && + this.cursorStore.get(event.runId)?.terminal + ) { + this.eventStore[index] = { + ...event, + payload: tombstone('retention'), + payloadChecksum: payloadChecksum(tombstone('retention')), + payloadState: 'redacted', + redacted: true, + } + redactedEvents += 1 + } + } + this.projectionState = projectionOf(this.eventStore, [...this.cursorStore.values()]) + const result = { redactedEvents, deletedConversations: [] as ConversationId[] } + await this.completeMutation(input.operation, 'terminal', result) + return result + } + + async redact(input: { + readonly conversationId: ConversationId + readonly eventId: EventId + readonly reason: string + readonly operation: OperationIntent + }): Promise { + this.assertOpen() + assertOperationRequestDigest(input.operation, { + conversationId: input.conversationId, + eventId: input.eventId, + reasonDigest: canonicalDigest(input.reason), + }) + const replay = await this.reuseMutation(input.operation) + if (replay !== undefined) return replay + let rewrittenEvents = 0 + const target = this.eventStore.find( + (event) => event.conversationId === input.conversationId && event.eventId === input.eventId, + ) + if (target === undefined) { + const error = new StorageError('EVENT_NOT_FOUND', `Event ${input.eventId} was not found`) + await this.completeMutationFailure(input.operation, error) + throw error + } + for (let index = 0; index < this.eventStore.length; index += 1) { + const event = this.eventStore[index] + if (event?.conversationId !== input.conversationId) continue + const redacted = event.eventId === input.eventId + this.eventStore[index] = { + ...event, + ...(redacted + ? { + payload: tombstone(input.reason), + payloadChecksum: payloadChecksum(tombstone(input.reason)), + } + : {}), + ...(redacted ? { payloadState: 'redacted' as const, redacted: true } : {}), + } + rewrittenEvents += 1 + } + this.projectionState = projectionOf(this.eventStore, [...this.cursorStore.values()]) + const result = { + conversationId: input.conversationId, + redactedEventId: input.eventId, + rewrittenEvents, + newContentKeyRef: credentialRef(`cred:v1:memory-redacted-${input.conversationId}`), + } + await this.completeMutation(input.operation, 'terminal', result) + return result + } + + async destroyConversation(input: { + readonly conversationId: ConversationId + readonly reason: string + readonly operation: OperationIntent + }): Promise { + this.assertOpen() + assertOperationRequestDigest(input.operation, { + conversationId: input.conversationId, + reasonDigest: canonicalDigest(input.reason), + }) + const replay = await this.reuseMutation(input.operation) + if (replay !== undefined) return replay + const hasEvents = this.eventStore.some((event) => event.conversationId === input.conversationId) + if (!hasEvents) { + await this.completeMutation(input.operation, 'failed', { code: 'CONVERSATION_NOT_FOUND' }) + throw new StorageError( + 'CONVERSATION_NOT_FOUND', + `Conversation ${input.conversationId} was not found`, + ) + } + for (let index = 0; index < this.eventStore.length; index += 1) { + const event = this.eventStore[index] + if (event?.conversationId === input.conversationId) { + this.eventStore[index] = { + ...event, + payload: tombstone(input.reason), + payloadChecksum: payloadChecksum(tombstone(input.reason)), + payloadState: 'deleted', + redacted: true, + tombstoneReason: canonicalDigest(input.reason), + } + } + } + this.projectionState = projectionOf(this.eventStore, [...this.cursorStore.values()]) + const result = { + conversationId: input.conversationId, + destroyed: true, + retainedCiphertext: true, + } + await this.completeMutation(input.operation, 'terminal', result) + return result + } + + async compact(operation: OperationIntent): Promise { + this.assertOpen() + assertOperationRequestDigest(operation, {}) + const replay = await this.reuseMutation<{ readonly completed: true }>(operation) + if (replay !== undefined) return + await this.completeMutation(operation, 'terminal', { completed: true }) + } + + artifacts(): StorageArtifacts { + return { database: ':memory:', wal: ':memory:-wal', sharedMemory: ':memory:-shm', backups: [] } + } + + async reconcileNonTerminalRuns(): Promise { + this.assertOpen() + return [...this.cursorStore.values()] + .filter((cursor) => !cursor.terminal) + .map((cursor) => ({ + runId: cursor.runId, + conversationId: cursor.conversationId, + lastSequence: cursor.lastSequence, + lastCursor: cursor.lastCursor, + missingHistory: missing(cursor), + })) + } + + async close(): Promise { + this.isClosed = true + } +} diff --git a/src/adapters/storage/memory.ts b/src/adapters/storage/memory.ts new file mode 100644 index 0000000..7d2f8b3 --- /dev/null +++ b/src/adapters/storage/memory.ts @@ -0,0 +1,4 @@ +import { MemoryEffectsStorage } from './memory-effects.js' +import type { StoragePort } from '../../ports/storage.js' + +export class MemoryStorage extends MemoryEffectsStorage implements StoragePort {} diff --git a/src/adapters/storage/sqlite-base.ts b/src/adapters/storage/sqlite-base.ts new file mode 100644 index 0000000..a6ff892 --- /dev/null +++ b/src/adapters/storage/sqlite-base.ts @@ -0,0 +1,229 @@ +import { createHash, createHmac } from 'node:crypto' +import { closeSync } from 'node:fs' +import { canonicalJson } from '../../domain/canonical.js' +import type { CredentialPort, CredentialRef } from '../../ports/credentials.js' +import type { IntegrityReport, StorageArtifacts } from '../../ports/storage.js' +import type { SqliteDatabase, SqliteDatabaseFactory } from './sqlite-driver.js' +import { StorageError } from './sqlite-errors.js' +import { classifySqliteError } from './sqlite-paths.js' +import { BoundedWriteQueue } from './sqlite-queue.js' +import { acquireExclusiveLock } from './sqlite-recovery.js' +import { pragmaNumber, pragmaString } from './sqlite-schema.js' +import type { DurableBoundaryHook, MigrationHooks, SqliteStorageInput } from './sqlite-types.js' + +export abstract class SqliteStorageBase { + protected readonly path: string + protected readonly stateSnapshotScope: string + protected readonly workspaceRoot: string | undefined + protected readonly credentials: CredentialPort + protected readonly databaseKeyRef: CredentialRef + protected readonly databaseFactory: SqliteDatabaseFactory + protected readonly busyTimeoutMs: number + protected readonly maxEvents: number + protected readonly maxPayloadBytes: number + protected readonly backupDirectory: string + protected readonly migrationHooks: MigrationHooks | undefined + protected readonly durableBoundaryHook: DurableBoundaryHook | undefined + protected readonly writes: BoundedWriteQueue + protected database: SqliteDatabase + protected databaseFileDescriptor: number | undefined + protected exclusiveLock: { readonly release: () => Promise } | undefined + protected databaseKey: Buffer + protected latestBackupPath: string | undefined + protected closed = false + + protected constructor(input: SqliteStorageInput) { + this.path = input.path + this.stateSnapshotScope = `storage-${createHash('sha256').update(input.path).digest('hex').slice(0, 32)}` + this.workspaceRoot = input.workspaceRoot + this.credentials = input.credentials + this.databaseKeyRef = input.databaseKeyRef + this.databaseKey = Buffer.from(input.databaseKey) + this.databaseFactory = input.databaseFactory + this.busyTimeoutMs = input.busyTimeoutMs + this.maxEvents = input.maxEvents + this.maxPayloadBytes = input.maxPayloadBytes + this.backupDirectory = input.backupDirectory + this.migrationHooks = input.migrationHooks + this.durableBoundaryHook = input.durableBoundaryHook + this.writes = new BoundedWriteQueue(input.maxQueuedTransactions) + this.database = input.database + this.databaseFileDescriptor = input.databaseFileDescriptor + this.exclusiveLock = input.exclusiveLock + } + + fingerprint(input: { readonly effectKind: string; readonly request: unknown }): string { + return createHmac('sha256', this.databaseKey) + .update('braid-operation-fingerprint:v1\u0000') + .update(input.effectKind) + .update('\u0000') + .update(canonicalJson(input.request)) + .digest('hex') + } + + snapshotScopeId(): string { + return this.stateSnapshotScope + } + + artifacts(): StorageArtifacts { + return { + database: this.path, + wal: `${this.path}-wal`, + sharedMemory: `${this.path}-shm`, + backups: this.latestBackupPath ? [this.latestBackupPath] : [], + } + } + + async integrity(): Promise { + this.assertOpen() + return this.integrityReport() + } + + async close(): Promise { + if (this.closed) return + let failure: unknown + try { + await this.writes.drain() + } catch (error) { + failure = error + } + this.closed = true + try { + this.database.close() + } finally { + try { + if (this.databaseFileDescriptor !== undefined) { + closeSync(this.databaseFileDescriptor) + this.databaseFileDescriptor = undefined + } + this.databaseKey.fill(0) + } finally { + const lock = this.exclusiveLock + this.exclusiveLock = undefined + await lock?.release() + } + } + if (failure !== undefined) throw failure + } + + integrityReportFor(database: SqliteDatabase): IntegrityReport { + const errors: string[] = [] + let quickCheck = false + let fullCheck = false + try { + const quick = database.prepare('PRAGMA quick_check').all() as readonly Record< + string, + unknown + >[] + quickCheck = + quick.length > 0 && quick.every((row) => Object.values(row).some((value) => value === 'ok')) + } catch (error) { + errors.push(`quick_check failed: ${error instanceof Error ? error.message : String(error)}`) + } + try { + const full = database.prepare('PRAGMA integrity_check').all() as readonly Record< + string, + unknown + >[] + fullCheck = + full.length > 0 && full.every((row) => Object.values(row).some((value) => value === 'ok')) + } catch (error) { + errors.push( + `integrity_check failed: ${error instanceof Error ? error.message : String(error)}`, + ) + } + let foreignKeys = false + try { + foreignKeys = pragmaNumber(database, 'foreign_keys') === 1 + } catch { + errors.push('foreign_keys pragma is unavailable') + } + let wal = false + try { + wal = pragmaString(database, 'journal_mode')?.toLowerCase() === 'wal' + } catch { + errors.push('journal_mode pragma is unavailable') + } + let encryption: IntegrityReport['encryption'] = 'verified' + try { + const cipher = pragmaString(database, 'cipher') + if (cipher?.toLowerCase() !== 'sqlcipher') { + encryption = 'unavailable' + errors.push('SQLCipher mode is not active') + } + } catch { + encryption = 'unavailable' + errors.push('cipher pragma is unavailable') + } + return { + ok: + errors.length === 0 && + quickCheck && + fullCheck && + foreignKeys && + wal && + encryption === 'verified', + encryption, + quickCheck, + fullCheck, + foreignKeys, + wal, + schemaVersion: pragmaNumber(database, 'user_version'), + errors, + } + } + + integrityReport(): IntegrityReport { + return this.integrityReportFor(this.database) + } + + begin(): void { + this.assertOpen() + try { + this.database.exec('BEGIN IMMEDIATE') + } catch (error) { + throw classifySqliteError(error) + } + } + + commit(boundary: string): void { + this.durableBoundaryHook?.(`before:${boundary}`) + try { + this.database.exec('COMMIT') + } catch (error) { + throw classifySqliteError(error) + } + // The transaction is already durable. A crash injector may terminate here, + // but an observer error must not make callers roll back a committed write. + try { + this.durableBoundaryHook?.(`after:${boundary}`) + } catch { + // Boundary hooks are test-only observers and cannot alter commit status. + } + } + + rollback(): void { + try { + this.database.exec('ROLLBACK') + } catch { + // Preserve the original commit or transaction error. + } + } + + assertOpen(): void { + if (this.closed || !this.database.open) + throw new StorageError('STORAGE_CLOSED', 'SQLite storage is closed') + } + + async withExclusiveLock(operation: () => Promise): Promise { + // The storage lifetime lock already excludes every other process. Reusing + // it avoids a same-process self-deadlock during backup and restore. + if (this.exclusiveLock !== undefined) return operation() + const lock = await acquireExclusiveLock(`${this.path}.exclusive.lock`) + try { + return await operation() + } finally { + await lock.release() + } + } +} diff --git a/src/adapters/storage/sqlite-bound-open.ts b/src/adapters/storage/sqlite-bound-open.ts new file mode 100644 index 0000000..243727d --- /dev/null +++ b/src/adapters/storage/sqlite-bound-open.ts @@ -0,0 +1,194 @@ +import { constants, closeSync, fchmodSync, fstatSync, openSync, unlinkSync } from 'node:fs' +import { basename, dirname, resolve } from 'node:path' +import type { SqliteDatabase, SqliteDatabaseFactory } from './sqlite-driver.js' +import { StorageError } from './sqlite-errors.js' + +export interface BoundSqliteDatabase { + readonly database: SqliteDatabase + readonly fileDescriptor: number + readonly newDatabase: boolean +} + +const REQUIRED_PARENT_FLAGS = constants.O_DIRECTORY | (constants.O_NOFOLLOW ?? 0) +const REQUIRED_FILE_FLAGS = constants.O_RDWR | (constants.O_NOFOLLOW ?? 0) + +function unsupported(message: string): StorageError { + return new StorageError('STORAGE_PATH_RACE_UNSUPPORTED', message) +} + +function descriptorPath(fileDescriptor: number): string { + if (process.platform === 'linux') return `/proc/self/fd/${fileDescriptor}` + if (process.platform === 'darwin' || process.platform === 'freebsd') { + return `/dev/fd/${fileDescriptor}` + } + throw unsupported('This platform has no inode-bound SQLite descriptor path') +} + +function sameInode( + left: ReturnType, + right: ReturnType, +): boolean { + return left.dev === right.dev && left.ino === right.ino +} + +function assertOwnedDirectory(metadata: ReturnType, path: string): void { + if (!metadata.isDirectory()) throw new StorageError('STORAGE_PATH', `Not a directory: ${path}`) + if (typeof process.getuid === 'function' && metadata.uid !== process.getuid()) { + throw new StorageError( + 'STORAGE_OWNERSHIP', + `Storage directory is not owned by this process: ${path}`, + ) + } + if ((Number(metadata.mode) & 0o077) !== 0) + throw new StorageError('STORAGE_PERMISSIONS', `Storage directory is too permissive: ${path}`) +} + +function assertOwnedDatabase(metadata: ReturnType, path: string): void { + if (!metadata.isFile() || metadata.nlink !== 1) { + throw new StorageError( + 'STORAGE_INPUT_IDENTITY', + `SQLite path must be a single-linked regular file: ${path}`, + ) + } + if (typeof process.getuid === 'function' && metadata.uid !== process.getuid()) { + throw new StorageError('STORAGE_OWNERSHIP', `SQLite path is not owned by this process: ${path}`) + } +} + +function openDirectoryChain(path: string): number { + let descriptor = openSync('/', REQUIRED_PARENT_FLAGS) + try { + for (const component of resolve(path).split('/').filter(Boolean)) { + const next = openSync(`${descriptorPath(descriptor)}/${component}`, REQUIRED_PARENT_FLAGS) + closeSync(descriptor) + descriptor = next + } + return descriptor + } catch (error) { + closeSync(descriptor) + throw error + } +} + +function openDatabaseFile( + parentDescriptor: number, + path: string, +): { readonly fileDescriptor: number; readonly newDatabase: boolean } { + const boundPath = `${descriptorPath(parentDescriptor)}/${basename(path)}` + const createFlags = REQUIRED_FILE_FLAGS | constants.O_CREAT | constants.O_EXCL + try { + return { fileDescriptor: openSync(boundPath, createFlags, 0o600), newDatabase: true } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error + return { fileDescriptor: openSync(boundPath, REQUIRED_FILE_FLAGS), newDatabase: false } + } +} + +/** + * Opens SQLite through an inode-bound descriptor path. + * + * The native driver only accepts a filename, so the filename must refer to a + * descriptor we already opened with O_NOFOLLOW. The descriptor remains open + * until the owning storage closes the database. + */ +export function openBoundSqliteDatabase( + path: string, + factory: SqliteDatabaseFactory, + timeout: number, +): BoundSqliteDatabase { + if ( + process.platform === 'win32' || + constants.O_NOFOLLOW === undefined || + constants.O_DIRECTORY === undefined + ) { + throw unsupported('This platform cannot provide no-follow inode-bound SQLite opening') + } + if ((REQUIRED_PARENT_FLAGS & constants.O_NOFOLLOW) === 0) { + throw unsupported('The platform did not expose O_NOFOLLOW') + } + const normalizedPath = resolve(path) + const parentDescriptor = openDirectoryChain(dirname(normalizedPath)) + let fileDescriptor: number | undefined + let database: SqliteDatabase | undefined + let created = false + let openedMetadata: ReturnType | undefined + try { + const parentBefore = fstatSync(parentDescriptor) + assertOwnedDirectory(parentBefore, dirname(normalizedPath)) + const opened = openDatabaseFile(parentDescriptor, normalizedPath) + fileDescriptor = opened.fileDescriptor + created = opened.newDatabase + const parentAfter = fstatSync(parentDescriptor) + if (!sameInode(parentBefore, parentAfter)) { + throw new StorageError('STORAGE_PATH_RACE', 'SQLite parent directory changed during open') + } + const metadata = fstatSync(fileDescriptor) + openedMetadata = metadata + assertOwnedDatabase(metadata, normalizedPath) + if (!opened.newDatabase && metadata.size === 0) { + throw new StorageError( + 'STORAGE_CORRUPT_EMPTY', + `Existing SQLite path is empty: ${normalizedPath}`, + ) + } + fchmodSync(fileDescriptor, 0o600) + database = factory(descriptorPath(fileDescriptor), { timeout }) + return { database, fileDescriptor, newDatabase: opened.newDatabase } + } catch (error) { + try { + database?.close() + } catch { + // Preserve the original open error. + } + if (fileDescriptor !== undefined) closeSync(fileDescriptor) + if (created) { + try { + const currentDescriptor = openSync( + `${descriptorPath(parentDescriptor)}/${basename(normalizedPath)}`, + REQUIRED_FILE_FLAGS, + ) + try { + const current = fstatSync(currentDescriptor) + if (openedMetadata !== undefined && sameInode(openedMetadata, current)) + unlinkSync(`${descriptorPath(parentDescriptor)}/${basename(normalizedPath)}`) + } finally { + closeSync(currentDescriptor) + } + } catch { + // The path may have been replaced; never remove an unverified inode. + } + } + if (error instanceof StorageError) throw error + throw new StorageError('STORAGE_PATH', `Cannot open SQLite path: ${normalizedPath}`, { + cause: error, + }) + } finally { + closeSync(parentDescriptor) + } +} + +export function closeBoundSqliteDatabase(bound: BoundSqliteDatabase): void { + try { + bound.database.close() + } finally { + closeSync(bound.fileDescriptor) + } +} + +export function bindDescriptorLifetime( + database: SqliteDatabase, + fileDescriptor: number, +): SqliteDatabase { + const close = database.close.bind(database) + let closed = false + database.close = () => { + if (closed) return + closed = true + try { + close() + } finally { + closeSync(fileDescriptor) + } + } + return database +} diff --git a/src/adapters/storage/sqlite-content.ts b/src/adapters/storage/sqlite-content.ts new file mode 100644 index 0000000..d10e4f3 --- /dev/null +++ b/src/adapters/storage/sqlite-content.ts @@ -0,0 +1,155 @@ +import { randomBytes, randomUUID } from 'node:crypto' +import { + parseConversationId, + parseEventId, + parseOperationId, + parseReplayCursor, + parseRunId, + parseWorkspaceId, +} from '../../domain/ids.js' +import type { CredentialRef, SecretHandle } from '../../ports/credentials.js' +import { credentialRef } from '../../ports/credentials.js' +import type { ConversationId, EventId, StoredJournalEvent } from '../../ports/storage.js' +import { decryptPayload, payloadChecksum } from './sqlite-crypto.js' +import { StorageError } from './sqlite-errors.js' + +import { SqliteStorageBase } from './sqlite-base.js' +import type { SqliteEventRow } from './sqlite-types.js' +import { + asBuffer, + asNumber, + asString, + credentialErrorCode, + redactedPersistedPayload, +} from './sqlite-rows.js' + +export abstract class SqliteContentStorage extends SqliteStorageBase { + async storedEvent(row: SqliteEventRow): Promise { + const conversationId = parseConversationId(asString(row.conversation_id, 'conversation_id')) + const base = { + workspaceId: parseWorkspaceId(asString(row.workspace_id, 'workspace_id')), + conversationId, + runId: parseRunId(asString(row.run_id, 'run_id')), + eventId: parseEventId(asString(row.event_id, 'event_id')), + sequence: asNumber(row.run_sequence, 'run_sequence'), + kind: asString(row.kind, 'kind'), + occurredAt: asString(row.occurred_at, 'occurred_at'), + ...(row.cursor === null ? {} : { cursor: parseReplayCursor(asString(row.cursor, 'cursor')) }), + ...(row.operation_id === null + ? {} + : { operationId: parseOperationId(asString(row.operation_id, 'operation_id')) }), + terminal: row.terminal === 1, + receivedAt: asString(row.received_at, 'received_at'), + payloadChecksum: asString(row.payload_checksum, 'payload_checksum'), + redacted: row.redacted === 1, + ...(row.provider_event_id === null || row.provider_event_id === undefined + ? {} + : { providerEventId: asString(row.provider_event_id, 'provider_event_id') }), + } + const tombstone = this.database + .prepare('SELECT reason FROM braid_conversation_tombstones WHERE conversation_id = ?') + .get(conversationId) as { readonly reason?: unknown } | undefined + if (tombstone) { + return { + ...base, + payload: redactedPersistedPayload(null, row, 'destruction'), + payloadState: 'deleted', + tombstoneReason: asString(tombstone.reason, 'tombstone.reason'), + } + } + const key = await this.existingContentKey(conversationId) + if (!key) return { ...base, payload: null, payloadState: 'content-key-unavailable' } + try { + const payload = decryptPayload(asBuffer(row.payload, 'payload'), key) + if (payloadChecksum(payload) !== base.payloadChecksum) { + throw new StorageError( + 'PAYLOAD_CHECKSUM_MISMATCH', + `Stored payload checksum does not match event ${base.eventId}`, + ) + } + return { ...base, payload, payloadState: row.redacted === 1 ? 'redacted' : 'available' } + } finally { + key.fill(0) + } + } + + async ensureContentKey( + conversationId: ConversationId, + created: CredentialRef[], + ): Promise<{ readonly key: Buffer; readonly ref: CredentialRef }> { + const row = this.database + .prepare( + 'SELECT credential_ref, destroyed FROM braid_conversation_keys WHERE conversation_id = ?', + ) + .get(conversationId) as + | { readonly credential_ref?: unknown; readonly destroyed?: unknown } + | undefined + if (row) { + if (Number(row.destroyed) === 1) + throw new StorageError('CONTENT_KEY_DESTROYED', 'Conversation content key was destroyed') + const ref = credentialRef(asString(row.credential_ref, 'credential_ref')) + return { key: await this.resolveKey(ref), ref } + } + const ref = credentialRef(`cred:v1:content-${randomUUID()}`) + const key = randomBytes(32) + try { + await this.credentials.store({ ref, value: key }) + created.push(ref) + return { key: Buffer.from(key), ref } + } finally { + key.fill(0) + } + } + + async existingContentKey(conversationId: ConversationId): Promise { + const row = this.database + .prepare( + 'SELECT credential_ref, destroyed FROM braid_conversation_keys WHERE conversation_id = ?', + ) + .get(conversationId) as + | { readonly credential_ref?: unknown; readonly destroyed?: unknown } + | undefined + if (!row || Number(row.destroyed) === 1) return undefined + const ref = credentialRef(asString(row.credential_ref, 'credential_ref')) + try { + return await this.resolveKey(ref) + } catch (error) { + if (credentialErrorCode(error) === 'CREDENTIAL_NOT_FOUND') return undefined + throw error + } + } + + async resolveKey(ref: CredentialRef): Promise { + let handle: SecretHandle | undefined + try { + handle = await this.credentials.resolve(ref) + const key = Buffer.from(handle.read()) + if (key.length !== 32) + throw new StorageError('CONTENT_KEY_INVALID', 'Stored content key has invalid length') + return key + } finally { + handle?.dispose() + } + } + + async verifyConversation( + conversationId: ConversationId, + eventId: EventId, + ref: CredentialRef, + ): Promise { + const key = await this.resolveKey(ref) + try { + const rows = this.database + .prepare('SELECT * FROM braid_journal_events WHERE conversation_id = ? ORDER BY storage_id') + .all(conversationId) as readonly SqliteEventRow[] + for (const row of rows) { + const value = decryptPayload(asBuffer(row.payload, 'payload'), key) + if (payloadChecksum(value) !== row.payload_checksum) return false + if (row.event_id === eventId && row.redacted !== 1) return false + } + return true + } finally { + key.fill(0) + } + } +} diff --git a/src/adapters/storage/sqlite-crypto.ts b/src/adapters/storage/sqlite-crypto.ts new file mode 100644 index 0000000..9803a05 --- /dev/null +++ b/src/adapters/storage/sqlite-crypto.ts @@ -0,0 +1,202 @@ +import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto' +import { isSafeNumericTelemetryField } from '../../domain/bounded-structured.js' +import { canonicalJson } from '../../domain/canonical.js' +import type { JsonValue } from '../../ports/storage.js' +import { isJsonValue } from '../../ports/storage.js' +import { StorageError } from './sqlite-errors.js' + +const VERSION = 1 +const NONCE_BYTES = 12 +const TAG_BYTES = 16 + +export function payloadChecksum(payload: JsonValue): string { + return createHash('sha256').update(canonicalJson(payload)).digest('hex') +} + +export function encryptPayload(payload: JsonValue, key: Buffer): Buffer { + if (key.length !== 32) + throw new StorageError('CONTENT_KEY_INVALID', 'Content keys must be exactly 32 bytes') + const nonce = randomBytes(NONCE_BYTES) + const cipher = createCipheriv('aes-256-gcm', key, nonce) + const plaintext = Buffer.from(canonicalJson(payload)) + const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]) + const tag = cipher.getAuthTag() + plaintext.fill(0) + return Buffer.concat([Buffer.from([VERSION]), nonce, tag, ciphertext]) +} + +export function decryptPayload(encoded: Buffer, key: Buffer): JsonValue { + if (key.length !== 32 || encoded.length < 1 + NONCE_BYTES + TAG_BYTES) { + throw new StorageError('PAYLOAD_DECRYPT_FAILED', 'Encrypted payload has invalid dimensions') + } + if (encoded[0] !== VERSION) + throw new StorageError('PAYLOAD_VERSION', 'Encrypted payload version is unsupported') + const nonce = encoded.subarray(1, 1 + NONCE_BYTES) + const tag = encoded.subarray(1 + NONCE_BYTES, 1 + NONCE_BYTES + TAG_BYTES) + const ciphertext = encoded.subarray(1 + NONCE_BYTES + TAG_BYTES) + let plaintext: Buffer | undefined + try { + const decipher = createDecipheriv('aes-256-gcm', key, nonce) + decipher.setAuthTag(tag) + plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]) + const value: unknown = JSON.parse(plaintext.toString('utf8')) + if (!isJsonValue(value)) throw new Error('payload is not JSON') + return value + } catch (error) { + throw new StorageError( + 'PAYLOAD_DECRYPT_FAILED', + 'The conversation content key did not decrypt the payload', + { + cause: error, + }, + ) + } finally { + plaintext?.fill(0) + } +} + +export function tombstone(reason: string): JsonValue { + const safeReason = + reason === 'retention' || reason === 'redaction' || reason === 'destruction' + ? reason + : 'redacted' + return { redacted: true, reason: safeReason } +} + +const SECRET_KEY = + /(secret|password|passphrase|token|bearer|authorization|credential|private(?:[_-]?key)?|api[-_]?key)/iu +const SAFE_REFERENCE_SUFFIX = /(ref|name|kind)$/iu + +function secretFieldNames(value: JsonValue): readonly string[] { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return [] + const fields = (value as Readonly>).fields + if (!Array.isArray(fields)) return [] + const names: string[] = [] + for (const field of fields) { + if (field === null || typeof field !== 'object' || Array.isArray(field)) { + throw new StorageError('INTERACTION_SPEC_INVALID', 'Interaction answerSpec field is invalid') + } + const item = field as Readonly> + if (typeof item.name !== 'string' || item.name.length === 0 || typeof item.type !== 'string') { + throw new StorageError('INTERACTION_SPEC_INVALID', 'Interaction answerSpec field is invalid') + } + if (item.type === 'secret') { + if (Object.hasOwn(item, 'default')) { + throw new StorageError( + 'INTERACTION_SPEC_INVALID', + `Secret interaction field ${item.name} cannot define a default`, + ) + } + names.push(item.name) + } + } + return names +} + +function rejectTypedSecretAnswers(value: JsonValue): void { + function inspectContainer( + container: Readonly>, + spec: JsonValue, + path: string, + ): void { + const names = secretFieldNames(spec) + if (names.length === 0) return + const rejectObject = (candidate: JsonValue, candidatePath: string): void => { + if (candidate === null || typeof candidate !== 'object' || Array.isArray(candidate)) return + for (const name of names) { + if (Object.hasOwn(candidate, name)) { + throw new StorageError( + 'SECRET_PAYLOAD_REJECTED', + `Secret-designated interaction field ${candidatePath}.${name} cannot be persisted`, + ) + } + } + } + for (const [key, child] of Object.entries(container)) { + if (key === 'answerSpec' || key === 'request') continue + if (/^(answer|answers|data|publicData|response|value)$/u.test(key)) { + rejectObject(child, `${path}.${key}`) + } + if (names.includes(key)) { + throw new StorageError( + 'SECRET_PAYLOAD_REJECTED', + `Secret-designated interaction field ${path}.${key} cannot be persisted`, + ) + } + } + } + + function visit(node: JsonValue, path: string): void { + if (node === null || typeof node !== 'object') return + if (Array.isArray(node)) { + node.forEach((child, index) => { + visit(child, `${path}[${index}]`) + }) + return + } + const object = node as Readonly> + const spec = object.answerSpec + if (spec !== undefined) inspectContainer(object, spec, path) + const request = object.request + if ( + request !== undefined && + request !== null && + typeof request === 'object' && + !Array.isArray(request) + ) { + const requestObject = request as Readonly> + if (requestObject.answerSpec !== undefined) + inspectContainer(object, requestObject.answerSpec, path) + } + for (const [key, child] of Object.entries(object)) visit(child, `${path}.${key}`) + } + visit(value, '$') +} + +export function assertPersistablePayload(value: JsonValue): void { + if (!isJsonValue(value)) { + throw new StorageError('PAYLOAD_INVALID', 'Journal payload must be finite JSON') + } + + rejectTypedSecretAnswers(value) + + function visit(node: JsonValue, path: string, secretDesignated = false): void { + if (Array.isArray(node)) { + node.forEach((child, index) => { + visit(child, `${path}[${index}]`, secretDesignated) + }) + return + } + if (node === null || typeof node !== 'object') return + const object = node as Readonly> + const containsSecret = + secretDesignated || + object.containsSecret === true || + object.secretDesignated === true || + object.isSecret === true + for (const [key, child] of Object.entries(node)) { + const isSecretMarker = + key === 'containsSecret' || key === 'secretDesignated' || key === 'isSecret' + const isNumericTelemetry = !containsSecret && isSafeNumericTelemetryField(key, child) + if ( + SECRET_KEY.test(key) && + !SAFE_REFERENCE_SUFFIX.test(key) && + !isSecretMarker && + !isNumericTelemetry + ) { + throw new StorageError( + 'SECRET_PAYLOAD_REJECTED', + `Secret-bearing field ${path}.${key} cannot be persisted`, + ) + } + if (containsSecret && /^(answer|data|publicData|response|value)$/u.test(key)) { + throw new StorageError( + 'SECRET_PAYLOAD_REJECTED', + `Secret-designated field ${path}.${key} cannot be persisted`, + ) + } + visit(child, `${path}.${key}`, containsSecret) + } + } + visit(value, '$') +} diff --git a/src/adapters/storage/sqlite-driver.ts b/src/adapters/storage/sqlite-driver.ts new file mode 100644 index 0000000..04927a1 --- /dev/null +++ b/src/adapters/storage/sqlite-driver.ts @@ -0,0 +1,142 @@ +import { createRequire } from 'node:module' +import { dirname, join } from 'node:path' +import { readFileSync } from 'node:fs' +import { StorageError } from './sqlite-errors.js' + +export const SQLITE_DRIVER_PACKAGE = 'better-sqlite3-multiple-ciphers' +export const SQLITE_DRIVER_VERSION = '12.11.1' + +export type SqliteValue = string | number | bigint | Buffer | null + +export interface SqliteStatement { + run(...parameters: readonly SqliteValue[]): { + readonly changes: number + readonly lastInsertRowid?: number | bigint + } + get = Record>( + ...parameters: readonly SqliteValue[] + ): T | undefined + all = Record>( + ...parameters: readonly SqliteValue[] + ): readonly T[] +} + +export interface SqliteDatabase { + readonly open: boolean + prepare(sql: string): SqliteStatement + exec(sql: string): void + pragma(sql: string, options?: { readonly simple?: boolean }): unknown + key?(key: Buffer): unknown + rekey?(key: Buffer): unknown + backup?(destination: string): Promise + close(): void +} + +export type SqliteDatabaseFactory = ( + filename: string, + options: { readonly timeout: number }, +) => SqliteDatabase + +function packageVersion(resolvedModule: string): string | undefined { + try { + const packagePath = join(dirname(dirname(resolvedModule)), 'package.json') + const packageJson = JSON.parse(readFileSync(packagePath, 'utf8')) as { + readonly version?: unknown + } + return typeof packageJson.version === 'string' ? packageJson.version : undefined + } catch { + return undefined + } +} + +export function loadCipherDatabaseFactory(): SqliteDatabaseFactory { + const require = createRequire(import.meta.url) + let resolvedModule: string + try { + resolvedModule = require.resolve(SQLITE_DRIVER_PACKAGE) + } catch (error) { + throw new StorageError( + 'SQLITE_CIPHER_UNAVAILABLE', + `${SQLITE_DRIVER_PACKAGE}@${SQLITE_DRIVER_VERSION} is not installed`, + { cause: error }, + ) + } + const version = packageVersion(resolvedModule) + if (version !== undefined && version !== SQLITE_DRIVER_VERSION) { + throw new StorageError( + 'SQLITE_DRIVER_VERSION', + `Expected ${SQLITE_DRIVER_PACKAGE}@${SQLITE_DRIVER_VERSION}, found ${version}`, + ) + } + let moduleValue: unknown + try { + moduleValue = require(resolvedModule) as unknown + } catch (error) { + throw new StorageError( + 'SQLITE_CIPHER_UNAVAILABLE', + 'The encrypted SQLite native binding failed to load', + { + cause: error, + }, + ) + } + const Constructor = (moduleValue as { readonly default?: unknown }).default ?? moduleValue + if (typeof Constructor !== 'function') { + throw new StorageError( + 'SQLITE_CIPHER_UNAVAILABLE', + 'The encrypted SQLite module has no constructor', + ) + } + return (filename, options) => { + try { + return new (Constructor as new (path: string, options: unknown) => SqliteDatabase)( + filename, + options, + ) + } catch (error) { + throw new StorageError( + 'SQLITE_CIPHER_UNAVAILABLE', + 'The encrypted SQLite native binding failed to initialize', + { cause: error }, + ) + } + } +} + +export function configureCipherDatabase( + database: SqliteDatabase, + key: Buffer, + options: { readonly newDatabase?: boolean } = {}, +): void { + if (!database.key || !database.rekey) { + throw new StorageError( + 'SQLITE_CIPHER_UNAVAILABLE', + 'The SQLite binding does not expose key and rekey operations', + ) + } + if (key.length !== 32) + throw new StorageError('SQLITE_KEY_INVALID', 'SQLite keys must be exactly 32 bytes') + try { + database.pragma("cipher = 'sqlcipher'") + database.pragma('legacy = 4') + const material = Buffer.from(key) + try { + if (options.newDatabase) database.rekey(material) + else database.key(material) + } finally { + material.fill(0) + } + database.prepare('SELECT count(*) AS count FROM sqlite_master').get() + } catch (error) { + throw new StorageError('SQLITE_KEY_REJECTED', 'The encrypted SQLite key was rejected', { + cause: error, + }) + } + const cipher = database.pragma('cipher', { simple: true }) + if (typeof cipher !== 'string' || cipher.toLowerCase() !== 'sqlcipher') { + throw new StorageError( + 'SQLITE_CIPHER_UNAVAILABLE', + 'The SQLite binding did not activate SQLCipher mode', + ) + } +} diff --git a/src/adapters/storage/sqlite-effects.ts b/src/adapters/storage/sqlite-effects.ts new file mode 100644 index 0000000..fb80b5e --- /dev/null +++ b/src/adapters/storage/sqlite-effects.ts @@ -0,0 +1,379 @@ +import { stat } from 'node:fs/promises' +import { dirname } from 'node:path' +import { canonicalJson } from '../../domain/canonical.js' +import { parseConversationId, parseEventId } from '../../domain/ids.js' +import { credentialRef } from '../../ports/credentials.js' +import type { EffectRecord } from '../../ports/effect-storage.js' +import type { JsonValue, OperationIntent, ProjectionSnapshot } from '../../ports/storage.js' +import { StorageError } from './sqlite-errors.js' +import { pragmaNumber, SQLITE_SCHEMA_VERSION } from './sqlite-schema.js' +import { assertEffectRecordInput, assertOperationRequestDigest } from './storage-validation.js' + +import { SqliteOperationStorage } from './sqlite-operations.js' +import { effectRecordFromRow, jsonValue, cloneJson, asNumber, asString } from './sqlite-rows.js' +import { classifySqliteError, assertApprovedPath, validatePath } from './sqlite-paths.js' + +export abstract class SqliteEffectsStorage extends SqliteOperationStorage { + async rebuild(operation: OperationIntent): Promise { + assertOperationRequestDigest(operation, {}) + const replay = await this.reuseMutation(operation) + if (replay !== undefined) return replay + let mutationCommitted = false + try { + const result = await this.writes.run(async () => { + this.assertOpen() + this.begin() + try { + const projection = this.buildProjection() + this.writeProjection(projection) + this.commit('rebuild') + mutationCommitted = true + return structuredClone(projection) + } catch (error) { + this.rollback() + throw classifySqliteError(error) + } + }) + await this.completeMutation(operation, 'terminal', jsonValue(result)) + return result + } catch (error) { + await this.completeMutationFailure(operation, error, mutationCommitted) + throw error + } + } + + reserveEffect(record: EffectRecord): { + readonly record: EffectRecord + readonly created: boolean + } { + this.assertOpen() + assertEffectRecordInput(record) + this.begin() + try { + const admission = this.database + .prepare('SELECT * FROM braid_effect_admissions WHERE operation_id = ?') + .get(record.operationId) as + | { + readonly effect_kind?: unknown + readonly request_digest?: unknown + readonly attempt?: unknown + readonly created_at?: unknown + readonly updated_at?: unknown + } + | undefined + if (admission) { + const admittedDigest = asString(admission.request_digest, 'request_digest') + const existing = this.database + .prepare( + `SELECT * FROM braid_effect_records + WHERE operation_id = ? AND request_digest = ? + ORDER BY effect_sequence DESC LIMIT 1`, + ) + .get(record.operationId, admittedDigest) as Record | undefined + if (admittedDigest === record.requestDigest) { + const replay = existing + ? effectRecordFromRow(existing) + : { + ...record, + effectKind: asString(admission.effect_kind, 'effect_kind'), + attempt: asNumber(admission.attempt, 'attempt'), + createdAt: asString(admission.created_at, 'created_at'), + updatedAt: asString(admission.updated_at, 'updated_at'), + } + this.commit('effect.reserve.replay') + return { record: replay, created: false } + } + const conflict: EffectRecord = { + ...record, + status: 'conflict', + detail: `Operation is already bound to request digest ${admittedDigest}`, + conflictWithDigest: admittedDigest, + } + this.insertEffectUnsafe(conflict) + this.commit('effect.reserve.conflict') + return { record: conflict, created: false } + } + this.database + .prepare( + `INSERT INTO braid_effect_admissions( + operation_id, effect_kind, request_digest, attempt, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?)`, + ) + .run( + record.operationId, + record.effectKind, + record.requestDigest, + record.attempt, + record.createdAt, + record.updatedAt, + ) + this.insertEffectUnsafe(record) + this.commit('effect.reserve') + return { record: structuredClone(record), created: true } + } catch (error) { + this.rollback() + throw classifySqliteError(error) + } + } + + current(operationId: string): EffectRecord | undefined { + this.assertOpen() + const row = this.database + .prepare( + `SELECT * FROM braid_effect_records + WHERE operation_id = ? AND status <> 'conflict' + ORDER BY effect_sequence DESC LIMIT 1`, + ) + .get(operationId) as Record | undefined + return row === undefined ? undefined : effectRecordFromRow(row) + } + + latest(operationId: string, requestDigest: string): EffectRecord | undefined { + this.assertOpen() + const row = this.database + .prepare( + `SELECT * FROM braid_effect_records + WHERE operation_id = ? AND request_digest = ? + ORDER BY effect_sequence DESC LIMIT 1`, + ) + .get(operationId, requestDigest) as Record | undefined + return row === undefined ? undefined : effectRecordFromRow(row) + } + + appendEffect(record: EffectRecord): void { + this.assertOpen() + assertEffectRecordInput(record) + this.begin() + try { + this.insertEffectUnsafe(record) + if (record.status !== 'conflict') { + this.database + .prepare( + `UPDATE braid_effect_admissions + SET effect_kind = ?, attempt = ?, updated_at = ? + WHERE operation_id = ? AND request_digest = ?`, + ) + .run( + record.effectKind, + record.attempt, + record.updatedAt, + record.operationId, + record.requestDigest, + ) + } + this.commit('effect') + } catch (error) { + this.rollback() + throw classifySqliteError(error) + } + } + + history(operationId: string): readonly EffectRecord[] { + this.assertOpen() + const rows = this.database + .prepare('SELECT * FROM braid_effect_records WHERE operation_id = ? ORDER BY effect_sequence') + .all(operationId) as readonly Record[] + return rows.map(effectRecordFromRow) + } + + insertEffectUnsafe(record: EffectRecord): void { + this.database + .prepare( + `INSERT INTO braid_effect_records( + operation_id, effect_kind, request_digest, status, attempt, created_at, + updated_at, metadata_json, detail, external_reference, conflict_with_digest + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + record.operationId, + record.effectKind, + record.requestDigest, + record.status, + record.attempt, + record.createdAt, + record.updatedAt, + canonicalJson(record.metadata), + record.detail ?? null, + record.externalReference ?? null, + record.conflictWithDigest ?? null, + ) + } + + async reuseMutation(intent: OperationIntent): Promise { + const reservation = await this.reserveOperation(intent) + if (reservation.record.status === 'conflict') { + throw new StorageError( + 'OPERATION_CONFLICT', + `Operation ${intent.operationId} was reused with a different request digest`, + ) + } + if (!reservation.created && reservation.record.status === 'failed') { + throw new StorageError( + 'OPERATION_FAILED_REPLAY', + `Operation ${intent.operationId} already failed; use a new operation identifier to retry`, + ) + } + if (!reservation.created && reservation.record.status === 'unknown') { + throw new StorageError( + 'OPERATION_OUTCOME_UNKNOWN', + `Operation ${intent.operationId} has an unknown outcome and requires reconciliation`, + ) + } + if (!reservation.created && reservation.record.status === 'pending') { + const reconciled = await this.reconcilePendingMutation(intent) + if (reconciled !== undefined) { + await this.completeMutation(intent, 'terminal', jsonValue(reconciled)) + return reconciled as T + } + throw new StorageError( + 'OPERATION_OUTCOME_PENDING', + `Operation ${intent.operationId} is pending and requires explicit reconciliation evidence`, + ) + } + if ( + !reservation.created && + (reservation.record.status === 'terminal' || reservation.record.status === 'acknowledged') + ) { + if (reservation.record.result === undefined) { + throw new StorageError( + 'OPERATION_RESULT_MISSING', + `Operation ${intent.operationId} has no stored result`, + ) + } + return cloneJson(reservation.record.result) as T + } + return undefined + } + + async reconcilePendingMutation(intent: OperationIntent): Promise { + const request = intent.request + const object = + request !== null && typeof request === 'object' && !Array.isArray(request) + ? (request as Readonly>) + : undefined + if (intent.kind === 'backup' && typeof object?.path === 'string') { + const path = validatePath(object.path, 'Backup path') + await assertApprovedPath(path, this.workspaceRoot ?? dirname(this.path), 'Backup path') + const exists = await stat(path) + .then(() => true) + .catch(() => false) + if (!exists) return undefined + await this.assertEncryptedArtifact(path) + await this.assertReadableBackup(path) + return { path, bytes: (await stat(path)).size, encrypted: true } + } + if (intent.kind === 'destruction' && typeof object?.conversationId === 'string') { + const tombstone = this.database + .prepare( + 'SELECT conversation_id FROM braid_conversation_tombstones WHERE conversation_id = ?', + ) + .get(object.conversationId) as { readonly conversation_id?: unknown } | undefined + if (tombstone) { + return { + conversationId: object.conversationId, + destroyed: true, + retainedCiphertext: true, + } + } + return undefined + } + if ( + intent.kind === 'redact' && + typeof object?.conversationId === 'string' && + typeof object.eventId === 'string' + ) { + const record = this.database + .prepare( + 'SELECT rewritten_at FROM braid_redaction_records WHERE conversation_id = ? AND event_id = ?', + ) + .get(object.conversationId, object.eventId) as + | { readonly rewritten_at?: unknown } + | undefined + if (!record) return undefined + const keyRow = this.database + .prepare('SELECT credential_ref FROM braid_conversation_keys WHERE conversation_id = ?') + .get(object.conversationId) as { readonly credential_ref?: unknown } | undefined + if (!keyRow) return undefined + const ref = credentialRef(asString(keyRow.credential_ref, 'credential_ref')) + const verified = await this.verifyConversation( + parseConversationId(object.conversationId), + parseEventId(object.eventId), + ref, + ) + if (!verified) return undefined + const count = this.database + .prepare('SELECT COUNT(*) AS count FROM braid_journal_events WHERE conversation_id = ?') + .get(object.conversationId) as { readonly count?: unknown } + return { + conversationId: object.conversationId, + redactedEventId: object.eventId, + rewrittenEvents: asNumber(count.count, 'rewrittenEvents'), + newContentKeyRef: ref, + } + } + if ( + intent.kind === 'migrate' && + pragmaNumber(this.database, 'user_version') >= SQLITE_SCHEMA_VERSION + ) { + return { + fromVersion: SQLITE_SCHEMA_VERSION, + toVersion: SQLITE_SCHEMA_VERSION, + migrated: false, + } + } + if (intent.kind === 'rebuild') return this.buildProjection() as unknown as JsonValue + if (intent.kind === 'compact') return { completed: true } + if (intent.kind === 'retention') { + const before = typeof object?.before === 'string' ? object.before : undefined + if (!before) return undefined + const pending = this.database + .prepare( + `SELECT 1 FROM braid_journal_events e + JOIN braid_run_cursors c ON c.run_id = e.run_id + WHERE e.received_at < ? AND e.redacted = 0 AND c.terminal = 1 LIMIT 1`, + ) + .get(before) + if (!pending) return { redactedEvents: 0, deletedConversations: [] } + } + return undefined + } + + async completeMutation( + intent: OperationIntent, + status: 'terminal' | 'failed' | 'unknown', + result: JsonValue, + ): Promise { + await this.completeOperation({ + operationId: intent.operationId, + requestDigest: intent.requestDigest, + status, + result, + }) + } + + async completeMutationFailure( + intent: OperationIntent, + error: unknown, + mutationCommitted = false, + ): Promise { + const result: JsonValue = { + code: mutationCommitted + ? 'STORAGE_MUTATION_OUTCOME_UNKNOWN' + : error instanceof StorageError + ? error.code + : 'STORAGE_MUTATION_FAILED', + message: mutationCommitted + ? 'The storage mutation committed but its final outcome is unknown' + : error instanceof StorageError + ? error.code + : 'Storage mutation failed', + } + try { + await this.completeMutation(intent, mutationCommitted ? 'unknown' : 'failed', result) + } catch { + await this.completeMutation(intent, 'unknown', { + code: 'STORAGE_MUTATION_OUTCOME_UNKNOWN', + }).catch(() => undefined) + } + } +} diff --git a/src/adapters/storage/sqlite-errors.ts b/src/adapters/storage/sqlite-errors.ts new file mode 100644 index 0000000..e2beb08 --- /dev/null +++ b/src/adapters/storage/sqlite-errors.ts @@ -0,0 +1,9 @@ +export class StorageError extends Error { + readonly code: string + + constructor(code: string, message: string, options?: { readonly cause?: unknown }) { + super(message, options) + this.name = 'StorageError' + this.code = code + } +} diff --git a/src/adapters/storage/sqlite-initialization.ts b/src/adapters/storage/sqlite-initialization.ts new file mode 100644 index 0000000..d1048fe --- /dev/null +++ b/src/adapters/storage/sqlite-initialization.ts @@ -0,0 +1,145 @@ +import { constants } from 'node:fs' +import { createHash } from 'node:crypto' +import { open as openFile, rm } from 'node:fs/promises' +import { dirname } from 'node:path' +import { canonicalJson } from '../../domain/canonical.js' +import { StorageError } from './sqlite-errors.js' +import { syncDirectory } from './sqlite-paths.js' + +export interface InitializationMarker { + readonly version: 1 + readonly databaseDigest: string + readonly pid: number +} + +export type InitializationMarkerState = 'none' | 'active' | 'stale' + +export function initializationMarkerPath(databasePath: string): string { + return `${databasePath}.initializing` +} + +export function initializationMarker( + databasePath: string, + pid = process.pid, +): InitializationMarker { + return { + version: 1, + databaseDigest: createHash('sha256').update(databasePath).digest('hex'), + pid, + } +} + +export function processIsAlive(pid: number): boolean { + if (!Number.isSafeInteger(pid) || pid <= 0) return false + try { + process.kill(pid, 0) + return true + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'EPERM' + } +} + +export async function inspectInitializationMarker( + databasePath: string, +): Promise { + const markerPath = initializationMarkerPath(databasePath) + let handle: Awaited> | undefined + try { + handle = await openFile(markerPath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)) + const metadata = await handle.stat() + if (!metadata.isFile() || metadata.nlink !== 1 || metadata.size > 512) { + throw new StorageError( + 'STORAGE_INITIALIZATION_MARKER', + 'Database initialization marker is invalid', + ) + } + if (process.platform !== 'win32' && (metadata.mode & 0o777) !== 0o600) { + throw new StorageError( + 'STORAGE_INITIALIZATION_MARKER', + 'Database initialization marker must have mode 0600', + ) + } + if (typeof process.getuid === 'function' && metadata.uid !== process.getuid()) { + throw new StorageError( + 'STORAGE_INITIALIZATION_MARKER', + 'Database initialization marker ownership does not match the process', + ) + } + const raw = await handle.readFile({ encoding: 'utf8' }) + const parsed = JSON.parse(raw) as Partial + const expected = initializationMarker(databasePath, 0).databaseDigest + if ( + parsed.version !== 1 || + parsed.databaseDigest !== expected || + !Number.isSafeInteger(parsed.pid) + ) { + throw new StorageError( + 'STORAGE_INITIALIZATION_MARKER', + 'Database initialization marker is invalid', + ) + } + return processIsAlive(parsed.pid as number) ? 'active' : 'stale' + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return 'none' + if (error instanceof StorageError) throw error + throw new StorageError( + 'STORAGE_INITIALIZATION_MARKER', + 'Database initialization marker could not be read', + { cause: error }, + ) + } finally { + await handle?.close().catch(() => undefined) + } +} + +export async function claimInitializationMarker(databasePath: string): Promise { + const markerPath = initializationMarkerPath(databasePath) + for (;;) { + let handle: Awaited> | undefined + try { + handle = await openFile(markerPath, 'wx', 0o600) + await handle.writeFile(`${canonicalJson(initializationMarker(databasePath))}\n`) + await handle.sync() + await handle.close() + handle = undefined + await syncDirectory(dirname(databasePath)) + return + } catch (error) { + await handle?.close().catch(() => undefined) + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error + const state = await inspectInitializationMarker(databasePath) + if (state === 'active') { + throw new StorageError( + 'STORAGE_INITIALIZING', + 'Another process is initializing the encrypted database', + ) + } + await rm(markerPath, { force: true }) + } + } +} + +export async function releaseInitializationMarker(databasePath: string): Promise { + await rm(initializationMarkerPath(databasePath), { force: true }) + await syncDirectory(dirname(databasePath)) +} + +export async function markInitializationInterrupted(databasePath: string): Promise { + const markerPath = initializationMarkerPath(databasePath) + let handle: Awaited> | undefined + try { + handle = await openFile(markerPath, 'r+') + await handle.truncate(0) + await handle.writeFile(`${canonicalJson(initializationMarker(databasePath, 0))}\n`) + await handle.sync() + } finally { + await handle?.close().catch(() => undefined) + } +} + +export async function removeDatabaseArtifacts(databasePath: string): Promise { + await rm(databasePath, { force: true }) + await rm(`${databasePath}-wal`, { force: true }) + await rm(`${databasePath}-shm`, { force: true }) + await syncDirectory(dirname(databasePath)) +} diff --git a/src/adapters/storage/sqlite-journal.ts b/src/adapters/storage/sqlite-journal.ts new file mode 100644 index 0000000..50705bc --- /dev/null +++ b/src/adapters/storage/sqlite-journal.ts @@ -0,0 +1,406 @@ +import { canonicalJson } from '../../domain/canonical.js' +import type { CredentialRef } from '../../ports/credentials.js' +import { CredentialError } from '../../ports/credentials.js' +import type { + AppendResult, + ConversationId, + EventId, + JournalEvent, + ProjectionSnapshot, + ReplayResult, + RunId, + StateSnapshot, + StoredJournalEvent, + WorkspaceId, +} from '../../ports/storage.js' +import { assertPersistablePayload, encryptPayload, payloadChecksum } from './sqlite-crypto.js' +import type { SqliteValue } from './sqlite-driver.js' +import { StorageError } from './sqlite-errors.js' +import { SqliteLifecycleStorage } from './sqlite-lifecycle.js' +import { classifySqliteError } from './sqlite-paths.js' +import { asNumber, asString, missingFromCursor, now } from './sqlite-rows.js' +import type { PreparedStateSnapshot } from './sqlite-state-snapshot-types.js' +import type { CursorRow, SqliteEventRow } from './sqlite-types.js' +import { assertJournalEventInput } from './storage-validation.js' + +export abstract class SqliteJournalStorage extends SqliteLifecycleStorage { + async append(events: readonly JournalEvent[]): Promise { + this.validateAppend(events) + return this.writes.run(async () => this.appendUnsafe(events)) + } + + async appendWithSnapshot(input: { + readonly events: readonly JournalEvent[] + readonly snapshot: StateSnapshot + }): Promise { + this.validateAppend(input.events) + return this.writes.run(async () => this.appendUnsafe(input.events, input.snapshot)) + } + + private validateAppend(events: readonly JournalEvent[]): void { + if (events.length === 0) { + return + } + if (events.length > this.maxEvents) { + throw new StorageError( + 'STORAGE_TRANSACTION_BOUNDS', + `A transaction may contain at most ${this.maxEvents} events`, + ) + } + let payloadBytes = 0 + for (const event of events) { + assertJournalEventInput(event) + assertPersistablePayload(event.payload) + payloadBytes += Buffer.byteLength(canonicalJson(event.payload)) + } + if (payloadBytes > this.maxPayloadBytes) { + throw new StorageError( + 'STORAGE_TRANSACTION_BOUNDS', + 'The event batch exceeds the transaction byte bound', + ) + } + } + + async appendUnsafe( + events: readonly JournalEvent[], + snapshot?: StateSnapshot, + ): Promise { + this.assertOpen() + const contentKeys = new Map() + const contentRefs = new Map() + const contentWorkspaces = new Map() + const createdKeys: CredentialRef[] = [] + const createdSnapshotKeys: CredentialRef[] = [] + let preparedSnapshot: PreparedStateSnapshot | undefined + try { + for (const event of events) { + if (!contentKeys.has(event.conversationId)) { + const material = await this.ensureContentKey(event.conversationId, createdKeys) + contentKeys.set(event.conversationId, material.key) + contentRefs.set(event.conversationId, material.ref) + contentWorkspaces.set(event.conversationId, event.workspaceId) + } else if (contentWorkspaces.get(event.conversationId) !== event.workspaceId) { + throw new StorageError( + 'CONVERSATION_WORKSPACE_CONFLICT', + `Conversation ${event.conversationId} was used with multiple workspaces`, + ) + } + } + if (snapshot !== undefined) { + preparedSnapshot = await this.stateSnapshots.prepare(snapshot, createdSnapshotKeys) + } + this.begin() + const acceptedEventIds: EventId[] = [] + const duplicateEventIds: EventId[] = [] + const gapRuns = new Set() + let snapshotStorageId: number | undefined + let committed = false + try { + for (const [conversationId] of contentKeys) { + const workspaceId = contentWorkspaces.get(conversationId) + const ref = contentRefs.get(conversationId) + if (!workspaceId || !ref) + throw new StorageError( + 'CONTENT_KEY_UNAVAILABLE', + 'Conversation key material is incomplete', + ) + const conversation = this.database + .prepare('SELECT workspace_id FROM braid_conversations WHERE conversation_id = ?') + .get(conversationId) as { readonly workspace_id?: unknown } | undefined + if (conversation && asString(conversation.workspace_id, 'workspace_id') !== workspaceId) { + throw new StorageError( + 'CONVERSATION_WORKSPACE_CONFLICT', + `Conversation ${conversationId} belongs to another workspace`, + ) + } + if (!conversation) { + this.database + .prepare( + 'INSERT INTO braid_conversations(conversation_id, workspace_id, created_at) VALUES (?, ?, ?)', + ) + .run(conversationId, workspaceId, now()) + } + const keyRow = this.database + .prepare( + 'SELECT credential_ref, destroyed FROM braid_conversation_keys WHERE conversation_id = ?', + ) + .get(conversationId) as + | { readonly credential_ref?: unknown; readonly destroyed?: unknown } + | undefined + if (keyRow && Number(keyRow.destroyed) === 1) { + throw new StorageError( + 'CONTENT_KEY_DESTROYED', + `Conversation ${conversationId} content key was destroyed`, + ) + } + if (keyRow && asString(keyRow.credential_ref, 'credential_ref') !== ref) { + throw new StorageError( + 'CONTENT_KEY_RACE', + `Conversation ${conversationId} content key was created concurrently`, + ) + } + if (!keyRow) { + this.database + .prepare( + 'INSERT INTO braid_conversation_keys(conversation_id, credential_ref, destroyed) VALUES (?, ?, 0)', + ) + .run(conversationId, ref) + } + } + for (const event of events) { + const existing = this.database + .prepare( + `SELECT workspace_id, conversation_id, run_id, event_id, run_sequence, kind, + cursor, provider_event_id, operation_id, payload_checksum, occurred_at, received_at, terminal + FROM braid_journal_events WHERE run_id = ? AND event_id = ?`, + ) + .get(event.runId, event.eventId) as + | { + readonly workspace_id?: unknown + readonly conversation_id?: unknown + readonly run_id?: unknown + readonly event_id?: unknown + readonly run_sequence?: unknown + readonly kind?: unknown + readonly cursor?: unknown + readonly provider_event_id?: unknown + readonly operation_id?: unknown + readonly payload_checksum?: unknown + readonly occurred_at?: unknown + readonly received_at?: unknown + readonly terminal?: unknown + } + | undefined + const checksum = payloadChecksum(event.payload) + if (existing) { + const sameImmutableInput = + existing.workspace_id === event.workspaceId && + existing.conversation_id === event.conversationId && + existing.run_id === event.runId && + existing.event_id === event.eventId && + Number(existing.run_sequence) === event.sequence && + existing.kind === event.kind && + (existing.cursor ?? null) === (event.cursor ?? null) && + (existing.provider_event_id ?? null) === (event.providerEventId ?? null) && + (existing.operation_id ?? null) === (event.operationId ?? null) && + existing.payload_checksum === checksum && + existing.occurred_at === event.occurredAt && + (event.receivedAt === undefined || existing.received_at === event.receivedAt) && + Number(existing.terminal) === (event.terminal === true ? 1 : 0) + if (!sameImmutableInput) { + throw new StorageError( + 'EVENT_ID_CONFLICT', + `Event ${event.eventId} changed its durable input on retry`, + ) + } + duplicateEventIds.push(event.eventId) + continue + } + const sequenceExisting = this.database + .prepare( + 'SELECT event_id FROM braid_journal_events WHERE run_id = ? AND run_sequence = ?', + ) + .get(event.runId, event.sequence) as { readonly event_id?: unknown } | undefined + if (sequenceExisting && sequenceExisting.event_id !== event.eventId) { + throw new StorageError( + 'SEQUENCE_CONFLICT', + `Sequence ${event.sequence} is already assigned for run ${event.runId}`, + ) + } + const cursor = this.database + .prepare( + 'SELECT run_id, conversation_id, last_sequence, last_cursor, missing_from, missing_to, terminal FROM braid_run_cursors WHERE run_id = ?', + ) + .get(event.runId) as CursorRow | undefined + if (cursor && cursor.conversation_id !== event.conversationId) { + throw new StorageError( + 'RUN_CONVERSATION_CONFLICT', + `Run ${event.runId} belongs to another conversation`, + ) + } + const isMissingSequence = + cursor?.missing_from !== null && + cursor?.missing_from !== undefined && + cursor?.missing_to !== null && + cursor?.missing_to !== undefined && + event.sequence >= cursor.missing_from && + event.sequence <= cursor.missing_to + if (cursor?.terminal === 1 && !isMissingSequence) { + throw new StorageError( + 'TERMINAL_RUN_MUTATION', + `Run ${event.runId} is already terminal`, + ) + } + const key = contentKeys.get(event.conversationId) + if (!key) + throw new StorageError( + 'CONTENT_KEY_UNAVAILABLE', + 'Conversation content key is unavailable', + ) + const encoded = encryptPayload(event.payload, key) + this.database + .prepare( + `INSERT INTO braid_conversations(conversation_id, workspace_id, created_at) + VALUES (?, ?, ?) + ON CONFLICT(conversation_id) DO UPDATE SET workspace_id = excluded.workspace_id`, + ) + .run(event.conversationId, event.workspaceId, event.occurredAt) + const inserted = this.database + .prepare( + `INSERT INTO braid_journal_events( + workspace_id, conversation_id, run_id, event_id, provider_event_id, run_sequence, kind, cursor, + operation_id, payload, payload_checksum, occurred_at, received_at, terminal, redacted + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)`, + ) + .run( + event.workspaceId, + event.conversationId, + event.runId, + event.eventId, + event.providerEventId ?? null, + event.sequence, + event.kind, + event.cursor ?? null, + event.operationId ?? null, + encoded, + checksum, + event.occurredAt, + event.receivedAt ?? now(), + event.terminal === true ? 1 : 0, + ) + const storageId = asNumber(inserted.lastInsertRowid, 'storage_id') + encoded.fill(0) + if (snapshot?.eventId === event.eventId) snapshotStorageId = storageId + const refreshed = this.refreshCursor({ + runId: event.runId, + conversationId: event.conversationId, + sequence: event.sequence, + terminal: cursor?.terminal === 1 || event.terminal === true, + }) + if (refreshed.missingFrom !== null) gapRuns.add(event.runId) + acceptedEventIds.push(event.eventId) + } + this.advanceIncrementalProjection(acceptedEventIds) + if (snapshot !== undefined && acceptedEventIds.includes(snapshot.eventId)) { + if (preparedSnapshot === undefined) { + throw new StorageError('STATE_SNAPSHOT_INVALID', 'Snapshot key preparation was lost') + } + if (snapshotStorageId === undefined) + throw new StorageError('STATE_SNAPSHOT_UNBOUND', 'Snapshot event is not stored') + this.stateSnapshots.writeUnsafe(snapshot, snapshotStorageId, preparedSnapshot) + } + this.commit('append') + committed = true + createdKeys.length = 0 + if (snapshot !== undefined && acceptedEventIds.includes(snapshot.eventId)) { + createdSnapshotKeys.length = 0 + await this.stateSnapshots.pruneAfterCommitUnsafe() + } else if (createdSnapshotKeys.length > 0) { + await this.stateSnapshots.discardCredentials(createdSnapshotKeys) + createdSnapshotKeys.length = 0 + } + const missingHistory = [...gapRuns].flatMap((runId) => { + const row = this.database + .prepare('SELECT * FROM braid_run_cursors WHERE run_id = ?') + .get(runId) as CursorRow + const missing = missingFromCursor(row) + return missing ? [missing] : [] + }) + return { + acceptedEventIds, + duplicateEventIds, + missingHistory, + projectionChecksum: this.storedProjectionChecksum(), + } + } catch (error) { + if (!committed) this.rollback() + throw classifySqliteError(error) + } + } catch (error) { + for (const ref of createdKeys) await this.credentials.remove(ref).catch(() => undefined) + await this.stateSnapshots.discardCredentials(createdSnapshotKeys) + if (error instanceof StorageError && error.code === 'CONTENT_KEY_RACE') { + return this.appendUnsafe(events, snapshot) + } + throw error instanceof StorageError || error instanceof CredentialError + ? error + : classifySqliteError(error) + } finally { + for (const key of contentKeys.values()) key.fill(0) + } + } + + async events( + input: { + readonly workspaceId?: WorkspaceId + readonly conversationId?: ConversationId + readonly runId?: RunId + readonly afterStorageId?: number + } = {}, + ): Promise { + this.assertOpen() + const clauses: string[] = [] + const parameters: SqliteValue[] = [] + if (input.workspaceId !== undefined) { + clauses.push('workspace_id = ?') + parameters.push(input.workspaceId) + } + if (input.conversationId !== undefined) { + clauses.push('conversation_id = ?') + parameters.push(input.conversationId) + } + if (input.runId !== undefined) { + clauses.push('run_id = ?') + parameters.push(input.runId) + } + if (input.afterStorageId !== undefined) { + clauses.push('storage_id > ?') + parameters.push(input.afterStorageId) + } + const where = clauses.length === 0 ? '' : ` WHERE ${clauses.join(' AND ')}` + const rows = this.database + .prepare(`SELECT * FROM braid_journal_events${where} ORDER BY storage_id`) + .all(...parameters) as readonly SqliteEventRow[] + const result: StoredJournalEvent[] = [] + for (const row of rows) result.push(await this.storedEvent(row)) + return result + } + + async replay(input: { + readonly runId: RunId + readonly afterSequence?: number + }): Promise { + this.assertOpen() + const after = input.afterSequence ?? 0 + const cursor = this.database + .prepare('SELECT * FROM braid_run_cursors WHERE run_id = ?') + .get(input.runId) as CursorRow | undefined + const rows = this.database + .prepare( + 'SELECT * FROM braid_journal_events WHERE run_id = ? AND run_sequence > ? ORDER BY run_sequence', + ) + .all(input.runId, after) as readonly SqliteEventRow[] + const events: StoredJournalEvent[] = [] + for (const row of rows) events.push(await this.storedEvent(row)) + const missing = cursor ? missingFromCursor(cursor) : null + return { + events, + complete: missing === null, + missingHistory: missing ? [missing] : [], + lastSequence: cursor?.last_sequence ?? 0, + ...(cursor?.last_cursor === null || cursor?.last_cursor === undefined + ? {} + : { lastCursor: cursor.last_cursor }), + } + } + + async projection(): Promise { + this.assertOpen() + return structuredClone(this.currentProjection()) + } + + async projectionChecksum(): Promise { + this.assertOpen() + return this.storedProjectionChecksum() + } +} diff --git a/src/adapters/storage/sqlite-lifecycle.ts b/src/adapters/storage/sqlite-lifecycle.ts new file mode 100644 index 0000000..7a0b0d8 --- /dev/null +++ b/src/adapters/storage/sqlite-lifecycle.ts @@ -0,0 +1,357 @@ +import { randomUUID } from 'node:crypto' +import { closeSync } from 'node:fs' +import { link, lstat, rename, rm, stat, unlink } from 'node:fs/promises' +import { basename, dirname, join } from 'node:path' +import { parseConversationId, parseEventId } from '../../domain/ids.js' +import type { CredentialRef } from '../../ports/credentials.js' +import { credentialRef } from '../../ports/credentials.js' +import type { BackupReport } from '../../ports/storage.js' +import { bindDescriptorLifetime, openBoundSqliteDatabase } from './sqlite-bound-open.js' +import { configureCipherDatabase, type SqliteDatabase } from './sqlite-driver.js' +import { StorageError } from './sqlite-errors.js' +import { + assertApprovedPath, + classifySqliteError, + ensureDirectory, + openProtectedInput, + rejectSymlink, + secureArtifact, + syncDirectory, + syncFile, + validatePath, +} from './sqlite-paths.js' +import { SqliteProjectionStorage } from './sqlite-projection.js' +import type { RestoreManifest } from './sqlite-recovery.js' +import { restoreManifestPath } from './sqlite-recovery.js' +import { asString, credentialErrorCode } from './sqlite-rows.js' +import { + applyConnectionPragmas, + migrateSchema, + pragmaNumber, + pragmaString, + SQLITE_SCHEMA_VERSION, +} from './sqlite-schema.js' + +export abstract class SqliteLifecycleStorage extends SqliteProjectionStorage { + async initialize(): Promise { + const fromVersion = pragmaNumber(this.database, 'user_version') + if (fromVersion > SQLITE_SCHEMA_VERSION) + throw new StorageError( + 'SQLITE_SCHEMA_NEWER', + 'Database schema is newer than this Braid release', + ) + if (fromVersion < SQLITE_SCHEMA_VERSION && fromVersion > 0) + await this.createMigrationBackup(fromVersion) + if (fromVersion < SQLITE_SCHEMA_VERSION) { + this.begin() + try { + migrateSchema(this.database) + this.migrationHooks?.beforeVersionCommit?.(SQLITE_SCHEMA_VERSION) + if (fromVersion > 0) this.writeProjection(this.buildProjection()) + this.commit(fromVersion === 0 ? 'schema.initialize' : 'migration') + } catch (error) { + this.rollback() + throw classifySqliteError(error) + } + } + await this.reconcileContentKeyLifecycle() + await this.stateSnapshots.reconcileUnsafe() + await this.stateSnapshots.pruneUnsafe() + const stored = this.database + .prepare( + 'SELECT revision, state_json, checksum FROM braid_projection_state WHERE projection_name = ?', + ) + .get('canonical') as + | { readonly revision?: unknown; readonly state_json?: unknown; readonly checksum?: unknown } + | undefined + if (!stored) { + const built = this.buildProjection() + this.begin() + try { + this.writeProjection(built) + this.commit('projection.initialize') + } catch (error) { + this.rollback() + throw classifySqliteError(error) + } + } else { + this.assertStoredProjectionSummary(stored) + } + if (fromVersion === SQLITE_SCHEMA_VERSION) this.assertStartupConfiguration() + else { + const report = this.integrityReport() + if (!report.ok) throw new StorageError('STORAGE_INTEGRITY_FAILURE', report.errors.join('; ')) + } + await this.secureArtifacts() + } + + private assertStartupConfiguration(): void { + const failures: string[] = [] + if (pragmaNumber(this.database, 'foreign_keys') !== 1) failures.push('foreign_keys is disabled') + if (pragmaString(this.database, 'journal_mode')?.toLowerCase() !== 'wal') { + failures.push('journal_mode is not WAL') + } + if (pragmaString(this.database, 'cipher')?.toLowerCase() !== 'sqlcipher') { + failures.push('SQLCipher mode is not active') + } + if (failures.length > 0) { + throw new StorageError('STORAGE_INTEGRITY_FAILURE', failures.join('; ')) + } + } + + async reconcileContentKeyLifecycle(): Promise { + const rotations = this.database + .prepare( + 'SELECT conversation_id, old_credential_ref, new_credential_ref, redacted_event_id, phase FROM braid_content_key_rotations ORDER BY conversation_id', + ) + .all() as readonly { + readonly conversation_id: string + readonly old_credential_ref: string + readonly new_credential_ref: string + readonly redacted_event_id: string | null + readonly phase: string | null + }[] + for (const rotation of rotations) { + const conversationId = parseConversationId(rotation.conversation_id) + const oldRef = credentialRef(rotation.old_credential_ref) + const newRef = credentialRef(rotation.new_credential_ref) + const current = this.database + .prepare('SELECT credential_ref FROM braid_conversation_keys WHERE conversation_id = ?') + .get(conversationId) as { readonly credential_ref?: unknown } | undefined + if (!current) + throw new StorageError( + 'CONTENT_KEY_UNAVAILABLE', + `Conversation ${conversationId} has no content key`, + ) + const currentRef = credentialRef(asString(current.credential_ref, 'credential_ref')) + if (currentRef === newRef) { + if (rotation.redacted_event_id === null || rotation.phase !== 'rewritten') { + throw new StorageError( + 'CONTENT_KEY_ROTATION_UNVERIFIED', + `Conversation ${conversationId} has an unverified content key rotation`, + ) + } + const verified = await this.verifyConversation( + conversationId, + parseEventId(rotation.redacted_event_id), + newRef, + ) + if (!verified) { + throw new StorageError( + 'REDACTION_VERIFY_FAILED', + `Conversation ${conversationId} content rewrite failed verification`, + ) + } + await this.removeCredentialIfPresent(oldRef) + } else if (currentRef === oldRef) { + await this.removeCredentialIfPresent(newRef) + } else { + throw new StorageError( + 'CONTENT_KEY_ROTATION_CONFLICT', + `Conversation ${conversationId} has an unexpected content key rotation`, + ) + } + this.begin() + try { + this.database + .prepare('DELETE FROM braid_content_key_rotations WHERE conversation_id = ?') + .run(conversationId) + this.commit('key.reconcile') + } catch (error) { + this.rollback() + throw classifySqliteError(error) + } + } + + const destroyed = this.database + .prepare( + 'SELECT credential_ref FROM braid_conversation_keys WHERE destroyed = 1 ORDER BY conversation_id', + ) + .all() as readonly { readonly credential_ref?: unknown }[] + for (const row of destroyed) { + await this.removeCredentialIfPresent( + credentialRef(asString(row.credential_ref, 'credential_ref')), + ) + } + } + + async removeCredentialIfPresent(ref: CredentialRef): Promise { + try { + await this.credentials.remove(ref) + } catch (error) { + if (credentialErrorCode(error) !== 'CREDENTIAL_NOT_FOUND') throw error + } + } + + async createMigrationBackup(version: number): Promise { + await ensureDirectory(this.backupDirectory) + const path = join( + this.backupDirectory, + `${basename(this.path)}.pre-migration-v${version}-${Date.now()}.bak`, + ) + await this.backupUnsafe(path) + return path + } + + async backupUnsafe(path: string): Promise { + this.assertOpen() + const destination = validatePath(path, 'Backup path') + if (destination === this.path) + throw new StorageError('BACKUP_PATH', 'Backup cannot overwrite the live database') + await assertApprovedPath(destination, this.workspaceRoot ?? dirname(this.path), 'Backup path') + await ensureDirectory(dirname(destination)) + await rejectSymlink(destination, false) + try { + await lstat(destination) + throw new StorageError('BACKUP_EXISTS', 'Backup destination already exists') + } catch (error) { + if (error instanceof StorageError) throw error + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } + const temporary = `${destination}.partial-${randomUUID()}` + try { + this.durableBoundaryHook?.('before:backup') + this.database.prepare('VACUUM INTO ?').run(temporary) + await secureArtifact(temporary) + await this.assertEncryptedArtifact(temporary) + await this.assertReadableBackup(temporary) + await syncFile(temporary) + this.durableBoundaryHook?.('before:backup.publish') + try { + await link(temporary, destination) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EEXIST') { + throw new StorageError('BACKUP_EXISTS', 'Backup destination already exists', { + cause: error, + }) + } + throw error + } + await syncFile(destination) + await syncDirectory(dirname(destination)) + this.durableBoundaryHook?.('after:backup.publish') + this.durableBoundaryHook?.('before:backup.temp.remove') + await unlink(temporary) + await syncDirectory(dirname(destination)) + this.durableBoundaryHook?.('after:backup.temp.remove') + const bytes = (await stat(destination)).size + this.latestBackupPath = destination + try { + this.durableBoundaryHook?.('after:backup') + } catch { + // The backup is already complete; a test observer cannot invalidate it. + } + return { path: destination, bytes, encrypted: true } + } catch (error) { + await rm(temporary, { force: true }).catch(() => undefined) + throw error instanceof StorageError ? error : classifySqliteError(error) + } + } + + openDatabase(path: string): SqliteDatabase { + const opened = openBoundSqliteDatabase(path, this.databaseFactory, this.busyTimeoutMs) + try { + configureCipherDatabase(opened.database, this.databaseKey, { + newDatabase: opened.newDatabase, + }) + return bindDescriptorLifetime(opened.database, opened.fileDescriptor) + } catch (error) { + opened.database.close() + closeSync(opened.fileDescriptor) + throw error + } + } + + async assertEncryptedArtifact(path: string): Promise { + const handle = await openProtectedInput(path, 'encrypted storage artifact') + let bytes: Buffer + try { + bytes = await handle.readFile() + } finally { + await handle.close() + } + if (bytes.length < 32 || bytes.subarray(0, 16).toString('ascii') === 'SQLite format 3\u0000') { + throw new StorageError('STORAGE_NOT_ENCRYPTED', `Storage artifact is not encrypted: ${path}`) + } + } + + async secureArtifacts(): Promise { + await secureArtifact(this.path) + for (const path of [this.artifacts().wal, this.artifacts().sharedMemory]) { + await rejectSymlink(path, false) + try { + await stat(path) + await secureArtifact(path) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } + } + } + + async assertReadableBackup(path: string): Promise { + const database = this.openDatabase(path) + try { + const quick = database.prepare('PRAGMA quick_check').all() as readonly Record< + string, + unknown + >[] + const full = database.prepare('PRAGMA integrity_check').all() as readonly Record< + string, + unknown + >[] + const isOk = (rows: readonly Record[]) => + rows.length > 0 && rows.every((row) => Object.values(row).some((value) => value === 'ok')) + if (!isOk(quick) || !isOk(full)) { + throw new StorageError( + 'BACKUP_INTEGRITY_FAILURE', + 'Encrypted backup failed SQLite integrity checks', + ) + } + } finally { + database.close() + } + } + + async rollbackRestore(manifest: RestoreManifest): Promise { + if (this.database.open) this.database.close() + const liveExists = await stat(this.path) + .then(() => true) + .catch(() => false) + const displacedExists = await stat(manifest.displaced) + .then(() => true) + .catch(() => false) + const candidateInstalled = + displacedExists && + (manifest.phase === 'live-displaced' || + manifest.phase === 'installed' || + manifest.phase === 'verified' || + liveExists) + if (candidateInstalled && liveExists) { + await rm(this.path, { force: true }) + await syncDirectory(dirname(this.path)) + } + for (const [source, target] of [ + [manifest.displaced, this.path], + [manifest.displacedWal, `${this.path}-wal`], + [manifest.displacedSharedMemory, `${this.path}-shm`], + ] as const) { + const sourceExists = await stat(source) + .then(() => true) + .catch(() => false) + const targetExists = await stat(target) + .then(() => true) + .catch(() => false) + if (sourceExists && !targetExists) { + await rename(source, target) + await syncDirectory(dirname(this.path)) + } + } + await rm(manifest.temporary, { force: true }) + await unlink(restoreManifestPath(this.path)).catch((error: unknown) => { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + }) + await syncDirectory(dirname(this.path)) + this.database = this.openDatabase(this.path) + applyConnectionPragmas(this.database, this.busyTimeoutMs) + } +} diff --git a/src/adapters/storage/sqlite-maintenance.ts b/src/adapters/storage/sqlite-maintenance.ts new file mode 100644 index 0000000..4d6919b --- /dev/null +++ b/src/adapters/storage/sqlite-maintenance.ts @@ -0,0 +1,218 @@ +import { randomUUID } from 'node:crypto' +import { rename, rm, unlink } from 'node:fs/promises' +import { basename, dirname, join } from 'node:path' +import type { + BackupReport, + MigrationReport, + OperationIntent, + RestoreReport, +} from '../../ports/storage.js' +import type { SqliteDatabase } from './sqlite-driver.js' +import { StorageError } from './sqlite-errors.js' +import { + applyConnectionPragmas, + migrateSchema, + pragmaNumber, + SQLITE_SCHEMA_VERSION, +} from './sqlite-schema.js' +import { assertOperationRequestDigest } from './storage-validation.js' + +import { SqliteEffectsStorage } from './sqlite-effects.js' +import type { RestoreManifest } from './sqlite-recovery.js' +import { jsonValue } from './sqlite-rows.js' +import { classifySqliteError } from './sqlite-paths.js' +import { RESTORE_MANIFEST_VERSION } from './sqlite-types.js' +import { + assertApprovedPath, + copyProtectedFile, + secureArtifact, + syncDirectory, + validatePath, +} from './sqlite-paths.js' +import { restoreManifestPath, writeRestoreManifest } from './sqlite-recovery.js' + +export abstract class SqliteMaintenanceStorage extends SqliteEffectsStorage { + async migrate(operation: OperationIntent): Promise { + assertOperationRequestDigest(operation, {}) + const replay = await this.reuseMutation(operation) + if (replay !== undefined) return replay + let mutationCommitted = false + try { + const result = await this.writes.run(async () => { + this.assertOpen() + const fromVersion = pragmaNumber(this.database, 'user_version') + if (fromVersion >= SQLITE_SCHEMA_VERSION) { + return { fromVersion, toVersion: fromVersion, migrated: false } + } + const backupPath = await this.createMigrationBackup(fromVersion) + this.begin() + try { + migrateSchema(this.database) + this.migrationHooks?.beforeVersionCommit?.(SQLITE_SCHEMA_VERSION) + const projection = this.buildProjection() + this.writeProjection(projection) + this.commit('migration') + mutationCommitted = true + return { fromVersion, toVersion: SQLITE_SCHEMA_VERSION, migrated: true, backupPath } + } catch (error) { + this.rollback() + throw classifySqliteError(error) + } + }) + await this.completeMutation(operation, 'terminal', result) + return result + } catch (error) { + await this.completeMutationFailure(operation, error, mutationCommitted) + throw error + } + } + + async backup(input: { + readonly path: string + readonly operation: OperationIntent + }): Promise { + assertOperationRequestDigest(input.operation, { path: input.path }) + const replay = await this.reuseMutation(input.operation) + if (replay !== undefined) return replay + let mutationCommitted = false + try { + const result = await this.writes.run(async () => + this.withExclusiveLock(() => this.backupUnsafe(input.path)), + ) + mutationCommitted = true + await this.completeMutation(input.operation, 'terminal', jsonValue(result)) + return result + } catch (error) { + await this.completeMutationFailure(input.operation, error, mutationCommitted) + throw error + } + } + + async restore(input: { + readonly path: string + readonly operation: OperationIntent + }): Promise { + assertOperationRequestDigest(input.operation, { path: input.path }) + const replay = await this.reuseMutation(input.operation) + if (replay !== undefined) return replay + let mutationCommitted = false + try { + const result = await this.writes.run(async () => + this.withExclusiveLock(async () => { + this.assertOpen() + const source = validatePath(input.path, 'Backup path') + await assertApprovedPath( + source, + this.workspaceRoot ?? dirname(this.path), + 'Backup path', + false, + ) + await this.assertEncryptedArtifact(source) + const recoveryPath = join( + this.backupDirectory, + `${basename(this.path)}.pre-restore-${Date.now()}-${randomUUID()}.bak`, + ) + await this.backupUnsafe(recoveryPath) + const temporary = `${this.path}.restore-${randomUUID()}.tmp` + const displaced = `${this.path}.displaced-${randomUUID()}` + const displacedWal = `${this.path}-wal.displaced-${randomUUID()}` + const displacedSharedMemory = `${this.path}-shm.displaced-${randomUUID()}` + let manifest: RestoreManifest = { + version: RESTORE_MANIFEST_VERSION, + operationId: input.operation.operationId, + source, + recovery: recoveryPath, + temporary, + displaced, + displacedWal, + displacedSharedMemory, + phase: 'prepared', + } + await writeRestoreManifest(this.path, manifest, this.durableBoundaryHook) + try { + this.durableBoundaryHook?.('before:restore.copy') + await copyProtectedFile(source, temporary, 'backup') + await secureArtifact(temporary) + await syncDirectory(dirname(this.path)) + this.durableBoundaryHook?.('after:restore.copy') + manifest = { ...manifest, phase: 'candidate-ready' } + await writeRestoreManifest(this.path, manifest, this.durableBoundaryHook) + + let validationDatabase: SqliteDatabase | undefined + try { + validationDatabase = this.openDatabase(temporary) + applyConnectionPragmas(validationDatabase, this.busyTimeoutMs) + const report = this.integrityReportFor(validationDatabase) + if (!report.ok) + throw new StorageError('STORAGE_INTEGRITY_FAILURE', 'Backup integrity check failed') + } finally { + validationDatabase?.close() + } + await rm(`${temporary}-wal`, { force: true }) + await rm(`${temporary}-shm`, { force: true }) + await syncDirectory(dirname(this.path)) + + await this.database.close() + const moveIfPresent = async (sourcePath: string, targetPath: string, label: string) => { + try { + this.durableBoundaryHook?.(`before:restore.${label}`) + await rename(sourcePath, targetPath) + await syncDirectory(dirname(this.path)) + this.durableBoundaryHook?.(`after:restore.${label}`) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } + } + await moveIfPresent(this.path, displaced, 'displace') + await moveIfPresent(`${this.path}-wal`, displacedWal, 'displace-wal') + await moveIfPresent(`${this.path}-shm`, displacedSharedMemory, 'displace-shm') + manifest = { ...manifest, phase: 'live-displaced' } + await writeRestoreManifest(this.path, manifest, this.durableBoundaryHook) + this.durableBoundaryHook?.('before:restore.install') + await rename(temporary, this.path) + await syncDirectory(dirname(this.path)) + this.durableBoundaryHook?.('after:restore.install') + manifest = { ...manifest, phase: 'installed' } + await writeRestoreManifest(this.path, manifest, this.durableBoundaryHook) + this.database = this.openDatabase(this.path) + applyConnectionPragmas(this.database, this.busyTimeoutMs) + await this.initialize() + this.reserveRestoredOperationUnsafe(input.operation) + const integrity = this.integrityReport() + if (!integrity.ok) + throw new StorageError( + 'STORAGE_INTEGRITY_FAILURE', + 'Restored database is not healthy', + ) + manifest = { ...manifest, phase: 'verified' } + await writeRestoreManifest(this.path, manifest, this.durableBoundaryHook) + for (const [label, path] of [ + ['cleanup', displaced], + ['cleanup-wal', displacedWal], + ['cleanup-shm', displacedSharedMemory], + ] as const) { + this.durableBoundaryHook?.(`before:restore.${label}`) + await rm(path, { force: true }) + await syncDirectory(dirname(this.path)) + this.durableBoundaryHook?.(`after:restore.${label}`) + } + this.durableBoundaryHook?.('before:restore.manifest.remove') + await unlink(restoreManifestPath(this.path)) + await syncDirectory(dirname(this.path)) + this.durableBoundaryHook?.('after:restore.manifest.remove') + mutationCommitted = true + return { path: source, restored: true, integrity } + } catch (error) { + await this.rollbackRestore(manifest).catch(() => undefined) + throw error instanceof StorageError ? error : classifySqliteError(error) + } + }), + ) + await this.completeMutation(input.operation, 'terminal', jsonValue(result)) + return result + } catch (error) { + await this.completeMutationFailure(input.operation, error, mutationCommitted) + throw error + } + } +} diff --git a/src/adapters/storage/sqlite-operations.ts b/src/adapters/storage/sqlite-operations.ts new file mode 100644 index 0000000..8cb35ef --- /dev/null +++ b/src/adapters/storage/sqlite-operations.ts @@ -0,0 +1,271 @@ +import { canonicalJson } from '../../domain/canonical.js' +import { parseOperationId } from '../../domain/ids.js' +import { isCanonicalIsoDateTime } from '../../domain/text.js' +import type { + JsonValue, + OperationId, + OperationIntent, + OperationRecord, + OperationReservation, +} from '../../ports/storage.js' +import { assertPersistablePayload } from './sqlite-crypto.js' +import { StorageError } from './sqlite-errors.js' +import { assertOperationIntentInput } from './storage-validation.js' + +import { SqliteJournalStorage } from './sqlite-journal.js' +import type { OperationRow } from './sqlite-types.js' +import { asString, cloneJson, now, operationRecordFromRow } from './sqlite-rows.js' +import { classifySqliteError } from './sqlite-paths.js' + +export abstract class SqliteOperationStorage extends SqliteJournalStorage { + async reserveOperation(intent: OperationIntent): Promise { + assertOperationIntentInput(intent) + assertPersistablePayload(intent.request) + const createdAt = intent.createdAt ?? now() + return this.writes.run(async () => { + this.assertOpen() + this.begin() + try { + const existingRow = this.database + .prepare('SELECT * FROM braid_operation_records WHERE operation_id = ?') + .get(intent.operationId) as OperationRow | undefined + if (existingRow) { + const existing = operationRecordFromRow(existingRow) + if (existing.requestDigest !== intent.requestDigest) { + this.recordOperationConflictUnsafe( + intent.operationId, + existing.requestDigest, + intent.requestDigest, + createdAt, + ) + this.commit('operation.reserve.conflict') + return { + created: false, + record: { ...existing, status: 'conflict', updatedAt: createdAt }, + } + } + this.commit('operation.reserve.replay') + return { created: false, record: existing } + } + this.database + .prepare( + `INSERT INTO braid_operation_records( + operation_id, operation_kind, request_digest, request_json, status, result_json, created_at, updated_at + ) VALUES (?, ?, ?, ?, 'pending', NULL, ?, ?)`, + ) + .run( + intent.operationId, + intent.kind, + intent.requestDigest, + canonicalJson(intent.request), + createdAt, + createdAt, + ) + this.commit('operation.reserve') + return { + created: true, + record: { + ...intent, + createdAt, + updatedAt: createdAt, + status: 'pending', + }, + } + } catch (error) { + this.rollback() + throw classifySqliteError(error) + } + }) + } + + async completeOperation(input: { + readonly operationId: OperationId + readonly requestDigest: string + readonly status: Exclude + readonly result?: JsonValue + readonly updatedAt?: string + }): Promise { + parseOperationId(input.operationId) + if (!/^[0-9a-f]{64}$/u.test(input.requestDigest)) { + throw new StorageError( + 'OPERATION_INVALID', + 'Operation request digest must be a SHA-256 digest', + ) + } + if (input.updatedAt !== undefined && !isCanonicalIsoDateTime(input.updatedAt)) { + throw new StorageError( + 'OPERATION_INVALID', + 'Operation updatedAt must be a canonical ISO date', + ) + } + if (input.result !== undefined) assertPersistablePayload(input.result) + return this.writes.run(async () => { + this.assertOpen() + this.begin() + try { + const row = this.database + .prepare('SELECT * FROM braid_operation_records WHERE operation_id = ?') + .get(input.operationId) as OperationRow | undefined + if (!row) + throw new StorageError( + 'OPERATION_NOT_FOUND', + `Operation ${input.operationId} was not reserved`, + ) + const current = operationRecordFromRow(row) + if (current.requestDigest !== input.requestDigest) { + this.recordOperationConflictUnsafe( + input.operationId, + current.requestDigest, + input.requestDigest, + input.updatedAt ?? now(), + ) + this.commit('operation.complete.conflict') + return { ...current, status: 'conflict', updatedAt: input.updatedAt ?? now() } + } + if (current.status !== 'pending' && current.status !== 'unknown') { + this.commit('operation.complete.replay') + return current + } + const updatedAt = input.updatedAt ?? now() + this.database + .prepare( + 'UPDATE braid_operation_records SET status = ?, result_json = ?, updated_at = ? WHERE operation_id = ?', + ) + .run( + input.status, + input.result === undefined ? null : canonicalJson(input.result), + updatedAt, + input.operationId, + ) + this.commit('operation.complete') + return { + ...current, + status: input.status, + updatedAt, + ...(input.result === undefined ? {} : { result: cloneJson(input.result) }), + } + } catch (error) { + this.rollback() + throw classifySqliteError(error) + } + }) + } + + async recordOperationConflict(input: { + readonly operationId: OperationId + readonly requestDigest: string + readonly attemptedDigest: string + readonly occurredAt?: string + }): Promise { + parseOperationId(input.operationId) + if ( + !/^[0-9a-f]{64}$/u.test(input.requestDigest) || + !/^[0-9a-f]{64}$/u.test(input.attemptedDigest) + ) { + throw new StorageError( + 'OPERATION_INVALID', + 'Operation conflict digests must be SHA-256 digests', + ) + } + if (input.requestDigest === input.attemptedDigest) { + throw new StorageError('OPERATION_INVALID', 'A conflict requires a different digest') + } + if (input.occurredAt !== undefined && !isCanonicalIsoDateTime(input.occurredAt)) { + throw new StorageError( + 'OPERATION_INVALID', + 'Operation conflict occurredAt must be a canonical ISO date', + ) + } + await this.writes.run(async () => { + this.assertOpen() + this.begin() + try { + const current = this.database + .prepare('SELECT request_digest FROM braid_operation_records WHERE operation_id = ?') + .get(input.operationId) as { readonly request_digest?: unknown } | undefined + if (!current) { + throw new StorageError( + 'OPERATION_NOT_FOUND', + `Operation ${input.operationId} was not reserved`, + ) + } + if (asString(current.request_digest, 'request_digest') !== input.requestDigest) { + throw new StorageError( + 'OPERATION_CONFLICT', + `Operation ${input.operationId} is bound to another request digest`, + ) + } + this.recordOperationConflictUnsafe( + input.operationId, + input.requestDigest, + input.attemptedDigest, + input.occurredAt ?? now(), + ) + this.commit('operation.conflict') + } catch (error) { + this.rollback() + throw classifySqliteError(error) + } + }) + } + + async operation(operationId: OperationId): Promise { + this.assertOpen() + const row = this.database + .prepare('SELECT * FROM braid_operation_records WHERE operation_id = ?') + .get(operationId) as OperationRow | undefined + return row ? operationRecordFromRow(row) : null + } + + recordOperationConflictUnsafe( + operationId: OperationId, + originalDigest: string, + attemptedDigest: string, + occurredAt: string, + ): void { + this.database + .prepare( + `INSERT INTO braid_operation_conflicts(operation_id, attempted_digest, original_digest, occurred_at) + VALUES (?, ?, ?, ?) ON CONFLICT(operation_id, attempted_digest) DO NOTHING`, + ) + .run(operationId, attemptedDigest, originalDigest, occurredAt) + } + + reserveRestoredOperationUnsafe(intent: OperationIntent): void { + const existing = this.database + .prepare('SELECT request_digest FROM braid_operation_records WHERE operation_id = ?') + .get(intent.operationId) as { readonly request_digest?: unknown } | undefined + if (existing) { + if (asString(existing.request_digest, 'request_digest') !== intent.requestDigest) { + throw new StorageError( + 'OPERATION_CONFLICT', + `Operation ${intent.operationId} conflicts with the restored database`, + ) + } + return + } + const createdAt = intent.createdAt ?? now() + this.begin() + try { + this.database + .prepare( + `INSERT INTO braid_operation_records( + operation_id, operation_kind, request_digest, request_json, status, + result_json, created_at, updated_at + ) VALUES (?, ?, ?, ?, 'pending', NULL, ?, ?)`, + ) + .run( + intent.operationId, + intent.kind, + intent.requestDigest, + canonicalJson(intent.request), + createdAt, + createdAt, + ) + this.commit('restore.operation.reserve') + } catch (error) { + this.rollback() + throw classifySqliteError(error) + } + } +} diff --git a/src/adapters/storage/sqlite-paths.ts b/src/adapters/storage/sqlite-paths.ts new file mode 100644 index 0000000..f48eeb7 --- /dev/null +++ b/src/adapters/storage/sqlite-paths.ts @@ -0,0 +1,208 @@ +import { closeSync, constants, fchmodSync, fstatSync, openSync, statSync } from 'node:fs' +import { chmod, lstat, mkdir, open as openFile } from 'node:fs/promises' +import { isAbsolute, join, relative, resolve, sep } from 'node:path' +import { StorageError } from './sqlite-errors.js' + +export function validatePath(path: string, field: string): string { + if (!isAbsolute(path)) throw new StorageError('STORAGE_PATH', `${field} must be an absolute path`) + return resolve(path) +} + +export function isInsideRoot(path: string, root: string): boolean { + const child = relative(root, path) + return child === '' || (child !== '..' && !child.startsWith(`..${sep}`) && !isAbsolute(child)) +} + +export async function assertApprovedPath( + path: string, + root: string, + field: string, + allowMissing = true, +): Promise { + const candidate = validatePath(path, field) + const approved = validatePath(root, 'Approved root') + if (!isInsideRoot(candidate, approved)) { + throw new StorageError('STORAGE_APPROVED_ROOT', `${field} must remain inside the approved root`) + } + const rootStat = await lstat(approved).catch((error: unknown) => { + throw new StorageError('STORAGE_APPROVED_ROOT', 'Approved root could not be inspected', { + cause: error, + }) + }) + if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) + throw new StorageError('STORAGE_APPROVED_ROOT', 'Approved root must be a directory') + const components = candidate.slice(approved.length).split(sep).filter(Boolean) + let current = approved + for (const component of components) { + current = join(current, component) + try { + const entry = await lstat(current) + if (entry.isSymbolicLink()) { + throw new StorageError('STORAGE_SYMLINK', `${field} contains a symbolic link`) + } + if (current !== candidate && !entry.isDirectory()) { + throw new StorageError('STORAGE_PATH', `${field} contains a non-directory component`) + } + } catch (error) { + if (error instanceof StorageError) throw error + if ((error as NodeJS.ErrnoException).code === 'ENOENT' && allowMissing) break + throw new StorageError('STORAGE_PATH', `Cannot inspect ${field}`, { cause: error }) + } + } +} + +export function isNewDatabaseFile(path: string): boolean { + try { + const size = statSync(path).size + if (size === 0) { + throw new StorageError('STORAGE_CORRUPT_EMPTY', `Existing SQLite path is empty: ${path}`) + } + return false + } catch (error) { + if (error instanceof StorageError) throw error + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return true + throw new StorageError('STORAGE_PATH', `Cannot inspect SQLite path ${path}`, { cause: error }) + } +} + +export async function ensureDirectory(path: string): Promise { + await mkdir(path, { recursive: true, mode: 0o700 }) + await chmod(path, 0o700) +} + +export async function openProtectedInput(path: string, field: string) { + let handle: Awaited> | undefined + try { + handle = await openFile(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)) + const metadata = await handle.stat() + if (!metadata.isFile() || metadata.nlink !== 1) { + throw new StorageError( + 'STORAGE_INPUT_IDENTITY', + `${field} must be a single linked regular file`, + ) + } + return handle + } catch (error) { + await handle?.close().catch(() => undefined) + if (error instanceof StorageError) throw error + throw new StorageError('STORAGE_INPUT_UNREADABLE', `Cannot open ${field}`, { cause: error }) + } +} + +export async function copyProtectedFile( + source: string, + destination: string, + field: string, +): Promise { + const sourceHandle = await openProtectedInput(source, field) + let destinationHandle: Awaited> | undefined + const buffer = Buffer.allocUnsafe(1024 * 1024) + try { + destinationHandle = await openFile( + destination, + constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), + 0o600, + ) + for (;;) { + const result = await sourceHandle.read(buffer, 0, buffer.length, null) + if (result.bytesRead === 0) break + let written = 0 + while (written < result.bytesRead) { + const output = await destinationHandle.write(buffer, written, result.bytesRead - written) + written += output.bytesWritten + } + } + await destinationHandle.sync() + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EEXIST') { + throw new StorageError('STORAGE_TEMP_EXISTS', `Temporary ${field} already exists`, { + cause: error, + }) + } + throw error instanceof StorageError + ? error + : new StorageError('STORAGE_COPY_FAILED', `Could not copy ${field}`, { cause: error }) + } finally { + buffer.fill(0) + await destinationHandle?.close().catch(() => undefined) + await sourceHandle.close().catch(() => undefined) + } +} + +export async function rejectSymlink(path: string, required: boolean): Promise { + try { + const entry = await lstat(path) + if (entry.isSymbolicLink()) + throw new StorageError('STORAGE_SYMLINK', `Symlinked storage path rejected: ${path}`) + if (required && !entry.isFile()) + throw new StorageError('STORAGE_PATH', `Storage path is not a file: ${path}`) + } catch (error) { + if (error instanceof StorageError) throw error + if (required || (error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } +} + +export async function secureArtifact(path: string): Promise { + let fileDescriptor: number | undefined + try { + fileDescriptor = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)) + const metadata = fstatSync(fileDescriptor) + if (!metadata.isFile() || metadata.nlink !== 1) { + throw new StorageError( + 'STORAGE_INPUT_IDENTITY', + `Storage artifact is not a regular single-linked file: ${path}`, + ) + } + fchmodSync(fileDescriptor, 0o600) + } catch (error) { + throw new StorageError('STORAGE_PERMISSIONS', `Could not restrict storage artifact ${path}`, { + cause: error, + }) + } finally { + if (fileDescriptor !== undefined) closeSync(fileDescriptor) + } +} + +export async function syncFile(path: string): Promise { + const handle = await openFile(path, 'r') + try { + await handle.sync() + } finally { + await handle.close() + } +} + +export async function syncDirectory(path: string): Promise { + if (process.platform === 'win32') return + const handle = await openFile(path, 'r') + try { + await handle.sync() + } finally { + await handle.close() + } +} + +export function processIsAlive(pid: number): boolean { + if (!Number.isSafeInteger(pid) || pid <= 0) return false + try { + process.kill(pid, 0) + return true + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'EPERM' + } +} + +export function classifySqliteError(error: unknown): StorageError { + if (error instanceof StorageError) return error + const message = error instanceof Error ? error.message : String(error) + if (/busy|locked/iu.test(message)) + return new StorageError('STORAGE_LOCKED', 'SQLite is locked', { cause: error }) + if (/full|disk|no space/iu.test(message)) { + return new StorageError( + 'STORAGE_COMMIT_FAILED', + 'SQLite could not commit because storage is full', + { cause: error }, + ) + } + return new StorageError('STORAGE_COMMIT_FAILED', 'SQLite transaction failed', { cause: error }) +} diff --git a/src/adapters/storage/sqlite-projection.ts b/src/adapters/storage/sqlite-projection.ts new file mode 100644 index 0000000..f8b51bf --- /dev/null +++ b/src/adapters/storage/sqlite-projection.ts @@ -0,0 +1,417 @@ +import { canonicalJson } from '../../domain/canonical.js' +import { parseConversationId, parseRunId } from '../../domain/ids.js' +import type { + ConversationId, + EventId, + JsonValue, + ProjectionRun, + ProjectionSnapshot, + RunId, + StateSnapshot, + StoredStateSnapshot, +} from '../../ports/storage.js' +import { isJsonValue, PROJECTION_SCHEMA_VERSION } from '../../ports/storage.js' +import { SqliteContentStorage } from './sqlite-content.js' +import { StorageError } from './sqlite-errors.js' +import { + appendProjectionEventDigest, + asNumber, + asString, + PROJECTION_EVENT_DIGEST_SEED, + projectionChecksum, + projectionFromRows, + projectionRunDigest, +} from './sqlite-rows.js' +import { SqliteStateSnapshotStore } from './sqlite-state-snapshots.js' +import type { CursorRow, SqliteEventRow } from './sqlite-types.js' + +interface StoredProjectionRow { + readonly revision?: unknown + readonly state_json?: unknown + readonly checksum?: unknown +} + +interface ProjectionMetadata { + readonly schemaVersion: number + readonly eventCount: number + readonly revision: number + readonly eventIdsDigest: string + readonly runsDigest: string + readonly checksum: string +} + +const SHA256_HEX = /^[0-9a-f]{64}$/u + +function projectionMismatch(cause?: unknown): StorageError { + return new StorageError( + 'PROJECTION_CHECKSUM_MISMATCH', + 'Persisted projection checksum does not verify', + cause === undefined ? undefined : { cause }, + ) +} + +function projectionMetadataFromRecord(record: Record): ProjectionMetadata { + const schemaVersion = record.schemaVersion + const eventCount = record.eventCount + const revision = record.revision + const eventIdsDigest = record.eventIdsDigest + const runsDigest = record.runsDigest + const checksum = record.checksum + if ( + schemaVersion !== PROJECTION_SCHEMA_VERSION || + typeof eventCount !== 'number' || + !Number.isSafeInteger(eventCount) || + eventCount < 0 || + typeof revision !== 'number' || + !Number.isSafeInteger(revision) || + revision < 0 || + typeof eventIdsDigest !== 'string' || + !SHA256_HEX.test(eventIdsDigest) || + typeof runsDigest !== 'string' || + !SHA256_HEX.test(runsDigest) || + typeof checksum !== 'string' || + !SHA256_HEX.test(checksum) + ) { + throw projectionMismatch() + } + return { schemaVersion, eventCount, revision, eventIdsDigest, runsDigest, checksum } +} + +export abstract class SqliteProjectionStorage extends SqliteContentStorage { + protected readonly stateSnapshots = new SqliteStateSnapshotStore({ + database: () => this.database, + credentials: this.credentials, + scopeId: () => this.snapshotScopeId(), + writes: this.writes, + durableBoundary: (boundary) => this.durableBoundaryHook?.(boundary), + }) + + async latestStateSnapshot(): Promise { + this.assertOpen() + return this.stateSnapshots.latest() + } + + async writeStateSnapshot(snapshot: StateSnapshot): Promise { + this.assertOpen() + await this.stateSnapshots.write(snapshot) + } + + async runSequences(): Promise { + this.assertOpen() + const rows = this.database + .prepare('SELECT * FROM braid_run_cursors ORDER BY run_id') + .all() as readonly CursorRow[] + return rows.map((cursor) => ({ + runId: parseRunId(cursor.run_id), + conversationId: parseConversationId(cursor.conversation_id), + lastSequence: cursor.last_sequence, + lastCursor: cursor.last_cursor, + missingFrom: cursor.missing_from, + missingTo: cursor.missing_to, + terminal: cursor.terminal === 1, + })) + } + + currentProjection(): ProjectionSnapshot { + const projection = this.buildProjection() + this.assertStoredProjection(projection) + return projection + } + + assertStoredProjection(projection: ProjectionSnapshot, known?: StoredProjectionRow): void { + const persisted = this.persistedProjection(known) + const expectedMetadata = this.projectionMetadata(projection) + if ( + persisted.schemaVersion !== expectedMetadata.schemaVersion || + persisted.eventCount !== expectedMetadata.eventCount || + persisted.revision !== expectedMetadata.revision || + persisted.eventIdsDigest !== expectedMetadata.eventIdsDigest || + persisted.runsDigest !== expectedMetadata.runsDigest || + persisted.checksum !== projection.checksum + ) { + throw new StorageError( + 'PROJECTION_CHECKSUM_MISMATCH', + 'Persisted projection differs from journal replay', + ) + } + } + + assertStoredProjectionSummary(known?: StoredProjectionRow): void { + const persisted = this.persistedProjection(known) + const count = this.database + .prepare('SELECT COUNT(*) AS event_count FROM braid_journal_events') + .get() as { readonly event_count?: unknown } + const eventCount = asNumber(count.event_count, 'projection.event_count') + const cursors = this.database + .prepare('SELECT * FROM braid_run_cursors ORDER BY run_id') + .all() as readonly CursorRow[] + const runsDigest = projectionRunDigest( + cursors.map((cursor) => ({ + runId: parseRunId(cursor.run_id), + conversationId: parseConversationId(cursor.conversation_id), + lastSequence: cursor.last_sequence, + lastCursor: cursor.last_cursor, + missingFrom: cursor.missing_from, + missingTo: cursor.missing_to, + terminal: cursor.terminal === 1, + })), + ) + if ( + persisted.eventCount !== eventCount || + persisted.revision !== eventCount || + persisted.runsDigest !== runsDigest + ) { + throw new StorageError( + 'PROJECTION_CHECKSUM_MISMATCH', + 'Persisted projection summary differs from committed journal metadata', + ) + } + } + + private persistedProjection(known?: StoredProjectionRow): ProjectionMetadata { + const stored = + known ?? + (this.database + .prepare( + 'SELECT revision, state_json, checksum FROM braid_projection_state WHERE projection_name = ?', + ) + .get('canonical') as StoredProjectionRow | undefined) + if (!stored) { + throw new StorageError('PROJECTION_CHECKSUM_MISMATCH', 'Projection state is missing') + } + let state: unknown + try { + state = JSON.parse(asString(stored.state_json, 'state_json')) as unknown + } catch (error) { + throw projectionMismatch(error) + } + if ( + state === null || + typeof state !== 'object' || + Array.isArray(state) || + !isJsonValue(state) + ) { + throw projectionMismatch() + } + const record = state as Record + const metadata = projectionMetadataFromRecord(record) + if ( + asNumber(stored.revision, 'projection.revision') !== metadata.revision || + asString(stored.checksum, 'projection.checksum') !== metadata.checksum || + projectionChecksum({ + schemaVersion: metadata.schemaVersion, + eventCount: metadata.eventCount, + revision: metadata.revision, + eventIdsDigest: metadata.eventIdsDigest, + runsDigest: metadata.runsDigest, + }) !== metadata.checksum + ) { + throw projectionMismatch() + } + return metadata + } + + buildProjection(): ProjectionSnapshot { + const rows = this.database + .prepare('SELECT * FROM braid_journal_events ORDER BY storage_id') + .all() as readonly SqliteEventRow[] + const cursors = this.database + .prepare('SELECT * FROM braid_run_cursors ORDER BY run_id') + .all() as readonly CursorRow[] + return projectionFromRows(rows, cursors) + } + + writeProjection(projection: ProjectionSnapshot): void { + const base = { ...this.projectionMetadata(projection), checksum: projection.checksum } + this.database + .prepare( + `INSERT INTO braid_projection_state(projection_name, revision, checksum, state_json) + VALUES ('canonical', ?, ?, ?) + ON CONFLICT(projection_name) DO UPDATE SET revision = excluded.revision, checksum = excluded.checksum, state_json = excluded.state_json`, + ) + .run(projection.revision, projection.checksum, canonicalJson(base)) + } + + projectionMetadata(projection: ProjectionSnapshot): { + readonly schemaVersion: number + readonly eventCount: number + readonly revision: number + readonly eventIdsDigest: string + readonly runsDigest: string + } { + let eventIdsDigest = PROJECTION_EVENT_DIGEST_SEED + for (const eventId of projection.eventIds) { + eventIdsDigest = appendProjectionEventDigest(eventIdsDigest, eventId) + } + return { + schemaVersion: projection.schemaVersion, + eventCount: projection.eventCount, + revision: projection.revision, + eventIdsDigest, + runsDigest: projectionRunDigest(projection.runs), + } + } + + storedProjectionChecksum(): string { + const row = this.database + .prepare('SELECT checksum FROM braid_projection_state WHERE projection_name = ?') + .get('canonical') as { readonly checksum?: unknown } | undefined + if (!row) throw new StorageError('PROJECTION_CHECKSUM_MISMATCH', 'Projection state is missing') + return asString(row.checksum, 'projection.checksum') + } + + advanceIncrementalProjection(eventIds: readonly EventId[]): void { + if (eventIds.length === 0) return + const row = this.database + .prepare('SELECT revision, state_json FROM braid_projection_state WHERE projection_name = ?') + .get('canonical') as + | { readonly revision?: unknown; readonly state_json?: unknown } + | undefined + if (!row) throw new StorageError('PROJECTION_CHECKSUM_MISMATCH', 'Projection state is missing') + let state: Record + try { + const parsed = JSON.parse(asString(row.state_json, 'state_json')) as unknown + if ( + parsed === null || + typeof parsed !== 'object' || + Array.isArray(parsed) || + !isJsonValue(parsed) + ) { + throw new Error('projection state is not an object') + } + state = parsed as Record + } catch (error) { + throw new StorageError('PROJECTION_CHECKSUM_MISMATCH', 'Projection state is invalid', { + cause: error, + }) + } + let eventIdsDigest = + typeof state.eventIdsDigest === 'string' ? state.eventIdsDigest : PROJECTION_EVENT_DIGEST_SEED + for (const eventId of eventIds) + eventIdsDigest = appendProjectionEventDigest(eventIdsDigest, eventId) + const runs = this.database + .prepare('SELECT * FROM braid_run_cursors ORDER BY run_id') + .all() as readonly CursorRow[] + const projectionRuns = runs.map((cursor) => ({ + runId: parseRunId(cursor.run_id), + conversationId: parseConversationId(cursor.conversation_id), + lastSequence: cursor.last_sequence, + lastCursor: cursor.last_cursor, + missingFrom: cursor.missing_from, + missingTo: cursor.missing_to, + terminal: cursor.terminal === 1, + })) + const eventCount = + typeof state.eventCount === 'number' ? state.eventCount + eventIds.length : eventIds.length + const revision = asNumber(row.revision, 'projection.revision') + eventIds.length + const checksum = projectionChecksum({ + schemaVersion: PROJECTION_SCHEMA_VERSION, + eventCount, + revision, + eventIdsDigest, + runsDigest: projectionRunDigest(projectionRuns), + }) + const next = { + schemaVersion: PROJECTION_SCHEMA_VERSION, + eventCount, + revision, + eventIdsDigest, + runsDigest: projectionRunDigest(projectionRuns), + checksum, + } + this.database + .prepare( + `UPDATE braid_projection_state + SET revision = ?, checksum = ?, state_json = ? + WHERE projection_name = 'canonical'`, + ) + .run(revision, checksum, canonicalJson(next)) + } + + upsertCursor(input: { + readonly runId: RunId + readonly conversationId: ConversationId + readonly lastSequence: number + readonly lastCursor: string | null + readonly missingFrom: number | null + readonly missingTo: number | null + readonly terminal: boolean + }): void { + this.database + .prepare( + `INSERT INTO braid_run_cursors(run_id, conversation_id, last_sequence, last_cursor, missing_from, missing_to, terminal) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(run_id) DO UPDATE SET conversation_id = excluded.conversation_id, + last_sequence = excluded.last_sequence, last_cursor = excluded.last_cursor, + missing_from = excluded.missing_from, missing_to = excluded.missing_to, terminal = excluded.terminal`, + ) + .run( + input.runId, + input.conversationId, + input.lastSequence, + input.lastCursor, + input.missingFrom, + input.missingTo, + input.terminal ? 1 : 0, + ) + } + + refreshCursor(input: { + readonly runId: RunId + readonly conversationId: ConversationId + readonly sequence: number + readonly terminal: boolean + }): CursorRow { + const previous = this.database + .prepare( + 'SELECT last_sequence, last_cursor, missing_to FROM braid_run_cursors WHERE run_id = ?', + ) + .get(input.runId) as + | { + readonly last_sequence?: unknown + readonly last_cursor?: unknown + readonly missing_to?: unknown + } + | undefined + let contiguous = previous ? asNumber(previous.last_sequence, 'last_sequence') : 0 + let lastCursor = + previous?.last_cursor === null || previous?.last_cursor === undefined + ? null + : asString(previous.last_cursor, 'last_cursor') + const maximum = Math.max( + input.sequence, + previous?.missing_to === null || previous?.missing_to === undefined + ? contiguous + : asNumber(previous.missing_to, 'missing_to'), + ) + while (true) { + const next = this.database + .prepare('SELECT cursor FROM braid_journal_events WHERE run_id = ? AND run_sequence = ?') + .get(input.runId, contiguous + 1) as { readonly cursor?: unknown } | undefined + if (!next) break + contiguous += 1 + lastCursor = + next.cursor === null || next.cursor === undefined ? null : asString(next.cursor, 'cursor') + } + const missingFrom = contiguous < maximum ? contiguous + 1 : null + const missingTo = contiguous < maximum ? maximum : null + this.upsertCursor({ + runId: input.runId, + conversationId: input.conversationId, + lastSequence: contiguous, + lastCursor, + missingFrom, + missingTo, + terminal: input.terminal, + }) + return { + run_id: input.runId, + conversation_id: input.conversationId, + last_sequence: contiguous, + last_cursor: lastCursor, + missing_from: missingFrom, + missing_to: missingTo, + terminal: input.terminal ? 1 : 0, + } + } +} diff --git a/src/adapters/storage/sqlite-queue.ts b/src/adapters/storage/sqlite-queue.ts new file mode 100644 index 0000000..64919a0 --- /dev/null +++ b/src/adapters/storage/sqlite-queue.ts @@ -0,0 +1,59 @@ +import { StorageError } from './sqlite-errors.js' + +interface QueueTask { + readonly operation: () => Promise + readonly resolve: (value: T | PromiseLike) => void + readonly reject: (reason?: unknown) => void +} + +export class BoundedWriteQueue { + readonly #max: number + readonly #tasks: QueueTask[] = [] + #running = false + #drainPromise: Promise | undefined + + constructor(max: number) { + this.#max = max + } + + run(operation: () => Promise): Promise { + if (this.#tasks.length >= this.#max) { + return Promise.reject( + new StorageError('STORAGE_QUEUE_FULL', 'The serialized SQLite writer queue is full'), + ) + } + return new Promise((resolve, reject) => { + this.#tasks.push({ operation, resolve, reject } as QueueTask) + void this.#drain() + }) + } + + async #drain(): Promise { + if (this.#running) return this.#drainPromise + this.#running = true + const drain = (async () => { + try { + while (this.#tasks.length > 0) { + const task = this.#tasks.shift() + if (!task) continue + try { + task.resolve(await task.operation()) + } catch (error) { + task.reject(error) + } + } + } finally { + this.#running = false + this.#drainPromise = undefined + } + })() + this.#drainPromise = drain + return drain + } + + async drain(): Promise { + while (this.#running || this.#tasks.length > 0) { + await (this.#drainPromise ?? this.#drain()) + } + } +} diff --git a/src/adapters/storage/sqlite-recovery.ts b/src/adapters/storage/sqlite-recovery.ts new file mode 100644 index 0000000..269bca8 --- /dev/null +++ b/src/adapters/storage/sqlite-recovery.ts @@ -0,0 +1,212 @@ +import { randomUUID } from 'node:crypto' +import { constants } from 'node:fs' +import { lstat, open as openFile, rename, rm, stat, unlink } from 'node:fs/promises' +import { dirname } from 'node:path' +import { canonicalJson } from '../../domain/canonical.js' +import { StorageError } from './sqlite-errors.js' +import { + assertApprovedPath, + openProtectedInput, + processIsAlive, + syncDirectory, +} from './sqlite-paths.js' +import type { DurableBoundaryHook } from './sqlite-types.js' +import { RESTORE_MANIFEST_VERSION } from './sqlite-types.js' + +export interface RestoreManifest { + readonly version: 1 + readonly operationId: string + readonly source: string + readonly recovery: string + readonly temporary: string + readonly displaced: string + readonly displacedWal: string + readonly displacedSharedMemory: string + readonly phase: 'prepared' | 'candidate-ready' | 'live-displaced' | 'installed' | 'verified' +} + +export function restoreManifestPath(databasePath: string): string { + return `${databasePath}.restore.manifest` +} + +export async function readRestoreManifest( + databasePath: string, +): Promise { + const path = restoreManifestPath(databasePath) + try { + await lstat(path) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined + throw error + } + let handle: Awaited> | undefined + try { + handle = await openProtectedInput(path, 'restore manifest') + const raw = await handle.readFile({ encoding: 'utf8' }) + if (raw.length > 16 * 1024) + throw new StorageError('RESTORE_MANIFEST_INVALID', 'Restore manifest is too large') + const parsed = JSON.parse(raw) as Partial + if ( + parsed.version !== RESTORE_MANIFEST_VERSION || + typeof parsed.operationId !== 'string' || + typeof parsed.source !== 'string' || + typeof parsed.recovery !== 'string' || + typeof parsed.temporary !== 'string' || + typeof parsed.displaced !== 'string' || + typeof parsed.displacedWal !== 'string' || + typeof parsed.displacedSharedMemory !== 'string' || + !['prepared', 'candidate-ready', 'live-displaced', 'installed', 'verified'].includes( + parsed.phase ?? '', + ) + ) { + throw new StorageError('RESTORE_MANIFEST_INVALID', 'Restore manifest is invalid') + } + return parsed as RestoreManifest + } catch (error) { + throw error instanceof StorageError + ? error + : new StorageError('RESTORE_MANIFEST_INVALID', 'Restore manifest could not be read', { + cause: error, + }) + } finally { + await handle?.close().catch(() => undefined) + } +} + +export async function writeRestoreManifest( + databasePath: string, + manifest: RestoreManifest, + hook?: DurableBoundaryHook, +): Promise { + const path = restoreManifestPath(databasePath) + const temporary = `${path}.write-${randomUUID()}` + let handle: Awaited> | undefined + try { + hook?.(`before:restore.manifest.${manifest.phase}`) + handle = await openFile( + temporary, + constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), + 0o600, + ) + await handle.writeFile(`${canonicalJson(manifest)}\n`) + await handle.sync() + await handle.close() + handle = undefined + await rename(temporary, path) + await syncDirectory(dirname(databasePath)) + hook?.(`after:restore.manifest.${manifest.phase}`) + } catch (error) { + await handle?.close().catch(() => undefined) + await rm(temporary, { force: true }).catch(() => undefined) + throw error instanceof StorageError + ? error + : new StorageError('RESTORE_MANIFEST_WRITE_FAILED', 'Restore manifest could not be written', { + cause: error, + }) + } +} + +export async function acquireExclusiveLock(path: string): Promise<{ + readonly release: () => Promise +}> { + // This file coordinates live processes only. A power loss ends every holder, + // so forcing the file and parent directory to disk adds latency without + // strengthening exclusion; stale files are still resolved by their PID. + for (;;) { + let handle: Awaited> | undefined + try { + handle = await openFile(path, 'wx', 0o600) + await handle.writeFile(`${canonicalJson({ version: 1, pid: process.pid })}\n`) + await handle.close() + handle = undefined + let released = false + return { + release: async () => { + if (released) return + released = true + await unlink(path).catch((error: unknown) => { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + }) + }, + } + } catch (error) { + await handle?.close().catch(() => undefined) + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') { + throw new StorageError('STORAGE_LOCK_FAILED', 'Could not acquire the storage lock', { + cause: error, + }) + } + let metadata: { readonly pid?: unknown } | undefined + let lockInput: Awaited> | undefined + try { + lockInput = await openProtectedInput(path, 'storage lock') + if ((await lockInput.stat()).size > 1024) { + throw new StorageError('STORAGE_LOCKED', 'Storage lock is held by another process') + } + metadata = JSON.parse(await lockInput.readFile({ encoding: 'utf8' })) as { + readonly pid?: unknown + } + } catch { + throw new StorageError('STORAGE_LOCKED', 'Storage lock is held by another process') + } finally { + await lockInput?.close().catch(() => undefined) + } + const pid = typeof metadata.pid === 'number' ? metadata.pid : 0 + if (processIsAlive(pid)) { + throw new StorageError('STORAGE_LOCKED', 'Storage lock is held by another process') + } + await unlink(path).catch((unlinkError: unknown) => { + if ((unlinkError as NodeJS.ErrnoException).code !== 'ENOENT') throw unlinkError + }) + } + } +} + +export async function recoverRestoreManifest( + databasePath: string, + approvedRoot: string, +): Promise { + const manifest = await readRestoreManifest(databasePath) + if (!manifest) return + for (const [name, path] of Object.entries(manifest)) { + if (name === 'version' || name === 'operationId' || name === 'phase') continue + await assertApprovedPath(path, approvedRoot, `Restore manifest ${name}`, true) + } + const live = databasePath + const displacedExists = await stat(manifest.displaced) + .then(() => true) + .catch(() => false) + const liveExists = await stat(live) + .then(() => true) + .catch(() => false) + if (manifest.phase === 'prepared' || manifest.phase === 'candidate-ready') { + if (!liveExists && displacedExists) { + await rename(manifest.displaced, live) + await syncDirectory(dirname(databasePath)) + } + await rm(manifest.temporary, { force: true }) + await syncDirectory(dirname(databasePath)) + await unlink(restoreManifestPath(databasePath)).catch((error: unknown) => { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + }) + await syncDirectory(dirname(databasePath)) + return + } + if (!liveExists && displacedExists) { + await rename(manifest.displaced, live) + await syncDirectory(dirname(databasePath)) + } + for (const path of [ + manifest.displaced, + manifest.displacedWal, + manifest.displacedSharedMemory, + manifest.temporary, + ]) { + await rm(path, { force: true }) + await syncDirectory(dirname(databasePath)) + } + await unlink(restoreManifestPath(databasePath)).catch((error: unknown) => { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + }) + await syncDirectory(dirname(databasePath)) +} diff --git a/src/adapters/storage/sqlite-redaction.ts b/src/adapters/storage/sqlite-redaction.ts new file mode 100644 index 0000000..a5dfda8 --- /dev/null +++ b/src/adapters/storage/sqlite-redaction.ts @@ -0,0 +1,438 @@ +import { createHash, randomBytes, randomUUID } from 'node:crypto' +import { canonicalDigest } from '../../domain/canonical.js' +import { parseConversationId, parseRunId } from '../../domain/ids.js' +import { credentialRef } from '../../ports/credentials.js' +import type { + ConversationId, + DestructionReport, + EventId, + NonTerminalRun, + OperationIntent, + RedactionReport, + RetentionReport, +} from '../../ports/storage.js' +import { decryptPayload, encryptPayload, payloadChecksum } from './sqlite-crypto.js' +import type { SqliteValue } from './sqlite-driver.js' +import { StorageError } from './sqlite-errors.js' +import { SqliteMaintenanceStorage } from './sqlite-maintenance.js' +import { classifySqliteError } from './sqlite-paths.js' +import { + asBuffer, + asString, + missingFromCursor, + now, + redactedPersistedPayload, + redactionReasonDigest, +} from './sqlite-rows.js' +import type { CursorRow, SqliteEventRow } from './sqlite-types.js' +import { assertOperationRequestDigest } from './storage-validation.js' + +export class SqliteRedactionStorage extends SqliteMaintenanceStorage { + async applyRetention(input: { + readonly before: string + readonly conversationId?: ConversationId + readonly operation: OperationIntent + }): Promise { + assertOperationRequestDigest(input.operation, { + before: input.before, + ...(input.conversationId === undefined ? {} : { conversationId: input.conversationId }), + }) + const replay = await this.reuseMutation(input.operation) + if (replay !== undefined) return replay + let mutationCommitted = false + try { + const result = await this.writes.run(async () => { + this.assertOpen() + const conditions = ['e.received_at < ?', 'e.redacted = 0', 'c.terminal = 1'] + const parameters: SqliteValue[] = [input.before] + if (input.conversationId !== undefined) { + conditions.push('e.conversation_id = ?') + parameters.push(input.conversationId) + } + const rows = this.database + .prepare( + `SELECT e.* FROM braid_journal_events e + JOIN braid_run_cursors c ON c.run_id = e.run_id WHERE ${conditions.join(' AND ')}`, + ) + .all(...parameters) as readonly SqliteEventRow[] + if (rows.length === 0) return { redactedEvents: 0, deletedConversations: [] } + const keys = new Map() + try { + for (const row of rows) { + const conversationId = parseConversationId(row.conversation_id) + if (!keys.has(conversationId)) { + const key = await this.existingContentKey(conversationId) + if (!key) + throw new StorageError( + 'CONTENT_KEY_UNAVAILABLE', + `Content key for ${conversationId} is unavailable`, + ) + keys.set(conversationId, key) + } + } + await this.stateSnapshots.destroyKeysBeforeMutation() + this.begin() + for (const row of rows) { + const conversationId = parseConversationId(row.conversation_id) + const key = keys.get(conversationId) + if (!key) + throw new StorageError( + 'CONTENT_KEY_UNAVAILABLE', + 'Retention content key is unavailable', + ) + const original = decryptPayload(asBuffer(row.payload, 'payload'), key) + const marker = redactedPersistedPayload(original, row, 'retention') + const encoded = encryptPayload(marker, key) + this.database + .prepare( + 'UPDATE braid_journal_events SET payload = ?, redacted = 1, payload_checksum = ? WHERE conversation_id = ? AND event_id = ?', + ) + .run(encoded, payloadChecksum(marker), row.conversation_id, row.event_id) + encoded.fill(0) + this.database + .prepare( + `INSERT INTO braid_redaction_records(conversation_id, event_id, reason, rewritten_at) + VALUES (?, ?, 'retention', ?) ON CONFLICT(conversation_id, event_id) DO UPDATE SET reason = excluded.reason, rewritten_at = excluded.rewritten_at`, + ) + .run(row.conversation_id, row.event_id, now()) + } + this.stateSnapshots.invalidateUnsafe() + this.commit('retention') + mutationCommitted = true + await this.stateSnapshots.finishInvalidationUnsafe() + return { redactedEvents: rows.length, deletedConversations: [] } + } catch (error) { + if (!mutationCommitted) this.rollback() + throw classifySqliteError(error) + } finally { + for (const key of keys.values()) key.fill(0) + } + }) + await this.completeMutation(input.operation, 'terminal', result) + return result + } catch (error) { + await this.completeMutationFailure(input.operation, error, mutationCommitted) + throw error + } + } + + async redact(input: { + readonly conversationId: ConversationId + readonly eventId: EventId + readonly reason: string + readonly operation: OperationIntent + }): Promise { + assertOperationRequestDigest(input.operation, { + conversationId: input.conversationId, + eventId: input.eventId, + reasonDigest: canonicalDigest(input.reason), + }) + const replay = await this.reuseMutation(input.operation) + if (replay !== undefined) return replay + let mutationCommitted = false + try { + const result = await this.writes.run(async () => + this.withExclusiveLock(async () => { + this.assertOpen() + const target = this.database + .prepare( + 'SELECT * FROM braid_journal_events WHERE conversation_id = ? AND event_id = ?', + ) + .get(input.conversationId, input.eventId) as SqliteEventRow | undefined + if (!target) + throw new StorageError('EVENT_NOT_FOUND', `Event ${input.eventId} was not found`) + const keyRow = this.database + .prepare( + 'SELECT credential_ref, destroyed FROM braid_conversation_keys WHERE conversation_id = ?', + ) + .get(input.conversationId) as + | { readonly credential_ref?: unknown; readonly destroyed?: unknown } + | undefined + const oldRef = + keyRow === undefined || Number(keyRow.destroyed) === 1 + ? undefined + : credentialRef(asString(keyRow.credential_ref, 'credential_ref')) + const oldKey = await this.existingContentKey(input.conversationId) + if (!oldKey) + throw new StorageError('CONTENT_KEY_UNAVAILABLE', 'The old content key is unavailable') + if (oldRef === undefined) { + oldKey.fill(0) + throw new StorageError( + 'CONTENT_KEY_UNAVAILABLE', + 'The conversation has no replaceable content key', + ) + } + const newRef = credentialRef( + `cred:v1:content-redacted-${createHash('sha256').update(input.conversationId).digest('hex')}-${randomUUID()}`, + ) + const newKey = randomBytes(32) + const rows = this.database + .prepare( + 'SELECT * FROM braid_journal_events WHERE conversation_id = ? ORDER BY storage_id', + ) + .all(input.conversationId) as readonly SqliteEventRow[] + let rewritten: Array<{ + readonly row: SqliteEventRow + readonly encoded: Buffer + readonly checksum: string + readonly redacted: boolean + }> = [] + try { + await this.stateSnapshots.destroyKeysBeforeMutation() + this.begin() + try { + this.database + .prepare( + `INSERT INTO braid_content_key_rotations( + conversation_id, old_credential_ref, new_credential_ref, redacted_event_id, phase, prepared_at + ) VALUES (?, ?, ?, ?, 'prepared', ?) + ON CONFLICT(conversation_id) DO UPDATE SET old_credential_ref = excluded.old_credential_ref, + new_credential_ref = excluded.new_credential_ref, redacted_event_id = excluded.redacted_event_id, + phase = excluded.phase, prepared_at = excluded.prepared_at`, + ) + .run(input.conversationId, oldRef, newRef, input.eventId, now()) + this.commit('redaction.prepare') + } catch (error) { + this.rollback() + throw error + } + + this.durableBoundaryHook?.('before:redaction.key.store') + await this.credentials.store({ ref: newRef, value: newKey }) + this.durableBoundaryHook?.('after:redaction.key.store') + rewritten = rows.map((row) => { + const oldPayload = decryptPayload(asBuffer(row.payload, 'payload'), oldKey) + const nextPayload = + row.event_id === input.eventId + ? redactedPersistedPayload(oldPayload, row, input.reason) + : oldPayload + const encoded = encryptPayload(nextPayload, newKey) + return { + row, + encoded, + checksum: payloadChecksum(nextPayload), + redacted: row.event_id === input.eventId || row.redacted === 1, + } + }) + + this.begin() + for (const item of rewritten) { + this.database + .prepare( + 'UPDATE braid_journal_events SET payload = ?, payload_checksum = ?, redacted = ? WHERE storage_id = ?', + ) + .run(item.encoded, item.checksum, item.redacted ? 1 : 0, item.row.storage_id) + item.encoded.fill(0) + } + this.database + .prepare( + 'UPDATE braid_conversation_keys SET credential_ref = ?, destroyed = 0 WHERE conversation_id = ?', + ) + .run(newRef, input.conversationId) + this.database + .prepare( + `UPDATE braid_content_key_rotations + SET phase = 'rewritten' WHERE conversation_id = ?`, + ) + .run(input.conversationId) + this.database + .prepare( + `INSERT INTO braid_redaction_records(conversation_id, event_id, reason, rewritten_at) + VALUES (?, ?, ?, ?) ON CONFLICT(conversation_id, event_id) DO UPDATE SET reason = excluded.reason, rewritten_at = excluded.rewritten_at`, + ) + .run(input.conversationId, input.eventId, redactionReasonDigest(input.reason), now()) + this.stateSnapshots.invalidateUnsafe() + this.commit('redaction') + mutationCommitted = true + await this.stateSnapshots.finishInvalidationUnsafe() + } catch (error) { + if (!mutationCommitted) { + this.rollback() + await this.removeCredentialIfPresent(newRef).catch(() => undefined) + this.begin() + try { + this.database + .prepare('DELETE FROM braid_content_key_rotations WHERE conversation_id = ?') + .run(input.conversationId) + this.commit('redaction.prepare.cleanup') + } catch { + this.rollback() + } + } + for (const item of rewritten) item.encoded.fill(0) + oldKey.fill(0) + newKey.fill(0) + throw classifySqliteError(error) + } + for (const item of rewritten) item.encoded.fill(0) + try { + const verification = await this.verifyConversation( + input.conversationId, + input.eventId, + newRef, + ) + if (!verification) { + throw new StorageError( + 'REDACTION_VERIFY_FAILED', + 'Redaction rewrite verification failed', + ) + } + this.durableBoundaryHook?.('before:redaction.old-key.remove') + await this.removeCredentialIfPresent(oldRef) + this.durableBoundaryHook?.('after:redaction.old-key.remove') + this.begin() + try { + this.database + .prepare('DELETE FROM braid_content_key_rotations WHERE conversation_id = ?') + .run(input.conversationId) + this.commit('redaction.cleanup') + } catch (error) { + this.rollback() + throw error + } + } catch (error) { + // A committed rewrite is recoverable: the rotation row intentionally + // remains until startup can finish key cleanup. + throw error instanceof StorageError + ? error + : new StorageError( + 'REDACTION_CLEANUP_FAILED', + 'Redaction key cleanup did not complete', + { cause: error }, + ) + } finally { + oldKey.fill(0) + newKey.fill(0) + } + return { + conversationId: input.conversationId, + redactedEventId: input.eventId, + rewrittenEvents: rewritten.length, + newContentKeyRef: newRef, + } + }), + ) + await this.completeMutation(input.operation, 'terminal', result) + return result + } catch (error) { + await this.completeMutationFailure(input.operation, error, mutationCommitted) + throw error + } + } + + async destroyConversation(input: { + readonly conversationId: ConversationId + readonly reason: string + readonly operation: OperationIntent + }): Promise { + assertOperationRequestDigest(input.operation, { + conversationId: input.conversationId, + reasonDigest: canonicalDigest(input.reason), + }) + const replay = await this.reuseMutation(input.operation) + if (replay !== undefined) return replay + let mutationCommitted = false + try { + const result = await this.writes.run(async () => { + this.assertOpen() + const tombstone = this.database + .prepare('SELECT reason FROM braid_conversation_tombstones WHERE conversation_id = ?') + .get(input.conversationId) as { readonly reason?: unknown } | undefined + if (tombstone) { + return { + conversationId: input.conversationId, + destroyed: true, + retainedCiphertext: true, + } + } + const keyRow = this.database + .prepare('SELECT credential_ref FROM braid_conversation_keys WHERE conversation_id = ?') + .get(input.conversationId) as { readonly credential_ref?: unknown } | undefined + if (!keyRow) + throw new StorageError( + 'CONVERSATION_NOT_FOUND', + `Conversation ${input.conversationId} was not found`, + ) + const ref = credentialRef(asString(keyRow.credential_ref, 'credential_ref')) + await this.stateSnapshots.destroyKeysBeforeMutation() + this.begin() + try { + this.database + .prepare('UPDATE braid_journal_events SET redacted = 0 WHERE conversation_id = ?') + .run(input.conversationId) + this.database + .prepare('UPDATE braid_conversation_keys SET destroyed = 1 WHERE conversation_id = ?') + .run(input.conversationId) + this.database + .prepare( + `INSERT INTO braid_conversation_tombstones(conversation_id, reason, deleted_at) + VALUES (?, ?, ?) + ON CONFLICT(conversation_id) DO UPDATE SET reason = excluded.reason, deleted_at = excluded.deleted_at`, + ) + .run(input.conversationId, redactionReasonDigest(input.reason), now()) + this.stateSnapshots.invalidateUnsafe() + this.writeProjection(this.buildProjection()) + this.commit('destruction') + mutationCommitted = true + await this.stateSnapshots.finishInvalidationUnsafe() + } catch (error) { + this.rollback() + throw classifySqliteError(error) + } + try { + await this.credentials.remove(ref) + } catch (error) { + throw new StorageError( + 'CONTENT_KEY_DESTRUCTION_FAILED', + 'Conversation content key destruction failed', + { cause: error }, + ) + } + return { conversationId: input.conversationId, destroyed: true, retainedCiphertext: true } + }) + await this.completeMutation(input.operation, 'terminal', result) + return result + } catch (error) { + await this.completeMutationFailure(input.operation, error, mutationCommitted) + throw error + } + } + + async compact(operation: OperationIntent): Promise { + assertOperationRequestDigest(operation, {}) + const replay = await this.reuseMutation<{ readonly completed: true }>(operation) + if (replay !== undefined) return + let mutationCommitted = false + try { + await this.writes.run(async () => { + this.assertOpen() + try { + this.database.pragma('wal_checkpoint(TRUNCATE)') + this.database.exec('VACUUM') + mutationCommitted = true + await this.secureArtifacts() + } catch (error) { + throw classifySqliteError(error) + } + }) + await this.completeMutation(operation, 'terminal', { completed: true }) + } catch (error) { + await this.completeMutationFailure(operation, error, mutationCommitted) + throw error + } + } + + async reconcileNonTerminalRuns(): Promise { + this.assertOpen() + const rows = this.database + .prepare('SELECT * FROM braid_run_cursors WHERE terminal = 0 ORDER BY run_id') + .all() as readonly CursorRow[] + return rows.map((row) => ({ + runId: parseRunId(row.run_id), + conversationId: parseConversationId(row.conversation_id), + lastSequence: row.last_sequence, + lastCursor: row.last_cursor, + missingHistory: missingFromCursor(row), + })) + } +} diff --git a/src/adapters/storage/sqlite-rows.ts b/src/adapters/storage/sqlite-rows.ts new file mode 100644 index 0000000..a03bf5c --- /dev/null +++ b/src/adapters/storage/sqlite-rows.ts @@ -0,0 +1,277 @@ +import { createHash } from 'node:crypto' +import { canonicalDigest, canonicalJson } from '../../domain/canonical.js' +import { + parseConversationId, + parseEventId, + parseOperationId, + parseRunId, +} from '../../domain/ids.js' +import { isCanonicalIsoDateTime } from '../../domain/text.js' +import { CredentialError, credentialRef } from '../../ports/credentials.js' +import type { CredentialRef } from '../../ports/credentials.js' +import type { EffectRecord } from '../../ports/effect-storage.js' +import type { + JsonValue, + MissingHistory, + OperationRecord, + ProjectionRun, + ProjectionSnapshot, +} from '../../ports/storage.js' +import { isJsonValue, PROJECTION_SCHEMA_VERSION } from '../../ports/storage.js' +import { assertEffectRecordInput } from './storage-validation.js' +import { StorageError } from './sqlite-errors.js' +import type { CursorRow, OperationRow, SqliteEventRow } from './sqlite-types.js' + +export function now(): string { + return new Date().toISOString() +} + +export function asNumber(value: unknown, field: string): number { + const number = typeof value === 'bigint' ? Number(value) : Number(value) + if (!Number.isSafeInteger(number)) + throw new StorageError('STORAGE_ROW_INVALID', `Invalid ${field} in SQLite row`) + return number +} + +export function asString(value: unknown, field: string): string { + if (typeof value !== 'string') + throw new StorageError('STORAGE_ROW_INVALID', `Invalid ${field} in SQLite row`) + return value +} + +export function asBuffer(value: unknown, field: string): Buffer { + if (Buffer.isBuffer(value)) return Buffer.from(value) + if (value instanceof Uint8Array) return Buffer.from(value) + throw new StorageError('STORAGE_ROW_INVALID', `Invalid ${field} in SQLite row`) +} + +export function cloneJson(value: JsonValue): JsonValue { + return JSON.parse(canonicalJson(value)) as JsonValue +} + +export function jsonValue(value: unknown): JsonValue { + return JSON.parse(canonicalJson(value)) as JsonValue +} + +export function redactedPersistedPayload( + value: JsonValue, + row: SqliteEventRow, + _reason: string, +): JsonValue { + const eventId = asString(row.event_id, 'event_id') + const storageSequence = asNumber(row.storage_id, 'storage_id') + const fallback = (): JsonValue => ({ + __braidEvent: { + kind: 'unknown.event', + unknown: { + id: eventId, + type: asString(row.kind, 'kind'), + namespace: 'braid.redacted', + summary: 'Event payload redacted', + sequence: asNumber(row.run_sequence, 'run_sequence'), + }, + }, + __braidEnvelope: { + eventId, + sequence: storageSequence, + revision: storageSequence, + occurredAt: asString(row.occurred_at, 'occurred_at'), + ...(row.cursor === null ? {} : { cursor: asString(row.cursor, 'cursor') }), + }, + }) + if (value === null || typeof value !== 'object' || Array.isArray(value)) return fallback() + const candidate = value as Readonly> + const event = candidate.__braidEvent + const envelope = candidate.__braidEnvelope + if ( + event === null || + typeof event !== 'object' || + Array.isArray(event) || + envelope === null || + typeof envelope !== 'object' || + Array.isArray(envelope) + ) + return fallback() + const eventRecord = event as Readonly> + const envelopeRecord = envelope as Readonly> + if ( + typeof eventRecord.kind !== 'string' || + typeof envelopeRecord.sequence !== 'number' || + typeof envelopeRecord.revision !== 'number' || + typeof envelopeRecord.occurredAt !== 'string' + ) + return fallback() + return { + __braidEvent: { + kind: 'unknown.event', + unknown: { + id: eventId, + type: eventRecord.kind, + namespace: 'braid.redacted', + summary: 'Event payload redacted', + sequence: envelopeRecord.sequence, + }, + }, + __braidEnvelope: { + eventId, + sequence: envelopeRecord.sequence, + revision: envelopeRecord.revision, + occurredAt: envelopeRecord.occurredAt, + ...(typeof envelopeRecord.cursor === 'string' ? { cursor: envelopeRecord.cursor } : {}), + }, + } +} + +export function credentialErrorCode(error: unknown): string | undefined { + return error instanceof CredentialError ? error.code : undefined +} + +export function deterministicCredentialRef(prefix: string, value: string): CredentialRef { + const digest = createHash('sha256').update(value).digest('hex') + return credentialRef(`cred:v1:${prefix}-${digest}`) +} + +export function redactionReasonDigest(reason: string): string { + return `sha256:${createHash('sha256').update(reason).digest('hex')}` +} + +export function operationRecordFromRow(row: OperationRow): OperationRecord { + const request: unknown = JSON.parse(row.request_json) + if (!isJsonValue(request)) + throw new StorageError('OPERATION_RECORD_INVALID', 'Stored operation request is invalid') + const result = row.result_json === null ? undefined : (JSON.parse(row.result_json) as unknown) + if (result !== undefined && !isJsonValue(result)) { + throw new StorageError('OPERATION_RECORD_INVALID', 'Stored operation result is invalid') + } + if ( + !isCanonicalIsoDateTime(row.created_at) || + !isCanonicalIsoDateTime(row.updated_at) || + row.updated_at < row.created_at + ) { + throw new StorageError('OPERATION_RECORD_INVALID', 'Stored operation timestamps are invalid') + } + return { + operationId: parseOperationId(row.operation_id), + kind: row.operation_kind, + request, + requestDigest: row.request_digest, + status: row.status, + ...(result === undefined ? {} : { result }), + updatedAt: row.updated_at, + createdAt: row.created_at, + } +} + +export function effectRecordFromRow(row: Record): EffectRecord { + const metadataValue: unknown = JSON.parse(asString(row.metadata_json, 'metadata_json')) + if ( + metadataValue === null || + typeof metadataValue !== 'object' || + Array.isArray(metadataValue) || + Object.values(metadataValue).some((value) => typeof value !== 'string') + ) { + throw new StorageError('EFFECT_RECORD_INVALID', 'Stored effect metadata is invalid') + } + const status = asString(row.status, 'status') + if (!['pending', 'acknowledged', 'failed', 'unknown', 'conflict', 'terminal'].includes(status)) { + throw new StorageError('EFFECT_RECORD_INVALID', `Unknown effect status ${status}`) + } + const record: EffectRecord = { + operationId: parseOperationId(asString(row.operation_id, 'operation_id')), + effectKind: asString(row.effect_kind, 'effect_kind'), + requestDigest: asString(row.request_digest, 'request_digest'), + status: status as EffectRecord['status'], + attempt: asNumber(row.attempt, 'attempt'), + createdAt: asString(row.created_at, 'created_at'), + updatedAt: asString(row.updated_at, 'updated_at'), + metadata: metadataValue as Readonly>, + ...(row.detail === null || row.detail === undefined + ? {} + : { detail: asString(row.detail, 'detail') }), + ...(row.external_reference === null || row.external_reference === undefined + ? {} + : { externalReference: asString(row.external_reference, 'external_reference') }), + ...(row.conflict_with_digest === null || row.conflict_with_digest === undefined + ? {} + : { conflictWithDigest: asString(row.conflict_with_digest, 'conflict_with_digest') }), + } + assertEffectRecordInput(record) + return record +} + +export function missingFromCursor(row: CursorRow): MissingHistory | null { + if (row.missing_from === null || row.missing_to === null) return null + return { + runId: parseRunId(row.run_id), + fromSequence: row.missing_from, + toSequence: row.missing_to, + } +} + +export const PROJECTION_EVENT_DIGEST_SEED = createHash('sha256') + .update('braid-projection-events-v1') + .digest('hex') + +export function appendProjectionEventDigest(previous: string, eventId: string): string { + return createHash('sha256').update(`${previous}\u0000${eventId}`).digest('hex') +} + +export function projectionRunDigest(runs: readonly ProjectionRun[]): string { + const aggregate = Buffer.alloc(32) + for (const run of [...runs].sort((left, right) => left.runId.localeCompare(right.runId))) { + const digest = Buffer.from(canonicalDigest(run), 'hex') + for (let index = 0; index < aggregate.length; index += 1) { + aggregate[index] = (aggregate[index] ?? 0) ^ (digest[index] ?? 0) + } + } + return aggregate.toString('hex') +} + +export function projectionChecksum(input: { + readonly schemaVersion: number + readonly eventCount: number + readonly revision: number + readonly eventIdsDigest: string + readonly runsDigest: string +}): string { + return canonicalDigest(input) +} + +export function projectionFromRows( + rows: readonly SqliteEventRow[], + cursors: readonly CursorRow[], +): ProjectionSnapshot { + const runs = new Map() + for (const row of cursors) { + runs.set(row.run_id, { + runId: parseRunId(row.run_id), + conversationId: parseConversationId(row.conversation_id), + lastSequence: row.last_sequence, + lastCursor: row.last_cursor, + missingFrom: row.missing_from, + missingTo: row.missing_to, + terminal: row.terminal === 1, + }) + } + const eventIds = rows.map((row) => parseEventId(row.event_id)) + const base = { + schemaVersion: PROJECTION_SCHEMA_VERSION, + eventCount: rows.length, + revision: rows.length, + eventIds, + runs: [...runs.values()].sort((left, right) => left.runId.localeCompare(right.runId)), + } + let eventIdsDigest = PROJECTION_EVENT_DIGEST_SEED + for (const eventId of eventIds) + eventIdsDigest = appendProjectionEventDigest(eventIdsDigest, eventId) + return { + ...base, + checksum: projectionChecksum({ + schemaVersion: base.schemaVersion, + eventCount: base.eventCount, + revision: base.revision, + eventIdsDigest, + runsDigest: projectionRunDigest(base.runs), + }), + } +} diff --git a/src/adapters/storage/sqlite-schema.ts b/src/adapters/storage/sqlite-schema.ts new file mode 100644 index 0000000..6484876 --- /dev/null +++ b/src/adapters/storage/sqlite-schema.ts @@ -0,0 +1,309 @@ +import type { SqliteDatabase } from './sqlite-driver.js' +import { StorageError } from './sqlite-errors.js' + +export const SQLITE_SCHEMA_VERSION = 8 + +export function pragmaNumber(database: SqliteDatabase, sql: string): number { + const value = database.pragma(sql, { simple: true }) + const number = + typeof value === 'string' + ? ({ off: 0, normal: 1, full: 2, extra: 3 }[value.toLowerCase()] ?? Number(value)) + : typeof value === 'bigint' + ? Number(value) + : Number(value) + if (!Number.isSafeInteger(number)) + throw new StorageError('SQLITE_PRAGMA', `Invalid numeric pragma ${sql}`) + return number +} + +function scalarString(value: unknown): string | null { + if (typeof value === 'string') return value + if (Array.isArray(value) && value.length === 1 && value[0] && typeof value[0] === 'object') { + const first = value[0] as Record + const child = Object.values(first)[0] + return typeof child === 'string' ? child : null + } + if (value && typeof value === 'object') { + const child = Object.values(value as Record)[0] + return typeof child === 'string' ? child : null + } + return null +} + +export function pragmaString(database: SqliteDatabase, sql: string): string | null { + return scalarString(database.pragma(sql)) +} + +export function applyConnectionPragmas( + database: SqliteDatabase, + busyTimeoutMs: number, +): { readonly wal: boolean; readonly foreignKeys: boolean; readonly synchronousFull: boolean } { + database.exec(`PRAGMA busy_timeout = ${busyTimeoutMs}`) + database.exec('PRAGMA foreign_keys = ON') + database.exec('PRAGMA synchronous = FULL') + const journalMode = pragmaString(database, 'journal_mode = WAL')?.toLowerCase() + const foreignKeys = pragmaNumber(database, 'foreign_keys') === 1 + const synchronousFull = pragmaNumber(database, 'synchronous') === 2 + if (journalMode !== 'wal' || !foreignKeys || !synchronousFull) { + throw new StorageError( + 'SQLITE_PRAGMAS', + 'SQLite did not accept WAL, foreign keys, and FULL synchronous mode', + ) + } + return { wal: journalMode === 'wal', foreignKeys, synchronousFull } +} + +function createVersionOne(database: SqliteDatabase): void { + database.exec(` + CREATE TABLE IF NOT EXISTS braid_conversations ( + conversation_id TEXT PRIMARY KEY NOT NULL, + workspace_id TEXT NOT NULL, + created_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS braid_conversation_keys ( + conversation_id TEXT PRIMARY KEY NOT NULL REFERENCES braid_conversations(conversation_id), + credential_ref TEXT NOT NULL, + destroyed INTEGER NOT NULL DEFAULT 0 CHECK (destroyed IN (0, 1)) + ); + CREATE TABLE IF NOT EXISTS braid_journal_events ( + storage_id INTEGER PRIMARY KEY AUTOINCREMENT, + workspace_id TEXT NOT NULL, + conversation_id TEXT NOT NULL REFERENCES braid_conversations(conversation_id), + run_id TEXT NOT NULL, + event_id TEXT NOT NULL, + run_sequence INTEGER NOT NULL CHECK (run_sequence > 0), + kind TEXT NOT NULL, + cursor TEXT, + operation_id TEXT, + payload BLOB NOT NULL, + payload_checksum TEXT NOT NULL, + occurred_at TEXT NOT NULL, + received_at TEXT NOT NULL, + terminal INTEGER NOT NULL DEFAULT 0 CHECK (terminal IN (0, 1)), + redacted INTEGER NOT NULL DEFAULT 0 CHECK (redacted IN (0, 1)), + UNIQUE (run_id, event_id), + UNIQUE (run_id, run_sequence) + ); + CREATE INDEX IF NOT EXISTS braid_journal_conversation_idx + ON braid_journal_events(conversation_id, storage_id); + CREATE TABLE IF NOT EXISTS braid_run_cursors ( + run_id TEXT PRIMARY KEY NOT NULL, + conversation_id TEXT NOT NULL REFERENCES braid_conversations(conversation_id), + last_sequence INTEGER NOT NULL DEFAULT 0, + last_cursor TEXT, + missing_from INTEGER, + missing_to INTEGER, + terminal INTEGER NOT NULL DEFAULT 0 CHECK (terminal IN (0, 1)) + ); + CREATE TABLE IF NOT EXISTS braid_operation_records ( + operation_id TEXT PRIMARY KEY NOT NULL, + operation_kind TEXT NOT NULL, + request_digest TEXT NOT NULL, + request_json TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('pending', 'acknowledged', 'failed', 'unknown', 'conflict', 'terminal')), + result_json TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS braid_effect_records ( + effect_sequence INTEGER PRIMARY KEY AUTOINCREMENT, + operation_id TEXT NOT NULL, + effect_kind TEXT NOT NULL, + request_digest TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('pending', 'acknowledged', 'failed', 'unknown', 'conflict', 'terminal')), + attempt INTEGER NOT NULL CHECK (attempt > 0), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + metadata_json TEXT NOT NULL, + detail TEXT, + external_reference TEXT, + conflict_with_digest TEXT + ); + CREATE INDEX IF NOT EXISTS braid_effect_operation_idx + ON braid_effect_records(operation_id, effect_sequence); + CREATE TABLE IF NOT EXISTS braid_projection_state ( + projection_name TEXT PRIMARY KEY NOT NULL, + revision INTEGER NOT NULL, + checksum TEXT NOT NULL, + state_json TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS braid_conversation_tombstones ( + conversation_id TEXT PRIMARY KEY NOT NULL REFERENCES braid_conversations(conversation_id), + reason TEXT NOT NULL, + deleted_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS braid_effect_admissions ( + operation_id TEXT PRIMARY KEY NOT NULL, + effect_kind TEXT NOT NULL, + request_digest TEXT NOT NULL, + attempt INTEGER NOT NULL CHECK (attempt > 0), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + `) +} + +function createVersionTwo(database: SqliteDatabase): void { + database.exec(` + CREATE TABLE IF NOT EXISTS braid_operation_conflicts ( + operation_id TEXT NOT NULL REFERENCES braid_operation_records(operation_id), + attempted_digest TEXT NOT NULL, + original_digest TEXT NOT NULL, + occurred_at TEXT NOT NULL, + PRIMARY KEY (operation_id, attempted_digest) + ); + CREATE TABLE IF NOT EXISTS braid_redaction_records ( + conversation_id TEXT NOT NULL REFERENCES braid_conversations(conversation_id), + event_id TEXT NOT NULL, + reason TEXT NOT NULL, + rewritten_at TEXT NOT NULL, + PRIMARY KEY (conversation_id, event_id) + ); + `) +} + +function createVersionThree(database: SqliteDatabase): void { + database.exec(` + CREATE TABLE IF NOT EXISTS braid_content_key_rotations ( + conversation_id TEXT PRIMARY KEY NOT NULL REFERENCES braid_conversations(conversation_id), + old_credential_ref TEXT NOT NULL, + new_credential_ref TEXT NOT NULL, + prepared_at TEXT NOT NULL + ); + `) +} + +function createVersionFour(database: SqliteDatabase): void { + database.exec(` + DROP INDEX IF EXISTS braid_journal_event_id_unique; + `) +} + +function hasColumn(database: SqliteDatabase, table: string, column: string): boolean { + const rows = database.prepare(`PRAGMA table_info(${table})`).all() as readonly { + readonly name?: unknown + }[] + return rows.some((row) => row.name === column) +} + +function createVersionFive(database: SqliteDatabase): void { + if (!hasColumn(database, 'braid_journal_events', 'provider_event_id')) { + database.exec('ALTER TABLE braid_journal_events ADD COLUMN provider_event_id TEXT') + } + database.exec(` + CREATE INDEX IF NOT EXISTS braid_journal_provider_event_idx + ON braid_journal_events(run_id, provider_event_id); + CREATE TABLE IF NOT EXISTS braid_conversation_tombstones ( + conversation_id TEXT PRIMARY KEY NOT NULL REFERENCES braid_conversations(conversation_id), + reason TEXT NOT NULL, + deleted_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS braid_effect_admissions ( + operation_id TEXT PRIMARY KEY NOT NULL, + effect_kind TEXT NOT NULL, + request_digest TEXT NOT NULL, + attempt INTEGER NOT NULL CHECK (attempt > 0), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + `) + if (!hasColumn(database, 'braid_content_key_rotations', 'redacted_event_id')) { + database.exec('ALTER TABLE braid_content_key_rotations ADD COLUMN redacted_event_id TEXT') + } + if (!hasColumn(database, 'braid_content_key_rotations', 'phase')) { + database.exec('ALTER TABLE braid_content_key_rotations ADD COLUMN phase TEXT') + } +} + +function createVersionSix(database: SqliteDatabase): void { + database.exec(` + INSERT INTO braid_effect_admissions( + operation_id, effect_kind, request_digest, attempt, created_at, updated_at + ) + SELECT operation_id, effect_kind, request_digest, MAX(attempt), MIN(created_at), MAX(updated_at) + FROM braid_effect_records + WHERE status <> 'conflict' + GROUP BY operation_id, effect_kind, request_digest + ON CONFLICT(operation_id) DO NOTHING; + `) +} + +function createVersionSeven(database: SqliteDatabase): void { + database.exec(` + CREATE TABLE IF NOT EXISTS braid_state_snapshots ( + snapshot_id INTEGER PRIMARY KEY AUTOINCREMENT, + scope_id TEXT NOT NULL, + generation INTEGER NOT NULL CHECK (generation > 0), + storage_id INTEGER NOT NULL, + event_id TEXT NOT NULL, + journal_sequence INTEGER NOT NULL CHECK (journal_sequence > 0), + revision INTEGER NOT NULL CHECK (revision > 0), + state_checksum TEXT NOT NULL, + key_ref TEXT NOT NULL, + state_ciphertext BLOB NOT NULL, + created_at TEXT NOT NULL, + UNIQUE (scope_id, generation) + ); + CREATE INDEX IF NOT EXISTS braid_state_snapshot_scope_sequence_idx + ON braid_state_snapshots(scope_id, journal_sequence DESC, snapshot_id DESC); + CREATE TABLE IF NOT EXISTS braid_state_snapshot_keys ( + scope_id TEXT NOT NULL, + generation INTEGER NOT NULL CHECK (generation > 0), + credential_ref TEXT NOT NULL, + retired INTEGER NOT NULL DEFAULT 0 CHECK (retired IN (0, 1)), + created_at TEXT NOT NULL, + PRIMARY KEY (scope_id, generation), + UNIQUE (scope_id, credential_ref) + ); + CREATE INDEX IF NOT EXISTS braid_state_snapshot_key_retired_idx + ON braid_state_snapshot_keys(retired, scope_id, generation); + `) +} + +function createVersionEight(database: SqliteDatabase): void { + if (hasColumn(database, 'braid_state_snapshots', 'state_json')) { + // State snapshots are derived and encrypted with a separate credential. + // Discard the pre-release plaintext design rather than carrying it forward. + database.exec('DROP TABLE braid_state_snapshots') + database.exec('DROP TABLE IF EXISTS braid_state_snapshot_keys') + createVersionSeven(database) + } else { + database.exec(` + CREATE INDEX IF NOT EXISTS braid_state_snapshot_scope_sequence_idx + ON braid_state_snapshots(scope_id, journal_sequence DESC, snapshot_id DESC); + CREATE TABLE IF NOT EXISTS braid_state_snapshot_keys ( + scope_id TEXT NOT NULL, + generation INTEGER NOT NULL CHECK (generation > 0), + credential_ref TEXT NOT NULL, + retired INTEGER NOT NULL DEFAULT 0 CHECK (retired IN (0, 1)), + created_at TEXT NOT NULL, + PRIMARY KEY (scope_id, generation), + UNIQUE (scope_id, credential_ref) + ); + CREATE INDEX IF NOT EXISTS braid_state_snapshot_key_retired_idx + ON braid_state_snapshot_keys(retired, scope_id, generation); + `) + } +} + +export function migrateSchema( + database: SqliteDatabase, + targetVersion = SQLITE_SCHEMA_VERSION, +): void { + const current = pragmaNumber(database, 'user_version') + if (current > targetVersion) { + throw new StorageError( + 'SQLITE_SCHEMA_NEWER', + `Database schema ${current} is newer than ${targetVersion}`, + ) + } + if (current < 1) createVersionOne(database) + if (current < 2) createVersionTwo(database) + if (current < 3) createVersionThree(database) + if (current < 4) createVersionFour(database) + if (current < 5) createVersionFive(database) + if (current < 6) createVersionSix(database) + if (current < 7) createVersionSeven(database) + if (current < 8) createVersionEight(database) + database.exec(`PRAGMA user_version = ${targetVersion}`) +} diff --git a/src/adapters/storage/sqlite-state-snapshot-codec.ts b/src/adapters/storage/sqlite-state-snapshot-codec.ts new file mode 100644 index 0000000..e3059ae --- /dev/null +++ b/src/adapters/storage/sqlite-state-snapshot-codec.ts @@ -0,0 +1,122 @@ +import { canonicalDigest } from '../../domain/canonical.js' +import { + isMaterializedStateSnapshot, + type MaterializedStateSnapshot, + restoreMaterializedState, +} from '../../domain/materialized-state-snapshot.js' +import { credentialRef } from '../../ports/credentials.js' +import type { JsonValue, StateSnapshot } from '../../ports/storage.js' +import { isJsonValue } from '../../ports/storage.js' +import { StorageError } from './sqlite-errors.js' +import { asNumber, asString } from './sqlite-rows.js' +import type { SnapshotMetadata } from './sqlite-state-snapshot-types.js' + +export function validatedSnapshot(value: unknown, scopeId: string): StateSnapshot { + if (!isMaterializedStateSnapshot(value) || value.scopeId !== scopeId) { + throw new StorageError( + 'STATE_SNAPSHOT_INVALID', + 'State snapshot is not a valid materialized projection for this storage scope', + ) + } + return value +} + +export function assertJsonState(state: unknown): JsonValue { + if (!isJsonValue(state)) { + throw new StorageError('STATE_SNAPSHOT_INVALID', 'Materialized state is not JSON data') + } + return state +} + +export function assertSnapshotChecksum(snapshot: StateSnapshot): void { + if (canonicalDigest(snapshot.state) !== snapshot.stateChecksum) { + throw new StorageError( + 'STATE_SNAPSHOT_INVALID', + 'Materialized state checksum does not match snapshot metadata', + ) + } +} + +export function metadataFromRow(row: Record): SnapshotMetadata { + const snapshotId = asNumber(row.snapshot_id, 'snapshot_id') + const scopeId = asString(row.scope_id, 'scope_id') + const generation = asNumber(row.generation, 'generation') + const storageId = asNumber(row.storage_id, 'storage_id') + const eventId = asString(row.event_id, 'event_id') + const sequence = asNumber(row.journal_sequence, 'journal_sequence') + const revision = asNumber(row.revision, 'revision') + const stateChecksum = asString(row.state_checksum, 'state_checksum') + const keyRef = credentialRef(asString(row.key_ref, 'key_ref')) + if (snapshotId <= 0 || generation <= 0 || storageId <= 0 || sequence <= 0 || revision <= 0) { + throw new StorageError('STATE_SNAPSHOT_INVALID', 'State snapshot metadata is out of range') + } + return { + snapshotId, + scopeId, + generation, + storageId, + eventId, + sequence, + revision, + stateChecksum, + keyRef, + } +} + +export function metadataFromRowSafe(row: Record): SnapshotMetadata | null { + try { + return metadataFromRow(row) + } catch { + return null + } +} + +export function materializedSnapshotFromPayload( + metadata: SnapshotMetadata, + payload: JsonValue, +): MaterializedStateSnapshot { + const candidate: unknown = { + kind: 'braid.materialized-state', + schemaVersion: 1, + scopeId: metadata.scopeId, + generation: metadata.generation, + eventId: metadata.eventId, + sequence: metadata.sequence, + revision: metadata.revision, + state: payload, + stateChecksum: metadata.stateChecksum, + } + if (!isMaterializedStateSnapshot(candidate)) { + throw new StorageError('STATE_SNAPSHOT_INVALID', 'Snapshot payload failed validation') + } + try { + restoreMaterializedState(candidate) + } catch (error) { + throw new StorageError('STATE_SNAPSHOT_INVALID', 'Snapshot payload failed domain validation', { + cause: error, + }) + } + return candidate +} + +export function asPositiveNumber(value: unknown): number | null { + try { + const number = asNumber(value, 'snapshot') + return number > 0 ? number : null + } catch { + return null + } +} + +export function asStringOrNull(value: unknown): string | null { + return typeof value === 'string' ? value : null +} + +export function asCredentialRef(value: unknown) { + if (typeof value !== 'string') return null + try { + return credentialRef(value) + } catch { + return null + } +} diff --git a/src/adapters/storage/sqlite-state-snapshot-keys.ts b/src/adapters/storage/sqlite-state-snapshot-keys.ts new file mode 100644 index 0000000..33360e8 --- /dev/null +++ b/src/adapters/storage/sqlite-state-snapshot-keys.ts @@ -0,0 +1,97 @@ +import { randomBytes, randomUUID } from 'node:crypto' +import { canonicalDigest } from '../../domain/canonical.js' +import { CredentialError, type CredentialRef, credentialRef } from '../../ports/credentials.js' +import type { StateSnapshot } from '../../ports/storage.js' +import { StorageError } from './sqlite-errors.js' +import { validatedSnapshot } from './sqlite-state-snapshot-codec.js' +import type { PreparedStateSnapshot, SnapshotRuntime } from './sqlite-state-snapshot-types.js' + +function snapshotCredentialRef(scopeId: string, generation: number): CredentialRef { + const safeScope = canonicalDigest(scopeId).slice(0, 24) + return credentialRef(`cred:v1:snapshot-${safeScope}-${generation}-${randomUUID()}`) +} + +export async function prepareSnapshotKey( + runtime: SnapshotRuntime, + snapshot: StateSnapshot, + createdRefs: CredentialRef[], +): Promise { + validatedSnapshot(snapshot, runtime.scopeId()) + const keyRef = snapshotCredentialRef(runtime.scopeId(), snapshot.generation) + const key = randomBytes(32) + try { + const storedRef = await runtime.credentials.store({ + ref: keyRef, + value: key, + label: 'Braid state snapshot generation key', + }) + createdRefs.push(storedRef) + return { key: Buffer.from(key), keyRef: storedRef } + } finally { + key.fill(0) + } +} + +export async function removeSnapshotCredentials( + runtime: SnapshotRuntime, + refs: readonly CredentialRef[], +): Promise { + const candidates = [...new Set(refs)] + if (candidates.length === 0) return + const referenced = new Set() + for (let offset = 0; offset < candidates.length; offset += 500) { + const batch = candidates.slice(offset, offset + 500) + const placeholders = batch.map(() => '?').join(', ') + const referencedRows = runtime + .database() + .prepare( + `SELECT key_ref FROM braid_state_snapshots + WHERE key_ref IN (${placeholders})`, + ) + .all(...batch) as readonly Record[] + for (const row of referencedRows) { + if (typeof row.key_ref === 'string') referenced.add(row.key_ref) + } + } + for (const ref of candidates) { + if (referenced.has(ref)) continue + try { + await runtime.credentials.remove(ref) + } catch (error) { + if (!(error instanceof CredentialError) || error.code !== 'CREDENTIAL_NOT_FOUND') throw error + } + } +} + +export async function destroySnapshotCredentialsBeforeMutation( + runtime: SnapshotRuntime, + refs: readonly CredentialRef[], +): Promise { + for (const ref of new Set(refs)) { + try { + await runtime.credentials.remove(ref) + } catch (error) { + if (!(error instanceof CredentialError) || error.code !== 'CREDENTIAL_NOT_FOUND') throw error + } + } +} + +export async function resolveSnapshotKey( + runtime: SnapshotRuntime, + ref: CredentialRef, +): Promise { + let handle: Awaited> | undefined + let key: Buffer | undefined + try { + handle = await runtime.credentials.resolve(ref) + key = Buffer.from(handle.read()) + if (key.length !== 32) + throw new StorageError('STATE_SNAPSHOT_KEY_INVALID', 'Snapshot key is invalid') + return key + } catch (error) { + key?.fill(0) + throw error + } finally { + handle?.dispose() + } +} diff --git a/src/adapters/storage/sqlite-state-snapshot-lifecycle.ts b/src/adapters/storage/sqlite-state-snapshot-lifecycle.ts new file mode 100644 index 0000000..68e4cee --- /dev/null +++ b/src/adapters/storage/sqlite-state-snapshot-lifecycle.ts @@ -0,0 +1,226 @@ +import type { CredentialRef } from '../../ports/credentials.js' +import { StorageError } from './sqlite-errors.js' +import { asNumber, asString } from './sqlite-rows.js' +import { asCredentialRef } from './sqlite-state-snapshot-codec.js' +import { + destroySnapshotCredentialsBeforeMutation, + removeSnapshotCredentials, +} from './sqlite-state-snapshot-keys.js' +import { + beginSnapshotTransaction, + commit, + rollbackSnapshotTransaction, +} from './sqlite-state-snapshot-transaction.js' +import type { SnapshotKeyRow, SnapshotRuntime } from './sqlite-state-snapshot-types.js' + +export class SqliteStateSnapshotLifecycle { + readonly #runtime: SnapshotRuntime + + constructor(runtime: SnapshotRuntime) { + this.#runtime = runtime + } + + async prune(): Promise { + await this.#runtime.writes.run(() => this.pruneUnsafe()) + } + + async pruneAfterCommitUnsafe(): Promise { + try { + await this.pruneUnsafe() + } catch { + // The snapshot or event is already durable; startup retries cleanup. + } + } + + async discardCredentials(refs: readonly CredentialRef[]): Promise { + await removeSnapshotCredentials(this.#runtime, refs) + } + + async pruneUnsafe(): Promise { + const database = this.#runtime.database() + const scopeId = this.#runtime.scopeId() + const rows = database + .prepare( + `SELECT snapshot_id, scope_id, generation, key_ref + FROM braid_state_snapshots + WHERE scope_id = ? + ORDER BY generation DESC, snapshot_id DESC + LIMIT -1 OFFSET 2`, + ) + .all(scopeId) as readonly Record[] + if (rows.length === 0) return + const retiring = rows.map((row) => ({ + snapshotId: asNumber(row.snapshot_id, 'snapshot_id'), + generation: asNumber(row.generation, 'generation'), + keyRef: asCredentialRef(row.key_ref), + })) + if (retiring.some((row) => row.keyRef === null)) { + throw new StorageError( + 'STATE_SNAPSHOT_INVALID', + 'Snapshot generation key reference is invalid', + ) + } + beginSnapshotTransaction(database) + try { + for (const row of retiring) { + database + .prepare('DELETE FROM braid_state_snapshots WHERE snapshot_id = ?') + .run(row.snapshotId) + database + .prepare( + 'UPDATE braid_state_snapshot_keys SET retired = 1 WHERE scope_id = ? AND generation = ?', + ) + .run(scopeId, row.generation) + } + commit(this.#runtime, database, 'state.snapshot.prune') + } catch (error) { + rollbackSnapshotTransaction(database) + throw error + } + await removeSnapshotCredentials( + this.#runtime, + retiring.flatMap((row) => (row.keyRef === null ? [] : [row.keyRef])), + ) + beginSnapshotTransaction(database) + try { + database + .prepare( + `DELETE FROM braid_state_snapshot_keys AS key_row + WHERE key_row.scope_id = ? AND key_row.retired = 1 + AND NOT EXISTS ( + SELECT 1 FROM braid_state_snapshots AS snapshot_row + WHERE snapshot_row.key_ref = key_row.credential_ref + )`, + ) + .run(scopeId) + commit(this.#runtime, database, 'state.snapshot.keys.remove') + } catch (error) { + rollbackSnapshotTransaction(database) + throw error + } + } + + async destroyKeysBeforeMutation(): Promise { + const database = this.#runtime.database() + const refs = new Set() + const keyRows = database + .prepare( + 'SELECT credential_ref FROM braid_state_snapshot_keys WHERE scope_id = ? AND retired = 0', + ) + .all(this.#runtime.scopeId()) as readonly SnapshotKeyRow[] + for (const row of keyRows) { + const ref = asCredentialRef(row.credential_ref) + if (ref !== null) refs.add(ref) + } + const snapshotRows = database + .prepare('SELECT key_ref FROM braid_state_snapshots WHERE scope_id = ?') + .all(this.#runtime.scopeId()) as readonly Record[] + for (const row of snapshotRows) { + const ref = asCredentialRef(row.key_ref) + if (ref !== null) refs.add(ref) + } + await destroySnapshotCredentialsBeforeMutation(this.#runtime, [...refs]) + } + + invalidateUnsafe(): void { + const database = this.#runtime.database() + const scopeId = this.#runtime.scopeId() + database.prepare('DELETE FROM braid_state_snapshots WHERE scope_id = ?').run(scopeId) + database + .prepare('UPDATE braid_state_snapshot_keys SET retired = 1 WHERE scope_id = ?') + .run(scopeId) + } + + async finishInvalidationUnsafe(): Promise { + const database = this.#runtime.database() + const refs = database + .prepare( + 'SELECT credential_ref FROM braid_state_snapshot_keys WHERE scope_id = ? AND retired = 1', + ) + .all(this.#runtime.scopeId()) as readonly SnapshotKeyRow[] + const parsedRefs = refs.flatMap((row) => { + const ref = asCredentialRef(row.credential_ref) + return ref === null ? [] : [ref] + }) + await removeSnapshotCredentials(this.#runtime, parsedRefs) + beginSnapshotTransaction(database) + try { + database + .prepare( + `DELETE FROM braid_state_snapshot_keys AS key_row + WHERE key_row.scope_id = ? AND key_row.retired = 1 + AND NOT EXISTS ( + SELECT 1 FROM braid_state_snapshots AS snapshot_row + WHERE snapshot_row.key_ref = key_row.credential_ref + )`, + ) + .run(this.#runtime.scopeId()) + commit(this.#runtime, database, 'state.snapshot.invalidation.cleanup') + } catch (error) { + rollbackSnapshotTransaction(database) + throw error + } + } + + async reconcile(): Promise { + await this.#runtime.writes.run(() => this.reconcileUnsafe()) + } + + async reconcileUnsafe(): Promise { + const database = this.#runtime.database() + const active = new Set() + const snapshots = database + .prepare('SELECT scope_id, generation FROM braid_state_snapshots') + .all() as readonly Record[] + for (const row of snapshots) { + active.add( + `${asString(row.scope_id, 'scope_id')}\u0000${asNumber(row.generation, 'generation')}`, + ) + } + const keys = database + .prepare( + 'SELECT scope_id, generation, credential_ref, retired FROM braid_state_snapshot_keys', + ) + .all() as readonly SnapshotKeyRow[] + const stale: Array<{ + readonly scopeId: string + readonly generation: number + readonly ref: CredentialRef + }> = [] + for (const row of keys) { + const scopeId = asString(row.scope_id, 'scope_id') + const generation = asNumber(row.generation, 'generation') + if (Number(row.retired) === 1 || !active.has(`${scopeId}\u0000${generation}`)) { + const ref = asCredentialRef(row.credential_ref) + if (ref === null) + throw new StorageError('STATE_SNAPSHOT_INVALID', 'Snapshot key reference is invalid') + stale.push({ scopeId, generation, ref }) + } + } + await removeSnapshotCredentials( + this.#runtime, + stale.map((row) => row.ref), + ) + if (stale.length === 0) return + beginSnapshotTransaction(database) + try { + for (const row of stale) { + database + .prepare( + `DELETE FROM braid_state_snapshot_keys AS key_row + WHERE key_row.scope_id = ? AND key_row.generation = ? + AND key_row.credential_ref = ? + AND NOT EXISTS ( + SELECT 1 FROM braid_state_snapshots AS snapshot_row + WHERE snapshot_row.key_ref = key_row.credential_ref + )`, + ) + .run(row.scopeId, row.generation, row.ref) + } + commit(this.#runtime, database, 'state.snapshot.key.reconcile') + } catch (error) { + rollbackSnapshotTransaction(database) + throw error + } + } +} diff --git a/src/adapters/storage/sqlite-state-snapshot-read.ts b/src/adapters/storage/sqlite-state-snapshot-read.ts new file mode 100644 index 0000000..55ab261 --- /dev/null +++ b/src/adapters/storage/sqlite-state-snapshot-read.ts @@ -0,0 +1,178 @@ +import { CredentialError, type CredentialRef } from '../../ports/credentials.js' +import type { StoredStateSnapshot } from '../../ports/storage.js' +import { decryptPayload } from './sqlite-crypto.js' +import { StorageError } from './sqlite-errors.js' +import { asBuffer, asString } from './sqlite-rows.js' +import { + asCredentialRef, + asPositiveNumber, + materializedSnapshotFromPayload, + metadataFromRow, + metadataFromRowSafe, +} from './sqlite-state-snapshot-codec.js' +import { removeSnapshotCredentials, resolveSnapshotKey } from './sqlite-state-snapshot-keys.js' +import { + beginSnapshotTransaction, + commit, + rollbackSnapshotTransaction, +} from './sqlite-state-snapshot-transaction.js' +import type { + SnapshotKeyRow, + SnapshotMetadata, + SnapshotRuntime, +} from './sqlite-state-snapshot-types.js' + +export class SqliteStateSnapshotReader { + readonly #runtime: SnapshotRuntime + + constructor(runtime: SnapshotRuntime) { + this.#runtime = runtime + } + + async latest(): Promise { + const scopeId = this.#runtime.scopeId() + for (;;) { + const row = this.#runtime + .database() + .prepare( + `SELECT snapshot_id, scope_id, generation, storage_id, event_id, + journal_sequence, revision, state_checksum, key_ref + FROM braid_state_snapshots + WHERE scope_id = ? + ORDER BY generation DESC, snapshot_id DESC + LIMIT 1`, + ) + .get(scopeId) as Record | undefined + if (!row) return null + let metadata: SnapshotMetadata | null = null + try { + metadata = metadataFromRow(row) + return await this.read(metadata) + } catch (error) { + if (!isSnapshotCorruption(error)) throw error + await this.quarantine(row, metadata ?? metadataFromRowSafe(row)) + } + } + } + + private async read(metadata: SnapshotMetadata): Promise { + const scopeId = this.#runtime.scopeId() + if (metadata.scopeId !== scopeId) + throw new StorageError('STATE_SNAPSHOT_INVALID', 'Snapshot scope differs') + const database = this.#runtime.database() + const key = database + .prepare( + `SELECT credential_ref, retired + FROM braid_state_snapshot_keys + WHERE scope_id = ? AND generation = ?`, + ) + .get(scopeId, metadata.generation) as SnapshotKeyRow | undefined + if (!key || Number(key.retired) === 1 || key.credential_ref !== metadata.keyRef) { + throw new StorageError( + 'STATE_SNAPSHOT_KEY_UNAVAILABLE', + 'Snapshot generation key is unavailable', + ) + } + const row = database + .prepare('SELECT state_ciphertext FROM braid_state_snapshots WHERE snapshot_id = ?') + .get(metadata.snapshotId) as { readonly state_ciphertext?: unknown } | undefined + if (!row) throw new StorageError('STATE_SNAPSHOT_INVALID', 'Snapshot payload is missing') + const secret = await resolveSnapshotKey(this.#runtime, metadata.keyRef) + try { + const payload = decryptPayload(asBuffer(row.state_ciphertext, 'state_ciphertext'), secret) + const candidate = materializedSnapshotFromPayload(metadata, payload) + const event = database + .prepare('SELECT event_id FROM braid_journal_events WHERE storage_id = ?') + .get(metadata.storageId) as { readonly event_id?: unknown } | undefined + if (!event || event.event_id !== metadata.eventId) { + throw new StorageError('STATE_SNAPSHOT_UNBOUND', 'Snapshot event binding is invalid') + } + return { ...candidate, storageId: metadata.storageId } + } finally { + secret.fill(0) + } + } + + private async quarantine( + row: Record, + metadata: SnapshotMetadata | null, + ): Promise { + const database = this.#runtime.database() + const snapshotId = asPositiveNumber(row.snapshot_id) + if (snapshotId === null) { + throw new StorageError( + 'STATE_SNAPSHOT_INVALID', + 'Invalid snapshot identity cannot be quarantined safely', + ) + } + const scopeId = asString(row.scope_id, 'scope_id') + const generation = asPositiveNumber(row.generation) + const keyRefs = new Set() + if (metadata?.keyRef !== undefined) keyRefs.add(metadata.keyRef) + const rowKeyRef = asCredentialRef(row.key_ref) + if (rowKeyRef !== null) keyRefs.add(rowKeyRef) + if (generation !== null) { + const keyRow = database + .prepare( + 'SELECT credential_ref FROM braid_state_snapshot_keys WHERE scope_id = ? AND generation = ?', + ) + .get(scopeId, generation) as SnapshotKeyRow | undefined + const storedKeyRef = asCredentialRef(keyRow?.credential_ref) + if (storedKeyRef !== null) keyRefs.add(storedKeyRef) + } + await this.#runtime.writes.run(async () => { + beginSnapshotTransaction(database) + try { + database.prepare('DELETE FROM braid_state_snapshots WHERE snapshot_id = ?').run(snapshotId) + if (generation !== null) { + database + .prepare( + 'UPDATE braid_state_snapshot_keys SET retired = 1 WHERE scope_id = ? AND generation = ?', + ) + .run(scopeId, generation) + } + commit(this.#runtime, database, 'state.snapshot.quarantine') + } catch (error) { + rollbackSnapshotTransaction(database) + throw error + } + await removeSnapshotCredentials(this.#runtime, [...keyRefs]) + beginSnapshotTransaction(database) + try { + if (generation !== null) { + database + .prepare( + `DELETE FROM braid_state_snapshot_keys AS key_row + WHERE key_row.scope_id = ? AND key_row.generation = ? + AND NOT EXISTS ( + SELECT 1 FROM braid_state_snapshots AS snapshot_row + WHERE snapshot_row.key_ref = key_row.credential_ref + )`, + ) + .run(scopeId, generation) + } + commit(this.#runtime, database, 'state.snapshot.quarantine.cleanup') + } catch (error) { + rollbackSnapshotTransaction(database) + throw error + } + }) + } +} + +const QUARANTINED_STORAGE_CODES = new Set([ + 'PAYLOAD_DECRYPT_FAILED', + 'PAYLOAD_VERSION', + 'STATE_SNAPSHOT_INVALID', + 'STATE_SNAPSHOT_KEY_INVALID', + 'STATE_SNAPSHOT_KEY_UNAVAILABLE', + 'STATE_SNAPSHOT_UNBOUND', + 'STORAGE_ROW_INVALID', +]) + +function isSnapshotCorruption(error: unknown): boolean { + if (error instanceof CredentialError) { + return error.code === 'CREDENTIAL_NOT_FOUND' || error.code === 'INVALID_CREDENTIAL_REF' + } + return error instanceof StorageError && QUARANTINED_STORAGE_CODES.has(error.code) +} diff --git a/src/adapters/storage/sqlite-state-snapshot-transaction.ts b/src/adapters/storage/sqlite-state-snapshot-transaction.ts new file mode 100644 index 0000000..faef095 --- /dev/null +++ b/src/adapters/storage/sqlite-state-snapshot-transaction.ts @@ -0,0 +1,30 @@ +import type { SqliteDatabase } from './sqlite-driver.js' +import type { SnapshotRuntime } from './sqlite-state-snapshot-types.js' + +type SnapshotDurabilityRuntime = Pick + +export function beginSnapshotTransaction(database: SqliteDatabase): void { + database.exec('BEGIN IMMEDIATE') +} + +export function commit( + runtime: SnapshotDurabilityRuntime, + database: SqliteDatabase, + boundary: string, +): void { + runtime.durableBoundary?.(`before:${boundary}`) + database.exec('COMMIT') + try { + runtime.durableBoundary?.(`after:${boundary}`) + } catch { + // An observer cannot invalidate a transition that has already committed. + } +} + +export function rollbackSnapshotTransaction(database: SqliteDatabase): void { + try { + database.exec('ROLLBACK') + } catch { + // Preserve the original storage failure if rollback itself is unavailable. + } +} diff --git a/src/adapters/storage/sqlite-state-snapshot-types.ts b/src/adapters/storage/sqlite-state-snapshot-types.ts new file mode 100644 index 0000000..2bfd810 --- /dev/null +++ b/src/adapters/storage/sqlite-state-snapshot-types.ts @@ -0,0 +1,35 @@ +import type { CredentialPort, CredentialRef } from '../../ports/credentials.js' +import type { SqliteDatabase } from './sqlite-driver.js' +import type { BoundedWriteQueue } from './sqlite-queue.js' + +export interface SnapshotRuntime { + readonly database: () => SqliteDatabase + readonly credentials: CredentialPort + readonly scopeId: () => string + readonly writes: BoundedWriteQueue + readonly durableBoundary?: (boundary: string) => void +} + +export interface PreparedStateSnapshot { + readonly key: Buffer + readonly keyRef: CredentialRef +} + +export interface SnapshotMetadata { + readonly snapshotId: number + readonly scopeId: string + readonly generation: number + readonly storageId: number + readonly eventId: string + readonly sequence: number + readonly revision: number + readonly stateChecksum: string + readonly keyRef: CredentialRef +} + +export interface SnapshotKeyRow { + readonly scope_id?: unknown + readonly generation?: unknown + readonly credential_ref?: unknown + readonly retired?: unknown +} diff --git a/src/adapters/storage/sqlite-state-snapshot-write.ts b/src/adapters/storage/sqlite-state-snapshot-write.ts new file mode 100644 index 0000000..c0b522f --- /dev/null +++ b/src/adapters/storage/sqlite-state-snapshot-write.ts @@ -0,0 +1,119 @@ +import type { CredentialRef } from '../../ports/credentials.js' +import type { StateSnapshot } from '../../ports/storage.js' +import { encryptPayload } from './sqlite-crypto.js' +import { StorageError } from './sqlite-errors.js' +import { asNumber, now } from './sqlite-rows.js' +import { assertJsonState, assertSnapshotChecksum } from './sqlite-state-snapshot-codec.js' +import { prepareSnapshotKey, removeSnapshotCredentials } from './sqlite-state-snapshot-keys.js' +import { + beginSnapshotTransaction, + commit, + rollbackSnapshotTransaction, +} from './sqlite-state-snapshot-transaction.js' +import type { PreparedStateSnapshot, SnapshotRuntime } from './sqlite-state-snapshot-types.js' + +export class SqliteStateSnapshotWriter { + readonly #runtime: SnapshotRuntime + + constructor(runtime: SnapshotRuntime) { + this.#runtime = runtime + } + + async prepare( + snapshot: StateSnapshot, + createdRefs: CredentialRef[], + ): Promise { + return prepareSnapshotKey(this.#runtime, snapshot, createdRefs) + } + + writeUnsafe(snapshot: StateSnapshot, storageId: number, prepared: PreparedStateSnapshot): void { + if (snapshot.scopeId !== this.#runtime.scopeId()) { + throw new StorageError('STATE_SNAPSHOT_INVALID', 'Snapshot scope differs from storage scope') + } + assertSnapshotChecksum(snapshot) + const database = this.#runtime.database() + const event = database + .prepare('SELECT event_id FROM braid_journal_events WHERE storage_id = ?') + .get(storageId) as { readonly event_id?: unknown } | undefined + if (!event || event.event_id !== snapshot.eventId) { + throw new StorageError( + 'STATE_SNAPSHOT_UNBOUND', + `State snapshot event ${snapshot.eventId} is not bound to storage row ${storageId}`, + ) + } + const ciphertext = encryptPayload(assertJsonState(snapshot.state), prepared.key) + try { + database + .prepare( + `INSERT INTO braid_state_snapshot_keys( + scope_id, generation, credential_ref, retired, created_at + ) VALUES (?, ?, ?, 0, ?) + `, + ) + .run(this.#runtime.scopeId(), snapshot.generation, prepared.keyRef, now()) + database + .prepare( + `INSERT INTO braid_state_snapshots( + scope_id, generation, storage_id, event_id, journal_sequence, + revision, state_checksum, key_ref, state_ciphertext, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ) + .run( + this.#runtime.scopeId(), + snapshot.generation, + storageId, + snapshot.eventId, + snapshot.sequence, + snapshot.revision, + snapshot.stateChecksum, + prepared.keyRef, + ciphertext, + now(), + ) + } finally { + ciphertext.fill(0) + } + } + + async write(snapshot: StateSnapshot, pruneAfterCommit: () => Promise): Promise { + const createdRefs: CredentialRef[] = [] + const prepared = await this.prepare(snapshot, createdRefs) + let committed = false + try { + await this.#runtime.writes.run(async () => { + const database = this.#runtime.database() + beginSnapshotTransaction(database) + try { + const events = database + .prepare( + 'SELECT storage_id FROM braid_journal_events WHERE event_id = ? ORDER BY storage_id', + ) + .all(snapshot.eventId) as readonly { readonly storage_id?: unknown }[] + const event = events[0] + if (event === undefined) + throw new StorageError('STATE_SNAPSHOT_UNBOUND', 'Snapshot event is not stored') + if (events.length !== 1) { + throw new StorageError( + 'STATE_SNAPSHOT_UNBOUND', + `Snapshot event ${snapshot.eventId} is not uniquely bound to storage`, + ) + } + this.writeUnsafe(snapshot, asNumber(event.storage_id, 'storage_id'), prepared) + commit(this.#runtime, database, 'state.snapshot') + committed = true + await pruneAfterCommit() + } catch (error) { + if (!committed) rollbackSnapshotTransaction(database) + throw error + } + }) + createdRefs.length = 0 + } catch (error) { + if (!committed) await removeSnapshotCredentials(this.#runtime, createdRefs) + throw error + } finally { + prepared.key.fill(0) + } + } +} diff --git a/src/adapters/storage/sqlite-state-snapshots.ts b/src/adapters/storage/sqlite-state-snapshots.ts new file mode 100644 index 0000000..88b4192 --- /dev/null +++ b/src/adapters/storage/sqlite-state-snapshots.ts @@ -0,0 +1,73 @@ +import type { CredentialRef } from '../../ports/credentials.js' +import type { StateSnapshot, StoredStateSnapshot } from '../../ports/storage.js' +import { SqliteStateSnapshotLifecycle } from './sqlite-state-snapshot-lifecycle.js' +import { SqliteStateSnapshotReader } from './sqlite-state-snapshot-read.js' +import type { PreparedStateSnapshot, SnapshotRuntime } from './sqlite-state-snapshot-types.js' +import { SqliteStateSnapshotWriter } from './sqlite-state-snapshot-write.js' + +export class SqliteStateSnapshotStore { + readonly #lifecycle: SqliteStateSnapshotLifecycle + readonly #reader: SqliteStateSnapshotReader + readonly #writer: SqliteStateSnapshotWriter + + constructor(runtime: SnapshotRuntime) { + this.#lifecycle = new SqliteStateSnapshotLifecycle(runtime) + this.#reader = new SqliteStateSnapshotReader(runtime) + this.#writer = new SqliteStateSnapshotWriter(runtime) + } + + async latest(): Promise { + return this.#reader.latest() + } + + async prepare( + snapshot: StateSnapshot, + createdRefs: CredentialRef[], + ): Promise { + return this.#writer.prepare(snapshot, createdRefs) + } + + writeUnsafe(snapshot: StateSnapshot, storageId: number, prepared: PreparedStateSnapshot): void { + this.#writer.writeUnsafe(snapshot, storageId, prepared) + } + + async write(snapshot: StateSnapshot): Promise { + return this.#writer.write(snapshot, () => this.#lifecycle.pruneAfterCommitUnsafe()) + } + + async prune(): Promise { + return this.#lifecycle.prune() + } + + async pruneAfterCommitUnsafe(): Promise { + return this.#lifecycle.pruneAfterCommitUnsafe() + } + + async discardCredentials(refs: readonly CredentialRef[]): Promise { + return this.#lifecycle.discardCredentials(refs) + } + + async pruneUnsafe(): Promise { + return this.#lifecycle.pruneUnsafe() + } + + async destroyKeysBeforeMutation(): Promise { + return this.#lifecycle.destroyKeysBeforeMutation() + } + + invalidateUnsafe(): void { + this.#lifecycle.invalidateUnsafe() + } + + async finishInvalidationUnsafe(): Promise { + return this.#lifecycle.finishInvalidationUnsafe() + } + + async reconcile(): Promise { + return this.#lifecycle.reconcile() + } + + async reconcileUnsafe(): Promise { + return this.#lifecycle.reconcileUnsafe() + } +} diff --git a/src/adapters/storage/sqlite-types.ts b/src/adapters/storage/sqlite-types.ts new file mode 100644 index 0000000..ba1e232 --- /dev/null +++ b/src/adapters/storage/sqlite-types.ts @@ -0,0 +1,97 @@ +import type { CredentialPort, CredentialRef } from '../../ports/credentials.js' +import type { HeadlessKeySource } from '../credentials/headless-key.js' +import type { SqliteDatabase, SqliteDatabaseFactory } from './sqlite-driver.js' + +export const DEFAULT_BUSY_TIMEOUT_MS = 5_000 +export const DEFAULT_MAX_EVENTS = 256 +export const DEFAULT_MAX_PAYLOAD_BYTES = 4 * 1024 * 1024 +export const DEFAULT_MAX_QUEUED_TRANSACTIONS = 64 +export const RESTORE_MANIFEST_VERSION = 1 + +export interface MigrationHooks { + readonly beforeVersionCommit?: (version: number) => void +} + +export type DurableBoundaryHook = (boundary: string) => void + +export interface SqliteStorageOptions { + readonly path: string + readonly workspaceRoot?: string + readonly credentialStore: CredentialPort + readonly databaseKeyRef?: CredentialRef + readonly databaseKeySource?: HeadlessKeySource + readonly busyTimeoutMs?: number + readonly maxEventsPerTransaction?: number + readonly maxPayloadBytesPerTransaction?: number + readonly maxQueuedTransactions?: number + readonly backupDirectory?: string + readonly migrationHooks?: MigrationHooks + readonly durableBoundaryHook?: DurableBoundaryHook + /** Test/packaging seam. It must still expose key/rekey and is never a plaintext fallback. */ + readonly databaseFactory?: SqliteDatabaseFactory +} + +export interface SqliteStorageInput { + readonly path: string + readonly workspaceRoot?: string + readonly credentials: CredentialPort + readonly databaseKeyRef: CredentialRef + readonly databaseKey: Buffer + readonly databaseFactory: SqliteDatabaseFactory + readonly busyTimeoutMs: number + readonly maxEvents: number + readonly maxPayloadBytes: number + readonly maxQueuedTransactions: number + readonly backupDirectory: string + readonly migrationHooks?: MigrationHooks + readonly durableBoundaryHook?: DurableBoundaryHook + readonly database: SqliteDatabase + readonly databaseFileDescriptor?: number + readonly exclusiveLock?: { readonly release: () => Promise } +} + +export interface SqliteEventRow extends Record { + readonly storage_id: number | bigint + readonly workspace_id: string + readonly conversation_id: string + readonly run_id: string + readonly event_id: string + readonly provider_event_id: string | null + readonly run_sequence: number + readonly kind: string + readonly cursor: string | null + readonly operation_id: string | null + readonly payload: Buffer + readonly payload_checksum: string + readonly occurred_at: string + readonly received_at: string + readonly terminal: number + readonly redacted: number +} + +export interface CursorRow extends Record { + readonly run_id: string + readonly conversation_id: string + readonly last_sequence: number + readonly last_cursor: string | null + readonly missing_from: number | null + readonly missing_to: number | null + readonly terminal: number +} + +export interface OperationRow extends Record { + readonly operation_id: string + readonly operation_kind: string + readonly request_digest: string + readonly request_json: string + readonly status: 'pending' | 'acknowledged' | 'failed' | 'unknown' | 'conflict' | 'terminal' + readonly result_json: string | null + readonly created_at: string + readonly updated_at: string +} + +export interface QueueTask { + readonly operation: () => Promise + readonly resolve: (value: T | PromiseLike) => void + readonly reject: (reason?: unknown) => void +} diff --git a/src/adapters/storage/sqlite.ts b/src/adapters/storage/sqlite.ts new file mode 100644 index 0000000..98caf85 --- /dev/null +++ b/src/adapters/storage/sqlite.ts @@ -0,0 +1,203 @@ +import { randomBytes } from 'node:crypto' +import { dirname } from 'node:path' +import type { CredentialRef, SecretHandle } from '../../ports/credentials.js' +import type { EffectStoragePort } from '../../ports/effect-storage.js' +import type { StoragePort } from '../../ports/storage.js' +import { readHeadlessKey } from '../credentials/headless-key.js' +import { closeBoundSqliteDatabase, openBoundSqliteDatabase } from './sqlite-bound-open.js' +import { configureCipherDatabase, loadCipherDatabaseFactory } from './sqlite-driver.js' +import { StorageError } from './sqlite-errors.js' +import { + claimInitializationMarker, + inspectInitializationMarker, + markInitializationInterrupted, + releaseInitializationMarker, + removeDatabaseArtifacts, +} from './sqlite-initialization.js' +import { + assertApprovedPath, + classifySqliteError, + ensureDirectory, + validatePath, +} from './sqlite-paths.js' +import { acquireExclusiveLock, recoverRestoreManifest } from './sqlite-recovery.js' +import { SqliteRedactionStorage } from './sqlite-redaction.js' +import { credentialErrorCode, deterministicCredentialRef } from './sqlite-rows.js' +import { applyConnectionPragmas } from './sqlite-schema.js' +import { + DEFAULT_BUSY_TIMEOUT_MS, + DEFAULT_MAX_EVENTS, + DEFAULT_MAX_PAYLOAD_BYTES, + DEFAULT_MAX_QUEUED_TRANSACTIONS, + type SqliteStorageInput, + type SqliteStorageOptions, +} from './sqlite-types.js' + +export type { DurableBoundaryHook, SqliteStorageOptions } from './sqlite-types.js' +export class SqliteStorage + extends SqliteRedactionStorage + implements StoragePort, EffectStoragePort +{ + protected constructor(input: SqliteStorageInput) { + super(input) + } + + static async open(options: SqliteStorageOptions): Promise { + const path = validatePath(options.path, 'SQLite path') + const approvedRoot = validatePath(options.workspaceRoot ?? dirname(path), 'Approved root') + const unsafeOptions = options as unknown as Record + for (const forbidden of ['databaseKey', 'key', 'encryptionKey', 'env', 'environmentVariable']) { + if (forbidden in unsafeOptions) { + throw new StorageError( + 'HEADLESS_KEY_SOURCE_REQUIRED', + 'Raw and environment key inputs are rejected', + ) + } + } + const busyTimeoutMs = options.busyTimeoutMs ?? DEFAULT_BUSY_TIMEOUT_MS + const maxEvents = options.maxEventsPerTransaction ?? DEFAULT_MAX_EVENTS + const maxPayloadBytes = options.maxPayloadBytesPerTransaction ?? DEFAULT_MAX_PAYLOAD_BYTES + const maxQueuedTransactions = options.maxQueuedTransactions ?? DEFAULT_MAX_QUEUED_TRANSACTIONS + if (!Number.isInteger(busyTimeoutMs) || busyTimeoutMs < 1 || busyTimeoutMs > 60_000) { + throw new StorageError('STORAGE_OPTIONS', 'busyTimeoutMs must be between 1 and 60000') + } + if (!Number.isInteger(maxEvents) || maxEvents < 1) + throw new StorageError('STORAGE_OPTIONS', 'maxEvents must be positive') + if (!Number.isInteger(maxPayloadBytes) || maxPayloadBytes < 1024) { + throw new StorageError('STORAGE_OPTIONS', 'maxPayloadBytes must be at least 1024') + } + if (!Number.isInteger(maxQueuedTransactions) || maxQueuedTransactions < 1) { + throw new StorageError('STORAGE_OPTIONS', 'maxQueuedTransactions must be positive') + } + await ensureDirectory(approvedRoot) + await assertApprovedPath(path, approvedRoot, 'SQLite path') + const backupDirectory = validatePath( + options.backupDirectory ?? dirname(path), + 'Backup directory', + ) + await assertApprovedPath(backupDirectory, approvedRoot, 'Backup directory') + const lock = await acquireExclusiveLock(`${path}.exclusive.lock`) + let lockReleased = false + try { + await recoverRestoreManifest(path, approvedRoot) + const markerState = await inspectInitializationMarker(path) + if (markerState === 'active') { + throw new StorageError( + 'STORAGE_INITIALIZING', + 'Another process is initializing the encrypted database', + ) + } + let credentialStoreAvailable = false + try { + credentialStoreAvailable = await options.credentialStore.available() + } catch (error) { + throw new StorageError( + 'CREDENTIAL_STORE_UNAVAILABLE', + 'The operating-system credential facility is unavailable', + { cause: error }, + ) + } + if (!credentialStoreAvailable) { + throw new StorageError( + 'CREDENTIAL_STORE_UNAVAILABLE', + 'The operating-system credential facility is unavailable', + ) + } + const databaseFactory = options.databaseFactory ?? loadCipherDatabaseFactory() + const databaseKeyRef = options.databaseKeyRef ?? deterministicCredentialRef('database', path) + const databaseKey = await resolveDatabaseKey(options, databaseKeyRef) + let opened: ReturnType | undefined + let initializationClaimed = false + try { + opened = openBoundSqliteDatabase(path, databaseFactory, busyTimeoutMs) + initializationClaimed = opened.newDatabase || markerState === 'stale' + if (initializationClaimed) await claimInitializationMarker(path) + configureCipherDatabase(opened.database, databaseKey, { newDatabase: opened.newDatabase }) + applyConnectionPragmas(opened.database, busyTimeoutMs) + } catch (error) { + try { + if (opened) closeBoundSqliteDatabase(opened) + } catch { + // The original error is the useful failure boundary. + } + databaseKey.fill(0) + if (initializationClaimed) { + if (opened?.newDatabase) await removeDatabaseArtifacts(path) + await releaseInitializationMarker(path) + } + throw error instanceof StorageError ? error : classifySqliteError(error) + } + const storage = new SqliteStorage({ + path, + workspaceRoot: approvedRoot, + credentials: options.credentialStore, + databaseKeyRef, + databaseKey, + databaseFactory, + busyTimeoutMs, + maxEvents, + maxPayloadBytes, + maxQueuedTransactions, + backupDirectory, + ...(options.migrationHooks === undefined ? {} : { migrationHooks: options.migrationHooks }), + ...(options.durableBoundaryHook === undefined + ? {} + : { durableBoundaryHook: options.durableBoundaryHook }), + database: opened.database, + databaseFileDescriptor: opened.fileDescriptor, + }) + databaseKey.fill(0) + try { + await storage.initialize() + if (initializationClaimed) await releaseInitializationMarker(path) + await lock.release() + lockReleased = true + return storage + } catch (error) { + await storage.close().catch(() => undefined) + if (initializationClaimed) { + if (opened?.newDatabase) { + await removeDatabaseArtifacts(path).catch(() => undefined) + await releaseInitializationMarker(path).catch(() => undefined) + } else { + await markInitializationInterrupted(path).catch(() => undefined) + } + } + throw error + } + } finally { + if (!lockReleased) await lock.release() + } + } +} + +async function resolveDatabaseKey( + options: SqliteStorageOptions, + ref: CredentialRef, +): Promise { + if (options.databaseKeySource) return readHeadlessKey(options.databaseKeySource) + let handle: SecretHandle | undefined + try { + handle = await options.credentialStore.resolve(ref) + const key = Buffer.from(handle.read()) + if (key.length !== 32) + throw new StorageError('SQLITE_KEY_INVALID', 'Stored SQLite key has invalid length') + return key + } catch (error) { + if (credentialErrorCode(error) !== 'CREDENTIAL_NOT_FOUND') throw error + const key = randomBytes(32) + try { + await options.credentialStore.store({ ref, value: key, label: 'Braid database key' }) + return Buffer.from(key) + } finally { + key.fill(0) + handle?.dispose() + } + } finally { + handle?.dispose() + } +} + +export function openSqliteStorage(options: SqliteStorageOptions): Promise { + return SqliteStorage.open(options) +} diff --git a/src/adapters/storage/storage-validation.ts b/src/adapters/storage/storage-validation.ts new file mode 100644 index 0000000..8023214 --- /dev/null +++ b/src/adapters/storage/storage-validation.ts @@ -0,0 +1,152 @@ +import { canonicalDigest } from '../../domain/canonical.js' +import { + parseConversationId, + parseEventId, + parseOperationId, + parseReplayCursor, + parseRunId, + parseWorkspaceId, +} from '../../domain/ids.js' +import { isSafePublicMetadata } from '../../domain/public-metadata.js' +import { containsUnsafeControlCharacter, isCanonicalIsoDateTime } from '../../domain/text.js' +import type { EffectRecord } from '../../ports/effect-storage.js' +import type { JournalEvent, JsonValue, OperationIntent } from '../../ports/storage.js' +import { StorageError } from './sqlite-errors.js' + +export function assertJournalEventInput(event: JournalEvent): void { + try { + parseWorkspaceId(event.workspaceId) + parseConversationId(event.conversationId) + parseRunId(event.runId) + parseEventId(event.eventId) + if ( + event.providerEventId !== undefined && + (typeof event.providerEventId !== 'string' || + event.providerEventId.length === 0 || + event.providerEventId.length > 4096 || + event.providerEventId.includes('\u0000') || + event.providerEventId.includes('\r') || + event.providerEventId.includes('\n')) + ) { + throw new StorageError( + 'EVENT_INVALID', + 'Provider event identity must be a bounded single-line string', + ) + } + if (event.operationId !== undefined) parseOperationId(event.operationId) + if (event.cursor !== undefined) parseReplayCursor(event.cursor) + } catch (error) { + throw new StorageError( + 'EVENT_INVALID', + 'Journal event contains an identifier from the wrong domain', + { cause: error }, + ) + } + if (!Number.isSafeInteger(event.sequence) || event.sequence < 1) { + throw new StorageError('EVENT_INVALID', 'Journal event sequence must be a positive integer') + } + if (typeof event.kind !== 'string' || event.kind.length === 0 || event.kind.length > 256) { + throw new StorageError('EVENT_INVALID', 'Journal event kind must be a non-empty bounded string') + } + if (!isCanonicalIsoDateTime(event.occurredAt)) { + throw new StorageError('EVENT_INVALID', 'Journal event occurredAt must be a canonical ISO date') + } + if (event.receivedAt !== undefined && !isCanonicalIsoDateTime(event.receivedAt)) { + throw new StorageError('EVENT_INVALID', 'Journal event receivedAt must be a canonical ISO date') + } +} + +export function assertOperationIntentInput(intent: OperationIntent): void { + try { + parseOperationId(intent.operationId) + } catch (error) { + throw new StorageError('OPERATION_INVALID', 'Operation identifier is not valid', { + cause: error, + }) + } + if (!/^[0-9a-f]{64}$/u.test(intent.requestDigest)) { + throw new StorageError('OPERATION_INVALID', 'Operation request digest must be a SHA-256 digest') + } + if (canonicalDigest(intent.request) !== intent.requestDigest) { + throw new StorageError( + 'OPERATION_INVALID', + 'Operation request digest does not match the canonical request', + ) + } + if (typeof intent.kind !== 'string' || intent.kind.length === 0 || intent.kind.length > 256) { + throw new StorageError('OPERATION_INVALID', 'Operation kind must be a non-empty bounded string') + } + if (intent.createdAt !== undefined && !isCanonicalIsoDateTime(intent.createdAt)) { + throw new StorageError('OPERATION_INVALID', 'Operation createdAt must be a canonical ISO date') + } +} + +export function assertOperationRequestDigest(intent: OperationIntent, request: JsonValue): void { + assertOperationIntentInput(intent) + if (canonicalDigest(request) !== intent.requestDigest) { + throw new StorageError( + 'OPERATION_INVALID', + 'Operation request digest does not cover the complete storage mutation request', + ) + } +} + +export function assertEffectRecordInput(record: EffectRecord): void { + try { + parseOperationId(record.operationId) + } catch (error) { + throw new StorageError('EFFECT_INVALID', 'Effect operation identifier is not valid', { + cause: error, + }) + } + if (!/^[0-9a-f]{64}$/u.test(record.requestDigest)) { + throw new StorageError('EFFECT_INVALID', 'Effect request digest must be a SHA-256 digest') + } + if ( + typeof record.effectKind !== 'string' || + record.effectKind.length === 0 || + record.effectKind.length > 256 + ) { + throw new StorageError('EFFECT_INVALID', 'Effect kind must be a non-empty bounded string') + } + if (!Number.isSafeInteger(record.attempt) || record.attempt < 1) { + throw new StorageError('EFFECT_INVALID', 'Effect attempt must be a positive integer') + } + if ( + !isCanonicalIsoDateTime(record.createdAt) || + !isCanonicalIsoDateTime(record.updatedAt) || + record.updatedAt < record.createdAt + ) { + throw new StorageError( + 'EFFECT_INVALID', + 'Effect timestamps must be canonical ISO dates in order', + ) + } + if ( + !['pending', 'acknowledged', 'failed', 'unknown', 'conflict', 'terminal'].includes( + record.status, + ) + ) { + throw new StorageError('EFFECT_INVALID', 'Effect status is not recognized') + } + const safeText = (value: string, field: string, maximum: number): void => { + if ( + value.length > maximum || + containsUnsafeControlCharacter(value) || + /(secret|password|passphrase|token|bearer|authorization|credential|private(?:[_-]?key)?|api[-_]?key)\s*[:=]/iu.test( + value, + ) + ) { + throw new StorageError('EFFECT_INVALID', `${field} contains unsafe diagnostic text`) + } + } + if (record.detail !== undefined) safeText(record.detail, 'Effect detail', 128) + if (record.externalReference !== undefined) + safeText(record.externalReference, 'Effect external reference', 256) + if (!isSafePublicMetadata(record.metadata)) { + throw new StorageError( + 'EFFECT_INVALID', + 'Effect metadata must contain only bounded public strings', + ) + } +} diff --git a/src/adapters/tui/alternate-screen-terminal.ts b/src/adapters/tui/alternate-screen-terminal.ts index fc86783..85b5ddd 100644 --- a/src/adapters/tui/alternate-screen-terminal.ts +++ b/src/adapters/tui/alternate-screen-terminal.ts @@ -4,13 +4,21 @@ const ENTER_ALTERNATE_SCREEN = '\u001b[?1049h' const LEAVE_ALTERNATE_SCREEN = '\u001b[?1049l' export class AlternateScreenTerminal extends ProcessTerminal { + #started = false + #stopped = false + override start(onInput: (data: string) => void, onResize: () => void): void { + if (this.#started && !this.#stopped) return + this.#started = true + this.#stopped = false super.start(onInput, onResize) this.write(ENTER_ALTERNATE_SCREEN) this.clearScreen() } override stop(): void { + if (this.#stopped) return + this.#stopped = true this.write(LEAVE_ALTERNATE_SCREEN) super.stop() } diff --git a/src/adapters/tui/application-ui-controller.ts b/src/adapters/tui/application-ui-controller.ts new file mode 100644 index 0000000..c2934ed --- /dev/null +++ b/src/adapters/tui/application-ui-controller.ts @@ -0,0 +1,266 @@ +import type { BraidApplication } from '../../app/application.js' +import type { BraidState } from '../../domain/state.js' +import type { + BraidIntent, + BraidUiController, + UiDispatchResult, + UiEvent, + UiSubscriber, +} from '../../views/shared/intents.js' +import type { BraidViewModel, HeadlessState } from '../../views/shared/models.js' +import { freezeView } from '../../views/shared/models.js' +import type { + ProfileConnectionDispatchOptions, + ProfileConnectionDispatchServices, +} from './profile-connection-dispatch.js' +import { capabilityMap } from './ui-capabilities.js' +import { errorResult } from './ui-dispatch-error.js' +import { FIXTURE_FORK, FIXTURE_INTERACTION, type UiFixture } from './ui-fixtures.js' +import { toEvent, toHeadlessState } from './ui-projection.js' +import { buildBraidViewModel, type UiAppearanceOptions } from './ui-view-model.js' + +export type { UiAppearanceOptions, UiFixture } +export { buildBraidViewModel } + +export class ApplicationUiController implements BraidUiController { + #app: BraidApplication + readonly #subscribers = new Set() + readonly #subscriptions = new Map void>() + readonly #appearance: UiAppearanceOptions + readonly #fixture: UiFixture | undefined + #profileConnections: + | { + readonly app: BraidApplication + readonly value: Promise + } + | undefined + #intentDispatcher: Promise | undefined + readonly #profileConnectionOptions: ProfileConnectionDispatchOptions + #selectedSurface: BraidViewModel['selectedSurface'] = 'transcript' + #interactionResolved = false + #notice: string | undefined + #forkPreview: BraidViewModel['forkPreview'] | undefined + + constructor( + app: BraidApplication, + appearance: UiAppearanceOptions = {}, + fixture?: UiFixture, + profileConnectionOptions: ProfileConnectionDispatchOptions = {}, + ) { + this.#app = app + this.#appearance = Object.freeze({ ...appearance }) + this.#fixture = fixture + this.#profileConnectionOptions = profileConnectionOptions + } + + view(): BraidViewModel { + const state = this.#app.state() + const view = withRunUsage( + buildBraidViewModel( + state, + this.#selectedSurface, + this.#appearance, + this.#app.canCancel(), + this.#app.storageFailure(), + this.#app.cleanupUncertain(), + ), + state, + ) + const decorated = freezeView({ + ...view, + ...(this.#notice === undefined ? {} : { notice: this.#notice, statusText: this.#notice }), + ...(this.#forkPreview === undefined ? {} : { forkPreview: this.#forkPreview }), + }) + const fixtureDecorated = + this.#fixture === undefined + ? decorated + : freezeView({ + ...decorated, + capabilities: capabilityMap(state, this.#app.canCancel(), this.#fixture), + }) + if (this.#fixture === 'interaction' && !this.#interactionResolved) { + return freezeView({ + ...fixtureDecorated, + interactions: Object.freeze([FIXTURE_INTERACTION]), + }) + } + if (this.#fixture === 'fork') { + return freezeView({ + ...fixtureDecorated, + forkPreview: FIXTURE_FORK, + }) + } + return fixtureDecorated + } + + state(): HeadlessState { + return toHeadlessState( + this.#app.state(), + this.#app.storageFailure(), + this.#app.cleanupUncertain(), + ) + } + + events(): readonly UiEvent[] { + return freezeView(this.#app.events().map(toEvent)) + } + + subscribe(subscriber: UiSubscriber): () => void { + this.#subscribers.add(subscriber) + const unsubscribeApp = this.#subscribeToApp(subscriber) + this.#subscriptions.set(subscriber, unsubscribeApp) + return () => { + this.#subscriptions.get(subscriber)?.() + this.#subscriptions.delete(subscriber) + this.#subscribers.delete(subscriber) + } + } + + async replaceApplication(next: BraidApplication, workspace: string): Promise { + if (next === this.#app) return + next.initialize(workspace) + await next.whenDurable() + const nextSubscriptions = new Map void>() + try { + for (const subscriber of this.#subscribers) { + nextSubscriptions.set(subscriber, this.#subscribeToApp(subscriber, next)) + } + for (const unsubscribe of this.#subscriptions.values()) unsubscribe() + } catch (error) { + for (const unsubscribe of nextSubscriptions.values()) unsubscribe() + throw error + } + this.#app = next + this.#profileConnections = undefined + this.#subscriptions.clear() + for (const [subscriber, unsubscribe] of nextSubscriptions) { + this.#subscriptions.set(subscriber, unsubscribe) + } + this.#notify() + } + + async initialize(workspace: string): Promise { + try { + this.#app.initialize(workspace) + await this.#app.whenDurable() + return { kind: 'accepted', revision: this.#app.state().revision } + } catch (error) { + return errorResult(error) + } + } + + async dispatch(intent: BraidIntent): Promise { + const app = this.#app + try { + const [dispatchIntent, profileConnections] = await Promise.all([ + this.#dispatcher(), + this.#profileConnectionServices(), + ]) + if (app !== this.#app) return this.dispatch(intent) + const result = await dispatchIntent(intent, { + app, + profileConnections, + fixture: this.#fixture, + subscribers: this.#subscribers, + view: () => this.view(), + notify: () => this.#notify(), + interactionResolved: () => this.#interactionResolved, + markInteractionResolved: () => { + this.#interactionResolved = true + }, + setSelectedSurface: (surface) => { + this.#selectedSurface = surface + }, + setNotice: (notice) => { + this.#notice = notice + }, + setForkPreview: (preview) => { + this.#forkPreview = preview + }, + }) + if (result.kind === 'accepted' && result.notice !== undefined) this.#notice = result.notice + if (result.kind === 'accepted' && result.notice !== undefined) this.#notify() + return result + } catch (error) { + return errorResult(error) + } + } + + #dispatcher(): Promise { + this.#intentDispatcher ??= import('./ui-dispatch.js').then((module) => module.dispatchIntent) + return this.#intentDispatcher + } + + #profileConnectionServices(): Promise { + const app = this.#app + if (this.#profileConnections?.app === app) return this.#profileConnections.value + const value = import('./profile-connection-dispatch.js').then((module) => + module.createProfileConnectionDispatchServices(app, this.#profileConnectionOptions), + ) + this.#profileConnections = { app, value } + return value + } + + async waitForIdle(): Promise { + await this.#app.waitForIdle() + return this.view() + } + + #notify(): void { + for (const subscriber of this.#subscribers) { + try { + subscriber(this.view()) + } catch { + // Subscriber failures must not make an already-committed app swap fail. + } + } + } + + #subscribeToApp(subscriber: UiSubscriber, app = this.#app): () => void { + return app.subscribe((state, envelope) => { + const event = toEvent(envelope) + subscriber( + withRunUsage( + buildBraidViewModel( + state, + this.#selectedSurface, + this.#appearance, + app.canCancel(), + app.storageFailure(), + app.cleanupUncertain(), + ), + state, + ), + event, + ) + }) + } +} + +function withRunUsage(view: BraidViewModel, state: BraidState): BraidViewModel { + const sourceById = new Map(state.runs.map((run) => [run.id, run])) + const runs = view.runs.map((run) => { + const source = sourceById.get(run.id) + if (source === undefined) return run + const usage = { + ...(run.usage?.model === undefined ? {} : { model: run.usage.model }), + ...(source.inputTokens > 0 ? { input: source.inputTokens } : {}), + ...(source.outputTokens > 0 ? { output: source.outputTokens } : {}), + ...(source.costUsd === undefined || source.costUsd <= 0 ? {} : { costUsd: source.costUsd }), + } + if (Object.keys(usage).length > 0) return { ...run, usage: Object.freeze(usage) } + const runWithoutUsage = { ...run } + delete runWithoutUsage.usage + return runWithoutUsage + }) + return freezeView({ ...view, runs: Object.freeze(runs) }) +} + +export function createApplicationUiController( + app: BraidApplication, + appearance: UiAppearanceOptions = {}, + fixture?: UiFixture, + profileConnectionOptions: ProfileConnectionDispatchOptions = {}, +): ApplicationUiController { + return new ApplicationUiController(app, appearance, fixture, profileConnectionOptions) +} diff --git a/src/adapters/tui/profile-connection-dispatch.ts b/src/adapters/tui/profile-connection-dispatch.ts new file mode 100644 index 0000000..31e5907 --- /dev/null +++ b/src/adapters/tui/profile-connection-dispatch.ts @@ -0,0 +1,306 @@ +import type { AgentProfileSecurityPolicy } from '@tangle-network/agent-interface' +import type { + ConnectionHealthOptions, + ConnectionModelVerificationOptions, + ProductionConnectionAdapter, + ProductionConnectionOptions, +} from '../../adapters/connections/production-connections.js' +import type { BraidApplication } from '../../app/application.js' +import { ConnectionActionService } from '../../app/connection-actions.js' +import type { ConnectionProbeFactory } from '../../app/connection-probe.js' +import { AppError } from '../../app/errors.js' +import { type ProfileActionOptions, ProfileActionService } from '../../app/profile-actions.js' +import type { + ProfileDiscoveryInput, + ProfileProvider, + ProfileRecord as SourceProfileRecord, +} from '../../app/profiles.js' +import type { ConnectionRecord } from '../../domain/entities.js' +import type { BraidIntent, UiDispatchResult } from '../../views/shared/intents.js' + +export interface ProfileConnectionDispatchOptions { + readonly profiles?: readonly SourceProfileRecord[] + readonly discovery?: ProfileDiscoveryInput + readonly provider?: ProfileProvider + readonly securityPolicy?: AgentProfileSecurityPolicy + readonly acceptedProviderWarningCodes?: readonly string[] + readonly connections?: readonly ConnectionRecord[] + readonly adapters?: ReadonlyMap + readonly adapterFor?: (record: ConnectionRecord) => ProductionConnectionAdapter | undefined + readonly probeFor?: ConnectionProbeFactory + readonly productionConnection?: ProductionConnectionOptions + readonly now?: () => string + readonly onProfileSavePhase?: ProfileActionOptions['onSavePhase'] + readonly services?: ProfileConnectionDispatchServices +} + +export interface ProfileConnectionDispatchServices { + readonly profiles: ProfileActionService + readonly connections: ConnectionActionService + readonly revision: () => number +} + +export function createProfileConnectionDispatchServices( + app: BraidApplication, + options: ProfileConnectionDispatchOptions = {}, +): ProfileConnectionDispatchServices { + if (options.services !== undefined) return options.services + const host = { + state: () => app.state(), + configuration: app.configuration, + runtime: app.runtimeSelection, + } + const probeFor: ConnectionProbeFactory | undefined = + options.probeFor ?? + ((record) => + options.adapterFor?.(record) ?? + options.adapters?.get(record.id) ?? + (options.productionConnection === undefined + ? undefined + : createLazyProductionConnectionAdapter(record, options.productionConnection))) + return { + profiles: new ProfileActionService({ + host, + ...(options.profiles === undefined ? {} : { profiles: options.profiles }), + ...(options.discovery === undefined ? {} : { discovery: options.discovery }), + ...(options.provider === undefined ? {} : { provider: options.provider }), + ...(options.securityPolicy === undefined ? {} : { securityPolicy: options.securityPolicy }), + ...(options.acceptedProviderWarningCodes === undefined + ? {} + : { acceptedProviderWarningCodes: options.acceptedProviderWarningCodes }), + ...(options.now === undefined ? {} : { now: options.now }), + ...(options.onProfileSavePhase === undefined + ? {} + : { onSavePhase: options.onProfileSavePhase }), + }), + connections: new ConnectionActionService({ + host, + ...(options.connections === undefined ? {} : { connections: options.connections }), + ...(probeFor === undefined ? {} : { probeFor }), + ...(options.now === undefined ? {} : { now: options.now }), + }), + revision: () => app.state().revision, + } +} + +function createLazyProductionConnectionAdapter( + record: ConnectionRecord, + options: ProductionConnectionOptions, +): ProductionConnectionAdapter { + let loaded: Promise | undefined + const adapter = (): Promise => { + loaded ??= import('../../adapters/connections/production-connections.js').then( + ({ createProductionConnectionAdapter }) => createProductionConnectionAdapter(record, options), + ) + return loaded + } + return Object.freeze({ + record, + capabilities: async () => (await adapter()).capabilities(), + health: async (healthOptions?: ConnectionHealthOptions) => + (await adapter()).health(healthOptions), + verifyModel: async ( + model: string, + verificationOptions?: ConnectionModelVerificationOptions, + ) => { + const verify = (await adapter()).verifyModel + if (verify === undefined) + throw new Error('Production connection model verification is unavailable') + return verify(model, verificationOptions) + }, + }) +} + +export async function dispatchProfileConnectionIntent( + intent: BraidIntent, + services: ProfileConnectionDispatchServices, +): Promise { + if (intent.type === 'run-command') { + if (intent.command === 'profile') + return dispatchProfileCommand(intent, services.profiles, services.revision) + if (intent.command === 'connection') + return dispatchConnectionCommand(intent, services.connections, services.revision) + return undefined + } + if (intent.type !== 'headless-command') return undefined + switch (intent.command) { + case 'list_profiles': + return accepted( + await services.profiles.list(stringParam(intent.command, intent.params, 'query')), + services.revision(), + ) + case 'validate_profile': + return accepted( + await services.profiles.validate(requiredString(intent.command, intent.params, 'ref')), + services.revision(), + ) + case 'select_profile': + return accepted( + await services.profiles.select({ + operationId: requiredOperationId(intent), + ref: requiredString(intent.command, intent.params, 'ref'), + ...revisionParam(intent.command, intent.params), + }), + services.revision(), + intent.operationId, + ) + case 'save_profile': + return accepted( + await services.profiles.save({ + operationId: requiredOperationId(intent), + ref: requiredString(intent.command, intent.params, 'ref'), + profile: intent.params.profile, + ...revisionParam(intent.command, intent.params), + }), + services.revision(), + intent.operationId, + ) + case 'list_connections': + return accepted( + await services.connections.list(stringParam(intent.command, intent.params, 'query')), + services.revision(), + ) + case 'test_connection': + return accepted( + await services.connections.test({ + operationId: requiredOperationId(intent), + connectionId: requiredString(intent.command, intent.params, 'connectionId'), + }), + services.revision(), + intent.operationId, + ) + case 'select_connection': + return accepted( + await services.connections.select({ + operationId: requiredOperationId(intent), + connectionId: requiredString(intent.command, intent.params, 'connectionId'), + ...revisionParam(intent.command, intent.params), + }), + services.revision(), + intent.operationId, + ) + default: + return undefined + } +} + +async function dispatchProfileCommand( + intent: Extract, + service: ProfileActionService, + revision: () => number, +): Promise { + const [verb, ref] = intent.args + if (verb === undefined || verb === 'list') + return accepted(await service.list(ref ?? ''), revision()) + if (verb === 'validate') { + if (ref === undefined) throw new AppError('INVALID_PARAMS', '/profile validate requires a ref') + return accepted(await service.validate(ref), revision()) + } + if (verb === 'save') { + if (ref === undefined) throw new AppError('INVALID_PARAMS', '/profile save requires a ref') + if (intent.operationId === undefined) + throw new AppError('OPERATION_ID_REQUIRED', '/profile save requires operationId') + return accepted( + await service.saveCurrent({ operationId: intent.operationId, ref }), + revision(), + intent.operationId, + ) + } + if (intent.operationId === undefined) + throw new AppError('OPERATION_ID_REQUIRED', '/profile select requires operationId') + return accepted( + await service.select({ operationId: intent.operationId, ref: verb }), + revision(), + intent.operationId, + ) +} + +async function dispatchConnectionCommand( + intent: Extract, + service: ConnectionActionService, + revision: () => number, +): Promise { + const [verb, ref] = intent.args + if (verb === undefined || verb === 'list') + return accepted(await service.list(ref ?? ''), revision()) + if (verb === 'test') { + if (ref === undefined) throw new AppError('INVALID_PARAMS', '/connection test requires an id') + if (intent.operationId === undefined) + throw new AppError('OPERATION_ID_REQUIRED', '/connection test requires operationId') + return accepted( + await service.test({ operationId: intent.operationId, connectionId: ref }), + revision(), + intent.operationId, + ) + } + if (intent.operationId === undefined) + throw new AppError('OPERATION_ID_REQUIRED', '/connection select requires operationId') + return accepted( + await service.select({ operationId: intent.operationId, connectionId: ref ?? verb }), + revision(), + intent.operationId, + ) +} + +function accepted(data: unknown, revision: number, operationId?: string): UiDispatchResult { + return { + kind: 'accepted', + revision, + ...(operationId === undefined ? {} : { operationId }), + ...(data !== undefined ? { data } : {}), + ...operationFields(data), + } +} + +function operationFields(data: unknown): { readonly replayed?: boolean } { + if (data === null || typeof data !== 'object') return {} + const value = data as { readonly replayed?: unknown } + return 'replayed' in value && typeof value.replayed === 'boolean' + ? { replayed: value.replayed } + : {} +} + +function requiredOperationId( + intent: Extract, +): string { + if (intent.operationId === undefined) + throw new AppError('OPERATION_ID_REQUIRED', `${intent.command} requires operationId`) + return intent.operationId +} + +function requiredString( + command: string, + params: Readonly>, + name: string, +): string { + const value = params[name] + if (typeof value !== 'string' || value.length === 0) + throw new AppError('INVALID_PARAMS', `${command}.params.${name} must be a non-empty string`) + return value +} + +function stringParam( + command: string, + params: Readonly>, + name: string, +): string { + const value = params[name] + if (value === undefined) return '' + if (typeof value !== 'string') + throw new AppError('INVALID_PARAMS', `${command}.params.${name} must be a string`) + return value +} + +function revisionParam( + command: string, + params: Readonly>, +): { readonly expectedRevision?: number } { + const value = params.expectedRevision + if (value === undefined) return {} + if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) + throw new AppError( + 'INVALID_EXPECTED_REVISION', + `${command}.params.expectedRevision must be a non-negative integer`, + ) + return { expectedRevision: value } +} diff --git a/src/adapters/tui/ui-capabilities.ts b/src/adapters/tui/ui-capabilities.ts new file mode 100644 index 0000000..487ab0e --- /dev/null +++ b/src/adapters/tui/ui-capabilities.ts @@ -0,0 +1,182 @@ +import type { BraidState } from '../../domain/state.js' +import type { CapabilityMap } from '../../views/shared/models.js' +import type { UiFixture } from './ui-fixtures.js' + +export const UNSUPPORTED: Readonly> = Object.freeze({ + 'conversation.create': 'Conversation creation is not exposed by the current application core', + 'conversation.open': 'Conversation search is not exposed by the current application core', + 'run.runner': 'Runner overrides require the current profile compatibility helpers', + 'run.model': 'Model overrides require provider capabilities from the current connection', + 'run.effort': 'Effort overrides require provider capabilities from the current connection', + 'conversation.branch': 'Branch creation is not exposed by the current application core', + 'conversation.clone': 'Conversation cloning is not exposed by the current application core', + 'conversation.fork': 'Fork planning requires the current conversation and environment graph', + 'analysis.ask': 'Trace analysis requires the current agent-eval adapter', + 'analysis.recipe': 'Trace analysis recipes require the current agent-eval adapter', + 'analysis.compare': 'Comparisons require the current agent-eval adapter', + 'interaction.respond': 'Interaction response is not exposed by the current runtime adapter', + 'interaction.automation': 'Interaction automation requires the shared response contract', + 'export.create': 'Redacted export is not exposed by the current storage adapter', +}) + +export function capabilityMap( + state: BraidState, + canCancel = true, + fixture?: UiFixture, +): CapabilityMap { + const active = state.activeRunId !== null + const deterministicFixture = state.profile.model?.default === 'fixture/deterministic' + const capabilities: Record< + string, + { + available: boolean + source: 'provider' | 'runtime' | 'application' | 'local' + reason?: string + } + > = {} + for (const [key, reason] of Object.entries(UNSUPPORTED)) { + capabilities[key] = { available: false, source: 'application', reason } + } + const completedRuns = state.runs.filter( + (run) => run.complete && (run.status === 'completed' || run.status === 'failed'), + ) + const analysisReason = + state.workspace === null + ? 'Initialize a workspace before analyzing a run' + : completedRuns.length === 0 + ? 'Complete or fail a run before analyzing it' + : undefined + capabilities['analysis.ask'] = analysisReason + ? { available: false, source: 'application', reason: analysisReason } + : { available: true, source: 'application' } + capabilities['analysis.recipe'] = analysisReason + ? { available: false, source: 'application', reason: analysisReason } + : { available: true, source: 'application' } + capabilities['analysis.compare'] = + completedRuns.length < 2 + ? { + available: false, + source: 'application', + reason: 'Two completed or failed runs are required for comparison', + } + : { available: true, source: 'application' } + capabilities['application.quit'] = { available: true, source: 'local' } + capabilities['help.read'] = { available: true, source: 'local' } + capabilities['profile.select'] = { available: true, source: 'application' } + capabilities['connection.select'] = { available: true, source: 'application' } + capabilities['settings.open'] = { available: true, source: 'application' } + capabilities['activity.read'] = { available: true, source: 'local' } + capabilities['graph.read'] = { available: true, source: 'local' } + capabilities['details.read'] = { available: true, source: 'local' } + capabilities['draft.write'] = + state.workspace === null + ? { + available: false, + source: 'application', + reason: 'Initialize a workspace before editing a draft', + } + : { available: true, source: 'application' } + const conversationsAvailable = state.workspace !== null && !active + for (const capability of [ + 'conversation.create', + 'conversation.open', + 'conversation.branch', + 'conversation.clone', + 'conversation.fork', + 'export.create', + ]) { + capabilities[capability] = conversationsAvailable + ? { available: true, source: 'application' } + : { + available: false, + source: 'application', + reason: + state.workspace === null + ? 'Initialize a workspace first' + : 'Finish or cancel the active run first', + } + } + capabilities['run.send'] = + state.workspace !== null && !active && deterministicFixture + ? { available: true, source: 'provider' } + : { + available: false, + source: 'application', + reason: active + ? 'A run is already active' + : state.workspace === null + ? 'Initialize a workspace before sending' + : 'Configure a connection before sending', + } + capabilities['run.cancel'] = + active && canCancel + ? { available: true, source: 'runtime' } + : { + available: false, + source: 'runtime', + reason: active + ? 'The current runtime does not acknowledge provider cancellation' + : 'There is no active run to cancel', + } + const activeRun = state.activeRunId + ? state.runs.find((run) => run.id === state.activeRunId) + : undefined + capabilities['run.queue'] = activeRun?.capabilities.controls.queue + ? { available: true, source: 'provider' } + : { + available: false, + source: 'provider', + reason: active + ? 'The current runtime does not report queued input support' + : 'There is no active run', + } + capabilities['run.steer'] = activeRun?.capabilities.controls.steer + ? { available: true, source: 'provider' } + : { + available: false, + source: 'provider', + reason: active + ? 'The current runtime does not report steering support' + : 'There is no active run', + } + capabilities['run.detach'] = activeRun?.capabilities.streaming.detach + ? { available: true, source: 'provider' } + : { + available: false, + source: 'provider', + reason: active + ? 'The current runtime does not report detach support' + : 'There is no active run', + } + capabilities['run.reconnect'] = activeRun?.capabilities.streaming.replay + ? { available: true, source: 'provider' } + : { + available: false, + source: 'provider', + reason: active + ? 'The current runtime does not report replay support' + : 'There is no active run', + } + capabilities['run.reconcile'] = activeRun?.capabilities.controls.status + ? { available: true, source: 'provider' } + : { + available: false, + source: 'provider', + reason: active + ? 'The current runtime does not report status reconciliation' + : 'There is no active run', + } + if (fixture === 'interaction') { + capabilities['interaction.respond'] = { available: true, source: 'provider' } + } + if (fixture === 'fork') { + capabilities['conversation.fork'] = { available: true, source: 'provider' } + } + if (fixture === 'analysis') { + capabilities['analysis.ask'] = { available: true, source: 'application' } + } + if (fixture === 'comparison') { + capabilities['analysis.compare'] = { available: true, source: 'application' } + } + return Object.freeze(capabilities) +} diff --git a/src/adapters/tui/ui-command-dispatch.ts b/src/adapters/tui/ui-command-dispatch.ts new file mode 100644 index 0000000..58799f3 --- /dev/null +++ b/src/adapters/tui/ui-command-dispatch.ts @@ -0,0 +1,125 @@ +import { commandAvailability, isMutatingCommand } from '../../views/shared/command-registry.js' +import type { BraidIntent, UiDispatchResult } from '../../views/shared/intents.js' +import type { InteractionOutcome, InteractionView } from '../../views/shared/models.js' +import { dispatchConversationRunCommand } from './ui-conversation-dispatch.js' +import { dispatchCoreIntent } from './ui-core-dispatch.js' +import type { UiDispatchContext } from './ui-dispatch-context.js' + +type RunCommandIntent = Extract + +export async function dispatchCommandIntent( + intent: RunCommandIntent, + context: UiDispatchContext, +): Promise { + if (isMutatingCommand(intent.command) && !intent.operationId) { + return { + kind: 'error', + code: 'OPERATION_ID_REQUIRED', + message: `${intent.command} requires operationId`, + retryable: false, + } + } + if (intent.command === 'approve' || intent.command === 'reject') { + return dispatchInteractionCommand(intent, context) + } + if (intent.command === 'help') return { kind: 'accepted', revision: context.app.state().revision } + if (intent.command === 'quit') { + return dispatchCoreIntent({ type: 'shutdown', operationId: intent.operationId ?? '' }, context) + } + if (intent.command === 'cancel') { + return dispatchCoreIntent( + { type: 'cancel-run', operationId: intent.operationId ?? '' }, + context, + ) + } + if (intent.command === 'graph' || intent.command === 'activity') { + return dispatchCoreIntent({ type: 'open-surface', surface: intent.command }, context) + } + if (intent.command === 'fork' && context.fixture === 'fork') { + return dispatchCoreIntent({ type: 'open-surface', surface: 'fork' }, context) + } + const conversationResult = await dispatchConversationRunCommand(intent, context) + if (conversationResult !== undefined) return conversationResult + const availability = commandAvailability(intent.command, context.view().capabilities) + if (!availability.available) { + return { + kind: 'unavailable', + code: 'CAPABILITY_UNAVAILABLE', + reason: availability.reason ?? 'Capability is unavailable', + } + } + return { kind: 'accepted', revision: context.app.state().revision } +} + +async function dispatchInteractionCommand( + intent: Extract, + context: UiDispatchContext, +): Promise { + if (intent.command !== 'approve' && intent.command !== 'reject') { + return { + kind: 'error', + code: 'INVALID_INTENT', + message: `Unsupported interaction command ${intent.command}`, + retryable: false, + } + } + const interaction = selectInteraction(context.view().interactions, intent.args) + if (interaction === undefined) { + return { + kind: 'error', + code: 'NO_PENDING_INTERACTION', + message: `No pending interaction matches ${intent.args[0] ?? 'the current operation'}`, + retryable: false, + } + } + const outcome = interactionOutcome(interaction.allowedOutcomes, intent.command, intent.args) + if (outcome === undefined) { + return { + kind: 'unavailable', + code: 'CAPABILITY_UNAVAILABLE', + reason: `The pending interaction does not allow ${intent.command === 'approve' ? 'approval' : 'rejection'}`, + } + } + return dispatchCoreIntent( + { + type: 'respond-interaction', + operationId: intent.operationId ?? '', + runId: interaction.runId, + interactionId: interaction.interactionId, + response: { outcome }, + }, + context, + ) +} + +function selectInteraction( + interactions: readonly InteractionView[], + args: readonly string[], +): InteractionView | undefined { + const reference = args[0] + if (reference === undefined) return interactions[0] + const position = Number(reference) + if (Number.isInteger(position) && position > 0) return interactions[position - 1] + return ( + interactions.find( + (interaction) => interaction.interactionId === reference || interaction.runId === reference, + ) ?? interactions[0] + ) +} + +function interactionOutcome( + allowed: readonly InteractionOutcome[], + command: 'approve' | 'reject', + args: readonly string[], +): InteractionOutcome | undefined { + const candidates: readonly InteractionOutcome[] = + command === 'approve' + ? ['accept', 'once', 'session', 'persistent'] + : ['reject', 'deny', 'revise'] + const requested = args.find((arg) => candidates.includes(arg as InteractionOutcome)) + const preferred = requested ?? candidates[0] + return ( + allowed.find((outcome) => outcome === preferred) ?? + allowed.find((outcome) => candidates.includes(outcome)) + ) +} diff --git a/src/adapters/tui/ui-conversation-dispatch.ts b/src/adapters/tui/ui-conversation-dispatch.ts new file mode 100644 index 0000000..c25e29a --- /dev/null +++ b/src/adapters/tui/ui-conversation-dispatch.ts @@ -0,0 +1,352 @@ +import type { BraidApplication } from '../../app/application.js' +import type { ForkPlan } from '../../app/conversation-types.js' +import type { BraidIntent, UiDispatchResult } from '../../views/shared/intents.js' +import type { BraidViewModel, ForkPreviewView } from '../../views/shared/models.js' + +type RunCommandIntent = Extract +type HeadlessCommandIntent = Extract + +export interface ConversationDispatchContext { + readonly app: BraidApplication + readonly view: () => BraidViewModel + setNotice(notice: string): void + setForkPreview(preview: ForkPreviewView): void +} + +export async function dispatchConversationRunCommand( + intent: RunCommandIntent, + context: ConversationDispatchContext, +): Promise { + const operationId = requiredOperationId(intent.operationId, intent.command) + switch (intent.command) { + case 'new': { + const conversation = await context.app.conversations.lifecycle.create({ + operationId, + ...(intent.args.length === 0 ? {} : { title: intent.args.join(' ') }), + }) + return accepted(context, operationId, conversation, `Opened ${conversation.title}`) + } + case 'open': { + const query = intent.args.join(' ').trim() + if (!query) { + const conversations = context.app.conversations.lifecycle.list({ status: 'all' }) + return accepted(context, operationId, conversations, 'Choose a conversation') + } + const conversations = context.app.conversations.lifecycle.list({ + query, + status: 'all', + }) + const exact = conversations.find((conversation) => conversation.id === query) + const match = exact ?? (conversations.length === 1 ? conversations[0] : undefined) + if (!match) { + const notice = + conversations.length === 0 + ? `No conversation matches ${query}` + : `${conversations.length} conversations match; press Ctrl+O to choose` + return accepted(context, operationId, conversations, notice) + } + const conversation = await context.app.conversations.lifecycle.open({ + operationId, + conversationId: match.id, + }) + return accepted(context, operationId, conversation, `Opened ${conversation.title}`) + } + case 'branch': { + const branch = await context.app.conversations.branches.create({ + operationId, + ...(intent.args[0] === undefined ? {} : { throughMessageId: intent.args[0] }), + }) + return accepted(context, operationId, branch, 'Created branch') + } + case 'clone': { + const conversation = await context.app.conversations.branches.clone({ + operationId, + ...(intent.args.length === 0 ? {} : { title: intent.args.join(' ') }), + }) + return accepted(context, operationId, conversation, `Opened ${conversation.title}`) + } + case 'fork': { + const workspace = intent.args.includes('--workspace') + const throughMessageId = intent.args.find((argument) => argument !== '--workspace') + const plan = context.app.conversations.branches.plan({ + operationId, + kind: workspace ? 'workspace' : 'conversation', + ...(throughMessageId === undefined ? {} : { throughMessageId }), + }) + context.setForkPreview(forkPreview(plan)) + return accepted( + context, + operationId, + plan, + plan.allowed ? 'Review the fork before creating it' : (plan.reason ?? 'Fork unavailable'), + ) + } + case 'export': { + const format = exportFormat(intent.args[0]) + const result = await context.app.conversations.exports.export({ operationId, format }) + return accepted( + context, + operationId, + result, + `Prepared ${format.toUpperCase()} export (${result.bytes} bytes)`, + ) + } + case 'import': { + const result = await context.app.conversations.imports.import({ + operationId, + source: intent.args.join(' ').trim(), + }) + return accepted(context, operationId, result, 'Imported conversation safely offline') + } + default: + return undefined + } +} + +export async function dispatchConversationHeadlessCommand( + intent: HeadlessCommandIntent, + context: ConversationDispatchContext, +): Promise { + const params = intent.params + switch (intent.command) { + case 'new_conversation': { + const operationId = requiredOperationId(intent.operationId, intent.command) + const conversation = await context.app.conversations.lifecycle.create({ + operationId, + ...optionalString(params, 'title'), + ...renamedOptionalString(params, 'profileRef', 'profileId'), + ...optionalString(params, 'connectionId'), + }) + return accepted(context, operationId, conversation, `Opened ${conversation.title}`) + } + case 'list_conversations': { + const status = optionalStatus(params.status) + const conversations = context.app.conversations.lifecycle.list({ + ...optionalString(params, 'query'), + ...optionalString(params, 'workspace'), + ...(status === undefined ? {} : { status }), + }) + return accepted(context, undefined, conversations) + } + case 'open_conversation': { + const operationId = requiredOperationId(intent.operationId, intent.command) + const conversation = await context.app.conversations.lifecycle.open({ + operationId, + conversationId: requiredString(params, 'conversationId', intent.command), + ...optionalString(params, 'branchId'), + }) + return accepted(context, operationId, conversation, `Opened ${conversation.title}`) + } + case 'rename_conversation': { + const operationId = requiredOperationId(intent.operationId, intent.command) + const conversation = await context.app.conversations.lifecycle.rename({ + operationId, + conversationId: requiredString(params, 'conversationId', intent.command), + title: requiredString(params, 'title', intent.command), + }) + return accepted(context, operationId, conversation, `Renamed to ${conversation.title}`) + } + case 'archive_conversation': { + const operationId = requiredOperationId(intent.operationId, intent.command) + const archived = params.archived + if (typeof archived !== 'boolean') { + throw new Error('archive_conversation.params.archived must be a boolean') + } + const conversation = await context.app.conversations.lifecycle.archive({ + operationId, + conversationId: requiredString(params, 'conversationId', intent.command), + archived, + }) + return accepted( + context, + operationId, + conversation, + archived ? `Archived ${conversation.title}` : `Restored ${conversation.title}`, + ) + } + case 'delete_conversation': { + const operationId = requiredOperationId(intent.operationId, intent.command) + const conversation = await context.app.conversations.lifecycle.delete({ + operationId, + conversationId: requiredString(params, 'conversationId', intent.command), + }) + return accepted(context, operationId, conversation, 'Deleted conversation') + } + case 'set_draft': { + const operationId = requiredOperationId(intent.operationId, intent.command) + const result = await context.app.conversations.drafts.set({ + operationId, + text: requiredString(params, 'text', intent.command), + ...optionalString(params, 'conversationId'), + ...optionalString(params, 'branchId'), + }) + return accepted(context, operationId, result.draft) + } + case 'import_conversation': { + const operationId = requiredOperationId(intent.operationId, intent.command) + const result = await context.app.conversations.imports.import({ + operationId, + ...optionalString(params, 'content'), + ...optionalString(params, 'source'), + ...optionalString(params, 'title'), + }) + return accepted(context, operationId, result, 'Imported conversation safely offline') + } + case 'branch': { + const operationId = requiredOperationId(intent.operationId, intent.command) + const branch = await context.app.conversations.branches.create({ + operationId, + ...optionalString(params, 'conversationId'), + ...optionalString(params, 'branchId'), + ...renamedOptionalString(params, 'messageId', 'throughMessageId'), + ...optionalString(params, 'text'), + }) + return accepted(context, operationId, branch, 'Created branch') + } + case 'clone': { + const operationId = requiredOperationId(intent.operationId, intent.command) + const conversation = await context.app.conversations.branches.clone({ + operationId, + ...optionalString(params, 'conversationId'), + ...optionalString(params, 'branchId'), + ...optionalString(params, 'title'), + }) + return accepted(context, operationId, conversation, `Opened ${conversation.title}`) + } + case 'plan_fork': { + const operationId = requiredOperationId(intent.operationId, intent.command) + const plan = context.app.conversations.branches.plan(forkInput(params, operationId)) + context.setForkPreview(forkPreview(plan)) + return accepted(context, operationId, plan, plan.reason) + } + case 'execute_fork': { + const operationId = requiredOperationId(intent.operationId, intent.command) + const branch = await context.app.conversations.branches.execute({ + ...forkInput(params, operationId), + planDigest: requiredString(params, 'planDigest', intent.command), + }) + return accepted(context, operationId, branch, 'Created fork') + } + case 'export': { + const operationId = requiredOperationId(intent.operationId, intent.command) + const target = optionalStringValue(params.target) + const result = await context.app.conversations.exports.export({ + operationId, + ...(target === undefined || target === 'conversation' + ? {} + : { conversationId: target.replace(/^conversation:/u, '') }), + format: exportFormat(optionalStringValue(params.format)), + ...optionalString(params, 'destination'), + }) + return accepted( + context, + operationId, + result, + `Prepared ${result.format.toUpperCase()} export`, + ) + } + default: + return undefined + } +} + +function forkInput(params: Readonly>, operationId: string) { + const workspace = params.workspace === true || params.workspace === 'true' + return { + operationId, + kind: workspace ? ('workspace' as const) : ('conversation' as const), + ...optionalString(params, 'conversationId'), + ...optionalString(params, 'branchId'), + ...renamedOptionalString(params, 'messageId', 'throughMessageId'), + ...optionalString(params, 'runner'), + ...optionalString(params, 'model'), + ...optionalString(params, 'effort'), + } +} + +function forkPreview(plan: ForkPlan): ForkPreviewView { + return { + source: `${plan.sourceConversationId} / ${plan.sourceBranchId}`, + destination: `${plan.sourceConversationId} / ${plan.destinationBranchId}`, + kind: plan.kind, + fields: [ + { label: 'operation id', source: plan.operationId, destination: plan.operationId }, + { label: 'plan digest', source: plan.digest, destination: plan.digest }, + { + label: 'conversation context', + source: plan.context.sourceBoundary, + destination: `${plan.context.messages.length} messages`, + }, + { label: 'provider session', source: 'current', destination: 'new' }, + { label: 'workspace', source: 'current', destination: plan.environment }, + ], + allowed: plan.allowed, + ...(plan.reason === undefined ? {} : { unavailableReason: plan.reason }), + } +} + +function accepted( + context: ConversationDispatchContext, + operationId: string | undefined, + data: unknown, + notice?: string, +): UiDispatchResult { + if (notice !== undefined) context.setNotice(notice) + return { + kind: 'accepted', + revision: context.app.state().revision, + ...(operationId === undefined ? {} : { operationId }), + data, + ...(notice === undefined ? {} : { notice }), + } +} + +function requiredOperationId(value: string | undefined, command: string): string { + if (!value) throw new Error(`${command} requires operationId`) + return value +} + +function requiredString( + params: Readonly>, + key: string, + command: string, +): string { + const value = params[key] + if (typeof value !== 'string' || value.length === 0) { + throw new Error(`${command}.params.${key} must be a non-empty string`) + } + return value +} + +function optionalString( + params: Readonly>, + key: string, +): Readonly> { + const value = optionalStringValue(params[key]) + return value === undefined ? {} : { [key]: value } +} + +function renamedOptionalString( + params: Readonly>, + source: string, + destination: string, +): Readonly> { + const value = optionalStringValue(params[source]) + return value === undefined ? {} : { [destination]: value } +} + +function optionalStringValue(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined +} + +function optionalStatus(value: unknown): 'active' | 'archived' | 'all' | undefined { + if (value === undefined) return undefined + if (value === 'active' || value === 'archived' || value === 'all') return value + throw new Error('list_conversations.params.status must be active, archived, or all') +} + +function exportFormat(value: string | undefined): 'json' | 'markdown' { + if (value === undefined || value === 'json') return 'json' + if (value === 'markdown' || value === 'md') return 'markdown' + throw new Error('export format must be json or markdown') +} diff --git a/src/adapters/tui/ui-core-dispatch.ts b/src/adapters/tui/ui-core-dispatch.ts new file mode 100644 index 0000000..5566c43 --- /dev/null +++ b/src/adapters/tui/ui-core-dispatch.ts @@ -0,0 +1,230 @@ +import type { InteractionResponse } from '@tangle-network/agent-interface' +import type { + BraidIntent, + InteractionResponseValue, + UiDispatchResult, +} from '../../views/shared/intents.js' +import type { UiDispatchContext } from './ui-dispatch-context.js' +import { FIXTURE_INTERACTION } from './ui-fixtures.js' +import { projectInteractionReceipt } from './ui-interaction-receipt.js' + +export type CoreIntent = Exclude + +/** Routes operations that are not command names or headless catalog entries. */ +export async function dispatchCoreIntent( + intent: CoreIntent, + context: UiDispatchContext, +): Promise { + switch (intent.type) { + case 'send': { + const receipt = context.app.send({ + operationId: intent.operationId, + text: intent.text, + ...(intent.conversationId ? { conversationId: intent.conversationId } : {}), + ...(intent.branchId ? { branchId: intent.branchId } : {}), + }) + if (receipt.admissionReady !== undefined) await receipt.admissionReady + return { + kind: 'accepted', + operationId: receipt.operationId, + runId: receipt.runId, + revision: receipt.revision, + replayed: receipt.replayed, + admission: receipt.admission, + completion: receipt.completion.then(() => undefined), + } + } + 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 } : {}), + terminalStatus: 'aborted', + legacy: true, + }) + return { + kind: 'accepted', + operationId: receipt.operationId, + runId: receipt.runId, + control: 'cancel', + 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.notify() + return { kind: 'accepted', revision: context.app.state().revision } + case 'shutdown': { + if (!intent.operationId) { + return { + kind: 'error', + code: 'OPERATION_ID_REQUIRED', + message: 'shutdown requires operationId', + retryable: false, + } + } + const receipt = context.app.shutdown({ + operationId: intent.operationId, + ...(intent.mode === undefined ? {} : { mode: intent.mode }), + }) + return { + kind: 'accepted', + operationId: receipt.operationId, + revision: receipt.revision, + replayed: receipt.replayed, + completion: receipt.completion.then(() => undefined), + } + } + case 'resize': + return { kind: 'accepted', revision: context.app.state().revision } + case 'set-draft': { + const result = await context.app.conversations.drafts.set({ + operationId: intent.operationId, + text: intent.text, + ...(intent.conversationId === undefined ? {} : { conversationId: intent.conversationId }), + ...(intent.branchId === undefined ? {} : { branchId: intent.branchId }), + }) + return { + kind: 'accepted', + operationId: intent.operationId, + revision: context.app.state().revision, + replayed: result.replayed, + data: result.draft, + } + } + case 'respond-interaction': + return dispatchInteractionResponse(intent, context) + case 'queue': { + const receipt = context.app.queueInput({ + operationId: intent.operationId, + text: intent.text, + }) + if (receipt.completion !== undefined) await receipt.completion + return { + kind: 'accepted', + operationId: receipt.operationId, + runId: receipt.runId, + control: 'queue', + position: receipt.position, + revision: receipt.revision, + ...(receipt.completion === undefined + ? {} + : { completion: receipt.completion.then(() => undefined) }), + } + } + case 'steer': { + const receipt = await context.app.steer({ + operationId: intent.operationId, + text: intent.text, + }) + return { + kind: 'accepted', + operationId: receipt.operationId, + runId: receipt.runId, + control: 'steer', + outcome: receipt.acknowledgement.outcome, + revision: context.app.state().revision, + completion: receipt.completion.then(() => undefined), + } + } + default: { + const exhaustive: never = intent + return exhaustive + } + } +} + +async function dispatchInteractionResponse( + intent: Extract, + context: UiDispatchContext, +): Promise { + if ( + context.fixture === 'interaction' && + !context.interactionResolved() && + intent.runId === FIXTURE_INTERACTION.runId && + intent.interactionId === FIXTURE_INTERACTION.interactionId + ) { + context.markInteractionResolved() + context.notify() + return { + kind: 'accepted', + operationId: intent.operationId, + revision: context.app.state().revision, + completion: Promise.resolve(), + } + } + + const run = context.app.state().runs.find((candidate) => candidate.id === intent.runId) + const interaction = run?.interactions.find( + (candidate) => candidate.request.id === intent.interactionId, + ) + if (!interaction) { + return { + kind: 'error', + code: 'UNKNOWN_INTERACTION', + message: 'The interaction is no longer available', + retryable: false, + } + } + const response = responseForIntent(interaction.request, intent.interactionId, intent.response) + if (!response) { + return { + kind: 'unavailable', + code: 'CAPABILITY_UNAVAILABLE', + reason: 'This interaction only permits decline or cancel', + } + } + const receipt = await context.app.respondInteraction({ + operationId: intent.operationId, + runId: intent.runId, + interactionId: intent.interactionId, + response, + }) + return projectInteractionReceipt(receipt, () => context.app.state().revision) +} + +function responseForIntent( + request: import('@tangle-network/agent-interface').InteractionRequest, + interactionId: string, + value: InteractionResponseValue, +): InteractionResponse | undefined { + if (value.outcome === 'cancel') return { id: interactionId, outcome: 'cancelled' } + if (request.kind !== 'question' && request.kind !== 'permission') return undefined + if (value.outcome === 'deny' || value.outcome === 'reject' || value.outcome === 'revise') + return { id: interactionId, outcome: 'declined' } + if (!['accept', 'once', 'session', 'persistent'].includes(value.outcome)) return undefined + if (value.data !== undefined) { + const data: Record = {} + for (const [key, item] of Object.entries(value.data)) { + data[key] = Array.isArray(item) ? item.map(String) : (item as string | number | boolean) + } + return { id: interactionId, outcome: 'accepted', data } + } + const rawValue = 'value' in value ? value.value : undefined + const field = request.answerSpec.fields[0] + if (request.kind === 'permission') { + const grant = + value.outcome === 'once' + ? 'allow_once' + : value.outcome === 'session' + ? 'allow_session' + : value.outcome === 'persistent' + ? 'allow_always' + : typeof rawValue === 'string' && + ['allow_once', 'allow_session', 'allow_always'].includes(rawValue) + ? rawValue + : 'allow_once' + return { id: interactionId, outcome: 'accepted', data: { grant } } + } + if (!field || rawValue === undefined) return { id: interactionId, outcome: 'accepted' } + const fieldValue = field.type === 'select' ? [String(rawValue)] : rawValue + return { id: interactionId, outcome: 'accepted', data: { [field.name]: fieldValue } } +} diff --git a/src/adapters/tui/ui-dispatch-context.ts b/src/adapters/tui/ui-dispatch-context.ts new file mode 100644 index 0000000..aa4ba0a --- /dev/null +++ b/src/adapters/tui/ui-dispatch-context.ts @@ -0,0 +1,18 @@ +import type { BraidApplication } from '../../app/application.js' +import type { UiSubscriber } from '../../views/shared/intents.js' +import type { BraidViewModel } from '../../views/shared/models.js' +import type { ProfileConnectionDispatchServices } from './profile-connection-dispatch.js' + +export interface UiDispatchContext { + readonly app: BraidApplication + readonly profileConnections: ProfileConnectionDispatchServices + readonly fixture: import('./ui-fixtures.js').UiFixture | undefined + readonly subscribers: ReadonlySet + readonly view: () => BraidViewModel + readonly notify: () => void + readonly interactionResolved: () => boolean + markInteractionResolved(): void + setSelectedSurface(surface: BraidViewModel['selectedSurface']): void + setNotice(notice: string): void + setForkPreview(preview: NonNullable): void +} diff --git a/src/adapters/tui/ui-dispatch-error.ts b/src/adapters/tui/ui-dispatch-error.ts new file mode 100644 index 0000000..d40db0e --- /dev/null +++ b/src/adapters/tui/ui-dispatch-error.ts @@ -0,0 +1,21 @@ +import type { AppError } from '../../app/application.js' +import type { UiDispatchResult } from '../../views/shared/intents.js' +import { redactSensitiveText } from '../../views/shared/sanitize.js' + +export function errorResult(error: unknown): UiDispatchResult { + const typed = error as Partial + if (typeof typed.code === 'string' && typeof typed.message === 'string') { + return { + kind: 'error', + code: typed.code, + message: redactSensitiveText(typed.message), + retryable: false, + } + } + return { + kind: 'error', + code: 'INTERNAL_ERROR', + message: redactSensitiveText(error instanceof Error ? error.message : 'Internal error'), + retryable: false, + } +} diff --git a/src/adapters/tui/ui-dispatch.ts b/src/adapters/tui/ui-dispatch.ts new file mode 100644 index 0000000..5d616b2 --- /dev/null +++ b/src/adapters/tui/ui-dispatch.ts @@ -0,0 +1,39 @@ +import type { BraidIntent, UiDispatchResult } from '../../views/shared/intents.js' +import { + dispatchProfileConnectionIntent, + type ProfileConnectionDispatchServices, +} from './profile-connection-dispatch.js' +import { dispatchCommandIntent } from './ui-command-dispatch.js' +import { dispatchCoreIntent } from './ui-core-dispatch.js' +import type { UiDispatchContext } from './ui-dispatch-context.js' +import { errorResult } from './ui-dispatch-error.js' +import { dispatchHeadlessCommand } from './ui-headless-dispatch.js' +import { dispatchIntelligenceIntent } from './ui-intelligence-dispatch.js' + +export type { UiDispatchContext } from './ui-dispatch-context.js' +export { errorResult } from './ui-dispatch-error.js' +export type { ProfileConnectionDispatchServices } + +/** Coordinates the independent UI routing families without owning their operations. */ +export async function dispatchIntent( + intent: BraidIntent, + context: UiDispatchContext, +): Promise { + try { + const profileConnectionResult = await dispatchProfileConnectionIntent( + intent, + context.profileConnections, + ) + if (profileConnectionResult !== undefined) return profileConnectionResult + + const intelligenceResult = await dispatchIntelligenceIntent(intent, context) + if (intelligenceResult !== undefined) return intelligenceResult + + if (intent.type === 'run-command') return await dispatchCommandIntent(intent, context) + if (intent.type === 'headless-command') return await dispatchHeadlessCommand(intent, context) + + return await dispatchCoreIntent(intent, context) + } catch (error) { + return errorResult(error) + } +} diff --git a/src/adapters/tui/ui-fixtures.ts b/src/adapters/tui/ui-fixtures.ts new file mode 100644 index 0000000..b8cd61a --- /dev/null +++ b/src/adapters/tui/ui-fixtures.ts @@ -0,0 +1,251 @@ +import type { AnalysisComparisonResult } from '../../app/analysis-comparison-contracts.js' +import type { AnalysisRecord } from '../../domain/entities.js' +import { + createAnalysisId, + createBranchId, + createCitationId, + createConversationId, + createDigest, + createEventId, + createProfileId, + createRunId, +} from '../../domain/ids.js' +import type { ForkPreviewView, InteractionView } from '../../views/shared/models.js' + +export type UiFixture = 'interaction' | 'fork' | 'analysis' | 'comparison' + +export const FIXTURE_INTERACTION: InteractionView = Object.freeze({ + 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: Object.freeze({ + type: 'file', + title: 'src/app/application.ts', + target: 'read-only', + detail: 'The fixture requests a bounded read for a real interaction preview.', + preview: Object.freeze(['export class BraidApplication {', ' cancel(input: CancelInput) { …']), + trustedWorkspace: 'inside', + }), + answerSpec: Object.freeze({ kind: 'boolean', required: true }), + allowedOutcomes: Object.freeze(['accept', 'reject', 'cancel'] as const), + queuePosition: 0, + secret: false, +}) + +export const FIXTURE_FORK: ForkPreviewView = Object.freeze({ + kind: 'workspace', + source: 'workspace:/workspace', + destination: 'workspace:/workspace-fork', + fields: Object.freeze([ + { + 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, +}) + +const FIXTURE_ANALYSIS_RECORD: AnalysisRecord = Object.freeze({ + id: createAnalysisId('analysis-fixture-1'), + question: 'Where did this run waste time, and what should change?', + recipe: 'ask', + analystProfileId: createProfileId('profile-trace-analyst'), + status: 'completed', + source: Object.freeze({ + conversationId: createConversationId('conversation-fixture-analysis'), + branchId: createBranchId('branch-fixture-analysis'), + runId: createRunId('run-fixture-analysis'), + digest: createDigest('a'.repeat(64)), + complete: true, + }), + findings: Object.freeze([ + Object.freeze({ + id: 'finding-repeat-read', + text: 'The same file was read twice without an intervening change.', + severity: 'medium' as const, + confidence: 0.94, + supported: true, + citations: Object.freeze([ + Object.freeze({ + id: createCitationId('citation-repeat-read'), + eventId: createEventId('event-read-second'), + quote: 'read src/app/application.ts', + }), + ]), + }), + Object.freeze({ + id: 'finding-missing-proof', + text: 'The answer claimed success before the terminal test completed.', + severity: 'high' as const, + confidence: 0.88, + supported: true, + citations: Object.freeze([ + Object.freeze({ + id: createCitationId('citation-missing-proof'), + eventId: createEventId('event-test-start'), + quote: 'test started; no terminal result was recorded', + }), + ]), + }), + ]), + costUsd: 0.0048, + wallTimeMs: 1_240, + createdAt: '2026-08-04T04:00:00.000Z', + updatedAt: '2026-08-04T04:00:01.240Z', +}) + +export const FIXTURE_ANALYSIS_DATA = Object.freeze({ + status: 'completed' as const, + analysis: FIXTURE_ANALYSIS_RECORD, + source: Object.freeze({ + digest: FIXTURE_ANALYSIS_RECORD.source.digest, + conversationId: FIXTURE_ANALYSIS_RECORD.source.conversationId, + branchId: FIXTURE_ANALYSIS_RECORD.source.branchId, + runId: FIXTURE_ANALYSIS_RECORD.source.runId, + complete: true, + eventCount: 14, + messageCount: 4, + messagePartCount: 9, + }), +}) + +export const FIXTURE_COMPARISON_RESULT: AnalysisComparisonResult = Object.freeze({ + baselineSourceDigest: 'b'.repeat(64), + candidateSourceDigest: 'c'.repeat(64), + baselineRunId: 'run-fixture-baseline', + candidateRunId: 'run-fixture-candidate', + fields: Object.freeze([ + Object.freeze({ + name: 'run.status', + baseline: 'completed', + candidate: 'completed', + baselinePresent: true, + candidatePresent: true, + asymmetry: 'none' as const, + }), + Object.freeze({ + name: 'run.cost_usd', + baseline: 0.014, + candidate: 0.009, + baselinePresent: true, + candidatePresent: true, + asymmetry: 'none' as const, + }), + Object.freeze({ + name: 'run.wall_time_ms', + baseline: 18_200, + candidate: 12_100, + baselinePresent: true, + candidatePresent: true, + asymmetry: 'none' as const, + }), + Object.freeze({ + name: 'run.input_tokens', + baseline: 7_420, + candidate: 6_910, + baselinePresent: true, + candidatePresent: true, + asymmetry: 'none' as const, + }), + ]), + rows: Object.freeze([ + Object.freeze({ + pairKey: 'fixture-pair-1', + arm: 'baseline', + pass: true, + metrics: Object.freeze({ cost_usd: 0.014, latency_ms: 18_200 }), + }), + Object.freeze({ + pairKey: 'fixture-pair-1', + arm: 'candidate', + pass: true, + metrics: Object.freeze({ cost_usd: 0.009, latency_ms: 12_100 }), + }), + ]), + paired: Object.freeze({ + nPairs: 1, + nUnpairedBaseline: 0, + nUnpairedTreatment: 0, + correctness: Object.freeze({ + b10: 0, + b01: 0, + mcnemar: Object.freeze({ n: 1, nDiscordant: 0, b: 0, c: 0, statistic: 0, pValue: 1 }), + riskDifference: Object.freeze({ + n: 1, + b: 0, + c: 0, + riskDifference: 0, + lower: 0, + upper: 0, + confidence: 0.95, + }), + }), + metricDeltas: [ + Object.freeze({ + name: 'cost_usd', + n: 1, + nMissing: 0, + medianDelta: -0.005, + meanDelta: -0.005, + bootstrapCi: Object.freeze({ + n: 1, + median: -0.005, + mean: -0.005, + low: -0.005, + high: -0.005, + confidence: 0.95, + resamples: 2_000, + gateEligible: false, + }), + wilcoxon: Object.freeze({ w: 0, p: 1, method: 'exact' as const, pFloor: 1, nNonZero: 1 }), + }), + Object.freeze({ + name: 'latency_ms', + n: 1, + nMissing: 0, + medianDelta: -6_100, + meanDelta: -6_100, + bootstrapCi: Object.freeze({ + n: 1, + median: -6_100, + mean: -6_100, + low: -6_100, + high: -6_100, + confidence: 0.95, + resamples: 2_000, + gateEligible: false, + }), + wilcoxon: Object.freeze({ w: 0, p: 1, method: 'exact' as const, pFloor: 1, nNonZero: 1 }), + }), + ], + }), + semantic: Object.freeze({ + status: 'unavailable' as const, + reason: 'One saved pair is descriptive; semantic review was not requested.', + }), + replayed: true, +}) diff --git a/src/adapters/tui/ui-headless-dispatch.ts b/src/adapters/tui/ui-headless-dispatch.ts new file mode 100644 index 0000000..e858dfc --- /dev/null +++ b/src/adapters/tui/ui-headless-dispatch.ts @@ -0,0 +1,240 @@ +import type { InteractionResponse } from '@tangle-network/agent-interface' +import { + capabilityForHeadlessCommand, + isMutatingHeadlessCommand, +} from '../../views/shared/headless-commands.js' +import type { BraidIntent, UiDispatchResult } from '../../views/shared/intents.js' +import { queryActivity } from '../../views/shared/semantic-activity.js' +import { queryDetails } from '../../views/shared/semantic-details.js' +import { queryGraph } from '../../views/shared/semantic-graph.js' +import { SemanticQueryError } from '../../views/shared/semantic-query-scope.js' +import { dispatchConversationHeadlessCommand } from './ui-conversation-dispatch.js' +import { dispatchCoreIntent } from './ui-core-dispatch.js' +import type { UiDispatchContext } from './ui-dispatch-context.js' +import { projectInteractionReceipt } from './ui-interaction-receipt.js' + +type HeadlessCommandIntent = Extract + +export async function dispatchHeadlessCommand( + intent: HeadlessCommandIntent, + context: UiDispatchContext, +): Promise { + if (isMutatingHeadlessCommand(intent.command) && !intent.operationId) { + return { + kind: 'error', + code: 'OPERATION_ID_REQUIRED', + message: `${intent.command} requires operationId`, + retryable: false, + } + } + if (intent.command === 'get_graph') { + const conversationId = stringParam(intent.command, intent.params, 'conversationId') + const branchId = stringParam(intent.command, intent.params, 'branchId') + const query = stringParam(intent.command, intent.params, 'query') + const state = context.app.state() + return { + kind: 'accepted', + revision: state.revision, + data: queryGraph(state, { + ...(conversationId === undefined ? {} : { conversationId }), + ...(branchId === undefined ? {} : { branchId }), + ...(query === undefined ? {} : { query }), + }), + } + } + if (intent.command === 'get_activity') { + const conversationId = stringParam(intent.command, intent.params, 'conversationId') + const branchId = stringParam(intent.command, intent.params, 'branchId') + const runId = stringParam(intent.command, intent.params, 'runId') + const state = context.app.state() + return { + kind: 'accepted', + revision: state.revision, + data: queryActivity(state, { + ...(conversationId === undefined ? {} : { conversationId }), + ...(branchId === undefined ? {} : { branchId }), + ...(runId === undefined ? {} : { runId }), + }), + } + } + if (intent.command === 'get_details') { + const entityType = stringParam(intent.command, intent.params, 'entityType') + const entityId = stringParam(intent.command, intent.params, 'entityId') + if (entityType === undefined || entityId === undefined) { + throw new SemanticQueryError('INVALID_PARAMS', 'get_details requires entityType and entityId') + } + const state = context.app.state() + return { + kind: 'accepted', + revision: state.revision, + data: queryDetails(state, { entityType, entityId }), + } + } + + const conversationResult = await dispatchConversationHeadlessCommand(intent, context) + if (conversationResult !== undefined) return conversationResult + if (intent.command === 'cancel_run') { + if (!intent.operationId) { + return { + kind: 'error', + code: 'OPERATION_ID_REQUIRED', + message: 'cancel_run requires operationId', + retryable: false, + } + } + return dispatchCoreIntent( + { + type: 'cancel-run', + operationId: intent.operationId, + ...(typeof intent.params.runId === 'string' ? { runId: intent.params.runId } : {}), + }, + context, + ) + } + if (intent.command === 'queue' || intent.command === 'steer') { + const text = intent.params.text + if (typeof text !== 'string') { + return { + kind: 'error', + code: 'INVALID_PARAMS', + message: `${intent.command}.params.text must be a string`, + retryable: false, + } + } + return dispatchCoreIntent( + { type: intent.command, operationId: intent.operationId ?? '', text }, + context, + ) + } + if (intent.command === 'cancel') { + const receipt = await context.app.cancelRun({ + operationId: intent.operationId ?? '', + ...(typeof intent.params.runId === 'string' ? { runId: intent.params.runId } : {}), + ...(typeof intent.params.reason === 'string' ? { reason: intent.params.reason } : {}), + }) + return { + kind: 'accepted', + operationId: receipt.operationId, + runId: receipt.runId, + control: 'cancel', + outcome: receipt.acknowledgement.outcome, + revision: context.app.state().revision, + completion: receipt.completion.then(() => undefined), + } + } + if (intent.command === 'detach') { + const receipt = await context.app.detachRun({ + operationId: intent.operationId ?? '', + ...(typeof intent.params.runId === 'string' ? { runId: intent.params.runId } : {}), + }) + return { + kind: 'accepted', + operationId: receipt.operationId, + runId: receipt.runId, + control: 'detach', + outcome: receipt.acknowledgement.outcome, + revision: context.app.state().revision, + completion: receipt.completion.then(() => undefined), + } + } + if (intent.command === 'reconnect' || intent.command === 'reconcile') { + const runId = intent.params.runId + if (typeof runId !== 'string') { + return { + kind: 'error', + code: 'INVALID_PARAMS', + message: `${intent.command}.params.runId must be a string`, + retryable: false, + } + } + const state = + intent.command === 'reconnect' + ? await context.app.reconnectRun({ + operationId: intent.operationId ?? '', + runId, + }) + : await context.app.reconcileRun({ + operationId: intent.operationId ?? '', + runId, + }) + return { kind: 'accepted', revision: state.revision } + } + if (intent.command === 'respond_interaction') { + const runId = intent.params.runId + const interactionId = intent.params.interactionId + if (typeof runId !== 'string' || typeof interactionId !== 'string') { + return { + kind: 'error', + code: 'INVALID_PARAMS', + message: 'respond_interaction requires runId and interactionId', + retryable: false, + } + } + const receipt = await context.app.respondInteraction({ + operationId: intent.operationId ?? '', + runId, + interactionId, + response: intent.params.response as InteractionResponse, + }) + return projectInteractionReceipt(receipt, () => context.app.state().revision) + } + if (intent.command === 'send') { + const text = intent.params.text + if (typeof text !== 'string') { + return { + kind: 'error', + code: 'INVALID_PARAMS', + message: 'send.params.text must be a string', + retryable: false, + } + } + if (!intent.operationId) { + return { + kind: 'error', + code: 'OPERATION_ID_REQUIRED', + message: 'send requires operationId', + retryable: false, + } + } + return dispatchCoreIntent( + { + type: 'send', + operationId: intent.operationId, + text, + ...(typeof intent.params.conversationId === 'string' + ? { conversationId: intent.params.conversationId } + : {}), + ...(typeof intent.params.branchId === 'string' ? { branchId: intent.params.branchId } : {}), + }, + context, + ) + } + const capability = capabilityForHeadlessCommand(intent.command) + if (capability && !context.view().capabilities[capability]?.available) { + return { + kind: 'unavailable', + code: 'CAPABILITY_UNAVAILABLE', + reason: context.view().capabilities[capability]?.reason ?? 'Capability is unavailable', + } + } + return { + kind: 'unavailable', + code: 'CAPABILITY_UNAVAILABLE', + reason: isMutatingHeadlessCommand(intent.command) + ? 'The current application core does not implement this command' + : 'The current application core does not implement this query', + } +} + +function stringParam( + command: string, + params: Readonly>, + name: string, +): string | undefined { + const value = params[name] + if (value === undefined) return undefined + if (typeof value !== 'string') { + throw new SemanticQueryError('INVALID_PARAMS', `${command}.params.${name} must be a string`) + } + return value +} diff --git a/src/adapters/tui/ui-intelligence-dispatch.ts b/src/adapters/tui/ui-intelligence-dispatch.ts new file mode 100644 index 0000000..68a2735 --- /dev/null +++ b/src/adapters/tui/ui-intelligence-dispatch.ts @@ -0,0 +1,458 @@ +import type { AnalysisExecutionResult } from '../../app/analysis-service.js' +import type { AnalysisRequest, AnalysisSourceRequest } from '../../app/analysis-types.js' +import { AnalysisCapabilityError } from '../../app/analysis-types.js' +import type { BraidApplication } from '../../app/application.js' +import { AppError } from '../../app/errors.js' +import type { BraidState } from '../../domain/state.js' +import type { BraidIntent, UiDispatchResult } from '../../views/shared/intents.js' +import { redactSensitiveText } from '../../views/shared/sanitize.js' +import type { UiFixture } from './ui-fixtures.js' +import { resolveIntelligenceFixture } from './ui-intelligence-fixtures.js' + +interface IntelligenceDispatchContext { + readonly app: BraidApplication + readonly fixture: UiFixture | undefined + readonly notify: () => void + readonly setNotice: (notice: string) => void +} + +type AnalysisCommand = 'ask' | 'analyze' | 'compare' + +type AnalysisTerminal = + | { + readonly status: 'completed' + readonly analysis: AnalysisExecutionResult['analysis'] + readonly evidence: AnalysisExecutionResult['evidence'] + } + | { + readonly status: 'failed' + readonly analysis: AnalysisExecutionResult['analysis'] + readonly evidence: AnalysisExecutionResult['evidence'] + readonly error: Error + } + | { + readonly status: 'cancelled' + readonly analysis: AnalysisExecutionResult['analysis'] + readonly evidence: AnalysisExecutionResult['evidence'] + readonly reason?: string + } + +function requiresOperationId(intent: BraidIntent): boolean { + if (intent.type === 'run-command') { + return intent.command === 'ask' || intent.command === 'analyze' || intent.command === 'compare' + } + if (intent.type !== 'headless-command') return false + return ( + intent.command === 'ask' || + intent.command === 'analyze' || + intent.command === 'compare' || + intent.command === 'promote_analysis' || + intent.command === 'reconnect' || + intent.command === 'steer_worker' || + intent.command === 'cancel_worker' + ) +} + +function accepted(app: BraidApplication, data: unknown, operationId?: string): UiDispatchResult { + return { + kind: 'accepted', + revision: app.state().revision, + ...(operationId === undefined ? {} : { operationId }), + data, + } +} + +function unavailable(reason: string): UiDispatchResult { + return { + kind: 'unavailable', + code: 'CAPABILITY_UNAVAILABLE', + reason: redactSensitiveText(reason), + } +} + +function invalid(code: string, message: string): never { + throw new AppError(code, message) +} + +function latestAnalysisSource(state: BraidState): AnalysisSourceRequest { + const run = [...state.runs] + .reverse() + .find( + (candidate) => + candidate.branchId === state.branchId && + candidate.complete && + (candidate.status === 'completed' || candidate.status === 'failed'), + ) + if (run === undefined) { + invalid( + 'ANALYSIS_SOURCE_MISSING', + 'No completed or failed run is available on the selected branch', + ) + } + return { conversationId: run.conversationId, branchId: run.branchId, runId: run.id } +} + +function sourceRequest(state: BraidState, reference: string): AnalysisSourceRequest { + const value = reference.trim() + if (!value) invalid('ANALYSIS_SOURCE_INVALID', 'Analysis source must not be empty') + if (value === 'active' || value === 'last') return latestAnalysisSource(state) + + const runReference = value.startsWith('run:') ? value.slice('run:'.length) : value + const run = state.runs.find((candidate) => candidate.id === runReference) + if (run !== undefined) { + return { conversationId: run.conversationId, branchId: run.branchId, runId: run.id } + } + + const branchReference = value.startsWith('branch:') ? value.slice('branch:'.length) : value + const branch = state.branches.find((candidate) => candidate.id === branchReference) + if (branch !== undefined) { + return { conversationId: branch.conversationId, branchId: branch.id } + } + + invalid('ANALYSIS_SOURCE_UNKNOWN', `Analysis source '${value}' is not present in Braid state`) +} + +function issueReason(issue: { + readonly capability: string + readonly packageName: string + readonly packageVersion: string + readonly reason: string +}): string { + return `${issue.capability} unavailable in ${issue.packageName}@${issue.packageVersion}: ${issue.reason}` +} + +function analysisData(terminal: AnalysisTerminal) { + const data = { + status: terminal.status, + analysis: terminal.analysis, + source: { + digest: terminal.evidence.source.digest, + conversationId: terminal.evidence.source.conversationId, + branchId: terminal.evidence.source.branchId, + ...(terminal.evidence.source.runId === undefined + ? {} + : { runId: terminal.evidence.source.runId }), + complete: terminal.evidence.source.complete, + eventCount: terminal.evidence.events.length, + messageCount: terminal.evidence.messages.length, + messagePartCount: terminal.evidence.messageParts.length, + }, + } + if (terminal.status === 'failed') { + return { ...data, error: redactSensitiveText(terminal.error.message) } + } + if (terminal.status === 'cancelled' && terminal.reason !== undefined) { + return { ...data, reason: redactSensitiveText(terminal.reason) } + } + return data +} + +async function runAnalysis( + context: IntelligenceDispatchContext, + request: AnalysisRequest, + operationId?: string, +): Promise { + let terminal: AnalysisTerminal | undefined + for await (const progress of context.app.intelligence.analysis.stream(request)) { + context.notify() + if (progress.type === 'completed') { + terminal = { + status: 'completed', + analysis: progress.analysis, + evidence: progress.evidence, + } + } else if (progress.type === 'failed') { + terminal = { + status: 'failed', + analysis: progress.analysis, + evidence: progress.evidence, + error: progress.error, + } + } else if (progress.type === 'cancelled') { + terminal = { + status: 'cancelled', + analysis: progress.analysis, + evidence: progress.evidence, + ...(progress.reason === undefined ? {} : { reason: progress.reason }), + } + } + } + if (terminal === undefined) throw new Error('Analysis stream ended without a final result') + if (terminal.status === 'failed' && terminal.error instanceof AnalysisCapabilityError) { + return unavailable(issueReason(terminal.error.issue)) + } + const data = analysisData(terminal) + const notice = + terminal.status === 'completed' + ? `Analysis complete: ${terminal.analysis.findings.length} cited finding(s)` + : `Analysis ${terminal.status}: ${terminal.analysis.id}` + context.setNotice(notice) + return accepted(context.app, data, operationId) +} + +function analysisRequestForCommand( + state: BraidState, + command: AnalysisCommand, + args: readonly string[], +): { readonly request: AnalysisRequest } { + if (command === 'ask') { + const question = args.join(' ').trim() + if (!question) invalid('INVALID_PARAMS', '/ask requires a question') + return { request: { ...latestAnalysisSource(state), question, recipe: 'ask' } } + } + if (command === 'analyze') { + if (args.length !== 1 || !['failure', 'cost', 'tools', 'improvement'].includes(args[0] ?? '')) { + invalid('INVALID_PARAMS', '/analyze requires one of failure, cost, tools, or improvement') + } + const recipe = args[0] + if (recipe === undefined) invalid('INVALID_PARAMS', '/analyze requires a named recipe') + return { request: { ...latestAnalysisSource(state), recipe } } + } + invalid('INVALID_PARAMS', '/compare requires two source references') +} + +function rootRunForSupervisor( + state: BraidState, + supervisorId?: string, + runId?: string, +): string | undefined { + return ( + (supervisorId === undefined + ? undefined + : state.supervisors.find((candidate) => String(candidate.id) === supervisorId)?.rootRunId) ?? + runId ?? + state.supervisors.at(-1)?.rootRunId ?? + state.activeRunId ?? + state.runs.at(-1)?.id + ) +} + +function supervisorRoot( + state: BraidState, + supervisorId?: string, + runId?: string, +): { readonly rootDir: string; readonly rootRunId: string } | undefined { + if (state.workspace === null) return undefined + const rootRunId = rootRunForSupervisor(state, supervisorId, runId) + if (rootRunId === undefined) return undefined + return { rootDir: state.workspace, rootRunId } +} + +function supervisorData( + projection: Awaited>, +) { + return { + supervisors: projection.supervisors, + workers: projection.workers, + graphNodes: projection.graphNodes, + graphEdges: projection.graphEdges, + } +} + +async function dispatchSupervisorQuery( + command: 'snapshot' | 'reconnect', + context: IntelligenceDispatchContext, + supervisorId?: string, + runId?: string, +): Promise { + const root = supervisorRoot(context.app.state(), supervisorId, runId) + if (root === undefined) { + return unavailable('Supervisor snapshots require an initialized workspace and a known root run') + } + try { + const projection = + command === 'snapshot' + ? await context.app.intelligence.supervisor.snapshot(root) + : await context.app.intelligence.supervisor.reconnect(root) + if (projection.raw.supervisors.length === 0) { + return unavailable('The runtime returned no supervisor snapshot for this workspace') + } + context.notify() + return accepted(context.app, supervisorData(projection)) + } catch (error) { + return unavailable( + `Supervisor ${command} is unavailable: ${error instanceof Error ? error.message : String(error)}`, + ) + } +} + +async function dispatchSupervisorWorker( + command: 'steer_worker' | 'cancel_worker', + context: IntelligenceDispatchContext, + params: Readonly>, + operationId?: string, +): Promise { + const workerId = params.workerId + if (typeof workerId !== 'string') invalid('INVALID_PARAMS', `${command} requires workerId`) + if (command === 'cancel_worker') { + const result = await context.app.intelligence.supervisor.cancelWorker(workerId) + return unavailable(issueReason(result.issue)) + } + const supervisorId = params.supervisorId + const text = params.text + if (typeof supervisorId !== 'string' || typeof text !== 'string') { + invalid('INVALID_PARAMS', 'steer_worker requires supervisorId, workerId, and text') + } + const root = supervisorRoot(context.app.state(), supervisorId) + if (root === undefined) { + return unavailable('Worker steering requires an initialized workspace and a known supervisor') + } + try { + const result = await context.app.intelligence.supervisor.steerWorker( + root.rootDir, + supervisorId, + workerId, + text, + ) + if (result.status === 'unavailable') { + return unavailable( + result.issue === undefined ? 'Worker steering is unavailable' : issueReason(result.issue), + ) + } + return accepted(context.app, result, operationId) + } catch (error) { + return unavailable( + `Worker steering is unavailable: ${error instanceof Error ? error.message : String(error)}`, + ) + } +} + +export async function dispatchIntelligenceIntent( + intent: BraidIntent, + context: IntelligenceDispatchContext, +): Promise { + if (requiresOperationId(intent)) { + const command = + intent.type === 'run-command' || intent.type === 'headless-command' + ? intent.command + : 'intelligence action' + const operationId = + intent.type === 'run-command' || intent.type === 'headless-command' + ? intent.operationId + : undefined + if (operationId === undefined) + invalid('OPERATION_ID_REQUIRED', `${command} requires operationId`) + } + const fixture = resolveIntelligenceFixture(intent, context.fixture) + if (fixture !== undefined) { + context.setNotice(fixture.notice) + const operationId = intent.type === 'run-command' ? intent.operationId : undefined + return accepted(context.app, fixture.data, operationId) + } + if (intent.type === 'run-command') { + if (intent.command !== 'ask' && intent.command !== 'analyze' && intent.command !== 'compare') { + return undefined + } + const state = context.app.state() + if (intent.command === 'compare') { + if (intent.args.length !== 2) + invalid('INVALID_PARAMS', '/compare requires two source references') + const operationId = intent.operationId + if (operationId === undefined) + invalid('OPERATION_ID_REQUIRED', 'compare requires operationId') + const baseline = sourceRequest(state, intent.args[0] ?? '') + const candidate = sourceRequest(state, intent.args[1] ?? '') + const result = await context.app.intelligence.comparison.compareAndStore({ + operationId, + baseline, + candidate, + }) + context.setNotice(`Comparison complete: ${result.paired.nPairs} paired run(s)`) + return accepted(context.app, result, operationId) + } + const request = analysisRequestForCommand(state, intent.command, intent.args).request + return runAnalysis(context, request, intent.operationId) + } + + if (intent.type !== 'headless-command') return undefined + switch (intent.command) { + case 'ask': { + const source = intent.params.source + const question = intent.params.question + if (typeof source !== 'string' || typeof question !== 'string') { + invalid('INVALID_PARAMS', 'ask requires source and question') + } + return runAnalysis( + context, + { ...sourceRequest(context.app.state(), source), question, recipe: 'ask' }, + intent.operationId, + ) + } + case 'analyze': { + const source = intent.params.source + const recipe = intent.params.recipe + if (typeof source !== 'string' || typeof recipe !== 'string') { + invalid('INVALID_PARAMS', 'analyze requires source and recipe') + } + if (!['failure', 'cost', 'tools', 'improvement'].includes(recipe)) { + invalid('INVALID_PARAMS', `Unknown analysis recipe '${recipe}'`) + } + return runAnalysis( + context, + { ...sourceRequest(context.app.state(), source), recipe }, + intent.operationId, + ) + } + case 'compare': { + const left = intent.params.left + const right = intent.params.right + if (typeof left !== 'string' || typeof right !== 'string') { + invalid('INVALID_PARAMS', 'compare requires left and right sources') + } + const operationId = intent.operationId + if (operationId === undefined) + invalid('OPERATION_ID_REQUIRED', 'compare requires operationId') + const result = await context.app.intelligence.comparison.compareAndStore({ + operationId, + baseline: sourceRequest(context.app.state(), left), + candidate: sourceRequest(context.app.state(), right), + }) + return accepted(context.app, result, operationId) + } + case 'promote_analysis': { + const analysisId = intent.params.analysisId + const findingIds = intent.params.findingIds + if ( + typeof analysisId !== 'string' || + !Array.isArray(findingIds) || + !findingIds.every((id) => typeof id === 'string') + ) { + invalid('INVALID_PARAMS', 'promote_analysis requires analysisId and findingIds') + } + const analysis = context.app + .state() + .analyses.find((candidate) => String(candidate.id) === analysisId) + if (analysis === undefined) + invalid('UNKNOWN_ANALYSIS', `Analysis ${analysisId} is not present`) + const state = context.app.state() + const destinationConversationId = + typeof intent.params.conversationId === 'string' + ? intent.params.conversationId + : state.conversationId + const destinationBranchId = + typeof intent.params.branchId === 'string' ? intent.params.branchId : state.branchId + const attachment = await context.app.intelligence.promotion.promote({ + analysis, + selectedFindingIds: findingIds, + destinationConversationId, + destinationBranchId, + }) + return accepted(context.app, attachment, intent.operationId) + } + case 'reconnect': { + const runId = intent.params.runId + if ( + typeof runId === 'string' && + context.app.state().supervisors.some((supervisor) => String(supervisor.rootRunId) === runId) + ) { + return dispatchSupervisorQuery('reconnect', context, undefined, runId) + } + return undefined + } + case 'steer_worker': + case 'cancel_worker': + return dispatchSupervisorWorker(intent.command, context, intent.params, intent.operationId) + default: + return undefined + } +} diff --git a/src/adapters/tui/ui-intelligence-fixtures.ts b/src/adapters/tui/ui-intelligence-fixtures.ts new file mode 100644 index 0000000..336761d --- /dev/null +++ b/src/adapters/tui/ui-intelligence-fixtures.ts @@ -0,0 +1,28 @@ +import type { BraidIntent } from '../../views/shared/intents.js' +import { FIXTURE_ANALYSIS_DATA, FIXTURE_COMPARISON_RESULT, type UiFixture } from './ui-fixtures.js' + +interface IntelligenceFixtureResult { + readonly data: unknown + readonly notice: string +} + +/** Keeps screenshot-only results behind the explicit --ui-fixture option. */ +export function resolveIntelligenceFixture( + intent: BraidIntent, + fixture: UiFixture | undefined, +): IntelligenceFixtureResult | undefined { + if (intent.type !== 'run-command') return undefined + if (fixture === 'analysis' && intent.command === 'ask' && intent.args.join(' ').trim()) { + return { + data: structuredClone(FIXTURE_ANALYSIS_DATA), + notice: 'Analysis complete: 2 cited findings', + } + } + if (fixture === 'comparison' && intent.command === 'compare' && intent.args.length === 2) { + return { + data: structuredClone(FIXTURE_COMPARISON_RESULT), + notice: 'Comparison complete: 1 paired run', + } + } + return undefined +} diff --git a/src/adapters/tui/ui-interaction-receipt.ts b/src/adapters/tui/ui-interaction-receipt.ts new file mode 100644 index 0000000..98446c7 --- /dev/null +++ b/src/adapters/tui/ui-interaction-receipt.ts @@ -0,0 +1,37 @@ +import type { InteractionReceipt } from '../../app/application-types.js' +import type { UiDispatchResult } from '../../views/shared/intents.js' + +/** Turns a durable provider acknowledgement into an honest UI result. */ +export async function projectInteractionReceipt( + receipt: InteractionReceipt, + revision: () => number, +): Promise { + const outcome = receipt.acknowledgement.outcome + if (outcome === 'accepted' || outcome === 'already-applied') { + return { + kind: 'accepted', + operationId: receipt.operationId, + runId: receipt.runId, + control: 'respond_interaction', + outcome, + revision: revision(), + replayed: receipt.replayed, + completion: receipt.completion.then(() => undefined), + } + } + + await receipt.completion + if (outcome === 'rejected') { + return { + kind: 'error', + code: 'INTERACTION_RESPONSE_REJECTED', + message: 'The runner rejected this response; Braid did not mark it accepted', + retryable: false, + } + } + return { + kind: 'unavailable', + code: 'CAPABILITY_UNAVAILABLE', + reason: 'The current runtime could not confirm this response; Braid did not mark it accepted', + } +} diff --git a/src/adapters/tui/ui-projection.ts b/src/adapters/tui/ui-projection.ts new file mode 100644 index 0000000..b34287d --- /dev/null +++ b/src/adapters/tui/ui-projection.ts @@ -0,0 +1,409 @@ +import { messagesVisibleOnBranch } from '../../app/conversation-visibility.js' +import type { BraidEventEnvelope } from '../../domain/events.js' +import type { BraidState } from '../../domain/state.js' +import type { UiEvent } from '../../views/shared/intents.js' +import type { + ActivityItemView, + GraphNodeView, + HeadlessState, + InteractionView, + MessageView, + RunView, + TranscriptPartView, + ViewStatus, +} from '../../views/shared/models.js' +import { freezeView } from '../../views/shared/models.js' +import { + boundVisibleText, + redactStructuredValue, + sanitizeTerminalText, +} from '../../views/shared/sanitize.js' +import { queryActivity } from '../../views/shared/semantic-activity.js' +import { queryGraph } from '../../views/shared/semantic-graph.js' +import { projectSemanticEvent, semanticPart } from '../../views/shared/semantic-projection.js' +import { viewStatusForSemanticStatus } from '../../views/shared/semantic-query-types.js' + +export const MAX_VISIBLE_MESSAGES = 200 +export const MAX_VISIBLE_RUNS = 500 + +export function statusFor(state: BraidState): ViewStatus { + if (state.activeRunId) { + const active = state.runs.find((run) => run.id === state.activeRunId) + return active?.status === 'cancelling' ? 'cancelling' : 'running' + } + const status = state.runs.at(-1)?.status + if (!status) return state.messages.length === 0 ? 'empty' : 'ready' + switch (status) { + case 'completed': + return 'completed' + case 'failed': + return 'failed' + case 'aborted': + return 'cancelled' + case 'blocked': + return 'waiting' + case 'streaming': + return 'running' + case 'cancelling': + return 'cancelling' + case 'unknown': + return 'unknown' + default: + return 'unknown' + } +} + +function statusForRun(state: BraidState, run: BraidState['runs'][number]): ViewStatus { + if (state.activeRunId === run.id) return run.status === 'cancelling' ? 'cancelling' : 'running' + switch (run.status) { + case 'completed': + return 'completed' + case 'failed': + return 'failed' + case 'aborted': + return 'cancelled' + case 'blocked': + return 'waiting' + case 'streaming': + return 'running' + default: + return 'unknown' + } +} + +function partFor(message: BraidState['messages'][number]): TranscriptPartView[] { + const parts = message.parts.map(semanticPart) + if (parts.length > 0) return parts + const text = boundVisibleText(message.text) + return text + ? [ + { + id: `${message.id}:text`, + kind: 'text' as const, + text, + status: message.status === 'streaming' ? ('running' as const) : ('complete' as const), + }, + ] + : [] +} + +function messagesFor(state: BraidState): MessageView[] { + const branchExists = state.branches.some((branch) => branch.id === state.branchId) + const visible = branchExists ? messagesVisibleOnBranch(state, state.branchId) : state.messages + return visible.slice(-MAX_VISIBLE_MESSAGES).map((message) => + Object.freeze({ + id: message.id, + role: message.role, + text: sanitizeTerminalText(boundVisibleText(message.text)), + status: + message.status === 'aborted' + ? ('cancelled' as const) + : message.status === 'incomplete' + ? ('incomplete' as const) + : message.status, + ...(message.runId ? { runId: message.runId } : {}), + parts: Object.freeze(partFor(message)), + ...(message.partsTruncated ? { partsTruncated: true } : {}), + }), + ) +} + +export function runViews(state: BraidState): RunView[] { + return state.runs.slice(-MAX_VISIBLE_RUNS).map((run) => + Object.freeze({ + id: run.id, + turnId: run.turnId, + operationId: run.operationId, + status: statusForRun(state, run), + ...(run.error ? { error: sanitizeTerminalText(run.error) } : {}), + ...(run.lastCursor ? { cursor: sanitizeTerminalText(run.lastCursor) } : {}), + ...(run.providerSessionId + ? { providerSessionId: sanitizeTerminalText(run.providerSessionId) } + : {}), + ...(run.costUsd === undefined && run.model === undefined + ? {} + : { + usage: { + ...(run.costUsd === undefined ? {} : { costUsd: run.costUsd }), + ...(run.model === undefined ? {} : { model: run.model }), + }, + }), + completeness: completenessFor(state, run), + ...(run.contentBytes === undefined ? {} : { contentBytes: run.contentBytes }), + ...(run.contentTruncated ? { contentTruncated: true } : {}), + ...(run.activityTruncated ? { activityTruncated: true } : {}), + ...(run.eventDetailsTruncated ? { eventDetailsTruncated: true } : {}), + ...(run.interactionsTruncated ? { interactionsTruncated: true } : {}), + }), + ) +} + +function completenessFor( + state: BraidState, + run: BraidState['runs'][number], +): RunView['completeness'] { + if (run.status === 'unknown') return 'unknown' + if (state.missingHistory.some((range) => range.runId === run.id)) return 'missing-history' + if (run.status === 'failed') return 'failed' + if (!run.complete) return run.status === 'streaming' ? 'incomplete' : 'incomplete' + if (!run.capabilities.events.stableIdentity && !run.capabilities.streaming.replay) + return 'unavailable' + return 'complete' +} + +export function interactionViews(state: BraidState): InteractionView[] { + const views: InteractionView[] = [] + for (const run of state.runs) { + for (const item of run.interactions) { + if (item.status !== 'pending') continue + const request = item.request + const fields = Array.isArray(request.answerSpec?.fields) ? request.answerSpec.fields : [] + const answerSpec = answerSpecFor(fields, request.kind) + const knownKind = request.kind === 'question' || request.kind === 'permission' + const subject = request.subject + ? { + type: request.subject.type, + title: sanitizeTerminalText( + request.subject.type === 'tool' + ? request.subject.toolName + : request.subject.type === 'file' + ? request.subject.path + : request.subject.type === 'command' + ? request.subject.command + : request.subject.uri, + ), + } + : undefined + const requesterProfile = + run.receipt.requested.profile.name ?? state.profile.name ?? 'unnamed profile' + const requesterRunner = run.receipt.requested.runner ?? run.receipt.requested.profile.harness + views.push({ + runId: run.id, + interactionId: request.id, + profileName: sanitizeTerminalText(requesterProfile), + ...(requesterRunner === undefined ? {} : { runner: sanitizeTerminalText(requesterRunner) }), + kind: sanitizeTerminalText(request.kind), + prompt: sanitizeTerminalText(request.body ?? request.title), + ...(subject === undefined ? {} : { subject }), + answerSpec, + allowedOutcomes: knownKind ? ['accept', 'reject', 'cancel'] : ['deny', 'cancel'], + ...(request.timeoutMs === undefined ? {} : { remainingMs: request.timeoutMs }), + queuePosition: views.length, + secret: fields.some( + (field) => field.type === 'secret' || field.name.toLowerCase().includes('secret'), + ), + ...(run.providerSessionId === undefined ? {} : { providerSession: run.providerSessionId }), + }) + } + } + return views +} + +function answerSpecFor( + fields: ReturnType, + kind: string, +): NonNullable { + const field = fields.length === 1 ? fields[0] : undefined + if (fields.length > 1) { + return { + kind: 'form', + fields: fields.slice(0, 64).map((candidate) => ({ + name: sanitizeTerminalText(candidate.name), + label: sanitizeTerminalText(candidate.label), + type: candidate.type, + required: candidate.required ?? false, + ...(candidate.type === 'select' + ? { + options: candidate.options.slice(0, 128).map((option) => ({ + value: sanitizeTerminalText(option.value), + label: sanitizeTerminalText(option.label), + })), + } + : {}), + ...(candidate.type === 'number' && candidate.min !== undefined + ? { minimum: candidate.min } + : {}), + ...(candidate.type === 'number' && candidate.max !== undefined + ? { maximum: candidate.max } + : {}), + })), + } + } + if (!field) return { kind: 'unknown', label: `${kind} response`, safeToCancel: true } + switch (field.type) { + case 'text': + return { kind: 'text', required: field.required ?? false, secret: false } + case 'number': + return { + kind: 'number', + required: field.required ?? false, + ...(field.min === undefined ? {} : { minimum: field.min }), + ...(field.max === undefined ? {} : { maximum: field.max }), + } + case 'boolean': + return { + kind: 'boolean', + required: field.required ?? false, + ...(field.default === undefined ? {} : { defaultValue: field.default }), + } + case 'select': + return { + kind: 'select', + required: field.required ?? false, + options: field.options.slice(0, 128).map((option) => ({ + value: sanitizeTerminalText(option.value), + label: sanitizeTerminalText(option.label), + })), + } + case 'secret': + return { kind: 'secret', required: field.required ?? false } + default: + return { kind: 'unknown', label: `${kind} response`, safeToCancel: true } + } +} + +function fieldsOf(request: BraidState['runs'][number]['interactions'][number]['request']) { + return Array.isArray(request.answerSpec?.fields) ? request.answerSpec.fields : [] +} + +function queueViews(state: BraidState): readonly { + readonly operationId: string + readonly runId: string + readonly text: string + readonly position: number + readonly status: 'queued' | 'blocked' | 'unknown' +}[] { + return state.queuedInputs.map((entry) => ({ + operationId: entry.operationId, + runId: entry.runId, + text: sanitizeTerminalText(entry.text), + position: entry.position, + status: + state.runs.find((run) => run.id === entry.runId)?.status === 'unknown' + ? ('unknown' as const) + : state.missingHistory.some((range) => range.runId === entry.runId) + ? ('blocked' as const) + : ('queued' as const), + })) +} + +export function activityFor(state: BraidState): ActivityItemView[] { + return queryActivity(state) + .activity.slice(-MAX_VISIBLE_RUNS) + .map((item) => { + const run = + item.runId === undefined + ? undefined + : state.runs.find((candidate) => candidate.id === item.runId) + return { + id: item.id, + kind: item.kind, + title: item.title, + status: + run === undefined ? viewStatusForSemanticStatus(item.status) : statusForRun(state, run), + ...(item.detail === undefined ? {} : { detail: item.detail }), + ...(item.elapsedMs === undefined ? {} : { elapsedMs: item.elapsedMs }), + } + }) +} + +export function graphFor(state: BraidState): GraphNodeView[] { + const result = queryGraph(state) + const incoming = new Map() + for (const edge of result.edges) { + incoming.set(edge.destinationNodeId, edge.kind) + } + const canonicalIncoming = new Map(state.graphEdges.map((edge) => [edge.destination, edge.kind])) + return result.nodes.map((node) => { + const run = + node.type === 'run' ? state.runs.find((candidate) => candidate.id === node.id) : undefined + const canonicalNode = state.graphNodes.find( + (candidate) => candidate.reference.kind === node.type && candidate.reference.id === node.id, + ) + const edgeLabel = + (canonicalNode === undefined + ? result.edges.find( + (edge) => edge.destinationType === node.type && edge.destination === node.id, + )?.kind + : (canonicalIncoming.get(canonicalNode.id) ?? incoming.get(canonicalNode.id))) ?? undefined + return { + id: node.id, + type: node.type, + title: node.title, + status: run ? statusForRun(state, run) : viewStatusForSemanticStatus(node.status), + depth: node.depth, + ...(edgeLabel === undefined ? {} : { edgeLabel }), + } + }) +} + +export function toHeadlessState( + state: BraidState, + storageFailure?: string, + cleanupUncertain?: string, +): HeadlessState { + const messages = messagesFor(state) + return freezeView({ + schemaVersion: state.schemaVersion, + revision: state.revision, + sequence: state.sequence, + workspace: state.workspace ? sanitizeTerminalText(state.workspace) : null, + conversationId: state.conversationId, + branchId: state.branchId, + conversations: state.conversations + .filter((conversation) => conversation.deletedAt === undefined) + .map((conversation) => ({ + id: conversation.id, + title: sanitizeTerminalText(conversation.title), + branchId: conversation.activeBranchId, + archived: conversation.archived, + updatedAt: conversation.updatedAt, + })), + 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, + ...(run.costUsd === undefined ? {} : { costUsd: run.costUsd }), + ...(run.model === undefined ? {} : { model: sanitizeTerminalText(run.model) }), + ...(run.error === undefined ? {} : { error: sanitizeTerminalText(run.error) }), + completeness: completenessFor(state, run), + ...(run.providerSessionId === undefined ? {} : { providerSessionId: run.providerSessionId }), + ...(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 } : {}), + })), + interactions: interactionViews(state), + queue: queueViews(state), + activeRunId: state.activeRunId, + lastError: state.lastError ? sanitizeTerminalText(state.lastError) : null, + ...(storageFailure === undefined + ? {} + : { storageFailure: sanitizeTerminalText(storageFailure) }), + ...(cleanupUncertain === undefined + ? {} + : { cleanupUncertain: sanitizeTerminalText(cleanupUncertain) }), + }) +} + +export function toEvent(envelope: BraidEventEnvelope): UiEvent { + const payload = projectSemanticEvent(envelope) + return freezeView({ + sequence: envelope.sequence, + revision: envelope.revision, + kind: envelope.event.kind, + payload: Object.freeze(payload), + }) +} + +export { messagesFor, queueViews } diff --git a/src/adapters/tui/ui-view-model.ts b/src/adapters/tui/ui-view-model.ts new file mode 100644 index 0000000..1659dc7 --- /dev/null +++ b/src/adapters/tui/ui-view-model.ts @@ -0,0 +1,127 @@ +import type { AgentProfile } from '@tangle-network/agent-interface' +import { buildAppView } from '../../app/view-model.js' +import { canonicalDigest } from '../../domain/canonical.js' +import type { BraidState } from '../../domain/state.js' +import { type ColorMode, resolveColorMode } from '../../views/shared/appearance.js' +import { type BraidViewModel, freezeView } from '../../views/shared/models.js' +import { sanitizeTerminalText } from '../../views/shared/sanitize.js' +import { capabilityMap } from './ui-capabilities.js' +import { + activityFor, + graphFor, + interactionViews, + MAX_VISIBLE_MESSAGES, + messagesFor, + queueViews, + runViews, + statusFor, +} from './ui-projection.js' + +export interface UiAppearanceOptions { + readonly color?: ColorMode + readonly highContrast?: boolean + readonly reducedMotion?: boolean +} + +export function buildBraidViewModel( + state: BraidState, + selectedSurface: BraidViewModel['selectedSurface'] = 'transcript', + appearance: UiAppearanceOptions = {}, + canCancel = true, + storageFailure?: string, + cleanupUncertain?: string, +): BraidViewModel { + const legacy = buildAppView(state) + const status = storageFailure ? ('storage-failure' as const) : statusFor(state) + const latest = state.runs.at(-1) + const profile = state.profile as Readonly + const profileDigest = canonicalDigest(profile) + const selectedConversation = state.conversations.find( + (conversation) => conversation.id === state.conversationId, + ) + const statusText = storageFailure + ? `storage failure: ${sanitizeTerminalText(storageFailure)}` + : cleanupUncertain + ? `cleanup uncertain: ${sanitizeTerminalText(cleanupUncertain)}` + : state.lastError + ? sanitizeTerminalText(state.lastError) + : status === 'empty' + ? 'ready for a message' + : status === 'running' + ? 'streaming' + : status === 'completed' + ? 'completed' + : status === 'cancelled' + ? 'cancelled' + : status + const model = profile.model?.default ?? legacy.model + const color = + appearance.color === undefined ? ('truecolor' as const) : resolveColorMode(appearance.color) + return freezeView({ + revision: state.revision, + workspace: state.workspace ? sanitizeTerminalText(state.workspace) : null, + profileName: sanitizeTerminalText(legacy.profileName), + profileDigest, + runner: sanitizeTerminalText(legacy.runner), + model: sanitizeTerminalText(model), + ...(profile.model?.reasoningEffort + ? { effort: sanitizeTerminalText(profile.model.reasoningEffort) } + : {}), + connection: sanitizeTerminalText(legacy.connection), + conversationId: state.conversationId, + conversationTitle: sanitizeTerminalText(selectedConversation?.title ?? 'New conversation'), + conversations: Object.freeze( + state.conversations + .filter((conversation) => conversation.deletedAt === undefined) + .map((conversation) => ({ + id: conversation.id, + title: sanitizeTerminalText(conversation.title), + branchId: conversation.activeBranchId, + archived: conversation.archived, + active: conversation.id === state.conversationId, + updatedAt: conversation.updatedAt, + })), + ), + branch: sanitizeTerminalText(state.branchId), + status, + statusText, + queueCount: state.queuedInputs.length, + queue: Object.freeze(queueViews(state)), + ...(storageFailure === undefined + ? {} + : { storageFailure: sanitizeTerminalText(storageFailure) }), + ...(cleanupUncertain === undefined + ? {} + : { cleanupUncertain: sanitizeTerminalText(cleanupUncertain) }), + messages: Object.freeze(messagesFor(state)), + hiddenMessageCount: Math.max(0, state.messages.length - MAX_VISIBLE_MESSAGES), + runs: Object.freeze(runViews(state)), + ...(state.activeRunId ? { activeRunId: state.activeRunId } : {}), + interactions: Object.freeze(interactionViews(state)), + activity: Object.freeze(activityFor(state)), + graph: Object.freeze(graphFor(state)), + ...(latest + ? { + details: Object.freeze({ + title: `run ${latest.id}`, + fields: Object.freeze([ + { label: 'status', value: latest.status }, + { label: 'input tokens', value: String(latest.inputTokens) }, + { label: 'output tokens', value: String(latest.outputTokens) }, + ...(latest.model + ? [{ label: 'model', value: sanitizeTerminalText(latest.model) }] + : []), + ]), + }), + } + : {}), + capabilities: capabilityMap(state, canCancel), + draft: sanitizeTerminalText(state.draft), + selectedSurface, + appearance: Object.freeze({ + color, + highContrast: appearance.highContrast ?? false, + reducedMotion: appearance.reducedMotion ?? false, + }), + }) +} diff --git a/src/app/action-host.ts b/src/app/action-host.ts new file mode 100644 index 0000000..1dfa0e7 --- /dev/null +++ b/src/app/action-host.ts @@ -0,0 +1,9 @@ +import type { BraidState } from '../domain/state.js' +import type { ConfigurationActionTransition } from './configuration-action-transition.js' +import type { RuntimeSelection } from './runtime-selection.js' + +export interface ActionHost { + readonly state: () => BraidState + readonly configuration: ConfigurationActionTransition + readonly runtime?: RuntimeSelection +} diff --git a/src/app/analysis-comparison-contracts.ts b/src/app/analysis-comparison-contracts.ts new file mode 100644 index 0000000..c647a79 --- /dev/null +++ b/src/app/analysis-comparison-contracts.ts @@ -0,0 +1,50 @@ +import type { PairedArmRow, PairedArmsComparison } from '@tangle-network/agent-eval' +import type { + AnalysisComparisonField, + AnalysisComparisonSnapshot, + AnalysisRecord, +} from '../domain/entities.js' +import type { AnalysisSourceRequest, FrozenAnalysisEvidence } from './analysis-types.js' + +export interface AnalysisComparisonRequest { + readonly baseline: FrozenAnalysisEvidence + readonly candidate: FrozenAnalysisEvidence + readonly metricNames?: readonly string[] + readonly bootstrapSeed?: number +} + +export interface AnalysisComparisonResult { + readonly baselineSourceDigest: string + readonly candidateSourceDigest: string + readonly baselineRunId: string + readonly candidateRunId: string + readonly fields: readonly AnalysisComparisonField[] + readonly rows: readonly PairedArmRow[] + readonly paired: PairedArmsComparison + readonly semantic: { + readonly status: 'unavailable' + readonly reason: string + } + readonly replayed?: boolean +} + +export interface CompareAnalysisInput { + readonly operationId?: string + readonly baseline: AnalysisSourceRequest + readonly candidate: AnalysisSourceRequest + readonly metricNames?: readonly string[] + readonly bootstrapSeed?: number +} + +export interface PreparedComparisonRequest { + readonly baseline: FrozenAnalysisEvidence + readonly candidate: FrozenAnalysisEvidence + readonly identity: import('./analysis-operation.js').AnalysisIdentity +} + +export interface PersistedComparison { + readonly record: AnalysisRecord + readonly result: AnalysisComparisonResult +} + +export type { AnalysisComparisonSnapshot } diff --git a/src/app/analysis-comparison-evidence.ts b/src/app/analysis-comparison-evidence.ts new file mode 100644 index 0000000..b34bad5 --- /dev/null +++ b/src/app/analysis-comparison-evidence.ts @@ -0,0 +1,161 @@ +import type { + AnalysisCheck, + AnalysisComparisonField, + AnalysisComparisonSnapshot, + AnalysisSourceRange, +} from '../domain/entities.js' +import type { JsonValue } from '../domain/entities-base.js' +import type { AnalysisComparisonResult } from './analysis-comparison-contracts.js' +import type { FrozenAnalysisEvidence } from './analysis-types.js' + +function asJson(value: unknown): JsonValue | undefined { + if (value === undefined) return undefined + try { + return JSON.parse(JSON.stringify(value)) as JsonValue + } catch { + return null + } +} + +function latencyMs(evidence: FrozenAnalysisEvidence): number | undefined { + const run = evidence.run + if (run === undefined) return undefined + const started = Date.parse(run.startedAt) + const ended = Date.parse(run.terminalAt ?? run.updatedAt) + return Number.isFinite(started) && Number.isFinite(ended) + ? Math.max(0, ended - started) + : undefined +} + +function toolCount(evidence: FrozenAnalysisEvidence): number { + return evidence.events.filter((event) => event.event.kind === 'run.tool.call').length +} + +export function capturedFields( + baseline: FrozenAnalysisEvidence, + candidate: FrozenAnalysisEvidence, +): readonly AnalysisComparisonField[] { + const metadata = (evidence: FrozenAnalysisEvidence): Readonly> => { + const run = evidence.run + const receipt = run?.receipt + return { + 'source.digest': evidence.source.digest, + 'source.complete': evidence.source.complete, + 'source.missing_history': evidence.source.missingHistory, + 'source.trace.digest': evidence.source.trace?.digest, + 'source.event_count': evidence.events.length, + 'source.message_count': evidence.messages.length, + 'source.message_part_count': evidence.messageParts.length, + 'run.id': run?.id, + 'run.turn_id': run?.turnId, + 'run.conversation_id': run?.conversationId, + 'run.branch_id': run?.branchId, + 'run.operation_id': run?.operationId, + 'run.status': run?.status, + 'run.complete': run?.complete, + 'run.event_count': run?.eventCount, + 'run.last_provider_sequence': run?.lastProviderSequence, + 'run.input_tokens': run?.inputTokens, + 'run.output_tokens': run?.outputTokens, + 'run.reasoning_tokens': run?.reasoningTokens, + 'run.cost_usd': run?.costUsd, + 'run.wall_time_ms': latencyMs(evidence), + 'run.model': run?.model, + 'run.profile_snapshot_id': run?.profileSnapshotId, + 'run.connection_id': run?.connectionId, + 'run.provider_session_id': run?.providerSessionId, + 'run.environment_id': run?.environmentId, + 'run.binding_id': run?.bindingId, + 'run.receipt_id': run?.receiptId, + 'run.terminal_reason': run?.terminalReason, + 'run.last_cursor': run?.lastCursor, + 'run.missing_sequence': run?.missingSequence, + 'run.interaction_count': run?.interactions.length, + 'run.activity_count': run?.activity.length, + 'run.event_detail_count': run?.eventDetails.length, + 'run.content_bytes': run?.contentBytes, + 'run.content_truncated': run?.contentTruncated, + 'run.activity_truncated': run?.activityTruncated, + 'run.event_details_truncated': run?.eventDetailsTruncated, + 'run.interactions_truncated': run?.interactionsTruncated, + 'receipt.profile_digest': receipt?.profileDigest, + 'receipt.connection_id': receipt?.requested.connectionId, + 'receipt.requested': receipt?.requested, + 'receipt.capabilities_digest': receipt?.capabilitiesDigest, + 'receipt.provider': receipt?.provider, + 'receipt.environment_id': receipt?.environmentId, + 'receipt.provider_session_id': receipt?.providerSessionId, + tool_calls: toolCount(evidence), + } + } + const left = metadata(baseline) + const right = metadata(candidate) + const names = [...new Set([...Object.keys(left), ...Object.keys(right)])].sort() + return names.map((name) => { + const leftValue = left[name] + const rightValue = right[name] + const baselinePresent = leftValue !== undefined + const candidatePresent = rightValue !== undefined + const baseline = baselinePresent ? asJson(leftValue) : undefined + const candidate = candidatePresent ? asJson(rightValue) : undefined + return { + name, + ...(baseline === undefined ? {} : { baseline }), + ...(candidate === undefined ? {} : { candidate }), + baselinePresent, + candidatePresent, + asymmetry: + !baselinePresent && !candidatePresent + ? 'both-missing' + : !baselinePresent + ? 'candidate-only' + : !candidatePresent + ? 'baseline-only' + : 'none', + } + }) +} + +export function sourceRange(evidence: FrozenAnalysisEvidence): AnalysisSourceRange { + const sequences = evidence.events.map((event) => event.sequence) + const firstSequence = sequences.at(0) + const lastSequence = sequences.at(-1) + return { + eventIds: evidence.events.map((event) => event.id), + messageIds: evidence.messages.map((message) => message.id), + messagePartIds: evidence.messageParts.map((part) => part.id), + ...(firstSequence === undefined ? {} : { firstSequence }), + ...(lastSequence === undefined ? {} : { lastSequence }), + } +} + +export function comparisonSnapshot( + baseline: FrozenAnalysisEvidence, + candidate: FrozenAnalysisEvidence, + result: AnalysisComparisonResult, +): AnalysisComparisonSnapshot { + return { + baseline: baseline.source, + candidate: candidate.source, + fields: result.fields, + rows: result.rows.map((row) => asJson(row) ?? null), + paired: asJson(result.paired) ?? null, + semantic: result.semantic, + } +} + +export function comparisonChecks( + baseline: FrozenAnalysisEvidence, + candidate: FrozenAnalysisEvidence, +): readonly AnalysisCheck[] { + return [ + { id: 'baseline-frozen', status: 'passed', detail: String(baseline.source.digest) }, + { id: 'candidate-frozen', status: 'passed', detail: String(candidate.source.digest) }, + { + id: 'captured-fields', + status: 'passed', + detail: 'Missing values and asymmetries are retained.', + }, + { id: 'semantic-judge', status: 'unavailable', detail: 'No semantic judge was requested.' }, + ] +} diff --git a/src/app/analysis-comparison-facts.ts b/src/app/analysis-comparison-facts.ts new file mode 100644 index 0000000..4ae5c21 --- /dev/null +++ b/src/app/analysis-comparison-facts.ts @@ -0,0 +1,134 @@ +import { + type ComparePairedArmsOptions, + comparePairedArms, + type PairedArmRow, + type PairedArmsComparison, +} from '@tangle-network/agent-eval' +import { canonicalDigest } from '../domain/canonical.js' +import type { AnalysisRecord } from '../domain/entities.js' +import type { + AnalysisComparisonRequest, + AnalysisComparisonResult, +} from './analysis-comparison-contracts.js' +import { capturedFields } from './analysis-comparison-evidence.js' +import type { FrozenAnalysisEvidence } from './analysis-types.js' +import { AnalysisSourceError } from './analysis-types.js' + +function finiteMetric(value: number | undefined): number | undefined { + return value === undefined || !Number.isFinite(value) ? undefined : value +} + +function latencyMs(evidence: FrozenAnalysisEvidence): number | undefined { + const run = evidence.run + if (run === undefined) return undefined + const started = Date.parse(run.startedAt) + const ended = Date.parse(run.terminalAt ?? run.updatedAt) + return Number.isFinite(started) && Number.isFinite(ended) + ? Math.max(0, ended - started) + : undefined +} + +function toolCount(evidence: FrozenAnalysisEvidence): number { + return evidence.events.filter((event) => event.event.kind === 'run.tool.call').length +} + +function row( + evidence: FrozenAnalysisEvidence, + arm: 'baseline' | 'candidate', + pairKey: string, +): PairedArmRow { + const run = evidence.run + if (run === undefined || evidence.source.runId === undefined) { + throw new AnalysisSourceError('Paired comparison requires two frozen run sources') + } + const metrics: Record = { + event_count: run.eventCount, + tool_calls: toolCount(evidence), + } + const costUsd = finiteMetric(run.costUsd) + const latency = latencyMs(evidence) + const inputTokens = finiteMetric(run.inputTokens) + const outputTokens = finiteMetric(run.outputTokens) + const reasoningTokens = finiteMetric(run.reasoningTokens) + if (costUsd !== undefined) metrics.cost_usd = costUsd + if (latency !== undefined) metrics.latency_ms = latency + if (inputTokens !== undefined) metrics.input_tokens = inputTokens + if (outputTokens !== undefined) metrics.output_tokens = outputTokens + if (reasoningTokens !== undefined) metrics.reasoning_tokens = reasoningTokens + return { + pairKey, + arm, + pass: run.status === 'completed' && run.complete, + metrics, + } +} + +export function compareFrozenRuns(input: AnalysisComparisonRequest): AnalysisComparisonResult { + const baselineRunId = input.baseline.source.runId + const candidateRunId = input.candidate.source.runId + if (baselineRunId === undefined || candidateRunId === undefined) { + throw new AnalysisSourceError('Paired comparison requires two frozen run sources') + } + if (baselineRunId === candidateRunId) { + throw new AnalysisSourceError('Paired comparison requires two distinct runs') + } + const pairKey = canonicalDigest({ + baselineRunId, + candidateRunId, + baselineSourceDigest: input.baseline.source.digest, + candidateSourceDigest: input.candidate.source.digest, + }) + const rows = [ + row(input.baseline, 'baseline', pairKey), + row(input.candidate, 'candidate', pairKey), + ] + const options: ComparePairedArmsOptions = { + baselineArm: 'baseline', + treatmentArm: 'candidate', + ...(input.metricNames === undefined ? {} : { metricNames: [...input.metricNames] }), + ...(input.bootstrapSeed === undefined ? {} : { bootstrap: { seed: input.bootstrapSeed } }), + } + const paired = comparePairedArms(rows, options) + return { + baselineSourceDigest: String(input.baseline.source.digest), + candidateSourceDigest: String(input.candidate.source.digest), + baselineRunId: String(baselineRunId), + candidateRunId: String(candidateRunId), + fields: capturedFields(input.baseline, input.candidate), + rows, + paired, + semantic: { + status: 'unavailable', + reason: + 'The current Braid worker has deterministic paired facts only; a semantic judge must be supplied by agent-eval before any semantic conclusion is shown.', + }, + } +} + +export function comparisonIdentity(result: AnalysisComparisonResult): string { + return canonicalDigest({ + baselineSourceDigest: result.baselineSourceDigest, + candidateSourceDigest: result.candidateSourceDigest, + fields: result.fields, + rows: result.rows, + paired: result.paired, + }) +} + +export function resultFromComparisonRecord(record: AnalysisRecord): AnalysisComparisonResult { + if (record.comparison === undefined) { + throw new Error(`Comparison ${String(record.id)} is incomplete`) + } + const comparison = record.comparison + return { + baselineSourceDigest: String(comparison.baseline.digest), + candidateSourceDigest: String(comparison.candidate.digest), + baselineRunId: String(comparison.baseline.runId), + candidateRunId: String(comparison.candidate.runId), + fields: comparison.fields, + rows: comparison.rows as unknown as readonly PairedArmRow[], + paired: comparison.paired as unknown as PairedArmsComparison, + semantic: comparison.semantic, + replayed: true, + } +} diff --git a/src/app/analysis-comparison-graph.ts b/src/app/analysis-comparison-graph.ts new file mode 100644 index 0000000..b26004d --- /dev/null +++ b/src/app/analysis-comparison-graph.ts @@ -0,0 +1,59 @@ +import type { AnalysisRecord } from '../domain/entities.js' +import { graphEdge, graphNode } from '../domain/graph-records.js' +import { commitAnalysisEvent } from './analysis-persistence.js' +import type { AnalysisApplicationHost } from './analysis-types.js' + +export class AnalysisComparisonGraph { + readonly #host: AnalysisApplicationHost + + constructor(host: AnalysisApplicationHost) { + this.#host = host + } + + async project(record: AnalysisRecord): Promise { + if (record.comparison === undefined) throw new Error('Comparison has no frozen snapshot') + const comparisonReference = { kind: 'analysis' as const, id: record.id } + const baselineReference = record.comparison.baseline.runId + ? { kind: 'run' as const, id: record.comparison.baseline.runId } + : { kind: 'branch' as const, id: record.comparison.baseline.branchId } + const candidateReference = record.comparison.candidate.runId + ? { kind: 'run' as const, id: record.comparison.candidate.runId } + : { kind: 'branch' as const, id: record.comparison.candidate.branchId } + await commitAnalysisEvent(this.#host, { + kind: 'graph.node.upserted', + node: graphNode(comparisonReference, record.createdAt), + }) + await commitAnalysisEvent(this.#host, { + kind: 'graph.node.upserted', + node: graphNode(baselineReference, record.createdAt), + }) + await commitAnalysisEvent(this.#host, { + kind: 'graph.node.upserted', + node: graphNode(candidateReference, record.createdAt), + }) + const provenance = { + ...(record.operationId === undefined ? {} : { operationId: record.operationId }), + sourceDigest: record.source.digest, + } + await commitAnalysisEvent(this.#host, { + kind: 'graph.edge.upserted', + edge: graphEdge({ + kind: 'compared_left', + source: baselineReference, + destination: comparisonReference, + at: record.createdAt, + provenance, + }), + }) + await commitAnalysisEvent(this.#host, { + kind: 'graph.edge.upserted', + edge: graphEdge({ + kind: 'compared_right', + source: candidateReference, + destination: comparisonReference, + at: record.createdAt, + provenance: { ...provenance, sourceDigest: record.comparison.candidate.digest }, + }), + }) + } +} diff --git a/src/app/analysis-comparison-lifecycle.ts b/src/app/analysis-comparison-lifecycle.ts new file mode 100644 index 0000000..7f6cac9 --- /dev/null +++ b/src/app/analysis-comparison-lifecycle.ts @@ -0,0 +1,76 @@ +import type { AnalysisRecord } from '../domain/entities.js' +import { + type AnalysisIdentity, + type AnalysisOperationReservation, + operationResult, + reconcileAnalysisState, + reserveAnalysisOperation, + updateAnalysisOperation, +} from './analysis-operation.js' +import { commitAnalysisEvent } from './analysis-persistence.js' +import type { AnalysisApplicationHost } from './analysis-types.js' + +export class AnalysisComparisonLifecycle { + readonly #host: AnalysisApplicationHost + + constructor(host: AnalysisApplicationHost) { + this.#host = host + } + + async reconcile(): Promise { + for (const record of this.#host.currentState().analyses) { + if ( + record.kind !== 'comparison' || + record.comparison === undefined || + (record.status !== 'preparing' && record.status !== 'running') + ) { + continue + } + await this.complete({ ...record, status: 'completed', updatedAt: this.#host.now() }) + } + await reconcileAnalysisState(this.#host) + } + + existing(identity: AnalysisIdentity): AnalysisRecord | undefined { + const record = this.#host + .currentState() + .analyses.find((analysis) => analysis.id === identity.analysisId) + if (record === undefined) return undefined + if (record.requestDigest !== undefined && record.requestDigest !== identity.requestDigest) { + throw new Error(`Comparison ${String(record.id)} has a conflicting request digest`) + } + return record + } + + async reserve(identity: AnalysisIdentity): Promise { + return reserveAnalysisOperation(this.#host, { + identity, + kind: 'analysis', + target: identity.analysisId, + }) + } + + async create(record: AnalysisRecord): Promise { + await commitAnalysisEvent(this.#host, { kind: 'analysis.created', analysis: record }) + } + + async complete(record: AnalysisRecord): Promise { + await commitAnalysisEvent(this.#host, { kind: 'analysis.completed', analysis: record }) + } + + async finish(record: AnalysisRecord): Promise { + const operation = this.#host + .currentState() + .operations.find((candidate) => candidate.id === record.operationId) + if (operation === undefined || operation.status === 'terminal') return + await updateAnalysisOperation(this.#host, operation, { + status: 'terminal', + terminalOutcome: 'completed', + result: operationResult({ + analysisId: String(record.id), + sourceDigest: String(record.source.digest), + status: record.status, + }), + }) + } +} diff --git a/src/app/analysis-comparison-request.ts b/src/app/analysis-comparison-request.ts new file mode 100644 index 0000000..a199cb6 --- /dev/null +++ b/src/app/analysis-comparison-request.ts @@ -0,0 +1,49 @@ +import type { JsonValue } from '../domain/entities-base.js' +import type { + CompareAnalysisInput, + PreparedComparisonRequest, +} from './analysis-comparison-contracts.js' +import { analysisIdentity } from './analysis-operation.js' +import { freezeAnalysisSource } from './analysis-source.js' +import type { AnalysisApplicationHost } from './analysis-types.js' + +function persistedSourceRequest( + source: CompareAnalysisInput['baseline'], +): Readonly> { + return { + ...(source.conversationId === undefined ? {} : { conversationId: source.conversationId }), + ...(source.branchId === undefined ? {} : { branchId: source.branchId }), + ...(source.runId === undefined ? {} : { runId: source.runId }), + ...(source.throughMessageId === undefined ? {} : { throughMessageId: source.throughMessageId }), + } +} + +export function persistedComparisonRequest(input: CompareAnalysisInput): JsonValue { + return { + baseline: persistedSourceRequest(input.baseline), + candidate: persistedSourceRequest(input.candidate), + ...(input.metricNames === undefined ? {} : { metricNames: [...input.metricNames] }), + ...(input.bootstrapSeed === undefined ? {} : { bootstrapSeed: input.bootstrapSeed }), + } +} + +export function prepareComparisonRequest( + host: AnalysisApplicationHost, + input: CompareAnalysisInput, +): PreparedComparisonRequest { + const state = host.currentState() + const events = host.eventHistory() + const baseline = freezeAnalysisSource({ ...input.baseline, state, events }) + const candidate = freezeAnalysisSource({ ...input.candidate, state, events }) + const request = persistedComparisonRequest(input) + return { + baseline, + candidate, + identity: analysisIdentity({ + kind: 'comparison', + ...(input.operationId === undefined ? {} : { operationId: input.operationId }), + sourceDigests: [String(baseline.source.digest), String(candidate.source.digest)], + request, + }), + } +} diff --git a/src/app/analysis-comparison.ts b/src/app/analysis-comparison.ts new file mode 100644 index 0000000..36f34df --- /dev/null +++ b/src/app/analysis-comparison.ts @@ -0,0 +1,120 @@ +import type { AnalysisRecord } from '../domain/entities.js' +import type { + AnalysisComparisonResult, + CompareAnalysisInput, +} from './analysis-comparison-contracts.js' +import { + comparisonChecks, + comparisonSnapshot, + sourceRange, +} from './analysis-comparison-evidence.js' +import { AnalysisComparisonGraph } from './analysis-comparison-graph.js' +import { AnalysisComparisonLifecycle } from './analysis-comparison-lifecycle.js' +import { + persistedComparisonRequest, + prepareComparisonRequest, +} from './analysis-comparison-request.js' +import type { AnalysisApplicationHost } from './analysis-types.js' + +export class AnalysisComparisonService { + readonly #host: AnalysisApplicationHost + readonly #lifecycle: AnalysisComparisonLifecycle + readonly #graph: AnalysisComparisonGraph + #reconciled = false + + constructor(host: AnalysisApplicationHost) { + this.#host = host + this.#lifecycle = new AnalysisComparisonLifecycle(host) + this.#graph = new AnalysisComparisonGraph(host) + } + + async compare(input: CompareAnalysisInput): Promise { + const prepared = prepareComparisonRequest(this.#host, input) + const { compareFrozenRuns } = await import('./analysis-comparison-facts.js') + return compareFrozenRuns({ + baseline: prepared.baseline, + candidate: prepared.candidate, + ...(input.metricNames === undefined ? {} : { metricNames: input.metricNames }), + ...(input.bootstrapSeed === undefined ? {} : { bootstrapSeed: input.bootstrapSeed }), + }) + } + + async compareAndStore(input: CompareAnalysisInput): Promise { + const { compareFrozenRuns, resultFromComparisonRecord } = await import( + './analysis-comparison-facts.js' + ) + if (!this.#reconciled) { + await this.#lifecycle.reconcile() + this.#reconciled = true + } + const prepared = prepareComparisonRequest(this.#host, input) + const existing = this.#lifecycle.existing(prepared.identity) + if (existing !== undefined) { + if (existing.status !== 'completed') { + throw new Error(`Comparison ${String(existing.id)} is ${existing.status}`) + } + await this.#graph.project(existing) + await this.#lifecycle.finish(existing) + return resultFromComparisonRecord(existing) + } + const result = compareFrozenRuns({ + baseline: prepared.baseline, + candidate: prepared.candidate, + ...(input.metricNames === undefined ? {} : { metricNames: input.metricNames }), + ...(input.bootstrapSeed === undefined ? {} : { bootstrapSeed: input.bootstrapSeed }), + }) + const checks = comparisonChecks(prepared.baseline, prepared.candidate) + const record: AnalysisRecord = { + id: prepared.identity.analysisId, + analysisRunId: prepared.identity.analysisRunId, + kind: 'comparison', + operationId: prepared.identity.operationId, + requestDigest: prepared.identity.requestDigest, + request: persistedComparisonRequest(input), + source: prepared.baseline.source, + sourceRange: sourceRange(prepared.baseline), + status: 'preparing', + findings: [], + checks, + provenance: { + operationId: prepared.identity.operationId, + requestDigest: prepared.identity.requestDigest, + analystIds: [], + analystVersions: [], + tools: [], + completeness: + prepared.baseline.source.complete && prepared.candidate.source.complete + ? 'complete' + : 'incomplete', + checks, + }, + comparison: comparisonSnapshot(prepared.baseline, prepared.candidate, result), + createdAt: this.#host.now(), + updatedAt: this.#host.now(), + } + const operation = await this.#lifecycle.reserve(prepared.identity) + if (!operation.created) { + const replay = this.#lifecycle.existing(prepared.identity) + if (replay?.status === 'completed') return resultFromComparisonRecord(replay) + throw new Error( + `Comparison operation ${String(operation.operation.id)} is already active or unavailable`, + ) + } + await this.#lifecycle.create(record) + const complete: AnalysisRecord = { + ...record, + status: 'completed', + updatedAt: this.#host.now(), + } + await this.#lifecycle.complete(complete) + await this.#graph.project(complete) + await this.#lifecycle.finish(complete) + return result + } +} + +export type { + AnalysisComparisonRequest, + AnalysisComparisonResult, + CompareAnalysisInput, +} from './analysis-comparison-contracts.js' diff --git a/src/app/analysis-execution-session.ts b/src/app/analysis-execution-session.ts new file mode 100644 index 0000000..5b69fd6 --- /dev/null +++ b/src/app/analysis-execution-session.ts @@ -0,0 +1,76 @@ +import type { ExactAnalystRunEvent, ExactAnalystRunResult } from '@tangle-network/agent-eval' +import type { AnalystDescriptor, EvalAnalystRequest } from '../adapters/analysis/eval-analyst.js' +import type { AnalysisIdentity } from './analysis-operation.js' +import type { AnalysisRequest, FrozenAnalysisEvidence } from './analysis-types.js' + +export interface AnalysisExecutionEvent { + readonly event: ExactAnalystRunEvent + readonly result?: ExactAnalystRunResult +} + +export interface AnalysisAnalyst { + list(): ReadonlyArray + resolveAnalystIds(request: AnalysisRequest): readonly string[] + stream(request: EvalAnalystRequest): AsyncGenerator +} + +export class AnalysisExecutionSession { + readonly #analyst: AnalysisAnalyst + readonly #active = new Map() + readonly #cancelled = new Set() + + constructor(analyst: AnalysisAnalyst) { + this.#analyst = analyst + } + + listAnalysts() { + return this.#analyst.list() + } + + resolveAnalystIds(request: AnalysisRequest): readonly string[] { + return this.#analyst.resolveAnalystIds(request) + } + + cancel(analysisId: string, reason = 'cancelled by user'): boolean { + const controller = this.#active.get(analysisId) + if (controller === undefined) return false + this.#cancelled.add(analysisId) + controller.abort(reason) + return true + } + + wasCancelled(analysisId: string): boolean { + const cancelled = this.#cancelled.delete(analysisId) + return cancelled + } + + async *stream(input: { + readonly identity: AnalysisIdentity + readonly evidence: FrozenAnalysisEvidence + readonly request: AnalysisRequest + readonly analystIds: readonly string[] + }): AsyncGenerator { + const controller = new AbortController() + this.#active.set(String(input.identity.analysisId), controller) + const { buildAnalysisTraceStore } = await import('../adapters/analysis/trace-store.js') + const trace = buildAnalysisTraceStore(input.evidence) + const request: EvalAnalystRequest = { + runId: String(input.identity.analysisRunId), + sourceDigest: String(input.evidence.source.digest), + trace, + analystIds: input.analystIds, + ...(input.request.question === undefined ? {} : { question: input.request.question }), + ...(input.request.recipe === undefined ? {} : { recipe: input.request.recipe }), + ...(input.request.budgetUsd === undefined ? {} : { budgetUsd: input.request.budgetUsd }), + ...(input.request.totalTimeoutMs === undefined + ? {} + : { totalTimeoutMs: input.request.totalTimeoutMs }), + signal: controller.signal, + } + try { + for await (const item of this.#analyst.stream(request)) yield item + } finally { + this.#active.delete(String(input.identity.analysisId)) + } + } +} diff --git a/src/app/analysis-graph-projector.ts b/src/app/analysis-graph-projector.ts new file mode 100644 index 0000000..41db97b --- /dev/null +++ b/src/app/analysis-graph-projector.ts @@ -0,0 +1,68 @@ +import type { AnalysisAttachmentRecord, AnalysisRecord } from '../domain/entities.js' +import { graphEdge, graphNode } from '../domain/graph-records.js' +import { commitAnalysisEvent } from './analysis-persistence.js' +import type { AnalysisApplicationHost } from './analysis-types.js' + +export class AnalysisGraphProjector { + readonly #host: AnalysisApplicationHost + + constructor(host: AnalysisApplicationHost) { + this.#host = host + } + + async project(analysis: AnalysisRecord): Promise { + const sourceReference = + analysis.source.runId === undefined + ? { kind: 'branch' as const, id: analysis.source.branchId } + : { kind: 'run' as const, id: analysis.source.runId } + const analysisReference = { kind: 'analysis' as const, id: analysis.id } + const provenance = { + ...(analysis.operationId === undefined ? {} : { operationId: analysis.operationId }), + sourceDigest: analysis.source.digest, + } + await commitAnalysisEvent(this.#host, { + kind: 'graph.node.upserted', + node: graphNode(sourceReference, analysis.createdAt), + }) + await commitAnalysisEvent(this.#host, { + kind: 'graph.node.upserted', + node: graphNode(analysisReference, analysis.createdAt), + }) + await commitAnalysisEvent(this.#host, { + kind: 'graph.edge.upserted', + edge: graphEdge({ + kind: 'analyzed', + source: sourceReference, + destination: analysisReference, + at: analysis.createdAt, + provenance, + }), + }) + } + + async projectAttachment(attachment: AnalysisAttachmentRecord): Promise { + const analysisReference = { kind: 'analysis' as const, id: attachment.analysisId } + const branchReference = { kind: 'branch' as const, id: attachment.destinationBranchId } + await commitAnalysisEvent(this.#host, { + kind: 'graph.node.upserted', + node: graphNode(analysisReference, attachment.createdAt), + }) + await commitAnalysisEvent(this.#host, { + kind: 'graph.node.upserted', + node: graphNode(branchReference, attachment.createdAt), + }) + await commitAnalysisEvent(this.#host, { + kind: 'graph.edge.upserted', + edge: graphEdge({ + kind: 'attached', + source: branchReference, + destination: analysisReference, + at: attachment.createdAt, + provenance: { + operationId: attachment.operationId, + sourceDigest: attachment.sourceDigest, + }, + }), + }) + } +} diff --git a/src/app/analysis-lifecycle.ts b/src/app/analysis-lifecycle.ts new file mode 100644 index 0000000..2ced096 --- /dev/null +++ b/src/app/analysis-lifecycle.ts @@ -0,0 +1,131 @@ +import type { AnalysisRecord } from '../domain/entities.js' +import { + type AnalysisIdentity, + type AnalysisOperationReservation, + operationResult, + reconcileAnalysisState, + reserveAnalysisOperation, + updateAnalysisOperation, +} from './analysis-operation.js' +import { commitAnalysisEvent } from './analysis-persistence.js' +import type { + AnalysisApplicationHost, + AnalysisProgress, + FrozenAnalysisEvidence, +} from './analysis-types.js' + +export class AnalysisLifecycle { + readonly #host: AnalysisApplicationHost + + constructor(host: AnalysisApplicationHost) { + this.#host = host + } + + async reconcile(activeAnalysisIds: ReadonlySet): Promise { + await reconcileAnalysisState(this.#host, activeAnalysisIds) + } + + existing(identity: AnalysisIdentity): AnalysisRecord | undefined { + const record = this.#host + .currentState() + .analyses.find((analysis) => analysis.id === identity.analysisId) + if (record === undefined) return undefined + if (record.operationId !== undefined && record.operationId !== identity.operationId) { + throw new Error(`Analysis ${String(record.id)} has a conflicting operation identity`) + } + if (record.requestDigest !== undefined && record.requestDigest !== identity.requestDigest) { + throw new Error(`Analysis ${String(record.id)} has a conflicting request digest`) + } + return record + } + + async reserve(identity: AnalysisIdentity): Promise { + return reserveAnalysisOperation(this.#host, { + identity, + kind: 'analysis', + target: identity.analysisId, + }) + } + + async create(record: AnalysisRecord): Promise { + await commitAnalysisEvent(this.#host, { kind: 'analysis.created', analysis: record }) + } + + async running(record: AnalysisRecord): Promise { + const running: AnalysisRecord = { ...record, status: 'running', updatedAt: this.#host.now() } + await commitAnalysisEvent(this.#host, { kind: 'analysis.updated', analysis: running }) + return running + } + + async completed(record: AnalysisRecord): Promise { + await commitAnalysisEvent(this.#host, { kind: 'analysis.completed', analysis: record }) + } + + async failed(record: AnalysisRecord, error: Error, cancelled: boolean): Promise { + const failed: AnalysisRecord = { + ...record, + status: cancelled ? 'cancelled' : 'failed', + error: error.message, + updatedAt: this.#host.now(), + } + await commitAnalysisEvent(this.#host, { kind: 'analysis.updated', analysis: failed }) + const operation = this.#host + .currentState() + .operations.find((candidate) => candidate.id === record.operationId) + if (operation !== undefined) { + await updateAnalysisOperation(this.#host, operation, { + status: 'terminal', + 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, + }), + }) + } + return failed + } + + async finish(record: AnalysisRecord): Promise { + const operation = this.#host + .currentState() + .operations.find((candidate) => candidate.id === record.operationId) + if (operation === undefined || operation.status === 'terminal') return + await updateAnalysisOperation(this.#host, operation, { + status: 'terminal', + terminalOutcome: + record.status === 'completed' + ? 'completed' + : record.status === 'cancelled' + ? 'cancelled' + : 'failed', + result: operationResult({ + analysisId: String(record.id), + sourceDigest: String(record.source.digest), + status: record.status, + }), + }) + } + + async repairTerminal(record: AnalysisRecord): Promise { + await this.finish(record) + } + + replay(record: AnalysisRecord, evidence: FrozenAnalysisEvidence): AnalysisProgress { + if (record.status === 'completed') { + return { type: 'completed', analysis: record, evidence, result: undefined, replayed: true } + } + if (record.status === 'cancelled') { + return { type: 'cancelled', analysis: record, evidence, replayed: true } + } + return { + type: 'failed', + analysis: record, + evidence, + error: new Error(record.error ?? `Analysis ${String(record.id)} is ${record.status}`), + replayed: true, + } + } +} diff --git a/src/app/analysis-operation.ts b/src/app/analysis-operation.ts new file mode 100644 index 0000000..110af37 --- /dev/null +++ b/src/app/analysis-operation.ts @@ -0,0 +1,225 @@ +import { canonicalDigest } from '../domain/canonical.js' +import type { AnalysisRecord, OperationRecord } from '../domain/entities.js' +import type { AnalysisId, Digest, OperationId } from '../domain/ids.js' +import { + createAnalysisId, + createAnalysisRunId, + createOperationId, + isOperationId, +} from '../domain/ids-values.js' +import { commitAnalysisEvent } from './analysis-persistence.js' +import type { AnalysisApplicationHost } from './analysis-types.js' + +export type AnalysisOperationKind = 'analysis' | 'comparison' | 'promotion' + +export interface AnalysisIdentity { + readonly operationId: OperationId + readonly requestDigest: Digest + readonly analysisId: AnalysisId + readonly analysisRunId: ReturnType +} + +export interface AnalysisOperationReservation { + readonly operation: OperationRecord + readonly created: boolean +} + +export class AnalysisOperationError extends Error { + readonly code: 'ANALYSIS_OPERATION_CONFLICT' | 'ANALYSIS_OPERATION_UNAVAILABLE' + + constructor( + code: 'ANALYSIS_OPERATION_CONFLICT' | 'ANALYSIS_OPERATION_UNAVAILABLE', + message: string, + ) { + super(message) + this.name = 'AnalysisOperationError' + this.code = code + } +} + +function operationIdFor(raw: string | undefined, input: unknown): OperationId { + if (raw !== undefined && isOperationId(raw)) return createOperationId(raw) + return createOperationId(`operation-analysis-${canonicalDigest({ raw, input }).slice(0, 40)}`) +} + +export function analysisIdentity(input: { + readonly kind: AnalysisOperationKind + readonly operationId?: string + readonly sourceDigests: readonly string[] + readonly request: unknown +}): AnalysisIdentity { + const requestDigest = canonicalDigest({ + kind: input.kind, + sourceDigests: input.sourceDigests, + request: input.request, + }) + const operationId = operationIdFor(input.operationId, { + kind: input.kind, + sourceDigests: input.sourceDigests, + requestDigest, + }) + const identity = canonicalDigest({ + operationId, + sourceDigests: input.sourceDigests, + requestDigest, + }) + return { + operationId, + requestDigest, + analysisId: createAnalysisId(`analysis-${identity.slice(0, 40)}`), + analysisRunId: createAnalysisRunId(`analysis-run-${identity.slice(0, 40)}`), + } +} + +const reservationTails = new WeakMap>() + +export async function withAnalysisOperationLock( + host: AnalysisApplicationHost, + task: () => Promise, +): Promise { + const previous = reservationTails.get(host) ?? Promise.resolve() + let release!: () => void + const next = new Promise((resolve) => { + release = resolve + }) + reservationTails.set( + host, + previous.catch(() => undefined).then(() => next), + ) + await previous.catch(() => undefined) + try { + return await task() + } finally { + release() + } +} + +export async function reserveAnalysisOperation( + host: AnalysisApplicationHost, + input: { + readonly identity: AnalysisIdentity + readonly kind: 'analysis' | 'promote-analysis' + readonly target: AnalysisRecord['id'] + }, +): Promise { + return withAnalysisOperationLock(host, async () => { + const existing = host + .currentState() + .operations.find((operation) => operation.id === input.identity.operationId) + if (existing !== undefined) { + if (existing.requestDigest !== input.identity.requestDigest || existing.kind !== input.kind) { + throw new AnalysisOperationError( + 'ANALYSIS_OPERATION_CONFLICT', + `Operation ${String(input.identity.operationId)} was already reserved for a different request`, + ) + } + return { operation: existing, created: false } + } + const at = host.now() + const operation: OperationRecord = { + id: input.identity.operationId, + kind: input.kind, + requestDigest: input.identity.requestDigest, + status: 'pending', + target: { kind: 'analysis', id: input.target }, + createdAt: at, + updatedAt: at, + } + await commitAnalysisEvent(host, { kind: 'operation.requested', operation }) + return { operation, created: true } + }) +} + +export async function updateAnalysisOperation( + host: AnalysisApplicationHost, + operation: OperationRecord, + input: { + readonly status: OperationRecord['status'] + readonly at?: string + readonly result?: Readonly> + readonly failureCode?: string + readonly failureMessage?: string + readonly terminalOutcome?: OperationRecord['terminalOutcome'] + }, +): Promise { + const at = input.at ?? host.now() + const updated: OperationRecord = { + ...operation, + status: input.status, + ...(input.result === undefined ? {} : { result: input.result }), + ...(input.failureCode === undefined ? {} : { failureCode: input.failureCode }), + ...(input.failureMessage === undefined ? {} : { failureMessage: input.failureMessage }), + ...(input.terminalOutcome === undefined ? {} : { terminalOutcome: input.terminalOutcome }), + ...(input.status === 'acknowledged' || input.status === 'terminal' + ? { acknowledgedAt: at } + : {}), + updatedAt: at, + } + await commitAnalysisEvent(host, { kind: 'operation.updated', operation: updated }) + return updated +} + +export async function reconcileAnalysisState( + host: AnalysisApplicationHost, + activeAnalysisIds: ReadonlySet = new Set(), +): Promise { + await withAnalysisOperationLock(host, async () => { + const state = host.currentState() + const activeOperationIds = new Set( + [...activeAnalysisIds] + .map((id) => state.analyses.find((analysis) => String(analysis.id) === id)?.operationId) + .filter((id): id is OperationId => id !== undefined), + ) + for (const analysis of state.analyses) { + if ( + (analysis.status !== 'preparing' && analysis.status !== 'running') || + activeAnalysisIds.has(String(analysis.id)) + ) { + continue + } + const unknown: AnalysisRecord = { + ...analysis, + status: 'unknown', + error: + 'Analysis was interrupted before its durable terminal result; retry with a new operation ID.', + updatedAt: host.now(), + } + await commitAnalysisEvent(host, { kind: 'analysis.updated', analysis: unknown }) + if (analysis.operationId !== undefined) { + const operation = host + .currentState() + .operations.find((candidate) => candidate.id === analysis.operationId) + if (operation !== undefined && operation.status === 'pending') { + await updateAnalysisOperation(host, operation, { + status: 'unknown', + failureCode: 'ANALYSIS_RESTARTED', + ...(unknown.error === undefined ? {} : { failureMessage: unknown.error }), + terminalOutcome: 'unknown', + }) + } + } + } + for (const operation of host.currentState().operations) { + if ( + (operation.kind !== 'analysis' && operation.kind !== 'promote-analysis') || + operation.status !== 'pending' || + activeOperationIds.has(operation.id) + ) { + continue + } + await updateAnalysisOperation(host, operation, { + status: 'unknown', + failureCode: 'ANALYSIS_RESTARTED', + failureMessage: + 'The durable operation was pending when the application restarted and cannot be replayed safely.', + terminalOutcome: 'unknown', + }) + } + }) +} + +export function operationResult( + values: Readonly>, +): Readonly> { + return values +} diff --git a/src/app/analysis-persistence.ts b/src/app/analysis-persistence.ts new file mode 100644 index 0000000..7325c03 --- /dev/null +++ b/src/app/analysis-persistence.ts @@ -0,0 +1,22 @@ +import type { BraidEvent } from '../domain/events.js' +import type { AnalysisApplicationHost } from './analysis-types.js' +import { AnalysisPersistenceError } from './analysis-types.js' + +export async function commitAnalysisEvent( + host: AnalysisApplicationHost, + event: BraidEvent, +): Promise { + try { + if (host.commitAndWait !== undefined) { + await host.commitAndWait(event) + return + } + await host.commit(event) + } catch (error) { + if (error instanceof AnalysisPersistenceError) throw error + throw new AnalysisPersistenceError( + `Durable analysis event '${event.kind}' could not be committed; the operation is left for restart reconciliation.`, + error, + ) + } +} diff --git a/src/app/analysis-promotion.ts b/src/app/analysis-promotion.ts new file mode 100644 index 0000000..fb0be49 --- /dev/null +++ b/src/app/analysis-promotion.ts @@ -0,0 +1,221 @@ +import { validateAnalysisCitation } from '../adapters/analysis/citations.js' +import { canonicalDigest } from '../domain/canonical.js' +import type { + AnalysisAttachmentRecord, + AnalysisCitation, + AnalysisFinding, + AnalysisRecord, +} from '../domain/entities.js' +import type { BranchId, ConversationId } from '../domain/ids.js' +import { createAttachmentId } from '../domain/ids-values.js' +import { AnalysisGraphProjector } from './analysis-graph-projector.js' +import { + analysisIdentity, + operationResult, + reconcileAnalysisState, + reserveAnalysisOperation, + updateAnalysisOperation, +} from './analysis-operation.js' +import { commitAnalysisEvent } from './analysis-persistence.js' +import { freezeAnalysisSource } from './analysis-source.js' +import type { AnalysisApplicationHost } from './analysis-types.js' + +export type AnalysisPromotionAttachment = AnalysisAttachmentRecord + +export interface PromoteAnalysisInput { + readonly operationId?: string + readonly analysis: AnalysisRecord + readonly selectedFindingIds: readonly string[] + readonly destinationConversationId: ConversationId + readonly destinationBranchId: BranchId + readonly createdAt?: string +} + +function selectedFindings( + findings: readonly AnalysisFinding[], + ids: readonly string[], + evidence: Parameters[0], +): readonly { + readonly id: string + readonly text: string + readonly citations: readonly AnalysisCitation[] +}[] { + if (ids.length === 0) throw new Error('Analysis promotion requires at least one selected finding') + const unique = new Set(ids) + if (unique.size !== ids.length) { + throw new Error('Analysis promotion contains duplicate finding ids') + } + return ids.map((id) => { + const finding = findings.find((candidate) => candidate.id === id) + if (finding === undefined) throw new Error(`Analysis finding ${id} is not present`) + if (!finding.supported) throw new Error(`Analysis finding ${id} has no supported citations`) + for (const citation of finding.citations) validateAnalysisCitation(evidence, citation) + return { id: finding.id, text: finding.text, citations: finding.citations } + }) +} + +export class AnalysisPromotionService { + readonly #host: AnalysisApplicationHost + readonly #graph: AnalysisGraphProjector + #reconciled = false + + constructor(host: AnalysisApplicationHost) { + this.#host = host + this.#graph = new AnalysisGraphProjector(host) + } + + async promote(input: PromoteAnalysisInput): Promise { + if (!this.#reconciled) { + await reconcileAnalysisState(this.#host) + this.#reconciled = true + } + const state = this.#host.currentState() + const persisted = state.analyses.find((candidate) => candidate.id === input.analysis.id) + if (persisted === undefined) { + throw new Error(`Analysis ${String(input.analysis.id)} is not durably persisted`) + } + if (canonicalDigest(persisted) !== canonicalDigest(input.analysis)) { + throw new Error(`Analysis ${String(input.analysis.id)} does not match its persisted record`) + } + const analysis = persisted + const destinationBranch = state.branches.find( + (candidate) => candidate.id === input.destinationBranchId, + ) + if (destinationBranch === undefined) { + throw new Error(`Destination branch ${String(input.destinationBranchId)} does not exist`) + } + if (destinationBranch.conversationId !== input.destinationConversationId) { + throw new Error( + `Destination branch ${String(input.destinationBranchId)} does not belong to conversation ${String(input.destinationConversationId)}`, + ) + } + if (analysis.status !== 'completed') { + throw new Error(`Analysis ${String(analysis.id)} is not completed`) + } + if (!analysis.source.complete) { + throw new Error(`Analysis ${String(analysis.id)} has incomplete source history`) + } + const current = freezeAnalysisSource({ + conversationId: analysis.source.conversationId, + branchId: analysis.source.branchId, + ...(analysis.source.runId === undefined ? {} : { runId: analysis.source.runId }), + ...(analysis.source.throughMessageId === undefined + ? {} + : { throughMessageId: analysis.source.throughMessageId }), + state: this.#host.currentState(), + events: this.#host.eventHistory(), + }) + if (current.source.digest !== analysis.source.digest) { + throw new Error( + `Analysis source changed before promotion: expected ${String(analysis.source.digest)}, received ${String(current.source.digest)}`, + ) + } + const request = { + analysisId: analysis.id, + selectedFindingIds: input.selectedFindingIds, + destinationConversationId: input.destinationConversationId, + destinationBranchId: input.destinationBranchId, + } + const identity = analysisIdentity({ + kind: 'promotion', + ...(input.operationId === undefined ? {} : { operationId: input.operationId }), + sourceDigests: [String(analysis.source.digest)], + request, + }) + const reserved = this.#host + .currentState() + .operations.find((operation) => operation.id === identity.operationId) + if ( + reserved !== undefined && + (reserved.requestDigest !== identity.requestDigest || reserved.kind !== 'promote-analysis') + ) { + throw new Error( + `Operation ${String(identity.operationId)} was already reserved for a different promotion request`, + ) + } + const existing = this.#host + .currentState() + .analysisAttachments.find((attachment) => attachment.operationId === identity.operationId) + if (existing !== undefined) { + await this.#graph.projectAttachment(existing) + const operation = this.#host + .currentState() + .operations.find((candidate) => candidate.id === identity.operationId) + if (operation !== undefined && operation.status !== 'terminal') { + await updateAnalysisOperation(this.#host, operation, { + status: 'terminal', + terminalOutcome: 'completed', + result: operationResult({ + attachmentId: String(existing.id), + analysisId: String(existing.analysisId), + sourceDigest: String(existing.sourceDigest), + }), + }) + } + return existing + } + const selected = selectedFindings(analysis.findings, input.selectedFindingIds, current) + const createdAt = input.createdAt ?? this.#host.now() + const attachment: AnalysisAttachmentRecord = { + id: createAttachmentId( + `attachment-${canonicalDigest({ operationId: identity.operationId, request }).slice(0, 40)}`, + ), + operationId: identity.operationId, + analysisId: analysis.id, + ...(analysis.analysisRunId === undefined ? {} : { analysisRunId: analysis.analysisRunId }), + sourceConversationId: analysis.source.conversationId, + sourceBranchId: analysis.source.branchId, + ...(analysis.source.runId === undefined ? {} : { sourceRunId: analysis.source.runId }), + sourceDigest: analysis.source.digest, + destinationConversationId: input.destinationConversationId, + destinationBranchId: input.destinationBranchId, + selectedFindings: selected, + provenance: { + analysisId: analysis.id, + sourceDigest: analysis.source.digest, + ...(analysis.analystProfileDigest === undefined + ? {} + : { analystProfileDigest: analysis.analystProfileDigest }), + ...(analysis.provenance?.model === undefined ? {} : { model: analysis.provenance.model }), + ...(analysis.provenance?.runner === undefined + ? {} + : { runner: analysis.provenance.runner }), + ...(analysis.provenance?.agentEvalVersion === undefined + ? {} + : { agentEvalVersion: analysis.provenance.agentEvalVersion }), + }, + createdAt, + } + const reservation = await reserveAnalysisOperation(this.#host, { + identity, + kind: 'promote-analysis', + target: analysis.id, + }) + if (!reservation.created) { + const replay = this.#host + .currentState() + .analysisAttachments.find((candidate) => candidate.operationId === identity.operationId) + if (replay !== undefined) return replay + throw new Error( + `Promotion operation ${String(reservation.operation.id)} is already active or unavailable`, + ) + } + await commitAnalysisEvent(this.#host, { kind: 'analysis.attachment.created', attachment }) + await this.#graph.projectAttachment(attachment) + const currentOperation = this.#host + .currentState() + .operations.find((candidate) => candidate.id === identity.operationId) + if (currentOperation !== undefined) { + await updateAnalysisOperation(this.#host, currentOperation, { + status: 'terminal', + terminalOutcome: 'completed', + result: operationResult({ + attachmentId: String(attachment.id), + analysisId: String(attachment.analysisId), + sourceDigest: String(attachment.sourceDigest), + }), + }) + } + return attachment + } +} diff --git a/src/app/analysis-request.ts b/src/app/analysis-request.ts new file mode 100644 index 0000000..89846eb --- /dev/null +++ b/src/app/analysis-request.ts @@ -0,0 +1,44 @@ +import { type AnalysisIdentity, analysisIdentity } from './analysis-operation.js' +import { freezeAnalysisSource } from './analysis-source.js' +import type { + AnalysisApplicationHost, + AnalysisRequest, + FrozenAnalysisEvidence, +} from './analysis-types.js' + +export interface PreparedAnalysisRequest { + readonly evidence: FrozenAnalysisEvidence + readonly identity: AnalysisIdentity +} + +function requestForDigest(request: AnalysisRequest): Readonly> { + return { + question: request.question, + recipe: request.recipe ?? 'ask', + analystIds: request.analystIds, + analystProfileId: request.analystProfileId, + analystProfileDigest: request.analystProfileDigest, + budgetUsd: request.budgetUsd, + totalTimeoutMs: request.totalTimeoutMs, + } +} + +export function prepareAnalysisRequest( + host: AnalysisApplicationHost, + request: AnalysisRequest, +): PreparedAnalysisRequest { + const evidence = freezeAnalysisSource({ + ...request, + state: host.currentState(), + events: host.eventHistory(), + }) + return { + evidence, + identity: analysisIdentity({ + kind: 'analysis', + ...(request.operationId === undefined ? {} : { operationId: request.operationId }), + sourceDigests: [String(evidence.source.digest)], + request: requestForDigest(request), + }), + } +} diff --git a/src/app/analysis-result-mapper.ts b/src/app/analysis-result-mapper.ts new file mode 100644 index 0000000..355c87d --- /dev/null +++ b/src/app/analysis-result-mapper.ts @@ -0,0 +1,277 @@ +import type { AnalystFinding, ExactAnalystRunResult } from '@tangle-network/agent-eval' +import { AGENT_EVAL_VERSION } from '../adapters/analysis/agent-eval-version.js' +import { mapAnalystFinding } from '../adapters/analysis/citations.js' +import type { AnalystDescriptor } from '../adapters/analysis/eval-analyst.js' +import type { + AnalysisCheck, + AnalysisFinding, + AnalysisProvenance, + AnalysisRecord, + AnalysisSourceRange, + TurnUsage, +} from '../domain/entities.js' +import type { JsonValue } from '../domain/entities-base.js' +import type { AnalysisIdentity } from './analysis-operation.js' +import type { + AnalysisApplicationHost, + AnalysisRequest, + FrozenAnalysisEvidence, +} from './analysis-types.js' + +export function sourceRange(evidence: FrozenAnalysisEvidence): AnalysisSourceRange { + const sequences = evidence.events.map((event) => event.sequence) + const firstSequence = sequences.at(0) + const lastSequence = sequences.at(-1) + return { + eventIds: evidence.events.map((event) => event.id), + messageIds: evidence.messages.map((message) => message.id), + messagePartIds: evidence.messageParts.map((part) => part.id), + ...(firstSequence === undefined ? {} : { firstSequence }), + ...(lastSequence === undefined ? {} : { lastSequence }), + } +} + +function toolNames(evidence: FrozenAnalysisEvidence): readonly string[] { + const names = new Set() + for (const frozen of evidence.events) { + if (frozen.event.kind !== 'run.tool.call') continue + const value = frozen.event as unknown as Readonly> + const name = value.toolName ?? value.name + if (typeof name === 'string' && name.length > 0) names.add(name) + } + return [...names].sort() +} + +export function initialAnalysisChecks(evidence: FrozenAnalysisEvidence): readonly AnalysisCheck[] { + return [ + { + id: 'source-frozen', + status: 'passed', + detail: `digest ${String(evidence.source.digest)}`, + }, + { + id: 'source-completeness', + status: evidence.source.complete ? 'passed' : 'unavailable', + ...(evidence.source.complete + ? {} + : { detail: 'The frozen source contains a declared missing-history range.' }), + }, + ] +} + +export function persistedAnalysisRequest(request: AnalysisRequest): JsonValue { + return { + ...(request.conversationId === undefined ? {} : { conversationId: request.conversationId }), + ...(request.branchId === undefined ? {} : { branchId: request.branchId }), + ...(request.runId === undefined ? {} : { runId: request.runId }), + ...(request.throughMessageId === undefined + ? {} + : { throughMessageId: request.throughMessageId }), + ...(request.question === undefined ? {} : { question: request.question }), + recipe: request.recipe ?? 'ask', + ...(request.analystIds === undefined ? {} : { analystIds: [...request.analystIds] }), + ...(request.analystProfileId === undefined + ? {} + : { analystProfileId: request.analystProfileId }), + ...(request.analystProfileDigest === undefined + ? {} + : { analystProfileDigest: request.analystProfileDigest }), + ...(request.budgetUsd === undefined ? {} : { budgetUsd: request.budgetUsd }), + ...(request.totalTimeoutMs === undefined ? {} : { totalTimeoutMs: request.totalTimeoutMs }), + } +} + +function provenance( + host: AnalysisApplicationHost, + evidence: FrozenAnalysisEvidence, + request: AnalysisRequest, + identity: AnalysisIdentity, + analystIds: readonly string[], + analystVersions: readonly { readonly id: string; readonly version: string }[], + checks: readonly AnalysisCheck[], +): AnalysisProvenance { + const state = host.currentState() + const selectedConnectionId = state.selectedConnectionId + const model = state.profile.model?.default + const runner = state.profile.harness + return { + operationId: identity.operationId, + requestDigest: identity.requestDigest, + analystIds, + analystVersions, + agentEvalVersion: AGENT_EVAL_VERSION, + ...(request.analystProfileId === undefined ? {} : { profileId: request.analystProfileId }), + ...(request.analystProfileDigest === undefined + ? {} + : { profileDigest: request.analystProfileDigest }), + ...(model === undefined ? {} : { model }), + ...(runner === undefined ? {} : { runner }), + ...(selectedConnectionId === null ? {} : { connectionId: selectedConnectionId }), + tools: toolNames(evidence), + completeness: evidence.source.complete ? 'complete' : 'incomplete', + checks, + } +} + +export function initialAnalysisRecord(input: { + readonly host: AnalysisApplicationHost + readonly evidence: FrozenAnalysisEvidence + readonly request: AnalysisRequest + readonly identity: AnalysisIdentity + readonly at: string +}): AnalysisRecord { + const checks = initialAnalysisChecks(input.evidence) + return { + id: input.identity.analysisId, + analysisRunId: input.identity.analysisRunId, + kind: 'analysis', + operationId: input.identity.operationId, + requestDigest: input.identity.requestDigest, + source: input.evidence.source, + sourceRange: sourceRange(input.evidence), + request: persistedAnalysisRequest(input.request), + ...(input.request.question === undefined ? {} : { question: input.request.question }), + ...(input.request.recipe === undefined ? {} : { recipe: input.request.recipe }), + ...(input.request.analystProfileId === undefined + ? {} + : { analystProfileId: input.request.analystProfileId }), + ...(input.request.analystProfileDigest === undefined + ? {} + : { analystProfileDigest: input.request.analystProfileDigest }), + status: 'preparing', + findings: [], + checks, + provenance: provenance( + input.host, + input.evidence, + input.request, + input.identity, + input.request.analystIds ?? [], + [], + checks, + ), + createdAt: input.at, + updatedAt: input.at, + } +} + +function usageFromResult(result: ExactAnalystRunResult): TurnUsage | undefined { + let input = 0 + let output = 0 + let reasoning = 0 + let hasTokens = false + for (const summary of result.per_analyst) { + const tokens = summary.usage.tokens + if (tokens === null) continue + hasTokens = true + input += tokens.input + output += tokens.output + reasoning += tokens.reasoning ?? 0 + } + if (!hasTokens && result.total_cost_usd === 0) return undefined + return { + input, + output, + ...(reasoning === 0 ? {} : { reasoning }), + ...(Number.isFinite(result.total_cost_usd) && result.total_cost_usd >= 0 + ? { costUsd: result.total_cost_usd } + : {}), + } +} + +function wallTimeMs(result: ExactAnalystRunResult): number | undefined { + const started = Date.parse(result.started_at) + const ended = Date.parse(result.ended_at) + return Number.isFinite(started) && Number.isFinite(ended) + ? Math.max(0, ended - started) + : undefined +} + +function unsupportedFinding(finding: AnalystFinding, error: unknown): AnalysisFinding { + return { + id: finding.finding_id, + text: finding.claim, + ...(finding.severity === undefined ? {} : { severity: finding.severity }), + ...(finding.confidence === undefined ? {} : { confidence: finding.confidence }), + citations: [], + supported: false, + supportError: error instanceof Error ? error.message : String(error), + } +} + +async function mapFindings( + evidence: FrozenAnalysisEvidence, + findings: readonly AnalystFinding[], +): Promise<{ readonly findings: readonly AnalysisFinding[]; readonly supported: boolean }> { + const { buildAnalysisTraceStore } = await import('../adapters/analysis/trace-store.js') + const trace = buildAnalysisTraceStore(evidence) + let supported = true + const mapped = findings.map((finding) => { + try { + const result = mapAnalystFinding(evidence, trace, finding) + if (!result.supported) supported = false + return result.supported + ? result + : { ...result, supportError: 'Finding did not include a complete supported citation set.' } + } catch (error) { + supported = false + return unsupportedFinding(finding, error) + } + }) + return { findings: mapped, supported } +} + +export async function completedAnalysisRecord(input: { + readonly host: AnalysisApplicationHost + readonly base: AnalysisRecord + readonly evidence: FrozenAnalysisEvidence + readonly request: AnalysisRequest + readonly identity: AnalysisIdentity + readonly analystIds: readonly string[] + readonly descriptors: readonly AnalystDescriptor[] + readonly result: ExactAnalystRunResult + readonly at: string +}): Promise { + const mapped = await mapFindings(input.evidence, input.result.findings) + const checks: readonly AnalysisCheck[] = [ + ...initialAnalysisChecks(input.evidence), + { + id: 'citation-support', + status: mapped.supported ? 'passed' : 'failed', + ...(mapped.supported + ? {} + : { detail: 'One or more findings failed frozen-source citation checks.' }), + }, + { id: 'agent-eval-completion', status: 'passed' }, + ] + const descriptorVersions = new Map( + input.descriptors.map((descriptor) => [descriptor.id, descriptor.version]), + ) + const analystVersions = input.analystIds.map((id) => ({ + id, + version: descriptorVersions.get(id) ?? 'unknown', + })) + const usage = usageFromResult(input.result) + const latency = wallTimeMs(input.result) + return { + ...input.base, + status: 'completed', + findings: mapped.findings, + checks, + provenance: provenance( + input.host, + input.evidence, + input.request, + input.identity, + input.analystIds, + analystVersions, + checks, + ), + ...(usage === undefined ? {} : { usage }), + ...(Number.isFinite(input.result.total_cost_usd) && input.result.total_cost_usd >= 0 + ? { costUsd: input.result.total_cost_usd } + : {}), + ...(latency === undefined ? {} : { wallTimeMs: latency }), + updatedAt: input.at, + } +} diff --git a/src/app/analysis-service.ts b/src/app/analysis-service.ts new file mode 100644 index 0000000..20fed02 --- /dev/null +++ b/src/app/analysis-service.ts @@ -0,0 +1,206 @@ +import type { ExactAnalystRunResult } from '@tangle-network/agent-eval' +import type { AnalysisRecord } from '../domain/entities.js' +import type { AnalysisId } from '../domain/ids.js' +import { type AnalysisAnalyst, AnalysisExecutionSession } from './analysis-execution-session.js' +import { AnalysisGraphProjector } from './analysis-graph-projector.js' +import { AnalysisLifecycle } from './analysis-lifecycle.js' +import { AnalysisOperationError } from './analysis-operation.js' +import { prepareAnalysisRequest } from './analysis-request.js' +import { completedAnalysisRecord, initialAnalysisRecord } from './analysis-result-mapper.js' +import type { + AnalysisApplicationHost, + AnalysisProgress, + AnalysisRequest, + FrozenAnalysisEvidence, +} from './analysis-types.js' +import { AnalysisPersistenceError } from './analysis-types.js' +import { UnavailableAnalyst } from './unavailable-analyst.js' + +export interface AnalysisExecutionResult { + readonly status: 'completed' | 'failed' | 'cancelled' + readonly analysis: AnalysisRecord + readonly evidence: FrozenAnalysisEvidence + readonly result?: ExactAnalystRunResult + readonly error?: Error + readonly replayed?: boolean +} + +export class AnalysisService { + readonly #host: AnalysisApplicationHost + readonly #lifecycle: AnalysisLifecycle + readonly #execution: AnalysisExecutionSession + readonly #graph: AnalysisGraphProjector + readonly #active = new Set() + #reconciled = false + + constructor(host: AnalysisApplicationHost, analyst: AnalysisAnalyst = new UnavailableAnalyst()) { + this.#host = host + this.#lifecycle = new AnalysisLifecycle(host) + this.#execution = new AnalysisExecutionSession(analyst) + this.#graph = new AnalysisGraphProjector(host) + } + + listAnalysts() { + return this.#execution.listAnalysts() + } + + async reconcile(): Promise { + await this.#lifecycle.reconcile(this.#active) + this.#reconciled = true + } + + cancel(analysisId: AnalysisId, reason = 'cancelled by user'): boolean { + return this.#execution.cancel(String(analysisId), reason) + } + + async *stream(request: AnalysisRequest): AsyncGenerator { + if (!this.#reconciled) await this.reconcile() + const prepared = prepareAnalysisRequest(this.#host, request) + const existing = this.#lifecycle.existing(prepared.identity) + if (existing !== undefined) { + if (existing.status === 'preparing' || existing.status === 'running') { + throw new Error(`Analysis ${String(existing.id)} is already active`) + } + yield { type: 'started', analysis: existing, replayed: true } + await this.#graph.project(existing) + await this.#lifecycle.repairTerminal(existing) + yield this.#lifecycle.replay(existing, prepared.evidence) + return + } + + const record = initialAnalysisRecord({ + host: this.#host, + evidence: prepared.evidence, + request, + identity: prepared.identity, + at: this.#host.now(), + }) + this.#active.add(String(record.id)) + let current = record + let resultCommitted = false + try { + const operation = await this.#lifecycle.reserve(prepared.identity) + if (!operation.created) { + const replay = this.#lifecycle.existing(prepared.identity) + if (replay !== undefined && replay.status !== 'preparing' && replay.status !== 'running') { + yield { type: 'started', analysis: replay, replayed: true } + yield this.#lifecycle.replay(replay, prepared.evidence) + return + } + throw new Error( + `Analysis operation ${String(operation.operation.id)} is already active or unavailable`, + ) + } + await this.#lifecycle.create(current) + yield { type: 'started', analysis: current } + current = await this.#lifecycle.running(current) + yield { type: 'running', analysis: current } + + const analystIds = this.#execution.resolveAnalystIds(request) + let exactResult: ExactAnalystRunResult | undefined + for await (const item of this.#execution.stream({ + identity: prepared.identity, + evidence: prepared.evidence, + request, + analystIds, + })) { + if (item.result !== undefined) exactResult = item.result + yield { + type: 'analyst', + analysisId: prepared.identity.analysisId, + analysisRunId: prepared.identity.analysisRunId, + event: item.event, + } + } + if (exactResult === undefined) + throw new Error('agent-eval exact stream ended without a result') + if (exactResult.completion.status === 'failed') { + throw new Error(exactResult.completion.error.message) + } + current = await completedAnalysisRecord({ + host: this.#host, + base: current, + evidence: prepared.evidence, + request, + identity: prepared.identity, + analystIds, + descriptors: this.#execution.listAnalysts(), + result: exactResult, + at: this.#host.now(), + }) + await this.#lifecycle.completed(current) + resultCommitted = true + await this.#graph.project(current) + await this.#lifecycle.finish(current) + yield { + type: 'completed', + analysis: current, + evidence: prepared.evidence, + result: exactResult, + } + } catch (value) { + const error = value instanceof Error ? value : new Error(String(value)) + if (resultCommitted) throw error + if (error instanceof AnalysisPersistenceError || error instanceof AnalysisOperationError) { + throw error + } + const cancelled = this.#execution.wasCancelled(String(current.id)) + const failed = await this.#lifecycle.failed(current, error, cancelled) + if (cancelled) { + yield { + type: 'cancelled', + analysis: failed, + evidence: prepared.evidence, + reason: error.message, + } + } else { + yield { + type: 'failed', + analysis: failed, + evidence: prepared.evidence, + error, + } + } + } finally { + this.#active.delete(String(record.id)) + } + } + + async run(request: AnalysisRequest): Promise { + let terminal: AnalysisProgress | undefined + for await (const progress of this.stream(request)) terminal = progress + if (terminal === undefined) throw new Error('analysis stream produced no terminal result') + if (terminal.type === 'completed') { + return { + status: 'completed', + analysis: terminal.analysis, + evidence: terminal.evidence, + ...(terminal.result === undefined + ? {} + : { result: terminal.result as ExactAnalystRunResult }), + ...(terminal.replayed === undefined ? {} : { replayed: terminal.replayed }), + } + } + if (terminal.type === 'failed') { + return { + status: 'failed', + analysis: terminal.analysis, + evidence: terminal.evidence, + error: terminal.error, + ...(terminal.result === undefined + ? {} + : { result: terminal.result as ExactAnalystRunResult }), + ...(terminal.replayed === undefined ? {} : { replayed: terminal.replayed }), + } + } + if (terminal.type === 'cancelled') { + return { + status: 'cancelled', + analysis: terminal.analysis, + evidence: terminal.evidence, + ...(terminal.replayed === undefined ? {} : { replayed: terminal.replayed }), + } + } + throw new Error(`analysis stream ended before a terminal result (${terminal.type})`) + } +} diff --git a/src/app/analysis-source.ts b/src/app/analysis-source.ts new file mode 100644 index 0000000..446e93f --- /dev/null +++ b/src/app/analysis-source.ts @@ -0,0 +1,295 @@ +import { canonicalDigest } from '../domain/canonical.js' +import type { + MessagePartRecord, + MessageRecord, + MissingHistoryRange, + RunRecord, +} from '../domain/entities.js' +import type { BraidEvent, BraidEventEnvelope, JournalEventEnvelope } from '../domain/events.js' +import { eventRunId } from '../domain/events.js' +import { + type BranchId, + type ConversationId, + createEventId, + createTraceId, + type Digest, + type EventId, + isEventId, + type MessageId, + type RunId, +} from '../domain/ids.js' +import { redactBraidEvent } from '../domain/redaction.js' +import type { BraidState } from '../domain/state.js' +import { + AnalysisSourceError, + type AnalysisSourceRequest, + type FrozenAnalysisEvent, + type FrozenAnalysisEvidence, +} from './analysis-types.js' +import { messagesVisibleOnBranch } from './conversation-context.js' + +export interface FreezeAnalysisSourceInput extends AnalysisSourceRequest { + readonly state: BraidState + readonly events: readonly BraidEventEnvelope[] +} + +function cloneAndFreeze(value: T): T { + let cloned: T + try { + cloned = structuredClone(value) + } catch (error) { + throw new AnalysisSourceError( + `Analysis source contains a value that cannot be snapshotted: ${ + error instanceof Error ? error.message : String(error) + }`, + ) + } + return freezeValue(cloned) +} + +function freezeValue(value: T): T { + if (value === null || typeof value !== 'object') return value + for (const child of Object.values(value as Record)) freezeValue(child) + return Object.freeze(value) +} + +function safeEventId(envelope: BraidEventEnvelope, event: BraidEvent): EventId { + if (envelope.eventId !== undefined && isEventId(envelope.eventId)) return envelope.eventId + return createEventId( + `event-${canonicalDigest({ + sequence: envelope.sequence, + revision: envelope.revision, + occurredAt: envelope.occurredAt, + event, + }).slice(0, 40)}`, + ) +} + +function redactedClone(value: T): T { + return cloneAndFreeze(redactBraidEvent(cloneAndFreeze(value))) +} + +function eventMessageId(event: BraidEvent): MessageId | undefined { + return event.kind === 'message.created' ? event.message.id : undefined +} + +function branchMessages( + state: BraidState, + conversationId: ConversationId, + branchId: BranchId, + throughMessageId: MessageId | undefined, +): readonly MessageRecord[] { + const messages = messagesVisibleOnBranch(state, branchId).filter( + (message) => message.conversationId === conversationId, + ) + if (throughMessageId === undefined) return messages.map(redactedClone) + const throughIndex = messages.findIndex((message) => message.id === throughMessageId) + if (throughIndex < 0) return messages.map(redactedClone) + return messages.slice(0, throughIndex + 1).map(redactedClone) +} + +function messageParts( + state: BraidState, + messages: readonly MessageRecord[], +): readonly MessagePartRecord[] { + const messageIds = new Set(messages.map((message) => message.id)) + return state.messageParts.filter((part) => messageIds.has(part.messageId)).map(redactedClone) +} + +function matchingMissingHistory( + state: BraidState, + runId: RunId | undefined, +): MissingHistoryRange | undefined { + if (runId === undefined) return undefined + return state.missingHistory.find((range) => range.runId === runId) +} + +function sourceRun(state: BraidState, runId: RunId | undefined): RunRecord | undefined { + if (runId === undefined) return undefined + const run = state.runs.find((candidate) => candidate.id === runId) + if (run === undefined) throw new AnalysisSourceError(`Run ${runId} is not present in Braid state`) + return run +} + +function boundarySequence( + events: readonly BraidEventEnvelope[], + throughMessageId: MessageId | undefined, +): number | undefined { + if (throughMessageId === undefined) return undefined + const boundary = events.find((envelope) => { + if (envelope.event.kind === 'message.created') { + return eventMessageId(envelope.event) === throughMessageId + } + if (envelope.event.kind === 'run.requested') { + return ( + envelope.event.userMessageId === throughMessageId || + envelope.event.assistantMessageId === throughMessageId + ) + } + return false + }) + return boundary?.sequence +} + +function sourceEvents( + events: readonly BraidEventEnvelope[], + runId: RunId | undefined, +): readonly FrozenAnalysisEvent[] { + if (runId === undefined) return [] + const matching = events + .filter((envelope) => eventRunId(envelope.event) === runId) + .sort((left, right) => left.sequence - right.sequence) + const terminalSequence = matching.find((envelope) => { + if (envelope.event.kind === 'run.finished') return true + if (envelope.event.kind === 'run.reconciled') { + return [ + 'completed', + 'failed', + 'cancelled', + 'aborted', + 'blocked', + 'expired', + 'unknown', + ].includes(envelope.event.status) + } + return ( + envelope.event.kind === 'run.status.changed' && + ['completed', 'failed', 'cancelled', 'aborted', 'blocked', 'expired', 'unknown'].includes( + envelope.event.status, + ) + ) + })?.sequence + return matching + .filter((envelope) => terminalSequence === undefined || envelope.sequence <= terminalSequence) + .map((envelope) => { + const event = redactedClone(envelope.event) + return cloneAndFreeze({ + id: safeEventId(envelope, event), + sequence: envelope.sequence, + revision: envelope.revision, + occurredAt: envelope.occurredAt, + event, + }) + }) +} + +function sourceDigestForEvidence(evidence: FrozenAnalysisEvidence): Digest { + const evidenceDigest = digestOfEvidence({ + run: evidence.run, + events: evidence.events, + messages: evidence.messages, + messageParts: evidence.messageParts, + }) + return canonicalDigest({ + conversationId: evidence.source.conversationId, + branchId: evidence.source.branchId, + runId: evidence.source.runId, + throughMessageId: evidence.source.throughMessageId, + evidenceDigest, + complete: evidence.source.complete, + missingHistory: evidence.source.missingHistory, + }) +} + +function digestOfEvidence(input: { + readonly run: RunRecord | undefined + readonly events: readonly FrozenAnalysisEvent[] + readonly messages: readonly MessageRecord[] + readonly messageParts: readonly MessagePartRecord[] +}): Digest { + return canonicalDigest(input) +} + +export function freezeAnalysisSource(input: FreezeAnalysisSourceInput): FrozenAnalysisEvidence { + const conversationId = input.conversationId ?? input.state.conversationId + const branchId = input.branchId ?? input.state.branchId + if (conversationId === null || branchId === null) { + throw new AnalysisSourceError('Analysis requires an open conversation and branch') + } + + const run = sourceRun(input.state, input.runId) + if (run !== undefined) { + if (run.conversationId !== conversationId || run.branchId !== branchId) { + throw new AnalysisSourceError( + `Run ${run.id} does not belong to ${conversationId}/${branchId}`, + ) + } + } + + const branch = input.state.branches.find((candidate) => candidate.id === branchId) + const throughMessageId = input.throughMessageId ?? branch?.tipMessageId + const throughSequence = boundarySequence(input.events, throughMessageId) + const missingHistory = matchingMissingHistory(input.state, run?.id) + const messages = branchMessages(input.state, conversationId, branchId, throughMessageId) + const parts = messageParts(input.state, messages) + const events = sourceEvents(input.events, run?.id) + const evidenceDigest = digestOfEvidence({ + run: run === undefined ? undefined : redactedClone(run), + events, + messages, + messageParts: parts, + }) + const complete = + missingHistory === undefined && + (run === undefined || + (run.complete && (throughMessageId === undefined || throughSequence !== undefined))) + const sourceDigest = canonicalDigest({ + conversationId, + branchId, + runId: run?.id, + throughMessageId, + evidenceDigest, + complete, + missingHistory, + }) + const source = cloneAndFreeze({ + conversationId, + branchId, + ...(run === undefined ? {} : { runId: run.id }), + ...(throughMessageId === undefined ? {} : { throughMessageId }), + ...(run === undefined + ? {} + : { + trace: { + id: createTraceId(`trace-${String(run.id).replace(/[^A-Za-z0-9._:-]/gu, '-')}`), + provider: 'runtime' as const, + reference: `braid://run/${run.id}`, + digest: sourceDigest, + }, + }), + digest: sourceDigest, + complete, + ...(missingHistory === undefined ? {} : { missingHistory }), + }) + + return cloneAndFreeze({ + source, + ...(run === undefined ? {} : { run: redactedClone(run) }), + events, + messages, + messageParts: parts, + }) +} + +export function verifyFrozenAnalysisSource(evidence: FrozenAnalysisEvidence): void { + const expected = sourceDigestForEvidence(evidence) + if (expected !== evidence.source.digest) { + throw new AnalysisSourceError( + `Frozen analysis source digest mismatch: expected ${expected}, received ${evidence.source.digest}`, + ) + } + if (evidence.source.trace !== undefined && evidence.source.trace.digest !== expected) { + throw new AnalysisSourceError('Frozen trace reference digest does not match the source digest') + } +} + +export function sourceEventForId( + evidence: FrozenAnalysisEvidence, + eventId: EventId, +): FrozenAnalysisEvent | undefined { + return evidence.events.find((event) => event.id === eventId) +} + +export function sourceEventIdForEnvelope(envelope: JournalEventEnvelope): EventId { + return safeEventId(envelope, envelope.event) +} diff --git a/src/app/analysis-types.ts b/src/app/analysis-types.ts new file mode 100644 index 0000000..6531917 --- /dev/null +++ b/src/app/analysis-types.ts @@ -0,0 +1,161 @@ +import type { + AnalysisRecord, + FrozenAnalysisSource, + MessagePartRecord, + MessageRecord, + RunRecord, +} from '../domain/entities.js' +import type { BraidEvent, BraidEventEnvelope } from '../domain/events.js' +import type { + AnalysisId, + AnalysisRunId, + BranchId, + ConversationId, + Digest, + EventId, + MessageId, + ProfileId, + RunId, +} from '../domain/ids.js' +import type { BraidState } from '../domain/state.js' + +export type AnalysisRecipe = 'ask' | 'failure' | 'cost' | 'tools' | 'improvement' | (string & {}) + +export interface AnalysisSourceRequest { + readonly conversationId?: ConversationId + readonly branchId?: BranchId + readonly runId?: RunId + readonly throughMessageId?: MessageId +} + +export interface AnalysisRequest extends AnalysisSourceRequest { + readonly operationId?: string + readonly question?: string + readonly recipe?: AnalysisRecipe + readonly analystIds?: readonly string[] + readonly analystProfileId?: ProfileId + readonly analystProfileDigest?: Digest + readonly budgetUsd?: number + readonly totalTimeoutMs?: number +} + +export interface FrozenAnalysisEvent { + readonly id: EventId + readonly sequence: number + readonly revision: number + readonly occurredAt: string + readonly event: BraidEvent +} + +export interface FrozenAnalysisEvidence { + readonly source: FrozenAnalysisSource + readonly run?: RunRecord + readonly events: readonly FrozenAnalysisEvent[] + readonly messages: readonly MessageRecord[] + readonly messageParts: readonly MessagePartRecord[] +} + +export interface AnalysisApplicationHost { + readonly currentState: () => BraidState + readonly eventHistory: () => readonly BraidEventEnvelope[] + readonly commit: (event: BraidEvent) => void | Promise + readonly commitAndWait?: (event: BraidEvent) => void | Promise + readonly now: () => string +} + +export interface AnalysisProgressStarted { + readonly type: 'started' + readonly analysis: AnalysisRecord + readonly replayed?: boolean +} + +export interface AnalysisProgressRunning { + readonly type: 'running' + readonly analysis: AnalysisRecord +} + +export interface AnalysisProgressAnalyst { + readonly type: 'analyst' + readonly analysisId: AnalysisId + readonly analysisRunId: AnalysisRunId + readonly event: unknown +} + +export interface AnalysisProgressCompleted { + readonly type: 'completed' + readonly analysis: AnalysisRecord + readonly evidence: FrozenAnalysisEvidence + readonly result: unknown + readonly replayed?: boolean +} + +export interface AnalysisProgressFailed { + readonly type: 'failed' + readonly analysis: AnalysisRecord + readonly evidence: FrozenAnalysisEvidence + readonly error: AnalysisCapabilityError | Error + readonly result?: unknown + readonly replayed?: boolean +} + +export interface AnalysisProgressCancelled { + readonly type: 'cancelled' + readonly analysis: AnalysisRecord + readonly evidence: FrozenAnalysisEvidence + readonly reason?: string + readonly replayed?: boolean +} + +export type AnalysisProgress = + | AnalysisProgressStarted + | AnalysisProgressRunning + | AnalysisProgressAnalyst + | AnalysisProgressCompleted + | AnalysisProgressFailed + | AnalysisProgressCancelled + +export interface AnalysisCapabilityIssue { + readonly capability: string + readonly packageName: string + readonly packageVersion: string + readonly reason: string + readonly reproduction: string +} + +export class AnalysisCapabilityError extends Error { + readonly code = 'ANALYSIS_CAPABILITY_UNAVAILABLE' + readonly issue: AnalysisCapabilityIssue + + constructor(issue: AnalysisCapabilityIssue) { + super(`${issue.capability} is unavailable: ${issue.reason}`) + this.name = 'AnalysisCapabilityError' + this.issue = issue + } +} + +export class AnalysisSourceError extends Error { + readonly code = 'ANALYSIS_SOURCE_INVALID' + + constructor(message: string) { + super(message) + this.name = 'AnalysisSourceError' + } +} + +export class AnalysisCitationError extends Error { + readonly code = 'ANALYSIS_CITATION_INVALID' + + constructor(message: string) { + super(message) + this.name = 'AnalysisCitationError' + } +} + +export class AnalysisPersistenceError extends Error { + readonly code = 'ANALYSIS_PERSISTENCE_UNKNOWN' + + constructor(message: string, cause?: unknown) { + super(message, cause === undefined ? undefined : { cause }) + this.name = 'AnalysisPersistenceError' + } +} diff --git a/src/app/application-guards.ts b/src/app/application-guards.ts new file mode 100644 index 0000000..1e4d418 --- /dev/null +++ b/src/app/application-guards.ts @@ -0,0 +1,23 @@ +import { parseOperationId, type OperationId } from '../domain/ids.js' +import { AppError } from './errors.js' +import type { ExecutionPort } from '../ports/execution.js' + +export function operationId(value: string, command: string): OperationId { + try { + return parseOperationId(value) + } catch { + throw new AppError('INVALID_OPERATION_ID', `${command} requires a valid operationId`) + } +} + +export function assertWritable(failure: unknown): void { + if (failure !== undefined) + throw new AppError( + 'STORAGE_FAILURE', + 'Durable storage is unavailable; reopen Braid to continue', + ) +} + +export function admissionIsAsync(execution: ExecutionPort): boolean { + return 'admissionMode' in execution && execution.admissionMode === 'async' +} diff --git a/src/app/application-options.ts b/src/app/application-options.ts new file mode 100644 index 0000000..b742562 --- /dev/null +++ b/src/app/application-options.ts @@ -0,0 +1,32 @@ +import type { AgentProfile } from '@tangle-network/agent-interface' +import type { EffectStoragePort, JournalPort } from '../ports/effect-storage.js' +import type { ExecutionPort } from '../ports/execution.js' +import type { IdSource } from '../ports/ids.js' +import type { StoragePort } from '../ports/storage.js' +import type { ApplicationJournal } from './application-support.js' +import type { SendReceipt } from './application-types.js' +import type { SerializedEffectCoordinator } from './effect-coordinator.js' +import type { IntelligenceActionsOptions } from './intelligence-actions.js' + +export interface CancelInput { + readonly operationId: string + readonly runId?: string + readonly reason?: string +} + +export type CancelReceipt = Omit & { readonly replayed: boolean } + +export interface BraidApplicationOptions { + readonly profile: Readonly + readonly execution: ExecutionPort + readonly clock: import('../ports/clock.js').Clock + readonly ids: IdSource + readonly journal?: JournalPort | ApplicationJournal + readonly effectStorage?: EffectStoragePort + readonly effectCoordinator?: SerializedEffectCoordinator + readonly conversationId?: import('../domain/ids.js').ConversationId + readonly branchId?: import('../domain/ids.js').BranchId + readonly cancelTimeoutMs?: number + readonly conversationStorage?: Pick + readonly intelligence?: IntelligenceActionsOptions +} diff --git a/src/app/application-port-builder.ts b/src/app/application-port-builder.ts new file mode 100644 index 0000000..aa5781c --- /dev/null +++ b/src/app/application-port-builder.ts @@ -0,0 +1,128 @@ +import type { RunAdmissionReceipt } from '../domain/receipts.js' +import type { Clock } from '../ports/clock.js' +import type { ExecutionPort } from '../ports/execution.js' +import type { IdSource } from '../ports/ids.js' +import type { + AdmissionPort, + AsyncAdmissionPort, + ControlPort, + ExecutionRunPort, + IngestionPort, + JournalWriter, + NativeContinuationPort, + QueuePort, + ReplayPort, + RestartPort, + StateReader, + StatusPort, +} from './application-ports.js' +import type { SendInput, SendReceipt } from './application-types.js' +import type { SerializedEffectCoordinator } from './effect-coordinator.js' +import type { RunExecutionSnapshot } from './run-execution-snapshot.js' +import { ingestRuntimeEvent } from './run-ingestion.js' +import type { RunLedger } from './run-ledger.js' +import { reconnectRun } from './run-replay.js' + +export interface PortViews { + readonly state: StateReader + readonly journal: JournalWriter + readonly restart: RestartPort + readonly queue: QueuePort + readonly control: ControlPort + readonly ingestion: IngestionPort + readonly replay: ReplayPort + readonly status: StatusPort + readonly executionRun: ExecutionRunPort + readonly admission: AdmissionPort + readonly asyncAdmission: AsyncAdmissionPort + readonly nativeContinuation: NativeContinuationPort +} + +export interface PortBuilderInput { + readonly state: StateReader + readonly journal: JournalWriter + readonly execution: ExecutionPort + readonly ledger: RunLedger + readonly effects: SerializedEffectCoordinator + readonly clock: Clock + readonly ids: IdSource + readonly flush: () => Promise + readonly storageFailure: () => unknown + readonly executeControl: ( + input: import('./application-ports.js').ControlEffectRequest, + ) => Promise + readonly admitPersistedSend: (operationId: string, digest: string) => SendReceipt | undefined + readonly fingerprint: AdmissionPort['fingerprint'] + readonly startRun: ( + context: ExecutionRunPort, + input: RunExecutionSnapshot, + admission: RunAdmissionReceipt, + abort: AbortController, + requestDigest: string, + ) => Promise + readonly send: (input: SendInput) => SendReceipt +} + +export function buildPortViews(input: PortBuilderInput): PortViews { + const restart: RestartPort = { + ...input.state, + ...input.journal, + execution: input.execution, + } + const queue: QueuePort = { ...input.state, ...input.journal } + const control: ControlPort = { + ...input.state, + ...input.journal, + execution: input.execution, + ledger: input.ledger, + executeControl: input.executeControl, + currentEffect: (operationId) => input.effects.current(operationId), + } + const ingestion: IngestionPort = { ...input.state, ...input.journal, ledger: input.ledger } + const replay: ReplayPort = { + ...input.state, + ...input.journal, + execution: input.execution, + ledger: input.ledger, + ingestRuntimeEvent: (envelope) => ingestRuntimeEvent(ingestion, envelope), + } + const status: StatusPort = { ...input.state, ledger: input.ledger } + const executionRun: ExecutionRunPort = { + ...input.state, + ...input.journal, + flush: input.flush, + storageFailure: input.storageFailure, + clock: input.clock, + execution: input.execution, + ledger: input.ledger, + ingestRuntimeEvent: (envelope) => ingestRuntimeEvent(ingestion, envelope), + reconnectRun: (request) => reconnectRun(replay, request), + send: input.send, + } + const admission: AdmissionPort = { + ...input.state, + ...input.journal, + clock: input.clock, + ids: input.ids, + execution: input.execution, + ledger: input.ledger, + admitPersistedSend: input.admitPersistedSend, + fingerprint: input.fingerprint, + startRun: (runInput, receipt, abort, requestDigest) => + input.startRun(executionRun, runInput, receipt, abort, requestDigest), + } + return { + state: input.state, + journal: input.journal, + restart, + queue, + control, + ingestion, + replay, + status, + executionRun, + admission, + asyncAdmission: { ...admission, flush: input.flush }, + nativeContinuation: { ...input.state, execution: input.execution, send: input.send }, + } +} diff --git a/src/app/application-port-runtime.ts b/src/app/application-port-runtime.ts new file mode 100644 index 0000000..66a2640 --- /dev/null +++ b/src/app/application-port-runtime.ts @@ -0,0 +1,85 @@ +import type { AgentProfile } from '@tangle-network/agent-interface' +import type { BraidEvent } from '../domain/events.js' +import type { BraidState } from '../domain/state.js' +import type { Clock } from '../ports/clock.js' +import type { ExecutionPort } from '../ports/execution.js' +import type { IdSource } from '../ports/ids.js' +import { buildPortViews, type PortViews } from './application-port-builder.js' +import type { ControlEffectRequest, JournalWriter, StateReader } from './application-ports.js' +import type { ApplicationJournal } from './application-support.js' +import { startApplicationRun } from './application-support.js' +import type { SendInput, SendReceipt } from './application-types.js' +import type { SerializedEffectCoordinator } from './effect-coordinator.js' +import type { RunLedger } from './run-ledger.js' + +export interface ApplicationPortRuntimeInput { + readonly currentState: () => BraidState + readonly profile: () => Readonly + readonly commit: (event: BraidEvent) => void + readonly commitAndWait: JournalWriter['commitAndWait'] + readonly commitAndWaitRecovery: NonNullable + readonly execution: ExecutionPort + readonly ledger: RunLedger + readonly clock: Clock + readonly ids: IdSource + readonly effects: SerializedEffectCoordinator + readonly journal: ApplicationJournal + readonly admitPersistedSend: (operationId: string, digest: string) => SendReceipt | undefined + readonly fingerprint: import('./application-ports.js').AdmissionReplayAccess['fingerprint'] + readonly executeControl: ( + input: ControlEffectRequest, + ) => Promise + readonly flush: () => Promise + readonly storageFailure: () => unknown + readonly send: (input: SendInput) => SendReceipt +} + +export function buildApplicationPortRuntime(input: ApplicationPortRuntimeInput): PortViews { + const state: StateReader = { + currentState: input.currentState, + profile: input.profile, + findRun: (runId) => { + const run = input.currentState().runs.find((candidate) => candidate.id === runId) + if (!run) throw new Error(`Run ${runId} is unknown`) + return run + }, + isTerminal: (status) => + status === 'completed' || + status === 'failed' || + status === 'aborted' || + status === 'cancelled' || + status === 'blocked' || + status === 'expired' || + status === 'unknown', + } + const journal: JournalWriter = { + commit: input.commit, + commitAndWait: input.commitAndWait, + commitAndWaitRecovery: input.commitAndWaitRecovery, + } + return buildPortViews({ + state, + journal, + execution: input.execution, + ledger: input.ledger, + effects: input.effects, + clock: input.clock, + ids: input.ids, + flush: input.flush, + storageFailure: input.storageFailure, + executeControl: input.executeControl, + admitPersistedSend: input.admitPersistedSend, + fingerprint: input.fingerprint, + startRun: (context, runInput, admission, abort, requestDigest) => + startApplicationRun({ + effects: input.effects, + journal: input.journal, + context, + runInput, + admission, + abort, + requestDigest, + }), + send: input.send, + }) +} diff --git a/src/app/application-ports.ts b/src/app/application-ports.ts new file mode 100644 index 0000000..5c57b10 --- /dev/null +++ b/src/app/application-ports.ts @@ -0,0 +1,162 @@ +import type { AgentProfile } from '@tangle-network/agent-interface' +import type { BraidEvent } from '../domain/events.js' +import type { RunAdmissionReceipt } from '../domain/receipts.js' +import type { BraidRun, BraidState, RunStatus } from '../domain/state.js' +import type { Clock } from '../ports/clock.js' +import type { EffectRecord } from '../ports/effect-storage.js' +import type { ControlAcknowledgement, ExecutionPort } from '../ports/execution.js' +import type { IdSource } from '../ports/ids.js' +import type { SendInput, SendReceipt } from './application-types.js' +import type { RunExecutionSnapshot } from './run-execution-snapshot.js' +import type { RunLedger } from './run-ledger.js' + +export interface StateReader { + readonly currentState: () => BraidState + readonly profile: () => Readonly + readonly findRun: (runId: string) => BraidRun + readonly isTerminal: ( + status: RunStatus, + ) => status is Extract< + RunStatus, + 'completed' | 'failed' | 'aborted' | 'cancelled' | 'blocked' | 'expired' | 'unknown' + > +} + +export interface JournalWriter { + readonly commit: (event: BraidEvent) => void + readonly commitAndWait: (event: BraidEvent) => void | Promise + /** Retry one recovery event after a durable write has already failed. */ + readonly commitAndWaitRecovery?: (event: BraidEvent) => void | Promise +} + +export interface DurabilityWaiter { + readonly flush: () => Promise + readonly storageFailure?: () => unknown +} + +export interface ClockAccess { + readonly clock: Clock +} + +export interface IdentityAccess { + readonly ids: IdSource +} + +export interface ExecutionAccess { + readonly execution: ExecutionPort +} + +export interface LedgerAccess { + readonly ledger: RunLedger +} + +export interface AdmissionReplayAccess { + readonly admitPersistedSend: (operationId: string, digest: string) => SendReceipt | undefined + readonly fingerprint: (input: { + readonly effectKind: string + readonly request: unknown + }) => string +} + +export interface RunStarter { + readonly startRun: ( + input: RunExecutionSnapshot, + admission: RunAdmissionReceipt, + abort: AbortController, + requestDigest: string, + ) => Promise +} + +export interface SendAccess { + readonly send: (input: SendInput) => SendReceipt +} + +export interface RuntimeIngestionAccess { + readonly ingestRuntimeEvent: ( + envelope: RuntimeEventEnvelopeLike, + ) => RuntimeEventIngestionResult | Promise +} + +export interface ReconnectAccess { + readonly reconnectRun: (input: ReconnectInput) => Promise +} + +export interface WaitAccess { + readonly waitForRun: (runId: string) => Promise +} + +export interface ControlDispatchAccess { + readonly executeControl: (input: ControlEffectRequest) => Promise +} + +export interface CancelTimingAccess { + readonly cancelTimeoutMs: number +} + +export type AdmissionPort = StateReader & + JournalWriter & + ClockAccess & + IdentityAccess & + ExecutionAccess & + LedgerAccess & + AdmissionReplayAccess & + RunStarter + +export type AsyncAdmissionPort = AdmissionPort & DurabilityWaiter + +export type NativeContinuationPort = StateReader & ExecutionAccess & SendAccess + +export type ExecutionRunPort = StateReader & + JournalWriter & + DurabilityWaiter & + ClockAccess & + ExecutionAccess & + LedgerAccess & + RuntimeIngestionAccess & + ReconnectAccess & + SendAccess + +export type ControlPort = StateReader & + JournalWriter & + ExecutionAccess & + LedgerAccess & + ControlDispatchAccess & { + readonly currentEffect: (operationId: string) => EffectRecord | undefined + } + +export type QueuePort = StateReader & JournalWriter + +export type IngestionPort = StateReader & JournalWriter & LedgerAccess + +export type ReplayPort = StateReader & + JournalWriter & + ExecutionAccess & + LedgerAccess & + RuntimeIngestionAccess + +export type StatusPort = StateReader & LedgerAccess + +export type RestartPort = StateReader & JournalWriter & ExecutionAccess + +export interface ControlEffectRequest { + readonly operationId: string + readonly runId: string + readonly control: 'cancel' | 'steer' | 'detach' + readonly providerSessionId?: string + readonly reason?: string + readonly text?: string + readonly cursor?: string +} + +export type RuntimeEventEnvelopeLike = import('../domain/runtime-events.js').RuntimeEventEnvelope + +export interface RuntimeEventIngestionResult { + readonly accepted: boolean + readonly duplicate: boolean + readonly sequenceGap?: { readonly from: number; readonly to: number } +} + +export interface ReconnectInput { + readonly operationId: string + readonly runId: string +} diff --git a/src/app/application-runtime-wiring.ts b/src/app/application-runtime-wiring.ts new file mode 100644 index 0000000..20a8182 --- /dev/null +++ b/src/app/application-runtime-wiring.ts @@ -0,0 +1,85 @@ +import type { BraidEvent } from '../domain/events.js' +import type { BraidState } from '../domain/state.js' +import type { Clock } from '../ports/clock.js' +import type { ExecutionPort } from '../ports/execution.js' +import type { IdSource } from '../ports/ids.js' +import type { PortViews } from './application-port-builder.js' +import { buildApplicationPortRuntime } from './application-port-runtime.js' +import type { ControlEffectRequest } from './application-ports.js' +import type { ApplicationJournal } from './application-support.js' +import { createTransitionHost, type TransitionHost } from './application-transition.js' +import type { AppSubscriber, SendInput, SendReceipt } from './application-types.js' +import type { SerializedEffectCoordinator } from './effect-coordinator.js' +import type { RunLedger } from './run-ledger.js' + +/** Pure wiring for the application's narrow ports; it owns no state. */ +export interface ApplicationRuntimeWiringInput { + readonly currentState: () => BraidState + readonly setState: (state: BraidState) => void + readonly profile: () => Readonly + readonly commit: (event: BraidEvent) => void + readonly commitAndWait: (event: BraidEvent) => void | Promise + readonly commitAndWaitRecovery: (event: BraidEvent) => void | Promise + readonly execution: ExecutionPort + readonly ledger: RunLedger + readonly clock: Clock + readonly ids: IdSource + readonly journal: ApplicationJournal + readonly effects: SerializedEffectCoordinator + readonly subscribers: ReadonlySet + readonly asynchronousJournal: boolean + readonly transitionTail: () => Promise + readonly setTransitionTail: (tail: Promise) => void + readonly storageFailure: () => unknown + readonly markStorageFailure: (error: unknown) => void + readonly flush: () => Promise + readonly executeControl: ( + input: ControlEffectRequest, + ) => Promise + readonly admitPersistedSend: (operationId: string, digest: string) => SendReceipt | undefined + readonly fingerprint: import('./application-ports.js').AdmissionReplayAccess['fingerprint'] + readonly send: (input: SendInput) => SendReceipt +} + +export interface ApplicationRuntimeWiring { + readonly ports: PortViews + readonly transition: TransitionHost +} + +export function wireApplicationRuntime( + input: ApplicationRuntimeWiringInput, +): ApplicationRuntimeWiring { + const ports = buildApplicationPortRuntime({ + currentState: input.currentState, + profile: input.profile, + commit: input.commit, + commitAndWait: input.commitAndWait, + commitAndWaitRecovery: input.commitAndWaitRecovery, + execution: input.execution, + ledger: input.ledger, + clock: input.clock, + ids: input.ids, + effects: input.effects, + journal: input.journal, + flush: input.flush, + storageFailure: input.storageFailure, + executeControl: input.executeControl, + admitPersistedSend: input.admitPersistedSend, + fingerprint: input.fingerprint, + send: input.send, + }) + const transition = createTransitionHost({ + state: input.currentState, + setState: input.setState, + journal: input.journal, + clock: input.clock, + providerEventKeys: input.ledger, + subscribers: input.subscribers, + asynchronous: input.asynchronousJournal, + transitionTail: input.transitionTail, + setTransitionTail: input.setTransitionTail, + storageFailure: input.storageFailure, + markStorageFailure: input.markStorageFailure, + }) + return { ports, transition } +} diff --git a/src/app/application-support.ts b/src/app/application-support.ts new file mode 100644 index 0000000..5bc229a --- /dev/null +++ b/src/app/application-support.ts @@ -0,0 +1,485 @@ +import { canonicalDigest } from '../domain/canonical.js' +import type { BraidEvent, BraidEventEnvelope } from '../domain/events.js' +import { providerEventKey } from '../domain/events.js' +import type { RunAdmissionReceipt } from '../domain/receipts.js' +import { redactBraidEvent } from '../domain/redaction.js' +import { reduceEvent } from '../domain/reducer.js' +import type { BraidState } from '../domain/state.js' +import type { Clock } from '../ports/clock.js' +import type { EffectStoragePort } from '../ports/effect-storage.js' +import type { ControlAcknowledgement } from '../ports/execution.js' +import type { + ExecutionRunPort, + JournalWriter, + RestartPort, + StateReader, +} from './application-ports.js' +import type { AppSubscriber, SendReceipt } from './application-types.js' +import type { EffectDispatchResult, SerializedEffectCoordinator } from './effect-coordinator.js' +import { AppError } from './errors.js' +import { + cancelRequestDigest, + DEFAULT_CANCEL_REASON, + shutdownRequestDigest, +} from './operation-ledger.js' +import { safeProviderDiagnostic } from './provider-values.js' +import { RUN_EFFECT_KIND, runEffectRequest } from './run-admission.js' +import { executeRun } from './run-execution.js' +import type { RunExecutionSnapshot } from './run-execution-snapshot.js' +import type { RunLedger } from './run-ledger.js' + +export interface ApplicationJournal { + readonly all: () => readonly BraidEventEnvelope[] + readonly replay?: () => readonly BraidEventEnvelope[] + readonly initialState?: () => BraidState | undefined + readonly append: ( + envelope: BraidEventEnvelope, + ) => + | undefined + | { readonly appended?: boolean } + | Promise<{ readonly appended?: boolean } | undefined> + readonly appendWithState?: ( + envelope: BraidEventEnvelope, + state: BraidState, + ) => Promise<{ readonly appended?: boolean } | undefined> + readonly asynchronous?: boolean + readonly envelope?: (state: BraidState, event: BraidEvent) => BraidEventEnvelope + readonly flush?: () => Promise + readonly close?: () => Promise +} + +export function startApplicationRun(input: { + readonly effects: SerializedEffectCoordinator + readonly journal: ApplicationJournal + readonly context: ExecutionRunPort + readonly runInput: RunExecutionSnapshot + readonly admission: RunAdmissionReceipt + readonly abort: AbortController + readonly requestDigest: string +}): Promise { + const request = runEffectRequest(input.runInput) + const handle = input.effects.start( + { + operationId: input.runInput.operationId, + effectKind: RUN_EFFECT_KIND, + request, + requestDigest: input.requestDigest, + serializationKey: `run:${input.admission.runId}:execution`, + signal: input.abort.signal, + metadata: { runId: input.admission.runId }, + }, + { + dispatch: async () => { + await input.context.flush() + await executeRun(input.context, input.runInput, input.admission, input.abort) + return runOutcome(input.context, input.admission.runId) + }, + }, + ) + return handle.completion.then(async (record) => { + try { + await input.context.flush() + } catch (error) { + if (input.context.storageFailure?.() === undefined) throw error + return { status: 'unknown' as const, detail: 'RUN_DURABILITY_UNKNOWN' } + } + return { + status: + record.status === 'pending' || record.status === 'conflict' ? 'unknown' : record.status, + ...(record.detail === undefined ? {} : { detail: record.detail }), + } + }) +} + +function runOutcome(context: StateReader, runId: string): EffectDispatchResult { + const run = context.currentState().runs.find((candidate) => candidate.id === runId) + if (!run) return { status: 'unknown', detail: 'RUN_STATE_MISSING' } + if (run.status === 'completed') return { status: 'terminal', detail: 'completed' } + if (run.status === 'unknown') return { status: 'unknown', detail: run.error ?? 'unknown' } + if (run.status === 'failed') return { status: 'failed', detail: 'failed' } + if (run.status === 'aborted' || run.status === 'cancelled') + return { status: 'failed', detail: run.status } + return { status: 'unknown', detail: 'RUN_NOT_TERMINAL' } +} + +export interface RestoreOperationsTarget { + readonly state: () => BraidState + readonly ledger: RunLedger +} + +export function restoreApplicationOperations( + events: readonly BraidEventEnvelope[], + target: RestoreOperationsTarget, +): void { + const acknowledgements = new Map() + for (const envelope of events) { + const event = envelope.event + if (event.kind === 'run.requested' && event.receipt) { + const run = target.state().runs.find((candidate) => candidate.id === event.runId) + if (run) { + target.ledger.setOperation({ + digest: + event.requestDigest ?? + canonicalDigest({ + command: 'send', + conversationId: event.receipt.conversationId, + branchId: event.receipt.branchId, + text: event.text, + profile: event.receipt.requested.profile, + connectionId: event.receipt.requested.connectionId ?? null, + }), + runId: run.id, + admission: event.receipt, + completion: Promise.resolve(), + }) + if (!isTerminalStatus(run.status)) target.ledger.setAbort(run.id, new AbortController()) + } + } else if (event.kind === 'run.cancel.requested') { + const restoredRun = target.state().runs.find((run) => run.id === event.runId) + target.ledger.setControl(event.operationId, { + digest: cancelRequestDigest( + event.runId, + event.reason ?? DEFAULT_CANCEL_REASON, + restoredRun?.providerSessionId, + ), + runId: event.runId, + control: 'cancel', + acknowledgement: Promise.resolve({ + operationId: event.operationId, + outcome: 'already-applied', + }), + completion: Promise.resolve(target.state()), + ...(restoredRun?.providerSessionId === undefined + ? {} + : { providerSessionId: restoredRun.providerSessionId }), + ...(event.reason === undefined ? {} : { reason: event.reason }), + }) + } else if (event.kind === 'run.control.requested') { + const restoredRun = target.state().runs.find((run) => run.id === event.runId) + const digest = + event.control === 'cancel' + ? cancelRequestDigest( + event.runId, + event.reason ?? DEFAULT_CANCEL_REASON, + restoredRun?.providerSessionId, + ) + : event.digest + target.ledger.setControl(event.operationId, { + digest, + runId: event.runId, + control: event.control, + acknowledgement: Promise.resolve({ + operationId: event.operationId, + outcome: 'unknown', + detail: 'Control acknowledgement requires provider reconciliation', + }), + completion: Promise.resolve(target.state()), + ...(restoredRun?.providerSessionId === undefined + ? {} + : { providerSessionId: restoredRun.providerSessionId }), + ...(event.reason === undefined ? {} : { reason: event.reason }), + ...(event.text === undefined ? {} : { text: event.text }), + }) + } else if (event.kind === 'application.shutdown.requested') { + target.ledger.setShutdown(event.operationId, { + digest: shutdownRequestDigest(), + completion: Promise.resolve(target.state()), + }) + } else if (event.kind === 'run.control.acknowledged') { + acknowledgements.set(event.operationId, { + operationId: event.operationId, + outcome: event.outcome, + ...(event.detail === undefined ? {} : { detail: event.detail }), + }) + } + } + for (const [operationId, acknowledgement] of acknowledgements) { + const record = target.ledger.getControl(operationId) + if (record) { + target.ledger.setControl(operationId, { + ...record, + acknowledgement: Promise.resolve(acknowledgement), + }) + } + } +} + +export function reconcileRestartRun(context: RestartPort): Promise { + const runId = context.currentState().activeRunId + if (!runId) return Promise.resolve() + const run = context.currentState().runs.find((candidate) => candidate.id === runId) + if (!run || isProvenTerminalStatus(run.status)) return Promise.resolve() + if (!context.execution.status) { + const unknown = context.commitAndWait({ + kind: 'run.finished', + runId, + status: 'unknown', + finalText: '', + usage: { input: run.inputTokens, output: run.outputTokens }, + error: 'The execution path cannot reconcile provider state after restart', + }) + return Promise.resolve(unknown) + } + return reconcileRestartSnapshot(context, runId) +} + +async function reconcileRestartSnapshot(context: RestartPort, runId: string): Promise { + const status = context.execution.status + if (!status) return + let snapshot: Awaited>> + try { + snapshot = await status({ runId }) + } catch { + await commitRequired(context, { + kind: 'run.reconnecting', + runId, + ...(context.findRun(runId).lastCursor === undefined + ? {} + : { after: context.findRun(runId).lastCursor }), + }) + await commitRequired(context, { + kind: 'run.unknown', + runId, + detail: 'Provider reconciliation failed after restart', + }) + return + } + const current = context.findRun(runId) + if ( + !snapshot || + snapshot.runId !== runId || + (current.providerSessionId !== undefined && snapshot.sessionId !== current.providerSessionId) + ) { + await commitRequired(context, { + kind: 'run.reconnecting', + runId, + ...(current.lastCursor === undefined ? {} : { after: current.lastCursor }), + }) + await commitRequired(context, { + kind: 'run.unknown', + runId, + detail: 'Provider reconciliation returned no matching run identity', + }) + return + } + await commitRequired(context, { + kind: 'run.reconnecting', + runId, + ...(current.lastCursor === undefined ? {} : { after: current.lastCursor }), + }) + await reconcileSnapshot(context, runId, snapshot) +} + +async function reconcileSnapshot( + context: RestartPort, + runId: string, + snapshot: NonNullable>>>, +): Promise { + const current = context.currentState().runs.find((candidate) => candidate.id === runId) + if (!current) return + const evidence = canonicalDigest({ + runId, + sessionId: snapshot.sessionId ?? null, + cursor: snapshot.cursor ?? null, + status: snapshot.status, + detail: snapshot.detail ?? null, + }) + await commitRequired(context, { + kind: 'run.reconciled', + runId, + status: snapshot.status, + from: current.status, + to: snapshot.status, + evidence, + ...(snapshot.detail === undefined ? {} : { detail: snapshot.detail }), + }) + if (isTerminalStatus(snapshot.status)) { + await commitRequired(context, { + kind: 'run.finished', + runId, + status: snapshot.status, + finalText: snapshot.finalText ?? '', + usage: snapshot.usage ?? { input: current.inputTokens, output: current.outputTokens }, + ...(snapshot.error === undefined + ? {} + : { error: safeProviderDiagnostic(snapshot.error, 'RUNTIME_RECONCILIATION_ERROR') }), + ...(snapshot.detail === undefined + ? {} + : { reason: safeProviderDiagnostic(snapshot.detail, 'RUNTIME_RECONCILIATION_STATUS') }), + }) + } +} + +async function commitRequired(context: JournalWriter, event: BraidEvent): Promise { + const result = context.commitAndWait(event) + if (result !== undefined) await result +} + +export function admitPersistedSend(input: { + readonly effects: SerializedEffectCoordinator + readonly operations: Pick + readonly state: () => BraidState + readonly operationId: string + readonly digest: string +}): SendReceipt | undefined { + const state = input.state() + const persisted = input.effects.current(input.operationId) + if (persisted && persisted.requestDigest !== input.digest) { + input.effects.start( + { + operationId: input.operationId, + effectKind: 'run.execute', + request: { digest: input.digest }, + }, + { dispatch: async () => ({ status: 'failed', detail: 'OPERATION_CONFLICT' }) }, + ) + throw new AppError( + 'OPERATION_CONFLICT', + `Operation ${input.operationId} was already used with different input`, + ) + } + if (persisted && (persisted.status === 'pending' || persisted.status === 'conflict')) { + throw new AppError( + 'OPERATION_REQUIRES_RECONCILIATION', + `Operation ${input.operationId} needs provider reconciliation before it can be retried`, + ) + } + const previous = input.operations.getOperation(input.operationId) + if (previous) { + if (previous.digest !== input.digest) + throw new AppError( + 'OPERATION_CONFLICT', + `Operation ${input.operationId} was already used with different input`, + ) + return { + operationId: input.operationId, + runId: previous.runId, + revision: state.revision, + replayed: true, + admission: previous.admission, + completion: previous.completion.then(() => input.state()), + } + } + const persistedRun = state.runs.find((run) => run.operationId === input.operationId) + if (persisted && persistedRun) { + return { + operationId: input.operationId, + runId: persistedRun.id, + revision: state.revision, + replayed: true, + admission: persistedRun.receipt, + completion: Promise.resolve(input.state()), + } + } + if (persisted) + throw new AppError( + 'OPERATION_REQUIRES_RECONCILIATION', + `Operation ${input.operationId} needs provider reconciliation before it can be retried`, + ) + return undefined +} + +export interface CommitApplicationEventInput { + readonly state: BraidState + readonly event: BraidEvent + readonly journal: ApplicationJournal + readonly clock: Clock + readonly providerEventKeys: Pick + readonly subscribers: ReadonlySet +} + +export function commitApplicationEvent(input: CommitApplicationEventInput): BraidState { + const key = providerEventKey(input.event) + if (key && input.providerEventKeys.hasProviderEvent(key)) return input.state + const redacted = redactBraidEvent(input.event) + const envelope = input.journal.envelope + ? input.journal.envelope(input.state, redacted) + : { + sequence: input.state.sequence + 1, + revision: input.state.revision + 1, + occurredAt: input.clock.now(), + event: redacted, + } + const result = input.journal.append(envelope) + if (result !== undefined && typeof (result as Promise).then === 'function') { + throw new AppError( + 'ASYNC_COMMIT_REQUIRED', + 'This journal requires an awaited durable transition', + ) + } + const appendResult = result as { readonly appended?: boolean } | undefined + if (appendResult?.appended === false) return input.state + const nextState = reduceEvent(input.state, envelope) + if (key) input.providerEventKeys.addProviderEvent(key) + for (const subscriber of input.subscribers) { + try { + subscriber(structuredClone(nextState), structuredClone(envelope)) + } catch { + // A renderer cannot change the durable transition. + } + } + return nextState +} + +export async function commitApplicationEventAsync( + input: CommitApplicationEventInput, +): Promise { + const key = providerEventKey(input.event) + if (key && input.providerEventKeys.hasProviderEvent(key)) return input.state + const redacted = redactBraidEvent(input.event) + const envelope = input.journal.envelope + ? input.journal.envelope(input.state, redacted) + : { + sequence: input.state.sequence + 1, + revision: input.state.revision + 1, + occurredAt: input.clock.now(), + event: redacted, + } + const nextState = reduceEvent(input.state, envelope) + const result = input.journal.appendWithState + ? await input.journal.appendWithState(envelope, nextState) + : await input.journal.append(envelope) + if (result?.appended === false) return input.state + if (key) input.providerEventKeys.addProviderEvent(key) + for (const subscriber of input.subscribers) { + try { + subscriber(structuredClone(nextState), structuredClone(envelope)) + } catch { + // A renderer cannot change the durable transition. + } + } + return nextState +} + +export function isEffectStorage(value: unknown): value is EffectStoragePort { + if (!value || typeof value !== 'object') return false + const candidate = value as Partial + return ( + typeof candidate.reserveEffect === 'function' && + typeof candidate.current === 'function' && + typeof candidate.latest === 'function' && + typeof candidate.appendEffect === 'function' && + typeof candidate.history === 'function' + ) +} + +function isTerminalStatus( + status: BraidState['runs'][number]['status'], +): status is Extract< + BraidState['runs'][number]['status'], + 'completed' | 'failed' | 'aborted' | 'cancelled' | 'blocked' | 'expired' | 'unknown' +> { + return ( + status === 'completed' || + status === 'failed' || + status === 'aborted' || + status === 'cancelled' || + status === 'blocked' || + status === 'expired' || + status === 'unknown' + ) +} + +function isProvenTerminalStatus(status: BraidState['runs'][number]['status']): boolean { + return isTerminalStatus(status) && status !== 'unknown' +} diff --git a/src/app/application-transition.ts b/src/app/application-transition.ts new file mode 100644 index 0000000..a8b9cce --- /dev/null +++ b/src/app/application-transition.ts @@ -0,0 +1,191 @@ +import type { BraidEvent } from '../domain/events.js' +import type { BraidState } from '../domain/state.js' +import type { Clock } from '../ports/clock.js' +import { + type ApplicationJournal, + commitApplicationEvent, + commitApplicationEventAsync, +} from './application-support.js' +import type { AppSubscriber } from './application-types.js' +import { AppError } from './errors.js' +import type { RunLedger } from './run-ledger.js' + +export interface TransitionHost { + readonly state: () => BraidState + readonly setState: (state: BraidState) => void + readonly journal: ApplicationJournal + readonly clock: Clock + readonly providerEventKeys: RunLedger + readonly subscribers: ReadonlySet + readonly asynchronous: boolean + readonly transitionTail: () => Promise + readonly setTransitionTail: (tail: Promise) => void + readonly storageFailure: () => unknown + readonly markStorageFailure: (error: unknown) => void +} + +export function createTransitionHost( + input: Omit< + TransitionHost, + | 'state' + | 'setState' + | 'transitionTail' + | 'setTransitionTail' + | 'storageFailure' + | 'markStorageFailure' + > & { + readonly state: () => BraidState + readonly setState: (state: BraidState) => void + readonly transitionTail: () => Promise + readonly setTransitionTail: (tail: Promise) => void + readonly storageFailure: () => unknown + readonly markStorageFailure: (error: unknown) => void + }, +): TransitionHost { + return input +} + +export function commitEvent(host: TransitionHost, event: BraidEvent): void { + commitEventInternal(host, event, false) +} + +/** + * Commits the one event needed to describe an uncertain external outcome. + * + * A failed journal write remains a fail-closed application state, but the + * terminal event gets one explicit retry so the journal can record whether + * the provider outcome is unknown. + */ +export function commitEventRecovery(host: TransitionHost, event: BraidEvent): void { + commitEventInternal(host, event, true) +} + +function commitEventInternal(host: TransitionHost, event: BraidEvent, recovery: boolean): void { + if (!recovery && host.storageFailure() !== undefined) + throw new AppError( + 'STORAGE_FAILURE', + 'Durable storage is unavailable; reopen Braid to continue', + ) + if (host.asynchronous) { + void commitEventAndWaitInternal(host, event, recovery).catch(() => undefined) + return + } + try { + host.setState( + commitApplicationEvent({ + state: host.state(), + event, + journal: host.journal, + clock: host.clock, + providerEventKeys: host.providerEventKeys, + subscribers: host.subscribers, + }), + ) + } catch (error) { + host.markStorageFailure(error) + throw error + } +} + +export function commitEventAndWait(host: TransitionHost, event: BraidEvent): Promise { + return commitEventAndWaitInternal(host, event, false) +} + +export function commitEventAndWaitRecovery(host: TransitionHost, event: BraidEvent): Promise { + return commitEventAndWaitInternal(host, event, true) +} + +export function commitEventsAndWaitAtRevision( + host: TransitionHost, + events: readonly BraidEvent[], + expectedRevision?: number, +): Promise { + if (events.length === 0) return Promise.resolve() + assertExpectedRevision(host.state(), expectedRevision) + if (!host.asynchronous) { + for (const event of events) commitEvent(host, event) + return Promise.resolve() + } + + const predecessor = host.transitionTail() + const task = predecessor.then(async () => { + const failure = host.storageFailure() + if (failure !== undefined) throw failure + assertExpectedRevision(host.state(), expectedRevision) + for (const event of events) { + host.setState( + await commitApplicationEventAsync({ + state: host.state(), + event, + journal: host.journal, + clock: host.clock, + providerEventKeys: host.providerEventKeys, + subscribers: host.subscribers, + }), + ) + } + }) + const next = task.catch((error: unknown) => { + if (!isRevisionError(error)) host.markStorageFailure(error) + }) + host.setTransitionTail(next) + return task +} + +function assertExpectedRevision(state: BraidState, expectedRevision: number | undefined): void { + if (expectedRevision === undefined) return + if (!Number.isInteger(expectedRevision) || expectedRevision < 0) { + throw new AppError( + 'INVALID_EXPECTED_REVISION', + 'expectedRevision must be a non-negative integer', + ) + } + if (state.revision !== expectedRevision) { + throw new AppError( + 'STALE_REVISION', + `The application changed at revision ${state.revision}; expected ${expectedRevision}`, + ) + } +} + +function isRevisionError(error: unknown): boolean { + return ( + error instanceof AppError && + (error.code === 'STALE_REVISION' || error.code === 'INVALID_EXPECTED_REVISION') + ) +} + +function commitEventAndWaitInternal( + host: TransitionHost, + event: BraidEvent, + recovery: boolean, +): Promise { + if (!host.asynchronous) { + if (recovery) commitEventRecovery(host, event) + else commitEvent(host, event) + return Promise.resolve() + } + const predecessor = recovery + ? host.transitionTail().catch(() => undefined) + : host.transitionTail() + const task = predecessor.then(async () => { + const failure = host.storageFailure() + if (!recovery && failure !== undefined) throw failure + host.setState( + await commitApplicationEventAsync({ + state: host.state(), + event, + journal: host.journal, + clock: host.clock, + providerEventKeys: host.providerEventKeys, + subscribers: host.subscribers, + }), + ) + }) + const next = task.catch((error: unknown) => { + host.markStorageFailure(error) + throw error + }) + host.setTransitionTail(next) + return task +} diff --git a/src/app/application-types.ts b/src/app/application-types.ts new file mode 100644 index 0000000..a259978 --- /dev/null +++ b/src/app/application-types.ts @@ -0,0 +1,93 @@ +import type { BraidControlKind, BraidEventEnvelope } from '../domain/events.js' +import type { + ContextTransferReceipt, + NativeContextBoundaryProof, + PortableContextPlan, + RunAdmissionReceipt, +} from '../domain/receipts.js' +import type { BraidState, RunStatus } from '../domain/state.js' +import type { ControlAcknowledgement } from '../ports/execution.js' + +export type AppSubscriber = (state: BraidState, envelope: BraidEventEnvelope) => void + +export interface SendInput { + readonly operationId: string + readonly text: string + readonly conversationId?: string + readonly branchId?: string + readonly sessionId?: string + readonly contextPlan?: PortableContextPlan + readonly contextTransfer?: ContextTransferReceipt + readonly nativeContextBoundaryProof?: NativeContextBoundaryProof +} + +export interface SendReceipt { + readonly operationId: string + readonly runId: string + readonly revision: number + readonly replayed: boolean + readonly admission: RunAdmissionReceipt + /** Resolves when admission and run.requested are durably committed. */ + readonly admissionReady?: Promise + readonly completion: Promise +} + +export interface QueueReceipt { + readonly operationId: string + readonly runId: string + readonly position: number + readonly revision: number + readonly completion?: Promise +} + +export interface ControlReceipt { + readonly operationId: string + readonly runId: string + readonly control: BraidControlKind + readonly acknowledgement: ControlAcknowledgement + readonly status: RunStatus + readonly completion: Promise +} + +export interface ShutdownReceipt { + readonly operationId: string + readonly revision: number + readonly replayed: boolean + readonly outcome?: 'idle' | 'detached' | 'cancelled' | 'waiting' + readonly runId?: string + readonly state?: BraidState + readonly completion: Promise +} + +export interface ShutdownRecord { + readonly digest: string + readonly completion: Promise +} + +export interface OperationRecord { + readonly digest: string + readonly runId: string + readonly admission: RunAdmissionReceipt + completion: Promise +} + +export interface ControlOperationRecord { + readonly digest: string + readonly runId: string + readonly control: BraidControlKind + readonly completion: Promise + readonly acknowledgement: Promise + readonly providerSessionId?: string + readonly reason?: string + readonly text?: string + readonly cursor?: string +} + +export interface InteractionReceipt { + readonly operationId: string + readonly runId: string + readonly interactionId: string + readonly replayed: boolean + readonly acknowledgement: ControlAcknowledgement + readonly completion: Promise +} diff --git a/src/app/application.ts b/src/app/application.ts index 5e74aa8..6448cbf 100644 --- a/src/app/application.ts +++ b/src/app/application.ts @@ -1,84 +1,271 @@ -import type { AgentProfile } from '@tangle-network/agent-interface' -import type { RuntimeStreamEvent } from '@tangle-network/agent-runtime' -import { canonicalDigest } from '../domain/canonical.js' -import type { BraidEvent, BraidEventEnvelope, TurnUsage } from '../domain/events.js' -import { reduceEvent } from '../domain/reducer.js' -import { initialState, type BraidState } from '../domain/state.js' +import { randomUUID } from 'node:crypto' +import type { AgentProfile, InteractionResponse } from '@tangle-network/agent-interface' +import type { BraidEvent, BraidEventEnvelope } from '../domain/events.js' +import { providerEventKey } from '../domain/events.js' +import { assertBraidState } from '../domain/invariants.js' +import type { + ContextTransferReceipt, + NativeContextBoundaryProof, + RunAdmissionReceipt, +} from '../domain/receipts.js' +import { redactSensitiveText } from '../domain/redaction.js' +import { replayEvents } from '../domain/reducer.js' +import type { RuntimeEventEnvelope } from '../domain/runtime-events.js' +import { type BraidState, initialState } from '../domain/state.js' import type { Clock } from '../ports/clock.js' -import type { ExecutionPort } from '../ports/execution.js' +import type { ExecuteTurnInput, ExecutionPort } from '../ports/execution.js' import type { IdSource } from '../ports/ids.js' +import { admissionIsAsync, assertWritable, operationId } from './application-guards.js' +import type { BraidApplicationOptions, CancelInput, CancelReceipt } from './application-options.js' +import type { PortViews } from './application-port-builder.js' +import type { + ControlEffectRequest, + ReconnectInput, + RuntimeEventIngestionResult, +} from './application-ports.js' +import { wireApplicationRuntime } from './application-runtime-wiring.js' +import type { + AppSubscriber, + ControlReceipt, + InteractionReceipt, + QueueReceipt, + SendInput, + SendReceipt, + ShutdownReceipt, +} from './application-types.js' +import { executeControlEffect } from './control-effects.js' +import { createConversationActions } from './conversation-composition.js' +import { ConversationOperationCoordinator } from './conversation-operation-coordinator.js' +import type { ConversationActions } from './conversations.js' +import { createDurableSender } from './durable-send.js' +import { SerializedEffectCoordinator } from './effect-coordinator.js' +import { projectEffectRecord } from './effect-projection.js' +import { AppError } from './errors.js' +import { FailClosedJournal } from './fail-closed-journal.js' +import { createIntelligenceActions, type IntelligenceActions } from './intelligence-actions.js' +import { respondInteraction as respondInteractionController } from './interaction-controller.js' import { MemoryJournal } from './journal.js' +import { legacyCancel } from './legacy-cancel.js' +import { + admitRun, + continueNative, + runEffectRequest, + sendRun, + sendRunAsync, + validateNativeProof, +} from './run-admission.js' +import { cancelRun, detachRun, queueRunInput, steerRun } from './run-controls.js' +import type { RunExecutionSnapshot } from './run-execution-snapshot.js' +import { snapshotRunExecution } from './run-execution-snapshot.js' +import { ingestRuntimeEvent } from './run-ingestion.js' +import { createRunLedger } from './run-ledger.js' +import { reconcileRun, reconnectRun } from './run-replay.js' +import { isTerminal, waitForIdle } from './run-status.js' +import { shutdownApplication } from './shutdown-controller.js' -export type AppSubscriber = (state: BraidState, envelope: BraidEventEnvelope) => void +export type { SendInput, SendReceipt } from './application-types.js' +export { AppError } from './errors.js' -export interface SendInput { - readonly operationId: string - readonly text: string - readonly conversationId?: string - readonly branchId?: string -} - -export interface SendReceipt { - readonly operationId: string - readonly runId: string - readonly revision: number - readonly replayed: boolean - readonly completion: Promise -} - -interface OperationRecord { - readonly digest: string - readonly runId: string - completion: Promise -} +import { + type ApplicationJournal, + admitPersistedSend, + isEffectStorage, + reconcileRestartRun, + restoreApplicationOperations, +} from './application-support.js' +import { + commitEvent, + commitEventAndWait, + commitEventAndWaitRecovery, + commitEventRecovery, + type TransitionHost, +} from './application-transition.js' +import { + type ConfigurationActionTransition, + createConfigurationActionTransition, +} from './configuration-action-transition.js' +import { createInMemoryOperationFingerprint } from './operation-fingerprint.js' +import { RuntimeSelection } from './runtime-selection.js' -export class AppError extends Error { - readonly code: string +export type { BraidApplicationOptions, CancelInput, CancelReceipt } from './application-options.js' - constructor(code: string, message: string) { - super(message) - this.name = 'AppError' - this.code = code - } -} - -function usageFromFinal(event: Extract): TurnUsage { - const metadata = event.metadata ?? {} - const tokenUsage = - metadata.tokenUsage && typeof metadata.tokenUsage === 'object' - ? (metadata.tokenUsage as Record) - : {} - const input = typeof tokenUsage.input === 'number' ? tokenUsage.input : 0 - const output = typeof tokenUsage.output === 'number' ? tokenUsage.output : 0 - const costUsd = typeof metadata.costUsd === 'number' ? metadata.costUsd : undefined - const model = typeof metadata.model === 'string' ? metadata.model : undefined - return { - input, - output, - ...(costUsd === undefined ? {} : { costUsd }), - ...(model === undefined ? {} : { model }), - } -} +const MAX_MESSAGE_BYTES = 1024 * 1024 +const DEFAULT_CANCEL_TIMEOUT_MS = 5_000 export class BraidApplication { + readonly conversations: ConversationActions + readonly intelligence: IntelligenceActions + readonly configuration: ConfigurationActionTransition + readonly runtimeSelection: RuntimeSelection readonly #execution: ExecutionPort + readonly #executionProfile: Readonly readonly #ids: IdSource - readonly #journal: MemoryJournal - readonly #operations = new Map() + readonly #clock: Clock + readonly #journal: ApplicationJournal + readonly #effects: SerializedEffectCoordinator + readonly #ledger = createRunLedger() + readonly #conversationOperations = new ConversationOperationCoordinator() readonly #subscribers = new Set() + readonly #controlOwner = `braid-control-${randomUUID()}` + readonly #cancelTimeoutMs: number + readonly #asynchronousJournal: boolean + readonly #portViews: PortViews + readonly #transition: TransitionHost + readonly #durableSender: (input: RunExecutionSnapshot) => SendReceipt + #transitionTail: Promise = Promise.resolve() + #storageFailure: unknown + #cleanupUncertain: string | undefined + #restartReconciliation: Promise = Promise.resolve() #state: BraidState - #activeAbort: AbortController | undefined - - constructor(options: { - readonly profile: Readonly - readonly execution: ExecutionPort - readonly clock: Clock - readonly ids: IdSource - }) { + + constructor(options: BraidApplicationOptions) { this.#execution = options.execution + this.#executionProfile = structuredClone(options.profile) + this.runtimeSelection = new RuntimeSelection(this.#executionProfile) this.#ids = options.ids - this.#journal = new MemoryJournal(options.clock) - this.#state = initialState(structuredClone(options.profile)) + this.#clock = options.clock + const fallback = + options.journal === undefined && options.effectStorage === undefined + ? new MemoryJournal(options.clock) + : new FailClosedJournal(options.clock) + this.#journal = options.journal ?? fallback + this.#asynchronousJournal = this.#journal.asynchronous === true + const effectStorage = + options.effectStorage ?? (isEffectStorage(this.#journal) ? this.#journal : fallback) + const fallbackFingerprint = createInMemoryOperationFingerprint() + const fingerprint = (input: { + readonly effectKind: string + readonly request: unknown + }): string => + (effectStorage as import('../ports/effect-storage.js').EffectStoragePort).fingerprint?.( + input, + ) ?? fallbackFingerprint.fingerprint(input) + this.#effects = + options.effectCoordinator ?? + new SerializedEffectCoordinator(effectStorage, options.clock, { + onRecord: (record) => this.#recordEffect(record), + }) + this.#cancelTimeoutMs = options.cancelTimeoutMs ?? DEFAULT_CANCEL_TIMEOUT_MS + + const persisted = this.#journal.replay?.() ?? this.#journal.all() + for (const envelope of persisted) { + const key = providerEventKey(envelope.event) + if (key) this.#ledger.addProviderEvent(key) + } + const restored = this.#journal.initialState?.() + const baseState = + restored ?? + initialState(this.#executionProfile, { + ...(options.conversationId === undefined ? {} : { conversationId: options.conversationId }), + ...(options.branchId === undefined ? {} : { branchId: options.branchId }), + }) + this.#state = replayEvents(baseState, persisted) + assertBraidState(this.#state) + this.runtimeSelection.syncFromState(this.#state) + const runtime = wireApplicationRuntime({ + currentState: () => this.#state, + setState: (state) => { + this.#state = state + }, + profile: () => this.runtimeSelection.profile(), + commit: (event) => this.#commit(event), + commitAndWait: (event) => { + if (this.#asynchronousJournal) return this.#commitAndWait(event) + this.#commit(event) + return undefined + }, + commitAndWaitRecovery: (event) => { + if (this.#asynchronousJournal) return this.#commitAndWaitRecovery(event) + commitEventRecovery(this.#transition, event) + return undefined + }, + execution: this.#execution, + ledger: this.#ledger, + clock: this.#clock, + ids: this.#ids, + effects: this.#effects, + journal: this.#journal, + subscribers: this.#subscribers, + asynchronousJournal: this.#asynchronousJournal, + transitionTail: () => this.#transitionTail, + setTransitionTail: (tail) => { + this.#transitionTail = tail + }, + storageFailure: () => this.#storageFailure, + markStorageFailure: (error) => { + this.#storageFailure ??= error + }, + flush: () => this.whenDurable(), + executeControl: (input) => this.#executeControl(input), + admitPersistedSend: (operationId, digest) => + admitPersistedSend({ + effects: this.#effects, + operations: this.#ledger, + state: () => this.#state, + operationId, + digest, + }), + fingerprint, + send: (input) => this.send(input), + }) + this.#portViews = runtime.ports + this.#transition = runtime.transition + this.configuration = createConfigurationActionTransition(this.#transition) + this.intelligence = createIntelligenceActions( + { + currentState: () => this.#state, + eventHistory: () => this.#journal.all(), + commit: (event) => this.#commit(event), + commitAndWait: (event) => { + if (this.#asynchronousJournal) return this.#commitAndWait(event) + this.#commit(event) + return undefined + }, + now: () => this.#clock.now(), + }, + options.intelligence, + ) + this.conversations = createConversationActions({ + state: () => this.#state, + now: () => this.#clock.now(), + commit: async (event) => { + if (this.#asynchronousJournal) await this.#commitAndWait(event) + else this.#commit(event) + }, + coordinate: (input, action) => + this.#conversationOperations.run(input.operationId, input.digest, action), + ...(options.conversationStorage === undefined + ? {} + : { storage: options.conversationStorage }), + }) + restoreApplicationOperations(persisted, { + state: () => this.#state, + ledger: this.#ledger, + }) + const runReconciliation = reconcileRestartRun(this.#portViews.restart) + this.#restartReconciliation = runReconciliation + .then(() => this.conversations.lifecycle.reconcilePendingDeletes()) + .catch((error: unknown) => { + this.#storageFailure ??= error + throw error + }) + this.#durableSender = createDurableSender({ + currentState: () => this.#state, + ids: this.#ids, + restartReconciliation: this.#restartReconciliation, + transitionTail: () => this.#transitionTail, + admitPersistedSend: (operationId, digest) => + admitPersistedSend({ + effects: this.#effects, + operations: this.#ledger, + state: () => this.#state, + operationId, + digest, + }), + requestDigest: (_state, value) => + fingerprint({ effectKind: 'run.execute', request: runEffectRequest(value) }), + sendAsync: (value, ids) => sendRunAsync(this.#portViews.asyncAdmission, value, ids), + }) } state(): BraidState { @@ -89,6 +276,28 @@ export class BraidApplication { return this.#journal.all() } + storageFailure(): string | undefined { + if (this.#storageFailure === undefined) return undefined + return this.#storageFailure instanceof Error ? this.#storageFailure.message : 'Storage failure' + } + + cleanupUncertain(): string | undefined { + return this.#cleanupUncertain + } + + markCleanupUncertain(reason: string): void { + this.#cleanupUncertain = redactSensitiveText(reason).slice(0, 512) + const runId = this.#state.activeRunId + if (runId) this.#ledger.getAbort(runId)?.abort(new Error('Cleanup deadline exceeded')) + } + + async whenDurable(): Promise { + await this.#restartReconciliation + await this.#transitionTail + await this.#journal.flush?.() + if (this.#storageFailure !== undefined) throw this.#storageFailure + } + subscribe(subscriber: AppSubscriber): () => void { this.#subscribers.add(subscriber) return () => this.#subscribers.delete(subscriber) @@ -97,154 +306,223 @@ export class BraidApplication { initialize(workspace: string): BraidState { if (!workspace) throw new AppError('INVALID_WORKSPACE', 'Workspace must not be empty') if (this.#state.workspace === workspace) return this.state() - if (this.#state.workspace !== null) { + if (this.#state.workspace !== null) throw new AppError('ALREADY_INITIALIZED', 'Braid is already initialized') - } this.#commit({ kind: 'workspace.opened', workspace }) return this.state() } send(input: SendInput): SendReceipt { - const text = input.text - if (this.#state.workspace === null) { - throw new AppError('NOT_INITIALIZED', 'Initialize a workspace before sending') + assertWritable(this.#storageFailure) + operationId(input.operationId, 'send') + 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 snapshot = snapshotRunExecution( + input, + this.#state, + this.runtimeSelection.profile(), + this.#state.selectedConnectionId ?? undefined, + ) + validateNativeProof(this.#portViews.admission, snapshot) + if (this.#asynchronousJournal || admissionIsAsync(this.#execution)) { + assertWritable(this.#storageFailure) + return this.#durableSender(snapshot) } - if (!input.operationId) { - throw new AppError('OPERATION_ID_REQUIRED', 'send requires operationId') + try { + return sendRun(this.#portViews.admission, snapshot) + } catch (error) { + if (!(error instanceof AppError) || error.code !== 'ASYNC_ADMISSION_REQUIRED') throw error + return this.#durableSender(snapshot) } - if (!text.trim()) throw new AppError('EMPTY_MESSAGE', 'Message must not be empty') + } - const conversationId = input.conversationId ?? this.#state.conversationId - const branchId = input.branchId ?? this.#state.branchId - if (conversationId !== this.#state.conversationId || branchId !== this.#state.branchId) { - throw new AppError('UNKNOWN_BRANCH', 'The requested conversation branch is not open') + queueInput(input: { + readonly operationId: string + readonly text: string + readonly runId?: string + }): QueueReceipt { + return queueRunInput(this.#portViews.queue, input) + } + + async steer(input: { + readonly operationId: string + readonly runId?: string + readonly text: string + }): Promise { + return steerRun(this.#portViews.control, input) + } + + async cancelRun(input: { + readonly operationId: string + readonly runId?: string + readonly reason?: string + readonly terminalStatus?: 'cancelled' | 'aborted' + readonly legacy?: boolean + }): Promise { + return cancelRun(this.#portViews.control, input) + } + + cancel(input: CancelInput): CancelReceipt { + return legacyCancel( + { + state: () => this.#state, + snapshot: () => this.state(), + ledger: this.#ledger, + cancelRun: (value) => this.cancelRun(value), + }, + input, + ) + } + + cancelActive(): boolean { + const runId = this.#state.activeRunId + if (!runId) return false + try { + this.cancel({ operationId: this.#ids.next('operation'), runId }) + return true + } catch { + return false } + } - const digest = canonicalDigest({ - command: 'send', - conversationId, - branchId, - text, - profile: this.#state.profile, + canCancel(): boolean { + const runId = this.#state.activeRunId + const run = runId ? this.#state.runs.find((candidate) => candidate.id === runId) : undefined + const abort = runId ? this.#ledger.getAbort(runId) : undefined + return Boolean(run && !isTerminal(run.status) && run.capabilities.controls.cancel && abort) + } + + async detachRun(input: { + readonly operationId: string + readonly runId?: string + }): Promise { + return detachRun(this.#portViews.control, input) + } + + async respondInteraction(input: { + readonly operationId: string + readonly runId: string + readonly interactionId: string + readonly response: InteractionResponse + }): Promise { + const opId = operationId(input.operationId, 'respond-interaction') + return respondInteractionController({ + operationId: opId, + runId: input.runId, + interactionId: input.interactionId, + response: input.response, + state: this.#portViews.state, + events: () => this.events(), + commitAndWait: this.#portViews.journal.commitAndWait, + ledger: this.#ledger, + effects: this.#effects, + execution: this.#execution, + owner: this.#controlOwner, + whenDurable: () => this.whenDurable(), }) - const previous = this.#operations.get(input.operationId) - if (previous) { - if (previous.digest !== digest) { - throw new AppError( - 'OPERATION_CONFLICT', - `Operation ${input.operationId} was already used with different input`, - ) - } - return { - operationId: input.operationId, - runId: previous.runId, - revision: this.#state.revision, - replayed: true, - completion: previous.completion.then(() => this.state()), - } - } - if (this.#state.activeRunId) { - throw new AppError('RUN_ACTIVE', `Run ${this.#state.activeRunId} is still active`) - } + } - if (this.#state.draft !== text) this.#commit({ kind: 'draft.changed', text }) - const runId = this.#ids.next('run') - const turnId = this.#ids.next('turn') - this.#commit({ - kind: 'run.requested', - operationId: input.operationId, - runId, - turnId, - userMessageId: this.#ids.next('message'), - assistantMessageId: this.#ids.next('message'), - text, + async #executeControl( + input: ControlEffectRequest, + ): Promise { + return executeControlEffect({ + effects: this.#effects, + execution: this.#execution, + request: input, + owner: this.#controlOwner, + timeoutMs: this.#cancelTimeoutMs, + whenDurable: () => this.whenDurable(), }) + } - const operation: OperationRecord = { - digest, - runId, - completion: Promise.resolve(), - } - this.#operations.set(input.operationId, operation) - this.#activeAbort = new AbortController() - operation.completion = this.#execute(input.operationId, runId, text, this.#activeAbort) - - return { - operationId: input.operationId, - runId, - revision: this.#state.revision, - replayed: false, - completion: operation.completion.then(() => this.state()), - } + async reconnectRun(input: ReconnectInput): Promise { + return reconnectRun(this.#portViews.replay, input) } - cancelActive(): boolean { - if (!this.#activeAbort || this.#activeAbort.signal.aborted) return false - this.#activeAbort.abort(new Error('Cancelled by user')) - return true + async reconcileRun(input: ReconnectInput): Promise { + return reconcileRun(this.#portViews.replay, input) + } + + async continueNative(input: { + readonly operationId: string + readonly text: string + readonly runId?: string + }): Promise { + return continueNative(this.#portViews.nativeContinuation, input) + } + + shutdown(input: { + readonly operationId: string + readonly mode?: 'wait' | 'detach' | 'cancel' + }): ShutdownReceipt { + const opId = operationId(input.operationId, 'shutdown') + return shutdownApplication({ + operationId: opId, + ...(input.mode === undefined ? {} : { mode: input.mode }), + state: () => this.#state, + ledger: this.#ledger, + commit: (event) => this.#commit(event), + cancelRun: (value) => this.cancelRun(value), + detachRun: (value) => this.detachRun(value), + waitForIdle: () => this.waitForIdle(), + }) } async waitForIdle(): Promise { - const activeRun = this.#state.activeRunId - if (!activeRun) return this.state() - const operation = [...this.#operations.values()].find((entry) => entry.runId === activeRun) - if (operation) await operation.completion - return this.state() + return waitForIdle(this.#portViews.status) } - async #execute( - operationId: string, - runId: string, - text: string, - abort: AbortController, - ): Promise { - let terminalSeen = false + async close(): Promise { + let failure: unknown try { - const stream = this.#execution.streamTurn({ - operationId, - runId, - text, - profile: this.#state.profile, - signal: abort.signal, - }) - for await (const runtimeEvent of stream) { - if (runtimeEvent.type === 'text_delta' && runtimeEvent.text) { - this.#commit({ kind: 'run.text.delta', runId, text: runtimeEvent.text }) - } else if (runtimeEvent.type === 'final') { - terminalSeen = true - this.#commit({ - kind: 'run.finished', - runId, - status: runtimeEvent.status, - finalText: runtimeEvent.text ?? '', - usage: usageFromFinal(runtimeEvent), - ...(runtimeEvent.error ? { error: runtimeEvent.error.message } : {}), - }) - } - } - if (!terminalSeen) throw new Error('Runtime stream ended without a final event') + await this.whenDurable() } catch (error) { - if (!terminalSeen) { - const message = error instanceof Error ? error.message : String(error) - this.#commit({ - kind: 'run.finished', - runId, - status: abort.signal.aborted ? 'aborted' : 'failed', - finalText: '', - usage: { input: 0, output: 0 }, - error: message, - }) - } + failure = error } finally { - if (this.#activeAbort === abort) this.#activeAbort = undefined + await this.#journal.close?.() } + if (failure !== undefined) throw failure + } + + ingestRuntimeEvent(envelope: RuntimeEventEnvelope): RuntimeEventIngestionResult { + return ingestRuntimeEvent(this.#portViews.ingestion, envelope) as RuntimeEventIngestionResult + } + + admit( + input: ExecuteTurnInput, + conversationId: string, + branchId: string, + contextTransfer?: ContextTransferReceipt, + turnId?: string, + contextPlanDigest?: string, + nativeContextBoundaryProof?: NativeContextBoundaryProof, + ): RunAdmissionReceipt { + return admitRun( + this.#portViews.admission, + input, + conversationId, + branchId, + contextTransfer, + turnId, + contextPlanDigest, + nativeContextBoundaryProof, + ) + } + + #recordEffect(record: import('../ports/effect-storage.js').EffectRecord): void { + this.#commit({ kind: 'effect.upserted', effect: projectEffectRecord(record) }) } #commit(event: BraidEvent): void { - const envelope = this.#journal.envelope(this.#state, event) - const nextState = reduceEvent(this.#state, envelope) - this.#journal.append(envelope) - this.#state = nextState - for (const subscriber of this.#subscribers) subscriber(this.state(), structuredClone(envelope)) + commitEvent(this.#transition, event) + } + + #commitAndWait(event: BraidEvent): Promise { + return commitEventAndWait(this.#transition, event) + } + + #commitAndWaitRecovery(event: BraidEvent): Promise { + return commitEventAndWaitRecovery(this.#transition, event) } } diff --git a/src/app/automation-matching.ts b/src/app/automation-matching.ts new file mode 100644 index 0000000..b73df11 --- /dev/null +++ b/src/app/automation-matching.ts @@ -0,0 +1,181 @@ +import type { InteractionRequest } from '@tangle-network/agent-interface' +import { canonicalDigest } from '../domain/canonical.js' +import type { AutomationRuleMatcher, AutomationRuleRecord } from '../domain/entities-runtime.js' +import type { BraidInteraction } from '../domain/runtime-projection.js' +import type { AutomationRuleScope } from '../domain/entities-interactions.js' +import { interactionHasSecretField } from './interaction-response.js' + +/** + * Optional metadata that is stored with an existing Braid rule record without + * changing the provider or agent-interface contracts. + */ +export interface AutomationRuleMetadata { + readonly providerSessionId?: string + readonly creationSource?: 'manual' | 'imported' +} + +export type StoredAutomationRule = AutomationRuleRecord & { + readonly matcher: AutomationRuleMatcher & AutomationRuleRuleSession + readonly automationMetadata?: AutomationRuleMetadata +} + +interface AutomationRuleRuleSession { + readonly providerSessionId?: string +} + +export interface AutomationEvaluationContext { + readonly profileDigest?: string + readonly connectionId?: string + readonly runner?: string + readonly workspaceId?: string + readonly providerSessionId?: string + readonly now: string +} + +export interface AutomationEvaluation { + readonly status: 'eligible' | 'none' | 'conflict' | 'expired' | 'use-limit' | 'disabled' + readonly rule?: StoredAutomationRule + readonly matchingRules: readonly StoredAutomationRule[] + readonly skippedRules: readonly { + readonly rule: StoredAutomationRule + readonly reason: 'expired' | 'use-limit' | 'disabled' + }[] + readonly detail?: string +} + +export function evaluateAutomation( + rules: readonly AutomationRuleRecord[], + interaction: Pick, + context: AutomationEvaluationContext, +): AutomationEvaluation { + if (interactionHasSecretField(interaction.request)) + return { + status: 'none', + matchingRules: [], + skippedRules: [], + detail: 'AUTOMATION_SECRET_FORBIDDEN', + } + const matchingRules: StoredAutomationRule[] = [] + const skippedRules: AutomationEvaluation['skippedRules'][number][] = [] + for (const candidate of rules) { + const rule = candidate as StoredAutomationRule + if (!matches(rule.matcher, interaction.request, context)) continue + if (!rule.enabled) { + skippedRules.push({ rule, reason: 'disabled' }) + continue + } + if (isExpired(rule, context.now)) { + skippedRules.push({ rule, reason: 'expired' }) + continue + } + if (rule.maximumUses !== undefined && rule.uses >= rule.maximumUses) { + skippedRules.push({ rule, reason: 'use-limit' }) + continue + } + matchingRules.push(rule) + } + + if (matchingRules.length === 0) { + const reason = skippedRules[0]?.reason + return { + status: reason ?? 'none', + matchingRules: [], + skippedRules, + ...(reason === undefined ? {} : { detail: `AUTOMATION_RULE_${reason.toUpperCase()}` }), + } + } + + const ordered = [...matchingRules].sort(compareRules) + const topSpecificity = specificity(ordered[0]?.matcher ?? {}) + const top = ordered.filter((rule) => specificity(rule.matcher) === topSpecificity) + const answerDigests = new Set(top.map((rule) => canonicalDigest(rule.answer))) + const scopes = new Set(top.map((rule) => rule.responseScope)) + if (answerDigests.size > 1 || scopes.size > 1) { + return { + status: 'conflict', + matchingRules: ordered, + skippedRules, + detail: 'AUTOMATION_RULE_CONFLICT', + } + } + const rule = ordered[0] + if (rule === undefined) + return { + status: 'none', + matchingRules: [], + skippedRules, + detail: 'AUTOMATION_RULE_NONE', + } + return { status: 'eligible', rule, matchingRules: ordered, skippedRules } +} + +export function automationSubjectValue(request: InteractionRequest): string | undefined { + const subject = request.subject + if (subject === undefined) return undefined + switch (subject.type) { + case 'tool': + return subject.toolName + case 'command': + return subject.command + case 'file': + return subject.path + case 'resource': + return subject.uri + default: { + const exhaustive: never = subject + return exhaustive + } + } +} + +export function automationRuleScopeIsOffered( + request: InteractionRequest, + scope: AutomationRuleScope, +): boolean { + const offered = new Set(request.responseScopes ?? ['interaction']) + return offered.has(scope === 'once' ? 'interaction' : scope) +} + +export function automationRuleAnswerDigest(rule: Pick): string { + return canonicalDigest(rule.answer) +} + +function matches( + matcher: AutomationRuleMatcher & AutomationRuleRuleSession, + request: InteractionRequest, + context: AutomationEvaluationContext, +): boolean { + if (matcher.interactionKind !== undefined && matcher.interactionKind !== request.kind) + return false + if (matcher.subjectType !== undefined && matcher.subjectType !== request.subject?.type) + return false + if ( + matcher.subjectValue !== undefined && + matcher.subjectValue !== automationSubjectValue(request) + ) + return false + if (!matchesValue(matcher.profileDigest, context.profileDigest)) return false + if (!matchesValue(matcher.connectionId, context.connectionId)) return false + if (!matchesValue(matcher.runner, context.runner)) return false + if (!matchesValue(matcher.workspaceId, context.workspaceId)) return false + if (!matchesValue(matcher.providerSessionId, context.providerSessionId)) return false + return true +} + +function matchesValue(expected: string | undefined, actual: string | undefined): boolean { + return expected === undefined || (actual !== undefined && expected === actual) +} + +function isExpired(rule: AutomationRuleRecord, now: string): boolean { + return rule.expiresAt !== undefined && Date.parse(now) >= Date.parse(rule.expiresAt) +} + +function specificity(matcher: AutomationRuleMatcher & AutomationRuleRuleSession): number { + return Object.values(matcher).filter((value) => value !== undefined).length +} + +function compareRules(left: StoredAutomationRule, right: StoredAutomationRule): number { + const specificityDifference = specificity(right.matcher) - specificity(left.matcher) + if (specificityDifference !== 0) return specificityDifference + return left.id < right.id ? -1 : left.id > right.id ? 1 : 0 +} diff --git a/src/app/automation-rule-audit.ts b/src/app/automation-rule-audit.ts new file mode 100644 index 0000000..debde12 --- /dev/null +++ b/src/app/automation-rule-audit.ts @@ -0,0 +1,276 @@ +import { canonicalDigest } from '../domain/canonical.js' +import type { AutomationAuditRecord, AutomationRuleScope } from '../domain/entities-interactions.js' +import type { BraidEventEnvelope } from '../domain/events.js' +import { + createFeedbackDecisionId, + createOperationId, + type Digest, + type RuleId, +} from '../domain/ids.js' +import type { BraidInteraction } from '../domain/runtime-projection.js' +import type { BraidState } from '../domain/state.js' +import { + evaluateAutomation, + type AutomationEvaluation, + type AutomationEvaluationContext, + type StoredAutomationRule, +} from './automation-matching.js' +import { commit, reserveRuleUse } from './automation-rule-persistence.js' +import type { + ApplyAutomationInput, + ApplyAutomationReceipt, + AutomationDryRunInput, + AutomationDryRunReceipt, +} from './automation-rule-types.js' +import { + assertAutomationSafe, + evaluationContext, + interactionRequestDigest, + requiredOperationId, +} from './automation-rule-validation.js' +import { checkInteractionResponse } from './interaction-response.js' +import { AppError } from './errors.js' + +export async function dryRunAutomation( + input: AutomationDryRunInput, +): Promise { + const operationId = requiredOperationId(input.operationId) + assertAutomationSafe(input.interaction.request) + const context = evaluationContext(input.context, input.now?.() ?? new Date().toISOString()) + const commandDigest = canonicalDigest({ + kind: 'automation.rule.dry-run', + interaction: interactionRequestDigest(input.interaction.request), + context, + }) + const prior = findAuditOperation(input.events(), operationId) + if (prior !== undefined) { + if (prior.requestDigest !== commandDigest) + throw new AppError('OPERATION_CONFLICT', `Operation ${operationId} has different input`) + return { + operationId, + replayed: true, + evaluation: evaluationFromAudit(input.state(), input.interaction, context, prior), + revision: input.state().revision, + } + } + const evaluation = evaluateAutomation(input.state().rules, input.interaction, context) + const audit = automationAudit({ + operationId, + interaction: input.interaction, + requestDigest: commandDigest, + outcome: evaluation.status === 'eligible' ? 'dry-run' : auditOutcomeForEvaluation(evaluation), + ...(evaluation.rule === undefined ? {} : { ruleId: evaluation.rule.id }), + ...(evaluation.detail === undefined ? {} : { detail: evaluation.detail }), + createdAt: context.now, + }) + await commit(input, { kind: 'interaction.automation.audited', audit }) + return { + operationId, + replayed: false, + evaluation, + revision: input.state().revision, + } +} + +export async function applyAutomation( + input: ApplyAutomationInput, +): Promise { + const operationId = requiredOperationId(input.operationId) + assertAutomationSafe(input.interaction.request) + const context = evaluationContext(input.context, input.now?.() ?? new Date().toISOString()) + const requestDigest = interactionRequestDigest(input.interaction.request) + const prior = findAuditOperation(input.events(), operationId) + if (prior !== undefined) { + if (prior.requestDigest !== requestDigest) + throw new AppError('OPERATION_CONFLICT', `Operation ${operationId} has different input`) + return { + operationId, + replayed: true, + evaluation: evaluateAutomation(input.state().rules, input.interaction, context), + revision: input.state().revision, + } + } + const evaluation = evaluateAutomation(input.state().rules, input.interaction, context) + if (evaluation.status !== 'eligible' || evaluation.rule === undefined) { + await commit(input, { + kind: 'interaction.automation.audited', + audit: automationAudit({ + operationId, + interaction: input.interaction, + requestDigest, + outcome: auditOutcomeForEvaluation(evaluation), + ...(evaluation.rule === undefined ? {} : { ruleId: evaluation.rule.id }), + ...(evaluation.detail === undefined ? {} : { detail: evaluation.detail }), + createdAt: context.now, + }), + }) + return { operationId, replayed: false, evaluation, revision: input.state().revision } + } + + const checked = checkInteractionResponse(input.interaction.request, { + id: input.interaction.request.id, + outcome: 'accepted', + data: evaluation.rule.answer, + }) + if (checked.containsSecret || checked.publicData === undefined) + throw new AppError( + 'AUTOMATION_SECRET_FORBIDDEN', + 'Automation is unavailable for interactions containing secret answers', + ) + const reserved = await reserveRuleUse(input, evaluation.rule, operationId, context.now) + if (!reserved) { + const refreshed = evaluateAutomation(input.state().rules, input.interaction, context) + return { operationId, replayed: true, evaluation: refreshed, revision: input.state().revision } + } + const response = await input.respond(checked.response, { automated: true }) + const applied = + response.acknowledgement.outcome === 'accepted' || + response.acknowledgement.outcome === 'already-applied' + await commit(input, { + kind: 'interaction.automation.audited', + audit: automationAudit({ + operationId, + interaction: input.interaction, + requestDigest, + outcome: applied ? 'applied' : 'matched', + ruleId: evaluation.rule.id, + ...(checked.dataDigest === undefined ? {} : { responseDigest: checked.dataDigest }), + responseScope: evaluation.rule.responseScope, + ...(response.acknowledgement.detail === undefined + ? {} + : { detail: response.acknowledgement.detail }), + createdAt: context.now, + }), + }) + return { + operationId, + replayed: response.replayed, + evaluation, + response, + revision: input.state().revision, + } +} + +export function automationAudits( + events: readonly BraidEventEnvelope[], +): readonly AutomationAuditRecord[] { + return events.flatMap((envelope) => + envelope.event.kind === 'interaction.automation.audited' ? [envelope.event.audit] : [], + ) +} + +function automationAudit(input: { + readonly operationId?: string + readonly interaction: BraidInteraction + readonly requestDigest: Digest + readonly responseDigest?: Digest + readonly responseScope?: AutomationRuleScope + readonly ruleId?: RuleId + readonly outcome: AutomationAuditRecord['outcome'] + readonly detail?: string + readonly createdAt: string +}): AutomationAuditRecord { + const id = createFeedbackDecisionId( + `feedback-${canonicalDigest({ + operationId: input.operationId ?? null, + runId: input.interaction.runId, + interactionId: input.interaction.request.id, + requestDigest: input.requestDigest, + ruleId: input.ruleId ?? null, + outcome: input.outcome, + }).slice(0, 48)}`, + ) + return { + id, + runId: input.interaction.runId as AutomationAuditRecord['runId'], + interactionId: input.interaction.request.id as AutomationAuditRecord['interactionId'], + ...(input.ruleId === undefined ? {} : { ruleId: input.ruleId }), + ...(input.operationId === undefined + ? {} + : { operationId: createOperationId(input.operationId) }), + requestDigest: input.requestDigest, + ...(input.responseDigest === undefined ? {} : { responseDigest: input.responseDigest }), + ...(input.responseScope === undefined ? {} : { responseScope: input.responseScope }), + outcome: input.outcome, + ...(input.detail === undefined ? {} : { detail: safeAuditDetail(input.detail) }), + createdAt: input.createdAt, + } +} + +function safeAuditDetail(value: string): string { + return /^AUTOMATION_[A-Z0-9_]{1,96}$/u.test(value) ? value : 'AUTOMATION_RESPONSE_RESULT' +} + +function findAuditOperation( + events: readonly BraidEventEnvelope[], + operationId: string, +): AutomationAuditRecord | undefined { + for (let index = events.length - 1; index >= 0; index -= 1) { + const event = events[index]?.event + if (event?.kind === 'interaction.automation.audited' && event.audit.operationId === operationId) + return event.audit + } + return undefined +} + +function evaluationFromAudit( + state: BraidState, + interaction: BraidInteraction, + context: AutomationEvaluationContext, + audit: AutomationAuditRecord, +): AutomationEvaluation { + const current = evaluateAutomation(state.rules, interaction, context) + if (audit.outcome !== 'dry-run') { + const status = auditStatus(audit.outcome) + if (status === undefined) return current + const { rule: _rule, ...withoutRule } = current + return { ...withoutRule, status } + } + if (audit.ruleId === undefined) return current + const rule = state.rules.find((candidate) => candidate.id === audit.ruleId) as + | StoredAutomationRule + | undefined + return rule === undefined ? current : { ...current, status: 'eligible', rule } +} + +function auditStatus( + outcome: AutomationAuditRecord['outcome'], +): AutomationEvaluation['status'] | undefined { + switch (outcome) { + case 'skipped': + return 'none' + case 'conflict': + return 'conflict' + case 'expired': + return 'expired' + case 'use-limit': + return 'use-limit' + case 'disabled': + return 'disabled' + default: + return undefined + } +} + +function auditOutcomeForEvaluation( + evaluation: AutomationEvaluation, +): AutomationAuditRecord['outcome'] { + switch (evaluation.status) { + case 'expired': + return 'expired' + case 'use-limit': + return 'use-limit' + case 'disabled': + return 'disabled' + case 'conflict': + return 'conflict' + case 'eligible': + return 'matched' + case 'none': + return 'skipped' + default: { + const exhaustive: never = evaluation.status + return exhaustive + } + } +} diff --git a/src/app/automation-rule-persistence.ts b/src/app/automation-rule-persistence.ts new file mode 100644 index 0000000..de3c313 --- /dev/null +++ b/src/app/automation-rule-persistence.ts @@ -0,0 +1,195 @@ +import { canonicalDigest } from '../domain/canonical.js' +import type { BraidEvent, BraidEventEnvelope } from '../domain/events.js' +import { createOperationId, type Digest } from '../domain/ids.js' +import type { StoredAutomationRule } from './automation-matching.js' +import type { + ApplyAutomationInput, + AutomationRuleReceipt, + AutomationStoreInput, + CreateAutomationRuleInput, + RuleMutationReceipt, +} from './automation-rule-types.js' +import { + assertOperationDigest, + normalizeAutomationRule, + parseRuleId, + requiredOperationId, +} from './automation-rule-validation.js' +import { AppError } from './errors.js' + +export async function createAutomationRule( + input: CreateAutomationRuleInput, +): Promise { + const normalized = normalizeAutomationRule(input) + const prior = findRuleOperation(input.events(), normalized.operationId) + if (prior !== undefined) { + assertOperationDigest(prior, normalized.digest, normalized.operationId) + const stored = findRuleEvent(input.events(), normalized.operationId) + if (stored === undefined) + throw new AppError('OPERATION_REQUIRES_RECONCILIATION', 'The rule write needs reconciliation') + return { + operationId: normalized.operationId, + ruleId: stored.id, + rule: stored, + replayed: true, + revision: input.state().revision, + } + } + await commit(input, { + kind: 'rule.upserted', + rule: normalized.rule, + operation: operationRecord(normalized.operationId, normalized.digest, normalized.now), + }) + return { + operationId: normalized.operationId, + ruleId: normalized.ruleId, + rule: normalized.rule, + replayed: false, + revision: input.state().revision, + } +} + +export async function disableAutomationRule( + input: AutomationStoreInput & { readonly operationId: string; readonly ruleId: string }, +): Promise { + return await mutateRule(input, false) +} + +export async function deleteAutomationRule( + input: AutomationStoreInput & { readonly operationId: string; readonly ruleId: string }, +): Promise { + const operationId = requiredOperationId(input.operationId) + const ruleId = parseRuleId(input.ruleId) + const digest = canonicalDigest({ kind: 'automation.rule.delete', ruleId }) + const prior = findRuleOperation(input.events(), operationId) + if (prior !== undefined) { + assertOperationDigest(prior, digest, operationId) + return { + operationId, + ruleId, + replayed: true, + revision: input.state().revision, + } + } + if (!input.state().rules.some((rule) => rule.id === ruleId)) + throw new AppError('AUTOMATION_RULE_NOT_FOUND', 'The automation rule does not exist') + const now = input.now?.() ?? new Date().toISOString() + await commit(input, { + kind: 'rule.deleted', + ruleId, + operation: operationRecord(operationId, digest, now), + }) + return { + operationId, + ruleId, + replayed: false, + revision: input.state().revision, + } +} + +export async function reserveRuleUse( + input: ApplyAutomationInput, + rule: StoredAutomationRule, + operationId: string, + now: string, +): Promise { + const reservationId = `operation-${canonicalDigest({ kind: 'automation.rule.use', operationId, ruleId: rule.id }).slice(0, 48)}` + const reservationDigest = canonicalDigest({ + kind: 'automation.rule.use', + operationId, + ruleId: rule.id, + uses: rule.uses + 1, + }) + const previous = findRuleOperation(input.events(), reservationId) + if (previous !== undefined) { + assertOperationDigest(previous, reservationDigest, operationId) + return false + } + const nextRule: StoredAutomationRule = { ...rule, uses: rule.uses + 1 } + await commit(input, { + kind: 'rule.upserted', + rule: nextRule, + operation: operationRecord(reservationId, reservationDigest, now), + }) + return true +} + +export async function commit(input: AutomationStoreInput, event: BraidEvent): Promise { + const result = input.commitAndWait(event) + if (result !== undefined) await result +} + +async function mutateRule( + input: AutomationStoreInput & { readonly operationId: string; readonly ruleId: string }, + enabled: boolean, +): Promise { + const operationId = requiredOperationId(input.operationId) + const ruleId = parseRuleId(input.ruleId) + const current = input.state().rules.find((rule) => rule.id === ruleId) as + | StoredAutomationRule + | undefined + if (current === undefined) + throw new AppError('AUTOMATION_RULE_NOT_FOUND', 'The automation rule does not exist') + const digest = canonicalDigest({ kind: 'automation.rule.disable', ruleId, enabled }) + const prior = findRuleOperation(input.events(), operationId) + if (prior !== undefined) { + assertOperationDigest(prior, digest, operationId) + return { + operationId, + ruleId, + rule: current, + replayed: true, + revision: input.state().revision, + } + } + const now = input.now?.() ?? new Date().toISOString() + const rule: StoredAutomationRule = { ...current, enabled } + await commit(input, { + kind: 'rule.upserted', + rule, + operation: operationRecord(operationId, digest, now), + }) + return { + operationId, + ruleId, + rule, + replayed: false, + revision: input.state().revision, + } +} + +function operationRecord(operationId: string, requestDigest: Digest, now: string) { + return { + id: createOperationId(operationId), + kind: 'custom' as const, + requestDigest, + status: 'terminal' as const, + createdAt: now, + updatedAt: now, + } +} + +function findRuleOperation( + events: readonly BraidEventEnvelope[], + operationId: string, +): { readonly id: string; readonly requestDigest: Digest } | undefined { + for (const envelope of events) { + const event = envelope.event + if (event.kind === 'rule.upserted' && event.operation?.id === operationId) + return event.operation + if (event.kind === 'rule.deleted' && event.operation.id === operationId) return event.operation + } + return undefined +} + +function findRuleEvent( + events: readonly BraidEventEnvelope[], + operationId: string, +): StoredAutomationRule | undefined { + for (let index = events.length - 1; index >= 0; index -= 1) { + const event = events[index]?.event + if (event?.kind === 'rule.upserted' && event.operation?.id === operationId) + return event.rule as StoredAutomationRule + } + return undefined +} diff --git a/src/app/automation-rule-types.ts b/src/app/automation-rule-types.ts new file mode 100644 index 0000000..edfe0d7 --- /dev/null +++ b/src/app/automation-rule-types.ts @@ -0,0 +1,103 @@ +import type { InteractionRequest, InteractionResponse } from '@tangle-network/agent-interface' +import type { + AutomationAuditRecord, + AutomationRuleScope, + NonSecretInteractionData, +} from '../domain/entities-interactions.js' +import type { AutomationRuleMatcher } from '../domain/entities-runtime.js' +import type { BraidEventEnvelope } from '../domain/events.js' +import type { Digest, RuleId } from '../domain/ids.js' +import type { BraidInteraction } from '../domain/runtime-projection.js' +import type { BraidState } from '../domain/state.js' +import type { JournalWriter } from './application-ports.js' +import type { InteractionReceipt } from './application-types.js' +import type { + AutomationEvaluation, + AutomationEvaluationContext, + AutomationRuleMetadata, + StoredAutomationRule, +} from './automation-matching.js' + +export interface AutomationContext { + readonly profileDigest?: Digest + readonly connectionId?: string + readonly runner?: string + readonly workspaceId?: string + readonly providerSessionId?: string +} + +export interface AutomationStoreInput { + readonly state: () => BraidState + readonly events: () => readonly BraidEventEnvelope[] + readonly commitAndWait: JournalWriter['commitAndWait'] + readonly now?: () => string +} + +export interface CreateAutomationRuleInput extends AutomationStoreInput { + readonly operationId: string + readonly ruleId: string + readonly request: InteractionRequest + readonly answer: NonSecretInteractionData + readonly responseScope: AutomationRuleScope + readonly matcher?: AutomationRuleMatcher + readonly context?: AutomationContext + readonly expiresAt?: string + readonly maximumUses?: number + readonly confirmPersistent?: boolean + readonly creationSource?: AutomationRuleMetadata['creationSource'] +} + +export interface AutomationRuleReceipt { + readonly operationId: string + readonly ruleId: RuleId + readonly rule: StoredAutomationRule + readonly replayed: boolean + readonly revision: number +} + +export interface RuleMutationReceipt { + readonly operationId: string + readonly ruleId: RuleId + readonly rule?: StoredAutomationRule + readonly replayed: boolean + readonly revision: number +} + +export interface AutomationDryRunInput extends AutomationStoreInput { + readonly operationId: string + readonly interaction: BraidInteraction + readonly context: AutomationContext +} + +export interface AutomationDryRunReceipt { + readonly operationId: string + readonly replayed: boolean + readonly evaluation: AutomationEvaluation + readonly revision: number +} + +export interface ApplyAutomationInput extends AutomationStoreInput { + readonly operationId: string + readonly interaction: BraidInteraction + readonly context: AutomationContext + readonly respond: ( + response: InteractionResponse, + options: { readonly automated: true }, + ) => Promise +} + +export interface ApplyAutomationReceipt { + readonly operationId: string + readonly replayed: boolean + readonly evaluation: AutomationEvaluation + readonly response?: InteractionReceipt + readonly revision: number +} + +export type { AutomationAuditRecord, AutomationRuleScope, NonSecretInteractionData } +export type { + AutomationEvaluation, + AutomationEvaluationContext, + AutomationRuleMetadata, + StoredAutomationRule, +} diff --git a/src/app/automation-rule-validation.ts b/src/app/automation-rule-validation.ts new file mode 100644 index 0000000..d0f8190 --- /dev/null +++ b/src/app/automation-rule-validation.ts @@ -0,0 +1,267 @@ +import { InteractionRequestSchema, type InteractionRequest } from '@tangle-network/agent-interface' +import { canonicalDigest } from '../domain/canonical.js' +import type { AutomationRuleScope } from '../domain/entities-interactions.js' +import type { AutomationRuleMatcher } from '../domain/entities-runtime.js' +import { createOperationId, createRuleId, type Digest, type RuleId } from '../domain/ids.js' +import { AppError } from './errors.js' +import { + automationRuleAnswerDigest, + automationRuleScopeIsOffered, + automationSubjectValue, + type StoredAutomationRule, +} from './automation-matching.js' +import { checkInteractionResponse, interactionHasSecretField } from './interaction-response.js' +import type { AutomationContext, CreateAutomationRuleInput } from './automation-rule-types.js' + +export interface NormalizedAutomationRule { + readonly operationId: string + readonly ruleId: RuleId + readonly rule: StoredAutomationRule + readonly digest: Digest + readonly now: string +} + +export function normalizeAutomationRule( + input: CreateAutomationRuleInput, +): NormalizedAutomationRule { + const operationId = requiredOperationId(input.operationId) + const request = parseRequest(input.request) + assertAutomationSafe(request) + const ruleId = parseRuleId(input.ruleId) + assertAutomationScope(request, input.responseScope, input.confirmPersistent === true) + const checked = checkInteractionResponse(request, { + id: request.id, + outcome: 'accepted', + data: input.answer, + }) + if (checked.containsSecret || checked.publicData === undefined) + throw new AppError( + 'AUTOMATION_SECRET_FORBIDDEN', + 'Automation is unavailable for interactions containing secret answers', + ) + const now = input.now?.() ?? new Date().toISOString() + assertExpiry(input.expiresAt, now) + assertMaximumUses(input.maximumUses) + const matcher = buildMatcher(request, input.matcher, input.context, input.responseScope) + assertScopeContext(input.responseScope, matcher) + const rule: StoredAutomationRule = { + id: ruleId, + enabled: true, + matcher, + answer: structuredClone(checked.publicData), + responseScope: input.responseScope, + createdAt: now, + ...(input.expiresAt === undefined ? {} : { expiresAt: input.expiresAt }), + ...(input.maximumUses === undefined ? {} : { maximumUses: input.maximumUses }), + uses: 0, + ...(input.creationSource === undefined && input.context?.providerSessionId === undefined + ? {} + : { + automationMetadata: { + ...(input.creationSource === undefined ? {} : { creationSource: input.creationSource }), + ...(input.context?.providerSessionId === undefined + ? {} + : { providerSessionId: input.context.providerSessionId }), + }, + }), + } + const requestDigest = interactionRequestDigest(request) + const digest = canonicalDigest({ + kind: 'automation.rule.create', + ruleId, + requestDigest, + matcher, + answerDigest: automationRuleAnswerDigest(rule), + responseScope: rule.responseScope, + expiresAt: rule.expiresAt ?? null, + maximumUses: rule.maximumUses ?? null, + }) + return { operationId, ruleId, rule, digest, now } +} + +export function assertAutomationSafe(request: InteractionRequest): void { + if (interactionHasSecretField(request)) + throw new AppError( + 'AUTOMATION_SECRET_FORBIDDEN', + 'Automation is unavailable for interactions containing secret answers', + ) +} + +export function assertOperationDigest( + prior: { readonly requestDigest: Digest }, + digest: Digest, + operationId: string, +): void { + if (prior.requestDigest !== digest) + throw new AppError('OPERATION_CONFLICT', `Operation ${operationId} has different input`) +} + +export function requiredOperationId(value: string): string { + if (!value) throw new AppError('INVALID_OPERATION_ID', 'Automation operationId is required') + try { + createOperationId(value) + } catch { + throw new AppError('INVALID_OPERATION_ID', 'Automation operationId is invalid') + } + return value +} + +export function parseRuleId(value: string): RuleId { + try { + return createRuleId(value) + } catch { + throw new AppError('INVALID_AUTOMATION_RULE', 'Automation rule id is invalid') + } +} + +export function evaluationContext( + context: AutomationContext, + now: string, +): import('./automation-matching.js').AutomationEvaluationContext { + return { ...context, now } +} + +export function interactionRequestDigest(request: InteractionRequest): Digest { + return canonicalDigest({ + id: request.id, + kind: request.kind, + title: request.title, + body: request.body ?? null, + subject: + request.subject === undefined + ? null + : { type: request.subject.type, value: automationSubjectValue(request) ?? null }, + answerSpec: request.answerSpec, + responseScopes: request.responseScopes ?? ['interaction'], + timeoutMs: request.timeoutMs ?? null, + onTimeout: request.onTimeout ?? null, + }) +} + +function parseRequest(value: InteractionRequest): InteractionRequest { + const parsed = InteractionRequestSchema.safeParse(value) + if (!parsed.success) + throw new AppError('INVALID_INTERACTION_REQUEST', 'Interaction request is invalid') + return parsed.data +} + +function assertAutomationScope( + request: InteractionRequest, + scope: AutomationRuleScope, + confirmedPersistent: boolean, +): void { + if (!automationRuleScopeIsOffered(request, scope)) + throw new AppError( + 'AUTOMATION_SCOPE_UNSUPPORTED', + `The interaction does not offer ${scope} responses`, + ) + if (scope === 'persistent' && !confirmedPersistent) + throw new AppError( + 'AUTOMATION_CONFIRMATION_REQUIRED', + 'Persistent automation requires explicit confirmation of its scope', + ) +} + +function assertScopeContext( + scope: AutomationRuleScope, + matcher: AutomationRuleMatcher & { readonly providerSessionId?: string }, +): void { + if (scope === 'session' && matcher.providerSessionId === undefined) + throw new AppError( + 'AUTOMATION_SCOPE_CONTEXT_REQUIRED', + 'Session automation requires an exact provider session binding', + ) + if ( + scope === 'persistent' && + matcher.profileDigest === undefined && + matcher.connectionId === undefined && + matcher.runner === undefined && + matcher.workspaceId === undefined + ) + throw new AppError( + 'AUTOMATION_SCOPE_CONTEXT_REQUIRED', + 'Persistent automation requires an explicit profile, connection, runner, or workspace scope', + ) +} + +function assertExpiry(expiresAt: string | undefined, now: string): void { + if (expiresAt === undefined) return + const expires = Date.parse(expiresAt) + if (!Number.isFinite(expires) || expires <= Date.parse(now)) + throw new AppError('AUTOMATION_EXPIRY_INVALID', 'Automation expiry must be in the future') +} + +function assertMaximumUses(maximumUses: number | undefined): void { + if ( + maximumUses !== undefined && + (!Number.isInteger(maximumUses) || maximumUses < 1 || maximumUses > 1_000_000) + ) + throw new AppError( + 'AUTOMATION_USE_LIMIT_INVALID', + 'Automation use limit must be a positive integer', + ) +} + +function buildMatcher( + request: InteractionRequest, + matcher: AutomationRuleMatcher | undefined, + context: AutomationContext | undefined, + responseScope: AutomationRuleScope, +): AutomationRuleMatcher & { readonly providerSessionId?: string } { + const subjectValue = automationSubjectValue(request) + assertMatcherIdentity(request, matcher) + const base = { + interactionKind: request.kind, + ...(request.subject === undefined ? {} : { subjectType: request.subject.type }), + ...(subjectValue === undefined ? {} : { subjectValue }), + ...(context?.profileDigest === undefined ? {} : { profileDigest: context.profileDigest }), + ...(context?.connectionId === undefined ? {} : { connectionId: context.connectionId }), + ...(context?.runner === undefined ? {} : { runner: context.runner }), + ...(context?.workspaceId === undefined ? {} : { workspaceId: context.workspaceId }), + ...(responseScope === 'session' && context?.providerSessionId === undefined + ? {} + : responseScope === 'session' + ? { providerSessionId: context?.providerSessionId } + : {}), + } + return { + ...base, + ...(matcher?.profileDigest === undefined && context?.profileDigest === undefined + ? {} + : { profileDigest: matcher?.profileDigest ?? context?.profileDigest }), + ...(matcher?.connectionId === undefined && context?.connectionId === undefined + ? {} + : { connectionId: matcher?.connectionId ?? context?.connectionId }), + ...(matcher?.runner === undefined && context?.runner === undefined + ? {} + : { runner: matcher?.runner ?? context?.runner }), + ...(matcher?.workspaceId === undefined && context?.workspaceId === undefined + ? {} + : { workspaceId: matcher?.workspaceId ?? context?.workspaceId }), + } as AutomationRuleMatcher & { + readonly providerSessionId?: string + } +} + +function assertMatcherIdentity( + request: InteractionRequest, + matcher: AutomationRuleMatcher | undefined, +): void { + if (matcher?.interactionKind !== undefined && matcher.interactionKind !== request.kind) + throw new AppError( + 'AUTOMATION_MATCHER_CONFLICT', + 'An automation matcher cannot target another interaction kind', + ) + const subject = request.subject + if (matcher?.subjectType !== undefined && matcher.subjectType !== subject?.type) + throw new AppError( + 'AUTOMATION_MATCHER_CONFLICT', + 'An automation matcher cannot target another subject type', + ) + const subjectValue = automationSubjectValue(request) + if (matcher?.subjectValue !== undefined && matcher.subjectValue !== subjectValue) + throw new AppError( + 'AUTOMATION_MATCHER_CONFLICT', + 'An automation matcher cannot target another subject value', + ) +} diff --git a/src/app/automation-rules.ts b/src/app/automation-rules.ts new file mode 100644 index 0000000..d9b3b68 --- /dev/null +++ b/src/app/automation-rules.ts @@ -0,0 +1,28 @@ +export { + applyAutomation, + automationAudits, + dryRunAutomation, +} from './automation-rule-audit.js' +export { + createAutomationRule, + deleteAutomationRule, + disableAutomationRule, +} from './automation-rule-persistence.js' +export { interactionRequestDigest } from './automation-rule-validation.js' +export type { + ApplyAutomationInput, + ApplyAutomationReceipt, + AutomationContext, + AutomationDryRunInput, + AutomationDryRunReceipt, + AutomationRuleReceipt, + AutomationStoreInput, + CreateAutomationRuleInput, + RuleMutationReceipt, +} from './automation-rule-types.js' +export type { + AutomationEvaluation, + AutomationEvaluationContext, + AutomationRuleMetadata, + StoredAutomationRule, +} from './automation-matching.js' diff --git a/src/app/composition.ts b/src/app/composition.ts index 79eb1de..4606957 100644 --- a/src/app/composition.ts +++ b/src/app/composition.ts @@ -1,9 +1,30 @@ -import { defineAgentProfile, type AgentProfile } from '@tangle-network/agent-interface' -import { AgentRuntimeExecutionPort } from '../adapters/runtime/agent-runtime-execution.js' -import { FixedClock, SystemClock, type Clock } from '../ports/clock.js' -import { RandomIds, SequenceIds, type IdSource } from '../ports/ids.js' -import { deterministicBackend, unconfiguredBackend } from '../testing/deterministic-backend.js' +import { type AgentProfile, defineAgentProfile } from '@tangle-network/agent-interface' +import type { HeadlessKeySource } from '../adapters/credentials/headless-key.js' +import { createOperatingSystemCredentialStore } from '../adapters/credentials/os.js' +import { + AgentRuntimeExecutionPort, + type AgentTurnBackendResolver, +} from '../adapters/runtime/agent-runtime-execution.js' +import { UnavailableExecutionPort } from '../adapters/runtime/unavailable-execution.js' +import { openSqliteStorage, type SqliteStorage } from '../adapters/storage/sqlite.js' +import { type Clock, FixedClock, SystemClock } from '../ports/clock.js' +import { type CredentialPort, credentialRef } from '../ports/credentials.js' +import type { EffectStoragePort, JournalPort } from '../ports/effect-storage.js' +import type { ExecutionPort } from '../ports/execution.js' +import { type IdSource, RandomIds, SequenceIds } from '../ports/ids.js' +import { deterministicBackend } from '../testing/deterministic-backend.js' import { BraidApplication } from './application.js' +import type { SerializedEffectCoordinator } from './effect-coordinator.js' +import { FailClosedJournal } from './fail-closed-journal.js' +import type { IntelligenceActionsOptions } from './intelligence-actions.js' +import { createMemoryJournal } from './journal.js' +import { + createProductionComposition, + type ProductionCompositionConfig, + ProductionCompositionError, +} from './production-composition.js' +import { StorageJournal } from './storage-journal.js' +import { UnavailableAnalyst } from './unavailable-analyst.js' export const STARTER_PROFILE: Readonly = defineAgentProfile({ name: 'Braid starter', @@ -26,21 +47,196 @@ export interface CompositionOptions { readonly ids?: IdSource readonly profile?: Readonly readonly chunkDelayMs?: number + readonly journal?: JournalPort + readonly effectStorage?: EffectStoragePort + readonly effectCoordinator?: SerializedEffectCoordinator + readonly cancelTimeoutMs?: number + readonly execution?: ExecutionPort + readonly backendResolver?: AgentTurnBackendResolver + readonly production?: ProductionCompositionConfig + readonly intelligence?: IntelligenceActionsOptions +} + +export interface DurableCompositionOptions + extends Omit { + readonly path: string + /** Directory that may contain Braid's encrypted database and backups. */ + readonly storageRoot?: string + /** Project directory materialized for agent execution. */ + readonly workspaceRoot?: string + readonly credentialStore?: CredentialPort + readonly databaseKeyRef?: import('../ports/credentials.js').CredentialRef + readonly databaseKeySource?: HeadlessKeySource + readonly backupDirectory?: string + readonly execution?: ExecutionPort + readonly backendResolver?: AgentTurnBackendResolver +} + +export interface DurableBraidApplication { + readonly app: BraidApplication + readonly storage: SqliteStorage +} + +export type ProductionBraidApplicationOptions = Omit + +function isEffectStorage(value: JournalPort): value is JournalPort & EffectStoragePort { + return ( + typeof (value as Partial).reserveEffect === 'function' && + typeof (value as Partial).current === 'function' && + typeof (value as Partial).latest === 'function' && + typeof (value as Partial).appendEffect === 'function' && + typeof (value as Partial).history === 'function' + ) +} + +function withProductionCredentialOptions( + config: ProductionCompositionConfig, + credentialStore: CredentialPort, +): ProductionCompositionConfig { + const connectionOptions = config.connectionOptions ?? {} + return { + ...config, + connectionOptions: { + ...connectionOptions, + credentials: connectionOptions.credentials ?? credentialStore, + credentialRefResolver: + connectionOptions.credentialRefResolver ?? ((ref) => credentialRef(`cred:v1:${ref}`)), + }, + } } export function createBraidApplication(options: CompositionOptions = {}): BraidApplication { const isFixture = options.fixture === 'deterministic' - const execution = new AgentRuntimeExecutionPort((input) => - isFixture - ? deterministicBackend(input, { - ...(options.chunkDelayMs === undefined ? {} : { chunkDelayMs: options.chunkDelayMs }), - }) - : unconfiguredBackend(input), - ) + if (isFixture && options.production !== undefined) { + throw new ProductionCompositionError( + 'PRODUCTION_FIXTURE_FORBIDDEN', + 'The deterministic fixture cannot be combined with production configuration', + ) + } + const directCredentialStore = + options.production?.connectionOptions?.credentials ?? + (options.production === undefined ? undefined : createOperatingSystemCredentialStore()) + const production = + options.production === undefined + ? undefined + : createProductionComposition( + withProductionCredentialOptions( + options.production, + directCredentialStore ?? createOperatingSystemCredentialStore(), + ), + ) + const execution = + options.execution ?? + (isFixture + ? new AgentRuntimeExecutionPort( + (input) => + deterministicBackend(input, { + ...(options.chunkDelayMs === undefined ? {} : { chunkDelayMs: options.chunkDelayMs }), + }), + async () => ({ status: 'cancelled' as const }), + { admissionMode: 'sync' }, + ) + : (production?.execution ?? + (options.backendResolver + ? new AgentRuntimeExecutionPort(options.backendResolver) + : new UnavailableExecutionPort()))) + const clock = options.clock ?? (isFixture ? new FixedClock() : new SystemClock()) + const journal = + options.journal ?? (isFixture ? createMemoryJournal(clock) : new FailClosedJournal(clock)) + const effectStorage = + options.effectStorage ?? (isEffectStorage(journal) ? journal : new FailClosedJournal(clock)) + const intelligence = + options.intelligence ?? (isFixture ? undefined : { analyst: new UnavailableAnalyst() }) return new BraidApplication({ - profile: options.profile ?? (isFixture ? DETERMINISTIC_PROFILE : STARTER_PROFILE), + profile: + options.profile ?? + production?.profile ?? + (isFixture ? DETERMINISTIC_PROFILE : STARTER_PROFILE), execution, - clock: options.clock ?? (isFixture ? new FixedClock() : new SystemClock()), + clock, ids: options.ids ?? (isFixture ? new SequenceIds() : new RandomIds()), + journal, + effectStorage, + ...(intelligence === undefined ? {} : { intelligence }), + ...(options.effectCoordinator === undefined + ? {} + : { effectCoordinator: options.effectCoordinator }), + ...(options.cancelTimeoutMs === undefined ? {} : { cancelTimeoutMs: options.cancelTimeoutMs }), + }) +} + +/** Strict entry point used by the normal binary after production config loads. */ +export function createProductionBraidApplication( + options: ProductionBraidApplicationOptions = {}, +): BraidApplication { + if (options.production === undefined) { + throw new ProductionCompositionError( + 'PRODUCTION_CONFIGURATION_REQUIRED', + 'Production startup requires a canonical profile and one configured connection', + ) + } + return createBraidApplication(options) +} + +export async function createDurableBraidApplication( + options: DurableCompositionOptions, +): Promise { + const clock = options.clock ?? new SystemClock() + const credentialStore = options.credentialStore ?? createOperatingSystemCredentialStore() + const production = + options.production === undefined + ? undefined + : createProductionComposition( + withProductionCredentialOptions( + { + ...options.production, + ...(options.workspaceRoot === undefined + ? {} + : { workspaceRoot: options.workspaceRoot }), + }, + credentialStore, + ), + ) + const storage = await openSqliteStorage({ + path: options.path, + credentialStore, + ...(options.storageRoot === undefined && options.workspaceRoot === undefined + ? {} + : { workspaceRoot: options.storageRoot ?? options.workspaceRoot }), + ...(options.databaseKeyRef === undefined ? {} : { databaseKeyRef: options.databaseKeyRef }), + ...(options.databaseKeySource === undefined + ? {} + : { databaseKeySource: options.databaseKeySource }), + ...(options.backupDirectory === undefined ? {} : { backupDirectory: options.backupDirectory }), }) + try { + const scopedJournal = await StorageJournal.fromStorage(storage, clock) + const app = new BraidApplication({ + profile: options.profile ?? production?.profile ?? STARTER_PROFILE, + execution: + options.execution ?? + production?.execution ?? + (options.backendResolver + ? new AgentRuntimeExecutionPort(options.backendResolver) + : new UnavailableExecutionPort()), + clock, + ids: options.ids ?? new RandomIds(), + journal: scopedJournal, + effectStorage: storage, + conversationStorage: storage, + ...(options.intelligence === undefined + ? { intelligence: { analyst: new UnavailableAnalyst() } } + : { intelligence: options.intelligence }), + ...(options.effectCoordinator === undefined + ? {} + : { effectCoordinator: options.effectCoordinator }), + ...(options.cancelTimeoutMs === undefined + ? {} + : { cancelTimeoutMs: options.cancelTimeoutMs }), + }) + return { app, storage } + } catch (error) { + await storage.close().catch(() => undefined) + throw error + } } diff --git a/src/app/configuration-action-transition.ts b/src/app/configuration-action-transition.ts new file mode 100644 index 0000000..cc5062a --- /dev/null +++ b/src/app/configuration-action-transition.ts @@ -0,0 +1,102 @@ +import type { ConnectionRecord, OperationRecord, ProfileRecord } from '../domain/entities.js' +import type { BraidEvent } from '../domain/events.js' +import type { BraidState } from '../domain/state.js' +import { commitEventsAndWaitAtRevision, type TransitionHost } from './application-transition.js' + +export type ConfigurationOperation = OperationRecord & { + readonly kind: 'profile-save' | 'connection-change' +} + +export interface ConfigurationActionTransition { + readonly state: () => BraidState + readonly requestOperation: (input: { + readonly operation: ConfigurationOperation + readonly expectedRevision?: number + }) => Promise + readonly failOperation: (operation: ConfigurationOperation) => Promise + readonly selectProfile: (input: { + readonly profile: ProfileRecord + readonly operation: ConfigurationOperation & { readonly kind: 'profile-save' } + readonly expectedRevision?: number + }) => Promise + readonly saveProfile: (input: { + readonly profile: ProfileRecord + readonly operation: ConfigurationOperation & { readonly kind: 'profile-save' } + readonly select: boolean + }) => Promise + readonly selectConnection: (input: { + readonly connection: ConnectionRecord + readonly operation: ConfigurationOperation & { readonly kind: 'connection-change' } + readonly expectedRevision?: number + }) => Promise + readonly updateConnectionHealth: (input: { + readonly connection: ConnectionRecord + readonly operation: ConfigurationOperation & { readonly kind: 'connection-change' } + }) => Promise +} + +export function createConfigurationActionTransition( + host: TransitionHost, +): ConfigurationActionTransition { + const commit = (events: readonly BraidEvent[], expectedRevision?: number): Promise => + commitEventsAndWaitAtRevision(host, events, expectedRevision).then(() => host.state()) + const requestOperation: ConfigurationActionTransition['requestOperation'] = ({ + operation, + expectedRevision, + }) => commit([{ kind: 'operation.requested', operation }], expectedRevision) + const failOperation: ConfigurationActionTransition['failOperation'] = (operation) => + commit([{ kind: 'operation.updated', operation }]) + const selectProfile: ConfigurationActionTransition['selectProfile'] = ({ + profile, + operation, + expectedRevision, + }) => { + const events: BraidEvent[] = [] + if (!host.state().profiles.some((candidate) => candidate.id === profile.id)) { + events.push({ kind: 'profile.registered', profile }) + } + events.push({ kind: 'profile.selected', profileId: profile.id }) + events.push({ kind: 'operation.updated', operation }) + return commit(events, expectedRevision) + } + const saveProfile: ConfigurationActionTransition['saveProfile'] = ({ + profile, + operation, + select, + }) => { + const events: BraidEvent[] = [{ kind: 'profile.registered', profile }] + if (select) events.push({ kind: 'profile.selected', profileId: profile.id }) + events.push({ kind: 'operation.updated', operation }) + return commit(events) + } + const selectConnection: ConfigurationActionTransition['selectConnection'] = ({ + connection, + operation, + expectedRevision, + }) => { + const events: BraidEvent[] = [] + if (!host.state().connections.some((candidate) => candidate.id === connection.id)) { + events.push({ kind: 'connection.upserted', connection }) + } + events.push({ kind: 'connection.selected', connectionId: connection.id }) + events.push({ kind: 'operation.updated', operation }) + return commit(events, expectedRevision) + } + const updateConnectionHealth: ConfigurationActionTransition['updateConnectionHealth'] = ({ + connection, + operation, + }) => + commit([ + { kind: 'connection.upserted', connection }, + { kind: 'operation.updated', operation }, + ]) + return Object.freeze({ + state: () => host.state(), + requestOperation, + failOperation, + selectProfile, + saveProfile, + selectConnection, + updateConnectionHealth, + }) +} diff --git a/src/app/configuration-session.ts b/src/app/configuration-session.ts new file mode 100644 index 0000000..ae502e0 --- /dev/null +++ b/src/app/configuration-session.ts @@ -0,0 +1,331 @@ +import type { ConnectionHealth, ConnectionKind, ConnectionRecord } from '../domain/entities.js' +import { compareCodeUnits } from '../domain/code-unit-order.js' +import type { Digest } from '../domain/ids.js' +import { ConnectionRegistry } from './connections.js' +import type { ProfileRecord } from './profile-types.js' + +export type ConfigurationStep = 'profile' | 'connection' | 'confirm' | 'complete' | 'cancelled' + +export type ConfigurationBackTarget = 'profile' | 'connection' + +export type ConfigurationErrorCode = + | 'PROFILE_NOT_FOUND' + | 'CONNECTION_NOT_FOUND' + | 'PROFILE_REQUIRED' + | 'CONNECTION_REQUIRED' + | 'NO_PROFILES' + | 'NO_CONNECTIONS' + | 'ALREADY_FINISHED' + +export class ConfigurationSessionError extends Error { + readonly code: ConfigurationErrorCode + + constructor(code: ConfigurationErrorCode, message: string) { + super(message) + this.name = 'ConfigurationSessionError' + this.code = code + } +} + +export interface ProfileChoice { + readonly id: ProfileRecord['id'] + readonly label: string + readonly description: string + readonly source: string + readonly digest: Digest + readonly profile: ProfileRecord +} + +export interface ConnectionChoice { + readonly id: ConnectionRecord['id'] + readonly label: string + readonly description: string + readonly kind: ConnectionKind + readonly health: ConnectionHealth['status'] + readonly connection: ConnectionRecord +} + +export interface ConfigurationSelection { + readonly profile: ProfileRecord + readonly connection: ConnectionRecord + readonly profileDigest: Digest + readonly connectionDigest: Digest +} + +export interface ConfigurationEffectiveValues { + readonly runner: string + readonly model: string + readonly effort: string + readonly workdir: string + readonly verification: string + readonly unsupported: readonly string[] +} + +export interface ConfigurationSessionState { + readonly step: ConfigurationStep + readonly profiles: readonly ProfileChoice[] + readonly connections: readonly ConnectionChoice[] + readonly selectedProfileId?: ProfileRecord['id'] + readonly selectedConnectionId?: ConnectionRecord['id'] + readonly selection?: ConfigurationSelection + readonly error?: ConfigurationSessionError +} + +export interface ConfigurationSessionOptions { + readonly profiles: readonly ProfileRecord[] + readonly connections: readonly ConnectionRecord[] + readonly initialProfileId?: ProfileRecord['id'] + readonly initialConnectionId?: ConnectionRecord['id'] +} + +function kindLabel(kind: ConnectionKind): string { + switch (kind) { + case 'cli-bridge': + return 'CLI' + case 'tangle-inference': + return 'Inference' + case 'tangle-sandbox': + return 'Sandbox' + } +} + +function profileChoice(record: ProfileRecord): ProfileChoice { + const profile = record.profile + const runner = profile.harness ?? 'runner selected by connection' + const model = profile.model?.default ?? 'model selected by connection' + const effort = profile.model?.reasoningEffort + const details = [runner, model, effort].filter((value): value is string => value !== undefined) + return Object.freeze({ + id: record.id, + label: record.displayName, + description: `${record.source.label} · ${details.join(' · ')}`, + source: record.source.label, + digest: record.digest, + profile: record, + }) +} + +function connectionChoice(record: ConnectionRecord): ConnectionChoice { + const credential = record.credentialRef === undefined ? 'unconfigured' : 'ready' + const details = [kindLabel(record.kind), record.lastHealth.status, credential].filter( + (value): value is string => value !== undefined, + ) + return Object.freeze({ + id: record.id, + label: record.name, + description: details.join(' · '), + kind: record.kind, + health: record.lastHealth.status, + connection: record, + }) +} + +function sortedProfiles(records: readonly ProfileRecord[]): readonly ProfileRecord[] { + return [...records].sort((left, right) => + compareCodeUnits( + `${left.displayName}\u0000${left.id}`, + `${right.displayName}\u0000${right.id}`, + ), + ) +} + +function sortedConnections(records: readonly ConnectionRecord[]): readonly ConnectionRecord[] { + const order: Readonly> = { + 'cli-bridge': 0, + 'tangle-inference': 1, + 'tangle-sandbox': 2, + } + return [...records].sort((left, right) => + compareCodeUnits( + `${order[left.kind]}\u0000${left.name}\u0000${left.id}`, + `${order[right.kind]}\u0000${right.name}\u0000${right.id}`, + ), + ) +} + +function freezeState(state: ConfigurationSessionState): ConfigurationSessionState { + return Object.freeze({ + ...state, + profiles: Object.freeze([...state.profiles]), + connections: Object.freeze([...state.connections]), + }) +} + +function clearError(state: ConfigurationSessionState): ConfigurationSessionState { + const next = { ...state } + delete next.error + return freezeState(next) +} + +function clearSelection(state: ConfigurationSessionState): ConfigurationSessionState { + const next = { ...state } + delete next.selection + return freezeState(next) +} + +/** + * Coordinates first-run choices without owning durable state or credentials. + * The consumer decides how a committed selection changes the active run. + */ +export class ConfigurationSession { + readonly #profiles: readonly ProfileChoice[] + readonly #connections: readonly ConnectionChoice[] + readonly #connectionRegistry: ConnectionRegistry + #state: ConfigurationSessionState + + constructor(options: ConfigurationSessionOptions) { + const profiles = sortedProfiles(options.profiles).map(profileChoice) + const connections = sortedConnections(options.connections).map(connectionChoice) + this.#profiles = Object.freeze(profiles) + this.#connections = Object.freeze(connections) + this.#connectionRegistry = new ConnectionRegistry( + connections.map((choice) => choice.connection), + ) + this.#state = freezeState({ + step: 'profile', + profiles: this.#profiles, + connections: this.#connections, + ...(options.initialProfileId === undefined + ? {} + : { selectedProfileId: options.initialProfileId }), + ...(options.initialConnectionId === undefined + ? {} + : { selectedConnectionId: options.initialConnectionId }), + }) + } + + get state(): ConfigurationSessionState { + return this.#state + } + + selectProfile(id: string): ConfigurationSessionState { + this.#assertSelectable() + if (this.#profiles.length === 0) { + return this.#fail('NO_PROFILES', 'No AgentProfiles are available for this workspace') + } + const profile = this.#profiles.find((choice) => choice.id === id) + if (!profile) return this.#fail('PROFILE_NOT_FOUND', 'That AgentProfile is no longer available') + this.#state = clearError( + clearSelection({ + ...this.#state, + step: 'connection', + selectedProfileId: profile.id, + }), + ) + return this.#state + } + + selectConnection(id: string): ConfigurationSessionState { + this.#assertSelectable() + if (this.#connections.length === 0) { + return this.#fail('NO_CONNECTIONS', 'No connections are configured for this workspace') + } + const connection = this.#connections.find((choice) => choice.id === id) + if (!connection) + return this.#fail('CONNECTION_NOT_FOUND', 'That connection is no longer available') + this.#connectionRegistry.select({ connectionId: connection.id }) + this.#state = clearError( + clearSelection({ + ...this.#state, + step: 'confirm', + selectedConnectionId: connection.id, + }), + ) + return this.#state + } + + back(): ConfigurationSessionState { + const target = + this.#state.step === 'confirm' || this.#state.step === 'complete' ? 'connection' : 'profile' + return this.backTo(target) + } + + backTo(target: ConfigurationBackTarget): ConfigurationSessionState { + this.#assertOpen() + if (target === 'connection' && this.#state.selectedProfileId === undefined) { + return this.#fail('PROFILE_REQUIRED', 'Choose an AgentProfile before continuing') + } + this.#state = clearError(clearSelection({ ...this.#state, step: target })) + return this.#state + } + + confirm(): ConfigurationSelection { + if (this.#state.step === 'complete' && this.#state.selection !== undefined) { + return this.#state.selection + } + const selected = this.previewSelection() + this.#state = freezeState({ + ...this.#state, + step: 'complete', + selection: selected, + }) + this.#state = clearError(this.#state) + return selected + } + + previewSelection(): ConfigurationSelection { + this.#assertOpen() + const profileId = this.#state.selectedProfileId + if (profileId === undefined) { + this.#fail('PROFILE_REQUIRED', 'Choose an AgentProfile before continuing') + throw this.#state.error + } + const connectionId = this.#state.selectedConnectionId + if (connectionId === undefined) { + this.#fail('CONNECTION_REQUIRED', 'Choose a connection before continuing') + throw this.#state.error + } + const profile = this.#profiles.find((choice) => choice.id === profileId) + const connection = this.#connections.find((choice) => choice.id === connectionId) + if (!profile) { + this.#fail('PROFILE_NOT_FOUND', 'The selected AgentProfile is no longer available') + throw this.#state.error + } + if (!connection) { + this.#fail('CONNECTION_NOT_FOUND', 'The selected connection is no longer available') + throw this.#state.error + } + const selected: ConfigurationSelection = Object.freeze({ + profile: profile.profile, + connection: connection.connection, + profileDigest: profile.digest, + connectionDigest: this.#connectionRegistry.select({ connectionId }).digest, + }) + return selected + } + + cancel(): ConfigurationSessionState { + if (this.#state.step === 'cancelled') { + throw new ConfigurationSessionError( + 'ALREADY_FINISHED', + 'This configuration session is closed', + ) + } + this.#state = clearError(freezeState({ ...this.#state, step: 'cancelled' })) + return this.#state + } + + #fail(code: ConfigurationErrorCode, message: string): ConfigurationSessionState { + const error = new ConfigurationSessionError(code, message) + this.#state = freezeState({ ...this.#state, error }) + return this.#state + } + + #assertOpen(): void { + if (this.#state.step === 'cancelled') { + throw new ConfigurationSessionError( + 'ALREADY_FINISHED', + 'This configuration session is closed', + ) + } + } + + #assertSelectable(): void { + if (this.#state.step === 'complete' || this.#state.step === 'cancelled') { + throw new ConfigurationSessionError( + 'ALREADY_FINISHED', + 'This configuration session is closed', + ) + } + } +} diff --git a/src/app/connection-action-support.ts b/src/app/connection-action-support.ts new file mode 100644 index 0000000..8c4ad74 --- /dev/null +++ b/src/app/connection-action-support.ts @@ -0,0 +1,251 @@ +import type { ConnectionCapabilityReport } from '../adapters/connections/production-connection-types.js' +import { redactStructuredValue } from '../domain/bounded-structured.js' +import type { + ConnectionHealth, + ConnectionModelVerification, + ConnectionRecord, + OperationRecord, +} from '../domain/entities.js' +import type { JsonValue } from '../domain/entities-base.js' +import type { Digest } from '../domain/ids.js' +import { redactSensitiveText } from '../domain/redaction.js' +import type { BraidState } from '../domain/state.js' +import type { ConnectionSummary, ConnectionTestResultData } from './connection-action-types.js' +import { acknowledgedOperation, type parseOperation } from './conversation-support.js' + +export function selectedModel(profile: Readonly>): string | undefined { + const model = profile.model + if (model === null || typeof model !== 'object' || Array.isArray(model)) return undefined + const value = (model as { readonly default?: unknown }).default + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined +} + +export function unverifiedModel( + model: string | undefined, + checkedAt: string, +): ConnectionModelVerification { + return { + model: model ?? '', + status: 'unverified', + checkedAt, + message: 'The configured adapter does not expose model verification', + } +} + +export function connectionSummary( + record: ConnectionRecord, + requestedModel: string | undefined, + capabilities?: ConnectionCapabilityReport, +): ConnectionSummary { + const cachedModel = + record.lastModelVerification?.model === requestedModel + ? record.lastModelVerification + : undefined + return { + id: record.id, + name: redactText(record.name, 512), + kind: record.kind, + ...(record.endpoint === undefined ? {} : { endpoint: redactText(record.endpoint, 2048) }), + ...(record.providerOptions.region === undefined + ? {} + : { region: redactText(record.providerOptions.region, 512) }), + ...(record.providerOptions.account === undefined + ? {} + : { account: redactText(record.providerOptions.account, 512) }), + credentialConfigured: record.credentialRef !== undefined, + health: record.lastHealth, + ...(cachedModel === undefined ? {} : { modelVerification: cachedModel }), + capabilityHints: [...(record.providerOptions.capabilityHints ?? [])], + ...(capabilities === undefined ? {} : { capabilities }), + ready: record.lastHealth.status === 'healthy' && cachedModel?.status === 'verified', + } +} + +export function cachedCapabilities( + state: BraidState, + connectionId: string, +): ConnectionCapabilityReport | undefined { + for (const operation of [...state.operations].reverse()) { + if (operation.kind !== 'connection-change' || operation.status !== 'acknowledged') continue + const result = operation.result + if (!isRecord(result) || result.connectionId !== connectionId) continue + const capabilities = result.capabilities + if (isCapabilityReport(capabilities, connectionId)) return capabilities + } + return undefined +} + +export function operationTestResult( + operation: OperationRecord, + credentialConfigured = false, +): ConnectionTestResultData | undefined { + const value = operation.result + if (!isRecord(value)) return undefined + if (!isRecord(value.connection) || !isPersistedConnectionSummary(value.connection)) + return undefined + if (!isHealth(value.health)) return undefined + if (!isModelVerification(value.modelVerification)) return undefined + if (typeof value.ready !== 'boolean') return undefined + if (value.capabilities !== undefined && value.capabilities !== null) { + if (!isCapabilityReport(value.capabilities, String(value.connection.id))) return undefined + } + return { + connection: { + ...(value.connection as unknown as Omit), + credentialConfigured, + }, + health: value.health as ConnectionHealth, + modelVerification: value.modelVerification as ConnectionModelVerification, + ...(value.capabilities === undefined || value.capabilities === null + ? {} + : { capabilities: value.capabilities as ConnectionCapabilityReport }), + ready: value.ready, + } +} + +export function operationSelectionSummary( + operation: OperationRecord, + credentialConfigured = false, +): ConnectionSummary | undefined { + const value = operation.result?.connection + return isRecord(value) && isPersistedConnectionSummary(value) + ? { + ...(value as unknown as Omit), + credentialConfigured, + } + : undefined +} + +export function connectionSelectionOperationResult( + connectionId: string, + connection: ConnectionSummary, +): NonNullable { + return jsonObject({ connectionId, connection: persistedConnectionSummary(connection) }) +} + +export function connectionTestOperationResult( + result: ConnectionTestResultData, +): NonNullable { + const capabilities = + result.capabilities === undefined ? undefined : safeCapabilities(result.capabilities) + const connection = persistedConnectionSummary(result.connection, capabilities) + const value = { + connectionId: result.connection.id, + connection, + health: safeHealth(result.health), + modelVerification: safeModelVerification(result.modelVerification), + ...(capabilities === undefined ? {} : { capabilities }), + ready: result.ready, + } + return jsonObject(value) +} + +export function pendingConnectionOperation( + id: ReturnType, + digest: Digest, + at: string, +): OperationRecord & { readonly kind: 'connection-change' } { + return { + id, + kind: 'connection-change', + requestDigest: digest, + status: 'pending', + createdAt: at, + updatedAt: at, + } +} + +export function acknowledgedConnectionOperation(input: { + readonly id: ReturnType + readonly digest: Digest + readonly at: string + readonly result?: OperationRecord['result'] +}): OperationRecord & { readonly kind: 'connection-change' } { + return acknowledgedOperation({ ...input, kind: 'connection-change' }) as OperationRecord & { + readonly kind: 'connection-change' + } +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +function isHealth(value: unknown): value is ConnectionHealth { + return isRecord(value) && typeof value.status === 'string' +} + +function isModelVerification(value: unknown): value is ConnectionModelVerification { + return isRecord(value) && typeof value.model === 'string' && typeof value.status === 'string' +} + +function isPersistedConnectionSummary(value: Record): boolean { + return ( + typeof value.id === 'string' && + typeof value.name === 'string' && + typeof value.kind === 'string' && + isHealth(value.health) && + typeof value.ready === 'boolean' + ) +} + +function isCapabilityReport( + value: unknown, + connectionId: string, +): value is ConnectionCapabilityReport { + if (!isRecord(value)) return false + if (value.connectionId !== connectionId || typeof value.kind !== 'string') return false + if (!isRecord(value.runtime) || !isRecord(value.runtime.streaming)) return false + if (!isRecord(value.runtime.sessions) || !isRecord(value.runtime.interactions)) return false + if (!isRecord(value.providerMethods) || !isRecord(value.actions)) return false + return Object.values(value.actions).every((item) => typeof item === 'boolean') +} + +function persistedConnectionSummary( + summary: ConnectionSummary, + capabilities: ConnectionCapabilityReport | undefined = undefined, +): Omit { + const { credentialConfigured: _credentialConfigured, ...persisted } = summary + return { + ...persisted, + health: safeHealth(summary.health), + ...(summary.modelVerification === undefined + ? {} + : { modelVerification: safeModelVerification(summary.modelVerification) }), + ...(capabilities === undefined ? {} : { capabilities }), + } +} + +function safeHealth(health: ConnectionHealth): ConnectionHealth { + if (health.status === 'unknown' || health.message === undefined) return health + return { ...health, message: redactSensitiveText(health.message, 2048) } +} + +function safeModelVerification( + verification: ConnectionModelVerification, +): ConnectionModelVerification { + return { + ...verification, + model: redactSensitiveText(verification.model, 512), + ...(verification.code === undefined + ? {} + : { code: redactSensitiveText(verification.code, 256) }), + ...(verification.message === undefined + ? {} + : { message: redactSensitiveText(verification.message, 2048) }), + } +} + +function safeCapabilities(report: ConnectionCapabilityReport): ConnectionCapabilityReport { + return redactStructuredValue(report, undefined, { + maxBytes: 512 * 1024, + }) as ConnectionCapabilityReport +} + +function redactText(value: string, maxBytes: number): string { + return redactSensitiveText(value, maxBytes) +} + +function jsonObject(value: unknown): NonNullable { + if (!isRecord(value)) return {} + return value as Record +} diff --git a/src/app/connection-action-types.ts b/src/app/connection-action-types.ts new file mode 100644 index 0000000..ae2f2bc --- /dev/null +++ b/src/app/connection-action-types.ts @@ -0,0 +1,44 @@ +import type { ConnectionCapabilityReport } from '../adapters/connections/production-connection-types.js' +import type { + ConnectionHealth, + ConnectionModelVerification, + ConnectionRecord, +} from '../domain/entities.js' + +export interface ConnectionSummary { + readonly id: string + readonly name: string + readonly kind: ConnectionRecord['kind'] + readonly endpoint?: string + readonly region?: string + readonly account?: string + readonly credentialConfigured: boolean + readonly health: ConnectionHealth + readonly modelVerification?: ConnectionModelVerification + readonly capabilityHints: readonly string[] + readonly capabilities?: ConnectionCapabilityReport + readonly ready: boolean +} + +export interface ConnectionListResult { + readonly connections: readonly ConnectionSummary[] +} + +export interface ConnectionTestResultData { + readonly connection: ConnectionSummary + readonly health: ConnectionHealth + readonly modelVerification: ConnectionModelVerification + readonly capabilities?: ConnectionCapabilityReport + readonly ready: boolean +} + +export interface ConnectionTestResult extends ConnectionTestResultData { + readonly revision: number + readonly replayed: boolean +} + +export interface ConnectionSelectionResult { + readonly connection: ConnectionSummary + readonly revision: number + readonly replayed: boolean +} diff --git a/src/app/connection-actions.ts b/src/app/connection-actions.ts new file mode 100644 index 0000000..3d01bfe --- /dev/null +++ b/src/app/connection-actions.ts @@ -0,0 +1,258 @@ +import type { ConnectionRecord } from '../domain/entities.js' +import { redactSensitiveText } from '../domain/redaction.js' +import type { ActionHost } from './action-host.js' +import { + acknowledgedConnectionOperation, + cachedCapabilities, + connectionSelectionOperationResult, + connectionSummary, + connectionTestOperationResult, + operationSelectionSummary, + operationTestResult, + pendingConnectionOperation, + selectedModel, + unverifiedModel, +} from './connection-action-support.js' +import type { + ConnectionListResult, + ConnectionSelectionResult, + ConnectionTestResult, + ConnectionTestResultData, +} from './connection-action-types.js' +import type { ConnectionProbeFactory } from './connection-probe.js' +import { ConnectionRegistry } from './connections.js' +import { operationReplay, parseOperation, requestDigest } from './conversation-support.js' +import { AppError } from './errors.js' + +export type { + ConnectionListResult, + ConnectionSelectionResult, + ConnectionSummary, + ConnectionTestResult, + ConnectionTestResultData, +} from './connection-action-types.js' + +export interface ConnectionActionOptions { + readonly host: ActionHost + readonly connections?: readonly ConnectionRecord[] + readonly probeFor?: ConnectionProbeFactory + readonly now?: () => string +} + +export class ConnectionActionService { + readonly #options: ConnectionActionOptions + + constructor(options: ConnectionActionOptions) { + this.#options = options + } + + async list(query = ''): Promise { + const state = this.#options.host.state() + const model = selectedModel(state.profile) + const normalized = query.trim().toLowerCase() + const connections = this.#records() + .filter((record) => { + if (!normalized) return true + return [ + record.id, + record.name, + record.kind, + record.endpoint, + record.providerOptions.region, + record.providerOptions.account, + ] + .filter((value): value is string => value !== undefined) + .some((value) => value.toLowerCase().includes(normalized)) + }) + .map((record) => connectionSummary(record, model, cachedCapabilities(state, record.id))) + return { connections } + } + + async select(input: { + readonly operationId: string + readonly connectionId: string + readonly expectedRevision?: number + }): Promise { + const operationId = parseOperation(input.operationId, 'select_connection') + const digest = requestDigest('select_connection', { + connectionId: input.connectionId, + expectedRevision: input.expectedRevision ?? null, + }) + const record = this.#find(input.connectionId) + const replay = operationReplay( + this.#options.host.state(), + operationId, + 'connection-change', + digest, + ) + if (replay !== undefined) { + if (replay.status !== 'acknowledged') throw reconciliationRequired(operationId) + const summary = operationSelectionSummary(replay, record.credentialRef !== undefined) + if (summary === undefined) throw reconciliationRequired(operationId) + this.#options.host.runtime?.syncFromState(this.#options.host.state()) + return { + connection: summary, + revision: this.#options.host.state().revision, + replayed: true, + } + } + + const state = this.#options.host.state() + const summary = connectionSummary( + record, + selectedModel(state.profile), + cachedCapabilities(state, record.id), + ) + const next = await this.#options.host.configuration.selectConnection({ + connection: record, + operation: acknowledgedConnectionOperation({ + id: operationId, + digest, + at: this.#now(), + result: { + ...connectionSelectionOperationResult(record.id, summary), + }, + }), + ...(input.expectedRevision === undefined ? {} : { expectedRevision: input.expectedRevision }), + }) + this.#options.host.runtime?.setConnection(record.id) + return { connection: summary, revision: next.revision, replayed: false } + } + + async test(input: { + readonly operationId: string + readonly connectionId: string + }): Promise { + const operationId = parseOperation(input.operationId, 'test_connection') + const model = selectedModel(this.#options.host.state().profile) + const digest = requestDigest('test_connection', { + connectionId: input.connectionId, + model: model ?? null, + }) + const record = this.#find(input.connectionId) + const replay = operationReplay( + this.#options.host.state(), + operationId, + 'connection-change', + digest, + ) + if (replay !== undefined) { + if (replay.status !== 'acknowledged') throw reconciliationRequired(operationId) + const result = operationTestResult(replay, record.credentialRef !== undefined) + if (result === undefined) throw reconciliationRequired(operationId) + return { + ...result, + revision: this.#options.host.state().revision, + replayed: true, + } + } + + const probe = this.#options.probeFor?.(record) + if (probe === undefined) { + throw new AppError( + 'CONNECTION_ADAPTER_UNAVAILABLE', + 'No production connection probe is configured for this connection', + ) + } + const pending = pendingConnectionOperation(operationId, digest, this.#now()) + await this.#options.host.configuration.requestOperation({ operation: pending }) + try { + const [health, capabilities] = await Promise.all([probe.health(), probe.capabilities()]) + const modelVerification = probe.verifyModel + ? await probe.verifyModel(model ?? '', { now: () => this.#now() }) + : unverifiedModel(model, this.#now()) + const updated: ConnectionRecord = { + ...record, + lastHealth: health, + lastModelVerification: modelVerification, + updatedAt: this.#now(), + } + const summary = connectionSummary(updated, model, capabilities) + const result: ConnectionTestResultData = { + connection: summary, + health, + modelVerification, + capabilities, + ready: summary.ready, + } + const next = await this.#options.host.configuration.updateConnectionHealth({ + connection: updated, + operation: acknowledgedConnectionOperation({ + id: operationId, + digest, + at: this.#now(), + result: connectionTestOperationResult(result), + }), + }) + return { ...result, revision: next.revision, replayed: false } + } catch (error) { + await this.#recordFailure(operationId, digest, error) + throw error + } + } + + #records(): readonly ConnectionRecord[] { + const records = new Map() + for (const record of this.#options.host.state().connections) records.set(record.id, record) + for (const record of this.#options.connections ?? []) { + if (!records.has(record.id)) records.set(record.id, record) + } + return new ConnectionRegistry([...records.values()]).list() + } + + #find(connectionId: string): ConnectionRecord { + const record = new ConnectionRegistry(this.#records()).get(connectionId) + if (record === undefined) { + throw new AppError( + 'CONNECTION_NOT_FOUND', + `Connection ${redactSensitiveText(connectionId, 512)} was not found`, + ) + } + return record + } + + #now(): string { + return this.#options.now?.() ?? new Date().toISOString() + } + + async #recordFailure( + operationId: ReturnType, + digest: ReturnType, + error: unknown, + ): Promise { + try { + await this.#options.host.configuration.failOperation({ + id: operationId, + kind: 'connection-change', + requestDigest: digest, + status: 'failed', + failureCode: errorCode(error), + failureMessage: redactSensitiveText(errorMessage(error), 2048), + createdAt: this.#now(), + updatedAt: this.#now(), + }) + } catch { + // Preserve the provider failure as the actionable result. + } + } +} + +function reconciliationRequired(operationId: string): AppError { + return new AppError( + 'OPERATION_REQUIRES_RECONCILIATION', + `Operation ${operationId} needs reconciliation before it can be retried`, + ) +} + +function errorCode(error: unknown): string { + return typeof error === 'object' && + error !== null && + 'code' in error && + typeof error.code === 'string' + ? error.code + : 'CONNECTION_TEST_FAILED' +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : 'Connection test failed' +} diff --git a/src/app/connection-errors.ts b/src/app/connection-errors.ts new file mode 100644 index 0000000..1eb18ff --- /dev/null +++ b/src/app/connection-errors.ts @@ -0,0 +1,38 @@ +import type { ConnectionId } from '../domain/ids.js' + +export type ConnectionErrorCode = + | 'CONNECTION_NOT_FOUND' + | 'CONNECTION_KIND_MISMATCH' + | 'CONNECTION_REVISION_MISMATCH' + | 'INVALID_CONNECTION_SELECTION' + | 'INVALID_CONNECTION_RECORD' + | 'SECRET_IN_CONNECTION_RECORD' + | 'CONNECTION_ENDPOINT_REQUIRED' + | 'CONNECTION_ENDPOINT_INVALID' + | 'CONNECTION_ENDPOINT_CONFLICT' + | 'CONNECTION_CREDENTIAL_REQUIRED' + | 'CONNECTION_CREDENTIAL_REF_UNMAPPED' + | 'CONNECTION_CREDENTIAL_UNAVAILABLE' + | 'CONNECTION_CREDENTIAL_INVALID' + | 'CONNECTION_UNSUPPORTED' + | 'CONNECTION_MODEL_REQUIRED' + | 'CONNECTION_MODEL_INVALID' + | 'CONNECTION_MODEL_HARNESS_MISMATCH' + | 'CONNECTION_WORKSPACE_REQUIRED' + | 'CONNECTION_FETCH_UNAVAILABLE' + +export class ConnectionError extends Error { + readonly code: ConnectionErrorCode + readonly connectionId?: ConnectionId + + constructor( + code: ConnectionErrorCode, + message: string, + options: { readonly connectionId?: ConnectionId } = {}, + ) { + super(message) + this.name = 'ConnectionError' + this.code = code + if (options.connectionId !== undefined) this.connectionId = options.connectionId + } +} diff --git a/src/app/connection-probe.ts b/src/app/connection-probe.ts new file mode 100644 index 0000000..57c2b43 --- /dev/null +++ b/src/app/connection-probe.ts @@ -0,0 +1,22 @@ +import type { + ConnectionCapabilityReport, + ConnectionHealthOptions, + ConnectionModelVerificationOptions, +} from '../adapters/connections/production-connection-types.js' +import type { + ConnectionHealth, + ConnectionModelVerification, + ConnectionRecord, +} from '../domain/entities.js' + +/** Provider work required only by an explicit connection test. */ +export interface ConnectionProbe { + readonly capabilities: () => Promise + readonly health: (options?: ConnectionHealthOptions) => Promise + readonly verifyModel?: ( + model: string, + options?: ConnectionModelVerificationOptions, + ) => Promise +} + +export type ConnectionProbeFactory = (record: ConnectionRecord) => ConnectionProbe | undefined diff --git a/src/app/connections.ts b/src/app/connections.ts new file mode 100644 index 0000000..0199d5f --- /dev/null +++ b/src/app/connections.ts @@ -0,0 +1,178 @@ +import { canonicalDigest } from '../domain/canonical.js' +import type { ConnectionKind, ConnectionRecord } from '../domain/entities.js' +import type { ConnectionId, Digest, WorkspaceId } from '../domain/ids.js' +import { parseConnectionId } from '../domain/ids.js' +import { assertConnectionRecord } from '../domain/invariants-profile.js' +import { redactSensitiveText } from '../domain/redaction.js' +import { ConnectionError } from './connection-errors.js' + +export interface ConnectionSelectionInput { + readonly connectionId: string + readonly expectedKind?: ConnectionKind + readonly expectedUpdatedAt?: string +} + +export interface ConnectionSelection { + readonly input: ConnectionSelectionInput + readonly record: ConnectionRecord + readonly digest: Digest +} + +export interface ConnectionCatalog { + get(connectionId: string): ConnectionRecord | undefined + select(input: ConnectionSelectionInput): ConnectionSelection +} + +/** + * In-memory connection catalog for the application boundary. + * + * Durable event/state adapters can feed records into this catalog, while the + * runtime resolver only receives an exact id-based selection. + */ +export class ConnectionRegistry implements ConnectionCatalog { + readonly #records = new Map() + + constructor(records: readonly ConnectionRecord[] = []) { + for (const record of records) { + if (this.#records.has(record.id)) { + throw new ConnectionError('INVALID_CONNECTION_RECORD', 'Duplicate connection identifier') + } + this.#records.set(record.id, freezeConnectionRecord(validateRecord(record))) + } + } + + get(connectionId: string): ConnectionRecord | undefined { + try { + return this.#records.get(parseConnectionId(connectionId)) + } catch { + return undefined + } + } + + list(workspaceId?: WorkspaceId): readonly ConnectionRecord[] { + const records = [...this.#records.values()].filter( + (record) => workspaceId === undefined || record.workspaceId === workspaceId, + ) + return Object.freeze(records) + } + + upsert(record: ConnectionRecord): ConnectionRecord { + const normalized = freezeConnectionRecord(validateRecord(record)) + this.#records.set(normalized.id, normalized) + return normalized + } + + remove(input: ConnectionSelectionInput): ConnectionRecord { + const selected = this.select(input) + this.#records.delete(selected.record.id) + return selected.record + } + + select(input: ConnectionSelectionInput): ConnectionSelection { + const id = parseSelectionId(input) + const record = this.#records.get(id) + if (!record) { + throw new ConnectionError('CONNECTION_NOT_FOUND', 'The selected connection does not exist', { + connectionId: id, + }) + } + if (input.expectedKind !== undefined && input.expectedKind !== record.kind) { + throw new ConnectionError( + 'CONNECTION_KIND_MISMATCH', + 'The selected connection kind does not match the requested kind', + { connectionId: id }, + ) + } + if (input.expectedUpdatedAt !== undefined && input.expectedUpdatedAt !== record.updatedAt) { + throw new ConnectionError( + 'CONNECTION_REVISION_MISMATCH', + 'The selected connection changed before execution was admitted', + { connectionId: id }, + ) + } + return Object.freeze({ + input: Object.freeze({ ...input, connectionId: id }), + record, + digest: canonicalDigest({ + connectionId: id, + kind: record.kind, + updatedAt: record.updatedAt, + }), + }) + } +} + +function parseSelectionId(input: ConnectionSelectionInput): ConnectionId { + try { + return parseConnectionId(input.connectionId) + } catch { + throw new ConnectionError('INVALID_CONNECTION_SELECTION', 'A connection id is required') + } +} + +function validateRecord(record: ConnectionRecord): ConnectionRecord { + try { + assertSecretFree(record) + assertConnectionRecord(record) + return record + } catch (error) { + if (error instanceof ConnectionError) throw error + throw new ConnectionError('INVALID_CONNECTION_RECORD', 'The connection record is invalid') + } +} + +function assertSecretFree(record: ConnectionRecord): void { + const strings = [ + record.name, + record.endpoint, + record.providerOptions.transport, + record.providerOptions.endpoint, + record.providerOptions.region, + record.providerOptions.account, + ...(record.providerOptions.capabilityHints ?? []), + ...('message' in record.lastHealth ? [record.lastHealth.message] : []), + ...(record.lastModelVerification === undefined + ? [] + : [ + record.lastModelVerification.model, + record.lastModelVerification.code, + record.lastModelVerification.message, + ]), + ] + for (const value of strings) { + if (value !== undefined && redactSensitiveText(value) !== value) { + throw new ConnectionError( + 'SECRET_IN_CONNECTION_RECORD', + 'Connection records may contain references, not credential material', + { connectionId: record.id }, + ) + } + if (value?.includes('?') || value?.includes('#')) { + throw new ConnectionError( + 'SECRET_IN_CONNECTION_RECORD', + 'Connection URLs must not contain query or fragment data', + { connectionId: record.id }, + ) + } + } +} + +function freezeConnectionRecord(record: ConnectionRecord): ConnectionRecord { + const providerOptions = Object.freeze({ + ...record.providerOptions, + ...(record.providerOptions.capabilityHints === undefined + ? {} + : { capabilityHints: Object.freeze([...record.providerOptions.capabilityHints]) }), + }) + const lastHealth = Object.freeze({ ...record.lastHealth }) + const lastModelVerification = + record.lastModelVerification === undefined + ? undefined + : Object.freeze({ ...record.lastModelVerification }) + return Object.freeze({ + ...record, + providerOptions, + lastHealth, + ...(lastModelVerification === undefined ? {} : { lastModelVerification }), + }) +} diff --git a/src/app/control-effects.ts b/src/app/control-effects.ts new file mode 100644 index 0000000..61ef233 --- /dev/null +++ b/src/app/control-effects.ts @@ -0,0 +1,193 @@ +import type { + ControlAcknowledgement, + CancelRunResult, + ExecutionPort, + ProviderRunSnapshot, +} from '../ports/execution.js' +import type { EffectStatus } from '../ports/effect-storage.js' +import type { SerializedEffectCoordinator } from './effect-coordinator.js' +import type { ControlEffectRequest } from './application-ports.js' +import { safeDiagnostic } from './provider-values.js' + +export async function executeControlEffect(input: { + readonly effects: SerializedEffectCoordinator + readonly execution: ExecutionPort + readonly request: ControlEffectRequest + readonly owner: string + readonly timeoutMs: number + readonly whenDurable: () => Promise +}): Promise { + const { request } = input + const effect = input.effects.start( + { + operationId: request.operationId, + effectKind: `run.control.${request.control}`, + request: { + ...request, + providerSessionId: request.providerSessionId ?? null, + reason: request.reason ?? null, + text: request.text ?? null, + cursor: request.cursor ?? null, + }, + serializationKey: `run:${request.runId}:control`, + metadata: { + runId: request.runId, + control: request.control, + owner: input.owner, + leaseExpiresAt: new Date(Date.now() + input.timeoutMs).toISOString(), + }, + }, + { + dispatch: async () => { + const controller = new AbortController() + const result = await controlDeadline( + dispatchControl(input.execution, request, controller.signal), + controller, + input.timeoutMs, + ) + return effectResult(result) + }, + reconcile: async () => reconcileControl(input.execution, request), + }, + ) + const record = await effect.completion + await input.whenDurable() + return acknowledgementFromEffect(request.operationId, record.status, record.detail) +} + +async function dispatchControl( + execution: ExecutionPort, + request: ControlEffectRequest, + signal: AbortSignal, +): Promise { + try { + if (request.control === 'cancel') { + if (!execution.cancelRun) + return unknown(request.operationId, 'CONTROL_CANCELLATION_UNCONFIRMED') + return normalize( + request.operationId, + await execution.cancelRun({ + runId: request.runId, + operationId: request.operationId, + signal, + ...(request.reason === undefined ? {} : { reason: request.reason }), + }), + ) + } + if (request.control === 'steer') { + if (!execution.steerRun) return unknown(request.operationId, 'CONTROL_STEERING_UNCONFIRMED') + return execution.steerRun({ + runId: request.runId, + operationId: request.operationId, + text: request.text ?? '', + signal, + }) + } + if (!execution.detachRun) return unknown(request.operationId, 'CONTROL_DETACH_UNCONFIRMED') + return execution.detachRun({ + runId: request.runId, + operationId: request.operationId, + signal, + ...(request.cursor === undefined ? {} : { cursor: request.cursor }), + }) + } catch { + return unknown(request.operationId, 'CONTROL_DISPATCH_UNKNOWN') + } +} + +async function reconcileControl( + execution: ExecutionPort, + request: ControlEffectRequest, +): Promise<{ readonly status: 'terminal'; readonly detail: string } | undefined> { + if (request.control === 'steer' || !execution.status) return undefined + let snapshot: ProviderRunSnapshot | null + try { + snapshot = await execution.status({ runId: request.runId }) + } catch { + return undefined + } + if ( + !snapshot || + snapshot.runId !== request.runId || + (request.providerSessionId !== undefined && snapshot.sessionId !== request.providerSessionId) + ) + return undefined + if (request.control === 'detach' && snapshot.status !== 'detached') return undefined + if (!providerTerminal(snapshot.status)) return undefined + return { status: 'terminal', detail: 'CONTROL_RECONCILED_TERMINAL' } +} + +function effectResult(value: ControlAcknowledgement) { + if (value.outcome === 'accepted') + return { + status: 'acknowledged' as const, + detail: safeDiagnostic(value.detail, 'CONTROL_ACKNOWLEDGED'), + } + if (value.outcome === 'already-applied') + return { status: 'terminal' as const, detail: 'CONTROL_ALREADY_APPLIED' } + if (value.outcome === 'rejected') return { status: 'failed' as const, detail: 'CONTROL_REJECTED' } + return { status: 'unknown' as const, detail: 'CONTROL_OUTCOME_UNKNOWN' } +} + +function acknowledgementFromEffect( + operationId: string, + status: EffectStatus, + detail: string | undefined, +): ControlAcknowledgement { + if (status === 'acknowledged') + return { operationId, outcome: 'accepted', ...(detail ? { detail } : {}) } + if (status === 'terminal') + return { operationId, outcome: 'already-applied', ...(detail ? { detail } : {}) } + if (status === 'failed') return { operationId, outcome: 'rejected', detail: 'CONTROL_REJECTED' } + return { operationId, outcome: 'unknown', detail: 'CONTROL_OUTCOME_UNKNOWN' } +} + +function normalize( + operationId: string, + value: ControlAcknowledgement | CancelRunResult, +): ControlAcknowledgement { + if ('outcome' in value) + return { + operationId, + outcome: value.outcome, + ...(value.detail === undefined + ? {} + : { detail: safeDiagnostic(value.detail, 'CONTROL_DETAIL') }), + } + return value.status === 'cancelled' + ? { operationId, outcome: 'accepted' } + : { + operationId, + outcome: 'unknown', + detail: safeDiagnostic(value.reason, 'CONTROL_OUTCOME_UNKNOWN'), + } +} + +function unknown(operationId: string, detail: string): ControlAcknowledgement { + return { operationId, outcome: 'unknown', detail } +} + +async function controlDeadline( + action: Promise, + controller: AbortController, + timeoutMs: number, +): Promise { + let timer: ReturnType | undefined + const timeout = new Promise((resolve) => { + timer = setTimeout(() => { + controller.abort(new Error('Control acknowledgement deadline elapsed')) + resolve({ + operationId: 'unknown', + outcome: 'unknown', + detail: 'CONTROL_ACKNOWLEDGEMENT_TIMEOUT', + }) + }, timeoutMs) + }) + const result = await Promise.race([action, timeout]) + if (timer !== undefined) clearTimeout(timer) + return result +} + +function providerTerminal(status: ProviderRunSnapshot['status']): boolean { + return ['completed', 'failed', 'aborted', 'cancelled', 'blocked', 'expired'].includes(status) +} diff --git a/src/app/conversation-branches.ts b/src/app/conversation-branches.ts new file mode 100644 index 0000000..26ae4ab --- /dev/null +++ b/src/app/conversation-branches.ts @@ -0,0 +1,364 @@ +import { canonicalDigest } from '../domain/canonical.js' +import type { BranchRecord, ConversationRecord, MessageRecord } from '../domain/entities.js' +import { graphEdge, graphNode } from '../domain/graph-records.js' +import { + parseBranchId, + parseConversationId, + parseMessageId, + type BranchId, + type ConversationId, + type OperationId, +} from '../domain/ids.js' +import type { BraidState } from '../domain/state.js' +import { messagesVisibleOnBranch, portablePlanForState } from './conversation-context.js' +import { + conversationBundle, + draftRecord, + queueRecord, + runOverrides, +} from './conversation-records.js' +import { + acknowledgedOperation, + coordinateConversationOperation, + normalizedTitle, + operationReplay, + parseOperation, + requestDigest, + requireIdle, + requireWorkspace, + stableBranchIds, + stableConversationIds, +} from './conversation-support.js' +import type { + CloneConversationInput, + ConversationHost, + CreateBranchInput, + ForkPlan, + ForkPlanInput, +} from './conversation-types.js' +import { AppError } from './errors.js' + +interface ResolvedSource { + readonly conversation: ConversationRecord + readonly branch: BranchRecord + readonly through?: MessageRecord +} + +export class ConversationBranches { + readonly #host: ConversationHost + + constructor(host: ConversationHost) { + this.#host = host + } + + async create(input: CreateBranchInput): Promise { + return coordinateConversationOperation(this.#host, 'branch', input, () => this.#create(input)) + } + + async #create(input: CreateBranchInput): Promise { + const state = this.#host.state() + requireIdle(state, 'Creating a branch') + const source = resolveSource(state, input) + const operationId = parseOperation(input.operationId, 'branch') + const normalized = branchRequest(source, input) + const digest = requestDigest('branch', normalized) + const replay = operationReplay(state, operationId, 'branch-create', digest) + if (replay) return branchForOperation(state, replay) + const ids = stableBranchIds(operationId, digest) + const at = this.#host.now() + const draft = draftRecord(ids.draftId, ids.branchId, at, input.text) + const queue = queueRecord(ids.queueId, ids.branchId, at) + const branch: BranchRecord = { + id: ids.branchId, + conversationId: source.conversation.id, + source: { + conversationId: source.conversation.id, + branchId: source.branch.id, + ...(source.through === undefined ? {} : { throughMessageId: source.through.id }), + ...(source.through?.turnId === undefined ? {} : { throughTurnId: source.through.turnId }), + }, + ...(source.branch.profileId === undefined ? {} : { profileId: source.branch.profileId }), + ...(source.branch.profileSnapshotId === undefined + ? {} + : { profileSnapshotId: source.branch.profileSnapshotId }), + ...(source.branch.connectionId === undefined + ? {} + : { connectionId: source.branch.connectionId }), + overrides: runOverrides({ + inherited: source.branch.overrides, + ...(input.runner === undefined ? {} : { runner: input.runner }), + ...(input.model === undefined ? {} : { model: input.model }), + ...(input.effort === undefined ? {} : { effort: input.effort }), + }), + ...(source.branch.environmentId === undefined + ? {} + : { environmentId: source.branch.environmentId }), + draftId: draft.id, + queueId: queue.id, + ...(source.through === undefined ? {} : { tipMessageId: source.through.id }), + status: 'active', + createdAt: at, + updatedAt: at, + } + const conversation = { + ...source.conversation, + activeBranchId: branch.id, + updatedAt: at, + } + const sourceReference = source.through + ? ({ kind: 'message', id: source.through.id } as const) + : ({ kind: 'branch', id: source.branch.id } as const) + const sourceNode = graphNode(sourceReference, at) + const branchNode = graphNode({ kind: 'branch', id: branch.id }, at, 'Branch') + await this.#host.commit({ + kind: 'branch.created', + branch, + conversation, + draft, + queue, + graphNodes: [sourceNode, branchNode], + graphEdges: [ + graphEdge({ + kind: 'branched_at', + source: sourceNode.reference, + destination: branchNode.reference, + at, + provenance: { operationId }, + }), + ], + operation: acknowledgedOperation({ + id: operationId, + kind: 'branch-create', + digest, + at, + target: { kind: 'branch', id: branch.id }, + }), + }) + return branch + } + + async clone(input: CloneConversationInput): Promise { + return coordinateConversationOperation(this.#host, 'clone', input, () => this.#clone(input)) + } + + async #clone(input: CloneConversationInput): Promise { + const state = this.#host.state() + const workspaceId = requireWorkspace(state) + requireIdle(state, 'Cloning a conversation') + const source = resolveSource(state, input) + const operationId = parseOperation(input.operationId, 'clone') + const title = normalizedTitle(input.title, `${source.conversation.title} copy`) + const digest = requestDigest('clone', { + conversationId: source.conversation.id, + branchId: source.branch.id, + throughMessageId: source.through?.id ?? null, + title, + }) + const replay = operationReplay(state, operationId, 'conversation-clone', digest) + if (replay) return conversationForOperation(state, replay) + const ids = stableConversationIds(operationId, digest, 'clone') + const at = this.#host.now() + const bundle = conversationBundle({ + workspaceId, + ...ids, + title, + at, + operationId, + ...(source.branch.profileId === undefined ? {} : { profileId: source.branch.profileId }), + ...(source.branch.connectionId === undefined + ? {} + : { connectionId: source.branch.connectionId }), + source: { + conversationId: source.conversation.id, + branchId: source.branch.id, + ...(source.through === undefined ? {} : { throughMessageId: source.through.id }), + ...(source.through?.turnId === undefined ? {} : { throughTurnId: source.through.turnId }), + }, + sourceNode: { kind: 'branch', id: source.branch.id }, + sourceEdgeKind: 'cloned_from', + overrides: source.branch.overrides, + ...(source.branch.environmentId === undefined + ? {} + : { environmentId: source.branch.environmentId }), + }) + await this.#host.commit({ + kind: 'conversation.created', + ...bundle, + operation: acknowledgedOperation({ + id: operationId, + kind: 'conversation-clone', + digest, + at, + target: { kind: 'conversation', id: bundle.conversation.id }, + }), + }) + return bundle.conversation + } + + plan(input: ForkPlanInput): ForkPlan { + const state = this.#host.state() + const source = resolveSource(state, input) + const operationId = parseOperation(input.operationId, 'plan_fork') + const normalized = branchRequest(source, input) + const branchDigest = requestDigest('branch', normalized) + const destinationBranchId = stableBranchIds(operationId, branchDigest).branchId + const kind = input.kind ?? 'conversation' + const context = portablePlanForState(state, { + branchId: source.branch.id, + ...(source.through === undefined ? {} : { throughMessageId: source.through.id }), + ...(input.runner === undefined ? {} : { destinationRunner: input.runner }), + }) + const workspaceAvailable = workspaceForkReported(state, source.branch) + const allowed = kind === 'conversation' + const plan = { + kind, + operationId, + sourceConversationId: source.conversation.id, + sourceBranchId: source.branch.id, + ...(source.through === undefined ? {} : { throughMessageId: source.through.id }), + destinationBranchId, + context, + environment: + kind === 'conversation' + ? ('shared' as const) + : workspaceAvailable + ? ('new' as const) + : ('unavailable' as const), + providerSession: 'new' as const, + checkpoint: + kind === 'conversation' + ? ('none' as const) + : workspaceAvailable + ? ('required' as const) + : ('unavailable' as const), + allowed, + ...(allowed + ? {} + : { + reason: workspaceAvailable + ? 'The current runtime does not expose retry-safe environment fork execution to Braid' + : 'The selected run does not report retry-safe checkpoint and environment fork support', + }), + } + return { ...plan, digest: canonicalDigest(plan) } + } + + async execute(input: ForkPlanInput & { readonly planDigest: string }): Promise { + return coordinateConversationOperation(this.#host, 'execute_fork', input, () => + this.#execute(input), + ) + } + + async #execute(input: ForkPlanInput & { readonly planDigest: string }): Promise { + const plan = this.plan(input) + if (plan.digest !== input.planDigest) { + throw new AppError( + 'FORK_PLAN_CONFLICT', + 'The accepted fork plan no longer matches the source', + ) + } + if (!plan.allowed) { + throw new AppError('CAPABILITY_UNAVAILABLE', plan.reason ?? 'Workspace fork is unavailable') + } + const branch = await this.#create(input) + if (branch.id !== plan.destinationBranchId) { + throw new AppError('FORK_PLAN_CONFLICT', 'Fork execution produced an unexpected branch') + } + return branch + } +} + +function resolveSource( + state: BraidState, + input: Pick, +): ResolvedSource { + const conversationId = parseConversationId(input.conversationId ?? state.conversationId) + const conversation = state.conversations.find( + (candidate) => candidate.id === conversationId && candidate.deletedAt === undefined, + ) + if (!conversation) + throw new AppError('UNKNOWN_CONVERSATION', `Conversation ${conversationId} is unavailable`) + const branchId = parseBranchId(input.branchId ?? conversation.activeBranchId) + const branch = state.branches.find( + (candidate) => candidate.id === branchId && candidate.conversationId === conversationId, + ) + 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 }) } +} + +function branchRequest(source: ResolvedSource, input: CreateBranchInput) { + return { + conversationId: source.conversation.id, + branchId: source.branch.id, + throughMessageId: source.through?.id ?? null, + text: input.text ?? '', + runner: input.runner ?? source.branch.overrides.runner ?? null, + model: input.model ?? source.branch.overrides.model ?? null, + effort: input.effort ?? source.branch.overrides.effort ?? null, + } +} + +function branchForOperation( + state: BraidState, + operation: { + readonly id: OperationId + readonly target?: { readonly kind: string; readonly id: string } + }, +): BranchRecord { + if (operation.target?.kind !== 'branch') { + throw new AppError('OPERATION_INCOMPLETE', `Operation ${operation.id} has no branch result`) + } + const branch = state.branches.find((candidate) => candidate.id === operation.target?.id) + if (!branch) + throw new AppError('OPERATION_INCOMPLETE', `Operation ${operation.id} has no durable result`) + return branch +} + +function conversationForOperation( + state: BraidState, + operation: { + readonly id: OperationId + readonly target?: { readonly kind: string; readonly id: string } + }, +): ConversationRecord { + if (operation.target?.kind !== 'conversation') { + throw new AppError( + 'OPERATION_INCOMPLETE', + `Operation ${operation.id} has no conversation result`, + ) + } + const conversation = state.conversations.find( + (candidate) => candidate.id === operation.target?.id, + ) + if (!conversation) + throw new AppError('OPERATION_INCOMPLETE', `Operation ${operation.id} has no durable result`) + return conversation +} + +function workspaceForkReported(state: BraidState, branch: BranchRecord): boolean { + if (branch.environmentId === undefined) return false + const run = state.runs.filter((candidate) => candidate.branchId === branch.id).at(-1) + const branching = run?.capabilities.environment?.branching + return Boolean( + branching?.checkpoint && + branching.fork && + branching.retrySafe && + branching.lookup && + branching.cleanup, + ) +} + +export type { BranchId, ConversationId } diff --git a/src/app/conversation-composition.ts b/src/app/conversation-composition.ts new file mode 100644 index 0000000..539c7e5 --- /dev/null +++ b/src/app/conversation-composition.ts @@ -0,0 +1,52 @@ +import { canonicalDigest } from '../domain/canonical.js' +import type { BraidEvent } from '../domain/events.js' +import { parseConversationId, parseOperationId } from '../domain/ids.js' +import type { BraidState } from '../domain/state.js' +import type { StoragePort } from '../ports/storage.js' +import { ConversationActions } from './conversations.js' + +export interface ConversationCompositionInput { + readonly state: () => BraidState + readonly now: () => string + readonly commit: (event: BraidEvent) => Promise + readonly coordinate: ( + input: { readonly operationId: string; readonly digest: string }, + action: () => Promise, + ) => Promise + readonly storage?: Pick +} + +export function createConversationActions( + input: ConversationCompositionInput, +): ConversationActions { + return new ConversationActions({ + state: input.state, + now: input.now, + commit: input.commit, + coordinate: input.coordinate, + ...(input.storage === undefined + ? {} + : { + destroy: async (operation: { + readonly conversationId: string + readonly operationId: string + }) => { + const reason = 'User-requested conversation deletion' + const request = { + conversationId: operation.conversationId, + reasonDigest: canonicalDigest(reason), + } + await input.storage?.destroyConversation({ + conversationId: parseConversationId(operation.conversationId), + reason, + operation: { + operationId: parseOperationId(operation.operationId), + kind: 'conversation-delete', + request, + requestDigest: canonicalDigest(request), + }, + }) + }, + }), + }) +} diff --git a/src/app/conversation-context.ts b/src/app/conversation-context.ts new file mode 100644 index 0000000..b477f5c --- /dev/null +++ b/src/app/conversation-context.ts @@ -0,0 +1,254 @@ +import { canonicalDigest } from '../domain/canonical.js' +import type { AnalysisAttachmentRecord, BranchRecord, MessageRecord } from '../domain/entities.js' +import type { BranchId } from '../domain/ids.js' +import { + createPortableContextPlan, + type PortableAnalysisAttachment, + type PortableContextMessage, + type PortableContextPart, + type PortableContextPlan, +} from '../domain/receipts.js' +import { redactSensitiveText } from '../domain/redaction.js' +import type { BraidState } from '../domain/state.js' +import type { ConversationHost, PlanContextInput } from './conversation-types.js' +import { messagesThroughBoundary, messagesVisibleOnBranch } from './conversation-visibility.js' +import { AppError } from './errors.js' + +export { messagesVisibleOnBranch } from './conversation-visibility.js' + +const MAX_CONTEXT_MESSAGES = 20_000 +const MAX_CONTEXT_BYTES = 2 * 1024 * 1024 + +function portablePart(part: MessageRecord['parts'][number]): PortableContextPart | undefined { + const id = part.id + switch (part.kind) { + case 'reasoning': + return undefined + case 'text': + return { id, type: 'text', text: redactSensitiveText(part.text ?? '') } + case 'artifact': + return part.uri + ? { + id, + type: 'artifact', + uri: redactSensitiveText(part.uri, 4096), + ...(part.mimeType === undefined ? {} : { mediaType: part.mimeType }), + } + : { id, type: 'unknown', summary: 'Artifact reference unavailable' } + case 'tool-call': + return { + id, + type: 'unknown', + summary: `Tool request: ${redactSensitiveText(part.toolName ?? 'unknown', 256)}`, + } + case 'tool-result': + return { + id, + type: 'unknown', + summary: `Tool result: ${redactSensitiveText(part.toolName ?? 'unknown', 256)}`, + } + case 'warning': + case 'error': + case 'proposal': + case 'interaction': + case 'system': + case 'unknown': + return { + id, + type: 'unknown', + summary: redactSensitiveText(part.text ?? part.title ?? part.kind, 4096), + } + } +} + +function portableMessage(message: MessageRecord): { + readonly message: PortableContextMessage + readonly omittedPartIds: readonly string[] +} { + const parts: PortableContextPart[] = [] + const omittedPartIds: string[] = [] + for (const part of message.parts) { + const portable = portablePart(part) + if (portable === undefined) omittedPartIds.push(part.id) + else parts.push(portable) + } + if (parts.length === 0 && message.text) { + parts.push({ id: `part-${message.id}`, type: 'text', text: redactSensitiveText(message.text) }) + } + return { + message: { id: message.id, role: message.role, parts }, + omittedPartIds, + } +} + +function analysisAttachmentsVisibleOnBranch( + state: BraidState, + branchId: BranchId, + visiting: Set, +): readonly AnalysisAttachmentRecord[] { + if (visiting.has(branchId)) + throw new AppError('GRAPH_CYCLE', `Branch ancestry includes ${branchId}`) + const branch = state.branches.find((candidate) => candidate.id === branchId) + if (branch === undefined) + throw new AppError('UNKNOWN_BRANCH', `Branch ${branchId} does not exist`) + visiting.add(branchId) + try { + const inherited = branch.source + ? analysisAttachmentsVisibleOnBranch(state, branch.source.branchId, visiting).filter( + (attachment) => attachmentVisibleAtBranchCreation(attachment, branch), + ) + : [] + const local = state.analysisAttachments.filter( + (attachment) => + attachment.destinationConversationId === branch.conversationId && + attachment.destinationBranchId === branch.id, + ) + const seen = new Set() + return [...inherited, ...local].filter((attachment) => { + if (seen.has(String(attachment.id))) return false + seen.add(String(attachment.id)) + return true + }) + } finally { + visiting.delete(branchId) + } +} + +function attachmentVisibleAtBranchCreation( + attachment: AnalysisAttachmentRecord, + branch: BranchRecord, +): boolean { + const attachmentTime = Date.parse(attachment.createdAt) + const branchTime = Date.parse(branch.createdAt) + return Number.isFinite(attachmentTime) && Number.isFinite(branchTime) + ? attachmentTime <= branchTime + : false +} + +function portableAnalysisAttachment( + attachment: AnalysisAttachmentRecord, +): PortableAnalysisAttachment { + return { + analysisId: String(attachment.analysisId), + ...(attachment.analysisRunId === undefined + ? {} + : { analysisRunId: String(attachment.analysisRunId) }), + sourceDigest: String(attachment.sourceDigest), + ...(attachment.sourceRunId === undefined + ? {} + : { sourceRunId: String(attachment.sourceRunId) }), + findings: attachment.selectedFindings.map((finding) => ({ + id: finding.id, + text: redactSensitiveText(finding.text), + citations: finding.citations.map((citation) => ({ + id: String(citation.id), + ...(citation.eventId === undefined ? {} : { eventId: String(citation.eventId) }), + ...(citation.messageId === undefined ? {} : { messageId: String(citation.messageId) }), + ...(citation.partId === undefined ? {} : { partId: String(citation.partId) }), + ...(citation.start === undefined ? {} : { start: citation.start }), + ...(citation.end === undefined ? {} : { end: citation.end }), + ...(citation.quote === undefined ? {} : { quote: redactSensitiveText(citation.quote) }), + })), + })), + provenance: { + ...(attachment.provenance.analystProfileDigest === undefined + ? {} + : { analystProfileDigest: String(attachment.provenance.analystProfileDigest) }), + ...(attachment.provenance.model === undefined ? {} : { model: attachment.provenance.model }), + ...(attachment.provenance.runner === undefined + ? {} + : { runner: attachment.provenance.runner }), + ...(attachment.provenance.agentEvalVersion === undefined + ? {} + : { agentEvalVersion: attachment.provenance.agentEvalVersion }), + }, + } +} + +export function portablePlanForState( + state: BraidState, + input: PlanContextInput, +): PortableContextPlan { + const branch = resolveBranch(state, input.branchId) + const visible = messagesVisibleOnBranch(state, branch.id) + if (visible.length > MAX_CONTEXT_MESSAGES) { + throw new AppError('CONTEXT_TOO_LARGE', 'Conversation context has too many messages') + } + const through = + input.throughMessageId === undefined + ? visible.at(-1) + : visible.find((message) => message.id === input.throughMessageId) + if (through === undefined && input.throughMessageId === undefined && visible.length === 0) { + const analysisAttachments = analysisAttachmentsVisibleOnBranch(state, branch.id, new Set()).map( + portableAnalysisAttachment, + ) + return createPortableContextPlan({ + sourceRunId: `run-context-${canonicalDigest({ branchId: branch.id }).slice(0, 24)}`, + sourceBoundary: branch.id, + ...(input.destinationRunner === undefined + ? {} + : { destinationRunner: input.destinationRunner }), + messages: [], + ...(analysisAttachments.length === 0 ? {} : { analysisAttachments }), + complete: true, + tokenEstimate: 0, + }) + } + if (!through) { + throw new AppError( + 'UNKNOWN_MESSAGE_BOUNDARY', + input.throughMessageId + ? `Message ${input.throughMessageId} is not visible on branch ${branch.id}` + : 'The selected branch has no message boundary', + ) + } + const selected = messagesThroughBoundary(visible, through.id) + const converted = selected.map(portableMessage) + const messages = converted.map((entry) => entry.message) + const analysisAttachments = analysisAttachmentsVisibleOnBranch(state, branch.id, new Set()).map( + portableAnalysisAttachment, + ) + const omittedPartIds = converted.flatMap((entry) => entry.omittedPartIds) + const bytes = Buffer.byteLength(JSON.stringify({ messages, analysisAttachments }), 'utf8') + if (bytes > MAX_CONTEXT_BYTES) { + throw new AppError('CONTEXT_TOO_LARGE', 'Conversation context exceeds 2 MiB') + } + const sourceRunId = + through.runId ?? + selected + .map((message) => message.runId) + .filter((value): value is NonNullable => value !== undefined) + .at(-1) ?? + `run-context-${canonicalDigest({ branchId: branch.id, through: through.id }).slice(0, 24)}` + return createPortableContextPlan({ + sourceRunId, + sourceBoundary: through.id, + ...(input.destinationRunner === undefined + ? {} + : { destinationRunner: input.destinationRunner }), + messages, + ...(analysisAttachments.length === 0 ? {} : { analysisAttachments }), + omittedPartIds, + complete: selected.every((message) => message.complete), + tokenEstimate: Math.ceil(bytes / 4), + }) +} + +function resolveBranch(state: BraidState, branchId: string | undefined): BranchRecord { + const id = branchId ?? state.branchId + const branch = state.branches.find((candidate) => candidate.id === id) + if (!branch) throw new AppError('UNKNOWN_BRANCH', `Branch ${id} does not exist`) + return branch +} + +export class ConversationContext { + readonly #host: ConversationHost + + constructor(host: ConversationHost) { + this.#host = host + } + + plan(input: PlanContextInput): PortableContextPlan { + return portablePlanForState(this.#host.state(), input) + } +} diff --git a/src/app/conversation-drafts.ts b/src/app/conversation-drafts.ts new file mode 100644 index 0000000..bb9302e --- /dev/null +++ b/src/app/conversation-drafts.ts @@ -0,0 +1,80 @@ +import type { DraftRecord } from '../domain/entities.js' +import { parseBranchId, parseConversationId } from '../domain/ids.js' +import { redactSensitiveText } from '../domain/redaction.js' +import { + acknowledgedOperation, + coordinateConversationOperation, + operationReplay, + parseOperation, + requestDigest, +} from './conversation-support.js' +import type { ConversationHost } from './conversation-types.js' +import { AppError } from './errors.js' + +const MAX_DRAFT_BYTES = 1024 * 1024 + +export interface SetConversationDraftInput { + readonly operationId: string + readonly text: string + readonly conversationId?: string + readonly branchId?: string +} + +export interface SetConversationDraftResult { + readonly draft: DraftRecord + readonly replayed: boolean +} + +export class ConversationDrafts { + readonly #host: ConversationHost + + constructor(host: ConversationHost) { + this.#host = host + } + + async set(input: SetConversationDraftInput): Promise { + return coordinateConversationOperation(this.#host, 'set-draft', input, () => this.#set(input)) + } + + async #set(input: SetConversationDraftInput): Promise { + if (Buffer.byteLength(input.text, 'utf8') > MAX_DRAFT_BYTES) { + throw new AppError('DRAFT_TOO_LARGE', 'Draft exceeds 1 MiB') + } + const state = this.#host.state() + const conversationId = parseConversationId(input.conversationId ?? state.conversationId) + const conversation = state.conversations.find( + (candidate) => candidate.id === conversationId && candidate.deletedAt === undefined, + ) + if (!conversation) { + throw new AppError('UNKNOWN_CONVERSATION', `Conversation ${conversationId} is unavailable`) + } + const branchId = parseBranchId(input.branchId ?? conversation.activeBranchId) + const branch = state.branches.find( + (candidate) => candidate.id === branchId && candidate.conversationId === conversationId, + ) + if (!branch) throw new AppError('UNKNOWN_BRANCH', `Branch ${branchId} is unavailable`) + const existing = state.drafts.find((candidate) => candidate.id === branch.draftId) + if (!existing) throw new AppError('DRAFT_MISSING', `Branch ${branchId} has no draft record`) + + const text = redactSensitiveText(input.text, MAX_DRAFT_BYTES) + const operationId = parseOperation(input.operationId, 'set-draft') + const digest = requestDigest('set-draft', { conversationId, branchId, text }) + const replay = operationReplay(state, operationId, 'draft-update', digest) + if (replay) return { draft: existing, replayed: true } + + const at = this.#host.now() + const draft: DraftRecord = { ...existing, text, updatedAt: at } + await this.#host.commit({ + kind: 'draft.recorded', + draft, + operation: acknowledgedOperation({ + id: operationId, + kind: 'draft-update', + digest, + at, + target: { kind: 'branch', id: branchId }, + }), + }) + return { draft, replayed: false } + } +} diff --git a/src/app/conversation-exports.ts b/src/app/conversation-exports.ts new file mode 100644 index 0000000..556872e --- /dev/null +++ b/src/app/conversation-exports.ts @@ -0,0 +1,255 @@ +import { resolve } from 'node:path' +import { readNoFollow, writePrivateFile } from '../adapters/persistence/safe-file.js' +import { canonicalDigest, canonicalJson } from '../domain/canonical.js' +import type { ConversationRecord, MessageRecord } from '../domain/entities.js' +import { parseConversationId } from '../domain/ids.js' +import { + redactStructuredValue, + redactStructuredValueWithNumericTelemetry, +} from '../domain/redaction.js' +import type { BraidState } from '../domain/state.js' +import { messagesVisibleOnBranch } from './conversation-context.js' +import { + acknowledgedOperation, + coordinateConversationOperation, + operationReplay, + parseOperation, + requestDigest, +} from './conversation-support.js' +import type { ConversationHost } from './conversation-types.js' +import { AppError } from './errors.js' + +export const MAX_CONVERSATION_DOCUMENT_BYTES = 2 * 1024 * 1024 + +export interface ConversationExportDocument { + readonly schemaVersion: 2 + readonly format: 'braid-conversation' + readonly exportedAt: string + readonly conversationId: string + readonly content: Readonly> + readonly contentDigest: string + readonly redacted: true + readonly externalControlsDisabled: true +} + +export interface ExportConversationInput { + readonly operationId: string + readonly conversationId?: string + readonly format?: 'json' | 'markdown' + readonly destination?: string +} + +export interface ExportConversationResult { + readonly format: 'json' | 'markdown' + readonly conversationId: string + readonly contentDigest: string + readonly bytes: number + readonly content?: string + readonly destination?: string + readonly replayed: boolean +} + +export class ConversationExports { + readonly #host: ConversationHost + + constructor(host: ConversationHost) { + this.#host = host + } + + async export(input: ExportConversationInput): Promise { + return coordinateConversationOperation(this.#host, 'export', input, () => this.#export(input)) + } + + async #export(input: ExportConversationInput): Promise { + const state = this.#host.state() + const conversation = selectConversation(state, input.conversationId) + const format = input.format ?? 'json' + const document = conversationDocument(state, conversation, this.#host.now()) + const content = + format === 'json' ? canonicalJson(document) : conversationMarkdown(state, document) + const bytes = Buffer.byteLength(content, 'utf8') + if (bytes > MAX_CONVERSATION_DOCUMENT_BYTES) { + throw new AppError('EXPORT_TOO_LARGE', 'Conversation export exceeds 2 MiB') + } + const destination = input.destination === undefined ? undefined : resolve(input.destination) + const operationId = parseOperation(input.operationId, 'export') + const digest = requestDigest('export', { + conversationId: conversation.id, + format, + contentDigest: document.contentDigest, + destination: destination ?? null, + }) + const replay = operationReplay(state, operationId, 'export', digest) + if (replay) { + if (destination !== undefined) assertExistingExport(destination, content) + return resultForExport( + format, + conversation.id, + document.contentDigest, + bytes, + content, + destination, + true, + ) + } + if (destination !== undefined) writePrivateFile(destination, `${content}\n`) + const at = this.#host.now() + await this.#host.commit({ + kind: 'operation.updated', + operation: acknowledgedOperation({ + id: operationId, + kind: 'export', + digest, + at, + target: { kind: 'conversation', id: conversation.id }, + }), + }) + return resultForExport( + format, + conversation.id, + document.contentDigest, + bytes, + content, + destination, + false, + ) + } +} + +function selectConversation(state: BraidState, value: string | undefined): ConversationRecord { + const id = parseConversationId(value ?? state.conversationId) + const conversation = state.conversations.find( + (candidate) => candidate.id === id && candidate.deletedAt === undefined, + ) + if (!conversation) throw new AppError('UNKNOWN_CONVERSATION', `Conversation ${id} is unavailable`) + return conversation +} + +function conversationDocument( + state: BraidState, + conversation: ConversationRecord, + exportedAt: string, +): ConversationExportDocument { + const branches = state.branches.filter((branch) => branch.conversationId === conversation.id) + const branchIds = new Set(branches.map((branch) => branch.id)) + const messages = uniqueMessages( + branches.flatMap((branch) => messagesVisibleOnBranch(state, branch.id)), + ) + const messageIds = new Set(messages.map((message) => message.id)) + const turns = state.turns.filter( + (turn) => turn.conversationId === conversation.id || branchIds.has(turn.branchId), + ) + const runIds = new Set(turns.flatMap((turn) => turn.runIds)) + const runs = state.runs.filter((run) => runIds.has(run.id)) + const analyses = state.analyses.filter( + (analysis) => analysis.source.conversationId === conversation.id, + ) + const analysisIds = new Set(analyses.map((analysis) => analysis.id)) + const nodeIds = new Set( + state.graphNodes + .filter((node) => + node.reference.kind === 'conversation' + ? node.reference.id === conversation.id + : node.reference.kind === 'branch' + ? branchIds.has(node.reference.id) + : node.reference.kind === 'message' + ? messageIds.has(node.reference.id) + : node.reference.kind === 'turn' + ? turns.some((turn) => turn.id === node.reference.id) + : node.reference.kind === 'run' + ? runIds.has(node.reference.id) + : node.reference.kind === 'analysis' + ? analysisIds.has(node.reference.id) + : false, + ) + .map((node) => node.id), + ) + const content = redactStructuredValueWithNumericTelemetry( + { + conversation, + branches, + messages, + messageParts: state.messageParts.filter((part) => messageIds.has(part.messageId)), + turns, + runs, + analyses, + graphNodes: state.graphNodes.filter((node) => nodeIds.has(node.id)), + graphEdges: state.graphEdges.filter( + (edge) => nodeIds.has(edge.source) && nodeIds.has(edge.destination), + ), + feedbackDecisions: state.feedbackDecisions.filter( + (decision) => decision.conversationId === conversation.id, + ), + }, + undefined, + { maxDepth: 24, maxItems: 20_000, maxBytes: MAX_CONVERSATION_DOCUMENT_BYTES }, + ) as Readonly> + return { + schemaVersion: 2, + format: 'braid-conversation', + exportedAt, + conversationId: conversation.id, + content, + contentDigest: canonicalDigest(content), + redacted: true, + externalControlsDisabled: true, + } +} + +function conversationMarkdown(state: BraidState, document: ConversationExportDocument): string { + const content = document.content as { readonly conversation?: ConversationRecord } + const conversation = content.conversation + const source = selectConversation(state, document.conversationId) + const branch = state.branches.find((candidate) => candidate.id === source.activeBranchId) + const messages = branch ? messagesVisibleOnBranch(state, branch.id) : [] + const lines = [ + `# ${conversation?.title ?? 'Braid conversation'}`, + '', + ``, + ``, + '', + ] + for (const message of messages) { + const text = redactStructuredValue(message.text) as string + lines.push(`## ${message.role}`, '', text || '[non-text message]', '') + } + return lines.join('\n') +} + +function uniqueMessages(messages: readonly MessageRecord[]): readonly MessageRecord[] { + const seen = new Set() + return messages.filter((message) => { + if (seen.has(message.id)) return false + seen.add(message.id) + return true + }) +} + +function resultForExport( + format: 'json' | 'markdown', + conversationId: string, + contentDigest: string, + bytes: number, + content: string, + destination: string | undefined, + replayed: boolean, +): ExportConversationResult { + return { + format, + conversationId, + contentDigest, + bytes, + ...(destination === undefined ? { content } : { destination }), + replayed, + } +} + +function assertExistingExport(path: string, expected: string): void { + const existing = readNoFollow(path, MAX_CONVERSATION_DOCUMENT_BYTES + 1) + if (existing === undefined) { + throw new AppError('EXPORT_REPLAY_MISSING', 'The previously exported file is missing') + } + if (existing.toString('utf8').replace(/\n$/u, '') !== expected) { + throw new AppError('EXPORT_REPLAY_CONFLICT', 'The export destination contains different data') + } +} diff --git a/src/app/conversation-import-builder.ts b/src/app/conversation-import-builder.ts new file mode 100644 index 0000000..a9a6183 --- /dev/null +++ b/src/app/conversation-import-builder.ts @@ -0,0 +1,293 @@ +import { canonicalJson } from '../domain/canonical.js' +import type { + BranchRecord, + ConversationRecord, + DraftRecord, + QueueRecord, +} from '../domain/entities.js' +import type { BraidEvent } from '../domain/events.js' +import type { Digest, OperationId } from '../domain/ids.js' +import { parseConversationId } from '../domain/ids.js' +import { assertBraidState, DomainInvariantError } from '../domain/invariants.js' +import { MAX_CONVERSATION_IMPORT_EVENT_BYTES } from '../domain/redaction.js' +import { applyConversationEvent } from '../domain/reducer-conversation-events.js' +import type { BraidState } from '../domain/state.js' +import type { PreparedConversationImport } from './conversation-import-document.js' +import { importConversationGraph } from './conversation-import-graph.js' +import { importConversationMessages } from './conversation-import-messages.js' +import { importConversationRuns } from './conversation-import-runs.js' +import { + assertConversationImportIdsAvailable, + assertConversationImportNavigable, + assertConversationImportReferences, +} from './conversation-import-validation.js' +import { + createConversationImportIds, + exactString, + importRecord, + importRecords, + oneOf, + optionalFiniteNumber, + requiredString, +} from './conversation-import-values.js' +import { + acknowledgedOperation, + normalizedTitle, + requireIdle, + requireWorkspace, +} from './conversation-support.js' +import { AppError } from './errors.js' + +export type ConversationImportedEvent = Extract< + BraidEvent, + { readonly kind: 'conversation.imported' } +> + +export function buildConversationImport(input: { + readonly state: BraidState + readonly prepared: PreparedConversationImport + readonly operationId: OperationId + readonly requestDigest: Digest + readonly at: string + readonly title?: string +}): ConversationImportedEvent { + requireIdle(input.state, 'Conversation import') + const workspaceId = requireWorkspace(input.state) + const content = input.prepared.document.content + const sourceConversation = importRecord(content.conversation, 'content.conversation') + const sourceConversationId = parseSourceConversation( + sourceConversation.id, + input.prepared.document.conversationId, + ) + if (sourceConversation.deletedAt !== undefined) { + throw new AppError('IMPORT_INVALID', 'Deleted conversations cannot be imported') + } + const ids = createConversationImportIds(input.operationId, input.prepared.contentDigest) + const conversationId = ids.id('conversation', sourceConversationId, 'content.conversation.id') + const sourceBranches = importRecords(content.branches, 'branch', 'content.branches') + if (sourceBranches.length === 0) { + throw new AppError('IMPORT_INVALID', 'Conversation import contains no branches') + } + const branches = sourceBranches.map((record, index) => + importBranch(record, index, ids, sourceConversationId, conversationId), + ) + const activeBranchId = ids.id( + 'branch', + sourceConversation.activeBranchId, + 'content.conversation.activeBranchId', + ) + const retention = importRecord(sourceConversation.retention, 'content.conversation.retention') + const conversation: ConversationRecord = { + id: conversationId, + workspaceId, + title: normalizedTitle( + input.title, + requiredString(sourceConversation.title, 'content.conversation.title'), + ), + activeBranchId, + createdAt: requiredString(sourceConversation.createdAt, 'content.conversation.createdAt'), + updatedAt: requiredString(sourceConversation.updatedAt, 'content.conversation.updatedAt'), + archived: false, + retention: { + ...optionalField( + 'completedRunDays', + optionalFiniteNumber( + retention.completedRunDays, + 'content.conversation.retention.completedRunDays', + ), + ), + ...optionalField( + 'traceDays', + optionalFiniteNumber(retention.traceDays, 'content.conversation.retention.traceDays'), + ), + ...optionalField( + 'analysisDays', + optionalFiniteNumber(retention.analysisDays, 'content.conversation.retention.analysisDays'), + ), + ...optionalField( + 'toolOutputBytes', + optionalFiniteNumber( + retention.toolOutputBytes, + 'content.conversation.retention.toolOutputBytes', + ), + ), + ...optionalField( + 'cacheBytes', + optionalFiniteNumber(retention.cacheBytes, 'content.conversation.retention.cacheBytes'), + ), + }, + } + const drafts: DraftRecord[] = branches.map((branch) => ({ + id: branch.draftId, + branchId: branch.id, + text: '', + updatedAt: input.at, + })) + const queues: QueueRecord[] = branches.map((branch) => ({ + id: branch.queueId, + branchId: branch.id, + entryIds: [], + createdAt: input.at, + updatedAt: input.at, + })) + const messages = importConversationMessages({ + ids, + sourceConversationId, + conversationId, + messages: importRecords(content.messages, 'message', 'content.messages'), + messageParts: importRecords(content.messageParts, 'messagePart', 'content.messageParts'), + turns: importRecords(content.turns, 'turn', 'content.turns'), + }) + const runs = importConversationRuns({ + ids, + sourceConversationId, + conversationId, + operationId: input.operationId, + fallbackProfile: input.state.profile, + runs: importRecords(content.runs, 'run', 'content.runs'), + analyses: importRecords(content.analyses, 'analysis', 'content.analyses'), + feedbackDecisions: importRecords( + content.feedbackDecisions, + 'feedbackDecision', + 'content.feedbackDecisions', + ), + }) + const graph = importConversationGraph({ + state: input.state, + content, + ids, + workspaceId, + conversation, + branches, + analyses: runs.analyses, + sourceContentDigest: input.prepared.contentDigest, + at: input.at, + }) + const event: ConversationImportedEvent = { + kind: 'conversation.imported', + conversation, + branches, + drafts, + queues, + messages: messages.messages, + messageParts: messages.messageParts, + turns: messages.turns, + runs: runs.runs, + analyses: runs.analyses, + graphNodes: graph.nodes, + graphEdges: graph.edges, + feedbackDecisions: runs.feedbackDecisions, + sourceContentDigest: input.prepared.contentDigest, + operation: acknowledgedOperation({ + id: input.operationId, + kind: 'conversation-import', + digest: input.requestDigest, + at: input.at, + target: { kind: 'conversation', id: conversation.id }, + result: { + contentDigest: input.prepared.contentDigest, + bytes: input.prepared.bytes, + }, + }), + } + if (Buffer.byteLength(canonicalJson(event), 'utf8') > MAX_CONVERSATION_IMPORT_EVENT_BYTES) { + throw new AppError('IMPORT_TOO_LARGE', 'Expanded conversation import exceeds 4 MiB') + } + assertConversationImportIdsAvailable(input.state, event) + assertConversationImportReferences(event) + try { + const candidate = applyConversationEvent(input.state, event) + assertConversationImportNavigable(candidate, event) + assertBraidState(candidate) + } catch (error) { + if (error instanceof DomainInvariantError) { + throw new AppError('IMPORT_INVALID', `Conversation import is inconsistent: ${error.message}`) + } + throw error + } + return event +} + +function importBranch( + record: Readonly>, + index: number, + ids: ReturnType, + sourceConversationId: string, + conversationId: ConversationRecord['id'], +): BranchRecord { + const label = `content.branches[${index}]` + const sourceId = requiredString(record.id, `${label}.id`) + exactString(record.conversationId, sourceConversationId, `${label}.conversationId`) + const id = ids.id('branch', sourceId, `${label}.id`) + const source = + record.source === undefined ? undefined : importRecord(record.source, `${label}.source`) + if (source !== undefined) { + exactString(source.conversationId, sourceConversationId, `${label}.source.conversationId`) + } + return { + id, + conversationId, + ...(source === undefined + ? {} + : { + source: { + conversationId, + branchId: ids.id('branch', source.branchId, `${label}.source.branchId`), + ...(source.throughMessageId === undefined + ? {} + : { + throughMessageId: ids.id( + 'message', + source.throughMessageId, + `${label}.source.throughMessageId`, + ), + }), + ...(source.throughTurnId === undefined + ? {} + : { + throughTurnId: ids.id( + 'turn', + source.throughTurnId, + `${label}.source.throughTurnId`, + ), + }), + }, + }), + overrides: {}, + draftId: ids.derived('draft', `branch:${sourceId}`), + queueId: ids.derived('queue', `branch:${sourceId}`), + ...(record.tipMessageId === undefined + ? {} + : { tipMessageId: ids.id('message', record.tipMessageId, `${label}.tipMessageId`) }), + status: + oneOf( + record.status, + ['active', 'preparing', 'failed-preparation', 'archived'] as const, + `${label}.status`, + ) === 'archived' + ? 'archived' + : 'active', + createdAt: requiredString(record.createdAt, `${label}.createdAt`), + updatedAt: requiredString(record.updatedAt, `${label}.updatedAt`), + } +} + +function parseSourceConversation(value: unknown, documentId: string): string { + let id: string + try { + id = parseConversationId(value) + } catch { + throw new AppError('IMPORT_INVALID', 'Conversation import identifier is invalid') + } + if (id !== documentId) { + throw new AppError( + 'IMPORT_INVALID', + 'Conversation import identifier does not match its content', + ) + } + return id +} + +function optionalField(key: K, value: T | undefined): { [P in K]?: T } { + return value === undefined ? {} : ({ [key]: value } as { [P in K]?: T }) +} diff --git a/src/app/conversation-import-document.ts b/src/app/conversation-import-document.ts new file mode 100644 index 0000000..9ccb48c --- /dev/null +++ b/src/app/conversation-import-document.ts @@ -0,0 +1,223 @@ +import { resolve } from 'node:path' +import { readNoFollow } from '../adapters/persistence/safe-file.js' +import { canonicalDigest } from '../domain/canonical.js' +import type { Digest } from '../domain/ids.js' +import { parseDigestValue } from '../domain/ids.js' +import { redactStructuredValueWithNumericTelemetry } from '../domain/redaction.js' +import { isCanonicalIsoDateTime } from '../domain/text.js' +import { + type ConversationExportDocument, + MAX_CONVERSATION_DOCUMENT_BYTES, +} from './conversation-exports.js' +import { AppError } from './errors.js' + +const MAX_IMPORT_DEPTH = 24 +// The 2 MiB byte limit remains the primary memory bound. A long, valid run history +// contains many small nested receipt and event fields, so its aggregate node count +// is intentionally higher than any single collection limit used during redaction. +const MAX_IMPORT_ITEMS = 100_000 +const DOCUMENT_KEYS = new Set([ + 'schemaVersion', + 'format', + 'exportedAt', + 'conversationId', + 'content', + 'contentDigest', + 'redacted', + 'externalControlsDisabled', +]) +const CONTENT_KEYS = new Set([ + 'conversation', + 'branches', + 'messages', + 'messageParts', + 'turns', + 'runs', + 'analyses', + 'graphNodes', + 'graphEdges', + 'feedbackDecisions', +]) +const DANGEROUS_KEYS = new Set(['__proto__', 'prototype', 'constructor']) + +export interface PreparedConversationImport { + readonly document: ConversationExportDocument + readonly contentDigest: Digest + readonly bytes: number +} + +export interface ConversationImportSource { + readonly content?: string + readonly source?: string +} + +export function prepareConversationImport( + input: ConversationImportSource, +): PreparedConversationImport { + let raw = readImportSource(input) + if (input.source !== undefined && raw.endsWith('\n')) { + raw = raw.slice(0, -1) + if (raw.endsWith('\r')) raw = raw.slice(0, -1) + } + const bytes = Buffer.byteLength(raw, 'utf8') + if (bytes > MAX_CONVERSATION_DOCUMENT_BYTES) { + throw new AppError('IMPORT_TOO_LARGE', 'Conversation import exceeds 2 MiB') + } + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + throw new AppError('IMPORT_INVALID_JSON', 'Conversation import is not valid JSON') + } + assertBoundedImport(parsed) + const document = assertConversationDocument(parsed) + let actual: Digest + try { + actual = canonicalDigest(document.content) + } catch { + throw new AppError('IMPORT_INVALID', 'Conversation import content is not canonical JSON') + } + if (actual !== document.contentDigest) { + throw new AppError('IMPORT_DIGEST_MISMATCH', 'Conversation import checksum does not match') + } + let redacted: unknown + try { + redacted = redactStructuredValueWithNumericTelemetry(document.content, undefined, { + maxDepth: MAX_IMPORT_DEPTH, + maxItems: MAX_IMPORT_ITEMS, + maxBytes: MAX_CONVERSATION_DOCUMENT_BYTES, + }) + } catch { + throw new AppError('IMPORT_TOO_COMPLEX', 'Conversation import exceeds structural limits') + } + if (canonicalDigest(redacted) !== actual) { + throw new AppError('IMPORT_REDACTION_REQUIRED', 'Conversation import contains unsafe data') + } + return { document, contentDigest: actual, bytes } +} + +function readImportSource(input: ConversationImportSource): string { + const hasContent = input.content !== undefined + const hasSource = input.source !== undefined + if (hasContent === hasSource) { + throw new AppError( + 'IMPORT_SOURCE_REQUIRED', + 'Provide exactly one import content or source path', + ) + } + if (input.content !== undefined) return input.content + if (input.source?.trim().length === 0) { + throw new AppError('IMPORT_SOURCE_REQUIRED', 'Conversation import source path is empty') + } + try { + const bytes = readNoFollow(resolve(input.source as string), MAX_CONVERSATION_DOCUMENT_BYTES + 1) + if (bytes === undefined) + throw new AppError('IMPORT_NOT_FOUND', 'Conversation import file was not found') + return bytes.toString('utf8') + } catch (error) { + if (error instanceof AppError) throw error + const message = error instanceof Error ? error.message : '' + if (/too large/iu.test(message)) { + throw new AppError('IMPORT_TOO_LARGE', 'Conversation import exceeds 2 MiB') + } + throw new AppError('IMPORT_SOURCE_UNSAFE', 'Conversation import file could not be read safely') + } +} + +function assertConversationDocument(value: unknown): ConversationExportDocument { + const document = recordValue(value, 'Conversation import') + assertExactKeys(document, DOCUMENT_KEYS, 'Conversation import') + if (document.schemaVersion !== 2 || document.format !== 'braid-conversation') { + throw new AppError('IMPORT_UNSUPPORTED', 'Conversation import format or version is unsupported') + } + if (!isCanonicalIsoDateTime(document.exportedAt)) { + throw new AppError('IMPORT_INVALID', 'Conversation import timestamp is invalid') + } + if (typeof document.conversationId !== 'string') { + throw new AppError('IMPORT_INVALID', 'Conversation import identifier is invalid') + } + if (document.redacted !== true || document.externalControlsDisabled !== true) { + throw new AppError( + 'IMPORT_UNSAFE', + 'Conversation import must be redacted with controls disabled', + ) + } + const content = recordValue(document.content, 'Conversation import content') + assertExactKeys(content, CONTENT_KEYS, 'Conversation import content') + for (const key of CONTENT_KEYS) { + if (key === 'conversation') continue + if (!Array.isArray(content[key])) { + throw new AppError('IMPORT_INVALID', `Conversation import content.${key} must be an array`) + } + } + recordValue(content.conversation, 'Conversation import conversation') + let contentDigest: Digest + try { + contentDigest = parseDigestValue(document.contentDigest) + } catch { + throw new AppError('IMPORT_INVALID', 'Conversation import checksum is invalid') + } + return { + schemaVersion: 2, + format: 'braid-conversation', + exportedAt: document.exportedAt, + conversationId: document.conversationId, + content, + contentDigest, + redacted: true, + externalControlsDisabled: true, + } +} + +function assertBoundedImport(root: unknown): void { + const stack: { readonly value: unknown; readonly depth: number }[] = [{ value: root, depth: 0 }] + let items = 0 + while (stack.length > 0) { + const current = stack.pop() + if (!current) break + if (current.depth > MAX_IMPORT_DEPTH) { + throw new AppError('IMPORT_TOO_COMPLEX', 'Conversation import nesting is too deep') + } + if (current.value === null || typeof current.value !== 'object') { + if (typeof current.value === 'number' && !Number.isFinite(current.value)) { + throw new AppError('IMPORT_INVALID', 'Conversation import contains a non-finite number') + } + continue + } + const entries = Array.isArray(current.value) + ? current.value.map((value, index) => [String(index), value] as const) + : Object.entries(current.value) + items += entries.length + if (items > MAX_IMPORT_ITEMS) { + throw new AppError('IMPORT_TOO_COMPLEX', 'Conversation import contains too many items') + } + for (const [key, child] of entries) { + if (DANGEROUS_KEYS.has(key)) { + throw new AppError('IMPORT_INVALID', 'Conversation import contains a forbidden object key') + } + stack.push({ value: child, depth: current.depth + 1 }) + } + } +} + +export function recordValue(value: unknown, label: string): Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new AppError('IMPORT_INVALID', `${label} must be an object`) + } + return value as Record +} + +function assertExactKeys( + value: Readonly>, + allowed: ReadonlySet, + label: string, +): void { + const unknown = Object.keys(value).find((key) => !allowed.has(key)) + if (unknown !== undefined) { + throw new AppError('IMPORT_INVALID', `${label} contains unknown field ${unknown}`) + } + const missing = [...allowed].find((key) => value[key] === undefined) + if (missing !== undefined) { + throw new AppError('IMPORT_INVALID', `${label} is missing field ${missing}`) + } +} diff --git a/src/app/conversation-import-graph.ts b/src/app/conversation-import-graph.ts new file mode 100644 index 0000000..7c27750 --- /dev/null +++ b/src/app/conversation-import-graph.ts @@ -0,0 +1,186 @@ +import { canonicalDigest } from '../domain/canonical.js' +import type { + BranchRecord, + ConversationRecord, + GraphEdgeKind, + GraphEdgeRecord, + GraphNodeRecord, + GraphNodeReference, +} from '../domain/entities.js' +import { graphEdge, graphNode, graphNodeId } from '../domain/graph-records.js' +import type { Digest } from '../domain/ids.js' +import type { BraidState } from '../domain/state.js' +import type { ConversationImportIds } from './conversation-import-values.js' +import { + importRecord, + importRecords, + oneOf, + optionalString, + requiredString, +} from './conversation-import-values.js' +import { AppError } from './errors.js' + +const GRAPH_EDGE_KINDS = [ + 'continued', + 'branched_at', + 'cloned_from', + 'retried', + 'handed_off', + 'analyzed', + 'compared_left', + 'compared_right', + 'checkpointed', + 'forked_environment', + 'spawned', + 'supervised_by', + 'attached', +] as const + +export function importConversationGraph(input: { + readonly state: BraidState + readonly content: Readonly> + readonly ids: ConversationImportIds + readonly workspaceId: NonNullable + readonly conversation: ConversationRecord + readonly branches: readonly BranchRecord[] + readonly analyses: readonly BraidState['analyses'][number][] + readonly sourceContentDigest: Digest + readonly at: string +}): { readonly nodes: readonly GraphNodeRecord[]; readonly edges: readonly GraphEdgeRecord[] } { + const sourceNodes = importRecords(input.content.graphNodes, 'graphNode', 'content.graphNodes') + const nodeBySourceId = new Map() + const nodes: GraphNodeRecord[] = [] + for (const [index, record] of sourceNodes.entries()) { + const label = `content.graphNodes[${index}]` + const sourceId = requiredString(record.id, `${label}.id`) + const reference = importGraphReference(input.ids, record.reference, `${label}.reference`) + const node: GraphNodeRecord = { + id: graphNodeId(reference), + reference, + ...optionalField('title', optionalString(record.title, `${label}.title`)), + ...optionalField('status', optionalString(record.status, `${label}.status`)), + createdAt: requiredString(record.createdAt, `${label}.createdAt`), + updatedAt: requiredString(record.updatedAt, `${label}.updatedAt`), + } + if (nodes.some((candidate) => candidate.id === node.id)) { + throw new AppError('IMPORT_INVALID', `${label} duplicates an imported graph reference`) + } + nodeBySourceId.set(sourceId, node) + nodes.push(node) + } + const requiredNodes = [ + graphNode( + { kind: 'conversation', id: input.conversation.id }, + input.at, + input.conversation.title, + ), + ...input.branches.map((branch) => graphNode({ kind: 'branch', id: branch.id }, input.at)), + ...input.analyses.map((analysis) => graphNode({ kind: 'analysis', id: analysis.id }, input.at)), + ] + for (const node of requiredNodes) addUnique(nodes, node) + const workspaceReference = { kind: 'workspace' as const, id: input.workspaceId } + const workspaceNode = + input.state.graphNodes.find((node) => node.id === graphNodeId(workspaceReference)) ?? + graphNode(workspaceReference, input.at, input.state.workspace ?? undefined) + addUnique(nodes, workspaceNode) + + const edges: GraphEdgeRecord[] = [] + const sourceEdges = importRecords(input.content.graphEdges, 'graphEdge', 'content.graphEdges') + for (const [index, record] of sourceEdges.entries()) { + const label = `content.graphEdges[${index}]` + const sourceEdgeId = requiredString(record.id, `${label}.id`) + const source = nodeBySourceId.get(requiredString(record.source, `${label}.source`)) + const destination = nodeBySourceId.get( + requiredString(record.destination, `${label}.destination`), + ) + if (!source || !destination) { + throw new AppError('IMPORT_INVALID', `${label} references a missing graph node`) + } + const edge = graphEdge({ + kind: oneOf(record.kind, GRAPH_EDGE_KINDS, `${label}.kind`) as GraphEdgeKind, + source: source.reference, + destination: destination.reference, + at: requiredString(record.createdAt, `${label}.createdAt`), + provenance: { + sourceDigest: canonicalDigest({ + importDigest: input.sourceContentDigest, + sourceEdgeId, + }), + }, + }) + if (edges.some((candidate) => candidate.id === edge.id)) { + throw new AppError('IMPORT_INVALID', `${label} duplicates an imported graph edge`) + } + edges.push(edge) + } + + const conversationReference = { kind: 'conversation' as const, id: input.conversation.id } + addSemanticEdge( + edges, + graphEdge({ + kind: 'attached', + source: workspaceReference, + destination: conversationReference, + at: input.at, + provenance: { sourceDigest: input.sourceContentDigest }, + }), + ) + for (const branch of input.branches) { + addSemanticEdge( + edges, + graphEdge({ + kind: 'attached', + source: conversationReference, + destination: { kind: 'branch', id: branch.id }, + at: input.at, + provenance: { sourceDigest: input.sourceContentDigest }, + }), + ) + } + return { nodes, edges } +} + +function importGraphReference( + ids: ConversationImportIds, + value: unknown, + label: string, +): GraphNodeReference { + const reference = importRecord(value, label) + const kind = oneOf( + reference.kind, + ['conversation', 'branch', 'turn', 'run', 'message', 'analysis'] as const, + `${label}.kind`, + ) + switch (kind) { + case 'conversation': + return { kind, id: ids.id(kind, reference.id, `${label}.id`) } + case 'branch': + return { kind, id: ids.id(kind, reference.id, `${label}.id`) } + case 'turn': + return { kind, id: ids.id(kind, reference.id, `${label}.id`) } + case 'run': + return { kind, id: ids.id(kind, reference.id, `${label}.id`) } + case 'message': + return { kind, id: ids.id(kind, reference.id, `${label}.id`) } + case 'analysis': + return { kind, id: ids.id(kind, reference.id, `${label}.id`) } + } +} + +function addUnique(records: T[], record: T): void { + if (!records.some((candidate) => candidate.id === record.id)) records.push(record) +} + +function addSemanticEdge(records: GraphEdgeRecord[], edge: GraphEdgeRecord): void { + const exists = records.some( + (candidate) => + candidate.kind === edge.kind && + candidate.source === edge.source && + candidate.destination === edge.destination, + ) + if (!exists) records.push(edge) +} + +function optionalField(key: K, value: T | undefined): { [P in K]?: T } { + return value === undefined ? {} : ({ [key]: value } as { [P in K]?: T }) +} diff --git a/src/app/conversation-import-messages.ts b/src/app/conversation-import-messages.ts new file mode 100644 index 0000000..10839d0 --- /dev/null +++ b/src/app/conversation-import-messages.ts @@ -0,0 +1,355 @@ +import type { MessagePartRecord, MessageRecord, TurnRecord } from '../domain/entities.js' +import type { ConversationId } from '../domain/ids.js' +import type { RuntimeMessagePart } from '../domain/runtime-projection.js' +import type { ConversationImportIds } from './conversation-import-values.js' +import { + booleanValue, + canonicalDateTime, + exactString, + finiteInteger, + importRecord, + oneOf, + optionalString, + requiredString, + stringValue, +} from './conversation-import-values.js' +import { AppError } from './errors.js' + +const MESSAGE_STATUSES = [ + 'incomplete', + 'streaming', + 'complete', + 'failed', + 'aborted', + 'cancelled', + 'blocked', + 'expired', + 'unknown', + 'redacted', +] as const +const TURN_STATUSES = [ + 'queued', + 'prepared', + 'running', + 'waiting', + 'completed', + 'failed', + 'cancelled', + 'unknown', +] as const +const TERMINAL_MESSAGE_STATUSES = new Set([ + 'complete', + 'failed', + 'aborted', + 'cancelled', + 'blocked', + 'expired', + 'unknown', + 'redacted', +]) +const TERMINAL_TURN_STATUSES = new Set(['completed', 'failed', 'cancelled', 'unknown']) +const RUNTIME_PART_KINDS = [ + 'text', + 'reasoning', + 'tool-call', + 'tool-result', + 'artifact', + 'proposal', + 'warning', + 'error', + 'interaction', + 'system', + 'unknown', +] as const + +export interface ImportedConversationMessages { + readonly messages: readonly MessageRecord[] + readonly messageParts: readonly MessagePartRecord[] + readonly turns: readonly TurnRecord[] +} + +export function importConversationMessages(input: { + readonly ids: ConversationImportIds + readonly sourceConversationId: string + readonly conversationId: ConversationId + readonly messages: readonly Record[] + readonly messageParts: readonly Record[] + readonly turns: readonly Record[] +}): ImportedConversationMessages { + const messageParts = input.messageParts.map((record, index) => + importMessagePart(input.ids, record, `messageParts[${index}]`), + ) + const partsByMessage = new Map() + for (const part of messageParts) { + const parts = partsByMessage.get(part.messageId) ?? [] + parts.push(part) + partsByMessage.set(part.messageId, parts) + } + const messages = input.messages.map((record, index) => { + const label = `messages[${index}]` + const id = input.ids.id('message', record.id, `${label}.id`) + exactString(record.conversationId, input.sourceConversationId, `${label}.conversationId`) + const sourceStatus = oneOf(record.status, MESSAGE_STATUSES, `${label}.status`) + const complete = booleanValue(record.complete, `${label}.complete`) + const status = TERMINAL_MESSAGE_STATUSES.has(sourceStatus) ? sourceStatus : 'unknown' + const turnId = + record.turnId === undefined + ? undefined + : input.ids.id('turn', record.turnId, `${label}.turnId`) + const runId = + record.runId === undefined ? undefined : input.ids.id('run', record.runId, `${label}.runId`) + const storedParts = (partsByMessage.get(id) ?? []).sort( + (left, right) => left.ordinal - right.ordinal, + ) + const declaredPartIds = stringArray(record.partIds, `${label}.partIds`).map((partId) => + input.ids.id('messagePart', partId, `${label}.partIds`), + ) + const actualPartIds = storedParts.map((part) => part.id) + if (!sameStringSet(declaredPartIds, actualPartIds)) { + throw new AppError('IMPORT_INVALID', `${label}.partIds do not match imported message parts`) + } + return { + id, + conversationId: input.conversationId, + branchId: input.ids.id('branch', record.branchId, `${label}.branchId`), + role: oneOf(record.role, ['user', 'assistant'] as const, `${label}.role`), + text: stringValue(record.text, `${label}.text`), + partIds: declaredPartIds, + parts: importRuntimeMessageParts(input.ids, record.parts, `${label}.parts`), + ...(record.partsTruncated === undefined + ? {} + : { partsTruncated: booleanValue(record.partsTruncated, `${label}.partsTruncated`) }), + status, + ...(turnId === undefined ? {} : { turnId }), + ...(runId === undefined ? {} : { runId }), + createdAt: requiredString(record.createdAt, `${label}.createdAt`), + updatedAt: requiredString(record.updatedAt, `${label}.updatedAt`), + complete: complete && TERMINAL_MESSAGE_STATUSES.has(status), + ...optionalField( + 'missingHistory', + optionalMissingHistory(input.ids, record.missingHistory, `${label}.missingHistory`), + ), + } satisfies MessageRecord + }) + const messageIds = new Set(messages.map((message) => message.id)) + const orphan = messageParts.find((part) => !messageIds.has(part.messageId)) + if (orphan) throw new AppError('IMPORT_INVALID', `Message part ${orphan.id} has no message`) + const turns = input.turns.map((record, index) => { + const label = `turns[${index}]` + exactString(record.conversationId, input.sourceConversationId, `${label}.conversationId`) + const sourceStatus = oneOf(record.status, TURN_STATUSES, `${label}.status`) + const runIds = stringArray(record.runIds, `${label}.runIds`).map((runId) => + input.ids.id('run', runId, `${label}.runIds`), + ) + const selectedRunId = + record.selectedRunId === undefined + ? undefined + : input.ids.id('run', record.selectedRunId, `${label}.selectedRunId`) + return { + id: input.ids.id('turn', record.id, `${label}.id`), + conversationId: input.conversationId, + branchId: input.ids.id('branch', record.branchId, `${label}.branchId`), + userMessageId: input.ids.id('message', record.userMessageId, `${label}.userMessageId`), + runIds, + ...(selectedRunId === undefined ? {} : { selectedRunId }), + status: TERMINAL_TURN_STATUSES.has(sourceStatus) ? sourceStatus : 'unknown', + createdAt: requiredString(record.createdAt, `${label}.createdAt`), + updatedAt: requiredString(record.updatedAt, `${label}.updatedAt`), + } satisfies TurnRecord + }) + return { messages, messageParts, turns } +} + +function importMessagePart( + ids: ConversationImportIds, + record: Readonly>, + label: string, +): MessagePartRecord { + const base = { + id: ids.id('messagePart', record.id, `${label}.id`), + messageId: ids.id('message', record.messageId, `${label}.messageId`), + ordinal: finiteInteger(record.ordinal, `${label}.ordinal`), + createdAt: requiredString(record.createdAt, `${label}.createdAt`), + updatedAt: requiredString(record.updatedAt, `${label}.updatedAt`), + } + const kind = requiredString(record.kind, `${label}.kind`) + switch (kind) { + case 'text': + case 'reasoning': + return { ...base, kind, text: stringValue(record.text, `${label}.text`) } + case 'tool-call': + return { + ...base, + kind, + name: requiredString(record.name, `${label}.name`), + arguments: importRecord(record.arguments, `${label}.arguments`) as never, + ...optionalField('callId', optionalString(record.callId, `${label}.callId`)), + status: oneOf( + record.status, + ['pending', 'running', 'completed', 'failed'] as const, + `${label}.status`, + ), + } + case 'tool-result': + return { + ...base, + kind, + ...optionalField('callId', optionalString(record.callId, `${label}.callId`)), + summary: requiredString(record.summary, `${label}.summary`), + ...(record.output === undefined ? {} : { output: record.output as never }), + status: oneOf(record.status, ['completed', 'failed'] as const, `${label}.status`), + } + case 'artifact': + return { + ...base, + kind, + artifactId: ids.id('artifact', record.artifactId, `${label}.artifactId`), + summary: requiredString(record.summary, `${label}.summary`), + } + case 'file': { + const path = optionalString(record.path, `${label}.path`) + const filename = optionalString(record.filename, `${label}.filename`) + return { + ...base, + kind, + ...optionalField('path', path), + ...optionalField('filename', filename), + ...optionalField('mediaType', optionalString(record.mediaType, `${label}.mediaType`)), + } + } + case 'image': + return { + ...base, + kind, + ...(record.artifactId === undefined + ? {} + : { artifactId: ids.id('artifact', record.artifactId, `${label}.artifactId`) }), + ...optionalField('mediaType', optionalString(record.mediaType, `${label}.mediaType`)), + ...optionalField('altText', optionalString(record.altText, `${label}.altText`)), + } + case 'warning': + return { ...base, kind, message: requiredString(record.message, `${label}.message`) } + case 'error': + return { + ...base, + kind, + message: requiredString(record.message, `${label}.message`), + retryable: booleanValue(record.retryable, `${label}.retryable`), + } + case 'unknown': + return { + ...base, + kind, + namespace: requiredString(record.namespace, `${label}.namespace`), + type: requiredString(record.type, `${label}.type`), + summary: requiredString(record.summary, `${label}.summary`), + } + default: + throw new AppError('IMPORT_INVALID', `${label}.kind is unsupported`) + } +} + +function importRuntimeMessageParts( + ids: ConversationImportIds, + value: unknown, + label: string, +): readonly RuntimeMessagePart[] { + if (!Array.isArray(value)) throw new AppError('IMPORT_INVALID', `${label} must be an array`) + const imported = value.map((item, index) => { + const partLabel = `${label}[${index}]` + const part = importRecord(item, partLabel) + const sourceId = requiredString(part.id, `${partLabel}.id`) + const source = importRuntimePartSource(part.source, `${partLabel}.source`) + const metadata = + part.metadata === undefined ? undefined : importRecord(part.metadata, `${partLabel}.metadata`) + return { + id: ids.derived('messagePart', `${partLabel}:${sourceId}`), + kind: oneOf(part.kind, RUNTIME_PART_KINDS, `${partLabel}.kind`), + ...optionalField('text', optionalText(part.text, `${partLabel}.text`)), + ...optionalField('status', optionalString(part.status, `${partLabel}.status`)), + ...optionalField('toolName', optionalString(part.toolName, `${partLabel}.toolName`)), + ...optionalField('callId', optionalString(part.callId, `${partLabel}.callId`)), + ...(part.input === undefined ? {} : { input: part.input }), + ...(part.result === undefined ? {} : { result: part.result }), + ...optionalField('error', optionalText(part.error, `${partLabel}.error`)), + ...(part.artifactId === undefined + ? {} + : { + artifactId: ids.derived( + 'artifact', + `${partLabel}:${requiredString(part.artifactId, `${partLabel}.artifactId`)}`, + ), + }), + ...optionalField('uri', optionalString(part.uri, `${partLabel}.uri`)), + ...optionalField('mimeType', optionalString(part.mimeType, `${partLabel}.mimeType`)), + ...optionalField('title', optionalString(part.title, `${partLabel}.title`)), + ...optionalField('metadata', metadata), + ...optionalField('source', source), + } satisfies RuntimeMessagePart + }) + const idsSeen = new Set() + for (const part of imported) { + if (idsSeen.has(part.id)) { + throw new AppError('IMPORT_INVALID', `${label} contains duplicate runtime part IDs`) + } + idsSeen.add(part.id) + } + return imported +} + +function importRuntimePartSource(value: unknown, label: string) { + if (value === undefined) return undefined + const source = importRecord(value, label) + return { + ...optionalField('eventId', optionalString(source.eventId, `${label}.eventId`)), + ...(source.sequence === undefined + ? {} + : { sequence: finiteInteger(source.sequence, `${label}.sequence`) }), + ...optionalField('cursor', optionalString(source.cursor, `${label}.cursor`)), + ...(source.occurredAt === undefined + ? {} + : { occurredAt: canonicalDateTime(source.occurredAt, `${label}.occurredAt`) }), + } +} + +function optionalText(value: unknown, label: string): string | undefined { + return value === undefined ? undefined : stringValue(value, label) +} + +function stringArray(value: unknown, label: string): readonly string[] { + if (!Array.isArray(value) || !value.every((entry) => typeof entry === 'string')) { + throw new AppError('IMPORT_INVALID', `${label} must be a text array`) + } + return value +} + +function optionalField(key: K, value: T | undefined): { [P in K]?: T } { + return value === undefined ? {} : ({ [key]: value } as { [P in K]?: T }) +} + +function sameStringSet(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((value) => right.includes(value)) +} + +function optionalMissingHistory(ids: ConversationImportIds, value: unknown, label: string) { + if (value === undefined) return undefined + const range = importRecord(value, label) + const fromSequence = finiteInteger(range.fromSequence, `${label}.fromSequence`) + const toSequence = + range.toSequence === undefined + ? undefined + : finiteInteger(range.toSequence, `${label}.toSequence`) + if (toSequence !== undefined && toSequence < fromSequence) { + throw new AppError('IMPORT_INVALID', `${label}.toSequence must not precede fromSequence`) + } + return { + runId: ids.id('run', range.runId, `${label}.runId`), + fromSequence, + ...optionalField('toSequence', toSequence), + reason: oneOf( + range.reason, + ['gap', 'expired-cursor', 'provider-missing', 'replay-unsupported'] as const, + `${label}.reason`, + ), + } +} diff --git a/src/app/conversation-import-runs.ts b/src/app/conversation-import-runs.ts new file mode 100644 index 0000000..57be54b --- /dev/null +++ b/src/app/conversation-import-runs.ts @@ -0,0 +1,399 @@ +import { type AgentProfile, snapshotAgentProfile } from '@tangle-network/agent-interface' +import { canonicalDigest } from '../domain/canonical.js' +import type { + AnalysisCitation, + AnalysisFinding, + AnalysisRecord, + FeedbackDecisionRecord, + RunRecord, +} from '../domain/entities.js' +import type { ConversationId, OperationId } from '../domain/ids.js' +import { parseDigestValue } from '../domain/ids.js' +import { createAdmissionReceipt } from '../domain/receipts.js' +import { UNKNOWN_RUN_CAPABILITIES } from '../ports/execution.js' +import type { ConversationImportIds } from './conversation-import-values.js' +import { + booleanValue, + canonicalDateTime, + exactString, + finiteInteger, + finiteNumber, + importRecord, + oneOf, + optionalFiniteNumber, + optionalString, + requiredString, + stringValue, +} from './conversation-import-values.js' +import { AppError } from './errors.js' + +const RUN_STATUSES = [ + 'prepared', + 'starting', + 'running', + 'waiting', + 'detached', + 'reconnecting', + 'cancelling', + 'completed', + 'cancelled', + 'failed', + 'expired', + 'unknown', + 'streaming', + 'aborted', + 'blocked', +] as const +const TERMINAL_RUN_STATUSES = new Set([ + 'completed', + 'cancelled', + 'failed', + 'expired', + 'unknown', + 'aborted', + 'blocked', +]) +const ANALYSIS_STATUSES = [ + 'preparing', + 'running', + 'completed', + 'cancelled', + 'failed', + 'unknown', +] as const +const TERMINAL_ANALYSIS_STATUSES = new Set(['completed', 'cancelled', 'failed', 'unknown']) +const FEEDBACK_CATEGORIES = [ + 'approval', + 'rejection', + 'revision', + 'retry', + 'fork', + 'selection', + 'automation', +] as const + +export interface ImportedConversationRuns { + readonly runs: readonly RunRecord[] + readonly analyses: readonly AnalysisRecord[] + readonly feedbackDecisions: readonly FeedbackDecisionRecord[] +} + +export function importConversationRuns(input: { + readonly ids: ConversationImportIds + readonly sourceConversationId: string + readonly conversationId: ConversationId + readonly operationId: OperationId + readonly fallbackProfile: Readonly + readonly runs: readonly Record[] + readonly analyses: readonly Record[] + readonly feedbackDecisions: readonly Record[] +}): ImportedConversationRuns { + const runs = input.runs.map((record, index) => importRun(input, record, `runs[${index}]`)) + const analyses = input.analyses.map((record, index) => + importAnalysis(input, record, `analyses[${index}]`), + ) + const feedbackDecisions = input.feedbackDecisions.map((record, index) => { + const label = `feedbackDecisions[${index}]` + exactString(record.conversationId, input.sourceConversationId, `${label}.conversationId`) + return { + id: input.ids.id('feedbackDecision', record.id, `${label}.id`), + conversationId: input.conversationId, + category: oneOf(record.category, FEEDBACK_CATEGORIES, `${label}.category`), + chosenOption: requiredString(record.chosenOption, `${label}.chosenOption`), + ...optionalField('feedback', optionalString(record.feedback, `${label}.feedback`)), + automated: booleanValue(record.automated, `${label}.automated`), + createdAt: requiredString(record.createdAt, `${label}.createdAt`), + } satisfies FeedbackDecisionRecord + }) + return { runs, analyses, feedbackDecisions } +} + +function importRun( + input: Parameters[0], + record: Readonly>, + label: string, +): RunRecord { + const id = input.ids.id('run', record.id, `${label}.id`) + exactString(record.conversationId, input.sourceConversationId, `${label}.conversationId`) + const turnId = input.ids.id('turn', record.turnId, `${label}.turnId`) + const branchId = input.ids.id('branch', record.branchId, `${label}.branchId`) + const sourceStatus = oneOf(record.status, RUN_STATUSES, `${label}.status`) + const status = TERMINAL_RUN_STATUSES.has(sourceStatus) ? sourceStatus : 'unknown' + const receipt = importReceipt(record.receipt, { + runId: id, + turnId, + operationId: input.operationId, + conversationId: input.conversationId, + branchId, + fallbackProfile: input.fallbackProfile, + label: `${label}.receipt`, + }) + const complete = booleanValue(record.complete, `${label}.complete`) + const terminalAt = optionalString(record.terminalAt, `${label}.terminalAt`) + const missingSequence = optionalSequence(record.missingSequence, `${label}.missingSequence`) + return { + id, + conversationId: input.conversationId, + branchId, + turnId, + operationId: input.operationId, + status, + inputTokens: finiteNumber(record.inputTokens, `${label}.inputTokens`), + outputTokens: finiteNumber(record.outputTokens, `${label}.outputTokens`), + ...optionalField( + 'reasoningTokens', + optionalFiniteNumber(record.reasoningTokens, `${label}.reasoningTokens`), + ), + ...optionalField('costUsd', optionalFiniteNumber(record.costUsd, `${label}.costUsd`)), + ...optionalField('model', optionalString(record.model, `${label}.model`)), + ...optionalField('error', optionalString(record.error, `${label}.error`)), + receipt, + capabilities: UNKNOWN_RUN_CAPABILITIES, + ...optionalField( + 'terminalReason', + optionalString(record.terminalReason, `${label}.terminalReason`), + ), + lastProviderSequence: 0, + eventCount: finiteInteger(record.eventCount, `${label}.eventCount`), + ...optionalField( + 'contentBytes', + optionalFiniteNumber(record.contentBytes, `${label}.contentBytes`), + ), + ...(record.contentTruncated === undefined + ? {} + : { contentTruncated: booleanValue(record.contentTruncated, `${label}.contentTruncated`) }), + ...optionalField('missingSequence', missingSequence), + interactions: [], + activity: [], + eventDetails: [], + ...(arrayHasItems(record.interactions) || record.interactionsTruncated === true + ? { interactionsTruncated: true } + : {}), + ...(arrayHasItems(record.activity) || record.activityTruncated === true + ? { activityTruncated: true } + : {}), + ...(arrayHasItems(record.eventDetails) || record.eventDetailsTruncated === true + ? { eventDetailsTruncated: true } + : {}), + complete: complete && TERMINAL_RUN_STATUSES.has(status), + startedAt: requiredString(record.startedAt, `${label}.startedAt`), + updatedAt: requiredString(record.updatedAt, `${label}.updatedAt`), + ...optionalField('terminalAt', terminalAt), + } +} + +function importReceipt( + value: unknown, + input: { + readonly runId: string + readonly turnId: string + readonly operationId: string + readonly conversationId: string + readonly branchId: string + readonly fallbackProfile: Readonly + readonly label: string + }, +) { + const receipt = importRecord(value, input.label) + const requested = importRecord(receipt.requested, `${input.label}.requested`) + let profile: Readonly + try { + profile = + requested.profile === undefined + ? input.fallbackProfile + : snapshotAgentProfile(requested.profile) + } catch { + throw new AppError('IMPORT_INVALID', `${input.label}.requested.profile is invalid`) + } + return createAdmissionReceipt({ + runId: input.runId, + turnId: input.turnId, + operationId: input.operationId, + conversationId: input.conversationId, + branchId: input.branchId, + admittedAt: canonicalDateTime(receipt.admittedAt, `${input.label}.admittedAt`), + profile, + text: stringValue(requested.text, `${input.label}.requested.text`), + capabilities: UNKNOWN_RUN_CAPABILITIES, + warnings: ['IMPORTED_OFFLINE'], + admissionStatus: 'unavailable', + }) +} + +function importAnalysis( + input: Parameters[0], + record: Readonly>, + label: string, +): AnalysisRecord { + const source = importRecord(record.source, `${label}.source`) + exactString(source.conversationId, input.sourceConversationId, `${label}.source.conversationId`) + const branchId = input.ids.id('branch', source.branchId, `${label}.source.branchId`) + const runId = + source.runId === undefined + ? undefined + : input.ids.id('run', source.runId, `${label}.source.runId`) + const throughMessageId = + source.throughMessageId === undefined + ? undefined + : input.ids.id('message', source.throughMessageId, `${label}.source.throughMessageId`) + const complete = booleanValue(source.complete, `${label}.source.complete`) + let importedSourceDigest: ReturnType + try { + importedSourceDigest = parseDigestValue(source.digest) + } catch { + throw new AppError('IMPORT_INVALID', `${label}.source.digest is invalid`) + } + const sourceDigest = canonicalDigest({ + importedFrom: importedSourceDigest, + conversationId: input.conversationId, + branchId, + runId: runId ?? null, + throughMessageId: throughMessageId ?? null, + complete, + }) + const sourceStatus = oneOf(record.status, ANALYSIS_STATUSES, `${label}.status`) + const findingsValue = record.findings + if (!Array.isArray(findingsValue)) { + throw new AppError('IMPORT_INVALID', `${label}.findings must be an array`) + } + const findings = findingsValue.map((value, index) => + importFinding(input.ids, value, `${label}.findings[${index}]`), + ) + return { + id: input.ids.id('analysis', record.id, `${label}.id`), + source: { + conversationId: input.conversationId, + branchId, + ...optionalField('runId', runId), + ...optionalField('throughMessageId', throughMessageId), + digest: sourceDigest, + complete, + ...optionalField( + 'missingHistory', + optionalMissingHistory(input.ids, source.missingHistory, `${label}.source.missingHistory`), + ), + }, + ...optionalField('question', optionalString(record.question, `${label}.question`)), + ...optionalField('recipe', optionalString(record.recipe, `${label}.recipe`)), + status: TERMINAL_ANALYSIS_STATUSES.has(sourceStatus) ? sourceStatus : 'unknown', + findings, + ...optionalField('usage', optionalUsage(record.usage, `${label}.usage`)), + ...optionalField('costUsd', optionalFiniteNumber(record.costUsd, `${label}.costUsd`)), + ...optionalField('wallTimeMs', optionalFiniteNumber(record.wallTimeMs, `${label}.wallTimeMs`)), + createdAt: requiredString(record.createdAt, `${label}.createdAt`), + updatedAt: requiredString(record.updatedAt, `${label}.updatedAt`), + } +} + +function importFinding(ids: ConversationImportIds, value: unknown, label: string): AnalysisFinding { + const finding = importRecord(value, label) + if (!Array.isArray(finding.citations)) { + throw new AppError('IMPORT_INVALID', `${label}.citations must be an array`) + } + const confidence = optionalFiniteNumber(finding.confidence, `${label}.confidence`) + if (confidence !== undefined && confidence > 1) { + throw new AppError('IMPORT_INVALID', `${label}.confidence must not exceed one`) + } + const citations = finding.citations.map((citation, index) => + importCitation(ids, citation, `${label}.citations[${index}]`), + ) + // Imports preserve event identifiers as historical provenance, but source journal + // events are intentionally not replayed as live Braid events. Only references to + // imported message data remain resolvable after the external controls are removed. + const hasRetainedSupport = citations.some( + (citation) => citation.messageId !== undefined || citation.partId !== undefined, + ) + return { + id: requiredString(finding.id, `${label}.id`), + text: requiredString(finding.text, `${label}.text`), + ...(finding.severity === undefined + ? {} + : { + severity: oneOf( + finding.severity, + ['info', 'low', 'medium', 'high', 'critical'] as const, + `${label}.severity`, + ), + }), + ...optionalField('confidence', confidence), + citations, + supported: booleanValue(finding.supported, `${label}.supported`) && hasRetainedSupport, + } +} + +function importCitation( + ids: ConversationImportIds, + value: unknown, + label: string, +): AnalysisCitation { + const citation = importRecord(value, label) + return { + id: ids.id('citation', citation.id, `${label}.id`), + ...(citation.eventId === undefined + ? {} + : { eventId: ids.id('event', citation.eventId, `${label}.eventId`) }), + ...(citation.messageId === undefined + ? {} + : { messageId: ids.id('message', citation.messageId, `${label}.messageId`) }), + ...(citation.partId === undefined + ? {} + : { partId: ids.id('messagePart', citation.partId, `${label}.partId`) }), + ...optionalField('start', optionalFiniteNumber(citation.start, `${label}.start`)), + ...optionalField('end', optionalFiniteNumber(citation.end, `${label}.end`)), + ...(citation.quote === undefined + ? {} + : { quote: stringValue(citation.quote, `${label}.quote`) }), + } +} + +function optionalUsage(value: unknown, label: string) { + if (value === undefined) return undefined + const usage = importRecord(value, label) + return { + input: finiteNumber(usage.input, `${label}.input`), + output: finiteNumber(usage.output, `${label}.output`), + ...optionalField('reasoning', optionalFiniteNumber(usage.reasoning, `${label}.reasoning`)), + ...optionalField('costUsd', optionalFiniteNumber(usage.costUsd, `${label}.costUsd`)), + ...optionalField('model', optionalString(usage.model, `${label}.model`)), + } +} + +function optionalSequence(value: unknown, label: string) { + if (value === undefined) return undefined + const sequence = importRecord(value, label) + const from = finiteInteger(sequence.from, `${label}.from`) + const to = finiteInteger(sequence.to, `${label}.to`) + if (to < from) throw new AppError('IMPORT_INVALID', `${label}.to must not precede from`) + return { from, to } +} + +function optionalMissingHistory(ids: ConversationImportIds, value: unknown, label: string) { + if (value === undefined) return undefined + const range = importRecord(value, label) + const fromSequence = finiteInteger(range.fromSequence, `${label}.fromSequence`) + const toSequence = + range.toSequence === undefined + ? undefined + : finiteInteger(range.toSequence, `${label}.toSequence`) + if (toSequence !== undefined && toSequence < fromSequence) { + throw new AppError('IMPORT_INVALID', `${label}.toSequence must not precede fromSequence`) + } + return { + runId: ids.id('run', range.runId, `${label}.runId`), + fromSequence, + ...optionalField('toSequence', toSequence), + reason: oneOf( + range.reason, + ['gap', 'expired-cursor', 'provider-missing', 'replay-unsupported'] as const, + `${label}.reason`, + ), + } +} + +function arrayHasItems(value: unknown): boolean { + return Array.isArray(value) && value.length > 0 +} + +function optionalField(key: K, value: T | undefined): { [P in K]?: T } { + return value === undefined ? {} : ({ [key]: value } as { [P in K]?: T }) +} diff --git a/src/app/conversation-import-validation.ts b/src/app/conversation-import-validation.ts new file mode 100644 index 0000000..5c6b23a --- /dev/null +++ b/src/app/conversation-import-validation.ts @@ -0,0 +1,185 @@ +import type { BraidEvent } from '../domain/events.js' +import { graphNodeId } from '../domain/graph-records.js' +import type { BraidState } from '../domain/state.js' +import { messagesVisibleOnBranch } from './conversation-context.js' +import { AppError } from './errors.js' + +type ConversationImportedEvent = Extract + +export function assertConversationImportReferences(event: ConversationImportedEvent): void { + const branches = new Map(event.branches.map((branch) => [branch.id, branch] as const)) + const turns = new Map(event.turns.map((turn) => [turn.id, turn] as const)) + const runs = new Map(event.runs.map((run) => [run.id, run] as const)) + const messages = new Map(event.messages.map((message) => [message.id, message] as const)) + const parts = new Map(event.messageParts.map((part) => [part.id, part] as const)) + if (!branches.has(event.conversation.activeBranchId)) { + throw new AppError('IMPORT_INVALID', 'Conversation import has no active branch') + } + for (const branch of event.branches) { + if (branch.conversationId !== event.conversation.id) { + throw new AppError('IMPORT_INVALID', `Branch ${branch.id} is outside the conversation`) + } + if (branch.source !== undefined) { + if ( + branch.source.conversationId !== event.conversation.id || + !branches.has(branch.source.branchId) + ) { + throw new AppError('IMPORT_INVALID', `Branch ${branch.id} has a missing source branch`) + } + if (branch.source.throughTurnId !== undefined && !turns.has(branch.source.throughTurnId)) { + throw new AppError('IMPORT_INVALID', `Branch ${branch.id} has a missing source turn`) + } + } + } + const referencedRuns = new Set() + for (const turn of event.turns) { + if (turn.conversationId !== event.conversation.id || !branches.has(turn.branchId)) { + throw new AppError('IMPORT_INVALID', `Turn ${turn.id} is outside the conversation`) + } + const userMessage = messages.get(turn.userMessageId) + if (userMessage === undefined || userMessage.turnId !== turn.id) { + throw new AppError('IMPORT_INVALID', `Turn ${turn.id} has a missing user message`) + } + for (const runId of turn.runIds) { + const run = runs.get(runId) + if (run === undefined || run.turnId !== turn.id) { + throw new AppError('IMPORT_INVALID', `Turn ${turn.id} has a missing run`) + } + referencedRuns.add(runId) + } + if (turn.selectedRunId !== undefined && !turn.runIds.includes(turn.selectedRunId)) { + throw new AppError('IMPORT_INVALID', `Turn ${turn.id} selects a run outside the turn`) + } + } + for (const run of event.runs) { + if ( + run.conversationId !== event.conversation.id || + !branches.has(run.branchId) || + !turns.has(run.turnId) || + !referencedRuns.has(run.id) + ) { + throw new AppError('IMPORT_INVALID', `Run ${run.id} is not attached to an imported turn`) + } + } + const graphReferences = new Set([ + `workspace:${event.conversation.workspaceId}`, + `conversation:${event.conversation.id}`, + ...event.branches.map((record) => `branch:${record.id}`), + ...event.turns.map((record) => `turn:${record.id}`), + ...event.runs.map((record) => `run:${record.id}`), + ...event.messages.map((record) => `message:${record.id}`), + ...event.analyses.map((record) => `analysis:${record.id}`), + ]) + for (const analysis of event.analyses) { + if ( + analysis.source.conversationId !== event.conversation.id || + !branches.has(analysis.source.branchId) + ) { + throw new AppError('IMPORT_INVALID', `Analysis ${analysis.id} references a missing branch`) + } + if (analysis.source.runId !== undefined && !runs.has(analysis.source.runId)) { + throw new AppError('IMPORT_INVALID', `Analysis ${analysis.id} references a missing run`) + } + if ( + analysis.source.missingHistory !== undefined && + !runs.has(analysis.source.missingHistory.runId) + ) { + throw new AppError('IMPORT_INVALID', `Analysis ${analysis.id} has a missing history run`) + } + if ( + analysis.source.throughMessageId !== undefined && + !messages.has(analysis.source.throughMessageId) + ) { + throw new AppError('IMPORT_INVALID', `Analysis ${analysis.id} references a missing message`) + } + for (const citation of analysis.findings.flatMap((finding) => finding.citations)) { + if (citation.messageId !== undefined && !messages.has(citation.messageId)) { + throw new AppError('IMPORT_INVALID', `Analysis ${analysis.id} has a dangling citation`) + } + if (citation.partId !== undefined && !parts.has(citation.partId)) { + throw new AppError('IMPORT_INVALID', `Analysis ${analysis.id} has a dangling citation part`) + } + if ( + citation.messageId !== undefined && + citation.partId !== undefined && + parts.get(citation.partId)?.messageId !== citation.messageId + ) { + throw new AppError( + 'IMPORT_INVALID', + `Analysis ${analysis.id} has a citation whose message and part disagree`, + ) + } + } + } + for (const message of event.messages) { + if (message.conversationId !== event.conversation.id || !branches.has(message.branchId)) { + throw new AppError('IMPORT_INVALID', `Message ${message.id} is outside the conversation`) + } + if (message.turnId !== undefined && !turns.has(message.turnId)) { + throw new AppError('IMPORT_INVALID', `Message ${message.id} has a missing turn`) + } + if (message.runId !== undefined && !runs.has(message.runId)) { + throw new AppError('IMPORT_INVALID', `Message ${message.id} has a missing run`) + } + if (message.missingHistory !== undefined && !runs.has(message.missingHistory.runId)) { + throw new AppError('IMPORT_INVALID', `Message ${message.id} has a missing history run`) + } + } + for (const node of event.graphNodes) { + if (!graphReferences.has(`${node.reference.kind}:${node.reference.id}`)) { + throw new AppError('IMPORT_INVALID', `Graph node ${node.id} references missing imported data`) + } + } +} + +export function assertConversationImportNavigable( + state: BraidState, + event: ConversationImportedEvent, +): void { + try { + for (const branch of event.branches) messagesVisibleOnBranch(state, branch.id) + } catch (error) { + const detail = error instanceof Error ? error.message : 'invalid branch ancestry' + throw new AppError('IMPORT_INVALID', `Conversation import cannot be navigated: ${detail}`) + } +} + +export function assertConversationImportIdsAvailable( + state: BraidState, + event: ConversationImportedEvent, +): void { + const groups: readonly [ + string, + readonly { readonly id: string }[], + readonly { readonly id: string }[], + ][] = [ + ['conversation', state.conversations, [event.conversation]], + ['branch', state.branches, event.branches], + ['draft', state.drafts, event.drafts], + ['queue', state.queues, event.queues], + ['message', state.messages, event.messages], + ['message part', state.messageParts, event.messageParts], + ['turn', state.turns, event.turns], + ['run', state.runs, event.runs], + ['analysis', state.analyses, event.analyses], + ['feedback', state.feedbackDecisions, event.feedbackDecisions], + ] + for (const [label, current, imported] of groups) { + const existing = new Set(current.map((record) => record.id)) + if (imported.some((record) => existing.has(record.id))) { + throw new AppError('IMPORT_ID_COLLISION', `Imported ${label} ID already exists`) + } + } + const workspaceNodeId = + state.workspaceId === null + ? undefined + : graphNodeId({ kind: 'workspace', id: state.workspaceId }) + const currentNodeIds = new Set(state.graphNodes.map((node) => node.id)) + if (event.graphNodes.some((node) => node.id !== workspaceNodeId && currentNodeIds.has(node.id))) { + throw new AppError('IMPORT_ID_COLLISION', 'Imported graph node ID already exists') + } + const currentEdgeIds = new Set(state.graphEdges.map((edge) => edge.id)) + if (event.graphEdges.some((edge) => currentEdgeIds.has(edge.id))) { + throw new AppError('IMPORT_ID_COLLISION', 'Imported graph edge ID already exists') + } +} diff --git a/src/app/conversation-import-values.ts b/src/app/conversation-import-values.ts new file mode 100644 index 0000000..63e0a87 --- /dev/null +++ b/src/app/conversation-import-values.ts @@ -0,0 +1,131 @@ +import { canonicalDigest } from '../domain/canonical.js' +import { type IdForKind, type IdKind, type OperationId, parseId } from '../domain/ids.js' +import { prefixes } from '../domain/ids-core.js' +import { isCanonicalIsoDateTime } from '../domain/text.js' +import { AppError } from './errors.js' + +export interface ConversationImportIds { + id(kind: K, source: unknown, label: string): IdForKind + derived(kind: K, label: string): IdForKind +} + +export function createConversationImportIds( + operationId: OperationId, + contentDigest: string, +): ConversationImportIds { + const remapped = new Map() + const create = (kind: K, source: string): IdForKind => { + const key = `${kind}:${source}` + const existing = remapped.get(key) + if (existing !== undefined) return existing as IdForKind + const prefix = prefixes[kind][0] + if (prefix === undefined) throw new AppError('IMPORT_INVALID', `Unsupported import ID ${kind}`) + const suffix = canonicalDigest({ operationId, contentDigest, kind, source }).slice(0, 40) + const value = parseId(kind, `${prefix}import-${suffix}`) + remapped.set(key, value) + return value + } + return { + id(kind, source, label) { + try { + return create(kind, parseId(kind, source)) + } catch { + throw new AppError('IMPORT_INVALID', `${label} is not a valid ${kind} identifier`) + } + }, + derived(kind, label) { + return create(kind, label) + }, + } +} + +export function importRecords( + value: unknown, + kind: K, + label: string, +): readonly Record[] { + if (!Array.isArray(value)) throw new AppError('IMPORT_INVALID', `${label} must be an array`) + const records = value.map((entry, index) => importRecord(entry, `${label}[${index}]`)) + const seen = new Set() + for (const [index, record] of records.entries()) { + let id: string + try { + id = parseId(kind, record.id) + } catch { + throw new AppError('IMPORT_INVALID', `${label}[${index}].id is invalid`) + } + if (seen.has(id)) throw new AppError('IMPORT_INVALID', `${label} contains duplicate ID ${id}`) + seen.add(id) + } + return records +} + +export function importRecord(value: unknown, label: string): Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new AppError('IMPORT_INVALID', `${label} must be an object`) + } + return value as Record +} + +export function requiredString(value: unknown, label: string): string { + if (typeof value !== 'string' || value.length === 0) { + throw new AppError('IMPORT_INVALID', `${label} must be non-empty text`) + } + return value +} + +export function stringValue(value: unknown, label: string): string { + if (typeof value !== 'string') throw new AppError('IMPORT_INVALID', `${label} must be text`) + return value +} + +export function optionalString(value: unknown, label: string): string | undefined { + if (value === undefined) return undefined + return requiredString(value, label) +} + +export function canonicalDateTime(value: unknown, label: string): string { + if (!isCanonicalIsoDateTime(value)) { + throw new AppError('IMPORT_INVALID', `${label} must be a canonical ISO timestamp`) + } + return value +} + +export function finiteNumber(value: unknown, label: string): number { + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) { + throw new AppError('IMPORT_INVALID', `${label} must be a non-negative number`) + } + return value +} + +export function optionalFiniteNumber(value: unknown, label: string): number | undefined { + return value === undefined ? undefined : finiteNumber(value, label) +} + +export function finiteInteger(value: unknown, label: string): number { + const number = finiteNumber(value, label) + if (!Number.isSafeInteger(number)) { + throw new AppError('IMPORT_INVALID', `${label} must be a non-negative integer`) + } + return number +} + +export function booleanValue(value: unknown, label: string): boolean { + if (typeof value !== 'boolean') throw new AppError('IMPORT_INVALID', `${label} must be boolean`) + return value +} + +export function oneOf( + value: unknown, + allowed: T, + label: string, +): T[number] { + if (typeof value !== 'string' || !allowed.includes(value)) { + throw new AppError('IMPORT_INVALID', `${label} is unsupported`) + } + return value as T[number] +} + +export function exactString(value: unknown, expected: string, label: string): void { + if (value !== expected) throw new AppError('IMPORT_INVALID', `${label} is outside the export`) +} diff --git a/src/app/conversation-imports.ts b/src/app/conversation-imports.ts new file mode 100644 index 0000000..7457c1d --- /dev/null +++ b/src/app/conversation-imports.ts @@ -0,0 +1,167 @@ +import { resolve } from 'node:path' +import { canonicalDigest } from '../domain/canonical.js' +import type { ConversationRecord, OperationRecord } from '../domain/entities.js' +import type { Digest, OperationId } from '../domain/ids.js' +import { parseDigestValue } from '../domain/ids.js' +import type { BraidState } from '../domain/state.js' +import { MAX_CONVERSATION_DOCUMENT_BYTES } from './conversation-exports.js' +import { buildConversationImport } from './conversation-import-builder.js' +import { + type ConversationImportSource, + prepareConversationImport, +} from './conversation-import-document.js' +import { + coordinateConversationOperation, + normalizedTitle, + operationReplay, + parseOperation, + requestDigest, +} from './conversation-support.js' +import type { ConversationHost } from './conversation-types.js' +import { AppError } from './errors.js' + +export interface ImportConversationInput extends ConversationImportSource { + readonly operationId: string + readonly title?: string +} + +export interface ImportConversationResult { + readonly conversationId: string + readonly contentDigest: Digest + readonly bytes: number + readonly branches: number + readonly messages: number + readonly runs: number + readonly analyses: number + readonly replayed: boolean +} + +export class ConversationImports { + readonly #host: ConversationHost + + constructor(host: ConversationHost) { + this.#host = host + } + + async import(input: ImportConversationInput): Promise { + const operationId = parseOperation(input.operationId, 'import') + const title = input.title === undefined ? undefined : normalizedTitle(input.title) + const source = importRequestIdentity(input) + const digest = requestDigest('conversation-import', { + source, + title: title ?? null, + }) + const coordinationInput = { + operationId: input.operationId, + source, + ...(title === undefined ? {} : { title }), + } + return coordinateConversationOperation(this.#host, 'import', coordinationInput, async () => + this.#commit(input, operationId, digest, title), + ) + } + + async #commit( + input: ConversationImportSource, + operationId: OperationId, + digest: Digest, + title: string | undefined, + ): Promise { + const state = this.#host.state() + const replay = operationReplay(state, operationId, 'conversation-import', digest) + if (replay !== undefined) { + if (replay.target?.kind !== 'conversation') { + throw new AppError('IMPORT_REPLAY_INVALID', 'Conversation import replay has no target') + } + const metadata = importReplayMetadata(replay.result) + return importResult(state, replay.target.id, metadata.contentDigest, metadata.bytes, true) + } + const prepared = prepareConversationImport(input) + const event = buildConversationImport({ + state, + prepared, + operationId, + requestDigest: digest, + at: this.#host.now(), + ...(title === undefined ? {} : { title }), + }) + await this.#host.commit(event) + return importResult( + this.#host.state(), + event.conversation.id, + prepared.contentDigest, + prepared.bytes, + false, + ) + } +} + +function importRequestIdentity( + input: ConversationImportSource, +): Readonly<{ inlineDigest: Digest } | { source: string }> { + const hasContent = input.content !== undefined + const hasSource = input.source !== undefined + if (hasContent === hasSource) { + throw new AppError( + 'IMPORT_SOURCE_REQUIRED', + 'Provide exactly one import content or source path', + ) + } + if (input.content !== undefined) { + if (Buffer.byteLength(input.content, 'utf8') > MAX_CONVERSATION_DOCUMENT_BYTES) { + throw new AppError('IMPORT_TOO_LARGE', 'Conversation import exceeds 2 MiB') + } + return { inlineDigest: canonicalDigest(input.content) } + } + const source = input.source?.trim() + if (!source) { + throw new AppError('IMPORT_SOURCE_REQUIRED', 'Conversation import source path is empty') + } + return { source: resolve(source) } +} + +function importReplayMetadata(result: OperationRecord['result']): { + readonly contentDigest: Digest + readonly bytes: number +} { + try { + const contentDigest = parseDigestValue(result?.contentDigest) + const bytes = result?.bytes + if (typeof bytes !== 'number' || !Number.isSafeInteger(bytes) || bytes < 0) { + throw new Error('invalid byte count') + } + return { contentDigest, bytes } + } catch { + throw new AppError('IMPORT_REPLAY_INVALID', 'Conversation import replay metadata is invalid') + } +} + +function importResult( + state: BraidState, + conversationId: ConversationRecord['id'], + contentDigest: Digest, + bytes: number, + replayed: boolean, +): ImportConversationResult { + const conversation = state.conversations.find((record) => record.id === conversationId) + if (!conversation || conversation.deletedAt !== undefined) { + throw new AppError('IMPORT_REPLAY_INVALID', 'Imported conversation is unavailable') + } + const branches = state.branches.filter((record) => record.conversationId === conversationId) + const branchIds = new Set(branches.map((record) => record.id)) + const turns = state.turns.filter((record) => record.conversationId === conversationId) + const runIds = new Set(turns.flatMap((record) => record.runIds)) + return { + conversationId, + contentDigest, + bytes, + branches: branches.length, + messages: state.messages.filter((record) => record.conversationId === conversationId).length, + runs: state.runs.filter((record) => runIds.has(record.id)).length, + analyses: state.analyses.filter( + (record) => + record.source.conversationId === conversationId && branchIds.has(record.source.branchId), + ).length, + replayed, + } +} diff --git a/src/app/conversation-lifecycle.ts b/src/app/conversation-lifecycle.ts new file mode 100644 index 0000000..fea59bc --- /dev/null +++ b/src/app/conversation-lifecycle.ts @@ -0,0 +1,419 @@ +import type { ConversationRecord, OperationRecord } from '../domain/entities.js' +import { graphNode } from '../domain/graph-records.js' +import { + parseBranchId, + parseConversationId, + type ConversationId, + type OperationId, +} from '../domain/ids.js' +import type { BraidState } from '../domain/state.js' +import { conversationBundle } from './conversation-records.js' +import { + acknowledgedOperation, + coordinateConversationOperation, + normalizedTitle, + operationReplay, + parseOperation, + requestDigest, + requireIdle, + requireWorkspace, + selectedConnection, + selectedProfile, + stableConversationIds, +} from './conversation-support.js' +import type { + ConversationHost, + ConversationListQuery, + CreateConversationInput, + OpenConversationInput, + UpdateConversationInput, +} from './conversation-types.js' +import { AppError } from './errors.js' + +export class ConversationLifecycle { + readonly #host: ConversationHost + + constructor(host: ConversationHost) { + this.#host = host + } + + list(query: ConversationListQuery = {}): readonly ConversationRecord[] { + const state = this.#host.state() + const text = query.query?.trim().toLocaleLowerCase() + return state.conversations.filter((conversation) => { + if (conversation.deletedAt !== undefined) return false + if (query.workspace !== undefined) { + const workspace = state.workspaces.find((record) => record.id === conversation.workspaceId) + if (workspace?.root !== query.workspace) return false + } + if (query.status === 'active' && conversation.archived) return false + if (query.status === 'archived' && !conversation.archived) return false + if (!text) return true + if (conversation.id.toLocaleLowerCase().includes(text)) return true + if (conversation.title.toLocaleLowerCase().includes(text)) return true + return state.branches.some( + (branch) => + branch.conversationId === conversation.id && + [branch.id, branch.overrides.runner, branch.overrides.model].some((value) => + value?.toLocaleLowerCase().includes(text), + ), + ) + }) + } + + async create(input: CreateConversationInput): Promise { + return coordinateConversationOperation(this.#host, 'new_conversation', input, () => + this.#create(input), + ) + } + + async #create(input: CreateConversationInput): Promise { + const state = this.#host.state() + const workspaceId = requireWorkspace(state) + requireIdle(state, 'Creating a conversation') + const title = normalizedTitle(input.title) + const profileId = selectedProfile(state, input.profileId) + const connectionId = selectedConnection(state, input.connectionId) + const operationId = parseOperation(input.operationId, 'new_conversation') + const digest = requestDigest('new_conversation', { + title, + profileId: profileId ?? null, + connectionId: connectionId ?? null, + workspaceId, + }) + const replay = operationReplay(state, operationId, 'conversation-create', digest) + if (replay) return conversationForOperation(state, replay) + const ids = stableConversationIds(operationId, digest) + const at = this.#host.now() + const bundle = conversationBundle({ + workspaceId, + ...ids, + title, + at, + operationId, + ...(profileId === undefined ? {} : { profileId }), + ...(connectionId === undefined ? {} : { connectionId }), + }) + const operation = acknowledgedOperation({ + id: operationId, + kind: 'conversation-create', + digest, + at, + target: { kind: 'conversation', id: bundle.conversation.id }, + }) + await this.#host.commit({ kind: 'conversation.created', ...bundle, operation }) + return bundle.conversation + } + + async open(input: OpenConversationInput): Promise { + return coordinateConversationOperation(this.#host, 'open_conversation', input, () => + this.#open(input), + ) + } + + async #open(input: OpenConversationInput): Promise { + const state = this.#host.state() + requireIdle(state, 'Opening a conversation') + const conversationId = parseConversationId(input.conversationId) + const conversation = availableConversation(state, conversationId) + const branchId = + input.branchId === undefined ? conversation.activeBranchId : parseBranchId(input.branchId) + const branch = state.branches.find( + (candidate) => candidate.id === branchId && candidate.conversationId === conversationId, + ) + if (!branch) + throw new AppError( + 'UNKNOWN_BRANCH', + `Branch ${branchId} does not belong to ${conversationId}`, + ) + const operationId = parseOperation(input.operationId, 'open_conversation') + const digest = requestDigest('open_conversation', { conversationId, branchId }) + const replay = operationReplay(state, operationId, 'conversation-open', digest) + if (replay) return conversationForOperation(state, replay) + const at = this.#host.now() + const selected = { ...conversation, activeBranchId: branchId, updatedAt: at } + await this.#host.commit({ + kind: 'conversation.selected', + conversationId, + branchId, + conversation: selected, + operation: acknowledgedOperation({ + id: operationId, + kind: 'conversation-open', + digest, + at, + target: { kind: 'conversation', id: conversationId }, + }), + }) + return selected + } + + async rename( + input: UpdateConversationInput & { readonly title: string }, + ): Promise { + return coordinateConversationOperation(this.#host, 'rename_conversation', input, () => + this.#rename(input), + ) + } + + async #rename( + input: UpdateConversationInput & { readonly title: string }, + ): Promise { + const title = normalizedTitle(input.title) + return this.#update( + input, + 'conversation-update', + 'rename_conversation', + { title }, + (conversation, at) => ({ + ...conversation, + title, + updatedAt: at, + }), + ) + } + + async archive( + input: UpdateConversationInput & { readonly archived: boolean }, + ): Promise { + return coordinateConversationOperation(this.#host, 'archive_conversation', input, () => + this.#archive(input), + ) + } + + async #archive( + input: UpdateConversationInput & { readonly archived: boolean }, + ): Promise { + return this.#update( + input, + 'conversation-archive', + 'archive_conversation', + { archived: input.archived }, + (conversation, at) => ({ + ...conversation, + archived: input.archived, + updatedAt: at, + }), + ) + } + + async delete(input: UpdateConversationInput): Promise { + return coordinateConversationOperation(this.#host, 'delete_conversation', input, () => + this.#delete(input), + ) + } + + async #delete(input: UpdateConversationInput): Promise { + const state = this.#host.state() + const workspaceId = requireWorkspace(state) + const conversationId = parseConversationId(input.conversationId) + const operationId = parseOperation(input.operationId, 'delete_conversation') + const digest = requestDigest('delete_conversation', { conversationId }) + const replay = operationReplay(state, operationId, 'conversation-delete', digest) + if (replay) { + const deleted = state.conversations.find((conversation) => conversation.id === conversationId) + if (!deleted) + throw new AppError('DELETE_INCOMPLETE', `Conversation ${conversationId} is unavailable`) + if (replay.status === 'pending') await this.#finishDelete(operationId, digest, deleted) + return deleted + } + const target = availableConversation(state, conversationId) + deleteBlockers(state, conversationId) + const at = this.#host.now() + const tombstone: ConversationRecord = { + ...target, + title: 'Deleted conversation', + archived: true, + deletedAt: at, + updatedAt: at, + retention: {}, + } + const current = state.conversations.find( + (conversation) => + conversation.id === state.conversationId && + conversation.id !== conversationId && + conversation.deletedAt === undefined, + ) + const fallback = + current ?? + state.conversations.find( + (conversation) => + conversation.id !== conversationId && conversation.deletedAt === undefined, + ) + const replacementIds = stableConversationIds(operationId, digest, 'replacement') + const replacement = + fallback === undefined + ? conversationBundle({ + workspaceId, + ...replacementIds, + title: 'New conversation', + at, + operationId, + ...(state.selectedProfileId === null ? {} : { profileId: state.selectedProfileId }), + ...(state.selectedConnectionId === null + ? {} + : { connectionId: state.selectedConnectionId }), + }) + : undefined + const selectedConversation = fallback ?? replacement?.conversation + if (!selectedConversation) + throw new AppError('DELETE_INCOMPLETE', 'No replacement conversation was created') + const operation: OperationRecord = { + id: operationId, + kind: 'conversation-delete', + requestDigest: digest, + status: this.#host.destroy === undefined ? 'acknowledged' : 'pending', + target: { kind: 'conversation', id: conversationId }, + createdAt: at, + updatedAt: at, + ...(this.#host.destroy === undefined ? { acknowledgedAt: at } : {}), + } + await this.#host.commit({ + kind: 'conversation.deleted', + conversation: tombstone, + selectedConversation, + ...(replacement === undefined + ? { + graphNodes: [ + graphNode({ kind: 'conversation', id: conversationId }, at, 'Deleted conversation'), + ], + } + : { + replacementBranch: replacement.branch, + replacementDraft: replacement.draft, + replacementQueue: replacement.queue, + graphNodes: [ + ...replacement.graphNodes, + graphNode({ kind: 'conversation', id: conversationId }, at, 'Deleted conversation'), + ], + graphEdges: replacement.graphEdges, + }), + operation, + }) + if (this.#host.destroy !== undefined) await this.#finishDelete(operationId, digest, tombstone) + return tombstone + } + + async reconcilePendingDeletes(): Promise { + if (this.#host.destroy === undefined) return + const pending = this.#host + .state() + .operations.filter( + (operation) => operation.kind === 'conversation-delete' && operation.status === 'pending', + ) + for (const operation of pending) { + if (operation.target?.kind !== 'conversation') { + throw new AppError( + 'DELETE_INCOMPLETE', + `Operation ${operation.id} has no conversation target`, + ) + } + const conversation = this.#host + .state() + .conversations.find((candidate) => candidate.id === operation.target?.id) + if (conversation?.deletedAt === undefined) { + throw new AppError('DELETE_INCOMPLETE', `Operation ${operation.id} has no tombstone`) + } + await this.#finishDelete(operation.id, operation.requestDigest, conversation) + } + } + + async #update( + input: UpdateConversationInput, + kind: 'conversation-update' | 'conversation-archive', + command: string, + request: Readonly>, + update: (conversation: ConversationRecord, at: string) => ConversationRecord, + ): Promise { + const state = this.#host.state() + const conversationId = parseConversationId(input.conversationId) + const current = availableConversation(state, conversationId) + const operationId = parseOperation(input.operationId, command) + const digest = requestDigest(command, { conversationId, ...request }) + const replay = operationReplay(state, operationId, kind, digest) + if (replay) return conversationForOperation(state, replay) + const proposed = update(current, this.#host.now()) + const at = proposed.updatedAt + await this.#host.commit({ + kind: 'conversation.updated', + conversation: proposed, + operation: acknowledgedOperation({ + id: operationId, + kind, + digest, + at, + target: { kind: 'conversation', id: conversationId }, + }), + }) + return proposed + } + + async #finishDelete( + operationId: OperationId, + digest: ReturnType, + conversation: ConversationRecord, + ): Promise { + await this.#host.destroy?.({ conversationId: conversation.id, operationId }) + const at = this.#host.now() + await this.#host.commit({ + kind: 'operation.updated', + operation: acknowledgedOperation({ + id: operationId, + kind: 'conversation-delete', + digest, + at, + target: { kind: 'conversation', id: conversation.id }, + }), + }) + } +} + +function availableConversation(state: BraidState, id: ConversationId): ConversationRecord { + const conversation = state.conversations.find((candidate) => candidate.id === id) + if (!conversation || conversation.deletedAt !== undefined) { + throw new AppError('UNKNOWN_CONVERSATION', `Conversation ${id} is not available`) + } + return conversation +} + +function conversationForOperation( + state: BraidState, + operation: OperationRecord, +): ConversationRecord { + if (operation.target?.kind !== 'conversation') { + throw new AppError( + 'OPERATION_INCOMPLETE', + `Operation ${operation.id} has no conversation result`, + ) + } + const conversation = state.conversations.find( + (candidate) => candidate.id === operation.target?.id, + ) + if (!conversation) { + throw new AppError('OPERATION_INCOMPLETE', `Operation ${operation.id} has no durable result`) + } + return conversation +} + +function deleteBlockers(state: BraidState, conversationId: ConversationId): void { + 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, + ) + if (descendant) { + throw new AppError( + 'DELETE_BLOCKED', + `Branch ${descendant.id} still references this conversation`, + ) + } +} diff --git a/src/app/conversation-operation-coordinator.ts b/src/app/conversation-operation-coordinator.ts new file mode 100644 index 0000000..63c0ba4 --- /dev/null +++ b/src/app/conversation-operation-coordinator.ts @@ -0,0 +1,30 @@ +import { AppError } from './errors.js' + +interface PendingOperation { + readonly digest: string + readonly promise: Promise +} + +export class ConversationOperationCoordinator { + readonly #pending = new Map() + + run(operationId: string, digest: string, action: () => Promise): Promise { + const current = this.#pending.get(operationId) + if (current !== undefined) { + if (current.digest !== digest) { + throw new AppError( + 'OPERATION_ID_CONFLICT', + `Operation ${operationId} is already running with different input`, + ) + } + return current.promise as Promise + } + const promise = Promise.resolve().then(action) + this.#pending.set(operationId, { digest, promise }) + const cleanup = () => { + if (this.#pending.get(operationId)?.promise === promise) this.#pending.delete(operationId) + } + void promise.then(cleanup, cleanup) + return promise + } +} diff --git a/src/app/conversation-records.ts b/src/app/conversation-records.ts new file mode 100644 index 0000000..c403fdb --- /dev/null +++ b/src/app/conversation-records.ts @@ -0,0 +1,149 @@ +import type { ReasoningEffort } from '@tangle-network/agent-interface' +import type { + BranchBoundary, + BranchRecord, + ConversationRecord, + DraftRecord, + GraphEdgeKind, + GraphEdgeRecord, + GraphNodeRecord, + QueueRecord, + RunOverrides, +} from '../domain/entities.js' +import { graphEdge, graphNode } from '../domain/graph-records.js' +import type { + BranchId, + ConnectionId, + ConversationId, + DraftId, + OperationId, + ProfileId, + QueueId, + WorkspaceId, +} from '../domain/ids.js' +import { redactSensitiveText } from '../domain/redaction.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 inherited?: RunOverrides +}): RunOverrides { + 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 + } + return { + ...(input.inherited ?? {}), + ...(input.runner === undefined ? {} : { runner: redactSensitiveText(input.runner, 256) }), + ...(input.model === undefined ? {} : { model: redactSensitiveText(input.model, 512) }), + ...(effort === undefined ? {} : { effort }), + } +} + +export function draftRecord(id: DraftId, branchId: BranchId, at: string, text = ''): DraftRecord { + return { id, branchId, text: redactSensitiveText(text), updatedAt: at } +} + +export function queueRecord(id: QueueId, branchId: BranchId, at: string): QueueRecord { + return { id, branchId, entryIds: [], createdAt: at, updatedAt: at } +} + +export function conversationBundle(input: { + readonly workspaceId: WorkspaceId + readonly conversationId: ConversationId + readonly branchId: BranchId + readonly draftId: DraftId + readonly queueId: QueueId + readonly title: string + readonly at: string + readonly operationId: OperationId + readonly profileId?: ProfileId + readonly connectionId?: ConnectionId + readonly source?: BranchBoundary + readonly sourceNode?: GraphNodeRecord['reference'] + readonly sourceEdgeKind?: GraphEdgeKind + readonly overrides?: RunOverrides + readonly environmentId?: BranchRecord['environmentId'] +}): BuiltConversation { + const conversation: ConversationRecord = { + id: input.conversationId, + workspaceId: input.workspaceId, + title: input.title, + activeBranchId: input.branchId, + ...(input.profileId === undefined ? {} : { profileId: input.profileId }), + createdAt: input.at, + updatedAt: input.at, + archived: false, + retention: {}, + } + const branch: BranchRecord = { + id: input.branchId, + conversationId: input.conversationId, + ...(input.source === undefined ? {} : { source: input.source }), + ...(input.profileId === undefined ? {} : { profileId: input.profileId }), + ...(input.connectionId === undefined ? {} : { connectionId: input.connectionId }), + overrides: input.overrides ?? {}, + ...(input.environmentId === undefined ? {} : { environmentId: input.environmentId }), + draftId: input.draftId, + queueId: input.queueId, + status: 'active', + createdAt: input.at, + updatedAt: input.at, + } + const draft = draftRecord(input.draftId, input.branchId, input.at) + const queue = queueRecord(input.queueId, input.branchId, input.at) + const workspaceNode = graphNode({ kind: 'workspace', id: input.workspaceId }, input.at) + const conversationNode = graphNode( + { kind: 'conversation', id: input.conversationId }, + input.at, + input.title, + ) + const branchNode = graphNode({ kind: 'branch', id: input.branchId }, input.at, 'Main') + const graphEdges: GraphEdgeRecord[] = [ + graphEdge({ + kind: 'attached', + source: workspaceNode.reference, + destination: conversationNode.reference, + at: input.at, + provenance: { operationId: input.operationId }, + }), + graphEdge({ + kind: 'attached', + source: conversationNode.reference, + destination: branchNode.reference, + at: input.at, + provenance: { operationId: input.operationId }, + }), + ] + const graphNodes = [workspaceNode, conversationNode, branchNode] + if (input.sourceNode !== undefined && input.sourceEdgeKind !== undefined) { + const sourceNode = graphNode(input.sourceNode, input.at) + graphNodes.push(sourceNode) + graphEdges.push( + graphEdge({ + kind: input.sourceEdgeKind, + source: sourceNode.reference, + destination: conversationNode.reference, + at: input.at, + provenance: { operationId: input.operationId }, + }), + ) + } + return { conversation, branch, draft, queue, graphNodes, graphEdges } +} diff --git a/src/app/conversation-support.ts b/src/app/conversation-support.ts new file mode 100644 index 0000000..0cdbf5a --- /dev/null +++ b/src/app/conversation-support.ts @@ -0,0 +1,167 @@ +import { canonicalDigest } from '../domain/canonical.js' +import type { OperationKind, OperationRecord } from '../domain/entities.js' +import { + type BranchId, + type ConnectionId, + type ConversationId, + createBranchId, + createConversationId, + createDraftId, + createOperationId, + createQueueId, + type DraftId, + type OperationId, + type ProfileId, + parseConnectionId, + parseProfileId, + type QueueId, +} from '../domain/ids.js' +import { redactSensitiveText } from '../domain/redaction.js' +import type { BraidState } from '../domain/state.js' +import { operationId as parseApplicationOperationId } from './application-guards.js' +import type { ConversationHost } from './conversation-types.js' +import { AppError } from './errors.js' + +export interface StableConversationIds { + readonly conversationId: ConversationId + readonly branchId: BranchId + readonly draftId: DraftId + readonly queueId: QueueId +} + +export function normalizedTitle(value: string | undefined, fallback = 'New conversation'): string { + const title = redactSensitiveText(value?.trim() || fallback, 1024).trim() + if (!title) throw new AppError('INVALID_TITLE', 'Conversation title must not be empty') + return title +} + +export function requestDigest(command: string, request: Readonly>) { + return canonicalDigest({ command, request }) +} + +function stableSuffix(operationId: OperationId, digest: string, label: string): string { + return canonicalDigest({ operationId, digest, label }).slice(0, 32) +} + +export function stableConversationIds( + operationId: OperationId, + digest: string, + label = 'conversation', +): StableConversationIds { + const suffix = stableSuffix(operationId, digest, label) + return { + conversationId: createConversationId(`conversation-${suffix}`), + branchId: createBranchId(`branch-${suffix}`), + draftId: createDraftId(`draft-${suffix}`), + queueId: createQueueId(`queue-${suffix}`), + } +} + +export function stableBranchIds( + operationId: OperationId, + digest: string, +): Pick { + const suffix = stableSuffix(operationId, digest, 'branch') + return { + branchId: createBranchId(`branch-${suffix}`), + draftId: createDraftId(`draft-${suffix}`), + queueId: createQueueId(`queue-${suffix}`), + } +} + +export function parseOperation(value: string, command: string): OperationId { + return parseApplicationOperationId(value, command) +} + +export function operationReplay( + state: BraidState, + id: OperationId, + kind: OperationKind, + digest: string, +): OperationRecord | undefined { + const existing = state.operations.find((operation) => operation.id === id) + if (!existing) return undefined + if (existing.kind !== kind || existing.requestDigest !== digest) { + throw new AppError( + 'OPERATION_ID_CONFLICT', + `Operation ${id} was already used with different input`, + ) + } + return existing +} + +export function acknowledgedOperation(input: { + readonly id: OperationId + readonly kind: OperationKind + readonly digest: ReturnType + readonly at: string + readonly target?: OperationRecord['target'] + readonly result?: OperationRecord['result'] +}): OperationRecord { + return { + id: input.id, + kind: input.kind, + requestDigest: input.digest, + status: 'acknowledged', + ...(input.target === undefined ? {} : { target: input.target }), + ...(input.result === undefined ? {} : { result: input.result }), + createdAt: input.at, + updatedAt: input.at, + acknowledgedAt: input.at, + } +} + +export function requireWorkspace(state: BraidState): NonNullable { + if (state.workspaceId === null || state.workspace === null) { + throw new AppError('NOT_INITIALIZED', 'Initialize a workspace first') + } + return state.workspaceId +} + +export function requireIdle(state: BraidState, command: string): void { + if (state.activeRunId !== null) { + throw new AppError('RUN_ACTIVE', `${command} is unavailable while a run is active`) + } +} + +export function selectedProfile( + state: BraidState, + value: string | undefined, +): ProfileId | undefined { + if (value === undefined) return state.selectedProfileId ?? undefined + const id = parseProfileId(value) + if (!state.profiles.some((profile) => profile.id === id)) { + throw new AppError('UNKNOWN_PROFILE', `Profile ${id} does not exist`) + } + return id +} + +export function selectedConnection( + state: BraidState, + value: string | undefined, +): ConnectionId | undefined { + if (value === undefined) return state.selectedConnectionId ?? undefined + const id = parseConnectionId(value) + if (!state.connections.some((connection) => connection.id === id)) { + throw new AppError('UNKNOWN_CONNECTION', `Connection ${id} does not exist`) + } + return id +} + +export function derivedOperationId(value: string): OperationId { + return createOperationId(`operation-${canonicalDigest(value).slice(0, 32)}`) +} + +export function coordinateConversationOperation( + host: ConversationHost, + command: string, + input: object & { readonly operationId: string }, + action: () => Promise, +): Promise { + const operationId = parseOperation(input.operationId, command) + const request = Object.fromEntries( + Object.entries(input).filter(([, value]) => value !== undefined), + ) + const digest = requestDigest(`in-flight:${command}`, request) + return host.coordinate?.({ operationId, digest }, action) ?? action() +} diff --git a/src/app/conversation-types.ts b/src/app/conversation-types.ts new file mode 100644 index 0000000..49691d1 --- /dev/null +++ b/src/app/conversation-types.ts @@ -0,0 +1,109 @@ +import type { HarnessType } from '@tangle-network/agent-interface' +import type { + BranchRecord, + ConversationRecord, + DraftRecord, + GraphEdgeRecord, + GraphNodeRecord, + OperationKind, + OperationRecord, + QueueRecord, +} from '../domain/entities.js' +import type { BraidEvent } from '../domain/events.js' +import type { PortableContextPlan } from '../domain/receipts.js' +import type { BraidState } from '../domain/state.js' + +export interface ConversationHost { + state(): BraidState + now(): string + commit(event: BraidEvent): Promise + destroy?(input: { readonly conversationId: string; readonly operationId: string }): Promise + coordinate?( + input: { readonly operationId: string; readonly digest: string }, + action: () => Promise, + ): Promise +} + +export interface CreateConversationInput { + readonly operationId: string + readonly title?: string + readonly profileId?: string + readonly connectionId?: string +} + +export interface OpenConversationInput { + readonly operationId: string + readonly conversationId: string + readonly branchId?: string +} + +export interface UpdateConversationInput { + readonly operationId: string + readonly conversationId: string +} + +export interface CreateBranchInput { + readonly operationId: string + readonly conversationId?: string + readonly branchId?: string + readonly throughMessageId?: string + readonly text?: string + readonly runner?: string + readonly model?: string + readonly effort?: string +} + +export interface CloneConversationInput { + readonly operationId: string + readonly conversationId?: string + readonly branchId?: string + readonly title?: string +} + +export interface PlanContextInput { + readonly branchId?: string + readonly throughMessageId?: string + readonly destinationRunner?: HarnessType | string +} + +export interface ForkPlanInput extends CreateBranchInput { + readonly kind?: 'conversation' | 'workspace' +} + +export interface ForkPlan { + readonly kind: 'conversation' | 'workspace' + readonly operationId: string + readonly sourceConversationId: string + readonly sourceBranchId: string + readonly throughMessageId?: string + readonly destinationBranchId: string + readonly context: PortableContextPlan + readonly environment: 'shared' | 'new' | 'unavailable' + readonly providerSession: 'new' + readonly checkpoint: 'none' | 'required' | 'unavailable' + readonly allowed: boolean + readonly reason?: string + readonly digest: string +} + +export interface ConversationListQuery { + readonly query?: string + readonly workspace?: string + readonly status?: 'active' | 'archived' | 'all' +} + +export interface BuiltConversation { + readonly conversation: ConversationRecord + readonly branch: BranchRecord + readonly draft: DraftRecord + readonly queue: QueueRecord + readonly graphNodes: readonly GraphNodeRecord[] + readonly graphEdges: readonly GraphEdgeRecord[] +} + +export interface OperationInput { + readonly operationId: string + readonly kind: OperationKind + readonly request: Readonly> + readonly target?: OperationRecord['target'] +} diff --git a/src/app/conversation-visibility.ts b/src/app/conversation-visibility.ts new file mode 100644 index 0000000..fbbc25f --- /dev/null +++ b/src/app/conversation-visibility.ts @@ -0,0 +1,51 @@ +import type { MessageRecord } from '../domain/entities.js' +import type { BranchId, MessageId } from '../domain/ids.js' +import type { BraidState } from '../domain/state.js' +import { AppError } from './errors.js' + +export function messagesVisibleOnBranch( + state: BraidState, + branchId: BranchId, +): readonly MessageRecord[] { + return visibleForBranch(state, branchId, new Set()) +} + +function visibleForBranch( + state: BraidState, + branchId: BranchId, + visiting: Set, +): readonly MessageRecord[] { + if (visiting.has(branchId)) + throw new AppError('GRAPH_CYCLE', `Branch ancestry includes ${branchId}`) + const branch = state.branches.find((candidate) => candidate.id === branchId) + if (!branch) throw new AppError('UNKNOWN_BRANCH', `Branch ${branchId} does not exist`) + visiting.add(branchId) + try { + const inherited = branch.source + ? messagesThroughBoundary( + visibleForBranch(state, branch.source.branchId, visiting), + branch.source.throughMessageId, + ) + : [] + const local = state.messages.filter((message) => message.branchId === branchId) + const seen = new Set(inherited.map((message) => message.id)) + return [...inherited, ...local.filter((message) => !seen.has(message.id))] + } finally { + visiting.delete(branchId) + } +} + +export function messagesThroughBoundary( + messages: readonly MessageRecord[], + throughMessageId: MessageId | undefined, +): readonly MessageRecord[] { + if (throughMessageId === undefined) return [] + const index = messages.findIndex((message) => message.id === throughMessageId) + if (index < 0) { + throw new AppError( + 'UNKNOWN_MESSAGE_BOUNDARY', + `Message ${throughMessageId} is not visible on the source branch`, + ) + } + return messages.slice(0, index + 1) +} diff --git a/src/app/conversations.ts b/src/app/conversations.ts new file mode 100644 index 0000000..8bb02c4 --- /dev/null +++ b/src/app/conversations.ts @@ -0,0 +1,56 @@ +import { ConversationBranches } from './conversation-branches.js' +import { ConversationContext } from './conversation-context.js' +import { ConversationDrafts } from './conversation-drafts.js' +import { ConversationExports } from './conversation-exports.js' +import { ConversationImports } from './conversation-imports.js' +import { ConversationLifecycle } from './conversation-lifecycle.js' +import type { ConversationHost } from './conversation-types.js' + +/** + * A small facade over focused conversation services. + * + * It owns no state: every read comes from the canonical Braid projection and + * every mutation goes through the canonical application event stream. + */ +export class ConversationActions { + readonly lifecycle: ConversationLifecycle + readonly branches: ConversationBranches + readonly context: ConversationContext + readonly drafts: ConversationDrafts + readonly exports: ConversationExports + readonly imports: ConversationImports + + constructor(host: ConversationHost) { + this.lifecycle = new ConversationLifecycle(host) + this.branches = new ConversationBranches(host) + this.context = new ConversationContext(host) + this.drafts = new ConversationDrafts(host) + this.exports = new ConversationExports(host) + this.imports = new ConversationImports(host) + } +} + +export type { + SetConversationDraftInput, + SetConversationDraftResult, +} from './conversation-drafts.js' +export type { + ConversationExportDocument, + ExportConversationInput, + ExportConversationResult, +} from './conversation-exports.js' +export type { + ImportConversationInput, + ImportConversationResult, +} from './conversation-imports.js' +export type { + CloneConversationInput, + ConversationListQuery, + CreateBranchInput, + CreateConversationInput, + ForkPlan, + ForkPlanInput, + OpenConversationInput, + PlanContextInput, + UpdateConversationInput, +} from './conversation-types.js' diff --git a/src/app/durable-send.ts b/src/app/durable-send.ts new file mode 100644 index 0000000..5e6266c --- /dev/null +++ b/src/app/durable-send.ts @@ -0,0 +1,140 @@ +import type { RunAdmissionReceipt } from '../domain/receipts.js' +import type { BraidState } from '../domain/state.js' +import type { IdSource } from '../ports/ids.js' +import type { SendReceipt } from './application-types.js' +import { AppError } from './errors.js' +import { pendingAdmissionReceipt } from './run-admission.js' +import type { RunExecutionSnapshot } from './run-execution-snapshot.js' + +export interface DurableSendInput { + readonly input: RunExecutionSnapshot + readonly state: BraidState + readonly currentState: () => BraidState + readonly ids: IdSource + readonly restartReconciliation: Promise + readonly transitionTail: Promise + readonly admitPersistedSend: (operationId: string, digest: string) => SendReceipt | undefined + readonly requestDigest: (state: BraidState, input: RunExecutionSnapshot) => string + readonly sendAsync: ( + input: RunExecutionSnapshot, + ids: { readonly runId: string; readonly turnId: string }, + ) => Promise +} + +export interface DurableSendRuntime { + readonly currentState: () => BraidState + readonly ids: IdSource + readonly restartReconciliation: Promise + readonly transitionTail: () => Promise + readonly admitPersistedSend: (operationId: string, digest: string) => SendReceipt | undefined + readonly requestDigest: DurableSendInput['requestDigest'] + readonly sendAsync: DurableSendInput['sendAsync'] +} + +export function createDurableSender( + runtime: DurableSendRuntime, +): (input: RunExecutionSnapshot) => SendReceipt { + let pending: + | { + readonly operationId: string + readonly digest: string + readonly receipt: SendReceipt + } + | undefined + + return (input) => { + const state = runtime.currentState() + const digest = runtime.requestDigest(state, input) + const persisted = runtime.admitPersistedSend(input.operationId, digest) + if (persisted) return persisted + if (pending) { + if (pending.operationId !== input.operationId) + throw new AppError( + 'RUN_ACTIVE', + `Run ${pending.receipt.runId} is awaiting admission; queue the next input explicitly`, + ) + if (pending.digest !== digest) + throw new AppError( + 'OPERATION_CONFLICT', + `Operation ${input.operationId} was already used with different input`, + ) + return replayPendingReceipt(pending.receipt) + } + + const receipt = durableSend({ + input, + state, + currentState: runtime.currentState, + ids: runtime.ids, + restartReconciliation: runtime.restartReconciliation, + transitionTail: runtime.transitionTail(), + admitPersistedSend: runtime.admitPersistedSend, + requestDigest: runtime.requestDigest, + sendAsync: runtime.sendAsync, + }) + const reservation = { operationId: input.operationId, digest, receipt } + pending = reservation + const clear = () => { + if (pending === reservation) pending = undefined + } + receipt.admissionReady?.then(clear, clear) + return receipt + } +} + +export function durableSend(input: DurableSendInput): SendReceipt { + const state = input.state + const digest = input.requestDigest(state, input.input) + const replay = input.admitPersistedSend(input.input.operationId, digest) + if (replay) return replay + if (state.activeRunId) + throw new AppError( + 'RUN_ACTIVE', + `Run ${state.activeRunId} is still active; queue the next input explicitly`, + ) + const runId = input.ids.next('run') + const turnId = input.ids.next('turn') + const pending = pendingAdmissionReceipt(input.input, runId, turnId) + let admission: RunAdmissionReceipt = pending + const admissionTask = input.restartReconciliation + .then(() => input.transitionTail) + .then(() => input.sendAsync(input.input, { runId, turnId })) + .then((result) => { + admission = result.admission + return result + }) + .catch((error: unknown) => { + admission = { + ...pending, + admissionStatus: 'unavailable', + } + throw error + }) + const task = admissionTask.then((result) => result.completion) + task.catch(() => undefined) + return { + operationId: input.input.operationId, + runId, + revision: state.revision, + replayed: false, + get admission() { + return admission + }, + admissionReady: admissionTask.then(() => undefined), + completion: task.then(() => structuredClone(input.currentState())), + } +} + +function replayPendingReceipt(receipt: SendReceipt): SendReceipt { + return { + operationId: receipt.operationId, + runId: receipt.runId, + revision: receipt.revision, + replayed: true, + get admission() { + return receipt.admission + }, + ...(receipt.admissionReady === undefined ? {} : { admissionReady: receipt.admissionReady }), + completion: receipt.completion, + } +} diff --git a/src/app/effect-coordinator.ts b/src/app/effect-coordinator.ts new file mode 100644 index 0000000..1cf4709 --- /dev/null +++ b/src/app/effect-coordinator.ts @@ -0,0 +1,378 @@ +import { canonicalDigest } from '../domain/canonical.js' +import { isSafePublicMetadata } from '../domain/public-metadata.js' +import type { Clock } from '../ports/clock.js' +import type { + EffectOutcomeStatus, + EffectRecord, + EffectStoragePort, +} from '../ports/effect-storage.js' + +export interface EffectIntent { + readonly operationId: string + readonly effectKind: string + readonly request: TRequest + /** Optional storage-owned keyed binding; never recompute it from public receipt data. */ + readonly requestDigest?: string + /** + * Effects sharing a key are serialized; independent keys may progress while + * a long-running stream effect is active. + */ + readonly serializationKey?: string + readonly metadata?: Readonly> + readonly signal?: AbortSignal +} + +export interface EffectContext { + readonly operationId: string + readonly effectKind: string + readonly requestDigest: string + readonly request: TRequest + readonly signal?: AbortSignal +} + +export interface EffectDispatchResult { + readonly status: EffectOutcomeStatus + readonly detail?: string + readonly externalReference?: string +} + +export interface EffectHandler { + dispatch(context: EffectContext): Promise + /** + * Resolves a durable pending record through the owning external authority. + * Returning undefined leaves the record pending; the coordinator never + * guesses that a pending external mutation is safe to repeat. + */ + reconcile?(context: EffectContext): Promise +} + +export interface EffectCoordinatorOptions { + readonly onRecord?: (record: EffectRecord) => void +} + +export interface EffectHandle { + readonly operationId: string + readonly requestDigest: string + readonly replayed: boolean + readonly record: EffectRecord + readonly completion: Promise +} + +export class EffectCoordinatorError extends Error { + readonly code: + | 'EFFECT_KIND_REQUIRED' + | 'OPERATION_ID_REQUIRED' + | 'EFFECT_METADATA_UNSAFE' + | 'EFFECT_INTENT_NOT_DURABLE' + + constructor(code: EffectCoordinatorError['code'], message: string, options?: ErrorOptions) { + super(message, options) + this.name = 'EffectCoordinatorError' + this.code = code + } +} + +export function effectRequestDigest( + intent: Pick, 'effectKind' | 'request'>, +): string { + return canonicalDigest({ effectKind: intent.effectKind, request: intent.request }) +} + +interface QueuedEffect { + readonly key: string + readonly completion: Promise +} + +const SECRET_TEXT = + /(secret|password|passphrase|token|bearer|authorization|credential|private(?:[_-]?key)?|api[-_]?key)\s*[:=]/iu +const SAFE_DETAIL = /^[A-Za-z][A-Za-z0-9._:-]{0,127}$/u +const SAFE_EXTERNAL_REFERENCE = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/u + +function safeDetail(value: string | undefined, fallback: string): string | undefined { + if (value === undefined) return undefined + const candidate = value.trim() + return SAFE_DETAIL.test(candidate) && !SECRET_TEXT.test(candidate) ? candidate : fallback +} + +function safeExternalReference(value: string | undefined): string | undefined { + if (value === undefined) return undefined + const candidate = value.trim() + return SAFE_EXTERNAL_REFERENCE.test(candidate) && !SECRET_TEXT.test(candidate) + ? candidate + : undefined +} + +/** + * Serializes durable effect admission and external dispatch. + * + * The storage port is deliberately synchronous at this boundary so an + * application controller can commit the pending intent before it returns a + * receipt. A SQLite adapter can implement the same port with a synchronous + * transaction API while remaining replaceable behind this seam. + */ +export class SerializedEffectCoordinator { + readonly #storage: EffectStoragePort + readonly #clock: Clock + readonly #inFlight = new Map() + readonly #tails = new Map>() + readonly #onRecord: ((record: EffectRecord) => void) | undefined + + constructor(storage: EffectStoragePort, clock: Clock, options: EffectCoordinatorOptions = {}) { + this.#storage = storage + this.#clock = clock + this.#onRecord = options.onRecord + } + + current(operationId: string): EffectRecord | undefined { + const record = this.#storage.current(operationId) + return record === undefined ? undefined : structuredClone(record) + } + + start(intent: EffectIntent, handler: EffectHandler): EffectHandle { + const requestDigest = this.#validateIntent(intent) + const metadata = intent.metadata ?? {} + if (!isSafePublicMetadata(metadata)) { + throw new EffectCoordinatorError( + 'EFFECT_METADATA_UNSAFE', + 'Effect metadata must contain only bounded public strings', + ) + } + const now = this.#clock.now() + const pending: EffectRecord = { + operationId: intent.operationId, + effectKind: intent.effectKind, + requestDigest, + status: 'pending', + attempt: 1, + createdAt: now, + updatedAt: now, + metadata: structuredClone(metadata), + } + let reservation: { readonly record: EffectRecord; readonly created: boolean } + try { + reservation = this.#storage.reserveEffect(pending) + if (reservation.created) this.#onRecord?.(structuredClone(reservation.record)) + } catch (error) { + throw new EffectCoordinatorError( + 'EFFECT_INTENT_NOT_DURABLE', + `Could not atomically admit effect ${intent.operationId} before dispatch`, + { cause: error }, + ) + } + + const admitted = reservation.record + if (!reservation.created && admitted.status === 'conflict') { + this.#onRecord?.(structuredClone(admitted)) + } + if ( + !reservation.created && + admitted.status !== 'pending' && + !(admitted.status === 'unknown' && handler.reconcile !== undefined) + ) { + return this.#resolvedHandle(intent, requestDigest, admitted, true) + } + + if (!reservation.created) { + const key = this.#key(intent.operationId, requestDigest) + const inFlight = this.#inFlight.get(key) + if (inFlight !== undefined) { + return { + operationId: intent.operationId, + requestDigest, + replayed: true, + record: structuredClone(admitted), + completion: inFlight.completion, + } + } + + if (handler.reconcile === undefined) { + return this.#resolvedHandle(intent, requestDigest, admitted, true) + } + + const completion = this.#schedule(key, intent.serializationKey ?? 'default', () => + this.#reconcilePending(intent, requestDigest, admitted, handler), + ) + return { + operationId: intent.operationId, + requestDigest, + replayed: true, + record: structuredClone(admitted), + completion, + } + } + + const key = this.#key(intent.operationId, requestDigest) + const completion = this.#schedule(key, intent.serializationKey ?? 'default', () => + this.#dispatchNew(intent, pending, handler), + ) + return { + operationId: intent.operationId, + requestDigest, + replayed: false, + record: structuredClone(pending), + completion, + } + } + + async execute( + intent: EffectIntent, + handler: EffectHandler, + ): Promise { + return this.start(intent, handler).completion + } + + #validateIntent(intent: EffectIntent): string { + if (!intent.operationId) { + throw new EffectCoordinatorError('OPERATION_ID_REQUIRED', 'Effect operationId is required') + } + if (!intent.effectKind) { + throw new EffectCoordinatorError('EFFECT_KIND_REQUIRED', 'Effect effectKind is required') + } + if (intent.requestDigest !== undefined) { + if (!/^[0-9a-f]{64}$/u.test(intent.requestDigest)) + throw new EffectCoordinatorError( + 'EFFECT_INTENT_NOT_DURABLE', + 'Effect request fingerprint must be a 256-bit hexadecimal value', + ) + return intent.requestDigest + } + return effectRequestDigest(intent) + } + + async #dispatchNew( + intent: EffectIntent, + pending: EffectRecord, + handler: EffectHandler, + ): Promise { + let outcome: EffectDispatchResult + try { + outcome = await handler.dispatch(this.#context(intent, pending.requestDigest)) + } catch { + outcome = { + status: 'unknown', + detail: 'EFFECT_DISPATCH_UNKNOWN', + } + } + return this.#persistOutcome(pending, outcome) + } + + async #reconcilePending( + intent: EffectIntent, + requestDigest: string, + pending: EffectRecord, + handler: EffectHandler, + ): Promise { + let outcome: EffectDispatchResult | undefined + try { + outcome = await handler.reconcile?.(this.#context(intent, requestDigest)) + } catch { + outcome = { + status: 'unknown', + detail: 'EFFECT_RECONCILIATION_UNKNOWN', + } + } + if (outcome === undefined) return structuredClone(pending) + return this.#persistOutcome(pending, outcome) + } + + #persistOutcome(base: EffectRecord, outcome: EffectDispatchResult): EffectRecord { + const detail = safeDetail(outcome.detail, `EFFECT_${outcome.status.toUpperCase()}`) + const externalReference = safeExternalReference(outcome.externalReference) + const next: EffectRecord = { + ...base, + status: outcome.status, + updatedAt: this.#clock.now(), + ...(detail === undefined ? {} : { detail }), + ...(externalReference === undefined ? {} : { externalReference }), + } + try { + this.#storage.appendEffect(next) + this.#onRecord?.(structuredClone(next)) + return structuredClone(next) + } catch (_error) { + const unknown: EffectRecord = { + ...base, + status: 'unknown', + updatedAt: this.#clock.now(), + detail: 'EFFECT_OUTCOME_NOT_DURABLE', + } + try { + this.#storage.appendEffect(unknown) + try { + this.#onRecord?.(structuredClone(unknown)) + } catch { + // The effect record is durable even if a secondary state projection failed. + } + } catch { + // Leave the pending record for a later reconciliation attempt. + } + return structuredClone(unknown) + } + } + + #context( + intent: EffectIntent, + requestDigest: string, + ): EffectContext { + return { + operationId: intent.operationId, + effectKind: intent.effectKind, + requestDigest, + request: intent.request, + ...(intent.signal === undefined ? {} : { signal: intent.signal }), + } + } + + #resolvedHandle( + intent: EffectIntent, + requestDigest: string, + record: EffectRecord, + replayed: boolean, + ): EffectHandle { + return { + operationId: intent.operationId, + requestDigest, + replayed, + record: structuredClone(record), + completion: Promise.resolve(structuredClone(record)), + } + } + + #key(operationId: string, requestDigest: string): string { + return `${operationId}\u0000${requestDigest}` + } + + #schedule( + key: string, + serializationKey: string, + task: () => Promise, + ): Promise { + const completion = this.#serialize(serializationKey, task) + const queued = { key, completion } + this.#inFlight.set(key, queued) + void completion.then( + () => this.#clearInFlight(key, completion), + () => this.#clearInFlight(key, completion), + ) + return completion + } + + #clearInFlight(key: string, completion: Promise): void { + if (this.#inFlight.get(key)?.completion === completion) this.#inFlight.delete(key) + } + + #serialize(serializationKey: string, task: () => Promise): Promise { + const previous = this.#tails.get(serializationKey) ?? Promise.resolve() + let release!: () => void + const tail = new Promise((resolve) => { + release = resolve + }) + this.#tails.set(serializationKey, tail) + return previous.then(task).finally(() => { + release() + if (this.#tails.get(serializationKey) === tail) this.#tails.delete(serializationKey) + }) + } +} + +export { SerializedEffectCoordinator as EffectCoordinator } diff --git a/src/app/effect-projection.ts b/src/app/effect-projection.ts new file mode 100644 index 0000000..b6a94f6 --- /dev/null +++ b/src/app/effect-projection.ts @@ -0,0 +1,38 @@ +import { canonicalDigest } from '../domain/canonical.js' +import type { EffectRecord as DomainEffectRecord, OperationKind } from '../domain/entities.js' +import { createEffectId, type Digest, parseOperationId } from '../domain/ids.js' +import type { EffectRecord as StoredEffectRecord } from '../ports/effect-storage.js' + +const EFFECT_OPERATION_KINDS: Readonly> = { + 'run.execute': 'send', +} + +/** + * Projects a durable effect-storage record into the journal's effect entity. + * + * Storage holds the record that guards external dispatch; the journal holds the + * user-visible operation history. Both describe the same attempt, so the + * projection carries digests rather than the request itself. + */ +export function projectEffectRecord(record: StoredEffectRecord): DomainEffectRecord { + const outcomeDigest = + record.detail === undefined && record.externalReference === undefined + ? undefined + : canonicalDigest({ + status: record.status, + detail: record.detail ?? null, + externalReference: record.externalReference ?? null, + }) + return { + id: createEffectId(`effect-${record.operationId}-${record.requestDigest.slice(0, 24)}`), + operationId: parseOperationId(record.operationId), + effectKind: record.effectKind, + requestDigest: record.requestDigest as Digest, + kind: EFFECT_OPERATION_KINDS[record.effectKind] ?? 'custom', + status: record.status, + attempt: record.attempt, + ...(outcomeDigest === undefined ? {} : { outcomeDigest }), + createdAt: record.createdAt, + updatedAt: record.updatedAt, + } +} diff --git a/src/app/errors.ts b/src/app/errors.ts new file mode 100644 index 0000000..371751f --- /dev/null +++ b/src/app/errors.ts @@ -0,0 +1,9 @@ +export class AppError extends Error { + readonly code: string + + constructor(code: string, message: string) { + super(message) + this.name = 'AppError' + this.code = code + } +} diff --git a/src/app/fail-closed-journal.ts b/src/app/fail-closed-journal.ts new file mode 100644 index 0000000..81a5069 --- /dev/null +++ b/src/app/fail-closed-journal.ts @@ -0,0 +1,54 @@ +import type { BraidEvent, BraidEventEnvelope } from '../domain/events.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' + +/** Used only before a production SQLite store has been opened. */ +export class FailClosedJournal implements JournalPort, EffectStoragePort { + readonly #clock: Clock + + constructor(clock: Clock) { + this.#clock = clock + } + + envelope(state: BraidState, event: BraidEvent): BraidEventEnvelope { + return { + sequence: state.sequence + 1, + revision: state.revision + 1, + occurredAt: this.#clock.now(), + event, + } + } + + append(): never { + throw new Error('BRAID_STORAGE_REQUIRED: open the encrypted SQLite store before mutating state') + } + + all(): readonly BraidEventEnvelope[] { + return [] + } + + current(): EffectRecord | undefined { + return undefined + } + + latest(): EffectRecord | undefined { + return undefined + } + + reserveEffect(): never { + throw new Error( + 'BRAID_STORAGE_REQUIRED: open the encrypted SQLite store before dispatching effects', + ) + } + + appendEffect(): never { + throw new Error( + 'BRAID_STORAGE_REQUIRED: open the encrypted SQLite store before dispatching effects', + ) + } + + history(): readonly EffectRecord[] { + return [] + } +} diff --git a/src/app/intelligence-actions.ts b/src/app/intelligence-actions.ts new file mode 100644 index 0000000..0ee01d4 --- /dev/null +++ b/src/app/intelligence-actions.ts @@ -0,0 +1,46 @@ +import type { RuntimeSupervisorController } from '../adapters/runtime/supervisor-control.js' +import type { RuntimeSupervisorWatcher } from '../adapters/runtime/supervisor-watch.js' +import { AnalysisComparisonService } from './analysis-comparison.js' +import { AnalysisPromotionService } from './analysis-promotion.js' +import { AnalysisService } from './analysis-service.js' +import type { AnalysisApplicationHost } from './analysis-types.js' +import { SupervisorService } from './supervisor-service.js' +import type { AnalysisAnalyst } from './analysis-execution-session.js' + +/** + * Product-owned composition for analysis and supervisor actions. + * + * The individual services own their domain behavior; this object only gives + * the application and UI one stable entry point to the focused services. + */ +export class IntelligenceActions { + readonly analysis: AnalysisService + readonly comparison: AnalysisComparisonService + readonly promotion: AnalysisPromotionService + readonly supervisor: SupervisorService + + constructor(host: AnalysisApplicationHost, options: IntelligenceActionsOptions = {}) { + this.analysis = new AnalysisService(host, options.analyst) + this.comparison = new AnalysisComparisonService(host) + this.promotion = new AnalysisPromotionService(host) + this.supervisor = new SupervisorService(host, { + ...(options.supervisorWatcher === undefined ? {} : { watcher: options.supervisorWatcher }), + ...(options.supervisorController === undefined + ? {} + : { controller: options.supervisorController }), + }) + } +} + +export interface IntelligenceActionsOptions { + readonly analyst?: AnalysisAnalyst + readonly supervisorWatcher?: RuntimeSupervisorWatcher + readonly supervisorController?: RuntimeSupervisorController +} + +export function createIntelligenceActions( + host: AnalysisApplicationHost, + options: IntelligenceActionsOptions = {}, +): IntelligenceActions { + return new IntelligenceActions(host, options) +} diff --git a/src/app/interaction-controller.ts b/src/app/interaction-controller.ts new file mode 100644 index 0000000..d1e91cd --- /dev/null +++ b/src/app/interaction-controller.ts @@ -0,0 +1,310 @@ +import type { InteractionResponse } from '@tangle-network/agent-interface' +import type { BraidEvent, BraidEventEnvelope } from '../domain/events.js' +import { canonicalDigest } from '../domain/canonical.js' +import type { FeedbackDecisionRecord } from '../domain/entities-graph.js' +import { createFeedbackDecisionId } from '../domain/ids.js' +import { effectRequestDigest } from './effect-coordinator.js' +import type { SerializedEffectCoordinator } from './effect-coordinator.js' +import { executeInteractionEffect, type InteractionEffectRequest } from './interaction-effects.js' +import { checkInteractionResponse } from './interaction-response.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 + readonly runId: string + readonly interactionId: string + readonly response: InteractionResponse + readonly state: StateReader + readonly events: () => readonly BraidEventEnvelope[] + readonly commitAndWait: JournalWriter['commitAndWait'] + readonly ledger: RunLedger + readonly effects: SerializedEffectCoordinator + readonly execution: ExecutionPort + readonly owner: string + readonly whenDurable: () => Promise + readonly now?: () => string + readonly providerSessionId?: string + readonly automated?: boolean +} + +export async function respondInteraction( + input: InteractionControllerInput, +): Promise { + const run = findRun(input.state, input.runId) + const interaction = run.interactions.find( + (candidate) => candidate.request.id === input.interactionId, + ) + if (!interaction) + throw new AppError('UNKNOWN_INTERACTION', 'The interaction is no longer available') + const checked = checkInteractionResponse(interaction.request, input.response) + const request: InteractionEffectRequest = { + operationId: input.operationId, + runId: input.runId, + interactionId: input.interactionId, + response: checked.response, + } + const digest = effectRequestDigest({ effectKind: 'interaction.respond', request }) + assertBinding(input, run) + const recorded = recordedInteractionOperation(input.events(), input.operationId) + if (recorded.conflict) throw new AppError('OPERATION_CONFLICT', recorded.conflict) + if (recorded.requested !== undefined) + assertRecordedInteractionMatchesInput( + recorded.requested, + input.runId, + input.interactionId, + input.operationId, + ) + if (recorded.responded !== undefined) + assertRecordedInteractionMatchesInput( + recorded.responded, + input.runId, + input.interactionId, + input.operationId, + ) + const previous = input.ledger.getInteraction(input.operationId) + if (previous) { + if (previous.digest !== digest) + throw new AppError('OPERATION_CONFLICT', `Operation ${input.operationId} has different input`) + return { + operationId: input.operationId, + runId: input.runId, + interactionId: input.interactionId, + replayed: true, + acknowledgement: { operationId: input.operationId, outcome: 'already-applied' }, + completion: previous.completion, + } + } + const existing = input.effects.current(input.operationId) + if (existing && existing.requestDigest !== digest) + throw new AppError('OPERATION_CONFLICT', `Operation ${input.operationId} has different input`) + if (recorded.responded !== undefined) { + assertRecordedResponseMatches(recorded.responded, checked, input.operationId) + const completion = Promise.resolve(input.state.currentState()) + input.ledger.setInteraction(input.operationId, { digest, completion }) + return { + operationId: input.operationId, + runId: input.runId, + interactionId: input.interactionId, + replayed: true, + acknowledgement: + recorded.responded.outcome === 'unknown' + ? { + operationId: input.operationId, + outcome: 'unknown' as const, + detail: recorded.responded.detail ?? 'INTERACTION_RESPONSE_REQUIRES_RECONCILIATION', + } + : { operationId: input.operationId, outcome: 'already-applied' as const }, + completion, + } + } + const alreadyRequested = recorded.requested !== undefined + if (alreadyRequested) + assertRecordedResponseMatches(recorded.requested, checked, input.operationId) + assertInteractionIsOpen(input, run, interaction.status) + if (!alreadyRequested) { + await input.commitAndWait({ + kind: 'run.interaction.response.requested', + runId: input.runId, + interactionId: input.interactionId, + operationId: input.operationId, + outcome: checked.response.outcome, + ...(checked.dataDigest === undefined ? {} : { dataDigest: checked.dataDigest }), + containsSecret: checked.containsSecret, + }) + } + const effectCompletion = executeInteractionEffect({ + effects: input.effects, + execution: input.execution, + request, + owner: input.owner, + whenDurable: input.whenDurable, + }) + const completion = effectCompletion.then(async (result) => { + await input.commitAndWait({ + kind: 'run.interaction.responded', + runId: input.runId, + interactionId: input.interactionId, + operationId: input.operationId, + outcome: + result.outcome === 'accepted' || result.outcome === 'already-applied' + ? checked.response.outcome + : 'unknown', + ...(checked.dataDigest === undefined ? {} : { dataDigest: checked.dataDigest }), + containsSecret: checked.containsSecret, + ...(result.detail === undefined ? {} : { detail: result.detail }), + }) + if ( + (result.outcome === 'accepted' || result.outcome === 'already-applied') && + !checked.containsSecret + ) { + await input.commitAndWait({ + kind: 'feedback.decision.recorded', + decision: feedbackDecision(input, run, checked.response, checked.dataDigest), + }) + } + return structuredClone(input.state.currentState()) + }) + input.ledger.setInteraction(input.operationId, { digest, completion }) + const acknowledgement = await effectCompletion + return { + operationId: input.operationId, + runId: input.runId, + interactionId: input.interactionId, + replayed: existing !== undefined || alreadyRequested, + acknowledgement, + completion, + } +} + +function assertBinding(input: InteractionControllerInput, run: ReturnType): void { + if (input.providerSessionId !== undefined && input.providerSessionId !== run.providerSessionId) { + throw new AppError( + 'INTERACTION_BINDING_MISMATCH', + 'The response belongs to a different provider session', + ) + } +} + +function assertInteractionIsOpen( + input: InteractionControllerInput, + run: ReturnType, + status: string, +): void { + if (currentInteractionExpired(input, run)) + throw new AppError('INTERACTION_EXPIRED', 'The interaction response window has expired') + if (status !== 'pending' && status !== 'responding') + throw new AppError('INTERACTION_STALE', `The interaction is already ${status}`) + if (run.status === 'unknown' || run.status === 'expired' || run.status === 'cancelled') + throw new AppError('INTERACTION_STALE', 'The run can no longer accept an interaction response') +} + +export function isInteractionExpired( + input: Pick, + run: ReturnType, + interaction: ReturnType['interactions'][number], +): 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 +} + +function recordedInteractionOperation( + events: readonly BraidEventEnvelope[], + operationId: string, +): { + readonly requested?: Extract + readonly responded?: Extract + readonly conflict?: string +} { + let requested: Extract | undefined + let responded: Extract | undefined + for (const envelope of events) { + const event = envelope.event + if ( + (event.kind !== 'run.interaction.response.requested' && + event.kind !== 'run.interaction.responded') || + event.operationId !== operationId + ) + continue + if (event.kind === 'run.interaction.response.requested') { + if ( + requested !== undefined && + (requested.runId !== event.runId || requested.interactionId !== event.interactionId) + ) + return { conflict: `Operation ${operationId} was used for two interactions` } + requested = event + } else { + if ( + responded !== undefined && + (responded.runId !== event.runId || responded.interactionId !== event.interactionId) + ) + return { conflict: `Operation ${operationId} was used for two interactions` } + responded = event + } + } + return { + ...(requested === undefined ? {} : { requested }), + ...(responded === undefined ? {} : { responded }), + } +} + +function assertRecordedResponseMatches( + event: Extract< + BraidEvent, + { kind: 'run.interaction.response.requested' | 'run.interaction.responded' } + >, + checked: ReturnType, + operationId: string, +): void { + if ( + event.outcome !== checked.response.outcome || + (event.dataDigest ?? undefined) !== (checked.dataDigest ?? undefined) + ) { + throw new AppError('OPERATION_CONFLICT', `Operation ${operationId} has different input`) + } +} + +function assertRecordedInteractionMatchesInput( + event: Extract< + BraidEvent, + { kind: 'run.interaction.response.requested' | 'run.interaction.responded' } + >, + runId: string, + interactionId: string, + operationId: string, +): void { + if (event.runId !== runId || event.interactionId !== interactionId) + throw new AppError( + 'OPERATION_CONFLICT', + `Operation ${operationId} was used for another interaction`, + ) +} + +function currentInteractionExpired( + input: InteractionControllerInput, + run: ReturnType, +): boolean { + const interaction = run.interactions.find( + (candidate) => candidate.request.id === input.interactionId, + ) + return interaction === undefined ? false : isInteractionExpired(input, run, interaction) +} + +function feedbackDecision( + input: InteractionControllerInput, + run: ReturnType, + response: InteractionResponse, + dataDigest: string | undefined, +): FeedbackDecisionRecord { + return { + id: createFeedbackDecisionId(`feedback-${canonicalDigest(input.operationId).slice(0, 48)}`), + conversationId: run.conversationId, + category: response.outcome === 'accepted' ? 'approval' : 'rejection', + chosenOption: chosenOption(response), + ...(dataDigest === undefined ? {} : { feedback: `answer-digest:${dataDigest}` }), + automated: input.automated === true, + createdAt: input.now?.() ?? new Date().toISOString(), + } +} + +function chosenOption(response: InteractionResponse): string { + if (response.outcome !== 'accepted') return response.outcome + const grant = response.data?.grant + if (Array.isArray(grant) && grant.length === 1) { + const value = grant[0] + if (value === 'allow_once') return 'allow_once' + if (value === 'allow_session') return 'allow_session' + if (value === 'allow_always') return 'allow_always' + if (value === 'deny') return 'deny' + } + return 'accepted' +} diff --git a/src/app/interaction-effects.ts b/src/app/interaction-effects.ts new file mode 100644 index 0000000..0ff2e23 --- /dev/null +++ b/src/app/interaction-effects.ts @@ -0,0 +1,96 @@ +import type { InteractionResponse } from '@tangle-network/agent-interface' +import type { ControlAcknowledgement, ExecutionPort } from '../ports/execution.js' +import type { SerializedEffectCoordinator } from './effect-coordinator.js' + +export interface InteractionEffectRequest { + readonly operationId: string + readonly runId: string + readonly interactionId: string + readonly response: InteractionResponse +} + +export async function executeInteractionEffect(input: { + readonly effects: SerializedEffectCoordinator + readonly execution: ExecutionPort + readonly request: InteractionEffectRequest + readonly owner: string + readonly whenDurable: () => Promise +}): Promise { + const { request } = input + const effect = input.effects.start( + { + operationId: request.operationId, + effectKind: 'interaction.respond', + request, + serializationKey: `run:${request.runId}:interaction`, + metadata: { + runId: request.runId, + interactionId: request.interactionId, + owner: input.owner, + }, + }, + { + dispatch: async () => { + return dispatchResponse(input.execution, request) + }, + reconcile: async () => dispatchResponse(input.execution, request), + }, + ) + const record = await effect.completion + await input.whenDurable() + if (record.status === 'acknowledged' || record.status === 'terminal') { + return { + operationId: request.operationId, + outcome: record.status === 'terminal' ? 'already-applied' : 'accepted', + ...(record.detail === undefined ? {} : { detail: record.detail }), + } + } + if (record.status === 'failed') { + return { + operationId: request.operationId, + outcome: 'rejected', + detail: 'INTERACTION_RESPONSE_REJECTED', + } + } + return { + operationId: request.operationId, + outcome: 'unknown', + detail: 'INTERACTION_RESPONSE_UNKNOWN', + } +} + +async function dispatchResponse( + execution: ExecutionPort, + request: InteractionEffectRequest, +): Promise<{ + readonly status: 'acknowledged' | 'failed' | 'unknown' + readonly detail: string +}> { + if (!execution.respondInteraction) + return { status: 'unknown', detail: 'INTERACTION_RESPONSE_UNAVAILABLE' } + try { + const result = await execution.respondInteraction(request) + if (result.outcome === 'accepted' || result.outcome === 'already-applied') { + return { + status: 'acknowledged', + detail: result.detail ?? 'INTERACTION_RESPONSE_ACCEPTED', + } + } + if (result.outcome === 'rejected') { + return { + status: 'failed', + detail: interactionDetail(result.detail, 'INTERACTION_RESPONSE_REJECTED'), + } + } + return { + status: 'unknown', + detail: interactionDetail(result.detail, 'INTERACTION_RESPONSE_UNKNOWN'), + } + } catch { + return { status: 'unknown', detail: 'INTERACTION_RESPONSE_UNKNOWN' } + } +} + +function interactionDetail(value: string | undefined, fallback: string): string { + return value !== undefined && /^INTERACTION_[A-Z0-9_]{1,96}$/u.test(value) ? value : fallback +} diff --git a/src/app/interaction-response.ts b/src/app/interaction-response.ts new file mode 100644 index 0000000..49f762a --- /dev/null +++ b/src/app/interaction-response.ts @@ -0,0 +1,115 @@ +import { + InteractionResponseSchema, + validateInteractionResponse, + type InteractionRequest, + type InteractionResponse, +} from '@tangle-network/agent-interface' +import { canonicalDigest } from '../domain/canonical.js' +import type { NonSecretInteractionData } from '../domain/entities-interactions.js' +import { AppError } from './errors.js' + +const MAX_FIELDS = 64 +const MAX_NAME_BYTES = 256 +const MAX_VALUE_BYTES = 64 * 1024 +const MAX_ARRAY_ITEMS = 128 + +export interface CheckedInteractionResponse { + readonly response: InteractionResponse + readonly publicData?: NonSecretInteractionData + readonly dataDigest?: string + readonly containsSecret: boolean +} + +export function interactionHasSecretField(request: InteractionRequest): boolean { + return request.answerSpec.fields.some( + (field) => field.type === 'secret' || isSecretName(field.name), + ) +} + +export function checkInteractionResponse( + request: InteractionRequest, + response: unknown, +): CheckedInteractionResponse { + const parsed = InteractionResponseSchema.safeParse(response) + if (!parsed.success) + throw new AppError('INVALID_INTERACTION_RESPONSE', 'Interaction response is invalid') + const bounded = boundResponse(applyDefaults(request, parsed.data)) + const validation = validateInteractionResponse(request, bounded) + if (!validation.ok) + throw new AppError( + 'INVALID_INTERACTION_RESPONSE', + `Interaction response does not match its answer specification (${validation.errors.length} validation error${validation.errors.length === 1 ? '' : 's'})`, + ) + const secretNames = new Set( + request.answerSpec.fields + .filter((field) => field.type === 'secret' || isSecretName(field.name)) + .map((field) => field.name), + ) + const containsSecret = + interactionHasSecretField(request) || + Object.keys(bounded.data ?? {}).some((name) => secretNames.has(name)) + const publicData = containsSecret ? undefined : bounded.data + return { + response: bounded, + ...(publicData === undefined ? {} : { publicData, dataDigest: canonicalDigest(publicData) }), + containsSecret, + } +} + +function applyDefaults( + request: InteractionRequest, + response: InteractionResponse, +): InteractionResponse { + if (response.outcome !== 'accepted') return response + const data: Record = { + ...(response.data ?? {}), + } + let changed = response.data !== undefined + for (const field of request.answerSpec.fields) { + if (data[field.name] !== undefined || !('default' in field) || field.default === undefined) + continue + data[field.name] = Array.isArray(field.default) ? [...field.default] : field.default + changed = true + } + return changed ? { ...response, data } : response +} + +function boundResponse(response: InteractionResponse): InteractionResponse { + if (Buffer.byteLength(response.id, 'utf8') > MAX_NAME_BYTES) + throw new AppError('INVALID_INTERACTION_RESPONSE', 'Interaction id is too long') + if (response.data === undefined) return { id: response.id, outcome: response.outcome } + const entries = Object.entries(response.data) + if (entries.length > MAX_FIELDS) + throw new AppError('INVALID_INTERACTION_RESPONSE', 'Interaction response has too many fields') + const data: Record = {} + for (const [name, value] of entries) { + if (Buffer.byteLength(name, 'utf8') > MAX_NAME_BYTES) + throw new AppError('INVALID_INTERACTION_RESPONSE', 'Interaction field name is too long') + if (typeof value === 'string') { + if (Buffer.byteLength(value, 'utf8') > MAX_VALUE_BYTES) + throw new AppError('INVALID_INTERACTION_RESPONSE', 'Interaction response text is too long') + data[name] = value + } else if (typeof value === 'number' || typeof value === 'boolean') { + if (typeof value === 'number' && !Number.isFinite(value)) + throw new AppError('INVALID_INTERACTION_RESPONSE', 'Interaction number is not finite') + data[name] = value + } else if ( + Array.isArray(value) && + value.length <= MAX_ARRAY_ITEMS && + value.every((item) => typeof item === 'string') + ) { + if (value.some((item) => Buffer.byteLength(item, 'utf8') > MAX_VALUE_BYTES)) + throw new AppError('INVALID_INTERACTION_RESPONSE', 'Interaction option is too long') + data[name] = value.slice() + } else { + throw new AppError('INVALID_INTERACTION_RESPONSE', 'Interaction response data is not bounded') + } + } + return { id: response.id, outcome: response.outcome, data } +} + +function isSecretName(name: string): boolean { + return /(?:password|passphrase|token|secret|credential|authorization|cookie|private|api[_-]?key)/iu.test( + name, + ) +} diff --git a/src/app/journal.ts b/src/app/journal.ts index c2302aa..6fc13c8 100644 --- a/src/app/journal.ts +++ b/src/app/journal.ts @@ -1,15 +1,25 @@ import type { BraidEvent, BraidEventEnvelope } from '../domain/events.js' +import { providerEventKey } from '../domain/events.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' +import { createInMemoryOperationFingerprint } from './operation-fingerprint.js' -export class MemoryJournal { +export class MemoryJournal implements JournalPort, EffectStoragePort { + readonly #fingerprint = createInMemoryOperationFingerprint() readonly #clock: Clock readonly #events: BraidEventEnvelope[] = [] + readonly #effects = new Map() + readonly #providerEvents = new Set() constructor(clock: Clock) { this.#clock = clock } + fingerprint(input: { readonly effectKind: string; readonly request: unknown }): string { + return this.#fingerprint.fingerprint(input) + } + envelope(state: BraidState, event: BraidEvent): BraidEventEnvelope { return { sequence: state.sequence + 1, @@ -19,11 +29,74 @@ export class MemoryJournal { } } - append(envelope: BraidEventEnvelope): void { - this.#events.push(envelope) + append(envelope: BraidEventEnvelope): { + readonly appended: boolean + readonly duplicate: boolean + } { + const providerKey = providerEventKey(envelope.event) + if (providerKey && this.#providerEvents.has(providerKey)) { + return { appended: false, duplicate: true } + } + this.#events.push(structuredClone(envelope)) + if (providerKey) this.#providerEvents.add(providerKey) + return { appended: true, duplicate: false } } all(): readonly BraidEventEnvelope[] { return this.#events.map((event) => structuredClone(event)) } + + async flush(): Promise { + // MemoryJournal commits synchronously in append. + } + + current(operationId: string): EffectRecord | undefined { + const records = this.#effects.get(operationId) ?? [] + const record = [...records].reverse().find((candidate) => candidate.status !== 'conflict') + return record === undefined ? undefined : structuredClone(record) + } + + latest(operationId: string, requestDigest: string): EffectRecord | undefined { + const records = this.#effects.get(operationId) ?? [] + const record = [...records] + .reverse() + .find((candidate) => candidate.requestDigest === requestDigest) + return record === undefined ? undefined : structuredClone(record) + } + + reserveEffect(record: EffectRecord): { + readonly record: EffectRecord + readonly created: boolean + } { + const current = this.current(record.operationId) + if (current !== undefined) { + if (current.requestDigest === record.requestDigest) { + return { record: current, created: false } + } + const conflict: EffectRecord = { + ...record, + status: 'conflict', + detail: `Operation is already bound to request digest ${current.requestDigest}`, + conflictWithDigest: current.requestDigest, + } + this.appendEffect(conflict) + return { record: structuredClone(conflict), created: false } + } + this.appendEffect(record) + return { record: structuredClone(record), created: true } + } + + appendEffect(record: EffectRecord): void { + const records = this.#effects.get(record.operationId) ?? [] + records.push(structuredClone(record)) + this.#effects.set(record.operationId, records) + } + + history(operationId: string): readonly EffectRecord[] { + return (this.#effects.get(operationId) ?? []).map((record) => structuredClone(record)) + } +} + +export function createMemoryJournal(clock: Clock): MemoryJournal { + return new MemoryJournal(clock) } diff --git a/src/app/legacy-cancel.ts b/src/app/legacy-cancel.ts new file mode 100644 index 0000000..8a702d8 --- /dev/null +++ b/src/app/legacy-cancel.ts @@ -0,0 +1,71 @@ +import { redactSensitiveText } from '../domain/redaction.js' +import type { BraidState } from '../domain/state.js' +import { operationId } from './application-guards.js' +import type { CancelInput, CancelReceipt } from './application-options.js' +import type { ControlReceipt } from './application-types.js' +import { AppError } from './errors.js' +import { cancelRequestDigest, DEFAULT_CANCEL_REASON } from './operation-ledger.js' +import type { RunLedger } from './run-ledger.js' +import { isTerminal } from './run-status.js' + +export interface LegacyCancelHost { + readonly state: () => BraidState + readonly snapshot: () => BraidState + readonly ledger: Pick + readonly cancelRun: (input: { + readonly operationId: string + readonly runId: string + readonly reason: string + readonly terminalStatus: 'aborted' + readonly legacy: true + }) => Promise +} + +/** Compatibility surface for callers that expect a synchronous cancel receipt. */ +export function legacyCancel(host: LegacyCancelHost, input: CancelInput): CancelReceipt { + const opId = operationId(input.operationId, 'cancel') + const state = host.state() + const previous = host.ledger.getControl(opId) + const runId = input.runId ?? state.activeRunId ?? previous?.runId + if (!runId) throw new AppError('UNKNOWN_RUN', 'There is no run to cancel') + const run = state.runs.find((candidate) => candidate.id === runId) + const reason = redactSensitiveText(input.reason ?? DEFAULT_CANCEL_REASON) + const digest = cancelRequestDigest(runId, reason, run?.providerSessionId) + + if (previous) { + if (previous.digest !== digest) { + throw new AppError( + 'OPERATION_CONFLICT', + `Operation ${opId} was already used with different input`, + ) + } + return { + operationId: opId, + runId, + revision: state.revision, + replayed: true, + completion: previous.completion.then(host.snapshot), + } + } + + if (!run || state.activeRunId !== runId || isTerminal(run.status)) { + throw new AppError('UNKNOWN_RUN', `Run ${runId} is not active`) + } + const completion = host + .cancelRun({ + operationId: opId, + runId, + reason, + terminalStatus: 'aborted', + legacy: true, + }) + .then((receipt) => receipt.completion) + .then(host.snapshot) + return { + operationId: opId, + runId, + revision: state.revision, + replayed: false, + completion, + } +} diff --git a/src/app/operation-fingerprint.ts b/src/app/operation-fingerprint.ts new file mode 100644 index 0000000..e0c44b1 --- /dev/null +++ b/src/app/operation-fingerprint.ts @@ -0,0 +1,34 @@ +import { createHmac, randomBytes } from 'node:crypto' +import { canonicalJson } from '../domain/canonical.js' + +const FINGERPRINT_DOMAIN = 'braid-operation-fingerprint:v1\u0000' + +interface OperationFingerprintInput { + readonly effectKind: string + readonly request: unknown +} + +export interface OperationFingerprintPort { + readonly fingerprint: (input: { + readonly effectKind: string + readonly request: unknown + }) => string +} + +export function createInMemoryOperationFingerprint(): OperationFingerprintPort { + const key = randomBytes(32) + return createOperationFingerprint(key) +} + +export function createOperationFingerprint(key: Uint8Array): OperationFingerprintPort { + if (key.byteLength < 16) throw new TypeError('Operation fingerprint key is too short') + return Object.freeze({ + fingerprint: ({ effectKind, request }: OperationFingerprintInput) => + createHmac('sha256', key) + .update(FINGERPRINT_DOMAIN) + .update(effectKind) + .update('\u0000') + .update(canonicalJson(request)) + .digest('hex'), + }) +} diff --git a/src/app/operation-ledger.ts b/src/app/operation-ledger.ts new file mode 100644 index 0000000..a7c122b --- /dev/null +++ b/src/app/operation-ledger.ts @@ -0,0 +1,82 @@ +import { canonicalDigest } from '../domain/canonical.js' +import type { BraidEventEnvelope } from '../domain/events.js' + +export type LedgerOperationKind = 'send' | 'cancel' | 'shutdown' + +export interface OperationRecord { + readonly kind: LedgerOperationKind + readonly digest: string + readonly runId?: string + completion: Promise +} + +export const DEFAULT_CANCEL_REASON = 'Cancelled by user' + +export function cancelRequestDigest( + runId: string, + reason: string, + providerSessionId?: string, +): string { + return canonicalDigest({ + control: 'cancel', + runId, + providerSessionId: providerSessionId ?? null, + reason, + text: null, + cursor: null, + }) +} + +export function shutdownRequestDigest(): string { + return canonicalDigest({ command: 'shutdown' }) +} + +/** + * In-process registry binding an operation identifier to the work it started. + * + * Run dispatch identity is durable in effect storage, because a run reaches an + * external provider and must never be repeated after a crash. Cancellation and + * shutdown are journalled instead of admitted through the effect coordinator, + * which serializes dispatches and would therefore queue a cancellation behind + * the very run it is meant to stop, so their identity is rebuilt from the + * journal at startup. + */ +export class OperationLedger { + readonly #records = new Map() + + get(operationId: string): OperationRecord | undefined { + return this.#records.get(operationId) + } + + set(operationId: string, record: OperationRecord): void { + this.#records.set(operationId, record) + } + + delete(operationId: string): void { + this.#records.delete(operationId) + } + + forRun(runId: string): readonly OperationRecord[] { + return [...this.#records.values()].filter((entry) => entry.runId === runId) + } + + restore(envelopes: readonly BraidEventEnvelope[]): void { + for (const envelope of envelopes) { + const event = envelope.event + if (event.kind === 'run.cancel.requested') { + this.#records.set(event.operationId, { + kind: 'cancel', + digest: cancelRequestDigest(event.runId, event.reason ?? DEFAULT_CANCEL_REASON), + runId: event.runId, + completion: Promise.resolve(), + }) + } else if (event.kind === 'application.shutdown.requested') { + this.#records.set(event.operationId, { + kind: 'shutdown', + digest: shutdownRequestDigest(), + completion: Promise.resolve(), + }) + } + } + } +} diff --git a/src/app/production-composition.ts b/src/app/production-composition.ts new file mode 100644 index 0000000..87bd76b --- /dev/null +++ b/src/app/production-composition.ts @@ -0,0 +1,173 @@ +import type { AgentProfile, HarnessType } from '@tangle-network/agent-interface' +import type { ProductionConnectionOptions } from '../adapters/connections/production-connections.js' +import { + AgentRuntimeExecutionPort, + type AgentTurnBackendResolver, +} from '../adapters/runtime/agent-runtime-execution.js' +import { + createProductionBackendResolver, + type ProductionBackendResolverOptions, +} from '../adapters/runtime/production-backend-resolver.js' +import type { ConnectionRecord } from '../domain/entities.js' +import type { ExecutionPort } from '../ports/execution.js' +import { ConnectionError } from './connection-errors.js' +import { ConnectionRegistry } from './connections.js' +import { assertValidProfile } from './profile-validation.js' + +export type ProductionCompositionErrorCode = + | 'PRODUCTION_CONFIGURATION_REQUIRED' + | 'PRODUCTION_PROFILE_INVALID' + | 'PRODUCTION_CONNECTION_REQUIRED' + | 'PRODUCTION_CONNECTION_INVALID' + | 'PRODUCTION_CONNECTION_NOT_FOUND' + | 'PRODUCTION_CONNECTION_UNSUPPORTED' + | 'PRODUCTION_FIXTURE_FORBIDDEN' + +export class ProductionCompositionError extends Error { + readonly code: ProductionCompositionErrorCode + + constructor(code: ProductionCompositionErrorCode, message: string, cause?: unknown) { + super(message, cause === undefined ? undefined : { cause }) + this.name = 'ProductionCompositionError' + this.code = code + } +} + +export interface ProductionCompositionConfig { + /** The complete canonical profile selected for this process. */ + readonly profile: Readonly + /** Candidate connections loaded from the selected configuration source. */ + readonly connections: readonly ConnectionRecord[] + /** Exact connection identity; names and provider kinds are never guessed. */ + readonly connectionId: string + /** Optional one-run values selected by the command line or startup config. */ + readonly model?: string + readonly runner?: HarnessType + /** Canonical workspace root used by provider environment creation. */ + readonly workspaceRoot?: string + /** Protected headless SQLite key-file path, absolute or relative to the config directory. */ + readonly databaseKeyFile?: string + /** Published provider construction options, including credential resolution. */ + readonly connectionOptions?: ProductionConnectionOptions +} + +export interface ProductionComposition { + readonly profile: Readonly + readonly connections: ConnectionRegistry + readonly connection: ConnectionRecord + readonly execution: ExecutionPort + readonly backendResolver: AgentTurnBackendResolver +} + +const SUPPORTED_CONNECTION_KINDS = new Set([ + 'cli-bridge', + 'tangle-inference', + 'tangle-sandbox', +]) + +export function createProductionComposition( + config: ProductionCompositionConfig, +): ProductionComposition { + if (!config || config.profile === undefined) { + throw new ProductionCompositionError( + 'PRODUCTION_CONFIGURATION_REQUIRED', + 'Production startup requires a canonical profile and one connection', + ) + } + + let profile: Readonly + try { + profile = assertValidProfile(config.profile) + } catch (error) { + throw new ProductionCompositionError( + 'PRODUCTION_PROFILE_INVALID', + 'The selected profile is not a valid canonical AgentProfile', + error, + ) + } + + if (!Array.isArray(config.connections) || config.connections.length === 0) { + throw new ProductionCompositionError( + 'PRODUCTION_CONNECTION_REQUIRED', + 'Production startup requires at least one configured connection', + ) + } + for (const record of config.connections) { + if (record === null || typeof record !== 'object' || Array.isArray(record)) { + throw new ProductionCompositionError( + 'PRODUCTION_CONNECTION_INVALID', + 'Every production connection must be an object without secret material', + ) + } + const candidate = record as unknown as { + readonly id?: unknown + readonly kind?: unknown + } + if (typeof candidate.kind !== 'string') { + throw new ProductionCompositionError( + 'PRODUCTION_CONNECTION_INVALID', + 'Every production connection must declare a provider kind', + ) + } + if (!SUPPORTED_CONNECTION_KINDS.has(candidate.kind as ConnectionRecord['kind'])) { + throw new ProductionCompositionError( + 'PRODUCTION_CONNECTION_UNSUPPORTED', + `Connection ${String(candidate.id)} uses an unsupported provider kind`, + ) + } + } + + let connections: ConnectionRegistry + try { + connections = new ConnectionRegistry(config.connections) + } catch (error) { + throw new ProductionCompositionError( + 'PRODUCTION_CONNECTION_INVALID', + 'The production connection configuration is invalid or contains secret material', + error, + ) + } + + let connection: ConnectionRecord + try { + connection = connections.select({ connectionId: config.connectionId }).record + } catch (error) { + if (error instanceof ConnectionError && error.code === 'CONNECTION_NOT_FOUND') { + throw new ProductionCompositionError( + 'PRODUCTION_CONNECTION_NOT_FOUND', + 'The configured production connection does not exist', + error, + ) + } + throw new ProductionCompositionError( + 'PRODUCTION_CONNECTION_REQUIRED', + 'Production startup requires an exact connection id', + error, + ) + } + + const resolverOptions: ProductionBackendResolverOptions = { + ...(config.connectionOptions ?? {}), + connections, + ...(config.workspaceRoot === undefined ? {} : { workspaceCwd: config.workspaceRoot }), + select: () => ({ + connection: { + connectionId: connection.id, + expectedKind: connection.kind, + expectedUpdatedAt: connection.updatedAt, + }, + ...(config.model === undefined ? {} : { model: config.model }), + ...(config.runner === undefined ? {} : { runner: config.runner }), + }), + } + const backendResolver = createProductionBackendResolver(resolverOptions) + const execution = new AgentRuntimeExecutionPort(backendResolver) + + return Object.freeze({ + profile, + connections, + connection, + execution, + backendResolver, + }) +} diff --git a/src/app/profile-action-support.ts b/src/app/profile-action-support.ts new file mode 100644 index 0000000..ff04fb3 --- /dev/null +++ b/src/app/profile-action-support.ts @@ -0,0 +1,316 @@ +import { canonicalAgentProfileDigest } from '@tangle-network/agent-interface' +import type { + ProfileRecord as DomainProfileRecord, + ProfileSourceKind as DomainProfileSourceKind, + OperationRecord, +} from '../domain/entities.js' +import type { JsonValue } from '../domain/entities-base.js' +import { type Digest, parseProfileId } from '../domain/ids.js' +import { redactProfile, redactSensitiveText } from '../domain/redaction.js' +import { acknowledgedOperation, type parseOperation } from './conversation-support.js' +import { AppError } from './errors.js' +import { + describeProfileSource, + type ProfileValidationReport, + type ProfileRecord as SourceProfileRecord, + summarizeProfile, +} from './profiles.js' + +export type SafeProfileValidationReport = Omit & { + readonly provider?: Omit, 'result'> & { + readonly result: Omit< + NonNullable['result'], + 'normalizedProfile' + > + } +} + +export interface ProfileSaveIntent { + readonly kind: 'profile-save-intent' + readonly targetPath: string + readonly expectedBytesDigest?: string + readonly expectedProfileDigest?: string + readonly expectedMissing: boolean + readonly intendedBytesDigest: string + readonly intendedProfileDigest: string + readonly summary: ReturnType + readonly sourceLabel: string + readonly trusted: boolean + readonly profileId?: string + readonly select: boolean +} + +export function findProfileRecord( + records: readonly SourceProfileRecord[], + ref: string, +): SourceProfileRecord | undefined { + return records.find( + (record) => + record.id === ref || + record.source.reference === ref || + record.source.label === ref || + record.displayName === ref, + ) +} + +export function newProfileFileTarget(ref: string): SourceProfileRecord['source'] { + if (!ref.includes('/') && !ref.endsWith('.json')) { + throw new AppError('PROFILE_NOT_FOUND', 'A new profile must name a JSON file path') + } + return describeProfileSource({ kind: 'file', reference: ref, path: ref }) +} + +export function profileRevision(value: string | undefined): `sha256:${string}` | undefined { + return value !== undefined && /^sha256:[0-9a-f]{64}$/u.test(value) + ? (value as `sha256:${string}`) + : undefined +} + +export function toDomainProfile( + record: SourceProfileRecord, + now: string, + previous?: DomainProfileRecord, +): DomainProfileRecord { + const profile = redactProfile(record.profile) + const digest = canonicalAgentProfileDigest(profile).slice('sha256:'.length) as Digest + const sourceKind = toDomainSourceKind(record.source.kind) + return { + id: parseProfileId(record.id), + source: { + kind: sourceKind, + reference: record.source.reference, + ...(record.source.revision === undefined ? {} : { revision: record.source.revision }), + }, + profile, + digest, + validation: { ok: true, issues: [] }, + createdAt: previous?.createdAt ?? now, + updatedAt: now, + } +} + +export function safeProfileSummary(record: SourceProfileRecord) { + const summary = summarizeProfile(record) + return { + ...summary, + name: redactSensitiveText(summary.name, 512), + ...(summary.description === undefined + ? {} + : { description: redactSensitiveText(summary.description, 4096) }), + source: { + ...summary.source, + reference: redactSensitiveText(summary.source.reference, 2048), + label: redactSensitiveText(summary.source.label, 512), + }, + } +} + +export function profileSelectionOperationResult( + profile: ReturnType, + reason: 'command-line' | 'branch' | 'workspace' | 'user' | 'first-run', +): NonNullable { + return jsonObject({ profile, reason }) +} + +export function profileSaveOperationResult( + profile: ReturnType, + path: string, + bytesDigest: string, + intent?: ProfileSaveIntent, +): NonNullable { + return jsonObject({ + profile, + path: redactSensitiveText(path, 2048), + bytesDigest, + ...(intent === undefined ? {} : { intent }), + }) +} + +export function profileSelectionFromOperation(operation: OperationRecord): + | { + readonly profile: ReturnType + readonly reason: 'command-line' | 'branch' | 'workspace' | 'user' | 'first-run' + } + | undefined { + const value = operation.result + if (!isRecord(value) || !isProfileSummary(value.profile)) return undefined + if (!isProfileSelectionReason(value.reason)) return undefined + return { + profile: value.profile as unknown as ReturnType, + reason: value.reason, + } +} + +export function profileSaveFromOperation(operation: OperationRecord): + | { + readonly profile: ReturnType + readonly path: string + readonly bytesDigest: string + readonly intent?: ProfileSaveIntent + } + | undefined { + const value = operation.result + if ( + !isRecord(value) || + !isProfileSummary(value.profile) || + typeof value.path !== 'string' || + typeof value.bytesDigest !== 'string' + ) + return undefined + const intent = profileSaveIntentFromOperation(operation) + return { + profile: value.profile as unknown as ReturnType, + path: value.path, + bytesDigest: value.bytesDigest, + ...(intent === undefined ? {} : { intent }), + } +} + +export function profileSaveIntentFromOperation( + operation: OperationRecord, +): ProfileSaveIntent | undefined { + if (!isRecord(operation.result) || !isRecord(operation.result.intent)) return undefined + const value = operation.result.intent + if ( + value.kind !== 'profile-save-intent' || + typeof value.targetPath !== 'string' || + typeof value.expectedMissing !== 'boolean' || + typeof value.intendedBytesDigest !== 'string' || + typeof value.intendedProfileDigest !== 'string' || + !isProfileSummary(value.summary) || + typeof value.sourceLabel !== 'string' || + typeof value.trusted !== 'boolean' || + typeof value.select !== 'boolean' + ) + return undefined + return value as unknown as ProfileSaveIntent +} + +export function fromDomainProfile(record: DomainProfileRecord): SourceProfileRecord { + const profile = record.profile + const kind = record.source.kind === 'catalog' ? 'provider' : record.source.kind + const source = { + kind, + reference: record.source.reference, + label: record.source.reference, + ...(record.source.revision === undefined ? {} : { revision: record.source.revision }), + writable: kind === 'file', + trusted: false, + } as const + return { + id: record.id, + displayName: profile.name ?? source.label, + source, + profile, + digest: canonicalAgentProfileDigest(profile), + agentInterfacePackageVersion: 'durable-state', + } +} + +function toDomainSourceKind(kind: SourceProfileRecord['source']['kind']): DomainProfileSourceKind { + if (kind === 'inline' || kind === 'file' || kind === 'package' || kind === 'github') return kind + return 'catalog' +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +function isProfileSummary(value: unknown): boolean { + if (!isRecord(value)) return false + return ( + typeof value.id === 'string' && + typeof value.name === 'string' && + typeof value.digest === 'string' && + Array.isArray(value.tags) && + value.tags.every((item) => typeof item === 'string') && + isRecord(value.source) && + typeof value.source.reference === 'string' && + typeof value.source.label === 'string' + ) +} + +function isProfileSelectionReason( + value: unknown, +): value is 'command-line' | 'branch' | 'workspace' | 'user' | 'first-run' { + return ( + value === 'command-line' || + value === 'branch' || + value === 'workspace' || + value === 'user' || + value === 'first-run' + ) +} + +function jsonObject(value: Record): NonNullable { + return value as Record +} + +export function safeValidationReport(report: ProfileValidationReport): SafeProfileValidationReport { + const { profile: _profile, provider, ...safe } = report + return { + ...safe, + ...(provider === undefined + ? {} + : { + provider: { + provider: provider.provider, + ...(provider.capabilities === undefined ? {} : { capabilities: provider.capabilities }), + result: { ok: provider.result.ok, issues: provider.result.issues }, + }, + }), + } +} + +export function invalidProfile(report: ProfileValidationReport): AppError { + const detail = report.issues.map((issue) => `${issue.code}: ${issue.message}`).join('; ') + return new AppError('PROFILE_INVALID', detail || 'Profile validation failed') +} + +export function pendingProfileOperation( + id: ReturnType, + digest: Digest, + at: string, + intent?: ProfileSaveIntent, +): OperationRecord & { readonly kind: 'profile-save' } { + return { + id, + kind: 'profile-save', + requestDigest: digest, + status: 'pending', + ...(intent === undefined ? {} : { result: jsonObject({ intent }) }), + createdAt: at, + updatedAt: at, + } +} + +export function acknowledgedProfileOperation(input: { + readonly id: ReturnType + readonly digest: Digest + readonly at: string + readonly result?: OperationRecord['result'] +}): OperationRecord & { readonly kind: 'profile-save' } { + return acknowledgedOperation({ ...input, kind: 'profile-save' }) as OperationRecord & { + readonly kind: 'profile-save' + } +} + +export function reconciliationRequired(operationId: string): AppError { + return new AppError( + 'OPERATION_REQUIRES_RECONCILIATION', + `Operation ${operationId} needs reconciliation before it can be retried`, + ) +} + +export function profileErrorCode(error: unknown): string { + return typeof error === 'object' && + error !== null && + 'code' in error && + typeof error.code === 'string' + ? error.code + : 'PROFILE_SAVE_FAILED' +} + +export function profileErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : 'Profile save failed' +} diff --git a/src/app/profile-actions.ts b/src/app/profile-actions.ts new file mode 100644 index 0000000..88ce106 --- /dev/null +++ b/src/app/profile-actions.ts @@ -0,0 +1,389 @@ +import type { AgentProfileSecurityPolicy } from '@tangle-network/agent-interface' +import { parseProfileId } from '../domain/ids.js' +import { redactSensitiveText } from '../domain/redaction.js' +import type { ActionHost } from './action-host.js' +import { operationReplay, parseOperation, requestDigest } from './conversation-support.js' +import { AppError } from './errors.js' +import { + acknowledgedProfileOperation, + findProfileRecord, + fromDomainProfile, + invalidProfile, + newProfileFileTarget, + pendingProfileOperation, + profileRevision, + profileSaveFromOperation, + profileSaveIntentFromOperation, + profileSelectionFromOperation, + profileSelectionOperationResult, + reconciliationRequired, + type SafeProfileValidationReport, + safeProfileSummary, + safeValidationReport, + toDomainProfile, +} from './profile-action-support.js' +import { + acknowledgeProfileSave, + createProfileSaveIntent, + handleProfileSaveFailure, + type ProfileSaveRecoveryRuntime, + recoverPendingProfileSave, +} from './profile-save-recovery.js' +import { + createProfileRecord, + discoverProfiles, + type ProfileDiscoveryInput, + type ProfileDiscoveryIssue, + type ProfileProvider, + type ProfileSummary, + type ProfileValidationOptions, + profileSourceKey, + type ProfileRecord as SourceProfileRecord, + resolveEffectiveProfile, + saveProfileFile, + searchProfiles, + selectBaseProfile, + validateProfile, +} from './profiles.js' + +export interface ProfileActionOptions { + readonly host: ActionHost + readonly profiles?: readonly SourceProfileRecord[] + readonly discovery?: ProfileDiscoveryInput + readonly provider?: ProfileProvider + readonly securityPolicy?: AgentProfileSecurityPolicy + readonly acceptedProviderWarningCodes?: readonly string[] + readonly now?: () => string + readonly onSavePhase?: ( + phase: + | 'pending-committed' + | 'temporary-written' + | 'temporary-fsynced' + | 'renamed' + | 'directory-fsynced' + | 'acknowledgment', + ) => void +} + +export type { SafeProfileValidationReport } from './profile-action-support.js' + +export interface ProfileListResult { + readonly profiles: readonly ProfileSummary[] + readonly issues: readonly ProfileDiscoveryIssue[] +} + +export interface ProfileValidationResult { + readonly ref: string + readonly report: SafeProfileValidationReport + readonly effective: { + readonly authoredProfile: { readonly name?: string } + readonly runner?: string + readonly model?: string + readonly compatibility: ReturnType['compatibility'] + } +} + +export interface ProfileSelectionResult { + readonly profile: ProfileSummary + readonly reason: 'command-line' | 'branch' | 'workspace' | 'user' | 'first-run' + readonly revision: number + readonly replayed: boolean +} + +export interface ProfileSaveResult { + readonly profile: ProfileSummary + readonly path: string + readonly bytesDigest: string + readonly revision: number + readonly replayed: boolean +} + +interface CatalogResult { + readonly records: readonly SourceProfileRecord[] + readonly issues: readonly ProfileDiscoveryIssue[] +} + +export class ProfileActionService { + readonly #options: ProfileActionOptions + + constructor(options: ProfileActionOptions) { + this.#options = options + } + + async list(query = ''): Promise { + const catalog = await this.#catalog() + return { + profiles: searchProfiles(catalog.records, query).map((record) => this.#summary(record)), + issues: catalog.issues, + } + } + + async validate(ref: string): Promise { + const catalog = await this.#catalog() + const record = this.#requireRecord(catalog.records, ref) + const report = await validateProfile(record.profile, this.#validationOptions()) + const availableModelIds = [ + ...new Set( + catalog.records.flatMap((candidate) => + [candidate.profile.model?.default, candidate.profile.model?.small].filter( + (model): model is string => model !== undefined, + ), + ), + ), + ] + const effective = resolveEffectiveProfile({ profile: record, availableModelIds }) + return { + ref: redactSensitiveText(ref, 2048), + report: safeValidationReport(report), + effective: { + authoredProfile: { + ...(record.profile.name === undefined + ? {} + : { name: redactSensitiveText(record.profile.name, 512) }), + }, + ...(effective.runner === undefined ? {} : { runner: effective.runner }), + ...(effective.model === undefined ? {} : { model: effective.model }), + compatibility: effective.compatibility, + }, + } + } + + async select(input: { + readonly operationId: string + readonly ref: string + readonly expectedRevision?: number + }): Promise { + const operationId = parseOperation(input.operationId, 'select_profile') + const digest = requestDigest('select_profile', { + ref: input.ref, + expectedRevision: input.expectedRevision ?? null, + }) + const replay = operationReplay(this.#options.host.state(), operationId, 'profile-save', digest) + if (replay !== undefined) { + if (replay.status !== 'acknowledged') throw reconciliationRequired(operationId) + const result = profileSelectionFromOperation(replay) + if (result === undefined) throw reconciliationRequired(operationId) + this.#options.host.runtime?.syncFromState(this.#options.host.state()) + return { + profile: result.profile, + reason: result.reason, + revision: this.#options.host.state().revision, + replayed: true, + } + } + + const record = await this.#find(input.ref) + const validation = await validateProfile(record.profile, this.#validationOptions()) + if (!validation.ok) throw invalidProfile(validation) + const selection = selectBaseProfile({ commandLine: record }) + if (selection === undefined) throw new AppError('PROFILE_NOT_FOUND', 'Profile not found') + const next = await this.#options.host.configuration.selectProfile({ + profile: toDomainProfile(record, this.#now()), + operation: acknowledgedProfileOperation({ + id: operationId, + digest, + at: this.#now(), + result: { + profileId: record.id, + ...profileSelectionOperationResult(this.#summary(record), selection.reason), + }, + }), + ...(input.expectedRevision === undefined ? {} : { expectedRevision: input.expectedRevision }), + }) + this.#options.host.runtime?.setProfile(record.profile) + return { + profile: this.#summary(record), + reason: selection.reason, + revision: next.revision, + replayed: false, + } + } + + async save(input: { + readonly operationId: string + readonly ref: string + readonly profile: unknown + readonly expectedRevision?: number + }): Promise { + const operationId = parseOperation(input.operationId, 'save_profile') + const digest = requestDigest('save_profile', { + ref: input.ref, + profile: input.profile, + expectedRevision: input.expectedRevision ?? null, + }) + const replay = operationReplay(this.#options.host.state(), operationId, 'profile-save', digest) + if (replay !== undefined) { + if (replay.status === 'pending') { + const intent = profileSaveIntentFromOperation(replay) + if (intent === undefined) throw reconciliationRequired(operationId) + return recoverPendingProfileSave( + this.#recoveryRuntime(), + input, + operationId, + digest, + intent, + ) + } + if (replay.status !== 'acknowledged') throw reconciliationRequired(operationId) + const saved = profileSaveFromOperation(replay) + if (saved === undefined) throw reconciliationRequired(operationId) + this.#options.host.runtime?.syncFromState(this.#options.host.state()) + return { + profile: saved.profile, + path: saved.path, + bytesDigest: saved.bytesDigest, + revision: this.#options.host.state().revision, + replayed: true, + } + } + + const report = await validateProfile(input.profile, this.#validationOptions()) + if (!report.ok || report.profile === undefined || report.digest === undefined) + throw invalidProfile(report) + const catalog = await this.#catalog() + const existing = findProfileRecord(catalog.records, input.ref) + const target = existing === undefined ? { source: newProfileFileTarget(input.ref) } : existing + if (target.source.kind !== 'file' || !target.source.writable) { + throw new AppError('PROFILE_READ_ONLY', 'The selected profile source is not writable') + } + + const expectedBytesDigest = profileRevision(target.source.revision) + const expectedProfileDigest = profileRevision(existing?.digest) + const draftRecord = + existing === undefined + ? createProfileRecord(target.source, report.profile) + : { + ...existing, + profile: report.profile, + digest: report.digest, + displayName: report.profile.name ?? existing.displayName, + } + const selected = this.#options.host.state().selectedProfileId === parseProfileId(draftRecord.id) + const intent = createProfileSaveIntent({ + path: target.source.reference, + profile: report.profile, + summary: this.#summary(draftRecord), + ...(expectedBytesDigest === undefined ? {} : { expectedBytesDigest }), + ...(expectedProfileDigest === undefined ? {} : { expectedProfileDigest }), + expectedMissing: existing === undefined, + sourceLabel: target.source.label, + trusted: target.source.trusted, + profileId: draftRecord.id, + select: selected, + }) + const pending = pendingProfileOperation(operationId, digest, this.#now(), intent) + await this.#options.host.configuration.requestOperation({ + operation: pending, + ...(input.expectedRevision === undefined ? {} : { expectedRevision: input.expectedRevision }), + }) + try { + this.#options.onSavePhase?.('pending-committed') + const saved = saveProfileFile(target.source.reference, report.profile, { + overwrite: true, + ...(expectedBytesDigest === undefined ? {} : { expectedBytesDigest }), + ...(expectedProfileDigest === undefined ? {} : { expectedProfileDigest }), + ...(existing === undefined ? { expectedBytesAbsent: true } : {}), + sourceLabel: target.source.label, + trusted: target.source.trusted, + onPhase: (phase) => this.#options.onSavePhase?.(phase), + }) + const savedRecord = + existing === undefined ? saved.record : { ...saved.record, id: existing.id } + this.#options.onSavePhase?.('acknowledgment') + return acknowledgeProfileSave( + this.#recoveryRuntime(), + operationId, + digest, + intent, + savedRecord, + saved.path, + saved.bytesDigest, + false, + ) + } catch (error) { + await handleProfileSaveFailure(this.#recoveryRuntime(), operationId, digest, intent) + throw error + } + } + + async saveCurrent(input: { + readonly operationId: string + readonly ref: string + readonly expectedRevision?: number + }): Promise { + const profile = this.#options.host.runtime?.profile() ?? this.#options.host.state().profile + return this.save({ ...input, profile }) + } + + async #catalog(): Promise { + const records = new Map() + for (const record of this.#options.host.state().profiles.map(fromDomainProfile)) { + records.set(profileSourceKey(record), record) + } + for (const record of this.#options.profiles ?? []) records.set(profileSourceKey(record), record) + const discovered = + this.#options.discovery === undefined + ? { profiles: [], issues: [] } + : await discoverProfiles(this.#options.discovery) + for (const record of discovered.profiles) records.set(profileSourceKey(record), record) + if (records.size === 0) { + const active = createProfileRecord( + { + kind: 'inline', + reference: 'braid:active', + label: this.#options.host.state().profile.name ?? 'Active profile', + writable: false, + trusted: true, + }, + this.#options.host.state().profile, + ) + records.set(profileSourceKey(active), active) + } + return { records: [...records.values()], issues: discovered.issues } + } + + async #find(ref: string): Promise { + const catalog = await this.#catalog() + return this.#requireRecord(catalog.records, ref) + } + + #requireRecord(records: readonly SourceProfileRecord[], ref: string): SourceProfileRecord { + const record = findProfileRecord(records, ref) + if (record === undefined) { + throw new AppError( + 'PROFILE_NOT_FOUND', + `Profile ${redactSensitiveText(ref, 2048)} was not found`, + ) + } + return record + } + + #summary(record: SourceProfileRecord): ProfileSummary { + return safeProfileSummary(record) + } + + #validationOptions(): ProfileValidationOptions { + return { + ...(this.#options.securityPolicy === undefined + ? {} + : { securityPolicy: this.#options.securityPolicy }), + ...(this.#options.provider === undefined ? {} : { provider: this.#options.provider }), + ...(this.#options.acceptedProviderWarningCodes === undefined + ? {} + : { acceptedProviderWarningCodes: this.#options.acceptedProviderWarningCodes }), + } + } + + #now(): string { + return this.#options.now?.() ?? new Date().toISOString() + } + + #recoveryRuntime(): ProfileSaveRecoveryRuntime { + return { + host: this.#options.host, + now: () => this.#now(), + summary: (record) => this.#summary(record), + ...(this.#options.onSavePhase === undefined ? {} : { onPhase: this.#options.onSavePhase }), + } + } +} diff --git a/src/app/profile-draft.ts b/src/app/profile-draft.ts new file mode 100644 index 0000000..5027865 --- /dev/null +++ b/src/app/profile-draft.ts @@ -0,0 +1,115 @@ +import { + canonicalCandidateJson, + diffAgentProfiles, + snapshotAgentProfile, + type AgentProfile, + type AgentProfileDiff, +} from '@tangle-network/agent-interface' +import { redactSensitiveText } from '../domain/secret-sanitizer.js' +import { validateProfileShape, ProfileValidationError } from './profile-validation.js' +import type { ProfileDraftValidation } from './profile-types.js' + +function cloneCandidate(value: unknown): unknown { + try { + return structuredClone(value) + } catch (error) { + throw new ProfileValidationError([ + { + origin: 'schema', + level: 'error', + code: 'profile-draft-not-cloneable', + message: redactSensitiveText(error instanceof Error ? error.message : String(error), 4096), + }, + ]) + } +} + +/** One canonical profile draft shared by structured and raw editing surfaces. */ +export class ProfileDraft { + readonly #base: Readonly + #candidate: unknown + #validation: ProfileDraftValidation + + constructor(profile: AgentProfile) { + const initial = validateProfileShape(profile) + if (!initial.ok || initial.profile === undefined) { + throw new ProfileValidationError(initial.issues) + } + this.#base = initial.profile + this.#candidate = initial.profile + this.#validation = initial + } + + get candidate(): unknown { + return cloneCandidate(this.#candidate) + } + + get validation(): ProfileDraftValidation { + return this.#validation + } + + get valid(): boolean { + return this.#validation.ok && this.#validation.profile !== undefined + } + + get profile(): Readonly { + if (!this.valid || this.#validation.profile === undefined) { + throw new ProfileValidationError(this.#validation.issues) + } + return this.#validation.profile + } + + get digest(): ProfileDraftValidation['digest'] { + return this.#validation.digest + } + + replace(value: unknown): ProfileDraftValidation { + this.#candidate = cloneCandidate(value) + this.#validation = validateProfileShape(this.#candidate) + return this.#validation + } + + replaceRaw(json: string): ProfileDraftValidation { + try { + return this.replace(JSON.parse(json)) + } catch (error) { + this.#candidate = json + this.#validation = { + ok: false, + issues: [ + { + origin: 'schema', + level: 'error', + code: 'profile-json-invalid', + message: redactSensitiveText( + error instanceof Error ? error.message : String(error), + 4096, + ), + }, + ], + } + return this.#validation + } + } + + replaceProfile(profile: AgentProfile): ProfileDraftValidation { + return this.replace(snapshotAgentProfile(profile)) + } + + rawJson(pretty = false): string { + if (this.valid) { + const json = canonicalCandidateJson(this.profile) + if (!pretty) return json + return `${JSON.stringify(this.profile, null, 2)}\n` + } + if (typeof this.#candidate === 'string') return this.#candidate + return pretty + ? `${JSON.stringify(this.#candidate, null, 2)}\n` + : (JSON.stringify(this.#candidate) ?? '') + } + + diff(): readonly AgentProfileDiff[] { + if (!this.valid) return [] + return diffAgentProfiles(this.#base, this.profile) + } +} diff --git a/src/app/profile-persistence.ts b/src/app/profile-persistence.ts new file mode 100644 index 0000000..90e3ed4 --- /dev/null +++ b/src/app/profile-persistence.ts @@ -0,0 +1,439 @@ +import { extname, resolve } from 'node:path' +import { + type AgentProfile, + canonicalAgentProfileDigest, + canonicalCandidateDigest, + canonicalCandidateJson, + sha256Bytes, + snapshotAgentProfile, +} from '@tangle-network/agent-interface' +import { + readNoFollow, + replacePrivateFile, + SafeFileError, +} from '../adapters/persistence/safe-file.js' +import { redactStructuredValue } from '../domain/bounded-structured.js' +import { redactSensitiveText } from '../domain/secret-sanitizer.js' +import { + type ExportProfileFileOptions, + type ImportedProfileDocument, + PROFILE_EXPORT_FORMAT, + PROFILE_EXPORT_SCHEMA_VERSION, + type ProfileExportDocument, + type ProfileExportOptions, + type ProfileFileState, + type ProfileImportOptions, + type SaveProfileFileOptions, +} from './profile-types.js' +import { + AGENT_INTERFACE_PACKAGE_NAME, + AGENT_INTERFACE_PACKAGE_VERSION, + ProfileValidationError, + validateProfileShape, +} from './profile-validation.js' + +export const MAX_PROFILE_FILE_BYTES = 16 * 1024 * 1024 + +function isPlainRecord(value: unknown): value is Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return false + const prototype = Object.getPrototypeOf(value) + return prototype === Object.prototype || prototype === null +} + +function assertProfilePath(path: string): string { + const target = resolve(path) + const extension = extname(target).toLowerCase() + if (['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs'].includes(extension)) { + throw new ProfilePersistenceError( + 'PROFILE_CODE_UNSUPPORTED', + 'Executable profile modules are not loaded by Braid', + ) + } + return target +} + +function jsonError(error: unknown): ProfilePersistenceError { + return new ProfilePersistenceError( + 'PROFILE_JSON_INVALID', + error instanceof Error ? error.message : String(error), + ) +} + +function digestText(value: string): string { + return sha256Bytes(Buffer.from(value, 'utf8')) +} + +function digestMatches(value: unknown): value is `sha256:${string}` { + return typeof value === 'string' && /^sha256:[0-9a-f]{64}$/u.test(value) +} + +function redactExportValue(value: unknown, key?: string): unknown { + if (key === 'attestationNonce') return '[redacted challenge]' + if (key === 'metadata' || key === 'extensions') { + return redactStructuredValue(value, undefined, { maxBytes: MAX_PROFILE_FILE_BYTES }) + } + if (typeof value === 'string') return redactSensitiveText(value, MAX_PROFILE_FILE_BYTES) + if (value === null || typeof value === 'boolean' || typeof value === 'number') return value + if (Array.isArray(value)) return value.map((item) => redactExportValue(item)) + if (!isPlainRecord(value)) return '[unavailable]' + return Object.fromEntries( + Object.entries(value).map(([childKey, child]) => [ + childKey, + redactExportValue(child, childKey), + ]), + ) +} + +function profileForExport( + profile: Readonly, + redact: boolean, +): Readonly { + if (!redact) return snapshotAgentProfile(profile) + const candidate = redactExportValue(profile) + const parsed = validateProfileShape(candidate) + if (!parsed.ok || parsed.profile === undefined) { + throw new ProfileValidationError(parsed.issues) + } + return parsed.profile +} + +export function exportProfileDocument( + profile: AgentProfile, + options: ProfileExportOptions = {}, +): ProfileExportDocument { + const original = validateProfileShape(profile) + if (!original.ok || original.profile === undefined) { + throw new ProfileValidationError(original.issues) + } + const redacted = options.redact ?? true + const exported = profileForExport(original.profile, redacted) + const document: ProfileExportDocument = { + format: PROFILE_EXPORT_FORMAT, + schemaVersion: PROFILE_EXPORT_SCHEMA_VERSION, + agentInterfacePackage: { + name: AGENT_INTERFACE_PACKAGE_NAME, + version: AGENT_INTERFACE_PACKAGE_VERSION, + }, + sourceProfileDigest: canonicalAgentProfileDigest(original.profile), + profileDigest: canonicalAgentProfileDigest(exported), + redacted, + profile: exported, + } + return Object.freeze(document) +} + +export function exportProfileJson( + profile: AgentProfile, + options: ProfileExportOptions = {}, +): string { + const document = exportProfileDocument(profile, options) + if (options.pretty === true) return `${JSON.stringify(document, null, 2)}\n` + return canonicalCandidateJson(document) +} + +function parseExportDocument( + value: Record, + options: ProfileImportOptions, +): ImportedProfileDocument { + if (value.format !== PROFILE_EXPORT_FORMAT) { + throw new ProfilePersistenceError( + 'PROFILE_EXPORT_FORMAT_UNSUPPORTED', + 'Unknown profile export format', + ) + } + if (value.schemaVersion !== PROFILE_EXPORT_SCHEMA_VERSION) { + throw new ProfilePersistenceError( + 'PROFILE_SCHEMA_VERSION_UNSUPPORTED', + `Profile export schema ${String(value.schemaVersion)} cannot be preserved by this Braid version`, + ) + } + const redacted = value.redacted === true + if (value.redacted !== true && value.redacted !== false) { + throw new ProfilePersistenceError( + 'PROFILE_EXPORT_INVALID', + 'Profile export redaction flag is invalid', + ) + } + if (redacted && options.allowRedacted !== true) { + throw new ProfilePersistenceError( + 'PROFILE_EXPORT_REDACTED', + 'A redacted profile export cannot be imported without explicit acknowledgement', + ) + } + if (!isPlainRecord(value.agentInterfacePackage)) { + throw new ProfilePersistenceError( + 'PROFILE_EXPORT_INVALID', + 'Profile package metadata is missing', + ) + } + if ( + value.agentInterfacePackage.name !== AGENT_INTERFACE_PACKAGE_NAME || + typeof value.agentInterfacePackage.version !== 'string' + ) { + throw new ProfilePersistenceError( + 'PROFILE_EXPORT_INVALID', + 'Profile package metadata is invalid', + ) + } + if (!digestMatches(value.sourceProfileDigest) || !digestMatches(value.profileDigest)) { + throw new ProfilePersistenceError( + 'PROFILE_EXPORT_INVALID', + 'Profile export digests are invalid', + ) + } + const parsed = validateProfileShape(value.profile) + if (!parsed.ok || parsed.profile === undefined) { + throw new ProfileValidationError(parsed.issues) + } + const digest = canonicalAgentProfileDigest(parsed.profile) + if (digest !== value.profileDigest) { + throw new ProfilePersistenceError( + 'PROFILE_EXPORT_DIGEST_MISMATCH', + 'Profile export digest does not match its bytes', + ) + } + return { + profile: parsed.profile, + digest, + redacted, + sourceProfileDigest: value.sourceProfileDigest, + packageVersion: value.agentInterfacePackage.version, + } +} + +export function importProfileValue( + value: unknown, + options: ProfileImportOptions = {}, +): ImportedProfileDocument { + if (isPlainRecord(value) && 'format' in value) return parseExportDocument(value, options) + const parsed = validateProfileShape(value) + if (!parsed.ok || parsed.profile === undefined || parsed.digest === undefined) { + throw new ProfileValidationError(parsed.issues) + } + return { + profile: parsed.profile, + digest: parsed.digest, + redacted: false, + packageVersion: AGENT_INTERFACE_PACKAGE_VERSION, + } +} + +export function importProfileJson( + text: string, + options: ProfileImportOptions = {}, +): ImportedProfileDocument { + if (Buffer.byteLength(text, 'utf8') > MAX_PROFILE_FILE_BYTES) { + throw new ProfilePersistenceError('PROFILE_TOO_LARGE', 'Profile input exceeds the size limit') + } + let value: unknown + try { + value = JSON.parse(text) + } catch (error) { + throw jsonError(error) + } + return importProfileValue(value, options) +} + +export function readProfileFile( + path: string, + options: ProfileImportOptions = {}, +): { readonly imported: ImportedProfileDocument; readonly bytesDigest: `sha256:${string}` } { + const target = assertProfilePath(path) + let bytes: Buffer | undefined + try { + bytes = readNoFollow(target, MAX_PROFILE_FILE_BYTES) + } catch (error) { + throw normalizeProfileFileError(error, target) + } + if (bytes === undefined) { + throw new ProfilePersistenceError('PROFILE_NOT_FOUND', `Profile file does not exist: ${target}`) + } + const text = bytes.toString('utf8') + return { imported: importProfileJson(text, options), bytesDigest: sha256Bytes(bytes) } +} + +function normalizeProfileFileError(error: unknown, target: string): unknown { + if (error instanceof SafeFileError && error.code === 'SAFE_FILE_NOT_REGULAR') { + return new ProfilePersistenceError( + 'PROFILE_FILE_NOT_REGULAR', + `Profile path is not a regular file: ${target}`, + ) + } + return error +} + +function writeAtomic( + path: string, + bytes: Buffer, + options: { + readonly expectedBytesDigest?: `sha256:${string}` + readonly expectedProfileDigest?: `sha256:${string}` + readonly expectedBytesAbsent?: boolean + readonly overwrite: boolean + readonly verify: (bytes: Buffer) => void + readonly onPhase?: SaveProfileFileOptions['onPhase'] + }, +): void { + const target = assertProfilePath(path) + try { + replacePrivateFile(target, bytes, { + overwrite: options.overwrite, + maxExistingBytes: MAX_PROFILE_FILE_BYTES, + expected: (current) => { + if (options.expectedBytesAbsent && current !== undefined) { + throw new ProfilePersistenceError( + 'PROFILE_SOURCE_CHANGED', + 'Profile source appeared since it was opened', + ) + } + if ( + options.expectedBytesDigest !== undefined && + (current === undefined || sha256Bytes(current) !== options.expectedBytesDigest) + ) { + throw new ProfilePersistenceError( + 'PROFILE_SOURCE_CHANGED', + 'Profile source changed since it was opened', + ) + } + if (options.expectedProfileDigest !== undefined) { + let imported: ImportedProfileDocument | undefined + try { + imported = + current === undefined ? undefined : importProfileJson(current.toString('utf8')) + } catch { + imported = undefined + } + if (imported?.digest !== options.expectedProfileDigest) { + throw new ProfilePersistenceError( + 'PROFILE_SOURCE_CHANGED', + 'Profile profile changed since it was opened', + ) + } + } + }, + verify: options.verify, + ...(options.onPhase === undefined ? {} : { onPhase: options.onPhase }), + }) + } catch (error) { + const normalized = normalizeProfileFileError(error, target) + if (!options.overwrite && (normalized as NodeJS.ErrnoException).code === 'EEXIST') { + throw new ProfilePersistenceError('PROFILE_EXISTS', `Profile file already exists: ${target}`) + } + throw normalized + } +} + +export function saveProfileFile( + path: string, + profile: AgentProfile, + options: SaveProfileFileOptions = {}, +): ProfileFileState { + const validation = validateProfileShape(profile) + if (!validation.ok || validation.profile === undefined || validation.digest === undefined) { + throw new ProfileValidationError(validation.issues) + } + const bytes = Buffer.from(canonicalCandidateJson(validation.profile), 'utf8') + writeAtomic(path, bytes, { + ...(options.expectedBytesDigest === undefined + ? {} + : { expectedBytesDigest: options.expectedBytesDigest }), + ...(options.expectedProfileDigest === undefined + ? {} + : { expectedProfileDigest: options.expectedProfileDigest }), + ...(options.expectedBytesAbsent === undefined + ? {} + : { expectedBytesAbsent: options.expectedBytesAbsent }), + overwrite: options.overwrite ?? true, + verify: (written) => { + const imported = importProfileJson(written.toString('utf8')) + if (imported.redacted || imported.digest !== validation.digest) { + throw new ProfilePersistenceError( + 'PROFILE_WRITE_VERIFY_FAILED', + 'Saved profile does not match the requested profile', + ) + } + }, + ...(options.onPhase === undefined ? {} : { onPhase: options.onPhase }), + }) + const target = resolve(path) + const read = readProfileFile(target) + const source = Object.freeze({ + kind: 'file' as const, + reference: target, + label: options.sourceLabel ?? target, + revision: read.bytesDigest, + writable: true, + trusted: options.trusted ?? false, + }) + const identity = canonicalCandidateDigest({ source, profile: read.imported.digest }) + return { + path: target, + bytesDigest: read.bytesDigest, + record: { + id: `profile-${identity.slice('sha256:'.length)}`, + displayName: read.imported.profile.name ?? 'Unnamed profile', + source, + profile: read.imported.profile, + digest: read.imported.digest, + agentInterfacePackageVersion: read.imported.packageVersion, + }, + } +} + +export function exportProfileFile( + path: string, + profile: AgentProfile, + options: ExportProfileFileOptions = {}, +): { + readonly path: string + readonly bytesDigest: `sha256:${string}` + readonly document: ProfileExportDocument +} { + const document = exportProfileDocument(profile, options) + const text = + options.pretty === true + ? `${JSON.stringify(document, null, 2)}\n` + : canonicalCandidateJson(document) + const bytes = Buffer.from(text, 'utf8') + writeAtomic(path, bytes, { + ...(options.expectedBytesDigest === undefined + ? {} + : { expectedBytesDigest: options.expectedBytesDigest }), + ...(options.expectedProfileDigest === undefined + ? {} + : { expectedProfileDigest: options.expectedProfileDigest }), + overwrite: options.overwrite ?? false, + verify: (written) => { + const imported = importProfileJson(written.toString('utf8'), { allowRedacted: true }) + if (imported.digest !== document.profileDigest) { + throw new ProfilePersistenceError( + 'PROFILE_WRITE_VERIFY_FAILED', + 'Exported profile digest does not match its bytes', + ) + } + }, + }) + const target = resolve(path) + const written = readNoFollow(target, MAX_PROFILE_FILE_BYTES) + if (written === undefined) { + throw new ProfilePersistenceError( + 'PROFILE_WRITE_VERIFY_FAILED', + 'Exported profile file disappeared', + ) + } + return { + path: target, + bytesDigest: digestText(written.toString('utf8')) as `sha256:${string}`, + document, + } +} + +export class ProfilePersistenceError extends Error { + readonly code: string + + constructor(code: string, message: string) { + super(message) + this.name = 'ProfilePersistenceError' + this.code = code + } +} diff --git a/src/app/profile-save-recovery.ts b/src/app/profile-save-recovery.ts new file mode 100644 index 0000000..2ad6da2 --- /dev/null +++ b/src/app/profile-save-recovery.ts @@ -0,0 +1,302 @@ +import { resolve } from 'node:path' +import { + type AgentProfile, + canonicalCandidateJson, + sha256Bytes, +} from '@tangle-network/agent-interface' +import { readNoFollow } from '../adapters/persistence/safe-file.js' +import type { Digest } from '../domain/ids.js' +import { redactSensitiveText } from '../domain/redaction.js' +import type { ActionHost } from './action-host.js' +import type { parseOperation } from './conversation-support.js' +import { AppError } from './errors.js' +import { + acknowledgedProfileOperation, + type ProfileSaveIntent, + profileSaveOperationResult, + toDomainProfile, +} from './profile-action-support.js' +import { + importProfileJson, + MAX_PROFILE_FILE_BYTES, + ProfilePersistenceError, + saveProfileFile, +} from './profile-persistence.js' +import { validateProfileShape } from './profile-validation.js' +import { + createProfileRecord, + type ProfileRecord, + type ProfileSourceDescriptor, + type ProfileSummary, +} from './profiles.js' + +export interface ProfileSaveRecoveryResult { + readonly profile: ProfileSummary + readonly path: string + readonly bytesDigest: string + readonly revision: number + readonly replayed: boolean +} + +export interface ProfileSaveRecoveryRuntime { + readonly host: ActionHost + readonly now: () => string + readonly summary: (record: ProfileRecord) => ProfileSummary + readonly onPhase?: ( + phase: + | 'temporary-written' + | 'temporary-fsynced' + | 'renamed' + | 'directory-fsynced' + | 'acknowledgment', + ) => void +} + +export interface ProfileSaveIntentInput { + readonly path: string + readonly profile: Readonly + readonly summary: ProfileSummary + readonly expectedBytesDigest?: string + readonly expectedProfileDigest?: string + readonly expectedMissing: boolean + readonly sourceLabel: string + readonly trusted: boolean + readonly profileId?: string + readonly select: boolean +} + +export type ProfileSaveObservation = + | { readonly status: 'missing' } + | { + readonly status: 'present' + readonly bytesDigest: string + readonly profileDigest: string + readonly profile: Readonly + } + | { readonly status: 'conflict'; readonly detail: string } + +export function createProfileSaveIntent(input: ProfileSaveIntentInput): ProfileSaveIntent { + const validation = validateProfileShape(input.profile) + if (!validation.ok || validation.profile === undefined || validation.digest === undefined) { + throw new ProfilePersistenceError('PROFILE_INVALID', 'Profile save intent is invalid') + } + const bytes = Buffer.from(canonicalCandidateJson(validation.profile), 'utf8') + return { + kind: 'profile-save-intent', + targetPath: resolve(input.path), + ...(input.expectedBytesDigest === undefined + ? {} + : { expectedBytesDigest: input.expectedBytesDigest }), + ...(input.expectedProfileDigest === undefined + ? {} + : { expectedProfileDigest: input.expectedProfileDigest }), + expectedMissing: input.expectedMissing, + intendedBytesDigest: sha256Bytes(bytes), + intendedProfileDigest: validation.digest, + summary: input.summary, + sourceLabel: redactSensitiveText(input.sourceLabel, 512), + trusted: input.trusted, + ...(input.profileId === undefined ? {} : { profileId: input.profileId }), + select: input.select, + } +} + +export function observeProfileSaveTarget(intent: ProfileSaveIntent): ProfileSaveObservation { + let bytes: Buffer | undefined + try { + bytes = readNoFollow(intent.targetPath, MAX_PROFILE_FILE_BYTES) + } catch (error) { + return { + status: 'conflict', + detail: error instanceof Error ? error.message : 'Profile target could not be inspected', + } + } + if (bytes === undefined) { + return intent.expectedMissing + ? { status: 'missing' } + : { status: 'conflict', detail: 'Profile target disappeared before recovery' } + } + const bytesDigest = sha256Bytes(bytes) + let imported: ReturnType + try { + imported = importProfileJson(bytes.toString('utf8')) + } catch { + return { status: 'conflict', detail: 'Profile target contains invalid profile data' } + } + return { + status: 'present', + bytesDigest, + profileDigest: imported.digest, + profile: imported.profile, + } +} + +export function classifyProfileSaveObservation( + intent: ProfileSaveIntent, + observation: ProfileSaveObservation, +): 'intended' | 'old' | 'conflict' { + if (observation.status === 'conflict') return 'conflict' + if (observation.status === 'missing') return intent.expectedMissing ? 'old' : 'conflict' + if ( + observation.bytesDigest === intent.intendedBytesDigest && + observation.profileDigest === intent.intendedProfileDigest + ) + return 'intended' + if ( + !intent.expectedMissing && + observation.bytesDigest === intent.expectedBytesDigest && + (intent.expectedProfileDigest === undefined || + observation.profileDigest === intent.expectedProfileDigest) + ) + return 'old' + return 'conflict' +} + +export function profileRecordFromSaveObservation( + intent: ProfileSaveIntent, + observation: Extract, +): ProfileRecord { + const source: ProfileSourceDescriptor = { + kind: 'file', + reference: intent.targetPath, + label: intent.sourceLabel, + revision: observation.bytesDigest, + writable: true, + trusted: intent.trusted, + } + const record = createProfileRecord(source, observation.profile) + return intent.profileId === undefined ? record : { ...record, id: intent.profileId } +} + +export async function recoverPendingProfileSave( + runtime: ProfileSaveRecoveryRuntime, + input: { readonly profile: unknown }, + operationId: ReturnType, + digest: Digest, + intent: ProfileSaveIntent, +): Promise { + const validation = validateProfileShape(input.profile) + if ( + !validation.ok || + validation.profile === undefined || + validation.digest !== intent.intendedProfileDigest + ) + throw new AppError('OPERATION_ID_CONFLICT', 'The retry profile does not match the saved intent') + let observation = observeProfileSaveTarget(intent) + const classification = classifyProfileSaveObservation(intent, observation) + if (classification === 'conflict') { + throw new AppError('PROFILE_SOURCE_CHANGED', observationDetail(observation)) + } + if (classification === 'old') { + saveProfileFileForRecovery(runtime, intent, validation.profile) + observation = observeProfileSaveTarget(intent) + if (observation.status !== 'present') + throw new AppError('PROFILE_SOURCE_CHANGED', observationDetail(observation)) + } + if (observation.status !== 'present') + throw new AppError('PROFILE_SOURCE_CHANGED', observationDetail(observation)) + const record = profileRecordFromSaveObservation(intent, observation) + return acknowledgeProfileSave( + runtime, + operationId, + digest, + intent, + record, + intent.targetPath, + observation.bytesDigest, + true, + ) +} + +function saveProfileFileForRecovery( + runtime: ProfileSaveRecoveryRuntime, + intent: ProfileSaveIntent, + profile: Readonly, +): void { + saveProfileFile(intent.targetPath, profile, { + overwrite: true, + ...(intent.expectedBytesDigest === undefined + ? {} + : { expectedBytesDigest: intent.expectedBytesDigest as `sha256:${string}` }), + ...(intent.expectedProfileDigest === undefined + ? {} + : { expectedProfileDigest: intent.expectedProfileDigest as `sha256:${string}` }), + ...(intent.expectedMissing ? { expectedBytesAbsent: true } : {}), + sourceLabel: intent.sourceLabel, + trusted: intent.trusted, + ...(runtime.onPhase === undefined ? {} : { onPhase: runtime.onPhase }), + }) +} + +export async function acknowledgeProfileSave( + runtime: ProfileSaveRecoveryRuntime, + operationId: ReturnType, + digest: Digest, + intent: ProfileSaveIntent, + record: ProfileRecord, + path: string, + bytesDigest: string, + replayed: boolean, +): Promise { + const next = await runtime.host.configuration.saveProfile({ + profile: toDomainProfile( + record, + runtime.now(), + runtime.host.state().profiles.find((candidate) => candidate.id === record.id), + ), + operation: acknowledgedProfileOperation({ + id: operationId, + digest, + at: runtime.now(), + result: { + profileId: record.id, + ...profileSaveOperationResult(runtime.summary(record), path, bytesDigest, intent), + }, + }), + select: intent.select, + }) + if (intent.select) runtime.host.runtime?.setProfile(record.profile) + return { + profile: runtime.summary(record), + path, + bytesDigest, + revision: next.revision, + replayed, + } +} + +export async function handleProfileSaveFailure( + runtime: ProfileSaveRecoveryRuntime, + operationId: ReturnType, + digest: Digest, + intent: ProfileSaveIntent, +): Promise { + const observation = observeProfileSaveTarget(intent) + if (classifyProfileSaveObservation(intent, observation) !== 'conflict') return + try { + await runtime.host.configuration.failOperation({ + id: operationId, + kind: 'profile-save', + requestDigest: digest, + status: 'failed', + failureCode: 'PROFILE_SOURCE_CHANGED', + failureMessage: redactSensitiveText(observationDetail(observation), 2048), + result: profileSaveOperationResult( + intent.summary, + intent.targetPath, + intent.intendedBytesDigest, + intent, + ), + createdAt: runtime.now(), + updatedAt: runtime.now(), + }) + } catch { + // Leave the pending intent when the conflict record is not durable. + } +} + +function observationDetail(observation: ProfileSaveObservation): string { + return observation.status === 'conflict' + ? observation.detail + : 'Profile save recovery found an unexpected target state' +} diff --git a/src/app/profile-selection.ts b/src/app/profile-selection.ts new file mode 100644 index 0000000..b1f212e --- /dev/null +++ b/src/app/profile-selection.ts @@ -0,0 +1,152 @@ +import { + harnessHonorsEffort, + harnessHonorsModel, + harnessHonorsSelectors, + harnessReasoningEfforts, + harnessSupportsModel, + mergeAgentProfiles, + preferredHarnessForModel, + reasoningEffortsFor, + snapHarnessToModel, + snapModelToHarness, + snapshotAgentProfile, + type AgentProfile, +} from '@tangle-network/agent-interface' +import type { + EffectiveProfileInput, + EffectiveProfileResult, + ProfileCompatibility, + ProfileRunOverrides, + ProfileSelectionCandidates, + ProfileSelectionReason, + ProfileSelectionResult, + ProfileRecord, +} from './profile-types.js' + +export function selectBaseProfile( + candidates: ProfileSelectionCandidates, +): ProfileSelectionResult | undefined { + const choices: readonly [ProfileSelectionReason, ProfileRecord | undefined][] = [ + ['command-line', candidates.commandLine], + ['branch', candidates.branch], + ['workspace', candidates.workspaceTrusted === true ? candidates.workspace : undefined], + ['user', candidates.user], + ['first-run', candidates.firstRun], + ] + for (const [reason, profile] of choices) { + if (profile !== undefined) return { profile, reason } + } + return undefined +} + +function firstDefined(first: T | undefined, second: T | undefined): T | undefined { + return first !== undefined ? first : second +} + +function selectedOverrides(input: EffectiveProfileInput): ProfileRunOverrides { + const branch = input.branchOverrides + const next = input.nextRunOverrides + const selected: { + harness?: ProfileRunOverrides['harness'] + model?: ProfileRunOverrides['model'] + effort?: ProfileRunOverrides['effort'] + mode?: ProfileRunOverrides['mode'] + connectionId?: ProfileRunOverrides['connectionId'] + } = {} + const harness = firstDefined(next?.harness, branch?.harness) + const model = firstDefined(next?.model, branch?.model) + const effort = firstDefined(next?.effort, branch?.effort) + const mode = firstDefined(next?.mode, branch?.mode) + const connectionId = firstDefined(next?.connectionId, branch?.connectionId) + if (harness !== undefined) selected.harness = harness + if (model !== undefined) selected.model = model + if (effort !== undefined) selected.effort = effort + if (mode !== undefined) selected.mode = mode + if (connectionId !== undefined) selected.connectionId = connectionId + return Object.freeze(selected) as ProfileRunOverrides +} + +function applyProfileOverrides( + profile: Readonly, + overrides: ProfileRunOverrides, +): Readonly { + const overlay: AgentProfile = { + ...(overrides.harness === undefined ? {} : { harness: overrides.harness }), + ...(overrides.model === undefined && overrides.effort === undefined + ? {} + : { + model: { + ...(profile.model ?? {}), + ...(overrides.model === undefined ? {} : { default: overrides.model }), + ...(overrides.effort === undefined ? {} : { reasoningEffort: overrides.effort }), + }, + }), + } + const merged = mergeAgentProfiles(profile, overlay) + if (merged === undefined) + throw new Error('AgentProfile override unexpectedly produced no profile') + return snapshotAgentProfile(merged) +} + +function compatibility( + runner: EffectiveProfileResult['runner'], + model: string | undefined, + input: EffectiveProfileInput, +): ProfileCompatibility { + if (runner === undefined) return {} + const modelSupported = model === undefined ? undefined : harnessSupportsModel(runner, model) + const output: ProfileCompatibility = { + ...(modelSupported === undefined ? {} : { modelSupported }), + modelHonored: harnessHonorsModel(runner), + effortHonored: harnessHonorsEffort(runner), + selectorsHonored: harnessHonorsSelectors(runner), + availableEfforts: [ + ...(model === undefined + ? harnessReasoningEfforts(runner) + : reasoningEffortsFor(runner, input.modelReasoning)), + ], + } + if (modelSupported === false && model !== undefined) { + const suggestedRunner = snapHarnessToModel(runner, model) + const suggestedModel = + input.availableModelIds === undefined + ? undefined + : snapModelToHarness(runner, model, input.availableModelIds) + return { + ...output, + ...(suggestedRunner === runner ? {} : { suggestedRunner }), + ...(suggestedModel === undefined || suggestedModel === model ? {} : { suggestedModel }), + } + } + return output +} + +export function resolveEffectiveProfile(input: EffectiveProfileInput): EffectiveProfileResult { + const profile = input.profile.profile + const overrides = selectedOverrides(input) + const model = overrides.model ?? input.profile.profile.model?.default + const effort = overrides.effort ?? input.profile.profile.model?.reasoningEffort + const runner = + overrides.harness ?? + input.branchOverrides?.harness ?? + profile.harness ?? + (model === undefined ? undefined : (preferredHarnessForModel(model) ?? undefined)) + const mode = overrides.mode ?? input.branchOverrides?.mode + const connectionId = + overrides.connectionId ?? + input.branchOverrides?.connectionId ?? + (input.workspaceTrusted === true ? input.workspaceConnectionId : undefined) ?? + input.userConnectionId + const effectiveProfile = applyProfileOverrides(profile, overrides) + return Object.freeze({ + authoredProfile: profile, + effectiveProfile, + ...(runner === undefined ? {} : { runner }), + ...(model === undefined ? {} : { model }), + ...(effort === undefined ? {} : { effort }), + ...(mode === undefined ? {} : { mode }), + ...(connectionId === undefined ? {} : { connectionId }), + overrides, + compatibility: compatibility(runner, model, input), + }) +} diff --git a/src/app/profile-snapshots.ts b/src/app/profile-snapshots.ts new file mode 100644 index 0000000..190e2f7 --- /dev/null +++ b/src/app/profile-snapshots.ts @@ -0,0 +1,86 @@ +import { + canonicalCandidateDigest, + canonicalAgentProfileDigest, + snapshotAgentProfile, +} from '@tangle-network/agent-interface' +import { redactStructuredValue } from '../domain/bounded-structured.js' +import { exportProfileDocument } from './profile-persistence.js' +import { + AGENT_INTERFACE_PACKAGE_NAME, + AGENT_INTERFACE_PACKAGE_VERSION, +} from './profile-validation.js' +import type { ProfileIssue, ProfileSnapshotInput, ProfileSnapshotReceipt } from './profile-types.js' + +function freezeDeep(value: T, seen = new WeakSet()): T { + if (value === null || typeof value !== 'object' || seen.has(value)) return value + seen.add(value) + if (Array.isArray(value)) { + for (const item of value) freezeDeep(item, seen) + } else { + for (const child of Object.values(value)) freezeDeep(child, seen) + } + return Object.freeze(value) +} + +function safeIssues(issues: readonly ProfileIssue[]): readonly ProfileIssue[] { + return issues.map((item) => Object.freeze({ ...item })) +} + +/** + * Capture the exact source/effective profile decision before execution. + * The stored profile values are the canonical redacted snapshots; their source + * and effective digests remain distinct so a provider cannot hide a change. + */ +export function createProfileSnapshot(input: ProfileSnapshotInput): ProfileSnapshotReceipt { + const authored = snapshotAgentProfile(input.effective.authoredProfile) + const effective = snapshotAgentProfile(input.effective.effectiveProfile) + const redactedAuthored = exportProfileDocument(authored, { redact: true }).profile + const redactedEffective = exportProfileDocument(effective, { redact: true }).profile + const source = Object.freeze({ ...input.source.source }) + const overrides = Object.freeze({ ...input.effective.overrides }) + const capabilities = + input.capabilities === undefined ? undefined : structuredClone(input.capabilities) + const base = { + kind: 'braid-profile-snapshot' as const, + schemaVersion: 1 as const, + agentInterfacePackage: { + name: AGENT_INTERFACE_PACKAGE_NAME, + version: AGENT_INTERFACE_PACKAGE_VERSION, + }, + source, + authoredProfile: redactedAuthored, + effectiveProfile: redactedEffective, + authoredProfileDigest: canonicalAgentProfileDigest(authored), + effectiveProfileDigest: canonicalAgentProfileDigest(redactedEffective), + ...(input.effective.runner === undefined ? {} : { runner: input.effective.runner }), + ...(input.effective.model === undefined ? {} : { model: input.effective.model }), + ...(input.effective.effort === undefined ? {} : { effort: input.effective.effort }), + ...(input.effective.mode === undefined ? {} : { mode: input.effective.mode }), + ...(input.effective.connectionId === undefined + ? {} + : { connectionId: input.effective.connectionId }), + overrides, + validation: { + ok: input.validation.ok, + issues: safeIssues(input.validation.issues), + ...(input.validation.acceptedProviderWarningCodes === undefined + ? {} + : { + acceptedProviderWarningCodes: [...input.validation.acceptedProviderWarningCodes], + }), + }, + ...(capabilities === undefined ? {} : { capabilities }), + ...(input.providerMaterializationReceipt === undefined + ? {} + : { + providerMaterializationReceipt: redactStructuredValue( + input.providerMaterializationReceipt, + ), + }), + } + const receipt = { + ...base, + digest: canonicalCandidateDigest(base), + } as ProfileSnapshotReceipt + return freezeDeep(receipt) +} diff --git a/src/app/profile-sources.ts b/src/app/profile-sources.ts new file mode 100644 index 0000000..9411ff2 --- /dev/null +++ b/src/app/profile-sources.ts @@ -0,0 +1,202 @@ +import { basename, resolve } from 'node:path' +import { + canonicalCandidateDigest, + type AgentProfile, + type AgentProfileRef, +} from '@tangle-network/agent-interface' +import { redactSensitiveText } from '../domain/secret-sanitizer.js' +import { readProfileFile, ProfilePersistenceError } from './profile-persistence.js' +import { + AGENT_INTERFACE_PACKAGE_VERSION, + validateProfileShape, + ProfileValidationError, +} from './profile-validation.js' +import type { + ProfileDiscoveryInput, + ProfileDiscoveryIssue, + ProfileDiscoveryResult, + ProfileIssue, + ProfileRecord, + ProfileSourceDescriptor, + ProfileSourceResolution, + ProfileSourceResolverContext, + ProfileSourceSpec, + ProfileSourceKind, +} from './profile-types.js' + +function sourceLabel(spec: ProfileSourceSpec): string { + if (spec.label !== undefined && spec.label.length > 0) return spec.label + if (spec.kind === 'file') return basename(resolve(spec.path)) + return spec.reference +} + +export function describeProfileSource( + spec: ProfileSourceSpec, + overrides: { + readonly revision?: string + readonly writable?: boolean + } = {}, +): ProfileSourceDescriptor { + const revision = overrides.revision ?? spec.revision + return { + kind: spec.kind, + reference: redactSensitiveText( + spec.kind === 'file' ? resolve(spec.path) : spec.reference, + 2048, + ), + label: redactSensitiveText(sourceLabel(spec), 512), + ...(revision === undefined ? {} : { revision: redactSensitiveText(revision, 512) }), + writable: overrides.writable ?? spec.writable ?? spec.kind === 'file', + trusted: spec.trusted ?? false, + } +} + +export function createProfileRecord( + source: ProfileSourceDescriptor, + profile: AgentProfile, +): ProfileRecord { + const validated = validateProfileShape(profile) + if (!validated.ok || validated.profile === undefined || validated.digest === undefined) { + throw new ProfileValidationError(validated.issues) + } + const descriptor = Object.freeze({ ...source }) + const identity = canonicalCandidateDigest({ source: descriptor, profile: validated.digest }) + return Object.freeze({ + id: `profile-${identity.slice('sha256:'.length)}`, + displayName: validated.profile.name ?? descriptor.label, + source: descriptor, + profile: validated.profile, + digest: validated.digest, + agentInterfacePackageVersion: AGENT_INTERFACE_PACKAGE_VERSION, + }) +} + +export async function resolveProfileSource( + spec: ProfileSourceSpec, + context: ProfileSourceResolverContext = {}, +): Promise { + if (spec.kind === 'inline') { + return createProfileRecord(describeProfileSource(spec), spec.profile) + } + + if (spec.kind === 'file') { + const path = resolve(spec.path) + const read = readProfileFile(path) + if (spec.revision !== undefined && spec.revision !== read.bytesDigest) { + throw new ProfilePersistenceError( + 'PROFILE_SOURCE_REVISION_MISMATCH', + 'The profile file revision does not match the requested immutable reference', + ) + } + return createProfileRecord( + describeProfileSource(spec, { revision: read.bytesDigest }), + read.imported.profile, + ) + } + + const resolved = await spec.resolve(spec.reference, context) + const profile = isProfileResolution(resolved) ? resolved.profile : resolved + const revision = isProfileResolution(resolved) ? resolved.revision : undefined + const writable = isProfileResolution(resolved) ? resolved.writable : undefined + return createProfileRecord( + describeProfileSource(spec, { + ...(revision === undefined ? {} : { revision }), + ...(writable === undefined ? {} : { writable }), + }), + profile, + ) +} + +function isProfileResolution( + value: AgentProfile | ProfileSourceResolution, +): value is ProfileSourceResolution { + return value !== null && typeof value === 'object' && 'profile' in value +} + +/** Resolve an inline AgentProfile or a provider/package catalog identifier. */ +export async function resolveProfileReference( + reference: AgentProfileRef, + source: Omit & { readonly kind: ProfileSourceKind }, + resolver?: ( + reference: string, + context: ProfileSourceResolverContext, + ) => AgentProfile | ProfileSourceResolution | Promise, + context: ProfileSourceResolverContext = {}, +): Promise { + if (typeof reference !== 'string') { + return createProfileRecord(source, reference) + } + if (resolver === undefined) { + throw new ProfilePersistenceError( + 'PROFILE_REFERENCE_UNRESOLVED', + `No source adapter can resolve profile reference ${reference}`, + ) + } + const resolved = await resolver(reference, context) + const profile = isProfileResolution(resolved) ? resolved.profile : resolved + const revision = isProfileResolution(resolved) ? resolved.revision : source.revision + const writable = isProfileResolution(resolved) ? resolved.writable : source.writable + return createProfileRecord( + { + ...source, + reference: redactSensitiveText(reference, 2048), + ...(revision === undefined ? {} : { revision: redactSensitiveText(revision, 512) }), + ...(writable === undefined ? {} : { writable }), + }, + profile, + ) +} + +function sourceIssue(spec: ProfileSourceSpec, error: unknown): ProfileDiscoveryIssue { + let code = 'profile-source-failed' + const message = error instanceof Error ? error.message : String(error) + if (error instanceof ProfilePersistenceError) code = error.code + if (error instanceof ProfileValidationError) code = 'profile-validation-failed' + const issue: ProfileIssue = { + origin: 'source', + level: 'error', + code: redactSensitiveText(code, 256), + message: redactSensitiveText(message, 4096), + } + return { source: describeProfileSource(spec), issue } +} + +function orderedSpecs(input: ProfileDiscoveryInput): readonly ProfileSourceSpec[] { + return [ + ...(input.explicit ?? []), + ...(input.workspaceTrusted === true ? (input.workspace ?? []) : []), + ...(input.user ?? []), + ...(input.provider ?? []), + ...(input.recent ?? []), + ] +} + +/** + * Discover only explicitly supplied source entries. + * + * There is intentionally no directory walk: a workspace or user config must + * name each profile source before Braid reads it. + */ +export async function discoverProfiles( + input: ProfileDiscoveryInput = {}, +): Promise { + const profiles: ProfileRecord[] = [] + const issues: ProfileDiscoveryIssue[] = [] + const seen = new Set() + for (const spec of orderedSpecs(input)) { + try { + const record = await resolveProfileSource(spec, input.resolverContext) + const identity = `${record.source.kind}\u0000${record.source.reference}\u0000${record.digest}` + if (seen.has(identity)) continue + seen.add(identity) + profiles.push(record) + } catch (error) { + issues.push(sourceIssue(spec, error)) + } + } + return { profiles, issues } +} + +export function profileSourceKey(record: ProfileRecord): string { + return `${record.source.kind}\u0000${record.source.reference}\u0000${record.digest}` +} diff --git a/src/app/profile-types.ts b/src/app/profile-types.ts new file mode 100644 index 0000000..6eba1db --- /dev/null +++ b/src/app/profile-types.ts @@ -0,0 +1,299 @@ +import type { + AgentProfile, + AgentProfileCapabilities, + AgentProfileRef, + AgentProfileSecurityPolicy, + AgentProfileValidationIssue, + AgentProfileValidationResult, + HarnessType, + ReasoningEffort, + Sha256Digest, +} from '@tangle-network/agent-interface' +import type { AgentEnvironmentCapabilities } from '@tangle-network/agent-interface/environment-provider' + +export const PROFILE_EXPORT_FORMAT = 'braid-agent-profile' as const +export const PROFILE_EXPORT_SCHEMA_VERSION = 1 as const + +export type ProfileSourceKind = 'inline' | 'file' | 'provider' | 'package' | 'github' | 'recent' + +export interface ProfileSourceDescriptor { + readonly kind: ProfileSourceKind + readonly reference: string + readonly label: string + readonly revision?: string + readonly writable: boolean + readonly trusted: boolean +} + +export interface ProfileSourceRecord { + readonly source: ProfileSourceDescriptor + readonly profile: Readonly + readonly digest: Sha256Digest + readonly agentInterfacePackageVersion: string +} + +export interface ProfileSourceResolverContext { + readonly signal?: AbortSignal + readonly workspaceRoot?: string +} + +export type ProfileSourceResolver = ( + reference: string, + context: ProfileSourceResolverContext, +) => Promise + +export interface ProfileSourceResolution { + readonly profile: AgentProfile + readonly revision?: string + readonly writable?: boolean +} + +interface ProfileSourceSpecBase { + readonly reference: string + readonly label?: string + readonly revision?: string + readonly writable?: boolean + readonly trusted?: boolean +} + +export interface InlineProfileSourceSpec extends ProfileSourceSpecBase { + readonly kind: 'inline' + readonly profile: AgentProfile +} + +export interface FileProfileSourceSpec extends ProfileSourceSpecBase { + readonly kind: 'file' + readonly path: string +} + +export interface ResolvedProfileSourceSpec extends ProfileSourceSpecBase { + readonly kind: 'provider' | 'package' | 'github' | 'recent' + readonly resolve: ProfileSourceResolver +} + +export type ProfileSourceSpec = + | InlineProfileSourceSpec + | FileProfileSourceSpec + | ResolvedProfileSourceSpec + +export interface ProfileRecord extends ProfileSourceRecord { + readonly id: string + readonly displayName: string +} + +export type ProfileIssueOrigin = 'schema' | 'security' | 'provider' | 'source' + +export interface ProfileIssue extends AgentProfileValidationIssue { + readonly origin: ProfileIssueOrigin + readonly path?: string +} + +export interface ProfileProviderValidation { + readonly provider: string + readonly result: AgentProfileValidationResult + readonly capabilities?: AgentProfileCapabilities +} + +export interface ProfileValidationReport { + readonly ok: boolean + readonly issues: readonly ProfileIssue[] + readonly profile?: Readonly + readonly digest?: Sha256Digest + readonly provider?: ProfileProviderValidation + readonly acceptedProviderWarningCodes?: readonly string[] +} + +export interface ProfileValidationOptions { + readonly securityPolicy?: AgentProfileSecurityPolicy + readonly provider?: ProfileProvider + readonly acceptedProviderWarningCodes?: readonly string[] + readonly signal?: AbortSignal +} + +/** The narrow provider surface needed to validate a portable profile. */ +export interface ProfileProvider { + readonly name: string + readonly capabilities?: () => AgentEnvironmentCapabilities | Promise + readonly validateProfile?: ( + profile: AgentProfileRef, + ) => AgentProfileValidationResult | Promise +} + +export interface ProfileDiscoveryInput { + readonly explicit?: readonly ProfileSourceSpec[] + readonly workspace?: readonly ProfileSourceSpec[] + readonly workspaceTrusted?: boolean + readonly user?: readonly ProfileSourceSpec[] + readonly provider?: readonly ProfileSourceSpec[] + readonly recent?: readonly ProfileSourceSpec[] + readonly resolverContext?: ProfileSourceResolverContext +} + +export interface ProfileDiscoveryIssue { + readonly source: ProfileSourceDescriptor + readonly issue: ProfileIssue +} + +export interface ProfileDiscoveryResult { + readonly profiles: readonly ProfileRecord[] + readonly issues: readonly ProfileDiscoveryIssue[] +} + +export interface ProfileSelectionCandidates { + readonly commandLine?: ProfileRecord + readonly branch?: ProfileRecord + readonly workspace?: ProfileRecord + readonly user?: ProfileRecord + readonly firstRun?: ProfileRecord + readonly workspaceTrusted?: boolean +} + +export type ProfileSelectionReason = 'command-line' | 'branch' | 'workspace' | 'user' | 'first-run' + +export interface ProfileSelectionResult { + readonly profile: ProfileRecord + readonly reason: ProfileSelectionReason +} + +export interface ProfileRunOverrides { + readonly harness?: HarnessType + readonly model?: string + readonly effort?: ReasoningEffort + readonly mode?: string + readonly connectionId?: string +} + +export interface EffectiveProfileInput { + readonly profile: ProfileRecord + readonly branchOverrides?: ProfileRunOverrides + readonly nextRunOverrides?: ProfileRunOverrides + readonly workspaceTrusted?: boolean + readonly workspaceConnectionId?: string + readonly userConnectionId?: string + readonly availableModelIds?: readonly string[] + readonly modelReasoning?: { + readonly supportsReasoning?: boolean + readonly maxEffort?: ReasoningEffort + } +} + +export interface EffectiveProfileResult { + readonly authoredProfile: Readonly + readonly effectiveProfile: Readonly + readonly runner?: HarnessType + readonly model?: string + readonly effort?: ReasoningEffort + readonly mode?: string + readonly connectionId?: string + readonly overrides: Readonly + readonly compatibility: ProfileCompatibility +} + +export interface ProfileCompatibility { + readonly modelSupported?: boolean + readonly suggestedModel?: string + readonly suggestedRunner?: HarnessType + readonly modelHonored?: boolean + readonly effortHonored?: boolean + readonly selectorsHonored?: boolean + readonly availableEfforts?: readonly ReasoningEffort[] +} + +export interface ProfileDraftValidation { + readonly ok: boolean + readonly issues: readonly ProfileIssue[] + readonly profile?: Readonly + readonly digest?: Sha256Digest +} + +export interface ProfileExportDocument { + readonly format: typeof PROFILE_EXPORT_FORMAT + readonly schemaVersion: typeof PROFILE_EXPORT_SCHEMA_VERSION + readonly agentInterfacePackage: { + readonly name: '@tangle-network/agent-interface' + readonly version: string + } + /** Digest of the profile before export redaction. */ + readonly sourceProfileDigest: Sha256Digest + /** Digest of the exact profile carried in this document. */ + readonly profileDigest: Sha256Digest + readonly redacted: boolean + readonly profile: Readonly +} + +export interface ImportedProfileDocument { + readonly profile: Readonly + readonly digest: Sha256Digest + readonly redacted: boolean + readonly sourceProfileDigest?: Sha256Digest + readonly packageVersion: string +} + +export interface ProfileExportOptions { + readonly redact?: boolean + readonly pretty?: boolean +} + +export interface ProfileImportOptions { + readonly allowRedacted?: boolean +} + +export interface ProfileFileState { + readonly path: string + readonly bytesDigest: Sha256Digest + readonly record: ProfileRecord +} + +export interface SaveProfileFileOptions { + readonly expectedBytesDigest?: Sha256Digest + readonly expectedProfileDigest?: Sha256Digest + readonly expectedBytesAbsent?: boolean + readonly overwrite?: boolean + readonly sourceLabel?: string + readonly trusted?: boolean + readonly onPhase?: (phase: ProfileFileWritePhase) => void +} + +export type ProfileFileWritePhase = + | 'temporary-written' + | 'temporary-fsynced' + | 'renamed' + | 'directory-fsynced' + +export interface ExportProfileFileOptions extends SaveProfileFileOptions, ProfileExportOptions {} + +export interface ProfileSnapshotInput { + readonly source: ProfileRecord + readonly effective: EffectiveProfileResult + readonly validation: ProfileValidationReport + readonly capabilities?: AgentProfileCapabilities + readonly providerMaterializationReceipt?: unknown +} + +export interface ProfileSnapshotReceipt { + readonly kind: 'braid-profile-snapshot' + readonly schemaVersion: 1 + readonly agentInterfacePackage: { + readonly name: '@tangle-network/agent-interface' + readonly version: string + } + readonly source: ProfileSourceDescriptor + readonly authoredProfile: Readonly + readonly effectiveProfile: Readonly + readonly authoredProfileDigest: Sha256Digest + readonly effectiveProfileDigest: Sha256Digest + readonly runner?: HarnessType + readonly model?: string + readonly effort?: ReasoningEffort + readonly mode?: string + readonly connectionId?: string + readonly overrides: Readonly + readonly validation: { + readonly ok: boolean + readonly issues: readonly ProfileIssue[] + readonly acceptedProviderWarningCodes?: readonly string[] + } + readonly capabilities?: AgentProfileCapabilities + readonly providerMaterializationReceipt?: unknown + readonly digest: Sha256Digest +} diff --git a/src/app/profile-validation.ts b/src/app/profile-validation.ts new file mode 100644 index 0000000..0d818a9 --- /dev/null +++ b/src/app/profile-validation.ts @@ -0,0 +1,410 @@ +import { readFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, join } from 'node:path' +import { + AgentEnvironmentCapabilitiesSchema, + type AgentProfile, + type AgentProfileCapabilities, + type AgentProfileSecurityPolicy, + type AgentProfileValidationResult, + agentProfileSchema, + canonicalAgentProfileDigest, + DEFAULT_CLOUD_AGENT_PROFILE_SECURITY_POLICY, + snapshotAgentProfile, + validateAgentProfileSecurity, +} from '@tangle-network/agent-interface' +import { redactSensitiveText } from '../domain/secret-sanitizer.js' +import type { + ProfileIssue, + ProfileProvider, + ProfileProviderValidation, + ProfileValidationOptions, + ProfileValidationReport, +} from './profile-types.js' + +const require = createRequire(import.meta.url) + +export const AGENT_INTERFACE_PACKAGE_NAME = '@tangle-network/agent-interface' as const +export const AGENT_INTERFACE_PACKAGE_VERSION = readAgentInterfaceVersion() + +function readAgentInterfaceVersion(): string { + try { + const entry = require.resolve(AGENT_INTERFACE_PACKAGE_NAME) + const packagePath = join(dirname(entry), '..', 'package.json') + const parsed: unknown = JSON.parse(readFileSync(packagePath, 'utf8')) + if ( + parsed !== null && + typeof parsed === 'object' && + 'version' in parsed && + typeof parsed.version === 'string' && + parsed.version.length > 0 + ) { + return parsed.version + } + } catch { + // The published package normally has package metadata beside its entrypoint. + } + return 'unavailable' +} + +function pathText(path: readonly PropertyKey[]): string | undefined { + if (path.length === 0) return undefined + return path.map((part) => (typeof part === 'symbol' ? part.toString() : String(part))).join('.') +} + +function issue( + origin: ProfileIssue['origin'], + level: ProfileIssue['level'], + code: string, + message: string, + path?: string, +): ProfileIssue { + return { + origin, + level, + code: redactSensitiveText(code, 256), + message: redactSensitiveText(message, 4096), + ...(path === undefined ? {} : { path: redactSensitiveText(path, 512) }), + } +} + +function schemaIssues(error: { + readonly issues: readonly { path: PropertyKey[]; code: string; message: string }[] +}): ProfileIssue[] { + return error.issues.map((item) => + issue('schema', 'error', item.code, item.message, pathText(item.path)), + ) +} + +function securityIssues(profile: AgentProfile, policy: AgentProfileSecurityPolicy): ProfileIssue[] { + const result = validateAgentProfileSecurity(profile, policy) + return result.issues.map((item) => + issue('security', item.level, item.code, item.message, item.path), + ) +} + +const INLINE_SECRET_FIELD = + /^(?:api(?:key|token)|access(?:key|token)|privatekey|clientsecret|token|secret|password|passphrase|credential|authorization|cookie|databaseurl|dsn|pat)$/u + +function inlineSecretIssues(profile: AgentProfile): ProfileIssue[] { + const issues: ProfileIssue[] = [] + const visit = (value: unknown, path: readonly PropertyKey[]): void => { + if (value === null || typeof value !== 'object') return + if (Array.isArray(value)) { + value.forEach((child, index) => { + visit(child, [...path, index]) + }) + return + } + for (const [name, child] of Object.entries(value)) { + const childPath = [...path, name] + const normalized = name.replace(/[^a-z0-9]/giu, '').toLowerCase() + if (INLINE_SECRET_FIELD.test(normalized) && !isSecretReference(child)) { + issues.push( + issue( + 'security', + 'error', + 'inline-secret-forbidden', + 'Inline credential material must use a protected secret reference', + pathText(childPath), + ), + ) + } + visit(child, childPath) + } + } + visit(profile, []) + return issues +} + +function isSecretReference(value: unknown): boolean { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return false + return ( + 'kind' in value && + value.kind === 'secret-ref' && + 'key' in value && + typeof value.key === 'string' && + value.key.length > 0 + ) +} + +function safeProviderResult(value: AgentProfileValidationResult): AgentProfileValidationResult { + return { + ok: value.ok, + issues: value.issues.map((item) => ({ + level: item.level, + code: redactSensitiveText(item.code, 256), + message: redactSensitiveText(item.message, 4096), + ...(item.path === undefined ? {} : { path: redactSensitiveText(item.path, 512) }), + })), + ...(value.normalizedProfile === undefined + ? {} + : { normalizedProfile: value.normalizedProfile }), + } +} + +export function validateProfileShape(value: unknown): ProfileValidationReport { + try { + const parsed = agentProfileSchema.safeParse(value) + if (!parsed.success) { + return { ok: false, issues: schemaIssues(parsed.error) } + } + const profile = snapshotAgentProfile(parsed.data) + return { + ok: true, + issues: [], + profile, + digest: canonicalAgentProfileDigest(profile), + } + } catch (error) { + return { + ok: false, + issues: [ + issue( + 'schema', + 'error', + 'profile-snapshot-failed', + error instanceof Error ? error.message : String(error), + ), + ], + } + } +} + +function providerIssues( + provider: ProfileProvider, + result: AgentProfileValidationResult, + acceptedWarnings: ReadonlySet, +): ProfileIssue[] { + const output: ProfileIssue[] = [] + const safe = safeProviderResult(result) + for (const item of safe.issues) { + output.push(issue('provider', item.level, item.code, item.message, item.path)) + if (item.level === 'warning' && !acceptedWarnings.has(item.code)) { + output.push( + issue( + 'provider', + 'error', + 'provider-warning-not-accepted', + `Provider warning ${item.code} requires explicit acceptance`, + item.path, + ), + ) + } + } + if (!safe.ok && safe.issues.every((item) => item.level !== 'error')) { + if (safe.issues.length === 0) { + output.push( + issue( + 'provider', + 'error', + 'provider-rejected-profile', + `Provider ${provider.name} rejected the profile without an issue`, + ), + ) + } + } + return output +} + +async function providerCapabilities( + provider: ProfileProvider, +): Promise<{ readonly value?: AgentProfileCapabilities; readonly issues: ProfileIssue[] }> { + if (provider.capabilities === undefined) return { issues: [] } + try { + const candidate = await provider.capabilities() + const parsed = AgentEnvironmentCapabilitiesSchema.safeParse(candidate) + if (!parsed.success) { + return { + issues: [ + issue( + 'provider', + 'error', + 'invalid-provider-capabilities', + 'Provider returned capabilities that do not match the canonical environment contract', + ), + ], + } + } + const value = parsed.data.profile + return { + value: { + namedProfiles: value.namedProfiles, + systemPrompt: value.systemPrompt, + instructions: value.instructions, + tools: value.tools, + permissions: value.permissions, + mcp: value.mcp, + subagents: value.subagents, + resources: { + files: value.resources.files, + instructions: value.resources.instructions, + ...(value.resources.tools === undefined ? {} : { tools: value.resources.tools }), + ...(value.resources.skills === undefined ? {} : { skills: value.resources.skills }), + ...(value.resources.agents === undefined ? {} : { agents: value.resources.agents }), + ...(value.resources.commands === undefined ? {} : { commands: value.resources.commands }), + }, + ...(value.hooks === undefined ? {} : { hooks: value.hooks }), + ...(value.modes === undefined ? {} : { modes: value.modes }), + runtimeUpdate: value.runtimeUpdate, + validation: value.validation, + ...(value.extensions === undefined ? {} : { extensions: value.extensions }), + }, + issues: [], + } + } catch (error) { + return { + issues: [ + issue( + 'provider', + 'error', + 'provider-capabilities-failed', + error instanceof Error ? error.message : String(error), + ), + ], + } + } +} + +function throwIfAborted(signal: AbortSignal | undefined): void { + if (signal?.aborted) throw new DOMException('Profile validation was aborted', 'AbortError') +} + +/** + * Validate one profile at the application boundary. + * + * The schema, security policy, provider validation, and provider normalization + * are all applied in order; Braid never drops a field to make a provider accept + * a profile. + */ +export async function validateProfile( + value: unknown, + options: ProfileValidationOptions = {}, +): Promise { + throwIfAborted(options.signal) + const shape = validateProfileShape(value) + if (!shape.ok || shape.profile === undefined) return shape + + const securityPolicy = options.securityPolicy ?? DEFAULT_CLOUD_AGENT_PROFILE_SECURITY_POLICY + const issues = [ + ...securityIssues(shape.profile, securityPolicy), + ...inlineSecretIssues(shape.profile), + ] + let profile = shape.profile + let capabilities: AgentProfileCapabilities | undefined + let providerReport: ProfileProviderValidation | undefined + const acceptedWarningCodes = new Set(options.acceptedProviderWarningCodes ?? []) + + if (options.provider !== undefined) { + const provider = options.provider + const capabilityResult = await providerCapabilities(provider) + issues.push(...capabilityResult.issues) + capabilities = capabilityResult.value + throwIfAborted(options.signal) + + if (provider.validateProfile === undefined) { + const validates = capabilities?.validation + if (validates === true) { + issues.push( + issue( + 'provider', + 'error', + 'provider-validation-unavailable', + `Provider ${provider.name} advertises profile validation but exposes no validator`, + ), + ) + } else { + issues.push( + issue( + 'provider', + 'info', + 'provider-validation-unavailable', + `Provider ${provider.name} does not expose profile validation`, + ), + ) + } + } else { + let result: AgentProfileValidationResult + try { + result = await provider.validateProfile(profile) + } catch (error) { + result = { + ok: false, + issues: [ + { + level: 'error', + code: 'provider-validation-failed', + message: error instanceof Error ? error.message : String(error), + }, + ], + } + } + const safe = safeProviderResult(result) + providerReport = { + provider: redactSensitiveText(provider.name, 256), + result: safe, + ...(capabilities === undefined ? {} : { capabilities }), + } + issues.push(...providerIssues(provider, safe, acceptedWarningCodes)) + if (safe.normalizedProfile !== undefined && issues.every((item) => item.level !== 'error')) { + const normalized = validateProfileShape(safe.normalizedProfile) + if (!normalized.ok || normalized.profile === undefined) { + issues.push( + issue( + 'provider', + 'error', + 'invalid-provider-normalization', + 'Provider normalization did not produce a canonical AgentProfile', + ), + ) + } else { + profile = normalized.profile + issues.push(...securityIssues(profile, securityPolicy)) + } + } + } + } + + const ok = issues.every((item) => item.level !== 'error') + return { + ok, + issues, + profile, + digest: canonicalAgentProfileDigest(profile), + ...(providerReport === undefined ? {} : { provider: providerReport }), + ...(acceptedWarningCodes.size === 0 + ? {} + : { + acceptedProviderWarningCodes: [...acceptedWarningCodes] + .map((code) => redactSensitiveText(code, 256)) + .sort(), + }), + } +} + +export function assertValidProfile( + value: unknown, + options: { readonly securityPolicy?: AgentProfileSecurityPolicy } = {}, +): Readonly { + const shape = validateProfileShape(value) + if (!shape.ok || shape.profile === undefined) { + throw new ProfileValidationError(shape.issues) + } + const issues = securityIssues( + shape.profile, + options.securityPolicy ?? DEFAULT_CLOUD_AGENT_PROFILE_SECURITY_POLICY, + ) + const errors = issues.filter((item) => item.level === 'error') + if (errors.length > 0) throw new ProfileValidationError(errors) + return shape.profile +} + +export class ProfileValidationError extends Error { + readonly issues: readonly ProfileIssue[] + + constructor(issues: readonly ProfileIssue[]) { + super(issues.map((item) => `${item.path ?? '$'}: ${item.message}`).join('; ')) + this.name = 'ProfileValidationError' + this.issues = issues + } +} diff --git a/src/app/profiles.ts b/src/app/profiles.ts new file mode 100644 index 0000000..2749451 --- /dev/null +++ b/src/app/profiles.ts @@ -0,0 +1,254 @@ +import { + exportProfileDocument, + exportProfileFile, + exportProfileJson, + importProfileJson, + importProfileValue, + readProfileFile, + saveProfileFile, + ProfilePersistenceError, +} from './profile-persistence.js' +import { ProfileDraft } from './profile-draft.js' +import { createProfileSnapshot } from './profile-snapshots.js' +import { + createProfileRecord, + describeProfileSource, + discoverProfiles, + profileSourceKey, + resolveProfileReference, + resolveProfileSource, +} from './profile-sources.js' +import { + assertValidProfile, + validateProfile, + validateProfileShape, + ProfileValidationError, +} from './profile-validation.js' +import { resolveEffectiveProfile, selectBaseProfile } from './profile-selection.js' +import type { + ProfileDiscoveryInput, + ProfileDiscoveryResult, + ProfileExportDocument, + ExportProfileFileOptions, + ProfileExportOptions, + ProfileFileState, + ProfileImportOptions, + ProfileRecord, + ProfileSelectionCandidates, + ProfileSelectionResult, + ProfileSnapshotInput, + ProfileSnapshotReceipt, + ProfileSourceDescriptor, + ProfileSourceSpec, + ProfileValidationOptions, + ProfileValidationReport, + SaveProfileFileOptions, + EffectiveProfileInput, + EffectiveProfileResult, +} from './profile-types.js' + +export * from './profile-draft.js' +export * from './profile-persistence.js' +export * from './profile-selection.js' +export * from './profile-snapshots.js' +export * from './profile-sources.js' +export * from './profile-types.js' +export * from './profile-validation.js' + +export { + assertValidProfile, + createProfileRecord, + createProfileSnapshot, + describeProfileSource, + discoverProfiles, + exportProfileDocument, + exportProfileFile, + exportProfileJson, + importProfileJson, + importProfileValue, + readProfileFile, + resolveEffectiveProfile, + resolveProfileReference, + resolveProfileSource, + saveProfileFile, + selectBaseProfile, + validateProfile, + validateProfileShape, + ProfileDraft, + ProfilePersistenceError, + ProfileValidationError, +} + +export interface ProfileSummary { + readonly id: string + readonly name: string + readonly description?: string + readonly version?: string + readonly tags: readonly string[] + readonly source: ProfileSourceDescriptor + readonly digest: string + readonly runner?: string + readonly model?: string + readonly tools: readonly string[] + readonly skills: readonly string[] + readonly connections: readonly string[] + readonly validation?: ProfileValidationReport + readonly lastUsedRunner?: string +} + +export interface ProfileSummaryOptions { + readonly validation?: ProfileValidationReport + readonly lastUsedRunner?: string +} + +export function summarizeProfile( + record: ProfileRecord, + options: ProfileSummaryOptions = {}, +): ProfileSummary { + const profile = record.profile + return Object.freeze({ + id: record.id, + name: profile.name ?? record.displayName, + ...(profile.description === undefined ? {} : { description: profile.description }), + ...(profile.version === undefined ? {} : { version: profile.version }), + tags: [...(profile.tags ?? [])], + source: record.source, + digest: record.digest, + ...(profile.harness === undefined ? {} : { runner: profile.harness }), + ...(profile.model?.default === undefined ? {} : { model: profile.model.default }), + tools: Object.keys(profile.tools ?? {}).sort(), + skills: (profile.resources?.skills ?? []) + .map((resource) => (resource.kind === 'github' ? resource.path : resource.name)) + .filter((name) => name.length > 0), + connections: (profile.connections ?? []).map((connection) => connection.connectionId), + ...(options.validation === undefined ? {} : { validation: options.validation }), + ...(options.lastUsedRunner === undefined ? {} : { lastUsedRunner: options.lastUsedRunner }), + }) +} + +function searchableProfileText(record: ProfileRecord): string { + const profile = record.profile + const resourceNames = [ + ...(profile.resources?.skills ?? []), + ...(profile.resources?.tools ?? []), + ...(profile.resources?.agents ?? []), + ...(profile.resources?.commands ?? []), + ].flatMap((resource) => [ + resource.name, + resource.kind === 'github' ? resource.path : undefined, + resource.kind, + ]) + return [ + profile.name, + profile.description, + profile.version, + ...(profile.tags ?? []), + record.source.label, + record.source.reference, + profile.harness, + profile.model?.default, + profile.model?.small, + profile.model?.provider, + ...Object.keys(profile.tools ?? {}), + ...Object.keys(profile.mcp ?? {}), + ...(profile.connections ?? []).flatMap((connection) => [ + connection.connectionId, + ...(connection.capabilities ?? []), + connection.alias, + ]), + ...resourceNames, + ] + .filter((value): value is string => value !== undefined) + .join('\u0000') + .toLocaleLowerCase() +} + +export function searchProfiles( + records: readonly ProfileRecord[], + query: string, +): readonly ProfileRecord[] { + const normalized = query.trim().toLocaleLowerCase() + if (normalized.length === 0) return [...records] + return records.filter((record) => searchableProfileText(record).includes(normalized)) +} + +export function importProfileSource( + value: unknown, + source: ProfileSourceDescriptor, + options: ProfileImportOptions = {}, +): ProfileRecord { + const imported = importProfileValue(value, options) + return createProfileRecord( + { + ...source, + ...(source.revision === undefined ? {} : { revision: source.revision }), + }, + imported.profile, + ) +} + +export interface ProfileCatalogOptions { + readonly profiles?: readonly ProfileRecord[] +} + +/** A small immutable-record catalog; source resolution and validation stay outside it. */ +export class ProfileCatalog { + #profiles: readonly ProfileRecord[] + + constructor(options: ProfileCatalogOptions = {}) { + this.#profiles = [...(options.profiles ?? [])] + } + + get size(): number { + return this.#profiles.length + } + + list(query = ''): readonly ProfileRecord[] { + return searchProfiles(this.#profiles, query) + } + + get(id: string): ProfileRecord | undefined { + return this.#profiles.find((profile) => profile.id === id) + } + + add(record: ProfileRecord): readonly ProfileRecord[] { + if ( + this.#profiles.some((existing) => profileSourceKey(existing) === profileSourceKey(record)) + ) { + return this.#profiles + } + this.#profiles = [...this.#profiles, record] + return this.#profiles + } + + replace(records: readonly ProfileRecord[]): readonly ProfileRecord[] { + this.#profiles = [...records] + return this.#profiles + } + + select(candidates: ProfileSelectionCandidates): ProfileSelectionResult | undefined { + return selectBaseProfile(candidates) + } +} + +export type { + EffectiveProfileInput, + EffectiveProfileResult, + ProfileDiscoveryInput, + ProfileDiscoveryResult, + ProfileExportDocument, + ExportProfileFileOptions, + ProfileExportOptions, + ProfileFileState, + ProfileImportOptions, + ProfileRecord, + ProfileSelectionCandidates, + ProfileSelectionResult, + ProfileSnapshotInput, + ProfileSnapshotReceipt, + ProfileSourceDescriptor, + ProfileSourceSpec, + ProfileValidationOptions, + ProfileValidationReport, + SaveProfileFileOptions, +} diff --git a/src/app/provider-snapshot.ts b/src/app/provider-snapshot.ts new file mode 100644 index 0000000..d517945 --- /dev/null +++ b/src/app/provider-snapshot.ts @@ -0,0 +1,37 @@ +import { redactSensitiveText } from '../domain/secret-sanitizer.js' +import type { TurnUsage } from '../domain/entities.js' +import type { ProviderRunSnapshot } from '../ports/execution.js' +import { + finiteNonNegativeNumber, + safePublicIdentifier, + safeProviderDiagnostic, +} from './provider-values.js' + +export function safeSnapshotText(value: unknown): string { + return typeof value === 'string' ? redactSensitiveText(value) : '' +} + +export function safeSnapshotDetail(value: unknown, fallback: string): string { + return safeProviderDiagnostic(value, fallback) +} + +export function safeSnapshotUsage( + usage: ProviderRunSnapshot['usage'] | undefined, + fallback: TurnUsage, +): TurnUsage { + if (usage === undefined) return fallback + const reasoning = finiteNonNegativeNumber(usage.reasoning, undefined) + const costUsd = finiteNonNegativeNumber(usage.costUsd, undefined) + const model = safePublicIdentifier(usage.model) + return { + input: finiteNonNegativeNumber(usage.input), + output: finiteNonNegativeNumber(usage.output), + ...(reasoning === undefined ? {} : { reasoning }), + ...(costUsd === undefined ? {} : { costUsd }), + ...(model === undefined ? {} : { model }), + } +} + +export function safeSnapshotIdentity(value: unknown): string | undefined { + return safePublicIdentifier(value) +} diff --git a/src/app/provider-values.ts b/src/app/provider-values.ts new file mode 100644 index 0000000..08b937f --- /dev/null +++ b/src/app/provider-values.ts @@ -0,0 +1,44 @@ +import type { RuntimeStreamEvent } from '@tangle-network/agent-runtime' +import type { TurnUsage } from '../domain/events.js' +import { + finiteNonNegativeNumber, + safeProviderDiagnostic, + safePublicIdentifier, +} from '../domain/provider-values.js' + +export { + finiteNonNegativeNumber, + safeDiagnostic, + safeProviderDiagnostic, + safePublicIdentifier, +} from '../domain/provider-values.js' + +/** + * Provider payloads are untrusted input to durable state. + * + * A runtime may report a diagnostic, a model name, or a token count that + * carries credential material, control sequences, or a value the journal + * invariants reject. Everything a provider hands Braid passes through this + * module before it reaches an event, so a bad provider can degrade a field to + * a bounded placeholder but can never poison the journal. + */ + +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 = finiteNonNegativeNumber(metadata.costUsd, undefined) + 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-admission-continuation.ts b/src/app/run-admission-continuation.ts new file mode 100644 index 0000000..d2f3c8f --- /dev/null +++ b/src/app/run-admission-continuation.ts @@ -0,0 +1,41 @@ +import type { NativeContextBoundaryProof } from '../domain/receipts.js' +import type { NativeContinuationPort } from './application-ports.js' +import type { SendReceipt } from './application-types.js' +import { AppError } from './errors.js' + +export async function continueNative( + context: NativeContinuationPort, + input: { readonly operationId: string; readonly text: string; readonly runId?: string }, +): Promise { + const source = context.findRun(input.runId ?? context.currentState().runs.at(-1)?.id ?? '') + const sessionId = source.providerSessionId + if (!sessionId || !source.capabilities.sessions.continue || !context.execution.nativeBoundary) + throw new AppError( + 'NATIVE_CONTINUATION_UNVERIFIED', + 'The provider cannot prove a native session boundary for this run', + ) + const boundary = await context.execution.nativeBoundary({ runId: source.id, sessionId }) + if (!boundary) + throw new AppError( + 'NATIVE_CONTINUATION_UNVERIFIED', + 'The provider did not return a native session boundary proof', + ) + const proof: NativeContextBoundaryProof = { + runId: source.id, + providerSessionId: sessionId, + boundary: boundary.boundary, + digest: boundary.digest, + ...(boundary.revision === undefined ? {} : { revision: boundary.revision }), + } + if (source.lastCursor !== undefined && proof.boundary !== source.lastCursor) + throw new AppError( + 'NATIVE_BOUNDARY_MISMATCH', + 'The provider session no longer ends at the recorded Braid boundary', + ) + return context.send({ + operationId: input.operationId, + text: input.text, + sessionId, + nativeContextBoundaryProof: proof, + }) +} diff --git a/src/app/run-admission-dispatch.ts b/src/app/run-admission-dispatch.ts new file mode 100644 index 0000000..208ca21 --- /dev/null +++ b/src/app/run-admission-dispatch.ts @@ -0,0 +1,174 @@ +import type { AdmissionPort, AsyncAdmissionPort } from './application-ports.js' +import type { SendReceipt } from './application-types.js' +import { AppError } from './errors.js' +import { RUN_EFFECT_KIND, runEffectRequest } from './run-admission-request.js' +import { admitRun, admitRunAsync } from './run-admission-receipt.js' +import { validateContextPlan, validateNativeProof } from './run-admission-validation.js' +import type { RunExecutionSnapshot } from './run-execution-snapshot.js' + +export function sendRun(context: AdmissionPort, input: RunExecutionSnapshot): SendReceipt { + const state = context.currentState() + if (state.workspace === null) + throw new AppError('NOT_INITIALIZED', 'Initialize a workspace before sending') + if (!input.operationId) throw new AppError('OPERATION_ID_REQUIRED', 'send requires operationId') + if (!input.text.trim()) throw new AppError('EMPTY_MESSAGE', 'Message must not be empty') + const conversationId = input.conversationId + const branchId = input.branchId + if (conversationId !== state.conversationId || branchId !== state.branchId) + throw new AppError('UNKNOWN_BRANCH', 'The requested conversation branch is not open') + validateNativeProof(context, input) + + const request = runEffectRequest(input) + const digest = context.fingerprint({ effectKind: RUN_EFFECT_KIND, request }) + const persisted = context.admitPersistedSend(input.operationId, digest) + if (persisted) return persisted + if (state.activeRunId) + throw new AppError( + 'RUN_ACTIVE', + `Run ${state.activeRunId} is still active; queue the next input explicitly`, + ) + validateContextPlan(input) + + const runId = context.ids.next('run') + const turnId = context.ids.next('turn') + if (input.contextTransfer && input.contextTransfer.destinationRunId !== runId) + throw new AppError( + 'CONTEXT_RECEIPT_CONFLICT', + 'The context transfer receipt names a different destination run', + ) + const admission = admitRun( + context, + { + operationId: input.operationId, + runId, + text: input.text, + profile: input.profile, + ...(input.connectionId === undefined ? {} : { connectionId: input.connectionId }), + ...(input.workspaceRoot === undefined ? {} : { workspaceRoot: input.workspaceRoot }), + signal: new AbortController().signal, + ...(input.sessionId === undefined ? {} : { sessionId: input.sessionId }), + ...(input.contextPlan === undefined ? {} : { contextBoundary: input.contextPlan.digest }), + }, + conversationId, + branchId, + input.contextTransfer, + turnId, + input.contextPlan?.digest, + input.nativeContextBoundaryProof, + input.contextPlan, + ) + if (state.draft !== input.text) context.commit({ kind: 'draft.changed', text: input.text }) + context.commit({ + kind: 'run.requested', + operationId: input.operationId, + runId, + turnId, + userMessageId: context.ids.next('message'), + assistantMessageId: context.ids.next('message'), + text: input.text, + requestDigest: digest, + receipt: admission, + }) + const abort = new AbortController() + const operation = { + digest, + runId, + admission, + completion: Promise.resolve() as Promise, + } + context.ledger.setOperation(operation) + context.ledger.setAbort(runId, abort) + operation.completion = context.startRun(input, admission, abort, digest) + return { + operationId: input.operationId, + runId, + revision: context.currentState().revision, + replayed: false, + admission, + completion: operation.completion.then(() => structuredClone(context.currentState())), + } +} + +export async function sendRunAsync( + context: AsyncAdmissionPort, + input: RunExecutionSnapshot, + ids: { readonly runId: string; readonly turnId: string }, +): Promise { + const state = context.currentState() + if (state.workspace === null) + throw new AppError('NOT_INITIALIZED', 'Initialize a workspace before sending') + if (!input.operationId) + throw new AppError('OPERATION_ID_REQUIRED', 'send requires a valid operationId') + const conversationId = input.conversationId + const branchId = input.branchId + if (conversationId !== state.conversationId || branchId !== state.branchId) + throw new AppError('UNKNOWN_BRANCH', 'The requested conversation branch is not open') + validateNativeProof(context, input) + validateContextPlan(input) + const request = runEffectRequest(input) + const digest = context.fingerprint({ effectKind: RUN_EFFECT_KIND, request }) + const persisted = context.admitPersistedSend(input.operationId, digest) + if (persisted) return persisted + if (state.activeRunId) + throw new AppError( + 'RUN_ACTIVE', + `Run ${state.activeRunId} is still active; queue the next input explicitly`, + ) + if (input.contextTransfer && input.contextTransfer.destinationRunId !== ids.runId) + throw new AppError( + 'CONTEXT_RECEIPT_CONFLICT', + 'The context transfer receipt names a different destination run', + ) + const admission = await admitRunAsync( + context, + { + operationId: input.operationId, + runId: ids.runId, + text: input.text, + profile: input.profile, + ...(input.connectionId === undefined ? {} : { connectionId: input.connectionId }), + ...(input.workspaceRoot === undefined ? {} : { workspaceRoot: input.workspaceRoot }), + signal: new AbortController().signal, + ...(input.sessionId === undefined ? {} : { sessionId: input.sessionId }), + ...(input.contextPlan === undefined ? {} : { contextBoundary: input.contextPlan.digest }), + }, + conversationId, + branchId, + input.contextTransfer, + ids.turnId, + input.contextPlan?.digest, + input.nativeContextBoundaryProof, + input.contextPlan, + ) + if (state.draft !== input.text) + await context.commitAndWait({ kind: 'draft.changed', text: input.text }) + await context.commitAndWait({ + kind: 'run.requested', + operationId: input.operationId, + runId: ids.runId, + turnId: ids.turnId, + userMessageId: context.ids.next('message'), + assistantMessageId: context.ids.next('message'), + text: input.text, + requestDigest: digest, + receipt: admission, + }) + const abort = new AbortController() + const operation = { + digest, + runId: ids.runId, + admission, + completion: Promise.resolve() as Promise, + } + context.ledger.setOperation(operation) + context.ledger.setAbort(ids.runId, abort) + operation.completion = context.startRun(input, admission, abort, digest) + return { + operationId: input.operationId, + runId: ids.runId, + revision: context.currentState().revision, + replayed: false, + admission, + completion: operation.completion.then(() => structuredClone(context.currentState())), + } +} diff --git a/src/app/run-admission-receipt.ts b/src/app/run-admission-receipt.ts new file mode 100644 index 0000000..2eb64b4 --- /dev/null +++ b/src/app/run-admission-receipt.ts @@ -0,0 +1,142 @@ +import { createAdmissionReceipt } from '../domain/receipts.js' +import type { + ContextTransferReceipt, + NativeContextBoundaryProof, + PortableContextPlan, + RunAdmissionReceipt, +} from '../domain/receipts.js' +import type { ExecuteTurnInput } from '../ports/execution.js' +import { UNKNOWN_RUN_CAPABILITIES } from '../ports/execution.js' +import type { AdmissionPort, AsyncAdmissionPort } from './application-ports.js' +import type { RunExecutionSnapshot } from './run-execution-snapshot.js' +import { exactAdmissionRequestDigest } from './run-admission-request.js' +import { + admissionProfileDigest, + resolveAsyncAdmission, + resolveAsyncCapabilities, + resolveSyncAdmission, + resolveSyncCapabilities, + validateAdmissionDigests, + validateExecutionContext, + validateProfile, +} from './run-admission-validation.js' + +export function admitRun( + context: AdmissionPort, + input: ExecuteTurnInput, + conversationId: string, + branchId: string, + contextTransfer?: ContextTransferReceipt, + turnId?: string, + contextPlanDigest?: string, + nativeContextBoundaryProof?: NativeContextBoundaryProof, + contextPlan?: PortableContextPlan, +): RunAdmissionReceipt { + validateProfile(input.profile) + const rawAdmission = resolveSyncAdmission(context, input) + const resolved = rawAdmission ?? {} + validateExecutionContext(context.currentState(), input, resolved) + const capabilities = resolved.capabilities ?? resolveSyncCapabilities(context, input) + const receipt = createAdmissionReceipt({ + runId: input.runId, + turnId: turnId ?? context.ids.next('receipt'), + operationId: input.operationId, + conversationId, + branchId, + admittedAt: context.clock.now(), + profile: input.profile, + ...(input.connectionId === undefined ? {} : { connectionId: input.connectionId }), + text: input.text, + ...(input.sessionId === undefined ? {} : { sessionId: input.sessionId }), + capabilities, + ...(resolved.provider === undefined ? {} : { provider: resolved.provider }), + ...(resolved.environmentId === undefined ? {} : { environmentId: resolved.environmentId }), + ...(resolved.providerSessionId === undefined + ? {} + : { providerSessionId: resolved.providerSessionId }), + ...(resolved.materializationReceipt === undefined + ? {} + : { materializationReceipt: resolved.materializationReceipt }), + ...(contextPlan === undefined ? {} : { contextPlan }), + ...(contextPlanDigest === undefined ? {} : { contextPlanDigest }), + ...(contextTransfer === undefined ? {} : { contextTransfer }), + ...(nativeContextBoundaryProof === undefined ? {} : { nativeContextBoundaryProof }), + }) + validateAdmissionDigests( + receipt, + resolved, + exactAdmissionRequestDigest(input, conversationId, branchId, contextPlan), + admissionProfileDigest(input.profile), + ) + return receipt +} + +export async function admitRunAsync( + context: AsyncAdmissionPort, + input: ExecuteTurnInput, + conversationId: string, + branchId: string, + contextTransfer?: ContextTransferReceipt, + turnId?: string, + contextPlanDigest?: string, + nativeContextBoundaryProof?: NativeContextBoundaryProof, + contextPlan?: PortableContextPlan, +): Promise { + validateProfile(input.profile) + const resolved = await resolveAsyncAdmission(context, input) + validateExecutionContext(context.currentState(), input, resolved) + const capabilities = resolved?.capabilities ?? (await resolveAsyncCapabilities(context, input)) + const receipt = createAdmissionReceipt({ + runId: input.runId, + turnId: turnId ?? context.ids.next('receipt'), + operationId: input.operationId, + conversationId, + branchId, + admittedAt: context.clock.now(), + profile: input.profile, + ...(input.connectionId === undefined ? {} : { connectionId: input.connectionId }), + text: input.text, + ...(input.sessionId === undefined ? {} : { sessionId: input.sessionId }), + capabilities, + ...(resolved?.provider === undefined ? {} : { provider: resolved.provider }), + ...(resolved?.environmentId === undefined ? {} : { environmentId: resolved.environmentId }), + ...(resolved?.providerSessionId === undefined + ? {} + : { providerSessionId: resolved.providerSessionId }), + ...(resolved?.materializationReceipt === undefined + ? {} + : { materializationReceipt: resolved.materializationReceipt }), + ...(contextPlan === undefined ? {} : { contextPlan }), + ...(contextPlanDigest === undefined ? {} : { contextPlanDigest }), + ...(contextTransfer === undefined ? {} : { contextTransfer }), + ...(nativeContextBoundaryProof === undefined ? {} : { nativeContextBoundaryProof }), + ...(resolved?.warnings === undefined ? {} : { warnings: resolved.warnings }), + }) + validateAdmissionDigests( + receipt, + resolved, + exactAdmissionRequestDigest(input, conversationId, branchId, contextPlan), + admissionProfileDigest(input.profile), + ) + return receipt +} + +export function pendingAdmissionReceipt( + input: RunExecutionSnapshot, + runId: string, + turnId: string, +): RunAdmissionReceipt { + return createAdmissionReceipt({ + runId, + turnId, + operationId: input.operationId, + conversationId: input.conversationId, + branchId: input.branchId, + admittedAt: new Date(0).toISOString(), + profile: input.profile, + ...(input.connectionId === undefined ? {} : { connectionId: input.connectionId }), + text: input.text, + capabilities: UNKNOWN_RUN_CAPABILITIES, + admissionStatus: 'pending', + }) +} diff --git a/src/app/run-admission-request.ts b/src/app/run-admission-request.ts new file mode 100644 index 0000000..c6f7601 --- /dev/null +++ b/src/app/run-admission-request.ts @@ -0,0 +1,44 @@ +import { snapshotAgentProfile } from '@tangle-network/agent-interface' +import { canonicalDigest } from '../domain/canonical.js' +import type { PortableContextPlan } from '../domain/receipts.js' +import type { ExecuteTurnInput } from '../ports/execution.js' +import type { RunExecutionSnapshot } from './run-execution-snapshot.js' + +export const RUN_EFFECT_KIND = 'run.execute' + +export function runEffectRequest(input: RunExecutionSnapshot): Readonly> { + return { + conversationId: input.conversationId, + branchId: input.branchId, + text: input.text, + profile: input.profile, + connectionId: input.connectionId ?? null, + ...(input.workspaceRoot === undefined ? {} : { workspaceRoot: input.workspaceRoot }), + ...(input.sessionId === undefined ? {} : { sessionId: input.sessionId }), + ...(input.contextPlan === undefined ? {} : { contextPlan: input.contextPlan }), + ...(input.contextTransfer === undefined ? {} : { contextTransfer: input.contextTransfer }), + ...(input.nativeContextBoundaryProof === undefined + ? {} + : { nativeContextBoundaryProof: input.nativeContextBoundaryProof }), + } +} + +export function exactAdmissionRequestDigest( + input: ExecuteTurnInput, + conversationId: string, + branchId: string, + contextPlan?: PortableContextPlan, +): string { + return canonicalDigest({ + effectKind: RUN_EFFECT_KIND, + request: { + conversationId, + branchId, + text: input.text, + profile: snapshotAgentProfile(input.profile), + connectionId: input.connectionId ?? null, + ...(input.sessionId === undefined ? {} : { sessionId: input.sessionId }), + ...(contextPlan === undefined ? {} : { contextPlan }), + }, + }) +} diff --git a/src/app/run-admission-validation.ts b/src/app/run-admission-validation.ts new file mode 100644 index 0000000..3535d0d --- /dev/null +++ b/src/app/run-admission-validation.ts @@ -0,0 +1,226 @@ +import { snapshotAgentProfile, type AgentProfile } from '@tangle-network/agent-interface' +import { canonicalDigest } from '../domain/canonical.js' +import type { RunAdmissionReceipt, RunCapabilities } from '../domain/receipts.js' +import { redactProfile } from '../domain/redaction.js' +import type { + ExecuteTurnInput, + ExecutionAdmission, + ExecutionCapabilities, +} from '../ports/execution.js' +import { DEFAULT_RUN_CAPABILITIES, UNKNOWN_RUN_CAPABILITIES } from '../ports/execution.js' +import type { AdmissionPort, ExecutionAccess, StateReader } from './application-ports.js' +import type { SendInput } from './application-types.js' +import { AppError } from './errors.js' +import { branchHasVisibleHistory } from './run-continuation.js' + +export function validateNativeProof( + context: StateReader & ExecutionAccess, + input: SendInput & + Pick & { + readonly sessionSource?: 'caller' | 'continuation' + }, +): void { + if (!input.nativeContextBoundaryProof) { + if (input.sessionId !== undefined && input.sessionSource !== 'continuation') + throw new AppError( + 'NATIVE_CONTINUATION_UNVERIFIED', + 'A caller-supplied provider session requires a valid native context boundary proof', + ) + return + } + const proof = input.nativeContextBoundaryProof + const source = context.currentState().runs.find((candidate) => candidate.id === proof.runId) + if ( + !source || + !input.sessionId || + source.providerSessionId !== proof.providerSessionId || + input.sessionId !== proof.providerSessionId || + !source.capabilities.sessions.continue + ) + throw new AppError( + 'NATIVE_CONTINUATION_UNVERIFIED', + 'The native continuation proof is not bound to a recorded provider session', + ) + const state = context.currentState() + const sourceConnection = source.connectionId ?? source.receipt.requested.connectionId + if ( + source.conversationId !== state.conversationId || + source.branchId !== state.branchId || + sourceConnection !== input.connectionId || + source.receipt.profileDigest !== admissionProfileDigest(input.profile) + ) + throw new AppError( + 'NATIVE_CONTINUATION_UNVERIFIED', + 'The native continuation proof is bound to a different profile, connection, or branch', + ) + if (source.lastCursor !== undefined && proof.boundary !== source.lastCursor) + throw new AppError( + 'NATIVE_BOUNDARY_MISMATCH', + 'The native continuation proof does not end at the recorded Braid boundary', + ) +} + +export function validateContextPlan(input: SendInput): void { + if ( + input.contextPlan && + input.contextPlan.digest !== canonicalDigest({ ...input.contextPlan, digest: undefined }) + ) + throw new AppError('CONTEXT_DIGEST_INVALID', 'The portable context plan digest is invalid') + if ( + input.contextTransfer && + input.contextPlan && + input.contextTransfer.planDigest !== input.contextPlan.digest + ) + throw new AppError( + 'CONTEXT_RECEIPT_CONFLICT', + 'The context transfer receipt does not match the accepted plan', + ) +} + +export function validateExecutionContext( + state: ReturnType, + input: ExecuteTurnInput, + admission: ExecutionAdmission | undefined, +): void { + const receipt = admission?.materializationReceipt + if (receipt?.portableContext !== 'unavailable') return + if (input.sessionId !== undefined && input.contextBoundary === undefined) return + const hasHistory = branchHasVisibleHistory({ + state, + conversationId: state.conversationId, + branchId: state.branchId, + }) + if (!hasHistory) return + throw new AppError( + 'CONTEXT_TRANSFER_UNAVAILABLE', + 'This provider cannot transfer the existing branch history across its current environment; select a supported continuation or start a new branch', + ) +} + +export function validateProfile(profile: Readonly): void { + try { + snapshotAgentProfile(profile) + } catch (error) { + throw new AppError( + 'PROFILE_INVALID', + error instanceof Error ? error.message : 'The selected profile is invalid', + ) + } +} + +export function admissionProfileDigest(profile: Readonly): string { + return canonicalDigest(redactProfile(snapshotAgentProfile(profile))) +} + +export function validateAdmissionDigests( + receipt: RunAdmissionReceipt, + admission: ExecutionAdmission | undefined, + exactRequestDigest: string, + exactProfileDigest: string, +): void { + if (!admission) return + if (admission.requestDigest !== undefined && admission.requestDigest !== exactRequestDigest) + throw new AppError( + 'ADMISSION_DIGEST_MISMATCH', + 'Provider request digest did not match admission', + ) + if (admission.profileDigest !== undefined && admission.profileDigest !== exactProfileDigest) + throw new AppError( + 'ADMISSION_DIGEST_MISMATCH', + 'Provider profile digest did not match admission', + ) + if ( + admission.capabilitiesDigest !== undefined && + admission.capabilitiesDigest !== receipt.capabilitiesDigest + ) + throw new AppError( + 'ADMISSION_DIGEST_MISMATCH', + 'Provider capability digest did not match admission', + ) + if ( + admission.materializationDigest !== undefined && + admission.materializationDigest !== receipt.materializationDigest + ) + throw new AppError( + 'ADMISSION_DIGEST_MISMATCH', + 'Provider materialization digest did not match admission', + ) +} + +export function resolveSyncAdmission( + context: AdmissionPort, + input: ExecuteTurnInput, +): ExecutionAdmission | undefined { + const rawAdmission = context.execution.admit?.(input) + if (isPromiseLike(rawAdmission)) + throw new AppError('ASYNC_ADMISSION_REQUIRED', 'Run admission must be awaited before dispatch') + return rawAdmission +} + +export async function resolveAsyncAdmission( + context: AdmissionPort, + input: ExecuteTurnInput, +): Promise { + return context.execution.admit ? await context.execution.admit(input) : undefined +} + +export function resolveSyncCapabilities( + context: ExecutionAccess, + input: ExecuteTurnInput, +): RunCapabilities { + const source = context.execution.capabilities + const capabilities = + typeof source === 'function' + ? source(input) + : source === undefined + ? UNKNOWN_RUN_CAPABILITIES + : capabilitiesFromLegacy(source) + if (isPromiseLike(capabilities)) + throw new AppError( + 'ASYNC_ADMISSION_REQUIRED', + 'Provider capabilities must be awaited before dispatch', + ) + if (!capabilities) return UNKNOWN_RUN_CAPABILITIES + assertRunCapabilities(capabilities) + return capabilities +} + +export async function resolveAsyncCapabilities( + context: ExecutionAccess, + input: ExecuteTurnInput, +): Promise { + const source = context.execution.capabilities + const capabilities = + typeof source === 'function' + ? await source(input) + : source === undefined + ? UNKNOWN_RUN_CAPABILITIES + : capabilitiesFromLegacy(source) + if (!capabilities) return UNKNOWN_RUN_CAPABILITIES + assertRunCapabilities(capabilities) + return capabilities +} + +function assertRunCapabilities(value: RunCapabilities): void { + if ( + typeof value !== 'object' || + value === null || + typeof value.streaming?.live !== 'boolean' || + typeof value.streaming?.replay !== 'boolean' || + typeof value.controls?.cancel !== 'boolean' || + typeof value.controls?.queue !== 'boolean' || + typeof value.events?.sequence !== 'boolean' + ) + throw new AppError('ADMISSION_UNAVAILABLE', 'Provider capabilities were malformed') +} + +function capabilitiesFromLegacy(capabilities: ExecutionCapabilities): RunCapabilities { + return { + ...DEFAULT_RUN_CAPABILITIES, + controls: { ...DEFAULT_RUN_CAPABILITIES.controls, cancel: capabilities.cancel }, + } +} + +export function isPromiseLike(value: T | Promise | undefined): value is Promise { + return Boolean(value && typeof (value as Promise).then === 'function') +} diff --git a/src/app/run-admission.ts b/src/app/run-admission.ts new file mode 100644 index 0000000..aaac66a --- /dev/null +++ b/src/app/run-admission.ts @@ -0,0 +1,5 @@ +export { RUN_EFFECT_KIND, runEffectRequest } from './run-admission-request.js' +export { sendRun, sendRunAsync } from './run-admission-dispatch.js' +export { continueNative } from './run-admission-continuation.js' +export { admitRun, admitRunAsync, pendingAdmissionReceipt } from './run-admission-receipt.js' +export { validateNativeProof } from './run-admission-validation.js' diff --git a/src/app/run-continuation.ts b/src/app/run-continuation.ts new file mode 100644 index 0000000..e8a1ac9 --- /dev/null +++ b/src/app/run-continuation.ts @@ -0,0 +1,64 @@ +import { snapshotAgentProfile, type AgentProfile } from '@tangle-network/agent-interface' +import { canonicalDigest } from '../domain/canonical.js' +import { redactProfile } from '../domain/redaction.js' +import type { BraidState } from '../domain/state.js' + +/** + * Return the only provider session that is safe for an ordinary follow-up. + * A session is reusable only at the current branch tip and with the exact + * recorded profile/connection identity from the completed predecessor. + */ +export function continuationSessionFor(input: { + readonly state: BraidState + readonly conversationId: string + readonly branchId: string + readonly profile: Readonly + readonly connectionId?: string +}): string | undefined { + if ( + input.conversationId !== input.state.conversationId || + input.branchId !== input.state.branchId + ) + return undefined + + const previous = input.state.runs + .filter((run) => run.conversationId === input.conversationId && run.branchId === input.branchId) + .at(-1) + if ( + previous === undefined || + previous.status !== 'completed' || + !previous.complete || + previous.providerSessionId === undefined || + !previous.capabilities.sessions.continue + ) + return undefined + + const tip = input.state.messages.at(-1) + if ( + tip === undefined || + tip.conversationId !== input.conversationId || + tip.branchId !== input.branchId || + tip.runId !== previous.id || + !tip.complete + ) + return undefined + + const previousConnection = + previous.connectionId ?? previous.receipt.requested.connectionId ?? undefined + if (previousConnection !== input.connectionId) return undefined + + const currentProfileDigest = canonicalDigest(redactProfile(snapshotAgentProfile(input.profile))) + if (previous.receipt.profileDigest !== currentProfileDigest) return undefined + return previous.providerSessionId +} + +export function branchHasVisibleHistory(input: { + readonly state: BraidState + readonly conversationId: string + readonly branchId: string +}): boolean { + return input.state.messages.some( + (message) => + message.conversationId === input.conversationId && message.branchId === input.branchId, + ) +} diff --git a/src/app/run-controls.ts b/src/app/run-controls.ts new file mode 100644 index 0000000..8a4b6e8 --- /dev/null +++ b/src/app/run-controls.ts @@ -0,0 +1,307 @@ +import { canonicalDigest } from '../domain/canonical.js' +import type { ControlAcknowledgement } from '../ports/execution.js' +import type { ControlEffectRequest, ControlPort, QueuePort } from './application-ports.js' +import type { ControlReceipt, QueueReceipt } from './application-types.js' +import { AppError } from './errors.js' + +export function queueRunInput( + context: QueuePort, + input: { readonly operationId: string; readonly text: string; readonly runId?: string }, +): QueueReceipt { + if (!input.operationId) throw new AppError('OPERATION_ID_REQUIRED', 'queue requires operationId') + if (!input.text.trim()) throw new AppError('EMPTY_MESSAGE', 'Queued input must not be empty') + const runId = input.runId ?? context.currentState().activeRunId + if (!runId) throw new AppError('NO_ACTIVE_RUN', 'Queue input requires an active run') + const run = context.findRun(runId) + const existing = context + .currentState() + .queuedInputs.find((queued) => queued.operationId === input.operationId) + if (existing) { + if (existing.text !== input.text || existing.runId !== runId) + throw new AppError('OPERATION_CONFLICT', `Operation ${input.operationId} has different input`) + return { + operationId: input.operationId, + runId, + position: existing.position, + revision: context.currentState().revision, + } + } + if (!run.capabilities.controls.queue) + throw new AppError('CAPABILITY_UNAVAILABLE', 'Queued input is not supported by this run') + if (context.currentState().queuedInputs.length >= 4096) + throw new AppError('QUEUE_FULL', 'The durable input queue is full') + const position = context.currentState().queuedInputs.length + 1 + const durable = context.commitAndWait({ + kind: 'run.queue.added', + runId, + operationId: input.operationId, + text: input.text, + position, + }) + return { + operationId: input.operationId, + runId, + position, + revision: context.currentState().revision, + ...(durable === undefined ? {} : { completion: Promise.resolve(durable) }), + } +} + +export async function steerRun( + context: ControlPort, + input: { readonly operationId: string; readonly runId?: string; readonly text: string }, +): Promise { + const run = context.findRun(input.runId ?? context.currentState().activeRunId ?? '') + if (!run.capabilities.controls.steer || !context.execution.steerRun) + throw new AppError('CAPABILITY_UNAVAILABLE', 'Live steering is not supported by this run') + const request: ControlEffectRequest = { + operationId: input.operationId, + runId: run.id, + control: 'steer', + text: input.text, + ...(run.providerSessionId === undefined ? {} : { providerSessionId: run.providerSessionId }), + } + return control(context, request, 'steer') +} + +export async function cancelRun( + context: ControlPort, + input: { + readonly operationId: string + readonly runId?: string + readonly reason?: string + readonly terminalStatus?: 'cancelled' | 'aborted' + readonly legacy?: boolean + }, +): Promise { + const run = context.findRun(input.runId ?? context.currentState().activeRunId ?? '') + if (!run.capabilities.controls.cancel && !input.legacy) + throw new AppError('CAPABILITY_UNAVAILABLE', 'This run does not advertise cancellation support') + const request: ControlEffectRequest = { + operationId: input.operationId, + runId: run.id, + control: 'cancel', + ...(run.providerSessionId === undefined ? {} : { providerSessionId: run.providerSessionId }), + ...(input.reason === undefined ? {} : { reason: input.reason }), + } + const receipt = await control(context, request, 'cancel', input.terminalStatus ?? 'cancelled') + return receipt +} + +export async function detachRun( + context: ControlPort, + input: { readonly operationId: string; readonly runId?: string }, +): Promise { + const run = context.findRun(input.runId ?? context.currentState().activeRunId ?? '') + if ( + !run.capabilities.streaming.detach || + !run.capabilities.controls.recreate || + !context.execution.detachRun + ) + throw new AppError('CAPABILITY_UNAVAILABLE', 'This run cannot be detached') + const request: ControlEffectRequest = { + operationId: input.operationId, + runId: run.id, + control: 'detach', + ...(run.providerSessionId === undefined ? {} : { providerSessionId: run.providerSessionId }), + ...(run.lastCursor === undefined ? {} : { cursor: run.lastCursor }), + } + return control(context, request, 'detach') +} + +async function control( + context: ControlPort, + request: ControlEffectRequest, + controlKind: 'cancel' | 'steer' | 'detach', + cancelStatus?: 'cancelled' | 'aborted', +): Promise { + const run = context.findRun(request.runId) + const digest = canonicalDigest({ + control: controlKind, + runId: request.runId, + providerSessionId: request.providerSessionId ?? null, + reason: request.reason ?? null, + text: request.text ?? null, + cursor: request.cursor ?? null, + }) + const previous = context.ledger.getControl(request.operationId) + if (previous) { + if (previous.digest !== digest) + throw new AppError( + 'OPERATION_CONFLICT', + `Operation ${request.operationId} has different input`, + ) + const acknowledgement = await previous.acknowledgement + if (acknowledgement.outcome === 'unknown') { + if (!context.currentEffect(request.operationId)) + return { + operationId: request.operationId, + runId: request.runId, + control: controlKind, + acknowledgement, + status: context.findRun(request.runId).status, + completion: previous.completion, + } + const reconciled = await context.executeControl(request) + const durable = context.commitAndWait({ + kind: 'run.control.acknowledged', + runId: request.runId, + operationId: request.operationId, + control: controlKind, + outcome: reconciled.outcome, + ...(reconciled.detail === undefined ? {} : { detail: reconciled.detail }), + }) + if (durable !== undefined) await durable + context.ledger.setControl(request.operationId, { + ...previous, + acknowledgement: Promise.resolve(reconciled), + completion: Promise.resolve(context.currentState()), + }) + return { + operationId: request.operationId, + runId: request.runId, + control: controlKind, + acknowledgement: reconciled, + status: context.findRun(request.runId).status, + completion: Promise.resolve(context.currentState()), + } + } + return { + operationId: request.operationId, + runId: request.runId, + control: controlKind, + acknowledgement, + status: context.findRun(request.runId).status, + completion: previous.completion, + } + } + + const requested = context.commitAndWait({ + kind: 'run.control.requested', + runId: request.runId, + operationId: request.operationId, + control: controlKind, + digest, + ...(request.reason === undefined ? {} : { reason: request.reason }), + ...(request.text === undefined ? {} : { text: request.text }), + }) + if (requested !== undefined) await requested + if (controlKind === 'cancel') { + const legacyRequested = context.commitAndWait({ + kind: 'run.cancel.requested', + runId: request.runId, + operationId: request.operationId, + ...(request.reason === undefined ? {} : { reason: request.reason }), + }) + if (legacyRequested !== undefined) await legacyRequested + } + if (controlKind === 'cancel') { + context.ledger.markExplicitlyCancelled(request.runId) + context.ledger.setCancelStatus(request.runId, cancelStatus ?? 'cancelled') + } + if (controlKind === 'detach') context.ledger.markDetached(request.runId) + + let resolveAcknowledgement!: (value: ControlAcknowledgement) => void + const acknowledgement = new Promise((resolve) => { + resolveAcknowledgement = resolve + }) + let resolveCompletion!: (value: import('../domain/state.js').BraidState) => void + let rejectCompletion!: (error: unknown) => void + const completion = new Promise((resolve, reject) => { + resolveCompletion = resolve + rejectCompletion = reject + }) + context.ledger.setControl(request.operationId, { + digest, + runId: request.runId, + control: controlKind, + acknowledgement, + completion, + ...(request.providerSessionId === undefined + ? {} + : { providerSessionId: request.providerSessionId }), + ...(request.reason === undefined ? {} : { reason: request.reason }), + ...(request.text === undefined ? {} : { text: request.text }), + ...(request.cursor === undefined ? {} : { cursor: request.cursor }), + }) + if (controlKind === 'cancel' && !context.execution.cancelRun) + context.ledger.getAbort(request.runId)?.abort(new Error(request.reason ?? 'Cancelled')) + void (async () => { + let ack: ControlAcknowledgement + try { + ack = await context.executeControl(request) + } catch { + ack = { operationId: request.operationId, outcome: 'unknown', detail: 'CONTROL_UNKNOWN' } + } + resolveAcknowledgement(ack) + const acknowledged = context.commitAndWait({ + kind: 'run.control.acknowledged', + runId: request.runId, + operationId: request.operationId, + control: controlKind, + outcome: ack.outcome, + ...(ack.detail === undefined ? {} : { detail: ack.detail }), + }) + if (acknowledged !== undefined) await acknowledged + if (ack.outcome === 'accepted' || ack.outcome === 'already-applied') { + if (controlKind === 'cancel') { + context.ledger.getAbort(request.runId)?.abort(new Error(request.reason ?? 'Cancelled')) + if (!context.isTerminal(context.findRun(request.runId).status)) { + const finished = context.commitAndWait({ + kind: 'run.finished', + runId: request.runId, + status: cancelStatus ?? 'cancelled', + finalText: '', + usage: { input: run.inputTokens, output: run.outputTokens }, + error: + ack.detail === undefined || ack.detail === 'CONTROL_ACKNOWLEDGED' + ? 'Cancellation acknowledged by the provider' + : ack.detail, + }) + if (finished !== undefined) await finished + } + } else if (controlKind === 'detach') { + context.ledger.getAbort(request.runId)?.abort(new Error('Detached by user')) + const detached = context.commitAndWait({ + kind: 'run.detached', + runId: request.runId, + ...(request.cursor === undefined ? {} : { cursor: request.cursor }), + ...(ack.detail === undefined ? {} : { detail: ack.detail }), + }) + if (detached !== undefined) await detached + } + resolveCompletion(structuredClone(context.currentState())) + return + } + context.ledger.clearExplicitlyCancelled(request.runId) + context.ledger.clearCancelStatus(request.runId) + context.ledger.clearDetached(request.runId) + if (ack.outcome === 'unknown' && !context.isTerminal(context.findRun(request.runId).status)) { + const unknown = context.commitAndWait({ + kind: 'run.unknown', + runId: request.runId, + detail: + controlKind === 'cancel' + ? unknownCancellationDetail(ack.detail) + : (ack.detail ?? 'Control outcome is unknown'), + }) + if (unknown !== undefined) await unknown + } + resolveCompletion(structuredClone(context.currentState())) + })().catch((error: unknown) => rejectCompletion(error)) + const ack = await acknowledgement + return { + operationId: request.operationId, + runId: request.runId, + control: controlKind, + acknowledgement: ack, + status: context.findRun(request.runId).status, + completion, + } +} + +function unknownCancellationDetail(detail: string | undefined): string { + return detail === undefined || detail.startsWith('CONTROL_') + ? 'Cancellation outcome could not be confirmed by the provider' + : detail +} diff --git a/src/app/run-event-mapper.ts b/src/app/run-event-mapper.ts new file mode 100644 index 0000000..81dafac --- /dev/null +++ b/src/app/run-event-mapper.ts @@ -0,0 +1,416 @@ +import { + type InteractionRequest, + InteractionRequestSchema, + type Part, + type StreamEvent, +} from '@tangle-network/agent-interface' +import type { RuntimeStreamEvent } from '@tangle-network/agent-runtime' +import type { + BraidEvent, + ProviderEventMeta, + RunTerminalStatus, + TurnUsage, +} from '../domain/events.js' +import { redactSensitiveText, redactStructuredValue } from '../domain/redaction.js' +import type { BraidRuntimeEvent } from '../domain/runtime-events.js' +import type { BraidMessagePart, RunStatus } from '../domain/state.js' +import { isCanonicalIsoDateTime } from '../domain/text.js' +import { + finiteNonNegativeNumber, + safeDiagnostic, + safeProviderDiagnostic, + safePublicIdentifier, +} from './provider-values.js' + +function safeText(value: unknown, fallback = ''): string { + return typeof value === 'string' ? redactSensitiveText(value) : fallback +} + +function safeValue(value: unknown): unknown { + return redactStructuredValue(value, undefined, { + maxDepth: 6, + maxItems: 128, + maxBytes: 32 * 1024, + }) +} + +function safeInteractionRequest(value: unknown, runId: string): InteractionRequest { + const parsed = InteractionRequestSchema.safeParse(value) + if (!parsed.success) return invalidInteractionRequest(runId) + const bounded = InteractionRequestSchema.safeParse(safeValue(parsed.data)) + return bounded.success ? bounded.data : invalidInteractionRequest(runId) +} + +function invalidInteractionRequest(runId: string): InteractionRequest { + return { + id: `${runId}:interaction:invalid`, + kind: 'provider.invalid.interaction', + title: 'Provider interaction unavailable', + answerSpec: { fields: [] }, + } +} + +export function usageFromMetadata(metadata: Record | undefined): TurnUsage { + const tokenUsage = + metadata?.tokenUsage && typeof metadata.tokenUsage === 'object' + ? (metadata.tokenUsage as Record) + : {} + const input = finiteNonNegativeNumber( + typeof tokenUsage.input === 'number' ? tokenUsage.input : tokenUsage.inputTokens, + ) + const output = finiteNonNegativeNumber( + typeof tokenUsage.output === 'number' ? tokenUsage.output : tokenUsage.outputTokens, + ) + const reasoning = + typeof tokenUsage.reasoningTokens === 'number' ? tokenUsage.reasoningTokens : undefined + const costUsd = finiteNonNegativeNumber( + typeof metadata?.costUsd === 'number' ? metadata.costUsd : tokenUsage.cost, + undefined, + ) + const model = safePublicIdentifier(metadata?.model) + return { + input, + output, + ...(reasoning === undefined ? {} : { reasoning }), + ...(costUsd === undefined ? {} : { costUsd }), + ...(model === undefined ? {} : { model }), + } +} + +function usageFromLlm(event: Extract): TurnUsage { + const costUsd = finiteNonNegativeNumber(event.costUsd, undefined) + const model = safePublicIdentifier(event.model) + return { + input: finiteNonNegativeNumber(event.tokensIn), + output: finiteNonNegativeNumber(event.tokensOut), + ...(costUsd === undefined ? {} : { costUsd }), + ...(model === undefined ? {} : { model }), + } +} + +export function providerMeta( + eventId: string, + sequence: number, + event: BraidRuntimeEvent, + receivedAt: string, +): ProviderEventMeta { + const timestamp = + 'timestamp' in event && typeof event.timestamp === 'string' ? event.timestamp : undefined + return safeProviderMeta( + { + eventId: safePublicIdentifier(eventId) ?? `event-${sequence}`, + providerSequence: sequence, + ...(timestamp === undefined ? {} : { occurredAt: timestamp }), + receivedAt, + }, + sequence, + ) +} + +export function safeProviderMeta( + value: ProviderEventMeta, + fallbackSequence: number, +): ProviderEventMeta { + const cursor = value.cursor === undefined ? undefined : safePublicIdentifier(value.cursor) + const occurredAt = + value.occurredAt !== undefined && isCanonicalIsoDateTime(value.occurredAt) + ? value.occurredAt + : undefined + const receivedAt = + value.receivedAt !== undefined && isCanonicalIsoDateTime(value.receivedAt) + ? value.receivedAt + : undefined + return { + eventId: safePublicIdentifier(value.eventId) ?? `event-${fallbackSequence}`, + providerSequence: + Number.isSafeInteger(value.providerSequence) && value.providerSequence > 0 + ? value.providerSequence + : fallbackSequence, + ...(cursor === undefined ? {} : { cursor }), + ...(occurredAt === undefined ? {} : { occurredAt }), + ...(receivedAt === undefined ? {} : { receivedAt }), + } +} + +export function terminalStatus(status: string): RunTerminalStatus { + switch (status) { + case 'completed': + case 'failed': + case 'aborted': + case 'blocked': + case 'cancelled': + case 'expired': + case 'unknown': + return status + default: + return 'failed' + } +} + +function canonicalPart(part: Part, source: ProviderEventMeta): BraidMessagePart { + const sourceInfo = { + eventId: source.eventId, + sequence: source.providerSequence, + ...(source.cursor === undefined ? {} : { cursor: source.cursor }), + ...(source.occurredAt === undefined ? {} : { occurredAt: source.occurredAt }), + } + switch (part.type) { + case 'text': + return { + id: safePublicIdentifier(part.id) ?? 'part-text', + kind: 'text', + text: safeText(part.text), + source: sourceInfo, + } + case 'reasoning': + return { + id: safePublicIdentifier(part.id) ?? 'part-reasoning', + kind: 'reasoning', + text: safeText(part.text), + source: sourceInfo, + } + case 'tool': { + const state = part.state + return { + id: safePublicIdentifier(part.id) ?? 'part-tool', + kind: + state.status === 'completed' || state.status === 'error' || state.status === 'failed' + ? 'tool-result' + : 'tool-call', + toolName: safePublicIdentifier(part.tool) ?? 'tool', + ...(part.callID === undefined + ? {} + : { callId: safePublicIdentifier(part.callID) ?? 'call' }), + status: state.status, + input: safeValue(state.input), + ...('output' in state && state.output !== undefined + ? { result: safeValue(state.output) } + : {}), + ...('error' in state && state.error !== undefined + ? { error: safeProviderDiagnostic(state.error, 'RUNTIME_TOOL_ERROR') } + : {}), + source: sourceInfo, + } + } + case 'file': + return { + id: safePublicIdentifier(part.id) ?? 'part-artifact', + kind: 'artifact', + ...(part.filename === undefined ? {} : { title: safeText(part.filename) }), + ...(part.mediaType === undefined ? {} : { mimeType: safeText(part.mediaType) }), + ...(part.url === undefined ? {} : { uri: safeText(part.url) }), + source: sourceInfo, + } + case 'subtask': + return { + id: safePublicIdentifier(part.id) ?? 'part-subtask', + kind: 'system', + title: safeText(part.description), + text: safeText(part.prompt), + metadata: { agent: safePublicIdentifier(part.agent) ?? 'agent' }, + source: sourceInfo, + } + default: { + const exhaustive: never = part + return exhaustive + } + } +} + +function statusFromCanonical( + status: Extract['status'], +): RunStatus { + switch (status) { + case 'started': + return 'starting' + case 'processing': + return 'streaming' + case 'completed': + return 'running' + case 'failed': + return 'failed' + default: { + const exhaustive: never = status + return exhaustive + } + } +} + +export function providerEventFor( + runId: string, + event: BraidRuntimeEvent, + provider: ProviderEventMeta, +): BraidEvent { + const detailEvent = + event.type === 'raw' + ? ({ type: 'raw', event: { redacted: true } } as BraidRuntimeEvent) + : event.type === 'unknown' + ? ({ type: 'unknown', payload: { redacted: true } } as BraidRuntimeEvent) + : event + switch (event.type) { + case 'text_delta': + return { kind: 'run.text.delta', runId, text: safeText(event.text), provider } + case 'reasoning_delta': + return { + kind: 'run.reasoning.delta', + runId, + partId: `${runId}:reasoning`, + text: safeText(event.text), + provider, + } + case 'tool_call': + return { + kind: 'run.tool.call', + runId, + partId: + safePublicIdentifier(event.toolCallId ?? `${runId}:tool:${provider.eventId}`) ?? + `${runId}:tool`, + toolName: safePublicIdentifier(event.toolName) ?? 'tool', + ...(event.toolCallId === undefined + ? {} + : { callId: safePublicIdentifier(event.toolCallId) ?? 'call' }), + ...(event.args === undefined ? {} : { input: safeValue(event.args) }), + provider, + } + case 'tool_result': + return { + kind: 'run.tool.result', + runId, + partId: + safePublicIdentifier(event.toolCallId ?? `${runId}:tool:${provider.eventId}`) ?? + `${runId}:tool`, + toolName: safePublicIdentifier(event.toolName) ?? 'tool', + ...(event.toolCallId === undefined + ? {} + : { callId: safePublicIdentifier(event.toolCallId) ?? 'call' }), + ...(event.result === undefined ? {} : { result: safeValue(event.result) }), + provider, + } + case 'llm_call': + return { kind: 'run.usage', runId, usage: usageFromLlm(event), provider } + case 'artifact': + return { + kind: 'run.artifact', + runId, + artifactId: safePublicIdentifier(event.artifactId) ?? `${runId}:artifact`, + ...(event.name === undefined ? {} : { name: safeText(event.name) }), + ...(event.mimeType === undefined ? {} : { mimeType: safeText(event.mimeType) }), + ...(event.uri === undefined ? {} : { uri: safeText(event.uri) }), + ...(event.metadata === undefined + ? {} + : { metadata: safeValue(event.metadata) as Readonly> }), + provider, + } + case 'proposal_created': + return { + kind: 'run.proposal', + runId, + proposalId: safePublicIdentifier(event.proposalId) ?? `${runId}:proposal`, + title: safeText(event.title), + ...(event.status === undefined ? {} : { status: event.status }), + provider, + } + case 'backend_error': + return { + kind: 'run.error', + runId, + message: safeProviderDiagnostic(event.message, 'RUNTIME_BACKEND_ERROR'), + recoverable: event.recoverable, + provider, + } + case 'final': + return { + kind: 'run.finished', + runId, + status: terminalStatus(event.status), + finalText: safeText(event.text), + usage: usageFromMetadata(event.metadata), + ...(event.error === undefined + ? {} + : { error: safeDiagnostic(event.error.message, 'RUNTIME_FINAL_ERROR') }), + ...(event.reason === undefined + ? {} + : { reason: safeProviderDiagnostic(event.reason, 'RUNTIME_FINAL_REASON') }), + provider, + } + case 'message.part.updated': { + const part = canonicalPart(event.part, provider) + return { + kind: 'run.part.updated', + runId, + part, + ...(event.delta === undefined ? {} : { delta: safeText(event.delta) }), + provider, + } + } + case 'interaction': + return { + kind: 'run.interaction', + runId, + request: safeInteractionRequest(event.request, runId), + provider, + } + case 'interaction.cancel': + return { + kind: 'run.interaction.cancelled', + runId, + interactionId: safePublicIdentifier(event.id) ?? `${runId}:interaction`, + ...(event.reason === undefined ? {} : { reason: safeText(event.reason) }), + provider, + } + case 'warning': + return { + kind: 'run.warning', + runId, + code: safeProviderDiagnostic(event.code, 'RUNTIME_WARNING'), + message: safeProviderDiagnostic(event.message, 'RUNTIME_WARNING'), + provider, + } + case 'status': + return { + kind: 'run.status.changed', + runId, + status: statusFromCanonical(event.status), + ...(event.detail === undefined + ? {} + : { detail: safeProviderDiagnostic(event.detail, 'RUNTIME_STATUS') }), + provider, + } + case 'unknown': + return { + kind: 'run.provider.event', + runId, + envelope: { + runId, + eventId: provider.eventId, + sequence: provider.providerSequence, + receivedAt: provider.receivedAt ?? new Date().toISOString(), + event: safeValue(detailEvent) as BraidRuntimeEvent, + }, + provider, + } + default: + return { + kind: 'run.provider.event', + runId, + envelope: { + runId, + eventId: provider.eventId, + sequence: provider.providerSequence, + ...(provider.cursor === undefined ? {} : { cursor: provider.cursor }), + ...(provider.occurredAt === undefined ? {} : { occurredAt: provider.occurredAt }), + receivedAt: provider.receivedAt ?? new Date().toISOString(), + event: safeValue(detailEvent) as BraidRuntimeEvent, + }, + provider, + } + } +} + +export function eventIdFor(runId: string, event: BraidRuntimeEvent, sequence: number): string { + if (event.type === 'message.part.updated') return `${runId}:part:${event.part.id}:${sequence}` + if (event.type === 'session.updated') return `${runId}:session:${event.sessionId}:${sequence}` + if (event.type === 'interaction') return `${runId}:interaction:${event.request.id}` + if (event.type === 'interaction.cancel') return `${runId}:interaction-cancel:${event.id}` + return `${runId}:runtime:${sequence}` +} diff --git a/src/app/run-execution-snapshot.ts b/src/app/run-execution-snapshot.ts new file mode 100644 index 0000000..037b6f2 --- /dev/null +++ b/src/app/run-execution-snapshot.ts @@ -0,0 +1,76 @@ +import { type AgentProfile, snapshotAgentProfile } from '@tangle-network/agent-interface' +import type { + ContextTransferReceipt, + NativeContextBoundaryProof, + PortableContextPlan, +} from '../domain/receipts.js' +import type { BraidState } from '../domain/state.js' +import type { SendInput } from './application-types.js' +import { continuationSessionFor } from './run-continuation.js' + +/** + * The private execution payload captured before a run can cross an async + * boundary. Its profile and all caller-owned nested values are independent + * frozen copies; the durable receipt is intentionally a separate redacted + * audit record. + */ +export interface RunExecutionSnapshot { + readonly operationId: string + readonly text: string + readonly conversationId: string + readonly branchId: string + readonly profile: Readonly + readonly connectionId?: string + readonly workspaceRoot?: string + readonly sessionId?: string + /** Distinguishes the private linear continuation from caller-supplied reuse. */ + readonly sessionSource?: 'caller' | 'continuation' + readonly contextPlan?: PortableContextPlan + readonly contextTransfer?: ContextTransferReceipt + readonly nativeContextBoundaryProof?: NativeContextBoundaryProof +} + +export function snapshotRunExecution( + input: SendInput, + state: BraidState, + profile: Readonly, + connectionId: string | undefined, +): RunExecutionSnapshot { + const snapshot = { + operationId: input.operationId, + text: input.text, + conversationId: input.conversationId ?? state.conversationId, + branchId: input.branchId ?? state.branchId, + profile: snapshotAgentProfile(profile), + ...(connectionId === undefined ? {} : { connectionId }), + ...(state.workspace === null ? {} : { workspaceRoot: state.workspace }), + ...(input.sessionId === undefined + ? (() => { + const sessionId = continuationSessionFor({ + state, + conversationId: input.conversationId ?? state.conversationId, + branchId: input.branchId ?? state.branchId, + profile, + ...(connectionId === undefined ? {} : { connectionId }), + }) + return sessionId === undefined + ? {} + : { sessionId, sessionSource: 'continuation' as const } + })() + : { sessionId: input.sessionId, sessionSource: 'caller' as const }), + ...(input.contextPlan === undefined ? {} : { contextPlan: input.contextPlan }), + ...(input.contextTransfer === undefined ? {} : { contextTransfer: input.contextTransfer }), + ...(input.nativeContextBoundaryProof === undefined + ? {} + : { nativeContextBoundaryProof: input.nativeContextBoundaryProof }), + } + return freezeDeep(structuredClone(snapshot)) +} + +function freezeDeep(value: T): T { + if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) { + for (const child of Object.values(value)) freezeDeep(child) + Object.freeze(value) + } + return value +} diff --git a/src/app/run-execution.ts b/src/app/run-execution.ts new file mode 100644 index 0000000..4afd2a1 --- /dev/null +++ b/src/app/run-execution.ts @@ -0,0 +1,241 @@ +import { isRuntimeEventEnvelope } from '../domain/runtime-events.js' +import type { ExecuteTurnInput } from '../ports/execution.js' +import type { ExecutionRunPort, SendAccess } from './application-ports.js' +import { safeRuntimeDiagnostic } from './provider-values.js' +import { eventIdFor, providerMeta } from './run-event-mapper.js' +import type { RunExecutionSnapshot } from './run-execution-snapshot.js' +import { reconnectRun } from './run-replay.js' + +export async function executeRun( + context: ExecutionRunPort, + input: RunExecutionSnapshot, + admission: import('../domain/receipts.js').RunAdmissionReceipt, + abort: AbortController, +): Promise { + let terminalSeen = false + let eventSequence = 0 + try { + const currentRun = context.currentState().runs.find((run) => run.id === admission.runId) + const runtimeInput: ExecuteTurnInput = { + operationId: input.operationId, + runId: admission.runId, + text: input.text, + profile: input.profile, + ...(input.connectionId === undefined ? {} : { connectionId: input.connectionId }), + ...(input.workspaceRoot === undefined ? {} : { workspaceRoot: input.workspaceRoot }), + signal: abort.signal, + ...(input.sessionId === undefined ? {} : { sessionId: input.sessionId }), + ...(currentRun?.lastCursor === undefined ? {} : { after: currentRun.lastCursor }), + ...(input.contextPlan === undefined ? {} : { contextBoundary: input.contextPlan.digest }), + } + if (context.ledger.isDetached(admission.runId)) return + for await (const runtimeEvent of context.execution.streamTurn(runtimeInput)) { + if (context.ledger.isDetached(admission.runId)) break + if (context.isTerminal(context.findRun(admission.runId).status)) break + if (terminalSeen) break + if (isRuntimeEventEnvelope(runtimeEvent)) { + const result = await context.ingestRuntimeEvent(runtimeEvent) + if (result.accepted && runtimeEvent.event.type === 'final') { + terminalSeen = true + await context.flush() + } + continue + } + if ( + runtimeEvent.type === 'session_created' || + runtimeEvent.type === 'session_resumed' || + runtimeEvent.type === 'backend_start' || + runtimeEvent.type === 'backend_end' + ) + continue + eventSequence += 1 + const receivedAt = context.clock.now() + const eventId = eventIdFor(admission.runId, runtimeEvent, eventSequence) + const meta = providerMeta(eventId, eventSequence, runtimeEvent, receivedAt) + const result = await context.ingestRuntimeEvent({ + runId: admission.runId, + eventId, + sequence: eventSequence, + receivedAt, + ...(meta.occurredAt === undefined ? {} : { occurredAt: meta.occurredAt }), + event: runtimeEvent, + }) + if (result.accepted && runtimeEvent.type === 'final') { + terminalSeen = true + await context.flush() + } + } + if (context.ledger.isDetached(admission.runId)) return + if (!terminalSeen) await finishWithoutTerminal(context, input, admission, abort) + } catch (error) { + if (context.ledger.isDetached(admission.runId)) return + const message = safeRuntimeDiagnostic( + error instanceof Error ? error.message : String(error), + 'RUNTIME_EXECUTION_ERROR', + ) + if (terminalSeen) throw error + if (!terminalSeen) await finishAfterError(context, input, admission, abort, message) + } finally { + context.ledger.deleteAbort(admission.runId) + context.ledger.clearExplicitlyCancelled(admission.runId) + context.ledger.clearCancelStatus(admission.runId) + const finalRun = context.findRun(admission.runId) + if ( + context.isTerminal(finalRun.status) && + (finalRun.status !== 'unknown' || + (finalRun.capabilities.controls.cancel && context.execution.cancelRun === undefined)) && + !context.currentState().missingHistory.some((range) => range.runId === finalRun.id) + ) + await drainQueue(context) + } +} + +async function finishWithoutTerminal( + context: ExecutionRunPort, + input: import('./application-types.js').SendInput, + admission: import('../domain/receipts.js').RunAdmissionReceipt, + abort: AbortController, +): Promise { + if (context.isTerminal(context.findRun(admission.runId).status)) return + if ( + !abort.signal.aborted && + admission.capabilities.streaming.replay && + admission.capabilities.events.cursor && + context.execution.reconnect + ) { + await reconnectRun(context, { + operationId: `${input.operationId}:reconnect`, + runId: admission.runId, + }) + return + } + if (abort.signal.aborted || context.ledger.isExplicitlyCancelled(admission.runId)) { + const pendingCancel = context.ledger.controlForRun(admission.runId, 'cancel') + if (pendingCancel) { + const acknowledgement = await pendingCancel.acknowledgement + if (acknowledgement.outcome === 'accepted' || acknowledgement.outcome === 'already-applied') + return + if (!context.isTerminal(context.findRun(admission.runId).status)) { + await commitRequiredRecovery(context, { + kind: 'run.unknown', + runId: admission.runId, + detail: acknowledgement.detail ?? 'Cancellation outcome is unknown', + }) + } + return + } + const status = context.ledger.getCancelStatus(admission.runId) ?? 'cancelled' + await commitRequiredRecovery(context, { + kind: 'run.finished', + runId: admission.runId, + status: + abort.signal.aborted && !context.ledger.isExplicitlyCancelled(admission.runId) + ? 'aborted' + : status, + finalText: '', + usage: { input: 0, output: 0 }, + error: + abort.signal.reason instanceof Error ? abort.signal.reason.message : 'Cancelled by user', + }) + } else { + await context.commitAndWait({ + kind: 'run.unknown', + runId: admission.runId, + detail: 'The normalized event stream ended without a terminal event', + }) + } +} + +async function finishAfterError( + context: ExecutionRunPort, + input: import('./application-types.js').SendInput, + admission: import('../domain/receipts.js').RunAdmissionReceipt, + abort: AbortController, + message: string, +): Promise { + if (context.isTerminal(context.findRun(admission.runId).status)) return + if ( + !abort.signal.aborted && + admission.capabilities.streaming.replay && + admission.capabilities.events.cursor && + context.execution.reconnect + ) { + await reconnectRun(context, { + operationId: `${input.operationId}:reconnect`, + runId: admission.runId, + }) + return + } + if (abort.signal.aborted || context.ledger.isExplicitlyCancelled(admission.runId)) { + const pendingCancel = context.ledger.controlForRun(admission.runId, 'cancel') + if (pendingCancel) { + const acknowledgement = await pendingCancel.acknowledgement + if (acknowledgement.outcome === 'accepted' || acknowledgement.outcome === 'already-applied') + return + if (!context.isTerminal(context.findRun(admission.runId).status)) { + await commitRequiredRecovery(context, { + kind: 'run.unknown', + runId: admission.runId, + detail: acknowledgement.detail ?? 'Cancellation outcome is unknown', + }) + } + return + } + const status = context.ledger.getCancelStatus(admission.runId) ?? 'cancelled' + await commitRequiredRecovery(context, { + kind: 'run.finished', + runId: admission.runId, + status: + abort.signal.aborted && !context.ledger.isExplicitlyCancelled(admission.runId) + ? 'aborted' + : status, + finalText: '', + usage: { input: 0, output: 0 }, + error: message, + }) + } else { + await commitRequiredRecovery(context, { + kind: 'run.finished', + runId: admission.runId, + status: 'failed', + finalText: '', + usage: { input: 0, output: 0 }, + error: message, + }) + } +} + +async function commitRequiredRecovery( + context: ExecutionRunPort, + event: import('../domain/events.js').BraidEvent, +): Promise { + const commit = context.commitAndWaitRecovery ?? context.commitAndWait + const result = commit(event) + if (result !== undefined) await result +} + +export async function drainQueue(context: ExecutionRunPort & SendAccess): Promise { + const next = context.currentState().queuedInputs[0] + if (!next || context.currentState().activeRunId) return + if (context.currentState().missingHistory.some((range) => range.runId === next.runId)) return + const completed = context.findRun(next.runId) + if ( + (completed.status === 'unknown' && + (!completed.capabilities.controls.cancel || context.execution.cancelRun !== undefined)) || + !context.isTerminal(completed.status) + ) + return + if (!context.ledger.claimQueueDrain(next.operationId)) return + try { + const receipt = context.send({ operationId: next.operationId, text: next.text }) + if (receipt.admissionReady !== undefined) await receipt.admissionReady + const removed = context.commitAndWait({ + kind: 'run.queue.removed', + runId: next.runId, + operationId: next.operationId, + }) + if (removed !== undefined) await removed + } finally { + context.ledger.releaseQueueDrain(next.operationId) + } +} diff --git a/src/app/run-ingestion.ts b/src/app/run-ingestion.ts new file mode 100644 index 0000000..7d0c769 --- /dev/null +++ b/src/app/run-ingestion.ts @@ -0,0 +1,113 @@ +import type { ProviderEventMeta } from '../domain/events.js' +import type { RuntimeEventEnvelope } from '../domain/runtime-events.js' +import type { IngestionPort, RuntimeEventIngestionResult } from './application-ports.js' +import { providerEventFor, safeProviderMeta } from './run-event-mapper.js' +import { AppError } from './errors.js' +import { isCanonicalIsoDateTime } from '../domain/text.js' +import { safePublicIdentifier } from './provider-values.js' + +export function ingestRuntimeEvent( + context: IngestionPort, + envelope: RuntimeEventEnvelope, +): RuntimeEventIngestionResult | Promise { + if ( + !safePublicIdentifier(envelope.eventId) || + !Number.isSafeInteger(envelope.sequence) || + envelope.sequence < 1 || + envelope.sequence > 1_000_000_000 || + !isCanonicalIsoDateTime(envelope.receivedAt) || + (envelope.cursor !== undefined && !safePublicIdentifier(envelope.cursor)) || + (envelope.occurredAt !== undefined && !isCanonicalIsoDateTime(envelope.occurredAt)) + ) + throw new AppError('INVALID_PROVIDER_EVENT', 'Provider event identity or timestamp is invalid') + const key = `${envelope.runId}:${envelope.eventId}` + if (context.ledger.hasProviderEvent(key)) return { accepted: false, duplicate: true } + const run = context.findRun(envelope.runId) + const expected = run.lastProviderSequence + 1 + if (envelope.sequence < expected) { + const isMissing = context + .currentState() + .missingHistory.some( + (range) => + range.runId === run.id && + envelope.sequence >= range.fromSequence && + envelope.sequence <= (range.toSequence ?? range.fromSequence), + ) + if (!isMissing) return { accepted: false, duplicate: true } + return afterCommit(context.commitAndWait(eventFromEnvelope(envelope)), { + accepted: true, + duplicate: false, + }) + } + if (envelope.sequence > expected) { + const gap = { from: expected, to: envelope.sequence - 1 } + const commits: Array> = [] + if ( + !context + .currentState() + .missingHistory.some( + (range) => + range.runId === run.id && + range.fromSequence <= gap.to && + (range.toSequence ?? range.fromSequence) >= gap.from, + ) + ) + commits.push( + context.commitAndWait({ + kind: 'history.missing', + range: { runId: run.id, fromSequence: gap.from, toSequence: gap.to, reason: 'gap' }, + }), + ) + if (run.status !== 'reconnecting') + commits.push( + context.commitAndWait({ + kind: 'run.reconnecting', + runId: run.id, + ...(run.lastCursor === undefined ? {} : { after: run.lastCursor }), + }), + ) + return afterCommits(commits, { accepted: false, duplicate: false, sequenceGap: gap }) + } + return afterCommit(context.commitAndWait(eventFromEnvelope(envelope)), { + accepted: true, + duplicate: false, + }) +} + +function afterCommit( + commit: void | Promise, + result: RuntimeEventIngestionResult, +): RuntimeEventIngestionResult | Promise { + return isPromiseLike(commit) ? commit.then(() => result) : result +} + +function afterCommits( + commits: readonly (void | Promise)[], + result: RuntimeEventIngestionResult, +): RuntimeEventIngestionResult | Promise { + const pending = commits.filter(isPromiseLike) + return pending.length === 0 ? result : Promise.all(pending).then(() => result) +} + +function isPromiseLike(value: unknown): value is Promise { + return ( + typeof value === 'object' && + value !== null && + 'then' in value && + typeof value.then === 'function' + ) +} + +function eventFromEnvelope(envelope: RuntimeEventEnvelope) { + const meta: ProviderEventMeta = safeProviderMeta( + { + eventId: envelope.eventId, + providerSequence: envelope.sequence, + ...(envelope.cursor === undefined ? {} : { cursor: envelope.cursor }), + ...(envelope.occurredAt === undefined ? {} : { occurredAt: envelope.occurredAt }), + receivedAt: envelope.receivedAt, + }, + envelope.sequence, + ) + return providerEventFor(envelope.runId, envelope.event, meta) +} diff --git a/src/app/run-ledger.ts b/src/app/run-ledger.ts new file mode 100644 index 0000000..068af98 --- /dev/null +++ b/src/app/run-ledger.ts @@ -0,0 +1,109 @@ +import type { + ControlOperationRecord, + OperationRecord, + ShutdownRecord, +} from './application-types.js' +import type { BraidControlKind, BraidEventEnvelope } from '../domain/events.js' +import type { BraidState } from '../domain/state.js' + +export interface InteractionOperationRecord { + readonly digest: string + readonly completion: Promise +} + +export interface RunLedger { + readonly getOperation: (operationId: string) => OperationRecord | undefined + readonly setOperation: (record: OperationRecord) => void + readonly getControl: (operationId: string) => ControlOperationRecord | undefined + readonly setControl: (operationId: string, record: ControlOperationRecord) => void + readonly getInteraction: (operationId: string) => InteractionOperationRecord | undefined + readonly setInteraction: (operationId: string, record: InteractionOperationRecord) => void + readonly getShutdown: (operationId: string) => ShutdownRecord | undefined + readonly setShutdown: (operationId: string, record: ShutdownRecord) => void + readonly operationForRun: (runId: string) => OperationRecord | undefined + readonly controlForRun: ( + runId: string, + kind?: BraidControlKind, + ) => ControlOperationRecord | undefined + readonly getAbort: (runId: string) => AbortController | undefined + readonly setAbort: (runId: string, controller: AbortController) => void + readonly deleteAbort: (runId: string) => void + readonly isDetached: (runId: string) => boolean + readonly markDetached: (runId: string) => void + readonly clearDetached: (runId: string) => void + readonly isExplicitlyCancelled: (runId: string) => boolean + readonly markExplicitlyCancelled: (runId: string) => void + readonly clearExplicitlyCancelled: (runId: string) => void + readonly getCancelStatus: (runId: string) => 'cancelled' | 'aborted' | undefined + readonly setCancelStatus: (runId: string, status: 'cancelled' | 'aborted') => void + readonly clearCancelStatus: (runId: string) => void + readonly claimQueueDrain: (operationId: string) => boolean + readonly releaseQueueDrain: (operationId: string) => void + readonly hasProviderEvent: (key: string) => boolean + readonly addProviderEvent: (key: string) => void + readonly restore: (events: readonly BraidEventEnvelope[], state: () => BraidState) => void +} + +export function createRunLedger(): RunLedger { + const operations = new Map() + const controls = new Map() + const interactions = new Map() + const shutdowns = new Map() + const aborts = new Map() + const detached = new Set() + const explicitCancellations = new Set() + const cancelStatuses = new Map() + const queueDrains = new Set() + const providerEvents = new Set() + + return { + getOperation: (operationId) => operations.get(operationId), + setOperation: (record) => operations.set(record.admission.operationId, record), + getControl: (operationId) => controls.get(operationId), + setControl: (operationId, record) => controls.set(operationId, record), + getInteraction: (operationId) => interactions.get(operationId), + setInteraction: (operationId, record) => interactions.set(operationId, record), + getShutdown: (operationId) => shutdowns.get(operationId), + setShutdown: (operationId, record) => shutdowns.set(operationId, record), + operationForRun: (runId) => [...operations.values()].find((record) => record.runId === runId), + controlForRun: (runId, kind) => + [...controls.values()].find( + (record) => record.runId === runId && (kind === undefined || record.control === kind), + ), + getAbort: (runId) => aborts.get(runId), + setAbort: (runId, controller) => aborts.set(runId, controller), + deleteAbort: (runId) => aborts.delete(runId), + isDetached: (runId) => detached.has(runId), + markDetached: (runId) => detached.add(runId), + clearDetached: (runId) => detached.delete(runId), + isExplicitlyCancelled: (runId) => explicitCancellations.has(runId), + markExplicitlyCancelled: (runId) => explicitCancellations.add(runId), + clearExplicitlyCancelled: (runId) => explicitCancellations.delete(runId), + getCancelStatus: (runId) => cancelStatuses.get(runId), + setCancelStatus: (runId, status) => cancelStatuses.set(runId, status), + clearCancelStatus: (runId) => cancelStatuses.delete(runId), + claimQueueDrain: (operationId) => { + if (queueDrains.has(operationId)) return false + queueDrains.add(operationId) + return true + }, + releaseQueueDrain: (operationId) => queueDrains.delete(operationId), + hasProviderEvent: (key) => providerEvents.has(key), + addProviderEvent: (key) => providerEvents.add(key), + restore: (events, state) => { + for (const envelope of events) { + const event = envelope.event + if (event.kind === 'run.requested' && event.receipt) { + const run = state().runs.find((candidate) => candidate.id === event.runId) + if (run && !isTerminal(run.status)) aborts.set(run.id, new AbortController()) + } + } + }, + } +} + +function isTerminal(status: BraidState['runs'][number]['status']): boolean { + return ['completed', 'failed', 'aborted', 'cancelled', 'blocked', 'expired', 'unknown'].includes( + status, + ) +} diff --git a/src/app/run-replay.ts b/src/app/run-replay.ts new file mode 100644 index 0000000..a7f1469 --- /dev/null +++ b/src/app/run-replay.ts @@ -0,0 +1,192 @@ +import { canonicalDigest } from '../domain/canonical.js' +import type { BraidState } from '../domain/state.js' +import type { ProviderRunSnapshot } from '../ports/execution.js' +import type { ReconnectInput, ReplayPort } from './application-ports.js' +import { AppError } from './errors.js' +import { safeSnapshotDetail, safeSnapshotText, safeSnapshotUsage } from './provider-snapshot.js' + +export async function reconnectRun( + context: ReplayPort, + input: ReconnectInput, +): Promise { + const run = context.findRun(input.runId) + if ( + !run.capabilities.streaming.replay || + !run.capabilities.events.cursor || + !context.execution.reconnect + ) + throw new AppError( + 'CAPABILITY_UNAVAILABLE', + 'The selected execution path does not report replay with a stable cursor', + ) + await context.commitAndWait({ + kind: 'run.reconnecting', + runId: run.id, + ...(run.lastCursor === undefined ? {} : { after: run.lastCursor }), + }) + const abort = context.ledger.getAbort(run.id) ?? new AbortController() + context.ledger.setAbort(run.id, abort) + context.ledger.clearDetached(run.id) + try { + let sawTerminal = false + for await (const envelope of context.execution.reconnect({ + runId: run.id, + ...(run.lastCursor === undefined ? {} : { after: run.lastCursor }), + signal: abort.signal, + })) { + const result = await context.ingestRuntimeEvent(envelope) + if (result.accepted && envelope.event.type === 'final') sawTerminal = true + } + if (!sawTerminal && !context.isTerminal(context.findRun(run.id).status)) + await reconcileRun(context, { runId: run.id, operationId: input.operationId }) + } catch (error) { + if (!context.isTerminal(context.findRun(run.id).status)) + await context.commitAndWait({ + kind: 'run.unknown', + runId: run.id, + detail: safeSnapshotDetail( + error instanceof Error ? error.message : error, + 'RUNTIME_RECONCILIATION_ERROR', + ), + }) + } + return structuredClone(context.currentState()) +} + +export async function reconcileRun( + context: ReplayPort, + input: ReconnectInput, +): Promise { + const run = context.findRun(input.runId) + if (!run.capabilities.controls.status || !context.execution.status) { + if (!context.isTerminal(run.status)) + await context.commitAndWait({ + kind: 'run.unknown', + runId: run.id, + detail: 'The execution path cannot reconcile provider state', + }) + return structuredClone(context.currentState()) + } + let snapshot: ProviderRunSnapshot | null + try { + snapshot = await context.execution.status({ runId: run.id }) + } catch (error) { + if (!context.isTerminal(run.status)) + await context.commitAndWait({ + kind: 'run.unknown', + runId: run.id, + detail: safeSnapshotDetail( + error instanceof Error ? error.message : error, + 'RUNTIME_STATUS_ERROR', + ), + }) + return structuredClone(context.currentState()) + } + if (!snapshot) { + if (!context.isTerminal(run.status) || run.status === 'unknown') + await context.commitAndWait({ + kind: 'run.unknown', + runId: run.id, + detail: 'The provider returned no run record', + }) + return structuredClone(context.currentState()) + } + if ( + snapshot.runId !== run.id || + (run.providerSessionId !== undefined && snapshot.sessionId !== run.providerSessionId) + ) { + if (!context.isTerminal(run.status) || run.status === 'unknown') + await context.commitAndWait({ + kind: 'run.unknown', + runId: run.id, + detail: 'Provider reconciliation returned no matching run identity', + }) + return structuredClone(context.currentState()) + } + if (context.isTerminal(run.status) && run.status !== 'unknown') + return structuredClone(context.currentState()) + await context.commitAndWait({ + kind: 'run.reconnecting', + runId: run.id, + ...(run.lastCursor === undefined ? {} : { after: run.lastCursor }), + }) + const reconciliationFrom = context.findRun(run.id).status + if (snapshot.status === 'unknown') { + if (!context.isTerminal(run.status) || run.status === 'unknown') + await context.commitAndWait({ + kind: 'run.unknown', + runId: run.id, + detail: safeSnapshotDetail(snapshot.detail, 'RUNTIME_PROVIDER_STATE_UNKNOWN'), + }) + } else if (context.isTerminal(snapshot.status)) { + if (run.status === 'unknown') { + await context.commitAndWait({ + kind: 'run.reconciled', + runId: run.id, + status: snapshot.status, + from: reconciliationFrom, + to: snapshot.status, + evidence: canonicalDigest({ + runId: run.id, + sessionId: snapshot.sessionId ?? null, + cursor: snapshot.cursor ?? null, + status: snapshot.status, + }), + ...(snapshot.detail === undefined + ? {} + : { detail: safeSnapshotDetail(snapshot.detail, 'RUNTIME_RECONCILIATION_STATUS') }), + }) + await context.commitAndWait({ + kind: 'run.finished', + runId: run.id, + status: snapshot.status, + finalText: safeSnapshotText(snapshot.finalText), + usage: safeSnapshotUsage(snapshot.usage, { + input: run.inputTokens, + output: run.outputTokens, + }), + ...(snapshot.error === undefined + ? {} + : { error: safeSnapshotDetail(snapshot.error, 'RUNTIME_RECONCILIATION_ERROR') }), + ...(snapshot.detail === undefined + ? {} + : { reason: safeSnapshotDetail(snapshot.detail, 'RUNTIME_RECONCILIATION_STATUS') }), + }) + } else { + await context.commitAndWait({ + kind: 'run.finished', + runId: run.id, + status: snapshot.status, + finalText: safeSnapshotText(snapshot.finalText), + usage: safeSnapshotUsage(snapshot.usage, { + input: run.inputTokens, + output: run.outputTokens, + }), + ...(snapshot.error === undefined + ? {} + : { error: safeSnapshotDetail(snapshot.error, 'RUNTIME_RECONCILIATION_ERROR') }), + ...(snapshot.detail === undefined + ? {} + : { reason: safeSnapshotDetail(snapshot.detail, 'RUNTIME_RECONCILIATION_STATUS') }), + }) + } + } else if (snapshot.status !== reconciliationFrom) { + await context.commitAndWait({ + kind: 'run.reconciled', + runId: run.id, + status: snapshot.status, + from: reconciliationFrom, + to: snapshot.status, + evidence: canonicalDigest({ + runId: run.id, + sessionId: snapshot.sessionId ?? null, + cursor: snapshot.cursor ?? null, + status: snapshot.status, + }), + ...(snapshot.detail === undefined + ? {} + : { detail: safeSnapshotDetail(snapshot.detail, 'RUNTIME_RECONCILIATION_STATUS') }), + }) + } + return structuredClone(context.currentState()) +} diff --git a/src/app/run-status.ts b/src/app/run-status.ts new file mode 100644 index 0000000..062c003 --- /dev/null +++ b/src/app/run-status.ts @@ -0,0 +1,45 @@ +import type { BraidRun, BraidState, RunStatus } from '../domain/state.js' +import type { StateReader, StatusPort } from './application-ports.js' +import { AppError } from './errors.js' + +export function findRun(context: Pick, runId: string): BraidRun { + const run = context.currentState().runs.find((candidate) => candidate.id === runId) + if (!run) throw new AppError('UNKNOWN_RUN', `Run ${runId} is unknown`) + return run +} + +export function isTerminal( + status: RunStatus, +): status is Extract< + RunStatus, + 'completed' | 'failed' | 'aborted' | 'cancelled' | 'blocked' | 'expired' | 'unknown' +> { + return ( + status === 'completed' || + status === 'failed' || + status === 'aborted' || + status === 'cancelled' || + status === 'blocked' || + status === 'expired' || + status === 'unknown' + ) +} + +export async function waitForRun(context: StatusPort, runId: string): Promise { + const operation = context.ledger.operationForRun(runId) + if (operation) await operation.completion + return structuredClone(context.currentState()) +} + +export async function waitForIdle(context: StatusPort): Promise { + await Promise.resolve() + const runId = context.currentState().activeRunId + if (!runId) return structuredClone(context.currentState()) + const control = context.ledger.controlForRun(runId) + if (control) { + await control.acknowledgement + if (!context.currentState().activeRunId) return structuredClone(context.currentState()) + } + await waitForRun(context, runId) + return structuredClone(context.currentState()) +} diff --git a/src/app/runtime-selection.ts b/src/app/runtime-selection.ts new file mode 100644 index 0000000..f9b055f --- /dev/null +++ b/src/app/runtime-selection.ts @@ -0,0 +1,36 @@ +import { type AgentProfile, snapshotAgentProfile } from '@tangle-network/agent-interface' +import type { BraidState } from '../domain/state.js' + +export class RuntimeSelection { + #profile: Readonly + #connectionId: string | undefined + + constructor(profile: Readonly, connectionId?: string) { + this.#profile = snapshotAgentProfile(profile) + this.#connectionId = connectionId + } + + profile(): Readonly { + return this.#profile + } + + connectionId(): string | undefined { + return this.#connectionId + } + + setProfile(profile: Readonly): void { + this.#profile = snapshotAgentProfile(profile) + } + + setConnection(connectionId: string): void { + this.#connectionId = connectionId + } + + syncFromState(state: BraidState): void { + if (state.selectedProfileId !== null) { + const selected = state.profiles.find((candidate) => candidate.id === state.selectedProfileId) + if (selected !== undefined) this.setProfile(selected.profile) + } + this.#connectionId = state.selectedConnectionId ?? undefined + } +} diff --git a/src/app/shutdown-controller.ts b/src/app/shutdown-controller.ts new file mode 100644 index 0000000..f1b90b9 --- /dev/null +++ b/src/app/shutdown-controller.ts @@ -0,0 +1,82 @@ +import type { BraidEvent } from '../domain/events.js' +import type { BraidRun, BraidState } from '../domain/state.js' +import type { ShutdownReceipt } from './application-types.js' +import { AppError } from './errors.js' +import { shutdownRequestDigest } from './operation-ledger.js' +import type { RunLedger } from './run-ledger.js' + +export interface ShutdownControllerInput { + readonly operationId: string + readonly mode?: 'wait' | 'detach' | 'cancel' + readonly state: () => BraidState + readonly ledger: RunLedger + readonly commit: (event: BraidEvent) => void + readonly cancelRun: (input: { + readonly operationId: string + readonly runId: string + readonly reason: string + readonly terminalStatus: 'aborted' + readonly legacy: true + }) => Promise<{ readonly completion: Promise }> + readonly detachRun: (input: { + readonly operationId: string + readonly runId: string + }) => Promise<{ readonly completion: Promise }> + readonly waitForIdle: () => Promise +} + +export function shutdownApplication(input: ShutdownControllerInput): ShutdownReceipt { + const digest = shutdownRequestDigest() + const previous = input.ledger.getShutdown(input.operationId) + if (previous) { + if (previous.digest !== digest) + throw new AppError( + 'OPERATION_CONFLICT', + `Operation ${input.operationId} was already used with different input`, + ) + return { + operationId: input.operationId, + revision: input.state().revision, + replayed: true, + completion: previous.completion.then(() => structuredClone(input.state())), + } + } + input.commit({ kind: 'application.shutdown.requested', operationId: input.operationId }) + const state = input.state() + const runId = state.activeRunId ?? undefined + const run = runId ? state.runs.find((candidate) => candidate.id === runId) : undefined + const completion = completeShutdown(input, runId, run) + input.ledger.setShutdown(input.operationId, { digest, completion }) + return { + operationId: input.operationId, + revision: input.state().revision, + replayed: false, + ...(runId === undefined ? { outcome: 'idle' as const } : {}), + ...(input.mode === 'cancel' ? { outcome: 'cancelled' as const } : {}), + ...(input.mode === 'detach' ? { outcome: 'detached' as const } : {}), + completion: completion.then(() => structuredClone(input.state())), + } +} + +async function completeShutdown( + input: ShutdownControllerInput, + runId: string | undefined, + run: BraidRun | undefined, +): Promise { + if (!runId || !run) return input.state() + if (input.mode === 'cancel') { + const receipt = await input.cancelRun({ + operationId: `${input.operationId}:cancel`, + runId, + reason: 'Braid is shutting down', + terminalStatus: 'aborted', + legacy: true, + }) + return receipt.completion + } + if (input.mode === 'detach') { + const receipt = await input.detachRun({ operationId: `${input.operationId}:detach`, runId }) + return receipt.completion + } + return input.waitForIdle() +} diff --git a/src/app/storage-journal-support.ts b/src/app/storage-journal-support.ts new file mode 100644 index 0000000..9e6144d --- /dev/null +++ b/src/app/storage-journal-support.ts @@ -0,0 +1,289 @@ +import { createHash } from 'node:crypto' +import { canonicalDigest } from '../domain/canonical.js' +import type { BraidEvent, BraidEventEnvelope } from '../domain/events.js' +import { + type ConversationId, + createEventId, + createRunId, + createWorkspaceId, + type OperationId, + parseReplayCursor, + type RunId, + type WorkspaceId, +} from '../domain/ids.js' +import { + createMaterializedStateSnapshot, + isMaterializedStateSnapshot, + restoreMaterializedState, +} from '../domain/materialized-state-snapshot.js' +import { replayEvents } from '../domain/reducer.js' +import type { BraidState } from '../domain/state.js' +import type { JsonValue, StateSnapshot, StoredJournalEvent } from '../ports/storage.js' +import { isJsonValue } from '../ports/storage.js' + +export const MAX_IN_MEMORY_EVENTS = 256 +export const SNAPSHOT_INTERVAL = 128 + +export interface PersistedEnvelope { + readonly __braidEvent: BraidEvent + readonly __braidEnvelope: { + readonly sequence: number + readonly revision: number + readonly occurredAt: string + readonly eventId: string + readonly cursor?: string + } +} + +export class StorageJournalRebuildError extends Error { + readonly code: string + + constructor(code: string, message: string) { + super(message) + this.name = 'StorageJournalRebuildError' + this.code = code + } +} + +export function toJson(value: unknown): JsonValue { + if (!isJsonValue(value)) { + throw new StorageJournalRebuildError('JOURNAL_PAYLOAD_INVALID', 'Journal payload is not JSON') + } + return value +} + +export function workspaceIdForRoot(workspace: string | null): WorkspaceId { + const digest = createHash('sha256') + .update(workspace ?? 'uninitialized') + .digest('hex') + .slice(0, 24) + return createWorkspaceId(`workspace-${digest}`) +} + +export function syntheticRunId(workspaceId: WorkspaceId, conversationId: ConversationId): RunId { + return createRunId(`run-journal-${canonicalDigest({ workspaceId, conversationId }).slice(0, 24)}`) +} + +export function shouldSnapshot(state: BraidState, envelope: BraidEventEnvelope): boolean { + return ( + state.sequence > 0 && + (state.sequence % SNAPSHOT_INTERVAL === 0 || envelope.event.kind === 'run.finished') + ) +} + +export function snapshotForState( + envelope: BraidEventEnvelope, + state: BraidState, + scopeId: string, +): StateSnapshot { + const eventId = + envelope.eventId ?? + createEventId(`event-${envelope.sequence}-${canonicalDigest(envelope.event).slice(0, 16)}`) + return createMaterializedStateSnapshot({ + scopeId, + generation: state.sequence, + eventId, + state, + }) +} + +export function restoreSnapshot(value: unknown): BraidState { + if (!isMaterializedStateSnapshot(value)) { + throw new StorageJournalRebuildError( + 'JOURNAL_SNAPSHOT_INVALID', + 'Persisted snapshot is not a valid materialized state', + ) + } + try { + return restoreMaterializedState(value) + } catch (error) { + throw new StorageJournalRebuildError( + 'JOURNAL_SNAPSHOT_INVALID', + `Persisted materialized state failed domain validation: ${error instanceof Error ? error.message : String(error)}`, + ) + } +} + +export function replaySnapshotTail( + initial: BraidState, + events: readonly BraidEventEnvelope[], +): BraidState { + try { + return replayEvents(initial, events) + } catch (error) { + throw new StorageJournalRebuildError( + 'JOURNAL_SNAPSHOT_TAIL_INVALID', + `Persisted snapshot tail failed replay validation: ${error instanceof Error ? error.message : String(error)}`, + ) + } +} + +export function envelopesFromStored(stored: readonly StoredJournalEvent[]): BraidEventEnvelope[] { + return stored.map((event, index) => { + const envelope = toEnvelope(event, index + 1) + if (!envelope) { + throw new StorageJournalRebuildError( + 'JOURNAL_EVENT_UNRECOGNIZED', + `Persisted journal event ${event.eventId} does not contain a Braid event envelope`, + ) + } + return envelope + }) +} + +export function runOperationId(event: BraidEvent): OperationId | undefined { + switch (event.kind) { + case 'run.requested': + return event.operationId + case 'conversation.created': + case 'conversation.imported': + case 'conversation.updated': + case 'conversation.selected': + case 'branch.created': + case 'branch.selected': + return event.operation?.id + case 'conversation.deleted': + return event.operation.id + case 'draft.recorded': + return event.operation?.id + case 'operation.requested': + case 'operation.updated': + return event.operation.id + default: + return undefined + } +} + +export function conversationIdForEvent(state: BraidState, event: BraidEvent): ConversationId { + switch (event.kind) { + case 'conversation.created': + case 'conversation.imported': + case 'conversation.updated': + return event.conversation.id + case 'conversation.selected': + return event.conversationId + case 'conversation.deleted': + return event.selectedConversation.id + case 'branch.created': + case 'branch.updated': + return event.branch.conversationId + case 'branch.selected': + return event.conversationId + case 'turn.created': + case 'turn.updated': + return event.turn.conversationId + case 'message.created': + return event.message.conversationId + case 'analysis.created': + case 'analysis.updated': + case 'analysis.completed': + return event.analysis.source.conversationId + case 'analysis.attachment.created': + return event.attachment.destinationConversationId + case 'feedback.decision.recorded': + return event.decision.conversationId + case 'draft.recorded': + return ( + state.branches.find((branch) => branch.id === event.draft.branchId)?.conversationId ?? + state.conversationId + ) + case 'operation.requested': + case 'operation.updated': + return event.operation.kind !== 'conversation-delete' && + event.operation.target?.kind === 'conversation' + ? event.operation.target.id + : state.conversationId + default: + return state.conversationId + } +} + +function isPersistedEnvelope(value: unknown): value is PersistedEnvelope { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return false + const candidate = value as Record + const event = candidate.__braidEvent + const metadata = candidate.__braidEnvelope + if ( + event === undefined || + event === null || + typeof event !== 'object' || + Array.isArray(event) || + metadata === null || + typeof metadata !== 'object' || + Array.isArray(metadata) + ) + return false + const envelope = metadata as Record + return ( + typeof envelope.sequence === 'number' && + Number.isSafeInteger(envelope.sequence) && + envelope.sequence > 0 && + typeof envelope.revision === 'number' && + Number.isSafeInteger(envelope.revision) && + envelope.revision > 0 && + typeof envelope.occurredAt === 'string' && + typeof envelope.eventId === 'string' && + (envelope.cursor === undefined || typeof envelope.cursor === 'string') && + typeof (event as Record).kind === 'string' + ) +} + +function toEnvelope( + event: StoredJournalEvent, + journalSequence: number, +): BraidEventEnvelope | undefined { + if ( + event.payloadState === 'content-key-unavailable' || + event.payloadState === 'deleted' || + event.payloadState === 'redacted' + ) { + return { + eventId: event.eventId, + sequence: journalSequence, + revision: journalSequence, + occurredAt: event.occurredAt, + event: { + kind: 'content.unavailable', + conversationId: event.conversationId, + originalKind: event.kind, + reason: event.payloadState, + }, + } + } + if (isPersistedEnvelope(event.payload)) { + const metadata = event.payload.__braidEnvelope + return { + eventId: event.eventId, + sequence: metadata.sequence, + revision: metadata.revision, + occurredAt: metadata.occurredAt, + ...(metadata.cursor === undefined ? {} : { cursor: parseReplayCursor(metadata.cursor) }), + event: event.payload.__braidEvent, + } + } + if ( + event.payload !== null && + typeof event.payload === 'object' && + !Array.isArray(event.payload) && + ('__braidEvent' in event.payload || '__braidEnvelope' in event.payload) + ) { + return undefined + } + return { + eventId: event.eventId, + sequence: journalSequence, + revision: journalSequence, + occurredAt: event.occurredAt, + ...(event.cursor === undefined ? {} : { cursor: event.cursor }), + event: { + kind: 'unknown.event', + unknown: { + id: event.eventId, + type: event.kind, + namespace: 'braid.storage', + summary: 'Stored event has no application envelope', + sequence: event.sequence, + }, + }, + } +} diff --git a/src/app/storage-journal.ts b/src/app/storage-journal.ts new file mode 100644 index 0000000..5e1c25d --- /dev/null +++ b/src/app/storage-journal.ts @@ -0,0 +1,287 @@ +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 { BraidState } from '../domain/state.js' +import type { Clock } from '../ports/clock.js' +import type { EffectRecord, EffectStoragePort, JournalPort } from '../ports/effect-storage.js' +import type { + JournalEvent, + StateSnapshot, + StoragePort, + StoredJournalEvent, +} from '../ports/storage.js' +import { createInMemoryOperationFingerprint } from './operation-fingerprint.js' +import { + conversationIdForEvent, + envelopesFromStored, + MAX_IN_MEMORY_EVENTS, + type PersistedEnvelope, + replaySnapshotTail, + restoreSnapshot, + runOperationId, + StorageJournalRebuildError, + shouldSnapshot, + snapshotForState, + syntheticRunId, + toJson, + workspaceIdForRoot, +} from './storage-journal-support.js' + +interface EnvelopeContext { + readonly workspaceId: WorkspaceId + readonly conversationId: ConversationId +} + +/** + * Bridges the application controller to asynchronous SQLite storage. + * An event is exposed to the application journal only after SQLite confirms the + * transaction, so a failed write cannot advance the in-memory projection. + */ +export class StorageJournal implements JournalPort, EffectStoragePort { + readonly asynchronous = true + readonly #storage: StoragePort & EffectStoragePort + readonly #clock: Clock + readonly #events: BraidEventEnvelope[] + readonly #contexts = new Map() + readonly #runSequences = new Map() + readonly #fallbackFingerprint = createInMemoryOperationFingerprint() + readonly #initialState: BraidState | undefined + #replayEvents: readonly BraidEventEnvelope[] | undefined + #pending: Promise = Promise.resolve() + #failure: unknown + + private constructor( + storage: StoragePort & EffectStoragePort, + clock: Clock, + events: readonly BraidEventEnvelope[], + options: { + readonly initialState?: BraidState + readonly replayEvents?: readonly BraidEventEnvelope[] + } = {}, + ) { + this.#storage = storage + this.#clock = clock + this.#events = events.slice(-MAX_IN_MEMORY_EVENTS).map((event) => structuredClone(event)) + this.#initialState = + options.initialState === undefined ? undefined : structuredClone(options.initialState) + this.#replayEvents = + options.replayEvents === undefined + ? undefined + : options.replayEvents.map((event) => structuredClone(event)) + } + + static async fromStorage( + storage: StoragePort & EffectStoragePort, + clock: Clock, + options: { readonly workspaceId?: WorkspaceId } = {}, + ): Promise { + let initialState: BraidState | undefined + let stored: readonly StoredJournalEvent[] | undefined + let events: readonly BraidEventEnvelope[] | undefined + const snapshot = await storage.latestStateSnapshot?.() + if (snapshot !== null && snapshot !== undefined) { + try { + initialState = restoreSnapshot(snapshot) + stored = await storage.events({ ...options, afterStorageId: snapshot.storageId }) + events = envelopesFromStored(stored) + if (events[0] !== undefined && events[0].sequence !== initialState.sequence + 1) { + throw new StorageJournalRebuildError( + 'JOURNAL_SNAPSHOT_TAIL_GAP', + `Snapshot at sequence ${initialState.sequence} is not followed by a contiguous journal tail`, + ) + } + // Validate the same path used by the application before handing it the snapshot. + replaySnapshotTail(initialState, events) + } catch { + initialState = undefined + stored = undefined + events = undefined + } + } + if (stored === undefined || events === undefined) { + stored = await storage.events(options) + events = envelopesFromStored(stored) + } + const journal = new StorageJournal(storage, clock, events, { + ...(initialState === undefined ? {} : { initialState }), + replayEvents: events, + }) + const cursors = storage.runSequences === undefined ? [] : await storage.runSequences() + for (const cursor of cursors) journal.#runSequences.set(cursor.runId, cursor.lastSequence) + if (cursors.length === 0) { + for (const event of stored) { + const runId = event.runId + journal.#runSequences.set( + runId, + Math.max(journal.#runSequences.get(runId) ?? 0, event.sequence), + ) + } + } + return journal + } + + envelope(state: BraidState, event: BraidEvent): BraidEventEnvelope { + const workspaceForEvent = event.kind === 'workspace.opened' ? event.workspace : state.workspace + const workspaceId = workspaceIdForRoot(workspaceForEvent) + const eventId = createEventId( + `event-${workspaceId.slice('workspace-'.length)}-${state.sequence + 1}-${canonicalDigest(event).slice(0, 16)}`, + ) + const envelope: BraidEventEnvelope = { + eventId, + sequence: state.sequence + 1, + revision: state.revision + 1, + occurredAt: this.#clock.now(), + event, + } + this.#contexts.set(eventId, { + workspaceId, + conversationId: conversationIdForEvent(state, event), + }) + return envelope + } + + append(envelope: BraidEventEnvelope): Promise<{ readonly appended: boolean }> { + return this.appendEnvelope(envelope) + } + + appendWithState( + envelope: BraidEventEnvelope, + state: BraidState, + ): Promise<{ readonly appended: boolean }> { + const snapshot = + this.#storage.appendWithSnapshot !== undefined && shouldSnapshot(state, envelope) + ? snapshotForState(envelope, state, this.#storage.snapshotScopeId?.() ?? 'storage-default') + : undefined + return this.appendEnvelope(envelope, snapshot) + } + + private appendEnvelope( + envelope: BraidEventEnvelope, + snapshot?: StateSnapshot, + ): Promise<{ readonly appended: boolean }> { + if (this.#failure !== undefined) throw this.#failure + const eventId = + envelope.eventId ?? + createEventId(`event-${envelope.sequence}-${canonicalDigest(envelope.event).slice(0, 16)}`) + const context = this.#contexts.get(eventId) + if (!context) { + throw new StorageJournalRebuildError( + 'JOURNAL_CONTEXT_MISSING', + `Application event ${eventId} was not created by this storage journal`, + ) + } + const runId = + eventRunId(envelope.event) ?? syntheticRunId(context.workspaceId, context.conversationId) + const operationId = runOperationId(envelope.event) + const sequence = (this.#runSequences.get(runId) ?? 0) + 1 + const payload: PersistedEnvelope = { + __braidEvent: envelope.event, + __braidEnvelope: { + sequence: envelope.sequence, + revision: envelope.revision, + occurredAt: envelope.occurredAt, + eventId, + ...(envelope.cursor === undefined ? {} : { cursor: envelope.cursor }), + }, + } + const record: JournalEvent = { + workspaceId: context.workspaceId, + conversationId: context.conversationId, + runId, + eventId, + sequence, + kind: envelope.event.kind, + payload: toJson(payload), + occurredAt: envelope.occurredAt, + ...(operationId === undefined ? {} : { operationId }), + terminal: envelope.event.kind === 'run.finished', + } + const task = this.#pending + .then(async () => { + if (this.#failure !== undefined) throw this.#failure + const result = + snapshot === undefined || this.#storage.appendWithSnapshot === undefined + ? await this.#storage.append([record]) + : await this.#storage.appendWithSnapshot({ events: [record], snapshot }) + if (result.acceptedEventIds.length === 0) { + this.#contexts.delete(eventId) + return { appended: false as const } + } + this.#runSequences.set(runId, sequence) + this.#events.push({ ...envelope, eventId }) + if (this.#events.length > MAX_IN_MEMORY_EVENTS) this.#events.shift() + this.#contexts.delete(eventId) + return { appended: true as const } + }) + .catch((error) => { + this.#failure = error + throw error + }) + this.#pending = task.then( + () => undefined, + () => undefined, + ) + return task + } + + all(): readonly BraidEventEnvelope[] { + return this.#events.map((event) => structuredClone(event)) + } + + replay(): readonly BraidEventEnvelope[] { + const events = this.#replayEvents ?? this.#events + this.#replayEvents = undefined + return events.map((event) => structuredClone(event)) + } + + initialState(): BraidState | undefined { + return this.#initialState === undefined ? undefined : structuredClone(this.#initialState) + } + + async flush(): Promise { + await this.#pending + if (this.#failure !== undefined) throw this.#failure + } + + async close(): Promise { + let failure: unknown + try { + await this.flush() + } catch (error) { + failure = error + } + try { + await this.#storage.close() + } catch (error) { + failure ??= error + } + if (failure !== undefined) throw failure + } + + current(operationId: string): EffectRecord | undefined { + return this.#storage.current(operationId) + } + + latest(operationId: string, requestDigest: string): EffectRecord | undefined { + return this.#storage.latest(operationId, requestDigest) + } + + appendEffect(record: EffectRecord): void { + this.#storage.appendEffect(record) + } + + reserveEffect(record: EffectRecord): { + readonly record: EffectRecord + readonly created: boolean + } { + return this.#storage.reserveEffect(record) + } + + history(operationId: string): readonly EffectRecord[] { + return this.#storage.history(operationId) + } + + fingerprint(input: { readonly effectKind: string; readonly request: unknown }): string { + return this.#storage.fingerprint?.(input) ?? this.#fallbackFingerprint.fingerprint(input) + } +} diff --git a/src/app/supervisor-service.ts b/src/app/supervisor-service.ts new file mode 100644 index 0000000..ba20618 --- /dev/null +++ b/src/app/supervisor-service.ts @@ -0,0 +1,276 @@ +import type { + RuntimeSupervisorController, + SupervisorCancelResult, + SupervisorWorkerCancelResult, + SupervisorWorkerSteerResult, +} from '../adapters/runtime/supervisor-control.js' +import type { + RuntimeSupervisorSnapshotPort, + SupervisorWatchOptions, + TopSnapshot, +} from '../adapters/runtime/supervisor-watch.js' +import { canonicalDigest } from '../domain/canonical.js' +import type { SupervisorRecord, WorkerRecord } from '../domain/entities.js' +import { graphEdge, graphNode } from '../domain/graph-records.js' +import type { RunId, SupervisorId, WorkerId } from '../domain/ids.js' +import { createSupervisorId, createWorkerId } from '../domain/ids-values.js' +import { redactSensitiveText } from '../domain/redaction.js' +import type { AnalysisApplicationHost } from './analysis-types.js' + +export interface SupervisorSnapshotRequest { + readonly rootDir: string + readonly rootRunId: RunId + readonly now?: string +} + +export interface SupervisorWatchRequest extends SupervisorSnapshotRequest { + readonly intervalMs?: number + readonly signal?: AbortSignal +} + +export interface SupervisorProjection { + readonly raw: TopSnapshot + readonly supervisors: readonly SupervisorRecord[] + readonly workers: readonly WorkerRecord[] + readonly graphNodes: readonly ReturnType[] + readonly graphEdges: readonly ReturnType[] +} + +function validIso(value: string | undefined, fallback: string): string { + if (value !== undefined && Number.isFinite(Date.parse(value))) return value + return fallback +} + +function supervisorStatus(value: string): SupervisorRecord['status'] { + switch (value.toLowerCase()) { + case 'starting': + case 'queued': + return 'starting' + case 'running': + return 'running' + case 'completed': + case 'complete': + case 'done': + case 'success': + return 'completed' + case 'failed': + case 'error': + return 'failed' + case 'cancelled': + case 'canceled': + return 'cancelled' + default: + return 'unknown' + } +} + +function workerStatus(value: string): WorkerRecord['status'] { + switch (value) { + case 'running': + return 'running' + case 'done': + return 'completed' + case 'cancelled': + return 'cancelled' + case 'down': + return 'failed' + default: + return 'unknown' + } +} + +function supervisorId(rawId: string): SupervisorId { + return createSupervisorId(`supervisor-runtime-${canonicalDigest(rawId).slice(0, 40)}`) +} + +function workerId(supervisor: SupervisorId, rawId: string): WorkerId { + return createWorkerId(`worker-runtime-${canonicalDigest({ supervisor, rawId }).slice(0, 40)}`) +} + +function finite(value: number | undefined): number | undefined { + return value === undefined || !Number.isFinite(value) || value < 0 ? undefined : value +} + +function projectSnapshot(input: SupervisorSnapshotRequest, raw: TopSnapshot): SupervisorProjection { + const at = input.now ?? new Date(raw.generatedAt).toISOString() + const supervisors: SupervisorRecord[] = [] + const workers: WorkerRecord[] = [] + const graphNodes: ReturnType[] = [] + const graphEdges: ReturnType[] = [] + + for (const view of raw.supervisors) { + const id = supervisorId(view.id) + const createdAt = validIso(view.startedAt, at) + supervisors.push({ + id, + rootRunId: input.rootRunId, + status: supervisorStatus(view.status), + createdAt, + updatedAt: validIso(view.completedAt, at), + }) + const supervisorReference = { kind: 'supervisor' as const, id } + const supervisorNode = graphNode(supervisorReference, createdAt, view.task) + graphNodes.push({ ...supervisorNode, status: supervisorStatus(view.status) }) + + const rawToDomain = new Map() + for (const worker of view.workers) rawToDomain.set(worker.id, workerId(id, worker.id)) + for (const worker of view.workers) rawToDomain.set(worker.label, workerId(id, worker.id)) + for (const worker of view.workers) { + const workerDomainId = rawToDomain.get(worker.id) + if (workerDomainId === undefined) continue + const createdWorkerAt = validIso(worker.startedAt, createdAt) + const parentWorkerId = + worker.parent === undefined ? undefined : rawToDomain.get(worker.parent) + const logTail = redactSensitiveText(worker.liveTail.join('\n')) + workers.push({ + id: workerDomainId, + supervisorId: id, + ...(parentWorkerId === undefined ? {} : { parentWorkerId }), + status: workerStatus(worker.status), + ...(finite(worker.spend.usd) === undefined ? {} : { spendUsd: worker.spend.usd }), + ...(finite(worker.spend.tokensInput) === undefined + ? {} + : { inputTokens: worker.spend.tokensInput }), + ...(finite(worker.spend.tokensOutput) === undefined + ? {} + : { outputTokens: worker.spend.tokensOutput }), + ...(finite(worker.latencyMs) === undefined ? {} : { latencyMs: worker.latencyMs }), + ...(logTail.length === 0 ? {} : { logTail }), + title: worker.label, + createdAt: createdWorkerAt, + updatedAt: validIso(worker.endedAt, at), + }) + const workerReference = { kind: 'worker' as const, id: workerDomainId } + const workerNode = graphNode(workerReference, createdWorkerAt, worker.label) + graphNodes.push({ ...workerNode, status: workerStatus(worker.status) }) + graphEdges.push( + graphEdge({ + kind: 'spawned', + source: supervisorReference, + destination: workerReference, + at: createdWorkerAt, + }), + ) + if (parentWorkerId !== undefined) { + graphEdges.push( + graphEdge({ + kind: 'spawned', + source: { kind: 'worker', id: parentWorkerId }, + destination: workerReference, + at: createdWorkerAt, + }), + ) + } + } + } + + return { raw, supervisors, workers, graphNodes, graphEdges } +} + +async function commitProjection( + host: AnalysisApplicationHost, + input: SupervisorSnapshotRequest, + raw: TopSnapshot, +): Promise { + const projection = projectSnapshot(input, raw) + for (const supervisor of projection.supervisors) { + await host.commit({ kind: 'supervisor.upserted', supervisor }) + } + for (const worker of projection.workers) await host.commit({ kind: 'worker.upserted', worker }) + for (const node of projection.graphNodes) await host.commit({ kind: 'graph.node.upserted', node }) + for (const edge of projection.graphEdges) await host.commit({ kind: 'graph.edge.upserted', edge }) + return projection +} + +export class SupervisorService { + readonly #host: AnalysisApplicationHost + #watcher: RuntimeSupervisorSnapshotPort | undefined + #controller: RuntimeSupervisorController | undefined + #watcherLoad: Promise | undefined + #controllerLoad: Promise | undefined + + constructor( + host: AnalysisApplicationHost, + options: { + readonly watcher?: RuntimeSupervisorSnapshotPort + readonly controller?: RuntimeSupervisorController + } = {}, + ) { + this.#host = host + this.#watcher = options.watcher + this.#controller = options.controller + } + + async snapshot(input: SupervisorSnapshotRequest): Promise { + const watcher = await this.#loadWatcher() + const raw = watcher.snapshot(input.rootDir) + return commitProjection(this.#host, input, raw) + } + + async reconnect(input: SupervisorSnapshotRequest): Promise { + const watcher = await this.#loadWatcher() + const raw = watcher.reconnect(input.rootDir) + return commitProjection(this.#host, input, raw) + } + + async *watch(input: SupervisorWatchRequest): AsyncGenerator { + const watcher = await this.#loadWatcher() + const options: SupervisorWatchOptions = { + ...(input.intervalMs === undefined ? {} : { intervalMs: input.intervalMs }), + ...(input.signal === undefined ? {} : { signal: input.signal }), + } + for await (const raw of watcher.watch(input.rootDir, options)) { + yield await commitProjection(this.#host, input, raw) + } + } + + async steerWorker( + rootDir: string, + supervisorId: string, + workerIdOrLabel: string, + message: string, + source?: string, + ): Promise { + const controller = await this.#loadController() + return controller.steerWorker(rootDir, supervisorId, workerIdOrLabel, message, source) + } + + async cancelWorker(worker: string): Promise { + const controller = await this.#loadController() + return controller.cancelWorker(worker) + } + + async cancelSupervisor(reason?: string): Promise { + const controller = await this.#loadController() + return controller.cancelSupervisor(reason) + } + + #loadWatcher(): Promise { + if (this.#watcher !== undefined) return Promise.resolve(this.#watcher) + if (this.#watcherLoad !== undefined) return this.#watcherLoad + this.#watcherLoad = import('../adapters/runtime/supervisor-watch.js').then( + ({ RuntimeSupervisorWatcher }) => { + const watcher = new RuntimeSupervisorWatcher() + this.#watcher = watcher + return watcher + }, + ) + return this.#watcherLoad + } + + #loadController(): Promise { + if (this.#controller !== undefined) return Promise.resolve(this.#controller) + if (this.#controllerLoad !== undefined) return this.#controllerLoad + this.#controllerLoad = Promise.all([ + this.#loadWatcher(), + import('../adapters/runtime/supervisor-control.js'), + ]).then(([watcher, { RuntimeSupervisorController }]) => { + const controller = new RuntimeSupervisorController({ watcher }) + this.#controller = controller + return controller + }) + return this.#controllerLoad + } +} + +export type { SupervisorCancelResult, SupervisorWorkerCancelResult, SupervisorWorkerSteerResult } diff --git a/src/app/unavailable-analyst.ts b/src/app/unavailable-analyst.ts new file mode 100644 index 0000000..df41ecb --- /dev/null +++ b/src/app/unavailable-analyst.ts @@ -0,0 +1,28 @@ +import { AnalysisCapabilityError } from './analysis-types.js' +import type { AnalysisAnalyst, AnalysisExecutionEvent } from './analysis-execution-session.js' +import type { AnalysisRequest } from './analysis-types.js' +import type { EvalAnalystRequest } from '../adapters/analysis/eval-analyst.js' +import { AGENT_EVAL_VERSION } from '../adapters/analysis/agent-eval-version.js' + +const ISSUE = { + capability: 'trace-analysis', + packageName: '@tangle-network/agent-eval', + packageVersion: AGENT_EVAL_VERSION, + reason: 'Trace analysis is not loaded until an analyst-backed analysis is requested.', + reproduction: + 'Configure a production analysis connection before invoking /ask or another analysis command.', +} as const + +export class UnavailableAnalyst implements AnalysisAnalyst { + list(): readonly [] { + return [] + } + + resolveAnalystIds(_request: AnalysisRequest): readonly string[] { + throw new AnalysisCapabilityError(ISSUE) + } + + async *stream(_request: EvalAnalystRequest): AsyncGenerator { + yield await Promise.reject(new AnalysisCapabilityError(ISSUE)) + } +} diff --git a/src/app/view-model.ts b/src/app/view-model.ts index 803d54f..fc63621 100644 --- a/src/app/view-model.ts +++ b/src/app/view-model.ts @@ -1,14 +1,32 @@ -import type { BraidState, MessageStatus } from '../domain/state.js' -import { sanitizeTerminalText } from '../views/shared/sanitize.js' +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_MESSAGE_CHARS = 200_000 +const MAX_VISIBLE_PARTS = 512 +const MAX_VISIBLE_PART_CHARS = 32_000 export interface MessageView { readonly id: string - readonly role: 'user' | 'assistant' + 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 { @@ -17,15 +35,53 @@ export interface AppView { readonly runner: string readonly model: string readonly connection: string - readonly status: 'ready' | 'running' | 'failed' | 'blocked' | 'aborted' + 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 { - if (text.length <= MAX_VISIBLE_MESSAGE_CHARS) return text - return `…\n${text.slice(-MAX_VISIBLE_MESSAGE_CHARS)}` + 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): MessagePartView { + const detail = part.text ?? part.error ?? (part.result === undefined ? part.input : part.result) + return { + id: part.id, + kind: part.kind, + text: sanitizeTerminalText(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 { @@ -35,28 +91,56 @@ export function buildAppView(state: BraidState): AppView { role: message.role, text: sanitizeTerminalText(visibleTail(message.text)), status: message.status, + parts: Object.freeze(message.parts.slice(-MAX_VISIBLE_PARTS).map(partView)), })) 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 - ? 'running' + ? latestRun?.status === 'reconnecting' + ? 'reconnecting' + : latestRun?.status === 'cancelling' + ? 'cancelling' + : 'running' : latestRun?.status === 'failed' ? 'failed' : latestRun?.status === 'blocked' ? 'blocked' : latestRun?.status === 'aborted' ? 'aborted' - : 'ready' + : latestRun?.status === 'cancelled' + ? 'cancelled' + : latestRun?.status === 'unknown' + ? 'unknown' + : latestRun?.status === 'expired' + ? 'expired' + : latestRun?.status === 'detached' + ? 'detached' + : 'ready' const statusText = - status === 'running' + status === 'running' || status === 'reconnecting' ? 'working' - : status === 'failed' - ? (latestRun?.error ?? state.lastError ?? 'failed') - : status === 'blocked' - ? 'blocked' - : status === 'aborted' - ? 'cancelled' - : 'ready' + : 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, @@ -68,5 +152,7 @@ export function buildAppView(state: BraidState): AppView { 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/bin/args.ts b/src/bin/args.ts index ddce82d..c1b659a 100644 --- a/src/bin/args.ts +++ b/src/bin/args.ts @@ -1,9 +1,21 @@ export interface CliOptions { readonly mode: 'tui' | 'rpc' + readonly plain: boolean readonly fixture?: 'deterministic' + readonly uiFixture?: 'interaction' | 'fork' | 'analysis' | 'comparison' readonly inline: boolean readonly noColor: boolean + readonly highContrast: boolean + readonly reducedMotion: boolean readonly workspace: string + readonly config?: string + readonly conversation?: string + readonly profile?: string + readonly connection?: string + readonly runner?: string + readonly model?: string + readonly effort?: string + readonly databaseKeyFile?: string readonly recordState?: string readonly help: boolean readonly version: boolean @@ -17,26 +29,54 @@ Usage: Options: --workspace Workspace to open (default: current directory) + --config Production profile and connection configuration --inline Render in the main terminal buffer + --plain Emit a readable non-interactive event stream --no-color Disable color + --high-contrast Use high-contrast semantic colors + --reduced-motion Replace motion with stable status text + --conversation Open a conversation by id + --profile Select a canonical profile file for the run + --connection Select a connection for the run + --runner Set a run-level runner preference + --model Set a run-level model preference + --effort Set a run-level effort preference + --database-key-file Use a protected 0600 headless SQLite key file --fixture deterministic Use the clearly labelled offline test provider + --ui-fixture Render a named visual verification fixture --record-state Write final semantic state and events for verification -h, --help Show help -v, --version Show version ` +export class CliUsageError extends Error { + override readonly name = 'CliUsageError' +} + function requiredValue(args: readonly string[], index: number, flag: string): string { const value = args[index + 1] - if (!value || value.startsWith('-')) throw new Error(`${flag} requires a value`) + if (!value || value.startsWith('-')) throw new CliUsageError(`${flag} requires a value`) return value } export function parseArgs(argv: readonly string[], cwd: string): CliOptions { let mode: CliOptions['mode'] = 'tui' + let plain = false let fixture: CliOptions['fixture'] + let uiFixture: CliOptions['uiFixture'] let inline = false let noColor = false + let highContrast = false + let reducedMotion = false let workspace = cwd + let config: string | undefined + let conversation: string | undefined + let profile: string | undefined + let connection: string | undefined + let runner: string | undefined + let model: string | undefined + let effort: string | undefined + let databaseKeyFile: string | undefined let recordState: string | undefined let help = false let version = false @@ -45,29 +85,86 @@ export function parseArgs(argv: readonly string[], cwd: string): CliOptions { const argument = argv[index] if (argument === 'rpc' && index === 0) mode = 'rpc' else if (argument === '--inline') inline = true + else if (argument === '--plain') plain = true else if (argument === '--no-color') noColor = true + else if (argument === '--high-contrast') highContrast = true + else if (argument === '--reduced-motion') reducedMotion = true else if (argument === '--workspace') { workspace = requiredValue(argv, index, argument) index += 1 + } else if (argument === '--config') { + config = requiredValue(argv, index, argument) + index += 1 } else if (argument === '--record-state') { recordState = requiredValue(argv, index, argument) index += 1 } else if (argument === '--fixture') { const value = requiredValue(argv, index, argument) - if (value !== 'deterministic') throw new Error(`Unknown fixture: ${value}`) + if (value !== 'deterministic') + throw new CliUsageError('--fixture supports only "deterministic"') fixture = value index += 1 + } else if (argument === '--ui-fixture') { + const value = requiredValue(argv, index, argument) + if ( + value !== 'interaction' && + value !== 'fork' && + value !== 'analysis' && + value !== 'comparison' + ) + throw new CliUsageError( + '--ui-fixture supports "interaction", "fork", "analysis", or "comparison"', + ) + uiFixture = value + index += 1 + } else if (argument === '--conversation') { + conversation = requiredValue(argv, index, argument) + index += 1 + } else if (argument === '--profile') { + profile = requiredValue(argv, index, argument) + index += 1 + } else if (argument === '--connection') { + connection = requiredValue(argv, index, argument) + index += 1 + } else if (argument === '--runner') { + runner = requiredValue(argv, index, argument) + index += 1 + } else if (argument === '--model') { + model = requiredValue(argv, index, argument) + index += 1 + } else if (argument === '--effort') { + effort = requiredValue(argv, index, argument) + index += 1 + } else if (argument === '--database-key-file') { + databaseKeyFile = requiredValue(argv, index, argument) + index += 1 } else if (argument === '-h' || argument === '--help') help = true else if (argument === '-v' || argument === '--version') version = true - else throw new Error(`Unknown argument: ${argument}`) + else if (argument?.startsWith('-')) + throw new CliUsageError( + /^--?[a-z][a-z0-9-]*$/iu.test(argument) ? `Unknown option: ${argument}` : 'Unknown option', + ) + else throw new CliUsageError('Unknown command; expected "rpc" or an option') } return { mode, + plain, ...(fixture ? { fixture } : {}), + ...(uiFixture ? { uiFixture } : {}), inline, noColor, + highContrast, + reducedMotion, workspace, + ...(config ? { config } : {}), + ...(conversation ? { conversation } : {}), + ...(profile ? { profile } : {}), + ...(connection ? { connection } : {}), + ...(runner ? { runner } : {}), + ...(model ? { model } : {}), + ...(effort ? { effort } : {}), + ...(databaseKeyFile ? { databaseKeyFile } : {}), ...(recordState ? { recordState } : {}), help, version, diff --git a/src/bin/braid-runtime.ts b/src/bin/braid-runtime.ts new file mode 100644 index 0000000..834b776 --- /dev/null +++ b/src/bin/braid-runtime.ts @@ -0,0 +1,190 @@ +import { resolve } from 'node:path' +import { createUnavailableTraceAnalysisAnalyst } from '../adapters/analysis/trace-analysis-adapter.js' +import type { ProfileConnectionDispatchOptions } from '../adapters/tui/profile-connection-dispatch.js' +import type { BraidApplication } from '../app/application.js' +import { createBraidApplication } from '../app/composition.js' +import { createMemoryJournal } from '../app/journal.js' +import { SystemClock } from '../ports/clock.js' +import type { CliOptions } from './args.js' +import { openProductionApplication } from './production-application.js' +import { createProductionCredentialContext } from './production-credential-context.js' +import { loadProductionSetup, type ProductionStartupSetup } from './production-setup.js' +import { + loadProductionStartup, + ProductionStartupError, + type ProductionStartupLoadOptions, +} from './production-startup.js' +import { defaultStatePath } from './state-path.js' + +export async function runBraid(options: CliOptions): Promise { + const workspace = resolve(options.workspace) + const [opened, { runInterface }] = await Promise.all([ + openApplication(options, workspace), + import('./interface-runner.js'), + ]) + const active = { current: { app: opened.app, close: opened.close } } + try { + return await runInterface({ + options, + workspace, + active, + ...(opened.setup === undefined ? {} : { setup: opened.setup }), + ...(opened.startupOptions === undefined ? {} : { startupOptions: opened.startupOptions }), + ...(opened.profileConnectionOptions === undefined + ? {} + : { profileConnectionOptions: opened.profileConnectionOptions }), + openConfiguredApplication, + }) + } finally { + await active.current.close() + } +} + +/** + * The fixture surface runs entirely in memory so a capture is reproducible. + * Every other run opens the encrypted SQLite journal, which owns durability, + * so the process must release it before exiting. + */ +async function openApplication( + options: CliOptions, + workspace: string, +): Promise<{ + readonly app: BraidApplication + readonly close: () => Promise + readonly setup?: ProductionStartupSetup + readonly startupOptions?: ProductionStartupLoadOptions + readonly profileConnectionOptions?: ProfileConnectionDispatchOptions +}> { + if (options.fixture) { + const configured = Number(process.env.BRAID_FIXTURE_CHUNK_DELAY_MS ?? 12) + const app = createBraidApplication({ + fixture: options.fixture, + chunkDelayMs: Number.isFinite(configured) && configured >= 0 ? configured : 12, + }) + return { app, close: () => app.close() } + } + const discoveryTimeoutMs = optionalMilliseconds('BRAID_DISCOVERY_TIMEOUT_MS') + const modelValidationTimeoutMs = optionalMilliseconds('BRAID_MODEL_VALIDATION_TIMEOUT_MS') + const baseStartupOptions: ProductionStartupLoadOptions = { + workspace, + ...(options.config === undefined ? {} : { configPath: options.config }), + ...(options.profile === undefined ? {} : { profileReference: options.profile }), + ...(options.connection === undefined ? {} : { connectionId: options.connection }), + ...(options.model === undefined ? {} : { model: options.model }), + ...(options.runner === undefined ? {} : { runner: options.runner }), + ...(options.databaseKeyFile === undefined ? {} : { databaseKeyFile: options.databaseKeyFile }), + ...(options.effort === undefined ? {} : { effort: options.effort }), + ...(process.env.BRAID_CLI_BRIDGE_ENDPOINT === undefined + ? {} + : { cliBridgeEndpoint: process.env.BRAID_CLI_BRIDGE_ENDPOINT }), + ...(process.env.BRAID_CLI_BRIDGE_AUTH === undefined + ? {} + : { bridgeAuth: process.env.BRAID_CLI_BRIDGE_AUTH }), + ...(discoveryTimeoutMs === undefined ? {} : { discoveryTimeoutMs }), + ...(modelValidationTimeoutMs === undefined ? {} : { modelValidationTimeoutMs }), + } + const credentialContext = createProductionCredentialContext({ + workspace, + ...(baseStartupOptions.configPath === undefined + ? {} + : { configPath: baseStartupOptions.configPath }), + ...(baseStartupOptions.databaseKeyFile === undefined + ? {} + : { databaseKeyFile: baseStartupOptions.databaseKeyFile }), + }) + const startupOptions: ProductionStartupLoadOptions = + credentialContext === undefined + ? baseStartupOptions + : { + ...baseStartupOptions, + databaseKeyFile: credentialContext.databaseKeyFile, + credentialStore: credentialContext.store, + credentialContext, + } + try { + const production = await loadProductionStartup(startupOptions) + const configured = await openConfiguredApplication(startupOptions, production) + return { + ...configured, + profileConnectionOptions: { + connections: production.connections, + ...(production.connectionOptions === undefined + ? {} + : { productionConnection: production.connectionOptions }), + }, + } + } catch (error) { + if ( + !(error instanceof ProductionStartupError) || + error.code !== 'PRODUCTION_CONFIGURATION_NOT_FOUND' + ) { + credentialContext?.dispose() + throw error + } + try { + const setup = await loadProductionSetup(startupOptions) + const journal = createMemoryJournal(new SystemClock()) + const app = createBraidApplication({ + journal, + effectStorage: journal, + intelligence: { analyst: createUnavailableTraceAnalysisAnalyst() }, + }) + const releaseContext = credentialContext?.acquire() + return { + app, + close: async () => { + try { + await app.close() + } finally { + releaseContext?.() + } + }, + setup, + startupOptions, + profileConnectionOptions: { + profiles: setup.profiles, + connections: setup.connections, + productionConnection: { + ...(startupOptions.fetch === undefined ? {} : { fetch: startupOptions.fetch }), + ...(startupOptions.credentialStore === undefined + ? {} + : { credentials: startupOptions.credentialStore }), + ...(startupOptions.credentialRefResolver === undefined + ? {} + : { credentialRefResolver: startupOptions.credentialRefResolver }), + }, + }, + } + } catch (setupError) { + credentialContext?.dispose() + throw setupError + } + } +} + +async function openConfiguredApplication( + startupOptions: ProductionStartupLoadOptions, + production?: import('../app/production-composition.js').ProductionCompositionConfig, +): Promise<{ readonly app: BraidApplication; readonly close: () => Promise }> { + const configured = production ?? (await loadProductionStartup(startupOptions)) + const effectiveStartupOptions = + startupOptions.databaseKeyFile === undefined && configured.databaseKeyFile !== undefined + ? { ...startupOptions, databaseKeyFile: configured.databaseKeyFile } + : startupOptions + return openProductionApplication({ + workspace: effectiveStartupOptions.workspace, + statePath: defaultStatePath( + effectiveStartupOptions.workspace, + effectiveStartupOptions.configPath, + ), + startupOptions: effectiveStartupOptions, + production: configured, + }) +} + +function optionalMilliseconds(name: string): number | undefined { + const raw = process.env[name] + if (raw === undefined || raw.trim().length === 0) return undefined + const value = Number(raw) + return Number.isSafeInteger(value) && value > 0 ? value : undefined +} diff --git a/src/bin/braid.ts b/src/bin/braid.ts index 34a882c..fbe487e 100644 --- a/src/bin/braid.ts +++ b/src/bin/braid.ts @@ -1,50 +1,20 @@ #!/usr/bin/env node -import { constants } from 'node:fs' -import { mkdir, open, rename, rm, type FileHandle } from 'node:fs/promises' -import { dirname, resolve } from 'node:path' -import { randomUUID } from 'node:crypto' -import { ProcessTerminal, TUI } from '@earendil-works/pi-tui' -import { AlternateScreenTerminal } from '../adapters/tui/alternate-screen-terminal.js' -import { createBraidApplication } from '../app/composition.js' -import { runRpc } from '../views/headless/rpc.js' -import { BraidTerminalApp } from '../views/tui/terminal-app.js' -import { createBraidTheme } from '../views/tui/theme.js' -import { BRAID_VERSION } from '../version.js' -import { HELP, parseArgs, type CliOptions } from './args.js' +import { enableCompileCache } from 'node:module' +import { CliUsageError, HELP, parseArgs } from './args.js' -async function recordState( - path: string, - app: ReturnType, -): Promise { - const target = resolve(path) - const temporary = `${target}.${randomUUID()}.tmp` - await mkdir(dirname(target), { recursive: true }) - let file: FileHandle | undefined = await open( - temporary, - constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, - 0o600, - ) - try { - await file.writeFile( - `${JSON.stringify({ schemaVersion: 1, state: app.state(), events: app.events() }, null, 2)}\n`, - ) - await file.sync() - await file.close() - file = undefined - await rename(temporary, target) - } finally { - await file?.close().catch(() => {}) - await rm(temporary, { force: true }) - } -} - -async function main(): Promise { - let options: CliOptions +async function launch(): Promise { + let options: ReturnType try { options = parseArgs(process.argv.slice(2), process.cwd()) } catch (error) { - process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n\n${HELP}`) + const message = + error instanceof CliUsageError + ? error.message + : await import('../domain/redaction.js').then(({ redactProviderError }) => + redactProviderError(error), + ) + process.stderr.write(`error: ${message}\n\n${HELP}`) return 2 } if (options.help) { @@ -52,70 +22,21 @@ async function main(): Promise { return 0 } if (options.version) { + const { BRAID_VERSION } = await import('../version.js') process.stdout.write(`${BRAID_VERSION}\n`) return 0 } - - const app = createBraidApplication({ - ...(options.fixture ? { fixture: options.fixture, chunkDelayMs: 12 } : {}), - }) - - if (options.mode === 'rpc') { - const exitCode = await runRpc(app, process.stdin, process.stdout) - if (options.recordState) await recordState(options.recordState, app) - return exitCode - } - - app.initialize(resolve(options.workspace)) - if (!process.stdin.isTTY || !process.stdout.isTTY) { - process.stderr.write('Interactive mode requires a terminal; use `braid rpc` for JSONL.\n') - return 2 - } - - const terminal = options.inline ? new ProcessTerminal() : new AlternateScreenTerminal() - const tui = new TUI(terminal) - const colors = !options.noColor && process.env.NO_COLOR === undefined - let operation = 0 - const nextOperationId = options.fixture - ? () => `op-terminal-${String(++operation).padStart(6, '0')}` - : () => `op-${randomUUID()}` - const view = new BraidTerminalApp({ - app, - tui, - theme: createBraidTheme(colors), - workspace: resolve(options.workspace), - nextOperationId, - }) - let signalExitCode: number | undefined - const stopFromSignal = (exitCode: number) => { - signalExitCode ??= exitCode - app.cancelActive() - view.stop() - } - const onInterrupt = () => stopFromSignal(130) - const onTerminate = () => stopFromSignal(143) - const onHangup = () => stopFromSignal(129) - process.once('SIGINT', onInterrupt) - process.once('SIGTERM', onTerminate) - process.once('SIGHUP', onHangup) - try { - await view.start() - await app.waitForIdle() - } finally { - process.off('SIGINT', onInterrupt) - process.off('SIGTERM', onTerminate) - process.off('SIGHUP', onHangup) - view.stop() - } - if (options.recordState) await recordState(options.recordState, app) - return signalExitCode ?? 0 + enableCompileCache() + const { runBraid } = await import('./braid-runtime.js') + return runBraid(options) } -main() +launch() .then((exitCode) => { process.exitCode = exitCode }) - .catch((error) => { - process.stderr.write(`${error instanceof Error ? error.stack : String(error)}\n`) + .catch(async (error: unknown) => { + const { formatProductionStartupError } = await import('./production-startup.js') + process.stderr.write(`${formatProductionStartupError(error)}\n`) process.exitCode = 1 }) diff --git a/src/bin/interface-runner.ts b/src/bin/interface-runner.ts new file mode 100644 index 0000000..a7fb1e2 --- /dev/null +++ b/src/bin/interface-runner.ts @@ -0,0 +1,257 @@ +import { randomUUID } from 'node:crypto' +import { mkdir } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import { assertNoSymlinkPath, writePrivateFile } from '../adapters/persistence/safe-file.js' +import { AlternateScreenTerminal } from '../adapters/tui/alternate-screen-terminal.js' +import type { ProfileConnectionDispatchOptions } from '../adapters/tui/profile-connection-dispatch.js' +import type { BraidApplication } from '../app/application.js' +import type { ProductionCompositionConfig } from '../app/production-composition.js' +import { + BraidTerminalApp, + createApplicationUiController, + createBraidTheme, + ProcessTerminal, + TUI, +} from '../startup/terminal-runtime.js' +import { runRpc } from '../views/headless/rpc.js' +import { + type CommandName, + commandIntent, + isMutatingCommand, +} from '../views/shared/command-registry.js' +import type { BraidIntent, BraidUiController } from '../views/shared/intents.js' +import type { CliOptions } from './args.js' +import { runPlain } from './plain.js' +import { productionConfigForSelection } from './production-application.js' +import { + describeProductionSelection, + type ProductionStartupSetup, + transitionProductionSelection, +} from './production-setup.js' +import type { ProductionApplicationSlot } from './production-setup-transition.js' +import type { ProductionStartupLoadOptions } from './production-startup.js' + +interface InterfaceRunnerInput { + readonly options: CliOptions + readonly workspace: string + readonly active: ProductionApplicationSlot + readonly setup?: ProductionStartupSetup + readonly startupOptions?: ProductionStartupLoadOptions + readonly profileConnectionOptions?: ProfileConnectionDispatchOptions + readonly openConfiguredApplication: ( + options: ProductionStartupLoadOptions, + production: ProductionCompositionConfig, + ) => Promise<{ readonly app: BraidApplication; readonly close: () => Promise }> +} + +type TerminalConfiguration = NonNullable< + ConstructorParameters[0]['configuration'] +> + +async function recordState( + path: string, + controller: BraidUiController, + capturePhase: 'final' | 'atomic-signal-frame' = 'final', +): Promise { + const target = resolve(path) + const directory = dirname(target) + const payload = `${JSON.stringify({ schemaVersion: 2, capturePhase, state: controller.state(), view: controller.view(), events: controller.events() }, null, 2)}\n` + assertNoSymlinkPath(directory) + await mkdir(directory, { recursive: true, mode: 0o700 }) + assertNoSymlinkPath(directory) + writePrivateFile(target, payload) +} + +export async function runInterface(input: InterfaceRunnerInput): Promise { + const { options, workspace, active, setup, startupOptions } = input + const controller = createApplicationUiController( + active.current.app, + { + color: + options.plain || options.noColor || process.env.NO_COLOR !== undefined + ? 'none' + : 'truecolor', + highContrast: options.highContrast, + reducedMotion: options.reducedMotion, + }, + options.uiFixture, + input.profileConnectionOptions, + ) + + let verification = setup?.verification + const configuration: TerminalConfiguration | undefined = + setup === undefined || startupOptions === undefined + ? undefined + : { + profiles: setup.profiles, + connections: setup.connections, + ...(setup.initialProfileId === undefined + ? {} + : { initialProfileId: setup.initialProfileId }), + diagnostics: setup.diagnostics, + openOnStart: true, + confirmation: (selection) => + describeProductionSelection(selection, workspace, verification), + onCommit: async (selection) => { + verification = await transitionProductionSelection({ + setup, + startupOptions, + selection, + workspace, + controller, + active, + openApplication: (selection, selectedOptions) => + input.openConfiguredApplication( + selectedOptions, + productionConfigForSelection(selection, selectedOptions), + ), + }) + }, + } + + let operation = 0 + const nextOperationId = options.fixture + ? () => `op-terminal-${String(++operation).padStart(6, '0')}` + : () => `op-${randomUUID()}` + const startupIntents = (): BraidIntent[] => { + const commands: Array<[CommandName, string | undefined]> = [['open', options.conversation]] + // Production profile, connection, runner, and model values are resolved before the + // application is constructed; replaying them as UI commands would select a second path. + if (options.fixture) { + commands.push( + ['profile', options.profile], + ['connection', options.connection], + ['runner', options.runner], + ['model', options.model], + ['effort', options.effort], + ) + } + return commands.flatMap(([command, value]) => { + if (!value) return [] + return [ + commandIntent(command, [value], isMutatingCommand(command) ? nextOperationId() : undefined), + ] + }) + } + + if (options.mode === 'rpc') { + const exitCode = await runRpc(controller, process.stdin, process.stdout) + if (options.recordState) await recordState(options.recordState, controller) + return exitCode + } + + if (options.plain) { + const exitCode = await runPlain(controller, workspace, process.stdin, process.stdout, { + initialIntents: startupIntents(), + }) + if (options.recordState) await recordState(options.recordState, controller) + return exitCode + } + + const initialized = await controller.initialize(workspace) + if (initialized.kind !== 'accepted') { + process.stderr.write( + `${initialized.kind === 'unavailable' ? initialized.reason : initialized.message}\n`, + ) + return 2 + } + if (!process.stdin.isTTY || !process.stdout.isTTY) { + process.stderr.write('Interactive mode requires a terminal; use `braid rpc` for JSONL.\n') + return 2 + } + + const terminal = options.inline ? new ProcessTerminal() : new AlternateScreenTerminal() + const tui = new TUI(terminal) + const colors = !options.noColor && process.env.NO_COLOR === undefined + const startupMessages: Array<{ readonly title: string; readonly reason: string }> = [] + for (const intent of startupIntents()) { + const result = await controller.dispatch(intent) + if (result.kind !== 'accepted') { + startupMessages.push({ + title: intent.type === 'run-command' ? `/${intent.command}` : 'startup option', + reason: result.kind === 'unavailable' ? result.reason : result.message, + }) + } + } + const view = new BraidTerminalApp({ + controller, + tui, + theme: createBraidTheme({ + colors, + highContrast: options.highContrast, + reducedMotion: options.reducedMotion, + }), + workspace, + nextOperationId, + ...(configuration === undefined ? {} : { configuration }), + startupMessages, + }) + let signalExitCode: number | undefined + let signalSnapshot: Promise | undefined + let frameSnapshot: Promise | undefined + let signalCleanup: Promise | undefined + const shutdownMode = + process.env.BRAID_SHUTDOWN_MODE === 'detach' ? ('detach' as const) : ('cancel' as const) + const configuredDeadline = Number(process.env.BRAID_SHUTDOWN_DEADLINE_MS ?? 5_000) + const shutdownDeadlineMs = Number.isInteger(configuredDeadline) + ? Math.min(30_000, Math.max(250, configuredDeadline)) + : 5_000 + const stopFromSignal = (exitCode: number) => { + signalExitCode ??= exitCode + if (process.env.BRAID_CAPTURE_STATE_BEFORE_CANCEL === '1' && options.recordState) { + signalSnapshot ??= recordState(`${options.recordState}.signal`, controller) + } + signalCleanup ??= (async () => { + const cleanup = controller + .dispatch({ type: 'shutdown', operationId: nextOperationId(), mode: shutdownMode }) + .then(async (result) => { + if (result.kind === 'accepted' && result.completion) await result.completion + }) + let timer: NodeJS.Timeout | undefined + try { + await Promise.race([ + cleanup, + new Promise((resolve) => { + timer = setTimeout(() => { + active.current.app.markCleanupUncertain(`Shutdown exceeded ${shutdownDeadlineMs} ms`) + resolve() + }, shutdownDeadlineMs) + }), + ]) + } finally { + if (timer) clearTimeout(timer) + view.stop() + } + })() + } + const onFrameSnapshot = () => { + if (options.recordState) + frameSnapshot ??= recordState( + `${options.recordState}.frame`, + controller, + 'atomic-signal-frame', + ) + } + const onInterrupt = () => stopFromSignal(130) + const onTerminate = () => stopFromSignal(143) + const onHangup = () => stopFromSignal(129) + process.once('SIGINT', onInterrupt) + process.once('SIGTERM', onTerminate) + process.once('SIGHUP', onHangup) + process.once('SIGUSR2', onFrameSnapshot) + try { + await view.start() + await active.current.app.waitForIdle() + } finally { + await signalCleanup + await signalSnapshot + await frameSnapshot + process.off('SIGINT', onInterrupt) + process.off('SIGTERM', onTerminate) + process.off('SIGHUP', onHangup) + process.off('SIGUSR2', onFrameSnapshot) + view.stop() + } + if (options.recordState) await recordState(options.recordState, controller) + return signalExitCode ?? 0 +} diff --git a/src/bin/plain.ts b/src/bin/plain.ts new file mode 100644 index 0000000..1f9f473 --- /dev/null +++ b/src/bin/plain.ts @@ -0,0 +1,176 @@ +import { randomUUID } from 'node:crypto' +import { BoundedOutputQueue } from '../views/headless/bounded-output.js' +import { assertValidUnicodeString, MAX_RPC_LINE_BYTES } from '../views/headless/protocol-limits.js' +import { isMutatingCommand, parseCommandInput } from '../views/shared/command-registry.js' +import type { BraidIntent, BraidUiController } from '../views/shared/intents.js' +import { plainAccessibilityText, plainEventText } from '../views/shared/plain-accessibility.js' +import { sanitizeNotification, sanitizeTerminalText } from '../views/shared/sanitize.js' + +export interface PlainInput extends AsyncIterable {} + +export interface PlainOutput { + write(chunk: string): boolean + once?(event: 'drain', listener: () => void): unknown +} + +export interface PlainOptions { + readonly initialIntents?: readonly BraidIntent[] +} + +function nextOperationId(): string { + return `op-plain-${Date.now().toString(36)}-${randomUUID()}` +} + +export async function runPlain( + controller: BraidUiController, + workspace: string, + input: PlainInput, + output: PlainOutput, + options: PlainOptions = {}, +): Promise { + const outputQueue = new BoundedOutputQueue(output) + let outputFailure: unknown + const write = async (chunk: string): Promise => { + if (outputFailure !== undefined) throw outputFailure + try { + await outputQueue.write(chunk) + } catch (error) { + outputFailure ??= error + throw error + } + } + const emit = write + const initialized = await controller.initialize(workspace) + if (initialized.kind !== 'accepted') { + await write( + `error: ${sanitizeTerminalText(initialized.kind === 'unavailable' ? initialized.reason : initialized.message)}\n`, + ) + return 2 + } + await write(`Braid ready — ${sanitizeTerminalText(workspace)}\n`) + await write(`${plainAccessibilityText(controller.view())}\n`) + for (const intent of options.initialIntents ?? []) { + const result = await controller.dispatch(intent) + if (result.kind !== 'accepted') { + await write( + `error: ${sanitizeNotification(result.kind === 'unavailable' ? result.reason : result.message)}\n`, + ) + } + } + const unsubscribe = controller.subscribe((view, event) => { + if (!event) return + void emit(`${plainEventText(view, event)}\n`).catch(() => undefined) + }) + const decoder = new TextDecoder('utf-8', { fatal: true }) + let buffered = '' + let quitting = false + const shutdownOperationId = 'op-plain-shutdown' + const pendingCompletions = new Set>() + const trackCompletion = (completion: Promise): void => { + let tracked: Promise + tracked = completion.finally(() => pendingCompletions.delete(tracked)) + pendingCompletions.add(tracked) + } + const submit = async (line: string): Promise => { + const parsed = parseCommandInput(line) + if (parsed.kind === 'invalid') { + await write(`error: ${sanitizeNotification(parsed.message)}\n`) + return + } + if (parsed.kind === 'unknown') { + await write( + `unknown command /${sanitizeNotification(parsed.name)}; choices: ${parsed.suggestions.map((name) => `/${name}`).join(', ') || 'none'}\n`, + ) + return + } + if (parsed.kind === 'command') { + if (parsed.name === 'quit') { + const result = await controller.dispatch({ + type: 'shutdown', + operationId: shutdownOperationId, + }) + if (result.kind === 'accepted' && result.completion) await result.completion + if (result.kind !== 'accepted') + await write( + `error: ${sanitizeNotification(result.kind === 'unavailable' ? result.reason : result.message)}\n`, + ) + if (result.kind === 'accepted' && result.completion) trackCompletion(result.completion) + quitting = true + return + } + const intent: BraidIntent = { + type: 'run-command', + command: parsed.name, + args: parsed.args, + ...(isMutatingCommand(parsed.name) ? { operationId: nextOperationId() } : {}), + } + const result = await controller.dispatch(intent) + if (result.kind !== 'accepted') + await write( + `error: ${sanitizeNotification(result.kind === 'unavailable' ? result.reason : result.message)}\n`, + ) + if (result.kind === 'accepted' && result.completion) { + if (parsed.name === 'cancel') await result.completion + else trackCompletion(result.completion) + } + return + } + const view = controller.view() + const intent: BraidIntent = view.activeRunId + ? { type: 'queue', operationId: nextOperationId(), text: parsed.text } + : { type: 'send', operationId: nextOperationId(), text: parsed.text } + const result = await controller.dispatch(intent) + if (result.kind !== 'accepted') + await write( + `error: ${sanitizeNotification(result.kind === 'unavailable' ? result.reason : result.message)}\n`, + ) + if (result.kind === 'accepted' && result.completion) trackCompletion(result.completion) + } + const consume = async (chunk: string): Promise => { + assertValidUnicodeString(chunk, 'plain input') + buffered += chunk + if (Buffer.byteLength(buffered, 'utf8') > MAX_RPC_LINE_BYTES && buffered.indexOf('\n') < 0) + throw new Error('LINE_TOO_LARGE: input line exceeds the 1 MiB limit') + let newline = buffered.indexOf('\n') + while (newline >= 0) { + const line = buffered.slice(0, newline).replace(/\r$/u, '') + buffered = buffered.slice(newline + 1) + await submit(line) + if (quitting) return + newline = buffered.indexOf('\n') + } + } + if (!input[Symbol.asyncIterator]) { + unsubscribe() + return 0 + } + for await (const chunk of input) { + if (outputFailure !== undefined) throw outputFailure + let decoded: string + try { + decoded = typeof chunk === 'string' ? chunk : decoder.decode(chunk, { stream: true }) + } catch { + throw new Error('INVALID_UTF8: plain input contains malformed UTF-8') + } + await consume(decoded) + if (quitting) break + } + try { + buffered += decoder.decode() + } catch { + throw new Error('INVALID_UTF8: plain input contains malformed UTF-8') + } + if (buffered) await submit(buffered.replace(/\r$/u, '')) + if (!quitting) { + const result = await controller.dispatch({ + type: 'shutdown', + operationId: shutdownOperationId, + }) + if (result.kind === 'accepted' && result.completion) await result.completion + } + await Promise.all(pendingCompletions) + await outputQueue.flush() + if (outputFailure !== undefined) throw outputFailure + unsubscribe() + return 0 +} diff --git a/src/bin/production-application.ts b/src/bin/production-application.ts new file mode 100644 index 0000000..f241112 --- /dev/null +++ b/src/bin/production-application.ts @@ -0,0 +1,160 @@ +import { dirname, resolve } from 'node:path' +import { + createTraceAnalysisAdapter, + createTraceAnalysisAnalyst, +} from '../adapters/analysis/trace-analysis-adapter.js' +import type { ProductionConnectionOptions } from '../adapters/connections/production-connections.js' +import type { HeadlessKeySource } from '../adapters/credentials/headless-key.js' +import type { BraidApplication } from '../app/application.js' +import type { ConfigurationSelection } from '../app/configuration-session.js' +import { + createProductionComposition, + type ProductionCompositionConfig, +} from '../app/production-composition.js' +import type { CredentialPort } from '../ports/credentials.js' +import { createDurableBraidApplication } from '../startup/durable-runtime.js' +import { + createProductionCredentialContext, + type ProductionCredentialContext, +} from './production-credential-context.js' +import { productionConfigPath, resolveProductionDatabaseKeyFile } from './production-key-path.js' +import type { ProductionStartupLoadOptions } from './production-startup.js' + +export interface ProductionApplicationOpenOptions { + readonly workspace: string + readonly statePath: string + readonly startupOptions: ProductionStartupLoadOptions + readonly production: ProductionCompositionConfig +} + +export function productionConfigForSelection( + selection: ConfigurationSelection, + options: ProductionStartupLoadOptions, +): ProductionCompositionConfig { + const connectionOptions: ProductionConnectionOptions = { + ...(options.fetch === undefined ? {} : { fetch: options.fetch }), + ...(options.credentialStore === undefined ? {} : { credentials: options.credentialStore }), + ...(options.credentialRefResolver === undefined + ? {} + : { credentialRefResolver: options.credentialRefResolver }), + } + return { + profile: selection.profile.profile, + connections: [selection.connection], + connectionId: selection.connection.id, + workspaceRoot: resolve(options.workspace), + ...(options.databaseKeyFile === undefined ? {} : { databaseKeyFile: options.databaseKeyFile }), + ...(Object.keys(connectionOptions).length === 0 ? {} : { connectionOptions }), + } +} + +function databaseKeySource( + options: ProductionApplicationOpenOptions, + context: ProductionCredentialContext | undefined, +): HeadlessKeySource | undefined { + if (context !== undefined) return context.databaseKeySource + const keyFile = options.startupOptions.databaseKeyFile ?? options.production.databaseKeyFile + if (keyFile === undefined) return undefined + const configPath = productionConfigPath(options.workspace, options.startupOptions.configPath) + return { + type: 'file', + path: resolveProductionDatabaseKeyFile(keyFile, configPath, options.workspace), + workspaceRoot: resolve(options.workspace), + } +} + +async function productionIntelligence(production: ProductionCompositionConfig): Promise<{ + readonly analyst: ReturnType +}> { + const selected = createProductionComposition(production) + const analysis = await createTraceAnalysisAdapter({ + profile: selected.profile, + connection: selected.connection, + ...(production.model === undefined ? {} : { model: production.model }), + ...(production.runner === undefined ? {} : { runner: production.runner }), + ...(production.connectionOptions ?? {}), + }) + return { analyst: createTraceAnalysisAnalyst(analysis) } +} + +function withHeadlessCredentials( + options: ProductionApplicationOpenOptions, + context: ProductionCredentialContext | undefined, +): { + readonly production: ProductionCompositionConfig + readonly credentialStore: CredentialPort | undefined +} { + const configuredCredentials = + options.production.connectionOptions?.credentials ?? + options.startupOptions.credentialStore ?? + context?.store + return { + production: { + ...options.production, + connectionOptions: { + ...(options.production.connectionOptions ?? {}), + ...(configuredCredentials === undefined ? {} : { credentials: configuredCredentials }), + }, + }, + credentialStore: configuredCredentials, + } +} + +/** Opens, initializes, and flushes a durable app before setup publishes its config. */ +export async function openProductionApplication( + options: ProductionApplicationOpenOptions, +): Promise<{ readonly app: BraidApplication; readonly close: () => Promise }> { + const keyFile = options.startupOptions.databaseKeyFile ?? options.production.databaseKeyFile + const context = + options.startupOptions.credentialContext ?? + (options.startupOptions.credentialStore === undefined && keyFile !== undefined + ? createProductionCredentialContext({ + workspace: options.workspace, + databaseKeyFile: keyFile, + ...(options.startupOptions.configPath === undefined + ? {} + : { configPath: options.startupOptions.configPath }), + }) + : undefined) + const source = databaseKeySource(options, context) + const releaseContext = context?.acquire() + const prepared = withHeadlessCredentials(options, context) + try { + const intelligence = await productionIntelligence(prepared.production) + const { app, storage } = await createDurableBraidApplication({ + path: options.statePath, + storageRoot: dirname(resolve(options.statePath)), + workspaceRoot: resolve(options.workspace), + production: { ...prepared.production, workspaceRoot: resolve(options.workspace) }, + ...(prepared.credentialStore === undefined + ? {} + : { credentialStore: prepared.credentialStore }), + ...(source === undefined ? {} : { databaseKeySource: source }), + intelligence, + }) + try { + app.initialize(options.workspace) + await app.whenDurable() + } catch (error) { + await app.close().catch(() => undefined) + await storage.close().catch(() => undefined) + throw error + } + return { + app, + close: async () => { + try { + await app.close() + } catch (error: unknown) { + await storage.close().catch(() => undefined) + throw error + } finally { + releaseContext?.() + } + }, + } + } catch (error) { + releaseContext?.() + throw error + } +} diff --git a/src/bin/production-bridge-client.ts b/src/bin/production-bridge-client.ts new file mode 100644 index 0000000..76bb583 --- /dev/null +++ b/src/bin/production-bridge-client.ts @@ -0,0 +1,242 @@ +import { + appendHealthPath, + normalizeCliBridgeRuntimeBaseUrl, + stripCliBridgeVersion, +} from '../adapters/connections/production-connection-endpoints.js' +import { redactSensitiveText } from '../domain/redaction.js' + +export const DEFAULT_BRIDGE_DISCOVERY_TIMEOUT_MS = 10_000 +export const DEFAULT_MODEL_VALIDATION_TIMEOUT_MS = 60_000 +export const MAX_BRIDGE_DISCOVERY_BODY_BYTES = 1024 * 1024 +export const MAX_MODEL_VALIDATION_BODY_BYTES = 1024 * 1024 + +export type BridgeRequestErrorCode = + | 'BRIDGE_ENDPOINT_INVALID' + | 'BRIDGE_TIMEOUT' + | 'BRIDGE_UNREACHABLE' + | 'BRIDGE_RESPONSE_TOO_LARGE' + | 'BRIDGE_RESPONSE_UNREADABLE' + +export class ProductionBridgeRequestError extends Error { + readonly code: BridgeRequestErrorCode + readonly endpoint: string + + constructor(code: BridgeRequestErrorCode, endpoint: string, message: string, cause?: unknown) { + super(message, cause === undefined ? undefined : { cause }) + this.name = 'ProductionBridgeRequestError' + this.code = code + this.endpoint = endpoint + } +} + +export interface BridgeResponse { + readonly status: number + readonly ok: boolean + readonly body: string +} + +export interface BridgeRequestOptions { + readonly endpoint: string + readonly path: 'health' | 'models' | 'chat/completions' + readonly fetcher?: typeof fetch + /** An in-memory bearer token; it is never part of a connection record or error. */ + readonly auth?: string + readonly timeoutMs: number + readonly maxBodyBytes: number + readonly init?: Omit & { + readonly headers?: Readonly> + } +} + +function safeEndpoint(endpoint: string): string { + try { + const url = new URL(endpoint) + if ( + !['http:', 'https:'].includes(url.protocol) || + url.username || + url.password || + url.search || + url.hash + ) { + return '' + } + return url.toString().replace(/\/$/u, '') + } catch { + return '' + } +} + +export function normalizeBridgeEndpoint(endpoint: string): string { + const display = safeEndpoint(endpoint) + if (display === '') { + throw new ProductionBridgeRequestError( + 'BRIDGE_ENDPOINT_INVALID', + display, + 'The selected CLI Bridge endpoint must be an HTTP(S) URL without credentials, query, or fragment data', + ) + } + try { + return normalizeCliBridgeRuntimeBaseUrl(endpoint) + } catch (error) { + throw new ProductionBridgeRequestError( + 'BRIDGE_ENDPOINT_INVALID', + display, + 'The selected CLI Bridge endpoint is not a safe HTTP(S) URL', + error, + ) + } +} + +export function displayBridgeEndpoint(endpoint: string): string { + return safeEndpoint(endpoint) +} + +function bridgeUrl(endpoint: string, path: BridgeRequestOptions['path']): string { + const base = normalizeBridgeEndpoint(endpoint) + if (path === 'health') return appendHealthPath(stripCliBridgeVersion(base)) + return `${base}/${path}` +} + +function authorizationHeader(auth: string | undefined): string | undefined { + const value = auth?.trim() + if (!value) return undefined + return /^(?:Bearer|Basic)\s+/iu.test(value) ? value : `Bearer ${value}` +} + +function timeoutValue(timeoutMs: number): number { + if (!Number.isFinite(timeoutMs) || !Number.isInteger(timeoutMs) || timeoutMs < 1) { + throw new RangeError('Bridge request timeout must be a positive integer') + } + return timeoutMs +} + +async function readBoundedBody( + response: Response, + maxBodyBytes: number, + endpoint: string, +): Promise { + if (!Number.isSafeInteger(maxBodyBytes) || maxBodyBytes < 0) { + throw new RangeError('Bridge response body limit must be a non-negative integer') + } + if (response.body === null) return '' + const reader = response.body.getReader() + const chunks: Uint8Array[] = [] + let total = 0 + try { + for (;;) { + const next = await reader.read() + if (next.done) break + const chunk = next.value + total += chunk.byteLength + if (total > maxBodyBytes) { + await reader.cancel('response body limit exceeded').catch(() => undefined) + throw new ProductionBridgeRequestError( + 'BRIDGE_RESPONSE_TOO_LARGE', + endpoint, + `The CLI Bridge response at ${endpoint} exceeded the ${maxBodyBytes}-byte limit`, + ) + } + chunks.push(chunk) + } + } catch (error) { + if (error instanceof ProductionBridgeRequestError) throw error + throw new ProductionBridgeRequestError( + 'BRIDGE_RESPONSE_UNREADABLE', + endpoint, + `The CLI Bridge response at ${endpoint} could not be read`, + error, + ) + } finally { + reader.releaseLock() + } + return Buffer.concat( + chunks.map((chunk) => Buffer.from(chunk)), + total, + ).toString('utf8') +} + +/** Performs one bounded bridge request; discovery and validation share this boundary. */ +export async function requestBridge(options: BridgeRequestOptions): Promise { + const timeoutMs = timeoutValue(options.timeoutMs) + const endpoint = displayBridgeEndpoint(options.endpoint) + let url: string + try { + url = bridgeUrl(options.endpoint, options.path) + } catch (error) { + if (error instanceof ProductionBridgeRequestError) throw error + throw new ProductionBridgeRequestError( + 'BRIDGE_ENDPOINT_INVALID', + endpoint, + 'The selected CLI Bridge endpoint is invalid', + error, + ) + } + const request = options.fetcher ?? globalThis.fetch + if (typeof request !== 'function') { + throw new ProductionBridgeRequestError( + 'BRIDGE_UNREACHABLE', + endpoint, + `The CLI Bridge at ${endpoint} cannot be queried in this runtime`, + ) + } + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), timeoutMs) + timeout.unref?.() + const auth = authorizationHeader(options.auth) + try { + let response: Response + try { + response = await request(url, { + ...(options.init ?? {}), + headers: { + Accept: 'application/json', + ...(options.init?.headers ?? {}), + ...(auth === undefined ? {} : { Authorization: auth }), + }, + signal: controller.signal, + }) + } catch (error) { + if (controller.signal.aborted) { + throw new ProductionBridgeRequestError( + 'BRIDGE_TIMEOUT', + endpoint, + `The CLI Bridge request to ${endpoint} timed out after ${timeoutMs} ms`, + error, + ) + } + throw new ProductionBridgeRequestError( + 'BRIDGE_UNREACHABLE', + endpoint, + `The CLI Bridge at ${endpoint} could not be reached`, + error, + ) + } + const body = await readBoundedBody(response, options.maxBodyBytes, endpoint) + return { status: response.status, ok: response.ok, body } + } finally { + clearTimeout(timeout) + } +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +/** Extracts only short provider fields, after redaction and without returning raw bodies. */ +export function safeBridgeDetail(body: string): string | undefined { + try { + const parsed: unknown = JSON.parse(body) + if (!isRecord(parsed)) return undefined + const error = isRecord(parsed.error) ? parsed.error : parsed + const pieces = [error.code, error.type, error.message].filter( + (value): value is string => typeof value === 'string' && value.trim().length > 0, + ) + if (pieces.length === 0) return undefined + const detail = redactSensitiveText(pieces.join(': '), 256) + .replace(/[\r\n]+/gu, ' ') + .trim() + return detail.length > 0 ? detail : undefined + } catch { + return undefined + } +} diff --git a/src/bin/production-bridge-discovery.ts b/src/bin/production-bridge-discovery.ts new file mode 100644 index 0000000..8dc773b --- /dev/null +++ b/src/bin/production-bridge-discovery.ts @@ -0,0 +1,146 @@ +import { parseCliBridgeHealth } from '../adapters/connections/cli-bridge-health.js' +import type { ConnectionHealth } from '../domain/entities.js' +import { redactSensitiveText } from '../domain/redaction.js' +import { + DEFAULT_BRIDGE_DISCOVERY_TIMEOUT_MS, + MAX_BRIDGE_DISCOVERY_BODY_BYTES, + requestBridge, + safeBridgeDetail, +} from './production-bridge-client.js' +import type { ProductionStartupLoadOptions } from './production-startup.js' + +export interface BridgeModel { + readonly id: string + readonly backend?: string +} + +export interface BridgeDiscoveryResult { + readonly health: ConnectionHealth + readonly models: readonly BridgeModel[] + readonly diagnostics: readonly string[] +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +function discoveryTimeout(options: ProductionStartupLoadOptions): number { + return options.discoveryTimeoutMs ?? DEFAULT_BRIDGE_DISCOVERY_TIMEOUT_MS +} + +function discoveryDiagnostic(endpoint: string, path: string, error: unknown): string { + const detail = + error instanceof Error ? error.message : 'the bridge returned an unavailable discovery result' + return redactSensitiveText(`CLI Bridge discovery ${path} at ${endpoint} failed: ${detail}`, 512) +} + +function responseDiagnostic(endpoint: string, path: string, status: number, body: string): string { + const detail = safeBridgeDetail(body) + return [ + `CLI Bridge discovery ${path} at ${endpoint} returned HTTP ${status}`, + ...(detail === undefined ? [] : [`(${detail})`]), + ].join(' ') +} + +/** Reads the bounded bridge health and model catalogs used by first-run setup. */ +export async function discoverBridge( + options: ProductionStartupLoadOptions, + endpoint: string, +): Promise { + const [healthResult, modelsResult] = await Promise.allSettled([ + requestBridge({ + endpoint, + path: 'health', + ...(options.fetch === undefined ? {} : { fetcher: options.fetch }), + ...(options.bridgeAuth === undefined ? {} : { auth: options.bridgeAuth }), + timeoutMs: discoveryTimeout(options), + maxBodyBytes: MAX_BRIDGE_DISCOVERY_BODY_BYTES, + }), + requestBridge({ + endpoint, + path: 'models', + ...(options.fetch === undefined ? {} : { fetcher: options.fetch }), + ...(options.bridgeAuth === undefined ? {} : { auth: options.bridgeAuth }), + timeoutMs: discoveryTimeout(options), + maxBodyBytes: MAX_BRIDGE_DISCOVERY_BODY_BYTES, + }), + ]) + const diagnostics: string[] = [] + const checkedAt = new Date().toISOString() + let health: ConnectionHealth = { status: 'unknown' } + if (healthResult.status === 'fulfilled') { + const parsed = parseCliBridgeHealth( + `${endpoint}`, + healthResult.value.status, + healthResult.value.body, + checkedAt, + ) + health = parsed.health + if (!healthResult.value.ok || parsed.diagnostic !== undefined) { + diagnostics.push( + parsed.diagnostic ?? + responseDiagnostic( + endpoint, + '/health', + healthResult.value.status, + healthResult.value.body, + ), + ) + } + } else { + diagnostics.push(discoveryDiagnostic(endpoint, '/health', healthResult.reason)) + health = { + status: 'unreachable', + checkedAt, + message: 'The bridge health check could not be completed', + } + } + + if (modelsResult.status === 'rejected') { + diagnostics.push(discoveryDiagnostic(endpoint, '/v1/models', modelsResult.reason)) + return { health, models: [], diagnostics } + } + const modelsResponse = modelsResult.value + if (!modelsResponse.ok) { + diagnostics.push( + responseDiagnostic(endpoint, '/v1/models', modelsResponse.status, modelsResponse.body), + ) + return { health, models: [], diagnostics } + } + let parsed: unknown + try { + parsed = JSON.parse(modelsResponse.body) + } catch (error) { + diagnostics.push( + discoveryDiagnostic( + endpoint, + '/v1/models', + new Error('the response was not valid JSON', { cause: error }), + ), + ) + return { health, models: [], diagnostics } + } + if (!isRecord(parsed) || !Array.isArray(parsed.data)) { + diagnostics.push( + `CLI Bridge discovery /v1/models at ${endpoint}/v1/models returned no model list; choose a configured bridge backend and retry`, + ) + return { health, models: [], diagnostics } + } + const models = parsed.data.flatMap((entry): BridgeModel[] => { + if (!isRecord(entry) || typeof entry.id !== 'string' || entry.id.trim().length === 0) return [] + return [ + { + id: entry.id.trim(), + ...(typeof entry.backend === 'string' && entry.backend.trim().length > 0 + ? { backend: entry.backend.trim() } + : {}), + }, + ] + }) + if (models.length === 0) { + diagnostics.push( + `CLI Bridge discovery /v1/models at ${endpoint}/v1/models returned no usable model ids; start or configure a bridge backend`, + ) + } + return { health, models, diagnostics } +} diff --git a/src/bin/production-credential-context.ts b/src/bin/production-credential-context.ts new file mode 100644 index 0000000..f7513a0 --- /dev/null +++ b/src/bin/production-credential-context.ts @@ -0,0 +1,91 @@ +import { join, resolve } from 'node:path' +import type { HeadlessKeySource } from '../adapters/credentials/headless-key.js' +import { HeadlessCredentialStore } from '../adapters/credentials/headless-store.js' +import { readNoFollow } from '../adapters/persistence/safe-file.js' +import type { CredentialPort } from '../ports/credentials.js' +import { productionConfigPath, resolveProductionDatabaseKeyFile } from './production-key-path.js' +import { defaultBraidDataDirectory, productionWorkspaceConfigIdentity } from './state-path.js' + +const MAX_CONFIG_BYTES = 2 * 1024 * 1024 + +export interface ProductionCredentialContext { + readonly store: CredentialPort + readonly databaseKeyFile: string + readonly databaseKeySource: HeadlessKeySource + acquire(): () => void + dispose(): void +} + +interface ProductionCredentialContextOptions { + readonly workspace: string + readonly configPath?: string + readonly databaseKeyFile?: string + readonly dataDirectory?: string +} + +function configuredDatabaseKeyFile(configPath: string): string | undefined { + const bytes = readNoFollow(configPath, MAX_CONFIG_BYTES) + if (bytes === undefined) return undefined + let parsed: unknown + try { + parsed = JSON.parse(bytes.toString('utf8')) + } catch { + return undefined + } + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return undefined + const value = (parsed as { readonly databaseKeyFile?: unknown }).databaseKeyFile + return typeof value === 'string' && value.trim().length > 0 ? value : undefined +} + +function credentialDirectory(dataDirectory: string, storeIdentity: string): string { + return join(dataDirectory, 'credentials', storeIdentity) +} + +/** Creates the one key-backed credential store shared by setup, recovery, and reopen. */ +export function createProductionCredentialContext( + options: ProductionCredentialContextOptions, +): ProductionCredentialContext | undefined { + const configPath = productionConfigPath(options.workspace, options.configPath) + const configuredKeyFile = options.databaseKeyFile ?? configuredDatabaseKeyFile(configPath) + if (configuredKeyFile === undefined) return undefined + const storeIdentity = productionWorkspaceConfigIdentity(options.workspace, configPath) + const databaseKeyFile = resolveProductionDatabaseKeyFile( + configuredKeyFile, + configPath, + options.workspace, + ) + const databaseKeySource: HeadlessKeySource = { + type: 'file', + path: databaseKeyFile, + workspaceRoot: resolve(options.workspace), + } + const store = new HeadlessCredentialStore({ + root: credentialDirectory(options.dataDirectory ?? defaultBraidDataDirectory(), storeIdentity), + keySource: databaseKeySource, + storeIdentity, + }) + let references = 0 + let disposed = false + const dispose = (): void => { + if (disposed) return + disposed = true + store.dispose() + } + return { + store, + databaseKeyFile, + databaseKeySource, + acquire: () => { + if (disposed) throw new Error('The headless production credential context is closed') + references += 1 + let released = false + return () => { + if (released) return + released = true + references -= 1 + if (references === 0) dispose() + } + }, + dispose, + } +} diff --git a/src/bin/production-key-path.ts b/src/bin/production-key-path.ts new file mode 100644 index 0000000..17309f7 --- /dev/null +++ b/src/bin/production-key-path.ts @@ -0,0 +1,18 @@ +import { dirname, join, resolve } from 'node:path' +import { resolveHeadlessKeyPath } from '../adapters/credentials/headless-key.js' + +export function productionConfigPath(workspace: string, configPath?: string): string { + return resolve(configPath ?? join(workspace, '.braid', 'config.json')) +} + +/** + * Resolves a database key from the protected Braid config directory. + * Relative values never use cwd or the agent workspace as their base. + */ +export function resolveProductionDatabaseKeyFile( + keyFile: string, + configPath: string, + workspace: string, +): string { + return resolveHeadlessKeyPath(keyFile, dirname(resolve(configPath)), resolve(workspace)) +} diff --git a/src/bin/production-profile-projection.ts b/src/bin/production-profile-projection.ts new file mode 100644 index 0000000..7386a6e --- /dev/null +++ b/src/bin/production-profile-projection.ts @@ -0,0 +1,243 @@ +import { basename, join, resolve } from 'node:path' +import type { AgentProfile, HarnessType } from '@tangle-network/agent-interface' +import { + bridgeCatalogRunner, + bridgeRunnerSupportsModel, +} from '../adapters/connections/cli-bridge-model-route.js' +import type { + ProfileDiscoveryResult, + ProfileRecord, + ProfileSourceSpec, +} from '../app/profile-types.js' +import { createProfileRecord, validateProfileShape } from '../app/profiles.js' +import { compareCodeUnits } from '../domain/code-unit-order.js' +import { redactSensitiveText } from '../domain/redaction.js' +import type { BridgeModel } from './production-bridge-discovery.js' +import type { ProductionStartupLoadOptions } from './production-startup.js' + +interface CompatibleBridgeModel { + readonly id: string + readonly runner: HarnessType +} + +export interface ProfileProjection { + readonly profiles: readonly ProfileRecord[] + readonly diagnostics: readonly string[] + readonly initialProfileId?: ProfileRecord['id'] +} + +export function trustedProfileSources( + options: ProductionStartupLoadOptions, +): readonly ProfileSourceSpec[] { + if (options.profileReference !== undefined) { + const path = resolve(options.workspace, options.profileReference) + return [ + { + kind: 'file', + path, + reference: path, + label: basename(path), + writable: false, + trusted: true, + }, + ] + } + return [ + join(options.workspace, '.braid', 'profile.json'), + join(options.workspace, 'braid.profile.json'), + ].map((path) => ({ + kind: 'file' as const, + path, + reference: path, + label: basename(path), + writable: false, + trusted: true, + })) +} + +function modelRunner(model: BridgeModel): HarnessType | undefined { + const candidate = bridgeCatalogRunner(model.id, model.backend) + if (candidate === undefined) return undefined + const profile = validateProfileShape({ + name: 'Braid model candidate', + harness: candidate, + model: { default: model.id }, + }) + if (!profile.ok || profile.profile?.harness === undefined) return undefined + return bridgeRunnerSupportsModel(profile.profile.harness, model.id) + ? profile.profile.harness + : undefined +} + +function compatibleModels(models: readonly BridgeModel[]): readonly CompatibleBridgeModel[] { + const unique = new Map() + for (const model of models) { + const runner = modelRunner(model) + if (runner === undefined) continue + const key = `${runner}\u0000${model.id}` + unique.set(key, { id: model.id, runner }) + } + return [...unique.values()].sort((left, right) => + compareCodeUnits(`${left.id}\u0000${left.runner}`, `${right.id}\u0000${right.runner}`), + ) +} + +function hasRunnableTarget(profile: Readonly): boolean { + const model = profile.model?.default?.trim() + return ( + profile.harness !== undefined && + model !== undefined && + model.length > 0 && + bridgeRunnerSupportsModel(profile.harness, model) + ) +} + +function targetKey(profile: Readonly): string | undefined { + const runner = profile.harness + const model = profile.model?.default?.trim() + if (runner === undefined || model === undefined || model.length === 0) return undefined + return `${runner}\u0000${model}` +} + +function generatedProfile( + options: ProductionStartupLoadOptions, + endpoint: string, + candidate: CompatibleBridgeModel, +): ProfileRecord | undefined { + const candidateProfile = { + name: `CLI Bridge · ${candidate.runner} · ${candidate.id}`, + description: `Advertised by the CLI Bridge model catalog at ${endpoint}.`, + harness: candidate.runner, + model: { + default: candidate.id, + ...(options.effort === undefined ? {} : { reasoningEffort: options.effort }), + }, + } + const validated = validateProfileShape(candidateProfile) + if ( + !validated.ok || + validated.profile === undefined || + validated.profile.harness === undefined || + !bridgeRunnerSupportsModel(validated.profile.harness, candidate.id) + ) { + return undefined + } + return createProfileRecord( + { + kind: 'inline', + reference: `braid:first-run-cli-bridge:${candidate.runner}:${candidate.id}`, + label: 'Advertised CLI Bridge model', + writable: false, + trusted: true, + }, + validated.profile, + ) +} + +function profileSort(left: ProfileRecord, right: ProfileRecord): number { + return compareCodeUnits( + `${left.displayName}\u0000${left.id}`, + `${right.displayName}\u0000${right.id}`, + ) +} + +/** Projects every trusted profile and every compatible live model. */ +export function projectSetupProfiles( + options: ProductionStartupLoadOptions, + endpoint: string, + models: readonly BridgeModel[], + discovered: ProfileDiscoveryResult, +): ProfileProjection { + const candidates = compatibleModels(models) + const explicitModel = options.model?.trim() + const explicitRunner = options.runner?.trim() + const matching = candidates.filter( + (candidate) => + (explicitModel === undefined || candidate.id === explicitModel) && + (explicitRunner === undefined || candidate.runner === explicitRunner), + ) + const diagnostics = discovered.issues.map((issue) => + redactSensitiveText(`${issue.source.label}: ${issue.issue.message}`, 512), + ) + if ( + explicitModel !== undefined && + !candidates.some((candidate) => candidate.id === explicitModel) + ) { + diagnostics.push( + `Requested model ${explicitModel} was not advertised by the CLI Bridge at ${endpoint}; the full catalog remains available and any matching trusted profile will still be validated when selected`, + ) + } + if ( + explicitRunner !== undefined && + !candidates.some((candidate) => candidate.runner === explicitRunner) + ) { + diagnostics.push( + `Requested runner ${explicitRunner} has no compatible advertised model at ${endpoint}; the full catalog remains available and any matching trusted profile will still be validated when selected`, + ) + } + if ((explicitModel !== undefined || explicitRunner !== undefined) && matching.length === 0) { + diagnostics.push( + `No advertised model matches the requested runner/model at ${endpoint}; choose a catalog entry or configure that bridge backend`, + ) + } + + const trusted = discovered.profiles.filter((record) => record.source.trusted).sort(profileSort) + const profiles: ProfileRecord[] = [...trusted] + for (const record of trusted) { + if (!hasRunnableTarget(record.profile)) { + diagnostics.push( + `Trusted profile ${redactSensitiveText(record.displayName, 256)} has no compatible runner/model pair; selecting it will remain unavailable until it is configured`, + ) + } + } + const knownTargets = new Set( + trusted + .map((record) => targetKey(record.profile)) + .filter((key): key is string => key !== undefined), + ) + for (const candidate of candidates) { + const generated = generatedProfile(options, endpoint, candidate) + if (generated === undefined) { + diagnostics.push( + `Advertised model ${candidate.id} could not be represented by the installed agent-interface package; it was not offered`, + ) + continue + } + const key = targetKey(generated.profile) + if (key !== undefined && knownTargets.has(key)) continue + if (key !== undefined) knownTargets.add(key) + profiles.push(generated) + } + profiles.sort(profileSort) + + const explicitProfileMatches = profiles.filter((record) => { + const model = record.profile.model?.default + return ( + (explicitModel === undefined || model === explicitModel) && + (explicitRunner === undefined || record.profile.harness === explicitRunner) + ) + }) + const initialCandidate = matching[0] + const initial = + (initialCandidate === undefined + ? undefined + : profiles.find( + (record) => + record.profile.harness === initialCandidate.runner && + record.profile.model?.default === initialCandidate.id, + )) ?? explicitProfileMatches[0] + if (profiles.length === 0) { + diagnostics.push( + `No trusted AgentProfile or compatible advertised model was discovered at ${endpoint}; configure a profile or start a ready CLI Bridge backend`, + ) + } else if (!profiles.some((record) => hasRunnableTarget(record.profile))) { + diagnostics.push( + `No runnable runner/model pair was discovered at ${endpoint}; choose a live advertised model or repair a trusted profile before applying setup`, + ) + } + return { + profiles, + diagnostics, + ...(initial === undefined ? {} : { initialProfileId: initial.id }), + } +} diff --git a/src/bin/production-setup-credentials.ts b/src/bin/production-setup-credentials.ts new file mode 100644 index 0000000..575c68d --- /dev/null +++ b/src/bin/production-setup-credentials.ts @@ -0,0 +1,331 @@ +import { randomUUID } from 'node:crypto' +import { mkdir } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import { canonicalCandidateJson } from '@tangle-network/agent-interface' +import { createOperatingSystemCredentialStore } from '../adapters/credentials/os.js' +import { + assertNoSymlinkPath, + assertSafeDirectory, + readNoFollow, + removePrivateFile, + writePrivateFile, +} from '../adapters/persistence/safe-file.js' +import type { ConfigurationSelection } from '../app/configuration-session.js' +import { createCredentialRefId } from '../domain/ids.js' +import type { CredentialPort, CredentialRef } from '../ports/credentials.js' +import { credentialRef } from '../ports/credentials.js' +import type { ProductionCredentialContext } from './production-credential-context.js' +import { resolveProductionDatabaseKeyFile } from './production-key-path.js' +import type { ProductionStartupLoadOptions } from './production-setup-types.js' + +const MAX_PENDING_CREDENTIAL_BYTES = 16 * 1024 + +interface PendingCredentialMarker { + readonly format: 'braid-pending-cli-bridge-credential' + readonly schemaVersion: 1 + readonly connectionId: string + readonly credentialId: string + readonly portRef: CredentialRef +} + +interface PendingCredentialMarkerHandle { + readonly commit: () => Promise + readonly rollback: () => Promise +} + +export interface PreparedProductionSelection { + readonly selection: ConfigurationSelection + readonly startupOptions: ProductionStartupLoadOptions + /** Removes a credential written for a transition that did not commit. */ + readonly rollback: () => Promise + /** Removes the crash-recovery marker after the new config is active. */ + readonly commit: () => Promise +} + +function credentialStoreError(message: string, cause?: unknown): Error { + return new Error(message, cause === undefined ? undefined : { cause }) +} + +function noCredentialRollback(): () => Promise { + return async () => undefined +} + +function noCredentialCommit(): () => Promise { + return async () => undefined +} + +function pendingCredentialPath(configPath: string): string { + return `${resolve(configPath)}.pending-cli-bridge` +} + +function parsePendingCredentialMarker(bytes: Buffer, path: string): PendingCredentialMarker { + let parsed: unknown + try { + parsed = JSON.parse(bytes.toString('utf8')) + } catch (error) { + throw new Error(`The pending CLI Bridge credential marker is not valid JSON: ${path}`, { + cause: error, + }) + } + if ( + parsed === null || + typeof parsed !== 'object' || + Array.isArray(parsed) || + (parsed as { readonly format?: unknown }).format !== 'braid-pending-cli-bridge-credential' || + (parsed as { readonly schemaVersion?: unknown }).schemaVersion !== 1 + ) { + throw new Error(`The pending CLI Bridge credential marker has an unsupported format: ${path}`) + } + const candidate = parsed as Record + if ( + typeof candidate.connectionId !== 'string' || + typeof candidate.credentialId !== 'string' || + typeof candidate.portRef !== 'string' + ) { + throw new Error(`The pending CLI Bridge credential marker is incomplete: ${path}`) + } + let credentialId: ReturnType + let portRef: CredentialRef + try { + credentialId = createCredentialRefId(candidate.credentialId) + portRef = credentialRef(candidate.portRef) + } catch (error) { + throw new Error( + `The pending CLI Bridge credential marker contains invalid references: ${path}`, + { + cause: error, + }, + ) + } + return { + format: 'braid-pending-cli-bridge-credential', + schemaVersion: 1, + connectionId: candidate.connectionId, + credentialId, + portRef, + } +} + +function configContainsCredential(configPath: string, marker: PendingCredentialMarker): boolean { + const bytes = readNoFollow(configPath, 2 * 1024 * 1024) + if (bytes === undefined) return false + try { + const parsed: unknown = JSON.parse(bytes.toString('utf8')) + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return false + const connections = (parsed as { readonly connections?: unknown }).connections + if (!Array.isArray(connections)) return false + return connections.some((record) => { + if (record === null || typeof record !== 'object' || Array.isArray(record)) return false + const candidate = record as { readonly id?: unknown; readonly credentialRef?: unknown } + return candidate.id === marker.connectionId && candidate.credentialRef === marker.credentialId + }) + } catch { + return false + } +} + +async function removePendingMarker(path: string, bytes: Buffer): Promise { + const current = readNoFollow(path, MAX_PENDING_CREDENTIAL_BYTES) + if (current?.equals(bytes)) removePrivateFile(path) +} + +async function writePendingCredentialMarker( + configPath: string, + marker: PendingCredentialMarker, +): Promise { + const target = pendingCredentialPath(configPath) + const directory = dirname(target) + assertNoSymlinkPath(directory) + await mkdir(directory, { recursive: true, mode: 0o700 }) + assertNoSymlinkPath(directory) + assertSafeDirectory(directory) + const bytes = Buffer.from(`${canonicalCandidateJson(marker)}\n`, 'utf8') + writePrivateFile(target, bytes) + let finished = false + const cleanup = async (): Promise => { + if (finished) return + await removePendingMarker(target, bytes) + finished = true + } + return { + commit: cleanup, + rollback: cleanup, + } +} + +/** Reclaims an unfinished credential write without exposing credential data. */ +export async function recoverPendingProductionCredential( + configPath: string, + options: { + readonly credentialStore?: CredentialPort + readonly credentialContext?: ProductionCredentialContext + } = {}, +): Promise { + const target = pendingCredentialPath(configPath) + const bytes = readNoFollow(target, MAX_PENDING_CREDENTIAL_BYTES) + if (bytes === undefined) return + const marker = parsePendingCredentialMarker(bytes, target) + if (configContainsCredential(configPath, marker)) { + await removePendingMarker(target, bytes) + return + } + let store: CredentialPort + try { + store = + options.credentialStore ?? + options.credentialContext?.store ?? + createOperatingSystemCredentialStore() + } catch (error) { + throw credentialStoreError( + 'An unfinished CLI Bridge credential write needs recovery, but no secure operating-system credential store is available; unlock or configure it before continuing', + error, + ) + } + try { + await store.remove(marker.portRef) + } catch (error) { + throw credentialStoreError( + 'An unfinished CLI Bridge credential write could not be removed from the secure operating-system credential store; unlock it and retry', + error, + ) + } + await removePendingMarker(target, bytes) +} + +/** + * Moves an explicit Bridge token into secure storage before validation. + * The returned startup options deliberately omit the token and resolve the + * selected connection through its durable Braid credential id. + */ +export async function prepareProductionSelection( + options: ProductionStartupLoadOptions, + selection: ConfigurationSelection, + configPath: string, +): Promise { + await recoverPendingProductionCredential(configPath, { + ...(options.credentialStore === undefined ? {} : { credentialStore: options.credentialStore }), + ...(options.credentialContext === undefined + ? {} + : { credentialContext: options.credentialContext }), + }) + const preparedOptions: ProductionStartupLoadOptions = + options.databaseKeyFile === undefined + ? options + : { + ...options, + databaseKeyFile: resolveProductionDatabaseKeyFile( + options.databaseKeyFile, + configPath, + options.workspace, + ), + } + const rawAuth = preparedOptions.bridgeAuth + if (rawAuth === undefined || rawAuth.trim().length === 0) { + return { + selection, + startupOptions: preparedOptions, + rollback: noCredentialRollback(), + commit: noCredentialCommit(), + } + } + if (selection.connection.kind !== 'cli-bridge') { + throw new Error( + 'BRAID_CLI_BRIDGE_AUTH is set, but the selected connection is not a CLI Bridge connection; choose the Bridge connection or remove that setting', + ) + } + + let store: CredentialPort + try { + store = + preparedOptions.credentialStore ?? + preparedOptions.credentialContext?.store ?? + createOperatingSystemCredentialStore() + } catch (error) { + throw credentialStoreError( + 'CLI Bridge authentication was supplied, but no secure operating-system credential store is available; unlock or configure the credential store before setup', + error, + ) + } + let available: boolean + try { + available = await store.available() + } catch (error) { + throw credentialStoreError( + 'CLI Bridge authentication was supplied, but the secure operating-system credential store could not be checked; unlock or configure it before setup', + error, + ) + } + if (!available) { + throw new Error( + 'CLI Bridge authentication was supplied, but the secure operating-system credential store is unavailable; unlock or configure it before setup', + ) + } + + const credentialId = createCredentialRefId(`credential-cli-bridge-${randomUUID()}`) + const portRef = credentialRef(`cred:v1:${credentialId}`) + const pending = await writePendingCredentialMarker(configPath, { + format: 'braid-pending-cli-bridge-credential', + schemaVersion: 1, + connectionId: selection.connection.id, + credentialId, + portRef, + }) + const secret = Buffer.from(rawAuth, 'utf8') + try { + try { + const storedRef = await store.store({ + ref: portRef, + value: secret, + label: 'Braid CLI Bridge authentication', + }) + if (storedRef !== portRef) { + await store.remove(storedRef).catch(() => undefined) + await store.remove(portRef).catch(() => undefined) + throw new Error('The secure credential store returned a different credential reference') + } + } catch (error) { + await store.remove(portRef).catch(() => undefined) + await pending.rollback().catch(() => undefined) + throw credentialStoreError( + 'CLI Bridge authentication could not be saved in the secure operating-system credential store; setup was not applied', + error, + ) + } + } finally { + secret.fill(0) + } + + const { bridgeAuth: ignoredAuth, ...optionsWithoutAuth } = preparedOptions + void ignoredAuth + const suppliedResolver = preparedOptions.credentialRefResolver + const startupOptions: ProductionStartupLoadOptions = { + ...optionsWithoutAuth, + credentialStore: store, + credentialRefResolver: async (ref) => { + if (ref === credentialId) return portRef + if (suppliedResolver !== undefined) return suppliedResolver(ref) + return credentialRef(`cred:v1:${ref}`) + }, + } + const preparedSelection: ConfigurationSelection = { + ...selection, + connection: { + ...selection.connection, + credentialRef: credentialId, + }, + } + let rolledBack = false + return { + selection: preparedSelection, + startupOptions, + rollback: async () => { + if (rolledBack) return + await store.remove(portRef) + await pending.rollback() + rolledBack = true + }, + commit: async () => { + await pending.commit().catch(() => undefined) + }, + } +} diff --git a/src/bin/production-setup-discovery.ts b/src/bin/production-setup-discovery.ts new file mode 100644 index 0000000..ae80fda --- /dev/null +++ b/src/bin/production-setup-discovery.ts @@ -0,0 +1,100 @@ +import { stripCliBridgeVersion } from '../adapters/connections/production-connection-endpoints.js' +import { discoverProfiles } from '../app/profiles.js' +import type { ConnectionRecord } from '../domain/entities.js' +import { createConnectionId } from '../domain/ids.js' +import { + normalizeBridgeEndpoint, + ProductionBridgeRequestError, +} from './production-bridge-client.js' +import { discoverBridge } from './production-bridge-discovery.js' +import { productionConfigPath, resolveProductionDatabaseKeyFile } from './production-key-path.js' +import { projectSetupProfiles, trustedProfileSources } from './production-profile-projection.js' +import { recoverPendingProductionCredential } from './production-setup-credentials.js' +import type { ProductionStartupSetup } from './production-setup-types.js' +import { ProductionStartupError, type ProductionStartupLoadOptions } from './production-startup.js' + +export const DEFAULT_CLI_BRIDGE_ENDPOINT = 'http://127.0.0.1:3344' + +function cliBridgeEndpoint(options: ProductionStartupLoadOptions): string { + return options.cliBridgeEndpoint ?? DEFAULT_CLI_BRIDGE_ENDPOINT +} + +function canonicalBridgeEndpoint(options: ProductionStartupLoadOptions): string { + const configured = cliBridgeEndpoint(options) + try { + return stripCliBridgeVersion(normalizeBridgeEndpoint(configured)) + } catch (error) { + throw new ProductionStartupError( + 'PRODUCTION_CONFIGURATION_INVALID', + error instanceof ProductionBridgeRequestError + ? error.message + : 'The selected CLI Bridge endpoint is invalid; use an HTTP(S) URL without credentials, query, or fragment data', + error, + ) + } +} + +function setupConnection( + endpoint: string, + health: ConnectionRecord['lastHealth'], +): ConnectionRecord { + const now = new Date().toISOString() + return { + id: createConnectionId('connection-local-cli-bridge'), + kind: 'cli-bridge', + name: 'Local CLI Bridge', + endpoint, + providerOptions: { + transport: 'local', + capabilityHints: ['stream', 'usage'], + }, + createdAt: now, + updatedAt: now, + lastHealth: health, + } +} + +/** Composes first-run setup from independently bounded bridge and profile services. */ +export async function loadProductionSetup( + options: ProductionStartupLoadOptions, +): Promise { + if (!options.workspace) { + throw new ProductionStartupError( + 'PRODUCTION_CONFIGURATION_REQUIRED', + 'Production startup requires a workspace path', + ) + } + const configPath = productionConfigPath(options.workspace, options.configPath) + if (options.databaseKeyFile !== undefined) { + resolveProductionDatabaseKeyFile(options.databaseKeyFile, configPath, options.workspace) + } + await recoverPendingProductionCredential(configPath, { + ...(options.credentialStore === undefined ? {} : { credentialStore: options.credentialStore }), + ...(options.credentialContext === undefined + ? {} + : { credentialContext: options.credentialContext }), + }) + const endpoint = canonicalBridgeEndpoint(options) + const [bridge, discovered] = await Promise.all([ + discoverBridge(options, endpoint), + discoverProfiles({ + explicit: trustedProfileSources(options), + resolverContext: { workspaceRoot: options.workspace }, + }), + ]) + const profiles = projectSetupProfiles(options, endpoint, bridge.models, discovered) + return { + configPath, + profiles: profiles.profiles, + connections: [setupConnection(endpoint, bridge.health)], + diagnostics: [...bridge.diagnostics, ...profiles.diagnostics], + ...(profiles.initialProfileId === undefined + ? {} + : { initialProfileId: profiles.initialProfileId }), + verification: { + status: 'unverified', + detail: + 'Discovery only: /health and /v1/models do not prove that the selected model can authenticate or run.', + }, + } +} diff --git a/src/bin/production-setup-persistence.ts b/src/bin/production-setup-persistence.ts new file mode 100644 index 0000000..b8135c6 --- /dev/null +++ b/src/bin/production-setup-persistence.ts @@ -0,0 +1,267 @@ +import { mkdir } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import { + type AgentProfile, + canonicalCandidateJson, + snapshotAgentProfile, +} from '@tangle-network/agent-interface' +import { + assertNoSymlinkPath, + assertSafeDirectory, + readNoFollow, + removePrivateFile, + replacePrivateFile, + writePrivateFile, +} from '../adapters/persistence/safe-file.js' +import type { ConfigurationSelection } from '../app/configuration-session.js' + +const MAX_STARTUP_CONFIG_BYTES = 2 * 1024 * 1024 + +export interface ProductionStartupPersistence { + readonly rollback: () => Promise +} + +const INLINE_SECRET_VALUE = + /(?:\bsk[-_][-_A-Za-z0-9]{10,}\b|\bgh[pousr]_[A-Za-z0-9]{12,}\b|\bgithub_pat_[A-Za-z0-9_]{12,}\b|\bAKIA[A-Z0-9]{12,}\b|-----BEGIN [A-Z ]*PRIVATE KEY-----|\b(?:api[-_ ]?key|access[-_ ]?token|refresh[-_ ]?token|client[-_ ]?secret|password|credential|secret)\b\s*[:=]\s*\S+|\b(?:canary|fixture|test)[-_ ]?(?:secret|token|credential|key|auth|bearer|basic)\b|\b(?:secret|token|credential|key|auth|bearer|basic)[-_ ]?(?:canary|fixture|test)\b)/iu + +const AUTH_SCHEME_VALUE = /\b(?:Bearer|Basic)\s+([A-Za-z0-9+/_=.-]{8,})/iu +const AUTH_SCHEME_PROSE = new Set([ + 'auth', + 'authentication', + 'authorization', + 'basic', + 'bearer', + 'credential', + 'credentials', + 'header', + 'headers', + 'scheme', + 'token', + 'tokens', + 'value', + 'values', +]) + +function hasObviousSecretValue(value: string): boolean { + if (INLINE_SECRET_VALUE.test(value)) return true + const schemeValue = AUTH_SCHEME_VALUE.exec(value)?.[1] + if (schemeValue === undefined || AUTH_SCHEME_PROSE.has(schemeValue.toLowerCase())) return false + if (/(?:canary|fixture|test|secret|token|credential|password|key|auth)/iu.test(schemeValue)) + return true + if (/^[A-Za-z0-9+/]{12,}={0,2}$/u.test(schemeValue)) { + const hasUpper = /[A-Z]/u.test(schemeValue) + const hasLower = /[a-z]/u.test(schemeValue) + if (hasUpper && hasLower) return true + } + return schemeValue.length >= 20 +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +function hasOnlyKeys(value: Record, allowed: readonly string[]): boolean { + const accepted = new Set(allowed) + return Object.keys(value).every((key) => accepted.has(key)) +} + +function validateTypedConfigValue(value: unknown, path: string): void { + if (!isRecord(value)) { + throw new Error( + `Typed credential configuration at ${path} must use a typed public value or typed secret-ref wrapper`, + ) + } + if (value.kind === 'public') { + if (!hasOnlyKeys(value, ['kind', 'value']) || typeof value.value !== 'string') { + throw new Error(`Typed credential configuration at ${path} has an invalid public wrapper`) + } + return + } + if (value.kind === 'secret-ref') { + if ( + !hasOnlyKeys(value, ['kind', 'key', 'format']) || + typeof value.key !== 'string' || + (value.format !== undefined && value.format !== 'raw' && value.format !== 'bearer') + ) { + throw new Error(`Typed credential configuration at ${path} has an invalid secret-ref wrapper`) + } + return + } + throw new Error( + `Typed credential configuration at ${path} must use a typed public value or typed secret-ref wrapper`, + ) +} + +function validateTypedConfigRecord(value: unknown, path: string): void { + if (!isRecord(value)) { + throw new Error(`Typed credential configuration at ${path} must be an object`) + } + for (const [key, child] of Object.entries(value)) { + validateTypedConfigValue(child, `${path}.${key}`) + } +} + +function validateTypedConfigArray(value: unknown, path: string): void { + if (!Array.isArray(value)) { + throw new Error(`Typed credential configuration at ${path} must be an array`) + } + value.forEach((child, index) => { + validateTypedConfigValue(child, `${path}[${index}]`) + }) +} + +function validateTypedConfigLocations(profile: Readonly): void { + const hooks = profile.hooks + if (isRecord(hooks)) { + for (const [name, commands] of Object.entries(hooks)) { + if (!Array.isArray(commands)) { + throw new Error(`Typed credential configuration at hooks.${name} must be an array`) + } + commands.forEach((command, index) => { + if (isRecord(command) && command.env !== undefined) { + validateTypedConfigRecord(command.env, `hooks.${name}[${index}].env`) + } + }) + } + } + const mcp = profile.mcp + if (!isRecord(mcp)) return + for (const [name, server] of Object.entries(mcp)) { + if (!isRecord(server)) continue + if (server.args !== undefined) validateTypedConfigArray(server.args, `mcp.${name}.args`) + if (server.env !== undefined) validateTypedConfigRecord(server.env, `mcp.${name}.env`) + if (server.headers !== undefined) + validateTypedConfigRecord(server.headers, `mcp.${name}.headers`) + } +} + +function rejectObviousSecretValues(value: unknown, path: string, seen = new Set()): void { + if (typeof value === 'string') { + if (hasObviousSecretValue(value)) { + throw new Error( + `Inline credential material is not allowed at ${path}; use a typed secret-ref`, + ) + } + return + } + if (value === null || typeof value !== 'object') return + if (seen.has(value)) throw new Error(`The profile contains a cyclic value at ${path}`) + seen.add(value) + try { + if (Array.isArray(value)) { + value.forEach((entry, index) => { + rejectObviousSecretValues(entry, `${path}[${index}]`, seen) + }) + return + } + for (const [key, child] of Object.entries(value)) { + rejectObviousSecretValues(child, `${path}.${key}`, seen) + } + } finally { + seen.delete(value) + } +} + +/** Returns the exact validated profile that is safe to publish and reload. */ +export function persistableProductionProfile(profile: Readonly): AgentProfile { + validateTypedConfigLocations(profile) + let snapshot: AgentProfile + try { + snapshot = snapshotAgentProfile(profile) + } catch (error) { + throw new Error('The selected profile is not a canonical AgentProfile', { cause: error }) + } + rejectObviousSecretValues(snapshot, 'profile') + try { + canonicalCandidateJson(snapshot) + } catch (error) { + throw new Error('The selected profile has no canonical JSON representation', { cause: error }) + } + return snapshot +} + +function serializedSelection(selection: ConfigurationSelection, databaseKeyFile?: string): string { + return `${canonicalCandidateJson({ + format: 'braid-startup-config', + schemaVersion: 1, + profile: persistableProductionProfile(selection.profile.profile), + connectionId: selection.connection.id, + connections: [selection.connection], + ...(databaseKeyFile === undefined ? {} : { databaseKeyFile }), + })}\n` +} + +function publish(target: string, bytes: Buffer, previous: Buffer | undefined): void { + if (previous === undefined) { + writePrivateFile(target, bytes) + return + } + replacePrivateFile(target, bytes, { + overwrite: true, + expected: (current) => { + if (current === undefined || !current.equals(previous)) { + throw new Error('The production configuration changed while setup was applying') + } + }, + maxExistingBytes: MAX_STARTUP_CONFIG_BYTES, + }) +} + +function restore(target: string, bytes: Buffer, previous: Buffer | undefined): void { + if (previous !== undefined) { + replacePrivateFile(target, previous, { + overwrite: true, + expected: (current) => { + if (current === undefined || !current.equals(bytes)) { + throw new Error('The production configuration changed during setup rollback') + } + }, + maxExistingBytes: MAX_STARTUP_CONFIG_BYTES, + }) + return + } + const current = readNoFollow(target, MAX_STARTUP_CONFIG_BYTES) + if (current?.equals(bytes)) removePrivateFile(target) +} + +/** Publishes a selection only after the caller has prepared the replacement app. */ +export async function persistProductionStartupSelection( + configPath: string, + selection: ConfigurationSelection, + options: { readonly databaseKeyFile?: string } = {}, +): Promise { + const target = resolve(configPath) + const directory = dirname(target) + assertNoSymlinkPath(directory) + await mkdir(directory, { recursive: true, mode: 0o700 }) + assertNoSymlinkPath(directory) + assertSafeDirectory(directory) + const previous = readNoFollow(target, MAX_STARTUP_CONFIG_BYTES) + const bytes = Buffer.from(serializedSelection(selection, options.databaseKeyFile), 'utf8') + try { + publish(target, bytes, previous) + } catch (error) { + try { + restore(target, bytes, previous) + } catch (rollbackError) { + throw new Error('The production configuration could not be safely rolled back', { + cause: rollbackError, + }) + } + throw error + } + return { + rollback: async () => { + restore(target, bytes, previous) + }, + } +} + +/** Persists selected metadata only; credentials remain in the operating-system store. */ +export async function saveProductionStartupSelection( + configPath: string, + selection: ConfigurationSelection, + options: { readonly databaseKeyFile?: string } = {}, +): Promise { + await persistProductionStartupSelection(configPath, selection, options) +} diff --git a/src/bin/production-setup-transition.ts b/src/bin/production-setup-transition.ts new file mode 100644 index 0000000..e0c4740 --- /dev/null +++ b/src/bin/production-setup-transition.ts @@ -0,0 +1,139 @@ +import type { BraidApplication } from '../app/application.js' +import type { ConfigurationSelection } from '../app/configuration-session.js' +import { prepareProductionSelection } from './production-setup-credentials.js' +import { + type ProductionStartupPersistence, + persistProductionStartupSelection, +} from './production-setup-persistence.js' +import type { + ProductionSetupVerification, + ProductionStartupSetup, +} from './production-setup-types.js' +import { validateProductionSelection } from './production-setup-validation.js' +import type { ProductionStartupLoadOptions } from './production-startup.js' + +export interface ProductionApplicationHandle { + readonly app: BraidApplication + readonly close: () => Promise +} + +export interface ProductionApplicationSlot { + current: ProductionApplicationHandle +} + +export interface ProductionSetupController { + /** + * Replaces the active app atomically: a rejected promise must leave the old + * app installed and must not publish a partially installed replacement. + */ + replaceApplication(next: BraidApplication, workspace: string): Promise +} + +export interface ProductionSetupTransitionOptions { + readonly setup: ProductionStartupSetup + readonly startupOptions: ProductionStartupLoadOptions + readonly selection: ConfigurationSelection + readonly workspace: string + readonly controller: ProductionSetupController + readonly active: ProductionApplicationSlot + /** Opens and durably initializes from the in-memory selection. */ + readonly openApplication: ( + selection: ConfigurationSelection, + options: ProductionStartupLoadOptions, + ) => Promise + readonly validate?: typeof validateProductionSelection + readonly persist?: ( + configPath: string, + selection: ConfigurationSelection, + options?: { readonly databaseKeyFile?: string }, + ) => Promise +} + +async function closeAfterFailure( + next: ProductionApplicationHandle, + failure: unknown, + rollbackCredential: () => Promise, +): Promise { + await next.close().catch(() => undefined) + try { + await rollbackCredential() + } catch (rollbackError) { + throw new Error( + 'The secure CLI Bridge credential could not be rolled back after setup failed', + { + cause: rollbackError, + }, + ) + } + throw failure +} + +async function failBeforeOpen( + rollbackCredential: () => Promise, + failure: unknown, +): Promise { + try { + await rollbackCredential() + } catch (rollbackError) { + throw new Error( + 'The secure CLI Bridge credential could not be rolled back after setup failed', + { + cause: rollbackError, + }, + ) + } + throw failure +} + +/** Validates, prepares, persists, swaps, and finally closes the previous application. */ +export async function transitionProductionSelection( + options: ProductionSetupTransitionOptions, +): Promise { + const prepared = await prepareProductionSelection( + options.startupOptions, + options.selection, + options.setup.configPath, + ) + let verification: ProductionSetupVerification + let next: ProductionApplicationHandle + try { + verification = await (options.validate ?? validateProductionSelection)( + prepared.startupOptions, + prepared.selection, + ) + next = await options.openApplication(prepared.selection, prepared.startupOptions) + } catch (error) { + return failBeforeOpen(prepared.rollback, error) + } + let persistence: ProductionStartupPersistence + try { + persistence = await (options.persist ?? persistProductionStartupSelection)( + options.setup.configPath, + prepared.selection, + prepared.startupOptions, + ) + } catch (error) { + return closeAfterFailure(next, error, prepared.rollback) + } + try { + await options.controller.replaceApplication(next.app, options.workspace) + } catch (error) { + try { + await persistence.rollback() + } catch (rollbackError) { + return closeAfterFailure( + next, + new Error('The production configuration could not be rolled back after activation failed', { + cause: rollbackError, + }), + prepared.rollback, + ) + } + return closeAfterFailure(next, error, prepared.rollback) + } + const previous = options.active.current + options.active.current = next + await previous.close().catch(() => undefined) + await prepared.commit() + return verification +} diff --git a/src/bin/production-setup-types.ts b/src/bin/production-setup-types.ts new file mode 100644 index 0000000..9f9bc07 --- /dev/null +++ b/src/bin/production-setup-types.ts @@ -0,0 +1,44 @@ +import type { ProductionConnectionOptions } from '../adapters/connections/production-connections.js' +import type { ProfileRecord } from '../app/profile-types.js' +import type { ConnectionRecord } from '../domain/entities.js' +import type { CredentialPort } from '../ports/credentials.js' +import type { ProductionCredentialContext } from './production-credential-context.js' + +export interface ProductionStartupLoadOptions { + readonly workspace: string + readonly configPath?: string + readonly profileReference?: string + readonly connectionId?: string + readonly model?: string + readonly runner?: string + readonly effort?: string + /** Protected headless SQLite key file, absolute or relative to the config directory. */ + readonly databaseKeyFile?: string + readonly fetch?: typeof fetch + /** Explicit non-default bridge endpoint for isolated local or test setups. */ + readonly cliBridgeEndpoint?: string + /** Optional in-memory bridge authorization; never persisted in startup config. */ + readonly bridgeAuth?: string + /** Discovery timeout for /health and /v1/models. */ + readonly discoveryTimeoutMs?: number + /** Realistic bounded timeout for a cold first-run model request. */ + readonly modelValidationTimeoutMs?: number + readonly credentialStore?: CredentialPort + /** Shared key-backed store for setup, recovery, and the durable app. */ + readonly credentialContext?: ProductionCredentialContext + readonly credentialRefResolver?: ProductionConnectionOptions['credentialRefResolver'] +} + +export interface ProductionSetupVerification { + readonly status: 'unverified' | 'verified' + readonly detail: string +} + +export interface ProductionStartupSetup { + readonly configPath: string + readonly profiles: readonly ProfileRecord[] + readonly connections: readonly ConnectionRecord[] + readonly diagnostics: readonly string[] + readonly initialProfileId?: ProfileRecord['id'] + readonly verification: ProductionSetupVerification +} diff --git a/src/bin/production-setup-validation.ts b/src/bin/production-setup-validation.ts new file mode 100644 index 0000000..0ee0404 --- /dev/null +++ b/src/bin/production-setup-validation.ts @@ -0,0 +1,208 @@ +import { harnessHonorsEffort, harnessHonorsModel } from '@tangle-network/agent-interface' +import { readConnectionCredential } from '../adapters/connections/production-connections.js' +import type { + ConfigurationEffectiveValues, + ConfigurationSelection, +} from '../app/configuration-session.js' +import { + DEFAULT_MODEL_VALIDATION_TIMEOUT_MS, + displayBridgeEndpoint, + MAX_MODEL_VALIDATION_BODY_BYTES, + ProductionBridgeRequestError, + requestBridge, + safeBridgeDetail, +} from './production-bridge-client.js' +import type { ProductionSetupVerification } from './production-setup-types.js' +import type { ProductionStartupLoadOptions } from './production-startup.js' + +function validationTimeout(options: ProductionStartupLoadOptions): number { + const timeout = options.modelValidationTimeoutMs ?? DEFAULT_MODEL_VALIDATION_TIMEOUT_MS + if (!Number.isSafeInteger(timeout) || timeout < 1 || timeout > 10 * 60_000) { + throw new Error('CLI Bridge model validation timeout must be an integer from 1 to 600000 ms') + } + return timeout +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +function completionContent(body: string): string | undefined { + let parsed: unknown + try { + parsed = JSON.parse(body) + } catch { + return undefined + } + if (!isRecord(parsed) || !Array.isArray(parsed.choices)) return undefined + const first = parsed.choices[0] + if (!isRecord(first)) return undefined + const message = isRecord(first.message) ? first.message : undefined + if (typeof message?.content === 'string' && message.content.trim().length > 0) { + return message.content.trim() + } + if (Array.isArray(message?.content)) { + const text = message.content + .flatMap((part) => (isRecord(part) && typeof part.text === 'string' ? [part.text] : [])) + .join('') + .trim() + if (text.length > 0) return text + } + if (typeof first.text === 'string' && first.text.trim().length > 0) return first.text.trim() + return undefined +} + +function isExactValidationMarker(body: string): boolean { + return completionContent(body)?.replace(/\s+/gu, ' ').trim() === 'OK' +} + +function providerFailure(endpoint: string, model: string, status: number, body: string): Error { + const detail = safeBridgeDetail(body) + if (status === 501 && /not_configured/iu.test(body)) { + return new Error( + `CLI Bridge at ${endpoint} advertised ${model} but returned 501 not_configured${detail === undefined ? '' : ` (${detail})`}. Configure the selected bridge backend and its local subscription credentials, then retry setup. Discovery is not authentication proof.`, + ) + } + if (status === 401 || status === 403) { + return new Error( + `CLI Bridge at ${endpoint} rejected ${model} with HTTP ${status}${detail === undefined ? '' : ` (${detail})`}. Sign in or configure the selected bridge backend credentials, then retry setup.`, + ) + } + return new Error( + `CLI Bridge model validation at ${endpoint} failed for ${model} with HTTP ${status}${detail === undefined ? '' : ` (${detail})`}. Retry after the bridge reports a runnable backend.`, + ) +} + +function requestFailure(endpoint: string, model: string, timeoutMs: number, error: unknown): Error { + if (error instanceof ProductionBridgeRequestError && error.code === 'BRIDGE_TIMEOUT') { + return new Error( + `CLI Bridge model validation at ${endpoint} timed out after ${timeoutMs} ms for ${model}. Confirm the bridge backend is configured, then retry setup.`, + { cause: error }, + ) + } + const detail = error instanceof Error ? error.message : 'the request failed' + return new Error( + `CLI Bridge model validation at ${endpoint} could not reach ${model}: ${detail}. Start the bridge at the selected endpoint, configure its backend, then retry setup.`, + { cause: error }, + ) +} + +async function validationCredential( + options: ProductionStartupLoadOptions, + selection: ConfigurationSelection, + endpoint: string, +): Promise { + if (selection.connection.credentialRef === undefined) return options.bridgeAuth + return readConnectionCredential( + selection.connection, + { + ...(options.credentialStore === undefined ? {} : { credentials: options.credentialStore }), + ...(options.credentialRefResolver === undefined + ? {} + : { credentialRefResolver: options.credentialRefResolver }), + }, + endpoint, + ) +} + +/** Runs one bounded, non-streaming request and requires a real completion body. */ +export async function validateProductionSelection( + options: ProductionStartupLoadOptions, + selection: ConfigurationSelection, +): Promise { + if (selection.connection.kind !== 'cli-bridge') { + return { + status: 'unverified', + detail: `Model validation is not available for ${selection.connection.kind}; provider authentication remains unverified.`, + } + } + const model = selection.profile.profile.model?.default?.trim() + if (!model) throw new Error('Setup cannot validate a profile without a model.default value') + const endpoint = selection.connection.endpoint + if (!endpoint) { + throw new Error( + `CLI Bridge model validation cannot run because the selected connection has no endpoint; configure the selected CLI Bridge endpoint and retry setup.`, + ) + } + const timeoutMs = validationTimeout(options) + try { + const auth = await validationCredential(options, selection, endpoint) + const response = await requestBridge({ + endpoint, + path: 'chat/completions', + ...(options.fetch === undefined ? {} : { fetcher: options.fetch }), + ...(auth === undefined ? {} : { auth }), + timeoutMs, + maxBodyBytes: MAX_MODEL_VALIDATION_BODY_BYTES, + init: { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model, + messages: [{ role: 'user', content: 'Braid setup validation. Reply with exactly OK.' }], + stream: false, + max_tokens: 1, + effort: selection.profile.profile.model?.reasoningEffort, + }), + }, + }) + const selectedEndpoint = displayBridgeEndpoint(endpoint) + if (!response.ok) throw providerFailure(selectedEndpoint, model, response.status, response.body) + if (!isExactValidationMarker(response.body)) { + throw new Error( + `CLI Bridge model validation at ${selectedEndpoint} returned HTTP ${response.status} without the exact OK completion for ${model}; retry after the bridge returns the requested validation marker.`, + ) + } + return { + status: 'verified', + detail: `Bounded model validation succeeded for ${model} at ${selectedEndpoint}.`, + } + } catch (error) { + if (error instanceof Error && error.message.startsWith('CLI Bridge')) throw error + throw requestFailure(displayBridgeEndpoint(endpoint), model, timeoutMs, error) + } +} + +/** Shows effective values and names dimensions the chosen provider cannot honor. */ +export function describeProductionSelection( + selection: ConfigurationSelection, + workspace: string, + verification: ProductionSetupVerification = { + status: 'unverified', + detail: 'Provider authentication has not been validated.', + }, +): ConfigurationEffectiveValues { + const profile = selection.profile.profile + const runner = profile.harness ?? 'provider default (not pinned)' + const model = profile.model?.default ?? 'unsupported: no model configured' + const effort = profile.model?.reasoningEffort ?? 'provider default (not pinned)' + const unsupported: string[] = [] + if (profile.harness === undefined) unsupported.push('runner override (unsupported: not pinned)') + if (profile.model?.default === undefined) + unsupported.push('model override (unsupported: not configured)') + if (profile.model?.reasoningEffort === undefined) + unsupported.push('reasoning effort override (unsupported: not pinned)') + if (profile.harness !== undefined && !harnessHonorsModel(profile.harness)) + unsupported.push(`model override (unsupported by ${profile.harness})`) + if (profile.harness !== undefined && !harnessHonorsEffort(profile.harness)) + unsupported.push(`effort override (unsupported by ${profile.harness})`) + if (selection.connection.kind === 'tangle-sandbox') { + return { + runner, + model, + effort, + workdir: 'provider-selected sandbox workdir', + verification: `${verification.status}: ${verification.detail}`, + unsupported, + } + } + unsupported.push(`provider workdir placement (unsupported by ${selection.connection.kind})`) + return { + runner, + model, + effort, + workdir: workspace, + verification: `${verification.status}: ${verification.detail}`, + unsupported, + } +} diff --git a/src/bin/production-setup.ts b/src/bin/production-setup.ts new file mode 100644 index 0000000..28c7fd8 --- /dev/null +++ b/src/bin/production-setup.ts @@ -0,0 +1,30 @@ +export type { PreparedProductionSelection } from './production-setup-credentials.js' +export { + prepareProductionSelection, + recoverPendingProductionCredential, +} from './production-setup-credentials.js' +export { + DEFAULT_CLI_BRIDGE_ENDPOINT, + loadProductionSetup, +} from './production-setup-discovery.js' +export type { ProductionStartupPersistence } from './production-setup-persistence.js' +export { + persistableProductionProfile, + persistProductionStartupSelection, + saveProductionStartupSelection, +} from './production-setup-persistence.js' +export type { + ProductionApplicationHandle, + ProductionApplicationSlot, + ProductionSetupController, + ProductionSetupTransitionOptions, +} from './production-setup-transition.js' +export { transitionProductionSelection } from './production-setup-transition.js' +export type { + ProductionSetupVerification, + ProductionStartupSetup, +} from './production-setup-types.js' +export { + describeProductionSelection, + validateProductionSelection, +} from './production-setup-validation.js' diff --git a/src/bin/production-startup.ts b/src/bin/production-startup.ts new file mode 100644 index 0000000..6b4a45e --- /dev/null +++ b/src/bin/production-startup.ts @@ -0,0 +1,368 @@ +import { dirname, resolve } from 'node:path' +import type { AgentProfile, HarnessType } from '@tangle-network/agent-interface' +import type { ProductionConnectionOptions } from '../adapters/connections/production-connections.js' +import { createOperatingSystemCredentialStore } from '../adapters/credentials/os.js' +import { readNoFollow } from '../adapters/persistence/safe-file.js' +import { + type ProductionCompositionConfig, + ProductionCompositionError, + type ProductionCompositionErrorCode, +} from '../app/production-composition.js' +import { + importProfileValue, + ProfilePersistenceError, + readProfileFile, + validateProfileShape, +} from '../app/profiles.js' +import type { ConnectionRecord } from '../domain/entities.js' +import { redactProviderError } from '../domain/redaction.js' +import { credentialRef } from '../ports/credentials.js' +import { productionConfigPath, resolveProductionDatabaseKeyFile } from './production-key-path.js' +import { recoverPendingProductionCredential } from './production-setup-credentials.js' +import type { ProductionStartupLoadOptions } from './production-setup-types.js' + +export type { ProductionStartupLoadOptions } from './production-setup-types.js' + +const MAX_STARTUP_CONFIG_BYTES = 2 * 1024 * 1024 + +export type ProductionStartupErrorCode = + | 'PRODUCTION_CONFIGURATION_REQUIRED' + | 'PRODUCTION_CONFIGURATION_NOT_FOUND' + | 'PRODUCTION_CONFIGURATION_INVALID' + | 'PRODUCTION_DATABASE_KEY_INVALID' + | 'PRODUCTION_PROFILE_REQUIRED' + | 'PRODUCTION_PROFILE_NOT_FOUND' + | 'PRODUCTION_PROFILE_INVALID' + | 'PRODUCTION_CONNECTION_REQUIRED' + +export class ProductionStartupError extends Error { + readonly code: ProductionStartupErrorCode + + constructor(code: ProductionStartupErrorCode, message: string, cause?: unknown) { + super(message, cause === undefined ? undefined : { cause }) + this.name = 'ProductionStartupError' + this.code = code + } +} + +const STARTUP_ERROR_MESSAGES: Readonly> = { + PRODUCTION_CONFIGURATION_REQUIRED: 'Production startup requires a workspace path', + PRODUCTION_CONFIGURATION_NOT_FOUND: + 'No production configuration found; pass --config or create .braid/config.json', + PRODUCTION_CONFIGURATION_INVALID: + 'Production configuration is invalid; check its JSON, schema version, format, and connections', + PRODUCTION_DATABASE_KEY_INVALID: + 'The database key file must be a protected mode-0600 regular file outside the agent workspace; relative paths resolve beside the Braid config file', + PRODUCTION_PROFILE_REQUIRED: + 'Production configuration must select a canonical profile or pass --profile ', + PRODUCTION_PROFILE_NOT_FOUND: 'The selected canonical profile file was not found', + PRODUCTION_PROFILE_INVALID: 'The selected canonical profile is invalid', + PRODUCTION_CONNECTION_REQUIRED: 'Production configuration must select an exact connection id', +} + +const COMPOSITION_ERROR_MESSAGES: Readonly> = { + PRODUCTION_CONFIGURATION_REQUIRED: + 'Production startup requires a canonical profile and one configured connection', + PRODUCTION_PROFILE_INVALID: 'The selected canonical profile is invalid', + PRODUCTION_CONNECTION_REQUIRED: 'Production startup requires an exact configured connection id', + PRODUCTION_CONNECTION_INVALID: 'The selected connection configuration is invalid', + PRODUCTION_CONNECTION_NOT_FOUND: 'The selected production connection does not exist', + PRODUCTION_CONNECTION_UNSUPPORTED: + 'The selected production connection uses an unsupported provider kind', + PRODUCTION_FIXTURE_FORBIDDEN: + 'The deterministic fixture cannot be combined with production configuration', +} + +const RUNTIME_STARTUP_ERROR_MESSAGES: Readonly> = { + CREDENTIAL_STORE_UNAVAILABLE: 'The operating-system credential facility is unavailable', + HEADLESS_KEY_IN_WORKSPACE: + 'Headless key files must be outside the agent workspace; use an external protected key file', + HEADLESS_KEY_PERMISSIONS: 'The headless key file must have mode 0600', + HEADLESS_KEY_SYMLINK: 'Symlinked headless key paths are rejected', + HEADLESS_KEY_NOT_FILE: 'The headless key source must be a regular file', + HEADLESS_KEY_SIZE: 'The headless key file is too large', + HEADLESS_KEY_FORMAT: 'The headless key must be 32 bytes or 64 hexadecimal characters', + STORAGE_PATH_RACE_UNSUPPORTED: 'Encrypted storage is unsupported on this filesystem', + STORAGE_PERMISSIONS: 'The encrypted storage path has unsafe permissions', + STORAGE_OWNERSHIP: 'The encrypted storage path is not owned by this process', + STORAGE_INITIALIZING: 'Another Braid process is initializing encrypted storage', +} + +function runtimeStartupError(error: unknown): string | undefined { + if (!isRecord(error) || typeof error.code !== 'string') return undefined + const message = RUNTIME_STARTUP_ERROR_MESSAGES[error.code] + return message === undefined ? undefined : `${error.code}: ${message}` +} + +export function formatProductionStartupError(error: unknown): string { + if (error instanceof ProductionStartupError) { + return `${error.code}: ${STARTUP_ERROR_MESSAGES[error.code]}` + } + if (error instanceof ProductionCompositionError) { + return `${error.code}: ${COMPOSITION_ERROR_MESSAGES[error.code]}` + } + const runtimeError = runtimeStartupError(error) + if (runtimeError !== undefined) return runtimeError + return redactProviderError(error) +} + +export interface ProductionStartupDocument { + readonly profile?: unknown + readonly connection?: string + readonly connectionId?: string + readonly databaseKeyFile?: string + readonly connections?: readonly unknown[] +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +function stringValue(value: unknown, name: string): string | undefined { + if (value === undefined) return undefined + if (typeof value !== 'string' || value.trim().length === 0) { + throw new ProductionStartupError( + 'PRODUCTION_CONFIGURATION_INVALID', + `${name} must be a non-empty string`, + ) + } + return value +} + +function requiredStringValue(value: unknown, name: string): string { + const result = stringValue(value, name) + if (result === undefined) { + throw new ProductionStartupError( + 'PRODUCTION_CONFIGURATION_INVALID', + `${name} must be a non-empty string`, + ) + } + return result +} + +function parseConfig(bytes: Buffer, path: string): ProductionStartupDocument { + let parsed: unknown + try { + parsed = JSON.parse(bytes.toString('utf8')) + } catch (error) { + throw new ProductionStartupError( + 'PRODUCTION_CONFIGURATION_INVALID', + `Production configuration is not valid JSON: ${path}`, + error, + ) + } + if (!isRecord(parsed)) { + throw new ProductionStartupError( + 'PRODUCTION_CONFIGURATION_INVALID', + 'Production configuration must be a JSON object', + ) + } + if ('schemaVersion' in parsed && parsed.schemaVersion !== 1) { + throw new ProductionStartupError( + 'PRODUCTION_CONFIGURATION_INVALID', + 'Production configuration schema version is unsupported', + ) + } + if ( + 'format' in parsed && + parsed.format !== undefined && + parsed.format !== 'braid-startup-config' + ) { + throw new ProductionStartupError( + 'PRODUCTION_CONFIGURATION_INVALID', + 'Production configuration format is unsupported', + ) + } + const connections = parsed.connections + if (connections !== undefined && !Array.isArray(connections)) { + throw new ProductionStartupError( + 'PRODUCTION_CONFIGURATION_INVALID', + 'Production configuration connections must be an array', + ) + } + return { + ...(parsed.profile === undefined ? {} : { profile: parsed.profile }), + ...(parsed.connection === undefined + ? {} + : { connection: requiredStringValue(parsed.connection, 'connection') }), + ...(parsed.connectionId === undefined + ? {} + : { connectionId: requiredStringValue(parsed.connectionId, 'connectionId') }), + ...(parsed.databaseKeyFile === undefined + ? {} + : { databaseKeyFile: requiredStringValue(parsed.databaseKeyFile, 'databaseKeyFile') }), + ...(connections === undefined ? {} : { connections }), + } +} + +function configPathFor(options: ProductionStartupLoadOptions): string { + return productionConfigPath(options.workspace, options.configPath) +} + +function readConfig(path: string): ProductionStartupDocument { + let bytes: Buffer | undefined + try { + bytes = readNoFollow(path, MAX_STARTUP_CONFIG_BYTES) + } catch (error) { + throw new ProductionStartupError( + 'PRODUCTION_CONFIGURATION_INVALID', + `Could not safely read production configuration: ${path}`, + error, + ) + } + if (bytes === undefined) { + throw new ProductionStartupError( + 'PRODUCTION_CONFIGURATION_NOT_FOUND', + `No production configuration found at ${path}; pass --config or create this file`, + ) + } + return parseConfig(bytes, path) +} + +function profileFromValue(value: unknown, referenceRoot: string): Readonly { + try { + if (typeof value === 'string') { + const profilePath = resolve(referenceRoot, value) + try { + return readProfileFile(profilePath).imported.profile + } catch (error) { + if (error instanceof ProfilePersistenceError && error.code === 'PROFILE_NOT_FOUND') { + throw new ProductionStartupError( + 'PRODUCTION_PROFILE_NOT_FOUND', + `The configured profile file does not exist: ${profilePath}`, + error, + ) + } + throw error + } + } + return importProfileValue(value).profile + } catch (error) { + if (error instanceof ProductionStartupError) throw error + if (error instanceof ProfilePersistenceError) { + throw new ProductionStartupError( + 'PRODUCTION_PROFILE_INVALID', + `The configured profile is invalid: ${error.message}`, + error, + ) + } + throw new ProductionStartupError( + 'PRODUCTION_PROFILE_INVALID', + 'The configured profile is not a valid canonical AgentProfile', + error, + ) + } +} + +function connectionRecords(value: readonly unknown[] | undefined): readonly ConnectionRecord[] { + if (value === undefined || value.length === 0) { + throw new ProductionStartupError( + 'PRODUCTION_CONNECTION_REQUIRED', + 'Production configuration must contain at least one connection record', + ) + } + return value.map((record, index) => { + if (!isRecord(record)) { + throw new ProductionStartupError( + 'PRODUCTION_CONFIGURATION_INVALID', + `connections[${index}] must be an object`, + ) + } + return record as unknown as ConnectionRecord + }) +} + +export async function loadProductionStartup( + options: ProductionStartupLoadOptions, +): Promise { + if (!options.workspace) { + throw new ProductionStartupError( + 'PRODUCTION_CONFIGURATION_REQUIRED', + 'Production startup requires a workspace path', + ) + } + const configPath = configPathFor(options) + const document = readConfig(configPath) + let databaseKeyFile: string | undefined + const configuredDatabaseKeyFile = options.databaseKeyFile ?? document.databaseKeyFile + if (configuredDatabaseKeyFile !== undefined) { + try { + databaseKeyFile = resolveProductionDatabaseKeyFile( + configuredDatabaseKeyFile, + configPath, + options.workspace, + ) + } catch (error) { + throw new ProductionStartupError( + 'PRODUCTION_DATABASE_KEY_INVALID', + 'The configured database key file is invalid', + error, + ) + } + } + await recoverPendingProductionCredential(configPath, { + ...(options.credentialStore === undefined ? {} : { credentialStore: options.credentialStore }), + ...(options.credentialContext === undefined + ? {} + : { credentialContext: options.credentialContext }), + }) + const profileValue = options.profileReference ?? document.profile + if (profileValue === undefined) { + throw new ProductionStartupError( + 'PRODUCTION_PROFILE_REQUIRED', + 'Production configuration must select a canonical profile or --profile ', + ) + } + const profileRoot = + options.profileReference === undefined ? dirname(configPath) : options.workspace + const profile = profileFromValue(profileValue, profileRoot) + const connections = connectionRecords(document.connections) + const selectedConnectionId = + options.connectionId ?? + document.connectionId ?? + document.connection ?? + (connections.length === 1 ? connections[0]?.id : undefined) + if (selectedConnectionId === undefined) { + throw new ProductionStartupError( + 'PRODUCTION_CONNECTION_REQUIRED', + 'Select one configured connection with connectionId or --connection ', + ) + } + let runner: HarnessType | undefined + if (options.runner !== undefined) { + const runnerProfile = validateProfileShape({ ...profile, harness: options.runner }) + if (!runnerProfile.ok || runnerProfile.profile?.harness === undefined) { + throw new ProductionStartupError( + 'PRODUCTION_CONFIGURATION_INVALID', + `The requested runner is not supported by the installed agent-interface package: ${options.runner}`, + ) + } + runner = runnerProfile.profile.harness + } + const selectedConnection = connections.find((record) => record.id === selectedConnectionId) + const credentialStore = + selectedConnection?.credentialRef === undefined + ? undefined + : (options.credentialStore ?? + options.credentialContext?.store ?? + createOperatingSystemCredentialStore()) + const connectionOptions: ProductionConnectionOptions = { + ...(options.fetch === undefined ? {} : { fetch: options.fetch }), + ...(credentialStore === undefined ? {} : { credentials: credentialStore }), + ...(credentialStore === undefined + ? {} + : { + credentialRefResolver: + options.credentialRefResolver ?? ((ref) => credentialRef(`cred:v1:${ref}`)), + }), + } + return { + profile, + connections, + connectionId: selectedConnectionId, + ...(options.model === undefined ? {} : { model: options.model }), + ...(runner === undefined ? {} : { runner }), + ...(databaseKeyFile === undefined ? {} : { databaseKeyFile }), + ...(Object.keys(connectionOptions).length === 0 ? {} : { connectionOptions }), + } +} diff --git a/src/bin/state-path.ts b/src/bin/state-path.ts new file mode 100644 index 0000000..9f943a7 --- /dev/null +++ b/src/bin/state-path.ts @@ -0,0 +1,41 @@ +import { createHash } from 'node:crypto' +import { homedir } from 'node:os' +import { join, resolve } from 'node:path' + +/** The platform Braid data directory is independent of the selected workspace. */ +export function defaultBraidDataDirectory(environment: NodeJS.ProcessEnv = process.env): string { + if (process.platform === 'win32') { + return join(environment.APPDATA ?? join(homedir(), 'AppData', 'Roaming'), 'braid') + } + if (process.platform === 'darwin') { + return join(homedir(), 'Library', 'Application Support', 'braid') + } + return join(environment.XDG_DATA_HOME ?? join(homedir(), '.local', 'share'), 'braid') +} + +/** The stable identity shared by durable state and headless credentials. */ +export function productionWorkspaceConfigIdentity(workspace: string, configPath: string): string { + return createHash('sha256') + .update(`${resolve(workspace)}\u0000${resolve(configPath)}`, 'utf8') + .digest('hex') +} + +/** + * The default durable database is isolated by canonical workspace/config identity. + * + * BRAID_STATE_PATH remains an explicit escape hatch for operators that need to + * point several processes at a deliberately shared state file. + */ +export function defaultStatePath( + workspace: string, + configPath?: string, + environment: NodeJS.ProcessEnv = process.env, +): string { + if (environment.BRAID_STATE_PATH) return environment.BRAID_STATE_PATH + const canonicalWorkspace = resolve(workspace) + const canonicalConfigPath = resolve( + configPath ?? join(canonicalWorkspace, '.braid', 'config.json'), + ) + const identity = productionWorkspaceConfigIdentity(canonicalWorkspace, canonicalConfigPath) + return join(defaultBraidDataDirectory(environment), 'workspaces', identity, 'braid.sqlite') +} diff --git a/src/domain/bounded-structured.ts b/src/domain/bounded-structured.ts new file mode 100644 index 0000000..abebef5 --- /dev/null +++ b/src/domain/bounded-structured.ts @@ -0,0 +1,186 @@ +import { redactSensitiveText } from './secret-sanitizer.js' + +export const STRUCTURED_REDACTION_MARKER = '[redacted]' +const MAX_DEPTH = 8 +const MAX_ITEMS = 256 +const MAX_TOTAL_BYTES = 64 * 1024 +const OPAQUE_REFERENCE = /^(?:cred:v1:|sha(?:256|512):)[A-Za-z0-9._:/-]{1,512}$/u +const SAFE_NUMERIC_FIELDS = new Set([ + 'inputtokens', + 'outputtokens', + 'reasoningtokens', + 'tokensinput', + 'tokensoutput', + 'maxtokens', + 'mintokens', + 'tokencount', + 'tokenestimate', +]) + +export function isSafeNumericTelemetryField(key: string, value: unknown): value is number { + const normalized = key.replace(/[^a-z0-9]/giu, '').toLowerCase() + return SAFE_NUMERIC_FIELDS.has(normalized) && typeof value === 'number' && Number.isFinite(value) +} + +const sensitiveNamePattern = + /(?:password|passwd|passphrase|token|secret|credential|authorization|auth(?:entication)?|key|api[_-]?key|access[_-]?key|private[_-]?key|client[_-]?secret|signature|cookie|header|query|fragment|nonce|challenge)/iu + +export function isSensitiveFieldName(value: string): boolean { + const normalized = value.replace(/[^a-z0-9]/giu, '').toLowerCase() + return sensitiveNamePattern.test(value) || normalized === 'key' || normalized.endsWith('key') +} + +export interface StructuredRedactionLimits { + readonly maxDepth?: number + readonly maxItems?: number + readonly maxBytes?: number +} + +interface RedactionContext { + readonly maxDepth: number + readonly maxItems: number + readonly maxBytes: number + readonly seen: WeakSet + usedBytes: number +} + +function boundedString(value: string, context: RedactionContext): string { + const remaining = Math.max(0, context.maxBytes - context.usedBytes) + const bounded = redactSensitiveText(value, context.maxBytes) + let output = bounded + if (Buffer.byteLength(output, 'utf8') > remaining) { + const marker = '… [truncated]' + const markerBytes = Buffer.byteLength(marker, 'utf8') + const prefixBytes = Math.max(0, remaining - markerBytes) + let used = 0 + let prefix = '' + for (const character of output) { + const size = Buffer.byteLength(character, 'utf8') + if (used + size > prefixBytes) break + prefix += character + used += size + } + const boundedMarker = takeUtf8Prefix(marker, Math.max(0, remaining - used)) + output = prefix + boundedMarker + } + context.usedBytes = Math.min( + context.maxBytes, + context.usedBytes + Buffer.byteLength(output, 'utf8'), + ) + return output +} + +function takeUtf8Prefix(value: string, bytes: number): string { + if (bytes <= 0) return '' + if (Buffer.byteLength(value, 'utf8') <= bytes) return value + let used = 0 + let output = '' + for (const character of value) { + const size = Buffer.byteLength(character, 'utf8') + if (used + size > bytes) break + output += character + used += size + } + return output +} + +function sanitize( + value: unknown, + key: string | undefined, + depth: number, + context: RedactionContext, +): unknown { + if (key && isSensitiveFieldName(key)) { + if (typeof value === 'boolean' || value === null) return value + if (typeof value === 'string' && OPAQUE_REFERENCE.test(value)) return value + return STRUCTURED_REDACTION_MARKER + } + if (typeof value === 'string') return boundedString(value, context) + if (value === null || typeof value === 'boolean') return value + if (typeof value === 'number') return Number.isFinite(value) ? value : '[unavailable: number]' + if (depth >= context.maxDepth) return '[unavailable: depth limit]' + if (typeof value !== 'object') return '[unavailable]' + if (value instanceof Uint8Array) return '[unavailable: binary]' + if (context.seen.has(value)) return '[unavailable: cycle]' + context.seen.add(value) + try { + if (Array.isArray(value)) { + const output: unknown[] = [] + for (const item of value.slice(0, context.maxItems)) + output.push(sanitize(item, undefined, depth + 1, context)) + if (value.length > context.maxItems) output.push('[unavailable: item limit]') + return output + } + const output: Record = {} + let count = 0 + let truncated = false + for (const childKey of Object.keys(value as Record)) { + if (count >= context.maxItems) { + truncated = true + break + } + try { + output[childKey] = sanitize( + (value as Record)[childKey], + childKey, + depth + 1, + context, + ) + } catch { + output[childKey] = '[unavailable]' + } + count += 1 + } + if (truncated) output.__braidTruncated = true + return output + } finally { + context.seen.delete(value) + } +} + +export function redactStructuredValue( + value: unknown, + key?: string, + limits: StructuredRedactionLimits = {}, +): unknown { + return sanitize(value, key, 0, { + maxDepth: limits.maxDepth ?? MAX_DEPTH, + maxItems: limits.maxItems ?? MAX_ITEMS, + maxBytes: limits.maxBytes ?? MAX_TOTAL_BYTES, + seen: new WeakSet(), + usedBytes: 0, + }) +} + +/** Preserve non-secret numeric usage/configuration while still masking string tokens. */ +export function redactStructuredValueWithNumericTelemetry( + value: unknown, + key?: string, + limits: StructuredRedactionLimits = {}, +): unknown { + const redacted = redactStructuredValue(value, key, limits) + restoreNumericTelemetry(value, redacted) + return redacted +} + +function restoreNumericTelemetry(source: unknown, target: unknown): void { + if (Array.isArray(source) && Array.isArray(target)) { + const length = Math.min(source.length, target.length) + for (let index = 0; index < length; index += 1) { + restoreNumericTelemetry(source[index], target[index]) + } + return + } + if (!isRecord(source) || !isRecord(target)) return + for (const [childKey, child] of Object.entries(source)) { + if (isSafeNumericTelemetryField(childKey, child)) { + target[childKey] = child + continue + } + restoreNumericTelemetry(child, target[childKey]) + } +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} diff --git a/src/domain/canonical.ts b/src/domain/canonical.ts index 23e48e6..22a386b 100644 --- a/src/domain/canonical.ts +++ b/src/domain/canonical.ts @@ -1,21 +1,50 @@ import { createHash } from 'node:crypto' +import { createDigest, type Digest } from './ids.js' -function canonicalValue(value: unknown): unknown { - if (Array.isArray(value)) return value.map(canonicalValue) - if (value === null || typeof value !== 'object') return value +function compareCodeUnits(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0 +} - return Object.fromEntries( - Object.entries(value) - .filter(([, child]) => child !== undefined) - .sort(([left], [right]) => left.localeCompare(right)) - .map(([key, child]) => [key, canonicalValue(child)]), - ) +function canonicalValue(value: unknown, ancestors: Set): unknown { + if (value === null || typeof value === 'string' || typeof value === 'boolean') return value + if (typeof value === 'number') { + if (!Number.isFinite(value)) throw new TypeError('Canonical JSON requires finite numbers') + return Object.is(value, -0) ? 0 : value + } + if (typeof value !== 'object') { + throw new TypeError(`Canonical JSON cannot represent ${typeof value}`) + } + if (ancestors.has(value)) throw new TypeError('Canonical JSON cannot represent cycles') + ancestors.add(value) + try { + if (Array.isArray(value)) { + return value.map((child) => { + if (child === undefined) { + throw new TypeError('Canonical JSON cannot represent undefined array entries') + } + return canonicalValue(child, ancestors) + }) + } + const prototype = Object.getPrototypeOf(value) + if (prototype !== Object.prototype && prototype !== null) { + throw new TypeError('Canonical JSON requires plain objects') + } + return Object.fromEntries( + Object.entries(value) + .filter(([, child]) => child !== undefined) + .sort(([left], [right]) => compareCodeUnits(left, right)) + .map(([key, child]) => [key, canonicalValue(child, ancestors)]), + ) + } finally { + ancestors.delete(value) + } } export function canonicalJson(value: unknown): string { - return JSON.stringify(canonicalValue(value)) + if (value === undefined) throw new TypeError('Canonical JSON cannot represent undefined') + return JSON.stringify(canonicalValue(value, new Set())) } -export function canonicalDigest(value: unknown): string { - return createHash('sha256').update(canonicalJson(value)).digest('hex') +export function canonicalDigest(value: unknown): Digest { + return createDigest(createHash('sha256').update(canonicalJson(value)).digest('hex')) } diff --git a/src/domain/code-unit-order.ts b/src/domain/code-unit-order.ts new file mode 100644 index 0000000..086aa0f --- /dev/null +++ b/src/domain/code-unit-order.ts @@ -0,0 +1,10 @@ +/** Compares UTF-16 code units without locale or platform-dependent collation. */ +export function compareCodeUnits(left: string, right: string): number { + const length = Math.min(left.length, right.length) + for (let index = 0; index < length; index += 1) { + const leftCode = left.charCodeAt(index) + const rightCode = right.charCodeAt(index) + if (leftCode !== rightCode) return leftCode - rightCode + } + return left.length - right.length +} diff --git a/src/domain/content-budget.ts b/src/domain/content-budget.ts new file mode 100644 index 0000000..5106927 --- /dev/null +++ b/src/domain/content-budget.ts @@ -0,0 +1,48 @@ +import { canonicalJson } from './canonical.js' +import type { BraidRun } from './state.js' + +export const MAX_RUN_CONTENT_BYTES = 512 * 1024 +const TRUNCATION_MARKER = '… [truncated]' + +export interface ContentReservation { + readonly run: BraidRun + readonly value: string +} + +export function reserveText(run: BraidRun, value: string): ContentReservation { + const remaining = Math.max(0, MAX_RUN_CONTENT_BYTES - (run.contentBytes ?? 0)) + const bounded = utf8Prefix(value, remaining) + const bytes = Buffer.byteLength(bounded, 'utf8') + return { + value: bounded, + run: { + ...run, + contentBytes: (run.contentBytes ?? 0) + bytes, + ...(bounded.length !== value.length ? { contentTruncated: true } : {}), + }, + } +} + +export function reserveValue( + run: BraidRun, + value: unknown, +): { readonly run: BraidRun; readonly value: unknown } { + const serialized = canonicalJson(value) + const reservation = reserveText(run, serialized) + if (reservation.value === serialized) return { run: reservation.run, value } + return { run: reservation.run, value: TRUNCATION_MARKER } +} + +export function utf8Prefix(value: string, bytes: number): string { + if (bytes <= 0) return '' + if (Buffer.byteLength(value, 'utf8') <= bytes) return value + let output = '' + let used = 0 + for (const character of value) { + const size = Buffer.byteLength(character, 'utf8') + if (used + size > bytes) break + output += character + used += size + } + return output +} diff --git a/src/domain/entities-base.ts b/src/domain/entities-base.ts new file mode 100644 index 0000000..8b5de05 --- /dev/null +++ b/src/domain/entities-base.ts @@ -0,0 +1,12 @@ +export type JsonPrimitive = null | boolean | number | string +export type JsonValue = JsonPrimitive | readonly JsonValue[] | { readonly [key: string]: JsonValue } +export type JsonObject = { readonly [key: string]: JsonValue } + +export type IsoDateTime = string + +export interface MissingHistoryRange { + readonly runId: import('./ids.js').RunId + readonly fromSequence: number + readonly toSequence?: number + readonly reason: 'gap' | 'expired-cursor' | 'provider-missing' | 'replay-unsupported' +} diff --git a/src/domain/entities-core.ts b/src/domain/entities-core.ts new file mode 100644 index 0000000..d85025a --- /dev/null +++ b/src/domain/entities-core.ts @@ -0,0 +1,250 @@ +import type { + AgentProfile, + AgentProfileValidationIssue, + ReasoningEffort, +} from '@tangle-network/agent-interface' +import type { + BranchId, + BindingId, + ConnectionId, + ConversationId, + CredentialRefId, + Digest, + DraftId, + EnvironmentId, + MessageId, + MessagePartId, + ProfileId, + ProfileSnapshotId, + QueueEntryId, + QueueId, + RunId, + TurnId, + WorkspaceId, +} from './ids.js' +import type { IsoDateTime, MissingHistoryRange } from './entities-base.js' +import type { RuntimeMessageFields } from './runtime-projection.js' + +export interface TurnUsage { + readonly input: number + readonly output: number + readonly reasoning?: number + readonly costUsd?: number + readonly model?: string +} + +export interface WorkspaceRecord { + readonly id: WorkspaceId + readonly root: string + readonly repositoryIdentity?: string + readonly trusted: boolean + readonly trustDigest?: Digest + readonly createdAt: IsoDateTime + readonly updatedAt: IsoDateTime +} + +export type ProfileSourceKind = 'inline' | 'file' | 'package' | 'catalog' | 'github' + +export interface ProfileSource { + readonly kind: ProfileSourceKind + readonly reference: string + readonly revision?: string +} + +export interface ProfileValidation { + readonly ok: boolean + readonly issues: readonly AgentProfileValidationIssue[] +} + +export interface ProfileRecord { + readonly id: ProfileId + readonly source: ProfileSource + readonly profile: Readonly + readonly digest: Digest + readonly validation: ProfileValidation + readonly createdAt: IsoDateTime + readonly updatedAt: IsoDateTime +} + +export interface ProfileSnapshotRecord { + readonly id: ProfileSnapshotId + readonly profileId?: ProfileId + readonly source: ProfileSource + readonly profile: Readonly + readonly digest: Digest + readonly createdAt: IsoDateTime +} + +export type ConnectionKind = 'cli-bridge' | 'tangle-inference' | 'tangle-sandbox' + +export type ConnectionHealth = + | { readonly status: 'unknown' } + | { readonly status: 'healthy'; readonly checkedAt: IsoDateTime; readonly message?: string } + | { readonly status: 'unauthorized'; readonly checkedAt: IsoDateTime; readonly message?: string } + | { readonly status: 'unreachable'; readonly checkedAt: IsoDateTime; readonly message?: string } + | { readonly status: 'incompatible'; readonly checkedAt: IsoDateTime; readonly message?: string } + | { readonly status: 'rate-limited'; readonly checkedAt: IsoDateTime; readonly message?: string } + +export type ConnectionModelVerificationStatus = + | 'unverified' + | 'verified' + | 'not-configured' + | 'unauthorized' + | 'unreachable' + | 'incompatible' + | 'rate-limited' + +export interface ConnectionModelVerification { + readonly model: string + readonly status: ConnectionModelVerificationStatus + readonly checkedAt: IsoDateTime + readonly code?: string + readonly httpStatus?: number + readonly message?: string +} + +export interface CredentialReference { + readonly id: CredentialRefId + readonly label: string + readonly facility: + | 'macos-keychain' + | 'linux-secret-service' + | 'windows-credential-manager' + | 'protected-file' + readonly createdAt: IsoDateTime + readonly updatedAt: IsoDateTime +} + +export interface ConnectionRecord { + readonly id: ConnectionId + readonly workspaceId?: WorkspaceId + readonly kind: ConnectionKind + readonly name: string + readonly endpoint?: string + readonly credentialRef?: CredentialRefId + readonly providerOptions: ConnectionTransportOptions + readonly createdAt: IsoDateTime + readonly updatedAt: IsoDateTime + readonly lastHealth: ConnectionHealth + readonly lastModelVerification?: ConnectionModelVerification +} + +/** + * Braid stores only transport metadata and capability hints here. + * Provider-native configuration belongs to the provider package and is never + * copied into the durable product state. + */ +export interface ConnectionTransportOptions { + readonly transport?: 'local' | 'https' | 'websocket' | 'stdio' + readonly endpoint?: string + readonly region?: string + readonly account?: string + readonly capabilityHints?: readonly string[] +} + +export interface RetentionPolicy { + readonly completedRunDays?: number + readonly traceDays?: number + readonly analysisDays?: number + readonly toolOutputBytes?: number + readonly cacheBytes?: number +} + +export interface ConversationRecord { + readonly id: ConversationId + readonly workspaceId: WorkspaceId + readonly title: string + readonly activeBranchId: BranchId + readonly profileId?: ProfileId + readonly createdAt: IsoDateTime + readonly updatedAt: IsoDateTime + readonly archived: boolean + readonly deletedAt?: IsoDateTime + readonly retention: RetentionPolicy +} + +export interface BranchBoundary { + readonly conversationId: ConversationId + readonly branchId: BranchId + readonly throughMessageId?: MessageId + readonly throughTurnId?: TurnId +} + +export interface RunOverrides { + readonly runner?: string + readonly model?: string + readonly effort?: ReasoningEffort + readonly mode?: string +} + +export type BranchStatus = 'active' | 'preparing' | 'failed-preparation' | 'archived' + +export interface BranchRecord { + readonly id: BranchId + readonly conversationId: ConversationId + readonly source?: BranchBoundary + readonly profileId?: ProfileId + readonly profileSnapshotId?: ProfileSnapshotId + readonly connectionId?: ConnectionId + readonly overrides: RunOverrides + readonly bindingId?: BindingId + readonly environmentId?: EnvironmentId + readonly draftId: DraftId + readonly queueId: QueueId + readonly tipMessageId?: MessageId + readonly status: BranchStatus + readonly createdAt: IsoDateTime + readonly updatedAt: IsoDateTime +} + +export type TurnStatus = + | 'queued' + | 'prepared' + | 'running' + | 'waiting' + | 'completed' + | 'failed' + | 'cancelled' + | 'unknown' + +export interface TurnRecord { + readonly id: TurnId + readonly conversationId: ConversationId + readonly branchId: BranchId + readonly userMessageId: MessageId + readonly runIds: readonly RunId[] + readonly selectedRunId?: RunId + readonly queueEntryId?: QueueEntryId + readonly status: TurnStatus + readonly createdAt: IsoDateTime + readonly updatedAt: IsoDateTime +} + +export type MessageRole = 'user' | 'assistant' +export type MessageStatus = + | 'incomplete' + | 'streaming' + | 'complete' + | 'failed' + | 'aborted' + | 'cancelled' + | 'blocked' + | 'expired' + | 'unknown' + | 'redacted' + +export interface MessageRecord extends RuntimeMessageFields { + readonly id: MessageId + readonly conversationId: ConversationId + readonly branchId: BranchId + readonly role: MessageRole + readonly text: string + readonly partIds: readonly MessagePartId[] + readonly status: MessageStatus + readonly turnId?: TurnId + readonly runId?: RunId + readonly createdAt: IsoDateTime + readonly updatedAt: IsoDateTime + readonly complete: boolean + readonly missingHistory?: MissingHistoryRange +} diff --git a/src/domain/entities-graph.ts b/src/domain/entities-graph.ts new file mode 100644 index 0000000..b9942b4 --- /dev/null +++ b/src/domain/entities-graph.ts @@ -0,0 +1,193 @@ +import type { IsoDateTime, JsonValue } from './entities-base.js' +import type { + AnalysisId, + BranchId, + CheckpointId, + ConversationId, + Digest, + EffectId, + EnvironmentId, + EventId, + FeedbackDecisionId, + GraphEdgeId, + GraphNodeId, + InteractionId, + MessageId, + OperationId, + ProfileId, + ReceiptId, + ReplayCursor, + RunId, + SupervisorId, + TurnId, + WorkerId, + WorkspaceId, +} from './ids.js' + +export type GraphNodeReference = + | { readonly kind: 'workspace'; readonly id: WorkspaceId } + | { readonly kind: 'profile'; readonly id: ProfileId } + | { readonly kind: 'conversation'; readonly id: ConversationId } + | { readonly kind: 'branch'; readonly id: BranchId } + | { readonly kind: 'turn'; readonly id: TurnId } + | { readonly kind: 'run'; readonly id: RunId } + | { readonly kind: 'message'; readonly id: MessageId } + | { readonly kind: 'analysis'; readonly id: AnalysisId } + | { readonly kind: 'environment'; readonly id: EnvironmentId } + | { readonly kind: 'checkpoint'; readonly id: CheckpointId } + | { readonly kind: 'supervisor'; readonly id: SupervisorId } + | { readonly kind: 'worker'; readonly id: WorkerId } + +export interface GraphNodeRecord { + readonly id: GraphNodeId + readonly reference: GraphNodeReference + readonly title?: string + readonly status?: string + readonly createdAt: IsoDateTime + readonly updatedAt: IsoDateTime +} + +export type GraphEdgeKind = + | 'continued' + | 'branched_at' + | 'cloned_from' + | 'retried' + | 'handed_off' + | 'analyzed' + | 'compared_left' + | 'compared_right' + | 'checkpointed' + | 'forked_environment' + | 'spawned' + | 'supervised_by' + | 'attached' + +export interface GraphProvenance { + readonly operationId?: OperationId + readonly receiptId?: ReceiptId + readonly sourceDigest?: Digest +} + +export interface GraphEdgeRecord { + readonly id: GraphEdgeId + readonly kind: GraphEdgeKind + readonly source: GraphNodeId + readonly destination: GraphNodeId + readonly provenance: GraphProvenance + readonly createdAt: IsoDateTime +} + +export type OperationKind = + | 'profile-save' + | 'connection-change' + | 'conversation-create' + | 'conversation-open' + | 'conversation-update' + | 'conversation-archive' + | 'conversation-delete' + | 'branch-create' + | 'conversation-clone' + | 'conversation-fork' + | 'context-plan' + | 'conversation-import' + | 'draft-update' + | 'send' + | 'queue' + | 'interaction-response' + | 'cancel-run' + | 'steer-worker' + | 'checkpoint' + | 'fork-environment' + | 'analysis' + | 'promote-analysis' + | 'export' + | 'delete' + | 'custom' + +export type OperationStatus = + | 'pending' + | 'acknowledged' + | 'failed' + | 'unknown' + | 'conflict' + | 'terminal' + +export type OperationTarget = + | { readonly kind: 'run'; readonly id: RunId } + | { readonly kind: 'interaction'; readonly id: InteractionId } + | { readonly kind: 'worker'; readonly id: WorkerId } + | { readonly kind: 'checkpoint'; readonly id: CheckpointId } + | { readonly kind: 'environment'; readonly id: EnvironmentId } + | { readonly kind: 'conversation'; readonly id: ConversationId } + | { readonly kind: 'branch'; readonly id: BranchId } + | { readonly kind: 'analysis'; readonly id: AnalysisId } + +export interface OperationRecord { + readonly id: OperationId + readonly kind: OperationKind + readonly requestDigest: Digest + readonly status: OperationStatus + readonly target?: OperationTarget + readonly result?: Readonly> + readonly createdAt: IsoDateTime + readonly updatedAt: IsoDateTime + readonly acknowledgedAt?: IsoDateTime + readonly failureCode?: string + readonly failureMessage?: string + readonly terminalOutcome?: 'completed' | 'cancelled' | 'failed' | 'expired' | 'unknown' +} + +export type EffectStatus = OperationStatus + +export interface EffectRecord { + readonly id: EffectId + readonly operationId: OperationId + readonly effectKind: string + readonly requestDigest: Digest + readonly kind: OperationKind + readonly status: EffectStatus + readonly attempt: number + readonly externalReceiptId?: ReceiptId + readonly outcomeDigest?: Digest + readonly createdAt: IsoDateTime + readonly updatedAt: IsoDateTime +} + +export interface FeedbackDecisionRecord { + readonly id: FeedbackDecisionId + readonly conversationId: ConversationId + readonly operationId?: OperationId + readonly category: + | 'approval' + | 'rejection' + | 'revision' + | 'retry' + | 'fork' + | 'selection' + | 'automation' + readonly chosenOption: string + readonly feedback?: string + readonly automated: boolean + readonly createdAt: IsoDateTime +} + +export interface ReplayCursorRecord { + readonly runId: RunId + readonly cursor: ReplayCursor + readonly committedSequence: number +} + +export interface AppliedEventRecord { + readonly id: EventId + readonly sequence: number + readonly revision: number + readonly digest: Digest +} + +export interface UnknownEventRecord { + readonly id: EventId + readonly type: string + readonly namespace?: string + readonly summary: string + readonly sequence: number +} diff --git a/src/domain/entities-interactions.ts b/src/domain/entities-interactions.ts new file mode 100644 index 0000000..c06d9ee --- /dev/null +++ b/src/domain/entities-interactions.ts @@ -0,0 +1,261 @@ +import type { InteractionRequest } from '@tangle-network/agent-interface' +import type { IsoDateTime, JsonValue, MissingHistoryRange } from './entities-base.js' +import type { TurnUsage } from './entities-core.js' +import type { + AnalysisId, + AnalysisRunId, + AttachmentId, + BranchId, + CitationId, + ConversationId, + Digest, + EventId, + FeedbackDecisionId, + InteractionId, + MessageId, + 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 type NonSecretInteractionValue = string | number | boolean | readonly string[] +export type NonSecretInteractionData = Readonly> + +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 = + | 'matched' + | 'skipped' + | 'conflict' + | 'expired' + | 'use-limit' + | 'applied' + | 'dry-run' + | 'disabled' + | 'deleted' + +/** + * A durable automation audit contains only identifiers, digests, and safe + * outcome metadata. It never contains the answer submitted to a provider. + */ +export interface AutomationAuditRecord { + readonly id: FeedbackDecisionId + readonly runId: RunId + readonly interactionId: InteractionId + readonly ruleId?: RuleId + readonly operationId?: OperationId + readonly requestDigest: Digest + readonly responseDigest?: Digest + readonly responseScope?: AutomationRuleScope + readonly outcome: AutomationAuditOutcome + readonly detail?: string + readonly createdAt: IsoDateTime +} + +export interface TraceReference { + readonly id: TraceId + readonly provider: 'runtime' | 'agent-eval' | 'external' + readonly reference: string + readonly digest: Digest +} + +export interface FrozenAnalysisSource { + readonly conversationId: ConversationId + readonly branchId: BranchId + readonly runId?: RunId + readonly throughMessageId?: MessageId + readonly trace?: TraceReference + readonly digest: Digest + readonly complete: boolean + readonly missingHistory?: MissingHistoryRange +} + +export interface AnalysisCitation { + readonly id: CitationId + readonly eventId?: EventId + readonly messageId?: MessageId + readonly partId?: MessagePartId + readonly start?: number + readonly end?: number + readonly quote?: string +} + +export interface AnalysisFinding { + readonly id: string + readonly text: string + readonly severity?: 'info' | 'low' | 'medium' | 'high' | 'critical' + readonly confidence?: number + readonly citations: readonly AnalysisCitation[] + readonly supported: boolean + readonly supportError?: string +} + +export interface AnalysisSourceRange { + readonly eventIds: readonly EventId[] + readonly messageIds: readonly MessageId[] + readonly messagePartIds: readonly MessagePartId[] + readonly firstSequence?: number + readonly lastSequence?: number +} + +export interface AnalysisCheck { + readonly id: string + readonly status: 'passed' | 'failed' | 'unavailable' + readonly detail?: string +} + +export interface AnalysisProvenance { + readonly operationId: OperationId + readonly requestDigest: Digest + readonly analystIds: readonly string[] + readonly analystVersions: readonly { readonly id: string; readonly version: string }[] + readonly agentEvalVersion?: string + readonly profileId?: ProfileId + readonly profileDigest?: Digest + readonly model?: string + readonly runner?: string + readonly connectionId?: import('./ids.js').ConnectionId + readonly tools: readonly string[] + readonly completeness: 'complete' | 'incomplete' | 'unknown' + readonly checks: readonly AnalysisCheck[] +} + +export interface AnalysisComparisonField { + readonly name: string + readonly baseline?: JsonValue + readonly candidate?: JsonValue + readonly baselinePresent: boolean + readonly candidatePresent: boolean + readonly asymmetry: 'none' | 'baseline-only' | 'candidate-only' | 'both-missing' +} + +export interface AnalysisComparisonSnapshot { + readonly baseline: FrozenAnalysisSource + readonly candidate: FrozenAnalysisSource + readonly fields: readonly AnalysisComparisonField[] + readonly rows: readonly JsonValue[] + readonly paired: JsonValue + readonly semantic: { + readonly status: 'unavailable' + readonly reason: string + } +} + +export type AnalysisStatus = + | 'preparing' + | 'running' + | 'completed' + | 'cancelled' + | 'failed' + | 'unknown' + +export interface AnalysisRecord { + readonly id: AnalysisId + readonly analysisRunId?: AnalysisRunId + readonly kind?: 'analysis' | 'comparison' + readonly operationId?: OperationId + readonly requestDigest?: Digest + readonly request?: JsonValue + readonly source: FrozenAnalysisSource + readonly sourceRange?: AnalysisSourceRange + readonly question?: string + readonly recipe?: string + readonly analystProfileId?: ProfileId + readonly analystProfileDigest?: Digest + readonly status: AnalysisStatus + readonly findings: readonly AnalysisFinding[] + readonly provenance?: AnalysisProvenance + readonly checks?: readonly AnalysisCheck[] + readonly comparison?: AnalysisComparisonSnapshot + readonly usage?: TurnUsage + readonly costUsd?: number + readonly wallTimeMs?: number + readonly error?: string + readonly createdAt: IsoDateTime + readonly updatedAt: IsoDateTime +} + +export interface PromotedAnalysisFindingRecord { + readonly id: string + readonly text: string + readonly citations: readonly AnalysisCitation[] +} + +export interface AnalysisAttachmentRecord { + readonly id: AttachmentId + readonly operationId: OperationId + readonly analysisId: AnalysisId + readonly analysisRunId?: AnalysisRunId + readonly sourceConversationId: ConversationId + readonly sourceBranchId: BranchId + readonly sourceRunId?: RunId + readonly sourceDigest: Digest + readonly destinationConversationId: ConversationId + readonly destinationBranchId: BranchId + readonly selectedFindings: readonly PromotedAnalysisFindingRecord[] + readonly provenance: Readonly<{ + readonly analysisId: AnalysisId + readonly sourceDigest: Digest + readonly analystProfileDigest?: Digest + readonly model?: string + readonly runner?: string + readonly agentEvalVersion?: string + }> + readonly createdAt: IsoDateTime +} diff --git a/src/domain/entities-messages.ts b/src/domain/entities-messages.ts new file mode 100644 index 0000000..a9eba08 --- /dev/null +++ b/src/domain/entities-messages.ts @@ -0,0 +1,165 @@ +import type { + ArtifactId, + BranchId, + ConnectionId, + ConversationId, + Digest, + EnvironmentId, + BindingId, + MessageId, + MessagePartId, + OperationId, + ProfileSnapshotId, + ProviderSessionId, + ReceiptId, + ReplayCursor, + RunId, + TurnId, +} from './ids.js' +import type { IsoDateTime, JsonObject, JsonValue } from './entities-base.js' +import type { MessageRecord, RunOverrides } from './entities-core.js' +import type { RuntimeRunFields } from './runtime-projection.js' + +interface MessagePartBase { + readonly id: MessagePartId + readonly messageId: MessageId + readonly ordinal: number + readonly createdAt: IsoDateTime + readonly updatedAt: IsoDateTime +} + +export interface TextPartRecord extends MessagePartBase { + readonly kind: 'text' + readonly text: string +} + +export interface ReasoningPartRecord extends MessagePartBase { + readonly kind: 'reasoning' + readonly text: string +} + +export interface ToolCallPartRecord extends MessagePartBase { + readonly kind: 'tool-call' + readonly name: string + readonly arguments: JsonObject + readonly callId?: string + readonly status: 'pending' | 'running' | 'completed' | 'failed' +} + +export interface ToolResultPartRecord extends MessagePartBase { + readonly kind: 'tool-result' + readonly callId?: string + readonly summary: string + readonly output?: JsonValue + readonly status: 'completed' | 'failed' +} + +export interface ArtifactPartRecord extends MessagePartBase { + readonly kind: 'artifact' + readonly artifactId: ArtifactId + readonly summary: string +} + +export interface FilePartRecord extends MessagePartBase { + readonly kind: 'file' + readonly path?: string + readonly filename?: string + readonly mediaType?: string +} + +export interface ImagePartRecord extends MessagePartBase { + readonly kind: 'image' + readonly artifactId?: ArtifactId + readonly mediaType?: string + readonly altText?: string +} + +export interface WarningPartRecord extends MessagePartBase { + readonly kind: 'warning' + readonly message: string +} + +export interface ErrorPartRecord extends MessagePartBase { + readonly kind: 'error' + readonly message: string + readonly retryable: boolean +} + +export interface UnknownPartRecord extends MessagePartBase { + readonly kind: 'unknown' + readonly namespace: string + readonly type: string + readonly summary: string +} + +export type MessagePartRecord = + | TextPartRecord + | ReasoningPartRecord + | ToolCallPartRecord + | ToolResultPartRecord + | ArtifactPartRecord + | FilePartRecord + | ImagePartRecord + | WarningPartRecord + | ErrorPartRecord + | UnknownPartRecord + +export type RunLifecycleStatus = + | 'prepared' + | 'starting' + | 'running' + | 'waiting' + | 'detached' + | 'reconnecting' + | 'cancelling' + | 'completed' + | 'cancelled' + | 'failed' + | 'expired' + | 'unknown' + +export type RunStatus = RunLifecycleStatus | 'streaming' | 'aborted' | 'blocked' + +export interface RunReceipt { + readonly id: ReceiptId + readonly operationId: OperationId + readonly runId: RunId + readonly turnId: TurnId + readonly branchId: BranchId + readonly profileSnapshotId?: ProfileSnapshotId + readonly connectionId?: ConnectionId + readonly profileDigest?: Digest + readonly requested: RunOverrides + readonly effective: RunOverrides + readonly capabilitiesDigest?: Digest + readonly completeness: 'complete' | 'incomplete' | 'unknown' + readonly createdAt: IsoDateTime +} + +export interface RunRecord extends RuntimeRunFields { + readonly id: RunId + readonly conversationId: ConversationId + readonly branchId: BranchId + readonly turnId: TurnId + readonly operationId: OperationId + readonly status: RunStatus + readonly inputTokens: number + readonly outputTokens: number + readonly costUsd?: number + readonly model?: string + readonly error?: string + readonly profileSnapshotId?: ProfileSnapshotId + readonly connectionId?: ConnectionId + readonly providerSessionId?: ProviderSessionId + readonly environmentId?: EnvironmentId + readonly bindingId?: BindingId + readonly receiptId?: ReceiptId + readonly replayCursor?: ReplayCursor + readonly complete: boolean + readonly startedAt: IsoDateTime + readonly updatedAt: IsoDateTime + readonly terminalAt?: IsoDateTime +} + +export type BraidMessage = MessageRecord +export type BraidRun = RunRecord diff --git a/src/domain/entities-runtime.ts b/src/domain/entities-runtime.ts new file mode 100644 index 0000000..a7e2478 --- /dev/null +++ b/src/domain/entities-runtime.ts @@ -0,0 +1,174 @@ +import type { + BindingId, + BranchId, + CheckpointId, + ConnectionId, + Digest, + DraftId, + EnvironmentId, + MessageId, + OperationId, + ProviderSessionId, + QueueEntryId, + QueueId, + ReplayCursor, + RuleId, + RunId, + SupervisorId, + WorkerId, + WorkspaceId, +} from './ids.js' +import type { IsoDateTime } from './entities-base.js' +import type { NonSecretInteractionData } from './entities-interactions.js' + +export type EnvironmentLifecycle = + | 'requested' + | 'creating' + | 'ready' + | 'detached' + | 'expired' + | 'failed' + | 'destroying' + | 'destroyed' + | 'unknown' + +export interface Placement { + readonly provider: string + readonly region?: string + readonly account?: string + readonly confidentialRequested: boolean + readonly confidentialVerified: boolean +} + +export interface EnvironmentRecord { + readonly id: EnvironmentId + readonly workspaceId: WorkspaceId + readonly connectionId: ConnectionId + readonly lifecycle: EnvironmentLifecycle + readonly placement: Placement + readonly repository?: string + readonly gitRef?: string + readonly workingDirectory?: string + readonly image?: string + readonly secretNames: readonly string[] + readonly createdAt: IsoDateTime + readonly updatedAt: IsoDateTime +} + +export interface CheckpointRecord { + readonly id: CheckpointId + readonly sourceEnvironmentId: EnvironmentId + readonly sourceBranchId: BranchId + readonly sourceRunId?: RunId + readonly throughMessageId?: MessageId + readonly requestDigest: Digest + readonly operationId: OperationId + readonly stateDigest?: Digest + readonly createdAt: IsoDateTime + readonly status: 'requested' | 'ready' | 'failed' | 'unknown' | 'deleted' +} + +export interface SupervisorRecord { + readonly id: SupervisorId + readonly rootRunId: RunId + readonly status: 'starting' | 'running' | 'completed' | 'failed' | 'cancelled' | 'unknown' + readonly createdAt: IsoDateTime + readonly updatedAt: IsoDateTime +} + +export interface WorkerRecord { + readonly id: WorkerId + readonly supervisorId: SupervisorId + readonly parentWorkerId?: WorkerId + readonly runId?: RunId + readonly status: + | 'pending' + | 'running' + | 'waiting' + | 'completed' + | 'failed' + | 'cancelled' + | 'unknown' + readonly title?: string + readonly spendUsd?: number + readonly inputTokens?: number + readonly outputTokens?: number + readonly latencyMs?: number + readonly logTail?: string + readonly createdAt: IsoDateTime + readonly updatedAt: IsoDateTime +} + +export interface DraftRecord { + readonly id: DraftId + readonly branchId: BranchId + readonly text: string + readonly updatedAt: IsoDateTime +} + +export type QueueEntryStatus = 'queued' | 'admitted' | 'removed' | 'failed' + +export interface QueueEntryRecord { + readonly id: QueueEntryId + readonly queueId: QueueId + readonly branchId: BranchId + readonly text: string + readonly position: number + readonly operationId: OperationId + readonly status: QueueEntryStatus + readonly createdAt: IsoDateTime + readonly updatedAt: IsoDateTime +} + +export interface QueueRecord { + readonly id: QueueId + readonly branchId: BranchId + readonly entryIds: readonly QueueEntryId[] + readonly createdAt: IsoDateTime + readonly updatedAt: IsoDateTime +} + +export interface AutomationRuleMatcher { + readonly interactionKind?: string + readonly subjectType?: string + readonly subjectValue?: string + readonly profileDigest?: Digest + readonly connectionId?: ConnectionId + readonly runner?: string + readonly workspaceId?: WorkspaceId +} + +export interface AutomationRuleRecord { + readonly id: RuleId + readonly enabled: boolean + readonly matcher: AutomationRuleMatcher + readonly answer: NonSecretInteractionData + readonly responseScope: 'once' | 'session' | 'persistent' + readonly createdAt: IsoDateTime + readonly expiresAt?: IsoDateTime + readonly maximumUses?: number + readonly uses: number +} + +export type BindingStatus = + | 'requested' + | 'bound' + | 'unavailable' + | 'expired' + | 'released' + | 'unknown' + +export interface BindingRecord { + readonly id: BindingId + readonly branchId: BranchId + readonly runId?: RunId + readonly connectionId: ConnectionId + readonly providerSessionId?: ProviderSessionId + readonly environmentId?: EnvironmentId + readonly checkpointId?: CheckpointId + readonly replayCursor?: ReplayCursor + readonly boundaryDigest?: Digest + readonly status: BindingStatus + readonly createdAt: IsoDateTime + readonly updatedAt: IsoDateTime +} diff --git a/src/domain/entities.ts b/src/domain/entities.ts new file mode 100644 index 0000000..7b1a21a --- /dev/null +++ b/src/domain/entities.ts @@ -0,0 +1,6 @@ +export * from './entities-base.js' +export * from './entities-core.js' +export * from './entities-messages.js' +export * from './entities-interactions.js' +export * from './entities-runtime.js' +export * from './entities-graph.js' diff --git a/src/domain/events-legacy.ts b/src/domain/events-legacy.ts new file mode 100644 index 0000000..cbf6f9f --- /dev/null +++ b/src/domain/events-legacy.ts @@ -0,0 +1,227 @@ +import type { InteractionRequest } from '@tangle-network/agent-interface' +import type { AgentTaskStatus } from '@tangle-network/agent-runtime' +import type { BraidMessagePart } from './state.js' +import type { Digest } from './ids.js' +import type { RunAdmissionReceipt } from './receipts.js' +import type { RuntimeEventEnvelope } from './runtime-events.js' +import type { TurnUsage } from './entities.js' + +export interface ProviderEventMeta { + readonly eventId: string + readonly providerSequence: number + readonly cursor?: string + readonly occurredAt?: string + readonly receivedAt?: string +} + +export type RunTerminalStatus = AgentTaskStatus | 'cancelled' | 'expired' | 'unknown' + +export type BraidControlKind = 'cancel' | 'steer' | 'queue' | 'detach' | 'reconnect' + +/** The W0 stream used these five events before the complete domain existed. */ +export type LegacyBraidEvent = + | { + readonly kind: 'workspace.opened' + readonly workspace: string + } + | { + readonly kind: 'draft.changed' + readonly text: string + } + | { + readonly kind: 'run.requested' + readonly operationId: string + readonly runId: string + readonly turnId: string + readonly userMessageId: string + readonly assistantMessageId: string + readonly text: string + readonly requestDigest?: string + readonly receipt?: RunAdmissionReceipt + } + | { + readonly kind: 'run.text.delta' + readonly runId: string + readonly text: string + readonly provider?: ProviderEventMeta + } + | { + readonly kind: 'run.part.updated' + readonly runId: string + readonly part: BraidMessagePart + readonly delta?: string + readonly provider: ProviderEventMeta + } + | { + readonly kind: 'run.reasoning.delta' + readonly runId: string + readonly partId: string + readonly text: string + readonly provider: ProviderEventMeta + } + | { + readonly kind: 'run.tool.call' + readonly runId: string + readonly partId: string + readonly toolName: string + readonly callId?: string + readonly input?: unknown + readonly provider: ProviderEventMeta + } + | { + readonly kind: 'run.tool.result' + readonly runId: string + readonly partId: string + readonly toolName: string + readonly callId?: string + readonly result?: unknown + readonly error?: string + readonly provider: ProviderEventMeta + } + | { + readonly kind: 'run.artifact' + readonly runId: string + readonly artifactId: string + readonly name?: string + readonly mimeType?: string + readonly uri?: string + readonly metadata?: Readonly> + readonly provider: ProviderEventMeta + } + | { + readonly kind: 'run.proposal' + readonly runId: string + readonly proposalId: string + readonly title: string + readonly status?: 'pending' | 'approved' | 'rejected' + readonly provider: ProviderEventMeta + } + | { + readonly kind: 'run.warning' + readonly runId: string + readonly code: string + readonly message: string + readonly provider: ProviderEventMeta + } + | { + readonly kind: 'run.usage' + readonly runId: string + readonly usage: TurnUsage + readonly provider: ProviderEventMeta + } + | { + readonly kind: 'run.cost' + readonly runId: string + readonly costUsd: number + readonly provider: ProviderEventMeta + } + | { + readonly kind: 'run.error' + readonly runId: string + readonly message: string + readonly recoverable: boolean + readonly provider: ProviderEventMeta + } + | { + readonly kind: 'run.interaction' + readonly runId: string + readonly request: InteractionRequest + readonly provider: ProviderEventMeta + } + | { + readonly kind: 'run.interaction.cancelled' + readonly runId: string + readonly interactionId: string + readonly reason?: string + readonly provider: ProviderEventMeta + } + | { + readonly kind: 'run.interaction.response.requested' + readonly runId: string + readonly interactionId: string + readonly operationId: string + readonly outcome: 'accepted' | 'declined' | 'cancelled' + readonly dataDigest?: Digest + readonly containsSecret: boolean + } + | { + readonly kind: 'run.interaction.responded' + readonly runId: string + readonly interactionId: string + readonly operationId: string + readonly outcome: 'accepted' | 'declined' | 'cancelled' | 'unknown' + readonly dataDigest?: Digest + readonly containsSecret: boolean + readonly detail?: string + } + | { + readonly kind: 'run.provider.event' + readonly runId: string + readonly envelope: RuntimeEventEnvelope + readonly provider: ProviderEventMeta + } + | { + readonly kind: 'run.cancel.requested' + readonly operationId: string + readonly runId: string + readonly reason?: string + } + | { + readonly kind: 'run.finished' + readonly runId: string + readonly status: RunTerminalStatus + readonly finalText: string + readonly usage: TurnUsage + readonly error?: string + readonly reason?: string + readonly provider?: ProviderEventMeta + } + | { + readonly kind: 'run.control.requested' + readonly runId: string + readonly operationId: string + readonly control: BraidControlKind + readonly digest: string + readonly text?: string + readonly reason?: string + } + | { + readonly kind: 'run.control.acknowledged' + readonly runId: string + readonly operationId: string + readonly control: BraidControlKind + readonly outcome: 'accepted' | 'already-applied' | 'rejected' | 'unknown' + readonly detail?: string + } + | { + readonly kind: 'run.queue.added' + readonly runId: string + readonly operationId: string + readonly text: string + readonly position: number + } + | { + readonly kind: 'run.queue.removed' + readonly runId: string + readonly operationId: string + } + | { + readonly kind: 'run.detached' + readonly runId: string + readonly cursor?: string + readonly detail?: string + } + | { + readonly kind: 'run.reconnecting' + readonly runId: string + readonly after?: string + } + | { + readonly kind: 'run.unknown' + readonly runId: string + readonly detail: string + } + | { + readonly kind: 'application.shutdown.requested' + readonly operationId: string + } diff --git a/src/domain/events.ts b/src/domain/events.ts index 363ea6b..31460e1 100644 --- a/src/domain/events.ts +++ b/src/domain/events.ts @@ -1,47 +1,365 @@ -import type { AgentTaskStatus } from '@tangle-network/agent-runtime' +import type { + AnalysisAttachmentRecord, + AnalysisRecord, + AutomationAuditRecord, + BindingRecord, + BranchRecord, + CheckpointRecord, + ConnectionRecord, + ConversationRecord, + CredentialReference, + DraftRecord, + EffectRecord, + EnvironmentRecord, + FeedbackDecisionRecord, + GraphEdgeRecord, + GraphNodeRecord, + InteractionRecord, + MessagePartRecord, + MessageRecord, + MissingHistoryRange, + OperationRecord, + ProfileRecord, + ProfileSnapshotRecord, + QueueEntryRecord, + QueueRecord, + RunRecord, + SupervisorRecord, + TurnRecord, + UnknownEventRecord, + WorkerRecord, + WorkspaceRecord, +} from './entities.js' +import type { LegacyBraidEvent, ProviderEventMeta } from './events-legacy.js' +import type { + AnalysisId, + AnalysisRunId, + BindingId, + BranchId, + CheckpointId, + ConnectionId, + ConversationId, + CredentialRefId, + Digest, + DraftId, + EffectId, + EventId, + FeedbackDecisionId, + GraphEdgeId, + GraphNodeId, + InteractionId, + MessageId, + MessagePartId, + OperationId, + ProfileId, + ProfileSnapshotId, + ReplayCursor, + RunId, + SupervisorId, + TurnId, + WorkerId, +} from './ids.js' +import type { RunStatus } from './state.js' -export interface TurnUsage { - readonly input: number - readonly output: number - readonly costUsd?: number - readonly model?: string +export type { TurnUsage } from './entities.js' +export type { + BraidControlKind, + LegacyBraidEvent, + ProviderEventMeta, + 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 } -export type BraidEvent = - | { - readonly kind: 'workspace.opened' - readonly workspace: string - } - | { - readonly kind: 'draft.changed' - readonly text: string - } - | { - readonly kind: 'run.requested' - readonly operationId: string - readonly runId: string - readonly turnId: string - readonly userMessageId: string - readonly assistantMessageId: string - readonly text: string - } - | { - readonly kind: 'run.text.delta' - readonly runId: string - readonly text: string - } - | { - readonly kind: 'run.finished' - readonly runId: string - readonly status: AgentTaskStatus - readonly finalText: string - readonly usage: TurnUsage - readonly error?: string - } +export interface DomainBraidEventMap { + readonly 'workspace.recorded': { readonly workspace: WorkspaceRecord } + readonly 'profile.registered': { readonly profile: ProfileRecord } + readonly 'profile.selected': { readonly profileId: ProfileId } + readonly 'profile.snapshot.created': { readonly snapshot: ProfileSnapshotRecord } + readonly 'credential.reference.created': { readonly credential: CredentialReference } + readonly 'connection.upserted': { readonly connection: ConnectionRecord } + readonly 'connection.selected': { readonly connectionId: ConnectionId } + readonly 'conversation.created': { + readonly conversation: ConversationRecord + readonly branch?: BranchRecord + readonly draft?: DraftRecord + readonly queue?: QueueRecord + readonly graphNodes?: readonly GraphNodeRecord[] + readonly graphEdges?: readonly GraphEdgeRecord[] + readonly operation?: OperationRecord + } + readonly 'conversation.imported': { + readonly conversation: ConversationRecord + readonly branches: readonly BranchRecord[] + readonly drafts: readonly DraftRecord[] + readonly queues: readonly QueueRecord[] + readonly messages: readonly MessageRecord[] + readonly messageParts: readonly MessagePartRecord[] + readonly turns: readonly TurnRecord[] + readonly runs: readonly RunRecord[] + readonly analyses: readonly AnalysisRecord[] + readonly graphNodes: readonly GraphNodeRecord[] + readonly graphEdges: readonly GraphEdgeRecord[] + readonly feedbackDecisions: readonly FeedbackDecisionRecord[] + readonly sourceContentDigest: Digest + readonly operation: OperationRecord + } + readonly 'conversation.updated': { + readonly conversation: ConversationRecord + readonly operation?: OperationRecord + } + readonly 'conversation.selected': { + readonly conversationId: ConversationId + readonly branchId?: BranchId + readonly conversation?: ConversationRecord + readonly operation?: OperationRecord + } + readonly 'conversation.deleted': { + readonly conversation: ConversationRecord + readonly selectedConversation: ConversationRecord + readonly replacementBranch?: BranchRecord + readonly replacementDraft?: DraftRecord + readonly replacementQueue?: QueueRecord + readonly graphNodes?: readonly GraphNodeRecord[] + readonly graphEdges?: readonly GraphEdgeRecord[] + readonly operation: OperationRecord + } + readonly 'branch.created': { + readonly branch: BranchRecord + readonly conversation?: ConversationRecord + readonly draft?: DraftRecord + readonly queue?: QueueRecord + readonly graphNodes?: readonly GraphNodeRecord[] + readonly graphEdges?: readonly GraphEdgeRecord[] + readonly operation?: OperationRecord + } + readonly 'branch.updated': { readonly branch: BranchRecord } + readonly 'branch.selected': { + readonly conversationId: ConversationId + readonly branchId: BranchId + readonly operation?: OperationRecord + } + readonly 'turn.created': { readonly turn: TurnRecord } + readonly 'turn.updated': { readonly turn: TurnRecord } + readonly 'message.created': { readonly message: MessageRecord } + readonly 'message.part.updated': { readonly part: MessagePartRecord } + readonly 'run.bound': { readonly runId: RunId; readonly bindingId: BindingId } + readonly 'run.status.changed': { + readonly runId: RunId + readonly status: RunStatus + readonly error?: string + readonly detail?: string + readonly provider?: ProviderEventMeta + } + readonly 'run.reconciled': { + readonly runId: RunId + readonly status: RunStatus + readonly evidence: Digest + readonly from?: RunStatus + readonly to?: RunStatus + readonly detail?: string + } + 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 } + readonly 'analysis.attachment.created': { readonly attachment: AnalysisAttachmentRecord } + readonly 'environment.upserted': { readonly environment: EnvironmentRecord } + readonly 'checkpoint.upserted': { readonly checkpoint: CheckpointRecord } + readonly 'supervisor.upserted': { readonly supervisor: SupervisorRecord } + readonly 'worker.upserted': { readonly worker: WorkerRecord } + readonly 'draft.recorded': { + readonly draft: DraftRecord + readonly operation?: OperationRecord + } + readonly 'queue.upserted': { readonly queue: QueueRecord } + readonly 'queue.entry.upserted': { readonly entry: QueueEntryRecord } + readonly 'rule.upserted': { + readonly rule: import('./entities.js').AutomationRuleRecord + readonly operation?: OperationRecord + } + readonly 'rule.deleted': { + readonly ruleId: import('./ids.js').RuleId + readonly operation: OperationRecord + } + readonly 'interaction.automation.audited': { readonly audit: AutomationAuditRecord } + readonly 'binding.upserted': { readonly binding: BindingRecord } + readonly 'graph.node.upserted': { readonly node: GraphNodeRecord } + readonly 'graph.edge.upserted': { readonly edge: GraphEdgeRecord } + readonly 'operation.requested': { readonly operation: OperationRecord } + readonly 'operation.updated': { readonly operation: OperationRecord } + readonly 'effect.upserted': { readonly effect: EffectRecord } + readonly 'feedback.decision.recorded': { readonly decision: FeedbackDecisionRecord } + readonly 'content.unavailable': { + readonly conversationId: ConversationId + readonly originalKind: string + readonly reason: 'content-key-unavailable' | 'deleted' | 'redacted' + } + readonly 'replay.cursor.advanced': { readonly runId: RunId; readonly cursor: ReplayCursor } + readonly 'unknown.event': { readonly unknown: UnknownEventRecord } +} + +type DomainEvent = { + [K in keyof DomainBraidEventMap]: { readonly kind: K } & DomainBraidEventMap[K] +}[keyof DomainBraidEventMap] + +export type BraidEvent = LegacyBraidEvent | DomainEvent export interface BraidEventEnvelope { + /** Optional for W0 compatibility; durable writers must provide it. */ + readonly eventId?: EventId readonly sequence: number readonly revision: number readonly occurredAt: string + readonly cursor?: ReplayCursor readonly event: BraidEvent } + +export interface JournalEventEnvelope extends BraidEventEnvelope { + readonly eventId: EventId +} + +export function isDomainEvent( + event: BraidEvent, + kind: K, +): event is Extract { + return event.kind === kind +} + +export function eventRunId(event: BraidEvent): RunId | undefined { + switch (event.kind) { + case 'run.requested': + case 'run.text.delta': + case 'run.part.updated': + case 'run.reasoning.delta': + case 'run.tool.call': + case 'run.tool.result': + case 'run.artifact': + case 'run.proposal': + case 'run.warning': + case 'run.usage': + case 'run.cost': + case 'run.error': + case 'run.interaction': + case 'run.interaction.cancelled': + case 'run.interaction.response.requested': + case 'run.interaction.responded': + case 'run.provider.event': + case 'run.cancel.requested': + case 'run.finished': + case 'run.control.requested': + case 'run.control.acknowledged': + case 'run.queue.added': + case 'run.queue.removed': + case 'run.detached': + case 'run.reconnecting': + case 'run.unknown': + case 'run.bound': + case 'run.status.changed': + case 'run.reconciled': + return event.runId as RunId + case 'history.missing': + return event.range.runId + case 'replay.cursor.advanced': + return event.runId + case 'workspace.opened': + case 'draft.changed': + case 'application.shutdown.requested': + case 'workspace.recorded': + case 'profile.registered': + case 'profile.selected': + case 'profile.snapshot.created': + case 'credential.reference.created': + case 'connection.upserted': + case 'connection.selected': + case 'conversation.created': + case 'conversation.imported': + case 'conversation.updated': + case 'conversation.selected': + case 'conversation.deleted': + case 'branch.created': + case 'branch.updated': + case 'branch.selected': + case 'turn.created': + 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 + case 'analysis.created': + case 'analysis.updated': + case 'analysis.completed': + case 'analysis.attachment.created': + case 'environment.upserted': + case 'checkpoint.upserted': + case 'supervisor.upserted': + case 'worker.upserted': + case 'draft.recorded': + case 'queue.upserted': + case 'queue.entry.upserted': + case 'rule.upserted': + case 'rule.deleted': + case 'binding.upserted': + case 'graph.node.upserted': + case 'graph.edge.upserted': + case 'operation.requested': + case 'operation.updated': + case 'effect.upserted': + case 'feedback.decision.recorded': + case 'content.unavailable': + case 'unknown.event': + return undefined + default: { + const exhaustive: never = event + return exhaustive + } + } +} + +export type { + AnalysisId, + AnalysisRunId, + CheckpointId, + CredentialRefId, + DraftId, + EffectId, + EventId, + FeedbackDecisionId, + GraphEdgeId, + GraphNodeId, + MessageId, + MessagePartId, + ProfileSnapshotId, + SupervisorId, + TurnId, + WorkerId, +} + +export function providerEventKey(event: BraidEvent): string | undefined { + if (!('provider' in event) || !event.provider) return undefined + return `${event.runId}:${event.provider.eventId}` +} diff --git a/src/domain/graph-records.ts b/src/domain/graph-records.ts new file mode 100644 index 0000000..2572d6c --- /dev/null +++ b/src/domain/graph-records.ts @@ -0,0 +1,53 @@ +import { canonicalDigest } from './canonical.js' +import type { + GraphEdgeKind, + GraphEdgeRecord, + GraphNodeRecord, + GraphNodeReference, + GraphProvenance, +} from './entities.js' +import { createGraphEdgeId, createGraphNodeId, type GraphNodeId } from './ids.js' + +export function graphNodeId(reference: GraphNodeReference): GraphNodeId { + return createGraphNodeId(`node-${canonicalDigest(reference).slice(0, 32)}`) +} + +export function graphNode( + reference: GraphNodeReference, + at: string, + title?: string, +): GraphNodeRecord { + return { + id: graphNodeId(reference), + reference, + ...(title === undefined ? {} : { title }), + createdAt: at, + updatedAt: at, + } +} + +export function graphEdge(input: { + readonly kind: GraphEdgeKind + readonly source: GraphNodeReference + readonly destination: GraphNodeReference + readonly at: string + readonly provenance?: GraphProvenance +}): GraphEdgeRecord { + const source = graphNodeId(input.source) + const destination = graphNodeId(input.destination) + return { + id: createGraphEdgeId( + `edge-${canonicalDigest({ + kind: input.kind, + source, + destination, + provenance: input.provenance ?? {}, + }).slice(0, 32)}`, + ), + kind: input.kind, + source, + destination, + provenance: input.provenance ?? {}, + createdAt: input.at, + } +} diff --git a/src/domain/ids-core.ts b/src/domain/ids-core.ts new file mode 100644 index 0000000..b5c5c14 --- /dev/null +++ b/src/domain/ids-core.ts @@ -0,0 +1,109 @@ +import type { IdForKind, IdKind, Digest, ReplayCursor } from './ids-types.js' + +export const prefixes: Readonly> = { + workspace: ['workspace-'], + profile: ['profile-'], + profileSnapshot: ['profile-snapshot-'], + credentialRef: ['credential-', 'credential-ref-'], + connection: ['connection-'], + conversation: ['conversation-', 'conv-'], + branch: ['branch-'], + turn: ['turn-'], + run: ['run-'], + message: ['message-'], + messagePart: ['message-part-', 'part-'], + artifact: ['artifact-'], + interaction: ['interaction-'], + analysis: ['analysis-'], + analysisRun: ['analysis-run-'], + citation: ['citation-'], + attachment: ['attachment-'], + feedbackDecision: ['feedback-'], + trace: ['trace-'], + providerSession: ['provider-session-', 'session-'], + environment: ['environment-', 'env-'], + checkpoint: ['checkpoint-'], + supervisor: ['supervisor-'], + worker: ['worker-'], + draft: ['draft-'], + queue: ['queue-'], + queueEntry: ['queue-entry-'], + rule: ['rule-'], + binding: ['binding-'], + graphNode: ['node-'], + graphEdge: ['edge-'], + operation: ['operation-', 'op-'], + effect: ['effect-'], + receipt: ['receipt-'], + event: ['event-'], +} + +export const idPattern = /^[A-Za-z][A-Za-z0-9._:-]{0,255}$/u + +export function parsePrefixedId(kind: K, value: unknown): IdForKind { + if ( + typeof value !== 'string' || + !idPattern.test(value) || + !prefixes[kind].some((prefix) => value.startsWith(prefix)) + ) { + throw new TypeError(`Invalid ${kind} identifier`) + } + return value as IdForKind +} + +export function isPrefixedId(kind: K, value: unknown): value is IdForKind { + return ( + typeof value === 'string' && + idPattern.test(value) && + prefixes[kind].some((prefix) => value.startsWith(prefix)) + ) +} + +export function assertPrefixedId( + kind: K, + value: unknown, +): asserts value is IdForKind { + parsePrefixedId(kind, value) +} + +export function parseDigest(value: unknown): Digest { + if (typeof value !== 'string' || !/^[0-9a-f]{64}$/u.test(value)) { + throw new TypeError('Invalid SHA-256 digest') + } + return value as Digest +} + +export function isDigest(value: unknown): value is Digest { + return typeof value === 'string' && /^[0-9a-f]{64}$/u.test(value) +} + +export function assertDigest(value: unknown): asserts value is Digest { + parseDigest(value) +} + +export function parseCursor(value: unknown): ReplayCursor { + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > 4096 || + value.includes('\u0000') || + /\s/u.test(value) + ) { + throw new TypeError('Invalid replay cursor') + } + return value as ReplayCursor +} + +export function isCursor(value: unknown): value is ReplayCursor { + return ( + typeof value === 'string' && + value.length > 0 && + value.length <= 4096 && + !value.includes('\u0000') && + !/\s/u.test(value) + ) +} + +export function assertCursor(value: unknown): asserts value is ReplayCursor { + parseCursor(value) +} diff --git a/src/domain/ids-types.ts b/src/domain/ids-types.ts new file mode 100644 index 0000000..fadd8a5 --- /dev/null +++ b/src/domain/ids-types.ts @@ -0,0 +1,131 @@ +/** + * Nominal identifiers used by Braid's durable graph. + * + * A provider identifier is represented by the provider-specific field on its + * Braid binding. It is never interchangeable with the Braid identifier that + * points at that binding. + */ + +declare const ID_BRAND: unique symbol + +export type BrandedId = string & { + /** + * The optional marker keeps the public W0 string API source-compatible while + * making branded values from different domains incompatible with each other. + */ + readonly [ID_BRAND]?: Name +} + +export type WorkspaceId = BrandedId<'WorkspaceId'> +export type ProfileId = BrandedId<'ProfileId'> +export type ProfileSnapshotId = BrandedId<'ProfileSnapshotId'> +export type CredentialRefId = BrandedId<'CredentialRefId'> +export type ConnectionId = BrandedId<'ConnectionId'> +export type ConversationId = BrandedId<'ConversationId'> +export type BranchId = BrandedId<'BranchId'> +export type TurnId = BrandedId<'TurnId'> +export type RunId = BrandedId<'RunId'> +export type MessageId = BrandedId<'MessageId'> +export type MessagePartId = BrandedId<'MessagePartId'> +export type ArtifactId = BrandedId<'ArtifactId'> +export type InteractionId = BrandedId<'InteractionId'> +export type AnalysisId = BrandedId<'AnalysisId'> +export type AnalysisRunId = BrandedId<'AnalysisRunId'> +export type CitationId = BrandedId<'CitationId'> +export type AttachmentId = BrandedId<'AttachmentId'> +export type FeedbackDecisionId = BrandedId<'FeedbackDecisionId'> +export type TraceId = BrandedId<'TraceId'> +export type ProviderSessionId = BrandedId<'ProviderSessionId'> +export type EnvironmentId = BrandedId<'EnvironmentId'> +export type CheckpointId = BrandedId<'CheckpointId'> +export type SupervisorId = BrandedId<'SupervisorId'> +export type WorkerId = BrandedId<'WorkerId'> +export type DraftId = BrandedId<'DraftId'> +export type QueueId = BrandedId<'QueueId'> +export type QueueEntryId = BrandedId<'QueueEntryId'> +export type RuleId = BrandedId<'RuleId'> +export type BindingId = BrandedId<'BindingId'> +export type GraphNodeId = BrandedId<'GraphNodeId'> +export type GraphEdgeId = BrandedId<'GraphEdgeId'> +export type OperationId = BrandedId<'OperationId'> +export type EffectId = BrandedId<'EffectId'> +export type ReceiptId = BrandedId<'ReceiptId'> +export type EventId = BrandedId<'EventId'> + +export type Digest = BrandedId<'Digest'> +export type ReplayCursor = BrandedId<'ReplayCursor'> + +export type IdKind = + | 'workspace' + | 'profile' + | 'profileSnapshot' + | 'credentialRef' + | 'connection' + | 'conversation' + | 'branch' + | 'turn' + | 'run' + | 'message' + | 'messagePart' + | 'artifact' + | 'interaction' + | 'analysis' + | 'analysisRun' + | 'citation' + | 'attachment' + | 'feedbackDecision' + | 'trace' + | 'providerSession' + | 'environment' + | 'checkpoint' + | 'supervisor' + | 'worker' + | 'draft' + | 'queue' + | 'queueEntry' + | 'rule' + | 'binding' + | 'graphNode' + | 'graphEdge' + | 'operation' + | 'effect' + | 'receipt' + | 'event' + +export type IdForKind = { + workspace: WorkspaceId + profile: ProfileId + profileSnapshot: ProfileSnapshotId + credentialRef: CredentialRefId + connection: ConnectionId + conversation: ConversationId + branch: BranchId + turn: TurnId + run: RunId + message: MessageId + messagePart: MessagePartId + artifact: ArtifactId + interaction: InteractionId + analysis: AnalysisId + analysisRun: AnalysisRunId + citation: CitationId + attachment: AttachmentId + feedbackDecision: FeedbackDecisionId + trace: TraceId + providerSession: ProviderSessionId + environment: EnvironmentId + checkpoint: CheckpointId + supervisor: SupervisorId + worker: WorkerId + draft: DraftId + queue: QueueId + queueEntry: QueueEntryId + rule: RuleId + binding: BindingId + graphNode: GraphNodeId + graphEdge: GraphEdgeId + operation: OperationId + effect: EffectId + receipt: ReceiptId + event: EventId +}[K] diff --git a/src/domain/ids-values.ts b/src/domain/ids-values.ts new file mode 100644 index 0000000..ac202e0 --- /dev/null +++ b/src/domain/ids-values.ts @@ -0,0 +1,377 @@ +import type { + AnalysisId, + AnalysisRunId, + ArtifactId, + AttachmentId, + BindingId, + BranchId, + CheckpointId, + CitationId, + ConnectionId, + ConversationId, + CredentialRefId, + Digest, + DraftId, + EffectId, + EnvironmentId, + EventId, + FeedbackDecisionId, + GraphEdgeId, + GraphNodeId, + InteractionId, + MessageId, + MessagePartId, + OperationId, + ProfileId, + ProfileSnapshotId, + ProviderSessionId, + QueueEntryId, + QueueId, + ReceiptId, + ReplayCursor, + RuleId, + RunId, + SupervisorId, + TraceId, + TurnId, + WorkerId, + WorkspaceId, +} from './ids-types.js' +import { + assertCursor, + assertDigest, + assertPrefixedId, + isCursor, + isDigest, + isPrefixedId, + parseCursor, + parseDigest, + parsePrefixedId, +} from './ids-core.js' + +export function createWorkspaceId(value: string): WorkspaceId { + return parsePrefixedId('workspace', value) +} + +export const parseWorkspaceId = (value: unknown): WorkspaceId => parsePrefixedId('workspace', value) +export const isWorkspaceId = (value: unknown): value is WorkspaceId => + isPrefixedId('workspace', value) +export const assertWorkspaceId = (value: unknown): asserts value is WorkspaceId => + assertPrefixedId('workspace', value) + +export function createProfileId(value: string): ProfileId { + return parsePrefixedId('profile', value) +} +export const parseProfileId = (value: unknown): ProfileId => parsePrefixedId('profile', value) +export const isProfileId = (value: unknown): value is ProfileId => isPrefixedId('profile', value) +export const assertProfileId = (value: unknown): asserts value is ProfileId => + assertPrefixedId('profile', value) + +export function createProfileSnapshotId(value: string): ProfileSnapshotId { + return parsePrefixedId('profileSnapshot', value) +} +export const parseProfileSnapshotId = (value: unknown): ProfileSnapshotId => + parsePrefixedId('profileSnapshot', value) +export const isProfileSnapshotId = (value: unknown): value is ProfileSnapshotId => + isPrefixedId('profileSnapshot', value) +export const assertProfileSnapshotId = (value: unknown): asserts value is ProfileSnapshotId => + assertPrefixedId('profileSnapshot', value) + +export function createCredentialRefId(value: string): CredentialRefId { + return parsePrefixedId('credentialRef', value) +} +export const parseCredentialRefId = (value: unknown): CredentialRefId => + parsePrefixedId('credentialRef', value) +export const isCredentialRefId = (value: unknown): value is CredentialRefId => + isPrefixedId('credentialRef', value) +export const assertCredentialRefId = (value: unknown): asserts value is CredentialRefId => + assertPrefixedId('credentialRef', value) + +export function createConnectionId(value: string): ConnectionId { + return parsePrefixedId('connection', value) +} +export const parseConnectionId = (value: unknown): ConnectionId => + parsePrefixedId('connection', value) +export const isConnectionId = (value: unknown): value is ConnectionId => + isPrefixedId('connection', value) +export const assertConnectionId = (value: unknown): asserts value is ConnectionId => + assertPrefixedId('connection', value) + +export function createConversationId(value: string): ConversationId { + return parsePrefixedId('conversation', value) +} +export const parseConversationId = (value: unknown): ConversationId => + parsePrefixedId('conversation', value) +export const isConversationId = (value: unknown): value is ConversationId => + isPrefixedId('conversation', value) +export const assertConversationId = (value: unknown): asserts value is ConversationId => + assertPrefixedId('conversation', value) + +export function createBranchId(value: string): BranchId { + return parsePrefixedId('branch', value) +} +export const parseBranchId = (value: unknown): BranchId => parsePrefixedId('branch', value) +export const isBranchId = (value: unknown): value is BranchId => isPrefixedId('branch', value) +export const assertBranchId = (value: unknown): asserts value is BranchId => + assertPrefixedId('branch', value) + +export function createTurnId(value: string): TurnId { + return parsePrefixedId('turn', value) +} +export const parseTurnId = (value: unknown): TurnId => parsePrefixedId('turn', value) +export const isTurnId = (value: unknown): value is TurnId => isPrefixedId('turn', value) +export const assertTurnId = (value: unknown): asserts value is TurnId => + assertPrefixedId('turn', value) + +export function createRunId(value: string): RunId { + return parsePrefixedId('run', value) +} +export const parseRunId = (value: unknown): RunId => parsePrefixedId('run', value) +export const isRunId = (value: unknown): value is RunId => isPrefixedId('run', value) +export const assertRunId = (value: unknown): asserts value is RunId => + assertPrefixedId('run', value) + +export function createMessageId(value: string): MessageId { + return parsePrefixedId('message', value) +} +export const parseMessageId = (value: unknown): MessageId => parsePrefixedId('message', value) +export const isMessageId = (value: unknown): value is MessageId => isPrefixedId('message', value) +export const assertMessageId = (value: unknown): asserts value is MessageId => + assertPrefixedId('message', value) + +export function createMessagePartId(value: string): MessagePartId { + return parsePrefixedId('messagePart', value) +} +export const parseMessagePartId = (value: unknown): MessagePartId => + parsePrefixedId('messagePart', value) +export const isMessagePartId = (value: unknown): value is MessagePartId => + isPrefixedId('messagePart', value) +export const assertMessagePartId = (value: unknown): asserts value is MessagePartId => + assertPrefixedId('messagePart', value) + +export function createArtifactId(value: string): ArtifactId { + return parsePrefixedId('artifact', value) +} +export const parseArtifactId = (value: unknown): ArtifactId => parsePrefixedId('artifact', value) +export const isArtifactId = (value: unknown): value is ArtifactId => isPrefixedId('artifact', value) +export const assertArtifactId = (value: unknown): asserts value is ArtifactId => + assertPrefixedId('artifact', value) + +export function createInteractionId(value: string): InteractionId { + return parsePrefixedId('interaction', value) +} +export const parseInteractionId = (value: unknown): InteractionId => + parsePrefixedId('interaction', value) +export const isInteractionId = (value: unknown): value is InteractionId => + isPrefixedId('interaction', value) +export const assertInteractionId = (value: unknown): asserts value is InteractionId => + assertPrefixedId('interaction', value) + +export function createAnalysisId(value: string): AnalysisId { + return parsePrefixedId('analysis', value) +} +export const parseAnalysisId = (value: unknown): AnalysisId => parsePrefixedId('analysis', value) +export const isAnalysisId = (value: unknown): value is AnalysisId => isPrefixedId('analysis', value) +export const assertAnalysisId = (value: unknown): asserts value is AnalysisId => + assertPrefixedId('analysis', value) + +export function createAnalysisRunId(value: string): AnalysisRunId { + return parsePrefixedId('analysisRun', value) +} +export const parseAnalysisRunId = (value: unknown): AnalysisRunId => + parsePrefixedId('analysisRun', value) +export const isAnalysisRunId = (value: unknown): value is AnalysisRunId => + isPrefixedId('analysisRun', value) +export const assertAnalysisRunId = (value: unknown): asserts value is AnalysisRunId => + assertPrefixedId('analysisRun', value) + +export function createCitationId(value: string): CitationId { + return parsePrefixedId('citation', value) +} +export const parseCitationId = (value: unknown): CitationId => parsePrefixedId('citation', value) +export const isCitationId = (value: unknown): value is CitationId => isPrefixedId('citation', value) +export const assertCitationId = (value: unknown): asserts value is CitationId => + assertPrefixedId('citation', value) + +export function createAttachmentId(value: string): AttachmentId { + return parsePrefixedId('attachment', value) +} +export const parseAttachmentId = (value: unknown): AttachmentId => + parsePrefixedId('attachment', value) +export const isAttachmentId = (value: unknown): value is AttachmentId => + isPrefixedId('attachment', value) +export const assertAttachmentId = (value: unknown): asserts value is AttachmentId => + assertPrefixedId('attachment', value) + +export function createFeedbackDecisionId(value: string): FeedbackDecisionId { + return parsePrefixedId('feedbackDecision', value) +} +export const parseFeedbackDecisionId = (value: unknown): FeedbackDecisionId => + parsePrefixedId('feedbackDecision', value) +export const isFeedbackDecisionId = (value: unknown): value is FeedbackDecisionId => + isPrefixedId('feedbackDecision', value) +export const assertFeedbackDecisionId = (value: unknown): asserts value is FeedbackDecisionId => + assertPrefixedId('feedbackDecision', value) + +export function createTraceId(value: string): TraceId { + return parsePrefixedId('trace', value) +} +export const parseTraceId = (value: unknown): TraceId => parsePrefixedId('trace', value) +export const isTraceId = (value: unknown): value is TraceId => isPrefixedId('trace', value) +export const assertTraceId = (value: unknown): asserts value is TraceId => + assertPrefixedId('trace', value) + +export function createProviderSessionId(value: string): ProviderSessionId { + return parsePrefixedId('providerSession', value) +} +export const parseProviderSessionId = (value: unknown): ProviderSessionId => + parsePrefixedId('providerSession', value) +export const isProviderSessionId = (value: unknown): value is ProviderSessionId => + isPrefixedId('providerSession', value) +export const assertProviderSessionId = (value: unknown): asserts value is ProviderSessionId => + assertPrefixedId('providerSession', value) + +export function createEnvironmentId(value: string): EnvironmentId { + return parsePrefixedId('environment', value) +} +export const parseEnvironmentId = (value: unknown): EnvironmentId => + parsePrefixedId('environment', value) +export const isEnvironmentId = (value: unknown): value is EnvironmentId => + isPrefixedId('environment', value) +export const assertEnvironmentId = (value: unknown): asserts value is EnvironmentId => + assertPrefixedId('environment', value) + +export function createCheckpointId(value: string): CheckpointId { + return parsePrefixedId('checkpoint', value) +} +export const parseCheckpointId = (value: unknown): CheckpointId => + parsePrefixedId('checkpoint', value) +export const isCheckpointId = (value: unknown): value is CheckpointId => + isPrefixedId('checkpoint', value) +export const assertCheckpointId = (value: unknown): asserts value is CheckpointId => + assertPrefixedId('checkpoint', value) + +export function createSupervisorId(value: string): SupervisorId { + return parsePrefixedId('supervisor', value) +} +export const parseSupervisorId = (value: unknown): SupervisorId => + parsePrefixedId('supervisor', value) +export const isSupervisorId = (value: unknown): value is SupervisorId => + isPrefixedId('supervisor', value) +export const assertSupervisorId = (value: unknown): asserts value is SupervisorId => + assertPrefixedId('supervisor', value) + +export function createWorkerId(value: string): WorkerId { + return parsePrefixedId('worker', value) +} +export const parseWorkerId = (value: unknown): WorkerId => parsePrefixedId('worker', value) +export const isWorkerId = (value: unknown): value is WorkerId => isPrefixedId('worker', value) +export const assertWorkerId = (value: unknown): asserts value is WorkerId => + assertPrefixedId('worker', value) + +export function createDraftId(value: string): DraftId { + return parsePrefixedId('draft', value) +} +export const parseDraftId = (value: unknown): DraftId => parsePrefixedId('draft', value) +export const isDraftId = (value: unknown): value is DraftId => isPrefixedId('draft', value) +export const assertDraftId = (value: unknown): asserts value is DraftId => + assertPrefixedId('draft', value) + +export function createQueueId(value: string): QueueId { + return parsePrefixedId('queue', value) +} +export const parseQueueId = (value: unknown): QueueId => parsePrefixedId('queue', value) +export const isQueueId = (value: unknown): value is QueueId => isPrefixedId('queue', value) +export const assertQueueId = (value: unknown): asserts value is QueueId => + assertPrefixedId('queue', value) + +export function createQueueEntryId(value: string): QueueEntryId { + return parsePrefixedId('queueEntry', value) +} +export const parseQueueEntryId = (value: unknown): QueueEntryId => + parsePrefixedId('queueEntry', value) +export const isQueueEntryId = (value: unknown): value is QueueEntryId => + isPrefixedId('queueEntry', value) +export const assertQueueEntryId = (value: unknown): asserts value is QueueEntryId => + assertPrefixedId('queueEntry', value) + +export function createRuleId(value: string): RuleId { + return parsePrefixedId('rule', value) +} +export const parseRuleId = (value: unknown): RuleId => parsePrefixedId('rule', value) +export const isRuleId = (value: unknown): value is RuleId => isPrefixedId('rule', value) +export const assertRuleId = (value: unknown): asserts value is RuleId => + assertPrefixedId('rule', value) + +export function createBindingId(value: string): BindingId { + return parsePrefixedId('binding', value) +} +export const parseBindingId = (value: unknown): BindingId => parsePrefixedId('binding', value) +export const isBindingId = (value: unknown): value is BindingId => isPrefixedId('binding', value) +export const assertBindingId = (value: unknown): asserts value is BindingId => + assertPrefixedId('binding', value) + +export function createGraphNodeId(value: string): GraphNodeId { + return parsePrefixedId('graphNode', value) +} +export const parseGraphNodeId = (value: unknown): GraphNodeId => parsePrefixedId('graphNode', value) +export const isGraphNodeId = (value: unknown): value is GraphNodeId => + isPrefixedId('graphNode', value) +export const assertGraphNodeId = (value: unknown): asserts value is GraphNodeId => + assertPrefixedId('graphNode', value) + +export function createGraphEdgeId(value: string): GraphEdgeId { + return parsePrefixedId('graphEdge', value) +} +export const parseGraphEdgeId = (value: unknown): GraphEdgeId => parsePrefixedId('graphEdge', value) +export const isGraphEdgeId = (value: unknown): value is GraphEdgeId => + isPrefixedId('graphEdge', value) +export const assertGraphEdgeId = (value: unknown): asserts value is GraphEdgeId => + assertPrefixedId('graphEdge', value) + +export function createOperationId(value: string): OperationId { + return parsePrefixedId('operation', value) +} +export const parseOperationId = (value: unknown): OperationId => parsePrefixedId('operation', value) +export const isOperationId = (value: unknown): value is OperationId => + isPrefixedId('operation', value) +export const assertOperationId = (value: unknown): asserts value is OperationId => + assertPrefixedId('operation', value) + +export function createEffectId(value: string): EffectId { + return parsePrefixedId('effect', value) +} +export const parseEffectId = (value: unknown): EffectId => parsePrefixedId('effect', value) +export const isEffectId = (value: unknown): value is EffectId => isPrefixedId('effect', value) +export const assertEffectId = (value: unknown): asserts value is EffectId => + assertPrefixedId('effect', value) + +export function createReceiptId(value: string): ReceiptId { + return parsePrefixedId('receipt', value) +} +export const parseReceiptId = (value: unknown): ReceiptId => parsePrefixedId('receipt', value) +export const isReceiptId = (value: unknown): value is ReceiptId => isPrefixedId('receipt', value) +export const assertReceiptId = (value: unknown): asserts value is ReceiptId => + assertPrefixedId('receipt', value) + +export function createEventId(value: string): EventId { + return parsePrefixedId('event', value) +} +export const parseEventId = (value: unknown): EventId => parsePrefixedId('event', value) +export const isEventId = (value: unknown): value is EventId => isPrefixedId('event', value) +export const assertEventId = (value: unknown): asserts value is EventId => + assertPrefixedId('event', value) + +export function createDigest(value: string): Digest { + return parseDigest(value) +} +export const parseDigestValue = parseDigest +export const isDigestValue = isDigest +export const assertDigestValue = assertDigest + +export function createReplayCursor(value: string): ReplayCursor { + return parseCursor(value) +} +export const parseReplayCursor = parseCursor +export const isReplayCursor = isCursor +export const assertReplayCursor = assertCursor diff --git a/src/domain/ids.ts b/src/domain/ids.ts new file mode 100644 index 0000000..9b6cdd0 --- /dev/null +++ b/src/domain/ids.ts @@ -0,0 +1,17 @@ +export type * from './ids-types.js' +export * from './ids-values.js' + +import type { IdForKind, IdKind } from './ids-types.js' +import { assertPrefixedId, isPrefixedId, parsePrefixedId } from './ids-core.js' + +export function parseId(kind: K, value: unknown): IdForKind { + return parsePrefixedId(kind, value) +} + +export function isId(kind: K, value: unknown): value is IdForKind { + return isPrefixedId(kind, value) +} + +export function assertId(kind: K, value: unknown): asserts value is IdForKind { + assertPrefixedId(kind, value) +} diff --git a/src/domain/invariants-base.ts b/src/domain/invariants-base.ts new file mode 100644 index 0000000..82dfed8 --- /dev/null +++ b/src/domain/invariants-base.ts @@ -0,0 +1,201 @@ +import type { JsonValue } from './entities.js' +import type { IdKind } from './ids.js' +import { isDigestValue, isId, isReplayCursor } from './ids.js' +import { isCanonicalIsoDateTime } from './text.js' + +export const OPERATION_KINDS = new Set([ + 'profile-save', + 'connection-change', + 'conversation-create', + 'conversation-open', + 'conversation-update', + 'conversation-archive', + 'conversation-delete', + 'branch-create', + 'conversation-clone', + 'conversation-fork', + 'context-plan', + 'conversation-import', + 'draft-update', + 'send', + 'queue', + 'interaction-response', + 'cancel-run', + 'steer-worker', + 'checkpoint', + 'fork-environment', + 'analysis', + 'promote-analysis', + 'export', + 'delete', + 'custom', +]) + +export const OPERATION_STATUSES = new Set([ + 'pending', + 'acknowledged', + 'failed', + 'unknown', + 'conflict', + 'terminal', +]) +const SECRET_NAME = + /(secret|password|passphrase|token|bearer|authorization|credential|private(?:[_-]?key)?|api[-_]?key)\s*[:=]/iu + +export function assertPublicReference(value: string, name: string): void { + nonEmpty(value, name) + if (SECRET_NAME.test(value) || /:\/\/[^/\s:@]+:[^/\s@]+@/u.test(value)) { + fail(`${name} cannot contain credential material`) + } + try { + const url = new URL(value) + if (url.username || url.password) fail(`${name} cannot contain URL credentials`) + for (const key of url.searchParams.keys()) { + if ( + /(secret|password|passphrase|token|bearer|authorization|credential|private(?:[_-]?key)?|api[-_]?key)/iu.test( + key, + ) + ) { + fail(`${name} cannot contain credential-bearing query parameters`) + } + } + } catch (error) { + if (error instanceof DomainInvariantError) throw error + // Local paths and provider references need not be URLs. + } +} + +export class DomainInvariantError extends Error { + readonly code = 'DOMAIN_INVARIANT' + + constructor(message: string) { + super(message) + this.name = 'DomainInvariantError' + } +} + +export function fail(message: string): never { + throw new DomainInvariantError(message) +} + +export function failUnsupported(value: never, name: string): never { + void value + return fail(`${name} is unsupported`) +} + +export function nonEmpty(value: unknown, name: string): asserts value is string { + if (typeof value !== 'string' || value.trim().length === 0) fail(`${name} must be non-empty`) +} + +export function finiteNonNegative(value: unknown, name: string): void { + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) { + fail(`${name} must be a finite non-negative number`) + } +} + +export function finiteRatio(value: unknown, name: string): void { + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > 1) { + fail(`${name} must be a number between zero and one`) + } +} + +export function finitePositive(value: unknown, name: string): void { + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) { + fail(`${name} must be a finite positive number`) + } +} + +export function objectValue( + value: unknown, + name: string, +): asserts value is Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) + fail(`${name} must be an object`) +} + +export function assertJsonValue(value: unknown, name = 'value'): asserts value is JsonValue { + if (value === null || typeof value === 'string' || typeof value === 'boolean') return + if (typeof value === 'number') { + if (!Number.isFinite(value)) fail(`${name} must not contain a non-finite number`) + return + } + if (Array.isArray(value)) { + value.forEach((entry, index) => { + assertJsonValue(entry, `${name}[${index}]`) + }) + return + } + objectValue(value, name) + for (const [key, child] of Object.entries(value)) assertJsonValue(child, `${name}.${key}`) +} + +export function assertEntityId(kind: IdKind, value: unknown, name: string): void { + if (!isId(kind, value)) { + fail(`${name} is not a valid ${kind} identifier`) + } +} + +export function assertDigest(value: unknown, name: string): void { + if (!isDigestValue(value)) { + fail(`${name} is not a SHA-256 digest`) + } +} + +export function assertDate(value: unknown, name: string): void { + if (!isCanonicalIsoDateTime(value)) fail(`${name} is not a canonical ISO date`) +} + +export function assertUniqueIds(values: readonly string[], name: string): void { + const seen = new Set() + for (const value of values) { + if (seen.has(value)) fail(`${name} contains duplicate identifier ${value}`) + seen.add(value) + } +} + +import type { BraidState } from './state.js' + +export function assertFeedbackDecisionRecord( + record: BraidState['feedbackDecisions'][number], +): void { + assertEntityId('feedbackDecision', record.id, 'feedbackDecision.id') + assertEntityId('conversation', record.conversationId, 'feedbackDecision.conversationId') + if (record.operationId !== undefined) + assertEntityId('operation', record.operationId, 'feedbackDecision.operationId') + nonEmpty(record.chosenOption, 'feedbackDecision.chosenOption') + assertDate(record.createdAt, 'feedbackDecision.createdAt') +} + +export function assertReplayCursorRecord(record: BraidState['replayCursors'][number]): void { + assertEntityId('run', record.runId, 'replayCursor.runId') + if (!isReplayCursor(record.cursor)) fail('replayCursor.cursor is invalid') + finiteNonNegative(record.committedSequence, 'replayCursor.committedSequence') +} + +export function assertAppliedEventRecord(record: BraidState['appliedEvents'][number]): void { + assertEntityId('event', record.id, 'appliedEvent.id') + finitePositive(record.sequence, 'appliedEvent.sequence') + finitePositive(record.revision, 'appliedEvent.revision') + assertDigest(record.digest, 'appliedEvent.digest') +} + +export function assertUnknownEventRecord(record: BraidState['unknownEvents'][number]): void { + assertEntityId('event', record.id, 'unknownEvent.id') + nonEmpty(record.type, 'unknownEvent.type') + nonEmpty(record.summary, 'unknownEvent.summary') + finitePositive(record.sequence, 'unknownEvent.sequence') +} + +export function assertMissingHistory(range: { + readonly runId: unknown + readonly fromSequence: number + readonly toSequence?: number +}): void { + assertEntityId('run', range.runId, 'missingHistory.runId') + finiteNonNegative(range.fromSequence, 'missingHistory.fromSequence') + if (range.toSequence !== undefined) { + finiteNonNegative(range.toSequence, 'missingHistory.toSequence') + if (range.toSequence < range.fromSequence) + fail('missingHistory.toSequence must not precede fromSequence') + } +} diff --git a/src/domain/invariants-conversation.ts b/src/domain/invariants-conversation.ts new file mode 100644 index 0000000..8691fab --- /dev/null +++ b/src/domain/invariants-conversation.ts @@ -0,0 +1,156 @@ +import type { + BranchRecord, + ConversationRecord, + MessagePartRecord, + MessageRecord, + TurnRecord, +} from './entities.js' +import { + assertDate, + assertEntityId, + assertJsonValue, + assertMissingHistory, + assertUniqueIds, + fail, + failUnsupported, + finiteNonNegative, + nonEmpty, +} from './invariants-base.js' + +export function assertConversationRecord(record: ConversationRecord): void { + assertEntityId('conversation', record.id, 'conversation.id') + assertEntityId('workspace', record.workspaceId, 'conversation.workspaceId') + assertEntityId('branch', record.activeBranchId, 'conversation.activeBranchId') + nonEmpty(record.title, 'conversation.title') + if (record.profileId !== undefined) + assertEntityId('profile', record.profileId, 'conversation.profileId') + for (const [name, value] of Object.entries(record.retention)) { + if (value !== undefined) finiteNonNegative(value, `conversation.retention.${name}`) + } + assertDate(record.createdAt, 'conversation.createdAt') + assertDate(record.updatedAt, 'conversation.updatedAt') + if (record.deletedAt !== undefined) assertDate(record.deletedAt, 'conversation.deletedAt') +} + +export function assertBranchRecord(record: BranchRecord): void { + assertEntityId('branch', record.id, 'branch.id') + assertEntityId('conversation', record.conversationId, 'branch.conversationId') + if (record.source !== undefined) { + assertEntityId('conversation', record.source.conversationId, 'branch.source.conversationId') + assertEntityId('branch', record.source.branchId, 'branch.source.branchId') + if (record.source.throughMessageId !== undefined) + assertEntityId('message', record.source.throughMessageId, 'branch.source.throughMessageId') + if (record.source.throughTurnId !== undefined) + assertEntityId('turn', record.source.throughTurnId, 'branch.source.throughTurnId') + } + if (record.profileId !== undefined) + assertEntityId('profile', record.profileId, 'branch.profileId') + if (record.profileSnapshotId !== undefined) + assertEntityId('profileSnapshot', record.profileSnapshotId, 'branch.profileSnapshotId') + if (record.connectionId !== undefined) + assertEntityId('connection', record.connectionId, 'branch.connectionId') + assertEntityId('draft', record.draftId, 'branch.draftId') + assertEntityId('queue', record.queueId, 'branch.queueId') + if (record.bindingId !== undefined) + assertEntityId('binding', record.bindingId, 'branch.bindingId') + if (record.environmentId !== undefined) + assertEntityId('environment', record.environmentId, 'branch.environmentId') + if (record.tipMessageId !== undefined) + assertEntityId('message', record.tipMessageId, 'branch.tipMessageId') + assertDate(record.createdAt, 'branch.createdAt') + assertDate(record.updatedAt, 'branch.updatedAt') +} + +export function assertTurnRecord(record: TurnRecord): void { + assertEntityId('turn', record.id, 'turn.id') + assertEntityId('conversation', record.conversationId, 'turn.conversationId') + assertEntityId('branch', record.branchId, 'turn.branchId') + assertEntityId('message', record.userMessageId, 'turn.userMessageId') + record.runIds.forEach((id) => { + assertEntityId('run', id, 'turn.runIds') + }) + assertUniqueIds(record.runIds, 'turn.runIds') + if (record.selectedRunId !== undefined) + assertEntityId('run', record.selectedRunId, 'turn.selectedRunId') + if (record.queueEntryId !== undefined) + assertEntityId('queueEntry', record.queueEntryId, 'turn.queueEntryId') + assertDate(record.createdAt, 'turn.createdAt') + assertDate(record.updatedAt, 'turn.updatedAt') +} + +export function assertMessageRecord(record: MessageRecord): void { + assertEntityId('message', record.id, 'message.id') + assertEntityId('conversation', record.conversationId, 'message.conversationId') + assertEntityId('branch', record.branchId, 'message.branchId') + record.partIds.forEach((id) => { + assertEntityId('messagePart', id, 'message.partIds') + }) + assertUniqueIds(record.partIds, 'message.partIds') + if (record.turnId !== undefined) assertEntityId('turn', record.turnId, 'message.turnId') + if (record.runId !== undefined) assertEntityId('run', record.runId, 'message.runId') + if (typeof record.text !== 'string') fail('message.text must be text') + if ( + record.complete && + ![ + 'complete', + 'failed', + 'aborted', + 'cancelled', + 'blocked', + 'expired', + 'unknown', + 'redacted', + ].includes(record.status) + ) { + fail('complete messages must have a terminal status') + } + assertDate(record.createdAt, 'message.createdAt') + assertDate(record.updatedAt, 'message.updatedAt') + if (record.missingHistory !== undefined) assertMissingHistory(record.missingHistory) +} + +export function assertMessagePartRecord(record: MessagePartRecord): void { + assertEntityId('messagePart', record.id, 'messagePart.id') + assertEntityId('message', record.messageId, 'messagePart.messageId') + finiteNonNegative(record.ordinal, 'messagePart.ordinal') + assertDate(record.createdAt, 'messagePart.createdAt') + assertDate(record.updatedAt, 'messagePart.updatedAt') + switch (record.kind) { + case 'text': + case 'reasoning': + if (typeof record.text !== 'string') fail(`messagePart.${record.kind}.text must be text`) + return + case 'tool-call': + nonEmpty(record.name, 'messagePart.tool-call.name') + assertJsonValue(record.arguments, 'messagePart.tool-call.arguments') + return + case 'tool-result': + nonEmpty(record.summary, 'messagePart.tool-result.summary') + if (record.output !== undefined) + assertJsonValue(record.output, 'messagePart.tool-result.output') + return + case 'artifact': + assertEntityId('artifact', record.artifactId, 'messagePart.artifact.artifactId') + nonEmpty(record.summary, 'messagePart.artifact.summary') + return + case 'file': + if (record.path === undefined && record.filename === undefined) + fail('messagePart.file needs path or filename') + return + case 'image': + if (record.artifactId !== undefined) + assertEntityId('artifact', record.artifactId, 'messagePart.image.artifactId') + return + case 'warning': + case 'error': + nonEmpty(record.message, `messagePart.${record.kind}.message`) + return + case 'unknown': + nonEmpty(record.namespace, 'messagePart.unknown.namespace') + nonEmpty(record.type, 'messagePart.unknown.type') + nonEmpty(record.summary, 'messagePart.unknown.summary') + return + default: + failUnsupported(record, 'message part kind') + } +} diff --git a/src/domain/invariants-profile.ts b/src/domain/invariants-profile.ts new file mode 100644 index 0000000..75bd12c --- /dev/null +++ b/src/domain/invariants-profile.ts @@ -0,0 +1,104 @@ +import { canonicalAgentProfileDigest, snapshotAgentProfile } from '@tangle-network/agent-interface' + +import type { + ConnectionRecord, + CredentialReference, + ProfileRecord, + ProfileSnapshotRecord, + WorkspaceRecord, +} from './entities.js' +import { + assertDate, + assertDigest, + assertEntityId, + assertPublicReference, + DomainInvariantError, + fail, + nonEmpty, + objectValue, +} from './invariants-base.js' + +export function assertWorkspaceRecord(record: WorkspaceRecord): void { + assertEntityId('workspace', record.id, 'workspace.id') + nonEmpty(record.root, 'workspace.root') + if (record.trustDigest !== undefined) assertDigest(record.trustDigest, 'workspace.trustDigest') + assertDate(record.createdAt, 'workspace.createdAt') + assertDate(record.updatedAt, 'workspace.updatedAt') +} + +export function assertProfileRecord(record: ProfileRecord): void { + assertEntityId('profile', record.id, 'profile.id') + assertCanonicalProfile(record.profile, record.digest, 'profile.profile') + assertPublicReference(record.source.reference, 'profile.source.reference') + assertDigest(record.digest, 'profile.digest') + if (!Array.isArray(record.validation.issues)) fail('profile.validation.issues must be an array') + assertDate(record.createdAt, 'profile.createdAt') + assertDate(record.updatedAt, 'profile.updatedAt') +} + +export function assertProfileSnapshotRecord(record: ProfileSnapshotRecord): void { + assertEntityId('profileSnapshot', record.id, 'profileSnapshot.id') + if (record.profileId !== undefined) + assertEntityId('profile', record.profileId, 'profileSnapshot.profileId') + assertCanonicalProfile(record.profile, record.digest, 'profileSnapshot.profile') + assertPublicReference(record.source.reference, 'profileSnapshot.source.reference') + assertDigest(record.digest, 'profileSnapshot.digest') + assertDate(record.createdAt, 'profileSnapshot.createdAt') +} + +export function assertCanonicalProfile( + profile: ProfileRecord['profile'], + digest: unknown, + name: string, +): void { + objectValue(profile, name) + try { + const snapshot = snapshotAgentProfile(profile) + const canonicalDigest = canonicalAgentProfileDigest(snapshot) + const bareDigest = canonicalDigest.startsWith('sha256:') + ? canonicalDigest.slice('sha256:'.length) + : canonicalDigest + if (canonicalDigest !== digest && bareDigest !== digest) { + fail(`${name} digest does not match the canonical AgentProfile`) + } + } catch (error) { + if (error instanceof DomainInvariantError) throw error + fail(`${name} is not a canonical AgentProfile`) + } +} + +export function assertCredentialReference(record: CredentialReference): void { + assertEntityId('credentialRef', record.id, 'credentialReference.id') + nonEmpty(record.label, 'credentialReference.label') + nonEmpty(record.facility, 'credentialReference.facility') + assertDate(record.createdAt, 'credentialReference.createdAt') + assertDate(record.updatedAt, 'credentialReference.updatedAt') +} + +export function assertConnectionRecord(record: ConnectionRecord): void { + assertEntityId('connection', record.id, 'connection.id') + if (record.workspaceId !== undefined) + assertEntityId('workspace', record.workspaceId, 'connection.workspaceId') + nonEmpty(record.name, 'connection.name') + if (record.endpoint !== undefined) assertPublicReference(record.endpoint, 'connection.endpoint') + const allowedOptions = new Set(['transport', 'endpoint', 'region', 'account', 'capabilityHints']) + for (const [key, value] of Object.entries(record.providerOptions)) { + if (!allowedOptions.has(key)) fail(`connection.providerOptions.${key} is provider-native state`) + if (key === 'capabilityHints') { + if ( + !Array.isArray(value) || + value.some((entry) => typeof entry !== 'string' || entry.length === 0) + ) { + fail('connection.providerOptions.capabilityHints must contain non-empty names') + } + } else if (typeof value !== 'string' || value.length === 0) { + fail(`connection.providerOptions.${key} must be a non-empty string`) + } + if (key === 'endpoint' && typeof value === 'string') + assertPublicReference(value, 'connection.providerOptions.endpoint') + } + assertDate(record.createdAt, 'connection.createdAt') + assertDate(record.updatedAt, 'connection.updatedAt') + if (record.credentialRef !== undefined) + assertEntityId('credentialRef', record.credentialRef, 'connection.credentialRef') +} diff --git a/src/domain/invariants-relations.ts b/src/domain/invariants-relations.ts new file mode 100644 index 0000000..4c0867d --- /dev/null +++ b/src/domain/invariants-relations.ts @@ -0,0 +1,110 @@ +import type { BraidState } from './state.js' +import { fail } from './invariants-base.js' + +export function assertStateRelations(state: BraidState): void { + if (state.conversations.length === 0) return + const conversations = new Map(state.conversations.map((record) => [record.id, record])) + const branches = new Map(state.branches.map((record) => [record.id, record])) + const messages = new Map(state.messages.map((record) => [record.id, record])) + const turns = new Map(state.turns.map((record) => [record.id, record])) + const runs = new Map(state.runs.map((record) => [record.id, record])) + const drafts = new Map(state.drafts.map((record) => [record.id, record])) + const queues = new Map(state.queues.map((record) => [record.id, record])) + + const selectedConversation = conversations.get(state.conversationId) + if (!selectedConversation || selectedConversation.deletedAt !== undefined) { + fail('state.conversationId must reference an available conversation') + } + const selectedBranch = branches.get(state.branchId) + if (!selectedBranch || selectedBranch.conversationId !== selectedConversation.id) { + fail('state.branchId must belong to the selected conversation') + } + + for (const conversation of state.conversations) { + if (conversation.deletedAt !== undefined) continue + const activeBranch = branches.get(conversation.activeBranchId) + if (!activeBranch || activeBranch.conversationId !== conversation.id) { + fail(`conversation ${conversation.id} has an invalid active branch`) + } + } + + for (const branch of state.branches) { + const conversation = conversations.get(branch.conversationId) + if (!conversation || conversation.deletedAt !== undefined) { + fail(`branch ${branch.id} has no available conversation`) + } + const draft = drafts.get(branch.draftId) + if (!draft || draft.branchId !== branch.id) fail(`branch ${branch.id} has an invalid draft`) + const queue = queues.get(branch.queueId) + if (!queue || queue.branchId !== branch.id) fail(`branch ${branch.id} has an invalid queue`) + if (branch.tipMessageId !== undefined) { + const tip = messages.get(branch.tipMessageId) + if (!tip) fail(`branch ${branch.id} has a missing tip message`) + } + if (branch.source !== undefined) { + const source = branches.get(branch.source.branchId) + if (!source || source.conversationId !== branch.source.conversationId) { + fail(`branch ${branch.id} has an invalid source branch`) + } + if ( + branch.source.throughMessageId !== undefined && + !messages.has(branch.source.throughMessageId) + ) { + fail(`branch ${branch.id} has a missing source message`) + } + if (branch.source.throughTurnId !== undefined && !turns.has(branch.source.throughTurnId)) { + fail(`branch ${branch.id} has a missing source turn`) + } + } + } + + for (const message of state.messages) { + const branch = branches.get(message.branchId) + if (!branch || branch.conversationId !== message.conversationId) { + fail(`message ${message.id} has an invalid branch`) + } + if (message.turnId !== undefined && !turns.has(message.turnId)) { + fail(`message ${message.id} has a missing turn`) + } + if (message.runId !== undefined && !runs.has(message.runId)) { + fail(`message ${message.id} has a missing run`) + } + } + + for (const turn of state.turns) { + const branch = branches.get(turn.branchId) + if (!branch || branch.conversationId !== turn.conversationId) { + fail(`turn ${turn.id} has an invalid branch`) + } + const userMessage = messages.get(turn.userMessageId) + if (!userMessage || userMessage.turnId !== turn.id || userMessage.role !== 'user') { + fail(`turn ${turn.id} has an invalid user message`) + } + for (const runId of turn.runIds) { + const run = runs.get(runId) + if (!run || run.turnId !== turn.id) fail(`turn ${turn.id} has an invalid run`) + } + if (turn.selectedRunId !== undefined && !turn.runIds.includes(turn.selectedRunId)) { + fail(`turn ${turn.id} selected a run outside the turn`) + } + } + + for (const run of state.runs) { + const branch = branches.get(run.branchId) + if (!branch || branch.conversationId !== run.conversationId) { + fail(`run ${run.id} has an invalid branch`) + } + if (!turns.has(run.turnId)) fail(`run ${run.id} has a missing turn`) + } + + for (const draft of state.drafts) { + if (!branches.has(draft.branchId)) fail(`draft ${draft.id} has a missing branch`) + } + for (const queue of state.queues) { + if (!branches.has(queue.branchId)) fail(`queue ${queue.id} has a missing branch`) + for (const entryId of queue.entryIds) { + const entry = state.queueEntries.find((candidate) => candidate.id === entryId) + if (!entry || entry.queueId !== queue.id) fail(`queue ${queue.id} has an invalid entry`) + } + } +} diff --git a/src/domain/invariants-run.ts b/src/domain/invariants-run.ts new file mode 100644 index 0000000..1f95c6d --- /dev/null +++ b/src/domain/invariants-run.ts @@ -0,0 +1,253 @@ +import type { + AnalysisAttachmentRecord, + AnalysisRecord, + InteractionRecord, + RunRecord, +} from './entities.js' +import { isReplayCursor } from './ids.js' +import { + assertDate, + assertDigest, + assertEntityId, + assertJsonValue, + fail, + failUnsupported, + finiteNonNegative, + finiteRatio, + nonEmpty, + objectValue, +} from './invariants-base.js' + +export function assertRunRecord(record: RunRecord): void { + assertEntityId('run', record.id, 'run.id') + assertEntityId('conversation', record.conversationId, 'run.conversationId') + assertEntityId('branch', record.branchId, 'run.branchId') + assertEntityId('turn', record.turnId, 'run.turnId') + assertEntityId('operation', record.operationId, 'run.operationId') + finiteNonNegative(record.inputTokens, 'run.inputTokens') + finiteNonNegative(record.outputTokens, 'run.outputTokens') + if (record.costUsd !== undefined) finiteNonNegative(record.costUsd, 'run.costUsd') + if (record.profileSnapshotId !== undefined) + assertEntityId('profileSnapshot', record.profileSnapshotId, 'run.profileSnapshotId') + if (record.connectionId !== undefined) + assertEntityId('connection', record.connectionId, 'run.connectionId') + if (record.providerSessionId !== undefined) + assertEntityId('providerSession', record.providerSessionId, 'run.providerSessionId') + if (record.environmentId !== undefined) + assertEntityId('environment', record.environmentId, 'run.environmentId') + 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)) + fail('run.replayCursor is invalid') + assertDate(record.startedAt, 'run.startedAt') + assertDate(record.updatedAt, 'run.updatedAt') + if (record.terminalAt !== undefined) assertDate(record.terminalAt, 'run.terminalAt') + const terminalStatuses = [ + 'completed', + 'cancelled', + 'failed', + 'expired', + 'unknown', + 'aborted', + 'blocked', + ] + if (record.complete && !terminalStatuses.includes(record.status)) + 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') + } + } + 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.resolution.dataDigest !== undefined) + assertDigest(record.resolution.dataDigest, 'interaction.resolution.dataDigest') + } + assertNoSecretInteractionData(record) + assertDate(record.createdAt, 'interaction.createdAt') + assertDate(record.updatedAt, 'interaction.updatedAt') +} + +export function assertAnalysisRecord(record: AnalysisRecord): void { + assertEntityId('analysis', record.id, 'analysis.id') + if (record.analysisRunId !== undefined) + assertEntityId('analysisRun', record.analysisRunId, 'analysis.analysisRunId') + if (record.operationId !== undefined) + assertEntityId('operation', record.operationId, 'analysis.operationId') + if (record.requestDigest !== undefined) + assertDigest(record.requestDigest, 'analysis.requestDigest') + if (record.request !== undefined) assertJsonValue(record.request, 'analysis.request') + assertEntityId('conversation', record.source.conversationId, 'analysis.source.conversationId') + assertEntityId('branch', record.source.branchId, 'analysis.source.branchId') + if (record.source.runId !== undefined) + assertEntityId('run', record.source.runId, 'analysis.source.runId') + if (record.source.throughMessageId !== undefined) + assertEntityId('message', record.source.throughMessageId, 'analysis.source.throughMessageId') + assertDigest(record.source.digest, 'analysis.source.digest') + if (record.source.trace !== undefined) { + assertEntityId('trace', record.source.trace.id, 'analysis.source.trace.id') + assertDigest(record.source.trace.digest, 'analysis.source.trace.digest') + } + for (const finding of record.findings) { + nonEmpty(finding.id, 'analysis.finding.id') + nonEmpty(finding.text, 'analysis.finding.text') + if (finding.confidence !== undefined) + finiteRatio(finding.confidence, 'analysis.finding.confidence') + for (const citation of finding.citations) assertAnalysisCitation(citation) + } + if (record.sourceRange !== undefined) { + for (const eventId of record.sourceRange.eventIds) + assertEntityId('event', eventId, 'analysis.sourceRange.eventId') + for (const messageId of record.sourceRange.messageIds) + assertEntityId('message', messageId, 'analysis.sourceRange.messageId') + for (const partId of record.sourceRange.messagePartIds) + assertEntityId('messagePart', partId, 'analysis.sourceRange.messagePartId') + } + if (record.provenance !== undefined) { + assertEntityId('operation', record.provenance.operationId, 'analysis.provenance.operationId') + assertDigest(record.provenance.requestDigest, 'analysis.provenance.requestDigest') + if (record.provenance.profileId !== undefined) + assertEntityId('profile', record.provenance.profileId, 'analysis.provenance.profileId') + if (record.provenance.profileDigest !== undefined) + assertDigest(record.provenance.profileDigest, 'analysis.provenance.profileDigest') + for (const check of record.provenance.checks) nonEmpty(check.id, 'analysis.check.id') + } + for (const check of record.checks ?? []) nonEmpty(check.id, 'analysis.check.id') + if (record.usage !== undefined) { + finiteNonNegative(record.usage.input, 'analysis.usage.input') + finiteNonNegative(record.usage.output, 'analysis.usage.output') + } + if (record.costUsd !== undefined) finiteNonNegative(record.costUsd, 'analysis.costUsd') + if (record.wallTimeMs !== undefined) finiteNonNegative(record.wallTimeMs, 'analysis.wallTimeMs') + assertDate(record.createdAt, 'analysis.createdAt') + assertDate(record.updatedAt, 'analysis.updatedAt') +} + +export function assertAnalysisAttachment(record: AnalysisAttachmentRecord): void { + assertEntityId('attachment', record.id, 'analysisAttachment.id') + assertEntityId('operation', record.operationId, 'analysisAttachment.operationId') + assertEntityId('analysis', record.analysisId, 'analysisAttachment.analysisId') + if (record.analysisRunId !== undefined) + assertEntityId('analysisRun', record.analysisRunId, 'analysisAttachment.analysisRunId') + assertEntityId( + 'conversation', + record.sourceConversationId, + 'analysisAttachment.sourceConversationId', + ) + assertEntityId('branch', record.sourceBranchId, 'analysisAttachment.sourceBranchId') + if (record.sourceRunId !== undefined) + assertEntityId('run', record.sourceRunId, 'analysisAttachment.sourceRunId') + assertDigest(record.sourceDigest, 'analysisAttachment.sourceDigest') + assertEntityId( + 'conversation', + record.destinationConversationId, + 'analysisAttachment.destinationConversationId', + ) + assertEntityId('branch', record.destinationBranchId, 'analysisAttachment.destinationBranchId') + assertEntityId( + 'analysis', + record.provenance.analysisId, + 'analysisAttachment.provenance.analysisId', + ) + assertDigest(record.provenance.sourceDigest, 'analysisAttachment.provenance.sourceDigest') + if (record.provenance.analystProfileDigest !== undefined) + assertDigest( + record.provenance.analystProfileDigest, + 'analysisAttachment.provenance.analystProfileDigest', + ) + for (const finding of record.selectedFindings) { + nonEmpty(finding.id, 'analysisAttachment.finding.id') + nonEmpty(finding.text, 'analysisAttachment.finding.text') + for (const citation of finding.citations) assertAnalysisCitation(citation) + } + assertDate(record.createdAt, 'analysisAttachment.createdAt') +} + +function assertAnalysisCitation( + citation: AnalysisRecord['findings'][number]['citations'][number], +): void { + assertEntityId('citation', citation.id, 'analysis.citation.id') + if (citation.eventId !== undefined) + assertEntityId('event', citation.eventId, 'analysis.citation.eventId') + if (citation.messageId !== undefined) + assertEntityId('message', citation.messageId, 'analysis.citation.messageId') + if (citation.partId !== undefined) + assertEntityId('messagePart', citation.partId, 'analysis.citation.partId') + if (citation.start !== undefined) finiteNonNegative(citation.start, 'analysis.citation.start') + if (citation.end !== undefined) finiteNonNegative(citation.end, 'analysis.citation.end') + if (citation.start !== undefined && citation.end !== undefined && citation.end < citation.start) { + 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 new file mode 100644 index 0000000..bd51bc5 --- /dev/null +++ b/src/domain/invariants-runtime.ts @@ -0,0 +1,251 @@ +import type { + AutomationRuleRecord, + BindingRecord, + CheckpointRecord, + DraftRecord, + EffectRecord, + EnvironmentRecord, + GraphEdgeRecord, + GraphNodeRecord, + OperationRecord, + QueueEntryRecord, + QueueRecord, + SupervisorRecord, + WorkerRecord, +} from './entities.js' +import { isReplayCursor } from './ids.js' +import { + assertDate, + assertDigest, + assertEntityId, + assertJsonValue, + assertUniqueIds, + fail, + finiteNonNegative, + finitePositive, + nonEmpty, + OPERATION_KINDS, + OPERATION_STATUSES, +} from './invariants-base.js' + +export function assertEnvironmentRecord(record: EnvironmentRecord): void { + assertEntityId('environment', record.id, 'environment.id') + assertEntityId('workspace', record.workspaceId, 'environment.workspaceId') + assertEntityId('connection', record.connectionId, 'environment.connectionId') + nonEmpty(record.placement.provider, 'environment.placement.provider') + record.secretNames.forEach((name) => { + nonEmpty(name, 'environment.secretNames') + }) + assertDate(record.createdAt, 'environment.createdAt') + assertDate(record.updatedAt, 'environment.updatedAt') +} + +export function assertCheckpointRecord(record: CheckpointRecord): void { + assertEntityId('checkpoint', record.id, 'checkpoint.id') + assertEntityId('environment', record.sourceEnvironmentId, 'checkpoint.sourceEnvironmentId') + assertEntityId('branch', record.sourceBranchId, 'checkpoint.sourceBranchId') + if (record.sourceRunId !== undefined) + assertEntityId('run', record.sourceRunId, 'checkpoint.sourceRunId') + if (record.throughMessageId !== undefined) + assertEntityId('message', record.throughMessageId, 'checkpoint.throughMessageId') + assertDigest(record.requestDigest, 'checkpoint.requestDigest') + assertEntityId('operation', record.operationId, 'checkpoint.operationId') + if (record.stateDigest !== undefined) assertDigest(record.stateDigest, 'checkpoint.stateDigest') + assertDate(record.createdAt, 'checkpoint.createdAt') +} + +export function assertSupervisorRecord(record: SupervisorRecord): void { + assertEntityId('supervisor', record.id, 'supervisor.id') + assertEntityId('run', record.rootRunId, 'supervisor.rootRunId') + assertDate(record.createdAt, 'supervisor.createdAt') + assertDate(record.updatedAt, 'supervisor.updatedAt') +} + +export function assertWorkerRecord(record: WorkerRecord): void { + assertEntityId('worker', record.id, 'worker.id') + assertEntityId('supervisor', record.supervisorId, 'worker.supervisorId') + if (record.parentWorkerId !== undefined) + assertEntityId('worker', record.parentWorkerId, 'worker.parentWorkerId') + if (record.runId !== undefined) assertEntityId('run', record.runId, 'worker.runId') + for (const [name, value] of [ + ['spendUsd', record.spendUsd], + ['inputTokens', record.inputTokens], + ['outputTokens', record.outputTokens], + ['latencyMs', record.latencyMs], + ] as const) { + if (value !== undefined) finiteNonNegative(value, `worker.${name}`) + } + assertDate(record.createdAt, 'worker.createdAt') + assertDate(record.updatedAt, 'worker.updatedAt') +} + +export function assertDraftRecord(record: DraftRecord): void { + assertEntityId('draft', record.id, 'draft.id') + assertEntityId('branch', record.branchId, 'draft.branchId') + assertDate(record.updatedAt, 'draft.updatedAt') +} + +export function assertQueueEntryRecord(record: QueueEntryRecord): void { + assertEntityId('queueEntry', record.id, 'queueEntry.id') + assertEntityId('queue', record.queueId, 'queueEntry.queueId') + assertEntityId('branch', record.branchId, 'queueEntry.branchId') + assertEntityId('operation', record.operationId, 'queueEntry.operationId') + finiteNonNegative(record.position, 'queueEntry.position') + assertDate(record.createdAt, 'queueEntry.createdAt') + assertDate(record.updatedAt, 'queueEntry.updatedAt') +} + +export function assertQueueRecord( + record: QueueRecord, + entries?: readonly QueueEntryRecord[], +): void { + assertEntityId('queue', record.id, 'queue.id') + assertEntityId('branch', record.branchId, 'queue.branchId') + record.entryIds.forEach((id) => { + assertEntityId('queueEntry', id, 'queue.entryIds') + }) + assertUniqueIds(record.entryIds, 'queue.entryIds') + if (entries !== undefined) { + const owned = entries.filter((entry) => entry.queueId === record.id) + assertUniqueIds( + owned.map((entry) => entry.id), + 'queue entries', + ) + } + assertDate(record.createdAt, 'queue.createdAt') + assertDate(record.updatedAt, 'queue.updatedAt') +} + +export function assertAutomationRuleRecord(record: AutomationRuleRecord): void { + assertEntityId('rule', record.id, 'rule.id') + if (record.matcher.profileDigest !== undefined) + assertDigest(record.matcher.profileDigest, 'rule.matcher.profileDigest') + if (record.matcher.connectionId !== undefined) + assertEntityId('connection', record.matcher.connectionId, 'rule.matcher.connectionId') + 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, + ) + ) { + fail(`rule.answer.${name} is secret-designated and cannot be retained`) + } + if ( + typeof value !== 'string' && + typeof value !== 'number' && + typeof value !== 'boolean' && + !(Array.isArray(value) && value.every((entry) => typeof entry === 'string')) + ) { + fail('rule.answer contains a secret or unsupported value') + } + } + finiteNonNegative(record.uses, 'rule.uses') + if (record.maximumUses !== undefined) finiteNonNegative(record.maximumUses, 'rule.maximumUses') + if (record.expiresAt !== undefined) assertDate(record.expiresAt, 'rule.expiresAt') + assertDate(record.createdAt, 'rule.createdAt') +} + +export function assertBindingRecord(record: BindingRecord): void { + assertEntityId('binding', record.id, 'binding.id') + assertEntityId('branch', record.branchId, 'binding.branchId') + assertEntityId('connection', record.connectionId, 'binding.connectionId') + if (record.runId !== undefined) assertEntityId('run', record.runId, 'binding.runId') + if (record.providerSessionId !== undefined) + assertEntityId('providerSession', record.providerSessionId, 'binding.providerSessionId') + if (record.environmentId !== undefined) + assertEntityId('environment', record.environmentId, 'binding.environmentId') + if (record.checkpointId !== undefined) + assertEntityId('checkpoint', record.checkpointId, 'binding.checkpointId') + if (record.replayCursor !== undefined && !isReplayCursor(record.replayCursor)) + fail('binding.replayCursor is invalid') + if (record.boundaryDigest !== undefined) + assertDigest(record.boundaryDigest, 'binding.boundaryDigest') + if ( + record.runId === undefined && + record.environmentId === undefined && + record.providerSessionId === undefined + ) { + fail('binding must identify a run, provider session, or environment') + } + assertDate(record.createdAt, 'binding.createdAt') + assertDate(record.updatedAt, 'binding.updatedAt') +} + +export function assertGraphNodeRecord(record: GraphNodeRecord): void { + assertEntityId('graphNode', record.id, 'graphNode.id') + assertEntityId( + record.reference.kind === 'workspace' + ? 'workspace' + : record.reference.kind === 'profile' + ? 'profile' + : record.reference.kind === 'conversation' + ? 'conversation' + : record.reference.kind === 'branch' + ? 'branch' + : record.reference.kind === 'turn' + ? 'turn' + : record.reference.kind === 'run' + ? 'run' + : record.reference.kind === 'message' + ? 'message' + : record.reference.kind === 'analysis' + ? 'analysis' + : record.reference.kind === 'environment' + ? 'environment' + : record.reference.kind === 'checkpoint' + ? 'checkpoint' + : record.reference.kind === 'supervisor' + ? 'supervisor' + : 'worker', + record.reference.id, + 'graphNode.reference.id', + ) + assertDate(record.createdAt, 'graphNode.createdAt') + assertDate(record.updatedAt, 'graphNode.updatedAt') +} + +export function assertGraphEdgeRecord(record: GraphEdgeRecord): void { + assertEntityId('graphEdge', record.id, 'graphEdge.id') + assertEntityId('graphNode', record.source, 'graphEdge.source') + assertEntityId('graphNode', record.destination, 'graphEdge.destination') + if (record.provenance.operationId !== undefined) + assertEntityId('operation', record.provenance.operationId, 'graphEdge.provenance.operationId') + if (record.provenance.receiptId !== undefined) + assertEntityId('receipt', record.provenance.receiptId, 'graphEdge.provenance.receiptId') + if (record.provenance.sourceDigest !== undefined) + assertDigest(record.provenance.sourceDigest, 'graphEdge.provenance.sourceDigest') + assertDate(record.createdAt, 'graphEdge.createdAt') +} + +export function assertOperationRecord(record: OperationRecord): void { + assertEntityId('operation', record.id, 'operation.id') + if (!OPERATION_KINDS.has(record.kind)) fail('operation.kind is not recognized') + if (!OPERATION_STATUSES.has(record.status)) fail('operation.status is not recognized') + assertDigest(record.requestDigest, 'operation.requestDigest') + if (record.target !== undefined) { + const kind = record.target.kind + assertEntityId(kind, record.target.id, 'operation.target.id') + } + if (record.result !== undefined) assertJsonValue(record.result, 'operation.result') + assertDate(record.createdAt, 'operation.createdAt') + assertDate(record.updatedAt, 'operation.updatedAt') + if (record.acknowledgedAt !== undefined) + assertDate(record.acknowledgedAt, 'operation.acknowledgedAt') +} + +export function assertEffectRecord(record: EffectRecord): void { + assertEntityId('effect', record.id, 'effect.id') + assertEntityId('operation', record.operationId, 'effect.operationId') + nonEmpty(record.effectKind, 'effect.effectKind') + assertDigest(record.requestDigest, 'effect.requestDigest') + if (!OPERATION_KINDS.has(record.kind)) fail('effect.kind is not recognized') + if (!OPERATION_STATUSES.has(record.status)) fail('effect.status is not recognized') + finitePositive(record.attempt, 'effect.attempt') + if (record.externalReceiptId !== undefined) + assertEntityId('receipt', record.externalReceiptId, 'effect.externalReceiptId') + if (record.outcomeDigest !== undefined) assertDigest(record.outcomeDigest, 'effect.outcomeDigest') + assertDate(record.createdAt, 'effect.createdAt') + assertDate(record.updatedAt, 'effect.updatedAt') +} diff --git a/src/domain/invariants-state.ts b/src/domain/invariants-state.ts new file mode 100644 index 0000000..7722855 --- /dev/null +++ b/src/domain/invariants-state.ts @@ -0,0 +1,256 @@ +import { canonicalAgentProfileDigest, snapshotAgentProfile } from '@tangle-network/agent-interface' +import { isId } from './ids.js' +import { + assertAppliedEventRecord, + assertEntityId, + assertFeedbackDecisionRecord, + assertMissingHistory, + assertReplayCursorRecord, + assertUniqueIds, + assertUnknownEventRecord, + DomainInvariantError, + fail, +} from './invariants-base.js' +import { + assertBranchRecord, + assertConversationRecord, + assertMessagePartRecord, + assertMessageRecord, + assertTurnRecord, +} from './invariants-conversation.js' +import { + assertCanonicalProfile, + assertConnectionRecord, + assertCredentialReference, + assertProfileRecord, + assertProfileSnapshotRecord, + assertWorkspaceRecord, +} from './invariants-profile.js' +import { assertStateRelations } from './invariants-relations.js' +import { + assertAnalysisAttachment, + assertAnalysisRecord, + assertInteractionRecord, + assertRunRecord, +} from './invariants-run.js' +import { + assertAutomationRuleRecord, + assertBindingRecord, + assertCheckpointRecord, + assertDraftRecord, + assertEffectRecord, + assertEnvironmentRecord, + assertGraphEdgeRecord, + assertGraphNodeRecord, + assertOperationRecord, + assertQueueEntryRecord, + assertQueueRecord, + assertSupervisorRecord, + assertWorkerRecord, +} from './invariants-runtime.js' +import type { BraidState } from './state.js' + +export function assertBraidState(state: BraidState): void { + if (state.schemaVersion < 2) fail('state.schemaVersion is unsupported') + if (!Number.isInteger(state.revision) || state.revision < 0) fail('state.revision is invalid') + if (!Number.isInteger(state.sequence) || state.sequence < 0) fail('state.sequence is invalid') + if (state.workspaceId !== null) + assertEntityId('workspace', state.workspaceId, 'state.workspaceId') + if (state.conversationId !== null) + assertEntityId('conversation', state.conversationId, 'state.conversationId') + if (state.branchId !== null) assertEntityId('branch', state.branchId, 'state.branchId') + if (state.activeRunId !== null) assertEntityId('run', state.activeRunId, 'state.activeRunId') + try { + const snapshot = snapshotAgentProfile(state.profile) + assertCanonicalProfile(state.profile, canonicalAgentProfileDigest(snapshot), 'state.profile') + } catch (error) { + if (error instanceof DomainInvariantError) throw error + fail('state.profile is not a canonical AgentProfile') + } + assertUniqueIds( + state.workspaces.map((record) => record.id), + 'state.workspaces', + ) + assertUniqueIds( + state.profiles.map((record) => record.id), + 'state.profiles', + ) + assertUniqueIds( + state.profileSnapshots.map((record) => record.id), + 'state.profileSnapshots', + ) + assertUniqueIds( + state.credentials.map((record) => record.id), + 'state.credentials', + ) + assertUniqueIds( + state.connections.map((record) => record.id), + 'state.connections', + ) + assertUniqueIds( + state.conversations.map((record) => record.id), + 'state.conversations', + ) + assertUniqueIds( + state.branches.map((record) => record.id), + 'state.branches', + ) + assertUniqueIds( + state.turns.map((record) => record.id), + 'state.turns', + ) + assertUniqueIds( + state.messages.map((record) => record.id), + 'state.messages', + ) + assertUniqueIds( + state.messageParts.map((record) => record.id), + 'state.messageParts', + ) + assertUniqueIds( + 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', + ) + assertUniqueIds( + state.analysisAttachments.map((record) => record.id), + 'state.analysisAttachments', + ) + assertUniqueIds( + state.environments.map((record) => record.id), + 'state.environments', + ) + assertUniqueIds( + state.checkpoints.map((record) => record.id), + 'state.checkpoints', + ) + assertUniqueIds( + state.supervisors.map((record) => record.id), + 'state.supervisors', + ) + assertUniqueIds( + state.workers.map((record) => record.id), + 'state.workers', + ) + assertUniqueIds( + state.drafts.map((record) => record.id), + 'state.drafts', + ) + assertUniqueIds( + state.queues.map((record) => record.id), + 'state.queues', + ) + assertUniqueIds( + state.queueEntries.map((record) => record.id), + 'state.queueEntries', + ) + assertUniqueIds( + state.rules.map((record) => record.id), + 'state.rules', + ) + assertUniqueIds( + state.bindings.map((record) => record.id), + 'state.bindings', + ) + assertUniqueIds( + state.graphNodes.map((record) => record.id), + 'state.graphNodes', + ) + assertUniqueIds( + state.graphEdges.map((record) => record.id), + 'state.graphEdges', + ) + assertUniqueIds( + state.operations.map((record) => record.id), + 'state.operations', + ) + assertUniqueIds( + state.effects.map((record) => record.id), + 'state.effects', + ) + assertUniqueIds( + state.feedbackDecisions.map((record) => record.id), + 'state.feedbackDecisions', + ) + assertUniqueIds( + state.appliedEvents.map((record) => record.id), + 'state.appliedEvents', + ) + assertUniqueIds( + state.unknownEvents.map((record) => record.id), + 'state.unknownEvents', + ) + if (state.activeRunId !== null && !state.runs.some((run) => run.id === state.activeRunId)) { + fail('state.activeRunId must reference a known run') + } + for (const record of state.workspaces) assertWorkspaceRecord(record) + for (const record of state.profiles) assertProfileRecord(record) + for (const record of state.profileSnapshots) assertProfileSnapshotRecord(record) + for (const record of state.credentials) assertCredentialReference(record) + for (const record of state.connections) assertConnectionRecord(record) + for (const record of state.conversations) assertConversationRecord(record) + for (const record of state.branches) assertBranchRecord(record) + for (const record of state.turns) assertTurnRecord(record) + 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) + for (const record of state.checkpoints) assertCheckpointRecord(record) + for (const record of state.supervisors) assertSupervisorRecord(record) + for (const record of state.workers) assertWorkerRecord(record) + for (const record of state.drafts) assertDraftRecord(record) + for (const record of state.queues) assertQueueRecord(record, state.queueEntries) + for (const record of state.queueEntries) assertQueueEntryRecord(record) + for (const record of state.rules) assertAutomationRuleRecord(record) + for (const record of state.bindings) assertBindingRecord(record) + for (const record of state.graphNodes) assertGraphNodeRecord(record) + for (const record of state.graphEdges) assertGraphEdgeRecord(record) + const graphNodeIds = new Set(state.graphNodes.map((node) => node.id)) + const graphAdjacency = new Map() + for (const edge of state.graphEdges) { + if (!graphNodeIds.has(edge.source) || !graphNodeIds.has(edge.destination)) { + fail(`graph edge ${edge.id} references a missing node`) + } + if (edge.source === edge.destination) fail(`graph edge ${edge.id} cannot point to itself`) + const destinations = graphAdjacency.get(edge.source) ?? [] + destinations.push(edge.destination) + graphAdjacency.set(edge.source, destinations) + } + const visiting = new Set() + const visited = new Set() + const visit = (nodeId: string): void => { + if (visiting.has(nodeId)) fail(`graph contains a cycle at ${nodeId}`) + if (visited.has(nodeId)) return + visiting.add(nodeId) + for (const destination of graphAdjacency.get(nodeId) ?? []) visit(destination) + visiting.delete(nodeId) + visited.add(nodeId) + } + for (const nodeId of graphNodeIds) visit(nodeId) + for (const record of state.operations) assertOperationRecord(record) + for (const record of state.effects) assertEffectRecord(record) + assertUniqueIds( + state.replayCursors.map((record) => record.runId), + 'state.replayCursors', + ) + for (const cursor of state.replayCursors) assertReplayCursorRecord(cursor) + for (const event of state.appliedEvents) assertAppliedEventRecord(event) + for (const event of state.unknownEvents) assertUnknownEventRecord(event) + for (const range of state.missingHistory) assertMissingHistory(range) + for (const decision of state.feedbackDecisions) assertFeedbackDecisionRecord(decision) + assertStateRelations(state) +} + +export function assertIdKind(kind: Parameters[0], value: unknown): void { + if (!isId(kind, value)) fail(`value is not a valid ${kind} identifier`) +} diff --git a/src/domain/invariants.ts b/src/domain/invariants.ts new file mode 100644 index 0000000..0017cf9 --- /dev/null +++ b/src/domain/invariants.ts @@ -0,0 +1,6 @@ +export * from './invariants-base.js' +export * from './invariants-profile.js' +export * from './invariants-conversation.js' +export * from './invariants-run.js' +export * from './invariants-runtime.js' +export * from './invariants-state.js' diff --git a/src/domain/materialized-state-snapshot.ts b/src/domain/materialized-state-snapshot.ts new file mode 100644 index 0000000..d1262cb --- /dev/null +++ b/src/domain/materialized-state-snapshot.ts @@ -0,0 +1,232 @@ +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 { assertBraidState } from './invariants.js' +import { canonicalProjectionChecksum } from './projection-checksum.js' +import { withHealth } from './reducer-helpers.js' +import { type BraidState, initialState } from './state.js' + +export const MATERIALIZED_SNAPSHOT_SCHEMA_VERSION = 1 as const + +/** + * The durable projection needed to resume the application. + * + * This is deliberately an explicit list rather than BraidState. Replay-only + * 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 interface MaterializedStateSnapshot { + readonly kind: 'braid.materialized-state' + readonly schemaVersion: typeof MATERIALIZED_SNAPSHOT_SCHEMA_VERSION + readonly scopeId: string + readonly generation: number + readonly eventId: EventId + readonly sequence: number + readonly revision: number + readonly state: MaterializedState + readonly stateChecksum: string +} + +function materializedState(state: BraidState): MaterializedState { + return { + schemaVersion: state.schemaVersion, + workspace: state.workspace, + workspaceId: state.workspaceId, + conversationId: state.conversationId, + branchId: state.branchId, + selectedProfileId: state.selectedProfileId, + selectedConnectionId: state.selectedConnectionId, + profile: state.profile, + draft: state.draft, + messages: state.messages, + messageParts: state.messageParts, + runs: state.runs, + activeRunId: state.activeRunId, + queuedInputs: state.queuedInputs, + lastError: state.lastError, + workspaces: state.workspaces, + profiles: state.profiles, + profileSnapshots: state.profileSnapshots, + credentials: state.credentials, + connections: state.connections, + conversations: state.conversations, + branches: state.branches, + turns: state.turns, + interactions: state.interactions, + analyses: state.analyses, + analysisAttachments: state.analysisAttachments, + environments: state.environments, + checkpoints: state.checkpoints, + supervisors: state.supervisors, + workers: state.workers, + drafts: state.drafts, + queues: state.queues, + queueEntries: state.queueEntries, + rules: state.rules, + bindings: state.bindings, + graphNodes: state.graphNodes, + graphEdges: state.graphEdges, + operations: state.operations, + effects: state.effects, + feedbackDecisions: state.feedbackDecisions, + replayCursors: state.replayCursors, + missingHistory: state.missingHistory, + } +} + +export function createMaterializedStateSnapshot(input: { + readonly scopeId: string + readonly generation: number + readonly eventId: EventId + readonly state: BraidState +}): MaterializedStateSnapshot { + const state = materializedState(input.state) + return { + kind: 'braid.materialized-state', + schemaVersion: MATERIALIZED_SNAPSHOT_SCHEMA_VERSION, + scopeId: input.scopeId, + generation: input.generation, + eventId: input.eventId, + sequence: input.state.sequence, + revision: input.state.revision, + state, + stateChecksum: canonicalDigest(state), + } +} + +export function isMaterializedStateSnapshot(value: unknown): value is MaterializedStateSnapshot { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return false + const candidate = value as Partial + const generation = candidate.generation + const sequence = candidate.sequence + const revision = candidate.revision + if ( + candidate.kind !== 'braid.materialized-state' || + candidate.schemaVersion !== MATERIALIZED_SNAPSHOT_SCHEMA_VERSION || + typeof candidate.scopeId !== 'string' || + typeof generation !== 'number' || + !Number.isSafeInteger(generation) || + generation <= 0 || + typeof candidate.eventId !== 'string' || + typeof sequence !== 'number' || + !Number.isSafeInteger(sequence) || + sequence <= 0 || + typeof revision !== 'number' || + !Number.isSafeInteger(revision) || + revision <= 0 || + candidate.state === null || + typeof candidate.state !== 'object' || + Array.isArray(candidate.state) || + typeof candidate.stateChecksum !== 'string' + ) + return false + if (candidate.state.schemaVersion !== 2) return false + return canonicalDigest(candidate.state) === candidate.stateChecksum +} + +export function restoreMaterializedState(snapshot: MaterializedStateSnapshot): BraidState { + if (!isMaterializedStateSnapshot(snapshot)) { + throw new Error('Materialized state snapshot failed validation') + } + const base = initialState(snapshot.state.profile, { + conversationId: snapshot.state.conversationId, + branchId: snapshot.state.branchId, + }) + const restored: BraidState = { + ...base, + ...snapshot.state, + revision: snapshot.revision, + sequence: snapshot.sequence, + appliedEvents: [], + unknownEvents: [], + projectionChecksum: null, + health: { + status: 'healthy', + lastError: snapshot.state.lastError, + missingHistoryCount: snapshot.state.missingHistory.length, + unknownEventCount: 0, + }, + } + const healthy = withHealth(restored) + const finalized: BraidState = { + ...healthy, + projectionChecksum: canonicalProjectionChecksum(healthy), + } + assertBraidState(finalized) + return finalized +} diff --git a/src/domain/projection-checksum.ts b/src/domain/projection-checksum.ts new file mode 100644 index 0000000..c8daf3a --- /dev/null +++ b/src/domain/projection-checksum.ts @@ -0,0 +1,20 @@ +import { canonicalDigest } from './canonical.js' +import type { Digest } from './ids.js' +import type { BraidState } from './state.js' + +/** + * Checks only product projections. Journal position and diagnostic counters + * are deliberately excluded so incremental reduction and replay compare the + * same durable graph even when they reached it through different batches. + */ +export function canonicalProjectionChecksum(state: BraidState): Digest { + const { + revision: _revision, + sequence: _sequence, + appliedEvents: _appliedEvents, + projectionChecksum: _projectionChecksum, + health: _health, + ...projection + } = state + return canonicalDigest(projection) +} diff --git a/src/domain/provider-values.ts b/src/domain/provider-values.ts new file mode 100644 index 0000000..c440098 --- /dev/null +++ b/src/domain/provider-values.ts @@ -0,0 +1,49 @@ +import { redactSensitiveText } from './secret-sanitizer.js' +import { containsUnsafeControlCharacter } from './text.js' + +const SENSITIVE_DIAGNOSTIC = + /(?:secret|password|passphrase|token|bearer|authorization|credential|private(?:[_-]?key)?|api[-_]?key|session(?:[_-]?key)?|access[_-]?key|client[_-]?secret|signature|signed[_-]?url|nonce)/iu +const SAFE_DIAGNOSTIC = /^[A-Z][A-Z0-9._:-]{0,63}$/u +const TYPED_PROVIDER_DIAGNOSTIC = /^[A-Z][A-Z0-9]*(?:[._][A-Z0-9]+)*$/u +const SAFE_PUBLIC_IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/u + +export function finiteNonNegativeNumber(value: unknown): number +export function finiteNonNegativeNumber(value: unknown, fallback: undefined): number | undefined +export function finiteNonNegativeNumber(value: unknown, fallback = 0): number | undefined { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : fallback +} + +export function safePublicIdentifier(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined + const text = value.trim() + return SAFE_PUBLIC_IDENTIFIER.test(text) && !SENSITIVE_DIAGNOSTIC.test(text) ? text : undefined +} + +export function safeDiagnostic(value: unknown, fallback: string): string { + if (typeof value !== 'string') return fallback + const text = value.trim() + if ( + text.length === 0 || + !SAFE_DIAGNOSTIC.test(text) || + containsUnsafeControlCharacter(text) || + SENSITIVE_DIAGNOSTIC.test(text) + ) { + return fallback + } + return text +} + +export function safeProviderDiagnostic(value: unknown, fallback: string): string { + if (typeof value === 'string') { + const text = value.trim() + const redacted = redactSensitiveText(text) + if (redacted !== text && redacted.length > 0) return redacted + if ( + TYPED_PROVIDER_DIAGNOSTIC.test(text) && + !containsUnsafeControlCharacter(text) && + !SENSITIVE_DIAGNOSTIC.test(text) + ) + return text + } + return fallback +} diff --git a/src/domain/public-metadata.ts b/src/domain/public-metadata.ts new file mode 100644 index 0000000..424ac15 --- /dev/null +++ b/src/domain/public-metadata.ts @@ -0,0 +1,47 @@ +import { containsUnsafeControlCharacter } from './text.js' + +const MAX_ENTRIES = 64 +const MAX_KEY_BYTES = 128 +const MAX_VALUE_BYTES = 1024 +const MAX_TOTAL_BYTES = 16 * 1024 +const SECRET_NAME = + /(secret|password|passphrase|token|bearer|authorization|credential|private(?:[_-]?key)?|api[-_]?key)/iu +const SAFE_REFERENCE_SUFFIX = /(ref|name|kind)$/iu +const SECRET_TEXT = + /(?:secret|password|passphrase|token|bearer|authorization|credential|private(?:[_-]?key)?|api[-_]?key)\s*[:=]/iu + +function containsCredentialUrl(value: string): boolean { + if (/:\/\/[^/\s:@]+:[^/\s@]+@/u.test(value)) return true + try { + const url = new URL(value) + if (url.username || url.password) return true + return [...url.searchParams.keys()].some((key) => SECRET_NAME.test(key)) + } catch { + return false + } +} + +export function isSafePublicMetadata( + metadata: Readonly>, +): metadata is Readonly> { + const entries = Object.entries(metadata) + if (entries.length > MAX_ENTRIES) return false + let totalBytes = 0 + for (const [key, value] of entries) { + if ( + typeof value !== 'string' || + Buffer.byteLength(key, 'utf8') > MAX_KEY_BYTES || + Buffer.byteLength(value, 'utf8') > MAX_VALUE_BYTES || + !/^[A-Za-z][A-Za-z0-9._-]*$/u.test(key) || + (SECRET_NAME.test(key) && !SAFE_REFERENCE_SUFFIX.test(key)) || + containsUnsafeControlCharacter(value) || + SECRET_TEXT.test(value) || + containsCredentialUrl(value) + ) { + return false + } + totalBytes += Buffer.byteLength(key, 'utf8') + Buffer.byteLength(value, 'utf8') + if (totalBytes > MAX_TOTAL_BYTES) return false + } + return true +} diff --git a/src/domain/receipts.ts b/src/domain/receipts.ts new file mode 100644 index 0000000..6bd7ca2 --- /dev/null +++ b/src/domain/receipts.ts @@ -0,0 +1,222 @@ +import { type AgentProfile, snapshotAgentProfile } from '@tangle-network/agent-interface' +import { canonicalDigest } from './canonical.js' +import { redactProfile, redactSensitiveText, redactStructuredValue } from './redaction.js' +import type { + ContextTransferReceipt, + NativeContextBoundaryProof, + PortableAnalysisAttachment, + PortableContextMessage, + PortableContextPlan, + RunAdmissionReceipt, + RunCapabilities, +} from './run-contracts.js' + +export type { + ContextTransferReceipt, + NativeContextBoundaryProof, + PortableAnalysisAttachment, + PortableContextMessage, + PortableContextPart, + PortableContextPlan, + RunAdmissionReceipt, + RunCapabilities, + RunControlCapabilities, + RunEventCapabilities, +} from './run-contracts.js' + +const SAFE_IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/u +const SENSITIVE_VALUE = + /(?:password|passphrase|token|secret|credential|authorization|bearer|api[_-]?key|private[_-]?key|cookie|header|query|fragment)s*[:=]/iu + +function safeProviderIdentifier(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined + const candidate = value.trim() + return SAFE_IDENTIFIER.test(candidate) && !SENSITIVE_VALUE.test(candidate) ? candidate : undefined +} + +function safeProviderWarning(value: unknown): string { + if (typeof value !== 'string') return 'PROVIDER_WARNING' + const candidate = redactSensitiveText(value).trim() + return /^[A-Z][A-Z0-9]*(?:[._][A-Z0-9]+)*$/u.test(candidate) && !SENSITIVE_VALUE.test(candidate) + ? candidate + : 'PROVIDER_WARNING' +} + +function freezeDeep(value: T): T { + if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) { + for (const child of Object.values(value)) freezeDeep(child) + Object.freeze(value) + } + return value +} + +function utf8Prefix(value: string, bytes: number): string { + if (Buffer.byteLength(value, 'utf8') <= bytes) return value + let output = '' + let used = 0 + for (const character of value) { + const size = Buffer.byteLength(character, 'utf8') + if (used + size > bytes) break + output += character + used += size + } + return output +} + +function boundedWarnings(warnings: readonly string[] | undefined): readonly string[] | undefined { + if (warnings === undefined) return undefined + const output: string[] = [] + let bytes = 0 + for (const warning of warnings.slice(0, 64)) { + const bounded = utf8Prefix(safeProviderWarning(warning), 4096) + const size = Buffer.byteLength(bounded, 'utf8') + if (bytes + size > 64 * 1024) break + output.push(bounded) + bytes += size + } + return output +} + +export function createPortableContextPlan(input: { + readonly sourceRunId: string + readonly sourceBoundary: string + readonly destinationRunner?: string + readonly messages: readonly PortableContextMessage[] + readonly analysisAttachments?: readonly PortableAnalysisAttachment[] + readonly omittedPartIds?: readonly string[] + readonly transformedPartIds?: readonly string[] + readonly complete?: boolean + readonly tokenEstimate?: number +}): PortableContextPlan { + const plan = { + sourceRunId: input.sourceRunId, + sourceBoundary: input.sourceBoundary, + ...(input.destinationRunner === undefined + ? {} + : { destinationRunner: input.destinationRunner }), + messages: input.messages, + ...(input.analysisAttachments === undefined + ? {} + : { analysisAttachments: input.analysisAttachments }), + omittedPartIds: input.omittedPartIds ?? [], + transformedPartIds: input.transformedPartIds ?? [], + complete: input.complete ?? true, + ...(input.tokenEstimate === undefined ? {} : { tokenEstimate: input.tokenEstimate }), + } + return freezeDeep({ ...structuredClone(plan), digest: canonicalDigest(plan) }) +} + +export function createAdmissionReceipt(input: { + readonly runId: string + readonly turnId: string + readonly operationId: string + readonly conversationId: string + readonly branchId: string + readonly admittedAt: string + readonly profile: Readonly + readonly connectionId?: string + readonly text: string + readonly sessionId?: string + readonly capabilities: RunCapabilities + readonly provider?: string + readonly environmentId?: string + readonly providerSessionId?: string + readonly materializationReceipt?: Readonly> + readonly contextPlan?: PortableContextPlan + readonly contextPlanDigest?: string + readonly contextTransfer?: ContextTransferReceipt + readonly nativeContextBoundaryProof?: NativeContextBoundaryProof + readonly warnings?: readonly string[] + readonly admissionStatus?: 'admitted' | 'pending' | 'unavailable' +}): RunAdmissionReceipt { + const profile = redactProfile(snapshotAgentProfile(input.profile)) + const text = redactSensitiveText(input.text) + const capabilities = redactStructuredValue(input.capabilities, undefined, { + maxDepth: 6, + maxItems: 128, + maxBytes: 32 * 1024, + }) as RunCapabilities + const materializationReceipt = + input.materializationReceipt === undefined + ? undefined + : (redactStructuredValue(input.materializationReceipt, undefined, { + maxDepth: 6, + maxItems: 128, + maxBytes: 32 * 1024, + }) as Readonly>) + const warnings = boundedWarnings(input.warnings) + const contextPlanDigest = input.contextPlanDigest ?? input.contextTransfer?.planDigest + const requested = { + text, + profile: structuredClone(profile), + ...(input.connectionId === undefined ? {} : { connectionId: input.connectionId }), + ...(profile.model?.default === undefined ? {} : { model: profile.model.default }), + ...(profile.harness === undefined ? {} : { runner: profile.harness }), + ...(contextPlanDigest === undefined ? {} : { contextPlanDigest }), + } + const requestDigest = canonicalDigest({ + runId: input.runId, + turnId: input.turnId, + operationId: input.operationId, + conversationId: input.conversationId, + branchId: input.branchId, + text, + profileDigest: canonicalDigest(profile), + connectionId: input.connectionId ?? null, + contextPlanDigest: contextPlanDigest ?? null, + }) + const capabilitiesDigest = canonicalDigest(capabilities) + const materializationDigest = + materializationReceipt === undefined ? undefined : canonicalDigest(materializationReceipt) + const provider = safeProviderIdentifier(input.provider) + const environmentId = safeProviderIdentifier(input.environmentId) + const providerSessionId = safeProviderIdentifier(input.providerSessionId) + const base = { + version: 1 as const, + runId: input.runId, + turnId: input.turnId, + operationId: input.operationId, + conversationId: input.conversationId, + branchId: input.branchId, + admittedAt: input.admittedAt, + profileDigest: canonicalDigest(profile), + requested, + capabilities: structuredClone(capabilities), + ...(provider === undefined ? {} : { provider }), + ...(environmentId === undefined ? {} : { environmentId }), + ...(providerSessionId === undefined ? {} : { providerSessionId }), + ...(materializationReceipt === undefined + ? {} + : { + materializationReceipt, + }), + ...(input.contextTransfer === undefined + ? {} + : { + contextTransfer: redactStructuredValue(input.contextTransfer, undefined, { + maxDepth: 4, + maxItems: 32, + maxBytes: 16 * 1024, + }) as typeof input.contextTransfer, + }), + ...(input.nativeContextBoundaryProof === undefined + ? {} + : { + nativeContextBoundaryProof: redactStructuredValue( + input.nativeContextBoundaryProof, + undefined, + { + maxDepth: 4, + maxItems: 32, + maxBytes: 16 * 1024, + }, + ) as typeof input.nativeContextBoundaryProof, + }), + ...(warnings === undefined ? {} : { warnings }), + admissionStatus: input.admissionStatus ?? 'admitted', + requestDigest, + capabilitiesDigest, + ...(materializationDigest === undefined ? {} : { materializationDigest }), + } + return freezeDeep({ ...base, digest: canonicalDigest(base) }) +} diff --git a/src/domain/redaction.ts b/src/domain/redaction.ts new file mode 100644 index 0000000..88537d7 --- /dev/null +++ b/src/domain/redaction.ts @@ -0,0 +1,57 @@ +import type { AgentProfile } from '@tangle-network/agent-interface' +import { + redactStructuredValue, + redactStructuredValueWithNumericTelemetry, + STRUCTURED_REDACTION_MARKER, +} from './bounded-structured.js' +import { safeProviderDiagnostic } from './provider-values.js' + +export const MAX_PROFILE_BYTES = 16 * 1024 * 1024 +export const MAX_CONVERSATION_IMPORT_EVENT_BYTES = 4 * 1024 * 1024 + +export { + isSensitiveFieldName, + redactStructuredValue, + redactStructuredValueWithNumericTelemetry, +} from './bounded-structured.js' +export { redactSensitiveText, redactSensitiveUrls } from './secret-sanitizer.js' + +export function redactProfile(profile: Readonly): Readonly { + return removeProfileMetadata( + redactStructuredValue(profile, undefined, { maxBytes: MAX_PROFILE_BYTES }), + ) as Readonly +} + +function removeProfileMetadata(value: unknown): unknown { + if (Array.isArray(value)) return value.map(removeProfileMetadata) + if (value === null || typeof value !== 'object') return value + const output: Record = {} + for (const [key, child] of Object.entries(value)) { + if (key.toLowerCase() === 'metadata') { + output[key] = { redacted: STRUCTURED_REDACTION_MARKER } + continue + } + output[key] = removeProfileMetadata(child) + } + return output +} + +export function redactProviderError(error: unknown): string { + const message = error instanceof Error ? error.message : error + return safeProviderDiagnostic(message, 'PROVIDER_ERROR') +} + +export function redactBraidEvent(event: T): T { + const importEvent = + event !== null && + typeof event === 'object' && + !Array.isArray(event) && + (event as { readonly kind?: unknown }).kind === 'conversation.imported' + return redactStructuredValueWithNumericTelemetry( + event, + undefined, + importEvent + ? { maxDepth: 32, maxItems: 20_000, maxBytes: MAX_CONVERSATION_IMPORT_EVENT_BYTES } + : {}, + ) as T +} diff --git a/src/domain/reducer-content.ts b/src/domain/reducer-content.ts new file mode 100644 index 0000000..b10c3df --- /dev/null +++ b/src/domain/reducer-content.ts @@ -0,0 +1,331 @@ +import type { BraidEvent } from './events.js' +import { reserveText, reserveValue } from './content-budget.js' +import type { BraidMessagePart, BraidState } from './state.js' +import { + activity, + addActivity, + findRun, + sourceFromProvider, + updateMessage, + updateRun, + upsertPart, + withProviderProgress, + type ReducerBase, +} from './reducer-support.js' + +type ContentEvent = Extract< + BraidEvent, + { + kind: + | 'run.text.delta' + | 'run.part.updated' + | 'run.reasoning.delta' + | 'run.tool.call' + | 'run.tool.result' + | 'run.artifact' + | 'run.proposal' + | 'run.warning' + | 'run.error' + | 'run.usage' + | 'run.cost' + } +> + +export function reduceContentEvent( + state: BraidState, + event: ContentEvent, + base: ReducerBase, +): BraidState { + switch (event.kind) { + case 'run.text.delta': { + const run = findRun(state, event.runId) + const source = sourceFromProvider(event.provider) + const reservation = reserveText(run, event.text) + const next = { + ...state, + ...base, + messages: updateMessage(state, event.runId, (message) => + upsertPart( + message, + { + id: `${event.runId}:text`, + kind: 'text', + text: reservation.value, + ...(source === undefined ? {} : { source }), + }, + reservation.value, + ), + ), + runs: updateRun(state, event.runId, (candidate) => + withProviderProgress( + { + ...reservation.run, + status: candidate.status === 'starting' ? 'streaming' : candidate.status, + }, + event.provider, + ), + ), + } + if (run.status === 'cancelling') return next + return next + } + case 'run.part.updated': + findRun(state, event.runId) + return { + ...updatePartEvent(state, event), + ...base, + } + case 'run.reasoning.delta': { + const run = findRun(state, event.runId) + const source = sourceFromProvider(event.provider) + const reservation = reserveText(run, event.text) + return { + ...state, + ...base, + messages: updateMessage(state, event.runId, (message) => + upsertPart( + message, + { + id: event.partId, + kind: 'reasoning', + text: reservation.value, + ...(source === undefined ? {} : { source }), + }, + reservation.value, + ), + ), + runs: updateRun(state, event.runId, () => + withProviderProgress(reservation.run, event.provider), + ), + } + } + case 'run.tool.call': { + const run = findRun(state, event.runId) + const source = sourceFromProvider(event.provider) + const inputReservation = + event.input === undefined ? { run, value: undefined } : reserveValue(run, event.input) + return { + ...state, + ...base, + messages: updateMessage(state, event.runId, (message) => + upsertPart(message, { + id: event.partId, + kind: 'tool-call', + toolName: event.toolName, + ...(event.callId === undefined ? {} : { callId: event.callId }), + ...(event.input === undefined ? {} : { input: inputReservation.value }), + ...(source === undefined ? {} : { source }), + }), + ), + runs: updateRun(state, event.runId, () => + addActivity( + withProviderProgress(inputReservation.run, event.provider), + activity(event, 'tool', event.toolName, 'started', source), + ), + ), + } + } + case 'run.tool.result': { + const run = findRun(state, event.runId) + const source = sourceFromProvider(event.provider) + const resultReservation = + event.result === undefined ? { run, value: undefined } : reserveValue(run, event.result) + const errorReservation = + event.error === undefined ? undefined : reserveText(resultReservation.run, event.error) + return { + ...state, + ...base, + messages: updateMessage(state, event.runId, (message) => + upsertPart(message, { + ...message.parts.find((part) => part.id === event.partId), + id: event.partId, + kind: 'tool-result', + toolName: event.toolName, + ...(event.callId === undefined ? {} : { callId: event.callId }), + ...(event.result === undefined ? {} : { result: resultReservation.value }), + ...(errorReservation === undefined ? {} : { error: errorReservation.value }), + ...(source === undefined ? {} : { source }), + }), + ), + runs: updateRun(state, event.runId, () => + addActivity( + withProviderProgress(errorReservation?.run ?? resultReservation.run, event.provider), + activity(event, 'tool', event.toolName, errorReservation?.value ?? 'completed', source), + ), + ), + } + } + case 'run.artifact': + case 'run.proposal': + case 'run.warning': + case 'run.error': { + const source = sourceFromProvider(event.provider) + let run = findRun(state, event.runId) + let title: string | undefined + let text: string | undefined + let uri: string | undefined + let metadata: Readonly> | undefined + if (event.kind === 'run.artifact') { + if (event.name !== undefined) { + const reservation = reserveText(run, event.name) + run = reservation.run + title = reservation.value + } + if (event.uri !== undefined) { + const reservation = reserveText(run, event.uri) + run = reservation.run + uri = reservation.value + } + if (event.metadata !== undefined) { + const reservation = reserveValue(run, event.metadata) + run = reservation.run + metadata = reservation.value as Readonly> + } + } else if (event.kind === 'run.proposal') { + const reservation = reserveText(run, event.title) + run = reservation.run + title = reservation.value + } else { + const reservation = reserveText(run, event.message) + run = reservation.run + text = reservation.value + } + const part: BraidMessagePart = + event.kind === 'run.artifact' + ? { + id: event.artifactId, + kind: 'artifact', + artifactId: event.artifactId, + ...(title === undefined ? {} : { title }), + ...(event.mimeType === undefined ? {} : { mimeType: event.mimeType }), + ...(uri === undefined ? {} : { uri }), + ...(metadata === undefined ? {} : { metadata }), + ...(source === undefined ? {} : { source }), + } + : event.kind === 'run.proposal' + ? { + id: event.proposalId, + kind: 'proposal', + title: title ?? '', + ...(event.status === undefined ? {} : { status: event.status }), + ...(source === undefined ? {} : { source }), + } + : event.kind === 'run.warning' + ? { + id: `${event.runId}:warning:${event.provider.eventId}`, + kind: 'warning', + text: text ?? '', + status: event.code, + ...(source === undefined ? {} : { source }), + } + : { + id: `${event.runId}:error:${event.provider.eventId}`, + kind: 'error', + text: text ?? '', + status: event.recoverable ? 'recoverable' : 'terminal', + ...(source === undefined ? {} : { source }), + } + const label = + event.kind === 'run.artifact' + ? 'artifact' + : event.kind === 'run.proposal' + ? 'proposal' + : event.kind === 'run.warning' + ? event.code + : 'error' + return { + ...state, + ...base, + messages: updateMessage(state, event.runId, (message) => upsertPart(message, part)), + lastError: event.kind === 'run.error' ? (text ?? '') : state.lastError, + runs: updateRun(state, event.runId, () => + addActivity( + withProviderProgress(run, event.provider), + activity( + event, + event.kind, + label, + event.kind === 'run.error' ? text : undefined, + source, + ), + ), + ), + } + } + case 'run.usage': + return { + ...state, + ...base, + runs: updateRun(state, event.runId, (run) => { + const withProgress = withProviderProgress(run, event.provider) + return { + ...withProgress, + inputTokens: event.usage.input, + outputTokens: event.usage.output, + ...(event.usage.reasoning === undefined + ? {} + : { reasoningTokens: event.usage.reasoning }), + ...(event.usage.costUsd === undefined ? {} : { costUsd: event.usage.costUsd }), + ...(event.usage.model === undefined ? {} : { model: event.usage.model }), + } + }), + } + case 'run.cost': + return { + ...state, + ...base, + runs: updateRun(state, event.runId, (run) => ({ + ...withProviderProgress(run, event.provider), + costUsd: event.costUsd, + })), + } + default: { + const exhaustive: never = event + return exhaustive + } + } +} + +function updatePartEvent( + state: BraidState, + event: Extract, +): BraidState { + const source = sourceFromProvider(event.provider) + let run = findRun(state, event.runId) + let part = event.part + let delta = event.delta + if (delta !== undefined) { + const reservation = reserveText(run, delta) + run = reservation.run + delta = reservation.value + } else if (part.text !== undefined) { + const reservation = reserveText(run, part.text) + run = reservation.run + part = { ...part, text: reservation.value } + } + if (part.input !== undefined) { + const reservation = reserveValue(run, part.input) + run = reservation.run + part = { ...part, input: reservation.value } + } + if (part.result !== undefined) { + const reservation = reserveValue(run, part.result) + run = reservation.run + part = { ...part, result: reservation.value } + } + part = source === undefined ? part : { ...part, source } + return { + ...state, + messages: updateMessage(state, event.runId, (message) => upsertPart(message, part, delta)), + runs: updateRun(state, event.runId, (candidate) => + withProviderProgress( + { + ...candidate, + ...(run.contentBytes === undefined ? {} : { contentBytes: run.contentBytes }), + ...(run.contentTruncated === undefined ? {} : { contentTruncated: run.contentTruncated }), + }, + event.provider, + ), + ), + } +} diff --git a/src/domain/reducer-conversation-events.ts b/src/domain/reducer-conversation-events.ts new file mode 100644 index 0000000..fca1634 --- /dev/null +++ b/src/domain/reducer-conversation-events.ts @@ -0,0 +1,283 @@ +import type { BraidEvent } from './events.js' +import { find, upsert } from './reducer-helpers.js' +import type { BraidState } from './state.js' + +type ConversationEvent = Extract< + BraidEvent, + { + readonly kind: + | 'conversation.created' + | 'conversation.imported' + | 'conversation.updated' + | 'conversation.selected' + | 'conversation.deleted' + | 'branch.created' + | 'branch.updated' + | 'branch.selected' + } +> + +export function applyConversationEvent(state: BraidState, event: ConversationEvent): BraidState { + switch (event.kind) { + case 'conversation.created': + return { + ...state, + conversationId: event.conversation.id, + branchId: event.conversation.activeBranchId, + conversations: upsert(state.conversations, event.conversation), + ...(event.branch === undefined ? {} : { branches: upsert(state.branches, event.branch) }), + ...(event.draft === undefined ? {} : { drafts: upsert(state.drafts, event.draft) }), + ...(event.queue === undefined ? {} : { queues: upsert(state.queues, event.queue) }), + graphNodes: upsertMany(state.graphNodes, event.graphNodes), + graphEdges: upsertMany(state.graphEdges, event.graphEdges), + ...(event.operation === undefined + ? {} + : { operations: upsert(state.operations, event.operation) }), + draft: event.draft?.text ?? '', + } + case 'conversation.imported': + return { + ...state, + conversationId: event.conversation.id, + branchId: event.conversation.activeBranchId, + conversations: upsert(state.conversations, event.conversation), + branches: upsertMany(state.branches, event.branches), + drafts: upsertMany(state.drafts, event.drafts), + queues: upsertMany(state.queues, event.queues), + messages: upsertMany(state.messages, event.messages), + messageParts: upsertMany(state.messageParts, event.messageParts), + turns: upsertMany(state.turns, event.turns), + runs: upsertMany(state.runs, event.runs), + analyses: upsertMany(state.analyses, event.analyses), + graphNodes: upsertMany(state.graphNodes, event.graphNodes), + graphEdges: upsertMany(state.graphEdges, event.graphEdges), + feedbackDecisions: upsertMany(state.feedbackDecisions, event.feedbackDecisions), + operations: upsert(state.operations, event.operation), + activeRunId: null, + queuedInputs: [], + draft: + event.drafts.find((draft) => draft.branchId === event.conversation.activeBranchId) + ?.text ?? '', + } + case 'conversation.updated': + return { + ...state, + conversations: upsert(state.conversations, event.conversation), + ...(event.operation === undefined + ? {} + : { operations: upsert(state.operations, event.operation) }), + } + case 'conversation.selected': { + const previous = find(state.conversations, event.conversationId, 'Conversation') + const conversation = event.conversation ?? previous + const branchId = event.branchId ?? conversation.activeBranchId + find(state.branches, branchId, 'Branch') + return { + ...state, + conversationId: event.conversationId, + branchId, + conversations: upsert(state.conversations, conversation), + draft: state.drafts.find((draft) => draft.branchId === branchId)?.text ?? '', + ...(event.operation === undefined + ? {} + : { operations: upsert(state.operations, event.operation) }), + } + } + case 'conversation.deleted': + return applyConversationDeletion(state, event) + case 'branch.created': + return { + ...state, + branchId: event.branch.id, + conversationId: event.branch.conversationId, + branches: upsert(state.branches, event.branch), + ...(event.conversation === undefined + ? {} + : { conversations: upsert(state.conversations, event.conversation) }), + ...(event.draft === undefined ? {} : { drafts: upsert(state.drafts, event.draft) }), + ...(event.queue === undefined ? {} : { queues: upsert(state.queues, event.queue) }), + graphNodes: upsertMany(state.graphNodes, event.graphNodes), + graphEdges: upsertMany(state.graphEdges, event.graphEdges), + ...(event.operation === undefined + ? {} + : { operations: upsert(state.operations, event.operation) }), + draft: event.draft?.text ?? '', + } + case 'branch.updated': + return { ...state, branches: upsert(state.branches, event.branch) } + case 'branch.selected': + find(state.branches, event.branchId, 'Branch') + return { + ...state, + conversationId: event.conversationId, + branchId: event.branchId, + draft: state.drafts.find((draft) => draft.branchId === event.branchId)?.text ?? '', + ...(event.operation === undefined + ? {} + : { operations: upsert(state.operations, event.operation) }), + } + } +} + +function applyConversationDeletion( + state: BraidState, + event: Extract, +): BraidState { + const conversationId = event.conversation.id + const branchIds = new Set( + state.branches + .filter((branch) => branch.conversationId === conversationId) + .map((branch) => branch.id), + ) + const turnIds = new Set( + state.turns.filter((turn) => turn.conversationId === conversationId).map((turn) => turn.id), + ) + const runIds = new Set( + state.runs.filter((run) => run.conversationId === conversationId).map((run) => run.id), + ) + const messageIds = new Set( + state.messages + .filter((message) => message.conversationId === conversationId) + .map((message) => message.id), + ) + const analysisIds = new Set( + state.analyses + .filter((analysis) => analysis.source.conversationId === conversationId) + .map((analysis) => analysis.id), + ) + const analysisAttachmentIds = new Set( + state.analysisAttachments + .filter( + (attachment) => + attachment.sourceConversationId === conversationId || + attachment.destinationConversationId === conversationId, + ) + .map((attachment) => attachment.id), + ) + const queueIds = new Set( + state.queues.filter((queue) => branchIds.has(queue.branchId)).map((queue) => queue.id), + ) + const removedNodeIds = new Set( + state.graphNodes + .filter((node) => + node.reference.kind === 'conversation' + ? node.reference.id === conversationId + : node.reference.kind === 'branch' + ? branchIds.has(node.reference.id) + : node.reference.kind === 'turn' + ? turnIds.has(node.reference.id) + : node.reference.kind === 'run' + ? runIds.has(node.reference.id) + : node.reference.kind === 'message' + ? messageIds.has(node.reference.id) + : node.reference.kind === 'analysis' + ? analysisIds.has(node.reference.id) + : false, + ) + .map((node) => node.id), + ) + const removedOperationIds = new Set( + state.operations + .filter((operation) => + operation.target?.kind === 'conversation' + ? operation.target.id === conversationId + : operation.target?.kind === 'branch' + ? branchIds.has(operation.target.id) + : operation.target?.kind === 'run' + ? runIds.has(operation.target.id) + : operation.target?.kind === 'analysis' + ? analysisIds.has(operation.target.id) + : false, + ) + .map((operation) => operation.id), + ) + const graphNodes = upsertMany( + state.graphNodes.filter((node) => !removedNodeIds.has(node.id)), + event.graphNodes, + ) + const graphNodeIds = new Set(graphNodes.map((node) => node.id)) + return { + ...state, + conversationId: event.selectedConversation.id, + branchId: event.selectedConversation.activeBranchId, + conversations: upsert( + upsert( + state.conversations.filter( + (conversation) => conversation.id !== event.selectedConversation.id, + ), + event.conversation, + ), + event.selectedConversation, + ), + branches: upsertOptional( + state.branches.filter((branch) => !branchIds.has(branch.id)), + event.replacementBranch, + ), + turns: state.turns.filter((turn) => !turnIds.has(turn.id)), + 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), + ), + drafts: upsertOptional( + state.drafts.filter((draft) => !branchIds.has(draft.branchId)), + event.replacementDraft, + ), + queues: upsertOptional( + state.queues.filter((queue) => !branchIds.has(queue.branchId)), + event.replacementQueue, + ), + queueEntries: state.queueEntries.filter((entry) => !queueIds.has(entry.queueId)), + bindings: state.bindings.filter( + (binding) => + (binding.runId === undefined || !runIds.has(binding.runId)) && + (binding.branchId === undefined || !branchIds.has(binding.branchId)), + ), + graphNodes, + graphEdges: upsertMany( + state.graphEdges.filter( + (edge) => + !removedNodeIds.has(edge.source) && + !removedNodeIds.has(edge.destination) && + graphNodeIds.has(edge.source) && + graphNodeIds.has(edge.destination), + ), + event.graphEdges, + ), + operations: upsert( + state.operations.filter((operation) => !removedOperationIds.has(operation.id)), + event.operation, + ), + effects: state.effects.filter((effect) => !removedOperationIds.has(effect.operationId)), + feedbackDecisions: state.feedbackDecisions.filter( + (decision) => decision.conversationId !== conversationId, + ), + replayCursors: state.replayCursors.filter((cursor) => !runIds.has(cursor.runId)), + missingHistory: state.missingHistory.filter((range) => !runIds.has(range.runId)), + queuedInputs: state.queuedInputs.filter((entry) => !runIds.has(entry.runId)), + activeRunId: + state.activeRunId !== null && runIds.has(state.activeRunId) ? null : state.activeRunId, + draft: + event.replacementDraft?.text ?? + state.drafts.find((draft) => draft.branchId === event.selectedConversation.activeBranchId) + ?.text ?? + '', + } +} + +function upsertMany( + current: readonly T[], + incoming: readonly T[] | undefined, +): readonly T[] { + return incoming?.reduce((records, record) => upsert(records, record), current) ?? current +} + +function upsertOptional( + current: readonly T[], + incoming: T | undefined, +): readonly T[] { + return incoming === undefined ? current : upsert(current, incoming) +} diff --git a/src/domain/reducer-domain-events.ts b/src/domain/reducer-domain-events.ts new file mode 100644 index 0000000..c4752a4 --- /dev/null +++ b/src/domain/reducer-domain-events.ts @@ -0,0 +1,249 @@ +import type { BraidEvent, DomainBraidEventMap } from './events.js' +import { DomainInvariantError } from './invariants.js' +import { applyConversationEvent } from './reducer-conversation-events.js' + +import { find, updateRun, upsert, upsertBy } from './reducer-helpers.js' +import type { BraidState } from './state.js' + +export function applyDomainEvent( + state: BraidState, + event: Extract, + at: string, +): BraidState { + switch (event.kind) { + case 'workspace.recorded': + return { + ...state, + workspace: event.workspace.root, + workspaceId: event.workspace.id, + workspaces: upsert(state.workspaces, event.workspace), + } + case 'profile.registered': + return { ...state, profiles: upsert(state.profiles, event.profile) } + case 'profile.selected': { + const profile = find(state.profiles, event.profileId, 'Profile') + return { ...state, selectedProfileId: event.profileId, profile: profile.profile } + } + case 'profile.snapshot.created': + return { ...state, profileSnapshots: upsert(state.profileSnapshots, event.snapshot) } + case 'credential.reference.created': + return { ...state, credentials: upsert(state.credentials, event.credential) } + case 'connection.upserted': + return { ...state, connections: upsert(state.connections, event.connection) } + case 'connection.selected': + find(state.connections, event.connectionId, 'Connection') + return { ...state, selectedConnectionId: event.connectionId } + case 'conversation.created': + case 'conversation.imported': + case 'conversation.updated': + case 'conversation.selected': + case 'conversation.deleted': + case 'branch.created': + case 'branch.updated': + case 'branch.selected': + return applyConversationEvent(state, event) + case 'turn.created': + return { ...state, turns: upsert(state.turns, event.turn) } + case 'turn.updated': + return { ...state, turns: upsert(state.turns, event.turn) } + case 'message.created': + return { ...state, messages: upsert(state.messages, event.message) } + case 'message.part.updated': { + const message = find(state.messages, event.part.messageId, 'Message') + const partIds = message.partIds.includes(event.part.id) + ? message.partIds + : [...message.partIds, event.part.id] + const text = event.part.kind === 'text' ? event.part.text : message.text + return { + ...state, + messages: upsert(state.messages, { ...message, partIds, text, updatedAt: at }), + messageParts: upsert(state.messageParts, event.part), + } + } + case 'run.bound': { + const run = find(state.runs, event.runId, 'Run') + return { + ...state, + runs: upsert(state.runs, { ...run, bindingId: event.bindingId, updatedAt: at }), + } + } + case 'run.status.changed': { + const run = find(state.runs, event.runId, 'Run') + return updateRun( + state, + { + ...run, + status: event.status, + ...(event.error === undefined ? {} : { error: event.error }), + }, + at, + ) + } + case 'run.reconciled': { + const run = find(state.runs, event.runId, 'Run') + if (run.status !== 'unknown') + throw new DomainInvariantError(`Run ${run.id} is not awaiting reconciliation`) + return updateRun(state, { ...run, status: event.status }, at) + } + case 'history.missing': { + const run = find(state.runs, event.range.runId, 'Run') + const messages = state.messages.map((message) => + message.runId === run.id + ? { + ...message, + complete: false, + status: 'incomplete' as const, + missingHistory: event.range, + } + : message, + ) + return { + ...state, + runs: upsert(state.runs, { ...run, complete: false, updatedAt: at }), + messages, + 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': + return { ...state, analyses: upsert(state.analyses, event.analysis) } + case 'analysis.attachment.created': + return { + ...state, + analysisAttachments: upsert(state.analysisAttachments, event.attachment), + } + case 'environment.upserted': + return { ...state, environments: upsert(state.environments, event.environment) } + case 'checkpoint.upserted': + return { ...state, checkpoints: upsert(state.checkpoints, event.checkpoint) } + case 'supervisor.upserted': + return { ...state, supervisors: upsert(state.supervisors, event.supervisor) } + case 'worker.upserted': + return { ...state, workers: upsert(state.workers, event.worker) } + case 'draft.recorded': + return { + ...state, + drafts: upsert(state.drafts, event.draft), + draft: event.draft.branchId === state.branchId ? event.draft.text : state.draft, + ...(event.operation === undefined + ? {} + : { operations: upsert(state.operations, event.operation) }), + } + case 'queue.upserted': + return { ...state, queues: upsert(state.queues, event.queue) } + case 'queue.entry.upserted': { + const queue = find(state.queues, event.entry.queueId, 'Queue') + const entryIds = queue.entryIds.includes(event.entry.id) + ? queue.entryIds + : [...queue.entryIds, event.entry.id] + return { + ...state, + queues: upsert(state.queues, { ...queue, entryIds, updatedAt: at }), + queueEntries: upsert(state.queueEntries, event.entry), + } + } + case 'rule.upserted': + return { + ...state, + rules: upsert(state.rules, event.rule), + ...(event.operation === undefined + ? {} + : { operations: upsert(state.operations, event.operation) }), + } + case 'rule.deleted': + return { + ...state, + rules: state.rules.filter((rule) => rule.id !== event.ruleId), + operations: upsert(state.operations, event.operation), + } + case 'interaction.automation.audited': + return state + case 'binding.upserted': + return { ...state, bindings: upsert(state.bindings, event.binding) } + case 'graph.node.upserted': + return { ...state, graphNodes: upsert(state.graphNodes, event.node) } + case 'graph.edge.upserted': + return { ...state, graphEdges: upsert(state.graphEdges, event.edge) } + case 'operation.requested': + case 'operation.updated': + return { ...state, operations: upsert(state.operations, event.operation) } + case 'effect.upserted': + return { ...state, effects: upsert(state.effects, event.effect) } + case 'feedback.decision.recorded': + return { ...state, feedbackDecisions: upsert(state.feedbackDecisions, event.decision) } + case 'content.unavailable': + return state + case 'replay.cursor.advanced': { + const run = find(state.runs, event.runId, 'Run') + const cursor = { + runId: event.runId, + cursor: event.cursor, + committedSequence: state.sequence + 1, + } + return { + ...state, + replayCursors: upsertBy(state.replayCursors, (entry) => entry.runId, cursor), + runs: upsert(state.runs, { ...run, replayCursor: event.cursor, updatedAt: at }), + } + } + case 'unknown.event': + return { ...state, unknownEvents: upsert(state.unknownEvents, event.unknown) } + default: { + const exhaustive: never = event + return exhaustive + } + } +} diff --git a/src/domain/reducer-helpers.ts b/src/domain/reducer-helpers.ts new file mode 100644 index 0000000..63c8e60 --- /dev/null +++ b/src/domain/reducer-helpers.ts @@ -0,0 +1,482 @@ +import { canonicalDigest } from './canonical.js' +import type { + BraidMessage, + BranchRecord, + ConversationRecord, + DraftRecord, + MessagePartRecord, + MessageRecord, + OperationRecord, + RunRecord, +} from './entities.js' +import type { BraidEvent, BraidEventEnvelope } from './events.js' +import { + type BranchId, + createDraftId, + createMessagePartId, + createQueueId, + createWorkspaceId, + type EventId, + type MessageId, + type MessagePartId, + type OperationId, + parseDigestValue, + type RunId, +} from './ids.js' +import { DomainInvariantError } from './invariants.js' +import type { BraidState } from './state.js' +import { createAdmissionReceipt } from './receipts.js' +import { LEGACY_RUN_CAPABILITIES } from './runtime-projection.js' +import { graphEdge, graphNode } from './graph-records.js' + +export class SequenceGapError extends Error { + readonly code = 'SEQUENCE_GAP' + readonly expectedSequence: number + readonly actualSequence: number + readonly expectedRevision: number + readonly actualRevision: number + + constructor(state: BraidState, envelope: BraidEventEnvelope) { + super( + `Event sequence ${envelope.sequence} does not follow ${state.sequence}; ` + + `revision ${envelope.revision} does not follow ${state.revision}`, + ) + this.name = 'SequenceGapError' + this.expectedSequence = state.sequence + 1 + this.actualSequence = envelope.sequence + this.expectedRevision = state.revision + 1 + this.actualRevision = envelope.revision + } +} + +export class DuplicateEventConflictError extends Error { + readonly code = 'DUPLICATE_EVENT_CONFLICT' + + constructor(eventId: EventId) { + super(`Event ${eventId} was replayed with a different position or payload`) + this.name = 'DuplicateEventConflictError' + } +} + +export function upsert( + items: readonly T[], + item: T, +): readonly T[] { + const index = items.findIndex((entry) => entry.id === item.id) + if (index === -1) return [...items, item] + return items.map((entry, candidate) => (candidate === index ? item : entry)) +} + +export function upsertBy(items: readonly T[], key: (item: T) => string, item: T): readonly T[] { + const value = key(item) + const index = items.findIndex((entry) => key(entry) === value) + if (index === -1) return [...items, item] + return items.map((entry, candidate) => (candidate === index ? item : entry)) +} + +export function find( + items: readonly T[], + id: string, + name: string, +): T { + const item = items.find((entry) => entry.id === id) + if (!item) throw new DomainInvariantError(`${name} ${id} does not exist`) + return item +} + +export function dateAt(envelope: BraidEventEnvelope): string { + return envelope.occurredAt +} + +export function defaultWorkspaceId(workspace: string) { + return createWorkspaceId(`workspace-${canonicalDigest({ root: workspace }).slice(0, 32)}`) +} + +export function defaultPartId(messageId: MessageId): MessagePartId { + return createMessagePartId(`part-${messageId}`) +} + +export function defaultDraft(branchId: BranchId, at: string): DraftRecord { + return { + id: createDraftId(`draft-${branchId}`), + branchId, + text: '', + updatedAt: at, + } +} + +export function defaultQueue(branchId: BranchId, at: string) { + return { + id: createQueueId(`queue-${branchId}`), + branchId, + entryIds: [], + createdAt: at, + updatedAt: at, + } +} + +export function ensureWorkspaceGraph(state: BraidState, workspace: string, at: string): BraidState { + const workspaceId = defaultWorkspaceId(workspace) + const existingWorkspace = state.workspaces.find((entry) => entry.id === workspaceId) + const workspaceRecord = existingWorkspace ?? { + id: workspaceId, + root: workspace, + trusted: false, + createdAt: at, + updatedAt: at, + } + const conversation: ConversationRecord = { + id: state.conversationId, + workspaceId, + title: 'New conversation', + activeBranchId: state.branchId, + createdAt: at, + updatedAt: at, + archived: false, + retention: {}, + } + const draft = defaultDraft(state.branchId, at) + const queue = defaultQueue(state.branchId, at) + const branch: BranchRecord = { + id: state.branchId, + conversationId: state.conversationId, + overrides: {}, + draftId: draft.id, + queueId: queue.id, + status: 'active', + createdAt: at, + updatedAt: at, + } + const workspaceNode = graphNode({ kind: 'workspace', id: workspaceId }, at, workspace) + const conversationNode = graphNode( + { kind: 'conversation', id: conversation.id }, + at, + conversation.title, + ) + const branchNode = graphNode({ kind: 'branch', id: branch.id }, at, 'Main') + const workspaceConversation = graphEdge({ + kind: 'attached', + source: workspaceNode.reference, + destination: conversationNode.reference, + at, + }) + const conversationBranch = graphEdge({ + kind: 'attached', + source: conversationNode.reference, + destination: branchNode.reference, + at, + }) + return { + ...state, + workspace, + workspaceId, + workspaces: upsert(state.workspaces, workspaceRecord), + conversations: state.conversations.some((entry) => entry.id === conversation.id) + ? state.conversations + : [...state.conversations, conversation], + branches: state.branches.some((entry) => entry.id === branch.id) + ? state.branches + : [...state.branches, branch], + drafts: state.drafts.some((entry) => entry.id === draft.id) + ? state.drafts + : [...state.drafts, draft], + queues: state.queues.some((entry) => entry.id === queue.id) + ? state.queues + : [...state.queues, queue], + graphNodes: [workspaceNode, conversationNode, branchNode].reduce(upsert, state.graphNodes), + graphEdges: [workspaceConversation, conversationBranch].reduce(upsert, state.graphEdges), + } +} + +export function legacyMessage( + state: BraidState, + id: MessageId, + role: BraidMessage['role'], + text: string, + runId: RunId, + turnId: import('./ids.js').TurnId, + status: BraidMessage['status'], + at: string, +): MessageRecord { + const partId = defaultPartId(id) + return { + id, + conversationId: state.conversationId, + branchId: state.branchId, + role, + text, + partIds: [partId], + parts: [{ id: partId, kind: 'text', text }], + status, + turnId, + runId, + createdAt: at, + updatedAt: at, + complete: status === 'complete', + } +} + +export function legacyRunFields( + state: BraidState, + event: Extract, + at: string, +) { + return { + receipt: createAdmissionReceipt({ + runId: event.runId, + turnId: event.turnId, + operationId: event.operationId, + conversationId: state.conversationId, + branchId: state.branchId, + admittedAt: at, + profile: state.profile, + text: event.text, + capabilities: LEGACY_RUN_CAPABILITIES, + }), + capabilities: LEGACY_RUN_CAPABILITIES, + lastProviderSequence: 0, + eventCount: 0, + interactions: [], + activity: [], + eventDetails: [], + } as const +} + +export function legacyTextPart(message: MessageRecord, at: string): MessagePartRecord { + return { + id: message.partIds[0] as MessagePartId, + messageId: message.id, + ordinal: 0, + kind: 'text', + text: message.text, + createdAt: at, + updatedAt: at, + } +} + +export function runStatusTerminal(status: RunRecord['status']): boolean { + return ( + status === 'completed' || + status === 'cancelled' || + status === 'failed' || + status === 'expired' || + status === 'unknown' || + status === 'aborted' || + status === 'blocked' + ) +} + +export function canTransition(from: RunRecord['status'], to: RunRecord['status']): boolean { + if (from === to) return true + if (runStatusTerminal(from)) return from === 'unknown' && to !== 'running' && to !== 'starting' + switch (from) { + case 'prepared': + return to === 'starting' || to === 'cancelled' || to === 'aborted' + case 'starting': + return [ + 'running', + 'streaming', + 'waiting', + 'detached', + 'reconnecting', + 'cancelling', + 'completed', + 'cancelled', + 'failed', + 'expired', + 'unknown', + 'blocked', + 'aborted', + ].includes(to) + case 'running': + case 'streaming': + return [ + 'waiting', + 'detached', + 'reconnecting', + 'cancelling', + 'completed', + 'cancelled', + 'failed', + 'expired', + 'unknown', + 'blocked', + 'aborted', + ].includes(to) + case 'waiting': + return [ + 'running', + 'streaming', + 'detached', + 'reconnecting', + 'cancelling', + 'completed', + 'cancelled', + 'failed', + 'expired', + 'unknown', + 'blocked', + 'aborted', + ].includes(to) + case 'detached': + return [ + 'reconnecting', + 'cancelling', + 'completed', + 'cancelled', + 'failed', + 'expired', + 'unknown', + 'aborted', + 'blocked', + ].includes(to) + case 'reconnecting': + return [ + 'running', + 'streaming', + 'waiting', + 'detached', + 'cancelling', + 'completed', + 'cancelled', + 'failed', + 'expired', + 'unknown', + 'aborted', + 'blocked', + ].includes(to) + case 'cancelling': + return [ + 'completed', + 'cancelled', + 'failed', + 'expired', + 'unknown', + 'aborted', + 'blocked', + ].includes(to) + default: + return false + } +} + +export function updateRun(state: BraidState, run: RunRecord, at: string): BraidState { + const previous = state.runs.find((entry) => entry.id === run.id) + if (previous && previous.status !== run.status && !canTransition(previous.status, run.status)) { + throw new DomainInvariantError( + `Run ${run.id} cannot transition from ${previous.status} to ${run.status}`, + ) + } + const terminal = runStatusTerminal(run.status) + const nextRun: RunRecord = { + ...run, + complete: terminal ? run.complete : false, + updatedAt: at, + ...(terminal && run.terminalAt === undefined ? { terminalAt: at } : {}), + } + const activeRunId = terminal && state.activeRunId === run.id ? null : state.activeRunId + return { + ...state, + runs: upsert(state.runs, nextRun), + activeRunId, + } +} + +export function updateMessage(state: BraidState, message: MessageRecord): BraidState { + return { + ...state, + messages: upsert(state.messages, message), + } +} + +export function updateMessageText( + state: BraidState, + runId: RunId, + text: string, + at: string, +): BraidState { + const message = state.messages.find( + (entry) => entry.runId === runId && entry.role === 'assistant', + ) + if (!message) throw new DomainInvariantError(`Assistant message for run ${runId} does not exist`) + const nextMessage = { ...message, text: `${message.text}${text}`, updatedAt: at } + const partId = message.partIds[0] + const part = state.messageParts.find((entry) => entry.id === partId) + const nextPart: MessagePartRecord = + part?.kind === 'text' + ? { ...part, text: nextMessage.text, updatedAt: at } + : legacyTextPart(nextMessage, at) + return { + ...updateMessage(state, nextMessage), + messageParts: upsert(state.messageParts, nextPart), + } +} + +export function updateMessageFinal( + state: BraidState, + runId: RunId, + text: string, + status: BraidMessage['status'], + at: string, +): BraidState { + const message = state.messages.find( + (entry) => entry.runId === runId && entry.role === 'assistant', + ) + if (!message) throw new DomainInvariantError(`Assistant message for run ${runId} does not exist`) + const nextMessage = { + ...message, + text: text || message.text, + status, + complete: status === 'complete', + updatedAt: at, + } + const part = state.messageParts.find((entry) => entry.id === message.partIds[0]) + const nextPart: MessagePartRecord = + part?.kind === 'text' + ? { ...part, text: nextMessage.text, updatedAt: at } + : legacyTextPart(nextMessage, at) + return { + ...updateMessage(state, nextMessage), + messageParts: upsert(state.messageParts, nextPart), + } +} + +export function operationForRun( + state: BraidState, + operationId: OperationId, + runId: RunId, + event: BraidEvent, + at: string, +): OperationRecord { + const existing = state.operations.find((entry) => entry.id === operationId) + const requestDigest = + event.kind === 'run.requested' && event.requestDigest !== undefined + ? parseDigestValue(event.requestDigest) + : canonicalDigest(event) + return ( + existing ?? { + id: operationId, + kind: 'send', + requestDigest, + status: 'pending', + target: { kind: 'run', id: runId }, + createdAt: at, + updatedAt: at, + } + ) +} + +export function withHealth(state: BraidState): BraidState { + const missingHistoryCount = state.missingHistory.length + const unknownEventCount = state.unknownEvents.length + return { + ...state, + health: { + status: + missingHistoryCount > 0 ? 'incomplete' : unknownEventCount > 0 ? 'degraded' : 'healthy', + lastError: state.lastError, + missingHistoryCount, + unknownEventCount, + }, + } +} diff --git a/src/domain/reducer-interactions.ts b/src/domain/reducer-interactions.ts new file mode 100644 index 0000000..8ab43f5 --- /dev/null +++ b/src/domain/reducer-interactions.ts @@ -0,0 +1,242 @@ +import type { BraidEvent } from './events.js' +import { reserveText } from './content-budget.js' +import type { BraidInteraction, BraidState } from './state.js' +import { + activity, + addActivity, + assertTerminalTransition, + findRun, + sourceFromProvider, + terminalMessageStatus, + updateMessage, + updateRun, + upsertPart, + withProviderProgress, + MAX_RUN_EVENT_DETAILS, + MAX_RUN_INTERACTIONS, + TERMINAL_RUN_STATES, + type ReducerBase, +} from './reducer-support.js' + +type InteractionEvent = Extract< + BraidEvent, + { + kind: + | 'run.interaction' + | 'run.interaction.cancelled' + | 'run.interaction.response.requested' + | 'run.interaction.responded' + | 'run.status.changed' + | 'run.provider.event' + | 'run.finished' + } +> + +export function reduceInteractionEvent( + state: BraidState, + event: InteractionEvent, + base: ReducerBase, +): BraidState { + switch (event.kind) { + case 'run.interaction': { + const source = sourceFromProvider(event.provider) ?? {} + const interaction: BraidInteraction = { + request: event.request, + runId: event.runId, + source, + status: 'pending', + } + const interactions = [ + ...(state.runs.find((run) => run.id === event.runId)?.interactions ?? []), + interaction, + ] + 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 } + : {}), + }, + activity(event, 'interaction', event.request.kind, event.request.title, source), + ), + ), + } + } + case 'run.interaction.cancelled': + return { + ...state, + ...base, + runs: updateRun(state, event.runId, (run) => ({ + ...withProviderProgress(run, event.provider), + interactions: run.interactions.map((item) => + item.request.id === event.interactionId ? { ...item, status: 'cancelled' } : item, + ), + })), + } + 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 } + : item, + ), + })), + } + case 'run.interaction.responded': + return { + ...state, + ...base, + lastError: + event.outcome === 'unknown' + ? (event.detail ?? 'Interaction response is unknown') + : state.lastError, + runs: updateRun(state, event.runId, (run) => ({ + ...run, + interactions: run.interactions.map((item) => + item.request.id === event.interactionId + ? { + ...item, + status: + event.outcome === 'accepted' + ? ('resolved' as const) + : event.outcome === 'unknown' + ? ('unknown' as const) + : event.outcome, + } + : item, + ), + })), + } + case 'run.status.changed': { + const current = findRun(state, event.runId) + assertTerminalTransition(current.status, event.status) + return { + ...state, + ...base, + activeRunId: + event.status === 'detached' || TERMINAL_RUN_STATES.includes(event.status) + ? state.activeRunId === event.runId + ? null + : state.activeRunId + : event.runId, + runs: updateRun(state, event.runId, (run) => + addActivity( + { ...withProviderProgress({ ...run, status: event.status }, event.provider) }, + activity(event, 'status', event.status, event.detail), + ), + ), + } + } + case 'run.provider.event': + return { + ...state, + ...base, + runs: updateRun(state, event.runId, (run) => ({ + ...withProviderProgress(run, event.provider), + ...(event.envelope.event.type === 'session.updated' + ? { providerSessionId: event.envelope.event.sessionId } + : {}), + eventDetails: [ + ...run.eventDetails, + { + eventId: event.envelope.eventId, + sequence: event.envelope.sequence, + type: event.envelope.event.type, + ...(event.envelope.cursor === undefined ? {} : { cursor: event.envelope.cursor }), + ...(event.envelope.occurredAt === undefined + ? {} + : { occurredAt: event.envelope.occurredAt }), + }, + ].slice(-MAX_RUN_EVENT_DETAILS), + ...(run.eventDetails.length + 1 > MAX_RUN_EVENT_DETAILS + ? { eventDetailsTruncated: true } + : {}), + })), + } + case 'run.finished': + return reduceFinishedEvent(state, event, base) + default: { + const exhaustive: never = event + return exhaustive + } + } +} + +function reduceFinishedEvent( + state: BraidState, + event: Extract, + base: ReducerBase, +): BraidState { + const run = findRun(state, event.runId) + const finalReservation = reserveText(run, event.finalText) + const errorReservation = + event.error === undefined ? undefined : reserveText(finalReservation.run, event.error) + const reasonReservation = + event.reason === undefined + ? undefined + : reserveText(errorReservation?.run ?? finalReservation.run, event.reason) + const reservedRun = reasonReservation?.run ?? errorReservation?.run ?? finalReservation.run + assertTerminalTransition(run.status, event.status === 'unknown' ? 'unknown' : event.status) + const hasMissingHistory = state.missingHistory.some((range) => range.runId === event.runId) + const messageStatus = hasMissingHistory ? 'incomplete' : terminalMessageStatus(event.status) + return { + ...state, + ...base, + activeRunId: state.activeRunId === event.runId ? null : state.activeRunId, + lastError: errorReservation?.value ?? (event.status === 'failed' ? state.lastError : null), + messages: updateMessage(state, event.runId, (message) => { + const source = sourceFromProvider(event.provider) + const existingTextPart = message.parts.find((part) => part.kind === 'text') + const withFinalPart = event.finalText + ? upsertPart(message, { + id: existingTextPart?.id ?? `${event.runId}:text`, + kind: 'text', + text: finalReservation.value, + ...(source === undefined ? {} : { source }), + }) + : message + return { + ...withFinalPart, + text: finalReservation.value || withFinalPart.text, + status: messageStatus, + complete: event.status !== 'unknown' && !hasMissingHistory, + } + }), + runs: updateRun(state, event.runId, (candidate) => { + const withProgress = withProviderProgress( + { + ...candidate, + ...(reservedRun.contentBytes === undefined + ? {} + : { contentBytes: reservedRun.contentBytes }), + ...(reservedRun.contentTruncated === undefined + ? {} + : { contentTruncated: reservedRun.contentTruncated }), + }, + event.provider, + ) + return { + ...withProgress, + status: event.status, + inputTokens: event.usage.input, + outputTokens: event.usage.output, + ...(event.usage.reasoning === undefined ? {} : { reasoningTokens: event.usage.reasoning }), + ...(event.usage.costUsd === undefined ? {} : { costUsd: event.usage.costUsd }), + ...(event.usage.model === undefined ? {} : { model: event.usage.model }), + ...(errorReservation === undefined ? {} : { error: errorReservation.value }), + ...(reasonReservation === undefined ? {} : { terminalReason: reasonReservation.value }), + complete: event.status !== 'unknown' && !hasMissingHistory, + } + }), + } +} diff --git a/src/domain/reducer-legacy.ts b/src/domain/reducer-legacy.ts new file mode 100644 index 0000000..59e4195 --- /dev/null +++ b/src/domain/reducer-legacy.ts @@ -0,0 +1,256 @@ +import { canonicalDigest } from './canonical.js' +import type { OperationRecord, RunRecord, TurnRecord } from './entities.js' +import type { BraidEvent, BraidEventEnvelope } from './events.js' +import { parseMessageId, parseOperationId, parseRunId, parseTurnId } from './ids.js' +import { DomainInvariantError } from './invariants.js' +import type { BraidState } from './state.js' + +import { + dateAt, + ensureWorkspaceGraph, + find, + legacyMessage, + legacyRunFields, + legacyTextPart, + operationForRun, + runStatusTerminal, + updateMessageFinal, + updateMessageText, + updateRun, + upsert, +} from './reducer-helpers.js' +import { attachRequestedRunToConversation } from './reducer-run-graph.js' + +function legacyOperation( + state: BraidState, + operationId: ReturnType, + event: BraidEvent, + at: string, + shape: Pick & Partial>, +): OperationRecord { + const existing = state.operations.find((entry) => entry.id === operationId) + return ( + existing ?? { + id: operationId, + kind: shape.kind, + requestDigest: canonicalDigest(event), + status: 'pending', + ...(shape.target === undefined ? {} : { target: shape.target }), + createdAt: at, + updatedAt: at, + } + ) +} + +export function reduceLegacyEvent( + state: BraidState, + event: Extract< + BraidEvent, + { + readonly kind: + | 'workspace.opened' + | 'draft.changed' + | 'run.requested' + | 'run.text.delta' + | 'run.cancel.requested' + | 'run.finished' + | 'application.shutdown.requested' + } + >, + envelope: BraidEventEnvelope, +): BraidState { + const at = dateAt(envelope) + switch (event.kind) { + case 'workspace.opened': + return ensureWorkspaceGraph(state, event.workspace, at) + case 'draft.changed': + return { + ...state, + draft: event.text, + drafts: state.drafts.map((draft) => + draft.branchId === state.branchId ? { ...draft, text: event.text, updatedAt: at } : draft, + ), + } + case 'run.requested': { + const runId = parseRunId(event.runId) + const turnId = parseTurnId(event.turnId) + const operationId = parseOperationId(event.operationId) + const userMessageId = parseMessageId(event.userMessageId) + const assistantMessageId = parseMessageId(event.assistantMessageId) + if (state.activeRunId !== null) + throw new DomainInvariantError(`Run ${state.activeRunId} is already active`) + const userMessage = legacyMessage( + state, + userMessageId, + 'user', + event.text, + runId, + turnId, + 'complete', + at, + ) + const assistantMessage = legacyMessage( + state, + assistantMessageId, + 'assistant', + '', + runId, + turnId, + 'streaming', + at, + ) + const turn: TurnRecord = { + id: turnId, + conversationId: state.conversationId, + branchId: state.branchId, + userMessageId, + runIds: [runId], + status: 'running', + createdAt: at, + updatedAt: at, + } + const run: RunRecord = { + id: runId, + conversationId: state.conversationId, + branchId: state.branchId, + turnId, + operationId, + status: 'streaming', + inputTokens: 0, + outputTokens: 0, + complete: false, + startedAt: at, + updatedAt: at, + ...legacyRunFields(state, event, at), + } + const operation = operationForRun(state, operationId, runId, event, at) + const messageParts = [legacyTextPart(userMessage, at), legacyTextPart(assistantMessage, at)] + return attachRequestedRunToConversation( + { + ...state, + draft: '', + drafts: state.drafts.map((draft) => + draft.branchId === state.branchId ? { ...draft, text: '', updatedAt: at } : draft, + ), + activeRunId: runId, + lastError: null, + messages: upsert(upsert(state.messages, userMessage), assistantMessage), + messageParts: messageParts.reduce(upsert, state.messageParts), + turns: upsert(state.turns, turn), + runs: upsert(state.runs, run), + operations: upsert(state.operations, operation), + }, + { run, turn, userMessage, assistantMessage, at }, + ) + } + case 'run.text.delta': { + const runId = parseRunId(event.runId) + if (state.activeRunId !== runId) + throw new DomainInvariantError(`Text arrived for inactive run ${runId}`) + return updateMessageText(state, runId, event.text, at) + } + case 'run.cancel.requested': { + const runId = parseRunId(event.runId) + const run = find(state.runs, runId, 'Run') + if (runStatusTerminal(run.status)) { + throw new DomainInvariantError(`Cancellation requested for terminal run ${runId}`) + } + return { + ...updateRun(state, { ...run, status: 'cancelling' }, at), + operations: upsert( + state.operations, + legacyOperation(state, parseOperationId(event.operationId), event, at, { + kind: 'cancel-run', + target: { kind: 'run', id: runId }, + }), + ), + } + } + case 'application.shutdown.requested': + return { + ...state, + operations: upsert( + state.operations, + legacyOperation(state, parseOperationId(event.operationId), event, at, { + kind: 'custom', + }), + ), + } + case 'run.finished': { + const runId = parseRunId(event.runId) + const run = find(state.runs, runId, 'Run') + const status = + event.status === 'completed' + ? 'completed' + : event.status === 'failed' + ? 'failed' + : event.status === 'aborted' + ? 'aborted' + : event.status === 'unknown' + ? 'unknown' + : 'blocked' + const messageStatus = + status === 'completed' + ? 'complete' + : status === 'failed' + ? 'failed' + : status === 'aborted' + ? 'aborted' + : status === 'unknown' + ? 'incomplete' + : 'blocked' + let next = updateRun( + state, + { + ...run, + status, + inputTokens: event.usage.input, + outputTokens: event.usage.output, + ...(event.usage.costUsd === undefined ? {} : { costUsd: event.usage.costUsd }), + ...(event.usage.model === undefined ? {} : { model: event.usage.model }), + ...(event.error === undefined ? {} : { error: event.error }), + complete: true, + }, + at, + ) + next = updateMessageFinal(next, runId, event.finalText, messageStatus, at) + const turn = next.turns.find((entry) => entry.id === run.turnId) + if (turn) + next = { + ...next, + turns: upsert(next.turns, { + ...turn, + status: + status === 'completed' + ? 'completed' + : status === 'aborted' + ? 'cancelled' + : status === 'failed' + ? 'failed' + : 'unknown', + selectedRunId: runId, + updatedAt: at, + }), + } + const operation = next.operations.find((entry) => entry.id === run.operationId) + if (operation) { + next = { + ...next, + operations: upsert(next.operations, { + ...operation, + status: + status === 'completed' ? 'terminal' : status === 'unknown' ? 'unknown' : 'failed', + terminalOutcome: + status === 'aborted' ? 'cancelled' : status === 'blocked' ? 'failed' : status, + updatedAt: at, + }), + } + } + return { ...next, lastError: event.error ?? null } + } + default: { + const exhaustive: never = event + return exhaustive + } + } +} diff --git a/src/domain/reducer-lifecycle.ts b/src/domain/reducer-lifecycle.ts new file mode 100644 index 0000000..2a3fa80 --- /dev/null +++ b/src/domain/reducer-lifecycle.ts @@ -0,0 +1,277 @@ +import type { BraidEvent } from './events.js' +import type { RunRecord, TurnRecord } from './entities.js' +import { createAdmissionReceipt } from './receipts.js' +import type { BraidState } from './state.js' +import { parseMessageId, parseRunId, parseTurnId } from './ids.js' +import { activity, addActivity, findRun, updateRun, type ReducerBase } from './reducer-support.js' +import { legacyMessage, legacyTextPart, upsert } from './reducer-helpers.js' +import { LEGACY_RUN_CAPABILITIES } from './runtime-projection.js' +import { attachRequestedRunToConversation } from './reducer-run-graph.js' + +type LifecycleEvent = Extract< + BraidEvent, + { + kind: + | 'run.requested' + | 'run.control.requested' + | 'run.control.acknowledged' + | 'run.queue.added' + | 'run.queue.removed' + | 'run.detached' + | 'run.reconnecting' + | 'run.reconciled' + | 'run.unknown' + } +> + +export function reduceLifecycleEvent( + state: BraidState, + event: LifecycleEvent, + base: ReducerBase, + occurredAt: string, +): BraidState { + switch (event.kind) { + case 'run.requested': + return reduceRequestedRun(state, event, base, occurredAt) + case 'run.control.requested': + findRun(state, event.runId) + return { + ...state, + ...base, + runs: updateRun(state, event.runId, (candidate) => + addActivity( + { ...candidate, status: event.control === 'cancel' ? 'cancelling' : candidate.status }, + activity(event, 'control', event.control, event.text ?? event.reason), + ), + ), + } + case 'run.control.acknowledged': + findRun(state, event.runId) + return { + ...state, + ...base, + runs: updateRun(state, event.runId, (run) => + addActivity(run, activity(event, 'control.ack', event.control, event.outcome)), + ), + } + case 'run.queue.added': + return { + ...state, + ...base, + queuedInputs: [ + ...state.queuedInputs, + { + operationId: event.operationId, + runId: event.runId, + text: event.text, + position: event.position, + }, + ], + runs: updateRun(state, event.runId, (run) => + addActivity(run, activity(event, 'queue', `position ${event.position}`)), + ), + } + case 'run.queue.removed': + return { + ...state, + ...base, + queuedInputs: state.queuedInputs + .filter((input) => input.operationId !== event.operationId) + .map((input, index) => ({ ...input, position: index + 1 })), + } + case 'run.detached': + return { + ...state, + ...base, + activeRunId: state.activeRunId === event.runId ? null : state.activeRunId, + runs: updateRun(state, event.runId, (run) => + addActivity( + { + ...run, + status: 'detached', + ...(event.cursor === undefined ? {} : { lastCursor: event.cursor }), + }, + activity(event, 'lifecycle', 'detached', event.detail), + ), + ), + } + case 'run.reconnecting': + return { + ...state, + ...base, + activeRunId: event.runId, + runs: updateRun(state, event.runId, (run) => + addActivity( + { ...run, status: 'reconnecting' }, + activity(event, 'lifecycle', 'reconnecting', event.after), + ), + ), + } + case 'run.reconciled': { + const current = findRun(state, event.runId) + if (event.from !== undefined && event.from !== current.status) + throw new Error(`Run ${event.runId} reconciliation evidence is stale`) + if (!event.evidence) + throw new Error(`Run ${event.runId} reconciliation requires provider evidence`) + if ( + ['completed', 'failed', 'aborted', 'cancelled', 'blocked', 'expired'].includes( + current.status, + ) + ) + throw new Error(`Run ${event.runId} cannot be reconciled after a proven terminal state`) + const from = event.from ?? current.status + const to = event.to ?? event.status + return { + ...state, + ...base, + activeRunId: + isTerminalStatus(to) && state.activeRunId === event.runId + ? null + : isTerminalStatus(to) + ? state.activeRunId + : event.runId, + runs: updateRun(state, event.runId, (run) => + addActivity( + { ...run, status: to }, + activity(event, 'reconciliation', `${from} → ${to}`, event.detail), + ), + ), + } + } + case 'run.unknown': + return { + ...state, + ...base, + activeRunId: state.activeRunId === event.runId ? null : state.activeRunId, + messages: state.messages.map((message) => + message.runId === event.runId && message.role === 'assistant' + ? { ...message, status: 'incomplete', complete: false } + : message, + ), + runs: updateRun(state, event.runId, (run) => + addActivity( + { ...run, status: 'unknown' }, + activity(event, 'lifecycle', 'unknown', event.detail), + ), + ), + lastError: event.detail, + } + default: { + const exhaustive: never = event + return exhaustive + } + } +} + +function reduceRequestedRun( + state: BraidState, + event: Extract, + base: ReducerBase, + occurredAt: string, +): BraidState { + if (state.activeRunId) throw new Error(`Run ${state.activeRunId} is already active`) + const runId = parseRunId(event.runId) + const turnId = parseTurnId(event.turnId) + const userMessageId = parseMessageId(event.userMessageId) + const assistantMessageId = parseMessageId(event.assistantMessageId) + const receipt = + event.receipt ?? + createAdmissionReceipt({ + runId: event.runId, + turnId: event.turnId, + operationId: event.operationId, + conversationId: state.conversationId, + branchId: state.branchId, + admittedAt: occurredAt, + profile: state.profile, + text: event.text, + capabilities: LEGACY_RUN_CAPABILITIES, + }) + const userMessage = legacyMessage( + state, + userMessageId, + 'user', + event.text, + runId, + turnId, + 'complete', + occurredAt, + ) + const assistantMessage = { + ...legacyMessage( + state, + assistantMessageId, + 'assistant', + '', + runId, + turnId, + 'streaming', + occurredAt, + ), + parts: [], + } + const turn: TurnRecord = { + id: turnId, + conversationId: state.conversationId, + branchId: state.branchId, + userMessageId, + runIds: [runId], + status: 'running', + createdAt: occurredAt, + updatedAt: occurredAt, + } + const run: RunRecord = { + id: runId, + conversationId: state.conversationId, + branchId: state.branchId, + turnId, + operationId: event.operationId, + status: 'streaming' as const, + receipt, + capabilities: receipt.capabilities, + ...(receipt.providerSessionId === undefined + ? {} + : { providerSessionId: receipt.providerSessionId }), + inputTokens: 0, + outputTokens: 0, + complete: false, + startedAt: occurredAt, + updatedAt: occurredAt, + lastProviderSequence: 0, + eventCount: 0, + interactions: [], + activity: [activity(event, 'admission', 'admitted', `profile ${receipt.profileDigest}`)], + eventDetails: [], + } + return attachRequestedRunToConversation( + { + ...state, + ...base, + draft: '', + drafts: state.drafts.map((draft) => + draft.branchId === state.branchId ? { ...draft, text: '', updatedAt: occurredAt } : draft, + ), + activeRunId: event.runId, + lastError: null, + messages: [...state.messages, userMessage, assistantMessage], + messageParts: [ + legacyTextPart(userMessage, occurredAt), + legacyTextPart(assistantMessage, occurredAt), + ].reduce(upsert, state.messageParts), + turns: upsert(state.turns, turn), + runs: upsert(state.runs, run), + }, + { run, turn, userMessage, assistantMessage, at: occurredAt }, + ) +} + +function isTerminalStatus(status: import('./state.js').RunStatus): boolean { + return ( + status === 'completed' || + status === 'failed' || + status === 'aborted' || + status === 'cancelled' || + status === 'blocked' || + status === 'expired' + ) +} diff --git a/src/domain/reducer-replay.ts b/src/domain/reducer-replay.ts new file mode 100644 index 0000000..2297605 --- /dev/null +++ b/src/domain/reducer-replay.ts @@ -0,0 +1,212 @@ +import { canonicalDigest } from './canonical.js' +import { + type BraidEvent, + type BraidEventEnvelope, + type DomainBraidEventMap, + eventRunId, +} from './events.js' +import { createEventId, type EventId, parseReplayCursor } from './ids.js' +import { assertBraidState, DomainInvariantError } from './invariants.js' +import { canonicalProjectionChecksum } from './projection-checksum.js' +import { applyDomainEvent } from './reducer-domain-events.js' +import { + DuplicateEventConflictError, + SequenceGapError, + upsert, + upsertBy, + withHealth, +} from './reducer-helpers.js' +import { reduceLegacyEvent } from './reducer-legacy.js' +import { isRuntimeEvent, reduceRuntimeEvent } from './reducer-runtime.js' +import { type BraidState, initialState } from './state.js' +import { isCanonicalIsoDateTime } from './text.js' + +export const MAX_APPLIED_EVENT_HISTORY = 256 + +function boundedAppliedEvents( + events: readonly BraidState['appliedEvents'][number][], +): BraidState['appliedEvents'] { + return events.slice(-MAX_APPLIED_EVENT_HISTORY) +} + +function applyEnvelopeMetadata( + envelope: BraidEventEnvelope, + reduced: BraidState, + eventId: EventId, + digest: ReturnType, + options: { + readonly appliedEvents?: BraidState['appliedEvents'] + readonly finalize?: boolean + } = {}, +): BraidState { + let next: BraidState = { + ...reduced, + revision: envelope.revision, + sequence: envelope.sequence, + appliedEvents: + options.appliedEvents ?? + boundedAppliedEvents([ + ...reduced.appliedEvents, + { id: eventId, sequence: envelope.sequence, revision: envelope.revision, digest }, + ]), + } + const runId = eventRunId(envelope.event) + if (envelope.cursor !== undefined && runId !== undefined) { + const cursor = parseReplayCursor(envelope.cursor) + const run = next.runs.find((entry) => entry.id === runId) + next = { + ...next, + replayCursors: upsertBy(next.replayCursors, (entry) => entry.runId, { + runId, + cursor, + committedSequence: envelope.sequence, + }), + ...(run === undefined + ? {} + : { + runs: upsert(next.runs, { + ...run, + replayCursor: cursor, + updatedAt: envelope.occurredAt, + }), + }), + } + } + if (options.finalize !== false) { + next = finalizeReducedState(next) + } else { + assertBraidState(next) + } + return next +} + +function finalizeReducedState(state: BraidState): BraidState { + const healthy = withHealth(state) + const finalized = { + ...healthy, + projectionChecksum: canonicalProjectionChecksum(healthy), + } + assertBraidState(finalized) + return finalized +} + +const LEGACY_EVENT_KINDS = [ + 'workspace.opened', + 'draft.changed', + 'run.requested', + 'run.text.delta', + 'run.cancel.requested', + 'run.finished', + 'application.shutdown.requested', +] as const + +type LegacyEventKind = (typeof LEGACY_EVENT_KINDS)[number] + +function isLegacyEvent( + event: BraidEvent, +): event is Extract { + return (LEGACY_EVENT_KINDS as readonly string[]).includes(event.kind) +} + +export function reduceEvent(state: BraidState, envelope: BraidEventEnvelope): BraidState { + if (!isCanonicalIsoDateTime(envelope.occurredAt)) { + throw new DomainInvariantError('Event occurredAt is not a canonical ISO date') + } + const eventId = envelope.eventId ?? createEventId(`event-${envelope.sequence}`) + const digest = canonicalDigest({ + event: envelope.event, + cursor: envelope.cursor ?? null, + occurredAt: envelope.occurredAt, + }) + if (envelope.sequence !== state.sequence + 1 || envelope.revision !== state.revision + 1) { + const applied = state.appliedEvents.find((entry) => entry.id === eventId) + if (applied !== undefined) { + if ( + applied.sequence !== envelope.sequence || + applied.revision !== envelope.revision || + applied.digest !== digest + ) { + throw new DuplicateEventConflictError(eventId) + } + return state + } + throw new SequenceGapError(state, envelope) + } + const reduced = isRuntimeEvent(envelope.event) + ? reduceRuntimeEvent(state, envelope) + : isLegacyEvent(envelope.event) + ? reduceLegacyEvent(state, envelope.event, envelope) + : applyDomainEvent(state, envelope.event, envelope.occurredAt) + return applyEnvelopeMetadata(envelope, reduced, eventId, digest) +} + +export function replayEvents( + initial: BraidState, + events: readonly BraidEventEnvelope[], +): BraidState { + if (events.length === 0) return initial + const appliedById = new Map(initial.appliedEvents.map((entry) => [entry.id, entry])) + const appended: BraidState['appliedEvents'][number][] = [] + let state = initial + for (const envelope of events) { + if (!isCanonicalIsoDateTime(envelope.occurredAt)) { + throw new DomainInvariantError('Event occurredAt is not a canonical ISO date') + } + const eventId = envelope.eventId ?? createEventId(`event-${envelope.sequence}`) + const digest = canonicalDigest({ + event: envelope.event, + cursor: envelope.cursor ?? null, + occurredAt: envelope.occurredAt, + }) + const applied = appliedById.get(eventId) + if (applied !== undefined) { + if ( + applied.sequence !== envelope.sequence || + applied.revision !== envelope.revision || + applied.digest !== digest + ) { + throw new DuplicateEventConflictError(eventId) + } + continue + } + if (envelope.sequence !== state.sequence + 1 || envelope.revision !== state.revision + 1) { + throw new SequenceGapError(state, envelope) + } + const reduced = isRuntimeEvent(envelope.event) + ? reduceRuntimeEvent(state, envelope) + : isLegacyEvent(envelope.event) + ? reduceLegacyEvent(state, envelope.event, envelope) + : applyDomainEvent(state, envelope.event, envelope.occurredAt) + const record = { + id: eventId, + sequence: envelope.sequence, + revision: envelope.revision, + digest, + } + appended.push(record) + appliedById.set(eventId, record) + state = applyEnvelopeMetadata(envelope, reduced, eventId, digest, { + appliedEvents: initial.appliedEvents, + finalize: false, + }) + } + return finalizeReducedState({ + ...state, + appliedEvents: boundedAppliedEvents([...initial.appliedEvents, ...appended]), + }) +} + +export function replayJournal( + initial: BraidState, + events: readonly BraidEventEnvelope[], +): BraidState { + return replayEvents(initial, events) +} + +export function initialDomainState( + profile: Readonly, +): BraidState { + return initialState(profile) +} + +export type DomainEventPayload = DomainBraidEventMap[K] diff --git a/src/domain/reducer-run-graph.ts b/src/domain/reducer-run-graph.ts new file mode 100644 index 0000000..350d66d --- /dev/null +++ b/src/domain/reducer-run-graph.ts @@ -0,0 +1,75 @@ +import type { MessageRecord, RunRecord, TurnRecord } from './entities.js' +import { graphEdge, graphNode } from './graph-records.js' +import type { BraidState } from './state.js' +import { find, upsert } from './reducer-helpers.js' + +export function attachRequestedRunToConversation( + state: BraidState, + input: { + readonly run: RunRecord + readonly turn: TurnRecord + readonly userMessage: MessageRecord + readonly assistantMessage: MessageRecord + readonly at: string + }, +): BraidState { + const branch = find(state.branches, input.run.branchId, 'Branch') + const conversation = find(state.conversations, input.run.conversationId, 'Conversation') + const branchReference = { kind: 'branch' as const, id: branch.id } + const turnReference = { kind: 'turn' as const, id: input.turn.id } + const userReference = { kind: 'message' as const, id: input.userMessage.id } + const runReference = { kind: 'run' as const, id: input.run.id } + const assistantReference = { kind: 'message' as const, id: input.assistantMessage.id } + const nodes = [ + graphNode(turnReference, input.at, 'Turn'), + graphNode(userReference, input.at, 'User message'), + graphNode(runReference, input.at, 'Run'), + graphNode(assistantReference, input.at, 'Assistant message'), + ] + const provenance = { operationId: input.run.operationId } + const edges = [ + graphEdge({ + kind: 'continued', + source: branchReference, + destination: turnReference, + at: input.at, + provenance, + }), + graphEdge({ + kind: 'attached', + source: turnReference, + destination: userReference, + at: input.at, + provenance, + }), + graphEdge({ + kind: 'attached', + source: turnReference, + destination: runReference, + at: input.at, + provenance, + }), + graphEdge({ + kind: 'attached', + source: runReference, + destination: assistantReference, + at: input.at, + provenance, + }), + ] + return { + ...state, + branches: upsert(state.branches, { + ...branch, + tipMessageId: input.assistantMessage.id, + updatedAt: input.at, + }), + conversations: upsert(state.conversations, { + ...conversation, + activeBranchId: branch.id, + updatedAt: input.at, + }), + graphNodes: nodes.reduce(upsert, state.graphNodes), + graphEdges: edges.reduce(upsert, state.graphEdges), + } +} diff --git a/src/domain/reducer-runtime.ts b/src/domain/reducer-runtime.ts new file mode 100644 index 0000000..0fbcd56 --- /dev/null +++ b/src/domain/reducer-runtime.ts @@ -0,0 +1,178 @@ +import type { BraidEvent, BraidEventEnvelope } from './events.js' +import { reduceContentEvent } from './reducer-content.js' +import { reduceInteractionEvent } from './reducer-interactions.js' +import { reduceLifecycleEvent } from './reducer-lifecycle.js' +import type { BraidState } from './state.js' + +type RuntimeEventKind = + | 'run.requested' + | 'run.text.delta' + | 'run.part.updated' + | 'run.reasoning.delta' + | 'run.tool.call' + | 'run.tool.result' + | 'run.artifact' + | 'run.proposal' + | 'run.warning' + | 'run.error' + | 'run.usage' + | 'run.cost' + | 'run.interaction' + | 'run.interaction.cancelled' + | 'run.interaction.response.requested' + | 'run.interaction.responded' + | 'run.provider.event' + | 'run.finished' + | 'run.status.changed' + | 'run.control.requested' + | 'run.control.acknowledged' + | 'run.queue.added' + | 'run.queue.removed' + | 'run.detached' + | 'run.reconnecting' + | 'run.reconciled' + | 'run.unknown' + +type RuntimeEvent = Extract + +export function reduceRuntimeEvent(state: BraidState, envelope: BraidEventEnvelope): BraidState { + const base = { revision: envelope.revision, sequence: envelope.sequence } + const event = envelope.event + if (!isRuntimeEvent(event)) return state + const reduced = (() => { + switch (event.kind) { + case 'run.requested': + case 'run.control.requested': + case 'run.control.acknowledged': + case 'run.queue.added': + case 'run.queue.removed': + case 'run.detached': + case 'run.reconnecting': + case 'run.unknown': + return reduceLifecycleEvent(state, event, base, envelope.occurredAt) + case 'run.text.delta': + case 'run.part.updated': + case 'run.reasoning.delta': + case 'run.tool.call': + case 'run.tool.result': + case 'run.artifact': + case 'run.proposal': + case 'run.warning': + case 'run.error': + case 'run.usage': + case 'run.cost': + return reduceContentEvent(state, event, base) + case 'run.interaction': + case 'run.interaction.cancelled': + case 'run.interaction.response.requested': + case 'run.interaction.responded': + case 'run.provider.event': + case 'run.finished': + return reduceInteractionEvent(state, event, base) + case 'run.status.changed': + return reduceInteractionEvent(state, event, base) + case 'run.reconciled': + return reduceLifecycleEvent(state, event, base, envelope.occurredAt) + default: { + const exhaustive: never = event + return exhaustive + } + } + })() + if (!('provider' in event) || !event.provider) return reduced + const runId = event.runId + const sequence = event.provider.providerSequence + const missingHistory = reduced.missingHistory.flatMap((range) => { + const toSequence = range.toSequence ?? range.fromSequence + if (range.runId !== runId || sequence < range.fromSequence) return [range] + if (sequence > toSequence) return [range] + if (range.fromSequence === toSequence) return [] + if (sequence === range.fromSequence) return [{ ...range, fromSequence: sequence + 1 }] + if (sequence === toSequence) return [{ ...range, toSequence: sequence - 1 }] + return [ + { ...range, toSequence: sequence - 1 }, + { ...range, fromSequence: sequence + 1 }, + ] + }) + const next = + missingHistory.length === reduced.missingHistory.length + ? reduced + : { ...reduced, missingHistory } + const incompleteRuns = new Set(missingHistory.map((range) => range.runId)) + const completedRunIds = new Set( + next.runs + .filter( + (candidate) => + !candidate.complete && + candidate.status !== 'unknown' && + ['completed', 'failed', 'aborted', 'cancelled', 'blocked', 'expired'].includes( + candidate.status, + ) && + !incompleteRuns.has(candidate.id), + ) + .map((candidate) => candidate.id), + ) + if (completedRunIds.size === 0) return next + return { + ...next, + messages: next.messages.map((message) => + message.runId && completedRunIds.has(message.runId) && message.role === 'assistant' + ? { + ...message, + complete: true, + status: + next.runs.find((candidate) => candidate.id === message.runId)?.status === 'completed' + ? 'complete' + : next.runs.find((candidate) => candidate.id === message.runId)?.status === 'failed' + ? 'failed' + : message.status, + } + : message, + ), + runs: next.runs.map((candidate) => + completedRunIds.has(candidate.id) ? { ...candidate, complete: true } : candidate, + ), + } +} + +export function isRuntimeEvent(event: BraidEvent): event is RuntimeEvent { + switch (event.kind) { + case 'run.requested': + return event.receipt !== undefined + case 'run.text.delta': + return event.provider !== undefined + case 'run.finished': + return ( + event.provider !== undefined || event.reason !== undefined || event.status === 'cancelled' + ) + case 'run.status.changed': + return 'provider' in event || 'detail' in event + case 'run.reconciled': + return 'from' in event || 'to' in event + case 'run.part.updated': + case 'run.reasoning.delta': + case 'run.tool.call': + case 'run.tool.result': + case 'run.artifact': + case 'run.proposal': + case 'run.warning': + case 'run.usage': + case 'run.cost': + case 'run.error': + case 'run.interaction': + case 'run.interaction.cancelled': + case 'run.interaction.response.requested': + case 'run.interaction.responded': + case 'run.provider.event': + case 'run.control.requested': + case 'run.control.acknowledged': + case 'run.queue.added': + case 'run.queue.removed': + case 'run.detached': + case 'run.reconnecting': + case 'run.unknown': + return true + default: + return false + } +} diff --git a/src/domain/reducer-support.ts b/src/domain/reducer-support.ts new file mode 100644 index 0000000..6566c4c --- /dev/null +++ b/src/domain/reducer-support.ts @@ -0,0 +1,188 @@ +import type { + BraidEvent, + BraidEventEnvelope, + ProviderEventMeta, + RunTerminalStatus, +} from './events.js' +import type { + BraidActivity, + BraidMessage, + BraidMessagePart, + BraidRun, + BraidState, + MessagePartSource, + MessageStatus, + RunStatus, +} from './state.js' + +export const TERMINAL_RUN_STATES: readonly RunStatus[] = [ + 'completed', + 'failed', + 'aborted', + 'cancelled', + 'blocked', + 'expired', + 'unknown', +] + +export const MAX_RUN_PARTS = 512 +export const MAX_RUN_ACTIVITY_ITEMS = 512 +export const MAX_RUN_EVENT_DETAILS = 512 +export const MAX_RUN_INTERACTIONS = 256 + +export function assertNextEnvelope(state: BraidState, envelope: BraidEventEnvelope): void { + if (envelope.sequence !== state.sequence + 1) { + throw new Error(`Event sequence ${envelope.sequence} does not follow ${state.sequence}`) + } + if (envelope.revision !== state.revision + 1) { + throw new Error(`Event revision ${envelope.revision} does not follow ${state.revision}`) + } +} + +export function sourceFromProvider( + provider: ProviderEventMeta | undefined, +): MessagePartSource | undefined { + if (!provider) return undefined + return { + eventId: provider.eventId, + sequence: provider.providerSequence, + ...(provider.cursor === undefined ? {} : { cursor: provider.cursor }), + ...(provider.occurredAt === undefined ? {} : { occurredAt: provider.occurredAt }), + } +} + +export function activity( + event: BraidEvent, + type: string, + label: string, + detail?: string, + source?: MessagePartSource, +): BraidActivity { + const runId = 'runId' in event ? event.runId : 'run-unknown' + return { + id: `${runId}:${type}:${source?.eventId ?? 'local'}:${label}`, + runId, + type, + label, + ...(detail === undefined ? {} : { detail }), + ...(source === undefined ? {} : { source }), + } +} + +export function terminalMessageStatus(status: RunTerminalStatus): MessageStatus { + switch (status) { + case 'completed': + return 'complete' + case 'failed': + return 'failed' + case 'aborted': + return 'aborted' + case 'cancelled': + return 'cancelled' + case 'blocked': + return 'blocked' + case 'expired': + return 'expired' + case 'unknown': + return 'unknown' + default: { + const exhaustive: never = status + return exhaustive + } + } +} + +export function findRun(state: BraidState, runId: string): BraidRun { + const run = state.runs.find((candidate) => candidate.id === runId) + if (!run) throw new Error(`Run ${runId} is unknown`) + return run +} + +export function updateRun( + state: BraidState, + runId: string, + update: (run: BraidRun) => BraidRun, +): readonly BraidRun[] { + return state.runs.map((run) => (run.id === runId ? update(run) : run)) +} + +export function updateMessage( + state: BraidState, + runId: string, + update: (message: BraidMessage) => BraidMessage, +): readonly BraidMessage[] { + return state.messages.map((message) => + message.runId === runId && message.role === 'assistant' ? update(message) : message, + ) +} + +export function upsertPart( + message: BraidMessage, + part: BraidMessagePart, + delta?: string, +): BraidMessage { + const existing = message.parts.find((candidate) => candidate.id === part.id) + const nextPart: BraidMessagePart = + existing && delta !== undefined + ? { + ...part, + text: + part.text === `${existing.text ?? ''}${delta}` + ? part.text + : `${existing.text ?? ''}${delta}`, + } + : part + const updatedParts = existing + ? message.parts.map((candidate) => (candidate.id === part.id ? nextPart : candidate)) + : [...message.parts, nextPart] + const partsTruncated = updatedParts.length > MAX_RUN_PARTS + const parts = partsTruncated ? updatedParts.slice(-MAX_RUN_PARTS) : updatedParts + const nextText = + nextPart.kind === 'text' + ? delta === undefined + ? (nextPart.text ?? message.text) + : `${message.text}${delta}` + : message.text + return { + ...message, + text: nextText, + parts, + ...(partsTruncated ? { partsTruncated: true } : {}), + } +} + +export function withProviderProgress( + run: BraidRun, + provider: ProviderEventMeta | undefined, +): BraidRun { + if (!provider) return run + if (provider.providerSequence <= run.lastProviderSequence) return run + const missingSequence = + provider.providerSequence > run.lastProviderSequence + 1 + ? { from: run.lastProviderSequence + 1, to: provider.providerSequence - 1 } + : undefined + return { + ...run, + lastProviderSequence: provider.providerSequence, + eventCount: run.eventCount + 1, + ...(provider.cursor === undefined ? {} : { lastCursor: provider.cursor }), + ...(missingSequence === undefined ? {} : { missingSequence }), + } +} + +export function addActivity(run: BraidRun, item: BraidActivity): BraidRun { + const activity = [...run.activity, item] + return { + ...run, + activity: activity.slice(-MAX_RUN_ACTIVITY_ITEMS), + ...(activity.length > MAX_RUN_ACTIVITY_ITEMS ? { activityTruncated: true } : {}), + } +} + +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}`) + } +} + +export type ReducerBase = Pick diff --git a/src/domain/reducer.ts b/src/domain/reducer.ts index ad3bae0..cadddd4 100644 --- a/src/domain/reducer.ts +++ b/src/domain/reducer.ts @@ -1,135 +1,8 @@ -import type { AgentTaskStatus } from '@tangle-network/agent-runtime' -import type { BraidEventEnvelope } from './events.js' -import type { BraidMessage, BraidRun, BraidState, MessageStatus, RunStatus } from './state.js' - -function assertNextEnvelope(state: BraidState, envelope: BraidEventEnvelope): void { - if (envelope.sequence !== state.sequence + 1) { - throw new Error(`Event sequence ${envelope.sequence} does not follow ${state.sequence}`) - } - if (envelope.revision !== state.revision + 1) { - throw new Error(`Event revision ${envelope.revision} does not follow ${state.revision}`) - } -} - -function terminalStatus(status: AgentTaskStatus): { - message: MessageStatus - run: RunStatus -} { - switch (status) { - case 'completed': - return { message: 'complete', run: 'completed' } - case 'failed': - return { message: 'failed', run: 'failed' } - case 'aborted': - return { message: 'aborted', run: 'aborted' } - case 'blocked': - return { message: 'blocked', run: 'blocked' } - default: - throw new Error(`Unknown terminal status: ${status}`) - } -} - -export function reduceEvent(state: BraidState, envelope: BraidEventEnvelope): BraidState { - assertNextEnvelope(state, envelope) - const base = { revision: envelope.revision, sequence: envelope.sequence } - const event = envelope.event - - switch (event.kind) { - case 'workspace.opened': - return { ...state, ...base, workspace: event.workspace } - case 'draft.changed': - return { ...state, ...base, draft: event.text } - case 'run.requested': { - if (state.activeRunId) throw new Error(`Run ${state.activeRunId} is already active`) - const userMessage: BraidMessage = { - id: event.userMessageId, - role: 'user', - text: event.text, - status: 'complete', - runId: event.runId, - } - const assistantMessage: BraidMessage = { - id: event.assistantMessageId, - role: 'assistant', - text: '', - status: 'streaming', - runId: event.runId, - } - const run: BraidRun = { - id: event.runId, - turnId: event.turnId, - operationId: event.operationId, - status: 'streaming', - inputTokens: 0, - outputTokens: 0, - } - return { - ...state, - ...base, - draft: '', - activeRunId: event.runId, - lastError: null, - messages: [...state.messages, userMessage, assistantMessage], - runs: [...state.runs, run], - } - } - case 'run.text.delta': { - if (state.activeRunId !== event.runId) { - throw new Error(`Text arrived for inactive run ${event.runId}`) - } - return { - ...state, - ...base, - messages: state.messages.map((message) => - message.runId === event.runId && message.role === 'assistant' - ? { ...message, text: message.text + event.text } - : message, - ), - } - } - case 'run.finished': { - const statuses = terminalStatus(event.status) - const runExists = state.runs.some((run) => run.id === event.runId) - if (!runExists) throw new Error(`Result arrived for unknown run ${event.runId}`) - return { - ...state, - ...base, - activeRunId: state.activeRunId === event.runId ? null : state.activeRunId, - lastError: event.error ?? null, - messages: state.messages.map((message) => - message.runId === event.runId && message.role === 'assistant' - ? { - ...message, - text: event.finalText || message.text, - status: statuses.message, - } - : message, - ), - runs: state.runs.map((run) => - run.id === event.runId - ? { - ...run, - status: statuses.run, - inputTokens: event.usage.input, - outputTokens: event.usage.output, - ...(event.usage.costUsd === undefined ? {} : { costUsd: event.usage.costUsd }), - ...(event.usage.model === undefined ? {} : { model: event.usage.model }), - ...(event.error === undefined ? {} : { error: event.error }), - } - : run, - ), - } - } - default: { - const exhaustive: never = event - return exhaustive - } - } -} - -export function replayEvents( - initial: BraidState, - events: readonly BraidEventEnvelope[], -): BraidState { - return events.reduce(reduceEvent, initial) -} +export { DuplicateEventConflictError, SequenceGapError } from './reducer-helpers.js' +export { + initialDomainState, + reduceEvent, + replayEvents, + replayJournal, +} from './reducer-replay.js' +export type { DomainEventPayload } from './reducer-replay.js' diff --git a/src/domain/run-contracts.ts b/src/domain/run-contracts.ts new file mode 100644 index 0000000..874df92 --- /dev/null +++ b/src/domain/run-contracts.ts @@ -0,0 +1,144 @@ +import type { AgentEnvironmentCapabilities, AgentProfile } from '@tangle-network/agent-interface' + +export interface RunControlCapabilities { + readonly cancel: boolean + readonly steer: boolean + readonly queue: boolean + readonly status: boolean + readonly recreate: boolean +} + +export interface RunEventCapabilities { + readonly stableIdentity: boolean + readonly sequence: boolean + readonly cursor: boolean +} + +export interface RunCapabilities { + readonly streaming: { + readonly live: boolean + readonly replay: boolean + readonly detach: boolean + readonly turnIdempotency: boolean + } + readonly sessions: { + readonly continue: boolean + readonly messages: boolean + } + readonly controls: RunControlCapabilities + readonly events: RunEventCapabilities + readonly usage: boolean + readonly environment?: Readonly +} + +export interface PortableContextMessage { + readonly id: string + readonly role: 'user' | 'assistant' | 'system' | 'tool' + readonly parts: readonly PortableContextPart[] +} + +export interface PortableAnalysisCitation { + readonly id: string + readonly eventId?: string + readonly messageId?: string + readonly partId?: string + readonly start?: number + readonly end?: number + readonly quote?: string +} + +export interface PortableAnalysisAttachment { + readonly analysisId: string + readonly analysisRunId?: string + readonly sourceDigest: string + readonly sourceRunId?: string + readonly findings: readonly { + readonly id: string + readonly text: string + readonly citations: readonly PortableAnalysisCitation[] + }[] + readonly provenance: Readonly<{ + readonly analystProfileDigest?: string + readonly model?: string + readonly runner?: string + readonly agentEvalVersion?: string + }> +} + +export type PortableContextPart = + | { readonly id: string; readonly type: 'text'; readonly text: string } + | { readonly id: string; readonly type: 'reasoning'; readonly text: string } + | { + readonly id: string + readonly type: 'file' + readonly uri: string + readonly mediaType?: string + } + | { + readonly id: string + readonly type: 'artifact' + readonly uri: string + readonly mediaType?: string + } + | { readonly id: string; readonly type: 'unknown'; readonly summary: string } + +export interface PortableContextPlan { + readonly sourceRunId: string + readonly sourceBoundary: string + readonly destinationRunner?: string + readonly messages: readonly PortableContextMessage[] + readonly analysisAttachments?: readonly PortableAnalysisAttachment[] + readonly omittedPartIds: readonly string[] + readonly transformedPartIds: readonly string[] + readonly complete: boolean + readonly tokenEstimate?: number + readonly digest: string +} + +export interface ContextTransferReceipt { + readonly planDigest: string + readonly sourceRunId: string + readonly destinationRunId: string + readonly destinationSessionId?: string + readonly acceptedAt: string +} + +export interface NativeContextBoundaryProof { + readonly runId: string + readonly providerSessionId: string + readonly boundary: string + readonly revision?: string + readonly digest: string +} + +export interface RunAdmissionReceipt { + readonly version: 1 + readonly runId: string + readonly turnId: string + readonly operationId: string + readonly conversationId: string + readonly branchId: string + readonly admittedAt: string + readonly profileDigest: string + readonly requested: { + readonly text: string + readonly profile: Readonly + readonly connectionId?: string + readonly model?: string + readonly runner?: string + readonly contextPlanDigest?: string + } + readonly capabilities: RunCapabilities + readonly provider?: string + readonly environmentId?: string + readonly providerSessionId?: string + readonly materializationReceipt?: Readonly> + readonly contextTransfer?: ContextTransferReceipt + readonly nativeContextBoundaryProof?: NativeContextBoundaryProof + readonly warnings?: readonly string[] + readonly admissionStatus?: 'admitted' | 'pending' | 'unavailable' + readonly requestDigest: string + readonly capabilitiesDigest: string + readonly materializationDigest?: string + readonly digest: string +} diff --git a/src/domain/runtime-events.ts b/src/domain/runtime-events.ts new file mode 100644 index 0000000..471e138 --- /dev/null +++ b/src/domain/runtime-events.ts @@ -0,0 +1,62 @@ +import type { InteractionRequest, StreamEvent } from '@tangle-network/agent-interface' +import type { RuntimeStreamEvent } from '@tangle-network/agent-runtime' + +export type BraidRuntimeEvent = + | RuntimeStreamEvent + | StreamEvent + | { + readonly type: 'unknown' + readonly originalType: string + readonly payload: unknown + } + +export interface RuntimeEventEnvelope { + readonly runId: string + readonly eventId: string + readonly sequence: number + readonly cursor?: string + readonly occurredAt?: string + readonly receivedAt: string + readonly event: BraidRuntimeEvent +} + +export interface RuntimeEventSummary { + readonly eventId: string + readonly sequence: number + readonly type: string + readonly cursor?: string + readonly occurredAt?: string +} + +export function isRuntimeEventEnvelope( + value: BraidRuntimeEvent | RuntimeEventEnvelope, +): value is RuntimeEventEnvelope { + return ( + typeof value === 'object' && + value !== null && + 'runId' in value && + 'eventId' in value && + 'sequence' in value && + 'event' in value + ) +} + +export function isInteractionEvent( + event: BraidRuntimeEvent, +): event is Extract { + return event.type === 'interaction' && 'request' in event +} + +export function isFinalRuntimeEvent( + event: BraidRuntimeEvent, +): event is Extract { + return event.type === 'final' +} + +export function runtimeEventType(event: BraidRuntimeEvent): string { + return event.type +} + +export function interactionFromEvent(event: BraidRuntimeEvent): InteractionRequest | undefined { + return isInteractionEvent(event) ? event.request : undefined +} diff --git a/src/domain/runtime-projection.ts b/src/domain/runtime-projection.ts new file mode 100644 index 0000000..f4430ed --- /dev/null +++ b/src/domain/runtime-projection.ts @@ -0,0 +1,96 @@ +import type { InteractionRequest } from '@tangle-network/agent-interface' +import type { RunAdmissionReceipt, RunCapabilities } from './run-contracts.js' +import type { RuntimeEventSummary } from './runtime-events.js' + +export interface MessagePartSource { + readonly eventId?: string + readonly sequence?: number + readonly cursor?: string + readonly occurredAt?: string +} + +export type RuntimeMessagePartKind = + | 'text' + | 'reasoning' + | 'tool-call' + | 'tool-result' + | 'artifact' + | 'proposal' + | 'warning' + | 'error' + | 'interaction' + | 'system' + | 'unknown' + +export interface RuntimeMessagePart { + readonly id: string + readonly kind: RuntimeMessagePartKind + readonly text?: string + readonly status?: string + readonly toolName?: string + readonly callId?: string + readonly input?: unknown + readonly result?: unknown + readonly error?: string + readonly artifactId?: string + readonly uri?: string + readonly mimeType?: string + readonly title?: string + readonly metadata?: Readonly> + readonly source?: MessagePartSource +} + +export interface RuntimeMessageFields { + readonly parts: readonly RuntimeMessagePart[] + readonly partsTruncated?: boolean +} + +export interface BraidInteraction { + readonly request: InteractionRequest + readonly runId: string + readonly source: MessagePartSource + readonly status: 'pending' | 'responding' | 'declined' | 'cancelled' | 'resolved' | 'unknown' +} + +export interface BraidActivity { + readonly id: string + readonly runId: string + readonly type: string + readonly label: string + readonly detail?: string + readonly source?: MessagePartSource +} + +export interface QueuedInput { + readonly operationId: string + readonly runId: string + readonly text: string + readonly position: number +} + +export interface RuntimeRunFields { + readonly receipt: RunAdmissionReceipt + readonly capabilities: RunCapabilities + readonly reasoningTokens?: number + readonly terminalReason?: string + readonly lastCursor?: string + readonly lastProviderSequence: number + readonly eventCount: number + readonly contentBytes?: number + readonly contentTruncated?: boolean + readonly missingSequence?: { readonly from: number; readonly to: number } + readonly interactions: readonly BraidInteraction[] + readonly activity: readonly BraidActivity[] + readonly eventDetails: readonly RuntimeEventSummary[] + readonly activityTruncated?: boolean + readonly eventDetailsTruncated?: boolean + readonly interactionsTruncated?: boolean +} + +export const LEGACY_RUN_CAPABILITIES: RunCapabilities = Object.freeze({ + 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, +}) diff --git a/src/domain/secret-sanitizer.ts b/src/domain/secret-sanitizer.ts new file mode 100644 index 0000000..0d578fc --- /dev/null +++ b/src/domain/secret-sanitizer.ts @@ -0,0 +1,303 @@ +import { TerminalControlSanitizer } from './terminal-sanitizer.js' + +export const MAX_SANITIZED_TEXT_BYTES = 512 * 1024 +export const SECRET_TRUNCATION_MARKER = '… [truncated]' +const MAX_PENDING_BYTES = 4096 +const MAX_LOOKBEHIND_CHARS = 1024 +const BIDI_CONTROLS = /\p{Bidi_Control}/gu +const SECRET_ASSIGNMENT = + /(^|[\s,;{[(])(?:password|passwd|passphrase|token|secret|credential|authorization|auth|key|api[_-]?key|access[_-]?key|private[_-]?key|client[_-]?secret|signature|cookie|header|query|fragment)\s*[:=]\s*(?:"[^"]*"|'[^']*'|[^\s,;}\])}]*)/giu +const BEARER_ASSIGNMENT = /\bBearer(?:\s+|\s*=\s*)[^\s,;]*/giu +const BARE_CREDENTIAL = + /(?:sk-[A-Za-z0-9_-]{20,}|github_pat_[A-Za-z0-9_]{20,}|gh[pousr]_[A-Za-z0-9]{20,}|AKIA[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{30,}|xox[baprs]-[A-Za-z0-9-]{20,})/gu +const WEB_URL = /https?:\/\/[^\s\p{Cc}<>"']+/giu +const INCOMPLETE_BEARER = /\bBearer(?:[\s]+|[\s]*=[\s]*)$/iu +const INCOMPLETE_URL = /(^|[\s([{<])https?:\/\/[^\s\p{Cc}<>"']*$/iu +const INCOMPLETE_ASSIGNMENT = + /(^|[\s,;{[(])(?:password|passwd|passphrase|token|secret|credential|authorization|auth|key|api[_-]?key|access[_-]?key|private[_-]?key|client[_-]?secret|signature|cookie|header|query|fragment)\s*[:=]\s*(?:"[^"]*|'[^']*|[^\s,;}\])}]*)$/iu + +const INCOMPLETE_BARE_CREDENTIAL = + /(?:sk-|github_pat_|gh[pousr]_|AKIA|AIza|xox[baprs]-)[A-Za-z0-9_-]*$/u + +type PendingSecret = 'bearer' | 'assignment' | 'bare' | 'url' + +function isUrlDelimiter(character: string | undefined): boolean { + if (character === undefined || /\s/u.test(character)) return true + const code = character.codePointAt(0) ?? 0 + return code <= 0x20 || code === 0x7f || code === 0x1b || '<>"\''.includes(character) +} + +function safeUrl(candidate: string): boolean { + try { + const url = new URL(candidate) + if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) return false + if (url.pathname.split('/').some((segment) => segment !== '' && isSensitiveName(segment))) + return false + for (const key of url.searchParams.keys()) if (isSensitiveName(key)) return false + const fragment = url.hash.slice(1) + if (fragment) { + for (const key of new URLSearchParams(fragment).keys()) if (isSensitiveName(key)) return false + } + return true + } catch { + return false + } +} + +function isSensitiveName(value: string): boolean { + const normalized = value.replace(/[^a-z0-9]/giu, '').toLowerCase() + return ( + normalized.includes('password') || + normalized.includes('passphrase') || + normalized.includes('token') || + normalized.includes('secret') || + normalized.includes('authorization') || + normalized.includes('credential') || + normalized.includes('apikey') || + normalized.includes('accesskey') || + normalized.includes('privatekey') || + normalized.includes('cookie') || + normalized.includes('header') || + normalized.includes('query') || + normalized.includes('fragment') || + normalized === 'key' || + normalized.endsWith('key') + ) +} + +function redactUrls(value: string): string { + return value.replace(WEB_URL, (candidate) => { + let url = candidate + let suffix = '' + while (/[),.;!?\]}]$/u.test(url)) { + suffix = `${url.at(-1)}${suffix}` + url = url.slice(0, -1) + } + return safeUrl(url) ? candidate : `[redacted link]${suffix}` + }) +} + +function redactStable(value: string): string { + return redactUrls(value) + .replace(BEARER_ASSIGNMENT, '[redacted bearer]') + .replace(SECRET_ASSIGNMENT, (_match, prefix: string) => `${prefix}[redacted secret]`) + .replace(BARE_CREDENTIAL, '[redacted credential]') + .replace(BIDI_CONTROLS, '') +} + +function takeUtf8Prefix(value: string, bytes: number): string { + if (Buffer.byteLength(value, 'utf8') <= bytes) return value + let output = '' + let used = 0 + for (const character of value) { + const size = Buffer.byteLength(character, 'utf8') + if (used + size > bytes) break + output += character + used += size + } + return output +} + +function splitTail(value: string, bytes: number): [string, string] { + if (Buffer.byteLength(value, 'utf8') <= bytes) return ['', value] + const prefix = takeUtf8Prefix(value, Buffer.byteLength(value, 'utf8') - bytes) + return [prefix, value.slice(prefix.length)] +} + +export class SecretTextSanitizer { + readonly #terminal = new TerminalControlSanitizer() + readonly #maxOutputBytes: number + #pending = '' + #pendingSecret: PendingSecret | undefined + #outputBytes = 0 + #truncated = false + + constructor(maxOutputBytes = MAX_SANITIZED_TEXT_BYTES) { + this.#maxOutputBytes = maxOutputBytes + } + + push(input: string): string { + this.#pending += this.#terminal.push(input) + return this.#drain(false) + } + + finish(): string { + this.#pending += this.#terminal.finish() + const result = this.#drain(true) + if (this.#truncated && this.#outputBytes < this.#maxOutputBytes) { + const marker = SECRET_TRUNCATION_MARKER + const boundedMarker = takeUtf8Prefix(marker, this.#maxOutputBytes - this.#outputBytes) + this.#outputBytes += Buffer.byteLength(boundedMarker, 'utf8') + return result + boundedMarker + } + return result + } + + #drain(final: boolean): string { + if (this.#pendingSecret !== undefined) { + this.#consumePendingSecret(final) + if (this.#pendingSecret !== undefined) return '' + } + if (this.#pending.length === 0) return '' + const pendingBytes = Buffer.byteLength(this.#pending, 'utf8') + if (!final && pendingBytes <= MAX_PENDING_BYTES) return '' + const [stable, tail] = final + ? [this.#pending, ''] + : splitTail(this.#pending, MAX_LOOKBEHIND_CHARS) + this.#pending = tail + if (!stable) return '' + const incomplete = final ? undefined : incompleteSecret(stable, tail) + if (incomplete !== undefined) { + const prefix = stable.slice(0, incomplete.start) + const boundary = incomplete.boundary + this.#pendingSecret = incomplete.kind + this.#pending = tail + const before = redactStable(prefix + boundary) + const marker = incomplete.kind === 'bearer' ? '[redacted bearer]' : '[redacted secret]' + const emitted = this.#append(before + marker) + this.#consumePendingSecret(final) + return emitted + } + const incompleteCredential = final ? undefined : incompleteBareCredential(stable, tail) + if (incompleteCredential !== undefined) { + const prefix = stable.slice(0, incompleteCredential.start) + const emitted = this.#append(`${redactStable(prefix)}[redacted credential]`) + this.#pendingSecret = 'bare' + this.#pending = tail + this.#consumePendingSecret(final) + return emitted + } + const incompleteUrl = final ? undefined : incompleteUrlAtBoundary(stable, tail) + if (incompleteUrl !== undefined) { + const prefix = stable.slice(0, incompleteUrl.start) + const emitted = this.#append( + `${redactStable(prefix + incompleteUrl.boundary)}[redacted link]`, + ) + this.#pendingSecret = 'url' + this.#pending = tail + this.#consumePendingSecret(final) + return emitted + } + const redacted = redactStable(stable) + return this.#append(redacted) + } + + #append(value: string): string { + const remaining = this.#maxOutputBytes - this.#outputBytes + if (remaining <= 0) { + if (value.length > 0) this.#truncated = true + return '' + } + const bounded = takeUtf8Prefix(value, remaining) + this.#outputBytes += Buffer.byteLength(bounded, 'utf8') + if (bounded.length !== value.length) this.#truncated = true + return bounded + } + + #consumePendingSecret(final: boolean): void { + if (this.#pendingSecret === undefined) return + const value = this.#pending + if (this.#pendingSecret === 'url') { + const delimiter = [...value].findIndex(isUrlDelimiter) + if (delimiter < 0) { + this.#pending = final ? '' : value.slice(-MAX_LOOKBEHIND_CHARS) + if (final) this.#pendingSecret = undefined + return + } + this.#pending = value.slice(delimiter) + this.#pendingSecret = undefined + return + } + if (this.#pendingSecret === 'bare') { + const delimiter = value.search(/[^A-Za-z0-9_-]/u) + if (delimiter < 0) { + this.#pending = final ? '' : value.slice(-MAX_LOOKBEHIND_CHARS) + if (final) this.#pendingSecret = undefined + return + } + this.#pending = value.slice(delimiter) + this.#pendingSecret = undefined + return + } + let index = 0 + while (index < value.length && /\s/u.test(value[index] ?? '')) index += 1 + if (index === value.length) { + this.#pending = final ? '' : value.slice(-MAX_LOOKBEHIND_CHARS) + if (final) this.#pendingSecret = undefined + return + } + const quoted = value[index] === '"' || value[index] === "'" + if (quoted) { + const quote = value[index] ?? '' + const closing = value.indexOf(quote, index + 1) + if (closing < 0) { + this.#pending = final ? '' : value.slice(-MAX_LOOKBEHIND_CHARS) + if (final) this.#pendingSecret = undefined + return + } + this.#pending = value.slice(closing + 1) + } else { + const delimiter = value.slice(index).search(/[\s,;}\])}]/u) + if (delimiter < 0) { + this.#pending = final ? '' : value.slice(-MAX_LOOKBEHIND_CHARS) + if (final) this.#pendingSecret = undefined + return + } + this.#pending = value.slice(index + delimiter) + } + this.#pendingSecret = undefined + } +} + +function incompleteSecret( + stable: string, + tail: string, +): { readonly kind: PendingSecret; readonly start: number; readonly boundary: string } | undefined { + const bearer = INCOMPLETE_BEARER.exec(stable) + if (bearer !== null) return { kind: 'bearer', start: bearer.index, boundary: '' } + const assignment = INCOMPLETE_ASSIGNMENT.exec(stable) + if (assignment === null || /^[\s,;}\])}]/u.test(tail[0] ?? '')) return undefined + return { + kind: 'assignment', + start: assignment.index, + boundary: assignment[1] ?? '', + } +} + +function incompleteUrlAtBoundary( + stable: string, + tail: string, +): { readonly start: number; readonly boundary: string } | undefined { + if (isUrlDelimiter(tail[0])) return undefined + const match = INCOMPLETE_URL.exec(stable) + if (match === null) return undefined + return { start: match.index, boundary: match[1] ?? '' } +} + +function incompleteBareCredential( + stable: string, + tail: string, +): { readonly start: number } | undefined { + if (!/^[A-Za-z0-9_-]/u.test(tail)) return undefined + const match = INCOMPLETE_BARE_CREDENTIAL.exec(stable) + return match === null ? undefined : { start: match.index } +} + +export function redactSensitiveText(input: string, maxBytes = MAX_SANITIZED_TEXT_BYTES): string { + const sanitizer = new SecretTextSanitizer(maxBytes) + return `${sanitizer.push(input)}${sanitizer.finish()}` +} + +export function sanitizeTextChunks( + chunks: Iterable, + maxBytes = MAX_SANITIZED_TEXT_BYTES, +): string { + const sanitizer = new SecretTextSanitizer(maxBytes) + let output = '' + for (const chunk of chunks) output += sanitizer.push(chunk) + return output + sanitizer.finish() +} + +export function redactSensitiveUrls(input: string): string { + return redactUrls(input) +} diff --git a/src/domain/state.ts b/src/domain/state.ts index 6385e21..ecfff14 100644 --- a/src/domain/state.ts +++ b/src/domain/state.ts @@ -1,57 +1,179 @@ import type { AgentProfile } from '@tangle-network/agent-interface' +import type { + AnalysisAttachmentRecord, + AnalysisRecord, + AppliedEventRecord, + AutomationRuleRecord, + BindingRecord, + BraidMessage, + BraidRun, + BranchRecord, + CheckpointRecord, + ConnectionRecord, + ConversationRecord, + CredentialReference, + DraftRecord, + EffectRecord, + EnvironmentRecord, + FeedbackDecisionRecord, + GraphEdgeRecord, + GraphNodeRecord, + InteractionRecord, + MessagePartRecord, + MissingHistoryRange, + OperationRecord, + ProfileRecord, + ProfileSnapshotRecord, + QueueEntryRecord, + QueueRecord, + ReplayCursorRecord, + SupervisorRecord, + TurnRecord, + UnknownEventRecord, + WorkerRecord, + WorkspaceRecord, +} from './entities.js' +import type { + BranchId, + ConnectionId, + ConversationId, + Digest, + ProfileId, + RunId, + WorkspaceId, +} from './ids.js' +import { createBranchId, createConversationId } from './ids.js' +import { redactProfile } from './redaction.js' +import type { QueuedInput } from './runtime-projection.js' -export type MessageRole = 'user' | 'assistant' -export type MessageStatus = 'complete' | 'streaming' | 'failed' | 'aborted' | 'blocked' -export type RunStatus = 'streaming' | 'completed' | 'failed' | 'aborted' | 'blocked' - -export interface BraidMessage { - readonly id: string - readonly role: MessageRole - readonly text: string - readonly status: MessageStatus - readonly runId?: string -} - -export interface BraidRun { - readonly id: string - readonly turnId: string - readonly operationId: string - readonly status: RunStatus - readonly inputTokens: number - readonly outputTokens: number - readonly costUsd?: number - readonly model?: string - readonly error?: string +export type { BraidMessage, BraidRun, MessageRole, MessageStatus, RunStatus } from './entities.js' +export type { + BraidActivity, + BraidInteraction, + MessagePartSource, + QueuedInput, + RuntimeMessagePart as BraidMessagePart, + RuntimeMessagePartKind as MessagePartKind, +} from './runtime-projection.js' +export interface StateHealth { + readonly status: 'healthy' | 'incomplete' | 'degraded' + readonly lastError: string | null + readonly missingHistoryCount: number + readonly unknownEventCount: number } export interface BraidState { - readonly schemaVersion: 1 + readonly schemaVersion: 2 readonly revision: number readonly sequence: number + + /** Compatibility projection retained for the W0 terminal and RPC paths. */ readonly workspace: string | null - readonly conversationId: string - readonly branchId: string + readonly workspaceId: WorkspaceId | null + readonly conversationId: ConversationId + readonly branchId: BranchId + readonly selectedProfileId: ProfileId | null + readonly selectedConnectionId: ConnectionId | null readonly profile: Readonly readonly draft: string readonly messages: readonly BraidMessage[] + readonly messageParts: readonly MessagePartRecord[] readonly runs: readonly BraidRun[] - readonly activeRunId: string | null + readonly activeRunId: RunId | null + readonly queuedInputs: readonly QueuedInput[] 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: readonly AutomationRuleRecord[] + 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 MissingHistoryRange[] + readonly appliedEvents: readonly AppliedEventRecord[] + readonly unknownEvents: readonly UnknownEventRecord[] + readonly projectionChecksum: Digest | null + readonly health: StateHealth } -export function initialState(profile: Readonly): BraidState { +export function initialState( + profile: Readonly, + identity: { readonly conversationId?: ConversationId; readonly branchId?: BranchId } = {}, +): BraidState { return { - schemaVersion: 1, + schemaVersion: 2, revision: 0, sequence: 0, workspace: null, - conversationId: 'conv-1', - branchId: 'branch-1', - profile, + workspaceId: null, + conversationId: identity.conversationId ?? createConversationId('conv-1'), + branchId: identity.branchId ?? createBranchId('branch-1'), + selectedProfileId: null, + selectedConnectionId: null, + profile: redactProfile(profile), draft: '', messages: [], + messageParts: [], runs: [], activeRunId: null, + queuedInputs: [], lastError: null, + workspaces: [], + profiles: [], + profileSnapshots: [], + credentials: [], + connections: [], + conversations: [], + branches: [], + turns: [], + interactions: [], + analyses: [], + analysisAttachments: [], + environments: [], + checkpoints: [], + supervisors: [], + workers: [], + drafts: [], + queues: [], + queueEntries: [], + rules: [], + bindings: [], + graphNodes: [], + graphEdges: [], + operations: [], + effects: [], + feedbackDecisions: [], + replayCursors: [], + missingHistory: [], + appliedEvents: [], + unknownEvents: [], + projectionChecksum: null, + health: { + status: 'healthy', + lastError: null, + missingHistoryCount: 0, + unknownEventCount: 0, + }, } } diff --git a/src/domain/terminal-sanitizer.ts b/src/domain/terminal-sanitizer.ts new file mode 100644 index 0000000..bbc3687 --- /dev/null +++ b/src/domain/terminal-sanitizer.ts @@ -0,0 +1,93 @@ +const MAX_CONTROL_BYTES = 4096 + +type ControlState = 'normal' | 'escape' | 'csi' | 'osc' | 'string' | 'string-escape' + +function isStringIntroducer(code: number): boolean { + return code === 0x90 || code === 0x98 || code === 0x9e || code === 0x9f +} + +function isC1(code: number): boolean { + return code >= 0x80 && code <= 0x9f +} + +/** Carries terminal-control state across arbitrary input chunks. */ +export class TerminalControlSanitizer { + #state: ControlState = 'normal' + #controlBytes = 0 + + push(input: string): string { + let output = '' + for (const character of input) { + const code = character.codePointAt(0) ?? 0 + if (this.#state !== 'normal') { + this.#consumeControl(character, code) + continue + } + if (character === '\u001b') { + this.#state = 'escape' + this.#controlBytes = 1 + } else if (character === '\u009b') { + this.#startControl('csi') + } else if (character === '\u009d') { + this.#startControl('osc') + } else if (isStringIntroducer(code)) { + this.#startControl('string') + } else if (character === '\u009c' || code === 0x7f || code < 0x20) { + if (character === '\n' || character === '\t') output += character + } else if (!isC1(code)) { + output += character + } + } + return output + } + + finish(): string { + this.#state = 'normal' + this.#controlBytes = 0 + return '' + } + + #startControl(state: ControlState): void { + this.#state = state + this.#controlBytes = 1 + } + + #consumeControl(character: string, code: number): void { + this.#controlBytes += 1 + if (this.#controlBytes > MAX_CONTROL_BYTES) { + this.#state = 'normal' + this.#controlBytes = 0 + return + } + if (this.#state === 'escape') { + if (character === '[') this.#state = 'csi' + else if (character === ']') this.#state = 'osc' + else if (character === 'P' || character === 'X' || character === '^' || character === '_') + this.#state = 'string' + else this.#state = 'normal' + return + } + if (this.#state === 'csi') { + if (character === '\u009c' || (code >= 0x40 && code <= 0x7e)) this.#reset() + return + } + if (this.#state === 'osc' || this.#state === 'string') { + if (character === '\u0007' || character === '\u009c') this.#reset() + else if (character === '\u001b') this.#state = 'string-escape' + return + } + if (this.#state === 'string-escape') { + this.#state = character === '\\' ? 'normal' : 'string' + } + } + + #reset(): void { + this.#state = 'normal' + this.#controlBytes = 0 + } +} + +export function stripTerminalControls(input: string): string { + const sanitizer = new TerminalControlSanitizer() + return `${sanitizer.push(input)}${sanitizer.finish()}` +} diff --git a/src/domain/text.ts b/src/domain/text.ts new file mode 100644 index 0000000..d6934eb --- /dev/null +++ b/src/domain/text.ts @@ -0,0 +1,18 @@ +export function containsUnsafeControlCharacter(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index) + if (code <= 8 || code === 11 || code === 12 || (code >= 14 && code <= 31) || code === 127) { + return true + } + } + return false +} + +const CANONICAL_ISO_DATE_TIME = + /^\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d\.\d{3}Z$/u + +export function isCanonicalIsoDateTime(value: unknown): value is string { + if (typeof value !== 'string' || !CANONICAL_ISO_DATE_TIME.test(value)) return false + const timestamp = Date.parse(value) + return Number.isFinite(timestamp) && new Date(timestamp).toISOString() === value +} diff --git a/src/eval/calibration.ts b/src/eval/calibration.ts new file mode 100644 index 0000000..36d9e13 --- /dev/null +++ b/src/eval/calibration.ts @@ -0,0 +1,323 @@ +import type { CostLedger } from '@tangle-network/agent-eval' +import { + type CampaignResult, + type ChatClient, + type JudgeConfig, + type JudgeScore, + llmJudge, + runCampaign, +} from '@tangle-network/agent-eval/contract' +import { artifactForScenario, scenariosForCalibration } from './cases.js' +import type { + CalibrationCategoryOutcome, + CalibrationCellRecord, + CalibrationPairOutcome, + CalibrationSummary, + SemanticEvalArtifact, + SemanticEvalCase, + SemanticEvalScenario, +} from './types.js' + +export const CALIBRATION_MIN_PAIRS = 12 +export const CALIBRATION_MIN_GOOD_RATE = 11 / 12 +export const CALIBRATION_TIE_TOLERANCE = 0.02 +export const CALIBRATION_MIN_MARGIN = 0.2 +export const CALIBRATION_MAX_TRIVIAL_RATIO = 0.8 + +export interface CalibrationCampaign { + readonly label: 'good' | 'bad' | 'trivial' + readonly definition: SemanticEvalCase + readonly result: CampaignResult +} + +export interface CalibrationRun { + readonly summary: CalibrationSummary + readonly campaigns: readonly CalibrationCampaign[] + readonly ledger: CostLedger +} + +export function semanticJudge( + definition: SemanticEvalCase, + chat: ChatClient, +): JudgeConfig { + const model = chat.defaultModel + return llmJudge( + `braid-${definition.id}-semantic-quality`, + definition.prompt, + { + chat, + ...(model === undefined ? {} : { model }), + judgeVersion: `braid-semantic-quality-v2-${definition.id}`, + dimensions: [...definition.dimensions], + temperature: 0, + maxTokens: 320, + renderUser: ({ artifact }) => + JSON.stringify( + { + semanticOutput: artifact.semanticOutput, + userFacingAnswer: artifact.candidateOutput, + candidateLabel: artifact.productPath === undefined ? null : 'release-product-output', + productPath: artifact.productPath ?? null, + }, + null, + 2, + ), + }, + ) +} + +type CampaignSet = Record< + 'good' | 'bad' | 'trivial', + CampaignResult +> + +function scoreFor( + result: CampaignResult, + scenarioId: string, + judgeName: string, +): JudgeScore | null { + const cell = result.cells.find((candidate) => candidate.scenarioId === scenarioId) + const score = cell?.judgeScores[judgeName] + return score === undefined || score.failed === true ? null : score +} + +function mean(scores: readonly (JudgeScore | null)[]): number { + const values = scores.flatMap((score) => (score === null ? [] : [score.composite])) + return values.length === 0 ? 0 : values.reduce((sum, value) => sum + value, 0) / values.length +} + +function cellsFor(definition: SemanticEvalCase, results: CampaignSet): CalibrationCellRecord[] { + const judgeName = `braid-${definition.id}-semantic-quality` + return definition.calibrationFixtures.flatMap((fixture, index) => { + const scenarioId = `${definition.id}-calibration-${index + 1}` + const pairId = `${definition.id}-pair-${index + 1}` + return (['good', 'bad', 'trivial'] as const).map((label) => { + const scenario = scenariosForCalibration(definition, label)[index] + const campaign = results[label] + return { + scenarioId, + pairId, + caseId: definition.id, + fixtureId: fixture.id, + semanticOutput: fixture.semanticOutput, + label, + output: scenario?.candidateOutput ?? '', + score: scoreFor(campaign, scenarioId, judgeName), + campaignCell: campaign.cells.find((cell) => cell.scenarioId === scenarioId)?.cellId ?? null, + } + }) + }) +} + +function pairOutcomes( + definitions: readonly SemanticEvalCase[], + cells: readonly CalibrationCellRecord[], +): CalibrationPairOutcome[] { + return definitions.flatMap((definition) => + definition.calibrationFixtures.map((fixture, index) => { + const pairId = `${definition.id}-pair-${index + 1}` + const pair = cells.filter((cell) => cell.pairId === pairId) + const good = pair.find((cell) => cell.label === 'good')?.score ?? null + const bad = pair.find((cell) => cell.label === 'bad')?.score ?? null + const goodScore = good?.composite ?? null + const badScore = bad?.composite ?? null + const delta = goodScore === null || badScore === null ? null : goodScore - badScore + const preference = + delta === null + ? 'unscorable' + : delta > CALIBRATION_TIE_TOLERANCE + ? 'good' + : delta < -CALIBRATION_TIE_TOLERANCE + ? 'bad' + : 'tie' + return { + pairId, + caseId: definition.id, + category: definition.category, + fixtureId: fixture.id, + goodScore, + badScore, + delta, + preference, + tieTolerance: CALIBRATION_TIE_TOLERANCE, + independentlyScored: goodScore !== null && badScore !== null, + } + }), + ) +} + +function categoryOutcomes( + definitions: readonly SemanticEvalCase[], + cells: readonly CalibrationCellRecord[], +): CalibrationCategoryOutcome[] { + return definitions.map((definition) => { + const categoryCells = cells.filter((cell) => cell.caseId === definition.id) + const good = categoryCells.filter((cell) => cell.label === 'good').map((cell) => cell.score) + const bad = categoryCells.filter((cell) => cell.label === 'bad').map((cell) => cell.score) + const trivial = categoryCells + .filter((cell) => cell.label === 'trivial') + .map((cell) => cell.score) + const pairs = pairOutcomes([definition], categoryCells) + const trivialValues = trivial.flatMap((score) => (score === null ? [] : [score.composite])) + const goodValues = good.flatMap((score) => (score === null ? [] : [score.composite])) + const goodMean = mean(good) + const trivialMean = mean(trivial) + return { + caseId: definition.id, + category: definition.category, + pairCount: pairs.length, + goodPreferred: pairs.filter((pair) => pair.preference === 'good').length, + ties: pairs.filter((pair) => pair.preference === 'tie').length, + reversals: pairs.filter((pair) => pair.preference === 'bad').length, + trivialComparisons: trivialValues.length, + trivialRejected: + trivialValues.length === definition.calibrationFixtures.length && + trivialValues.every((value) => value < definition.criteria.passThreshold) && + goodValues.length === definition.calibrationFixtures.length && + trivialMean < goodMean, + goodMean, + badMean: mean(bad), + trivialMean, + } + }) +} + +export function summarizeCalibration( + definitions: readonly SemanticEvalCase[], + cells: readonly CalibrationCellRecord[], +): CalibrationSummary { + const pairs = pairOutcomes(definitions, cells) + const categories = categoryOutcomes(definitions, cells) + const goodScores = cells.filter((cell) => cell.label === 'good').map((cell) => cell.score) + const badScores = cells.filter((cell) => cell.label === 'bad').map((cell) => cell.score) + const trivialScores = cells.filter((cell) => cell.label === 'trivial').map((cell) => cell.score) + const strongMean = mean(goodScores) + const weakMean = mean(badScores) + const trivialMean = mean(trivialScores) + const strongWeakMargin = strongMean - weakMean + const trivialStrongRatio = strongMean <= 0 ? 1 : trivialMean / strongMean + const goodPreferred = pairs.filter((pair) => pair.preference === 'good').length + const ties = pairs.filter((pair) => pair.preference === 'tie').length + const reversals = pairs.filter((pair) => pair.preference === 'bad').length + const minimumGoodPreferred = Math.ceil(pairs.length * CALIBRATION_MIN_GOOD_RATE) + const failures: string[] = [] + if (pairs.length < CALIBRATION_MIN_PAIRS) { + failures.push( + `only ${pairs.length} paired examples; at least ${CALIBRATION_MIN_PAIRS} are required`, + ) + } + if (goodPreferred < minimumGoodPreferred) { + failures.push( + `good preference ${goodPreferred}/${pairs.length} is below ${minimumGoodPreferred}/${pairs.length}`, + ) + } + if (pairs.some((pair) => !pair.independentlyScored)) { + failures.push('at least one good-versus-bad pair has a missing or failed judge score') + } + if (strongWeakMargin < CALIBRATION_MIN_MARGIN) { + failures.push( + `supplementary good-versus-bad mean margin ${strongWeakMargin.toFixed(3)} is below ${CALIBRATION_MIN_MARGIN.toFixed(3)}`, + ) + } + if (trivialStrongRatio >= CALIBRATION_MAX_TRIVIAL_RATIO) { + failures.push( + `supplementary trivial-to-good mean ratio ${trivialStrongRatio.toFixed(3)} is not below ${CALIBRATION_MAX_TRIVIAL_RATIO.toFixed(3)}`, + ) + } + for (const category of categories) { + if (!category.trivialRejected) + failures.push(`${category.caseId} trivial baseline was not rejected`) + } + return { + passed: failures.length === 0, + pairedExamples: pairs.length, + minimumPairedExamples: CALIBRATION_MIN_PAIRS, + goodPreferred, + minimumGoodPreferred, + ties, + reversals, + pairPreferenceRate: pairs.length === 0 ? 0 : goodPreferred / pairs.length, + strongMean, + weakMean, + trivialMean, + strongWeakMargin, + trivialStrongRatio, + minimumMargin: CALIBRATION_MIN_MARGIN, + maximumTrivialRatio: CALIBRATION_MAX_TRIVIAL_RATIO, + perCategory: categories, + pairOutcomes: pairs, + failures, + cells, + } +} + +async function campaign( + definition: SemanticEvalCase, + chat: ChatClient, + label: 'good' | 'bad' | 'trivial', + runDir: string, + ledger: CostLedger, +): Promise> { + const scenarios = scenariosForCalibration(definition, label) + return runCampaign({ + scenarios, + dispatch: async (scenario) => artifactForScenario(scenario), + dispatchRef: `braid-semantic-calibration-v2-${definition.id}-${label}`, + judges: [semanticJudge(definition, chat)], + seed: 42, + reps: 1, + resumable: false, + costLedger: ledger, + costPhase: `semantic-calibration-${definition.id}-${label}`, + costTags: { braidEvalCase: definition.id, calibrationLabel: label }, + maxConcurrency: 1, + abortOnCellError: false, + dispatchTimeoutMs: 120_000, + runDir, + tracing: 'on', + // Calibration scores deterministic Braid output; only the judge makes a paid call. + expectUsage: 'off', + }) +} + +export async function runCalibration(input: { + readonly definitions: readonly SemanticEvalCase[] + readonly chat: ChatClient + readonly runDir: string + readonly ledger: CostLedger +}): Promise { + const campaigns: CalibrationCampaign[] = [] + const allCells: CalibrationCellRecord[] = [] + for (const definition of input.definitions) { + const good = await campaign( + definition, + input.chat, + 'good', + `${input.runDir}/${definition.id}/good`, + input.ledger, + ) + campaigns.push({ label: 'good', definition, result: good }) + const bad = await campaign( + definition, + input.chat, + 'bad', + `${input.runDir}/${definition.id}/bad`, + input.ledger, + ) + campaigns.push({ label: 'bad', definition, result: bad }) + const trivial = await campaign( + definition, + input.chat, + 'trivial', + `${input.runDir}/${definition.id}/trivial`, + input.ledger, + ) + campaigns.push({ label: 'trivial', definition, result: trivial }) + allCells.push(...cellsFor(definition, { good, bad, trivial })) + } + return { + summary: summarizeCalibration(input.definitions, allCells), + campaigns, + ledger: input.ledger, + } +} diff --git a/src/eval/campaigns.ts b/src/eval/campaigns.ts new file mode 100644 index 0000000..087e714 --- /dev/null +++ b/src/eval/campaigns.ts @@ -0,0 +1,309 @@ +import type { CostLedger } from '@tangle-network/agent-eval' +import { + type CampaignResult, + type ChatClient, + runCampaign, +} from '@tangle-network/agent-eval/contract' +import { semanticJudge } from './calibration.js' +import { artifactForScenario, scenariosForRelease } from './cases.js' +import type { PilotInspection } from './records.js' +import { cellCostEvidence, cellCostReceipts, cellEvidence, evalSha256 } from './records.js' +import type { + RecordedJudgeCall, + SemanticCaseEvidence, + SemanticEvalArtifact, + SemanticEvalCase, + SemanticEvalScenario, +} from './types.js' + +export interface CampaignExecution { + readonly scenarios: readonly SemanticEvalScenario[] + readonly productFailures: readonly { readonly fixtureId: string; readonly reason: string }[] + readonly campaign: CampaignResult | null + readonly ledger: CostLedger +} + +function productFailures(scenarios: readonly SemanticEvalScenario[]) { + return scenarios.flatMap((scenario) => { + const product = scenario.productPath + return product?.available === false + ? [ + { + fixtureId: scenario.fixtureId, + reason: product.missingReason ?? 'product presenter unavailable', + }, + ] + : [] + }) +} + +function validProductScenarios(scenarios: readonly SemanticEvalScenario[]): boolean { + return ( + productFailures(scenarios).length === 0 && + scenarios.every((scenario) => scenario.productPath?.available === true) + ) +} + +function campaignOptions( + definition: SemanticEvalCase, + chat: ChatClient, + ledger: CostLedger, + scenarios: readonly SemanticEvalScenario[], + runDir: string, + dispatchRef: string, + costPhase: string, + costTags: Record, + abortOnCellError: boolean, +) { + return { + scenarios: [...scenarios], + dispatch: async (scenario: SemanticEvalScenario) => artifactForScenario(scenario), + dispatchRef, + judges: [semanticJudge(definition, chat)], + seed: 42, + reps: 1, + resumable: false, + costLedger: ledger, + costPhase, + costTags, + maxConcurrency: 1, + abortOnCellError, + dispatchTimeoutMs: 120_000, + runDir, + tracing: 'on' as const, + // The product presenter is deterministic. Paid work happens only in the judge, + // whose receipt is independently required and recorded by agent-eval. + expectUsage: 'off' as const, + } +} + +export async function runPilotCampaign(input: { + readonly definition: SemanticEvalCase + readonly chat: ChatClient + readonly ledger: CostLedger + readonly runDir: string +}): Promise { + const scenarios = scenariosForRelease(input.definition).slice(0, 1) + const failures = productFailures(scenarios) + if (!validProductScenarios(scenarios)) { + return { scenarios, productFailures: failures, campaign: null, ledger: input.ledger } + } + const campaign = await runCampaign( + campaignOptions( + input.definition, + input.chat, + input.ledger, + scenarios, + input.runDir, + 'braid-semantic-pilot-v2-EVAL-01', + 'semantic-pilot-EVAL-01', + { braidEvalCase: 'EVAL-01', phase: 'pilot' }, + true, + ), + ) + return { scenarios, productFailures: failures, campaign, ledger: input.ledger } +} + +export async function runReleaseCampaign(input: { + readonly definition: SemanticEvalCase + readonly chat: ChatClient + readonly ledger: CostLedger + readonly runDir: string +}): Promise { + const scenarios = scenariosForRelease(input.definition) + const failures = productFailures(scenarios) + if (!validProductScenarios(scenarios)) { + return { scenarios, productFailures: failures, campaign: null, ledger: input.ledger } + } + const campaign = await runCampaign( + campaignOptions( + input.definition, + input.chat, + input.ledger, + scenarios, + input.runDir, + `braid-semantic-release-v2-${input.definition.id}`, + `semantic-release-${input.definition.id}`, + { braidEvalCase: input.definition.id, phase: 'release' }, + true, + ), + ) + return { scenarios, productFailures: failures, campaign, ledger: input.ledger } +} + +function scoreIsParseable(score: unknown): boolean { + if (score === null || typeof score !== 'object') return false + const value = score as { + readonly composite?: unknown + readonly dimensions?: unknown + readonly failed?: unknown + } + if ( + value.failed === true || + typeof value.composite !== 'number' || + !Number.isFinite(value.composite) + ) + return false + if (value.dimensions === null || typeof value.dimensions !== 'object') return false + return Object.values(value.dimensions as Record).every( + (dimension) => typeof dimension === 'number' && Number.isFinite(dimension), + ) +} + +export function inspectPilot( + execution: CampaignExecution, + rawCalls: readonly RecordedJudgeCall[], + threshold: number, +): PilotInspection { + const failures: string[] = [] + const cell = execution.campaign?.cells.length === 1 ? execution.campaign.cells[0] : undefined + const scenario = execution.scenarios[0] + const productAvailable = + scenario?.productPath?.available === true && execution.productFailures.length === 0 + const scores = cell === undefined ? [] : Object.values(cell.judgeScores) + const parseableJudgeScore = scores.length === 1 && scores.every(scoreIsParseable) + const rawJudgeCallCaptured = + cell !== undefined && + (cell.costCallIds ?? []).some((callId) => + rawCalls.some( + (call) => call.callId === callId && call.response !== null && call.error === null, + ), + ) + const judgeReceipts = + cell === undefined + ? [] + : cellCostReceipts(cell, execution.ledger).filter((receipt) => receipt.channel === 'judge') + const usageCaptured = + judgeReceipts.length > 0 && + judgeReceipts.every( + (receipt) => + receipt.usageUnknown !== true && + Number.isFinite(receipt.inputTokens) && + receipt.inputTokens >= 0 && + Number.isFinite(receipt.outputTokens) && + receipt.outputTokens >= 0 && + (receipt.reasoningTokens === undefined || + (Number.isFinite(receipt.reasoningTokens) && receipt.reasoningTokens >= 0)), + ) && + judgeReceipts.some((receipt) => receipt.inputTokens + receipt.outputTokens > 0) + const costCaptured = + judgeReceipts.length > 0 && + judgeReceipts.every( + (receipt) => + Number.isFinite(receipt.costUsd) && receipt.costUsd >= 0 && receipt.costUnknown === false, + ) + const costEvidence = + cell === undefined ? null : cellCostEvidence(cell, rawCalls, execution.ledger) + const wallTimeCaptured = + costEvidence !== null && Number.isFinite(costEvidence.wallTimeMs) && costEvidence.wallTimeMs > 0 + if (!productAvailable) + failures.push( + ...execution.productFailures.map((failure) => `${failure.fixtureId}: ${failure.reason}`), + ) + if (execution.campaign === null) + failures.push('pilot produced no campaign because the product path was unavailable') + if (cell === undefined) failures.push('pilot did not produce exactly one campaign cell') + if (!parseableJudgeScore) failures.push('pilot did not produce one parseable judge score') + if (!rawJudgeCallCaptured) failures.push('pilot did not capture the successful raw judge call') + if (!usageCaptured) failures.push('pilot did not capture input/output token usage') + if (!costCaptured) failures.push('pilot did not capture cost provenance, including zero') + if (!wallTimeCaptured) failures.push('pilot did not capture positive wall time') + if (cell?.error !== undefined) failures.push(`pilot cell failed: ${cell.error}`) + const composite = scores[0]?.composite + if (typeof composite !== 'number' || composite < threshold) + failures.push(`pilot score is below ${threshold.toFixed(2)}`) + return { + passed: failures.length === 0, + productAvailable, + parseableJudgeScore, + rawJudgeCallCaptured, + usageCaptured, + costCaptured, + wallTimeCaptured, + failures, + } +} + +export function caseEvidence( + definition: SemanticEvalCase, + execution: CampaignExecution, + rawCalls: readonly RecordedJudgeCall[], +): SemanticCaseEvidence { + const artifacts = execution.scenarios.map(artifactForScenario) + const cells = + execution.campaign === null + ? [] + : execution.campaign.cells.map((cell) => + cellEvidence(cell, rawCalls, definition.criteria.passThreshold, execution.ledger), + ) + const disagreements = [ + ...execution.productFailures.map((failure) => `${failure.fixtureId}: ${failure.reason}`), + ...cells.flatMap((cell) => + cell.pass + ? [] + : [`${cell.scenarioId} scored below ${definition.criteria.passThreshold.toFixed(2)}`], + ), + ] + return { + id: definition.id, + category: 'eval', + command: 'pnpm test:eval', + question: definition.question, + referenceCriteria: definition.criteria, + fixtureCount: execution.scenarios.length, + passedFixtures: cells.filter((cell) => cell.pass).length, + failedFixtures: execution.scenarios.length - cells.filter((cell) => cell.pass).length, + artifacts, + productFailures: execution.productFailures, + result: + execution.campaign !== null && + execution.productFailures.length === 0 && + cells.length === execution.scenarios.length && + cells.every((cell) => cell.pass) + ? 'passed' + : 'failed', + campaign: execution.campaign, + cells, + disagreements, + } +} + +export function unavailableCaseEvidence( + definition: SemanticEvalCase, + reason: string, +): SemanticCaseEvidence { + const scenarios = scenariosForRelease(definition) + return { + id: definition.id, + category: 'eval', + command: 'pnpm test:eval', + question: definition.question, + referenceCriteria: definition.criteria, + fixtureCount: scenarios.length, + passedFixtures: 0, + failedFixtures: scenarios.length, + artifacts: scenarios.map(artifactForScenario), + productFailures: scenarios.map((scenario) => ({ fixtureId: scenario.fixtureId, reason })), + result: 'unavailable', + campaign: null, + cells: [], + disagreements: [reason], + } +} + +export function assertHeldOutInputs(definitions: readonly SemanticEvalCase[]): void { + const calibrationDigests = new Set( + definitions.flatMap((definition) => + definition.calibrationFixtures.map((fixture) => evalSha256(fixture.semanticOutput)), + ), + ) + const collisions = definitions.flatMap((definition) => + definition.releaseFixtures.flatMap((fixture) => { + const hash = evalSha256(fixture.semanticOutput) + return calibrationDigests.has(hash) + ? [`${definition.id}/${fixture.id} collides with calibration input ${hash}`] + : [] + }), + ) + if (collisions.length > 0) throw new Error(`Held-out fixture collision: ${collisions.join('; ')}`) +} diff --git a/src/eval/cases.ts b/src/eval/cases.ts new file mode 100644 index 0000000..7c7381b --- /dev/null +++ b/src/eval/cases.ts @@ -0,0 +1,317 @@ +import type { SemanticEvalArtifact, SemanticEvalCase, SemanticEvalScenario } from './types.js' +import { SEMANTIC_CALIBRATION_FIXTURES, SEMANTIC_RELEASE_FIXTURES } from './fixtures.js' +import { + type CalibrationLabel, + SEMANTIC_EVAL_CATEGORIES, + type SemanticEvalCaseId, +} from './types.js' + +export const SEMANTIC_PASS_THRESHOLD = 0.7 + +const CASE_PROMPTS: Readonly> = { + 'EVAL-01': `You are judging a Braid fork explanation for a person deciding whether to continue work. +Use only the Braid fork-plan object in the semantic output and the answer shown to the user. +Reward explicit names or values for copied conversation context, provider-session behavior, environment behavior, checkpoint behavior, and omitted state. +Penalize claims that a new provider session resumes the source session, that an unavailable environment was created, or that omitted provider-owned state was copied. +The answer must make the allowed or unavailable decision and a useful next choice clear.`, + 'EVAL-02': `You are judging a Braid permission explanation before a command can run. +Reward the exact subject, concrete consequence, offered permission scopes, and concise safe choices. +The explanation must preserve the pause and must not recommend a broader scope than the InteractionRequest offers. +Penalize vague approval language, hidden destructive or network consequences, persistent approval when it is not offered, and burying the decision.`, + 'EVAL-03': `You are judging a Braid /ask answer over a frozen analysis record. +Reward actionable findings visibly supported by the serialized citation, accurate uncertainty or confidence, and a next diagnostic action. +Penalize any citation that does not resolve to the shown frozen source, any invented citation, and any root-cause claim not supported by the source event. +The answer must respect failed or incomplete source status and must not promote speculation into fact.`, + 'EVAL-04': `You are judging a Braid paired-comparison explanation. +Before any verdict, require every field captured on either arm, explicit missing fields, cost provenance, terminal outcome, and sample-size limitations. +Missing is not zero, a failed arm is not successful, and one pair is descriptive rather than a reliable general conclusion. +Penalize any verdict that hides an asymmetry or invents a value.`, + 'EVAL-05': `You are judging Braid reconnect status language. +Reward distinct plain-language meanings and actions for detached, reconnecting, cancelled, failed, expired, and unknown. +Detached and reconnecting describe connection or recovery state; cancelled, failed, and expired are different terminal outcomes; unknown means evidence is insufficient. +Penalize collapsing these states, claiming success without evidence, or recommending reconnect for a terminal state.`, + 'EVAL-06': `You are judging a Braid profile incompatibility explanation. +Identify the exact unsupported profile field and value, preserve the authored profile, and name valid explicit choices that resolve the mismatch. +Reward the distinction between changing the harness and changing the model. +Penalize silently dropping, replacing, or weakening a profile field, and penalize claims that compatibility is automatic.`, +} + +const CASE_METADATA: Readonly< + Record< + SemanticEvalCaseId, + { + readonly question: string + readonly category: string + readonly requiredSignals: readonly string[] + readonly forbiddenSignals: readonly string[] + readonly dimensions: readonly { readonly key: string; readonly description: string }[] + } + > +> = { + 'EVAL-01': { + question: 'Can a user tell what a fork copied?', + category: SEMANTIC_EVAL_CATEGORIES['EVAL-01'], + requiredSignals: [ + 'conversation context', + 'provider session', + 'environment', + 'checkpoint', + 'omitted state', + ], + forbiddenSignals: [ + 'false session resume', + 'fabricated environment', + 'fabricated checkpoint', + 'omitted state claimed copied', + ], + dimensions: [ + { + key: 'copied_state', + description: 'Names the conversation and context that the plan actually copies.', + }, + { + key: 'runtime_boundaries', + description: + 'Correctly distinguishes provider session, environment, and checkpoint behavior.', + }, + { + key: 'omissions', + description: 'Names omitted or unavailable state without inventing a copy.', + }, + { + key: 'decision_clarity', + description: 'Makes the allowed or unavailable decision and next choice clear.', + }, + ], + }, + 'EVAL-02': { + question: 'Is a permission understandable?', + category: SEMANTIC_EVAL_CATEGORIES['EVAL-02'], + requiredSignals: ['exact subject', 'consequence', 'offered scope', 'safe choices'], + forbiddenSignals: [ + 'vague approval', + 'unoffered scope', + 'hidden consequence', + 'buried decision', + ], + dimensions: [ + { + key: 'subject_consequence', + description: 'Identifies the exact subject and what it will do.', + }, + { + key: 'scope_accuracy', + description: 'Describes only the permission scopes offered by the answer specification.', + }, + { + key: 'safe_choices', + description: 'Names clear deny or allow choices without broadening the request.', + }, + { + key: 'decision_clarity', + description: 'Keeps the paused decision concise and understandable.', + }, + ], + }, + 'EVAL-03': { + question: 'Is /ask useful?', + category: SEMANTIC_EVAL_CATEGORIES['EVAL-03'], + requiredSignals: [ + 'actionable finding', + 'resolvable citation', + 'uncertainty', + 'supported claim', + ], + forbiddenSignals: [ + 'dangling citation', + 'invented citation', + 'unsupported root cause', + 'certainty beyond source', + ], + dimensions: [ + { key: 'actionability', description: 'Gives a concrete next action tied to the finding.' }, + { + key: 'citation_integrity', + description: 'Uses the shown citation and does not invent or misresolve evidence.', + }, + { + key: 'uncertainty', + description: 'Communicates confidence and source completeness accurately.', + }, + { + key: 'causal_discipline', + description: 'Does not assert a root cause absent from the frozen source.', + }, + ], + }, + 'EVAL-04': { + question: 'Is a comparison honest?', + category: SEMANTIC_EVAL_CATEGORIES['EVAL-04'], + requiredSignals: [ + 'every captured field', + 'missing field', + 'cost', + 'outcome', + 'sample limitation', + ], + forbiddenSignals: [ + 'missing treated as zero', + 'failed treated as success', + 'hidden asymmetry', + 'overconfident verdict', + ], + dimensions: [ + { + key: 'asymmetry_completeness', + description: 'Shows every captured field present on either comparison arm.', + }, + { + key: 'missingness', + description: 'Labels missing values as missing and never substitutes zero.', + }, + { + key: 'cost_outcome', + description: 'Reports cost provenance and terminal outcome before judging.', + }, + { + key: 'verdict_discipline', + description: 'Limits the verdict to what the pair count and evidence support.', + }, + ], + }, + 'EVAL-05': { + question: 'Is reconnect status clear?', + category: SEMANTIC_EVAL_CATEGORIES['EVAL-05'], + requiredSignals: ['detached', 'reconnecting', 'cancelled', 'failed', 'expired', 'unknown'], + forbiddenSignals: [ + 'collapsed statuses', + 'unknown called success', + 'terminal reconnect', + 'unproven continuation', + ], + dimensions: [ + { + key: 'status_distinction', + description: 'Gives each of the six statuses a distinct meaning.', + }, + { + key: 'evidence', + description: 'States what the available status evidence does and does not prove.', + }, + { + key: 'next_step', + description: 'Gives an appropriate action for recoverable and terminal states.', + }, + { + key: 'uncertainty', + description: 'Treats unknown as unresolved evidence rather than success.', + }, + ], + }, + 'EVAL-06': { + question: 'Is profile incompatibility clear?', + category: SEMANTIC_EVAL_CATEGORIES['EVAL-06'], + requiredSignals: [ + 'unsupported field', + 'exact values', + 'valid harness choice', + 'valid model choice', + 'no silent mutation', + ], + forbiddenSignals: [ + 'silent model replacement', + 'dropped field', + 'automatic compatibility', + 'hidden profile mutation', + ], + dimensions: [ + { key: 'exact_field', description: 'Identifies the unsupported field and exact values.' }, + { + key: 'valid_choices', + description: 'Names explicit harness and model choices that resolve the mismatch.', + }, + { + key: 'profile_integrity', + description: 'Makes clear that the authored profile is not silently weakened.', + }, + { key: 'decision_clarity', description: 'Explains the user decision in plain language.' }, + ], + }, +} + +function caseDefinition(id: SemanticEvalCaseId): SemanticEvalCase { + const metadata = CASE_METADATA[id] + return { + id, + question: metadata.question, + category: metadata.category, + prompt: CASE_PROMPTS[id], + dimensions: metadata.dimensions, + criteria: { + requiredSignals: metadata.requiredSignals, + forbiddenSignals: metadata.forbiddenSignals, + passThreshold: SEMANTIC_PASS_THRESHOLD, + }, + calibrationFixtures: SEMANTIC_CALIBRATION_FIXTURES[id], + releaseFixtures: SEMANTIC_RELEASE_FIXTURES[id], + } +} + +export const SEMANTIC_CASES: readonly SemanticEvalCase[] = [ + caseDefinition('EVAL-01'), + caseDefinition('EVAL-02'), + caseDefinition('EVAL-03'), + caseDefinition('EVAL-04'), + caseDefinition('EVAL-05'), + caseDefinition('EVAL-06'), +] + +export function scenariosForCalibration( + definition: SemanticEvalCase, + label: CalibrationLabel, +): SemanticEvalScenario[] { + return definition.calibrationFixtures.map((fixture, index) => ({ + id: `${definition.id}-calibration-${index + 1}`, + kind: `braid-semantic-${definition.category}`, + tags: ['braid', definition.id, 'calibration', label], + caseId: definition.id, + fixtureId: fixture.id, + semanticOutput: fixture.semanticOutput, + candidateOutput: + label === 'good' + ? fixture.goodOutput + : label === 'bad' + ? fixture.badOutput + : fixture.trivialOutput, + candidateLabel: label, + })) +} + +export function scenariosForRelease(definition: SemanticEvalCase): SemanticEvalScenario[] { + return definition.releaseFixtures.map((fixture, index) => ({ + id: `${definition.id}-release-${index + 1}`, + kind: `braid-semantic-${definition.category}`, + tags: ['braid', definition.id, 'release', fixture.productOutput.path], + caseId: definition.id, + fixtureId: fixture.id, + semanticOutput: fixture.semanticOutput, + candidateOutput: fixture.productOutput.text, + productPath: fixture.productOutput, + })) +} + +export function artifactForScenario(scenario: SemanticEvalScenario): SemanticEvalArtifact { + return { + caseId: scenario.caseId, + fixtureId: scenario.fixtureId, + semanticOutput: scenario.semanticOutput, + candidateOutput: scenario.candidateOutput, + ...(scenario.productPath === undefined ? {} : { productPath: scenario.productPath }), + } +} + +export function definitionForCase(id: SemanticEvalCaseId): SemanticEvalCase { + const definition = SEMANTIC_CASES.find((candidate) => candidate.id === id) + if (definition === undefined) throw new Error(`Unknown semantic evaluation case ${id}`) + return definition +} diff --git a/src/eval/execution.ts b/src/eval/execution.ts new file mode 100644 index 0000000..c00e8b4 --- /dev/null +++ b/src/eval/execution.ts @@ -0,0 +1,298 @@ +import { createChatClient } from '@tangle-network/agent-eval' +import type { + ChatCallOpts, + ChatClient, + ChatRequest, + ChatResponse, +} from '@tangle-network/agent-eval' +import type { EvalProviderIdentity, RecordedJudgeCall } from './types.js' +import { evalSha256, redactEvalValue } from './records.js' + +export const DEFAULT_EVAL_BRIDGE_URL = 'http://127.0.0.1:3344/v1' +export const DEFAULT_EVAL_MODEL = 'opencode/zai-coding-plan/glm-5.2' +export const DEFAULT_EVAL_CALL_TIMEOUT_MS = 120_000 +export const DEFAULT_EVAL_TOTAL_TIMEOUT_MS = 15 * 60_000 + +export interface EvalRouteConfig { + readonly baseUrl: string + readonly model: string + readonly bearer?: string + readonly timeoutMs: number + readonly totalTimeoutMs: number +} + +export interface EvalRouteReady { + readonly status: 'ready' + readonly config: EvalRouteConfig + readonly provider: EvalProviderIdentity + readonly health: unknown + readonly models: readonly string[] +} + +export interface EvalRouteUnavailable { + readonly status: 'unavailable' + readonly config: EvalRouteConfig + readonly provider: EvalProviderIdentity + readonly reason: string + readonly health: unknown | null + readonly models: readonly string[] +} + +export type EvalRouteProbe = EvalRouteReady | EvalRouteUnavailable + +function cleanBaseUrl(value: string): string { + const url = new URL(value) + if (url.username || url.password) + throw new Error('BRAID_EVAL_BRIDGE_URL cannot include credentials') + if (url.search || url.hash) + throw new Error('BRAID_EVAL_BRIDGE_URL cannot include query or fragment') + const pathname = url.pathname.replace(/\/+$/u, '') + if (pathname !== '/v1') throw new Error('BRAID_EVAL_BRIDGE_URL must end in /v1') + return `${url.origin}${pathname}` +} + +function positiveNumber(value: string | undefined, fallback: number, name: string): number { + const parsed = Number(value ?? String(fallback)) + if (!Number.isFinite(parsed) || parsed <= 0) throw new Error(`${name} must be positive`) + return parsed +} + +export function readEvalRouteConfig( + env: Readonly> = process.env, +): EvalRouteConfig { + const baseUrl = cleanBaseUrl(env.BRAID_EVAL_BRIDGE_URL ?? DEFAULT_EVAL_BRIDGE_URL) + const model = (env.BRAID_EVAL_MODEL ?? DEFAULT_EVAL_MODEL).trim() + if (model.length === 0) throw new Error('BRAID_EVAL_MODEL must be non-empty') + const timeoutMs = positiveNumber( + env.BRAID_EVAL_TIMEOUT_MS, + DEFAULT_EVAL_CALL_TIMEOUT_MS, + 'BRAID_EVAL_TIMEOUT_MS', + ) + const totalTimeoutMs = positiveNumber( + env.BRAID_EVAL_TOTAL_TIMEOUT_MS, + DEFAULT_EVAL_TOTAL_TIMEOUT_MS, + 'BRAID_EVAL_TOTAL_TIMEOUT_MS', + ) + const bearer = env.BRAID_EVAL_BEARER?.trim() + return { + baseUrl, + model, + ...(bearer === undefined || bearer.length === 0 ? {} : { bearer }), + timeoutMs, + totalTimeoutMs, + } +} + +export function providerIdentity(config: EvalRouteConfig): EvalProviderIdentity { + return { + transport: 'cli-bridge', + baseUrl: config.baseUrl, + model: config.model, + endpointSha256: evalSha256(config.baseUrl), + bearerPresent: config.bearer !== undefined, + } +} + +function healthUrl(baseUrl: string): string { + return `${baseUrl.slice(0, -'/v1'.length)}/health` +} + +async function jsonFetch( + url: string, + config: EvalRouteConfig, + fetchImpl: typeof fetch, +): Promise<{ readonly response: Response; readonly body: unknown }> { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), config.timeoutMs) + try { + const response = await fetchImpl(url, { + method: 'GET', + headers: config.bearer === undefined ? {} : { authorization: `Bearer ${config.bearer}` }, + signal: controller.signal, + }) + let body: unknown = null + try { + body = await response.json() + } catch { + body = null + } + return { response, body } + } finally { + clearTimeout(timer) + } +} + +function modelIds(value: unknown): string[] { + if (value === null || typeof value !== 'object') return [] + const data = (value as { readonly data?: unknown }).data + if (!Array.isArray(data)) return [] + return data.flatMap((entry) => { + if (entry === null || typeof entry !== 'object') return [] + const id = (entry as { readonly id?: unknown }).id + return typeof id === 'string' ? [id] : [] + }) +} + +export async function probeCliBridge( + config: EvalRouteConfig, + fetchImpl: typeof fetch = fetch, +): Promise { + const provider = providerIdentity(config) + let health: unknown = null + let models: string[] = [] + try { + const healthResult = await jsonFetch(healthUrl(config.baseUrl), config, fetchImpl) + health = redactEvalValue(healthResult.body) + if (!healthResult.response.ok) { + return { + status: 'unavailable', + config, + provider, + reason: `CLI Bridge health returned HTTP ${healthResult.response.status}`, + health, + models, + } + } + const modelsResult = await jsonFetch(`${config.baseUrl}/models`, config, fetchImpl) + models = modelIds(modelsResult.body) + if (!modelsResult.response.ok) { + return { + status: 'unavailable', + config, + provider, + reason: `CLI Bridge model discovery returned HTTP ${modelsResult.response.status}`, + health, + models, + } + } + if (!models.includes(config.model)) { + return { + status: 'unavailable', + config, + provider, + reason: `CLI Bridge does not advertise required model ${config.model}`, + health, + models, + } + } + return { status: 'ready', config, provider, health, models } + } catch (error) { + return { + status: 'unavailable', + config, + provider, + reason: error instanceof Error ? error.message : String(error), + health, + models, + } + } +} + +export function createEvalChatClient(config: EvalRouteConfig): ChatClient { + return createChatClient({ + transport: 'cli-bridge', + baseUrl: config.baseUrl, + defaultModel: config.model, + maximumAttempts: 1, + ...(config.bearer === undefined ? {} : { bearer: config.bearer }), + }) +} + +function abortError(message: string): Error { + const error = new Error(message) + error.name = 'TimeoutError' + return error +} + +function boundedOptions( + options: ChatCallOpts | undefined, + callTimeoutMs: number, + totalDeadline: number, +): { readonly options: ChatCallOpts; readonly cleanup: () => void } { + const controller = new AbortController() + const parent = options?.signal + const onParentAbort = () => controller.abort(parent?.reason) + if (parent?.aborted === true) onParentAbort() + else parent?.addEventListener('abort', onParentAbort, { once: true }) + const remaining = Math.max(0, totalDeadline - Date.now()) + const timeoutMs = Math.min(callTimeoutMs, remaining) + const timer = setTimeout( + () => + controller.abort( + abortError( + timeoutMs === remaining ? 'eval total timeout exceeded' : 'eval call timeout exceeded', + ), + ), + timeoutMs, + ) + return { + options: { ...(options ?? {}), signal: controller.signal }, + cleanup: () => { + clearTimeout(timer) + parent?.removeEventListener('abort', onParentAbort) + }, + } +} + +export function recordingChatClient( + client: ChatClient, + calls: RecordedJudgeCall[], + limits: { readonly callTimeoutMs: number; readonly totalTimeoutMs: number }, +): ChatClient { + const totalDeadline = Date.now() + limits.totalTimeoutMs + const wrapped: ChatClient = { + transport: client.transport, + chat: async (request: ChatRequest, options?: ChatCallOpts): Promise => { + const startedAt = new Date().toISOString() + const started = Date.now() + if (Date.now() >= totalDeadline) { + const error = abortError('eval total timeout exceeded before judge call') + calls.push({ + callId: options?.idempotencyKey ?? null, + request: redactEvalValue(request), + response: null, + error: { name: error.name, message: error.message }, + startedAt, + finishedAt: new Date().toISOString(), + wallTimeMs: 0, + }) + throw error + } + const bounded = boundedOptions(options, limits.callTimeoutMs, totalDeadline) + try { + const response = await client.chat(request, bounded.options) + calls.push({ + callId: options?.idempotencyKey ?? null, + request: redactEvalValue(request), + response: redactEvalValue(response), + error: null, + startedAt, + finishedAt: new Date().toISOString(), + wallTimeMs: Date.now() - started, + }) + return response + } catch (error) { + calls.push({ + callId: options?.idempotencyKey ?? null, + request: redactEvalValue(request), + response: null, + error: { + name: error instanceof Error ? error.name : 'UnknownError', + message: error instanceof Error ? error.message : String(error), + }, + startedAt, + finishedAt: new Date().toISOString(), + wallTimeMs: Date.now() - started, + }) + throw error + } finally { + bounded.cleanup() + } + }, + } + if (client.defaultModel !== undefined) + (wrapped as { defaultModel: string }).defaultModel = client.defaultModel + if (client.maximumAttempts !== undefined) + (wrapped as { maximumAttempts: number }).maximumAttempts = client.maximumAttempts + return wrapped +} diff --git a/src/eval/fixtures.ts b/src/eval/fixtures.ts new file mode 100644 index 0000000..41330c1 --- /dev/null +++ b/src/eval/fixtures.ts @@ -0,0 +1,741 @@ +import type { AnalystFinding } from '@tangle-network/agent-eval' +import { + type AgentProfile, + type InteractionRequest, + permissionAnswerSpec, +} from '@tangle-network/agent-interface' +import { mapAnalystFinding } from '../adapters/analysis/citations.js' +import { buildAnalysisTraceStore } from '../adapters/analysis/trace-store.js' +import { compareFrozenRuns } from '../app/analysis-comparison-facts.js' +import { freezeAnalysisSource } from '../app/analysis-source.js' +import { DETERMINISTIC_PROFILE } from '../app/composition.js' +import { ConversationBranches } from '../app/conversation-branches.js' +import { resolveEffectiveProfile } from '../app/profile-selection.js' +import { createProfileRecord } from '../app/profile-sources.js' +import { canonicalDigest } from '../domain/canonical.js' +import type { BranchRecord, ConversationRecord, MessageRecord } from '../domain/entities.js' +import type { BraidEvent, JournalEventEnvelope } from '../domain/events.js' +import { + createBranchId, + createConversationId, + createDraftId, + createEnvironmentId, + createEventId, + createMessageId, + createMessagePartId, + createQueueId, + createRunId, + createProviderSessionId, + createTurnId, + createWorkspaceId, +} from '../domain/ids-values.js' +import { createAdmissionReceipt } from '../domain/receipts.js' +import { replayEvents } from '../domain/reducer.js' +import { type BraidState, initialState } from '../domain/state.js' +import { DEFAULT_RUN_CAPABILITIES } from '../ports/execution.js' +import { + loadProductPresenters, + type ProductPresenterProvenance, + renderBraidProductOutput, + unavailableProductOutput, +} from './product-path.js' +import type { CalibrationFixture, ReleaseFixture, SemanticEvalCaseId } from './types.js' + +export const EVAL_NOW = '2026-08-03T20:00:00.000Z' +const LUNA_MODEL = 'pi/openai-codex/gpt-5.6-luna' +const ANTHROPIC_MODEL = 'anthropic/claude-opus-4-1' +const MOONSHOT_MODEL = 'moonshot/kimi-k2.5' +const OPENAI_MODEL = 'openai/gpt-5.6-luna' +const ZAI_MODEL = 'zai/glm-5.2' + +function profileWith( + model: string, + harness: NonNullable, +): Readonly { + return { + ...DETERMINISTIC_PROFILE, + harness, + model: { ...(DETERMINISTIC_PROFILE.model ?? {}), default: model }, + } +} + +function conversationState(suffix: string, messages: readonly MessageRecord[]): BraidState { + const conversationId = createConversationId(`conversation-eval-${suffix}`) + const branchId = createBranchId(`branch-eval-${suffix}`) + const workspaceId = createWorkspaceId(`workspace-eval-${suffix}`) + const tipMessageId = messages.at(-1)?.id + const branch: BranchRecord = { + id: branchId, + conversationId, + overrides: { runner: 'pi', model: LUNA_MODEL, effort: 'high' }, + draftId: createDraftId(`draft-eval-${suffix}`), + queueId: createQueueId(`queue-eval-${suffix}`), + ...(tipMessageId === undefined ? {} : { tipMessageId }), + status: 'active', + createdAt: EVAL_NOW, + updatedAt: EVAL_NOW, + } + const conversation: ConversationRecord = { + id: conversationId, + workspaceId, + title: `Braid evaluation ${suffix}`, + activeBranchId: branchId, + createdAt: EVAL_NOW, + updatedAt: EVAL_NOW, + archived: false, + retention: {}, + } + const base = initialState(DETERMINISTIC_PROFILE) + return { + ...base, + workspace: `/workspace/${suffix}`, + workspaceId, + conversationId, + branchId, + conversations: [conversation], + branches: [branch], + messages, + } +} + +function textMessage( + conversationId: ReturnType, + branchId: ReturnType, + id: string, + role: 'user' | 'assistant', + text: string, +): MessageRecord { + const messageId = createMessageId(id) + const partId = createMessagePartId(`part-${id}`) + return { + id: messageId, + conversationId, + branchId, + role, + text, + partIds: [partId], + status: 'complete', + createdAt: EVAL_NOW, + updatedAt: EVAL_NOW, + complete: true, + parts: [{ id: partId, kind: 'text', text }], + } +} + +function forkPlan(suffix: string, kind: 'conversation' | 'workspace', runner: string) { + const conversationId = createConversationId(`conversation-eval-${suffix}`) + const branchId = createBranchId(`branch-eval-${suffix}`) + const user = textMessage( + conversationId, + branchId, + `message-user-${suffix}`, + 'user', + `Inspect the repository state for ${suffix}.`, + ) + const assistant = textMessage( + conversationId, + branchId, + `message-assistant-${suffix}`, + 'assistant', + 'The repository is ready for the next step.', + ) + const state = conversationState(suffix, [user, assistant]) + const plan = new ConversationBranches({ + state: () => state, + now: () => EVAL_NOW, + commit: async () => undefined, + }).plan({ + operationId: `operation-eval-fork-${suffix}`, + conversationId, + branchId, + throughMessageId: assistant.id, + kind, + runner, + }) + return { kind: 'braid.fork.plan', plan } +} + +function permissionRequest( + suffix: string, + command: string, + responseScopes: readonly ('interaction' | 'session' | 'persistent')[], +): InteractionRequest { + return { + id: `interaction-eval-${suffix}`, + kind: 'permission', + title: 'Permission required before running a command', + body: `Braid paused before running ${command}. Choose a scope or deny the request.`, + subject: { type: 'command', command }, + answerSpec: permissionAnswerSpec({ allowFeedback: true, responseScopes }), + responseScopes: [...responseScopes], + timeoutMs: 30_000, + onTimeout: 'fail', + } +} + +function permissionOutput( + suffix: string, + command: string, + path: string, + responseScopes: readonly ('interaction' | 'session' | 'persistent')[], +) { + return { + kind: 'braid.interaction.request', + interaction: permissionRequest(suffix, command, responseScopes), + run: { + id: createRunId(`run-permission-${suffix}`), + status: 'waiting', + scope: responseScopes, + subject: { path, command }, + }, + } +} + +interface AnalysisFixtureResult { + readonly analysis: unknown + readonly evidence: ReturnType +} + +export function analysisEvidence( + suffix: string, + status: 'completed' | 'failed' = 'completed', +): AnalysisFixtureResult { + const runId = createRunId(`run-analysis-${suffix}`) + const turnId = createTurnId(`turn-analysis-${suffix}`) + const state = initialState(DETERMINISTIC_PROFILE) + const receipt = createAdmissionReceipt({ + runId: String(runId), + turnId: String(turnId), + operationId: `operation-analysis-${suffix}`, + conversationId: String(state.conversationId), + branchId: String(state.branchId), + admittedAt: EVAL_NOW, + profile: DETERMINISTIC_PROFILE, + text: `Analyze ${suffix}.`, + capabilities: DEFAULT_RUN_CAPABILITIES, + provider: 'fixture-analysis-provider', + providerSessionId: createProviderSessionId(`provider-session-fixture-${suffix}`), + environmentId: createEnvironmentId(`environment-fixture-${suffix}`), + admissionStatus: 'admitted', + }) + const eventId = (sequence: number) => createEventId(`event-analysis-${suffix}-${sequence}`) + const events: JournalEventEnvelope[] = [ + { + eventId: eventId(1), + sequence: 1, + revision: 1, + occurredAt: EVAL_NOW, + event: { kind: 'workspace.opened', workspace: `/workspace/analysis-${suffix}` }, + }, + { + eventId: eventId(2), + sequence: 2, + revision: 2, + occurredAt: EVAL_NOW, + event: { + kind: 'run.requested', + operationId: `operation-analysis-${suffix}`, + runId, + turnId, + userMessageId: `message-analysis-user-${suffix}`, + assistantMessageId: `message-analysis-assistant-${suffix}`, + text: `Analyze ${suffix}.`, + requestDigest: receipt.requestDigest, + receipt, + }, + }, + { + eventId: eventId(3), + sequence: 3, + revision: 3, + occurredAt: EVAL_NOW, + event: { + kind: 'run.tool.call', + runId, + partId: `part-analysis-${suffix}`, + toolName: 'shell', + callId: `call-analysis-${suffix}`, + input: { command: `printf ${suffix}` }, + provider: { eventId: `provider-call-${suffix}`, providerSequence: 1 }, + }, + }, + { + eventId: eventId(4), + sequence: 4, + revision: 4, + occurredAt: EVAL_NOW, + event: { + kind: 'run.tool.result', + runId, + partId: `part-analysis-${suffix}`, + toolName: 'shell', + callId: `call-analysis-${suffix}`, + result: `safe ${suffix}`, + provider: { eventId: `provider-result-${suffix}`, providerSequence: 2 }, + }, + }, + { + eventId: eventId(5), + sequence: 5, + revision: 5, + occurredAt: EVAL_NOW, + event: { kind: 'run.text.delta', runId, text: `Observed safe output for ${suffix}.` }, + }, + { + eventId: eventId(6), + sequence: 6, + revision: 6, + occurredAt: EVAL_NOW, + event: { + kind: 'run.finished', + runId, + status, + finalText: status === 'completed' ? `Observed safe output for ${suffix}.` : '', + usage: { + input: suffix.includes('missing-cost') ? 120 : 100, + output: suffix.includes('missing-cost') ? 60 : 50, + ...(suffix.includes('baseline') ? { costUsd: 0.02 } : {}), + model: 'fixture/eval-source', + }, + ...(status === 'failed' ? { error: 'provider returned a recoverable error' } : {}), + }, + }, + ] + const replayed = replayEvents(state, events) + const evidence = freezeAnalysisSource({ state: replayed, events, runId }) + const trace = buildAnalysisTraceStore(evidence) + const textEvent = evidence.events.find((event) => event.event.kind === 'run.text.delta') + if (textEvent === undefined) throw new Error(`Analysis fixture ${suffix} has no text event`) + const finding: AnalystFinding = { + schema_version: '1.0.0', + finding_id: `finding-${suffix}`, + analyst_id: 'efficiency-behavioral', + produced_at: EVAL_NOW, + severity: 'medium', + area: 'tool-use', + claim: `The ${suffix} run contains one observed shell result and no measured root cause.`, + confidence: 0.72, + evidence_refs: [ + { + kind: 'event', + uri: `event://${String(textEvent.id)}`, + excerpt: `Observed safe output for ${suffix}.`, + }, + ], + } + const mapped = mapAnalystFinding(evidence, trace, finding) + const analysis = { + kind: 'braid.analysis.record', + analysis: { + id: `analysis-${suffix}`, + source: evidence.source, + question: `What happened in ${suffix}?`, + recipe: 'ask', + status, + findings: [mapped], + usage: { input: 100, output: 30, model: 'fixture/analyst' }, + ...(suffix.includes('baseline') ? { costUsd: 0.01 } : {}), + createdAt: EVAL_NOW, + updatedAt: EVAL_NOW, + }, + } + return { analysis, evidence } +} + +function comparisonOutput(suffix: string) { + const baseline = analysisEvidence(`${suffix}-baseline`) + const candidate = analysisEvidence(`${suffix}-missing-cost`, 'failed') + const comparison = compareFrozenRuns({ + baseline: baseline.evidence, + candidate: candidate.evidence, + bootstrapSeed: 7, + }) + return { + kind: 'braid.analysis.comparison', + source: { suffix, baseline: baseline.evidence.source, candidate: candidate.evidence.source }, + comparison, + } +} + +function reconnectOutput(suffix: string) { + const events: BraidEvent[] = [ + { + kind: 'run.detached', + runId: createRunId(`run-reconnect-${suffix}-detached`), + cursor: `cursor-${suffix}`, + detail: 'local stream detached', + }, + { + kind: 'run.reconnecting', + runId: createRunId(`run-reconnect-${suffix}-reconnecting`), + after: `cursor-${suffix}`, + }, + { + kind: 'run.finished', + runId: createRunId(`run-reconnect-${suffix}-cancelled`), + status: 'cancelled', + finalText: '', + usage: { input: 1, output: 0 }, + reason: 'user_cancelled', + }, + { + kind: 'run.finished', + runId: createRunId(`run-reconnect-${suffix}-failed`), + status: 'failed', + finalText: '', + usage: { input: 1, output: 0 }, + error: 'provider failed', + }, + { + kind: 'run.finished', + runId: createRunId(`run-reconnect-${suffix}-expired`), + status: 'expired', + finalText: '', + usage: { input: 1, output: 0 }, + reason: 'response_window_expired', + }, + { + kind: 'run.unknown', + runId: createRunId(`run-reconnect-${suffix}-unknown`), + detail: 'provider history is unavailable', + }, + ] + return { + kind: 'braid.semantic.run-statuses', + events, + } +} + +function profileCompatibilityOutput( + suffix: string, + runner: NonNullable, + model: string, + compatibleModel: string, +) { + const profile = createProfileRecord( + { + kind: 'inline', + reference: `eval-profile-${suffix}`, + label: `Eval profile ${suffix}`, + writable: false, + trusted: true, + }, + profileWith(model, runner), + ) + return { + kind: 'braid.profile.selection', + authoredProfile: profile.profile, + profileDigest: profile.digest, + effective: resolveEffectiveProfile({ + profile, + availableModelIds: [model, compatibleModel], + }), + } +} + +type CaseFixtureMap = Readonly> + +const CALIBRATION_FIXTURES: CaseFixtureMap = { + 'EVAL-01': [ + { + id: 'cal-fork-shared-conversation', + semanticOutput: forkPlan('cal-fork-shared-conversation', 'conversation', 'opencode'), + goodOutput: + 'The conversation context is copied through the assistant boundary, the destination provider session is new, the environment remains shared, and no checkpoint is made. Omitted provider-owned reasoning and tool state are not copied, so this is not a resumed session.', + badOutput: + 'The fork resumes the original provider session with the same environment and checkpoint, so all hidden state is copied.', + trivialOutput: 'Fork plan received.', + }, + { + id: 'cal-fork-workspace-unavailable', + semanticOutput: forkPlan('cal-fork-workspace-unavailable', 'workspace', 'pi'), + goodOutput: + 'The workspace fork is unavailable: conversation context is portable, but the environment and checkpoint are unavailable. Braid cannot claim a new workspace or resumed provider session; choose a conversation fork or configure the missing capabilities.', + badOutput: + 'Workspace fork is ready and will resume the source session with copied environment and checkpoint.', + trivialOutput: 'Workspace fork unavailable.', + }, + { + id: 'cal-fork-runner-boundary', + semanticOutput: forkPlan('cal-fork-runner-boundary', 'conversation', 'codex'), + goodOutput: + 'This conversation fork copies the selected messages and starts a new codex provider session. The environment is shared, checkpoint is none, and provider-owned reasoning and tool internals are omitted.', + badOutput: + 'Everything, including provider session, environment, checkpoint, and hidden tool state, was cloned into codex.', + trivialOutput: 'Conversation fork prepared.', + }, + ], + 'EVAL-02': [ + { + id: 'cal-permission-shell-read', + semanticOutput: permissionOutput( + 'cal-permission-shell-read', + 'git diff -- README.md', + 'README.md', + ['interaction', 'session'], + ), + goodOutput: + 'Braid is paused before running `git diff -- README.md`. The subject is that exact shell command; the offered scopes are this interaction or the current session, not persistent access. Deny, allow once, or allow for the session, with optional feedback.', + badOutput: + 'Allow the agent to do what it needs; this may apply everywhere and is probably safe.', + trivialOutput: 'Permission requested.', + }, + { + id: 'cal-permission-network-write', + semanticOutput: permissionOutput( + 'cal-permission-network-write', + 'curl -X POST https://example.invalid', + 'network', + ['interaction'], + ), + goodOutput: + 'The exact subject is `curl -X POST https://example.invalid`, a network write. Only this interaction is offered, not session or persistent scope. Deny or allow once; the run stays paused until you choose.', + badOutput: + 'The network operation is fine; approve permanently so the agent does not pause again.', + trivialOutput: 'Network permission needed.', + }, + { + id: 'cal-permission-delete', + semanticOutput: permissionOutput( + 'cal-permission-delete', + 'rm -f build/cache.json', + 'build/cache.json', + ['interaction', 'session', 'persistent'], + ), + goodOutput: + 'Braid requests permission for `rm -f build/cache.json`, which deletes that file. The offered choices are deny, once, session, or persistent; persistent approval affects future matching requests and should be explicit.', + badOutput: 'Approve the broad file permission; request details and scope do not matter.', + trivialOutput: 'A permission choice is waiting.', + }, + ], + 'EVAL-03': [ + { + id: 'cal-analysis-shell', + semanticOutput: analysisEvidence('cal-analysis-shell').analysis, + goodOutput: + 'The medium finding is supported by its citation to the frozen observed text event, confidence is 0.72, and the source shows no measured root cause. Inspect the cited shell result next; do not promote speculation into a causal claim.', + badOutput: + 'The root cause is a shell security regression and the provider corrupted the repository; cite event://missing-root-cause.', + trivialOutput: 'Analysis completed with one finding.', + }, + { + id: 'cal-analysis-timing', + semanticOutput: analysisEvidence('cal-analysis-timing').analysis, + goodOutput: + 'The finding is tied to the frozen trace and reports only what the event supports. Confidence 0.72 is bounded evidence; inspect the cited event before changing the system and do not assert an unsupported root cause.', + badOutput: + 'The trace proves the provider timeout caused latency; no citation or uncertainty is needed.', + trivialOutput: 'There is a trace finding.', + }, + { + id: 'cal-analysis-failed', + semanticOutput: analysisEvidence('cal-analysis-failed', 'failed').analysis, + goodOutput: + 'The finding cites an observed event, but the source run is failed. State that limitation, give the next diagnostic action, and avoid calling the finding a confirmed root cause.', + badOutput: + 'The failed run proves the database caused the issue; promote it and cite an event absent from the source.', + trivialOutput: 'A failed analysis has a finding.', + }, + ], + 'EVAL-04': [ + { + id: 'cal-comparison-cost', + semanticOutput: comparisonOutput('cal-comparison-cost'), + goodOutput: + 'Before a verdict, show the paired baseline and candidate fields: baseline cost is 0.02 USD while candidate cost is missing, and baseline completed while candidate failed. Report tokens, event counts, and latency separately; n=1 is descriptive, not a general cheaper-or-better claim.', + badOutput: 'The candidate is 20% cheaper and better; both runs succeeded, so ship it.', + trivialOutput: 'Comparison complete.', + }, + { + id: 'cal-comparison-outcome', + semanticOutput: comparisonOutput('cal-comparison-outcome'), + goodOutput: + 'List every field captured on either arm, mark candidate cost missing rather than zero, show baseline completed versus candidate failed, and limit the verdict because this has one pair.', + badOutput: + 'Only latency matters; ignore missing cost and the failed candidate because the candidate wins.', + trivialOutput: 'Baseline and candidate were compared.', + }, + { + id: 'cal-comparison-single-pair', + semanticOutput: comparisonOutput('cal-comparison-single-pair'), + goodOutput: + 'This is one paired observation. Show both IDs, every asymmetric field, missing candidate cost, completion versus failure, and the limitation that one pair cannot establish a reliable general result.', + badOutput: + 'The sample is large enough to conclude the candidate is faster and cheaper; missing values can be zero.', + trivialOutput: 'The arms differ.', + }, + ], + 'EVAL-05': [ + { + id: 'cal-reconnect-detach', + semanticOutput: reconnectOutput('cal-reconnect-detach'), + goodOutput: + 'Detached means the local stream stopped while the run may still exist; reconnecting requests events after the cursor. Cancelled, failed, and expired are distinct terminal results, while unknown means evidence is insufficient. Only detached and reconnecting invite recovery.', + badOutput: 'All statuses mean complete and safe to resume; reconnect whenever possible.', + trivialOutput: 'Run status changed.', + }, + { + id: 'cal-reconnect-gap', + semanticOutput: reconnectOutput('cal-reconnect-gap'), + goodOutput: + 'Distinguish the temporary detached stream, active reconnecting, and terminal cancelled, failed, expired, and unknown outcomes. Provider history being unavailable is not completion.', + badOutput: + 'The provider gap is reconnecting, so the run will continue and no warning is needed.', + trivialOutput: 'The run has several states.', + }, + { + id: 'cal-reconnect-cancelled', + semanticOutput: reconnectOutput('cal-reconnect-cancelled'), + goodOutput: + 'A detached stream may be reconnectable, but a later cancelled result is terminal. Failed, expired, and unknown remain distinct; unknown is unresolved evidence, not success.', + badOutput: + 'Cancelled, failed, expired, and unknown are interchangeable labels for a completed run.', + trivialOutput: 'The run is not active.', + }, + ], + 'EVAL-06': [ + { + id: 'cal-profile-glm-codex', + semanticOutput: profileCompatibilityOutput( + 'cal-profile-glm-codex', + 'codex', + ZAI_MODEL, + OPENAI_MODEL, + ), + goodOutput: + 'The unsupported pair is harness=codex with model=zai/glm-5.2. Preserve the authored profile and explicitly choose opencode to keep GLM or openai/gpt-5.6-luna to keep codex; do not silently replace either field.', + badOutput: + 'codex will silently choose a compatible model, so the profile is valid and no choice is needed.', + trivialOutput: 'The profile is incompatible.', + }, + { + id: 'cal-profile-runner-choice', + semanticOutput: profileCompatibilityOutput( + 'cal-profile-luna-claude', + 'claude-code', + OPENAI_MODEL, + ANTHROPIC_MODEL, + ), + goodOutput: + 'The mismatch is harness=claude-code with model=openai/gpt-5.6-luna. Select codex to preserve Luna or anthropic/claude-opus-4-1 to preserve claude-code; keep the authored profile and digest unchanged.', + badOutput: 'Remove the model field and retry with pi; that changes nothing important.', + trivialOutput: 'Choose another profile.', + }, + { + id: 'cal-profile-digest', + semanticOutput: profileCompatibilityOutput( + 'cal-profile-claude-kimi', + 'kimi-code', + ANTHROPIC_MODEL, + MOONSHOT_MODEL, + ), + goodOutput: + 'The unsupported pair is harness=kimi-code with model=anthropic/claude-opus-4-1. Keep the authored profile unchanged and explicitly choose claude-code for that model or moonshot/kimi-k2.5 for kimi-code.', + badOutput: + 'Omit the unsupported model and continue with a default runner; users do not need to know.', + trivialOutput: 'Profile selection needs attention.', + }, + ], +} + +const RELEASE_SEMANTIC_OUTPUTS: CaseFixtureMap = { + 'EVAL-01': [ + forkPlan('heldout-fork-conversation', 'conversation', 'opencode'), + forkPlan('heldout-fork-workspace', 'workspace', 'pi'), + forkPlan('heldout-fork-codex', 'conversation', 'codex'), + ], + 'EVAL-02': [ + permissionOutput('heldout-permission-read', 'git diff -- package.json', 'package.json', [ + 'interaction', + 'session', + ]), + permissionOutput( + 'heldout-permission-network', + 'curl -X POST https://example.invalid/api', + 'network', + ['interaction'], + ), + permissionOutput('heldout-permission-delete', 'rm -f dist/cache.json', 'dist/cache.json', [ + 'interaction', + 'session', + 'persistent', + ]), + ], + 'EVAL-03': [ + analysisEvidence('heldout-analysis-shell').analysis, + analysisEvidence('heldout-analysis-timing').analysis, + analysisEvidence('heldout-analysis-failed', 'failed').analysis, + ], + 'EVAL-04': [ + comparisonOutput('heldout-comparison-cost'), + comparisonOutput('heldout-comparison-outcome'), + comparisonOutput('heldout-comparison-single-pair'), + ], + 'EVAL-05': [ + reconnectOutput('heldout-reconnect-detach'), + reconnectOutput('heldout-reconnect-gap'), + reconnectOutput('heldout-reconnect-cancelled'), + ], + 'EVAL-06': [ + profileCompatibilityOutput( + 'heldout-profile-moonshot-codex', + 'codex', + MOONSHOT_MODEL, + OPENAI_MODEL, + ), + profileCompatibilityOutput( + 'heldout-profile-glm-claude', + 'claude-code', + ZAI_MODEL, + ANTHROPIC_MODEL, + ), + profileCompatibilityOutput( + 'heldout-profile-luna-kimi', + 'kimi-code', + OPENAI_MODEL, + MOONSHOT_MODEL, + ), + ], +} + +export const SEMANTIC_CALIBRATION_FIXTURES = CALIBRATION_FIXTURES + +export const SEMANTIC_RELEASE_FIXTURES: CaseFixtureMap = Object.fromEntries( + (Object.keys(RELEASE_SEMANTIC_OUTPUTS) as SemanticEvalCaseId[]).map((caseId) => [ + caseId, + RELEASE_SEMANTIC_OUTPUTS[caseId].map((semanticOutput, index) => { + const id = `release-${caseId.toLowerCase()}-${index + 1}` + return { + id, + semanticOutput, + productOutput: unavailableProductOutput( + semanticOutput, + 'installed Braid product presenters were not loaded', + ), + } + }), + ]), +) as unknown as CaseFixtureMap + +export async function prepareReleaseProductOutputs( + packageRoot: string, +): Promise { + const provenance = await loadProductPresenters(packageRoot) + for (const caseId of Object.keys(SEMANTIC_RELEASE_FIXTURES) as SemanticEvalCaseId[]) { + for (const fixture of SEMANTIC_RELEASE_FIXTURES[caseId]) { + const mutable = fixture as { productOutput: ReleaseFixture['productOutput'] } + mutable.productOutput = renderBraidProductOutput(caseId, fixture.semanticOutput) + } + } + return provenance +} + +export function releaseFixtureProductReady(): boolean { + return (Object.keys(SEMANTIC_RELEASE_FIXTURES) as SemanticEvalCaseId[]).every((caseId) => + SEMANTIC_RELEASE_FIXTURES[caseId].every( + (fixture) => + fixture.productOutput.sourceDigest === canonicalDigest(fixture.semanticOutput) && + fixture.productOutput.path !== 'unavailable', + ), + ) +} diff --git a/src/eval/product-path.ts b/src/eval/product-path.ts new file mode 100644 index 0000000..42696f7 --- /dev/null +++ b/src/eval/product-path.ts @@ -0,0 +1,329 @@ +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import type { AnalysisComparisonResult } from '../app/analysis-comparison-contracts.js' +import type { ForkPlan } from '../app/conversation-types.js' +import type { AnalysisRecord } from '../domain/entities.js' +import type { BraidEvent, BraidEventEnvelope } from '../domain/events.js' +import { canonicalDigest } from '../domain/canonical.js' +import type { + AnalysisView, + BraidViewModel, + ComparisonView, + ForkPreviewView, +} from '../views/shared/models.js' +import type { UiEvent } from '../views/shared/intents.js' +import type { ProductOutput, SemanticEvalCaseId } from './types.js' + +type ProductTheme = ReturnType +type ProductForkPanel = InstanceType< + typeof import('../views/tui/fork-preview.js')['ForkPreviewPanel'] +> +type ProductAnalysisPanel = InstanceType< + typeof import('../views/tui/analysis.js')['AnalysisViewPanel'] +> + +interface ProductPresenters { + readonly packageRoot: string + readonly createBraidTheme: (options: boolean | Record) => ProductTheme + readonly ForkPreviewPanel: new (theme: ProductTheme) => ProductForkPanel + readonly AnalysisViewPanel: new (theme: ProductTheme) => ProductAnalysisPanel + readonly analysisViewForRecord: (record: AnalysisRecord) => AnalysisView + readonly analysisLines: (view: AnalysisView) => readonly string[] + readonly isAnalysisComparisonResult: (value: unknown) => value is AnalysisComparisonResult + readonly comparisonViewForResult: (result: AnalysisComparisonResult) => ComparisonView + readonly comparisonLines: (view: ComparisonView) => readonly string[] + readonly profileCompatibilityTextLines: ( + result: Readonly>, + width?: number, + ) => readonly string[] + readonly plainEventText: (view: BraidViewModel, event: UiEvent) => string + readonly projectSemanticEvent: (envelope: BraidEventEnvelope) => Readonly> +} + +export interface ProductPresenterProvenance { + readonly packageRoot: string + readonly modulePaths: Readonly> +} + +let presenters: ProductPresenters | null = null + +function moduleUrl(packageRoot: string, relativePath: string): string { + return pathToFileURL(join(packageRoot, 'dist', relativePath)).href +} + +export async function loadProductPresenters( + packageRoot: string, +): Promise { + const root = packageRoot.trim() + if (root.length === 0) + throw new Error('BRAID_EVAL_PACKAGE_ROOT is required for release presenters') + const modules = { + theme: moduleUrl(root, 'views/tui/theme.js'), + fork: moduleUrl(root, 'views/tui/fork-preview.js'), + analysis: moduleUrl(root, 'views/tui/analysis.js'), + comparison: moduleUrl(root, 'views/tui/comparison.js'), + profileCompatibility: moduleUrl(root, 'views/tui/profile-compatibility.js'), + plain: moduleUrl(root, 'views/shared/plain-accessibility.js'), + semantic: moduleUrl(root, 'views/shared/semantic-projection.js'), + } + const [theme, fork, analysis, comparison, profileCompatibility, plain, semantic] = + await Promise.all([ + import(modules.theme), + import(modules.fork), + import(modules.analysis), + import(modules.comparison), + import(modules.profileCompatibility), + import(modules.plain), + import(modules.semantic), + ]) + presenters = { + packageRoot: root, + createBraidTheme: theme.createBraidTheme as ProductPresenters['createBraidTheme'], + ForkPreviewPanel: fork.ForkPreviewPanel as ProductPresenters['ForkPreviewPanel'], + AnalysisViewPanel: analysis.AnalysisViewPanel as ProductPresenters['AnalysisViewPanel'], + analysisViewForRecord: + analysis.analysisViewForRecord as ProductPresenters['analysisViewForRecord'], + analysisLines: analysis.analysisLines as ProductPresenters['analysisLines'], + isAnalysisComparisonResult: + comparison.isAnalysisComparisonResult as ProductPresenters['isAnalysisComparisonResult'], + comparisonViewForResult: + comparison.comparisonViewForResult as ProductPresenters['comparisonViewForResult'], + comparisonLines: comparison.comparisonLines as ProductPresenters['comparisonLines'], + profileCompatibilityTextLines: + profileCompatibility.profileCompatibilityTextLines as ProductPresenters['profileCompatibilityTextLines'], + plainEventText: plain.plainEventText as ProductPresenters['plainEventText'], + projectSemanticEvent: + semantic.projectSemanticEvent as ProductPresenters['projectSemanticEvent'], + } + return { packageRoot: root, modulePaths: modules } +} + +function productPresenters(): ProductPresenters { + if (presenters === null) + throw new Error('Product presenters were not loaded from an installed package root') + return presenters +} + +function digest(value: unknown): string { + return canonicalDigest(value) +} + +function rendered( + lines: readonly string[], + path: ProductOutput['path'], + source: unknown, +): ProductOutput { + const text = lines.join('\n').trim() + return { + text, + path, + available: text.length > 0, + missingReason: text.length > 0 ? null : 'The current Braid presenter returned no visible text', + sourceDigest: digest(source), + } +} + +export function unavailableProductOutput(source: unknown, missingReason: string): ProductOutput { + return { + text: '', + path: 'unavailable', + available: false, + missingReason, + sourceDigest: digest(source), + } +} + +function forkPreview(plan: ForkPlan): ForkPreviewView { + return { + source: `${plan.sourceConversationId} / ${plan.sourceBranchId}`, + destination: `${plan.sourceConversationId} / ${plan.destinationBranchId}`, + kind: plan.kind, + fields: [ + { label: 'operation id', source: plan.operationId, destination: plan.operationId }, + { label: 'plan digest', source: plan.digest, destination: plan.digest }, + { + label: 'conversation context', + source: plan.context.sourceBoundary, + destination: `${plan.context.messages.length} messages`, + }, + { label: 'provider session', source: 'current', destination: plan.providerSession }, + { label: 'workspace', source: 'current', destination: plan.environment }, + { label: 'checkpoint', source: 'source checkpoint', destination: plan.checkpoint }, + ], + allowed: plan.allowed, + ...(plan.reason === undefined ? {} : { unavailableReason: plan.reason }), + } +} + +function minimalView(overrides: Readonly> = {}): BraidViewModel { + return { + revision: 1, + workspace: '/eval', + profileName: 'Braid evaluation profile', + runner: 'opencode', + model: 'opencode/zai-coding-plan/glm-5.2', + connection: 'CLI Bridge', + conversationId: 'conversation-eval', + conversationTitle: 'Braid evaluation', + conversations: [], + branch: 'branch-eval', + status: 'ready', + statusText: 'ready', + queueCount: 0, + messages: [], + hiddenMessageCount: 0, + runs: [], + interactions: [], + activity: [], + graph: [], + capabilities: {} as BraidViewModel['capabilities'], + draft: '', + selectedSurface: 'transcript', + appearance: { color: 'none', highContrast: false, reducedMotion: true }, + ...overrides, + } +} + +function renderFork(source: unknown): ProductOutput { + if (source === null || typeof source !== 'object') + return unavailableProductOutput(source, 'EVAL-01 fixture is not a Braid fork plan') + const value = source as { readonly kind?: unknown; readonly plan?: unknown } + if (value.kind !== 'braid.fork.plan' || value.plan === null || typeof value.plan !== 'object') + return unavailableProductOutput(source, 'EVAL-01 fixture does not contain a Braid fork plan') + const panel = new (productPresenters().ForkPreviewPanel)( + productPresenters().createBraidTheme(false), + ) + panel.setView(minimalView({ forkPreview: forkPreview(value.plan as ForkPlan) })) + return rendered(panel.render(140), 'tui', source) +} + +function renderInteraction(source: unknown): ProductOutput { + if (source === null || typeof source !== 'object') + return unavailableProductOutput(source, 'EVAL-02 fixture is not a Braid interaction request') + const value = source as { + readonly interaction?: unknown + readonly run?: { readonly id?: unknown } + } + if (value.interaction === null || typeof value.interaction !== 'object') + return unavailableProductOutput(source, 'EVAL-02 fixture has no complete InteractionRequest') + const runId = typeof value.run?.id === 'string' ? value.run.id : 'run-eval-interaction' + const event: BraidEvent = { + kind: 'run.interaction', + runId, + request: value.interaction as never, + provider: { eventId: `provider-interaction-${runId}`, providerSequence: 1 }, + } + const projected = productPresenters().projectSemanticEvent({ + event, + sequence: 1, + revision: 1, + occurredAt: new Date(0).toISOString(), + }) + const uiEvent: UiEvent = { sequence: 1, revision: 1, kind: event.kind, payload: projected } + return rendered( + [ + productPresenters().plainEventText( + minimalView({ status: 'waiting', statusText: 'waiting for permission' }), + uiEvent, + ), + ], + 'plain', + source, + ) +} + +function renderAnalysis(source: unknown): ProductOutput { + if (source === null || typeof source !== 'object') + return unavailableProductOutput(source, 'EVAL-03 fixture is not a Braid analysis record') + const value = source as { readonly analysis?: unknown } + if (value.analysis === null || typeof value.analysis !== 'object') + return unavailableProductOutput(source, 'EVAL-03 fixture has no AnalysisRecord') + const view = productPresenters().analysisViewForRecord(value.analysis as AnalysisRecord) + return rendered(productPresenters().analysisLines(view), 'tui', source) +} + +function renderComparison(source: unknown): ProductOutput { + if (source === null || typeof source !== 'object') { + return unavailableProductOutput(source, 'EVAL-04 fixture is not a Braid comparison') + } + const value = source as { readonly comparison?: unknown } + if (!productPresenters().isAnalysisComparisonResult(value.comparison)) { + return unavailableProductOutput(source, 'EVAL-04 fixture has no complete comparison result') + } + const view = productPresenters().comparisonViewForResult(value.comparison) + return rendered(productPresenters().comparisonLines(view), 'tui', source) +} + +function renderReconnect(source: unknown): ProductOutput { + if (source === null || typeof source !== 'object') + return unavailableProductOutput(source, 'EVAL-05 fixture is not a Braid status event set') + const value = source as { readonly events?: readonly BraidEvent[] } + if (!Array.isArray(value.events) || value.events.length === 0) + return unavailableProductOutput(source, 'EVAL-05 fixture has no complete status events') + const lines = value.events.map((event, index) => { + const projected = productPresenters().projectSemanticEvent({ + event, + sequence: index + 1, + revision: index + 1, + occurredAt: new Date(0).toISOString(), + }) + const statusText = typeof projected.status === 'string' ? projected.status : 'unknown' + const uiEvent: UiEvent = { + sequence: index + 1, + revision: index + 1, + kind: event.kind, + payload: projected, + } + return productPresenters().plainEventText( + minimalView({ status: statusText, statusText }), + uiEvent, + ) + }) + return rendered(lines, 'plain', source) +} + +function renderProfileCompatibility(source: unknown): ProductOutput { + if (source === null || typeof source !== 'object') { + return unavailableProductOutput(source, 'EVAL-06 fixture is not a Braid profile selection') + } + const value = source as { readonly effective?: unknown } + if (value.effective === null || typeof value.effective !== 'object') { + return unavailableProductOutput( + source, + 'EVAL-06 fixture has no effective profile compatibility result', + ) + } + return rendered( + productPresenters().profileCompatibilityTextLines( + value.effective as Readonly>, + 140, + ), + 'tui', + source, + ) +} + +export function renderBraidProductOutput( + caseId: SemanticEvalCaseId, + semanticOutput: unknown, +): ProductOutput { + if (presenters === null) + return unavailableProductOutput( + semanticOutput, + 'installed Braid product presenters were not loaded', + ) + switch (caseId) { + case 'EVAL-01': + return renderFork(semanticOutput) + case 'EVAL-02': + return renderInteraction(semanticOutput) + case 'EVAL-03': + return renderAnalysis(semanticOutput) + case 'EVAL-04': + return renderComparison(semanticOutput) + case 'EVAL-05': + return renderReconnect(semanticOutput) + case 'EVAL-06': + return renderProfileCompatibility(semanticOutput) + } +} diff --git a/src/eval/record-builder.ts b/src/eval/record-builder.ts new file mode 100644 index 0000000..5a6f9fc --- /dev/null +++ b/src/eval/record-builder.ts @@ -0,0 +1,250 @@ +import { createHash } from 'node:crypto' +import { mkdir, readFile } from 'node:fs/promises' +import { join, resolve } from 'node:path' +import type { CostLedger } from '@tangle-network/agent-eval' +import type { EvalRouteConfig } from './execution.js' +import { providerIdentity } from './execution.js' +import { + assertSemanticEvalRecord, + type EvalArtifactReference, + type EvalPackageProvenance, + type PilotInspection, + receiptsView, + redactEvalValue, + type SemanticEvalRecord, + withArtifactHash, + writeJsonArtifact, +} from './records.js' +import type { + CalibrationSummary, + EvalProviderIdentity, + RecordedJudgeCall, + SemanticCaseEvidence, +} from './types.js' +import { SEMANTIC_EVAL_CASE_IDS, SEMANTIC_EVAL_CATEGORIES } from './types.js' + +interface PackageJsonShape { + readonly version?: unknown + readonly dependencies?: Readonly> +} + +function hashBytes(value: Uint8Array): string { + return `sha256:${createHash('sha256').update(value).digest('hex')}` +} + +export async function packageVersions(rootDir: string): Promise>> { + const packageJson = JSON.parse( + await readFile(join(rootDir, 'package.json'), 'utf8'), + ) as PackageJsonShape + const dependencies = packageJson.dependencies ?? {} + const names = [ + '@tangle-network/braid', + '@tangle-network/agent-eval', + '@tangle-network/agent-interface', + '@tangle-network/agent-runtime', + '@tangle-network/agent-provider-cli-bridge', + ] + return Object.fromEntries( + names.map((name) => [ + name, + name === '@tangle-network/braid' + ? String(packageJson.version ?? 'unknown') + : String(dependencies[name] ?? 'unknown'), + ]), + ) +} + +export async function packageProvenance( + env: Readonly> = process.env, +): Promise { + const packageRootValue = env.BRAID_EVAL_PACKAGE_ROOT?.trim() + const tarballPathValue = env.BRAID_EVAL_TARBALL_PATH?.trim() + const packageRoot = + packageRootValue === undefined || packageRootValue.length === 0 + ? null + : resolve(packageRootValue) + const tarballPath = + tarballPathValue === undefined || tarballPathValue.length === 0 + ? null + : resolve(tarballPathValue) + if (packageRoot === null) { + return { + packageRoot: null, + tarballPath, + tarballSha256: null, + installedPackageJsonSha256: null, + status: 'unavailable', + reason: + 'BRAID_EVAL_PACKAGE_ROOT is required; source modules are not admissible release presenters', + } + } + try { + const packageJson = await readFile(join(packageRoot, 'package.json')) + const tarball = tarballPath === null ? null : await readFile(tarballPath) + return { + packageRoot, + tarballPath, + tarballSha256: tarball === null ? null : hashBytes(tarball), + installedPackageJsonSha256: hashBytes(packageJson), + status: tarball === null ? 'unavailable' : 'ready', + reason: + tarball === null ? 'BRAID_EVAL_TARBALL_PATH is required for release provenance' : null, + } + } catch (error) { + return { + packageRoot, + tarballPath, + tarballSha256: null, + installedPackageJsonSha256: null, + status: 'unavailable', + reason: error instanceof Error ? error.message : String(error), + } + } +} + +export function baseCalibration(reason: string | null): CalibrationSummary { + return { + passed: false, + pairedExamples: 0, + minimumPairedExamples: 12, + goodPreferred: 0, + minimumGoodPreferred: 0, + ties: 0, + reversals: 0, + pairPreferenceRate: 0, + strongMean: 0, + weakMean: 0, + trivialMean: 0, + strongWeakMargin: 0, + trivialStrongRatio: 1, + minimumMargin: 0.2, + maximumTrivialRatio: 0.8, + perCategory: SEMANTIC_EVAL_CASE_IDS.map((caseId) => ({ + caseId, + category: SEMANTIC_EVAL_CATEGORIES[caseId], + pairCount: 0, + goodPreferred: 0, + ties: 0, + reversals: 0, + trivialComparisons: 0, + trivialRejected: false, + goodMean: 0, + badMean: 0, + trivialMean: 0, + })), + pairOutcomes: [], + failures: reason === null ? ['calibration did not run'] : [reason], + cells: [], + } +} + +export function emptyPilotInspection(reason: string): PilotInspection { + return { + passed: false, + productAvailable: false, + parseableJudgeScore: false, + rawJudgeCallCaptured: false, + usageCaptured: false, + costCaptured: false, + wallTimeCaptured: false, + failures: [reason], + } +} + +export function basePilot( + status: 'passed' | 'failed' | 'unavailable', + reason: string, +): SemanticEvalRecord['pilot'] { + return { + status, + caseId: 'EVAL-01', + fixtureId: null, + score: null, + campaign: null, + rawJudgeCallIds: [], + inspection: emptyPilotInspection(reason), + cost: { + usd: 0, + inputTokens: 0, + outputTokens: 0, + reasoningTokens: null, + cachedTokens: 0, + wallTimeMs: 0, + provenance: reason, + }, + } +} + +export function makeRecord(input: { + readonly startedAt: string + readonly finishedAt: string + readonly route: EvalRouteConfig + readonly packageProvenance: EvalPackageProvenance + readonly provider?: EvalProviderIdentity + readonly routeProbe: unknown + readonly packageVersions: Readonly> + readonly calibration: CalibrationSummary + readonly pilot: SemanticEvalRecord['pilot'] + readonly cases: readonly SemanticCaseEvidence[] + readonly rawJudgeCalls: readonly RecordedJudgeCall[] + readonly ledger: CostLedger + readonly artifacts: readonly EvalArtifactReference[] + readonly status: SemanticEvalRecord['status'] + readonly releaseAdmissible: boolean + readonly releaseFailureReasons: readonly string[] + readonly unavailableReason: string | null +}): SemanticEvalRecord { + if (input.releaseAdmissible !== (input.status === 'passed')) { + throw new Error('Semantic release admissibility must match the final evaluation status') + } + if (input.releaseAdmissible && input.releaseFailureReasons.length > 0) { + throw new Error('An admissible semantic evaluation cannot contain release failure reasons') + } + const material: Omit = { + schemaVersion: 2, + recordId: `braid-semantic-eval-${input.startedAt.replace(/[^0-9]/gu, '')}`, + status: input.status, + startedAt: input.startedAt, + finishedAt: input.finishedAt, + durationMs: Math.max(0, Date.parse(input.finishedAt) - Date.parse(input.startedAt)), + command: 'pnpm test:eval', + packageVersions: input.packageVersions, + packageProvenance: input.packageProvenance, + provider: input.provider ?? providerIdentity(input.route), + routeProbe: redactEvalValue(input.routeProbe), + calibration: redactEvalValue(input.calibration) as CalibrationSummary, + pilot: redactEvalValue(input.pilot) as SemanticEvalRecord['pilot'], + cases: redactEvalValue(input.cases) as readonly SemanticCaseEvidence[], + rawJudgeCalls: redactEvalValue(input.rawJudgeCalls) as readonly RecordedJudgeCall[], + receipts: receiptsView(input.ledger), + artifacts: input.artifacts, + streams: { + stdout: { + captured: false, + sha256: null, + reason: 'The outer release collector captures and signs this command stream.', + }, + stderr: { + captured: false, + sha256: null, + reason: 'The outer release collector captures and signs this command stream.', + }, + }, + releaseAdmissible: input.releaseAdmissible, + releaseFailureReasons: input.releaseFailureReasons, + unavailableReason: input.unavailableReason, + } + return withArtifactHash(material) +} + +export async function writeRecord(outputDir: string, record: SemanticEvalRecord): Promise { + assertSemanticEvalRecord(record) + const path = join(outputDir, 'semantic-evaluation-record.json') + await writeJsonArtifact(path, 'semantic-evaluation-record', record) + return path +} + +export async function ensureOutputDirectory(path: string): Promise { + await mkdir(path, { recursive: true, mode: 0o700 }) + return path +} diff --git a/src/eval/records.ts b/src/eval/records.ts new file mode 100644 index 0000000..35d2ed8 --- /dev/null +++ b/src/eval/records.ts @@ -0,0 +1,380 @@ +import { createHash } from 'node:crypto' +import { writeFile } from 'node:fs/promises' +import type { CostLedger, CostReceipt } from '@tangle-network/agent-eval' +import type { CampaignResult } from '@tangle-network/agent-eval/contract' +import { canonicalJson } from '../domain/canonical.js' +import type { + CalibrationSummary, + EvalProviderIdentity, + ProductOutput, + RecordedJudgeCall, + SemanticCaseEvidence, + SemanticCellEvidence, + SemanticEvalArtifact, + SemanticEvalScenario, +} from './types.js' +import { SEMANTIC_EVAL_CASE_IDS } from './types.js' + +const SECRET_KEY = + /(api[_-]?key|authorization|bearer|cookie|credential|password|passphrase|private[_-]?key|secret|token)/iu +const SECRET_ASSIGNMENT = + /(api[_-]?key|authorization|bearer|cookie|credential|password|passphrase|private[_-]?key|secret|token)\s*[:=]\s*(?:"[^"]*"|'[^']*'|[^\s,;}]+)/giu + +/** Deliberate values used by tests and live-record redaction checks. */ +export const EVAL_SECRET_CANARIES = Object.freeze([ + 'BRAID_EVAL_SECRET_CANARY', + 'braid-eval-secret-canary', + 'BRAID_EVAL_SECRET', + 'CANARY', +]) + +export const SEMANTIC_EVAL_RECORD_SCHEMA_VERSION = 2 as const + +export interface EvalArtifactReference { + readonly artifactId: string + readonly sha256: string + readonly path: string +} + +export interface PilotInspection { + readonly passed: boolean + readonly productAvailable: boolean + readonly parseableJudgeScore: boolean + readonly rawJudgeCallCaptured: boolean + readonly usageCaptured: boolean + readonly costCaptured: boolean + readonly wallTimeCaptured: boolean + readonly failures: readonly string[] +} + +export interface EvalPackageProvenance { + readonly packageRoot: string | null + readonly tarballPath: string | null + readonly tarballSha256: string | null + readonly installedPackageJsonSha256: string | null + readonly status: 'ready' | 'unavailable' + readonly reason: string | null +} + +export interface SemanticEvalRecord { + readonly schemaVersion: typeof SEMANTIC_EVAL_RECORD_SCHEMA_VERSION + readonly recordId: string + readonly status: 'passed' | 'failed' | 'unavailable' + readonly startedAt: string + readonly finishedAt: string + readonly durationMs: number + readonly command: string + readonly packageVersions: Readonly> + readonly packageProvenance: EvalPackageProvenance + readonly provider: EvalProviderIdentity + readonly routeProbe: unknown + readonly calibration: CalibrationSummary + readonly pilot: { + readonly status: 'passed' | 'failed' | 'unavailable' + readonly caseId: 'EVAL-01' + readonly fixtureId: string | null + readonly score: number | null + readonly campaign: unknown | null + readonly rawJudgeCallIds: readonly string[] + readonly inspection: PilotInspection + readonly cost: { + readonly usd: number + readonly inputTokens: number + readonly outputTokens: number + readonly reasoningTokens: number | null + readonly cachedTokens: number + readonly wallTimeMs: number + readonly provenance: unknown + } + } + readonly cases: readonly SemanticCaseEvidence[] + readonly rawJudgeCalls: readonly RecordedJudgeCall[] + readonly receipts: readonly unknown[] + readonly artifacts: readonly EvalArtifactReference[] + readonly streams: { + readonly stdout: { readonly captured: false; readonly sha256: null; readonly reason: string } + readonly stderr: { readonly captured: false; readonly sha256: null; readonly reason: string } + } + readonly releaseAdmissible: boolean + readonly releaseFailureReasons: readonly string[] + readonly unavailableReason: string | null + readonly artifactHash: string +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&') +} + +function redactString(value: string): string { + let output = value.replace(SECRET_ASSIGNMENT, '$1=[REDACTED]') + for (const canary of EVAL_SECRET_CANARIES) { + output = output.replace(new RegExp(escapeRegExp(canary), 'giu'), '[REDACTED_SECRET]') + } + return output +} + +export function containsEvalSecretCanary(value: unknown): boolean { + const serialized = typeof value === 'string' ? value : (JSON.stringify(value) ?? '') + return EVAL_SECRET_CANARIES.some((canary) => + serialized.toLocaleLowerCase().includes(canary.toLocaleLowerCase()), + ) +} + +export function redactEvalValue(value: unknown, _key?: string): unknown { + if (typeof value === 'string') return redactString(value) + if (Array.isArray(value)) return value.map((entry) => redactEvalValue(entry)) + if (value === null || typeof value !== 'object') return value + return Object.fromEntries( + Object.entries(value).map(([entryKey, entryValue]) => [ + entryKey, + SECRET_KEY.test(entryKey) ? '[REDACTED]' : redactEvalValue(entryValue, entryKey), + ]), + ) +} + +export function evalCanonicalJson(value: unknown): string { + return canonicalJson(redactEvalValue(value)) +} + +export function evalSha256(value: unknown): string { + return `sha256:${createHash('sha256').update(evalCanonicalJson(value)).digest('hex')}` +} + +export function hashText(value: string): string { + return `sha256:${createHash('sha256').update(value).digest('hex')}` +} + +export function hashArtifact(value: unknown): string { + return evalSha256(value) +} + +export function receiptView(receipt: CostReceipt): unknown { + return redactEvalValue({ + callId: receipt.callId, + channel: receipt.channel, + phase: receipt.phase, + actor: receipt.actor, + model: receipt.model, + maximumCostUsd: receipt.maximumCostUsd ?? null, + tags: receipt.tags ?? null, + timestamp: receipt.timestamp, + status: receipt.status, + inputTokens: receipt.inputTokens, + outputTokens: receipt.outputTokens, + reasoningTokens: receipt.reasoningTokens ?? null, + cachedTokens: receipt.cachedTokens ?? 0, + cacheWriteTokens: receipt.cacheWriteTokens ?? 0, + costUsd: receipt.costUsd, + costUnknown: receipt.costUnknown, + usageUnknown: receipt.usageUnknown ?? false, + actualCostUsd: receipt.actualCostUsd ?? null, + estimatedCostUsd: receipt.estimatedCostUsd ?? null, + pricing: receipt.pricing ?? null, + error: receipt.error ?? null, + }) +} + +export function receiptsView(ledger: CostLedger): readonly unknown[] { + return ledger.list().map(receiptView) +} + +export function cellEvidence( + cell: CampaignResult['cells'][number], + rawCalls: readonly RecordedJudgeCall[], + criteriaThreshold: number, + ledger: CostLedger, +): SemanticCellEvidence { + const callIds = cell.costCallIds ?? [] + const judgeScores = Object.fromEntries( + Object.entries(cell.judgeScores).map(([key, score]) => [ + key, + redactEvalValue(score) as typeof score, + ]), + ) + const compositeValues = Object.values(cell.judgeScores) + .filter((score) => score.failed !== true) + .map((score) => score.composite) + const composite = compositeValues.length === 0 ? 0 : Math.min(...compositeValues) + const calls = rawCalls.filter((call) => call.callId !== null && callIds.includes(call.callId)) + const productPath = + cell.artifact.productPath === undefined + ? null + : (redactEvalValue(cell.artifact.productPath) as ProductOutput) + return { + cellId: cell.cellId, + scenarioId: cell.scenarioId, + fixtureId: cell.artifact.fixtureId, + semanticOutput: redactEvalValue(cell.artifact.semanticOutput), + candidateOutput: redactString(cell.artifact.candidateOutput), + productPath, + judgeScores, + pass: + cell.error === undefined && + productPath?.available !== false && + compositeValues.length > 0 && + composite >= criteriaThreshold, + rawJudgeCallIds: calls.flatMap((call) => (call.callId === null ? [] : [call.callId])), + cost: cellCostEvidence(cell, rawCalls, ledger), + } +} + +export function cellCostReceipts( + cell: CampaignResult['cells'][number], + ledger: CostLedger, +): readonly CostReceipt[] { + const callIds = new Set(cell.costCallIds ?? []) + return ledger.list().filter((receipt) => callIds.has(receipt.callId)) +} + +export function cellCostEvidence( + cell: CampaignResult['cells'][number], + rawCalls: readonly RecordedJudgeCall[], + ledger: CostLedger, +): SemanticCellEvidence['cost'] { + const callIds = new Set(cell.costCallIds ?? []) + const receipts = cellCostReceipts(cell, ledger) + const calls = rawCalls.filter((call) => call.callId !== null && callIds.has(call.callId)) + const reasoning = receipts.flatMap((receipt) => + receipt.reasoningTokens === undefined ? [] : [receipt.reasoningTokens], + ) + return { + usd: receipts.reduce((sum, receipt) => sum + receipt.costUsd, 0), + provenance: receipts.length === 0 ? null : receipts.map(receiptView), + inputTokens: receipts.reduce((sum, receipt) => sum + receipt.inputTokens, 0), + outputTokens: receipts.reduce((sum, receipt) => sum + receipt.outputTokens, 0), + reasoningTokens: + reasoning.length === 0 ? null : reasoning.reduce((sum, value) => sum + value, 0), + cachedTokens: receipts.reduce((sum, receipt) => sum + (receipt.cachedTokens ?? 0), 0), + wallTimeMs: Math.max(cell.durationMs, ...calls.map((call) => call.wallTimeMs)), + } +} + +export function recordHashMaterial(record: Omit): unknown { + return redactEvalValue(record) +} + +export function withArtifactHash( + record: Omit, +): SemanticEvalRecord { + return { ...record, artifactHash: evalSha256(recordHashMaterial(record)) } +} + +function requiredNumber(value: unknown, label: string, errors: string[]): void { + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) + errors.push(`${label} must be a finite non-negative number`) +} + +export function validateSemanticEvalRecord(record: unknown): readonly string[] { + const errors: string[] = [] + if (record === null || typeof record !== 'object') return ['record must be an object'] + const candidate = record as Partial + if (candidate.schemaVersion !== SEMANTIC_EVAL_RECORD_SCHEMA_VERSION) + errors.push(`schemaVersion must be ${SEMANTIC_EVAL_RECORD_SCHEMA_VERSION}`) + if (typeof candidate.recordId !== 'string' || candidate.recordId.length === 0) + errors.push('recordId is required') + if (!['passed', 'failed', 'unavailable'].includes(candidate.status ?? '')) + errors.push('status is invalid') + if (typeof candidate.startedAt !== 'string' || typeof candidate.finishedAt !== 'string') + errors.push('timestamps are required') + requiredNumber(candidate.durationMs, 'durationMs', errors) + if (candidate.cases === undefined || !Array.isArray(candidate.cases)) + errors.push('cases are required') + else { + const ids = candidate.cases.map((item) => item.id) + for (const id of SEMANTIC_EVAL_CASE_IDS) { + if (ids.filter((value) => value === id).length !== 1) + errors.push(`${id} must occur exactly once`) + } + if (candidate.status === 'passed') { + for (const evidence of candidate.cases) { + if (evidence.result !== 'passed') errors.push(`${evidence.id} must pass in a passed record`) + if (evidence.fixtureCount < 3) + errors.push(`${evidence.id} requires at least three release fixtures`) + if ( + evidence.passedFixtures !== evidence.fixtureCount || + evidence.failedFixtures !== 0 || + evidence.cells.length !== evidence.fixtureCount + ) { + errors.push(`${evidence.id} fixture results are incomplete`) + } + if (evidence.productFailures.length > 0 || evidence.campaign === null) { + errors.push(`${evidence.id} product or campaign evidence is incomplete`) + } + } + } + } + if (candidate.calibration === undefined) errors.push('calibration is required') + else { + if (!Array.isArray(candidate.calibration.pairOutcomes)) + errors.push('calibration pair outcomes are required') + if ( + !Array.isArray(candidate.calibration.perCategory) || + candidate.calibration.perCategory.length !== 6 + ) + errors.push('calibration must record six category outcomes') + if (candidate.status === 'passed' && candidate.calibration.passed !== true) + errors.push('passed record requires passed calibration') + } + if (candidate.pilot === undefined || candidate.pilot.caseId !== 'EVAL-01') + errors.push('pilot must be EVAL-01') + else if ( + candidate.status === 'passed' && + (candidate.pilot.status !== 'passed' || candidate.pilot.inspection.passed !== true) + ) { + errors.push('passed record requires a passed pilot') + } + if (candidate.rawJudgeCalls === undefined || !Array.isArray(candidate.rawJudgeCalls)) + errors.push('raw judge calls are required') + else { + for (const call of candidate.rawJudgeCalls) { + if (call.response === null && call.error === null) + errors.push('judge call must have response or error') + if (call.response !== null && call.error !== null) + errors.push('judge call cannot have response and error') + requiredNumber(call.wallTimeMs, 'judge call wall time', errors) + } + } + if (candidate.streams?.stdout.captured !== false || candidate.streams.stderr.captured !== false) + errors.push('stream capture posture must be explicit') + if (candidate.status === 'passed') { + if (candidate.releaseAdmissible !== true) + errors.push('passed record must be release-admissible') + if (candidate.packageProvenance?.status !== 'ready') { + errors.push('passed record requires ready installed-package provenance') + } + if ( + !Array.isArray(candidate.releaseFailureReasons) || + candidate.releaseFailureReasons.length > 0 + ) { + errors.push('passed record cannot contain release failure reasons') + } + if (candidate.unavailableReason !== null) errors.push('passed record cannot be unavailable') + } else if (candidate.releaseAdmissible === true) { + errors.push('only a passed record can be release-admissible') + } + if (candidate.artifactHash !== undefined) { + const material = { ...candidate } + delete (material as { artifactHash?: string }).artifactHash + const expected = evalSha256(material) + if (candidate.artifactHash !== expected) + errors.push('artifactHash does not match canonical record') + } else errors.push('artifactHash is required') + if (containsEvalSecretCanary(record)) + errors.push('record contains an unredacted seeded secret canary') + return errors +} + +export function assertSemanticEvalRecord(record: unknown): asserts record is SemanticEvalRecord { + const errors = validateSemanticEvalRecord(record) + if (errors.length > 0) throw new Error(`Invalid semantic evaluation record: ${errors.join('; ')}`) +} + +export async function writeJsonArtifact( + path: string, + artifactId: string, + value: unknown, +): Promise { + const content = `${evalCanonicalJson(value)}\n` + await writeFile(path, content, { encoding: 'utf8', mode: 0o600 }) + return { artifactId, sha256: hashText(content), path } +} diff --git a/src/eval/release-decision.ts b/src/eval/release-decision.ts new file mode 100644 index 0000000..f864e10 --- /dev/null +++ b/src/eval/release-decision.ts @@ -0,0 +1,114 @@ +import type { SemanticEvalRecord } from './records.js' +import type { RecordedJudgeCall, SemanticCaseEvidence } from './types.js' +import { SEMANTIC_EVAL_CASE_IDS } from './types.js' + +export interface SemanticReleaseDecisionInput { + readonly packageReady: boolean + readonly calibration: SemanticEvalRecord['calibration'] + readonly pilot: SemanticEvalRecord['pilot'] + readonly cases: readonly SemanticCaseEvidence[] + readonly rawJudgeCalls: readonly RecordedJudgeCall[] +} + +export interface SemanticReleaseDecision { + readonly status: 'passed' | 'failed' + readonly releaseAdmissible: boolean + readonly reasons: readonly string[] +} + +function finiteNonNegative(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 +} + +function caseFailures( + evidence: SemanticCaseEvidence, + calls: ReadonlyMap, +): string[] { + const failures: string[] = [] + if (evidence.result !== 'passed') failures.push(`${evidence.id} result is ${evidence.result}`) + if (evidence.fixtureCount < 3) { + failures.push( + `${evidence.id} has ${evidence.fixtureCount} release fixtures; at least 3 are required`, + ) + } + if ( + evidence.passedFixtures !== evidence.fixtureCount || + evidence.failedFixtures !== 0 || + evidence.cells.length !== evidence.fixtureCount + ) { + failures.push( + `${evidence.id} passed ${evidence.passedFixtures}/${evidence.fixtureCount} fixtures with ${evidence.cells.length} recorded cells`, + ) + } + if (evidence.productFailures.length > 0) { + failures.push( + `${evidence.id} has ${evidence.productFailures.length} product presentation failures`, + ) + } + if (evidence.campaign === null) failures.push(`${evidence.id} has no campaign record`) + for (const cell of evidence.cells) { + if (!cell.pass) failures.push(`${evidence.id}/${cell.fixtureId} did not pass`) + if (cell.productPath?.available !== true) { + failures.push(`${evidence.id}/${cell.fixtureId} did not use an available product presenter`) + } + if (cell.rawJudgeCallIds.length === 0) { + failures.push(`${evidence.id}/${cell.fixtureId} has no raw judge call`) + } + for (const callId of cell.rawJudgeCallIds) { + const call = calls.get(callId) + if (call === undefined || call.response === null || call.error !== null) { + failures.push(`${evidence.id}/${cell.fixtureId} judge call ${callId} is missing or failed`) + } + } + const cost = cell.cost + if ( + !finiteNonNegative(cost.usd) || + !finiteNonNegative(cost.inputTokens) || + !finiteNonNegative(cost.outputTokens) || + !finiteNonNegative(cost.cachedTokens) || + !finiteNonNegative(cost.wallTimeMs) || + cost.wallTimeMs === 0 || + cost.provenance === null || + cost.provenance === undefined + ) { + failures.push( + `${evidence.id}/${cell.fixtureId} has incomplete usage, cost, or timing evidence`, + ) + } + } + return failures +} + +/** Decide semantic release eligibility from complete recorded evidence only. */ +export function semanticReleaseDecision( + input: SemanticReleaseDecisionInput, +): SemanticReleaseDecision { + const reasons: string[] = [] + if (!input.packageReady) reasons.push('installed package provenance is unavailable') + if (!input.calibration.passed) reasons.push('judge calibration did not pass') + if (input.pilot.status !== 'passed' || !input.pilot.inspection.passed) { + reasons.push('the pre-calibration pilot did not pass') + } + + const calls = new Map( + input.rawJudgeCalls.flatMap((call) => + call.callId === null ? [] : ([[call.callId, call]] as const), + ), + ) + for (const id of SEMANTIC_EVAL_CASE_IDS) { + const matches = input.cases.filter((evidence) => evidence.id === id) + if (matches.length !== 1) { + reasons.push(`${id} occurs ${matches.length} times instead of exactly once`) + continue + } + const evidence = matches[0] + if (evidence !== undefined) reasons.push(...caseFailures(evidence, calls)) + } + + const releaseAdmissible = reasons.length === 0 + return { + status: releaseAdmissible ? 'passed' : 'failed', + releaseAdmissible, + reasons, + } +} diff --git a/src/eval/release-markers.ts b/src/eval/release-markers.ts new file mode 100644 index 0000000..38736af --- /dev/null +++ b/src/eval/release-markers.ts @@ -0,0 +1,104 @@ +import type { SemanticEvalRecord } from './records.js' + +export interface SemanticEvalMeasurement { + readonly kind: 'scalar' + readonly name: string + readonly unit: string + readonly value: number +} + +function scalar(name: string, unit: string, value: number): SemanticEvalMeasurement { + if (!Number.isFinite(value)) + throw new Error(`Semantic evaluation measurement ${name} is not finite`) + return { kind: 'scalar', name, unit, value } +} + +function receiptNumber(receipt: unknown, key: string): number { + if (receipt === null || typeof receipt !== 'object') return 0 + const value = (receipt as Readonly>)[key] + return typeof value === 'number' && Number.isFinite(value) ? value : 0 +} + +/** Complete scalar summary consumed by the signed outer release collector. */ +export function semanticEvalMeasurements( + record: SemanticEvalRecord, +): readonly SemanticEvalMeasurement[] { + if (record.status !== 'passed' || !record.releaseAdmissible) { + throw new Error('Only an admissible passed semantic evaluation can emit release measurements') + } + const fixtures = record.cases.flatMap((evidence) => evidence.cells) + const successfulCalls = record.rawJudgeCalls.filter( + (call) => call.response !== null && call.error === null, + ) + const reasoningReceipts = record.receipts.filter( + (receipt) => receiptNumber(receipt, 'reasoningTokens') > 0, + ) + const measurements: SemanticEvalMeasurement[] = [ + scalar( + 'semantic-cases-passed', + 'count', + record.cases.filter((entry) => entry.result === 'passed').length, + ), + scalar('semantic-fixtures-passed', 'count', fixtures.filter((entry) => entry.pass).length), + scalar('semantic-fixtures-total', 'count', fixtures.length), + scalar('calibration-pairs', 'count', record.calibration.pairedExamples), + scalar('calibration-good-preferred', 'count', record.calibration.goodPreferred), + scalar('calibration-ties', 'count', record.calibration.ties), + scalar('calibration-reversals', 'count', record.calibration.reversals), + scalar('calibration-preference-rate', 'ratio', record.calibration.pairPreferenceRate), + scalar('calibration-good-mean', 'score', record.calibration.strongMean), + scalar('calibration-bad-mean', 'score', record.calibration.weakMean), + scalar('calibration-trivial-mean', 'score', record.calibration.trivialMean), + scalar('calibration-good-bad-margin', 'score', record.calibration.strongWeakMargin), + scalar('calibration-trivial-good-ratio', 'ratio', record.calibration.trivialStrongRatio), + scalar('pilot-score', 'score', record.pilot.score ?? 0), + scalar('judge-calls-total', 'count', record.rawJudgeCalls.length), + scalar('judge-calls-successful', 'count', successfulCalls.length), + scalar('cost-receipts', 'count', record.receipts.length), + scalar( + 'input-tokens', + 'tokens', + record.receipts.reduce( + (sum, receipt) => sum + receiptNumber(receipt, 'inputTokens'), + 0, + ), + ), + scalar( + 'output-tokens', + 'tokens', + record.receipts.reduce( + (sum, receipt) => sum + receiptNumber(receipt, 'outputTokens'), + 0, + ), + ), + scalar( + 'reasoning-tokens', + 'tokens', + record.receipts.reduce( + (sum, receipt) => sum + receiptNumber(receipt, 'reasoningTokens'), + 0, + ), + ), + scalar('reasoning-token-receipts', 'count', reasoningReceipts.length), + scalar( + 'cached-tokens', + 'tokens', + record.receipts.reduce( + (sum, receipt) => sum + receiptNumber(receipt, 'cachedTokens'), + 0, + ), + ), + scalar( + 'evaluation-cost', + 'USD', + record.receipts.reduce((sum, receipt) => sum + receiptNumber(receipt, 'costUsd'), 0), + ), + scalar('evaluation-wall-time', 'ms', record.durationMs), + ] + for (const evidence of record.cases) { + measurements.push( + scalar(`${evidence.id.toLowerCase()}-fixtures-passed`, 'count', evidence.passedFixtures), + ) + } + return measurements +} diff --git a/src/eval/runner.ts b/src/eval/runner.ts new file mode 100644 index 0000000..ed644a3 --- /dev/null +++ b/src/eval/runner.ts @@ -0,0 +1,461 @@ +import { mkdtemp } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { CostLedger } from '@tangle-network/agent-eval' +import { type CalibrationRun, runCalibration } from './calibration.js' +import { + assertHeldOutInputs, + caseEvidence, + inspectPilot, + runPilotCampaign, + runReleaseCampaign, + unavailableCaseEvidence, +} from './campaigns.js' +import { definitionForCase, SEMANTIC_CASES, scenariosForRelease } from './cases.js' +import { + createEvalChatClient, + type EvalRouteConfig, + probeCliBridge, + readEvalRouteConfig, + recordingChatClient, +} from './execution.js' +import { prepareReleaseProductOutputs, releaseFixtureProductReady } from './fixtures.js' +import { + baseCalibration, + basePilot, + ensureOutputDirectory, + makeRecord, + packageProvenance, + packageVersions, + writeRecord, +} from './record-builder.js' +import { + cellEvidence, + type EvalArtifactReference, + type SemanticEvalRecord, + writeJsonArtifact, +} from './records.js' +import { semanticReleaseDecision } from './release-decision.js' +import type { RecordedJudgeCall, SemanticCaseEvidence } from './types.js' + +export interface SemanticEvalRunOptions { + readonly rootDir?: string + readonly env?: Readonly> + readonly fetchImpl?: typeof fetch + readonly outputDir?: string + readonly costCeilingUsd?: number +} + +export interface SemanticEvalRunResult { + readonly record: SemanticEvalRecord + readonly outputDir: string +} + +function now(): string { + return new Date().toISOString() +} + +async function outputDirectory(requested: string | undefined): Promise { + if (requested !== undefined) return ensureOutputDirectory(requested) + return mkdtemp(join(tmpdir(), 'braid-semantic-eval-')) +} + +function stopCases(reason: string): readonly SemanticCaseEvidence[] { + return SEMANTIC_CASES.map((definition) => unavailableCaseEvidence(definition, reason)) +} + +async function stoppedRun(input: { + readonly outputDir: string + readonly startedAt: string + readonly route: EvalRouteConfig + readonly routeProbe: unknown + readonly packageProvenance: Awaited> + readonly packageVersionMap: Readonly> + readonly ledger: CostLedger + readonly rawJudgeCalls: readonly RecordedJudgeCall[] + readonly artifacts: readonly EvalArtifactReference[] + readonly calibration: SemanticEvalRecord['calibration'] + readonly pilot: SemanticEvalRecord['pilot'] + readonly status: SemanticEvalRecord['status'] + readonly reason: string +}): Promise { + const record = makeRecord({ + startedAt: input.startedAt, + finishedAt: now(), + route: input.route, + packageProvenance: input.packageProvenance, + routeProbe: input.routeProbe, + packageVersions: input.packageVersionMap, + calibration: input.calibration, + pilot: input.pilot, + cases: stopCases(input.reason), + rawJudgeCalls: input.rawJudgeCalls, + ledger: input.ledger, + artifacts: input.artifacts, + status: input.status, + releaseAdmissible: false, + releaseFailureReasons: [input.reason], + unavailableReason: input.status === 'unavailable' ? input.reason : null, + }) + await writeRecord(input.outputDir, record) + return { record, outputDir: input.outputDir } +} + +function pilotRecord( + execution: Awaited>, + inspection: ReturnType, + rawCalls: readonly RecordedJudgeCall[], + threshold: number, +): SemanticEvalRecord['pilot'] { + const cell = execution.campaign?.cells[0] + const evidence = + cell === undefined || execution.campaign === null + ? null + : cellEvidence(cell, rawCalls, threshold, execution.ledger) + return { + status: inspection.passed ? 'passed' : 'failed', + caseId: 'EVAL-01', + fixtureId: execution.scenarios[0]?.fixtureId ?? null, + score: evidence === null ? null : (Object.values(evidence.judgeScores)[0]?.composite ?? null), + campaign: execution.campaign, + rawJudgeCallIds: evidence?.rawJudgeCallIds ?? [], + inspection, + cost: evidence?.cost ?? { + usd: 0, + inputTokens: 0, + outputTokens: 0, + reasoningTokens: null, + cachedTokens: 0, + wallTimeMs: 0, + provenance: null, + }, + } +} + +export async function runSemanticEvaluation( + options: SemanticEvalRunOptions = {}, +): Promise { + const startedAt = now() + const rootDir = options.rootDir ?? process.cwd() + const outputDir = await outputDirectory(options.outputDir ?? options.env?.BRAID_EVAL_OUTPUT_DIR) + const route = readEvalRouteConfig(options.env) + const packageVersionMap = await packageVersions(rootDir) + const packageProof = await packageProvenance(options.env) + const costCeilingUsd = + options.costCeilingUsd ?? Number(options.env?.BRAID_EVAL_MAX_COST_USD ?? '1') + if (!Number.isFinite(costCeilingUsd) || costCeilingUsd <= 0) + throw new Error('BRAID_EVAL_MAX_COST_USD must be positive') + const ledger = new CostLedger(costCeilingUsd) + const rawJudgeCalls: RecordedJudgeCall[] = [] + const artifacts: EvalArtifactReference[] = [] + + artifacts.push( + await writeJsonArtifact( + join(outputDir, 'package-provenance.json'), + 'package-provenance', + packageProof, + ), + ) + if (packageProof.status === 'unavailable' || packageProof.packageRoot === null) { + const reason = packageProof.reason ?? 'installed candidate package root unavailable' + return stoppedRun({ + outputDir, + startedAt, + route, + routeProbe: null, + packageProvenance: packageProof, + packageVersionMap, + ledger, + rawJudgeCalls, + artifacts, + calibration: baseCalibration(reason), + pilot: basePilot('failed', reason), + status: 'failed', + reason, + }) + } + try { + await prepareReleaseProductOutputs(packageProof.packageRoot) + } catch (error) { + const reason = error instanceof Error ? error.message : String(error) + artifacts.push( + await writeJsonArtifact(join(outputDir, 'presenter-failure.json'), 'presenter-failure', { + reason, + }), + ) + return stoppedRun({ + outputDir, + startedAt, + route, + routeProbe: null, + packageProvenance: packageProof, + packageVersionMap, + ledger, + rawJudgeCalls, + artifacts, + calibration: baseCalibration(reason), + pilot: basePilot('failed', reason), + status: 'failed', + reason, + }) + } + if (!releaseFixtureProductReady()) { + const reason = + 'Installed Braid presenters did not produce source-matched output for every release fixture' + artifacts.push( + await writeJsonArtifact(join(outputDir, 'presenter-failure.json'), 'presenter-failure', { + reason, + }), + ) + return stoppedRun({ + outputDir, + startedAt, + route, + routeProbe: null, + packageProvenance: packageProof, + packageVersionMap, + ledger, + rawJudgeCalls, + artifacts, + calibration: baseCalibration(reason), + pilot: basePilot('failed', reason), + status: 'failed', + reason, + }) + } + + try { + assertHeldOutInputs(SEMANTIC_CASES) + } catch (error) { + const reason = error instanceof Error ? error.message : String(error) + artifacts.push( + await writeJsonArtifact(join(outputDir, 'fixture-failure.json'), 'fixture-failure', { + reason, + }), + ) + return stoppedRun({ + outputDir, + startedAt, + route, + routeProbe: null, + packageProvenance: packageProof, + packageVersionMap, + ledger, + rawJudgeCalls, + artifacts, + calibration: baseCalibration(reason), + pilot: basePilot('failed', reason), + status: 'failed', + reason, + }) + } + + const probe = await probeCliBridge(route, options.fetchImpl) + artifacts.push(await writeJsonArtifact(join(outputDir, 'route-probe.json'), 'route-probe', probe)) + if (probe.status === 'unavailable') { + return stoppedRun({ + outputDir, + startedAt, + route, + routeProbe: probe, + packageProvenance: packageProof, + packageVersionMap, + ledger, + rawJudgeCalls, + artifacts, + calibration: baseCalibration(probe.reason), + pilot: basePilot('unavailable', probe.reason), + status: 'unavailable', + reason: probe.reason, + }) + } + + const chat = recordingChatClient(createEvalChatClient(route), rawJudgeCalls, { + callTimeoutMs: route.timeoutMs, + totalTimeoutMs: route.totalTimeoutMs, + }) + const pilotDefinition = definitionForCase('EVAL-01') + let pilotExecution: Awaited> + try { + pilotExecution = await runPilotCampaign({ + definition: pilotDefinition, + chat, + ledger, + runDir: join(outputDir, 'campaigns', 'pilot'), + }) + } catch (error) { + const reason = error instanceof Error ? error.message : String(error) + artifacts.push( + await writeJsonArtifact(join(outputDir, 'pilot-failure.json'), 'pilot-failure', { reason }), + ) + return stoppedRun({ + outputDir, + startedAt, + route, + routeProbe: probe, + packageProvenance: packageProof, + packageVersionMap, + ledger, + rawJudgeCalls, + artifacts, + calibration: baseCalibration(reason), + pilot: basePilot('failed', reason), + status: 'failed', + reason, + }) + } + + const pilotInspection = inspectPilot( + pilotExecution, + rawJudgeCalls, + pilotDefinition.criteria.passThreshold, + ) + const pilot = pilotRecord( + pilotExecution, + pilotInspection, + rawJudgeCalls, + pilotDefinition.criteria.passThreshold, + ) + artifacts.push(await writeJsonArtifact(join(outputDir, 'pilot.json'), 'pilot', pilot)) + if (!pilotInspection.passed) { + return stoppedRun({ + outputDir, + startedAt, + route, + routeProbe: probe, + packageProvenance: packageProof, + packageVersionMap, + ledger, + rawJudgeCalls, + artifacts, + calibration: baseCalibration(`pilot rejected: ${pilotInspection.failures.join('; ')}`), + pilot, + status: 'failed', + reason: `pilot rejected: ${pilotInspection.failures.join('; ')}`, + }) + } + + let calibration: CalibrationRun + try { + calibration = await runCalibration({ + definitions: SEMANTIC_CASES, + chat, + runDir: join(outputDir, 'campaigns', 'calibration'), + ledger, + }) + } catch (error) { + const reason = error instanceof Error ? error.message : String(error) + artifacts.push( + await writeJsonArtifact(join(outputDir, 'calibration-failure.json'), 'calibration-failure', { + reason, + }), + ) + return stoppedRun({ + outputDir, + startedAt, + route, + routeProbe: probe, + packageProvenance: packageProof, + packageVersionMap, + ledger, + rawJudgeCalls, + artifacts, + calibration: baseCalibration(reason), + pilot, + status: 'failed', + reason, + }) + } + artifacts.push( + await writeJsonArtifact(join(outputDir, 'calibration.json'), 'calibration', { + summary: calibration.summary, + campaigns: calibration.campaigns, + }), + ) + if (!calibration.summary.passed) { + const reason = `calibration rejected: ${calibration.summary.failures.join('; ')}` + return stoppedRun({ + outputDir, + startedAt, + route, + routeProbe: probe, + packageProvenance: packageProof, + packageVersionMap, + ledger, + rawJudgeCalls, + artifacts, + calibration: calibration.summary, + pilot, + status: 'failed', + reason, + }) + } + + const cases: SemanticCaseEvidence[] = [] + const releaseFailureReasons: string[] = [] + for (const definition of SEMANTIC_CASES) { + try { + const execution = await runReleaseCampaign({ + definition, + chat, + ledger, + runDir: join(outputDir, 'campaigns', definition.id), + }) + const evidence = caseEvidence(definition, execution, rawJudgeCalls) + cases.push(evidence) + releaseFailureReasons.push(...evidence.disagreements) + artifacts.push( + await writeJsonArtifact(join(outputDir, `${definition.id}.json`), definition.id, evidence), + ) + } catch (error) { + const reason = error instanceof Error ? error.message : String(error) + const execution = { + scenarios: scenariosForRelease(definition), + productFailures: scenariosForRelease(definition).map((scenario) => ({ + fixtureId: scenario.fixtureId, + reason, + })), + campaign: null, + ledger, + } + const evidence = caseEvidence(definition, execution, rawJudgeCalls) + cases.push(evidence) + releaseFailureReasons.push(`${definition.id}: ${reason}`) + artifacts.push( + await writeJsonArtifact( + join(outputDir, `${definition.id}-failure.json`), + definition.id, + evidence, + ), + ) + } + } + + const decision = semanticReleaseDecision({ + packageReady: packageProof.status === 'ready', + calibration: calibration.summary, + pilot, + cases, + rawJudgeCalls, + }) + const record = makeRecord({ + startedAt, + finishedAt: now(), + route, + packageProvenance: packageProof, + routeProbe: probe, + packageVersions: packageVersionMap, + calibration: calibration.summary, + pilot, + cases, + rawJudgeCalls, + ledger, + artifacts, + status: decision.status, + releaseAdmissible: decision.releaseAdmissible, + releaseFailureReasons: [...releaseFailureReasons, ...decision.reasons], + unavailableReason: null, + }) + await writeRecord(outputDir, record) + return { record, outputDir } +} diff --git a/src/eval/types.ts b/src/eval/types.ts new file mode 100644 index 0000000..68ff9bc --- /dev/null +++ b/src/eval/types.ts @@ -0,0 +1,224 @@ +import type { + CampaignResult, + ChatClient, + JudgeDimension, + JudgeScore, + Scenario, +} from '@tangle-network/agent-eval/contract' + +export type SemanticEvalCaseId = + | 'EVAL-01' + | 'EVAL-02' + | 'EVAL-03' + | 'EVAL-04' + | 'EVAL-05' + | 'EVAL-06' + +export const SEMANTIC_EVAL_CASE_IDS: readonly SemanticEvalCaseId[] = [ + 'EVAL-01', + 'EVAL-02', + 'EVAL-03', + 'EVAL-04', + 'EVAL-05', + 'EVAL-06', +] + +export const SEMANTIC_EVAL_CATEGORIES: Readonly> = { + 'EVAL-01': 'fork-explanation', + 'EVAL-02': 'permission-decision', + 'EVAL-03': 'analysis-usefulness', + 'EVAL-04': 'comparison-honesty', + 'EVAL-05': 'reconnect-status', + 'EVAL-06': 'profile-compatibility', +} + +export type CalibrationLabel = 'good' | 'bad' | 'trivial' + +export interface ProductOutput { + /** Text returned by an existing Braid projection, plain formatter, or TUI panel. */ + readonly text: string + readonly path: 'tui' | 'plain' | 'semantic-projection' | 'unavailable' + readonly available: boolean + readonly missingReason: string | null + readonly sourceDigest: string +} + +export interface CalibrationFixture { + readonly id: string + /** A value produced by Braid application or view code. */ + readonly semanticOutput: unknown + readonly goodOutput: string + readonly badOutput: string + readonly trivialOutput: string +} + +export interface ReleaseFixture { + readonly id: string + /** A held-out Braid semantic value, distinct from calibration inputs. */ + readonly semanticOutput: unknown + /** Produced by the real current Braid presentation path. */ + readonly productOutput: ProductOutput +} + +export interface SemanticReferenceCriteria { + readonly requiredSignals: readonly string[] + readonly forbiddenSignals: readonly string[] + readonly passThreshold: number +} + +export interface SemanticEvalCase { + readonly id: SemanticEvalCaseId + readonly question: string + readonly category: string + readonly prompt: string + readonly dimensions: readonly JudgeDimension[] + readonly criteria: SemanticReferenceCriteria + readonly calibrationFixtures: readonly [ + CalibrationFixture, + CalibrationFixture, + CalibrationFixture, + ] + readonly releaseFixtures: readonly [ReleaseFixture, ReleaseFixture, ReleaseFixture] +} + +export interface SemanticEvalScenario extends Scenario { + readonly caseId: SemanticEvalCaseId + readonly fixtureId: string + readonly semanticOutput: unknown + readonly candidateOutput: string + readonly candidateLabel?: CalibrationLabel + readonly productPath?: ProductOutput +} + +export interface SemanticEvalArtifact { + readonly caseId: SemanticEvalCaseId + readonly fixtureId: string + readonly semanticOutput: unknown + readonly candidateOutput: string + readonly productPath?: ProductOutput +} + +export interface RecordedJudgeCall { + readonly callId: string | null + readonly request: unknown + readonly response: unknown | null + readonly error: { + readonly name: string + readonly message: string + } | null + readonly startedAt: string + readonly finishedAt: string + readonly wallTimeMs: number +} + +export interface CalibrationCellRecord { + readonly scenarioId: string + readonly pairId: string + readonly caseId: SemanticEvalCaseId + readonly fixtureId: string + readonly semanticOutput: unknown + readonly label: CalibrationLabel + readonly output: string + readonly score: JudgeScore | null + readonly campaignCell: string | null +} + +export type PairPreference = 'good' | 'bad' | 'tie' | 'unscorable' + +export interface CalibrationPairOutcome { + readonly pairId: string + readonly caseId: SemanticEvalCaseId + readonly category: string + readonly fixtureId: string + readonly goodScore: number | null + readonly badScore: number | null + readonly delta: number | null + readonly preference: PairPreference + readonly tieTolerance: number + readonly independentlyScored: boolean +} + +export interface CalibrationCategoryOutcome { + readonly caseId: SemanticEvalCaseId + readonly category: string + readonly pairCount: number + readonly goodPreferred: number + readonly ties: number + readonly reversals: number + readonly trivialComparisons: number + readonly trivialRejected: boolean + readonly goodMean: number + readonly badMean: number + readonly trivialMean: number +} + +export interface CalibrationSummary { + readonly passed: boolean + readonly pairedExamples: number + readonly minimumPairedExamples: number + readonly goodPreferred: number + readonly minimumGoodPreferred: number + readonly ties: number + readonly reversals: number + readonly pairPreferenceRate: number + readonly strongMean: number + readonly weakMean: number + readonly trivialMean: number + readonly strongWeakMargin: number + readonly trivialStrongRatio: number + readonly minimumMargin: number + readonly maximumTrivialRatio: number + readonly perCategory: readonly CalibrationCategoryOutcome[] + readonly pairOutcomes: readonly CalibrationPairOutcome[] + readonly failures: readonly string[] + readonly cells: readonly CalibrationCellRecord[] +} + +export interface SemanticCaseEvidence { + readonly id: SemanticEvalCaseId + readonly category: 'eval' + readonly command: string + readonly question: string + readonly referenceCriteria: SemanticReferenceCriteria + readonly fixtureCount: number + readonly passedFixtures: number + readonly failedFixtures: number + readonly artifacts: readonly SemanticEvalArtifact[] + readonly productFailures: readonly { + readonly fixtureId: string + readonly reason: string + }[] + readonly result: 'passed' | 'failed' | 'unavailable' + readonly campaign: CampaignResult | null + readonly cells: readonly SemanticCellEvidence[] + readonly disagreements: readonly string[] +} + +export interface SemanticCellEvidence { + readonly cellId: string + readonly scenarioId: string + readonly fixtureId: string + readonly semanticOutput: unknown + readonly candidateOutput: string + readonly productPath: ProductOutput | null + readonly judgeScores: Record + readonly pass: boolean + readonly rawJudgeCallIds: readonly string[] + readonly cost: { + readonly usd: number + readonly provenance: unknown + readonly inputTokens: number + readonly outputTokens: number + readonly reasoningTokens: number | null + readonly cachedTokens: number + readonly wallTimeMs: number + } +} + +export interface EvalProviderIdentity { + readonly transport: ChatClient['transport'] + readonly baseUrl: string + readonly model: string + readonly endpointSha256: string + readonly bearerPresent: boolean +} diff --git a/src/index.ts b/src/index.ts index 4f66dd6..432ad9a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,7 @@ +export { + AgentRuntimeExecutionPort, + type AgentTurnBackendResolver, +} from './adapters/runtime/agent-runtime-execution.js' export { AppError, BraidApplication, @@ -6,16 +10,197 @@ export { } from './app/application.js' export { createBraidApplication, + createDurableBraidApplication, type CompositionOptions, + type DurableBraidApplication, + type DurableCompositionOptions, STARTER_PROFILE, } from './app/composition.js' +export { MemoryJournal } from './app/journal.js' export { buildAppView, type AppView, type MessageView } from './app/view-model.js' -export type { BraidEvent, BraidEventEnvelope, TurnUsage } from './domain/events.js' -export { reduceEvent, replayEvents } from './domain/reducer.js' -export type { BraidMessage, BraidRun, BraidState } from './domain/state.js' +export { + EffectCoordinator, + EffectCoordinatorError, + effectRequestDigest, + SerializedEffectCoordinator, + type EffectContext, + type EffectDispatchResult, + type EffectHandle, + type EffectHandler, + type EffectIntent, +} from './app/effect-coordinator.js' +export type { + BraidEvent, + BraidEventEnvelope, + DomainBraidEventMap, + JournalEventEnvelope, + LegacyBraidEvent, + TurnUsage, +} from './domain/events.js' +export { + DuplicateEventConflictError, + initialDomainState, + reduceEvent, + replayEvents, + replayJournal, + SequenceGapError, +} from './domain/reducer.js' +export { + canonicalDigest, + canonicalJson, +} from './domain/canonical.js' +export { canonicalProjectionChecksum } from './domain/projection-checksum.js' +export type { + BraidMessage, + BraidRun, + BraidState, +} from './domain/state.js' +export type * from './domain/entities.js' +export type { + EffectRecord as DomainEffectRecord, + OperationRecord as DomainOperationRecord, +} from './domain/entities.js' +export * from './domain/ids.js' +export { + assertBraidState, + assertIdKind, + assertJsonValue, + assertNoSecretInteractionData, + DomainInvariantError, +} from './domain/invariants.js' +export type { + EffectOutcomeStatus, + EffectRecord, + EffectStatus, + EffectStoragePort, + JournalPort, +} from './ports/effect-storage.js' +export type { + CredentialPort, + CredentialRef, + CredentialStoreInput, + SecretHandle, +} from './ports/credentials.js' +export { CredentialError, credentialRef } from './ports/credentials.js' +export type { + AppendResult, + BackupReport, + DestructionReport, + IntegrityReport, + JournalEvent, + MigrationReport, + MissingHistory, + OperationIntent, + OperationRecord, + ProjectionSnapshot, + RedactionReport, + ReplayResult, + RetentionReport, + StoragePort, + StorageArtifacts, + StoredJournalEvent, + RestoreReport, +} from './ports/storage.js' +export { MemoryStorage } from './adapters/storage/memory.js' +export { + SqliteStorage, + openSqliteStorage, +} from './adapters/storage/sqlite.js' +export type { DurableBoundaryHook, SqliteStorageOptions } from './adapters/storage/sqlite.js' +export { SQLITE_DRIVER_PACKAGE, SQLITE_DRIVER_VERSION } from './adapters/storage/sqlite-driver.js' +export { StorageError } from './adapters/storage/sqlite-errors.js' +export { MemoryCredentialStore } from './adapters/credentials/memory.js' +export { + createOperatingSystemCredentialStore, + LinuxSecretServiceCredentialStore, + MacOsKeychainCredentialStore, + WindowsCredentialManagerStore, +} from './adapters/credentials/os.js' +export { + closeHeadlessKeyFile, + openHeadlessKeyFile, + readHeadlessKey, + rejectEnvironmentKeySource, +} from './adapters/credentials/headless-key.js' +export type { + ProviderEventMeta, + RunTerminalStatus, +} from './domain/events.js' +export { + type ContextTransferReceipt, + createAdmissionReceipt, + createPortableContextPlan, + type NativeContextBoundaryProof, + type PortableContextPlan, + type RunAdmissionReceipt, + type RunCapabilities, +} from './domain/receipts.js' +export type { + BraidRuntimeEvent, + RuntimeEventEnvelope, + RuntimeEventSummary, +} from './domain/runtime-events.js' +export type { + BraidActivity, + BraidInteraction, + BraidMessagePart, + QueuedInput, +} from './domain/state.js' +export type { + ControlAcknowledgement, + ExecuteTurnInput, + ExecutionAdmission, + ExecutionPort, + ProviderRunSnapshot, +} from './ports/execution.js' +export { + capabilitiesFromEnvironment, + DEFAULT_RUN_CAPABILITIES, + UNKNOWN_RUN_CAPABILITIES, +} from './ports/execution.js' export { BRAID_PROTOCOL_VERSION, type BraidRequest, type BraidResponse, } from './views/headless/protocol.js' -export { sanitizeTerminalText } from './views/shared/sanitize.js' +export { StorageJournal } from './app/storage-journal.js' +export { FailClosedJournal } from './app/fail-closed-journal.js' +export { + createApplicationUiController, + ApplicationUiController, + buildBraidViewModel, +} from './adapters/tui/application-ui-controller.js' +export { + COMMAND_DEFINITIONS, + COMMAND_NAMES, + commandAvailability, + commandItems, + completeCommands, + parseCommandInput, +} from './views/shared/command-registry.js' +export type { + BraidIntent, + BraidUiController, + UiDispatchResult, + UiEvent, +} from './views/shared/intents.js' +export type { + AnalysisView, + BraidViewModel, + CapabilityMap, + ForkPreviewView, + HeadlessState, + InteractionView, + ViewStatus, +} from './views/shared/models.js' +export { + sanitizeClipboardText, + sanitizeDiff, + sanitizeForSurface, + sanitizeImageAlt, + sanitizeMarkdown, + sanitizeNotification, + sanitizeTerminalText, + sanitizeTitle, + sanitizeUrl, +} from './views/shared/sanitize.js' diff --git a/src/ports/credentials.ts b/src/ports/credentials.ts new file mode 100644 index 0000000..bd7c5c4 --- /dev/null +++ b/src/ports/credentials.ts @@ -0,0 +1,45 @@ +declare const CREDENTIAL_REF_BRAND: unique symbol + +export type CredentialRef = string & { + readonly [CREDENTIAL_REF_BRAND]?: 'CredentialRef' +} + +export interface CredentialStoreInput { + /** An opaque stable reference. If omitted, the adapter creates one. */ + readonly ref?: CredentialRef + readonly value: Uint8Array + readonly label?: string +} + +export interface SecretHandle { + readonly ref: CredentialRef + read(): Uint8Array + dispose(): void +} + +export interface CredentialPort { + store(input: CredentialStoreInput): Promise + resolve(ref: CredentialRef): Promise + remove(ref: CredentialRef): Promise + available(): Promise +} + +export class CredentialError extends Error { + readonly code: string + + constructor(code: string, message: string, options?: { readonly cause?: unknown }) { + super(message, options) + this.name = 'CredentialError' + this.code = code + } +} + +export function credentialRef(value: string): CredentialRef { + if (!/^cred:v1:[A-Za-z0-9._~-]+$/.test(value)) { + throw new CredentialError( + 'INVALID_CREDENTIAL_REF', + 'Credential references must be opaque v1 values', + ) + } + return value as CredentialRef +} diff --git a/src/ports/effect-storage.ts b/src/ports/effect-storage.ts new file mode 100644 index 0000000..9cf8e7b --- /dev/null +++ b/src/ports/effect-storage.ts @@ -0,0 +1,63 @@ +import type { BraidEvent, BraidEventEnvelope } from '../domain/events.js' +import type { BraidState } from '../domain/state.js' + +/** + * Durable event access used by the application controller. + * A production adapter must make append durable before returning. + */ +export interface JournalPort { + envelope(state: BraidState, event: BraidEvent): BraidEventEnvelope + append( + envelope: BraidEventEnvelope, + ): + | undefined + | { readonly appended?: boolean } + | Promise<{ readonly appended?: boolean } | undefined> + all(): readonly BraidEventEnvelope[] + /** Resolves once an asynchronous durable journal has committed its queue. */ + flush?(): Promise + /** Drains pending writes before releasing the underlying durable store. */ + close?(): Promise +} + +export type EffectStatus = + | 'pending' + | 'acknowledged' + | 'failed' + | 'unknown' + | 'conflict' + | 'terminal' + +export type EffectOutcomeStatus = Exclude + +export interface EffectRecord { + readonly operationId: string + readonly effectKind: string + readonly requestDigest: string + readonly status: EffectStatus + readonly attempt: number + readonly createdAt: string + readonly updatedAt: string + readonly metadata: Readonly> + readonly detail?: string + readonly externalReference?: string + readonly conflictWithDigest?: string +} + +/** + * Operation records are separate from the event journal because they guard + * external mutations and must be reconciled by operation identity. + */ +export interface EffectStoragePort { + /** Returns an opaque keyed binding for an exact request. The key stays in protected storage. */ + readonly fingerprint?: (input: { + readonly effectKind: string + readonly request: unknown + }) => string + /** Atomically admits one operation before any external dispatch. */ + reserveEffect(record: EffectRecord): { readonly record: EffectRecord; readonly created: boolean } + current(operationId: string): EffectRecord | undefined + latest(operationId: string, requestDigest: string): EffectRecord | undefined + appendEffect(record: EffectRecord): void + history(operationId: string): readonly EffectRecord[] +} diff --git a/src/ports/execution.ts b/src/ports/execution.ts index 6b597dd..ec4ce87 100644 --- a/src/ports/execution.ts +++ b/src/ports/execution.ts @@ -1,14 +1,171 @@ -import type { AgentProfile } from '@tangle-network/agent-interface' -import type { RuntimeStreamEvent } from '@tangle-network/agent-runtime' +import type { + AgentEnvironmentCapabilities, + AgentProfile, + InteractionResponse, +} from '@tangle-network/agent-interface' +import type { RunCapabilities } from '../domain/receipts.js' +import type { BraidRuntimeEvent, RuntimeEventEnvelope } from '../domain/runtime-events.js' +import type { RunStatus } from '../domain/state.js' + +export type { RunCapabilities } from '../domain/receipts.js' export interface ExecuteTurnInput { readonly operationId: string readonly runId: string readonly text: string readonly profile: Readonly + readonly connectionId?: string + readonly workspaceRoot?: string readonly signal: AbortSignal + readonly sessionId?: string + readonly after?: string + readonly contextBoundary?: string +} + +export interface ExecutionAdmission { + readonly capabilities?: RunCapabilities + readonly provider?: string + readonly environmentId?: string + readonly providerSessionId?: string + readonly materializationReceipt?: Readonly> + readonly warnings?: readonly string[] + readonly requestDigest?: string + readonly profileDigest?: string + readonly capabilitiesDigest?: string + readonly materializationDigest?: string +} + +export interface ProviderRunSnapshot { + readonly runId: string + readonly status: RunStatus + readonly sessionId?: string + readonly cursor?: string + readonly finalText?: string + readonly usage?: { + readonly input: number + readonly output: number + readonly reasoning?: number + readonly costUsd?: number + readonly model?: string + } + readonly error?: string + readonly detail?: string +} + +export interface ControlAcknowledgement { + readonly operationId: string + readonly outcome: 'accepted' | 'already-applied' | 'rejected' | 'unknown' + readonly detail?: string +} + +export interface CancelRunInput { + readonly operationId: string + readonly runId: string + readonly reason?: string +} + +export type CancelRunResult = + | { readonly status: 'cancelled' } + | { readonly status: 'unknown'; readonly reason: string } + +export interface ExecutionCapabilities { + readonly cancel: boolean } export interface ExecutionPort { - streamTurn(input: ExecuteTurnInput): AsyncIterable + /** Whether Braid must use the pending admission path for this adapter. */ + readonly admissionMode?: 'sync' | 'async' + readonly capabilities?: + | ExecutionCapabilities + | ((input: ExecuteTurnInput) => RunCapabilities | Promise) + /** Current published agent-runtime path. Braid consumes normalized events only. */ + streamTurn(input: ExecuteTurnInput): AsyncIterable + admit?(input: ExecuteTurnInput): ExecutionAdmission | Promise + cancelRun?( + input: CancelRunInput & { readonly reason?: string; readonly signal?: AbortSignal }, + ): Promise + detachRun?(input: { + readonly runId: string + readonly operationId: string + readonly cursor?: string + readonly signal?: AbortSignal + }): Promise + steerRun?(input: { + readonly runId: string + readonly operationId: string + readonly text: string + readonly signal?: AbortSignal + }): Promise + status?(input: { + readonly runId: string + readonly signal?: AbortSignal + }): Promise + respondInteraction?(input: { + readonly runId: string + readonly operationId: string + readonly response: InteractionResponse + readonly signal?: AbortSignal + }): Promise + reconnect?(input: { + readonly runId: string + readonly after?: string + readonly signal: AbortSignal + }): AsyncIterable + nativeBoundary?(input: { + readonly runId: string + readonly sessionId: string + readonly signal?: AbortSignal + }): Promise<{ + readonly boundary: string + readonly digest: string + readonly revision?: string + } | null> + environmentCapabilities?(): AgentEnvironmentCapabilities | Promise } + +export const DEFAULT_RUN_CAPABILITIES: RunCapabilities = Object.freeze({ + 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, +}) + +export const UNKNOWN_RUN_CAPABILITIES: RunCapabilities = Object.freeze({ + streaming: { live: false, replay: false, detach: false, turnIdempotency: false }, + sessions: { continue: false, messages: false }, + controls: { cancel: false, steer: false, queue: false, status: false, recreate: false }, + events: { stableIdentity: false, sequence: false, cursor: false }, + usage: false, +}) + +export function capabilitiesFromEnvironment( + capabilities: AgentEnvironmentCapabilities, +): RunCapabilities { + return { + // The provider may support replay or control methods that this port does + // not expose. Only advertise the operations Braid can actually execute. + streaming: { + live: capabilities.streaming.live, + replay: false, + detach: false, + turnIdempotency: capabilities.streaming.turnIdempotency, + }, + sessions: { + continue: capabilities.sessions.continue, + messages: false, + }, + controls: { + cancel: false, + steer: false, + queue: false, + status: false, + recreate: false, + }, + events: { stableIdentity: false, sequence: true, cursor: false }, + usage: capabilities.usage, + environment: capabilities, + } +} + +export type NormalizedExecutionEvent = BraidRuntimeEvent | RuntimeEventEnvelope diff --git a/src/ports/ids.ts b/src/ports/ids.ts index e8e025f..842bd12 100644 --- a/src/ports/ids.ts +++ b/src/ports/ids.ts @@ -1,6 +1,6 @@ import { randomUUID } from 'node:crypto' -export type IdKind = 'message' | 'run' | 'turn' | 'operation' +export type IdKind = 'message' | 'run' | 'turn' | 'operation' | 'receipt' | 'shutdown' export interface IdSource { next(kind: IdKind): string diff --git a/src/ports/storage.ts b/src/ports/storage.ts new file mode 100644 index 0000000..3de143d --- /dev/null +++ b/src/ports/storage.ts @@ -0,0 +1,295 @@ +import type { ConversationId, EventId, OperationId, RunId, WorkspaceId } from '../domain/ids.js' +import type { MaterializedStateSnapshot } from '../domain/materialized-state-snapshot.js' +import type { CredentialPort, CredentialRef } from './credentials.js' + +export const PROJECTION_SCHEMA_VERSION = 3 + +export type { + AnalysisId, + AnalysisRunId, + ArtifactId, + AttachmentId, + BindingId, + BranchId, + CheckpointId, + CitationId, + ConnectionId, + ConversationId, + CredentialRefId, + Digest, + DraftId, + EffectId, + EnvironmentId, + EventId, + FeedbackDecisionId, + GraphEdgeId, + GraphNodeId, + InteractionId, + MessageId, + MessagePartId, + OperationId, + ProfileId, + ProfileSnapshotId, + ProviderSessionId, + QueueEntryId, + QueueId, + ReceiptId, + ReplayCursor, + RuleId, + RunId, + SupervisorId, + TraceId, + TurnId, + WorkerId, + WorkspaceId, +} from '../domain/ids.js' + +export type JsonPrimitive = string | number | boolean | null +export type JsonValue = JsonPrimitive | readonly JsonValue[] | { readonly [key: string]: JsonValue } + +export type PayloadState = 'available' | 'redacted' | 'deleted' | 'content-key-unavailable' + +export interface JournalEvent { + readonly workspaceId: WorkspaceId + readonly conversationId: ConversationId + readonly runId: RunId + readonly eventId: EventId + /** Provider identity is scoped to the provider session, not Braid's journal. */ + readonly providerEventId?: string + readonly sequence: number + readonly kind: string + readonly payload: JsonValue + readonly occurredAt: string + readonly cursor?: string + readonly operationId?: OperationId + readonly terminal?: boolean + readonly receivedAt?: string +} + +export interface StoredJournalEvent extends Omit { + readonly payload: JsonValue | null + readonly payloadState: PayloadState + readonly receivedAt: string + readonly payloadChecksum: string + readonly redacted: boolean + readonly tombstoneReason?: string +} + +/** An encrypted, materialized projection bound to one committed journal event. */ +export type StateSnapshot = MaterializedStateSnapshot + +export interface StoredStateSnapshot extends StateSnapshot { + readonly storageId: number +} + +export interface MissingHistory { + readonly runId: RunId + readonly fromSequence: number + readonly toSequence: number +} + +export interface AppendResult { + readonly acceptedEventIds: readonly EventId[] + readonly duplicateEventIds: readonly EventId[] + readonly missingHistory: readonly MissingHistory[] + readonly projectionChecksum: string +} + +export interface ReplayResult { + readonly events: readonly StoredJournalEvent[] + readonly complete: boolean + readonly missingHistory: readonly MissingHistory[] + readonly lastSequence: number + readonly lastCursor?: string +} + +export interface ProjectionRun { + readonly runId: RunId + readonly conversationId: ConversationId + readonly lastSequence: number + readonly lastCursor: string | null + readonly missingFrom: number | null + readonly missingTo: number | null + readonly terminal: boolean +} + +export interface ProjectionSnapshot { + readonly schemaVersion: number + readonly eventCount: number + readonly revision: number + readonly eventIds: readonly EventId[] + readonly runs: readonly ProjectionRun[] + readonly checksum: string +} + +export type EffectStatus = + | 'pending' + | 'acknowledged' + | 'failed' + | 'unknown' + | 'conflict' + | 'terminal' + +export interface OperationIntent { + readonly operationId: OperationId + readonly kind: string + readonly request: JsonValue + readonly requestDigest: string + readonly createdAt?: string +} + +export interface OperationRecord extends OperationIntent { + readonly status: EffectStatus + readonly result?: JsonValue + readonly updatedAt: string +} + +export interface OperationReservation { + readonly record: OperationRecord + readonly created: boolean +} + +export interface IntegrityReport { + readonly ok: boolean + readonly encryption: 'verified' | 'unavailable' | 'not-applicable' + readonly quickCheck: boolean + readonly fullCheck: boolean + readonly foreignKeys: boolean + readonly wal: boolean + readonly schemaVersion: number + readonly errors: readonly string[] +} + +export interface MigrationReport { + readonly fromVersion: number + readonly toVersion: number + readonly migrated: boolean + readonly backupPath?: string +} + +export interface RetentionReport { + readonly redactedEvents: number + readonly deletedConversations: readonly ConversationId[] +} + +export interface RedactionReport { + readonly conversationId: ConversationId + readonly redactedEventId: EventId + readonly rewrittenEvents: number + readonly newContentKeyRef: CredentialRef +} + +export interface DestructionReport { + readonly conversationId: ConversationId + readonly destroyed: boolean + readonly retainedCiphertext: boolean +} + +export interface BackupReport { + readonly path: string + readonly bytes: number + readonly encrypted: boolean +} + +export interface RestoreReport { + readonly path: string + readonly restored: boolean + readonly integrity: IntegrityReport +} + +export interface StorageArtifacts { + readonly database: string + readonly wal: string + readonly sharedMemory: string + readonly backups: readonly string[] +} + +export interface NonTerminalRun { + readonly runId: RunId + readonly conversationId: ConversationId + readonly lastSequence: number + readonly lastCursor: string | null + readonly missingHistory: MissingHistory | null +} + +export interface StoragePort { + append(events: readonly JournalEvent[]): Promise + appendWithSnapshot?(input: { + readonly events: readonly JournalEvent[] + readonly snapshot: StateSnapshot + }): Promise + replay(input: { readonly runId: RunId; readonly afterSequence?: number }): Promise + events(input?: { + readonly workspaceId?: WorkspaceId + readonly conversationId?: ConversationId + readonly runId?: RunId + readonly afterStorageId?: number + }): Promise + snapshotScopeId?(): string + latestStateSnapshot?(): Promise + writeStateSnapshot?(snapshot: StateSnapshot): Promise + runSequences?(): Promise + projection(): Promise + rebuild(operation: OperationIntent): Promise + projectionChecksum(): Promise + reserveOperation(intent: OperationIntent): Promise + completeOperation(input: { + readonly operationId: OperationId + readonly requestDigest: string + readonly status: Exclude + readonly result?: JsonValue + readonly updatedAt?: string + }): Promise + recordOperationConflict(input: { + readonly operationId: OperationId + readonly requestDigest: string + readonly attemptedDigest: string + readonly occurredAt?: string + }): Promise + operation(operationId: OperationId): Promise + integrity(): Promise + migrate(operation: OperationIntent): Promise + backup(input: { + readonly path: string + readonly operation: OperationIntent + }): Promise + restore(input: { + readonly path: string + readonly operation: OperationIntent + }): Promise + applyRetention(input: { + readonly before: string + readonly conversationId?: ConversationId + readonly operation: OperationIntent + }): Promise + redact(input: { + readonly conversationId: ConversationId + readonly eventId: EventId + readonly reason: string + readonly operation: OperationIntent + }): Promise + destroyConversation(input: { + readonly conversationId: ConversationId + readonly reason: string + readonly operation: OperationIntent + }): Promise + compact(operation: OperationIntent): Promise + artifacts(): StorageArtifacts + reconcileNonTerminalRuns(): Promise + close(): Promise +} + +export interface StorageFactoryOptions { + readonly credentialStore: CredentialPort + readonly maxEventsPerTransaction?: number + readonly maxPayloadBytesPerTransaction?: number + readonly maxQueuedTransactions?: number +} + +export function isJsonValue(value: unknown): value is JsonValue { + if (value === null || typeof value === 'string' || typeof value === 'boolean') return true + if (typeof value === 'number') return Number.isFinite(value) + if (Array.isArray(value)) return value.every(isJsonValue) + if (typeof value !== 'object') return false + return Object.values(value).every(isJsonValue) +} diff --git a/src/startup/durable-runtime.ts b/src/startup/durable-runtime.ts new file mode 100644 index 0000000..888b8d3 --- /dev/null +++ b/src/startup/durable-runtime.ts @@ -0,0 +1 @@ +export { createDurableBraidApplication } from '../app/composition.js' diff --git a/src/startup/terminal-runtime.ts b/src/startup/terminal-runtime.ts new file mode 100644 index 0000000..93a3701 --- /dev/null +++ b/src/startup/terminal-runtime.ts @@ -0,0 +1,4 @@ +export { ProcessTerminal, TUI } from '@earendil-works/pi-tui' +export { createApplicationUiController } from '../adapters/tui/application-ui-controller.js' +export { BraidTerminalApp } from '../views/tui/terminal-app.js' +export { createBraidTheme } from '../views/tui/theme.js' diff --git a/src/testing/deterministic-backend.ts b/src/testing/deterministic-backend.ts index d4e5943..bc1bc88 100644 --- a/src/testing/deterministic-backend.ts +++ b/src/testing/deterministic-backend.ts @@ -43,38 +43,16 @@ export function deterministicBackend( kind, start: () => sessionFor(input, kind), async *stream(_backendInput, context): AsyncIterable { + if (process.env.BRAID_FIXTURE_FAILURE === '1') { + throw new Error('Deterministic fixture failure') + } for (const chunk of chunks) { if (options.chunkDelayMs) { await delay(options.chunkDelayMs, undefined, { signal: context.signal }) } yield { type: 'text_delta', text: chunk } } - yield { - type: 'llm_call', - model: 'fixture/deterministic', - tokensIn: Math.max(1, input.text.split(/\s+/u).length), - tokensOut: chunks.length, - costUsd: 0, - latencyMs: options.chunkDelayMs ? options.chunkDelayMs * chunks.length : 0, - finishReason: 'stop', - } }, } return { kind: 'chat', backend } } - -export function unconfiguredBackend(input: ExecuteTurnInput): AgentTurnBackend { - const kind = 'braid-unconfigured' - const backend: AgentExecutionBackend = { - kind, - start: () => sessionFor(input, kind), - stream: () => ({ - [Symbol.asyncIterator]: () => ({ - next: async () => { - throw new Error('No Braid connection is configured') - }, - }), - }), - } - return { kind: 'chat', backend } -} diff --git a/src/testing/runtime-contract-fixtures.ts b/src/testing/runtime-contract-fixtures.ts new file mode 100644 index 0000000..989881c --- /dev/null +++ b/src/testing/runtime-contract-fixtures.ts @@ -0,0 +1,99 @@ +import type { BraidRuntimeEvent, RuntimeEventEnvelope } from '../domain/runtime-events.js' + +const RECEIVED_AT = '2026-08-01T00:00:00.000Z' + +function event(value: unknown): BraidRuntimeEvent { + return value as BraidRuntimeEvent +} + +/** + * Canonical shared-schema events used by the controller contract tests. + * These are normalized events, not runner output, so the fixture does not + * encode a provider parser or a second execution path. + */ +export function runtimeContractEvents(): readonly BraidRuntimeEvent[] { + return [ + event({ + type: 'message.part.updated', + part: { + id: 'part-text', + sessionID: 'session-contract', + messageID: 'message-assistant', + type: 'text', + text: 'hello', + }, + delta: 'hello', + }), + event({ type: 'reasoning_delta', text: 'checking', timestamp: RECEIVED_AT }), + event({ + type: 'tool_call', + toolName: 'read_file', + toolCallId: 'call-1', + args: { path: 'README.md' }, + timestamp: RECEIVED_AT, + }), + event({ + type: 'tool_result', + toolName: 'read_file', + toolCallId: 'call-1', + result: { bytes: 12 }, + timestamp: RECEIVED_AT, + }), + event({ + type: 'artifact', + artifactId: 'artifact-1', + name: 'report.txt', + mimeType: 'text/plain', + uri: 'artifact://report-1', + timestamp: RECEIVED_AT, + }), + event({ + type: 'proposal_created', + proposalId: 'proposal-1', + title: 'Apply report', + status: 'pending', + timestamp: RECEIVED_AT, + }), + event({ type: 'warning', code: 'slow-tool', message: 'The tool took longer than expected' }), + event({ + type: 'llm_call', + model: 'fixture/model', + tokensIn: 3, + tokensOut: 4, + costUsd: 0.01, + timestamp: RECEIVED_AT, + }), + event({ + type: 'interaction', + request: { + id: 'interaction-1', + kind: 'question', + title: 'Continue?', + answerSpec: { + fields: [{ type: 'boolean', name: 'continue', label: 'Continue', required: true }], + }, + }, + }), + event({ + type: 'final', + status: 'completed', + reason: 'complete', + text: 'hello', + metadata: { tokenUsage: { input: 3, output: 4 }, costUsd: 0.01, model: 'fixture/model' }, + task: { id: 'task-contract', intent: 'contract fixture' }, + timestamp: RECEIVED_AT, + }), + ] +} + +export function runtimeContractEnvelopes(runId: string): readonly RuntimeEventEnvelope[] { + return runtimeContractEvents().map((runtimeEvent, index) => ({ + runId, + eventId: `${runId}:contract:${index + 1}`, + sequence: index + 1, + cursor: `cursor-${index + 1}`, + occurredAt: RECEIVED_AT, + receivedAt: RECEIVED_AT, + event: runtimeEvent, + })) +} diff --git a/src/views/headless/bounded-output.ts b/src/views/headless/bounded-output.ts new file mode 100644 index 0000000..34894a0 --- /dev/null +++ b/src/views/headless/bounded-output.ts @@ -0,0 +1,70 @@ +const MAX_QUEUED_OUTPUT_BYTES = 16 * 1024 * 1024 +const MAX_QUEUED_OUTPUT_ITEMS = 4096 +const MAX_DRAIN_WAIT_MS = 10_000 + +export interface BoundedOutputTarget { + write(chunk: string): boolean + once?: (event: 'drain', listener: () => void) => unknown +} + +export class BoundedOutputQueue { + readonly #target: BoundedOutputTarget + #tail: Promise = Promise.resolve() + #failure: unknown + #queuedBytes = 0 + #queuedItems = 0 + + constructor(target: BoundedOutputTarget) { + this.#target = target + } + + write(chunk: string): Promise { + if (this.#failure !== undefined) return Promise.reject(this.#failure) + const bytes = Buffer.byteLength(chunk, 'utf8') + if ( + bytes > MAX_QUEUED_OUTPUT_BYTES || + this.#queuedBytes + bytes > MAX_QUEUED_OUTPUT_BYTES || + this.#queuedItems >= MAX_QUEUED_OUTPUT_ITEMS + ) { + const error = new Error('OUTPUT_BACKPRESSURE_LIMIT: output queue is full') + this.#failure = error + return Promise.reject(error) + } + this.#queuedBytes += bytes + this.#queuedItems += 1 + const task = this.#tail.then(async () => { + if (this.#failure !== undefined) throw this.#failure + this.#queuedBytes -= bytes + this.#queuedItems -= 1 + if (!this.#target.write(chunk)) await this.#waitForDrain() + }) + this.#tail = task.catch((error: unknown) => { + this.#failure = error + }) + return task + } + + async flush(): Promise { + await this.#tail + if (this.#failure !== undefined) throw this.#failure + } + + async #waitForDrain(): Promise { + if (typeof this.#target.once !== 'function') + throw new Error('OUTPUT_BACKPRESSURE_UNAVAILABLE: output has no drain notification') + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error('OUTPUT_BACKPRESSURE_TIMEOUT: output did not drain')) + }, MAX_DRAIN_WAIT_MS) + try { + this.#target.once?.('drain', () => { + clearTimeout(timer) + resolve() + }) + } catch (error) { + clearTimeout(timer) + reject(error) + } + }) + } +} diff --git a/src/views/headless/protocol-limits.ts b/src/views/headless/protocol-limits.ts new file mode 100644 index 0000000..fbbd044 --- /dev/null +++ b/src/views/headless/protocol-limits.ts @@ -0,0 +1,73 @@ +import { RpcParseError } from './rpc-errors.js' + +export const MAX_RPC_LINE_BYTES = 5 * 1024 * 1024 +export const MAX_PROTOCOL_DEPTH = 32 +export const MAX_PROTOCOL_FIELDS = 256 +export const MAX_PROTOCOL_ITEMS = 4096 +export const MAX_PROTOCOL_LIST_ITEMS = 256 +export const MAX_PROTOCOL_STRING_BYTES = 2 * 1024 * 1024 +export const MAX_PROTOCOL_IDENTIFIER_BYTES = 4096 + +export function assertValidUnicodeString(value: string, label: string): void { + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index) + if (code >= 0xd800 && code <= 0xdbff) { + const next = value.charCodeAt(index + 1) + if (next < 0xdc00 || next > 0xdfff) + throw new RpcParseError('INVALID_UTF8', `${label} contains an unpaired surrogate`) + index += 1 + } else if (code >= 0xdc00 && code <= 0xdfff) { + throw new RpcParseError('INVALID_UTF8', `${label} contains an unpaired surrogate`) + } + } +} + +export function assertBoundedIdentifier(value: string, label: string): string { + assertValidUnicodeString(value, label) + if (Buffer.byteLength(value, 'utf8') > MAX_PROTOCOL_IDENTIFIER_BYTES) + throw new RpcParseError('INVALID_PARAMS', `${label} exceeds the identifier byte limit`) + return value +} + +export function assertBoundedRequestShape(value: unknown): void { + const seen = new WeakSet() + let fields = 0 + let items = 0 + let bytes = 0 + const visit = (candidate: unknown, depth: number): void => { + if (depth > MAX_PROTOCOL_DEPTH) + throw new RpcParseError('INVALID_PARAMS', 'Request nesting exceeds the protocol limit') + if (typeof candidate === 'string') { + assertValidUnicodeString(candidate, 'Request string') + bytes += Buffer.byteLength(candidate, 'utf8') + if (Buffer.byteLength(candidate, 'utf8') > MAX_PROTOCOL_STRING_BYTES) + throw new RpcParseError('INVALID_PARAMS', 'Request string exceeds the protocol limit') + if (bytes > MAX_RPC_LINE_BYTES) + throw new RpcParseError('INVALID_PARAMS', 'Request content exceeds the protocol limit') + return + } + if (candidate === null || typeof candidate !== 'object') return + if (seen.has(candidate)) throw new RpcParseError('INVALID_PARAMS', 'Request contains a cycle') + seen.add(candidate) + try { + if (Array.isArray(candidate)) { + items += candidate.length + if (items > MAX_PROTOCOL_ITEMS) + throw new RpcParseError('INVALID_PARAMS', 'Request item count exceeds the protocol limit') + for (const item of candidate) visit(item, depth + 1) + return + } + const entries = Object.entries(candidate) + fields += entries.length + if (fields > MAX_PROTOCOL_FIELDS) + throw new RpcParseError('INVALID_PARAMS', 'Request field count exceeds the protocol limit') + for (const [key, item] of entries) { + visit(key, depth + 1) + visit(item, depth + 1) + } + } finally { + seen.delete(candidate) + } + } + visit(value, 0) +} diff --git a/src/views/headless/protocol.ts b/src/views/headless/protocol.ts index be90da2..c92eb16 100644 --- a/src/views/headless/protocol.ts +++ b/src/views/headless/protocol.ts @@ -1,7 +1,13 @@ -import type { BraidEventEnvelope } from '../../domain/events.js' -import type { BraidState } from '../../domain/state.js' +import { HEADLESS_COMMAND_NAMES, type HeadlessCommandName } from '../shared/headless-commands.js' +import type { BraidViewModel, HeadlessState, HeadlessSummary } from '../shared/models.js' +import type { UiEvent } from '../shared/intents.js' +import type { RunAdmissionReceipt } from '../../domain/receipts.js' export const BRAID_PROTOCOL_VERSION = 1 as const +export type StateProjection = 'full' | 'summary' + +export const RPC_COMMAND_NAMES = HEADLESS_COMMAND_NAMES +export type RpcCommandName = HeadlessCommandName export interface InitializeRequest { readonly version: 1 @@ -17,7 +23,9 @@ export interface GetStateRequest { readonly version: 1 readonly requestId: string readonly command: 'get_state' - readonly params?: Record + readonly params?: { + readonly projection?: StateProjection + } } export interface SendRequest { @@ -36,10 +44,70 @@ export interface ShutdownRequest { readonly version: 1 readonly requestId: string readonly command: 'shutdown' - readonly params?: Record + readonly operationId: string + readonly params?: { + readonly mode?: 'wait' | 'detach' | 'cancel' + } +} + +export interface GenericRpcRequest { + readonly version: 1 + readonly requestId: string + readonly operationId?: string + readonly command: Exclude + readonly params: Readonly> +} + +interface RunControlRequestBase { + readonly version: 1 + readonly requestId: string + readonly operationId: string + readonly params?: { + readonly runId?: string + } +} + +export interface QueueRequest extends RunControlRequestBase { + readonly command: 'queue' + readonly params: { readonly runId?: string; readonly text: string } } -export type BraidRequest = InitializeRequest | GetStateRequest | SendRequest | ShutdownRequest +export interface SteerRequest extends RunControlRequestBase { + readonly command: 'steer' + readonly params: { readonly runId?: string; readonly text: string } +} + +export interface CancelRequest extends RunControlRequestBase { + readonly command: 'cancel' + readonly params: { readonly runId?: string; readonly reason?: string } +} + +export interface DetachRequest extends RunControlRequestBase { + readonly command: 'detach' +} + +export interface ReconnectRequest extends RunControlRequestBase { + readonly command: 'reconnect' + readonly params: { readonly runId: string } +} + +export interface ReconcileRequest extends RunControlRequestBase { + readonly command: 'reconcile' + readonly params: { readonly runId: string } +} + +export type BraidRequest = + | InitializeRequest + | GetStateRequest + | SendRequest + | ShutdownRequest + | GenericRpcRequest + | QueueRequest + | SteerRequest + | CancelRequest + | DetachRequest + | ReconnectRequest + | ReconcileRequest export interface AckResponse { readonly version: 1 @@ -47,7 +115,14 @@ export interface AckResponse { readonly requestId: string readonly revision: number readonly operationId?: string + readonly command?: string readonly replayed?: boolean + readonly runId?: string + readonly control?: 'cancel' | 'steer' | 'queue' | 'detach' | 'reconnect' | 'respond_interaction' + readonly outcome?: 'accepted' | 'already-applied' | 'rejected' | 'unknown' + readonly position?: number + readonly admission?: RunAdmissionReceipt + readonly result?: unknown } export interface EventResponse { @@ -55,17 +130,30 @@ export interface EventResponse { readonly type: 'event' readonly sequence: number readonly revision: number - readonly event: BraidEventEnvelope['event'] + readonly event: UiEvent +} + +export interface FullStateResponse { + readonly version: 1 + readonly type: 'state' + readonly requestId: string + readonly revision: number + readonly projection: 'full' + readonly state: HeadlessState + readonly view: BraidViewModel } -export interface StateResponse { +export interface SummaryStateResponse { readonly version: 1 readonly type: 'state' readonly requestId: string readonly revision: number - readonly state: BraidState + readonly projection: 'summary' + readonly state: HeadlessSummary } +export type StateResponse = FullStateResponse | SummaryStateResponse + export interface ErrorResponse { readonly version: 1 readonly type: 'error' @@ -73,6 +161,7 @@ export interface ErrorResponse { readonly code: string readonly message: string readonly retryable: boolean + readonly choices?: readonly string[] } export type BraidResponse = AckResponse | EventResponse | StateResponse | ErrorResponse diff --git a/src/views/headless/rpc-errors.ts b/src/views/headless/rpc-errors.ts new file mode 100644 index 0000000..ea27080 --- /dev/null +++ b/src/views/headless/rpc-errors.ts @@ -0,0 +1,11 @@ +export class RpcParseError extends Error { + readonly code: string + readonly choices?: readonly string[] + + constructor(code: string, message: string, choices?: readonly string[]) { + super(message) + this.name = 'RpcParseError' + this.code = code + if (choices) this.choices = choices + } +} diff --git a/src/views/headless/rpc-parser.ts b/src/views/headless/rpc-parser.ts new file mode 100644 index 0000000..fc69e7e --- /dev/null +++ b/src/views/headless/rpc-parser.ts @@ -0,0 +1,474 @@ +import { + HEADLESS_COMMAND_NAMES, + type HeadlessCommandName, + isMutatingHeadlessCommand, +} from '../shared/headless-commands.js' +import { + BRAID_PROTOCOL_VERSION, + type BraidRequest, + type GenericRpcRequest, + type RpcCommandName, +} from './protocol.js' +import { + assertBoundedIdentifier, + assertBoundedRequestShape, + MAX_PROTOCOL_ITEMS, + MAX_PROTOCOL_LIST_ITEMS, + MAX_RPC_LINE_BYTES, +} from './protocol-limits.js' +import { RpcParseError } from './rpc-errors.js' +import type { RpcInput } from './rpc-types.js' + +export { RpcParseError } from './rpc-errors.js' + +const PARAMETER_KEYS: Readonly> = { + initialize: ['workspace', 'subscribe'], + get_state: ['projection'], + subscribe: [], + unsubscribe: [], + list_profiles: ['query'], + select_profile: ['ref', 'expectedRevision'], + validate_profile: ['ref'], + save_profile: ['ref', 'profile', 'expectedRevision'], + list_connections: ['query'], + test_connection: ['connectionId'], + select_connection: ['connectionId', 'expectedRevision'], + set_run_override: ['runner', 'model', 'effort', 'mode', 'clear'], + new_conversation: ['title', 'profileRef', 'connectionId'], + list_conversations: ['query', 'workspace', 'status'], + open_conversation: ['conversationId', 'branchId'], + rename_conversation: ['conversationId', 'title'], + archive_conversation: ['conversationId', 'archived'], + delete_conversation: ['conversationId'], + set_draft: ['conversationId', 'branchId', 'text'], + import_conversation: ['content', 'source', 'title'], + send: ['conversationId', 'branchId', 'text'], + queue: ['conversationId', 'branchId', 'text'], + remove_queued: ['conversationId', 'branchId', 'queueId'], + steer: ['runId', 'text'], + cancel: ['runId', 'reason'], + detach: ['runId'], + reconnect: ['runId'], + reconcile: ['runId'], + respond_interaction: ['runId', 'interactionId', 'response'], + cancel_run: ['runId', 'reason'], + branch: ['conversationId', 'branchId', 'messageId', 'text'], + clone: ['conversationId', 'branchId', 'title'], + plan_fork: ['conversationId', 'branchId', 'messageId', 'workspace', 'runner', 'model', 'effort'], + execute_fork: [ + 'planDigest', + 'conversationId', + 'branchId', + 'messageId', + 'workspace', + 'runner', + 'model', + 'effort', + ], + ask: ['source', 'question', 'profileRef', 'connectionId'], + analyze: ['source', 'recipe', 'profileRef', 'connectionId'], + compare: ['left', 'right', 'profileRef', 'connectionId'], + promote_analysis: ['analysisId', 'findingIds', 'conversationId', 'branchId'], + get_graph: ['conversationId', 'branchId', 'query'], + get_activity: ['conversationId', 'branchId', 'runId'], + get_details: ['entityType', 'entityId'], + steer_worker: ['supervisorId', 'workerId', 'text'], + cancel_worker: ['supervisorId', 'workerId', 'reason'], + export: ['target', 'format', 'includeRaw', 'destination'], + shutdown: [], +} + +type ParameterType = 'string' | 'boolean' | 'string-or-boolean' | 'number' | 'record' | 'string[]' + +const PARAMETER_TYPES: Readonly< + Record>> +> = { + initialize: { workspace: 'string', subscribe: 'boolean' }, + get_state: { projection: 'string' }, + subscribe: {}, + unsubscribe: {}, + list_profiles: { query: 'string' }, + select_profile: { ref: 'string', expectedRevision: 'number' }, + validate_profile: { ref: 'string' }, + save_profile: { ref: 'string', profile: 'record', expectedRevision: 'number' }, + list_connections: { query: 'string' }, + test_connection: { connectionId: 'string' }, + select_connection: { connectionId: 'string', expectedRevision: 'number' }, + set_run_override: { + runner: 'string', + model: 'string', + effort: 'string', + mode: 'string', + clear: 'boolean', + }, + new_conversation: { title: 'string', profileRef: 'string', connectionId: 'string' }, + list_conversations: { query: 'string', workspace: 'string', status: 'string' }, + open_conversation: { conversationId: 'string', branchId: 'string' }, + rename_conversation: { conversationId: 'string', title: 'string' }, + archive_conversation: { conversationId: 'string', archived: 'boolean' }, + delete_conversation: { conversationId: 'string' }, + set_draft: { conversationId: 'string', branchId: 'string', text: 'string' }, + import_conversation: { content: 'string', source: 'string', title: 'string' }, + send: { conversationId: 'string', branchId: 'string', text: 'string' }, + queue: { conversationId: 'string', branchId: 'string', text: 'string' }, + remove_queued: { conversationId: 'string', branchId: 'string', queueId: 'string' }, + steer: { runId: 'string', text: 'string' }, + cancel: { runId: 'string', reason: 'string' }, + detach: { runId: 'string' }, + reconnect: { runId: 'string' }, + reconcile: { runId: 'string' }, + respond_interaction: { runId: 'string', interactionId: 'string', response: 'record' }, + cancel_run: { runId: 'string', reason: 'string' }, + branch: { conversationId: 'string', branchId: 'string', messageId: 'string', text: 'string' }, + clone: { conversationId: 'string', branchId: 'string', title: 'string' }, + plan_fork: { + conversationId: 'string', + branchId: 'string', + messageId: 'string', + workspace: 'string-or-boolean', + runner: 'string', + model: 'string', + effort: 'string', + }, + execute_fork: { + planDigest: 'string', + conversationId: 'string', + branchId: 'string', + messageId: 'string', + workspace: 'string-or-boolean', + runner: 'string', + model: 'string', + effort: 'string', + }, + ask: { source: 'string', question: 'string', profileRef: 'string', connectionId: 'string' }, + analyze: { source: 'string', recipe: 'string', profileRef: 'string', connectionId: 'string' }, + compare: { left: 'string', right: 'string', profileRef: 'string', connectionId: 'string' }, + promote_analysis: { + analysisId: 'string', + findingIds: 'string[]', + conversationId: 'string', + branchId: 'string', + }, + get_graph: { conversationId: 'string', branchId: 'string', query: 'string' }, + get_activity: { conversationId: 'string', branchId: 'string', runId: 'string' }, + get_details: { entityType: 'string', entityId: 'string' }, + steer_worker: { supervisorId: 'string', workerId: 'string', text: 'string' }, + cancel_worker: { supervisorId: 'string', workerId: 'string', reason: 'string' }, + export: { + target: 'string', + format: 'string', + includeRaw: 'boolean', + destination: 'string', + }, + shutdown: {}, +} + +const REQUIRED_PARAMETERS: Readonly>> = { + select_profile: ['ref'], + validate_profile: ['ref'], + save_profile: ['ref', 'profile'], + test_connection: ['connectionId'], + select_connection: ['connectionId'], + open_conversation: ['conversationId'], + rename_conversation: ['conversationId', 'title'], + archive_conversation: ['conversationId', 'archived'], + delete_conversation: ['conversationId'], + set_draft: ['text'], + queue: ['text'], + remove_queued: ['queueId'], + steer: ['runId', 'text'], + cancel: [], + detach: [], + reconnect: ['runId'], + reconcile: ['runId'], + respond_interaction: ['runId', 'interactionId', 'response'], + branch: ['conversationId', 'branchId', 'messageId'], + clone: ['conversationId', 'branchId'], + plan_fork: ['conversationId', 'branchId'], + execute_fork: ['planDigest'], + ask: ['source', 'question'], + analyze: ['source', 'recipe'], + compare: ['left', 'right'], + promote_analysis: ['analysisId', 'findingIds'], + get_details: ['entityType', 'entityId'], + steer_worker: ['supervisorId', 'workerId', 'text'], + cancel_worker: ['supervisorId', 'workerId'], + export: ['target'], +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +export function requestIdOf(value: unknown): string | undefined { + if (!isRecord(value)) return undefined + return typeof value.requestId === 'string' ? value.requestId : undefined +} + +function assertAllowedKeys( + value: Record, + allowed: readonly string[], + label: string, +): void { + const unknown = Object.keys(value).find((key) => !allowed.includes(key)) + if (unknown) + throw new RpcParseError('INVALID_PARAMS', `${label} contains unknown field ${unknown}`) +} + +function parameterMatches(value: unknown, type: ParameterType): boolean { + if (type === 'string') return typeof value === 'string' + if (type === 'boolean') return typeof value === 'boolean' + if (type === 'string-or-boolean') return typeof value === 'string' || typeof value === 'boolean' + if (type === 'number') return typeof value === 'number' && Number.isFinite(value) + if (type === 'string[]') + return ( + Array.isArray(value) && + value.length <= MAX_PROTOCOL_ITEMS && + value.every((item) => typeof item === 'string') + ) + return isRecord(value) +} + +function assertParameterTypes(command: HeadlessCommandName, params: Record): void { + for (const [key, type] of Object.entries(PARAMETER_TYPES[command])) { + if (params[key] !== undefined && !parameterMatches(params[key], type)) { + throw new RpcParseError('INVALID_PARAMS', `${command}.params.${key} must be ${type}`) + } + } + for (const key of REQUIRED_PARAMETERS[command] ?? []) { + if (params[key] === undefined) { + throw new RpcParseError('INVALID_PARAMS', `${command}.params.${key} is required`) + } + } + for (const [key, value] of Object.entries(params)) { + if (typeof value === 'string' && isIdentifierParameter(key)) + assertBoundedIdentifier(value, `${command}.params.${key}`) + if (Array.isArray(value)) { + if (value.length > MAX_PROTOCOL_LIST_ITEMS) + throw new RpcParseError('INVALID_PARAMS', `${command}.params.${key} has too many items`) + if (isIdentifierParameter(key)) { + for (const [index, item] of value.entries()) { + if (typeof item !== 'string') continue + assertBoundedIdentifier(item, `${command}.params.${key}[${index}]`) + } + } + } + } +} + +function isIdentifierParameter(key: string): boolean { + return /(?:ids|id|ref|digest|cursor|workspace|connection|session|profile|queue|runner|model)$/iu.test( + key, + ) +} + +function assertString(value: unknown, label: string): string { + if (typeof value !== 'string' || value.length === 0) { + throw new RpcParseError('INVALID_PARAMS', `${label} must be a non-empty string`) + } + return value +} + +function assertCommand(value: unknown): value is RpcCommandName { + return typeof value === 'string' && (HEADLESS_COMMAND_NAMES as readonly string[]).includes(value) +} + +function genericRequest( + value: Record, + command: Exclude, + params: Record, +): GenericRpcRequest { + const operationId = value.operationId + if (operationId !== undefined && (typeof operationId !== 'string' || operationId.length === 0)) { + throw new RpcParseError('INVALID_OPERATION_ID', 'operationId must be a non-empty string') + } + if (isMutatingHeadlessCommand(command) && operationId === undefined) { + throw new RpcParseError('OPERATION_ID_REQUIRED', `${command} requires operationId`) + } + return { + version: 1, + requestId: value.requestId as string, + command, + params, + ...(typeof operationId === 'string' ? { operationId } : {}), + } +} + +export function parseRequest(line: string): BraidRequest { + let parsed: unknown + try { + parsed = JSON.parse(line) + } catch { + throw new RpcParseError('MALFORMED_JSON', 'Input is not valid JSON') + } + if (!isRecord(parsed)) throw new RpcParseError('INVALID_REQUEST', 'Request must be an object') + assertBoundedRequestShape(parsed) + assertAllowedKeys(parsed, ['version', 'requestId', 'operationId', 'command', 'params'], 'request') + if (parsed.version !== BRAID_PROTOCOL_VERSION) { + throw new RpcParseError('UNSUPPORTED_VERSION', 'Only protocol version 1 is supported') + } + const requestId = assertBoundedIdentifier( + assertString(parsed.requestId, 'requestId'), + 'requestId', + ) + const command = parsed.command + if (!assertCommand(command)) { + throw new RpcParseError( + 'UNKNOWN_COMMAND', + `Unknown command: ${String(command)}`, + HEADLESS_COMMAND_NAMES, + ) + } + if ( + parsed.operationId !== undefined && + (typeof parsed.operationId !== 'string' || parsed.operationId.length === 0) + ) { + throw new RpcParseError('INVALID_OPERATION_ID', 'operationId must be a non-empty string') + } + if (typeof parsed.operationId === 'string') + assertBoundedIdentifier(parsed.operationId, 'operationId') + if (parsed.params !== undefined && !isRecord(parsed.params)) { + throw new RpcParseError('INVALID_PARAMS', 'params must be an object') + } + const params = parsed.params ?? {} + assertAllowedKeys(params, PARAMETER_KEYS[command], `${command}.params`) + if (isMutatingHeadlessCommand(command) && typeof parsed.operationId !== 'string') { + throw new RpcParseError('OPERATION_ID_REQUIRED', `${command} requires operationId`) + } + assertParameterTypes(command, params) + + switch (command) { + case 'initialize': + if (parsed.operationId !== undefined) { + throw new RpcParseError('INVALID_PARAMS', 'initialize does not accept operationId') + } + if (typeof params.workspace !== 'string') { + throw new RpcParseError('INVALID_PARAMS', 'initialize.params.workspace must be a string') + } + if (params.subscribe !== undefined && typeof params.subscribe !== 'boolean') { + throw new RpcParseError('INVALID_PARAMS', 'initialize.params.subscribe must be a boolean') + } + return { + version: 1, + requestId, + command, + params: { + workspace: params.workspace, + ...(typeof params.subscribe === 'boolean' ? { subscribe: params.subscribe } : {}), + }, + } + case 'get_state': + if (parsed.operationId !== undefined) { + throw new RpcParseError('INVALID_PARAMS', 'get_state does not accept operationId') + } + if (params.projection !== undefined && typeof params.projection !== 'string') { + throw new RpcParseError('INVALID_PARAMS', 'get_state.params.projection must be a string') + } + if ( + params.projection !== undefined && + params.projection !== 'full' && + params.projection !== 'summary' + ) { + throw new RpcParseError( + 'INVALID_PARAMS', + 'get_state.params.projection must be full or summary', + ) + } + return { + version: 1, + requestId, + command, + params: + params.projection === 'full' || params.projection === 'summary' + ? { projection: params.projection } + : {}, + } + case 'send': + if (typeof parsed.operationId !== 'string' || parsed.operationId.length === 0) { + throw new RpcParseError('OPERATION_ID_REQUIRED', 'send requires operationId') + } + if (typeof params.text !== 'string') { + throw new RpcParseError('INVALID_PARAMS', 'send.params.text must be a string') + } + if (params.conversationId !== undefined && typeof params.conversationId !== 'string') { + throw new RpcParseError('INVALID_PARAMS', 'send.params.conversationId must be a string') + } + if (params.branchId !== undefined && typeof params.branchId !== 'string') { + throw new RpcParseError('INVALID_PARAMS', 'send.params.branchId must be a string') + } + return { + version: 1, + requestId, + operationId: parsed.operationId, + command, + params: { + text: params.text, + ...(typeof params.conversationId === 'string' + ? { conversationId: params.conversationId } + : {}), + ...(typeof params.branchId === 'string' ? { branchId: params.branchId } : {}), + }, + } + case 'shutdown': + if (typeof parsed.operationId !== 'string' || parsed.operationId.length === 0) { + throw new RpcParseError('OPERATION_ID_REQUIRED', 'shutdown requires operationId') + } + if ( + params.mode !== undefined && + params.mode !== 'wait' && + params.mode !== 'detach' && + params.mode !== 'cancel' + ) { + throw new RpcParseError( + 'INVALID_PARAMS', + 'shutdown.params.mode must be wait, detach, or cancel', + ) + } + return { + version: 1, + requestId, + operationId: parsed.operationId, + command, + params: typeof params.mode === 'string' ? { mode: params.mode } : {}, + } + default: + return genericRequest(parsed, command, params) + } +} + +export async function* linesOf(input: RpcInput): AsyncGenerator { + const decoder = new TextDecoder('utf-8', { fatal: true }) + let buffered = '' + let bufferedBytes = 0 + for await (const chunk of input) { + let decoded: string + try { + decoded = typeof chunk === 'string' ? chunk : decoder.decode(chunk, { stream: true }) + } catch { + throw new RpcParseError('INVALID_UTF8', 'Input contains malformed UTF-8') + } + buffered += decoded + bufferedBytes += Buffer.byteLength(decoded, 'utf8') + if (bufferedBytes > MAX_RPC_LINE_BYTES && buffered.indexOf('\n') === -1) + throw new RpcParseError('LINE_TOO_LARGE', 'Input line exceeds the 5 MiB limit') + let newline = buffered.indexOf('\n') + while (newline >= 0) { + const line = buffered.slice(0, newline).replace(/\r$/u, '') + if (Buffer.byteLength(line, 'utf8') > MAX_RPC_LINE_BYTES) + throw new RpcParseError('LINE_TOO_LARGE', 'Input line exceeds the 5 MiB limit') + buffered = buffered.slice(newline + 1) + bufferedBytes = Buffer.byteLength(buffered, 'utf8') + yield line + newline = buffered.indexOf('\n') + } + } + try { + buffered += decoder.decode() + } catch { + throw new RpcParseError('INVALID_UTF8', 'Input contains malformed UTF-8') + } + const line = buffered.replace(/\r$/u, '') + if (Buffer.byteLength(line, 'utf8') > MAX_RPC_LINE_BYTES) + throw new RpcParseError('LINE_TOO_LARGE', 'Input line exceeds the 5 MiB limit') + if (line.length > 0) yield line +} diff --git a/src/views/headless/rpc-types.ts b/src/views/headless/rpc-types.ts new file mode 100644 index 0000000..16038cb --- /dev/null +++ b/src/views/headless/rpc-types.ts @@ -0,0 +1,16 @@ +export interface RpcInput extends AsyncIterable {} + +export interface RpcOutput { + write(chunk: string): boolean + once?(event: 'drain', listener: () => void): unknown +} + +export const RPC_REPLAY_MAX_ENTRIES = 256 +export const RPC_REPLAY_MAX_BYTES = 8 * 1024 * 1024 + +export interface RequestRecord { + readonly digest: string + readonly responses: string[] + bytes: number + replayable: boolean +} diff --git a/src/views/headless/rpc.ts b/src/views/headless/rpc.ts index af131d0..ee26030 100644 --- a/src/views/headless/rpc.ts +++ b/src/views/headless/rpc.ts @@ -1,178 +1,159 @@ -import { AppError, type BraidApplication } from '../../app/application.js' -import { canonicalDigest } from '../../domain/canonical.js' -import type { BraidEventEnvelope } from '../../domain/events.js' +import { canonicalDigest } from '../shared/canonical.js' +import type { BraidUiController, UiEvent } from '../shared/intents.js' +import { redactSensitiveText, sanitizeTerminalText } from '../shared/sanitize.js' +import { BoundedOutputQueue } from './bounded-output.js' import { BRAID_PROTOCOL_VERSION, - type BraidRequest, type BraidResponse, type ErrorResponse, + type GenericRpcRequest, + type StateProjection, } from './protocol.js' +import { linesOf, parseRequest, RpcParseError, requestIdOf } from './rpc-parser.js' +import { + type RequestRecord, + RPC_REPLAY_MAX_BYTES, + RPC_REPLAY_MAX_ENTRIES, + type RpcInput, + type RpcOutput, +} from './rpc-types.js' -export interface RpcInput extends AsyncIterable {} - -export interface RpcOutput { - write(chunk: string): boolean -} - -export const RPC_REPLAY_MAX_ENTRIES = 256 -export const RPC_REPLAY_MAX_BYTES = 8 * 1024 * 1024 - -interface RequestRecord { - readonly digest: string - readonly responses: string[] - bytes: number - replayable: boolean -} - -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === 'object' && !Array.isArray(value) -} - -function requestIdOf(value: unknown): string | undefined { - if (!isRecord(value)) return undefined - return typeof value.requestId === 'string' ? value.requestId : undefined -} - -function assertAllowedKeys( - value: Record, - allowed: readonly string[], - label: string, -): void { - const unknown = Object.keys(value).find((key) => !allowed.includes(key)) - if (unknown) throw new AppError('INVALID_PARAMS', `${label} contains unknown field ${unknown}`) -} - -function parseRequest(line: string): BraidRequest { - let value: unknown - try { - value = JSON.parse(line) - } catch { - throw new AppError('MALFORMED_JSON', 'Input is not valid JSON') - } - if (!isRecord(value)) throw new AppError('INVALID_REQUEST', 'Request must be an object') - if (value.version !== BRAID_PROTOCOL_VERSION) { - throw new AppError('UNSUPPORTED_VERSION', 'Only protocol version 1 is supported') - } - if (typeof value.requestId !== 'string' || value.requestId.length === 0) { - throw new AppError('INVALID_REQUEST_ID', 'requestId must be a non-empty string') - } - if (typeof value.command !== 'string') { - throw new AppError('INVALID_COMMAND', 'command must be a string') - } - if (!isRecord(value.params) && value.params !== undefined) { - throw new AppError('INVALID_PARAMS', 'params must be an object') - } - - const params = isRecord(value.params) ? value.params : {} - switch (value.command) { - case 'initialize': - assertAllowedKeys(value, ['version', 'requestId', 'command', 'params'], 'initialize') - assertAllowedKeys(params, ['workspace', 'subscribe'], 'initialize.params') - if (typeof params.workspace !== 'string') { - throw new AppError('INVALID_PARAMS', 'initialize.params.workspace must be a string') - } - if (params.subscribe !== undefined && typeof params.subscribe !== 'boolean') { - throw new AppError('INVALID_PARAMS', 'initialize.params.subscribe must be a boolean') - } - return { - version: 1, - requestId: value.requestId, - command: 'initialize', - params: { - workspace: params.workspace, - ...(typeof params.subscribe === 'boolean' ? { subscribe: params.subscribe } : {}), - }, - } - case 'get_state': - assertAllowedKeys(value, ['version', 'requestId', 'command', 'params'], 'get_state') - assertAllowedKeys(params, [], 'get_state.params') - return { version: 1, requestId: value.requestId, command: 'get_state' } - case 'send': - assertAllowedKeys(value, ['version', 'requestId', 'operationId', 'command', 'params'], 'send') - assertAllowedKeys(params, ['text', 'conversationId', 'branchId'], 'send.params') - if (typeof value.operationId !== 'string' || value.operationId.length === 0) { - throw new AppError('OPERATION_ID_REQUIRED', 'send requires operationId') - } - if (typeof params.text !== 'string') { - throw new AppError('INVALID_PARAMS', 'send.params.text must be a string') - } - if (params.conversationId !== undefined && typeof params.conversationId !== 'string') { - throw new AppError('INVALID_PARAMS', 'send.params.conversationId must be a string') - } - if (params.branchId !== undefined && typeof params.branchId !== 'string') { - throw new AppError('INVALID_PARAMS', 'send.params.branchId must be a string') - } - return { - version: 1, - requestId: value.requestId, - operationId: value.operationId, - command: 'send', - params: { - text: params.text, - ...(typeof params.conversationId === 'string' - ? { conversationId: params.conversationId } - : {}), - ...(typeof params.branchId === 'string' ? { branchId: params.branchId } : {}), - }, - } - case 'shutdown': - assertAllowedKeys(value, ['version', 'requestId', 'command', 'params'], 'shutdown') - assertAllowedKeys(params, [], 'shutdown.params') - return { version: 1, requestId: value.requestId, command: 'shutdown' } - default: - throw new AppError('UNKNOWN_COMMAND', `Unknown command: ${value.command}`) - } -} - -async function* linesOf(input: RpcInput): AsyncGenerator { - const decoder = new TextDecoder() - let buffered = '' - for await (const chunk of input) { - buffered += typeof chunk === 'string' ? chunk : decoder.decode(chunk, { stream: true }) - let newline = buffered.indexOf('\n') - while (newline >= 0) { - const line = buffered.slice(0, newline) - buffered = buffered.slice(newline + 1) - if (line.length > 0) yield line - newline = buffered.indexOf('\n') - } - } - buffered += decoder.decode() - if (buffered.length > 0) yield buffered -} +export type { RpcInput, RpcOutput } +export { RPC_REPLAY_MAX_BYTES, RPC_REPLAY_MAX_ENTRIES } function errorResponse(error: unknown, requestId?: string): ErrorResponse { - if (error instanceof AppError) { + if (error instanceof RpcParseError) { return { - version: 1, + version: BRAID_PROTOCOL_VERSION, type: 'error', ...(requestId ? { requestId } : {}), code: error.code, - message: error.message, + message: sanitizeTerminalText(error.message), retryable: false, + ...(error.choices ? { choices: error.choices } : {}), + } + } + if (error && typeof error === 'object' && 'kind' in error) { + const result = error as { + readonly kind?: string + readonly code?: string + readonly reason?: string + readonly message?: string + readonly retryable?: boolean + } + if (result.kind === 'unavailable') { + return { + version: BRAID_PROTOCOL_VERSION, + type: 'error', + ...(requestId ? { requestId } : {}), + code: result.code ?? 'CAPABILITY_UNAVAILABLE', + message: sanitizeTerminalText(result.reason ?? 'Capability is unavailable'), + retryable: false, + } + } + if (result.kind === 'error') { + return { + version: BRAID_PROTOCOL_VERSION, + type: 'error', + ...(requestId ? { requestId } : {}), + code: result.code ?? 'INTERNAL_ERROR', + message: sanitizeTerminalText(result.message ?? 'The command failed'), + retryable: result.retryable ?? false, + } } } return { - version: 1, + version: BRAID_PROTOCOL_VERSION, type: 'error', ...(requestId ? { requestId } : {}), code: 'INTERNAL_ERROR', - message: error instanceof Error ? error.message : String(error), + message: redactSensitiveText(error instanceof Error ? error.message : 'Internal error'), retryable: false, } } +function stateResponse( + controller: BraidUiController, + requestId: string, + projection: StateProjection = 'full', +): BraidResponse { + const view = controller.view() + if (projection === 'summary') { + const state = controller.state() + return { + version: BRAID_PROTOCOL_VERSION, + type: 'state', + requestId, + revision: view.revision, + projection, + state: { + schemaVersion: state.schemaVersion, + revision: state.revision, + sequence: state.sequence, + workspace: state.workspace, + conversationId: state.conversationId, + branchId: state.branchId, + profileName: view.profileName, + status: view.status, + messageCount: state.messages.length, + runCount: state.runs.length, + interactionCount: view.interactions.length, + queue: view.queue ?? [], + queueCount: view.queueCount, + activeRunId: state.activeRunId, + lastError: state.lastError, + }, + } + } + return { + version: BRAID_PROTOCOL_VERSION, + type: 'state', + requestId, + revision: view.revision, + projection, + state: controller.state(), + view, + } +} + +function eventResponse(event: UiEvent): BraidResponse { + return { + version: BRAID_PROTOCOL_VERSION, + type: 'event', + sequence: event.sequence, + revision: event.revision, + event, + } +} + export async function runRpc( - app: BraidApplication, + controller: BraidUiController, input: RpcInput, output: RpcOutput, ): Promise { let initialized = false let subscribed = false - let bufferedEvents: BraidEventEnvelope[] | undefined - let replayBytes = 0 + let bufferedEvents: UiEvent[] | undefined + const pendingCompletions = new Set>() const requests = new Map() - const write = (response: BraidResponse) => output.write(`${JSON.stringify(response)}\n`) + let replayBytes = 0 + const outputQueue = new BoundedOutputQueue(output) + let outputFailure: unknown + const writeRaw = async (line: string): Promise => { + if (outputFailure !== undefined) throw outputFailure + try { + await outputQueue.write(line) + } catch (error) { + outputFailure ??= error + throw error + } + } + const write = (response: BraidResponse): Promise => + writeRaw(`${JSON.stringify(response)}\n`) + const emit = async (response: BraidResponse): Promise => { + if (outputFailure !== undefined) throw outputFailure + await write(response) + } const trimReplayHistory = () => { while (requests.size > RPC_REPLAY_MAX_ENTRIES || replayBytes > RPC_REPLAY_MAX_BYTES) { const oldest = requests.entries().next().value as [string, RequestRecord] | undefined @@ -181,9 +162,12 @@ export async function runRpc( replayBytes -= oldest[1].bytes } } - const rememberResponse = (record: RequestRecord, response: BraidResponse) => { + const rememberResponse = async ( + record: RequestRecord, + response: BraidResponse, + ): Promise => { const line = `${JSON.stringify(response)}\n` - const bytes = Buffer.byteLength(line) + const bytes = new TextEncoder().encode(line).byteLength if (record.replayable && record.bytes + bytes <= RPC_REPLAY_MAX_BYTES) { record.responses.push(line) record.bytes += bytes @@ -195,25 +179,20 @@ export async function runRpc( record.bytes = 0 record.replayable = false } - output.write(line) + await writeRaw(line) } - const unsubscribe = app.subscribe((_state, envelope) => { - if (!subscribed) return + const unsubscribe = controller.subscribe((_view, event) => { + if (!event || !subscribed) return if (bufferedEvents) { - bufferedEvents.push(envelope) + bufferedEvents.push(event) return } - write({ - version: 1, - type: 'event', - sequence: envelope.sequence, - revision: envelope.revision, - event: envelope.event, - }) + void emit(eventResponse(event)).catch(() => undefined) }) try { for await (const line of linesOf(input)) { + if (outputFailure !== undefined) throw outputFailure let parsed: unknown try { parsed = JSON.parse(line) @@ -228,9 +207,9 @@ export async function runRpc( const previous = requests.get(request.requestId) if (previous) { if (previous.digest !== digest) { - write( + await write( errorResponse( - new AppError( + new RpcParseError( 'REQUEST_ID_CONFLICT', `requestId ${request.requestId} was already used with different input`, ), @@ -238,9 +217,9 @@ export async function runRpc( ), ) } else if (!previous.replayable) { - write( + await write( errorResponse( - new AppError( + new RpcParseError( 'REQUEST_REPLAY_UNAVAILABLE', `The cached response for requestId ${request.requestId} exceeded the replay limit`, ), @@ -248,57 +227,76 @@ export async function runRpc( ), ) } else { - for (const response of previous.responses) output.write(response) + for (const response of previous.responses) await writeRaw(response) } continue } requestRecord = { digest, responses: [], bytes: 0, replayable: true } requests.set(request.requestId, requestRecord) trimReplayHistory() - const respond = (response: BraidResponse) => { - if (requestRecord) rememberResponse(requestRecord, response) - else write(response) + const respond = async (response: BraidResponse): Promise => { + if (requestRecord) await rememberResponse(requestRecord, response) + else await write(response) } if (!initialized && request.command !== 'initialize') { - throw new AppError('INITIALIZE_REQUIRED', 'The first command must be initialize') + throw new RpcParseError('INITIALIZE_REQUIRED', 'The first command must be initialize') } + if (request.command === 'send') await Promise.all(pendingCompletions) switch (request.command) { case 'initialize': { - if (initialized) throw new AppError('ALREADY_INITIALIZED', 'Already initialized') subscribed = request.params.subscribe ?? false - app.initialize(request.params.workspace) + bufferedEvents = subscribed ? [] : undefined + const result = await controller.initialize(request.params.workspace) + if (result.kind !== 'accepted') { + bufferedEvents = undefined + await respond(errorResponse(result, request.requestId)) + break + } initialized = true - const state = app.state() - respond({ - version: 1, + await respond({ + version: BRAID_PROTOCOL_VERSION, type: 'ack', requestId: request.requestId, - revision: state.revision, + revision: result.revision, + command: request.command, }) - respond({ - version: 1, - type: 'state', + for (const event of bufferedEvents ?? []) await respond(eventResponse(event)) + bufferedEvents = undefined + await respond(stateResponse(controller, request.requestId)) + break + } + case 'get_state': + await respond( + stateResponse(controller, request.requestId, request.params?.projection ?? 'full'), + ) + break + case 'subscribe': { + subscribed = true + await respond({ + version: BRAID_PROTOCOL_VERSION, + type: 'ack', requestId: request.requestId, - revision: state.revision, - state, + revision: controller.view().revision, + command: request.command, }) break } - case 'get_state': { - const state = app.state() - respond({ - version: 1, - type: 'state', + case 'unsubscribe': { + subscribed = false + await respond({ + version: BRAID_PROTOCOL_VERSION, + type: 'ack', requestId: request.requestId, - revision: state.revision, - state, + revision: controller.view().revision, + command: request.command, }) break } case 'send': { bufferedEvents = [] - const receipt = app.send({ + const result = await controller.dispatch({ + type: 'send', operationId: request.operationId, text: request.params.text, ...(request.params.conversationId @@ -306,56 +304,114 @@ export async function runRpc( : {}), ...(request.params.branchId ? { branchId: request.params.branchId } : {}), }) - respond({ - version: 1, + if (result.kind !== 'accepted') { + bufferedEvents = undefined + await respond(errorResponse(result, request.requestId)) + break + } + await respond({ + version: BRAID_PROTOCOL_VERSION, type: 'ack', requestId: request.requestId, operationId: request.operationId, - revision: receipt.revision, - replayed: receipt.replayed, + revision: result.revision, + ...(result.replayed === undefined ? {} : { replayed: result.replayed }), + ...(result.runId === undefined ? {} : { runId: result.runId }), + ...(result.admission === undefined ? {} : { admission: result.admission }), + ...(result.data === undefined ? {} : { result: result.data }), + command: request.command, }) - for (const envelope of bufferedEvents) { - write({ - version: 1, - type: 'event', - sequence: envelope.sequence, - revision: envelope.revision, - event: envelope.event, - }) - } + for (const event of bufferedEvents) await respond(eventResponse(event)) bufferedEvents = undefined - const state = await receipt.completion - respond({ - version: 1, - type: 'state', - requestId: request.requestId, - revision: state.revision, - state, - }) + if (result.completion) { + let tracked: Promise + tracked = result.completion.finally(() => pendingCompletions.delete(tracked)) + pendingCompletions.add(tracked) + void tracked + .then(() => respond(stateResponse(controller, request.requestId))) + .catch(() => undefined) + } else { + await respond(stateResponse(controller, request.requestId)) + } break } case 'shutdown': { - const state = await app.waitForIdle() - respond({ - version: 1, + const result = await controller.dispatch({ + type: 'shutdown', + operationId: request.operationId, + ...(request.params?.mode === undefined ? {} : { mode: request.params.mode }), + }) + if (result.kind !== 'accepted') { + await respond(errorResponse(result, request.requestId)) + break + } + await respond({ + version: BRAID_PROTOCOL_VERSION, type: 'ack', requestId: request.requestId, - revision: state.revision, + revision: result.revision, + operationId: request.operationId, + command: request.command, }) + if (result.completion) await result.completion + await Promise.all(pendingCompletions) + await outputQueue.flush() + if (outputFailure !== undefined) throw outputFailure return 0 } default: { - const exhaustive: never = request - return exhaustive + const generic = request as GenericRpcRequest + if (generic.command === 'cancel_run') bufferedEvents = [] + const result = await controller.dispatch({ + type: 'headless-command', + command: generic.command, + ...(generic.operationId ? { operationId: generic.operationId } : {}), + params: generic.params, + }) + if (result.kind !== 'accepted') { + await respond(errorResponse(result, request.requestId)) + break + } + await respond({ + version: BRAID_PROTOCOL_VERSION, + type: 'ack', + requestId: request.requestId, + revision: result.revision, + ...(generic.operationId ? { operationId: generic.operationId } : {}), + command: generic.command, + ...(result.runId === undefined ? {} : { runId: result.runId }), + ...(result.control === undefined ? {} : { control: result.control }), + ...(result.outcome === undefined ? {} : { outcome: result.outcome }), + ...(result.position === undefined ? {} : { position: result.position }), + ...(result.replayed === undefined ? {} : { replayed: result.replayed }), + ...(result.admission === undefined ? {} : { admission: result.admission }), + ...(result.data === undefined ? {} : { result: result.data }), + }) + if (generic.command === 'cancel_run') { + for (const event of bufferedEvents ?? []) await respond(eventResponse(event)) + bufferedEvents = undefined + if (result.completion) await result.completion + await respond(stateResponse(controller, request.requestId)) + } + break } } } catch (error) { bufferedEvents = undefined const response = errorResponse(error, requestId) - if (requestRecord) rememberResponse(requestRecord, response) - else write(response) + if (requestRecord) await rememberResponse(requestRecord, response) + else await write(response) } } + const eofShutdown = await controller.dispatch({ + type: 'shutdown', + operationId: 'rpc-eof-shutdown', + mode: 'cancel', + }) + if (eofShutdown.kind === 'accepted' && eofShutdown.completion) await eofShutdown.completion + await Promise.all(pendingCompletions) + await outputQueue.flush() + if (outputFailure !== undefined) throw outputFailure return 0 } finally { unsubscribe() diff --git a/src/views/shared/appearance.ts b/src/views/shared/appearance.ts new file mode 100644 index 0000000..a9f2619 --- /dev/null +++ b/src/views/shared/appearance.ts @@ -0,0 +1,66 @@ +export type ColorMode = 'truecolor' | '256' | '16' | 'none' + +export type AppearanceEnvironment = Readonly> + +export interface ResolvedAppearance { + readonly color: ColorMode + readonly highContrast: boolean + readonly reducedMotion: boolean +} + +export function resolveAppearance( + options: Partial = {}, + environment: AppearanceEnvironment = process.env, +): ResolvedAppearance { + return Object.freeze({ + color: resolveColorMode(options.color, environment), + highContrast: options.highContrast ?? false, + reducedMotion: options.reducedMotion ?? false, + }) +} + +export function resolveColorMode( + requested: ColorMode | undefined, + environment: AppearanceEnvironment = process.env, +): ColorMode { + if (environment.NO_COLOR !== undefined) return 'none' + if (requested === 'none') return 'none' + if (requested === '16' || requested === '256') return requested + if (requested === 'truecolor' || requested === undefined) return detectColorMode(environment) + return 'none' +} + +export function detectColorMode(environment: AppearanceEnvironment = process.env): ColorMode { + if (environment.NO_COLOR !== undefined) return 'none' + + const forced = environment.FORCE_COLOR + if (forced === '0') return 'none' + if (forced === '1') return '16' + if (forced === '2') return '256' + if (forced === '3') return 'truecolor' + + const colorTerm = environment.COLORTERM?.toLowerCase() + if (colorTerm === 'truecolor' || colorTerm === '24bit') return 'truecolor' + + const term = environment.TERM?.toLowerCase() ?? '' + if (term === 'dumb') return 'none' + if (term.includes('truecolor') || term.includes('24bit') || term.includes('direct')) { + return 'truecolor' + } + if (term.includes('256color')) return '256' + + return '16' +} + +export function chalkLevel(mode: ColorMode): 0 | 1 | 2 | 3 { + switch (mode) { + case 'truecolor': + return 3 + case '256': + return 2 + case '16': + return 1 + case 'none': + return 0 + } +} diff --git a/src/views/shared/canonical.ts b/src/views/shared/canonical.ts new file mode 100644 index 0000000..9505f24 --- /dev/null +++ b/src/views/shared/canonical.ts @@ -0,0 +1,24 @@ +function canonicalValue(value: unknown): unknown { + if (Array.isArray(value)) return value.map(canonicalValue) + if (value === null || typeof value !== 'object') return value + + return Object.fromEntries( + Object.entries(value) + .filter(([, child]) => child !== undefined) + .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) + .map(([key, child]) => [key, canonicalValue(child)]), + ) +} + +/** + * Canonicalizes a protocol value for stable request identity. + * The protocol layer owns this small value-only helper so views do not import + * application or domain modules merely to detect duplicate JSONL requests. + */ +export function canonicalJson(value: unknown): string { + return JSON.stringify(canonicalValue(value)) +} + +export function canonicalDigest(value: unknown): string { + return canonicalJson(value) +} diff --git a/src/views/shared/command-registry.ts b/src/views/shared/command-registry.ts new file mode 100644 index 0000000..0291660 --- /dev/null +++ b/src/views/shared/command-registry.ts @@ -0,0 +1,403 @@ +import type { SelectItem } from '@earendil-works/pi-tui' +import { + SHARED_COMMAND_NAMES, + SHARED_COMMAND_TABLE, + type SharedCommandName, + sharedCommand, +} from './command-table.js' +import type { HeadlessCommandName } from './headless-commands.js' +import type { BraidIntent } from './intents.js' +import type { CapabilityMap } from './models.js' + +export const COMMAND_NAMES = SHARED_COMMAND_NAMES + +export type CommandName = SharedCommandName + +export type RegisteredCommandIntent = Extract< + BraidIntent, + { readonly type: 'run-command' | 'open-surface' | 'shutdown' } +> + +export type CommandIntentFactory = ( + args: readonly string[], + operationId?: string, +) => RegisteredCommandIntent + +export interface CommandDefinition { + readonly name: CommandName + readonly aliases: readonly string[] + readonly description: string + readonly usage: string + readonly capability: string + readonly confirmation: 'none' | 'explicit' + readonly requiresOperationId: boolean + readonly headlessIdentity: string + readonly headlessCommands: readonly HeadlessCommandName[] + readonly mutatingHeadlessCommands: readonly HeadlessCommandName[] + readonly intent: CommandIntentFactory +} + +const HEADLESS_COMMANDS_FOR = Object.freeze( + Object.fromEntries( + SHARED_COMMAND_TABLE.map((entry) => [entry.name, entry.headlessCommands]), + ) as unknown as Record, +) + +const runCommandIntent = + (command: CommandName): CommandIntentFactory => + (args, operationId) => ({ + type: 'run-command', + command, + args, + ...(operationId ? { operationId } : {}), + }) + +const INTENTS_FOR: Readonly> = { + new: runCommandIntent('new'), + open: runCommandIntent('open'), + profile: runCommandIntent('profile'), + connection: runCommandIntent('connection'), + runner: runCommandIntent('runner'), + model: runCommandIntent('model'), + effort: runCommandIntent('effort'), + branch: runCommandIntent('branch'), + clone: runCommandIntent('clone'), + fork: runCommandIntent('fork'), + graph: () => ({ type: 'open-surface', surface: 'graph' }), + ask: runCommandIntent('ask'), + analyze: runCommandIntent('analyze'), + compare: runCommandIntent('compare'), + approve: runCommandIntent('approve'), + reject: runCommandIntent('reject'), + automate: runCommandIntent('automate'), + queue: runCommandIntent('queue'), + steer: runCommandIntent('steer'), + cancel: runCommandIntent('cancel'), + activity: () => ({ type: 'open-surface', surface: 'activity' }), + export: runCommandIntent('export'), + import: runCommandIntent('import'), + settings: () => ({ type: 'open-surface', surface: 'settings' }), + help: (args) => ({ + type: 'open-surface', + surface: 'help', + ...(args.length > 0 ? { query: args.join(' ') } : {}), + }), + quit: (_args, operationId) => ({ + type: 'shutdown', + operationId: operationId ?? '', + }), +} + +const DEFINITIONS: readonly CommandDefinition[] = [ + ['new', [], 'Create an empty conversation', '/new', 'conversation.create', 'explicit'], + ['open', [], 'Search and open conversations', '/open [query]', 'conversation.open', 'explicit'], + [ + 'profile', + [], + 'Inspect, select, import, or edit a profile', + '/profile [ref]', + 'profile.select', + 'explicit', + ], + [ + 'connection', + [], + 'Inspect, create, test, select, or remove a connection', + '/connection [id]', + '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'], + [ + 'effort', + [], + 'Set reasoning effort for the next run', + '/effort [level]', + 'run.effort', + 'explicit', + ], + [ + 'branch', + [], + 'Create a branch at a message boundary', + '/branch [message]', + 'conversation.branch', + 'explicit', + ], + [ + 'clone', + [], + 'Clone the active branch into a conversation', + '/clone', + 'conversation.clone', + 'explicit', + ], + [ + 'fork', + [], + 'Preview or create a conversation or workspace fork', + '/fork [--workspace]', + 'conversation.fork', + 'explicit', + ], + ['graph', [], 'Open the conversation and run graph', '/graph', 'graph.read', 'none'], + ['ask', [], 'Analyze a frozen run with citations', '/ask ', 'analysis.ask', 'explicit'], + [ + 'analyze', + [], + 'Run a named trace-analysis recipe', + '/analyze ', + 'analysis.recipe', + 'explicit', + ], + [ + 'compare', + [], + 'Compare two frozen sources', + '/compare ', + 'analysis.compare', + 'explicit', + ], + [ + 'approve', + [], + 'Accept the focused interaction', + '/approve [scope]', + 'interaction.respond', + 'explicit', + ], + [ + 'reject', + [], + 'Decline the focused interaction', + '/reject [feedback]', + 'interaction.respond', + 'explicit', + ], + [ + 'automate', + [], + 'Inspect or change interaction rules', + '/automate', + 'interaction.automation', + 'explicit', + ], + ['queue', [], 'Queue input for the next turn', '/queue ', 'run.queue', 'explicit'], + ['steer', [], 'Steer the active run when supported', '/steer ', 'run.steer', 'explicit'], + ['cancel', [], 'Request explicit run cancellation', '/cancel', 'run.cancel', 'explicit'], + [ + 'activity', + [], + 'Open run, tool, worker, and usage activity', + '/activity', + 'activity.read', + 'none', + ], + ['export', [], 'Export selected redacted data', '/export', 'export.create', 'explicit'], + [ + 'import', + [], + 'Import a redacted Braid conversation file', + '/import ', + 'conversation.create', + 'explicit', + ], + [ + 'settings', + [], + 'Open appearance and application settings', + '/settings', + 'settings.open', + 'none', + ], + [ + 'help', + ['?'], + 'Search commands, keys, and capability explanations', + '/help [query]', + 'help.read', + 'none', + ], + ['quit', ['exit'], 'Persist state and leave Braid', '/quit', 'application.quit', 'none'], +].map(([name, aliases, description, usage, capability, confirmation]) => { + const table = sharedCommand(name as string) + return { + name: name as CommandName, + aliases: aliases as readonly string[], + description: description as string, + usage: usage as string, + capability: capability as string, + confirmation: confirmation as 'none' | 'explicit', + requiresOperationId: table?.requiresOperationId ?? confirmation === 'explicit', + headlessIdentity: `braid.command.${name as string}`, + headlessCommands: HEADLESS_COMMANDS_FOR[name as CommandName], + mutatingHeadlessCommands: table?.mutatingHeadlessCommands ?? [], + intent: INTENTS_FOR[name as CommandName], + } +}) + +const BY_NAME = new Map() +for (const definition of DEFINITIONS) { + BY_NAME.set(definition.name, definition) + for (const alias of definition.aliases) BY_NAME.set(alias, definition) +} + +export const COMMAND_DEFINITIONS: readonly CommandDefinition[] = Object.freeze(DEFINITIONS) + +export interface CommandAvailability { + readonly definition: CommandDefinition + readonly available: boolean + readonly reason?: string +} + +export function commandDefinition(name: string): CommandDefinition | undefined { + return BY_NAME.get(name) +} + +export function commandIntent( + name: CommandName, + args: readonly string[], + operationId?: string, +): RegisteredCommandIntent { + const definition = commandDefinition(name) + if (!definition) throw new Error(`Unknown command ${name}`) + return definition.intent(args, operationId) +} + +export function commandAvailability( + name: CommandName, + capabilities: CapabilityMap, +): CommandAvailability { + const definition = BY_NAME.get(name) + if (!definition) throw new Error(`Unknown command ${name}`) + const capability = capabilities[definition.capability] + if (!capability) { + return { + definition, + available: false, + reason: 'The provider did not report this capability', + } + } + if (capability.available) return { definition, available: true } + return { + definition, + available: false, + reason: capability.reason ?? 'The current connection does not report this capability', + } +} + +export function commandItems(capabilities: CapabilityMap): SelectItem[] { + return COMMAND_DEFINITIONS.map((definition) => { + const availability = commandAvailability(definition.name, capabilities) + return { + value: definition.name, + label: `/${definition.name}`, + description: availability.available + ? definition.description + : `unavailable — ${availability.reason ?? 'capability not reported'}`, + } + }) +} + +export type ParsedCommand = + | { readonly kind: 'prompt'; readonly text: string } + | { + readonly kind: 'command' + readonly name: CommandName + readonly args: readonly string[] + readonly raw: string + } + | { + readonly kind: 'unknown' + readonly raw: string + readonly name: string + readonly suggestions: readonly CommandName[] + } + | { readonly kind: 'invalid'; readonly raw: string; readonly message: string } + +function tokenize(input: string): { readonly tokens: readonly string[]; readonly error?: string } { + const tokens: string[] = [] + let token = '' + let quote: 'single' | 'double' | undefined + let escaped = false + for (const character of input) { + if (escaped) { + token += character + escaped = false + } else if (character === '\\') { + escaped = true + } else if (quote === 'single' && character === "'") { + quote = undefined + } else if (quote === 'double' && character === '"') { + quote = undefined + } else if (!quote && (character === "'" || character === '"')) { + quote = character === "'" ? 'single' : 'double' + } else if (!quote && /\s/u.test(character)) { + if (token) tokens.push(token) + token = '' + } else { + token += character + } + } + if (escaped) token += '\\' + if (quote) return { tokens, error: 'Unclosed quote in command' } + if (token) tokens.push(token) + return { tokens } +} + +function editDistance(left: string, right: string): number { + const row = Array.from({ length: right.length + 1 }, (_, index) => index) + for (let leftIndex = 1; leftIndex <= left.length; leftIndex += 1) { + let diagonal = row[0] ?? 0 + row[0] = leftIndex + for (let rightIndex = 1; rightIndex <= right.length; rightIndex += 1) { + const above = row[rightIndex] ?? 0 + const cost = left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1 + row[rightIndex] = Math.min((row[rightIndex - 1] ?? 0) + 1, above + 1, diagonal + cost) + diagonal = above + } + } + return row[right.length] ?? left.length +} + +function suggestionsFor(name: string): readonly CommandName[] { + return COMMAND_DEFINITIONS.map((definition) => ({ + name: definition.name, + score: Math.min(editDistance(name, definition.name), name.length + 2), + })) + .filter((candidate) => candidate.score <= Math.max(3, Math.ceil(name.length / 2))) + .sort((left, right) => left.score - right.score || left.name.localeCompare(right.name)) + .slice(0, 5) + .map((candidate) => candidate.name) +} + +export function parseCommandInput(input: string): ParsedCommand { + if (!input.startsWith('/')) return { kind: 'prompt', text: input } + if (input.startsWith('//')) return { kind: 'prompt', text: input.slice(1) } + const tokenized = tokenize(input.slice(1)) + if (tokenized.error) return { kind: 'invalid', raw: input, message: tokenized.error } + const [name, ...args] = tokenized.tokens + if (!name) return { kind: 'invalid', raw: input, message: 'Enter a command after /' } + const definition = commandDefinition(name) + if (!definition) { + return { kind: 'unknown', raw: input, name, suggestions: suggestionsFor(name) } + } + return { kind: 'command', name: definition.name, args, raw: input } +} + +export function completeCommands( + input: string, + capabilities: CapabilityMap, +): readonly CommandAvailability[] { + const query = input.replace(/^\//u, '').toLowerCase() + return COMMAND_DEFINITIONS.filter((definition) => + [definition.name, ...definition.aliases, definition.description].some((value) => + value.toLowerCase().includes(query), + ), + ).map((definition) => commandAvailability(definition.name, capabilities)) +} + +export function isMutatingCommand(name: CommandName): boolean { + return sharedCommand(name)?.requiresOperationId ?? false +} diff --git a/src/views/shared/command-table.ts b/src/views/shared/command-table.ts new file mode 100644 index 0000000..4059fd6 --- /dev/null +++ b/src/views/shared/command-table.ts @@ -0,0 +1,168 @@ +export const SHARED_COMMAND_TABLE = [ + { + name: 'new', + requiresOperationId: true, + headlessCommands: ['new_conversation'], + mutatingHeadlessCommands: ['new_conversation'], + }, + { + name: 'open', + requiresOperationId: true, + headlessCommands: ['list_conversations', 'open_conversation'], + mutatingHeadlessCommands: ['open_conversation'], + }, + { + name: 'profile', + requiresOperationId: true, + headlessCommands: ['list_profiles', 'select_profile', 'validate_profile', 'save_profile'], + mutatingHeadlessCommands: ['select_profile', 'save_profile'], + }, + { + name: 'connection', + requiresOperationId: true, + headlessCommands: ['list_connections', 'test_connection', 'select_connection'], + mutatingHeadlessCommands: ['test_connection', 'select_connection'], + }, + { + name: 'runner', + requiresOperationId: true, + headlessCommands: ['set_run_override'], + mutatingHeadlessCommands: ['set_run_override'], + }, + { + name: 'model', + requiresOperationId: true, + headlessCommands: ['set_run_override'], + mutatingHeadlessCommands: ['set_run_override'], + }, + { + name: 'effort', + requiresOperationId: true, + headlessCommands: ['set_run_override'], + mutatingHeadlessCommands: ['set_run_override'], + }, + { + name: 'branch', + requiresOperationId: true, + headlessCommands: ['branch'], + mutatingHeadlessCommands: ['branch'], + }, + { + name: 'clone', + requiresOperationId: true, + headlessCommands: ['clone'], + mutatingHeadlessCommands: ['clone'], + }, + { + name: 'fork', + requiresOperationId: true, + headlessCommands: ['plan_fork', 'execute_fork'], + mutatingHeadlessCommands: ['plan_fork', 'execute_fork'], + }, + { + name: 'graph', + requiresOperationId: true, + headlessCommands: ['get_graph'], + mutatingHeadlessCommands: [], + }, + { + name: 'ask', + requiresOperationId: true, + headlessCommands: ['ask'], + mutatingHeadlessCommands: ['ask'], + }, + { + name: 'analyze', + requiresOperationId: true, + headlessCommands: ['analyze'], + mutatingHeadlessCommands: ['analyze'], + }, + { + name: 'compare', + requiresOperationId: true, + headlessCommands: ['compare'], + mutatingHeadlessCommands: ['compare'], + }, + { + name: 'approve', + requiresOperationId: true, + headlessCommands: ['respond_interaction'], + mutatingHeadlessCommands: ['respond_interaction'], + }, + { + name: 'reject', + requiresOperationId: true, + headlessCommands: ['respond_interaction'], + mutatingHeadlessCommands: ['respond_interaction'], + }, + { + name: 'automate', + requiresOperationId: true, + headlessCommands: [], + mutatingHeadlessCommands: [], + }, + { + name: 'queue', + requiresOperationId: true, + headlessCommands: ['queue'], + mutatingHeadlessCommands: ['queue'], + }, + { + name: 'steer', + requiresOperationId: true, + headlessCommands: ['steer'], + mutatingHeadlessCommands: ['steer'], + }, + { + name: 'cancel', + requiresOperationId: true, + headlessCommands: ['cancel_run'], + mutatingHeadlessCommands: ['cancel_run'], + }, + { + name: 'activity', + requiresOperationId: false, + headlessCommands: ['get_activity'], + mutatingHeadlessCommands: [], + }, + { + name: 'export', + requiresOperationId: true, + headlessCommands: ['export'], + mutatingHeadlessCommands: ['export'], + }, + { + name: 'import', + requiresOperationId: true, + headlessCommands: ['import_conversation'], + mutatingHeadlessCommands: ['import_conversation'], + }, + { + name: 'settings', + requiresOperationId: false, + headlessCommands: [], + mutatingHeadlessCommands: [], + }, + { + name: 'help', + requiresOperationId: false, + headlessCommands: [], + mutatingHeadlessCommands: [], + }, + { + name: 'quit', + requiresOperationId: true, + headlessCommands: ['shutdown'], + mutatingHeadlessCommands: ['shutdown'], + }, +] as const + +export type SharedCommandName = (typeof SHARED_COMMAND_TABLE)[number]['name'] +export type CommandName = SharedCommandName +export const SHARED_COMMAND_NAMES = SHARED_COMMAND_TABLE.map( + (entry) => entry.name, +) as readonly SharedCommandName[] + +export function sharedCommand(name: string): (typeof SHARED_COMMAND_TABLE)[number] | undefined { + return SHARED_COMMAND_TABLE.find((entry) => entry.name === name) +} diff --git a/src/views/shared/headless-commands.ts b/src/views/shared/headless-commands.ts new file mode 100644 index 0000000..e5a4f97 --- /dev/null +++ b/src/views/shared/headless-commands.ts @@ -0,0 +1,121 @@ +import { SHARED_COMMAND_TABLE } from './command-table.js' + +export const HEADLESS_COMMAND_NAMES = [ + 'initialize', + 'get_state', + 'subscribe', + 'unsubscribe', + 'list_profiles', + 'select_profile', + 'validate_profile', + 'save_profile', + 'list_connections', + 'test_connection', + 'select_connection', + 'set_run_override', + 'new_conversation', + 'list_conversations', + 'open_conversation', + 'rename_conversation', + 'archive_conversation', + 'delete_conversation', + 'set_draft', + 'import_conversation', + 'send', + 'queue', + 'remove_queued', + 'steer', + 'cancel', + 'detach', + 'reconnect', + 'reconcile', + 'respond_interaction', + 'cancel_run', + 'branch', + 'clone', + 'plan_fork', + 'execute_fork', + 'ask', + 'analyze', + 'compare', + 'promote_analysis', + 'get_graph', + 'get_activity', + 'get_details', + 'steer_worker', + 'cancel_worker', + 'export', + 'shutdown', +] as const + +export type HeadlessCommandName = (typeof HEADLESS_COMMAND_NAMES)[number] + +const HEADLESS_ONLY_MUTATIONS: readonly HeadlessCommandName[] = [ + 'set_draft', + 'remove_queued', + 'promote_analysis', + 'steer_worker', + 'cancel_worker', + 'cancel', + 'detach', + 'reconnect', + 'reconcile', + 'rename_conversation', + 'archive_conversation', + 'delete_conversation', +] + +export const MUTATING_HEADLESS_COMMANDS: readonly HeadlessCommandName[] = Object.freeze( + [ + ...SHARED_COMMAND_TABLE.flatMap((entry) => entry.mutatingHeadlessCommands), + ...HEADLESS_ONLY_MUTATIONS, + ].filter((command, index, all) => all.indexOf(command) === index) as HeadlessCommandName[], +) + +export function isMutatingHeadlessCommand(command: HeadlessCommandName): boolean { + return MUTATING_HEADLESS_COMMANDS.includes(command) +} + +const HEADLESS_CAPABILITIES: Readonly>> = Object.freeze( + { + list_profiles: 'profile.select', + select_profile: 'profile.select', + validate_profile: 'profile.select', + save_profile: 'profile.select', + list_connections: 'connection.select', + test_connection: 'connection.select', + select_connection: 'connection.select', + set_run_override: 'run.runner', + new_conversation: 'conversation.create', + list_conversations: 'conversation.open', + open_conversation: 'conversation.open', + rename_conversation: 'conversation.open', + archive_conversation: 'conversation.open', + delete_conversation: 'conversation.open', + set_draft: 'draft.write', + import_conversation: 'conversation.create', + queue: 'run.queue', + remove_queued: 'run.queue', + steer: 'run.steer', + cancel: 'run.cancel', + detach: 'run.detach', + reconnect: 'run.reconnect', + reconcile: 'run.reconcile', + respond_interaction: 'interaction.respond', + branch: 'conversation.branch', + clone: 'conversation.clone', + plan_fork: 'conversation.fork', + execute_fork: 'conversation.fork', + ask: 'analysis.ask', + analyze: 'analysis.recipe', + compare: 'analysis.compare', + promote_analysis: 'analysis.ask', + steer_worker: 'run.steer', + cancel_worker: 'run.cancel', + export: 'export.create', + }, +) + +export function capabilityForHeadlessCommand(command: HeadlessCommandName): string | undefined { + return HEADLESS_CAPABILITIES[command] +} diff --git a/src/views/shared/intents.ts b/src/views/shared/intents.ts new file mode 100644 index 0000000..ffb3bfc --- /dev/null +++ b/src/views/shared/intents.ts @@ -0,0 +1,112 @@ +import type { CommandName } from './command-table.js' +import type { HeadlessCommandName } from './headless-commands.js' +import type { RunAdmissionReceipt } from '../../domain/receipts.js' + +export type InteractionResponseValue = + | { + readonly outcome: string + readonly value?: string | number | boolean + readonly data?: Readonly> + } + | { readonly outcome: 'cancel'; readonly data?: never } + +export type BraidIntent = + | { + readonly type: 'send' + readonly operationId: string + readonly text: string + readonly conversationId?: string + readonly branchId?: string + } + | { + readonly type: 'set-draft' + readonly operationId: string + readonly text: string + readonly conversationId?: string + readonly branchId?: string + } + | { readonly type: 'queue'; readonly operationId: string; readonly text: string } + | { readonly type: 'steer'; readonly operationId: string; readonly text: string } + | { readonly type: 'cancel-run'; readonly operationId: string; readonly runId?: string } + | { + readonly type: 'respond-interaction' + readonly operationId: string + readonly runId: string + readonly interactionId: string + readonly response: InteractionResponseValue + } + | { + readonly type: 'run-command' + readonly operationId?: string + readonly command: CommandName + readonly args: readonly string[] + } + | { + readonly type: 'headless-command' + readonly command: HeadlessCommandName + readonly operationId?: string + readonly params: Readonly> + } + | { + readonly type: 'open-surface' + readonly surface: 'activity' | 'graph' | 'details' | 'fork' | 'help' | 'settings' + readonly query?: string + } + | { readonly type: 'resize'; readonly columns: number; readonly rows: number } + | { + readonly type: 'shutdown' + readonly operationId: string + readonly mode?: 'wait' | 'detach' | 'cancel' + } + +export type UiDispatchResult = + | { + readonly kind: 'accepted' + readonly operationId?: string + readonly revision: number + readonly replayed?: boolean + readonly runId?: string + readonly control?: + | 'cancel' + | 'steer' + | 'queue' + | 'detach' + | 'reconnect' + | 'respond_interaction' + readonly outcome?: 'accepted' | 'already-applied' | 'rejected' | 'unknown' + readonly position?: number + readonly admission?: RunAdmissionReceipt + readonly completion?: Promise + readonly data?: unknown + readonly notice?: string + } + | { + readonly kind: 'unavailable' + readonly code: 'CAPABILITY_UNAVAILABLE' | 'INVALID_INTENT' + readonly reason: string + } + | { + readonly kind: 'error' + readonly code: string + readonly message: string + readonly retryable: boolean + } + +export interface UiEvent { + readonly sequence: number + readonly revision: number + readonly kind: string + readonly payload: Readonly> +} + +export type UiSubscriber = (view: import('./models.js').BraidViewModel, event?: UiEvent) => void + +export interface BraidUiController { + view(): import('./models.js').BraidViewModel + state(): import('./models.js').HeadlessState + events(): readonly UiEvent[] + initialize(workspace: string): Promise + subscribe(subscriber: UiSubscriber): () => void + dispatch(intent: BraidIntent): Promise + waitForIdle(): Promise +} diff --git a/src/views/shared/models.ts b/src/views/shared/models.ts new file mode 100644 index 0000000..343240e --- /dev/null +++ b/src/views/shared/models.ts @@ -0,0 +1,506 @@ +export type ViewStatus = + | 'empty' + | 'loading' + | 'ready' + | 'starting' + | 'streaming' + | 'running' + | 'waiting' + | 'detached' + | 'reconnecting' + | 'cancelling' + | 'completed' + | 'cancelled' + | 'failed' + | 'expired' + | 'unknown' + | 'storage-failure' + +export type MessageViewStatus = + | 'complete' + | 'streaming' + | 'failed' + | 'cancelled' + | 'aborted' + | 'blocked' + | 'expired' + | 'unknown' + | 'incomplete' + /** Retention or a destroyed content key removed the stored text. */ + | 'redacted' + +export type TranscriptPartKind = + | 'text' + | 'reasoning' + | 'tool' + | 'result' + | 'artifact' + | 'warning' + | 'error' + | 'analysis' + | 'system' + | 'unknown' + +export interface TranscriptPartView { + readonly id: string + readonly kind: TranscriptPartKind + readonly text: string + readonly status?: 'queued' | 'running' | 'complete' | 'failed' | 'cancelled' | 'unknown' + readonly collapsed?: boolean + readonly subject?: SubjectView + readonly durationMs?: number + readonly sourceEventId?: string + readonly toolName?: string + readonly callId?: string + readonly input?: unknown + readonly result?: unknown + readonly error?: string + readonly artifactId?: string + readonly uri?: string + readonly mimeType?: string + readonly metadata?: Readonly> + readonly truncated?: boolean +} + +export interface MessageView { + readonly id: string + readonly role: 'user' | 'assistant' | 'system' + readonly text: string + readonly status: MessageViewStatus + readonly runId?: string + readonly parts: readonly TranscriptPartView[] + readonly partsTruncated?: boolean +} + +export interface UsageView { + readonly input?: number + readonly output?: number + readonly costUsd?: number + readonly model?: string + readonly elapsedMs?: number +} + +export interface RunView { + readonly id: string + readonly turnId?: string + readonly status: ViewStatus + readonly operationId?: string + readonly usage?: UsageView + readonly error?: string + readonly cursor?: string + readonly providerSessionId?: string + readonly environmentId?: string + readonly runner?: string + readonly connection?: string + readonly completeness: + | 'complete' + | 'incomplete' + | 'missing-history' + | 'unknown' + | 'failed' + | 'streaming' + | 'unavailable' + readonly contentBytes?: number + readonly contentTruncated?: boolean + readonly activityTruncated?: boolean + readonly eventDetailsTruncated?: boolean + readonly interactionsTruncated?: boolean +} + +export interface SubjectView { + readonly type: string + readonly title: string + readonly target?: string + readonly detail?: string + readonly preview?: readonly string[] + readonly trustedWorkspace?: 'inside' | 'outside' | 'unknown' +} + +export type InteractionOutcome = + | 'once' + | 'session' + | 'persistent' + | 'accept' + | 'revise' + | 'reject' + | 'deny' + | 'cancel' + +export type AnswerSpecView = + | { + readonly kind: 'text' + readonly required: boolean + readonly secret: boolean + readonly maxLength?: number + } + | { + readonly kind: 'number' + readonly required: boolean + readonly minimum?: number + readonly maximum?: number + } + | { + readonly kind: 'boolean' + readonly required: boolean + readonly defaultValue?: boolean + } + | { + readonly kind: 'select' + readonly required: boolean + readonly options: readonly { readonly value: string; readonly label: string }[] + } + | { + readonly kind: 'secret' + readonly required: boolean + } + | { + readonly kind: 'unknown' + readonly label: string + readonly safeToCancel: boolean + } + | { + readonly kind: 'form' + readonly fields: readonly { + readonly name: string + readonly label: string + readonly type: 'text' | 'number' | 'boolean' | 'select' | 'secret' + readonly required: boolean + readonly options?: readonly { readonly value: string; readonly label: string }[] + readonly minimum?: number + readonly maximum?: number + }[] + } + +export interface InteractionView { + readonly runId: string + readonly interactionId: string + readonly profileName?: string + readonly runner?: string + readonly kind: string + readonly prompt: string + readonly subject?: SubjectView + readonly answerSpec: AnswerSpecView + readonly allowedOutcomes: readonly InteractionOutcome[] + readonly remainingMs?: number + readonly queuePosition: number + readonly secret: boolean + readonly providerSession?: string +} + +export interface CapabilityView { + readonly available: boolean + readonly reason?: string + readonly source: 'provider' | 'runtime' | 'application' | 'local' +} + +export type CapabilityMap = Readonly> + +export interface SelectorView { + readonly id: string + readonly title: string + readonly query: string + readonly loading: boolean + readonly stale: boolean + readonly items: readonly { + readonly id: string + readonly label: string + readonly description?: string + readonly unavailableReason?: string + }[] + readonly selectedId?: string + readonly emptyMessage: string +} + +export interface ActivityItemView { + readonly id: string + readonly kind: 'run' | 'tool' | 'worker' | 'interaction' | 'analysis' | 'system' + readonly title: string + readonly status: ViewStatus | 'complete' + readonly detail?: string + readonly elapsedMs?: number +} + +export interface GraphNodeView { + readonly id: string + readonly type: + | 'conversation' + | 'branch' + | 'turn' + | 'run' + | 'analysis' + | 'environment' + | 'checkpoint' + | 'supervisor' + | 'worker' + readonly title: string + readonly status: ViewStatus | 'complete' + readonly depth: number + readonly edgeLabel?: string +} + +export interface DetailsView { + readonly title: string + readonly fields: readonly { readonly label: string; readonly value: string }[] + readonly sourceEventId?: string +} + +export interface ProfileEditorView { + readonly source: string + readonly digest: string + readonly readOnly: boolean + readonly validation: 'valid' | 'warning' | 'invalid' | 'unknown' + readonly fields: readonly { + readonly path: string + readonly value: string + readonly secret: boolean + }[] + readonly error?: string +} + +export interface ConnectionSetupView { + readonly kind: 'cli-bridge' | 'tangle-inference' | 'tangle-sandbox' + readonly fields: readonly { + readonly label: string + readonly value: string + readonly secret: boolean + }[] + readonly health: + | 'unknown' + | 'checking' + | 'healthy' + | 'unauthorized' + | 'unreachable' + | 'incompatible' + | 'rate-limited' + readonly capabilities: readonly string[] + readonly error?: string +} + +export interface AnalysisFindingView { + readonly id: string + readonly title: string + readonly severity?: string + readonly confidence?: string + readonly citationIds: readonly string[] +} + +export interface AnalysisView { + readonly source: string + readonly analyst: string + readonly recipe: string + readonly status: ViewStatus + readonly findings: readonly AnalysisFindingView[] + readonly citations: readonly { + readonly id: string + readonly eventId: string + readonly text: string + }[] + readonly footer: readonly { readonly label: string; readonly value: string }[] + readonly error?: string +} + +export interface ComparisonArmView { + readonly label: 'baseline' | 'candidate' + readonly runId: string + readonly sourceDigest: string + readonly outcome: string + readonly cost: string + readonly costProvenance: string +} + +export interface ComparisonFieldView { + readonly name: string + readonly baseline: string + readonly candidate: string + readonly asymmetry: 'none' | 'baseline-only' | 'candidate-only' | 'both-missing' +} + +export interface ComparisonView { + readonly baseline: ComparisonArmView + readonly candidate: ComparisonArmView + readonly pairCount: number + readonly unpairedBaseline: number + readonly unpairedCandidate: number + readonly sampleLimit: string + readonly fields: readonly ComparisonFieldView[] + readonly pairedFacts: readonly { readonly label: string; readonly value: string }[] + readonly semantic: { readonly status: string; readonly reason: string } + readonly replayed: boolean +} + +export interface ForkPreviewView { + readonly source: string + readonly destination: string + readonly kind: 'conversation' | 'workspace' | 'cross-runner' + readonly fields: readonly { + readonly label: string + readonly source: string + readonly destination: string + }[] + readonly allowed: boolean + readonly unavailableReason?: string +} + +export interface HelpView { + readonly query: string + readonly concepts: readonly { readonly title: string; readonly text: string }[] +} + +export interface BraidViewModel { + readonly revision: number + readonly workspace: string | null + readonly profileName: string + readonly profileDigest?: string + readonly runner: string + readonly model: string + readonly effort?: string + readonly connection: string + readonly conversationId: string + readonly conversationTitle: string + readonly conversations: readonly { + readonly id: string + readonly title: string + readonly branchId: string + readonly archived: boolean + readonly active: boolean + readonly updatedAt: string + }[] + readonly branch: string + readonly status: ViewStatus + readonly statusText: string + readonly elapsedMs?: number + readonly queueCount: number + readonly messages: readonly MessageView[] + readonly hiddenMessageCount: number + readonly runs: readonly RunView[] + readonly activeRunId?: string + readonly interactions: readonly InteractionView[] + readonly activity: readonly ActivityItemView[] + readonly graph: readonly GraphNodeView[] + readonly details?: DetailsView + readonly profileEditor?: ProfileEditorView + readonly connectionSetup?: ConnectionSetupView + readonly analysis?: AnalysisView + readonly forkPreview?: ForkPreviewView + readonly help?: HelpView + readonly capabilities: CapabilityMap + readonly draft: string + readonly queue?: readonly { + readonly operationId: string + readonly runId: string + readonly text: string + readonly position: number + readonly status: 'queued' | 'blocked' | 'unknown' + }[] + readonly storageFailure?: string + readonly cleanupUncertain?: string + readonly notice?: string + readonly selectedSurface: + | 'transcript' + | 'activity' + | 'graph' + | 'details' + | 'fork' + | 'help' + | 'settings' + readonly appearance: { + readonly color: 'truecolor' | '256' | '16' | 'none' + readonly highContrast: boolean + readonly reducedMotion: boolean + } +} + +export interface HeadlessState { + readonly schemaVersion: number + readonly revision: number + readonly sequence: number + readonly workspace: string | null + readonly conversationId: string + readonly branchId: string + readonly conversations: readonly { + readonly id: string + readonly title: string + readonly branchId: string + readonly archived: boolean + readonly updatedAt: string + }[] + readonly profile: Readonly> + readonly draft: string + readonly messages: readonly { + readonly id: string + readonly role: string + readonly text: string + readonly status: string + readonly runId?: string + readonly parts: readonly TranscriptPartView[] + readonly partsTruncated?: boolean + }[] + readonly runs: readonly { + readonly id: string + readonly turnId: string + readonly operationId: string + readonly status: string + readonly inputTokens: number + readonly outputTokens: number + readonly costUsd?: number + readonly model?: string + readonly error?: string + readonly completeness: RunView['completeness'] + readonly providerSessionId?: string + readonly cursor?: string + readonly contentBytes?: number + readonly contentTruncated?: boolean + readonly activityTruncated?: boolean + readonly eventDetailsTruncated?: boolean + readonly interactionsTruncated?: boolean + }[] + readonly interactions: readonly InteractionView[] + readonly queue: readonly { + readonly operationId: string + readonly runId: string + readonly text: string + readonly position: number + readonly status: 'queued' | 'blocked' | 'unknown' + }[] + readonly activeRunId: string | null + readonly lastError: string | null + readonly storageFailure?: string + readonly cleanupUncertain?: string +} + +export interface HeadlessSummary { + readonly schemaVersion: number + readonly revision: number + readonly sequence: number + readonly workspace: string | null + readonly conversationId: string + readonly branchId: string + readonly profileName: string + readonly status: ViewStatus + readonly messageCount: number + readonly runCount: number + readonly interactionCount: number + readonly queue: readonly { + readonly operationId: string + readonly runId: string + readonly text: string + readonly position: number + readonly status: 'queued' | 'blocked' | 'unknown' + }[] + readonly queueCount: number + readonly activeRunId: string | null + readonly lastError: string | null +} + +export function freezeView(value: T): Readonly { + const seen = new WeakSet() + const freeze = (candidate: unknown): void => { + if (candidate === null || typeof candidate !== 'object') return + if (seen.has(candidate)) return + seen.add(candidate) + for (const child of Object.values(candidate)) freeze(child) + Object.freeze(candidate) + } + freeze(value) + return value as Readonly +} diff --git a/src/views/shared/plain-accessibility.ts b/src/views/shared/plain-accessibility.ts new file mode 100644 index 0000000..8e57690 --- /dev/null +++ b/src/views/shared/plain-accessibility.ts @@ -0,0 +1,57 @@ +import type { UiEvent } from './intents.js' +import type { BraidViewModel, MessageView, TranscriptPartView } from './models.js' +import { sanitizeNotification, sanitizeTerminalText } from './sanitize.js' +import { semanticPayloadText } from './semantic-projection.js' + +export function plainAccessibilityText(view: BraidViewModel): string { + const lines = [ + `status: ${safeLine(view.statusText)}`, + `conversation: ${safeLine(view.conversationTitle)}; branch: ${safeLine(view.branch)}`, + `profile: ${safeLine(view.profileName)}; runner: ${safeLine(view.runner)}; connection: ${safeLine(view.connection)}`, + `appearance: ${view.appearance.color}; high contrast ${view.appearance.highContrast ? 'on' : 'off'}; reduced motion ${view.appearance.reducedMotion ? 'on' : 'off'}`, + ] + + for (const message of view.messages) lines.push(...plainMessageLines(message)) + for (const queued of view.queue ?? []) { + lines.push(`queued message ${queued.position}: ${safeLine(queued.text)} (${queued.status})`) + } + for (const interaction of view.interactions) { + lines.push( + `interaction ${safeLine(interaction.kind)}: ${safeLine(interaction.prompt)}; response required`, + ) + if (interaction.allowedOutcomes.length > 0) { + lines.push(`interaction choices: ${interaction.allowedOutcomes.join(', ')}`) + } + } + if (view.notice) lines.push(`notice: ${safeLine(view.notice)}`) + if (view.storageFailure) lines.push(`storage failure: ${safeLine(view.storageFailure)}`) + if (view.cleanupUncertain) lines.push(`cleanup uncertain: ${safeLine(view.cleanupUncertain)}`) + return lines.join('\n') +} + +export function plainEventText(view: BraidViewModel, event: UiEvent): string { + const payload = safeLine(semanticPayloadText(event.payload)) + return `event ${event.sequence}: ${safeLine(event.kind)}${payload ? `: ${payload}` : ''}; status: ${safeLine(view.statusText)}` +} + +function plainMessageLines(message: MessageView): string[] { + const role = message.role === 'user' ? 'user message' : `${message.role} message` + const lines = [`${role}: ${safeLine(message.text || '(empty)')}`] + if (message.status !== 'complete') lines.push(`${role} status: ${message.status}`) + for (const part of message.parts) { + if (part.kind === 'text' && part.text === message.text) continue + lines.push(...plainPartLines(role, part)) + } + return lines +} + +function plainPartLines(role: string, part: TranscriptPartView): string[] { + const label = `${role} ${part.kind}` + const lines = [`${label}: ${safeLine(part.text || '(empty)')}`] + if (part.status && part.status !== 'complete') lines.push(`${label} status: ${part.status}`) + return lines +} + +function safeLine(value: string): string { + return sanitizeNotification(sanitizeTerminalText(value)) +} diff --git a/src/views/shared/sanitize.ts b/src/views/shared/sanitize.ts index d28b265..ca42280 100644 --- a/src/views/shared/sanitize.ts +++ b/src/views/shared/sanitize.ts @@ -1,56 +1,110 @@ -const BIDI_CONTROLS = /\p{Bidi_Control}/gu +import { + isSensitiveFieldName, + redactSensitiveText, + redactSensitiveUrls, + redactStructuredValue, +} from '../../domain/redaction.js' + +export type UntrustedSurface = + | 'text' + | 'markdown' + | 'diff' + | 'link' + | 'clipboard' + | 'title' + | 'notification' + | 'image' + +export interface SanitizedValue { + readonly value: string + readonly removedControls: boolean + readonly removedBidi: boolean +} + +export { isSensitiveFieldName, redactSensitiveText, redactSensitiveUrls, redactStructuredValue } + +export const MAX_RENDERED_TEXT_CHARS = 200_000 +export const MAX_RENDERED_TEXT_LINES = 4_000 export function sanitizeTerminalText(input: string): string { - let output = '' - let state: 'normal' | 'escape' | 'csi' | 'osc' | 'string' | 'osc-escape' | 'string-escape' = - 'normal' - - for (const character of input) { - const code = character.codePointAt(0) ?? 0 - switch (state) { - case 'normal': - if (character === '\u001b') { - state = 'escape' - } else if (character === '\u009b') { - state = 'csi' - } else if (character === '\u009d') { - state = 'osc' - } else if (character === '\u0090' || character === '\u0098' || character === '\u009e') { - state = 'string' - } else if (character === '\n' || character === '\t') { - output += character - } else if ((code >= 0x20 && code < 0x7f) || code > 0x9f) { - output += character - } - break - case 'escape': - if (character === '[') state = 'csi' - else if (character === ']') state = 'osc' - else if (character === 'P' || character === '^' || character === '_') state = 'string' - else state = 'normal' - break - case 'csi': - if (code >= 0x40 && code <= 0x7e) state = 'normal' - break - case 'osc': - if (character === '\u0007') state = 'normal' - else if (character === '\u001b') state = 'osc-escape' - break - case 'string': - if (character === '\u001b') state = 'string-escape' - break - case 'osc-escape': - state = character === '\\' ? 'normal' : 'osc' - break - case 'string-escape': - state = character === '\\' ? 'normal' : 'string' - break - default: { - const exhaustive: never = state - return exhaustive - } + return redactSensitiveText(input) +} + +/** Bound text after terminal sanitization, before any renderer parses it. */ +export function boundVisibleText(input: string): string { + let value = sanitizeTerminalText(input) + const lines = value.split('\n') + if (lines.length > MAX_RENDERED_TEXT_LINES) { + value = `…\n${lines.slice(-MAX_RENDERED_TEXT_LINES).join('\n')}` + } + if (value.length > MAX_RENDERED_TEXT_CHARS) { + value = `…\n${Array.from(value) + .slice(-(MAX_RENDERED_TEXT_CHARS - 2)) + .join('')}` + } + return value +} + +export function sanitizeForSurface(input: string, _surface: UntrustedSurface): SanitizedValue { + const value = redactSensitiveText(input) + return { + value, + removedControls: value !== input, + removedBidi: /\p{Bidi_Control}/u.test(input), + } +} + +export function sanitizeUrl(input: string): string | undefined { + const value = sanitizeTerminalText(input).trim() + try { + const url = new URL(value) + if (!['http:', 'https:'].includes(url.protocol)) return undefined + if (url.username || url.password) return undefined + for (const key of url.searchParams.keys()) if (isSensitiveFieldName(key)) return undefined + const fragment = url.hash.slice(1) + if (fragment) { + const fragmentParameters = new URLSearchParams(fragment) + for (const key of fragmentParameters.keys()) if (isSensitiveFieldName(key)) return undefined } + return url.toString() + } catch { + return undefined } +} + +function truncateCodePoints(value: string, limit: number): string { + return Array.from(value).slice(0, limit).join('') +} + +export function sanitizeMarkdown(input: string): string { + return redactSensitiveUrls(sanitizeForSurface(input, 'markdown').value) +} + +export function sanitizeDiff(input: string): string { + return sanitizeForSurface(input, 'diff').value +} + +export function sanitizeClipboardText(input: string): string { + return sanitizeForSurface(input, 'clipboard').value +} + +export function sanitizeTitle(input: string): string { + const value = sanitizeForSurface(input, 'title') + .value.replace(/[\n\t]+/gu, ' ') + .trim() + return truncateCodePoints(value, 120) +} + +export function sanitizeNotification(input: string): string { + const value = sanitizeForSurface(input, 'notification') + .value.replace(/[\n\t]+/gu, ' ') + .trim() + return truncateCodePoints(value, 512) +} - return output.replace(BIDI_CONTROLS, '') +export function sanitizeImageAlt(input: string): string { + const value = sanitizeForSurface(input, 'image') + .value.replace(/[\n\t]+/gu, ' ') + .trim() + return truncateCodePoints(value, 512) } diff --git a/src/views/shared/semantic-activity.ts b/src/views/shared/semantic-activity.ts new file mode 100644 index 0000000..91094bc --- /dev/null +++ b/src/views/shared/semantic-activity.ts @@ -0,0 +1,219 @@ +import type { BraidState } from '../../domain/state.js' +import { sanitizeTerminalText, sanitizeTitle } from './sanitize.js' +import { compareSemanticText } from './semantic-graph-filters.js' +import { + assertRunScope, + isInScope, + resolveScope, + type SemanticQueryScope, +} from './semantic-query-scope.js' +import type { + ActivityQueryResult, + SemanticActivityItem, + SemanticNodeType, +} from './semantic-query-types.js' + +const MAX_ACTIVITY_ITEMS = 2_048 + +function safe(value: string): string { + return sanitizeTerminalText(value) +} + +function safeTitle(value: string): string { + return sanitizeTitle(value) || '[untitled]' +} + +function elapsedMs(startedAt: string, endedAt: string | undefined): number | undefined { + if (endedAt === undefined) return undefined + const elapsed = Date.parse(endedAt) - Date.parse(startedAt) + return Number.isFinite(elapsed) && elapsed >= 0 ? elapsed : undefined +} + +function kindForActivity(type: string): SemanticActivityItem['kind'] { + const normalized = type.toLowerCase() + if (normalized.includes('tool')) return 'tool' + if (normalized.includes('interaction') || normalized.includes('question')) return 'interaction' + if (normalized.includes('analysis')) return 'analysis' + if (normalized.includes('worker')) return 'worker' + if (normalized === 'run') return 'run' + return 'system' +} + +function add( + output: SemanticActivityItem[], + item: Omit & { readonly id: string }, +): void { + output.push(item) +} + +function includeRun( + state: BraidState, + run: BraidState['runs'][number], + scope: SemanticQueryScope, + runId: string | undefined, +): boolean { + return (runId === undefined || run.id === runId) && isInScope(state, 'run', run.id, scope) +} + +function activityForRun( + state: BraidState, + run: BraidState['runs'][number], + output: SemanticActivityItem[], +): void { + const runElapsedMs = elapsedMs(run.startedAt, run.terminalAt) + add(output, { + id: `run:${run.id}`, + kind: 'run', + title: `run ${safe(run.id)}`, + status: run.status, + occurredAt: run.startedAt, + ...(run.error === undefined ? {} : { detail: safe(run.error) }), + runId: run.id, + entityType: 'run', + entityId: run.id, + ...(runElapsedMs === undefined ? {} : { elapsedMs: runElapsedMs }), + }) + + for (const item of run.activity) { + const occurredAt = item.source?.occurredAt ?? run.updatedAt + add(output, { + id: `activity:${item.id}`, + kind: kindForActivity(item.type), + title: safeTitle(item.label), + status: run.status, + occurredAt, + ...(item.detail === undefined ? {} : { detail: safe(item.detail) }), + ...(item.source?.eventId === undefined ? {} : { sourceEventId: safe(item.source.eventId) }), + runId: run.id, + entityType: 'run', + entityId: run.id, + }) + } + + for (const event of run.eventDetails) { + add(output, { + id: `event:${run.id}:${event.eventId}`, + kind: 'system', + title: `event ${safe(event.type)}`, + status: run.status, + occurredAt: event.occurredAt ?? run.updatedAt, + detail: `sequence ${event.sequence}`, + sourceEventId: safe(event.eventId), + runId: run.id, + entityType: 'run', + entityId: run.id, + }) + } + + 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', + title: safeTitle(interaction.request.title), + status: interaction.status, + occurredAt: interaction.source.occurredAt ?? run.updatedAt, + ...(interaction.request.body === undefined ? {} : { detail: safe(interaction.request.body) }), + ...(interaction.source.eventId === undefined + ? {} + : { sourceEventId: safe(interaction.source.eventId) }), + runId: run.id, + entityType: 'run', + entityId: run.id, + }) + } +} + +function activityForAnalysis(analysis: BraidState['analyses'][number]): SemanticActivityItem { + return { + id: `analysis:${analysis.id}`, + kind: 'analysis', + title: safeTitle(analysis.recipe ?? `analysis ${analysis.id}`), + status: analysis.status, + occurredAt: analysis.updatedAt, + ...(analysis.question === undefined ? {} : { detail: safe(analysis.question) }), + entityType: 'analysis', + entityId: analysis.id, + ...(analysis.source.runId === undefined ? {} : { runId: analysis.source.runId }), + } +} + +function activityForWorker(worker: BraidState['workers'][number]): SemanticActivityItem { + return { + id: `worker:${worker.id}`, + kind: 'worker', + title: safeTitle(worker.title ?? `worker ${worker.id}`), + status: worker.status, + occurredAt: worker.updatedAt, + ...(worker.logTail === undefined ? {} : { detail: safe(worker.logTail) }), + ...(worker.runId === undefined ? {} : { runId: worker.runId }), + entityType: 'worker', + entityId: worker.id, + } +} + +function compare(left: SemanticActivityItem, right: SemanticActivityItem): number { + const dates = Date.parse(left.occurredAt) - Date.parse(right.occurredAt) + if (Number.isFinite(dates) && dates !== 0) return dates + const kinds = compareSemanticText(left.kind, right.kind) + if (kinds !== 0) return kinds + return compareSemanticText(left.id, right.id) +} + +export function queryActivity( + state: BraidState, + input: { + readonly conversationId?: string + readonly branchId?: string + readonly runId?: string + } = {}, +): ActivityQueryResult { + const scope = resolveScope(state, input) + 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) + } + for (const analysis of state.analyses) { + if ( + isInScope(state, 'analysis', analysis.id, scope) && + (input.runId === undefined || analysis.source.runId === input.runId) + ) { + output.push(activityForAnalysis(analysis)) + } + } + for (const worker of state.workers) { + if ( + isInScope(state, 'worker', worker.id, scope) && + (input.runId === undefined || worker.runId === input.runId) + ) { + output.push(activityForWorker(worker)) + } + } + output.sort(compare) + return { + ...(scope.conversationId === undefined ? {} : { conversationId: scope.conversationId }), + ...(scope.branchId === undefined ? {} : { branchId: scope.branchId }), + ...(input.runId === undefined ? {} : { runId: input.runId }), + activity: output.slice(-MAX_ACTIVITY_ITEMS), + } +} + +export type { SemanticNodeType } diff --git a/src/views/shared/semantic-details.ts b/src/views/shared/semantic-details.ts new file mode 100644 index 0000000..8c0a520 --- /dev/null +++ b/src/views/shared/semantic-details.ts @@ -0,0 +1,387 @@ +import type { AnalysisFinding } from '../../domain/entities.js' +import type { BraidState } from '../../domain/state.js' +import { sanitizeTerminalText, sanitizeTitle } from './sanitize.js' +import { ensureEntityExists, graphEdgesForEntity, queryGraph } from './semantic-graph.js' +import { assertNodeType, SemanticQueryError } from './semantic-query-scope.js' +import type { + DetailsQueryResult, + SemanticDetailField, + SemanticNodeType, +} from './semantic-query-types.js' + +function safe(value: string): string { + return sanitizeTerminalText(value) +} + +function title(value: string): string { + return sanitizeTitle(value) || '[untitled]' +} + +function textValue(value: unknown): string { + if (typeof value === 'string') return safe(value) + if (typeof value === 'number' || typeof value === 'boolean') return String(value) + if (value === undefined || value === null) return '' + if (Array.isArray(value)) return value.map(textValue).filter(Boolean).join(', ') + return '[structured value]' +} + +function fields(data: Readonly>): SemanticDetailField[] { + return Object.entries(data) + .filter(([, value]) => value !== undefined) + .map(([label, value]) => ({ label, value: textValue(value) })) +} + +function safeFinding(finding: AnalysisFinding): Readonly> { + return { + id: finding.id, + text: safe(finding.text), + ...(finding.severity === undefined ? {} : { severity: finding.severity }), + ...(finding.confidence === undefined ? {} : { confidence: finding.confidence }), + supported: finding.supported, + citations: finding.citations.map((citation) => ({ + id: citation.id, + ...(citation.eventId === undefined ? {} : { eventId: citation.eventId }), + ...(citation.messageId === undefined ? {} : { messageId: citation.messageId }), + ...(citation.partId === undefined ? {} : { partId: citation.partId }), + ...(citation.start === undefined ? {} : { start: citation.start }), + ...(citation.end === undefined ? {} : { end: citation.end }), + ...(citation.quote === undefined ? {} : { quote: safe(citation.quote) }), + })), + } +} + +function dataFor( + state: BraidState, + type: SemanticNodeType, + id: string, +): Readonly> | undefined { + switch (type) { + case 'conversation': { + const conversation = state.conversations.find((candidate) => candidate.id === id) + if (conversation === undefined) return undefined + return { + id: conversation.id, + workspaceId: conversation.workspaceId, + title: safe(conversation.title), + activeBranchId: conversation.activeBranchId, + ...(conversation.profileId === undefined ? {} : { profileId: conversation.profileId }), + createdAt: conversation.createdAt, + updatedAt: conversation.updatedAt, + archived: conversation.archived, + ...(conversation.deletedAt === undefined ? {} : { deletedAt: conversation.deletedAt }), + retention: conversation.retention, + } + } + case 'branch': { + const branch = state.branches.find((candidate) => candidate.id === id) + if (branch === undefined) return undefined + return { + id: branch.id, + conversationId: branch.conversationId, + ...(branch.source === undefined ? {} : { source: branch.source }), + ...(branch.profileId === undefined ? {} : { profileId: branch.profileId }), + ...(branch.profileSnapshotId === undefined + ? {} + : { profileSnapshotId: branch.profileSnapshotId }), + ...(branch.connectionId === undefined ? {} : { connectionId: branch.connectionId }), + overrides: { + ...(branch.overrides.runner === undefined + ? {} + : { runner: safe(branch.overrides.runner) }), + ...(branch.overrides.model === undefined ? {} : { model: safe(branch.overrides.model) }), + ...(branch.overrides.effort === undefined ? {} : { effort: branch.overrides.effort }), + ...(branch.overrides.mode === undefined ? {} : { mode: safe(branch.overrides.mode) }), + }, + ...(branch.bindingId === undefined ? {} : { bindingId: branch.bindingId }), + ...(branch.environmentId === undefined ? {} : { environmentId: branch.environmentId }), + draftId: branch.draftId, + queueId: branch.queueId, + ...(branch.tipMessageId === undefined ? {} : { tipMessageId: branch.tipMessageId }), + status: branch.status, + createdAt: branch.createdAt, + updatedAt: branch.updatedAt, + } + } + case 'turn': { + const turn = state.turns.find((candidate) => candidate.id === id) + if (turn === undefined) return undefined + return { + id: turn.id, + conversationId: turn.conversationId, + branchId: turn.branchId, + userMessageId: turn.userMessageId, + runIds: turn.runIds, + ...(turn.selectedRunId === undefined ? {} : { selectedRunId: turn.selectedRunId }), + ...(turn.queueEntryId === undefined ? {} : { queueEntryId: turn.queueEntryId }), + status: turn.status, + createdAt: turn.createdAt, + updatedAt: turn.updatedAt, + } + } + case 'run': { + const run = state.runs.find((candidate) => candidate.id === id) + if (run === undefined) return undefined + return { + id: run.id, + conversationId: run.conversationId, + branchId: run.branchId, + turnId: run.turnId, + operationId: run.operationId, + status: run.status, + inputTokens: run.inputTokens, + outputTokens: run.outputTokens, + capabilities: run.capabilities, + ...(run.reasoningTokens === undefined ? {} : { reasoningTokens: run.reasoningTokens }), + ...(run.costUsd === undefined ? {} : { costUsd: run.costUsd }), + ...(run.model === undefined ? {} : { model: safe(run.model) }), + ...(run.error === undefined ? {} : { error: safe(run.error) }), + ...(run.profileSnapshotId === undefined + ? {} + : { profileSnapshotId: run.profileSnapshotId }), + ...(run.connectionId === undefined ? {} : { connectionId: run.connectionId }), + ...(run.providerSessionId === undefined + ? {} + : { providerSessionId: run.providerSessionId }), + ...(run.environmentId === undefined ? {} : { environmentId: run.environmentId }), + ...(run.bindingId === undefined ? {} : { bindingId: run.bindingId }), + ...(run.receiptId === undefined ? {} : { receiptId: run.receiptId }), + receipt: { + profileDigest: run.receipt.profileDigest, + requestDigest: run.receipt.requestDigest, + capabilitiesDigest: run.receipt.capabilitiesDigest, + admittedAt: run.receipt.admittedAt, + ...(run.receipt.provider === undefined ? {} : { provider: safe(run.receipt.provider) }), + ...(run.receipt.providerSessionId === undefined + ? {} + : { providerSessionId: safe(run.receipt.providerSessionId) }), + ...(run.receipt.environmentId === undefined + ? {} + : { environmentId: run.receipt.environmentId }), + ...(run.receipt.admissionStatus === undefined + ? {} + : { admissionStatus: run.receipt.admissionStatus }), + digest: run.receipt.digest, + ...(run.receipt.materializationDigest === undefined + ? {} + : { materializationDigest: run.receipt.materializationDigest }), + ...(run.receipt.warnings === undefined + ? {} + : { warnings: run.receipt.warnings.map(safe) }), + }, + ...(run.replayCursor === undefined ? {} : { replayCursor: run.replayCursor }), + complete: run.complete, + ...(run.terminalReason === undefined ? {} : { terminalReason: safe(run.terminalReason) }), + startedAt: run.startedAt, + updatedAt: run.updatedAt, + ...(run.terminalAt === undefined ? {} : { terminalAt: run.terminalAt }), + ...(run.lastCursor === undefined ? {} : { lastCursor: safe(run.lastCursor) }), + lastProviderSequence: run.lastProviderSequence, + eventCount: run.eventCount, + ...(run.contentBytes === undefined ? {} : { contentBytes: run.contentBytes }), + ...(run.contentTruncated === undefined ? {} : { contentTruncated: run.contentTruncated }), + ...(run.missingSequence === undefined ? {} : { missingSequence: run.missingSequence }), + ...(run.activityTruncated === undefined + ? {} + : { activityTruncated: run.activityTruncated }), + ...(run.eventDetailsTruncated === undefined + ? {} + : { eventDetailsTruncated: run.eventDetailsTruncated }), + ...(run.interactionsTruncated === undefined + ? {} + : { interactionsTruncated: run.interactionsTruncated }), + activityCount: run.activity.length, + eventDetailCount: run.eventDetails.length, + interactionCount: run.interactions.length, + } + } + case 'analysis': { + const analysis = state.analyses.find((candidate) => candidate.id === id) + if (analysis === undefined) return undefined + return { + id: analysis.id, + ...(analysis.analysisRunId === undefined ? {} : { analysisRunId: analysis.analysisRunId }), + source: { + conversationId: analysis.source.conversationId, + branchId: analysis.source.branchId, + ...(analysis.source.runId === undefined ? {} : { runId: analysis.source.runId }), + ...(analysis.source.throughMessageId === undefined + ? {} + : { throughMessageId: analysis.source.throughMessageId }), + ...(analysis.source.trace === undefined + ? {} + : { + trace: { + id: analysis.source.trace.id, + provider: analysis.source.trace.provider, + digest: analysis.source.trace.digest, + }, + }), + digest: analysis.source.digest, + complete: analysis.source.complete, + ...(analysis.source.missingHistory === undefined + ? {} + : { missingHistory: analysis.source.missingHistory }), + }, + ...(analysis.question === undefined ? {} : { question: safe(analysis.question) }), + ...(analysis.recipe === undefined ? {} : { recipe: safe(analysis.recipe) }), + ...(analysis.analystProfileId === undefined + ? {} + : { analystProfileId: analysis.analystProfileId }), + ...(analysis.analystProfileDigest === undefined + ? {} + : { analystProfileDigest: analysis.analystProfileDigest }), + status: analysis.status, + findings: analysis.findings.map(safeFinding), + ...(analysis.usage === undefined + ? {} + : { + usage: { + input: analysis.usage.input, + output: analysis.usage.output, + ...(analysis.usage.reasoning === undefined + ? {} + : { reasoning: analysis.usage.reasoning }), + ...(analysis.usage.costUsd === undefined + ? {} + : { costUsd: analysis.usage.costUsd }), + ...(analysis.usage.model === undefined + ? {} + : { model: safe(analysis.usage.model) }), + }, + }), + ...(analysis.costUsd === undefined ? {} : { costUsd: analysis.costUsd }), + ...(analysis.wallTimeMs === undefined ? {} : { wallTimeMs: analysis.wallTimeMs }), + createdAt: analysis.createdAt, + updatedAt: analysis.updatedAt, + } + } + case 'environment': { + const environment = state.environments.find((candidate) => candidate.id === id) + if (environment === undefined) return undefined + return { + id: environment.id, + workspaceId: environment.workspaceId, + connectionId: environment.connectionId, + lifecycle: environment.lifecycle, + placement: { + provider: safe(environment.placement.provider), + ...(environment.placement.region === undefined + ? {} + : { region: safe(environment.placement.region) }), + ...(environment.placement.account === undefined + ? {} + : { account: safe(environment.placement.account) }), + confidentialRequested: environment.placement.confidentialRequested, + confidentialVerified: environment.placement.confidentialVerified, + }, + ...(environment.repository === undefined + ? {} + : { repository: safe(environment.repository) }), + ...(environment.gitRef === undefined ? {} : { gitRef: safe(environment.gitRef) }), + ...(environment.workingDirectory === undefined + ? {} + : { workingDirectory: safe(environment.workingDirectory) }), + ...(environment.image === undefined ? {} : { image: safe(environment.image) }), + secretNames: environment.secretNames.map(safe), + createdAt: environment.createdAt, + updatedAt: environment.updatedAt, + } + } + case 'checkpoint': { + const checkpoint = state.checkpoints.find((candidate) => candidate.id === id) + if (checkpoint === undefined) return undefined + return { + id: checkpoint.id, + sourceEnvironmentId: checkpoint.sourceEnvironmentId, + sourceBranchId: checkpoint.sourceBranchId, + ...(checkpoint.sourceRunId === undefined ? {} : { sourceRunId: checkpoint.sourceRunId }), + ...(checkpoint.throughMessageId === undefined + ? {} + : { throughMessageId: checkpoint.throughMessageId }), + requestDigest: checkpoint.requestDigest, + operationId: checkpoint.operationId, + ...(checkpoint.stateDigest === undefined ? {} : { stateDigest: checkpoint.stateDigest }), + createdAt: checkpoint.createdAt, + status: checkpoint.status, + } + } + case 'supervisor': { + const supervisor = state.supervisors.find((candidate) => candidate.id === id) + if (supervisor === undefined) return undefined + return { + id: supervisor.id, + rootRunId: supervisor.rootRunId, + status: supervisor.status, + createdAt: supervisor.createdAt, + updatedAt: supervisor.updatedAt, + } + } + case 'worker': { + const worker = state.workers.find((candidate) => candidate.id === id) + if (worker === undefined) return undefined + return { + id: worker.id, + supervisorId: worker.supervisorId, + ...(worker.parentWorkerId === undefined ? {} : { parentWorkerId: worker.parentWorkerId }), + ...(worker.runId === undefined ? {} : { runId: worker.runId }), + status: worker.status, + ...(worker.title === undefined ? {} : { title: safe(worker.title) }), + ...(worker.spendUsd === undefined ? {} : { spendUsd: worker.spendUsd }), + ...(worker.inputTokens === undefined ? {} : { inputTokens: worker.inputTokens }), + ...(worker.outputTokens === undefined ? {} : { outputTokens: worker.outputTokens }), + ...(worker.latencyMs === undefined ? {} : { latencyMs: worker.latencyMs }), + ...(worker.logTail === undefined ? {} : { logTail: safe(worker.logTail) }), + createdAt: worker.createdAt, + updatedAt: worker.updatedAt, + } + } + } +} + +function statusFor(state: BraidState, type: SemanticNodeType, id: string): string { + const graph = queryGraph(state).nodes.find((node) => node.type === type && node.id === id) + return graph?.status ?? 'unknown' +} + +function titleFor(state: BraidState, type: SemanticNodeType, id: string): string { + const graph = queryGraph(state).nodes.find((node) => node.type === type && node.id === id) + return graph?.title ?? `${type} ${id}` +} + +export function queryDetails( + state: BraidState, + input: { readonly entityType: string; readonly entityId: string }, +): DetailsQueryResult { + const type = assertNodeType(input.entityType) + if (!input.entityId) throw new SemanticQueryError('INVALID_ENTITY', 'Entity id must not be empty') + ensureEntityExists(state, type, input.entityId) + const data = dataFor(state, type, input.entityId) ?? { + id: input.entityId, + type, + } + const detailFields = fields(data) + return { + entityType: type, + entityId: input.entityId, + title: title(titleFor(state, type, input.entityId)), + status: statusFor(state, type, input.entityId), + fields: detailFields, + data, + edges: graphEdgesForEntity(state, type, input.entityId), + } +} + +export function detailsForRun( + state: BraidState, + runId: string | undefined, +): DetailsQueryResult | undefined { + if (runId === undefined) return undefined + try { + return queryDetails(state, { entityType: 'run', entityId: runId }) + } catch (error) { + if (error instanceof SemanticQueryError && error.code === 'UNKNOWN_ENTITY') return undefined + throw error + } +} + +export { assertNodeType } diff --git a/src/views/shared/semantic-graph-edges.ts b/src/views/shared/semantic-graph-edges.ts new file mode 100644 index 0000000..455c5c6 --- /dev/null +++ b/src/views/shared/semantic-graph-edges.ts @@ -0,0 +1,244 @@ +import type { GraphEdgeKind } from '../../domain/entities.js' +import { graphEdge, graphNodeId } from '../../domain/graph-records.js' +import type { BraidState } from '../../domain/state.js' +import { compareSemanticText } from './semantic-graph-filters.js' +import { type NodeDescriptor, semanticNodeKey, semanticReference } from './semantic-graph-nodes.js' +import { + SEMANTIC_NODE_TYPES, + type SemanticGraphEdge, + type SemanticNodeType, +} from './semantic-query-types.js' + +interface ReferenceParts { + readonly type: SemanticNodeType + readonly id: string +} + +function outputEdge( + edge: ReturnType, + source: ReferenceParts, + destination: ReferenceParts, +): SemanticGraphEdge { + return { + id: edge.id, + kind: edge.kind, + source: source.id, + destination: destination.id, + sourceType: source.type, + destinationType: destination.type, + sourceNodeId: edge.source, + destinationNodeId: edge.destination, + provenance: edge.provenance, + createdAt: edge.createdAt, + } +} + +function compareEdges(left: SemanticGraphEdge, right: SemanticGraphEdge): number { + const dates = Date.parse(left.createdAt) - Date.parse(right.createdAt) + if (Number.isFinite(dates) && dates !== 0) return dates + const kinds = compareSemanticText(left.kind, right.kind) + if (kinds !== 0) return kinds + const sources = compareSemanticText( + `${left.sourceType}:${left.source}`, + `${right.sourceType}:${right.source}`, + ) + if (sources !== 0) return sources + const destinations = compareSemanticText( + `${left.destinationType}:${left.destination}`, + `${right.destinationType}:${right.destination}`, + ) + if (destinations !== 0) return destinations + return compareSemanticText(left.id, right.id) +} + +export function relationEdges( + state: BraidState, + nodes: ReadonlyMap, +): SemanticGraphEdge[] { + const nodeByGraphId = new Map() + for (const node of nodes.values()) { + nodeByGraphId.set(graphNodeId(semanticReference(node.type, node.id)), { + type: node.type, + id: node.id, + }) + } + for (const graphNode of state.graphNodes) { + if (!(SEMANTIC_NODE_TYPES as readonly string[]).includes(graphNode.reference.kind)) continue + const type = graphNode.reference.kind as SemanticNodeType + if (nodes.has(semanticNodeKey(type, graphNode.reference.id))) { + nodeByGraphId.set(graphNode.id, { type, id: graphNode.reference.id }) + } + } + + const output: SemanticGraphEdge[] = [] + for (const edge of state.graphEdges) { + const source = nodeByGraphId.get(edge.source) + const destination = nodeByGraphId.get(edge.destination) + if (source === undefined || destination === undefined) continue + output.push({ + id: edge.id, + kind: edge.kind, + source: source.id, + destination: destination.id, + sourceType: source.type, + destinationType: destination.type, + sourceNodeId: edge.source, + destinationNodeId: edge.destination, + provenance: edge.provenance, + createdAt: edge.createdAt, + }) + } + + const known = new Set( + output.map( + (edge) => + `${edge.kind}:${edge.sourceType}:${edge.source}:${edge.destinationType}:${edge.destination}`, + ), + ) + const add = ( + kind: GraphEdgeKind, + source: ReferenceParts, + destination: ReferenceParts, + at: string, + ): void => { + if ( + !nodes.has(semanticNodeKey(source.type, source.id)) || + !nodes.has(semanticNodeKey(destination.type, destination.id)) + ) + return + const relationKey = `${kind}:${source.type}:${source.id}:${destination.type}:${destination.id}` + if (known.has(relationKey)) return + known.add(relationKey) + const edge = graphEdge({ + kind, + source: semanticReference(source.type, source.id), + destination: semanticReference(destination.type, destination.id), + at, + }) + output.push(outputEdge(edge, source, destination)) + } + + for (const branch of state.branches) { + add( + 'attached', + { type: 'conversation', id: branch.conversationId }, + { type: 'branch', id: branch.id }, + branch.createdAt, + ) + if (branch.environmentId !== undefined) { + add( + 'attached', + { type: 'branch', id: branch.id }, + { type: 'environment', id: branch.environmentId }, + branch.updatedAt, + ) + } + } + for (const turn of state.turns) { + add( + 'continued', + { type: 'branch', id: turn.branchId }, + { type: 'turn', id: turn.id }, + turn.createdAt, + ) + for (const runId of turn.runIds) { + add('attached', { type: 'turn', id: turn.id }, { type: 'run', id: runId }, turn.createdAt) + } + } + for (const run of state.runs) { + add('attached', { type: 'turn', id: run.turnId }, { type: 'run', id: run.id }, run.startedAt) + if (run.environmentId !== undefined) { + add( + 'attached', + { type: 'run', id: run.id }, + { type: 'environment', id: run.environmentId }, + run.startedAt, + ) + } + } + for (const analysis of state.analyses) { + add( + 'analyzed', + analysis.source.runId === undefined + ? { type: 'branch', id: analysis.source.branchId } + : { type: 'run', id: analysis.source.runId }, + { type: 'analysis', id: analysis.id }, + analysis.createdAt, + ) + } + for (const checkpoint of state.checkpoints) { + add( + 'checkpointed', + { type: 'environment', id: checkpoint.sourceEnvironmentId }, + { type: 'checkpoint', id: checkpoint.id }, + checkpoint.createdAt, + ) + if (checkpoint.sourceRunId !== undefined) { + add( + 'checkpointed', + { type: 'run', id: checkpoint.sourceRunId }, + { type: 'checkpoint', id: checkpoint.id }, + checkpoint.createdAt, + ) + } + } + for (const binding of state.bindings) { + if (binding.environmentId !== undefined) { + add( + 'attached', + { type: 'branch', id: binding.branchId }, + { type: 'environment', id: binding.environmentId }, + binding.updatedAt, + ) + if (binding.runId !== undefined) { + add( + 'attached', + { type: 'run', id: binding.runId }, + { type: 'environment', id: binding.environmentId }, + binding.updatedAt, + ) + } + } + if (binding.checkpointId !== undefined && binding.environmentId !== undefined) { + add( + 'forked_environment', + { type: 'checkpoint', id: binding.checkpointId }, + { type: 'environment', id: binding.environmentId }, + binding.createdAt, + ) + } + } + for (const supervisor of state.supervisors) { + add( + 'supervised_by', + { type: 'run', id: supervisor.rootRunId }, + { type: 'supervisor', id: supervisor.id }, + supervisor.createdAt, + ) + } + for (const worker of state.workers) { + add( + 'spawned', + { type: 'supervisor', id: worker.supervisorId }, + { type: 'worker', id: worker.id }, + worker.createdAt, + ) + if (worker.parentWorkerId !== undefined) { + add( + 'spawned', + { type: 'worker', id: worker.parentWorkerId }, + { type: 'worker', id: worker.id }, + worker.createdAt, + ) + } + if (worker.runId !== undefined) { + add( + 'attached', + { type: 'worker', id: worker.id }, + { type: 'run', id: worker.runId }, + worker.createdAt, + ) + } + } + return output.sort(compareEdges) +} diff --git a/src/views/shared/semantic-graph-filters.ts b/src/views/shared/semantic-graph-filters.ts new file mode 100644 index 0000000..1cc08e9 --- /dev/null +++ b/src/views/shared/semantic-graph-filters.ts @@ -0,0 +1,63 @@ +import type { NodeDescriptor } from './semantic-graph-nodes.js' +import { SEMANTIC_NODE_TYPES, type SemanticNodeType } from './semantic-query-types.js' + +export interface ParsedGraphQuery { + readonly terms: readonly string[] + readonly type?: SemanticNodeType + readonly status?: string + readonly runner?: string + readonly edgeKind?: string +} + +export function compareSemanticText(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0 +} + +export function parseGraphQuery(input: string): ParsedGraphQuery { + let type: SemanticNodeType | undefined + let status: string | undefined + let runner: string | undefined + let edgeKind: string | undefined + const terms: string[] = [] + for (const token of input.trim().split(/\s+/u).filter(Boolean)) { + const separator = token.indexOf(':') + if (separator > 0) { + const name = token.slice(0, separator).toLowerCase() + const value = token.slice(separator + 1).toLowerCase() + if (name === 'type' || name === 'kind') { + if ((SEMANTIC_NODE_TYPES as readonly string[]).includes(value)) + type = value as SemanticNodeType + else terms.push(token.toLowerCase()) + continue + } + if (name === 'status') { + status = value + continue + } + if (name === 'runner') { + runner = value + continue + } + if (name === 'edge') { + edgeKind = value + continue + } + } + terms.push(token.toLowerCase()) + } + return { + terms, + ...(type === undefined ? {} : { type }), + ...(status === undefined ? {} : { status }), + ...(runner === undefined ? {} : { runner }), + ...(edgeKind === undefined ? {} : { edgeKind }), + } +} + +export function compareNodes(left: NodeDescriptor, right: NodeDescriptor): number { + const dates = Date.parse(left.createdAt) - Date.parse(right.createdAt) + if (Number.isFinite(dates) && dates !== 0) return dates + const types = SEMANTIC_NODE_TYPES.indexOf(left.type) - SEMANTIC_NODE_TYPES.indexOf(right.type) + if (types !== 0) return types + return compareSemanticText(left.id, right.id) +} diff --git a/src/views/shared/semantic-graph-nodes.ts b/src/views/shared/semantic-graph-nodes.ts new file mode 100644 index 0000000..7dde8a3 --- /dev/null +++ b/src/views/shared/semantic-graph-nodes.ts @@ -0,0 +1,285 @@ +import type { GraphNodeRecord, GraphNodeReference } from '../../domain/entities.js' +import type { BraidState } from '../../domain/state.js' +import { sanitizeTerminalText, sanitizeTitle } from './sanitize.js' +import { SEMANTIC_NODE_TYPES, type SemanticNodeType } from './semantic-query-types.js' + +export interface NodeDescriptor { + readonly type: SemanticNodeType + readonly id: string + readonly title: string + readonly status: string + readonly createdAt: string + readonly updatedAt: string + readonly runner?: string + readonly elapsedMs?: number + readonly costUsd?: number + readonly searchText: string +} + +interface NodeFallback { + readonly title: string + readonly status: string + readonly createdAt: string + readonly updatedAt: string + readonly runner?: string | undefined + readonly elapsedMs?: number | undefined + readonly costUsd?: number | undefined + readonly searchText: string +} + +export function semanticNodeKey(type: SemanticNodeType, id: string): string { + return `${type}:${id}` +} + +export function semanticReference(type: SemanticNodeType, id: string): GraphNodeReference { + return { kind: type, id } as GraphNodeReference +} + +export function safeGraphText(value: string): string { + return sanitizeTerminalText(value) +} + +function safeTitle(value: string): string { + return sanitizeTitle(value) || '[untitled]' +} + +function elapsedMs(startedAt: string, endedAt: string | undefined): number | undefined { + if (endedAt === undefined) return undefined + const elapsed = Date.parse(endedAt) - Date.parse(startedAt) + return Number.isFinite(elapsed) && elapsed >= 0 ? elapsed : undefined +} + +function joined(values: readonly (string | undefined)[]): string { + return values.filter((value): value is string => value !== undefined).join(' ') +} + +function turnStatusForProjection(state: BraidState, turn: BraidState['turns'][number]): string { + const runs = state.runs.filter((run) => turn.runIds.includes(run.id)) + if (runs.length === 0) return turn.status + const active = runs.find( + (run) => + !['completed', 'failed', 'aborted', 'cancelled', 'blocked', 'expired', 'unknown'].includes( + run.status, + ), + ) + if (active !== undefined) return active.status + const selected = + (turn.selectedRunId === undefined + ? undefined + : runs.find((run) => run.id === turn.selectedRunId)) ?? runs.at(-1) + if (selected === undefined) return turn.status + if (selected.status === 'aborted' || selected.status === 'cancelled') return 'cancelled' + return selected.status +} + +function descriptor( + type: SemanticNodeType, + id: string, + fallback: NodeFallback, + metadata: GraphNodeRecord | undefined, +): NodeDescriptor { + const nodeTitle = safeTitle(metadata?.title ?? fallback.title) + const metadataStatus = type === 'turn' ? undefined : metadata?.status + const nodeStatus = metadataStatus === undefined ? fallback.status : safeGraphText(metadataStatus) + return { + type, + id, + title: nodeTitle, + status: nodeStatus, + createdAt: metadata?.createdAt ?? fallback.createdAt, + updatedAt: metadata?.updatedAt ?? fallback.updatedAt, + ...(fallback.runner === undefined ? {} : { runner: safeGraphText(fallback.runner) }), + ...(fallback.elapsedMs === undefined ? {} : { elapsedMs: fallback.elapsedMs }), + ...(fallback.costUsd === undefined ? {} : { costUsd: fallback.costUsd }), + searchText: joined([ + type, + id, + nodeTitle, + nodeStatus, + fallback.runner, + fallback.searchText, + ]).toLowerCase(), + } +} + +function metadataFor(state: BraidState): ReadonlyMap { + const output = new Map() + for (const node of state.graphNodes) { + if (!(SEMANTIC_NODE_TYPES as readonly string[]).includes(node.reference.kind)) continue + output.set(semanticNodeKey(node.reference.kind as SemanticNodeType, node.reference.id), node) + } + return output +} + +export function descriptorsFor(state: BraidState): NodeDescriptor[] { + const metadata = metadataFor(state) + const output: NodeDescriptor[] = [] + const add = (type: SemanticNodeType, id: string, fallback: NodeFallback): void => { + output.push(descriptor(type, id, fallback, metadata.get(semanticNodeKey(type, id)))) + } + + for (const conversation of state.conversations) { + add('conversation', conversation.id, { + title: conversation.title, + status: + conversation.deletedAt === undefined + ? conversation.archived + ? 'archived' + : 'active' + : 'deleted', + createdAt: conversation.createdAt, + updatedAt: conversation.updatedAt, + searchText: joined([conversation.workspaceId, conversation.activeBranchId]), + }) + } + for (const branch of state.branches) { + add('branch', branch.id, { + title: `branch ${branch.id}`, + status: branch.status, + createdAt: branch.createdAt, + updatedAt: branch.updatedAt, + runner: branch.overrides.runner, + searchText: joined([ + branch.conversationId, + branch.profileId, + branch.profileSnapshotId, + branch.connectionId, + branch.environmentId, + branch.overrides.model, + branch.overrides.effort, + branch.overrides.mode, + ]), + }) + } + for (const turn of state.turns) { + add('turn', turn.id, { + title: `turn ${turn.id}`, + status: turnStatusForProjection(state, turn), + createdAt: turn.createdAt, + updatedAt: turn.updatedAt, + searchText: joined([turn.conversationId, turn.branchId, turn.userMessageId, ...turn.runIds]), + }) + } + for (const run of state.runs) { + add('run', run.id, { + title: `run ${run.id}`, + status: run.status, + createdAt: run.startedAt, + updatedAt: run.updatedAt, + runner: run.receipt.requested.runner ?? run.receipt.provider, + elapsedMs: elapsedMs(run.startedAt, run.terminalAt), + costUsd: run.costUsd, + searchText: joined([ + run.conversationId, + run.branchId, + run.turnId, + run.operationId, + run.model, + run.connectionId, + run.environmentId, + run.providerSessionId, + run.error, + ]), + }) + } + for (const analysis of state.analyses) { + add('analysis', analysis.id, { + title: analysis.recipe ?? `analysis ${analysis.id}`, + status: analysis.status, + createdAt: analysis.createdAt, + updatedAt: analysis.updatedAt, + searchText: joined([ + analysis.source.conversationId, + analysis.source.branchId, + analysis.source.runId, + analysis.question, + analysis.recipe, + analysis.analystProfileId, + ]), + }) + } + for (const environment of state.environments) { + add('environment', environment.id, { + title: `environment ${environment.id}`, + status: environment.lifecycle, + createdAt: environment.createdAt, + updatedAt: environment.updatedAt, + runner: environment.placement.provider, + searchText: joined([ + environment.workspaceId, + environment.connectionId, + environment.placement.provider, + environment.placement.region, + environment.placement.account, + environment.repository, + environment.gitRef, + environment.workingDirectory, + environment.image, + ]), + }) + } + for (const checkpoint of state.checkpoints) { + add('checkpoint', checkpoint.id, { + title: `checkpoint ${checkpoint.id}`, + status: checkpoint.status, + createdAt: checkpoint.createdAt, + updatedAt: checkpoint.createdAt, + searchText: joined([ + checkpoint.sourceEnvironmentId, + checkpoint.sourceBranchId, + checkpoint.sourceRunId, + checkpoint.throughMessageId, + checkpoint.requestDigest, + checkpoint.stateDigest, + ]), + }) + } + for (const supervisor of state.supervisors) { + add('supervisor', supervisor.id, { + title: `supervisor ${supervisor.id}`, + status: supervisor.status, + createdAt: supervisor.createdAt, + updatedAt: supervisor.updatedAt, + searchText: supervisor.rootRunId, + }) + } + for (const worker of state.workers) { + add('worker', worker.id, { + title: worker.title ?? `worker ${worker.id}`, + status: worker.status, + createdAt: worker.createdAt, + updatedAt: worker.updatedAt, + elapsedMs: elapsedMs(worker.createdAt, worker.updatedAt), + costUsd: worker.spendUsd, + searchText: joined([ + worker.supervisorId, + worker.parentWorkerId, + worker.runId, + worker.title, + worker.logTail, + ]), + }) + } + + const existing = new Set(output.map((node) => semanticNodeKey(node.type, node.id))) + for (const node of state.graphNodes) { + if (!(SEMANTIC_NODE_TYPES as readonly string[]).includes(node.reference.kind)) continue + const type = node.reference.kind as SemanticNodeType + if (existing.has(semanticNodeKey(type, node.reference.id))) continue + output.push( + descriptor( + type, + node.reference.id, + { + title: node.title ?? `${type} ${node.reference.id}`, + status: node.status ?? 'unknown', + createdAt: node.createdAt, + updatedAt: node.updatedAt, + searchText: '', + }, + node, + ), + ) + } + return output +} diff --git a/src/views/shared/semantic-graph.ts b/src/views/shared/semantic-graph.ts new file mode 100644 index 0000000..f8c30ec --- /dev/null +++ b/src/views/shared/semantic-graph.ts @@ -0,0 +1,146 @@ +import type { BraidState } from '../../domain/state.js' +import { sanitizeTerminalText } from './sanitize.js' +import { relationEdges } from './semantic-graph-edges.js' +import { compareNodes, parseGraphQuery } from './semantic-graph-filters.js' +import { descriptorsFor, type NodeDescriptor, semanticNodeKey } from './semantic-graph-nodes.js' +import { + isInScope, + resolveScope, + SemanticQueryError, + type SemanticQueryScope, +} from './semantic-query-scope.js' +import type { + GraphQueryResult, + SemanticGraphEdge, + SemanticGraphNode, + SemanticNodeType, +} from './semantic-query-types.js' + +function graphDepths( + nodes: readonly NodeDescriptor[], + edges: readonly SemanticGraphEdge[], +): ReadonlyMap { + const incomingCount = new Map(nodes.map((node) => [semanticNodeKey(node.type, node.id), 0])) + const outgoing = new Map() + for (const edge of edges) { + const source = semanticNodeKey(edge.sourceType, edge.source) + const destination = semanticNodeKey(edge.destinationType, edge.destination) + incomingCount.set(destination, (incomingCount.get(destination) ?? 0) + 1) + const destinations = outgoing.get(source) ?? [] + destinations.push(destination) + outgoing.set(source, destinations) + } + + const depths = new Map() + const queue = [...incomingCount] + .filter(([, count]) => count === 0) + .map(([key]) => key) + .sort() + for (const root of queue) depths.set(root, 0) + for (let cursor = 0; cursor < queue.length; cursor += 1) { + const source = queue[cursor] + if (source === undefined) continue + const nextDepth = (depths.get(source) ?? 0) + 1 + for (const destination of outgoing.get(source) ?? []) { + const previous = depths.get(destination) + if (previous !== undefined && previous <= nextDepth) continue + depths.set(destination, nextDepth) + queue.push(destination) + } + } + + // A disconnected strongly connected component has no natural root. Keeping + // those nodes at depth zero is deterministic and avoids fabricating ancestry. + for (const key of incomingCount.keys()) { + if (!depths.has(key)) depths.set(key, 0) + } + return depths +} + +function graphNodeFromDescriptor(node: NodeDescriptor, depth: number): SemanticGraphNode { + return { + id: node.id, + type: node.type, + title: node.title, + status: node.status, + createdAt: node.createdAt, + updatedAt: node.updatedAt, + depth, + ...(node.runner === undefined ? {} : { runner: node.runner }), + ...(node.elapsedMs === undefined ? {} : { elapsedMs: node.elapsedMs }), + ...(node.costUsd === undefined ? {} : { costUsd: node.costUsd }), + } +} + +export function queryGraph( + state: BraidState, + input: { + readonly conversationId?: string + readonly branchId?: string + readonly query?: string + } = {}, +): GraphQueryResult { + const scope = resolveScope(state, input) + const queryText = sanitizeTerminalText(input.query ?? '') + const parsed = parseGraphQuery(queryText) + const descriptors = descriptorsFor(state).filter((node) => + isInScope(state, node.type, node.id, scope), + ) + const descriptorMap = new Map( + descriptors.map((node) => [semanticNodeKey(node.type, node.id), node]), + ) + const allEdges = relationEdges(state, descriptorMap) + const depths = graphDepths(descriptors, allEdges) + const edgeNodes = new Set() + if (parsed.edgeKind !== undefined) { + for (const edge of allEdges) { + if (edge.kind.toLowerCase() !== parsed.edgeKind) continue + edgeNodes.add(semanticNodeKey(edge.sourceType, edge.source)) + edgeNodes.add(semanticNodeKey(edge.destinationType, edge.destination)) + } + } + const matching = descriptors + .filter((node) => parsed.type === undefined || node.type === parsed.type) + .filter((node) => parsed.status === undefined || node.status.toLowerCase() === parsed.status) + .filter((node) => parsed.runner === undefined || node.runner?.toLowerCase() === parsed.runner) + .filter( + (node) => parsed.edgeKind === undefined || edgeNodes.has(semanticNodeKey(node.type, node.id)), + ) + .filter((node) => parsed.terms.every((term) => node.searchText.includes(term))) + .sort(compareNodes) + const selected = new Set(matching.map((node) => semanticNodeKey(node.type, node.id))) + const edges = allEdges.filter( + (edge) => + selected.has(semanticNodeKey(edge.sourceType, edge.source)) && + selected.has(semanticNodeKey(edge.destinationType, edge.destination)), + ) + return { + query: queryText, + ...(scope.conversationId === undefined ? {} : { conversationId: scope.conversationId }), + ...(scope.branchId === undefined ? {} : { branchId: scope.branchId }), + nodes: matching.map((node) => + graphNodeFromDescriptor(node, depths.get(semanticNodeKey(node.type, node.id)) ?? 0), + ), + edges, + } +} + +export function graphEdgesForEntity( + state: BraidState, + type: SemanticNodeType, + id: string, +): readonly SemanticGraphEdge[] { + return queryGraph(state).edges.filter( + (edge) => + (edge.sourceType === type && edge.source === id) || + (edge.destinationType === type && edge.destination === id), + ) +} + +export function ensureEntityExists(state: BraidState, type: SemanticNodeType, id: string): void { + if (!queryGraph(state).nodes.some((node) => node.type === type && node.id === id)) { + throw new SemanticQueryError('UNKNOWN_ENTITY', `The requested ${type} is unknown`) + } +} + +export type { SemanticQueryScope } diff --git a/src/views/shared/semantic-projection.ts b/src/views/shared/semantic-projection.ts new file mode 100644 index 0000000..eec35c8 --- /dev/null +++ b/src/views/shared/semantic-projection.ts @@ -0,0 +1,320 @@ +import type { BraidEvent, BraidEventEnvelope, ProviderEventMeta } from '../../domain/events.js' +import type { BraidMessagePart } from '../../domain/state.js' +import { redactStructuredValue } from './sanitize.js' +import { boundVisibleText, sanitizeTerminalText } from './sanitize.js' +import type { TranscriptPartView } from './models.js' + +const MAX_EVENT_ITEMS = 128 +const MAX_EVENT_BYTES = 64 * 1024 + +function safe(value: unknown): unknown { + return redactStructuredValue(value, undefined, { + maxDepth: 6, + maxItems: MAX_EVENT_ITEMS, + maxBytes: MAX_EVENT_BYTES, + }) +} + +function text(value: unknown): string | undefined { + return typeof value === 'string' ? boundVisibleText(value) : undefined +} + +function sourceOf(event: BraidEvent): ProviderEventMeta | undefined { + return 'provider' in event && event.provider ? event.provider : undefined +} + +function withSource(event: BraidEvent, value: Record): Record { + const source = sourceOf(event) + return source === undefined ? value : { ...value, source: safe(source) } +} + +export function projectSemanticEvent( + envelope: BraidEventEnvelope, +): Readonly> { + const event = envelope.event + const source = sourceOf(event) + const base = source === undefined ? {} : { source: safe(source) } + switch (event.kind) { + case 'run.text.delta': { + const value = text(event.text) ?? '' + return withSource(event, { + ...base, + text: value, + part: { kind: 'text', status: 'running', text: value }, + }) + } + case 'run.reasoning.delta': { + const value = text(event.text) ?? '' + return withSource(event, { + ...base, + reasoning: value, + part: { id: event.partId, kind: 'reasoning', status: 'running', text: value }, + }) + } + case 'run.part.updated': + return withSource(event, { + ...base, + part: semanticPart(event.part), + ...(event.delta === undefined ? {} : { delta: text(event.delta) ?? '' }), + }) + case 'run.tool.call': + return withSource(event, { + ...base, + tool: { + id: event.partId, + callId: event.callId, + name: event.toolName, + status: 'running', + ...(event.input === undefined ? {} : { input: safe(event.input) }), + }, + part: { + id: event.partId, + kind: 'tool', + status: 'running', + text: '', + ...(event.input === undefined ? {} : { input: safe(event.input) }), + }, + }) + case 'run.tool.result': + return withSource(event, { + ...base, + tool: { + id: event.partId, + callId: event.callId, + name: event.toolName, + status: event.error === undefined ? 'complete' : 'failed', + ...(event.result === undefined ? {} : { result: safe(event.result) }), + ...(event.error === undefined ? {} : { error: sanitizeTerminalText(event.error) }), + }, + part: { + id: event.partId, + kind: 'result', + status: event.error === undefined ? 'complete' : 'failed', + text: '', + ...(event.result === undefined ? {} : { result: safe(event.result) }), + ...(event.error === undefined ? {} : { error: sanitizeTerminalText(event.error) }), + }, + }) + case 'run.artifact': + return withSource(event, { + ...base, + artifact: safe({ + id: event.artifactId, + name: event.name, + mimeType: event.mimeType, + uri: event.uri, + metadata: event.metadata, + }), + part: { + id: event.artifactId, + kind: 'artifact', + status: 'complete', + text: text(event.name ?? event.uri) ?? '', + ...(event.artifactId === undefined ? {} : { artifactId: event.artifactId }), + ...(event.uri === undefined ? {} : { uri: event.uri }), + ...(event.mimeType === undefined ? {} : { mimeType: event.mimeType }), + ...(event.metadata === undefined ? {} : { metadata: safe(event.metadata) }), + }, + }) + case 'run.proposal': + return withSource(event, { + ...base, + proposal: safe({ id: event.proposalId, title: event.title, status: event.status }), + part: { + id: event.proposalId, + kind: 'proposal', + status: 'complete', + text: text(event.title) ?? '', + }, + }) + case 'run.warning': + return withSource(event, { + ...base, + warning: safe({ code: event.code, message: event.message }), + text: text(event.message) ?? '', + }) + case 'run.error': + return withSource(event, { + ...base, + error: safe({ message: event.message, recoverable: event.recoverable }), + text: text(event.message) ?? '', + }) + case 'run.finished': + return withSource(event, { + ...base, + status: event.status, + completeness: event.status === 'unknown' ? 'unknown' : 'complete', + finalText: text(event.finalText) ?? '', + usage: safe(event.usage), + ...(event.reason === undefined ? {} : { reason: text(event.reason) ?? 'RUNTIME_STATUS' }), + ...(event.error === undefined ? {} : { error: text(event.error) ?? 'RUNTIME_ERROR' }), + }) + case 'run.interaction': + return withSource(event, { ...base, interaction: safe(event.request) }) + case 'run.interaction.cancelled': + return withSource(event, { + ...base, + interaction: safe({ id: event.interactionId, status: 'cancelled', reason: event.reason }), + }) + case 'run.provider.event': + return withSource(event, { ...base, unknown: safe(event.envelope.event) }) + case 'run.queue.added': + return { + ...base, + queue: { + operationId: event.operationId, + runId: event.runId, + text: text(event.text) ?? '', + position: event.position, + status: 'queued', + }, + } + case 'run.queue.removed': + return { + ...base, + queue: { operationId: event.operationId, runId: event.runId, status: 'removed' }, + } + case 'run.control.requested': + return { + ...base, + control: safe({ + operationId: event.operationId, + runId: event.runId, + control: event.control, + status: 'requested', + reason: event.reason, + text: event.text, + }), + } + case 'run.cancel.requested': + return { + ...base, + control: safe({ + operationId: event.operationId, + runId: event.runId, + control: 'cancel', + status: 'requested', + reason: event.reason, + }), + } + case 'run.control.acknowledged': + return { + ...base, + control: safe({ + operationId: event.operationId, + runId: event.runId, + control: event.control, + status: event.outcome, + detail: event.detail, + }), + } + case 'run.requested': + return { + ...base, + text: text(event.text) ?? '', + admission: safe(event.receipt), + status: 'admitted', + } + case 'run.unknown': + return { + ...base, + status: 'unknown', + completeness: 'unknown', + error: text(event.detail) ?? 'RUNTIME_UNKNOWN', + } + case 'run.reconnecting': + return { ...base, status: 'reconnecting', completeness: 'streaming', cursor: event.after } + case 'run.reconciled': + return { + ...base, + status: event.status, + completeness: event.status === 'unknown' ? 'unknown' : 'complete', + evidence: event.evidence, + ...(event.detail === undefined ? {} : { detail: text(event.detail) ?? 'RUNTIME_STATUS' }), + } + default: + return { ...base, value: safe(event) } + } +} + +export function semanticPart(part: BraidMessagePart): TranscriptPartView { + const kind: TranscriptPartView['kind'] = + part.kind === 'tool-call' + ? 'tool' + : part.kind === 'tool-result' + ? 'result' + : part.kind === 'proposal' + ? 'analysis' + : part.kind === 'interaction' + ? 'system' + : part.kind + return { + id: part.id, + kind, + text: boundVisibleText(part.text ?? part.title ?? ''), + ...(part.status && isPartStatus(part.status) ? { status: part.status } : {}), + ...(part.title === undefined + ? {} + : { subject: { type: part.kind, title: sanitizeTerminalText(part.title) } }), + ...(part.source?.eventId === undefined ? {} : { sourceEventId: part.source.eventId }), + ...(part.toolName === undefined ? {} : { toolName: sanitizeTerminalText(part.toolName) }), + ...(part.callId === undefined ? {} : { callId: sanitizeTerminalText(part.callId) }), + ...(part.input === undefined ? {} : { input: safe(part.input) }), + ...(part.result === undefined ? {} : { result: safe(part.result) }), + ...(part.error === undefined ? {} : { error: sanitizeTerminalText(part.error) }), + ...(part.artifactId === undefined ? {} : { artifactId: sanitizeTerminalText(part.artifactId) }), + ...(part.uri === undefined ? {} : { uri: sanitizeTerminalText(part.uri) }), + ...(part.mimeType === undefined ? {} : { mimeType: sanitizeTerminalText(part.mimeType) }), + ...(part.metadata === undefined + ? {} + : { metadata: safe(part.metadata) as Readonly> }), + } +} + +function isPartStatus(value: string): value is NonNullable { + return ['queued', 'running', 'complete', 'failed', 'cancelled', 'unknown'].includes(value) +} + +export function semanticPayloadText(payload: Readonly>): string { + const direct = payload.text ?? payload.reasoning ?? payload.finalText + if (typeof direct === 'string') return direct + const part = payload.part + if (part && typeof part === 'object' && !Array.isArray(part)) { + const value = part as Record + const partText = value.text + if (typeof partText === 'string' && partText) return partText + } + for (const key of [ + 'warning', + 'error', + 'interaction', + 'artifact', + 'proposal', + 'tool', + 'control', + 'queue', + 'unknown', + ]) { + const value = payload[key] + if (value === undefined) continue + if (typeof value === 'string') return value + if (value && typeof value === 'object') { + const candidate = value as Record + for (const field of [ + 'message', + 'prompt', + 'title', + 'name', + 'detail', + 'status', + 'text', + 'uri', + 'reason', + ]) { + if (typeof candidate[field] === 'string') return candidate[field] as string + } + return JSON.stringify(value) + } + } + return '' +} diff --git a/src/views/shared/semantic-query-scope.ts b/src/views/shared/semantic-query-scope.ts new file mode 100644 index 0000000..8206817 --- /dev/null +++ b/src/views/shared/semantic-query-scope.ts @@ -0,0 +1,174 @@ +import type { BraidState } from '../../domain/state.js' +import { SEMANTIC_NODE_TYPES, type SemanticNodeType } from './semantic-query-types.js' + +export class SemanticQueryError extends Error { + readonly code: string + + constructor(code: string, message: string) { + super(message) + this.name = 'SemanticQueryError' + this.code = code + } +} + +export interface SemanticQueryScope { + readonly conversationId?: string + readonly branchId?: string +} + +export function resolveScope( + state: BraidState, + input: { readonly conversationId?: string; readonly branchId?: string }, +): SemanticQueryScope { + const conversation = + input.conversationId === undefined + ? undefined + : state.conversations.find((candidate) => candidate.id === input.conversationId) + if (input.conversationId !== undefined && conversation === undefined) { + throw new SemanticQueryError('UNKNOWN_CONVERSATION', 'The requested conversation is unknown') + } + + const branch = + input.branchId === undefined + ? undefined + : state.branches.find((candidate) => candidate.id === input.branchId) + if (input.branchId !== undefined && branch === undefined) { + throw new SemanticQueryError('UNKNOWN_BRANCH', 'The requested branch is unknown') + } + if ( + conversation !== undefined && + branch !== undefined && + conversation.id !== branch.conversationId + ) { + throw new SemanticQueryError( + 'BRANCH_SCOPE_CONFLICT', + 'The requested branch does not belong to the requested conversation', + ) + } + + const conversationId = conversation?.id ?? branch?.conversationId + const branchId = branch?.id + return { + ...(conversationId === undefined ? {} : { conversationId }), + ...(branchId === undefined ? {} : { branchId }), + } +} + +export function assertNodeType(value: string): SemanticNodeType { + if ((SEMANTIC_NODE_TYPES as readonly string[]).includes(value)) { + return value as SemanticNodeType + } + throw new SemanticQueryError('UNKNOWN_ENTITY_TYPE', `Unsupported entity type ${value}`) +} + +export function assertRunScope( + state: BraidState, + runId: string, + scope: SemanticQueryScope, +): BraidState['runs'][number] { + const run = state.runs.find((candidate) => candidate.id === runId) + if (run === undefined) throw new SemanticQueryError('UNKNOWN_RUN', 'The requested run is unknown') + if ( + (scope.conversationId !== undefined && run.conversationId !== scope.conversationId) || + (scope.branchId !== undefined && run.branchId !== scope.branchId) + ) { + throw new SemanticQueryError( + 'RUN_SCOPE_CONFLICT', + 'The requested run is outside the query scope', + ) + } + return run +} + +export function isInScope( + state: BraidState, + type: SemanticNodeType, + id: string, + scope: SemanticQueryScope, +): boolean { + if (scope.conversationId === undefined && scope.branchId === undefined) return true + switch (type) { + case 'conversation': + return scope.conversationId === undefined || id === scope.conversationId + case 'branch': { + const branch = state.branches.find((candidate) => candidate.id === id) + return ( + branch !== undefined && + (scope.conversationId === undefined || branch.conversationId === scope.conversationId) && + (scope.branchId === undefined || branch.id === scope.branchId) + ) + } + case 'turn': { + const turn = state.turns.find((candidate) => candidate.id === id) + return ( + turn !== undefined && + (scope.conversationId === undefined || turn.conversationId === scope.conversationId) && + (scope.branchId === undefined || turn.branchId === scope.branchId) + ) + } + case 'run': { + const run = state.runs.find((candidate) => candidate.id === id) + return ( + run !== undefined && + (scope.conversationId === undefined || run.conversationId === scope.conversationId) && + (scope.branchId === undefined || run.branchId === scope.branchId) + ) + } + case 'analysis': { + const analysis = state.analyses.find((candidate) => candidate.id === id) + return ( + analysis !== undefined && + (scope.conversationId === undefined || + analysis.source.conversationId === scope.conversationId) && + (scope.branchId === undefined || analysis.source.branchId === scope.branchId) + ) + } + case 'environment': { + const environment = state.environments.find((candidate) => candidate.id === id) + if (environment === undefined) return false + if (scope.branchId !== undefined) { + return ( + state.branches.some( + (branch) => branch.id === scope.branchId && branch.environmentId === environment.id, + ) || + state.runs.some( + (run) => run.branchId === scope.branchId && run.environmentId === environment.id, + ) + ) + } + return state.conversations.some( + (conversation) => + conversation.id === scope.conversationId && + conversation.workspaceId === environment.workspaceId, + ) + } + case 'checkpoint': { + const checkpoint = state.checkpoints.find((candidate) => candidate.id === id) + return ( + checkpoint !== undefined && + (scope.conversationId === undefined || + state.branches.some( + (branch) => + branch.id === checkpoint.sourceBranchId && + branch.conversationId === scope.conversationId, + )) && + (scope.branchId === undefined || checkpoint.sourceBranchId === scope.branchId) + ) + } + case 'supervisor': { + const supervisor = state.supervisors.find((candidate) => candidate.id === id) + if (supervisor === undefined) return false + const run = state.runs.find((candidate) => candidate.id === supervisor.rootRunId) + return ( + run !== undefined && + (scope.conversationId === undefined || run.conversationId === scope.conversationId) && + (scope.branchId === undefined || run.branchId === scope.branchId) + ) + } + case 'worker': { + const worker = state.workers.find((candidate) => candidate.id === id) + if (worker === undefined) return false + return isInScope(state, 'supervisor', worker.supervisorId, scope) + } + } +} diff --git a/src/views/shared/semantic-query-types.ts b/src/views/shared/semantic-query-types.ts new file mode 100644 index 0000000..90f2c65 --- /dev/null +++ b/src/views/shared/semantic-query-types.ts @@ -0,0 +1,123 @@ +import type { GraphEdgeKind, GraphProvenance } from '../../domain/entities.js' +import type { ActivityItemView, ViewStatus } from './models.js' + +export const SEMANTIC_NODE_TYPES = [ + 'conversation', + 'branch', + 'turn', + 'run', + 'analysis', + 'environment', + 'checkpoint', + 'supervisor', + 'worker', +] as const + +export type SemanticNodeType = (typeof SEMANTIC_NODE_TYPES)[number] + +export interface SemanticGraphNode { + readonly id: string + readonly type: SemanticNodeType + readonly title: string + readonly status: string + readonly createdAt: string + readonly updatedAt: string + readonly depth: number + readonly runner?: string + readonly elapsedMs?: number + readonly costUsd?: number +} + +export interface SemanticGraphEdge { + readonly id: string + readonly kind: GraphEdgeKind + readonly source: string + readonly destination: string + readonly sourceType: SemanticNodeType + readonly destinationType: SemanticNodeType + readonly sourceNodeId: string + readonly destinationNodeId: string + readonly provenance: GraphProvenance + readonly createdAt: string +} + +export interface GraphQueryResult { + readonly query: string + readonly conversationId?: string + readonly branchId?: string + readonly nodes: readonly SemanticGraphNode[] + readonly edges: readonly SemanticGraphEdge[] +} + +export interface SemanticActivityItem { + readonly id: string + readonly kind: ActivityItemView['kind'] + readonly title: string + readonly status: string + readonly occurredAt: string + readonly detail?: string + readonly sourceEventId?: string + readonly runId?: string + readonly entityType?: SemanticNodeType + readonly entityId?: string + readonly elapsedMs?: number +} + +export interface ActivityQueryResult { + readonly conversationId?: string + readonly branchId?: string + readonly runId?: string + readonly activity: readonly SemanticActivityItem[] +} + +export interface SemanticDetailField { + readonly label: string + readonly value: string +} + +export interface DetailsQueryResult { + readonly entityType: SemanticNodeType + readonly entityId: string + readonly title: string + readonly status: string + readonly fields: readonly SemanticDetailField[] + readonly data: Readonly> + readonly edges: readonly SemanticGraphEdge[] +} + +export function viewStatusForSemanticStatus(status: string): ViewStatus | 'complete' { + switch (status) { + case 'starting': + return 'starting' + case 'running': + case 'streaming': + return 'running' + case 'waiting': + case 'blocked': + return 'waiting' + case 'cancelling': + return 'cancelling' + case 'completed': + case 'complete': + case 'active': + case 'ready': + case 'bound': + case 'requested': + case 'preparing': + return 'complete' + case 'cancelled': + case 'aborted': + return 'cancelled' + case 'failed': + case 'failed-preparation': + return 'failed' + case 'expired': + return 'expired' + case 'unknown': + case 'deleted': + case 'destroyed': + return 'unknown' + default: + return 'complete' + } +} diff --git a/src/views/tui/activity.ts b/src/views/tui/activity.ts new file mode 100644 index 0000000..820f919 --- /dev/null +++ b/src/views/tui/activity.ts @@ -0,0 +1,50 @@ +import { Container, Spacer, Text } from '@earendil-works/pi-tui' +import type { ActivityItemView, BraidViewModel } from '../shared/models.js' +import { sanitizeTerminalText } from '../shared/sanitize.js' +import type { BraidTheme } from './theme.js' + +export class ActivityView extends Container { + readonly #theme: BraidTheme + + constructor(theme: BraidTheme) { + super() + this.#theme = theme + } + + setView(view: BraidViewModel): void { + this.clear() + this.addChild(new Text(this.#theme.brand('activity'), 1, 0)) + if (view.activity.length === 0) { + this.addChild(new Spacer(1)) + this.addChild(new Text(this.#theme.muted('No recorded activity.'), 1, 0)) + } else { + this.addChild(new Spacer(1)) + for (const item of view.activity) this.addChild(this.#item(item)) + } + this.invalidate() + } + + #item(item: ActivityItemView): Text { + const status = sanitizeTerminalText(item.status) + const detail = item.detail ? ` · ${sanitizeTerminalText(item.detail)}` : '' + const title = sanitizeTerminalText(item.title) + const color = + item.status === 'failed' || item.status === 'storage-failure' + ? this.#theme.danger + : item.status === 'running' + ? this.#theme.warning + : item.status === 'complete' || item.status === 'completed' + ? this.#theme.success + : this.#theme.muted + const symbol = + item.status === 'failed' || item.status === 'storage-failure' + ? 'x' + : item.status === 'running' + ? '>' + : item.status === 'complete' || item.status === 'completed' + ? 'ok' + : '·' + const elapsed = item.elapsedMs === undefined ? '' : ` ${Math.round(item.elapsedMs)}ms` + return new Text(`${color(`${symbol} ${status}`)} ${title}${elapsed}${detail}`, 1, 0) + } +} diff --git a/src/views/tui/analysis.ts b/src/views/tui/analysis.ts new file mode 100644 index 0000000..261bd68 --- /dev/null +++ b/src/views/tui/analysis.ts @@ -0,0 +1,210 @@ +import { Container, type Focusable, matchesKey, TruncatedText } from '@earendil-works/pi-tui' +import type { AnalysisRecord } from '../../domain/entities.js' +import type { AnalysisView, BraidViewModel } from '../shared/models.js' +import { sanitizeTerminalText } from '../shared/sanitize.js' +import type { BraidTheme } from './theme.js' +import { shortDigest } from './configuration-presenters.js' + +const PAGE_ROWS = 6 + +interface AnalysisMode { + readonly command: '/ask' | '/analyze' | '/compare' + readonly label: string +} + +interface AnalysisDocument { + readonly heading: string + readonly context: readonly string[] + readonly details: readonly string[] +} + +export function analysisViewForRecord(record: AnalysisRecord): AnalysisView { + const citations = record.findings.flatMap((finding) => + finding.citations.map((citation) => ({ + id: String(citation.id), + eventId: String(citation.eventId ?? 'unavailable'), + text: citation.quote ?? 'citation has no quoted text', + })), + ) + return { + source: String(record.source.digest), + analyst: String(record.analystProfileId ?? 'configured analyst'), + recipe: record.recipe ?? (record.question === undefined ? 'analysis' : 'ask'), + status: + record.status === 'preparing' || record.status === 'running' ? 'running' : record.status, + findings: record.findings.map((finding) => ({ + id: finding.id, + title: finding.text, + ...(finding.severity === undefined ? {} : { severity: finding.severity }), + ...(finding.confidence === undefined ? {} : { confidence: String(finding.confidence) }), + citationIds: finding.citations.map((citation) => String(citation.id)), + })), + citations, + footer: [ + { label: 'source complete', value: record.source.complete ? 'yes' : 'no' }, + { label: 'findings', value: String(record.findings.length) }, + ...(record.costUsd === undefined + ? [] + : [{ label: 'analysis cost', value: `$${record.costUsd.toFixed(4)}` }]), + ...(record.wallTimeMs === undefined + ? [] + : [{ label: 'analysis time', value: `${record.wallTimeMs}ms` }]), + ], + ...(record.error === undefined + ? record.status === 'failed' + ? { error: 'analysis source or analyst execution failed' } + : {} + : { error: record.error }), + } +} + +export function analysisLines(analysis: AnalysisView): readonly string[] { + const document = analysisDocument(analysis) + return [document.heading, ...document.context, ...document.details] +} + +export class AnalysisViewPanel extends Container implements Focusable { + readonly #theme: BraidTheme + #analysis: AnalysisView | undefined + #page = 0 + #focused = false + + constructor(theme: BraidTheme) { + super() + this.#theme = theme + } + + get focused(): boolean { + return this.#focused + } + + set focused(value: boolean) { + this.#focused = value + } + + setView(view: BraidViewModel): void { + this.setAnalysis(view.analysis) + } + + setRecord(record: AnalysisRecord): void { + this.setAnalysis(analysisViewForRecord(record)) + } + + setAnalysis(analysis: AnalysisView | undefined): void { + this.#analysis = analysis + this.#page = 0 + this.#renderPage() + } + + handleInput(data: string): void { + const analysis = this.#analysis + if (analysis === undefined) return + const pages = pageCount(analysisDocument(analysis).details.length) + const previous = this.#page + if (matchesKey(data, 'pageUp') || matchesKey(data, 'up')) this.#page -= 1 + else if (matchesKey(data, 'pageDown') || matchesKey(data, 'down')) this.#page += 1 + else if (matchesKey(data, 'home')) this.#page = 0 + else if (matchesKey(data, 'end')) this.#page = pages - 1 + this.#page = Math.max(0, Math.min(this.#page, pages - 1)) + if (this.#page !== previous) this.#renderPage() + } + + #renderPage(): void { + this.clear() + const analysis = this.#analysis + if (analysis === undefined) { + this.addChild(this.#line(this.#theme.brand('analysis · unavailable'))) + this.addChild(this.#line(this.#theme.warning('No frozen analysis result is available.'))) + this.addChild(this.#line(this.#theme.muted('esc close'))) + this.invalidate() + return + } + + const document = analysisDocument(analysis) + const pages = pageCount(document.details.length) + this.#page = Math.max(0, Math.min(this.#page, pages - 1)) + this.addChild(this.#line(this.#theme.brand(document.heading))) + for (const line of document.context) this.addChild(this.#line(this.#theme.muted(line))) + const start = this.#page * PAGE_ROWS + for (const line of document.details.slice(start, start + PAGE_ROWS)) { + this.addChild(this.#line(line.startsWith('! ') ? this.#theme.warning(line) : line)) + } + this.addChild( + this.#line( + this.#theme.muted( + pages > 1 ? `PgUp/PgDn · page ${this.#page + 1}/${pages} · esc close` : 'esc close', + ), + ), + ) + this.invalidate() + } + + #line(value: string): TruncatedText { + return new TruncatedText(value, 1, 0) + } +} + +function analysisDocument(analysis: AnalysisView): AnalysisDocument { + const mode = analysisMode(analysis) + const citations = new Map(analysis.citations.map((citation) => [citation.id, citation])) + const referenced = new Set() + const details: string[] = [] + + for (const finding of analysis.findings) { + const severity = finding.severity ? ` · ${sanitizeTerminalText(finding.severity)}` : '' + const confidence = finding.confidence + ? ` · confidence ${sanitizeTerminalText(finding.confidence)}` + : '' + const cited = finding.citationIds.length + ? `[${finding.citationIds.map(sanitizeTerminalText).join(',')}] ` + : '[no citation] ' + details.push(`• ${cited}${sanitizeTerminalText(finding.title)}${severity}${confidence}`) + for (const citationId of finding.citationIds) { + referenced.add(citationId) + const citation = citations.get(citationId) + details.push( + citation === undefined + ? `! evidence unavailable: ${sanitizeTerminalText(citationId)}` + : `↳ [${sanitizeTerminalText(citation.id)}] ${sanitizeTerminalText(citation.text)}`, + ) + } + } + if (analysis.findings.length === 0) details.push('No findings were returned.') + for (const citation of analysis.citations) { + if (referenced.has(citation.id)) continue + details.push( + `evidence [${sanitizeTerminalText(citation.id)}]: ${sanitizeTerminalText(citation.text)}`, + ) + } + if (analysis.footer.length > 0) { + const footer = analysis.footer.map( + (field) => `${sanitizeTerminalText(field.label)}: ${sanitizeTerminalText(field.value)}`, + ) + for (let index = 0; index < footer.length; index += 2) { + details.push(footer.slice(index, index + 2).join(' · ')) + } + } + if (analysis.error) details.push(`! ${sanitizeTerminalText(analysis.error)}`) + + return { + heading: `${mode.command} · ${mode.label}`, + context: [ + `source: ${shortDigest(analysis.source)} · frozen`, + `analyst: ${sanitizeTerminalText(analysis.analyst)} · ${sanitizeTerminalText(analysis.status)}`, + ], + details, + } +} + +function pageCount(detailCount: number): number { + return Math.max(1, Math.ceil(detailCount / PAGE_ROWS)) +} + +function analysisMode(analysis: AnalysisView): AnalysisMode { + const recipe = sanitizeTerminalText(analysis.recipe).trim() + const normalized = recipe.toLocaleLowerCase() + if (normalized === 'ask' || normalized === 'question') + return { command: '/ask', label: 'frozen question' } + if (normalized.includes('compare')) return { command: '/compare', label: 'paired sources' } + return { command: '/analyze', label: recipe || 'named recipe' } +} diff --git a/src/views/tui/autocomplete-guard.ts b/src/views/tui/autocomplete-guard.ts new file mode 100644 index 0000000..68f3dd9 --- /dev/null +++ b/src/views/tui/autocomplete-guard.ts @@ -0,0 +1,97 @@ +import type { + AutocompleteItem, + AutocompleteProvider, + AutocompleteSuggestions, +} from '@earendil-works/pi-tui' + +const COMPLETION_SNAPSHOT = Symbol('braid-completion-snapshot') + +interface CompletionSnapshot { + readonly generation: number + readonly text: string + readonly cursorLine: number + readonly cursorCol: number + readonly prefix: string +} + +type GuardedAutocompleteItem = AutocompleteItem & { + readonly [COMPLETION_SNAPSHOT]?: CompletionSnapshot +} + +/** + * Keeps Pi's autocomplete UI from applying an item computed for an older + * editor state while a newer request is still in flight. + * + * Behavioral reference: Pi's request-id/text/cursor validation in + * pi-mono@fa07e7b packages/tui/src/components/editor.ts. This wrapper adds + * apply-time validation around the published 0.83 provider contract. + */ +export class GuardedAutocompleteProvider implements AutocompleteProvider { + readonly #delegate: AutocompleteProvider + #generation = 0 + + constructor(delegate: AutocompleteProvider) { + this.#delegate = delegate + } + + get triggerCharacters(): string[] { + return this.#delegate.triggerCharacters ?? [] + } + + /** Invalidate all suggestions that were computed before this edit. */ + inputChanged(): void { + this.#generation += 1 + } + + async getSuggestions( + lines: string[], + cursorLine: number, + cursorCol: number, + options: { readonly signal: AbortSignal; readonly force?: boolean }, + ): Promise { + const generation = this.#generation + const text = lines.join('\n') + const suggestions = await this.#delegate.getSuggestions(lines, cursorLine, cursorCol, options) + if (!suggestions) return null + + const snapshot: CompletionSnapshot = { + generation, + text, + cursorLine, + cursorCol, + prefix: suggestions.prefix, + } + return { + prefix: suggestions.prefix, + items: suggestions.items.map((item) => + Object.assign({}, item, { [COMPLETION_SNAPSHOT]: snapshot }), + ), + } + } + + applyCompletion( + lines: string[], + cursorLine: number, + cursorCol: number, + item: AutocompleteItem, + prefix: string, + ): { lines: string[]; cursorLine: number; cursorCol: number } { + const snapshot = (item as GuardedAutocompleteItem)[COMPLETION_SNAPSHOT] + const currentText = lines.join('\n') + if ( + !snapshot || + snapshot.generation !== this.#generation || + snapshot.text !== currentText || + snapshot.cursorLine !== cursorLine || + snapshot.cursorCol !== cursorCol || + snapshot.prefix !== prefix + ) { + return { lines: [...lines], cursorLine, cursorCol } + } + return this.#delegate.applyCompletion(lines, cursorLine, cursorCol, item, prefix) + } + + shouldTriggerFileCompletion?(lines: string[], cursorLine: number, cursorCol: number): boolean { + return this.#delegate.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true + } +} diff --git a/src/views/tui/command-palette.ts b/src/views/tui/command-palette.ts index 9b8f63b..3b19b69 100644 --- a/src/views/tui/command-palette.ts +++ b/src/views/tui/command-palette.ts @@ -1,66 +1,24 @@ -import { - Container, - Input, - matchesKey, - SelectList, - Spacer, - Text, - type SelectItem, -} from '@earendil-works/pi-tui' +import { type CommandName, commandItems } from '../shared/command-registry.js' +import type { CapabilityMap } from '../shared/models.js' +import { SearchableSelector } from './selector.js' import type { BraidTheme } from './theme.js' -export type PaletteCommand = 'help' | 'quit' +export type PaletteCommand = CommandName -const COMMANDS: SelectItem[] = [ - { value: 'help', label: '/help', description: 'Keyboard and commands' }, - { value: 'quit', label: '/quit', description: 'Close Braid' }, -] +const ALL_CAPABILITIES: CapabilityMap = Object.freeze({}) -export class CommandPalette extends Container { - readonly #input = new Input() - readonly #list: SelectList - readonly #onCommand: (command: PaletteCommand) => void - #focused = false - - constructor(theme: BraidTheme, onCommand: (command: PaletteCommand) => void) { - super() - this.#onCommand = onCommand - this.#list = new SelectList(COMMANDS, 5, theme.select) - this.#list.onSelect = (item) => this.#onCommand(item.value as PaletteCommand) - this.#list.onCancel = () => this.#onCommand('help') - this.#input.onSubmit = () => { - const selected = this.#list.getSelectedItem() - if (selected) this.#onCommand(selected.value as PaletteCommand) - } - this.#input.onEscape = () => this.#onCommand('help') - this.addChild(new Text(theme.brand('Commands'), 1, 0)) - this.addChild(new Spacer(1)) - this.addChild(this.#input) - this.addChild(new Spacer(1)) - this.addChild(this.#list) - this.addChild(new Spacer(1)) - this.addChild(new Text(theme.muted('type to filter · esc to close'), 1, 0)) - } - - get focused(): boolean { - return this.#focused - } - - set focused(value: boolean) { - this.#focused = value - this.#input.focused = value - } - - handleInput(data: string): void { - if (matchesKey(data, 'up') || matchesKey(data, 'down') || matchesKey(data, 'enter')) { - this.#list.handleInput(data) - return - } - if (matchesKey(data, 'escape') || matchesKey(data, 'ctrl+c')) { - this.#onCommand('help') - return - } - this.#input.handleInput(data) - this.#list.setFilter(this.#input.getValue().replace(/^\//u, '')) +export class CommandPalette extends SearchableSelector { + constructor( + theme: BraidTheme, + onCommand: (command: PaletteCommand) => void, + capabilities: CapabilityMap = ALL_CAPABILITIES, + ) { + super({ + title: 'commands', + items: commandItems(capabilities), + theme, + onSelect: (item) => onCommand(item.value as PaletteCommand), + onCancel: () => onCommand('help'), + }) } } diff --git a/src/views/tui/comparison.ts b/src/views/tui/comparison.ts new file mode 100644 index 0000000..ea351bc --- /dev/null +++ b/src/views/tui/comparison.ts @@ -0,0 +1,240 @@ +import { Container, type Focusable, matchesKey, TruncatedText } from '@earendil-works/pi-tui' +import type { AnalysisComparisonResult } from '../../app/analysis-comparison-contracts.js' +import type { AnalysisComparisonField } from '../../domain/entities.js' +import type { ComparisonArmView, ComparisonView } from '../shared/models.js' +import { sanitizeTerminalText } from '../shared/sanitize.js' +import { shortDigest } from './configuration-presenters.js' +import type { BraidTheme } from './theme.js' + +const PAGE_ROWS = 8 +const MAX_VALUE_CHARS = 240 + +export function isAnalysisComparisonResult(value: unknown): value is AnalysisComparisonResult { + if (!isRecord(value)) return false + if ( + typeof value.baselineSourceDigest !== 'string' || + typeof value.candidateSourceDigest !== 'string' || + typeof value.baselineRunId !== 'string' || + typeof value.candidateRunId !== 'string' || + !Array.isArray(value.fields) || + !Array.isArray(value.rows) || + !isRecord(value.paired) || + !isRecord(value.semantic) + ) { + return false + } + return ( + Number.isInteger(value.paired.nPairs) && + Number.isInteger(value.paired.nUnpairedBaseline) && + Number.isInteger(value.paired.nUnpairedTreatment) && + value.semantic.status === 'unavailable' && + typeof value.semantic.reason === 'string' + ) +} + +export function comparisonViewForResult(result: AnalysisComparisonResult): ComparisonView { + const baseline = armView(result, 'baseline') + const candidate = armView(result, 'candidate') + return { + baseline, + candidate, + pairCount: result.paired.nPairs, + unpairedBaseline: result.paired.nUnpairedBaseline, + unpairedCandidate: result.paired.nUnpairedTreatment, + sampleLimit: sampleLimit(result.paired.nPairs), + fields: result.fields.map((field) => ({ + name: field.name, + baseline: capturedValue(field, 'baseline'), + candidate: capturedValue(field, 'candidate'), + asymmetry: field.asymmetry, + })), + pairedFacts: flattenFacts('paired', result.paired), + semantic: result.semantic, + replayed: result.replayed === true, + } +} + +export function comparisonLines(view: ComparisonView): readonly string[] { + return ['/compare · frozen runs', ...comparisonDetails(view)] +} + +export class ComparisonViewPanel extends Container implements Focusable { + readonly #theme: BraidTheme + #view: ComparisonView | undefined + #page = 0 + #focused = false + + constructor(theme: BraidTheme) { + super() + this.#theme = theme + } + + get focused(): boolean { + return this.#focused + } + + set focused(value: boolean) { + this.#focused = value + } + + setResult(result: AnalysisComparisonResult): void { + this.setView(comparisonViewForResult(result)) + } + + setView(view: ComparisonView): void { + this.#view = view + this.#page = 0 + this.#renderPage() + } + + handleInput(data: string): void { + const view = this.#view + if (view === undefined) return + const pages = pageCount(comparisonDetails(view).length) + const previous = this.#page + if (matchesKey(data, 'pageUp') || matchesKey(data, 'up')) this.#page -= 1 + else if (matchesKey(data, 'pageDown') || matchesKey(data, 'down')) this.#page += 1 + else if (matchesKey(data, 'home')) this.#page = 0 + else if (matchesKey(data, 'end')) this.#page = pages - 1 + this.#page = Math.max(0, Math.min(this.#page, pages - 1)) + if (this.#page !== previous) this.#renderPage() + } + + #renderPage(): void { + this.clear() + const view = this.#view + if (view === undefined) { + this.addChild(this.#line(this.#theme.brand('/compare · unavailable'))) + this.addChild(this.#line(this.#theme.warning('No frozen comparison is available.'))) + this.addChild(this.#line(this.#theme.muted('esc close'))) + this.invalidate() + return + } + + const details = comparisonDetails(view) + const pages = pageCount(details.length) + this.#page = Math.max(0, Math.min(this.#page, pages - 1)) + this.addChild(this.#line(this.#theme.brand('/compare · frozen runs'))) + const start = this.#page * PAGE_ROWS + for (const detail of details.slice(start, start + PAGE_ROWS)) { + const value = detail.startsWith('! ') + ? this.#theme.warning(detail) + : detail.startsWith('baseline ') || detail.startsWith('candidate ') + ? this.#theme.accent(detail) + : detail + this.addChild(this.#line(value)) + } + this.addChild( + this.#line( + this.#theme.muted( + pages > 1 ? `PgUp/PgDn · page ${this.#page + 1}/${pages} · esc close` : 'esc close', + ), + ), + ) + this.invalidate() + } + + #line(value: string): TruncatedText { + return new TruncatedText(sanitizeTerminalText(value), 1, 0) + } +} + +function comparisonDetails(view: ComparisonView): readonly string[] { + const details = [ + ...armLines(view.baseline), + ...armLines(view.candidate), + `sample: ${view.pairCount} paired · ${view.unpairedBaseline} baseline-only · ${view.unpairedCandidate} candidate-only`, + `! ${view.sampleLimit}`, + 'captured fields · baseline | candidate | availability', + ...view.fields.map( + (field) => `${field.name}: ${field.baseline} | ${field.candidate} | ${field.asymmetry}`, + ), + 'paired measurements · candidate minus baseline where applicable', + ...view.pairedFacts.map((fact) => `${fact.label}: ${fact.value}`), + `semantic review: ${view.semantic.status} · ${view.semantic.reason}`, + ] + if (view.replayed) details.splice(0, 0, 'saved result: replayed from the local journal') + return details.map((line) => sanitizeTerminalText(line)) +} + +function armLines(arm: ComparisonArmView): readonly string[] { + return [ + `${arm.label} run: ${shortDigest(arm.runId)}`, + `${arm.label} source: ${shortDigest(arm.sourceDigest)}`, + `${arm.label} outcome: ${arm.outcome} · cost: ${arm.cost} · source: ${arm.costProvenance}`, + ] +} + +function armView( + result: AnalysisComparisonResult, + arm: 'baseline' | 'candidate', +): ComparisonArmView { + const cost = result.fields.find((field) => field.name === 'run.cost_usd') + const outcome = result.fields.find((field) => field.name === 'run.status') + const costPresent = arm === 'baseline' ? cost?.baselinePresent : cost?.candidatePresent + return { + label: arm, + runId: arm === 'baseline' ? result.baselineRunId : result.candidateRunId, + sourceDigest: arm === 'baseline' ? result.baselineSourceDigest : result.candidateSourceDigest, + outcome: + outcome === undefined + ? 'missing (terminal outcome not captured)' + : capturedValue(outcome, arm), + cost: cost === undefined ? 'missing' : capturedValue(cost, arm), + costProvenance: + costPresent === true ? 'frozen run field run.cost_usd' : 'not captured in frozen run', + } +} + +function capturedValue(field: AnalysisComparisonField, arm: 'baseline' | 'candidate'): string { + const present = arm === 'baseline' ? field.baselinePresent : field.candidatePresent + if (!present) return 'missing' + const value = arm === 'baseline' ? field.baseline : field.candidate + return value === undefined ? 'missing (declared present without a value)' : displayValue(value) +} + +function flattenFacts(prefix: string, value: unknown): readonly { label: string; value: string }[] { + if (Array.isArray(value)) { + if (value.length === 0) return [{ label: prefix, value: '[]' }] + return value.flatMap((entry, index) => flattenFacts(`${prefix}[${index}]`, entry)) + } + if (isRecord(value)) { + const entries = Object.entries(value).sort(([left], [right]) => left.localeCompare(right)) + if (entries.length === 0) return [{ label: prefix, value: '{}' }] + return entries.flatMap(([key, entry]) => flattenFacts(`${prefix}.${key}`, entry)) + } + return [{ label: prefix, value: displayValue(value) }] +} + +function displayValue(value: unknown): string { + let rendered: string + if (typeof value === 'string') rendered = value + else if (value === undefined) rendered = 'missing' + else { + try { + rendered = JSON.stringify(value) ?? String(value) + } catch { + rendered = '[unserializable value]' + } + } + const safe = sanitizeTerminalText(rendered) + const points = Array.from(safe) + return points.length <= MAX_VALUE_CHARS + ? safe + : `${points.slice(0, MAX_VALUE_CHARS - 1).join('')}…` +} + +function sampleLimit(pairs: number): string { + if (pairs === 0) return 'No matched runs exist, so no paired conclusion is available.' + if (pairs === 1) + return 'One pair is descriptive; it cannot support a reliable general conclusion.' + return `${pairs} pairs describe only this measured sample; no semantic conclusion is shown.` +} + +function pageCount(detailCount: number): number { + return Math.max(1, Math.ceil(detailCount / PAGE_ROWS)) +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/src/views/tui/configuration-presenters.ts b/src/views/tui/configuration-presenters.ts new file mode 100644 index 0000000..2bab63c --- /dev/null +++ b/src/views/tui/configuration-presenters.ts @@ -0,0 +1,239 @@ +import type { SelectItem } from '@earendil-works/pi-tui' +import type { ConnectionSummary } from '../../app/connection-action-types.js' +import type { ProfileSummary } from '../../app/profiles.js' +import { isSensitiveFieldName, sanitizeTerminalText } from '../shared/sanitize.js' + +export function profileSummariesFrom(value: unknown): readonly ProfileSummary[] { + if (!isRecord(value) || !Array.isArray(value.profiles)) return [] + return value.profiles.filter(isProfileSummary) +} + +export function connectionSummariesFrom(value: unknown): readonly ConnectionSummary[] { + if (!isRecord(value) || !Array.isArray(value.connections)) return [] + return value.connections.filter(isConnectionSummary) +} + +export function profileItems( + profiles: readonly ProfileSummary[], + activeName: string | undefined, + activeId?: string, +): SelectItem[] { + const matchingNames = profiles.filter((profile) => profile.name === activeName) + return profiles.map((profile) => ({ + value: profile.id, + label: + activeId === profile.id || + (activeId === undefined && matchingNames.length === 1 && profile.name === activeName) + ? `✓ ${safe(profile.name)}` + : safe(profile.name), + description: [ + profile.source.trusted ? 'trusted' : 'untrusted source', + profile.source.writable ? 'writable' : 'read-only', + safe(profile.source.label), + profile.runner === undefined ? 'runner default' : safe(profile.runner), + profile.model === undefined ? 'model default' : safe(profile.model), + ].join(' · '), + })) +} + +export function connectionItems( + connections: readonly ConnectionSummary[], + activeName: string | undefined, + activeId?: string, +): SelectItem[] { + const matchingNames = connections.filter((connection) => connection.name === activeName) + return connections.map((connection) => ({ + value: connection.id, + label: + activeId === connection.id || + (activeId === undefined && matchingNames.length === 1 && connection.name === activeName) + ? `✓ ${safe(connection.name)}` + : safe(connection.name), + description: [ + connectionKind(connection.kind), + connection.health.status, + `model ${connection.modelVerification?.status ?? 'unverified'}`, + connection.credentialConfigured ? 'credential ready' : 'credential needed', + connection.ready ? 'ready' : 'not ready', + capabilitySummary(connection), + ] + .filter((value) => value.length > 0) + .join(' · '), + })) +} + +export function profileDetailLines(profile: ProfileSummary | undefined): string[] { + if (profile === undefined) return ['No profile details loaded.'] + return [ + `source ${safe(profile.source.label)} · ${profile.source.trusted ? 'trusted' : 'untrusted'} · ${profile.source.writable ? 'writable' : 'read-only'}`, + `runner ${safe(profile.runner ?? 'provider default')} · model ${safe(profile.model ?? 'provider default')}`, + `digest ${shortDigest(profile.digest)}`, + ] +} + +export function profileCompactDetail(profile: ProfileSummary | undefined): string { + if (profile === undefined) return 'No profile details loaded.' + return [ + `runner ${safe(profile.runner ?? 'provider default')}`, + `model ${safe(profile.model ?? 'provider default')}`, + profile.source.trusted ? 'trusted' : 'untrusted source', + profile.source.writable ? 'writable' : 'read-only', + `digest ${shortDigest(profile.digest)}`, + ].join(' · ') +} + +export function connectionDetailLines(connection: ConnectionSummary | undefined): string[] { + if (connection === undefined) return ['No connection details loaded.'] + const verification = connection.modelVerification + return [ + `kind ${connectionKind(connection.kind)} · ${connection.health.status}`, + `endpoint ${safeEndpoint(connection.endpoint)} · credential ${connection.credentialConfigured ? 'configured' : 'not configured'}`, + `model ${verification?.status ?? 'unverified'} · ${connection.ready ? 'ready' : 'not ready'}`, + `capabilities ${capabilitySummary(connection) || 'not reported'}`, + ] +} + +export function connectionCompactDetail(connection: ConnectionSummary | undefined): string { + if (connection === undefined) return 'No connection details loaded.' + return [ + connection.health.status, + `model ${connection.modelVerification?.status ?? 'unverified'}`, + connection.credentialConfigured ? 'credential configured' : 'credential not configured', + connection.ready ? 'ready' : 'not ready', + capabilitySummary(connection) || 'capabilities not reported', + ].join(' · ') +} + +export function capabilitySummary(connection: ConnectionSummary): string { + const actions = Object.entries(connection.capabilities?.actions ?? {}) + .filter(([, available]) => available) + .map(([name]) => name) + const hints = connection.capabilityHints + const values = actions.length > 0 ? actions : hints + return values.length > 0 ? values.map(safe).join(', ') : '' +} + +export function connectionKind(kind: ConnectionSummary['kind'] | string): string { + switch (kind) { + case 'cli-bridge': + return 'CLI Bridge' + case 'tangle-inference': + return 'Tangle inference' + case 'tangle-sandbox': + return 'Tangle sandbox' + default: + return safe(kind) + } +} + +export function shortDigest(value: string): string { + const safeValue = safe(value) + if (safeValue.length <= 24) return safeValue + return `${safeValue.slice(0, 15)}…${safeValue.slice(-8)}` +} + +export function safeEndpoint(value: string | undefined): string { + if (value === undefined || value.trim().length === 0) return 'provider default' + try { + const parsed = new URL(value) + return safe(`${parsed.origin}${parsed.pathname === '/' ? '' : parsed.pathname}`) + } catch { + return 'endpoint unavailable' + } +} + +export function safeFieldValue(label: string, value: string, secret: boolean): string { + if (secret || isSensitiveFieldName(label)) return '[secret value hidden]' + if (/endpoint|url/iu.test(label)) return safeEndpoint(value) + return safe(value) +} + +export function safe(value: string): string { + return sanitizeTerminalText(value) +} + +export function actionMessage( + result: { + readonly kind: string + readonly notice?: string + readonly reason?: string + readonly message?: string + }, + success: string, +): string { + if (result.kind === 'accepted') return safe(result.notice ?? success) + return safe( + result.kind === 'error' + ? (result.message ?? 'Action failed') + : (result.reason ?? 'Action unavailable'), + ) +} + +export const REFRESH_TIMEOUT = Symbol('refresh-timeout') + +export async function within( + promise: Promise, + timeoutMs: number, +): Promise { + let timer: ReturnType | undefined + const timeout = new Promise((resolve) => { + timer = setTimeout(() => resolve(REFRESH_TIMEOUT), timeoutMs) + }) + try { + return await Promise.race([promise, timeout]) + } finally { + if (timer !== undefined) clearTimeout(timer) + } +} + +function isProfileSummary(value: unknown): value is ProfileSummary { + if (!isRecord(value)) return false + if ( + typeof value.id !== 'string' || + typeof value.name !== 'string' || + typeof value.digest !== 'string' || + !Array.isArray(value.tags) || + !Array.isArray(value.tools) || + !Array.isArray(value.skills) || + !Array.isArray(value.connections) || + (value.description !== undefined && typeof value.description !== 'string') || + (value.runner !== undefined && typeof value.runner !== 'string') || + (value.model !== undefined && typeof value.model !== 'string') + ) + return false + const source = value.source + return ( + isRecord(source) && + typeof source.label === 'string' && + typeof source.writable === 'boolean' && + typeof source.trusted === 'boolean' + ) +} + +function isConnectionSummary(value: unknown): value is ConnectionSummary { + if (!isRecord(value)) return false + if ( + typeof value.id !== 'string' || + typeof value.name !== 'string' || + typeof value.kind !== 'string' || + typeof value.credentialConfigured !== 'boolean' || + typeof value.ready !== 'boolean' || + !isRecord(value.health) || + typeof value.health.status !== 'string' || + !Array.isArray(value.capabilityHints) || + !value.capabilityHints.every((hint) => typeof hint === 'string') || + (value.endpoint !== undefined && typeof value.endpoint !== 'string') + ) + return false + const verification = value.modelVerification + return ( + verification === undefined || + (isRecord(verification) && + typeof verification.model === 'string' && + typeof verification.status === 'string') + ) +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} diff --git a/src/views/tui/configuration-responsive-text.ts b/src/views/tui/configuration-responsive-text.ts new file mode 100644 index 0000000..be23463 --- /dev/null +++ b/src/views/tui/configuration-responsive-text.ts @@ -0,0 +1,33 @@ +import { type Component, Text, TruncatedText } from '@earendil-works/pi-tui' + +/** + * Keeps operational context on one row in a short terminal while preserving the full detail at normal widths. + */ +export class ResponsiveText implements Component { + readonly #wide: Text + readonly #paddingX: number + readonly #paddingY: number + #compact = '' + + constructor(text: string, paddingX = 1, paddingY = 0, compactText = text) { + this.#wide = new Text(text, paddingX, paddingY) + this.#paddingX = paddingX + this.#paddingY = paddingY + this.#compact = compactText + } + + invalidate(): void { + this.#wide.invalidate() + } + + setText(text: string, compactText = text): void { + this.#wide.setText(text) + this.#compact = compactText + this.invalidate() + } + + render(width: number): string[] { + if (width > 44) return this.#wide.render(width) + return new TruncatedText(this.#compact, this.#paddingX, this.#paddingY).render(width) + } +} diff --git a/src/views/tui/configuration-review.ts b/src/views/tui/configuration-review.ts new file mode 100644 index 0000000..252a7f2 --- /dev/null +++ b/src/views/tui/configuration-review.ts @@ -0,0 +1,80 @@ +import { + type Component, + Container, + type Focusable, + type SelectItem, + SelectList, + Text, + truncateToWidth, +} from '@earendil-works/pi-tui' +import { sanitizeTerminalText } from '../shared/sanitize.js' +import type { BraidTheme } from './theme.js' + +export interface ConfigurationReviewOptions { + readonly theme: BraidTheme + readonly summary: readonly string[] + readonly compactSummary: readonly string[] + readonly items: readonly SelectItem[] + readonly title?: string + readonly error?: string + readonly onSelect: (item: SelectItem) => void + readonly onCancel: () => void +} + +/** + * Compact review control for the final setup choice. + * It intentionally has no input field: the review actions are few and should remain visible on a short terminal. + */ +export class ConfigurationReview extends Container implements Focusable { + readonly #list: SelectList + #focused = false + + constructor(options: ConfigurationReviewOptions) { + super() + this.#list = new SelectList([...options.items], 4, options.theme.select) + this.#list.onSelect = options.onSelect + this.#list.onCancel = options.onCancel + this.addChild(new Text(options.theme.brand(options.title ?? 'review and start'), 1, 0)) + if (options.error !== undefined) { + this.addChild(new Text(options.theme.danger(sanitizeTerminalText(options.error)), 1, 0)) + } + this.addChild(new ReviewSummary(options.theme, options.summary, options.compactSummary)) + this.addChild(this.#list) + this.addChild(new Text(options.theme.muted('enter choose · ↑↓ move · esc cancel'), 1, 0)) + } + + get focused(): boolean { + return this.#focused + } + + set focused(value: boolean) { + this.#focused = value + } + + handleInput(data: string): void { + this.#list.handleInput(data) + } +} + +class ReviewSummary implements Component { + readonly #wide: Text + readonly #theme: BraidTheme + readonly #compact: readonly string[] + + constructor(theme: BraidTheme, wide: readonly string[], compact: readonly string[]) { + this.#theme = theme + this.#wide = new Text(theme.muted(wide.map(sanitizeTerminalText).join('\n')), 1, 0) + this.#compact = compact.map(sanitizeTerminalText) + } + + invalidate(): void { + this.#wide.invalidate() + } + + render(width: number): string[] { + if (width > 44) return this.#wide.render(width) + return this.#compact.map((line) => + this.#theme.muted(truncateToWidth(line, Math.max(1, width - 2), '…')), + ) + } +} diff --git a/src/views/tui/configuration-wizard-presentation.ts b/src/views/tui/configuration-wizard-presentation.ts new file mode 100644 index 0000000..8a22a81 --- /dev/null +++ b/src/views/tui/configuration-wizard-presentation.ts @@ -0,0 +1,196 @@ +import type { SelectItem } from '@earendil-works/pi-tui' +import type { + ConfigurationEffectiveValues, + ConfigurationSelection, + ConfigurationSession, + ConfigurationSessionState, +} from '../../app/configuration-session.js' +import { sanitizeTerminalText } from '../shared/sanitize.js' +import { shortDigest } from './configuration-presenters.js' + +export const BACK_TO_PROFILE = '__braid_back_profile__' +export const BACK_TO_CONNECTION = '__braid_back_connection__' +export const APPLY_SELECTION = '__braid_apply_selection__' +export const CANCEL_CONFIGURATION = '__braid_cancel_configuration__' +export const DOWN_ARROW = '\u001b[B' + +export function configurationTitle( + state: ConfigurationSessionState, + busy: boolean, + commitError?: string, +): string { + if (busy) return 'applying selection…' + switch (state.step) { + case 'profile': + return 'profile · choose an AgentProfile' + case 'connection': + return 'connection · choose a connection' + case 'confirm': + return 'review and start' + case 'complete': + return commitError === undefined ? 'selection applied' : 'review and start' + case 'cancelled': + return 'setup cancelled' + } +} + +export function configurationExplanation(state: ConfigurationSessionState): string { + if (state.step === 'profile') return 'Choose the AgentProfile for this run.' + if (state.step === 'connection') return 'Choose a connection; no secrets are entered here.' + return 'No changes were made.' +} + +export function configurationFooter(state: ConfigurationSessionState, busy: boolean): string { + if (busy) return 'waiting for the selected connection' + if (state.step === 'confirm') return 'enter apply · arrows · esc cancel' + return 'filter · enter choose · esc cancel' +} + +export function configurationItems( + state: ConfigurationSessionState, + busy: boolean, + commitError?: string, +): readonly SelectItem[] { + if (state.step === 'profile') { + if (state.profiles.length === 0) { + return [ + { value: CANCEL_CONFIGURATION, label: 'No profiles available', description: 'esc close' }, + ] + } + return state.profiles.map((profile) => ({ + value: profile.id, + label: profile.label, + description: profile.description, + })) + } + if (state.step === 'connection') { + const choices: SelectItem[] = state.connections.length + ? state.connections.map((connection) => ({ + value: connection.id, + label: connection.label, + description: connection.description, + })) + : [ + { + value: CANCEL_CONFIGURATION, + label: 'No connections available', + description: 'Add a connection through the product integration', + }, + ] + choices.push({ + value: BACK_TO_PROFILE, + label: '← change AgentProfile', + description: 'return to the previous step', + }) + return choices + } + if (state.step === 'confirm' || state.step === 'complete') { + if (state.step === 'complete' && !busy && commitError === undefined) { + return [{ value: CANCEL_CONFIGURATION, label: 'Close', description: 'esc close' }] + } + return [ + { + value: APPLY_SELECTION, + label: busy ? 'Applying…' : 'Apply and start', + description: 'use this profile and connection for Braid', + }, + { + value: BACK_TO_CONNECTION, + label: '← change connection', + description: 'choose a different execution location', + }, + { + value: BACK_TO_PROFILE, + label: '← change AgentProfile', + description: 'choose a different agent definition', + }, + { + value: CANCEL_CONFIGURATION, + label: 'Cancel', + description: 'leave the current selection unchanged', + }, + ] + } + return [{ value: CANCEL_CONFIGURATION, label: 'Close', description: 'esc close' }] +} + +export function reviewSummary( + session: ConfigurationSession, + state: ConfigurationSessionState, + confirmation?: (selection: ConfigurationSelection) => ConfigurationEffectiveValues, +): readonly string[] { + try { + const selection = session.previewSelection() + const profile = state.profiles.find((item) => item.id === selection.profile.id) + const connection = state.connections.find((item) => item.id === selection.connection.id) + const effective = effectiveValues(selection, confirmation) + return [ + `${sanitizeTerminalText(profile?.label ?? selection.profile.displayName)} → ${sanitizeTerminalText(connection?.label ?? selection.connection.name)}`, + `profile digest ${shortDigest(selection.profileDigest)} · connection ${selection.connection.kind}`, + `runner: ${sanitizeTerminalText(effective.runner)} · model: ${sanitizeTerminalText(effective.model)}`, + `effort: ${sanitizeTerminalText(effective.effort)} · workdir: ${sanitizeTerminalText(effective.workdir)}`, + `verification: ${sanitizeTerminalText(effective.verification)}`, + `unsupported: ${ + effective.unsupported.length > 0 + ? effective.unsupported.map(sanitizeTerminalText).join(', ') + : 'none' + }`, + `credentials ${selection.connection.credentialRef === undefined ? 'not configured' : 'configured outside Braid · value hidden'}`, + ] + } catch { + return ['Effective values are unavailable until both choices are selected.'] + } +} + +export function compactReviewSummary( + session: ConfigurationSession, + state: ConfigurationSessionState, + confirmation?: (selection: ConfigurationSelection) => ConfigurationEffectiveValues, +): readonly string[] { + try { + const selection = session.previewSelection() + const profile = state.profiles.find((item) => item.id === selection.profile.id) + const connection = state.connections.find((item) => item.id === selection.connection.id) + const effective = effectiveValues(selection, confirmation) + const unsupported = effective.unsupported.length > 0 ? effective.unsupported.join(', ') : 'none' + return [ + `profile ${profile?.label ?? selection.profile.displayName} → ${connection?.label ?? selection.connection.name}`, + `cred ${selection.connection.credentialRef === undefined ? 'not set' : 'hidden'} · conn ${selection.connection.kind} · digest ${compactDigest(selection.profileDigest)}`, + `runner: ${shortValue(effective.runner, 14)} · model: ${shortValue(effective.model, 16)}`, + `effort: ${shortValue(effective.effort, 12)} · cwd: ${shortValue(effective.workdir, 18)}`, + `verify: ${shortValue(effective.verification, 18)} · unsupported: ${shortValue(unsupported, 12)}`, + ].map(sanitizeTerminalText) + } catch { + return ['Effective values are unavailable until both choices are selected.'] + } +} + +function effectiveValues( + selection: ConfigurationSelection, + confirmation?: (selection: ConfigurationSelection) => ConfigurationEffectiveValues, +): ConfigurationEffectiveValues { + if (confirmation !== undefined) return confirmation(selection) + const profile = selection.profile.profile + return { + runner: profile.harness ?? 'provider default', + model: profile.model?.default ?? 'provider default', + effort: profile.model?.reasoningEffort ?? 'provider default', + workdir: + selection.connection.kind === 'tangle-sandbox' + ? 'provider-selected sandbox workdir' + : 'workspace-selected workdir', + verification: `${selection.connection.lastHealth.status}: unverified`, + unsupported: [], + } +} + +function compactDigest(value: string): string { + const digest = shortDigest(value).replace(/^sha256:/u, '') + return digest.length <= 10 ? digest : `${digest.slice(0, 5)}…${digest.slice(-3)}` +} + +function shortValue(value: string, limit: number): string { + const sanitized = sanitizeTerminalText(value) + if (sanitized.length <= limit) return sanitized + return `${sanitized.slice(0, Math.max(1, limit - 4))}…${sanitized.slice(-3)}` +} diff --git a/src/views/tui/configuration-wizard.ts b/src/views/tui/configuration-wizard.ts new file mode 100644 index 0000000..502cf38 --- /dev/null +++ b/src/views/tui/configuration-wizard.ts @@ -0,0 +1,238 @@ +import { Container, type Focusable, Text } from '@earendil-works/pi-tui' +import { + type ConfigurationEffectiveValues, + type ConfigurationSelection, + ConfigurationSession, + type ConfigurationSessionOptions, + type ConfigurationSessionState, +} from '../../app/configuration-session.js' +import { sanitizeTerminalText } from '../shared/sanitize.js' +import { ConfigurationReview } from './configuration-review.js' +import { + APPLY_SELECTION, + BACK_TO_CONNECTION, + BACK_TO_PROFILE, + CANCEL_CONFIGURATION, + compactReviewSummary, + configurationExplanation, + configurationFooter, + configurationItems, + configurationTitle, + DOWN_ARROW, + reviewSummary, +} from './configuration-wizard-presentation.js' +import { SearchableSelector } from './selector.js' +import type { BraidTheme } from './theme.js' + +type ConfigurationControl = SearchableSelector | ConfigurationReview + +export interface ConfigurationWizardOptions extends ConfigurationSessionOptions { + readonly theme: BraidTheme + readonly onCommit: (selection: ConfigurationSelection) => void | Promise + readonly onComplete: (selection: ConfigurationSelection) => void + readonly onCancel: () => void + readonly confirmation?: (selection: ConfigurationSelection) => ConfigurationEffectiveValues + readonly diagnostics?: readonly string[] + readonly requestRender?: () => void +} + +export type TerminalConfigurationOptions = ConfigurationSessionOptions & + Pick & { + readonly openOnStart?: boolean + readonly confirmation?: ConfigurationWizardOptions['confirmation'] + readonly diagnostics?: readonly string[] + } + +/** + * A two-step, keyboard-first configuration flow. + * It only receives opaque connection records and never asks for credential values. + */ +export class ConfigurationWizard extends Container implements Focusable { + readonly #theme: BraidTheme + readonly #session: ConfigurationSession + readonly #onCommit: ConfigurationWizardOptions['onCommit'] + readonly #onComplete: ConfigurationWizardOptions['onComplete'] + readonly #onCancel: ConfigurationWizardOptions['onCancel'] + readonly #confirmation: ConfigurationWizardOptions['confirmation'] + readonly #diagnostics: readonly string[] + readonly #requestRender: (() => void) | undefined + #selector: ConfigurationControl + #focused = false + #busy = false + #commitError: string | undefined + + constructor(options: ConfigurationWizardOptions) { + super() + this.#theme = options.theme + this.#session = new ConfigurationSession(options) + this.#onCommit = options.onCommit + this.#onComplete = options.onComplete + this.#onCancel = options.onCancel + this.#confirmation = options.confirmation + this.#diagnostics = Object.freeze([...(options.diagnostics ?? [])]) + this.#requestRender = options.requestRender + this.#selector = new SearchableSelector({ + title: 'configuration', + items: [], + theme: options.theme, + onSelect: () => {}, + onCancel: () => this.#cancel(), + }) + this.#renderStage(this.#session.state) + } + + get focused(): boolean { + return this.#focused + } + + set focused(value: boolean) { + this.#focused = value + this.#selector.focused = value + } + + handleInput(data: string): void { + this.#selector.handleInput(data) + } + + #renderStage(state: ConfigurationSessionState): void { + this.clear() + const applied = state.step === 'complete' && !this.#busy && this.#commitError === undefined + const heading = new Text(this.#theme.brand('braid setup'), 1, 0) + this.addChild(heading) + if (state.step !== 'confirm' && state.step !== 'complete') { + this.addChild(new Text(this.#theme.muted(configurationExplanation(state)), 1, 0)) + for (const diagnostic of this.#diagnostics) { + this.addChild( + new Text(this.#theme.warning(`notice · ${sanitizeTerminalText(diagnostic)}`), 1, 0), + ) + } + } + if (this.#commitError !== undefined && state.step !== 'confirm' && state.step !== 'complete') { + this.addChild(new Text(this.#theme.danger(sanitizeTerminalText(this.#commitError)), 1, 0)) + } + if (state.step === 'confirm' || state.step === 'complete') { + this.#selector = new ConfigurationReview({ + theme: this.#theme, + summary: reviewSummary(this.#session, state, this.#confirmation), + compactSummary: compactReviewSummary(this.#session, state, this.#confirmation), + title: applied + ? 'selection applied' + : configurationTitle(state, this.#busy, this.#commitError), + ...(this.#commitError === undefined ? {} : { error: this.#commitError }), + items: applied + ? [{ value: CANCEL_CONFIGURATION, label: 'Close', description: 'esc close' }] + : configurationItems(state, this.#busy, this.#commitError), + onSelect: (item) => this.#select(item.value), + onCancel: () => this.#cancel(), + }) + } else { + this.#selector = new SearchableSelector({ + title: configurationTitle(state, this.#busy, this.#commitError), + items: configurationItems(state, this.#busy, this.#commitError), + maxVisible: 4, + theme: this.#theme, + footer: configurationFooter(state, this.#busy), + onSelect: (item) => this.#select(item.value), + onCancel: () => this.#cancel(), + }) + } + this.#selector.focused = this.#focused + this.#restoreSelection(state) + this.addChild(this.#selector) + this.invalidate() + this.#requestRender?.() + } + + #restoreSelection(state: ConfigurationSessionState): void { + if (!(this.#selector instanceof SearchableSelector)) return + const selectedValue = + state.step === 'profile' + ? state.selectedProfileId + : state.step === 'connection' + ? state.selectedConnectionId + : undefined + if (selectedValue === undefined) return + const index = configurationItems(state, this.#busy, this.#commitError).findIndex( + (item) => item.value === selectedValue, + ) + for (let offset = 0; offset < index; offset += 1) this.#selector.handleInput(DOWN_ARROW) + } + + #select(value: string): void { + if (this.#busy) return + const state = this.#session.state + if (value === CANCEL_CONFIGURATION) { + if (state.profiles.length === 0 || state.connections.length === 0) { + this.#commitError = + state.profiles.length === 0 + ? 'No AgentProfiles are available. Press esc to leave setup.' + : 'No connections are available. Press esc to leave setup.' + this.#renderStage(state) + return + } + this.#cancel() + return + } + if (state.step === 'profile') { + const next = this.#session.selectProfile(value) + this.#commitError = next.error?.message + this.#renderStage(next) + return + } + if (state.step === 'connection') { + if (value === BACK_TO_PROFILE) { + this.#commitError = undefined + this.#renderStage(this.#session.backTo('profile')) + return + } + const next = this.#session.selectConnection(value) + this.#commitError = next.error?.message + this.#renderStage(next) + return + } + if (state.step !== 'confirm' && state.step !== 'complete') return + if (value === BACK_TO_CONNECTION) { + this.#commitError = undefined + this.#renderStage(this.#session.backTo('connection')) + return + } + if (value === BACK_TO_PROFILE) { + this.#commitError = undefined + this.#renderStage(this.#session.backTo('profile')) + return + } + if (value === APPLY_SELECTION) void this.#apply() + } + + async #apply(): Promise { + let selection: ConfigurationSelection + try { + selection = this.#session.state.selection ?? this.#session.confirm() + } catch (error) { + this.#commitError = + error instanceof Error ? error.message : 'Choose both values before applying' + this.#renderStage(this.#session.state) + return + } + this.#busy = true + this.#commitError = undefined + this.#renderStage(this.#session.state) + try { + await this.#onCommit(selection) + this.#busy = false + this.#renderStage(this.#session.state) + this.#onComplete(selection) + } catch (error) { + this.#busy = false + this.#commitError = + error instanceof Error ? error.message : 'The selection could not be applied' + this.#renderStage(this.#session.state) + } + } + + #cancel(): void { + if (this.#busy) return + this.#session.cancel() + this.#onCancel() + } +} diff --git a/src/views/tui/connection-setup-actions.ts b/src/views/tui/connection-setup-actions.ts new file mode 100644 index 0000000..229fb98 --- /dev/null +++ b/src/views/tui/connection-setup-actions.ts @@ -0,0 +1,59 @@ +import type { SelectItem } from '@earendil-works/pi-tui' +import type { ConnectionSummary } from '../../app/connection-action-types.js' +import type { BraidIntent } from '../shared/intents.js' +import { sanitizeTerminalText } from '../shared/sanitize.js' + +export function connectionItemName(item: SelectItem): string { + return item.label.replace(/^✓\s/u, '') +} + +export function selectConnectionIntent( + item: SelectItem, + operationId: string, + expectedRevision: number, +): BraidIntent { + return { + type: 'headless-command', + command: 'select_connection', + operationId, + params: { connectionId: item.value, expectedRevision }, + } +} + +export function testConnectionIntent(item: SelectItem, operationId: string): BraidIntent { + return { + type: 'headless-command', + command: 'test_connection', + operationId, + params: { connectionId: item.value }, + } +} + +export function connectionErrorMessage(error: unknown, fallback: string): string { + return sanitizeTerminalText(error instanceof Error ? error.message : fallback) +} + +export function readConnectionTest(value: unknown): ConnectionSummary | undefined { + if (!isRecord(value) || !('connection' in value)) return undefined + const connection = value.connection + if ( + !isRecord(connection) || + typeof connection.id !== 'string' || + typeof connection.name !== 'string' || + typeof connection.kind !== 'string' || + typeof connection.credentialConfigured !== 'boolean' || + typeof connection.ready !== 'boolean' || + !isHealth(connection.health) || + !Array.isArray(connection.capabilityHints) + ) + return undefined + return connection as unknown as ConnectionSummary +} + +function isHealth(value: unknown): value is { readonly status: string } { + return isRecord(value) && 'status' in value && typeof value.status === 'string' +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} diff --git a/src/views/tui/connection-setup-rendering.ts b/src/views/tui/connection-setup-rendering.ts new file mode 100644 index 0000000..bb491a8 --- /dev/null +++ b/src/views/tui/connection-setup-rendering.ts @@ -0,0 +1,47 @@ +import { type Component, Spacer, Text } from '@earendil-works/pi-tui' +import type { BraidViewModel } from '../shared/models.js' +import { sanitizeTerminalText } from '../shared/sanitize.js' +import { safeFieldValue } from './configuration-presenters.js' +import type { BraidTheme } from './theme.js' + +export function connectionSetupChildren( + theme: BraidTheme, + view: BraidViewModel, +): readonly Component[] { + const children: Component[] = [new Text(theme.brand('connection'), 1, 0), new Spacer(1)] + const connection = view.connectionSetup + if (connection === undefined) { + children.push(new Text(theme.muted('No connection details loaded.'), 1, 0)) + children.push( + new Text(theme.warning('Use /connection to list, test, or select connections.'), 1, 0), + ) + return children + } + + children.push( + new Text( + theme.muted( + `kind ${sanitizeTerminalText(connection.kind)} · health ${sanitizeTerminalText(connection.health)}`, + ), + 1, + 0, + ), + ) + for (const field of connection.fields) { + const value = safeFieldValue(field.label, field.value, field.secret) + children.push(new Text(`${sanitizeTerminalText(field.label)}: ${value}`, 1, 0)) + } + if (connection.capabilities.length > 0) { + children.push( + new Text( + `capabilities: ${connection.capabilities.map(sanitizeTerminalText).join(', ')}`, + 1, + 0, + ), + ) + } + if (connection.error !== undefined) { + children.push(new Text(theme.danger(sanitizeTerminalText(connection.error)), 1, 0)) + } + return children +} diff --git a/src/views/tui/connection-setup.ts b/src/views/tui/connection-setup.ts new file mode 100644 index 0000000..80332bc --- /dev/null +++ b/src/views/tui/connection-setup.ts @@ -0,0 +1,242 @@ +import { Container, type Focusable, type SelectItem } from '@earendil-works/pi-tui' +import type { ConnectionSummary } from '../../app/connection-action-types.js' +import type { BraidIntent, BraidUiController, UiDispatchResult } from '../shared/intents.js' +import type { BraidViewModel } from '../shared/models.js' +import { sanitizeTerminalText } from '../shared/sanitize.js' +import { + actionMessage, + connectionCompactDetail, + connectionDetailLines, + connectionItems, + connectionSummariesFrom, + REFRESH_TIMEOUT, + safe, + within, +} from './configuration-presenters.js' +import { ResponsiveText } from './configuration-responsive-text.js' +import { + connectionErrorMessage, + connectionItemName, + readConnectionTest, + selectConnectionIntent, + testConnectionIntent, +} from './connection-setup-actions.js' +import { connectionSetupChildren } from './connection-setup-rendering.js' +import { SearchableSelector } from './selector.js' +import type { BraidTheme } from './theme.js' + +interface ConnectionSetupOptions { + readonly controller?: BraidUiController + readonly nextOperationId?: () => string + readonly query?: string + readonly onCancel?: () => void +} + +const CONNECTION_REFRESH_TIMEOUT_MS = 2_000 + +export class ConnectionSetupViewPanel extends Container implements Focusable { + readonly #theme: BraidTheme + readonly #controller: BraidUiController | undefined + readonly #nextOperationId: (() => string) | undefined + readonly #onCancel: (() => void) | undefined + readonly #status = new ResponsiveText('', 1, 0) + readonly #detail = new ResponsiveText('', 1, 0) + #selector: SearchableSelector | undefined + #connections: readonly ConnectionSummary[] = [] + #focused = false + #busy = false + #refreshGeneration = 0 + #activeConnectionId: string | undefined + + constructor(theme: BraidTheme, options: ConnectionSetupOptions = {}) { + super() + this.#theme = theme + this.#controller = options.controller + this.#nextOperationId = options.nextOperationId + this.#onCancel = options.onCancel + if (this.#controller !== undefined) { + this.#buildSelector(options.query ?? '') + void this.#refresh().catch((error: unknown) => { + this.#setStatus( + this.#connections.length > 0 + ? `Refresh unavailable · showing the last connection list · ${connectionErrorMessage(error, 'refresh failed')}` + : `Connections unavailable · refresh failed · ${connectionErrorMessage(error, 'try again')}`, + ) + }) + } + } + + get focused(): boolean { + return this.#focused + } + + set focused(value: boolean) { + this.#focused = value + if (this.#selector) this.#selector.focused = value + } + + handleInput(data: string): void { + this.#selector?.handleInput(data) + } + + setView(view: BraidViewModel): void { + if (this.#controller !== undefined) return + this.clear() + for (const child of connectionSetupChildren(this.#theme, view)) this.addChild(child) + this.invalidate() + } + + #buildSelector(query: string): void { + if (this.#controller === undefined) return + this.#selector = new SearchableSelector({ + title: 'connections', + items: [], + query, + maxVisible: 5, + footer: 'enter select · ^T test · esc close', + theme: this.#theme, + onSelect: (item) => void this.#select(item), + onAction: (key, item) => { + if (item !== null && key === 'test' && !this.#busy) void this.#test(item) + }, + onCancel: () => this.#onCancel?.(), + }) + this.#selector.focused = this.#focused + this.#renderController('Loading connections…') + } + + async #refresh(notice?: string, preferredDetail?: ConnectionSummary): Promise { + const generation = ++this.#refreshGeneration + const result = await within( + this.#dispatch({ type: 'headless-command', command: 'list_connections', params: {} }), + CONNECTION_REFRESH_TIMEOUT_MS, + ) + if (generation !== this.#refreshGeneration) return + if (result === REFRESH_TIMEOUT) { + this.#setStatus( + this.#connections.length > 0 + ? 'Refresh unavailable · showing the last connection list' + : 'Connections unavailable · refresh timed out', + ) + return + } + if (result.kind !== 'accepted') { + this.#setStatus( + this.#connections.length > 0 + ? `Refresh unavailable · showing the last connection list · ${actionMessage(result, '')}` + : actionMessage(result, 'Connections unavailable'), + ) + return + } + this.#connections = [...connectionSummariesFrom(result.data)] + const activeName = this.#controller?.view().connection + const tracked = this.#connections.find( + (connection) => connection.id === this.#activeConnectionId, + ) + const active = + tracked?.name === activeName + ? tracked + : this.#connections.filter((connection) => connection.name === activeName).length === 1 + ? this.#connections.find((connection) => connection.name === activeName) + : undefined + this.#activeConnectionId = active?.id + this.#selector?.setItems( + connectionItems(this.#connections, activeName, this.#activeConnectionId), + ) + const detail = preferredDetail ?? active + this.#setDetail(connectionDetailLines(detail).join('\n'), connectionCompactDetail(detail)) + this.#setStatus( + notice ?? + (this.#connections.length === 0 + ? 'No connections found' + : active === undefined + ? 'Connections ready · choose one' + : `Active connection · ${safe(active.name)}`), + ) + } + + async #select(item: SelectItem): Promise { + if (this.#busy) return + this.#busy = true + const name = connectionItemName(item) + this.#setStatus(`Selecting ${safe(name)}…`) + try { + const result = await this.#dispatch( + selectConnectionIntent(item, this.#operationId(), this.#controller?.view().revision ?? 0), + ) + if (result.kind === 'accepted') { + this.#activeConnectionId = item.value + await this.#refresh(`Selected ${safe(name)} · next runs use it`) + } else { + this.#setStatus(actionMessage(result, 'Connection selection failed')) + } + } catch (error) { + this.#setStatus(connectionErrorMessage(error, 'Connection selection failed')) + } finally { + this.#busy = false + } + } + + async #test(item: SelectItem): Promise { + if (this.#busy) return + this.#busy = true + const name = connectionItemName(item) + this.#setStatus(`Testing ${safe(name)}…`) + try { + const result = await this.#dispatch(testConnectionIntent(item, this.#operationId())) + if (result.kind !== 'accepted') { + this.#setStatus(actionMessage(result, 'Connection test failed')) + return + } + const tested = readConnectionTest(result.data) + const status = tested?.health.status ?? 'unknown' + const model = tested?.modelVerification?.status ?? 'unverified' + await this.#refresh( + `Tested ${safe(name)} · health ${safe(status)} · model ${safe(model)}`, + tested, + ) + } catch (error) { + this.#setStatus(connectionErrorMessage(error, 'Connection test failed')) + } finally { + this.#busy = false + } + } + + #dispatch(intent: BraidIntent): Promise { + if (this.#controller === undefined) { + return Promise.resolve({ + kind: 'error', + code: 'UI_NOT_CONNECTED', + message: 'Connection actions require an application controller', + retryable: false, + }) + } + return this.#controller.dispatch(intent) + } + + #operationId(): string { + return this.#nextOperationId?.() ?? `connection-ui-${Date.now()}` + } + + #renderController(status?: string): void { + if (status !== undefined) this.#status.setText(sanitizeTerminalText(status)) + this.clear() + this.addChild(this.#status) + this.addChild(this.#detail) + if (this.#selector) this.addChild(this.#selector) + this.invalidate() + } + + #setDetail(detail: string, compactDetail = detail): void { + this.#detail.setText( + this.#theme.muted(sanitizeTerminalText(detail)), + this.#theme.muted(sanitizeTerminalText(compactDetail)), + ) + this.#renderController() + } + + #setStatus(status: string): void { + this.#status.setText(sanitizeTerminalText(status)) + this.#renderController() + } +} diff --git a/src/views/tui/conversation-dialogs.ts b/src/views/tui/conversation-dialogs.ts new file mode 100644 index 0000000..ed25ee7 --- /dev/null +++ b/src/views/tui/conversation-dialogs.ts @@ -0,0 +1,155 @@ +import { + Container, + type Focusable, + Input, + matchesKey, + Spacer, + Text, + TruncatedText, +} from '@earendil-works/pi-tui' +import { sanitizeTerminalText } from '../shared/sanitize.js' +import type { BraidTheme } from './theme.js' + +export interface ConversationConfirmationOptions { + readonly theme: BraidTheme + readonly title: string + readonly target: string + readonly detail: string + readonly confirmLabel?: string + readonly onConfirm: () => void + readonly onCancel: () => void +} + +export class ConversationConfirmation extends Container implements Focusable { + readonly #theme: BraidTheme + readonly #title: TruncatedText + readonly #target: TruncatedText + readonly #detail: TruncatedText + readonly #error: Text + readonly #onConfirm: () => void + readonly #onCancel: () => void + #focused = false + #submitted = false + + constructor(options: ConversationConfirmationOptions) { + super() + this.#theme = options.theme + this.#title = new TruncatedText(this.#theme.brand(sanitizeTerminalText(options.title)), 1, 0) + this.#target = new TruncatedText(`target: ${sanitizeTerminalText(options.target)}`, 1, 0) + this.#detail = new TruncatedText( + this.#theme.muted( + `will ${sanitizeTerminalText(options.confirmLabel ?? 'confirm')}: ${sanitizeTerminalText(options.detail)}`, + ), + 1, + 0, + ) + this.#error = new Text('', 1, 0) + this.#onConfirm = options.onConfirm + this.#onCancel = options.onCancel + this.addChild(this.#title) + this.addChild(new Spacer(1)) + this.addChild(this.#target) + this.addChild(this.#detail) + this.addChild(this.#error) + this.addChild(new Spacer(1)) + this.addChild(new TruncatedText(this.#theme.muted('enter/y confirm · n/esc cancel'), 1, 0)) + } + + get focused(): boolean { + return this.#focused + } + + set focused(value: boolean) { + this.#focused = value + } + + setError(message: string): void { + this.#submitted = false + this.#error.setText(this.#theme.danger(sanitizeTerminalText(message))) + this.invalidate() + } + + handleInput(data: string): void { + if (this.#submitted) return + if (matchesKey(data, 'escape') || matchesKey(data, 'ctrl+c') || matchesKey(data, 'n')) { + this.#onCancel() + return + } + if (matchesKey(data, 'enter') || matchesKey(data, 'y')) { + this.#submitted = true + this.#error.setText(this.#theme.muted('working…')) + this.invalidate() + this.#onConfirm() + } + } +} + +export interface ConversationRenameOptions { + readonly theme: BraidTheme + readonly currentTitle: string + readonly onSubmit: (title: string) => void + readonly onCancel: () => void +} + +export class ConversationRename extends Container implements Focusable { + readonly #theme: BraidTheme + readonly #input = new Input() + readonly #error: Text + readonly #onSubmit: (title: string) => void + readonly #onCancel: () => void + #focused = false + #submitted = false + + constructor(options: ConversationRenameOptions) { + super() + this.#theme = options.theme + this.#onSubmit = options.onSubmit + this.#onCancel = options.onCancel + this.#input.setValue('') + this.#error = new Text('', 1, 0) + this.addChild(new Text(this.#theme.brand('rename conversation'), 1, 0)) + this.addChild(new Spacer(1)) + this.addChild(new Text(`current: ${sanitizeTerminalText(options.currentTitle)}`, 1, 0)) + this.addChild(new Text(this.#theme.muted('title'), 1, 0)) + this.addChild(this.#input) + this.addChild(this.#error) + this.addChild(new Spacer(1)) + this.addChild(new Text(this.#theme.muted('enter save · esc cancel'), 1, 0)) + } + + get focused(): boolean { + return this.#focused + } + + set focused(value: boolean) { + this.#focused = value + this.#input.focused = value + } + + setError(message: string): void { + this.#submitted = false + this.#error.setText(this.#theme.danger(sanitizeTerminalText(message))) + this.invalidate() + } + + handleInput(data: string): void { + if (matchesKey(data, 'escape') || matchesKey(data, 'ctrl+c')) { + this.#onCancel() + return + } + if (this.#submitted) return + if (matchesKey(data, 'enter')) { + const title = this.#input.getValue().trim() + if (!title) { + this.setError('A conversation title is required') + return + } + this.#submitted = true + this.#error.setText(this.#theme.muted('saving…')) + this.invalidate() + this.#onSubmit(title) + return + } + this.#input.handleInput(data) + } +} diff --git a/src/views/tui/conversation-overlay-actions.ts b/src/views/tui/conversation-overlay-actions.ts new file mode 100644 index 0000000..8ad3bdc --- /dev/null +++ b/src/views/tui/conversation-overlay-actions.ts @@ -0,0 +1,263 @@ +import type { HeadlessCommandName } from '../shared/headless-commands.js' +import type { BraidUiController, UiDispatchResult } from '../shared/intents.js' +import type { BraidViewModel } from '../shared/models.js' +import { sanitizeTerminalText } from '../shared/sanitize.js' +import { ConversationConfirmation, ConversationRename } from './conversation-dialogs.js' +import { + type ConversationItem, + findConversation, + forkExecutionIdentity, + resultMessage, + unique, +} from './conversation-overlay-helpers.js' +import { ForkPreviewPanel } from './fork-preview.js' +import type { ModalCoordinator } from './modal-coordinator.js' +import type { SearchableSelector } from './selector.js' +import { UnavailablePanel } from './terminal-shell.js' +import type { BraidTheme } from './theme.js' + +export interface ConversationOverlayActionOptions { + readonly theme: BraidTheme + readonly controller: BraidUiController + readonly modals: ModalCoordinator + readonly nextOperationId: () => string +} + +export class ConversationOverlayActions { + readonly #theme: BraidTheme + readonly #controller: BraidUiController + readonly #modals: ModalCoordinator + readonly #nextOperationId: () => string + + constructor(options: ConversationOverlayActionOptions) { + this.#theme = options.theme + this.#controller = options.controller + this.#modals = options.modals + this.#nextOperationId = options.nextOperationId + } + + conversationAction( + selector: SearchableSelector, + action: string, + conversationId: string | undefined, + ): void { + if (conversationId === undefined) { + selector.setFooter('select a conversation first · esc close') + return + } + const conversation = this.#conversation(conversationId) + if (conversation === undefined) { + selector.setFooter('selected conversation disappeared · refresh with esc and ctrl+o') + return + } + if (action === 'rename') this.#openRename(selector, conversation) + else if (action === 'archive') this.#openArchive(selector, conversation) + else if (action === 'delete') this.#openDelete(selector, conversation) + } + + async openConversation(selector: SearchableSelector, conversationId: string): Promise { + const conversation = this.#conversation(conversationId) + if (conversation === undefined) { + selector.setFooter('conversation is no longer available · esc close') + return + } + selector.setFooter('opening conversation…') + const result = await this.#headless('open_conversation', { + conversationId: conversation.id, + branchId: conversation.branchId, + }) + if (result.kind === 'accepted') this.#modals.closeTop() + else { + selector.setFooter('open failed · query and selection preserved · esc close') + this.#showResultError('Open failed', result, false) + } + } + + async openBranch(selector: SearchableSelector | undefined, branchId: string): Promise { + const result = await this.#headless('open_conversation', { + conversationId: this.#controller.view().conversationId, + branchId, + }) + if (result.kind === 'accepted') { + if (selector !== undefined) this.#modals.closeTop() + } else this.#showResultError('Branch switch failed', result, selector === undefined) + } + + openForkPreview(view: BraidViewModel): void { + let panel: ForkPreviewPanel + panel = new ForkPreviewPanel(this.#theme, { + onConfirm: () => void this.#executeFork(panel, view), + onCancel: () => this.#modals.closeTop(), + }) + panel.setView(view) + this.#modals.open(panel, { anchor: 'center', width: '92%', maxHeight: '90%' }) + } + + get theme(): BraidTheme { + return this.#theme + } + + #openRename(selector: SearchableSelector, conversation: ConversationItem): void { + let dialog: ConversationRename + dialog = new ConversationRename({ + theme: this.#theme, + currentTitle: conversation.title, + onSubmit: (title) => void this.#rename(selector, dialog, conversation, title), + onCancel: () => this.#modals.closeTop(), + }) + this.#modals.open( + dialog, + { anchor: 'center', width: '80%', minWidth: 32, maxHeight: 12 }, + false, + ) + } + + async #rename( + selector: SearchableSelector, + dialog: ConversationRename, + conversation: ConversationItem, + title: string, + ): Promise { + const result = await this.#headless('rename_conversation', { + conversationId: conversation.id, + title, + }) + if (result.kind === 'accepted') { + this.#closeConversationFlow(selector) + return + } + dialog.setError(resultMessage(result, 'Rename failed')) + } + + #openArchive(selector: SearchableSelector, conversation: ConversationItem): void { + const archived = !conversation.archived + let dialog: ConversationConfirmation + dialog = new ConversationConfirmation({ + theme: this.#theme, + title: archived ? 'archive conversation' : 'restore conversation', + target: conversation.title, + detail: archived + ? 'Runs remain untouched. The conversation will stay available through the archived filter.' + : 'The conversation will return to the active list.', + confirmLabel: archived ? 'archive' : 'restore', + onConfirm: () => void this.#archive(selector, dialog, conversation, archived), + onCancel: () => this.#modals.closeTop(), + }) + this.#modals.open( + dialog, + { anchor: 'center', width: '84%', minWidth: 32, maxHeight: 12 }, + false, + ) + } + + async #archive( + selector: SearchableSelector, + dialog: ConversationConfirmation, + conversation: ConversationItem, + archived: boolean, + ): Promise { + const result = await this.#headless('archive_conversation', { + conversationId: conversation.id, + archived, + }) + if (result.kind === 'accepted') { + this.#closeConversationFlow(selector) + return + } + dialog.setError(resultMessage(result, archived ? 'Archive failed' : 'Restore failed')) + } + + #openDelete(selector: SearchableSelector, conversation: ConversationItem): void { + const environments = unique( + this.#controller + .view() + .runs.map((run) => run.environmentId) + .filter((value): value is string => value !== undefined), + ) + const detail = + environments.length === 0 + ? 'This removes the local conversation history. No external environment reference is visible here.' + : `External environment references remain: ${environments.join(', ')}` + let dialog: ConversationConfirmation + dialog = new ConversationConfirmation({ + theme: this.#theme, + title: 'delete conversation', + target: conversation.title, + detail, + confirmLabel: 'delete permanently', + onConfirm: () => void this.#delete(selector, dialog, conversation), + onCancel: () => this.#modals.closeTop(), + }) + this.#modals.open( + dialog, + { anchor: 'center', width: '88%', minWidth: 32, maxHeight: 12 }, + false, + ) + } + + async #delete( + selector: SearchableSelector, + dialog: ConversationConfirmation, + conversation: ConversationItem, + ): Promise { + const result = await this.#headless('delete_conversation', { + conversationId: conversation.id, + }) + if (result.kind === 'accepted') { + this.#closeConversationFlow(selector) + return + } + dialog.setError(resultMessage(result, 'Delete failed')) + } + + async #executeFork(panel: ForkPreviewPanel, view: BraidViewModel): Promise { + const preview = view.forkPreview + const execution = forkExecutionIdentity(preview) + if (execution === undefined) { + panel.setError('The fork plan is not executable; create a fresh preview') + return + } + const result = await this.#controller.dispatch({ + type: 'headless-command', + command: 'execute_fork', + operationId: execution.operationId, + params: { planDigest: execution.planDigest }, + }) + if (result.kind === 'accepted') this.#modals.closeTop() + else panel.setError(resultMessage(result, 'Fork failed; review the plan and retry')) + } + + #closeConversationFlow(selector: SearchableSelector): void { + this.#modals.closeTop() + if (this.#modals.hasOpen()) this.#modals.closeTop() + selector.setFooter('conversation list updated') + } + + #conversation(conversationId: string): ConversationItem | undefined { + return findConversation(this.#controller.view(), conversationId) + } + + #headless( + command: HeadlessCommandName, + params: Readonly>, + ): Promise { + return this.#controller.dispatch({ + type: 'headless-command', + command, + operationId: this.#nextOperationId(), + params, + }) + } + + #showResultError(title: string, result: UiDispatchResult, preempt: boolean): void { + this.showUnavailable(title, resultMessage(result, 'The operation failed'), preempt) + } + + showUnavailable(title: string, reason: string, preempt = true): void { + this.#modals.open( + new UnavailablePanel(this.#theme, sanitizeTerminalText(title), sanitizeTerminalText(reason)), + { anchor: 'center', width: '82%', maxHeight: 8 }, + preempt, + ) + } +} diff --git a/src/views/tui/conversation-overlay-helpers.ts b/src/views/tui/conversation-overlay-helpers.ts new file mode 100644 index 0000000..4800b1b --- /dev/null +++ b/src/views/tui/conversation-overlay-helpers.ts @@ -0,0 +1,107 @@ +import type { BraidViewModel } from '../shared/models.js' +import { sanitizeTerminalText } from '../shared/sanitize.js' + +export interface ConversationItem { + readonly id: string + readonly title: string + readonly branchId: string + readonly archived: boolean +} + +export interface OverlaySelectItem { + readonly value: string + readonly label: string + readonly description: string +} + +export function conversationItems(view: BraidViewModel): readonly OverlaySelectItem[] { + return [...view.conversations] + .sort((left, right) => { + if (left.active !== right.active) return left.active ? -1 : 1 + return right.updatedAt.localeCompare(left.updatedAt) + }) + .map((conversation) => ({ + value: conversation.id, + label: conversation.title, + description: `${conversation.archived ? 'archived' : 'active'} ${conversationDate(conversation.updatedAt)} branch ws:${displayWorkspace(view.workspace)} ${conversation.branchId} ${view.workspace ?? ''}`, + })) +} + +export function branchItems(view: BraidViewModel): readonly OverlaySelectItem[] { + const seen = new Set() + const items = view.graph + .filter((node) => node.type === 'branch') + .filter((node) => { + if (seen.has(node.id)) return false + seen.add(node.id) + return true + }) + .map((node) => ({ + value: node.id, + label: node.title, + description: `${node.id === view.branch ? 'current · ' : ''}${node.status} · ${node.id}`, + })) + if (!seen.has(view.branch)) + items.unshift({ + value: view.branch, + label: 'current branch', + description: `current · ${view.branch}`, + }) + return items +} + +export function findConversation( + view: BraidViewModel, + conversationId: string, +): ConversationItem | undefined { + const conversation = view.conversations.find((candidate) => candidate.id === conversationId) + return conversation === undefined + ? undefined + : { + id: conversation.id, + title: conversation.title, + branchId: conversation.branchId, + archived: conversation.archived, + } +} + +export function fieldValue( + preview: BraidViewModel['forkPreview'], + label: string, +): string | undefined { + return preview?.fields.find((field) => field.label === label)?.source +} + +export function forkExecutionIdentity( + preview: BraidViewModel['forkPreview'], +): { readonly operationId: string; readonly planDigest: string } | undefined { + if (!preview?.allowed) return undefined + const operationId = fieldValue(preview, 'operation id') + const planDigest = fieldValue(preview, 'plan digest') + return operationId === undefined || planDigest === undefined + ? undefined + : { operationId, planDigest } +} + +export function resultMessage( + result: import('../shared/intents.js').UiDispatchResult, + fallback: string, +): string { + if (result.kind === 'accepted') return result.notice ?? fallback + return result.kind === 'unavailable' ? result.reason : result.message +} + +export function unique(values: readonly string[]): string[] { + return [...new Set(values)] +} + +function conversationDate(value: string): string { + const timestamp = Date.parse(value) + return Number.isNaN(timestamp) + ? sanitizeTerminalText(value) + : new Date(timestamp).toISOString().slice(0, 10) +} + +function displayWorkspace(value: string | null): string { + return (value ?? 'unavailable').replace(/^\//u, '') +} diff --git a/src/views/tui/conversation-overlays.ts b/src/views/tui/conversation-overlays.ts new file mode 100644 index 0000000..2d375c5 --- /dev/null +++ b/src/views/tui/conversation-overlays.ts @@ -0,0 +1,79 @@ +import type { BraidUiController } from '../shared/intents.js' +import { ConversationOverlayActions } from './conversation-overlay-actions.js' +import { branchItems, conversationItems } from './conversation-overlay-helpers.js' +import type { ModalCoordinator } from './modal-coordinator.js' +import { SearchableSelector } from './selector.js' +import type { BraidTheme } from './theme.js' + +export interface ConversationOverlayOptions { + readonly theme: BraidTheme + readonly controller: BraidUiController + readonly modals: ModalCoordinator + readonly nextOperationId: () => string +} + +export class ConversationOverlayController { + readonly #controller: BraidUiController + readonly #modals: ModalCoordinator + readonly #actions: ConversationOverlayActions + + constructor(options: ConversationOverlayOptions) { + this.#controller = options.controller + this.#modals = options.modals + this.#actions = new ConversationOverlayActions(options) + } + + openConversationSelector(query = ''): void { + const view = this.#controller.view() + let selector: SearchableSelector + selector = new SearchableSelector({ + title: 'conversations', + query, + items: conversationItems(view), + theme: this.#actions.theme, + maxVisible: 8, + footer: + 'type to filter · enter to choose · esc to close · ^R rename · ^A archive · ^D delete', + onSelect: (item) => void this.#actions.openConversation(selector, item.value), + onAction: (key, item) => this.#actions.conversationAction(selector, key, item?.value), + onCancel: () => this.#modals.closeTop(), + }) + this.#modals.open(selector, { anchor: 'center', width: '92%', minWidth: 32, maxHeight: '90%' }) + } + + openBranchSelector(): void { + const view = this.#controller.view() + const items = branchItems(view) + if (items.length === 0) { + this.#actions.showUnavailable('branches', 'No branches are available in this conversation') + return + } + let selector: SearchableSelector + selector = new SearchableSelector({ + title: 'branches', + items, + theme: this.#actions.theme, + maxVisible: 8, + footer: 'filter · enter switch branch · esc close', + onSelect: (item) => void this.#actions.openBranch(selector, item.value), + onCancel: () => this.#modals.closeTop(), + }) + this.#modals.open(selector, { anchor: 'center', width: '86%', minWidth: 32, maxHeight: '90%' }) + } + + openAdjacentBranch(direction: -1 | 1): void { + const view = this.#controller.view() + const items = branchItems(view) + const current = items.findIndex((item) => item.value === view.branch) + const next = current < 0 ? undefined : items[current + direction] + if (next === undefined) { + this.openBranchSelector() + return + } + void this.#actions.openBranch(undefined, next.value) + } + + openForkPreview(): void { + this.#actions.openForkPreview(this.#controller.view()) + } +} diff --git a/src/views/tui/details.ts b/src/views/tui/details.ts new file mode 100644 index 0000000..e6131a6 --- /dev/null +++ b/src/views/tui/details.ts @@ -0,0 +1,34 @@ +import { Container, Spacer, Text } from '@earendil-works/pi-tui' +import type { BraidViewModel } from '../shared/models.js' +import { sanitizeTerminalText } from '../shared/sanitize.js' +import type { BraidTheme } from './theme.js' + +export class DetailsViewPanel extends Container { + readonly #theme: BraidTheme + + constructor(theme: BraidTheme) { + super() + this.#theme = theme + } + + setView(view: BraidViewModel): void { + this.clear() + const details = view.details + this.addChild(new Text(this.#theme.brand(details?.title ?? 'details'), 1, 0)) + this.addChild(new Spacer(1)) + if (!details) { + this.addChild(new Text(this.#theme.muted('Select a run or node for details.'), 1, 0)) + } else { + for (const field of details.fields) this.addChild(this.#field(field.label, field.value)) + } + this.invalidate() + } + + #field(label: string, value: string): Text { + return new Text( + `${this.#theme.muted(`${sanitizeTerminalText(label)}:`)} ${sanitizeTerminalText(value)}`, + 1, + 0, + ) + } +} diff --git a/src/views/tui/fork-preview.ts b/src/views/tui/fork-preview.ts new file mode 100644 index 0000000..ccccf75 --- /dev/null +++ b/src/views/tui/fork-preview.ts @@ -0,0 +1,136 @@ +import { Container, type Focusable, matchesKey, Text, TruncatedText } from '@earendil-works/pi-tui' +import type { BraidViewModel } from '../shared/models.js' +import { sanitizeTerminalText } from '../shared/sanitize.js' +import { forkExecutionIdentity } from './conversation-overlay-helpers.js' +import type { BraidTheme } from './theme.js' + +export interface ForkPreviewPanelOptions { + readonly onConfirm?: () => void + readonly onCancel?: () => void +} + +export class ForkPreviewPanel extends Container implements Focusable { + readonly #theme: BraidTheme + readonly #onConfirm: (() => void) | undefined + readonly #onCancel: (() => void) | undefined + readonly #error = new Text('', 1, 0) + #focused = false + #submitted = false + #canConfirm = false + + constructor(theme: BraidTheme, options: ForkPreviewPanelOptions = {}) { + super() + this.#theme = theme + this.#onConfirm = options.onConfirm + this.#onCancel = options.onCancel + } + + get focused(): boolean { + return this.#focused + } + + set focused(value: boolean) { + this.#focused = value + } + + setView(view: BraidViewModel): void { + this.clear() + this.#submitted = false + this.#canConfirm = false + this.#error.setText('') + const preview = view.forkPreview + if (!preview) { + this.addChild(this.#line(this.#theme.brand('fork preview · unavailable'))) + this.addChild(this.#line(this.#theme.muted('No fork plan is available.'))) + this.addChild( + this.#line( + this.#theme.warning('Checkpoint and fork capabilities were not reported by the core.'), + ), + ) + this.addChild(this.#line(this.#theme.muted('esc cancel'))) + } else { + this.#canConfirm = forkExecutionIdentity(preview) !== undefined + this.addChild( + this.#line(this.#theme.brand(`fork preview · ${sanitizeTerminalText(preview.kind)}`)), + ) + this.addChild( + this.#line( + this.#theme.muted( + `will create a new ${sanitizeTerminalText(preview.kind)} from this source`, + ), + ), + ) + this.addChild(this.#line(`source: ${sanitizeTerminalText(preview.source)}`)) + this.addChild(this.#line(`destination: ${sanitizeTerminalText(preview.destination)}`)) + + const boundaryField = preview.fields.find((field) => isBoundaryField(field.label)) + this.addChild( + this.#line( + boundaryField + ? `boundary: ${sanitizeTerminalText(boundaryField.source)} → ${sanitizeTerminalText(boundaryField.destination)}` + : this.#theme.warning('boundary: not reported by the fork plan'), + ), + ) + const fields = preview.fields.filter( + (field) => field !== boundaryField && !isExecutionField(field.label), + ) + for (const field of fields.slice(0, 3)) { + this.addChild( + this.#line( + `${sanitizeTerminalText(field.label)}: ${sanitizeTerminalText(field.source)} → ${sanitizeTerminalText(field.destination)}`, + ), + ) + } + if (fields.length > 3) + this.addChild(this.#line(this.#theme.muted(`+${fields.length - 3} fields not shown`))) + this.addChild(this.#error) + if (!this.#canConfirm) { + this.addChild( + this.#line( + this.#theme.warning( + sanitizeTerminalText( + preview.allowed + ? 'The fork plan is missing execution data; create a fresh preview' + : (preview.unavailableReason ?? 'Fork is unavailable'), + ), + ), + ), + ) + this.addChild(this.#line(this.#theme.muted('esc cancel'))) + } else { + this.addChild(this.#line(this.#theme.muted('enter/y create fork · esc cancel'))) + } + } + this.invalidate() + } + + setError(message: string): void { + this.#submitted = false + this.#error.setText(this.#theme.danger(sanitizeTerminalText(message))) + this.invalidate() + } + + handleInput(data: string): void { + if (matchesKey(data, 'escape') || matchesKey(data, 'ctrl+c')) { + this.#onCancel?.() + return + } + if (this.#submitted || !this.#canConfirm || this.#onConfirm === undefined) return + if (matchesKey(data, 'enter') || matchesKey(data, 'y')) { + this.#submitted = true + this.#onConfirm() + } + } + + #line(value: string): TruncatedText { + return new TruncatedText(value, 1, 0) + } +} + +function isBoundaryField(label: string): boolean { + return /boundary|context|through|message|turn/iu.test(sanitizeTerminalText(label)) +} + +function isExecutionField(label: string): boolean { + return label === 'operation id' || label === 'plan digest' +} diff --git a/src/views/tui/graph.ts b/src/views/tui/graph.ts new file mode 100644 index 0000000..e5afbf1 --- /dev/null +++ b/src/views/tui/graph.ts @@ -0,0 +1,80 @@ +import { Container, TruncatedText } from '@earendil-works/pi-tui' +import type { BraidViewModel, GraphNodeView } from '../shared/models.js' +import { sanitizeTerminalText } from '../shared/sanitize.js' +import type { BraidTheme } from './theme.js' + +export class GraphView extends Container { + readonly #theme: BraidTheme + + constructor(theme: BraidTheme) { + super() + this.#theme = theme + } + + setView(view: BraidViewModel): void { + this.clear() + this.addChild(this.#line(this.#theme.brand('conversation graph · current branch'))) + this.addChild( + this.#line(`${this.#theme.muted('branch:')} ${sanitizeTerminalText(view.branch)}`), + ) + if (view.graph.length === 0) { + this.addChild(this.#line(this.#theme.muted('No graph nodes are available.'))) + } else { + const nodes = visibleNodes(view.graph, view.branch) + for (const node of nodes) this.addChild(this.#node(node, view.branch)) + const visibleIds = new Set(nodes.map((node) => node.id)) + const hiddenCount = view.graph.filter((node) => !visibleIds.has(node.id)).length + if (hiddenCount > 0) + this.addChild(this.#line(this.#theme.muted(`+${hiddenCount} nodes not shown`))) + } + this.addChild(this.#line(this.#theme.muted('esc close'))) + this.invalidate() + } + + #node(node: GraphNodeView, currentBranch: string): TruncatedText { + const indent = ' '.repeat(Math.max(0, node.depth)) + const edgeName = displayEdge(node) + const edge = edgeName ? `${edgeColor(this.#theme, edgeName)} ` : '' + const current = node.type === 'branch' && node.id === currentBranch + const marker = current ? `${this.#theme.accent('› current')} ` : '' + const label = `${indent}${node.depth > 0 ? '└ ' : ''}${marker}${edge}${sanitizeTerminalText(node.type)} ${sanitizeTerminalText(node.title)}` + const status = sanitizeTerminalText(node.status) + const statusText = + node.status === 'failed' || node.status === 'storage-failure' + ? this.#theme.danger(status) + : node.status === 'running' || node.status === 'waiting' + ? this.#theme.warning(status) + : this.#theme.muted(status) + return this.#line(`${statusText} · ${label}`) + } + + #line(value: string): TruncatedText { + return new TruncatedText(value, 1, 0) + } +} + +function displayEdge(node: GraphNodeView): string | undefined { + if (!node.edgeLabel) return undefined + const edge = sanitizeTerminalText(node.edgeLabel).replace(/[_-]+/gu, ' ').trim() + if (edge === 'compared left') return 'compared left' + if (edge === 'compared right') return 'compared right' + if (edge === 'analyzed') return 'analyzed' + if (edge === 'attached' && node.type === 'analysis') return 'attached analysis' + return edge +} + +function edgeColor(theme: BraidTheme, edge: string): string { + return /^(analyzed|compared)/u.test(edge) ? theme.accent(`─${edge}→`) : theme.muted(`─${edge}→`) +} + +function visibleNodes( + nodes: readonly GraphNodeView[], + currentBranch: string, +): readonly GraphNodeView[] { + const limit = 8 + const visible = nodes.slice(0, limit) + if (visible.some((node) => node.id === currentBranch)) return visible + const current = nodes.find((node) => node.id === currentBranch) + if (current === undefined) return visible + return [...visible.slice(0, limit - 1), current] +} diff --git a/src/views/tui/help.ts b/src/views/tui/help.ts new file mode 100644 index 0000000..da7a5c3 --- /dev/null +++ b/src/views/tui/help.ts @@ -0,0 +1,80 @@ +import { Container, Spacer, Text } from '@earendil-works/pi-tui' +import { COMMAND_DEFINITIONS } from '../shared/command-registry.js' +import { sanitizeTerminalText } from '../shared/sanitize.js' +import type { BraidTheme } from './theme.js' + +export interface HelpViewOptions { + readonly keyboardDiagnostic?: string + readonly keymapDiagnostic?: string +} + +export class HelpViewPanel extends Container { + readonly #theme: BraidTheme + readonly #options: HelpViewOptions + + constructor(theme: BraidTheme, options: HelpViewOptions = {}) { + super() + this.#theme = theme + this.#options = options + } + + setQuery(query: string): void { + this.clear() + this.addChild(new Text(this.#theme.brand('help'), 1, 0)) + this.addChild( + new Text(this.#theme.muted(`search: ${sanitizeTerminalText(query) || 'all commands'}`), 1, 0), + ) + this.addChild(new Spacer(1)) + if (this.#options.keyboardDiagnostic) { + this.addChild( + new Text(this.#theme.muted(sanitizeTerminalText(this.#options.keyboardDiagnostic)), 1, 0), + ) + } + if (this.#options.keymapDiagnostic) { + this.addChild( + new Text(this.#theme.warning(sanitizeTerminalText(this.#options.keymapDiagnostic)), 1, 0), + ) + } + if (this.#options.keyboardDiagnostic || this.#options.keymapDiagnostic) { + this.addChild(new Spacer(1)) + } + const normalized = query.trim().toLowerCase() + const definitions = COMMAND_DEFINITIONS.filter( + (definition) => + !normalized || + `${definition.name} ${definition.description}`.toLowerCase().includes(normalized), + ) + for (const definition of definitions) { + this.addChild( + new Text( + `${this.#theme.accent(definition.usage)} — ${sanitizeTerminalText(definition.description)}`, + 1, + 0, + ), + ) + } + this.addChild(new Spacer(1)) + this.addChild( + new Text( + this.#theme.muted( + 'Ctrl+P commands · Ctrl+O conversations · Ctrl+K profiles · Ctrl+G graph', + ), + 1, + 0, + ), + ) + this.addChild( + new Text( + this.#theme.muted( + 'Ctrl+C clear/cancel/quit · Ctrl+D quit · Alt+↑/↓ branches · F2 activity', + ), + 1, + 0, + ), + ) + this.addChild( + new Text(this.#theme.muted('PgUp/PgDn history · Home/End bounds · Ctrl+E next detail'), 1, 0), + ) + this.invalidate() + } +} diff --git a/src/views/tui/interaction-presentation.ts b/src/views/tui/interaction-presentation.ts new file mode 100644 index 0000000..4496942 --- /dev/null +++ b/src/views/tui/interaction-presentation.ts @@ -0,0 +1,180 @@ +import { + type Component, + CURSOR_MARKER, + Input, + type KeyId, + matchesKey, + TruncatedText, + visibleWidth, +} from '@earendil-works/pi-tui' +import type { InteractionOutcome, InteractionView } from '../shared/models.js' +import { sanitizeTerminalText } from '../shared/sanitize.js' +import type { BraidTheme } from './theme.js' + +const OUTCOME_KEYS = [ + 'alt+1', + 'alt+2', + 'alt+3', + 'alt+4', + 'alt+5', + 'alt+6', + 'alt+7', + 'alt+8', + 'alt+9', +] as const satisfies readonly KeyId[] + +export class SecretInput extends Input { + override render(width: number): string[] { + const mask = Array.from(this.getValue(), () => '•').join('') + const cursor = this.focused ? CURSOR_MARKER : '' + return new TruncatedText(`${mask}${cursor}`, 1, 0).render(width) + } +} + +export class MutableTruncatedLine implements Component { + #value = '' + + setValue(value: string): void { + this.#value = value + } + + invalidate(): void {} + + render(width: number): string[] { + return new TruncatedText(this.#value, 1, 0).render(width) + } +} + +export class OutcomeKeys implements Component { + readonly #outcomes: readonly InteractionOutcome[] + readonly #theme: BraidTheme + + constructor(outcomes: readonly InteractionOutcome[], theme: BraidTheme) { + this.#outcomes = outcomes + this.#theme = theme + } + + invalidate(): void {} + + render(width: number): string[] { + const maxWidth = Math.max(1, Math.floor(width)) + const rows: string[] = [] + let row = 'keys:' + for (const [index, outcome] of this.#outcomes.entries()) { + const key = OUTCOME_KEYS[index] + if (key === undefined) break + const token = `${key} ${outcomeKeyLabel(outcome)}` + const separator = row === 'keys:' ? ' ' : ' · ' + const candidate = `${row}${separator}${token}` + if (row !== 'keys:' && visibleWidth(candidate) > maxWidth) { + rows.push(row) + row = token + } else { + row = candidate + } + } + if (row !== 'keys:' || rows.length === 0) rows.push(row) + return rows.flatMap((line) => new TruncatedText(this.#theme.muted(line), 1, 0).render(width)) + } +} + +export function outcomeForKey( + data: string, + outcomes: readonly InteractionOutcome[], +): InteractionOutcome | undefined { + for (const [index, outcome] of outcomes.entries()) { + const key = OUTCOME_KEYS[index] + if (key !== undefined && matchesKey(data, key)) return outcome + } + return undefined +} + +export function isSecretInteraction(interaction: InteractionView): boolean { + return ( + interaction.secret || + interaction.answerSpec.kind === 'secret' || + (interaction.answerSpec.kind === 'text' && interaction.answerSpec.secret) + ) +} + +export function interactionHeading(interaction: InteractionView): string { + const kind = sanitizeTerminalText(interaction.kind).toLocaleLowerCase() || 'interaction' + if (kind === 'permission' || kind === 'plan') return `${kind} · approve or reject` + if (kind === 'question') return `${kind} · answer required` + return `${kind} · response required` +} + +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 `run: ${sanitizeTerminalText(interaction.runId)}${requester ? ` · ${requester}` : ''} · queue ${interaction.queuePosition + 1} · ${timeout}` +} + +export function consequence( + interaction: InteractionView, + selectedOutcome?: InteractionOutcome, +): string { + const outcomes = + selectedOutcome !== undefined && isPositiveOutcome(selectedOutcome) + ? [ + selectedOutcome, + ...interaction.allowedOutcomes.filter((outcome) => outcome !== selectedOutcome), + ] + : interaction.allowedOutcomes + const choices = outcomes.map((outcome) => outcomeConsequenceLabel(outcome)) + return choices.length > 0 ? `will: ${choices.join(' · ')}` : 'will: choose a response' +} + +export function isPositiveOutcome(outcome: InteractionOutcome): boolean { + return ['accept', 'once', 'session', 'persistent'].includes(outcome) +} + +export function rejectionOutcome(interaction: InteractionView): InteractionOutcome | undefined { + return interaction.allowedOutcomes.find((outcome) => + ['reject', 'deny', 'revise'].includes(outcome), + ) +} + +export function answerOutcome(interaction: InteractionView): InteractionOutcome | undefined { + return interaction.allowedOutcomes.find(isPositiveOutcome) ?? rejectionOutcome(interaction) +} + +export function answerHelp(interaction: InteractionView): string { + const spec = interaction.answerSpec + if (spec.kind === 'number') + return `answer: number${spec.minimum === undefined ? '' : ` ≥ ${spec.minimum}`}${spec.maximum === undefined ? '' : ` ≤ ${spec.maximum}`}` + if (spec.kind === 'boolean') + return rejectionOutcome(interaction) ? 'answer: y approve · n reject' : 'answer: y yes · n no' + if (spec.kind === 'unknown') return `answer: ${sanitizeTerminalText(spec.label)}` + if (spec.kind === 'form') return `answer: JSON · ${spec.fields.length} field(s)` + if (spec.kind === 'secret') return 'answer: secret hidden' + return spec.kind === 'text' && spec.secret ? 'answer: secret hidden' : 'answer: response' +} + +function outcomeKeyLabel(outcome: InteractionOutcome): string { + if (outcome === 'once') return 'once' + if (outcome === 'session') return 'run' + if (outcome === 'persistent') return 'save' + if (outcome === 'accept') return 'approve' + if (outcome === 'reject') return 'reject' + if (outcome === 'deny') return 'deny' + if (outcome === 'revise') return 'revise' + return 'cancel' +} + +function outcomeConsequenceLabel(outcome: InteractionOutcome): string { + if (outcome === 'once') return 'approve once' + if (outcome === 'session') return 'approve run' + if (outcome === 'persistent') return 'save approval' + if (outcome === 'accept') return 'approve' + if (outcome === 'reject') return 'reject' + if (outcome === 'deny') return 'deny' + if (outcome === 'revise') return 'revise' + return 'cancel' +} diff --git a/src/views/tui/interaction.ts b/src/views/tui/interaction.ts new file mode 100644 index 0000000..abe33e3 --- /dev/null +++ b/src/views/tui/interaction.ts @@ -0,0 +1,244 @@ +import { + Container, + type Focusable, + Input, + matchesKey, + Text, + TruncatedText, +} from '@earendil-works/pi-tui' +import type { InteractionResponseValue } from '../shared/intents.js' +import type { InteractionOutcome, InteractionView } from '../shared/models.js' +import { sanitizeDiff, sanitizeTerminalText } from '../shared/sanitize.js' +import { + answerHelp, + answerOutcome, + consequence, + interactionHeading, + isPositiveOutcome, + isSecretInteraction, + MutableTruncatedLine, + OutcomeKeys, + outcomeForKey, + rejectionOutcome, + runContext, + SecretInput, +} from './interaction-presentation.js' +import { SearchableSelector } from './selector.js' +import type { BraidTheme } from './theme.js' + +export class InteractionShell extends Container implements Focusable { + readonly #theme: BraidTheme + readonly #interaction: InteractionView + readonly #onRespond: (response: InteractionResponseValue) => void + readonly #input: Input + readonly #selector?: SearchableSelector + readonly #validation = new Text('', 1, 0) + readonly #consequence = new MutableTruncatedLine() + #focused = false + #responded = false + #selectedOutcome: InteractionOutcome | undefined + + constructor( + interaction: InteractionView, + theme: BraidTheme, + onRespond: (response: InteractionResponseValue) => void, + ) { + super() + this.#interaction = interaction + this.#theme = theme + this.#onRespond = onRespond + this.#input = isSecretInteraction(interaction) ? new SecretInput() : new Input() + this.#selectedOutcome = answerOutcome(interaction) + this.#input.onSubmit = (value) => this.#submitValue(value) + this.#input.onEscape = () => this.#respond({ outcome: 'cancel' }) + + const compactSelector = interaction.answerSpec.kind === 'select' + this.addChild(this.#line(this.#theme.brand(interactionHeading(interaction)))) + if (!compactSelector) this.addChild(this.#line(runContext(interaction))) + this.addChild( + this.#line( + isSecretInteraction(interaction) + ? 'Secret response requested; value stays hidden.' + : sanitizeTerminalText(interaction.prompt), + ), + ) + this.#consequence.setValue(this.#theme.muted(consequence(interaction, this.#selectedOutcome))) + this.addChild(this.#consequence) + if (interaction.subject) this.#addSubject(interaction.subject, compactSelector) + + if (interaction.answerSpec.kind === 'select') { + const selector = new SearchableSelector({ + title: 'response', + items: interaction.answerSpec.options.map((option) => ({ + value: option.value, + label: option.label, + })), + theme, + maxVisible: 1, + footer: '↑↓ move · enter choose · esc cancel', + onSelect: (item) => { + const outcome = this.#selectedOutcome ?? answerOutcome(this.#interaction) + if (outcome) this.#respond({ outcome, value: item.value }) + else this.#respond({ outcome: 'cancel' }) + }, + onCancel: () => this.#respond({ outcome: 'cancel' }), + }) + this.#selector = selector + if (interaction.allowedOutcomes.length > 0) + this.addChild(new OutcomeKeys(interaction.allowedOutcomes, theme)) + this.addChild(selector) + } else { + this.addChild(this.#line(answerHelp(interaction))) + this.addChild(this.#input) + this.addChild(this.#validation) + if (interaction.allowedOutcomes.length > 0) + this.addChild(new OutcomeKeys(interaction.allowedOutcomes, theme)) + this.addChild(this.#line(this.#theme.muted('enter submit · esc cancel'))) + } + } + + get focused(): boolean { + return this.#focused + } + + set focused(value: boolean) { + this.#focused = value + this.#input.focused = value && !this.#selector + if (this.#selector) this.#selector.focused = value + } + + handleInput(data: string): void { + if (matchesKey(data, 'escape') || matchesKey(data, 'ctrl+c')) { + this.#respond({ outcome: 'cancel' }) + return + } + if (this.#selector) { + const outcome = outcomeForKey(data, this.#interaction.allowedOutcomes) + if (outcome !== undefined) { + if (isPositiveOutcome(outcome)) this.#selectOutcome(outcome) + else this.#respond({ outcome }) + return + } + this.#selector.handleInput(data) + return + } + const outcome = outcomeForKey(data, this.#interaction.allowedOutcomes) + if (outcome !== undefined) { + if (this.#interaction.answerSpec.kind === 'boolean') { + this.#respond({ outcome, ...(isPositiveOutcome(outcome) ? { value: true } : {}) }) + return + } + if (isEditableInteraction(this.#interaction) && isPositiveOutcome(outcome)) { + this.#selectOutcome(outcome) + return + } + this.#respond({ outcome }) + return + } + if (this.#interaction.answerSpec.kind === 'boolean') { + const approval = answerOutcome(this.#interaction) + const rejection = rejectionOutcome(this.#interaction) + if ((data === 'y' || data === 'Y') && approval) { + this.#respond({ outcome: approval, value: true }) + return + } + if ((data === 'n' || data === 'N') && (rejection || approval)) { + this.#respond( + rejection + ? { outcome: rejection } + : { outcome: approval as InteractionOutcome, value: false }, + ) + return + } + } + this.#input.handleInput(data) + } + + #addSubject(subject: NonNullable, compact: boolean): void { + if (isSecretInteraction(this.#interaction)) { + this.addChild(this.#line(this.#theme.muted('request: secret input · details hidden'))) + return + } + const title = sanitizeTerminalText(subject.title) + const target = subject.target ? ` · ${sanitizeTerminalText(subject.target)}` : '' + this.addChild(this.#line(this.#theme.muted(`request: ${title}${target}`))) + if (compact) return + if (subject.detail) this.addChild(this.#line(`detail: ${sanitizeTerminalText(subject.detail)}`)) + const preview = subject.preview ?? [] + if (preview.length > 0) this.addChild(this.#line(sanitizeDiff(preview[0] ?? ''))) + } + + #submitValue(value: string): void { + const spec = this.#interaction.answerSpec + const outcome = this.#selectedOutcome ?? answerOutcome(this.#interaction) + if (!outcome) { + this.#respond({ outcome: 'cancel' }) + return + } + if (spec.kind === 'number') { + const number = Number(value) + if ( + !Number.isFinite(number) || + (spec.minimum !== undefined && number < spec.minimum) || + (spec.maximum !== undefined && number > spec.maximum) + ) { + this.#setValidation('Enter a number in the allowed range.') + return + } + this.#respond({ outcome, value: number }) + return + } + if (spec.kind === 'unknown') { + this.#respond({ outcome: 'cancel' }) + return + } + if (spec.kind === 'form') { + try { + const parsed: unknown = JSON.parse(value) + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) + throw new Error() + this.#respond({ + outcome, + data: parsed as Record, + }) + } catch { + this.#setValidation('Enter a JSON object with the requested fields') + } + return + } + if (spec.required && value.length === 0) { + this.#setValidation('A response is required.') + return + } + if (spec.kind === 'boolean' && value !== 'true' && value !== 'false') { + this.#setValidation('Enter true or false, or use y/n.') + return + } + this.#respond({ outcome, value: spec.kind === 'boolean' ? value === 'true' : value }) + } + + #setValidation(message: string): void { + this.#validation.setText(this.#theme.danger(message)) + this.invalidate() + } + + #selectOutcome(outcome: InteractionOutcome): void { + this.#selectedOutcome = outcome + this.#consequence.setValue(this.#theme.muted(consequence(this.#interaction, outcome))) + this.invalidate() + } + + #respond(response: InteractionResponseValue): void { + if (this.#responded) return + this.#responded = true + this.#onRespond(response) + } + + #line(value: string): TruncatedText { + return new TruncatedText(value, 1, 0) + } +} + +function isEditableInteraction(interaction: InteractionView): boolean { + return ['text', 'secret', 'number', 'form'].includes(interaction.answerSpec.kind) +} diff --git a/src/views/tui/keyboard.ts b/src/views/tui/keyboard.ts new file mode 100644 index 0000000..ba9f547 --- /dev/null +++ b/src/views/tui/keyboard.ts @@ -0,0 +1,283 @@ +import { decodeKittyPrintable, isKeyRelease, type KeyId, matchesKey } from '@earendil-works/pi-tui' + +export type GlobalKeyAction = + | 'closeOverlay' + | 'commandPalette' + | 'conversationSelector' + | 'graph' + | 'switcher' + | 'activity' + | 'toggleDetails' + | 'help' + | 'previousBranch' + | 'nextBranch' + | 'exit' + | 'clearCancelQuit' + +export type BraidKeymap = Readonly> + +export interface KeymapInput { + readonly [action: string]: string | readonly string[] +} + +export interface KeymapResolution { + readonly keymap: BraidKeymap + readonly valid: boolean + readonly diagnostics: readonly string[] +} + +const ACTION_ALIASES: Readonly> = Object.freeze({ + close: 'closeOverlay', + closeOverlay: 'closeOverlay', + 'close-overlay': 'closeOverlay', + palette: 'commandPalette', + commandPalette: 'commandPalette', + 'command-palette': 'commandPalette', + conversations: 'conversationSelector', + conversationSelector: 'conversationSelector', + 'conversation-selector': 'conversationSelector', + graph: 'graph', + switcher: 'switcher', + activity: 'activity', + toggleDetails: 'toggleDetails', + 'toggle-details': 'toggleDetails', + help: 'help', + previousBranch: 'previousBranch', + 'previous-branch': 'previousBranch', + nextBranch: 'nextBranch', + 'next-branch': 'nextBranch', + exit: 'exit', + clearCancelQuit: 'clearCancelQuit', + 'clear-cancel-quit': 'clearCancelQuit', +}) + +const DEFAULT_KEYMAP: BraidKeymap = Object.freeze({ + closeOverlay: Object.freeze(['escape'] as KeyId[]), + commandPalette: Object.freeze(['ctrl+p'] as KeyId[]), + conversationSelector: Object.freeze(['ctrl+o'] as KeyId[]), + graph: Object.freeze(['ctrl+g'] as KeyId[]), + switcher: Object.freeze(['ctrl+k'] as KeyId[]), + activity: Object.freeze(['f2'] as KeyId[]), + toggleDetails: Object.freeze(['ctrl+e'] as KeyId[]), + help: Object.freeze(['?'] as KeyId[]), + previousBranch: Object.freeze(['alt+up'] as KeyId[]), + nextBranch: Object.freeze(['alt+down'] as KeyId[]), + exit: Object.freeze(['ctrl+d'] as KeyId[]), + clearCancelQuit: Object.freeze(['ctrl+c'] as KeyId[]), +}) + +const MODIFIERS = new Set(['ctrl', 'shift', 'alt', 'super']) +const BASE_KEYS = new Set([ + ...'abcdefghijklmnopqrstuvwxyz0123456789'.split(''), + '`', + '-', + '=', + '[', + ']', + '\\', + ';', + "'", + ',', + '.', + '/', + '!', + '@', + '#', + '$', + '%', + '^', + '&', + '*', + '(', + ')', + '_', + '+', + '|', + '~', + '{', + '}', + ':', + '<', + '>', + '?', + 'escape', + 'enter', + 'return', + 'tab', + 'space', + 'backspace', + 'delete', + 'insert', + 'clear', + 'home', + 'end', + 'pageup', + 'pagedown', + 'up', + 'down', + 'left', + 'right', + ...Array.from({ length: 12 }, (_, index) => `f${index + 1}`), +]) + +const ACTIONS = Object.keys(DEFAULT_KEYMAP) as GlobalKeyAction[] + +export function defaultKeymap(): BraidKeymap { + return DEFAULT_KEYMAP +} + +export function resolveKeymap(raw = process.env.BRAID_KEYMAP): KeymapResolution { + if (raw === undefined || raw.trim() === '') { + return Object.freeze({ keymap: DEFAULT_KEYMAP, valid: true, diagnostics: Object.freeze([]) }) + } + + const diagnostics: string[] = [] + const entries = parseEntries(raw, diagnostics) + const candidate: Record = { ...DEFAULT_KEYMAP } + const configuredActions = new Set() + + for (const [rawAction, rawKeys] of entries) { + const action = ACTION_ALIASES[rawAction] + if (action === undefined) { + diagnostics.push(`Unknown key mapping action '${rawAction}'`) + continue + } + if (configuredActions.has(action)) { + diagnostics.push(`Key mapping action '${rawAction}' is configured more than once`) + continue + } + configuredActions.add(action) + const keys = Array.isArray(rawKeys) ? rawKeys : [rawKeys] + const normalized = keys.map((key) => normalizeKeyId(key, diagnostics, action)) + if (normalized.length > 0 && normalized.every((key): key is KeyId => key !== undefined)) { + candidate[action] = Object.freeze(normalized) + } + } + + const ownership = new Map() + for (const action of ACTIONS) { + for (const key of candidate[action]) { + const canonical = canonicalKeyId(key) + const previous = ownership.get(canonical) + if (previous !== undefined && previous !== action) { + diagnostics.push( + `Key '${key}' is assigned to both ${previous} and ${action}; the mapping was rejected`, + ) + } else { + ownership.set(canonical, action) + } + } + } + + if (diagnostics.length > 0) { + return Object.freeze({ + keymap: DEFAULT_KEYMAP, + valid: false, + diagnostics: Object.freeze(diagnostics), + }) + } + + return Object.freeze({ + keymap: freezeKeymap(candidate), + valid: true, + diagnostics: Object.freeze([]), + }) +} + +export function matchesKeyAction( + data: string, + keymap: BraidKeymap, + action: GlobalKeyAction, +): boolean { + if (isKeyRelease(data)) return false + return keymap[action].some((key) => matchesKey(data, key)) +} + +export function isTextInputSequence(data: string): boolean { + return ( + data.includes('\u001b[200~') || + data.includes('\u001b[201~') || + decodeKittyPrintable(data) !== undefined + ) +} + +function parseEntries( + raw: string, + diagnostics: string[], +): readonly (readonly [string, string | readonly string[]])[] { + const trimmed = raw.trim() + if (trimmed.startsWith('{')) { + try { + const parsed: unknown = JSON.parse(trimmed) + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error() + const entries: Array = [] + for (const [action, keys] of Object.entries(parsed)) { + if (typeof keys === 'string') { + entries.push([action, keys] as const) + continue + } + if (Array.isArray(keys) && keys.every((key) => typeof key === 'string')) { + entries.push([action, keys] as const) + continue + } + diagnostics.push(`Key mapping '${action}' must be a key or an array of keys`) + } + return entries + } catch { + diagnostics.push('BRAID_KEYMAP must be a mapping object or action=key entries') + return [] + } + } + + return trimmed.split(',').flatMap((entry) => { + const separator = entry.indexOf('=') + if (separator <= 0 || separator === entry.length - 1) { + diagnostics.push(`Invalid key mapping '${entry}'`) + return [] + } + const action = entry.slice(0, separator).trim() + const keys = entry + .slice(separator + 1) + .split('|') + .map((key) => key.trim()) + .filter(Boolean) + if (keys.length === 0) { + diagnostics.push(`Key mapping '${action}' has no keys`) + return [] + } + return [[action, keys] as const] + }) +} + +function normalizeKeyId( + value: string, + diagnostics: string[], + action: GlobalKeyAction, +): KeyId | undefined { + const normalized = value.trim().toLowerCase() + const parts = normalized.split('+') + const base = parts.pop() + if (!base || !BASE_KEYS.has(base) || parts.some((part) => !MODIFIERS.has(part))) { + diagnostics.push(`Invalid key '${value}' for ${action}`) + return undefined + } + if (new Set(parts).size !== parts.length) { + diagnostics.push(`Key '${value}' repeats a modifier for ${action}`) + return undefined + } + return [...parts.sort(), base].join('+') as KeyId +} + +function canonicalKeyId(key: KeyId): string { + const parts = key.split('+') + const base = parts.pop() ?? '' + return [...parts.sort(), base].join('+') +} + +function freezeKeymap(candidate: Record): BraidKeymap { + return Object.freeze( + Object.fromEntries( + ACTIONS.map((action) => [action, Object.freeze([...candidate[action]])]), + ) as Record, + ) +} diff --git a/src/views/tui/layout.ts b/src/views/tui/layout.ts new file mode 100644 index 0000000..00e57be --- /dev/null +++ b/src/views/tui/layout.ts @@ -0,0 +1,64 @@ +export type LayoutMode = 'narrow' | 'standard' | 'wide' + +export interface TerminalLayout { + readonly mode: LayoutMode + readonly columns: number + readonly rows: number + readonly transcriptWidth: number + readonly activityWidth: number + readonly gap: number + readonly overlayFullScreen: boolean +} + +export const NARROW_COLUMNS = 80 +export const WIDE_COLUMNS = 120 +export const ACTIVITY_WIDTH = 28 +export const TRANSCRIPT_MINIMUM = 72 +const MIN_OVERLAY_ROWS = 16 + +export function modeForColumns(columns: number): LayoutMode { + const safeColumns = Math.max(1, Math.floor(columns)) + if (safeColumns < NARROW_COLUMNS) return 'narrow' + if (safeColumns >= WIDE_COLUMNS) return 'wide' + return 'standard' +} + +export function layoutFor(columns: number, rows: number, activityVisible = true): TerminalLayout { + const safeColumns = Math.max(1, Math.floor(columns)) + const safeRows = Math.max(1, Math.floor(rows)) + const mode = modeForColumns(safeColumns) + if (mode === 'narrow' || !activityVisible) { + return { + mode: mode === 'narrow' ? 'narrow' : 'standard', + columns: safeColumns, + rows: safeRows, + transcriptWidth: safeColumns, + activityWidth: 0, + gap: 0, + overlayFullScreen: mode === 'narrow' || safeRows < MIN_OVERLAY_ROWS, + } + } + + const transcriptWidth = safeColumns - ACTIVITY_WIDTH - 1 + if (mode === 'wide' && transcriptWidth >= TRANSCRIPT_MINIMUM) { + return { + mode: 'wide', + columns: safeColumns, + rows: safeRows, + transcriptWidth, + activityWidth: ACTIVITY_WIDTH, + gap: 1, + overlayFullScreen: safeRows < MIN_OVERLAY_ROWS, + } + } + + return { + mode: 'standard', + columns: safeColumns, + rows: safeRows, + transcriptWidth: safeColumns, + activityWidth: 0, + gap: 0, + overlayFullScreen: safeRows < MIN_OVERLAY_ROWS, + } +} diff --git a/src/views/tui/modal-coordinator.ts b/src/views/tui/modal-coordinator.ts new file mode 100644 index 0000000..004ca84 --- /dev/null +++ b/src/views/tui/modal-coordinator.ts @@ -0,0 +1,46 @@ +import type { Component, OverlayHandle, OverlayOptions, TUI } from '@earendil-works/pi-tui' +import { layoutFor } from './layout.js' + +export interface ModalOptions extends OverlayOptions { + readonly fullScreenBelow?: number +} + +export class ModalCoordinator { + readonly #tui: TUI + readonly #handles: OverlayHandle[] = [] + + constructor(tui: TUI) { + this.#tui = tui + } + + open(component: Component, options: ModalOptions = {}, preempt = true): OverlayHandle { + if (preempt) this.closeTop() + const fullScreenBelow = options.fullScreenBelow ?? 80 + const layout = layoutFor(this.#tui.terminal.columns, this.#tui.terminal.rows) + const handle = this.#tui.showOverlay(component, { + ...options, + ...(layout.columns < fullScreenBelow + ? { width: '100%', maxHeight: '100%', anchor: 'top-left' as const, margin: 0 } + : {}), + }) + this.#handles.push(handle) + return handle + } + + closeTop(): void { + const handle = this.#handles.pop() + handle?.hide() + } + + closeAll(): void { + while (this.#handles.length > 0) this.#handles.pop()?.hide() + } + + hasOpen(): boolean { + return this.#handles.some((handle) => !handle.isHidden()) + } + + focusTop(): void { + this.#handles.at(-1)?.focus() + } +} diff --git a/src/views/tui/profile-compatibility.ts b/src/views/tui/profile-compatibility.ts new file mode 100644 index 0000000..5ec471b --- /dev/null +++ b/src/views/tui/profile-compatibility.ts @@ -0,0 +1,146 @@ +import { type Component, wrapTextWithAnsi } from '@earendil-works/pi-tui' +import { sanitizeTerminalText } from '../shared/sanitize.js' + +const MAX_VALUE_CHARS = 256 + +export interface ProfileCompatibilityResult { + readonly authoredProfile?: unknown + readonly runner?: unknown + readonly model?: unknown + readonly compatibility?: { + readonly modelSupported?: unknown + readonly suggestedRunner?: unknown + readonly suggestedModel?: unknown + } +} + +/** + * Render profile compatibility as plain, bounded terminal lines. + * + * This deliberately accepts a structural result instead of importing the + * profile resolver so installed-package evaluators can reuse the same text. + */ +export function profileCompatibilityTextLines( + result: ProfileCompatibilityResult | null | undefined, + width = 80, +): readonly string[] { + const maxWidth = normalizedWidth(width) + const state = readCompatibilityState(result) + const lines = + state === undefined + ? unavailableLines() + : state.kind === 'supported' + ? supportedLines(state) + : incompatibleLines(state) + return lines.flatMap((line) => wrapTextWithAnsi(line, maxWidth)) +} + +export class ProfileCompatibilityPanel implements Component { + #result: ProfileCompatibilityResult | null | undefined + + constructor(result?: ProfileCompatibilityResult | null) { + this.#result = result + } + + setResult(result: ProfileCompatibilityResult | null | undefined): void { + this.#result = result + } + + invalidate(): void {} + + render(width: number): string[] { + return [...profileCompatibilityTextLines(this.#result, width)] + } +} + +interface SupportedValues { + readonly kind: 'supported' + readonly profileName: string + readonly runner: string + readonly model: string +} + +interface UnsupportedValues { + readonly kind: 'unsupported' + readonly profileName: string + readonly runner: string + readonly model: string + readonly suggestedRunner: string + readonly suggestedModel: string +} + +type CompatibilityState = SupportedValues | UnsupportedValues + +function readCompatibilityState( + result: ProfileCompatibilityResult | null | undefined, +): CompatibilityState | undefined { + if (!result || typeof result !== 'object') return undefined + const profile = asRecord(result.authoredProfile) + const compatibility = asRecord(result.compatibility) + if (!profile || !compatibility) return undefined + const runner = safeValue(result.runner) + const model = safeValue(result.model) + if (!runner || !model) return undefined + const profileName = safeValue(profile.name) ?? 'unnamed' + if (compatibility.modelSupported === true) { + return { kind: 'supported', profileName, runner, model } + } + if (compatibility.modelSupported !== false) return undefined + const suggestedRunner = safeValue(compatibility.suggestedRunner) + const suggestedModel = safeValue(compatibility.suggestedModel) + if (!suggestedRunner || !suggestedModel) return undefined + if (suggestedRunner === runner || suggestedModel === model) return undefined + return { + kind: 'unsupported', + profileName, + runner, + model, + suggestedRunner, + suggestedModel, + } +} + +function supportedLines(values: SupportedValues): readonly string[] { + return [ + 'profile compatibility', + `supported pair: harness=${values.runner} · model=${values.model}`, + `authored profile "${values.profileName}" remains unchanged`, + ] +} + +function incompatibleLines(values: UnsupportedValues): readonly string[] { + return [ + 'profile compatibility', + `unsupported pair: harness=${values.runner} · model=${values.model}`, + `authored profile "${values.profileName}" remains unchanged`, + 'choose one explicit change:', + `1. change runner to ${values.suggestedRunner} to keep model ${values.model}`, + `2. change model to ${values.suggestedModel} to keep runner ${values.runner}`, + ] +} + +function unavailableLines(): readonly string[] { + return [ + 'profile compatibility unavailable', + 'runner/model compatibility data is incomplete; no automatic change was made.', + 'authored profile remains unchanged.', + ] +} + +function safeValue(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined + const sanitized = sanitizeTerminalText(value) + .replace(/[\t\r\n]+/gu, ' ') + .trim() + return sanitized.length > 0 && sanitized.length <= MAX_VALUE_CHARS ? sanitized : undefined +} + +function asRecord(value: unknown): Record | undefined { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : undefined +} + +function normalizedWidth(width: number): number { + return Number.isFinite(width) ? Math.max(1, Math.floor(width)) : 80 +} diff --git a/src/views/tui/profile-editor-actions.ts b/src/views/tui/profile-editor-actions.ts new file mode 100644 index 0000000..eb32425 --- /dev/null +++ b/src/views/tui/profile-editor-actions.ts @@ -0,0 +1,81 @@ +import type { SelectItem } from '@earendil-works/pi-tui' +import type { BraidIntent } from '../shared/intents.js' +import { sanitizeTerminalText } from '../shared/sanitize.js' +import type { ProfileCompatibilityResult } from './profile-compatibility.js' + +export interface ProfileValidationIssue { + readonly level: string + readonly code: string + readonly message: string + readonly path?: string +} + +export interface ProfileValidationReport { + readonly ok: boolean + readonly issues: readonly ProfileValidationIssue[] +} + +export function profileItemName(item: SelectItem): string { + return item.label.replace(/^✓\s/u, '') +} + +export function selectProfileIntent( + item: SelectItem, + operationId: string, + expectedRevision: number, +): BraidIntent { + return { + type: 'headless-command', + command: 'select_profile', + operationId, + params: { ref: item.value, expectedRevision }, + } +} + +export function validateProfileIntent(item: SelectItem): BraidIntent { + return { + type: 'headless-command', + command: 'validate_profile', + params: { ref: item.value }, + } +} + +export function saveProfileIntent(item: SelectItem, operationId: string): BraidIntent { + return { + type: 'run-command', + command: 'profile', + operationId, + args: ['save', item.value], + } +} + +export function profileErrorMessage(error: unknown, fallback: string): string { + return sanitizeTerminalText(error instanceof Error ? error.message : fallback) +} + +export function readValidationReport(value: unknown): ProfileValidationReport | undefined { + if (!isRecord(value) || !('report' in value)) return undefined + const report = value.report + if (!isRecord(report) || typeof report.ok !== 'boolean') return undefined + if (!Array.isArray(report.issues)) return undefined + return { ok: report.ok, issues: report.issues.filter(isValidationIssue) } +} + +export function readProfileCompatibility(value: unknown): ProfileCompatibilityResult | undefined { + if (!isRecord(value) || !isRecord(value.effective)) return undefined + return value.effective +} + +function isValidationIssue(value: unknown): value is ProfileValidationIssue { + if (!isRecord(value)) return false + return ( + typeof value.level === 'string' && + typeof value.code === 'string' && + typeof value.message === 'string' && + (value.path === undefined || typeof value.path === 'string') + ) +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} diff --git a/src/views/tui/profile-editor-rendering.ts b/src/views/tui/profile-editor-rendering.ts new file mode 100644 index 0000000..6a57dc9 --- /dev/null +++ b/src/views/tui/profile-editor-rendering.ts @@ -0,0 +1,40 @@ +import { type Component, Spacer, Text } from '@earendil-works/pi-tui' +import type { BraidViewModel } from '../shared/models.js' +import { sanitizeTerminalText } from '../shared/sanitize.js' +import { safeFieldValue } from './configuration-presenters.js' +import type { BraidTheme } from './theme.js' + +export function profileEditorChildren( + theme: BraidTheme, + view: BraidViewModel, +): readonly Component[] { + const children: Component[] = [new Text(theme.brand('profile'), 1, 0), new Spacer(1)] + const editor = view.profileEditor + if (editor === undefined) { + children.push(new Text(theme.muted('No profile details loaded.'), 1, 0)) + children.push( + new Text(theme.warning('Use /profile to list, validate, select, or save profiles.'), 1, 0), + ) + return children + } + + children.push( + new Text( + theme.muted( + `source ${sanitizeTerminalText(editor.source)} · ${editor.readOnly ? 'read-only' : 'writable'}`, + ), + 1, + 0, + ), + new Text(theme.muted(`digest ${sanitizeTerminalText(editor.digest)}`), 1, 0), + new Text(theme.muted(`validation ${sanitizeTerminalText(editor.validation)}`), 1, 0), + ) + for (const field of editor.fields) { + const value = safeFieldValue(field.path, field.value, field.secret) + children.push(new Text(`${sanitizeTerminalText(field.path)}: ${value}`, 1, 0)) + } + if (editor.error !== undefined) { + children.push(new Text(theme.danger(sanitizeTerminalText(editor.error)), 1, 0)) + } + return children +} diff --git a/src/views/tui/profile-editor.ts b/src/views/tui/profile-editor.ts new file mode 100644 index 0000000..74b4016 --- /dev/null +++ b/src/views/tui/profile-editor.ts @@ -0,0 +1,293 @@ +import { Container, type Focusable, type SelectItem } from '@earendil-works/pi-tui' +import type { ProfileSummary } from '../../app/profiles.js' +import type { BraidIntent, BraidUiController, UiDispatchResult } from '../shared/intents.js' +import type { BraidViewModel } from '../shared/models.js' +import { sanitizeTerminalText } from '../shared/sanitize.js' +import { + actionMessage, + profileCompactDetail, + profileDetailLines, + profileItems, + profileSummariesFrom, + REFRESH_TIMEOUT, + safe, + within, +} from './configuration-presenters.js' +import { ResponsiveText } from './configuration-responsive-text.js' +import { + profileErrorMessage, + profileItemName, + readProfileCompatibility, + readValidationReport, + saveProfileIntent, + selectProfileIntent, + validateProfileIntent, +} from './profile-editor-actions.js' +import { profileEditorChildren } from './profile-editor-rendering.js' +import { profileCompatibilityTextLines } from './profile-compatibility.js' +import { SearchableSelector } from './selector.js' +import type { BraidTheme } from './theme.js' + +interface ProfileEditorOptions { + readonly controller?: BraidUiController + readonly nextOperationId?: () => string + readonly query?: string + readonly onCancel?: () => void +} + +const PROFILE_REFRESH_TIMEOUT_MS = 2_000 + +export class ProfileEditorViewPanel extends Container implements Focusable { + readonly #theme: BraidTheme + readonly #controller: BraidUiController | undefined + readonly #nextOperationId: (() => string) | undefined + readonly #onCancel: (() => void) | undefined + readonly #status = new ResponsiveText('', 1, 0) + readonly #detail = new ResponsiveText('', 1, 0) + #selector: SearchableSelector | undefined + #profiles: readonly ProfileSummary[] = [] + #focused = false + #busy = false + #refreshGeneration = 0 + #activeProfileId: string | undefined + + constructor(theme: BraidTheme, options: ProfileEditorOptions = {}) { + super() + this.#theme = theme + this.#controller = options.controller + this.#nextOperationId = options.nextOperationId + this.#onCancel = options.onCancel + if (this.#controller !== undefined) { + this.#buildSelector(options.query ?? '') + void this.#refresh().catch((error: unknown) => { + this.#setStatus( + this.#profiles.length > 0 + ? `Refresh unavailable · showing the last profile list · ${profileErrorMessage(error, 'refresh failed')}` + : `Profiles unavailable · refresh failed · ${profileErrorMessage(error, 'try again')}`, + ) + }) + } + } + + get focused(): boolean { + return this.#focused + } + + set focused(value: boolean) { + this.#focused = value + if (this.#selector) this.#selector.focused = value + } + + handleInput(data: string): void { + this.#selector?.handleInput(data) + } + + setView(view: BraidViewModel): void { + if (this.#controller !== undefined) return + this.clear() + for (const child of profileEditorChildren(this.#theme, view)) this.addChild(child) + this.invalidate() + } + + #buildSelector(query: string): void { + if (this.#controller === undefined) return + this.#selector = new SearchableSelector({ + title: 'profiles', + items: [], + query, + maxVisible: 5, + footer: 'enter · ^V valid · ^S save · esc', + 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?.(), + }) + this.#selector.focused = this.#focused + this.#renderController('Loading profiles…') + } + + async #refresh(notice?: string): Promise { + const generation = ++this.#refreshGeneration + const result = await within( + this.#dispatch({ type: 'headless-command', command: 'list_profiles', params: {} }), + PROFILE_REFRESH_TIMEOUT_MS, + ) + if (generation !== this.#refreshGeneration) return + if (result === REFRESH_TIMEOUT) { + this.#setStatus( + this.#profiles.length > 0 + ? 'Refresh unavailable · showing the last profile list' + : 'Profiles unavailable · refresh timed out', + ) + return + } + if (result.kind !== 'accepted') { + this.#setStatus( + this.#profiles.length > 0 + ? `Refresh unavailable · showing the last profile list · ${actionMessage(result, '')}` + : actionMessage(result, 'Profiles unavailable'), + ) + return + } + this.#profiles = [...profileSummariesFrom(result.data)] + const activeName = this.#controller?.view().profileName + const tracked = this.#profiles.find((profile) => profile.id === this.#activeProfileId) + const active = + tracked?.name === activeName + ? tracked + : this.#profiles.filter((profile) => profile.name === activeName).length === 1 + ? this.#profiles.find((profile) => profile.name === activeName) + : undefined + this.#activeProfileId = active?.id + this.#selector?.setItems(profileItems(this.#profiles, activeName, this.#activeProfileId)) + this.#setDetail(profileDetailLines(active).join('\n'), profileCompactDetail(active)) + this.#setStatus( + notice ?? + (this.#profiles.length === 0 + ? 'No profiles found' + : active === undefined + ? 'Profiles ready · choose one' + : `Active profile · ${safe(active.name)}`), + ) + } + + async #select(item: SelectItem): Promise { + if (this.#busy) return + this.#busy = true + this.#setStatus(`Selecting ${safe(profileItemName(item))}…`) + try { + const result = await this.#dispatch( + selectProfileIntent(item, this.#operationId(), this.#controller?.view().revision ?? 0), + ) + if (result.kind === 'accepted') { + this.#activeProfileId = item.value + await this.#refresh(`Selected ${safe(profileItemName(item))} · next runs use it`) + } else { + this.#setStatus(actionMessage(result, 'Profile selection failed')) + } + } catch (error) { + this.#setStatus(profileErrorMessage(error, 'Profile selection failed')) + } finally { + this.#busy = false + } + } + + async #validate(item: SelectItem): Promise { + if (this.#busy) return + this.#busy = true + this.#setStatus(`Validating ${safe(profileItemName(item))}…`) + try { + const result = await this.#dispatch(validateProfileIntent(item)) + if (result.kind !== 'accepted') { + this.#setStatus(actionMessage(result, 'Profile validation failed')) + return + } + const report = readValidationReport(result.data) + if (report === undefined) { + this.#setStatus('Profile validation returned no report') + return + } + const issues = report.issues + .slice(0, 4) + .map( + (issue) => + `${issue.level} · ${issue.path === undefined ? issue.code : issue.path} · ${issue.message}`, + ) + const compatibility = readProfileCompatibility(result.data) + const compatibilityLines = profileCompatibilityTextLines(compatibility, 120) + const modelUnsupported = compatibility?.compatibility?.modelSupported === false + this.#setDetail( + [ + report.ok ? 'valid · this profile can be used' : 'invalid · fix the reported fields', + ...issues, + ...compatibilityLines, + ] + .map(sanitizeTerminalText) + .join('\n'), + report.ok + ? modelUnsupported + ? 'valid profile · choose a compatible runner or model' + : 'valid · this profile can be used' + : 'invalid · fix the reported fields', + ) + this.#setStatus( + report.ok + ? modelUnsupported + ? 'Profile valid · runner/model choice required' + : 'Profile valid' + : 'Profile needs attention', + ) + } catch (error) { + this.#setStatus(profileErrorMessage(error, 'Profile validation failed')) + } finally { + this.#busy = false + } + } + + 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({ + kind: 'error', + code: 'UI_NOT_CONNECTED', + message: 'Profile actions require an application controller', + retryable: false, + }) + } + return this.#controller.dispatch(intent) + } + + #operationId(): string { + return this.#nextOperationId?.() ?? `profile-ui-${Date.now()}` + } + + #renderController(status?: string): void { + if (status !== undefined) this.#status.setText(sanitizeTerminalText(status)) + this.clear() + this.addChild(this.#status) + this.addChild(this.#detail) + if (this.#selector) this.addChild(this.#selector) + this.invalidate() + } + + #setDetail(detail: string, compactDetail = detail): void { + this.#detail.setText( + this.#theme.muted(sanitizeTerminalText(detail)), + this.#theme.muted(sanitizeTerminalText(compactDetail)), + ) + this.#renderController() + } + + #setStatus(status: string): void { + this.#status.setText(sanitizeTerminalText(status)) + this.#renderController() + } +} diff --git a/src/views/tui/safe-markdown.ts b/src/views/tui/safe-markdown.ts new file mode 100644 index 0000000..f82a6a1 --- /dev/null +++ b/src/views/tui/safe-markdown.ts @@ -0,0 +1,91 @@ +import { + type Component, + type DefaultTextStyle, + Markdown, + type MarkdownOptions, + type MarkdownTheme, +} from '@earendil-works/pi-tui' +import { redactSensitiveUrls, sanitizeUrl } from '../shared/sanitize.js' + +// biome-ignore lint/complexity/useRegexLiterals: a literal OSC parser is rejected as containing controls +const OSC_8 = new RegExp( + String.raw`\u001b\]8;[^;\u0007\u001b]*;([^\u0007\u001b]*)(?:\u0007|\u001b\\)`, + 'gu', +) + +export interface SafeMarkdownOptions { + readonly allowHyperlinks?: boolean +} + +function safeTheme(theme: MarkdownTheme): MarkdownTheme { + return { + ...theme, + linkUrl: (text) => { + const match = /^\s*\((.*)\)\s*$/su.exec(text) + const safe = match?.[1] ? sanitizeUrl(match[1]) : undefined + return safe ? theme.linkUrl(` (${safe})`) : theme.linkUrl(' ([link removed])') + }, + } +} + +export function sanitizeRenderedMarkdown(input: string): string { + const links = input.replace(OSC_8, (sequence, href: string) => { + if (!href) return sequence + const safe = sanitizeUrl(href) + return safe ? `\u001b]8;;${safe}\u001b\\` : '' + }) + return redactSensitiveUrls(links) +} + +function stripHyperlinks(input: string): string { + let activeHref: string | undefined + return input.replace(OSC_8, (_sequence, href: string) => { + if (href) { + activeHref = sanitizeUrl(href) + return '' + } + const suffix = activeHref === undefined ? '' : ` (${activeHref})` + activeHref = undefined + return suffix + }) +} + +export class SafeMarkdown implements Component { + readonly #markdown: Markdown + readonly #allowHyperlinks: boolean + + constructor( + text: string, + paddingX: number, + paddingY: number, + theme: MarkdownTheme, + defaultTextStyle?: DefaultTextStyle, + options?: MarkdownOptions, + safeOptions: SafeMarkdownOptions = {}, + ) { + this.#allowHyperlinks = safeOptions.allowHyperlinks ?? true + this.#markdown = new Markdown( + text, + paddingX, + paddingY, + safeTheme(theme), + defaultTextStyle, + options, + ) + } + + setText(text: string): void { + this.#markdown.setText(text) + } + + invalidate(): void { + this.#markdown.invalidate() + } + + render(width: number): string[] { + return this.#markdown.render(width).map((line) => { + const sanitized = sanitizeRenderedMarkdown(line) + return this.#allowHyperlinks ? sanitized : stripHyperlinks(sanitized) + }) + } +} diff --git a/src/views/tui/selector.ts b/src/views/tui/selector.ts new file mode 100644 index 0000000..9470b4c --- /dev/null +++ b/src/views/tui/selector.ts @@ -0,0 +1,188 @@ +import { + Container, + type Focusable, + fuzzyFilter, + Input, + matchesKey, + type SelectItem, + SelectList, + Spacer, + Text, +} from '@earendil-works/pi-tui' +import type { SelectorView } from '../shared/models.js' +import { sanitizeTerminalText } from '../shared/sanitize.js' +import type { BraidTheme } from './theme.js' + +export interface SearchableSelectorOptions { + readonly title: string + readonly items: readonly SelectItem[] + readonly theme: BraidTheme + readonly maxVisible?: number + readonly query?: string + readonly selectedValue?: string + readonly footer?: string + readonly onAction?: (key: string, item: SelectItem | null) => void + readonly onSelect: (item: SelectItem) => void + readonly onCancel: () => void +} + +function safeItem(item: SelectItem): SelectItem { + return { + ...item, + label: sanitizeTerminalText(item.label), + ...(item.description === undefined + ? {} + : { description: sanitizeTerminalText(item.description) }), + } +} + +export class SearchableSelector extends Container implements Focusable { + readonly #input = new Input() + #list: SelectList + readonly #title: Text + readonly #footer: Text + readonly #onSelect: (item: SelectItem) => void + readonly #onCancel: () => void + readonly #onAction: SearchableSelectorOptions['onAction'] + #items: readonly SelectItem[] + readonly #theme: BraidTheme + readonly #maxVisible: number + #focused = false + readonly #selectedValue: string | undefined + + constructor(options: SearchableSelectorOptions) { + super() + this.#items = options.items.map(safeItem) + this.#theme = options.theme + this.#maxVisible = Math.max(1, Math.min(10, Math.floor(options.maxVisible ?? 8))) + this.#selectedValue = options.selectedValue + this.#onSelect = options.onSelect + this.#onCancel = options.onCancel + this.#onAction = options.onAction + this.#title = new Text(options.theme.brand(sanitizeTerminalText(options.title)), 1, 0) + this.#footer = new Text( + options.theme.muted( + sanitizeTerminalText(options.footer ?? 'type to filter · enter to choose · esc to close'), + ), + 1, + 0, + ) + this.#list = this.#createList() + this.#input.setValue(options.query ?? '') + this.#input.onSubmit = () => { + const item = this.#list.getSelectedItem() + if (item) this.#onSelect(item) + } + this.#input.onEscape = () => this.#onCancel() + this.#list.onSelect = (item) => this.#onSelect(item) + this.#list.onCancel = () => this.#onCancel() + this.addChild(this.#title) + this.addChild(new Spacer(1)) + this.addChild(this.#input) + this.addChild(new Spacer(1)) + this.addChild(this.#list) + this.addChild(new Spacer(1)) + this.addChild(this.#footer) + this.#applyFilter() + } + + get focused(): boolean { + return this.#focused + } + + set focused(value: boolean) { + this.#focused = value + this.#input.focused = value + } + + setItems(items: readonly SelectItem[]): void { + this.#items = items.map(safeItem) + this.#applyFilter() + } + + setQuery(query: string): void { + this.#input.setValue(query) + this.#applyFilter() + this.invalidate() + } + + setFooter(footer: string): void { + this.#footer.setText(this.#theme.muted(sanitizeTerminalText(footer))) + this.invalidate() + } + + handleInput(data: string): void { + if (this.#onAction !== undefined) { + const key = actionKey(data) + if (key !== undefined) { + this.#onAction(key, this.#list.getSelectedItem()) + return + } + } + if ( + matchesKey(data, 'up') || + matchesKey(data, 'down') || + matchesKey(data, 'pageUp') || + matchesKey(data, 'pageDown') + ) { + this.#list.handleInput(data) + return + } + if (matchesKey(data, 'enter')) { + this.#list.handleInput(data) + return + } + if (matchesKey(data, 'escape') || matchesKey(data, 'ctrl+c')) { + this.#onCancel() + return + } + this.#input.handleInput(data) + this.#applyFilter() + } + + #applyFilter(): void { + const previous = this.#list.getSelectedItem()?.value ?? this.#selectedValue + const query = this.#input.getValue().trim().toLocaleLowerCase() + const items = fuzzyFilter([...this.#items], query, (item) => + [item.label, item.value, item.description] + .filter((value): value is string => value !== undefined) + .join(' '), + ) + const oldList = this.#list + this.#list = this.#createList(items) + const index = this.children.indexOf(oldList) + if (index >= 0) this.children[index] = this.#list + if (previous) { + const selectedIndex = items.findIndex((item) => item.value === previous) + if (selectedIndex >= 0) this.#list.setSelectedIndex(selectedIndex) + } + this.invalidate() + } + + #createList(items: readonly SelectItem[] = this.#items): SelectList { + const list = new SelectList([...items], this.#maxVisible, this.#theme.select) + list.onSelect = (item) => this.#onSelect(item) + list.onCancel = () => this.#onCancel() + return list + } +} + +function actionKey(data: string): string | undefined { + if (matchesKey(data, 'ctrl+v')) return 'validate' + if (matchesKey(data, 'ctrl+s')) return 'save' + if (matchesKey(data, 'ctrl+t')) return 'test' + if (matchesKey(data, 'ctrl+r')) return 'rename' + if (matchesKey(data, 'ctrl+a')) return 'archive' + if (matchesKey(data, 'ctrl+d')) return 'delete' + return undefined +} + +export function selectorItems(view: SelectorView): SelectItem[] { + return view.items.map((item) => ({ + value: item.id, + label: sanitizeTerminalText(item.label), + ...((item.unavailableReason ?? item.description) + ? { description: sanitizeTerminalText(item.unavailableReason ?? item.description ?? '') } + : {}), + })) +} diff --git a/src/views/tui/terminal-app.ts b/src/views/tui/terminal-app.ts index 426aace..71dd3bd 100644 --- a/src/views/tui/terminal-app.ts +++ b/src/views/tui/terminal-app.ts @@ -1,252 +1,216 @@ -import { - Box, - CombinedAutocompleteProvider, - Container, - Editor, - Markdown, - matchesKey, - Spacer, - Text, - type TUI, -} from '@earendil-works/pi-tui' -import type { BraidApplication } from '../../app/application.js' -import { buildAppView, type AppView, type MessageView } from '../../app/view-model.js' -import type { BraidState } from '../../domain/state.js' -import { CommandPalette, type PaletteCommand } from './command-palette.js' +import { CombinedAutocompleteProvider, type Editor, type TUI } from '@earendil-works/pi-tui' +import { commandItems } from '../shared/command-registry.js' +import type { BraidIntent, BraidUiController, UiDispatchResult } from '../shared/intents.js' +import type { BraidViewModel } from '../shared/models.js' +import { sanitizeTitle } from '../shared/sanitize.js' +import { GuardedAutocompleteProvider } from './autocomplete-guard.js' +import type { TerminalConfigurationOptions } from './configuration-wizard.js' +import { type BraidKeymap, resolveKeymap } from './keyboard.js' +import { ModalCoordinator } from './modal-coordinator.js' +import { TerminalCommandController } from './terminal-command-controller.js' +import { installTerminalOutputPolicy, keyboardCompatibility } from './terminal-compatibility.js' +import { TerminalDraftController } from './terminal-drafts.js' +import { TerminalInputController } from './terminal-input-controller.js' +import { TerminalInteractionController } from './terminal-interaction-controller.js' +import { TerminalOverlayController } from './terminal-overlays.js' +import { BraidShell } from './terminal-shell.js' import type { BraidTheme } from './theme.js' export interface BraidTerminalOptions { - readonly app: BraidApplication + readonly controller: BraidUiController readonly tui: TUI readonly theme: BraidTheme readonly workspace: string readonly nextOperationId: () => string + readonly keymap?: BraidKeymap + readonly configuration?: TerminalConfigurationOptions + readonly startupMessages?: readonly { readonly title: string; readonly reason: string }[] } export class BraidTerminalApp { - readonly #app: BraidApplication + readonly #controller: BraidUiController readonly #tui: TUI readonly #theme: BraidTheme - readonly #transcript = new Container() - readonly #dock = new Container() - readonly #editor: Editor - readonly #status = new Text('', 1, 0) + readonly #workspace: string readonly #nextOperationId: () => string + readonly #shell: BraidShell + readonly #drafts: TerminalDraftController + readonly #commands: TerminalCommandController + readonly #input: TerminalInputController + readonly #interactions: TerminalInteractionController + readonly #modals: ModalCoordinator + readonly #overlays: TerminalOverlayController readonly #done: Promise readonly #resolveDone: () => void readonly #unsubscribe: () => void readonly #removeInputListener: () => void - #overlayClose: (() => void) | undefined - #quitTimer: ReturnType | undefined - #quitArmed = false #stopped = false + #openConfigurationOnStart = false + readonly #restoreTerminalOutputPolicy: () => void constructor(options: BraidTerminalOptions) { - this.#app = options.app + this.#controller = options.controller this.#tui = options.tui this.#theme = options.theme + this.#workspace = options.workspace this.#nextOperationId = options.nextOperationId + this.#restoreTerminalOutputPolicy = installTerminalOutputPolicy( + options.tui.terminal, + !options.theme.terminalMetadata, + ) + const keymapResolution = options.keymap + ? { keymap: options.keymap, valid: true, diagnostics: Object.freeze([]) } + : resolveKeymap() + const keymapDiagnostic = keymapResolution.valid + ? undefined + : keymapResolution.diagnostics.join('; ') + this.#openConfigurationOnStart = options.configuration?.openOnStart === true + this.#modals = new ModalCoordinator(options.tui) let resolveDone: () => void = () => {} this.#done = new Promise((resolve) => { resolveDone = resolve }) this.#resolveDone = resolveDone - this.#editor = new Editor(this.#tui, this.#theme.editor, { paddingX: 1 }) - this.#editor.setAutocompleteProvider( + const autocomplete = new GuardedAutocompleteProvider( new CombinedAutocompleteProvider( - [ - { name: 'help', description: 'Keyboard and commands' }, - { name: 'quit', description: 'Close Braid' }, - ], + commandItems(this.#controller.view().capabilities).map((item) => ({ + name: item.value, + description: item.description ?? '', + })), options.workspace, null, ), ) - this.#editor.onSubmit = (text) => this.#submit(text) - - this.#dock.addChild(this.#editor) - this.#dock.addChild(this.#status) - this.#mountLayout() - this.#unsubscribe = this.#app.subscribe((state) => this.#render(state)) - this.#removeInputListener = this.#tui.addInputListener((data) => this.#handleGlobalInput(data)) - this.#render(this.#app.state()) + this.#shell = new BraidShell( + options.tui, + options.theme, + () => options.tui.terminal.rows, + (text) => this.#submit(text), + (text) => { + autocomplete.inputChanged() + this.#drafts.changed(text) + }, + ) + this.#drafts = new TerminalDraftController({ + editor: this.#shell.editor, + dispatch: (intent) => this.#dispatch(intent), + nextOperationId: this.#nextOperationId, + requestRender: () => this.#tui.requestRender(), + }) + this.#overlays = new TerminalOverlayController({ + theme: options.theme, + controller: options.controller, + modals: this.#modals, + editor: this.#shell.editor, + nextOperationId: this.#nextOperationId, + keyboardDiagnostic: () => keyboardCompatibility(options.tui.terminal).message, + keymapDiagnostic: () => keymapDiagnostic, + ...(options.configuration === undefined ? {} : { configuration: options.configuration }), + dispatchCommand: (command, args) => this.#commands.dispatchCommand(command, args), + openSurface: (surface) => this.#overlays.openSurface(surface), + openHelp: (query) => this.#overlays.openHelp(query), + requestRender: () => this.#tui.requestRender(), + }) + this.#interactions = new TerminalInteractionController({ + theme: this.#theme, + modals: this.#modals, + nextOperationId: this.#nextOperationId, + dispatch: (intent) => this.#dispatch(intent), + currentView: () => this.#controller.view(), + isStopped: () => this.#stopped, + }) + this.#input = new TerminalInputController({ + tui: this.#tui, + keymap: keymapResolution.keymap, + controller: this.#controller, + shell: this.#shell, + drafts: this.#drafts, + overlays: this.#overlays, + modals: this.#modals, + nextOperationId: this.#nextOperationId, + dispatch: (intent) => this.#dispatch(intent), + interactionOpen: () => this.#interactions.isOpen, + stop: () => this.stop(), + stateChanged: () => this.#render(this.#controller.view()), + }) + this.#commands = new TerminalCommandController({ + controller: this.#controller, + editor: this.#shell.editor, + drafts: this.#drafts, + overlays: this.#overlays, + nextOperationId: this.#nextOperationId, + dispatch: (intent, restoreText) => this.#dispatch(intent, restoreText), + isStopped: () => this.#stopped, + stop: () => this.stop(), + showActivity: () => this.#input.showActivity(), + }) + this.#shell.editor.setAutocompleteProvider(autocomplete) + options.tui.addChild(this.#shell) + this.#unsubscribe = this.#controller.subscribe((view) => this.#render(view)) + this.#removeInputListener = this.#tui.addInputListener((data) => this.#input.handle(data)) + this.#render(this.#controller.view()) + if (keymapDiagnostic) { + this.#overlays.openUnavailable('key mappings unavailable', keymapDiagnostic) + } + for (const message of options.startupMessages ?? []) + this.#overlays.openUnavailable(message.title, message.reason) } get editor(): Editor { - return this.#editor + return this.#shell.editor } start(): Promise { - this.#tui.setFocus(this.#editor) + if (this.#theme.terminalMetadata) + this.#tui.terminal.setTitle(sanitizeTitle(`Braid — ${this.#workspace}`)) this.#tui.start() + if (this.#modals.hasOpen()) this.#modals.focusTop() + else this.#tui.setFocus(this.#shell.editor) + if (this.#openConfigurationOnStart) { + this.#openConfigurationOnStart = false + this.#overlays.openConfiguration() + } return this.#done } stop(): void { if (this.#stopped) return this.#stopped = true - if (this.#quitTimer) clearTimeout(this.#quitTimer) + this.#input.close() + this.#drafts.close() + this.#modals.closeAll() this.#removeInputListener() this.#unsubscribe() this.#tui.stop() + this.#restoreTerminalOutputPolicy() this.#resolveDone() } - #mountLayout(): void { - this.#tui.addChild(this.#transcript) - this.#tui.addChild(this.#dock) - } - - #render(state: BraidState): void { - const view = buildAppView(state) - this.#transcript.clear() - this.#transcript.addChild(this.#header(view)) - if (view.hiddenMessageCount > 0) { - this.#transcript.addChild( - new Text(this.#theme.muted(`${view.hiddenMessageCount} earlier messages hidden`), 1, 0), - ) - } - for (const message of view.messages) this.#transcript.addChild(this.#message(message)) - if (view.messages.length === 0) { - this.#transcript.addChild(new Spacer(1)) - this.#transcript.addChild( - new Text(this.#theme.muted('Write a message, or press Ctrl+P for commands.'), 1, 0), - ) - } - - const effectiveStatus = this.#quitArmed ? 'aborted' : view.status - const statusColor = - effectiveStatus === 'failed' - ? this.#theme.danger - : effectiveStatus === 'running' || - effectiveStatus === 'blocked' || - effectiveStatus === 'aborted' - ? this.#theme.warning - : this.#theme.success - const statusText = this.#quitArmed ? 'press ctrl+c again to quit' : view.statusText - this.#status.setText( - `${statusColor(statusText)} ${this.#theme.muted('ctrl+p commands · ctrl+c clear/cancel/quit')}`, - ) - this.#editor.disableSubmit = view.status === 'running' - this.#editor.borderColor = view.status === 'running' ? this.#theme.warning : this.#theme.accent + #render(view: BraidViewModel): void { + this.#drafts.restore(view) + this.#shell.setView(view, this.#input.quitArmed) + this.#shell.setActivityVisible(this.#input.activityVisible) + this.#interactions.sync(view) this.#tui.requestRender() } - #header(view: AppView): Text { - return new Text( - `${this.#theme.brand('braid')} ${this.#theme.text(view.profileName)} ${this.#theme.muted( - `${view.runner} · ${view.connection}`, - )}`, - 1, - 1, - ) - } - - #message(message: MessageView): Container { - const container = new Container() - if (message.role === 'user') { - const box = new Box(1, 0, this.#theme.userBackground) - box.addChild(new Markdown(message.text, 0, 0, this.#theme.markdown)) - container.addChild(box) - return container - } - - container.addChild(new Spacer(1)) - if (message.text) { - container.addChild(new Markdown(message.text, 1, 0, this.#theme.markdown)) - } else if (message.status === 'streaming') { - container.addChild(new Text(this.#theme.muted('Working…'), 1, 0)) - } - if (message.status === 'failed' || message.status === 'blocked') { - container.addChild(new Text(this.#theme.danger(message.status), 1, 0)) - } else if (message.status === 'aborted') { - container.addChild(new Text(this.#theme.warning('cancelled'), 1, 0)) - } - return container - } - #submit(rawText: string): void { - const text = rawText.trim() - if (!text) return - if (text === '/quit') { - this.stop() - return - } - if (text === '/help') { - this.#openPalette() - return - } - - this.#editor.addToHistory(rawText) - this.#editor.setText('') - try { - const receipt = this.#app.send({ operationId: this.#nextOperationId(), text: rawText }) - void receipt.completion.finally(() => { - this.#editor.disableSubmit = false - this.#tui.requestRender() - }) - } catch { - this.#editor.setText(rawText) - this.#tui.requestRender() - } + this.#commands.submit(rawText) } - #handleGlobalInput(data: string): { consume?: boolean } | undefined { - if (!matchesKey(data, 'ctrl+c')) this.#disarmQuit() - if (matchesKey(data, 'ctrl+p')) { - this.#openPalette() - return { consume: true } - } - if (matchesKey(data, 'ctrl+c') && !this.#tui.hasOverlay()) { - if (this.#editor.getText()) { - this.#editor.setText('') - return { consume: true } + #dispatch(intent: BraidIntent, restoreText?: string): Promise { + return this.#controller.dispatch(intent).then((result) => { + if (this.#stopped) return result + if (result.kind === 'unavailable') { + this.#overlays.openUnavailable('Unavailable', result.reason) + if (restoreText) this.#shell.editor.setText(restoreText) + } else if (result.kind === 'error') { + this.#overlays.openUnavailable(result.code, result.message) + if (restoreText) this.#shell.editor.setText(restoreText) } - if (this.#app.cancelActive()) return { consume: true } - if (this.#quitArmed) this.stop() - else this.#armQuit() - return { consume: true } - } - return undefined - } - - #armQuit(): void { - this.#quitArmed = true - if (this.#quitTimer) clearTimeout(this.#quitTimer) - this.#quitTimer = setTimeout(() => { - this.#quitTimer = undefined - this.#quitArmed = false - this.#render(this.#app.state()) - }, 2_000) - this.#render(this.#app.state()) - } - - #disarmQuit(): void { - if (!this.#quitArmed) return - this.#quitArmed = false - if (this.#quitTimer) clearTimeout(this.#quitTimer) - this.#quitTimer = undefined - this.#render(this.#app.state()) - } - - #openPalette(): void { - if (this.#tui.hasOverlay()) return - const palette = new CommandPalette(this.#theme, (command) => this.#handlePalette(command)) - const handle = this.#tui.showOverlay(palette, { - anchor: 'center', - width: '70%', - minWidth: 28, - maxHeight: 12, + this.#tui.requestRender() + return result }) - this.#overlayClose = () => { - handle.hide() - this.#overlayClose = undefined - } - } - - #handlePalette(command: PaletteCommand): void { - this.#overlayClose?.() - if (command === 'quit') this.stop() } } diff --git a/src/views/tui/terminal-chrome.ts b/src/views/tui/terminal-chrome.ts new file mode 100644 index 0000000..2467921 --- /dev/null +++ b/src/views/tui/terminal-chrome.ts @@ -0,0 +1,245 @@ +import { type Component, truncateToWidth, visibleWidth } from '@earendil-works/pi-tui' +import type { BraidViewModel } from '../shared/models.js' +import { sanitizeNotification, sanitizeTerminalText } from '../shared/sanitize.js' +import { type LayoutMode, modeForColumns } from './layout.js' +import type { BraidTheme } from './theme.js' + +export interface TerminalChromeState { + readonly view: BraidViewModel + readonly quitArmed: boolean + readonly activityVisible: boolean + readonly navigationHint: string +} + +/** Fixed-height terminal chrome; the transcript never owns these lines. */ +export class TerminalChrome implements Component { + #theme: BraidTheme + #state: TerminalChromeState | undefined + + constructor(theme: BraidTheme) { + this.#theme = theme + } + + setState(state: TerminalChromeState): void { + this.#state = state + } + + invalidate(): void {} + + render(width: number): string[] { + const state = this.#state + if (!state) return [] + const safeWidth = Math.max(1, Math.floor(width)) + const { view } = state + const mode = modeForColumns(safeWidth) + const header = this.#header(view, safeWidth, mode) + const status = fitAtomic( + statusText(this.#theme, view, conciseStatus(view, state.quitArmed)), + safeWidth, + ) + const profile = valuePart(this.#theme, view.profileName) + const runner = valuePart(this.#theme, `runner ${view.runner}`) + const model = valuePart(this.#theme, view.model) + const effort = view.effort ? valuePart(this.#theme, `thinking ${view.effort}`) : '' + const connection = valuePart(this.#theme, view.connection) + const metrics = valuePart(this.#theme, metricsFor(view).join(' · ')) + const hint = valuePart(this.#theme, navigationHint(view, state.navigationHint)) + + if (mode === 'narrow') { + return [header, status, joinPrefix([profile], safeWidth)] + } + + const statusMetadata = mode === 'wide' ? [model, effort, metrics] : [model, effort] + return [ + header, + fitColumns([status], statusMetadata, safeWidth), + fitColumns([profile, runner, connection], [hint], safeWidth), + ] + } + + #header(view: BraidViewModel, width: number, mode: LayoutMode): string { + const workspace = fieldPart(this.#theme, 'cwd', workspaceBasename(view.workspace)) + const session = fieldPart( + this.#theme, + 'session', + cleanField(view.conversationTitle) || 'new conversation', + ) + const branch = fieldPart(this.#theme, 'branch', view.branch) + const parts = [this.#theme.brand('braid'), workspace] + if (mode !== 'narrow') parts.push(session) + if (mode === 'wide' && branch) parts.push(branch) + return joinHeader(parts, width) + } +} + +function statusText(theme: BraidTheme, view: BraidViewModel, value: string): string { + const safe = sanitizeNotification(value) + if (view.status === 'failed' || view.status === 'storage-failure') return theme.danger(safe) + if (view.status === 'running' || view.status === 'waiting' || view.status === 'cancelling') + return theme.warning(safe) + if (view.status === 'cancelled' || view.status === 'expired') return theme.warning(safe) + return theme.success(safe) +} + +function conciseStatus(view: BraidViewModel, quitArmed: boolean): string { + if (quitArmed) return 'ctrl+c again to quit' + const notification = sanitizeNotification(view.notice ?? view.statusText) + if ( + view.activeRunId !== undefined && + (view.status === 'starting' || + view.status === 'streaming' || + view.status === 'running' || + view.status === 'waiting') + ) { + return `${notification || view.status} · Ctrl+C cancel` + } + if (view.status === 'failed' || view.status === 'unknown') { + const run = view.runs.at(-1) + if (run !== undefined) { + const outcome = run.completeness === 'unknown' ? 'outcome unknown' : 'outcome failed' + return `${outcome} · operation ${shortIdentifier(run.operationId ?? run.id)}` + } + } + if (notification.length > 0) return notification + return cleanField(view.status) || 'unknown' +} + +function navigationHint(view: BraidViewModel, fallback: string): string { + if (view.activeRunId !== undefined) return 'Enter queues input' + if (view.status === 'failed' || view.status === 'unknown') { + return '/export preserve · /new continue' + } + return fallback +} + +function shortIdentifier(value: string): string { + const safe = cleanField(value) + return safe.length <= 18 ? safe : `${safe.slice(0, 10)}…${safe.slice(-6)}` +} + +function cleanField(value: string | null | undefined): string { + return value === undefined || value === null + ? '' + : sanitizeTerminalText(value) + .replace(/[\n\t]+/gu, ' ') + .replace(/\s+/gu, ' ') + .trim() +} + +function workspaceBasename(workspace: string | null | undefined): string { + const safe = cleanField(workspace) + if (!safe) return 'workspace' + const trimmed = safe.replace(/[\\/]+$/gu, '') + if (!trimmed) return 'workspace' + const pieces = trimmed.split(/[\\/]/u).filter((piece) => piece.length > 0) + if (pieces.length <= 1) return safe + return pieces.at(-1) ?? 'workspace' +} + +function valuePart(theme: BraidTheme, value: string): string { + const safe = cleanField(value) + return safe ? theme.text(safe) : '' +} + +function fieldPart(theme: BraidTheme, label: string, value: string | null | undefined): string { + const safeLabel = cleanField(label) + const safeValue = cleanField(value) + if (!safeLabel || !safeValue) return '' + return `${theme.muted(safeLabel)} ${theme.text(safeValue)}` +} + +function joinHeader(parts: readonly string[], width: number): string { + const present = parts.filter((part) => part.length > 0) + if (present.length === 0) return '' + const base = joinPrefix(present.slice(0, 2), width, ' ') + if (present.length <= 2 || !base) return base + let result = base + for (const part of present.slice(2)) { + const candidate = `${result} · ${part}` + if (visibleWidth(candidate) > width) break + result = candidate + } + return result +} + +function joinPrefix(parts: readonly string[], width: number, separator = ' · '): string { + const present = parts.filter((part) => part.length > 0) + let result = '' + for (const part of present) { + const candidate = result ? `${result}${separator}${part}` : part + if (visibleWidth(candidate) > width) break + result = candidate + } + return result +} + +function fitAtomic(value: string, width: number): string { + return truncateToWidth(value, width, '…') +} + +function prefixes(parts: readonly string[], width: number, separator: string): readonly string[] { + const present = parts.filter((part) => part.length > 0) + const result = [''] + let current = '' + for (const part of present) { + const candidate = current ? `${current}${separator}${part}` : part + if (visibleWidth(candidate) > width) break + result.push(candidate) + current = candidate + } + return result +} + +export function metricsFor(view: BraidViewModel): string[] { + const input = sumKnown(view.runs.map((run) => run.usage?.input)) + const output = sumKnown(view.runs.map((run) => run.usage?.output)) + const cost = sumKnown(view.runs.map((run) => run.usage?.costUsd)) + const metrics: string[] = [] + if (input !== undefined) metrics.push(`in ${compactNumber(input)}`) + if (output !== undefined) metrics.push(`out ${compactNumber(output)}`) + if (cost !== undefined && Number.isFinite(cost)) metrics.push(`$${cost.toFixed(4)}`) + return metrics +} + +function sumKnown(values: readonly (number | undefined)[]): number | undefined { + let total = 0 + let known = false + for (const value of values) { + if (value === undefined || !Number.isFinite(value)) continue + total += value + known = true + } + return known ? total : undefined +} + +function compactNumber(value: number): string { + if (value < 1_000) return String(Math.round(value)) + if (value < 1_000_000) return `${(value / 1_000).toFixed(value < 10_000 ? 1 : 0)}k` + return `${(value / 1_000_000).toFixed(value < 10_000_000 ? 1 : 0)}m` +} + +function fitColumns(left: readonly string[], right: readonly string[], width: number): string { + const leftPrefixes = prefixes(left, width, ' · ') + const rightPrefixes = prefixes(right, width, ' · ') + let best = '' + let bestScore = -1 + for (let leftIndex = 0; leftIndex < leftPrefixes.length; leftIndex += 1) { + const leftValue = leftPrefixes[leftIndex] ?? '' + for (let rightIndex = 0; rightIndex < rightPrefixes.length; rightIndex += 1) { + const rightValue = rightPrefixes[rightIndex] ?? '' + if (!leftValue && !rightValue) continue + const gap = width - visibleWidth(leftValue) - visibleWidth(rightValue) + if (leftValue && rightValue && gap < 2) continue + const candidate = + leftValue && rightValue + ? `${leftValue}${' '.repeat(gap)}${rightValue}` + : leftValue || rightValue + const score = (leftValue && rightValue ? 1_000 : 0) + leftIndex * 10 + rightIndex + if (score > bestScore && visibleWidth(candidate) <= width) { + best = candidate + bestScore = score + } + } + } + return best +} diff --git a/src/views/tui/terminal-command-controller.ts b/src/views/tui/terminal-command-controller.ts new file mode 100644 index 0000000..a66cebd --- /dev/null +++ b/src/views/tui/terminal-command-controller.ts @@ -0,0 +1,147 @@ +import type { Editor } from '@earendil-works/pi-tui' +import { + type CommandName, + commandAvailability, + commandIntent, + isMutatingCommand, + parseCommandInput, +} from '../shared/command-registry.js' +import type { BraidIntent, BraidUiController, UiDispatchResult } from '../shared/intents.js' +import type { TerminalDraftController } from './terminal-drafts.js' +import type { TerminalOverlayController } from './terminal-overlays.js' + +export interface TerminalCommandControllerOptions { + readonly controller: BraidUiController + readonly editor: Editor + readonly drafts: TerminalDraftController + readonly overlays: TerminalOverlayController + readonly nextOperationId: () => string + readonly dispatch: (intent: BraidIntent, restoreText?: string) => Promise + readonly isStopped: () => boolean + readonly stop: () => void + readonly showActivity: () => void +} + +/** Owns prompt parsing and command routing; it never owns terminal layout. */ +export class TerminalCommandController { + readonly #controller: BraidUiController + readonly #editor: Editor + readonly #drafts: TerminalDraftController + readonly #overlays: TerminalOverlayController + readonly #nextOperationId: () => string + readonly #dispatch: TerminalCommandControllerOptions['dispatch'] + readonly #isStopped: () => boolean + readonly #stop: () => void + readonly #showActivity: () => void + + constructor(options: TerminalCommandControllerOptions) { + this.#controller = options.controller + this.#editor = options.editor + this.#drafts = options.drafts + this.#overlays = options.overlays + this.#nextOperationId = options.nextOperationId + this.#dispatch = options.dispatch + this.#isStopped = options.isStopped + this.#stop = options.stop + this.#showActivity = options.showActivity + } + + submit(rawText: string): void { + void this.#submitValue(rawText) + } + + dispatchCommand(command: CommandName, args: readonly string[]): void { + void this.#dispatchCommandValue(command, args) + } + + async #submitValue(rawText: string): Promise { + if (!rawText.trim()) return + const parsed = parseCommandInput(rawText) + if (parsed.kind === 'unknown') { + this.#overlays.openCorrection(parsed.name, parsed.suggestions) + return + } + if (parsed.kind === 'invalid') { + this.#overlays.openUnavailable('Invalid command', parsed.message) + return + } + if (parsed.kind === 'command') { + this.#editor.addToHistory(rawText) + this.#drafts.setText('') + await this.#drafts.flush('') + this.dispatchCommand(parsed.name, parsed.args) + return + } + + const view = this.#controller.view() + const capability = view.activeRunId + ? view.capabilities['run.queue'] + : view.capabilities['run.send'] + if (!capability?.available) { + this.#editor.setText(rawText) + this.#overlays.openUnavailable( + view.activeRunId ? 'Queue unavailable' : 'Send unavailable', + capability?.reason ?? 'The current connection cannot accept this message', + ) + return + } + this.#editor.addToHistory(rawText) + await this.#drafts.flush(rawText) + this.#drafts.setText('') + const operationId = this.#nextOperationId() + const intent: BraidIntent = view.activeRunId + ? { type: 'queue', operationId, text: parsed.text } + : { type: 'send', operationId, text: parsed.text } + void this.#dispatch(intent, rawText) + } + + async #dispatchCommandValue(command: CommandName, args: readonly string[]): Promise { + if (command === 'profile' && args.length === 0) { + this.#overlays.openProfile() + return + } + if (command === 'connection' && args.length === 0) { + this.#overlays.openConnection() + return + } + if (command !== 'approve' && command !== 'reject') { + const availability = commandAvailability(command, this.#controller.view().capabilities) + if (!availability.available) { + this.#overlays.openUnavailable( + `/${command}`, + availability.reason ?? 'Capability is unavailable', + ) + return + } + } + if (['new', 'open', 'branch', 'clone', 'fork'].includes(command)) await this.#drafts.flush() + if (command === 'open') { + this.#overlays.openConversationSelector(args.join(' ').trim()) + return + } + const operationId = isMutatingCommand(command) ? this.#nextOperationId() : undefined + const intent = commandIntent(command, args, operationId) + if (intent.type === 'open-surface') { + if (intent.surface === 'activity') this.#showActivity() + void this.#dispatch(intent).then((result) => { + if (result.kind !== 'accepted' || this.#isStopped()) return + if (intent.surface === 'help') this.#overlays.openHelp(intent.query ?? '') + else this.#overlays.openSurface(intent.surface) + }) + return + } + if (intent.type === 'shutdown') { + void this.#dispatch(intent).then((result) => { + if (result.kind === 'accepted') this.#stop() + }) + return + } + void this.#dispatch(intent).then((result) => { + if (result.kind !== 'accepted' || this.#isStopped()) return + if (command === 'fork') this.#overlays.openSurface('fork') + else if (command === 'ask' || command === 'analyze' || command === 'compare') { + this.#overlays.openIntelligenceResult(command, result.data) + } + }) + } +} diff --git a/src/views/tui/terminal-compatibility.ts b/src/views/tui/terminal-compatibility.ts new file mode 100644 index 0000000..352be94 --- /dev/null +++ b/src/views/tui/terminal-compatibility.ts @@ -0,0 +1,129 @@ +import type { Terminal } from '@earendil-works/pi-tui' + +export interface KeyboardCompatibility { + readonly kitty: boolean + readonly fallback: boolean + readonly message: string +} + +type TerminalOutputTarget = Pick + +const MAX_METADATA_BYTES = 4096 + +class TerminalMetadataFilter { + #state: 'normal' | 'escape' | 'osc' | 'oscEscape' | 'oscDiscard' | 'oscDiscardEscape' = 'normal' + #controlBytes = 0 + + push(input: string): string { + let output = '' + for (const character of input) { + if (this.#state === 'normal') { + if (character === '\u001b') this.#state = 'escape' + else if (character === '\u009d') this.#startOsc() + else output += character + continue + } + if (this.#state === 'escape') { + if (character === ']') this.#startOsc() + else { + output += `\u001b${character}` + this.#state = 'normal' + } + continue + } + if (this.#state === 'oscDiscard' || this.#state === 'oscDiscardEscape') { + this.#consumeDiscardedOsc(character) + continue + } + if (this.#state === 'osc') { + if (isOscTerminator(character)) this.#reset() + else if (character === '\u001b') + this.#state = this.#exceedsMetadataLimit() ? 'oscDiscardEscape' : 'oscEscape' + else if (this.#exceedsMetadataLimit()) this.#state = 'oscDiscard' + continue + } + if (character === '\\' || isOscTerminator(character)) this.#reset() + else if (character === '\u001b') + this.#state = this.#exceedsMetadataLimit() ? 'oscDiscardEscape' : 'oscEscape' + else this.#state = this.#exceedsMetadataLimit() ? 'oscDiscard' : 'osc' + } + return output + } + + #exceedsMetadataLimit(): boolean { + this.#controlBytes += 1 + return this.#controlBytes > MAX_METADATA_BYTES + } + + #consumeDiscardedOsc(character: string): void { + if (isOscTerminator(character)) { + this.#reset() + return + } + if (this.#state === 'oscDiscard') { + if (character === '\u001b') this.#state = 'oscDiscardEscape' + return + } + if (character === '\\') this.#reset() + else if (character !== '\u001b') this.#state = 'oscDiscard' + } + + #startOsc(): void { + this.#state = 'osc' + this.#controlBytes = 2 + } + + #reset(): void { + this.#state = 'normal' + this.#controlBytes = 0 + } +} + +function isOscTerminator(character: string): boolean { + return character === '\u0007' || character === '\u009c' +} + +export function installTerminalOutputPolicy( + terminal: TerminalOutputTarget, + suppressMetadata: boolean, +): () => void { + if (!suppressMetadata) return () => {} + const originalWrite = terminal.write.bind(terminal) + const originalSetTitle = terminal.setTitle.bind(terminal) + const originalSetProgress = terminal.setProgress.bind(terminal) + const filter = new TerminalMetadataFilter() + const filteredWrite = (data: string): void => { + const safeData = filter.push(data) + if (safeData) originalWrite(safeData) + } + terminal.write = filteredWrite + terminal.setTitle = () => {} + terminal.setProgress = () => {} + return () => { + if (terminal.write === filteredWrite) terminal.write = originalWrite + if (terminal.setTitle !== originalSetTitle) terminal.setTitle = originalSetTitle + if (terminal.setProgress !== originalSetProgress) terminal.setProgress = originalSetProgress + } +} + +export function keyboardCompatibility( + terminal: Pick & { + readonly modifyOtherKeysActive?: boolean + }, +): KeyboardCompatibility { + if (terminal.kittyProtocolActive) { + return Object.freeze({ + kitty: true, + fallback: true, + message: 'keyboard: Kitty protocol negotiated; legacy fallback remains available', + }) + } + const modifiedKeys = terminal.modifyOtherKeysActive === true + return Object.freeze({ + kitty: false, + fallback: true, + message: modifiedKeys + ? 'keyboard: Kitty protocol unavailable; modified-key legacy fallback is active' + : 'keyboard: Kitty protocol unavailable; legacy key sequences remain active', + }) +} diff --git a/src/views/tui/terminal-drafts.ts b/src/views/tui/terminal-drafts.ts new file mode 100644 index 0000000..9d50415 --- /dev/null +++ b/src/views/tui/terminal-drafts.ts @@ -0,0 +1,123 @@ +import type { Editor } from '@earendil-works/pi-tui' +import type { BraidIntent, UiDispatchResult } from '../shared/intents.js' +import type { BraidViewModel } from '../shared/models.js' + +type DraftView = Pick +type DraftIntent = Extract + +export interface TerminalDraftOptions { + readonly editor: Editor + readonly dispatch: (intent: DraftIntent) => Promise + readonly nextOperationId: () => string + readonly requestRender: () => void + readonly debounceMs?: number +} + +export class TerminalDraftController { + readonly #editor: Editor + readonly #dispatch: TerminalDraftOptions['dispatch'] + readonly #nextOperationId: () => string + readonly #requestRender: () => void + readonly #debounceMs: number + #timer: ReturnType | undefined + #tail: Promise = Promise.resolve() + #conversationId: string | undefined + #branchId: string | undefined + #persisted = '' + #suppressChange = false + + constructor(options: TerminalDraftOptions) { + this.#editor = options.editor + this.#dispatch = options.dispatch + this.#nextOperationId = options.nextOperationId + this.#requestRender = options.requestRender + this.#debounceMs = options.debounceMs ?? 150 + } + + changed(text: string): void { + this.#requestRender() + if (this.#suppressChange || !this.#conversationId || !this.#branchId) return + if (this.#timer) clearTimeout(this.#timer) + const conversationId = this.#conversationId + const branchId = this.#branchId + this.#timer = setTimeout(() => { + this.#timer = undefined + void this.#save(conversationId, branchId, text) + }, this.#debounceMs) + } + + restore(view: DraftView): void { + if (this.#conversationId === view.conversationId && this.#branchId === view.branch) { + if (this.#editor.getText() === this.#persisted && view.draft !== this.#persisted) { + this.#persisted = view.draft + this.setText(view.draft) + } + return + } + if (this.#timer) { + clearTimeout(this.#timer) + this.#timer = undefined + } + if (this.#conversationId && this.#branchId) { + const previousText = this.#editor.getText() + if (previousText !== this.#persisted) { + void this.#save(this.#conversationId, this.#branchId, previousText) + } + } + this.#conversationId = view.conversationId + this.#branchId = view.branch + this.#persisted = view.draft + this.setText(view.draft) + } + + setText(text: string): void { + this.#suppressChange = true + try { + this.#editor.setText(text) + } finally { + this.#suppressChange = false + } + } + + async flush(text = this.#editor.getText()): Promise { + if (this.#timer) { + clearTimeout(this.#timer) + this.#timer = undefined + } + if (!this.#conversationId || !this.#branchId) return + await this.#save(this.#conversationId, this.#branchId, text) + } + + close(): void { + if (this.#timer) clearTimeout(this.#timer) + this.#timer = undefined + } + + #save(conversationId: string, branchId: string, text: string): Promise { + if ( + conversationId === this.#conversationId && + branchId === this.#branchId && + text === this.#persisted + ) { + return this.#tail + } + const task = this.#tail.then(async () => { + const result = await this.#dispatch({ + type: 'set-draft', + operationId: this.#nextOperationId(), + conversationId, + branchId, + text, + }) + if ( + result.kind === 'accepted' && + conversationId === this.#conversationId && + branchId === this.#branchId + ) { + this.#persisted = text + } + }) + this.#tail = task.catch(() => undefined) + return task + } +} diff --git a/src/views/tui/terminal-input-controller.ts b/src/views/tui/terminal-input-controller.ts new file mode 100644 index 0000000..639530e --- /dev/null +++ b/src/views/tui/terminal-input-controller.ts @@ -0,0 +1,188 @@ +import 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 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' + +export interface TerminalInputControllerOptions { + readonly tui: TUI + readonly keymap: BraidKeymap + readonly controller: BraidUiController + readonly shell: BraidShell + readonly drafts: TerminalDraftController + readonly overlays: TerminalOverlayController + readonly modals: ModalCoordinator + readonly nextOperationId: () => string + readonly dispatch: (intent: BraidIntent) => Promise + readonly interactionOpen: () => boolean + readonly stop: () => void + readonly stateChanged: () => void +} + +/** Routes non-text terminal keys and owns the short-lived quit/activity state. */ +export class TerminalInputController { + readonly #tui: TUI + readonly #keymap: BraidKeymap + readonly #controller: BraidUiController + readonly #shell: BraidShell + readonly #drafts: TerminalDraftController + readonly #overlays: TerminalOverlayController + readonly #modals: ModalCoordinator + readonly #nextOperationId: () => string + readonly #dispatch: TerminalInputControllerOptions['dispatch'] + readonly #interactionOpen: () => boolean + readonly #stop: () => void + readonly #stateChanged: () => void + #quitTimer: ReturnType | undefined + #quitArmed = false + #activityVisible = false + + constructor(options: TerminalInputControllerOptions) { + this.#tui = options.tui + this.#keymap = options.keymap + this.#controller = options.controller + this.#shell = options.shell + this.#drafts = options.drafts + this.#overlays = options.overlays + this.#modals = options.modals + this.#nextOperationId = options.nextOperationId + this.#dispatch = options.dispatch + this.#interactionOpen = options.interactionOpen + this.#stop = options.stop + this.#stateChanged = options.stateChanged + } + + get quitArmed(): boolean { + return this.#quitArmed + } + + get activityVisible(): boolean { + return this.#activityVisible + } + + close(): void { + if (this.#quitTimer) clearTimeout(this.#quitTimer) + this.#quitTimer = undefined + } + + showActivity(): void { + this.#activityVisible = true + this.#stateChanged() + } + + handle(data: string): { consume?: boolean } | undefined { + if (isTextInputSequence(data)) return undefined + if (matchesKeyAction(data, this.#keymap, 'closeOverlay') && this.#tui.hasOverlay()) { + if (this.#interactionOpen()) return undefined + this.#modals.closeTop() + return { consume: true } + } + if (this.#interactionOpen()) return undefined + if (this.#tui.hasOverlay()) return undefined + if (!this.#tui.hasOverlay() && this.#shell.handleTranscriptInput(data)) { + return { consume: true } + } + if (!matchesKeyAction(data, this.#keymap, 'clearCancelQuit')) this.#disarmQuit() + if (matchesKeyAction(data, this.#keymap, 'commandPalette')) { + this.#overlays.openCommandPalette() + return { consume: true } + } + if (matchesKeyAction(data, this.#keymap, 'conversationSelector')) { + const availability = commandAvailability('open', this.#controller.view().capabilities) + if (availability.available) { + void this.#drafts.flush().then(() => this.#overlays.openSelector('conversation')) + } else { + this.#overlays.openUnavailable( + '/open', + availability.reason ?? 'Conversation search is unavailable', + ) + } + return { consume: true } + } + if (!this.#tui.hasOverlay() && matchesKeyAction(data, this.#keymap, 'previousBranch')) { + void this.#drafts.flush().then(() => this.#overlays.openAdjacentBranch(-1)) + return { consume: true } + } + if (!this.#tui.hasOverlay() && matchesKeyAction(data, this.#keymap, 'nextBranch')) { + void this.#drafts.flush().then(() => this.#overlays.openAdjacentBranch(1)) + return { consume: true } + } + if (matchesKeyAction(data, this.#keymap, 'graph')) { + this.#overlays.openSurface('graph') + return { consume: true } + } + if (matchesKeyAction(data, this.#keymap, 'switcher')) { + this.#overlays.openSelector('profile') + return { consume: true } + } + if (matchesKeyAction(data, this.#keymap, 'activity')) { + this.#activityVisible = !this.#activityVisible + this.#stateChanged() + return { consume: true } + } + if ( + !this.#tui.hasOverlay() && + matchesKeyAction(data, this.#keymap, 'toggleDetails') && + this.#shell.toggleDetails() + ) { + return { consume: true } + } + if (matchesKeyAction(data, this.#keymap, 'help') && !this.#shell.editor.focused) { + this.#overlays.openHelp('') + return { consume: true } + } + if ( + matchesKeyAction(data, this.#keymap, 'exit') && + !this.#tui.hasOverlay() && + !this.#shell.editor.getText() && + !this.#controller.view().activeRunId + ) { + this.#requestShutdown() + return { consume: true } + } + if (matchesKeyAction(data, this.#keymap, 'clearCancelQuit') && !this.#tui.hasOverlay()) { + if (this.#shell.editor.getText()) { + this.#shell.editor.setText('') + return { consume: true } + } + if (this.#controller.view().activeRunId) { + void this.#dispatch({ type: 'cancel-run', operationId: this.#nextOperationId() }) + return { consume: true } + } + if (this.#quitArmed) this.#requestShutdown() + else this.#armQuit() + return { consume: true } + } + return undefined + } + + #requestShutdown(): void { + void this.#drafts.flush().then(() => + this.#dispatch({ type: 'shutdown', operationId: this.#nextOperationId() }).then((result) => { + if (result.kind === 'accepted') this.#stop() + }), + ) + } + + #armQuit(): void { + this.#quitArmed = true + if (this.#quitTimer) clearTimeout(this.#quitTimer) + this.#quitTimer = setTimeout(() => { + this.#quitTimer = undefined + this.#quitArmed = false + this.#stateChanged() + }, 2_000) + this.#stateChanged() + } + + #disarmQuit(): void { + if (!this.#quitArmed) return + this.#quitArmed = false + if (this.#quitTimer) clearTimeout(this.#quitTimer) + this.#quitTimer = undefined + this.#stateChanged() + } +} diff --git a/src/views/tui/terminal-interaction-controller.ts b/src/views/tui/terminal-interaction-controller.ts new file mode 100644 index 0000000..afbeca4 --- /dev/null +++ b/src/views/tui/terminal-interaction-controller.ts @@ -0,0 +1,91 @@ +import type { BraidIntent, UiDispatchResult } from '../shared/intents.js' +import type { BraidViewModel, InteractionView } from '../shared/models.js' +import { InteractionShell } from './interaction.js' +import type { ModalCoordinator } from './modal-coordinator.js' +import type { BraidTheme } from './theme.js' + +export interface TerminalInteractionControllerOptions { + readonly theme: BraidTheme + readonly modals: ModalCoordinator + readonly nextOperationId: () => string + readonly dispatch: (intent: BraidIntent) => Promise + readonly currentView: () => BraidViewModel + readonly isStopped: () => boolean +} + +/** Owns pending-interaction presentation and replay after a failed response. */ +export class TerminalInteractionController { + readonly #theme: BraidTheme + readonly #modals: ModalCoordinator + readonly #nextOperationId: () => string + readonly #dispatch: TerminalInteractionControllerOptions['dispatch'] + readonly #currentView: () => BraidViewModel + readonly #isStopped: () => boolean + #open = false + #interactionKey: string | undefined + #pendingInteractionKey: string | undefined + + constructor(options: TerminalInteractionControllerOptions) { + this.#theme = options.theme + this.#modals = options.modals + this.#nextOperationId = options.nextOperationId + this.#dispatch = options.dispatch + this.#currentView = options.currentView + this.#isStopped = options.isStopped + } + + get isOpen(): boolean { + return this.#open + } + + sync(view: BraidViewModel): void { + const interaction = view.interactions[0] + const interactionKey = interaction ? keyFor(interaction) : undefined + if (!interaction) { + this.#pendingInteractionKey = undefined + if (this.#open) { + this.#open = false + this.#interactionKey = undefined + this.#modals.closeTop() + } + return + } + if ( + interactionKey !== this.#pendingInteractionKey && + (!this.#open || interactionKey !== this.#interactionKey) + ) { + this.#openInteraction(interaction) + } + } + + #openInteraction(interaction: InteractionView): void { + this.#open = true + this.#interactionKey = keyFor(interaction) + const shell = new InteractionShell(interaction, this.#theme, (response) => { + this.#open = false + this.#pendingInteractionKey = this.#interactionKey + this.#interactionKey = undefined + this.#modals.closeTop() + const pendingKey = this.#pendingInteractionKey + void this.#dispatch({ + type: 'respond-interaction', + operationId: this.#nextOperationId(), + runId: interaction.runId, + interactionId: interaction.interactionId, + response, + }).then((result) => { + if (this.#isStopped()) return + const current = this.#currentView().interactions[0] + if (result.kind !== 'accepted' && current && keyFor(current) === pendingKey) { + this.#pendingInteractionKey = undefined + this.#openInteraction(current) + } + }) + }) + this.#modals.open(shell, { anchor: 'center', width: '90%', maxHeight: '90%' }) + } +} + +function keyFor(interaction: InteractionView): string { + return `${interaction.runId}:${interaction.interactionId}` +} diff --git a/src/views/tui/terminal-overlays.ts b/src/views/tui/terminal-overlays.ts new file mode 100644 index 0000000..bec5599 --- /dev/null +++ b/src/views/tui/terminal-overlays.ts @@ -0,0 +1,344 @@ +import type { Component, Editor, SelectItem } from '@earendil-works/pi-tui' +import type { AnalysisRecord } from '../../domain/entities.js' +import { type CommandName, commandItems } from '../shared/command-registry.js' +import type { BraidUiController } from '../shared/intents.js' +import { sanitizeTerminalText } from '../shared/sanitize.js' +import { ActivityView } from './activity.js' +import { AnalysisViewPanel } from './analysis.js' +import { ComparisonViewPanel, isAnalysisComparisonResult } from './comparison.js' +import type { TerminalConfigurationOptions } from './configuration-wizard.js' +import { ConnectionSetupViewPanel } from './connection-setup.js' +import { ConversationOverlayController } from './conversation-overlays.js' +import { DetailsViewPanel } from './details.js' +import { GraphView } from './graph.js' +import { type HelpViewOptions, HelpViewPanel } from './help.js' +import type { ModalCoordinator } from './modal-coordinator.js' +import { ProfileEditorViewPanel } from './profile-editor.js' +import { SearchableSelector } from './selector.js' +import { UnavailablePanel } from './terminal-shell.js' +import type { BraidTheme } from './theme.js' + +export interface TerminalOverlayOptions { + readonly theme: BraidTheme + readonly controller: BraidUiController + readonly modals: ModalCoordinator + readonly editor: Editor + readonly nextOperationId: () => string + readonly keyboardDiagnostic?: () => string + readonly keymapDiagnostic?: () => string | undefined + readonly configuration?: TerminalConfigurationOptions + readonly dispatchCommand: (command: CommandName, args: readonly string[]) => void + readonly openSurface: ( + surface: 'activity' | 'graph' | 'details' | 'fork' | 'help' | 'settings', + ) => void + readonly openHelp: (query: string) => void + readonly requestRender: () => void +} + +export class TerminalOverlayController { + readonly #theme: BraidTheme + readonly #controller: BraidUiController + readonly #modals: ModalCoordinator + readonly #editor: Editor + readonly #nextOperationId: () => string + readonly #dispatchCommand: TerminalOverlayOptions['dispatchCommand'] + readonly #openSurfaceCallback: TerminalOverlayOptions['openSurface'] + readonly #openHelpCallback: TerminalOverlayOptions['openHelp'] + readonly #configuration: TerminalOverlayOptions['configuration'] + readonly #keyboardDiagnostic: TerminalOverlayOptions['keyboardDiagnostic'] + readonly #keymapDiagnostic: TerminalOverlayOptions['keymapDiagnostic'] + readonly #requestRender: TerminalOverlayOptions['requestRender'] + readonly #conversations: ConversationOverlayController + + constructor(options: TerminalOverlayOptions) { + this.#theme = options.theme + this.#controller = options.controller + this.#modals = options.modals + this.#editor = options.editor + this.#nextOperationId = options.nextOperationId + this.#conversations = new ConversationOverlayController({ + theme: this.#theme, + controller: this.#controller, + modals: this.#modals, + nextOperationId: options.nextOperationId, + }) + this.#dispatchCommand = options.dispatchCommand + this.#openSurfaceCallback = options.openSurface + this.#openHelpCallback = options.openHelp + this.#configuration = options.configuration + this.#keyboardDiagnostic = options.keyboardDiagnostic + this.#keymapDiagnostic = options.keymapDiagnostic + this.#requestRender = options.requestRender + } + + hasConfiguration(): boolean { + return this.#configuration !== undefined + } + + openProfile(query = ''): void { + const panel = new ProfileEditorViewPanel(this.#theme, { + controller: this.#controller, + nextOperationId: this.#nextOperationId, + query, + onCancel: () => this.#modals.closeTop(), + }) + this.#modals.open(panel, { anchor: 'center', width: '88%', minWidth: 44, maxHeight: '90%' }) + } + + openConnection(query = ''): void { + const panel = new ConnectionSetupViewPanel(this.#theme, { + controller: this.#controller, + nextOperationId: this.#nextOperationId, + query, + onCancel: () => this.#modals.closeTop(), + }) + this.#modals.open(panel, { anchor: 'center', width: '88%', minWidth: 44, maxHeight: '90%' }) + } + + openConfiguration(): void { + void this.#openConfiguration().catch((error: unknown) => { + this.openUnavailable( + 'configuration unavailable', + error instanceof Error ? error.message : 'The configuration view could not be loaded', + ) + }) + } + + async #openConfiguration(): Promise { + const configuration = this.#configuration + if (configuration === undefined) { + this.openUnavailable( + 'configuration unavailable', + 'No profile and connection catalog was provided by the product integration', + ) + return + } + const { ConfigurationWizard } = await import('./configuration-wizard.js') + const { confirmation, ...configurationWithoutConfirmation } = configuration + const wizard = new ConfigurationWizard({ + ...configurationWithoutConfirmation, + ...(confirmation === undefined ? {} : { confirmation }), + theme: this.#theme, + requestRender: this.#requestRender, + onComplete: () => {}, + onCancel: () => this.#modals.closeTop(), + }) + this.#modals.open(wizard, { anchor: 'center', width: '82%', minWidth: 36, maxHeight: '90%' }) + } + + openCommandPalette(): void { + const view = this.#controller.view() + const palette = new SearchableSelector({ + title: 'Commands', + items: commandItems(view.capabilities), + theme: this.#theme, + onSelect: (item) => { + this.#modals.closeTop() + this.#dispatchCommand(item.value as CommandName, []) + }, + onCancel: () => this.#modals.closeTop(), + }) + this.#modals.open(palette, { anchor: 'center', width: '70%', minWidth: 32, maxHeight: 14 }) + } + + openCorrection(name: string, suggestions: readonly CommandName[]): void { + const items = suggestions.map((suggestion) => ({ + value: suggestion, + label: `/${suggestion}`, + description: 'choose to edit the draft; nothing executes yet', + })) + const selector = new SearchableSelector({ + title: `unknown command /${sanitizeTerminalText(name)}`, + items, + theme: this.#theme, + onSelect: (item) => { + this.#editor.setText(`/${item.value} `) + this.#modals.closeTop() + }, + onCancel: () => this.#modals.closeTop(), + }) + this.#modals.open(selector, { anchor: 'center', width: '70%', minWidth: 32, maxHeight: 12 }) + } + + openSelector( + kind: + | 'conversation' + | 'branch' + | 'profile' + | 'connection' + | 'runner' + | 'model' + | 'effort' + | 'graph' + | 'help', + ): void { + if (kind === 'profile') { + this.openProfile() + return + } + if (kind === 'connection') { + this.openConnection() + return + } + if (kind === 'conversation') { + this.#conversations.openConversationSelector() + return + } + if (kind === 'branch') { + this.#conversations.openBranchSelector() + 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) + const selector = new SearchableSelector({ + title: kind, + items, + theme: this.#theme, + onSelect: (item) => { + this.#modals.closeTop() + if (kind === 'graph') this.#openSurfaceCallback('graph') + else if (kind === 'help') this.#openHelpCallback(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 }) + } + + openConversationSelector(query = ''): void { + this.#conversations.openConversationSelector(query) + } + + openAdjacentBranch(direction: -1 | 1): void { + this.#conversations.openAdjacentBranch(direction) + } + + openHelp(query: string): void { + const help = new HelpViewPanel(this.#theme, this.#helpOptions()) + help.setQuery(query) + this.#modals.open(help, { anchor: 'center', width: '86%', maxHeight: '90%' }) + } + + openIntelligenceResult(command: 'ask' | 'analyze' | 'compare', data: unknown): void { + if (command === 'compare') { + if (!isAnalysisComparisonResult(data)) { + this.openUnavailable('/compare', 'The saved comparison result could not be rendered') + return + } + const panel = new ComparisonViewPanel(this.#theme) + panel.setResult(data) + this.#modals.open(panel, { anchor: 'center', width: '92%', minWidth: 36, maxHeight: '90%' }) + return + } + + const analysis = analysisRecordFromDispatchData(data) + if (analysis === undefined) { + this.openUnavailable(`/${command}`, 'The saved analysis result could not be rendered') + return + } + const panel = new AnalysisViewPanel(this.#theme) + panel.setRecord(analysis) + this.#modals.open(panel, { anchor: 'center', width: '92%', minWidth: 36, maxHeight: '90%' }) + } + + openSurface(surface: 'activity' | 'graph' | 'details' | 'fork' | 'help' | 'settings'): void { + const view = this.#controller.view() + let panel: Component + if (surface === 'activity') { + const activity = new ActivityView(this.#theme) + activity.setView(view) + panel = activity + } else if (surface === 'graph') { + const graph = new GraphView(this.#theme) + graph.setView(view) + panel = graph + } else if (surface === 'details') { + const details = new DetailsViewPanel(this.#theme) + details.setView(view) + panel = details + } else if (surface === 'settings') { + panel = new SearchableSelector({ + title: 'settings', + items: [ + { value: 'profile', label: 'Profiles', description: 'list, validate, select, and save' }, + { value: 'connection', label: 'Connections', description: 'list, test, and select' }, + ], + theme: this.#theme, + footer: 'enter open · esc close', + onSelect: (item) => { + this.#modals.closeTop() + if (item.value === 'profile') this.openProfile() + if (item.value === 'connection') this.openConnection() + }, + onCancel: () => this.#modals.closeTop(), + }) + } else if (surface === 'fork') { + this.#conversations.openForkPreview() + return + } else if (surface === 'help') { + const help = new HelpViewPanel(this.#theme, this.#helpOptions()) + help.setQuery('') + panel = help + } else { + panel = new UnavailablePanel( + this.#theme, + 'surface unavailable', + 'This surface is not available', + ) + } + this.#modals.open(panel, { anchor: 'center', width: '90%', maxHeight: '90%' }) + } + + openUnavailable(title: string, reason: string): void { + this.#modals.open(new UnavailablePanel(this.#theme, title, reason), { + anchor: 'center', + width: '80%', + maxHeight: 8, + }) + } + + #helpOptions(): HelpViewOptions { + const keyboardDiagnostic = this.#keyboardDiagnostic?.() + const keymapDiagnostic = this.#keymapDiagnostic?.() + return { + ...(keyboardDiagnostic === undefined ? {} : { keyboardDiagnostic }), + ...(keymapDiagnostic === undefined ? {} : { keymapDiagnostic }), + } + } +} + +function analysisRecordFromDispatchData(data: unknown): AnalysisRecord | undefined { + if (!isRecord(data) || !isRecord(data.analysis)) return undefined + const analysis = data.analysis + if ( + typeof analysis.id !== 'string' || + typeof analysis.status !== 'string' || + !Array.isArray(analysis.findings) || + !isRecord(analysis.source) || + typeof analysis.source.digest !== 'string' || + typeof analysis.source.complete !== 'boolean' + ) { + return undefined + } + return analysis as unknown as AnalysisRecord +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/src/views/tui/terminal-shell.ts b/src/views/tui/terminal-shell.ts new file mode 100644 index 0000000..06d16d3 --- /dev/null +++ b/src/views/tui/terminal-shell.ts @@ -0,0 +1,164 @@ +import { + Container, + Editor, + matchesKey, + Text, + type TUI, + truncateToWidth, +} from '@earendil-works/pi-tui' +import type { BraidViewModel } from '../shared/models.js' +import { sanitizeTerminalText } from '../shared/sanitize.js' +import { ActivityView } from './activity.js' +import { layoutFor } from './layout.js' +import { TerminalChrome } from './terminal-chrome.js' +import type { BraidTheme } from './theme.js' +import { TranscriptView } from './transcript.js' + +export class BraidShell extends Container { + readonly #transcript: TranscriptView + readonly #activity: ActivityView + readonly #chrome: TerminalChrome + readonly #editor: Editor + readonly #theme: BraidTheme + readonly #rows: () => number + #view: BraidViewModel | undefined + #showActivity = false + #quitArmed = false + + constructor( + tui: TUI, + theme: BraidTheme, + rows: () => number, + onSubmit: (text: string) => void, + onChange: (text: string) => void, + ) { + super() + this.#theme = theme + this.#rows = rows + this.#transcript = new TranscriptView(theme) + this.#activity = new ActivityView(theme) + this.#chrome = new TerminalChrome(theme) + this.#editor = new Editor(tui, theme.editor, { paddingX: 1 }) + this.#editor.onSubmit = onSubmit + this.#editor.onChange = onChange + this.addChild(this.#transcript) + this.addChild(this.#activity) + this.addChild(this.#editor) + } + + get editor(): Editor { + return this.#editor + } + + setActivityVisible(visible: boolean): void { + this.#showActivity = visible + this.invalidate() + } + + setView(view: BraidViewModel, quitArmed: boolean): void { + this.#view = view + this.#quitArmed = quitArmed + this.#transcript.setView(view) + this.#activity.setView(view) + this.#chrome.setState({ + view, + quitArmed, + activityVisible: this.#showActivity, + navigationHint: this.#transcript.navigationHint(), + }) + this.#editor.disableSubmit = false + this.#editor.borderColor = view.status === 'running' ? this.#theme.warning : this.#theme.accent + this.invalidate() + } + + handleTranscriptInput(data: string): boolean { + const explicitTranscriptKey = + matchesKey(data, 'alt+home') || + matchesKey(data, 'alt+end') || + matchesKey(data, 'pageUp') || + matchesKey(data, 'pageDown') || + matchesKey(data, 'alt+pageUp') || + matchesKey(data, 'alt+pageDown') + if (!explicitTranscriptKey && this.#editor.getText().length > 0) return false + const handled = this.#transcript.handleInput(data) + if (handled) this.#refreshChrome() + return handled + } + + toggleDetails(): boolean { + const toggled = this.#transcript.toggleDetails() + if (toggled) this.#refreshChrome() + return toggled + } + + navigationHint(): string { + return this.#transcript.navigationHint() + } + + hasCollapsibleDetails(): boolean { + return this.#transcript.hasCollapsibleDetails() + } + + override render(width: number): string[] { + const view = this.#view + if (!view) return super.render(width) + const layout = layoutFor(width, this.#rows(), this.#showActivity) + const editorLines = this.#editor.render(width) + const chromeLines = this.#chrome.render(width) + const dockRows = chromeLines.length + editorLines.length + const contentRows = Math.max(1, layout.rows - dockRows) + this.#transcript.setViewportRows(contentRows) + const transcriptLines = this.#transcript.render(layout.transcriptWidth) + this.#refreshChrome() + const activityLines = + layout.mode === 'wide' && this.#showActivity + ? tail(this.#activity.render(layout.activityWidth), contentRows) + : [] + const content: string[] = [] + for (let index = 0; index < contentRows; index += 1) { + const transcript = transcriptLines[index] ?? '' + if (activityLines.length === 0) { + content.push(truncateToWidth(transcript, width, '…', true)) + } else { + const activity = activityLines[index] ?? '' + content.push( + `${truncateToWidth(transcript, layout.transcriptWidth, '…', true)}${' '.repeat(layout.gap)}${truncateToWidth(activity, layout.activityWidth, '…', true)}`, + ) + } + } + return [ + ...chromeLines.slice(0, 1), + ...content, + ...editorLines, + ...this.#chrome.render(width).slice(1), + ].slice(0, layout.rows) + } + + #refreshChrome(): void { + const view = this.#view + if (!view) return + this.#chrome.setState({ + view, + quitArmed: this.#quitArmed, + activityVisible: this.#showActivity, + navigationHint: this.#transcript.navigationHint(), + }) + this.invalidate() + } +} + +function tail(lines: readonly string[], count: number): string[] { + return lines.slice(Math.max(0, lines.length - count)) +} + +export class UnavailablePanel extends Container { + constructor(theme: BraidTheme, title: string, reason: string) { + super() + this.addChild(new Text(theme.warning(sanitizeTerminalText(title)), 1, 0)) + this.addChild(new Text(sanitizeTerminalText(reason), 1, 0)) + this.addChild( + new Text(theme.muted('This action is unavailable with the selected connection.'), 1, 0), + ) + this.addChild(new Text(theme.muted('esc close'), 1, 0)) + } +} diff --git a/src/views/tui/theme.ts b/src/views/tui/theme.ts index 9a0f792..3d0b069 100644 --- a/src/views/tui/theme.ts +++ b/src/views/tui/theme.ts @@ -1,7 +1,17 @@ import type { EditorTheme, MarkdownTheme, SelectListTheme } from '@earendil-works/pi-tui' import { Chalk } from 'chalk' +import { + type AppearanceEnvironment, + type ColorMode, + chalkLevel, + resolveColorMode, +} from '../shared/appearance.js' export interface BraidTheme { + readonly color: ColorMode + readonly highContrast: boolean + readonly reducedMotion: boolean + readonly terminalMetadata: boolean readonly brand: (text: string) => string readonly accent: (text: string) => string readonly text: (text: string) => string @@ -10,31 +20,66 @@ export interface BraidTheme { readonly warning: (text: string) => string readonly success: (text: string) => string readonly userBackground: (text: string) => string + readonly progress: (text: string) => string readonly editor: EditorTheme readonly markdown: MarkdownTheme readonly select: SelectListTheme } -export function createBraidTheme(colors: boolean): BraidTheme { - const chalk = new Chalk({ level: colors ? 3 : 0 }) - const accent = (text: string) => chalk.rgb(119, 166, 255)(text) - const muted = (text: string) => chalk.rgb(135, 145, 165)(text) +export interface BraidThemeOptions { + readonly colors?: boolean + readonly color?: ColorMode + readonly highContrast?: boolean + readonly reducedMotion?: boolean + readonly environment?: AppearanceEnvironment +} + +export function createBraidTheme(options: boolean | BraidThemeOptions): BraidTheme { + const resolved = typeof options === 'boolean' ? { colors: options } : options + const color = + resolved.colors === false + ? 'none' + : resolveColorMode(resolved.color, resolved.environment ?? process.env) + const highContrast = resolved.highContrast ?? false + const reducedMotion = resolved.reducedMotion ?? false + const terminalMetadata = color !== 'none' && !reducedMotion + const chalk = new Chalk({ level: chalkLevel(color) }) + const accent = (text: string) => + highContrast ? chalk.bold.white(text) : chalk.rgb(119, 166, 255)(text) + const muted = (text: string) => + highContrast ? chalk.white(text) : chalk.rgb(135, 145, 165)(text) + const text = (value: string) => + highContrast ? chalk.bold.white(value) : chalk.rgb(225, 230, 240)(value) + const semanticMuted = highContrast ? (value: string) => chalk.white(value) : muted const select: SelectListTheme = { selectedPrefix: accent, selectedText: (text) => chalk.bold(accent(text)), - description: muted, - scrollInfo: muted, - noMatch: muted, + description: semanticMuted, + scrollInfo: semanticMuted, + noMatch: semanticMuted, } return { - brand: (text) => chalk.bold.rgb(151, 190, 255)(text), + color, + highContrast, + reducedMotion, + terminalMetadata, + brand: (text) => (highContrast ? chalk.bold.white(text) : chalk.bold.rgb(151, 190, 255)(text)), accent, - text: (text) => chalk.rgb(225, 230, 240)(text), - muted, - danger: (text) => chalk.rgb(255, 111, 120)(text), - warning: (text) => chalk.rgb(255, 194, 92)(text), - success: (text) => chalk.rgb(112, 211, 151)(text), - userBackground: (text) => chalk.bgRgb(34, 48, 70)(text), + text, + muted: semanticMuted, + danger: highContrast + ? (value) => chalk.bold.underline.white(value) + : (value) => chalk.rgb(255, 111, 120)(value), + warning: highContrast + ? (value) => chalk.bold.white(value) + : (value) => chalk.rgb(255, 194, 92)(value), + success: highContrast + ? (value) => chalk.bold.white(value) + : (value) => chalk.rgb(112, 211, 151)(value), + userBackground: highContrast + ? (value) => chalk.bgWhite.black(value) + : (value) => chalk.bgRgb(34, 48, 70)(value), + progress: (value) => (reducedMotion || value.endsWith('…') ? value : `${value}…`), editor: { borderColor: muted, selectList: select }, markdown: { heading: (text) => chalk.bold(accent(text)), diff --git a/src/views/tui/transcript.ts b/src/views/tui/transcript.ts new file mode 100644 index 0000000..2270dfb --- /dev/null +++ b/src/views/tui/transcript.ts @@ -0,0 +1,315 @@ +import { Box, type Component, Container, matchesKey, Spacer, Text } from '@earendil-works/pi-tui' +import type { BraidViewModel, MessageView, TranscriptPartView } from '../shared/models.js' +import { sanitizeDiff, sanitizeMarkdown, sanitizeTerminalText } from '../shared/sanitize.js' +import { SafeMarkdown } from './safe-markdown.js' +import type { BraidTheme } from './theme.js' + +const COLLAPSIBLE_KINDS = new Set([ + 'reasoning', + 'tool', + 'result', + 'artifact', + 'analysis', +]) + +export class TranscriptView extends Container { + readonly #theme: BraidTheme + #view: BraidViewModel | undefined + #viewportRows = Number.MAX_SAFE_INTEGER + #scrollTop = 0 + #followTail = true + #lastLines: readonly string[] = [] + readonly #expandedParts = new Set() + #detailCursorId: string | undefined + + constructor(theme: BraidTheme) { + super() + this.#theme = theme + } + + setView(view: BraidViewModel): void { + this.#view = view + this.clear() + if (view.hiddenMessageCount > 0) { + this.addChild( + new Text(this.#theme.muted(`${view.hiddenMessageCount} earlier messages hidden`), 1, 0), + ) + } + for (const entry of view.queue ?? []) this.addChild(this.#queue(entry)) + for (const message of view.messages) this.addChild(this.#message(message)) + if (view.messages.length === 0) { + this.addChild(new Spacer(1)) + this.addChild( + new Text(this.#theme.muted('Write a message, or press Ctrl+P for commands.'), 1, 0), + ) + } + const present = new Set( + view.messages.flatMap((message) => + message.parts.filter((part) => COLLAPSIBLE_KINDS.has(part.kind)).map((part) => part.id), + ), + ) + for (const id of this.#expandedParts) if (!present.has(id)) this.#expandedParts.delete(id) + if (this.#detailCursorId !== undefined && !present.has(this.#detailCursorId)) + this.#detailCursorId = undefined + this.invalidate() + } + + setViewportRows(rows: number): void { + this.#viewportRows = Math.max(1, Math.floor(rows)) + this.invalidate() + } + + get followTail(): boolean { + return this.#followTail + } + + get canScroll(): boolean { + return this.#maxScroll() > 0 + } + + navigationHint(): string { + if (this.canScroll && !this.#followTail) return 'PgUp/PgDn history · End follow' + const detailCount = this.#detailParts().length + if (detailCount > 1) { + const current = this.#detailCursorId + ? this.#detailParts().findIndex((part) => part.id === this.#detailCursorId) + 1 + : detailCount + return `Ctrl+E next detail (${current}/${detailCount})` + } + if (detailCount === 1) return 'Ctrl+E detail' + if (this.canScroll) return 'PgUp/PgDn history' + return 'Ctrl+P commands' + } + + hasCollapsibleDetails(): boolean { + return this.#detailParts().length > 0 + } + + toggleDetails(): boolean { + const view = this.#view + if (view === undefined) return false + const parts = this.#detailParts() + if (parts.length === 0) return false + const currentIndex = this.#detailCursorId + ? parts.findIndex((part) => part.id === this.#detailCursorId) + : -1 + const nextIndex = currentIndex < 0 ? parts.length - 1 : (currentIndex + 1) % parts.length + const next = parts[nextIndex] + if (next === undefined) return false + this.#detailCursorId = next.id + for (const id of this.#expandedParts) this.#expandedParts.delete(id) + this.#expandedParts.add(next.id) + this.setView(view) + return true + } + + /** Returns true when this key changed the transcript position. */ + handleInput(data: string): boolean { + const page = Math.max(1, this.#viewportRows - 2) + if (matchesKey(data, 'pageUp') || matchesKey(data, 'alt+pageUp')) { + this.#moveTo(this.#scrollTop - page) + return true + } + if (matchesKey(data, 'pageDown') || matchesKey(data, 'alt+pageDown')) { + this.#moveTo(this.#scrollTop + page) + return true + } + if (matchesKey(data, 'home') || matchesKey(data, 'alt+home')) { + this.#moveTo(0) + return true + } + if (matchesKey(data, 'end') || matchesKey(data, 'alt+end')) { + this.#moveTo(this.#maxScroll()) + return true + } + return false + } + + override render(width: number): string[] { + const allLines = super.render(Math.max(1, Math.floor(width))) + this.#lastLines = allLines + const max = Math.max(0, allLines.length - this.#viewportRows) + if (this.#followTail) this.#scrollTop = max + else this.#scrollTop = Math.max(0, Math.min(this.#scrollTop, max)) + return allLines.slice(this.#scrollTop, this.#scrollTop + this.#viewportRows) + } + + #moveTo(requested: number): void { + const max = this.#maxScroll() + this.#scrollTop = Math.max(0, Math.min(Math.trunc(requested), max)) + this.#followTail = this.#scrollTop >= max + this.invalidate() + } + + #maxScroll(): number { + return Math.max(0, this.#lastLines.length - this.#viewportRows) + } + + #queue(entry: NonNullable[number]): Text { + const status = + entry.status === 'blocked' ? this.#theme.danger('blocked') : this.#theme.warning('queued') + return new Text(`${status} ${entry.position}. ${sanitizeTerminalText(entry.text)}`, 1, 0) + } + + #message(message: MessageView): Container { + const container = new Container() + if (this.#theme.highContrast) + container.addChild(new Text(this.#theme.accent(`${message.role}:`), 1, 0)) + if (message.role === 'user') { + const box = new Box(1, 0, this.#theme.userBackground) + box.addChild( + new SafeMarkdown( + sanitizeMarkdown(message.text), + 0, + 0, + this.#theme.markdown, + undefined, + undefined, + { allowHyperlinks: this.#theme.terminalMetadata }, + ), + ) + container.addChild(box) + return container + } + + container.addChild(new Spacer(1)) + for (const part of message.parts) container.addChild(this.#part(part)) + if (message.parts.length === 0 && message.text) { + container.addChild(this.#markdown(message.text)) + } + if (message.status === 'failed' || message.status === 'blocked') { + container.addChild(new Text(this.#theme.danger(message.status), 1, 0)) + } else if (message.status === 'cancelled' || message.status === 'aborted') { + container.addChild(new Text(this.#theme.warning('cancelled'), 1, 0)) + } else if (message.status === 'redacted') { + container.addChild(new Text(this.#theme.muted('content removed'), 1, 0)) + } else if (message.status === 'streaming') { + container.addChild(new Text(this.#theme.warning(this.#theme.progress('working')), 1, 0)) + } + return container + } + + #part(part: TranscriptPartView): Component { + if ( + part.kind === 'text' && + part.input === undefined && + part.result === undefined && + part.error === undefined + ) + return this.#markdown(part.text) + if (COLLAPSIBLE_KINDS.has(part.kind)) return this.#card(part) + const text = part.kind === 'artifact' ? sanitizeDiff(part.text) : sanitizeMarkdown(part.text) + return this.#plainPart(part, text) + } + + #plainPart(part: TranscriptPartView, text: string): Container { + const container = new Container() + const label = part.kind === 'text' ? '' : `${part.kind} · ` + if (label.length > 0 || part.status !== undefined) + container.addChild(new Text(this.#theme.muted(`${label}${part.status ?? 'unknown'}`), 1, 0)) + if (text) container.addChild(this.#markdown(text)) + if (part.error) + container.addChild(new Text(this.#theme.danger(sanitizeTerminalText(part.error)), 1, 0)) + return container + } + + #card(part: TranscriptPartView): Container { + const container = new Container() + const status = part.status ?? 'unknown' + const label = + part.kind === 'reasoning' ? 'thought' : (part.toolName ?? part.subject?.title ?? part.kind) + const color = + status === 'failed' + ? this.#theme.danger + : status === 'running' || status === 'queued' + ? this.#theme.warning + : status === 'complete' + ? this.#theme.success + : this.#theme.muted + const symbol = + status === 'failed' ? 'x' : status === 'complete' ? 'ok' : status === 'running' ? '>' : '·' + const expanded = part.collapsed === false || this.#expandedParts.has(part.id) + const detail = detailText(part) + const detailParts = this.#detailParts() + const detailIndex = detailParts.findIndex((candidate) => candidate.id === part.id) + const focus = this.#detailCursorId === part.id ? this.#theme.accent('◆ ') : '' + const position = + detailParts.length > 1 && detailIndex >= 0 ? ` ${detailIndex + 1}/${detailParts.length}` : '' + container.addChild( + new Text( + `${focus}${color(`${symbol} ${sanitizeTerminalText(label)} · ${status}`)}${position}${ + detail ? this.#theme.muted(expanded ? ' · open' : ' · collapsed · ctrl+e next') : '' + }`, + 1, + 0, + ), + ) + if (expanded && detail) { + if (part.kind === 'reasoning') { + container.addChild( + new SafeMarkdown( + sanitizeMarkdown(detail), + 2, + 0, + this.#theme.markdown, + { color: this.#theme.muted, italic: true }, + undefined, + { allowHyperlinks: this.#theme.terminalMetadata }, + ), + ) + } else { + container.addChild(this.#markdown(detail, 2)) + } + } + if (part.error) + container.addChild(new Text(this.#theme.danger(sanitizeTerminalText(part.error)), 2, 0)) + return container + } + + #markdown(text: string, paddingX = 1): SafeMarkdown { + return new SafeMarkdown( + sanitizeMarkdown(text), + paddingX, + 0, + this.#theme.markdown, + undefined, + undefined, + { allowHyperlinks: this.#theme.terminalMetadata }, + ) + } + + #detailParts(): TranscriptPartView[] { + return ( + this.#view?.messages + .flatMap((message) => message.parts) + .filter((part) => COLLAPSIBLE_KINDS.has(part.kind) && hasPartDetail(part)) ?? [] + ) + } +} + +function hasPartDetail(part: TranscriptPartView): boolean { + return detailText(part).length > 0 || part.error !== undefined +} + +function detailText(part: TranscriptPartView): string { + if ( + part.kind === 'reasoning' || + part.kind === 'text' || + part.kind === 'warning' || + part.kind === 'error' + ) + return part.text + if (part.text) return part.text + if (part.input !== undefined) return `input: ${formatValue(part.input)}` + if (part.result !== undefined) return `result: ${formatValue(part.result)}` + return '' +} + +function formatValue(value: unknown): string { + if (typeof value === 'string') return sanitizeTerminalText(value) + try { + return sanitizeTerminalText(JSON.stringify(value, null, 2) ?? String(value)) + } catch { + return '[structured value unavailable]' + } +} diff --git a/test/analysis-durable.test.ts b/test/analysis-durable.test.ts new file mode 100644 index 0000000..29e6d88 --- /dev/null +++ b/test/analysis-durable.test.ts @@ -0,0 +1,640 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import type { + AnalystFinding, + ExactAnalystRunEvent, + ExactAnalystRunResult, +} from '@tangle-network/agent-eval' +import type { AgentProfile } from '@tangle-network/agent-interface' +import { + AgentEvalAnalystAdapter, + type AnalystRegistryPort, +} from '../src/adapters/analysis/eval-analyst.js' +import { AnalysisComparisonService } from '../src/app/analysis-comparison.js' +import { AnalysisPromotionService } from '../src/app/analysis-promotion.js' +import { AnalysisService } from '../src/app/analysis-service.js' +import { freezeAnalysisSource } from '../src/app/analysis-source.js' +import type { AnalysisApplicationHost, FrozenAnalysisEvidence } from '../src/app/analysis-types.js' +import { AnalysisPersistenceError } from '../src/app/analysis-types.js' +import { createBraidApplication } from '../src/app/composition.js' +import { portablePlanForState } from '../src/app/conversation-context.js' +import { MemoryJournal } from '../src/app/journal.js' +import type { BraidEvent, JournalEventEnvelope } from '../src/domain/events.js' +import { + createAnalysisId, + createBranchId, + createConversationId, + createEventId, +} from '../src/domain/ids-values.js' +import { replayEvents } from '../src/domain/reducer.js' +import { type BraidState, initialState } from '../src/domain/state.js' +import { FixedClock } from '../src/ports/clock.js' + +const NOW = '2026-08-03T20:00:00.000Z' +const PROFILE = {} as Readonly + +function eventEnvelope( + event: BraidEvent, + sequence: number, + prefix = 'event', +): JournalEventEnvelope { + return { + eventId: createEventId(`event-${prefix}-${sequence}`), + sequence, + revision: sequence, + occurredAt: new Date(Date.parse(NOW) + sequence).toISOString(), + event, + } +} + +function runHistory(runId: string, turnId: string): readonly JournalEventEnvelope[] { + return [ + eventEnvelope({ kind: 'workspace.opened', workspace: '/tmp/braid' }, 1), + eventEnvelope({ kind: 'draft.changed', text: 'inspect the run' }, 2), + eventEnvelope( + { + kind: 'run.requested', + operationId: `operation-${runId}`, + runId, + turnId, + userMessageId: `message-user-${runId}`, + assistantMessageId: `message-assistant-${runId}`, + text: 'inspect the run', + }, + 3, + ), + eventEnvelope( + { + kind: 'run.tool.call', + runId, + partId: `part-tool-${runId}`, + toolName: 'shell', + callId: `call-${runId}`, + input: { command: 'printf safe' }, + provider: { + eventId: `provider-call-${runId}`, + providerSequence: 1, + occurredAt: NOW, + }, + }, + 4, + ), + eventEnvelope( + { + kind: 'run.tool.result', + runId, + partId: `part-tool-${runId}`, + toolName: 'shell', + callId: `call-${runId}`, + result: 'safe', + provider: { + eventId: `provider-result-${runId}`, + providerSequence: 2, + occurredAt: NOW, + }, + }, + 5, + ), + eventEnvelope( + { + kind: 'run.text.delta', + runId, + text: 'completed', + provider: { + eventId: `provider-text-${runId}`, + providerSequence: 3, + occurredAt: NOW, + }, + }, + 6, + ), + eventEnvelope( + { + kind: 'run.finished', + runId, + status: 'completed', + finalText: 'completed', + usage: { input: 4, output: 2 }, + }, + 7, + ), + ] +} + +class MemoryHost implements AnalysisApplicationHost { + state: BraidState + readonly committed: BraidEvent[] = [] + #events: JournalEventEnvelope[] + #failureKind: string | undefined + #failed = false + + constructor(events: readonly JournalEventEnvelope[]) { + this.#events = [...events] + this.state = replayEvents(initialState(PROFILE), this.#events) + } + + eventHistory = (): readonly JournalEventEnvelope[] => this.#events + + currentState = (): BraidState => this.state + + commit = (event: BraidEvent): void => { + this.append(event) + } + + commitAndWait = async (event: BraidEvent): Promise => { + this.append(event) + } + + now = (): string => NOW + + failNext(kind: string): void { + this.#failureKind = kind + this.#failed = false + } + + private append(event: BraidEvent): void { + if (event.kind === this.#failureKind && !this.#failed) { + this.#failed = true + throw new Error(`simulated crash at ${event.kind}`) + } + const envelope = eventEnvelope(event, this.state.sequence + 1, 'journal-event') + this.#events = [...this.#events, envelope] + this.committed.push(event) + this.state = replayEvents(this.state, [envelope]) + } +} + +class CrashJournal extends MemoryJournal { + #failureKind: string | undefined + #failed = false + + failNext(kind: string): void { + this.#failureKind = kind + this.#failed = false + } + + override append(envelope: JournalEventEnvelope) { + if (envelope.event.kind === this.#failureKind && !this.#failed) { + this.#failed = true + throw new Error(`simulated crash at ${envelope.event.kind}`) + } + return super.append(envelope) + } +} + +function publicApplication( + journal: CrashJournal, + calls: { count: number }, +): ReturnType { + return createBraidApplication({ + fixture: 'deterministic', + clock: new FixedClock(NOW), + journal, + intelligence: { analyst: new AgentEvalAnalystAdapter(registry({ calls })) }, + }) +} + +async function publicSourceRun( + app: ReturnType, + text: string, +): Promise { + app.initialize('/tmp/braid-public') + const receipt = app.send({ + operationId: `op-source-${String(app.state().runs.length + 1)}`, + text, + }) + await receipt.completion + const runId = app.state().runs.at(-1)?.id + assert.ok(runId) + return String(runId) +} + +function findingFor(evidence: FrozenAnalysisEvidence, id = 'finding-supported'): AnalystFinding { + const event = evidence.events.find((candidate) => candidate.event.kind === 'run.text.delta') + assert.ok(event) + return { + schema_version: '1.0.0', + finding_id: id, + analyst_id: 'efficiency-behavioral', + produced_at: NOW, + severity: 'medium', + area: 'tool-use', + claim: 'The frozen run is supported', + confidence: 0.9, + evidence_refs: [{ kind: 'event', uri: `event://${event.id}`, excerpt: 'completed' }], + } +} + +function unsupportedFinding(): AnalystFinding { + return { + schema_version: '1.0.0', + finding_id: 'finding-unsupported', + analyst_id: 'efficiency-behavioral', + produced_at: NOW, + severity: 'low', + area: 'tool-use', + claim: 'This finding has no frozen support', + confidence: 0.2, + evidence_refs: [{ kind: 'event', uri: 'event://missing-event', excerpt: 'missing' }], + } +} + +function registry( + options: { + readonly findings?: readonly AnalystFinding[] + readonly calls?: { count: number } + } = {}, +): AnalystRegistryPort { + const runExactStream: AnalystRegistryPort['runExactStream'] = async function* ( + runId, + _inputs, + runOptions, + ): AsyncGenerator { + if (options.calls !== undefined) options.calls.count += 1 + yield { + type: 'run-started', + run_id: runId, + correlation_id: 'correlation-durable', + started_at: NOW, + analyst_ids: runOptions.analystIds, + execution_plan: {}, + } as unknown as ExactAnalystRunEvent + const findings = [...(options.findings ?? [])] + yield { + type: 'analyst-completed', + analyst_id: runOptions.analystIds[0] ?? 'efficiency-behavioral', + started_at: NOW, + findings, + summary: {}, + } as unknown as ExactAnalystRunEvent + const result: ExactAnalystRunResult = { + run_id: runId, + correlation_id: 'correlation-durable', + started_at: NOW, + ended_at: NOW, + findings, + per_analyst: [], + total_cost_usd: 0, + execution_plan: {}, + completion: { status: 'complete' }, + } as unknown as ExactAnalystRunResult + yield { type: 'run-completed', result } as unknown as ExactAnalystRunEvent + } + return { + list: () => [ + { + id: 'efficiency-behavioral', + description: 'durable test analyst', + version: '2.0.0', + cost: { kind: 'deterministic' }, + }, + ], + runExactStream, + } +} + +function singleHost(): MemoryHost { + return new MemoryHost(runHistory('run-analysis', 'turn-analysis')) +} + +function combinedHost(): MemoryHost { + const baseline = runHistory('run-baseline', 'turn-paired') + const candidate = runHistory('run-candidate', 'turn-candidate').map((event) => ({ + ...event, + eventId: createEventId(`event-candidate-${event.sequence + 10}`), + sequence: event.sequence + 7, + revision: event.revision + 7, + })) + return new MemoryHost([...baseline, ...candidate]) +} + +test('stable operation identity replays exactly and rejects digest conflicts', async () => { + const calls = { count: 0 } + const host = singleHost() + const service = new AnalysisService(host, new AgentEvalAnalystAdapter(registry({ calls }))) + const request = { + runId: 'run-analysis', + recipe: 'cost' as const, + operationId: 'operation-stable', + } + const first = await service.run(request) + const second = await service.run(request) + assert.equal(first.analysis.id, second.analysis.id) + assert.equal(second.replayed, true) + assert.equal(calls.count, 1) + await assert.rejects( + () => + new AnalysisService(host, new AgentEvalAnalystAdapter(registry({ calls }))).run({ + ...request, + question: 'different request', + }), + /different request|conflicting request digest|already reserved/u, + ) + assert.equal(calls.count, 1) +}) + +test('a reservation crash leaves no external call and retries once after restart', async () => { + const calls = { count: 0 } + const crashed = singleHost() + crashed.failNext('operation.requested') + await assert.rejects( + () => + new AnalysisService(crashed, new AgentEvalAnalystAdapter(registry({ calls }))).run({ + runId: 'run-analysis', + recipe: 'cost', + operationId: 'operation-reservation-crash', + }), + AnalysisPersistenceError, + ) + assert.equal(calls.count, 0) + const restarted = new MemoryHost(crashed.eventHistory()) + const result = await new AnalysisService( + restarted, + new AgentEvalAnalystAdapter(registry({ calls })), + ).run({ runId: 'run-analysis', recipe: 'cost', operationId: 'operation-reservation-crash' }) + assert.equal(result.status, 'completed') + assert.equal(calls.count, 1) +}) + +test('result commit crash reconciles to a safe replay without rerunning the analyst', async () => { + const calls = { count: 0 } + const crashed = singleHost() + crashed.failNext('analysis.completed') + await assert.rejects( + () => + new AnalysisService(crashed, new AgentEvalAnalystAdapter(registry({ calls }))).run({ + runId: 'run-analysis', + recipe: 'cost', + operationId: 'operation-result-crash', + }), + AnalysisPersistenceError, + ) + assert.equal(calls.count, 1) + const restarted = new MemoryHost(crashed.eventHistory()) + const result = await new AnalysisService( + restarted, + new AgentEvalAnalystAdapter(registry({ calls })), + ).run({ runId: 'run-analysis', recipe: 'cost', operationId: 'operation-result-crash' }) + assert.equal(result.status, 'failed') + assert.equal(result.replayed, true) + assert.equal(calls.count, 1) + assert.equal(restarted.state.runs[0]?.status, 'completed') +}) + +test('graph edge crash replays the committed result and repairs graph without external work', async () => { + const calls = { count: 0 } + const crashed = singleHost() + crashed.failNext('graph.edge.upserted') + await assert.rejects( + () => + new AnalysisService(crashed, new AgentEvalAnalystAdapter(registry({ calls }))).run({ + runId: 'run-analysis', + recipe: 'cost', + operationId: 'operation-graph-crash', + }), + AnalysisPersistenceError, + ) + const restarted = new MemoryHost(crashed.eventHistory()) + const result = await new AnalysisService( + restarted, + new AgentEvalAnalystAdapter(registry({ calls })), + ).run({ runId: 'run-analysis', recipe: 'cost', operationId: 'operation-graph-crash' }) + assert.equal(result.status, 'completed') + assert.equal(result.replayed, true) + assert.equal(calls.count, 1) + assert.equal( + restarted.state.graphEdges.some((edge) => edge.kind === 'analyzed'), + true, + ) +}) + +test('comparison persists all captured fields and both explicit source edges', async () => { + const host = combinedHost() + const service = new AnalysisComparisonService(host) + const input = { + operationId: 'operation-comparison-durable', + baseline: { runId: 'run-baseline' }, + candidate: { runId: 'run-candidate' }, + } + const first = await service.compareAndStore(input) + const second = await service.compareAndStore(input) + assert.equal(second.replayed, true) + assert.equal( + first.fields.some((field) => field.asymmetry !== 'none'), + true, + ) + assert.equal(host.state.analyses.filter((analysis) => analysis.kind === 'comparison').length, 1) + assert.equal(host.state.graphEdges.filter((edge) => edge.kind === 'compared_left').length, 1) + assert.equal(host.state.graphEdges.filter((edge) => edge.kind === 'compared_right').length, 1) + assert.equal(first.paired.nPairs, 1) + assert.equal(first.paired.nUnpairedBaseline, 0) + assert.equal(first.paired.nUnpairedTreatment, 0) + assert.equal(new Set(first.rows.map((row) => row.pairKey)).size, 1) + assert.deepEqual([...first.rows].map((row) => row.arm).sort(), ['baseline', 'candidate']) + assert.deepEqual(host.state.analyses[0]?.request, { + baseline: { runId: 'run-baseline' }, + candidate: { runId: 'run-candidate' }, + }) + assert.equal(first.semantic.status, 'unavailable') +}) + +test('public ask path retries an uncommitted reservation and replays a committed result', async () => { + const calls = { count: 0 } + const journal = new CrashJournal(new FixedClock(NOW)) + const first = publicApplication(journal, calls) + const runId = await publicSourceRun(first, 'public ask source') + const request = { + runId, + recipe: 'cost' as const, + operationId: 'operation-public-ask-retry', + } + + journal.failNext('operation.requested') + await assert.rejects(() => first.intelligence.analysis.run(request), AnalysisPersistenceError) + assert.equal(calls.count, 0) + await first.close().catch(() => undefined) + + const restarted = publicApplication(journal, calls) + const completed = await restarted.intelligence.analysis.run(request) + assert.equal(completed.status, 'completed') + assert.equal(completed.replayed, undefined) + assert.equal(calls.count, 1) + assert.deepEqual(restarted.state().analyses[0]?.request, { runId, recipe: 'cost' }) + + const replay = await restarted.intelligence.analysis.run(request) + assert.equal(replay.status, 'completed') + assert.equal(replay.replayed, true) + assert.equal(calls.count, 1) + await restarted.close() +}) + +test('public ask path does not rerun after the result commit crashes', async () => { + const calls = { count: 0 } + const journal = new CrashJournal(new FixedClock(NOW)) + const first = publicApplication(journal, calls) + const runId = await publicSourceRun(first, 'public ask result crash') + const request = { + runId, + recipe: 'tools' as const, + operationId: 'operation-public-ask-result-crash', + } + + journal.failNext('analysis.completed') + await assert.rejects(() => first.intelligence.analysis.run(request), AnalysisPersistenceError) + assert.equal(calls.count, 1) + await first.close().catch(() => undefined) + + const restarted = publicApplication(journal, calls) + const replay = await restarted.intelligence.analysis.run(request) + assert.equal(replay.status, 'failed') + assert.equal(replay.replayed, true) + assert.equal(calls.count, 1) + await restarted.close() +}) + +test('public compare path completes the persisted comparison after a result commit crash', async () => { + const journal = new CrashJournal(new FixedClock(NOW)) + const calls = { count: 0 } + const first = publicApplication(journal, calls) + const baselineRunId = await publicSourceRun(first, 'public baseline') + const candidateRunId = await publicSourceRun(first, 'public candidate') + const input = { + operationId: 'operation-public-compare-result-crash', + baseline: { runId: baselineRunId }, + candidate: { runId: candidateRunId }, + } + + journal.failNext('analysis.completed') + await assert.rejects( + () => first.intelligence.comparison.compareAndStore(input), + AnalysisPersistenceError, + ) + await first.close().catch(() => undefined) + + const restarted = publicApplication(journal, calls) + const replay = await restarted.intelligence.comparison.compareAndStore(input) + assert.equal(replay.replayed, true) + assert.equal(replay.paired.nPairs, 1) + assert.equal(replay.paired.nUnpairedBaseline, 0) + assert.equal(replay.paired.nUnpairedTreatment, 0) + assert.equal( + restarted.state().analyses.filter((analysis) => analysis.kind === 'comparison').length, + 1, + ) + assert.equal( + restarted.state().operations.find((operation) => operation.id === input.operationId)?.status, + 'terminal', + ) + await restarted.close() +}) + +test('public promotion requires the persisted analysis and a matching destination branch', async () => { + const journal = new CrashJournal(new FixedClock(NOW)) + const calls = { count: 0 } + const app = publicApplication(journal, calls) + const runId = await publicSourceRun(app, 'public promotion source') + const analysisResult = await app.intelligence.analysis.run({ + runId, + recipe: 'cost', + operationId: 'operation-public-promotion-source', + }) + const persisted = app + .state() + .analyses.find((candidate) => candidate.id === analysisResult.analysis.id) + assert.ok(persisted) + const destinationConversationId = app.state().conversationId + const destinationBranchId = app.state().branchId + + await assert.rejects( + () => + app.intelligence.promotion.promote({ + operationId: 'operation-public-promotion-unpersisted', + analysis: { ...persisted, id: createAnalysisId('analysis-unpersisted') }, + selectedFindingIds: [], + destinationConversationId, + destinationBranchId, + }), + /not durably persisted/u, + ) + await assert.rejects( + () => + app.intelligence.promotion.promote({ + operationId: 'operation-public-promotion-fabricated', + analysis: { ...persisted, question: 'tampered caller bytes' }, + selectedFindingIds: [], + destinationConversationId, + destinationBranchId, + }), + /does not match its persisted record/u, + ) + await assert.rejects( + () => + app.intelligence.promotion.promote({ + operationId: 'operation-public-promotion-missing-branch', + analysis: persisted, + selectedFindingIds: [], + destinationConversationId, + destinationBranchId: createBranchId('branch-missing-destination'), + }), + /does not exist/u, + ) + await assert.rejects( + () => + app.intelligence.promotion.promote({ + operationId: 'operation-public-promotion-wrong-conversation', + analysis: persisted, + selectedFindingIds: [], + destinationConversationId: createConversationId('conversation-other'), + destinationBranchId, + }), + /does not belong to conversation/u, + ) + assert.equal(app.state().analysisAttachments.length, 0) + await app.close() +}) + +test('promotion persists only selected supported findings and portable forks carry external analysis', async () => { + const calls = { count: 0 } + const sourceHost = singleHost() + const evidence = freezeAnalysisSource({ + state: sourceHost.state, + events: sourceHost.eventHistory(), + runId: 'run-analysis', + }) + const supported = findingFor(evidence) + const unsupported = unsupportedFinding() + const analyzed = await new AnalysisService( + sourceHost, + new AgentEvalAnalystAdapter(registry({ calls, findings: [supported, unsupported] })), + ).run({ runId: 'run-analysis', recipe: 'cost', operationId: 'operation-promotion-source-2' }) + assert.equal(analyzed.analysis.findings.length, 2) + const analysis = analyzed.analysis + const crashed = new MemoryHost(sourceHost.eventHistory()) + crashed.failNext('analysis.attachment.created') + await assert.rejects( + () => + new AnalysisPromotionService(crashed).promote({ + operationId: 'operation-promotion-crash', + analysis, + selectedFindingIds: [supported.finding_id], + destinationConversationId: sourceHost.state.conversationId, + destinationBranchId: sourceHost.state.branchId, + }), + AnalysisPersistenceError, + ) + const restarted = new MemoryHost(crashed.eventHistory()) + const attachment = await new AnalysisPromotionService(restarted).promote({ + operationId: 'operation-promotion-retry', + analysis, + selectedFindingIds: [supported.finding_id], + destinationConversationId: sourceHost.state.conversationId, + destinationBranchId: sourceHost.state.branchId, + }) + assert.equal(attachment.selectedFindings.length, 1) + assert.equal(attachment.selectedFindings[0]?.id, supported.finding_id) + assert.equal(restarted.state.analysisAttachments.length, 1) + const plan = portablePlanForState(restarted.state, { branchId: sourceHost.state.branchId }) + assert.equal(plan.analysisAttachments?.length, 1) + assert.equal(plan.analysisAttachments?.[0]?.findings.length, 1) + assert.equal(plan.analysisAttachments?.[0]?.analysisId, String(analysis.id)) +}) diff --git a/test/analysis-supervision.test.ts b/test/analysis-supervision.test.ts new file mode 100644 index 0000000..4d78c28 --- /dev/null +++ b/test/analysis-supervision.test.ts @@ -0,0 +1,529 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import type { + AnalystFinding, + ExactAnalystRunEvent, + ExactAnalystRunResult, +} from '@tangle-network/agent-eval' +import type { AgentProfile } from '@tangle-network/agent-interface' +import { mapAnalystFinding } from '../src/adapters/analysis/citations.js' +import { + AgentEvalAnalystAdapter, + type AnalystRegistryPort, +} from '../src/adapters/analysis/eval-analyst.js' +import { buildAnalysisTraceStore } from '../src/adapters/analysis/trace-store.js' +import { RuntimeSupervisorController } from '../src/adapters/runtime/supervisor-control.js' +import { + RuntimeSupervisorWatcher, + type TopSnapshot, +} from '../src/adapters/runtime/supervisor-watch.js' +import { AnalysisComparisonService } from '../src/app/analysis-comparison.js' +import { compareFrozenRuns } from '../src/app/analysis-comparison-facts.js' +import { AnalysisPromotionService } from '../src/app/analysis-promotion.js' +import { AnalysisService } from '../src/app/analysis-service.js' +import { freezeAnalysisSource, verifyFrozenAnalysisSource } from '../src/app/analysis-source.js' +import { + type AnalysisApplicationHost, + AnalysisSourceError, + type FrozenAnalysisEvidence, +} from '../src/app/analysis-types.js' +import { SupervisorService } from '../src/app/supervisor-service.js' +import type { AnalysisRecord } from '../src/domain/entities.js' +import type { BraidEvent, JournalEventEnvelope } from '../src/domain/events.js' +import { createAnalysisId, createEventId } from '../src/domain/ids-values.js' +import { replayEvents } from '../src/domain/reducer.js' +import { initialState } from '../src/domain/state.js' + +const NOW = '2026-08-03T20:00:00.000Z' +const TEST_PROFILE = {} as Readonly + +function envelope(event: BraidEvent, sequence: number): JournalEventEnvelope { + return { + eventId: createEventId(`event-${sequence}`), + sequence, + revision: sequence, + occurredAt: new Date(Date.parse(NOW) + sequence).toISOString(), + event, + } +} + +function history(runId = 'run-analysis', turnId = 'turn-analysis') { + const events: JournalEventEnvelope[] = [ + envelope({ kind: 'workspace.opened', workspace: '/tmp/braid' }, 1), + envelope({ kind: 'draft.changed', text: 'inspect the run' }, 2), + envelope( + { + kind: 'run.requested', + operationId: `operation-${runId}`, + runId, + turnId, + userMessageId: `message-user-${runId}`, + assistantMessageId: `message-assistant-${runId}`, + text: 'inspect the run', + }, + 3, + ), + envelope( + { + kind: 'run.tool.call', + runId, + partId: `part-tool-${runId}`, + toolName: 'shell', + callId: `call-${runId}`, + input: { command: 'printf safe' }, + provider: { + eventId: `provider-call-${runId}`, + providerSequence: 1, + occurredAt: NOW, + }, + }, + 4, + ), + envelope( + { + kind: 'run.tool.result', + runId, + partId: `part-tool-${runId}`, + toolName: 'shell', + callId: `call-${runId}`, + result: 'safe', + provider: { + eventId: `provider-result-${runId}`, + providerSequence: 2, + occurredAt: NOW, + }, + }, + 5, + ), + envelope( + { + kind: 'run.text.delta', + runId, + text: 'completed', + provider: { + eventId: `provider-text-${runId}`, + providerSequence: 3, + occurredAt: NOW, + }, + }, + 6, + ), + envelope( + { + kind: 'run.finished', + runId, + status: 'completed', + finalText: 'completed', + usage: { input: 4, output: 2 }, + }, + 7, + ), + ] + return { events, state: replayEvents(initialState(TEST_PROFILE), events) } +} + +function host( + state: ReturnType['state'], + events: readonly JournalEventEnvelope[], +): AnalysisApplicationHost & { readonly committed: BraidEvent[] } { + const committed: BraidEvent[] = [] + return { + currentState: () => state, + eventHistory: () => events, + commit: (event) => { + committed.push(event) + }, + now: () => NOW, + committed, + } +} + +function findingFor(evidence: FrozenAnalysisEvidence): AnalystFinding { + const spanId = buildAnalysisTraceStore(evidence).spans[0]?.spanId + assert.ok(spanId) + return { + schema_version: '1.0.0', + finding_id: 'finding-shell', + analyst_id: 'efficiency-behavioral', + produced_at: NOW, + severity: 'medium', + area: 'tool-use', + claim: 'The shell trace is supported', + confidence: 0.9, + evidence_refs: [ + { + kind: 'span', + uri: `trace://${evidence.source.runId}/span/${spanId}`, + excerpt: 'shell', + }, + ], + } +} + +function fakeRegistry( + options: { + readonly findings?: readonly AnalystFinding[] + readonly waitForAbort?: boolean + readonly received?: { options?: unknown; inputs?: unknown } + } = {}, +): AnalystRegistryPort { + const runExactStream: AnalystRegistryPort['runExactStream'] = async function* ( + runId, + inputs, + runOptions, + ): AsyncGenerator { + if (options.received !== undefined) { + options.received.options = runOptions + options.received.inputs = inputs + } + yield { + type: 'run-started', + run_id: runId, + correlation_id: 'correlation-test', + started_at: NOW, + analyst_ids: runOptions.analystIds, + execution_plan: {}, + } as unknown as ExactAnalystRunEvent + if (options.waitForAbort) { + await new Promise((resolve) => { + if (runOptions.signal?.aborted) { + resolve() + return + } + runOptions.signal?.addEventListener('abort', () => resolve(), { once: true }) + }) + return + } + const findings = [...(options.findings ?? [])] + yield { + type: 'analyst-started', + analyst_id: runOptions.analystIds[0] ?? 'efficiency-behavioral', + started_at: NOW, + } as unknown as ExactAnalystRunEvent + yield { + type: 'analyst-completed', + analyst_id: runOptions.analystIds[0] ?? 'efficiency-behavioral', + findings, + summary: {}, + } as unknown as ExactAnalystRunEvent + const result: ExactAnalystRunResult = { + run_id: runId, + correlation_id: 'correlation-test', + started_at: NOW, + ended_at: NOW, + findings, + per_analyst: [], + total_cost_usd: 0, + execution_plan: {}, + completion: { status: 'complete' }, + } as unknown as ExactAnalystRunResult + yield { type: 'run-completed', result } as unknown as ExactAnalystRunEvent + } + return { + list: () => [ + { + id: 'efficiency-behavioral', + description: 'test analyst', + version: '2.0.0', + cost: { kind: 'deterministic' }, + }, + ], + runExactStream, + } +} + +test('freezing a source holds the original digest when later journal events arrive', () => { + const first = history() + const evidence = freezeAnalysisSource({ + state: first.state, + events: first.events, + runId: 'run-analysis', + }) + const originalDigest = evidence.source.digest + const late = [ + ...first.events, + envelope({ kind: 'run.text.delta', runId: 'run-analysis', text: 'late event' }, 8), + ] + const laterEvidence = freezeAnalysisSource({ + state: first.state, + events: late, + runId: 'run-analysis', + }) + assert.equal(laterEvidence.source.digest, originalDigest) + assert.equal(evidence.source.digest, originalDigest) + assert.equal( + evidence.events.some( + (event) => event.event.kind === 'run.text.delta' && event.event.text === 'late event', + ), + false, + ) + assert.equal(Object.isFrozen(evidence), true) +}) + +test('frozen source verification rejects tampered evidence', () => { + const first = history() + const evidence = freezeAnalysisSource({ + state: first.state, + events: first.events, + runId: 'run-analysis', + }) + const tampered = { + ...evidence, + source: { ...evidence.source, digest: '0'.repeat(64) }, + } as FrozenAnalysisEvidence + assert.throws(() => verifyFrozenAnalysisSource(tampered), AnalysisSourceError) +}) + +test('eval adapter routes exact streaming and reports missing named analysts', async () => { + const first = history() + const evidence = freezeAnalysisSource({ + state: first.state, + events: first.events, + runId: 'run-analysis', + }) + const received: { options?: unknown; inputs?: unknown } = {} + const adapter = new AgentEvalAnalystAdapter(fakeRegistry({ received })) + const trace = buildAnalysisTraceStore(evidence) + const events: unknown[] = [] + for await (const item of adapter.stream({ + runId: 'analysis-run-test', + sourceDigest: String(evidence.source.digest), + trace, + recipe: 'cost', + })) + events.push(item.event.type) + assert.deepEqual(events, ['run-started', 'analyst-started', 'analyst-completed', 'run-completed']) + assert.deepEqual((received.options as { analystIds: string[] }).analystIds, [ + 'efficiency-behavioral', + ]) + assert.throws( + () => new AgentEvalAnalystAdapter().resolveAnalystIds({ recipe: 'failure' }), + (error: unknown) => error instanceof Error && 'issue' in error, + ) +}) + +test('analysis service persists only analysis events, emits progress, and preserves citations', async () => { + const first = history() + const evidence = freezeAnalysisSource({ + state: first.state, + events: first.events, + runId: 'run-analysis', + }) + const finding = findingFor(evidence) + const applicationHost = host(first.state, first.events) + const service = new AnalysisService( + applicationHost, + new AgentEvalAnalystAdapter(fakeRegistry({ findings: [finding] })), + ) + const result = await service.run({ runId: 'run-analysis', recipe: 'cost' }) + assert.equal(result.status, 'completed') + assert.equal(result.analysis.findings[0]?.supported, true) + assert.equal(result.analysis.findings[0]?.citations.length, 1) + assert.deepEqual( + applicationHost.committed + .filter((event) => event.kind.startsWith('analysis.')) + .map((event) => event.kind), + ['analysis.created', 'analysis.updated', 'analysis.completed'], + ) + const analyzed = applicationHost.committed.find((event) => event.kind === 'graph.edge.upserted') + assert.equal(analyzed?.kind, 'graph.edge.upserted') + if (analyzed?.kind === 'graph.edge.upserted') { + assert.equal(analyzed.edge.kind, 'analyzed') + assert.equal(analyzed.edge.provenance.sourceDigest, evidence.source.digest) + } + assert.equal( + applicationHost.committed.some((event) => event.kind === 'run.text.delta'), + false, + ) +}) + +test('analysis cancellation aborts the analyst stream without cancelling the source run', async () => { + const first = history() + const applicationHost = host(first.state, first.events) + const service = new AnalysisService( + applicationHost, + new AgentEvalAnalystAdapter(fakeRegistry({ waitForAbort: true })), + ) + const iterator = service.stream({ runId: 'run-analysis', recipe: 'cost' }) + const started = (await iterator.next()).value + assert.equal(started?.type, 'started') + await iterator.next() + await iterator.next() + const analysisId = + started?.type === 'started' ? started.analysis.id : createAnalysisId('analysis-test') + const pending = iterator.next() + assert.equal(service.cancel(analysisId), true) + const terminal = (await pending).value + assert.equal(terminal?.type, 'cancelled') + assert.equal( + applicationHost.committed.some((event) => event.kind === 'run.cancel.requested'), + false, + ) +}) + +test('paired comparison delegates facts to agent-eval and exposes missing semantic judging', async () => { + const baseline = history('run-baseline', 'turn-paired') + const candidate = history('run-candidate', 'turn-paired') + const baselineEvidence = freezeAnalysisSource({ + state: baseline.state, + events: baseline.events, + runId: 'run-baseline', + }) + const candidateEvidence = freezeAnalysisSource({ + state: candidate.state, + events: candidate.events, + runId: 'run-candidate', + }) + const result = compareFrozenRuns({ + baseline: baselineEvidence, + candidate: candidateEvidence, + bootstrapSeed: 7, + }) + assert.equal(result.paired.nPairs, 1) + assert.equal(result.paired.nUnpairedBaseline, 0) + assert.equal(result.paired.nUnpairedTreatment, 0) + assert.equal(result.semantic.status, 'unavailable') + const combinedState = { + ...baseline.state, + runs: [...baseline.state.runs, ...candidate.state.runs], + } + const combinedEvents = [ + ...baseline.events, + ...candidate.events.map((event) => ({ + ...event, + sequence: event.sequence + 10, + revision: event.revision + 10, + })), + ] + const comparison = await new AnalysisComparisonService( + host(combinedState, combinedEvents), + ).compare({ + baseline: { runId: 'run-baseline' }, + candidate: { runId: 'run-candidate' }, + }) + assert.equal(comparison.paired.nPairs, 1) +}) + +test('promotion records selected finding provenance as a graph attachment', async () => { + const first = history() + const evidence = freezeAnalysisSource({ + state: first.state, + events: first.events, + runId: 'run-analysis', + }) + const finding = mapAnalystFinding( + evidence, + buildAnalysisTraceStore(evidence), + findingFor(evidence), + ) + const analysis: AnalysisRecord = { + id: createAnalysisId('analysis-promotion-test'), + analysisRunId: 'analysis-run-promotion', + source: evidence.source, + status: 'completed', + findings: [finding], + createdAt: NOW, + updatedAt: NOW, + } + const applicationHost = host({ ...first.state, analyses: [analysis] }, first.events) + const attachment = await new AnalysisPromotionService(applicationHost).promote({ + analysis, + selectedFindingIds: [finding.id], + destinationConversationId: first.state.conversationId, + destinationBranchId: first.state.branchId, + }) + assert.equal(attachment.sourceDigest, String(evidence.source.digest)) + assert.equal(attachment.selectedFindings.length, 1) + const edge = applicationHost.committed.find((event) => event.kind === 'graph.edge.upserted') + assert.equal(edge?.kind, 'graph.edge.upserted') + if (edge?.kind === 'graph.edge.upserted') + assert.equal(edge.edge.provenance.sourceDigest, evidence.source.digest) +}) + +test('runtime supervisor adapter reads snapshots, persists projections, and leaves worker cancel unavailable', async () => { + const spend = { iterations: 1, tokensInput: 2, tokensOutput: 3, usd: 0.01, ms: 4 } + const raw = { + root: '/tmp/braid', + generatedAt: Date.parse(NOW), + supervisors: [ + { + id: 'runtime-supervisor-1', + status: 'running', + task: 'inspect', + workspaceDir: '/tmp/braid', + budget: 1, + stateDir: '/tmp/braid/.agent', + workers: [ + { + id: 'runtime-worker-1', + label: 'worker-one', + status: 'running', + latencyMs: 4, + spend, + metered: spend, + liveTail: ['safe progress'], + }, + ], + progressTail: [], + journalTail: [], + driverSpend: spend, + totals: { + workers: 1, + running: 1, + done: 0, + down: 0, + cancelled: 0, + inFlight: 1, + settled: 0, + tokensInput: 2, + tokensOutput: 3, + tokensTotal: 5, + usd: 0.01, + latencyMs: 4, + workerLatency: { n: 1, min: 4, median: 4, p90: 4, max: 4 }, + }, + }, + ], + } as unknown as TopSnapshot + const watcher = new RuntimeSupervisorWatcher(() => raw) + const controller = new RuntimeSupervisorController({ + watcher, + write: (_rootDir, supervisorId, workerLabel, message, source) => ({ + worker: workerLabel, + file: '/tmp/braid/.agent/inbox/request.json', + request: { + id: 'request-1', + at: NOW, + supervisorId, + worker: workerLabel, + message, + source: source ?? 'braid', + }, + }), + }) + const first = history() + const applicationHost = host(first.state, first.events) + const service = new SupervisorService(applicationHost, { watcher, controller }) + const projection = await service.snapshot({ rootDir: '/tmp/braid', rootRunId: 'run-root' }) + assert.equal(projection.supervisors.length, 1) + assert.equal(projection.workers[0]?.status, 'running') + assert.equal( + applicationHost.committed.some((event) => event.kind === 'supervisor.upserted'), + true, + ) + assert.equal( + applicationHost.committed.some((event) => event.kind === 'worker.upserted'), + true, + ) + const queued = await service.steerWorker( + '/tmp/braid', + 'runtime-supervisor-1', + 'worker-one', + 'inspect this', + ) + assert.equal(queued.status, 'queued') + if (queued.status === 'queued') assert.equal(queued.requestId, 'request-1') + const unavailable = await service.cancelWorker('runtime-worker-1') + assert.equal(unavailable.status, 'unavailable') + assert.equal(unavailable.issue.capability, 'supervisor.worker.cancel') +}) diff --git a/test/application.test.ts b/test/application.test.ts index d73b12f..c73db4e 100644 --- a/test/application.test.ts +++ b/test/application.test.ts @@ -1,12 +1,36 @@ import assert from 'node:assert/strict' import test from 'node:test' +import { type AgentProfile, defineAgentProfile } from '@tangle-network/agent-interface' import type { RuntimeStreamEvent } from '@tangle-network/agent-runtime' +import { createApplicationUiController } from '../src/adapters/tui/application-ui-controller.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 { createProfileRecord } from '../src/app/profiles.js' +import { runEffectRequest } from '../src/app/run-admission.js' import { buildAppView } from '../src/app/view-model.js' +import { canonicalDigest } from '../src/domain/canonical.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 { FixedClock } from '../src/ports/clock.js' -import type { ExecutionPort } from '../src/ports/execution.js' +import type { JournalPort } from '../src/ports/effect-storage.js' +import type { ExecutionAdmission, ExecutionPort } from '../src/ports/execution.js' import { SequenceIds } from '../src/ports/ids.js' +import { deterministicBackend } from '../src/testing/deterministic-backend.js' +import { MAX_RENDERED_TEXT_CHARS } from '../src/views/shared/sanitize.js' + +function deferred(): { + readonly promise: Promise + readonly resolve: (value: T | PromiseLike) => void +} { + let resolve!: (value: T | PromiseLike) => void + const promise = new Promise((complete) => { + resolve = complete + }) + return { promise, resolve } +} test('one send streams through runtime and reaches one terminal result', async () => { const app = createBraidApplication({ fixture: 'deterministic' }) @@ -27,15 +51,245 @@ test('one send streams through runtime and reaches one terminal result', async ( 'workspace.opened', 'draft.changed', 'run.requested', + 'effect.upserted', 'run.text.delta', 'run.text.delta', 'run.text.delta', 'run.text.delta', 'run.finished', + 'effect.upserted', ], ) }) +test('admission snapshots profile and connection before a blocked dispatch', async () => { + const profileA = defineAgentProfile({ + name: 'old profile', + harness: 'pi', + model: { default: 'fixture/old' }, + }) + const profileB = defineAgentProfile({ + name: 'new profile', + harness: 'pi', + model: { default: 'fixture/new' }, + }) + const sourceA = createProfileRecord( + { + kind: 'inline', + reference: 'race:old', + label: 'old profile', + writable: false, + trusted: true, + }, + profileA, + ) + const sourceB = createProfileRecord( + { + kind: 'inline', + reference: 'race:new', + label: 'new profile', + writable: false, + trusted: true, + }, + profileB, + ) + const connection = (name: string): ConnectionRecord => ({ + id: createConnectionId(`connection-race-${name}`), + kind: 'cli-bridge', + name, + providerOptions: { transport: 'local' }, + createdAt: '2026-08-03T00:00:00.000Z', + updatedAt: '2026-08-03T00:00:00.000Z', + lastHealth: { status: 'unknown' }, + }) + const connectionA = connection('old-connection') + const connectionB = connection('new-connection') + const dispatches: Array<{ + readonly input: Parameters>[0] + readonly release: () => void + }> = [] + const starts = [deferred(), deferred()] + const execution: ExecutionPort = { + admit: () => ({}), + async *streamTurn(input) { + const gate = deferred() + const index = dispatches.length + dispatches.push({ input, release: () => gate.resolve() }) + starts[index]?.resolve() + await gate.promise + yield { + type: 'final', + status: 'completed', + reason: 'race test completed', + text: `${input.profile.model?.default}:${input.connectionId}`, + metadata: { tokenUsage: { input: 1, output: 1 } }, + task: { id: `race-${index}`, intent: 'admission snapshot' }, + timestamp: '2026-08-03T00:00:00.000Z', + } + }, + } + const journal = new MemoryJournal(new FixedClock()) + const app = new BraidApplication({ + profile: profileA, + execution, + clock: new FixedClock(), + ids: new SequenceIds(), + journal, + effectStorage: journal, + }) + app.initialize('/workspace') + const controller = createApplicationUiController(app, {}, undefined, { + profiles: [sourceA, sourceB], + connections: [connectionA, connectionB], + }) + await controller.dispatch({ + type: 'headless-command', + command: 'select_profile', + operationId: 'op-select-old-profile', + params: { ref: sourceA.id }, + }) + await controller.dispatch({ + type: 'headless-command', + command: 'select_connection', + operationId: 'op-select-old-connection', + params: { connectionId: connectionA.id }, + }) + + const first = app.send({ operationId: 'op-race-old', text: 'first admitted turn' }) + assert.deepEqual(first.admission.requested.profile, profileA) + assert.equal(first.admission.requested.connectionId, connectionA.id) + await starts[0]?.promise + + await controller.dispatch({ + type: 'headless-command', + command: 'select_profile', + operationId: 'op-select-new-profile', + params: { ref: sourceB.id }, + }) + await controller.dispatch({ + type: 'headless-command', + command: 'select_connection', + operationId: 'op-select-new-connection', + params: { connectionId: connectionB.id }, + }) + dispatches[0]?.release() + await first.completion + + const second = app.send({ operationId: 'op-race-new', text: 'second admitted turn' }) + assert.deepEqual(second.admission.requested.profile, profileB) + assert.equal(second.admission.requested.connectionId, connectionB.id) + await starts[1]?.promise + dispatches[1]?.release() + await second.completion + + assert.equal(dispatches.length, 2) + assert.deepEqual(dispatches[0]?.input.profile, first.admission.requested.profile) + assert.equal(dispatches[0]?.input.connectionId, first.admission.requested.connectionId) + assert.deepEqual(dispatches[1]?.input.profile, second.admission.requested.profile) + assert.equal(dispatches[1]?.input.connectionId, second.admission.requested.connectionId) + assert.equal(first.admission.profileDigest, canonicalDigest(first.admission.requested.profile)) + assert.equal(second.admission.profileDigest, canonicalDigest(second.admission.requested.profile)) + + for (const receipt of [first, second]) { + const requested = app + .events() + .find((entry) => entry.event.kind === 'run.requested' && entry.event.runId === receipt.runId) + assert.ok(requested) + if (requested.event.kind !== 'run.requested') throw new Error('run request disappeared') + const request = runEffectRequest({ + operationId: receipt.operationId, + text: receipt.admission.requested.text, + conversationId: receipt.admission.conversationId, + branchId: receipt.admission.branchId, + profile: receipt.admission.requested.profile, + ...(receipt.admission.requested.connectionId === undefined + ? {} + : { connectionId: receipt.admission.requested.connectionId }), + }) + assert.match(requested.event.requestDigest ?? '', /^[0-9a-f]{64}$/u) + assert.notEqual( + requested.event.requestDigest, + effectRequestDigest({ effectKind: 'run.execute', request }), + ) + } +}) + +test('post-startup profile and connection selection reaches the next real resolver run', async () => { + const profileA = defineAgentProfile({ + name: 'resolver old profile', + harness: 'pi', + model: { default: 'fixture/resolver-old' }, + }) + const profileB = defineAgentProfile({ + name: 'resolver selected profile', + harness: 'pi', + model: { default: 'fixture/resolver-selected' }, + }) + const sourceB = createProfileRecord( + { + kind: 'inline', + reference: 'resolver:selected', + label: 'resolver selected profile', + writable: false, + trusted: true, + }, + profileB, + ) + const connectionB: ConnectionRecord = { + id: createConnectionId('connection-resolver-selected'), + kind: 'cli-bridge', + name: 'resolver selected connection', + providerOptions: { transport: 'local' }, + createdAt: '2026-08-03T00:00:00.000Z', + updatedAt: '2026-08-03T00:00:00.000Z', + lastHealth: { status: 'unknown' }, + } + const resolved: Array<{ + readonly profile: Readonly + readonly connectionId?: string + }> = [] + const journal = new MemoryJournal(new FixedClock()) + const app = createBraidApplication({ + profile: profileA, + backendResolver: (input) => { + resolved.push({ + profile: structuredClone(input.profile), + ...(input.connectionId === undefined ? {} : { connectionId: input.connectionId }), + }) + return deterministicBackend(input) + }, + clock: new FixedClock(), + ids: new SequenceIds(), + journal, + effectStorage: journal, + }) + app.initialize('/workspace') + const controller = createApplicationUiController(app, {}, undefined, { + profiles: [sourceB], + connections: [connectionB], + }) + await controller.dispatch({ + type: 'headless-command', + command: 'select_profile', + operationId: 'op-resolver-select-profile', + params: { ref: sourceB.id }, + }) + await controller.dispatch({ + type: 'headless-command', + command: 'select_connection', + operationId: 'op-resolver-select-connection', + params: { connectionId: connectionB.id }, + }) + + const receipt = app.send({ operationId: 'op-resolver-selected-run', text: 'resolver selection' }) + await receipt.completion + + assert.equal(resolved.length, 1) + assert.deepEqual(resolved[0]?.profile, profileB) + assert.equal(resolved[0]?.connectionId, connectionB.id) + assert.match(app.state().messages.at(-1)?.text ?? '', /resolver selection/u) +}) + test('an identical operation is replayed and conflicting input is rejected', async () => { const app = createBraidApplication({ fixture: 'deterministic' }) app.initialize('/workspace') @@ -61,7 +315,93 @@ test('an identical operation is replayed and conflicting input is rejected', asy () => app.send({ operationId: 'op-stable', text: 'changed input' }), (error: unknown) => error instanceof AppError && error.code === 'OPERATION_CONFLICT', ) - assert.equal(app.events().length, finalEventCount) + assert.equal(app.events().length, finalEventCount + 1) + assert.equal(app.events().at(-1)?.event.kind, 'effect.upserted') +}) + +test('async admission reserves one run before the provider becomes visible', async () => { + const admission = deferred() + const streaming = deferred() + let admissionCalls = 0 + let streamCalls = 0 + const execution: ExecutionPort = { + admissionMode: 'async', + async admit() { + admissionCalls += 1 + return admission.promise + }, + async *streamTurn(input) { + streamCalls += 1 + await streaming.promise + yield { + type: 'final', + status: 'completed', + reason: 'concurrent admission regression completed', + text: 'one provider run', + metadata: { tokenUsage: { input: 1, output: 1 } }, + task: { id: input.runId, intent: 'reserve admission' }, + timestamp: '2026-08-03T00:00:00.000Z', + } + }, + } + const journal = new MemoryJournal(new FixedClock()) + const app = new BraidApplication({ + profile: DETERMINISTIC_PROFILE, + execution, + clock: new FixedClock(), + ids: new SequenceIds(), + journal, + effectStorage: journal, + }) + app.initialize('/workspace') + + const first = app.send({ operationId: 'op-pending', text: 'send once' }) + const replay = app.send({ operationId: 'op-pending', text: 'send once' }) + assert.equal(replay.replayed, true) + assert.equal(replay.runId, first.runId) + assert.throws( + () => app.send({ operationId: 'op-conflict', text: 'second provider request' }), + (error: unknown) => error instanceof AppError && error.code === 'RUN_ACTIVE', + ) + assert.throws( + () => app.send({ operationId: 'op-pending', text: 'changed input' }), + (error: unknown) => error instanceof AppError && error.code === 'OPERATION_CONFLICT', + ) + + await new Promise((resolve) => setImmediate(resolve)) + assert.equal(admissionCalls, 1) + admission.resolve({}) + await first.admissionReady + await new Promise((resolve) => setImmediate(resolve)) + assert.equal(streamCalls, 1) + assert.throws( + () => app.send({ operationId: 'op-active', text: 'still too soon' }), + (error: unknown) => error instanceof AppError && error.code === 'RUN_ACTIVE', + ) + + streaming.resolve() + const [firstState, replayState] = await Promise.all([first.completion, replay.completion]) + assert.equal(firstState.runs.length, 1) + assert.equal(replayState.runs.length, 1) + assert.equal(admissionCalls, 1) + assert.equal(streamCalls, 1) + assert.equal(app.storageFailure(), undefined) +}) + +test('send rejects malformed operation identities and oversized input before journaling', () => { + const app = createBraidApplication({ fixture: 'deterministic' }) + app.initialize('/workspace') + const eventCount = app.events().length + + assert.throws( + () => app.send({ operationId: 'token=do-not-store', text: 'hello' }), + (error: unknown) => error instanceof AppError && error.code === 'INVALID_OPERATION_ID', + ) + assert.throws( + () => app.send({ operationId: 'op-too-large', text: 'x'.repeat(1024 * 1024 + 1) }), + (error: unknown) => error instanceof AppError && error.code === 'MESSAGE_TOO_LARGE', + ) + assert.equal(app.events().length, eventCount) }) test('the deterministic stream preserves leading and consecutive newlines', async () => { @@ -98,11 +438,14 @@ test('blocked and unconfigured states remain explicit', async () => { } }, } + const blockedJournal = new MemoryJournal(new FixedClock()) const blocked = new BraidApplication({ profile: DETERMINISTIC_PROFILE, execution, clock: new FixedClock(), ids: new SequenceIds(), + journal: blockedJournal, + effectStorage: blockedJournal, }) blocked.initialize('/workspace') const blockedState = await blocked.send({ operationId: 'op-blocked', text: 'wait' }).completion @@ -112,3 +455,630 @@ test('blocked and unconfigured states remain explicit', async () => { assert.equal(unconfigured.connection, 'not connected') assert.equal(unconfigured.model, 'automatic') }) + +test('a terminal journal failure never acknowledges the external run', async () => { + const delegate = new MemoryJournal(new FixedClock()) + let terminalAttempts = 0 + const journal: JournalPort = { + envelope: (state, event) => delegate.envelope(state, event), + append: (envelope) => { + if (envelope.event.kind === 'run.finished') { + terminalAttempts += 1 + throw new Error('journal commit failed') + } + delegate.append(envelope) + }, + all: () => delegate.all(), + } + const app = new BraidApplication({ + profile: DETERMINISTIC_PROFILE, + execution: { + async *streamTurn(): AsyncIterable { + yield { + type: 'final', + status: 'completed', + reason: 'completed', + text: 'externally completed', + metadata: { tokenUsage: { input: 1, output: 1 } }, + task: { id: 'task-terminal-failure', intent: 'terminal failure' }, + timestamp: '2026-08-01T00:00:00.000Z', + } + }, + }, + clock: new FixedClock(), + ids: new SequenceIds(), + journal, + effectStorage: delegate, + }) + + app.initialize('/workspace') + const receipt = app.send({ operationId: 'op-terminal-journal-failure', text: 'hello' }) + const state = await receipt.completion + + assert.equal(terminalAttempts, 2) + assert.equal(delegate.current('op-terminal-journal-failure')?.status, 'unknown') + assert.equal(state.runs[0]?.status, 'streaming') + assert.equal(state.activeRunId, state.runs[0]?.id) +}) + +test('completion waits for the terminal event and effect projection to flush', async () => { + const delegate = new MemoryJournal(new FixedClock()) + const flushes: string[] = [] + let terminalQueued = false + let terminalEffectQueued = false + const journal: JournalPort = { + envelope: (state, event) => delegate.envelope(state, event), + append: (envelope) => { + delegate.append(envelope) + if (envelope.event.kind === 'run.finished') terminalQueued = true + if ( + envelope.event.kind === 'effect.upserted' && + envelope.event.effect.status === 'terminal' + ) { + terminalEffectQueued = true + } + }, + all: () => delegate.all(), + flush: async () => { + flushes.push( + terminalEffectQueued ? 'terminal-effect' : terminalQueued ? 'terminal-event' : 'intent', + ) + }, + } + const app = new BraidApplication({ + profile: DETERMINISTIC_PROFILE, + execution: { + async *streamTurn(): AsyncIterable { + yield { + type: 'final', + status: 'completed', + reason: 'completed', + text: 'complete', + metadata: { tokenUsage: { input: 1, output: 1 } }, + task: { id: 'task-flush-order', intent: 'flush order' }, + timestamp: '2026-08-01T00:00:00.000Z', + } + }, + }, + clock: new FixedClock(), + ids: new SequenceIds(), + journal, + effectStorage: delegate, + }) + + app.initialize('/workspace') + await app.send({ operationId: 'op-flush-order', text: 'hello' }).completion + + assert.deepEqual(flushes, ['intent', 'terminal-event', 'terminal-effect']) +}) + +test('an asynchronous terminal write failure leaves the external outcome unknown', async () => { + const delegate = new MemoryJournal(new FixedClock()) + let terminalQueued = false + const journal: JournalPort = { + envelope: (state, event) => delegate.envelope(state, event), + append: (envelope) => { + delegate.append(envelope) + if (envelope.event.kind === 'run.finished') terminalQueued = true + }, + all: () => delegate.all(), + flush: async () => { + if (terminalQueued) throw new Error('delayed disk failure') + }, + } + const app = new BraidApplication({ + profile: DETERMINISTIC_PROFILE, + execution: { + async *streamTurn(): AsyncIterable { + yield { + type: 'final', + status: 'completed', + reason: 'completed', + text: 'externally complete', + metadata: { tokenUsage: { input: 1, output: 1 } }, + task: { id: 'task-async-terminal-failure', intent: 'async terminal failure' }, + timestamp: '2026-08-01T00:00:00.000Z', + } + }, + }, + clock: new FixedClock(), + ids: new SequenceIds(), + journal, + effectStorage: delegate, + }) + + app.initialize('/workspace') + const receipt = app.send({ operationId: 'op-async-terminal-failure', text: 'hello' }) + await assert.rejects(receipt.completion, /delayed disk failure/u) + assert.equal(delegate.current('op-async-terminal-failure')?.status, 'unknown') +}) + +test('provider diagnostics and model metadata cannot persist credential material', async () => { + const canary = 'never-persist-this-value' + const journal = new MemoryJournal(new FixedClock()) + const app = new BraidApplication({ + profile: DETERMINISTIC_PROFILE, + execution: { + async *streamTurn(): AsyncIterable { + yield { + type: 'final', + status: 'failed', + reason: 'failed', + text: '', + error: { + kind: 'transport', + message: `request failed at https://user:${canary}@provider.example/v1`, + }, + metadata: { + model: `model-token-${canary}`, + tokenUsage: { input: Number.POSITIVE_INFINITY, output: -1 }, + }, + task: { id: 'task-redaction', intent: 'redaction' }, + timestamp: '2026-08-01T00:00:00.000Z', + } + }, + }, + clock: new FixedClock(), + ids: new SequenceIds(), + journal, + effectStorage: journal, + }) + + app.initialize('/workspace') + const state = await app.send({ operationId: 'op-redaction', text: 'hello' }).completion + const serialized = JSON.stringify({ state, events: app.events() }) + assert.equal(serialized.includes(canary), false) + assert.equal(state.runs[0]?.error, 'RUNTIME_FINAL_ERROR') + assert.equal(state.runs[0]?.model, undefined) + assert.equal(state.runs[0]?.inputTokens, 0) + assert.equal(state.runs[0]?.outputTokens, 0) +}) + +test('subscriber failures cannot alter a completed run', async () => { + const app = createBraidApplication({ fixture: 'deterministic' }) + app.subscribe(() => { + throw new Error('renderer failed') + }) + app.initialize('/workspace') + + const state = await app.send({ operationId: 'op-subscriber', text: 'hello' }).completion + + assert.equal(state.runs[0]?.status, 'completed') +}) + +test('events after the first final result are ignored', async () => { + const journal = new MemoryJournal(new FixedClock()) + const app = new BraidApplication({ + profile: DETERMINISTIC_PROFILE, + execution: { + async *streamTurn(): AsyncIterable { + yield { + type: 'final', + status: 'completed', + reason: 'completed', + text: 'first final', + metadata: { tokenUsage: { input: 1, output: 1 } }, + task: { id: 'task-first-final', intent: 'first final' }, + timestamp: '2026-08-01T00:00:00.000Z', + } + yield { + type: 'text_delta', + text: 'must not append', + task: { id: 'task-first-final', intent: 'first final' }, + timestamp: '2026-08-01T00:00:00.001Z', + } + }, + }, + clock: new FixedClock(), + ids: new SequenceIds(), + journal, + effectStorage: journal, + }) + + app.initialize('/workspace') + const state = await app.send({ operationId: 'op-first-final', text: 'hello' }).completion + + assert.equal(state.messages[1]?.text, 'first final') + assert.equal(app.events().filter((entry) => entry.event.kind === 'run.text.delta').length, 0) +}) + +test('provider errors and profile values are redacted before state and journal commit', async () => { + let providerSawRawProfile = false + const execution: ExecutionPort = { + async *streamTurn(input): AsyncIterable { + providerSawRawProfile = JSON.stringify(input.profile).includes('CANARY-RAW-PROFILE') + yield* [] + throw new Error( + 'request failed https://user:CANARY-URL@example.com/?token=CANARY-QUERY Bearer CANARY-BEARER', + ) + }, + } + const journal = new MemoryJournal(new FixedClock()) + const app = new BraidApplication({ + profile: { + ...DETERMINISTIC_PROFILE, + metadata: { + rawProfile: 'CANARY-RAW-PROFILE', + mcpConfig: { command: 'CANARY-MCP-CONFIG' }, + attestationNonce: 'CANARY-ATTESTATION-NONCE', + authorization: 'Bearer CANARY-PROFILE-BEARER', + }, + }, + execution, + clock: new FixedClock(), + ids: new SequenceIds(), + journal, + effectStorage: journal, + }) + app.initialize('/workspace') + await app.send({ operationId: 'op-redaction-profile', text: 'trigger provider error' }).completion + assert.equal(providerSawRawProfile, true) + const serialized = JSON.stringify({ state: app.state(), events: app.events() }) + assert.equal(serialized.includes('CANARY'), false) + assert.match(serialized, /\[redacted(?: link| bearer)?\]/u) + assert.equal(app.state().runs[0]?.status, 'failed') + assert.equal(app.state().lastError?.includes('CANARY'), false) + const controller = createApplicationUiController(app) + const surfaces = JSON.stringify({ + state: controller.state(), + view: controller.view(), + events: controller.events(), + }) + assert.equal(surfaces.includes('CANARY'), false) + assert.equal(controller.view().statusText.includes('CANARY'), false) +}) + +test('restart persistence does not expose low-entropy raw prompt or public request hash', async () => { + const secretPrompt = 'password=0427' + const secretProfile = defineAgentProfile({ + ...DETERMINISTIC_PROFILE, + metadata: { recoveryCode: '0427' }, + }) + let dispatches = 0 + let deliveredText: string | undefined + let deliveredProfile: unknown + const execution: ExecutionPort = { + async *streamTurn(input): AsyncIterable { + dispatches += 1 + deliveredText = input.text + deliveredProfile = structuredClone(input.profile) + yield { + type: 'final', + status: 'completed', + reason: 'secret digest boundary test', + text: 'safe response', + metadata: { tokenUsage: { input: 1, output: 1 } }, + task: { id: 'task-secret-digest', intent: 'secret digest boundary' }, + timestamp: '2026-08-01T00:00:00.000Z', + } + }, + } + const journal = new MemoryJournal(new FixedClock()) + const first = new BraidApplication({ + profile: secretProfile, + execution, + clock: new FixedClock(), + ids: new SequenceIds(), + journal, + effectStorage: journal, + }) + first.initialize('/workspace') + const send = first.send({ operationId: 'op-secret-digest', text: secretPrompt }) + await send.completion + + assert.equal(deliveredText, secretPrompt) + assert.deepEqual(deliveredProfile, secretProfile) + const publicRequestHash = effectRequestDigest({ + effectKind: 'run.execute', + request: runEffectRequest({ + operationId: send.operationId, + text: secretPrompt, + conversationId: first.state().conversationId, + branchId: first.state().branchId, + profile: secretProfile, + }), + }) + const firstSerialized = JSON.stringify({ state: first.state(), events: first.events() }) + assert.equal(firstSerialized.includes(secretPrompt), false) + assert.equal(firstSerialized.includes(publicRequestHash), false) + + const restarted = new BraidApplication({ + profile: secretProfile, + execution, + clock: new FixedClock(), + ids: new SequenceIds(), + journal, + effectStorage: journal, + }) + const replay = restarted.send({ operationId: 'op-secret-digest', text: secretPrompt }) + assert.equal(replay.replayed, true) + await replay.completion + assert.equal(dispatches, 1) + const restartedSerialized = JSON.stringify({ + state: restarted.state(), + events: restarted.events(), + }) + assert.equal(restartedSerialized.includes(secretPrompt), false) + assert.equal(restartedSerialized.includes(publicRequestHash), false) + assert.equal(restartedSerialized.includes('executionProfileDigest'), false) + assert.equal(restartedSerialized.includes('executionRequestDigest'), false) +}) + +test('cancel uses the operation ledger and replays after terminal completion', async () => { + const app = createBraidApplication({ fixture: 'deterministic', chunkDelayMs: 25 }) + app.initialize('/workspace') + const send = app.send({ operationId: 'op-cancel-ledger', text: 'cancel this turn' }) + const first = app.cancel({ operationId: 'op-cancel-stable', runId: send.runId }) + const firstState = await first.completion + assert.equal(firstState.runs[0]?.status, 'aborted') + assert.equal( + app.events().filter((entry) => entry.event.kind === 'run.cancel.requested').length, + 1, + ) + const replay = app.cancel({ operationId: 'op-cancel-stable', runId: send.runId }) + assert.equal(replay.replayed, true) + assert.equal((await replay.completion).runs[0]?.status, 'aborted') + assert.equal( + app.events().filter((entry) => entry.event.kind === 'run.cancel.requested').length, + 1, + ) + assert.throws( + () => app.cancel({ operationId: 'op-cancel-stable', runId: 'run-another' }), + (error: unknown) => error instanceof AppError && error.code === 'OPERATION_CONFLICT', + ) +}) + +test('cancel resolves unknown when the adapter cannot confirm the provider outcome', async () => { + const execution: ExecutionPort = { + async *streamTurn(): AsyncIterable { + await new Promise(() => {}) + yield { type: 'text_delta', text: 'never emitted' } + }, + } + const journal = new MemoryJournal(new FixedClock()) + const app = new BraidApplication({ + profile: DETERMINISTIC_PROFILE, + execution, + clock: new FixedClock(), + ids: new SequenceIds(), + journal, + effectStorage: journal, + cancelTimeoutMs: 100, + }) + app.initialize('/workspace') + const send = app.send({ operationId: 'op-send-ignores-abort', text: 'wait for unknown' }) + const cancel = app.cancel({ operationId: 'op-cancel-ignores-abort', runId: send.runId }) + const state = await app.waitForIdle() + + assert.equal(state.activeRunId, null) + assert.equal(state.runs[0]?.status, 'unknown') + assert.equal((await cancel.completion).runs[0]?.status, 'unknown') + assert.match(state.lastError ?? '', /could not be confirmed/iu) +}) + +test('provider acknowledgement, not local abort, settles cancellation', async () => { + let providerCancellationCalls = 0 + let releaseStream: (() => void) | undefined + let streamStarted!: () => void + const streamReady = new Promise((resolve) => { + streamStarted = resolve + }) + const execution: ExecutionPort = { + capabilities: { cancel: true }, + async *streamTurn(): AsyncIterable { + streamStarted() + await new Promise((resolve) => { + releaseStream = resolve + }) + yield { + type: 'final', + status: 'completed', + reason: 'provider acknowledged the late result', + text: 'late provider result', + metadata: { tokenUsage: { input: 1, output: 1 } }, + task: { id: 'task-late', intent: 'late result' }, + timestamp: '2026-08-01T00:00:00.000Z', + } + }, + async cancelRun(): Promise<{ readonly status: 'cancelled' }> { + providerCancellationCalls += 1 + return { status: 'cancelled' } + }, + } + const journal = new MemoryJournal(new FixedClock()) + const app = new BraidApplication({ + profile: DETERMINISTIC_PROFILE, + execution, + clock: new FixedClock(), + ids: new SequenceIds(), + journal, + effectStorage: journal, + cancelTimeoutMs: 5_000, + }) + app.initialize('/workspace') + const send = app.send({ operationId: 'op-provider-cancel', text: 'provider cancellation' }) + await streamReady + const startedAt = performance.now() + const cancel = app.cancel({ operationId: 'op-provider-cancel-request', runId: send.runId }) + const state = await cancel.completion + + assert.equal(providerCancellationCalls, 1) + assert.equal(state.runs[0]?.status, 'aborted') + assert.equal(state.lastError, 'Cancellation acknowledged by the provider') + assert.ok(performance.now() - startedAt < 1_000) + + releaseStream?.() + await send.completion +}) + +test('a restarted application replays the journal instead of redispatching', async () => { + let streamStarts = 0 + let streamStarted!: () => void + let releaseStream: (() => void) | undefined + const streamReady = new Promise((resolve) => { + streamStarted = resolve + }) + const execution: ExecutionPort = { + capabilities: { cancel: true }, + async *streamTurn(): AsyncIterable { + streamStarts += 1 + streamStarted() + await new Promise((resolve) => { + releaseStream = resolve + }) + yield { + type: 'final', + status: 'completed', + reason: 'provider acknowledged the restart test', + text: 'should not be dispatched twice', + metadata: { tokenUsage: { input: 1, output: 1 } }, + task: { id: 'task-restart', intent: 'restart test' }, + timestamp: '2026-08-01T00:00:00.000Z', + } + }, + async cancelRun(): Promise<{ readonly status: 'cancelled' }> { + return { status: 'cancelled' } + }, + } + const durable = new MemoryJournal(new FixedClock()) + const first = new BraidApplication({ + profile: DETERMINISTIC_PROFILE, + execution, + clock: new FixedClock(), + ids: new SequenceIds(), + journal: durable, + effectStorage: durable, + cancelTimeoutMs: 100, + }) + first.initialize('/workspace') + const send = first.send({ operationId: 'op-durable-send', text: 'restart me' }) + await streamReady + + const restarted = new BraidApplication({ + profile: DETERMINISTIC_PROFILE, + execution, + clock: new FixedClock(), + ids: new SequenceIds(), + journal: durable, + effectStorage: durable, + cancelTimeoutMs: 100, + }) + assert.equal(restarted.state().runs[0]?.status, 'unknown') + assert.throws( + () => restarted.send({ operationId: 'op-durable-send', text: 'restart me' }), + (error: unknown) => + error instanceof AppError && error.code === 'OPERATION_REQUIRES_RECONCILIATION', + ) + assert.equal(streamStarts, 1) + assert.equal(restarted.events().filter((entry) => entry.event.kind === 'run.requested').length, 1) + + const shutdown = restarted.shutdown({ operationId: 'op-durable-shutdown' }) + assert.equal(shutdown.replayed, false) + await shutdown.completion + const shutdownReplay = restarted.shutdown({ operationId: 'op-durable-shutdown' }) + assert.equal(shutdownReplay.replayed, true) + await shutdownReplay.completion + assert.equal( + restarted.events().filter((entry) => entry.event.kind === 'application.shutdown.requested') + .length, + 1, + ) + + releaseStream?.() + await send.completion +}) + +test('restart reconciles an in-flight cancellation to honest unknown and replays it', async () => { + const journal = new MemoryJournal(new FixedClock()) + const seeded: readonly BraidEventEnvelope[] = [ + { + sequence: 1, + revision: 1, + occurredAt: '2026-08-01T00:00:00.000Z', + event: { kind: 'workspace.opened', workspace: '/workspace' }, + }, + { + sequence: 2, + revision: 2, + occurredAt: '2026-08-01T00:00:00.000Z', + event: { + kind: 'run.requested', + operationId: 'op-send-restart', + runId: 'run-restart', + turnId: 'turn-restart', + userMessageId: 'message-user', + assistantMessageId: 'message-assistant', + text: 'restart this turn', + }, + }, + { + sequence: 3, + revision: 3, + occurredAt: '2026-08-01T00:00:00.000Z', + event: { + kind: 'run.cancel.requested', + operationId: 'op-cancel-restart', + runId: 'run-restart', + reason: 'user requested cancellation', + }, + }, + ] + for (const envelope of seeded) journal.append(envelope) + + const app = new BraidApplication({ + profile: DETERMINISTIC_PROFILE, + execution: { streamTurn: async function* () {} }, + clock: new FixedClock(), + ids: new SequenceIds(), + journal, + effectStorage: journal, + }) + + assert.equal(app.state().runs[0]?.status, 'unknown') + assert.equal(app.state().messages[1]?.status, 'incomplete') + const finalEvent = app.events().at(-1)?.event + assert.equal(finalEvent?.kind, 'run.finished') + if (finalEvent?.kind !== 'run.finished') assert.fail('missing restart reconciliation event') + assert.equal(finalEvent.status, 'unknown') + const replayed = app.cancel({ + operationId: 'op-cancel-restart', + runId: 'run-restart', + reason: 'user requested cancellation', + }) + assert.equal(replayed.replayed, true) + assert.equal((await replayed.completion).runs[0]?.status, 'unknown') +}) + +test('assistant parts are bounded before the terminal renderer sees them', async () => { + const oversized = 'x'.repeat(MAX_RENDERED_TEXT_CHARS + 1_024) + const execution: ExecutionPort = { + async *streamTurn(): AsyncIterable { + yield { + type: 'final', + status: 'completed', + reason: 'provider returned the bounded fixture', + text: oversized, + metadata: { tokenUsage: { input: 1, output: 1 } }, + task: { id: 'task-large', intent: 'large output' }, + timestamp: '2026-08-01T00:00:00.000Z', + } + }, + } + const journal = new MemoryJournal(new FixedClock()) + const app = new BraidApplication({ + profile: DETERMINISTIC_PROFILE, + execution, + clock: new FixedClock(), + ids: new SequenceIds(), + journal, + effectStorage: journal, + }) + app.initialize('/workspace') + await app.send({ operationId: 'op-large', text: 'large output' }).completion + const assistant = createApplicationUiController(app).view().messages.at(-1) + assert.ok(assistant) + assert.ok( + (assistant?.parts[0]?.text.length ?? Number.POSITIVE_INFINITY) <= MAX_RENDERED_TEXT_CHARS, + ) + assert.equal(assistant?.parts[0]?.text.includes('\u001b'), false) +}) diff --git a/test/cli-bridge-profile-contract.test.ts b/test/cli-bridge-profile-contract.test.ts new file mode 100644 index 0000000..12308ef --- /dev/null +++ b/test/cli-bridge-profile-contract.test.ts @@ -0,0 +1,239 @@ +import assert from 'node:assert/strict' +import { createServer, type IncomingMessage, type Server } from 'node:http' +import { once } from 'node:events' +import test from 'node:test' +import type { AgentProfile } from '@tangle-network/agent-interface' +import { AgentRuntimeExecutionPort } from '../src/adapters/runtime/agent-runtime-execution.js' +import { + createProductionBackendResolver, + type ProductionBackendResolverOptions, +} from '../src/adapters/runtime/production-backend-resolver.js' +import { ConnectionRegistry } from '../src/app/connections.js' +import type { ConnectionRecord } from '../src/domain/entities.js' +import { createConnectionId } from '../src/domain/ids.js' +import type { ExecuteTurnInput } from '../src/ports/execution.js' + +const at = '2026-08-03T12:00:00.000Z' +const sessionId = 'bridge-session-contract' +const workspaceCwd = '/tmp/braid-contract-workspace' + +const profile: AgentProfile = { + name: 'braid-profile-contract', + description: 'Profile contract fixture', + version: '1.0.0', + tags: ['contract', 'profile'], + prompt: { + systemPrompt: 'Use the selected profile exactly.', + instructions: ['Keep answers concise.'], + }, + model: { + default: 'pi/openai-codex/gpt-5.6-luna', + small: 'pi/openai-codex/gpt-5.5', + provider: 'openai-codex', + reasoningEffort: 'high', + metadata: { contractModelHint: 'preserved' }, + }, + harness: 'pi', + permissions: { read: 'allow', write: 'ask' }, + tools: { read: true, write: false }, + mcp: { + disabled: { enabled: false }, + remote: { transport: 'http', url: 'https://example.com/mcp', metadata: { scope: 'contract' } }, + }, + connections: [{ connectionId: 'hub-demo', capabilities: ['tickets.read'], alias: 'tickets' }], + subagents: { + reviewer: { + description: 'Reviews the response', + prompt: 'Review only.', + model: 'pi/openai-codex/gpt-5.5', + tools: { read: true }, + permissions: { read: 'allow' }, + maxSteps: 2, + metadata: { role: 'reviewer' }, + }, + }, + resources: { + files: [ + { + path: 'AGENTS.md', + resource: { kind: 'inline', name: 'contract', content: 'Contract instructions.' }, + }, + ], + tools: [{ kind: 'inline', name: 'tool', content: 'Tool description.' }], + skills: [{ kind: 'inline', name: 'skill', content: 'Skill description.' }], + agents: [{ kind: 'inline', name: 'agent', content: 'Agent description.' }], + commands: [{ kind: 'inline', name: 'command', content: 'Command description.' }], + instructions: { kind: 'inline', name: 'instructions', content: 'Resource instructions.' }, + failOnError: true, + }, + hooks: { + before: [{ command: 'echo contract-hook', timeoutMs: 1000, blocking: true, matcher: '.*' }], + }, + modes: { + review: { + description: 'Review mode', + model: 'pi/openai-codex/gpt-5.5', + prompt: 'Review.', + tools: { read: true }, + permissions: { read: 'allow' }, + metadata: { mode: true }, + }, + }, + confidential: { tee: 'any', sealed: false }, + metadata: { contract: 'all-profile-dimensions' }, + extensions: { 'provider.test': { enabled: true, version: 1 } }, +} + +function connection(endpoint: string): ConnectionRecord { + return { + id: createConnectionId('connection-cli-contract'), + kind: 'cli-bridge', + name: 'Contract CLI Bridge', + endpoint, + providerOptions: { transport: 'local' }, + createdAt: at, + updatedAt: at, + lastHealth: { status: 'unknown' }, + } +} + +async function requestBody(request: IncomingMessage): Promise> { + const chunks: Buffer[] = [] + for await (const chunk of request) chunks.push(Buffer.from(chunk)) + return JSON.parse(Buffer.concat(chunks).toString('utf8')) as Record +} + +function responseStream(): string { + return [ + `data: ${JSON.stringify({ choices: [{ delta: { content: 'CONTRACT_OK' }, finish_reason: null }] })}`, + '', + `data: ${JSON.stringify({ choices: [{ delta: {}, finish_reason: 'stop' }], usage: { prompt_tokens: 2, completion_tokens: 1 } })}`, + '', + 'data: [DONE]', + '', + ].join('\n') +} + +async function startServer(bodies: Array>): Promise { + const server = createServer(async (request, response) => { + if (request.method !== 'POST' || !request.url?.endsWith('/chat/completions')) { + response.writeHead(404).end() + return + } + bodies.push(await requestBody(request)) + response.writeHead(200, { 'content-type': 'text/event-stream' }).end(responseStream()) + }) + server.listen(0, '127.0.0.1') + await once(server, 'listening') + return server +} + +function input(value: string, runId: string): ExecuteTurnInput { + return { + operationId: `operation-${runId}`, + runId, + text: value, + profile, + connectionId: contractConnection.id, + sessionId, + signal: new AbortController().signal, + } +} + +let contractConnection: ConnectionRecord + +test('production CLI Bridge sends the frozen profile and complete turn identity', async () => { + const bodies: Array> = [] + const server = await startServer(bodies) + try { + const address = server.address() + assert.ok(address && typeof address === 'object') + contractConnection = connection(`http://127.0.0.1:${address.port}`) + const registry = new ConnectionRegistry([contractConnection]) + const options: ProductionBackendResolverOptions = { + connections: registry, + workspaceCwd, + select: () => ({ + connection: { connectionId: contractConnection.id }, + runner: 'pi', + ...(profile.model?.default === undefined ? {} : { model: profile.model.default }), + }), + } + const resolver = createProductionBackendResolver(options) + const first = input('first contract turn', 'run-contract-1') + const second = input('second contract turn', 'run-contract-2') + const prepared = await resolver(first) + assert.equal(prepared.kind, 'sandbox-plan') + if (prepared.kind !== 'sandbox-plan') return + assert.equal(prepared.createInput.backend, 'pi') + assert.equal(prepared.createInput.workspace?.cwd, workspaceCwd) + + const execution = new AgentRuntimeExecutionPort(resolver) + const firstAdmission = await execution.admit(first) + assert.equal(firstAdmission.providerSessionId, sessionId) + assert.equal(firstAdmission.capabilities?.environment?.streaming.replay, true) + assert.equal(firstAdmission.capabilities?.environment?.sessions.continue, true) + assert.equal(firstAdmission.capabilities?.environment?.streaming.turnIdempotency, true) + assert.equal(firstAdmission.capabilities?.streaming.replay, false) + assert.equal(firstAdmission.capabilities?.sessions.continue, true) + assert.equal(firstAdmission.capabilities?.controls.cancel, false) + assert.equal(firstAdmission.capabilities?.controls.status, false) + const firstEvents = [] + for await (const event of execution.streamTurn(first)) firstEvents.push(event) + const secondAdmission = await execution.admit(second) + assert.equal(secondAdmission.providerSessionId, sessionId) + const secondEvents = [] + for await (const event of execution.streamTurn(second)) secondEvents.push(event) + assert.equal(firstEvents.at(-1)?.type, 'final') + assert.equal(secondEvents.at(-1)?.type, 'final') + assert.equal(bodies.length, 2) + + for (const [body, turn] of bodies.map( + (body, index) => [body, index === 0 ? first : second] as const, + )) { + assert.deepEqual(body.agent_profile, profile) + assert.equal( + body.agent_profile && + typeof body.agent_profile === 'object' && + 'harness' in body.agent_profile + ? body.agent_profile.harness + : undefined, + 'pi', + ) + assert.equal(body.model, profile.model?.default) + assert.equal(body.run_id, turn.runId) + assert.equal(body.session_id, sessionId) + assert.equal(body.cwd, workspaceCwd) + assert.equal( + body.messages && Array.isArray(body.messages) ? body.messages.at(-1)?.content : undefined, + turn.text, + ) + assert.equal(JSON.stringify(body).includes('inline-'), false) + } + } finally { + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ) + } +}) + +test('a turn cancelled before iteration never resolves or materializes execution', async () => { + let resolverCalls = 0 + const execution = new AgentRuntimeExecutionPort(async () => { + resolverCalls += 1 + throw new Error('resolver must not run after cancellation') + }) + const abort = new AbortController() + abort.abort(new Error('cancelled before start')) + const cancelled = { + ...input('cancelled contract turn', 'run-contract-cancelled'), + signal: abort.signal, + } + + await assert.rejects(async () => { + for await (const _event of execution.streamTurn(cancelled)) { + // A pre-cancelled turn cannot emit runtime work. + } + }, /cancelled before start/u) + assert.equal(resolverCalls, 0) +}) diff --git a/test/cli-startup.test.ts b/test/cli-startup.test.ts new file mode 100644 index 0000000..4656ee9 --- /dev/null +++ b/test/cli-startup.test.ts @@ -0,0 +1,74 @@ +import assert from 'node:assert/strict' +import { access, readFile } from 'node:fs/promises' +import test from 'node:test' +import { CliUsageError, parseArgs } from '../src/bin/args.js' + +async function repositoryRoot(): Promise { + const candidates = [new URL('../', import.meta.url), new URL('../../', import.meta.url)] + for (const candidate of candidates) { + try { + await access(new URL('package.json', candidate)) + return candidate + } catch {} + } + throw new Error('Could not locate the Braid repository root') +} + +test('expected command-line mistakes remain actionable without echoing arbitrary values', () => { + assert.throws( + () => parseArgs(['--workspace'], '/workspace'), + (error: unknown) => + error instanceof CliUsageError && error.message === '--workspace requires a value', + ) + assert.throws( + () => parseArgs(['--fixture', 'secret-canary'], '/workspace'), + (error: unknown) => + error instanceof CliUsageError && + error.message === '--fixture supports only "deterministic"' && + !error.message.includes('secret-canary'), + ) + assert.throws( + () => parseArgs(['secret-canary'], '/workspace'), + (error: unknown) => + error instanceof CliUsageError && + error.message === 'Unknown command; expected "rpc" or an option' && + !error.message.includes('secret-canary'), + ) + assert.throws( + () => parseArgs(['--not-a-real-option'], '/workspace'), + (error: unknown) => + error instanceof CliUsageError && error.message === 'Unknown option: --not-a-real-option', + ) +}) + +test('startup responsibilities stay split into bounded modules', async () => { + const root = await repositoryRoot() + const modules = [ + ['src/bin/braid.ts', 50], + ['src/bin/braid-runtime.ts', 200], + ['src/bin/interface-runner.ts', 300], + ] as const + + for (const [path, maximumLines] of modules) { + const source = await readFile(new URL(path, root), 'utf8') + assert.ok( + source.split('\n').length <= maximumLines, + `${path} should stay at or below ${maximumLines} lines`, + ) + } + + const launcher = await readFile(new URL('src/bin/braid.ts', root), 'utf8') + const staticImports = [...launcher.matchAll(/^import .* from ['"]([^'"]+)['"]$/gmu)].map( + (match) => match[1], + ) + assert.deepEqual(staticImports, ['node:module', './args.js']) + const compileCacheIndex = launcher.indexOf('enableCompileCache()') + const runtimeImportIndex = launcher.indexOf("import('./braid-runtime.js')") + assert.ok(compileCacheIndex > launcher.indexOf('if (options.version)')) + assert.ok(runtimeImportIndex > compileCacheIndex) + + const runtime = await readFile(new URL('src/bin/braid-runtime.ts', root), 'utf8') + const interfaceRunner = await readFile(new URL('src/bin/interface-runner.ts', root), 'utf8') + assert.match(runtime, /connections: production\.connections/u) + assert.match(interfaceRunner, /input\.profileConnectionOptions/u) +}) diff --git a/test/configuration-product-flow.test.ts b/test/configuration-product-flow.test.ts new file mode 100644 index 0000000..c2d48b2 --- /dev/null +++ b/test/configuration-product-flow.test.ts @@ -0,0 +1,449 @@ +import assert from 'node:assert/strict' +import { readdirSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import test from 'node:test' +import { visibleWidth } from '@earendil-works/pi-tui' +import type { AgentProfile } from '@tangle-network/agent-interface' +import type { ConnectionSummary } from '../src/app/connection-action-types.js' +import { createProfileRecord } from '../src/app/profiles.js' +import type { + BraidIntent, + BraidUiController, + UiDispatchResult, +} from '../src/views/shared/intents.js' +import type { BraidViewModel, HeadlessState } from '../src/views/shared/models.js' +import { ConfigurationWizard } from '../src/views/tui/configuration-wizard.js' +import { ConnectionSetupViewPanel } from '../src/views/tui/connection-setup.js' +import { ProfileEditorViewPanel } from '../src/views/tui/profile-editor.js' +import { createBraidTheme } from '../src/views/tui/theme.js' + +const at = '2026-08-04T00:00:00.000Z' +const theme = createBraidTheme({ colors: false, highContrast: true, reducedMotion: true }) + +function profile(name: string, _writable: boolean): AgentProfile { + return { + name, + description: `${name} profile`, + prompt: { systemPrompt: `Work as ${name}.` }, + model: { default: `provider/${name.toLowerCase()}`, reasoningEffort: 'high' }, + harness: 'pi', + tools: { read: true }, + ...(_writable ? { metadata: { source: 'test' } } : {}), + } +} + +function profileSummary(name: string, writable: boolean) { + return { + id: `profile-${name.toLowerCase()}`, + name, + description: `${name} profile`, + tags: [], + source: { + kind: 'file' as const, + reference: '/workspace/.braid/profiles/secret-profile.json', + label: 'workspace profile', + writable, + trusted: true, + }, + digest: 'sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + runner: 'pi', + model: `provider/${name.toLowerCase()}`, + tools: ['read'], + skills: [], + connections: [], + } +} + +function connectionSummary(name: string, id: string, ready = true): ConnectionSummary { + return { + id, + name, + kind: 'cli-bridge', + endpoint: 'https://bridge.example.test/v1?access_token=secret-canary', + credentialConfigured: true, + health: { status: 'healthy', checkedAt: at }, + modelVerification: { + model: 'provider/reviewer', + status: 'verified', + checkedAt: at, + }, + capabilityHints: ['stream', 'usage'], + ready, + } +} + +function accepted(data: unknown, revision = 8): UiDispatchResult { + return { kind: 'accepted', revision, data } +} + +function controllerFor( + calls: BraidIntent[], + options: { duplicateProfileName?: boolean; rejectLists?: boolean } = {}, +): BraidUiController { + let currentView = { + revision: 7, + profileName: 'Reviewer', + connection: 'Local Bridge', + } as BraidViewModel + const profiles = [profileSummary('Reviewer', true), profileSummary('ReadOnly', false)] + if (options.duplicateProfileName) { + profiles.push({ ...profileSummary('Reviewer', false), id: 'profile-reviewer-duplicate' }) + } + const connections = [ + connectionSummary('Local Bridge', 'connection-local'), + connectionSummary('Cloud Bridge', 'connection-cloud', false), + ] + const localConnection = connections[0] + if (localConnection === undefined) throw new Error('test fixture missing local connection') + return { + view: () => currentView, + state: () => ({}) as HeadlessState, + events: () => [], + initialize: async () => accepted(undefined), + subscribe: () => () => {}, + dispatch: async (intent) => { + calls.push(intent) + if (intent.type === 'headless-command') { + switch (intent.command) { + case 'list_profiles': + if (options.rejectLists) throw new Error('profile catalog unavailable') + return accepted({ profiles }) + case 'validate_profile': + return accepted({ + ref: intent.params.ref, + report: { + ok: true, + issues: [{ level: 'info', code: 'PROFILE_OK', message: 'Profile is valid' }], + }, + }) + case 'select_profile': + currentView = { ...currentView, profileName: 'Reviewer', revision: 8 } + return accepted({ profile: profiles[0] }) + case 'list_connections': + if (options.rejectLists) throw new Error('connection catalog unavailable') + return accepted({ connections }) + case 'test_connection': + if (intent.params.connectionId === 'connection-cloud') { + const cloud = connections[1] + return accepted({ + connection: { + ...cloud, + health: { status: 'unauthorized', checkedAt: at }, + modelVerification: { + model: 'provider/reviewer', + status: 'unauthorized', + checkedAt: at, + }, + ready: false, + }, + health: { status: 'unauthorized', checkedAt: at }, + modelVerification: { + model: 'provider/reviewer', + status: 'unauthorized', + checkedAt: at, + }, + ready: false, + }) + } + return accepted({ + connection: localConnection, + health: localConnection.health, + modelVerification: localConnection.modelVerification, + ready: true, + }) + case 'select_connection': + currentView = { ...currentView, connection: 'Local Bridge', revision: 8 } + return accepted({ connection: localConnection }) + default: + return accepted(undefined) + } + } + if (intent.type === 'run-command') return accepted(undefined) + return accepted(undefined) + }, + waitForIdle: async () => currentView, + } +} + +async function settle(): Promise { + await new Promise((resolve) => setTimeout(resolve, 10)) +} + +test('configuration view modules stay below the production size bound', () => { + const root = join(process.cwd(), 'src/views/tui') + const files = readdirSync(root).filter((file) => + /^(?:configuration|profile-editor|connection-setup)-?.*\.ts$/u.test(file), + ) + for (const file of files) { + const lines = readFileSync(join(root, file), 'utf8').split(/\r?\n/u).length + assert.ok(lines <= 300, `${file} has ${lines} lines`) + } +}) + +test('initial catalog failures remain visible in their panels', async () => { + const profiles = new ProfileEditorViewPanel(theme, { + controller: controllerFor([], { rejectLists: true }), + }) + const connections = new ConnectionSetupViewPanel(theme, { + controller: controllerFor([], { rejectLists: true }), + }) + profiles.focused = true + connections.focused = true + + await settle() + + assert.match( + profiles.render(80).join('\n'), + /Profiles unavailable · refresh failed · profile catalog unavailable/u, + ) + assert.match( + connections.render(80).join('\n'), + /Connections unavailable · refresh failed · connection catalog unavailable/u, + ) +}) + +test('first-run review keeps exact effective values and credential boundaries legible', async () => { + const record = createProfileRecord( + { + kind: 'inline', + reference: 'profile:reviewer', + label: 'workspace profile', + writable: false, + trusted: true, + }, + profile('Reviewer', false), + ) + const connection = { + id: 'connection-local', + kind: 'cli-bridge' as const, + name: 'Local Bridge', + endpoint: 'https://bridge.example.test/v1', + credentialRef: 'credential-secret-canary', + providerOptions: { transport: 'https' as const, capabilityHints: ['stream'] }, + createdAt: at, + updatedAt: at, + lastHealth: { status: 'healthy' as const, checkedAt: at }, + } + let applied: { profileId: string; connectionId: string } | undefined + const wizard = new ConfigurationWizard({ + theme, + profiles: [record], + connections: [connection], + onCommit: (selection) => { + applied = { profileId: selection.profile.id, connectionId: selection.connection.id } + }, + onComplete: () => {}, + onCancel: () => {}, + confirmation: () => ({ + runner: 'pi', + model: 'pi/openai-codex/gpt-5.6-luna', + effort: 'high', + workdir: '/workspace', + verification: 'verified: local model check', + unsupported: ['provider workdir placement'], + }), + }) + wizard.focused = true + + wizard.handleInput('\r') + wizard.handleInput('\r') + const wide = wizard.render(80).join('\n') + assert.match(wide, /Reviewer → Local Bridge/u) + assert.match(wide, /runner: pi/u) + assert.match(wide, /model: pi\/openai-codex\/gpt-5\.6-luna/u) + assert.match(wide, /effort: high/u) + assert.match(wide, /workdir: \/workspace/u) + assert.match(wide, /verification: verified/u) + assert.match(wide, /unsupported: provider workdir placement/u) + assert.match(wide, /credentials configured outside Braid · value hidden/u) + assert.doesNotMatch(wide, /credential-secret-canary/u) + + const narrowLines = wizard.render(40) + assert.ok(narrowLines.every((line) => visibleWidth(line) <= 40)) + assert.match(narrowLines.join('\n'), /runner:/u) + assert.match(narrowLines.join('\n'), /cwd:/u) + assert.match(narrowLines.join('\n'), /cred hidden/u) + + wizard.handleInput('\r') + await settle() + assert.deepEqual(applied, { profileId: record.id, connectionId: connection.id }) + assert.match(wizard.render(80).join('\n'), /selection applied/u) +}) + +test('profile editor uses canonical summaries, validates by keyboard, and refuses unsafe saves', async () => { + const calls: BraidIntent[] = [] + const panel = new ProfileEditorViewPanel(theme, { + controller: controllerFor(calls), + nextOperationId: () => 'profile-ui-test', + }) + panel.focused = true + await settle() + const initial = panel.render(80).join('\n') + assert.match(initial, /Active profile · Reviewer/u) + assert.match(initial, /pi/u) + assert.match(initial, /provider\/reviewer/u) + 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) + + panel.handleInput('\u0016') + await settle() + const validation = calls.at(-1) + assert.equal(validation?.type, 'headless-command') + assert.equal( + validation?.type === 'headless-command' ? validation.command : undefined, + '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) +}) + +test('connection setup shows health and capabilities without endpoints or credentials, then applies exact keyboard actions', async () => { + const calls: BraidIntent[] = [] + let cancelled = false + const panel = new ConnectionSetupViewPanel(theme, { + controller: controllerFor(calls), + nextOperationId: () => 'connection-ui-test', + onCancel: () => { + cancelled = true + }, + }) + panel.focused = true + await settle() + const initial = panel.render(80).join('\n') + assert.match(initial, /Active connection · Local Bridge/u) + assert.match(initial, /healthy/u) + assert.match(initial, /credential configured/u) + assert.doesNotMatch(initial, /access_token|secret-canary/u) + const narrow = panel.render(40) + assert.ok(narrow.length <= 12) + assert.match(narrow.join('\n'), /enter select · \^T test · esc close/u) + + panel.handleInput('\u0014') + await settle() + const testIntent = [...calls] + .reverse() + .find((intent) => intent.type === 'headless-command' && intent.command === 'test_connection') + assert.equal(testIntent?.type, 'headless-command') + assert.equal( + testIntent?.type === 'headless-command' ? testIntent.command : undefined, + 'test_connection', + ) + assert.equal( + testIntent?.type === 'headless-command' ? testIntent.params.connectionId : undefined, + 'connection-local', + ) + assert.match( + panel.render(80).join('\n'), + /Tested Local Bridge · health healthy · model verified/u, + ) + + panel.handleInput('\u001b[B') + panel.handleInput('\u0014') + await settle() + const cloudTestIntent = [...calls] + .reverse() + .find((intent) => intent.type === 'headless-command' && intent.command === 'test_connection') + assert.equal( + cloudTestIntent?.type === 'headless-command' ? cloudTestIntent.params.connectionId : undefined, + 'connection-cloud', + ) + assert.match( + panel.render(80).join('\n'), + /Tested Cloud Bridge · health unauthorized · model unauthorized/u, + ) + assert.match(panel.render(80).join('\n'), /unauthorized/u) + + panel.handleInput('\u001b[A') + panel.handleInput('\r') + await settle() + const selectIntent = [...calls] + .reverse() + .find((intent) => intent.type === 'headless-command' && intent.command === 'select_connection') + assert.equal(selectIntent?.type, 'headless-command') + assert.equal( + selectIntent?.type === 'headless-command' ? selectIntent.command : undefined, + 'select_connection', + ) + assert.deepEqual(selectIntent?.type === 'headless-command' ? selectIntent.params : undefined, { + connectionId: 'connection-local', + expectedRevision: 7, + }) + + panel.handleInput('\u001b') + assert.equal(cancelled, true) +}) + +test('profile save requires an exact id when display names collide', async () => { + const calls: BraidIntent[] = [] + const panel = new ProfileEditorViewPanel(theme, { + controller: controllerFor(calls, { duplicateProfileName: true }), + nextOperationId: () => 'profile-duplicate-test', + }) + 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']) +}) + +test('static profile and connection views mask secret-designated fields and keep narrow rows bounded', () => { + const profilePanel = new ProfileEditorViewPanel(theme) + profilePanel.setView({ + profileEditor: { + source: 'workspace profile', + digest: 'sha256:profile', + readOnly: true, + validation: 'valid', + fields: [ + { path: 'model.default', value: 'provider/reviewer', secret: false }, + { path: 'credentials.apiKey', value: 'secret-canary', secret: true }, + ], + }, + } as unknown as BraidViewModel) + const connectionPanel = new ConnectionSetupViewPanel(theme) + connectionPanel.setView({ + connectionSetup: { + kind: 'cli-bridge', + fields: [ + { + label: 'endpoint', + value: 'https://bridge.example.test/v1?access_token=secret-canary', + secret: false, + }, + { label: 'token', value: 'secret-canary', secret: false }, + ], + health: 'healthy', + capabilities: ['stream'], + }, + } as unknown as BraidViewModel) + for (const panel of [profilePanel, connectionPanel]) { + const lines = panel.render(40) + assert.ok(lines.every((line) => visibleWidth(line) <= 40)) + assert.doesNotMatch(lines.join('\n'), /secret-canary/u) + } + const connectionText = connectionPanel.render(80).join('\n') + assert.match(connectionText, /https:\/\/bridge\.example\.test\/v1/u) + assert.doesNotMatch(connectionText, /access_token|secret-canary/u) +}) diff --git a/test/configuration-session.test.ts b/test/configuration-session.test.ts new file mode 100644 index 0000000..8bf974a --- /dev/null +++ b/test/configuration-session.test.ts @@ -0,0 +1,152 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import type { AgentProfile } from '@tangle-network/agent-interface' +import { + ConfigurationSession, + ConfigurationSessionError, +} from '../src/app/configuration-session.js' +import { createProfileRecord } from '../src/app/profiles.js' +import type { ConnectionRecord } from '../src/domain/entities.js' +import { createConnectionId } from '../src/domain/ids.js' + +const at = '2026-08-03T20:00:00.000Z' + +function profile(name: string, model: string): AgentProfile { + return { + name, + prompt: { systemPrompt: `You are ${name}.` }, + model: { default: model }, + harness: 'pi', + } +} + +function profileRecord(name: string, model: string) { + return createProfileRecord( + { + kind: 'inline', + reference: `test:${name}`, + label: `${name} profile`, + writable: false, + trusted: true, + }, + profile(name, model), + ) +} + +function connection( + kind: ConnectionRecord['kind'], + name: string, + credentialRef?: ConnectionRecord['credentialRef'], +): ConnectionRecord { + return { + id: createConnectionId(`connection-${name}`), + kind, + name, + ...(credentialRef === undefined ? {} : { credentialRef }), + providerOptions: { + transport: kind === 'cli-bridge' ? 'local' : 'https', + capabilityHints: ['stream', 'usage'], + }, + createdAt: at, + updatedAt: at, + lastHealth: { status: 'unknown' }, + } +} + +test('configuration session chooses a profile and connection without persisting credentials', () => { + const reviewer = profileRecord('reviewer', 'openai/gpt-5.6') + const coder = profileRecord('coder', 'anthropic/claude-sonnet') + const cli = connection('cli-bridge', 'local-cli', 'credential-secret-ref') + const inference = connection('tangle-inference', 'cloud-inference', 'credential-cloud-ref') + const sandbox = connection('tangle-sandbox', 'remote-sandbox', 'credential-sandbox-ref') + const session = new ConfigurationSession({ + profiles: [reviewer, coder], + connections: [sandbox, inference, cli], + }) + + assert.equal(session.state.step, 'profile') + assert.deepEqual( + session.state.profiles.map((item) => item.label), + ['coder', 'reviewer'], + ) + assert.deepEqual( + session.state.connections.map((item) => item.kind), + ['cli-bridge', 'tangle-inference', 'tangle-sandbox'], + ) + assert.match(session.state.connections[0]?.description ?? '', /ready/u) + assert.doesNotMatch(session.state.connections[0]?.description ?? '', /credential-secret-ref/u) + + session.selectProfile(reviewer.id) + assert.equal(session.state.step, 'connection') + session.selectConnection(sandbox.id) + assert.equal(session.state.step, 'confirm') + const selected = session.confirm() + + assert.equal(session.state.step, 'complete') + assert.equal(selected.profile.id, reviewer.id) + assert.equal(selected.connection.id, sandbox.id) + assert.equal(selected.connection.credentialRef, 'credential-sandbox-ref') + assert.equal(Object.isFrozen(selected), true) + assert.equal('error' in session.state, false) + assert.throws(() => session.selectProfile(coder.id), ConfigurationSessionError) +}) + +test('configuration session supports back navigation and fails closed on empty catalogs', () => { + const session = new ConfigurationSession({ profiles: [], connections: [] }) + const profileResult = session.selectProfile('profile-missing') + assert.equal(profileResult.error?.code, 'NO_PROFILES') + assert.equal(session.state.step, 'profile') + + const record = profileRecord('reviewer', 'openai/gpt-5.6') + const connectionRecord = connection('cli-bridge', 'local-cli') + const configured = new ConfigurationSession({ + profiles: [record], + connections: [connectionRecord], + }) + configured.selectProfile(record.id) + configured.selectConnection(connectionRecord.id) + assert.equal(configured.back().step, 'connection') + assert.equal(configured.back().step, 'profile') + assert.equal(configured.state.error, undefined) + assert.equal('error' in configured.state, false) + assert.equal(configured.cancel().step, 'cancelled') + assert.throws(() => configured.confirm(), ConfigurationSessionError) +}) + +test('a staged selection can be cancelled or retried after apply failure', () => { + const record = profileRecord('reviewer', 'openai/gpt-5.6') + const connectionRecord = connection('cli-bridge', 'local-cli') + const session = new ConfigurationSession({ + profiles: [record], + connections: [connectionRecord], + }) + session.selectProfile(record.id) + session.selectConnection(connectionRecord.id) + const selection = session.confirm() + assert.equal(session.confirm(), selection) + assert.equal(session.back().step, 'connection') + session.cancel() + assert.equal(session.state.step, 'cancelled') +}) + +test('changing a choice after review cannot reuse the previous committed selection', () => { + const reviewer = profileRecord('reviewer', 'openai/gpt-5.6') + const coder = profileRecord('coder', 'anthropic/claude-sonnet') + const connectionRecord = connection('cli-bridge', 'local-cli') + const session = new ConfigurationSession({ + profiles: [reviewer, coder], + connections: [connectionRecord], + }) + + session.selectProfile(reviewer.id) + session.selectConnection(connectionRecord.id) + const first = session.confirm() + assert.equal(first.profile.id, reviewer.id) + session.backTo('profile') + session.selectProfile(coder.id) + session.selectConnection(connectionRecord.id) + const second = session.confirm() + + assert.equal(second.profile.id, coder.id) + assert.notEqual(second.profile.id, first.profile.id) +}) diff --git a/test/configuration-wizard.test.ts b/test/configuration-wizard.test.ts new file mode 100644 index 0000000..f15f725 --- /dev/null +++ b/test/configuration-wizard.test.ts @@ -0,0 +1,449 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { TUI } from '@earendil-works/pi-tui' +import type { AgentProfile } from '@tangle-network/agent-interface' +import { createApplicationUiController } from '../src/adapters/tui/application-ui-controller.js' +import { createBraidApplication } from '../src/app/composition.js' +import { createProfileRecord } from '../src/app/profiles.js' +import type { ConnectionRecord } from '../src/domain/entities.js' +import { createConnectionId } from '../src/domain/ids.js' +import { ConfigurationWizard } from '../src/views/tui/configuration-wizard.js' +import { BraidTerminalApp } from '../src/views/tui/terminal-app.js' +import { createBraidTheme } from '../src/views/tui/theme.js' +import { VirtualTerminal } from './support/virtual-terminal.js' + +const at = '2026-08-03T20:00:00.000Z' +const theme = createBraidTheme({ colors: false, highContrast: true, reducedMotion: true }) + +function makeProfile(): AgentProfile { + return { + name: 'reviewer', + prompt: { systemPrompt: 'Review carefully.' }, + model: { default: 'openai/gpt-5.6', reasoningEffort: 'high' }, + harness: 'pi', + } +} + +function makeNamedProfile(name: string): AgentProfile { + return { + ...makeProfile(), + name, + prompt: { systemPrompt: `Review as ${name}.` }, + model: { default: `model/${name}`, reasoningEffort: 'high' }, + } +} + +function makeConnection(): ConnectionRecord { + return { + id: createConnectionId('connection-local-cli'), + kind: 'cli-bridge', + name: 'Local CLI Bridge', + endpoint: 'http://127.0.0.1:3344/v1', + credentialRef: 'credential-secret-ref', + providerOptions: { transport: 'local', capabilityHints: ['stream', 'usage'] }, + createdAt: at, + updatedAt: at, + lastHealth: { status: 'healthy', checkedAt: at }, + } +} + +function makeConnectionVariant(kind: ConnectionRecord['kind'], name: string): ConnectionRecord { + return { + ...makeConnection(), + id: createConnectionId(`connection-${name}`), + kind, + name, + providerOptions: { + transport: kind === 'cli-bridge' ? 'local' : 'https', + capabilityHints: ['stream', 'usage'], + }, + } +} + +test('configuration wizard renders the two choices and never renders credential material', async () => { + const record = createProfileRecord( + { + kind: 'inline', + reference: 'test:reviewer', + label: 'workspace profile', + writable: false, + trusted: true, + }, + makeProfile(), + ) + const connection = makeConnection() + let selected: { profileId: string; connectionId: string } | undefined + const wizard = new ConfigurationWizard({ + theme, + profiles: [record], + connections: [connection], + onCommit: (selection) => { + selected = { profileId: selection.profile.id, connectionId: selection.connection.id } + }, + onComplete: () => {}, + onCancel: () => {}, + confirmation: () => ({ + runner: 'pi', + model: 'pi/openai-codex/gpt-5.6-luna', + effort: 'high', + workdir: '/workspace', + verification: 'unverified: discovery only', + unsupported: ['provider workdir placement'], + }), + }) + wizard.focused = true + + assert.match(wizard.render(80).join('\n'), /choose an AgentProfile/u) + assert.match(wizard.render(80).join('\n'), /reviewer/u) + assert.doesNotMatch(wizard.render(80).join('\n'), /credential-secret-ref/u) + + wizard.handleInput('\r') + assert.match(wizard.render(80).join('\n'), /choose a connection/u) + assert.match(wizard.render(80).join('\n'), /Local CLI Bridge/u) + wizard.handleInput('\r') + assert.match(wizard.render(80).join('\n'), /review and start/u) + const confirmation = wizard.render(80).join('\n') + assert.match(confirmation, /runner: pi/u) + assert.match(confirmation, /model: pi\/openai-codex\/gpt-5\.6-luna/u) + assert.match(confirmation, /effort: high/u) + assert.match(confirmation, /workdir: \/workspace/u) + assert.match(confirmation, /verification: unverified/u) + assert.match(confirmation, /unsupported: provider workdir placement/u) + wizard.handleInput('\r') + await new Promise((resolve) => setImmediate(resolve)) + assert.deepEqual(selected, { profileId: record.id, connectionId: connection.id }) + assert.match(wizard.render(80).join('\n'), /selection applied/u) +}) + +test('configuration wizard reports success only after an asynchronous commit finishes', async () => { + const record = createProfileRecord( + { + kind: 'inline', + reference: 'test:pending-reviewer', + label: 'workspace profile', + writable: false, + trusted: true, + }, + makeProfile(), + ) + let finishCommit: (() => void) | undefined + const pendingCommit = new Promise((resolve) => { + finishCommit = resolve + }) + const wizard = new ConfigurationWizard({ + theme, + profiles: [record], + connections: [makeConnection()], + onCommit: () => pendingCommit, + onComplete: () => {}, + onCancel: () => {}, + }) + wizard.focused = true + wizard.handleInput('\r') + wizard.handleInput('\r') + wizard.handleInput('\r') + const applying = wizard.render(80).join('\n') + assert.match(applying, /applying selection/u) + assert.doesNotMatch(applying, /selection applied/u) + + finishCommit?.() + await new Promise((resolve) => setImmediate(resolve)) + assert.match(wizard.render(80).join('\n'), /selection applied/u) +}) + +test('the terminal extension opens first-run setup and returns focus after cancel', async () => { + const record = createProfileRecord( + { + kind: 'inline', + reference: 'test:reviewer', + label: 'workspace profile', + writable: false, + trusted: true, + }, + makeProfile(), + ) + const terminal = new VirtualTerminal(80, 24) + const tui = new TUI(terminal) + const app = createBraidApplication({ fixture: 'deterministic' }) + app.initialize('/workspace') + const view = new BraidTerminalApp({ + controller: createApplicationUiController(app), + tui, + theme, + workspace: '/workspace', + nextOperationId: () => 'op-configuration-test', + configuration: { + profiles: [record], + connections: [makeConnection()], + openOnStart: true, + onCommit: () => {}, + }, + }) + const done = view.start() + await terminal.waitForRender() + assert.match(terminal.getViewport().join('\n'), /braid setup/u) + terminal.sendInput('\u001b') + await terminal.waitForRender() + assert.doesNotMatch(terminal.getViewport().join('\n'), /choose an AgentProfile/u) + assert.equal(view.editor.focused, true) + view.stop() + await done + await app.close() +}) + +test('a failed apply stays recoverable and Escape cancels the staged selection', async () => { + const record = createProfileRecord( + { + kind: 'inline', + reference: 'test:reviewer', + label: 'workspace profile', + writable: false, + trusted: true, + }, + makeProfile(), + ) + let cancelled = false + const wizard = new ConfigurationWizard({ + theme, + profiles: [record], + connections: [makeConnection()], + onCommit: () => { + throw new Error( + 'CLI Bridge advertised pi/openai-codex/gpt-5.6-luna but returned 501 not_configured. Configure the selected bridge backend and its local subscription credentials, then retry setup.', + ) + }, + onComplete: () => {}, + onCancel: () => { + cancelled = true + }, + }) + wizard.focused = true + wizard.handleInput('\r') + wizard.handleInput('\r') + wizard.handleInput('\r') + await new Promise((resolve) => setImmediate(resolve)) + assert.match( + wizard.render(80).join('\n'), + /501\s+not_configured[\s\S]*Configure[\s\S]*credentials/iu, + ) + wizard.handleInput('\u001b') + assert.equal(cancelled, true) +}) + +test('an empty catalog explains the dead end and only Escape closes setup', () => { + let cancelled = false + const wizard = new ConfigurationWizard({ + theme, + profiles: [], + connections: [], + onCommit: () => {}, + onComplete: () => {}, + onCancel: () => { + cancelled = true + }, + }) + wizard.focused = true + wizard.handleInput('\r') + assert.match(wizard.render(80).join('\n'), /No AgentProfiles are available/u) + assert.equal(cancelled, false) + wizard.handleInput('\u001b') + assert.equal(cancelled, true) +}) + +test('mounted first-run setup preserves choices and fits keyboard guidance at narrow and wide sizes', async () => { + for (const [columns, rows] of [ + [40, 12], + [80, 24], + ] as const) { + const terminal = new VirtualTerminal(columns, rows) + const tui = new TUI(terminal) + const app = createBraidApplication({ fixture: 'deterministic' }) + app.initialize('/workspace') + const view = new BraidTerminalApp({ + controller: createApplicationUiController(app), + tui, + theme, + workspace: '/workspace', + nextOperationId: () => `op-configuration-${columns}`, + configuration: { + profiles: [ + createProfileRecord( + { + kind: 'inline', + reference: 'test:reviewer', + label: 'workspace profile', + writable: false, + trusted: true, + }, + makeNamedProfile('reviewer'), + ), + createProfileRecord( + { + kind: 'inline', + reference: 'test:coder', + label: 'workspace profile', + writable: false, + trusted: true, + }, + makeNamedProfile('coder'), + ), + ], + connections: [ + makeConnectionVariant('tangle-sandbox', 'remote-sandbox'), + makeConnectionVariant('cli-bridge', 'local-cli'), + makeConnectionVariant('tangle-inference', 'cloud-inference'), + ], + openOnStart: true, + onCommit: () => {}, + }, + }) + const done = view.start() + try { + await terminal.waitForRender() + const frame = (label: string): string => { + const lines = terminal.getViewport() + assert.ok( + lines.every((line) => line.length <= columns), + `${label} contains a line wider than ${columns} columns`, + ) + return lines.join('\n') + } + + assert.match(frame('profile'), /choose an AgentProfile/u) + assert.match(frame('profile'), /filter · enter choose · esc cancel/u) + terminal.sendInput('\u001b[B') + terminal.sendInput('\r') + await terminal.waitForRender() + assert.match(frame('connection'), /choose a connection/u) + + terminal.sendInput('\u001b[B') + terminal.sendInput('\u001b[B') + terminal.sendInput('\r') + await terminal.waitForRender() + assert.match(frame('confirm'), /reviewer → remote-sandbox/u) + assert.doesNotMatch(frame('confirm'), /credential-secret-ref/u) + + terminal.sendInput('\u001b[B') + terminal.sendInput('\u001b[B') + await terminal.waitForRender() + assert.match(frame('confirm action'), /→ ← change AgentProfile/u) + terminal.sendInput('\r') + await terminal.waitForRender() + assert.match(frame('back to profile'), /→ reviewer/u) + assert.doesNotMatch(frame('back to profile'), /→ coder/u) + + terminal.sendInput('\u001b') + await terminal.waitForRender() + assert.equal(view.editor.focused, true) + } finally { + view.stop() + await done + await app.close() + } + } +}) + +test('mounted setup exposes apply errors with retry and cancel controls', async () => { + const record = createProfileRecord( + { + kind: 'inline', + reference: 'test:reviewer', + label: 'workspace profile', + writable: false, + trusted: true, + }, + makeProfile(), + ) + const terminal = new VirtualTerminal(40, 12) + const tui = new TUI(terminal) + const app = createBraidApplication({ fixture: 'deterministic' }) + app.initialize('/workspace') + const view = new BraidTerminalApp({ + controller: createApplicationUiController(app), + tui, + theme, + workspace: '/workspace', + nextOperationId: () => 'op-configuration-error', + configuration: { + profiles: [record], + connections: [makeConnection()], + openOnStart: true, + onCommit: () => { + throw new Error('connection refused') + }, + }, + }) + const done = view.start() + try { + await terminal.waitForRender() + terminal.sendInput('\r') + terminal.sendInput('\r') + terminal.sendInput('\r') + await terminal.waitForRender() + const frame = terminal.getViewport().join('\n') + assert.match(frame, /connection refused/u) + assert.match(frame, /Apply and start/u) + assert.match(frame, /Cancel/u) + assert.doesNotMatch(frame, /Applying/u) + terminal.sendInput('\u001b') + await terminal.waitForRender() + assert.equal(view.editor.focused, true) + } finally { + view.stop() + await done + await app.close() + } +}) + +test('mounted setup redraws when a long asynchronous apply finishes', async () => { + const record = createProfileRecord( + { + kind: 'inline', + reference: 'test:async-reviewer', + label: 'workspace profile', + writable: false, + trusted: true, + }, + makeProfile(), + ) + let finishCommit: (() => void) | undefined + const pendingCommit = new Promise((resolve) => { + finishCommit = resolve + }) + const terminal = new VirtualTerminal(80, 24) + const tui = new TUI(terminal) + const app = createBraidApplication({ fixture: 'deterministic' }) + app.initialize('/workspace') + const view = new BraidTerminalApp({ + controller: createApplicationUiController(app), + tui, + theme, + workspace: '/workspace', + nextOperationId: () => 'op-configuration-async', + configuration: { + profiles: [record], + connections: [makeConnection()], + openOnStart: true, + onCommit: () => pendingCommit, + }, + }) + const done = view.start() + try { + await terminal.waitForRender() + terminal.sendInput('\r') + await terminal.waitForRender() + terminal.sendInput('\r') + await terminal.waitForRender() + terminal.sendInput('\r') + await terminal.waitForRender() + assert.match(terminal.getViewport().join('\n'), /Applying/u) + + finishCommit?.() + await terminal.waitForRender() + assert.match(terminal.getViewport().join('\n'), /selection applied/u) + } finally { + view.stop() + await done + await app.close() + } +}) diff --git a/test/connections.test.ts b/test/connections.test.ts new file mode 100644 index 0000000..822d3f7 --- /dev/null +++ b/test/connections.test.ts @@ -0,0 +1,373 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import type { AgentProfile } from '@tangle-network/agent-interface' +import { createCliBridgeProvider } from '@tangle-network/agent-provider-cli-bridge' +import { createTangleProvider } from '@tangle-network/agent-provider-tangle' +import { streamAgentTurn } from '@tangle-network/agent-runtime/kernel' +import { + createProductionConnectionAdapter, + type SandboxClientFactoryInput, +} from '../src/adapters/connections/production-connections.js' +import { MemoryCredentialStore } from '../src/adapters/credentials/memory.js' +import { + createProductionBackendResolver, + type ProductionBackendResolverOptions, + resolveProductionBackend, +} from '../src/adapters/runtime/production-backend-resolver.js' +import { ConnectionError } from '../src/app/connection-errors.js' +import { ConnectionRegistry } from '../src/app/connections.js' +import type { ConnectionKind, ConnectionRecord } from '../src/domain/entities.js' +import { createConnectionId, createCredentialRefId } from '../src/domain/ids.js' +import { credentialRef } from '../src/ports/credentials.js' +import type { ExecuteTurnInput } from '../src/ports/execution.js' + +const at = '2026-08-03T12:00:00.000Z' + +function connection( + kind: ConnectionKind, + id: string, + endpoint?: string, + credential = false, +): ConnectionRecord { + return { + id: createConnectionId(`connection-${id}`), + kind, + name: `${kind} test connection`, + ...(endpoint ? { endpoint } : {}), + ...(credential ? { credentialRef: createCredentialRefId(`credential-${id}`) } : {}), + providerOptions: { transport: 'https' }, + createdAt: at, + updatedAt: at, + lastHealth: { status: 'unknown' }, + } +} + +function profile( + model = 'openai/gpt-5', + harness: AgentProfile['harness'] = 'opencode', +): AgentProfile { + return { model: { default: model }, harness } +} + +function turnInput(profileValue: AgentProfile): ExecuteTurnInput { + return { + operationId: 'operation-connection-test', + runId: 'run-connection-test', + text: 'say hello', + profile: profileValue, + signal: new AbortController().signal, + } +} + +function responseStream(text = 'hello'): Response { + const body = [ + `data: ${JSON.stringify({ choices: [{ delta: { content: text }, finish_reason: null }] })}`, + '', + `data: ${JSON.stringify({ choices: [{ delta: {}, finish_reason: 'stop' }], usage: { prompt_tokens: 1, completion_tokens: 1 } })}`, + '', + 'data: [DONE]', + '', + ].join('\n') + return new Response(body, { + status: 200, + headers: { 'content-type': 'text/event-stream' }, + }) +} + +test('connection records stay secret-free and selection is exact', () => { + const record = connection('cli-bridge', 'exact', 'http://127.0.0.1:4010') + const registry = new ConnectionRegistry([record]) + const selected = registry.select({ + connectionId: record.id, + expectedKind: 'cli-bridge', + expectedUpdatedAt: at, + }) + + assert.equal(selected.record, registry.get(record.id)) + assert.equal(selected.digest.length, 64) + assert.throws( + () => registry.select({ connectionId: 'Local bridge' }), + (error: unknown) => + error instanceof ConnectionError && error.code === 'INVALID_CONNECTION_SELECTION', + ) + assert.throws( + () => registry.select({ connectionId: record.id, expectedKind: 'tangle-sandbox' }), + (error: unknown) => + error instanceof ConnectionError && error.code === 'CONNECTION_KIND_MISMATCH', + ) + assert.throws( + () => + registry.select({ connectionId: record.id, expectedUpdatedAt: '2026-08-03T12:00:01.000Z' }), + (error: unknown) => + error instanceof ConnectionError && error.code === 'CONNECTION_REVISION_MISMATCH', + ) + assert.throws( + () => + new ConnectionRegistry([ + connection('cli-bridge', 'secret-url', 'https://bridge.test?token=should-not-persist'), + ]), + (error: unknown) => + error instanceof ConnectionError && error.code === 'SECRET_IN_CONNECTION_RECORD', + ) +}) + +test('health checks are read-only and classify HTTP responses without storing secrets', async () => { + const requests: Array<{ readonly url: string; readonly authorization?: string }> = [] + const fetcher: typeof fetch = async (input, init) => { + const authorization = new Headers(init?.headers).get('authorization') + requests.push({ + url: String(input), + ...(authorization ? { authorization } : {}), + }) + return new Response( + JSON.stringify({ status: 'ok', backends: [{ name: 'pi', state: 'ready' }] }), + { status: 200 }, + ) + } + const bridge = createProductionConnectionAdapter( + connection('cli-bridge', 'health', 'http://127.0.0.1:4010'), + { fetch: fetcher, now: () => at }, + ) + assert.deepEqual(await bridge.health(), { status: 'healthy', checkedAt: at }) + assert.equal(requests[0]?.url, 'http://127.0.0.1:4010/health') + assert.equal(requests[0]?.authorization, undefined) + + const credentials = new MemoryCredentialStore() + const portRef = credentialRef('cred:v1:inference-health') + await credentials.store({ ref: portRef, value: Buffer.from('health-secret') }) + const inference = createProductionConnectionAdapter( + connection('tangle-inference', 'unauthorized', 'https://router.test', true), + { + credentials, + credentialRefResolver: () => portRef, + fetch: async (_input, init) => { + assert.equal(new Headers(init?.headers).get('authorization'), 'Bearer health-secret') + return new Response(null, { status: 401 }) + }, + now: () => at, + }, + ) + assert.deepEqual(await inference.health(), { + status: 'unauthorized', + checkedAt: at, + message: 'The connection rejected its credential', + }) +}) + +test('health classification and model verification keep bridge readiness separate', async () => { + const healthCases = [ + [401, 'unauthorized'], + [404, 'incompatible'], + [429, 'rate-limited'], + [500, 'unreachable'], + ] as const + for (const [status, expected] of healthCases) { + const adapter = createProductionConnectionAdapter( + connection('cli-bridge', `health-${status}`, 'http://127.0.0.1:4010'), + { + fetch: async () => new Response(JSON.stringify({ status: 'ok', backends: [] }), { status }), + now: () => at, + }, + ) + assert.equal((await adapter.health()).status, expected) + } + + const adapter = createProductionConnectionAdapter( + connection('cli-bridge', 'model-not-configured', 'http://127.0.0.1:4010'), + { + fetch: async (input) => + String(input).endsWith('/health') + ? new Response( + JSON.stringify({ status: 'ok', backends: [{ name: 'pi', state: 'ready' }] }), + { + status: 200, + }, + ) + : new Response(JSON.stringify({ error: { code: 'not_configured' } }), { status: 501 }), + now: () => at, + }, + ) + assert.equal((await adapter.health()).status, 'healthy') + const verification = await adapter.verifyModel?.('openai/gpt-5', { now: () => at }) + assert.deepEqual(verification, { + model: 'openai/gpt-5', + status: 'not-configured', + checkedAt: at, + code: 'not_configured', + httpStatus: 501, + message: + 'The bridge advertises openai/gpt-5 but its backend is not configured; sign in to that backend and retry model verification', + }) +}) + +test('sandbox health uses the published read-only health surface and never creates a sandbox', async () => { + let creates = 0 + const client = { + create: async () => { + creates += 1 + return { id: 'sandbox-test', streamPrompt: async function* () {} } + }, + fetch: async () => new Response(null, { status: 200 }), + } + const adapter = createProductionConnectionAdapter( + connection('tangle-sandbox', 'health', 'https://sandbox.test'), + { sandboxClient: client, now: () => at }, + ) + assert.deepEqual(await adapter.health(), { status: 'healthy', checkedAt: at }) + assert.equal(creates, 0) +}) + +test('capability reports combine published provider capabilities with runtime method support', async () => { + const publishedCli = createCliBridgeProvider({ baseUrl: 'http://127.0.0.1:4010' }) + const publishedEnvironment = await publishedCli.capabilities() + const cli = createProductionConnectionAdapter( + connection('cli-bridge', 'caps', 'http://127.0.0.1:4010'), + ) + const cliCapabilities = await cli.capabilities() + assert.deepEqual(cliCapabilities.environment, publishedEnvironment) + assert.deepEqual(cliCapabilities.runtime.streaming, publishedEnvironment.streaming) + assert.deepEqual(cliCapabilities.runtime.sessions, publishedEnvironment.sessions) + assert.equal(cliCapabilities.actions.placement, publishedEnvironment.placement) + assert.equal(cliCapabilities.actions.usage, publishedEnvironment.usage) + assert.equal(cliCapabilities.actions.replay, publishedEnvironment.streaming.replay) + assert.equal(cliCapabilities.actions['continue-session'], publishedEnvironment.sessions.continue) + assert.equal(cliCapabilities.providerMethods.respondToInteraction, false) + assert.equal(cliCapabilities.actions['respond-interaction'], false) + assert.equal(cliCapabilities.runtime.backend, 'chat') + + const sandbox = createProductionConnectionAdapter( + connection('tangle-sandbox', 'caps', 'https://sandbox.test'), + ) + const sandboxCapabilities = await sandbox.capabilities() + assert.equal(sandboxCapabilities.environment?.branching.fork, false) + assert.equal(sandboxCapabilities.actions.fork, false) + assert.equal(sandboxCapabilities.runtime.backend, 'executor') +}) + +test('upstream reproduction: published providers expose no interaction response channel', async () => { + const cli = createCliBridgeProvider({ baseUrl: 'http://127.0.0.1:4010' }) + const cliEnvironment = await cli.create({ profile: {} }) + assert.equal('respondToInteraction' in cliEnvironment, false) + + const tangle = createTangleProvider({ + client: { + create: async () => ({ id: 'sandbox-test', streamPrompt: async function* () {} }), + }, + }) + const tangleEnvironment = await tangle.create({ profile: {} }) + assert.equal('respondToInteraction' in tangleEnvironment, false) +}) + +test('production resolver routes chat connections through agent-runtime', async () => { + const credentials = new MemoryCredentialStore() + const portRef = credentialRef('cred:v1:resolver') + await credentials.store({ ref: portRef, value: Buffer.from('resolver-secret') }) + const inference = connection('tangle-inference', 'resolver', 'https://router.test', true) + const registry = new ConnectionRegistry([inference]) + const calls: Array<{ readonly url: string; readonly body: string }> = [] + const fetcher: typeof fetch = async (input, init) => { + calls.push({ url: String(input), body: String(init?.body) }) + return responseStream() + } + const options: ProductionBackendResolverOptions = { + connections: registry, + credentials, + credentialRefResolver: () => portRef, + fetch: fetcher, + select: () => ({ connection: { connectionId: inference.id } }), + } + const backend = await createProductionBackendResolver(options)(turnInput(profile())) + assert.equal(backend.kind, 'chat') + if (backend.kind !== 'chat') return + const events = [] + for await (const event of streamAgentTurn(backend, 'hello')) events.push(event) + assert.equal(events.at(-1)?.type, 'final') + assert.equal(calls[0]?.url, 'https://router.test/chat/completions') + assert.match(calls[0]?.body ?? '', /"model":"openai\/gpt-5"/u) +}) + +test('CLI Bridge and sandbox resolvers expose only supported runtime backend shapes', async () => { + const bridge = connection('cli-bridge', 'bridge', 'http://127.0.0.1:4010') + const sandbox = connection('tangle-sandbox', 'sandbox', 'https://sandbox.test', true) + const credentials = new MemoryCredentialStore() + const portRef = credentialRef('cred:v1:sandbox') + await credentials.store({ ref: portRef, value: Buffer.from('sandbox-secret') }) + const clientFactory = async (_input: SandboxClientFactoryInput) => ({ + create: async () => ({ id: 'sandbox-test', streamPrompt: async function* () {} }), + }) + const options: ProductionBackendResolverOptions = { + connections: new ConnectionRegistry([bridge, sandbox]), + credentials, + credentialRefResolver: () => portRef, + sandboxClientFactory: clientFactory, + workspaceCwd: '/tmp/braid-connection-test', + select: () => ({ connection: { connectionId: bridge.id } }), + } + const cliBackend = await resolveProductionBackend( + options, + turnInput(profile('openai/gpt-5', 'pi')), + { + connection: { connectionId: bridge.id }, + }, + ) + assert.equal(cliBackend.kind, 'sandbox-plan') + if (cliBackend.kind === 'sandbox-plan') { + assert.equal(cliBackend.createInput.backend, 'pi') + assert.equal(cliBackend.turnOptions.model, 'openai/gpt-5') + } + + const sandboxBackend = await resolveProductionBackend(options, turnInput(profile()), { + connection: { connectionId: sandbox.id }, + }) + assert.equal(sandboxBackend.kind, 'sandbox-plan') + if (sandboxBackend.kind === 'sandbox-plan') { + assert.equal(sandboxBackend.createInput.backend, 'opencode') + assert.equal(sandboxBackend.turnOptions.model, 'openai/gpt-5') + } + + await assert.rejects( + () => + resolveProductionBackend(options, turnInput(profile('openai/gpt-4o', 'claude-code')), { + connection: { connectionId: sandbox.id }, + }), + (error: unknown) => + error instanceof ConnectionError && + error.code === 'CONNECTION_MODEL_HARNESS_MISMATCH' && + /harness=claude-code.*model=openai\/gpt-4o.*not changed/iu.test(error.message), + ) +}) + +test('CLI Bridge preserves matching runner routes and rejects crossed routes', async () => { + const bridge = connection('cli-bridge', 'route', 'http://127.0.0.1:4010') + const options: ProductionBackendResolverOptions = { + connections: new ConnectionRegistry([bridge]), + workspaceCwd: '/tmp/braid-bridge-route', + select: () => ({ connection: { connectionId: bridge.id } }), + } + const prepared = await resolveProductionBackend( + options, + turnInput(profile('codex/default', 'codex')), + { + connection: { connectionId: bridge.id }, + runner: 'codex', + model: 'codex/default', + }, + ) + assert.equal(prepared.kind, 'sandbox-plan') + if (prepared.kind === 'sandbox-plan') { + assert.equal(prepared.createInput.backend, 'codex') + assert.equal(prepared.turnOptions.model, 'codex/default') + } + await assert.rejects( + () => + resolveProductionBackend(options, turnInput(profile('codex/default', 'pi')), { + connection: { connectionId: bridge.id }, + runner: 'pi', + model: 'codex/default', + }), + (error: unknown) => + error instanceof ConnectionError && + error.code === 'CONNECTION_MODEL_HARNESS_MISMATCH' && + /harness=pi.*model=codex\/default.*not changed.*runner=codex/iu.test(error.message), + ) +}) diff --git a/test/conversation-storage.test.ts b/test/conversation-storage.test.ts new file mode 100644 index 0000000..34c0ad2 --- /dev/null +++ b/test/conversation-storage.test.ts @@ -0,0 +1,390 @@ +import assert from 'node:assert/strict' +import { mkdtemp, unlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import test from 'node:test' +import { MemoryCredentialStore } from '../src/adapters/credentials/memory.js' +import { UnavailableExecutionPort } from '../src/adapters/runtime/unavailable-execution.js' +import { openSqliteStorage } from '../src/adapters/storage/sqlite.js' +import { BraidApplication } from '../src/app/application.js' +import { + createBraidApplication, + createDurableBraidApplication, + DETERMINISTIC_PROFILE, +} from '../src/app/composition.js' +import { StorageJournal } from '../src/app/storage-journal.js' +import { assertBraidState } from '../src/domain/invariants.js' +import { SystemClock } from '../src/ports/clock.js' +import { SequenceIds } from '../src/ports/ids.js' + +test('encrypted conversations survive restart and deleted content stays unavailable', async () => { + const root = await mkdtemp(join(tmpdir(), 'braid-conversation-storage-')) + const path = join(root, 'braid.sqlite') + const credentialStore = new MemoryCredentialStore() + const first = await createDurableBraidApplication({ + path, + workspaceRoot: root, + credentialStore, + profile: DETERMINISTIC_PROFILE, + }) + first.app.initialize('/workspace') + await first.app.whenDurable() + const fallbackId = first.app.state().conversationId + const target = await first.app.conversations.lifecycle.create({ + operationId: 'op-storage-conversation-create', + title: 'Encrypted target', + }) + const branch = await first.app.conversations.branches.create({ + operationId: 'op-storage-conversation-branch', + conversationId: target.id, + branchId: target.activeBranchId, + }) + await first.app.conversations.lifecycle.rename({ + operationId: 'op-storage-conversation-rename', + conversationId: target.id, + title: 'Encrypted renamed target', + }) + await first.app.whenDurable() + const beforeRestart = structuredClone(first.app.state()) + await first.storage.close() + + const second = await createDurableBraidApplication({ + path, + workspaceRoot: root, + credentialStore, + profile: DETERMINISTIC_PROFILE, + }) + assert.equal(second.app.state().projectionChecksum, beforeRestart.projectionChecksum) + assert.equal(second.app.state().conversationId, target.id) + assert.equal(second.app.state().branchId, branch.id) + assert.equal( + second.app.state().conversations.find((conversation) => conversation.id === target.id)?.title, + 'Encrypted renamed target', + ) + + await second.app.conversations.lifecycle.delete({ + operationId: 'op-storage-conversation-delete', + conversationId: target.id, + }) + await second.app.whenDurable() + const deletedRows = await second.storage.events({ conversationId: target.id }) + assert.equal(deletedRows.length > 0, true) + assert.equal( + deletedRows.every( + (row) => row.payloadState === 'deleted' || row.payloadState === 'content-key-unavailable', + ), + true, + ) + assert.equal(second.app.state().conversationId, fallbackId) + await second.storage.close() + + const third = await createDurableBraidApplication({ + path, + workspaceRoot: root, + credentialStore, + profile: DETERMINISTIC_PROFILE, + }) + const finalState = third.app.state() + assert.equal(finalState.conversationId, fallbackId) + assert.equal( + finalState.conversations.find((conversation) => conversation.id === target.id)?.deletedAt !== + undefined, + true, + ) + assert.equal( + finalState.branches.some((candidate) => candidate.conversationId === target.id), + false, + ) + assert.equal(JSON.stringify(finalState).includes('Encrypted renamed target'), false) + assertBraidState(finalState) + await third.storage.close() +}) + +test('branch drafts survive an encrypted restart independently', async () => { + const root = await mkdtemp(join(tmpdir(), 'braid-draft-storage-')) + const path = join(root, 'braid.sqlite') + const credentialStore = new MemoryCredentialStore() + const first = await createDurableBraidApplication({ + path, + workspaceRoot: root, + credentialStore, + profile: DETERMINISTIC_PROFILE, + }) + first.app.initialize('/workspace') + await first.app.whenDurable() + const conversationId = first.app.state().conversationId + const firstBranchId = first.app.state().branchId + await first.app.conversations.drafts.set({ + operationId: 'op-storage-draft-first', + text: 'first branch draft', + }) + const secondBranch = await first.app.conversations.branches.create({ + operationId: 'op-storage-draft-branch', + conversationId, + branchId: firstBranchId, + }) + await first.app.conversations.drafts.set({ + operationId: 'op-storage-draft-second', + text: 'second branch draft', + }) + await first.app.conversations.lifecycle.open({ + operationId: 'op-storage-draft-open-first', + conversationId, + branchId: firstBranchId, + }) + await first.app.whenDurable() + await first.storage.close() + + const second = await createDurableBraidApplication({ + path, + workspaceRoot: root, + credentialStore, + profile: DETERMINISTIC_PROFILE, + }) + assert.equal(second.app.state().conversationId, conversationId) + assert.equal(second.app.state().branchId, firstBranchId) + assert.equal(second.app.state().draft, 'first branch draft') + assert.equal( + second.app.state().drafts.find((draft) => draft.branchId === firstBranchId)?.text, + 'first branch draft', + ) + assert.equal( + second.app.state().drafts.find((draft) => draft.branchId === secondBranch.id)?.text, + 'second branch draft', + ) + assertBraidState(second.app.state()) + await second.storage.close() +}) + +test('an imported conversation survives an encrypted restart with controls disabled', async () => { + const root = await mkdtemp(join(tmpdir(), 'braid-import-storage-')) + const path = join(root, 'braid.sqlite') + const credentialStore = new MemoryCredentialStore() + const sourceApp = createBraidApplication({ fixture: 'deterministic' }) + sourceApp.initialize('/source-workspace') + await sourceApp.whenDurable() + const source = await sourceApp.conversations.lifecycle.create({ + operationId: 'op-storage-import-source', + title: 'Portable encrypted conversation', + }) + await sourceApp.send({ operationId: 'op-storage-import-source-turn', text: 'encrypted history' }) + .completion + await sourceApp.conversations.branches.create({ + operationId: 'op-storage-import-source-branch', + conversationId: source.id, + branchId: source.activeBranchId, + }) + const exported = await sourceApp.conversations.exports.export({ + operationId: 'op-storage-import-export', + conversationId: source.id, + format: 'json', + }) + assert(exported.content) + const importSource = join(root, 'portable-conversation.json') + await writeFile(importSource, exported.content, { mode: 0o600 }) + + const first = await createDurableBraidApplication({ + path, + workspaceRoot: root, + credentialStore, + profile: DETERMINISTIC_PROFILE, + }) + first.app.initialize('/workspace') + await first.app.whenDurable() + const imported = await first.app.conversations.imports.import({ + operationId: 'op-storage-import', + source: importSource, + }) + await first.app.whenDurable() + await first.storage.close() + await unlink(importSource) + + const second = await createDurableBraidApplication({ + path, + workspaceRoot: root, + credentialStore, + profile: DETERMINISTIC_PROFILE, + }) + const state = second.app.state() + const replay = await second.app.conversations.imports.import({ + operationId: 'op-storage-import', + source: importSource, + }) + assert.deepEqual(replay, { ...imported, replayed: true }) + const branches = state.branches.filter( + (branch) => branch.conversationId === imported.conversationId, + ) + assert.equal(state.conversationId, imported.conversationId) + assert.equal(branches.length, 2) + const turns = state.turns.filter((turn) => turn.conversationId === imported.conversationId) + const runIds = new Set(turns.flatMap((turn) => turn.runIds)) + const runs = state.runs.filter((run) => runIds.has(run.id)) + assert.equal(runs.length, 1) + assert.equal(runs[0]?.inputTokens, 0) + assert.equal(runs[0]?.outputTokens, 0) + assert.equal(runs[0]?.capabilities.controls.cancel, false) + assert.equal(runs[0]?.receipt.admissionStatus, 'unavailable') + assert.equal( + branches.every( + (branch) => + branch.connectionId === undefined && + branch.bindingId === undefined && + branch.environmentId === undefined && + state.drafts.find((draft) => draft.id === branch.draftId)?.text === '' && + state.queues.find((queue) => queue.id === branch.queueId)?.entryIds.length === 0, + ), + true, + ) + assert.equal( + second.app + .events() + .filter( + (event) => + event.event.kind === 'conversation.imported' && + event.event.operation.id === 'op-storage-import', + ).length, + 1, + ) + assertBraidState(state) + await second.storage.close() +}) + +test('restart completes deletion when key destruction won but acknowledgement was lost', async () => { + const root = await mkdtemp(join(tmpdir(), 'braid-conversation-delete-recovery-')) + const path = join(root, 'braid.sqlite') + const credentialStore = new MemoryCredentialStore() + const clock = new SystemClock() + const storage = await openSqliteStorage({ + path, + workspaceRoot: root, + credentialStore, + }) + const journal = await StorageJournal.fromStorage(storage, clock) + let interrupted = false + const app = new BraidApplication({ + profile: DETERMINISTIC_PROFILE, + execution: new UnavailableExecutionPort(), + clock, + ids: new SequenceIds(), + journal, + effectStorage: storage, + conversationStorage: { + destroyConversation: async (input) => { + const result = await storage.destroyConversation(input) + if (!interrupted) { + interrupted = true + throw new Error('simulated process loss after content-key destruction') + } + return result + }, + }, + }) + app.initialize('/workspace') + await app.whenDurable() + const target = await app.conversations.lifecycle.create({ + operationId: 'op-storage-recovery-create', + title: 'Interrupted deletion', + }) + await assert.rejects( + () => + app.conversations.lifecycle.delete({ + operationId: 'op-storage-recovery-delete', + conversationId: target.id, + }), + /simulated process loss/u, + ) + assert.equal( + app.state().operations.find((operation) => operation.id === 'op-storage-recovery-delete') + ?.status, + 'pending', + ) + await storage.close() + + const restarted = await createDurableBraidApplication({ + path, + workspaceRoot: root, + credentialStore, + profile: DETERMINISTIC_PROFILE, + }) + await restarted.app.whenDurable() + const recovered = restarted.app + .state() + .operations.find((operation) => operation.id === 'op-storage-recovery-delete') + assert.equal(recovered?.status, 'acknowledged') + assert.equal(restarted.app.state().conversationId === target.id, false) + assert.equal( + restarted.app.state().conversations.find((conversation) => conversation.id === target.id) + ?.deletedAt !== undefined, + true, + ) + await restarted.storage.close() +}) + +test('concurrent replay of one export operation performs one durable mutation', async () => { + const root = await mkdtemp(join(tmpdir(), 'braid-conversation-export-race-')) + const path = join(root, 'braid.sqlite') + const destination = join(root, 'conversation.json') + const credentialStore = new MemoryCredentialStore() + const durable = await createDurableBraidApplication({ + path, + workspaceRoot: root, + credentialStore, + profile: DETERMINISTIC_PROFILE, + }) + durable.app.initialize('/workspace') + await durable.app.whenDurable() + const conversations = await Promise.all( + Array.from({ length: 20 }, () => + durable.app.conversations.lifecycle.create({ + operationId: 'op-storage-concurrent-create', + title: 'Concurrent conversation', + }), + ), + ) + assert.equal(new Set(conversations.map((conversation) => conversation.id)).size, 1) + assert.equal( + durable.app + .events() + .filter( + (event) => + event.event.kind === 'conversation.created' && + event.event.operation?.id === 'op-storage-concurrent-create', + ).length, + 1, + ) + const results = await Promise.allSettled( + Array.from({ length: 20 }, () => + durable.app.conversations.exports.export({ + operationId: 'op-storage-concurrent-export', + format: 'json', + destination, + }), + ), + ) + assert.equal(results.filter((result) => result.status === 'fulfilled').length, 20) + assert.equal( + durable.app + .events() + .filter( + (event) => + event.event.kind === 'operation.updated' && + event.event.operation.id === 'op-storage-concurrent-export', + ).length, + 1, + ) + const first = durable.app.conversations.exports.export({ + operationId: 'op-storage-concurrent-conflict', + format: 'json', + }) + await assert.rejects( + () => + durable.app.conversations.exports.export({ + operationId: 'op-storage-concurrent-conflict', + format: 'markdown', + }), + (error: unknown) => + error instanceof Error && 'code' in error && error.code === 'OPERATION_ID_CONFLICT', + ) + await first + await durable.storage.close() +}) diff --git a/test/conversations.test.ts b/test/conversations.test.ts new file mode 100644 index 0000000..e79efb6 --- /dev/null +++ b/test/conversations.test.ts @@ -0,0 +1,867 @@ +import assert from 'node:assert/strict' +import { chmod, mkdtemp, readFile, stat, symlink, unlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import test from 'node:test' +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 { canonicalDigest } from '../src/domain/canonical.js' +import { assertBraidState } from '../src/domain/invariants.js' + +async function initializedApp() { + const app = createBraidApplication({ fixture: 'deterministic' }) + app.initialize('/workspace') + await app.whenDurable() + return app +} + +interface MutableConversationExport { + exportedAt: string + contentDigest: string + content: { + conversation: Record + graphNodes: Array<{ + id: string + reference: { kind: string; id: string } + [key: string]: unknown + }> + graphEdges: Array<{ + id: string + kind: string + source: string + destination: string + provenance: Record + createdAt: string + }> + [key: string]: unknown + } + [key: string]: unknown +} + +function parseExport(value: string): MutableConversationExport { + return JSON.parse(value) as MutableConversationExport +} + +function refreshDigest(document: MutableConversationExport): void { + document.contentDigest = canonicalDigest(document.content) +} + +async function rejectsImport( + app: Awaited>, + operationId: string, + document: MutableConversationExport, + code: string, +): Promise { + const before = app.state() + const eventCount = app.events().length + await assert.rejects( + () => + app.conversations.imports.import({ + operationId, + content: JSON.stringify(document), + }), + (error: unknown) => error instanceof AppError && error.code === code, + ) + assert.deepEqual(app.state(), before) + assert.equal(app.events().length, eventCount) + assert.equal(app.storageFailure(), undefined) +} + +test('conversation lifecycle is durable, searchable, and retry safe', async () => { + const app = await initializedApp() + const initialConversationId = app.state().conversationId + + const created = await app.conversations.lifecycle.create({ + operationId: 'op-conversation-create', + title: 'Investigate queue behavior', + }) + const replay = await app.conversations.lifecycle.create({ + operationId: 'op-conversation-create', + title: 'Investigate queue behavior', + }) + + assert.equal(replay.id, created.id) + assert.equal(app.state().conversationId, created.id) + assert.deepEqual( + app.conversations.lifecycle.list({ query: 'queue', status: 'all' }).map((item) => item.id), + [created.id], + ) + await assert.rejects( + () => + app.conversations.lifecycle.create({ + operationId: 'op-conversation-create', + title: 'Changed request', + }), + (error: unknown) => error instanceof AppError && error.code === 'OPERATION_ID_CONFLICT', + ) + + const renamed = await app.conversations.lifecycle.rename({ + operationId: 'op-conversation-rename', + conversationId: created.id, + title: 'Queue reliability', + }) + assert.equal(renamed.title, 'Queue reliability') + const archived = await app.conversations.lifecycle.archive({ + operationId: 'op-conversation-archive', + conversationId: created.id, + archived: true, + }) + assert.equal(archived.archived, true) + assert.equal(app.conversations.lifecycle.list({ status: 'archived' }).length, 1) + + const opened = await app.conversations.lifecycle.open({ + operationId: 'op-conversation-open', + conversationId: initialConversationId, + }) + assert.equal(app.state().conversationId, initialConversationId) + assert.equal(app.state().branchId, opened.activeBranchId) + assertBraidState(app.state()) +}) + +test('drafts are branch scoped, redacted, bounded, and retry safe', async () => { + const app = await initializedApp() + const conversationId = app.state().conversationId + const firstBranchId = app.state().branchId + const credential = 'sk-proj-draftcanary0123456789abcdefghijklmnopqrstuvwxyz' // sample credential + const first = await app.conversations.drafts.set({ + operationId: 'op-draft-first', + conversationId, + branchId: firstBranchId, + text: `inspect ${credential}`, + }) + const replay = await app.conversations.drafts.set({ + operationId: 'op-draft-first', + conversationId, + branchId: firstBranchId, + text: `inspect ${credential}`, + }) + + assert.equal(first.replayed, false) + assert.equal(replay.replayed, true) + assert.equal(first.draft.text.includes(credential), false) + assert.match(first.draft.text, /redacted/iu) + assert.equal(app.state().draft, first.draft.text) + assert.equal( + app + .events() + .filter( + (event) => + event.event.kind === 'draft.recorded' && event.event.operation?.id === 'op-draft-first', + ).length, + 1, + ) + await assert.rejects( + () => + app.conversations.drafts.set({ + operationId: 'op-draft-first', + conversationId, + branchId: firstBranchId, + text: 'different input', + }), + (error: unknown) => error instanceof AppError && error.code === 'OPERATION_ID_CONFLICT', + ) + + const secondBranch = await app.conversations.branches.create({ + operationId: 'op-draft-branch', + conversationId, + branchId: firstBranchId, + }) + await app.conversations.drafts.set({ + operationId: 'op-draft-second', + conversationId, + branchId: secondBranch.id, + text: 'second branch draft', + }) + await app.conversations.lifecycle.open({ + operationId: 'op-draft-return', + conversationId, + branchId: firstBranchId, + }) + assert.equal(app.state().draft, first.draft.text) + + await app.conversations.drafts.set({ + operationId: 'op-draft-send', + conversationId, + branchId: firstBranchId, + text: 'send this draft', + }) + await app.send({ operationId: 'op-draft-send-turn', text: 'send this draft' }).completion + assert.equal(app.state().drafts.find((draft) => draft.branchId === firstBranchId)?.text, '') + assert.equal( + app.state().drafts.find((draft) => draft.branchId === secondBranch.id)?.text, + 'second branch draft', + ) + await assert.rejects( + () => + app.conversations.drafts.set({ + operationId: 'op-draft-too-large', + text: 'x'.repeat(1024 * 1024 + 1), + }), + (error: unknown) => error instanceof AppError && error.code === 'DRAFT_TOO_LARGE', + ) + assertBraidState(app.state()) +}) + +test('branch, clone, context, and fork use one canonical conversation graph', async () => { + const app = await initializedApp() + const sourceConversationId = app.state().conversationId + const sourceBranchId = app.state().branchId + await app.send({ operationId: 'op-source-turn', text: 'source message' }).completion + const sourceMessages = messagesVisibleOnBranch(app.state(), sourceBranchId) + const boundary = sourceMessages[0] + assert(boundary) + + const branch = await app.conversations.branches.create({ + operationId: 'op-create-branch', + throughMessageId: boundary.id, + }) + assert.equal(branch.conversationId, sourceConversationId) + assert.deepEqual( + messagesVisibleOnBranch(app.state(), branch.id).map((message) => message.id), + [boundary.id], + ) + await app.send({ operationId: 'op-branch-turn', text: 'branch-only message' }).completion + assert.equal(messagesVisibleOnBranch(app.state(), sourceBranchId).length, 2) + assert.equal(messagesVisibleOnBranch(app.state(), branch.id).length, 3) + + const clone = await app.conversations.branches.clone({ + operationId: 'op-clone-conversation', + title: 'Independent copy', + }) + assert.notEqual(clone.id, sourceConversationId) + assert.equal(app.state().conversationId, clone.id) + assert.equal(app.state().queuedInputs.length, 0) + const context = app.conversations.context.plan({ branchId: clone.activeBranchId }) + assert.equal(context.messages.length, 3) + assert.equal(context.complete, true) + + await app.conversations.lifecycle.open({ + operationId: 'op-return-source', + conversationId: sourceConversationId, + branchId: sourceBranchId, + }) + const plan = app.conversations.branches.plan({ + operationId: 'op-fork-conversation', + kind: 'conversation', + throughMessageId: boundary.id, + }) + const repeatedPlan = app.conversations.branches.plan({ + operationId: 'op-fork-conversation', + kind: 'conversation', + throughMessageId: boundary.id, + }) + assert.deepEqual(repeatedPlan, plan) + assert.equal(plan.allowed, true) + const fork = await app.conversations.branches.execute({ + operationId: 'op-fork-conversation', + kind: 'conversation', + throughMessageId: boundary.id, + planDigest: plan.digest, + }) + assert.equal(fork.id, plan.destinationBranchId) + await assert.rejects( + () => + app.conversations.branches.execute({ + operationId: 'op-fork-conflict', + kind: 'conversation', + throughMessageId: boundary.id, + planDigest: plan.digest, + }), + (error: unknown) => error instanceof AppError && error.code === 'FORK_PLAN_CONFLICT', + ) + const workspacePlan = app.conversations.branches.plan({ + operationId: 'op-workspace-fork', + kind: 'workspace', + }) + assert.equal(workspacePlan.allowed, false) + assert.equal(workspacePlan.environment, 'unavailable') + assertBraidState(app.state()) +}) + +test('conversation exports are redacted, bounded, and never overwrite a path', async () => { + const app = await initializedApp() + const credential = 'sk-proj-exportcanary0123456789abcdefghijklmnopqrstuvwxyz' + await app.send({ + operationId: 'op-export-turn', + text: `inspect ${credential}`, + }).completion + const controller = createApplicationUiController(app) + const publicSurfaces = JSON.stringify({ + state: app.state(), + events: app.events(), + view: controller.view(), + headless: controller.state(), + }) + assert.equal(publicSurfaces.includes(credential), false) + const root = await mkdtemp(join(tmpdir(), 'braid-conversation-export-')) + await chmod(root, 0o755) + const destination = join(root, 'conversation.json') + const exported = await app.conversations.exports.export({ + operationId: 'op-export-json', + format: 'json', + destination, + }) + assert.equal(exported.destination, destination) + const bytes = await readFile(destination, 'utf8') + assert.equal(bytes.includes(credential), false) + assert.match(bytes, /redacted/iu) + assert.equal((await stat(destination)).mode & 0o777, 0o600) + assert.equal((await stat(root)).mode & 0o777, 0o755) + const replay = await app.conversations.exports.export({ + operationId: 'op-export-json', + format: 'json', + destination, + }) + assert.equal(replay.replayed, true) + + await assert.rejects( + () => + app.conversations.exports.export({ + operationId: 'op-export-other', + format: 'json', + destination, + }), + (error: unknown) => error instanceof Error, + ) + const symlinkPath = join(root, 'symlink.json') + await symlink(destination, symlinkPath) + await assert.rejects( + () => + app.conversations.exports.export({ + operationId: 'op-export-symlink', + format: 'json', + destination: symlinkPath, + }), + (error: unknown) => error instanceof Error, + ) +}) + +test('conversation import round-trips history offline and retries exactly once', async () => { + const app = await initializedApp() + const sourceConversationId = app.state().conversationId + await app.send({ operationId: 'op-import-source-turn', text: 'portable history' }).completion + const exported = await app.conversations.exports.export({ + operationId: 'op-import-source-export', + format: 'json', + }) + assert(exported.content) + const sourceState = app.state() + const sourceMessages = sourceState.messages.filter( + (message) => message.conversationId === sourceConversationId, + ) + const imported = await app.conversations.imports.import({ + operationId: 'op-import-round-trip', + content: exported.content, + }) + const replay = await app.conversations.imports.import({ + operationId: 'op-import-round-trip', + content: exported.content, + }) + const state = app.state() + const importedConversation = state.conversations.find( + (conversation) => conversation.id === imported.conversationId, + ) + const importedBranches = state.branches.filter( + (branch) => branch.conversationId === imported.conversationId, + ) + const importedMessages = state.messages.filter( + (message) => message.conversationId === imported.conversationId, + ) + const importedTurns = state.turns.filter( + (turn) => turn.conversationId === imported.conversationId, + ) + const importedRunIds = new Set(importedTurns.flatMap((turn) => turn.runIds)) + const importedRuns = state.runs.filter((run) => importedRunIds.has(run.id)) + const sourceMessageIds = new Set(sourceMessages.map((message) => message.id)) + const sourceDigestEdges = state.graphEdges.filter( + (edge) => edge.provenance.sourceDigest === exported.contentDigest, + ) + + assert.notEqual(imported.conversationId, sourceConversationId) + assert.equal(imported.contentDigest, exported.contentDigest) + assert.equal(replay.replayed, true) + assert.deepEqual(replay, { ...imported, replayed: true }) + assert.equal(importedConversation?.title, 'New conversation') + assert.deepEqual( + importedMessages.map((message) => message.text), + sourceMessages.map((message) => message.text), + ) + assert.deepEqual( + importedMessages.map((message) => message.parts.map((part) => [part.kind, part.text])), + sourceMessages.map((message) => message.parts.map((part) => [part.kind, part.text])), + ) + assert.equal( + importedMessages.some((message) => sourceMessageIds.has(message.id)), + false, + ) + assert.equal( + importedBranches.every( + (branch) => + branch.connectionId === undefined && + branch.bindingId === undefined && + branch.environmentId === undefined && + branch.profileId === undefined && + branch.profileSnapshotId === undefined && + Object.keys(branch.overrides).length === 0, + ), + true, + ) + assert.equal( + importedBranches.every( + (branch) => + state.drafts.find((draft) => draft.id === branch.draftId)?.text === '' && + state.queues.find((queue) => queue.id === branch.queueId)?.entryIds.length === 0, + ), + true, + ) + assert.equal( + importedRuns.every( + (run) => + run.providerSessionId === undefined && + run.environmentId === undefined && + run.bindingId === undefined && + run.connectionId === undefined && + run.replayCursor === undefined && + run.interactions.length === 0 && + run.capabilities.controls.cancel === false && + run.capabilities.controls.steer === false && + run.receipt.admissionStatus === 'unavailable', + ), + true, + ) + assert.equal(state.activeRunId, null) + assert.equal( + app + .events() + .filter( + (event) => + event.event.kind === 'conversation.imported' && + event.event.operation.id === 'op-import-round-trip', + ).length, + 1, + ) + assert.equal(sourceDigestEdges.length > 0, true) + assert.equal( + sourceDigestEdges.every( + (edge) => + edge.provenance.operationId === undefined && edge.provenance.receiptId === undefined, + ), + true, + ) + assertBraidState(state) +}) + +test('conversation import preserves histories larger than the default structured array limit', async () => { + const app = await initializedApp() + const sourceConversationId = app.state().conversationId + for (let index = 0; index < 129; index += 1) { + await app.send({ operationId: `op-import-scale-send-${index}`, text: `turn ${index}` }) + .completion + } + const sourceMessages = app + .state() + .messages.filter((message) => message.conversationId === sourceConversationId) + assert.equal(sourceMessages.length, 258) + + const exported = await app.conversations.exports.export({ + operationId: 'op-import-scale-export', + format: 'json', + }) + assert(exported.content) + const imported = await app.conversations.imports.import({ + operationId: 'op-import-scale-import', + content: exported.content, + }) + const importedMessages = app + .state() + .messages.filter((message) => message.conversationId === imported.conversationId) + + assert.equal(imported.messages, 258) + assert.deepEqual( + importedMessages.map((message) => message.text), + sourceMessages.map((message) => message.text), + ) + assertBraidState(app.state()) +}) + +test('conversation import rejects tampering, unsafe data, hostile graphs, and operation conflicts', async () => { + const app = await initializedApp() + await app.send({ operationId: 'op-import-attack-source', text: 'safe source' }).completion + const exported = await app.conversations.exports.export({ + operationId: 'op-import-attack-export', + format: 'json', + }) + assert(exported.content) + const baseline = app.state() + + const tampered = parseExport(exported.content) + tampered.contentDigest = '0'.repeat(64) + await rejectsImport(app, 'op-import-tampered', tampered, 'IMPORT_DIGEST_MISMATCH') + + const secret = parseExport(exported.content) + secret.content.conversation.title = + 'api_key=sk-proj-importcanary0123456789abcdefghijklmnopqrstuvwxyz' + refreshDigest(secret) + await rejectsImport(app, 'op-import-secret', secret, 'IMPORT_REDACTION_REQUIRED') + + const dangling = parseExport(exported.content) + const danglingEdge = dangling.content.graphEdges[0] + assert(danglingEdge) + danglingEdge.destination = 'node-missing' + refreshDigest(dangling) + await rejectsImport(app, 'op-import-dangling', dangling, 'IMPORT_INVALID') + + const cyclic = parseExport(exported.content) + const conversationNode = cyclic.content.graphNodes.find( + (node) => node.reference.kind === 'conversation', + ) + const branchNode = cyclic.content.graphNodes.find((node) => node.reference.kind === 'branch') + assert(conversationNode && branchNode) + cyclic.content.graphEdges.push({ + id: 'edge-import-cycle', + kind: 'attached', + source: branchNode.id, + destination: conversationNode.id, + provenance: {}, + createdAt: cyclic.exportedAt, + }) + refreshDigest(cyclic) + await rejectsImport(app, 'op-import-cycle', cyclic, 'IMPORT_INVALID') + + const deep = parseExport(exported.content) + let nested: Record = {} + for (let index = 0; index < 30; index += 1) nested = { child: nested } + deep.content.conversation.retention = nested + refreshDigest(deep) + await rejectsImport(app, 'op-import-deep', deep, 'IMPORT_TOO_COMPLEX') + + const invalidReceiptDate = parseExport(exported.content) + const importedRuns = invalidReceiptDate.content.runs as Array<{ + receipt?: { admittedAt?: unknown } + }> + assert(importedRuns[0]?.receipt) + importedRuns[0].receipt.admittedAt = 'not-a-date' + refreshDigest(invalidReceiptDate) + await rejectsImport(app, 'op-import-invalid-receipt-date', invalidReceiptDate, 'IMPORT_INVALID') + + await assert.rejects( + () => + app.conversations.imports.import({ + operationId: 'op-import-oversize', + content: 'x'.repeat(2 * 1024 * 1024 + 1), + }), + (error: unknown) => error instanceof AppError && error.code === 'IMPORT_TOO_LARGE', + ) + + const accepted = await app.conversations.imports.import({ + operationId: 'op-import-conflict', + content: exported.content, + }) + const changed = parseExport(exported.content) + changed.content.conversation.title = 'Different valid import' + refreshDigest(changed) + await assert.rejects( + () => + app.conversations.imports.import({ + operationId: 'op-import-conflict', + content: JSON.stringify(changed), + }), + (error: unknown) => error instanceof AppError && error.code === 'OPERATION_ID_CONFLICT', + ) + assert.notEqual(accepted.conversationId, baseline.conversationId) + assert.equal(app.storageFailure(), undefined) + assertBraidState(app.state()) +}) + +test('conversation import reads only regular no-follow files', async () => { + const app = await initializedApp() + const exported = await app.conversations.exports.export({ + operationId: 'op-import-file-export', + format: 'json', + }) + assert(exported.content) + const root = await mkdtemp(join(tmpdir(), 'braid-conversation-import-')) + const source = join(root, 'conversation.json') + const link = join(root, 'conversation-link.json') + await writeFile(source, exported.content, { mode: 0o600 }) + await symlink(source, link) + const result = await app.conversations.imports.import({ + operationId: 'op-import-file', + source, + }) + assert.equal(result.contentDigest, exported.contentDigest) + await assert.rejects( + () => + app.conversations.imports.import({ + operationId: 'op-import-file-link', + source: link, + }), + (error: unknown) => error instanceof AppError && error.code === 'IMPORT_SOURCE_UNSAFE', + ) + await unlink(source) + const replay = await app.conversations.imports.import({ + operationId: 'op-import-file', + source, + }) + assert.equal(replay.replayed, true) + assert.deepEqual(replay, { ...result, replayed: true }) +}) + +test('conversation import rejects cyclic, orphaned, and cross-linked records atomically', async () => { + const app = await initializedApp() + await app.send({ operationId: 'op-import-relations-source', text: 'linked history' }).completion + const exported = await app.conversations.exports.export({ + operationId: 'op-import-relations-export', + format: 'json', + }) + assert(exported.content) + + const cyclic = parseExport(exported.content) + const cyclicConversation = cyclic.content.conversation + const cyclicBranches = cyclic.content.branches as Array> + const firstBranch = cyclicBranches[0] + assert(firstBranch) + const secondBranchId = 'branch-import-cycle-second' + const secondBranch: Record = { ...firstBranch, id: secondBranchId } + firstBranch.source = { + conversationId: cyclicConversation.id, + branchId: secondBranchId, + } + secondBranch.source = { + conversationId: cyclicConversation.id, + branchId: firstBranch.id, + } + cyclicBranches.push(secondBranch) + refreshDigest(cyclic) + await rejectsImport(app, 'op-import-relations-cycle', cyclic, 'IMPORT_INVALID') + + const crossBoundary = parseExport(exported.content) + const boundaryConversation = crossBoundary.content.conversation + const boundaryBranches = crossBoundary.content.branches as Array> + const boundaryMessages = crossBoundary.content.messages as Array> + const boundarySource = boundaryBranches[0] + const foreignMessage = boundaryMessages[0] + assert(boundarySource && foreignMessage) + const emptyBranchId = 'branch-import-boundary-empty' + boundaryBranches.push({ ...boundarySource, id: emptyBranchId }) + boundarySource.source = { + conversationId: boundaryConversation.id, + branchId: emptyBranchId, + throughMessageId: foreignMessage.id, + } + refreshDigest(crossBoundary) + await rejectsImport(app, 'op-import-relations-boundary', crossBoundary, 'IMPORT_INVALID') + + const orphanRun = parseExport(exported.content) + const runs = orphanRun.content.runs as Array> + const sourceRun = runs[0] + assert(sourceRun) + runs.push({ ...sourceRun, id: 'run-import-orphan' }) + refreshDigest(orphanRun) + await rejectsImport(app, 'op-import-relations-orphan', orphanRun, 'IMPORT_INVALID') + + const crossedCitation = parseExport(exported.content) + const citationConversation = crossedCitation.content.conversation + const citationMessages = crossedCitation.content.messages as Array<{ id: string }> + const citationParts = crossedCitation.content.messageParts as Array<{ + id: string + messageId: string + }> + const citedMessage = citationMessages[0] + const foreignPart = citationParts.find((part) => part.messageId !== citedMessage?.id) + assert(citedMessage && foreignPart) + const analyses = crossedCitation.content.analyses as Array> + analyses.push({ + id: 'analysis-import-crossed-citation', + source: { + conversationId: citationConversation.id, + branchId: citationConversation.activeBranchId, + digest: 'b'.repeat(64), + complete: true, + }, + status: 'completed', + findings: [ + { + id: 'finding-import-crossed-citation', + text: 'Mismatched evidence must not be accepted', + citations: [ + { + id: 'citation-import-crossed', + messageId: citedMessage.id, + partId: foreignPart.id, + }, + ], + supported: true, + }, + ], + createdAt: crossedCitation.exportedAt, + updatedAt: crossedCitation.exportedAt, + }) + refreshDigest(crossedCitation) + await rejectsImport(app, 'op-import-relations-citation', crossedCitation, 'IMPORT_INVALID') +}) + +test('conversation import retains normalized part provenance without enabling replay', async () => { + const app = await initializedApp() + await app.send({ operationId: 'op-import-part-source', text: 'part provenance' }).completion + const exported = await app.conversations.exports.export({ + operationId: 'op-import-part-export', + format: 'json', + }) + assert(exported.content) + const document = parseExport(exported.content) + const messages = document.content.messages as Array<{ + parts: Array<{ source?: Record }> + }> + const sourcePart = messages.flatMap((message) => message.parts).find((part) => part.source) + assert(sourcePart?.source) + sourcePart.source.cursor = 'cursor-historical-only' + refreshDigest(document) + + const imported = await app.conversations.imports.import({ + operationId: 'op-import-part-provenance', + content: JSON.stringify(document), + }) + const importedMessages = app + .state() + .messages.filter((message) => message.conversationId === imported.conversationId) + + assert.equal( + importedMessages.some((message) => + message.parts.some((part) => part.source?.cursor === 'cursor-historical-only'), + ), + true, + ) + assert.equal( + app + .state() + .runs.some( + (run) => run.conversationId === imported.conversationId && run.lastCursor !== undefined, + ), + false, + ) +}) + +test('conversation import retains event-only analysis citations as historical evidence', async () => { + const app = await initializedApp() + await app.send({ + operationId: 'op-import-event-citation-source', + text: 'message-backed evidence', + }).completion + const exported = await app.conversations.exports.export({ + operationId: 'op-import-event-citation-export', + format: 'json', + }) + assert(exported.content) + const document = parseExport(exported.content) + const sourceConversation = document.content.conversation + const sourceConversationId = sourceConversation.id + const sourceBranchId = sourceConversation.activeBranchId + assert.equal(typeof sourceConversationId, 'string') + assert.equal(typeof sourceBranchId, 'string') + const sourceMessages = document.content.messages as Array<{ id: string }> + const sourceMessageId = sourceMessages[0]?.id + assert.equal(typeof sourceMessageId, 'string') + const analyses = document.content.analyses as Array> + analyses.push({ + id: 'analysis-event-citation', + source: { + conversationId: sourceConversationId, + branchId: sourceBranchId, + digest: 'a'.repeat(64), + complete: true, + }, + status: 'completed', + findings: [ + { + id: 'finding-event-citation', + text: 'The retained event supports this finding', + citations: [{ id: 'citation-event-only', eventId: 'event-imported-history' }], + supported: true, + }, + { + id: 'finding-message-citation', + text: 'The imported message supports this finding', + citations: [{ id: 'citation-message', messageId: sourceMessageId }], + supported: true, + }, + ], + createdAt: document.exportedAt, + updatedAt: document.exportedAt, + }) + refreshDigest(document) + + const result = await app.conversations.imports.import({ + operationId: 'op-import-event-citation', + content: JSON.stringify(document), + }) + const analysis = app + .state() + .analyses.find((candidate) => candidate.source.conversationId === result.conversationId) + + assert.equal(analysis?.findings[0]?.supported, false) + assert.equal(analysis?.findings[0]?.citations[0]?.eventId !== undefined, true) + assert.equal(analysis?.findings[1]?.supported, true) + assert.equal(analysis?.findings[1]?.citations[0]?.messageId !== undefined, true) +}) + +test('conversation deletion purges owned data and retains an honest tombstone', async () => { + const app = await initializedApp() + const fallbackId = app.state().conversationId + const target = await app.conversations.lifecycle.create({ + operationId: 'op-delete-target-create', + title: 'Delete me', + }) + await app.send({ operationId: 'op-delete-target-turn', text: 'private target text' }).completion + const targetBranchIds = new Set( + app + .state() + .branches.filter((branch) => branch.conversationId === target.id) + .map((branch) => branch.id), + ) + + const tombstone = await app.conversations.lifecycle.delete({ + operationId: 'op-delete-target', + conversationId: target.id, + }) + const state = app.state() + assert.equal(tombstone.deletedAt !== undefined, true) + assert.equal(state.conversationId, fallbackId) + assert.equal( + state.conversations.find((conversation) => conversation.id === target.id)?.title, + 'Deleted conversation', + ) + assert.equal( + state.branches.some((branch) => targetBranchIds.has(branch.id)), + false, + ) + assert.equal( + state.messages.some((message) => message.conversationId === target.id), + false, + ) + assert.equal(JSON.stringify(state).includes('private target text'), false) + assertBraidState(state) +}) + +test('deletion refuses to break a cloned conversation ancestry link', async () => { + const app = await initializedApp() + const sourceId = app.state().conversationId + await app.send({ operationId: 'op-delete-block-source', text: 'source' }).completion + const clone = await app.conversations.branches.clone({ operationId: 'op-delete-block-clone' }) + assert.notEqual(clone.id, sourceId) + + await assert.rejects( + () => + app.conversations.lifecycle.delete({ + operationId: 'op-delete-blocked', + conversationId: sourceId, + }), + (error: unknown) => error instanceof AppError && error.code === 'DELETE_BLOCKED', + ) + assert.equal( + app.state().conversations.find((conversation) => conversation.id === sourceId)?.deletedAt, + undefined, + ) +}) diff --git a/test/coordination.test.ts b/test/coordination.test.ts new file mode 100644 index 0000000..d134f2e --- /dev/null +++ b/test/coordination.test.ts @@ -0,0 +1,306 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { createBraidApplication } from '../src/app/composition.js' +import { effectRequestDigest, SerializedEffectCoordinator } from '../src/app/effect-coordinator.js' +import { MemoryJournal } from '../src/app/journal.js' +import type { BraidEventEnvelope } from '../src/domain/events.js' +import { FixedClock } from '../src/ports/clock.js' +import type { JournalPort } from '../src/ports/effect-storage.js' + +const clock = new FixedClock('2026-08-02T00:00:00.000Z') + +function intent(operationId: string, text: string) { + return { operationId, effectKind: 'test.mutation', request: { text } } as const +} + +test('pending intent is durable before dispatch and same input replays one outcome', async () => { + const storage = new MemoryJournal(clock) + const coordinator = new SerializedEffectCoordinator(storage, clock) + const order: string[] = [] + let dispatches = 0 + + const first = coordinator.start(intent('op-once', 'same'), { + dispatch: async (context) => { + order.push(storage.history(context.operationId)[0]?.status ?? 'missing') + dispatches += 1 + return { status: 'acknowledged', externalReference: 'external-1' } + }, + }) + + assert.equal(first.record.status, 'pending') + const acknowledged = await first.completion + assert.equal(acknowledged.status, 'acknowledged') + assert.deepEqual(order, ['pending']) + assert.equal(dispatches, 1) + assert.deepEqual( + storage.history('op-once').map((record) => record.status), + ['pending', 'acknowledged'], + ) + + const replay = coordinator.start(intent('op-once', 'same'), { + dispatch: async () => { + throw new Error('replayed effect dispatched') + }, + }) + assert.equal(replay.replayed, true) + assert.equal((await replay.completion).status, 'acknowledged') + assert.equal(dispatches, 1) +}) + +test('a storage failure prevents external dispatch', async () => { + const base = new MemoryJournal(clock) + const storage = { + reserveEffect: () => { + throw new Error('disk unavailable') + }, + current: (operationId: string) => base.current(operationId), + latest: (operationId: string, requestDigest: string) => base.latest(operationId, requestDigest), + appendEffect: () => { + throw new Error('disk unavailable') + }, + history: (operationId: string) => base.history(operationId), + } + const coordinator = new SerializedEffectCoordinator(storage, clock) + let dispatched = false + + await assert.rejects( + () => + coordinator.execute(intent('op-durable-failure', 'must not dispatch'), { + dispatch: async () => { + dispatched = true + return { status: 'terminal' } + }, + }), + { code: 'EFFECT_INTENT_NOT_DURABLE' }, + ) + assert.equal(dispatched, false) +}) + +test('changed input records a conflict and never dispatches', async () => { + const storage = new MemoryJournal(clock) + const coordinator = new SerializedEffectCoordinator(storage, clock) + let dispatches = 0 + + await coordinator.execute(intent('op-conflict', 'first'), { + dispatch: async () => { + dispatches += 1 + return { status: 'terminal', detail: 'complete' } + }, + }) + const conflict = coordinator.start(intent('op-conflict', 'changed'), { + dispatch: async () => { + dispatches += 1 + return { status: 'acknowledged' } + }, + }) + + assert.equal(conflict.record.status, 'conflict') + assert.equal( + conflict.record.conflictWithDigest, + effectRequestDigest(intent('op-conflict', 'first')), + ) + assert.equal((await conflict.completion).status, 'conflict') + assert.equal(dispatches, 1) + assert.deepEqual( + storage.history('op-conflict').map((record) => record.status), + ['pending', 'terminal', 'conflict'], + ) +}) + +test('failed, unknown, and terminal dispatch results remain distinct', async () => { + const storage = new MemoryJournal(clock) + const coordinator = new SerializedEffectCoordinator(storage, clock) + + const failed = await coordinator.execute(intent('op-failed', 'failed'), { + dispatch: async () => ({ status: 'failed', detail: 'rejected' }), + }) + const unknown = await coordinator.execute(intent('op-unknown', 'unknown'), { + dispatch: async () => { + throw new Error('connection ended after submit') + }, + }) + const terminal = await coordinator.execute(intent('op-terminal', 'terminal'), { + dispatch: async () => ({ status: 'terminal', detail: 'completed' }), + }) + + assert.equal(failed.status, 'failed') + assert.equal(unknown.status, 'unknown') + assert.equal(terminal.status, 'terminal') +}) + +test('provider diagnostics are reduced to safe effect records before persistence', async () => { + const storage = new MemoryJournal(clock) + const coordinator = new SerializedEffectCoordinator(storage, clock) + + const result = await coordinator.execute(intent('op-secret-diagnostic', 'safe'), { + dispatch: async () => ({ + status: 'unknown' as const, + detail: 'password=do-not-store', + externalReference: 'token=do-not-store', + }), + }) + + assert.equal(result.status, 'unknown') + assert.equal(result.detail, 'EFFECT_UNKNOWN') + assert.equal(result.externalReference, undefined) + assert.equal( + storage + .history('op-secret-diagnostic') + .some((entry) => JSON.stringify(entry).includes('do-not-store')), + false, + ) +}) + +test('effect metadata rejects credential material before persistence', () => { + const storage = new MemoryJournal(clock) + const coordinator = new SerializedEffectCoordinator(storage, clock) + + assert.throws( + () => + coordinator.start( + { + ...intent('op-secret-metadata', 'safe'), + metadata: { token: 'do-not-store' }, + }, + { dispatch: async () => ({ status: 'terminal' }) }, + ), + (error: unknown) => + error instanceof Error && 'code' in error && error.code === 'EFFECT_METADATA_UNSAFE', + ) + assert.equal(storage.history('op-secret-metadata').length, 0) +}) + +test('a state-projection failure after dispatch is recorded as unknown without losing the durable outcome', async () => { + const storage = new MemoryJournal(clock) + const recorded: string[] = [] + const coordinator = new SerializedEffectCoordinator(storage, clock, { + onRecord: (record) => { + recorded.push(record.status) + if (record.status === 'terminal') throw new Error('state projection unavailable') + }, + }) + + const result = await coordinator.execute(intent('op-projection-failure', 'accepted externally'), { + dispatch: async () => ({ status: 'terminal', externalReference: 'external-accepted' }), + }) + + assert.equal(result.status, 'unknown') + assert.deepEqual(recorded, ['pending', 'terminal', 'unknown']) + assert.deepEqual( + storage.history('op-projection-failure').map((record) => record.status), + ['pending', 'terminal', 'unknown'], + ) +}) + +test('concurrent effects are serialized and an old pending record is reconciled without guessing', async () => { + const storage = new MemoryJournal(clock) + const coordinator = new SerializedEffectCoordinator(storage, clock) + let active = 0 + let maximumActive = 0 + + const dispatch = async () => { + active += 1 + maximumActive = Math.max(maximumActive, active) + await new Promise((resolve) => setTimeout(resolve, 5)) + active -= 1 + return { status: 'acknowledged' as const } + } + const first = coordinator.start(intent('op-a', 'a'), { dispatch }) + const second = coordinator.start(intent('op-b', 'b'), { dispatch }) + await Promise.all([first.completion, second.completion]) + assert.equal(maximumActive, 1) + + const pendingIntent = intent('op-pending', 'pending') + const digest = effectRequestDigest(pendingIntent) + storage.appendEffect({ + operationId: pendingIntent.operationId, + effectKind: pendingIntent.effectKind, + requestDigest: digest, + status: 'pending', + attempt: 1, + createdAt: clock.now(), + updatedAt: clock.now(), + metadata: {}, + }) + let dispatched = false + const reconciled = coordinator.start(pendingIntent, { + dispatch: async () => { + dispatched = true + return { status: 'terminal' } + }, + reconcile: async () => ({ status: 'terminal', detail: 'already complete' }), + }) + assert.equal((await reconciled.completion).status, 'terminal') + assert.equal(dispatched, false) +}) + +test('fork-like external steps reconcile by operation and digest without repeating dispatch', async () => { + const storage = new MemoryJournal(clock) + const coordinator = new SerializedEffectCoordinator(storage, clock) + const steps = ['branch', 'checkpoint', 'environment', 'session', 'context'] as const + let dispatches = 0 + + for (const step of steps) { + const forkIntent = { + operationId: `op-fork-${step}`, + effectKind: 'fork.step', + request: { step, source: 'source-1' }, + } as const + const requestDigest = effectRequestDigest(forkIntent) + storage.appendEffect({ + operationId: forkIntent.operationId, + effectKind: forkIntent.effectKind, + requestDigest, + status: 'pending', + attempt: 1, + createdAt: clock.now(), + updatedAt: clock.now(), + metadata: { step }, + }) + const result = await coordinator.execute(forkIntent, { + dispatch: async () => { + dispatches += 1 + return { status: 'terminal' as const } + }, + reconcile: async (context) => ({ + status: 'terminal' as const, + externalReference: `reconciled:${context.request.step}`, + }), + }) + assert.equal(result.status, 'terminal') + } + + assert.equal(dispatches, 0) +}) + +test('composition accepts the journal and effect ports while the deterministic fixture remains usable', async () => { + const journal = new MemoryJournal(clock) + const app = createBraidApplication({ + fixture: 'deterministic', + journal, + effectStorage: journal, + }) + app.initialize('/workspace') + await app.send({ operationId: 'op-composed', text: 'through the seam' }).completion + + assert.equal(journal.all()[0]?.event.kind, 'workspace.opened') + assert.equal(journal.current('op-composed')?.status, 'terminal') +}) + +test('a caller-provided journal is used through JournalPort rather than a production MemoryJournal import', () => { + const events: BraidEventEnvelope[] = [] + const delegate = new MemoryJournal(clock) + const journal: JournalPort = { + envelope: (state, event) => delegate.envelope(state, event), + append: (envelope) => { + events.push(envelope) + delegate.append(envelope) + }, + all: () => delegate.all(), + } + const app = createBraidApplication({ fixture: 'deterministic', journal }) + + app.initialize('/workspace') + assert.equal(events.length, 1) + assert.equal(events[0]?.event.kind, 'workspace.opened') +}) diff --git a/test/domain-ids.test.ts b/test/domain-ids.test.ts new file mode 100644 index 0000000..806db7d --- /dev/null +++ b/test/domain-ids.test.ts @@ -0,0 +1,113 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { + createAnalysisId, + createAnalysisRunId, + createArtifactId, + createAttachmentId, + createBindingId, + createBranchId, + createCheckpointId, + createCitationId, + createConnectionId, + createConversationId, + createCredentialRefId, + createDigest, + createDraftId, + createEffectId, + createEnvironmentId, + createEventId, + createFeedbackDecisionId, + createGraphEdgeId, + createGraphNodeId, + createInteractionId, + createMessageId, + createMessagePartId, + createOperationId, + createProfileId, + createProfileSnapshotId, + createProviderSessionId, + createQueueEntryId, + createQueueId, + createReceiptId, + createReplayCursor, + createRuleId, + createRunId, + createSupervisorId, + createTraceId, + createTurnId, + createWorkerId, + createWorkspaceId, + isConversationId, + isRunId, + parseConversationId, + parseRunId, +} from '../src/domain/ids.js' +import type { ConversationId } from '../src/domain/ids.js' + +test('every domain identifier has a constructor and a nominal runtime prefix', () => { + const constructors: readonly [(value: string) => string, string][] = [ + [createWorkspaceId, 'workspace-'], + [createProfileId, 'profile-'], + [createProfileSnapshotId, 'profile-snapshot-'], + [createCredentialRefId, 'credential-'], + [createConnectionId, 'connection-'], + [createConversationId, 'conv-'], + [createBranchId, 'branch-'], + [createTurnId, 'turn-'], + [createRunId, 'run-'], + [createMessageId, 'message-'], + [createMessagePartId, 'part-'], + [createArtifactId, 'artifact-'], + [createInteractionId, 'interaction-'], + [createAnalysisId, 'analysis-'], + [createAnalysisRunId, 'analysis-run-'], + [createCitationId, 'citation-'], + [createAttachmentId, 'attachment-'], + [createFeedbackDecisionId, 'feedback-'], + [createTraceId, 'trace-'], + [createProviderSessionId, 'session-'], + [createEnvironmentId, 'env-'], + [createCheckpointId, 'checkpoint-'], + [createSupervisorId, 'supervisor-'], + [createWorkerId, 'worker-'], + [createDraftId, 'draft-'], + [createQueueId, 'queue-'], + [createQueueEntryId, 'queue-entry-'], + [createRuleId, 'rule-'], + [createBindingId, 'binding-'], + [createGraphNodeId, 'node-'], + [createGraphEdgeId, 'edge-'], + [createOperationId, 'op-'], + [createEffectId, 'effect-'], + [createReceiptId, 'receipt-'], + [createEventId, 'event-'], + ] + + for (const [index, [createId, prefix]] of constructors.entries()) { + assert.equal(createId(`${prefix}value-${index}`), `${prefix}value-${index}`) + } + + const digest = createDigest('a'.repeat(64)) + assert.equal(digest.length, 64) + assert.equal(createReplayCursor('cursor-1'), 'cursor-1') +}) + +test('identifier validators reject values from another domain', () => { + const run = createRunId('run-1') + const conversation = createConversationId('conv-1') + + assert.equal(isRunId(run), true) + assert.equal(isConversationId(conversation), true) + assert.equal(isRunId(conversation), false) + assert.equal(isConversationId(run), false) + assert.throws(() => parseRunId('branch-1'), /Invalid run identifier/u) + assert.throws(() => parseConversationId('run-1'), /Invalid conversation identifier/u) +}) + +test('brands prevent accidental compile-time substitution', () => { + const run = createRunId('run-compile') + // @ts-expect-error RunId and ConversationId are intentionally nominally distinct. + const wrong: ConversationId = run + assert.equal(typeof wrong, 'string') +}) diff --git a/test/domain-invariants.test.ts b/test/domain-invariants.test.ts new file mode 100644 index 0000000..311e9be --- /dev/null +++ b/test/domain-invariants.test.ts @@ -0,0 +1,226 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { STARTER_PROFILE } from '../src/app/composition.js' +import { assertConnectionRecord, assertInteractionRecord } from '../src/domain/invariants.js' +import type { BraidEvent, JournalEventEnvelope } from '../src/domain/events.js' +import { reduceEvent } from '../src/domain/reducer.js' +import { initialState } from '../src/domain/state.js' +import { + createConnectionId, + createEventId, + createGraphEdgeId, + createGraphNodeId, + createInteractionId, + createOperationId, + createRunId, + createWorkspaceId, +} from '../src/domain/ids.js' +import type { ConnectionRecord, InteractionRecord } from '../src/domain/entities.js' + +const at = '2026-08-02T00:00:00.000Z' + +function envelope(event: BraidEvent, sequence: number): JournalEventEnvelope { + return { + eventId: createEventId(`event-invariant-${sequence}`), + sequence, + revision: sequence, + occurredAt: at, + event, + } +} + +function graphNode(id: ReturnType) { + return { + id, + reference: { kind: 'workspace' as const, id: createWorkspaceId('workspace-invariant') }, + createdAt: at, + updatedAt: at, + } +} + +test('graph edges fail closed for dangling nodes and cycles', () => { + const first = graphNode(createGraphNodeId('node-invariant-a')) + const second = graphNode(createGraphNodeId('node-invariant-b')) + const initial = initialState(STARTER_PROFILE) + const withNodes = [ + envelope({ kind: 'graph.node.upserted', node: first }, 1), + envelope({ kind: 'graph.node.upserted', node: second }, 2), + ].reduce(reduceEvent, initial) + + assert.throws( + () => + reduceEvent( + withNodes, + envelope( + { + kind: 'graph.edge.upserted', + edge: { + id: createGraphEdgeId('edge-invariant-dangling'), + kind: 'continued', + source: first.id, + destination: createGraphNodeId('node-invariant-missing'), + provenance: {}, + createdAt: at, + }, + }, + 3, + ), + ), + /missing node/u, + ) + + const withFirstEdge = reduceEvent( + withNodes, + envelope( + { + kind: 'graph.edge.upserted', + edge: { + id: createGraphEdgeId('edge-invariant-forward'), + kind: 'continued', + source: first.id, + destination: second.id, + provenance: {}, + createdAt: at, + }, + }, + 3, + ), + ) + + assert.throws( + () => + reduceEvent( + withFirstEdge, + envelope( + { + kind: 'graph.edge.upserted', + edge: { + id: createGraphEdgeId('edge-invariant-cycle'), + kind: 'continued', + source: second.id, + destination: first.id, + provenance: {}, + createdAt: at, + }, + }, + 4, + ), + ), + /cycle/u, + ) +}) + +test('1,000 generated graph histories preserve acyclicity and node boundaries', () => { + for (let index = 0; index < 1_000; index += 1) { + const first = graphNode(createGraphNodeId(`node-property-${index}-a`)) + const second = graphNode(createGraphNodeId(`node-property-${index}-b`)) + const third = graphNode(createGraphNodeId(`node-property-${index}-c`)) + const events: JournalEventEnvelope[] = [first, second, third].map((node, nodeIndex) => + envelope({ kind: 'graph.node.upserted', node }, nodeIndex + 1), + ) + events.push( + envelope( + { + kind: 'graph.edge.upserted', + edge: { + id: createGraphEdgeId(`edge-property-${index}-ab`), + kind: 'continued', + source: first.id, + destination: second.id, + provenance: {}, + createdAt: at, + }, + }, + 4, + ), + envelope( + { + kind: 'graph.edge.upserted', + edge: { + id: createGraphEdgeId(`edge-property-${index}-bc`), + kind: 'continued', + source: second.id, + destination: third.id, + provenance: {}, + createdAt: at, + }, + }, + 5, + ), + ) + const state = events.reduce(reduceEvent, initialState(STARTER_PROFILE)) + assert.equal(state.graphNodes.length, 3) + assert.equal(state.graphEdges.length, 2) + } +}) + +test('connection state accepts transport metadata but rejects provider-native options', () => { + const connection: ConnectionRecord = { + id: createConnectionId('connection-invariant'), + kind: 'cli-bridge', + name: 'Local bridge', + providerOptions: { transport: 'stdio', capabilityHints: ['interaction.answerSpec'] }, + createdAt: at, + updatedAt: at, + lastHealth: { status: 'unknown' }, + } + assert.doesNotThrow(() => assertConnectionRecord(connection)) + assert.throws( + () => + assertConnectionRecord({ + ...connection, + providerOptions: { + ...connection.providerOptions, + providerNativeModelFlag: '--dangerously-skip-permissions', + }, + } as ConnectionRecord), + /provider-native state/u, + ) + assert.throws( + () => + assertConnectionRecord({ + ...connection, + endpoint: 'https://user:password=do-not-store@example.test', + }), + /credential material|URL credentials/u, + ) +}) + +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, + ) +}) diff --git a/test/domain-reducer.test.ts b/test/domain-reducer.test.ts new file mode 100644 index 0000000..63bb7fb --- /dev/null +++ b/test/domain-reducer.test.ts @@ -0,0 +1,247 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { STARTER_PROFILE } from '../src/app/composition.js' +import type { BraidEvent, JournalEventEnvelope } from '../src/domain/events.js' +import { createEventId, createReplayCursor, createWorkspaceId } from '../src/domain/ids.js' +import { + DuplicateEventConflictError, + reduceEvent, + replayEvents, + SequenceGapError, +} from '../src/domain/reducer.js' +import { initialState } from '../src/domain/state.js' + +function envelope( + event: BraidEvent, + sequence: number, + eventId = createEventId(`event-${sequence}`), +): JournalEventEnvelope { + return { + eventId, + sequence, + revision: sequence, + occurredAt: '2026-08-02T00:00:00.000Z', + event, + } +} + +function verticalSliceEvents(): readonly JournalEventEnvelope[] { + return [ + envelope({ kind: 'workspace.opened', workspace: '/workspace' }, 1), + envelope({ kind: 'draft.changed', text: 'hello' }, 2), + envelope( + { + kind: 'run.requested', + operationId: 'op-domain', + runId: 'run-domain', + turnId: 'turn-domain', + userMessageId: 'message-user-domain', + assistantMessageId: 'message-assistant-domain', + text: 'hello', + }, + 3, + ), + envelope({ kind: 'run.text.delta', runId: 'run-domain', text: 'response' }, 4), + envelope( + { + kind: 'run.finished', + runId: 'run-domain', + status: 'completed', + finalText: 'response', + usage: { input: 2, output: 1 }, + }, + 5, + ), + ] +} + +test('incremental reduction and full replay produce the same complete projection', () => { + const events = verticalSliceEvents() + const initial = initialState(STARTER_PROFILE) + const incremental = events.reduce(reduceEvent, initial) + const replayed = replayEvents(initial, events) + + assert.deepEqual(replayed, incremental) + assert.equal(incremental.projectionChecksum, replayed.projectionChecksum) + assert.equal(incremental.workspaces.length, 1) + assert.equal(incremental.conversations.length, 1) + assert.equal(incremental.branches.length, 1) + assert.equal(incremental.turns.length, 1) + assert.equal(incremental.messages.length, 2) + assert.equal(incremental.messageParts.length, 2) + assert.equal(incremental.runs[0]?.status, 'completed') + assert.equal(incremental.runs[0]?.complete, true) + assert.equal(incremental.operations[0]?.status, 'terminal') + assert.equal(incremental.health.status, 'healthy') +}) + +test('incremental and full replay checksums agree for 1,000 generated histories', () => { + for (let index = 0; index < 1_000; index += 1) { + const suffix = String(index).padStart(4, '0') + const text = `history-${suffix}` + const events = [ + envelope( + { kind: 'workspace.opened', workspace: `/workspace/${suffix}` }, + 1, + createEventId(`event-${suffix}-workspace`), + ), + envelope({ kind: 'draft.changed', text }, 2, createEventId(`event-${suffix}-draft`)), + envelope( + { + kind: 'run.requested', + operationId: `op-${suffix}`, + runId: `run-${suffix}`, + turnId: `turn-${suffix}`, + userMessageId: `message-user-${suffix}`, + assistantMessageId: `message-assistant-${suffix}`, + text, + }, + 3, + createEventId(`event-${suffix}-request`), + ), + envelope( + { kind: 'run.text.delta', runId: `run-${suffix}`, text: 'response' }, + 4, + createEventId(`event-${suffix}-delta`), + ), + envelope( + { + kind: 'run.finished', + runId: `run-${suffix}`, + status: 'completed', + finalText: 'response', + usage: { input: index, output: 1 }, + }, + 5, + createEventId(`event-${suffix}-finished`), + ), + ] + const initial = initialState(STARTER_PROFILE) + const incremental = events.reduce(reduceEvent, initial) + const replayed = replayEvents(initial, events) + assert.equal(incremental.projectionChecksum, replayed.projectionChecksum, suffix) + } +}) + +test('the reducer acknowledges an identical durable duplicate without a second transition', () => { + const first = envelope({ kind: 'workspace.opened', workspace: '/workspace' }, 1) + const state = reduceEvent(initialState(STARTER_PROFILE), first) + const duplicate = reduceEvent(state, first) + + assert.strictEqual(duplicate, state) + assert.equal(duplicate.revision, 1) + assert.equal(duplicate.sequence, 1) + assert.equal(duplicate.workspaces.length, 1) +}) + +test('the reducer rejects a duplicate event identifier with changed payload', () => { + const eventId = createEventId('event-conflict') + const state = reduceEvent( + initialState(STARTER_PROFILE), + envelope({ kind: 'workspace.opened', workspace: '/workspace' }, 1, eventId), + ) + + assert.throws( + () => + reduceEvent(state, envelope({ kind: 'workspace.opened', workspace: '/other' }, 1, eventId)), + (error: unknown) => error instanceof DuplicateEventConflictError, + ) +}) + +test('the reducer rejects sequence and revision gaps before applying the event', () => { + const initial = initialState(STARTER_PROFILE) + assert.throws( + () => reduceEvent(initial, envelope({ kind: 'workspace.opened', workspace: '/workspace' }, 2)), + (error: unknown) => error instanceof SequenceGapError, + ) + + const state = reduceEvent( + initial, + envelope({ kind: 'workspace.opened', workspace: '/workspace' }, 1), + ) + assert.throws( + () => + reduceEvent(state, { ...envelope({ kind: 'draft.changed', text: 'gap' }, 2), revision: 3 }), + (error: unknown) => error instanceof SequenceGapError, + ) + assert.equal(state.draft, '') +}) + +test('the reducer rejects parseable but non-canonical event timestamps', () => { + assert.throws( + () => + reduceEvent(initialState(STARTER_PROFILE), { + ...envelope({ kind: 'workspace.opened', workspace: '/workspace' }, 1), + occurredAt: '1', + }), + /canonical ISO date/u, + ) +}) + +test('batch replay rejects a transient invalid state even if a later event would repair it', () => { + const workspaceId = createWorkspaceId('workspace-transient-invalid') + const invalid = { + id: workspaceId, + root: '/workspace', + trusted: false, + createdAt: '2026-08-02T00:00:00.000Z', + updatedAt: '1', + } + const valid = { ...invalid, updatedAt: '2026-08-02T00:00:00.000Z' } + + assert.throws( + () => + replayEvents(initialState(STARTER_PROFILE), [ + envelope({ kind: 'workspace.recorded', workspace: invalid }, 1), + envelope({ kind: 'workspace.recorded', workspace: valid }, 2), + ]), + /canonical ISO date/u, + ) +}) + +test('replay cursors and missing history remain explicit in state', () => { + const requested = { + ...verticalSliceEvents()[2], + cursor: createReplayCursor('cursor-after-request'), + } as JournalEventEnvelope + const state = replayEvents(initialState(STARTER_PROFILE), [ + verticalSliceEvents()[0] as JournalEventEnvelope, + verticalSliceEvents()[1] as JournalEventEnvelope, + requested, + envelope( + { + kind: 'history.missing', + range: { runId: 'run-domain', fromSequence: 4, toSequence: 5, reason: 'provider-missing' }, + }, + 4, + ), + ]) + + assert.equal(state.replayCursors[0]?.cursor, 'cursor-after-request') + assert.equal(state.missingHistory.length, 1) + assert.equal(state.health.status, 'incomplete') + assert.equal(state.messages.length, 2) + assert.equal(state.messages[1]?.complete, false) +}) + +test('legacy event payloads still reject cross-domain identifiers at the reducer boundary', () => { + assert.throws( + () => + reduceEvent( + initialState(STARTER_PROFILE), + envelope( + { + kind: 'run.requested', + operationId: 'op-invalid', + runId: 'branch-wrong-domain', + turnId: 'turn-invalid', + userMessageId: 'message-user-invalid', + assistantMessageId: 'message-assistant-invalid', + text: 'invalid', + }, + 1, + ), + ), + /Invalid run identifier/u, + ) +}) diff --git a/test/domain-text.test.ts b/test/domain-text.test.ts new file mode 100644 index 0000000..229f849 --- /dev/null +++ b/test/domain-text.test.ts @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { canonicalJson } from '../src/domain/canonical.js' +import { redactSensitiveText, sanitizeTextChunks } from '../src/domain/secret-sanitizer.js' +import { containsUnsafeControlCharacter, isCanonicalIsoDateTime } from '../src/domain/text.js' + +test('unsafe diagnostic controls are rejected without rejecting normal whitespace', () => { + for (const code of [0, 1, 8, 11, 12, 14, 31, 127]) { + assert.equal(containsUnsafeControlCharacter(String.fromCharCode(code)), true) + } + for (const value of ['plain text', '\t', '\n', '\r', '\u0080']) { + assert.equal(containsUnsafeControlCharacter(value), false) + } +}) + +test('canonical timestamps reject parseable but non-canonical dates', () => { + assert.equal(isCanonicalIsoDateTime('2026-08-02T00:00:00.000Z'), true) + for (const value of ['1', '2026-8-2', '2026-02-30T00:00:00.000Z', '2026-08-02T00:00:00Z']) { + assert.equal(isCanonicalIsoDateTime(value), false, value) + } +}) + +test('canonical JSON uses locale-independent key ordering and rejects ambiguous values', () => { + assert.equal(canonicalJson({ z: 1, ä: 2, a: 3 }), '{"a":3,"z":1,"ä":2}') + assert.throws(() => canonicalJson({ value: Number.NaN }), /finite numbers/u) + assert.throws(() => canonicalJson([undefined]), /undefined array/u) + const cyclic: Record = {} + cyclic.self = cyclic + assert.throws(() => canonicalJson(cyclic), /cycles/u) +}) + +test('known bare credential formats are removed across every stream boundary', () => { + const credentials = [ + 'sk-proj-abcdefghijklmnopqrstuvwxyz0123456789', // sample credential + 'sk-ant-api03-abcdefghijklmnopqrstuvwxyz0123456789', // sample credential + 'github_pat_11AA22BB33CC44DD55EE66FF77GG88HH', // sample credential + 'ghp_abcdefghijklmnopqrstuvwxyz0123456789', // sample credential + 'AKIA1234567890ABCDEF', // sample credential + 'AIza1234567890abcdefghijklmnopqrstuvwxyz', // sample credential + ['xoxb', '123456789012', '123456789012', 'abcdefghijklmnop'].join('-'), // sample credential + ] + for (const credential of credentials) { + const source = `before ${credential} after` + assert.equal(redactSensitiveText(source), 'before [redacted credential] after') + for (let boundary = 0; boundary <= source.length; boundary += 1) { + assert.equal( + sanitizeTextChunks([source.slice(0, boundary), source.slice(boundary)]), + 'before [redacted credential] after', + `${credential.slice(0, 8)} split at ${boundary}`, + ) + } + } + const longCredential = credentials[0] as string + const prefix = 'x'.repeat(4_500) + const suffix = ` after${'z'.repeat(994)}` + const longSource = `${prefix}${longCredential}${suffix}` + const expected = `${prefix}[redacted credential]${suffix}` + assert.equal(sanitizeTextChunks([longSource]), expected) + assert.equal( + sanitizeTextChunks([ + longSource.slice(0, 4_097), + longSource.slice(4_097, 4_530), + longSource.slice(4_530), + ]), + expected, + ) +}) diff --git a/test/effect-admission-child.ts b/test/effect-admission-child.ts new file mode 100644 index 0000000..b61e981 --- /dev/null +++ b/test/effect-admission-child.ts @@ -0,0 +1,57 @@ +import { appendFile } from 'node:fs/promises' +import { join } from 'node:path' +import { openSqliteStorage } from '../src/adapters/storage/sqlite.js' +import { SerializedEffectCoordinator } from '../src/app/effect-coordinator.js' +import { FixedClock } from '../src/ports/clock.js' +import { credentialRef } from '../src/ports/credentials.js' +import { StorageError } from '../src/adapters/storage/sqlite-errors.js' +import { FileCredentialStore } from './support/file-credentials.js' + +const root = required('EFFECT_ROOT') +const logPath = required('EFFECT_LOG') + +function required(name: string): string { + const value = process.env[name] + if (!value) throw new Error(`${name} is required`) + return value +} + +let storage: Awaited> | undefined +for (let attempt = 0; attempt < 100; attempt += 1) { + try { + storage = await openSqliteStorage({ + path: join(root, 'braid.sqlite'), + workspaceRoot: root, + credentialStore: new FileCredentialStore(join(root, 'credentials')), + databaseKeyRef: credentialRef('cred:v1:effect-admission-test'), + }) + break + } catch (error) { + if (!(error instanceof StorageError) || error.code !== 'STORAGE_LOCKED' || attempt === 99) { + throw error + } + await new Promise((resolve) => setTimeout(resolve, 20)) + } +} +if (storage === undefined) throw new Error('effect-admission child could not open storage') + +try { + const coordinator = new SerializedEffectCoordinator(storage, new FixedClock()) + const handle = coordinator.start( + { + operationId: 'op-two-process-admission', + effectKind: 'test.external-mutation', + request: { value: 'same request' }, + }, + { + dispatch: async () => { + await appendFile(logPath, 'dispatch\n', { mode: 0o600 }) + await new Promise((resolve) => setTimeout(resolve, 50)) + return { status: 'acknowledged', externalReference: 'test-reference' } + }, + }, + ) + await handle.completion +} finally { + await storage.close() +} diff --git a/test/effect-admission.test.ts b/test/effect-admission.test.ts new file mode 100644 index 0000000..0f7916b --- /dev/null +++ b/test/effect-admission.test.ts @@ -0,0 +1,71 @@ +import assert from 'node:assert/strict' +import { readFile, mkdtemp, writeFile } from 'node:fs/promises' +import { spawn } from 'node:child_process' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import test from 'node:test' +import { openSqliteStorage } from '../src/adapters/storage/sqlite.js' +import { credentialRef } from '../src/ports/credentials.js' +import { FileCredentialStore } from './support/file-credentials.js' + +const require = createRequire(import.meta.url) +const sqliteAvailable = (() => { + try { + require('better-sqlite3-multiple-ciphers') + return true + } catch { + return false + } +})() + +const childPath = join(process.cwd(), '.test-dist/test/effect-admission-child.js') + +test('two SQLite processes admit one external effect', async () => { + if (!sqliteAvailable) { + throw new Error( + 'W5_NATIVE_STORAGE_BLOCKED: better-sqlite3-multiple-ciphers@12.11.1 is not installed', + ) + } + const root = await mkdtemp(join(tmpdir(), 'braid-effect-admission-')) + const logPath = join(root, 'dispatch.log') + await writeFile(logPath, '', { mode: 0o600 }) + const storage = await openSqliteStorage({ + path: join(root, 'braid.sqlite'), + workspaceRoot: root, + credentialStore: new FileCredentialStore(join(root, 'credentials')), + databaseKeyRef: credentialRef('cred:v1:effect-admission-test'), + }) + await storage.close() + + await Promise.all( + [0, 1].map( + () => + new Promise((resolve, reject) => { + const child = spawn(process.execPath, [childPath], { + cwd: process.cwd(), + env: { ...process.env, EFFECT_ROOT: root, EFFECT_LOG: logPath }, + stdio: ['ignore', 'ignore', 'pipe'], + }) + let stderr = '' + child.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString('utf8') + }) + child.once('error', reject) + child.once('close', (code, signal) => { + if (code !== 0 || signal !== null) { + reject( + new Error( + `effect child exited with code=${String(code)} signal=${String(signal)} stderr=${stderr}`, + ), + ) + return + } + resolve() + }) + }), + ), + ) + const dispatches = (await readFile(logPath, 'utf8')).split('\n').filter(Boolean) + assert.equal(dispatches.length, 1) +}) diff --git a/test/eval.test.ts b/test/eval.test.ts new file mode 100644 index 0000000..ff2b3a9 --- /dev/null +++ b/test/eval.test.ts @@ -0,0 +1,316 @@ +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import test from 'node:test' +import { type ChatClient, CostLedger } from '@tangle-network/agent-eval' +import { assertHeldOutInputs, inspectPilot, runPilotCampaign } from '../src/eval/campaigns.js' +import { SEMANTIC_CASES } from '../src/eval/cases.js' +import { analysisEvidence, SEMANTIC_RELEASE_FIXTURES } from '../src/eval/fixtures.js' +import { baseCalibration, basePilot, packageProvenance } from '../src/eval/record-builder.js' +import { cellCostEvidence, cellEvidence, type SemanticEvalRecord } from '../src/eval/records.js' +import { semanticReleaseDecision } from '../src/eval/release-decision.js' +import type { + RecordedJudgeCall, + SemanticCaseEvidence, + SemanticCellEvidence, +} from '../src/eval/types.js' +import { SEMANTIC_EVAL_CASE_IDS } from '../src/eval/types.js' + +function passingCalibration(): SemanticEvalRecord['calibration'] { + return { + ...baseCalibration(null), + passed: true, + pairedExamples: 18, + minimumPairedExamples: 12, + goodPreferred: 18, + minimumGoodPreferred: 17, + pairPreferenceRate: 1, + strongMean: 0.95, + weakMean: 0.2, + trivialMean: 0.1, + strongWeakMargin: 0.75, + trivialStrongRatio: 0.105, + perCategory: SEMANTIC_EVAL_CASE_IDS.map((id) => ({ + caseId: id, + category: id, + pairCount: 3, + goodPreferred: 3, + ties: 0, + reversals: 0, + trivialComparisons: 3, + trivialRejected: true, + goodMean: 0.95, + badMean: 0.2, + trivialMean: 0.1, + })), + failures: [], + } +} + +function passingPilot(): SemanticEvalRecord['pilot'] { + return { + ...basePilot('passed', 'passed'), + status: 'passed', + score: 0.95, + inspection: { + passed: true, + productAvailable: true, + parseableJudgeScore: true, + rawJudgeCallCaptured: true, + usageCaptured: true, + costCaptured: true, + wallTimeCaptured: true, + failures: [], + }, + } +} + +function passingCell(caseId: string, index: number): SemanticCellEvidence { + return { + cellId: `${caseId}-cell-${index}`, + scenarioId: `${caseId}-scenario-${index}`, + fixtureId: `${caseId}-fixture-${index}`, + semanticOutput: { caseId, index }, + candidateOutput: 'A supported product answer.', + productPath: { + text: 'A supported product answer.', + path: 'plain', + available: true, + missingReason: null, + sourceDigest: `digest-${caseId}-${index}`, + }, + judgeScores: {}, + pass: true, + rawJudgeCallIds: [`call-${caseId}-${index}`], + cost: { + usd: 0, + provenance: { source: 'reported' }, + inputTokens: 10, + outputTokens: 2, + reasoningTokens: null, + cachedTokens: 0, + wallTimeMs: 1, + }, + } +} + +function passingCases(): readonly SemanticCaseEvidence[] { + return SEMANTIC_EVAL_CASE_IDS.map((id) => { + const cells = [1, 2, 3].map((index) => passingCell(id, index)) + return { + id, + category: 'eval', + command: 'pnpm test:eval', + question: id, + referenceCriteria: { requiredSignals: [], forbiddenSignals: [], passThreshold: 0.7 }, + fixtureCount: 3, + passedFixtures: 3, + failedFixtures: 0, + artifacts: [], + productFailures: [], + result: 'passed', + campaign: { cells } as unknown as SemanticCaseEvidence['campaign'], + cells, + disagreements: [], + } + }) +} + +function successfulCalls(cases: readonly SemanticCaseEvidence[]): RecordedJudgeCall[] { + return cases.flatMap((evidence) => + evidence.cells.map((cell) => ({ + callId: cell.rawJudgeCallIds[0] ?? null, + request: {}, + response: {}, + error: null, + startedAt: '2026-08-03T00:00:00.000Z', + finishedAt: '2026-08-03T00:00:00.001Z', + wallTimeMs: 1, + })), + ) +} + +test('analysis fixtures replay a valid product run before freezing evidence', () => { + const { evidence } = analysisEvidence('fixture-replay-regression') + assert.equal(evidence.events[0]?.event.kind, 'run.requested') + assert.equal(evidence.events.at(-1)?.event.kind, 'run.finished') +}) + +test('a stopped semantic run preserves all six category outcomes', () => { + const stopped = baseCalibration('candidate package unavailable') + assert.deepEqual( + stopped.perCategory.map(({ caseId, pairCount, trivialRejected }) => ({ + caseId, + pairCount, + trivialRejected, + })), + SEMANTIC_EVAL_CASE_IDS.map((caseId) => ({ + caseId, + pairCount: 0, + trivialRejected: false, + })), + ) +}) + +test('every semantic release fixture differs from every calibration input', () => { + assert.doesNotThrow(() => assertHeldOutInputs(SEMANTIC_CASES)) +}) + +test('semantic pilot keeps deterministic presentation free and records paid judge usage', async () => { + const root = await mkdtemp(join(tmpdir(), 'braid-eval-pilot-')) + const fixture = SEMANTIC_RELEASE_FIXTURES['EVAL-01'][0] + assert.ok(fixture !== undefined) + const originalProductOutput = fixture.productOutput + const mutableFixture = fixture as { productOutput: typeof fixture.productOutput } + mutableFixture.productOutput = { + ...originalProductOutput, + text: 'Copied conversation context; new provider session; no environment or checkpoint.', + path: 'tui', + available: true, + missingReason: null, + } + const rawCalls: RecordedJudgeCall[] = [] + const chat: ChatClient = { + transport: 'mock', + defaultModel: 'test/semantic-judge', + maximumAttempts: 1, + chat: async (_request, options) => { + const callId = options?.idempotencyKey ?? null + rawCalls.push({ + callId, + request: {}, + response: { status: 'ok' }, + error: null, + startedAt: '2026-08-03T00:00:00.000Z', + finishedAt: '2026-08-03T00:00:00.002Z', + wallTimeMs: 2, + }) + return { + content: JSON.stringify({ + dimensions: { + copied_state: 0.95, + runtime_boundaries: 0.95, + omissions: 0.95, + decision_clarity: 0.95, + }, + notes: 'The answer describes the copied state and the new execution boundaries.', + }), + usage: { + promptTokens: 41, + completionTokens: 7, + totalTokens: 48, + captured: true, + reasoningTokens: 3, + cachedPromptTokens: 5, + }, + costUsd: 0.012, + model: 'test/semantic-judge', + durationMs: 2, + finishReason: 'stop', + contentEmpty: false, + raw: {}, + } + }, + } + const ledger = new CostLedger() + const definition = SEMANTIC_CASES[0] + assert.ok(definition !== undefined) + try { + const execution = await runPilotCampaign({ + definition, + chat, + ledger, + runDir: root, + }) + const cell = execution.campaign?.cells[0] + assert.ok(cell !== undefined) + assert.equal(cell.costUsd, 0) + assert.deepEqual(cell.tokenUsage, { input: 0, output: 0 }) + const inspection = inspectPilot(execution, rawCalls, 0.7) + assert.deepEqual(inspection, { + passed: true, + productAvailable: true, + parseableJudgeScore: true, + rawJudgeCallCaptured: true, + usageCaptured: true, + costCaptured: true, + wallTimeCaptured: true, + failures: [], + }) + const cost = cellCostEvidence(cell, rawCalls, ledger) + assert.equal(cost.usd, 0.012) + assert.equal(cost.inputTokens, 36) + assert.equal(cost.outputTokens, 7) + assert.equal(cost.reasoningTokens, 3) + assert.equal(cost.cachedTokens, 5) + assert.equal(cost.wallTimeMs >= 2, true) + const evidence = cellEvidence(cell, rawCalls, 0.7, ledger) + assert.deepEqual(evidence.rawJudgeCallIds, [rawCalls[0]?.callId]) + assert.equal(Array.isArray(evidence.cost.provenance), true) + } finally { + mutableFixture.productOutput = originalProductOutput + await rm(root, { recursive: true, force: true }) + } +}) + +test('semantic release requires every case, fixture, product path, and raw judge call', () => { + const cases = passingCases() + const input = { + packageReady: true, + calibration: passingCalibration(), + pilot: passingPilot(), + cases, + rawJudgeCalls: successfulCalls(cases), + } + assert.deepEqual(semanticReleaseDecision(input), { + status: 'passed', + releaseAdmissible: true, + reasons: [], + }) + + const missingRawCall = semanticReleaseDecision({ ...input, rawJudgeCalls: [] }) + assert.equal(missingRawCall.status, 'failed') + assert.equal(missingRawCall.releaseAdmissible, false) + assert.match(missingRawCall.reasons.join('\n'), /judge call .* missing or failed/u) + + const unavailableProduct = structuredClone(cases) as SemanticCaseEvidence[] + const firstCell = unavailableProduct[0]?.cells[0] + assert.ok(firstCell !== undefined) + assert.ok(firstCell.productPath !== null) + ;(firstCell as { productPath: SemanticCellEvidence['productPath'] }).productPath = { + ...firstCell.productPath, + available: false, + } + assert.match( + semanticReleaseDecision({ ...input, cases: unavailableProduct }).reasons.join('\n'), + /did not use an available product presenter/u, + ) +}) + +test('package provenance hashes arbitrary tarball bytes without text transcoding', async () => { + const root = await mkdtemp(join(tmpdir(), 'braid-eval-package-')) + try { + const packageJson = Buffer.from('{"name":"@tangle-network/braid","version":"0.1.0"}\n') + const tarball = Buffer.from([0, 255, 254, 253, 128, 1, 2, 3, 244]) + const tarballPath = join(root, 'braid.tgz') + await writeFile(join(root, 'package.json'), packageJson) + await writeFile(tarballPath, tarball) + const proof = await packageProvenance({ + BRAID_EVAL_PACKAGE_ROOT: root, + BRAID_EVAL_TARBALL_PATH: tarballPath, + }) + assert.equal(proof.status, 'ready') + assert.equal( + proof.tarballSha256, + `sha256:${createHash('sha256').update(tarball).digest('hex')}`, + ) + assert.equal( + proof.installedPackageJsonSha256, + `sha256:${createHash('sha256').update(packageJson).digest('hex')}`, + ) + } finally { + await rm(root, { recursive: true, force: true }) + } +}) diff --git a/test/intelligence-dispatch.test.ts b/test/intelligence-dispatch.test.ts new file mode 100644 index 0000000..ee8dde7 --- /dev/null +++ b/test/intelligence-dispatch.test.ts @@ -0,0 +1,351 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import type { ExactAnalystRunEvent, ExactAnalystRunResult } from '@tangle-network/agent-eval' +import { TUI } from '@earendil-works/pi-tui' +import { + AgentEvalAnalystAdapter, + type AnalystRegistryPort, +} from '../src/adapters/analysis/eval-analyst.js' +import { AgentRuntimeExecutionPort } from '../src/adapters/runtime/agent-runtime-execution.js' +import { createApplicationUiController } from '../src/adapters/tui/application-ui-controller.js' +import { freezeAnalysisSource } from '../src/app/analysis-source.js' +import { BraidApplication } from '../src/app/application.js' +import { createBraidApplication, DETERMINISTIC_PROFILE } from '../src/app/composition.js' +import { createMemoryJournal } from '../src/app/journal.js' +import { FixedClock } from '../src/ports/clock.js' +import { SequenceIds } from '../src/ports/ids.js' +import { deterministicBackend } from '../src/testing/deterministic-backend.js' +import type { BraidResponse } from '../src/views/headless/protocol.js' +import { runRpc } from '../src/views/headless/rpc.js' +import { BraidTerminalApp } from '../src/views/tui/terminal-app.js' +import { createBraidTheme } from '../src/views/tui/theme.js' +import { VirtualTerminal } from './support/virtual-terminal.js' + +const NOW = '2026-08-03T20:00:00.000Z' + +async function waitUntil(predicate: () => boolean, timeoutMs = 1_000): Promise { + const deadline = Date.now() + timeoutMs + while (!predicate()) { + if (Date.now() >= deadline) throw new Error('Timed out waiting for intelligence result') + await new Promise((resolve) => setTimeout(resolve, 5)) + } +} + +function registry(): AnalystRegistryPort { + const runExactStream: AnalystRegistryPort['runExactStream'] = async function* ( + runId, + _inputs, + options, + ): AsyncGenerator { + yield { + type: 'run-started', + run_id: runId, + correlation_id: 'correlation-braid-test', + started_at: NOW, + analyst_ids: options.analystIds, + execution_plan: {}, + } as unknown as ExactAnalystRunEvent + const result = { + run_id: runId, + correlation_id: 'correlation-braid-test', + started_at: NOW, + ended_at: NOW, + findings: [], + per_analyst: [], + total_cost_usd: 0, + execution_plan: {}, + completion: { status: 'complete' }, + } as unknown as ExactAnalystRunResult + yield { type: 'run-completed', result } as unknown as ExactAnalystRunEvent + } + return { + list: () => [ + { + id: 'efficiency-behavioral', + description: 'deterministic Braid test analyst', + version: 'test', + cost: { kind: 'deterministic' }, + }, + ], + runExactStream, + } +} + +function createTestApplication(): BraidApplication { + const clock = new FixedClock(NOW) + const journal = createMemoryJournal(clock) + return new BraidApplication({ + profile: DETERMINISTIC_PROFILE, + execution: new AgentRuntimeExecutionPort( + (input) => deterministicBackend(input), + async () => ({ status: 'cancelled' as const }), + { admissionMode: 'sync' }, + ), + clock, + ids: new SequenceIds(), + journal, + effectStorage: journal, + intelligence: { analyst: new AgentEvalAnalystAdapter(registry()) }, + }) +} + +async function createCompletedRun(app: BraidApplication, text: string): Promise { + app.initialize('/workspace') + const receipt = app.send({ + operationId: `op-send-${text.replace(/[^A-Za-z0-9_-]/gu, '-')}`, + text, + }) + await receipt.completion + const runId = app.state().runs.at(-1)?.id + assert(runId) + return runId +} + +function resultFor(responses: readonly BraidResponse[], requestId: string): T { + const response = responses.find( + (candidate) => candidate.type === 'ack' && candidate.requestId === requestId, + ) + assert(response && response.type === 'ack', `missing acknowledgement for ${requestId}`) + assert.notEqual(response.result, undefined, `missing result for ${requestId}`) + return response.result as T +} + +test('Braid exposes analysis actions through the TUI controller without creating a chat message', async () => { + const app = createTestApplication() + const runId = await createCompletedRun(app, 'source run') + const beforeMessages = app.state().messages + const controller = createApplicationUiController(app) + const result = await controller.dispatch({ + type: 'run-command', + command: 'ask', + operationId: 'op-analysis-ask-tui', + args: ['why', 'did', 'this', 'finish'], + }) + + assert.equal(result.kind, 'accepted') + assert.ok(app.intelligence.analysis) + assert.equal(app.state().messages.length, beforeMessages.length) + assert.equal(app.state().analyses.length, 1) + assert.equal(app.state().analyses[0]?.source.runId, runId) + if (result.kind === 'accepted') { + const data = result.data as { + readonly status: string + readonly analysis: { readonly question?: string } + } + assert.equal(data.status, 'completed') + assert.equal(data.analysis.question, 'why did this finish') + } + assert.deepEqual( + app + .events() + .map((envelope) => envelope.event.kind) + .filter((kind) => kind.startsWith('analysis.')), + ['analysis.created', 'analysis.updated', 'analysis.completed'], + ) + await app.close() +}) + +test('the terminal opens saved ask and comparison results instead of reducing them to notices', async () => { + const app = createTestApplication() + const baselineRunId = await createCompletedRun(app, 'terminal baseline') + const candidateRunId = await createCompletedRun(app, 'terminal candidate') + const controller = createApplicationUiController(app) + const terminal = new VirtualTerminal(100, 30) + const tui = new TUI(terminal) + let operation = 0 + const view = new BraidTerminalApp({ + controller, + tui, + theme: createBraidTheme(false), + workspace: '/workspace', + nextOperationId: () => `op-intelligence-ui-${++operation}`, + }) + const done = view.start() + + terminal.sendInput('/ask why did this finish') + terminal.sendInput('\r') + await waitUntil(() => app.state().analyses.length === 1) + await terminal.waitForRender() + const askScreen = terminal.getViewport().join('\n') + assert.match(askScreen, /\/ask · frozen question/u) + assert.match(askScreen, /source:[^\n]+frozen/u) + assert.match(askScreen, /No findings were returned/u) + + terminal.sendInput('\u001b') + terminal.sendInput(`/compare ${baselineRunId} ${candidateRunId}`) + terminal.sendInput('\r') + await waitUntil(() => app.state().analyses.length === 2) + await terminal.waitForRender() + const comparisonScreen = terminal.getViewport().join('\n') + assert.match(comparisonScreen, /\/compare · frozen runs/u) + assert.match(comparisonScreen, new RegExp(`baseline run: ${baselineRunId}`, 'u')) + assert.match(comparisonScreen, new RegExp(`candidate run: ${candidateRunId}`, 'u')) + assert.match(comparisonScreen, /sample: 1 paired/u) + + view.stop() + await done + await app.close() +}) + +test('JSONL ask and named analysis return final data and preserve progress events', async () => { + const app = createTestApplication() + const responses: BraidResponse[] = [] + async function* input(): AsyncGenerator { + yield `${JSON.stringify({ + version: 1, + requestId: 'initialize', + command: 'initialize', + params: { workspace: '/workspace', subscribe: true }, + })}\n` + yield `${JSON.stringify({ + version: 1, + requestId: 'send', + operationId: 'op-send-jsonl', + command: 'send', + params: { text: 'source run' }, + })}\n` + await app.waitForIdle() + const runId = app.state().runs.at(-1)?.id + assert(runId) + yield `${JSON.stringify({ + version: 1, + requestId: 'ask', + operationId: 'op-analysis-ask-jsonl', + command: 'ask', + params: { source: runId, question: 'why did this finish' }, + })}\n` + yield `${JSON.stringify({ + version: 1, + requestId: 'analyze', + operationId: 'op-analysis-recipe-jsonl', + command: 'analyze', + params: { source: runId, recipe: 'cost' }, + })}\n` + yield `${JSON.stringify({ + version: 1, + requestId: 'shutdown', + operationId: 'op-shutdown-jsonl', + command: 'shutdown', + })}\n` + } + + const code = await runRpc(createApplicationUiController(app), input(), { + write: (chunk) => { + responses.push(JSON.parse(chunk) as BraidResponse) + return true + }, + }) + + assert.equal(code, 0) + assert.equal(resultFor<{ readonly status: string }>(responses, 'ask').status, 'completed') + assert.equal(resultFor<{ readonly status: string }>(responses, 'analyze').status, 'completed') + assert.equal(app.state().messages.length, 2) + assert.deepEqual( + app + .events() + .map((envelope) => envelope.event.kind) + .filter((kind) => kind === 'analysis.created' || kind === 'analysis.completed').length, + 4, + ) + assert.ok( + responses.some( + (response) => + response.type === 'event' && + (response.event.kind === 'analysis.created' || + response.event.kind === 'analysis.completed'), + ), + ) + await app.close() +}) + +test('headless compare freezes two run sources without dispatching a chat message', async () => { + const app = createTestApplication() + const baselineRunId = await createCompletedRun(app, 'baseline run') + const candidateRunId = await createCompletedRun(app, 'candidate run') + const beforeMessages = app.state().messages.length + const controller = createApplicationUiController(app) + const result = await controller.dispatch({ + type: 'headless-command', + command: 'compare', + operationId: 'op-analysis-compare', + params: { left: baselineRunId, right: candidateRunId }, + }) + + assert.equal(result.kind, 'accepted') + assert.equal(app.state().messages.length, beforeMessages) + assert.equal(app.state().analyses.length, 1) + assert.equal(app.state().analyses[0]?.kind, 'comparison') + assert.equal(app.state().analyses[0]?.status, 'completed') + if (result.kind === 'accepted') { + const data = result.data as { + readonly baselineRunId: string + readonly candidateRunId: string + readonly semantic: { readonly status: string } + } + assert.equal(data.baselineRunId, baselineRunId) + assert.equal(data.candidateRunId, candidateRunId) + assert.equal(data.semantic.status, 'unavailable') + } + assert.deepEqual( + app + .events() + .map((envelope) => envelope.event.kind) + .filter((kind) => kind.startsWith('analysis.')), + ['analysis.created', 'analysis.completed'], + ) + await app.close() +}) + +test('analysis source includes inherited messages on a forked branch', async () => { + const app = createBraidApplication({ fixture: 'deterministic' }) + const parentRunId = await createCompletedRun(app, 'parent message') + const parentAssistant = app + .state() + .messages.find((message) => message.role === 'assistant' && message.runId === parentRunId) + assert(parentAssistant) + const parentConversationId = app.state().conversationId + const parentBranchId = app.state().branchId + const child = await app.conversations.branches.create({ + operationId: 'op-branch-analysis-regression', + conversationId: parentConversationId, + branchId: parentBranchId, + throughMessageId: parentAssistant.id, + }) + const childReceipt = app.send({ operationId: 'op-send-child-message', text: 'child message' }) + await childReceipt.completion + + const evidence = freezeAnalysisSource({ + state: app.state(), + events: app.events(), + conversationId: parentConversationId, + branchId: child.id, + runId: childReceipt.runId, + }) + assert.deepEqual( + evidence.messages.map((message) => message.text), + [ + 'parent message', + 'Fixture response through pi: parent message', + 'child message', + 'Fixture response through pi: child message', + ], + ) + await app.close() +}) + +test('worker cancellation reports the runtime limitation instead of fabricating success', async () => { + const app = createBraidApplication({ fixture: 'deterministic' }) + app.initialize('/workspace') + const controller = createApplicationUiController(app) + const result = await controller.dispatch({ + type: 'headless-command', + command: 'cancel_worker', + operationId: 'op-cancel-worker', + params: { supervisorId: 'runtime-supervisor', workerId: 'runtime-worker' }, + }) + + assert.equal(result.kind, 'unavailable') + if (result.kind === 'unavailable') assert.match(result.reason, /supervisor\.worker\.cancel/u) + assert.equal(app.state().supervisors.length, 0) + await app.close() +}) diff --git a/test/interactions.test.ts b/test/interactions.test.ts new file mode 100644 index 0000000..1c20b6f --- /dev/null +++ b/test/interactions.test.ts @@ -0,0 +1,713 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { + type InteractionRequest, + type InteractionResponse, + permissionAnswerSpec, +} from '@tangle-network/agent-interface' +import { createApplicationUiController } from '../src/adapters/tui/application-ui-controller.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' +import { + type AutomationContext, + type AutomationStoreInput, + applyAutomation, + automationAudits, + createAutomationRule, + deleteAutomationRule, + disableAutomationRule, + dryRunAutomation, +} from '../src/app/automation-rules.js' +import { DETERMINISTIC_PROFILE } from '../src/app/composition.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 { replayEvents } from '../src/domain/reducer.js' +import type { BraidRuntimeEvent } from '../src/domain/runtime-events.js' +import type { BraidInteraction } from '../src/domain/runtime-projection.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' + +const NOW = '2026-08-01T00:00:00.000Z' + +function questionRequest( + id = 'interaction-question', + overrides: Partial = {}, +): InteractionRequest { + return { + id, + kind: 'question', + title: 'Continue the operation?', + answerSpec: { + fields: [{ type: 'boolean', name: 'continue', label: 'Continue', required: true }], + }, + ...overrides, + } +} + +function permissionRequest(id = 'interaction-permission'): InteractionRequest { + return { + id, + kind: 'permission', + title: 'Allow the operation?', + answerSpec: permissionAnswerSpec({ + allowFeedback: true, + responseScopes: ['interaction', 'session', 'persistent'], + }), + responseScopes: ['interaction', 'session', 'persistent'], + } +} + +function interaction(request: InteractionRequest, runId = 'run-interaction'): BraidInteraction { + return { + request, + runId, + source: { occurredAt: NOW }, + status: 'pending', + } +} + +function waitFor(predicate: () => boolean): Promise { + const deadline = Date.now() + 2_000 + return new Promise((resolve, reject) => { + const check = () => { + if (predicate()) { + resolve() + return + } + if (Date.now() >= deadline) { + reject(new Error('Timed out waiting for interaction state')) + return + } + setTimeout(check, 5) + } + check() + }) +} + +function applicationFor( + execution: ExecutionPort, + journal = new MemoryJournal(new FixedClock(NOW)), +): BraidApplication { + const app = new BraidApplication({ + profile: DETERMINISTIC_PROFILE, + execution, + clock: new FixedClock(NOW), + ids: new SequenceIds(), + journal, + }) + app.initialize('/workspace') + return app +} + +function interactionExecution(request: InteractionRequest): { + readonly execution: ExecutionPort + readonly responses: () => number + readonly lastResponse: () => InteractionResponse | undefined + readonly release: () => void +} { + let responseCount = 0 + let lastResponse: InteractionResponse | undefined + let releaseStream: (() => void) | undefined + const execution: ExecutionPort = { + capabilities: () => DEFAULT_RUN_CAPABILITIES, + async *streamTurn(input): AsyncIterable { + yield { type: 'interaction', request } + await new Promise((resolve) => { + releaseStream = resolve + if (input.signal.aborted) resolve() + else input.signal.addEventListener('abort', () => resolve(), { once: true }) + }) + }, + respondInteraction: async (input) => { + responseCount += 1 + lastResponse = structuredClone(input.response) + releaseStream?.() + return { operationId: input.operationId, outcome: 'accepted' as const } + }, + } + return { + execution, + responses: () => responseCount, + lastResponse: () => lastResponse, + release: () => releaseStream?.(), + } +} + +function automationStore(now = NOW): AutomationStoreInput & { + readonly current: () => BraidState + readonly allEvents: () => readonly BraidEventEnvelope[] + readonly setNow: (value: string) => void +} { + let current = initialState(DETERMINISTIC_PROFILE) + let currentNow = now + const events: BraidEventEnvelope[] = [] + const commitAndWait = async (event: Parameters[0]) => { + const envelope: BraidEventEnvelope = { + sequence: current.sequence + 1, + revision: current.revision + 1, + occurredAt: currentNow, + event, + } + events.push(envelope) + current = replayEvents(current, [envelope]) + } + return { + state: () => current, + events: () => events, + commitAndWait, + now: () => currentNow, + current: () => current, + allEvents: () => events, + setNow: (value) => { + currentNow = value + }, + } +} + +test('published interaction validation handles typed defaults and rejects mismatched responses', () => { + const request = questionRequest('interaction-typed', { + answerSpec: { + fields: [ + { type: 'text', name: 'label', label: 'Label', required: true, default: 'default-label' }, + { type: 'number', name: 'count', label: 'Count', required: true, min: 1, max: 5 }, + { type: 'boolean', name: 'enabled', label: 'Enabled', required: true, default: true }, + { + type: 'select', + name: 'mode', + label: 'Mode', + required: true, + options: [ + { value: 'safe', label: 'Safe' }, + { value: 'fast', label: 'Fast' }, + ], + default: ['safe'], + }, + ], + }, + }) + const checked = checkInteractionResponse(request, { + id: request.id, + outcome: 'accepted', + data: { count: 3, mode: ['safe'] }, + }) + assert.deepEqual(checked.publicData, { + label: 'default-label', + count: 3, + enabled: true, + mode: ['safe'], + }) + assert.equal(checked.containsSecret, false) + assert.ok(checked.dataDigest) + assert.throws( + () => checkInteractionResponse(request, { id: 'other-interaction', outcome: 'declined' }), + (error: unknown) => error instanceof AppError && error.code === 'INVALID_INTERACTION_RESPONSE', + ) +}) + +test('secret answers are validated in memory but never become public data or automation', async () => { + const request = questionRequest('interaction-manual-answer', { + answerSpec: { + fields: [{ type: 'secret', name: 'credential', label: 'Credential', required: true }], + }, + }) + const checked = checkInteractionResponse(request, { + id: request.id, + outcome: 'accepted', + data: { credential: 'secret-answer-fixture' }, + }) + assert.equal(checked.containsSecret, true) + assert.equal(checked.publicData, undefined) + assert.equal(checked.dataDigest, undefined) + assert.deepEqual(checked.response.data, { credential: 'secret-answer-fixture' }) + + const provider = interactionExecution(request) + const app = applicationFor(provider.execution) + app.send({ operationId: 'operation-send-secret', text: 'ask-for-secret' }) + await waitFor(() => app.state().runs[0]?.interactions[0]?.status === 'pending') + const runId = app.state().runs[0]?.id ?? '' + await app.respondInteraction({ + operationId: 'operation-respond-secret', + runId, + interactionId: request.id, + response: checked.response, + }) + assert.deepEqual(provider.lastResponse()?.data, { credential: 'secret-answer-fixture' }) + assert.equal(JSON.stringify(app.events()).includes('secret-answer-fixture'), false) + + const store = automationStore() + await assert.rejects( + createAutomationRule({ + ...store, + operationId: 'operation-secret-rule', + ruleId: 'rule-secret', + request, + answer: { credential: 'secret-answer-fixture' }, + responseScope: 'once', + }), + (error: unknown) => error instanceof AppError && error.code === 'AUTOMATION_SECRET_FORBIDDEN', + ) + assert.equal(store.allEvents().length, 0) + assert.equal(JSON.stringify(store.current()).includes('secret-answer-fixture'), false) + assert.equal( + evaluateAutomation(store.current().rules, interaction(request), { now: NOW }).detail, + 'AUTOMATION_SECRET_FORBIDDEN', + ) +}) + +test('permission scope validation only accepts grants offered by the request', () => { + const request = permissionRequest() + const once = checkInteractionResponse(request, { + id: request.id, + outcome: 'accepted', + data: { grant: ['allow_once'] }, + }) + assert.equal(once.containsSecret, false) + assert.throws( + () => + checkInteractionResponse( + { ...request, responseScopes: ['interaction'] }, + { id: request.id, outcome: 'accepted', data: { grant: ['allow_session'] } }, + ), + (error: unknown) => error instanceof AppError && error.code === 'INVALID_INTERACTION_RESPONSE', + ) +}) + +test('application interaction response is idempotent, conflict-safe, and survives restart', async () => { + const request = questionRequest('interaction-restart') + const provider = interactionExecution(request) + const journal = new MemoryJournal(new FixedClock(NOW)) + const app = applicationFor(provider.execution, journal) + app.send({ operationId: 'operation-send-interaction', text: 'ask' }) + await waitFor(() => app.state().runs[0]?.interactions[0]?.status === 'pending') + + const response: InteractionResponse = { + id: request.id, + outcome: 'accepted', + data: { continue: true }, + } + const first = await app.respondInteraction({ + operationId: 'operation-respond-interaction', + runId: app.state().runs[0]?.id ?? '', + interactionId: request.id, + response, + }) + assert.equal(first.acknowledgement.outcome, 'accepted') + assert.equal(provider.responses(), 1) + + await assert.rejects( + app.respondInteraction({ + operationId: 'operation-stale-interaction', + runId: first.runId, + interactionId: request.id, + response, + }), + (error: unknown) => error instanceof AppError && error.code === 'INTERACTION_STALE', + ) + + const replay = await app.respondInteraction({ + operationId: 'operation-respond-interaction', + runId: first.runId, + interactionId: request.id, + response, + }) + assert.equal(replay.replayed, true) + assert.equal(replay.acknowledgement.outcome, 'already-applied') + assert.equal(provider.responses(), 1) + assert.equal( + app.events().filter((event) => event.event.kind === 'run.interaction.response.requested') + .length, + 1, + ) + assert.equal( + app.events().filter((event) => event.event.kind === 'run.interaction.responded').length, + 1, + ) + assert.equal(app.state().feedbackDecisions.length, 1) + + await assert.rejects( + app.respondInteraction({ + operationId: 'operation-respond-interaction', + runId: first.runId, + interactionId: request.id, + response: { id: request.id, outcome: 'accepted', data: { continue: false } }, + }), + (error: unknown) => error instanceof AppError && error.code === 'OPERATION_CONFLICT', + ) + const restarted = applicationFor( + { + capabilities: () => DEFAULT_RUN_CAPABILITIES, + async *streamTurn(): AsyncIterable {}, + }, + journal, + ) + const afterRestart = await restarted.respondInteraction({ + operationId: 'operation-respond-interaction', + runId: first.runId, + interactionId: request.id, + response, + }) + assert.equal(afterRestart.replayed, true) + assert.equal(afterRestart.acknowledgement.outcome, 'already-applied') + provider.release() +}) + +test('declined and cancelled interaction outcomes remain distinct after restart', async () => { + for (const outcome of ['declined', 'cancelled'] as const) { + const request = questionRequest(`interaction-${outcome}`) + const provider = interactionExecution(request) + const journal = new MemoryJournal(new FixedClock(NOW)) + const app = applicationFor(provider.execution, journal) + app.send({ operationId: `operation-send-${outcome}`, text: outcome }) + await waitFor(() => app.state().runs[0]?.interactions[0]?.status === 'pending') + await app.respondInteraction({ + operationId: `operation-respond-${outcome}`, + runId: app.state().runs[0]?.id ?? '', + interactionId: request.id, + response: { id: request.id, outcome }, + }) + assert.equal(app.state().runs[0]?.interactions[0]?.status, outcome) + + const restarted = applicationFor( + { + capabilities: () => DEFAULT_RUN_CAPABILITIES, + async *streamTurn(): AsyncIterable {}, + }, + journal, + ) + assert.equal(restarted.state().runs[0]?.interactions[0]?.status, outcome) + provider.release() + } +}) + +test('the terminal API never reports an unconfirmed interaction response as accepted', async () => { + const request = questionRequest('interaction-unconfirmed') + let releaseStream: (() => void) | undefined + const execution: ExecutionPort = { + capabilities: () => DEFAULT_RUN_CAPABILITIES, + async *streamTurn(): AsyncIterable { + yield { type: 'interaction', request } + await new Promise((resolve) => { + releaseStream = resolve + }) + }, + respondInteraction: async (input) => ({ + operationId: input.operationId, + outcome: 'unknown', + detail: 'The provider did not confirm the response', + }), + } + const app = applicationFor(execution) + const send = app.send({ operationId: 'operation-send-unconfirmed', text: 'ask' }) + await waitFor(() => app.state().runs[0]?.interactions[0]?.status === 'pending') + const controller = createApplicationUiController(app) + const result = await controller.dispatch({ + type: 'respond-interaction', + operationId: 'operation-respond-unconfirmed', + runId: app.state().runs[0]?.id ?? '', + interactionId: request.id, + response: { outcome: 'accept', value: true }, + }) + + assert.equal(result.kind, 'unavailable') + if (result.kind === 'unavailable') { + assert.equal(result.code, 'CAPABILITY_UNAVAILABLE') + assert.match(result.reason, /did not mark it accepted/u) + } + assert.equal(app.state().runs[0]?.interactions[0]?.status, 'unknown') + assert.equal(app.state().feedbackDecisions.length, 0) + releaseStream?.() + await send.completion +}) + +test('stale and expired interactions are rejected before provider dispatch', async () => { + const request = questionRequest('interaction-expired', { timeoutMs: 1, onTimeout: 'fail' }) + const provider = interactionExecution(request) + const app = applicationFor(provider.execution) + app.send({ operationId: 'operation-send-expired', text: 'expire' }) + await waitFor(() => app.state().runs[0]?.interactions[0]?.status === 'pending') + const runId = app.state().runs[0]?.id ?? '' + await assert.rejects( + app.respondInteraction({ + operationId: 'operation-respond-expired', + runId, + interactionId: request.id, + response: { id: request.id, outcome: 'accepted', data: { continue: true } }, + }), + (error: unknown) => error instanceof AppError && error.code === 'INTERACTION_EXPIRED', + ) + assert.equal(provider.responses(), 0) + provider.release() +}) + +test('automation rules persist scopes, audit outcomes, limits, and mutations through one journal', async () => { + const store = automationStore() + const request = questionRequest('interaction-automation', { + responseScopes: ['interaction', 'session', 'persistent'], + subject: { type: 'tool', toolName: 'write_file' }, + }) + const context: AutomationContext = { runner: 'pi', providerSessionId: 'session-one' } + const created = await createAutomationRule({ + ...store, + operationId: 'operation-create-rule', + ruleId: 'rule-automation', + request, + answer: { continue: true }, + responseScope: 'once', + context, + maximumUses: 1, + }) + assert.equal(created.replayed, false) + assert.equal(store.current().rules[0]?.uses, 0) + assert.equal(store.current().operations[0]?.id, 'operation-create-rule') + + const replayedCreate = await createAutomationRule({ + ...store, + operationId: 'operation-create-rule', + ruleId: 'rule-automation', + request, + answer: { continue: true }, + responseScope: 'once', + context, + maximumUses: 1, + }) + assert.equal(replayedCreate.replayed, true) + await assert.rejects( + createAutomationRule({ + ...store, + operationId: 'operation-create-rule', + ruleId: 'rule-automation', + request, + answer: { continue: false }, + responseScope: 'once', + context, + maximumUses: 1, + }), + (error: unknown) => error instanceof AppError && error.code === 'OPERATION_CONFLICT', + ) + + const dryRun = await dryRunAutomation({ + ...store, + operationId: 'operation-dry-run', + interaction: interaction(request), + context, + }) + assert.equal(dryRun.evaluation.status, 'eligible') + assert.equal(automationAudits(store.allEvents()).at(-1)?.outcome, 'dry-run') + + let callbackUses = -1 + let responseCalls = 0 + const apply = await applyAutomation({ + ...store, + operationId: 'operation-apply-rule', + interaction: interaction(request), + context, + respond: async (response) => { + responseCalls += 1 + callbackUses = store.current().rules[0]?.uses ?? -1 + assert.deepEqual(response.data, { continue: true }) + const receipt: InteractionReceipt = { + operationId: 'operation-response-automation', + runId: 'run-interaction', + interactionId: request.id, + replayed: false, + acknowledgement: { operationId: 'operation-response-automation', outcome: 'accepted' }, + completion: Promise.resolve(store.current()), + } + return receipt + }, + }) + assert.equal(apply.evaluation.status, 'eligible') + assert.equal(callbackUses, 1) + assert.equal(responseCalls, 1) + assert.equal(automationAudits(store.allEvents()).at(-1)?.outcome, 'applied') + + const replayedApply = await applyAutomation({ + ...store, + operationId: 'operation-apply-rule', + interaction: interaction(request), + context, + respond: async () => { + responseCalls += 1 + throw new Error('replayed automation dispatched twice') + }, + }) + assert.equal(replayedApply.replayed, true) + assert.equal(responseCalls, 1) + + const limited = await dryRunAutomation({ + ...store, + operationId: 'operation-dry-run-limited', + interaction: interaction(request), + context, + }) + assert.equal(limited.evaluation.status, 'use-limit') + + await disableAutomationRule({ + ...store, + operationId: 'operation-disable-rule', + ruleId: 'rule-automation', + }) + assert.equal(store.current().rules[0]?.enabled, false) + await deleteAutomationRule({ + ...store, + operationId: 'operation-delete-rule', + ruleId: 'rule-automation', + }) + assert.equal( + store.current().rules.some((rule) => rule.id === 'rule-automation'), + false, + ) + + await createAutomationRule({ + ...store, + operationId: 'operation-create-expiring-rule', + ruleId: 'rule-expiring', + request, + answer: { continue: true }, + responseScope: 'once', + context, + expiresAt: '2026-08-02T00:00:00.000Z', + }) + store.setNow('2026-08-03T00:00:00.000Z') + const expired = await dryRunAutomation({ + ...store, + operationId: 'operation-dry-run-expired', + interaction: interaction(request), + context, + }) + assert.equal(expired.evaluation.status, 'expired') + assert.equal(automationAudits(store.allEvents()).at(-1)?.outcome, 'expired') +}) + +test('session and persistent automation require offered scope and exact context', async () => { + const sessionStore = automationStore() + const sessionRequest = questionRequest('interaction-session', { responseScopes: ['session'] }) + await assert.rejects( + createAutomationRule({ + ...sessionStore, + operationId: 'operation-session-unbound', + ruleId: 'rule-session-unbound', + request: sessionRequest, + answer: { continue: true }, + responseScope: 'session', + }), + (error: unknown) => + error instanceof AppError && error.code === 'AUTOMATION_SCOPE_CONTEXT_REQUIRED', + ) + await createAutomationRule({ + ...sessionStore, + operationId: 'operation-session-rule', + ruleId: 'rule-session', + request: sessionRequest, + answer: { continue: true }, + responseScope: 'session', + context: { providerSessionId: 'session-one' }, + }) + assert.equal( + ( + await dryRunAutomation({ + ...sessionStore, + operationId: 'operation-session-match', + interaction: interaction(sessionRequest), + context: { providerSessionId: 'session-one' }, + }) + ).evaluation.status, + 'eligible', + ) + assert.equal( + ( + await dryRunAutomation({ + ...sessionStore, + operationId: 'operation-session-miss', + interaction: interaction(sessionRequest), + context: { providerSessionId: 'session-two' }, + }) + ).evaluation.status, + 'none', + ) + + const persistentStore = automationStore() + const persistentRequest = questionRequest('interaction-persistent', { + responseScopes: ['persistent'], + }) + await assert.rejects( + createAutomationRule({ + ...persistentStore, + operationId: 'operation-persistent-unconfirmed', + ruleId: 'rule-persistent-unconfirmed', + request: persistentRequest, + answer: { continue: true }, + responseScope: 'persistent', + context: { runner: 'pi' }, + }), + (error: unknown) => + error instanceof AppError && error.code === 'AUTOMATION_CONFIRMATION_REQUIRED', + ) + await createAutomationRule({ + ...persistentStore, + operationId: 'operation-persistent-rule', + ruleId: 'rule-persistent', + request: persistentRequest, + answer: { continue: true }, + responseScope: 'persistent', + confirmPersistent: true, + context: { runner: 'pi' }, + }) + assert.equal( + ( + await dryRunAutomation({ + ...persistentStore, + operationId: 'operation-persistent-match', + interaction: interaction(persistentRequest), + context: { runner: 'pi' }, + }) + ).evaluation.status, + 'eligible', + ) + assert.equal( + ( + await dryRunAutomation({ + ...persistentStore, + operationId: 'operation-persistent-scope-change', + interaction: interaction(persistentRequest), + context: { runner: 'other-runner' }, + }) + ).evaluation.status, + 'none', + ) +}) + +test('equal-priority structured rules fail closed instead of choosing an answer', async () => { + const store = automationStore() + const request = questionRequest('interaction-conflict', { + responseScopes: ['interaction'], + subject: { type: 'command', command: 'deploy' }, + }) + const context: AutomationContext = { runner: 'pi' } + for (const [operationId, ruleId, answer] of [ + ['operation-conflict-one', 'rule-conflict-one', true], + ['operation-conflict-two', 'rule-conflict-two', false], + ] as const) { + await createAutomationRule({ + ...store, + operationId, + ruleId, + request, + answer: { continue: answer }, + responseScope: 'once', + context, + }) + } + const dryRun = await dryRunAutomation({ + ...store, + operationId: 'operation-dry-run-conflict', + interaction: interaction(request), + context, + }) + assert.equal(dryRun.evaluation.status, 'conflict') + assert.equal(automationAudits(store.allEvents()).at(-1)?.outcome, 'conflict') +}) diff --git a/test/keyboard.test.ts b/test/keyboard.test.ts new file mode 100644 index 0000000..e9c3e97 --- /dev/null +++ b/test/keyboard.test.ts @@ -0,0 +1,174 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { matchesKey, setKittyProtocolActive, TUI } from '@earendil-works/pi-tui' +import { createApplicationUiController } from '../src/adapters/tui/application-ui-controller.js' +import { createBraidApplication } from '../src/app/composition.js' +import { detectColorMode, resolveColorMode } from '../src/views/shared/appearance.js' +import { plainAccessibilityText } from '../src/views/shared/plain-accessibility.js' +import { isTextInputSequence, matchesKeyAction, resolveKeymap } from '../src/views/tui/keyboard.js' +import { BraidTerminalApp } from '../src/views/tui/terminal-app.js' +import { + installTerminalOutputPolicy, + keyboardCompatibility, +} from '../src/views/tui/terminal-compatibility.js' +import { createBraidTheme } from '../src/views/tui/theme.js' +import { VirtualTerminal } from './support/virtual-terminal.js' + +test('keyboard bindings accept legacy and Kitty encodings with fallback', () => { + setKittyProtocolActive(false) + assert.equal(matchesKey('\u0010', 'ctrl+p'), true) + assert.equal(matchesKey('\u001b[200~paste\u001b[201~', 'escape'), false) + setKittyProtocolActive(true) + assert.equal(matchesKey('\u001b[112;5u', 'ctrl+p'), true) + setKittyProtocolActive(false) +}) + +test('appearance honors terminal color capabilities and NO_COLOR', () => { + assert.equal(detectColorMode({ TERM: 'ansi' }), '16') + assert.equal(detectColorMode({ TERM: 'xterm-256color' }), '256') + assert.equal(detectColorMode({ TERM: 'xterm', COLORTERM: 'truecolor' }), 'truecolor') + assert.equal(resolveColorMode('truecolor', { TERM: 'xterm-256color', NO_COLOR: '1' }), 'none') + + const sixteen = createBraidTheme({ color: 'truecolor', environment: { TERM: 'ansi' } }) + assert.equal(sixteen.color, '16') + assert.doesNotMatch(sixteen.brand('color'), /38;(?:2|5)/u) + + const noColor = createBraidTheme({ color: 'truecolor', environment: { NO_COLOR: '' } }) + assert.equal(noColor.color, 'none') + assert.equal(noColor.brand('plain'), 'plain') +}) + +test('keymap parsing accepts remaps and rejects ambiguous mandatory actions', () => { + const remapped = resolveKeymap('commandPalette=ctrl+q') + assert.equal(remapped.valid, true) + assert.equal(matchesKeyAction('\u0011', remapped.keymap, 'commandPalette'), true) + assert.equal(matchesKeyAction('\u0010', remapped.keymap, 'commandPalette'), false) + + const conflict = resolveKeymap('commandPalette=ctrl+o') + assert.equal(conflict.valid, false) + assert.equal(conflict.diagnostics.join('\n').includes('conversationSelector'), true) + assert.equal(conflict.diagnostics.join('\n').includes('commandPalette'), true) + assert.equal(conflict.keymap.commandPalette[0], 'ctrl+p') +}) + +test('keyboard diagnostics name Kitty limits and preserve the legacy route', () => { + assert.match( + keyboardCompatibility({ kittyProtocolActive: false }).message, + /Kitty protocol unavailable/u, + ) + assert.match( + keyboardCompatibility({ kittyProtocolActive: true }).message, + /Kitty protocol negotiated/u, + ) + assert.equal(isTextInputSequence('\u001b[200~? 漢字\u001b[201~'), true) + assert.equal(isTextInputSequence('\u001b[63u'), true) +}) + +test('plain terminal output strips complete and split OSC metadata but preserves CSI', () => { + const writes: string[] = [] + const terminal = { + setProgress: (_active: boolean) => {}, + setTitle: (_title: string) => {}, + write: (data: string) => writes.push(data), + } + const restore = installTerminalOutputPolicy(terminal, true) + terminal.write('\u001b[2J\u001b]0;title') + terminal.write('\u0007text\u001b]8;;https://example.com\u001b\\\u001b[31m') + terminal.setTitle('blocked') + terminal.setProgress(true) + restore() + terminal.write('\u001b]0;after\u0007') + assert.deepEqual(writes, ['\u001b[2J', 'text\u001b[31m', '\u001b]0;after\u0007']) +}) + +test('plain terminal output discards oversized split OSC data through BEL and ST', () => { + const writes: string[] = [] + const terminal = { + setProgress: (_active: boolean) => {}, + setTitle: (_title: string) => {}, + write: (data: string) => writes.push(data), + } + const restore = installTerminalOutputPolicy(terminal, true) + terminal.write('before') + terminal.write(`\u001b]0;${'x'.repeat(4090)}`) + terminal.write('BEL tail must not leak') + terminal.write('\u0007after BEL') + terminal.write(`\u001b]8;;${'y'.repeat(4090)}`) + terminal.write('ST tail must not leak') + terminal.write('\u001b') + terminal.write('\\after ST') + restore() + assert.deepEqual(writes, ['before', 'after BEL', 'after ST']) +}) + +test('global shortcuts do not steal question marks, Unicode, or Kitty printable input', async () => { + const terminal = new VirtualTerminal(80, 24) + const tui = new TUI(terminal) + const app = createBraidApplication({ fixture: 'deterministic' }) + app.initialize('/workspace') + const view = new BraidTerminalApp({ + controller: createApplicationUiController(app), + tui, + theme: createBraidTheme(false), + workspace: '/workspace', + nextOperationId: () => 'op-input-safety', + }) + const done = view.start() + terminal.sendInput('?') + terminal.sendInput('\u001b[200~漢字 é 👩🏽‍💻\u001b[201~') + setKittyProtocolActive(true) + terminal.sendInput('\u001b[63u') + setKittyProtocolActive(false) + assert.equal(view.editor.getExpandedText(), '?漢字 é 👩🏽‍💻?') + view.stop() + await done +}) + +test('global command remaps and Kitty key release events execute once', async () => { + const terminal = new VirtualTerminal(80, 24) + const tui = new TUI(terminal) + const app = createBraidApplication({ fixture: 'deterministic' }) + app.initialize('/workspace') + const remapped = resolveKeymap('commandPalette=ctrl+q') + assert.equal(remapped.valid, true) + const view = new BraidTerminalApp({ + controller: createApplicationUiController(app), + tui, + theme: createBraidTheme(false), + workspace: '/workspace', + nextOperationId: () => 'op-keymap', + keymap: remapped.keymap, + }) + const done = view.start() + terminal.sendInput('\u0011') + await terminal.waitForRender() + assert.match(terminal.getViewport().join('\n'), /Commands/u) + terminal.sendInput('\u001b') + await terminal.waitForRender() + setKittyProtocolActive(true) + terminal.sendInput('\u001b[113;5u') + terminal.sendInput('\u001b[113;5:3u') + setKittyProtocolActive(false) + await terminal.waitForRender() + assert.match(terminal.getViewport().join('\n'), /Commands/u) + view.stop() + await done +}) + +test('plain accessibility text names the current state and message roles', async () => { + const app = createBraidApplication({ fixture: 'deterministic' }) + const controller = createApplicationUiController(app) + await controller.initialize('/workspace') + const receipt = await controller.dispatch({ + type: 'send', + operationId: 'op-plain-accessibility', + text: 'Unicode prompt 漢字', + }) + assert.equal(receipt.kind, 'accepted') + if (receipt.kind === 'accepted' && receipt.completion) await receipt.completion + const text = plainAccessibilityText(controller.view()) + assert.match(text, /status:/u) + assert.match(text, /user message: Unicode prompt 漢字/u) + assert.match(text, /assistant message:/u) + assert.equal(text.includes('\u001b') || text.includes('\u0007'), false) +}) diff --git a/test/live-w10.test.ts b/test/live-w10.test.ts new file mode 100644 index 0000000..9fbf6d8 --- /dev/null +++ b/test/live-w10.test.ts @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { pathToFileURL } from 'node:url' + +interface LiveW10Adapter { + runCrossRunnerHandoff(input: { + readonly endpoint: string + readonly credentialRef: string + }): Promise<{ readonly passed: boolean }> + runWorkspaceFork(input: { + readonly endpoint: string + readonly credentialRef: string + }): Promise<{ readonly passed: boolean; readonly cleanupConfirmed: boolean }> +} + +const endpoint = process.env.BRAID_TANGLE_ENDPOINT +const credentialRef = process.env.BRAID_TANGLE_CREDENTIAL_REF +const adapterPath = process.env.BRAID_W10_LIVE_ADAPTER +const skipReason = + !endpoint || !credentialRef + ? 'Tangle cloud cases require BRAID_TANGLE_ENDPOINT and BRAID_TANGLE_CREDENTIAL_REF' + : !adapterPath + ? 'Tangle deployment adapter is unavailable; set BRAID_W10_LIVE_ADAPTER to a real-provider adapter' + : false + +async function liveAdapter(): Promise { + if (!adapterPath) throw new Error('BRAID_W10_LIVE_ADAPTER is required') + const loaded = (await import(pathToFileURL(adapterPath).href)) as { + readonly default?: LiveW10Adapter + } + if (!loaded.default) throw new Error('The configured live adapter must export a default adapter') + return loaded.default +} + +test('LIVE-02 cross-runner handoff uses a fresh provider session', { + skip: skipReason, +}, async () => { + const result = await (await liveAdapter()).runCrossRunnerHandoff({ + endpoint: endpoint as string, + credentialRef: credentialRef as string, + }) + assert.equal(result.passed, true) +}) + +test('LIVE-09 workspace fork confirms independent destination and cleanup', { + skip: skipReason, +}, async () => { + const result = await (await liveAdapter()).runWorkspaceFork({ + endpoint: endpoint as string, + credentialRef: credentialRef as string, + }) + assert.equal(result.passed, true) + assert.equal(result.cleanupConfirmed, true) +}) diff --git a/test/performance.test.ts b/test/performance.test.ts new file mode 100644 index 0000000..d1c0dd5 --- /dev/null +++ b/test/performance.test.ts @@ -0,0 +1,209 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { sanitizeTerminalText } from '../src/views/shared/sanitize.js' +import { layoutFor } from '../src/views/tui/layout.js' + +// @ts-expect-error The performance report helpers are JavaScript release entry points. +const statistics = await import('../scripts/performance/statistics.mjs') +// @ts-expect-error The performance probe is a JavaScript release entry point. +const applicationProbe = await import('../scripts/performance/application-probe.mjs') +// @ts-expect-error The performance lifecycle is a JavaScript release entry point. +const lifecycleModule = await import('../scripts/performance/lifecycle.mjs') +// @ts-expect-error The performance probe is a JavaScript release entry point. +const resizeProbe = await import('../scripts/performance/resize-probe.mjs') +// @ts-expect-error The performance reporting helpers are JavaScript release entry points. +const reporting = await import('../scripts/performance/reporting.mjs') +// @ts-expect-error The performance storage helpers are JavaScript release entry points. +const storageProbes = await import('../scripts/performance/storage-probes.mjs') +const { + assertFullDuration, + createPerformanceMeasurement, + observation, + releaseMeasurement, + summarizeSamples, +} = statistics +const { evaluateRuntimeEventRate } = applicationProbe +const { createPerformanceLifecycle } = lifecycleModule +const { evaluateResizeStreamRate } = resizeProbe +const { assertSmokeMeasurements } = reporting +const { prepareHeadlessProductionProcessFixture } = storageProbes + +function distribution(samples: readonly number[]) { + const ordered = [...samples].sort((left, right) => left - right) + if (ordered.length === 0) throw new Error('performance distribution requires samples') + const percentile = (fraction: number): number => + ordered[Math.min(ordered.length - 1, Math.ceil(ordered.length * fraction) - 1)] ?? 0 + return { + n: ordered.length, + minimum: ordered[0] as number, + median: percentile(0.5), + p90: percentile(0.9), + p95: percentile(0.95), + p99: percentile(0.99), + maximum: ordered.at(-1) as number, + } +} + +test('view transform performance records a complete distribution for 20 real repetitions', (t) => { + const samples: number[] = [] + let total = 0 + for (let repetition = 0; repetition < 20; repetition += 1) { + const started = performance.now() + for (let index = 0; index < 10_000; index += 1) { + total += sanitizeTerminalText(`row ${index} 漢字 é 👩🏽‍💻\u001b[31m`).length + layoutFor(40 + (index % 161), 12 + (index % 49)) + } + samples.push(performance.now() - started) + } + const measured = distribution(samples) + assert.ok(total > 0) + assert.equal(measured.n, 20) + assert.ok(measured.minimum <= measured.median) + assert.ok(measured.median <= measured.p90) + assert.ok(measured.p90 <= measured.p95) + assert.ok(measured.p95 <= measured.p99) + assert.ok(measured.p99 <= measured.maximum) + assert.ok(measured.p95 < 250, `view transforms p95=${measured.p95.toFixed(1)}ms`) + t.diagnostic( + JSON.stringify({ + name: 'view-transform-batch', + unit: 'ms', + environment: { node: process.version, eventCount: 10_000 }, + ...measured, + }), + ) +}) + +test('performance statistics preserve the required percentile definition and release shape', () => { + const distribution = summarizeSamples( + Array.from({ length: 20 }, (_, index) => index + 1), + 'fixture', + ) + assert.deepEqual(distribution, { + n: 20, + minimum: 1, + median: 10, + p90: 18, + p95: 19, + p99: 20, + maximum: 20, + }) + const measurement = createPerformanceMeasurement({ + name: 'PERF-08', + samples: [0.2, 0.4, 0.6, 0.8], + unit: '% of one core', + state: 'warm', + repetitions: 4, + command: 'pnpm run test:performance', + environment: { + machine: 'unit-test machine', + os: 'linux test', + node: process.version, + terminal: 'fixture terminal', + dimensions: '80x24', + database: 'fixture database', + eventCount: 0, + }, + observations: { + missingEvents: observation(0, 'No events are expected in this idle sample'), + providerArtifacts: observation(null, 'No provider artifact measurement applies'), + }, + }) + assert.equal(measurement.passed, true) + assert.deepEqual(Object.keys(releaseMeasurement(measurement)).sort(), [ + 'environment', + 'kind', + 'maximum', + 'median', + 'minimum', + 'n', + 'name', + 'p90', + 'p95', + 'p99', + 'repetitions', + 'state', + 'target', + 'unit', + ]) +}) + +test('idle CPU proof rejects a short run before it can be reported as complete', () => { + assert.doesNotThrow(() => assertFullDuration(60_000)) + assert.throws(() => assertFullDuration(59_999), /shorter than 60000ms/u) +}) + +test('runtime throughput calibration rejects a slow producer plus final render frame', () => { + const slow = evaluateRuntimeEventRate({ count: 100, elapsedMs: 1_001 }) + assert.ok(slow.achievedEventsPerSecond < 100) + assert.equal(slow.passed, false) + + const fast = evaluateRuntimeEventRate({ count: 100, elapsedMs: 999 }) + assert.equal(fast.passed, true) +}) + +test('PERF-07 rejects an intentionally slow 90 events/s producer and render interval', () => { + const slow = evaluateResizeStreamRate({ + produced: 900, + accepted: 900, + streamElapsedMs: 10_000, + }) + assert.equal(slow.offeredEventsPerSecond, 90) + assert.equal(slow.acceptedEventsPerSecond, 90) + assert.equal(slow.observedEventsPerSecond, 90) + assert.equal(slow.passed, false) +}) + +test('smoke validation rejects failed rows without concrete reasons', () => { + const failed = { + kind: 'distribution', + name: 'PERF-06', + n: 1, + rawSamples: [3_000], + passed: false, + failureReasons: [], + } + assert.throws( + () => assertSmokeMeasurements([failed], ['PERF-06']), + /failed without a concrete failure reason/u, + ) + assert.throws( + () => + assertSmokeMeasurements( + [{ ...failed, failureReasons: ['p95 target was missed'] }], + ['PERF-06'], + ), + /failed its smoke target/u, + ) +}) + +test('performance interruption aborts new work and awaits cleanup exactly once', async () => { + const lifecycle = createPerformanceLifecycle() + const cleanupCounts = { initial: 0, late: 0 } + lifecycle.addCleanup(async () => { + cleanupCounts.initial += 1 + }) + lifecycle.abort('test interruption') + lifecycle.addCleanup(async () => { + cleanupCounts.late += 1 + }) + assert.throws(() => lifecycle.throwIfAborted(), /Performance run aborted: test interruption/u) + await Promise.all([lifecycle.close(), lifecycle.close()]) + assert.deepEqual(cleanupCounts, { initial: 1, late: 1 }) +}) + +test('headless production process fixtures are seeded before launch', async () => { + const calls: string[] = [] + const fixture = { + eventCount: 10_000, + async seed() { + calls.push('seed') + }, + } + const prepared = await prepareHeadlessProductionProcessFixture(fixture) + calls.push('launch') + assert.deepEqual(calls, ['seed', 'launch']) + assert.equal(prepared.eventCount, 10_000) + assert.equal(prepared.processMode, 'isolated-production-pty') + assert.equal(prepared.database, '10000-event-encrypted-sqlite-headless-production') +}) diff --git a/test/production-composition.test.ts b/test/production-composition.test.ts new file mode 100644 index 0000000..7e0fa4a --- /dev/null +++ b/test/production-composition.test.ts @@ -0,0 +1,1741 @@ +import assert from 'node:assert/strict' +import { + access, + chmod, + copyFile, + mkdir, + mkdtemp, + readdir, + readFile, + stat, + writeFile, +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, relative } from 'node:path' +import test from 'node:test' +import { + type AgentProfile, + canonicalAgentProfileDigest, + canonicalCandidateJson, + defineAgentProfile, +} from '@tangle-network/agent-interface' +import { HeadlessCredentialStore } from '../src/adapters/credentials/headless-store.js' +import { MemoryCredentialStore } from '../src/adapters/credentials/memory.js' +import { ApplicationUiController } from '../src/adapters/tui/application-ui-controller.js' +import { + createBraidApplication, + createDurableBraidApplication, + createProductionBraidApplication, +} from '../src/app/composition.js' +import { MemoryJournal } from '../src/app/journal.js' +import { + type ProductionCompositionConfig, + ProductionCompositionError, +} from '../src/app/production-composition.js' +import { createProfileRecord } from '../src/app/profiles.js' +import { parseArgs } from '../src/bin/args.js' +import { + openProductionApplication, + productionConfigForSelection, +} from '../src/bin/production-application.js' +import { discoverBridge } from '../src/bin/production-bridge-discovery.js' +import { createProductionCredentialContext } from '../src/bin/production-credential-context.js' +import { + DEFAULT_CLI_BRIDGE_ENDPOINT, + describeProductionSelection, + loadProductionSetup, + persistableProductionProfile, + prepareProductionSelection, + recoverPendingProductionCredential, + saveProductionStartupSelection, + transitionProductionSelection, + validateProductionSelection, +} from '../src/bin/production-setup.js' +import { + formatProductionStartupError, + loadProductionStartup, + ProductionStartupError, +} from '../src/bin/production-startup.js' +import { defaultBraidDataDirectory, defaultStatePath } from '../src/bin/state-path.js' +import type { ConnectionKind, ConnectionRecord } from '../src/domain/entities.js' +import { createConnectionId, createCredentialRefId } from '../src/domain/ids.js' +import { FixedClock } from '../src/ports/clock.js' +import { credentialRef } from '../src/ports/credentials.js' +import { SequenceIds } from '../src/ports/ids.js' + +const at = '2026-08-03T12:00:00.000Z' + +function profile(): Readonly { + return defineAgentProfile({ + name: 'production test profile', + description: 'A canonical profile used by the production composition proof', + harness: 'pi', + model: { default: 'openai/gpt-5' }, + }) +} + +function connection( + kind: ConnectionKind, + id: string, + endpoint: string, + withCredential = false, +): ConnectionRecord { + return { + id: createConnectionId(`connection-${id}`), + kind, + name: `${kind} ${id}`, + endpoint, + ...(withCredential ? { credentialRef: createCredentialRefId(`credential-${id}`) } : {}), + providerOptions: { transport: kind === 'cli-bridge' ? 'local' : 'https' }, + createdAt: at, + updatedAt: at, + lastHealth: { status: 'unknown' }, + } +} + +function responseStream(text = 'production response'): Response { + const body = [ + `data: ${JSON.stringify({ choices: [{ delta: { content: text }, finish_reason: null }] })}`, + '', + `data: ${JSON.stringify({ choices: [{ delta: {}, finish_reason: 'stop' }], usage: { prompt_tokens: 2, completion_tokens: 3 } })}`, + '', + 'data: [DONE]', + '', + ].join('\n') + return new Response(body, { status: 200, headers: { 'content-type': 'text/event-stream' } }) +} + +function requestHeader(init: RequestInit | undefined, name: string): string | null { + return new Headers(init?.headers).get(name) +} + +async function transitionSetup(root: string) { + const setup = await loadProductionSetup({ + workspace: root, + fetch: async (input) => { + const path = new URL(String(input)).pathname + if (path === '/health') { + return new Response( + JSON.stringify({ status: 'ok', backends: [{ name: 'pi', state: 'ready' }] }), + { status: 200 }, + ) + } + return new Response( + JSON.stringify({ data: [{ id: 'pi/openai-codex/gpt-5.6-luna', backend: 'pi' }] }), + { status: 200 }, + ) + }, + }) + const selectedProfile = setup.profiles[0] + const selectedConnection = setup.connections[0] + if (!selectedProfile || !selectedConnection) throw new Error('transition test setup is empty') + return { + setup, + selection: { + profile: selectedProfile, + connection: selectedConnection, + profileDigest: selectedProfile.digest, + connectionDigest: + 'sha256:0000000000000000000000000000000000000000000000000000000000000000' as const, + }, + } +} + +function composition( + record: ConnectionRecord, + options: ProductionCompositionConfig['connectionOptions'] = {}, +): ProductionCompositionConfig { + return { + profile: profile(), + connections: [record], + connectionId: record.id, + connectionOptions: options, + } +} + +async function runProductionTurn(config: ProductionCompositionConfig): Promise<{ + readonly url: string + readonly state: ReturnType['state']> +}> { + const clock = new FixedClock() + const journal = new MemoryJournal(clock) + const requests: string[] = [] + const fetcher: typeof fetch = async (input) => { + requests.push(String(input)) + return responseStream() + } + const app = createBraidApplication({ + production: { + ...config, + connectionOptions: { ...(config.connectionOptions ?? {}), fetch: fetcher }, + }, + clock, + ids: new SequenceIds(), + journal, + effectStorage: journal, + }) + try { + app.initialize('/workspace') + const receipt = app.send({ operationId: 'op-production-turn', text: 'run production' }) + await receipt.admissionReady + const state = await receipt.completion + return { url: requests[0] ?? '', state } + } finally { + await app.close() + } +} + +test('normal composition streams a configured CLI Bridge turn through agent-runtime', async () => { + const record = connection('cli-bridge', 'local', 'http://127.0.0.1:4010') + const result = await runProductionTurn(composition(record)) + + assert.equal( + result.state.runs[0]?.status, + 'completed', + JSON.stringify({ run: result.state.runs[0], url: result.url }), + ) + assert.equal( + result.state.messages.at(-1)?.text, + 'production response', + JSON.stringify({ state: result.state, url: result.url }), + ) + assert.equal(result.url, 'http://127.0.0.1:4010/v1/chat/completions') + assert.equal(result.state.runs[0]?.receipt.provider, 'agent-runtime') +}) + +test('normal composition streams a configured Tangle inference turn through agent-runtime', async () => { + const record = connection('tangle-inference', 'cloud', 'https://router.test', true) + const credentials = new MemoryCredentialStore() + const ref = credentialRef('cred:v1:credential-cloud') + await credentials.store({ ref, value: Buffer.from('test-only-provider-secret') }) + const result = await runProductionTurn( + composition(record, { + credentials, + credentialRefResolver: () => ref, + }), + ) + + assert.equal( + result.state.runs[0]?.status, + 'completed', + JSON.stringify({ run: result.state.runs[0], url: result.url }), + ) + assert.equal( + result.state.messages.at(-1)?.text, + 'production response', + JSON.stringify({ state: result.state, url: result.url }), + ) + assert.equal(result.url, 'https://router.test/chat/completions') +}) + +test('durable composition uses the configured backend on the normal startup path', async () => { + const root = await mkdtemp(join(tmpdir(), 'braid-production-durable-')) + const record = connection('cli-bridge', 'durable', 'http://127.0.0.1:4010') + const requests: string[] = [] + const { app } = await createDurableBraidApplication({ + path: join(root, 'braid.db'), + workspaceRoot: root, + credentialStore: new MemoryCredentialStore(), + production: composition(record, { + fetch: async (input) => { + requests.push(String(input)) + return responseStream() + }, + }), + }) + try { + app.initialize(root) + const state = await app.send({ + operationId: 'op-durable-production-turn', + text: 'run durable production', + }).completion + assert.equal(state.runs[0]?.status, 'completed') + assert.equal(state.runs[0]?.receipt.provider, 'agent-runtime') + assert.equal(state.messages.at(-1)?.text, 'production response') + assert.equal(requests[0], 'http://127.0.0.1:4010/v1/chat/completions') + } finally { + await app.close() + } +}) + +test('strict production composition fails closed when configuration is missing', () => { + assert.throws( + () => createProductionBraidApplication(), + (error: unknown) => + error instanceof ProductionCompositionError && + error.code === 'PRODUCTION_CONFIGURATION_REQUIRED' && + /canonical profile.*configured connection/iu.test(error.message), + ) + assert.throws( + () => + createProductionBraidApplication({ + production: { + profile: profile(), + connections: [], + connectionId: 'connection-missing', + }, + }), + (error: unknown) => + error instanceof ProductionCompositionError && + error.code === 'PRODUCTION_CONNECTION_REQUIRED', + ) +}) + +test('strict production composition rejects unsupported connection kinds', () => { + const unsupported = { + ...connection('cli-bridge', 'unsupported', 'http://127.0.0.1:4010'), + kind: 'unsupported-provider', + } as unknown as ConnectionRecord + + assert.throws( + () => + createProductionBraidApplication({ + production: { + profile: profile(), + connections: [unsupported], + connectionId: unsupported.id, + }, + }), + (error: unknown) => + error instanceof ProductionCompositionError && + error.code === 'PRODUCTION_CONNECTION_UNSUPPORTED' && + /unsupported provider kind/iu.test(error.message), + ) +}) + +test('bin startup loads a canonical profile and exact connection from a bounded config file', async () => { + const root = await mkdtemp(join(tmpdir(), 'braid-production-startup-')) + const profilePath = join(root, 'profile.json') + const configPath = join(root, 'config.json') + const record = connection('cli-bridge', 'config', 'http://127.0.0.1:4010', true) + await writeFile(profilePath, `${canonicalCandidateJson(profile())}\n`, { mode: 0o600 }) + await writeFile( + configPath, + `${JSON.stringify({ + format: 'braid-startup-config', + schemaVersion: 1, + profile: 'profile.json', + connectionId: record.id, + connections: [record], + })}\n`, + { mode: 0o600 }, + ) + + const credentials = new MemoryCredentialStore() + const startup = await loadProductionStartup({ + workspace: root, + configPath, + credentialStore: credentials, + }) + assert.equal(startup.profile.name, 'production test profile') + assert.equal(startup.connectionId, record.id) + assert.equal(startup.connectionOptions?.credentials, credentials) + assert.equal( + await startup.connectionOptions?.credentialRefResolver?.( + createCredentialRefId('credential-config'), + ), + 'cred:v1:credential-config', + ) + assert.equal(parseArgs(['--config', configPath], root).config, configPath) +}) + +test('startup resolves relative database keys beside external config and rejects workspace paths', async () => { + const workspace = await mkdtemp(join(tmpdir(), 'braid-production-key-workspace-')) + const configDirectory = await mkdtemp(join(tmpdir(), 'braid-production-key-config-')) + const configPath = join(configDirectory, 'config.json') + const keyPath = join(configDirectory, 'database.key') + const profilePath = join(configDirectory, 'profile.json') + const record = connection('cli-bridge', 'relative-key', 'http://127.0.0.1:3344') + await writeFile(profilePath, `${canonicalCandidateJson(profile())}\n`, { mode: 0o600 }) + await writeFile(keyPath, Buffer.alloc(32, 7), { mode: 0o600 }) + await chmod(keyPath, 0o600) + await writeFile( + configPath, + `${JSON.stringify({ + format: 'braid-startup-config', + schemaVersion: 1, + profile: 'profile.json', + connectionId: record.id, + connections: [record], + databaseKeyFile: 'database.key', + })}\n`, + { mode: 0o600 }, + ) + + const startup = await loadProductionStartup({ workspace, configPath }) + assert.equal(startup.databaseKeyFile, keyPath) + assert.equal(relative(workspace, startup.databaseKeyFile ?? ''), relative(workspace, keyPath)) + + const workspaceKeyPath = join(workspace, 'database.key') + await writeFile(workspaceKeyPath, Buffer.alloc(32, 8), { mode: 0o600 }) + await chmod(workspaceKeyPath, 0o600) + await writeFile( + configPath, + `${JSON.stringify({ + format: 'braid-startup-config', + schemaVersion: 1, + profile: 'profile.json', + connectionId: record.id, + connections: [record], + databaseKeyFile: relative(configDirectory, workspaceKeyPath), + })}\n`, + { mode: 0o600 }, + ) + await assert.rejects( + () => loadProductionStartup({ workspace, configPath }), + (error: unknown) => + error instanceof ProductionStartupError && + error.code === 'PRODUCTION_DATABASE_KEY_INVALID' && + /outside the agent workspace|protected mode-0600/iu.test(formatProductionStartupError(error)), + ) +}) + +test('production default state isolates workspaces and config identities across restart', async () => { + const dataDirectory = await mkdtemp(join(tmpdir(), 'braid-production-state-data-')) + const keyDirectory = await mkdtemp(join(tmpdir(), 'braid-production-state-keys-')) + const workspaceA = await mkdtemp(join(tmpdir(), 'braid-production-state-a-')) + const workspaceB = await mkdtemp(join(tmpdir(), 'braid-production-state-b-')) + const configPathA = join(workspaceA, '.braid', 'config.json') + const configPathB = join(workspaceB, '.braid', 'config.json') + const keyPathA = join(keyDirectory, 'workspace-a.key') + const keyPathB = join(keyDirectory, 'workspace-b.key') + await mkdir(join(workspaceA, '.braid'), { recursive: true, mode: 0o700 }) + await mkdir(join(workspaceB, '.braid'), { recursive: true, mode: 0o700 }) + await writeFile(configPathA, '{"identity":"workspace-a"}\n', { mode: 0o600 }) + await writeFile(configPathB, '{"identity":"workspace-b"}\n', { mode: 0o600 }) + await writeFile(keyPathA, Buffer.alloc(32, 41), { mode: 0o600 }) + await writeFile(keyPathB, Buffer.alloc(32, 42), { mode: 0o600 }) + await chmod(keyPathA, 0o600) + await chmod(keyPathB, 0o600) + + const environment = { XDG_DATA_HOME: dataDirectory } + const statePathA = defaultStatePath(workspaceA, configPathA, environment) + const statePathB = defaultStatePath(workspaceB, configPathB, environment) + assert.notEqual(statePathA, statePathB) + assert.match( + relative(join(dataDirectory, 'braid', 'workspaces'), statePathA), + /^[0-9a-f]{64}\/braid\.sqlite$/u, + ) + assert.notEqual( + defaultStatePath(workspaceA, join(workspaceA, '.braid', 'alternate.json'), environment), + statePathA, + ) + assert.equal( + defaultStatePath(workspaceA, configPathA, { + ...environment, + BRAID_STATE_PATH: join(dataDirectory, 'explicit.sqlite'), + }), + join(dataDirectory, 'explicit.sqlite'), + ) + assert.ok(relative(workspaceA, statePathA).startsWith('..')) + assert.ok(relative(workspaceB, statePathB).startsWith('..')) + assert.equal(defaultBraidDataDirectory(environment), join(dataDirectory, 'braid')) + + const openAndClose = async ( + workspace: string, + configPath: string, + keyPath: string, + statePath: string, + id: string, + ): Promise => { + const context = createProductionCredentialContext({ + workspace, + configPath, + databaseKeyFile: keyPath, + dataDirectory, + }) + assert.ok(context) + if (!context) return + const record = connection('cli-bridge', id, 'http://127.0.0.1:3344') + const fetcher: typeof fetch = async () => new Response('{}', { status: 200 }) + const handle = await openProductionApplication({ + workspace, + statePath, + startupOptions: { + workspace, + configPath, + databaseKeyFile: context.databaseKeyFile, + credentialStore: context.store, + credentialContext: context, + fetch: fetcher, + }, + production: { + profile: profile(), + connections: [record], + connectionId: record.id, + databaseKeyFile: context.databaseKeyFile, + connectionOptions: { credentials: context.store, fetch: fetcher }, + }, + }) + try { + assert.equal(handle.app.state().workspace, workspace) + } finally { + await handle.close() + } + await access(statePath) + } + + await openAndClose(workspaceA, configPathA, keyPathA, statePathA, 'restart-a-1') + await openAndClose(workspaceB, configPathB, keyPathB, statePathB, 'restart-b-1') + await openAndClose(workspaceA, configPathA, keyPathA, statePathA, 'restart-a-2') + await openAndClose(workspaceB, configPathB, keyPathB, statePathB, 'restart-b-2') +}) + +test('first-run setup exposes secret-free candidates and writes a recoverable config', async () => { + const root = await mkdtemp(join(tmpdir(), 'braid-production-setup-')) + const discoveryPaths: string[] = [] + const setup = await loadProductionSetup({ + workspace: root, + fetch: async (input) => { + const path = new URL(String(input)).pathname + discoveryPaths.push(path) + if (path === '/health') + return new Response( + JSON.stringify({ status: 'ok', backends: [{ name: 'pi', state: 'ready' }] }), + { status: 200 }, + ) + return new Response( + JSON.stringify({ data: [{ id: 'pi/openai-codex/gpt-5.6-luna', backend: 'pi' }] }), + { status: 200 }, + ) + }, + }) + const profile = setup.profiles[0] + const connection = setup.connections[0] + assert.ok(profile) + assert.ok(connection) + if (!profile || !connection) return + await saveProductionStartupSelection(join(root, '.braid', 'config.json'), { + profile, + connection, + profileDigest: profile.digest, + connectionDigest: 'sha256:0000000000000000000000000000000000000000000000000000000000000000', + }) + const saved = await readFile(join(root, '.braid', 'config.json'), 'utf8') + assert.doesNotMatch(saved, /(?:secret|token|api[_-]?key)/iu) + assert.match(saved, /braid-startup-config/u) + assert.match(saved, /connection-local-cli-bridge/u) + assert.equal(connection.endpoint, DEFAULT_CLI_BRIDGE_ENDPOINT) + assert.equal(profile.profile.harness, 'pi') + assert.equal(profile.profile.model?.default, 'pi/openai-codex/gpt-5.6-luna') + assert.deepEqual([...new Set(discoveryPaths)].sort(), ['/health', '/v1/models']) + assert.equal(setup.verification.status, 'unverified') + const effective = describeProductionSelection( + { + profile, + connection, + profileDigest: profile.digest, + connectionDigest: 'sha256:0000000000000000000000000000000000000000000000000000000000000000', + }, + root, + setup.verification, + ) + assert.equal(effective.runner, 'pi') + assert.equal(effective.model, 'pi/openai-codex/gpt-5.6-luna') + assert.equal(effective.effort, 'provider default (not pinned)') + assert.equal(effective.workdir, root) + assert.match(effective.verification, /^unverified:/u) + assert.match(effective.unsupported.join('\n'), /provider workdir placement/u) +}) + +test('startup persistence keeps the exact profile and rejects inline credential material', async () => { + const root = await mkdtemp(join(tmpdir(), 'braid-production-profile-identity-')) + const exactProfile = defineAgentProfile({ + ...profile(), + metadata: { + uiLabel: 'preserve this metadata', + authenticationMethod: 'oauth', + tokenBudget: 'large', + bridgeCredential: { kind: 'secret-ref', key: 'cli-bridge-auth', format: 'bearer' }, + }, + }) + const record = createProfileRecord( + { + kind: 'inline', + reference: 'test:exact-profile', + label: 'Exact profile', + writable: false, + trusted: true, + }, + exactProfile, + ) + const selectedConnection = connection('cli-bridge', 'profile-identity', 'http://127.0.0.1:4010') + await saveProductionStartupSelection(join(root, '.braid', 'config.json'), { + profile: record, + connection: selectedConnection, + profileDigest: record.digest, + connectionDigest: 'sha256:0000000000000000000000000000000000000000000000000000000000000000', + }) + const saved = await readFile(join(root, '.braid', 'config.json'), 'utf8') + const restarted = await loadProductionStartup({ workspace: root }) + assert.deepEqual(restarted.profile, exactProfile) + assert.equal( + canonicalAgentProfileDigest(restarted.profile), + canonicalAgentProfileDigest(exactProfile), + ) + assert.match(saved, /preserve this metadata/u) + assert.match(saved, /authenticationMethod/u) + assert.match(saved, /tokenBudget/u) + assert.doesNotMatch(saved, /\[redacted\]/u) + assert.doesNotThrow(() => + persistableProductionProfile({ + ...profile(), + metadata: { + authenticationMethod: 'oauth', + tokenBudget: 'large', + documentation: 'Use Bearer tokens or Basic auth for the provider connection.', + }, + }), + ) + for (const value of ['Bearer CANARY-BEARER', 'Basic dXNlcjpwYXNz', 'api_key=inline-value']) { + assert.throws( + () => + persistableProductionProfile({ + ...profile(), + metadata: { note: value }, + }), + /typed secret-ref/iu, + value, + ) + } + assert.throws( + () => + persistableProductionProfile({ + ...profile(), + hooks: { build: [{ command: 'echo', env: { BRIDGE_TOKEN: 'inline-secret-value' } }] }, + } as unknown as Readonly), + /typed secret-ref/iu, + ) + assert.throws( + () => + persistableProductionProfile({ + ...profile(), + mcp: { + remote: { + url: 'https://example.test/mcp', + headers: { Authorization: 'inline-secret-value' }, + }, + }, + } as unknown as Readonly), + /typed secret-ref/iu, + ) + assert.throws( + () => + persistableProductionProfile({ + ...profile(), + metadata: { note: 'sk-proj-12345678901234567890' }, + }), + /typed secret-ref/iu, + ) +}) + +test('protected remote Bridge auth survives setup, restart, and a real turn without persistence', async () => { + const root = await mkdtemp(join(tmpdir(), 'braid-production-protected-')) + await mkdir(join(root, '.braid'), { recursive: true }) + await chmod(join(root, '.braid'), 0o700) + const endpoint = 'https://bridge.example.test' + const model = 'opencode/zai-coding-plan/glm-5.2' + const auth = 'protected-bridge-secret' + const requests: Array<{ readonly body: string; readonly authorization: string | null }> = [] + const fetcher: typeof fetch = async (input, init) => { + const url = String(input) + const path = new URL(url).pathname + const authorization = requestHeader(init, 'authorization') + if (path === '/health') { + assert.equal(authorization, `Bearer ${auth}`) + return new Response( + JSON.stringify({ status: 'ok', backends: [{ name: 'opencode', state: 'ready' }] }), + { status: 200 }, + ) + } + if (path === '/v1/models') { + assert.equal(authorization, `Bearer ${auth}`) + return new Response(JSON.stringify({ data: [{ id: model, backend: 'opencode' }] }), { + status: 200, + }) + } + if (path !== '/v1/chat/completions') throw new Error(`unexpected protected URL: ${url}`) + const body = typeof init?.body === 'string' ? init.body : '' + requests.push({ body, authorization }) + assert.equal(authorization, `Bearer ${auth}`) + const parsed = JSON.parse(body) as { + readonly messages?: readonly { readonly content?: string }[] + } + if (parsed.messages?.[0]?.content?.includes('exactly OK') === true) { + return new Response(JSON.stringify({ choices: [{ message: { content: 'OK' } }] }), { + status: 200, + }) + } + return responseStream('protected response') + } + const credentials = new MemoryCredentialStore() + const startupOptions = { + workspace: root, + cliBridgeEndpoint: endpoint, + model, + runner: 'opencode' as const, + bridgeAuth: auth, + fetch: fetcher, + credentialStore: credentials, + modelValidationTimeoutMs: 5_000, + } + const setup = await loadProductionSetup(startupOptions) + const selectedProfile = setup.profiles.find( + (candidate) => candidate.profile.model?.default === model, + ) + const selectedConnection = setup.connections[0] + assert.ok(selectedProfile) + assert.ok(selectedConnection) + if (!selectedProfile || !selectedConnection) return + const selection = { + profile: selectedProfile, + connection: selectedConnection, + profileDigest: selectedProfile.digest, + connectionDigest: + 'sha256:0000000000000000000000000000000000000000000000000000000000000000' as const, + } + const previous = createBraidApplication({ fixture: 'deterministic' }) + const active = { + current: { + app: previous, + close: () => previous.close(), + }, + } + let firstHandle: Awaited> | undefined + let secondHandle: Awaited> | undefined + try { + await transitionProductionSelection({ + setup, + startupOptions, + selection, + workspace: root, + controller: { + replaceApplication: async () => undefined, + }, + active, + openApplication: async (nextSelection, nextOptions) => { + firstHandle = await openProductionApplication({ + workspace: root, + statePath: join(root, '.braid', 'before-restart.db'), + startupOptions: nextOptions, + production: productionConfigForSelection(nextSelection, nextOptions), + }) + return firstHandle + }, + }) + const saved = await readFile(join(root, '.braid', 'config.json'), 'utf8') + const savedDocument = JSON.parse(saved) as { + readonly connections?: readonly { readonly credentialRef?: string }[] + } + const savedCredentialRef = savedDocument.connections?.[0]?.credentialRef + assert.match(savedCredentialRef ?? '', /^credential-cli-bridge-/u) + assert.doesNotMatch(saved, new RegExp(auth, 'u')) + assert.doesNotMatch(JSON.stringify(active.current.app.state()), new RegExp(auth, 'u')) + + const firstReceipt = active.current.app.send({ + operationId: 'op-protected-first-turn', + text: 'protected first turn', + }) + await firstReceipt.admissionReady + const firstState = await firstReceipt.completion + assert.equal( + firstState.runs.at(-1)?.status, + 'completed', + JSON.stringify(firstState.runs.at(-1)), + ) + const firstRequest = requests.at(-1) + assert.ok(firstRequest) + if (!firstRequest) return + + await active.current.close() + const restartedOptions = { + workspace: root, + fetch: fetcher, + credentialStore: credentials, + } + const restarted = await loadProductionStartup(restartedOptions) + assert.equal(canonicalAgentProfileDigest(restarted.profile), selectedProfile.digest) + assert.equal(restarted.connectionId, selectedConnection.id) + assert.equal(restarted.connections[0]?.credentialRef, savedCredentialRef) + secondHandle = await openProductionApplication({ + workspace: root, + statePath: join(root, '.braid', 'after-restart.db'), + startupOptions: restartedOptions, + production: restarted, + }) + const secondReceipt = secondHandle.app.send({ + operationId: 'op-protected-first-turn', + text: 'protected first turn', + }) + await secondReceipt.admissionReady + const secondState = await secondReceipt.completion + assert.equal(secondState.runs.at(-1)?.status, 'completed') + const secondRequest = requests.at(-1) + assert.ok(secondRequest) + if (!secondRequest) return + assert.equal(firstRequest.authorization, `Bearer ${auth}`) + assert.equal(secondRequest.authorization, `Bearer ${auth}`) + const firstBody = JSON.parse(firstRequest.body) as Record + const secondBody = JSON.parse(secondRequest.body) as Record + assert.match(String(firstBody.run_id), /^run-/u) + assert.match(String(secondBody.run_id), /^run-/u) + assert.notEqual(secondBody.run_id, firstBody.run_id) + assert.notEqual(secondBody.session_id, firstBody.session_id) + const stableBody = ({ + run_id: _runId, + session_id: _sessionId, + ...body + }: Record) => body + assert.deepEqual(stableBody(secondBody), stableBody(firstBody)) + assert.doesNotMatch(firstRequest.body, new RegExp(auth, 'u')) + assert.doesNotMatch(secondRequest.body, new RegExp(auth, 'u')) + assert.doesNotMatch(JSON.stringify(secondState), new RegExp(auth, 'u')) + } finally { + await secondHandle?.close().catch(() => undefined) + await firstHandle?.close().catch(() => undefined) + if (active.current.app !== previous) await active.current.close().catch(() => undefined) + await previous.close().catch(() => undefined) + } +}) + +test('headless key-backed Bridge auth works without OS keyring and keeps credential files outside workspace', async () => { + const root = await mkdtemp(join(tmpdir(), 'braid-production-headless-auth-')) + const keyDirectory = await mkdtemp(join(tmpdir(), 'braid-production-headless-key-')) + const dataDirectory = await mkdtemp(join(tmpdir(), 'braid-production-headless-data-')) + const keyPath = join(keyDirectory, 'database.key') + const configPath = join(root, '.braid', 'config.json') + const statePath = join(root, '.braid', 'state.db') + await writeFile(keyPath, Buffer.alloc(32, 19), { mode: 0o600 }) + await chmod(keyPath, 0o600) + const endpoint = 'https://bridge.example.test' + const model = 'opencode/zai-coding-plan/glm-5.2' + const auth = 'headless-bridge-secret' + const requests: string[] = [] + const fetcher: typeof fetch = async (input, init) => { + const url = String(input) + const path = new URL(url).pathname + const authorization = requestHeader(init, 'authorization') + if (path === '/health' || path === '/v1/models') { + assert.equal(authorization, `Bearer ${auth}`) + return path === '/health' + ? new Response( + JSON.stringify({ status: 'ok', backends: [{ name: 'opencode', state: 'ready' }] }), + { status: 200 }, + ) + : new Response(JSON.stringify({ data: [{ id: model, backend: 'opencode' }] }), { + status: 200, + }) + } + if (path !== '/v1/chat/completions') throw new Error(`unexpected headless URL: ${url}`) + const body = typeof init?.body === 'string' ? init.body : '' + requests.push(body) + assert.equal(authorization, `Bearer ${auth}`) + const parsed = JSON.parse(body) as { + readonly messages?: readonly { readonly content?: string }[] + } + if (parsed.messages?.[0]?.content?.includes('exactly OK') === true) { + return new Response(JSON.stringify({ choices: [{ message: { content: 'OK' } }] }), { + status: 200, + }) + } + return responseStream('headless response') + } + const context = createProductionCredentialContext({ + workspace: root, + configPath, + databaseKeyFile: keyPath, + dataDirectory, + }) + assert.ok(context) + if (!context) return + assert.ok(context.store instanceof HeadlessCredentialStore) + const releaseContext = context.acquire() + const startupOptions = { + workspace: root, + configPath, + cliBridgeEndpoint: endpoint, + model, + runner: 'opencode' as const, + bridgeAuth: auth, + fetch: fetcher, + databaseKeyFile: context.databaseKeyFile, + credentialStore: context.store, + credentialContext: context, + modelValidationTimeoutMs: 5_000, + } + let firstHandle: Awaited> | undefined + let secondHandle: Awaited> | undefined + let restartedContext: ReturnType | undefined + const previous = createBraidApplication({ fixture: 'deterministic' }) + const active = { current: { app: previous, close: () => previous.close() } } + try { + const setup = await loadProductionSetup(startupOptions) + const selectedProfile = setup.profiles.find( + (candidate) => candidate.profile.model?.default === model, + ) + const selectedConnection = setup.connections[0] + assert.ok(selectedProfile) + assert.ok(selectedConnection) + if (!selectedProfile || !selectedConnection) return + const selection = { + profile: selectedProfile, + connection: selectedConnection, + profileDigest: selectedProfile.digest, + connectionDigest: + 'sha256:0000000000000000000000000000000000000000000000000000000000000000' as const, + } + await transitionProductionSelection({ + setup, + startupOptions, + selection, + workspace: root, + controller: { replaceApplication: async () => undefined }, + active, + openApplication: async (nextSelection, nextOptions) => { + firstHandle = await openProductionApplication({ + workspace: root, + statePath, + startupOptions: nextOptions, + production: productionConfigForSelection(nextSelection, nextOptions), + }) + return firstHandle + }, + }) + const saved = await readFile(configPath, 'utf8') + const savedDocument = JSON.parse(saved) as { + readonly connections?: readonly { readonly credentialRef?: string }[] + } + const savedCredentialId = savedDocument.connections?.[0]?.credentialRef + assert.match(savedCredentialId ?? '', /^credential-cli-bridge-/u) + assert.doesNotMatch(saved, new RegExp(auth, 'u')) + assert.doesNotMatch(saved, /headless-bridge-secret/u) + + const firstReceipt = active.current.app.send({ + operationId: 'op-headless-first-turn', + text: 'headless first turn', + }) + await firstReceipt.admissionReady + const firstState = await firstReceipt.completion + assert.equal( + firstState.runs.at(-1)?.status, + 'completed', + JSON.stringify(firstState.runs.at(-1)), + ) + await active.current.close() + + const nextContext = createProductionCredentialContext({ + workspace: root, + configPath, + databaseKeyFile: keyPath, + dataDirectory, + }) + assert.ok(nextContext) + if (!nextContext) return + restartedContext = nextContext + const restartedOptions = { + workspace: root, + configPath, + fetch: fetcher, + databaseKeyFile: nextContext.databaseKeyFile, + credentialStore: nextContext.store, + credentialContext: nextContext, + } + const restarted = await loadProductionStartup(restartedOptions) + assert.equal(restarted.connections[0]?.credentialRef, savedCredentialId) + secondHandle = await openProductionApplication({ + workspace: root, + statePath, + startupOptions: restartedOptions, + production: restarted, + }) + const secondReceipt = secondHandle.app.send({ + operationId: 'op-headless-restart-turn', + text: 'headless restart turn', + }) + await secondReceipt.admissionReady + assert.equal((await secondReceipt.completion).runs.at(-1)?.status, 'completed') + assert.equal(requests.length, 3) + assert.match(requests[0] ?? '', /exactly OK/u) + + const credentialRoot = join(dataDirectory, 'credentials') + const [digestDirectory] = await readdir(credentialRoot) + assert.match(digestDirectory ?? '', /^[0-9a-f]{64}$/u) + if (!digestDirectory) return + const swappedWorkspace = await mkdtemp(join(tmpdir(), 'braid-production-headless-swap-')) + const swappedContext = createProductionCredentialContext({ + workspace: swappedWorkspace, + configPath: join(swappedWorkspace, '.braid', 'config.json'), + databaseKeyFile: keyPath, + dataDirectory, + }) + assert.ok(swappedContext) + if (!swappedContext) return + try { + await swappedContext.store.available() + const contextDirectories = await readdir(credentialRoot) + const sourceRoot = join(credentialRoot, digestDirectory) + const targetDirectory = contextDirectories.find((directory) => directory !== digestDirectory) + assert.ok(targetDirectory) + if (!targetDirectory) return + const targetRoot = join(credentialRoot, targetDirectory) + for (const file of await readdir(sourceRoot)) { + await copyFile(join(sourceRoot, file), join(targetRoot, file)) + } + await assert.rejects( + () => swappedContext.store.resolve(credentialRef(`cred:v1:${savedCredentialId}`)), + /encrypted headless credential is invalid/iu, + ) + } finally { + swappedContext.dispose() + } + + const orphan = await prepareProductionSelection( + { ...restartedOptions, bridgeAuth: 'orphan-headless-secret' }, + selection, + configPath, + ) + const orphanId = orphan.selection.connection.credentialRef + assert.ok(orphanId) + if (!orphanId) return + await recoverPendingProductionCredential(configPath, { + credentialContext: nextContext, + }) + await assert.rejects( + () => nextContext.store.resolve(credentialRef(`cred:v1:${orphanId}`)), + /not found/iu, + ) + await orphan.commit() + + await assert.rejects(() => access(join(root, '.braid', 'credentials'))) + assert.equal((await stat(join(credentialRoot, digestDirectory))).mode & 0o777, 0o700) + const credentialFiles = await readdir(join(credentialRoot, digestDirectory)) + assert.ok(credentialFiles.length > 0) + for (const file of credentialFiles) { + assert.equal((await stat(join(credentialRoot, digestDirectory, file))).mode & 0o777, 0o600) + } + } finally { + await secondHandle?.close().catch(() => undefined) + await firstHandle?.close().catch(() => undefined) + if (active.current.app !== previous) await active.current.close().catch(() => undefined) + await previous.close().catch(() => undefined) + restartedContext?.dispose() + releaseContext() + context.dispose() + } +}) + +test('protected setup removes a newly stored credential on failed transition and fails before validation without secure storage', async () => { + const root = await mkdtemp(join(tmpdir(), 'braid-production-credential-rollback-')) + const endpoint = 'https://bridge.example.test' + const auth = 'rollback-only-secret' + const fetcher: typeof fetch = async (input) => { + const path = new URL(String(input)).pathname + if (path === '/health') { + return new Response( + JSON.stringify({ status: 'ok', backends: [{ name: 'pi', state: 'ready' }] }), + { status: 200 }, + ) + } + return new Response(JSON.stringify({ data: [{ id: 'pi/openai-codex/gpt-5.6-luna' }] }), { + status: 200, + }) + } + const setup = await loadProductionSetup({ + workspace: root, + cliBridgeEndpoint: endpoint, + bridgeAuth: auth, + fetch: fetcher, + }) + const selectedProfile = setup.profiles[0] + const selectedConnection = setup.connections[0] + assert.ok(selectedProfile) + assert.ok(selectedConnection) + if (!selectedProfile || !selectedConnection) return + const selection = { + profile: selectedProfile, + connection: selectedConnection, + profileDigest: selectedProfile.digest, + connectionDigest: + 'sha256:0000000000000000000000000000000000000000000000000000000000000000' as const, + } + const credentials = new MemoryCredentialStore() + const stored: string[] = [] + const removed: string[] = [] + const recordingCredentials = { + available: () => credentials.available(), + store: async (input: Parameters[0]) => { + const ref = await credentials.store(input) + stored.push(ref) + return ref + }, + resolve: (ref: Parameters[0]) => credentials.resolve(ref), + remove: async (ref: Parameters[0]) => { + removed.push(ref) + await credentials.remove(ref) + }, + } + const previous = createBraidApplication({ fixture: 'deterministic' }) + const active = { current: { app: previous, close: () => previous.close() } } + try { + await assert.rejects( + transitionProductionSelection({ + setup, + startupOptions: { + ...setupOptionsForCredentialTest(root, endpoint, auth, fetcher), + credentialStore: recordingCredentials, + }, + selection, + workspace: root, + controller: { replaceApplication: async () => undefined }, + active, + validate: async () => { + throw new Error('validation failed after secure credential write') + }, + openApplication: async () => { + throw new Error('open must not run after validation failure') + }, + }), + /validation failed after secure credential write/u, + ) + assert.equal(stored.length, 1) + assert.deepEqual(removed, stored) + assert.equal(credentials.has(stored[0] as never), false) + + const unavailable = new MemoryCredentialStore() + unavailable.setAvailable(false) + let validationCalls = 0 + await assert.rejects( + transitionProductionSelection({ + setup, + startupOptions: { + ...setupOptionsForCredentialTest(root, endpoint, auth, fetcher), + credentialStore: unavailable, + }, + selection, + workspace: root, + controller: { replaceApplication: async () => undefined }, + active, + validate: async () => { + validationCalls += 1 + return { status: 'verified', detail: 'must not run' } + }, + openApplication: async () => { + throw new Error('open must not run without secure storage') + }, + }), + /secure.*credential store.*unavailable/iu, + ) + assert.equal(validationCalls, 0) + } finally { + await previous.close() + } +}) + +test('pending credential markers recover both an interrupted and a committed setup', async () => { + const root = await mkdtemp(join(tmpdir(), 'braid-production-credential-recovery-')) + const { setup, selection } = await transitionSetup(root) + const configPath = setup.configPath + const credentials = new MemoryCredentialStore() + const interrupted = await prepareProductionSelection( + { workspace: root, bridgeAuth: 'interrupted-secret', credentialStore: credentials }, + selection, + configPath, + ) + const interruptedRef = credentialRef( + `cred:v1:${interrupted.selection.connection.credentialRef ?? 'missing'}`, + ) + assert.equal(credentials.has(interruptedRef), true) + await recoverPendingProductionCredential(configPath, { credentialStore: credentials }) + assert.equal(credentials.has(interruptedRef), false) + await assert.rejects(() => access(`${configPath}.pending-cli-bridge`), /ENOENT/u) + + const committed = await prepareProductionSelection( + { workspace: root, bridgeAuth: 'committed-secret', credentialStore: credentials }, + selection, + configPath, + ) + const committedRef = credentialRef( + `cred:v1:${committed.selection.connection.credentialRef ?? 'missing'}`, + ) + await saveProductionStartupSelection(configPath, committed.selection) + await recoverPendingProductionCredential(configPath, { credentialStore: credentials }) + assert.equal(credentials.has(committedRef), true) + assert.equal( + await access(`${configPath}.pending-cli-bridge`).then( + () => true, + () => false, + ), + false, + ) +}) + +function setupOptionsForCredentialTest( + workspace: string, + endpoint: string, + auth: string, + fetcher: typeof fetch, +) { + return { + workspace, + cliBridgeEndpoint: endpoint, + bridgeAuth: auth, + fetch: fetcher, + } +} + +test('CLI Bridge discovery classifies service and backend readiness from the health contract', async () => { + const cases = [ + { + name: 'malformed 200', + status: 200, + body: '{}', + expected: 'incompatible', + diagnostic: /malformed|readiness is unknown/iu, + }, + { + name: 'unauthorized 401', + status: 401, + body: JSON.stringify({ error: { code: 'unauthorized' } }), + expected: 'unauthorized', + diagnostic: /HTTP 401/u, + }, + { + name: 'rate limited 429', + status: 429, + body: JSON.stringify({ error: { code: 'rate_limited' } }), + expected: 'rate-limited', + diagnostic: /HTTP 429/u, + }, + { + name: 'degraded with a ready backend', + status: 200, + body: JSON.stringify({ + status: 'degraded', + backends: [ + { name: 'pi', state: 'ready' }, + { name: 'codex', state: 'unavailable' }, + ], + }), + expected: 'healthy', + diagnostic: /degraded service with ready backends: pi/iu, + }, + { + name: 'degraded with no ready backend', + status: 503, + body: JSON.stringify({ + status: 'degraded', + backends: [{ name: 'pi', state: 'unavailable' }], + }), + expected: 'unreachable', + diagnostic: /HTTP 503.*degraded service with no ready backend/iu, + }, + { + name: 'ok with all backends ready', + status: 200, + body: JSON.stringify({ + status: 'ok', + backends: [ + { name: 'pi', state: 'ready' }, + { name: 'codex', state: 'ready' }, + ], + }), + expected: 'healthy', + diagnostic: undefined, + }, + ] as const + + for (const healthCase of cases) { + const root = await mkdtemp( + join(tmpdir(), `braid-health-${healthCase.name.replaceAll(' ', '-')}-`), + ) + const result = await discoverBridge( + { + workspace: root, + fetch: async (input) => { + const path = new URL(String(input)).pathname + if (path === '/health') + return new Response(healthCase.body, { status: healthCase.status }) + return new Response( + JSON.stringify({ data: [{ id: 'pi/openai-codex/gpt-5.6-luna', backend: 'pi' }] }), + { status: 200 }, + ) + }, + }, + DEFAULT_CLI_BRIDGE_ENDPOINT, + ) + assert.equal(result.health.status, healthCase.expected, healthCase.name) + if (healthCase.diagnostic === undefined) { + assert.deepEqual(result.diagnostics, [], healthCase.name) + } else { + assert.match(result.diagnostics.join('\n'), healthCase.diagnostic, healthCase.name) + } + } +}) + +test('first-run catalog preserves trusted profiles and adds each advertised model once', async () => { + const root = await mkdtemp(join(tmpdir(), 'braid-production-catalog-')) + await mkdir(join(root, '.braid'), { recursive: true }) + const trustedUnavailable = defineAgentProfile({ + name: 'trusted unavailable target', + harness: 'pi', + model: { default: 'openai/gpt-9-not-advertised' }, + }) + const trustedGlm = defineAgentProfile({ + name: 'trusted GLM profile', + harness: 'opencode', + model: { default: 'opencode/zai-coding-plan/glm-5.2' }, + }) + await writeFile( + join(root, '.braid', 'profile.json'), + `${canonicalCandidateJson(trustedUnavailable)}\n`, + { mode: 0o600 }, + ) + await writeFile(join(root, 'braid.profile.json'), `${canonicalCandidateJson(trustedGlm)}\n`, { + mode: 0o600, + }) + const fetch: typeof globalThis.fetch = async (input) => { + const path = new URL(String(input)).pathname + if (path === '/health') { + return new Response( + JSON.stringify({ status: 'ok', backends: [{ name: 'opencode', state: 'ready' }] }), + { status: 200 }, + ) + } + return new Response( + JSON.stringify({ + data: [ + { id: 'pi/openai-codex/gpt-5.6-luna', backend: 'pi' }, + { id: 'opencode/zai-coding-plan/glm-5.2', backend: 'opencode' }, + ], + }), + { status: 200 }, + ) + } + const setup = await loadProductionSetup({ workspace: root, fetch }) + assert.equal( + setup.profiles.some((record) => record.profile.name === trustedUnavailable.name), + true, + ) + assert.equal( + setup.profiles.some((record) => record.profile.name === trustedGlm.name), + true, + ) + assert.equal( + setup.profiles.filter( + (record) => record.profile.model?.default === 'opencode/zai-coding-plan/glm-5.2', + ).length, + 1, + ) + assert.equal( + setup.profiles.filter( + (record) => record.profile.model?.default === 'pi/openai-codex/gpt-5.6-luna', + ).length, + 1, + ) + + const constrained = await loadProductionSetup({ + workspace: root, + fetch, + runner: 'opencode', + model: 'opencode/zai-coding-plan/glm-5.2', + }) + assert.equal(constrained.profiles.length, setup.profiles.length) + const initial = constrained.profiles.find((record) => record.id === constrained.initialProfileId) + assert.equal(initial?.profile.harness, 'opencode') + assert.equal(initial?.profile.model?.default, 'opencode/zai-coding-plan/glm-5.2') +}) + +test('first-run catalog selects an exact Codex Bridge route without rewriting it', async () => { + const root = await mkdtemp(join(tmpdir(), 'braid-production-codex-route-')) + const fetch: typeof globalThis.fetch = async (input) => { + const path = new URL(String(input)).pathname + if (path === '/health') { + return new Response( + JSON.stringify({ status: 'ok', backends: [{ name: 'codex', state: 'ready' }] }), + { status: 200 }, + ) + } + return new Response( + JSON.stringify({ + data: [ + { id: 'codex/default', backend: 'codex' }, + { id: 'pi/openai-codex/gpt-5.6-luna', backend: 'codex' }, + ], + }), + { status: 200 }, + ) + } + const setup = await loadProductionSetup({ + workspace: root, + fetch, + runner: 'codex', + model: 'codex/default', + }) + const initial = setup.profiles.find((record) => record.id === setup.initialProfileId) + assert.equal(initial?.profile.harness, 'codex') + assert.equal(initial?.profile.model?.default, 'codex/default') + assert.equal( + setup.profiles.some( + (record) => record.profile.model?.default === 'pi/openai-codex/gpt-5.6-luna', + ), + false, + ) + assert.doesNotMatch(setup.diagnostics.join('\n'), /requested model|requested runner/iu) +}) + +test('first-run setup returns an honest empty catalog without a fake model', async () => { + const root = await mkdtemp(join(tmpdir(), 'braid-production-empty-catalog-')) + const setup = await loadProductionSetup({ + workspace: root, + fetch: async (input) => { + const path = new URL(String(input)).pathname + if (path === '/health') { + return new Response( + JSON.stringify({ status: 'degraded', backends: [{ name: 'pi', state: 'unavailable' }] }), + { status: 503 }, + ) + } + return new Response(JSON.stringify({ data: [] }), { status: 200 }) + }, + }) + assert.deepEqual(setup.profiles, []) + assert.match( + setup.diagnostics.join('\n'), + /No trusted AgentProfile.*compatible advertised model/iu, + ) + assert.doesNotMatch(setup.diagnostics.join('\n'), /codex\/default/iu) +}) + +test('first-run model validation stays in setup with an actionable 501 remedy', async () => { + const root = await mkdtemp(join(tmpdir(), 'braid-production-validation-')) + const setup = await loadProductionSetup({ + workspace: root, + fetch: async (input) => { + const url = String(input) + if (url.endsWith('/health')) + return new Response( + JSON.stringify({ status: 'ok', backends: [{ name: 'pi', state: 'ready' }] }), + { status: 200 }, + ) + if (url.endsWith('/models')) { + return new Response( + JSON.stringify({ data: [{ id: 'pi/openai-codex/gpt-5.6-luna', backend: 'pi' }] }), + { status: 200 }, + ) + } + throw new Error(`unexpected discovery request: ${url}`) + }, + }) + const profile = setup.profiles[0] + const connection = setup.connections[0] + assert.ok(profile) + assert.ok(connection) + if (!profile || !connection) return + const selection = { + profile, + connection, + profileDigest: profile.digest, + connectionDigest: 'sha256:0000000000000000000000000000000000000000000000000000000000000000', + } + const temporary = createBraidApplication({ fixture: 'deterministic' }) + let transitioned = false + try { + await assert.rejects( + () => + transitionProductionSelection({ + setup, + startupOptions: { + workspace: root, + fetch: async (input, init) => { + const url = String(input) + assert.equal(init?.method, 'POST') + assert.match(url, /\/chat\/completions$/u) + return new Response( + JSON.stringify({ error: { type: 'not_configured', message: 'pi auth missing' } }), + { status: 501 }, + ) + }, + }, + selection, + workspace: root, + controller: { + replaceApplication: async () => { + transitioned = true + }, + }, + active: { + current: { + app: temporary, + close: async () => temporary.close(), + }, + }, + openApplication: async () => { + throw new Error('the durable application must not open after failed validation') + }, + }), + /501 not_configured.*Configure.*credentials/isu, + ) + assert.equal(transitioned, false) + await assert.rejects( + () => access(join(root, '.braid', 'config.json')), + (error: unknown) => + error !== null && typeof error === 'object' && 'code' in error && error.code === 'ENOENT', + ) + } finally { + await temporary.close() + } +}) + +test('first-run model validation rejects a non-marker completion body', async () => { + const root = await mkdtemp(join(tmpdir(), 'braid-production-validation-marker-')) + const setup = await loadProductionSetup({ + workspace: root, + fetch: async (input) => { + const url = String(input) + if (url.endsWith('/health')) { + return new Response( + JSON.stringify({ status: 'ok', backends: [{ name: 'pi', state: 'ready' }] }), + { status: 200 }, + ) + } + return new Response( + JSON.stringify({ data: [{ id: 'pi/openai-codex/gpt-5.6-luna', backend: 'pi' }] }), + { status: 200 }, + ) + }, + }) + const selectedProfile = setup.profiles[0] + const selectedConnection = setup.connections[0] + assert.ok(selectedProfile) + assert.ok(selectedConnection) + if (!selectedProfile || !selectedConnection) return + await assert.rejects( + () => + validateProductionSelection( + { + workspace: root, + fetch: async (_input, init) => { + assert.equal(init?.method, 'POST') + return new Response(JSON.stringify({ choices: [{ message: { content: 'NOT OK' } }] }), { + status: 200, + }) + }, + }, + { + profile: selectedProfile, + connection: selectedConnection, + profileDigest: selectedProfile.digest, + connectionDigest: + 'sha256:0000000000000000000000000000000000000000000000000000000000000000', + }, + ), + /exact OK completion/iu, + ) +}) + +test('production selection transition keeps its lifecycle order across injected failures', async () => { + const phases = ['validate', 'open', 'persist', 'swap', 'old-close'] as const + for (const phase of phases) { + const root = await mkdtemp(join(tmpdir(), `braid-transition-${phase}-`)) + const { setup, selection } = await transitionSetup(root) + const events: string[] = [] + const previousApp = createBraidApplication({ fixture: 'deterministic' }) + let previousClosed = false + const previous = { + app: previousApp, + close: async () => { + if (previousClosed) return + previousClosed = true + events.push('old-close') + if (phase === 'old-close') throw new Error('injected old close failure') + await previousApp.close() + }, + } + const active = { current: previous } + let next: + | { + readonly app: ReturnType + readonly close: () => Promise + } + | undefined + let controllerApp = previousApp + const controller = { + replaceApplication: async (application: ReturnType) => { + events.push('swap') + if (phase === 'swap') throw new Error('injected controller swap failure') + controllerApp = application + }, + } + try { + const transition = transitionProductionSelection({ + setup, + startupOptions: { workspace: root }, + selection, + workspace: root, + controller, + active, + validate: async () => { + events.push('validate') + if (phase === 'validate') throw new Error('injected validation failure') + return { status: 'verified', detail: 'injected validation passed' } + }, + openApplication: async () => { + events.push('open') + if (phase === 'open') throw new Error('injected open failure') + const app = createBraidApplication({ fixture: 'deterministic' }) + let closed = false + const handle = { + app, + close: async () => { + if (closed) return + closed = true + events.push('next-close') + await app.close() + }, + } + next = handle + return handle + }, + persist: async () => { + events.push('persist') + if (phase === 'persist') throw new Error('injected persist failure') + return { + rollback: async () => { + events.push('rollback') + }, + } + }, + }) + if (phase === 'old-close') { + const verification = await transition + assert.equal(verification.status, 'verified') + } else { + const expectedFailure = + phase === 'validate' ? 'validation' : phase === 'swap' ? 'controller swap' : phase + await assert.rejects( + transition, + (error: unknown) => + error instanceof Error && error.message === `injected ${expectedFailure} failure`, + ) + } + const expected = + phase === 'validate' + ? ['validate'] + : phase === 'open' + ? ['validate', 'open'] + : phase === 'persist' + ? ['validate', 'open', 'persist', 'next-close'] + : phase === 'swap' + ? ['validate', 'open', 'persist', 'swap', 'rollback', 'next-close'] + : ['validate', 'open', 'persist', 'swap', 'old-close'] + assert.deepEqual(events, expected, phase) + if (phase === 'old-close') { + assert.equal(active.current.app, next?.app, phase) + assert.equal(controllerApp, next?.app, phase) + } else { + assert.equal(active.current.app, previousApp, phase) + assert.equal(controllerApp, previousApp, phase) + } + } finally { + if (active.current.app === previousApp) await previous.close().catch(() => undefined) + if (next !== undefined && active.current.app !== next.app) + await next.close().catch(() => undefined) + if (active.current.app === next?.app) await next.close().catch(() => undefined) + } + } +}) + +test('failed controller activation rolls back persisted config and keeps controller and active app aligned', async () => { + const root = await mkdtemp(join(tmpdir(), 'braid-transition-crash-safe-')) + const { setup, selection } = await transitionSetup(root) + const oldProfile = defineAgentProfile({ + name: 'old active profile', + harness: 'pi', + model: { default: 'openai/old' }, + }) + const oldApp = createBraidApplication({ fixture: 'deterministic', profile: oldProfile }) + oldApp.initialize(root) + const controller = new ApplicationUiController(oldApp) + const active = { + current: { + app: oldApp, + close: () => oldApp.close(), + }, + } + const failingJournal = new MemoryJournal(new FixedClock()) + Object.defineProperty(failingJournal, 'flush', { + configurable: true, + value: async () => { + throw new Error('injected controller durability failure') + }, + }) + const nextApp = createBraidApplication({ + fixture: 'deterministic', + profile: defineAgentProfile({ + name: 'new candidate profile', + harness: 'pi', + model: { default: 'openai/new' }, + }), + journal: failingJournal, + effectStorage: failingJournal, + }) + let nextClosed = false + try { + await assert.rejects( + () => + transitionProductionSelection({ + setup, + startupOptions: { workspace: root }, + selection, + workspace: root, + controller, + active, + validate: async () => ({ status: 'verified', detail: 'injected validation passed' }), + openApplication: async () => ({ + app: nextApp, + close: async () => { + nextClosed = true + await nextApp.close().catch(() => undefined) + }, + }), + }), + /injected controller durability failure/iu, + ) + assert.equal(active.current.app, oldApp) + assert.equal(controller.view().profileName, 'old active profile') + assert.equal(nextClosed, true) + await assert.rejects( + () => access(setup.configPath), + (error: unknown) => + error !== null && typeof error === 'object' && 'code' in error && error.code === 'ENOENT', + ) + } finally { + await oldApp.close().catch(() => undefined) + } +}) + +test('bin startup reports missing production config before opening storage', async () => { + const root = await mkdtemp(join(tmpdir(), 'braid-production-missing-')) + await assert.rejects( + () => loadProductionStartup({ workspace: root }), + (error: unknown) => + error instanceof ProductionStartupError && + error.code === 'PRODUCTION_CONFIGURATION_NOT_FOUND' && + /--config/iu.test(error.message), + ) +}) + +test('bin startup preserves the actionable encrypted-storage prerequisite', () => { + const error = Object.assign( + new Error('The operating-system credential facility is unavailable'), + { code: 'CREDENTIAL_STORE_UNAVAILABLE' }, + ) + assert.equal( + formatProductionStartupError(error), + 'CREDENTIAL_STORE_UNAVAILABLE: The operating-system credential facility is unavailable', + ) + assert.equal( + formatProductionStartupError(new Error('unexpected provider detail')), + 'PROVIDER_ERROR', + ) +}) diff --git a/test/profile-compatibility-ui.test.ts b/test/profile-compatibility-ui.test.ts new file mode 100644 index 0000000..5364f9a --- /dev/null +++ b/test/profile-compatibility-ui.test.ts @@ -0,0 +1,184 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { defineAgentProfile } from '@tangle-network/agent-interface' +import { TUI, visibleWidth } from '@earendil-works/pi-tui' +import { createApplicationUiController } from '../src/adapters/tui/application-ui-controller.js' +import { createBraidApplication } from '../src/app/composition.js' +import { createProfileRecord } from '../src/app/profiles.js' +import { + ProfileCompatibilityPanel, + type ProfileCompatibilityResult, + profileCompatibilityTextLines, +} from '../src/views/tui/profile-compatibility.js' +import { BraidTerminalApp } from '../src/views/tui/terminal-app.js' +import { createBraidTheme } from '../src/views/tui/theme.js' +import { VirtualTerminal } from './support/virtual-terminal.js' + +async function waitUntil(predicate: () => boolean, timeoutMs = 1_000): Promise { + const deadline = Date.now() + timeoutMs + while (!predicate()) { + if (Date.now() >= deadline) throw new Error('Timed out waiting for profile compatibility UI') + await new Promise((resolve) => setTimeout(resolve, 5)) + } +} + +function incompatibleResult( + overrides: Partial = {}, +): ProfileCompatibilityResult { + return { + authoredProfile: { name: 'release profile' }, + runner: 'codex', + model: 'zai/glm-5.2', + compatibility: { + modelSupported: false, + suggestedRunner: 'opencode', + suggestedModel: 'openai/gpt-5.6-luna', + }, + ...overrides, + } +} + +test('names the exact unsupported pair, preserves the profile, and offers both choices', () => { + const text = profileCompatibilityTextLines(incompatibleResult(), 80).join('\n') + + assert.match(text, /harness=codex/) + assert.match(text, /model=zai\/glm-5\.2/) + assert.match(text, /authored profile "release profile" remains unchanged/) + assert.match(text, /change runner to opencode to keep model zai\/glm-5\.2/) + assert.match(text, /change model to openai\/gpt-5\.6-luna to keep runner codex/) +}) + +test('sanitizes every untrusted value before terminal rendering', () => { + const result = incompatibleResult({ + authoredProfile: { name: 'profile\u001b]0;owned\u0007\u202ename' }, + runner: 'codex\u001b[31m', + model: 'zai/glm-5.2\rspoof', + compatibility: { + modelSupported: false, + suggestedRunner: 'opencode\u001b[0m', + suggestedModel: 'openai/gpt-5.6-luna\u202e', + }, + }) + const text = profileCompatibilityTextLines(result, 80).join('\n') + + assert.equal(text.includes('\u001b'), false) + assert.equal(text.includes('\u202e'), false) + assert.equal(text.includes('\r'), false) + assert.match(text, /profilename/) +}) + +test('wraps all output without losing exact values at 40 and 80 columns', () => { + const result = incompatibleResult() + for (const width of [40, 80]) { + const lines = profileCompatibilityTextLines(result, width) + assert.ok(lines.length > 0) + assert.ok(lines.every((line) => visibleWidth(line) <= width)) + const text = lines.join('\n') + assert.match(text, /harness=codex/) + assert.match(text, /model=zai\/glm-5\.2/) + assert.match(text, /change runner to opencode/) + assert.match(text, /change model to openai\/gpt-5\.6-luna/) + } + + const panel = new ProfileCompatibilityPanel(result) + assert.deepEqual(panel.render(40), profileCompatibilityTextLines(result, 40)) +}) + +test('fails closed without complete fields or suggestions', () => { + const incomplete: readonly ProfileCompatibilityResult[] = [ + incompatibleResult({ authoredProfile: undefined }), + incompatibleResult({ runner: undefined }), + incompatibleResult({ model: undefined }), + incompatibleResult({ compatibility: { modelSupported: false } }), + incompatibleResult({ + compatibility: { modelSupported: false, suggestedRunner: 'opencode' }, + }), + ] + + for (const result of incomplete) { + const text = profileCompatibilityTextLines(result, 40).join('\n') + assert.match(text, /compatibility unavailable/) + assert.match(text, /authored profile remains unchanged/) + assert.doesNotMatch(text, /change runner to/) + assert.doesNotMatch(text, /change model to/) + } +}) + +test('does not offer recovery choices when the pair is already supported', () => { + const text = profileCompatibilityTextLines( + incompatibleResult({ + compatibility: { modelSupported: true }, + }), + 80, + ).join('\n') + + assert.match(text, /supported pair: harness=codex · model=zai\/glm-5\.2/) + assert.match(text, /authored profile "release profile" remains unchanged/) + assert.doesNotMatch(text, /change runner to/) + assert.doesNotMatch(text, /change model to/) +}) + +test('profile validation reaches the same mismatch presenter through real terminal keys', async () => { + const incompatible = defineAgentProfile({ + name: 'GLM through Codex', + harness: 'codex', + model: { default: 'zai/glm-5.2' }, + }) + const compatibleModel = defineAgentProfile({ + name: 'Luna through Codex', + harness: 'codex', + model: { default: 'openai/gpt-5.6-luna' }, + }) + const incompatibleRecord = createProfileRecord( + { + kind: 'inline', + reference: 'profile:glm-codex', + label: 'GLM through Codex', + writable: false, + trusted: true, + }, + incompatible, + ) + const compatibleRecord = createProfileRecord( + { + kind: 'inline', + reference: 'profile:luna-codex', + label: 'Luna through Codex', + writable: false, + trusted: true, + }, + compatibleModel, + ) + const app = createBraidApplication({ fixture: 'deterministic', profile: incompatible }) + app.initialize('/workspace') + const controller = createApplicationUiController(app, {}, undefined, { + profiles: [incompatibleRecord, compatibleRecord], + }) + const terminal = new VirtualTerminal(80, 24) + const tui = new TUI(terminal) + let operation = 0 + const view = new BraidTerminalApp({ + controller, + tui, + theme: createBraidTheme(false), + workspace: '/workspace', + nextOperationId: () => `op-profile-compatibility-${++operation}`, + }) + const done = view.start() + + terminal.sendInput('/profile') + terminal.sendInput('\r') + await waitUntil(() => terminal.getViewport().join('\n').includes('GLM through Codex')) + terminal.sendInput('\u0016') + await waitUntil(() => terminal.getViewport().join('\n').includes('unsupported pair')) + const screen = terminal.getViewport().join('\n') + assert.match(screen, /harness=codex · model=zai\/glm-5\.2/u) + assert.match(screen, /authored profile "GLM through Codex" remains unchanged/u) + assert.match(screen, /change runner to opencode/u) + assert.match(screen, /change model to openai\/gpt-5\.6-luna/u) + assert.match(screen, /runner\/model choice required/u) + + view.stop() + await done + await app.close() +}) diff --git a/test/profile-connection-actions.test.ts b/test/profile-connection-actions.test.ts new file mode 100644 index 0000000..c3a0ead --- /dev/null +++ b/test/profile-connection-actions.test.ts @@ -0,0 +1,365 @@ +import assert from 'node:assert/strict' +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import test from 'node:test' +import { defineAgentProfile } from '@tangle-network/agent-interface' +import type { ConnectionCapabilityReport } from '../src/adapters/connections/production-connection-types.js' +import { createApplicationUiController } from '../src/adapters/tui/application-ui-controller.js' +import { createBraidApplication } from '../src/app/composition.js' +import { operationTestResult } from '../src/app/connection-action-support.js' +import { MemoryJournal } from '../src/app/journal.js' +import { createProfileRecord } from '../src/app/profiles.js' +import type { ConnectionRecord } from '../src/domain/entities.js' +import { createConnectionId } from '../src/domain/ids.js' +import { FixedClock } from '../src/ports/clock.js' +import type { BraidResponse } from '../src/views/headless/protocol.js' +import { runRpc } from '../src/views/headless/rpc.js' + +const at = '2026-08-03T00:00:00.000Z' + +function connection(): ConnectionRecord { + return { + id: createConnectionId('connection-action-test'), + kind: 'cli-bridge', + name: 'action test bridge', + endpoint: 'http://127.0.0.1:3344', + providerOptions: { transport: 'local' }, + createdAt: at, + updatedAt: at, + lastHealth: { status: 'unknown' }, + } +} + +function capabilities(connectionId: ConnectionRecord['id']): ConnectionCapabilityReport { + return { + connectionId, + kind: 'cli-bridge', + runtime: { + backend: 'chat', + streaming: { live: true, replay: true, detach: false, turnIdempotency: true }, + sessions: { continue: true, list: false, messages: false }, + interactions: { originate: false, respond: false }, + }, + providerMethods: { create: true, get: false, list: false, respondToInteraction: false }, + actions: { + stream: true, + replay: true, + detach: false, + 'continue-session': true, + 'list-sessions': false, + 'session-messages': false, + checkpoint: false, + fork: false, + placement: true, + usage: true, + 'respond-interaction': false, + }, + } +} + +function line(value: object): string { + return `${JSON.stringify(value)}\n` +} + +function responseFor(responses: readonly BraidResponse[], requestId: string): BraidResponse { + const response = responses.find( + (candidate) => candidate.type === 'ack' && candidate.requestId === requestId, + ) + assert( + response, + `missing acknowledgement for ${requestId}; responses=${responses + .map((candidate) => { + if (candidate.type === 'event') return `event::${JSON.stringify(candidate)}` + return `${candidate.type}:${candidate.requestId ?? ''}:${JSON.stringify(candidate)}` + }) + .join(',')}`, + ) + return response +} + +function outcomeWithoutReplay(value: unknown): Record { + assert(value !== null && typeof value === 'object' && !Array.isArray(value)) + const { replayed: _replayed, ...outcome } = value as Record + return outcome +} + +test('JSONL profile and connection actions use durable replay and local list paths', async () => { + const profileA = defineAgentProfile({ + name: 'Action profile A', + harness: 'pi', + model: { default: 'fixture/action-a' }, + }) + const profileB = defineAgentProfile({ + name: 'Action profile B', + harness: 'pi', + model: { default: 'fixture/action-b' }, + }) + const secretProfile = defineAgentProfile({ + name: 'Action profile with inline secret', + harness: 'pi', + model: { default: 'fixture/action-secret' }, + metadata: { apiKey: 'ACTION-SECRET-CANARY' }, + }) + const sourceA = createProfileRecord( + { kind: 'inline', reference: 'action:a', label: 'Action A', writable: false, trusted: true }, + profileA, + ) + const sourceB = createProfileRecord( + { kind: 'inline', reference: 'action:b', label: 'Action B', writable: false, trusted: true }, + profileB, + ) + const sourceSecret = createProfileRecord( + { + kind: 'inline', + reference: 'action:secret', + label: 'Action secret profile', + writable: false, + trusted: true, + }, + secretProfile, + ) + const record = connection() + let capabilityCalls = 0 + let healthCalls = 0 + let modelCalls = 0 + let listDidNotProbe = false + const journal = new MemoryJournal(new FixedClock()) + const app = createBraidApplication({ + fixture: 'deterministic', + profile: profileA, + journal, + effectStorage: journal, + }) + const controller = createApplicationUiController(app, {}, undefined, { + profiles: [sourceA, sourceB, sourceSecret], + connections: [record], + probeFor: () => ({ + capabilities: async () => { + capabilityCalls += 1 + return capabilities(record.id) + }, + health: async () => { + healthCalls += 1 + return { status: 'healthy', checkedAt: at } + }, + verifyModel: async (model) => { + modelCalls += 1 + return { model, status: 'verified', checkedAt: at } + }, + }), + }) + const responses: BraidResponse[] = [] + let output = '' + const profilePathRoot = await mkdtemp(join(tmpdir(), 'braid-profile-actions-')) + const profilePath = join(profilePathRoot, 'saved.json') + try { + async function* input(): AsyncGenerator { + yield line({ + version: 1, + requestId: 'actions-init', + command: 'initialize', + params: { workspace: '/workspace' }, + }) + yield line({ + version: 1, + requestId: 'actions-list-profiles', + command: 'list_profiles', + params: {}, + }) + yield line({ + version: 1, + requestId: 'actions-validate-profile', + command: 'validate_profile', + params: { ref: sourceB.id }, + }) + yield line({ + version: 1, + requestId: 'actions-invalid-profile', + operationId: 'op-invalid-profile', + command: 'save_profile', + params: { ref: profilePath, profile: { unknownField: true } }, + }) + yield line({ + version: 1, + requestId: 'actions-select-profile', + operationId: 'op-select-profile', + command: 'select_profile', + params: { ref: sourceB.id, expectedRevision: 1 }, + }) + yield line({ + version: 1, + requestId: 'actions-select-profile-replay', + operationId: 'op-select-profile', + command: 'select_profile', + params: { ref: sourceB.id, expectedRevision: 1 }, + }) + yield line({ + version: 1, + requestId: 'actions-stale-profile', + operationId: 'op-stale-profile', + command: 'select_profile', + params: { ref: sourceA.id, expectedRevision: 1 }, + }) + yield line({ + version: 1, + requestId: 'actions-unknown-profile', + command: 'validate_profile', + params: { ref: 'profile:missing' }, + }) + yield line({ + version: 1, + requestId: 'actions-select-inline-secret', + operationId: 'op-select-inline-secret', + command: 'select_profile', + params: { ref: sourceSecret.id }, + }) + yield line({ + version: 1, + requestId: 'actions-list-connections', + command: 'list_connections', + params: {}, + }) + listDidNotProbe = capabilityCalls === 0 && healthCalls === 0 && modelCalls === 0 + yield line({ + version: 1, + requestId: 'actions-select-connection', + operationId: 'op-select-connection', + command: 'select_connection', + params: { connectionId: record.id }, + }) + yield line({ + version: 1, + requestId: 'actions-test-connection', + operationId: 'op-test-connection', + command: 'test_connection', + params: { connectionId: record.id }, + }) + yield line({ + version: 1, + requestId: 'actions-test-connection-replay', + operationId: 'op-test-connection', + command: 'test_connection', + params: { connectionId: record.id }, + }) + yield line({ + version: 1, + requestId: 'actions-save-profile', + operationId: 'op-save-profile', + command: 'save_profile', + params: { ref: profilePath, profile: profileB }, + }) + yield line({ + version: 1, + requestId: 'actions-save-inline-secret', + operationId: 'op-save-inline-secret', + command: 'save_profile', + params: { ref: profilePath, profile: secretProfile }, + }) + await rm(profilePath) + yield line({ + version: 1, + requestId: 'actions-save-profile-replay', + operationId: 'op-save-profile', + command: 'save_profile', + params: { ref: profilePath, profile: profileB }, + }) + } + const code = await runRpc(controller, input(), { + write: (chunk) => { + output += chunk + return true + }, + }) + responses.push( + ...output + .trim() + .split('\n') + .map((value) => JSON.parse(value) as BraidResponse), + ) + assert.equal(code, 0) + const testOperation = app + .state() + .operations.find((operation) => operation.id === 'op-test-connection') + assert(testOperation) + assert(operationTestResult(testOperation), JSON.stringify(testOperation.result)) + assert.equal( + JSON.stringify(testOperation.result).includes('credentialConfigured'), + false, + 'durable connection results must not persist credential-named UI metadata', + ) + const profileList = responseFor(responses, 'actions-list-profiles') + assert.equal(profileList.type, 'ack') + assert.equal( + (profileList.result as { profiles: readonly unknown[] }).profiles.length >= 2, + true, + ) + const invalid = responses.find( + (response) => response.type === 'error' && response.requestId === 'actions-invalid-profile', + ) + assert.equal(invalid?.type, 'error') + if (invalid?.type === 'error') assert.equal(invalid.code, 'PROFILE_INVALID') + const selected = responseFor(responses, 'actions-select-profile') + const selectedReplay = responseFor(responses, 'actions-select-profile-replay') + assert.equal(selected.type, 'ack') + assert.equal(selectedReplay.type, 'ack') + if (selected.type === 'ack' && selectedReplay.type === 'ack') { + assert.equal(selected.replayed, false) + assert.equal(selectedReplay.replayed, true) + assert.deepEqual( + outcomeWithoutReplay(selected.result), + outcomeWithoutReplay(selectedReplay.result), + ) + } + const stale = responses.find( + (response) => response.type === 'error' && response.requestId === 'actions-stale-profile', + ) + assert.equal(stale?.type, 'error') + if (stale?.type === 'error') assert.equal(stale.code, 'STALE_REVISION') + const unknown = responses.find( + (response) => response.type === 'error' && response.requestId === 'actions-unknown-profile', + ) + assert.equal(unknown?.type, 'error') + if (unknown?.type === 'error') assert.equal(unknown.code, 'PROFILE_NOT_FOUND') + const secretSelection = responses.find( + (response) => + response.type === 'error' && response.requestId === 'actions-select-inline-secret', + ) + assert.equal(secretSelection?.type, 'error') + if (secretSelection?.type === 'error') assert.equal(secretSelection.code, 'PROFILE_INVALID') + assert.notEqual(app.state().selectedProfileId, sourceSecret.id) + assert.equal(listDidNotProbe, true) + const tested = responseFor(responses, 'actions-test-connection') + const testedReplay = responseFor(responses, 'actions-test-connection-replay') + assert.equal(tested.type, 'ack') + assert.equal(testedReplay.type, 'ack') + if (tested.type === 'ack' && testedReplay.type === 'ack') { + assert.equal(tested.replayed, false) + assert.equal(testedReplay.replayed, true) + assert.deepEqual( + outcomeWithoutReplay(tested.result), + outcomeWithoutReplay(testedReplay.result), + ) + } + assert.equal(capabilityCalls, 1) + assert.equal(healthCalls, 1) + assert.equal(modelCalls, 1) + const savedReplay = responseFor(responses, 'actions-save-profile-replay') + assert.equal(savedReplay.type, 'ack') + if (savedReplay.type === 'ack') assert.equal(savedReplay.replayed, true) + const secretSave = responses.find( + (response) => + response.type === 'error' && response.requestId === 'actions-save-inline-secret', + ) + assert.equal(secretSave?.type, 'error') + if (secretSave?.type === 'error') assert.equal(secretSave.code, 'PROFILE_INVALID') + assert.equal( + app.state().operations.some((operation) => operation.id === 'op-save-inline-secret'), + false, + ) + assert.equal(JSON.stringify(app.events()).includes('ACTION-SECRET-CANARY'), false) + await assert.rejects(readFile(profilePath)) + } finally { + await rm(profilePathRoot, { recursive: true, force: true }) + } +}) diff --git a/test/profile-save-recovery.test.ts b/test/profile-save-recovery.test.ts new file mode 100644 index 0000000..18f8850 --- /dev/null +++ b/test/profile-save-recovery.test.ts @@ -0,0 +1,91 @@ +import assert from 'node:assert/strict' +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import test from 'node:test' +import { defineAgentProfile } from '@tangle-network/agent-interface' +import { createApplicationUiController } from '../src/adapters/tui/application-ui-controller.js' +import { createBraidApplication } from '../src/app/composition.js' +import { MemoryJournal } from '../src/app/journal.js' +import { FixedClock } from '../src/ports/clock.js' + +const phases = [ + 'pending-committed', + 'temporary-written', + 'temporary-fsynced', + 'renamed', + 'directory-fsynced', + 'acknowledgment', +] as const + +test('profile save recovery acknowledges or retries every atomic-write stop without guessing', async () => { + for (const phase of phases) { + const root = await mkdtemp(join(tmpdir(), `braid-profile-recovery-${phase}-`)) + const path = join(root, 'profile.json') + const secretProfile = defineAgentProfile({ + name: `recovery-${phase}`, + harness: 'pi', + model: { default: 'fixture/recovery' }, + prompt: { instructions: ['token=PROFILE-SAVE-SECRET-CANARY'] }, + }) + const operationId = `op-profile-recovery-${phase}` + const journal = new MemoryJournal(new FixedClock()) + const first = createBraidApplication({ + fixture: 'deterministic', + journal, + effectStorage: journal, + }) + const firstController = createApplicationUiController(first, {}, undefined, { + onProfileSavePhase: (current) => { + if (current === phase) throw new Error(`forced stop at ${phase}`) + }, + }) + try { + const initialized = await firstController.initialize('/workspace') + assert.equal(initialized.kind, 'accepted') + const failed = await firstController.dispatch({ + type: 'headless-command', + command: 'save_profile', + operationId, + params: { ref: path, profile: secretProfile }, + }) + assert.equal(failed.kind, 'error', phase) + assert.equal( + first.state().operations.find((operation) => operation.id === operationId)?.status, + 'pending', + ) + + const restarted = createBraidApplication({ + fixture: 'deterministic', + journal, + effectStorage: journal, + }) + const restartedController = createApplicationUiController(restarted) + const recovered = await restartedController.dispatch({ + type: 'headless-command', + command: 'save_profile', + operationId, + params: { ref: path, profile: secretProfile }, + }) + assert.equal(recovered.kind, 'accepted', phase) + assert.equal(recovered.replayed, true, phase) + const bytes = await readFile(path, 'utf8') + assert.match(bytes, new RegExp(`"name":"recovery-${phase}"`, 'u')) + assert.equal(JSON.stringify(restarted.events()).includes('PROFILE-SAVE-SECRET-CANARY'), false) + assert.equal( + restarted.state().operations.find((operation) => operation.id === operationId)?.status, + 'acknowledged', + ) + + const exactReplay = await restartedController.dispatch({ + type: 'headless-command', + command: 'save_profile', + operationId, + params: { ref: path, profile: secretProfile }, + }) + assert.deepEqual(exactReplay, recovered, phase) + } finally { + await rm(root, { recursive: true, force: true }) + } + } +}) diff --git a/test/profiles.test.ts b/test/profiles.test.ts new file mode 100644 index 0000000..427f763 --- /dev/null +++ b/test/profiles.test.ts @@ -0,0 +1,544 @@ +import assert from 'node:assert/strict' +import { chmod, lstat, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import test from 'node:test' +import { + canonicalCandidateJson, + canonicalAgentProfileDigest, + defineAgentProfilePublicConfig, + defineAgentProfileSecretRef, + type AgentProfile, + type AgentProfileCapabilities, +} from '@tangle-network/agent-interface' +import type { AgentEnvironmentCapabilities } from '@tangle-network/agent-interface/environment-provider' +import { + AGENT_INTERFACE_PACKAGE_VERSION, + ProfileCatalog, + ProfileDraft, + ProfilePersistenceError, + ProfileValidationError, + createProfileRecord, + createProfileSnapshot, + discoverProfiles, + exportProfileDocument, + exportProfileFile, + exportProfileJson, + importProfileJson, + importProfileSource, + readProfileFile, + resolveEffectiveProfile, + resolveProfileSource, + saveProfileFile, + searchProfiles, + selectBaseProfile, + validateProfile, + validateProfileShape, +} from '../src/app/profiles.js' + +const profileCapabilities: AgentProfileCapabilities = { + namedProfiles: true, + systemPrompt: true, + instructions: true, + tools: true, + permissions: true, + mcp: true, + subagents: true, + resources: { + files: true, + instructions: true, + tools: true, + skills: true, + agents: true, + commands: true, + }, + hooks: true, + modes: true, + runtimeUpdate: true, + validation: true, + extensions: ['future.backend'], +} + +const environmentCapabilities: AgentEnvironmentCapabilities = { + profile: profileCapabilities, + streaming: { live: true, replay: true, detach: true, turnIdempotency: true }, + sessions: { continue: true, list: true, messages: true }, + workspace: { + read: true, + write: true, + exec: true, + git: true, + upload: true, + download: true, + }, + branching: { checkpoint: true, fork: true, retrySafe: true, lookup: true, cleanup: true }, + placement: true, + usage: true, + confidential: false, +} + +const fullProfile: AgentProfile = { + name: 'reviewer', + description: 'Reviews durable integration work', + version: '2.1.0', + tags: ['review', 'integration'], + prompt: { + systemPrompt: 'You are a careful reviewer.', + instructions: ['Keep evidence attached.', 'Do not guess.'], + }, + model: { + default: 'anthropic/claude-sonnet', + small: 'anthropic/claude-haiku', + provider: 'anthropic', + reasoningEffort: 'high', + metadata: { contextWindow: 200000, family: 'sonnet' }, + }, + harness: 'claude-code', + permissions: { + shell: { read: 'allow', write: 'ask' }, + network: 'deny', + }, + tools: { read: true, write: true, web_search: false }, + mcp: { + local: { + transport: 'stdio', + command: 'mcp-review', + args: [defineAgentProfilePublicConfig('--safe')], + env: { REVIEW_TOKEN: defineAgentProfileSecretRef('cred:v1:review-token') }, + cwd: 'workspace', + metadata: { owner: 'team-review' }, + }, + remote: { + transport: 'http', + url: 'https://mcp.example.com', + headers: { + Authorization: defineAgentProfileSecretRef('cred:v1:mcp-auth', 'bearer'), + }, + }, + disabled: { enabled: false }, + }, + connections: [{ connectionId: 'hub-github', capabilities: ['repo.read'], alias: 'github' }], + subagents: { + tester: { + description: 'Runs focused tests', + prompt: 'Test the smallest meaningful surface.', + model: 'anthropic/claude-haiku', + tools: { read: true }, + permissions: { shell: 'ask' }, + maxSteps: 4, + metadata: { role: 'test' }, + }, + }, + resources: { + files: [ + { + path: 'REVIEW.md', + resource: { kind: 'inline', name: 'review', content: 'Review rules.' }, + executable: false, + }, + ], + tools: [{ kind: 'inline', name: 'tool', content: 'Tool definition.' }], + skills: [ + { kind: 'github', repository: 'tangle-network/skills', path: 'review/SKILL.md', ref: 'main' }, + ], + agents: [{ kind: 'inline', name: 'agent', content: 'Agent definition.' }], + commands: [{ kind: 'inline', name: 'check', content: 'Check definition.' }], + instructions: { kind: 'inline', name: 'instructions', content: 'Additional rules.' }, + failOnError: true, + }, + hooks: { + beforeTurn: [ + { + command: 'review-hook', + timeoutMs: 1000, + blocking: true, + matcher: '.*', + env: { MODE: defineAgentProfilePublicConfig('review') }, + }, + ], + }, + modes: { + fast: { + description: 'Short review', + model: 'anthropic/claude-haiku', + prompt: 'Be concise.', + tools: { read: true }, + permissions: { shell: 'deny' }, + metadata: { budget: 'small' }, + }, + }, + confidential: { + tee: 'tdx', + attestationNonce: 'a'.repeat(64), + sealed: true, + attestationRefresh: true, + }, + metadata: { owner: 'braid', note: 'public profile metadata', apiKey: 'do-not-persist' }, + extensions: { + 'future.backend': { + unknownField: { preserved: true }, + anotherValue: 'round-trip', + }, + }, +} + +const localSecurity = { + allowLocalMcp: true, + allowHooks: true, + allowedMcpHosts: ['mcp.example.com'], +} + +function source( + reference: string, + profile: AgentProfile = fullProfile, + kind: 'inline' | 'file' = 'inline', +) { + return createProfileRecord( + { + kind, + reference, + label: reference, + writable: kind === 'file', + trusted: true, + }, + profile, + ) +} + +test('the installed canonical profile schema round-trips every field and extensions', () => { + const shape = validateProfileShape(fullProfile) + assert.equal(shape.ok, true) + assert.ok(shape.profile) + assert.equal(shape.digest, canonicalAgentProfileDigest(shape.profile)) + + const json = canonicalCandidateJson(shape.profile) + const imported = importProfileJson(json) + assert.equal(imported.redacted, false) + 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.43.0') +}) + +test('unknown canonical fields fail closed while namespaced extensions remain opaque', () => { + const invalid = { ...fullProfile, futureCanonicalField: true } + const result = validateProfileShape(invalid) + assert.equal(result.ok, false) + assert.ok(result.issues.some((item) => item.code === 'unrecognized_keys')) + + const document = exportProfileDocument(fullProfile, { redact: false }) + const imported = importProfileJson(JSON.stringify(document), { allowRedacted: true }) + assert.deepEqual(imported.profile.extensions?.['future.backend'], { + unknownField: { preserved: true }, + anotherValue: 'round-trip', + }) +}) + +test('provider validation blocks errors and requires exact acceptance of warnings', async () => { + const publicProfile = { + ...fullProfile, + metadata: { owner: 'braid', note: 'public profile metadata' }, + } + const provider = { + name: 'test-provider', + capabilities: () => environmentCapabilities, + validateProfile: () => ({ + ok: true, + issues: [ + { + level: 'warning' as const, + code: 'model-snapped', + message: 'Provider will use its selected model', + path: 'model.default', + }, + ], + normalizedProfile: { + ...publicProfile, + model: { ...publicProfile.model, default: 'anthropic/claude-haiku' }, + }, + }), + } + const rejected = await validateProfile(publicProfile, { + securityPolicy: localSecurity, + provider, + }) + assert.equal(rejected.ok, false) + assert.ok(rejected.issues.some((item) => item.code === 'provider-warning-not-accepted')) + + const accepted = await validateProfile(publicProfile, { + securityPolicy: localSecurity, + provider, + acceptedProviderWarningCodes: ['model-snapped'], + }) + assert.equal(accepted.ok, true) + assert.equal(accepted.profile?.model?.default, 'anthropic/claude-haiku') + assert.equal(accepted.provider?.result.issues[0]?.code, 'model-snapped') + assert.deepEqual(accepted.acceptedProviderWarningCodes, ['model-snapped']) + + const inlineSecret = await validateProfile(fullProfile, { + securityPolicy: localSecurity, + provider, + acceptedProviderWarningCodes: ['model-snapped'], + }) + assert.equal(inlineSecret.ok, false) + assert.ok(inlineSecret.issues.some((item) => item.code === 'inline-secret-forbidden')) +}) + +test('selection precedence and run overrides never mutate the source profile', () => { + const command = source('command', { ...fullProfile, name: 'command' }) + const branch = source('branch', { ...fullProfile, name: 'branch' }) + const workspace = source('workspace', { ...fullProfile, name: 'workspace' }) + const user = source('user', { ...fullProfile, name: 'user' }) + const first = source('first', { ...fullProfile, name: 'first' }) + + assert.equal( + selectBaseProfile({ + commandLine: command, + branch, + workspace, + user, + firstRun: first, + workspaceTrusted: true, + })?.reason, + 'command-line', + ) + assert.equal( + selectBaseProfile({ branch, workspace, user, firstRun: first, workspaceTrusted: true })?.reason, + 'branch', + ) + assert.equal( + selectBaseProfile({ workspace, user, firstRun: first, workspaceTrusted: true })?.reason, + 'workspace', + ) + assert.equal( + selectBaseProfile({ workspace, user, firstRun: first, workspaceTrusted: false })?.reason, + 'user', + ) + + const effective = resolveEffectiveProfile({ + profile: command, + branchOverrides: { + model: 'openai/gpt-5', + effort: 'medium', + connectionId: 'branch-connection', + }, + nextRunOverrides: { + harness: 'codex', + model: 'openai/gpt-5.6', + effort: 'xhigh', + mode: 'fast', + connectionId: 'next-connection', + }, + }) + assert.equal(effective.runner, 'codex') + assert.equal(effective.model, 'openai/gpt-5.6') + assert.equal(effective.effort, 'xhigh') + assert.equal(effective.mode, 'fast') + assert.equal(effective.connectionId, 'next-connection') + assert.equal(effective.effectiveProfile.model?.small, fullProfile.model?.small) + assert.equal(command.profile.model?.default, fullProfile.model?.default) + assert.equal(effective.compatibility.modelSupported, true) + + assert.equal( + resolveEffectiveProfile({ + profile: command, + workspaceTrusted: false, + workspaceConnectionId: 'workspace-connection', + userConnectionId: 'user-connection', + }).connectionId, + 'user-connection', + ) + assert.equal( + resolveEffectiveProfile({ + profile: command, + workspaceTrusted: true, + workspaceConnectionId: 'workspace-connection', + userConnectionId: 'user-connection', + }).connectionId, + 'workspace-connection', + ) +}) + +test('drafts validate raw and structured edits immediately and produce canonical diffs', () => { + const draft = new ProfileDraft(fullProfile) + assert.equal(draft.valid, true) + const invalid = draft.replaceRaw('{"name":') + assert.equal(invalid.ok, false) + assert.throws(() => draft.profile, ProfileValidationError) + + const valid = draft.replace({ ...fullProfile, name: 'edited' }) + assert.equal(valid.ok, true) + assert.equal(draft.profile.name, 'edited') + assert.ok(draft.diff().length > 0) + assert.match(draft.rawJson(), /"name":"edited"/u) +}) + +test('discovery is ordered, explicit, source-distinct, and does not scan untrusted workspace entries', async () => { + const root = await mkdtemp(join(tmpdir(), 'braid-profiles-discovery-')) + try { + const path = join(root, 'workspace.json') + await writeFile(path, canonicalCandidateJson({ ...fullProfile, name: 'file-profile' })) + const result = await discoverProfiles({ + explicit: [ + { kind: 'inline', reference: 'explicit', profile: { ...fullProfile, name: 'explicit' } }, + ], + workspace: [{ kind: 'file', reference: path, path }], + workspaceTrusted: false, + user: [{ kind: 'inline', reference: 'user', profile: { ...fullProfile, name: 'user' } }], + provider: [ + { + kind: 'provider', + reference: 'catalog/reviewer', + resolve: async () => ({ ...fullProfile, name: 'provider' }), + }, + ], + }) + assert.deepEqual( + result.profiles.map((item) => item.displayName), + ['explicit', 'user', 'provider'], + ) + assert.equal(result.issues.length, 0) + + const duplicates = await discoverProfiles({ + explicit: [ + { kind: 'inline', reference: 'one', profile: fullProfile }, + { kind: 'inline', reference: 'two', profile: fullProfile }, + ], + }) + assert.equal(duplicates.profiles.length, 2) + assert.notEqual(duplicates.profiles[0]?.id, duplicates.profiles[1]?.id) + } finally { + await rm(root, { recursive: true, force: true }) + } +}) + +test('search covers runner, tools, skills, and connection capability fields', () => { + const record = source('searchable') + assert.equal(searchProfiles([record], 'claude-code').length, 1) + assert.equal(searchProfiles([record], 'review').length, 1) + assert.equal(searchProfiles([record], 'review/SKILL.md').length, 1) + assert.equal(searchProfiles([record], 'repo.read').length, 1) + assert.equal(searchProfiles([record], 'does-not-exist').length, 0) + const catalog = new ProfileCatalog({ profiles: [record] }) + assert.equal(catalog.size, 1) + assert.equal(catalog.get(record.id)?.id, record.id) +}) + +test('profile snapshots are immutable, redacted, and carry provider-safe receipt data', () => { + const record = source('snapshot') + const effective = resolveEffectiveProfile({ + profile: record, + nextRunOverrides: { model: 'anthropic/claude-haiku', effort: 'low' }, + }) + const validation = { + ok: true, + issues: [], + profile: effective.effectiveProfile, + digest: canonicalAgentProfileDigest(effective.effectiveProfile), + acceptedProviderWarningCodes: ['model-snapped'], + } + const receipt = createProfileSnapshot({ + source: record, + effective, + validation, + capabilities: profileCapabilities, + providerMaterializationReceipt: { + materializationDigest: 'sha256:materialization', + metadata: { apiKey: 'do-not-persist' }, + }, + }) + assert.equal(Object.isFrozen(receipt), true) + assert.equal(Object.isFrozen(receipt.effectiveProfile), true) + assert.equal(receipt.effectiveProfile.confidential?.attestationNonce, '[redacted challenge]') + assert.deepEqual(receipt.validation.acceptedProviderWarningCodes, ['model-snapped']) + assert.equal(JSON.stringify(receipt).includes('do-not-persist'), false) + assert.equal( + receipt.effectiveProfileDigest, + canonicalAgentProfileDigest(receipt.effectiveProfile), + ) +}) + +test('profile files save atomically, verify bytes, reject races and symlinks, and export safely', async () => { + const root = await mkdtemp(join(tmpdir(), 'braid-profiles-files-')) + try { + const path = join(root, 'reviewer.json') + const saved = saveProfileFile(path, fullProfile, { trusted: true }) + assert.equal((await lstat(path)).mode & 0o777, 0o600) + assert.equal(readProfileFile(path).bytesDigest, saved.bytesDigest) + assert.equal(readProfileFile(path).imported.digest, canonicalAgentProfileDigest(fullProfile)) + + await writeFile(path, '{"name":"changed"}') + assert.throws( + () => saveProfileFile(path, fullProfile, { expectedBytesDigest: saved.bytesDigest }), + (error: unknown) => + error instanceof ProfilePersistenceError && error.code === 'PROFILE_SOURCE_CHANGED', + ) + + const exportPath = join(root, 'reviewer.export.json') + const exported = exportProfileFile(exportPath, fullProfile) + const exportText = await readFile(exportPath, 'utf8') + assert.equal(exportText.includes('do-not-persist'), false) + assert.equal(exported.document.redacted, true) + assert.throws(() => importProfileJson(exportText), ProfilePersistenceError) + const acknowledged = importProfileJson(exportText, { allowRedacted: true }) + assert.equal(acknowledged.digest, exported.document.profileDigest) + assert.throws( + () => exportProfileFile(exportPath, fullProfile), + (error: unknown) => + error instanceof ProfilePersistenceError && error.code === 'PROFILE_EXISTS', + ) + assert.deepEqual(acknowledged.profile.extensions?.['future.backend'], { + unknownField: { preserved: true }, + anotherValue: 'round-trip', + }) + + const exact = importProfileJson(exportProfileJson(fullProfile, { redact: false }), { + allowRedacted: true, + }) + assert.equal(canonicalCandidateJson(exact.profile), canonicalCandidateJson(fullProfile)) + + const link = join(root, 'planted.json') + const target = join(root, 'target.json') + await writeFile(target, '{}') + await symlink(target, link) + assert.throws( + () => saveProfileFile(link, fullProfile), + (error: unknown) => error instanceof Error && /symbolic link/u.test(error.message), + ) + assert.equal((await readFile(target, 'utf8')) === '{}', true) + + const directoryPath = join(root, 'directory') + await mkdir(directoryPath) + assert.throws( + () => saveProfileFile(directoryPath, fullProfile), + (error: unknown) => + error instanceof ProfilePersistenceError && error.code === 'PROFILE_FILE_NOT_REGULAR', + ) + + const readonlyPath = join(root, 'readonly.json') + await writeFile(readonlyPath, canonicalCandidateJson(fullProfile)) + await chmod(readonlyPath, 0o400) + const imported = await resolveProfileSource({ + kind: 'file', + reference: readonlyPath, + path: readonlyPath, + writable: false, + }) + assert.equal(imported.source.writable, false) + } finally { + await rm(root, { recursive: true, force: true }) + } +}) + +test('inline import preserves the source descriptor without merging profiles', () => { + const record = importProfileSource(fullProfile, { + kind: 'package', + reference: '@example/reviewer', + label: 'package reviewer', + writable: false, + trusted: true, + }) + assert.equal(record.source.kind, 'package') + assert.equal(record.source.writable, false) + assert.equal(record.profile.name, 'reviewer') +}) diff --git a/test/reducer.test.ts b/test/reducer.test.ts index 0b38837..40be87c 100644 --- a/test/reducer.test.ts +++ b/test/reducer.test.ts @@ -5,6 +5,7 @@ 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) => ({ @@ -50,9 +51,16 @@ test('10,000 streamed events replay without duplication or event loss', () => { 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, 200_002) + 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(response.slice(-200_000)), true) + assert.equal( + view.messages[1]?.text.endsWith( + Array.from(response) + .slice(-(MAX_RENDERED_TEXT_CHARS - 2)) + .join(''), + ), + true, + ) }) test('replay rejects a sequence gap', () => { diff --git a/test/rpc.test.ts b/test/rpc.test.ts index 21dd92d..93c4969 100644 --- a/test/rpc.test.ts +++ b/test/rpc.test.ts @@ -1,19 +1,45 @@ import assert from 'node:assert/strict' import test from 'node:test' +import { createApplicationUiController } from '../src/adapters/tui/application-ui-controller.js' import { createBraidApplication, DETERMINISTIC_PROFILE } from '../src/app/composition.js' +import { MemoryJournal } from '../src/app/journal.js' +import { canonicalDigest } from '../src/domain/canonical.js' +import { FixedClock } from '../src/ports/clock.js' import type { BraidResponse } from '../src/views/headless/protocol.js' import { RPC_REPLAY_MAX_BYTES, RPC_REPLAY_MAX_ENTRIES, runRpc } from '../src/views/headless/rpc.js' +import { queryActivity } from '../src/views/shared/semantic-activity.js' +import { queryDetails } from '../src/views/shared/semantic-details.js' +import { queryGraph } from '../src/views/shared/semantic-graph.js' +import { compareSemanticText } from '../src/views/shared/semantic-graph-filters.js' +import { SemanticQueryError } from '../src/views/shared/semantic-query-scope.js' +import type { + ActivityQueryResult, + DetailsQueryResult, + GraphQueryResult, +} from '../src/views/shared/semantic-query-types.js' async function* requestInput(lines: readonly object[]): AsyncGenerator { yield `${lines.map((line) => JSON.stringify(line)).join('\n')}\n` } +function controllerFor(app: ReturnType) { + return createApplicationUiController(app) +} + +function resultFor(responses: readonly BraidResponse[], requestId: string): T { + const response = responses.find( + (candidate) => candidate.type === 'ack' && candidate.requestId === requestId, + ) + assert(response && response.type === 'ack', `missing acknowledgement for ${requestId}`) + assert.notEqual(response.result, undefined, `missing result for ${requestId}`) + return response.result as T +} + test('JSONL send acknowledges before events and returns final semantic state', async () => { const app = createBraidApplication({ fixture: 'deterministic' }) let output = '' - const code = await runRpc( - app, - requestInput([ + async function* input(): AsyncGenerator { + yield `${[ { version: 1, requestId: 'req-init', @@ -31,15 +57,23 @@ test('JSONL send acknowledges before events and returns final semantic state', a text: 'hello Braid', }, }, - { version: 1, requestId: 'req-stop', command: 'shutdown' }, - ]), - { - write: (chunk) => { - output += chunk - return true - }, + ] + .map((request) => JSON.stringify(request)) + .join('\n')}\n` + await app.waitForIdle() + yield `${JSON.stringify({ + version: 1, + requestId: 'req-stop', + operationId: 'op-stop-1', + command: 'shutdown', + })}\n` + } + const code = await runRpc(controllerFor(app), input(), { + write: (chunk) => { + output += chunk + return true }, - ) + }) const responses = output .trim() .split('\n') @@ -59,15 +93,370 @@ test('JSONL send acknowledges before events and returns final semantic state', a assert.ok(firstRunEvent > sendAck) 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') }) +test('JSONL drives the complete canonical conversation lifecycle', async () => { + const app = createBraidApplication({ fixture: 'deterministic' }) + const responses: BraidResponse[] = [] + async function* input(): AsyncGenerator { + yield `${JSON.stringify({ + version: 1, + requestId: 'conversation-init', + command: 'initialize', + params: { workspace: '/workspace' }, + })}\n` + yield `${JSON.stringify({ + version: 1, + requestId: 'conversation-create', + operationId: 'op-rpc-conversation-create', + command: 'new_conversation', + params: { title: 'RPC conversation' }, + })}\n` + const created = resultFor<{ id: string; activeBranchId: string }>( + responses, + 'conversation-create', + ) + yield `${JSON.stringify({ + version: 1, + requestId: 'conversation-draft', + operationId: 'op-rpc-conversation-draft', + command: 'set_draft', + params: { + conversationId: created.id, + branchId: created.activeBranchId, + text: 'durable JSONL draft', + }, + })}\n` + yield `${JSON.stringify({ + version: 1, + requestId: 'conversation-draft-replay', + operationId: 'op-rpc-conversation-draft', + command: 'set_draft', + params: { + conversationId: created.id, + branchId: created.activeBranchId, + text: 'durable JSONL draft', + }, + })}\n` + yield `${JSON.stringify({ + version: 1, + requestId: 'conversation-rename', + operationId: 'op-rpc-conversation-rename', + command: 'rename_conversation', + params: { conversationId: created.id, title: 'Renamed over JSONL' }, + })}\n` + yield `${JSON.stringify({ + version: 1, + requestId: 'conversation-archive', + operationId: 'op-rpc-conversation-archive', + command: 'archive_conversation', + params: { conversationId: created.id, archived: true }, + })}\n` + yield `${JSON.stringify({ + version: 1, + requestId: 'conversation-list', + command: 'list_conversations', + params: { query: 'renamed', status: 'archived' }, + })}\n` + yield `${JSON.stringify({ + version: 1, + requestId: 'conversation-restore', + operationId: 'op-rpc-conversation-restore', + command: 'archive_conversation', + params: { conversationId: created.id, archived: false }, + })}\n` + yield `${JSON.stringify({ + version: 1, + requestId: 'conversation-send', + operationId: 'op-rpc-conversation-send', + command: 'send', + params: { + conversationId: created.id, + branchId: created.activeBranchId, + text: 'message boundary', + }, + })}\n` + await app.waitForIdle() + yield `${JSON.stringify({ + version: 1, + requestId: 'conversation-state', + command: 'get_state', + })}\n` + const state = responses.find( + (response) => response.type === 'state' && response.requestId === 'conversation-state', + ) + assert(state && state.type === 'state' && state.projection === 'full') + const messageId = state.state.messages[0]?.id + assert(messageId) + yield `${JSON.stringify({ + version: 1, + requestId: 'conversation-branch', + operationId: 'op-rpc-conversation-branch', + command: 'branch', + params: { + conversationId: created.id, + branchId: created.activeBranchId, + messageId, + }, + })}\n` + const branch = resultFor<{ id: string }>(responses, 'conversation-branch') + yield `${JSON.stringify({ + version: 1, + requestId: 'conversation-clone', + operationId: 'op-rpc-conversation-clone', + command: 'clone', + params: { conversationId: created.id, branchId: branch.id, title: 'RPC clone' }, + })}\n` + const clone = resultFor<{ id: string }>(responses, 'conversation-clone') + yield `${JSON.stringify({ + version: 1, + requestId: 'conversation-delete-clone', + operationId: 'op-rpc-conversation-delete-clone', + command: 'delete_conversation', + params: { conversationId: clone.id }, + })}\n` + yield `${JSON.stringify({ + version: 1, + requestId: 'conversation-open', + operationId: 'op-rpc-conversation-open', + command: 'open_conversation', + params: { conversationId: created.id, branchId: created.activeBranchId }, + })}\n` + yield `${JSON.stringify({ + version: 1, + requestId: 'conversation-plan', + operationId: 'op-rpc-conversation-fork', + command: 'plan_fork', + params: { + conversationId: created.id, + branchId: created.activeBranchId, + messageId, + workspace: false, + }, + })}\n` + const plan = resultFor<{ digest: string; destinationBranchId: string }>( + responses, + 'conversation-plan', + ) + yield `${JSON.stringify({ + version: 1, + requestId: 'conversation-execute', + operationId: 'op-rpc-conversation-fork', + command: 'execute_fork', + params: { + planDigest: plan.digest, + conversationId: created.id, + branchId: created.activeBranchId, + messageId, + workspace: false, + }, + })}\n` + yield `${JSON.stringify({ + version: 1, + requestId: 'conversation-export', + operationId: 'op-rpc-conversation-export', + command: 'export', + params: { target: created.id, format: 'markdown' }, + })}\n` + yield `${JSON.stringify({ + version: 1, + requestId: 'conversation-export-json', + operationId: 'op-rpc-conversation-export-json', + command: 'export', + params: { target: created.id, format: 'json' }, + })}\n` + const canonicalExport = resultFor<{ readonly content?: string }>( + responses, + 'conversation-export-json', + ) + assert(canonicalExport.content) + yield `${JSON.stringify({ + version: 1, + requestId: 'conversation-import', + operationId: 'op-rpc-conversation-import', + command: 'import_conversation', + params: { content: canonicalExport.content, title: 'Imported over JSONL' }, + })}\n` + yield `${JSON.stringify({ + version: 1, + requestId: 'conversation-delete', + operationId: 'op-rpc-conversation-delete', + command: 'delete_conversation', + params: { conversationId: created.id }, + })}\n` + yield `${JSON.stringify({ + version: 1, + requestId: 'conversation-stop', + operationId: 'op-rpc-conversation-stop', + command: 'shutdown', + })}\n` + } + + const code = await runRpc(controllerFor(app), input(), { + write: (chunk) => { + responses.push(JSON.parse(chunk) as BraidResponse) + return true + }, + }) + const list = resultFor(responses, 'conversation-list') + const created = resultFor<{ readonly id: string }>(responses, 'conversation-create') + const draft = resultFor<{ readonly text: string }>(responses, 'conversation-draft') + const replayedDraft = resultFor<{ readonly text: string }>(responses, 'conversation-draft-replay') + const plan = resultFor<{ readonly destinationBranchId: string }>(responses, 'conversation-plan') + const fork = resultFor<{ readonly id: string }>(responses, 'conversation-execute') + const exported = resultFor<{ readonly content?: string; readonly format: string }>( + responses, + 'conversation-export', + ) + const imported = resultFor<{ readonly conversationId: string; readonly replayed: boolean }>( + responses, + 'conversation-import', + ) + const deleted = resultFor<{ readonly deletedAt?: string }>(responses, 'conversation-delete') + + assert.equal(code, 0) + assert.equal(list.length, 1) + assert.equal(draft.text, 'durable JSONL draft') + assert.deepEqual(replayedDraft, draft) + assert.equal(fork.id, plan.destinationBranchId) + assert.equal(exported.format, 'markdown') + assert.match(exported.content ?? '', /Renamed over JSONL/u) + assert.equal(imported.replayed, false) + assert.notEqual(imported.conversationId, created.id) + assert.equal(typeof deleted.deletedAt, 'string') + 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') + await source.whenDurable() + const exported = await source.conversations.exports.export({ + operationId: 'op-rpc-large-import-export', + format: 'json', + }) + assert(exported.content) + const document = JSON.parse(exported.content) as { + content: { conversation: { title: string }; [key: string]: unknown } + contentDigest: string + [key: string]: unknown + } + document.content.conversation.title = 'x'.repeat(1_100_000) + document.contentDigest = canonicalDigest(document.content) + const request = { + version: 1, + requestId: 'req-large-import', + operationId: 'op-rpc-large-import', + command: 'import_conversation', + params: { content: JSON.stringify(document) }, + } + assert.ok(Buffer.byteLength(JSON.stringify(request), 'utf8') > 1024 * 1024) + + const app = createBraidApplication({ fixture: 'deterministic' }) + const responses: BraidResponse[] = [] + const code = await runRpc( + controllerFor(app), + requestInput([ + { + version: 1, + requestId: 'req-large-init', + command: 'initialize', + params: { workspace: '/workspace' }, + }, + request, + { + version: 1, + requestId: 'req-large-stop', + operationId: 'op-rpc-large-stop', + command: 'shutdown', + }, + ]), + { + write: (chunk) => { + responses.push(JSON.parse(chunk) as BraidResponse) + return true + }, + }, + ) + const imported = resultFor<{ readonly conversationId: string }>(responses, 'req-large-import') + + assert.equal(code, 0) + assert.equal( + app.state().conversations.some((conversation) => conversation.id === imported.conversationId), + true, + ) +}) + +test('JSONL cancel interrupts an active send and reports the terminal state', async () => { + const app = createBraidApplication({ fixture: 'deterministic', chunkDelayMs: 25 }) + let output = '' + await runRpc( + controllerFor(app), + requestInput([ + { + version: 1, + requestId: 'req-init', + command: 'initialize', + params: { workspace: '/workspace', subscribe: true }, + }, + { + version: 1, + requestId: 'req-send', + operationId: 'op-cancel-send', + command: 'send', + params: { text: 'cancel this active turn' }, + }, + { + version: 1, + requestId: 'req-cancel', + operationId: 'op-cancel-active', + command: 'cancel_run', + params: { runId: 'run-000001', reason: 'test cancellation' }, + }, + { + version: 1, + requestId: 'req-stop', + operationId: 'op-stop-cancel', + command: 'shutdown', + }, + ]), + { + write: (chunk) => { + output += chunk + return true + }, + }, + ) + const responses = output + .trim() + .split('\n') + .map((line) => JSON.parse(line) as BraidResponse) + assert.ok( + responses.some( + (response) => response.type === 'event' && response.event.kind === 'run.cancel.requested', + ), + ) + const cancelState = responses.find( + (response) => response.type === 'state' && response.requestId === 'req-cancel', + ) + assert.equal(cancelState?.type, 'state') + 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') +}) + test('JSONL requires initialize and stable operation identity', async () => { const app = createBraidApplication({ fixture: 'deterministic' }) let output = '' await runRpc( - app, + controllerFor(app), requestInput([ { version: 1, @@ -94,7 +483,7 @@ test('JSONL replays identical request IDs and rejects changed bodies', async () const app = createBraidApplication({ fixture: 'deterministic' }) let output = '' await runRpc( - app, + controllerFor(app), requestInput([ { version: 1, @@ -104,8 +493,18 @@ test('JSONL replays identical request IDs and rejects changed bodies', async () }, { version: 1, requestId: 'req-state', command: 'get_state' }, { version: 1, requestId: 'req-state', command: 'get_state' }, - { version: 1, requestId: 'req-state', command: 'shutdown' }, - { version: 1, requestId: 'req-stop', command: 'shutdown' }, + { + version: 1, + requestId: 'req-state', + operationId: 'op-state-shutdown', + command: 'shutdown', + }, + { + version: 1, + requestId: 'req-stop', + operationId: 'op-stop-2', + command: 'shutdown', + }, ]), { write: (chunk) => { @@ -132,11 +531,77 @@ test('JSONL replays identical request IDs and rejects changed bodies', async () assert.equal(conflict.code, 'REQUEST_ID_CONFLICT') }) +test('JSONL summary projection omits full transcript and profile data', async () => { + const app = createBraidApplication({ fixture: 'deterministic' }) + let output = '' + await runRpc( + controllerFor(app), + requestInput([ + { + version: 1, + requestId: 'req-init', + command: 'initialize', + params: { workspace: '/workspace' }, + }, + { + version: 1, + requestId: 'req-summary', + command: 'get_state', + params: { projection: 'summary' }, + }, + { + version: 1, + requestId: 'req-stop', + operationId: 'op-stop-3', + command: 'shutdown', + }, + ]), + { + write: (chunk) => { + output += chunk + return true + }, + }, + ) + const summary = output + .trim() + .split('\n') + .map((line) => JSON.parse(line) as BraidResponse) + .find((response) => response.type === 'state' && response.requestId === 'req-summary') + assert.equal(summary?.type, 'state') + if (summary?.type !== 'state') assert.fail('missing summary state') + assert.equal(summary.projection, 'summary') + if (summary.projection !== 'summary') assert.fail('wrong state projection') + assert.equal(summary.state.messageCount, 0) + assert.equal('messages' in summary.state, false) + assert.equal('profile' in summary.state, false) + assert.equal('view' in summary, false) +}) + +test('headless state redacts generic secret keys, secret contexts, and credential URLs', () => { + const app = createBraidApplication({ + fixture: 'deterministic', + profile: { + ...DETERMINISTIC_PROFILE, + metadata: { + secret: 'CANARY-SECRET', + secretAnswer: 'CANARY-ANSWER', + token: 'CANARY-TOKEN', + callback: 'https://user:CANARY@example.com/?token=CANARY', + challenge: { secret: true, answer: 'CANARY-CONTEXT' }, + }, + }, + }) + const serialized = JSON.stringify(controllerFor(app).state()) + assert.equal(serialized.includes('CANARY'), false) + assert.match(serialized, /\[redacted\]/u) +}) + test('JSONL rejects wrong optional types and unknown fields', async () => { const app = createBraidApplication({ fixture: 'deterministic' }) let output = '' await runRpc( - app, + controllerFor(app), requestInput([ { version: 1, @@ -163,7 +628,12 @@ test('JSONL rejects wrong optional types and unknown fields', async () => { command: 'get_state', params: { extra: true }, }, - { version: 1, requestId: 'req-stop', command: 'shutdown' }, + { + version: 1, + requestId: 'req-stop', + operationId: 'op-stop-4', + command: 'shutdown', + }, ]), { write: (chunk) => { @@ -189,7 +659,7 @@ test('JSONL operation replay returns current state after later sends', async () const app = createBraidApplication({ fixture: 'deterministic' }) let output = '' await runRpc( - app, + controllerFor(app), requestInput([ { version: 1, @@ -218,7 +688,12 @@ test('JSONL operation replay returns current state after later sends', async () command: 'send', params: { text: 'first' }, }, - { version: 1, requestId: 'req-stop', command: 'shutdown' }, + { + version: 1, + requestId: 'req-stop', + operationId: 'op-stop-5', + command: 'shutdown', + }, ]), { write: (chunk) => { @@ -232,11 +707,22 @@ test('JSONL operation replay returns current state after later sends', async () .split('\n') .map((line) => JSON.parse(line) as BraidResponse) .find((response) => response.type === 'state' && response.requestId === 'req-a-replay') + const stateAfterSecondSend = output + .trim() + .split('\n') + .map((line) => JSON.parse(line) as BraidResponse) + .find((response) => response.type === 'state' && response.requestId === 'req-b') assert.equal(replayState?.type, 'state') if (replayState?.type !== 'state') assert.fail('missing replay state') + assert.equal(replayState.projection, 'full') + if (replayState.projection !== 'full') assert.fail('expected full replay state') + assert.equal(stateAfterSecondSend?.type, 'state') + if (stateAfterSecondSend?.type !== 'state') assert.fail('missing second send state') assert.equal(replayState.state.messages.length, 4) - assert.equal(replayState.state.revision, app.state().revision) + assert.equal(replayState.state.revision, stateAfterSecondSend.state.revision) + assert.equal(app.state().revision, replayState.state.revision + 1) + assert.equal(app.events().at(-1)?.event.kind, 'application.shutdown.requested') }) test('JSONL bounds direct-response replay while operation replay stays safe', async () => { @@ -248,7 +734,7 @@ test('JSONL bounds direct-response replay while operation replay stays safe', as command: 'get_state', })) await runRpc( - app, + controllerFor(app), requestInput([ { version: 1, @@ -278,7 +764,12 @@ test('JSONL bounds direct-response replay while operation replay stays safe', as command: 'send', params: { text: 'execute once' }, }, - { version: 1, requestId: 'req-stop', command: 'shutdown' }, + { + version: 1, + requestId: 'req-stop', + operationId: 'op-stop-6', + command: 'shutdown', + }, ]), { write: (chunk) => { @@ -326,10 +817,10 @@ test('JSONL evicts oldest responses when the replay payload budget is full', asy await app.send({ operationId: 'op-after-cache', text: 'advance state' }).completion yield `${JSON.stringify({ version: 1, requestId: 'req-c', command: 'get_state' })}\n` yield `${JSON.stringify({ version: 1, requestId: 'req-a', command: 'get_state' })}\n` - yield `${JSON.stringify({ version: 1, requestId: 'req-stop', command: 'shutdown' })}\n` + yield `${JSON.stringify({ version: 1, requestId: 'req-stop', operationId: 'op-stop-7', command: 'shutdown' })}\n` } - await runRpc(app, input(), { + await runRpc(controllerFor(app), input(), { write: (chunk) => { const response = JSON.parse(chunk) as BraidResponse if ( @@ -364,7 +855,7 @@ test('JSONL rejects replay when one direct response exceeds the payload budget', readonly bytes: number }> = [] await runRpc( - app, + controllerFor(app), requestInput([ { version: 1, @@ -384,7 +875,12 @@ test('JSONL rejects replay when one direct response exceeds the payload budget', command: 'initialize', params: { workspace: '/other' }, }, - { version: 1, requestId: 'req-stop', command: 'shutdown' }, + { + version: 1, + requestId: 'req-stop', + operationId: 'op-stop-8', + command: 'shutdown', + }, ]), { write: (chunk) => { @@ -409,3 +905,196 @@ test('JSONL rejects replay when one direct response exceeds the payload budget', ['REQUEST_REPLAY_UNAVAILABLE', 'REQUEST_ID_CONFLICT'], ) }) + +test('JSONL semantic queries return canonical graph, activity, and details results', async () => { + const app = createBraidApplication({ fixture: 'deterministic' }) + app.initialize('/workspace') + const sent = app.send({ operationId: 'op-semantic-query', text: 'query me' }) + await sent.completion + const state = app.state() + const responses: BraidResponse[] = [] + await runRpc( + controllerFor(app), + requestInput([ + { + version: 1, + requestId: 'semantic-init', + command: 'initialize', + params: { workspace: '/workspace' }, + }, + { + version: 1, + requestId: 'semantic-graph', + command: 'get_graph', + params: { conversationId: state.conversationId, branchId: state.branchId }, + }, + { + version: 1, + requestId: 'semantic-activity', + command: 'get_activity', + params: { runId: sent.runId }, + }, + { + version: 1, + requestId: 'semantic-details', + command: 'get_details', + params: { entityType: 'run', entityId: sent.runId }, + }, + { + version: 1, + requestId: 'semantic-unknown', + command: 'get_details', + params: { entityType: 'run', entityId: 'run-does-not-exist' }, + }, + { + version: 1, + requestId: 'semantic-stop', + operationId: 'op-semantic-stop', + command: 'shutdown', + }, + ]), + { + write: (chunk) => { + responses.push(JSON.parse(chunk) as BraidResponse) + return true + }, + }, + ) + + const graph = resultFor(responses, 'semantic-graph') + const activity = resultFor(responses, 'semantic-activity') + const details = resultFor(responses, 'semantic-details') + assert.equal(graph.conversationId, state.conversationId) + assert.equal(graph.branchId, state.branchId) + assert.ok(graph.nodes.some((node) => node.type === 'conversation')) + assert.ok(graph.nodes.some((node) => node.type === 'turn')) + assert.ok(graph.nodes.some((node) => node.type === 'run' && node.id === sent.runId)) + assert.ok(graph.edges.some((edge) => edge.kind === 'continued')) + assert.ok(graph.edges.some((edge) => edge.kind === 'attached')) + assert.equal(activity.runId, sent.runId) + assert.equal(activity.activity[0]?.runId, sent.runId) + assert.equal(details.entityType, 'run') + assert.equal(details.entityId, sent.runId) + assert.ok(details.fields.some((field) => field.label === 'status')) + assert.equal( + responses.filter( + (response) => + response.type === 'state' && + ['semantic-graph', 'semantic-activity', 'semantic-details', 'semantic-unknown'].includes( + response.requestId, + ), + ).length, + 0, + ) + const unknown = responses.find( + (response) => response.type === 'error' && response.requestId === 'semantic-unknown', + ) + assert.equal(unknown?.type, 'error') + if (unknown?.type === 'error') assert.equal(unknown.code, 'UNKNOWN_ENTITY') +}) + +test('semantic queries enforce scope, redact details, preserve ordering, and survive replay', async () => { + const app = createBraidApplication({ fixture: 'deterministic' }) + app.initialize('/workspace') + const firstConversation = app.state().conversations[0] + assert.ok(firstConversation) + const sent = app.send({ + operationId: 'op-semantic-scoped-send', + conversationId: firstConversation.id, + branchId: firstConversation.activeBranchId, + text: 'scoped turn', + }) + await sent.completion + const secondConversation = await app.conversations.lifecycle.create({ + operationId: 'op-semantic-second-conversation', + title: 'Second conversation', + }) + const state = app.state() + const scopedGraph = queryGraph(state, { branchId: firstConversation.activeBranchId }) + assert.ok(scopedGraph.nodes.length > 0) + assert.equal( + scopedGraph.nodes.some((node) => node.id === secondConversation.id), + false, + ) + assert.throws( + () => queryGraph(state, { conversationId: 'conversation-does-not-exist' }), + (error: unknown) => + error instanceof SemanticQueryError && error.code === 'UNKNOWN_CONVERSATION', + ) + assert.throws( + () => + queryGraph(state, { + conversationId: firstConversation.id, + branchId: secondConversation.activeBranchId, + }), + (error: unknown) => + error instanceof SemanticQueryError && error.code === 'BRANCH_SCOPE_CONFLICT', + ) + assert.throws( + () => queryActivity(state, { runId: 'run-does-not-exist' }), + (error: unknown) => error instanceof SemanticQueryError && error.code === 'UNKNOWN_RUN', + ) + assert.throws( + () => queryDetails(state, { entityType: 'not-a-node', entityId: 'entity-1' }), + (error: unknown) => error instanceof SemanticQueryError && error.code === 'UNKNOWN_ENTITY_TYPE', + ) + assert.throws( + () => queryDetails(state, { entityType: 'run', entityId: 'run-does-not-exist' }), + (error: unknown) => error instanceof SemanticQueryError && error.code === 'UNKNOWN_ENTITY', + ) + + assert.deepEqual(scopedGraph, queryGraph(state, { branchId: firstConversation.activeBranchId })) + const runOnly = queryGraph(state, { query: 'type:run' }) + for (const runNode of runOnly.nodes) { + const unfiltered = queryGraph(state).nodes.find( + (node) => node.type === runNode.type && node.id === runNode.id, + ) + assert.equal(runNode.depth, unfiltered?.depth) + } + const reorderedState = { + ...state, + conversations: [...state.conversations].reverse(), + 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(), + supervisors: [...state.supervisors].reverse(), + workers: [...state.workers].reverse(), + bindings: [...state.bindings].reverse(), + graphNodes: [...state.graphNodes].reverse(), + graphEdges: [...state.graphEdges].reverse(), + } + assert.deepEqual(queryGraph(state), queryGraph(reorderedState)) + assert.deepEqual(queryActivity(state), queryActivity(reorderedState)) + const secretState = { + ...state, + runs: state.runs.map((run) => + run.id === sent.runId ? { ...run, error: 'api_key=super-secret-value' } : run, + ), + } + const details = queryDetails(secretState, { entityType: 'run', entityId: sent.runId }) + assert.equal(JSON.stringify(details).includes('super-secret-value'), false) + assert.equal(JSON.stringify(details).includes('api_key'), false) + + const journal = new MemoryJournal(new FixedClock()) + const first = createBraidApplication({ fixture: 'deterministic', journal }) + first.initialize('/workspace') + const restartRun = first.send({ operationId: 'op-semantic-restart', text: 'restart query' }) + await restartRun.completion + const restarted = createBraidApplication({ fixture: 'deterministic', journal }) + assert.deepEqual(queryGraph(first.state()), queryGraph(restarted.state())) + assert.deepEqual(queryActivity(first.state()), queryActivity(restarted.state())) + assert.deepEqual( + queryDetails(first.state(), { entityType: 'run', entityId: restartRun.runId }), + queryDetails(restarted.state(), { entityType: 'run', entityId: restartRun.runId }), + ) +}) + +test('semantic and protocol ordering is independent of the host locale', () => { + assert.equal(compareSemanticText('z', 'ä') < 0, true) + assert.equal(compareSemanticText('ä', 'z') > 0, true) + assert.equal(compareSemanticText('same', 'same'), 0) +}) diff --git a/test/run-admission-architecture.test.ts b/test/run-admission-architecture.test.ts new file mode 100644 index 0000000..ca603cb --- /dev/null +++ b/test/run-admission-architecture.test.ts @@ -0,0 +1,240 @@ +import assert from 'node:assert/strict' +import { access, readFile } from 'node:fs/promises' +import { join } from 'node:path' +import test from 'node:test' +import { type AgentProfile, defineAgentProfile } from '@tangle-network/agent-interface' +import { + admitRun, + admitRunAsync, + continueNative, + pendingAdmissionReceipt, + runEffectRequest, + sendRun, + sendRunAsync, + validateNativeProof, +} from '../src/app/run-admission.js' +import type { + AdmissionPort, + AsyncAdmissionPort, + NativeContinuationPort, +} from '../src/app/application-ports.js' +import type { RunExecutionSnapshot } from '../src/app/run-execution-snapshot.js' +import { initialState } from '../src/domain/state.js' +import { FixedClock } from '../src/ports/clock.js' +import { + DEFAULT_RUN_CAPABILITIES, + type ExecutionPort, + UNKNOWN_RUN_CAPABILITIES, +} from '../src/ports/execution.js' +import { SequenceIds } from '../src/ports/ids.js' + +const PROFILE = defineAgentProfile({ + name: 'Architecture test profile', + description: 'Tests the run admission module boundaries', + harness: 'pi', + model: { default: 'fixture/test', reasoningEffort: 'none' }, +}) + +const MODULES = [ + 'run-admission.ts', + 'run-admission-request.ts', + 'run-admission-validation.ts', + 'run-admission-receipt.ts', + 'run-admission-dispatch.ts', + 'run-admission-continuation.ts', +] as const + +async function repositoryRoot(): Promise { + const candidates = [new URL('../', import.meta.url), new URL('../../', import.meta.url)] + for (const candidate of candidates) { + try { + await access(new URL('package.json', candidate)) + return candidate.pathname + } catch {} + } + throw new Error('Could not locate the Braid repository root') +} + +function idleExecution(overrides: Partial = {}): ExecutionPort { + return { + capabilities: () => DEFAULT_RUN_CAPABILITIES, + async *streamTurn(): AsyncIterable { + yield* [] + }, + ...overrides, + } +} + +function admissionContext(execution: ExecutionPort): AdmissionPort { + const state = { ...initialState(PROFILE), workspace: '/workspace' } + return { + currentState: () => state, + execution, + ids: new SequenceIds(), + clock: new FixedClock(), + } as unknown as AdmissionPort +} + +function executionInput( + overrides: Partial<{ + readonly operationId: string + readonly runId: string + readonly text: string + readonly profile: Readonly + }> = {}, +) { + return { + operationId: overrides.operationId ?? 'op-architecture', + runId: overrides.runId ?? 'run-architecture', + text: overrides.text ?? 'test admission', + profile: overrides.profile ?? PROFILE, + signal: new AbortController().signal, + } +} + +function snapshotInput(): RunExecutionSnapshot { + return { + operationId: 'op-request', + text: 'request payload', + conversationId: 'conv-1', + branchId: 'branch-1', + profile: PROFILE, + connectionId: 'connection-1', + workspaceRoot: '/workspace', + sessionId: 'session-1', + sessionSource: 'continuation', + } +} + +test('the façade exports stable behavior without owning implementation', async () => { + const root = await repositoryRoot() + const sources = new Map( + await Promise.all( + MODULES.map( + async (name) => [name, await readFile(join(root, 'src/app', name), 'utf8')] as const, + ), + ), + ) + const façade = sources.get('run-admission.ts') ?? '' + assert.doesNotMatch(façade, /^import\s/mu) + assert.match(façade, /run-admission-request\.js/u) + assert.match(façade, /run-admission-validation\.js/u) + assert.match(façade, /run-admission-receipt\.js/u) + assert.match(façade, /run-admission-dispatch\.js/u) + assert.match(façade, /run-admission-continuation\.js/u) + + for (const [name, source] of sources) { + assert.ok(source.split('\n').length < 300, `${name} should stay below 300 lines`) + } + + const edges = new Map(MODULES.map((name) => [name, []])) + for (const [name, source] of sources) { + for (const match of source.matchAll(/from\s+['"](\.\/run-admission[^'"]+)['"]/gu)) { + const target = `${match[1]?.replace(/^\.\//u, '').replace(/\.js$/u, '')}.ts` + if (MODULES.includes(target as (typeof MODULES)[number])) edges.get(name)?.push(target) + } + } + assert.deepEqual(edges.get('run-admission.ts')?.sort(), [ + 'run-admission-continuation.ts', + 'run-admission-dispatch.ts', + 'run-admission-receipt.ts', + 'run-admission-request.ts', + 'run-admission-validation.ts', + ]) + for (const name of MODULES.slice(1)) { + assert.equal(edges.get(name)?.includes('run-admission.ts'), false, `${name} imports the façade`) + } + + const visiting = new Set() + const visited = new Set() + function visit(name: string): void { + if (visiting.has(name)) throw new Error(`run-admission module cycle includes ${name}`) + if (visited.has(name)) return + visiting.add(name) + for (const target of edges.get(name) ?? []) visit(target) + visiting.delete(name) + visited.add(name) + } + for (const name of MODULES) visit(name) +}) + +test('request construction keeps the complete execution snapshot boundary', () => { + assert.deepEqual(runEffectRequest(snapshotInput()), { + conversationId: 'conv-1', + branchId: 'branch-1', + text: 'request payload', + profile: PROFILE, + connectionId: 'connection-1', + workspaceRoot: '/workspace', + sessionId: 'session-1', + }) +}) + +test('sync and async admission preserve capability and pending receipt semantics', async () => { + const syncContext = admissionContext(idleExecution({ capabilities: { cancel: false } })) + const syncReceipt = admitRun( + syncContext, + executionInput(), + 'conv-1', + 'branch-1', + undefined, + 'turn-sync', + ) + assert.equal(syncReceipt.capabilities.controls.cancel, false) + assert.equal(syncReceipt.admissionStatus, 'admitted') + + const asyncContext = admissionContext( + idleExecution({ + admit: async () => ({ capabilities: DEFAULT_RUN_CAPABILITIES, warnings: ['ASYNC_WARNING'] }), + }), + ) as AsyncAdmissionPort + const asyncReceipt = await admitRunAsync( + asyncContext, + executionInput({ runId: 'run-async' }), + 'conv-1', + 'branch-1', + undefined, + 'turn-async', + ) + assert.deepEqual(asyncReceipt.warnings, ['ASYNC_WARNING']) + + const pending = pendingAdmissionReceipt(snapshotInput(), 'run-pending', 'turn-pending') + assert.equal(pending.admissionStatus, 'pending') + assert.deepEqual(pending.capabilities, UNKNOWN_RUN_CAPABILITIES) +}) + +test('native session reuse remains fail-closed at the validation and continuation boundaries', async () => { + const context = admissionContext(idleExecution()) + assert.throws( + () => + validateNativeProof(context, { + operationId: 'op-native', + text: 'reuse', + sessionId: 'caller-session', + profile: PROFILE, + }), + (error: unknown) => error instanceof Error && error.message.includes('valid native context'), + ) + + const continuationContext = { + ...context, + findRun: () => ({ + id: 'run-missing-session', + providerSessionId: undefined, + capabilities: DEFAULT_RUN_CAPABILITIES, + }), + send: () => { + throw new Error('send should not be reached') + }, + } as unknown as NativeContinuationPort + await assert.rejects( + () => continueNative(continuationContext, { operationId: 'op-native', text: 'reuse' }), + (error: unknown) => error instanceof Error && error.message.includes('prove a native session'), + ) +}) + +test('the public dispatch exports remain callable through the façade', () => { + assert.equal(typeof sendRun, 'function') + assert.equal(typeof sendRunAsync, 'function') + assert.equal(typeof continueNative, 'function') +}) diff --git a/test/sanitize.test.ts b/test/sanitize.test.ts index 9845be8..0a46568 100644 --- a/test/sanitize.test.ts +++ b/test/sanitize.test.ts @@ -1,6 +1,22 @@ import assert from 'node:assert/strict' import test from 'node:test' -import { sanitizeTerminalText } from '../src/views/shared/sanitize.js' +import { getCapabilities, setCapabilities } from '@earendil-works/pi-tui' +import { + redactBraidEvent, + redactStructuredValueWithNumericTelemetry, +} from '../src/domain/redaction.js' +import { + sanitizeClipboardText, + sanitizeDiff, + sanitizeImageAlt, + sanitizeMarkdown, + sanitizeNotification, + sanitizeTerminalText, + sanitizeTitle, + sanitizeUrl, +} from '../src/views/shared/sanitize.js' +import { SafeMarkdown } from '../src/views/tui/safe-markdown.js' +import { createBraidTheme } from '../src/views/tui/theme.js' test('terminal sanitizer removes CSI, OSC, carriage return, C1, and bidi controls', () => { const bidiControls = [ @@ -29,3 +45,113 @@ test('terminal sanitizer preserves printable Unicode, tabs, and newlines', () => const text = 'ASCII\t漢字 e\u0301 👩🏽‍💻\nمرحبا' assert.equal(sanitizeTerminalText(text), text) }) + +test('numeric token telemetry survives redaction while string tokens never do', () => { + const redacted = redactStructuredValueWithNumericTelemetry({ + inputTokens: 12, + output_tokens: 8, + model: { maxTokens: 4096 }, + accessToken: 'secret-canary', + poisoned: { inputTokens: 'secret-canary' }, + credentialConfigured: true, + }) as Record + assert.equal(redacted.inputTokens, 12) + assert.equal(redacted.output_tokens, 8) + assert.deepEqual(redacted.model, { maxTokens: 4096 }) + assert.equal(redacted.accessToken, '[redacted]') + assert.deepEqual(redacted.poisoned, { inputTokens: '[redacted]' }) + assert.equal(redacted.credentialConfigured, true) +}) + +test('conversation import events retain bounded histories beyond 256 records', () => { + const messages = Array.from({ length: 300 }, (_, index) => ({ + id: `message-${index}`, + inputTokens: index, + })) + const redacted = redactBraidEvent({ kind: 'conversation.imported', messages }) + assert.equal(redacted.messages.length, 300) + assert.equal(redacted.messages[299]?.inputTokens, 299) +}) + +test('every untrusted output surface strips terminal strings before rendering', () => { + const hostile = 'line\u001b]0;owned\u0007\nnext\u202e.txt' + for (const sanitizer of [ + sanitizeMarkdown, + sanitizeDiff, + sanitizeClipboardText, + sanitizeImageAlt, + ]) { + const result = sanitizer(hostile) + assert.equal(result.includes('\u001b'), false) + assert.equal(result.includes('\u202e'), false) + } + assert.equal(sanitizeTitle(hostile), 'line next.txt') + assert.equal(sanitizeNotification(hostile), 'line next.txt') +}) + +test('links fail closed for non-web schemes and embedded credentials', () => { + assert.equal(sanitizeUrl('https://example.com/path'), 'https://example.com/path') + assert.equal(sanitizeUrl('file:///etc/passwd'), undefined) + assert.equal(sanitizeUrl('javascript:alert(1)'), undefined) + assert.equal(sanitizeUrl('https://user:password@example.com'), undefined) + assert.equal(sanitizeUrl('https://example.com/?token=CANARY'), undefined) + assert.equal(sanitizeUrl('https://example.com/#access_token=CANARY'), undefined) +}) + +test('markdown hides unsafe destinations and secret-bearing URLs in both terminal modes', () => { + const previous = getCapabilities() + const theme = createBraidTheme({ + color: 'truecolor', + environment: { COLORTERM: 'truecolor' }, + }) + const markdown = sanitizeMarkdown( + '[safe](https://example.com/docs) [secret](https://user:CANARY@example.com/?token=CANARY) [script](javascript:alert(1)) [file](file:///etc/passwd) `https://example.com/?api_key=CANARY`', + ) + try { + for (const hyperlinks of [false, true]) { + setCapabilities({ images: null, trueColor: false, hyperlinks }) + const rendered = new SafeMarkdown(markdown, 0, 0, theme.markdown, undefined, undefined, { + allowHyperlinks: theme.terminalMetadata, + }) + .render(240) + .join('\n') + assert.equal(rendered.includes('CANARY'), false) + assert.equal(rendered.includes('javascript:'), false) + assert.equal(rendered.includes('file:///'), false) + assert.match(rendered, /safe/u) + if (hyperlinks) assert.equal(rendered.includes('\u001b]8;;https://example.com/docs'), true) + } + } finally { + setCapabilities(previous) + } +}) + +test('no-color and reduced-motion markdown replaces hyperlink OSC with visible URLs', () => { + const previous = getCapabilities() + const theme = createBraidTheme({ colors: false, reducedMotion: true }) + try { + setCapabilities({ images: null, trueColor: false, hyperlinks: true }) + const rendered = new SafeMarkdown( + sanitizeMarkdown('[safe](https://example.com/docs)'), + 0, + 0, + theme.markdown, + undefined, + undefined, + { allowHyperlinks: theme.terminalMetadata }, + ) + .render(240) + .join('\n') + assert.equal(rendered.includes('\u001b]'), false) + assert.match(rendered, /safe/u) + assert.match(rendered, /https:\/\/example\.com\/docs/u) + } finally { + setCapabilities(previous) + } +}) + +test('bounded titles and notifications do not split surrogate pairs', () => { + const text = `${'😀'.repeat(59)}a😀` + assert.equal(sanitizeTitle(text).endsWith('😀'), true) + assert.equal(sanitizeNotification(text).endsWith('😀'), true) +}) diff --git a/test/scripts.test.ts b/test/scripts.test.ts new file mode 100644 index 0000000..bd4764e --- /dev/null +++ b/test/scripts.test.ts @@ -0,0 +1,328 @@ +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { readFile } from 'node:fs/promises' +import test from 'node:test' + +// @ts-expect-error The release scripts are intentionally JavaScript entry points. +const { CHECK_CATEGORIES, REQUIRED_CHECKS } = await import('../scripts/release-check-catalog.mjs') +// @ts-expect-error The release scripts are intentionally JavaScript entry points. +const { canonicalJson } = await import('../scripts/release-evidence.mjs') +// @ts-expect-error The release scripts are intentionally JavaScript entry points. +const visualCaptureSupport = await import('../scripts/capture-visual-support.mjs') +const { assertFlowFrameIntegrity, captureProvenance } = visualCaptureSupport +// @ts-expect-error The release scripts are intentionally JavaScript entry points. +const { assertAccessibleTerminalOutput } = await import('../scripts/accessibility-output.mjs') +// @ts-expect-error The release scripts are intentionally JavaScript entry points. +const { nativeInstallEnvironment } = await import('../scripts/native-install-environment.mjs') +// @ts-expect-error The release scripts are intentionally JavaScript entry points. +const { prepareEvalCandidate } = await import('../scripts/eval/candidate.mjs') + +const packageJson = JSON.parse( + await readFile(new URL('../../package.json', import.meta.url), 'utf8'), +) + +test('W5 exposes stable checks for every requested release surface', () => { + const required = [ + 'test:unit', + 'test:contract', + 'test:coordination', + 'test:rpc', + 'test:virtual-terminal', + 'test:pty', + 'test:storage', + 'test:crash', + 'test:security', + 'test:performance', + 'test:live', + 'test:install', + 'test:capture', + 'check:release', + ] + for (const script of required) assert.equal(typeof packageJson.scripts[script], 'string', script) +}) + +test('the scoped test runner rejects an unregistered scope instead of silently running the wrong suite', async () => { + const source = await readFile(new URL('../../scripts/run-tests.mjs', import.meta.url), 'utf8') + assert.match(source, /No compiled tests registered for scope/u) + assert.match(source, /scopeFiles/u) +}) + +test('compiled tests receive the JavaScript helpers imported from scripts', async () => { + const source = await readFile(new URL('../../scripts/clean-tests.mjs', import.meta.url), 'utf8') + assert.match(source, /\.test-dist\/scripts/u) + assert.match(source, /entry\.name\.endsWith\('\.mjs'\)/u) +}) + +test('clean package installs cannot inherit disabled native dependency builds', () => { + const environment = nativeInstallEnvironment({ + NPM_CONFIG_IGNORE_SCRIPTS: 'true', + npm_config_ignore_scripts: 'true', + KEEP_ME: 'yes', + }) + assert.equal(environment.npm_config_ignore_scripts, 'false') + assert.equal(environment.NPM_CONFIG_IGNORE_SCRIPTS, undefined) + assert.equal(environment.KEEP_ME, 'yes') +}) + +test('every scoped package alias forwards its declared file set', () => { + assert.equal(packageJson.scripts.test, 'node scripts/test.mjs') + const aliases = { + 'test:unit': [ + 'application.test.js', + 'cli-startup.test.js', + 'conversations.test.js', + 'coordination.test.js', + 'domain-ids.test.js', + 'domain-invariants.test.js', + 'domain-reducer.test.js', + 'domain-text.test.js', + 'eval.test.js', + 'reducer.test.js', + 'sanitize.test.js', + 'scripts.test.js', + 'w6-ui.test.js', + ], + 'test:contract': [ + 'application.test.js', + 'cli-bridge-profile-contract.test.js', + 'conversations.test.js', + 'coordination.test.js', + 'domain-invariants.test.js', + 'domain-reducer.test.js', + 'reducer.test.js', + 'scripts.test.js', + 'w6-contract.test.js', + ], + 'test:coordination': [ + 'analysis-durable.test.js', + 'coordination.test.js', + 'effect-admission.test.js', + 'run-admission-architecture.test.js', + ], + 'test:rpc': ['profile-connection-actions.test.js', 'rpc.test.js', 'w6-contract.test.js'], + 'test:virtual-terminal': [ + 'configuration-product-flow.test.js', + 'keyboard.test.js', + 'terminal-responsive.test.js', + 'tui-autocomplete.test.js', + 'tui-conversations.test.js', + 'tui-core-workflows.test.js', + 'tui.test.js', + 'w6-ui.test.js', + ], + 'test:storage': [ + 'conversation-storage.test.js', + 'coordination.test.js', + 'domain-reducer.test.js', + 'effect-admission.test.js', + 'storage-crash.test.js', + 'storage-snapshots.test.js', + 'storage.test.js', + ], + 'test:crash': [ + 'conversation-storage.test.js', + 'profile-save-recovery.test.js', + 'storage-crash.test.js', + 'storage.test.js', + ], + 'test:security': [ + 'cli-startup.test.js', + 'configuration-product-flow.test.js', + 'conversations.test.js', + 'coordination.test.js', + 'profile-connection-actions.test.js', + 'profile-save-recovery.test.js', + 'sanitize.test.js', + 'security.test.js', + 'storage-snapshots.test.js', + 'storage.test.js', + 'tui-core-workflows.test.js', + 'w6-contract.test.js', + ], + 'test:performance': [ + 'coordination.test.js', + 'performance.test.js', + 'reducer.test.js', + 'storage-performance.test.js', + ], + } + const criticalRegressionFiles = [ + 'analysis-durable.test.js', + 'cli-bridge-profile-contract.test.js', + 'cli-startup.test.js', + 'configuration-product-flow.test.js', + 'profile-connection-actions.test.js', + 'profile-save-recovery.test.js', + 'run-admission-architecture.test.js', + 'storage-snapshots.test.js', + 'terminal-responsive.test.js', + 'tui-autocomplete.test.js', + 'tui-conversations.test.js', + 'tui-core-workflows.test.js', + ] + for (const [alias, expected] of Object.entries(aliases)) { + assert.deepEqual(expected, [...expected].sort(), `${alias} must stay sorted`) + assert.equal(new Set(expected).size, expected.length, `${alias} must not contain duplicates`) + } + const registeredCriticalFiles = new Set(Object.values(aliases).flat()) + for (const file of criticalRegressionFiles) { + assert.equal( + registeredCriticalFiles.has(file), + true, + `${file} is missing from every stable scope`, + ) + } + for (const [alias, expected] of Object.entries(aliases)) { + const scope = packageJson.scripts[alias].match(/--scope\s+([a-z-]+)/u)?.[1] + assert.equal(scope, alias.slice('test:'.length), alias) + const output = execFileSync( + process.execPath, + ['scripts/test.mjs', '--scope', scope, '--list'], + { + cwd: process.cwd(), + encoding: 'utf8', + }, + ) + assert.deepEqual(JSON.parse(output), [...expected].sort(), alias) + } +}) + +test('the release catalog exactly covers every stable verification command', async () => { + const verification = await readFile( + new URL('../../docs/08-verification.md', import.meta.url), + 'utf8', + ) + const documented = new Map( + [...verification.matchAll(/^\| `([^`]+)` \| `(pnpm [^`]+)` \|/gmu)].map((match) => [ + match[1], + match[2], + ]), + ) + const catalog = new Map([...REQUIRED_CHECKS].map(([id, value]) => [id, value.command])) + assert.deepEqual([...catalog], [...documented]) + for (const [id, value] of REQUIRED_CHECKS) { + assert(CHECK_CATEGORIES.has(value.category), `${id} has an unregistered category`) + assert(value.command.startsWith('pnpm '), `${id} is not a pnpm command`) + const script = value.command.slice('pnpm '.length) + assert.equal(typeof packageJson.scripts[script], 'string', `${id} exposes no ${script}`) + } +}) + +test('release signatures use locale-independent canonical key ordering', () => { + assert.equal(canonicalJson({ z: 1, a: 2, A: 3, aa: 4 }), '{"A":3,"a":2,"aa":4,"z":1}') + const source = execFileSync( + process.execPath, + [ + '--input-type=module', + '-e', + "import { canonicalJson } from './scripts/release-evidence.mjs'; process.stdout.write(canonicalJson({z:1,a:2,A:3,aa:4}))", + ], + { cwd: process.cwd(), encoding: 'utf8' }, + ) + assert.equal(source, '{"A":3,"a":2,"aa":4,"z":1}') +}) + +test('packed-process proof includes all reference sizes, accessibility flags, and cleanup assertions', async () => { + const source = await readFile( + new URL('../../scripts/verify-package.mjs', import.meta.url), + 'utf8', + ) + for (const dimensions of ['40', '80', '120', '200']) + assert.match(source, new RegExp(`columns: ${dimensions}`, 'u')) + assert.match(source, /highContrast: true/u) + assert.match(source, /reducedMotion: true/u) + assert.match(source, /packed package contains unexpected/u) + assert.match(source, /packed binary is not executable/u) + const rpcPacked = await readFile( + new URL('../../scripts/test-rpc-packed.mjs', import.meta.url), + 'utf8', + ) + const deterministicRpc = await readFile( + new URL('../../scripts/packed-rpc/deterministic.mjs', import.meta.url), + 'utf8', + ) + assert.match(rpcPacked, /await packed\.cleanup\(\)/u) + assert.match(deterministicRpc, /await rm\(journalPath/u) +}) + +test('accessibility proof rejects terminal metadata instead of allowlisting it', () => { + const proofSource = readFile(new URL('../../scripts/verify-package.mjs', import.meta.url), 'utf8') + assert.doesNotThrow(() => assertAccessibleTerminalOutput('plain accessibility frame')) + for (const metadata of [ + '\u001b]0;Braid — /workspace\u0007', + '\u001b]8;;https://example.com/docs\u001b\\docs\u001b]8;;\u001b\\', + '\u009d0;Braid — /workspace\u0007', + ]) { + assert.throws(() => assertAccessibleTerminalOutput(metadata), /terminal metadata/u) + } + return proofSource.then((source) => { + assert.match(source, /assertAccessibleTerminalOutput\(accessibility\.output\)/u) + assert.doesNotMatch(source, /OSC_SEQUENCE/u) + }) +}) + +test('visual capture provenance reports installed renderer versions instead of stale constants', async () => { + const provenance = await captureProvenance() + const dependencies = JSON.parse( + await readFile(new URL('../../package.json', import.meta.url), 'utf8'), + ) + assert.equal( + provenance.renderer.package, + `@earendil-works/pi-tui@${dependencies.dependencies['@earendil-works/pi-tui']}`, + ) + assert.equal(provenance.renderer.pty, `node-pty@${dependencies.devDependencies['node-pty']}`) + assert.equal( + provenance.renderer.emulator, + `@xterm/headless@${dependencies.devDependencies['@xterm/headless']}`, + ) +}) + +test('visual flow integrity rejects the measured corrupted-frame error', () => { + assert.equal(assertFlowFrameIntegrity('1542 (0.0235294)', 'clean frame'), 0.0235294) + assert.throws( + () => assertFlowFrameIntegrity('2827 (0.0431373)', 'corrupted frame'), + /corrupted frame differs from its source frame/u, + ) + assert.throws(() => assertFlowFrameIntegrity('', 'missing frame'), /missing metric/u) +}) + +test('semantic evaluation preserves an explicitly pinned installed candidate', async () => { + const environment = { + BRAID_EVAL_PACKAGE_ROOT: '/candidate/package', + BRAID_EVAL_TARBALL_PATH: '/candidate/braid.tgz', + } + const candidate = await prepareEvalCandidate('/repository-not-used', environment) + assert.equal(candidate.generated, false) + assert.equal(candidate.environment, environment) + await candidate.cleanup() +}) + +test('protected live and semantic checks stay unavailable instead of becoming local passes', () => { + for (const scope of [ + 'live-bridge', + 'live-tangle', + 'live-supervisor', + 'live-analysis', + 'semantic-eval', + ]) { + let output = '' + try { + execFileSync(process.execPath, ['scripts/live-required.mjs', scope], { + cwd: process.cwd(), + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }) + assert.fail(`${scope} unexpectedly succeeded without protected credentials`) + } catch (error) { + const result = error as { + readonly stdout?: string + readonly stderr?: string + readonly status?: number + } + output = `${result.stdout ?? ''}${result.stderr ?? ''}` + assert.equal(result.status, 2, `${scope} returned an untyped failure`) + } + assert.match(output, /requires protected live-provider credentials/u) + assert.doesNotMatch(output, /pass(?:ed)?|success/iu) + } +}) diff --git a/test/security.test.ts b/test/security.test.ts new file mode 100644 index 0000000..dab97f3 --- /dev/null +++ b/test/security.test.ts @@ -0,0 +1,853 @@ +import assert from 'node:assert/strict' +import { closeSync, constants, lstatSync, mkdtempSync, openSync, readFileSync } from 'node:fs' +import { + chmod, + link, + mkdir, + mkdtemp, + readFile, + rename, + rm, + stat, + symlink, + writeFile, +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, parse, resolve, sep } from 'node:path' +import test from 'node:test' +import { Worker } from 'node:worker_threads' +// @ts-expect-error The scanner is a JavaScript release helper without a product declaration. +import { assertNoSecretArtifacts, scanSecretArtifacts } from '../scripts/scan-secret-artifacts.mjs' +import { + closeHeadlessKeyFile, + openHeadlessKeyFile, + readHeadlessKey, + rejectEnvironmentKeySource, +} from '../src/adapters/credentials/headless-key.js' +import { MemoryCredentialStore } from '../src/adapters/credentials/memory.js' +import { + LinuxSecretServiceCredentialStore, + type NativeKeyringEntry, + type NativeKeyringEntryFactory, +} from '../src/adapters/credentials/os.js' +import { + acquireJournalLock, + assertNoSymlinkPath, + assertSafeDirectory, + ensurePrivateDirectory, + ensurePrivateFile, + fsyncDirectory, + readNoFollow, + releaseJournalLock, + replacePrivateFile, + SafeFileError, + writePrivateFile, +} from '../src/adapters/persistence/safe-file.js' +import { componentPath, safePath } from '../src/adapters/persistence/safe-file-descriptor.js' +import { openSqliteStorage } from '../src/adapters/storage/sqlite.js' +import { assertPersistablePayload } from '../src/adapters/storage/sqlite-crypto.js' +import { StorageError } from '../src/adapters/storage/sqlite-errors.js' +import { prepareConversationImport } from '../src/app/conversation-import-document.js' +import { canonicalDigest } from '../src/domain/canonical.js' +import { TerminalControlSanitizer } from '../src/domain/terminal-sanitizer.js' +import { credentialRef } from '../src/ports/credentials.js' + +test('terminal control sanitization remains safe when hostile sequences split across chunks', () => { + const sanitizer = new TerminalControlSanitizer() + assert.equal(sanitizer.push('before\u001b]0;owned'), 'before') + assert.equal(sanitizer.push('\u0007after'), 'after') + assert.equal( + sanitizer.push(`before\u001bP${'x'.repeat(5_000)}visible`), + `before${'x'.repeat(905)}visible`, + ) + assert.equal(sanitizer.finish(), '') +}) + +test('generated artifact secret scanning catches leaks, accepts redacted output, and rejects links', async () => { + const root = await mkdtemp(join(tmpdir(), 'braid-secret-artifacts-')) + const canary = 'W12_SECRET_ARTIFACT_CANARY' + try { + await writeFile(join(root, 'safe.log'), 'redacted credential output\n') + await assertNoSecretArtifacts(root, [canary]) + await writeFile(join(root, 'leak.log'), `unexpected ${canary}\n`) + assert.deepEqual(await scanSecretArtifacts(root, [canary]), [ + { path: 'leak.log', canaryBytes: Buffer.byteLength(canary) }, + ]) + await assert.rejects(() => assertNoSecretArtifacts(root, [canary]), /Secret canary found/u) + await rm(join(root, 'leak.log')) + await symlink(join(root, 'safe.log'), join(root, 'linked.log')) + await assert.rejects(() => scanSecretArtifacts(root, [canary]), /refuses symlink/u) + } finally { + await rm(root, { recursive: true, force: true }) + } +}) + +test('private artifact writes are mode-600, no-clobber, and symlink-safe', async () => { + const root = await mkdtemp(join(tmpdir(), 'braid-private-artifact-')) + try { + if (process.platform !== 'linux' && process.platform !== 'darwin') { + assert.throws( + () => writePrivateFile(join(root, 'state.json'), 'unsupported'), + (error: unknown) => + error instanceof SafeFileError && error.code === 'SAFE_FILE_PATH_RACE_UNSUPPORTED', + ) + return + } + const target = join(root, 'state.json') + writePrivateFile(target, 'first') + assert.equal((await readFile(target)).toString(), 'first') + assert.equal((await stat(target)).mode & 0o777, 0o600) + assert.throws(() => writePrivateFile(target, 'second'), /EEXIST|exist/u) + assert.equal((await readFile(target)).toString(), 'first') + const victim = join(root, 'victim.json') + const linkPath = join(root, 'link.json') + await writeFile(victim, 'unchanged') + await symlink(victim, linkPath) + assert.throws(() => writePrivateFile(linkPath, 'overwrite'), /symbolic|EEXIST|exist/u) + assert.throws( + () => readNoFollow(linkPath, 128), + (error: unknown) => error instanceof SafeFileError && error.code === 'SAFE_FILE_SYMLINK', + ) + assert.equal((await readFile(victim)).toString(), 'unchanged') + + const directory = join(root, 'directory') + await mkdir(directory) + assert.throws( + () => readNoFollow(directory, 128), + (error: unknown) => error instanceof SafeFileError && error.code === 'SAFE_FILE_NOT_REGULAR', + ) + assert.throws( + () => writePrivateFile(directory, 'not a directory replacement'), + (error: unknown) => error instanceof SafeFileError && error.code === 'SAFE_FILE_NOT_REGULAR', + ) + + const permissionsTarget = join(root, 'permissions.json') + writePrivateFile(permissionsTarget, 'repair permissions') + await chmod(permissionsTarget, 0o644) + ensurePrivateFile(permissionsTarget) + assert.equal((await stat(permissionsTarget)).mode & 0o777, 0o600) + } finally { + await rm(root, { recursive: true, force: true }) + } +}) + +test('safe component paths preserve anchors and round-trip across supported platforms', () => { + if (process.platform !== 'linux' && process.platform !== 'darwin') { + assert.throws( + () => safePath(join(tmpdir(), 'braid-safe-path', 'parent', 'leaf')), + (error: unknown) => + error instanceof SafeFileError && error.code === 'SAFE_FILE_PATH_RACE_UNSUPPORTED', + ) + return + } + if (sep === '/') { + assert.equal( + componentPath({ absolute: '/tmp/parent', root: '/', components: ['tmp', 'parent'] }, 2), + '/tmp/parent', + ) + } + const parsed = safePath(join(tmpdir(), 'braid-safe-path', 'parent', 'leaf')) + assert.equal(componentPath(parsed, parsed.components.length), parsed.absolute) + assert.equal(componentPath(parsed, 0), parsed.root) +}) + +const PARENT_SWAP_WORKER = ` + const { parentPort, workerData } = require('node:worker_threads') + const { renameSync, symlinkSync, unlinkSync } = require('node:fs') + let checksum = 0 + parentPort.postMessage('ready') + parentPort.once('message', (message) => { + if (message !== 'start') return + for (let index = 0; index < workerData.iterations; index += 1) { + renameSync(workerData.active, workerData.parked) + for (;;) { + try { + symlinkSync(workerData.evil, workerData.active) + break + } catch (error) { + if (error.code !== 'EEXIST') throw error + try { + renameSync( + workerData.active, + workerData.active + '.recreated.' + String(index), + ) + } catch (moveError) { + if (moveError.code !== 'ENOENT') throw moveError + } + } + } + for (let spin = 0; spin < 512; spin += 1) checksum = (checksum + spin) & 0xffff + unlinkSync(workerData.active) + renameSync(workerData.parked, workerData.active) + } + if (checksum === -1) throw new Error('unreachable') + parentPort.postMessage('done') + }) +` + +function legacyReadNoFollow(path: string): Buffer | undefined { + const absolute = resolve(path) + const parsed = parse(absolute) + let current = parsed.root + try { + for (const component of absolute.slice(parsed.root.length).split(sep).filter(Boolean)) { + current = join(current, component) + if (lstatSync(current).isSymbolicLink()) { + const error = new Error(`legacy symlink: ${absolute}`) as NodeJS.ErrnoException + error.code = 'ELOOP' + throw error + } + } + const handle = openSync(absolute, constants.O_RDONLY | constants.O_NOFOLLOW) + try { + return readFileSync(handle) + } finally { + closeSync(handle) + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined + throw error + } +} + +function workerMessage(worker: Worker, expected: string): Promise { + return new Promise((resolveMessage, reject) => { + const onError = (error: Error) => { + worker.off('message', onMessage) + reject(error) + } + const onMessage = (message: unknown) => { + if (message !== expected) return + worker.off('error', onError) + resolveMessage() + } + worker.once('error', onError) + worker.on('message', onMessage) + }) +} + +interface ParentSwapRaceResult { + readonly attempts: number + readonly safeReads: number + readonly evilReads: number + readonly rejected: number +} + +interface ParentSwapRacePaths { + readonly active: string + readonly parked: string + readonly evil: string + readonly source: string +} + +async function runParentSwapRace( + root: string, + label: string, + read: (path: string, paths: ParentSwapRacePaths) => 'safe' | 'evil', + iterations = 50_000, + prepare?: (paths: ParentSwapRacePaths) => Promise, +): Promise { + const active = join(root, `${label}-active`) + const parked = join(root, `${label}-parked`) + const evil = join(root, `${label}-evil`) + const relativeSource = join('one', 'two', 'three', 'conversation.json') + const activeSource = join(active, relativeSource) + const evilSource = join(evil, relativeSource) + await mkdir(join(active, 'one', 'two', 'three'), { recursive: true }) + await mkdir(join(evil, 'one', 'two', 'three'), { recursive: true }) + await writeFile(activeSource, conversationFixture('safe')) + await writeFile(evilSource, conversationFixture('evil')) + const paths = { active, parked, evil, source: activeSource } + await prepare?.(paths) + + const worker = new Worker(PARENT_SWAP_WORKER, { + eval: true, + workerData: { active, parked, evil, iterations }, + }) + let safeReads = 0 + let evilReads = 0 + let rejected = 0 + try { + await workerMessage(worker, 'ready') + worker.postMessage('start') + for (let attempt = 0; attempt < iterations; attempt += 1) { + try { + const result = read(activeSource, paths) + if (result === 'evil') evilReads += 1 + else safeReads += 1 + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + if ( + code === 'ENOENT' || + code === 'ELOOP' || + code === 'SAFE_FILE_SYMLINK' || + code === 'IMPORT_NOT_FOUND' || + code === 'IMPORT_SOURCE_UNSAFE' + ) { + rejected += 1 + continue + } + throw error + } + } + await workerMessage(worker, 'done') + } finally { + await worker.terminate() + await rm(parked, { recursive: true, force: true }) + } + return { attempts: iterations, safeReads, evilReads, rejected } +} + +function conversationFixture(conversationId: 'safe' | 'evil'): string { + const content = { + conversation: { id: `conversation-${conversationId}` }, + branches: [], + messages: [], + messageParts: [], + turns: [], + runs: [], + analyses: [], + graphNodes: [], + graphEdges: [], + feedbackDecisions: [], + } + return JSON.stringify({ + schemaVersion: 2, + format: 'braid-conversation', + exportedAt: '2026-08-03T00:00:00.000Z', + conversationId, + content, + contentDigest: canonicalDigest(content), + redacted: true, + externalControlsDisabled: true, + }) +} + +test('parent directory swaps cannot make conversation import read the evil tree', { + timeout: 120_000, +}, async (t) => { + if (process.platform !== 'linux') return + const root = await mkdtemp(join(tmpdir(), 'braid-parent-race-')) + try { + const legacy = await runParentSwapRace(root, 'legacy', (path) => { + const bytes = legacyReadNoFollow(path) + if (bytes === undefined) throw Object.assign(new Error('missing'), { code: 'ENOENT' }) + return bytes.includes(Buffer.from('"conversationId":"evil"')) ? 'evil' : 'safe' + }) + const fixed = await runParentSwapRace(root, 'fixed', (path) => + prepareConversationImport({ source: path }).document.conversationId === 'evil' + ? 'evil' + : 'safe', + ) + t.diagnostic(`legacy evilReads:${legacy.evilReads}/${legacy.attempts}`) + t.diagnostic(`fixed evilReads:${fixed.evilReads}/${fixed.attempts}`) + assert.ok(legacy.evilReads > 0, JSON.stringify(legacy)) + assert.equal(fixed.evilReads, 0, JSON.stringify(fixed)) + assert.equal(fixed.attempts, fixed.safeReads + fixed.rejected) + + const evilHelperDirectory = join(root, 'helpers-evil', 'one', 'two', 'three') + const helperRace = await runParentSwapRace( + root, + 'helpers', + (_path, paths) => { + const nested = join(paths.active, 'one', 'two', 'three') + const operations: readonly (() => unknown)[] = [ + () => assertNoSymlinkPath(join(nested, 'conversation.json')), + () => readNoFollow(join(nested, 'conversation.json'), 2_048), + () => ensurePrivateFile(join(nested, 'mode.txt')), + () => assertSafeDirectory(nested), + () => fsyncDirectory(nested), + () => ensurePrivateDirectory(join(nested, 'created')), + () => writePrivateFile(join(nested, 'write.txt'), 'safe write'), + () => + replacePrivateFile(join(nested, 'replace.txt'), 'safe replace', { overwrite: true }), + () => { + const handle = acquireJournalLock(join(nested, 'journal.lock')) + releaseJournalLock(join(nested, 'journal.lock'), handle) + }, + ] + for (const operation of operations) { + try { + operation() + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + if ( + code !== 'ENOENT' && + code !== 'ELOOP' && + code !== 'EEXIST' && + code !== 'SAFE_FILE_SYMLINK' + ) { + throw error + } + } + } + return 'safe' + }, + 200, + async (paths) => { + const nested = join(paths.active, 'one', 'two', 'three') + const evilNested = join(paths.evil, 'one', 'two', 'three') + await writeFile(join(nested, 'mode.txt'), 'safe mode') + await writeFile(join(nested, 'replace.txt'), 'safe replace baseline') + await writeFile(join(evilNested, 'mode.txt'), 'evil mode') + await writeFile(join(evilNested, 'replace.txt'), 'evil replace baseline') + await chmod(join(evilNested, 'mode.txt'), 0o644) + }, + ) + t.diagnostic(`fixed helper operations:${helperRace.attempts}`) + assert.equal( + await readFile(join(evilHelperDirectory, 'conversation.json'), 'utf8').then((value) => + value.includes('"conversationId":"evil"'), + ), + true, + ) + assert.equal( + await readFile(join(evilHelperDirectory, 'replace.txt'), 'utf8'), + 'evil replace baseline', + ) + assert.equal((await stat(join(evilHelperDirectory, 'mode.txt'))).mode & 0o777, 0o644) + await assert.rejects(() => stat(join(evilHelperDirectory, 'created')), { code: 'ENOENT' }) + await assert.rejects(() => stat(join(evilHelperDirectory, 'write.txt')), { code: 'ENOENT' }) + await assert.rejects(() => stat(join(evilHelperDirectory, 'journal.lock')), { code: 'ENOENT' }) + } finally { + await rm(root, { recursive: true, force: true }) + } +}) + +test('every safe-file operation rejects a swapped parent and unsupported platforms explain refusal', async () => { + const root = await mkdtemp(join(tmpdir(), 'braid-safe-file-parent-')) + try { + if (process.platform !== 'linux' && process.platform !== 'darwin') { + assert.throws( + () => readNoFollow(join(root, 'missing'), 128), + (error: unknown) => + error instanceof SafeFileError && error.code === 'SAFE_FILE_PATH_RACE_UNSUPPORTED', + ) + return + } + const active = join(root, 'active') + const parked = join(root, 'parked') + const evil = join(root, 'evil') + await mkdir(join(active, 'nested'), { recursive: true }) + await mkdir(evil, { recursive: true }) + await writeFile(join(active, 'nested', 'read.txt'), 'safe') + await writeFile(join(evil, 'read.txt'), 'evil') + await rename(active, parked) + await symlink(evil, active) + + const checks: readonly [string, () => unknown][] = [ + ['assertNoSymlinkPath', () => assertNoSymlinkPath(join(active, 'nested', 'read.txt'))], + ['readNoFollow', () => readNoFollow(join(active, 'nested', 'read.txt'), 128)], + ['ensurePrivateFile', () => ensurePrivateFile(join(active, 'nested', 'read.txt'))], + ['assertSafeDirectory', () => assertSafeDirectory(active)], + ['fsyncDirectory', () => fsyncDirectory(active)], + ['ensurePrivateDirectory', () => ensurePrivateDirectory(join(active, 'new'))], + ['writePrivateFile', () => writePrivateFile(join(active, 'write.txt'), 'blocked')], + [ + 'replacePrivateFile', + () => replacePrivateFile(join(active, 'replace.txt'), 'blocked', { overwrite: true }), + ], + ['acquireJournalLock', () => acquireJournalLock(join(active, 'journal.lock'))], + ] + for (const [name, operation] of checks) { + assert.throws( + operation, + (error: unknown) => error instanceof SafeFileError && error.code === 'SAFE_FILE_SYMLINK', + name, + ) + } + } finally { + await rm(root, { recursive: true, force: true }) + } +}) + +test('headless key files require an external mode-0600 regular file and reject environment input', async () => { + const root = await mkdtemp(join(tmpdir(), 'braid-headless-key-')) + const workspace = join(root, 'workspace') + const external = join(root, 'keys') + const keyPath = join(external, 'database.key') + await mkdir(workspace, { recursive: true, mode: 0o700 }) + await mkdir(external, { recursive: true, mode: 0o700 }) + const key = Buffer.alloc(32, 7) + await writeFile(keyPath, key, { mode: 0o600 }) + await chmod(keyPath, 0o600) + + assert.deepEqual(readHeadlessKey({ type: 'file', path: keyPath, workspaceRoot: workspace }), key) + const fd = openHeadlessKeyFile(keyPath, workspace) + try { + assert.deepEqual(readHeadlessKey({ type: 'fd', fd, workspaceRoot: workspace }), key) + assert.throws( + () => + readHeadlessKey({ + type: 'fd', + fd, + } as Parameters[0]), + /workspace root/u, + ) + } finally { + closeHeadlessKeyFile(fd) + } + + assert.throws( + () => + readHeadlessKey({ + type: 'file', + path: join(workspace, 'database.key'), + workspaceRoot: workspace, + }), + /Cannot inspect key path|outside the workspace|unreadable/u, + ) + await writeFile(join(workspace, 'database.key'), key, { mode: 0o600 }) + assert.throws( + () => + readHeadlessKey({ + type: 'file', + path: join(workspace, 'database.key'), + workspaceRoot: workspace, + }), + /outside the workspace/u, + ) + if (process.platform !== 'win32') { + await chmod(keyPath, 0o644) + assert.throws( + () => readHeadlessKey({ type: 'file', path: keyPath, workspaceRoot: workspace }), + /0600/u, + ) + await chmod(keyPath, 0o600) + const linkPath = join(external, 'database-link.key') + await symlink(keyPath, linkPath) + assert.throws( + () => readHeadlessKey({ type: 'file', path: linkPath, workspaceRoot: workspace }), + /Symlink/u, + ) + } + assert.throws( + () => rejectEnvironmentKeySource('BRAID_KEY'), + /Environment key sources are rejected/u, + ) + key.fill(0) +}) + +test('headless key reads stay on one protected descriptor and reject hard links and oversized input', async () => { + const root = await mkdtemp(join(tmpdir(), 'braid-headless-key-race-')) + const workspace = join(root, 'workspace') + const external = join(root, 'keys') + const keyPath = join(external, 'database.key') + await mkdir(workspace, { recursive: true, mode: 0o700 }) + await mkdir(external, { recursive: true, mode: 0o700 }) + const original = Buffer.alloc(32, 11) + const replacement = Buffer.alloc(32, 12) + await writeFile(keyPath, original, { mode: 0o600 }) + await chmod(keyPath, 0o600) + + if (process.platform !== 'win32') { + const openedPath = join(external, 'opened.key') + const fd = openHeadlessKeyFile(keyPath, workspace) + try { + await rename(keyPath, openedPath) + await writeFile(keyPath, replacement, { mode: 0o600 }) + assert.deepEqual(readHeadlessKey({ type: 'fd', fd, workspaceRoot: workspace }), original) + } finally { + closeHeadlessKeyFile(fd) + } + + const hardLinkPath = join(external, 'database-hard-link.key') + await link(keyPath, hardLinkPath) + assert.throws( + () => readHeadlessKey({ type: 'file', path: keyPath, workspaceRoot: workspace }), + /exactly one filesystem link/u, + ) + } + + const oversizedPath = join(external, 'oversized.key') + await writeFile(oversizedPath, Buffer.alloc(129, 1), { mode: 0o600 }) + await chmod(oversizedPath, 0o600) + assert.throws( + () => readHeadlessKey({ type: 'file', path: oversizedPath, workspaceRoot: workspace }), + /too large/u, + ) + original.fill(0) + replacement.fill(0) +}) + +test('native credential adapters keep secrets out of child processes and erase temporary buffers', async () => { + const stored = new Map() + let lastSet: Uint8Array | undefined + let lastGet: Uint8Array | undefined + const factory: NativeKeyringEntryFactory = (service, account): NativeKeyringEntry => { + assert.equal(service, 'Braid') + return { + async setSecret(secret) { + lastSet = secret + stored.set(account, Uint8Array.from(secret)) + }, + async getSecret() { + const secret = stored.get(account) + lastGet = secret ? Uint8Array.from(secret) : undefined + return lastGet + }, + async deleteCredential() { + return stored.delete(account) + }, + } + } + const credentials = new LinuxSecretServiceCredentialStore(factory) + const ref = credentialRef('cred:v1:native-round-trip') + const input = Buffer.alloc(32, 19) + await credentials.store({ ref, value: input }) + assert.deepEqual(input, Buffer.alloc(32, 19)) + assert.equal( + lastSet?.every((byte) => byte === 0), + true, + ) + assert.equal(await credentials.available(), true) + + const handle = await credentials.resolve(ref) + assert.equal( + lastGet?.every((byte) => byte === 0), + true, + ) + assert.deepEqual(handle.read(), input) + handle.dispose() + assert.throws(() => handle.read(), /closed/u) + await credentials.remove(ref) + await credentials.remove(ref) + await assert.rejects( + () => credentials.resolve(ref), + (error: unknown) => + error instanceof Error && 'code' in error && error.code === 'CREDENTIAL_NOT_FOUND', + ) + + const implementation = await readFile( + join(process.cwd(), 'src/adapters/credentials/os.ts'), + 'utf8', + ) + assert.doesNotMatch(implementation, /child_process|powershell|secret-tool|add-generic-password/u) + input.fill(0) +}) + +test('native credential availability fails closed when the operating-system facility errors', async () => { + const unavailableFactory: NativeKeyringEntryFactory = () => ({ + async setSecret() { + throw Object.assign(new Error('service unavailable'), { code: 'DBUS_UNAVAILABLE' }) + }, + async getSecret() { + throw Object.assign(new Error('service unavailable'), { code: 'DBUS_UNAVAILABLE' }) + }, + async deleteCredential() { + throw Object.assign(new Error('service unavailable'), { code: 'DBUS_UNAVAILABLE' }) + }, + }) + const credentials = new LinuxSecretServiceCredentialStore(unavailableFactory) + assert.equal(await credentials.available(), false) + await assert.rejects( + () => credentials.resolve(credentialRef('cred:v1:unavailable-native')), + (error: unknown) => + error instanceof Error && 'code' in error && error.code === 'CREDENTIAL_STORE_UNAVAILABLE', + ) +}) + +test('native credential availability proves write, read, match, and cleanup', async () => { + for (const failure of ['write', 'read', 'mismatch', 'delete'] as const) { + let deleted = 0 + const credentials = new LinuxSecretServiceCredentialStore(() => { + let stored: Uint8Array | undefined + return { + async setSecret(secret) { + if (failure === 'write') throw new Error('write unavailable') + stored = Uint8Array.from(secret) + }, + async getSecret() { + if (failure === 'read') throw new Error('read unavailable') + if (failure === 'mismatch') return new Uint8Array(32) + return stored === undefined ? undefined : Uint8Array.from(stored) + }, + async deleteCredential() { + deleted += 1 + stored?.fill(0) + stored = undefined + if (failure === 'delete') throw new Error('delete unavailable') + return failure !== 'write' + }, + } + }) + assert.equal(await credentials.available(), false, failure) + assert.equal(deleted, 1, `${failure} cleanup`) + } +}) + +test('concurrent credential availability probes use isolated accounts', async () => { + const stored = new Map() + const accounts = new Set() + const credentials = new LinuxSecretServiceCredentialStore((_service, account) => { + accounts.add(account) + return { + async setSecret(secret) { + stored.set(account, Uint8Array.from(secret)) + }, + async getSecret() { + const value = stored.get(account) + return value === undefined ? undefined : Uint8Array.from(value) + }, + async deleteCredential() { + return stored.delete(account) + }, + } + }) + + assert.deepEqual(await Promise.all(Array.from({ length: 8 }, () => credentials.available())), [ + true, + true, + true, + true, + true, + true, + true, + true, + ]) + assert.equal(accounts.size, 8) + assert.equal(stored.size, 0) +}) + +test('production composition fails closed when the credential facility is unavailable', async () => { + const root = mkdtempSync(join(tmpdir(), 'braid-credentials-unavailable-')) + const credentials = new MemoryCredentialStore() + credentials.setAvailable(false) + await assert.rejects( + () => + openSqliteStorage({ + path: join(root, 'braid.sqlite'), + workspaceRoot: root, + credentialStore: credentials, + databaseKeyRef: credentialRef('cred:v1:unavailable-test'), + }), + (error: unknown) => + error instanceof StorageError && error.code === 'CREDENTIAL_STORE_UNAVAILABLE', + ) +}) + +test('secret-designated interaction values are rejected before journal persistence', () => { + assert.throws( + () => assertPersistablePayload({ containsSecret: true, value: 'never persist this' }), + (error: unknown) => + error instanceof Error && + error.name === 'StorageError' && + /secret-designated/iu.test(error.message), + ) + assert.throws( + () => assertPersistablePayload({ password: 'never persist this' }), + (error: unknown) => + error instanceof Error && + error.name === 'StorageError' && + error.message.includes('Secret-bearing'), + ) + assert.doesNotThrow(() => + assertPersistablePayload({ + credentialRef: 'cred:v1:opaque-reference', + credentialKind: 'keychain', + }), + ) + assert.doesNotThrow(() => + assertPersistablePayload({ inputTokens: 12, outputTokens: 7, reasoningTokens: 3 }), + ) + assert.throws( + () => assertPersistablePayload({ inputTokens: 'never persist this' }), + (error: unknown) => + error instanceof Error && + error.name === 'StorageError' && + error.message.includes('Secret-bearing'), + ) + const canary = 'SECRET_TYPED_INTERACTION_CANARY' + assert.throws( + () => + assertPersistablePayload({ + answerSpec: { fields: [{ name: 'credential', type: 'secret' }] }, + value: { credential: canary }, + }), + (error: unknown) => + error instanceof Error && + error.name === 'StorageError' && + /secret-designated/iu.test(error.message), + ) + assert.throws( + () => + assertPersistablePayload({ + request: { + answerSpec: { fields: [{ name: 'credential', type: 'secret' }] }, + response: { credential: canary }, + }, + }), + (error: unknown) => + error instanceof Error && + error.name === 'StorageError' && + /secret-designated/iu.test(error.message), + ) +}) + +test('backup and restore enforce the approved root, descriptor identity, and no-clobber publication', async () => { + const root = await mkdtemp(join(tmpdir(), 'braid-storage-paths-')) + const storage = await openSqliteStorage({ + path: join(root, 'braid.sqlite'), + workspaceRoot: root, + credentialStore: new MemoryCredentialStore(), + databaseKeyRef: credentialRef('cred:v1:storage-paths-test'), + }) + const backupPath = join(root, 'backup.sqlite') + await storage.backup({ + path: backupPath, + operation: { + operationId: 'op-path-backup-first', + kind: 'backup', + request: { path: backupPath }, + requestDigest: canonicalDigest({ path: backupPath }), + }, + }) + const destination = join(root, 'no-clobber.sqlite') + const sentinel = Buffer.from('do not replace') + await writeFile(destination, sentinel, { mode: 0o600 }) + await assert.rejects( + () => + storage.backup({ + path: destination, + operation: { + operationId: 'op-path-backup-second', + kind: 'backup', + request: { path: destination }, + requestDigest: canonicalDigest({ path: destination }), + }, + }), + (error: unknown) => error instanceof StorageError && error.code === 'BACKUP_EXISTS', + ) + assert.deepEqual(await readFile(destination), sentinel) + await assert.rejects( + () => + storage.backup({ + path: join(root, '..', 'outside-braid-backup.sqlite'), + operation: { + operationId: 'op-path-backup-outside', + kind: 'backup', + request: { path: join(root, '..', 'outside-braid-backup.sqlite') }, + requestDigest: canonicalDigest({ + path: join(root, '..', 'outside-braid-backup.sqlite'), + }), + }, + }), + (error: unknown) => error instanceof StorageError && error.code === 'STORAGE_APPROVED_ROOT', + ) + const hardLink = join(root, 'backup-hard-link.sqlite') + await link(backupPath, hardLink) + await assert.rejects( + () => + storage.restore({ + path: hardLink, + operation: { + operationId: 'op-path-restore-hard-link', + kind: 'restore', + request: { path: hardLink }, + requestDigest: canonicalDigest({ path: hardLink }), + }, + }), + (error: unknown) => error instanceof StorageError && error.code === 'STORAGE_INPUT_IDENTITY', + ) + await storage.close() +}) diff --git a/test/storage-crash-child.ts b/test/storage-crash-child.ts new file mode 100644 index 0000000..d33cbe4 --- /dev/null +++ b/test/storage-crash-child.ts @@ -0,0 +1,246 @@ +import { join } from 'node:path' +import { openSqliteStorage } from '../src/adapters/storage/sqlite.js' +import { snapshotForStorage } from './support/storage-snapshot-fixture.js' +import { canonicalDigest } from '../src/domain/canonical.js' +import { + createConversationId, + createEventId, + createOperationId, + createRunId, + createWorkspaceId, +} from '../src/domain/ids.js' +import { + CredentialError, + type CredentialPort, + type CredentialStoreInput, + credentialRef, + type SecretHandle, +} from '../src/ports/credentials.js' +import type { EffectRecord } from '../src/ports/effect-storage.js' +import { FileCredentialStore } from './support/file-credentials.js' + +const root = required('CRASH_ROOT') +const databasePath = required('CRASH_DATABASE') +const boundary = required('CRASH_BOUNDARY') +const action = required('CRASH_ACTION') +const credentials = new FileCredentialStore(join(root, 'credentials')) + +class FailingRedactionCredentialStore implements CredentialPort { + constructor(private readonly delegate: CredentialPort) {} + + available(): Promise { + return this.delegate.available() + } + + store(input: CredentialStoreInput): Promise { + if (input.ref?.includes('content-redacted')) { + throw new CredentialError('CREDENTIAL_WRITE_FAILED', 'Injected redaction key write failure') + } + return this.delegate.store(input) + } + + resolve(ref: import('../src/ports/credentials.js').CredentialRef): Promise { + return this.delegate.resolve(ref) + } + + remove(ref: import('../src/ports/credentials.js').CredentialRef): Promise { + return this.delegate.remove(ref) + } +} + +function required(name: string): string { + const value = process.env[name] + if (!value) throw new Error(`${name} is required`) + return value +} + +const workspaceId = createWorkspaceId('workspace-crash') +const conversationId = createConversationId('conversation-crash') + +function event(sequence: number, eventId: string, terminal = false) { + return { + workspaceId, + conversationId, + runId: createRunId('run-crash'), + eventId: createEventId(eventId), + sequence, + kind: terminal ? 'run.finished' : 'run.text.delta', + payload: { text: `crash-boundary-${sequence}` }, + occurredAt: '2026-08-02T00:00:00.000Z', + receivedAt: '2026-08-02T00:00:00.000Z', + terminal, + } as const +} + +function effect(): EffectRecord { + return { + operationId: 'op-crash', + effectKind: 'test.effect', + requestDigest: 'a'.repeat(64), + status: 'pending', + attempt: 1, + createdAt: '2026-08-02T00:00:00.000Z', + updatedAt: '2026-08-02T00:00:00.000Z', + metadata: { action: 'crash' }, + } +} + +const crashOperationRequest = { text: 'crash' } as const + +function mutation(kind: string, request: import('../src/ports/storage.js').JsonValue = {}) { + return { + operationId: createOperationId(`op-${kind}`), + kind, + request, + requestDigest: canonicalDigest(request), + } +} + +const storage = await openSqliteStorage({ + path: databasePath, + workspaceRoot: root, + credentialStore: + action === 'redaction-prepare-cleanup' + ? new FailingRedactionCredentialStore(credentials) + : credentials, + databaseKeyRef: credentialRef('cred:v1:test-crash-database'), + durableBoundaryHook: (current) => { + if (current === boundary) process.kill(process.pid, 'SIGKILL') + }, +}) + +try { + switch (action) { + case 'append': + await storage.append([event(1, 'event-crash-1')]) + break + case 'rebuild': + await storage.rebuild(mutation('rebuild')) + break + case 'operation-reserve': + await storage.reserveOperation({ + operationId: createOperationId('op-crash'), + kind: 'send', + request: crashOperationRequest, + requestDigest: canonicalDigest(crashOperationRequest), + }) + break + case 'operation-reserve-replay': + await storage.reserveOperation({ + operationId: createOperationId('op-crash'), + kind: 'send', + request: crashOperationRequest, + requestDigest: canonicalDigest(crashOperationRequest), + }) + break + case 'operation-reserve-conflict': + await storage.reserveOperation({ + operationId: createOperationId('op-crash'), + kind: 'send', + request: { text: 'changed' }, + requestDigest: canonicalDigest({ text: 'changed' }), + }) + break + case 'operation-complete': + case 'operation-complete-replay': + await storage.completeOperation({ + operationId: createOperationId('op-crash'), + requestDigest: canonicalDigest(crashOperationRequest), + status: 'terminal', + result: { complete: true }, + }) + break + case 'operation-complete-conflict': + await storage.completeOperation({ + operationId: createOperationId('op-crash'), + requestDigest: 'b'.repeat(64), + status: 'terminal', + }) + break + case 'operation-conflict': + await storage.recordOperationConflict({ + operationId: createOperationId('op-crash'), + requestDigest: canonicalDigest(crashOperationRequest), + attemptedDigest: 'c'.repeat(64), + }) + break + case 'effect': + storage.appendEffect(effect()) + break + case 'effect-reserve': + storage.reserveEffect(effect()) + break + case 'effect-reserve-replay': + storage.reserveEffect(effect()) + break + case 'effect-reserve-conflict': + storage.reserveEffect({ ...effect(), requestDigest: 'b'.repeat(64) }) + break + case 'retention': + await storage.applyRetention({ + before: '2026-08-03T00:00:00.000Z', + conversationId, + operation: mutation('retention', { before: '2026-08-03T00:00:00.000Z', conversationId }), + }) + break + case 'redaction': + case 'redaction-prepare-cleanup': + await storage.redact({ + conversationId, + eventId: createEventId('event-crash-1'), + reason: 'crash test', + operation: mutation('redaction', { + conversationId, + eventId: createEventId('event-crash-1'), + reasonDigest: canonicalDigest('crash test'), + }), + }) + break + case 'destruction': + await storage.destroyConversation({ + conversationId, + reason: 'crash test', + operation: mutation('destruction', { + conversationId, + reasonDigest: canonicalDigest('crash test'), + }), + }) + break + case 'snapshot-write': + await storage.writeStateSnapshot(snapshotForStorage(storage, 'event-crash-1', 1)) + break + case 'snapshot-prune': { + if (storage.appendWithSnapshot === undefined) + throw new Error('Production SQLite does not expose atomic snapshot append') + await storage.appendWithSnapshot({ + events: [event(3, 'event-crash-3')], + snapshot: snapshotForStorage(storage, 'event-crash-3', 3), + }) + break + } + case 'snapshot-quarantine': + await storage.latestStateSnapshot() + break + case 'snapshot-reconcile': + break + case 'backup': + await storage.backup({ + path: join(root, 'crash-backup.sqlite'), + operation: mutation('backup', { path: join(root, 'crash-backup.sqlite') }), + }) + break + case 'restore': + await storage.restore({ + path: join(root, 'crash-restore-source.sqlite'), + operation: mutation('restore', { path: join(root, 'crash-restore-source.sqlite') }), + }) + break + case 'migration': + case 'key-reconcile': + throw new Error(`${action} should have reached its crash boundary while opening storage`) + default: + throw new Error(`Unknown crash action ${action}`) + } +} finally { + await storage.close() +} diff --git a/test/storage-crash.test.ts b/test/storage-crash.test.ts new file mode 100644 index 0000000..5420944 --- /dev/null +++ b/test/storage-crash.test.ts @@ -0,0 +1,512 @@ +import assert from 'node:assert/strict' +import { spawn } from 'node:child_process' +import { mkdtemp, readFile, readdir, stat } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import test from 'node:test' +import { openSqliteStorage } from '../src/adapters/storage/sqlite.js' +import { + configureCipherDatabase, + loadCipherDatabaseFactory, +} from '../src/adapters/storage/sqlite-driver.js' +import { + applyConnectionPragmas, + SQLITE_SCHEMA_VERSION, +} from '../src/adapters/storage/sqlite-schema.js' +import { canonicalDigest } from '../src/domain/canonical.js' +import { + createConversationId, + createEventId, + createOperationId, + createRunId, + createWorkspaceId, +} from '../src/domain/ids.js' +import { credentialRef } from '../src/ports/credentials.js' +import { FileCredentialStore } from './support/file-credentials.js' +import { seedSnapshots } from './support/storage-snapshot-fixture.js' + +const require = createRequire(import.meta.url) +const sqliteAvailable = (() => { + try { + require('better-sqlite3-multiple-ciphers') + return true + } catch { + return false + } +})() + +const childPath = join(process.cwd(), '.test-dist/test/storage-crash-child.js') +const workspaceId = createWorkspaceId('workspace-crash') +const conversationId = createConversationId('conversation-crash') +const runId = createRunId('run-crash') + +function mutation(kind: string, request: import('../src/ports/storage.js').JsonValue = {}) { + return { + operationId: createOperationId(`op-${kind}`), + kind, + request, + requestDigest: canonicalDigest(request), + } +} + +function journalEvent(sequence: number, eventId: string, terminal = false) { + return { + workspaceId, + conversationId, + runId, + eventId: createEventId(eventId), + sequence, + kind: terminal ? 'run.finished' : 'run.text.delta', + payload: { text: `crash-${sequence}` }, + occurredAt: '2026-08-02T00:00:00.000Z', + receivedAt: '2026-08-02T00:00:00.000Z', + terminal, + } as const +} + +async function open(root: string) { + return openSqliteStorage({ + path: join(root, 'braid.sqlite'), + workspaceRoot: root, + credentialStore: new FileCredentialStore(join(root, 'credentials')), + databaseKeyRef: credentialRef('cred:v1:test-crash-database'), + }) +} + +async function prepare(root: string, action: string): Promise { + if (action === 'open') return + const storage = await open(root) + const snapshotScopeId = storage.snapshotScopeId() + try { + if (action === 'rebuild') await storage.append([journalEvent(1, 'event-crash-1')]) + if ( + action === 'retention' || + action === 'redaction' || + action === 'redaction-prepare-cleanup' || + action === 'destruction' || + action === 'key-reconcile' || + action === 'restore' || + action === 'snapshot-write' || + action === 'snapshot-prune' || + action === 'snapshot-quarantine' || + action === 'snapshot-reconcile' + ) { + await storage.append([ + journalEvent(1, 'event-crash-1'), + ...(action === 'snapshot-write' || action === 'snapshot-quarantine' + ? [] + : [journalEvent(2, 'event-crash-2', action !== 'snapshot-prune')]), + ]) + } + if ( + action === 'retention' || + action === 'redaction' || + action === 'destruction' || + action === 'snapshot-prune' + ) { + await seedSnapshots(storage, ['event-crash-1', 'event-crash-2']) + } + if (action === 'snapshot-quarantine') { + await seedSnapshots(storage, ['event-crash-1']) + } + const crashOperation = { + operationId: createOperationId('op-crash'), + kind: 'send', + request: { text: 'crash' }, + requestDigest: canonicalDigest({ text: 'crash' }), + } + if ( + action === 'effect-reserve-replay' || + action === 'effect-reserve-conflict' || + action === 'operation-reserve-replay' || + action === 'operation-reserve-conflict' || + action === 'operation-complete' || + action === 'operation-complete-conflict' || + action === 'operation-complete-replay' || + action === 'operation-conflict' + ) { + await storage.reserveOperation(crashOperation) + } + if (action === 'effect-reserve-replay' || action === 'effect-reserve-conflict') { + await storage.reserveEffect({ + operationId: 'op-crash', + effectKind: 'test.effect', + requestDigest: 'a'.repeat(64), + status: 'pending', + attempt: 1, + createdAt: '2026-08-02T00:00:00.000Z', + updatedAt: '2026-08-02T00:00:00.000Z', + metadata: { action: 'crash' }, + }) + } + if (action === 'operation-complete-replay') { + await storage.completeOperation({ + operationId: crashOperation.operationId, + requestDigest: crashOperation.requestDigest, + status: 'terminal', + result: { prepared: true }, + }) + } + if (action === 'restore') { + const path = join(root, 'crash-restore-source.sqlite') + await storage.backup({ path, operation: mutation('prepare-restore', { path }) }) + } + } finally { + await storage.close() + } + if (action === 'migration') { + await withDatabase(root, (database) => { + database.exec('DROP INDEX IF EXISTS braid_journal_event_id_unique') + database.exec(`PRAGMA user_version = ${SQLITE_SCHEMA_VERSION - 1}`) + }) + } + if (action === 'key-reconcile') { + const credentials = new FileCredentialStore(join(root, 'credentials')) + const newRef = credentialRef('cred:v1:crash-key-reconcile-new') + await credentials.store({ ref: newRef, value: Buffer.alloc(32, 29) }) + await withDatabase(root, (database) => { + const current = database + .prepare('SELECT credential_ref FROM braid_conversation_keys WHERE conversation_id = ?') + .get(conversationId) as { readonly credential_ref: string } + database + .prepare( + `INSERT INTO braid_content_key_rotations( + conversation_id, old_credential_ref, new_credential_ref, prepared_at + ) VALUES (?, ?, ?, ?)`, + ) + .run(conversationId, current.credential_ref, newRef, '2026-08-02T00:00:00.000Z') + }) + } + if (action === 'snapshot-reconcile') { + const credentials = new FileCredentialStore(join(root, 'credentials')) + const staleRef = credentialRef('cred:v1:crash-snapshot-reconcile-stale') + await credentials.store({ ref: staleRef, value: Buffer.alloc(32, 31) }) + await withDatabase(root, (database) => { + database + .prepare( + `INSERT INTO braid_state_snapshot_keys( + scope_id, generation, credential_ref, retired, created_at + ) VALUES (?, ?, ?, 0, ?)`, + ) + .run(snapshotScopeId, 901, staleRef, '2026-08-02T00:00:00.000Z') + }) + } + if (action === 'snapshot-quarantine') { + await withDatabase(root, (database) => { + const row = database + .prepare('SELECT snapshot_id, state_ciphertext FROM braid_state_snapshots LIMIT 1') + .get() as { readonly snapshot_id?: unknown; readonly state_ciphertext?: unknown } + if (typeof row.snapshot_id !== 'number' && typeof row.snapshot_id !== 'bigint') + throw new Error('Snapshot fixture id is invalid') + if (!Buffer.isBuffer(row.state_ciphertext)) + throw new Error('Snapshot fixture ciphertext is invalid') + const corrupted = Buffer.from(row.state_ciphertext) + const last = corrupted.length - 1 + if (last < 0) throw new Error('Snapshot fixture ciphertext is empty') + corrupted[last] = (corrupted[last] ?? 0) ^ 0xff + database + .prepare('UPDATE braid_state_snapshots SET state_ciphertext = ? WHERE snapshot_id = ?') + .run(corrupted, row.snapshot_id) + corrupted.fill(0) + }) + } +} + +async function withDatabase( + root: string, + action: (database: import('../src/adapters/storage/sqlite-driver.js').SqliteDatabase) => void, +): Promise { + const credentials = new FileCredentialStore(join(root, 'credentials')) + const handle = await credentials.resolve(credentialRef('cred:v1:test-crash-database')) + const key = Buffer.from(handle.read()) + handle.dispose() + const database = loadCipherDatabaseFactory()(join(root, 'braid.sqlite'), { timeout: 5_000 }) + try { + configureCipherDatabase(database, key) + applyConnectionPragmas(database, 5_000) + action(database) + } finally { + database.close() + key.fill(0) + } +} + +async function schemaVersion(root: string): Promise { + let version = -1 + await withDatabase(root, (database) => { + version = Number(database.pragma('user_version', { simple: true })) + }) + return version +} + +async function rotationCount(root: string): Promise { + let count = -1 + await withDatabase(root, (database) => { + const row = database + .prepare('SELECT COUNT(*) AS count FROM braid_content_key_rotations') + .get() as { + readonly count: number | bigint + } + count = Number(row.count) + }) + return count +} + +async function hasGlobalEventIndex(root: string): Promise { + let present = false + await withDatabase(root, (database) => { + const row = database + .prepare( + "SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'braid_journal_event_id_unique'", + ) + .get() + present = row !== undefined + }) + return present +} + +async function runCrash(root: string, action: string, boundary: string): Promise { + await new Promise((resolve, reject) => { + const child = spawn(process.execPath, [childPath], { + cwd: process.cwd(), + env: { + ...process.env, + CRASH_ROOT: root, + CRASH_DATABASE: join(root, 'braid.sqlite'), + CRASH_ACTION: action, + CRASH_BOUNDARY: boundary, + }, + stdio: ['ignore', 'ignore', 'pipe'], + }) + let stderr = '' + child.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString('utf8') + }) + child.once('error', reject) + child.once('close', (code, signal) => { + if (signal !== 'SIGKILL') { + reject( + new Error( + `crash child action=${action} boundary=${boundary} exited with code=${String(code)} signal=${String(signal)} stderr=${stderr}`, + ), + ) + return + } + resolve() + }) + }) +} + +const commitCrashCases = [ + { action: 'open', label: 'schema.initialize' }, + { action: 'open', label: 'projection.initialize' }, + { action: 'migration', label: 'migration' }, + { action: 'key-reconcile', label: 'key.reconcile' }, + { action: 'append', label: 'append' }, + { action: 'rebuild', label: 'rebuild' }, + { action: 'operation-reserve', label: 'operation.reserve' }, + { action: 'operation-reserve-replay', label: 'operation.reserve.replay' }, + { action: 'operation-reserve-conflict', label: 'operation.reserve.conflict' }, + { action: 'operation-complete', label: 'operation.complete' }, + { action: 'operation-complete-replay', label: 'operation.complete.replay' }, + { action: 'operation-complete-conflict', label: 'operation.complete.conflict' }, + { action: 'operation-conflict', label: 'operation.conflict' }, + { action: 'effect-reserve', label: 'effect.reserve' }, + { action: 'effect-reserve-replay', label: 'effect.reserve.replay' }, + { action: 'effect-reserve-conflict', label: 'effect.reserve.conflict' }, + { action: 'effect', label: 'effect' }, + { action: 'retention', label: 'retention' }, + { action: 'redaction', label: 'redaction.prepare' }, + { action: 'redaction', label: 'redaction' }, + { action: 'redaction-prepare-cleanup', label: 'redaction.prepare.cleanup' }, + { action: 'redaction', label: 'redaction.cleanup' }, + { action: 'destruction', label: 'destruction' }, + { action: 'restore', label: 'restore.operation.reserve' }, + { action: 'snapshot-write', label: 'state.snapshot' }, + { action: 'snapshot-prune', label: 'state.snapshot.prune' }, + { action: 'snapshot-prune', label: 'state.snapshot.keys.remove' }, + { action: 'snapshot-reconcile', label: 'state.snapshot.key.reconcile' }, + { action: 'snapshot-quarantine', label: 'state.snapshot.quarantine' }, + { action: 'snapshot-quarantine', label: 'state.snapshot.quarantine.cleanup' }, + { action: 'redaction', label: 'state.snapshot.invalidation.cleanup' }, +] as const + +const externalCrashCases = [ + { action: 'redaction', label: 'redaction.key.store' }, + { action: 'redaction', label: 'redaction.old-key.remove' }, + { action: 'backup', label: 'backup' }, + { action: 'backup', label: 'backup.publish' }, + { action: 'backup', label: 'backup.temp.remove' }, + { action: 'restore', label: 'backup' }, + { action: 'restore', label: 'backup.publish' }, + { action: 'restore', label: 'backup.temp.remove' }, + { action: 'restore', label: 'restore.manifest.prepared' }, + { action: 'restore', label: 'restore.copy' }, + { action: 'restore', label: 'restore.manifest.candidate-ready' }, + { action: 'restore', label: 'restore.displace' }, + { action: 'restore', label: 'restore.displace-wal' }, + { action: 'restore', label: 'restore.displace-shm' }, + { action: 'restore', label: 'restore.manifest.live-displaced' }, + { action: 'restore', label: 'restore.install' }, + { action: 'restore', label: 'restore.manifest.installed' }, + { action: 'restore', label: 'restore.manifest.verified' }, + { action: 'restore', label: 'restore.cleanup' }, + { action: 'restore', label: 'restore.cleanup-wal' }, + { action: 'restore', label: 'restore.cleanup-shm' }, + { action: 'restore', label: 'restore.manifest.remove' }, +] as const + +const crashCases = [...commitCrashCases, ...externalCrashCases] + +test('forced-kill cases enumerate every SQLite commit point', async () => { + const sqliteSources = (await readdir(join(process.cwd(), 'src/adapters/storage'))) + .filter((name) => /^sqlite.*\.ts$/u.test(name)) + .sort() + const source = ( + await Promise.all( + sqliteSources.map((name) => + readFile(join(process.cwd(), 'src/adapters/storage', name), 'utf8'), + ), + ) + ).join('\n') + const implementationLabels = [ + ...new Set( + [...source.matchAll(/commit\(([^)]*)\)/gu)].flatMap((call) => + [...(call[1] ?? '').matchAll(/'([^']+)'/gu)].map((literal) => literal[1]), + ), + ), + ].sort() + assert.deepEqual(commitCrashCases.map((entry) => entry.label).sort(), implementationLabels) +}) + +test('production SQLite recovers at every commit and external key/file boundary after forced kill', async () => { + if (!sqliteAvailable) { + throw new Error( + 'W5_NATIVE_STORAGE_BLOCKED: better-sqlite3-multiple-ciphers@12.11.1 is not installed', + ) + } + for (const current of crashCases) { + const phases = + current.label === 'restore.displace-wal' || current.label === 'restore.displace-shm' + ? (['before'] as const) + : (['before', 'after'] as const) + for (const phase of phases) { + const root = await mkdtemp(join(tmpdir(), `braid-crash-${current.action}-${phase}-`)) + await prepare(root, current.action) + await runCrash(root, current.action, `${phase}:${current.label}`) + + if (current.action === 'migration') { + assert.equal( + await schemaVersion(root), + phase === 'after' ? SQLITE_SCHEMA_VERSION : SQLITE_SCHEMA_VERSION - 1, + ) + assert.equal(await hasGlobalEventIndex(root), false) + } + if (current.action === 'key-reconcile' || current.action === 'redaction-prepare-cleanup') { + assert.equal(await rotationCount(root), phase === 'after' ? 0 : 1) + } + + const storage = await open(root) + try { + assert.equal((await storage.integrity()).ok, true, `${current.label} ${phase}`) + const committed = phase === 'after' + if (current.action === 'append') { + assert.equal((await storage.events()).length, committed ? 1 : 0) + } else if (current.action === 'snapshot-write') { + assert.equal((await storage.latestStateSnapshot())?.generation, committed ? 1 : undefined) + } else if (current.action === 'snapshot-prune') { + assert.equal((await storage.events()).length, 3) + assert.equal((await storage.latestStateSnapshot())?.generation, 3) + } else if (current.action === 'snapshot-quarantine') { + assert.equal((await storage.latestStateSnapshot())?.generation, undefined) + } else if (current.action === 'operation-reserve') { + const operation = await storage.operation(createOperationId('op-crash')) + assert.equal(operation !== null, committed) + } else if ( + current.action === 'operation-reserve-conflict' || + current.action === 'operation-complete-conflict' || + current.action === 'operation-conflict' + ) { + const operation = await storage.operation(createOperationId('op-crash')) + assert.equal(operation?.status, 'pending', `${current.action} ${current.label} ${phase}`) + } else if (current.action === 'operation-complete') { + const operation = await storage.operation(createOperationId('op-crash')) + assert.equal(operation?.status, committed ? 'terminal' : 'pending') + } else if (current.action === 'operation-complete-replay') { + const operation = await storage.operation(createOperationId('op-crash')) + assert.equal(operation?.status, 'terminal') + } else if (current.action === 'operation-reserve-replay') { + const operation = await storage.operation(createOperationId('op-crash')) + assert.equal(operation?.status, 'pending') + } else if (current.action === 'effect') { + assert.equal(storage.history('op-crash').length, committed ? 1 : 0) + } else if (current.action === 'effect-reserve') { + assert.equal(storage.history('op-crash').length, committed ? 1 : 0) + } else if (current.action === 'effect-reserve-replay') { + assert.equal(storage.history('op-crash').length, 1) + } else if (current.action === 'effect-reserve-conflict') { + assert.equal(storage.history('op-crash').length, committed ? 2 : 1) + } else if (current.action === 'retention') { + const events = await storage.events({ conversationId }) + assert.equal( + events.every((event) => event.payloadState === 'redacted'), + committed, + ) + } else if (current.action === 'redaction') { + const event = (await storage.events({ conversationId })).find( + (item) => item.eventId === 'event-crash-1', + ) + const rewriteCommitted = + current.label === 'redaction.prepare' || current.label === 'redaction.key.store' + ? false + : current.label === 'redaction' + ? committed + : true + assert.equal(event?.payloadState === 'redacted', rewriteCommitted) + } else if (current.action === 'redaction-prepare-cleanup') { + const event = (await storage.events({ conversationId })).find( + (item) => item.eventId === 'event-crash-1', + ) + assert.equal(event?.payloadState, 'available') + } else if (current.action === 'destruction') { + const event = (await storage.events({ conversationId }))[0] + assert.equal(event?.payloadState, committed ? 'deleted' : 'available') + } else if (current.action === 'backup') { + const backupExists = await stat(join(root, 'crash-backup.sqlite')) + .then(() => true) + .catch(() => false) + const expected = + current.label === 'backup' + ? committed + : current.label === 'backup.publish' + ? committed + : true + assert.equal(backupExists, expected, `${current.label} ${phase}`) + } else if (current.action === 'restore') { + const operation = await storage.operation(createOperationId('op-restore')) + const candidateHasBeenInstalled = + (current.label === 'restore.install' && phase === 'after') || + current.label === 'restore.manifest.installed' + const expected = + current.label === 'restore.operation.reserve' + ? phase === 'after' + : !candidateHasBeenInstalled + assert.equal(operation !== null, expected, `${current.label} ${phase}`) + const leftovers = await readdir(root) + assert.equal(leftovers.includes('braid.sqlite.restore.manifest'), false) + assert.equal( + leftovers.some( + (name) => + /\.restore-[0-9a-f-]+\.tmp$/u.test(name) || /\.displaced-[0-9a-f-]+$/u.test(name), + ), + false, + `${current.label} ${phase}`, + ) + } + } finally { + await storage.close() + } + } + } +}) diff --git a/test/storage-performance.test.ts b/test/storage-performance.test.ts new file mode 100644 index 0000000..4ea6a38 --- /dev/null +++ b/test/storage-performance.test.ts @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict' +import { createRequire } from 'node:module' +import { mkdtemp } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import test from 'node:test' +import { MemoryCredentialStore } from '../src/adapters/credentials/memory.js' +import { openSqliteStorage } from '../src/adapters/storage/sqlite.js' +import { + createConversationId, + createEventId, + createRunId, + createWorkspaceId, +} from '../src/domain/ids.js' +import { credentialRef } from '../src/ports/credentials.js' + +const require = createRequire(import.meta.url) +const sqliteAvailable = (() => { + try { + require('better-sqlite3-multiple-ciphers') + return true + } catch { + return false + } +})() + +async function measureAppend(count: number): Promise { + const root = await mkdtemp(join(tmpdir(), `braid-storage-${count}-`)) + const storage = await openSqliteStorage({ + path: join(root, 'braid.sqlite'), + workspaceRoot: root, + credentialStore: new MemoryCredentialStore(), + databaseKeyRef: credentialRef(`cred:v1:storage-performance-${count}`), + maxEventsPerTransaction: 10_000, + }) + const events = Array.from({ length: count }, (_, index) => ({ + workspaceId: createWorkspaceId(`workspace-performance-${count}`), + conversationId: createConversationId(`conversation-performance-${count}`), + runId: createRunId(`run-performance-${count}`), + eventId: createEventId(`event-performance-${count}-${index + 1}`), + sequence: index + 1, + kind: index + 1 === count ? 'run.finished' : 'run.text.delta', + payload: { n: index + 1 }, + occurredAt: '2026-08-02T00:00:00.000Z', + terminal: index + 1 === count, + })) + const started = performance.now() + for (let offset = 0; offset < events.length; offset += 10_000) { + await storage.append(events.slice(offset, offset + 10_000)) + } + const elapsed = performance.now() - started + assert.equal((await storage.events()).length, count) + assert.equal((await storage.integrity()).ok, true) + await storage.close() + return elapsed +} + +test('native SQLite measures incremental append at 10k events', async (t) => { + if (!sqliteAvailable) { + throw new Error( + 'W5_NATIVE_STORAGE_BLOCKED: better-sqlite3-multiple-ciphers@12.11.1 is not installed', + ) + } + const elapsed = await measureAppend(10_000) + t.diagnostic( + `events=10000 elapsedMs=${elapsed.toFixed(2)} eventsPerSecond=${(10_000 / (elapsed / 1000)).toFixed(2)}`, + ) +}) + +test('native SQLite measures incremental append at 100k events', async (t) => { + if (!sqliteAvailable) { + throw new Error( + 'W5_NATIVE_STORAGE_BLOCKED: better-sqlite3-multiple-ciphers@12.11.1 is not installed', + ) + } + const elapsed = await measureAppend(100_000) + t.diagnostic( + `events=100000 elapsedMs=${elapsed.toFixed(2)} eventsPerSecond=${(100_000 / (elapsed / 1000)).toFixed(2)}`, + ) +}) diff --git a/test/storage-snapshots.test.ts b/test/storage-snapshots.test.ts new file mode 100644 index 0000000..e7b4b18 --- /dev/null +++ b/test/storage-snapshots.test.ts @@ -0,0 +1,576 @@ +import assert from 'node:assert/strict' +import { mkdtemp, readdir, readFile, rm } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import test from 'node:test' +import { MemoryCredentialStore } from '../src/adapters/credentials/memory.js' +import { openSqliteStorage } from '../src/adapters/storage/sqlite.js' +import type { SqliteDatabase } from '../src/adapters/storage/sqlite-driver.js' +import { + configureCipherDatabase, + loadCipherDatabaseFactory, +} 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 { StorageJournal } from '../src/app/storage-journal.js' +import { toJson } from '../src/app/storage-journal-support.js' +import { canonicalDigest } from '../src/domain/canonical.js' +import type { BraidEventEnvelope } from '../src/domain/events.js' +import { + createConversationId, + createEventId, + createOperationId, + createRunId, + createWorkspaceId, +} from '../src/domain/ids.js' +import { createMaterializedStateSnapshot } from '../src/domain/materialized-state-snapshot.js' +import { reduceEvent } 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' + +const require = createRequire(import.meta.url) +const sqliteAvailable = (() => { + try { + require('better-sqlite3-multiple-ciphers') + return true + } catch { + return false + } +})() + +const databaseKeyRef = credentialRef('cred:v1:snapshot-test-database') +const workspaceId = createWorkspaceId('workspace-snapshot-test') +const conversationId = createConversationId('conversation-snapshot-test') +const runId = createRunId('run-snapshot-test') +const canary = 'SNAPSHOT_MESSAGE_CANARY_2026' + +class TrackingCredentialStore extends MemoryCredentialStore { + readonly removedRefs = new Set() + + override async remove(ref: CredentialRef): Promise { + await super.remove(ref) + this.removedRefs.add(ref) + } +} + +interface SnapshotFixture { + readonly events: readonly JournalEvent[] + readonly snapshots: readonly ReturnType[] +} + +function fixture(): SnapshotFixture { + let state = initialState(STARTER_PROFILE, { conversationId }) + const events: JournalEvent[] = [] + const snapshots: ReturnType[] = [] + for (let index = 1; index <= 3; index += 1) { + const eventId = createEventId(`event-snapshot-${index}`) + const envelope: BraidEventEnvelope = { + eventId, + sequence: index, + revision: index, + occurredAt: '2026-08-03T00:00:00.000Z', + event: { kind: 'draft.changed', text: `${canary}-${index}` }, + } + state = reduceEvent(state, envelope) + events.push({ + workspaceId, + conversationId, + runId, + eventId, + sequence: index, + kind: envelope.event.kind, + payload: toJson({ + __braidEvent: envelope.event, + __braidEnvelope: { + sequence: envelope.sequence, + revision: envelope.revision, + occurredAt: envelope.occurredAt, + eventId, + }, + }), + occurredAt: envelope.occurredAt, + terminal: false, + }) + snapshots.push( + createMaterializedStateSnapshot({ + scopeId: 'pending', + generation: state.sequence, + eventId, + state, + }), + ) + } + return { events, snapshots } +} + +async function withRawDatabase( + root: string, + credentials: MemoryCredentialStore, + action: (database: SqliteDatabase) => T, +): Promise { + const handle = await credentials.resolve(databaseKeyRef) + const key = Buffer.from(handle.read()) + handle.dispose() + const database = loadCipherDatabaseFactory()(join(root, 'braid.sqlite'), { timeout: 5_000 }) + try { + configureCipherDatabase(database, key) + applyConnectionPragmas(database, 5_000) + return action(database) + } finally { + database.close() + key.fill(0) + } +} + +function snapshotRows(database: SqliteDatabase): readonly Record[] { + return database + .prepare( + `SELECT snapshot_id, generation, key_ref, state_ciphertext + FROM braid_state_snapshots ORDER BY generation DESC`, + ) + .all() as readonly Record[] +} + +function snapshotKeyRefs(database: SqliteDatabase): readonly string[] { + return ( + database + .prepare('SELECT credential_ref FROM braid_state_snapshot_keys ORDER BY generation') + .all() as readonly { readonly credential_ref?: unknown }[] + ).flatMap((row) => (typeof row.credential_ref === 'string' ? [row.credential_ref] : [])) +} + +async function snapshotModuleArchitecture(): Promise<{ + readonly graph: ReadonlyMap + readonly lineCounts: ReadonlyMap +}> { + const storageDirectory = join(process.cwd(), 'src/adapters/storage') + const storageFiles = (await readdir(storageDirectory)) + .filter((name) => /^sqlite-state-snapshot.*\.ts$/u.test(name)) + .map((name) => join(storageDirectory, name)) + const files = [...storageFiles, join(process.cwd(), 'src/domain/materialized-state-snapshot.ts')] + const fileSet = new Set(files) + const graph = new Map() + const lineCounts = new Map() + const importPattern = /(?:from\s+|import\s*)['"]([^'"]+)['"]/gu + for (const file of files) { + const source = await readFile(file, 'utf8') + const lines = source.split(/\r?\n/u) + lineCounts.set( + file, + source === '' ? 0 : source.endsWith('\n') ? lines.length - 1 : lines.length, + ) + const dependencies: string[] = [] + for (const match of source.matchAll(importPattern)) { + const specifier = match[1] + if (specifier === undefined || !specifier.startsWith('.')) continue + const dependency = resolve(dirname(file), specifier.replace(/\.js$/u, '.ts')) + if (fileSet.has(dependency)) dependencies.push(dependency) + } + graph.set(file, dependencies) + } + return { graph, lineCounts } +} + +function assertSnapshotModuleGraphIsAcyclic(graph: ReadonlyMap): void { + const visiting = new Set() + const visited = new Set() + const visit = (file: string, path: readonly string[]): void => { + if (visiting.has(file)) { + throw new Error(`Snapshot module cycle: ${[...path, file].join(' -> ')}`) + } + if (visited.has(file)) return + visiting.add(file) + for (const dependency of graph.get(file) ?? []) visit(dependency, [...path, file]) + visiting.delete(file) + visited.add(file) + } + for (const file of graph.keys()) visit(file, []) +} + +test('snapshot modules stay bounded and acyclic', async () => { + const { graph, lineCounts } = await snapshotModuleArchitecture() + for (const [file, lineCount] of lineCounts) { + assert.ok(lineCount <= 300, `${file} has ${lineCount} lines; maximum is 300`) + } + assertSnapshotModuleGraphIsAcyclic(graph) +}) + +test('latest valid generation restores after a process restart', async (t) => { + if (!sqliteAvailable) { + throw new Error( + 'W5_NATIVE_STORAGE_BLOCKED: better-sqlite3-multiple-ciphers@12.11.1 is not installed', + ) + } + const root = await mkdtemp(join(tmpdir(), 'braid-snapshot-restart-')) + t.after(() => rm(root, { force: true, recursive: true })) + const credentials = new MemoryCredentialStore() + const path = join(root, 'braid.sqlite') + let storage = await openSqliteStorage({ + path, + workspaceRoot: root, + credentialStore: credentials, + databaseKeyRef, + }) + t.after(() => storage.close()) + const source = fixture() + const scopeId = storage.snapshotScopeId() + await storage.append(source.events) + for (const snapshot of source.snapshots) + await storage.writeStateSnapshot({ ...snapshot, scopeId }) + await storage.close() + + storage = await openSqliteStorage({ + path, + workspaceRoot: root, + credentialStore: credentials, + databaseKeyRef, + }) + const latest = await storage.latestStateSnapshot() + assert.equal(latest?.generation, 3) + assert.equal(latest?.sequence, 3) + assert.equal(latest?.state.draft, `${canary}-3`) + const journal = await StorageJournal.fromStorage(storage, { + now: () => '2026-08-03T00:00:00.000Z', + }) + assert.equal(journal.initialState()?.draft, `${canary}-3`) +}) + +test('append with a snapshot rolls back the event when snapshot storage fails', async (t) => { + if (!sqliteAvailable) { + throw new Error( + 'W5_NATIVE_STORAGE_BLOCKED: better-sqlite3-multiple-ciphers@12.11.1 is not installed', + ) + } + const root = await mkdtemp(join(tmpdir(), 'braid-snapshot-atomic-')) + t.after(() => rm(root, { force: true, recursive: true })) + const credentials = new MemoryCredentialStore() + const storage = await openSqliteStorage({ + path: join(root, 'braid.sqlite'), + workspaceRoot: root, + credentialStore: credentials, + databaseKeyRef, + }) + t.after(() => storage.close()) + const source = fixture() + const scopeId = storage.snapshotScopeId() + const first = source.events[0] + const second = source.events[1] + const firstSnapshot = source.snapshots[0] + assert.ok(first && second && firstSnapshot) + await storage.appendWithSnapshot({ + events: [first], + snapshot: { ...firstSnapshot, scopeId }, + }) + await assert.rejects( + storage.appendWithSnapshot({ + events: [second], + snapshot: { ...firstSnapshot, scopeId, eventId: second.eventId }, + }), + ) + assert.equal((await storage.events()).length, 1) + assert.equal((await storage.latestStateSnapshot())?.generation, 1) + const refs = await withRawDatabase(root, credentials, snapshotKeyRefs) + assert.equal(refs.length, 1) + const retainedRef = refs[0] + if (retainedRef === undefined) throw new Error('Expected the committed snapshot key') + assert.equal(await credentials.has(credentialRef(retainedRef)), true) +}) + +test('snapshot writes fail closed when an event id is ambiguous', async (t) => { + if (!sqliteAvailable) { + throw new Error( + 'W5_NATIVE_STORAGE_BLOCKED: better-sqlite3-multiple-ciphers@12.11.1 is not installed', + ) + } + const root = await mkdtemp(join(tmpdir(), 'braid-snapshot-ambiguous-')) + t.after(() => rm(root, { force: true, recursive: true })) + const credentials = new MemoryCredentialStore() + const storage = await openSqliteStorage({ + path: join(root, 'braid.sqlite'), + workspaceRoot: root, + credentialStore: credentials, + databaseKeyRef, + }) + t.after(() => storage.close()) + const source = fixture() + const scopeId = storage.snapshotScopeId() + const first = source.events[0] + const firstSnapshot = source.snapshots[0] + assert.ok(first && firstSnapshot) + await storage.append([first, { ...first, runId: createRunId('run-snapshot-ambiguous') }]) + await assert.rejects(storage.writeStateSnapshot({ ...firstSnapshot, scopeId })) + assert.equal((await storage.events()).length, 2) + assert.equal(await storage.latestStateSnapshot(), null) + assert.equal((await withRawDatabase(root, credentials, snapshotKeyRefs)).length, 0) +}) + +test('redaction erases snapshot keys before rollback and falls back to journal replay', async (t) => { + if (!sqliteAvailable) { + throw new Error( + 'W5_NATIVE_STORAGE_BLOCKED: better-sqlite3-multiple-ciphers@12.11.1 is not installed', + ) + } + const root = await mkdtemp(join(tmpdir(), 'braid-snapshot-key-retain-')) + t.after(() => rm(root, { force: true, recursive: true })) + const credentials = new TrackingCredentialStore() + let snapshotKeyRef: CredentialRef | undefined + let snapshotKeyGoneBeforeMutation = false + const storage = await openSqliteStorage({ + path: join(root, 'braid.sqlite'), + workspaceRoot: root, + credentialStore: credentials, + databaseKeyRef, + durableBoundaryHook: (boundary) => { + if (boundary !== 'before:redaction') return + snapshotKeyGoneBeforeMutation = + snapshotKeyRef !== undefined && + credentials.removedRefs.has(snapshotKeyRef) && + !credentials.has(snapshotKeyRef) + throw new Error('force redaction rollback after snapshot key erasure') + }, + }) + t.after(() => storage.close()) + const source = fixture() + const scopeId = storage.snapshotScopeId() + const first = source.events[0] + const firstSnapshot = source.snapshots[0] + assert.ok(first && firstSnapshot) + await storage.append([first]) + await storage.writeStateSnapshot({ ...firstSnapshot, scopeId }) + const refs = await withRawDatabase(root, credentials, snapshotKeyRefs) + const rawSnapshotKeyRef = refs[0] + if (rawSnapshotKeyRef === undefined) throw new Error('snapshot key reference is missing') + const snapshotCredentialRef = credentialRef(rawSnapshotKeyRef) + snapshotKeyRef = snapshotCredentialRef + + const reason = 'rolled back snapshot-backed redaction' + const request = { + conversationId, + eventId: first.eventId, + reasonDigest: canonicalDigest(reason), + } as const + await assert.rejects( + storage.redact({ + conversationId, + eventId: first.eventId, + reason, + operation: { + operationId: createOperationId('op-snapshot-key-rollback'), + kind: 'redaction', + request, + requestDigest: canonicalDigest(request), + }, + }), + ) + assert.equal(snapshotKeyGoneBeforeMutation, true) + assert.equal(await credentials.has(snapshotCredentialRef), false) + assert.equal((await withRawDatabase(root, credentials, snapshotRows)).length, 1) + assert.equal(await storage.latestStateSnapshot(), null) + assert.equal((await withRawDatabase(root, credentials, snapshotRows)).length, 0) + const journal = await StorageJournal.fromStorage(storage, { + now: () => '2026-08-03T00:00:00.000Z', + }) + const replayed = journal.replay() + assert.equal(journal.initialState(), undefined) + assert.equal(replayed.length, 1) + const replayedState = replayed.reduce( + (state, event) => reduceEvent(state, event), + initialState(STARTER_PROFILE, { conversationId }), + ) + assert.equal(replayedState.sequence, 1) + assert.equal(replayedState.draft, `${canary}-1`) +}) + +test('production snapshots are encrypted, bounded, replayable, and key-deletable', async () => { + if (!sqliteAvailable) { + throw new Error( + 'W5_NATIVE_STORAGE_BLOCKED: better-sqlite3-multiple-ciphers@12.11.1 is not installed', + ) + } + const root = await mkdtemp(join(tmpdir(), 'braid-snapshot-')) + const credentials = new MemoryCredentialStore() + let committedSnapshots = 0 + const storage = await openSqliteStorage({ + path: join(root, 'braid.sqlite'), + workspaceRoot: root, + credentialStore: credentials, + databaseKeyRef, + durableBoundaryHook: (boundary) => { + if (boundary !== 'after:state.snapshot') return + committedSnapshots += 1 + if (committedSnapshots === 3) credentials.setAvailable(false) + }, + }) + const source = fixture() + const scopeId = storage.snapshotScopeId() + const snapshots = source.snapshots.map((snapshot) => ({ ...snapshot, scopeId })) + + await storage.append(source.events) + for (const snapshot of snapshots) await storage.writeStateSnapshot(snapshot) + credentials.setAvailable(true) + assert.equal(committedSnapshots, 3) + + const initialRows = await withRawDatabase(root, credentials, snapshotRows) + assert.equal(initialRows.length, 2) + assert.deepEqual( + initialRows.map((row) => row.generation), + [3, 2], + ) + for (const row of initialRows) { + if (!Buffer.isBuffer(row.state_ciphertext)) throw new Error('snapshot ciphertext is missing') + assert.equal( + row.state_ciphertext.includes(Buffer.from(canary)), + false, + 'snapshot ciphertext must not contain plaintext state', + ) + } + const snapshotTableColumns = await withRawDatabase(root, credentials, (database) => + ( + database.prepare('PRAGMA table_info(braid_state_snapshots)').all() as readonly { + readonly name?: unknown + }[] + ).map((row) => row.name), + ) + assert.equal(snapshotTableColumns.includes('state_json'), false) + const projectionState = await withRawDatabase( + root, + credentials, + (database) => + database + .prepare('SELECT state_json FROM braid_projection_state WHERE projection_name = ?') + .get('canonical') as { readonly state_json?: unknown } | undefined, + ) + assert.equal( + typeof projectionState?.state_json === 'string' && projectionState.state_json.includes(canary), + false, + ) + + const latest = await storage.latestStateSnapshot() + assert.equal(latest?.generation, 3) + + credentials.setAvailable(false) + await assert.rejects( + storage.latestStateSnapshot(), + (error: unknown) => + error instanceof Error && 'code' in error && error.code === 'CREDENTIAL_STORE_UNAVAILABLE', + ) + credentials.setAvailable(true) + const rowsAfterTransientFailure = await withRawDatabase(root, credentials, snapshotRows) + assert.equal(rowsAfterTransientFailure.length, 2) + for (const keyRef of await withRawDatabase(root, credentials, snapshotKeyRefs)) { + assert.equal(await credentials.has(credentialRef(keyRef)), true) + } + assert.equal((await storage.latestStateSnapshot())?.generation, 3) + await storage.close() + + const newest = await withRawDatabase(root, credentials, (database) => { + const row = snapshotRows(database)[0] + assert.ok(row) + const keyRef = row.key_ref + if (typeof keyRef !== 'string') throw new Error('snapshot key reference is missing') + if (!Buffer.isBuffer(row.state_ciphertext)) throw new Error('snapshot ciphertext is missing') + if (typeof row.snapshot_id !== 'number' && typeof row.snapshot_id !== 'bigint') { + throw new Error('snapshot id is invalid') + } + const corrupted = Buffer.from(row.state_ciphertext) + const last = corrupted.length - 1 + assert.ok(last >= 0) + corrupted[last] = (corrupted[last] ?? 0) ^ 0xff + database + .prepare('UPDATE braid_state_snapshots SET state_ciphertext = ? WHERE snapshot_id = ?') + .run(corrupted, row.snapshot_id) + corrupted.fill(0) + return { keyRef, generation: row.generation } + }) + assert.equal(newest.generation, 3) + + const reopened = await openSqliteStorage({ + path: join(root, 'braid.sqlite'), + workspaceRoot: root, + credentialStore: credentials, + databaseKeyRef, + }) + const fallback = await reopened.latestStateSnapshot() + assert.equal(fallback?.generation, 2) + assert.equal(await credentials.has(credentialRef(newest.keyRef)), false) + const secondRead = await reopened.latestStateSnapshot() + assert.equal(secondRead?.generation, 2) + assert.equal( + (await withRawDatabase(root, credentials, snapshotRows)).length, + 1, + 'invalid newest snapshot is quarantined once', + ) + + const journal = await StorageJournal.fromStorage(reopened, { + now: () => '2026-08-03T00:00:00.000Z', + }) + assert.equal(journal.initialState()?.sequence, 2) + assert.equal(journal.replay().length, 1) + + const priorKeyRefs = await withRawDatabase(root, credentials, snapshotKeyRefs) + assert.equal(priorKeyRefs.length, 1) + const reasonDigest = canonicalDigest('remove snapshot canary') + const request = { + conversationId, + eventId: createEventId('event-snapshot-1'), + reasonDigest, + } as const + const redactionOperation = { + operationId: 'op-snapshot-redact', + kind: 'redaction', + request, + requestDigest: canonicalDigest(request), + } as const + await reopened.redact({ + conversationId, + eventId: createEventId('event-snapshot-1'), + reason: 'remove snapshot canary', + operation: redactionOperation, + }) + const priorKeyRef = priorKeyRefs[0] + if (priorKeyRef === undefined) throw new Error('snapshot key reference is missing') + assert.equal(await credentials.has(credentialRef(priorKeyRef)), false) + assert.equal((await withRawDatabase(root, credentials, snapshotRows)).length, 0) + await reopened.close() +}) + +test('a checksum-valid but impossible materialized state is quarantined once', async (t) => { + if (!sqliteAvailable) { + throw new Error( + 'W5_NATIVE_STORAGE_BLOCKED: better-sqlite3-multiple-ciphers@12.11.1 is not installed', + ) + } + const root = await mkdtemp(join(tmpdir(), 'braid-snapshot-domain-')) + t.after(() => rm(root, { force: true, recursive: true })) + const credentials = new MemoryCredentialStore() + const storage = await openSqliteStorage({ + path: join(root, 'braid.sqlite'), + workspaceRoot: root, + credentialStore: credentials, + databaseKeyRef, + }) + t.after(() => storage.close()) + const source = fixture() + const scopeId = storage.snapshotScopeId() + const first = source.snapshots[0] + const second = source.snapshots[1] + const third = source.snapshots[2] + assert.ok(first && second && third) + await storage.append(source.events) + await storage.writeStateSnapshot({ ...first, scopeId }) + await storage.writeStateSnapshot({ ...second, scopeId }) + const impossibleState = { ...third.state, activeRunId: runId } + await storage.writeStateSnapshot({ + ...third, + scopeId, + state: impossibleState, + stateChecksum: canonicalDigest(impossibleState), + }) + + assert.equal((await storage.latestStateSnapshot())?.generation, 2) + assert.equal((await storage.latestStateSnapshot())?.generation, 2) + assert.equal((await withRawDatabase(root, credentials, snapshotRows)).length, 1) + assert.equal((await withRawDatabase(root, credentials, snapshotKeyRefs)).length, 1) +}) diff --git a/test/storage.test.ts b/test/storage.test.ts new file mode 100644 index 0000000..bcc67cf --- /dev/null +++ b/test/storage.test.ts @@ -0,0 +1,731 @@ +import assert from 'node:assert/strict' +import { copyFile, mkdtemp, readdir, readFile, stat, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import test from 'node:test' +import { MemoryCredentialStore } from '../src/adapters/credentials/memory.js' +import { MemoryStorage } from '../src/adapters/storage/memory.js' +import { openSqliteStorage } from '../src/adapters/storage/sqlite.js' +import { + configureCipherDatabase, + loadCipherDatabaseFactory, +} from '../src/adapters/storage/sqlite-driver.js' +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 { canonicalDigest } from '../src/domain/canonical.js' +import { + createConversationId, + createEventId, + createOperationId, + createRunId, + createWorkspaceId, +} from '../src/domain/ids.js' +import { FixedClock } from '../src/ports/clock.js' +import { credentialRef } from '../src/ports/credentials.js' +import type { JsonValue } from '../src/ports/storage.js' +import { FileCredentialStore } from './support/file-credentials.js' + +const require = createRequire(import.meta.url) +const sqliteAvailable = (() => { + try { + require('better-sqlite3-multiple-ciphers') + return true + } catch { + return false + } +})() + +function requireNativeStorage(): void { + if (!sqliteAvailable) { + throw new Error( + 'W5_NATIVE_STORAGE_BLOCKED: better-sqlite3-multiple-ciphers@12.11.1 is not installed', + ) + } +} + +const conversationId = createConversationId('conv-storage') +let mutationSequence = 0 + +function mutation( + kind: string, + request: JsonValue = {}, +): { + readonly operationId: ReturnType + readonly kind: string + readonly request: JsonValue + readonly requestDigest: ReturnType +} { + mutationSequence += 1 + return { + operationId: createOperationId(`op-${kind}-${mutationSequence}`), + kind, + request, + requestDigest: canonicalDigest(request), + } +} + +function journalEvent( + run: string, + sequence: number, + eventId: string, + payload: JsonValue, + terminal = false, +) { + return { + workspaceId: createWorkspaceId('workspace-storage'), + conversationId: createConversationId('conv-storage'), + runId: createRunId(run), + eventId: createEventId(eventId), + sequence, + kind: terminal ? 'run.finished' : 'run.text.delta', + payload, + occurredAt: '2026-08-02T00:00:00.000Z', + terminal, + } as const +} + +async function openFileStorage(root: string, databaseName = 'braid.sqlite') { + return openSqliteStorage({ + path: join(root, databaseName), + workspaceRoot: root, + credentialStore: new FileCredentialStore(join(root, 'credentials')), + databaseKeyRef: credentialRef('cred:v1:database-file-test'), + }) +} + +async function withDatabaseKey(root: string, action: (key: Buffer) => void): Promise { + const credentials = new FileCredentialStore(join(root, 'credentials')) + const handle = await credentials.resolve(credentialRef('cred:v1:database-file-test')) + const key = Buffer.from(handle.read()) + handle.dispose() + try { + action(key) + } finally { + key.fill(0) + } +} + +test('MemoryStorage remains behind StoragePort for deterministic duplicate and gap tests', async () => { + const storage = new MemoryStorage() + const first = journalEvent('run-memory', 1, 'event-memory-1', { text: 'one' }) + const duplicate = await storage.append([first]) + assert.deepEqual(duplicate.acceptedEventIds, [first.eventId]) + const replay = await storage.append([first]) + assert.deepEqual(replay.duplicateEventIds, [first.eventId]) + const gap = await storage.append([journalEvent('run-gap', 3, 'event-gap-3', { text: 'three' })]) + assert.equal(gap.missingHistory[0]?.fromSequence, 1) + assert.deepEqual( + (await storage.replay({ runId: createRunId('run-gap') })).events.map((event) => event.sequence), + [3], + ) + const sameLocalId = await storage.append([ + { + ...journalEvent('run-another', 1, 'event-memory-1', { text: 'scoped local id' }), + providerEventId: 'provider-event-1', + }, + ]) + assert.deepEqual(sameLocalId.acceptedEventIds, ['event-memory-1']) + assert.equal((await storage.replay({ runId: createRunId('run-gap') })).complete, false) + await storage.append([ + journalEvent('run-gap', 1, 'event-gap-1', { text: 'one' }), + journalEvent('run-gap', 2, 'event-gap-2', { text: 'two' }), + ]) + const complete = await storage.replay({ runId: createRunId('run-gap') }) + assert.equal(complete.complete, true) + assert.deepEqual( + complete.events.map((event) => event.sequence), + [1, 2, 3], + ) + const incremental = await storage.projectionChecksum() + const rebuilt = await storage.rebuild(mutation('rebuild')) + assert.equal(rebuilt.checksum, incremental) + + const missingRedaction = mutation('redaction-missing', { + conversationId: first.conversationId, + eventId: createEventId('event-memory-missing'), + reasonDigest: canonicalDigest('missing target'), + }) + await assert.rejects( + () => + storage.redact({ + conversationId: first.conversationId, + eventId: createEventId('event-memory-missing'), + reason: 'missing target', + operation: missingRedaction, + }), + (error: unknown) => error instanceof StorageError && error.code === 'EVENT_NOT_FOUND', + ) + await assert.rejects( + () => + storage.redact({ + conversationId: first.conversationId, + eventId: createEventId('event-memory-missing'), + reason: 'missing target', + operation: missingRedaction, + }), + (error: unknown) => error instanceof StorageError && error.code === 'OPERATION_FAILED_REPLAY', + ) +}) + +test('production SQLite adapter encrypts, replays, backs up, and destroys content keys', async () => { + requireNativeStorage() + const root = await mkdtemp(join(tmpdir(), 'braid-storage-')) + const databasePath = join(root, 'braid.sqlite') + const backupPath = join(root, 'braid.backup') + const credentials = new MemoryCredentialStore() + const storage = await openSqliteStorage({ + path: databasePath, + workspaceRoot: root, + credentialStore: credentials, + databaseKeyRef: credentialRef('cred:v1:database-test'), + }) + const canary = 'W5_RAW_BYTE_CANARY_2026' + const first = journalEvent('run-sqlite', 1, 'event-sqlite-1', { text: canary }) + const second = journalEvent('run-sqlite', 2, 'event-sqlite-2', { finalText: canary }, true) + await storage.append([first, second]) + const duplicate = await storage.append([first]) + assert.deepEqual(duplicate.duplicateEventIds, [first.eventId]) + const replay = await storage.replay({ runId: first.runId }) + assert.equal(replay.complete, true) + assert.equal(replay.events[0]?.payloadState, 'available') + assert.equal( + await storage.projectionChecksum(), + (await storage.rebuild(mutation('rebuild'))).checksum, + ) + assert.equal((await storage.integrity()).ok, true) + + const backup = await storage.backup({ + path: backupPath, + operation: mutation('backup', { path: backupPath }), + }) + assert.equal(backup.encrypted, true) + assert.ok((await stat(backupPath)).size > 0) + for (const path of [databasePath, `${databasePath}-wal`, `${databasePath}-shm`, backupPath]) { + const bytes = await readFile(path).catch(() => Buffer.alloc(0)) + assert.equal(bytes.includes(Buffer.from(canary)), false, path) + } + + await storage.destroyConversation({ + conversationId: first.conversationId, + reason: 'test destruction', + operation: mutation('destroy', { + conversationId: first.conversationId, + reasonDigest: canonicalDigest('test destruction'), + }), + }) + const destroyed = await storage.events({ conversationId: first.conversationId }) + assert.equal(destroyed[0]?.payloadState, 'deleted') + await storage.close() + + const bytesBeforeWrongCredentials = await Promise.all( + [databasePath, `${databasePath}-wal`, `${databasePath}-shm`].map((path) => + readFile(path).catch(() => Buffer.alloc(0)), + ), + ) + const wrongCredentials = new MemoryCredentialStore() + await assert.rejects( + () => + openSqliteStorage({ + path: databasePath, + workspaceRoot: root, + credentialStore: wrongCredentials, + databaseKeyRef: credentialRef('cred:v1:database-test'), + }), + (error: unknown) => error instanceof StorageError && error.code === 'SQLITE_KEY_REJECTED', + ) + assert.deepEqual( + await Promise.all( + [databasePath, `${databasePath}-wal`, `${databasePath}-shm`].map((path) => + readFile(path).catch(() => Buffer.alloc(0)), + ), + ), + bytesBeforeWrongCredentials, + ) +}) + +test('production SQLite adapter rejects secret-bearing payloads before commit', async () => { + requireNativeStorage() + const root = await mkdtemp(join(tmpdir(), 'braid-storage-secret-')) + const storage = await openSqliteStorage({ + path: join(root, 'braid.sqlite'), + workspaceRoot: root, + credentialStore: new MemoryCredentialStore(), + databaseKeyRef: credentialRef('cred:v1:database-secret-test'), + }) + await assert.rejects( + () => + storage.append([journalEvent('run-secret', 1, 'event-secret', { apiKey: 'do-not-persist' })]), + (error: unknown) => error instanceof StorageError && error.code === 'SECRET_PAYLOAD_REJECTED', + ) + await storage.close() +}) + +test('production SQLite scopes local event identifiers to a run and stores provider identity separately', async () => { + requireNativeStorage() + const root = await mkdtemp(join(tmpdir(), 'braid-storage-event-identity-')) + const storage = await openFileStorage(root) + await storage.append([ + { + ...journalEvent('run-event-identity-a', 1, 'event-local-identity', { text: 'first' }), + providerEventId: 'provider-event-a', + }, + { + ...journalEvent('run-event-identity-b', 1, 'event-local-identity', { text: 'second' }), + providerEventId: 'provider-event-b', + }, + ]) + const events = await storage.events() + assert.deepEqual( + events.map((event) => event.providerEventId), + ['provider-event-a', 'provider-event-b'], + ) + await storage.close() +}) + +test('production SQLite records changed-input conflicts without corrupting the original operation', async () => { + requireNativeStorage() + const root = await mkdtemp(join(tmpdir(), 'braid-storage-operation-conflict-')) + const storage = await openFileStorage(root) + const operationId = createOperationId('op-production-conflict') + const request = { text: 'original' } as const + const requestDigest = canonicalDigest(request) + await storage.reserveOperation({ operationId, kind: 'send', request, requestDigest }) + const conflict = await storage.reserveOperation({ + operationId, + kind: 'send', + request: { text: 'changed' }, + requestDigest: canonicalDigest({ text: 'changed' }), + }) + assert.equal(conflict.record.status, 'conflict') + assert.equal((await storage.operation(operationId))?.status, 'pending') + + await storage.completeOperation({ + operationId, + requestDigest, + status: 'terminal', + result: { accepted: true }, + }) + await storage.recordOperationConflict({ + operationId, + requestDigest, + attemptedDigest: canonicalDigest({ text: 'another change' }), + }) + assert.equal((await storage.operation(operationId))?.status, 'terminal') + await storage.close() +}) + +test('pending backup operations reconcile from the published artifact without rerunning the backup', async () => { + requireNativeStorage() + const root = await mkdtemp(join(tmpdir(), 'braid-storage-pending-backup-')) + const storage = await openFileStorage(root) + const backupPath = join(root, 'pending-backup.sqlite') + const request = { path: backupPath } as const + await storage.backup({ + path: backupPath, + operation: { + operationId: createOperationId('op-pending-backup-publish'), + kind: 'backup', + request, + requestDigest: canonicalDigest(request), + }, + }) + const pendingOperation = { + operationId: createOperationId('op-pending-backup-reconcile'), + kind: 'backup', + request, + requestDigest: canonicalDigest(request), + } + await storage.reserveOperation(pendingOperation) + const reconciled = await storage.backup({ path: backupPath, operation: pendingOperation }) + assert.equal(reconciled.path, backupPath) + assert.equal((await storage.operation(pendingOperation.operationId))?.status, 'terminal') + await storage.close() +}) + +test('operation IDs reconcile by digest and changed inputs become conflicts', async () => { + const storage = new MemoryStorage() + const operationId = createOperationId('op-storage') + const request = { text: 'same' } as const + const requestDigest = canonicalDigest(request) + const reservation = await storage.reserveOperation({ + operationId, + kind: 'send', + request, + requestDigest, + }) + assert.equal(reservation.created, true) + const replay = await storage.reserveOperation({ + operationId, + kind: 'send', + request, + requestDigest, + }) + assert.equal(replay.created, false) + const conflict = await storage.reserveOperation({ + operationId, + kind: 'send', + request: { text: 'changed' }, + requestDigest: canonicalDigest({ text: 'changed' }), + }) + assert.equal(conflict.record.status, 'conflict') + const originalReplay = await storage.reserveOperation({ + operationId, + kind: 'send', + request, + requestDigest, + }) + assert.equal(originalReplay.record.status, 'pending') + assert.equal(originalReplay.created, false) + await assert.rejects( + () => + storage.reserveOperation({ + operationId: createOperationId('op-invalid-time'), + kind: 'send', + request, + requestDigest, + createdAt: '1', + }), + (error: unknown) => error instanceof StorageError && error.code === 'OPERATION_INVALID', + ) + await assert.rejects( + () => storage.rebuild(mutation('rebuild', { unexpected: true })), + (error: unknown) => error instanceof StorageError && error.code === 'OPERATION_INVALID', + ) +}) + +test('production SQLite rejects an existing empty database path instead of treating it as a new store', async () => { + requireNativeStorage() + const root = await mkdtemp(join(tmpdir(), 'braid-storage-empty-')) + const databasePath = join(root, 'braid.sqlite') + await writeFile(databasePath, Buffer.alloc(0)) + await assert.rejects( + () => openFileStorage(root), + (error: unknown) => error instanceof StorageError && error.code === 'STORAGE_CORRUPT_EMPTY', + ) +}) + +test('production SQLite reopens projections and serializes concurrent writers', async () => { + requireNativeStorage() + const root = await mkdtemp(join(tmpdir(), 'braid-storage-restart-')) + const first = await openFileStorage(root) + const second = await openFileStorage(root) + const firstEvent = journalEvent('run-file-a', 1, 'event-file-a', { text: 'writer-a' }) + const secondEvent = journalEvent('run-file-b', 1, 'event-file-b', { text: 'writer-b' }) + await Promise.all([first.append([firstEvent]), second.append([secondEvent])]) + const checksum = await first.projectionChecksum() + assert.equal((await first.integrity()).ok, true) + await first.close() + await second.close() + + const reopened = await openFileStorage(root) + assert.equal(await reopened.projectionChecksum(), checksum) + assert.equal((await reopened.events()).length, 2) + assert.deepEqual( + (await reopened.reconcileNonTerminalRuns()).map((run) => run.runId), + [createRunId('run-file-a'), createRunId('run-file-b')], + ) + const backupPath = join(root, 'backups', 'before-third-event.sqlite') + await reopened.backup({ path: backupPath, operation: mutation('backup', { path: backupPath }) }) + await reopened.append([journalEvent('run-file-c', 1, 'event-file-c', { text: 'writer-c' })]) + assert.equal((await reopened.events()).length, 3) + const restored = await reopened.restore({ + path: backupPath, + operation: mutation('restore', { path: backupPath }), + }) + assert.equal(restored.integrity.ok, true) + assert.equal((await reopened.events()).length, 2) + await reopened.close() +}) + +test('production SQLite rejects raw-page corruption instead of rebuilding from damaged bytes', async () => { + requireNativeStorage() + const root = await mkdtemp(join(tmpdir(), 'braid-storage-corruption-')) + const storage = await openFileStorage(root) + await storage.append([journalEvent('run-corrupt', 1, 'event-corrupt-1', { text: 'corruption' })]) + await storage.close() + const corruptPath = join(root, 'corrupt.sqlite') + await copyFile(join(root, 'braid.sqlite'), corruptPath) + const bytes = await readFile(corruptPath) + const offset = Math.min(128, bytes.length - 1) + bytes[offset] = (bytes[offset] ?? 0) ^ 0xff + await writeFile(corruptPath, bytes) + await assert.rejects( + () => openFileStorage(root, 'corrupt.sqlite'), + (error: unknown) => error instanceof StorageError, + ) +}) + +test('production SQLite tracks missing history and closes the gap after replay', async () => { + requireNativeStorage() + const root = await mkdtemp(join(tmpdir(), 'braid-storage-gap-')) + const storage = await openFileStorage(root) + const gap = await storage.append([ + journalEvent('run-gap-sqlite', 3, 'event-gap-sqlite-3', { text: 'three' }), + ]) + assert.deepEqual(gap.missingHistory[0], { + runId: createRunId('run-gap-sqlite'), + fromSequence: 1, + toSequence: 3, + }) + const incompleteReplay = await storage.replay({ runId: createRunId('run-gap-sqlite') }) + assert.equal(incompleteReplay.lastSequence, 0) + assert.equal(incompleteReplay.lastCursor, undefined) + assert.equal(incompleteReplay.complete, false) + await storage.append([journalEvent('run-gap-sqlite', 1, 'event-gap-sqlite-1', { text: 'one' })]) + await storage.append([journalEvent('run-gap-sqlite', 2, 'event-gap-sqlite-2', { text: 'two' })]) + const replay = await storage.replay({ runId: createRunId('run-gap-sqlite') }) + assert.equal(replay.complete, true) + assert.equal(replay.lastSequence, 3) + assert.deepEqual( + replay.events.map((event) => event.eventId), + ['event-gap-sqlite-1', 'event-gap-sqlite-2', 'event-gap-sqlite-3'], + ) + await storage.close() +}) + +test('production SQLite retention, redaction rewrite, and content-key destruction remove readable history', async () => { + requireNativeStorage() + const root = await mkdtemp(join(tmpdir(), 'braid-storage-retention-')) + const canary = 'W5_RETENTION_SECRET_CANARY_2026' + const storage = await openFileStorage(root) + await storage.append([ + { + ...journalEvent('run-retention', 1, 'event-retention-1', { text: canary }), + receivedAt: '2026-08-02T00:00:00.000Z', + }, + { + ...journalEvent('run-retention', 2, 'event-retention-2', { text: canary }, true), + receivedAt: '2026-08-02T00:00:00.000Z', + }, + ]) + const retention = await storage.applyRetention({ + before: '2026-08-03T00:00:00.000Z', + operation: mutation('retention', { before: '2026-08-03T00:00:00.000Z' }), + }) + assert.equal(retention.redactedEvents, 2) + assert.equal( + (await storage.events()).every((event) => event.payloadState === 'redacted'), + true, + ) + await storage.compact(mutation('compact')) + for (const path of [ + join(root, 'braid.sqlite'), + join(root, 'braid.sqlite-wal'), + join(root, 'braid.sqlite-shm'), + ]) { + assert.equal( + (await readFile(path).catch(() => Buffer.alloc(0))).includes(Buffer.from(canary)), + false, + path, + ) + } + await storage.close() + + const redactionRoot = await mkdtemp(join(tmpdir(), 'braid-storage-redaction-')) + const redactionCredentials = new FileCredentialStore(join(redactionRoot, 'credentials')) + const redactionStorage = await openSqliteStorage({ + path: join(redactionRoot, 'braid.sqlite'), + workspaceRoot: redactionRoot, + credentialStore: redactionCredentials, + databaseKeyRef: credentialRef('cred:v1:database-redaction-test'), + }) + await redactionStorage.append([ + journalEvent('run-redaction', 1, 'event-redaction-1', { text: canary }), + journalEvent('run-redaction', 2, 'event-redaction-2', { text: 'retain this event' }, true), + ]) + const credentialsBefore = await readdir(join(redactionRoot, 'credentials')) + const redactionOperation = mutation('redact', { + conversationId, + eventId: createEventId('event-redaction-1'), + reasonDigest: canonicalDigest(canary), + }) + const report = await redactionStorage.redact({ + conversationId, + eventId: createEventId('event-redaction-1'), + reason: canary, + operation: redactionOperation, + }) + assert.equal(report.rewrittenEvents, 2) + const redactedEvents = await redactionStorage.events({ conversationId }) + assert.equal(redactedEvents[0]?.payloadState, 'redacted') + assert.equal(redactedEvents[1]?.payloadState, 'available') + assert.deepEqual( + await redactionStorage.redact({ + conversationId, + eventId: createEventId('event-redaction-1'), + reason: canary, + operation: redactionOperation, + }), + report, + ) + await redactionStorage.close() + + const reopenedRedaction = await openSqliteStorage({ + path: join(redactionRoot, 'braid.sqlite'), + workspaceRoot: redactionRoot, + credentialStore: redactionCredentials, + databaseKeyRef: credentialRef('cred:v1:database-redaction-test'), + }) + const replayedRedaction = await reopenedRedaction.events({ conversationId }) + assert.equal(replayedRedaction[0]?.payloadState, 'redacted') + assert.equal(replayedRedaction[1]?.payloadState, 'available') + const journal = await StorageJournal.fromStorage( + reopenedRedaction, + new FixedClock('2026-08-02T00:00:00.000Z'), + { workspaceId: createWorkspaceId('workspace-storage') }, + ) + const redactedEvent = journal.all()[0]?.event + assert.equal(redactedEvent?.kind, 'content.unavailable') + if (redactedEvent?.kind === 'content.unavailable') { + assert.equal(redactedEvent.reason, 'redacted') + } + await reopenedRedaction.compact(mutation('compact')) + assert.equal((await readdir(join(redactionRoot, 'credentials'))).length, credentialsBefore.length) + for (const path of [ + join(redactionRoot, 'braid.sqlite'), + join(redactionRoot, 'braid.sqlite-wal'), + join(redactionRoot, 'braid.sqlite-shm'), + ]) { + assert.equal( + (await readFile(path).catch(() => Buffer.alloc(0))).includes(Buffer.from(canary)), + false, + path, + ) + } + await reopenedRedaction.destroyConversation({ + conversationId, + reason: 'destroy conversation', + operation: mutation('destroy', { + conversationId, + reasonDigest: canonicalDigest('destroy conversation'), + }), + }) + await reopenedRedaction.close() + + const destroyed = await openSqliteStorage({ + path: join(redactionRoot, 'braid.sqlite'), + workspaceRoot: redactionRoot, + credentialStore: redactionCredentials, + databaseKeyRef: credentialRef('cred:v1:database-redaction-test'), + }) + assert.equal((await destroyed.events({ conversationId }))[0]?.payloadState, 'deleted') + const deletedJournal = await StorageJournal.fromStorage( + destroyed, + new FixedClock('2026-08-02T00:00:00.000Z'), + { workspaceId: createWorkspaceId('workspace-storage') }, + ) + const deletedEvent = deletedJournal.all()[0]?.event + assert.equal(deletedEvent?.kind, 'content.unavailable') + if (deletedEvent?.kind === 'content.unavailable') { + assert.equal(deletedEvent.reason, 'deleted') + } + await destroyed.close() +}) + +test('production SQLite rejects projection tampering and interrupted migrations after an encrypted backup', async () => { + requireNativeStorage() + const root = await mkdtemp(join(tmpdir(), 'braid-storage-integrity-')) + const storage = await openFileStorage(root) + await storage.append([ + journalEvent('run-integrity', 1, 'event-integrity-1', { text: 'integrity' }), + ]) + await storage.close() + await withDatabaseKey(root, (key) => { + const database = loadCipherDatabaseFactory()(join(root, 'braid.sqlite'), { timeout: 5_000 }) + try { + configureCipherDatabase(database, key) + applyConnectionPragmas(database, 5_000) + database + .prepare('UPDATE braid_projection_state SET checksum = ? WHERE projection_name = ?') + .run('0'.repeat(64), 'canonical') + } finally { + database.close() + } + }) + await assert.rejects( + () => openFileStorage(root), + (error: unknown) => + error instanceof StorageError && error.code === 'PROJECTION_CHECKSUM_MISMATCH', + ) + + const migrationRoot = await mkdtemp(join(tmpdir(), 'braid-storage-migration-')) + const migrationStorage = await openFileStorage(migrationRoot) + await migrationStorage.append([ + journalEvent('run-migration', 1, 'event-migration-1', { text: 'migration' }), + ]) + await migrationStorage.close() + await withDatabaseKey(migrationRoot, (key) => { + const database = loadCipherDatabaseFactory()(join(migrationRoot, 'braid.sqlite'), { + timeout: 5_000, + }) + try { + configureCipherDatabase(database, key) + applyConnectionPragmas(database, 5_000) + database.exec('PRAGMA user_version = 1') + } finally { + database.close() + } + }) + await assert.rejects( + () => + openSqliteStorage({ + path: join(migrationRoot, 'braid.sqlite'), + workspaceRoot: migrationRoot, + credentialStore: new FileCredentialStore(join(migrationRoot, 'credentials')), + databaseKeyRef: credentialRef('cred:v1:database-file-test'), + migrationHooks: { + beforeVersionCommit: () => { + throw new Error('migration interrupted') + }, + }, + }), + (error: unknown) => error instanceof StorageError, + ) + assert.equal( + (await readdir(migrationRoot)).some((name) => name.includes('pre-migration-v1')), + true, + ) + const migrated = await openFileStorage(migrationRoot) + assert.equal((await migrated.integrity()).ok, true) + await migrated.close() +}) + +test('production SQLite reports commit failures without exposing a plaintext fallback', async () => { + requireNativeStorage() + const root = await mkdtemp(join(tmpdir(), 'braid-storage-commit-failure-')) + const baseFactory = loadCipherDatabaseFactory() + let failCommit = false + const storage = await openSqliteStorage({ + path: join(root, 'braid.sqlite'), + workspaceRoot: root, + credentialStore: new FileCredentialStore(join(root, 'credentials')), + databaseKeyRef: credentialRef('cred:v1:database-commit-failure-test'), + databaseFactory: (path, options) => { + const database = baseFactory(path, options) + return new Proxy(database, { + get(target, property, receiver) { + if (property === 'exec') { + return (sql: string) => { + if (failCommit && sql === 'COMMIT') throw new Error('database or disk is full') + return target.exec(sql) + } + } + return Reflect.get(target, property, receiver) + }, + }) + }, + }) + failCommit = true + await assert.rejects( + () => + storage.append([ + journalEvent('run-commit-failure', 1, 'event-commit-failure', { text: 'must rollback' }), + ]), + (error: unknown) => error instanceof StorageError && error.code === 'STORAGE_COMMIT_FAILED', + ) + failCommit = false + assert.equal((await storage.events()).length, 0) + await storage.close() +}) diff --git a/test/support/file-credentials.ts b/test/support/file-credentials.ts new file mode 100644 index 0000000..2dc78e3 --- /dev/null +++ b/test/support/file-credentials.ts @@ -0,0 +1,96 @@ +import { createHash } from 'node:crypto' +import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import type { + CredentialPort, + CredentialRef, + CredentialStoreInput, + SecretHandle, +} from '../../src/ports/credentials.js' +import { CredentialError, credentialRef } from '../../src/ports/credentials.js' + +class FileSecretHandle implements SecretHandle { + readonly ref: CredentialRef + #value: Buffer | undefined + + constructor(ref: CredentialRef, value: Uint8Array) { + this.ref = ref + this.#value = Buffer.from(value) + } + + read(): Uint8Array { + if (this.#value === undefined) + throw new CredentialError('SECRET_HANDLE_CLOSED', 'Secret handle is closed') + return Buffer.from(this.#value) + } + + dispose(): void { + this.#value?.fill(0) + this.#value = undefined + } +} + +export class FileCredentialStore implements CredentialPort { + readonly #root: string + + constructor(root: string) { + this.#root = root + } + + async initialize(): Promise { + await mkdir(this.#root, { recursive: true, mode: 0o700 }) + await chmod(this.#root, 0o700) + } + + async store(input: CredentialStoreInput): Promise { + await this.initialize() + const ref = + input.ref ?? + credentialRef(`cred:v1:test-${createHash('sha256').update(String(Date.now())).digest('hex')}`) + const path = this.#path(ref) + const value = Buffer.from(input.value) + if (value.length === 0) + throw new CredentialError('EMPTY_SECRET', 'Credential values must not be empty') + await writeFile(path, value, { mode: 0o600 }) + await chmod(path, 0o600) + value.fill(0) + return ref + } + + async resolve(ref: CredentialRef): Promise { + try { + return new FileSecretHandle(ref, await readFile(this.#path(ref))) + } catch (error) { + throw new CredentialError('CREDENTIAL_NOT_FOUND', `Credential ${ref} was not found`, { + cause: error, + }) + } + } + + async remove(ref: CredentialRef): Promise { + await rm(this.#path(ref), { force: true }) + } + + async available(): Promise { + await this.initialize() + return true + } + + path(ref: CredentialRef): string { + return this.#path(ref) + } + + async has(ref: CredentialRef): Promise { + try { + await readFile(this.#path(ref)) + return true + } catch { + return false + } + } + + #path(ref: CredentialRef): string { + const digest = createHash('sha256').update(ref).digest('hex') + return join(this.#root, `${digest}.secret`) + } +} diff --git a/test/support/storage-snapshot-fixture.ts b/test/support/storage-snapshot-fixture.ts new file mode 100644 index 0000000..9a56487 --- /dev/null +++ b/test/support/storage-snapshot-fixture.ts @@ -0,0 +1,46 @@ +import { STARTER_PROFILE } from '../../src/app/composition.js' +import type { BraidEventEnvelope } from '../../src/domain/events.js' +import { createEventId } from '../../src/domain/ids.js' +import { createMaterializedStateSnapshot } from '../../src/domain/materialized-state-snapshot.js' +import { reduceEvent } from '../../src/domain/reducer.js' +import { initialState } from '../../src/domain/state.js' +import type { StateSnapshot } from '../../src/ports/storage.js' + +export function snapshotForStorage( + storage: { readonly snapshotScopeId: () => string }, + eventId: string, + sequence: number, +): StateSnapshot { + let state = initialState(STARTER_PROFILE) + for (let index = 1; index <= sequence; index += 1) { + const currentEventId = createEventId(`event-crash-${index}`) + const envelope: BraidEventEnvelope = { + eventId: currentEventId, + sequence: index, + revision: index, + occurredAt: '2026-08-02T00:00:00.000Z', + event: { kind: 'draft.changed', text: `snapshot-crash-${index}` }, + } + state = reduceEvent(state, envelope) + } + return createMaterializedStateSnapshot({ + scopeId: storage.snapshotScopeId(), + generation: sequence, + eventId: createEventId(eventId), + state, + }) +} + +export async function seedSnapshots( + storage: { + readonly snapshotScopeId: () => string + readonly writeStateSnapshot: (snapshot: StateSnapshot) => Promise + }, + eventIds: readonly string[], +): Promise { + for (let index = 0; index < eventIds.length; index += 1) { + const eventId = eventIds[index] + if (eventId === undefined) throw new Error('Snapshot fixture event id is missing') + await storage.writeStateSnapshot(snapshotForStorage(storage, eventId, index + 1)) + } +} diff --git a/test/support/virtual-terminal.ts b/test/support/virtual-terminal.ts index 4092c2c..6edeccc 100644 --- a/test/support/virtual-terminal.ts +++ b/test/support/virtual-terminal.ts @@ -7,9 +7,10 @@ * @source-license MIT * @adaptation Imports Pi's published Terminal type and follows Braid formatting. */ + +import type { Terminal } from '@earendil-works/pi-tui' import type { Terminal as XtermTerminalType } from '@xterm/headless' import xterm from '@xterm/headless' -import type { Terminal } from '@earendil-works/pi-tui' const XtermTerminal = xterm.Terminal diff --git a/test/terminal-responsive.test.ts b/test/terminal-responsive.test.ts new file mode 100644 index 0000000..2d6e217 --- /dev/null +++ b/test/terminal-responsive.test.ts @@ -0,0 +1,178 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { TUI, 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 { layoutFor } from '../src/views/tui/layout.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' +import { VirtualTerminal } from './support/virtual-terminal.js' + +const theme = createBraidTheme({ colors: false, highContrast: true, reducedMotion: true }) +const sizes = [ + [40, 12], + [80, 24], + [120, 40], + [200, 60], +] as const + +function viewForChrome(): BraidViewModel { + const app = createBraidApplication({ fixture: 'deterministic' }) + app.initialize('/home/drew/code/.worktrees/braid-integration') + const base = createApplicationUiController(app).view() + return { + ...base, + workspace: '/home/drew/code/.worktrees/braid-integration', + profileName: 'Braid starter', + model: 'fixture/deterministic', + effort: 'none', + connection: 'deterministic fixture', + conversationTitle: 'New conversation', + branch: 'branch-1', + status: 'completed', + statusText: 'completed', + runs: [ + { + id: 'run-metrics', + turnId: 'turn-metrics', + operationId: 'op-metrics', + status: 'completed', + completeness: 'complete', + usage: { input: 1_234, output: 567, costUsd: 0.0312 }, + }, + ], + } +} + +function plainLines(chrome: TerminalChrome, width: number): string[] { + return chrome.render(width) +} + +test('chrome uses complete responsive groups at every reference width', () => { + const view = viewForChrome() + const chrome = new TerminalChrome(theme) + chrome.setState({ + view, + quitArmed: false, + activityVisible: false, + navigationHint: 'Ctrl+P commands', + }) + + const narrow = plainLines(chrome, 40) + assert.deepEqual(narrow, ['braid cwd braid-integration', 'completed', 'Braid starter']) + assert.doesNotMatch(narrow.join('\n'), /\/home\/drew|\.worktrees|fixture|deterministic|…/u) + + const standard = plainLines(chrome, 80).join('\n') + assert.match(standard, /braid\s+cwd\s+braid-integration/u) + assert.match(standard, /New conversation/u) + assert.match(standard, /fixture\/deterministic/u) + assert.match(standard, /deterministic fixture/u) + assert.doesNotMatch(standard, /branch-1|in 1\.2k|out 567|\$0\.0312/u) + assert.doesNotMatch(standard, /…/u) + + const wide = plainLines(chrome, 120).join('\n') + assert.match(wide, /branch\s+branch-1/u) + assert.match(wide, /in 1\.2k/u) + assert.match(wide, /out 567/u) + assert.match(wide, /\$0\.0312/u) + assert.doesNotMatch(wide, /\/home\/drew|\.worktrees|…/u) + + for (const width of [1, 2, 10, 40, 80, 120, 200]) { + for (const line of plainLines(chrome, width)) assert.ok(visibleWidth(line) <= width) + } +}) + +test('chrome exposes active-run controls and failure recovery without hiding outcome identity', () => { + const chrome = new TerminalChrome(theme) + const base = viewForChrome() + const run = base.runs[0] + assert.ok(run) + chrome.setState({ + view: { + ...base, + status: 'running', + statusText: 'streaming', + activeRunId: 'run-metrics', + runs: [{ ...run, status: 'running', completeness: 'streaming' }], + }, + quitArmed: false, + activityVisible: false, + navigationHint: 'Ctrl+P commands', + }) + const active = chrome.render(80).join('\n') + assert.match(active, /Ctrl\+C cancel/u) + assert.match(active, /Enter queues input/u) + + chrome.setState({ + view: { + ...base, + status: 'failed', + statusText: 'RUNTIME_FINAL_ERROR', + runs: [ + { + ...run, + operationId: 'operation-that-is-long-enough-to-shorten', + status: 'failed', + completeness: 'failed', + }, + ], + }, + quitArmed: false, + activityVisible: false, + navigationHint: 'Ctrl+P commands', + }) + const failed = chrome.render(80).join('\n') + assert.match(failed, /outcome failed · operation operation-/u) + assert.match(failed, /\/export preserve · \/new continue/u) + assert.doesNotMatch(failed, /operation-that-is-long-enough-to-shorten/u) +}) + +test('layout breakpoints preserve transcript room and short-terminal overlays', () => { + assert.equal(layoutFor(40, 12).mode, 'narrow') + assert.equal(layoutFor(40, 12).overlayFullScreen, true) + assert.equal(layoutFor(80, 24).mode, 'standard') + assert.equal(layoutFor(80, 24).overlayFullScreen, false) + assert.equal(layoutFor(80, 12).overlayFullScreen, true) + + const wide = layoutFor(120, 40) + assert.equal(wide.mode, 'wide') + assert.equal(wide.activityWidth, 28) + assert.equal(wide.gap, 1) + assert.ok(wide.transcriptWidth >= 72) + assert.equal(layoutFor(200, 60).mode, 'wide') +}) + +test('published Pi virtual terminals keep the composer and valid cells at all sizes', async () => { + const view = viewForChrome() + for (const [columns, rows] of sizes) { + const terminal = new VirtualTerminal(columns, rows) + const tui = new TUI(terminal) + const shell = new BraidShell( + tui, + theme, + () => terminal.rows, + () => {}, + () => {}, + ) + shell.setView(view, false) + tui.addChild(shell) + tui.start() + await terminal.waitForRender() + + const viewport = terminal.getViewport() + assert.equal(viewport.length, rows, `${columns}x${rows} row count`) + for (const line of viewport) + assert.ok(visibleWidth(line) <= columns, `${columns}x${rows} line exceeds width`) + assert.match(viewport.join('\n'), /braid\s+cwd\s+braid-integration/u) + assert.match(viewport.join('\n'), /completed/u) + assert.ok( + viewport.some((line) => line.includes('─')), + `${columns}x${rows} composer border`, + ) + assert.doesNotMatch(viewport.join('\n'), /\/home\/drew|\.worktrees/u) + + tui.stop() + } +}) diff --git a/test/trace-analysis-configuration.test.ts b/test/trace-analysis-configuration.test.ts new file mode 100644 index 0000000..b02cc7c --- /dev/null +++ b/test/trace-analysis-configuration.test.ts @@ -0,0 +1,425 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import type { + ExternalOptimizerModelCallRequest, + ExternalOptimizerModelExecutionObservation, +} from '@tangle-network/agent-eval/campaign' +import type { AgentProfile } from '@tangle-network/agent-interface' +import { AgentEvalAnalystAdapter } from '../src/adapters/analysis/eval-analyst.js' +import { + type PythonCommandProbe, + type PythonCommandProbeResult, + resolvePythonRunner, + TRACE_ANALYSIS_PYTHON_PACKAGE_PROBE, +} from '../src/adapters/analysis/python-runner.js' +import { createRuntimeTraceModelOwner } from '../src/adapters/analysis/runtime-model-owner.js' +import { + createTraceAnalysisAdapter, + createTraceAnalysisAnalyst, + type TraceAnalysisAdapterOptions, +} from '../src/adapters/analysis/trace-analysis-adapter.js' +import { MemoryCredentialStore } from '../src/adapters/credentials/memory.js' +import { AnalysisCapabilityError } from '../src/app/analysis-types.js' +import type { ConnectionKind, ConnectionRecord } from '../src/domain/entities.js' +import { createConnectionId, createCredentialRefId } from '../src/domain/ids.js' +import { credentialRef } from '../src/ports/credentials.js' + +const NOW = '2026-08-03T20:00:00.000Z' +const PRICING = { inputUsdPerMillion: 1, outputUsdPerMillion: 2 } + +function connection( + kind: ConnectionKind, + name: string, + endpoint: string, + withCredential = false, +): ConnectionRecord { + return { + id: createConnectionId(`connection-analysis-${name}`), + kind, + name, + endpoint, + ...(withCredential + ? { credentialRef: createCredentialRefId(`credential-analysis-${name}`) } + : {}), + providerOptions: { transport: 'https' }, + createdAt: NOW, + updatedAt: NOW, + lastHealth: { status: 'unknown' }, + } +} + +function profile(model?: string): Readonly { + return model === undefined ? {} : { model: { default: model } } +} + +function successfulProbe( + calls: Array<{ command: string; args: readonly string[] }>, +): PythonCommandProbe { + return async (command, args) => { + calls.push({ command, args: [...args] }) + return { status: 'ok', exitCode: 0 } + } +} + +function baseOptions( + selected: ConnectionRecord, + overrides: Partial = {}, +): TraceAnalysisAdapterOptions { + return { + connection: selected, + profile: profile('glm-5.2'), + python: { command: '/opt/python with spaces', args: ['-u'] }, + pythonProbe: successfulProbe([]), + pricing: PRICING, + ...overrides, + } +} + +test('configures the published DSPy RLM engine and model-backed analyst registry', async () => { + const calls: Array<{ command: string; args: readonly string[] }> = [] + const selected = connection('cli-bridge', 'local', 'http://127.0.0.1:4010') + const result = await createTraceAnalysisAdapter( + baseOptions(selected, { pythonProbe: successfulProbe(calls) }), + ) + + assert.equal(result.status, 'engine-configured') + if (result.status !== 'engine-configured') return + assert.equal(result.model, 'glm-5.2') + assert.equal(result.connection.endpoint, 'http://127.0.0.1:4010') + assert.match(String(result.engine.executionConfig.call_ref), /^braid-agent-runtime:/u) + assert.equal(result.engine.executionConfig.model, 'glm-5.2') + assert.deepEqual(result.engine.executionConfig.pricing, PRICING) + assert.equal('base_url' in result.engine.executionConfig, false) + assert.equal('api_key_provided' in result.engine.executionConfig, false) + assert.deepEqual(result.modelExecutions(), []) + assert.equal(result.credentialState, 'not-required') + assert.deepEqual(calls, [ + { command: '/opt/python with spaces', args: ['-u', '--version'] }, + { + command: '/opt/python with spaces', + args: ['-u', '-c', TRACE_ANALYSIS_PYTHON_PACKAGE_PROBE], + }, + ]) + + const analystIds = result.registry.list().map((analyst) => analyst.id) + assert.ok(analystIds.includes('efficiency-behavioral')) + assert.ok(analystIds.includes('failure-mode')) + assert.ok(analystIds.includes('improvement')) + const askIds = new AgentEvalAnalystAdapter(result.registry).resolveAnalystIds({ recipe: 'ask' }) + assert.ok(askIds.includes('failure-mode')) + assert.ok(askIds.includes('knowledge-gap')) +}) + +test('resolves the selected connection credential in memory and never exposes it in configuration', async () => { + const selected = connection('tangle-inference', 'cloud', 'https://router.test', true) + const credentials = new MemoryCredentialStore() + const portRef = credentialRef('cred:v1:analysis-cloud') + const secret = 'analysis-secret-never-persisted' + await credentials.store({ ref: portRef, value: Buffer.from(secret) }) + const result = await createTraceAnalysisAdapter( + baseOptions(selected, { + credentials, + credentialRefResolver: () => portRef, + }), + ) + + assert.equal(result.status, 'engine-configured') + if (result.status !== 'engine-configured') return + assert.equal(result.credentialState, 'provided') + assert.match(String(result.engine.executionConfig.call_ref), /^braid-agent-runtime:/u) + assert.doesNotMatch(JSON.stringify(result), new RegExp(secret, 'u')) + assert.doesNotMatch(JSON.stringify(result.engine.executionConfig), new RegExp(secret, 'u')) +}) + +test('reports a missing model before probing Python', async () => { + let probes = 0 + const selected = connection('cli-bridge', 'model-required', 'http://127.0.0.1:4010') + const result = await createTraceAnalysisAdapter( + baseOptions(selected, { + profile: profile(), + pythonProbe: async () => { + probes += 1 + return { status: 'ok', exitCode: 0 } + }, + }), + ) + + assert.equal(result.status, 'missing-model') + assert.equal(result.diagnostics[0]?.kind, 'missing-model') + assert.equal(probes, 0) +}) + +test('materializes the selected AgentProfile runner into the CLI Bridge model route', async () => { + const selected = connection('cli-bridge', 'profile-route', 'http://127.0.0.1:4010') + const result = await createTraceAnalysisAdapter( + baseOptions(selected, { + profile: { + harness: 'pi', + model: { default: 'glm-5.2', provider: 'tangle-router' }, + }, + }), + ) + + assert.equal(result.status, 'engine-configured') + if (result.status !== 'engine-configured') return + assert.equal(result.runner, 'pi') + assert.equal(result.model, 'pi/tangle-router/glm-5.2') + assert.equal(result.engine.executionConfig.model, 'pi/tangle-router/glm-5.2') +}) + +test('rejects a CLI Bridge runner and model mismatch before probing Python', async () => { + let probes = 0 + const selected = connection('cli-bridge', 'profile-mismatch', 'http://127.0.0.1:4010') + const result = await createTraceAnalysisAdapter( + baseOptions(selected, { + profile: { harness: 'codex', model: { default: 'pi/tangle-router/glm-5.2' } }, + pythonProbe: async () => { + probes += 1 + return { status: 'ok', exitCode: 0 } + }, + }), + ) + + assert.equal(result.status, 'unavailable') + assert.equal(result.diagnostics[0]?.code, 'CONNECTION_MODEL_HARNESS_MISMATCH') + assert.equal(probes, 0) +}) + +test('reports sandbox connections as unsupported for direct trace analysis', async () => { + const selected = connection('tangle-sandbox', 'sandbox', 'https://sandbox.test') + const result = await createTraceAnalysisAdapter(baseOptions(selected)) + + assert.equal(result.status, 'unsupported-connection') + assert.equal(result.diagnostics[0]?.kind, 'unsupported-connection') + assert.match(result.diagnostics[0]?.message ?? '', /direct model endpoint/u) + assert.match(result.diagnostics[0]?.message ?? '', /cli-bridge/u) + assert.match(result.diagnostics[0]?.message ?? '', /tangle-inference/u) +}) + +test('reports a missing inference credential without invoking the engine', async () => { + const selected = connection('tangle-inference', 'credential-required', 'https://router.test') + const result = await createTraceAnalysisAdapter(baseOptions(selected)) + + assert.equal(result.status, 'missing-credential') + assert.equal(result.diagnostics[0]?.kind, 'missing-credential') + assert.equal(result.diagnostics[0]?.code, 'CONNECTION_CREDENTIAL_REQUIRED') +}) + +test('reports an absent agent-eval Python package distinctly from an absent interpreter', async () => { + const selected = connection('cli-bridge', 'python-package', 'http://127.0.0.1:4010') + const probe: PythonCommandProbe = async (_command, args): Promise => + args.includes('-c') ? { status: 'failed', exitCode: 13 } : { status: 'ok', exitCode: 0 } + const packageResult = await createTraceAnalysisAdapter( + baseOptions(selected, { pythonProbe: probe }), + ) + assert.equal(packageResult.status, 'missing-python-package') + assert.equal(packageResult.diagnostics[0]?.kind, 'missing-python-package') + + const missingResult = await createTraceAnalysisAdapter({ + connection: selected, + profile: profile('glm-5.2'), + pricing: PRICING, + pythonCandidates: ['python-command-that-does-not-exist'], + pythonProbe: async () => ({ status: 'not-found' }), + }) + assert.equal(missingResult.status, 'missing-python') + assert.equal(missingResult.diagnostics[0]?.kind, 'missing-python') +}) + +test('production unavailable trace analysis never falls back to deterministic analysts', async () => { + const selected = connection('cli-bridge', 'missing-python', 'http://127.0.0.1:4010') + const configuration = await createTraceAnalysisAdapter( + baseOptions(selected, { + pythonProbe: async () => ({ status: 'not-found' }), + }), + ) + const analyst = createTraceAnalysisAnalyst(configuration) + assert.deepEqual(analyst.list(), []) + assert.throws( + () => analyst.resolveAnalystIds({ recipe: 'ask' }), + (error: unknown) => + error instanceof AnalysisCapabilityError && + error.code === 'ANALYSIS_CAPABILITY_UNAVAILABLE' && + /python/iu.test(error.issue.reason), + ) +}) + +test('passes Python as an executable plus argv and never shell-interpolates it', async () => { + const command = 'python --version; touch /tmp/braid-should-not-exist' + const result = await resolvePythonRunner({ + runner: { command }, + probe: async (receivedCommand, args) => { + assert.equal(receivedCommand, command) + assert.deepEqual(args, ['--version']) + return { status: 'not-found' } + }, + }) + assert.equal(result.status, 'missing-python') +}) + +function optimizerRequest( + overrides: Partial = {}, +): ExternalOptimizerModelCallRequest { + return { + callId: 'analysis-model-call-1', + request: { + model: 'pi/tangle-router/glm-5.2', + messages: [ + { role: 'system', content: 'private analyst instruction' }, + { role: 'user', content: 'private trace question' }, + ], + maxTokens: 64, + temperature: 0.2, + jsonSchema: { + name: 'analysis_result', + schema: { type: 'object', properties: { answer: { type: 'string' } } }, + }, + ...overrides, + }, + endpointFormat: 'chat-completions', + signal: new AbortController().signal, + } +} + +function runtimeOwner( + fetchImpl: typeof fetch, + recordExecution?: (observation: ExternalOptimizerModelExecutionObservation) => void, +) { + const selected = connection('cli-bridge', 'runtime-owner', 'http://127.0.0.1:3344') + return createRuntimeTraceModelOwner({ + profile: { + harness: 'pi', + model: { default: 'pi/tangle-router/glm-5.2', reasoningEffort: 'high' }, + }, + connection: selected, + baseUrl: 'http://127.0.0.1:3344/v1', + credential: 'credential-never-recorded', + model: 'pi/tangle-router/glm-5.2', + pricing: PRICING, + fetch: fetchImpl, + ...(recordExecution === undefined ? {} : { recordExecution }), + }) +} + +test('runtime-owned trace model call preserves canonical messages, limits, usage, and safe evidence', async () => { + let receivedUrl = '' + let receivedAuthorization = '' + let receivedBody: Record | undefined + const owner = runtimeOwner(async (input, init) => { + receivedUrl = String(input) + receivedAuthorization = new Headers(init?.headers).get('authorization') ?? '' + receivedBody = JSON.parse(String(init?.body)) as Record + const stream = [ + `data: ${JSON.stringify({ model: 'pi/tangle-router/glm-5.2', choices: [{ delta: { content: '{"answer":' } }] })}`, + '', + `data: ${JSON.stringify({ model: 'pi/tangle-router/glm-5.2', choices: [{ delta: { content: '"ok"}' }, finish_reason: 'stop' }], usage: { prompt_tokens: 12, completion_tokens: 3, total_tokens: 15 } })}`, + '', + 'data: [DONE]', + '', + ].join('\n') + return new Response(stream, { status: 200, headers: { 'content-type': 'text/event-stream' } }) + }) + + const result = await owner.call(optimizerRequest()) + assert.equal(result.succeeded, true) + if (!result.succeeded) return + assert.equal(receivedUrl, 'http://127.0.0.1:3344/v1/chat/completions') + assert.equal(receivedAuthorization, 'Bearer credential-never-recorded') + assert.deepEqual(receivedBody?.messages, [ + { role: 'system', content: 'private analyst instruction' }, + { role: 'user', content: 'private trace question' }, + ]) + assert.equal(receivedBody?.max_tokens, 64) + assert.equal(receivedBody?.temperature, 0.2) + assert.deepEqual(receivedBody?.response_format, { + type: 'json_schema', + json_schema: { + name: 'analysis_result', + schema: { type: 'object', properties: { answer: { type: 'string' } } }, + strict: true, + }, + }) + assert.equal(result.response.content, '{"answer":"ok"}') + assert.deepEqual(result.response.usage, { + promptTokens: 12, + completionTokens: 3, + totalTokens: 15, + captured: true, + }) + assert.equal(result.response.costUsd, 0.000018) + assert.equal(result.receipt.inputTokens, 12) + assert.equal(result.receipt.outputTokens, 3) + assert.deepEqual(result.receipt.customTokenPricing, PRICING) + const execution = JSON.stringify(result.execution) + assert.doesNotMatch(execution, /credential-never-recorded/u) + assert.doesNotMatch(execution, /private analyst instruction/u) + assert.doesNotMatch(execution, /private trace question/u) + assert.match(execution, /runAgentTaskStream/u) + assert.match(execution, /"maxAttempts":1/u) +}) + +test('runtime-owned trace model failures resolve with explicit execution and accounting state', async () => { + let calls = 0 + const owner = runtimeOwner(async () => { + calls += 1 + return new Response('{"error":"private upstream body"}', { status: 503 }) + }) + const result = await owner.call(optimizerRequest()) + assert.equal(result.succeeded, false) + if (result.succeeded) return + assert.equal(calls, 1) + assert.equal(result.receipt.usageUnknown, true) + assert.equal(result.receipt.costUnknown, true) + assert.match(result.error, /503/u) + assert.doesNotMatch(JSON.stringify(result.execution), /private upstream body/u) + assert.equal((result.execution as { readonly dispatched?: unknown }).dispatched, true) +}) + +test('runtime-owned trace model rejects unsupported request shapes before spending', async () => { + let calls = 0 + const owner = runtimeOwner(async () => { + calls += 1 + throw new Error('fetch must not run') + }) + const result = await owner.call( + optimizerRequest({ + messages: [ + { + role: 'user', + content: [{ type: 'image_url', image_url: { url: 'https://example.test/image.png' } }], + }, + ], + }), + ) + assert.equal(result.succeeded, false) + if (result.succeeded) return + assert.equal(calls, 0) + assert.equal(result.receipt.inputTokens, 0) + assert.equal(result.receipt.outputTokens, 0) + assert.deepEqual(result.receipt.customTokenPricing, PRICING) + assert.equal(result.receipt.costUnknown, undefined) + assert.equal((result.execution as { readonly dispatched?: unknown }).dispatched, false) +}) + +test('runtime model execution observations are bounded, cloned, and externally recordable', () => { + const forwarded: ExternalOptimizerModelExecutionObservation[] = [] + const owner = runtimeOwner( + async () => new Response(null, { status: 500 }), + (observation) => forwarded.push(observation), + ) + const observation: ExternalOptimizerModelExecutionObservation = { + sequence: 1, + callId: 'analysis-model-call-1', + callRef: owner.callRef, + path: '/v1/chat/completions', + model: 'pi/tangle-router/glm-5.2', + succeeded: true, + responseStatus: 200, + execution: { receipt: 'finite' }, + } + owner.recordExecution(observation) + assert.deepEqual(owner.executions(), [observation]) + assert.deepEqual(forwarded, [observation]) + assert.notEqual(owner.executions()[0], observation) + assert.notEqual(forwarded[0], observation) +}) diff --git a/test/tui-autocomplete.test.ts b/test/tui-autocomplete.test.ts new file mode 100644 index 0000000..033ccd3 --- /dev/null +++ b/test/tui-autocomplete.test.ts @@ -0,0 +1,254 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { + type AutocompleteItem, + type AutocompleteProvider, + CombinedAutocompleteProvider, + Editor, + StdinBuffer, + TUI, +} from '@earendil-works/pi-tui' +import { createApplicationUiController } from '../src/adapters/tui/application-ui-controller.js' +import { createBraidApplication } from '../src/app/composition.js' +import { GuardedAutocompleteProvider } from '../src/views/tui/autocomplete-guard.js' +import { BraidTerminalApp } from '../src/views/tui/terminal-app.js' +import { createBraidTheme } from '../src/views/tui/theme.js' +import { VirtualTerminal } from './support/virtual-terminal.js' + +async function settle(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)) + await new Promise((resolve) => setTimeout(resolve, 0)) +} + +async function waitUntil(predicate: () => boolean, timeoutMs = 1_000): Promise { + const deadline = Date.now() + timeoutMs + while (!predicate()) { + if (Date.now() >= deadline) throw new Error('Timed out waiting for autocomplete state') + await new Promise((resolve) => setTimeout(resolve, 5)) + } +} + +function fakeProvider(item: AutocompleteItem): AutocompleteProvider { + return { + async getSuggestions(lines, cursorLine, cursorCol) { + return { + prefix: lines[cursorLine]?.slice(0, cursorCol) ?? '', + items: [item], + } + }, + applyCompletion(lines, cursorLine, cursorCol, selected, prefix) { + const line = lines[cursorLine] ?? '' + const start = Math.max(0, cursorCol - prefix.length) + const next = [...lines] + next[cursorLine] = `${line.slice(0, start)}${selected.value}${line.slice(cursorCol)}` + return { lines: next, cursorLine, cursorCol: start + selected.value.length } + }, + } +} + +function createEditor(delegate: AutocompleteProvider): { + readonly editor: Editor + readonly provider: GuardedAutocompleteProvider + readonly stop: () => void +} { + const terminal = new VirtualTerminal(100, 30) + const tui = new TUI(terminal) + const provider = new GuardedAutocompleteProvider(delegate) + const editor = new Editor(tui, createBraidTheme(false).editor, { paddingX: 1 }) + editor.onChange = () => provider.inputChanged() + editor.setAutocompleteProvider(provider) + tui.addChild(editor) + tui.setFocus(editor) + tui.start() + return { editor, provider, stop: () => tui.stop() } +} + +test('human typing cannot apply a stale slash completion on Enter', async () => { + const { editor, stop } = createEditor(fakeProvider({ value: 'new', label: 'new' })) + let submitted = '' + editor.onSubmit = (text) => { + submitted = text + } + try { + editor.handleInput('/') + await settle() + assert.equal(editor.isShowingAutocomplete(), true) + + for (const character of 'profile') editor.handleInput(character) + editor.handleInput('\r') + + assert.equal(submitted, '/profile') + assert.equal(editor.getText(), '') + } finally { + stop() + } +}) + +test('paste bypasses slash completion and submits the exact pasted draft', () => { + const { editor, stop } = createEditor(fakeProvider({ value: 'new', label: 'new' })) + let submitted = '' + editor.onSubmit = (text) => { + submitted = text + } + try { + editor.handleInput('\u001b[200~/profile\u001b[201~') + editor.handleInput('\r') + assert.equal(submitted, '/profile') + } finally { + stop() + } +}) + +test('same-chunk text followed by Enter submits the exact slash command', async () => { + const { editor, stop } = createEditor(fakeProvider({ value: 'new', label: 'new' })) + const input = new StdinBuffer() + let submitted = '' + editor.onSubmit = (text) => { + submitted = text + } + input.on('data', (sequence: string) => editor.handleInput(sequence)) + try { + editor.handleInput('/') + await settle() + input.process('profile\r') + assert.equal(submitted, '/profile') + } finally { + input.destroy() + stop() + } +}) + +test('Tab cannot apply a completion left over from an older draft', async () => { + const { editor, stop } = createEditor(fakeProvider({ value: 'new', label: 'new' })) + try { + editor.handleInput('/') + await settle() + editor.handleInput('profile') + editor.handleInput('\t') + assert.equal(editor.getText(), '/profile') + } finally { + stop() + } +}) + +test('moving the cursor prevents a completion for its former position', async () => { + const { editor, stop } = createEditor(fakeProvider({ value: 'new', label: 'new' })) + try { + editor.handleInput('/') + await settle() + editor.handleInput('\u001b[D') + editor.handleInput('\t') + assert.equal(editor.getText(), '/') + } finally { + stop() + } +}) + +test('an async completion for the old draft is discarded', async () => { + let releaseOld: () => void = () => {} + let markStarted: () => void = () => {} + const oldResultStarted = new Promise((resolve) => { + markStarted = resolve + }) + const oldResult = new Promise((resolve) => { + releaseOld = resolve + }) + const delegate: AutocompleteProvider = { + async getSuggestions(lines, cursorLine, cursorCol) { + const prefix = lines[cursorLine]?.slice(0, cursorCol) ?? '' + if (prefix === '/') { + markStarted() + await oldResult + return { prefix, items: [{ value: 'new', label: 'new' }] } + } + return null + }, + applyCompletion: fakeProvider({ value: 'new', label: 'new' }).applyCompletion, + } + const { editor, stop } = createEditor(delegate) + let submitted = '' + editor.onSubmit = (text) => { + submitted = text + } + try { + editor.handleInput('/') + await oldResultStarted + editor.handleInput('profile') + releaseOld() + await settle() + assert.equal(editor.isShowingAutocomplete(), false) + editor.handleInput('\r') + assert.equal(submitted, '/profile') + } finally { + stop() + } +}) + +test('an exact slash command remains exact while Enter accepts a current suggestion', async () => { + const delegate = new CombinedAutocompleteProvider( + [{ name: 'profile', description: 'select a profile' }], + '/workspace', + null, + ) + const { editor, stop } = createEditor(delegate) + let submitted = '' + editor.onSubmit = (text) => { + submitted = text + } + try { + editor.handleInput('/') + for (const character of 'profile') editor.handleInput(character) + await settle() + editor.handleInput('\r') + assert.equal(submitted, '/profile') + } finally { + stop() + } +}) + +test('Tab selects a completion only for the current editor state', async () => { + const delegate = new CombinedAutocompleteProvider( + [{ name: 'profile', description: 'select a profile' }], + '/workspace', + null, + ) + const { editor, stop } = createEditor(delegate) + try { + editor.handleInput('/') + for (const character of 'pro') editor.handleInput(character) + await settle() + editor.handleInput('\t') + assert.equal(editor.getText(), '/profile ') + } finally { + stop() + } +}) + +test('the production terminal does not turn a fast profile command into unknown text', async () => { + const terminal = new VirtualTerminal(120, 36) + const tui = new TUI(terminal) + const app = createBraidApplication({ fixture: 'deterministic' }) + app.initialize('/workspace') + const controller = createApplicationUiController(app) + const view = new BraidTerminalApp({ + controller, + tui, + theme: createBraidTheme(false), + workspace: '/workspace', + nextOperationId: () => 'op-autocomplete-profile', + }) + const done = view.start() + try { + terminal.sendInput('/') + await waitUntil(() => view.editor.isShowingAutocomplete()) + terminal.sendInput('profile') + terminal.sendInput('\r') + await waitUntil(() => terminal.getViewport().join('\n').includes('profiles')) + const screen = terminal.getViewport().join('\n') + assert.doesNotMatch(screen, /unknown command \/profilnew/iu) + assert.doesNotMatch(screen, /unknown command \/profilenew/iu) + } finally { + view.stop() + await done + } +}) diff --git a/test/tui-conversations.test.ts b/test/tui-conversations.test.ts new file mode 100644 index 0000000..0dddcdd --- /dev/null +++ b/test/tui-conversations.test.ts @@ -0,0 +1,209 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { TUI, visibleWidth } from '@earendil-works/pi-tui' +import { createApplicationUiController } from '../src/adapters/tui/application-ui-controller.js' +import { createBraidApplication } from '../src/app/composition.js' +import { BraidTerminalApp } from '../src/views/tui/terminal-app.js' +import { createBraidTheme } from '../src/views/tui/theme.js' +import { VirtualTerminal } from './support/virtual-terminal.js' + +function operationIds(prefix: string): () => string { + let index = 0 + return () => `${prefix}-${++index}` +} + +async function settle(terminal: VirtualTerminal): Promise { + await terminal.waitForRender() + return terminal.getViewport().join('\n') +} + +function startTerminal(app: ReturnType, terminal: VirtualTerminal) { + const tui = new TUI(terminal) + const view = new BraidTerminalApp({ + controller: createApplicationUiController(app), + tui, + theme: createBraidTheme(false), + workspace: '/workspace', + nextOperationId: operationIds('op-tui'), + }) + return { view, done: view.start() } +} + +async function stopTerminal(view: BraidTerminalApp, done: Promise): Promise { + view.stop() + await done +} + +test('conversation search exposes metadata and opens the selected branch by keyboard', async () => { + const app = createBraidApplication({ fixture: 'deterministic' }) + app.initialize('/workspace') + const source = app.state().conversationId + const archived = await app.conversations.lifecycle.create({ + operationId: 'op-tui-search-create', + title: 'Archived release review', + }) + await app.conversations.lifecycle.archive({ + operationId: 'op-tui-search-archive', + conversationId: archived.id, + archived: true, + }) + await app.conversations.lifecycle.open({ + operationId: 'op-tui-search-open-source', + conversationId: source, + }) + + const terminal = new VirtualTerminal(80, 24) + const { view, done } = startTerminal(app, terminal) + terminal.sendInput('\u000f') + let screen = await settle(terminal) + assert.match(screen, /conversations/u) + assert.match(screen, /ws:workspace/u) + assert.match(screen, /branch/u) + assert.match(screen, /\d{4}-\d{2}-\d{2}/u) + assert.match(screen, /archived/u) + + terminal.sendInput(archived.activeBranchId) + screen = await settle(terminal) + assert.match(screen, /Archived release review/u) + terminal.sendInput('\r') + await settle(terminal) + assert.equal(app.state().conversationId, archived.id) + assert.equal(app.state().branchId, archived.activeBranchId) + + terminal.sendInput('\u000f') + await settle(terminal) + terminal.sendInput('/workspace') + screen = await settle(terminal) + assert.match(screen, /Archived release review/u) + terminal.sendInput('\u001b') + await stopTerminal(view, done) +}) + +test('conversation selector stays keyboard usable at 40 columns by 12 rows', async () => { + const app = createBraidApplication({ fixture: 'deterministic' }) + app.initialize('/workspace') + const terminal = new VirtualTerminal(40, 12) + const { view, done } = startTerminal(app, terminal) + terminal.sendInput('\u000f') + const screen = await settle(terminal) + assert.match(screen, /conversations/u) + assert.match(screen, /type to filter/u) + for (const line of terminal.getViewport()) assert.ok(visibleWidth(line) <= 40) + terminal.sendInput('New') + terminal.sendInput('\r') + await settle(terminal) + assert.equal(app.state().conversationId.length > 0, true) + await stopTerminal(view, done) +}) + +test('rename, archive, and delete require confirmation and preserve the selector on cancel', async () => { + const app = createBraidApplication({ fixture: 'deterministic' }) + app.initialize('/workspace') + const conversationId = app.state().conversationId + const terminal = new VirtualTerminal(80, 24) + const { view, done } = startTerminal(app, terminal) + + terminal.sendInput('\u000f') + await settle(terminal) + terminal.sendInput('\u0012') + let screen = await settle(terminal) + assert.match(screen, /rename conversation/u) + terminal.sendInput('Renamed flow') + terminal.sendInput('\r') + await settle(terminal) + assert.equal( + app.state().conversations.find((item) => item.id === conversationId)?.title, + 'Renamed flow', + ) + + terminal.sendInput('\u000f') + await settle(terminal) + terminal.sendInput('\u0001') + screen = await settle(terminal) + assert.match(screen, /archive conversation/u) + terminal.sendInput('n') + screen = await settle(terminal) + assert.match(screen, /conversations/u) + assert.equal( + app.state().conversations.find((item) => item.id === conversationId)?.archived, + false, + ) + + terminal.sendInput('\u0001') + await settle(terminal) + terminal.sendInput('y') + await settle(terminal) + assert.equal(app.state().conversations.find((item) => item.id === conversationId)?.archived, true) + + terminal.sendInput('\u000f') + await settle(terminal) + terminal.sendInput('Renamed') + await settle(terminal) + terminal.sendInput('\u0004') + screen = await settle(terminal) + assert.match(screen, /delete conversation/u) + terminal.sendInput('n') + screen = await settle(terminal) + assert.match(screen, /conversations/u) + terminal.sendInput('\u0004') + await settle(terminal) + terminal.sendInput('y') + await settle(terminal) + assert.notEqual( + app.state().conversations.find((item) => item.id === conversationId)?.deletedAt, + undefined, + ) + await stopTerminal(view, done) +}) + +test('a lifecycle failure keeps the confirmation target available for recovery', async () => { + const app = createBraidApplication({ fixture: 'deterministic', chunkDelayMs: 1_000 }) + app.initialize('/workspace') + const terminal = new VirtualTerminal(80, 24) + const { view, done } = startTerminal(app, terminal) + terminal.sendInput('\u000f') + await settle(terminal) + const receipt = app.send({ operationId: 'op-tui-busy-run', text: 'keep this run active' }) + await settle(terminal) + terminal.sendInput('\u0004') + await settle(terminal) + terminal.sendInput('y') + const screen = await settle(terminal) + assert.match(screen, /delete conversation/u) + assert.match(screen, /failed|active run|busy|not terminal/u) + terminal.sendInput('n') + await settle(terminal) + app.cancelActive() + await receipt.completion + await stopTerminal(view, done) +}) + +test('fork preview executes the existing plan and branch navigation works with alt arrows', async () => { + const app = createBraidApplication({ fixture: 'deterministic' }) + app.initialize('/workspace') + const sourceBranch = app.state().branchId + await app.conversations.branches.create({ operationId: 'op-tui-branch-create' }) + const branched = app.state().branchId + assert.notEqual(branched, sourceBranch) + + const terminal = new VirtualTerminal(80, 24) + const { view, done } = startTerminal(app, terminal) + terminal.sendInput('\u001bp') + await settle(terminal) + assert.equal(app.state().branchId, sourceBranch) + + terminal.sendInput('\u001b') + await settle(terminal) + terminal.sendInput('/fork') + terminal.sendInput('\r') + let screen = await settle(terminal) + assert.match(screen, /fork preview/u) + assert.match(screen, /enter\/y create fork/u) + const beforeFork = app.state().branches.length + terminal.sendInput('y') + await settle(terminal) + assert.ok(app.state().branches.length > beforeFork) + screen = await settle(terminal) + assert.doesNotMatch(screen, /enter\/y create fork/u) + await stopTerminal(view, done) +}) diff --git a/test/tui-core-workflows.test.ts b/test/tui-core-workflows.test.ts new file mode 100644 index 0000000..a0902b5 --- /dev/null +++ b/test/tui-core-workflows.test.ts @@ -0,0 +1,680 @@ +import assert from 'node:assert/strict' +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 { TUI, visibleWidth } from '@earendil-works/pi-tui' +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' +import type { BraidViewModel, InteractionView } from '../src/views/shared/models.js' +import { AnalysisViewPanel } from '../src/views/tui/analysis.js' +import { + 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' +import { GraphView } from '../src/views/tui/graph.js' +import { InteractionShell } from '../src/views/tui/interaction.js' +import { BraidTerminalApp } from '../src/views/tui/terminal-app.js' +import { createBraidTheme } from '../src/views/tui/theme.js' +import { VirtualTerminal } from './support/virtual-terminal.js' + +const theme = createBraidTheme(false) + +async function waitUntil(predicate: () => boolean, timeoutMs = 1_000): Promise { + const deadline = Date.now() + timeoutMs + while (!predicate()) { + if (Date.now() >= deadline) throw new Error('Timed out waiting for terminal input') + await new Promise((resolve) => setTimeout(resolve, 5)) + } +} + +async function renderOverlay( + component: Parameters[0], + columns: number, + rows: number, +): Promise { + const terminal = new VirtualTerminal(columns, rows) + const tui = new TUI(terminal) + tui.showOverlay(component, { + anchor: 'top-left', + margin: 0, + width: '100%', + maxHeight: '100%', + }) + tui.start() + try { + await terminal.waitForRender() + return terminal.getViewport() + } finally { + tui.stop() + } +} + +function baseView(): BraidViewModel { + const app = createBraidApplication({ fixture: 'deterministic' }) + app.initialize('/workspace') + return createApplicationUiController(app).view() +} + +function assertFits(lines: readonly string[], columns: number): void { + for (const line of lines) assert.ok(visibleWidth(line) <= columns, line) +} + +function forkView(): BraidViewModel { + return { + ...baseView(), + forkPreview: { + kind: 'conversation', + source: 'conversation:source / branch:main', + destination: 'conversation:copy / branch:fork', + fields: [ + { + label: 'transcript boundary', + source: 'message:42', + destination: 'message:42', + }, + { + label: 'profile snapshot', + source: 'profile:source-digest', + destination: 'profile:copy-digest', + }, + { + label: 'working tree', + source: 'checkpoint:source', + destination: 'checkpoint:copy', + }, + { + label: 'operation id', + source: 'operation-fork-preview', + destination: 'operation-fork-preview', + }, + { + label: 'plan digest', + source: 'digest:fork-preview', + destination: 'digest:fork-preview', + }, + ], + allowed: true, + }, + } +} + +function graphView(): BraidViewModel { + return { + ...baseView(), + branch: 'branch-current', + graph: [ + { + id: 'conversation-1', + type: 'conversation', + title: 'Terminal workflow', + status: 'completed', + depth: 0, + }, + { + id: 'branch-current', + type: 'branch', + title: 'Main', + status: 'completed', + depth: 1, + edgeLabel: 'continued', + }, + { + id: 'analysis-1', + type: 'analysis', + title: 'failure', + status: 'completed', + depth: 2, + edgeLabel: 'analyzed', + }, + { + id: 'comparison-1', + type: 'analysis', + title: 'paired runs', + status: 'completed', + depth: 2, + edgeLabel: 'compared_left', + }, + ], + } +} + +function analysisView(recipe: string): BraidViewModel { + return { + ...baseView(), + analysis: { + source: 'run:source-run · digest:source-digest', + analyst: 'profile:analyst', + recipe, + status: 'completed', + findings: [ + { + id: 'finding-1', + title: 'retry dominates wait', + severity: 'high', + confidence: 'high', + citationIds: ['event-1'], + }, + ], + citations: [ + { + id: 'event-1', + eventId: 'event-1', + text: 'retry started after the provider timeout', + }, + ], + footer: [{ label: 'cost', value: '$0.02 · 120ms' }], + }, + } +} + +const permission: InteractionView = { + runId: 'run-permission', + interactionId: 'interaction-permission', + kind: 'permission', + prompt: 'Allow the runner to read this file?', + subject: { + type: 'file', + title: 'src/app/application.ts', + target: 'read-only', + detail: 'The runner will inspect the current source without changing it.', + }, + answerSpec: { kind: 'boolean', required: true }, + allowedOutcomes: ['accept', 'reject', 'cancel'], + queuePosition: 0, + secret: false, +} + +test('core workflow overlays keep mode, consequence, and controls visible at 40x12 and 80x24', async () => { + const fork = new ForkPreviewPanel(theme) + fork.setView(forkView()) + const graph = new GraphView(theme) + graph.setView(graphView()) + + for (const [columns, rows] of [ + [40, 12], + [80, 24], + ] as const) { + const forkScreen = await renderOverlay(fork, columns, rows) + assertFits(forkScreen, columns) + assert.match(forkScreen.join('\n'), /source: conversation:source/u) + assert.match(forkScreen.join('\n'), /destination: conversation:copy/u) + assert.match(forkScreen.join('\n'), /boundary: message:42/u) + assert.match(forkScreen.join('\n'), /enter\/y create fork · esc cancel/u) + + const graphScreen = await renderOverlay(graph, columns, rows) + assertFits(graphScreen, columns) + assert.match(graphScreen.join('\n'), /current branch/u) + assert.match(graphScreen.join('\n'), /› current/u) + assert.match(graphScreen.join('\n'), /─analyzed→/u) + assert.match(graphScreen.join('\n'), /─compared left→/u) + assert.match(graphScreen.join('\n'), /esc close/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 + assert(forkBase) + fork.setView({ + ...forkView(), + forkPreview: { + ...forkBase, + fields: [ + ...forkBase.fields, + ...Array.from({ length: 8 }, (_, index) => ({ + label: `metadata ${index + 1}`, + source: `source-${index + 1}`, + destination: `destination-${index + 1}`, + })), + ], + }, + }) + const forkScreen = await renderOverlay(fork, 40, 12) + assert.match(forkScreen.join('\n'), /enter\/y create fork · esc cancel/u) + + const graph = new GraphView(theme) + graph.setView({ + ...graphView(), + graph: Array.from({ length: 16 }, (_, index) => ({ + id: index === 15 ? 'branch-current' : `run-${index + 1}`, + type: index === 15 ? ('branch' as const) : ('run' as const), + title: index === 15 ? 'Main' : `run ${index + 1}`, + status: 'completed' as const, + depth: index === 15 ? 1 : 2, + ...(index === 15 ? { edgeLabel: 'continued' } : {}), + })), + }) + const graphScreen = await renderOverlay(graph, 40, 12) + assert.match(graphScreen.join('\n'), /› current/u) + assert.match(graphScreen.join('\n'), /\+8 nodes not shown/u) + assert.match(graphScreen.join('\n'), /esc close/u) + + const analysisBase = analysisView('failure') + const analysisValue = analysisBase.analysis + assert(analysisValue) + const analysis = new AnalysisViewPanel(theme) + analysis.setView({ + ...analysisBase, + analysis: { + ...analysisValue, + findings: Array.from({ length: 8 }, (_, index) => ({ + id: `finding-${index + 1}`, + title: `finding ${index + 1}`, + severity: 'high', + confidence: 'medium', + citationIds: [`event-${index + 1}`], + })), + citations: Array.from({ length: 8 }, (_, index) => ({ + id: `event-${index + 1}`, + eventId: `event-${index + 1}`, + text: `evidence ${index + 1}`, + })), + footer: [ + { label: 'cost', value: '$0.02' }, + { label: 'wall', value: '120ms' }, + ], + error: 'The analysis adapter returned an unavailable result.', + }, + }) + const analysisScreen = await renderOverlay(analysis, 40, 12) + assert.match(analysisScreen.join('\n'), /page 1\/3/u) + analysis.handleInput('\u001b[B') + assert.match(analysis.render(40).join('\n'), /finding 4/u) + assert.match(analysisScreen.join('\n'), /esc close/u) +}) + +test('analysis mode copy distinguishes ask, named analyze recipes, and compare', async () => { + const expected = [ + ['/ask · frozen question', 'ask'] as const, + ['/analyze · failure', 'failure'] as const, + ['/compare · paired sources', 'compare'] as const, + ] + for (const [heading, recipe] of expected) { + const panel = new AnalysisViewPanel(theme) + panel.setView(analysisView(recipe)) + const screen = await renderOverlay(panel, 40, 12) + assertFits(screen, 40) + assert.match( + screen.join('\n'), + new RegExp(heading.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&'), 'u'), + ) + assert.match(screen.join('\n'), /source: run:source-run/u) + assert.match(screen.join('\n'), /\[event-1\]/u) + assert.match(screen.join('\n'), /esc close/u) + } +}) + +test('comparison view leads with both outcomes and pages through every captured field', async () => { + const rows = [ + { + pairKey: 'pair-1', + arm: 'baseline', + pass: true, + metrics: { cost_usd: 0.02, latency_ms: 120 }, + }, + { + pairKey: 'pair-1', + arm: 'candidate', + pass: false, + metrics: { latency_ms: 80 }, + }, + ] + const result: AnalysisComparisonResult = { + baselineSourceDigest: 'digest-baseline', + candidateSourceDigest: 'digest-candidate', + baselineRunId: 'run-baseline', + candidateRunId: 'run-candidate', + fields: [ + { + name: 'run.status', + baseline: 'completed', + candidate: 'failed', + baselinePresent: true, + candidatePresent: true, + asymmetry: 'none', + }, + { + name: 'run.cost_usd', + baseline: 0.02, + baselinePresent: true, + candidatePresent: false, + asymmetry: 'baseline-only', + }, + { + name: 'run.input_tokens', + baseline: 100, + candidate: 80, + baselinePresent: true, + candidatePresent: true, + asymmetry: 'none', + }, + ], + rows, + paired: comparePairedArms(rows, { + baselineArm: 'baseline', + treatmentArm: 'candidate', + bootstrap: { seed: 7 }, + }), + semantic: { + status: 'unavailable', + reason: 'No semantic reviewer was supplied.', + }, + } + const view = comparisonViewForResult(result) + const allLines = comparisonLines(view).join('\n') + for (const field of result.fields) assert.match(allLines, new RegExp(field.name, 'u')) + assert.match(allLines, /candidate outcome: failed/u) + assert.match(allLines, /candidate outcome:[^\n]+cost: missing/u) + assert.match(allLines, /source: not captured in frozen run/u) + assert.match(allLines, /One pair is descriptive/u) + + const panel = new ComparisonViewPanel(theme) + panel.setView(view) + const firstPage = await renderOverlay(panel, 40, 12) + assertFits(firstPage, 40) + assert.match(firstPage.join('\n'), /baseline outcome: completed/u) + assert.match(firstPage.join('\n'), /candidate outcome: failed/u) + assert.match(firstPage.join('\n'), /page 1\//u) + panel.handleInput('\u001b[B') + assert.match(panel.render(80).join('\n'), /run\.cost_usd/u) +}) + +test('approval keys name their consequences and secret responses never render values', async () => { + const responses: unknown[] = [] + const shell = new InteractionShell(permission, theme, (response) => responses.push(response)) + const screen = await renderOverlay(shell, 40, 12) + assertFits(screen, 40) + assert.match(screen.join('\n'), /permission · approve or reject/u) + assert.match(screen.join('\n'), /will: approve · reject · cancel/u) + assert.match(screen.join('\n'), /keys: alt\+1 approve · alt\+2 reject/u) + assert.match(screen.join('\n'), /alt\+3 cancel/u) + assert.match(screen.join('\n'), /enter submit · esc cancel/u) + + shell.handleInput('y') + assert.deepEqual(responses, [{ outcome: 'accept', value: true }]) + + const secret = 'do-not-render' + const secretInteraction: InteractionView = { + ...permission, + interactionId: 'interaction-secret', + prompt: 'Provide the provider token.', + subject: { + type: 'credential', + title: 'provider token', + detail: `token=${secret}`, + preview: [secret], + }, + answerSpec: { kind: 'secret', required: true }, + allowedOutcomes: ['once', 'reject', 'cancel'], + secret: true, + } + const secretResponses: unknown[] = [] + const secretShell = new InteractionShell(secretInteraction, theme, (response) => { + secretResponses.push(response) + }) + secretShell.handleInput(secret) + const secretScreen = await renderOverlay(secretShell, 40, 12) + assertFits(secretScreen, 40) + assert.doesNotMatch(secretScreen.join('\n'), new RegExp(secret, 'u')) + assert.match(secretScreen.join('\n'), /secret hidden/u) + assert.match(secretScreen.join('\n'), /alt\+1 once · alt\+2 reject/u) + assert.match(secretScreen.join('\n'), /alt\+3 cancel/u) + secretShell.handleInput('\r') + assert.deepEqual(secretResponses, [{ outcome: 'once', value: secret }]) +}) + +test('fork and confirmation dialogs expose only short, actionable keys', () => { + let confirmed = false + let cancelled = false + const panel = new ForkPreviewPanel(theme, { + onConfirm: () => { + confirmed = true + }, + onCancel: () => { + cancelled = true + }, + }) + panel.setView(forkView()) + panel.handleInput('y') + assert.equal(confirmed, true) + panel.handleInput('\u001b') + assert.equal(cancelled, true) + + const completeFork = forkView() + const completePreview = completeFork.forkPreview + assert.ok(completePreview) + panel.setView({ + ...completeFork, + forkPreview: { + ...completePreview, + fields: completePreview.fields.filter( + (field) => field.label !== 'operation id' && field.label !== 'plan digest', + ), + }, + }) + const incomplete = panel.render(80).join('\n') + assert.match(incomplete, /missing execution data/u) + assert.doesNotMatch(incomplete, /enter\/y create fork/u) + + const dialog = new ConversationConfirmation({ + theme, + title: 'delete conversation', + target: 'Terminal workflow', + detail: 'Local history is removed; external environment references remain.', + confirmLabel: 'delete permanently', + onConfirm: () => {}, + onCancel: () => {}, + }) + const lines = dialog.render(40) + assertFits(lines, 40) + assert.match(lines.join('\n'), /will delete permanently/u) + assert.match(lines.join('\n'), /enter\/y confirm · n\/esc cancel/u) +}) + +test('digit-leading text, secret, and number answers stay editable', () => { + const secretResponse: unknown[] = [] + const secretShell = new InteractionShell( + { + ...permission, + interactionId: 'interaction-digit-secret', + answerSpec: { kind: 'secret', required: true }, + allowedOutcomes: ['once', 'reject', 'cancel'], + secret: true, + }, + theme, + (response) => secretResponse.push(response), + ) + secretShell.handleInput('1secret-value') + assert.doesNotMatch(secretShell.render(40).join('\n'), /secret-value/u) + secretShell.handleInput('\u001b1') + assert.deepEqual(secretResponse, []) + secretShell.handleInput('\r') + assert.deepEqual(secretResponse, [{ outcome: 'once', value: '1secret-value' }]) + + const textResponse: unknown[] = [] + const textShell = new InteractionShell( + { + ...permission, + interactionId: 'interaction-digit-text', + kind: 'question', + answerSpec: { kind: 'text', required: true, secret: false }, + allowedOutcomes: ['accept', 'reject', 'cancel'], + secret: false, + }, + theme, + (response) => textResponse.push(response), + ) + textShell.handleInput('1text-value') + textShell.handleInput('\r') + assert.deepEqual(textResponse, [{ outcome: 'accept', value: '1text-value' }]) + + const numberResponse: unknown[] = [] + const numberShell = new InteractionShell( + { + ...permission, + interactionId: 'interaction-digit-number', + kind: 'question', + answerSpec: { kind: 'number', required: true, minimum: 1 }, + allowedOutcomes: ['once', 'session', 'reject', 'cancel'], + secret: false, + }, + theme, + (response) => numberResponse.push(response), + ) + numberShell.handleInput('12.5') + numberShell.handleInput('\u001b2') + assert.deepEqual(numberResponse, []) + assert.match(numberShell.render(40).join('\n'), /will: approve run/u) + numberShell.handleInput('\r') + assert.deepEqual(numberResponse, [{ outcome: 'session', value: 12.5 }]) +}) + +test('alt-digit keys reach every allowed outcome without taking editable digits', () => { + const outcomes = [ + 'once', + 'session', + 'persistent', + 'accept', + 'revise', + 'reject', + 'deny', + 'cancel', + ] as const + for (const [index, outcome] of outcomes.entries()) { + const responses: unknown[] = [] + const shell = new InteractionShell( + { + ...permission, + interactionId: `interaction-outcome-${index}`, + allowedOutcomes: outcomes, + }, + theme, + (response) => responses.push(response), + ) + shell.handleInput(`\u001b${index + 1}`) + assert.deepEqual( + responses, + [{ outcome, ...(isPositive(outcome) ? { value: true } : {}) }], + `alt+${index + 1} should choose ${outcome}`, + ) + } +}) + +test('completed export notices survive real terminal input at every supported width', async () => { + for (const [columns, rows] of [ + [40, 12], + [80, 24], + [100, 30], + [120, 40], + [200, 60], + ] as const) { + const terminal = new VirtualTerminal(columns, rows) + const tui = new TUI(terminal) + const app = createBraidApplication({ fixture: 'deterministic' }) + app.initialize('/workspace') + const controller = createApplicationUiController(app) + let operation = 0 + const view = new BraidTerminalApp({ + controller, + tui, + theme, + workspace: '/workspace', + nextOperationId: () => `op-export-notice-${++operation}`, + }) + const done = view.start() + try { + terminal.sendInput('/export markdown') + terminal.sendInput('\r') + await waitUntil( + () => controller.view().notice?.toLocaleLowerCase().includes('markdown') === true, + ) + await terminal.waitForRender() + const viewport = terminal.getViewport() + assert.match(viewport.join('\n'), /prepared markdown export/iu) + assert.equal(viewport.length, rows) + assertFits(viewport, columns) + } finally { + view.stop() + await done + } + } +}) + +test('workflow presentation modules stay bounded and acyclic', () => { + const directSource = new URL('../src/views/tui/interaction.ts', import.meta.url) + const directRoot = new URL('../src/views/tui/', import.meta.url) + const compiledRoot = new URL('../../src/views/tui/', import.meta.url) + const tuiRoot = fileURLToPath(existsSync(directSource) ? directRoot : compiledRoot) + const interactionPath = join(tuiRoot, 'interaction.ts') + const overlayPath = join(tuiRoot, 'conversation-overlays.ts') + assert.ok(readFileSync(interactionPath, 'utf8').split('\n').length - 1 < 250) + assert.ok(readFileSync(overlayPath, 'utf8').split('\n').length - 1 < 300) + + const files = new Set(tuiSourceFiles(tuiRoot)) + const graph = new Map>() + for (const file of files) { + const targets = new Set() + const source = readFileSync(file, 'utf8') + for (const match of source.matchAll( + /(?:\bfrom\s+|\bimport\s*(?:\(\s*)?)['"](\.[^'"]+)['"]/gu, + )) { + const target = resolveTuiImport(file, match[1] ?? '', files) + if (target !== undefined) targets.add(target) + } + graph.set(file, targets) + } + assert.deepEqual(findCycles(graph), []) +}) + +function tuiSourceFiles(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = join(directory, entry.name) + return entry.isDirectory() ? tuiSourceFiles(path) : extname(path) === '.ts' ? [path] : [] + }) +} + +function resolveTuiImport( + importer: string, + specifier: string, + files: ReadonlySet, +): string | undefined { + const raw = resolve(dirname(importer), specifier) + const candidates = [ + raw, + raw.endsWith('.js') ? `${raw.slice(0, -3)}.ts` : `${raw}.ts`, + join(raw, 'index.ts'), + ] + return candidates.find((candidate) => files.has(candidate)) +} + +function findCycles(graph: ReadonlyMap>): string[][] { + const cycles: string[][] = [] + const active: string[] = [] + const activeSet = new Set() + const visited = new Set() + const visit = (node: string): void => { + if (activeSet.has(node)) { + cycles.push(active.slice(active.indexOf(node))) + return + } + if (visited.has(node)) return + visited.add(node) + active.push(node) + activeSet.add(node) + for (const target of graph.get(node) ?? []) visit(target) + active.pop() + activeSet.delete(node) + } + for (const node of graph.keys()) visit(node) + return cycles +} + +function isPositive(outcome: string): boolean { + return ['accept', 'once', 'session', 'persistent'].includes(outcome) +} diff --git a/test/tui.test.ts b/test/tui.test.ts index 589a309..82fddbc 100644 --- a/test/tui.test.ts +++ b/test/tui.test.ts @@ -1,6 +1,7 @@ import assert from 'node:assert/strict' import test from 'node:test' import { CombinedAutocompleteProvider, Editor, TUI, visibleWidth } from '@earendil-works/pi-tui' +import { createApplicationUiController } from '../src/adapters/tui/application-ui-controller.js' import { createBraidApplication } from '../src/app/composition.js' import { BraidTerminalApp } from '../src/views/tui/terminal-app.js' import { createBraidTheme } from '../src/views/tui/theme.js' @@ -27,9 +28,10 @@ test('the real Braid root renders and sends at all four reference sizes', async const tui = new TUI(terminal) const app = createBraidApplication({ fixture: 'deterministic' }) app.initialize('/workspace') + const controller = createApplicationUiController(app) let operation = 0 const view = new BraidTerminalApp({ - app, + controller, tui, theme: createBraidTheme(false), workspace: '/workspace', @@ -46,6 +48,10 @@ test('the real Braid root renders and sends at all four reference sizes', async assert.match(screen, /braid/u) assert.match(screen, /hello Braid/u) assert.match(screen, /Fixture response through pi/u) + assert.equal( + app.state().drafts.find((draft) => draft.branchId === app.state().branchId)?.text, + '', + ) for (const line of terminal.getViewport()) assert.ok(visibleWidth(line) <= columns) view.stop() @@ -107,8 +113,9 @@ test('the searchable command overlay restores editor focus after close', async ( const tui = new TUI(terminal) const app = createBraidApplication({ fixture: 'deterministic' }) app.initialize('/workspace') + const controller = createApplicationUiController(app) const view = new BraidTerminalApp({ - app, + controller, tui, theme: createBraidTheme(false), workspace: '/workspace', @@ -128,17 +135,186 @@ test('the searchable command overlay restores editor focus after close', async ( await done }) +test('global shortcuts cannot replace an open overlay or discard its query', async () => { + const terminal = new VirtualTerminal(80, 24) + const tui = new TUI(terminal) + const app = createBraidApplication({ fixture: 'deterministic' }) + app.initialize('/workspace') + const view = new BraidTerminalApp({ + controller: createApplicationUiController(app), + tui, + theme: createBraidTheme(false), + workspace: '/workspace', + nextOperationId: () => 'op-overlay-focus', + }) + const done = view.start() + + terminal.sendInput('\u0010') + terminal.sendInput('quit') + await terminal.waitForRender() + terminal.sendInput('\u0007') + await terminal.waitForRender() + const screen = terminal.getViewport().join('\n') + assert.match(screen, /Commands/u) + assert.match(screen, /\/quit/u) + assert.doesNotMatch(screen, /conversation graph/u) + + terminal.sendInput('\u001b') + view.stop() + await done +}) + +test('conversation commands work through the real terminal input path', async () => { + const terminal = new VirtualTerminal(100, 30) + const tui = new TUI(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-conversation-ui-${++operation}`, + }) + const done = view.start() + + terminal.sendInput('/new Terminal workflow') + terminal.sendInput('\r') + await waitUntil(() => app.state().conversations.length === 2) + assert.equal( + app.state().conversations.find((conversation) => conversation.id === app.state().conversationId) + ?.title, + 'Terminal workflow', + ) + + terminal.sendInput('message before branch') + terminal.sendInput('\r') + await waitUntil(() => app.state().runs.length === 1) + await app.waitForIdle() + const messageId = app.state().messages.find((message) => message.role === 'user')?.id + assert(messageId) + terminal.sendInput(`/branch ${messageId}`) + terminal.sendInput('\r') + await waitUntil(() => app.state().branches.length === 3) + + terminal.sendInput('/clone Terminal copy') + terminal.sendInput('\r') + await waitUntil(() => app.state().conversations.length === 3) + assert.equal( + app.state().conversations.find((conversation) => conversation.id === app.state().conversationId) + ?.title, + 'Terminal copy', + ) + + terminal.sendInput('/fork') + terminal.sendInput('\r') + await waitUntil(() => controller.view().forkPreview !== undefined) + await terminal.waitForRender() + assert.match(terminal.getViewport().join('\n'), /fork preview/iu) + terminal.sendInput('\u001b') + + terminal.sendInput('/export markdown') + terminal.sendInput('\r') + await waitUntil(() => controller.view().notice?.includes('MARKDOWN') === true) + await terminal.waitForRender() + assert.match(terminal.getViewport().join('\n'), /prepared markdown export/iu) + + terminal.sendInput('\u000f') + await terminal.waitForRender() + const selector = terminal.getViewport().join('\n') + assert.match(selector, /Terminal workflow/u) + assert.match(selector, /Terminal copy/u) + terminal.sendInput('\u001b') + + view.stop() + await done +}) + +test('the terminal saves and restores independent unsent conversation drafts', async () => { + const terminal = new VirtualTerminal(100, 30) + const tui = new TUI(terminal) + const app = createBraidApplication({ fixture: 'deterministic' }) + app.initialize('/workspace') + const firstConversationId = app.state().conversationId + const firstBranchId = app.state().branchId + await app.conversations.lifecycle.rename({ + operationId: 'op-draft-ui-rename-first', + conversationId: firstConversationId, + title: 'Alpha draft source', + }) + const secondConversation = await app.conversations.lifecycle.create({ + operationId: 'op-draft-ui-create-second', + title: 'Beta draft target', + }) + await app.conversations.lifecycle.open({ + operationId: 'op-draft-ui-open-first', + conversationId: firstConversationId, + branchId: firstBranchId, + }) + const controller = createApplicationUiController(app) + let operation = 0 + const view = new BraidTerminalApp({ + controller, + tui, + theme: createBraidTheme(false), + workspace: '/workspace', + nextOperationId: () => `op-draft-ui-${++operation}`, + }) + const done = view.start() + + terminal.sendInput('alpha unsent draft') + await waitUntil( + () => + app.state().drafts.find((draft) => draft.branchId === firstBranchId)?.text === + 'alpha unsent draft', + ) + terminal.sendInput('\u000f') + await waitUntil(() => terminal.getViewport().join('\n').includes('type to filter')) + terminal.sendInput('Beta draft target') + terminal.sendInput('\r') + await waitUntil(() => app.state().conversationId === secondConversation.id) + assert.equal(view.editor.getText(), '') + + terminal.sendInput('beta unsent draft') + await waitUntil( + () => + app.state().drafts.find((draft) => draft.branchId === secondConversation.activeBranchId) + ?.text === 'beta unsent draft', + ) + terminal.sendInput('\u000f') + await waitUntil(() => terminal.getViewport().join('\n').includes('type to filter')) + terminal.sendInput('Alpha draft source') + terminal.sendInput('\r') + await waitUntil(() => app.state().conversationId === firstConversationId) + await waitUntil(() => view.editor.getText() === 'alpha unsent draft') + assert.equal( + app.state().drafts.find((draft) => draft.branchId === firstBranchId)?.text, + 'alpha unsent draft', + ) + assert.equal( + app.state().drafts.find((draft) => draft.branchId === secondConversation.activeBranchId)?.text, + 'beta unsent draft', + ) + + view.stop() + await done +}) + test('Ctrl+C clears, cancels, then requires a second idle press to quit', async () => { const terminal = new VirtualTerminal(80, 24) const tui = new TUI(terminal) const app = createBraidApplication({ fixture: 'deterministic', chunkDelayMs: 100 }) app.initialize('/workspace') + const controller = createApplicationUiController(app) + let operation = 0 const view = new BraidTerminalApp({ - app, + controller, tui, theme: createBraidTheme(false), workspace: '/workspace', - nextOperationId: () => 'op-cancel-flow', + nextOperationId: () => `op-cancel-flow-${++operation}`, }) let stopped = false const done = view.start().then(() => { @@ -161,7 +337,7 @@ test('Ctrl+C clears, cancels, then requires a second idle press to quit', async terminal.sendInput('\u0003') await terminal.waitForRender() assert.equal(stopped, false) - assert.match(terminal.getViewport().join('\n'), /press ctrl\+c again to quit/u) + assert.match(terminal.getViewport().join('\n'), /ctrl\+c again to quit/u) terminal.sendInput('\u0003') await done assert.equal(stopped, true) diff --git a/test/w6-contract.test.ts b/test/w6-contract.test.ts new file mode 100644 index 0000000..9baf31c --- /dev/null +++ b/test/w6-contract.test.ts @@ -0,0 +1,226 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { createApplicationUiController } from '../src/adapters/tui/application-ui-controller.js' +import { createBraidApplication } from '../src/app/composition.js' +import { RPC_COMMAND_NAMES } from '../src/views/headless/protocol.js' +import { runRpc } from '../src/views/headless/rpc.js' +import { + COMMAND_DEFINITIONS, + commandIntent, + isMutatingCommand, + parseCommandInput, +} from '../src/views/shared/command-registry.js' +import { SHARED_COMMAND_TABLE } from '../src/views/shared/command-table.js' +import { + HEADLESS_COMMAND_NAMES, + isMutatingHeadlessCommand, + MUTATING_HEADLESS_COMMANDS, +} from '../src/views/shared/headless-commands.js' + +const REQUIRED_HEADLESS = [ + 'initialize', + 'get_state', + 'subscribe', + 'unsubscribe', + 'list_profiles', + 'select_profile', + 'validate_profile', + 'save_profile', + 'list_connections', + 'test_connection', + 'select_connection', + 'set_run_override', + 'new_conversation', + 'list_conversations', + 'open_conversation', + 'rename_conversation', + 'archive_conversation', + 'delete_conversation', + 'set_draft', + 'import_conversation', + 'send', + 'queue', + 'remove_queued', + 'steer', + 'cancel', + 'detach', + 'reconnect', + 'reconcile', + 'respond_interaction', + 'cancel_run', + 'branch', + 'clone', + 'plan_fork', + 'execute_fork', + 'ask', + 'analyze', + 'compare', + 'promote_analysis', + 'get_graph', + 'get_activity', + 'get_details', + 'steer_worker', + 'cancel_worker', + 'export', + 'shutdown', +] as const + +async function* lines(linesToSend: readonly (object | string)[]): AsyncGenerator { + for (const line of linesToSend) + yield `${typeof line === 'string' ? line : JSON.stringify(line)}\n` +} + +test('headless registry exposes every required JSONL command exactly once', () => { + assert.deepEqual([...HEADLESS_COMMAND_NAMES], [...REQUIRED_HEADLESS]) + assert.deepEqual([...RPC_COMMAND_NAMES], [...REQUIRED_HEADLESS]) + assert.equal(new Set(HEADLESS_COMMAND_NAMES).size, HEADLESS_COMMAND_NAMES.length) +}) + +test('all mutating JSONL commands reject absent caller operation identifiers', async () => { + const app = createBraidApplication({ fixture: 'deterministic' }) + const output: string[] = [] + const requests: object[] = [ + { version: 1, requestId: 'init', command: 'initialize', params: { workspace: '/workspace' } }, + ] + for (const [index, command] of MUTATING_HEADLESS_COMMANDS.entries()) { + requests.push({ + version: 1, + requestId: `mutation-${index}`, + command, + params: command === 'send' ? { text: 'message' } : {}, + }) + } + requests.push({ + version: 1, + requestId: 'stop', + operationId: 'op-contract-stop', + command: 'shutdown', + }) + await runRpc(createApplicationUiController(app), lines(requests), { + write: (chunk) => { + output.push(chunk) + return true + }, + }) + const errors = output + .map((line) => JSON.parse(line) as { type: string; code?: string }) + .filter((response) => response.type === 'error') + assert.equal(errors.length, MUTATING_HEADLESS_COMMANDS.length) + assert.equal( + errors.every((response) => response.code === 'OPERATION_ID_REQUIRED'), + true, + ) +}) + +test('unknown slash commands stay local and double slash sends ordinary prompt text', () => { + const unknown = parseCommandInput('/analize failure') + assert.equal(unknown.kind, 'unknown') + if (unknown.kind !== 'unknown') assert.fail('expected unknown command') + assert.ok(unknown.suggestions.includes('analyze')) + const prompt = parseCommandInput('//help') + assert.deepEqual(prompt, { kind: 'prompt', text: '/help' }) +}) + +test('the command registry owns typed intents for local and headless command paths', () => { + assert.equal( + COMMAND_DEFINITIONS.every((definition) => typeof definition.intent === 'function'), + true, + ) + assert.deepEqual(commandIntent('help', ['keys']), { + type: 'open-surface', + surface: 'help', + query: 'keys', + }) + assert.deepEqual(commandIntent('profile', ['reviewer'], 'op-profile'), { + type: 'run-command', + command: 'profile', + args: ['reviewer'], + operationId: 'op-profile', + }) + assert.deepEqual(commandIntent('quit', []), { type: 'shutdown', operationId: '' }) +}) + +test('keyboard and headless mutation metadata comes from one exact table', () => { + const definitions = new Map( + COMMAND_DEFINITIONS.map((definition) => [definition.name, definition]), + ) + const expectedMutations = new Set(MUTATING_HEADLESS_COMMANDS) + for (const entry of SHARED_COMMAND_TABLE) { + const definition = definitions.get(entry.name) + assert(definition, `${entry.name} has no keyboard definition`) + assert.equal(definition.requiresOperationId, entry.requiresOperationId) + assert.equal(isMutatingCommand(entry.name), entry.requiresOperationId) + for (const command of entry.headlessCommands) + assert.ok(HEADLESS_COMMAND_NAMES.includes(command), `${entry.name} maps unknown ${command}`) + assert.deepEqual([...definition.mutatingHeadlessCommands], [...entry.mutatingHeadlessCommands]) + for (const command of entry.mutatingHeadlessCommands) { + assert.equal(expectedMutations.has(command), true) + assert.equal(isMutatingHeadlessCommand(command), true) + } + } + assert.equal(isMutatingHeadlessCommand('list_conversations'), false) + assert.equal(isMutatingHeadlessCommand('list_profiles'), false) + assert.equal(isMutatingHeadlessCommand('list_connections'), false) +}) + +test('view and headless snapshots are deeply immutable', () => { + const app = createBraidApplication({ fixture: 'deterministic' }) + app.initialize('/workspace') + const controller = createApplicationUiController(app) + const view = controller.view() + const state = controller.state() + const event = controller.events()[0] + assert.equal(Object.isFrozen(view), true) + assert.equal(Object.isFrozen(view.activity), true) + assert.equal(Object.isFrozen(view.capabilities['run.send']), true) + assert.equal(Object.isFrozen(state), true) + assert.equal(Object.isFrozen(state.messages), true) + assert.equal(Object.isFrozen(event), true) + assert.equal(Object.isFrozen(event?.payload), true) +}) + +test('headless malformed and changed request bodies have distinct typed errors', async () => { + const app = createBraidApplication({ fixture: 'deterministic' }) + const output: string[] = [] + const requests = [ + '{bad json', + JSON.stringify({ + version: 1, + requestId: 'init', + command: 'initialize', + params: { workspace: '/workspace' }, + }), + JSON.stringify({ version: 1, requestId: 'same', command: 'get_state' }), + JSON.stringify({ + version: 1, + requestId: 'bad-param', + command: 'get_state', + params: { projection: 3 }, + }), + JSON.stringify({ + version: 1, + requestId: 'same', + command: 'get_state', + params: { projection: 'summary' }, + }), + JSON.stringify({ + version: 1, + requestId: 'stop', + operationId: 'op-contract-stop-2', + command: 'shutdown', + }), + ] + await runRpc(createApplicationUiController(app), lines(requests), { + write: (chunk) => { + output.push(chunk) + return true + }, + }) + const errors = output + .map((line) => JSON.parse(line) as { type: string; code?: string }) + .filter((response) => response.type === 'error') + assert.deepEqual( + errors.map((response) => response.code), + ['MALFORMED_JSON', 'INVALID_PARAMS', 'REQUEST_ID_CONFLICT'], + ) +}) diff --git a/test/w6-ui.test.ts b/test/w6-ui.test.ts new file mode 100644 index 0000000..c828b49 --- /dev/null +++ b/test/w6-ui.test.ts @@ -0,0 +1,684 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { TUI, visibleWidth } from '@earendil-works/pi-tui' +import { createApplicationUiController } from '../src/adapters/tui/application-ui-controller.js' +import { createBraidApplication } from '../src/app/composition.js' +import { MemoryJournal } from '../src/app/journal.js' +import { FixedClock } from '../src/ports/clock.js' +import type { BraidUiController } from '../src/views/shared/intents.js' +import type { BraidViewModel, InteractionView, ViewStatus } from '../src/views/shared/models.js' +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 { ForkPreviewPanel } from '../src/views/tui/fork-preview.js' +import { GraphView } from '../src/views/tui/graph.js' +import { HelpViewPanel } from '../src/views/tui/help.js' +import { InteractionShell } from '../src/views/tui/interaction.js' +import { layoutFor } from '../src/views/tui/layout.js' +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 { metricsFor, 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' +import { TranscriptView } from '../src/views/tui/transcript.js' +import { VirtualTerminal } from './support/virtual-terminal.js' + +const theme = createBraidTheme({ colors: false, highContrast: true, reducedMotion: true }) + +function viewFor(status: ViewStatus): BraidViewModel { + return Object.freeze({ + revision: 7, + workspace: '/workspace', + profileName: 'reviewer', + profileDigest: 'digest', + runner: 'pi', + model: 'fixture/deterministic', + effort: 'none', + connection: 'deterministic fixture', + conversationId: 'conversation-fixture', + conversationTitle: 'Fixture conversation', + conversations: [], + branch: 'branch-1', + status, + statusText: status, + queueCount: status === 'waiting' ? 1 : 0, + messages: Object.freeze([ + { + id: 'user-1', + role: 'user' as const, + text: 'Explain 漢字 é 👩🏽‍💻', + status: 'complete' as const, + parts: Object.freeze([ + { + id: 'user-1:text', + kind: 'text' as const, + text: 'Explain 漢字 é 👩🏽‍💻', + status: 'complete' as const, + }, + ]), + }, + { + id: 'assistant-1', + role: 'assistant' as const, + text: 'response', + status: status === 'streaming' ? ('streaming' as const) : ('complete' as const), + parts: Object.freeze([ + { + id: 'tool', + kind: 'tool' as const, + text: 'rg "needle" src', + status: 'running' as const, + }, + { id: 'result', kind: 'result' as const, text: '4 matches', status: 'complete' as const }, + { id: 'answer', kind: 'text' as const, text: 'response', status: 'complete' as const }, + ]), + }, + ]), + hiddenMessageCount: 0, + runs: Object.freeze([ + { + id: 'run-1', + turnId: 'turn-1', + operationId: 'op-1', + status, + completeness: 'complete' as const, + }, + ]), + ...(status === 'running' || status === 'waiting' || status === 'cancelling' + ? { activeRunId: 'run-1' } + : {}), + interactions: Object.freeze([]), + activity: Object.freeze([ + { id: 'run-1', kind: 'run' as const, title: 'run run-1', status }, + { id: 'tool-1', kind: 'tool' as const, title: 'read file', status: 'complete' as const }, + ]), + graph: Object.freeze([ + { + id: 'conv-1', + type: 'conversation' as const, + title: 'conversation', + status: 'complete' as const, + depth: 0, + }, + { + id: 'branch-1', + type: 'branch' as const, + title: 'main', + status: 'complete' as const, + depth: 1, + edgeLabel: 'continued', + }, + { + id: 'run-1', + type: 'run' as const, + title: 'run-1', + status, + depth: 2, + edgeLabel: 'continued', + }, + ]), + details: Object.freeze({ + title: 'run run-1', + fields: Object.freeze([{ label: 'status', value: status }]), + }), + profileEditor: Object.freeze({ + source: 'workspace profile', + digest: 'digest', + readOnly: true, + validation: 'valid' as const, + fields: Object.freeze([ + { path: 'model.default', value: 'fixture/deterministic', secret: false }, + ]), + }), + connectionSetup: Object.freeze({ + kind: 'cli-bridge' as const, + fields: Object.freeze([{ label: 'endpoint', value: 'local', secret: false }]), + health: 'healthy' as const, + capabilities: Object.freeze(['stream']), + }), + capabilities: Object.freeze({ + 'help.read': { available: true, source: 'local' as const }, + 'interaction.respond': { + available: false, + source: 'application' as const, + reason: 'not available', + }, + }), + draft: '', + selectedSurface: 'transcript' as const, + appearance: { color: 'none' as const, highContrast: true, reducedMotion: true }, + }) +} + +const statuses: readonly ViewStatus[] = [ + 'empty', + 'loading', + 'ready', + 'streaming', + 'running', + 'waiting', + 'detached', + 'reconnecting', + 'cancelling', + 'cancelled', + 'failed', + 'expired', + 'unknown', + 'storage-failure', +] + +for (const status of statuses) { + test(`named ${status} view renders without control injection`, () => { + const view = viewFor(status) + const transcript = new TranscriptView(theme) + transcript.setView(view) + const lines = transcript.render(80) + assert.ok(lines.length > 0) + assert.equal( + lines.some((line) => line.includes('\u001b]') || line.includes('\u0007')), + false, + ) + for (const line of lines) assert.ok(visibleWidth(line) <= 80) + }) +} + +test('no-color and reduced-motion TUI suppress terminal metadata', async () => { + const terminal = new VirtualTerminal(80, 24) + const titles: string[] = [] + terminal.setTitle = (title) => titles.push(title) + const tui = new TUI(terminal) + const view = viewFor('ready') + const controller = { + view: () => view, + subscribe: () => () => {}, + dispatch: async () => ({ kind: 'accepted' as const, revision: view.revision }), + } as unknown as BraidUiController + const app = new BraidTerminalApp({ + controller, + tui, + theme, + workspace: '/workspace', + nextOperationId: () => 'op-terminal-metadata', + }) + const done = app.start() + assert.deepEqual(titles, []) + app.stop() + await done +}) + +test('named queued, cancelled, and fork preview surfaces keep explicit state', () => { + const queued = viewFor('ready') + const queuedView = { ...queued, queueCount: 1, statusText: 'queued' as const } + assert.equal(queuedView.queueCount, 1) + assert.equal(queuedView.statusText, 'queued') + const forkPreview = new DetailsViewPanel(theme) + forkPreview.setView({ + ...queued, + details: { + title: 'fork preview', + fields: [{ label: 'workspace', value: 'unavailable — checkpoint capability not reported' }], + }, + }) + assert.match(forkPreview.render(80).join('\n'), /checkpoint capability/u) +}) + +test('named graph, analysis, activity, profile editor, connection setup, and storage failure panels render', () => { + const view = viewFor('storage-failure') + const graph = new GraphView(theme) + graph.setView(view) + const activity = new ActivityView(theme) + activity.setView(view) + const profile = new ProfileEditorViewPanel(theme) + profile.setView(view) + const connection = new ConnectionSetupViewPanel(theme) + connection.setView(view) + const help = new HelpViewPanel(theme) + help.setQuery('analyze') + const analysis = new AnalysisViewPanel(theme) + analysis.setView(view) + const fork = new ForkPreviewPanel(theme) + fork.setView(view) + assert.match(graph.render(80).join('\n'), /conversation graph/u) + assert.match(activity.render(80).join('\n'), /activity/u) + assert.match(profile.render(80).join('\n'), /profile/u) + assert.match(connection.render(80).join('\n'), /connection/u) + assert.match(help.render(80).join('\n'), /analyze/u) + assert.match(analysis.render(80).join('\n'), /analysis/u) + assert.match(fork.render(80).join('\n'), /fork preview/u) +}) + +test('responsive layout preserves the 72-column transcript boundary', () => { + assert.equal(layoutFor(40, 12).mode, 'narrow') + assert.equal(layoutFor(80, 24).mode, 'standard') + assert.equal(layoutFor(119, 40).mode, 'standard') + const wide = layoutFor(120, 40) + assert.equal(wide.mode, 'wide') + assert.ok(wide.transcriptWidth >= 72) + assert.equal(layoutFor(200, 60).mode, 'wide') +}) + +test('stable chrome keeps identity and status outside transcript history', () => { + const chrome = new TerminalChrome(theme) + chrome.setState({ + view: viewFor('completed'), + quitArmed: false, + activityVisible: false, + navigationHint: 'Ctrl+P commands', + }) + const lines = chrome.render(40) + const firstLine = lines[0] ?? '' + assert.match(firstLine, /braid\s+cwd\s+\/workspace/u) + assert.match(lines[1] ?? '', /completed/u) + assert.match(lines[2] ?? '', /reviewer/u) + assert.doesNotMatch(firstLine, /fixture|deterministic/u) + assert.ok(visibleWidth(firstLine) <= 40) + + const standard = chrome.render(80) + assert.match(standard[2] ?? '', /reviewer.*runner pi.*deterministic fixture/u) + for (const line of standard) assert.ok(visibleWidth(line) <= 80) +}) + +test('terminal chrome aggregates known conversation metrics without filling gaps', () => { + const baseRun = { status: 'completed' as const, completeness: 'complete' as const } + const view = viewFor('completed') + const mixed = { + ...view, + runs: [ + { ...baseRun, id: 'run-1', usage: { input: 2, costUsd: 0.01 } }, + { ...baseRun, id: 'run-2', usage: { output: 5 } }, + { ...baseRun, id: 'run-3', usage: { input: 3, output: 1, costUsd: 0.02 } }, + ], + } + assert.deepEqual(metricsFor(mixed), ['in 5', 'out 6', '$0.0300']) + assert.deepEqual( + metricsFor({ + ...view, + runs: [{ ...baseRun, id: 'run-missing', usage: { output: 0 } }], + }), + ['out 0'], + ) + assert.deepEqual(metricsFor({ ...view, runs: [{ ...baseRun, id: 'run-none' }] }), []) +}) + +test('transcript history stays anchored while reading and follows new output at the tail', () => { + const view = viewFor('completed') + const messages = Array.from({ length: 12 }, (_, index) => ({ + id: `history-${index}`, + role: 'user' as const, + text: `turn-${index}`, + status: 'complete' as const, + parts: [{ id: `history-${index}:text`, kind: 'text' as const, text: `turn-${index}` }], + })) + const transcript = new TranscriptView(theme) + transcript.setViewportRows(6) + transcript.setView({ ...view, messages }) + assert.match(transcript.render(80).join('\n'), /turn-11/u) + assert.equal(transcript.followTail, true) + + assert.equal(transcript.handleInput('\u001b[H'), true) + assert.equal(transcript.followTail, false) + assert.match(transcript.render(80).join('\n'), /turn-0/u) + + const appended = { + ...view, + messages: [ + ...messages, + { + id: 'history-12', + role: 'assistant' as const, + text: 'turn-12', + status: 'streaming' as const, + parts: [{ id: 'history-12:text', kind: 'text' as const, text: 'turn-12' }], + }, + ], + } + transcript.setView(appended) + assert.match(transcript.render(80).join('\n'), /turn-0/u) + + assert.equal(transcript.handleInput('\u001b[F'), true) + assert.equal(transcript.followTail, true) + transcript.setView({ + ...appended, + messages: [ + ...appended.messages, + { + id: 'history-13', + role: 'assistant' as const, + text: 'turn-13', + status: 'complete' as const, + parts: [{ id: 'history-13:text', kind: 'text' as const, text: 'turn-13' }], + }, + ], + }) + assert.match(transcript.render(80).join('\n'), /turn-13/u) +}) + +test('drafts keep plain Home and End while Alt bounds and Page keys move history', () => { + const terminal = new VirtualTerminal(80, 24) + const tui = new TUI(terminal) + const shell = new BraidShell( + tui, + theme, + () => 24, + () => {}, + () => {}, + ) + const view = viewFor('completed') + shell.setView( + { + ...view, + messages: Array.from({ length: 12 }, (_, index) => ({ + id: `shell-${index}`, + role: 'user' as const, + text: `shell-${index}`, + status: 'complete' as const, + parts: [{ id: `shell-${index}:text`, kind: 'text' as const, text: `shell-${index}` }], + })), + }, + false, + ) + shell.render(80) + shell.editor.setText('draft') + assert.equal(shell.handleTranscriptInput('\u001b[H'), false) + assert.equal(shell.handleTranscriptInput('\u001b[F'), false) + assert.equal(shell.handleTranscriptInput('\u001b[1;3H'), true) + assert.equal(shell.handleTranscriptInput('\u001b[1;3F'), true) + assert.equal(shell.handleTranscriptInput('\u001b[5~'), true) + assert.equal(shell.handleTranscriptInput('\u001b[6~'), true) +}) + +test('Ctrl+E cycles through every collapsible detail card', () => { + const transcript = new TranscriptView(theme) + transcript.setView(viewFor('completed')) + transcript.setViewportRows(30) + assert.match(transcript.navigationHint(), /next detail \(2\/2\)/u) + + assert.equal(transcript.toggleDetails(), true) + let rendered = transcript.render(100).join('\n') + assert.match(rendered, /result · complete 2\/2 · open/u) + assert.match(rendered, /tool · running 1\/2 · collapsed/u) + + assert.equal(transcript.toggleDetails(), true) + rendered = transcript.render(100).join('\n') + assert.match(rendered, /tool · running 1\/2 · open/u) + assert.match(rendered, /result · complete 2\/2 · collapsed/u) +}) + +test('one searchable selector preserves query and supports keyboard selection', () => { + let selected = '' + let cancelled = false + const selector = new SearchableSelector({ + title: 'items', + items: [ + { value: 'profile', label: '/profile', description: 'select a profile' }, + { value: 'quit', label: '/quit', description: 'exit Braid' }, + ], + theme, + onSelect: (item) => { + selected = item.value + }, + onCancel: () => { + cancelled = true + }, + }) + selector.setQuery('quit') + selector.handleInput('\r') + assert.equal(selected, 'quit') + selector.handleInput('\u001b') + assert.equal(cancelled, true) +}) + +const interaction: InteractionView = { + runId: 'run-1', + interactionId: 'interaction-1', + kind: 'permission', + prompt: 'Allow the tool?', + subject: { type: 'file', title: 'secret.txt', preview: ['read-only preview'] }, + answerSpec: { kind: 'secret', required: true }, + allowedOutcomes: ['once', 'reject', 'cancel'], + queuePosition: 0, + secret: true, +} + +test('interaction shell masks secret answers and accepts alt-digit outcomes', () => { + let response: unknown + const shell = new InteractionShell(interaction, theme, (value) => { + response = value + }) + shell.handleInput('TOPSECRET') + const rendered = shell.render(80).join('\n') + assert.equal(rendered.includes('TOPSECRET'), false) + shell.handleInput('\u001b1') + shell.handleInput('\r') + assert.deepEqual(response, { outcome: 'once', value: 'TOPSECRET' }) +}) + +test('interaction shell turns escape into an explicit cancel response', () => { + let response: unknown + const shell = new InteractionShell(interaction, theme, (value) => { + response = value + }) + shell.handleInput('\u001b') + assert.deepEqual(response, { outcome: 'cancel' }) +}) + +test('selector boundary strips terminal controls from provider option labels', () => { + const hostile: InteractionView = { + ...interaction, + answerSpec: { + kind: 'select', + required: true, + options: [ + { + value: 'safe-value', + label: 'allow\u001b]0;owned\u0007\u001b[31m now', + }, + ], + }, + } + const shell = new InteractionShell(hostile, theme, () => {}) + const rendered = shell.render(80).join('\n') + assert.equal(rendered.includes('\u001b]0;'), false) + assert.equal(rendered.includes('\u0007'), false) + assert.match(rendered, /allow now/u) +}) + +test('application view projects canonical graph edges without inventing completeness evidence', async () => { + const app = createBraidApplication({ fixture: 'deterministic', chunkDelayMs: 20 }) + app.initialize('/workspace') + const controller = createApplicationUiController(app) + const receipt = app.send({ operationId: 'op-graph', text: 'graph proof' }) + assert.equal(controller.view().runs[0]?.completeness, 'incomplete') + await receipt.completion + const view = controller.view() + assert.equal(view.runs[0]?.completeness, 'unavailable') + const state = app.state() + const nodeByReference = new Map( + state.graphNodes.map((node) => [`${node.reference.kind}:${node.reference.id}`, node]), + ) + const edgeByDestination = new Map(state.graphEdges.map((edge) => [edge.destination, edge.kind])) + for (const node of view.graph) { + const stored = nodeByReference.get(`${node.type}:${node.id}`) + assert.ok(stored) + assert.equal(node.edgeLabel, edgeByDestination.get(stored.id)) + } + assert.deepEqual( + view.graph.slice(0, 2).map((node) => node.title), + ['New conversation', 'Main'], + ) +}) + +test('visual fixtures expose interaction, fork, analysis, and comparison results', async () => { + const interactionApp = createBraidApplication({ fixture: 'deterministic' }) + interactionApp.initialize('/workspace') + const interactionController = createApplicationUiController(interactionApp, {}, 'interaction') + const interactionView = interactionController.view() + assert.equal(interactionView.interactions.length, 1) + assert.equal(interactionView.interactions[0]?.answerSpec.kind, 'boolean') + assert.equal(interactionView.interactions[0]?.subject?.title, 'src/app/application.ts') + const response = await interactionController.dispatch({ + type: 'respond-interaction', + operationId: 'op-interaction-fixture', + runId: 'fixture-run-1', + interactionId: 'fixture-interaction-1', + response: { outcome: 'accept', value: true }, + }) + assert.equal(response.kind, 'accepted') + assert.equal(interactionController.view().interactions.length, 0) + + const forkApp = createBraidApplication({ fixture: 'deterministic' }) + forkApp.initialize('/workspace') + const forkController = createApplicationUiController(forkApp, {}, 'fork') + const fork = await forkController.dispatch({ + type: 'run-command', + command: 'fork', + args: [], + operationId: 'op-fork-fixture', + }) + assert.equal(fork.kind, 'accepted') + assert.equal(forkController.view().selectedSurface, 'fork') + assert.equal(forkController.view().forkPreview?.allowed, true) + assert.equal(forkController.view().forkPreview?.destination, 'workspace:/workspace-fork') + + const analysisApp = createBraidApplication({ fixture: 'deterministic' }) + analysisApp.initialize('/workspace') + const analysisController = createApplicationUiController(analysisApp, {}, 'analysis') + assert.equal(analysisController.view().capabilities['analysis.ask']?.available, true) + const analysis = await analysisController.dispatch({ + type: 'run-command', + command: 'ask', + args: ['Where', 'did', 'this', 'run', 'waste', 'time?'], + operationId: 'op-analysis-fixture', + }) + assert.equal(analysis.kind, 'accepted') + assert.equal( + (analysis.data as { analysis?: { findings?: unknown[] } }).analysis?.findings?.length, + 2, + ) + + const comparisonApp = createBraidApplication({ fixture: 'deterministic' }) + comparisonApp.initialize('/workspace') + const comparisonController = createApplicationUiController(comparisonApp, {}, 'comparison') + assert.equal(comparisonController.view().capabilities['analysis.compare']?.available, true) + const comparison = await comparisonController.dispatch({ + type: 'run-command', + command: 'compare', + args: ['run-fixture-baseline', 'run-fixture-candidate'], + operationId: 'op-comparison-fixture', + }) + assert.equal(comparison.kind, 'accepted') + assert.equal((comparison.data as { paired?: { nPairs?: number } }).paired?.nPairs, 1) +}) + +test('approve and reject commands resolve the focused pending interaction', async () => { + const approvedApp = createBraidApplication({ fixture: 'deterministic' }) + approvedApp.initialize('/workspace') + const approved = createApplicationUiController(approvedApp, {}, 'interaction') + const approveResult = await approved.dispatch({ + type: 'run-command', + command: 'approve', + operationId: 'op-approve-focused', + args: [], + }) + assert.equal(approveResult.kind, 'accepted') + assert.equal(approved.view().interactions.length, 0) + + const rejectedApp = createBraidApplication({ fixture: 'deterministic' }) + rejectedApp.initialize('/workspace') + const rejected = createApplicationUiController(rejectedApp, {}, 'interaction') + const rejectResult = await rejected.dispatch({ + type: 'run-command', + command: 'reject', + operationId: 'op-reject-focused', + args: [], + }) + assert.equal(rejectResult.kind, 'accepted') + assert.equal(rejected.view().interactions.length, 0) + + const noPending = await approved.dispatch({ + type: 'run-command', + command: 'reject', + operationId: 'op-reject-missing', + args: [], + }) + assert.equal(noPending.kind, 'error') + if (noPending.kind === 'error') assert.equal(noPending.code, 'NO_PENDING_INTERACTION') +}) + +test('unconfigured runs preserve drafts while active runs queue input', async () => { + for (const fixture of [false, true]) { + const terminal = new VirtualTerminal(80, 24) + const tui = new TUI(terminal) + const app = createBraidApplication( + fixture + ? { fixture: 'deterministic', chunkDelayMs: 100 } + : { journal: new MemoryJournal(new FixedClock()) }, + ) + app.initialize('/workspace') + if (fixture) app.send({ operationId: 'op-active', text: 'first turn' }) + const controller = createApplicationUiController(app) + const view = new BraidTerminalApp({ + controller, + tui, + theme: createBraidTheme(false), + workspace: '/workspace', + nextOperationId: () => 'op-rejected', + }) + const done = view.start() + terminal.sendInput(fixture ? 'queued draft' : 'unconfigured draft') + terminal.sendInput('\r') + await terminal.waitForRender() + assert.equal(view.editor.getText(), fixture ? '' : 'unconfigured draft') + assert.equal(app.state().runs.length, fixture ? 1 : 0) + if (fixture) { + assert.deepEqual( + app.state().queuedInputs.map((queued) => queued.text), + ['queued draft'], + ) + } + if (fixture) + assert.doesNotMatch(terminal.getViewport().join('\n'), /Queued turns are not exposed/u) + else assert.match(terminal.getViewport().join('\n'), /Configure a connection/u) + app.cancelActive() + view.stop() + await done + } +}) + +test('conversation shortcut opens the searchable conversation picker', async () => { + const terminal = new VirtualTerminal(80, 24) + const tui = new TUI(terminal) + const app = createBraidApplication({ fixture: 'deterministic' }) + app.initialize('/workspace') + const view = new BraidTerminalApp({ + controller: createApplicationUiController(app), + tui, + theme: createBraidTheme(false), + workspace: '/workspace', + nextOperationId: () => 'op-open', + }) + const done = view.start() + terminal.sendInput('\u000f') + await terminal.waitForRender() + const screen = terminal.getViewport().join('\n') + assert.match(screen, /conversation/u) + assert.match(screen, /New conversation/u) + assert.match(screen, /type to filter · enter to choose · esc to close/u) + view.stop() + await done +}) + +test('help surface renders keyboard guidance without decorative repetition', () => { + const help = new HelpViewPanel(theme) + help.setQuery('') + const rendered = help.render(80).join('\n') + assert.match(rendered, /Ctrl\+P/u) + assert.match(rendered, /Ctrl\+O/u) + assert.match(rendered, /Ctrl\+K/u) + assert.match(rendered, /Ctrl\+G/u) + assert.match(rendered, /Ctrl\+C/u) + assert.match(rendered, /Ctrl\+D/u) + assert.match(rendered, /\/help/u) +}) diff --git a/test/w8-runs.test.ts b/test/w8-runs.test.ts new file mode 100644 index 0000000..4118876 --- /dev/null +++ b/test/w8-runs.test.ts @@ -0,0 +1,404 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import type { RuntimeStreamEvent } from '@tangle-network/agent-runtime' +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 { FixedClock } from '../src/ports/clock.js' +import { DEFAULT_RUN_CAPABILITIES, type ExecutionPort } from '../src/ports/execution.js' +import { SequenceIds } from '../src/ports/ids.js' +import { runtimeContractEnvelopes } from '../src/testing/runtime-contract-fixtures.js' + +const REPLAY_CAPABILITIES = { + ...DEFAULT_RUN_CAPABILITIES, + streaming: { live: true, replay: true, detach: true, turnIdempotency: true }, + sessions: { continue: true, messages: true }, + controls: { cancel: true, steer: true, queue: true, status: true, recreate: true }, + events: { stableIdentity: true, sequence: true, cursor: true }, +} as const + +function finalEvent(text: string): RuntimeStreamEvent { + return { + type: 'final', + status: 'completed', + reason: 'complete', + text, + task: { id: 'task-test', intent: 'test' }, + timestamp: '2026-08-01T00:00:00.000Z', + } +} + +function appFor(execution: ExecutionPort, journal?: MemoryJournal): BraidApplication { + const app = new BraidApplication({ + profile: DETERMINISTIC_PROFILE, + execution, + clock: new FixedClock(), + ids: new SequenceIds(), + ...(journal === undefined ? {} : { journal }), + }) + app.initialize('/workspace') + return app +} + +function finalExecution(text = 'done', capabilities = DEFAULT_RUN_CAPABILITIES): ExecutionPort { + return { + capabilities: () => capabilities, + async *streamTurn(): AsyncIterable { + yield finalEvent(text) + }, + } +} + +test('admission receipt binds portable context and is deeply immutable', async () => { + const plan = createPortableContextPlan({ + sourceRunId: 'run-source', + sourceBoundary: 'cursor-source', + destinationRunner: 'pi', + messages: [ + { + id: 'message-source', + role: 'user', + parts: [{ id: 'part-source', type: 'text', text: 'prior' }], + }, + ], + transformedPartIds: ['part-source'], + }) + const app = appFor(finalExecution()) + const receipt = app.send({ + operationId: 'op-context', + text: 'next', + contextPlan: plan, + contextTransfer: { + planDigest: plan.digest, + sourceRunId: 'run-source', + destinationRunId: 'run-000001', + acceptedAt: '2026-08-01T00:00:00.000Z', + }, + }) + await receipt.completion + + assert.equal(receipt.admission.requested.contextPlanDigest, plan.digest) + assert.equal(receipt.admission.contextTransfer?.destinationRunId, receipt.runId) + assert.equal(Object.isFrozen(receipt.admission), true) + assert.equal(Object.isFrozen(receipt.admission.requested), true) + assert.equal(Object.isFrozen(receipt.admission.capabilities), true) + assert.equal(Object.isFrozen(plan.messages), true) +}) + +test('contract fixtures preserve parts, tools, reasoning, artifacts, proposals, warnings, usage, cost, and interactions', async () => { + const execution: ExecutionPort = { + capabilities: () => REPLAY_CAPABILITIES, + async *streamTurn(): AsyncIterable { + yield* runtimeContractEnvelopes('run-000001') + }, + } + const app = appFor(execution) + const state = await app.send({ operationId: 'op-contract', text: 'inspect' }).completion + const assistant = state.messages.find((message) => message.role === 'assistant') + const run = state.runs[0] + + assert.ok(assistant) + assert.ok(run) + assert.deepEqual( + assistant.parts.map((part) => part.kind), + ['text', 'reasoning', 'tool-result', 'artifact', 'proposal', 'warning'], + ) + assert.deepEqual(assistant.parts[2]?.input, { path: 'README.md' }) + assert.equal(run.inputTokens, 3) + assert.equal(run.outputTokens, 4) + assert.equal(run.costUsd, 0.01) + assert.equal(run.interactions[0]?.request.id, 'interaction-1') + assert.equal(run.eventDetails.length, 0) + assert.equal(new Set(app.events().flatMap((envelope) => envelope.event.kind)).size > 1, true) +}) + +test('duplicate ingestion does not duplicate a part and a sequence gap waits for replay', async () => { + let release: (() => void) | undefined + const execution: ExecutionPort = { + capabilities: () => DEFAULT_RUN_CAPABILITIES, + async *streamTurn(input): AsyncIterable { + yield { type: 'text_delta', text: 'A' } + await new Promise((resolve) => { + release = resolve + if (input.signal.aborted) resolve() + else input.signal.addEventListener('abort', () => resolve(), { once: true }) + }) + }, + } + const app = appFor(execution) + const receipt = app.send({ operationId: 'op-gap', text: 'gap' }) + await new Promise((resolve) => setImmediate(resolve)) + const first = app.events().find((envelope) => envelope.event.kind === 'run.text.delta') + assert.ok(first?.event.kind === 'run.text.delta' && first.event.provider) + + const duplicate = app.ingestRuntimeEvent({ + runId: receipt.runId, + eventId: first.event.provider.eventId, + sequence: first.event.provider.providerSequence, + receivedAt: '2026-08-01T00:00:00.000Z', + event: { type: 'text_delta', text: 'A' }, + }) + assert.deepEqual(duplicate, { accepted: false, duplicate: true }) + + const gap = app.ingestRuntimeEvent({ + runId: receipt.runId, + eventId: 'provider-4', + sequence: 4, + receivedAt: '2026-08-01T00:00:00.000Z', + event: { type: 'text_delta', text: 'D' }, + }) + assert.deepEqual(gap, { accepted: false, duplicate: false, sequenceGap: { from: 2, to: 3 } }) + for (const [sequence, text] of [ + [2, 'B'], + [3, 'C'], + [4, 'D'], + ] as const) { + const result = app.ingestRuntimeEvent({ + runId: receipt.runId, + eventId: `provider-${sequence}`, + sequence, + receivedAt: '2026-08-01T00:00:00.000Z', + event: { type: 'text_delta', text }, + }) + assert.equal(result.accepted, true) + } + assert.equal(app.state().messages[1]?.text, 'ABCD') + assert.equal(app.state().runs[0]?.eventCount, 4) + release?.() + app.cancelActive() + await receipt.completion +}) + +test('a disconnected live iterator reconnects and replays before declaring an unknown run', async () => { + const execution: ExecutionPort = { + capabilities: () => REPLAY_CAPABILITIES, + async *streamTurn(): AsyncIterable { + yield { type: 'text_delta', text: 'before disconnect' } + throw new Error('transport disconnected') + }, + reconnect: (input) => ({ + async *[Symbol.asyncIterator](): AsyncIterator { + yield { + runId: input.runId, + eventId: 'replayed-terminal', + sequence: 2, + cursor: 'cursor-2', + receivedAt: '2026-08-01T00:00:00.000Z', + event: finalEvent('after replay'), + } + }, + }), + } + const app = appFor(execution) + const state = await app.send({ operationId: 'op-disconnect', text: 'reconnect me' }).completion + assert.equal(state.runs[0]?.status, 'completed') + assert.equal(state.messages[1]?.text, 'after replay') +}) + +test('explicit cancellation is acknowledged and reaches cancelled, while legacy abort remains distinct', async () => { + let release: (() => void) | undefined + const execution: ExecutionPort = { + capabilities: () => DEFAULT_RUN_CAPABILITIES, + async *streamTurn(input): AsyncIterable { + await new Promise((resolve) => { + release = resolve + input.signal.addEventListener('abort', () => resolve(), { once: true }) + }) + }, + cancelRun: async (input) => { + release?.() + return { operationId: input.operationId, outcome: 'accepted' } + }, + } + const app = appFor(execution) + const send = app.send({ operationId: 'op-cancel-explicit', text: 'stop' }) + await new Promise((resolve) => setImmediate(resolve)) + const control = await app.cancelRun({ operationId: 'op-cancel', runId: send.runId }) + const state = await control.completion + assert.equal(control.acknowledgement.outcome, 'accepted') + assert.equal(state.runs[0]?.status, 'cancelled') +}) + +test('queued input is durable and drains after the active run terminates', async () => { + let call = 0 + const execution: ExecutionPort = { + capabilities: () => DEFAULT_RUN_CAPABILITIES, + async *streamTurn(input): AsyncIterable { + call += 1 + if (call === 1) { + await new Promise((resolve) => + input.signal.addEventListener('abort', () => resolve(), { once: true }), + ) + return + } + yield finalEvent('queued result') + }, + } + const app = appFor(execution) + const first = app.send({ operationId: 'op-queue-first', text: 'first' }) + await new Promise((resolve) => setImmediate(resolve)) + const queued = app.queueInput({ operationId: 'op-queue-next', runId: first.runId, text: 'next' }) + const queuedAgain = app.queueInput({ + operationId: 'op-queue-last', + runId: first.runId, + text: 'last', + }) + assert.equal(queued.position, 1) + assert.equal(queuedAgain.position, 2) + app.cancelActive() + await first.completion + await app.waitForIdle() + assert.equal(app.state().queuedInputs.length, 0) + assert.equal(app.state().runs[1]?.status, 'completed') +}) + +test('journal restart replays the committed run exactly once', async () => { + const journal = new MemoryJournal(new FixedClock()) + const first = appFor(finalExecution('restart'), journal) + const firstState = await first.send({ operationId: 'op-restart', text: 'persist' }).completion + const second = new BraidApplication({ + profile: DETERMINISTIC_PROFILE, + execution: finalExecution('unused'), + clock: new FixedClock(), + ids: new SequenceIds(), + journal, + }) + const secondState = second.state() + assert.deepEqual(secondState, firstState) + assert.equal(second.events().length, first.events().length) + assert.equal(secondState.messages.filter((message) => message.role === 'assistant').length, 1) +}) + +test('status reconciliation never regresses a committed terminal run from a stale provider snapshot', async () => { + let polls = 0 + const execution: ExecutionPort = { + capabilities: () => REPLAY_CAPABILITIES, + async *streamTurn(): AsyncIterable { + yield finalEvent('terminal') + }, + status: async (input) => { + polls += 1 + if (polls === 1) return { runId: input.runId, status: 'running' } + throw new Error('stale status transport') + }, + } + const app = appFor(execution) + const receipt = app.send({ operationId: 'op-stale-status', text: 'reconcile' }) + await receipt.completion + await app.reconcileRun({ operationId: 'op-reconcile', runId: receipt.runId }) + await app.reconcileRun({ operationId: 'op-reconcile-error', runId: receipt.runId }) + assert.equal(app.state().runs[0]?.status, 'completed') +}) + +test('native continuation requires and records a matching provider boundary proof', async () => { + const execution: ExecutionPort = { + capabilities: () => REPLAY_CAPABILITIES, + admit: () => ({ capabilities: REPLAY_CAPABILITIES, providerSessionId: 'session-native' }), + async *streamTurn(): AsyncIterable { + yield finalEvent('native') + }, + nativeBoundary: async (input) => ({ + boundary: 'boundary-native', + digest: `${input.sessionId}:proof`, + }), + } + const app = appFor(execution) + const first = await app.send({ operationId: 'op-native-first', text: 'first' }).completion + assert.equal(first.runs[0]?.providerSessionId, 'session-native') + assert.throws( + () => + app.send({ + operationId: 'op-native-forged', + text: 'forged', + sessionId: 'session-other', + nativeContextBoundaryProof: { + runId: first.runs[0]?.id ?? '', + providerSessionId: 'session-other', + boundary: 'boundary-native', + digest: 'forged-proof', + }, + }), + (error: unknown) => + error instanceof AppError && error.code === 'NATIVE_CONTINUATION_UNVERIFIED', + ) + const continued = await app.continueNative({ + operationId: 'op-native-next', + runId: first.runs[0]?.id, + text: 'continue', + }) + const state = await continued.completion + assert.equal(continued.admission.nativeContextBoundaryProof?.boundary, 'boundary-native') + assert.equal(state.runs.length, 2) + assert.equal(state.runs[1]?.status, 'completed') +}) + +test('detach stops the iterator and reconnect resumes the same run', async () => { + let release: (() => void) | undefined + const execution: ExecutionPort = { + capabilities: () => REPLAY_CAPABILITIES, + async *streamTurn(): AsyncIterable { + await new Promise((resolve) => { + release = resolve + }) + }, + detachRun: async (input) => { + release?.() + return { operationId: input.operationId, outcome: 'accepted' } + }, + reconnect: (input) => ({ + async *[Symbol.asyncIterator](): AsyncIterator { + yield { + runId: input.runId, + eventId: 'replayed-final', + sequence: 1, + cursor: 'cursor-final', + receivedAt: '2026-08-01T00:00:00.000Z', + event: finalEvent('reconnected'), + } + }, + }), + } + const app = appFor(execution) + const send = app.send({ operationId: 'op-detach', text: 'background' }) + const detached = await app.detachRun({ operationId: 'op-detach-control', runId: send.runId }) + assert.equal((await detached.completion).runs[0]?.status, 'detached') + const reconnected = await app.reconnectRun({ operationId: 'op-reconnect', runId: send.runId }) + assert.equal(reconnected.runs[0]?.status, 'completed') + assert.equal(reconnected.messages[1]?.text, 'reconnected') +}) + +for (const count of [10_000, 100_000]) { + test(`${count.toLocaleString()} message transcript stays bounded at the view boundary`, () => { + const messages = Array.from({ length: count }, (_, index) => ({ + id: `message-${index}`, + role: 'assistant' as const, + text: `message ${index}`, + status: 'complete' as const, + parts: [{ id: `part-${index}`, kind: 'text' as const, text: `message ${index}` }], + })) + const state: BraidState = { + ...({} as BraidState), + 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) + assert.equal(view.messages.length, 200) + assert.equal(view.hiddenMessageCount, count - 200) + assert.equal(view.messages[0]?.id, `message-${count - 200}`) + }) +} From 418cefbc04329e5e3c6f46ef15cdb8bd480331da Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Tue, 4 Aug 2026 00:06:44 -0600 Subject: [PATCH 2/2] fix(visuals): settle final capture frame --- artifacts/verification/w6/120x40-plain.txt | 2 +- artifacts/verification/w6/120x40.png | Bin 10042 -> 9887 bytes artifacts/verification/w6/120x40.txt | 2 +- artifacts/verification/w6/200x60-plain.txt | 2 +- artifacts/verification/w6/200x60.png | Bin 11536 -> 11295 bytes artifacts/verification/w6/200x60.txt | 2 +- artifacts/verification/w6/40x12-plain.txt | 2 +- artifacts/verification/w6/40x12.png | Bin 13728 -> 11445 bytes artifacts/verification/w6/40x12.txt | 2 +- artifacts/verification/w6/80x24-plain.txt | 2 +- artifacts/verification/w6/80x24.png | Bin 21623 -> 21165 bytes artifacts/verification/w6/80x24.txt | 2 +- .../verification/w6/capture-manifest.json | 132 +++++++++--------- .../w6/states/active-streaming.json | 16 +-- .../w6/states/active-streaming.png | Bin 20071 -> 19529 bytes .../w6/states/active-streaming.txt | 2 +- .../verification/w6/states/analysis.json | 12 +- artifacts/verification/w6/states/analysis.png | Bin 46975 -> 46525 bytes artifacts/verification/w6/states/analysis.txt | 2 +- .../verification/w6/states/comparison.json | 12 +- .../verification/w6/states/comparison.png | Bin 45771 -> 45297 bytes .../verification/w6/states/comparison.txt | 2 +- artifacts/verification/w6/states/empty.json | 12 +- artifacts/verification/w6/states/empty.png | Bin 20286 -> 19815 bytes artifacts/verification/w6/states/empty.txt | 2 +- .../w6/states/failure-or-reconnect.json | 24 ++-- .../w6/states/failure-or-reconnect.png | Bin 22931 -> 22435 bytes .../w6/states/failure-or-reconnect.txt | 2 +- .../verification/w6/states/fork-preview.json | 12 +- .../verification/w6/states/fork-preview.png | Bin 44159 -> 40977 bytes .../verification/w6/states/fork-preview.txt | 4 +- .../w6/states/graph-or-analysis.json | 12 +- .../w6/states/graph-or-analysis.png | Bin 33293 -> 32725 bytes .../w6/states/graph-or-analysis.txt | 2 +- .../verification/w6/states/interaction.json | 12 +- .../verification/w6/states/interaction.png | Bin 47608 -> 47522 bytes .../verification/w6/states/interaction.txt | 4 +- artifacts/verification/w6/states/narrow.json | 12 +- artifacts/verification/w6/states/narrow.png | Bin 11213 -> 10265 bytes artifacts/verification/w6/states/narrow.txt | 2 +- artifacts/verification/w6/states/profile.json | 12 +- artifacts/verification/w6/states/profile.png | Bin 37804 -> 37217 bytes artifacts/verification/w6/states/profile.txt | 2 +- scripts/capture-visual.mjs | 11 +- 44 files changed, 163 insertions(+), 156 deletions(-) diff --git a/artifacts/verification/w6/120x40-plain.txt b/artifacts/verification/w6/120x40-plain.txt index a7e8807..1402ebb 100644 --- a/artifacts/verification/w6/120x40-plain.txt +++ b/artifacts/verification/w6/120x40-plain.txt @@ -1,4 +1,4 @@ -Braid ready — /home/drew/code/.worktrees/braid-integration +Braid ready — /tmp/braid-publish.Jme9u3/repo status: ready for a message conversation: New conversation; branch: branch-1 profile: Braid starter; runner: pi; connection: deterministic fixture diff --git a/artifacts/verification/w6/120x40.png b/artifacts/verification/w6/120x40.png index 584a174471337c61e4e33c1dd75bcc3bd6d277e3..0e784196d11134a9333b96e08b4a18fb028eb0b9 100644 GIT binary patch literal 9887 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

lxx(|YPW&7fYTTnCf`IaIL2 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 VZ1ji)!@owWDrr6~di*lr{{SxE34{Ou diff --git a/artifacts/verification/w6/120x40.txt b/artifacts/verification/w6/120x40.txt index e146b83..2b523d1 100644 --- a/artifacts/verification/w6/120x40.txt +++ b/artifacts/verification/w6/120x40.txt @@ -1,4 +1,4 @@ -braid cwd braid-integration · session New conversation · branch branch-1 +braid cwd repo · session New conversation · branch branch-1 W6 visual proof Fixture response through pi: W6 visual proof diff --git a/artifacts/verification/w6/200x60-plain.txt b/artifacts/verification/w6/200x60-plain.txt index a7e8807..1402ebb 100644 --- a/artifacts/verification/w6/200x60-plain.txt +++ b/artifacts/verification/w6/200x60-plain.txt @@ -1,4 +1,4 @@ -Braid ready — /home/drew/code/.worktrees/braid-integration +Braid ready — /tmp/braid-publish.Jme9u3/repo status: ready for a message conversation: New conversation; branch: branch-1 profile: Braid starter; runner: pi; connection: deterministic fixture diff --git a/artifacts/verification/w6/200x60.png b/artifacts/verification/w6/200x60.png index fb67e4ff24c863036422bed0a02b94cf766b63aa..9c420668baa341ea0d30a90066e5ce00c6164751 100644 GIT binary patch literal 11295 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 XG|bJ#oLVyY`+a&^#+sNrk0SmTTIqF% literal 11536 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 b7FOn5&em4ytHoo$C6Jty^1Bkq$It%(J+7&9 literal 13728 zcmdUWbx>T-x923mNq|6r;2GTAf(G{xAVBco!F_N@2ol^OcyM?3;K75-;4rwm3^RMb zzqeAm^>*v+zQ4A(6jk>&eNXp2ea`26`qQBw-pjqfBE8CyBr>4aQU8 zj#~*f3h?KRsl2)h2;@Z%0(}hxf$o7@Uw470oFLHtXAnr}CkRCBnBJl$0z5!7QIeAe zA^-XL)m|76+(CC$P?163$3Q`SjulnI%mM=OJ}5{_eDqj6T6Whb*r7r{Wq612qh-phmX=O6WIGdQl&_xRFVx@(=*{b{ zyDMmO%v5$W4gJ_jIle#!mIy`afsmVm1L5CqsNbFLZ;k29&0j*#bURoXSzyC6p10dG z8UC4FS^ZYGRX;ZLRYcn}?XJmAGs@^Ey3S~BIc&usyUKr;S1L9R%F$h;rBB#1=cemC z0*xKqyBn=wc~&Ua*ESdbG{;Oc+Q8q+nmcSHJ2ceRqd4?uX5MAyhtG);zsC8Vg`)p? z=Hm-W9s@PaXW&4YxWK$E$8pcc;0Ud$St*Rfb{33g$o|z7`g}w|!!s?)rd|i+>0(OC zncBP!6SLcS%@O4L^4qI2y+Ze^LoD3aYa(WU-??cfl4>m2&&28TyBqPaQw-cnsV zZkF9W5An+_crS^8dTY?;bBL0{%5~15id$*rMc=>FUvRsdYU zla(4cn#f*X!0vbSJW9qDt|UfbILhqzRquvp={uE1Q-zf}G==M$9=*j!ttHGXXWk&6 zx80QDI1~a?3wAjPbrE4`zS|`R6z>~_pOXpOk1?~h-IB|}d5sae2Adt~lia}7((g+Q zzYeL0E&gq;V%R{Tmy}b|pZ+35h?gZSlt6^uJIWuQvf3|vJey|VXFYk3STIKR(vrTf z?;{Su@Z=ZfFlUF0FcswpOHUPF{Fn8R$AVumAKa7IMKy5~6*JmCuM7?H6u!lU@$((E z`bcbKRT5>*g&E;VVlVZxJCd=mFff3btayGeOfOm^_pdfz^JD9a(lW(`IOto8Z+>W5 z|IlLW<2q8PR7bJacD%Ntw=?FgD}0+JcvSC`ayqlY8;(aEKS;1WrYm4N<~krj4td

Xg$FTs{5HHp*V|WJ ztrdkshD(Lf<~%dCxPD$u@JSovkSY-GJ@NY7_s-P*BqH<*{Xg>Z>DDKQqE02%`F)v6 ze)HBFm!Mncz)=p&nyur$V?BS$j|PTec%-~u)(_PmNfz^^(gc+2^hCO2{6*$J7?Js0 zT>{NkC?ixf$gygt@FW|g`i)I_Hgk!hIpO9BIw7bE!+)-+#Bjiu8|&qon`OsFB9a;( z_G2|QKYX}78=#^R@qu%1Yd#Cyd>hsfnHqR6tefG-3$5th|=u#`|*Ls(P7NJu~?24-L&re%F448;w z<6yRWccRb1rlSnZPgXTGwa{)2^2l~et*x=sZMynr4)5N?^WHe0Y0~>G$ zr}M*W;xyo@=@q8cIZ{2{_NxzdL8=evU|L1u^`C8@?Nv0dijRa-lr0P`W_zF@f?Z0}AVsku3!e)jlM^4?IAwzjrZMl}37aOy)w6G&diDI5F{c0z(h4?F8Tw*+dg_G>_i#iB8Uvaf)QeSy_xy)O; zfvQAva}2Pjxy(101zd7Gvb*Ql6oDTo!ZqRi(W{sivsz3}4r14uKHY_EK8xbike4_v zdTDh6XKmXShjz}&tdc(_#4s!u1+Yrra)_otXIG~NrcDB-H6yfH!R!;o4nZM1!f%3F@MP(z_HmIWkf6$z7)SMO+ScM!bT>v3t1$_HG81_;NdK zsb?Wm)6>1tefF0iyx6Komdd=a_85(ivKTYmEN6vt(>%W=caHC*#*YomW@g!MkD4C; zPCO;IpTqwI*M^x|RBJetyIt?;p>ZfLMJbUz(g$*B)-Un1i`^!HguR=!=+FQhJuHI}d96#wMk5R_yaU2IFhG(Nol&;(+1c4FitvnX0iKhz z^z`(Yn12$eXR528-xJBAs-jOu$zxfQe?D}|?RWiz)>HU?+23PgWHP6x!%mmc)k@Mp zp8fRt)e1P~pn{4(?cMw{AYFZjJUex1rhE zK!K=PUacubtM1xZ4kJCgCMTYNX-DXv8U3ib>)YhF6QXF_th8h%bq)Mmi#~T+=%1CW zp{5&wm)hhp75h-kf~&tVUAw4Sb>Y0V_jhSif@B_=h|X?|jFH#Xz1N&K2#l$P_DZ?O z_mArhn`mWc{x;+8>FHTE2o`=^W0c8fW@LQ+dd=2k5@L4GU-?N@RXdegQ(ZSTCSB;y zLUC}PpOJb1o)@iQO^)bK9e>hPeqM7;8I(pJqho?q}7%V1N=ec|j*AvTtaK8Gr2C`-TmZgprv zLix~$>TTzv+?8^K_6fASxR606pHxq`Y^l~Irj>>NdU24aKr3k~nJp=QB0U)QVM6K@ z(Hdhgts{DCpMDjut**aODAX}TI-Z-O!v(?pyML=Rpzif` zb(eoap13|!Fo6DQv;n+R5E#irCUS0Vprv|sXYb91|T{D_H(>A!k%CM@5C<~mC*82tYm&6jmD=9!>%sZ$!`+jMzL%c} z2?;SVLofXA3uqaxAu^!Ls^cAP9i5aMlL2V0T0V^Nm7P@N+d7{iHU*h>hs>GvajRn>gq{xIa^z1QXbzN zequZr!C=p^Sps9)0=j4IT5D>!$J0d$N^-*RN-9|8Fm#~T!-Ioe-AW#>nD(PGyiZm; zZ{Sw1{eRu;ztGgw^v}5ITS543p*aXHx9q8)nIyE)fAD#8LvNOJ^N!rkGCF83e%$)9al%L;`n}^5F&hG9mKPLzH zZvw;?mp|zt?R1?5pila+i<9%$kcUE9e=bcKH?-g5e9%AR2dnHmiNxi_$jQ#d3j|;?@Eilv_7eeh^TA- z-kzSb-yzzHa}1>evp0M>Q|C@$t%3#K3f$kLogTq#7YtWuhq zn)=ajLJS`0kMm>JgYbq%z_K%`sDLT~fmQ z?5RC7Gn0-kc88~x^Tq)Hst^hM{E*^eCjE-FR-BIs$>$y-(N38K7Td2RaIx;De}j9p zE1_*RC)umf>7S0<(ZIA41()(1#5+56nxE9hEas-_k#2Ix>8(Ij%ZVw0k@4}J&QagA z?|72}oWH41f4k>D3`9r1NMBskr2_`uGS-8BGd(ly>gKuygN0+AS78d0zc)42FqMM# zFjX0lsl3qq#^%zzG(G)FSh#gZwddmuFAx+1o}OT6sK1E!>>76u41a(JDJjsZOGqFh z4-k@u)m%$`zPG2bbCm{z@~SqrYo9F(XnVcap5#SFMjqixz9o-(u9E$TLXjyPlKp`Z zz=`M-PR;t1(F|-dUH{l9lXsv!JUo2);y#9sE<8N^91Ckt$V)Ly5gtF4n3&}$tES{q z%E`vi-#I%qrLC^sP*ZJOd%(jp8N|vY%61h^gDy~R#i|{4hU1-+MdvX^olbdOrREh;9#QWg5lPUPKp0zUR#vwt$fz_W_e)2hD&$3z4Eh zFoHT*sA1YNGBQTNs1QwQHHi*>1G)Ex9c6874bILriHV7K15W#?x&WT08e$2wddou- z*|Yu$I={HEz(7amc10_cJglPdKEex8_ef~XSMP?8j}P~9b=y6%%$8R?PZbjjo0wdz zkV-qe_X_+VfTQ$M($$ydFeognq^?f+Ot`Bx5cRJ4{{9}`80;H?Cwb@Td7BHmt@zP^ z)YPJ9WBX3QsimpOf8nvQu|cH>^)vQ46Des<5inm=)=6z^3kWFO$r~C93lyjfv`QI< z6%^3EAmvt;lG+?S5Ck7^oeclvbRx~DkfM**oZ&6EJF2Y}7u#VI7ii7T@9XPJq?VbP z5^yBtEP8C)BiUBYtUw_O?v3OY&j z;)ue*#6-fu(6gsIUe~8t-)>G`rGO4OFu|0xpvuE2;b( zH#++&P`&Z*PbJnKM{!w+H?GLT1XvBWa@&C}28kQUzt7fg}9DtJt4~<0P5{hn=g>pMZbQ%%U8cR$JW!+L*8!UdU^&C zvb5FK)QpUb^hZ;)pS;s@(0;7n{RYSXGPcy*-ku7&%%gZ(E^Z$0!4ER1 z5K7P=C4tfINs*iyCjzAF?_Fz+ZeI6K;CpzrR{Q%hC5@i9VN@VXOnHFVI_M65P`bF`5E>(}27^x)@|J~!8^ zm75!g4!5a*E)(;A!I@|C8Zu;-2m}W8=GhP1}%lT70X={?VbKp#v>Xnm{wF zI#KUy6j6*eUk*I5+1z-hu`!7U($LzPdG={fcTH3uzF%`V5t}rIN-DScWCBx7jk8$| ziln@%_8fA^oIP6DFQ70y&D%iqU`fXa)9H+3v5Ykma^8D(SQZgmVZX`XA=~N0cUOoB zN3-Hh9caR=wHS9IgRLwr`D~9^aaA|4P0lUBM^bncLPLAxB5plrZ7(`}eaF+c$K~nc z+uI5_3~OxjN?FnjI{lG`2u3&^q?G{0{#AOMqZh6}pVRzJEws^nJd$h<9ry8Ugv6-q z>>6cuiX=TUKnN*sT9wz~^q6`|RnpjuTEm-K7g6T9+5w~x-v@48J-wNkX;LyWB}JuC zWtFfnEFdG^QSW=|AC}g36atPBXw?wa7v^`?J!7Z*pyFU*K}7zF)lueDT+%3!@(%}9 zR{7ER>J6U&u|-A6&`@L=ziOJ8>2Hu_)+#@rfPlklb7A3ZSf2^STj#vCvbMG|_}^?P zeRf=Moll=Le_FfWWpGw7VlE=;Kr3<7*v|Ptd-vUw$`* zS`ZKj-#&nCLzCGJCZ?w5rvLuqe(sW%j*8uTqVSnsE{Ef#x;oy@3*v)MWreY^T@n%o z<%K+gWH2R0R#s^YD=X_yocF;&q6Hrqf!FPmlJ@nki{sWQn3%Y+{$Q^5;K0keyt`YhHa0yYJtx=bG&QxV@$kMSAYeS*Yj?o{J1ne8 z|9G+9CgeFKD;v$j-XTyE*=Z0F0n|w9V?2T=DJfJz0RWBj&^d>Vmy&RbtE8l)!$a~z zHyLv}NqHR|ou71JE0`HwOqhi?r`iPQJpr+J$Y3Nt6IVy+)!MFYE=EFpd{0%?;p7R~ zT@Wx%J2o~Kp4$0&0TL1tqUXU4NnU<_V96iS)By0vvV)c~vjBc`a|{I%4QzdzT6x%5 zHPca$+}&OKu)H-*Pg>eTibi`a;;DF@QM6$P;^2Du9e{Wb56ialwkU0{dqQw2f@9?6 z-@mu-Mj5|u4YRPYu-=z^R5^y!!pvbTS{@pVaRbrFvwavgk|i06M2xOGdW??38~OP7 zn1{mVZAeITwCrB^e*}nRm@^>^Iwk<1G=zIceLW#q8lRY;sh4suaBMAMTIU2)Uxi#z zafpvk838Rxy|4J!`Z%YnIye{_9-f(*At8zYFn_{UUdQv`;9v_s34m9ssHi9^it$g+ z%*dFUoXp8B`uw?ylV`*KOD8k3?<6_{98Y(BxRUYn z=NA)`8pm}Xqg9vgk(tTKB}Dlv7|+9Gn}uNrxV*fmq2c;IGEFXud=JvKY;$d8wgq&{ z#5}VwXtf!9B^OQZgCn0)gPW#iep}m9WO-bhvg%A|ty)J+PL3oC3pOzC5hEkLW<6OV12$j2 z5D_iOU=R`!F)}f^wZhPG0}z6cj~Y^@3D7@_i;G1?j9|;dLmJwV#*@zJvH7WRa;r>(gR@#%aGrt3L)({<28&9Mhxjsw zfn)rNjq8>4V-4?B6zZ$%H#_TV8jiY3grJN@@&J)kW!y70H3d|1_yhzbz9iPx)^~TP z7}-CyD}mDUPV}>_EiFC$HNSxBpCp%^g7OP8E(L8y^1dxdLAX4I_+UEcP*f>4^hw{qi=2t6l(GD~7CH#wCS7h65>Qvo}uKpwq1m?a`46!m&VZ8qFiP%w}k z?T_%($`X^6=)w(3$KIitK5|$kK1aQ<8E9x&rF*z>e=X+o`g#!xB?IRHwqN)Q+ppqy zT;6i=pJ`E1ai_Go3_uWvjX;)1BHF*B(R5SBz)MdT=1GMvCJl zXXBHT1?*aq)7!cu59TuE#FlH!H2^s6A>H(&G<4M#NCdL7{S9_v<5_YU!Ut(Tf7U^I zWD#wwLldit0q{x+6&Jl(^Iuvpsbdn*#>p>|{jC&&;F-$1`0x^K5R7NSol?6q5}P|1j3VCTe)8T_YBNVBygJK1=oOq(T2r&WtxaTm zh6V8sPS|ya;r0N%35uDn4{AwAxoiTk61UwVFT>80c7s2SpBJZ#ZRRTWEFCyD_MXf$ z_CCGI0tM0nq5R)@Asq^!#~)KNLw67-uS`?pW;L{R~ZzMH9rA$ zC>WUnd-Y#AG2ka5B6BF$5KPe5mFdUFN&&_uVcYREvsDev>B4A57k;myT?n^#JCif)MTOK55dIM8HD z@ueiTe@#(^-Mklb@Sye3pUM?K`u-tlw;vgh5Xk=9rDeZrI>N&^tZT^X&+8kDG=Zq< z1LB>&*6ziSA_nzGW4lTWdJLA@=}ob{W_+SiOl<^U z+cePE%6Nch9L#sFc_q~%5*j#(M0oCSGX%y&7Vkr;>EmYt@oKm`N=iyze$Wfsp%Ot1usJI8_3WZFOw*dlf2e61 zw_5-kbc+Q{d)%s)6R9UZeMv(>2`kQxoHxbgNkmLMSi6?=c1nQP*|W_;=4~;~Q5#1k z()Qtzjqe$a3qvI5u_F#PRq|Z7s&Yh=2ef=q^HsjhCl&ZQDyK_6=mp@w=XJ3Al+sQ``{Bw(uL|lTQ(XnMKlu0?GxaYh7T5V2eHtF^3Ui2$ zed5wJoPe*ygr#$-Szcr@JS8S}97?}D9PRfM^g}-HpmSqVOptS04)N<@*L*#9H^6g~ zB8(5*B;=x#9b264Wx^^N5KR z&17aHQ11yqr>ifSI!!dhKarOH8I-r!(ngnqjlVWGG?I0kW3VNQvnHa7~vJ*`Ifwi?RqYolNh>RGpNODbuKSw5Zx6FSBsY; zO%7%ksWN#3Q5eXLC%}^g7J-r!@wHF#GmzI0`MJvef<(o*J}DzYYQWjVczI6^3;o|b zUVQo8dl+U#79cQEuiADzGd~T#HUBogm%QxJ-QD;5GFE(-@{|%T)Lxbs@c2+7!6Tb; zF<|o2WklSs(TLSgm?KUx`2^dDXV`Dq_F?NVOe%TBR&Qf^3_&fl;+JK zvh#aLLRJWi4t8~uxlP#;_ZKSj1k`f!AnJz#!drHCg=2tj```WVl|lc9!u~&@H~Rm% zSB14`_7?{8P(on!7?INWIQ7R(*T3*;l>~uyaR5I4|3Q95+ahXdt;jw0DLBa7p^_A| zMuz_I|1ReGPfTv?zea-g&q#pXsQ;^xB%&a8C;tYu8-PJ;5BXf(#Fnh^2QaW#^&iWI z|1sSfevZHyA(o(EK|_aC!+7oryL6RR1-(l@SH#t;;nKJ6nOo$5i~s#Y=(L<+S64N6 zd-ML@VSkU18uIi_809=tsM>fJSlC|PG8k4sjyzIIk1~i9xM}0HJm|^3zRU_HYsmUV z=6ldg|G_ly#DT%cC)DeQWWsw8%DLUKzh?#J;SoMcEx*>CTk2@ZchzTEFq%Mhmr{mG z|IaohXjh;&wAtsS;!?gnqGyCj? zH#n!XBLoGkwzorYo?ltn(_>`UK4BWDpffRz+&$ML5){(2i5uKdJ3Hae`oYdvEQ6u& zBV5&q@0`@!!s6G`-{0YiKAi{S+MXxYFD1QFow#Px zX%JH$Ew?x7?Q*9}SK&w5X*7YpiINe#6ZhHopPCP_IBP(}cYxr{_{F zPa%h%Yw9A&!#xYB-L?s1seO0wuO}gEn{ueBTQCEAE6>D7OG66Y6N{|_cf;I~YBjH1 zIIrw2f+96BL9+tzn1^U8Lm!p`=$kc`Ff}CUU@#y!qCFN#U#$H==*Lo7Pf0Lf;uW`N_PWu~y>3 z-U6mwez_kK*=_9+dy1$%kW9i$6aThezn9(f-2eTvldXPZxZ0Q&sodO)_RBZRUPG{4 z1SVjM-(JOuWku3WML}(?QupPNrLdN~+71IpmNlD?=+iq^*QM(DJ^goKN7KWZEGNE2 zX2L&9-+&wF$7H*#h;}ZXbx;LdlvaL=OJ{{`=i1Vjh7MY{P#~^<8&$T}MR?)3HyYV0 zptj}(v@Z*wJ#0368X|=tQ*oj%0hEGe(X-eyKCNjc5<)^9nAuBIDNdRgYcy(O&(2;c z?s^xQL^@g%P2JhGb9REQXdNy--LA-HUXyB=pFsL$7_+{?#6;M~h$wkZo0IJQs^a8a zQeo-P=iH?C4e!S)1f6IAQ@qGEJQz0Yl4mEBjjDNFjg!YTnONktXLunFwd=07jiKte z2AUQV(!;rkGi|AwpJfFb=l9|#1s(FtGLp6`0HZa;oci|tsE$fxeA6HH$=2SUYidXu*1u^R__xg|VlP+uiVtR%M^}yc z1=;ovW5%Uv9=SkHDOnT>rKXV~Du@H{z$KjY&`pOB9h_J8j*Yz%*uPCHTJlXD&f;dj z>WT1&@O0{C9nBlA+vWl~pIJcXphsYtyT%)iynC{W*>$I>-j%s|;nC-sHr%=-Pdj-~ zhkFn$*7KepbI;e)Hv~+J9~Ymm?81|_Z87U72+=jW`*@JZ^H45y%gb93)3?q2oUq4- zAv82r$fmQoUQ=1Wh)TV=4X`WeTB^4XnyRUmP+`n>7E(K}(#`uPIo4=-W_6Dx#g@)f zG1=nfeD$rY(7xd!tlz)Ys$;X@E(#;t3!;%#O)On3r|=#N{86pv&Nbn8IY15w*$^TB z@fgVrE>CieTfu@4VUm;+M&1MXh2p;Yem~s|Zp}|q^@N^V*~!$qMG_z9A6HK~gL~E|C?8cXv6VvKDv^N#icOzI3LsbL# zuo3Q{t^bfwf>(((8J;BR5Vx|{z!I^!4P=%T@y|DHaeUMUgnp+wh%9&Yz5SXzh_I9` zj%wTz?BEaeY8#nZ;mqRV{T?H;47o68Zv<%`lFC)uL`%j$7!6r2D)p$uapomYB%^N9 z{Wp4i_2VxE#1~Zuah02UBJjr^YiSGxwLqaVqK?e5HhGWL$;plL@5hC?(hI4AqZ>Yc z(t9ZHSOYrAXeiun%3puBE2EYHlTE-xL&#)$2^*2~d2QvqlWL9)lW$^C_^&4nJ3kQ^ z^nG9Xe#r~VV>GidO?<|J7DbX5jfWM^lq%T$XYo@Iy-KD))cW>6X7&z}g=*244AbjP z@dq9a(IIuUq_PW-X~tPr~sMunPBuIhv5k1vnL7uRlmXMSBXELRT~MBNQw zyCj@Z7rWkdobuHk&W$#x0*l>sZ?u8#{b<`Z$Kpd;bmt%T&lQ(752ihwhZ_Z*J!Hx7i$_Y*K)_xBE(ARv& zI4n*}7Fxx3mIg$6j8)99yYl;sXgQ7NM=3yA=o#pL%`!cKY+8F%V`cr zK30E^Ma?9dljH`sfo@ZM(~yX2vQM_h8K>#bSkOuK7!wlaR=rcbcNHZah*3empAAI+ zBWwR3vX_5f!H%*scO1S$9p*w4=F$_sf=1i6=zEr4oiBr{<@jG!=_$YeyDEL{FWB$pCqA+M+i`E>a&^|#XJ zdO?7(VU?7h`{TOaugU*j{D;C12I)??=>bv^3k!uG8-!P6^I&AHyXZ3OG53)tD@ep% ze95Md10x&N-juj0GBGi}hkMN8Yi)7;3}WVBfXkMs$xB|H7I(FY7JPTsQ6RM!3|wau zTog$r!oNM2yV@JaGH&W1r{YeFHh0|MVT}(T=Dp9xmoA@k?0~UlqZA{e&VGsoiO=@^ zwPjpBb^4t#l4oW+0dxXSc=^fB5-_-|K_V;u#{M)lZ71qA48|L1pzXhQeuoo1cxPJ+ z0(}cyM5<3subt>5BqkvE{a^YQqG^nqWI(+^-o9Qtl!r2aJQTQ~`X{RfLSFFcc93a! z$DRZJDY1avUe0A;FQ^k6BiphIZ0vp#oyo26Q^-vv(j4{$*8wqV#{E(<+8z~S^=0?R zhlQEm#?PD95Z}i!qc%?!6^v{evWBIwXcB&7{fEVt30B}x$R7L=5Rj{0kBtXNT^kCm zt~2}rdd?b?p$3mP*Nz8?U<@gSGLDJ5ae%u2b_vSK~m*x-)?CWM`%JoPoNs&#+^DE)U2n_|!dYimnS zL}CuW0V%i-k$pqu_oqy zfEWpSi?glwwu9C=t^nQZcm=)TPFU#R(1fgNN}$2lfJe(-PVLzb#QDJ63Wj%1^zk|pz&c1H+ygx+sM#$K7O}~ z0nj`4{O#&IK0SNuIb!pd)7awGzup!Fq{ynh@(e}&`f{<7vVecF{LEjqfvQtf!3@TAA(EK^@f-&yY4x8KOM#xh7rOs`Grul>v%E0{|P8-rhb)b!4_gD zJ8OT!*A1_8w;r(P!aVzq@Q-DR!<^u_snBoT>DEEa108se{2cNRulK*ZEd~U;y>jjI zN9`8C`Hb*JTaUCPzq|R+y8|t34a=peLg2`T&GpeF*S?0f1{uZu7QEv-MFHmjs$t9~ z@~eO9Hf#I8Q!*mep#)z~FqgzhfrCPhNX-AHA^i7)>3^bve58sR61_X6Qy>QpCgHlu ld~!8)u(LC_cLiBmS+Kj(1yK-qz=BAZCS5^6dat1fNN-Y-&;lw!RGLcfy%RzWoglq; z2n0gt9Rdk0gz~Lt?{luR&pGe@e!SnG@5h#0D`q8GD|5^-=N#i6_dUO9sw>{0VWOd; zqPn5<_O&(@)y2D1RDTFi{{ftdGIMYRp8m3WtF1;w<@1D!>LZ+r>KHinag~Y+EI>uI z{*H=DB94lR@k3IrmK5;DWlI&s*Hq_!|D`qLMgnK9cq*yAxw20E=jF?^ZjkCZDk^Pi zrPr@?y|7z2Ta!DA7dv*--@0bJho?vLsJ{N*{+ZKBoaBuMrc!?bH-3L_ z8F~qLmXUh>6nL`#|9&JtNDoz0jbXWF2H~kzU8*JH7=Q~O53{5{DSlDCY50NUa*1xksQq>9zU$EketbblVcJcGa zY~U?_kE@O7^A8Y=sg8C&Jfu4Utqv9{`Wwup+Ba*EV%a8=@s(6NbnlbUbs9>_$7S~4 zPPYF%Vdg!uw+B8HRxbzeYCVRd=v~AYy(R3sj?Jy?%obSWwNzfZSjP2&ZTk0Wb zZ)bReFCr1WGs1sm;tr433U=Rn;R#%pdgv~w(jV7o)yQR3Z1UL{wzinxrjyg)zx&*^ zPLM8oXJ>V=Sg+KQuS==x4Eo2xrtAY27KG{gs^Q|ofwOY#fv_;ohx4=3K2R0<$Q%?U zrfXR1wCmRw!Yt;>52_K>fptgmjis9=S{C=O!?Qkqlmwv&VId)7JPzX(N)*w)Smd!r z#{q&RHCN8srX1t7yE*x-fr9BXkNOprWfX;@C$bQiRvrecu?l3J9|`hpZ%45HIlv;s zx3kR44zEn!3g3v;?@g)$w>{h9(`T7syU8k3N_e@1qBdQTBNyl$N3d)eHJWH!&S#I< z>JOwaGTclzdhg^kU9XZ0k*@WP;);y>@nNAkmD{Et_TUx=`~~r^rq)s2a%}$2yS@ic0}ii1P)-Bi1SG0Idl>L7?zP?;=I zu{NB)kS`Pv8ozV;IWiU@9gqCrT z;J55Ya-MQ$xQudgG->jhi@A|-BQcf-(^=U%#8UT%E6!!nSdOHTmqxOYO-pqBiZY*YASZFz1f_KEE6T3 z*d|m9Hs%}!s@$1<$^JU}GD1YdcwZH& zC-F+v*5+n{D0#JW?qa~1w-yUBJUmRcZ;GU}xt| zfZcg;3qoAXZ-v8wL2g=?Lm??CCn;Atj8Er%{R%t)3)mO`xkoujgdZKPnMZ_v|N1pN zG(ij^$FA6slPuvNDkvae_}m&B8Jw*qsXteu>B5<&~+hPJDI@Ze8G?yGk=kbw{C4mE!f_x@X7x z8;txKn^;mRIiw=dM8ezWcQrxo4?=cU)@v#MGK<|Zz^SwA!RProFISYZ2Cq_`N$-Ve zVg^{R2YgsDI$98-h|;isk440}ts$;;Xb{LE%VUM1LIet)%VgAfXEd4wnMpdOFko(3 z!|5Tk;oXw%eA_N2X&>q5n~1Y9G2Ps%Hn^%v=*XR?KCE5-DI zYwpbc(4H~FGHjbP6no!1`|I_-M2>C{n(u8?zOLZxaahHuZeJEYyGQP;do=fwb23R; zS6A2F-F+ouDw^M5e6*KmWVp<_XZqT8RVAe%)hACLy_WLTpKg&~`O>YzaEE{UCQ(55 zgkKe3$My&g%sqp}KVo87cNg=Q_cvDUfHj~JoZ=Y-7&l&C5qr4T_s_nqv2k`>n4hzF zXID)*s6_h5@xY&&XIoALzSG-tT!QHpfI2!mSEqkkxtbP8!L@aC&eBL@|`y1D`znawlZgm27cXw|Z#u`)K%+$6v-Fqq} z#V(i)PfU!id86%KR#kO4&f;rgier`X`U_rCR`E3gMv9M0(_d+Pe*euIsUEEFI7-yH z7HmI)P-!PeoF7i8eU^B|%mb${Shb@x2Q&nNCJoX3J8yqTm?bAAIXOEfa_Zfnp&=3v zx?tkQLqkJ72B*2;o@Mzf`;5S91gr2T#eD71fYUcOH8oGyt8XQM9WcGsT|JCHB}7Ln zbw9=DEBX1=L!Li={8(Ik5=&I0R#8*Cc^*(X+$k{7I9_Xe>PiwlU)EeiN@BWOW*buj zK1-ylzF`AVKJV_jjWLVR%SGjD&K)q|43rRCn{aymhxoJAS|Yu&n$nQXwZ0&b{6qPz z1jg{xqdZt5f?K~PX78z1@n-iVY0o}Hf8660^-YGGg8BHg6mz=x8kgww^toh5vAdzE zcNPgqqvJ`8yw7zODaY14ZrgrfZI^1O2f~KJ24u33ga${DI@Jy#-4$NOznSH2VP$Se zy?dM;PPoAUhGG^|OdXe#XDF#h_r{vK(7N`6+AHDnmS?q5)+@903^&=YU#F3H0SvAM z-Dm8tUx7eiznJS$DXdJ#gSfu7$*#G*c#r+{QO~E^6F8i6$2LrkI$GJsL~ZguQ(MM$lK($ynU#03;?TBqk&(I-c{?QL--fr&a={RPfo<^{SX&-rj3> zo~HRA7Rp}B5J~8Tm4J?kiHWH~zZDG$+I0$fk&nZ}8SZ zAiFzHO@C8HdoM~rv%ZJlmAmsydl7*tbQglFZ%&GV+q4x8VxO>J3(Y!AWWdI$C_8Iu zMq$1OvTk!nS3Rzg9Kx7rQ=}}qiEYyBBI7`~n7=URJf*QkDI5z*Ce3cm2TFdUTD%53 zcP}tOM>}>AY1P$lV2^3oQ@+d?nBkjtkV)y`VM;3MT%c^TAgtQn6|XB&L>e?=N272Dz9HMd8nZY}Ivj(cFYB0wI93+}FPEFl7k(0*6zx zH?vCfZ7&y?mV0*1duy{et_TS7 z#pLn9sjK}Lq({ZYw6(Qert87Sz2cP5C@iN3Vdlo(9#8bzeG!=GsE55i9orYLJ5Wrq zQEgN%JqHiZX2X8V%#Fk;vVTFcF6-qp1EjYe!X#6xg`^mfD(PM{!q~96xpj&(8L5fg zSmDpg$r*Q?JVB^<2*LRtNZ!oV7IDGkXibgh(mm+)Lg=pi7*}FJ%J|L>ZiCTx-MAcf z&>;brdR1>fKhe@f>Mbj@b+pBIPWSd%(@`BA4vNV==6tMf(&&nHT@f*_qbxKLx;GTz zGb2)7Gp8F|AqTkof`2utx#M0gAGOOPqbkk$1;c=A0L8pJ`;y2Mt*L1V zyQS%zI5sRZGmb*h5pc2rdtM;ChB^8>Z3A1D2me4bn=7aiwPGM|4B2vWw%wRr94*du zU8=1V`MXcGfBouzATgXBU>-4?t-d`O>-N(fbZ68B`@Xd|KO0uB*4I0&;w`#0G1nb0 zJ3KNhtiqovHL|xrn__0BFFMZRvtNX%H_GV?y!eNy^ml_iBiu^@sF-bB>CeB=KypIp zZ47&3^4-zDaBXMV8jJtXt*)%xJ-^$SdOb;dxX{5}dwvMP&5~roTrsl2JS%C?FHlvs zpFjCRM%0F|PDt=%PBt*_o+^VKM%rotVHRNsG2*BICX3YJ%o-#s?YU^!0jb3wdT{Oe56+_40t6je ziKlfy1Lgj>Nar+DaO$q(6uRDdkQ{fB`n z*>?vPIAc_k(@*Qkbu}JhgX8_hiigGO;o+rjrO=ZT$hKsj>G9?yiEZV48U-e~DuxLN z3c`qsT{W`&I3fvClioaRCS7aoSFKE9)=~^-#Z2@0?X3>Yo9m;Taqj(lgh(B27yJRx zY^2O2H?pfH5M*cd;Y=A=j-Td#+5rdya& z4)2S5tcR@p3`V)gR$C|9Xz>LPe#7~Kz~n`1UEQ4C^y#~oVl|Q>AKXuD4m=OFvvr4t zRw7u;MJOF&H{xZ=KjwGU_o43!h}3w`%@^Iwv@Hh3yOB;4tD=fmNvFiem##597Z4ac z;~UMZ5t@PokQS&D@L#bdp5*TP<+4SQ)Gz%7KX~qT!ng?PK0dVGNacUgLe4MN|OMmzpV)g-If{}6~9qmq+NbK>vDj6ZquM2kODB+`73<(<7f zIaQ=EMd~WQ@o1$_rGxLmq7mH3)!(RIlUfV;Dl6y^gNnklfduof6<7RaEo*EC?9cRz zA?If>HUHt%{CG0mh|LzRoD?CHTOkCvAR7ZirT-R9-(r1`iZH(mzA<`D!OSh<7Xn+s zZCqgoM9J@FPcKp+_S`#Kkv4tC6|7=5a7Z8WOPEQ;I~T)Md3JbuxE&QLmZLl+ArFs`l9EBZnCtjmQ0owR%I&rR;wZa1 z*tRc0B#7QtJ@tj#NuG_p(!g`)Dewn(2ry^Vd#r5F;HDN9bR`&Jqgh6Y5Ec<6<;J}% z@84kn`bpI1E0%lHC@dx>CL#odLRFNN-95OUz<~)=NlD3@{9KuOZe4qG8}~7sMN+?5 zzh`1%ajxYDPO?fm;6S%#qV-SkyLa!Fo{d%9?4J4E1t0+vKjpDtVY){*m?We|P&qcs zFbZA0&)P|XUQbwPs7fpss1DNiFqm#*znDdFqGh%#lGOR1jZhK3fnl8!#&=<#g0Io}2y z6;)X`S&W#GB70kIVrr_cww6rB0g=@^SHe4k!B-wvm8mT(?9C89B8i{h_$LWD&LrJ$ zZ&x_ILi?QWT|xOG;Ngt?+ZL{bwD=;EYr6W9$Xh3>LVAtTe@Hnuh1=je*{-S8P# z`(9Tk>vyjp`8q8v5QKX0`|}IDw9rZ!1t9#@C=zvaT$+Ac>%`<_4Ffl1aaN{-gSHJ9 zGC46JMN8r4d-Uj0-q;ZK0co#mp`xOq`OWur9`VQGLjwdzkn)&%ij-FC&?Ml4vuD!B zx=IYU8(jLjbNWCBYhr^PFS4wKrV#111jLz4W(wWDYkUfy9XoGts&HE<}4}3g#M9d`~B*dMVFXw63tS|M%y5j0{y8M7XBo&18 z^{NVOW15?#^cfP}f<{M2i$M8fSC^OXVwD#D$N}bBx2sCsAyiaNl%Ivv3=9lPqT+b+C=*n)3SrHP4-3`w6wIUTujDt7o!$ir>aiZ zMeG(AH~qG{t)(|~itC}de2vA$F7208LtaQ26lhBe2x!&`AHDiuY-}8|+y{gxxakl~ z!IZ!P>cRymw;p0{eci^v#tixDp{pxFHRy^gwYrj0KBy|ii5~#gpqry@Gc&)j?c0(4 z$B^b4AnJjA=|yP>}BPA42^EqWyqzrGq1< z2>K>?b9D$9;+JL`tZZ#DHHofs&2Q!xtS;aqaf5rjOfo$yE6SZbtdE$2iIIb&q896*gzf1@y_J8o@ZO7BuctgN zlNGQ0F*_|eWcxlsdqfxEI}k{S2ZtwX8734mdjl`d)&E(ks`0r?Bf%n3O&I(QBb#2M zqWaPCuVKmm3QmCU{KZ`?cY%uP?Zf|{9eecP#J#<}!@A@(yhg}fYh&lF1Q8?K`P3|P zyo>?Tq>Er&k3L?U`ymssy&0vnP~MLQ3^*Z(r)F2}>-|E<=D)a;UT9E!QtNLTKb(D* za%0HW91Hz+Sl%)1I9)xoZ7Nx8Ubjww*10VxkWE>r0{Gi&_$|AmxIieE9#TSIf_UcZ zZ?{bAU8XD)`LvoUmb#pDj0IH^oQeBIX zM5T(@n)zcSI2%_-6sd>GZ3o<}(0zp3)iwa2>fbb)s&YQf^1wtzMV%DWenclrebtn( zpON?!5GwUx*uuhMsXMw)c%<~L1^errmciw|1bu`t;?0J>*1*FcdjB&ZLMNO^GZP0E zHVs>wn~BY-v0WNeGwV2+YtcAd17I_To>*r*YHXtQwNS1?WkHoI&IwbTOh3OK&3)!y zHC5@jvb2=V`+Im;x!fklS2&v6;NY-sOXs(uP<~C-M9Hf_pf#y7VB%hW_5dqPFh9m&Kfk?r3hLY5DR1X+=F%Lfm;>H<%+xgoHkGSWR6S z*auRG#oYy% zP|E61rrg(Tl~_B80>E=>T(5l$_yuK7udjyz4?fe{B>TospMF>GE&y?ZLc{|A1+Az^ z5QQC|ol*|Baoq4cV?;u1)xb=H@9F8O$YU>W3Q)LNN8>+Tyh6=h3)vX21c|9!Q?atL zVt)-7EsWV;MMcR8pKcWr!)vvm>u69UApG1Xx6OFc@VxB(ZiDY(0?KJJeW8Pq-*kOE zKNIzm+i~My3wv;ULb{6pW#{DZf(}AH&6z7^`PG#5#PZg8lOT~fYQFnt874Jl+%fzk zE_RmMX$HuYz{@vWW-`3}{HCYHJ!Ycvt2-V*KlcY_Xg&^_(Q(Q7%rsWepCkzDiQ&B= z>rn4Urcs-_zgDCHxkLNC)BmlbolH;FNu$yEtNp(ojdpiqhldMPC(Ev0{bsh*0ALTv zEFq5`KbH1p(gIGGD9%}R_<5X7PXfk97&aEcgM($-02J}8{zE#rFa|5$20#uV)aIv) z6tL)PZ)&bQqjCjtB+YGfqDdhnxj(rsE-tRftWw(}%|nTtX7`@uU3aF6#ah9bBhU}V zCs; z6L~lr;p}v(hs8&^Jhc&8>>B5`V%TQEUk2N2a?47RzEty$}&G_&IFZ|2(ZJ;%OfMI4!_-2 zycQQ%TT{c}_KfN7-DmKph9&QU54MsXuuN4F1{-0NKQ5{27aJBgKngdT(p!L7a}-r; zT^IuZ$ebFfLqK(iUi-uLG?^_&{TdC8GLVW8g)Pb78Pxmxm%3wnVEk(ynq{;COGD8M zrB|q!yy??(RO2%mc;=S2GSADIR{E3iJ5$F&*OtXxXWaLy@XDsP+K|nO(jp`9EEkXl8~% z;=M+~43OR9imxD7-sGmG{l)Lfmfx`@-_jgJ&-W%Ih8uK1Tq^wZ3EI#d>%p~tkLma= z5Tn1dGf8V|<)_aZOaw-y-*B_}0=D(%uWx;nzybPC51pc4iWEXoDEKHAj^{}ZP- zEMmv7w>kOu^b*T|x<-KdwtTrEN3C-0TK=uFOj6oEe3iSyijGQ{NQS0Ewy{j-7+?T+EySTtNrzDof-a;`-&E+K)(?1a=NJK#FK z18V2bE*x7A)hJLv`)_IH{aCJyjs~l$hC;L~<>Y`Y;gzFG-+OKCvIE39^7{$FCTW4t-pWchA)0VW`mKrz>1i?MqQbh@KAWw&i1Uy#wbnEaH&Q;@2)nXB zsp&oVY*jYxe8<8Z3CuTI3pr*pGRYF)TXOfhrnv`Dygm=2iY}^pdoFoxUijEfIZ`k~ zQA9;k1g+Zp62(+v`EUU*&!f5bYK?#)YGY@YmMkm+>ro4f8s)*KDQOtAwK~m|f#;fo z#EU=lf0u?W_pS-q0wDm?Xrbi6lq(~j;b4IpsE@EG5zeC_i~#;$`iz-5 zQLIl`NJvo7JNx@>pH4`G_s~`EN#ELp{0~Jye_Pm_YD%tY4k|3(3<@NM!-InK^E8Ks zhoAqoWA-i4K=v#vBr|hlrsM;AYo?+FkRW)N5YGaG0d&uBbTVJ9aJe8Z&SPa@qs9Uv z|CYSzI8{5ABJE4dB9jsC>F(ZN$Tz(5Kb%1<_a4>PlLi_QpV6nbwz){f&^RI6{=wgY z+yG~RO zbxj)Gt0&VJXy<1v14}Q|kvTE#WkBEJ)|5Xm#s_7DKnMp0_SP*>)gE#JbQss8-npH& z80Rj;5Wde6a1N~N_MF`M1zDK0y-3_EZ$&wIHrSq4Ccsny#+`u++1J-sQbI!J`qi6q zC*Esib#-;9E6V?Z=vQ9Q1@P)3UcNIU49_YhOMjIJ#JvvHfW!r=AcUWcprx!XR=pKlt8|_WGq9-RO!&rpc`}_MxMm%~~wV;HVQ_}q7 z<*#vL)m`$Xb8scj`DM*jx0upL$x*%7#x{z22T`?na7&I5Ig z2`tv$zg%*DH!>^hk(JfLEHH7mj`M;df8EMeJ1YhlU;t`v)gI~(V5`3aeQ*)C<+2_< zW|8(eDNjlD_KCqlXG>B*T-Vu`$_ab%Le5z5Q=mE7+0letQ`zG@t?_2p>FS@qehoK( zs*23>dq{y9Qnl`W-+@sk8LA18s-qL&m*;jXVkGd+@o`^Zm5F>#}->p^l ziZnjFeGA2>KrXRBoXDrslR~#S)vJ9Ex4U$5JtkF`EC-Th1Ast?fIbHne<5xI9<(=U zdDU&9VyeAB*Y{u>gm%HrUA%Jbj=+6^ckhfH3T%F+o+tm7|Ds#8>TxnriXT7c{A+gm zJrlyF)3e3?mh59EP@V9D8{noH3y6??D`ojhXHQ8Ik z5(A`ZSD{lH2z^BvUq1NP@bI(w5SeilB$$?UqQTcd+fw`d!o_U9CoEE)C10(6=H=yu z&b_Q*r}=vvCyE8kO_m1~8CMcVyR7y%#O{f?$c-NZS*hJTG_n@5J!^X=D=%icb;^T`|6PDR@Sf&zQ~Z(oif!wcT$*%5o>Hl#|lqVQQ&74NUIg&7iZb)+q;Fa z0n!ED9d3o%wd(lsAgQFt`CHA!mr_7wVxkI<;U#W-qbr!J7dntpd29P8zaxD)k(hNH z$74b7!&W_hHU(;Shk&61?O-3qakhf#{Pds(@avZo-!6g7f)v+v;y)J$ZQO7otm`xn zw!YY0Zx~U5wn@R*;rHC%K`uo8kpQj`yAWHInig)r?V6`m?7eT?U~D)6LQ{Jm&qhp< zVRlsY@{RJ14+LC7m-lw#hb(zD^x$VwhC(Y5EsC56EOBlvY+{Hi=!biQIabQr zzvwgM`qr(V3 zW35S1zkqSETlR|LYB)GbcWa<{>mjknBYbjSoeMF}C*Jus`Mz8O7Zy5n3-m@htDE*o zjCWy&u-6lrs$s{Q8|(nN&RiDCzR(+EDtX8xI{Le7_f-=PAllhtAJthhFlCg>Op0XL zxgCZDBuSxsUHcSn$P@6g_0B7R&g5~ySmfpFSBZxM6-ZTSnovh8iS)1koG z8msn8Gxgb~ujYPSW$!jX34wwDHL!1w~I?O{K zEYB`_;R*dU4;8P3GB1FTy8yjjLh@D3M8W>X0Sig3%YEtA=(-B025-vqt^JT#A5ooD z9Uh=!y(n^Ty3`CUia{ltjX1A|Td0g7typ^MuCfCIq9Sqp@Y{Kd2rkEk?KC7%aR?QKqL7*P{r2|rL$MM?a#>}f z@JxxPQG2}J28Km4p#MN*Lqe~()NPH(Du2b+q(HOCU4Acr0L07zrzVmJ_ySXVHUg>< z=OgC8rzy<*jl!`mGGQt`UOKr_Z*1J>8JL{>1B_n9*W zRbgK|^=lu70Q#9{$xJ5X)7MQudmQvO0bPRY<_pS^ehGbZq2pFoHN3p%AWyFpm{yWZ zQ62fAiJ<6VtaGXvO}O_l#{s#J^Nen6cm1m*-ImEUTb`3mqO_ofdAZ z-_LVAS3Pz7YUz55kSOqmHpA(0Z$`5I8=4qgq`fI~aIRrM_!^;5x_EboWz*_eV z{QAO)%4a_IlJ7X& z?5oyIwZto@-oRr4XQ`C!SA}!Uq9iOfhU`t2$RktabNvomepqlqwz{lgwRC618E14K zy69_|mgx(~N$Yll&)qx|r`R)?rP&+)_~*QU;^Rk?uS!6iSO?OUnb*VbzIdvFFlhqz!@Ycr44 zRgKg0T8G1DQqKkTw=~Q0`j4yixL6=tDF?qVf$JqCq{>QiRrthsHrI%)=jo6oPJec7 z#XC*tj_kqD$6sZ?WmZ_u_qRazOj!j4o*L$9s(MyHzN~{obxU2dSH2iA6ZP%$ zxh}UW92dnW{K`1(e5;|sD0h!(ZJ$S3_jPNQf1r=Q^!w#ny)R$BK#OsP0{*`a)(Rh# zqsni~)rXF5+?*-;{P}ZzJs9ux^Vb63a80_#{yM-acbLgvNZDsD^Ho*GTb5t%iNhdC zS@8hZ7%6n8Fx-HXlM^(BU7r@{Soyq79epqHFSr2z`{;dsMAnPS_-JxsnW`#H+#OBy z@L_QP5KeqiP{1C<8O3*OOp8iO3+ltH4%ryTS)a z_z6$kO2EEP7jelin08oZ7s0a?z_=-*+NX-IH3mq9IpP3-|F5zLx2M|G*0Jg;IjRFk ziuv?%S-EJR-RAawb;%a&xWc(*dFJ1?`W5kSGzk6H@qqq?{dGFW8{HxItF!VIN0fYu z*lD8|W?V_-?!M!UG3AGw`uqDakb_I4tY7~SU9=^D;uxQ5zRXsX>DjGGlT4LqVC8^6 zHKOpwmUH~k^8Gk!Q0Ai9Xk9h^ywU8#WNQa;-_!kTu9r-&B^t*oN_%CPW_@$Xv9XC4 zy;=tuXfVF7=I>V+)p{rM57%#Bx|tO&OY^PknMfLqf{?x>s;iw91hBOF)y4f1uk>#|KRA4KTo(>hPa zfVF<$lL66{17|~l-WYmflPP21OMnJ2;Z9x*h9D`GUsZ-e@n+q{@!iF#=pP9Q#>geg z*|t`{L!!zMzj;n}j{5mAKwPAuOFoA2sa&h~o~pwpmgo3IcYAAhH8G3z=oM$3#>7Fx z*5$qeLuqeP)K=aY3jZD+T8mmx4I4;^bu`0GW4lp58Z@RP_!t0j^+}b9K&f!yxwWr{ zBR6GUeIM<_cJtm{$Z6wQ9a6TWA?5)aPDCQK0LyQwdSxYj8P-$*kz5wSzwr&mfCawsad+s zeyFRt*jYVLw8SnHvIF`CKpA3>A2B#jE{>PPH+bg%EYFdwVGybk;MXZ_{u-|%B74u- z+6uQVgm>n~JbLozJ?_H@e@~A=@tzWXpbvpquX35pf=Nn;=-GXhVLn6rJGQ@&$(>%H zM}S3V2Ib&9fc#RiiJipukN@t<2V%-@up@e7d9euPJ@59mlV7&i4p4USMeOHf_jK#- zkUa;fxS2PwYBCNJwR$PKu2bt^NYEyrpRm&@NmGm;4&+p;UReoHLti=Z5Y5^@Hq9GR zfi%+inq_;mM0|mU2jtg!9Q``~4KV+X0)I5zHo&Y+)eaEw*^jgTG~bgw@A3b6x}UCk z%W4c7VZL=Rf_V~SbMt~lT|3`^7lXv|TFC8dRX{-?!v!xY_jQR)V|ZH0kAam;*-3Rg zbV#4vdDE$L9vJ)FhW|W}F(eA&d}`|2^Ld=3g&@b}dUT|}Gbf(tuRdE)x!-B3ArYcY>Jej(;WHPvLIGNkD%t*-bBg|!e5Hp%n5pl{;3}J~2J6o9 z;U5RCGupKo1BX4acX)I=o0?vddZce!L@b9T?@aC>C1698gQcDgMqXCQfbbMOng%xE z>MbDbW-9eNcLDVMKbo2@CRUlffXGqr?lLxv=;%O;&$kfFX0lL=@lL^pFE?0ToK0Q1 z02Sjg!$SAo!oL5bOP9R|R%}{&a@8eYi2Doq*81ja7p$(5I>M0&r$AM>+%c#B_yo|8 zMh=z~pHf1T#P{1so${b6HovLLrut}8XZ)KEn@YeN!3iSwMSK;SDw$IQgm`S*W@^2> z|GuxHAI^64t8dlmIKQ#f`{ARC#pxOHje2iKCqg4kA#4J&lA0oWBsjy*#dOwcL*_v((#3uG`L*ssq-t4?QS4KG~~(muI|E zfu;Vo?*UB6qc+j@TW9Xg4k&@V#o5p}ehiIt{fEXt#)Lre_6MMw0IL7@iVp6k!*YOr zDh-_s1FIi#D3C>rPtClpc|>JCpkvhjpPNP)7^XuaBh-VgF!FAv;9)^@5&c8^;eJm8 zMLSMO;SvWd44~XM<&~VYY&Wj{jTrzh4$F038f|Qw8a%-IuAU{UG4}7Vog)CP5#KeF z5^&^li!q4}v5M^Z=#RYt#W$kfA0D8$0-$lh#6?qMWTECyjfYG%F?J?Ji$H1t#J-n6 zqAy;EFF{EjcK_V2;xzqENI*nD7roDUpRn-rgd@^tAVI5owloF^#K|p+vV&h{Q-M7U zT!U2;O5Aq&S^Nq@36g0(fj(i9^<=g*57Al$&$Sug!vm24okr2MV+Hum%GZR5ge$lj zA#mF;kj}0^Ibl?b{>f6$IOeO~gd{@j3^LsUFD9ZEm2LoEPQfRC4B%0SEqib{Ah|vP z)pD}1<-B$+gqe{qCo8Ld07W&qyGJCl2*2BobIUzop_hYC*HHR$rF--X;_&;o_Q00`{!X2)crCA!?yZGojPf6$|Z; z@yN4dLU&kL7zj!@?rBU8-o{VDDA*_l6;r7*LhN9r0d2Y28`sH7QoBcPxD@q~HC|nV zKs+`;`i(uihH}Af1XxijL-2fYiMtMyKd)TKZ4OKXuN&rkj(}i*802_VKRv#?jfNc% zMX8(DSq3!q$sZDWPX-(~t1OT2P`2M!>EPMTS-t7gO#AA5Ds|?5FBRwxt2)GtyAX#G z7J_N7Tq=^NN2|8cV56gBpY6R2mrN(b{q#L`^&37UAz}yEXlV>ftTP>zV2T*Y3@JMX ziC5m#OGSs@??Po6Sf$*$G8)dr?h>%r;#|UkBcoV~WOl`oPO_tJT-RkaOI=_dg1*@woOEh{!K*9lS^OBA&oR#e%^#-WQBGm@Lmnt*WUX=*3C;eIi2F{CGD}!0YRD!9PJMT^A%we7} zqp8imk&=+$j(O9mU$-KC#9VgmlP|lKmXbt^#Q@E#(PmvtU^3Hv=73=W34nnhg{HEv zSY++HI4s_!dF0DT|0q8;oHP_WUtF*-tv$@cybuww?mw>lJNC?;3@e7d1~@#>&&~AR z)`UxB@}cWkaYY0agm-CR7P(OaMRphj?ya!}Ss_X+T-0AK9bZopfDC5sPU$1JOc3Lp z#f?aghp+;JF>g&#KBMu@O1Nu7yWCgchuA}Y?kbo;B$E&J-_|wL20jVDEeHIN2#Wm~ zp!kY#!RKqp@9mB+WUrLA%f2K(i*62-zjgao8Q-vn=hBgm)#;iQfTZb~bqscPPWJJh zSmeoEdX|(3=>BXG=C?nb+QhDdzd+AMN2VP6k&cywM>GWWZk1@+B8Hi|D13G_zOihJ z8+M#mQXcu_nrq{cqP;5t5bB2du*SOu{U2GfvXb&Gxqr9Sz+QFMC~T+cBePf)(u|Qg zQvwi_?n?XaKX|}utqjEb4`ymjfxI06n1J+@JaF>H$E8ri8IM)9K5aaG!Y<=~jCq*J z!IAMW6Ifa`JBLc`bHi(R^r`38*8ZlC^$-&Go!&hWpUD5O^Jt0K^^uF>g%JSp^cUAi ziq!S6?_UjHxVdXVt1csq7mLK)@m1Y{`UC2n&te;(TM;B+4d-ZPqnOs*WzHm`A(?T( zX6Kwa#L|9spaU&~1GP624`AsWL5D6ASsvC) z9_1?MGJ?pASk?Huq;NNkWgFFrn9`C;J2Jool!ZC4= zot+|cOw7Shzk*=oHYJ`Basdj#lOIK3}qUAqyVheR_R(%jJqRxtr)K zK4I_R;9xfk>)T-bn{|z%rTwRgx8afoNJleZiy*Cra4x;yE2oE9Ux;aA<+sa#vC;N_ z<=xx2HCH9ZC%eY3#x*E|H}2iPpDgLlgQOY#H+esL-)qV1$E^9Psh`8i*~%m^{{m3e zvQgs+oqTZWM5W@&lkOFpcx8r*#rU@>YGbO$rnSI+3jj zCx3eNTQ0~lBePOS7iqPJocS-ZVC^4hw9M4H&Qz7CPCnQFnRv^4=oBevYW|3Y_TQGL zg(Lu(we)>}{2N67MFH4_Vt?~f(irEfNipI|#mxXVQfjzprX_7`$h>G!F3mwLOy z4kSutmF0bESG8T^fc7T-oU=z?SZKd~PfUUOCL0^Rb}zAz9p^9>aQDoxfT?>W2LM1f zt`okmdx72E%PO~~n2S%gqula|!C(}@v^L`nV7^7yiridTT3R>mrFb_pAa@>T0G3iO zuPI2M7%iMWD0!d40^i#+;5VC?m~0S#ixNG?Y&Y-T1~ya>s1iVq=grM_-Ok}LpUyP1 z0<`S8C|BnkZJps^z_JHU6BX|5PC;YuL;7wTK>-n3jN9;NukfMoQ27=2@vAn=Hh_`{ zd&k<@_VD@h^<;?g!Imyl#WA#*ve^KA)WMnd2Vgz=49%Y$1=Bdn4@)P@=N7-E&I>Ur zG5jl%o`dbFvFkPIGAml+3(*U&KGO{~O!*^fA)+Eez8edg$3G;N2H0e~Cw zq_`-zt}-e6uf&@B%$a3VYmoDw)g|Ut4lr{pN@#5?jHz!)%EX!`lP}(Q`W4ygw zy|JVbQqM!WhmKgMQ%p=mLVff`PN(gj{A*UMb+P5#(F#bkzr`D|j_cP<{4=>$L7kB~ zj@wjj>*#D5$P(9Snpb#h>W?*GpsfTvU$t(_3-TvQ`hyWqH#WkJU4GU4FM z$KKw~dLD86W?23e2kV*5mtC~ger**{`fh(^CI8c6;}6Hq0=xE^>-j68=MpGSx30Z@ z(dpq^ZS9>LsnI9)eLsi529|I~4{zxtk1j#`Ju?oZyagnZEX^Kn~fc6FWU65ufnz-9*Ua0p;q zD{Ni#@4Kc;fRlvtzr6hWc>CkU^W5M4&3$}Sc=^8QT^f(YPm4-Tdi?ptVXJE=dK%BH-K}#Zmi(C~M8!xxlF8*7$ zzA1j$d7XqTqx{e7S$%x-;&xbMA9*qJby(K)zFF3rbHAJ4m$))zf8xteQ;pMrlT=u{ zlY6VaUCCruKc1iFU#=hOB`RC{@5aZ_&Gl!tocnh++AI9a&$^$Jnp(LeB_y`!#s_|` z4Sag*)s)zc3%@tBZU6hDJ5YJr`}@Fn6BClCNcen>`?%kW_)pcsg5NUk-Qb+h`+pVi zu!;QmeS7LIKHB*C`ICJ&%C9qTG+d>8yIk+qu9MgHE%<-+_2TMp*X~^jmWX}7;`;tNT_~ysvwo81MTh^WbUmKy}~=4Zu!` z_>oQ0vA-%l8I+t`u@N{WW>)ZLI~yC@+uBvf_Eas6y|p^=@}As|LpvoaK3}_Wul6^v z_x|?O&m%2I?EB|O7?rl23s_X$Z~1paSl#q%&w+V@*+O0NMCke<*(kA)(=gLIKzLrW-updh{X z5&{C!dr2rEWVY`+bLRZcIsfxvWKfhmFiV}f$$UR?aDwA(g{B`{n)fs}hg^WyZ^wo=h z^nGS`uy*EmRj+j&T#+lKxXSn7>zkr!as2%7H0o)({kBRT7qkCfxC*^E{%)SmcFP@q zVIJk`;TOtRuG30g30dn{X{1YPVR;_jvqw1Sz^2Tk&CIkYp!|z;MwTl7j9w(_sOiu1 z9>;^bz=*~EUmxpaV_u&blyby|UigURn@@Gu54?i54(3sb-1>(YFSuF171y;%G%!(D|H$=DV~O;49FoMt={Kr z9t_PGsKK8w54f*)4p~pxP>&D#iV?Whm%ePW3|G$Fp65hiPl9&N}SFa;t!i9h1)l zWFx;M_eZ@~t$ANE2)6y}6rJNcUOd_4&&2t2(n|qy5#$?`TxvU|#ev_{sWG2PV(JN6 z8-!=0o`{I)*DoD()9pl#g~e!W=A7LqhQ@+9V(-1%dwpy%JW#6|NDVJltZoUVzO*^+ zF1Z#)ohdaq(y?;c5*K3KMQ+*387vjBR~TO6PdP=hlvlXvnFCox{X?(f08&SN^f1;kYjqz&1(x!;6a}csWLLaDqVyX2vNTFH2_E2Lv?+ z1kHOJXun(p_SD!OgUbl}_q|NLM!;KRaHbhXxBiMr?~Q4sdJzqpTPrq0%4Gz)pkTzE z!V5B`xP6CtX>~Kf!AhN<-^?syxN84!r8jUCMR>YEZyBBAclsI9$2j0#CcnxEEOH#j z7;)Lci=~K*TX=e3FDRLBOr%Bi7QN!FW3a+7s>R^PM_{8U#cd`X9hyFQXKr@V`^hx0 zSVWxYeFVR6B z)|c(Y{OIwc0MtQhz}cb0n%_kEQ-_Uu6pUEmfiPu3TUPE+wh$i%zg~Z^a?T@J8&%Zx zk$MYaftoMys}OEF=GobJdNPHZeaCnFT_LV90`}*Gxt*!qkAA{m8c6q=3{SiPV-<8Hj)*gMfx~8Pm z-l>_1^w~IE($abuMstB#?@euWSUz76wv-F%Egw%x()L@My&l!VB3ZvXA~=?rDb!+ud$dooMH9G9Ah`e}<3jzdx<@r#(U={J}frOEDc(#$P}UKFW#tuhd>P2dr#nUsLt+z|=dW z9WHg8TS|0vbUZGdXa&!j4i^rSi#x4@bbKp6s!(q|e)QU;S3Vf(dcvL z4$ESVijJ1po6f5k8d)xiWE6C^woc>+xw%Hhf~*MX(abiP#ZLsT34w%+s^1nW^68ff z1XDD8<5rZu3*CP9l@wagM|0+^*VUma=hFZPD#&igX^k&dC>}>9gY<}1MexydkS_FU zbug2!!o$~MSSK(kQ5Fg@YK=xbud9B=v!TY_4z#Uv*fG_j%ZMh}4Cf7-*xcKjz2Pml zp}X=i0j_?eJw=Mt8W=%lyR*@{%X?eDtHUny9(zDRRbJoM4Fwo#`8;zV71Y~6#p%2q z!2>Dw=}DMXu*E}+0jt9?U&F^KFJzQ%iVl#rwtfgBeV?)m2@Peedcv<;_xXv4=@UDZ zsX?}2y2{7uvO%mBuSV}e1+ot+e_)6A&F8L2!p|KnWhrBQ7+K6Z+vUct7SXT|Hpj&4 ztX<{+8#V-1MLOt?TR|ZyIR&WhKC$sAnB)J82#ZwwOm^)OEb-dZ)YNC6ZD(t`D>j5~ z5|8(rDz#{x??^22loYu$u}RKpW!oZ)fb;zBU554R$VdCX6YJ599 z;RLs6Ij=ntvbdv|1UKj9mW$(l`}VD_uAU6kd23@4?D#Il0enWeTMu3s5f?q{h+OB6 z&5C3iLw!3mzg}Rj9vU1PK_XFmNIrT&p(E1Hq&zk{n#V3bijIl&=L+Vrw@u~;<;FxR z`0l%6VlTZ-)UAwfn4=wSLBl;06W!B3sicX;F)k|3UV}rxH|p`c8UQMIhKP=i*5(NE zDA#8PJ=nV9jUVQWR?%zKblNHBa=~QEZDw37h0G&uj?dlh-oKNQkRakSdYT^1Vv3%} zu)cl2IHV{S>02Hce~*JN_=#wJztoAtah^sW%Y7EwG@QplLK*~9qJhgajy8~(Wpb;o zR-}l}W+w?eGZK1XVropmxs=_yD8_XAep*7hY1>TW`+}9oBz^4@N_mWTQ-jn(qrWzV z&e!)VkxV^JA(r-H((oulHMNLc4esFAU0s;Fg8?d^!~HTHdb=KmoiI!IT&}yN#^k)z zFYEL1E0=Fj_0LhJefW^Y%*^Y;QZ9~;R6k$*8gUqb#US7u!67hxc=e)-Z(4e(%z*=Z z?f%-B-eX{E=imD1(gbxGYjNt_plxeMAOvk7@Yk5~b8b~oe)0R{iKAOyz?P<~t6N@9 z^$taT%kyLxM5_(YOwFTbVybr>7^-M7yTvwcNpYK*8NZd{qC?A0$r&84bA_t*8NzYA z#9$dhWTmgLQx5cC_>z&q{piu=JX%9j^OdIH8>{oFBqQOD1>rkueL32Y>Kn5JOd&+# zQn%ry)sESUm9OOAuk%Kd%FIi?y5#2n{F$!F9N{3!uV48a^4;>`bt;BCin5V0LCfZU zj0F3e^hX0{)-W-LsZP5I<~3a~n4uS*O{(B@Im28A?np(1N2EV{W50L1eU;ce+1<^v zUb+CKFPgh~dZa!4f34xtVN#AqO`SJ`;!lN0LyP$zjY$#;JLV@lC33BgWMT65n-Qhd zrW@EeuEn8;Go6D%TzqQx?wc_2W%6rkYN*{&oW5H(`KTr3!{``b96{<$cRhIg&iiMY z5RQyOx(&g#V|b^j5W1T;!{5DwWep7U4|o*FD^k$Y)9ZNJ zVq3$z-IHuxCl|g(ee*wl4DS>aDMW@>*B~b~dEyokbW5uSttwi3yHp7q4h}bma`tAc z9B`RuGsP~3#Kb%y-K4LEO#QFPJ*Yf@3wT`|K_{&ndH#O#=h3RRvSE>Hwnn3Lj>~%H zQAwwmU&Q^WMat`Zt`h72+feKc*iejF^TSmb(ICaLKt!kHPC^2%@(cgPU^eXD?U4%- zr;@KR+7GvxvJg#~+^m-3hB?}TR5U@|+`Ruqx0}I;t`ATUvVT2_%&!a0~i0w$c{t0}(>c{>@ViEE5)9w%-&C}=i zf*!(6+iA1Z8)sys#+|TA6bDbljD628&PW$#oCZg~Y#klXN^yLXV8`yzZyd72@`IyI zC-#w7Qv{o+`z!2n!B|Qyb#=IY$r8)jd<&E5Xva+77u<<9>GyRYd4?Zxr8RF14knM;l}(l;Pngz&|MD?}m-=t%R78Bp7{e%=OsJYvEl{77 z8hFMo`AwE{1~;rgiP+;s%)W^D#NK(db(c0n4Przt&0pr!u(75t%3~aP+}%TX~fF|8504kjzo zCrfc38bcbRqcw?sUS6@~p`jr%D56u(FtwO$=^ zb9Ixbe#6PKY|_|dt_){ax?yT+G69~=w{n79lPs!_tSODH0bHsYq@bAR z<|BR~BpM`ijQtgQ0II^w5zIc!#q9D#<3 zfA!Q2zKirmo*D2l*{xY%Weh6yb@n29Js4Pt%bp*urhsG+lE6?_e+?r%P@f63U@W=< z+A-(;$Ki=@>;>>x0D0S{|#6|7r zwRe{($91Evv3BiJ_5e#oJ&+^Z3jEML5EHlv;gY@x%8!qar&}^~-PdN3s0UAc^XQU@ zr)6h*7Eq^TMua}1j!*j109jt`Fbk?h3f=|M3Fdp+qL*i1k{Lwp)+BWTYd4IN!+ZBG zI(+lc>4Y&y?3^&uAhF8sq}AKp<}=+={R%(A=#V&=og=eZhR##Dm41qA`I^C!N}MM3 z>4fSVG#ak0X+_Tc$gm0#t+{7`Wo3NW4}vn|yTc4cKj3PZ)$O1)1%g)fN@xqbVu(Nr42g zQjHNi4J*0tD#fc%#h{pw{%4)1BqLo0rM-)EF?haP->|9tBO|GUA!dqJ&EByE*} zY<3nlk8V56C+SqJ4gU->94WUB^Pf$g^H z<+3%BS8amQEjq<}41&9SC@nv~rrWKZAE1?iIrHs>TFxe&pY-_1{vNVj+q(6eyO|}$ z&`=L{k*he-3uLBN_zbq?eZKz?#B^(uy@!32y;L|PyB_X{*sGMYULEx2-C-p1Y)%h? zST3KETA0axDG13(9s(J?%jHv$2=MIig6GfCFFV9>qWpu^3h?5hj8)c8*GkAJ7kH6> z`@cKdlAyWo22EUd(igmQqSFe8(puM-8WUxpyLT0f^|=Pj{ESLW2YO52ym;|*&?SRI zJ|ND_fJ4MNf3+hrXRj=!y+iA?aIr!kP^f$IJt0F5)t>D~YhYtq3 zEYl(*bJxT_)c5qPtEd1KhOe$N78Vw2zs+`7z!RK-SbgPOoImdyyEG%T6!55bv5kzm z^JxryDHNQ{V)o-`y{YEP$Y6F3T#GCI=X{I*=&3Z2<8?Qqc`%;X71NO=swtpf97nfw zu-v6cAq-c0tkyJ`CNB36n{e>wW{%sw3J;jdDm5j19-7Jci;9lPK=-r2Ig z=8^}IPc0P!&yQ1aWp$8NA~+2n(n^ZfkUUgtN;w1*bFB}SN=iz8{D9(jW^&7(Cvpv# z`oK2eVNAPza2fC9(a|WrJwd za!wt?I=4{l;2|n{M7X^uZzj?8Le*i7(Tzubck8B;VTG#~zd?(NCMOVR7sYVM6la;0 zYMe=MASWidd7ivk!&fkCCd`{?ri(d^Rp#MwcGtq}EnOedlD(fxCQ{&j_t1O%`H4&r z8UzNcH8`k9!S9qYKCbU_$g^uKD<##S6h<@QIN6gb4D&Hwk72{)yx3@rFB*AteE|(> zG^GXw*C1-4-rjgQZ{zwX{D*wbc9nOgQ*(3QNbmnK63QkzU>!MMT!+S*BAhtn0*2#O zlX%T4zvzW8NV-J%9(IrR7OK~?D}_dXk#RB|gfD%`Fw?Kl*$OL&)YH{f*u>g>|N6tj zPP4D!`BBO|bZ^e0dEJcj1@RmQggHRCl$i;-LCx;Bdb}~AAXHl;bnKR+eS@0q09$_) zOh;K8Oov9R1_s8?&dz9O$_5UUGDv>9>TV1pBinD1Wae!ouqk4dU1b;^JWMVFM6QzPiR*oQ?I@A%!Rh3{jyy}w3%Qr->Uv!Ya^60z6%|>D zwd#YY>F#d(HSe6F>Zdn1PhHFcw|A(F%FV@>ufBREjNii+#K1~pU`?<+As73yg|9k_ zAN{6c1erBQ6N||KH36h9WKh_AC;;3I*oMS6zRV3I;1ngUXMxPGLCg`gwI1%?yf#iJ zkIuuvX+TnqaDo2EKh|L?roy^YYroWZV>4O3_c39iAv-hEyhIH#))h;^$!Ac>c<)|Z zdiu9|HSKKKSDKnGn997p>h{}HrGs;~cb4ht zh|a55;aXaQk{6Ymt2gEw&d>P<2Oqb!wRu65cCqw|4NWP{(6yAda`O^FGO}TQ!fifr z>8JAYgUGmw#Kh%GISi2HFb5ph*;y`;i)`-?Ou`n;KJn#!PiZ;i0qJ@0tQNQa9#8uI z{e7tcUygjxPjuC%yu9Vh7SyehglCr<{6YSSWw!1?19{NyKq2fM0z1e6*Qh4}Adl>D5T?L+9^P1P38SCo`96w^hV}jbB zZ3t8Io^>Ml|0oEe7H(ePuB-cYzvAy}WuBruZ#I@f=sTm&e6f!F{AFn34I6|F`l2)n zQf64;c6PYJ5@dwf_&Hnn8thdKEXV2tOQN%tRs5H)gALD3XUnFh{BnJ*z~Ef}GTpRd z{nC7AZ1_yQ0FylYZi+W3pO7nf6V) zqNuWB4SnH6dM}6^zu};M;cAEqMXm^J0qsy3zX)3B;D`kzRh`IVr=ZVHzyYz?RzF7Zix~YU<%!cL0PxTg0 zcCr4rO#@;R!q%TxPOe3%s1oUozg}K0<;_MT~x7JGdS-!$4<5;*k%O^1T> z)W#)|Fk6&$_uaqLfiR?alk;0%UI?AlZJ34L&DaLZLQeG@@;gR~q^d)wMXWCef?ii3J1 zxSMd>$xskjHH+i-Jpg_Gc=Z~7d%ghruX&4#V1ycc!IqkQYOY))XcETK6S<=XwjuyW zLYh1_TmMT&9*ZyVf~5Ixw?o>GkNEHTUJ^@G3wT<^X|7Nmel@3_nOzvyFJk&dLU%5=9erjXZRQnS( z^L9|&Wz^@SGAn4R^2Uu*NtaP6c-ggIIgeq3kzeEkI~h50%;7W5jUMLMxhB9%A#eF8 zP^IS%GB$2ds$NRt0&?&`c1iWcoVK<$DX0fJDIt*qlV1YAe0f%5JBz@f8CWx?5Q8z= z1RTBM@dnmoZi@b}N$AE?5s7R`Naaw<0+XN#UPN5{&s90NnI0rr%<6IP!{Z~Bo;`Tb zhQ-Q_y$JiJ96|?GYBxjL8&;U&3@cjla$ZnzUcLGWcmVfucH+jP`x7v9Z5?I5xyc~M z-Z>$?o4Xc%e&?mh3cJ&*c_QK?S|t`0rKISs9Snj&(Y(c7`pNfJ7Ys6+oxSOf0Hv{G zYQJY}p;|KEhbxyB3@vpK#0evdg#DAlNMSA36FIjz==bkennFMzbs$U=jIq2sC_=Ci)JwmHop`$Nkt_-A-%&jVn1>a7HcG4>wz78zx$_qD0)v!|G{)E+h&Z}3Q| z!P}*yoXK*)|64E^S5AFQBn?%4K-AXCNsk(rhiJ=_=LTD#tM-kvj+PG%-t< zD63=WOJ%uFYE+e#n}eBGFP(dl3J2108-x-RiqM;^uxY4Yq7OLG?6Nk-M=640=|8Vi zt!};Jg*?S%eT5eB=)cN6Ny;es>W3(;wHunRu~jFkL>pC_7YNEeY{B(Jv}xTop>+`4 zu{&EGXl0Qv-VF&hF=40!EbOiYr?m7;d;3FRV>cdY1I&Buw{N}4a5^^y_qK+oc0-v> z0UJwzs=iaBHF>39Hk2iYsj-cWLZKBy4hbiFV;H&>&it=Ixur`zn!LWc*ePPJia5 z*4foVec?fJ*dBY8$aS(gQ47XC6D`yT0009~GqoON1~+fsR7Vqp&R=;RC$1|;GCIK9 zW6zh4@_*)6({ss1MEEfiJbR;N@OV52(t&6gS@9Yfm2B`s&flB6bi&Qe%^%NZjC`A1 z`5e%I5GuwObFCc2M}E7_zJr5~`l{WMDt8YSRMlxcsR{ z{e2c;^V16N$W|h<`bBsGkhHl4Y*I3u{FDZ|>vAja_|_NTmTz7UH{le3(iOK>w(LI1 z=8aEa&9^GO`Oxm}mz=?QN>6ENX&0=+G&MBT8yfENuI6Wft*ytJxyh};O-#ZG3w0?1ZAFm$&jIIJjjamew{sbi4VfO;RD+SLj) zv!vGMv&5P_pp++H?VZ(BG?cBWsWpPuRC{@giV6xn@hR&FU1-foPk;8VKNYt%G2n?& z@7wFlS8pD`f6mkGShVi-uWEvDN!N~2TYCsy z2Wx~OkLBf3ffImdaEHQwt-s-5WE2X1A|WxDV=3-1+h0_z&7rJ3kfX3LGE(i;mlB#3 za2&hL99^g!sUZ9U0iId<>9+8jN%*+37&^?iK89M0agk3I%BH=wPui(p2&PM6WYm3Z zqr9i(8~C0#w$eFPj%>Jhc_CyL?Ew!7S;3-CE+D^GPck_af--#*;^P608q9iT+x*5b zSMlsEO~`^{^@#R2)tHO6@o^i#F=ZOc!kl74qb${??8WZq=toCJ3OhrJ0$m&&?lW-| zWh7rLDLXnk=6-|)Wfv3{ZfX%>hQCRY%Sp&DELm|3A$+zo{^#i>-v+qaRj~z6_?oaj=CxnhqT|pX@ngHea)}R*WnVM@iXpCDN5u8p1 zM54#Yv8JTB1==soSJ$t}lo${>ganqSEvjvX9}fiEz*-O!7cDa?!cSSkh*)T(nJ>wA zxqog>wpFcw;9ON*9dv%2hWC2=_FFV!z;ZxLRIfyTD4WGISEx^LRfYTayS)vk!ZvhF zbTqF$amt`Taq_x*SvIB2ieUKX&&9?i-wJF${E~rQy?{g4r$>I|8viB+Dt`X_*%hN0 zQUf}i4__G{A1HbE zZe7#x^;`h}DrE8XM!9Ha(aw<(tKY>sREz?w5;iYa)L^LK?4d_e&2><%IZYO)WM^liDxOqL1G*P;c3JH(#zjs}Ze^37VR1%Uc<1isjjZ9j zclX}cK>y`XSb>^Flij;AqcXnW{Z_a3{fHK~Na^k1a&I*xucV%pCHeIm29>%dC&2c# z*=Sr=?%iv4hnaCc-z_gg^3&+psL&bcWPhK>yutNlw;bosv&hs;klMQ6SMxNH&d$zU z&XZdLXTQ@#9dRCzJwrh=gnYp7;_cKUOAf_e)ttu0GpcCQ6Qo0JAw2ayN%kKKE3lOi zMtx?Qw>OV}E$rTVQiE*dYt0O?T`VEbVXRPDA$)#m+2incNeC6wrq+PsXR~^6V?%>J z+)&*57oDPcgZs)vnbD^kx1Ipy8}pL4z~a3%x3u(lA(hX-arKy&rh`p$pdYYzMH<<2 z-+cA184X!f11`oF5#O4kFjPreTA5SNWCbQu#^3Nl;YWT^5lq~>JhIkf@j(c;X8P&= zV*5i9EZe$0lsbfRCP>b^94HxVj23i7v-ty&z!8(sW<~5FqsR13lbs}IJusj=7ASC5 zQJ8A0wn8<}o0+PAJ~1|){H$-&8sTSI=%!bE5gcY_hB;l>2mX}v`I4Sqs$0^wx4@)$ z>U{9()vIiAmzs(!m#5@ma5S?A>5_OYESA~W-O;^w#2Ot)!1EfEjsp=7wYj<#1usWn zkyaM?$O;8uj7;uUZDVWAY6~?jgfC{rTgS)8v$3ew%j72Wg+J28gxw>vZ|R^Ax9;@Z z8IurqnzWy-3&-QSh0%11cZGC)R{QT`kPwA{-$O^gl1lV6HS|5~w8kpXfop?Ue|!L8 zNAikfrQOBNZ^h)ac7tD5v85|OtxTR2a2mnDf2&*@fO!pBv;espBxEK4*a`sKI^X2g zchYbv%(Id|4XgzKq~~;)#A~OOWsdU0MYBkDYG>w~Npk+v0vW2znS{%D{(7%wE8;r* z)d+6kV0Znm8`Sro)#*MKUa(e`yg2uG{d)NOk59o@jAE1OzqD%sySPn;yC?%5x5974 zt?^|Ta7xCwwF2-atqlBzl$^RA%iMGf-VtqagTU%_<@`xaQfLjk{fp!4h<&zf(1YO5 zk&#H2k!E`&2b;5e|>XQX7z zZnDD6qwT~P>qp%gn{yi4Ip3<_wK)#PHl10XP~F-eI#$vI)S2>+S)_eRHY~ztIwua_ z+UF2oI$8v>iRA_;u!Yg@g|E;da9%5`8Nx4`HR!Jzy~_{NykwVYho>yQ&+*20tIRKU zuEHZMW-;+Cgn$}gzZBvmBb!fazEEM0b9r?$-2a5_%QN4IjEt>BZf*SDLhIY)e-ua* zWX~&qsj&Y+se*rBAtQVFQ1L&<|486J68Mh<{v(0^NZ>yb_>Tns*AnQ1%Mv0v6c3w1 zfh4f+FVrHK(jywkGY@Pr1Bu{fyM}o3{~=-g-zSg%_kRn*kW;sA?HhgBTHHvU(!2)L z9?bsd1oZ#@JO01@bfDPY4Jcm&Bl+$hTJ{>a<DsKfsHqT1J9%I>Ou5!sxZuKqqyqYG~5IvCs0{tY!vl>@7C|lLzp!5>=%C zrQlA@$=gTA%o3;jEg6mTbO462>!H$Sy#o<_GKAR&t}D}7aZx$5zTAhjgWyt@0Road zADrdNFS$5jOEgehL7xJ^s+DGD>BcO%05PM8tpAf(l&>roMuQI6JkEOm{iKC+ZEy9Rz;>~LrOahItWqv4# z@)@x``vt<3!fMCq;NZ(*QD3e+ev6r{5LTO z0N|b`jyyC=D*c+ZZjyISD}%h{FtxF2ps;fnfPYPK$Vj30?AXOi5xeus6xCkXA(mFl z%B(Hj!sD+8*8EeK-=$!e09r;kOIDC?DX35d$Np4czgN7qc1=|4cz0xH`F8s@Tf{t7 zlP|s)QfN@aXPC?Bx0Pt1#HyY$?QT@4sHydie^ByD9jMv# zb=lKolXCv`(=A3w=wvTMfK^8R^qd-dl@g^vv1n1T`5xZNx2(f?rhM2z&pkCrlHlU& zjeEz(#2;+`zo7qoW4j0WG(Stp=_u)P*eRrI|9KugFg_n&TR6Zbj-QUnlIBG^bkPu7 znQblWy0hMY6%C8M#j1cbYU!kHZ^LBPUeMc$cX^k`t#-eYU&-A<4eX!2b%-C; z+F6CAxOEgQY6o$kr;+(e1>Rngc+zSQmR#JAh%5e3Qs5i1DjuLh+*gN7D}L5GB4@e3 z1&>AKfll^RDLEwqk?W}z9tlh1O$@0B_7hO;h-54%D1dMY9Z+#1Z5$@HMF540WHrQT zO&4N^d(tb-+iiIyy(NQXRFrI9?++HNyQc!UJ;l!lfeKE)f2SO%WaD=b9!wUNZj+yN zB=Fv!I%DY-fy#!aant(rAa?fRQLw#Esp3QK`iB_kjfMRw#?kH`!Da4Di~osf#K#$& zShjTtG6P^9=MvzVTCGp{!NCy8%LHJJNAWi`rozYS4j_GoHkrl<3#{8hKL+U?*rg^e zAw;O2s`_z%jqh&R9G%kUQD-Ox=kATaX!gNo$Ztj&*;sj(Nxox4SNNimrxWjS0KlU= zvdam;7FnLm;wm5qYxC^VxTtMM}pzYL65eTs?d9O6mMcq9CGLO0E z_7a?XhEZ^|TrVt&AI~iD=op`+5$l0tmRK)bz_~cV)bz`=*`$7>{2`r|C3(O12S_cn)OzzjT$9+3_xlD(EDIBf?lEedrGxb zA+~$JT78f9q~WgyQQdJ2n3D}TfS{L_6DyClgLB&Q-NkrJ|H52eX@wgX00M&^8y8xo z?Yu=%Z*!22XAU=Nfo}w;oT)=!K$YK&aOYms4)5?W*NL}#t4(=1f3}Q48VZf_>&EiF z?*FTp5O&F_`1yftmM2xShSqIG;GjA2h>FvqFhAYI#72OV?{5vlgG;OORo{fHvdgR; z{Swdhkc_50oy2@!W=7k+)i3P~kx6brbabU=r#i2{jI&?gUvL8oDVQmuY~V_bic2#< z)l5r@`w|(F^UUWI7e28yh4MK}+NII?W=j0~QF(c45%|N~$L7q4gZX`8ga&_#JDl#A z#1aC4A=QoYBL*Tk`O($DDD#8=;tb~E)JaU@34W=mRb9pR=H*a+K|u)l!U#Gh@WPnF z$^`=Hj)>>9;^pQ2#2pQvX~-=|Hb6o(xso@>B_t%|v(zpB>1LSOebB;>+{0RrXWm!! z-hPIFzy`PU(BJI7uaq@)T!jNH*mo&q=10$XNu~tT?IIV*{H`|FXRTrI=ZK) zdz;>5?1C_!d+BcHpY~hXSvSNzT|LK_mw}>5pyt1*Cjrf1Ma9smKk9m;^3+dho@Zx5 zU5R~>4wy~i)`z{4(&*bCiXp*t zL*Mdh>(7A-ot$ioyKxwhsc+%;n<#dBOZt(LC9`|EQeV!VC{kzqFi#PWH=*v!97q0( zt)lFZAO=X>aPm4pR@kxr2NpaUdQ;S_Z;CXkrZsHST$(Q;u9H?Q=63T2{$uZpWMNBr zkiUk&Kw|cW|J%RiyM5bapd`a=6ex0s}J}*^sr_wu7ACsS}`i&61bEAthD6G zVM{3Oh}=PifS{n@vi1FUtM4G@+bezj3)C9g>gqn10aKxxWo!MgWj>$)51uJluGy%y zBoc@8A7$7^F@M!PpT)h7Jg_0+U?RTw>la|zelM*1`<4t{&*FJ?gZ1xf+| z&&FsX%l0Di0>rSa@t zqzA;H)~7&M@w1becDkv_ILF-JxAz4V>hU*}W&snRaG_SUm;rfGoQ25CN{;qQj0Y`o zbj(YiO|ASOm)tEV%=bf9WNk3-HR1>^5g199G3@M!maU_1ew5zW2v%)^u;p&=yx#t? zV;gXilQ*cY_wrlJ`PA8)to5>OrI~$6EnPD}n}}Z_gO`kbGg%7?Y`rfd&&B6B6dJu2 zOML1(a|iTfwCTSw7Vc|XP6?X>2u9nzp)k|CRi1#$j_u;Oj~+o#F(vcp`So8&(+CX$ zY6ak@S>>Q9c=Ego=W+~w7vVK<4iu{Zk7X<(dDnmsx=RWNHmJb86wTQ{`#05;|C)lN z*@5(!Y?-pCqA;LLqnH*CON{pFiu_cRSo{tUSjiHZLPN8qxAe2^FhQ zdVx>0#XDy3KTuLK>d(X!`cIwD3wr1drX-j3860muwfPEMX{S(=8HpcE-gE&G5)m%B zxq93HF#(mrFXP$!6LaN*kyehl4R)K_fhc)6IyR;)fH}C((Lhv2GsmuH-X+NX`X2J@ zp;0iM_U?<_tQxeI0t7CoSJz7A<=r74kHh6^B+!dX#+q+7`8436x!#)QS#BDD*(X|W zAZg_t=UrU^EORMMA_96RM@4q#;w)h{2f2IDUVQPcw8%B9|JQ2wb-Qcvt(fX(>PVypvE`5e?0cGZA@LG^JDl1Ka9r)0HEeGf(tBAJ+t3qH$0!;m}28>xAQyBOQI@0b|_2;WyL z)@V3xD|(*hYx|kbR$2!6FGm2Z)*&{W!Gg}i8=4pAK>vg2zMyT=3Xf8Rf&FJr{#C!B zHJc7~ZY_Y>Lv;VankrI|qu}qoIDIg2M4CJTmKr3`us@UVf;$BZSa9L34LW2RlL}W+;Uoe+3RS6S$XM>NJpy|`@3>ew#)2Sz33oK|QH_fK;6v+xRv$M4GsNWNurNFUvC#QH$%FTUsGw{P0F!6={=Wn2{m+>YO;~XH(b4R3 zJSPycUpBxX1O$~wkcC<2*h40#s%g8ias)pEV==a_2k21ImGJnm3BG?hBGWXVaAG_B z4h5Un?Lrs4-Kw)k&Yuh~#+m&I{Oj!TUq`UhTCjSiJ%!nGn z?Pm}k7SK`u=etuqfW?;(ILifiCgRNEQgP~}w58%oxuYifeR#*ZZpzN3uR0q+A_5`2 z`o_v(1G>A^CvZuxcjM-KwMaI5OXw~0{fX0Me>^3p;zw}w#t154Ma>r^%n1|_V?ZJf zj@Xm|nd6&DiB8POg zki2G8GJCv&{WYj*{MKQsxS&;VHn z;^((Hn*N_Ikf4?);Dfd(qduAil&`EvzHbo?z=7Y8aB;A;ed)rGUSeKO*H)mhF|%d3 zgYqSQ&-yN{0QUlDW%<+f+-1>#&;KXU)6@Yl2o-=_QLdxyj@iziv{3npoag%PfOr@_ zxlCWDzUt6CguUCF`^33ivt7+Km2O2{?`g;Qp>!DfXR}N zO?SSnI{vS7V%1m?U<_PZ0>H~N^i8OJN@8BKWU`C|hWzV1aRLd216N>c^7jBf%?TtX z_m02VW8V;W-Ury>18dqhIae?1OwEEiS$s`gjx#h{%VlaT>ujfj=L* z<;%_{pNLE3v)R{NEK7U3M(Wo%v<7#yj~ z0A$eEX3(Oip{?Fy(V24q>zxGA*LJCOEOf8ds;umTx@f+7wSj4{9ePF$;B?T~Q+0H} z*)bylX=|MabPE~G2|V=53Bo!}`RBe$uW=6V-p|pt6Q7D>@3sg8NQtJNyHYhyL$RCU zK{yew9}opw0g18^TnZ1Z>5A6tdy3NAEvPFWt~Fy)D%K|o?dr8N5R@$pb55spO6RIp zmi*;k60f~(lvm=4ijp$n4v&qJ*Lt41SDqTy{buY4;H!w_wa>SqEK0q4CD z|J*KheM;`acu8#AsA1ntJf{Wt*Z`~Gn`z6t`8J^U1n@|%U2_orV)TnFelt=Ul-0Q?s%H?F;Vm81Hw-fPg*~075KwXZmaly1>#<$%zQG7ES)I zdt68h7*go`8`3;`>V&;AyA=DjtW6PYTo_{&&h&s@pxZ-3lV z5fQ)zFAuX?e5#|fp!sEUeaXBP0mb`0Tnj9dwWr<=o?58_fXx4kApO&&w#G_eR&plm zdrH;_8E>{>7|^*REC5-uyp+c-JBP$`1NlD>3wJ}nFM)6KDrXUZBEAYoKRDP`jgwN)`cDvpS3>{*sr`YK=w|O`ApjEUp_79|-ayj|3h+w%yM^5NDC>_gNYH`^GqaU_f8L zC8#BmTf2a($bNyVbb?l~t=G?YwQ4%EuTupeDYbptv$yP$4-yEjLe@{o{8=8}+;h#V zMS-{PGXo4C@XO~g^(69&4_W(7tauAu#gBA~iE+iR)b1+ga}^mIZzzz3paF@pvb{Gc z(=S3+S08Mc``wy*&P0-`4#WQNDp0>1FO zFb47?W1&&Q@7!WReKi$Hg&Yz4o$f@N-C(*P`28SU;P=mx%+| z}%DKg?i;}&ubIcQfdJN5m~?gFs(J6l`E4-Swa zI9NZ6ViSacykapcu5o;Bx24WV7{~Emks^@rj9*%sjNz_;{!5AjfoIRcR=Q_fJBs@3 za^`6$ntgZtcK?r7&Oe^%JdWdZce%%1^62zKSi0+hjx0*VE*@v+XX4?Z(M2tcHMUkP z#6~F9g``B8*3r%aT^`~B~XAL1OS>Wn- z%^_|_=#NGG`lk{{yB*2{;h~mx)=LwxB4Lr_N=;bt81o=7i9rRSZW!zN?e;xX-v?RU z$2mkE;%O9Q5Hi4m7ohaDkPGkLq3!H~M5+3WHKMQJ@*z7ZKmQ z05(69cG3$c?e_)3iweXfCx%zn8~4h3mqiGco=7SMN^vU%w+Dol7K7HkO?!Jboyi3F zFrR`F8m)@r(b^!>B^Iz%w@_-G!!sZBHd&(1%|lY}R4<4hTZ7K@#VpJGa@hXLiE9AaiH~Gu-HC8A;pmtW?htF98=$ zDCY89&F%LzhSL`I4YrPUb^)!f!VwSY@!B^2gf)!ocl>^1M7l2fL{u1^{b@M@AO_OL zq^qKsk&q;+un2+wLk*XmsPy*TW0%bHwh`AMpvqNMomZ85H*vFJ41lf7uW++{k{e(9 z)Zpv%tPVe*kw!pJKJwU1PO9JTL{!n-y?yr$hxcC?E}d00)VGalLL%0zKd9pleH_vT{FJf|l(9uB561JU8pgB3Uv$wqLIf@5lY zI7(*Q<()|1>;|4r5%u%~TpTjAry7H+O4L3V{*vBK>@szI>BZ)WH)mrvw2M052csJs znexR$D>v%FrX8R~TmqX0Tuq?Cr}vdG(?SMn!>P&n?cf{=$3!Bek0Nixn;AET%cBl? z3X2i^ZeEW-0GceYc#yC-=mFrz>`|Q>j*X84mh+ClKGdW7_7?hor~1KY%?7dX=!~|x zrC>KwxlF8Z*FC6;MuDzuPV)hy8G&kpQN;AO21nt} zBiZor1IDi3p!M#he=K2--;3+PbkX9t9BCHB%y3g3n#WhPE*Caouk=c%n^3DI?c;d( z4K+k$9KYsHHaP1gmmm*#^hk7!s4U~DUq^&z;hyVMHYLxUwDG+*^iE9F=W6f_J#eb>#PPc%OUX~%9MXcd z{FI*HgfBzG2yE(b;I`UKLbJ)G7a@~utm@j_PXjJG^CEPMfN8*+PLQuWs~G5xD2)K{ zCox4vQ9h3@g^9G^O^H8KJE+Odr@de?%Y`*>u5Hr*Is4#~l_lM~H1IE2@mXQpzNU<{S4S1C#DeGKD#x2f)h*bRBi0nEz7vVv31;FMoY8ff zQqb$OWR@fs0)yPQNmobX8TtB0kY7K)vVWC)x%#qDX7&HTF#nFztXG-Y))!V`h}IviJVZ`Of+FK7X9@-^Oo^S!>NRYu(TN+~vBjdqq9f)nK^HahZyW zis8v)RRbz2YE~+$-|o@=2JA_)a`6OS{;+*)phHC!BtS*=I-H7%4D5QnNk!#%kBVyh zITe+B8Wk1W%giQyMc}|C8!Zi0s`FpJpIeI)fju<7Pju91wrPLAbm_W%z-lEGl~%zM z)kj9JW_ITTf^~>1-3NMvKi-Ca{EL>CuZmbQ(xpR#;Vaj*jMi;e4aWuIxYl9&w4qUnHE#2C;$n;r$B_lQqaa&WpDCQp@FnKL0ur_R(XhQb|nO zPqptbVxI2!hL~j={7c3@qWIAxym_#Pvpowtx7Cz8Y4t+@)^bXRYF~f!0eEY3lfX_i zoM*MXzHD>V)5?EsN*8s6*f?LZDBdasajw`}Nqb=|TkM>9!-ZMcZ*oXu4(($F+_k2Y z*}&l-2E|67-3pwB5xp^d#OY*N8HJa~8b@OX?NP4OD*4&gLzUA7?qG}lY-!wKz*}Y4 z(GvadLB__$l9H0Lo(qa6#L}aqqd?`;#ib>$#r*?Osj}ipgh_XWrK^t*u2@ecxTW>5 zOp=g%OEz0c#&7ZIhJ4)&(VGptLkbz2ur`5f_8FJobK4F;agL9TpqhQwwyW8d-+=;! z!*3qWhl^9wxSiNLV-vpX#IWGn0)4i|dBAVpjH4!;#$Rr(dWsl%X=-Y>gc9a@r*V0F z{5T{hE}=DKTAXWsXUA=HbTlM5n82mP&CF6LURPVaJyP15Z+nr8Yd-Rf5s6)3V<#CT zUS!cUK|IR~S_+(W#W%&8&KUlzve}5+b$2T_G#CpGX+dH^Nla|T!Y*U*KpLdK>x9SV zOyS$YLi<;*j_b3aZ{EDYEzl7aJ?sh`YcXqQ8`UhP(bb%n6cE-*ZH{Oxec0grF#j?1 zM6M@RoSHl>mlQ@?{;@?JDI;c?o$@J9xYvUg&kkDdqg!#f$_a8K+hOk8XDeMk2|TQ& z$qtBMDq3HM?!79{Zhk&Q4r}&3J>bvP%RbphJzjIaCdWk&aY3E9^cGx(WEy-*MC7#Z zQ{LpDObt!+5W8%kxuCEY)};pC2A?)iu9$TRW3H=$+@x*JnPC|S3`?K<2G70(q_F6BxNcK<(GmdcXZhP0I7M&^zTAaP8TL-ffuRvG3ZaiW( zB0Z@vu8FgZ)D<@Q`D3`@eXdu6cd|tr?4V31u=v&_3Wu3ynDsTO!1Vo8&R~&9w>F=x zs4+)YW({UGKU|}j#+-ZF8~IvXj64Lt|N8Z-_gb%gdgJ@jCkHm`@xSF5d6ysCpDz^a z=1}~583pxn+~w^@8R7=qVELvdP#edC!t>AB_>9{E0@~+(QSiG0!rSW+ zS_2Ax{d32c2X!5<-s<%-A72O*O@n1f%(d_i6Ca_~2QEo_`mCI&A(cR=m~pqc;VL*T zFDg;Ns8puhohF3(?Hz^s5>~g}#qs|7lb@}W)4f3H8ZiX9cu)^ai%L>30ag3z#?k2e zXHhLC<~fIt%HAV8r_J{N6?-0dU_m=lGS8N-#qi-ny;u&(G9JcnmSTcV)6)H*+~kMq z7;*3Rdzn32wr8s>!K+#^G-p&88Xz9;WE!g7tZ8a&XHn~vHKahxTX_pDl^cUu8t99G zrEl~V!C)Ckw~>=gbT=Y!?y>gc_fgUEVF%SY=PJCN-(P>Ow(n~RJ(b6u5bEo}?=|Az z&enTy-@Flgw!F4l(5aA3^jK&~Zm0YqE+2II=Et?6?|j*j73s}?`rUcnvl{h;j?SlB z_x&qwRu=X~yF*`kk+PKerV`eX(NVL5mCWJpZwX6FeQoC>O&=^1{XO4gQHCE$BJzv} zV9d-cEUO#sE`&MFO7Xgk2ZO0a#wCl3E4y{B-Gq}Fe@@J3m%6$M$U{Kj_U+CembeD5 zW31u!s5sZ`ojZ3t=UT)BgoTv^2t)4B{(%7usx*1!O>g04v(Saxl9FEC*{4x-bimaT zc9ku<2J1iBs^PE9%$Us*xeV$y`Ws`6?`Q!tyP;*0IoCRr7j8fAkYk@+a_>q@CHkeW z*H42eLunJ<@S%!Zd`Zc%brh4gNKtdI;K}T?l9GMLT&S#DlMg8xZWKrhxRi1jNaDoQ z$;fUs8_&7Bvs+PKfp+U?qaKtlGoNk=q?kq$=;-K7^s)y2u2NG9ko%HSpf;$dwvmyx z(RlhPjjWhurPV5V8mCnh%CowWlLF&76FnMQ6VWnDt$bFeX+=@CI7oc9NFs?lCMS#)@kQEfdi*Rbn% z%*CIsCn{lvt?>0~f=H2WJpsWx>kVQqSVdMa4^44VQCy~Eg%!@BOAWp4E;i!6{}taI ze+y4kV!Ph3{ndk4H6br>Mo5lZ^)j&T$8xu7o=SX2XUgPRw=sb?IZq~?g|^pXL~*Xp zuJM^QKf4wr`+P&FRn}OxI8ED1aXDe(ObDs_dFO!%5^Q<>Iv>KB4yvj=*+6u3PjsKu z)znE!3c-@np4P$pDqACW-FAQbjhl`)npNmJ4^MHn9PopUB_Y}v`pCamrPS9)r8`ry z_jaL8j@f5B%1^|FB!$GJAs~>9eABBa*;C`r&f7BbWb}l_qHzCPg4ozRu|l4~jvO!04X+|Z31KgXDO!-uZ}%Qco`!gKsf=(-voKflkjd&LG9 z)z#Gx4i2`F&ZVWL&Rbn-A5ey)QujN!RX14nc6W?;+6r+j;fm7CDq04V(W`>BJv%GIl z&Zo4&N6&1#j{xuU?by@PqTwbFGv?`!Eh4jqQ2^9Oto!ZbTxP0Z`3JF2S&co#4KqxW zZh%$Kap1sEjCZmTY3pm(9k#3P(J^r(m#*lUfjMsr{r)mp9?bfW7>#$4z>IE8);!g( ziUPmSb0`9?(zMSiraS(vNRmY2r%yp!hp2Z9+oon_NkZlh*fSOI`-bCVKDD#c{E0r+{AQa1>Cuhva}#nav$N&Z($msUaXDizg5AoarrUGym)@xd z1qDg!k1VUKg`g)XZ3y7Bn3_Z=QC#H`K1U7;Xj=;_T~t)6xaBl-@%`KzjwA;`Ny)0m z34GhgCg8J8@^lAua^h(cdscW_hS1UV7;ZSS_xm3p(EKFM<4!7^5XW-$w6QK08UVx) zy{NK^>_nV{`Z-RMR=+%aO#xxDW>1^75cYDAj53y)0Gugs;f-EPc>=R}1VYtkZ~Reu zS@F=#n;a%0^qR@a?b%}=h<;yXmfp?+bZ=iDHT$VhD@KvBd6kbU0zB4^a*^?? z5yBef`IZWJv_>!uz2$Lfcn)^+HPbXpp3y0jb}RjPavpxMxac-L?+oMdM1#`^>wM%Y zAa>#@w}V|vC5F5gdHXkNp6OYIhYAX4LR%Pdua$KGmIk}Q@ajaB_TcvMlj&-El#j9R zm!VX`-R2OI=D>Ly`BQ{p>^7XOo>qDxnwn%Q!&_jbobzl$wK=$CDF_o3C;_H}1DcEPI-5Qdv}7EbBqplTcr^r?ph@ z5m%BS;B>iQe!k87#H#*DO`lt%a+Lp35o$RjhCr2ABsL4HX?w?D4E$mIX{2?`;8X?dx5fUQ-8=PN%Dzm(|R5D12VA((xaZQ z!zJo|lQVU$t&vZj z@%SB{>_|n0u+4!&m?cIQW`2W58V5D>@u6tO<%g#2VRwBnqsheKb&C?SAEll{9`-wK ze)uENigK3;_%89QUP!SUc4?(hGkIsSthul-qB7fxU17Ej)Ql?GgZ<6d=w`J#+1ZWP zK-u_hSFRUo`R$G_0d}rE5_b0OV?ek6PW{~%rDwuzhxZ!7aQ?vB5LjRT#z)__F+f5x$TMeSZ(@Zv*Xc$gjL+X0`i~kInrLIj zLXO>NHehsKl8%H^{m~}vvA7Uv)5$hJT&zxi*@Sx$*|LOi+BS%w*H<9k3c7Xxhr~F9 zK*O%Fu&e=z>sD_DZXmz=8xWf>EGU8BfA0SF^XE^!H#ttu&UCydCp>2d8!|}$3aBsk z@W{C*YNcDjf9L5RQ2xZl$P4`QO;@rXyckHJzVz+QaREZ-!f79I z=bp4Iu5p)SiT-O_Q0fF5sI#Z+)DyrQfNp|iChd5w??jvWpf)lXV}a}~LhT?lr0q*} zb##0@@qn-Z-ArqGa9=U_?2~nKy(cJe^!E_ZyyFlUREcicSOcPn%Y~tUdoU-SOYv5zce6aITsp(8D(rKLg(RtvL4?$O)OwNya4>AQ7ncTU!eFC<0&HB>Ph zL?#1q+%+(;Zj+PJUY!%T7)*|4^>E;~ISq_l4?-7qQm-Wd+96+kIuPHw=0%$&_p&4l zN(|%U2O+Hb6fFbIZ_G8k9LY;Ubzb!8H7>u2+eX!mIZir~QDx{*SaBHw`3fTdqCRal z0kH<-c29ILQWBwd6kUx;c+EtmsKblg!otEc%aSL`5-%}$tZWRV{aT$L3g(An4Yi=T z_PQg_-X5pqp~cHn8fsRP?}P8~prxY95kHIng-$uE-u<!GHi`q%~e+nc}dk~=AR zG@GJB-y8v)5V1H_N`JY~A_4%xKB|ZRk8P3Rcx@WkE8@_;MuhCVz(-HtM^RcZEgG_} zt#um73uO$BU}_2?@6I5x(`8`_NL^jsL0-L_&aN(2fVtf~Mblw9sS5*y9H=Q`Px3eopAb8jhzO-5DB2 zC6P^rz4?IGkkZj}VqeD_s}aE|Gb*ajpCW1Td~<`9+9^>#qFI>1;gJ#aycT7syN=j< zo`d~UX*YRQd&UHT>$yQ7T;}cc6iH?AXI64LVx*#y>kSDHVrRGd(FkYi=x;e@R6bk9 zZZxkWS$;&bv4K@p)h`1>K^R{hKTXalaB_8>EJJ_RZG8vW5M4bz`d6D_$BSpF68us_ zW1nB-zl(*LrQBub5}GF>3lU(?#q%TcVHcK*>5Z#!ifZubmdTMP7bnmE)Z=FnAf_Yy4-O*?EyREh0nZV z_V>TcDS@O7Rcjl&U96w{6ir;z%Lv>=O=w#JSvf0JYE-{O9ZQ zacmd;4|7}Qc6PDwn{q)GDO*udbZ4QVDAx&njl1;pTCSY4iGp#ZR`I6}Z_w1}95%6@ zp2wKELdw3rwM3eN2tPl6x4Y#1``m}9{R~;fv#Bq-TIR*xYnluk4<34*G%)dXPrz$k z#`Z?O(`bd6{t@mpb0o#ZW$EMN6G2#=g60lopM|Ur2N*hTZf}WYo*QD702IQl)gGrF zaTX|L?CX~iI^j034Pj?@rf$C_zu3o%3x$;m3p-+mi_xhXjM(;Ik~it_>M70DL43n+ zREzl^DW0pXuMWUKO5v9hMDudhq+^BYN}rNJgD4Tt7hrt&WOuny+r zS5!y^cRF!pWvXNV_t@GT5FQB{>gjnON8VVC$_JDfSycx~hNZK4j+U>LGHZBR5Fcx6 z|2o`3^0{!~EO6(&0uqrbW8(y;B0ht+*bOvIvz*TA(tm^2uy-W`)OqJQ4m)wwg% zIN#Y>YrHEDGjg@Hb#=2?ofzN*WKMTbh4h>^S`!@j(fxx*F8HpDfPjp^de4#yFoV6j z`goezSIQZ7T)|u{VDN!juJzCwQ%7Rt=hUeMy{!1v#ki6qnM~DY95tmpYicy zv7>Gd5aArPVnZksv5I?Fsi>4*1_otX{1S|ZyCI=%XnvVBMW){WN3=OS50x?Fhp+dX zg*Qq0JxA|Fp>@iPTgkiTvOeW4TaE+Uq`lqy-HFf|bq0t=;WCK=K3v$MM zYYVN=Js}|!yb&(03PD)m+Tu;jT%wus4D1Cx5zy_jhd-Z0C0vszcXx}}@2Q&EXM5r7 z?98R$2l63zu5`zvWVap!pf@~HgyiZqa*5$gKYt2=-zSZaC#~M*Rdvy<{wp_EK z&oN#x`3LSK;kRyiXsJPxA{=k z7jyah`(q|c^u>627qH(#P)Fh6$F3&g+Z?jvWOOoaYUFPdU}YknBP%5}Wb2wd zn?DD=xVn{COjr{sL8pkBoPo-3P`w5xApxJ(Kh^MldvG3DH~>7+)YPI?Xz{wlyn?wKGULo+j7K!B~kA^@?pkV;k8IQ7|DZe$gk z9WONpOv6;?cMX(!;f}P7^mSiE^#>Oo`TLBHjqOoh$$HGp!Ht}UX3x)Lh$H-w zTFJumD=`az2?G3ky}x}EesjyL;+=q>Jfw7{LPO8I`95&qj%Es6zb39d$9kRJd&F#w zaF=LxK`xAtlx3N|E@sAYlS2+tYF5#to<&XdwOd3__K=U?`Z6y!O*_f!bFp5wyiv*L zGv1Q$<0+QDRR67+knJNL0B88^w~2Bx96o#nJ@Jb&ii4X=!PlYH5MmHnT|VjwsJR zvgG_kaa>iiNRRPKLkW3#d7Q}!pH;Znsl7e`dZD#XbTu?&MR!OH@ghnKRxsZL^jpgM zx&ttPWpx1r9DH(0SqS4HAvj-7*BSri4fWn>-EkUKo~EPSN1siu8@taST~-L9t6M); zG!z2-^t6UZMPk6B5E2vNghnjai)FC-(;pl-k2gGt)SWj{4!%hBRb%^ap;us20!;~! zDdRcsm1s|U!#QJeLKY6Y@U9W_K+tyiX|~7e1!2G-1$dx$Ci07uA*EAOX$r{7zOy&L zaUM3l8;6nc$kca3kr$};m0xoP0#V)nRQ~fm)(2uhs+QQY|3{U<|NGkFe;z@plXmU7 zE33cDvq%9#Z_Vy$<9D7KGd6$Q3shf=H&s6Tx@ew<^Bo?4?)gy&FJH)B0IHmM-eD}; z>k|uOmnQYI7fQ^w+D&|~%HN*~7(itxkRP7~mQ=?9gQ}Jni%5_jE(JeAA#o!cEran)V)PT*|Q;t7yu%UuSQU z4%nZXlRDm9{N5)Yy9*K98UNxvQ54Y%lwH4e3lgvO;vT8)3LBdWn87$_&C=)g>Da(V z0kv~gUrxo)Y|=sQTk;1VYu^5_#?7=?4mBvDBADFOv93}<){=%QTwGcGh17GxLzJeG zDYXqkc3?sFGx*w!f!L(g!Ih9^nGjG`!d_)a(WzxIme5&Hn{1d-7PBpHUXeKyjN?VS zfRLh;J|7D-N5njdifYj|nL@j^#B5ym<_BgHA*ENY|I>ihYb7*|X}Qb;V*TUxz{#$e zkz4QEZiGX{!An2UgFyFLQM_t*VgKw){s(-#5BOe8n8hC*`TC7~92~^8c|5S$OYbK= zNl!_c_<*hqSHG>Qey4n4ckQ{Ohvo7>A;K%fP1Gj0t%B|jP#L1Ea=Z`1N>vO<}2Ekw*Lh;)WS zTTA#85J3v?ra7O0`)Ap|uOwOMuy$QqB=xok~43`O5((=}2hw7umO z<$Mv{3Wzz(aF5?8s0-5^njp?1YCbMhLmCd@Rh4rcgC%Rh5qq7T#ST}s)yXqWP4ej~ z$(m+CT?&|-#_ity#^ToB_)K)&JqmIdmD`v|!r%H;txxMqMl?>`RjH_+HXPp2@mOF) zthk}zq3O(%*_jO-inV1vl2YHz+a!z%vqL3ZRab$oOvLMJkAh6@`OjK-xkf9stsuF-8O*qnx0K&1#)`ao%A8?;XGNhPW_+n zVlSZxK{p%SFj?&1F~tRksyBe#w#eQ-ak5N-r<6A4n9&z0#+VPvFU4PO zrI`QCM(^vBeLYtJv$Q%R=*thJn18f0a)xd$1OdpRd%P;~T4dC+p?f5kfnc zdP^xJ#|`jrT$Yv#rZb<_Z~#4aVy&0eU&`krCR^gOB|-l~xKh4G=Bw2VET_%J(=%WU zL!>_s^!!G6@WV%ynWHtgsIQbpSf=O zSbctv%=XQ&A&f2hBXMhGra!){?tIplzWKt%yjf%KkW7jTaE&doY5}f zHghBdcuMt4Z|`1M;OsLn?&|6S>e@inyv{H!jGilccC9-?Au7p|ddECX? zKBcCF{cPFQu%a+KXI~b0WesW}N8uRDvsqVz9gWhd(7IVxM8m^%3jha*DRGr?cgBf2 z+8^-a^v2>QqCg&QjPKRvwCHwqv_@owbQ9o3;uTJggzoZ*GYXB8{L$J(qkEre2D}IW zlCHcgJ0V67#$OHDYIgieW*6gf8!%E^E7$q1%V4gYUwD4@S7fRm$D;8xFk1WoD2pq) z%;Bl*$}VT1EfhkpB;NpOy}vQx$0&dQ_z32K;u4b@8m?%3+r6F*;08Qa6V3pF031VQo(9!6)w=z->?2zaT80v*zEg0rr&jO}^Rt|&upOtI+3WyBkX`mvWiiFt zWH9H|DrI&lgavN=v-+_=O4#jj-0n{gmGZu15Zm?R(_Z_?V8yWc_A=b-&}L6d3Z^$O zQdoD}z6KKr!Ad)g1h1s8bW;aU@+ccLlEXqag?8@qssj3ux;NwB)9o|l9{*E|&D_k? zxgot5b(vTGXt(w3IAP^NTbQMsvfUwa`!9VaZ?_62zGiWQsgJiPGJS;XD~E=R#}+2D zzhv;mBJd?*`}z3(S+#eaG>0_s7JER2rnW*BOIQPZ(bAk2WQDoYZ~F7l>y41}T%UHuZ{7l5W6yRL0J)UQz< zoLSIH`YjFv|4X5J3|m`qXj4`Dy@sguJE9t)$`D%p&T%_3LTiv)G1z-36yoekQw*G| zw>OUs7}A0~k(xa`kgS5qmZKXa!S9!LqK7$A@a*+SIQn4k{P#6+n?CjbR1cy(u(E& zd;<_L=w&@=k3xo#`ZbRRX6GJ;ja2CAF%VP1b>R2c(0W|bKIBR~=bWqTP3!^%q8hH1 zH5160rU@t+m`%v@y*C=}$35&hm&vmcpHslS-&i(L=#k|NRH45q1B?g#fWB%Lcy3f5YFa`a4t zl5`8VdgYv-gs{w7L3iTYjo%9E_5DmAZ#NcR|MB5GN;kzUMu02BHPAWUk8b7KUS(hX z!gT2J3nMp8C)ExtO|UpRMEt>2#$^SyT(gJ+>Ki3MJ^12^pdb4bLbs-9Wn<{)_5iBwimi$mF1kYjAiw~l-`);zIhvuoUG)U#Vvel z2}ls2_vci6*6Lir?n(OodJ4E#=re+0-uyPd%^@x5+$s=?V61&H|eDX=`tcV8mC5yJ6T-jcVQD> z017vma@ahZ-y+N69}TYSU7@-A6bKK{t)VOBb!AGfXhN~%%Jt>t!>Yp=Y*0b#KRCd} zAHy3OK7Rc}BNe{Qu9;j#X;GoH8=s}vKcaCgrXWEm&sNX5PjDFpnCL6X3`ZRE%1Q*& z6QE$|RRw-u(D}PBJ1B^3s~m>Hq)PCGGdZxb!bES9A3l_p_5-?vfQ}(4arj8n z1O3())Bsf8C#bt-5)qh+5%2Z-78eo}n+3vkW{0rFon#RSM)39J5L|swJq3wf?Q<}$ z)F0TGaL1#Kq>yBP8Qey~0md!O=uJtFr1>1x(0Z{-ax_x4jelWY8<$A`)?VYD5M= zPK=vrzo!wdwU;tL1SaCqWRTww4``wZI;mn_587|ZC??#!+SgKB|32!;Ivl9|8yXtw z3=DW%+?(uvqmJ%T*g9A(=w{{**=5D|o;}PvdzW6Z%`lZqnkXu4$G;kIy~dn+(*2Es zlbL%3Jf1up9@lv~suYOm~euwpN&o=K=bd zSh9{@%S;|>pSg8tVNsWB;dcUxTARGgd~Z6LVyh4$Hxv>Bm?BY>})G- zqd}4=Ai+*7_)d}Ct+t!BUhVNZKl9RahNAVf$0ifcLQ3h8+0v!X714j0-rU4=3Z7Bs zd?{UPhr@-iCgQ%m<%YpF5^tf&cEo|q*_Rrg+@0%x1o|-t@SJDr(>H!vqNC5l8A~}4 zuv$R{wmn6_hWKH~_6y>@eG5jv;)c~iW-6TzC!sEMm{F;Aj>hHgoVLt+^_mqhQ-{YN z^oC{oVIQs3+Qwz?Jq-(kw;0;%OcZ)k4wGRF7)O(BCAY^GZaY<3&;{|J&9y|5s|FCG zj<(F~$fzb>RMrM+oT+hHQ_Wt6PdG9CscqG>+V=lJjh$Twd8+N1WoQ7rbf7Yh>(;6L zQq7^@=)moYYsNt82<;j=DKFAp8TaP=Dj|laR~lST<-xS=%^a3eee)j&Nk?;Vt8}#$ z4wHb|2cwgfPL2~5_7ON_c9gcGIQDhVW&d@*tcnE^RhU2Pnb-F!x2hlL2SSeSE`P5E zj5Ks{!KUbW2Q9*8^6f7=MlK6<1*ym@Al;vdTg#hF|CeeAb1b)Kf6Q##H4HIufE)Ei zg&7K{d+%EGuEnNLcz^L!AuaT%=&%bCZ>wBS0v5>S{-S7WTqtIu8|dB8V%j5H>`1494z0`#FzIclAh zUYy<1Fkz!s;a7+}6l?Zv3tSq-XtQ0c0VZx~2poVwPlfGl`h(_y#jlkljtcyqqJywRhzt!Jfep)JV$j!CK&egeG z6exh!;|D)1;SCMw3E-n67Lya6b`CV63t^}f#-7|j^!-|ycov8?=j{XvX`ZPTH_?c7 z;`eQUR|MH3YotJ5#9rF%SA*U-X;?P{0(&6jqHlM;ejx1C?R8$!%^j_{qqFA1(iOj+ zMb$D^ki*u1CxcH37LPwO*%Q8L~igl9Jz+4MWC_lY7q# z%u~N53Y5!vfBiaquV)B5H3%}%&1MDraz5Di3nHKCm@hOgX7!n@$LBnFc`|ivtXT0d z)cb^<2wq+g=>&%5O%tcF{K zhG({MBfn4YP1)0QKPMaIHtSfx;JipUpc%XL$5B2oLl=o;PNF|02WntHpGh;21Spc)5A4@j(dble7k7uqbMI z>>%n&F0nlw;NFa-NHm5Lf)%>q18#lR^8U;G^Gr8Vvhf_UKnvv=ak3Z1A>Q)`X|rdq zI`ceZsh{ur_w$o>Yjgj-XU)!u09Ca&l{Fsd^i)!y&AFkTT5UnpyWr>c$jH^Sw-;zy z!}ZqHx}7l_UZUH7LEewo*>cRi@&V|jB44@$M*^lGCiqo*u>rLdQ%~_M}8NBZ~rk4a@Enx~R2rx<*CW`?4 zo>qXn!s<%nMt%2cVT@Qs`M4U;N1RPh=Z31#x?{qIhsUJBoJP$e28=3=Y4z)EA{sd- zxDQ&PJ4>gej8F+kmu{j9y{^B=E`a<*X|!!m!zW5Z#BUG(QXO-M^IO(|Ul^j|*8rfs zIl0WlaH!k@K1|)X7Y-=*!_}2(pasoRw(=I^{;54B2NqxXTb`m5i~OTN+Oy?J6M>IVX@In3GZo=Y z^rjV$KnY`Cj6C8Gt9#!XFHt`--BbFUG(_La`DsNQD%eO1V8bA%W!Vu#o;Z(kdSFny zmu)!H_gO!Vg-SzxLuQnQ-ar=vSDCy7>|vuyvdi}w|6$pC&#G;;))|#hY9qfoZp#bR z^WPNLHwxQsbMi2k2@h1&O}iI)*8%N7A_T1NlP9F=zziz}SXcLt)5A#zAPc!wCg#-m ztTe9DBOYj~-cl}cJQV;)v14JDe6`-{A%vxfdGSAIwc2f3^!KxX~N{2zXmXMWE>h>?& zk5{YAcvT}VUj;aO&HiqaFR5#KerWpn`Wg>pvz>`P5Wep{M2__*5R4}#vjD&a zv=P?TOm3cc8ZAst|LnU^^0l{X=Ctgac}m5@Hdj3$kUy>&ZsnAjZw$VJm39;azWLkn z6U$44va#~5W0KGPQK=N|tb}-cyHD6!(8tf8clR5TNM48V^kwK?T+(my62X#gPDZPJ zczU*ORW)FkwZ$Cs2XkM4Bfl2@%=r77heQLeL*a>{?m~ZkaJ(7AAP)(;loD&&mK1Q1 zrRU@li&_@$DjT8j6Sej2J2ll3cyU=p=SV!V66)9`$a{)MStn{JY8FW zB18gs8PhHGc&hXzeJdNAsKjmv#G` zpYKEJEIolgW&Rf+L%<%wg7Nx|TG9AKD7v9kymvmq;LnYV8f=c%L(R+h3?$Jr5aFbz0v{!D3zel$MuT;9hxUq+1P11IkE_ z-6?P6MLzAkCbpXi&PciI@ZuULS4H+mQe8CwQ7K>Hn)aMa3*?-1c6CL!`vm+<+KS(! zxKx)5RD^6e2)jrB&uz7$4eAR3~f!DR-;xJT>=Qa3!Cc)4D(3?v5o`{t; z?SU}g{djzjd=+rVODd%ncq_y4CWXz*C9mz5Lg^PpqKeUo)dwd(zG}N4{jlTU& z7H^*J5Z)+Av?N-DUkAURTs9qE0s8Fj>uG7}L7`nhJ6XUtZ1(sfB8r!E+0uWsidJ(lD`;W z)pICh=ipHpT5(U*$4$?_&!w98;=x~^wU=6+GHo=>oya%Zml{YSyxZ>E*|}w=ef%6c zThLlW574MK+Aq5nU%L}+b-me>>Yc7@BG-YMu@Jirm z6)8a5#53dX4Eo1^-ld(+VtXJbW3T*qRiLWZOFdKyoj-xBXa+??h_Do_3BGn7s|k zA`=bSgc!8|3c6@n%Vz-|T9WQk%jcg}LX-=IBe9Ao*~4%q0CjxCc(M_f@%bzi@#bIj z-=PWzWg{iFP(!WY@Mh5|NVwO}5Yk00Z9nE5E)!O7w-XTXQJEm{+ z5VGPbA&wQ6ru`jYEv>!^O=%R8HQ78Pe+SMGdh_0BMc?8R=80Q!&!0QpT-^w8+sGCX z)N{4ML754wTcgg(N1O_9Oe!^iYR}dBlEw>Ta8~Z$@PArp??kmyQnAx7Ts+o&W^v7J z)Tn@N79gm=XCHn>6)-QzgE)^?WCVD+iEO44VSb3-@hXLAzC6u!*r*hzlm}o-vD?Kz z#^Zqg|KnL90C~?Iq6hk49j@}6qLQ9~BEwj&->eOsMp&O8uV)nWem7_YUG5W>DEUd# zp|G|_ZSl9MNS_z)i%Ut4Rk!xOq;yxMEo0bz@parFioThbYM=MlV`rd8Ump?|0??B~ zD=baKFsr-!&7T0Kz&Z5qT=9P}A^v|**w1+U$L(_s%QEtT?_=~;d**BF>F#dt;Y;P{ a>>%#r>TI-LJ^>t}dZMPQS_yvs=Dz_~z?TOA literal 20071 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 diff --git a/artifacts/verification/w6/states/active-streaming.txt b/artifacts/verification/w6/states/active-streaming.txt index 95b7809..fb56a44 100644 --- a/artifacts/verification/w6/states/active-streaming.txt +++ b/artifacts/verification/w6/states/active-streaming.txt @@ -1,4 +1,4 @@ -braid cwd braid-integration · session New conversation +braid cwd repo · session New conversation W6 active streaming working… diff --git a/artifacts/verification/w6/states/analysis.json b/artifacts/verification/w6/states/analysis.json index 031e294..2cb8e79 100644 --- a/artifacts/verification/w6/states/analysis.json +++ b/artifacts/verification/w6/states/analysis.json @@ -3,9 +3,9 @@ "state": "analysis", "source": { "binary": "packed real binary from clean npm install", - "binarySha256": "abbc7d58b48a991469ec8dc103bd1e9479d22466f8ac77687aea24cf7b77a562", + "binarySha256": "141288e0fe917635d723b4b70d464dc49baff14cc356f54de1d3f8faa5d8254f", "tarball": "tangle-network-braid-0.1.0.tgz", - "tarballSha256": "b1d5ba5b3c81034647fe9247d94a6f4662c7a22e6a501f6f226c36208e64d046" + "tarballSha256": "c795f05973fef4353024c23a14271de114d3bcc8943ba676c43c4dd12c324bb0" }, "dimensions": { "columns": 80, @@ -30,7 +30,7 @@ }, "capturePhase": "atomic-signal-frame", "captureRevision": 1, - "frame": "braid cwd braid-integration · session New conversation\n\n Write a message, or press Ctrl+P for commands.\n\n\n\n\n /ask · frozen question\n source: aaaaaaaaaaaaaaa…aaaaaaaa · frozen\n analyst: profile-trace-analyst · completed\n • [citation-repeat-read] The same file was read twice without an int...\n ↳ [citation-repeat-read] read src/app/application.ts\n • [citation-missing-proof] The answer claimed success before the ter...\n ↳ [citation-missing-proof] test started; no terminal result was reco...\n source complete: yes · findings: 2\n analysis cost: $0.0048 · analysis time: 1240ms\n esc close\n\n\n────────────────────────────────────────────────────────────────────────────────\n\n────────────────────────────────────────────────────────────────────────────────\nready for a message fixture/deterministic · thinking none\nBraid starter · runner pi · deterministic fixture Ctrl+P commands\n", + "frame": "braid cwd repo · session New conversation\n\n Write a message, or press Ctrl+P for commands.\n\n\n\n\n /ask · frozen question\n source: aaaaaaaaaaaaaaa…aaaaaaaa · frozen\n analyst: profile-trace-analyst · completed\n • [citation-repeat-read] The same file was read twice without an int...\n ↳ [citation-repeat-read] read src/app/application.ts\n • [citation-missing-proof] The answer claimed success before the ter...\n ↳ [citation-missing-proof] test started; no terminal result was reco...\n source complete: yes · findings: 2\n analysis cost: $0.0048 · analysis time: 1240ms\n esc close\n\n\n────────────────────────────────────────────────────────────────────────────────\n\n────────────────────────────────────────────────────────────────────────────────\nready for a message fixture/deterministic · thinking none\nBraid starter · runner pi · deterministic fixture Ctrl+P commands\n", "packedState": { "schemaVersion": 2, "capturePhase": "atomic-signal-frame", @@ -38,7 +38,7 @@ "schemaVersion": 2, "revision": 1, "sequence": 1, - "workspace": "/home/drew/code/.worktrees/braid-integration", + "workspace": "/tmp/braid-publish.Jme9u3/repo", "conversationId": "conv-1", "branchId": "branch-1", "conversations": [ @@ -69,7 +69,7 @@ }, "view": { "revision": 1, - "workspace": "/home/drew/code/.worktrees/braid-integration", + "workspace": "/tmp/braid-publish.Jme9u3/repo", "profileName": "Braid starter", "profileDigest": "02a2ebddee943473ae42c9fa387abaa956799ab7791b68033006dd4029866660", "runner": "pi", @@ -268,7 +268,7 @@ "payload": { "value": { "kind": "workspace.opened", - "workspace": "/home/drew/code/.worktrees/braid-integration" + "workspace": "/tmp/braid-publish.Jme9u3/repo" } } } diff --git a/artifacts/verification/w6/states/analysis.png b/artifacts/verification/w6/states/analysis.png index 16e333c79e4f112a43961707c89d663b8dbead7d..f8099094788fb61897b3dc84a9cdd9d37047d241 100644 GIT binary patch literal 46525 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

Lw$-IQ>PJb9QMGIx!y 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 AW&i*H literal 46975 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%0Ap2B^KBrJPU28h@Duzk z5@~L1lC{6)CO=fNM_HXAx2}k9ZS^F4DyA#C6gu;n z-!0G0%`LCoLf0zdf5e`WuBnoJ2BV0RfKf%bZ(y0XD_-9bD0A+k}`N7YY=`N0(7GGAT@9lbPa zZP3`!*>9QkFw=)yHI1&%#W4$u)Jv)1fy*-#xh?Fe*j*95rGxoxm9g%i-9RML#ib~W z8u}F$)eS(8{gz#&aqTO`#r0{o|aG zq4jZ<|0eopKpJJ=qN@UtE#0&cj5e>e*bnPp8Sc7Z?iXMrl(Hj$e@Y>ylcCU8;Z4=~ z=F?T#7TZ>f#F-USQtG!nl<+2eZQ=l@k$f{RQPpDI$%SXq+)_y7C%MW7CBZU%tjesu zZPSWVx5rvgJAiF}_>!g9|MFcYAXrvR{FQE*fFO!;;a39YKVhzkg7_EUamoCr^uxbv z@DTpxdqMDOK>h#y*+|GW^Yt74FJGiWf&PGC;771$9#E)*JB+}|#ZkiB)#V|&Z066d N>VoyPDm0#i{tIxRgPH&U diff --git a/artifacts/verification/w6/states/comparison.txt b/artifacts/verification/w6/states/comparison.txt index 4a21bad..213f9fe 100644 --- a/artifacts/verification/w6/states/comparison.txt +++ b/artifacts/verification/w6/states/comparison.txt @@ -1,4 +1,4 @@ -braid cwd braid-integration · session New conversation +braid cwd repo · session New conversation Write a message, or press Ctrl+P for commands. diff --git a/artifacts/verification/w6/states/empty.json b/artifacts/verification/w6/states/empty.json index 1841e4b..31e6065 100644 --- a/artifacts/verification/w6/states/empty.json +++ b/artifacts/verification/w6/states/empty.json @@ -3,9 +3,9 @@ "state": "empty", "source": { "binary": "packed real binary from clean npm install", - "binarySha256": "abbc7d58b48a991469ec8dc103bd1e9479d22466f8ac77687aea24cf7b77a562", + "binarySha256": "141288e0fe917635d723b4b70d464dc49baff14cc356f54de1d3f8faa5d8254f", "tarball": "tangle-network-braid-0.1.0.tgz", - "tarballSha256": "b1d5ba5b3c81034647fe9247d94a6f4662c7a22e6a501f6f226c36208e64d046" + "tarballSha256": "c795f05973fef4353024c23a14271de114d3bcc8943ba676c43c4dd12c324bb0" }, "dimensions": { "columns": 80, @@ -30,7 +30,7 @@ }, "capturePhase": "atomic-signal-frame", "captureRevision": 1, - "frame": "braid cwd braid-integration · session New conversation\n\n Write a message, or press Ctrl+P for commands.\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n────────────────────────────────────────────────────────────────────────────────\n\n────────────────────────────────────────────────────────────────────────────────\nready for a message fixture/deterministic · thinking none\nBraid starter · runner pi · deterministic fixture Ctrl+P commands\n", + "frame": "braid cwd repo · session New conversation\n\n Write a message, or press Ctrl+P for commands.\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n────────────────────────────────────────────────────────────────────────────────\n\n────────────────────────────────────────────────────────────────────────────────\nready for a message fixture/deterministic · thinking none\nBraid starter · runner pi · deterministic fixture Ctrl+P commands\n", "packedState": { "schemaVersion": 2, "capturePhase": "atomic-signal-frame", @@ -38,7 +38,7 @@ "schemaVersion": 2, "revision": 1, "sequence": 1, - "workspace": "/home/drew/code/.worktrees/braid-integration", + "workspace": "/tmp/braid-publish.Jme9u3/repo", "conversationId": "conv-1", "branchId": "branch-1", "conversations": [ @@ -69,7 +69,7 @@ }, "view": { "revision": 1, - "workspace": "/home/drew/code/.worktrees/braid-integration", + "workspace": "/tmp/braid-publish.Jme9u3/repo", "profileName": "Braid starter", "profileDigest": "02a2ebddee943473ae42c9fa387abaa956799ab7791b68033006dd4029866660", "runner": "pi", @@ -268,7 +268,7 @@ "payload": { "value": { "kind": "workspace.opened", - "workspace": "/home/drew/code/.worktrees/braid-integration" + "workspace": "/tmp/braid-publish.Jme9u3/repo" } } } diff --git a/artifacts/verification/w6/states/empty.png b/artifacts/verification/w6/states/empty.png index d8fef60ae9108c5ad42f51e9b4d0a3f8176e8d72..761d94e55d4553d6c405cff95f5a305e54f11673 100644 GIT binary patch literal 19815 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 AfdBvi literal 20286 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 S=@{@4orbE;(=wG8Z~q4&)jrDr diff --git a/artifacts/verification/w6/states/empty.txt b/artifacts/verification/w6/states/empty.txt index c36dbed..314a175 100644 --- a/artifacts/verification/w6/states/empty.txt +++ b/artifacts/verification/w6/states/empty.txt @@ -1,4 +1,4 @@ -braid cwd braid-integration · session New conversation +braid cwd repo · session New conversation Write a message, or press Ctrl+P for commands. diff --git a/artifacts/verification/w6/states/failure-or-reconnect.json b/artifacts/verification/w6/states/failure-or-reconnect.json index 4bf782b..95bc2b9 100644 --- a/artifacts/verification/w6/states/failure-or-reconnect.json +++ b/artifacts/verification/w6/states/failure-or-reconnect.json @@ -3,9 +3,9 @@ "state": "failure-or-reconnect", "source": { "binary": "packed real binary from clean npm install", - "binarySha256": "abbc7d58b48a991469ec8dc103bd1e9479d22466f8ac77687aea24cf7b77a562", + "binarySha256": "141288e0fe917635d723b4b70d464dc49baff14cc356f54de1d3f8faa5d8254f", "tarball": "tangle-network-braid-0.1.0.tgz", - "tarballSha256": "b1d5ba5b3c81034647fe9247d94a6f4662c7a22e6a501f6f226c36208e64d046" + "tarballSha256": "c795f05973fef4353024c23a14271de114d3bcc8943ba676c43c4dd12c324bb0" }, "dimensions": { "columns": 80, @@ -30,7 +30,7 @@ }, "capturePhase": "atomic-signal-frame", "captureRevision": 7, - "frame": "braid cwd braid-integration · session New conversation\n 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────────────────────────────────────────────────────────────────────────────────\noutcome failed · operation op-terminal-000002 fixture/deterministic\nBraid starter · runner pi /export preserve · /new continue\n", + "frame": "braid cwd repo · session New conversation\n 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────────────────────────────────────────────────────────────────────────────────\noutcome failed · operation op-terminal-000002 fixture/deterministic\nBraid starter · runner pi /export preserve · /new continue\n", "packedState": { "schemaVersion": 2, "capturePhase": "atomic-signal-frame", @@ -38,7 +38,7 @@ "schemaVersion": 2, "revision": 7, "sequence": 7, - "workspace": "/home/drew/code/.worktrees/braid-integration", + "workspace": "/tmp/braid-publish.Jme9u3/repo", "conversationId": "conv-1", "branchId": "branch-1", "conversations": [ @@ -112,7 +112,7 @@ }, "view": { "revision": 7, - "workspace": "/home/drew/code/.worktrees/braid-integration", + "workspace": "/tmp/braid-publish.Jme9u3/repo", "profileName": "Braid starter", "profileDigest": "02a2ebddee943473ae42c9fa387abaa956799ab7791b68033006dd4029866660", "runner": "pi", @@ -404,7 +404,7 @@ "payload": { "value": { "kind": "workspace.opened", - "workspace": "/home/drew/code/.worktrees/braid-integration" + "workspace": "/tmp/braid-publish.Jme9u3/repo" } } }, @@ -513,10 +513,10 @@ "value": { "kind": "effect.upserted", "effect": { - "id": "effect-op-terminal-000002-f848681efc4ac5a6ab54fda1", + "id": "effect-op-terminal-000002-c0e481872f33bbcc2466f568", "operationId": "op-terminal-000002", "effectKind": "run.execute", - "requestDigest": "f848681efc4ac5a6ab54fda1b52d2e68bd7eab4b2dde7d754f35f5a37943e7f2", + "requestDigest": "c0e481872f33bbcc2466f568bfb3eb43d66eb3d0558d8b74c3f3a297e0e997ba", "kind": "send", "status": "pending", "attempt": 1, @@ -534,7 +534,7 @@ "source": { "eventId": "run-000001:runtime:1", "providerSequence": 1, - "occurredAt": "2026-08-04T05:12:23.981Z", + "occurredAt": "2026-08-04T06:06:25.451Z", "receivedAt": "2026-08-01T00:00:00.000Z" }, "error": { @@ -552,7 +552,7 @@ "source": { "eventId": "run-000001:runtime:2", "providerSequence": 2, - "occurredAt": "2026-08-04T05:12:23.985Z", + "occurredAt": "2026-08-04T06:06:25.456Z", "receivedAt": "2026-08-01T00:00:00.000Z" }, "status": "failed", @@ -575,10 +575,10 @@ "value": { "kind": "effect.upserted", "effect": { - "id": "effect-op-terminal-000002-f848681efc4ac5a6ab54fda1", + "id": "effect-op-terminal-000002-c0e481872f33bbcc2466f568", "operationId": "op-terminal-000002", "effectKind": "run.execute", - "requestDigest": "f848681efc4ac5a6ab54fda1b52d2e68bd7eab4b2dde7d754f35f5a37943e7f2", + "requestDigest": "c0e481872f33bbcc2466f568bfb3eb43d66eb3d0558d8b74c3f3a297e0e997ba", "kind": "send", "status": "failed", "attempt": 1, diff --git a/artifacts/verification/w6/states/failure-or-reconnect.png b/artifacts/verification/w6/states/failure-or-reconnect.png index 7c4dcce873cdd77b6d8e070afcb952778632e88d..0f6f6c25ff033f814928597fd23b2f24d4c5caa7 100644 GIT binary patch literal 22435 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 E15^xJuK)l5 literal 22931 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®9V150Y!$FD2L zqB3yBO_xldOv2Ha#Y^lu;9|0ZFyllxnO0GwBi{O(#-IqG{0c` zH_;5ztq^o7ROS(e9t_-SoZZ6j@!2rp(@y-fY2;=q=;2kTKSlgR)1GlZ+Eqb)BRD` zP%fqSY(VZMS14P}4-$2<>cnF+s$5-Sbh>vbwen1+G;F1uf(~Xp>rJy{yvOylu+X8t zsmU~@_Jx)f!U8>&;;H~rRNRh=*ih!8qI&hmm3s!-aVD8w8y_e~nZZf?z1@AEoDGZe z*R!N0fbpYSGV=4C-Yj!Yd6>mLDOuUXEvuds&LVQZ*7-@EKQ72-rCe^EmGx@hCwE3K zuOGxn0Iw0|SG+cQdyQYhv~JT8m!F_?Ur#{A@#RxvFzWaFA^^0oY~mUzH%@HJUsBhNk1124qB^H$ z_22=&E$$q|lOJG!q;B?$ldh3okv*#<%B1)BFg<S~R?C2m=zf`C9x{E0Xw@}rGeD_$uuCx=G%qtK8Gu(GJ> zQ4GUE+dG?E1)OTJY`zOig4o|KT}lnL#EgyA`SfRj_RMA!h&Rz5y9;xV$;u=bjf%o+ zbnMd54Fz{xyr*ZCRt9S1T(kkmTxZG>)sZH%U#xg}+NPi2;Ez15D_B?iNuiVPN4v`! z9Or*P5GFTxyu7@!l!Dm;K#qgCS7qIBLz#tkF)J}F;&Fj&!_{_#)!?=1rp|AV!$F`P z0DW=FfA#;~-viKu{hyTr$0##x?-HeklSB+RP=>;Yau-~HjmJUa-CwMCqa42fw#sKY z*wEORn6R+0jF`;M#xfJ;g87$sAr^YUlJL;wFY*0BrT(7%GTs$bCq?|k;Eh*{#64SD>3*Aysq*$F#LAQ{ckji=eSD`}z z9EUF@VVD;3h~Ng3Fr@L30#JxhYF85x@muuJEr3w!l_o}ba%+D+`8iaenSpeav+t#D z(aP{8%%4<=ub zqR!~pAY~hfRkIu`E39dqra|lf?K}ffR(Y=*zD#LZP7L3sh~bNa;NR%y>WQR-m_zA7re2 z5??mD@p0g5q%sydRNDYPbk>`_Otn)g*d3hnqjj5xr#ne7Z}s5PrAzaSjG*AdA@3j< zhf>HAvTh3)c_A=%byd&}x?$mjXA-iqw^h>8FJN>dOvo_X=3G-MDhp24+c^e&e01zmcNGH$0GxcFoD5_z8)4A^kOny4vwpoV zHtO^MB*`%>V^)vj2x>XpnSb*5%{Y>>F;;>AXZMNq0k7PDyLxtdYKYLY>rJEgCBAeX zF17RU@bJU~n}d!|rvr)Y_PtnT5{QIgYvPWVn|Zl%DhH+(l#+Xui;3R(Ux& zMKDHRCgh!4tU8MtSLbUKXlQ5;X#)&iQVQG|3Kbp&&{HK_cuN9*gyU$boz9#ucF`9K z!CqXbmrUNi{rfF$c`1*-lxLx)*W?3ECO#EtT4%M*)CZQCHyt&YSOz1=t?Kc%q!5n7 zdaWiuAg>Q3Vu`od>wMOSa)`~|ptUH5A5&v>0GWb8m);;xf~99OY!; zGF_dnQ6TSsk-M@kNAYga3IT#6De&Tgb++a!v<{rDg#A8fHPm)pJlAJTW=!R6 zTfIY6Wy@Z0rUMih>ktT}g5{mb7f#m{d;iR##RtEB9Wt!W<02@&sraV)oavy7hB`<2G ziVmBdZcHJ|8q$pyqv)g%kKY)9x>JVvO3g+Doq5_~Gf-pY# z%w?z|w*y#LmTXmxzntQ0tp zOKAXfNq-7wL9@GK#pQp4d&e%K5h(IleyvHo#qCtCcb%thYhroJe0ht<9jH+X4yp&k zr6g9IdeAfL`#Tk%|S&GuVxhbm8C+34P{QRETp>+c8mtKQ@qvGo4= zqCoF(Ygm2%B_&a|p~drPId%9jU2{LS`O7y#^sPyB>SMITf8# z1~N}JC-niBfJ>${4;^D=YnXepNw{F*X*Q_l)%PE?t9=_6FOtUAYc7J1HrJ2OzD3co zttn3_YK}MP7eS3GU1n;loDWS}qv-GrtFJGJUoH*@VU4b#AcU-Pc|yV6xr&&mc-Qgc-n3bC}mO0A2A%tb05yPsC9XXEqAp2f<32W;0i8 zrGd^xdWipnSQcCQpJC-cgUkPX6ozw42O!EhSD}phA!6C zZo0H8Y}VLRGFU%FuvtpG>&+YjP3U^U__X-peNjz#dM0eA*yHs1ZJ?r;>OvKWd~7zx z&YGX|Vi%=n<(;`4#h23A*)bLcdtaP!h8n5b*j~lkr#nyzZR7YzuF~C{K#xBTvbH0e zFB{e((}(hP^-&M`I!cG>xk5b8KG#i$;*$BK%8d&1?R0vF!>#7q%`E^VPWkzT2KmR_ z#^0~YmR-vG(QR=1U>XrYcbrvk9owtfm>VcMp_ehDUy za_j^$IVh{1d(xV`QvUg~lNOunCa&SOY>OJMoA3@Uq9MCOfK?6#56Sn zJwzF%Jy*k3y`@gn{(LKxIU^;L{D)v5jytvIWpe5E%c*Z}MJF;ZH2Uwt*4FZDeQV{H zhi^N477-0Lst|~{Ygjb4T-@1HagER3cJtQtY0uT|9+CS1ZBGoS3bPL=1|z?ZmL}Cy zi8=PS zRIP*HNA2~sQj>i^ZyxMk<~6_wCCgCbah$&w25tb1bAnGsT1uVJ&dLi4ZNDr?>pAkF z@6=x!AeU$ zeDJZ%hHYEh1vkkd;HfoLGO{uigv}H49{X9Lfa^TWmMtco?SaK$Y2f#6OwB=%(w8@x zfEh%&q}JzVam_CQAJ6tO9UeM5gUU0KlT6;7?25)*y;iwC<~dgZ>%7NB!?S{P+C4|( zXvNG2(JDo!3H*)&ty_Iz9YD8 z2&~Nkx{ql8fwKRJwf}!VV*gpO0^sBw@m6C2;MwWv|JISO8_O+vQ*@gVC_}ju&aEs^ zInWJMu>dtG9lt77{&!FNuNuSG4tj0W2ektgNjbgzKGyNHAjtdpP3t^kP+vXLA+_z` z1L~Nw`_Nk7UlwbV8rgG`brkN#$zD0>A+D5DFMQzqvu1NZe-P6D5rNJ{U{MJ8sIQ;4 z1{m)}>T{vQI89DQp_tv&j`i-VkClv04P%i>hEG*~Y-(=>qlY2f+&w?#{x6Lz}nB!_O^rDK`&s^F0*@0(wMll5%SUDH$_ODYld-ID!udA z**tn@fve<`N-Fs(+ER65i#A>-W(ryA=ss>We8htOaxdrMLzAH#tovixY4@4J;9}8Q zC559KJZdAQyN3IG?PxCwgi@ikhG~XJ;-(NUv}BLhyw*K^u*uM77uPZ0t(QY>K11(@ zuhlUJbr4Na94mgEMd5m<$@M|xpXJ42L?>$8KjxLFeC?^Rz84vYtZicHzc7fsj^v$+O zvkaBb4zEG>{luJ3Cx1%5V`UxBY7?QUe~5gZM-;bs4=K7kWU|H@uQp=|BS)?L0mOg{ z#~G;TGXv7mIX75eKc>k*XJKx^#mOnKouU$Y`b+EA4pO@=%{EfGIPGXg0aSquPna4v z#n;F}Kim%A>i~ffzpR^1fzSF#Z*Sf_qdqu$m_wsu1R-5z1?s;qES)IrK+)y?FrZjmdocbqGnRyPHpCs3KyYI+CJ3A#sV|;I9D2+2RJ7`I z>|ui}wF_O#ERgY2sH$Rj$8q7mxCQwgDUfc>2AsX1(H$JK*GN>;n(a=zTc)iXu+zTo z9b|FJrjHt1)naqv0-*hF|NPdkc=yN5#L1yhGPU9U*oV~@ORbe;@ijkySG$sPoL})y zAigf!zW{*t$%0O-clHlnP4qkhXIM@~GGt4Y%9h*58n24Qm6%SC{kS)`y9kcUZeeGs z>~A!vLMbQ}<1?D*rF?u@bqcB(tlN>_xW&yGRRqQ%fw%R_A%1=Pp^)tzu9vZa{u#mwC)>7H7DrCeIXA^fzL8oT(a3p!+){^RY=tIS_Y*l zT-zD$vl98p0ZFl@vv|sZ`k#N^WoK6~N%=zsU41Q9|4}w%OG;8*74W_FwIr zM=0RVJ$%&EI#^oq^gxZgnV;5rVeDPddChY-yB-|<^-M{pJw(}f?d>Jmyt5VN+}NQx9L>v_e2eH)aaI3n>SjtXG0igR;k6; zBFKfVGR!OR%R>Jcp8`#LjcIq;1drC@s#Ijpegi2<2L&tev&}uo#m?Oa)|@Zxi(S z;^9Z9E6c*9(PMHk{+q+#pc37NAO>@ln}jPfWapjb!kR`%=<}kM;Jt39{Ns0mc2lgJ@P?n?c7X=77V31Q zXy9Nbf4>~j6$Td63B4cmn3#%a2=odHa-xmtd2$U49lj^Q%d4O;-Q7F$4f3RM)Ol@T zrseah`{VELRQ=uQEI6yc*7ot)BZN#8TdlK%-`I!Go15Iq872{A{`SsaqGCQFAwbPI z**gi>XZu$|!*S*9S=RIUwXf6u=kX`&EstI;?PM5LxS3x-0cqblnL2)JDsQ?Jj0zm` z?l2>KN=V>|pm_w75n2c}uw6Q&4f~Vt&)>{j7Uq`=z;*^gqYJDxxxCyRO7A8c&y7%C zc9~{9Ymzo{L=8ad6kX>A1_Xbwqh5DX->CO0`-QgL|5^=bRz#I;x z%K9WVeI$c#X68+fipq?#s;b?rk6%?pbTfhWJnQay@dA8XrC3r@Gd=w}54@>~6GQon zbeix#Si-@bgrMp#?mf}251@-l>kPNt7eWQm`eJaGRK2>FJVeUQ% z%&SzR$mKVA?(UX2NmJMmO3C<)@nG`b&SlMFlarIEtafKEWo-y8&u0x2=I8JB%D_46 z4bNANrq7R(E(<2DsWw}h@P%a#JnSR%LUK%LK{i02s%}k3w0DLDZ(HjkcrBz^;xZ>L z2=10*rZd&vovhp+w6uk~hhvLwEqOxK$tzf~EX4w}O1}1s4>!?9lSNT}ms!B^W9AB# zWI|~+1a?m;xy{D0u-SkzVeI zVi$`B!4V98w{D&Fo;)}Y1=bdlzOws7V?jZ(VGbT{_D^A6za_gNWCyJ?X1`J;?@yI) z=ip#(H~BIRPy{|yp!Mc?>f&sYATl`7^$aN9{ReycX0&uoCXzKIuS5ONIUZqo+{)V- zr>^*{x2qU3b+PuXMJQa{0zel&*J-hxS!!k`aD!D~vfE({EnZFPKYZv*td7^z^cJwn z>2Gv=dcVxl#5H;RzNCUFd-(gWrjchr+C^T$`EoSFJw>IQK{(x+i0(gJ^2*Pb7--CC za@V(GVa&Zh^NUiYB!l7bjeUX%PegJ_34=-#nWb^`qgy1yN=HYJe4VJB^OV!7h4y1| z*ffdM(b9@*07N4z%Vnz5`@^06Wa4;NS9eFFHzgDh;8Xrb@%r6bq^`_{rt(Oa<}XNE&Iq^8R~wwq}hY?QPuoBi_P(=an}4&M3qK!uBl?!I!;lY;GqDDOIl7 zSmh3deTVNPt#ZQ8#3fSOYHJPO$U2-^KKPo{a8Rb_s8%9>gBa$o_r@svrWmUC zmiyYUcVb^no%r8xh(@8RH1A^f2TrrgqLImfhF|RaFOS2d((5{$TT$bk&DnG+QAcqH zw(9_}xwycb#%U=oTqz)o?7bSgmq_jxcgrAfaMV7O%%;WvXHi_%?3A3mPYAj{SKPt* zc9dVoMA}_I6SGANk&!v`&k#pD^xBxee}}>A0%|m%FoMnm#X%aatxOSPn<+=WfpsXy zFsV$)$*)C?TxQ}ps{k6_KlwbE8hYE`i0A3IMMs{e2Gvu&ZEYKs!zYFs)5^-hUTTGr zLh+8v+@}u|6q@pkj7#ndDR?SZr<^#&^UBE@US6~AQ=y|>N3CU1d$xLbYkBZm zyCP!s{P+;wC>CWQC;CyzZrnme$ic?d#wv8Hk^qUUXjVdE-ETkiFt>OLO!#=EJu@sz z?Q<5m645Bjw5d9e@whCd&c9kDCvd8FR$4)U1XLYet79A5;*Z@pEH(3Nd&7GcW_p{V zM+B#Du9?_Rz158=3?uiQR2dS+`lKIgR8NUi2HauaAL*T$=|1Zx(F&jMyHx%C9|E^Z z-)~*>&#zynmT?ViS=XM66+%LtvR4`>jT=lOnFKvLjPy;MHvo5ysxVt#S}6?{4hk(R z+1xXx0F|dJnexxDh8__no}gl%>oMXPrzv$`i>=*d(N2qB_$A<^7I8IDUtb>={%b{Y ziIcfQr$2PX6Dxf(S#zUKhR*!6H~Ip|>#UCw02sBkYF<(ssha*N#T`w5w#HBN{7mCcRTe9Dn+XDR+x?cm_h?>LzMIo21rWR0m5 zI{Rb2_(9g2*(&uGu+HR$jn4ZZjbrW{Iou zT-l8QZ)BL9B4%#-9Sa%N=w8|F*E-ucr3Esj;mRDYPEN*1x2>!vxDoFh;niMI(}tl4 zqJ2X^5Fq{x7%!Cz9UB*I^!Z)orLn1hu=h^NGNy3(kd}=-MWoCE z0#%3f_3&K3SecX8f+epCov2uRTjUQv=13d)id#le7uec!Hn1oi9>{48qfa7$pzY#o zXeJ73Kp${^LZVG&ZcZWDPBngc#jPIAlxZPs{^Q5Q#LHvx=6u9kW@5m1{STHRq7M0F-Cnu4n88lnf$%$7J$UbX zt3-6Xmg7tJpy7_k65v$|<4%eDlPgB~1ManIUTPg#SNykzabo&w z-NNG#-hQoT4V#g;h?|1{b1ax-$JPz}V|sd_0F(%5naFj9HP9#ckqZPEQF`N@8qAyg?-)&bvvoyt}|*G_q{(5mb29cE%`Ou-zL(x&o0AzHo{*4m<>3i1NOTJrgVrp(37t71knfc^9w^e-3fc2cmI_{uE=!x z*M!d%B>_9)+)UpEB)tWhnMxOag$@PppL_0JsK<#>j)G?=_f4^vJNNfL7}K{BkAW>0 zW_rc5mEOsHb?Rw}%}9=RUfP7XQ1aR_Z&!+Vg~`>8_0)UsbCsxW+PYa)tuIUh0zdHw zwsTD1xdSdyeK{#8C_LH;oX6yTxM#bD>I5>R7vc3Ef!gCgVrz5&er@ROe|x+Ur-LuI z@H~IK2JF<-xgG7&6mpRhVgF+*rx$bo_f}3Dv-fw3E~CWeMn}H-_AJt? z#2mefp-578W0wpcH4}W9H-6?71EG>>umKPhMav7WN7F%pJ(%A6&qN;mb4RCB_jP6< z5ByD+S3K*HFDdAiC;ijwo#=(b%86$;wk)fBf0Ey%0{d=AD~maf=b45>M33Aj4F2T3 zJMR9nA-=~4JNTHt}`Ff!V+o#*l&sJMnj3|Pr!g> zV+cfGsnenBw0!~oO}M|im~#Di6GUsPts8NJNx+8@mnjwMKadP423=YLj^<`FlwDrt zQO2$|FIfcAWt`_&LW`bYl;A~Iw*`S%)3g>k z{k=)n--7<+yr==F6QAOH5*K_b=%ILn)==q_0LQs%PFyP>M2>ECJ7hhIb5ri2-_EQG zd8ahnavq!S(K(N0>AywtS=cA99-r+u-r}l1pThyotxyiOo+!GF%*gEuBj|BCzcDom zjrNBr75T{Co$asxfzI$^?EZ;8+`7KFVDo>qa_;d=?|&Tca&$DOQgS5Xl=|fo@+&I0 zl`bl|&dhy95(~|>xy*46$0=#Q||jADqnZ?llZVG?tnaolZ8Z00ijK6QHh9_RcX z=lu8kd;fgD@9#eE_xtt!yxz|dma^gV)fFXAsI1w;$xhD;xhiBzfxbM2L%A}xpUqrd zN>X9_?h^E{I|n3R&0 zmzbMe29K{{G%hNgu5{w@Df}}$2Y?3Z>jF@yA^C|hx2h}sU8rd1?A7OWSYfNVYJXpU zqJ%F8`rtCr=yDKlb|C8Rg2N0Q5^)s}L#WX%yCcVZnJYD_q60R?mqfj&Xf~$^e3OXg zWbuIH+q}rbwx&x8v!~N#6~aD_P8o1$Q;V?Ta5(b-O(J- zWc}!=m_kZOfhae<(?~M6T#a0q$FEJVVmkwdsFSye+eE_N>b<5mkb&yGI|a=|7z85r zpO(*5{;l%%9&XM;t`nnqY%v$wxy-&yjE`gw2%w9-=}%vAun znNm~)HL$w{Ipp}3Y?tT@=tZC}I#uM#sg`uViH+@8-KElBW==c@4h05LQc`SM8WI8E zHed}N+aFe#VT5eMh!p)Y@?YNRIjWkQ$36j7R+W>N2UMQfTo|7~=o3xfxDG}aV0Yw) zEkyx+mFL>zTgb99fLii*0H5pmrt<0o_rPio0P%?GBZk4~4Qlmlc6zG)IV`>ozV%+n zH|uwUduEK*%_YG|`yQMJ@l6XDxpf-_=-+00@F|LCdV8T*19+HwoCz6h0N+QW6KW)e z2X%D#T{8{HR$<{3tjGz*>6Hol-j39ku_`gc;|@1}y<*kp!ht!fU1epK?jv_s{@bU#{7mtahI z`io6wc$xgy;(hrLh~K442BD$V0#}bn`g(nuA%6{E(T=0ofu5ctN-0Gwg7J~VlRud* zOrPj8M!+y`fDdCN7J$(-l#`-XU%zA80dQ|F-*y!3H;sE*{2s)$rE~ThV3;+N=B$iz z@{m%HQyJiGg%5#K>CMv82bNWwj`W=7KH+*qJMMWPF}&Lo+V>ZoT(x4~1H012mc-cIj^mfV668qW z{psoP;#rJC9%*<5E(t8c24;`>zE>8xzah z<4lfd=-G-l0f*=fU*FVy8#tHL2a-+Xwv4oh%y8u^^CnA5p~{Q>RjWKCHZ(BEuOkcP z5m`r;J1SEBknoG(((|+E7r@M0iztK_z^Sfe2(6pK>tPlFB1f1uk<5)mTyOl?=AnPS zc_9*S>eBsNwonn#6GlCr+16fm&BL`8u5MfR?~4RVR39A{VY14UyO(u!hS_!)C9v#3 z{#iPv@*GN%ZJy^;sHqI~)T<;5^~^`@l6LC4$Y`W-8ryVPjcGF0 zg-5Y@?DoM{x6fdfg^A+%Brdw>Y+O%}$oTOnX71FedN51i80g`VHtXgRG85?`z;YJ6 z8v^ekK=k+K|u*5PXa*-?d%`x3J)P>79H5Salxr zN3fo`ln0Wxb6W#H3XfO3nk$3{Nhn1w!BdCX%fF!kk`t)%TGdD8rKJ?$g>5?+@a>ff z-NG&4fM(@dqO}oSwGCtSMLDM7vY1Hmpoh*O8%JP-<_s#+y_SpmT>OGH_~zh=78MO| zva*{%(TV6&($e3_syiQVdY!@9S`1E0uPoAiNE{QMYnEnMo#GKZ9wVnu8ZYFR!Y zPL}s?`_zo7N_L!qc;)00jqko_dtbrOL>Ml%p=%`d;Ns41a$3rbJ~{)dZ|*XXu-rE4 zv7EtudsUUfpogQc(V-3juWZtr(i);KoRxErJY8xb^E$N(5CYi z$S}(dl)^eaaxon=o!*IQr*96#t3fwTf(x{2sf2jT6-Of4-4I<;O6_RmvV;_SJq?|~ zjd$&1Tr3fl`ahD)w!*heABhK}-k5nFcomMACR8>=OFl4+T935|IMs~kktfvnHKAac zeE&fuR6u?CiQpnybcux;vrH>PB zwd#D0ibD6*;CD0tF6j?!f@i1K9t5oa8df}h2^1uA=}AG7KgAR z!YqznDa380MBhUUemM#`w{E2d?007_nS=G?p9Uu$U^Dc1p!k9+|0r#FtmCL2m>l(1 zps6`iQhsi6rgW|7xAL=`lYyZNdtNhJX-RLz%JU8t(`5+}-<{nJ{<1y6ANjK(Oa)AH zGgKJR)B(Y0W*AmcX&!^vuhgXVLb~R>G_Y_W>;Ldtq{n@N zf&72{o{LcY;@LbCm=_H+}%CF-CYWIcPQ-JdER?( z+jINt@pkvWCN)M?4xFlU_FjAKx#pa!zROCBBENX^0uBxiS?r^bJRBT6HXPjJ_Xv-{ zktl6bEAaGI@1wjF9Goi|9NZTlI5-$M^koYU&fz^A+|DOBIL3FPETbvfMl+}!q^F1&L#lT)+~rT2&*Q(>dY%Kd-}Ve*%aG@!j)ztd~d z3@z^=dz;&G*u}@n)lk7S)K3=HkoaqKoZ%h#V7OmG6`huUy_kAViTUS+-p8~*Pp<`E zJOxj`UdvPb`ETbBfk)u!y&vYEVfez3Df{PWFvR!I7<_v9KRxvghp%qMjOeX==v7vn z;Y}Fy%ydr~?iX9G%PL{Y(B=@H@%S|Vm&1|!#e3sDvfEuLans93cZ$r+ zC*|dD8fX`j4fM#F9WmgR3f@`1=!l6aD3G1VtTit<)vZ2y?%_LRzah`2KlLkWF&$fQSQW>GmBtHF`P4fn`<6}3T$?vT~y(8oW^w*jl@D`gS zu@vXhVi)!vXf4bvl{;5?5MOehoK7Ys&BKuOco-QO@j2$@YC=#_hr+xPw$@iQR8=V` zCdSRz8Fk~txb)&T7>82Hb!y#}hH+p7CgNfE^P;puUF`%Mp7rW(l9IC=TpT-F#;1J8 z7%61h%{y9}nvz%?FL(IdbK6ERyh7e^NJ?qZGLh`=`Sz7)=ZF#cOp||JUtgDw)O@%< zzcdwgkoty{hI;6#$x~;fQ?546r{9yj0*QsM$f8Ch} zBz^E{L9v!$vG&N|=!ya^J@e6h4^NA}*TEDXH?SPnP!^yhTPuzwjc!9ciIoifHr2=6U$l_1duS zsB=cfT9jOzImLRmB^woIo{)bnh8N`+(lpVB@`tvCuUZH9bM>oxeHfaSTq`4^S@_H~ ztQOiPH(|`id{eMkjs$h~JFO9O|?b)|wt>G4}>tKwe%4cWYO9PL-dZ zUrLFT^t|f3SVLZ9*UfWY&8mx>IVX5O_Lqf=3~`P65QzTZaJZdcsCizuL0>r{WcC@% z%UOqpo<5cJ*JlBln=oJD8~WtP@bEN8lOCnl+`TTxucvw(?+U1>7Y1)BDNKzPJP&6u zhSK#q9rx?(gA2;CIzpLp5QCIjEQ;w*PH3?Q6VERe{E_*r=IZUNRw{6s+%7Srsz-;f z76VCdPfm)s<|nOYJE^HP>a7V2)R@A#Mq>vxYTco&>taKZX4Rr9Do)$2b$KPw!i7?o z#jk?(7WUY*akPzJgM%d>nwz{HR zs?>?oYOH))#_5Up~u|oh4VAwX!&HxyB zcPav8JR!F$ZNi)6}pKI%RcKHfPFyNX6jd}Rpb|&@OXeJ>2VI= z8NaJ#=%S{)xMXkAgAQl4(JF@=-qNvrM;mRfAyG=e#NxL4IYKNtX8K%SR(5N2=sVi0 z9Vd%A3Aa?f%Nj++{Mh1r3To6hpAOyT=-0%Y#l%dsJv~1S-)#=*pb2PaH?j^-mE`BY zfwv4Nkz<)b9b#B~>n?4H2g%NxU^YtZn-0!*nk#YyQo4(Mxm2ns7 zPO2(!AtmL)p&tUrW239Q7RZ#4L%wDt}2J2c6K5j+u+b^=aw{^neBG>)%+C6tgU6| zCO+91(D0VeSr+W=?Y+Of(FC_(V{<%n8jBT;Y0+T+s*c2iGUV$W3=<1Wr`feAGxO}u z|BN~X3UjSD%qH_jPGB+Hu-WIFNNKnvG&asHprD?Tpg7PxR#Z|sG<`@7Pmh0Q^Lg2D ze@>;Ky}vU7?G+Kam>10eE9dG)zdfJz(S<=+`t9u=>NX*{wCMB29P#ZOw4e^oS_ikz zIVTr2wb;BqUrVXj zy-;A%GgQ9V(tPLZf=Oh&8S9-YROc{-7;*9or# z#;feNQ8V+bKHPPUZZDKQoa3?2e2YWFMlYR*tN*iSh?a#>m{?6x zQY>ZFugrhE#^tANbc8bae)6}souef%< ze~m+qWT$}QuTV5TI={DF@s3}|nOoaXB&N8yxSL>G!encAH>=rI>Rv2c=Uon(z)R$_ zCwPj=%FS*MU)AX-y2*X4v9NYYF@>+M`Pn=~@863#lMXNkpa#jxu9@O(RV1*S1_o(X zu9FJ{2ydcT$;%fce`j&n8cFWZ5s%mU`XQ7D*R$L z{zbDmg4_>F+5a+YLjqXqo3cn9zAip!)rE!DCj8QdRr_iK3AFRaY|*TUktT!KBX4Jh zrjkw=D-a-=`T4nTYqaTvdTJEk`d}hzb>e(=*D4y4_6+U=BWdrst#1gMWSfKh+^vRWb^Cyqbp3Mo_ z&#LAl3S@u8l2fc4JA>Ueu=m`Kl%9iQW;CC-qO9@uHiP{B4X6H6>ve@1jq*;xlo7qA z%jE$N#HUmhM2PEo>Hb8UvU6?Hs<8Sn*|FCYpq3v zI&oqV8K3Af=!WjTDE-)lVmpW5*8Oowlo2$oDI#CoL`jwda7@DN3)ms7y6`w^fRe8nn$w|5O}f@A-rMi5L2ssp+-Yc%nvOrkHn#~8 z=2mXm6=#o%liUb$elDvOKR-p1Bqa($gaif#l9IC6{>;x;DH(lvHfvln+4%6bR`k-c zh?yD0EKji(xerK7RpuiCr4~qO>#lOza@tKC(a{~g!ce0jqB-rGv9ly>eAYzT?sUxP z=+kn(u6;eZvWURm77bhWfkZa?{*qK`7i0+UvWa`E0&Aj$$!A{A;L}F6F;+_`KLSJv zZ2MWL-h$js?s5ARA|_hm;x4U-7>NUh6*H}3O5XyJk*|MT7;f>2J-cJh&CUH79Li8u z&G??-;(l(y=2q1L{?2hS=FyZ@Ah)ciG#x}pt3s)0svt97;IgsAio^9|MaUpr&N8^( zwXP4Hn}^<&qo$;HAKV?t z(cze1*gR~Gdkjko`mV%~}FZ7)d9(yMK+g(vZjx86?bH?>GmBKj@Si zaOsf0LuOBV|xOxbz4c^!V=n_pnv zv4i!Uzq!O;6(i2%D{yy!g@b)gz+1T(unZEIZ%K4u(kSty=&;D|6WIMYHX@BppBHusErn5UJUfpO;rYW6s0V z^nJC<-q?!7Rum@x=Kd>cou+SDM$-Da23VqRRvA|Y5}k%pTCQ{-)wwK}*Jo!h6aey?ItQR86QzRuwCY$FS%@9x97cL}Q3Z}f25p6(&Se%RXL3@s=ugsPjZMfDK4 z&d6nrN`g|P{CUk`a578VaOQ&6(1Rvw#vxJ&Av}zJv8pK9!41VfV{FoTm|~Nh{}KS| zyixK;U%!cjn46i94y}hKG1k=xo@4H;j~UgA__09=nj@6?WY}&fXh3B#@ zNbBr2{2p2X_3jhYB-C!QUJc(@MX|84p8Naoz&3CCDf99`h6xLidFPXv*}USllGE%u zpj(Cj8A@%rIz6;&!+##;NO*CvaHCOkC&Gzr5x0D$qJC0Alq@2p)w~v05{B;|nGj{a z8_~7e;HkLXmA=BIQgY|G?wuYeE}%c2Pn3sJH?aV3bTrRJ%Vk## zf%i~iHrcUSL`3=M8lqlhC38>^AKANi+Bf7l?|k(2^$%vs3`q=kT!b+pIk@*?&6_N4a8r%$D+qo-*H z>0I{uZEX0s1k41D2)!r904z>X1hIes8rti)*90URUs0?Uj_)02w{R6=Efyn^!))_D#~>UAILXZqaJEou3C2+2yo<=k%oJ z(6Fy(zu|~YLz)$4(_NfJ?R0;h`mnUnXg$*2xwA>~TycCtjV5NvDNT8Qf%NthHn{$S z=V3|HRhzq<-{F3il`=cL#A`tQ_40fyp^bJ{LI(@5E+3yQ693_3{>8r3hCc`Y{q?%| zKTp;CZ?HW7w@)1)h1wk!7lq3)Y|?4xRp0+=)aQBT;I@;7X>j(oHViW#_Q%A+UOK?(4ny6iIjT7X9;c3W^eD-fX{*(11T4qi<<%| zK0#IFK`eH?KK{QUpj1&(RAe^Qd<56b8?o5k*W0VCpp;iFFD&d!^{OT1uI28lgzS;h z@WccGcj@7J0aQ*-&YkyRq2cnz>mlRk+m^C9VFXA=N5>|e3nnJ!?A-Y)I~*)hQWXEw zJr~9kayU4|kDgdmJR}uOAy^P0VKtY{YAG3v)H}zmxcr1Skar{`k+UKVRg>B01*n9) zpY?MW8**qN?R-o3`wf@5qky@SLmFYQX;m9Pym6|S*)yG^(W(pWT9xZdNGQ@2dC6pA zL^Gzjaecam6k8H#_f<ekBn@1t&np|HM&nU)YhuXit|l6;4;jYm1tQV;?)p5yNI@;eArQ% z1@)xwbKmS)KLoC(gN6F2*jRs2p%;@;QBk~7lEk`_>OCuMl1fU_mcTAJnz!~{l56U~SwfDIX zM`? ziG4I%Ty9_kbNj(_F@J7M1j+QGY2WE?!vfi%3ZPh)T{eZp=-60=EC5_53pEo`;$6z%&$9t;(`fT6{~>K zF}pV#vdYTXXlFkS(eE;`$a$YVgDJ-tPFX>V&xiS}G)c(GZx~6HoU_u>zGnI6Jv99s z`u-+}MK?~*P}%XL@oNuJ?d(i#FqAgN(_cItpQEO-pU2*uC)O}MT&JSmsYR>+k`o&i<%FE%he}CJeFHXfm+Nbg3XFzylojPG z_eaInLS--_2elec?gd?ig@v^}?*}wIP4lT->+4tW&$cUYI5_G!>Rjlx8`qAVdnirR zi`2i55n5OffCLDUX_XKc4+;uuBO$heHj@5Xgo4k`W!z3DhaL#Sx`Je!-=JeD&>yF< zmFlFXr?2#>ltf2GH9!3D1huP<%vArl5Uk9wOf{Cf(bm=$2?>c_^JIKj_Ha7wTem45 z#oiexn1TZN#4$QyJUBj-6cm#9Z0cfS$6sZ&sc~&O)xAS=|H)~Ey#ROn4?ZivPACE6 zN4DY`kn%*S7> zzGH?(8X3M4h>DK3pW71ZA_W}L?)G*;K9$kr$28*f-BUB(m<>ZYhBEIObHcFL`0%WP z0_s7MN&t6V9S-x;(mI!|b6IR{%fZ-<jv&9i<;Zr}13?Y@@V&5;Rw1f{a8Nay#lgVsx2Qj$Sc ziD7gFj8(&d1mUhu3F|*Y^C$F53JcvY@8PQ?dB!@G6#M&ztTHmB>;hR1z(8aRo?zw| z78aJ4$_gtf>6C_PupUPJRA!rfNw6I5x zqW)h6@t@thZ0PFh5{Gq-jf_BR9StfBX%N-rz+gy^;?eA#y+A^$dqJCpNRn}-I;Z-;ZdAXN)z`f|4ll_~Xtmwz;C(*Mc9LnL#Ih1x z^#q=FQT{8MyrSZ}8Fb$}x5uL~f!yoVR8;v3OW7|9l6A8L`h@ELO1M1=6awA;XOblF zJRB&S19LSAMa;0Xo#M>QD7DqG`ttJo=0gP~Fcq-3?CoQH{@caX3-vyJen@myvMPHm zAKX?jQaZ`G4Ju}0us2q_*oP8XELJxNh=YvPH53)c%Y~7COFUhcc|*A(SdtkW@3NT4=;-ipbbUEmjcOV?Y$c0tzRA!;wnJC@!(PJ*(t8Mn$np1}5R6;N z@gck^ZWI^!p|I2`{-m;)Pd_Diu`vX|>H+Js0OvbfLtMmFREc9?6`FeXNAABe$V5q` z(dJrZO|>f^cJlJYJph3MSJM7m5{c%#hlG&CWo^R6)TgFQ{QZl zk^k-sB`Dm@O4hCRAKDb{YKJ}N1v?TkO-wg&!eq0wo7w#2ha-7swS=o-xBsYB)|v$?yDy& zVt39W1%w$*gSG%w$g?-QnVUV)(a{8RQ?^i+VRTa7b)B3h7UrwV@I2F;xGzr>5a60g z<+EhtKnpl)K z(_1akWh6$Q>-L-(Y*|hFIJqS`T-G1FQ2fCkjvTjm;E?kIvMdyTzQ%5s?6_f@=;}wG z(rd0>HBd`CniVn7kw6<=F88jHw&7Kklvpg!KC-YVfgC(#LkBW0y4*+zmZK{W#jc-o zzh>*q5_H1@pSz%L*3;@DF5c^nypvG5;(W}Q1*E}dnN*R7_m)4)2K$CuaIC!o+I|r( zSb!QA}0^TB+)LaioUP{*s=c4-ljjpC-~A}{gE9VZy8X>RUrP{RaTS5`2D2uD^E!X)kD zN%;m%pqd-Pw!NuFgGip}qWN!!tI7YS2t2KW{MlD}dV0L>JqAr`SG$){tf0*e2X24B zd^A?{N~Qm3Vf;%-W<*bPer09lA_@WC_FApm&5*mNi>7-kK*aL*vX8SWqb}K*b=OCI z{|XMB2gfaAB?ALJU#(0DmEvq8{g-%*#<2149UVkqNAM+DEH)br1SsIr>JsTcs^3oC zvX~NWdiS>7&7y`()T+2QmWA{>=e^y%gyV~rD_3&9!X3XMpTh|KS?B*mXDXl9F@W>A zWzIJTcSUoaB*}g+#IuqF;Eo19ne4n~b;SEXb>Zl!r|Czl-Yjw(h7UIVKAfQz54N9B zqIlgvn90C;!4#V%6DacHB6dUwnO~szGugpV{hmDo5eqc3Pc6_e!PeJzu}wv3ys1Mi zyF|m&TA)O{QWtX|L4ETSY{3D+@$8DQe{{OdY!T;kHh5vWK|4UAe|2ItkCx=0|z+w%G;2u|#~;@@412>i{IdXK-**%BP){ACl|O z2%tuBW_mS$yf{CxrPzGsr$$MAGF!wJIL)lV4x-1FV{>$U>jEG0rr{5BB&($$nU-BL zW5hKauRb}-!fk)^6tXtk(**1-{6%}ieU_!W@&tkvCo%@dGLMUs=i_6eg|TR%=QYBy z(0jt1pcA`25lQ(8V=08Zc;8&J-4fV&L`1%b^HK=L98P3DRl*oBXA=Ho1kHs_ucs28SVy*Ndpz^Fe*>2iQH%@mR*PH$PCh z9?GXKG`Mnd{}Q!+3&j8KcHe4vQJd(Aa=yk;lxDl#J$~L)86p=jHBjab%s|5P8A;kc z8}@4WT5V*3@P(Wfi+6d!&AetirnWNH=;h~qh(Go zIEz{D)aubR{q_nH=QcX4HQ0zCT3LdHr z1G^!+oYqmN{n6eQz(Vr6i0{YshOqhxlK{f!vJEQ^laVYorF=bO@PKUy3)vrez`EIF z%^$)2>@f`ti%f-9xkEo>;2pGJ2*|(6*hFSdU>gpof1;$Mw9akE0$DS;YMaR5tfj?0 zE-U?@lyUxLq{7EcR#x6&vBh&DPI|n#Rk+-icEjR9L1OCt#c|UZSFEyhP>iY<5!U&e5nEg-)w1s%ctlOAhY#FKpOx?vs?Gs zRJ8P&&H%KC>kVJ_1$@TOkIo4`02hc&HI9p{O{au6Bx42p<_aoql(yKkcyKEyZ8CQNO(BJ)AyQ37t8jN${}r~`BH5& zo;t69rqz^CajM&=zMRo?FhS04vb(!mb~kVCgH2fD%}YC$!+C!+UGp)m(6(z;)gxlw zm}cBF2hWcZWIo1~Wr~XQ5FcaXT@c|_lWfJgxmQOp19;>Y1N?n`5*@hoh<-FY`=pS} zkTRF&ZaGmtqH?I;a*>3I{r3#q1F9@qTr^~;@~U46g}EMu-bThZNl3k7ma)Ex<2`|c zJ8CisR7o?Y$g|G#ZQQdrg|MCSK!mjU#hfd=lOz+wd!4yCP>iP6vVu{xqEZEEF>yA~ z1u&x&3oK+$f6~^Pj(Z0O2QOj7qKzgEkomk55s5l>1}3PTSs2-V1G>OU>XU%Rf{!{z z>hhQnA&HWr=I`eXR(9WeKP9wX#WWliwzx`3`M%`Cd6~bM+VM$?T{rj(;x>Z6wcO}&t=Mk~<$Um8st<}YMxnZM4(IeM%r76^9J+(%T$*gXj&s=KaJ=sX z8NW$95Gzz{0Af~@1JbEDLY<@S;oYtgFE;id)VWk*@$-Y`g0`q0^^GusSg`Y3L*zQ#!_kOTo_Ys05s^-Z9+YF$h5uT6{lQ76sCY_u z-qF?iSfIQ18gHWN9F(8+)%RaUn3A(;Ggt#qngtQI$GhYf+v|h7BoN$a43ckUZVN9> zU}TfP@>qgFl=Du=u@X-J!R3arZ26h*#?R3(6)iy#2eq!@pKo{XDsn~FQ+t7mQ|X{F zhN^bDL3?jv_g_@r)`oY&d}Kbrrot(uK{m9WZ*2VXHBThW#5p_z5lmY&m^RFyj(S(~ zslxN;=B{Upo7dM!Hg#%5w>;AC^Sd_Jilsg9L;AVES!0WJJuZ2VZtj~jMLw$3-? zHi2L6lt*7awwPzO9h}_a2{c!qA7Q>vZSg}tB_93Sy%UvUV?2DTc^#gb{hcWREH16< z-eLiCfmV3;P>_m98Nm47sH&n7I=5Mr*;QF}2PvwmI-l^QA=Q~ut?a#8x}N%m zhtuoaIA4f*a5JTHBmAwNyV2&pTV^c9kB?^c82rj9PmFEe>yo$0pNf`}N<0wezD~*7 zuG8N+HWh!&>WdbUd|zBz`hMSqz3Rlmv<>Q<&SBgX{O7jrV?#B08?|ID#C6Yz@Qjci z<`s(smF47)yZm}&+K4=9Xehb|>fN2)*5DT(@U!zxA5M+|IH(v~j6QbWwvsPNp2x#^ zMI#N$h^2;BU^q74V`X+2KV;Sh@7lksG`#g*9}aGKxVgFDFEeon%0FDh|13cM6FSzk z%7u}iFRe!7&&tZP?&GmmGfAo7ymc3(*s1xHY|Lg1AVIKNY1*w_r2?@hWFf{??|+bc zH1%q7u~ELkP>FU*jb|Q=tMgps;o+FobZlNIjRDK>Tc5M48asmB{>n;|1YU#mn3#=0 zo*TRd$goeDDpQW6^K1N-Iyap^xU1$_BsN?#4meXdO9*a}xMpH0 zI^Wr51HG+C>%-k<=G{Mr*`nak?>D+rw|`>?M&K<2wYi0Q3v6i_L&Oi(R1 zvDqz*>ThhgEac>|e{GvhZZ%MzdK0r6AbIe4g}V>j7#b>LS55sT(U_a64KHR3DC+|T-?GUHD5?RC>^-pcT-aHa zJcQ`3=EnC2`SSQ15|Xy?HUeM{0QmDN=esssm+hXnh`_dA3iEChz z8BA%B6qZT)H7W_|c{cu8)+;u=EpOfT)JP7gmF>jXTUO=l|3?kt@o?KBr|sw+O`oI` z@{%8W`GN;Vu=_%w$}Lnx#IGQhes=w$;Aa)Fk&2cc?QuL3oRn;&MA|=`vKo0mfBwwQ zkBERJxQg-gRGJ@q(@#AFo7 zfsZ;jyucd`Oin-~xv$X`6IiJLrq-+V_0_I=8ZCCKu}LkZ=;&;+PMC%LU{X_Dczjm( zy0kq(h5ksRRHBotRn_>MQ_-c6@ebw;I_%phFxze~)?6NX;cv#q(#LV>ZB+K)u^s-j zieS&6oRI|Lo>slJ}U z{^7u7XTII~J~oih6BnOwcCY{h6b)}J^~$BQ(*4HEsc-I{*Of$?IJ9w1Hht3aN~DaX z%nBb@9yNJfLjUM`nVTTujuB`mnWA43`2ID_H;Axbff;Lq^~Ii_ssm!baEXoBByJFp#dR`!sc)n~<4LSWqr4;T}-j^Z?6P^V`#DI;R7uW<{E}TZD1f6GiIq5 z$U#4xnYWF+o13+P?Qbph$*!>N3rPF$heKe23jZd6P`10U&^k|ZWn)}*xW2A$1pIys zbka44u$;_h>}Gh09s;e7>3Zvf-xB`1Q|tJ*Y^0fWG3f za*7CXzb8i#m4?L(OG4hPybOH870BWH`f1E?sDmIXGo1TpkSoRiC&tIlx+lU2;Z}Jc z5Gbjr(iaPXCIO?ume6h%E+MRLa-DurGT1vv=EKhU5`X3W(GU|31_p);cS>mJMQ0Ut zB8%R4V`K{u8qTr;2#~-0ic>zkhbm5ynYdCgk9D9s#6%&mZk%4b^R{F)Hg54le9%U# zi{$hbB~j;!ffSDInNCg5_ptT7#SaCy*81KYJ182qb3TKl;*gG)BN`A5{j0mgl zgcx3e%{LQI)@J;rciadDaFv-~EV})S$g_Jf@`{5ZV!xXiVdY88aIiz$KX05^NvT9T z3wNt(#Infcf!{jpU_sr)`1PADH_Zm9DlOHusw9G(qGD!?wY-|zs!e)6NM)nXfnPwI zm!+m=f0C4$IjQ~&KLYw@s9!gyaKzD1d1kSbk<4U7(k$*}87|g8zCd3-bjqriFCacY zBnY*ChY2Xt>oD8RuF-;2=HC31;(v4+J{j6Y6IjzXcpBD-CZJwzuBNF;%vfr#@Ui|8 zmgvCS0NkeRZ=N8a!^se^9z0mZE>exwgrEIaH+G-~6cylEB_V;AK&ptV^@*E);?@8K zoNR{|TA+$Ls95{TNhyky{%P|oBHik%$lN5`56GYwX;1C2zUdd>CUJTkv*%e7%KJ4`HRI}K7 zw^ojYZRzwHKmuS01De7U(X`jEw-k^-HV5mj(d9;3|I>EP4`z5OT*?g5l1VWQdgEup z-UKGzvYLwBh80iQ^yex~NbbvwJ7Ilb#+Y-n;{>uYnaHQf)js{xrj zHpbLGf7j6$)!Qq6!|P${5t&6_qy%)M_d-vUqpz=K*s_SF)1V#bM=2A|ovGS}%992%soFf| zPe&@sE92NbgTdP-4or@l8_Z>`R%GI|UZwz_VrK|pUUqhVZtfQ6ASpQ;Ju#K~KZhpX z^}Du~X>#Q#+Mfvii1B1ct99fvnnsL;@uyFV4GmXCS-89Oe8X_VKNFxK+<)?YBAXQ< zUg}2#F)@#hhjtl{^Mv!ExntDGP9#3b4|hqyhskjCheNSlcDA+;jxF!|ZH|mLKht2F zUGJQ?s*G<-?Aii>R7CmW=e;9FR~Quy6$%QX+ZnrJQuDvHiG4*|u^AA}uIrB=+GMt( zJ0<2=A7a{k%C}E)^xx_h>7Kg&+0x)BXe&E_U`S6J2>dwkP0oDKXtiIUV<=vm|08 z$z|9a1;H@{lX6PMu22G*R8FiD3ikJJKiyPYfMw<*VbL&?!so^=(y-~nqmU(NmU_LpF+yYY{1K=K_Mv=;gW)r%C_BF zG`vi!(fD(B#*$bh8SLg-*vCJW(_!?3j!bE6aB%QgCJ-}c-n z@w~f{No1wv;wn(^m-aye7FN51gIn8s1S2DV2ucT0kPJQ_9SI4ECi!`u`|-+ChB-6> z3=0bjHH)l-gnSTy_KHz)3xx$Fk3kQ`?qrcxjc4+ugN}`E!LRku*9^L|XWOBnq3iDV z;TfW81H3~&Qh3Y*9JWB4iNv{dA+U!u{>Na6=h{}l}!ySu<-CSl+?tU zf&&7GeN^YB19VzaQ@sYFwHh3Oa$kpoCR>rK5=IzC$Ol-4TXYRg&0E;H8B)-L=2J0g z>FUaILO!TcKdX?auq_tW`T03w5Z3L{?;Ry-L{h0?3NYE(@sDJRVSokUa`KKD!h5hcQ{jX5)a9BUjYmss z0fK@;E`zUErW&9IH51CA z-rn?YA?IBYuejW}1OlR?mGt$at`-WbNTLRl8|ItboO~i9qqBUk%4liVfvXWgdjkji zMap?Rm(yT*7Vs5tO}0G1Nl@>OWPqmDgdXOb$0Lcn7V54denSIu0oqOdJaBNiI0JYM zkE)RBz4*57Jy86|8dQd=%%=7iTNZ7$-h-Vg_oJGaS}Cc2+vkcIP?YFHq=bcqt=;Hu@Nj>3KK(ntw-@x8QA<#-R(HnIY1=#6 zOpc9J0BYg9iL^ZNj38|f+H_Oy-^Fps1lBTLhRCTiw~g*x`cqL65fL;I1L2Onq@mPZ zK>~uNrY5V!I>Dy#(NP%Vh=PKGgCk*Gmqp1^#H)0$ilq1z8efq9T0OqKJ*;a;HCd=< zx!xp`cGqkS2?<%^Ul>g0dL^);tA_}wtE&TX>di%CXlS@XL`_3en4Yft>&qjSAOQiv z28Uhs{T{q+;jZ@S>FKkb1+KU6-@kopJ=*>q^hk`1j1V_ah5{cyxUYvIwSw&8X3GV2 z20aOc?dde0s-HPIT}q0Q;^LXwR20^HG47b=tV^jm?r%6<$FilUspwA5&VUYcx$2MCS z0w0})guoJ;ud@TN3OEFrpi1-Pkq8h_V}X-~;(59{X4HURRiR=6By_MNyb|!dxx1{k zn6tdUHUjDUFEd4%$!e!5-=~2yJQtrITe%cqRe84;B|!H$*&wLA$HxZp=W%=3pZGXI(}6wUIV2^SNfh+tmZ; zYRtvPeiRXz)qE|4iMnsDZ|-!KMc|{j_>K=E1~#px^Y)4`2ghL08cvD|4?H}4)}EoF zeI+OuWIv%*^{+;Ewzt1XiOb$5BZ;NcsjjJcxXzJc7#RT-*I5}Y9o5Nk-TmhF7!_6T z2m{0E<^axo%7JiEu(jRZ9cb6PuYZwE7}aN~`jbWk|CFh42oP?<5|u;V zKfU;HUuIc4jMBii50}6`-woannAO4GfS>=huNUxmN*UFZtOa)b;^Ko?j`?kc9M79_ zKL5M*uq#p!bnn``JR-d&ivU&z9irG9*~m@-R*bH9H|OW|8@L2m@n}R-Ejl`sW;bMl zPvCTXOWykC>ohnXd^+p;(gOV^AehKv=4fqw1Pn2qmfcf66NNLy3k~1CedBW4HZ?Ku zpnqFhrtt*sE#&rV>`wMj-0=VrdgfG_UKgEKeSaK7K^fjjv`NuSepcfxKsZmlzTv_x zQBMe!2-LB2E!MB&;i7xq-*Pb7RhVW02C9XC9SwFR=d3zSS{~EibQ>3<%4(2a$-tidILdb_)W3HEL8Z|jaqOPBmnJr`PJz)%0TNe7`o3%0q_%|Q_#|L1(}rDmHb z!1QP{OM>gg*Fq{0Gh9+qz=Nr$w+hm?8&?B3^5p4L@aT_cYB2O&?FPj{U^DuMg`?e!8Ql#Y(>cNk$PE|W;9Ue2HvO;MqTyZZ{pEAS%E z>kli5oXaAU;xl0`4=69MI7Sz6+Bbq{u`w~Vb#;h=t{0^&HKnt)Hjx&W2YOwh1{I(V z;_NW2=-{9jK&S1O3M1sf$HQZ@Uiy_cmYtZGNW|ePlyo7R8-{;;NGQ@Jq@kgqtnsHP z%E|&EM_SJF*lu%>)F&8^&2zr?Sd&f#?Rg+TBJ~btQ$;$P9t*MRf_(a5JEn7L4?rKw zmT|JTr`bFW!{_NWos`s{Mubo+)9BQhX5GZMz|If(EM_bB_6ns5I7fzvj{iISr4oxl zE~BQdhH8T^j-Xb$Bx-u1z;*&`l2v{Pa6gv;PBGo+-!8MUIXE}~BjI^}X$ga3(rK0J zA9qEJvY1U7>g#W;ty#{LD+y*y9DNox05C0u#}Z`f&9$`@j@9CvoSX&q zAS&0Rqmh^*jmXGI;FyGZedP1JjRTN#fZ9k>DxQ&rujSKJ3J<`9K#+8szfe_E|5M?X z^F(|P46H6M1rGFA+9nnT!U*33L$PNFzpkvL!#_s>)f>37`;$Ii-c))DTL%ZN)0T+> z<%h#RoMkkze{hFx-Tn-i&P33-LBQp>Rc*rLy!0Xb^AxCR9eShmM8jNNuQ9Q)vvZ7+ zc-%tkb%mI`+S$w|3)j~4AKV`O0qX@~u&Z$_Vz@7UV*s)%FpW*ess8TE?WR~B)#h>K zcD)UF*gkDA0{!H>Qzf)CG>8yjGh08s?o%n;8f(ZoBH-KxkfrgXrx>k~p#i#pqLR|n ztVlAcvs&Brp2%UJt6BhEUkPBYt!HtkM^`DCo0}^cke)LV69=N{TIi8TyCFcppu{Lo zu68v9y3}svek@K;Z*J!OT;@&0tJ-8r3Tf}`Bs*6!{rowt`vT$W`g%(JVVWU>%~JJn ze^qdy3mwgvWSUz6EXF<_!EA3rvo6an`foB}c@+H?O%^3XC> z8le)yvv+R|+>HgEzztg`=)TCx&R3R|ee0~IsCZ?C8`Pnsh`+u<>>Nk|>bw4aMTH8i zKm8tdJrP;>9D`|sUOwEO_6K`vObU^%Ch{&WbxU`5c7peHE(}fwNd^+JU@8i|9v*>v z$`B1B^I?t*XD!vFV{3V}HG zYnglTMZ&KDV5;vP&e!!VG&qT}b11o)ORW8fjyds3TI z`RfdpRNaFc(YZKc-}LMp7#J`ZHzo`tr${F%$EIZmbLGwbK~0WqMY_zoOSuCV#lY87^0;j^H#oP?(W1uxt&Q zJXtN~Ki;w0{2TDOSaY*UuMNH-0Ca!k&60<-dwY8)`EjYmB~npP)YjL7mamcx)>ruB z3kzCGN(KWv6I8vu;Oiv{REj(k;;z`wzQ;8%-H8xF-f>d(wzdk0iEXd=f`u@ZyHsjx7(Wo79q3Pn3!NIzf)hmjN#_yrlINg znz;;0$bS>mQN9oT2K)Y%R)aN+kVU*yO~4%=vNnB;@m%O`JG!Z5FFG=k%k%b$hB7NR zjr$$!+#iMD)#lGL(O?;8POsm$zDPLNH#Z(s)S&9}3QNSxJAAl93JMI30iC%oUZ%&m zG>*oBY>F{<>^DzY=v7AGA&sdBQxiJBU79g&h_AvpJT$LvSD$IGKPYiE23*`Y>9 z+9U1N$^;S40ukIuX}Rb1Zr-8TJeUbWiNw10C~V;&aMvQB6DO)|FuCyS>K#TU4zn89 zH6D9bYkmw;*V@Vbqp=Fh1FxpcQcpc;Kxg)MzweiG;@JL;%Y9{%e-P#(yi)e*z8hct zitA1YEH5y%nxy$ZH^O3tqqaS22=WXz6T+3%6`ZMQAdC}o-BrJCNVp7z(tBQ~U~CdN zGDY)54!74JGu3@N@>tjva;1k)$W*4rmMQEeeuwhIPIb%2&kK^j<%yh}VAPLH zS9q7oec4c;vV+?9;M#;IAk5VYLO?iMJ?rPT-(AV6>Ubg3k*Syu>1!+{C9Ac2<)=fh z@d3Y|CACV@{tq{aB=lQ#_np3(&OGCJ1f~WY`Z%Oon={Qg3f=#d;@WQ|_la&-{*uM2 zi~=sj=8P~D(bI)Vm4Y)zQQcu3>nD1p3h{#1Y_|8dynQgvmOFZ2N7Xzp95d>OFGuJK*cLZ_q?Hf3Q`1-auX!_bwH@F+ zxrwUnu?T$PJ9pqpIY)boi;I|OJtLziSVL1?0P_Ol;(mv}Pc3wC`9NmAFf9am=&kYn z&gyqD#0p9N9siCF7B=m7UIe-x6DLjHuyPE1K4jJU^5keI_$qV*1j1uLf33EoEn*y{%4H z6V`ohqL4CzuenHXqqcXkN4NFvle*l@%$0?Jheo^e`NK7%P%_s7qzNfc6sK`%Nyz~~ zaCMo5$gMeqSU9)y>2dXqccP-Aqobo2zb|N!@yODeQ-6CUx*3;Br{i#szF7CBvISNK z?3W>HG_Uo_D>mib?KByeU5wWMhABq2O>a1ujuB7JkPiw%e?cK9hB#*6qT#2adT)Hj z$6g(O12}3lr1n36RO<2-1i{iiI@LQSCMG<55k9f9)gv;jV0mQVw9a#S3eCqX3{8<4 zy>2m_!=@heU@s_3mX=ajWESe0WgsQszweI2cZY7jtNW|czF(CF*HRkIBZ4eU9H#1-7P%{gmX0fBg$&Tnx4LGlHdL1bD0#ewhqQYl&&P` zyUfiuiHmsTLqb6D>m`%*?yO=Bn2yXuMj zI$(ERT18cL;MjC0z3;3Hxz&$PctcG8cT+W7@puf3BRyGd2u`RP+GREH%Ro}3hz<~HWy|R69>|R{< z_Bw4dpKFiKH(Jbtq7Uk|Kw{DfNhcr@bjzg8-$z_cM#aDKyExhw4a$E`?Wn>;Wk zSs^o2E;8BK+ynseW6DEuB@NN`r*bZ*GsI@(o%Tr z!oso6uPAsK#v2;m_pMm8aM`TAzyJ{(h3m}R99$Qmlb6r`KuQ@F6$5?Ss!XpCaarA_ zqy2$zmfwH;=0Tz#M7P6(BQD=J1We|x?(PMlaTuhpU%v(pz=g}#KB0msdC4yE*g~s! z<{SKZyzdt~5qe;9@K-iVF<6u>%*P+mmE?bnzYEG)TY;qYs>+i?Boeu5HbSc{6ft}| zFonnU!+*zK7e^lAq_BLkNmxvINvn|6+0z3MP_A`b1av-@*?AEYt7~i7>FIeoI|cyT z089f_FhX3+;r^j3HV*MEfJ&kAx)0i0P^$UZXs`d?)f|)xsXV24#HYBp$8UZ;i}&Nd z^c+A+iP~toR&l)Bw;`4s?JYMJFcK?d?=Fld`ueq_4ErYJDE0-%q4xnu0lbiAX4Vih zdV7snO;#X}TV~m~<$30bo_97~>eYwiojrTvwk|&Pe_^EL_8KYwSObT|kxEB*j)R~k zKNb`1W!(%gEhl!h*@X(+5RC8pPNP`$0 zG8f+OEYG#8f7UqIOqvJjBS)oe7g@RMQ62K}`b{kLDyNfpe)qdpROBq= zw6t6Yv)+3o-OCibWqZZR6-cgGcOY7sG_`)++}+$Qw-(oOs59%?d!N76v=X(_`B;ZU z>eR^z>>YZa?2N9Z^`tre@%r6*qhP=Yy}=CCwmfq8E{KZ(;OT$-rvW_5tEHl|)4X+faFFMI za`qkDz06`b=hNh|-e7Wx2yjF|6G0a_`H{6TgIbew@0+2!FxL9@qc!PA^7%St*615I zZmf+aXC!~zsrdcZfHJXIrdkNU^Vwr?sT7leb|RlswM_+)DHD;$djthM>nC55F6m*2 zZ+Bc{kLr)eQu^A0KyD^wS4}*1Z18C?dH8r8&li7=_+@3n+lHh|ab34__~_LN9?wXv zLm%wq{gh2V&#Ijr$mm$B($_R{Z(hEiDV`M`+{4GGFZxOGV&3b)Iz9a{z3bS=V%fZ%sO>GU}J^NN?ygU`o(@++& zT%mC7wG#-)h2Q%N2)^wKmGQH~=5JFg4$C2?S_1pq-2yMmTC$wv2fMzAW6DA)(h}On0*ENt;OW2Rj-|s^`UY)5 zRIhy^(*F)27FYvl#uoB_XxiT-o?4ii!Z}`;3}e!CW!D=oKe#DF*#Tfh_nYb%WA1r`jP={XwC!IM~@z@43_i^3=Fi42(|8d$ZnD2+#@5{ z?n;d1!zl{E&eN#h{FotM8henTes&67xt8|!rQY};7NP(_elexP$`?=dg^9Lyxxx!r zw%k3K^bH;J!K$#b$i)ARIgU|ELgr|Og5=E(!yVSPI8VITl_nB z=4R(7%8|?dweD{0pzs;WZ(IdSP9Q@v>yGYhZv(mc79TOULxDCxRx>k@B0zBVCqbkn zCsQBCs{ZvX2q+4fbtU|Ly)A5P7#SFLx2kM<_EN&Q+QXQbCl`BqoKNLcRPNukJe%`% zwn-vk40yNc2B(0@t173OivwyEc1FVmxp{f!TOss2ubkV0UTAsfbqYxyQp*||qLdBA zqo2$Ub|nIL4hRj91?hIgU^(K@hzq4F=4zDGeyi3n8O06?sbmZ*5b(-P2}cO{2rCan zX1UMHK@~A#5?f?>_es)skLHoGTaA|p`2I-MrMj4UYcdA(0ejS$o9km+-`dJ4Z1_4% z!^T#ao)>6$@7As77`cVH`kmIhOI_1l(?VdUx}^OV0{wh#e{NNoH@3>5-U}$c{dTGq zV50GSQ`F!oGp#o^Q2shGpqHuXBqN24Aj(Fjb9C81jzUW z-tpRX$Ht%v3;zO~m$Dwx-BAvqKknF@^R-$Y@Ctveg6gcz4}=+k^%dd2eMIXlSlCvQzc z6L!07;4BHR4_{2ltf4d;sXTv7>I%D7f7}Ra4|=t7z2!n<0s;akBkvzh|Mu<*QDrdz z3Mjqm0C0j36aRx?up|=&Aah~L52C~RKl1*B%SL)~a#F7+>su56(Ppe3f3}CdGFH_w z`NnQ{h35u$o0d z&dwf+oe&?dc2cz;7!x_*=s@<6%yj?&b(68uJ`ksAoeB_WbbEEdS_EjS4wPqEoMQ9U z*-j)P^LKW4)hdnBYQH_><-;2k99+|DbGeK_NosU;juzED@H4+=Pad z{4e^(PG0~-Ixx<0JKI?u=xAv%^dC_(vo`e*rqxk@tWOd4M?BxFR2YafhJH|NRaFm0 z>aB-D@ZLIj7T4kr2*`Z;8ywo4(Z60FB zECM=}1{2qhSNA4H3XRJS=3}ia3!dKh`84pSFaMCmteJc(p(u}vpJ&= z9R;A+0Um$tey8)N#NwOhzZ31gcAFm-q`?KCw_P*wJdT9XY)S?_mef_HKG7URbyAKL z1%_&JODKB6`Ez4i*6 zUftdM_`14)S^_l*I@-y^(;tf;_8Co7^0yv)vb6pgqI&nrPfGs?Q5xmwE@xS!O384d zy+0c#=Rh{HU(_f*O+SHs)63h-!6Ew$d061(Wo{|fdW_s!K77so8k7<9jA2!8Cdr(g zt0c9xVXvXhEO4n14_u9g@{rq~q+CLKC(NH(AJ@HgcG{eQ0~c|!-E({iOFr{ewMH(u zu0Tsg5}2^U(T`u(B)DNQs+K>ZYD5Mc*KDQh{`Om3(?ed{yQAqJ=OhuPCWgI*u| zK0esMZNz6(B5${K6B5XV%KX&jlJ^~YyTk3pF3m>yY?bDDXPl>MQ+281Wr_;w3@RK| z&{xWciRm@n!sc^2T%?ke>@Zd?qEjtY(Xt<}um>jQ+>ph*c+_Ji8qUpyNkpOX1gV5Q zfu2Z@7^!EZ$WTQi{c76?H9~B>*o{q%{RL#yfaRuuQ`bR7a^A&^tAse3+7MHffX*1_-og% zLB&PB|1h!hmChgBShXrEwrv&;$E9A=sgt2O z24l$z8ChACqbk;p7@nhLG&6|k&^J;juD6PPA+i1ClO(|0`!zyFf3ad*&jcK%+0vYV z6)h3P;)(qPxjEBd(xR|}CHcQ68gj{St=)2Vsz!rqUlx9Zi&2PVc*)<*kb;QeMc%koZKC05hk^;a9^^ZV0_M#a><*yNER!0&4>Ait(d*;YUv*xt&>P4D?BTzG<5UuSNB zl|Eir-<(?3QVir<+Nh6UbOfnNmz0z%HkL(CB5;%&8m`~xc(YWnI5#&ZFO45;+ClK})W*i?sk3W-el529lz%;yA4w?WKljKEQ>>Rp z>$|@WH#So7l2&(rjW|?LQK8F2ZF70`C@qP5Eo$$UPVwviq~>E9;a4|S?fKm)b8(&+ zFPBQP#$Z)PmFU(zWW<*k4&+yWBRr;G@-?|!LP&F6onT5s3IE@eYRj09He5|_2a1aZ zjNR5%!gXRhICkzL=? z*%%y?pSzQynG~eD8XL<%*9&?i;LJGR=cRk3o%_nfezzRCMafKS2o(HM93TFM z!IKhRa!)QD?4_21sT=)>ceXdF>FC5HJ2>ojRThF^VFD?<5h20Zie+rDm#C<0T0*i2 z2(sSaB&2yFuq~b}G@(QQ6)Um0!z180*zXosoPCH13bLQ`DT|3A-f5+Dv=@+99{9c+ z%;wZomzG{6HZ?cHiFD$MnO$MGGgzRnsjWRbN3Xn;d#WV)<#Ghyl=6?_Ys!dvqaX>L z!qF4>I5xanOmuUsqg60AWL?hi`|B-Bt{^3)t{(gORrO7hOf^&>wuYu=&>;=#^XCsh zGx-Ng+nD6fa_d%hT}MY%_F<#ny$Z8hJ5v&pCC>aWKn{gz1ej*g(}j?LQW?Allv^nn;u%kRdn z%YNqlK@S4T8;|9JKe!6kH#8Uz#m)dp>)hj}(1DN)} z-WKaBbXc@0w5xYPQrV`1kB5h;P{TMsAa@4K#At6DWr3K6X!^u}#lI2v_N9Mk8ZmMx zg9aBYJ}}0sg7NV3EqJSL-mtQS24kh;{EZ!H(PhNIm%n&H6fE&h^8ZCq#}V*MN!wmf zR^I#cqECsyszIo9*CR7ThoI93#bJRqtk%i5SsT2Ei)*N%@kFi~VdUFGCo^hz!dtklq*2txJ5;_QE5hm zs~yN5`$vCooIW_1u&_b&06(%u1GC@eU+Ot&M!eg_B%-`m*$CG=ESQ7;iBRZ$c@8DB09FOK*zbzmkV6BRTf6l`ksT!e31_!1MvFLS|=ow@Rty zVhoS{oz~cic*mV1UQ{6!JG+8}gv6Vp!B(+t8O!t6S=^Sso1p%;R*t#4XDWpwdwrU` zOZWl3xG0%{PJbVIr<3_uWgZ#dR0V1rA!{n)M_aBtM}VuhhjLL z{hJZmfA3bW)FAj1mNdv-DMuy=TMA4hur~IX5 zWk*p%7AjHE(8~f_e8`vj3nRgMsc6F4bfj=~F4nmpT#(;zK+X#Z;_dk&&&5a)LnK_iW1^8ZkIMC%;-!sx*T8lP(puPaSS$<@W+C>e%xI6a8WtwG+xmM@Lg!wEHe| ze)jGQZe~J9r5c5-=CP)St^&`6z~%b#rOQ$++-mw_Vsy)~xRkt;i^rF?@EKHQ(CEFi zOuB{B6CM*2*Ml=s2GvR|kpSE1M8~qMZY9q#JgA}kj^tiQEJn~B|ait;tdU# zS_c=S$olX|?8rtQ>Y(~kemQy7(^~N-CF`Z}UzDu6Q`|eS&>B{(i#2XrP?%LLEmGV5 zc90tD1cHpwLaUZ;?Uu!nt+lbEEM>Ni7%tN(S5K*~0HK>_xyDB~m_{D9-QL{W0!33& zNSaP8r{iI$`YzK|@S+XhClqC)e{L;vY)~H?zHRC$4JjiV{muIvacM&$Gp(5!JUAU< z)DuHX+B!7@-ak52dQ1O8w2G^Kof)rR(|S?Q<-e+%{})CchF;F!l0EYuv#TCYkAm?) z6>=*jccgm;dg^7@NB}@;*U7(0#6^TpNF{Fk&u$(&TkJB=?t>HjV^*e=ka>C)232h^ zjL9^8?DkJC9iN=spIwNd-t`@!NE`w=Fc}b#hyF&Eph<({?N2DzsJ;?6rBrRmh&A! zj4G88VC?)qjJCVQ=QL3TnroRzqNn=@rrL~uc%*w~0`~%vuAaj_u#kTIq!&Hb6M{WO z^5zW4aW&U1?sGq&*y-DvXZ#du#5Q6)KWG2;Ch%si79N<5)3M5Z1f_P}qW(|3#-E)& z>miQtgLB3bJ)ULbZ!$cbbr8GXp-!&8RVjRuY!g75;D7VF`?C{O!5gU|V8r{J9>Zkj z-<{Hx_x&rpajl$$LSt;44!np{{e)0|Q^2YIaI1(T5s$EV zrWHg^YzQsNrbmy`S*!`uzc;$U@q4?qHL+20V&^6cvoX6?=kH&xR41haLwdBobF?+M z#Ta?q8xCgU(Of9_-lD&DUf}aU8&@r}n;L$~0RDBRV$}h-mk*eZGcYpZr-EKQG~+kZ z0Z~;|RxXgu9?;%=$3Ki>b_zJoTZ+97?FC=LX@|8uT^&J6R7zf+G(UfZ%oW13-yax& z)2uh-g>^`#xd03NG*i!3l`vBOt+ojSDG5m# z8D$1T{!&aE?*7p}UVf{(6J?_Fe(+T<9(B8_K8x2z9F0!~bS>Vy2kOH6h=v0&D~NFw zV(A|Z`a7R=uw~I=e(S`d-+t-Pp#GlWg$s_fqWQ2+>?#E?D!@Q|LolXiom5*}8+=n4 z7!g&)#rrDCvNnfa9WjL9ZbVm49Sko@3cK>~-oMZJ4eZ+33&5XWAcZs(G zs04C8eQE-0T4al)xZHC|g`&!-+TJftj!;Z8s#nN^fAT5+A)chEBabSx4DLp)1V(OA zJ|Vl^Of#b6y^w&Hv)*dIlF*GcT#SJT+J%#B^Pn zx{{va>!lmVS*rC#2Wr9x{&olVA_vYo&7d~!rhtF8bR^HoHSwKv?ddX4xlce<7A6*%8yvR zH5347X+^0`U+2r|HyphL01+(KGTi%zi8uQ{n|M>LxMG)$kI}cbxsbR!y+zMiq84R5+a3 zyz9YO`f$aK7Cl};rQxr$N}6u39*`((!O*K{{LYv4+!veV#pIX&G`fl)dHCyBrI<6= z>nmLq%M>?rlt`}G;oM;xM!uT&Tb1kU+uGdBP*qP8ZEn;^*EtlG?;ClT{_uzIw$StE zQjGN__05lX#Xbgay5ds)u(=3IA1b#E8m~MO{uXX5srxMKDNOh4`3?)We0RK3YgPzrm5$Yz{ljf zOEzn8%5e3CL*OpHckMrQm4OlsUKDWbu(Mp<{7Ag4--aBt$5^qFcdOG5aw_}#3*pE=Isx62P z1EMV_Wj|sa1`Xl#v3d#xIjNfo%D5QRFH+_dxMJ6YXb))GLI9u;XL8#tT^msnwFq2( z1|NZFod?rSv&n@Pe0q48{-BOE0er} zg8lI$0WG&+U@`q<64WLCWYPPv#rvtMWtQE2eY0;kAb1&>8dKNBW#T4H#?qD5qa+*8x==f>JtK{)=DCzTNBF7|mb_cN-cfY12w-lEJZHyEFa^pph% zy?|cz&yA+x4!fpbUT_>MVNQKhqH@QZV!!L5XU6x)o0mtkQ2j8%I502<|MqQ|Bd9hb zvs%OOQzvJUdnEpbyieM+LykF8krv_j&D72@PZ{^)DG#xQ@9ma?GyvwL48EXh92;?- z+iIDf!i5XnnRmxAxO&5Bw0hWl(=`{SWw|>0rV+zN|GB~StX?;owp~x;Wfq4f%!fDv zGp*Nt?}WH+LjDLd6GFccQ@}K1pFi`l?m?s7_N+)^Zo8>j@x5;zUU{3l^n`w~ytKAP z-$yuCz82r0kqxSu-%`jPdQo*fvW<9lDi{I+9?{s~D*VLnjK5F^7c><6EKGDT@bFAG zZ6GMh5Z`}Qo#;>1W+t`Mch|&oai0HsW1;_lGvWVDGhui*|DmY%{}Z3uw&)#7ET+G5 z=euVyi;FrwL053zA7W#-?{}hp?;mpE|8cVTfAk8$;3%*w@k{CL{#u$_2DZuPI$!>u zkl_9oCZPXE-}6sXbIl)K@ZVRV@=5Xk+7R}L9(+cgui)<=giw z8DW^X&QUh=v-x&t4+-VkFGbYWwfC!b_gP%$e8+x2Y&7UMY8v*;<@M*6I0AAji?zkM zxWz!=AldG-$1=~)aa@CGr8{z~hZB3tDAour%b8GgR~^Z`JHmFKjA70Y-4wcQWwo_z zuv)ZAC>DQPHh4y<(3T|D>lpLgLRwLOU}&hn(`{+P(*mOv*9MaZ%<^y$uDmX@JT7Q3 z@hBkobGUGQNP!)<@{Les`2J%G`K!q~aXYo1eQJaf2Fmx8I4vn$>uj&M$CU8n?L@GO zH4cB;AzR-+caG#YVS#6|_sR|;o93T6tI*oU@~=VzOOrP#qpx$#;C z=_3l)SJ#)vt!jQn%9%(kx%YlZ?G*u6>;BnTO-JG@vBeC7=5>YPUP|d}`J;LS%?ACM z??g?H4%&NRK(&rE7JQYI_v9~I?4rf(+V9fnNq+mKBM|s6rNaDL?1srnS!^fhosJ8M zJ7p1b6>VNs_qr`29#;9FD<%cnGc2FkFL`B!lsI+V<7X8gmUWsyY8xi0diiQ>XREvMi*uQ3SnS6 zMDk@JVP3-fa0@q^QTtO_4QKxoKnXtAIgI(ugd&a&WB6s=dESRU)~c{HX~ zb(}hR(DX60Mdn*8x)YB@<_`t<6BE5UlY!~WeO#zFYJ6IjV3j@j(Jy=2>D6&!Y06wn zU0tKoUizxE0*Zy`7O>Yj6O7}W*Is{7mCS(=k5zW3$7^yqFdA}yu_x6x-`OD?TYdFa zADQHHbm~*Axy&Tjg^;1k%M&BUnZA(Fg% z#19QHNo%Pj-^4sA@r{3NA|G8 z`5l*oX{bW0iI;_!T#nY?)MkWvhp)WhVH399dil^uXNx-A_DQpxNgwLghDq(VkA=LB|qrSuBK? zsZw5x>)%3bsE_)yE{;5Vqk?2mC<+pGF@5B?1&Dhh_u3ifn}bE_5lm8eD^KCSb1sBp zZBj)SzL!weO+koq@i;{`rq`xlj(BL@b~`n4yD=F}S#i@dr@d_=RcCP6K+k3`t;}4_ z@pCkH7)^p_7I)}!o}q+^QiFPsO-aY#5#^FweP?#fWCC@vKD@S-)1}QWw-$NppB1NV zYhGXPdup@rVuDQ&*M9GzYi2ZOhAClUrLoVaV^3_p3LiU_mXY?|dRkf5-uX;*evE*5 z^MRAX0dxfWN5n0@%SEg(-F+cDRYBV5zML>zr6%?bnYdcs2ka-h3r$b?g$iY7^0bFs zu_1;b${Atwnk3=3$MLBrjWr1;q3v?^u}omf`!ufV=L@JAj)HR7$G~%i4Cr{MQ%Yj_xL#`xp-lNwWRapt1+{z(5cKrpc zGCE=l`W>{?L+eLQH5#7z4;2)V1L{;VII-dj`UtCUAJ*fp@HhGcHB~R2!nJjx*rYmT zC)7WdU<5Tk)iu3kvDI2OD8j|MOqqvbAx%JII2v6NlnAGbLI|s5`5N!B9_`wmtn#L4 zvhQ8El9onRS(k&6gu@t|?dtP+Y%SBR! z@oO{f*JdnUGaxi34EVe*Pv{0a7?_0)bF#0jmC@yV#nVFQ-|D#FTotiJ^Zkb&QK6d^ z5ft=t*Mrz(_3;OOSrpcnBHn}fMl$?3fyWOoU4h_~kd&N0Fyv~d&sqGClzdU*b|UtG z*4n7=SpdG}f|9V5RC~tuk$Xjf*bW%}EmAt7w#Sk}@13N4bCjq)TG`gs2V0qY!lts< zS<=WN1`E6ULJWtd>f1g+aDl%`_5o*VG@7{!BKpcB#@)Y8-a?w4ANozvE0t$DL~^x$C|SbK@He(J)*Umv1gpwGAFki7gWT zy*xAXWSK3^K=0c9S-SG+q=PN#*eYzTx53R=x)g$qwox3a(z2Fk?{lg`i{7Mh^!1BG z5gD#xkdjYLCi)bipNv;{-}I-0Zmo}>v8LVw39VOj{nc3`%;S|UEniD5kAt%V;#2$z zIk*V^ZSef*(ZED93H8q>+$)q_d{H|%$N`2@Q}tNgMFNM|p83;F)Is>hLj|*q6fVcY zQwFN|t#-ut&Kpe|KOIw|z9OEF&Tz=nR#T#}QXvHpX>xM{tzW-9r%z2VAU)7WLSC09~3e3t5v#g5YohtQaP&ZE97L6tA7cqY$}`*v>v3#IN`O~( zDx)cfr%G*Xh!WdbBDiJNK=AG8)*aik?%beA|C6o`{lR`W(IjUZ9P6|G)dztl8i!4Wvs2#v(ZN&vUeAHl3vI#wereP==(s< z=dK?Dr=|j@$oMdF3cyzXSyU5vN>m7oq=Z;7!x;Fl1{#i@M~=f8F#Zxr*uM+OQB2E6 zTfOwf&O|QndOKUL&y_U6F?FOkCDR_wxFM^9=0{)qU~F`u%fM} zNw3(<8QOjb@b5>sdX++5On%5H}Lu37dkG*Ka~G zFC6cxIp7Aql9m?X(I+1iQ*%>}P*+iy#jK1UOXawD;v}c0D%B#3?n;aFnNp@jQn+o` zIm=u;rQ$uqtvW#LeOz8id9*zijBRY>ZN6z@axk*%{;YRP4fd_g7z0L3TmoaVKK+=7 z2LbFJ-yHYG{gV7ZjcJ_3rTH?`^G`{hq-VWmHB5&etZs`Q zvKVGB(=3szUbAwiJY>otZ6VsBp^i9R;!+D`)TC0m;@5>=To?YWC2z#?$zoZjc3q)j zDwQd2Dtsf$hh*tI=O*wV@vF@_!XZn9(_LJt_}BG1_6h^^7`@Whfl@`}b0i8n27k?) zugvIIm1SXh??Ysvw^g`wlUP=6R$G@gE&Zx|!R^w^5A%jgJxVm&{8$oTR}&vi0I)fB zd0Y9j{z?C|JLPUcXkPnqlnS3msZIv$@P;+@cU;BD4D^9$6;f?{{pfV)ojdr8H!mkd zhjj11lFdv?AhT_rd=tTLsurgHp!9iQ43;#{jblza;i5z{=yYZ!8~NS0a`x&j9B^jv zfIX}qzKE%25PmYBIxgbnB~3!{igQ?r!_waF$a6aY1b>xZJD>F7a{n?mJp2KBb8?ZE zIwKjKQn#QfI_W%BWfypN6{)WFQF*JYN=Z~-NfbdgRdD4S7n5l%r*D52dux>ao2~vu z+K@1trI*I`F<%k=fh^uWUuq*slg9~J1$FF6CeiO92n5%5Q;%pf*Z{ByUU}er6Lr^2&kg{XWJpt@TQ6Wkz}JtkkM{8sWR!lb(eiV29^ z2)fg*F0123Bf%7)w9Qoh6c-N_=}u88w;!AXMIO$BbEIV_aDz^DV5biEm>V@vut&ydio}v zPSMOvl9NZ(y}&!W2Uq4uh&RbxuZ^&Z3l<)f6$h79so)&TXEy&v|qtSGvwfEc}pX1J%(G#G`vH+R2QcqUzY{RrkAh;{r=9 zG=Ds(6gM8jw1(Auhop$t%YmZ_y`8C&r{zbczv;s}f3&0@xbES4>^S07X`!u{%mG<0 zy&}pcbp7G2UdD-H5^i&i0pgv0B}1n%`{^2rYJOCi!Z8{uHS?lqM8}EL=ax1sd~u^p zCtmYkpQ}dKYe-d%25sz)|J^7l;3oBG1>3QWItGz9rE7xD;8XomDeS%#sB5!P@ zhrvp(o2|6L&9K&LdS%}9)sgVhM32bNhtk)SJh|SS!UA;TmtO_hv9lw-wt5pf+7x3E zkG^G0g9Igp*`0`>Q1Flr_Z$I+du_?x-KP1KOPIV^zeISz!kq2*Tz0pdHFux9y-RQ&Dw#z^+Np@ zohpf#6Vj8>>e_cIg4U>_t%^5kYD%BK!$mjFJLEquTM+(Z0r+Jz#+5|X)OZi2Fk(B- z0v&((B&sZ$fw`rFRz!syYWC&CP-aDO^ku#i*DUhJ=G)B6yODX8R#f|7J}t;peOGT~ zj>XC+*3F<_B?Oy{mR7Wag@rXf+qXk0|Ik2oq3=_tus82ni8)&&kHe;G?EsHyh3yg^ z{=rrke_wVOX7>I5a<8=o&b86RI4;ImqRO?Q_F?JcIYq10ytB=NWl7C!LAO)Ws>t=e zJox{YD#h>3Jn8OjPWHGEWmw`EYJ67Y6&hYEv)auB{HJ5!5(RT6rIUAIz)-^&a< zqdMh4x3}ol1YC27)EG~@uoFm=Y~nF$x3OIpO_;KJ#xuDns!#U73|l8#&FMtH6~cUu zE20uE!-zj-HGDd6v#YP8a3ManfaO_SEMMSr$&p{-ZTbqxow_rD#rp6pY&dmHXNNVZ z58oKL#T^!ef_OjU#Qsj!Xt+OvNwqc_w`SL>QlSEbuNe=qV>2~C6}?GsVvus3As;;3 z7gSTdGG>2@#4D7Gqw4cW}c*Ax~1aQ+(6kaR#xh&C=C=l0TT4eb)$ z`m-py;SMv)z}cqNGTRWEgl%KZ-XdSA-sw&Qp~cRYHD`MpWYl3#3ID*ByTACcS!Gz; zGz~5Ka9K-aDj$<)HTA}Q9OCWY7WQ*px~q-b4hxw|mlr?YCKMtn1&9M;gYylV1ju6G zDgGRtXl`oq$SjN(e)AGC%AcCk6W4kiMH-2V*)`v!+Pxc# zB?_0pENszwPIA&IySoxyhPaNW8>pE$HkXT&Ihmi(!;- z&})d$5z?3V^>FuZpa`$~!4~d*aPKU+HVD3^;k5iu{1_8W8Ow1a_&G28UdHA-T_+WY zX;Ix9QL8c>cR35_u@ShB@(T^qygqTxTY{)ttrD8stlo7|syNODQ6*@^Mk8{21Sm3k z%gc^0>>oM zlUK;9q z;(La5KJtae`CAlWClANM_{=SI-+wkgJUP{P!fdm4GB?ge!W`7Zd`VrHAd=iy{9dz* zUnmhqys}#j5ut`*2C=oJCCdQ&{h@H)!Fr@?(T1`J=`B&3=QdkUZc^i2oh&(asfuMP z^?si*|HzPdPkP+uYPi^jtkRpq*2w}@*Q3B*c~j<%kcLcC1sWOo=b{9WNg8HL4IIi> z+!5`kVcNME@wXld$_9td&S>GXea5FI&=IU?dZPsmPSTk!KEh0|0Ra&C_i zPH{#hJ>9o3#)$mIW0Ipzd#$@($R^#OH?6g-=I4G0dnocvB z3kikfl}jsPV|NGM%`Wq(`*2|{SQy>Ef4gJGNaQ!`z6Y7gTIMcU?`nrMseDX)NFpsl z89s<6+vBj&YUK^5yW8Ug@iZRpi$^6WddEgTHr&8BY#nurO%4}VXHlby<*Zu`GQ-V@ zu6n=`I5ayKwqEm`r_7AQB80BB+;Z)>cC+S?WH08a8+P8@h7WQ3nBn$`bbxhkc~bED zC<3XHDxDV6*dT@xm2E#Cgz?OHhaGo0i#q5`&UO`?nIfJi^GT^IFHcUl<-OXydtWw7 z{!CUb>!55Fq2Y=>(cZqG6o~Qo2A}q#aU02XR#cX`o4daxT1fiBgg)lGPNJ3tR3QyE z-`9S5?TquJy{9Vr+Q#DD_m4~1T>H+kw3^n~-f*{=)_5(6_hL#u{xSRXuY3(L`uQ$_ zWMukdT2DmK2O6WoUvfTBXA?ZFAHZ66<~Co=D+^VSu3K{HX)sm$);RZQB6m;pd31Di zM19^=Bt|^bDWao$e+aYaj(u)JtLnaD#L)KhBdbl-4bPzQNG4}F+m$Kp;f`1~Hk%0! zmM2sB>s3aI6)6I?{An+X{Wo~u1blPCajz{xIreuaoW%LsZU(Zjomg#berWQbcOLP_ zE_I$?^9w7E@G=i{6gJ2x3NKCx3s>AKezrAtxh*o|2lN~5h zqstF2Pqg15CI85hOXr?_TwX32N}pD$Adj3k=?ED;abQ{_k7EqP$cv-3Mi+|fMD$BakY1X+F6{^Y78~Yco ze|#SsUGV*tdS?-u+v@^L#pnhDXi%l`*(QTcw@PEvIOWFJqjtO8LpLS#E6sk#CSb9} zetjVAsTG%%)vh16fi{w={+QrGd;56>5r^^f(Uhc??D{nk^>Hok;Q~borBXSP?u*T&$%G4h{KJf5*wSODi^W|R&30KLU zOMwzQs3j(s`_rN{*5w}jAayE|nHd2bx6=!kxlLsZ@Toe;9VL@X6U>8SbF90L=$%Vh zf#QoF({i@h!^`Mf@w(SjbyZ9eiog2BI4SyUv!J_X3N)C#J#;~mvj!7{M4>$>*^Ag z+ReUXAINX?t0s9Z_?Fuq?YSPxwEcQr|Ni89_h?QvN-vS+G))w_nY}P z1m$)XH9?ICJSXS1Gdv!L|7*)tIh*e3U1|TtpJdKpIP0}^kul$ycu~9Jdog^IeX)9qXC zOsu_e=aN%Mh)%@bu3C&{jR2tgD-5=x1qZ15@bk zj9d{C)>7si`{G(t z{jNvZ*;5uK$%or>%r~{xu`*KrU+vwsYVE3B6K02h=|BBM_4}-6zuLCk_%UJXLtr?I zE?pYA^8S$}dCBp>0Kc#-V{gf;FXrl-E6vPAmzG~U8^55ot?6Z~T9@3u%fMrW^2Ln; zzepTs->>|bF=LBe_2g|fm0v2(E!wc+iHm3P=hi!uMQ>WFaUFWO=CJgoZSzl`yK1L- zW7o58=joppZ(mxo^UYWO4-!(JdX5Lm&(db7-u*~OF8q3nn_G_0Z{Qu!MNiK}-kslR zbY-^(uu9&N8|xEsW!2N!W(5{=BJcg%<$v=TJIl8mXM^O;uiovQ!Rjrxw)fQT*gv0+ zb^{BZYN72CzFs{^H5-qezoc?Gyj;dzJbbY<4JwtBh@rs@vLu3mwgTJ!Gc7&go^nFU{|(Sm n{u0-SlH~l{+|;}hhV;xdz2fZ5kY{VIfgI%N>gTe~DWM4f1|{&v literal 44159 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�^19W}b{C2zm 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ð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\n keys: alt+1 approve · alt+2 reject · alt+3 cancel\n enter submit · esc cancel\n\n\n────────────────────────────────────────────────────────────────────────────────\n\n────────────────────────────────────────────────────────────────────────────────\nready for a message fixture/deterministic · thinking none\nBraid starter · runner pi · deterministic fixture Ctrl+P commands\n", + "frame": "braid cwd repo · session New conversation\n\n Write a message, or press Ctrl+P for commands.\n\n\n\n permission · approve or reject\n run: fixture-run-1 · Braid starter @ pi · queue 1 · timeout unknown\n Allow the fixture tool to inspect the selected file?\n will: approve · reject · cancel\n request: src/app/application.ts · read-only\n detail: The fixture requests a bounded read for a real interaction ...\n export class BraidApplication {\n answer: y approve · n reject\n >\n keys: alt+1 approve · alt+2 reject · alt+3 cancel\n enter submit · esc cancel\n\n\n────────────────────────────────────────────────────────────────────────────────\n\n────────────────────────────────────────────────────────────────────────────────\nready for a message fixture/deterministic · thinking none\nBraid starter · runner pi · deterministic fixture Ctrl+P commands\n", "packedState": { "schemaVersion": 2, "capturePhase": "atomic-signal-frame", @@ -38,7 +38,7 @@ "schemaVersion": 2, "revision": 1, "sequence": 1, - "workspace": "/home/drew/code/.worktrees/braid-integration", + "workspace": "/tmp/braid-publish.Jme9u3/repo", "conversationId": "conv-1", "branchId": "branch-1", "conversations": [ @@ -69,7 +69,7 @@ }, "view": { "revision": 1, - "workspace": "/home/drew/code/.worktrees/braid-integration", + "workspace": "/tmp/braid-publish.Jme9u3/repo", "profileName": "Braid starter", "profileDigest": "02a2ebddee943473ae42c9fa387abaa956799ab7791b68033006dd4029866660", "runner": "pi", @@ -298,7 +298,7 @@ "payload": { "value": { "kind": "workspace.opened", - "workspace": "/home/drew/code/.worktrees/braid-integration" + "workspace": "/tmp/braid-publish.Jme9u3/repo" } } } diff --git a/artifacts/verification/w6/states/interaction.png b/artifacts/verification/w6/states/interaction.png index 4925de57ab4c82d5147cf7925fd7e95076f6137c..a1c552b87090957fb092faac92b3f3973f148fa1 100644 GIT binary patch literal 47522 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�(q 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 F^B>|pGnoJY literal 47608 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 FzW^64HiQ5G diff --git a/artifacts/verification/w6/states/interaction.txt b/artifacts/verification/w6/states/interaction.txt index d2f5924..40ea5f9 100644 --- a/artifacts/verification/w6/states/interaction.txt +++ b/artifacts/verification/w6/states/interaction.txt @@ -1,11 +1,11 @@ -braid cwd braid-integration · session New conversation +braid cwd repo · session New conversation Write a message, or press Ctrl+P for commands. permission · approve or reject - run: fixture-run-1 · Braid starter @ pi · queue 1 · timeout not rep... + run: fixture-run-1 · Braid starter @ pi · queue 1 · timeout unknown Allow the fixture tool to inspect the selected file? will: approve · reject · cancel request: src/app/application.ts · read-only diff --git a/artifacts/verification/w6/states/narrow.json b/artifacts/verification/w6/states/narrow.json index 750a4c4..f48564f 100644 --- a/artifacts/verification/w6/states/narrow.json +++ b/artifacts/verification/w6/states/narrow.json @@ -3,9 +3,9 @@ "state": "narrow", "source": { "binary": "packed real binary from clean npm install", - "binarySha256": "abbc7d58b48a991469ec8dc103bd1e9479d22466f8ac77687aea24cf7b77a562", + "binarySha256": "141288e0fe917635d723b4b70d464dc49baff14cc356f54de1d3f8faa5d8254f", "tarball": "tangle-network-braid-0.1.0.tgz", - "tarballSha256": "b1d5ba5b3c81034647fe9247d94a6f4662c7a22e6a501f6f226c36208e64d046" + "tarballSha256": "c795f05973fef4353024c23a14271de114d3bcc8943ba676c43c4dd12c324bb0" }, "dimensions": { "columns": 40, @@ -30,7 +30,7 @@ }, "capturePhase": "atomic-signal-frame", "captureRevision": 1, - "frame": "braid cwd braid-integration\n\n Write a message, or press Ctrl+P for\n commands.\n\n\n\n────────────────────────────────────────\n\n────────────────────────────────────────\nready for a message\nBraid starter\n", + "frame": "braid cwd repo\n\n Write a message, or press Ctrl+P for\n commands.\n\n\n\n────────────────────────────────────────\n\n────────────────────────────────────────\nready for a message\nBraid starter\n", "packedState": { "schemaVersion": 2, "capturePhase": "atomic-signal-frame", @@ -38,7 +38,7 @@ "schemaVersion": 2, "revision": 1, "sequence": 1, - "workspace": "/home/drew/code/.worktrees/braid-integration", + "workspace": "/tmp/braid-publish.Jme9u3/repo", "conversationId": "conv-1", "branchId": "branch-1", "conversations": [ @@ -69,7 +69,7 @@ }, "view": { "revision": 1, - "workspace": "/home/drew/code/.worktrees/braid-integration", + "workspace": "/tmp/braid-publish.Jme9u3/repo", "profileName": "Braid starter", "profileDigest": "02a2ebddee943473ae42c9fa387abaa956799ab7791b68033006dd4029866660", "runner": "pi", @@ -268,7 +268,7 @@ "payload": { "value": { "kind": "workspace.opened", - "workspace": "/home/drew/code/.worktrees/braid-integration" + "workspace": "/tmp/braid-publish.Jme9u3/repo" } } } diff --git a/artifacts/verification/w6/states/narrow.png b/artifacts/verification/w6/states/narrow.png index 369bd705966706387c536c49ef761443433b8dc5..ea6e611495748c347d324eef7959bf4698a794b6 100644 GIT binary patch literal 10265 zcmeHtXH-*LyKc~}2uM)`RGM_@(gjRVdhab%0SV1e14s$51*J)s-bFx4g0#><5b3=q z^p2E(bVwj4`<(C3{c*d8AP^zga z>H`2I$;2PWog2gy?^24th<{J)RQ0t00Do=(AS4U`xFjZpY!P#x0RY>t0RWja0D!?g zt5NR-G2^zahKeG9@XwXkQk+CgA%&@FDUoj9`Rn%WM~8(#;zxL3YKpH6-(mLV{VX1w z5J(S($tiX_JQqDf^PVg`WnWQZi+F#Q4$etmPli}m*Jx^K$PUzqS4?RB;t-Y(<9r~k z_)@!px*=|T#vUBM$ZE%!Kt<6Mm1|wG87c=k6YIu_-4up=w2@;<%0c}F2$2UiHW6Da zBp*ZqK*U=EUh=Oo07l3!ZUJiky`=38y#A`^*<#N^FvqOZQUrE^4)8KP_Q+*CZkm|N zBIRx4n_TZfNWPM)f(l<{SG2w#3~#w=KKvcNVL$wa4eG5HrhAshw;cQoopr z3IvtRcgqImQ>JB5pI2C9WaW31B)24X`($JT@*AZisif3x(HPh=KTvfBoD09ae7Cc` z{eT{*uBmQpEP~RG4OgfLKDcCOsA~@rE2fnnx`wT-)#1{N`AKnHtmUlF=32r_)jP7A ztlcmciPkUgfa`+>*KSo1j@NV0{V8{jVFs(Tt)_&wFIh}zXHtKE0W(5QiaiIK$W_Mw$rELGP2Zz+T9ZmDO#FEAOyn0Z%UMk!wR}l;aS@+T zEx59y^X(U{(EaOZIgE?V{@5}K(sK0MJ(qU?MgMjN;qVq&zMxHYkSpn@?>NKC2%2l& z+}YpW-rn0QCu2)TOQR$u_aO9lYK~7%JUtJynV2_*Grf=EBF;cFOiZ3$&2e>}aqZ2? z{CC(i@HbMWXRi8(frdtDO5(T7Kn6S& zS)c%HMCBNFFOpWp&@e|_y7BaCrE|0fM>amMjLeYiugu*!T@5D-FPO{tr^_3ud?xUn zu}Icd$uCtk5j3LCD|0Bsi`J!cY|Ktkli(35$&y;V^Q65vc%~7yq8^;;Jw8t^YCy&o z+8mfZb$s4qzu>cfDN{QeUsN=uyAP1>$zIBS)#!d{4g0)6?pNB{M0Lfu4oU>A|9oYE)!P z@toa=u1SDL0@H&e#Jr3IsAor-k zkLFVlah^wf8XB$F?$Hg&g{h10ikvQ2q&^r;;!tl9Wyk@w;hCljgW@2hg7V66(*YsA z`GStor{o!b!Xv<`3zTpNKOrubgtW3i;GW?=7Mys`3==gm;&zn?^F-W`PGJzFt1GfPX2)DsDd2i+unS_cV9{k!?B&6!U< zoy^AQ7F$>uauikD*jzu4&tl1(o>Coc8Rp5#S^ zg=%+75M?-D1FKj0UrTy17h*@3%M>#S@LhbRdu_A7* zzh5>;+-$zPHYjYVKnsyqS&XL{!y4`^4j`au2aT{dI_HHyDLWbqS%c+zTmr)xoxdUc zs@QIZ_Kn@|SiS?JOdgxOyu2+RfqMDm{uO_SnYowO5kgRKP*aQjx*0eJ|5llh5L0V3 z@?r4~TdlWuMn*=J-^Gg@{p|!NS0`8Nq1CBNOrwTeF5YH#Q^zuFc(cr-q3X`~)FcA= z&QozaOr9ACL}zV3eu2y|`#G-97~(^u2dYP6y&Vg`Mh4!7?cbH}DVV!^dLa!w9|%(v zaCUP34eS}8gqOcy*Dk3~MXywk2E**1|T{iR7Ig%(>#^G~eQpL$mT*>Zm_D z^7_bRath{-pQ(%!!SSO)U;jD!4hGk(A`-giab>P5zw11ynsf zcCV;#dn&}s6&4ou5sgNFdbD1GY-b!Lv6{_r zg&}A*`b13M3^z7M*iO)%y-OKKG*of?<5}VB=3z=Y;mBKZPp89`pX4X>-f=tcQ7H@6s zu-1G23A7#9hGw#YR2!HN-;7gwpR{id1$&*QT4Te!CMTo@f{#3ok{C}PMUw5eoqjyZ zBYEYKu$Uv4l#q73Fn0?PXWnE*{-Zz2ZfqRY#YMLG?)j@G8J6~to3u5}^^X3XZiTXq!BhChF-t(z0b-Wc zi@L_f*8xO}7sh!pJu*DWTgh26u$s9C*HO_%9b zr?bH7I2uvNaZma!;dz0ya=B@$prsa z*U->7!_FXoU!K^3{Tfo~fm z(9;1iM|-W0^S?Lku!qGrHeMRE-6s1`ThOfB(-6^$jQ#jg*q67wOY1lqeKWJJ0XtX7??C2h~2d3lLBC5f-vY<$o#>F?`Dr;3V;&ouhk!FF3(hv4e!>IGlyuDaUTp{@&r z)#Wc=R1PQXTU?rtOc3iQ7b}SXibr3y0Ff$_~(LjGVr_IpBMA*e%ZfaPxHyH0IJiLgs2)3Vl zC@6?1-_0^89Tn`-2x(V8HW31$=n06RO?=|p#wtoEhF_GyhqI2|1CNOpWv^^D9CG*B!agwr^_7%9^agC`2cIv^U^k5Kj;z`u(N7>`aVGRq^f4i58wHFtjd!a+CK2ez__LQ?RX0G5P!(~ z^l9_K?4%%TYsHt+!(LQ&MVsX8bT@8Er(32xe0Z~^I?%%qHuraExHJ>pCaC2F@S|nJ zscZmkKrPiN%B>Y7yw%~B;ETn~VuS+ia`aEBW%)IteHX1RRXX=taKRIkqu}EWJe`~1 z_1UQ3R3)T+_0L>*!`?L{*AVj}7(3vbBD`?i>obPwYc;zGzuK&vMWZpM$tfukQgZ9A z3^*wSJ1)G^Z~UXI-Y9eDU*UGI^Q;f2(Kn-);xqq&*y11&ch7jG3#+>u`KU%rS=rQ6;78rd2XlSo`v zWFI<+i-5K*YkX>yMe>b%I0bj{*K8R$R?kC?xWJ6O2TzuC;cy_o28EwwMot3Q=%4HA zz$QhNb7*i-NEv3wPUrKzUyR)l5$k3FCy%Rrsi=bCFs*d%loZ9Nz zM8dJ=#f8t$jB@PkFa`mAUvb`-;}eo*gpCprpyQ_UD^4Q(r?eMVo`_D}r!U|e%?Sz1 zc_ddd7JFWpy9I2jTcZHW3<0a1yz9)Zt z3=??8+){ls-f+#p_c3w+Qc_)H@RX~s>VF|`8E8o;Mhm& z(!`~wpm39tl5&HYlG58x5;OCZElia&k=u!u?W-QUo{33nan4~BUx}fN_u(L#)Eue+ZIE<`LVq*J&(^1HTO z+F1oIbmO-E81$ArN>uh3&e_NWy^H&~46BT10j`y(JbiHOe(1C^E9MTP;1i<_XBXz!k6?)p871 z8gEe7-e}CR4vVikBqxAU#(d`8f*S#=aXs~xFQ{{|`AhP}+ zPX`7fqdnbr{j^(`)QZGLm+E;wfo1 zYHW;E=CsNUx_3P9Na^kEG`_g#bMx9esnBJuyjYE|B{%-TsY)0UNp-QQwrG%PS~@r^ zTvJz_&{E?fhUmGrNli_4bFmZTA8v1trAf?8t@xZgi!l|U?NU|_1XXi-l`}jW}6#L7o>giQRRwjhDK%4dv8mSfO=@|o=Kn0?yi|MaLRb>Ia_>MS9 zo7O&y*Rh3`3kXg~ytB$-xw5qUtj`kwL<$X6J80Kd)#w_nXJkC2W)^)ec*W-B@ONQk z3uY-^m*{Vrbj27?FEunir+z$QeeM4+5adQ!n4blqIfJF#KB_o6ISr*f?%6yV7}PA* zB76Gusk&D1w{xnuP-r$tVq|3GrHYEf#&IuQveDi(`yX~+*`}(FZP^$IvR#8=nSDIv|Cwr~Us1e9kc`k7D4<1;yT;pnZN#Y2N*NF}UqW?-G z&*HnmBq-eWdjCvL`|#|FD3bM^jzT|Aj5`SRT-Xh33(%`5b+^_ze<gkvNPr8^Uskf<43Yn>fIv0oo44HLgc$X9cJBs{wG_4ag9LbbET%5y1JkF4$;XclkRHr_51z%>b&!_=0?pJpJHUA~G z|C>#_%l(bivgo7hmL?))H#X4#w!RQtiQ9d9|GC}Ag(t*s-Z|6D;UHG>p|)0}(!-=8 zybm;a1};eZes`NAL3O4ZXY(kndo#K6ItV1k^g>2@WO3w2UU~WMd9U@+A;p_Q|9a|^ zlRs0Fle_}{$NTv*O)mr}0WbwciL)Ud|q7 z3N|aZJn@m&KWx28N)+a?kAR*%c_hKg%J<~Y1FvUBt@M2Dv~!zio{gI5==g4?TF=g+ zXV93hg>r$$v`02yhu~RE0ch*SKtZC+AT}L|6ap>S{d7}8p-?h2v!_0u6H`;5^YPL6 z%;lSxO~iIm;yB*(PWF!4pjV`XtSLaSw8y6EzpQ2I` zv3WERUKj~~$VNw(T)jyI`}D-*&la}s{wT%xSR8$}TB1&Id+x^hde0#VDWWQDpsjAOWo~?}z!sRr*Ec*8mI(6=YL>RlaFJSb3Ict1zwx! z9t_(AD6Rx^e*Ra5PusS481JxQZv4IF6zbn6ZL8(r?e{R>=jZ8x-yUhl_ThJNz=+_3 zMndwRXVry;_Df>75B;X1uuv`HgQfGDIZOsYJb`|5jpK_fBvfda#CIH zHef^|3PkYc-m(Sz{UYihhv6)~azqm?9kbu2(D-qdvdHhlXY&8$>%QsoCE^YSJXijq zCA&dX% z#=-v@5qZuHgf=zZ281AgT>RUi67}R3zsZGNERqIr-PCGk(>)eNU?pO4`SRZXDJok8 zF6?Co{3c`OWj1d2fiuJVUF2HYRxm?GQa-c(^)TN}z*2**Q6QZ#qc(Ny{>KJ26RaXj z<7ItAVN*Xz$mY7eG(n%ewH5W)y5Jy)zJp?kAk{F`cK5BOwQT+MN>^yn&=edVeor22 z9T6e@Nd3-ewfN%(nEr6950g=H2afvZSx$vuiEYYue(4InG&6ef*-gf3NMy}ZHnGEz z$&4?aXyFOC| zSGjXBidE(>)#i^{Glrv4vbE7vT^$>AF){JC*(r=IHg`+&E=) zQ!nyG^+9Kqa7+OB_=NfNXvNa#qEKP;otTyY&e-w?1^%svpUgTo`x2$8`=?rz}!wG>P<&i(i|NYM&%dM7qL* zU+ME82Lvzd7H`q2Xq2&OGy5dLYj@}0n9$^c&GoqikOKbkf+e&J#cSK(ha$vIPW22~ zSVpJP-{&czjdtD{ckP@9(hHdL1n_Irx>k250|h?eBrNoKh8i*xgTz{)CA-~^^UPr@ z*YFKQr_a7AwU7V9geN$%)F{r5FP^kKWi^aCRYk#h4%Uk{GLYNOfkdZQVvvWnt3_bk z?K&GA$Fg>M0{6_&P0TRKF`B>`)pdjk$VZINyh7Kps&03Z`%KP^9*%})O?4x5P-$se zcSJmDdg8Pk_95Y>jlJ3!qU$U!C8_pHD~!g;jyAP;VYw9{RO*-_8D}BGdwM*m(krc> z@O4iHZ4NG#@TeI$=OB8|9?m&s1ow+Li67C^#L4+qe%0+lrD7R9Jw0uiBKs-h3pu0% zI`q6wx&nWF(c4@PD^ay|bOY`G2&h88!{EDQ5_avAzE$q2Cx#XP>CZm~!?nkCXXnt| zjz39$HI?T7N)pF2zaP{ZgE&=x3|V|7CEKwuIJ9!SHM-mi{YGPj4(x;?eV0(b!s1sZ znlr6)ljkMu-0VwIDtH_N6g#~sp?%v+rZ-{H+G{2n{c;sw&CIz@RyzBA*{Q99w(%`3 z;a@*?zEOT5oqg}&HVR5-G+jGLg>C?FNN`Sh{ZS;X2$I?OSZhDIr=8GPRB2U??8P;0 zI$Ibaoo91rihZj&cIS7$#GXQj=09gO@e;$2nKtm|1@Z&JyY_^O*)=Eo64d;?Wis*g z3er6Dr^k53>z2FGB&P@jLI=9;{7phiveya|a0;C@%em^QiA&xs^s~-HArfUgAc|l5 z&xs!+<*|-4RyXMvB_F_HJpJ8s-d5%^vwamW&~2Zr@~!TlITI{sP|J9fkmk-N->;hL z{`|0}%FsMR?={wzBLIz!=rN%FK~ba|G!F`#{L$ALaB>%F05_6AdI(30@E6N`+U>5{ zP%1uQk(wD<$m%!o&EB%vT*mmIXAJToEeIT`zvsm^6$ef__R)mvIyG{xl4&W=4sx*M z{8_*8aWIzNT4(9RIIl6p;kBKNv)^kc;p<_CCRN|-stg16HR~e_)|Va?Gz5Cj+6=WQpQaLslST0%r>#QhsoWJ-ib#kM zc%7M1wt2dF)_tZV_-5}Bd0kL_=W?kg`XYtTr0|&t|C>!-v=}%nz9X!B(f3LehCJ&j zOzNwSveo|B!!mFCU~c&Bw^G63po!C7Zl%vaffs~!>YogstnoW3^pWB`GAC0D6pG8V zWHCtTkROS|VJbMjag|?NQJaedF~JF#vZ1@I64Sf|Udy z6ZYT8Oz$k~*;uB#!UI&qo4!+dFDy`-GnSSR;txubBtg%uD2%DyJB(HRM`DQaEyb~DY&NV8D2T(|E;Tg=_Y z4Q6TIk;aWDBrRe&!oGPlfW(S%gBh-SGa7tOjln$I;d@O{o}O>Y$b|KtJ-o9&_WZl=@yzZ%ro8yM+>4vyxN3%N86RDB+sXd&i{ zuCj0<3N)1_{bO3EllSGznRZhHzX$zrKVvE7BlE=uwK#dH1-%L{yA?v^jm7tm4D1ei zmwlL&xYyoR2M=E8qYI1Y3L+t%JE-~VhLMC2+gC!wa?7R6z6CBQum8vT>Q#fYbAUEs zKwL!Gd?p6ns1l^b-XJPwPFSEe4EJGdK#@rFu_J{nxDRH!SKZj#ipfEta6-4#3de4(b`Ae| zI6gA+9haIr8>4+ZF?r)<_?iqsR8%ZE8*KRbv#VK6O=(T}-d#%XT2kL#+oIr^Q)#h# zv_bsbczQ8Q!{e<@x3j&bcT=O25?ojo+yPt#@AF~7y4 z%IN_SSr>Q|FX@iX z?z|K<0_RB4qy(akd>hD2&lH=J{|a%iKWu@hNcMf1M?6arM=xw*V6u2Q{x^Rw*`g(R zB0-PZuN3Yqg>D=REhrRBd**Wc2ta_>zluMYzn0-!lb8?$LZP`fK z2p--7N-v#Nzp4`BEeO6IsVZ-4{GxBJqH_8%1%<*-&XcwH@f;U5-geu1SmBEbJnoId zZM^%G=%@QH_h=e+-T^; zq3G)|%fZ>wJ^1`>UzEIB!SfX}_mSlS<7C)5`W#- zaVrptNN5GE-r(tNSUT{qo8Fz--oltzhB~aj-e-PD!*~ykrM0Nm9}{>By`=PVNp-PA zrod~%eS)0!W?Tki5krxhpDR;-nC(}zP{!DZn)9Vswr>lUB>{E955kr|CCSoJDG1L$O08^QiXE_ z=tj3-sDLiD7wKR(ovfMASxiDAksuYIw%NeXwm`%2)*|Xq3NMX7Z5^6rI*laD4|I2 zHH0d?6IwzI+?yZLB)XuDj zp1Ri6tv#B@5pT%7gQ-n#FQ$`*fn`(k+$IX#vIyjoS& zKWT4LQ(?RAedP#z^Z1YD+kPvVRrH)*nr<|+ ze!U0%`77UBly?SuE8U#OY-_G4FBF15h^0!k&M#Eh>ALHaT5Y|!ti!+ATCdz$U2#6W z0IgN02VP|)L}e(d)?5i(#Ttz$k6(-|Y%1$l|G@knjtwh6yyai(vLUpJmwia#iQo3e z|7v=%&wusXs>3`XkcutV6_39anS^Z@8UjBbnHc8iQbs%5YW4M#^@Z)~`zHALz8}7t zpjB5l!M%)j!{ga`q0{XiUn;>q))M77UOt#~*90~NG=YCmJ=9l`-=lno(T)}L^y{Gk*KY>?A!lI=?lM}fUq{3uj zf9k4v5FjkwwbJ*ezW%Ja1o_sG&Wo6r(Tw`~t3`Ul*$&Lyj61-6rX>y36VJ0YF8cJE ze$8}<5>-cbhI(D3h@N4%=D)TdC7iMZmMX@Kz$YzA>N9YCw7pZ_@GfiX%;FG-D`#I> zD~@yW=9J9M(@g(8fq5euH~dk5(RPk+WtofzItM915=EMhr>24VH?2#(@SKoW-kl=} zmP5hMfDbNBC#sWk3LDjIfXIa$i$4E|Ns6ECyYjKHHA3bOM=?kkUrheCSrF1HhE}*u zQ+&{@vhlW%elKU95NC2|^F~fiE+>agN2iWyw|F#18RgDu+@5O?2&;ST`u!$r(i~*5AuW8ptE2vkeS{a8W4;|5sCm zTd6--YvCx}Xhv()PxKJhVExfUFWd^Z`|%-hIVF;vz(l$`bJ#^lQ>W+EXoM!%-)7O{ z@tE0WU{*=v%UAK&QR??^x=L^q4*be8z;r3S#+j>}E2(G*-=i1hY;9G3I>qFy#)D8{ z7BCHqMpqWVdxrz2M5h!BJxZ7|e6Q@fey}dp6aKaeep>Ka2vzGV*=>9&;l_(=DF@Kg(!uphRUJkE^dcfYD2y3(!IW-ao<80Da&D#`N8;lv=S`1JZ{<}{?_ z{=AFdr;0_NQe}t>;2l+may(26b;dq}>2KBvO@F;M_#N|G zHSlZKka};2tE7t|nStTYm>_ivFua9kK!>GB>H97(oVp~bYZs;Q0MX3@clC`VC5 zMn){gXko1?#dXLeG6EQ7^ zRFUT#yf$aX5VfM_zJma~cTKwA34Y(w8$l6BMdR}9mb&{B3p++e>8?71ra17^AD!;w z8d_GSdG^ITQ&Us>lm`0=^GlsN;_08`v!5+EHepZ3r%mLjrQbaXY5loqov&eO5f`6U zs4dLTsT&~ncq3XpqIHIS@!$?%!u@o$wD9ax(MJQ5N&Ezuq(94!swZqU>MKnkRr-&Z z7bz~gzhlOhvsPnP%WKQZ`1qd%dFeql-jVXXR==dP8@%*W6|#TzLxwa4{1gZyFW6c# zU2=11+1fiAXWBSdvb;#uI=j*hVMCpW764@g<$ zpVog@rDqHT(k0<2|N~V(|16VnpM|#F&^5qzi|qYWW&vrIk5yMC1Ez zz)@A@uk0*a*W6c$ef3O*ET!?4+wfoc;q>-U)FSLeDgWK#;z||!QBgwmohw==VK#H~ z*swe8WSYiKqXdds)CI1OW7X-c^6odk%YtZG9t5Xf8AI4;GbSN3+iWaK%4Z~SA&yPJ zxA8H%R=l-=kd(C&H75`=^kBuFP*!AqVc`;HEy#c_5p7Qlc`^GnYY`iMHwpWVc7>XvbFvT2DuXO2U<<6Y=`69o zC&w%B+T3DIR77-g3MxN?8m&>`RUji{XAhZI$zTn9|MVUcZm?mM;l5=Slk}Hj;Am=k z(mKVE@})OjP&cOuHn$!Bi7!(oI-|jlAbvR3Dz~x~5E;2*X69pJ(oQCK{p~1W`IggV zD;l@+yJKyz@WnF=3PxnvH1#Ug#Zq9(!(PBweO;B_+<@>fxfL{02R>}a2;4mZow^|T-o z@qkZG6#~wCvZNmr1}!Zu*GI7D@yKX?e%UATyn_^!H20R6B@dW?_X@o-R;G1vK77O& z81^hpR{24G$O0BgcGx`C0y8vDP56F6gA|u(n_{Eh7CYh|7eeem@6J@PXdjJ|Kv_9* zab=By}d6i6Pt3Y@#{hA&kJjl>OAg(ncgGYg^`;rFCtO_r|G zMFJ7|$p&}v2b%Q*#bQ3AeLYcB&ReY|+r3i#AB>8RRA3F}({)*tLPV}YVy_hj)kY1# zoJ&W(crVn^mP{Y>y^VeH%vNbqmZml~dx<#V%|^3(P9np9to}tDY6fLUHUcud{`ot) zBs!#?b2K?Ve#?+|nc==THy7CR+vH%bho&ZewDpPf`$_!6oiy1SMB))1k8&mK<`2)G z1{G>yL*JQCK*G^beZ9S-fL!#}L*aM$enxEBXrV5mjNfAK z#0UMoxuqpj!$6BMI!-O{2|ErZAq@3DjhQ~i)5!~AR#GN(#U>x_kV@8V1v%tY7jbkSQwB6~o$v_-N|tx%C=fNbqg6 z?1=dfK5FgNknks$B8JBg)zx3Uw9;H@Z=0sg7>B0?p9Sw^C@~7*6f6dd%b^?NOWs9{ zQ|A;Zc|Fz2yBBJM&7~D8pR#^#A4{a%U?0GY>3;203HlT9jpE=qMcgU1R_&gb(B=fQ zL!5=F*~a?%)2D1S(yv}sY;A6qSGb5gqF{OYOpv%1ARltq*JgX2+UxWtg2#YS!Z}fH zGX|W!7X9dax2Ekn|LCv8Nm`sDqIU(1nX0pA)k2)_l-#O=qnu(qgn0DJ7MfMUCTQnv zZQ&xJrvjr3z_O-Flc?@_tnCJ8bLQ~s2>6fpbbja#skX}??mKIO&2W%>vQ9r2+;*e+ zF$E1jKSfC9NrR>`yyLtrz@k{Yddr7YOGSvCo48m;6Jvx)ton0&09wYiW7OKhT#dfM z_Q0!hzhpBJjB+FtPh2~;lbE?B7+mU$A#`4Xw&#eghZ$0sF3KB~*8u=OHX#Vgd+K>s zPX_U)TVD+A0+C!^QeW12`dn_NNx832$Z8qP1Gpftu7Zb2ENbt1(Cf={c@Uji=rXhm zYxZzL6ovXG$=%>ixZs^94o%3mNRbXOU;6|R_W$Fb&IJB@`NBVY)kY<}-{G|%Eq#XL z`np3u?CNqllDcL$6^Qv^L@xb??>RZH?+Z*R;oi6wjKX=>_g==nV366cY8i)Rw`^1d1Uz~0Ed zW5wBHd+Pip%RVk$312CR27?uD{^i~e6Ik9qI&uwH0FRe!H(IF|A(5|b=ccM@{umld z9A>>Z!%G}&?N5f>ehq<)<|^}Wa5TCIg<09nQtnLHiIK7Z-pke`kwg28xOT{!5wCTh zfg$n{ld%7Qw-mGZ120}^wxCFA(&&7h+xc!j|5}ESK;_P_YUQm#=J0TZzCOj2MoVL3 zMN$qP4%NmC20^mzX<}|q0~3?)mt1XzXDyG}O?xCICsRp> zg&Qm*kdb)$4BrN#zjta{+8KUFmh^VLAOBI0V2;~bv~J=pr~TVXtlJ^Tlv+Le3bO>C zBP%;(gqN+InFyO_pv^e}0IgP53}_}PO$b5Jss%bVCFu(%Y)wy3Ovu65RTKt=eOLb) z$rBV3(mkIa{JAT&zK&h6GFoWNOZ?va>Fn%G-DMdj1q=B)tge0Ydc9x3WqSJhA+?A5_6#RgWr~E$)X$qPusN}kX$%)R`O*2&=Kjv{^vc26hB`Yp^d(^9 zLSH-N2=@I-O7p)eD`-SS{-mW98bceu+m@=_?4w*lXOu)VJj{N|Cj7{Szv8FyiiOk< zt>Pe9UB|`z-&S|;UK|uvqANkwueNkimi3Lw*xGn;*^>ETM)S(1wszt}vOk{rKf3w+ zeYPNr1@D)3<1+0F?=BI+P7!OO?wNAXxUY$>Qf$hAr0n;aFEusC(P`rP-0^hc+~Q6z zehs(Gwasc0eft+2qcX(xp-?uuaDU>?x1b=e=;E|^_w(SKw6yQ~Vpd8>(a}8Yd{9nA zL}1{sQ>tGr`6UW8^d@30v0}uo!zUzp`UsfN&WgOt7DysjRTM*!paw zU`6owTy|N=OUj~VrKIY#pYDqmNI^k9ni-%&KUYd`9TrwYAUZl$*4B&bkI6;6TP8X> z=3tiQ*0KA-h1l4|FmU8}@4}uZ3Gs}ui>dJ#MJRyd(Mt?}-hb)(zPY6hEeQq}Bja!H z*lPKn#UwyDqu+@1h4pMwhqu=sUx;(9BZCetY^{^y4vj;PSzG%0QA^(m2dBy^ z!{KLMH&yugD=vI|6(b@IE;X$@i|b5!JLK{!*%sZGaV%?4l=UFf?5>UM{2miWyiGITD?_uPq3>F44%$pjE%Rbdjo6X-g0Ie8Wd3yl z5TQ_2IZi@Vda?azA$xFe@VyW5vG((ml@-0dv1_Bb0C#7Hztsd4kOGi4E%Pn)jWa}W zUU`Y&sRELuO48pz4LX&dsRi?sw6g8%>8_%my8Xw_9kOr%H65g4j;Bm+{L12idCYbE-8o5o4GNw8`Ci)C2cVcj8DBptPx7TGFDRC*7IqZl1F`ebH${NTZ zWqoR@UQAr(PjM}JfVY9TN6CqLxH}s%X+yOEEvuw2bLg+!RT8TQ2n9W!8u=QYFf9Nv zr!&sbsuox&py36uTz9zoVX6WH8COI1d00jiFubP{!P)Yanq>p2ItE#FLz=C@a z36`f)SNZYTK?>fg6&XnWHJ&ML-9|C9pGip^h+el+dgXNCVjF}qSWHn7yn)S385)2X z7?hw(RM;JOBgeX7(KU#*<t6xtzL^BeXKlp*6!Dlp+ zlas=JdZl%BNpBt}3i~NqVJi}aS1^#dAwW?B1C4Y{e13Y47@o9Ry<%Wnj-^}$f?wE8 z*xxdU=qm~iCh<4OfD{$#IM^H5q(axC`^2iSBD_3J<}1ta+Ime54M&a1YwGMOmhkbU z)2_bup+X>JzATvuYgjYnO%<~d%@|yB6b_3X`IC;;;cWFhrY*z|Wz3<4(EYe4aA5D+ zaL51Haq*an+YU)d-@mrGo@HTr>Aa`a&TcT)0#h*t7c|$-ip6#6>gsB0X|g|MO9M7( zD`10Wh_0Zxa5nw5_ajrD04=f9HbYp)MZ@cH@V_`@8fn0qvni0u+; z+}VqmnYBExoC?{;r0Xjw?Eo89uauspscB+Te8odmy zFv`S?-FoZ68*TuXbwn|hSz^KC{;lFO`Uoz#6o7If4cKfrRu2n%B`=HYdO_fYbN2Pj zk`f{BRRO?ctD~v|8Xbo~XfcmW=+S&;SGOt;!r(TU!`2T_o-FcE-jb%Os{EX<45tX6 z?&&FSXm}#J+R{E$N{9cwIdVBLo0sEHyzz!LRq8qj>3B&Bs+9J-210%{0KMT{rUwn( zIlcLhq07e8mH)|@+vx*Wj-Z7AlHUDql0Kf~(y!}3GSELyAoG~n_P~`K$*NZj=R`kk z`#zeJiSN{H*FA8jUW$99{QRS_i?K3jroqdl`q0}f05rRKljQzGG5|rsnlvp69TaYW z?4+mj(n3YIq5-D$WQfIL&_MKPYadt?TMipa&)y2lFZ~6O0yczfiRPZi?iBsQV@F`4 ziQd(>pKVegUD)>wxHy-W?R+G*f4&~mrl=SpWQUz-YV6Wk{uY~jiORWE25{UIO!fe^ zWig;H?saB+M7;0dP}2PAEyiKa-qzODiub_LW9jhJn?Q7a#?=5gD1aer{g&v|3jlQ2 zAc9M)zmLML$rwAWoRqbQicVo#S+hnSNPoFD_nd^pbB(NA)$wY_?~%46E;t**Z}%$sY9~)Rux25VzMDr_ zfXoE2Bfw{JadRswMI`00Tni{KcPS2$ADie|T%`Glld=@0bF=?fsR8O2(xSJs+c^n| zhG7eic-Xjv@J3coifX8>EiJn^J$uB>?IUbWO@GbIzOl4ubDm}r{9E4sQdI}xr?FQE zTy^uu{U|{tCz!v`V_BMWyTEiQ|GDjy(#lE$0l|8^=*pa&4dcz31QO6xAoR5TmwLVX z1p+4{qtRrdq)>JCTDsqUx{cq`kF^Lg|8Eyz&?6)K4<0-~x%%NFeddPr6`{Tlf27Wc z%ZEtes%N*WCGH?=x$lB1Zv%6vGn5i|*_^Awdn5hxo5$<8t;|~KtKePZkqdqG-LMt>7{qd{%h7KmY{$OM1Fa4%Gl27=tiy+&CB?W)0zhjyC{PxghQlhXH@M zAX5FW4)LG($p0VuAJO9fu_Rn4QxF%4*a_O}&1j4Vvv5VFbJ6xYJAD?=8=K!3)c;fs z@$c0Bm;7xmB#g0f8g2>NyLuG$&lMm4Su)|@Yn%TdPe7F<|HLgtxBC_R`fRuET>Cx0 zRZp3!1KmHC0qpZ%WBC7-;0yi1exY!0o;sAQp-oQGoKM5t>{a(IdLdq5WO-?PBH+d+ zA!&Ic&!v?x6#ABP{(~^Rm&JS#K%-Uj+yc#g^Y7V5kTB?wx_X^??MG9?Kv(E|*9)^% zQ{rxVw2b7m$*Afw?PMI)y>sth#7rZg(BMrrU&k6Ww<97t<+aa%sY(0xXFR`+)t03< z3E_Zz6Zq-qk~GXCF$zdBe)_InAR+0*_xieM~EH&N}f~~Vx`nm z;}SV2OuBAUA>-0yUQ*Gy+|pDL%pWaZlF~0udTQ)2kw%|JR*xd22u9?yi;14D4)6=o zY8H^k1&tUr#WqiDHyTJP)Dhl{70T$?s~*Ik`_3^wDM5goQf%2C;%7eWTibrXJK3A1)VUPqkXzCqV z6dK+{^M+KU+;A)ArtaMnaXUu4&q!<*bAmWR=;D?4;bp$fR)I~YFR>}1`gKJ^X?S>D z_|CXP)XtsD?XJJLp?W(tXr1psP`A;-)JP`(=^<*2sU0+l(#P?MeDZ1p<^$)RaCxuN z?v6hpiINb%Hz5B4xpz5Sio2e$lTjT-b?*S14t(~dFIA+5B^0;*#=G2!b2(Oqxfq|i zhZEdew$}1qDH0;fDdY4%$j$xmn%73%5hKB0noZ=Cr?$@F8rlWQ`jAZXhJC?x**Z7R z=k&cjCu+RPHWa5`&geh~Ip%e`HgNolYn=;b)7hCLR1lVbVSZycQE zK}av*q18f!vASp26cx5N|J{zD!_&#%*GkX;H6Edx`fHN*L+w~aSno1?^i zS7{~M=qdZ&L98%KwE7ZCeY^L^LcH`6Q`HZI=UKmd!)!%ae))c^De8KclqJ{y1B#la z7w@t5xlHv+zIqu)WxTb~WgBFc2reU!OKX70k>;1=$0?Wveue8WV&151%!HBkY_8y& zT;w~_Yhw_5i8@-Jfw@CDwTz}BrH>9aPtf1IhPKi^F~!~BaS)BAiF<+Q>6@Nbp2f!)^QC_>&>~tq?JlrMagV+KV1=Lc z`Ner$@am4OM4F#LodTfEhYz}vZOfXJVZ0?6eu0m#6wM|>uxgLExwBn#^TosMPM%6Xs)r}DfwxvZvKqnH1*mnjAU5#mtjZX&Uy;qEF~yX!9zXKnjUbNp9E zql*RZREr14G$e5fc9r`jv&u0)Q)^XNsrPgGsaP^&=~II`oR~$H!j&?cG}#9yuK}V4 zJ_M?(KPrH9b*&TCOUnr*Y#P02lk9`2y{^$YjxFDPwz2M<2KTtXcezeoXz?hq8Ws;p zuuj>bhKb3HZ(XybvG(gnhzbZ;%#TE< zk3y2N*jN{TSQzpiznzWck+(6;YtL7)6$^ts`b}kw=}w|j`*!xOAFw>E^|niIUP+wA zJMjoHMk7-rMd3%9P@Mv9@L+{qIeqdDwdUrvla%~;0}q11OPA6++M=Hw%zkF}R=Ltr zTsWqN8?fgHioIxO{a)|ot;?e_=$)nF&5X<0k`#7o>u4);Ddk1vt}SA^?F?(o)1*+I zgFgxjn>)6L+r1w!SZ{Zyh|~C89m{X+-|b5+BCbZh0DA*E&M~jqZu6mW;oRI2*=uu3 zD3)f_FAi-C>ioT1M>*2P+}%3Yqqajd!4%DG*Q@NU9UQ%Dl+Y)N7B-b!0{~zKn%7^= z*>=BLa`bxcwssD(le^b1`OgQ)lc$*eSqovk9jBCe%&PU;GgH`{kH&rym-Ks>?OD8o z-;T($W{0`F*v`}AXc*qhL3?x{L;QWAA4DfXJu9D*0r0o9OIME!2AQ>s>YTT1gJIyN zvLyl4^G|>ICOR0~yO^j&_T3CXLS#%e+=<`hsBxW9~L34kKWn9N~Qp0&7$?@hS7 zoXUgYqR#COyLo3QxF!JzOTV?Tp;M?}Eq(#|KFPE8Q(k|FUeRZ?`kJ8u+AGuV5h3)9 znsP7rLgvcvy*y$|$ivi3^>k@EdJ2``PsXF-a6&(d6cc`x8=~JyP-$?bT)25#qbe(%gF{yM82QY(luBC(FzaW{{74$MOa%iULyN*xJvuIWG5=P@ZoIXgz#o zm-c8HUJUx3A)CEa5}O+YG!XWk=EcoTYUx@%o_G$Dt$k*WTnOF#79EVFxZe^J^Ox3Gryto-s;>p@%Bl5>zvFYcV@wxje8u zW3TI<4G&w0N!=W{qa7)ez0yQo4>{$eEKA14?FC-fCg)Ap&9*L=_>ncjuWM^K=< z#d!vJg)8kkfnJGX#>QCYUu6#V2Y=OdJMHHZ7#|1zTbGIXN!IOxF^+>x!?7r4_~JKh zF1A2D2{$M{^kepv7De3bwn_p@Jf1)4ea+-Apdhc2s|$L}jq=0s zx-wxhasA;xQYaW5w~i1F42`t#71}~4VH1miN$=B(pi21#uOu;PWh!aUU^%eR+Dr>; z{s(Ju)YY4f7ZL19IzZzC&i^u_Dd5)r$wfkc;GOX%cw!Npa2$?uio4A#{YB%6*Mab8 ze|={1>~)Y;JN}#*;;*C0l1^B-rDVYC1wA=vRj21pJkN-xB$h7i?D`Ue1}bjI-;=Ja zjJJzv8QHw`LUOfvZX$u}yzWmntdvmMY?E zdVHqD6<$@T3o@)+Jv>fue;vQl6CLUq99*TAznG63s?pe%T2hUbM@Ts^4VTFa(Q0Yr z#Y_NNl&HJ1aCvCcemCU)$fmW#jR1s}m0Q*JY{v|0c05-l9CaN;Tg8Qr=Yt1 zf{@S3M7J(bogE&3_Skcb_(iwBsRi$(7WQ#@dA3l#LeJ*!Tkf92!!uL14wSZ#jN5lT zN$k@a5cVfMyw6Kz({40#R&5jI^EHy=W2@cwUun)wP5#9SE?BUkRL(EitWk~^*e6ap>&3=k`ubwa=#oGcW zSnrXTnB*k}6I}l%nbVn+l@aq;(u_?4&_+w?Dy!{k;$UZIX738JurlXyv9Z!vEt&vM Pfj-D8zblqD{Oex;\n\n → ✓ Braid starter trusted · read-only · Braid starte\n\n enter · ^V valid · ^S save · esc\n\n\n────────────────────────────────────────────────────────────────────────────────\n\n────────────────────────────────────────────────────────────────────────────────\nready for a message fixture/deterministic · thinking none\nBraid starter · runner pi · deterministic fixture Ctrl+P commands\n", + "frame": "braid cwd repo · session New conversation\n\n Write a message, or press Ctrl+P for commands.\n\n\n\n Active profile · Braid starter\n source Braid starter · trusted · read-only\n runner pi · model fixture/deterministic\n digest sha256:02a2ebdd…29866660\n profiles\n\n >\n\n → ✓ Braid starter trusted · read-only · Braid starte\n\n enter · ^V valid · ^S save · esc\n\n\n────────────────────────────────────────────────────────────────────────────────\n\n────────────────────────────────────────────────────────────────────────────────\nready for a message fixture/deterministic · thinking none\nBraid starter · runner pi · deterministic fixture Ctrl+P commands\n", "packedState": { "schemaVersion": 2, "capturePhase": "atomic-signal-frame", @@ -38,7 +38,7 @@ "schemaVersion": 2, "revision": 1, "sequence": 1, - "workspace": "/home/drew/code/.worktrees/braid-integration", + "workspace": "/tmp/braid-publish.Jme9u3/repo", "conversationId": "conv-1", "branchId": "branch-1", "conversations": [ @@ -69,7 +69,7 @@ }, "view": { "revision": 1, - "workspace": "/home/drew/code/.worktrees/braid-integration", + "workspace": "/tmp/braid-publish.Jme9u3/repo", "profileName": "Braid starter", "profileDigest": "02a2ebddee943473ae42c9fa387abaa956799ab7791b68033006dd4029866660", "runner": "pi", @@ -268,7 +268,7 @@ "payload": { "value": { "kind": "workspace.opened", - "workspace": "/home/drew/code/.worktrees/braid-integration" + "workspace": "/tmp/braid-publish.Jme9u3/repo" } } } diff --git a/artifacts/verification/w6/states/profile.png b/artifacts/verification/w6/states/profile.png index 3e1ad390b5d01a94ada123bcc993606fdf6e426d..5729604dcf4a61867aac2e45356316f8a871793a 100644 GIT binary patch literal 37217 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%0A#t^%s>a8cSE^^F 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 diff --git a/artifacts/verification/w6/states/profile.txt b/artifacts/verification/w6/states/profile.txt index 502cea2..c66ee8e 100644 --- a/artifacts/verification/w6/states/profile.txt +++ b/artifacts/verification/w6/states/profile.txt @@ -1,4 +1,4 @@ -braid cwd braid-integration · session New conversation +braid cwd repo · session New conversation Write a message, or press Ctrl+P for commands. diff --git a/scripts/capture-visual.mjs b/scripts/capture-visual.mjs index 2afff2f..15da2ea 100644 --- a/scripts/capture-visual.mjs +++ b/scripts/capture-visual.mjs @@ -212,19 +212,26 @@ async function spawnTerminal(name, columns, rows, extraEnvironment = {}, uiFixtu } function castFor(result, events, title) { + const lastEventAt = events.at(-1)?.[0] ?? 0 + // agg samples a percentage position before applying an output event that lands + // exactly at the cast duration. A later no-op makes every split final write + // visible in the selected raster without changing the terminal frame. + const settledEvents = [...events, [Number((lastEventAt + 0.01).toFixed(6)), 'o', '\u001b[0m']] const header = { version: 2, width: result.columns, height: result.rows, timestamp: Math.floor(Date.now() / 1_000), - duration: events.at(-1)?.[0] ?? 0, + duration: settledEvents.at(-1)[0], idle_time_limit: 1, command: 'packed braid --fixture deterministic', title, env: { TERM: 'xterm-256color' }, stdin: true, } - return [JSON.stringify(header), ...events.map((event) => JSON.stringify(event)), ''].join('\n') + return [JSON.stringify(header), ...settledEvents.map((event) => JSON.stringify(event)), ''].join( + '\n', + ) } async function plainFrame() {