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:
@@ -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\""
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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,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
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
+190
-182
File diff suppressed because one or more lines are too long
+132
-90
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user