heyspex 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -0
- package/bin/hs.mjs +931 -98
- package/bin/spexcode.mjs +262 -0
- package/dev-factory/engine/worktree.mjs +740 -0
- package/dev-factory/lib/code-repo-root.mjs +67 -0
- package/dev-factory/lib/context-budget.mjs +84 -0
- package/dev-factory/lib/epic-detect.mjs +190 -0
- package/dev-factory/lib/fleet-queue-file.mjs +846 -0
- package/dev-factory/lib/fleet-queue.mjs +426 -0
- package/dev-factory/lib/fleet-state.mjs +14 -18
- package/dev-factory/lib/model-catalog.mjs +338 -0
- package/dev-factory/lib/seat-context.mjs +199 -0
- package/dev-factory/lib/spec-io.mjs +93 -0
- package/dev-factory/lib/sufficiency-score.mjs +8 -2
- package/dev-factory/lib/worktree-allocator.mjs +744 -0
- package/dev-factory/model-catalog.json +222 -0
- package/dist/cli/hs-check.mjs +1085 -103
- package/dist/cli/hs-claim.mjs +936 -63
- package/dist/cli/hs-connect.mjs +9786 -1910
- package/dist/cli/hs-defer.mjs +5267 -3053
- package/dist/cli/hs-dev.mjs +564 -0
- package/dist/cli/hs-env.mjs +385 -299
- package/dist/cli/hs-index-rebuild.mjs +7129 -0
- package/dist/cli/hs-index-reconcile.mjs +7852 -0
- package/dist/cli/hs-init.mjs +777 -138
- package/dist/cli/hs-install.mjs +6583 -485
- package/dist/cli/hs-join.mjs +10137 -2368
- package/dist/cli/hs-vault-pull.mjs +4659 -5882
- package/dist/cli/hs-vault.mjs +3793 -1537
- package/dist/cli/hs-workspace.mjs +2550 -524
- package/dist/cli-templates/customer-vault/specs/entity-model.md +594 -601
- package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +110 -177
- package/dist/cli-templates/customer-vault/specs/protocols/destructive-action-checklist.md +122 -0
- package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +13 -15
- package/dist/cli-templates/customer-vault/specs/protocols/session-close-protocol.md +91 -0
- package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +150 -168
- package/dist/cli-templates/customer-vault/specs/protocols/spec-summary-authoring.md +83 -0
- package/dist/cli-templates/harness-commands/claude/hs-auto.md +36 -0
- package/dist/cli-templates/harness-commands/claude/hs-backfill.md +28 -0
- package/dist/cli-templates/harness-commands/claude/hs-capture.md +62 -0
- package/dist/cli-templates/harness-commands/claude/hs-defer.md +29 -0
- package/dist/cli-templates/harness-commands/claude/hs-dev.md +20 -0
- package/dist/cli-templates/harness-commands/claude/hs-doctor.md +32 -0
- package/dist/cli-templates/harness-commands/claude/hs-ground.md +28 -0
- package/dist/cli-templates/harness-commands/claude/hs-handoff.md +33 -0
- package/dist/cli-templates/harness-commands/claude/hs-log.md +27 -0
- package/dist/cli-templates/harness-commands/claude/hs-next.md +29 -0
- package/dist/cli-templates/harness-commands/claude/hs-promote.md +70 -0
- package/dist/cli-templates/harness-commands/claude/hs-push.md +35 -0
- package/dist/cli-templates/harness-commands/claude/hs-qc.md +27 -0
- package/dist/cli-templates/harness-commands/claude/hs-res.md +63 -0
- package/dist/cli-templates/harness-commands/claude/hs-resolve.md +23 -0
- package/dist/cli-templates/harness-commands/claude/hs-review.md +23 -0
- package/dist/cli-templates/harness-commands/claude/hs-view.md +26 -0
- package/dist/cli-templates/harness-commands/claude/kill-ports.md +32 -0
- package/dist/cli-templates/harness-commands/claude/prototype.md +91 -0
- package/dist/cli-templates/harness-commands/cursor/hs-auto.md +40 -0
- package/dist/cli-templates/harness-commands/cursor/hs-backfill.md +31 -0
- package/dist/cli-templates/harness-commands/cursor/hs-capture.md +65 -0
- package/dist/cli-templates/harness-commands/cursor/hs-defer.md +32 -0
- package/dist/cli-templates/harness-commands/cursor/hs-dev.md +24 -0
- package/dist/cli-templates/harness-commands/cursor/hs-doctor.md +36 -0
- package/dist/cli-templates/harness-commands/cursor/hs-ground.md +31 -0
- package/dist/cli-templates/harness-commands/cursor/hs-handoff.md +37 -0
- package/dist/cli-templates/harness-commands/cursor/hs-log.md +31 -0
- package/dist/cli-templates/harness-commands/cursor/hs-next.md +32 -0
- package/dist/cli-templates/harness-commands/cursor/hs-promote.md +73 -0
- package/dist/cli-templates/harness-commands/cursor/hs-push.md +38 -0
- package/dist/cli-templates/harness-commands/cursor/hs-qc.md +30 -0
- package/dist/cli-templates/harness-commands/cursor/hs-res.md +67 -0
- package/dist/cli-templates/harness-commands/cursor/hs-resolve.md +26 -0
- package/dist/cli-templates/harness-commands/cursor/hs-review.md +26 -0
- package/dist/cli-templates/harness-commands/cursor/hs-view.md +30 -0
- package/dist/cli-templates/harness-commands/cursor/kill-ports.md +35 -0
- package/dist/cli-templates/harness-commands/cursor/prototype.md +95 -0
- package/docs/mcp-client-config.md +259 -6
- package/package.json +231 -124
- package/plugins/heyspex/.claude-plugin/plugin.json +12 -0
- package/plugins/heyspex/.cursor-plugin/plugin.json +15 -0
- package/plugins/heyspex/.mcp.json +12 -0
- package/plugins/heyspex/bin/launch-mcp.mjs +95 -0
- package/plugins/heyspex/bin/run-hook.mjs +62 -0
- package/plugins/heyspex/hooks/cursor-hooks.json +227 -0
- package/plugins/heyspex/hooks/hooks.json +361 -0
- package/plugins/heyspex/mcp.json +13 -0
- package/scripts/check-mcp-freshness.mjs +17 -9
- package/scripts/claude-hooks/_env-vault-events.mjs +68 -0
- package/scripts/claude-hooks/_guardrail-events.mjs +96 -0
- package/scripts/claude-hooks/_guardrail-loader.mjs +494 -0
- package/scripts/claude-hooks/_intent-classifier-router.mjs +410 -0
- package/scripts/claude-hooks/_intent-classifier-semantic.mjs +351 -0
- package/scripts/claude-hooks/_run.mjs +120 -0
- package/scripts/claude-hooks/_transcript-text.mjs +60 -0
- package/scripts/claude-hooks/_vault-retrieval.mjs +322 -0
- package/scripts/claude-hooks/agent-spawn-delegation-gate.mjs +133 -0
- package/scripts/claude-hooks/capture-workflow-gate.mjs +11 -0
- package/scripts/claude-hooks/check-auto-memory-budget.mjs +396 -0
- package/scripts/claude-hooks/check-claude-md-budget.mjs +327 -0
- package/scripts/claude-hooks/check-mcp-freshness-hook.mjs +14 -0
- package/scripts/claude-hooks/claim-before-code-gate.mjs +11 -0
- package/scripts/claude-hooks/claim-liveness-shared.mjs +575 -0
- package/scripts/claude-hooks/close-terminal-on-ship.mjs +64 -0
- package/scripts/claude-hooks/coo-denylist-gate.mjs +11 -0
- package/scripts/claude-hooks/coo-guard-check.mjs +11 -0
- package/scripts/claude-hooks/deep-research-cost-gate.mjs +11 -0
- package/scripts/claude-hooks/dev-server-task-reminder.mjs +15 -0
- package/scripts/claude-hooks/emit-claim-status.mjs +41 -0
- package/scripts/claude-hooks/enforce-session-sequence.mjs +11 -0
- package/scripts/claude-hooks/enforce-vault-naming.mjs +11 -0
- package/scripts/claude-hooks/env-vault-guard.mjs +11 -0
- package/scripts/claude-hooks/epic-milestone-phrasing-gate.mjs +11 -0
- package/scripts/claude-hooks/git-identity-guard.mjs +11 -0
- package/scripts/claude-hooks/git-reflog-audit.mjs +15 -0
- package/scripts/claude-hooks/git-stash-gate.mjs +11 -0
- package/scripts/claude-hooks/guard-auto-memory-writes.mjs +137 -0
- package/scripts/claude-hooks/id-display-format-gate.mjs +11 -0
- package/scripts/claude-hooks/inject-communication-style.mjs +66 -0
- package/scripts/claude-hooks/inject-coo-seat.mjs +78 -0
- package/scripts/claude-hooks/inject-dev-server-awareness.mjs +11 -0
- package/scripts/claude-hooks/inject-doctrine.mjs +13 -0
- package/scripts/claude-hooks/inject-vault-context.mjs +14 -0
- package/scripts/claude-hooks/intent-classifier-trigger.mjs +247 -0
- package/scripts/claude-hooks/log-session-activity.mjs +341 -0
- package/scripts/claude-hooks/naughty-list-gate.mjs +11 -0
- package/scripts/claude-hooks/notify-status-flip.mjs +72 -0
- package/scripts/claude-hooks/plan-exit-ingest-gate.mjs +170 -0
- package/scripts/claude-hooks/plan-ingest-close-reminder.mjs +66 -0
- package/scripts/claude-hooks/plan-review-gate.mjs +11 -0
- package/scripts/claude-hooks/preflight-reminder.mjs +262 -0
- package/scripts/claude-hooks/protected-action-intent.mjs +221 -0
- package/scripts/claude-hooks/research-method-guard.mjs +398 -0
- package/scripts/claude-hooks/research-protocol-gate.mjs +11 -0
- package/scripts/claude-hooks/session-end.mjs +449 -0
- package/scripts/claude-hooks/settings-write-gate.mjs +11 -0
- package/scripts/claude-hooks/settlement-check-gate.mjs +39 -0
- package/scripts/claude-hooks/status-flip-detect.mjs +17 -0
- package/scripts/claude-hooks/terminal-close-shared.mjs +426 -0
- package/scripts/claude-hooks/terminal-close-watcher.mjs +122 -0
- package/scripts/claude-hooks/wip-checkpoint.mjs +298 -0
- package/scripts/cursor-hooks/manifest.mjs +235 -0
- package/scripts/dev-server-status.mjs +34 -13
- package/scripts/dev-server-stop.mjs +313 -0
- package/scripts/heyspex-mode.mjs +268 -0
- package/scripts/hs-auto-resolve.mjs +240 -0
- package/scripts/hs-auto-source.mjs +398 -0
- package/scripts/hs-auto.mjs +134 -0
- package/scripts/hs-capture-cli.mjs +326 -0
- package/scripts/hs-classifier.mjs +136 -0
- package/scripts/hs-companion.mjs +88 -0
- package/scripts/hs-gateway.mjs +115 -0
- package/scripts/hs-mode.mjs +125 -0
- package/scripts/hs-next.mjs +441 -0
- package/scripts/hs-settings.mjs +338 -0
- package/scripts/hs-view.mjs +333 -0
- package/scripts/hs-worktree.mjs +201 -0
- package/scripts/install-claude-code-hooks.mjs +270 -0
- package/scripts/install-cursor-hooks.mjs +471 -0
- package/scripts/lib/agent-env-markers.mjs +38 -0
- package/scripts/lib/console-url-resolver.mjs +197 -60
- package/scripts/lib/customer-kit.mjs +886 -0
- package/scripts/lib/dev-mode.mjs +244 -28
- package/scripts/lib/heyspex-home.mjs +72 -0
- package/scripts/lib/hs-command.mjs +277 -0
- package/scripts/lib/hs-companion-settings.mjs +472 -0
- package/scripts/lib/hs-source-commit.mjs +59 -5
- package/scripts/lib/hs-source-identity.mjs +526 -0
- package/scripts/lib/hs-source-log.mjs +11 -21
- package/scripts/lib/hs-source-push.mjs +107 -58
- package/scripts/lib/hs-worktree-args.mjs +115 -0
- package/scripts/lib/shared-path-helpers.mjs +134 -0
- package/scripts/lib/standalone-doctor.mjs +540 -3
- package/scripts/lib/subagent-git-lock.mjs +109 -0
- package/scripts/lib/template-pin.mjs +34 -0
- package/scripts/provenance-lib.mjs +91 -0
- package/scripts/stamp-provenance-on-edit.mjs +222 -0
- package/scripts/wip-ledger-health.mjs +653 -0
- package/spexharness/doctrine/baseline/SPEXCODE-DOCTRINE.baseline.json +29 -0
- package/spexharness/src/adapters/audit.mjs +197 -0
- package/spexharness/src/adapters/claude-code.mjs +441 -0
- package/spexharness/src/chat/tui/ansi.mjs +448 -0
- package/spexharness/src/chat/tui/palette.mjs +22 -0
- package/spexharness/src/chat/tui/select.mjs +208 -0
- package/spexharness/src/config-root.mjs +54 -0
- package/spexharness/src/context-budget.mjs +141 -0
- package/spexharness/src/dev-server-control.mjs +389 -0
- package/spexharness/src/doctrine/behaviour.mjs +371 -0
- package/spexharness/src/doctrine/corpus.mjs +733 -0
- package/spexharness/src/doctrine/inject.mjs +338 -0
- package/spexharness/src/doctrine/merge.mjs +184 -0
- package/spexharness/src/doctrine/sources.mjs +252 -0
- package/spexharness/src/doctrine/synthesize.mjs +401 -0
- package/spexharness/src/doctrine/vault-source.mjs +574 -0
- package/spexharness/src/doctrine/writer.mjs +176 -0
- package/spexharness/src/events.mjs +84 -0
- package/spexharness/src/gates/capture-workflow-gate.mjs +202 -0
- package/spexharness/src/gates/claim-before-code-gate.decide.mjs +33 -0
- package/spexharness/src/gates/claim-before-code-gate.mjs +129 -0
- package/spexharness/src/gates/deep-research-cost-gate.mjs +274 -0
- package/spexharness/src/gates/enforce-session-sequence.mjs +895 -0
- package/spexharness/src/gates/enforce-vault-naming.mjs +215 -0
- package/spexharness/src/gates/epic-milestone-phrasing-gate.mjs +233 -0
- package/spexharness/src/gates/git-identity-guard.decide.mjs +436 -0
- package/spexharness/src/gates/git-identity-guard.mjs +61 -0
- package/spexharness/src/gates/git-reflog-audit.mjs +151 -0
- package/spexharness/src/gates/git-stash-gate.decide.mjs +361 -0
- package/spexharness/src/gates/git-stash-gate.mjs +159 -0
- package/spexharness/src/gates/id-display-format-gate.mjs +190 -0
- package/spexharness/src/gates/inject-doctrine-gate.mjs +89 -0
- package/spexharness/src/gates/inject-vault-context.mjs +239 -0
- package/spexharness/src/gates/naughty-list-gate.mjs +213 -0
- package/spexharness/src/gates/plan-review-gate.mjs +233 -0
- package/spexharness/src/gates/preflight-reminder-gate.mjs +265 -0
- package/spexharness/src/gates/research-protocol-gate.mjs +175 -0
- package/spexharness/src/gates/settings-write-gate.decide.mjs +96 -0
- package/spexharness/src/gates/settings-write-gate.mjs +104 -0
- package/spexharness/src/git-reflog-audit.mjs +294 -0
- package/spexharness/src/intent-classifier.mjs +194 -0
- package/spexharness/src/intent-directive.mjs +541 -0
- package/spexharness/src/profile/learn.mjs +551 -0
- package/spexharness/src/profile/proposals.mjs +357 -0
- package/spexharness/src/profile/vault-inclusion.mjs +386 -0
- package/spexharness/src/redact.mjs +151 -0
- package/spexharness/src/rule-table.mjs +819 -0
- package/spexharness/src/seat-context.mjs +122 -0
- package/spexharness/src/session-bootstrap.mjs +512 -0
- package/spexharness/src/session-record.mjs +815 -0
- package/spexharness/src/token-inventory.mjs +207 -0
- package/vendor/heyspex-mcp/account-switch.mjs +95 -0
- package/vendor/heyspex-mcp/alignment-gate.mjs +315 -1
- package/vendor/heyspex-mcp/blocker-resolver.mjs +37 -2
- package/vendor/heyspex-mcp/breakdown-ladder.mjs +119 -4
- package/vendor/heyspex-mcp/breakdown-source.mjs +271 -0
- package/vendor/heyspex-mcp/breakdown-writer.mjs +23 -3
- package/vendor/heyspex-mcp/capture-buffer-patches.mjs +46 -5
- package/vendor/heyspex-mcp/capture-channel.mjs +55 -5
- package/vendor/heyspex-mcp/capture-console-client.mjs +80 -1
- package/vendor/heyspex-mcp/capture-focus.mjs +1035 -29
- package/vendor/heyspex-mcp/capture-outbox.mjs +5 -1
- package/vendor/heyspex-mcp/capture-tools.mjs +1219 -0
- package/vendor/heyspex-mcp/claim-tools.mjs +1124 -0
- package/vendor/heyspex-mcp/cli-verbs.mjs +2076 -0
- package/vendor/heyspex-mcp/cli.mjs +201 -15524
- package/vendor/heyspex-mcp/cloud-tool-surface.mjs +128 -0
- package/vendor/heyspex-mcp/cloud-vault-writes.mjs +412 -0
- package/vendor/heyspex-mcp/config-seed.yaml +15 -0
- package/vendor/heyspex-mcp/config-settings.mjs +290 -7
- package/vendor/heyspex-mcp/console-fetch.mjs +657 -16
- package/vendor/heyspex-mcp/console-transport.mjs +166 -0
- package/vendor/heyspex-mcp/credentials.mjs +584 -0
- package/vendor/heyspex-mcp/defer-spec.mjs +44 -4
- package/vendor/heyspex-mcp/deferred-reclaim.mjs +29 -3
- package/vendor/heyspex-mcp/design-handlers.mjs +31 -0
- package/vendor/heyspex-mcp/design-tools.mjs +4 -1
- package/vendor/heyspex-mcp/dev-factory-bridge.mjs +4 -4
- package/vendor/heyspex-mcp/dev-mode-core.mjs +45 -0
- package/vendor/heyspex-mcp/dev-tools.mjs +124 -0
- package/vendor/heyspex-mcp/diagnose-handlers.mjs +54 -0
- package/vendor/heyspex-mcp/events-tools.mjs +207 -0
- package/vendor/heyspex-mcp/filing-tools.mjs +688 -0
- package/vendor/heyspex-mcp/fleet-handlers.mjs +37 -0
- package/vendor/heyspex-mcp/fleet-queue-tools.mjs +69 -14
- package/vendor/heyspex-mcp/focus-tools.mjs +190 -0
- package/vendor/heyspex-mcp/folder-trust-store.mjs +59 -0
- package/vendor/heyspex-mcp/http-transport.mjs +300 -0
- package/vendor/heyspex-mcp/list-tools.mjs +257 -0
- package/vendor/heyspex-mcp/login-handoff.mjs +15 -1
- package/vendor/heyspex-mcp/mcp-config.mjs +94 -0
- package/vendor/heyspex-mcp/memory-tools.mjs +523 -0
- package/vendor/heyspex-mcp/mvp-mode.mjs +53 -6
- package/vendor/heyspex-mcp/occupied-keys.mjs +141 -0
- package/vendor/heyspex-mcp/on-deck-brief.mjs +3360 -0
- package/vendor/heyspex-mcp/on-deck-scheduler.mjs +1708 -0
- package/vendor/heyspex-mcp/package-root.mjs +32 -0
- package/vendor/heyspex-mcp/peer-presence.mjs +8 -1
- package/vendor/heyspex-mcp/plan-ground.mjs +621 -652
- package/vendor/heyspex-mcp/plan-push.mjs +906 -21
- package/vendor/heyspex-mcp/plan-review.mjs +124 -30
- package/vendor/heyspex-mcp/preflight-gate.mjs +0 -0
- package/vendor/heyspex-mcp/procedure-handlers.mjs +325 -0
- package/vendor/heyspex-mcp/procedure-tools.mjs +540 -351
- package/vendor/heyspex-mcp/promote-capture.mjs +386 -69
- package/vendor/heyspex-mcp/promote-diagnose.mjs +74 -0
- package/vendor/heyspex-mcp/promote-research-to-diagnose.mjs +9 -2
- package/vendor/heyspex-mcp/promote-research.mjs +660 -53
- package/vendor/heyspex-mcp/promotion-helpers.mjs +251 -0
- package/vendor/heyspex-mcp/prompts.mjs +1460 -0
- package/vendor/heyspex-mcp/read-path.mjs +29 -9
- package/vendor/heyspex-mcp/receipt-shape.mjs +146 -0
- package/vendor/heyspex-mcp/recipe-handlers.mjs +117 -0
- package/vendor/heyspex-mcp/regen-tasks.mjs +210 -3
- package/vendor/heyspex-mcp/register-spec.mjs +266 -54
- package/vendor/heyspex-mcp/request-context.mjs +59 -0
- package/vendor/heyspex-mcp/research-focus.mjs +207 -4
- package/vendor/heyspex-mcp/research-handlers.mjs +623 -0
- package/vendor/heyspex-mcp/research-tools.mjs +4 -4
- package/vendor/heyspex-mcp/response-budget.mjs +348 -0
- package/vendor/heyspex-mcp/review-focus.mjs +25 -1
- package/vendor/heyspex-mcp/search-specs.mjs +280 -0
- package/vendor/heyspex-mcp/seat-block-ledger.mjs +113 -0
- package/vendor/heyspex-mcp/seat-identity.mjs +2 -6
- package/vendor/heyspex-mcp/session-owner.mjs +108 -0
- package/vendor/heyspex-mcp/session-tools.mjs +152 -0
- package/vendor/heyspex-mcp/settings-registry.mjs +232 -0
- package/vendor/heyspex-mcp/settings-tools.mjs +85 -12
- package/vendor/heyspex-mcp/spec-edit-tools.mjs +446 -0
- package/vendor/heyspex-mcp/spec-id-lib.mjs +93 -17
- package/vendor/heyspex-mcp/split-research.mjs +301 -56
- package/vendor/heyspex-mcp/stdio-rpc.mjs +22 -0
- package/vendor/heyspex-mcp/submit-for-review.mjs +132 -6
- package/vendor/heyspex-mcp/summary-contract.mjs +105 -0
- package/vendor/heyspex-mcp/summary-write-tools.mjs +224 -0
- package/vendor/heyspex-mcp/symptom-query.mjs +146 -0
- package/vendor/heyspex-mcp/test-helpers.mjs +108 -1
- package/vendor/heyspex-mcp/tool-catalog.mjs +2813 -0
- package/vendor/heyspex-mcp/tool-dispatch.mjs +639 -0
- package/vendor/heyspex-mcp/tool-helpers.mjs +325 -0
- package/vendor/heyspex-mcp/validate-linkage.mjs +414 -27
- package/vendor/heyspex-mcp/vault-io.mjs +724 -0
- package/vendor/heyspex-mcp/vault-less-endpoints.mjs +52 -0
- package/vendor/heyspex-mcp/vault-resolve.mjs +366 -68
- package/vendor/heyspex-mcp/verify-reproduction.mjs +270 -30
- package/vendor/heyspex-mcp/whoami.mjs +91 -0
|
@@ -0,0 +1,815 @@
|
|
|
1
|
+
// spexharness/src/session-record.mjs
|
|
2
|
+
//
|
|
3
|
+
// The harness's session record: what one bounded loop run (spexharness/src/loop.mjs) did.
|
|
4
|
+
// A record carries the run itself — what was asked, the tool calls made in order, and what
|
|
5
|
+
// the model produced — plus the events the harness saw (events.mjs) with the decision
|
|
6
|
+
// decide.mjs made for each. This is the ONLY thing this module builds: the record. Per the
|
|
7
|
+
// owner's ruling (research HS-9XWJ-KB6H, filed 2026-08-26), no training path, export, or
|
|
8
|
+
// collector is built on top of it here — the classifier log, SpexDoctrine and any future
|
|
9
|
+
// adapter work are separate mechanisms on the same engine, none feeding another's training
|
|
10
|
+
// data from this module.
|
|
11
|
+
//
|
|
12
|
+
// Shape and persistence are modeled on dev-factory/lib/run-events.mjs — not a fresh
|
|
13
|
+
// implementation of "append-only JSONL that survives a crash mid-write": one JSONL line per
|
|
14
|
+
// record (open "a" + one writeSync + close, so concurrent writers never interleave into a
|
|
15
|
+
// corrupt line), and a corrupt/partial trailing line is skipped on read rather than thrown.
|
|
16
|
+
// The domain is different (one line = one whole session's record, not one lifecycle
|
|
17
|
+
// transition), so this is a sibling module, not a re-export of run-events.mjs.
|
|
18
|
+
//
|
|
19
|
+
// Pure-ish: the record-building helpers (createSessionRecord, recordEvent, recordToolCall,
|
|
20
|
+
// finishSessionRecord) do no I/O; only appendSessionRecord/readSessionRecords touch disk.
|
|
21
|
+
//
|
|
22
|
+
// ---------------------------------------------------------------------------------------
|
|
23
|
+
// DURABLE SESSION STORE (task HS-WJIR, epic HS-Y646 AC4)
|
|
24
|
+
// ---------------------------------------------------------------------------------------
|
|
25
|
+
// The JSONL half above is per-machine: a record appended on `specops-gpu0` is invisible to a
|
|
26
|
+
// seat opened on another box. The epic's fourth criterion — "a session closed on one machine
|
|
27
|
+
// is resumed by id on another with its claims and WIP intact" — needs a shared store, so the
|
|
28
|
+
// second half of this module adds one: a session record keyed by session id that carries the
|
|
29
|
+
// session's CLAIMS, its hand-off WIP block and its TOOL-CALL LOG, backed by Neon.
|
|
30
|
+
//
|
|
31
|
+
// Storage is behind a two-method backend (`read`/`write`, plus optional `list`/`delete`) and
|
|
32
|
+
// is INJECTABLE. Neon is the default; the tests inject an in-memory backend, so no test in
|
|
33
|
+
// this package ever needs a live database or a DATABASE_URL. That split is the same posture
|
|
34
|
+
// the rest of the harness takes with its loaders (see session-bootstrap.mjs's `loaders`).
|
|
35
|
+
//
|
|
36
|
+
// The Neon path is not a new connection style: it is the plain-.mjs pattern the repo already
|
|
37
|
+
// uses off the Next.js app — a lazy `await import('@neondatabase/serverless')`, `neon(url)`,
|
|
38
|
+
// and DATABASE_URL_UNPOOLED ?? DATABASE_URL ?? ~/.heyspex/credentials.yaml (identical to
|
|
39
|
+
// scripts/backfill-active-claims-to-neon.mjs's --direct-db path). The row shape mirrors
|
|
40
|
+
// spex-console's `wip_ledger` (`payload jsonb` + `updated_at`) rather than exploding the
|
|
41
|
+
// record into columns, because the record's interior is the harness's own evolving shape and
|
|
42
|
+
// the console never queries into it.
|
|
43
|
+
|
|
44
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, writeSync } from 'node:fs';
|
|
45
|
+
import { homedir, hostname } from 'node:os';
|
|
46
|
+
import { dirname, join } from 'node:path';
|
|
47
|
+
|
|
48
|
+
import { redactRecord } from './redact.mjs';
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Create a fresh, empty session record. Mutated in place by the record* helpers below as a
|
|
52
|
+
* loop runs, then persisted once via {@link appendSessionRecord}.
|
|
53
|
+
*
|
|
54
|
+
* @param {{ sessionId?: string|null, ask?: unknown }} [opts]
|
|
55
|
+
* @returns {{
|
|
56
|
+
* sessionId: string|null,
|
|
57
|
+
* ask: unknown,
|
|
58
|
+
* startedAt: string,
|
|
59
|
+
* events: Array<{ point: string, ts: number, decision: object|null }>,
|
|
60
|
+
* toolCalls: Array<{ name: string|null, input: unknown, output: unknown }>,
|
|
61
|
+
* confirms: Array<{ id: string|null, stepIndex: number|null, skill: string|null, approved: boolean, used: boolean, at: string }>,
|
|
62
|
+
* output: string|null,
|
|
63
|
+
* endedAt: string|null,
|
|
64
|
+
* }}
|
|
65
|
+
*/
|
|
66
|
+
export function createSessionRecord({ sessionId = null, ask = null } = {}) {
|
|
67
|
+
return {
|
|
68
|
+
sessionId,
|
|
69
|
+
ask, // what was asked — the run's initiating prompt/intent
|
|
70
|
+
startedAt: new Date().toISOString(),
|
|
71
|
+
events: [], // ordered — every gate-checked event point the harness saw, with its decision
|
|
72
|
+
toolCalls: [], // ordered — every tool call the loop made
|
|
73
|
+
// ordered — every confirm point a skill (skill-invoke.mjs's invokeSkill) or a scripted
|
|
74
|
+
// run (scripted-mode.mjs's scriptedConfirmHandler) answered, approved or not. This is the
|
|
75
|
+
// durable record spexharness/src/gates/confirm-gate.mjs reads to decide whether a
|
|
76
|
+
// promote/file tool call may proceed ([[HS-U0MC-NYPR-TASK-add-a-gate-that-refuses-a-promote-or]]).
|
|
77
|
+
confirms: [],
|
|
78
|
+
output: null, // what the model produced — the loop's final answer text
|
|
79
|
+
endedAt: null,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Append one answered confirm point to the record, in the order it was answered. The shape
|
|
85
|
+
* mirrors skill-invoke.mjs's own `confirms` entries (`{ id, stepIndex, approved, cues }`) so a
|
|
86
|
+
* caller wiring `invokeSkill`'s per-step confirm callback can record here with no translation.
|
|
87
|
+
* `skill` is optional context a caller may supply (skill-invoke.mjs's `plan.skill`) so
|
|
88
|
+
* {@link consumeApprovedConfirm} can match on it when no `id` is available.
|
|
89
|
+
*
|
|
90
|
+
* `used` starts `false` — an approval is a single-use token
|
|
91
|
+
* ([[HS-U0MC-NYPR-TASK-add-a-gate-that-refuses-a-promote-or]] QC fix: a session that answered
|
|
92
|
+
* ONE confirm must not unlock EVERY later confirm-required exit). Only
|
|
93
|
+
* {@link consumeApprovedConfirm} ever flips it to `true`.
|
|
94
|
+
*
|
|
95
|
+
* @param {ReturnType<typeof createSessionRecord>} record
|
|
96
|
+
* @param {{ id?: string|null, stepIndex?: number|null, skill?: string|null, approved: boolean }} confirm
|
|
97
|
+
* @returns {ReturnType<typeof createSessionRecord>} the same record, for chaining.
|
|
98
|
+
*/
|
|
99
|
+
export function recordConfirm(record, { id = null, stepIndex = null, skill = null, approved } = {}) {
|
|
100
|
+
record.confirms.push({
|
|
101
|
+
id,
|
|
102
|
+
stepIndex: stepIndex ?? null,
|
|
103
|
+
skill: skill ?? null,
|
|
104
|
+
approved: Boolean(approved),
|
|
105
|
+
used: false,
|
|
106
|
+
at: new Date().toISOString(),
|
|
107
|
+
});
|
|
108
|
+
return record;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Has ANY confirm point on `record` been recorded as approved? A read-only existence check —
|
|
113
|
+
* it does NOT consume anything and is unaffected by a prior {@link consumeApprovedConfirm}
|
|
114
|
+
* call, so it stays useful for "did this session ever answer a confirm at all" reporting.
|
|
115
|
+
* The engine's `before_tool` decision itself must never be gated on this alone — see
|
|
116
|
+
* {@link consumeApprovedConfirm}, which is the predicate spexharness/src/gates/confirm-gate.mjs
|
|
117
|
+
* actually runs before letting a promote/file tool call through.
|
|
118
|
+
*
|
|
119
|
+
* @param {{ confirms?: Array<{ approved?: boolean }> }|null|undefined} record
|
|
120
|
+
* @returns {boolean}
|
|
121
|
+
*/
|
|
122
|
+
export function hasApprovedConfirm(record) {
|
|
123
|
+
return Array.isArray(record?.confirms) && record.confirms.some((c) => c && c.approved === true);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Consume ONE approved, not-yet-used confirm off `record` and return whether one was found.
|
|
128
|
+
* This is the fix for the QC finding on {@link hasApprovedConfirm}'s old ANY-check: that
|
|
129
|
+
* predicate never marked anything used, so one approved confirm (e.g. a `/hs-capture`
|
|
130
|
+
* confirm) satisfied every later confirm-required exit in the session with no further
|
|
131
|
+
* confirm answered — filing 40 specs, then submitting for review, all on one "yes". A
|
|
132
|
+
* confirm is now a single-use token: the first gated call that consumes it marks it
|
|
133
|
+
* `used: true` on `record` (mutating in place, so the SAME record object a caller keeps
|
|
134
|
+
* passing to the gate sees the consumption on the very next check), and every subsequent
|
|
135
|
+
* confirm-required action needs a FRESH approved-and-unused entry.
|
|
136
|
+
*
|
|
137
|
+
* Matching (most to least specific, first match wins — matches the granularity the caller
|
|
138
|
+
* actually has available; see spexharness/src/gates/confirm-gate.mjs's header for why a
|
|
139
|
+
* bare `before_tool` event usually carries none of this):
|
|
140
|
+
* 1. `query.id` given — exact `id` match required (skill-invoke.mjs's `confirmPoint.id`,
|
|
141
|
+
* e.g. `"hs-promote.step-3"`, or a caller-minted confirm id).
|
|
142
|
+
* 2. else `query.namespace` given — the entry's id must be that namespace exactly, or sit
|
|
143
|
+
* under it (`<namespace>` + `.` / `:` / `/` + rest). See {@link confirmIdMatchesNamespace}.
|
|
144
|
+
* 3. else `query.skill` and/or `query.stepIndex` given — match whichever of those the
|
|
145
|
+
* caller supplied, against entries that recorded the same field.
|
|
146
|
+
* 4. else (no query fields at all) — the oldest unused approved entry, in recorded order,
|
|
147
|
+
* SKIPPING every namespace-BOUND entry (see {@link isNamespaceBoundConfirmId}): a
|
|
148
|
+
* confirm answered for another gate's ask (`dev-server-control:start`) or for a
|
|
149
|
+
* specific tool-level exit (`tool:heyspex_file_spec`) is bound to that ask and must be
|
|
150
|
+
* claimed by name, never picked up by an unqualified "oldest unused" sweep.
|
|
151
|
+
* In every case only `approved === true && used !== true` entries are eligible.
|
|
152
|
+
*
|
|
153
|
+
* A caller-supplied `confirm: async () => true` handler can never satisfy this by itself —
|
|
154
|
+
* this function (and the gate calling it) reads only `record.confirms`, never a callback's
|
|
155
|
+
* return value, so a handler wired straight to `true` with no matching entry actually
|
|
156
|
+
* `recordConfirm`-ed on the session still gets refused.
|
|
157
|
+
*
|
|
158
|
+
* @param {ReturnType<typeof createSessionRecord>|null|undefined} record
|
|
159
|
+
* @param {{ id?: string|null, namespace?: string|null, skill?: string|null, stepIndex?: number|null }} [query]
|
|
160
|
+
* @returns {boolean} true (and marks the matched entry `used: true`) iff a match was consumed.
|
|
161
|
+
*/
|
|
162
|
+
export function consumeApprovedConfirm(
|
|
163
|
+
record,
|
|
164
|
+
{ id = null, namespace = null, skill = null, stepIndex = null } = {},
|
|
165
|
+
) {
|
|
166
|
+
if (!Array.isArray(record?.confirms)) return false;
|
|
167
|
+
|
|
168
|
+
const eligible = (c) => c && c.approved === true && c.used !== true;
|
|
169
|
+
|
|
170
|
+
let match = null;
|
|
171
|
+
if (id != null) {
|
|
172
|
+
match = record.confirms.find((c) => eligible(c) && c.id === id) ?? null;
|
|
173
|
+
} else if (namespace != null) {
|
|
174
|
+
match = record.confirms.find((c) => eligible(c) && confirmIdMatchesNamespace(c.id, namespace)) ?? null;
|
|
175
|
+
} else if (skill != null || stepIndex != null) {
|
|
176
|
+
match = record.confirms.find((c) => {
|
|
177
|
+
if (!eligible(c)) return false;
|
|
178
|
+
if (isNamespaceBoundConfirmId(c.id)) return false; // bound to another ask — claim by name only
|
|
179
|
+
if (skill != null && c.skill !== skill) return false;
|
|
180
|
+
if (stepIndex != null && c.stepIndex !== stepIndex) return false;
|
|
181
|
+
return true;
|
|
182
|
+
}) ?? null;
|
|
183
|
+
} else {
|
|
184
|
+
match = record.confirms.find((c) => eligible(c) && !isNamespaceBoundConfirmId(c.id)) ?? null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (!match) return false;
|
|
188
|
+
match.used = true;
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The delimiters a confirm id uses between a namespace and the rest of the id. `.` is the
|
|
194
|
+
* skill-step shape (`hs-capture.step-2` — skill-invoke.mjs's `confirmPoint.id`); `:` is the
|
|
195
|
+
* BOUND shape a gate or a tool-level exit mints (`dev-server-control:start`,
|
|
196
|
+
* `tool:heyspex_file_spec`); `/` is accepted for symmetry with path-shaped ids.
|
|
197
|
+
*/
|
|
198
|
+
const CONFIRM_NAMESPACE_DELIMITERS = Object.freeze(['.', ':', '/']);
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Is `confirmId` inside `namespace`? True when the id IS the namespace, or sits under it
|
|
202
|
+
* behind one of {@link CONFIRM_NAMESPACE_DELIMITERS} — so `"hs-capture"` matches
|
|
203
|
+
* `"hs-capture.step-2"` (every confirm point of that skill), and `"tool:heyspex_file_spec"`
|
|
204
|
+
* matches only itself, never `"tool:heyspex_push_plan"`.
|
|
205
|
+
*
|
|
206
|
+
* @param {unknown} confirmId
|
|
207
|
+
* @param {string} namespace
|
|
208
|
+
* @returns {boolean}
|
|
209
|
+
*/
|
|
210
|
+
export function confirmIdMatchesNamespace(confirmId, namespace) {
|
|
211
|
+
if (typeof confirmId !== 'string' || typeof namespace !== 'string' || namespace === '') return false;
|
|
212
|
+
if (confirmId === namespace) return true;
|
|
213
|
+
return CONFIRM_NAMESPACE_DELIMITERS.some((d) => confirmId.startsWith(`${namespace}${d}`));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Is this confirm id BOUND to one specific ask — i.e. minted by a gate or an exit that
|
|
218
|
+
* names its own namespace with a `:` (`dev-server-control:start` from
|
|
219
|
+
* gates/dev-server-task-reminder.mjs's `confirmIdFor`, `tool:heyspex_file_spec` from
|
|
220
|
+
* gates/confirm-gate.mjs's `toolConfirmId`)?
|
|
221
|
+
*
|
|
222
|
+
* Why the distinction exists (regression R2, 2026-09-09): with no binding, the unqualified
|
|
223
|
+
* "oldest unused approved" fallback let a confirm answered for the dev-server RESTART ask be
|
|
224
|
+
* spent by the very next `heyspex_file_spec` — one ask silently authorizing an unrelated
|
|
225
|
+
* exit. A bound id is claimed by `id`/`namespace` only; the unqualified fallback skips it.
|
|
226
|
+
* A bare skill-step id (`hs-capture.step-2`) is NOT bound — it keeps the coarse behaviour
|
|
227
|
+
* confirm-gate.mjs's header documents for a front that cannot say which exit it is at.
|
|
228
|
+
*
|
|
229
|
+
* @param {unknown} confirmId
|
|
230
|
+
* @returns {boolean}
|
|
231
|
+
*/
|
|
232
|
+
export function isNamespaceBoundConfirmId(confirmId) {
|
|
233
|
+
return typeof confirmId === 'string' && confirmId.includes(':');
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Append one harness event (from events.mjs) and the decision decide.mjs made for it
|
|
238
|
+
* (from decide.mjs) to the record, in the order it happened.
|
|
239
|
+
*
|
|
240
|
+
* @param {ReturnType<typeof createSessionRecord>} record
|
|
241
|
+
* @param {{ point: string, ts?: number }} event
|
|
242
|
+
* @param {object|null} [decision] the decide() result, or null when the event carries no decision
|
|
243
|
+
* @returns {ReturnType<typeof createSessionRecord>} the same record, for chaining.
|
|
244
|
+
*/
|
|
245
|
+
export function recordEvent(record, event, decision = null) {
|
|
246
|
+
record.events.push({ point: event?.point ?? null, ts: event?.ts ?? Date.now(), decision });
|
|
247
|
+
return record;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Append one tool call to the record, in the order it happened.
|
|
252
|
+
*
|
|
253
|
+
* @param {ReturnType<typeof createSessionRecord>} record
|
|
254
|
+
* @param {{ name?: string|null, input?: unknown, output?: unknown }} toolCall
|
|
255
|
+
* @returns {ReturnType<typeof createSessionRecord>} the same record, for chaining.
|
|
256
|
+
*/
|
|
257
|
+
export function recordToolCall(record, { name = null, input = null, output = null } = {}) {
|
|
258
|
+
record.toolCalls.push({ name, input, output });
|
|
259
|
+
return record;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Seal the record with the run's final output and an end timestamp. Safe to call more than
|
|
264
|
+
* once (e.g. a caller that finishes a record on every loop exit path); the last call wins.
|
|
265
|
+
*
|
|
266
|
+
* @param {ReturnType<typeof createSessionRecord>} record
|
|
267
|
+
* @param {string|null} [output]
|
|
268
|
+
* @returns {ReturnType<typeof createSessionRecord>} the same record, for chaining.
|
|
269
|
+
*/
|
|
270
|
+
export function finishSessionRecord(record, output = null) {
|
|
271
|
+
record.output = output ?? null;
|
|
272
|
+
record.endedAt = new Date().toISOString();
|
|
273
|
+
return record;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Persist one session record as a single JSONL line. Mirrors
|
|
278
|
+
* dev-factory/lib/run-events.mjs's append-only, line-atomic write (open "a" + one
|
|
279
|
+
* writeSync + close) so concurrent writers from different fronts never interleave into a
|
|
280
|
+
* corrupt line.
|
|
281
|
+
*
|
|
282
|
+
* Redacted via {@link redactRecord} ([[HS-Y424-NYPR-TASK-add-one-redaction-module-that-strips]])
|
|
283
|
+
* before serialization — a fixture key, a bearer token, or a marked ingested-prose block never
|
|
284
|
+
* reaches this file. Redaction produces a COPY for the write; the `record` object this function
|
|
285
|
+
* returns (for chaining) is the caller's own, untouched.
|
|
286
|
+
*
|
|
287
|
+
* @param {string} filePath
|
|
288
|
+
* @param {object} record
|
|
289
|
+
* @returns {object} the same record, for chaining.
|
|
290
|
+
*/
|
|
291
|
+
export function appendSessionRecord(filePath, record) {
|
|
292
|
+
if (!filePath) throw new Error('spexharness/session-record: filePath is required');
|
|
293
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
294
|
+
const line = `${JSON.stringify(redactRecord(record))}\n`;
|
|
295
|
+
const fd = openSync(filePath, 'a');
|
|
296
|
+
try {
|
|
297
|
+
writeSync(fd, line);
|
|
298
|
+
} finally {
|
|
299
|
+
closeSync(fd);
|
|
300
|
+
}
|
|
301
|
+
return record;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Read all session records from a file, oldest first. Missing file → []. A corrupt/partial
|
|
306
|
+
* trailing line (e.g. the process died mid-write) is skipped rather than throwing — mirrors
|
|
307
|
+
* run-events.mjs readRunEvents' corrupt-safe read.
|
|
308
|
+
*
|
|
309
|
+
* @param {string} filePath
|
|
310
|
+
* @returns {object[]}
|
|
311
|
+
*/
|
|
312
|
+
export function readSessionRecords(filePath) {
|
|
313
|
+
if (!filePath || !existsSync(filePath)) return [];
|
|
314
|
+
const text = readFileSync(filePath, 'utf8');
|
|
315
|
+
const records = [];
|
|
316
|
+
for (const line of text.split('\n')) {
|
|
317
|
+
const trimmed = line.trim();
|
|
318
|
+
if (!trimmed) continue;
|
|
319
|
+
try {
|
|
320
|
+
records.push(JSON.parse(trimmed));
|
|
321
|
+
} catch {
|
|
322
|
+
// corrupt/partial line — skip, don't throw
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return records;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/* =========================================================================================
|
|
329
|
+
* Durable session store — claims + hand-off WIP + tool-call log, resumable by id.
|
|
330
|
+
* ========================================================================================= */
|
|
331
|
+
|
|
332
|
+
/** The Neon table the durable session record lives in. */
|
|
333
|
+
export const SESSION_RECORD_TABLE = 'harness_session_records';
|
|
334
|
+
|
|
335
|
+
/** Postgres identifiers are not parameterizable — anything spliced into SQL passes this. */
|
|
336
|
+
function assertIdentifier(name, what = 'table') {
|
|
337
|
+
if (typeof name !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
|
|
338
|
+
throw new Error(`spexharness/session-record: invalid ${what} identifier ${JSON.stringify(name)}`);
|
|
339
|
+
}
|
|
340
|
+
return name;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* The idempotent DDL for the durable session table. Exported (rather than hidden) so a
|
|
345
|
+
* migration runner, a provisioning script or a test can assert against the exact shape —
|
|
346
|
+
* the Neon backend runs it lazily on first use unless `ensureSchema: false`.
|
|
347
|
+
*
|
|
348
|
+
* @param {string} [table]
|
|
349
|
+
* @returns {string}
|
|
350
|
+
*/
|
|
351
|
+
export function sessionRecordSchemaSql(table = SESSION_RECORD_TABLE) {
|
|
352
|
+
const t = assertIdentifier(table);
|
|
353
|
+
return `CREATE TABLE IF NOT EXISTS ${t} (
|
|
354
|
+
session_id text PRIMARY KEY,
|
|
355
|
+
seat_id text,
|
|
356
|
+
front text,
|
|
357
|
+
machine text,
|
|
358
|
+
record jsonb NOT NULL,
|
|
359
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
360
|
+
updated_at timestamptz NOT NULL DEFAULT now()
|
|
361
|
+
)`;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* @typedef {object} DurableSessionRecord
|
|
366
|
+
* @property {string} sessionId the resume key — the only thing another machine needs.
|
|
367
|
+
* @property {string|null} seatId the seat the session runs as.
|
|
368
|
+
* @property {string|null} front the entry point that opened it ('chat', 'deepseek', …).
|
|
369
|
+
* @property {string|null} machine the machine that last wrote the record.
|
|
370
|
+
* @property {unknown} ask what was asked.
|
|
371
|
+
* @property {Array<object>} claims the specs this session holds.
|
|
372
|
+
* @property {unknown} wip the hand-off WIP block (null when there is none).
|
|
373
|
+
* @property {Array<object>} events harness events with their decisions, in order.
|
|
374
|
+
* @property {Array<object>} toolCalls the session's tool-call log, in order.
|
|
375
|
+
* @property {string|null} output what the model produced.
|
|
376
|
+
* @property {Array<{machine: string|null, at: string}>} resumes every pick-up of this id.
|
|
377
|
+
* @property {string} startedAt
|
|
378
|
+
* @property {string} updatedAt
|
|
379
|
+
* @property {string|null} endedAt
|
|
380
|
+
*/
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Create a durable session record: everything {@link createSessionRecord} carries, plus the
|
|
384
|
+
* three things a resume on another machine needs — `claims`, `wip` and the identity
|
|
385
|
+
* (`seatId`/`front`/`machine`) of the seat that opened it.
|
|
386
|
+
*
|
|
387
|
+
* Deliberately a SEPARATE constructor rather than a change to createSessionRecord: loop.mjs
|
|
388
|
+
* builds the plain record today and must keep getting exactly that shape.
|
|
389
|
+
*
|
|
390
|
+
* @param {{ sessionId?: string|null, seatId?: string|null, front?: string|null,
|
|
391
|
+
* machine?: string|null, ask?: unknown, claims?: Array<object>, wip?: unknown }} [opts]
|
|
392
|
+
* @returns {DurableSessionRecord}
|
|
393
|
+
*/
|
|
394
|
+
export function createDurableSessionRecord({
|
|
395
|
+
sessionId = null,
|
|
396
|
+
seatId = null,
|
|
397
|
+
front = null,
|
|
398
|
+
machine = undefined,
|
|
399
|
+
ask = null,
|
|
400
|
+
claims = [],
|
|
401
|
+
wip = null,
|
|
402
|
+
} = {}) {
|
|
403
|
+
const now = new Date().toISOString();
|
|
404
|
+
return {
|
|
405
|
+
...createSessionRecord({ sessionId, ask }),
|
|
406
|
+
seatId,
|
|
407
|
+
front,
|
|
408
|
+
machine: machine === undefined ? currentMachine() : machine,
|
|
409
|
+
claims: Array.isArray(claims) ? claims.slice() : [],
|
|
410
|
+
wip,
|
|
411
|
+
resumes: [],
|
|
412
|
+
startedAt: now,
|
|
413
|
+
updatedAt: now,
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** The machine identity stamped on a write — hostname, or null when it can't be read. */
|
|
418
|
+
export function currentMachine() {
|
|
419
|
+
try {
|
|
420
|
+
return process.env.HEYSPEX_MACHINE || hostname() || null;
|
|
421
|
+
} catch {
|
|
422
|
+
return null;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Fill in a record read back from storage so a caller never has to defend against a row
|
|
428
|
+
* written by an older shape (or by a different front): the three collections are always
|
|
429
|
+
* arrays, and the scalar fields are always present.
|
|
430
|
+
*
|
|
431
|
+
* @param {object|null} raw
|
|
432
|
+
* @returns {DurableSessionRecord|null}
|
|
433
|
+
*/
|
|
434
|
+
export function normalizeSessionRecord(raw) {
|
|
435
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
436
|
+
return {
|
|
437
|
+
sessionId: raw.sessionId ?? null,
|
|
438
|
+
seatId: raw.seatId ?? null,
|
|
439
|
+
front: raw.front ?? null,
|
|
440
|
+
machine: raw.machine ?? null,
|
|
441
|
+
ask: raw.ask ?? null,
|
|
442
|
+
claims: Array.isArray(raw.claims) ? raw.claims : [],
|
|
443
|
+
wip: raw.wip ?? null,
|
|
444
|
+
events: Array.isArray(raw.events) ? raw.events : [],
|
|
445
|
+
toolCalls: Array.isArray(raw.toolCalls) ? raw.toolCalls : [],
|
|
446
|
+
output: raw.output ?? null,
|
|
447
|
+
resumes: Array.isArray(raw.resumes) ? raw.resumes : [],
|
|
448
|
+
startedAt: raw.startedAt ?? null,
|
|
449
|
+
updatedAt: raw.updatedAt ?? null,
|
|
450
|
+
endedAt: raw.endedAt ?? null,
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** Structural copy — the store hands out copies so a caller's edits never mutate the store. */
|
|
455
|
+
function clone(value) {
|
|
456
|
+
return value == null ? value : JSON.parse(JSON.stringify(value));
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/* ------------------------------------------------------------------------- backends --- */
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* In-memory session backend. This is what the tests inject: same contract as the Neon
|
|
463
|
+
* backend (async, copy-on-read, copy-on-write), no database. Also useful to a front running
|
|
464
|
+
* fully offline, where a session simply does not survive the process.
|
|
465
|
+
*
|
|
466
|
+
* @param {Iterable<[string, object]>|Record<string, object>} [seed]
|
|
467
|
+
* @returns {{ read: Function, write: Function, list: Function, delete: Function, size: () => number }}
|
|
468
|
+
*/
|
|
469
|
+
export function createMemorySessionBackend(seed = undefined) {
|
|
470
|
+
const rows = new Map(
|
|
471
|
+
seed == null ? [] : (Symbol.iterator in Object(seed) ? seed : Object.entries(seed)),
|
|
472
|
+
);
|
|
473
|
+
return {
|
|
474
|
+
async read(sessionId) {
|
|
475
|
+
const row = rows.get(sessionId);
|
|
476
|
+
return row ? normalizeSessionRecord(clone(row)) : null;
|
|
477
|
+
},
|
|
478
|
+
async write(sessionId, record) {
|
|
479
|
+
rows.set(sessionId, clone(record));
|
|
480
|
+
return normalizeSessionRecord(clone(record));
|
|
481
|
+
},
|
|
482
|
+
async list({ seatId = null, limit = 100 } = {}) {
|
|
483
|
+
const all = [...rows.values()].map((row) => normalizeSessionRecord(clone(row)));
|
|
484
|
+
const filtered = seatId ? all.filter((r) => r.seatId === seatId) : all;
|
|
485
|
+
filtered.sort((a, b) => String(b.updatedAt ?? '').localeCompare(String(a.updatedAt ?? '')));
|
|
486
|
+
return filtered.slice(0, limit);
|
|
487
|
+
},
|
|
488
|
+
async delete(sessionId) {
|
|
489
|
+
return rows.delete(sessionId);
|
|
490
|
+
},
|
|
491
|
+
size: () => rows.size,
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Resolve the Neon connection string the same way the repo's other plain-.mjs Neon callers
|
|
497
|
+
* do: `DATABASE_URL_UNPOOLED` first (the durable session store is a handful of point
|
|
498
|
+
* lookups, not a hot pool), then `DATABASE_URL`, then `~/.heyspex/credentials.yaml`.
|
|
499
|
+
*
|
|
500
|
+
* @param {{ env?: Record<string, string|undefined>, home?: string }} [opts] injectable for tests.
|
|
501
|
+
* @returns {string}
|
|
502
|
+
*/
|
|
503
|
+
export function loadHarnessDatabaseUrl({ env = process.env, home = undefined } = {}) {
|
|
504
|
+
const fromEnv = env.DATABASE_URL_UNPOOLED ?? env.DATABASE_URL;
|
|
505
|
+
if (fromEnv) return fromEnv;
|
|
506
|
+
let credentialsPath;
|
|
507
|
+
try {
|
|
508
|
+
credentialsPath = join(home ?? homedir(), '.heyspex', 'credentials.yaml');
|
|
509
|
+
} catch {
|
|
510
|
+
credentialsPath = null;
|
|
511
|
+
}
|
|
512
|
+
if (credentialsPath && existsSync(credentialsPath)) {
|
|
513
|
+
const yaml = readFileSync(credentialsPath, 'utf8');
|
|
514
|
+
const unpooled = yaml.match(/^database_url_unpooled:\s*(.+)$/m)?.[1]?.trim();
|
|
515
|
+
const pooled = yaml.match(/^database_url:\s*(.+)$/m)?.[1]?.trim();
|
|
516
|
+
const url = unpooled || pooled;
|
|
517
|
+
if (url) return url;
|
|
518
|
+
}
|
|
519
|
+
throw new Error(
|
|
520
|
+
'spexharness/session-record: no DATABASE_URL configured (env DATABASE_URL_UNPOOLED/DATABASE_URL or ~/.heyspex/credentials.yaml)',
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/** neon()'s two return shapes: rows array (default) or { rows } under fullResults. */
|
|
525
|
+
function firstRow(result) {
|
|
526
|
+
if (Array.isArray(result)) return result[0] ?? null;
|
|
527
|
+
if (result && Array.isArray(result.rows)) return result.rows[0] ?? null;
|
|
528
|
+
return null;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function allRows(result) {
|
|
532
|
+
if (Array.isArray(result)) return result;
|
|
533
|
+
if (result && Array.isArray(result.rows)) return result.rows;
|
|
534
|
+
return [];
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** jsonb comes back parsed; a text column (or a stand-in driver) may hand back a string. */
|
|
538
|
+
function decodeRecordColumn(value) {
|
|
539
|
+
if (typeof value !== 'string') return value;
|
|
540
|
+
try {
|
|
541
|
+
return JSON.parse(value);
|
|
542
|
+
} catch {
|
|
543
|
+
return null;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Neon-backed session backend — the default. The driver import is LAZY (first call, not
|
|
549
|
+
* module load) so importing this module in an offline/test context costs nothing and needs
|
|
550
|
+
* no DATABASE_URL, and the schema DDL is idempotent and runs at most once per backend.
|
|
551
|
+
*
|
|
552
|
+
* @param {{
|
|
553
|
+
* sql?: ((text: string, params?: unknown[]) => Promise<unknown>)|null,
|
|
554
|
+
* databaseUrl?: string|null,
|
|
555
|
+
* table?: string,
|
|
556
|
+
* ensureSchema?: boolean,
|
|
557
|
+
* }} [options] `sql` injects a query function (a live `neon()` handle the caller already
|
|
558
|
+
* holds, or a stand-in); otherwise one is built from {@link loadHarnessDatabaseUrl}.
|
|
559
|
+
* @returns {{ read: Function, write: Function, list: Function, delete: Function }}
|
|
560
|
+
*/
|
|
561
|
+
export function createNeonSessionBackend(options = {}) {
|
|
562
|
+
const { sql: injectedSql = null, databaseUrl = null, table = SESSION_RECORD_TABLE, ensureSchema = true } = options;
|
|
563
|
+
const t = assertIdentifier(table);
|
|
564
|
+
|
|
565
|
+
let sqlPromise = null;
|
|
566
|
+
let schemaPromise = null;
|
|
567
|
+
|
|
568
|
+
async function connect() {
|
|
569
|
+
if (injectedSql) return injectedSql;
|
|
570
|
+
if (!sqlPromise) {
|
|
571
|
+
sqlPromise = (async () => {
|
|
572
|
+
const { neon } = await import('@neondatabase/serverless');
|
|
573
|
+
return neon(databaseUrl ?? loadHarnessDatabaseUrl());
|
|
574
|
+
})();
|
|
575
|
+
}
|
|
576
|
+
return sqlPromise;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
async function ready() {
|
|
580
|
+
const sql = await connect();
|
|
581
|
+
if (ensureSchema) {
|
|
582
|
+
if (!schemaPromise) schemaPromise = Promise.resolve(sql(sessionRecordSchemaSql(t), []));
|
|
583
|
+
await schemaPromise;
|
|
584
|
+
}
|
|
585
|
+
return sql;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
return {
|
|
589
|
+
async read(sessionId) {
|
|
590
|
+
const sql = await ready();
|
|
591
|
+
const row = firstRow(await sql(`SELECT record FROM ${t} WHERE session_id = $1`, [sessionId]));
|
|
592
|
+
return row ? normalizeSessionRecord(decodeRecordColumn(row.record)) : null;
|
|
593
|
+
},
|
|
594
|
+
async write(sessionId, record) {
|
|
595
|
+
const sql = await ready();
|
|
596
|
+
// Redacted before it ever reaches Neon — same module, same contract as
|
|
597
|
+
// appendSessionRecord's JSONL write (HS-Y424): a fixture key, a bearer
|
|
598
|
+
// token, or a marked ingested-prose block never lands in this row.
|
|
599
|
+
await sql(
|
|
600
|
+
`INSERT INTO ${t} (session_id, seat_id, front, machine, record, updated_at)
|
|
601
|
+
VALUES ($1, $2, $3, $4, $5::jsonb, now())
|
|
602
|
+
ON CONFLICT (session_id) DO UPDATE SET
|
|
603
|
+
seat_id = EXCLUDED.seat_id,
|
|
604
|
+
front = EXCLUDED.front,
|
|
605
|
+
machine = EXCLUDED.machine,
|
|
606
|
+
record = EXCLUDED.record,
|
|
607
|
+
updated_at = now()`,
|
|
608
|
+
[sessionId, record.seatId ?? null, record.front ?? null, record.machine ?? null, JSON.stringify(redactRecord(record))],
|
|
609
|
+
);
|
|
610
|
+
return normalizeSessionRecord(clone(record));
|
|
611
|
+
},
|
|
612
|
+
async list({ seatId = null, limit = 100 } = {}) {
|
|
613
|
+
const sql = await ready();
|
|
614
|
+
const result = seatId
|
|
615
|
+
? await sql(
|
|
616
|
+
`SELECT record FROM ${t} WHERE seat_id = $1 ORDER BY updated_at DESC LIMIT $2`,
|
|
617
|
+
[seatId, limit],
|
|
618
|
+
)
|
|
619
|
+
: await sql(`SELECT record FROM ${t} ORDER BY updated_at DESC LIMIT $1`, [limit]);
|
|
620
|
+
return allRows(result).map((row) => normalizeSessionRecord(decodeRecordColumn(row.record)));
|
|
621
|
+
},
|
|
622
|
+
async delete(sessionId) {
|
|
623
|
+
const sql = await ready();
|
|
624
|
+
const result = await sql(`DELETE FROM ${t} WHERE session_id = $1 RETURNING session_id`, [sessionId]);
|
|
625
|
+
return allRows(result).length > 0;
|
|
626
|
+
},
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/* ---------------------------------------------------------------------------- store --- */
|
|
631
|
+
|
|
632
|
+
function requireSessionId(sessionId) {
|
|
633
|
+
if (!sessionId || typeof sessionId !== 'string') {
|
|
634
|
+
throw new Error('spexharness/session-record: sessionId is required');
|
|
635
|
+
}
|
|
636
|
+
return sessionId;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* The durable session store. Every method is async and keyed by session id; the store holds
|
|
641
|
+
* no state of its own, so two store handles over the same backend — including two on
|
|
642
|
+
* DIFFERENT machines — see exactly the same session.
|
|
643
|
+
*
|
|
644
|
+
* @param {{ backend?: object, machine?: string|null, neon?: object }} [options]
|
|
645
|
+
* `backend` injects storage (the tests pass {@link createMemorySessionBackend}); omitted,
|
|
646
|
+
* it defaults to {@link createNeonSessionBackend} (constructed lazily — no connection is
|
|
647
|
+
* opened until a method is actually called). `neon` is passed through to that default.
|
|
648
|
+
* @returns {object}
|
|
649
|
+
*/
|
|
650
|
+
export function createSessionStore(options = {}) {
|
|
651
|
+
const { backend: injectedBackend = null, machine = undefined, neon: neonOptions = undefined } = options;
|
|
652
|
+
const writerMachine = machine === undefined ? currentMachine() : machine;
|
|
653
|
+
|
|
654
|
+
let backendInstance = injectedBackend;
|
|
655
|
+
function backend() {
|
|
656
|
+
if (!backendInstance) backendInstance = createNeonSessionBackend(neonOptions ?? {});
|
|
657
|
+
return backendInstance;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/** Read a session, or throw with the id — every mutation goes through this. */
|
|
661
|
+
async function mustRead(sessionId) {
|
|
662
|
+
const record = await backend().read(requireSessionId(sessionId));
|
|
663
|
+
if (!record) {
|
|
664
|
+
throw new Error(`spexharness/session-record: no session record for id ${sessionId}`);
|
|
665
|
+
}
|
|
666
|
+
return record;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/** Stamp the writer + time and persist. */
|
|
670
|
+
async function persist(record) {
|
|
671
|
+
const next = { ...record, machine: writerMachine, updatedAt: new Date().toISOString() };
|
|
672
|
+
return backend().write(next.sessionId, next);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/** Read-modify-write one session. */
|
|
676
|
+
async function mutate(sessionId, fn) {
|
|
677
|
+
const record = await mustRead(sessionId);
|
|
678
|
+
return persist(fn(record) ?? record);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
return {
|
|
682
|
+
/** The backend in use — exposed so a caller can assert which storage it got. */
|
|
683
|
+
backend,
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* Create (or replace) a session record under `sessionId` and persist it. Returns the
|
|
687
|
+
* stored record. Idempotent by id: calling it twice with the same id restarts the record.
|
|
688
|
+
*/
|
|
689
|
+
async createSession(init = {}) {
|
|
690
|
+
const sessionId = requireSessionId(init.sessionId ?? null);
|
|
691
|
+
const record = createDurableSessionRecord({ ...init, sessionId, machine: writerMachine });
|
|
692
|
+
return persist(record);
|
|
693
|
+
},
|
|
694
|
+
|
|
695
|
+
/** Persist an already-built record (e.g. one a loop mutated in memory). */
|
|
696
|
+
async saveSession(record) {
|
|
697
|
+
return persist({ ...normalizeSessionRecord(record), sessionId: requireSessionId(record?.sessionId) });
|
|
698
|
+
},
|
|
699
|
+
|
|
700
|
+
/** The full session record for `sessionId`, or null when the id is unknown. */
|
|
701
|
+
async getSession(sessionId) {
|
|
702
|
+
return backend().read(requireSessionId(sessionId));
|
|
703
|
+
},
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* Pick a session up by id — the resume path. Returns the full record (claims, WIP and
|
|
707
|
+
* tool-call log intact) and records the pick-up in `resumes`, so a hand-off is auditable
|
|
708
|
+
* rather than silent. Unknown id → null, so a caller can branch on "no session there".
|
|
709
|
+
*/
|
|
710
|
+
async resumeSession(sessionId, { machine: onMachine = writerMachine } = {}) {
|
|
711
|
+
const record = await backend().read(requireSessionId(sessionId));
|
|
712
|
+
if (!record) return null;
|
|
713
|
+
record.resumes = [...record.resumes, { machine: onMachine ?? null, at: new Date().toISOString() }];
|
|
714
|
+
return persist(record);
|
|
715
|
+
},
|
|
716
|
+
|
|
717
|
+
/** Append one tool call to the session's log, in the order it happened. */
|
|
718
|
+
async appendToolCall(sessionId, { name = null, input = null, output = null } = {}) {
|
|
719
|
+
return mutate(sessionId, (record) => {
|
|
720
|
+
record.toolCalls = [...record.toolCalls, { name, input, output }];
|
|
721
|
+
return record;
|
|
722
|
+
});
|
|
723
|
+
},
|
|
724
|
+
|
|
725
|
+
/** Append several tool calls in one write — the same order, one round trip. */
|
|
726
|
+
async appendToolCalls(sessionId, toolCalls = []) {
|
|
727
|
+
return mutate(sessionId, (record) => {
|
|
728
|
+
record.toolCalls = [
|
|
729
|
+
...record.toolCalls,
|
|
730
|
+
...toolCalls.map(({ name = null, input = null, output = null } = {}) => ({ name, input, output })),
|
|
731
|
+
];
|
|
732
|
+
return record;
|
|
733
|
+
});
|
|
734
|
+
},
|
|
735
|
+
|
|
736
|
+
/** Append one harness event + its decision, mirroring {@link recordEvent}'s shape. */
|
|
737
|
+
async appendEvent(sessionId, event, decision = null) {
|
|
738
|
+
return mutate(sessionId, (record) => {
|
|
739
|
+
record.events = [
|
|
740
|
+
...record.events,
|
|
741
|
+
{ point: event?.point ?? null, ts: event?.ts ?? Date.now(), decision },
|
|
742
|
+
];
|
|
743
|
+
return record;
|
|
744
|
+
});
|
|
745
|
+
},
|
|
746
|
+
|
|
747
|
+
/** Replace the session's claim set wholesale. */
|
|
748
|
+
async updateClaims(sessionId, claims = []) {
|
|
749
|
+
return mutate(sessionId, (record) => {
|
|
750
|
+
record.claims = Array.isArray(claims) ? claims.slice() : [];
|
|
751
|
+
return record;
|
|
752
|
+
});
|
|
753
|
+
},
|
|
754
|
+
|
|
755
|
+
/** Add one claim, replacing any existing claim on the same `specId` (claims are a set). */
|
|
756
|
+
async addClaim(sessionId, claim) {
|
|
757
|
+
if (!claim?.specId) throw new Error('spexharness/session-record: claim.specId is required');
|
|
758
|
+
return mutate(sessionId, (record) => {
|
|
759
|
+
record.claims = [
|
|
760
|
+
...record.claims.filter((c) => c?.specId !== claim.specId),
|
|
761
|
+
{ claimedAt: new Date().toISOString(), ...claim },
|
|
762
|
+
];
|
|
763
|
+
return record;
|
|
764
|
+
});
|
|
765
|
+
},
|
|
766
|
+
|
|
767
|
+
/** Drop the claim on `specId`. A spec that was not claimed is a no-op, not an error. */
|
|
768
|
+
async releaseClaim(sessionId, specId) {
|
|
769
|
+
return mutate(sessionId, (record) => {
|
|
770
|
+
record.claims = record.claims.filter((c) => c?.specId !== specId);
|
|
771
|
+
return record;
|
|
772
|
+
});
|
|
773
|
+
},
|
|
774
|
+
|
|
775
|
+
/** Replace the session's hand-off WIP block. */
|
|
776
|
+
async updateWip(sessionId, wip) {
|
|
777
|
+
return mutate(sessionId, (record) => {
|
|
778
|
+
record.wip = wip ?? null;
|
|
779
|
+
return record;
|
|
780
|
+
});
|
|
781
|
+
},
|
|
782
|
+
|
|
783
|
+
/** Merge fields into the hand-off WIP block, keeping what is already there. */
|
|
784
|
+
async mergeWip(sessionId, patch = {}) {
|
|
785
|
+
return mutate(sessionId, (record) => {
|
|
786
|
+
const base = record.wip && typeof record.wip === 'object' && !Array.isArray(record.wip) ? record.wip : {};
|
|
787
|
+
record.wip = { ...base, ...patch };
|
|
788
|
+
return record;
|
|
789
|
+
});
|
|
790
|
+
},
|
|
791
|
+
|
|
792
|
+
/** Seal the session with its final output and an end timestamp. */
|
|
793
|
+
async finishSession(sessionId, output = null) {
|
|
794
|
+
return mutate(sessionId, (record) => {
|
|
795
|
+
record.output = output ?? null;
|
|
796
|
+
record.endedAt = new Date().toISOString();
|
|
797
|
+
return record;
|
|
798
|
+
});
|
|
799
|
+
},
|
|
800
|
+
|
|
801
|
+
/** Sessions in the store, newest first (backends that support listing). */
|
|
802
|
+
async listSessions(opts = {}) {
|
|
803
|
+
const b = backend();
|
|
804
|
+
if (typeof b.list !== 'function') return [];
|
|
805
|
+
return b.list(opts);
|
|
806
|
+
},
|
|
807
|
+
|
|
808
|
+
/** Remove a session record. Returns whether a row was actually removed. */
|
|
809
|
+
async deleteSession(sessionId) {
|
|
810
|
+
const b = backend();
|
|
811
|
+
if (typeof b.delete !== 'function') return false;
|
|
812
|
+
return b.delete(requireSessionId(sessionId));
|
|
813
|
+
},
|
|
814
|
+
};
|
|
815
|
+
}
|