fix: harden startup and schema repair contracts

Reliability patch covering startup path resolution, install marker compatibility, export CLI request contracts, schema repair safety, hard-stop retry-loop handling, and the PR babysit status helper.
This commit is contained in:
Alex Newman
2026-05-06 18:29:26 -07:00
committed by GitHub
parent bb3dbfdb5a
commit 65f2fd8cdd
29 changed files with 2167 additions and 578 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
"command": "sh",
"args": [
"-c",
"_R=\"${CLAUDE_PLUGIN_ROOT:-$PLUGIN_ROOT}\"; [ -d \"$_R/plugin/scripts\" ] && _P=\"$_R/plugin\" || _P=\"$_R\"; exec node \"$_P/scripts/mcp-server.cjs\""
"_C=\"${CLAUDE_CONFIG_DIR:-$HOME/.claude}\"; _E=\"${CLAUDE_PLUGIN_ROOT:-${PLUGIN_ROOT:-}}\"; _P=$({ [ -n \"$_E\" ] && printf '%s\\n' \"$_E\"; ls -dt \"$_C/plugins/cache/thedotmack/claude-mem\"/[0-9]*/ 2>/dev/null; printf '%s\\n' \"$_C/plugins/marketplaces/thedotmack/plugin\"; } | while IFS= read -r _R; do _R=\"${_R%/}\"; [ -d \"$_R/plugin/scripts\" ] && _Q=\"$_R/plugin\" || _Q=\"$_R\"; [ -f \"$_Q/scripts/mcp-server.cjs\" ] && { printf '%s\\n' \"$_Q\"; break; }; done); [ -n \"$_P\" ] || { echo \"claude-mem: mcp server not found\" >&2; exit 1; }; exec node \"$_P/scripts/mcp-server.cjs\""
]
}
}
+100
View File
@@ -0,0 +1,100 @@
# Issue 2341 Reliability Slice Plan
Scope: first PR from the consolidated issue triage. This PR should not try to
solve the full backlog. It should remove a few high-confidence paper cuts from
the first two buckets: install/startup contract and DB/export contract.
## Phase 0: Documentation Discovery
Allowed APIs and patterns:
- Install marker helpers live in `src/npx-cli/install/setup-runtime.ts`.
Existing tests are in `tests/setup-runtime.test.ts`.
- Runtime startup warning logic lives in `plugin/scripts/version-check.js`.
It currently resolves the plugin root from `CLAUDE_PLUGIN_ROOT`, then from the
script directory.
- Export script reads worker settings via `SettingsDefaultsManager.loadFromFile`.
Worker settings must respect `CLAUDE_MEM_DATA_DIR`, because shared path helpers
and settings defaults already expose that environment override.
- `/api/sdk-sessions/batch` is registered in
`src/services/worker/http/routes/DataRoutes.ts` and expects
`memorySessionIds`. Existing coercion tests are in
`tests/worker/http/routes/data-routes-coercion.test.ts`.
- Current `PendingMessageStore` writes and reads `tool_use_id`, but no longer
reads `worker_pid`, `retry_count`, `failed_at_epoch`, or
`completed_at_epoch`. Current schema guardrails should match code that runs
today, not old migration intent.
Anti-pattern guards:
- Do not reintroduce `worker_pid` in `pending_messages` unless the current claim
query starts using it again.
- Do not rely only on `schema_versions` for columns that current SQL references.
- Do not add another install marker format. Read both legacy plain text and the
current JSON format, but keep writing the JSON marker.
- Do not make `export-memories.ts` fall back to `~/.claude-mem` when
`CLAUDE_MEM_DATA_DIR` is set.
## Phase 1: Install Marker Compatibility
What to implement:
- Teach `readInstallMarker()` to parse legacy plain-text marker files that only
contain a version string.
- Teach `plugin/scripts/version-check.js` to accept the same legacy marker shape.
- Keep `writeInstallMarker()` unchanged so new installs write the canonical JSON
schema.
Verification:
- Add `tests/setup-runtime.test.ts` coverage for a plain-text `.install-version`.
- Add a focused test for `plugin/scripts/version-check.js` behavior, or extend an
existing plugin script test if one exists.
- Run `bun test tests/setup-runtime.test.ts`.
## Phase 2: Export Script Contract Repair
What to implement:
- Update `scripts/export-memories.ts` to load settings from
`CLAUDE_MEM_DATA_DIR/settings.json` instead of always using
`~/.claude-mem/settings.json`.
- Change the `/api/sdk-sessions/batch` request body from `sdkSessionIds` to
`memorySessionIds`.
- Optionally allow `DataRoutes` to accept the legacy `sdkSessionIds` alias as a
compatibility bridge, but prefer the canonical field in scripts.
Verification:
- Add or update tests around the SDK-session batch route alias/coercion.
- Add a script-level test if practical; otherwise verify by grep that
`scripts/export-memories.ts` no longer sends `sdkSessionIds` and no longer
hardcodes `homedir(), '.claude-mem'`.
- Run the focused route/export tests.
## Phase 3: Current Pending Queue Shape Guardrails
What to implement:
- Add a regression test that initializes a DB whose `schema_versions` claims old
pending-message migrations are applied while `pending_messages.tool_use_id` is
missing. Constructing `SessionStore` should still add the missing column
because current enqueue SQL requires it.
- Add a regression test asserting the current fresh DB shape does not require
`worker_pid`, since the current claim query does not use it.
- If tests expose a real source/schema mismatch, update docs/schema comments to
match current code rather than reintroducing unused columns.
Verification:
- Run focused sqlite tests for `SessionStore` / `PendingMessageStore`.
- Grep for live `worker_pid` reads in TypeScript before deciding whether it is
still a required current column.
## Final Verification
- Run focused tests changed by this PR.
- Run `npm run typecheck:root` if dependencies are available.
- Run `git diff --check`.
- Open a non-draft PR against the upstream default branch.
- Do not merge, release, or ship without explicit user approval.
+1
View File
@@ -82,6 +82,7 @@
"queue": "bun scripts/check-pending-queue.ts",
"queue:process": "bun scripts/check-pending-queue.ts --process",
"queue:clear": "bun scripts/clear-failed-queue.ts --all --force",
"pr:status": "bun scripts/pr-babysit-status.ts",
"claude-md:regenerate": "bun scripts/regenerate-claude-md.ts",
"claude-md:dry-run": "bun scripts/regenerate-claude-md.ts --dry-run",
"strip-comments": "bun scripts/strip-comments.ts",
+1 -1
View File
@@ -5,7 +5,7 @@
"command": "sh",
"args": [
"-c",
"_R=\"${CLAUDE_PLUGIN_ROOT:-$PLUGIN_ROOT}\"; [ -d \"$_R/plugin/scripts\" ] && _P=\"$_R/plugin\" || _P=\"$_R\"; exec node \"$_P/scripts/mcp-server.cjs\""
"_C=\"${CLAUDE_CONFIG_DIR:-$HOME/.claude}\"; _E=\"${CLAUDE_PLUGIN_ROOT:-${PLUGIN_ROOT:-}}\"; _P=$({ [ -n \"$_E\" ] && printf '%s\\n' \"$_E\"; ls -dt \"$_C/plugins/cache/thedotmack/claude-mem\"/[0-9]*/ 2>/dev/null; printf '%s\\n' \"$_C/plugins/marketplaces/thedotmack/plugin\"; } | while IFS= read -r _R; do _R=\"${_R%/}\"; [ -d \"$_R/plugin/scripts\" ] && _Q=\"$_R/plugin\" || _Q=\"$_R\"; [ -f \"$_Q/scripts/mcp-server.cjs\" ] && { printf '%s\\n' \"$_Q\"; break; }; done); [ -n \"$_P\" ] || { echo \"claude-mem: mcp server not found\" >&2; exit 1; }; exec node \"$_P/scripts/mcp-server.cjs\""
]
}
}
+7 -7
View File
@@ -7,17 +7,17 @@
"hooks": [
{
"type": "command",
"command": "_R=\"${CLAUDE_PLUGIN_ROOT:-$PLUGIN_ROOT}\"; [ -d \"$_R/plugin/scripts\" ] && _P=\"$_R/plugin\" || _P=\"$_R\"; CLAUDE_MEM_CODEX_HOOK=1 node \"$_P/scripts/version-check.js\"",
"command": "_HP=$(printenv PATH 2>/dev/null || true); if [ -z \"$_HP\" ] && [ -n \"${SHELL:-}\" ]; then _HP=$(\"$SHELL\" -lc 'printf %s \"$PATH\"' 2>/dev/null || true); fi; _HP=$(printf '%s' \"$_HP\" | tr ' ' ':'); export PATH=\"${_HP:+$_HP:}$PATH\"; _C=\"${CLAUDE_CONFIG_DIR:-$HOME/.claude}\"; _E=\"${CLAUDE_PLUGIN_ROOT:-${PLUGIN_ROOT:-}}\"; _P=$({ [ -n \"$_E\" ] && printf '%s\\n' \"$_E\"; ls -dt \"$_C/plugins/cache/thedotmack/claude-mem\"/[0-9]*/ 2>/dev/null; printf '%s\\n' \"$_C/plugins/marketplaces/thedotmack/plugin\"; } | while IFS= read -r _R; do _R=\"${_R%/}\"; [ -d \"$_R/plugin/scripts\" ] && _Q=\"$_R/plugin\" || _Q=\"$_R\"; [ -f \"$_Q/scripts/version-check.js\" ] && { printf '%s\\n' \"$_Q\"; break; }; done); [ -n \"$_P\" ] || { echo \"claude-mem: version-check.js not found\" >&2; exit 1; }; command -v cygpath >/dev/null 2>&1 && { _W=$(cygpath -w \"$_P\" 2>/dev/null); [ -n \"$_W\" ] && _P=\"$_W\"; }; CLAUDE_MEM_CODEX_HOOK=1 node \"$_P/scripts/version-check.js\"",
"timeout": 5
},
{
"type": "command",
"command": "_R=\"${CLAUDE_PLUGIN_ROOT:-$PLUGIN_ROOT}\"; [ -d \"$_R/plugin/scripts\" ] && _P=\"$_R/plugin\" || _P=\"$_R\"; node \"$_P/scripts/bun-runner.js\" \"$_P/scripts/worker-service.cjs\" start",
"command": "_HP=$(printenv PATH 2>/dev/null || true); if [ -z \"$_HP\" ] && [ -n \"${SHELL:-}\" ]; then _HP=$(\"$SHELL\" -lc 'printf %s \"$PATH\"' 2>/dev/null || true); fi; _HP=$(printf '%s' \"$_HP\" | tr ' ' ':'); export PATH=\"${_HP:+$_HP:}$PATH\"; _C=\"${CLAUDE_CONFIG_DIR:-$HOME/.claude}\"; _E=\"${CLAUDE_PLUGIN_ROOT:-${PLUGIN_ROOT:-}}\"; _P=$({ [ -n \"$_E\" ] && printf '%s\\n' \"$_E\"; ls -dt \"$_C/plugins/cache/thedotmack/claude-mem\"/[0-9]*/ 2>/dev/null; printf '%s\\n' \"$_C/plugins/marketplaces/thedotmack/plugin\"; } | while IFS= read -r _R; do _R=\"${_R%/}\"; [ -d \"$_R/plugin/scripts\" ] && _Q=\"$_R/plugin\" || _Q=\"$_R\"; [ -f \"$_Q/scripts/bun-runner.js\" ] && [ -f \"$_Q/scripts/worker-service.cjs\" ] && { printf '%s\\n' \"$_Q\"; break; }; done); [ -n \"$_P\" ] || { echo \"claude-mem: plugin scripts not found\" >&2; exit 1; }; command -v cygpath >/dev/null 2>&1 && { _W=$(cygpath -w \"$_P\" 2>/dev/null); [ -n \"$_W\" ] && _P=\"$_W\"; }; node \"$_P/scripts/bun-runner.js\" \"$_P/scripts/worker-service.cjs\" start",
"timeout": 60
},
{
"type": "command",
"command": "_R=\"${CLAUDE_PLUGIN_ROOT:-$PLUGIN_ROOT}\"; [ -d \"$_R/plugin/scripts\" ] && _P=\"$_R/plugin\" || _P=\"$_R\"; node \"$_P/scripts/bun-runner.js\" \"$_P/scripts/worker-service.cjs\" hook codex context",
"command": "_HP=$(printenv PATH 2>/dev/null || true); if [ -z \"$_HP\" ] && [ -n \"${SHELL:-}\" ]; then _HP=$(\"$SHELL\" -lc 'printf %s \"$PATH\"' 2>/dev/null || true); fi; _HP=$(printf '%s' \"$_HP\" | tr ' ' ':'); export PATH=\"${_HP:+$_HP:}$PATH\"; _C=\"${CLAUDE_CONFIG_DIR:-$HOME/.claude}\"; _E=\"${CLAUDE_PLUGIN_ROOT:-${PLUGIN_ROOT:-}}\"; _P=$({ [ -n \"$_E\" ] && printf '%s\\n' \"$_E\"; ls -dt \"$_C/plugins/cache/thedotmack/claude-mem\"/[0-9]*/ 2>/dev/null; printf '%s\\n' \"$_C/plugins/marketplaces/thedotmack/plugin\"; } | while IFS= read -r _R; do _R=\"${_R%/}\"; [ -d \"$_R/plugin/scripts\" ] && _Q=\"$_R/plugin\" || _Q=\"$_R\"; [ -f \"$_Q/scripts/bun-runner.js\" ] && [ -f \"$_Q/scripts/worker-service.cjs\" ] && { printf '%s\\n' \"$_Q\"; break; }; done); [ -n \"$_P\" ] || { echo \"claude-mem: plugin scripts not found\" >&2; exit 1; }; command -v cygpath >/dev/null 2>&1 && { _W=$(cygpath -w \"$_P\" 2>/dev/null); [ -n \"$_W\" ] && _P=\"$_W\"; }; node \"$_P/scripts/bun-runner.js\" \"$_P/scripts/worker-service.cjs\" hook codex context",
"timeout": 60,
"statusMessage": "Loading claude-mem context"
}
@@ -29,7 +29,7 @@
"hooks": [
{
"type": "command",
"command": "_R=\"${CLAUDE_PLUGIN_ROOT:-$PLUGIN_ROOT}\"; [ -d \"$_R/plugin/scripts\" ] && _P=\"$_R/plugin\" || _P=\"$_R\"; node \"$_P/scripts/bun-runner.js\" \"$_P/scripts/worker-service.cjs\" hook codex session-init",
"command": "_HP=$(printenv PATH 2>/dev/null || true); if [ -z \"$_HP\" ] && [ -n \"${SHELL:-}\" ]; then _HP=$(\"$SHELL\" -lc 'printf %s \"$PATH\"' 2>/dev/null || true); fi; _HP=$(printf '%s' \"$_HP\" | tr ' ' ':'); export PATH=\"${_HP:+$_HP:}$PATH\"; _C=\"${CLAUDE_CONFIG_DIR:-$HOME/.claude}\"; _E=\"${CLAUDE_PLUGIN_ROOT:-${PLUGIN_ROOT:-}}\"; _P=$({ [ -n \"$_E\" ] && printf '%s\\n' \"$_E\"; ls -dt \"$_C/plugins/cache/thedotmack/claude-mem\"/[0-9]*/ 2>/dev/null; printf '%s\\n' \"$_C/plugins/marketplaces/thedotmack/plugin\"; } | while IFS= read -r _R; do _R=\"${_R%/}\"; [ -d \"$_R/plugin/scripts\" ] && _Q=\"$_R/plugin\" || _Q=\"$_R\"; [ -f \"$_Q/scripts/bun-runner.js\" ] && [ -f \"$_Q/scripts/worker-service.cjs\" ] && { printf '%s\\n' \"$_Q\"; break; }; done); [ -n \"$_P\" ] || { echo \"claude-mem: plugin scripts not found\" >&2; exit 1; }; command -v cygpath >/dev/null 2>&1 && { _W=$(cygpath -w \"$_P\" 2>/dev/null); [ -n \"$_W\" ] && _P=\"$_W\"; }; node \"$_P/scripts/bun-runner.js\" \"$_P/scripts/worker-service.cjs\" hook codex session-init",
"timeout": 60
}
]
@@ -41,7 +41,7 @@
"hooks": [
{
"type": "command",
"command": "_R=\"${CLAUDE_PLUGIN_ROOT:-$PLUGIN_ROOT}\"; [ -d \"$_R/plugin/scripts\" ] && _P=\"$_R/plugin\" || _P=\"$_R\"; node \"$_P/scripts/bun-runner.js\" \"$_P/scripts/worker-service.cjs\" hook codex file-context",
"command": "_HP=$(printenv PATH 2>/dev/null || true); if [ -z \"$_HP\" ] && [ -n \"${SHELL:-}\" ]; then _HP=$(\"$SHELL\" -lc 'printf %s \"$PATH\"' 2>/dev/null || true); fi; _HP=$(printf '%s' \"$_HP\" | tr ' ' ':'); export PATH=\"${_HP:+$_HP:}$PATH\"; _C=\"${CLAUDE_CONFIG_DIR:-$HOME/.claude}\"; _E=\"${CLAUDE_PLUGIN_ROOT:-${PLUGIN_ROOT:-}}\"; _P=$({ [ -n \"$_E\" ] && printf '%s\\n' \"$_E\"; ls -dt \"$_C/plugins/cache/thedotmack/claude-mem\"/[0-9]*/ 2>/dev/null; printf '%s\\n' \"$_C/plugins/marketplaces/thedotmack/plugin\"; } | while IFS= read -r _R; do _R=\"${_R%/}\"; [ -d \"$_R/plugin/scripts\" ] && _Q=\"$_R/plugin\" || _Q=\"$_R\"; [ -f \"$_Q/scripts/bun-runner.js\" ] && [ -f \"$_Q/scripts/worker-service.cjs\" ] && { printf '%s\\n' \"$_Q\"; break; }; done); [ -n \"$_P\" ] || { echo \"claude-mem: plugin scripts not found\" >&2; exit 1; }; command -v cygpath >/dev/null 2>&1 && { _W=$(cygpath -w \"$_P\" 2>/dev/null); [ -n \"$_W\" ] && _P=\"$_W\"; }; node \"$_P/scripts/bun-runner.js\" \"$_P/scripts/worker-service.cjs\" hook codex file-context",
"timeout": 30
}
]
@@ -53,7 +53,7 @@
"hooks": [
{
"type": "command",
"command": "_R=\"${CLAUDE_PLUGIN_ROOT:-$PLUGIN_ROOT}\"; [ -d \"$_R/plugin/scripts\" ] && _P=\"$_R/plugin\" || _P=\"$_R\"; node \"$_P/scripts/bun-runner.js\" \"$_P/scripts/worker-service.cjs\" hook codex observation",
"command": "_HP=$(printenv PATH 2>/dev/null || true); if [ -z \"$_HP\" ] && [ -n \"${SHELL:-}\" ]; then _HP=$(\"$SHELL\" -lc 'printf %s \"$PATH\"' 2>/dev/null || true); fi; _HP=$(printf '%s' \"$_HP\" | tr ' ' ':'); export PATH=\"${_HP:+$_HP:}$PATH\"; _C=\"${CLAUDE_CONFIG_DIR:-$HOME/.claude}\"; _E=\"${CLAUDE_PLUGIN_ROOT:-${PLUGIN_ROOT:-}}\"; _P=$({ [ -n \"$_E\" ] && printf '%s\\n' \"$_E\"; ls -dt \"$_C/plugins/cache/thedotmack/claude-mem\"/[0-9]*/ 2>/dev/null; printf '%s\\n' \"$_C/plugins/marketplaces/thedotmack/plugin\"; } | while IFS= read -r _R; do _R=\"${_R%/}\"; [ -d \"$_R/plugin/scripts\" ] && _Q=\"$_R/plugin\" || _Q=\"$_R\"; [ -f \"$_Q/scripts/bun-runner.js\" ] && [ -f \"$_Q/scripts/worker-service.cjs\" ] && { printf '%s\\n' \"$_Q\"; break; }; done); [ -n \"$_P\" ] || { echo \"claude-mem: plugin scripts not found\" >&2; exit 1; }; command -v cygpath >/dev/null 2>&1 && { _W=$(cygpath -w \"$_P\" 2>/dev/null); [ -n \"$_W\" ] && _P=\"$_W\"; }; node \"$_P/scripts/bun-runner.js\" \"$_P/scripts/worker-service.cjs\" hook codex observation",
"timeout": 120
}
]
@@ -64,7 +64,7 @@
"hooks": [
{
"type": "command",
"command": "_R=\"${CLAUDE_PLUGIN_ROOT:-$PLUGIN_ROOT}\"; [ -d \"$_R/plugin/scripts\" ] && _P=\"$_R/plugin\" || _P=\"$_R\"; node \"$_P/scripts/bun-runner.js\" \"$_P/scripts/worker-service.cjs\" hook codex summarize",
"command": "_HP=$(printenv PATH 2>/dev/null || true); if [ -z \"$_HP\" ] && [ -n \"${SHELL:-}\" ]; then _HP=$(\"$SHELL\" -lc 'printf %s \"$PATH\"' 2>/dev/null || true); fi; _HP=$(printf '%s' \"$_HP\" | tr ' ' ':'); export PATH=\"${_HP:+$_HP:}$PATH\"; _C=\"${CLAUDE_CONFIG_DIR:-$HOME/.claude}\"; _E=\"${CLAUDE_PLUGIN_ROOT:-${PLUGIN_ROOT:-}}\"; _P=$({ [ -n \"$_E\" ] && printf '%s\\n' \"$_E\"; ls -dt \"$_C/plugins/cache/thedotmack/claude-mem\"/[0-9]*/ 2>/dev/null; printf '%s\\n' \"$_C/plugins/marketplaces/thedotmack/plugin\"; } | while IFS= read -r _R; do _R=\"${_R%/}\"; [ -d \"$_R/plugin/scripts\" ] && _Q=\"$_R/plugin\" || _Q=\"$_R\"; [ -f \"$_Q/scripts/bun-runner.js\" ] && [ -f \"$_Q/scripts/worker-service.cjs\" ] && { printf '%s\\n' \"$_Q\"; break; }; done); [ -n \"$_P\" ] || { echo \"claude-mem: plugin scripts not found\" >&2; exit 1; }; command -v cygpath >/dev/null 2>&1 && { _W=$(cygpath -w \"$_P\" 2>/dev/null); [ -n \"$_W\" ] && _P=\"$_W\"; }; node \"$_P/scripts/bun-runner.js\" \"$_P/scripts/worker-service.cjs\" hook codex summarize",
"timeout": 60
}
]
+7 -7
View File
@@ -8,7 +8,7 @@
{
"type": "command",
"shell": "bash",
"command": "export PATH=\"$HOME/.nvm/versions/node/v$(ls \\\"$HOME/.nvm/versions/node\\\" 2>/dev/null | sed 's/^v//' | sort -t. -k1,1n -k2,2n -k3,3n | tail -1)/bin:$HOME/.local/bin:/usr/local/bin:/opt/homebrew/bin:$PATH\"; _R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=$(ls -dt \"$HOME/.claude/plugins/cache/thedotmack/claude-mem\"/[0-9]*/ 2>/dev/null | head -1); _R=\"${_R%/}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; command -v cygpath >/dev/null 2>&1 && { _W=$(cygpath -w \"$_R\" 2>/dev/null); [ -n \"$_W\" ] && _R=\"$_W\"; }; node \"$_R/scripts/version-check.js\"",
"command": "export PATH=\"$HOME/.nvm/versions/node/v$(ls \\\"$HOME/.nvm/versions/node\\\" 2>/dev/null | sed 's/^v//' | sort -t. -k1,1n -k2,2n -k3,3n | tail -1)/bin:$HOME/.local/bin:/usr/local/bin:/opt/homebrew/bin:$PATH\"; _C=\"${CLAUDE_CONFIG_DIR:-$HOME/.claude}\"; _E=\"${CLAUDE_PLUGIN_ROOT:-${PLUGIN_ROOT:-}}\"; _P=$({ [ -n \"$_E\" ] && printf '%s\\n' \"$_E\"; ls -dt \"$_C/plugins/cache/thedotmack/claude-mem\"/[0-9]*/ 2>/dev/null; printf '%s\\n' \"$_C/plugins/marketplaces/thedotmack/plugin\"; } | while IFS= read -r _R; do _R=\"${_R%/}\"; [ -d \"$_R/plugin/scripts\" ] && _Q=\"$_R/plugin\" || _Q=\"$_R\"; [ -f \"$_Q/scripts/version-check.js\" ] && { printf '%s\\n' \"$_Q\"; break; }; done); [ -n \"$_P\" ] || { echo \"claude-mem: version-check.js not found\" >&2; exit 1; }; command -v cygpath >/dev/null 2>&1 && { _W=$(cygpath -w \"$_P\" 2>/dev/null); [ -n \"$_W\" ] && _P=\"$_W\"; }; node \"$_P/scripts/version-check.js\"",
"timeout": 300
}
]
@@ -21,13 +21,13 @@
{
"type": "command",
"shell": "bash",
"command": "export PATH=\"$($SHELL -lc 'echo $PATH' 2>/dev/null):$PATH\"; _R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=$(ls -dt \"$HOME/.claude/plugins/cache/thedotmack/claude-mem\"/[0-9]*/ 2>/dev/null | head -1); _R=\"${_R%/}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; command -v cygpath >/dev/null 2>&1 && { _W=$(cygpath -w \"$_R\" 2>/dev/null); [ -n \"$_W\" ] && _R=\"$_W\"; }; node \"$_R/scripts/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" start; echo '{\"continue\":true,\"suppressOutput\":true}'",
"command": "export PATH=\"$($SHELL -lc 'echo $PATH' 2>/dev/null):$PATH\"; _C=\"${CLAUDE_CONFIG_DIR:-$HOME/.claude}\"; _E=\"${CLAUDE_PLUGIN_ROOT:-${PLUGIN_ROOT:-}}\"; _P=$({ [ -n \"$_E\" ] && printf '%s\\n' \"$_E\"; ls -dt \"$_C/plugins/cache/thedotmack/claude-mem\"/[0-9]*/ 2>/dev/null; printf '%s\\n' \"$_C/plugins/marketplaces/thedotmack/plugin\"; } | while IFS= read -r _R; do _R=\"${_R%/}\"; [ -d \"$_R/plugin/scripts\" ] && _Q=\"$_R/plugin\" || _Q=\"$_R\"; [ -f \"$_Q/scripts/bun-runner.js\" ] && [ -f \"$_Q/scripts/worker-service.cjs\" ] && { printf '%s\\n' \"$_Q\"; break; }; done); [ -n \"$_P\" ] || { echo \"claude-mem: plugin scripts not found\" >&2; exit 1; }; command -v cygpath >/dev/null 2>&1 && { _W=$(cygpath -w \"$_P\" 2>/dev/null); [ -n \"$_W\" ] && _P=\"$_W\"; }; node \"$_P/scripts/bun-runner.js\" \"$_P/scripts/worker-service.cjs\" start; echo '{\"continue\":true,\"suppressOutput\":true}'",
"timeout": 60
},
{
"type": "command",
"shell": "bash",
"command": "export PATH=\"$($SHELL -lc 'echo $PATH' 2>/dev/null):$PATH\"; _R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=$(ls -dt \"$HOME/.claude/plugins/cache/thedotmack/claude-mem\"/[0-9]*/ 2>/dev/null | head -1); _R=\"${_R%/}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; command -v cygpath >/dev/null 2>&1 && { _W=$(cygpath -w \"$_R\" 2>/dev/null); [ -n \"$_W\" ] && _R=\"$_W\"; }; node \"$_R/scripts/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" hook claude-code context",
"command": "export PATH=\"$($SHELL -lc 'echo $PATH' 2>/dev/null):$PATH\"; _C=\"${CLAUDE_CONFIG_DIR:-$HOME/.claude}\"; _E=\"${CLAUDE_PLUGIN_ROOT:-${PLUGIN_ROOT:-}}\"; _P=$({ [ -n \"$_E\" ] && printf '%s\\n' \"$_E\"; ls -dt \"$_C/plugins/cache/thedotmack/claude-mem\"/[0-9]*/ 2>/dev/null; printf '%s\\n' \"$_C/plugins/marketplaces/thedotmack/plugin\"; } | while IFS= read -r _R; do _R=\"${_R%/}\"; [ -d \"$_R/plugin/scripts\" ] && _Q=\"$_R/plugin\" || _Q=\"$_R\"; [ -f \"$_Q/scripts/bun-runner.js\" ] && [ -f \"$_Q/scripts/worker-service.cjs\" ] && { printf '%s\\n' \"$_Q\"; break; }; done); [ -n \"$_P\" ] || { echo \"claude-mem: plugin scripts not found\" >&2; exit 1; }; command -v cygpath >/dev/null 2>&1 && { _W=$(cygpath -w \"$_P\" 2>/dev/null); [ -n \"$_W\" ] && _P=\"$_W\"; }; node \"$_P/scripts/bun-runner.js\" \"$_P/scripts/worker-service.cjs\" hook claude-code context",
"timeout": 60
}
]
@@ -39,7 +39,7 @@
{
"type": "command",
"shell": "bash",
"command": "export PATH=\"$($SHELL -lc 'echo $PATH' 2>/dev/null):$PATH\"; _R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=$(ls -dt \"$HOME/.claude/plugins/cache/thedotmack/claude-mem\"/[0-9]*/ 2>/dev/null | head -1); _R=\"${_R%/}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; command -v cygpath >/dev/null 2>&1 && { _W=$(cygpath -w \"$_R\" 2>/dev/null); [ -n \"$_W\" ] && _R=\"$_W\"; }; node \"$_R/scripts/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" hook claude-code session-init",
"command": "export PATH=\"$($SHELL -lc 'echo $PATH' 2>/dev/null):$PATH\"; _C=\"${CLAUDE_CONFIG_DIR:-$HOME/.claude}\"; _E=\"${CLAUDE_PLUGIN_ROOT:-${PLUGIN_ROOT:-}}\"; _P=$({ [ -n \"$_E\" ] && printf '%s\\n' \"$_E\"; ls -dt \"$_C/plugins/cache/thedotmack/claude-mem\"/[0-9]*/ 2>/dev/null; printf '%s\\n' \"$_C/plugins/marketplaces/thedotmack/plugin\"; } | while IFS= read -r _R; do _R=\"${_R%/}\"; [ -d \"$_R/plugin/scripts\" ] && _Q=\"$_R/plugin\" || _Q=\"$_R\"; [ -f \"$_Q/scripts/bun-runner.js\" ] && [ -f \"$_Q/scripts/worker-service.cjs\" ] && { printf '%s\\n' \"$_Q\"; break; }; done); [ -n \"$_P\" ] || { echo \"claude-mem: plugin scripts not found\" >&2; exit 1; }; command -v cygpath >/dev/null 2>&1 && { _W=$(cygpath -w \"$_P\" 2>/dev/null); [ -n \"$_W\" ] && _P=\"$_W\"; }; node \"$_P/scripts/bun-runner.js\" \"$_P/scripts/worker-service.cjs\" hook claude-code session-init",
"timeout": 60
}
]
@@ -52,7 +52,7 @@
{
"type": "command",
"shell": "bash",
"command": "export PATH=\"$($SHELL -lc 'echo $PATH' 2>/dev/null):$PATH\"; _R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=$(ls -dt \"$HOME/.claude/plugins/cache/thedotmack/claude-mem\"/[0-9]*/ 2>/dev/null | head -1); _R=\"${_R%/}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; command -v cygpath >/dev/null 2>&1 && { _W=$(cygpath -w \"$_R\" 2>/dev/null); [ -n \"$_W\" ] && _R=\"$_W\"; }; node \"$_R/scripts/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" hook claude-code observation",
"command": "export PATH=\"$($SHELL -lc 'echo $PATH' 2>/dev/null):$PATH\"; _C=\"${CLAUDE_CONFIG_DIR:-$HOME/.claude}\"; _E=\"${CLAUDE_PLUGIN_ROOT:-${PLUGIN_ROOT:-}}\"; _P=$({ [ -n \"$_E\" ] && printf '%s\\n' \"$_E\"; ls -dt \"$_C/plugins/cache/thedotmack/claude-mem\"/[0-9]*/ 2>/dev/null; printf '%s\\n' \"$_C/plugins/marketplaces/thedotmack/plugin\"; } | while IFS= read -r _R; do _R=\"${_R%/}\"; [ -d \"$_R/plugin/scripts\" ] && _Q=\"$_R/plugin\" || _Q=\"$_R\"; [ -f \"$_Q/scripts/bun-runner.js\" ] && [ -f \"$_Q/scripts/worker-service.cjs\" ] && { printf '%s\\n' \"$_Q\"; break; }; done); [ -n \"$_P\" ] || { echo \"claude-mem: plugin scripts not found\" >&2; exit 1; }; command -v cygpath >/dev/null 2>&1 && { _W=$(cygpath -w \"$_P\" 2>/dev/null); [ -n \"$_W\" ] && _P=\"$_W\"; }; node \"$_P/scripts/bun-runner.js\" \"$_P/scripts/worker-service.cjs\" hook claude-code observation",
"timeout": 120
}
]
@@ -65,7 +65,7 @@
{
"type": "command",
"shell": "bash",
"command": "export PATH=\"$($SHELL -lc 'echo $PATH' 2>/dev/null):$PATH\"; _R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=$(ls -dt \"$HOME/.claude/plugins/cache/thedotmack/claude-mem\"/[0-9]*/ 2>/dev/null | head -1); _R=\"${_R%/}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; command -v cygpath >/dev/null 2>&1 && { _W=$(cygpath -w \"$_R\" 2>/dev/null); [ -n \"$_W\" ] && _R=\"$_W\"; }; node \"$_R/scripts/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" hook claude-code file-context",
"command": "export PATH=\"$($SHELL -lc 'echo $PATH' 2>/dev/null):$PATH\"; _C=\"${CLAUDE_CONFIG_DIR:-$HOME/.claude}\"; _E=\"${CLAUDE_PLUGIN_ROOT:-${PLUGIN_ROOT:-}}\"; _P=$({ [ -n \"$_E\" ] && printf '%s\\n' \"$_E\"; ls -dt \"$_C/plugins/cache/thedotmack/claude-mem\"/[0-9]*/ 2>/dev/null; printf '%s\\n' \"$_C/plugins/marketplaces/thedotmack/plugin\"; } | while IFS= read -r _R; do _R=\"${_R%/}\"; [ -d \"$_R/plugin/scripts\" ] && _Q=\"$_R/plugin\" || _Q=\"$_R\"; [ -f \"$_Q/scripts/bun-runner.js\" ] && [ -f \"$_Q/scripts/worker-service.cjs\" ] && { printf '%s\\n' \"$_Q\"; break; }; done); [ -n \"$_P\" ] || { echo \"claude-mem: plugin scripts not found\" >&2; exit 1; }; command -v cygpath >/dev/null 2>&1 && { _W=$(cygpath -w \"$_P\" 2>/dev/null); [ -n \"$_W\" ] && _P=\"$_W\"; }; node \"$_P/scripts/bun-runner.js\" \"$_P/scripts/worker-service.cjs\" hook claude-code file-context",
"timeout": 60
}
]
@@ -77,7 +77,7 @@
{
"type": "command",
"shell": "bash",
"command": "export PATH=\"$($SHELL -lc 'echo $PATH' 2>/dev/null):$PATH\"; _R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=$(ls -dt \"$HOME/.claude/plugins/cache/thedotmack/claude-mem\"/[0-9]*/ 2>/dev/null | head -1); _R=\"${_R%/}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; command -v cygpath >/dev/null 2>&1 && { _W=$(cygpath -w \"$_R\" 2>/dev/null); [ -n \"$_W\" ] && _R=\"$_W\"; }; node \"$_R/scripts/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" hook claude-code summarize",
"command": "export PATH=\"$($SHELL -lc 'echo $PATH' 2>/dev/null):$PATH\"; _C=\"${CLAUDE_CONFIG_DIR:-$HOME/.claude}\"; _E=\"${CLAUDE_PLUGIN_ROOT:-${PLUGIN_ROOT:-}}\"; _P=$({ [ -n \"$_E\" ] && printf '%s\\n' \"$_E\"; ls -dt \"$_C/plugins/cache/thedotmack/claude-mem\"/[0-9]*/ 2>/dev/null; printf '%s\\n' \"$_C/plugins/marketplaces/thedotmack/plugin\"; } | while IFS= read -r _R; do _R=\"${_R%/}\"; [ -d \"$_R/plugin/scripts\" ] && _Q=\"$_R/plugin\" || _Q=\"$_R\"; [ -f \"$_Q/scripts/bun-runner.js\" ] && [ -f \"$_Q/scripts/worker-service.cjs\" ] && { printf '%s\\n' \"$_Q\"; break; }; done); [ -n \"$_P\" ] || { echo \"claude-mem: plugin scripts not found\" >&2; exit 1; }; command -v cygpath >/dev/null 2>&1 && { _W=$(cygpath -w \"$_P\" 2>/dev/null); [ -n \"$_W\" ] && _P=\"$_W\"; }; node \"$_P/scripts/bun-runner.js\" \"$_P/scripts/worker-service.cjs\" hook claude-code summarize",
"timeout": 120
}
]
File diff suppressed because one or more lines are too long
+22 -2
View File
@@ -32,6 +32,24 @@ function emitUpgradeHint(message) {
}
}
const LEGACY_VERSION_MARKER_RE =
/^v?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
function readInstallMarkerVersion(markerPath) {
const content = readFileSync(markerPath, 'utf-8');
try {
const marker = JSON.parse(content);
return marker && typeof marker === 'object' && typeof marker.version === 'string'
? marker.version
: null;
} catch {
const legacyVersion = content.trim();
return LEGACY_VERSION_MARKER_RE.test(legacyVersion)
? legacyVersion.replace(/^v/i, '')
: null;
}
}
try {
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8'));
const markerPath = join(ROOT, '.install-version');
@@ -39,8 +57,10 @@ try {
emitUpgradeHint('claude-mem: runtime not yet set up - run: npx claude-mem@latest install');
process.exit(0);
}
const marker = JSON.parse(readFileSync(markerPath, 'utf-8'));
if (marker.version !== pkg.version) {
const markerVersion = readInstallMarkerVersion(markerPath);
if (!markerVersion) {
emitUpgradeHint('claude-mem: install marker unreadable - run: npx claude-mem@latest install');
} else if (markerVersion !== pkg.version) {
emitUpgradeHint(`claude-mem: upgraded to v${pkg.version} - run: npx claude-mem@latest install`);
}
} catch {
File diff suppressed because one or more lines are too long
+132 -90
View File
@@ -1,9 +1,10 @@
#!/usr/bin/env node
import { writeFileSync } from 'fs';
import { homedir } from 'os';
import { join } from 'path';
import { SettingsDefaultsManager } from '../src/shared/SettingsDefaultsManager';
import { pathToFileURL } from 'url';
import { SettingsDefaultsManager } from '../src/shared/SettingsDefaultsManager.js';
import { resolveDataDir } from '../src/shared/paths.js';
import type {
ObservationRecord,
SdkSessionRecord,
@@ -12,100 +13,141 @@ import type {
ExportData
} from './types/export.js';
async function exportMemories(query: string, outputFile: string, project?: string) {
const WORKER_FETCH_TIMEOUT_MS = 30_000;
function parseWorkerPort(rawPort: unknown): number {
if (typeof rawPort !== 'string' || rawPort.trim() === '') {
throw new Error('Invalid CLAUDE_MEM_WORKER_PORT in settings.json: missing');
}
const normalized = rawPort.trim();
const port = Number.parseInt(normalized, 10);
if (!Number.isInteger(port) || port < 1 || port > 65535 || String(port) !== normalized) {
throw new Error(`Invalid CLAUDE_MEM_WORKER_PORT in settings.json: ${rawPort}`);
}
return port;
}
async function fetchWithTimeout(url: string, init?: RequestInit): Promise<Response> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), WORKER_FETCH_TIMEOUT_MS);
try {
const settings = SettingsDefaultsManager.loadFromFile(join(homedir(), '.claude-mem', 'settings.json'));
const port = parseInt(settings.CLAUDE_MEM_WORKER_PORT, 10);
const baseUrl = `http://localhost:${port}`;
console.log(`🔍 Searching for: "${query}"${project ? ` (project: ${project})` : ' (all projects)'}`);
const params = new URLSearchParams({
query,
format: 'json',
limit: '999999'
return await fetch(url, {
...init,
signal: controller.signal,
});
if (project) params.set('project', project);
console.log('📡 Fetching all memories via hybrid search...');
const searchResponse = await fetch(`${baseUrl}/api/search?${params.toString()}`);
if (!searchResponse.ok) {
throw new Error(`Failed to search: ${searchResponse.status} ${searchResponse.statusText}`);
}
const searchData = await searchResponse.json();
const observations: ObservationRecord[] = searchData.observations || [];
const summaries: SessionSummaryRecord[] = searchData.sessions || [];
const prompts: UserPromptRecord[] = searchData.prompts || [];
console.log(`✅ Found ${observations.length} observations`);
console.log(`✅ Found ${summaries.length} session summaries`);
console.log(`✅ Found ${prompts.length} user prompts`);
const memorySessionIds = new Set<string>();
observations.forEach((o) => {
if (o.memory_session_id) memorySessionIds.add(o.memory_session_id);
});
summaries.forEach((s) => {
if (s.memory_session_id) memorySessionIds.add(s.memory_session_id);
});
console.log('📡 Fetching SDK sessions metadata...');
let sessions: SdkSessionRecord[] = [];
if (memorySessionIds.size > 0) {
const sessionsResponse = await fetch(`${baseUrl}/api/sdk-sessions/batch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sdkSessionIds: Array.from(memorySessionIds) })
});
if (sessionsResponse.ok) {
sessions = await sessionsResponse.json();
} else {
console.warn(`⚠️ Failed to fetch SDK sessions: ${sessionsResponse.status}`);
}
}
console.log(`✅ Found ${sessions.length} SDK sessions`);
const exportData: ExportData = {
exportedAt: new Date().toISOString(),
exportedAtEpoch: Date.now(),
query,
project,
totalObservations: observations.length,
totalSessions: sessions.length,
totalSummaries: summaries.length,
totalPrompts: prompts.length,
observations,
sessions,
summaries,
prompts
};
writeFileSync(outputFile, JSON.stringify(exportData, null, 2));
console.log(`\n📦 Export complete!`);
console.log(`📄 Output: ${outputFile}`);
console.log(`📊 Stats:`);
console.log(`${exportData.totalObservations} observations`);
console.log(`${exportData.totalSessions} sessions`);
console.log(`${exportData.totalSummaries} summaries`);
console.log(`${exportData.totalPrompts} prompts`);
} catch (error) {
console.error('❌ Export failed:', error);
process.exit(1);
if (error instanceof Error && error.name === 'AbortError') {
throw new Error(`Worker request timed out after ${WORKER_FETCH_TIMEOUT_MS}ms: ${url}`);
}
throw error;
} finally {
clearTimeout(timeout);
}
}
const args = process.argv.slice(2);
if (args.length < 2) {
console.error('Usage: npx tsx scripts/export-memories.ts <query> <output-file> [--project=name]');
console.error('Example: npx tsx scripts/export-memories.ts "windows" windows-memories.json --project=claude-mem');
console.error(' npx tsx scripts/export-memories.ts "authentication" auth.json');
process.exit(1);
export async function exportMemories(query: string, outputFile: string, project?: string) {
const settings = SettingsDefaultsManager.loadFromFile(join(resolveDataDir(), 'settings.json'));
const port = parseWorkerPort(settings.CLAUDE_MEM_WORKER_PORT);
const baseUrl = `http://localhost:${port}`;
console.log(`🔍 Searching for: "${query}"${project ? ` (project: ${project})` : ' (all projects)'}`);
const params = new URLSearchParams({
query,
format: 'json',
limit: '999999'
});
if (project) params.set('project', project);
console.log('📡 Fetching all memories via hybrid search...');
const searchResponse = await fetchWithTimeout(`${baseUrl}/api/search?${params.toString()}`);
if (!searchResponse.ok) {
throw new Error(`Failed to search: ${searchResponse.status} ${searchResponse.statusText}`);
}
const searchData = await searchResponse.json();
const observations: ObservationRecord[] = searchData.observations || [];
const summaries: SessionSummaryRecord[] = searchData.sessions || [];
const prompts: UserPromptRecord[] = searchData.prompts || [];
console.log(`✅ Found ${observations.length} observations`);
console.log(`✅ Found ${summaries.length} session summaries`);
console.log(`✅ Found ${prompts.length} user prompts`);
const memorySessionIds = new Set<string>();
observations.forEach((o) => {
if (o.memory_session_id) memorySessionIds.add(o.memory_session_id);
});
summaries.forEach((s) => {
if (s.memory_session_id) memorySessionIds.add(s.memory_session_id);
});
console.log('📡 Fetching SDK sessions metadata...');
let sessions: SdkSessionRecord[] = [];
if (memorySessionIds.size > 0) {
const sessionsResponse = await fetchWithTimeout(`${baseUrl}/api/sdk-sessions/batch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ memorySessionIds: Array.from(memorySessionIds) })
});
if (sessionsResponse.ok) {
sessions = await sessionsResponse.json();
} else {
const body = await sessionsResponse.text();
throw new Error(`Failed to fetch SDK sessions: ${sessionsResponse.status} ${sessionsResponse.statusText} ${body}`.trim());
}
}
console.log(`✅ Found ${sessions.length} SDK sessions`);
const exportData: ExportData = {
exportedAt: new Date().toISOString(),
exportedAtEpoch: Date.now(),
query,
project,
totalObservations: observations.length,
totalSessions: sessions.length,
totalSummaries: summaries.length,
totalPrompts: prompts.length,
observations,
sessions,
summaries,
prompts
};
writeFileSync(outputFile, JSON.stringify(exportData, null, 2));
console.log(`\n📦 Export complete!`);
console.log(`📄 Output: ${outputFile}`);
console.log(`📊 Stats:`);
console.log(`${exportData.totalObservations} observations`);
console.log(`${exportData.totalSessions} sessions`);
console.log(`${exportData.totalSummaries} summaries`);
console.log(`${exportData.totalPrompts} prompts`);
}
const [query, outputFile, ...flags] = args;
const project = flags.find(f => f.startsWith('--project='))?.split('=')[1];
function isDirectRun(): boolean {
if (process.env.CLAUDE_MEM_EXPORT_MEMORIES_NO_MAIN === '1') {
return false;
}
return Boolean(process.argv[1]) && import.meta.url === pathToFileURL(process.argv[1]).href;
}
exportMemories(query, outputFile, project);
if (isDirectRun()) {
const args = process.argv.slice(2);
if (args.length < 2) {
console.error('Usage: npx tsx scripts/export-memories.ts <query> <output-file> [--project=name]');
console.error('Example: npx tsx scripts/export-memories.ts "windows" windows-memories.json --project=claude-mem');
console.error(' npx tsx scripts/export-memories.ts "authentication" auth.json');
process.exit(1);
}
const [query, outputFile, ...flags] = args;
const project = flags.find(f => f.startsWith('--project='))?.split('=')[1];
exportMemories(query, outputFile, project).catch((error) => {
console.error('❌ Export failed:', error);
process.exit(1);
});
}
+513
View File
@@ -0,0 +1,513 @@
#!/usr/bin/env bun
import { pathToFileURL } from 'url';
type GhResult = {
stdout: string;
stderr: string;
exitCode: number;
};
type PullRequest = {
number: number;
title: string;
url: string;
headRefOid: string;
baseRefName: string;
state: string;
isDraft: boolean;
mergeable: string;
mergeStateStatus: string;
reviewDecision: string;
};
type RepoInfo = {
nameWithOwner: string;
};
type CheckRun = {
bucket: 'pass' | 'fail' | 'pending' | 'skipping' | 'cancel' | string;
completedAt?: string;
description?: string;
link?: string;
name: string;
startedAt?: string;
state: string;
workflow?: string;
};
type Review = {
id: number;
user?: { login?: string };
state: string;
body?: string | null;
commit_id?: string;
submitted_at?: string;
html_url?: string;
};
type ReviewComment = {
user?: { login?: string };
body?: string | null;
commit_id?: string;
path?: string;
line?: number | null;
original_line?: number | null;
updated_at?: string;
created_at?: string;
html_url?: string;
};
type BranchProtection = {
required_status_checks?: {
strict?: boolean;
contexts?: string[];
checks?: Array<{ context?: string; app_id?: number | null }>;
};
required_pull_request_reviews?: {
dismiss_stale_reviews?: boolean;
require_code_owner_reviews?: boolean;
require_last_push_approval?: boolean;
required_approving_review_count?: number;
};
required_signatures?: { enabled?: boolean };
enforce_admins?: { enabled?: boolean };
required_conversation_resolution?: { enabled?: boolean };
allow_force_pushes?: { enabled?: boolean };
};
type BotHint = {
source: string;
author: string;
when: string;
location?: string;
hints: string[];
};
const GH_PENDING_EXIT_CODE = 8;
const BOT_LOGIN_PATTERN = /(coderabbit|greptile)/i;
function runCommand(cmd: string[]): GhResult {
try {
const result = Bun.spawnSync({
cmd,
stdout: 'pipe',
stderr: 'pipe',
});
return {
stdout: new TextDecoder().decode(result.stdout).trim(),
stderr: new TextDecoder().decode(result.stderr).trim(),
exitCode: result.exitCode,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return { stdout: '', stderr: message, exitCode: 127 };
}
}
function runGh(args: string[], options: { allowExitCodes?: number[] } = {}): string {
const result = runCommand(['gh', ...args]);
const allowed = new Set([0, ...(options.allowExitCodes ?? [])]);
if (!allowed.has(result.exitCode)) {
const detail = result.stderr || result.stdout || `exit code ${result.exitCode}`;
throw new Error(`gh ${args.join(' ')} failed: ${detail}`);
}
return result.stdout;
}
function parseJson<T>(raw: string, label: string): T {
try {
return JSON.parse(raw) as T;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Could not parse ${label} JSON: ${message}`);
}
}
function checkPrerequisites() {
const git = runCommand(['git', 'rev-parse', '--is-inside-work-tree']);
if (git.exitCode !== 0 || git.stdout.trim() !== 'true') {
throw new Error('Not in a git repository. Run this from a checked-out repo.');
}
const ghVersion = runCommand(['gh', '--version']);
if (ghVersion.exitCode !== 0) {
throw new Error('GitHub CLI is not available. Install gh and try again.');
}
const auth = runCommand(['gh', 'auth', 'status']);
if (auth.exitCode !== 0) {
throw new Error(`GitHub CLI is not authenticated. Run "gh auth login".\n${auth.stderr || auth.stdout}`.trim());
}
}
function targetArgs(prArg?: string): string[] {
return prArg ? [prArg] : [];
}
function fetchPr(prArg?: string): PullRequest {
const fields = [
'number',
'title',
'url',
'headRefOid',
'baseRefName',
'state',
'isDraft',
'mergeable',
'mergeStateStatus',
'reviewDecision',
].join(',');
return parseJson<PullRequest>(
runGh(['pr', 'view', ...targetArgs(prArg), '--json', fields]),
'pull request',
);
}
function fetchRepo(): RepoInfo {
return parseJson<RepoInfo>(
runGh(['repo', 'view', '--json', 'nameWithOwner']),
'repository',
);
}
function fetchChecks(prArg?: string): CheckRun[] {
const fields = [
'bucket',
'completedAt',
'description',
'link',
'name',
'startedAt',
'state',
'workflow',
].join(',');
const raw = runGh(
['pr', 'checks', ...targetArgs(prArg), '--json', fields],
{ allowExitCodes: [GH_PENDING_EXIT_CODE] },
);
return raw ? parseJson<CheckRun[]>(raw, 'checks') : [];
}
function fetchBranchProtection(repo: RepoInfo, branch: string): BranchProtection | undefined {
const [owner, name] = repo.nameWithOwner.split('/');
const endpoint = `repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/branches/${encodeURIComponent(branch)}/protection`;
const result = runCommand(['gh', 'api', endpoint]);
if (result.exitCode !== 0) {
return undefined;
}
return parseJson<BranchProtection>(result.stdout, 'branch protection');
}
function fetchReviews(repo: RepoInfo, prNumber: number): Review[] {
const raw = runGh([
'api',
`repos/${repo.nameWithOwner}/pulls/${prNumber}/reviews`,
'--paginate',
]);
return raw ? parseJson<Review[]>(raw, 'reviews') : [];
}
function fetchReviewComments(repo: RepoInfo, prNumber: number): ReviewComment[] {
const raw = runGh([
'api',
`repos/${repo.nameWithOwner}/pulls/${prNumber}/comments`,
'--paginate',
]);
return raw ? parseJson<ReviewComment[]>(raw, 'review comments') : [];
}
function shortSha(sha: string): string {
return sha.slice(0, 12);
}
function formatBool(value: boolean | undefined): string {
return value ? 'yes' : 'no';
}
function formatCheck(check: CheckRun): string {
const workflow = check.workflow ? `${check.workflow} / ` : '';
const suffix = check.state ? ` (${check.state})` : '';
return `${workflow}${check.name}${suffix}`;
}
export function groupChecks(checks: CheckRun[]): Record<string, CheckRun[]> {
return checks.reduce<Record<string, CheckRun[]>>((groups, check) => {
const bucket = check.bucket || 'unknown';
groups[bucket] ??= [];
groups[bucket].push(check);
return groups;
}, {});
}
function markdownToText(raw: string): string {
return raw
.replace(/<!--[\s\S]*?-->/g, ' ')
.replace(/<details[\s\S]*?<\/details>/gi, ' ')
.replace(/<[^>]+>/g, ' ')
.replace(/!\[[^\]]*]\([^)]*\)/g, ' ')
.replace(/\[([^\]]+)]\([^)]*\)/g, '$1')
.replace(/[`*_>#|]/g, '')
.replace(/\s+/g, ' ')
.trim();
}
function withoutDetails(raw: string): string {
return raw.replace(/<details[\s\S]*?<\/details>/gi, ' ');
}
function concise(text: string, maxLength = 140): string {
const normalized = markdownToText(text);
if (normalized.length <= maxLength) return normalized;
return `${normalized.slice(0, maxLength - 1).trimEnd()}...`;
}
function firstMarkdownBold(raw: string): string | undefined {
const match = raw.match(/\*\*([^*\n][\s\S]*?)\*\*/);
return match ? concise(match[1]) : undefined;
}
function firstUsefulLine(raw: string): string | undefined {
for (const line of raw.split(/\r?\n/)) {
const hint = concise(line);
if (!hint) continue;
if (/^(details|summary|blockquote|---)$/i.test(hint)) continue;
if (/auto-generated|review info|run configuration|commits$/i.test(hint)) continue;
if (/^(?:\W+\s*)?Potential issue\b/i.test(hint)) continue;
return hint;
}
return undefined;
}
export function extractActionableHints(rawBody: string | null | undefined): string[] {
if (!rawBody) return [];
const hints: string[] = [];
const actionable = rawBody.match(/\*\*Actionable comments posted:\s*([^*]+)\*\*/i);
if (actionable) {
hints.push(`Actionable comments posted: ${actionable[1].trim()}`);
}
const bulletPattern = /^\s*-\s+(?:Around\s+)?(?:Line\s+)?([^:]{0,80}):\s+(.+)$/gim;
for (const match of rawBody.matchAll(bulletPattern)) {
const location = concise(match[1], 64);
const body = concise(match[2]);
if (/^https?:\/\//i.test(body)) continue;
if (body) hints.push(location ? `${location}: ${body}` : body);
}
const bodyWithoutDetails = withoutDetails(rawBody);
const bold = firstMarkdownBold(bodyWithoutDetails);
if (bold && !/^Actionable comments posted:/i.test(bold)) {
hints.push(bold);
}
const usefulLine = firstUsefulLine(bodyWithoutDetails);
if (usefulLine && !bold && !hints.includes(usefulLine)) {
hints.push(usefulLine);
}
return Array.from(new Set(hints)).slice(0, 4);
}
function isBot(login: string | undefined): boolean {
return Boolean(login && BOT_LOGIN_PATTERN.test(login));
}
function currentHeadReviews(reviews: Review[], headSha: string): Review[] {
return reviews
.filter(review => review.commit_id === headSha)
.sort((a, b) => String(a.submitted_at).localeCompare(String(b.submitted_at)));
}
function botHints(reviews: Review[], comments: ReviewComment[], headSha: string): BotHint[] {
const currentBotReviews = reviews.filter(review => review.commit_id === headSha && isBot(review.user?.login));
const earliestCurrentBotReview = currentBotReviews
.map(review => review.submitted_at ?? '')
.filter(Boolean)
.sort()[0];
const reviewHints: BotHint[] = reviews
.filter(review => review.commit_id === headSha && isBot(review.user?.login))
.map(review => ({
source: 'review',
author: review.user?.login ?? 'unknown',
when: review.submitted_at ?? '',
hints: extractActionableHints(review.body),
}))
.filter(item => item.hints.length > 0);
const commentHints: BotHint[] = comments
.filter(comment => {
if (comment.commit_id !== headSha || !isBot(comment.user?.login)) return false;
if (comment.body?.includes('Addressed in commit')) return false;
const when = comment.updated_at ?? comment.created_at ?? '';
return !earliestCurrentBotReview || when >= earliestCurrentBotReview;
})
.map(comment => {
const line = comment.line ?? comment.original_line ?? undefined;
const location = comment.path ? `${comment.path}${line ? `:${line}` : ''}` : undefined;
return {
source: 'comment',
author: comment.user?.login ?? 'unknown',
when: comment.updated_at ?? comment.created_at ?? '',
location,
hints: extractActionableHints(comment.body),
};
})
.filter(item => item.hints.length > 0);
return [...reviewHints, ...commentHints]
.sort((a, b) => b.when.localeCompare(a.when))
.slice(0, 8);
}
function summarizeRequiredChecks(protection: BranchProtection | undefined): string {
if (!protection) return 'unavailable';
const contexts = protection.required_status_checks?.contexts ?? [];
const checks = protection.required_status_checks?.checks
?.map(check => check.context)
.filter((context): context is string => Boolean(context)) ?? [];
const required = Array.from(new Set([...contexts, ...checks]));
if (required.length === 0) return 'none';
const strict = protection.required_status_checks?.strict ? 'strict' : 'not strict';
return `${required.length} (${strict}): ${required.join(', ')}`;
}
export function summarizeProtection(protection: BranchProtection | undefined): string[] {
if (!protection) return ['Branch protection: unavailable or not accessible'];
const reviews = protection.required_pull_request_reviews;
const approvalCount = reviews?.required_approving_review_count ?? 0;
return [
`Required checks: ${summarizeRequiredChecks(protection)}`,
`Required reviews: ${approvalCount || 'none'}${approvalCount ? ` approval${approvalCount === 1 ? '' : 's'}` : ''}`,
`Dismiss stale reviews: ${formatBool(reviews?.dismiss_stale_reviews)}`,
`Code owner reviews: ${formatBool(reviews?.require_code_owner_reviews)}`,
`Last-push approval: ${formatBool(reviews?.require_last_push_approval)}`,
`Conversation resolution: ${formatBool(protection.required_conversation_resolution?.enabled)}`,
`Signed commits: ${formatBool(protection.required_signatures?.enabled)}`,
`Enforce admins: ${formatBool(protection.enforce_admins?.enabled)}`,
`Allow force pushes: ${formatBool(protection.allow_force_pushes?.enabled)}`,
];
}
function printSection(title: string) {
console.log(`\n${title}`);
}
function printList(items: string[], empty: string) {
if (items.length === 0) {
console.log(` ${empty}`);
return;
}
for (const item of items) {
console.log(` - ${item}`);
}
}
function printChecks(checks: CheckRun[]) {
const groups = groupChecks(checks);
const order = ['fail', 'pending', 'pass', 'skipping', 'cancel'];
for (const bucket of order) {
const items = groups[bucket] ?? [];
console.log(` ${bucket}: ${items.length || 'none'}`);
for (const check of items) {
console.log(` - ${formatCheck(check)}`);
}
}
const known = new Set(order);
for (const bucket of Object.keys(groups).filter(bucket => !known.has(bucket)).sort()) {
console.log(` ${bucket}: ${groups[bucket].length}`);
for (const check of groups[bucket]) {
console.log(` - ${formatCheck(check)}`);
}
}
}
function usage() {
console.log(`
PR Babysit Status
Usage:
bun scripts/pr-babysit-status.ts [pr-number]
Without a PR number, gh resolves the PR for the current branch.
`);
}
export async function main(args = process.argv.slice(2)) {
if (args.includes('--help') || args.includes('-h')) {
usage();
return;
}
const prArg = args[0];
checkPrerequisites();
const pr = fetchPr(prArg);
const repo = fetchRepo();
const [checks, protection, reviews, comments] = await Promise.all([
Promise.resolve(fetchChecks(prArg)),
Promise.resolve(fetchBranchProtection(repo, pr.baseRefName)),
Promise.resolve(fetchReviews(repo, pr.number)),
Promise.resolve(fetchReviewComments(repo, pr.number)),
]);
const headReviews = currentHeadReviews(reviews, pr.headRefOid);
const hints = botHints(reviews, comments, pr.headRefOid);
console.log(`PR #${pr.number}: ${pr.title}`);
console.log(`URL: ${pr.url}`);
console.log(`Head: ${shortSha(pr.headRefOid)} (${pr.headRefOid})`);
console.log(`Base: ${pr.baseRefName}`);
console.log(`State: ${pr.state}; draft=${formatBool(pr.isDraft)}; mergeable=${pr.mergeable}; mergeStateStatus=${pr.mergeStateStatus}; reviewDecision=${pr.reviewDecision}`);
printSection(`Checks (${checks.length} current-head)`);
printChecks(checks);
printSection(`Branch Protection (${pr.baseRefName})`);
for (const line of summarizeProtection(protection)) {
console.log(` ${line}`);
}
printSection('Current-Head Reviews');
printList(
headReviews.map(review => {
const author = review.user?.login ?? 'unknown';
const summary = concise(review.body ?? '', 80);
const suffix = summary ? ` - ${summary}` : '';
return `${review.submitted_at ?? 'unknown time'} ${author}: ${review.state}${suffix}`;
}),
'none',
);
printSection('Actionable Bot Hints');
if (hints.length === 0) {
console.log(' none');
} else {
for (const hint of hints) {
const location = hint.location ? ` ${hint.location}` : '';
console.log(` - ${hint.when} ${hint.author} ${hint.source}${location}`);
for (const item of hint.hints) {
console.log(` ${item}`);
}
}
}
}
function isDirectRun(): boolean {
if (process.env.PR_BABYSIT_STATUS_NO_MAIN === '1') {
return false;
}
return Boolean(process.argv[1]) && import.meta.url === pathToFileURL(process.argv[1]).href;
}
if (isDirectRun()) {
main().catch(error => {
const message = error instanceof Error ? error.message : String(error);
console.error(`Error: ${message}`);
process.exit(1);
});
}
+16
View File
@@ -43,6 +43,14 @@ export function isWorkerUnavailableError(error: unknown): boolean {
return false;
}
export function isNonBlockingHookInputError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
const lower = message.toLowerCase();
return lower.includes('transcript path') &&
(lower.includes('missing') || lower.includes('does not exist'));
}
async function executeHookPipeline(
adapter: ReturnType<typeof getPlatformAdapter>,
handler: ReturnType<typeof getEventHandler>,
@@ -81,6 +89,14 @@ export async function hookCommand(platform: string, event: string, options: Hook
}
return HOOK_EXIT_CODES.SUCCESS;
}
if (isNonBlockingHookInputError(error)) {
logger.warn('HOOK', `Hook input unavailable, skipping hook: ${error instanceof Error ? error.message : error}`);
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
if (!options.skipExit) {
process.exit(HOOK_EXIT_CODES.SUCCESS);
}
return HOOK_EXIT_CODES.SUCCESS;
}
if (isWorkerUnavailableError(error)) {
logger.warn('HOOK', `Worker unavailable, skipping hook: ${error instanceof Error ? error.message : error}`);
if (!options.skipExit) {
+18 -2
View File
@@ -20,6 +20,9 @@ interface MarkerSchema {
installedAt?: string;
}
const LEGACY_VERSION_MARKER_RE =
/^v?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
function markerPath(targetDir: string): string {
return join(targetDir, '.install-version');
}
@@ -238,11 +241,22 @@ export async function installPluginDependencies(targetDir: string, bunPath: stri
export function readInstallMarker(targetDir: string): MarkerSchema | null {
const path = markerPath(targetDir);
if (!existsSync(path)) return null;
const content = readFileSync(path, 'utf-8');
try {
return JSON.parse(readFileSync(path, 'utf-8')) as MarkerSchema;
const marker = JSON.parse(content);
if (marker && typeof marker === 'object' && typeof marker.version === 'string') {
return marker as MarkerSchema;
}
} catch {
return null;
// Legacy installs wrote only the version string as plain text.
}
const legacyVersion = content.trim();
if (LEGACY_VERSION_MARKER_RE.test(legacyVersion)) {
return { version: legacyVersion.replace(/^v/i, '') };
}
return null;
}
export function writeInstallMarker(
@@ -266,6 +280,8 @@ export function isInstallCurrent(targetDir: string, expectedVersion: string): bo
if (!marker) return false;
if (marker.version !== expectedVersion) return false;
const currentBun = getBunVersion();
if (currentBun && !marker.bun) return false;
if (!currentBun && marker.bun) return false;
if (currentBun && marker.bun && currentBun !== marker.bun) return false;
return true;
}
+50 -32
View File
@@ -66,19 +66,19 @@ export class SessionStore {
this.addObservationModelColumns();
this.ensureMergedIntoProjectColumns();
this.addObservationSubagentColumns();
this.addPendingMessagesToolUseIdAndWorkerPidColumns();
this.addObservationsUniqueContentHashIndex();
this.addObservationsMetadataColumn();
this.dropDeadPendingMessagesColumns();
this.ensurePendingMessagesToolUseIdColumn();
this.dropWorkerPidColumn();
}
private dropWorkerPidColumn(): void {
const applied = this.db.prepare('SELECT version FROM schema_versions WHERE version = ?').get(32) as SchemaVersion | undefined;
if (applied) return;
const cols = this.db.query('PRAGMA table_info(pending_messages)').all() as TableColumnInfo[];
const hasColumn = cols.some(c => c.name === 'worker_pid');
if (applied && !hasColumn) return;
if (hasColumn) {
try {
@@ -87,35 +87,47 @@ export class SessionStore {
logger.debug('DB', 'Dropped worker_pid column and its index from pending_messages');
} catch (error) {
logger.warn('DB', 'Failed to drop worker_pid column from pending_messages', {}, error instanceof Error ? error : new Error(String(error)));
return;
}
}
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(32, new Date().toISOString());
if (!applied) {
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(32, new Date().toISOString());
}
}
private dropDeadPendingMessagesColumns(): void {
const applied = this.db.prepare('SELECT version FROM schema_versions WHERE version = ?').get(31) as SchemaVersion | undefined;
if (applied) return;
const cols = this.db.query('PRAGMA table_info(pending_messages)').all() as TableColumnInfo[];
const colNames = new Set(cols.map(c => c.name));
const deadColumns = ['retry_count', 'failed_at_epoch', 'completed_at_epoch', 'worker_pid'];
const deadColumns = ['retry_count', 'failed_at_epoch', 'completed_at_epoch'];
const toDrop = deadColumns.filter(name => colNames.has(name));
if (applied && toDrop.length === 0) return;
if (toDrop.length > 0) {
this.db.run(`DELETE FROM pending_messages WHERE status NOT IN ('pending', 'processing')`);
for (const colName of toDrop) {
try {
this.db.run('BEGIN TRANSACTION');
try {
this.db.run(`DELETE FROM pending_messages WHERE status NOT IN ('pending', 'processing')`);
for (const colName of toDrop) {
this.db.run(`ALTER TABLE pending_messages DROP COLUMN ${colName}`);
logger.debug('DB', `Dropped dead column ${colName} from pending_messages`);
} catch (error) {
logger.warn('DB', `Failed to drop column ${colName} from pending_messages`, {}, error instanceof Error ? error : new Error(String(error)));
}
if (!applied) {
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(31, new Date().toISOString());
}
this.db.run('COMMIT');
} catch (error) {
this.db.run('ROLLBACK');
logger.warn('DB', 'Failed to drop dead columns from pending_messages', {}, error instanceof Error ? error : new Error(String(error)));
return;
}
return;
}
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(31, new Date().toISOString());
if (!applied) {
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(31, new Date().toISOString());
}
}
private initializeSchema(): void {
@@ -899,7 +911,7 @@ export class SessionStore {
}
}
private addPendingMessagesToolUseIdAndWorkerPidColumns(): void {
private ensurePendingMessagesToolUseIdColumn(): void {
const tables = this.db.query(
"SELECT name FROM sqlite_master WHERE type='table' AND name='pending_messages'"
).all() as TableNameRow[];
@@ -910,29 +922,36 @@ export class SessionStore {
const cols = this.db.query('PRAGMA table_info(pending_messages)').all() as TableColumnInfo[];
const hasToolUseId = cols.some(c => c.name === 'tool_use_id');
const hasWorkerPid = cols.some(c => c.name === 'worker_pid');
if (!hasToolUseId) {
this.db.run('ALTER TABLE pending_messages ADD COLUMN tool_use_id TEXT');
}
if (!hasWorkerPid) {
this.db.run('ALTER TABLE pending_messages ADD COLUMN worker_pid INTEGER');
}
this.db.run('BEGIN TRANSACTION');
try {
this.db.run('CREATE INDEX IF NOT EXISTS idx_pending_messages_worker_pid ON pending_messages(worker_pid)');
this.db.run(`
DELETE FROM pending_messages
WHERE tool_use_id IS NOT NULL
AND id NOT IN (
SELECT MIN(id) FROM pending_messages
WHERE tool_use_id IS NOT NULL
GROUP BY content_session_id, tool_use_id
WHERE id IN (
SELECT id
FROM (
SELECT id,
ROW_NUMBER() OVER (
PARTITION BY content_session_id, tool_use_id
ORDER BY CASE status
WHEN 'processing' THEN 0
WHEN 'pending' THEN 1
ELSE 2
END, id
) AS duplicate_rank
FROM pending_messages
WHERE tool_use_id IS NOT NULL
)
WHERE duplicate_rank > 1
)
`);
this.db.run(`
-- tool_use_id is optional for summaries and legacy rows; enforce de-dupe
-- only for rows that came from a concrete tool-use event.
CREATE UNIQUE INDEX IF NOT EXISTS ux_pending_session_tool
ON pending_messages(content_session_id, tool_use_id)
WHERE tool_use_id IS NOT NULL
@@ -2061,16 +2080,15 @@ export class SessionStore {
summaryId = Number(result.lastInsertRowid);
}
const updateStmt = this.db.prepare(`
UPDATE pending_messages
SET
status = 'processed',
completed_at_epoch = ?,
tool_input = NULL,
tool_response = NULL
// Current queue rows are live work only; completed work is removed, not retained as processed.
const deleteStmt = this.db.prepare(`
DELETE FROM pending_messages
WHERE id = ? AND status = 'processing'
`);
updateStmt.run(timestampEpoch, messageId);
const deleteResult = deleteStmt.run(messageId);
if (deleteResult.changes !== 1) {
throw new Error(`storeObservationsAndMarkComplete: failed to complete pending message ${messageId}`);
}
return { observationIds, summaryId, createdAtEpoch: timestampEpoch };
});
+6 -6
View File
@@ -1,7 +1,8 @@
-- claude-mem SQLite schema
--
-- Authoritative shape of the database after all migrations through
-- runner.ts have been applied (current tip = migration 29). Fresh
-- runner.ts have been applied (current runner tip = migration 31;
-- SessionStore boot repair records migration 32). Fresh
-- databases boot directly into this shape; existing databases reach
-- it via the migration runner.
--
@@ -11,8 +12,8 @@
-- Invariants enforced here (Plan 01):
-- * pending_messages.UNIQUE(content_session_id, tool_use_id) — replaces
-- in-memory pendingTools Map for ingestion pairing (Plan 03 also depends).
-- * pending_messages.worker_pid INTEGER — populated by self-healing
-- claim query; replaces the legacy stale-reset epoch column.
-- * pending_messages only needs pending/processing status for current
-- claim handling; worker_pid and stale-reset epoch columns are legacy.
-- * observations.UNIQUE(memory_session_id, content_hash) — replaces the
-- legacy dedup window; ON CONFLICT DO NOTHING absorbs duplicates.
@@ -120,8 +121,8 @@ CREATE INDEX IF NOT EXISTS idx_summaries_merged_into ON session_summari
-- ─────────────────────────────────────────────────────────────────────
-- pending_messages: persistent work queue for SDK messages.
-- worker_pid + UNIQUE(content_session_id, tool_use_id) make the claim
-- query self-healing without any legacy stale-reset epoch column.
-- UNIQUE(content_session_id, tool_use_id) preserves ingestion pairing without
-- any legacy worker_pid or stale-reset epoch column.
-- ─────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pending_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -147,7 +148,6 @@ CREATE TABLE IF NOT EXISTS pending_messages (
CREATE INDEX IF NOT EXISTS idx_pending_messages_session ON pending_messages(session_db_id);
CREATE INDEX IF NOT EXISTS idx_pending_messages_status ON pending_messages(status);
CREATE INDEX IF NOT EXISTS idx_pending_messages_claude_session ON pending_messages(content_session_id);
CREATE INDEX IF NOT EXISTS idx_pending_messages_worker_pid ON pending_messages(worker_pid);
CREATE UNIQUE INDEX IF NOT EXISTS ux_pending_session_tool
ON pending_messages(content_session_id, tool_use_id)
WHERE tool_use_id IS NOT NULL;
+7 -8
View File
@@ -104,16 +104,15 @@ export function storeObservationsAndMarkComplete(
summaryId = Number(result.lastInsertRowid);
}
const updateStmt = db.prepare(`
UPDATE pending_messages
SET
status = 'processed',
completed_at_epoch = ?,
tool_input = NULL,
tool_response = NULL
// Current queue rows are live work only; completed work is removed, not retained as processed.
const deleteStmt = db.prepare(`
DELETE FROM pending_messages
WHERE id = ? AND status = 'processing'
`);
updateStmt.run(timestampEpoch, messageId);
const deleteResult = deleteStmt.run(messageId);
if (deleteResult.changes !== 1) {
throw new Error(`storeObservationsAndMarkComplete: failed to complete pending message ${messageId}`);
}
return { observationIds, summaryId, createdAtEpoch: timestampEpoch };
});
+11 -2
View File
@@ -53,9 +53,18 @@ const observationsBatchSchema = z.object({
project: z.string().optional(),
}).passthrough();
const sdkSessionsBatchSchema = z.object({
const sdkSessionsBatchSchema = z.preprocess((value) => {
if (!value || typeof value !== 'object' || Array.isArray(value)) return value;
const body = value as Record<string, unknown>;
if (body.memorySessionIds === undefined && body.sdkSessionIds !== undefined) {
return { ...body, memorySessionIds: body.sdkSessionIds };
}
return value;
}, z.object({
memorySessionIds: stringArrayLike,
}).passthrough();
}).passthrough());
const setProcessingSchema = z.object({}).passthrough();
@@ -11,6 +11,14 @@ export interface GeneratorExitDependencies {
restartGenerator: (session: ActiveSession, source: string) => void;
}
function isHardStopReason(reason: ActiveSession['abortReason']): boolean {
return reason === 'shutdown' ||
reason === 'restart-guard' ||
reason === 'overflow' ||
reason === 'quota' ||
(typeof reason === 'string' && reason.startsWith('quota:'));
}
/**
* Post-generator-exit handler. Under the new model:
* - 'processing' rows reset to 'pending' on next generator start (handled by SessionManager.getMessageIterator).
@@ -18,8 +26,8 @@ export interface GeneratorExitDependencies {
*
* Behavior:
* 1. Always: ensure SDK subprocess is dead.
* 2. Hard-stop reasons (shutdown / restart-guard): clear pending rows for the session and finalize.
* 3. Otherwise (idle / overflow / natural completion):
* 2. Hard-stop reasons (shutdown / restart-guard / overflow / quota): clear pending rows for the session and finalize.
* 3. Otherwise (idle / natural completion):
* - If 0 pending finalize.
* - If pending > 0 and restart guard allows respawn with backoff.
* - If guard tripped clear pending and finalize.
@@ -42,14 +50,39 @@ export async function handleGeneratorExit(
const pendingStore = sessionManager.getPendingMessageStore();
if (reason === 'shutdown' || reason === 'restart-guard') {
const terminateSession = (logPrefix: string, clearPending: boolean) => {
try {
if (clearPending) {
try {
pendingStore.clearPendingForSession(sessionDbId);
} catch (e) {
const normalized = e instanceof Error ? e : new Error(String(e));
logger.error('SESSION', `${logPrefix} pending cleanup failed; continuing finalization`, {
sessionId: sessionDbId,
reason
}, normalized);
}
}
try {
completionHandler.finalizeSession(sessionDbId);
} catch (e) {
const normalized = e instanceof Error ? e : new Error(String(e));
logger.error('SESSION', `${logPrefix} finalization failed; forcing in-memory session removal`, {
sessionId: sessionDbId,
reason
}, normalized);
}
} finally {
sessionManager.removeSessionImmediate(sessionDbId);
}
};
if (isHardStopReason(reason)) {
logger.info('SESSION', `Generator exited with hard-stop reason — clearing pending and finalizing`, {
sessionId: sessionDbId,
reason
});
pendingStore.clearPendingForSession(sessionDbId);
completionHandler.finalizeSession(sessionDbId);
sessionManager.removeSessionImmediate(sessionDbId);
terminateSession('Hard-stop', true);
return;
}
@@ -61,17 +94,14 @@ export async function handleGeneratorExit(
logger.error('SESSION', 'Error during recovery pending-count check; aborting to prevent leaks', {
sessionId: sessionDbId
}, normalized);
pendingStore.clearPendingForSession(sessionDbId);
completionHandler.finalizeSession(sessionDbId);
sessionManager.removeSessionImmediate(sessionDbId);
terminateSession('Recovery abort', true);
return;
}
if (pendingCount === 0) {
session.restartGuard?.recordSuccess();
session.consecutiveRestarts = 0;
completionHandler.finalizeSession(sessionDbId);
sessionManager.removeSessionImmediate(sessionDbId);
terminateSession('Natural completion', false);
return;
}
@@ -90,9 +120,7 @@ export async function handleGeneratorExit(
maxConsecutiveFailures: session.restartGuard.maxConsecutiveFailures,
});
session.consecutiveRestarts = 0;
pendingStore.clearPendingForSession(sessionDbId);
completionHandler.finalizeSession(sessionDbId);
sessionManager.removeSessionImmediate(sessionDbId);
terminateSession('Restart guard', true);
return;
}
+1 -1
View File
@@ -15,7 +15,7 @@ function getDirname(): string {
const _dirname = getDirname();
function resolveDataDir(): string {
export function resolveDataDir(): string {
if (process.env.CLAUDE_MEM_DATA_DIR) {
return process.env.CLAUDE_MEM_DATA_DIR;
}
+28 -1
View File
@@ -1,5 +1,32 @@
import { describe, it, expect } from 'bun:test';
import { isWorkerUnavailableError } from '../src/cli/hook-command.js';
import { isNonBlockingHookInputError, isWorkerUnavailableError } from '../src/cli/hook-command.js';
describe('isNonBlockingHookInputError', () => {
it('classifies missing transcript paths as non-blocking hook input errors', () => {
const error = new Error(
'Transcript path missing or file does not exist: /tmp/missing-session.jsonl'
);
expect(isNonBlockingHookInputError(error)).toBe(true);
});
it('classifies missing transcript-path errors without file-existence text', () => {
expect(
isNonBlockingHookInputError(new Error('Transcript path missing: /tmp/missing-session.jsonl'))
).toBe(true);
});
it('classifies nonexistent transcript-path errors without missing text', () => {
expect(
isNonBlockingHookInputError(new Error('Transcript path does not exist: /tmp/missing-session.jsonl'))
).toBe(true);
});
it('does not classify unrelated hook errors as non-blocking input errors', () => {
expect(isNonBlockingHookInputError(new Error('Cannot read properties of undefined'))).toBe(false);
expect(isNonBlockingHookInputError(new Error('Request failed: 400'))).toBe(false);
});
});
describe('isWorkerUnavailableError', () => {
describe('transport failures → true (graceful)', () => {
@@ -6,6 +6,26 @@ import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, '../..');
function readJson(relativePath: string): any {
return JSON.parse(readFileSync(path.join(projectRoot, relativePath), 'utf-8'));
}
function commandHooksFrom(relativePath: string): string[] {
const parsed = readJson(relativePath);
return Object.values(parsed.hooks ?? {}).flatMap((matchers: any) =>
matchers.flatMap((matcher: any) =>
(matcher.hooks ?? [])
.filter((hook: any) => hook.type === 'command')
.map((hook: any) => String(hook.command ?? ''))
)
);
}
function mcpStartupCommandFrom(relativePath: string): string {
const parsed = readJson(relativePath);
return parsed.mcpServers['mcp-search'].args[1];
}
describe('Plugin Distribution - Skills', () => {
const skillPath = path.join(projectRoot, 'plugin/skills/mem-search/SKILL.md');
@@ -58,61 +78,83 @@ describe('Plugin Distribution - hooks.json Integrity', () => {
});
it('should reference CLAUDE_PLUGIN_ROOT in all hook commands', () => {
const hooksPath = path.join(projectRoot, 'plugin/hooks/hooks.json');
const parsed = JSON.parse(readFileSync(hooksPath, 'utf-8'));
for (const [eventName, matchers] of Object.entries(parsed.hooks)) {
for (const matcher of matchers as any[]) {
for (const hook of matcher.hooks) {
if (hook.type === 'command') {
expect(hook.command).toContain('${CLAUDE_PLUGIN_ROOT}');
}
}
}
for (const command of commandHooksFrom('plugin/hooks/hooks.json')) {
expect(command).toContain('CLAUDE_PLUGIN_ROOT');
}
});
it('should include CLAUDE_PLUGIN_ROOT fallback in all hook commands (#1215)', () => {
const hooksPath = path.join(projectRoot, 'plugin/hooks/hooks.json');
const parsed = JSON.parse(readFileSync(hooksPath, 'utf-8'));
const expectedFallbackPath = '$HOME/.claude/plugins/marketplaces/thedotmack/plugin';
const expectedFallbackPath = '$_C/plugins/marketplaces/thedotmack/plugin';
for (const [eventName, matchers] of Object.entries(parsed.hooks)) {
for (const matcher of matchers as any[]) {
for (const hook of matcher.hooks) {
if (hook.type === 'command') {
expect(hook.command).toContain(expectedFallbackPath);
}
}
}
for (const command of commandHooksFrom('plugin/hooks/hooks.json')) {
expect(command).toContain(expectedFallbackPath);
}
});
it('should try cache path before marketplaces fallback in all hook commands (#1533)', () => {
const hooksPath = path.join(projectRoot, 'plugin/hooks/hooks.json');
const parsed = JSON.parse(readFileSync(hooksPath, 'utf-8'));
const cachePath = '$HOME/.claude/plugins/cache/thedotmack/claude-mem';
const marketplacesPath = '$HOME/.claude/plugins/marketplaces/thedotmack/plugin';
const cachePath = '$_C/plugins/cache/thedotmack/claude-mem';
const marketplacesPath = '$_C/plugins/marketplaces/thedotmack/plugin';
for (const [eventName, matchers] of Object.entries(parsed.hooks)) {
for (const matcher of matchers as any[]) {
for (const hook of matcher.hooks) {
if (hook.type === 'command') {
expect(hook.command).toContain(cachePath);
expect(hook.command.indexOf(cachePath)).toBeLessThan(hook.command.indexOf(marketplacesPath));
}
}
}
for (const command of commandHooksFrom('plugin/hooks/hooks.json')) {
expect(command).toContain(cachePath);
expect(command.indexOf(cachePath)).toBeLessThan(command.indexOf(marketplacesPath));
}
});
});
describe('Plugin Distribution - Startup Root Resolution', () => {
it('MCP startup commands should have config-dir based non-empty fallbacks', () => {
for (const relativePath of ['.mcp.json', 'plugin/.mcp.json']) {
const command = mcpStartupCommandFrom(relativePath);
expect(command).toContain('${CLAUDE_CONFIG_DIR:-$HOME/.claude}');
expect(command).toContain('_E="${CLAUDE_PLUGIN_ROOT:-${PLUGIN_ROOT:-}}"');
expect(command).toContain('while IFS= read -r _R');
expect(command).toContain('$_C/plugins/marketplaces/thedotmack/plugin');
expect(command).toContain('$_C/plugins/cache/thedotmack/claude-mem');
expect(command).toContain('[ -f "$_Q/scripts/mcp-server.cjs" ]');
expect(command).not.toContain('"/scripts/mcp-server.cjs"');
expect(command.indexOf('$_C/plugins/cache/thedotmack/claude-mem')).toBeLessThan(
command.indexOf('$_C/plugins/marketplaces/thedotmack/plugin')
);
}
});
it('Codex hook commands should have config-dir based non-empty fallbacks', () => {
for (const command of commandHooksFrom('plugin/hooks/codex-hooks.json')) {
expect(command).toContain('${CLAUDE_CONFIG_DIR:-$HOME/.claude}');
expect(command).toContain('export PATH=');
expect(command).toContain('while IFS= read -r _R');
expect(command).toContain('$_C/plugins/marketplaces/thedotmack/plugin');
expect(command).toContain('$_C/plugins/cache/thedotmack/claude-mem');
expect(command).toContain('[ -f "$_Q/scripts/');
expect(command).toContain('command -v cygpath');
expect(command.indexOf('$_C/plugins/cache/thedotmack/claude-mem')).toBeLessThan(
command.indexOf('$_C/plugins/marketplaces/thedotmack/plugin')
);
}
});
it('Claude hook commands should have config-dir based non-empty fallbacks', () => {
for (const command of commandHooksFrom('plugin/hooks/hooks.json')) {
expect(command).toContain('${CLAUDE_CONFIG_DIR:-$HOME/.claude}');
expect(command).toContain('while IFS= read -r _R');
expect(command).toContain('$_C/plugins/marketplaces/thedotmack/plugin');
expect(command).toContain('$_C/plugins/cache/thedotmack/claude-mem');
expect(command).toContain('[ -f "$_Q/scripts/');
expect(command).not.toContain('$HOME/.claude/plugins/');
}
});
});
describe('Plugin Distribution - package.json Files Field', () => {
it('should include "plugin" in root package.json files field', () => {
it('should include plugin distribution files in root package.json files field', () => {
const packageJsonPath = path.join(projectRoot, 'package.json');
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
expect(packageJson.files).toBeDefined();
expect(packageJson.files).toContain('plugin');
expect(packageJson.files).toContain('plugin/hooks');
expect(packageJson.files).toContain('plugin/.mcp.json');
expect(packageJson.files).toContain('plugin/skills');
});
});
+65
View File
@@ -0,0 +1,65 @@
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import { mkdirSync, writeFileSync, rmSync } from 'fs';
import { spawnSync } from 'child_process';
import { join } from 'path';
import { tmpdir } from 'os';
const VERSION_CHECK_SCRIPT = join(import.meta.dir, '..', 'plugin', 'scripts', 'version-check.js');
function runVersionCheck(root: string) {
const env = { ...process.env, CLAUDE_PLUGIN_ROOT: root };
delete env.CLAUDE_MEM_CODEX_HOOK;
return spawnSync('node', [VERSION_CHECK_SCRIPT], {
encoding: 'utf-8',
env,
});
}
describe('plugin/scripts/version-check.js install marker compatibility', () => {
let tempDir: string;
beforeEach(() => {
tempDir = join(
tmpdir(),
`version-check-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
mkdirSync(tempDir, { recursive: true });
writeFileSync(join(tempDir, 'package.json'), JSON.stringify({ version: '12.4.4' }));
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
it('accepts a matching legacy plain-text marker without an upgrade hint', () => {
writeFileSync(join(tempDir, '.install-version'), '12.4.4\n');
const result = runVersionCheck(tempDir);
expect(result.status).toBe(0);
expect(result.stdout).toBe('');
expect(result.stderr).toBe('');
});
it('accepts a matching legacy plain-text marker with a leading v', () => {
writeFileSync(join(tempDir, '.install-version'), 'v12.4.4\n');
const result = runVersionCheck(tempDir);
expect(result.status).toBe(0);
expect(result.stdout).toBe('');
expect(result.stderr).toBe('');
});
it('emits an upgrade hint for a mismatched legacy plain-text marker', () => {
writeFileSync(join(tempDir, '.install-version'), '12.4.3\n');
const result = runVersionCheck(tempDir);
expect(result.status).toBe(0);
expect(result.stderr).toContain(
'claude-mem: upgraded to v12.4.4 - run: npx claude-mem@latest install',
);
});
});
+228
View File
@@ -0,0 +1,228 @@
import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test';
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
const originalFetch = globalThis.fetch;
const originalDataDir = process.env.CLAUDE_MEM_DATA_DIR;
const originalNoMain = process.env.CLAUDE_MEM_EXPORT_MEMORIES_NO_MAIN;
describe('export-memories script', () => {
let tempDir: string | undefined;
const consoleSpies: ReturnType<typeof spyOn>[] = [];
afterEach(() => {
globalThis.fetch = originalFetch;
if (originalDataDir === undefined) {
delete process.env.CLAUDE_MEM_DATA_DIR;
} else {
process.env.CLAUDE_MEM_DATA_DIR = originalDataDir;
}
if (originalNoMain === undefined) {
delete process.env.CLAUDE_MEM_EXPORT_MEMORIES_NO_MAIN;
} else {
process.env.CLAUDE_MEM_EXPORT_MEMORIES_NO_MAIN = originalNoMain;
}
consoleSpies.splice(0).forEach(spy => spy.mockRestore());
if (tempDir && existsSync(tempDir)) {
rmSync(tempDir, { recursive: true, force: true });
}
tempDir = undefined;
mock.restore();
});
it('loads settings from CLAUDE_MEM_DATA_DIR and sends canonical memorySessionIds', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'claude-mem-export-'));
process.env.CLAUDE_MEM_DATA_DIR = tempDir;
process.env.CLAUDE_MEM_EXPORT_MEMORIES_NO_MAIN = '1';
writeFileSync(join(tempDir, 'settings.json'), JSON.stringify({
CLAUDE_MEM_WORKER_PORT: '45678',
}));
consoleSpies.push(
spyOn(console, 'log').mockImplementation(() => {}),
spyOn(console, 'warn').mockImplementation(() => {}),
spyOn(console, 'error').mockImplementation(() => {}),
);
let batchBody: unknown;
let searchSignal: unknown;
let batchSignal: unknown;
const fetchMock = mock(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.startsWith('http://localhost:45678/api/search?')) {
searchSignal = init?.signal;
return new Response(JSON.stringify({
observations: [
{ memory_session_id: 'memory-a' },
{ memory_session_id: 'memory-b' },
],
sessions: [
{ memory_session_id: 'memory-a' },
],
prompts: [],
}), { status: 200 });
}
if (url === 'http://localhost:45678/api/sdk-sessions/batch') {
batchSignal = init?.signal;
batchBody = JSON.parse(String(init?.body));
return new Response(JSON.stringify([
{ memory_session_id: 'memory-a' },
{ memory_session_id: 'memory-b' },
]), { status: 200 });
}
return new Response('unexpected url', { status: 500 });
});
globalThis.fetch = fetchMock as typeof fetch;
const { exportMemories } = await import('../../scripts/export-memories.ts');
const outputFile = join(tempDir, 'export.json');
await exportMemories('needle', outputFile, 'project-a');
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(searchSignal).toBeInstanceOf(AbortSignal);
expect(batchSignal).toBeInstanceOf(AbortSignal);
expect(batchBody).toEqual({ memorySessionIds: ['memory-a', 'memory-b'] });
expect(batchBody).not.toHaveProperty('sdkSessionIds');
const exported = JSON.parse(readFileSync(outputFile, 'utf-8'));
expect(exported.query).toBe('needle');
expect(exported.project).toBe('project-a');
expect(exported.totalSessions).toBe(2);
});
it('rejects an invalid worker port before fetching', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'claude-mem-export-'));
process.env.CLAUDE_MEM_DATA_DIR = tempDir;
process.env.CLAUDE_MEM_EXPORT_MEMORIES_NO_MAIN = '1';
writeFileSync(join(tempDir, 'settings.json'), JSON.stringify({
CLAUDE_MEM_WORKER_PORT: '45678abc',
}));
const fetchMock = mock(async () => new Response('{}', { status: 200 }));
globalThis.fetch = fetchMock as typeof fetch;
const { exportMemories } = await import('../../scripts/export-memories.ts');
await expect(exportMemories('needle', join(tempDir, 'export.json'))).rejects.toThrow(
'Invalid CLAUDE_MEM_WORKER_PORT',
);
expect(fetchMock).not.toHaveBeenCalled();
});
it('rejects an empty worker port with a clear configuration error', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'claude-mem-export-'));
process.env.CLAUDE_MEM_DATA_DIR = tempDir;
process.env.CLAUDE_MEM_EXPORT_MEMORIES_NO_MAIN = '1';
writeFileSync(join(tempDir, 'settings.json'), JSON.stringify({
CLAUDE_MEM_WORKER_PORT: '',
}));
const fetchMock = mock(async () => new Response('{}', { status: 200 }));
globalThis.fetch = fetchMock as typeof fetch;
const { exportMemories } = await import('../../scripts/export-memories.ts');
await expect(exportMemories('needle', join(tempDir, 'export.json'))).rejects.toThrow(
'Invalid CLAUDE_MEM_WORKER_PORT in settings.json: missing',
);
expect(fetchMock).not.toHaveBeenCalled();
});
it('rejects a non-string worker port with a clear configuration error', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'claude-mem-export-'));
process.env.CLAUDE_MEM_DATA_DIR = tempDir;
process.env.CLAUDE_MEM_EXPORT_MEMORIES_NO_MAIN = '1';
writeFileSync(join(tempDir, 'settings.json'), JSON.stringify({
CLAUDE_MEM_WORKER_PORT: 45678,
}));
const fetchMock = mock(async () => new Response('{}', { status: 200 }));
globalThis.fetch = fetchMock as typeof fetch;
const { exportMemories } = await import('../../scripts/export-memories.ts');
await expect(exportMemories('needle', join(tempDir, 'export.json'))).rejects.toThrow(
'Invalid CLAUDE_MEM_WORKER_PORT in settings.json: missing',
);
expect(fetchMock).not.toHaveBeenCalled();
});
it('fails the export when SDK session metadata cannot be fetched', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'claude-mem-export-'));
process.env.CLAUDE_MEM_DATA_DIR = tempDir;
process.env.CLAUDE_MEM_EXPORT_MEMORIES_NO_MAIN = '1';
writeFileSync(join(tempDir, 'settings.json'), JSON.stringify({
CLAUDE_MEM_WORKER_PORT: '45678',
}));
consoleSpies.push(
spyOn(console, 'log').mockImplementation(() => {}),
spyOn(console, 'warn').mockImplementation(() => {}),
spyOn(console, 'error').mockImplementation(() => {}),
);
const fetchMock = mock(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.startsWith('http://localhost:45678/api/search?')) {
return new Response(JSON.stringify({
observations: [{ memory_session_id: 'memory-a' }],
sessions: [],
prompts: [],
}), { status: 200 });
}
if (url === 'http://localhost:45678/api/sdk-sessions/batch') {
return new Response('worker unavailable', {
status: 503,
statusText: 'Service Unavailable',
});
}
return new Response('unexpected url', { status: 500 });
});
globalThis.fetch = fetchMock as typeof fetch;
const { exportMemories } = await import('../../scripts/export-memories.ts');
const outputFile = join(tempDir, 'export.json');
await expect(exportMemories('needle', outputFile)).rejects.toThrow(
'Failed to fetch SDK sessions: 503 Service Unavailable worker unavailable',
);
expect(existsSync(outputFile)).toBe(false);
});
it('fails deterministically when a worker request times out', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'claude-mem-export-'));
process.env.CLAUDE_MEM_DATA_DIR = tempDir;
process.env.CLAUDE_MEM_EXPORT_MEMORIES_NO_MAIN = '1';
writeFileSync(join(tempDir, 'settings.json'), JSON.stringify({
CLAUDE_MEM_WORKER_PORT: '45678',
}));
consoleSpies.push(
spyOn(console, 'log').mockImplementation(() => {}),
spyOn(console, 'warn').mockImplementation(() => {}),
spyOn(console, 'error').mockImplementation(() => {}),
);
const fetchMock = mock(async () => {
throw new DOMException('The operation was aborted.', 'AbortError');
});
globalThis.fetch = fetchMock as typeof fetch;
const { exportMemories } = await import('../../scripts/export-memories.ts');
await expect(exportMemories('needle', join(tempDir, 'export.json'))).rejects.toThrow(
'Worker request timed out after 30000ms',
);
});
});
+68
View File
@@ -0,0 +1,68 @@
import { describe, expect, it } from 'bun:test';
process.env.PR_BABYSIT_STATUS_NO_MAIN = '1';
describe('pr-babysit-status helpers', () => {
it('extracts concise actionable hints from bot review bodies', async () => {
const { extractActionableHints } = await import('../../scripts/pr-babysit-status.ts');
const hints = extractActionableHints(`
**Actionable comments posted: 2**
<details>
<summary>Prompt for all review comments with AI agents</summary>
Inline comments:
In \`@src/file.ts\`:
- Line 10: Replace the unsafe fallback with a checked path.
- Around line 22: Treat a missing binary as stale.
</details>
`);
expect(hints).toContain('Actionable comments posted: 2');
expect(hints).toContain('10: Replace the unsafe fallback with a checked path.');
expect(hints).toContain('22: Treat a missing binary as stale.');
expect(hints.join('\n')).not.toContain('Prompt for all review comments');
});
it('extracts review comment headings without dumping full markdown', async () => {
const { extractActionableHints } = await import('../../scripts/pr-babysit-status.ts');
const hints = extractActionableHints(`
_Potential issue_ | _Major_ | _Quick win_
**Treat a missing current Bun binary as stale too.**
If the marker says this install was created with Bun but getBunVersion now
returns null, this still reports the install as current and skips repair.
`);
expect(hints).toContain('Treat a missing current Bun binary as stale too.');
expect(hints.some(hint => hint.includes('skips repair'))).toBe(false);
});
it('summarizes branch protection without requiring unavailable fields', async () => {
const { summarizeProtection } = await import('../../scripts/pr-babysit-status.ts');
expect(summarizeProtection({
required_pull_request_reviews: {
dismiss_stale_reviews: true,
require_code_owner_reviews: false,
require_last_push_approval: false,
required_approving_review_count: 1,
},
enforce_admins: { enabled: false },
allow_force_pushes: { enabled: true },
})).toEqual([
'Required checks: none',
'Required reviews: 1 approval',
'Dismiss stale reviews: yes',
'Code owner reviews: no',
'Last-push approval: no',
'Conversation resolution: no',
'Signed commits: no',
'Enforce admins: no',
'Allow force pushes: yes',
]);
});
});
+292 -101
View File
@@ -1,124 +1,315 @@
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import { ClaudeMemDatabase } from '../../../src/services/sqlite/Database.js';
import { PendingMessageStore } from '../../../src/services/sqlite/PendingMessageStore.js';
import { createSDKSession } from '../../../src/services/sqlite/Sessions.js';
import type { PendingMessage } from '../../../src/services/worker-types.js';
import { describe, test, expect } from 'bun:test';
import type { Database } from 'bun:sqlite';
import { SessionStore } from '../../../src/services/sqlite/SessionStore.js';
import { PendingMessageStore } from '../../../src/services/sqlite/PendingMessageStore.js';
import type { PendingMessage } from '../../../src/services/worker-types.js';
describe('PendingMessageStore - Self-Healing claimNextMessage', () => {
let db: Database;
let store: PendingMessageStore;
let sessionDbId: number;
const CONTENT_SESSION_ID = 'test-self-heal';
function getColumnNames(db: Database, table: string): string[] {
const quotedTable = `"${table.replace(/"/g, '""')}"`;
return (db.prepare(`PRAGMA table_info(${quotedTable})`).all() as { name: string }[])
.map(column => column.name);
}
beforeEach(() => {
db = new ClaudeMemDatabase(':memory:').db;
store = new PendingMessageStore(db, 3);
sessionDbId = createSDKSession(db, CONTENT_SESSION_ID, 'test-project', 'Test prompt');
function getIndexNames(db: Database, table: string): string[] {
const quotedTable = `"${table.replace(/"/g, '""')}"`;
return (db.prepare(`PRAGMA index_list(${quotedTable})`).all() as { name: string }[])
.map(index => index.name);
}
function rebuildPendingMessagesWithoutToolUseId(db: Database): void {
db.run('DROP INDEX IF EXISTS ux_pending_session_tool');
db.run('DROP INDEX IF EXISTS idx_pending_messages_worker_pid');
db.run('DROP TABLE IF EXISTS pending_messages_without_tool_use_id');
db.run(`
CREATE TABLE pending_messages_without_tool_use_id (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_db_id INTEGER NOT NULL,
content_session_id TEXT NOT NULL,
message_type TEXT NOT NULL CHECK(message_type IN ('observation', 'summarize')),
tool_name TEXT,
tool_input TEXT,
tool_response TEXT,
cwd TEXT,
last_user_message TEXT,
last_assistant_message TEXT,
prompt_number INTEGER,
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'processing')),
created_at_epoch INTEGER NOT NULL,
agent_type TEXT,
agent_id TEXT,
FOREIGN KEY (session_db_id) REFERENCES sdk_sessions(id) ON DELETE CASCADE
)
`);
db.run(`
INSERT INTO pending_messages_without_tool_use_id (
id, session_db_id, content_session_id, message_type, tool_name,
tool_input, tool_response, cwd, last_user_message,
last_assistant_message, prompt_number, status, created_at_epoch,
agent_type, agent_id
)
SELECT
id, session_db_id, content_session_id, message_type, tool_name,
tool_input, tool_response, cwd, last_user_message,
last_assistant_message, prompt_number, status, created_at_epoch,
agent_type, agent_id
FROM pending_messages
`);
db.run('DROP TABLE pending_messages');
db.run('ALTER TABLE pending_messages_without_tool_use_id RENAME TO pending_messages');
db.run('CREATE INDEX IF NOT EXISTS idx_pending_messages_session ON pending_messages(session_db_id)');
db.run('CREATE INDEX IF NOT EXISTS idx_pending_messages_status ON pending_messages(status)');
db.run('CREATE INDEX IF NOT EXISTS idx_pending_messages_claude_session ON pending_messages(content_session_id)');
}
function rebuildLegacyPendingMessagesWithDeadColumns(db: Database): void {
db.run('DROP INDEX IF EXISTS ux_pending_session_tool');
db.run('DROP INDEX IF EXISTS idx_pending_messages_worker_pid');
db.run('DROP TABLE pending_messages');
db.run(`
CREATE TABLE pending_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_db_id INTEGER NOT NULL,
content_session_id TEXT NOT NULL,
message_type TEXT NOT NULL,
tool_name TEXT,
tool_input TEXT,
tool_response TEXT,
cwd TEXT,
last_user_message TEXT,
last_assistant_message TEXT,
prompt_number INTEGER,
status TEXT NOT NULL DEFAULT 'pending',
retry_count INTEGER NOT NULL DEFAULT 0,
failed_at_epoch INTEGER,
completed_at_epoch INTEGER,
created_at_epoch INTEGER NOT NULL,
agent_type TEXT,
agent_id TEXT,
tool_use_id TEXT,
worker_pid INTEGER,
FOREIGN KEY (session_db_id) REFERENCES sdk_sessions(id) ON DELETE CASCADE
)
`);
db.run('CREATE INDEX IF NOT EXISTS idx_pending_messages_session ON pending_messages(session_db_id)');
db.run('CREATE INDEX IF NOT EXISTS idx_pending_messages_status ON pending_messages(status)');
db.run('CREATE INDEX IF NOT EXISTS idx_pending_messages_claude_session ON pending_messages(content_session_id)');
db.run('CREATE INDEX IF NOT EXISTS idx_pending_messages_worker_pid ON pending_messages(worker_pid)');
}
function createPendingMessage(overrides: Partial<PendingMessage> = {}): PendingMessage {
return {
type: 'observation',
tool_name: 'TestTool',
tool_input: { test: 'input' },
tool_response: { test: 'response' },
prompt_number: 1,
...overrides,
};
}
describe('PendingMessageStore current schema guardrails', () => {
test('SessionStore repairs missing tool_use_id even when schema_versions says pending migrations already ran', () => {
const initialStore = new SessionStore(':memory:');
const db = initialStore.db;
rebuildPendingMessagesWithoutToolUseId(db);
const repairedStore = new SessionStore(db);
try {
const columns = getColumnNames(db, 'pending_messages');
expect(columns).toContain('tool_use_id');
expect(columns).not.toContain('worker_pid');
const sessionDbId = repairedStore.createSDKSession('content-shape-repair', 'test-project', 'initial prompt');
const pendingStore = new PendingMessageStore(db, () => {});
pendingStore.enqueue(sessionDbId, 'content-shape-repair', createPendingMessage({ toolUseId: 'tool-1' }));
pendingStore.enqueue(sessionDbId, 'content-shape-repair', createPendingMessage({ toolUseId: 'tool-1' }));
const count = db.prepare(`
SELECT COUNT(*) AS count
FROM pending_messages
WHERE content_session_id = ?
`).get('content-shape-repair') as { count: number };
expect(count.count).toBe(1);
} finally {
repairedStore.close();
}
});
afterEach(() => {
db.close();
test('SessionStore removes stale duplicate rows before creating the tool_use_id unique index', () => {
const initialStore = new SessionStore(':memory:');
const db = initialStore.db;
const sessionDbId = initialStore.createSDKSession('content-stale-dedupe', 'test-project', 'initial prompt');
rebuildLegacyPendingMessagesWithDeadColumns(db);
db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(31, new Date().toISOString());
db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(32, new Date().toISOString());
db.prepare(`
INSERT INTO pending_messages (
id, session_db_id, content_session_id, message_type, status,
created_at_epoch, tool_use_id, completed_at_epoch
)
VALUES (?, ?, ?, 'observation', ?, ?, ?, ?)
`).run(1, sessionDbId, 'content-stale-dedupe', 'completed', 1000, 'tool-stale', 1100);
db.prepare(`
INSERT INTO pending_messages (
id, session_db_id, content_session_id, message_type, status,
created_at_epoch, tool_use_id
)
VALUES (?, ?, ?, 'observation', ?, ?, ?)
`).run(2, sessionDbId, 'content-stale-dedupe', 'pending', 1200, 'tool-stale');
const repairedStore = new SessionStore(db);
try {
const rows = db.prepare(`
SELECT id, status, tool_use_id
FROM pending_messages
WHERE content_session_id = ?
`).all('content-stale-dedupe') as { id: number; status: string; tool_use_id: string }[];
expect(rows).toEqual([{ id: 2, status: 'pending', tool_use_id: 'tool-stale' }]);
expect(getColumnNames(db, 'pending_messages')).not.toContain('completed_at_epoch');
expect(getColumnNames(db, 'pending_messages')).not.toContain('worker_pid');
expect(getIndexNames(db, 'pending_messages')).toContain('ux_pending_session_tool');
} finally {
repairedStore.close();
}
});
function enqueueMessage(overrides: Partial<PendingMessage> = {}): number {
const message: PendingMessage = {
type: 'observation',
tool_name: 'TestTool',
tool_input: { test: 'input' },
tool_response: { test: 'response' },
prompt_number: 1,
...overrides,
test('SessionStore preserves processing duplicate rows during tool_use_id dedupe', () => {
const initialStore = new SessionStore(':memory:');
const db = initialStore.db;
const sessionDbId = initialStore.createSDKSession('content-processing-dedupe', 'test-project', 'initial prompt');
rebuildLegacyPendingMessagesWithDeadColumns(db);
db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(31, new Date().toISOString());
db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(32, new Date().toISOString());
db.prepare(`
INSERT INTO pending_messages (
id, session_db_id, content_session_id, message_type, status,
created_at_epoch, tool_use_id
)
VALUES (?, ?, ?, 'observation', ?, ?, ?)
`).run(1, sessionDbId, 'content-processing-dedupe', 'pending', 1000, 'tool-in-flight');
db.prepare(`
INSERT INTO pending_messages (
id, session_db_id, content_session_id, message_type, status,
created_at_epoch, tool_use_id
)
VALUES (?, ?, ?, 'observation', ?, ?, ?)
`).run(2, sessionDbId, 'content-processing-dedupe', 'processing', 1100, 'tool-in-flight');
const repairedStore = new SessionStore(db);
try {
const rows = db.prepare(`
SELECT id, status, tool_use_id
FROM pending_messages
WHERE content_session_id = ?
`).all('content-processing-dedupe') as { id: number; status: string; tool_use_id: string }[];
expect(rows).toEqual([{ id: 2, status: 'processing', tool_use_id: 'tool-in-flight' }]);
} finally {
repairedStore.close();
}
});
test('SessionStore does not stamp dead-column cleanup when a drop fails', () => {
const initialStore = new SessionStore(':memory:');
const db = initialStore.db;
const sessionDbId = initialStore.createSDKSession('content-drop-failure', 'test-project', 'initial prompt');
rebuildLegacyPendingMessagesWithDeadColumns(db);
db.prepare('DELETE FROM schema_versions WHERE version IN (31, 32)').run();
db.prepare(`
INSERT INTO pending_messages (
id, session_db_id, content_session_id, message_type, status,
created_at_epoch, tool_use_id, completed_at_epoch
)
VALUES (?, ?, ?, 'observation', 'completed', ?, ?, ?)
`).run(1, sessionDbId, 'content-drop-failure', 1000, 'tool-completed', 1100);
const originalRun = db.run.bind(db);
(db as any).run = (query: string, ...bindings: unknown[]) => {
if (query.includes('ALTER TABLE pending_messages DROP COLUMN completed_at_epoch')) {
throw new Error('simulated drop failure');
}
return originalRun(query, ...bindings);
};
return store.enqueue(sessionDbId, CONTENT_SESSION_ID, message);
}
function makeMessageStaleProcessing(messageId: number): void {
const staleTimestamp = Date.now() - 120_000;
db.run(
`UPDATE pending_messages SET status = 'processing', started_processing_at_epoch = ? WHERE id = ?`,
[staleTimestamp, messageId]
);
}
const repairedStore = new SessionStore(db);
try {
const version31 = db
.prepare('SELECT version FROM schema_versions WHERE version = ?')
.get(31);
test('stuck processing messages are recovered on next claim', () => {
const msgId = enqueueMessage();
makeMessageStaleProcessing(msgId);
const beforeClaim = db.query('SELECT status FROM pending_messages WHERE id = ?').get(msgId) as { status: string };
expect(beforeClaim.status).toBe('processing');
const claimed = store.claimNextMessage(sessionDbId);
expect(claimed).not.toBeNull();
expect(claimed!.id).toBe(msgId);
const afterClaim = db.query('SELECT status FROM pending_messages WHERE id = ?').get(msgId) as { status: string };
expect(afterClaim.status).toBe('processing');
expect(version31).toBeNull();
expect(getColumnNames(db, 'pending_messages')).toContain('completed_at_epoch');
const rowCount = db.prepare(`
SELECT COUNT(*) AS count
FROM pending_messages
WHERE content_session_id = ? AND status = 'completed'
`).get('content-drop-failure') as { count: number };
expect(rowCount.count).toBe(1);
} finally {
(db as any).run = originalRun;
repairedStore.close();
}
});
test('actively processing messages are NOT recovered', () => {
const activeId = enqueueMessage();
const pendingId = enqueueMessage();
test('SessionStore keeps null tool_use_id rows because summaries and legacy rows may not have tool ids', () => {
const store = new SessionStore(':memory:');
const db = store.db;
const sessionDbId = store.createSDKSession('content-null-tool', 'test-project', 'initial prompt');
const recentTimestamp = Date.now() - 5_000;
db.run(
`UPDATE pending_messages SET status = 'processing', started_processing_at_epoch = ? WHERE id = ?`,
[recentTimestamp, activeId]
);
try {
db.prepare(`
INSERT INTO pending_messages (
session_db_id, content_session_id, message_type, status, created_at_epoch, tool_use_id
)
VALUES (?, ?, 'summarize', 'pending', ?, NULL)
`).run(sessionDbId, 'content-null-tool', 1000);
const claimed = store.claimNextMessage(sessionDbId);
db.prepare(`
INSERT INTO pending_messages (
session_db_id, content_session_id, message_type, status, created_at_epoch, tool_use_id
)
VALUES (?, ?, 'summarize', 'pending', ?, NULL)
`).run(sessionDbId, 'content-null-tool', 1001);
expect(claimed).not.toBeNull();
expect(claimed!.id).toBe(pendingId);
const rows = db.prepare(`
SELECT COUNT(*) AS count
FROM pending_messages
WHERE content_session_id = ? AND tool_use_id IS NULL
`).get('content-null-tool') as { count: number };
const activeMsg = db.query('SELECT status FROM pending_messages WHERE id = ?').get(activeId) as { status: string };
expect(activeMsg.status).toBe('processing');
expect(rows.count).toBe(2);
} finally {
store.close();
}
});
test('recovery and claim is atomic within single call', () => {
const stuckId = enqueueMessage();
const pendingId1 = enqueueMessage();
const pendingId2 = enqueueMessage();
test('fresh SessionStore pending_messages shape does not require worker_pid for enqueue and claim', () => {
const store = new SessionStore(':memory:');
try {
const db = store.db;
const columns = getColumnNames(db, 'pending_messages');
const indexes = getIndexNames(db, 'pending_messages');
makeMessageStaleProcessing(stuckId);
expect(columns).toContain('tool_use_id');
expect(columns).not.toContain('worker_pid');
expect(indexes).not.toContain('idx_pending_messages_worker_pid');
const claimed = store.claimNextMessage(sessionDbId);
const sessionDbId = store.createSDKSession('content-claim-current', 'test-project', 'initial prompt');
const pendingStore = new PendingMessageStore(db, () => {});
const messageId = pendingStore.enqueue(
sessionDbId,
'content-claim-current',
createPendingMessage({ toolUseId: 'tool-claim' })
);
expect(claimed).not.toBeNull();
expect(claimed!.id).toBe(stuckId);
const msg1 = db.query('SELECT status FROM pending_messages WHERE id = ?').get(pendingId1) as { status: string };
const msg2 = db.query('SELECT status FROM pending_messages WHERE id = ?').get(pendingId2) as { status: string };
expect(msg1.status).toBe('pending');
expect(msg2.status).toBe('pending');
});
test('no messages returns null without error', () => {
const claimed = store.claimNextMessage(sessionDbId);
expect(claimed).toBeNull();
});
test('self-healing only affects the specified session', () => {
const session2Id = createSDKSession(db, 'other-session', 'test-project', 'Test');
const stuckInSession1 = enqueueMessage();
makeMessageStaleProcessing(stuckInSession1);
const msg: PendingMessage = {
type: 'observation',
tool_name: 'TestTool',
tool_input: { test: 'input' },
tool_response: { test: 'response' },
prompt_number: 1,
};
const session2MsgId = store.enqueue(session2Id, 'other-session', msg);
makeMessageStaleProcessing(session2MsgId);
const claimed = store.claimNextMessage(session2Id);
expect(claimed).not.toBeNull();
expect(claimed!.id).toBe(session2MsgId);
const session1Msg = db.query('SELECT status FROM pending_messages WHERE id = ?').get(stuckInSession1) as { status: string };
expect(session1Msg.status).toBe('processing');
const claimed = pendingStore.claimNextMessage(sessionDbId) as ({ id: number; tool_use_id: string | null } | null);
expect(claimed).not.toBeNull();
expect(claimed!.id).toBe(messageId);
expect(claimed!.tool_use_id).toBe('tool-claim');
} finally {
store.close();
}
});
});
@@ -0,0 +1,128 @@
import { describe, expect, it, mock } from 'bun:test';
import type { ActiveSession } from '../../../src/services/worker-types.js';
import { handleGeneratorExit } from '../../../src/services/worker/session/GeneratorExitHandler.js';
function createSession(): ActiveSession {
return {
sessionDbId: 42,
contentSessionId: 'content-42',
memorySessionId: 'memory-42',
project: 'test-project',
platformSource: 'claude-code',
userPrompt: 'test',
pendingMessages: [],
abortController: new AbortController(),
generatorPromise: Promise.resolve(),
lastPromptNumber: 1,
startTime: Date.now(),
cumulativeInputTokens: 0,
cumulativeOutputTokens: 0,
earliestPendingTimestamp: null,
conversationHistory: [],
currentProvider: 'claude',
consecutiveRestarts: 0,
lastGeneratorActivity: Date.now(),
};
}
function createDeps(pendingCount = 3) {
const pendingStore = {
clearPendingForSession: mock(() => undefined),
getPendingCount: mock(() => pendingCount),
};
const sessionManager = {
getPendingMessageStore: mock(() => pendingStore),
removeSessionImmediate: mock(() => undefined),
};
const completionHandler = {
finalizeSession: mock(() => undefined),
};
const restartGenerator = mock(() => undefined);
return {
pendingStore,
sessionManager,
completionHandler,
restartGenerator,
deps: {
sessionManager: sessionManager as any,
completionHandler: completionHandler as any,
restartGenerator,
},
};
}
describe('handleGeneratorExit hard-stop reasons', () => {
it('does not restart pending work after context overflow', async () => {
const session = createSession();
const { deps, pendingStore, completionHandler, sessionManager, restartGenerator } = createDeps();
await handleGeneratorExit(session, 'overflow', deps);
expect(pendingStore.clearPendingForSession).toHaveBeenCalledWith(42);
expect(completionHandler.finalizeSession).toHaveBeenCalledWith(42);
expect(sessionManager.removeSessionImmediate).toHaveBeenCalledWith(42);
expect(pendingStore.getPendingCount).not.toHaveBeenCalled();
expect(restartGenerator).not.toHaveBeenCalled();
});
it('does not restart pending work while quota guard is active', async () => {
const session = createSession();
const { deps, pendingStore, completionHandler, sessionManager, restartGenerator } = createDeps();
await handleGeneratorExit(session, 'quota:hourly', deps);
expect(pendingStore.clearPendingForSession).toHaveBeenCalledWith(42);
expect(completionHandler.finalizeSession).toHaveBeenCalledWith(42);
expect(sessionManager.removeSessionImmediate).toHaveBeenCalledWith(42);
expect(pendingStore.getPendingCount).not.toHaveBeenCalled();
expect(restartGenerator).not.toHaveBeenCalled();
});
it('removes hard-stopped sessions even when pending cleanup fails', async () => {
const session = createSession();
const { deps, pendingStore, completionHandler, sessionManager, restartGenerator } = createDeps();
pendingStore.clearPendingForSession.mockImplementation(() => {
throw new Error('simulated pending cleanup failure');
});
await handleGeneratorExit(session, 'overflow', deps);
expect(pendingStore.clearPendingForSession).toHaveBeenCalledWith(42);
expect(completionHandler.finalizeSession).toHaveBeenCalledWith(42);
expect(sessionManager.removeSessionImmediate).toHaveBeenCalledWith(42);
expect(pendingStore.getPendingCount).not.toHaveBeenCalled();
expect(restartGenerator).not.toHaveBeenCalled();
});
it('removes hard-stopped sessions even when finalization fails', async () => {
const session = createSession();
const { deps, pendingStore, completionHandler, sessionManager, restartGenerator } = createDeps();
completionHandler.finalizeSession.mockImplementation(() => {
throw new Error('simulated finalization failure');
});
await handleGeneratorExit(session, 'quota', deps);
expect(pendingStore.clearPendingForSession).toHaveBeenCalledWith(42);
expect(completionHandler.finalizeSession).toHaveBeenCalledWith(42);
expect(sessionManager.removeSessionImmediate).toHaveBeenCalledWith(42);
expect(pendingStore.getPendingCount).not.toHaveBeenCalled();
expect(restartGenerator).not.toHaveBeenCalled();
});
it('removes naturally completed sessions even when finalization fails', async () => {
const session = createSession();
const { deps, pendingStore, completionHandler, sessionManager, restartGenerator } = createDeps(0);
completionHandler.finalizeSession.mockImplementation(() => {
throw new Error('simulated finalization failure');
});
await handleGeneratorExit(session, 'idle', deps);
expect(pendingStore.clearPendingForSession).not.toHaveBeenCalled();
expect(completionHandler.finalizeSession).toHaveBeenCalledWith(42);
expect(sessionManager.removeSessionImmediate).toHaveBeenCalledWith(42);
expect(restartGenerator).not.toHaveBeenCalled();
});
});
+22
View File
@@ -58,6 +58,18 @@ describe('setup-runtime install marker', () => {
expect(marker?.bun).toBe('1.0.0');
expect(marker?.uv).toBe('0.5.0');
});
it('returns parsed marker when file is a legacy plain-text version', () => {
writeFileSync(join(tempDir, '.install-version'), '12.4.4\n');
const marker = readInstallMarker(tempDir);
expect(marker).toEqual({ version: '12.4.4' });
});
it('normalizes a leading v in legacy plain-text versions', () => {
writeFileSync(join(tempDir, '.install-version'), 'v12.4.4\n');
const marker = readInstallMarker(tempDir);
expect(marker).toEqual({ version: '12.4.4' });
});
});
describe('writeInstallMarker', () => {
@@ -109,5 +121,15 @@ describe('setup-runtime install marker', () => {
writeInstallMarker(tempDir, '1.0.0', bunVersion, '0.1.0');
expect(isInstallCurrent(tempDir, '1.0.0')).toBe(true);
});
it('returns false for a matching legacy plain-text marker when bun is available', () => {
const bunVersion = probeBunVersion();
if (!bunVersion) {
return;
}
mkdirSync(join(tempDir, 'node_modules'));
writeFileSync(join(tempDir, '.install-version'), '1.0.0\n');
expect(isInstallCurrent(tempDir, '1.0.0')).toBe(false);
});
});
});
+24 -3
View File
@@ -215,9 +215,9 @@ describe('Transactions Module', () => {
expect(result.observationIds).toHaveLength(1);
expect(result.summaryId).not.toBeNull();
const msgStmt = db.prepare('SELECT status FROM pending_messages WHERE id = ?');
const msg = msgStmt.get(messageId) as { status: string } | undefined;
expect(msg?.status).toBe('processed');
const msgStmt = db.prepare('SELECT id FROM pending_messages WHERE id = ?');
const msg = msgStmt.get(messageId) as { id: number } | null;
expect(msg).toBeNull();
});
it('should maintain atomicity - all operations share same timestamp', () => {
@@ -284,5 +284,26 @@ describe('Transactions Module', () => {
expect(result.observationIds).toHaveLength(1);
expect(result.summaryId).toBeNull();
});
it('should roll back stored observations when the pending message is not completed', () => {
const { memorySessionId } = createSessionWithMemoryId('content-missing-pending', 'missing-pending-session');
const observations = [createObservationInput({ title: 'Rollback Obs' })];
expect(() => storeObservationsAndMarkComplete(
db,
memorySessionId,
'test-project',
observations,
null,
99999
)).toThrow('storeObservationsAndMarkComplete: failed to complete pending message 99999');
const count = db.prepare(`
SELECT COUNT(*) AS count
FROM observations
WHERE title = ?
`).get('Rollback Obs') as { count: number };
expect(count.count).toBe(0);
});
});
});
@@ -193,6 +193,25 @@ describe('DataRoutes Type Coercion', () => {
expect(jsonSpy).toHaveBeenCalled();
});
it('should accept legacy sdkSessionIds as a compatibility alias', () => {
const { req, res, jsonSpy } = createMockReqRes({ sdkSessionIds: ['abc', 'def'] });
handler(req as Request, res as Response);
expect(mockGetSdkSessionsBySessionIds).toHaveBeenCalledWith(['abc', 'def']);
expect(jsonSpy).toHaveBeenCalled();
});
it('should prefer canonical memorySessionIds when both fields are provided', () => {
const { req, res, jsonSpy } = createMockReqRes({
memorySessionIds: ['canonical'],
sdkSessionIds: ['legacy'],
});
handler(req as Request, res as Response);
expect(mockGetSdkSessionsBySessionIds).toHaveBeenCalledWith(['canonical']);
expect(jsonSpy).toHaveBeenCalled();
});
it('should reject non-array, non-string values', () => {
const { req, res, statusSpy } = createMockReqRes({ memorySessionIds: 42 });
handler(req as Request, res as Response);