@ag-eco/agentplate-cli 0.13.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/LICENSE +21 -0
- package/README.md +462 -0
- package/agents/ap-co-creation.md +90 -0
- package/agents/builder.md +144 -0
- package/agents/coordinator.md +377 -0
- package/agents/lead.md +435 -0
- package/agents/merger.md +164 -0
- package/agents/monitor.md +214 -0
- package/agents/orchestrator.md +239 -0
- package/agents/reviewer.md +140 -0
- package/agents/scout.md +125 -0
- package/agents/supervisor.md +427 -0
- package/package.json +66 -0
- package/src/agents/capabilities.test.ts +85 -0
- package/src/agents/capabilities.ts +125 -0
- package/src/agents/checkpoint.test.ts +88 -0
- package/src/agents/checkpoint.ts +101 -0
- package/src/agents/copilot-hooks-deployer.test.ts +162 -0
- package/src/agents/copilot-hooks-deployer.ts +93 -0
- package/src/agents/guard-rules.test.ts +372 -0
- package/src/agents/guard-rules.ts +97 -0
- package/src/agents/headless-mail-injector.test.ts +709 -0
- package/src/agents/headless-mail-injector.ts +377 -0
- package/src/agents/headless-prompt.test.ts +102 -0
- package/src/agents/headless-prompt.ts +68 -0
- package/src/agents/hooks-deployer.test.ts +3119 -0
- package/src/agents/hooks-deployer.ts +804 -0
- package/src/agents/identity.test.ts +604 -0
- package/src/agents/identity.ts +384 -0
- package/src/agents/lifecycle.test.ts +196 -0
- package/src/agents/lifecycle.ts +183 -0
- package/src/agents/mail-poll-detect.test.ts +153 -0
- package/src/agents/mail-poll-detect.ts +73 -0
- package/src/agents/manifest.test.ts +1026 -0
- package/src/agents/manifest.ts +376 -0
- package/src/agents/overlay.test.ts +1058 -0
- package/src/agents/overlay.ts +490 -0
- package/src/agents/scope-detect.test.ts +190 -0
- package/src/agents/scope-detect.ts +146 -0
- package/src/agents/turn-lock.test.ts +181 -0
- package/src/agents/turn-lock.ts +235 -0
- package/src/agents/turn-runner-dispatch.test.ts +182 -0
- package/src/agents/turn-runner-dispatch.ts +105 -0
- package/src/agents/turn-runner.test.ts +2312 -0
- package/src/agents/turn-runner.ts +1383 -0
- package/src/beads/client.test.ts +217 -0
- package/src/beads/client.ts +230 -0
- package/src/beads/molecules.test.ts +338 -0
- package/src/beads/molecules.ts +198 -0
- package/src/commands/agents.test.ts +328 -0
- package/src/commands/agents.ts +299 -0
- package/src/commands/clean.test.ts +797 -0
- package/src/commands/clean.ts +791 -0
- package/src/commands/completions.test.ts +348 -0
- package/src/commands/completions.ts +981 -0
- package/src/commands/coordinator.test.ts +2975 -0
- package/src/commands/coordinator.ts +1841 -0
- package/src/commands/costs.test.ts +1183 -0
- package/src/commands/costs.ts +599 -0
- package/src/commands/dashboard.test.ts +954 -0
- package/src/commands/dashboard.ts +1212 -0
- package/src/commands/discover.test.ts +288 -0
- package/src/commands/discover.ts +202 -0
- package/src/commands/doctor.test.ts +303 -0
- package/src/commands/doctor.ts +311 -0
- package/src/commands/ecosystem.test.ts +226 -0
- package/src/commands/ecosystem.ts +248 -0
- package/src/commands/errors.test.ts +654 -0
- package/src/commands/errors.ts +197 -0
- package/src/commands/feed.test.ts +709 -0
- package/src/commands/feed.ts +260 -0
- package/src/commands/group.test.ts +475 -0
- package/src/commands/group.ts +546 -0
- package/src/commands/hooks.test.ts +458 -0
- package/src/commands/hooks.ts +263 -0
- package/src/commands/init.test.ts +1011 -0
- package/src/commands/init.ts +967 -0
- package/src/commands/inspect.test.ts +1239 -0
- package/src/commands/inspect.ts +648 -0
- package/src/commands/log.test.ts +1913 -0
- package/src/commands/log.ts +958 -0
- package/src/commands/logs.test.ts +801 -0
- package/src/commands/logs.ts +483 -0
- package/src/commands/mail.test.ts +1501 -0
- package/src/commands/mail.ts +848 -0
- package/src/commands/merge.test.ts +864 -0
- package/src/commands/merge.ts +381 -0
- package/src/commands/metrics.test.ts +458 -0
- package/src/commands/metrics.ts +129 -0
- package/src/commands/monitor.test.ts +191 -0
- package/src/commands/monitor.ts +409 -0
- package/src/commands/nudge.test.ts +579 -0
- package/src/commands/nudge.ts +646 -0
- package/src/commands/orchestrator.ts +42 -0
- package/src/commands/prime.test.ts +612 -0
- package/src/commands/prime.ts +359 -0
- package/src/commands/replay.test.ts +757 -0
- package/src/commands/replay.ts +231 -0
- package/src/commands/run.test.ts +469 -0
- package/src/commands/run.ts +353 -0
- package/src/commands/serve/agent-actions.test.ts +210 -0
- package/src/commands/serve/agent-actions.ts +192 -0
- package/src/commands/serve/build.test.ts +202 -0
- package/src/commands/serve/build.ts +206 -0
- package/src/commands/serve/coordinator-actions.test.ts +339 -0
- package/src/commands/serve/coordinator-actions.ts +410 -0
- package/src/commands/serve/dev.test.ts +168 -0
- package/src/commands/serve/dev.ts +117 -0
- package/src/commands/serve/mail-actions.test.ts +312 -0
- package/src/commands/serve/mail-actions.ts +167 -0
- package/src/commands/serve/rest.test.ts +1680 -0
- package/src/commands/serve/rest.ts +1130 -0
- package/src/commands/serve/static.ts +51 -0
- package/src/commands/serve/ws.test.ts +361 -0
- package/src/commands/serve/ws.ts +332 -0
- package/src/commands/serve.test.ts +459 -0
- package/src/commands/serve.ts +654 -0
- package/src/commands/sling.test.ts +1583 -0
- package/src/commands/sling.ts +1351 -0
- package/src/commands/spec.test.ts +179 -0
- package/src/commands/spec.ts +105 -0
- package/src/commands/status.test.ts +614 -0
- package/src/commands/status.ts +403 -0
- package/src/commands/stop.test.ts +964 -0
- package/src/commands/stop.ts +319 -0
- package/src/commands/supervisor.test.ts +185 -0
- package/src/commands/supervisor.ts +537 -0
- package/src/commands/trace.test.ts +762 -0
- package/src/commands/trace.ts +205 -0
- package/src/commands/update.test.ts +466 -0
- package/src/commands/update.ts +263 -0
- package/src/commands/upgrade.test.ts +48 -0
- package/src/commands/upgrade.ts +240 -0
- package/src/commands/watch.test.ts +257 -0
- package/src/commands/watch.ts +308 -0
- package/src/commands/worktree.test.ts +1297 -0
- package/src/commands/worktree.ts +451 -0
- package/src/config.test.ts +1535 -0
- package/src/config.ts +1064 -0
- package/src/doctor/agents.test.ts +523 -0
- package/src/doctor/agents.ts +399 -0
- package/src/doctor/config-check.test.ts +191 -0
- package/src/doctor/config-check.ts +183 -0
- package/src/doctor/consistency.test.ts +807 -0
- package/src/doctor/consistency.ts +347 -0
- package/src/doctor/databases.test.ts +350 -0
- package/src/doctor/databases.ts +243 -0
- package/src/doctor/dependencies.test.ts +296 -0
- package/src/doctor/dependencies.ts +272 -0
- package/src/doctor/ecosystem.test.ts +308 -0
- package/src/doctor/ecosystem.ts +156 -0
- package/src/doctor/logs.test.ts +253 -0
- package/src/doctor/logs.ts +295 -0
- package/src/doctor/merge-queue.test.ts +315 -0
- package/src/doctor/merge-queue.ts +167 -0
- package/src/doctor/providers.test.ts +409 -0
- package/src/doctor/providers.ts +250 -0
- package/src/doctor/serve.test.ts +95 -0
- package/src/doctor/serve.ts +86 -0
- package/src/doctor/structure.test.ts +423 -0
- package/src/doctor/structure.ts +285 -0
- package/src/doctor/types.ts +43 -0
- package/src/doctor/version.test.ts +241 -0
- package/src/doctor/version.ts +132 -0
- package/src/doctor/watchdog.test.ts +167 -0
- package/src/doctor/watchdog.ts +214 -0
- package/src/e2e/init-sling-lifecycle.test.ts +283 -0
- package/src/errors.test.ts +350 -0
- package/src/errors.ts +217 -0
- package/src/events/store.test.ts +660 -0
- package/src/events/store.ts +369 -0
- package/src/events/tailer.test.ts +719 -0
- package/src/events/tailer.ts +332 -0
- package/src/events/tool-filter.test.ts +330 -0
- package/src/events/tool-filter.ts +126 -0
- package/src/index.ts +533 -0
- package/src/insights/analyzer.test.ts +466 -0
- package/src/insights/analyzer.ts +203 -0
- package/src/insights/quality-gates.test.ts +141 -0
- package/src/insights/quality-gates.ts +156 -0
- package/src/json.test.ts +72 -0
- package/src/json.ts +53 -0
- package/src/loam/client.test.ts +752 -0
- package/src/loam/client.ts +664 -0
- package/src/logging/color.test.ts +252 -0
- package/src/logging/color.ts +105 -0
- package/src/logging/format.test.ts +110 -0
- package/src/logging/format.ts +255 -0
- package/src/logging/logger.test.ts +814 -0
- package/src/logging/logger.ts +266 -0
- package/src/logging/reporter.test.ts +259 -0
- package/src/logging/reporter.ts +110 -0
- package/src/logging/sanitizer.test.ts +190 -0
- package/src/logging/sanitizer.ts +57 -0
- package/src/logging/theme.ts +140 -0
- package/src/mail/broadcast.test.ts +204 -0
- package/src/mail/broadcast.ts +92 -0
- package/src/mail/client.test.ts +774 -0
- package/src/mail/client.ts +236 -0
- package/src/mail/store.test.ts +898 -0
- package/src/mail/store.ts +425 -0
- package/src/merge/lock.test.ts +149 -0
- package/src/merge/lock.ts +140 -0
- package/src/merge/predict.test.ts +387 -0
- package/src/merge/predict.ts +249 -0
- package/src/merge/queue.test.ts +426 -0
- package/src/merge/queue.ts +246 -0
- package/src/merge/resolver.test.ts +1993 -0
- package/src/merge/resolver.ts +926 -0
- package/src/metrics/pricing.test.ts +258 -0
- package/src/metrics/pricing.ts +135 -0
- package/src/metrics/store.test.ts +978 -0
- package/src/metrics/store.ts +501 -0
- package/src/metrics/summary.test.ts +398 -0
- package/src/metrics/summary.ts +178 -0
- package/src/metrics/transcript.test.ts +483 -0
- package/src/metrics/transcript.ts +114 -0
- package/src/runtimes/__fixtures__/claude-stream-fixture.ts +22 -0
- package/src/runtimes/aider.test.ts +124 -0
- package/src/runtimes/aider.ts +147 -0
- package/src/runtimes/amp.test.ts +164 -0
- package/src/runtimes/amp.ts +154 -0
- package/src/runtimes/claude.test.ts +1474 -0
- package/src/runtimes/claude.ts +579 -0
- package/src/runtimes/codex.test.ts +805 -0
- package/src/runtimes/codex.ts +273 -0
- package/src/runtimes/connections.test.ts +214 -0
- package/src/runtimes/connections.ts +103 -0
- package/src/runtimes/copilot.test.ts +707 -0
- package/src/runtimes/copilot.ts +316 -0
- package/src/runtimes/cursor.test.ts +497 -0
- package/src/runtimes/cursor.ts +205 -0
- package/src/runtimes/gemini.test.ts +537 -0
- package/src/runtimes/gemini.ts +243 -0
- package/src/runtimes/goose.test.ts +133 -0
- package/src/runtimes/goose.ts +157 -0
- package/src/runtimes/headless-connection.test.ts +264 -0
- package/src/runtimes/headless-connection.ts +158 -0
- package/src/runtimes/opencode.test.ts +325 -0
- package/src/runtimes/opencode.ts +188 -0
- package/src/runtimes/pi-guards.test.ts +486 -0
- package/src/runtimes/pi-guards.ts +367 -0
- package/src/runtimes/pi.test.ts +789 -0
- package/src/runtimes/pi.ts +305 -0
- package/src/runtimes/registry.test.ts +196 -0
- package/src/runtimes/registry.ts +99 -0
- package/src/runtimes/sapling.test.ts +1267 -0
- package/src/runtimes/sapling.ts +710 -0
- package/src/runtimes/types.ts +266 -0
- package/src/schema-consistency.test.ts +246 -0
- package/src/sessions/compat.test.ts +281 -0
- package/src/sessions/compat.ts +105 -0
- package/src/sessions/store.test.ts +1748 -0
- package/src/sessions/store.ts +858 -0
- package/src/test-helpers.test.ts +124 -0
- package/src/test-helpers.ts +145 -0
- package/src/test-setup.test.ts +31 -0
- package/src/test-setup.ts +28 -0
- package/src/tools/loam/api.ts +368 -0
- package/src/tools/loam/cli.ts +278 -0
- package/src/tools/loam/commands/add.ts +52 -0
- package/src/tools/loam/commands/archive.ts +214 -0
- package/src/tools/loam/commands/audit.ts +276 -0
- package/src/tools/loam/commands/compact.ts +1062 -0
- package/src/tools/loam/commands/completions.ts +79 -0
- package/src/tools/loam/commands/config.ts +381 -0
- package/src/tools/loam/commands/delete-domain.ts +121 -0
- package/src/tools/loam/commands/delete.ts +316 -0
- package/src/tools/loam/commands/diff.ts +200 -0
- package/src/tools/loam/commands/doctor.ts +1113 -0
- package/src/tools/loam/commands/edit.ts +226 -0
- package/src/tools/loam/commands/init.ts +31 -0
- package/src/tools/loam/commands/learn.ts +179 -0
- package/src/tools/loam/commands/move.ts +323 -0
- package/src/tools/loam/commands/onboard.ts +374 -0
- package/src/tools/loam/commands/outcome.ts +185 -0
- package/src/tools/loam/commands/prime.ts +688 -0
- package/src/tools/loam/commands/prune.ts +614 -0
- package/src/tools/loam/commands/query.ts +218 -0
- package/src/tools/loam/commands/rank.ts +180 -0
- package/src/tools/loam/commands/ready.ts +189 -0
- package/src/tools/loam/commands/record.ts +1210 -0
- package/src/tools/loam/commands/restore.ts +166 -0
- package/src/tools/loam/commands/search.ts +327 -0
- package/src/tools/loam/commands/setup.ts +887 -0
- package/src/tools/loam/commands/status.ts +103 -0
- package/src/tools/loam/commands/sync.ts +298 -0
- package/src/tools/loam/commands/update.ts +19 -0
- package/src/tools/loam/commands/upgrade.ts +93 -0
- package/src/tools/loam/commands/validate.ts +190 -0
- package/src/tools/loam/index.ts +62 -0
- package/src/tools/loam/log.ts +127 -0
- package/src/tools/loam/registry/builtins.ts +409 -0
- package/src/tools/loam/registry/custom.ts +431 -0
- package/src/tools/loam/registry/init.ts +55 -0
- package/src/tools/loam/registry/template.ts +40 -0
- package/src/tools/loam/registry/type-registry.ts +113 -0
- package/src/tools/loam/schemas/config-schema.ts +489 -0
- package/src/tools/loam/schemas/config.ts +245 -0
- package/src/tools/loam/schemas/index.ts +18 -0
- package/src/tools/loam/schemas/record-schema.ts +191 -0
- package/src/tools/loam/schemas/record.ts +115 -0
- package/src/tools/loam/utils/active-work.ts +205 -0
- package/src/tools/loam/utils/anchor-validity.ts +80 -0
- package/src/tools/loam/utils/archive.ts +146 -0
- package/src/tools/loam/utils/audit.ts +667 -0
- package/src/tools/loam/utils/bm25.ts +238 -0
- package/src/tools/loam/utils/budget.ts +142 -0
- package/src/tools/loam/utils/config.ts +344 -0
- package/src/tools/loam/utils/dir-anchors.ts +62 -0
- package/src/tools/loam/utils/domain-rules.ts +114 -0
- package/src/tools/loam/utils/expertise.ts +393 -0
- package/src/tools/loam/utils/format-helpers.ts +96 -0
- package/src/tools/loam/utils/format.ts +1234 -0
- package/src/tools/loam/utils/git-context.ts +50 -0
- package/src/tools/loam/utils/git.ts +183 -0
- package/src/tools/loam/utils/hooks.ts +299 -0
- package/src/tools/loam/utils/index.ts +52 -0
- package/src/tools/loam/utils/json-output.ts +13 -0
- package/src/tools/loam/utils/lock.ts +76 -0
- package/src/tools/loam/utils/markers.ts +48 -0
- package/src/tools/loam/utils/numeric-flags.ts +20 -0
- package/src/tools/loam/utils/palette.ts +44 -0
- package/src/tools/loam/utils/prime-ranking.ts +135 -0
- package/src/tools/loam/utils/recipe-discovery.ts +195 -0
- package/src/tools/loam/utils/runtime-flags.ts +28 -0
- package/src/tools/loam/utils/scoring.ts +94 -0
- package/src/tools/loam/utils/version.ts +116 -0
- package/src/tools/sprout/commands/block.ts +64 -0
- package/src/tools/sprout/commands/blocked.ts +86 -0
- package/src/tools/sprout/commands/close.ts +129 -0
- package/src/tools/sprout/commands/completions.ts +198 -0
- package/src/tools/sprout/commands/config.ts +238 -0
- package/src/tools/sprout/commands/create.ts +164 -0
- package/src/tools/sprout/commands/dep.ts +148 -0
- package/src/tools/sprout/commands/doctor.ts +979 -0
- package/src/tools/sprout/commands/init.ts +83 -0
- package/src/tools/sprout/commands/label.ts +178 -0
- package/src/tools/sprout/commands/list.ts +210 -0
- package/src/tools/sprout/commands/migrate.ts +133 -0
- package/src/tools/sprout/commands/onboard.ts +207 -0
- package/src/tools/sprout/commands/plan-show.ts +278 -0
- package/src/tools/sprout/commands/plan.ts +2526 -0
- package/src/tools/sprout/commands/prime.ts +399 -0
- package/src/tools/sprout/commands/ready.ts +245 -0
- package/src/tools/sprout/commands/search.ts +221 -0
- package/src/tools/sprout/commands/show.ts +277 -0
- package/src/tools/sprout/commands/stats.ts +146 -0
- package/src/tools/sprout/commands/sync.ts +134 -0
- package/src/tools/sprout/commands/tpl.ts +364 -0
- package/src/tools/sprout/commands/unblock.ts +115 -0
- package/src/tools/sprout/commands/update.ts +257 -0
- package/src/tools/sprout/commands/upgrade.ts +91 -0
- package/src/tools/sprout/config-schema.ts +152 -0
- package/src/tools/sprout/config.ts +355 -0
- package/src/tools/sprout/filter.ts +107 -0
- package/src/tools/sprout/format.ts +43 -0
- package/src/tools/sprout/id.ts +22 -0
- package/src/tools/sprout/index.ts +204 -0
- package/src/tools/sprout/log.ts +76 -0
- package/src/tools/sprout/markers.ts +22 -0
- package/src/tools/sprout/output.ts +121 -0
- package/src/tools/sprout/plan-backref.ts +93 -0
- package/src/tools/sprout/plan-context.ts +81 -0
- package/src/tools/sprout/plan-domain.ts +139 -0
- package/src/tools/sprout/plan-lifecycle.ts +65 -0
- package/src/tools/sprout/plan-loam.ts +207 -0
- package/src/tools/sprout/plan-schema.ts +209 -0
- package/src/tools/sprout/sort.ts +31 -0
- package/src/tools/sprout/store.ts +172 -0
- package/src/tools/sprout/types.ts +118 -0
- package/src/tools/sprout/validation.ts +119 -0
- package/src/tools/sprout/version.ts +1 -0
- package/src/tools/sprout/yaml.ts +387 -0
- package/src/tools/trellis/commands/archive.ts +87 -0
- package/src/tools/trellis/commands/completions.ts +610 -0
- package/src/tools/trellis/commands/config.ts +382 -0
- package/src/tools/trellis/commands/create.ts +252 -0
- package/src/tools/trellis/commands/diff.ts +150 -0
- package/src/tools/trellis/commands/doctor.ts +771 -0
- package/src/tools/trellis/commands/emit.ts +365 -0
- package/src/tools/trellis/commands/history.ts +83 -0
- package/src/tools/trellis/commands/import.ts +198 -0
- package/src/tools/trellis/commands/init.ts +81 -0
- package/src/tools/trellis/commands/list.ts +103 -0
- package/src/tools/trellis/commands/onboard.ts +156 -0
- package/src/tools/trellis/commands/pin.ts +172 -0
- package/src/tools/trellis/commands/prime.ts +193 -0
- package/src/tools/trellis/commands/render.ts +122 -0
- package/src/tools/trellis/commands/schema.ts +353 -0
- package/src/tools/trellis/commands/show.ts +115 -0
- package/src/tools/trellis/commands/stats.ts +65 -0
- package/src/tools/trellis/commands/sync.ts +112 -0
- package/src/tools/trellis/commands/tree.ts +123 -0
- package/src/tools/trellis/commands/update.ts +330 -0
- package/src/tools/trellis/commands/upgrade.ts +95 -0
- package/src/tools/trellis/commands/validate.ts +166 -0
- package/src/tools/trellis/config-schema.ts +81 -0
- package/src/tools/trellis/config.ts +108 -0
- package/src/tools/trellis/frontmatter.ts +348 -0
- package/src/tools/trellis/id.ts +24 -0
- package/src/tools/trellis/index.ts +209 -0
- package/src/tools/trellis/markers.ts +28 -0
- package/src/tools/trellis/output.ts +84 -0
- package/src/tools/trellis/render.ts +212 -0
- package/src/tools/trellis/store.ts +144 -0
- package/src/tools/trellis/types.ts +82 -0
- package/src/tools/trellis/validate.ts +199 -0
- package/src/tools/trellis/yaml.ts +309 -0
- package/src/tracker/beads.test.ts +454 -0
- package/src/tracker/beads.ts +56 -0
- package/src/tracker/factory.test.ts +90 -0
- package/src/tracker/factory.ts +65 -0
- package/src/tracker/sprout.test.ts +461 -0
- package/src/tracker/sprout.ts +182 -0
- package/src/tracker/types.ts +52 -0
- package/src/trellis/client.test.ts +107 -0
- package/src/trellis/client.ts +179 -0
- package/src/types.ts +970 -0
- package/src/utils/bin.test.ts +10 -0
- package/src/utils/bin.ts +37 -0
- package/src/utils/browser.test.ts +49 -0
- package/src/utils/browser.ts +48 -0
- package/src/utils/fs.test.ts +119 -0
- package/src/utils/fs.ts +62 -0
- package/src/utils/pid.test.ts +152 -0
- package/src/utils/pid.ts +130 -0
- package/src/utils/process-scan.test.ts +53 -0
- package/src/utils/process-scan.ts +76 -0
- package/src/utils/time.test.ts +43 -0
- package/src/utils/time.ts +37 -0
- package/src/utils/version.test.ts +33 -0
- package/src/utils/version.ts +70 -0
- package/src/version.ts +5 -0
- package/src/watchdog/daemon.test.ts +3721 -0
- package/src/watchdog/daemon.ts +1257 -0
- package/src/watchdog/health.test.ts +830 -0
- package/src/watchdog/health.ts +434 -0
- package/src/watchdog/triage.test.ts +205 -0
- package/src/watchdog/triage.ts +205 -0
- package/src/worktree/manager.test.ts +720 -0
- package/src/worktree/manager.ts +405 -0
- package/src/worktree/process.test.ts +172 -0
- package/src/worktree/process.ts +131 -0
- package/src/worktree/tmux.test.ts +1616 -0
- package/src/worktree/tmux.ts +721 -0
- package/templates/CLAUDE.md.tmpl +100 -0
- package/templates/copilot-hooks.json.tmpl +13 -0
- package/templates/hooks.json.tmpl +109 -0
- package/templates/overlay.md.tmpl +88 -0
- package/ui/dist/apple-touch-icon-bdy6teep.png +0 -0
- package/ui/dist/chunk-8s31f05k.css +1 -0
- package/ui/dist/chunk-vm5rz679.js +300 -0
- package/ui/dist/favicon-nzb39vza.svg +4 -0
- package/ui/dist/index.html +17 -0
|
@@ -0,0 +1,1748 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for SessionStore (SQLite-backed agent session tracking).
|
|
3
|
+
*
|
|
4
|
+
* Uses real bun:sqlite with temp files. No mocks.
|
|
5
|
+
* Temp files (not :memory:) because file-based migration must be tested.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
9
|
+
import { mkdtemp } from "node:fs/promises";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { cleanupTempDir } from "../test-helpers.ts";
|
|
13
|
+
import type { AgentSession, AgentState, InsertRun, Run, RunStore } from "../types.ts";
|
|
14
|
+
import { createRunStore, createSessionStore, type SessionStore } from "./store.ts";
|
|
15
|
+
|
|
16
|
+
let tempDir: string;
|
|
17
|
+
let dbPath: string;
|
|
18
|
+
let store: SessionStore;
|
|
19
|
+
|
|
20
|
+
beforeEach(async () => {
|
|
21
|
+
tempDir = await mkdtemp(join(tmpdir(), "agentplate-sessions-test-"));
|
|
22
|
+
dbPath = join(tempDir, "sessions.db");
|
|
23
|
+
store = createSessionStore(dbPath);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
afterEach(async () => {
|
|
27
|
+
store.close();
|
|
28
|
+
await cleanupTempDir(tempDir);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
/** Helper to create an AgentSession with optional overrides. */
|
|
32
|
+
function makeSession(overrides: Partial<AgentSession> = {}): AgentSession {
|
|
33
|
+
return {
|
|
34
|
+
id: "session-001-test-agent",
|
|
35
|
+
agentName: "test-agent",
|
|
36
|
+
capability: "builder",
|
|
37
|
+
worktreePath: "/tmp/worktrees/test-agent",
|
|
38
|
+
branchName: "agentplate/test-agent/task-1",
|
|
39
|
+
taskId: "task-1",
|
|
40
|
+
tmuxSession: "agentplate-test-agent",
|
|
41
|
+
state: "booting",
|
|
42
|
+
pid: 12345,
|
|
43
|
+
parentAgent: null,
|
|
44
|
+
depth: 0,
|
|
45
|
+
runId: null,
|
|
46
|
+
startedAt: "2026-01-15T10:00:00.000Z",
|
|
47
|
+
lastActivity: "2026-01-15T10:00:00.000Z",
|
|
48
|
+
escalationLevel: 0,
|
|
49
|
+
stalledSince: null,
|
|
50
|
+
transcriptPath: null,
|
|
51
|
+
...overrides,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// === upsert ===
|
|
56
|
+
|
|
57
|
+
describe("upsert", () => {
|
|
58
|
+
test("inserts a new session", () => {
|
|
59
|
+
const session = makeSession();
|
|
60
|
+
store.upsert(session);
|
|
61
|
+
|
|
62
|
+
const result = store.getByName("test-agent");
|
|
63
|
+
expect(result).not.toBeNull();
|
|
64
|
+
expect(result).toEqual(session);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("updates an existing session with the same agent name", () => {
|
|
68
|
+
store.upsert(makeSession({ state: "booting" }));
|
|
69
|
+
store.upsert(makeSession({ id: "session-002-test-agent", state: "working" }));
|
|
70
|
+
|
|
71
|
+
const all = store.getAll();
|
|
72
|
+
expect(all).toHaveLength(1);
|
|
73
|
+
|
|
74
|
+
const result = store.getByName("test-agent");
|
|
75
|
+
expect(result?.state).toBe("working");
|
|
76
|
+
expect(result?.id).toBe("session-002-test-agent");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("all fields roundtrip correctly (camelCase TS -> snake_case SQLite -> camelCase TS)", () => {
|
|
80
|
+
const session = makeSession({
|
|
81
|
+
id: "session-full-roundtrip",
|
|
82
|
+
agentName: "roundtrip-agent",
|
|
83
|
+
capability: "scout",
|
|
84
|
+
worktreePath: "/tmp/worktrees/roundtrip",
|
|
85
|
+
branchName: "agentplate/roundtrip-agent/task-42",
|
|
86
|
+
taskId: "task-42",
|
|
87
|
+
tmuxSession: "agentplate-roundtrip-agent",
|
|
88
|
+
state: "working",
|
|
89
|
+
pid: 99999,
|
|
90
|
+
parentAgent: "lead-agent",
|
|
91
|
+
depth: 2,
|
|
92
|
+
runId: "run-abc-123",
|
|
93
|
+
startedAt: "2026-02-01T08:30:00.000Z",
|
|
94
|
+
lastActivity: "2026-02-01T09:00:00.000Z",
|
|
95
|
+
escalationLevel: 2,
|
|
96
|
+
stalledSince: "2026-02-01T08:50:00.000Z",
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
store.upsert(session);
|
|
100
|
+
const result = store.getByName("roundtrip-agent");
|
|
101
|
+
expect(result).toEqual(session);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("handles null pid", () => {
|
|
105
|
+
const session = makeSession({ pid: null });
|
|
106
|
+
store.upsert(session);
|
|
107
|
+
|
|
108
|
+
const result = store.getByName("test-agent");
|
|
109
|
+
expect(result?.pid).toBeNull();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("handles null parentAgent", () => {
|
|
113
|
+
const session = makeSession({ parentAgent: null });
|
|
114
|
+
store.upsert(session);
|
|
115
|
+
|
|
116
|
+
const result = store.getByName("test-agent");
|
|
117
|
+
expect(result?.parentAgent).toBeNull();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("handles null runId", () => {
|
|
121
|
+
const session = makeSession({ runId: null });
|
|
122
|
+
store.upsert(session);
|
|
123
|
+
|
|
124
|
+
const result = store.getByName("test-agent");
|
|
125
|
+
expect(result?.runId).toBeNull();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("handles null stalledSince", () => {
|
|
129
|
+
const session = makeSession({ stalledSince: null });
|
|
130
|
+
store.upsert(session);
|
|
131
|
+
|
|
132
|
+
const result = store.getByName("test-agent");
|
|
133
|
+
expect(result?.stalledSince).toBeNull();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("accepts arbitrary state strings (CHECK relaxed in agentplate-3087)", () => {
|
|
137
|
+
// The inline CHECK on `state` was dropped (agentplate-3087): the
|
|
138
|
+
// TypeScript `AgentState` union enforces values at the writer
|
|
139
|
+
// boundary, so the SQL constraint became a maintenance tax that had
|
|
140
|
+
// to be rebuilt on every union extension. Verify the schema no
|
|
141
|
+
// longer throws when an out-of-union value reaches it. Real callers
|
|
142
|
+
// type their writes through the union and cannot land here.
|
|
143
|
+
const session = makeSession();
|
|
144
|
+
const badSession = { ...session, state: "invalid" as AgentState };
|
|
145
|
+
expect(() => store.upsert(badSession)).not.toThrow();
|
|
146
|
+
expect(store.getByName("test-agent")?.state).toBe("invalid" as AgentState);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("handles null transcriptPath", () => {
|
|
150
|
+
const session = makeSession({ transcriptPath: null });
|
|
151
|
+
store.upsert(session);
|
|
152
|
+
const result = store.getByName("test-agent");
|
|
153
|
+
expect(result?.transcriptPath).toBeNull();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("transcriptPath roundtrips correctly", () => {
|
|
157
|
+
const session = makeSession({ transcriptPath: "/home/user/.pi/sessions/abc.jsonl" });
|
|
158
|
+
store.upsert(session);
|
|
159
|
+
const result = store.getByName("test-agent");
|
|
160
|
+
expect(result?.transcriptPath).toBe("/home/user/.pi/sessions/abc.jsonl");
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// === claudeSessionId roundtrip via upsert ===
|
|
165
|
+
|
|
166
|
+
describe("claudeSessionId upsert roundtrip", () => {
|
|
167
|
+
test("undefined claudeSessionId leaves column null and field absent on roundtrip", () => {
|
|
168
|
+
store.upsert(makeSession());
|
|
169
|
+
const result = store.getByName("test-agent");
|
|
170
|
+
expect(result).not.toBeNull();
|
|
171
|
+
expect(Object.hasOwn(result ?? {}, "claudeSessionId")).toBe(false);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("claudeSessionId roundtrips correctly when set", () => {
|
|
175
|
+
store.upsert(makeSession({ claudeSessionId: "sess-roundtrip-xyz" }));
|
|
176
|
+
const result = store.getByName("test-agent");
|
|
177
|
+
expect(result?.claudeSessionId).toBe("sess-roundtrip-xyz");
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// === updateClaudeSessionId ===
|
|
182
|
+
|
|
183
|
+
describe("updateClaudeSessionId", () => {
|
|
184
|
+
test("sets claude_session_id for an existing session; getByName returns it", () => {
|
|
185
|
+
store.upsert(makeSession());
|
|
186
|
+
store.updateClaudeSessionId("test-agent", "sess-pin-001");
|
|
187
|
+
const result = store.getByName("test-agent");
|
|
188
|
+
expect(result?.claudeSessionId).toBe("sess-pin-001");
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("calling twice with the same value is idempotent", () => {
|
|
192
|
+
store.upsert(makeSession());
|
|
193
|
+
store.updateClaudeSessionId("test-agent", "sess-idempotent");
|
|
194
|
+
store.updateClaudeSessionId("test-agent", "sess-idempotent");
|
|
195
|
+
const result = store.getByName("test-agent");
|
|
196
|
+
expect(result?.claudeSessionId).toBe("sess-idempotent");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("calling for an unknown agent is a no-op (does not throw)", () => {
|
|
200
|
+
expect(() => store.updateClaudeSessionId("nonexistent", "sess-noop")).not.toThrow();
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// === updateTranscriptPath ===
|
|
205
|
+
|
|
206
|
+
describe("updateTranscriptPath", () => {
|
|
207
|
+
test("sets transcript path for an existing session", () => {
|
|
208
|
+
store.upsert(makeSession({ transcriptPath: null }));
|
|
209
|
+
store.updateTranscriptPath("test-agent", "/tmp/transcript.jsonl");
|
|
210
|
+
const result = store.getByName("test-agent");
|
|
211
|
+
expect(result?.transcriptPath).toBe("/tmp/transcript.jsonl");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("is a no-op for nonexistent agent", () => {
|
|
215
|
+
// Should not throw
|
|
216
|
+
store.updateTranscriptPath("nonexistent", "/tmp/transcript.jsonl");
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// === getByName ===
|
|
221
|
+
|
|
222
|
+
describe("getByName", () => {
|
|
223
|
+
test("returns null for nonexistent agent", () => {
|
|
224
|
+
const result = store.getByName("nonexistent");
|
|
225
|
+
expect(result).toBeNull();
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("returns the correct session when multiple exist", () => {
|
|
229
|
+
store.upsert(makeSession({ agentName: "agent-a", id: "s-a" }));
|
|
230
|
+
store.upsert(makeSession({ agentName: "agent-b", id: "s-b" }));
|
|
231
|
+
store.upsert(makeSession({ agentName: "agent-c", id: "s-c" }));
|
|
232
|
+
|
|
233
|
+
const result = store.getByName("agent-b");
|
|
234
|
+
expect(result?.id).toBe("s-b");
|
|
235
|
+
expect(result?.agentName).toBe("agent-b");
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
// === getActive ===
|
|
240
|
+
|
|
241
|
+
describe("getActive", () => {
|
|
242
|
+
test("returns empty array when no sessions exist", () => {
|
|
243
|
+
const result = store.getActive();
|
|
244
|
+
expect(result).toEqual([]);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("returns booting, working, and stalled sessions", () => {
|
|
248
|
+
store.upsert(makeSession({ agentName: "booting-1", id: "s-1", state: "booting" }));
|
|
249
|
+
store.upsert(makeSession({ agentName: "working-1", id: "s-2", state: "working" }));
|
|
250
|
+
store.upsert(makeSession({ agentName: "stalled-1", id: "s-3", state: "stalled" }));
|
|
251
|
+
|
|
252
|
+
const result = store.getActive();
|
|
253
|
+
expect(result).toHaveLength(3);
|
|
254
|
+
|
|
255
|
+
const states = result.map((s) => s.state);
|
|
256
|
+
expect(states).toContain("booting");
|
|
257
|
+
expect(states).toContain("working");
|
|
258
|
+
expect(states).toContain("stalled");
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("excludes completed and zombie sessions", () => {
|
|
262
|
+
store.upsert(makeSession({ agentName: "working-1", id: "s-1", state: "working" }));
|
|
263
|
+
store.upsert(makeSession({ agentName: "completed-1", id: "s-2", state: "completed" }));
|
|
264
|
+
store.upsert(makeSession({ agentName: "zombie-1", id: "s-3", state: "zombie" }));
|
|
265
|
+
|
|
266
|
+
const result = store.getActive();
|
|
267
|
+
expect(result).toHaveLength(1);
|
|
268
|
+
expect(result[0]?.agentName).toBe("working-1");
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("results are ordered by started_at ascending", () => {
|
|
272
|
+
store.upsert(
|
|
273
|
+
makeSession({
|
|
274
|
+
agentName: "late",
|
|
275
|
+
id: "s-2",
|
|
276
|
+
state: "working",
|
|
277
|
+
startedAt: "2026-01-15T12:00:00.000Z",
|
|
278
|
+
}),
|
|
279
|
+
);
|
|
280
|
+
store.upsert(
|
|
281
|
+
makeSession({
|
|
282
|
+
agentName: "early",
|
|
283
|
+
id: "s-1",
|
|
284
|
+
state: "working",
|
|
285
|
+
startedAt: "2026-01-15T10:00:00.000Z",
|
|
286
|
+
}),
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
const result = store.getActive();
|
|
290
|
+
expect(result[0]?.agentName).toBe("early");
|
|
291
|
+
expect(result[1]?.agentName).toBe("late");
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
// === getAll ===
|
|
296
|
+
|
|
297
|
+
describe("getAll", () => {
|
|
298
|
+
test("returns empty array when no sessions exist", () => {
|
|
299
|
+
expect(store.getAll()).toEqual([]);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("returns all sessions regardless of state", () => {
|
|
303
|
+
store.upsert(makeSession({ agentName: "a1", id: "s-1", state: "booting" }));
|
|
304
|
+
store.upsert(makeSession({ agentName: "a2", id: "s-2", state: "completed" }));
|
|
305
|
+
store.upsert(makeSession({ agentName: "a3", id: "s-3", state: "zombie" }));
|
|
306
|
+
|
|
307
|
+
const result = store.getAll();
|
|
308
|
+
expect(result).toHaveLength(3);
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
// === count ===
|
|
313
|
+
|
|
314
|
+
describe("count", () => {
|
|
315
|
+
test("returns 0 on empty database", () => {
|
|
316
|
+
expect(store.count()).toBe(0);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test("returns correct count after inserts", () => {
|
|
320
|
+
store.upsert(makeSession({ agentName: "a1", id: "s-1" }));
|
|
321
|
+
expect(store.count()).toBe(1);
|
|
322
|
+
|
|
323
|
+
store.upsert(makeSession({ agentName: "a2", id: "s-2" }));
|
|
324
|
+
expect(store.count()).toBe(2);
|
|
325
|
+
|
|
326
|
+
store.upsert(makeSession({ agentName: "a3", id: "s-3" }));
|
|
327
|
+
expect(store.count()).toBe(3);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test("count reflects removals", () => {
|
|
331
|
+
store.upsert(makeSession({ agentName: "a1", id: "s-1" }));
|
|
332
|
+
store.upsert(makeSession({ agentName: "a2", id: "s-2" }));
|
|
333
|
+
|
|
334
|
+
store.remove("a1");
|
|
335
|
+
expect(store.count()).toBe(1);
|
|
336
|
+
|
|
337
|
+
store.remove("a2");
|
|
338
|
+
expect(store.count()).toBe(0);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test("count matches getAll().length", () => {
|
|
342
|
+
for (let i = 0; i < 5; i++) {
|
|
343
|
+
store.upsert(makeSession({ agentName: `agent-${i}`, id: `s-${i}` }));
|
|
344
|
+
}
|
|
345
|
+
expect(store.count()).toBe(store.getAll().length);
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
// === getByRun ===
|
|
350
|
+
|
|
351
|
+
describe("getByRun", () => {
|
|
352
|
+
test("returns empty array for unknown run", () => {
|
|
353
|
+
expect(store.getByRun("nonexistent-run")).toEqual([]);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test("returns only sessions with matching runId", () => {
|
|
357
|
+
store.upsert(makeSession({ agentName: "a1", id: "s-1", runId: "run-1" }));
|
|
358
|
+
store.upsert(makeSession({ agentName: "a2", id: "s-2", runId: "run-1" }));
|
|
359
|
+
store.upsert(makeSession({ agentName: "a3", id: "s-3", runId: "run-2" }));
|
|
360
|
+
store.upsert(makeSession({ agentName: "a4", id: "s-4", runId: null }));
|
|
361
|
+
|
|
362
|
+
const result = store.getByRun("run-1");
|
|
363
|
+
expect(result).toHaveLength(2);
|
|
364
|
+
expect(result.map((s) => s.agentName).sort()).toEqual(["a1", "a2"]);
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
// === updateState ===
|
|
369
|
+
|
|
370
|
+
describe("updateState", () => {
|
|
371
|
+
test("updates state of an existing session", () => {
|
|
372
|
+
store.upsert(makeSession({ state: "booting" }));
|
|
373
|
+
|
|
374
|
+
store.updateState("test-agent", "working");
|
|
375
|
+
|
|
376
|
+
const result = store.getByName("test-agent");
|
|
377
|
+
expect(result?.state).toBe("working");
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test("is a no-op for nonexistent agent (does not throw)", () => {
|
|
381
|
+
// Should not throw
|
|
382
|
+
store.updateState("nonexistent", "completed");
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
test("accepts arbitrary state strings (CHECK relaxed in agentplate-3087)", () => {
|
|
386
|
+
// Same rationale as the upsert test: the TypeScript `AgentState`
|
|
387
|
+
// union is the authoritative gate; the SQL CHECK was dropped to
|
|
388
|
+
// avoid the rebuild tax on every union extension.
|
|
389
|
+
store.upsert(makeSession());
|
|
390
|
+
expect(() => store.updateState("test-agent", "invalid" as AgentState)).not.toThrow();
|
|
391
|
+
expect(store.getByName("test-agent")?.state).toBe("invalid" as AgentState);
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
// === tryTransitionState (matrix-guarded CAS) ===
|
|
396
|
+
|
|
397
|
+
describe("tryTransitionState", () => {
|
|
398
|
+
test("returns not_found for an unknown agent", () => {
|
|
399
|
+
const outcome = store.tryTransitionState("nonexistent", "completed");
|
|
400
|
+
expect(outcome.ok).toBe(false);
|
|
401
|
+
if (!outcome.ok) {
|
|
402
|
+
expect(outcome.reason).toBe("not_found");
|
|
403
|
+
expect(outcome.attempted).toBe("completed");
|
|
404
|
+
}
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
test("booting → working lands and returns prev/next", () => {
|
|
408
|
+
store.upsert(makeSession({ state: "booting" }));
|
|
409
|
+
const outcome = store.tryTransitionState("test-agent", "working");
|
|
410
|
+
expect(outcome.ok).toBe(true);
|
|
411
|
+
if (outcome.ok) {
|
|
412
|
+
expect(outcome.prev).toBe("booting");
|
|
413
|
+
expect(outcome.next).toBe("working");
|
|
414
|
+
}
|
|
415
|
+
expect(store.getByName("test-agent")?.state).toBe("working");
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
test("working → completed lands (clean turn-end settle)", () => {
|
|
419
|
+
store.upsert(makeSession({ state: "working" }));
|
|
420
|
+
const outcome = store.tryTransitionState("test-agent", "completed");
|
|
421
|
+
expect(outcome.ok).toBe(true);
|
|
422
|
+
expect(store.getByName("test-agent")?.state).toBe("completed");
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
test("working → zombie lands (watchdog terminate)", () => {
|
|
426
|
+
store.upsert(makeSession({ state: "working" }));
|
|
427
|
+
const outcome = store.tryTransitionState("test-agent", "zombie");
|
|
428
|
+
expect(outcome.ok).toBe(true);
|
|
429
|
+
expect(store.getByName("test-agent")?.state).toBe("zombie");
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
test("zombie → completed lands (ap stop cleanup of zombie)", () => {
|
|
433
|
+
store.upsert(makeSession({ state: "zombie" }));
|
|
434
|
+
const outcome = store.tryTransitionState("test-agent", "completed");
|
|
435
|
+
expect(outcome.ok).toBe(true);
|
|
436
|
+
expect(store.getByName("test-agent")?.state).toBe("completed");
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
test("completed → zombie is rejected (sticky completed)", () => {
|
|
440
|
+
store.upsert(makeSession({ state: "completed" }));
|
|
441
|
+
const outcome = store.tryTransitionState("test-agent", "zombie");
|
|
442
|
+
expect(outcome.ok).toBe(false);
|
|
443
|
+
if (!outcome.ok && outcome.reason === "illegal_transition") {
|
|
444
|
+
expect(outcome.prev).toBe("completed");
|
|
445
|
+
expect(outcome.attempted).toBe("zombie");
|
|
446
|
+
}
|
|
447
|
+
expect(store.getByName("test-agent")?.state).toBe("completed");
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
test("completed → working is rejected (turn-runner cannot revive completed)", () => {
|
|
451
|
+
store.upsert(makeSession({ state: "completed" }));
|
|
452
|
+
const outcome = store.tryTransitionState("test-agent", "working");
|
|
453
|
+
expect(outcome.ok).toBe(false);
|
|
454
|
+
if (!outcome.ok && outcome.reason === "illegal_transition") {
|
|
455
|
+
expect(outcome.prev).toBe("completed");
|
|
456
|
+
}
|
|
457
|
+
expect(store.getByName("test-agent")?.state).toBe("completed");
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
test("zombie → working is rejected (PreToolUse hook cannot revive zombie)", () => {
|
|
461
|
+
store.upsert(makeSession({ state: "zombie" }));
|
|
462
|
+
const outcome = store.tryTransitionState("test-agent", "working");
|
|
463
|
+
expect(outcome.ok).toBe(false);
|
|
464
|
+
if (!outcome.ok && outcome.reason === "illegal_transition") {
|
|
465
|
+
expect(outcome.prev).toBe("zombie");
|
|
466
|
+
expect(outcome.attempted).toBe("working");
|
|
467
|
+
}
|
|
468
|
+
expect(store.getByName("test-agent")?.state).toBe("zombie");
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
test("idempotent same-state transitions land (working → working)", () => {
|
|
472
|
+
store.upsert(makeSession({ state: "working" }));
|
|
473
|
+
const outcome = store.tryTransitionState("test-agent", "working");
|
|
474
|
+
expect(outcome.ok).toBe(true);
|
|
475
|
+
expect(store.getByName("test-agent")?.state).toBe("working");
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
test("idempotent completed → completed is allowed", () => {
|
|
479
|
+
store.upsert(makeSession({ state: "completed" }));
|
|
480
|
+
const outcome = store.tryTransitionState("test-agent", "completed");
|
|
481
|
+
expect(outcome.ok).toBe(true);
|
|
482
|
+
expect(store.getByName("test-agent")?.state).toBe("completed");
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
test("idempotent zombie → zombie is allowed", () => {
|
|
486
|
+
store.upsert(makeSession({ state: "zombie" }));
|
|
487
|
+
const outcome = store.tryTransitionState("test-agent", "zombie");
|
|
488
|
+
expect(outcome.ok).toBe(true);
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
test("nothing transitions into booting (matrix has no allowed predecessors)", () => {
|
|
492
|
+
store.upsert(makeSession({ state: "working" }));
|
|
493
|
+
const outcome = store.tryTransitionState("test-agent", "booting");
|
|
494
|
+
expect(outcome.ok).toBe(false);
|
|
495
|
+
if (!outcome.ok && outcome.reason === "illegal_transition") {
|
|
496
|
+
expect(outcome.prev).toBe("working");
|
|
497
|
+
}
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
test("stalled → working is allowed (recovery)", () => {
|
|
501
|
+
store.upsert(makeSession({ state: "stalled" }));
|
|
502
|
+
const outcome = store.tryTransitionState("test-agent", "working");
|
|
503
|
+
expect(outcome.ok).toBe(true);
|
|
504
|
+
expect(store.getByName("test-agent")?.state).toBe("working");
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
test("race scenario: ap stop wins, late watchdog zombie write rejected", () => {
|
|
508
|
+
// Models the agentplate-a993 symptom directly: ap stop completes the
|
|
509
|
+
// agent first; a stale watchdog tick then tries to mark it zombie.
|
|
510
|
+
store.upsert(makeSession({ state: "working" }));
|
|
511
|
+
|
|
512
|
+
const stopOutcome = store.tryTransitionState("test-agent", "completed");
|
|
513
|
+
expect(stopOutcome.ok).toBe(true);
|
|
514
|
+
|
|
515
|
+
const watchdogOutcome = store.tryTransitionState("test-agent", "zombie");
|
|
516
|
+
expect(watchdogOutcome.ok).toBe(false);
|
|
517
|
+
if (!watchdogOutcome.ok && watchdogOutcome.reason === "illegal_transition") {
|
|
518
|
+
expect(watchdogOutcome.prev).toBe("completed");
|
|
519
|
+
}
|
|
520
|
+
expect(store.getByName("test-agent")?.state).toBe("completed");
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
test("race scenario: watchdog wins zombie, late turn-runner working write rejected", () => {
|
|
524
|
+
// Watchdog observes the agent dead and marks zombie; meanwhile a
|
|
525
|
+
// turn-runner whose initialState was 'working' tries to settle to
|
|
526
|
+
// 'working' at end of turn. The settle must NOT undo the zombie call.
|
|
527
|
+
store.upsert(makeSession({ state: "working" }));
|
|
528
|
+
|
|
529
|
+
const watchdogOutcome = store.tryTransitionState("test-agent", "zombie");
|
|
530
|
+
expect(watchdogOutcome.ok).toBe(true);
|
|
531
|
+
|
|
532
|
+
const turnRunnerOutcome = store.tryTransitionState("test-agent", "working");
|
|
533
|
+
expect(turnRunnerOutcome.ok).toBe(false);
|
|
534
|
+
if (!turnRunnerOutcome.ok && turnRunnerOutcome.reason === "illegal_transition") {
|
|
535
|
+
expect(turnRunnerOutcome.prev).toBe("zombie");
|
|
536
|
+
}
|
|
537
|
+
expect(store.getByName("test-agent")?.state).toBe("zombie");
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
test("race scenario: ap stop promotes a zombie to completed", () => {
|
|
541
|
+
// Inverse of the previous race: watchdog already marked zombie, then
|
|
542
|
+
// the operator runs `ap stop` to clean up. The cleanup must succeed.
|
|
543
|
+
store.upsert(makeSession({ state: "zombie" }));
|
|
544
|
+
|
|
545
|
+
const stopOutcome = store.tryTransitionState("test-agent", "completed");
|
|
546
|
+
expect(stopOutcome.ok).toBe(true);
|
|
547
|
+
expect(store.getByName("test-agent")?.state).toBe("completed");
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
test("concurrent CAS: two writers try the same target, only one observes ok", () => {
|
|
551
|
+
// Simulates the SQL CAS exclusivity: two writers race against the same
|
|
552
|
+
// row with the same target. The DB serializes the writes; only the one
|
|
553
|
+
// that finds the row in an allowed-from state when the CAS executes
|
|
554
|
+
// reports ok=true. We can't truly run them in parallel from a single
|
|
555
|
+
// thread, but we can prove the invariant: after BOTH calls, the row
|
|
556
|
+
// is in the target state and exactly one call returned ok=true.
|
|
557
|
+
store.upsert(makeSession({ state: "working" }));
|
|
558
|
+
|
|
559
|
+
const a = store.tryTransitionState("test-agent", "completed");
|
|
560
|
+
const b = store.tryTransitionState("test-agent", "completed");
|
|
561
|
+
|
|
562
|
+
// First call lands (working → completed). Second is idempotent
|
|
563
|
+
// (completed → completed is in the matrix), so both report ok.
|
|
564
|
+
expect(a.ok).toBe(true);
|
|
565
|
+
expect(b.ok).toBe(true);
|
|
566
|
+
expect(store.getByName("test-agent")?.state).toBe("completed");
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
test("concurrent CAS: conflicting targets — second writer rejected", () => {
|
|
570
|
+
// First writer lands working → completed; second writer attempts
|
|
571
|
+
// working → zombie but the row is now completed → REJECTED.
|
|
572
|
+
store.upsert(makeSession({ state: "working" }));
|
|
573
|
+
|
|
574
|
+
const stop = store.tryTransitionState("test-agent", "completed");
|
|
575
|
+
expect(stop.ok).toBe(true);
|
|
576
|
+
|
|
577
|
+
const watchdog = store.tryTransitionState("test-agent", "zombie");
|
|
578
|
+
expect(watchdog.ok).toBe(false);
|
|
579
|
+
if (!watchdog.ok && watchdog.reason === "illegal_transition") {
|
|
580
|
+
expect(watchdog.prev).toBe("completed");
|
|
581
|
+
}
|
|
582
|
+
});
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
// === in_turn / between_turns spawn-per-turn substates (agentplate-3087) ===
|
|
586
|
+
//
|
|
587
|
+
// The spawn-per-turn engine splits the legacy `working` state into two:
|
|
588
|
+
// `in_turn` (claude is mid-execution, parser events streaming) and
|
|
589
|
+
// `between_turns` (claude exited cleanly, agent waiting for the next mail
|
|
590
|
+
// batch). The matrix must allow the cycle in both directions and forward
|
|
591
|
+
// progression to terminal/error states from either substate. The CHECK
|
|
592
|
+
// constraint and `getActive` query must accept the new values so the
|
|
593
|
+
// watchdog and dashboards see these workers as alive.
|
|
594
|
+
|
|
595
|
+
describe("in_turn / between_turns substates", () => {
|
|
596
|
+
test("upsert accepts in_turn via CHECK constraint", () => {
|
|
597
|
+
store.upsert(makeSession({ state: "in_turn" }));
|
|
598
|
+
expect(store.getByName("test-agent")?.state).toBe("in_turn");
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
test("upsert accepts between_turns via CHECK constraint", () => {
|
|
602
|
+
store.upsert(makeSession({ state: "between_turns" }));
|
|
603
|
+
expect(store.getByName("test-agent")?.state).toBe("between_turns");
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
test("getActive includes in_turn and between_turns alongside working/booting/stalled", () => {
|
|
607
|
+
store.upsert(makeSession({ agentName: "a-it", id: "s-it", state: "in_turn" }));
|
|
608
|
+
store.upsert(makeSession({ agentName: "a-bt", id: "s-bt", state: "between_turns" }));
|
|
609
|
+
store.upsert(makeSession({ agentName: "a-w", id: "s-w", state: "working" }));
|
|
610
|
+
store.upsert(makeSession({ agentName: "a-c", id: "s-c", state: "completed" }));
|
|
611
|
+
store.upsert(makeSession({ agentName: "a-z", id: "s-z", state: "zombie" }));
|
|
612
|
+
|
|
613
|
+
const activeNames = store
|
|
614
|
+
.getActive()
|
|
615
|
+
.map((s) => s.agentName)
|
|
616
|
+
.sort();
|
|
617
|
+
expect(activeNames).toEqual(["a-bt", "a-it", "a-w"]);
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
test("booting → in_turn lands (turn-runner first-event transition)", () => {
|
|
621
|
+
store.upsert(makeSession({ state: "booting" }));
|
|
622
|
+
const outcome = store.tryTransitionState("test-agent", "in_turn");
|
|
623
|
+
expect(outcome.ok).toBe(true);
|
|
624
|
+
expect(store.getByName("test-agent")?.state).toBe("in_turn");
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
test("in_turn → between_turns lands (turn-runner end-of-turn settle)", () => {
|
|
628
|
+
store.upsert(makeSession({ state: "in_turn" }));
|
|
629
|
+
const outcome = store.tryTransitionState("test-agent", "between_turns");
|
|
630
|
+
expect(outcome.ok).toBe(true);
|
|
631
|
+
expect(store.getByName("test-agent")?.state).toBe("between_turns");
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
test("between_turns → in_turn lands (next mail batch starts a turn)", () => {
|
|
635
|
+
store.upsert(makeSession({ state: "between_turns" }));
|
|
636
|
+
const outcome = store.tryTransitionState("test-agent", "in_turn");
|
|
637
|
+
expect(outcome.ok).toBe(true);
|
|
638
|
+
expect(store.getByName("test-agent")?.state).toBe("in_turn");
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
test("legacy working → in_turn is rejected (spawn-per-turn keeps separate path)", () => {
|
|
642
|
+
// A row in the legacy `working` state predates the spawn-per-turn
|
|
643
|
+
// substate split. The matrix intentionally does not list `working` as
|
|
644
|
+
// a predecessor of `in_turn` — turn-runner.ts handles legacy `working`
|
|
645
|
+
// rows by writing in_turn directly via updateState() / unconditional
|
|
646
|
+
// override on the first parser event of a fresh batch. A
|
|
647
|
+
// CAS-guarded transition is rejected to keep the two paths disjoint.
|
|
648
|
+
store.upsert(makeSession({ state: "working" }));
|
|
649
|
+
const outcome = store.tryTransitionState("test-agent", "in_turn");
|
|
650
|
+
expect(outcome.ok).toBe(false);
|
|
651
|
+
if (!outcome.ok && outcome.reason === "illegal_transition") {
|
|
652
|
+
expect(outcome.prev).toBe("working");
|
|
653
|
+
}
|
|
654
|
+
expect(store.getByName("test-agent")?.state).toBe("working");
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
test("legacy working → between_turns is rejected", () => {
|
|
658
|
+
store.upsert(makeSession({ state: "working" }));
|
|
659
|
+
const outcome = store.tryTransitionState("test-agent", "between_turns");
|
|
660
|
+
expect(outcome.ok).toBe(false);
|
|
661
|
+
if (!outcome.ok && outcome.reason === "illegal_transition") {
|
|
662
|
+
expect(outcome.prev).toBe("working");
|
|
663
|
+
}
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
test("booting → between_turns is rejected (must pass through in_turn)", () => {
|
|
667
|
+
// Spec: between_turns predecessors are in_turn / between_turns /
|
|
668
|
+
// stalled. The agent only reaches between_turns after a turn produced
|
|
669
|
+
// events — which means the turn-runner must have transitioned to
|
|
670
|
+
// in_turn first.
|
|
671
|
+
store.upsert(makeSession({ state: "booting" }));
|
|
672
|
+
const outcome = store.tryTransitionState("test-agent", "between_turns");
|
|
673
|
+
expect(outcome.ok).toBe(false);
|
|
674
|
+
if (!outcome.ok && outcome.reason === "illegal_transition") {
|
|
675
|
+
expect(outcome.prev).toBe("booting");
|
|
676
|
+
}
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
test("in_turn → completed lands (clean exit + terminal mail)", () => {
|
|
680
|
+
store.upsert(makeSession({ state: "in_turn" }));
|
|
681
|
+
const outcome = store.tryTransitionState("test-agent", "completed");
|
|
682
|
+
expect(outcome.ok).toBe(true);
|
|
683
|
+
expect(store.getByName("test-agent")?.state).toBe("completed");
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
test("between_turns → completed lands (operator stops an idle worker)", () => {
|
|
687
|
+
store.upsert(makeSession({ state: "between_turns" }));
|
|
688
|
+
const outcome = store.tryTransitionState("test-agent", "completed");
|
|
689
|
+
expect(outcome.ok).toBe(true);
|
|
690
|
+
expect(store.getByName("test-agent")?.state).toBe("completed");
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
test("in_turn → zombie lands (parser stall / abort)", () => {
|
|
694
|
+
store.upsert(makeSession({ state: "in_turn" }));
|
|
695
|
+
const outcome = store.tryTransitionState("test-agent", "zombie");
|
|
696
|
+
expect(outcome.ok).toBe(true);
|
|
697
|
+
expect(store.getByName("test-agent")?.state).toBe("zombie");
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
test("between_turns → zombie lands (watchdog terminate after long idle)", () => {
|
|
701
|
+
store.upsert(makeSession({ state: "between_turns" }));
|
|
702
|
+
const outcome = store.tryTransitionState("test-agent", "zombie");
|
|
703
|
+
expect(outcome.ok).toBe(true);
|
|
704
|
+
expect(store.getByName("test-agent")?.state).toBe("zombie");
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
test("in_turn → stalled lands (watchdog escalate)", () => {
|
|
708
|
+
store.upsert(makeSession({ state: "in_turn" }));
|
|
709
|
+
const outcome = store.tryTransitionState("test-agent", "stalled");
|
|
710
|
+
expect(outcome.ok).toBe(true);
|
|
711
|
+
expect(store.getByName("test-agent")?.state).toBe("stalled");
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
test("idempotent in_turn → in_turn is allowed (re-entering on same batch)", () => {
|
|
715
|
+
store.upsert(makeSession({ state: "in_turn" }));
|
|
716
|
+
const outcome = store.tryTransitionState("test-agent", "in_turn");
|
|
717
|
+
expect(outcome.ok).toBe(true);
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
test("idempotent between_turns → between_turns is allowed", () => {
|
|
721
|
+
store.upsert(makeSession({ state: "between_turns" }));
|
|
722
|
+
const outcome = store.tryTransitionState("test-agent", "between_turns");
|
|
723
|
+
expect(outcome.ok).toBe(true);
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
test("completed → in_turn is rejected (sticky completed)", () => {
|
|
727
|
+
store.upsert(makeSession({ state: "completed" }));
|
|
728
|
+
const outcome = store.tryTransitionState("test-agent", "in_turn");
|
|
729
|
+
expect(outcome.ok).toBe(false);
|
|
730
|
+
expect(store.getByName("test-agent")?.state).toBe("completed");
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
test("zombie → in_turn is rejected (turn-runner cannot revive zombie)", () => {
|
|
734
|
+
store.upsert(makeSession({ state: "zombie" }));
|
|
735
|
+
const outcome = store.tryTransitionState("test-agent", "in_turn");
|
|
736
|
+
expect(outcome.ok).toBe(false);
|
|
737
|
+
expect(store.getByName("test-agent")?.state).toBe("zombie");
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
test("zombie → between_turns is rejected", () => {
|
|
741
|
+
store.upsert(makeSession({ state: "zombie" }));
|
|
742
|
+
const outcome = store.tryTransitionState("test-agent", "between_turns");
|
|
743
|
+
expect(outcome.ok).toBe(false);
|
|
744
|
+
expect(store.getByName("test-agent")?.state).toBe("zombie");
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
test("nothing transitions into booting from in_turn or between_turns", () => {
|
|
748
|
+
store.upsert(makeSession({ state: "in_turn" }));
|
|
749
|
+
const outcome = store.tryTransitionState("test-agent", "booting");
|
|
750
|
+
expect(outcome.ok).toBe(false);
|
|
751
|
+
if (!outcome.ok && outcome.reason === "illegal_transition") {
|
|
752
|
+
expect(outcome.prev).toBe("in_turn");
|
|
753
|
+
}
|
|
754
|
+
});
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
// === migration: pre-3087 CHECK constraint relaxation ===
|
|
758
|
+
//
|
|
759
|
+
// SQLite cannot DROP a CHECK constraint in place, so the old inline CHECK on
|
|
760
|
+
// the `state` column must be removed by rebuilding the table. The migration
|
|
761
|
+
// must preserve every existing row verbatim and let inserts of the new
|
|
762
|
+
// values (and any future state extensions) land without a schema bump.
|
|
763
|
+
|
|
764
|
+
describe("migration: drop legacy state CHECK constraint", () => {
|
|
765
|
+
test("rebuilds the table when the recorded CHECK predates 3087", async () => {
|
|
766
|
+
store.close();
|
|
767
|
+
|
|
768
|
+
const { Database: Db } = await import("bun:sqlite");
|
|
769
|
+
const legacyDb = new Db(dbPath);
|
|
770
|
+
legacyDb.exec("DROP TABLE IF EXISTS sessions");
|
|
771
|
+
// Recreate using the pre-3087 CHECK so the migration has something
|
|
772
|
+
// to detect and rebuild.
|
|
773
|
+
legacyDb.exec(`
|
|
774
|
+
CREATE TABLE sessions (
|
|
775
|
+
id TEXT PRIMARY KEY,
|
|
776
|
+
agent_name TEXT NOT NULL UNIQUE,
|
|
777
|
+
capability TEXT NOT NULL,
|
|
778
|
+
worktree_path TEXT NOT NULL,
|
|
779
|
+
branch_name TEXT NOT NULL,
|
|
780
|
+
task_id TEXT NOT NULL,
|
|
781
|
+
tmux_session TEXT NOT NULL,
|
|
782
|
+
state TEXT NOT NULL DEFAULT 'booting'
|
|
783
|
+
CHECK(state IN ('booting','working','completed','stalled','zombie')),
|
|
784
|
+
pid INTEGER,
|
|
785
|
+
parent_agent TEXT,
|
|
786
|
+
depth INTEGER NOT NULL DEFAULT 0,
|
|
787
|
+
run_id TEXT,
|
|
788
|
+
started_at TEXT NOT NULL,
|
|
789
|
+
last_activity TEXT NOT NULL,
|
|
790
|
+
escalation_level INTEGER NOT NULL DEFAULT 0,
|
|
791
|
+
stalled_since TEXT,
|
|
792
|
+
transcript_path TEXT,
|
|
793
|
+
prompt_version TEXT,
|
|
794
|
+
claude_session_id TEXT
|
|
795
|
+
)
|
|
796
|
+
`);
|
|
797
|
+
legacyDb.exec(`
|
|
798
|
+
INSERT INTO sessions
|
|
799
|
+
(id, agent_name, capability, worktree_path, branch_name, task_id,
|
|
800
|
+
tmux_session, state, started_at, last_activity)
|
|
801
|
+
VALUES
|
|
802
|
+
('legacy-1','legacy-agent','builder','/tmp/wt','branch','task',
|
|
803
|
+
'','working','2026-01-01T00:00:00.000Z','2026-01-01T00:00:00.000Z')
|
|
804
|
+
`);
|
|
805
|
+
legacyDb.close();
|
|
806
|
+
|
|
807
|
+
// Opening a new SessionStore must run the migration and accept new states.
|
|
808
|
+
const migrated = createSessionStore(dbPath);
|
|
809
|
+
try {
|
|
810
|
+
expect(migrated.getByName("legacy-agent")?.state).toBe("working");
|
|
811
|
+
migrated.upsert(makeSession({ agentName: "fresh-it", id: "s-it", state: "in_turn" }));
|
|
812
|
+
migrated.upsert(makeSession({ agentName: "fresh-bt", id: "s-bt", state: "between_turns" }));
|
|
813
|
+
expect(migrated.getByName("fresh-it")?.state).toBe("in_turn");
|
|
814
|
+
expect(migrated.getByName("fresh-bt")?.state).toBe("between_turns");
|
|
815
|
+
} finally {
|
|
816
|
+
migrated.close();
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
store = createSessionStore(join(tempDir, "unused.db"));
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
test("is a no-op when the CHECK has already been dropped (idempotent)", () => {
|
|
823
|
+
// `store` was created by beforeEach against a fresh DB whose CREATE
|
|
824
|
+
// TABLE no longer carries a CHECK on `state`. Reopen on the same path
|
|
825
|
+
// and verify it does not throw or rebuild gratuitously.
|
|
826
|
+
store.upsert(makeSession({ state: "in_turn" }));
|
|
827
|
+
|
|
828
|
+
const reopened = createSessionStore(dbPath);
|
|
829
|
+
try {
|
|
830
|
+
expect(reopened.getByName("test-agent")?.state).toBe("in_turn");
|
|
831
|
+
} finally {
|
|
832
|
+
reopened.close();
|
|
833
|
+
}
|
|
834
|
+
});
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
// === tmux_session clearing on terminal transitions (agentplate-14c0) ===
|
|
838
|
+
//
|
|
839
|
+
// The tmux session is torn down by ap stop / watchdog / coordinator cleanup
|
|
840
|
+
// before the state lands at completed/zombie. The stored tmux_session string
|
|
841
|
+
// would otherwise stay forever, surfacing dead session names in the agents
|
|
842
|
+
// view of `ap status`. Both updateState and tryTransitionState must clear it.
|
|
843
|
+
|
|
844
|
+
describe("tmux_session clearing on terminal transitions", () => {
|
|
845
|
+
const tmux = "agentplate-test-agent";
|
|
846
|
+
|
|
847
|
+
describe("updateState", () => {
|
|
848
|
+
test("clears tmux_session when transitioning to completed", () => {
|
|
849
|
+
store.upsert(makeSession({ state: "working", tmuxSession: tmux }));
|
|
850
|
+
store.updateState("test-agent", "completed");
|
|
851
|
+
const result = store.getByName("test-agent");
|
|
852
|
+
expect(result?.state).toBe("completed");
|
|
853
|
+
expect(result?.tmuxSession).toBe("");
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
test("clears tmux_session when transitioning to zombie", () => {
|
|
857
|
+
store.upsert(makeSession({ state: "working", tmuxSession: tmux }));
|
|
858
|
+
store.updateState("test-agent", "zombie");
|
|
859
|
+
const result = store.getByName("test-agent");
|
|
860
|
+
expect(result?.state).toBe("zombie");
|
|
861
|
+
expect(result?.tmuxSession).toBe("");
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
test("preserves tmux_session when transitioning to a non-terminal state", () => {
|
|
865
|
+
store.upsert(makeSession({ state: "booting", tmuxSession: tmux }));
|
|
866
|
+
store.updateState("test-agent", "working");
|
|
867
|
+
expect(store.getByName("test-agent")?.tmuxSession).toBe(tmux);
|
|
868
|
+
|
|
869
|
+
store.updateState("test-agent", "stalled");
|
|
870
|
+
expect(store.getByName("test-agent")?.tmuxSession).toBe(tmux);
|
|
871
|
+
});
|
|
872
|
+
|
|
873
|
+
test("idempotent terminal write keeps tmux_session cleared", () => {
|
|
874
|
+
store.upsert(makeSession({ state: "completed", tmuxSession: "" }));
|
|
875
|
+
store.updateState("test-agent", "completed");
|
|
876
|
+
expect(store.getByName("test-agent")?.tmuxSession).toBe("");
|
|
877
|
+
});
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
describe("tryTransitionState", () => {
|
|
881
|
+
test("clears tmux_session on working → completed", () => {
|
|
882
|
+
store.upsert(makeSession({ state: "working", tmuxSession: tmux }));
|
|
883
|
+
const outcome = store.tryTransitionState("test-agent", "completed");
|
|
884
|
+
expect(outcome.ok).toBe(true);
|
|
885
|
+
expect(store.getByName("test-agent")?.tmuxSession).toBe("");
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
test("clears tmux_session on working → zombie", () => {
|
|
889
|
+
store.upsert(makeSession({ state: "working", tmuxSession: tmux }));
|
|
890
|
+
const outcome = store.tryTransitionState("test-agent", "zombie");
|
|
891
|
+
expect(outcome.ok).toBe(true);
|
|
892
|
+
expect(store.getByName("test-agent")?.tmuxSession).toBe("");
|
|
893
|
+
});
|
|
894
|
+
|
|
895
|
+
test("clears tmux_session on zombie → completed (ap stop cleanup of zombie)", () => {
|
|
896
|
+
// Zombie may still hold a tmux_session if the watchdog landed before
|
|
897
|
+
// any cleanup pass; the subsequent ap stop must wipe it.
|
|
898
|
+
store.upsert(makeSession({ state: "zombie", tmuxSession: tmux }));
|
|
899
|
+
const outcome = store.tryTransitionState("test-agent", "completed");
|
|
900
|
+
expect(outcome.ok).toBe(true);
|
|
901
|
+
expect(store.getByName("test-agent")?.tmuxSession).toBe("");
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
test("clears tmux_session on stalled → zombie (watchdog terminate)", () => {
|
|
905
|
+
store.upsert(makeSession({ state: "stalled", tmuxSession: tmux }));
|
|
906
|
+
const outcome = store.tryTransitionState("test-agent", "zombie");
|
|
907
|
+
expect(outcome.ok).toBe(true);
|
|
908
|
+
expect(store.getByName("test-agent")?.tmuxSession).toBe("");
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
test("preserves tmux_session on non-terminal targets (booting → working)", () => {
|
|
912
|
+
store.upsert(makeSession({ state: "booting", tmuxSession: tmux }));
|
|
913
|
+
const outcome = store.tryTransitionState("test-agent", "working");
|
|
914
|
+
expect(outcome.ok).toBe(true);
|
|
915
|
+
expect(store.getByName("test-agent")?.tmuxSession).toBe(tmux);
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
test("preserves tmux_session on non-terminal targets (stalled → working)", () => {
|
|
919
|
+
store.upsert(makeSession({ state: "stalled", tmuxSession: tmux }));
|
|
920
|
+
const outcome = store.tryTransitionState("test-agent", "working");
|
|
921
|
+
expect(outcome.ok).toBe(true);
|
|
922
|
+
expect(store.getByName("test-agent")?.tmuxSession).toBe(tmux);
|
|
923
|
+
});
|
|
924
|
+
|
|
925
|
+
test("does not clear tmux_session when CAS rejects an illegal terminal transition", () => {
|
|
926
|
+
// completed → zombie is rejected. The row must remain untouched —
|
|
927
|
+
// in particular, tmux_session must keep whatever the row already
|
|
928
|
+
// held (an empty string here, since the prior completed write
|
|
929
|
+
// cleared it).
|
|
930
|
+
store.upsert(makeSession({ state: "completed", tmuxSession: "" }));
|
|
931
|
+
const outcome = store.tryTransitionState("test-agent", "zombie");
|
|
932
|
+
expect(outcome.ok).toBe(false);
|
|
933
|
+
expect(store.getByName("test-agent")?.tmuxSession).toBe("");
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
test("does not clear tmux_session when CAS rejects against a live row", () => {
|
|
937
|
+
// nothing transitions into booting; a working row keeps both state
|
|
938
|
+
// and tmux_session.
|
|
939
|
+
store.upsert(makeSession({ state: "working", tmuxSession: tmux }));
|
|
940
|
+
const outcome = store.tryTransitionState("test-agent", "booting");
|
|
941
|
+
expect(outcome.ok).toBe(false);
|
|
942
|
+
const result = store.getByName("test-agent");
|
|
943
|
+
expect(result?.state).toBe("working");
|
|
944
|
+
expect(result?.tmuxSession).toBe(tmux);
|
|
945
|
+
});
|
|
946
|
+
});
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
// === updateLastActivity ===
|
|
950
|
+
|
|
951
|
+
describe("updateLastActivity", () => {
|
|
952
|
+
test("updates lastActivity to a recent ISO timestamp", () => {
|
|
953
|
+
const oldTime = "2026-01-01T00:00:00.000Z";
|
|
954
|
+
store.upsert(makeSession({ lastActivity: oldTime }));
|
|
955
|
+
|
|
956
|
+
const before = new Date().toISOString();
|
|
957
|
+
store.updateLastActivity("test-agent");
|
|
958
|
+
const after = new Date().toISOString();
|
|
959
|
+
|
|
960
|
+
const result = store.getByName("test-agent");
|
|
961
|
+
expect(result).not.toBeNull();
|
|
962
|
+
const updatedActivity = result?.lastActivity ?? "";
|
|
963
|
+
// The updated timestamp should be between before and after
|
|
964
|
+
expect(updatedActivity >= before).toBe(true);
|
|
965
|
+
expect(updatedActivity <= after).toBe(true);
|
|
966
|
+
});
|
|
967
|
+
|
|
968
|
+
test("does not modify other fields", () => {
|
|
969
|
+
const original = makeSession({ state: "working", escalationLevel: 2 });
|
|
970
|
+
store.upsert(original);
|
|
971
|
+
|
|
972
|
+
store.updateLastActivity("test-agent");
|
|
973
|
+
|
|
974
|
+
const result = store.getByName("test-agent");
|
|
975
|
+
expect(result?.state).toBe("working");
|
|
976
|
+
expect(result?.escalationLevel).toBe(2);
|
|
977
|
+
expect(result?.id).toBe(original.id);
|
|
978
|
+
});
|
|
979
|
+
});
|
|
980
|
+
|
|
981
|
+
// === updateEscalation ===
|
|
982
|
+
|
|
983
|
+
describe("updateEscalation", () => {
|
|
984
|
+
test("updates escalation level and stalled timestamp", () => {
|
|
985
|
+
store.upsert(makeSession({ escalationLevel: 0, stalledSince: null }));
|
|
986
|
+
|
|
987
|
+
const stalledTime = "2026-01-15T10:30:00.000Z";
|
|
988
|
+
store.updateEscalation("test-agent", 2, stalledTime);
|
|
989
|
+
|
|
990
|
+
const result = store.getByName("test-agent");
|
|
991
|
+
expect(result?.escalationLevel).toBe(2);
|
|
992
|
+
expect(result?.stalledSince).toBe(stalledTime);
|
|
993
|
+
});
|
|
994
|
+
|
|
995
|
+
test("can clear stalledSince by passing null", () => {
|
|
996
|
+
const stalledTime = "2026-01-15T10:30:00.000Z";
|
|
997
|
+
store.upsert(makeSession({ escalationLevel: 2, stalledSince: stalledTime }));
|
|
998
|
+
|
|
999
|
+
store.updateEscalation("test-agent", 0, null);
|
|
1000
|
+
|
|
1001
|
+
const result = store.getByName("test-agent");
|
|
1002
|
+
expect(result?.escalationLevel).toBe(0);
|
|
1003
|
+
expect(result?.stalledSince).toBeNull();
|
|
1004
|
+
});
|
|
1005
|
+
});
|
|
1006
|
+
|
|
1007
|
+
// === remove ===
|
|
1008
|
+
|
|
1009
|
+
describe("remove", () => {
|
|
1010
|
+
test("removes an existing session", () => {
|
|
1011
|
+
store.upsert(makeSession());
|
|
1012
|
+
|
|
1013
|
+
store.remove("test-agent");
|
|
1014
|
+
|
|
1015
|
+
const result = store.getByName("test-agent");
|
|
1016
|
+
expect(result).toBeNull();
|
|
1017
|
+
});
|
|
1018
|
+
|
|
1019
|
+
test("is a no-op for nonexistent agent", () => {
|
|
1020
|
+
// Should not throw
|
|
1021
|
+
store.remove("nonexistent");
|
|
1022
|
+
expect(store.getAll()).toEqual([]);
|
|
1023
|
+
});
|
|
1024
|
+
|
|
1025
|
+
test("does not affect other sessions", () => {
|
|
1026
|
+
store.upsert(makeSession({ agentName: "keep-me", id: "s-1" }));
|
|
1027
|
+
store.upsert(makeSession({ agentName: "remove-me", id: "s-2" }));
|
|
1028
|
+
|
|
1029
|
+
store.remove("remove-me");
|
|
1030
|
+
|
|
1031
|
+
expect(store.getAll()).toHaveLength(1);
|
|
1032
|
+
expect(store.getByName("keep-me")).not.toBeNull();
|
|
1033
|
+
});
|
|
1034
|
+
});
|
|
1035
|
+
|
|
1036
|
+
// === purge ===
|
|
1037
|
+
|
|
1038
|
+
describe("purge", () => {
|
|
1039
|
+
test("purge({ all: true }) removes all sessions and returns count", () => {
|
|
1040
|
+
store.upsert(makeSession({ agentName: "a1", id: "s-1" }));
|
|
1041
|
+
store.upsert(makeSession({ agentName: "a2", id: "s-2" }));
|
|
1042
|
+
store.upsert(makeSession({ agentName: "a3", id: "s-3" }));
|
|
1043
|
+
|
|
1044
|
+
const count = store.purge({ all: true });
|
|
1045
|
+
expect(count).toBe(3);
|
|
1046
|
+
expect(store.getAll()).toEqual([]);
|
|
1047
|
+
});
|
|
1048
|
+
|
|
1049
|
+
test("purge({ state }) removes only sessions with that state", () => {
|
|
1050
|
+
store.upsert(makeSession({ agentName: "a1", id: "s-1", state: "completed" }));
|
|
1051
|
+
store.upsert(makeSession({ agentName: "a2", id: "s-2", state: "working" }));
|
|
1052
|
+
store.upsert(makeSession({ agentName: "a3", id: "s-3", state: "completed" }));
|
|
1053
|
+
|
|
1054
|
+
const count = store.purge({ state: "completed" });
|
|
1055
|
+
expect(count).toBe(2);
|
|
1056
|
+
expect(store.getAll()).toHaveLength(1);
|
|
1057
|
+
expect(store.getByName("a2")?.state).toBe("working");
|
|
1058
|
+
});
|
|
1059
|
+
|
|
1060
|
+
test("purge({ agent }) removes only the specified agent", () => {
|
|
1061
|
+
store.upsert(makeSession({ agentName: "target", id: "s-1" }));
|
|
1062
|
+
store.upsert(makeSession({ agentName: "bystander", id: "s-2" }));
|
|
1063
|
+
|
|
1064
|
+
const count = store.purge({ agent: "target" });
|
|
1065
|
+
expect(count).toBe(1);
|
|
1066
|
+
expect(store.getByName("target")).toBeNull();
|
|
1067
|
+
expect(store.getByName("bystander")).not.toBeNull();
|
|
1068
|
+
});
|
|
1069
|
+
|
|
1070
|
+
test("purge({ state, agent }) combines filters with AND", () => {
|
|
1071
|
+
store.upsert(makeSession({ agentName: "a1", id: "s-1", state: "completed" }));
|
|
1072
|
+
store.upsert(makeSession({ agentName: "a2", id: "s-2", state: "working" }));
|
|
1073
|
+
|
|
1074
|
+
// Only purge if agent is "a1" AND state is "completed"
|
|
1075
|
+
const count = store.purge({ state: "completed", agent: "a1" });
|
|
1076
|
+
expect(count).toBe(1);
|
|
1077
|
+
expect(store.getAll()).toHaveLength(1);
|
|
1078
|
+
});
|
|
1079
|
+
|
|
1080
|
+
test("purge with no matching criteria returns 0", () => {
|
|
1081
|
+
store.upsert(makeSession());
|
|
1082
|
+
const count = store.purge({});
|
|
1083
|
+
expect(count).toBe(0);
|
|
1084
|
+
expect(store.getAll()).toHaveLength(1);
|
|
1085
|
+
});
|
|
1086
|
+
|
|
1087
|
+
test("purge({ all: true }) returns 0 on empty database", () => {
|
|
1088
|
+
const count = store.purge({ all: true });
|
|
1089
|
+
expect(count).toBe(0);
|
|
1090
|
+
});
|
|
1091
|
+
});
|
|
1092
|
+
|
|
1093
|
+
// === close ===
|
|
1094
|
+
|
|
1095
|
+
describe("close", () => {
|
|
1096
|
+
test("close does not throw when called on open store", () => {
|
|
1097
|
+
store.upsert(makeSession());
|
|
1098
|
+
// Should not throw
|
|
1099
|
+
expect(() => store.close()).not.toThrow();
|
|
1100
|
+
});
|
|
1101
|
+
});
|
|
1102
|
+
|
|
1103
|
+
// === concurrent access / file-based behavior ===
|
|
1104
|
+
|
|
1105
|
+
describe("file-based database", () => {
|
|
1106
|
+
test("data persists across separate store instances", () => {
|
|
1107
|
+
const session = makeSession();
|
|
1108
|
+
store.upsert(session);
|
|
1109
|
+
store.close();
|
|
1110
|
+
|
|
1111
|
+
// Open a new store on the same file
|
|
1112
|
+
const store2 = createSessionStore(dbPath);
|
|
1113
|
+
const result = store2.getByName("test-agent");
|
|
1114
|
+
expect(result).toEqual(session);
|
|
1115
|
+
store2.close();
|
|
1116
|
+
|
|
1117
|
+
// Re-assign store so afterEach cleanup does not double-close
|
|
1118
|
+
store = createSessionStore(join(tempDir, "unused.db"));
|
|
1119
|
+
});
|
|
1120
|
+
|
|
1121
|
+
test("schema is created idempotently (opening same db twice is safe)", () => {
|
|
1122
|
+
store.upsert(makeSession());
|
|
1123
|
+
store.close();
|
|
1124
|
+
|
|
1125
|
+
// Re-open -- should not fail even though table/indexes already exist
|
|
1126
|
+
const store2 = createSessionStore(dbPath);
|
|
1127
|
+
const result = store2.getAll();
|
|
1128
|
+
expect(result).toHaveLength(1);
|
|
1129
|
+
store2.close();
|
|
1130
|
+
|
|
1131
|
+
store = createSessionStore(join(tempDir, "unused.db"));
|
|
1132
|
+
});
|
|
1133
|
+
});
|
|
1134
|
+
|
|
1135
|
+
// === agent_name UNIQUE constraint ===
|
|
1136
|
+
|
|
1137
|
+
describe("agent_name uniqueness", () => {
|
|
1138
|
+
test("UNIQUE constraint on agent_name allows upsert to work correctly", () => {
|
|
1139
|
+
store.upsert(makeSession({ agentName: "unique-agent", id: "s-1", state: "booting" }));
|
|
1140
|
+
store.upsert(makeSession({ agentName: "unique-agent", id: "s-2", state: "working" }));
|
|
1141
|
+
|
|
1142
|
+
const all = store.getAll();
|
|
1143
|
+
expect(all).toHaveLength(1);
|
|
1144
|
+
expect(all[0]?.id).toBe("s-2");
|
|
1145
|
+
expect(all[0]?.state).toBe("working");
|
|
1146
|
+
});
|
|
1147
|
+
});
|
|
1148
|
+
|
|
1149
|
+
// === edge cases ===
|
|
1150
|
+
|
|
1151
|
+
describe("edge cases", () => {
|
|
1152
|
+
test("handles many sessions efficiently", () => {
|
|
1153
|
+
for (let i = 0; i < 100; i++) {
|
|
1154
|
+
store.upsert(
|
|
1155
|
+
makeSession({
|
|
1156
|
+
agentName: `agent-${i}`,
|
|
1157
|
+
id: `session-${i}`,
|
|
1158
|
+
state: i % 3 === 0 ? "completed" : "working",
|
|
1159
|
+
}),
|
|
1160
|
+
);
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
const all = store.getAll();
|
|
1164
|
+
expect(all).toHaveLength(100);
|
|
1165
|
+
|
|
1166
|
+
const active = store.getActive();
|
|
1167
|
+
// 34 completed (0,3,6,...,99) + 66 working. Active = working only since no booting/stalled
|
|
1168
|
+
expect(active.length).toBeGreaterThan(0);
|
|
1169
|
+
expect(active.every((s) => s.state !== "completed" && s.state !== "zombie")).toBe(true);
|
|
1170
|
+
});
|
|
1171
|
+
|
|
1172
|
+
test("special characters in agent name and worktree path", () => {
|
|
1173
|
+
const session = makeSession({
|
|
1174
|
+
agentName: "agent-with-special_chars.v2",
|
|
1175
|
+
worktreePath: "/tmp/path with spaces/worktree",
|
|
1176
|
+
branchName: "agentplate/agent-with-special_chars.v2/task-1",
|
|
1177
|
+
});
|
|
1178
|
+
|
|
1179
|
+
store.upsert(session);
|
|
1180
|
+
|
|
1181
|
+
const result = store.getByName("agent-with-special_chars.v2");
|
|
1182
|
+
expect(result?.worktreePath).toBe("/tmp/path with spaces/worktree");
|
|
1183
|
+
});
|
|
1184
|
+
|
|
1185
|
+
test("empty string fields are stored correctly", () => {
|
|
1186
|
+
const session = makeSession({ taskId: "", capability: "builder" });
|
|
1187
|
+
store.upsert(session);
|
|
1188
|
+
|
|
1189
|
+
const result = store.getByName("test-agent");
|
|
1190
|
+
expect(result?.taskId).toBe("");
|
|
1191
|
+
});
|
|
1192
|
+
});
|
|
1193
|
+
|
|
1194
|
+
// ============================================================
|
|
1195
|
+
// SessionStore migration: coordinator_name in runs table
|
|
1196
|
+
// ============================================================
|
|
1197
|
+
|
|
1198
|
+
describe("createSessionStore migrates runs table coordinator_name", () => {
|
|
1199
|
+
test("opens successfully when existing runs table lacks coordinator_name", async () => {
|
|
1200
|
+
// Close the store created by beforeEach so we can recreate the DB manually.
|
|
1201
|
+
store.close();
|
|
1202
|
+
|
|
1203
|
+
const { Database: Db } = await import("bun:sqlite");
|
|
1204
|
+
const legacyDb = new Db(dbPath);
|
|
1205
|
+
// Drop runs table and recreate WITHOUT coordinator_name (simulates pre-migration DB).
|
|
1206
|
+
legacyDb.exec("DROP TABLE IF EXISTS runs");
|
|
1207
|
+
legacyDb.exec(`
|
|
1208
|
+
CREATE TABLE runs (
|
|
1209
|
+
id TEXT PRIMARY KEY,
|
|
1210
|
+
started_at TEXT NOT NULL,
|
|
1211
|
+
completed_at TEXT,
|
|
1212
|
+
agent_count INTEGER NOT NULL DEFAULT 0,
|
|
1213
|
+
coordinator_session_id TEXT,
|
|
1214
|
+
status TEXT NOT NULL DEFAULT 'active'
|
|
1215
|
+
)
|
|
1216
|
+
`);
|
|
1217
|
+
legacyDb.exec(
|
|
1218
|
+
"INSERT INTO runs (id, started_at, status) VALUES ('legacy-run', '2026-01-01T00:00:00.000Z', 'active')",
|
|
1219
|
+
);
|
|
1220
|
+
legacyDb.close();
|
|
1221
|
+
|
|
1222
|
+
// createSessionStore should migrate the table and create indexes without error.
|
|
1223
|
+
const migratedStore = createSessionStore(dbPath);
|
|
1224
|
+
try {
|
|
1225
|
+
// Verify the store works (no "no such column" error).
|
|
1226
|
+
expect(migratedStore.getAll()).toBeArray();
|
|
1227
|
+
} finally {
|
|
1228
|
+
migratedStore.close();
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
// Re-assign so afterEach cleanup works.
|
|
1232
|
+
store = createSessionStore(join(tempDir, "unused.db"));
|
|
1233
|
+
});
|
|
1234
|
+
});
|
|
1235
|
+
|
|
1236
|
+
// ============================================================
|
|
1237
|
+
// RunStore Tests
|
|
1238
|
+
// ============================================================
|
|
1239
|
+
|
|
1240
|
+
describe("RunStore", () => {
|
|
1241
|
+
let runStore: RunStore;
|
|
1242
|
+
|
|
1243
|
+
beforeEach(async () => {
|
|
1244
|
+
// Reuse the same dbPath so RunStore shares sessions.db with SessionStore
|
|
1245
|
+
runStore = createRunStore(dbPath);
|
|
1246
|
+
});
|
|
1247
|
+
|
|
1248
|
+
afterEach(() => {
|
|
1249
|
+
runStore.close();
|
|
1250
|
+
});
|
|
1251
|
+
|
|
1252
|
+
/** Helper to create an InsertRun with optional overrides. */
|
|
1253
|
+
function makeRun(overrides: Partial<InsertRun> = {}): InsertRun {
|
|
1254
|
+
return {
|
|
1255
|
+
id: "run-2026-02-13T10:00:00.000Z",
|
|
1256
|
+
startedAt: "2026-02-13T10:00:00.000Z",
|
|
1257
|
+
coordinatorSessionId: "coord-session-001",
|
|
1258
|
+
status: "active",
|
|
1259
|
+
...overrides,
|
|
1260
|
+
};
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
// === createRun + getRun ===
|
|
1264
|
+
|
|
1265
|
+
describe("createRun and getRun", () => {
|
|
1266
|
+
test("creates and retrieves a run", () => {
|
|
1267
|
+
runStore.createRun(makeRun());
|
|
1268
|
+
|
|
1269
|
+
const result = runStore.getRun("run-2026-02-13T10:00:00.000Z");
|
|
1270
|
+
expect(result).not.toBeNull();
|
|
1271
|
+
expect(result?.id).toBe("run-2026-02-13T10:00:00.000Z");
|
|
1272
|
+
expect(result?.startedAt).toBe("2026-02-13T10:00:00.000Z");
|
|
1273
|
+
expect(result?.completedAt).toBeNull();
|
|
1274
|
+
expect(result?.agentCount).toBe(0);
|
|
1275
|
+
expect(result?.coordinatorSessionId).toBe("coord-session-001");
|
|
1276
|
+
expect(result?.status).toBe("active");
|
|
1277
|
+
});
|
|
1278
|
+
|
|
1279
|
+
test("returns null for nonexistent run", () => {
|
|
1280
|
+
const result = runStore.getRun("nonexistent-run");
|
|
1281
|
+
expect(result).toBeNull();
|
|
1282
|
+
});
|
|
1283
|
+
|
|
1284
|
+
test("agent count is derived from sessions in the same run", () => {
|
|
1285
|
+
runStore.createRun(makeRun({ agentCount: 5 }));
|
|
1286
|
+
// `agentCount` no longer reflects the column; it's a live count of
|
|
1287
|
+
// sessions whose `run_id` matches. With no sessions, count is 0.
|
|
1288
|
+
expect(runStore.getRun("run-2026-02-13T10:00:00.000Z")?.agentCount).toBe(0);
|
|
1289
|
+
|
|
1290
|
+
store.upsert(
|
|
1291
|
+
makeSession({
|
|
1292
|
+
id: "s-1",
|
|
1293
|
+
agentName: "a-1",
|
|
1294
|
+
runId: "run-2026-02-13T10:00:00.000Z",
|
|
1295
|
+
}),
|
|
1296
|
+
);
|
|
1297
|
+
expect(runStore.getRun("run-2026-02-13T10:00:00.000Z")?.agentCount).toBe(1);
|
|
1298
|
+
|
|
1299
|
+
store.upsert(
|
|
1300
|
+
makeSession({
|
|
1301
|
+
id: "s-2",
|
|
1302
|
+
agentName: "a-2",
|
|
1303
|
+
runId: "run-2026-02-13T10:00:00.000Z",
|
|
1304
|
+
}),
|
|
1305
|
+
);
|
|
1306
|
+
expect(runStore.getRun("run-2026-02-13T10:00:00.000Z")?.agentCount).toBe(2);
|
|
1307
|
+
});
|
|
1308
|
+
|
|
1309
|
+
test("creates a run with null coordinatorSessionId", () => {
|
|
1310
|
+
runStore.createRun(makeRun({ coordinatorSessionId: null }));
|
|
1311
|
+
|
|
1312
|
+
const result = runStore.getRun("run-2026-02-13T10:00:00.000Z");
|
|
1313
|
+
expect(result?.coordinatorSessionId).toBeNull();
|
|
1314
|
+
});
|
|
1315
|
+
|
|
1316
|
+
test("rejects invalid status via CHECK constraint", () => {
|
|
1317
|
+
const badRun = makeRun({ status: "invalid" as Run["status"] });
|
|
1318
|
+
expect(() => runStore.createRun(badRun)).toThrow();
|
|
1319
|
+
});
|
|
1320
|
+
|
|
1321
|
+
test("rejects duplicate run IDs via PRIMARY KEY constraint", () => {
|
|
1322
|
+
runStore.createRun(makeRun());
|
|
1323
|
+
expect(() => runStore.createRun(makeRun())).toThrow();
|
|
1324
|
+
});
|
|
1325
|
+
});
|
|
1326
|
+
|
|
1327
|
+
// === getActiveRun ===
|
|
1328
|
+
|
|
1329
|
+
describe("getActiveRun", () => {
|
|
1330
|
+
test("returns null when no runs exist", () => {
|
|
1331
|
+
const result = runStore.getActiveRun();
|
|
1332
|
+
expect(result).toBeNull();
|
|
1333
|
+
});
|
|
1334
|
+
|
|
1335
|
+
test("returns the most recently started active run", () => {
|
|
1336
|
+
runStore.createRun(
|
|
1337
|
+
makeRun({
|
|
1338
|
+
id: "run-early",
|
|
1339
|
+
startedAt: "2026-02-13T08:00:00.000Z",
|
|
1340
|
+
status: "active",
|
|
1341
|
+
}),
|
|
1342
|
+
);
|
|
1343
|
+
runStore.createRun(
|
|
1344
|
+
makeRun({
|
|
1345
|
+
id: "run-late",
|
|
1346
|
+
startedAt: "2026-02-13T12:00:00.000Z",
|
|
1347
|
+
status: "active",
|
|
1348
|
+
}),
|
|
1349
|
+
);
|
|
1350
|
+
|
|
1351
|
+
const result = runStore.getActiveRun();
|
|
1352
|
+
expect(result?.id).toBe("run-late");
|
|
1353
|
+
});
|
|
1354
|
+
|
|
1355
|
+
test("ignores completed and failed runs", () => {
|
|
1356
|
+
runStore.createRun(makeRun({ id: "run-completed", status: "active" }));
|
|
1357
|
+
runStore.completeRun("run-completed", "completed");
|
|
1358
|
+
|
|
1359
|
+
runStore.createRun(
|
|
1360
|
+
makeRun({
|
|
1361
|
+
id: "run-failed",
|
|
1362
|
+
startedAt: "2026-02-13T11:00:00.000Z",
|
|
1363
|
+
status: "active",
|
|
1364
|
+
}),
|
|
1365
|
+
);
|
|
1366
|
+
runStore.completeRun("run-failed", "failed");
|
|
1367
|
+
|
|
1368
|
+
const result = runStore.getActiveRun();
|
|
1369
|
+
expect(result).toBeNull();
|
|
1370
|
+
});
|
|
1371
|
+
});
|
|
1372
|
+
|
|
1373
|
+
// === listRuns ===
|
|
1374
|
+
|
|
1375
|
+
describe("listRuns", () => {
|
|
1376
|
+
test("returns empty array when no runs exist", () => {
|
|
1377
|
+
const result = runStore.listRuns();
|
|
1378
|
+
expect(result).toEqual([]);
|
|
1379
|
+
});
|
|
1380
|
+
|
|
1381
|
+
test("returns all runs ordered by started_at descending", () => {
|
|
1382
|
+
runStore.createRun(
|
|
1383
|
+
makeRun({
|
|
1384
|
+
id: "run-1",
|
|
1385
|
+
startedAt: "2026-02-13T08:00:00.000Z",
|
|
1386
|
+
}),
|
|
1387
|
+
);
|
|
1388
|
+
runStore.createRun(
|
|
1389
|
+
makeRun({
|
|
1390
|
+
id: "run-2",
|
|
1391
|
+
startedAt: "2026-02-13T12:00:00.000Z",
|
|
1392
|
+
}),
|
|
1393
|
+
);
|
|
1394
|
+
runStore.createRun(
|
|
1395
|
+
makeRun({
|
|
1396
|
+
id: "run-3",
|
|
1397
|
+
startedAt: "2026-02-13T10:00:00.000Z",
|
|
1398
|
+
}),
|
|
1399
|
+
);
|
|
1400
|
+
|
|
1401
|
+
const result = runStore.listRuns();
|
|
1402
|
+
expect(result).toHaveLength(3);
|
|
1403
|
+
expect(result[0]?.id).toBe("run-2");
|
|
1404
|
+
expect(result[1]?.id).toBe("run-3");
|
|
1405
|
+
expect(result[2]?.id).toBe("run-1");
|
|
1406
|
+
});
|
|
1407
|
+
|
|
1408
|
+
test("filters by status", () => {
|
|
1409
|
+
runStore.createRun(makeRun({ id: "run-active", status: "active" }));
|
|
1410
|
+
runStore.createRun(
|
|
1411
|
+
makeRun({
|
|
1412
|
+
id: "run-to-complete",
|
|
1413
|
+
startedAt: "2026-02-13T11:00:00.000Z",
|
|
1414
|
+
status: "active",
|
|
1415
|
+
}),
|
|
1416
|
+
);
|
|
1417
|
+
runStore.completeRun("run-to-complete", "completed");
|
|
1418
|
+
|
|
1419
|
+
const activeRuns = runStore.listRuns({ status: "active" });
|
|
1420
|
+
expect(activeRuns).toHaveLength(1);
|
|
1421
|
+
expect(activeRuns[0]?.id).toBe("run-active");
|
|
1422
|
+
|
|
1423
|
+
const completedRuns = runStore.listRuns({ status: "completed" });
|
|
1424
|
+
expect(completedRuns).toHaveLength(1);
|
|
1425
|
+
expect(completedRuns[0]?.id).toBe("run-to-complete");
|
|
1426
|
+
});
|
|
1427
|
+
|
|
1428
|
+
test("respects limit option", () => {
|
|
1429
|
+
for (let i = 0; i < 5; i++) {
|
|
1430
|
+
runStore.createRun(
|
|
1431
|
+
makeRun({
|
|
1432
|
+
id: `run-${i}`,
|
|
1433
|
+
startedAt: `2026-02-13T${String(10 + i).padStart(2, "0")}:00:00.000Z`,
|
|
1434
|
+
}),
|
|
1435
|
+
);
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
const result = runStore.listRuns({ limit: 2 });
|
|
1439
|
+
expect(result).toHaveLength(2);
|
|
1440
|
+
});
|
|
1441
|
+
|
|
1442
|
+
test("combines status and limit filters", () => {
|
|
1443
|
+
for (let i = 0; i < 5; i++) {
|
|
1444
|
+
runStore.createRun(
|
|
1445
|
+
makeRun({
|
|
1446
|
+
id: `run-${i}`,
|
|
1447
|
+
startedAt: `2026-02-13T${String(10 + i).padStart(2, "0")}:00:00.000Z`,
|
|
1448
|
+
status: "active",
|
|
1449
|
+
}),
|
|
1450
|
+
);
|
|
1451
|
+
}
|
|
1452
|
+
runStore.completeRun("run-0", "completed");
|
|
1453
|
+
runStore.completeRun("run-1", "completed");
|
|
1454
|
+
|
|
1455
|
+
const result = runStore.listRuns({ status: "active", limit: 2 });
|
|
1456
|
+
expect(result).toHaveLength(2);
|
|
1457
|
+
// All returned runs should be active
|
|
1458
|
+
for (const run of result) {
|
|
1459
|
+
expect(run.status).toBe("active");
|
|
1460
|
+
}
|
|
1461
|
+
});
|
|
1462
|
+
});
|
|
1463
|
+
|
|
1464
|
+
// === incrementAgentCount ===
|
|
1465
|
+
//
|
|
1466
|
+
// Retained as a no-op for API compatibility. `agentCount` is now derived
|
|
1467
|
+
// from the sessions table at read time (agentplate-8e69), so calls have no
|
|
1468
|
+
// effect on what `getRun` returns.
|
|
1469
|
+
|
|
1470
|
+
describe("incrementAgentCount", () => {
|
|
1471
|
+
test("is a no-op — agent count comes from sessions, not the column", () => {
|
|
1472
|
+
runStore.createRun(makeRun());
|
|
1473
|
+
|
|
1474
|
+
runStore.incrementAgentCount("run-2026-02-13T10:00:00.000Z");
|
|
1475
|
+
runStore.incrementAgentCount("run-2026-02-13T10:00:00.000Z");
|
|
1476
|
+
|
|
1477
|
+
const result = runStore.getRun("run-2026-02-13T10:00:00.000Z");
|
|
1478
|
+
expect(result?.agentCount).toBe(0);
|
|
1479
|
+
});
|
|
1480
|
+
|
|
1481
|
+
test("is safe to call for a nonexistent run (does not throw)", () => {
|
|
1482
|
+
runStore.incrementAgentCount("nonexistent-run");
|
|
1483
|
+
});
|
|
1484
|
+
});
|
|
1485
|
+
|
|
1486
|
+
// === completeRun ===
|
|
1487
|
+
|
|
1488
|
+
describe("completeRun", () => {
|
|
1489
|
+
test("sets status to completed and records completedAt", () => {
|
|
1490
|
+
runStore.createRun(makeRun());
|
|
1491
|
+
|
|
1492
|
+
const before = new Date().toISOString();
|
|
1493
|
+
runStore.completeRun("run-2026-02-13T10:00:00.000Z", "completed");
|
|
1494
|
+
const after = new Date().toISOString();
|
|
1495
|
+
|
|
1496
|
+
const result = runStore.getRun("run-2026-02-13T10:00:00.000Z");
|
|
1497
|
+
expect(result?.status).toBe("completed");
|
|
1498
|
+
expect(result?.completedAt).not.toBeNull();
|
|
1499
|
+
const completedAt = result?.completedAt ?? "";
|
|
1500
|
+
expect(completedAt >= before).toBe(true);
|
|
1501
|
+
expect(completedAt <= after).toBe(true);
|
|
1502
|
+
});
|
|
1503
|
+
|
|
1504
|
+
test("sets status to failed", () => {
|
|
1505
|
+
runStore.createRun(makeRun());
|
|
1506
|
+
runStore.completeRun("run-2026-02-13T10:00:00.000Z", "failed");
|
|
1507
|
+
|
|
1508
|
+
const result = runStore.getRun("run-2026-02-13T10:00:00.000Z");
|
|
1509
|
+
expect(result?.status).toBe("failed");
|
|
1510
|
+
expect(result?.completedAt).not.toBeNull();
|
|
1511
|
+
});
|
|
1512
|
+
|
|
1513
|
+
test("is a no-op for nonexistent run (does not throw)", () => {
|
|
1514
|
+
// Should not throw
|
|
1515
|
+
runStore.completeRun("nonexistent-run", "completed");
|
|
1516
|
+
});
|
|
1517
|
+
|
|
1518
|
+
test("preserves agent count when completing", () => {
|
|
1519
|
+
runStore.createRun(makeRun());
|
|
1520
|
+
for (let i = 0; i < 3; i++) {
|
|
1521
|
+
store.upsert(
|
|
1522
|
+
makeSession({
|
|
1523
|
+
id: `s-${i}`,
|
|
1524
|
+
agentName: `a-${i}`,
|
|
1525
|
+
runId: "run-2026-02-13T10:00:00.000Z",
|
|
1526
|
+
}),
|
|
1527
|
+
);
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
runStore.completeRun("run-2026-02-13T10:00:00.000Z", "completed");
|
|
1531
|
+
|
|
1532
|
+
const result = runStore.getRun("run-2026-02-13T10:00:00.000Z");
|
|
1533
|
+
expect(result?.agentCount).toBe(3);
|
|
1534
|
+
expect(result?.status).toBe("completed");
|
|
1535
|
+
});
|
|
1536
|
+
});
|
|
1537
|
+
|
|
1538
|
+
// === shared database ===
|
|
1539
|
+
|
|
1540
|
+
describe("shared database with SessionStore", () => {
|
|
1541
|
+
test("RunStore and SessionStore can share the same database file", () => {
|
|
1542
|
+
// SessionStore was already opened on dbPath in the outer beforeEach.
|
|
1543
|
+
// RunStore was opened on dbPath in the inner beforeEach.
|
|
1544
|
+
// Both should work without conflicts.
|
|
1545
|
+
runStore.createRun(makeRun());
|
|
1546
|
+
store.upsert(makeSession({ runId: "run-2026-02-13T10:00:00.000Z" }));
|
|
1547
|
+
|
|
1548
|
+
const run = runStore.getRun("run-2026-02-13T10:00:00.000Z");
|
|
1549
|
+
expect(run).not.toBeNull();
|
|
1550
|
+
|
|
1551
|
+
const sessions = store.getByRun("run-2026-02-13T10:00:00.000Z");
|
|
1552
|
+
expect(sessions).toHaveLength(1);
|
|
1553
|
+
});
|
|
1554
|
+
});
|
|
1555
|
+
|
|
1556
|
+
// === coordinatorName ===
|
|
1557
|
+
|
|
1558
|
+
describe("coordinatorName", () => {
|
|
1559
|
+
test("creates run with coordinatorName and retrieves it", () => {
|
|
1560
|
+
runStore.createRun(makeRun({ coordinatorName: "coordinator" }));
|
|
1561
|
+
const result = runStore.getRun("run-2026-02-13T10:00:00.000Z");
|
|
1562
|
+
expect(result?.coordinatorName).toBe("coordinator");
|
|
1563
|
+
});
|
|
1564
|
+
|
|
1565
|
+
test("creates run with null coordinatorName", () => {
|
|
1566
|
+
runStore.createRun(makeRun({ coordinatorName: null }));
|
|
1567
|
+
const result = runStore.getRun("run-2026-02-13T10:00:00.000Z");
|
|
1568
|
+
expect(result?.coordinatorName).toBeNull();
|
|
1569
|
+
});
|
|
1570
|
+
|
|
1571
|
+
test("creates run without coordinatorName defaults to null", () => {
|
|
1572
|
+
runStore.createRun(makeRun());
|
|
1573
|
+
const result = runStore.getRun("run-2026-02-13T10:00:00.000Z");
|
|
1574
|
+
expect(result?.coordinatorName).toBeNull();
|
|
1575
|
+
});
|
|
1576
|
+
});
|
|
1577
|
+
|
|
1578
|
+
// === getActiveRunForCoordinator ===
|
|
1579
|
+
|
|
1580
|
+
describe("getActiveRunForCoordinator", () => {
|
|
1581
|
+
test("returns the active run for the given coordinator", () => {
|
|
1582
|
+
runStore.createRun(
|
|
1583
|
+
makeRun({
|
|
1584
|
+
id: "run-coord-a",
|
|
1585
|
+
coordinatorName: "coordinator-a",
|
|
1586
|
+
startedAt: "2026-02-13T10:00:00.000Z",
|
|
1587
|
+
}),
|
|
1588
|
+
);
|
|
1589
|
+
runStore.createRun(
|
|
1590
|
+
makeRun({
|
|
1591
|
+
id: "run-coord-b",
|
|
1592
|
+
coordinatorName: "coordinator-b",
|
|
1593
|
+
startedAt: "2026-02-13T11:00:00.000Z",
|
|
1594
|
+
}),
|
|
1595
|
+
);
|
|
1596
|
+
|
|
1597
|
+
const result = runStore.getActiveRunForCoordinator("coordinator-a");
|
|
1598
|
+
expect(result?.id).toBe("run-coord-a");
|
|
1599
|
+
});
|
|
1600
|
+
|
|
1601
|
+
test("returns null when no active run for coordinator", () => {
|
|
1602
|
+
runStore.createRun(makeRun({ id: "run-coord-a", coordinatorName: "coordinator-a" }));
|
|
1603
|
+
runStore.completeRun("run-coord-a", "completed");
|
|
1604
|
+
|
|
1605
|
+
const result = runStore.getActiveRunForCoordinator("coordinator-a");
|
|
1606
|
+
expect(result).toBeNull();
|
|
1607
|
+
});
|
|
1608
|
+
|
|
1609
|
+
test("returns null for unknown coordinator", () => {
|
|
1610
|
+
runStore.createRun(makeRun({ id: "run-coord-a", coordinatorName: "coordinator-a" }));
|
|
1611
|
+
const result = runStore.getActiveRunForCoordinator("other-coordinator");
|
|
1612
|
+
expect(result).toBeNull();
|
|
1613
|
+
});
|
|
1614
|
+
|
|
1615
|
+
test("returns most recent active run when coordinator has multiple", () => {
|
|
1616
|
+
runStore.createRun(
|
|
1617
|
+
makeRun({
|
|
1618
|
+
id: "run-early",
|
|
1619
|
+
coordinatorName: "coordinator",
|
|
1620
|
+
startedAt: "2026-02-13T08:00:00.000Z",
|
|
1621
|
+
}),
|
|
1622
|
+
);
|
|
1623
|
+
runStore.createRun(
|
|
1624
|
+
makeRun({
|
|
1625
|
+
id: "run-late",
|
|
1626
|
+
coordinatorName: "coordinator",
|
|
1627
|
+
startedAt: "2026-02-13T12:00:00.000Z",
|
|
1628
|
+
}),
|
|
1629
|
+
);
|
|
1630
|
+
|
|
1631
|
+
const result = runStore.getActiveRunForCoordinator("coordinator");
|
|
1632
|
+
expect(result?.id).toBe("run-late");
|
|
1633
|
+
});
|
|
1634
|
+
|
|
1635
|
+
test("ignores runs for other coordinators", () => {
|
|
1636
|
+
runStore.createRun(makeRun({ id: "run-a", coordinatorName: "coordinator-a" }));
|
|
1637
|
+
runStore.createRun(
|
|
1638
|
+
makeRun({
|
|
1639
|
+
id: "run-b",
|
|
1640
|
+
coordinatorName: "coordinator-b",
|
|
1641
|
+
startedAt: "2026-02-13T11:00:00.000Z",
|
|
1642
|
+
}),
|
|
1643
|
+
);
|
|
1644
|
+
|
|
1645
|
+
const result = runStore.getActiveRunForCoordinator("coordinator-a");
|
|
1646
|
+
expect(result?.id).toBe("run-a");
|
|
1647
|
+
expect(result?.coordinatorName).toBe("coordinator-a");
|
|
1648
|
+
});
|
|
1649
|
+
});
|
|
1650
|
+
|
|
1651
|
+
// === migration: coordinator_name column ===
|
|
1652
|
+
|
|
1653
|
+
describe("migration: coordinator_name column", () => {
|
|
1654
|
+
test("adds coordinator_name column to existing runs table without it", async () => {
|
|
1655
|
+
// Create a store, close it, then manually drop the coordinator_name column
|
|
1656
|
+
// by creating a fresh DB without it, simulating a pre-migration schema.
|
|
1657
|
+
runStore.close();
|
|
1658
|
+
|
|
1659
|
+
const { Database: Db } = await import("bun:sqlite");
|
|
1660
|
+
const legacyDb = new Db(dbPath);
|
|
1661
|
+
legacyDb.exec("DROP TABLE IF EXISTS runs");
|
|
1662
|
+
legacyDb.exec(`
|
|
1663
|
+
CREATE TABLE runs (
|
|
1664
|
+
id TEXT PRIMARY KEY,
|
|
1665
|
+
started_at TEXT NOT NULL,
|
|
1666
|
+
completed_at TEXT,
|
|
1667
|
+
agent_count INTEGER NOT NULL DEFAULT 0,
|
|
1668
|
+
coordinator_session_id TEXT,
|
|
1669
|
+
status TEXT NOT NULL DEFAULT 'active'
|
|
1670
|
+
)
|
|
1671
|
+
`);
|
|
1672
|
+
legacyDb.exec(
|
|
1673
|
+
"INSERT INTO runs (id, started_at, status) VALUES ('legacy-run', '2026-01-01T00:00:00.000Z', 'active')",
|
|
1674
|
+
);
|
|
1675
|
+
legacyDb.close();
|
|
1676
|
+
|
|
1677
|
+
// Opening a new RunStore should run the migration and add coordinator_name
|
|
1678
|
+
const migratedStore = createRunStore(dbPath);
|
|
1679
|
+
try {
|
|
1680
|
+
const run = migratedStore.getRun("legacy-run");
|
|
1681
|
+
expect(run).not.toBeNull();
|
|
1682
|
+
expect(run?.coordinatorName).toBeNull();
|
|
1683
|
+
} finally {
|
|
1684
|
+
migratedStore.close();
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
// Re-assign store so afterEach cleanup doesn't double-close
|
|
1688
|
+
runStore = createRunStore(join(tempDir, "unused-run.db"));
|
|
1689
|
+
});
|
|
1690
|
+
});
|
|
1691
|
+
|
|
1692
|
+
// === close ===
|
|
1693
|
+
|
|
1694
|
+
describe("close", () => {
|
|
1695
|
+
test("close does not throw when called on open store", () => {
|
|
1696
|
+
runStore.createRun(makeRun());
|
|
1697
|
+
expect(() => runStore.close()).not.toThrow();
|
|
1698
|
+
});
|
|
1699
|
+
});
|
|
1700
|
+
|
|
1701
|
+
// === edge cases ===
|
|
1702
|
+
|
|
1703
|
+
describe("edge cases", () => {
|
|
1704
|
+
test("handles many runs efficiently", () => {
|
|
1705
|
+
for (let i = 0; i < 50; i++) {
|
|
1706
|
+
runStore.createRun(
|
|
1707
|
+
makeRun({
|
|
1708
|
+
id: `run-${i}`,
|
|
1709
|
+
startedAt: `2026-02-13T${String(i).padStart(2, "0")}:00:00.000Z`,
|
|
1710
|
+
}),
|
|
1711
|
+
);
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
const all = runStore.listRuns();
|
|
1715
|
+
expect(all).toHaveLength(50);
|
|
1716
|
+
});
|
|
1717
|
+
|
|
1718
|
+
test("all fields roundtrip correctly", () => {
|
|
1719
|
+
const run: InsertRun = {
|
|
1720
|
+
id: "run-roundtrip-test",
|
|
1721
|
+
startedAt: "2026-02-13T15:30:00.000Z",
|
|
1722
|
+
coordinatorSessionId: "coord-session-roundtrip",
|
|
1723
|
+
status: "active",
|
|
1724
|
+
agentCount: 7,
|
|
1725
|
+
};
|
|
1726
|
+
|
|
1727
|
+
runStore.createRun(run);
|
|
1728
|
+
for (let i = 0; i < 7; i++) {
|
|
1729
|
+
store.upsert(
|
|
1730
|
+
makeSession({
|
|
1731
|
+
id: `s-rt-${i}`,
|
|
1732
|
+
agentName: `a-rt-${i}`,
|
|
1733
|
+
runId: "run-roundtrip-test",
|
|
1734
|
+
}),
|
|
1735
|
+
);
|
|
1736
|
+
}
|
|
1737
|
+
const result = runStore.getRun("run-roundtrip-test");
|
|
1738
|
+
|
|
1739
|
+
expect(result).not.toBeNull();
|
|
1740
|
+
expect(result?.id).toBe("run-roundtrip-test");
|
|
1741
|
+
expect(result?.startedAt).toBe("2026-02-13T15:30:00.000Z");
|
|
1742
|
+
expect(result?.completedAt).toBeNull();
|
|
1743
|
+
expect(result?.agentCount).toBe(7);
|
|
1744
|
+
expect(result?.coordinatorSessionId).toBe("coord-session-roundtrip");
|
|
1745
|
+
expect(result?.status).toBe("active");
|
|
1746
|
+
});
|
|
1747
|
+
});
|
|
1748
|
+
});
|