@selesai/code 0.13.0 → 0.13.1
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/CHANGELOG.md +12 -0
- package/dist/core/agent-session.d.ts +1 -1
- package/dist/core/agent-session.js +1 -1
- package/dist/core/sdk.d.ts +2 -2
- package/dist/core/sdk.js +1 -1
- package/dist/core/system-prompt.d.ts +1 -1
- package/dist/core/system-prompt.js +1 -1
- package/dist/extensions/ascii.txt +9 -0
- package/dist/extensions/package.json +1 -2
- package/dist/extensions/pi-hermes-memory/CHANGELOG.md +371 -0
- package/dist/extensions/pi-hermes-memory/LICENSE +21 -0
- package/dist/extensions/pi-hermes-memory/README.md +643 -0
- package/dist/extensions/pi-hermes-memory/docs/0.1/TASKS.md +197 -0
- package/dist/extensions/pi-hermes-memory/docs/0.2/PLAN.md +290 -0
- package/dist/extensions/pi-hermes-memory/docs/0.2/TASKS.md +134 -0
- package/dist/extensions/pi-hermes-memory/docs/0.2/TEST-PLAN.md +216 -0
- package/dist/extensions/pi-hermes-memory/docs/0.3/PLAN.md +330 -0
- package/dist/extensions/pi-hermes-memory/docs/0.3/TASKS.md +125 -0
- package/dist/extensions/pi-hermes-memory/docs/0.4/PLAN.md +160 -0
- package/dist/extensions/pi-hermes-memory/docs/0.4/TASKS.md +113 -0
- package/dist/extensions/pi-hermes-memory/docs/0.6/CHANGELOG.md +57 -0
- package/dist/extensions/pi-hermes-memory/docs/0.6/PLAN.md +202 -0
- package/dist/extensions/pi-hermes-memory/docs/0.6/TASKS.md +144 -0
- package/dist/extensions/pi-hermes-memory/docs/0.7/PLAN.md +349 -0
- package/dist/extensions/pi-hermes-memory/docs/0.7/TASKS.md +110 -0
- package/dist/extensions/pi-hermes-memory/docs/PUBLISHING.md +149 -0
- package/dist/extensions/pi-hermes-memory/docs/ROADMAP.md +468 -0
- package/dist/extensions/pi-hermes-memory/docs/images/memory-architecture.svg +1 -0
- package/dist/extensions/pi-hermes-memory/docs/images/pi-logo.svg +22 -0
- package/dist/extensions/pi-hermes-memory/docs/images/pi_memory.png +0 -0
- package/dist/extensions/pi-hermes-memory/docs/images/pi_memory.svg +19 -0
- package/dist/extensions/pi-hermes-memory/docs/images/security-flow.svg +1 -0
- package/dist/extensions/pi-hermes-memory/docs/images/session-lifecycle.svg +1 -0
- package/dist/extensions/pi-hermes-memory/docs/images/source-architecture.svg +1 -0
- package/dist/extensions/pi-hermes-memory/docs/mermaid/memory-architecture.mmd +49 -0
- package/dist/extensions/pi-hermes-memory/docs/mermaid/security-flow.mmd +40 -0
- package/dist/extensions/pi-hermes-memory/docs/mermaid/session-lifecycle.mmd +56 -0
- package/dist/extensions/pi-hermes-memory/docs/mermaid/source-architecture.mmd +54 -0
- package/dist/extensions/pi-hermes-memory/package-lock.json +3799 -0
- package/dist/extensions/pi-hermes-memory/package.json +66 -0
- package/dist/extensions/pi-hermes-memory/scripts/check-min-sdk.mjs +99 -0
- package/dist/extensions/pi-hermes-memory/scripts/ensure-dev.mjs +57 -0
- package/dist/extensions/pi-hermes-memory/src/auto-consolidation-warning.ts +6 -0
- package/dist/extensions/pi-hermes-memory/src/config.ts +178 -0
- package/dist/extensions/pi-hermes-memory/src/constants.ts +424 -0
- package/dist/extensions/pi-hermes-memory/src/extension-root-migration.ts +639 -0
- package/dist/extensions/pi-hermes-memory/src/handlers/auto-consolidate.ts +401 -0
- package/dist/extensions/pi-hermes-memory/src/handlers/background-review.ts +297 -0
- package/dist/extensions/pi-hermes-memory/src/handlers/child-process-watchdog.mjs +90 -0
- package/dist/extensions/pi-hermes-memory/src/handlers/correction-detector.ts +300 -0
- package/dist/extensions/pi-hermes-memory/src/handlers/index-sessions.ts +86 -0
- package/dist/extensions/pi-hermes-memory/src/handlers/insights.ts +78 -0
- package/dist/extensions/pi-hermes-memory/src/handlers/interview.ts +37 -0
- package/dist/extensions/pi-hermes-memory/src/handlers/learn-memory.ts +186 -0
- package/dist/extensions/pi-hermes-memory/src/handlers/message-parts.ts +27 -0
- package/dist/extensions/pi-hermes-memory/src/handlers/pi-child-process.ts +485 -0
- package/dist/extensions/pi-hermes-memory/src/handlers/preview-context.ts +105 -0
- package/dist/extensions/pi-hermes-memory/src/handlers/review-memory-ops.ts +506 -0
- package/dist/extensions/pi-hermes-memory/src/handlers/session-backfill.ts +145 -0
- package/dist/extensions/pi-hermes-memory/src/handlers/session-flush.ts +151 -0
- package/dist/extensions/pi-hermes-memory/src/handlers/session-live-index.ts +94 -0
- package/dist/extensions/pi-hermes-memory/src/handlers/skills-command.ts +1334 -0
- package/dist/extensions/pi-hermes-memory/src/handlers/standing-pin.ts +111 -0
- package/dist/extensions/pi-hermes-memory/src/handlers/switch-project.ts +75 -0
- package/dist/extensions/pi-hermes-memory/src/handlers/sync-markdown-memories.ts +266 -0
- package/dist/extensions/pi-hermes-memory/src/index.ts +378 -0
- package/dist/extensions/pi-hermes-memory/src/lifecycle-timing.ts +54 -0
- package/dist/extensions/pi-hermes-memory/src/paths.ts +61 -0
- package/dist/extensions/pi-hermes-memory/src/project-context.ts +13 -0
- package/dist/extensions/pi-hermes-memory/src/project-memory-migration.ts +93 -0
- package/dist/extensions/pi-hermes-memory/src/project.ts +157 -0
- package/dist/extensions/pi-hermes-memory/src/prompt-context.ts +55 -0
- package/dist/extensions/pi-hermes-memory/src/store/atomic-lock-coordinator.ts +375 -0
- package/dist/extensions/pi-hermes-memory/src/store/canonical-storage-path.ts +54 -0
- package/dist/extensions/pi-hermes-memory/src/store/content-scanner.ts +101 -0
- package/dist/extensions/pi-hermes-memory/src/store/db.ts +1180 -0
- package/dist/extensions/pi-hermes-memory/src/store/fts-query.ts +101 -0
- package/dist/extensions/pi-hermes-memory/src/store/markdown-mutation-lock.ts +38 -0
- package/dist/extensions/pi-hermes-memory/src/store/memory-lookup.ts +24 -0
- package/dist/extensions/pi-hermes-memory/src/store/memory-store.ts +1191 -0
- package/dist/extensions/pi-hermes-memory/src/store/recovery-maintenance.ts +66 -0
- package/dist/extensions/pi-hermes-memory/src/store/schema.ts +118 -0
- package/dist/extensions/pi-hermes-memory/src/store/session-anchor-search.ts +472 -0
- package/dist/extensions/pi-hermes-memory/src/store/session-indexer.ts +511 -0
- package/dist/extensions/pi-hermes-memory/src/store/session-parser.ts +213 -0
- package/dist/extensions/pi-hermes-memory/src/store/session-search.ts +234 -0
- package/dist/extensions/pi-hermes-memory/src/store/skill-store.ts +950 -0
- package/dist/extensions/pi-hermes-memory/src/store/skill-utils.ts +133 -0
- package/dist/extensions/pi-hermes-memory/src/store/sqlite-memory-store.ts +1002 -0
- package/dist/extensions/pi-hermes-memory/src/store/sqlite-native.ts +241 -0
- package/dist/extensions/pi-hermes-memory/src/store/standing-instructions.ts +247 -0
- package/dist/extensions/pi-hermes-memory/src/tools/memory-search-tool.ts +95 -0
- package/dist/extensions/pi-hermes-memory/src/tools/memory-tool.ts +469 -0
- package/dist/extensions/pi-hermes-memory/src/tools/session-search-tool.ts +246 -0
- package/dist/extensions/pi-hermes-memory/src/tools/shared-output-view.ts +170 -0
- package/dist/extensions/pi-hermes-memory/src/tools/skill-tool.ts +333 -0
- package/dist/extensions/pi-hermes-memory/src/tools/tool-result-views.ts +105 -0
- package/dist/extensions/pi-hermes-memory/src/types.ts +214 -0
- package/dist/extensions/pi-hermes-memory/tests/config.test.ts +434 -0
- package/dist/extensions/pi-hermes-memory/tests/extension-root-migration.test.ts +368 -0
- package/dist/extensions/pi-hermes-memory/tests/handlers/auto-consolidate.test.ts +984 -0
- package/dist/extensions/pi-hermes-memory/tests/handlers/background-review.test.ts +950 -0
- package/dist/extensions/pi-hermes-memory/tests/handlers/correction-detector.test.ts +671 -0
- package/dist/extensions/pi-hermes-memory/tests/handlers/insights.test.ts +157 -0
- package/dist/extensions/pi-hermes-memory/tests/handlers/interview.test.ts +127 -0
- package/dist/extensions/pi-hermes-memory/tests/handlers/pi-child-process.test.ts +905 -0
- package/dist/extensions/pi-hermes-memory/tests/handlers/preview-context.test.ts +152 -0
- package/dist/extensions/pi-hermes-memory/tests/handlers/prompt-context.test.ts +166 -0
- package/dist/extensions/pi-hermes-memory/tests/handlers/resources-discover.test.ts +71 -0
- package/dist/extensions/pi-hermes-memory/tests/handlers/review-memory-ops.test.ts +614 -0
- package/dist/extensions/pi-hermes-memory/tests/handlers/session-backfill.test.ts +192 -0
- package/dist/extensions/pi-hermes-memory/tests/handlers/session-flush.test.ts +677 -0
- package/dist/extensions/pi-hermes-memory/tests/handlers/session-live-index.test.ts +225 -0
- package/dist/extensions/pi-hermes-memory/tests/handlers/skills-command.test.ts +827 -0
- package/dist/extensions/pi-hermes-memory/tests/handlers/standing-pin.test.ts +134 -0
- package/dist/extensions/pi-hermes-memory/tests/handlers/sync-markdown-memories.test.ts +491 -0
- package/dist/extensions/pi-hermes-memory/tests/handlers/system-prompt.test.ts +199 -0
- package/dist/extensions/pi-hermes-memory/tests/handlers/thinking-max.test.ts +64 -0
- package/dist/extensions/pi-hermes-memory/tests/index.test.ts +30 -0
- package/dist/extensions/pi-hermes-memory/tests/integration/flow.test.ts +126 -0
- package/dist/extensions/pi-hermes-memory/tests/lifecycle-timing.test.ts +76 -0
- package/dist/extensions/pi-hermes-memory/tests/paths.test.ts +23 -0
- package/dist/extensions/pi-hermes-memory/tests/project-memory-migration.test.ts +80 -0
- package/dist/extensions/pi-hermes-memory/tests/project-rebinding.test.ts +115 -0
- package/dist/extensions/pi-hermes-memory/tests/project.test.ts +120 -0
- package/dist/extensions/pi-hermes-memory/tests/run-all-timeout.test.ts +107 -0
- package/dist/extensions/pi-hermes-memory/tests/run-all.sh +45 -0
- package/dist/extensions/pi-hermes-memory/tests/store/atomic-lock-coordinator.test.ts +381 -0
- package/dist/extensions/pi-hermes-memory/tests/store/content-scanner.test.ts +388 -0
- package/dist/extensions/pi-hermes-memory/tests/store/db.test.ts +1046 -0
- package/dist/extensions/pi-hermes-memory/tests/store/markdown-mutation-lock.test.ts +34 -0
- package/dist/extensions/pi-hermes-memory/tests/store/memory-store.test.ts +2150 -0
- package/dist/extensions/pi-hermes-memory/tests/store/recovery-maintenance.test.ts +170 -0
- package/dist/extensions/pi-hermes-memory/tests/store/session-anchor-search.test.ts +210 -0
- package/dist/extensions/pi-hermes-memory/tests/store/session-indexer.test.ts +643 -0
- package/dist/extensions/pi-hermes-memory/tests/store/session-parser.test.ts +282 -0
- package/dist/extensions/pi-hermes-memory/tests/store/session-search.test.ts +287 -0
- package/dist/extensions/pi-hermes-memory/tests/store/skill-store.test.ts +678 -0
- package/dist/extensions/pi-hermes-memory/tests/store/skill-utils.test.ts +21 -0
- package/dist/extensions/pi-hermes-memory/tests/store/sqlite-lazy-load.test.ts +78 -0
- package/dist/extensions/pi-hermes-memory/tests/store/sqlite-memory-store.test.ts +543 -0
- package/dist/extensions/pi-hermes-memory/tests/store/sqlite-native.test.ts +164 -0
- package/dist/extensions/pi-hermes-memory/tests/store/standing-instructions.test.ts +171 -0
- package/dist/extensions/pi-hermes-memory/tests/tools/memory-search-tool.test.ts +84 -0
- package/dist/extensions/pi-hermes-memory/tests/tools/memory-tool.test.ts +709 -0
- package/dist/extensions/pi-hermes-memory/tests/tools/session-search-tool.test.ts +345 -0
- package/dist/extensions/pi-hermes-memory/tests/tools/shared-output-view.test.ts +362 -0
- package/dist/extensions/pi-hermes-memory/tests/tools/skill-tool.test.ts +478 -0
- package/dist/extensions/pi-hermes-memory/tests/tools/tool-result-renderer-wiring.test.ts +40 -0
- package/dist/extensions/pi-hermes-memory/tsconfig.json +19 -0
- package/dist/extensions/pi-subagents/.oxlintrc.json +43 -0
- package/dist/extensions/pi-subagents/AGENTS.md +5 -0
- package/dist/extensions/pi-subagents/CHANGELOG.md +157 -0
- package/dist/extensions/pi-subagents/README.md +12 -6
- package/dist/extensions/pi-subagents/VISION.md +81 -0
- package/dist/extensions/pi-subagents/agents/claude-code-writer.md +15 -0
- package/dist/extensions/pi-subagents/agents/claude-code.md +15 -0
- package/dist/extensions/pi-subagents/agents/codex-exec-writer.md +15 -0
- package/dist/extensions/pi-subagents/agents/codex-exec.md +15 -0
- package/dist/extensions/pi-subagents/agents/cursor-agent-writer.md +14 -0
- package/dist/extensions/pi-subagents/agents/cursor-agent.md +14 -0
- package/dist/extensions/pi-subagents/agents/reviewer.md +1 -3
- package/dist/extensions/pi-subagents/docs/agents.md +488 -0
- package/dist/extensions/pi-subagents/docs/configuration.md +46 -9
- package/dist/extensions/pi-subagents/docs/extension-api.md +42 -3
- package/dist/extensions/pi-subagents/docs/models.md +27 -4
- package/dist/extensions/pi-subagents/docs/observability.md +10 -9
- package/dist/extensions/pi-subagents/docs/tool-reference.md +102 -6
- package/dist/extensions/pi-subagents/docs/workflows.md +135 -0
- package/dist/extensions/pi-subagents/package-lock.json +389 -2
- package/dist/extensions/pi-subagents/package.json +3 -1
- package/dist/extensions/pi-subagents/prompts/review-loop.md +2 -2
- package/dist/extensions/pi-subagents/skills/council-mode/SKILL.md +1 -1
- package/dist/extensions/pi-subagents/skills/pi-subagents/SKILL.md +3 -1
- package/dist/extensions/pi-subagents/skills/pi-subagents/references/execution-controls.md +7 -3
- package/dist/extensions/pi-subagents/skills/pi-subagents/references/management-authoring-rpc.md +2 -2
- package/dist/extensions/pi-subagents/skills/pi-subagents/references/prompting-and-roles.md +26 -5
- package/dist/extensions/pi-subagents/src/agents/agent-management.ts +41 -22
- package/dist/extensions/pi-subagents/src/agents/agent-refinements.ts +4 -4
- package/dist/extensions/pi-subagents/src/agents/agent-serializer.ts +9 -2
- package/dist/extensions/pi-subagents/src/agents/agents.ts +322 -75
- package/dist/extensions/pi-subagents/src/agents/builtin-names.ts +6 -0
- package/dist/extensions/pi-subagents/src/agents/runtime-agent-events.ts +70 -0
- package/dist/extensions/pi-subagents/src/agents/runtime-agent-registry.ts +27 -20
- package/dist/extensions/pi-subagents/src/api/agents.ts +10 -5
- package/dist/extensions/pi-subagents/src/api/background-work.ts +5 -1
- package/dist/extensions/pi-subagents/src/api/delegation.ts +0 -7
- package/dist/extensions/pi-subagents/src/api/preflight.ts +34 -12
- package/dist/extensions/pi-subagents/src/extension/config.ts +70 -0
- package/dist/extensions/pi-subagents/src/extension/doctor.ts +3 -3
- package/dist/extensions/pi-subagents/src/extension/fanout-child.ts +5 -3
- package/dist/extensions/pi-subagents/src/extension/index.ts +83 -30
- package/dist/extensions/pi-subagents/src/extension/public-execution.ts +44 -15
- package/dist/extensions/pi-subagents/src/extension/rpc.ts +55 -19
- package/dist/extensions/pi-subagents/src/extension/schemas.ts +41 -15
- package/dist/extensions/pi-subagents/src/extension/tool-description.ts +28 -31
- package/dist/extensions/pi-subagents/src/inspectors/herdr/actions.ts +2 -1
- package/dist/extensions/pi-subagents/src/inspectors/herdr/inspector-runner.ts +2 -10
- package/dist/extensions/pi-subagents/src/inspectors/herdr/session-roots-codec.ts +42 -0
- package/dist/extensions/pi-subagents/src/integrations/herdr-status.ts +51 -3
- package/dist/extensions/pi-subagents/src/intercom/result-intercom.ts +2 -0
- package/dist/extensions/pi-subagents/src/profiles/profiles.ts +5 -6
- package/dist/extensions/pi-subagents/src/runs/background/active-async-capacity.ts +77 -10
- package/dist/extensions/pi-subagents/src/runs/background/async-execution.ts +176 -53
- package/dist/extensions/pi-subagents/src/runs/background/async-job-tracker.ts +19 -12
- package/dist/extensions/pi-subagents/src/runs/background/async-resume.ts +29 -27
- package/dist/extensions/pi-subagents/src/runs/background/async-retention.ts +20 -3
- package/dist/extensions/pi-subagents/src/runs/background/async-status-snapshot.ts +23 -261
- package/dist/extensions/pi-subagents/src/runs/background/async-status.ts +73 -7
- package/dist/extensions/pi-subagents/src/runs/background/chain-append.ts +8 -3
- package/dist/extensions/pi-subagents/src/runs/background/chain-root-attachment.ts +74 -8
- package/dist/extensions/pi-subagents/src/runs/background/fleet-view.ts +37 -21
- package/dist/extensions/pi-subagents/src/runs/background/inspect-rpc.ts +8 -8
- package/dist/extensions/pi-subagents/src/runs/background/notify.ts +184 -9
- package/dist/extensions/pi-subagents/src/runs/background/result-delivery-ownership.ts +45 -0
- package/dist/extensions/pi-subagents/src/runs/background/result-files.ts +28 -14
- package/dist/extensions/pi-subagents/src/runs/background/result-watcher.ts +38 -15
- package/dist/extensions/pi-subagents/src/runs/background/resume-guidance.ts +1 -1
- package/dist/extensions/pi-subagents/src/runs/background/retained-children.ts +1 -1
- package/dist/extensions/pi-subagents/src/runs/background/run-status.ts +50 -13
- package/dist/extensions/pi-subagents/src/runs/background/scheduled-runs.ts +93 -8
- package/dist/extensions/pi-subagents/src/runs/background/stale-run-reconciler.ts +10 -4
- package/dist/extensions/pi-subagents/src/runs/background/steering.ts +4 -14
- package/dist/extensions/pi-subagents/src/runs/background/subagent-runner.ts +549 -377
- package/dist/extensions/pi-subagents/src/runs/background/subagent-wait.ts +58 -10
- package/dist/extensions/pi-subagents/src/runs/background/terminal-run-index.ts +1 -1
- package/dist/extensions/pi-subagents/src/runs/background/wait-completions.ts +26 -1
- package/dist/extensions/pi-subagents/src/runs/background/wait-config.ts +23 -9
- package/dist/extensions/pi-subagents/src/runs/background/wait-tool.ts +9 -2
- package/dist/extensions/pi-subagents/src/runs/foreground/async-steering-action.ts +2 -2
- package/dist/extensions/pi-subagents/src/runs/foreground/execution.ts +204 -129
- package/dist/extensions/pi-subagents/src/runs/foreground/foreground-control.ts +9 -0
- package/dist/extensions/pi-subagents/src/runs/foreground/foreground-history.ts +23 -1
- package/dist/extensions/pi-subagents/src/runs/foreground/subagent-executor.ts +773 -311
- package/dist/extensions/pi-subagents/src/runs/foreground/workflow-detach-reconcile.ts +140 -115
- package/dist/extensions/pi-subagents/src/runs/shared/abort-recovery.ts +119 -0
- package/dist/extensions/pi-subagents/src/runs/shared/async-status-projection.ts +463 -0
- package/dist/extensions/pi-subagents/src/runs/shared/child-identity.ts +19 -4
- package/dist/extensions/pi-subagents/src/runs/shared/child-launch-plan.ts +151 -0
- package/dist/extensions/pi-subagents/src/runs/shared/child-protocol.ts +21 -7
- package/dist/extensions/pi-subagents/src/runs/shared/claude-code-adapter.ts +129 -0
- package/dist/extensions/pi-subagents/src/runs/shared/codex-exec-adapter.ts +129 -0
- package/dist/extensions/pi-subagents/src/runs/shared/completion-evidence.ts +89 -0
- package/dist/extensions/pi-subagents/src/runs/shared/completion-guard.ts +5 -4
- package/dist/extensions/pi-subagents/src/runs/shared/cursor-agent-adapter.ts +114 -0
- package/dist/extensions/pi-subagents/src/runs/shared/dynamic-fanout.ts +3 -3
- package/dist/extensions/pi-subagents/src/runs/shared/external-cli-contract.ts +167 -0
- package/dist/extensions/pi-subagents/src/runs/shared/external-cli-preflight.ts +122 -0
- package/dist/extensions/pi-subagents/src/runs/shared/external-cli-runner.ts +348 -55
- package/dist/extensions/pi-subagents/src/runs/shared/fast-mode-extension.ts +5 -5
- package/dist/extensions/pi-subagents/src/runs/shared/host-step-status.ts +230 -0
- package/dist/extensions/pi-subagents/src/runs/shared/lane-metadata.ts +105 -0
- package/dist/extensions/pi-subagents/src/runs/shared/launch-cwd.ts +16 -0
- package/dist/extensions/pi-subagents/src/runs/shared/long-running-guard.ts +2 -1
- package/dist/extensions/pi-subagents/src/runs/shared/mcp-config-sources.ts +422 -0
- package/dist/extensions/pi-subagents/src/runs/shared/mcp-direct-tool-allowlist.ts +221 -158
- package/dist/extensions/pi-subagents/src/runs/shared/mcp-direct-tool-grant.ts +197 -0
- package/dist/extensions/pi-subagents/src/runs/shared/model-exclusions.ts +69 -7
- package/dist/extensions/pi-subagents/src/runs/shared/model-fallback.ts +47 -6
- package/dist/extensions/pi-subagents/src/runs/shared/mutation-evidence.ts +7 -2
- package/dist/extensions/pi-subagents/src/runs/shared/nested-events.ts +9 -3
- package/dist/extensions/pi-subagents/src/runs/shared/nested-render.ts +9 -5
- package/dist/extensions/pi-subagents/src/runs/shared/parallel-handoff.ts +419 -7
- package/dist/extensions/pi-subagents/src/runs/shared/parallel-utils.ts +15 -1
- package/dist/extensions/pi-subagents/src/runs/shared/pi-args.ts +81 -8
- package/dist/extensions/pi-subagents/src/runs/shared/process-signal.ts +13 -0
- package/dist/extensions/pi-subagents/src/runs/shared/run-history.ts +21 -1
- package/dist/extensions/pi-subagents/src/runs/shared/single-output.ts +44 -4
- package/dist/extensions/pi-subagents/src/runs/shared/subagent-prompt-runtime.ts +98 -13
- package/dist/extensions/pi-subagents/src/runs/shared/subagent-startup-retry.ts +1 -1
- package/dist/extensions/pi-subagents/src/runs/shared/worktree-cleanup-plan.ts +847 -0
- package/dist/extensions/pi-subagents/src/runs/shared/worktree.ts +18 -0
- package/dist/extensions/pi-subagents/src/shared/child-session-name.ts +46 -0
- package/dist/extensions/pi-subagents/src/shared/extension-context.ts +24 -0
- package/dist/extensions/pi-subagents/src/shared/fork-context.ts +21 -0
- package/dist/extensions/pi-subagents/src/shared/formatters.ts +18 -3
- package/dist/extensions/pi-subagents/src/shared/launch-contract.ts +5 -1
- package/dist/extensions/pi-subagents/src/shared/pruned-fork.ts +450 -0
- package/dist/extensions/pi-subagents/src/shared/session-file-trust.ts +19 -0
- package/dist/extensions/pi-subagents/src/shared/session-tokens.ts +14 -3
- package/dist/extensions/pi-subagents/src/shared/settings.ts +19 -105
- package/dist/extensions/pi-subagents/src/shared/shortcuts.ts +17 -0
- package/dist/extensions/pi-subagents/src/shared/types.ts +359 -40
- package/dist/extensions/pi-subagents/src/shared/utils.ts +41 -84
- package/dist/extensions/pi-subagents/src/shared/workflow-child-permit.ts +116 -0
- package/dist/extensions/pi-subagents/src/slash/delegation-adapters.ts +1 -9
- package/dist/extensions/pi-subagents/src/slash/delegation-request.ts +0 -4
- package/dist/extensions/pi-subagents/src/slash/slash-bridge.ts +1 -2
- package/dist/extensions/pi-subagents/src/slash/slash-commands.ts +378 -88
- package/dist/extensions/pi-subagents/src/slash/slash-live-state.ts +22 -11
- package/dist/extensions/pi-subagents/src/slash/subagents-admin.ts +3 -0
- package/dist/extensions/pi-subagents/src/tui/fleet-status.ts +147 -79
- package/dist/extensions/pi-subagents/src/tui/fleet-transcript.ts +11 -5
- package/dist/extensions/pi-subagents/src/tui/fleet.ts +39 -18
- package/dist/extensions/pi-subagents/src/tui/render.ts +369 -46
- package/dist/extensions/pi-subagents/src/watchdog/turn-delta.ts +1 -1
- package/dist/extensions/pi-subagents/src/workflows/chat-progress.ts +6 -3
- package/dist/extensions/pi-subagents/src/workflows/host-command.ts +230 -0
- package/dist/extensions/pi-subagents/src/workflows/scripted-workflow.ts +740 -58
- package/dist/extensions/pi-subagents/src/workflows/workflow-child-summary.ts +121 -0
- package/dist/extensions/pi-subagents/src/workflows/workflow-preflight.ts +270 -0
- package/dist/extensions/pi-subagents/src/workflows/workflow-receipt.ts +195 -6
- package/dist/extensions/pi-subagents/src/workflows/workflow-settlement.ts +246 -0
- package/dist/extensions/pi-subagents/test/e2e/real-session-subagent.test.ts +2 -2
- package/dist/extensions/pi-subagents/test/integration/async-execution.test.ts +1114 -260
- package/dist/extensions/pi-subagents/test/integration/async-job-tracker.test.ts +34 -0
- package/dist/extensions/pi-subagents/test/integration/async-status.test.ts +150 -3
- package/dist/extensions/pi-subagents/test/integration/claude-code-smoke.test.ts +52 -0
- package/dist/extensions/pi-subagents/test/integration/claude-code-writer-smoke.test.ts +55 -0
- package/dist/extensions/pi-subagents/test/integration/codex-exec-smoke.test.ts +53 -0
- package/dist/extensions/pi-subagents/test/integration/codex-exec-writer-smoke.test.ts +57 -0
- package/dist/extensions/pi-subagents/test/integration/cursor-agent-smoke.test.ts +59 -0
- package/dist/extensions/pi-subagents/test/integration/cursor-agent-writer-smoke.test.ts +62 -0
- package/dist/extensions/pi-subagents/test/integration/error-handling.test.ts +57 -1
- package/dist/extensions/pi-subagents/test/integration/external-cli-runner.test.ts +1 -0
- package/dist/extensions/pi-subagents/test/integration/fork-context-execution.test.ts +20 -0
- package/dist/extensions/pi-subagents/test/integration/intercom-result-delivery.test.ts +4 -3
- package/dist/extensions/pi-subagents/test/integration/orca-progress-tabs.test.ts +2 -2
- package/dist/extensions/pi-subagents/test/integration/render-fork-badge.test.ts +54 -4
- package/dist/extensions/pi-subagents/test/integration/render-widget.test.ts +482 -4
- package/dist/extensions/pi-subagents/test/integration/result-watcher.test.ts +157 -9
- package/dist/extensions/pi-subagents/test/integration/session-tokens.test.ts +5 -5
- package/dist/extensions/pi-subagents/test/integration/single-execution.test.ts +1508 -291
- package/dist/extensions/pi-subagents/test/integration/slash-commands.test.ts +423 -1
- package/dist/extensions/pi-subagents/test/integration/template-resolution.test.ts +8 -0
- package/dist/extensions/pi-subagents/test/support/cursor-smoke-inputs.ts +51 -0
- package/dist/extensions/pi-subagents/test/support/helpers.ts +2 -0
- package/dist/extensions/pi-subagents/test/support/mock-pi.ts +5 -5
- package/dist/extensions/pi-subagents/test/support/real-session-runner.ts +6 -6
- package/dist/extensions/pi-subagents/test/unit/abort-recovery.test.ts +152 -0
- package/dist/extensions/pi-subagents/test/unit/active-async-capacity.test.ts +48 -0
- package/dist/extensions/pi-subagents/test/unit/agent-frontmatter.test.ts +184 -6
- package/dist/extensions/pi-subagents/test/unit/agent-management.test.ts +66 -6
- package/dist/extensions/pi-subagents/test/unit/agent-overrides.test.ts +77 -0
- package/dist/extensions/pi-subagents/test/unit/agent-refinements.test.ts +18 -0
- package/dist/extensions/pi-subagents/test/unit/anti-slop-oxlint.test.ts +86 -0
- package/dist/extensions/pi-subagents/test/unit/artifacts.test.ts +1 -1
- package/dist/extensions/pi-subagents/test/unit/async-execution.test.ts +101 -1
- package/dist/extensions/pi-subagents/test/unit/async-interrupt-action.test.ts +121 -0
- package/dist/extensions/pi-subagents/test/unit/async-recovery-descriptor.test.ts +28 -0
- package/dist/extensions/pi-subagents/test/unit/async-resume.test.ts +14 -7
- package/dist/extensions/pi-subagents/test/unit/async-retention.test.ts +53 -0
- package/dist/extensions/pi-subagents/test/unit/async-status-projection.test.ts +190 -0
- package/dist/extensions/pi-subagents/test/unit/async-status-snapshot.test.ts +42 -0
- package/dist/extensions/pi-subagents/test/unit/auto-drain.test.ts +8 -4
- package/dist/extensions/pi-subagents/test/unit/background-work.test.ts +23 -0
- package/dist/extensions/pi-subagents/test/unit/chain-root-attachment.test.ts +78 -1
- package/dist/extensions/pi-subagents/test/unit/child-launch-plan.test.ts +94 -0
- package/dist/extensions/pi-subagents/test/unit/child-protocol.test.ts +21 -0
- package/dist/extensions/pi-subagents/test/unit/child-session-name.test.ts +50 -0
- package/dist/extensions/pi-subagents/test/unit/claude-code-adapter.test.ts +245 -0
- package/dist/extensions/pi-subagents/test/unit/codex-exec-adapter.test.ts +192 -0
- package/dist/extensions/pi-subagents/test/unit/compaction-resume.test.ts +27 -0
- package/dist/extensions/pi-subagents/test/unit/completion-evidence.test.ts +150 -0
- package/dist/extensions/pi-subagents/test/unit/completion-guard.test.ts +21 -0
- package/dist/extensions/pi-subagents/test/unit/cursor-agent-adapter.test.ts +259 -0
- package/dist/extensions/pi-subagents/test/unit/cursor-smoke-inputs.test.ts +77 -0
- package/dist/extensions/pi-subagents/test/unit/delegation-api.test.ts +1 -4
- package/dist/extensions/pi-subagents/test/unit/doctor.test.ts +1 -1
- package/dist/extensions/pi-subagents/test/unit/extension-context.test.ts +27 -0
- package/dist/extensions/pi-subagents/test/unit/external-cli-runner.test.ts +223 -0
- package/dist/extensions/pi-subagents/test/unit/fast-mode-extension.test.ts +27 -0
- package/dist/extensions/pi-subagents/test/unit/fleet-status.test.ts +208 -2
- package/dist/extensions/pi-subagents/test/unit/fleet-transcript.test.ts +29 -1
- package/dist/extensions/pi-subagents/test/unit/fleet.test.ts +110 -1
- package/dist/extensions/pi-subagents/test/unit/foreground-control.test.ts +10 -0
- package/dist/extensions/pi-subagents/test/unit/formatters.test.ts +14 -0
- package/dist/extensions/pi-subagents/test/unit/herdr-inspector.test.ts +18 -9
- package/dist/extensions/pi-subagents/test/unit/herdr-session-roots-codec.test.ts +63 -0
- package/dist/extensions/pi-subagents/test/unit/herdr-shell-command.test.ts +2 -2
- package/dist/extensions/pi-subagents/test/unit/herdr-status-bridge.test.ts +132 -0
- package/dist/extensions/pi-subagents/test/unit/host-command.test.ts +156 -0
- package/dist/extensions/pi-subagents/test/unit/host-step-status.test.ts +98 -0
- package/dist/extensions/pi-subagents/test/unit/index-child-registration.test.ts +178 -26
- package/dist/extensions/pi-subagents/test/unit/index-segment.test.ts +2 -1
- package/dist/extensions/pi-subagents/test/unit/inspect-rpc.test.ts +21 -0
- package/dist/extensions/pi-subagents/test/unit/mcp-direct-tool-grant.test.ts +138 -0
- package/dist/extensions/pi-subagents/test/unit/mission-lifecycle.test.ts +1 -1
- package/dist/extensions/pi-subagents/test/unit/model-exclusions.test.ts +91 -0
- package/dist/extensions/pi-subagents/test/unit/model-fallback.test.ts +72 -7
- package/dist/extensions/pi-subagents/test/unit/mutation-evidence.test.ts +21 -0
- package/dist/extensions/pi-subagents/test/unit/nested-control.test.ts +1 -0
- package/dist/extensions/pi-subagents/test/unit/nested-events.test.ts +20 -2
- package/dist/extensions/pi-subagents/test/unit/notify.test.ts +129 -1
- package/dist/extensions/pi-subagents/test/unit/orca-progress-tabs.test.ts +2 -2
- package/dist/extensions/pi-subagents/test/unit/package-manifest.test.ts +4 -0
- package/dist/extensions/pi-subagents/test/unit/parallel-handoff.test.ts +358 -0
- package/dist/extensions/pi-subagents/test/unit/pi-args.test.ts +574 -21
- package/dist/extensions/pi-subagents/test/unit/pi-coding-agent-dir.test.ts +109 -1
- package/dist/extensions/pi-subagents/test/unit/preflight.test.ts +71 -1
- package/dist/extensions/pi-subagents/test/unit/profiles.test.ts +7 -7
- package/dist/extensions/pi-subagents/test/unit/pruned-fork.test.ts +214 -0
- package/dist/extensions/pi-subagents/test/unit/public-execution.test.ts +29 -8
- package/dist/extensions/pi-subagents/test/unit/render-helpers.test.ts +53 -0
- package/dist/extensions/pi-subagents/test/unit/result-delivery-ownership.test.ts +34 -0
- package/dist/extensions/pi-subagents/test/unit/result-files.test.ts +101 -3
- package/dist/extensions/pi-subagents/test/unit/retained-children.test.ts +1 -0
- package/dist/extensions/pi-subagents/test/unit/rpc.test.ts +10 -8
- package/dist/extensions/pi-subagents/test/unit/run-fanout-budget.test.ts +71 -1
- package/dist/extensions/pi-subagents/test/unit/run-status.test.ts +41 -7
- package/dist/extensions/pi-subagents/test/unit/runtime-agent-registration.test.ts +136 -2
- package/dist/extensions/pi-subagents/test/unit/scheduled-runs.test.ts +137 -0
- package/dist/extensions/pi-subagents/test/unit/schemas.test.ts +33 -12
- package/dist/extensions/pi-subagents/test/unit/scripted-workflow.test.ts +504 -1
- package/dist/extensions/pi-subagents/test/unit/single-output.test.ts +8 -0
- package/dist/extensions/pi-subagents/test/unit/skills-fallback.test.ts +1 -1
- package/dist/extensions/pi-subagents/test/unit/stale-run-reconciler.test.ts +12 -1
- package/dist/extensions/pi-subagents/test/unit/steering-action.test.ts +3 -5
- package/dist/extensions/pi-subagents/test/unit/steering.test.ts +5 -6
- package/dist/extensions/pi-subagents/test/unit/subagent-action-recovery.test.ts +0 -8
- package/dist/extensions/pi-subagents/test/unit/subagent-guide.test.ts +5 -0
- package/dist/extensions/pi-subagents/test/unit/subagent-prompt-runtime.test.ts +157 -2
- package/dist/extensions/pi-subagents/test/unit/subagent-startup-retry.test.ts +1 -1
- package/dist/extensions/pi-subagents/test/unit/subagent-wait.test.ts +63 -4
- package/dist/extensions/pi-subagents/test/unit/tool-description.test.ts +27 -23
- package/dist/extensions/pi-subagents/test/unit/wait-completions.test.ts +44 -0
- package/dist/extensions/pi-subagents/test/unit/wait-subscriptions.test.ts +1 -0
- package/dist/extensions/pi-subagents/test/unit/widget-nested-render.test.ts +7 -5
- package/dist/extensions/pi-subagents/test/unit/workflow-chat-progress.test.ts +106 -0
- package/dist/extensions/pi-subagents/test/unit/workflow-detach-reconcile.test.ts +484 -9
- package/dist/extensions/pi-subagents/test/unit/workflow-launch-params.test.ts +38 -4
- package/dist/extensions/pi-subagents/test/unit/workflow-preflight.test.ts +155 -0
- package/dist/extensions/pi-subagents/test/unit/workflow-receipt.test.ts +318 -0
- package/dist/extensions/pi-subagents/test/unit/workflow-resume-hint.test.ts +365 -0
- package/dist/extensions/pi-subagents/test/unit/worktree-cleanup-plan.test.ts +349 -0
- package/dist/extensions/pi-subagents/test/unit/worktree.test.ts +21 -0
- package/dist/extensions/pi-subagents/test/unit/writer-budget-guidance.test.ts +2 -7
- package/dist/extensions/pi-subagents/tools/oxlint/anti-slop/effect/index.ts +13 -0
- package/dist/extensions/pi-subagents/tools/oxlint/anti-slop/effect/rules/no-service-constructor-imports.ts +52 -0
- package/dist/extensions/pi-subagents/tools/oxlint/anti-slop/index.ts +41 -0
- package/dist/extensions/pi-subagents/tools/oxlint/anti-slop/rules/no-chained-type-assertions.ts +77 -0
- package/dist/extensions/pi-subagents/tools/oxlint/anti-slop/rules/no-conditional-empty-object-spread.ts +49 -0
- package/dist/extensions/pi-subagents/tools/oxlint/anti-slop/rules/no-known-value-widening.ts +247 -0
- package/dist/extensions/pi-subagents/tools/oxlint/anti-slop/rules/no-module-mocking.ts +91 -0
- package/dist/extensions/pi-subagents/tools/oxlint/anti-slop/rules/no-object-parameters.ts +126 -0
- package/dist/extensions/pi-subagents/tools/oxlint/anti-slop/rules/no-reflect-apply.ts +28 -0
- package/dist/extensions/pi-subagents/tools/oxlint/anti-slop/rules/no-reflect-get.ts +28 -0
- package/dist/extensions/pi-subagents/tools/oxlint/anti-slop/rules/no-runtime-typeof.ts +67 -0
- package/dist/extensions/pi-subagents/tools/oxlint/anti-slop/rules/no-shape-in-symbol-names.ts +39 -0
- package/dist/extensions/pi-subagents/tools/oxlint/anti-slop/rules/no-unknown-parameters.ts +83 -0
- package/dist/extensions/pi-subagents/tools/oxlint/anti-slop/rules/no-unknown-returns.ts +115 -0
- package/dist/extensions/pi-subagents/tools/oxlint/anti-slop/rules/no-unknown-type-aliases.ts +72 -0
- package/dist/extensions/pi-subagents/tools/oxlint/anti-slop/rules/no-unsafe-dictionary-type.ts +134 -0
- package/dist/extensions/pi-subagents/tools/oxlint/anti-slop/rules/no-widen-then-assert.ts +366 -0
- package/dist/extensions/pi-subagents/tools/oxlint/anti-slop/rules/require-safety-comment-for-type-assertion.ts +62 -0
- package/dist/extensions/pi-subagents/tools/oxlint/anti-slop/shared/dictionary-types.ts +502 -0
- package/dist/extensions/pi-subagents/tools/oxlint/anti-slop/shared/lexical-type-parameters.ts +60 -0
- package/dist/extensions/pi-subagents/tools/oxlint/anti-slop/shared/reflect-method.ts +35 -0
- package/dist/extensions/pi-tool-display/tests/capabilities.test.ts +1 -1
- package/dist/extensions/pi-tool-display/tests/settings-inspector.test.ts +2 -2
- package/dist/extensions/pi-tool-display/tests/tool-overrides-mcp.test.ts +1 -1
- package/dist/modes/interactive/interactive-mode.js +7 -7
- package/dist/skills/workflow/SKILL.md +65 -50
- package/package.json +112 -110
- package/dist/extensions/enable-readonly-tools.test.ts +0 -63
- package/dist/extensions/enable-readonly-tools.ts +0 -24
- package/dist/extensions/model-prompt-injector/config.json +0 -16
- package/dist/extensions/model-prompt-injector/index.test.ts +0 -102
- package/dist/extensions/model-prompt-injector/index.ts +0 -148
- package/dist/extensions/pi-subagents/src/runs/shared/turn-budget.ts +0 -98
- package/dist/extensions/pi-subagents/test/unit/turn-budget.test.ts +0 -250
|
@@ -0,0 +1,1046 @@
|
|
|
1
|
+
import { describe, it, beforeEach, afterEach } from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
7
|
+
import Database from 'better-sqlite3';
|
|
8
|
+
import { DatabaseManager, SQLITE_BUSY_TIMEOUT_MS, SQLITE_WAL_AUTOCHECKPOINT_PAGES } from '../../src/store/db.js';
|
|
9
|
+
import { AtomicLockCoordinator } from '../../src/store/atomic-lock-coordinator.js';
|
|
10
|
+
|
|
11
|
+
describe('DatabaseManager', () => {
|
|
12
|
+
let tmpDir: string;
|
|
13
|
+
let dbManager: DatabaseManager;
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'db-test-'));
|
|
17
|
+
dbManager = new DatabaseManager(tmpDir);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
dbManager.close();
|
|
22
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
function assertQuickCheckOk(db: InstanceType<typeof Database>): void {
|
|
26
|
+
const rows = db.prepare('PRAGMA quick_check').all() as Array<Record<string, unknown>>;
|
|
27
|
+
assert.deepStrictEqual(rows.map((row) => Object.values(row)[0]), ['ok']);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function corruptSqliteError(): Error & { code: string } {
|
|
31
|
+
const err = new Error('SQLITE_CORRUPT: database disk image is malformed') as Error & { code: string };
|
|
32
|
+
err.code = 'SQLITE_CORRUPT';
|
|
33
|
+
return err;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function corruptRecoverableIndexPage(dbPath: string, indexName: string): void {
|
|
37
|
+
const db = new Database(dbPath);
|
|
38
|
+
const pageSize = db.pragma('page_size', { simple: true }) as number;
|
|
39
|
+
const row = db.prepare(`
|
|
40
|
+
SELECT pageno
|
|
41
|
+
FROM dbstat
|
|
42
|
+
WHERE name = ? AND pagetype IN ('internal', 'leaf')
|
|
43
|
+
ORDER BY pageno ASC
|
|
44
|
+
LIMIT 1
|
|
45
|
+
`).get(indexName) as { pageno: number } | undefined;
|
|
46
|
+
db.close();
|
|
47
|
+
|
|
48
|
+
assert.ok(row, `dbstat did not find index page for ${indexName}`);
|
|
49
|
+
assert.ok(row.pageno > 1, 'will not corrupt sqlite database header page');
|
|
50
|
+
|
|
51
|
+
const buffer = fs.readFileSync(dbPath);
|
|
52
|
+
const offset = (row.pageno - 1) * pageSize;
|
|
53
|
+
for (let i = 0; i < 16 && offset + i < buffer.length; i++) {
|
|
54
|
+
buffer[offset + i] ^= 0xff;
|
|
55
|
+
}
|
|
56
|
+
fs.writeFileSync(dbPath, buffer);
|
|
57
|
+
|
|
58
|
+
const checkDb = new Database(dbPath);
|
|
59
|
+
try {
|
|
60
|
+
const rows = checkDb.prepare('PRAGMA quick_check').all() as Array<Record<string, unknown>>;
|
|
61
|
+
const ok = rows.length === 1 && Object.values(rows[0])[0] === 'ok';
|
|
62
|
+
assert.equal(ok, false, 'test fixture must produce a quick_check failure');
|
|
63
|
+
assert.doesNotThrow(() => {
|
|
64
|
+
checkDb.prepare('SELECT COUNT(*) as count FROM sessions NOT INDEXED').get();
|
|
65
|
+
checkDb.prepare('SELECT COUNT(*) as count FROM messages NOT INDEXED').get();
|
|
66
|
+
checkDb.prepare('SELECT COUNT(*) as count FROM memories NOT INDEXED').get();
|
|
67
|
+
}, 'test fixture must leave core table scans readable');
|
|
68
|
+
} finally {
|
|
69
|
+
checkDb.close();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
describe('initialization', () => {
|
|
74
|
+
it('should create database file on first getDb() call', () => {
|
|
75
|
+
assert.strictEqual(dbManager.exists(), false);
|
|
76
|
+
const db = dbManager.getDb();
|
|
77
|
+
assert.ok(db);
|
|
78
|
+
assert.strictEqual(dbManager.exists(), true);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('should create sessions.db in the specified directory', () => {
|
|
82
|
+
dbManager.getDb();
|
|
83
|
+
const expectedPath = path.join(tmpDir, 'sessions.db');
|
|
84
|
+
assert.strictEqual(dbManager.getPath(), expectedPath);
|
|
85
|
+
assert.ok(fs.existsSync(expectedPath));
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should return same db instance on multiple getDb() calls', () => {
|
|
89
|
+
const db1 = dbManager.getDb();
|
|
90
|
+
const db2 = dbManager.getDb();
|
|
91
|
+
assert.strictEqual(db1, db2);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('waits for a concurrent writer instead of failing immediately', async () => {
|
|
95
|
+
const db = dbManager.getDb();
|
|
96
|
+
const child = spawn(process.execPath, [
|
|
97
|
+
'-e',
|
|
98
|
+
`const Database = require('better-sqlite3');
|
|
99
|
+
const db = new Database(process.argv[1]);
|
|
100
|
+
db.exec('BEGIN IMMEDIATE');
|
|
101
|
+
process.stdout.write('locked');
|
|
102
|
+
setTimeout(() => { db.exec('COMMIT'); db.close(); }, 100);`,
|
|
103
|
+
path.join(tmpDir, 'sessions.db'),
|
|
104
|
+
], { stdio: ['ignore', 'pipe', 'inherit'] });
|
|
105
|
+
|
|
106
|
+
const childExit = new Promise<number | null>((resolve, reject) => {
|
|
107
|
+
child.once('error', reject);
|
|
108
|
+
child.once('exit', (code) => resolve(code));
|
|
109
|
+
});
|
|
110
|
+
const childReady = new Promise<void>((resolve, reject) => {
|
|
111
|
+
const timer = setTimeout(() => reject(new Error('child did not lock database in time')), 5000);
|
|
112
|
+
child.once('error', (error) => {
|
|
113
|
+
clearTimeout(timer);
|
|
114
|
+
reject(error);
|
|
115
|
+
});
|
|
116
|
+
child.once('exit', (code) => {
|
|
117
|
+
clearTimeout(timer);
|
|
118
|
+
reject(new Error(`child exited before locking database (code ${code})`));
|
|
119
|
+
});
|
|
120
|
+
child.stdout?.once('data', () => {
|
|
121
|
+
clearTimeout(timer);
|
|
122
|
+
resolve();
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
await childReady;
|
|
127
|
+
db.prepare(`
|
|
128
|
+
INSERT INTO extension_metadata (key, value)
|
|
129
|
+
VALUES ('concurrent-writer', 'waited')
|
|
130
|
+
`).run();
|
|
131
|
+
await childExit;
|
|
132
|
+
|
|
133
|
+
const timeout = db.prepare('PRAGMA busy_timeout').get() as { timeout: number };
|
|
134
|
+
assert.strictEqual(timeout.timeout, SQLITE_BUSY_TIMEOUT_MS);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('should create parent directory if it does not exist', () => {
|
|
138
|
+
const nestedDir = path.join(tmpDir, 'nested', 'dir');
|
|
139
|
+
const manager = new DatabaseManager(nestedDir);
|
|
140
|
+
manager.getDb();
|
|
141
|
+
assert.ok(fs.existsSync(path.join(nestedDir, 'sessions.db')));
|
|
142
|
+
manager.close();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('defers database creation while an initialization guard is active', () => {
|
|
146
|
+
const guardedDir = path.join(tmpDir, 'guarded');
|
|
147
|
+
const manager = new DatabaseManager(guardedDir);
|
|
148
|
+
manager.setOpenGuard(() => {
|
|
149
|
+
throw new Error('legacy database migration pending');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
assert.throws(() => manager.getDb(), /legacy database migration pending/);
|
|
153
|
+
assert.equal(fs.existsSync(path.join(guardedDir, 'sessions.db')), false);
|
|
154
|
+
|
|
155
|
+
manager.setOpenGuard(null);
|
|
156
|
+
assert.ok(manager.getDb());
|
|
157
|
+
assert.equal(fs.existsSync(path.join(guardedDir, 'sessions.db')), true);
|
|
158
|
+
manager.close();
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe('schema', () => {
|
|
163
|
+
it('should create all required tables', () => {
|
|
164
|
+
const db = dbManager.getDb();
|
|
165
|
+
const tables = db.prepare(`
|
|
166
|
+
SELECT name FROM sqlite_master WHERE type='table' ORDER BY name
|
|
167
|
+
`).all() as { name: string }[];
|
|
168
|
+
|
|
169
|
+
const tableNames = tables.map(t => t.name);
|
|
170
|
+
assert.ok(tableNames.includes('sessions'), 'sessions table missing');
|
|
171
|
+
assert.ok(tableNames.includes('messages'), 'messages table missing');
|
|
172
|
+
assert.ok(tableNames.includes('memories'), 'memories table missing');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('should create FTS5 virtual tables', () => {
|
|
176
|
+
const db = dbManager.getDb();
|
|
177
|
+
const tables = db.prepare(`
|
|
178
|
+
SELECT name FROM sqlite_master WHERE type='table' AND name LIKE '%_fts%'
|
|
179
|
+
`).all() as { name: string }[];
|
|
180
|
+
|
|
181
|
+
const tableNames = tables.map(t => t.name);
|
|
182
|
+
assert.ok(tableNames.includes('message_fts'), 'message_fts table missing');
|
|
183
|
+
assert.ok(tableNames.includes('memory_fts'), 'memory_fts table missing');
|
|
184
|
+
});
|
|
185
|
+
it('rebuilds existing unicode61 FTS tables and preserves indexed data', () => {
|
|
186
|
+
const db = dbManager.getDb();
|
|
187
|
+
db.prepare(`
|
|
188
|
+
INSERT INTO sessions (id, project, cwd, started_at)
|
|
189
|
+
VALUES (?, ?, ?, ?)
|
|
190
|
+
`).run('cjk-session', 'test-project', '/tmp/test-project', '2026-05-03T00:00:00Z');
|
|
191
|
+
db.prepare(`
|
|
192
|
+
INSERT INTO messages (id, session_id, role, content, timestamp)
|
|
193
|
+
VALUES (?, ?, ?, ?, ?)
|
|
194
|
+
`).run(
|
|
195
|
+
'cjk-message',
|
|
196
|
+
'cjk-session',
|
|
197
|
+
'assistant',
|
|
198
|
+
'设备清单包含 NAS',
|
|
199
|
+
'2026-05-03T00:01:00Z',
|
|
200
|
+
);
|
|
201
|
+
db.prepare(`
|
|
202
|
+
INSERT INTO memories (project, target, content, created, last_referenced)
|
|
203
|
+
VALUES (?, ?, ?, ?, ?)
|
|
204
|
+
`).run(
|
|
205
|
+
'test-project',
|
|
206
|
+
'memory',
|
|
207
|
+
'设备清单包含 NAS',
|
|
208
|
+
'2026-05-03',
|
|
209
|
+
'2026-05-03',
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
db.exec(`
|
|
213
|
+
DROP TABLE message_fts;
|
|
214
|
+
DROP TABLE memory_fts;
|
|
215
|
+
CREATE VIRTUAL TABLE message_fts USING fts5(
|
|
216
|
+
content,
|
|
217
|
+
content='messages',
|
|
218
|
+
content_rowid='rowid'
|
|
219
|
+
);
|
|
220
|
+
CREATE VIRTUAL TABLE memory_fts USING fts5(
|
|
221
|
+
content,
|
|
222
|
+
content='memories',
|
|
223
|
+
content_rowid='id'
|
|
224
|
+
);
|
|
225
|
+
INSERT INTO message_fts(message_fts) VALUES ('rebuild');
|
|
226
|
+
INSERT INTO memory_fts(memory_fts) VALUES ('rebuild');
|
|
227
|
+
DELETE FROM extension_metadata WHERE key = 'fts5_tokenizer_version';
|
|
228
|
+
`);
|
|
229
|
+
dbManager.close();
|
|
230
|
+
|
|
231
|
+
dbManager = new DatabaseManager(tmpDir);
|
|
232
|
+
const migrated = dbManager.getDb();
|
|
233
|
+
const tableSql = migrated.prepare(`
|
|
234
|
+
SELECT name, sql
|
|
235
|
+
FROM sqlite_master
|
|
236
|
+
WHERE type = 'table' AND name IN ('message_fts', 'memory_fts')
|
|
237
|
+
ORDER BY name
|
|
238
|
+
`).all() as Array<{ name: string; sql: string }>;
|
|
239
|
+
|
|
240
|
+
assert.strictEqual(tableSql.length, 2);
|
|
241
|
+
assert.ok(tableSql.every((table) => table.sql.includes("tokenize='trigram'")));
|
|
242
|
+
assert.deepStrictEqual(
|
|
243
|
+
migrated.prepare('SELECT value FROM extension_metadata WHERE key = ?').get('fts5_tokenizer_version'),
|
|
244
|
+
{ value: 'trigram-v1' },
|
|
245
|
+
);
|
|
246
|
+
assert.ok(
|
|
247
|
+
migrated.prepare('SELECT rowid FROM message_fts WHERE message_fts MATCH ?').all('设备清单').length > 0,
|
|
248
|
+
);
|
|
249
|
+
assert.ok(
|
|
250
|
+
migrated.prepare('SELECT rowid FROM memory_fts WHERE memory_fts MATCH ?').all('设备清单').length > 0,
|
|
251
|
+
);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('waits for a concurrent tokenizer migration and rechecks its result under the write lock', () => {
|
|
255
|
+
let beginAttempts = 0;
|
|
256
|
+
let commits = 0;
|
|
257
|
+
let rebuildAttempted = false;
|
|
258
|
+
const fakeDb = {
|
|
259
|
+
prepare: (sql: string) => ({
|
|
260
|
+
get: () => {
|
|
261
|
+
if (sql.includes('extension_metadata')) {
|
|
262
|
+
return beginAttempts >= 2 ? { value: 'trigram-v1' } : undefined;
|
|
263
|
+
}
|
|
264
|
+
if (sql.includes('sqlite_master')) {
|
|
265
|
+
return { sql: "CREATE VIRTUAL TABLE x USING fts5(content, tokenize='trigram')" };
|
|
266
|
+
}
|
|
267
|
+
return undefined;
|
|
268
|
+
},
|
|
269
|
+
run: () => undefined,
|
|
270
|
+
all: () => [],
|
|
271
|
+
}),
|
|
272
|
+
exec: (sql: string) => {
|
|
273
|
+
if (sql === 'BEGIN IMMEDIATE') {
|
|
274
|
+
beginAttempts++;
|
|
275
|
+
if (beginAttempts === 1) {
|
|
276
|
+
throw Object.assign(new Error('database is busy'), { code: 'SQLITE_BUSY' });
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
if (sql.includes('DROP TABLE')) rebuildAttempted = true;
|
|
280
|
+
if (sql === 'COMMIT') commits++;
|
|
281
|
+
},
|
|
282
|
+
close: () => undefined,
|
|
283
|
+
};
|
|
284
|
+
const internalManager = dbManager as unknown as {
|
|
285
|
+
migrateFtsTokenizer(db: typeof fakeDb): void;
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
internalManager.migrateFtsTokenizer(fakeDb);
|
|
289
|
+
|
|
290
|
+
assert.strictEqual(beginAttempts, 2);
|
|
291
|
+
assert.strictEqual(commits, 1);
|
|
292
|
+
assert.strictEqual(rebuildAttempted, false);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('fails startup with an actionable error when the tokenizer migration lock remains held', () => {
|
|
296
|
+
let beginAttempts = 0;
|
|
297
|
+
const fakeDb = {
|
|
298
|
+
prepare: (sql: string) => ({
|
|
299
|
+
get: () => {
|
|
300
|
+
if (sql.includes('extension_metadata')) return undefined;
|
|
301
|
+
if (sql.includes('sqlite_master')) return { sql: "CREATE VIRTUAL TABLE x USING fts5(content)" };
|
|
302
|
+
return undefined;
|
|
303
|
+
},
|
|
304
|
+
run: () => undefined,
|
|
305
|
+
all: () => [],
|
|
306
|
+
}),
|
|
307
|
+
exec: (sql: string) => {
|
|
308
|
+
if (sql === 'BEGIN IMMEDIATE') {
|
|
309
|
+
beginAttempts++;
|
|
310
|
+
throw Object.assign(new Error('database is busy'), { code: 'SQLITE_BUSY' });
|
|
311
|
+
}
|
|
312
|
+
},
|
|
313
|
+
close: () => undefined,
|
|
314
|
+
};
|
|
315
|
+
const internalManager = dbManager as unknown as {
|
|
316
|
+
migrateFtsTokenizer(db: typeof fakeDb): void;
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
assert.throws(
|
|
320
|
+
() => internalManager.migrateFtsTokenizer(fakeDb),
|
|
321
|
+
/Timed out waiting for the FTS tokenizer migration lock/i,
|
|
322
|
+
);
|
|
323
|
+
assert.strictEqual(beginAttempts, 3);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
it('should create triggers for FTS sync', () => {
|
|
328
|
+
const db = dbManager.getDb();
|
|
329
|
+
const triggers = db.prepare(`
|
|
330
|
+
SELECT name FROM sqlite_master WHERE type='trigger'
|
|
331
|
+
`).all() as { name: string }[];
|
|
332
|
+
|
|
333
|
+
const triggerNames = triggers.map(t => t.name);
|
|
334
|
+
assert.ok(triggerNames.includes('messages_ai'), 'messages_ai trigger missing');
|
|
335
|
+
assert.ok(triggerNames.includes('messages_ad'), 'messages_ad trigger missing');
|
|
336
|
+
assert.ok(triggerNames.includes('messages_au'), 'messages_au trigger missing');
|
|
337
|
+
assert.ok(triggerNames.includes('memories_ai'), 'memories_ai trigger missing');
|
|
338
|
+
assert.ok(triggerNames.includes('memories_ad'), 'memories_ad trigger missing');
|
|
339
|
+
assert.ok(triggerNames.includes('memories_au'), 'memories_au trigger missing');
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it('should be idempotent — running schema twice does not error', () => {
|
|
343
|
+
const db = dbManager.getDb();
|
|
344
|
+
// The schema uses IF NOT EXISTS, so running it again should be safe
|
|
345
|
+
assert.doesNotThrow(() => {
|
|
346
|
+
dbManager.close();
|
|
347
|
+
dbManager = new DatabaseManager(tmpDir);
|
|
348
|
+
dbManager.getDb();
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it('should migrate legacy memories table without category column', () => {
|
|
353
|
+
const dbPath = path.join(tmpDir, 'sessions.db');
|
|
354
|
+
const legacyDb = new Database(dbPath);
|
|
355
|
+
|
|
356
|
+
legacyDb.exec(`
|
|
357
|
+
CREATE TABLE memories (
|
|
358
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
359
|
+
project TEXT,
|
|
360
|
+
target TEXT NOT NULL CHECK (target IN ('memory', 'user')),
|
|
361
|
+
content TEXT NOT NULL,
|
|
362
|
+
created DATE NOT NULL,
|
|
363
|
+
last_referenced DATE NOT NULL
|
|
364
|
+
);
|
|
365
|
+
`);
|
|
366
|
+
legacyDb.close();
|
|
367
|
+
|
|
368
|
+
const migratedManager = new DatabaseManager(tmpDir);
|
|
369
|
+
const migratedDb = migratedManager.getDb();
|
|
370
|
+
const columns = migratedDb.prepare('PRAGMA table_info(memories)').all() as { name: string }[];
|
|
371
|
+
const names = columns.map((c) => c.name);
|
|
372
|
+
|
|
373
|
+
assert.ok(names.includes('category'));
|
|
374
|
+
assert.ok(names.includes('failure_reason'));
|
|
375
|
+
assert.ok(names.includes('tool_state'));
|
|
376
|
+
assert.ok(names.includes('corrected_to'));
|
|
377
|
+
|
|
378
|
+
migratedManager.close();
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it('should migrate legacy sessions table without project column', () => {
|
|
382
|
+
const dbPath = path.join(tmpDir, 'sessions.db');
|
|
383
|
+
const legacyDb = new Database(dbPath);
|
|
384
|
+
|
|
385
|
+
legacyDb.exec(`
|
|
386
|
+
CREATE TABLE sessions (
|
|
387
|
+
id TEXT PRIMARY KEY,
|
|
388
|
+
cwd TEXT NOT NULL,
|
|
389
|
+
started_at TEXT NOT NULL,
|
|
390
|
+
ended_at TEXT,
|
|
391
|
+
message_count INTEGER DEFAULT 0
|
|
392
|
+
);
|
|
393
|
+
`);
|
|
394
|
+
legacyDb.prepare(`
|
|
395
|
+
INSERT INTO sessions (id, cwd, started_at)
|
|
396
|
+
VALUES (?, ?, ?)
|
|
397
|
+
`).run('legacy-session', '/work/my-app', '2026-05-03T00:00:00Z');
|
|
398
|
+
legacyDb.close();
|
|
399
|
+
|
|
400
|
+
const migratedManager = new DatabaseManager(tmpDir);
|
|
401
|
+
const migratedDb = migratedManager.getDb();
|
|
402
|
+
const columns = migratedDb.prepare('PRAGMA table_info(sessions)').all() as { name: string }[];
|
|
403
|
+
const names = columns.map((c) => c.name);
|
|
404
|
+
|
|
405
|
+
assert.ok(names.includes('project'));
|
|
406
|
+
|
|
407
|
+
const row = migratedDb.prepare('SELECT project FROM sessions WHERE id = ?').get('legacy-session') as { project: string };
|
|
408
|
+
assert.strictEqual(row.project, 'my-app');
|
|
409
|
+
|
|
410
|
+
assert.doesNotThrow(() => {
|
|
411
|
+
migratedDb.prepare(`
|
|
412
|
+
INSERT INTO sessions (id, project, cwd, started_at)
|
|
413
|
+
VALUES (?, ?, ?, ?)
|
|
414
|
+
`).run('new-session', 'new-project', '/work/new-project', '2026-05-04T00:00:00Z');
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
migratedManager.close();
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it('should migrate legacy memories table without project column', () => {
|
|
421
|
+
const dbPath = path.join(tmpDir, 'sessions.db');
|
|
422
|
+
const legacyDb = new Database(dbPath);
|
|
423
|
+
|
|
424
|
+
legacyDb.exec(`
|
|
425
|
+
CREATE TABLE memories (
|
|
426
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
427
|
+
target TEXT NOT NULL CHECK (target IN ('memory', 'user')),
|
|
428
|
+
content TEXT NOT NULL,
|
|
429
|
+
created DATE NOT NULL,
|
|
430
|
+
last_referenced DATE NOT NULL
|
|
431
|
+
);
|
|
432
|
+
`);
|
|
433
|
+
legacyDb.prepare(`
|
|
434
|
+
INSERT INTO memories (target, content, created, last_referenced)
|
|
435
|
+
VALUES (?, ?, ?, ?)
|
|
436
|
+
`).run('memory', 'legacy memory entry', '2026-05-09', '2026-05-09');
|
|
437
|
+
legacyDb.close();
|
|
438
|
+
|
|
439
|
+
const migratedManager = new DatabaseManager(tmpDir);
|
|
440
|
+
const migratedDb = migratedManager.getDb();
|
|
441
|
+
const columns = migratedDb.prepare('PRAGMA table_info(memories)').all() as { name: string }[];
|
|
442
|
+
const names = columns.map((c) => c.name);
|
|
443
|
+
|
|
444
|
+
assert.ok(names.includes('project'));
|
|
445
|
+
|
|
446
|
+
const row = migratedDb.prepare('SELECT project, content FROM memories').get() as {
|
|
447
|
+
project: string | null;
|
|
448
|
+
content: string;
|
|
449
|
+
};
|
|
450
|
+
assert.strictEqual(row.project, null);
|
|
451
|
+
assert.strictEqual(row.content, 'legacy memory entry');
|
|
452
|
+
|
|
453
|
+
migratedManager.close();
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it('should migrate legacy target CHECK constraint to allow failure entries', () => {
|
|
457
|
+
const dbPath = path.join(tmpDir, 'sessions.db');
|
|
458
|
+
const legacyDb = new Database(dbPath);
|
|
459
|
+
|
|
460
|
+
legacyDb.exec(`
|
|
461
|
+
CREATE TABLE memories (
|
|
462
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
463
|
+
project TEXT,
|
|
464
|
+
target TEXT NOT NULL CHECK (target IN ('memory', 'user')),
|
|
465
|
+
category TEXT,
|
|
466
|
+
content TEXT NOT NULL,
|
|
467
|
+
failure_reason TEXT,
|
|
468
|
+
tool_state TEXT,
|
|
469
|
+
corrected_to TEXT,
|
|
470
|
+
created DATE NOT NULL,
|
|
471
|
+
last_referenced DATE NOT NULL
|
|
472
|
+
);
|
|
473
|
+
`);
|
|
474
|
+
legacyDb.prepare(`
|
|
475
|
+
INSERT INTO memories (project, target, category, content, failure_reason, tool_state, corrected_to, created, last_referenced)
|
|
476
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
477
|
+
`).run(null, 'memory', null, 'existing memory', null, null, null, '2026-05-09', '2026-05-09');
|
|
478
|
+
legacyDb.close();
|
|
479
|
+
|
|
480
|
+
const migratedManager = new DatabaseManager(tmpDir);
|
|
481
|
+
const migratedDb = migratedManager.getDb();
|
|
482
|
+
|
|
483
|
+
assert.doesNotThrow(() => {
|
|
484
|
+
migratedDb.prepare(`
|
|
485
|
+
INSERT INTO memories (project, target, category, content, failure_reason, tool_state, corrected_to, created, last_referenced)
|
|
486
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
487
|
+
`).run(null, 'failure', 'failure', 'failed setup', 'legacy check fixed', null, null, '2026-05-09', '2026-05-09');
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
const rows = migratedDb.prepare(`SELECT target, content FROM memories ORDER BY id ASC`).all() as Array<{ target: string; content: string }>;
|
|
491
|
+
assert.strictEqual(rows.length, 2);
|
|
492
|
+
assert.strictEqual(rows[0].content, 'existing memory');
|
|
493
|
+
assert.strictEqual(rows[1].target, 'failure');
|
|
494
|
+
|
|
495
|
+
const indexes = migratedDb.prepare(`
|
|
496
|
+
SELECT name FROM sqlite_master
|
|
497
|
+
WHERE type = 'index' AND name IN ('idx_memories_project', 'idx_memories_target', 'idx_memories_category')
|
|
498
|
+
`).all() as Array<{ name: string }>;
|
|
499
|
+
assert.deepStrictEqual(
|
|
500
|
+
indexes.map((row) => row.name).sort(),
|
|
501
|
+
['idx_memories_category', 'idx_memories_project', 'idx_memories_target'],
|
|
502
|
+
);
|
|
503
|
+
|
|
504
|
+
migratedManager.close();
|
|
505
|
+
});
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
describe('corruption recovery', () => {
|
|
509
|
+
it('waits for a recovery owner and reuses the healthy database it leaves behind', () => {
|
|
510
|
+
dbManager.getDb();
|
|
511
|
+
dbManager.close();
|
|
512
|
+
const canonicalDbPath = fs.realpathSync(path.join(tmpDir, 'sessions.db'));
|
|
513
|
+
const lockDbPath = path.join(path.dirname(canonicalDbPath), '.pi-hermes-locks.sqlite');
|
|
514
|
+
const lockKey = `recovery:${canonicalDbPath}`;
|
|
515
|
+
const coordinator = new AtomicLockCoordinator(lockDbPath);
|
|
516
|
+
const lease = coordinator.tryAcquire(lockKey, { staleMs: 10_000 });
|
|
517
|
+
assert.ok(lease);
|
|
518
|
+
spawn(process.execPath, [
|
|
519
|
+
'-e',
|
|
520
|
+
`setTimeout(() => {
|
|
521
|
+
const Database = require('better-sqlite3');
|
|
522
|
+
const db = new Database(process.argv[1]);
|
|
523
|
+
db.prepare('DELETE FROM locks WHERE lock_key = ? AND token = ?').run(process.argv[2], process.argv[3]);
|
|
524
|
+
db.close();
|
|
525
|
+
}, 100)`,
|
|
526
|
+
lockDbPath,
|
|
527
|
+
lockKey,
|
|
528
|
+
lease.token,
|
|
529
|
+
], { stdio: 'ignore' });
|
|
530
|
+
|
|
531
|
+
dbManager = new DatabaseManager(tmpDir, { recoveryLockWaitMs: 1000, recoveryLockPollMs: 10, recoveryLockStaleMs: 10_000 });
|
|
532
|
+
const started = Date.now();
|
|
533
|
+
const result = dbManager.recoverFromCorruption(corruptSqliteError());
|
|
534
|
+
|
|
535
|
+
assert.strictEqual(result.strategy, 'reused');
|
|
536
|
+
assert.ok(Date.now() - started >= 50, 'peer should wait for the active recovery owner');
|
|
537
|
+
assert.strictEqual(fs.readdirSync(tmpDir).filter((name) => name.startsWith('sessions.db.corrupt-')).length, 0);
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
it('takes over a stale recovery lock', () => {
|
|
541
|
+
dbManager.close();
|
|
542
|
+
fs.writeFileSync(path.join(tmpDir, 'sessions.db'), 'not a sqlite database');
|
|
543
|
+
const canonicalDbPath = fs.realpathSync(path.join(tmpDir, 'sessions.db'));
|
|
544
|
+
const lockDbPath = path.join(path.dirname(canonicalDbPath), '.pi-hermes-locks.sqlite');
|
|
545
|
+
const coordinator = new AtomicLockCoordinator(lockDbPath);
|
|
546
|
+
coordinator.tryAcquire('schema-init', { staleMs: 50 })!.release();
|
|
547
|
+
const lockDb = new Database(lockDbPath);
|
|
548
|
+
lockDb.prepare(`
|
|
549
|
+
INSERT INTO locks (lock_key, token, pid, acquired_at)
|
|
550
|
+
VALUES (?, 'dead-owner', 999999, ?)
|
|
551
|
+
`).run(`recovery:${canonicalDbPath}`, Date.now() - 10_000);
|
|
552
|
+
lockDb.close();
|
|
553
|
+
|
|
554
|
+
dbManager = new DatabaseManager(tmpDir, { recoveryLockStaleMs: 50 });
|
|
555
|
+
const db = dbManager.getDb();
|
|
556
|
+
|
|
557
|
+
assertQuickCheckOk(db as InstanceType<typeof Database>);
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
it('aborts destructive recovery rename when the recovery lease was stolen mid-flight', () => {
|
|
561
|
+
dbManager.close();
|
|
562
|
+
fs.writeFileSync(path.join(tmpDir, 'sessions.db'), 'not a sqlite database');
|
|
563
|
+
dbManager = new DatabaseManager(tmpDir, { recoveryLockStaleMs: 60_000 });
|
|
564
|
+
|
|
565
|
+
type MoveDatabaseFilesToBackup = (this: DatabaseManager, backupBase: string) => unknown;
|
|
566
|
+
const prototype = DatabaseManager.prototype as unknown as {
|
|
567
|
+
moveDatabaseFilesToBackup: MoveDatabaseFilesToBackup;
|
|
568
|
+
};
|
|
569
|
+
const originalMove = prototype.moveDatabaseFilesToBackup;
|
|
570
|
+
let moveCalls = 0;
|
|
571
|
+
prototype.moveDatabaseFilesToBackup = function (this: DatabaseManager, backupBase: string) {
|
|
572
|
+
moveCalls++;
|
|
573
|
+
if (moveCalls === 1) {
|
|
574
|
+
const canonicalDbPath = fs.realpathSync(path.join(tmpDir, 'sessions.db'));
|
|
575
|
+
const lockDbPath = path.join(path.dirname(canonicalDbPath), '.pi-hermes-locks.sqlite');
|
|
576
|
+
const lockKey = `recovery:${canonicalDbPath}`;
|
|
577
|
+
const lockDb = new Database(lockDbPath);
|
|
578
|
+
try {
|
|
579
|
+
lockDb.prepare('UPDATE locks SET acquired_at = ? WHERE lock_key = ?').run(Date.now() - 100_000, lockKey);
|
|
580
|
+
} finally {
|
|
581
|
+
lockDb.close();
|
|
582
|
+
}
|
|
583
|
+
const thief = new AtomicLockCoordinator(lockDbPath);
|
|
584
|
+
const stolen = thief.tryAcquire(lockKey, { staleMs: 50 });
|
|
585
|
+
assert.ok(stolen);
|
|
586
|
+
stolen.release();
|
|
587
|
+
}
|
|
588
|
+
return originalMove.call(this, backupBase);
|
|
589
|
+
};
|
|
590
|
+
|
|
591
|
+
try {
|
|
592
|
+
assert.throws(
|
|
593
|
+
() => dbManager.getDb(),
|
|
594
|
+
/SQLite recovery lease lost/,
|
|
595
|
+
);
|
|
596
|
+
assert.strictEqual(moveCalls, 1);
|
|
597
|
+
} finally {
|
|
598
|
+
prototype.moveDatabaseFilesToBackup = originalMove;
|
|
599
|
+
}
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
it('serializes recovery through symlinked database aliases', { skip: process.platform === 'win32' }, () => {
|
|
603
|
+
dbManager.close();
|
|
604
|
+
const realDir = path.join(tmpDir, 'real');
|
|
605
|
+
const aliasDir = path.join(tmpDir, 'alias');
|
|
606
|
+
fs.mkdirSync(realDir);
|
|
607
|
+
fs.symlinkSync(realDir, aliasDir, 'dir');
|
|
608
|
+
fs.writeFileSync(path.join(realDir, 'sessions.db'), 'not a sqlite database');
|
|
609
|
+
|
|
610
|
+
const canonicalDbPath = fs.realpathSync(path.join(realDir, 'sessions.db'));
|
|
611
|
+
const coordinator = new AtomicLockCoordinator(path.join(path.dirname(canonicalDbPath), '.pi-hermes-locks.sqlite'));
|
|
612
|
+
const lease = coordinator.tryAcquire(`recovery:${canonicalDbPath}`, { staleMs: 60_000 });
|
|
613
|
+
assert.ok(lease);
|
|
614
|
+
|
|
615
|
+
const aliasManager = new DatabaseManager(aliasDir, {
|
|
616
|
+
recoveryLockWaitMs: 25,
|
|
617
|
+
recoveryLockPollMs: 5,
|
|
618
|
+
recoveryLockStaleMs: 60_000,
|
|
619
|
+
});
|
|
620
|
+
try {
|
|
621
|
+
assert.throws(
|
|
622
|
+
() => aliasManager.getDb(),
|
|
623
|
+
/SQLite recovery already in progress/,
|
|
624
|
+
);
|
|
625
|
+
} finally {
|
|
626
|
+
aliasManager.close();
|
|
627
|
+
lease.release();
|
|
628
|
+
}
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
it('repairs a file-symlinked database target without replacing the link', { skip: process.platform === 'win32' }, () => {
|
|
632
|
+
dbManager.close();
|
|
633
|
+
const realDir = path.join(tmpDir, 'real');
|
|
634
|
+
const aliasDir = path.join(tmpDir, 'alias');
|
|
635
|
+
fs.mkdirSync(realDir);
|
|
636
|
+
fs.mkdirSync(aliasDir);
|
|
637
|
+
const realDbPath = path.join(realDir, 'sessions.db');
|
|
638
|
+
const aliasDbPath = path.join(aliasDir, 'sessions.db');
|
|
639
|
+
fs.writeFileSync(realDbPath, 'not a sqlite database');
|
|
640
|
+
fs.symlinkSync(realDbPath, aliasDbPath, 'file');
|
|
641
|
+
|
|
642
|
+
const aliasManager = new DatabaseManager(aliasDir);
|
|
643
|
+
const aliasDb = aliasManager.getDb();
|
|
644
|
+
aliasDb.prepare("INSERT INTO extension_metadata (key, value) VALUES ('alias-write', 'kept')").run();
|
|
645
|
+
aliasManager.close();
|
|
646
|
+
|
|
647
|
+
assert.equal(fs.lstatSync(aliasDbPath).isSymbolicLink(), true);
|
|
648
|
+
const directManager = new DatabaseManager(realDir);
|
|
649
|
+
try {
|
|
650
|
+
const directDb = directManager.getDb();
|
|
651
|
+
assertQuickCheckOk(directDb as InstanceType<typeof Database>);
|
|
652
|
+
assert.deepEqual(
|
|
653
|
+
directDb.prepare("SELECT value FROM extension_metadata WHERE key = 'alias-write'").get(),
|
|
654
|
+
{ value: 'kept' },
|
|
655
|
+
);
|
|
656
|
+
} finally {
|
|
657
|
+
directManager.close();
|
|
658
|
+
}
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
it('creates and repairs a dangling absolute database symlink through its target', { skip: process.platform === 'win32' }, () => {
|
|
662
|
+
dbManager.close();
|
|
663
|
+
const realDir = path.join(tmpDir, 'real');
|
|
664
|
+
const aliasDir = path.join(tmpDir, 'alias');
|
|
665
|
+
fs.mkdirSync(realDir);
|
|
666
|
+
fs.mkdirSync(aliasDir);
|
|
667
|
+
const realDbPath = path.join(realDir, 'sessions.db');
|
|
668
|
+
const aliasDbPath = path.join(aliasDir, 'sessions.db');
|
|
669
|
+
fs.symlinkSync(realDbPath, aliasDbPath, 'file');
|
|
670
|
+
|
|
671
|
+
const aliasManager = new DatabaseManager(aliasDir);
|
|
672
|
+
aliasManager.getDb().prepare(
|
|
673
|
+
"INSERT INTO extension_metadata (key, value) VALUES ('before-corruption', 'kept')",
|
|
674
|
+
).run();
|
|
675
|
+
aliasManager.close();
|
|
676
|
+
fs.writeFileSync(realDbPath, 'not a sqlite database');
|
|
677
|
+
|
|
678
|
+
try {
|
|
679
|
+
assertQuickCheckOk(aliasManager.getDb() as InstanceType<typeof Database>);
|
|
680
|
+
} finally {
|
|
681
|
+
aliasManager.close();
|
|
682
|
+
}
|
|
683
|
+
assert.equal(fs.lstatSync(aliasDbPath).isSymbolicLink(), true);
|
|
684
|
+
const directDb = new Database(realDbPath);
|
|
685
|
+
try {
|
|
686
|
+
assertQuickCheckOk(directDb);
|
|
687
|
+
} finally {
|
|
688
|
+
directDb.close();
|
|
689
|
+
}
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
it('rejects database symlink loops before opening SQLite', { skip: process.platform === 'win32' }, () => {
|
|
693
|
+
dbManager.close();
|
|
694
|
+
const loopDir = path.join(tmpDir, 'loop');
|
|
695
|
+
fs.mkdirSync(loopDir);
|
|
696
|
+
fs.symlinkSync('sessions.other', path.join(loopDir, 'sessions.db'), 'file');
|
|
697
|
+
fs.symlinkSync('sessions.db', path.join(loopDir, 'sessions.other'), 'file');
|
|
698
|
+
|
|
699
|
+
const manager = new DatabaseManager(loopDir);
|
|
700
|
+
assert.throws(() => manager.getDb(), /symbolic link loop/i);
|
|
701
|
+
manager.close();
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
it('cleans abandoned rebuild files and caps corrupt backup sets', () => {
|
|
705
|
+
dbManager.close();
|
|
706
|
+
for (let index = 0; index < 5; index++) {
|
|
707
|
+
fs.writeFileSync(path.join(tmpDir, `sessions.db.corrupt-20260701-${index}`), `backup-${index}`);
|
|
708
|
+
}
|
|
709
|
+
fs.writeFileSync(path.join(tmpDir, 'sessions.db.rebuild-abandoned.tmp'), 'abandoned');
|
|
710
|
+
fs.writeFileSync(path.join(tmpDir, 'sessions.db'), 'not a sqlite database');
|
|
711
|
+
|
|
712
|
+
dbManager = new DatabaseManager(tmpDir, { recoveryBackupRetention: 3 });
|
|
713
|
+
dbManager.getDb();
|
|
714
|
+
|
|
715
|
+
const names = fs.readdirSync(tmpDir);
|
|
716
|
+
assert.strictEqual(names.some((name) => name.startsWith('sessions.db.rebuild-')), false);
|
|
717
|
+
assert.ok(names.filter((name) => name.startsWith('sessions.db.corrupt-')).length <= 3);
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
it('does not count successful recreations toward the recovery circuit', () => {
|
|
721
|
+
dbManager.close();
|
|
722
|
+
const dbPath = path.join(tmpDir, 'sessions.db');
|
|
723
|
+
fs.writeFileSync(dbPath, 'first corrupt database');
|
|
724
|
+
dbManager = new DatabaseManager(tmpDir, {
|
|
725
|
+
recoveryCircuitLimit: 1,
|
|
726
|
+
recoveryCircuitWindowMs: 60_000,
|
|
727
|
+
});
|
|
728
|
+
dbManager.getDb();
|
|
729
|
+
dbManager.close();
|
|
730
|
+
|
|
731
|
+
fs.writeFileSync(dbPath, 'second corrupt database');
|
|
732
|
+
dbManager = new DatabaseManager(tmpDir, {
|
|
733
|
+
recoveryCircuitLimit: 1,
|
|
734
|
+
recoveryCircuitWindowMs: 60_000,
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
assert.doesNotThrow(() => dbManager.getDb());
|
|
738
|
+
assert.strictEqual(dbManager.getLastRecovery()?.strategy, 'recreated-empty');
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
it('keeps verified recovery successful when cleanup state removal fails', () => {
|
|
742
|
+
dbManager.close();
|
|
743
|
+
fs.writeFileSync(path.join(tmpDir, 'sessions.db'), 'corrupt database');
|
|
744
|
+
dbManager = new DatabaseManager(tmpDir);
|
|
745
|
+
let cleanupCalls = 0;
|
|
746
|
+
(dbManager as any).cleanupRecoveryArtifacts = () => {
|
|
747
|
+
cleanupCalls++;
|
|
748
|
+
if (cleanupCalls > 1) throw new Error('injected cleanup failure');
|
|
749
|
+
};
|
|
750
|
+
(dbManager as any).clearRecoveryFailures = () => {
|
|
751
|
+
throw new Error('injected circuit cleanup failure');
|
|
752
|
+
};
|
|
753
|
+
|
|
754
|
+
const db = dbManager.getDb();
|
|
755
|
+
|
|
756
|
+
assert.ok(db);
|
|
757
|
+
assert.strictEqual(dbManager.getLastRecovery()?.strategy, 'recreated-empty');
|
|
758
|
+
assertQuickCheckOk(db as InstanceType<typeof Database>);
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
it('keeps verified recovery successful and clears a failed release before retry', () => {
|
|
762
|
+
const prototype = AtomicLockCoordinator.prototype as any;
|
|
763
|
+
const originalDeleteOwnedLock = prototype.deleteOwnedLock;
|
|
764
|
+
let deleteAttempts = 0;
|
|
765
|
+
prototype.deleteOwnedLock = function (key: string, token: string): void {
|
|
766
|
+
deleteAttempts++;
|
|
767
|
+
if (deleteAttempts <= 3) throw new Error('injected recovery release failure');
|
|
768
|
+
return originalDeleteOwnedLock.call(this, key, token);
|
|
769
|
+
};
|
|
770
|
+
|
|
771
|
+
try {
|
|
772
|
+
dbManager.close();
|
|
773
|
+
const dbPath = path.join(tmpDir, 'sessions.db');
|
|
774
|
+
fs.writeFileSync(dbPath, 'first corrupt database');
|
|
775
|
+
dbManager = new DatabaseManager(tmpDir);
|
|
776
|
+
assert.doesNotThrow(() => dbManager.getDb());
|
|
777
|
+
assert.strictEqual(dbManager.getLastRecovery()?.strategy, 'recreated-empty');
|
|
778
|
+
|
|
779
|
+
dbManager.close();
|
|
780
|
+
fs.writeFileSync(dbPath, 'second corrupt database');
|
|
781
|
+
dbManager = new DatabaseManager(tmpDir);
|
|
782
|
+
assert.doesNotThrow(() => dbManager.getDb());
|
|
783
|
+
assert.strictEqual(dbManager.getLastRecovery()?.strategy, 'recreated-empty');
|
|
784
|
+
assert.ok(deleteAttempts >= 4);
|
|
785
|
+
} finally {
|
|
786
|
+
prototype.deleteOwnedLock = originalDeleteOwnedLock;
|
|
787
|
+
}
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
it('opens the recovery circuit after a failed recovery', () => {
|
|
791
|
+
dbManager.close();
|
|
792
|
+
fs.writeFileSync(path.join(tmpDir, 'sessions.db'), 'corrupt database');
|
|
793
|
+
dbManager = new DatabaseManager(tmpDir, {
|
|
794
|
+
recoveryCircuitLimit: 1,
|
|
795
|
+
recoveryCircuitWindowMs: 60_000,
|
|
796
|
+
});
|
|
797
|
+
let recoveryCalls = 0;
|
|
798
|
+
(dbManager as any).recoverDatabaseFileUnlocked = () => {
|
|
799
|
+
recoveryCalls++;
|
|
800
|
+
throw new Error('injected recovery failure');
|
|
801
|
+
};
|
|
802
|
+
|
|
803
|
+
assert.throws(() => dbManager.recoverFromCorruption(corruptSqliteError()), /injected recovery failure/);
|
|
804
|
+
assert.throws(() => dbManager.recoverFromCorruption(corruptSqliteError()), /recovery circuit is open/i);
|
|
805
|
+
assert.strictEqual(recoveryCalls, 1);
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
it('counts a failed post-recovery open before clearing the circuit', () => {
|
|
809
|
+
dbManager.close();
|
|
810
|
+
fs.writeFileSync(path.join(tmpDir, 'sessions.db'), 'corrupt database');
|
|
811
|
+
dbManager = new DatabaseManager(tmpDir, {
|
|
812
|
+
recoveryCircuitLimit: 1,
|
|
813
|
+
recoveryCircuitWindowMs: 60_000,
|
|
814
|
+
});
|
|
815
|
+
const originalOpenUnchecked = (dbManager as any).openUnchecked.bind(dbManager);
|
|
816
|
+
let openCalls = 0;
|
|
817
|
+
(dbManager as any).openUnchecked = () => {
|
|
818
|
+
openCalls++;
|
|
819
|
+
if (openCalls === 2) throw new Error('injected post-recovery open failure');
|
|
820
|
+
return originalOpenUnchecked();
|
|
821
|
+
};
|
|
822
|
+
|
|
823
|
+
assert.throws(() => dbManager.getDb(), /injected post-recovery open failure/);
|
|
824
|
+
const state = JSON.parse(
|
|
825
|
+
fs.readFileSync(path.join(tmpDir, 'sessions.db.recovery-state.json'), 'utf-8'),
|
|
826
|
+
);
|
|
827
|
+
assert.strictEqual(state.failures.length, 1);
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
it('does not treat legacy recovery attempt state as failed recoveries', () => {
|
|
831
|
+
dbManager.close();
|
|
832
|
+
fs.writeFileSync(path.join(tmpDir, 'sessions.db'), 'corrupt database');
|
|
833
|
+
fs.writeFileSync(
|
|
834
|
+
path.join(tmpDir, 'sessions.db.recovery-state.json'),
|
|
835
|
+
JSON.stringify({ attempts: [Date.now()] }),
|
|
836
|
+
);
|
|
837
|
+
dbManager = new DatabaseManager(tmpDir, {
|
|
838
|
+
recoveryCircuitLimit: 1,
|
|
839
|
+
recoveryCircuitWindowMs: 60_000,
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
assert.doesNotThrow(() => dbManager.getDb());
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
it('repairs recoverable corruption on open and preserves readable rows', async () => {
|
|
846
|
+
const db = dbManager.getDb();
|
|
847
|
+
db.prepare(`
|
|
848
|
+
INSERT INTO sessions (id, project, cwd, started_at)
|
|
849
|
+
VALUES (?, ?, ?, ?)
|
|
850
|
+
`).run('recover-session', 'recover-project', '/work/recover', '2026-05-03T00:00:00Z');
|
|
851
|
+
|
|
852
|
+
const insertMessage = db.prepare(`
|
|
853
|
+
INSERT INTO messages (id, session_id, role, content, timestamp)
|
|
854
|
+
VALUES (?, ?, ?, ?, ?)
|
|
855
|
+
`);
|
|
856
|
+
for (let i = 0; i < 50; i++) {
|
|
857
|
+
insertMessage.run(`recover-msg-${i}`, 'recover-session', i % 2 === 0 ? 'user' : 'assistant', `message ${i}`, `2026-05-03T00:${String(i).padStart(2, '0')}:00Z`);
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
db.prepare(`
|
|
861
|
+
INSERT INTO memories (project, target, content, created, last_referenced)
|
|
862
|
+
VALUES (?, ?, ?, ?, ?)
|
|
863
|
+
`).run(null, 'memory', 'recoverable memory', '2026-05-03', '2026-05-03');
|
|
864
|
+
dbManager.close();
|
|
865
|
+
|
|
866
|
+
corruptRecoverableIndexPage(path.join(tmpDir, 'sessions.db'), 'idx_messages_timestamp');
|
|
867
|
+
|
|
868
|
+
dbManager = new DatabaseManager(tmpDir);
|
|
869
|
+
assert.doesNotThrow(() => dbManager.getDb());
|
|
870
|
+
await dbManager.waitForStartupIntegrityScan();
|
|
871
|
+
const repairedDb = dbManager.getDb();
|
|
872
|
+
|
|
873
|
+
assert.strictEqual(dbManager.getLastRecovery()?.strategy, 'rebuilt');
|
|
874
|
+
assert.deepStrictEqual(dbManager.getLastRecovery()?.recoveredRows, {
|
|
875
|
+
extension_metadata: 1,
|
|
876
|
+
sessions: 1,
|
|
877
|
+
messages: 50,
|
|
878
|
+
session_files: 0,
|
|
879
|
+
memories: 1,
|
|
880
|
+
});
|
|
881
|
+
assert.deepStrictEqual(dbManager.getStats(), { sessions: 1, messages: 50, memories: 1 });
|
|
882
|
+
const memory = repairedDb.prepare('SELECT content FROM memories WHERE content = ?').get('recoverable memory') as { content: string } | undefined;
|
|
883
|
+
assert.ok(memory);
|
|
884
|
+
assertQuickCheckOk(repairedDb as InstanceType<typeof Database>);
|
|
885
|
+
assert.ok(fs.readdirSync(tmpDir).some((name) => name.startsWith('sessions.db.corrupt-')), 'corrupt DB should be quarantined');
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
it('reopened manager runs a fresh integrity scan after close()', async () => {
|
|
889
|
+
const db = dbManager.getDb();
|
|
890
|
+
db.prepare(`
|
|
891
|
+
INSERT INTO sessions (id, project, cwd, started_at)
|
|
892
|
+
VALUES (?, ?, ?, ?)
|
|
893
|
+
`).run('reopen-session', 'reopen-project', '/work/reopen', '2026-05-03T00:00:00Z');
|
|
894
|
+
dbManager.close();
|
|
895
|
+
|
|
896
|
+
corruptRecoverableIndexPage(path.join(tmpDir, 'sessions.db'), 'idx_messages_timestamp');
|
|
897
|
+
|
|
898
|
+
const reopenedDb = dbManager.getDb();
|
|
899
|
+
await dbManager.waitForStartupIntegrityScan();
|
|
900
|
+
const recoveredDb = dbManager.getDb();
|
|
901
|
+
|
|
902
|
+
assert.strictEqual(dbManager.getLastRecovery()?.strategy, 'rebuilt');
|
|
903
|
+
assert.deepStrictEqual(dbManager.getStats(), { sessions: 1, messages: 0, memories: 0 });
|
|
904
|
+
assertQuickCheckOk(recoveredDb as InstanceType<typeof Database>);
|
|
905
|
+
assert.ok(fs.readdirSync(tmpDir).some((name) => name.startsWith('sessions.db.corrupt-')), 'corrupt DB should be quarantined');
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
it('quarantines unrecoverable files and recreates an empty database', () => {
|
|
909
|
+
dbManager.close();
|
|
910
|
+
const dbPath = path.join(tmpDir, 'sessions.db');
|
|
911
|
+
fs.writeFileSync(dbPath, 'not a sqlite database');
|
|
912
|
+
|
|
913
|
+
dbManager = new DatabaseManager(tmpDir);
|
|
914
|
+
const db = dbManager.getDb();
|
|
915
|
+
|
|
916
|
+
assert.strictEqual(dbManager.getLastRecovery()?.strategy, 'recreated-empty');
|
|
917
|
+
assert.deepStrictEqual(dbManager.getStats(), { sessions: 0, messages: 0, memories: 0 });
|
|
918
|
+
assertQuickCheckOk(db as InstanceType<typeof Database>);
|
|
919
|
+
assert.ok(fs.readdirSync(tmpDir).some((name) => name.startsWith('sessions.db.corrupt-')), 'unrecoverable DB should be quarantined');
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
it('retries a corrupt operation once after self-healing', () => {
|
|
923
|
+
dbManager.getDb();
|
|
924
|
+
let attempts = 0;
|
|
925
|
+
|
|
926
|
+
const result = dbManager.withCorruptionRecovery(() => {
|
|
927
|
+
attempts++;
|
|
928
|
+
if (attempts === 1) throw corruptSqliteError();
|
|
929
|
+
return 'ok';
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
assert.strictEqual(result, 'ok');
|
|
933
|
+
assert.strictEqual(attempts, 2);
|
|
934
|
+
assert.strictEqual(dbManager.getLastRecovery()?.strategy, 'reused');
|
|
935
|
+
});
|
|
936
|
+
});
|
|
937
|
+
|
|
938
|
+
describe('close', () => {
|
|
939
|
+
it('should close database connection', () => {
|
|
940
|
+
const db = dbManager.getDb();
|
|
941
|
+
assert.ok(db);
|
|
942
|
+
dbManager.close();
|
|
943
|
+
// After close, getDb should create a new connection
|
|
944
|
+
const db2 = dbManager.getDb();
|
|
945
|
+
assert.ok(db2);
|
|
946
|
+
assert.notStrictEqual(db, db2);
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
it('should be safe to call close multiple times', () => {
|
|
950
|
+
dbManager.getDb();
|
|
951
|
+
assert.doesNotThrow(() => {
|
|
952
|
+
dbManager.close();
|
|
953
|
+
dbManager.close();
|
|
954
|
+
});
|
|
955
|
+
});
|
|
956
|
+
|
|
957
|
+
it('should truncate the WAL file on close so it is not retained across sessions', () => {
|
|
958
|
+
const db = dbManager.getDb();
|
|
959
|
+
const walPath = `${dbManager.getPath()}-wal`;
|
|
960
|
+
|
|
961
|
+
// Generate enough WAL traffic to materialize a non-trivial WAL file.
|
|
962
|
+
const insert = db.prepare(`
|
|
963
|
+
INSERT INTO memories (project, target, content, created, last_referenced)
|
|
964
|
+
VALUES (?, ?, ?, ?, ?)
|
|
965
|
+
`);
|
|
966
|
+
for (let i = 0; i < 500; i++) {
|
|
967
|
+
insert.run(null, 'memory', `entry ${i} ${'x'.repeat(200)}`, '2026-05-03', '2026-05-03');
|
|
968
|
+
}
|
|
969
|
+
assert.ok(fs.existsSync(walPath), 'WAL file should exist after writes');
|
|
970
|
+
assert.ok(fs.statSync(walPath).size > 0, 'WAL should be non-empty before close');
|
|
971
|
+
|
|
972
|
+
// close() runs PRAGMA wal_checkpoint(TRUNCATE), which shrinks the WAL to 0.
|
|
973
|
+
dbManager.close();
|
|
974
|
+
|
|
975
|
+
const walSizeAfter = fs.existsSync(walPath) ? fs.statSync(walPath).size : 0;
|
|
976
|
+
assert.strictEqual(walSizeAfter, 0, 'WAL should be truncated to 0 bytes after close');
|
|
977
|
+
});
|
|
978
|
+
});
|
|
979
|
+
|
|
980
|
+
describe('getStats', () => {
|
|
981
|
+
it('should return zero counts for empty database', () => {
|
|
982
|
+
dbManager.getDb();
|
|
983
|
+
const stats = dbManager.getStats();
|
|
984
|
+
assert.strictEqual(stats.sessions, 0);
|
|
985
|
+
assert.strictEqual(stats.messages, 0);
|
|
986
|
+
assert.strictEqual(stats.memories, 0);
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
it('should count inserted records', () => {
|
|
990
|
+
const db = dbManager.getDb();
|
|
991
|
+
|
|
992
|
+
// Insert a session
|
|
993
|
+
db.prepare(`
|
|
994
|
+
INSERT INTO sessions (id, project, cwd, started_at)
|
|
995
|
+
VALUES (?, ?, ?, ?)
|
|
996
|
+
`).run('test-session-1', 'test-project', '/test/cwd', '2026-05-03T00:00:00Z');
|
|
997
|
+
|
|
998
|
+
// Insert a message
|
|
999
|
+
db.prepare(`
|
|
1000
|
+
INSERT INTO messages (id, session_id, role, content, timestamp)
|
|
1001
|
+
VALUES (?, ?, ?, ?, ?)
|
|
1002
|
+
`).run('test-msg-1', 'test-session-1', 'user', 'Hello', '2026-05-03T00:01:00Z');
|
|
1003
|
+
|
|
1004
|
+
// Insert a memory
|
|
1005
|
+
db.prepare(`
|
|
1006
|
+
INSERT INTO memories (project, target, content, created, last_referenced)
|
|
1007
|
+
VALUES (?, ?, ?, ?, ?)
|
|
1008
|
+
`).run(null, 'memory', 'prefers pnpm', '2026-05-03', '2026-05-03');
|
|
1009
|
+
|
|
1010
|
+
const stats = dbManager.getStats();
|
|
1011
|
+
assert.strictEqual(stats.sessions, 1);
|
|
1012
|
+
assert.strictEqual(stats.messages, 1);
|
|
1013
|
+
assert.strictEqual(stats.memories, 1);
|
|
1014
|
+
});
|
|
1015
|
+
});
|
|
1016
|
+
|
|
1017
|
+
describe('WAL mode', () => {
|
|
1018
|
+
it('should enable WAL mode for concurrent reads', () => {
|
|
1019
|
+
const db = dbManager.getDb();
|
|
1020
|
+
const result = db.pragma('journal_mode', { simple: true }) as string;
|
|
1021
|
+
assert.strictEqual(result, 'wal');
|
|
1022
|
+
});
|
|
1023
|
+
|
|
1024
|
+
it('should use SQLite default-size WAL autocheckpoints', () => {
|
|
1025
|
+
const db = dbManager.getDb();
|
|
1026
|
+
const result = db.pragma('wal_autocheckpoint', { simple: true }) as number;
|
|
1027
|
+
assert.strictEqual(result, SQLITE_WAL_AUTOCHECKPOINT_PAGES);
|
|
1028
|
+
});
|
|
1029
|
+
});
|
|
1030
|
+
|
|
1031
|
+
describe('foreign keys', () => {
|
|
1032
|
+
it('should enforce foreign key constraints', () => {
|
|
1033
|
+
const db = dbManager.getDb();
|
|
1034
|
+
const result = db.pragma('foreign_keys', { simple: true }) as number;
|
|
1035
|
+
assert.strictEqual(result, 1);
|
|
1036
|
+
|
|
1037
|
+
// Inserting a message with non-existent session_id should fail
|
|
1038
|
+
assert.throws(() => {
|
|
1039
|
+
db.prepare(`
|
|
1040
|
+
INSERT INTO messages (id, session_id, role, content, timestamp)
|
|
1041
|
+
VALUES (?, ?, ?, ?, ?)
|
|
1042
|
+
`).run('bad-msg', 'nonexistent-session', 'user', 'test', '2026-05-03T00:00:00Z');
|
|
1043
|
+
}, /FOREIGN KEY/);
|
|
1044
|
+
});
|
|
1045
|
+
});
|
|
1046
|
+
});
|