@namzu/sdk 59.0.0 → 60.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +120 -0
- package/dist/agents/QueryAgent.d.ts.map +1 -1
- package/dist/agents/QueryAgent.js +1 -0
- package/dist/agents/QueryAgent.js.map +1 -1
- package/dist/bridge/a2a/mapper.d.ts.map +1 -1
- package/dist/bridge/a2a/mapper.js +16 -0
- package/dist/bridge/a2a/mapper.js.map +1 -1
- package/dist/bridge/acp/update.d.ts.map +1 -1
- package/dist/bridge/acp/update.js +1 -0
- package/dist/bridge/acp/update.js.map +1 -1
- package/dist/bridge/sse/mapper.d.ts.map +1 -1
- package/dist/bridge/sse/mapper.js +12 -0
- package/dist/bridge/sse/mapper.js.map +1 -1
- package/dist/compaction/context-window.d.ts +53 -0
- package/dist/compaction/context-window.d.ts.map +1 -1
- package/dist/compaction/context-window.js +141 -2
- package/dist/compaction/context-window.js.map +1 -1
- package/dist/constants/continuation.d.ts +9 -0
- package/dist/constants/continuation.d.ts.map +1 -1
- package/dist/constants/continuation.js +9 -0
- package/dist/constants/continuation.js.map +1 -1
- package/dist/contracts/session/api.d.ts +7 -1
- package/dist/contracts/session/api.d.ts.map +1 -1
- package/dist/execution/workflow/index.d.ts +3 -1
- package/dist/execution/workflow/index.d.ts.map +1 -1
- package/dist/execution/workflow/index.js +2 -1
- package/dist/execution/workflow/index.js.map +1 -1
- package/dist/execution/workflow/journal.d.ts +24 -4
- package/dist/execution/workflow/journal.d.ts.map +1 -1
- package/dist/execution/workflow/journal.js +131 -9
- package/dist/execution/workflow/journal.js.map +1 -1
- package/dist/execution/workflow/prelude.d.ts +2 -0
- package/dist/execution/workflow/prelude.d.ts.map +1 -1
- package/dist/execution/workflow/prelude.js +49 -8
- package/dist/execution/workflow/prelude.js.map +1 -1
- package/dist/execution/workflow/resume.d.ts +59 -0
- package/dist/execution/workflow/resume.d.ts.map +1 -0
- package/dist/execution/workflow/resume.js +273 -0
- package/dist/execution/workflow/resume.js.map +1 -0
- package/dist/execution/workflow/runtime.d.ts.map +1 -1
- package/dist/execution/workflow/runtime.js +112 -8
- package/dist/execution/workflow/runtime.js.map +1 -1
- package/dist/execution/workflow/types.d.ts +105 -1
- package/dist/execution/workflow/types.d.ts.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/manager/session/turn-recorder.d.ts.map +1 -1
- package/dist/manager/session/turn-recorder.js +1 -0
- package/dist/manager/session/turn-recorder.js.map +1 -1
- package/dist/plugin/compat.d.ts +24 -0
- package/dist/plugin/compat.d.ts.map +1 -0
- package/dist/plugin/compat.js +149 -0
- package/dist/plugin/compat.js.map +1 -0
- package/dist/plugin/loader.d.ts +2 -1
- package/dist/plugin/loader.d.ts.map +1 -1
- package/dist/plugin/loader.js +6 -0
- package/dist/plugin/loader.js.map +1 -1
- package/dist/pricing/index.d.ts.map +1 -1
- package/dist/pricing/index.js +2 -1
- package/dist/pricing/index.js.map +1 -1
- package/dist/process/kill-tree.d.ts +44 -0
- package/dist/process/kill-tree.d.ts.map +1 -1
- package/dist/process/kill-tree.js +102 -4
- package/dist/process/kill-tree.js.map +1 -1
- package/dist/prompt/coding-agent-doctrine.d.ts +1 -1
- package/dist/prompt/coding-agent-doctrine.d.ts.map +1 -1
- package/dist/prompt/coding-agent-doctrine.js +9 -1
- package/dist/prompt/coding-agent-doctrine.js.map +1 -1
- package/dist/provider/collect-chat-completion.d.ts.map +1 -1
- package/dist/provider/collect-chat-completion.js +4 -0
- package/dist/provider/collect-chat-completion.js.map +1 -1
- package/dist/provider/fallback.d.ts.map +1 -1
- package/dist/provider/fallback.js +11 -0
- package/dist/provider/fallback.js.map +1 -1
- package/dist/provider/idle-timeout.d.ts.map +1 -1
- package/dist/provider/idle-timeout.js +5 -0
- package/dist/provider/idle-timeout.js.map +1 -1
- package/dist/provider/mock.d.ts +2 -0
- package/dist/provider/mock.d.ts.map +1 -1
- package/dist/provider/mock.js +16 -8
- package/dist/provider/mock.js.map +1 -1
- package/dist/provider/model-window-suffix.d.ts +18 -0
- package/dist/provider/model-window-suffix.d.ts.map +1 -0
- package/dist/provider/model-window-suffix.js +78 -0
- package/dist/provider/model-window-suffix.js.map +1 -0
- package/dist/provider/retry.d.ts.map +1 -1
- package/dist/provider/retry.js +5 -0
- package/dist/provider/retry.js.map +1 -1
- package/dist/public-runtime.d.ts +5 -3
- package/dist/public-runtime.d.ts.map +1 -1
- package/dist/public-runtime.js +5 -4
- package/dist/public-runtime.js.map +1 -1
- package/dist/public-types.d.ts +1 -0
- package/dist/public-types.d.ts.map +1 -1
- package/dist/registry/command/kernel-commands.d.ts +1 -4
- package/dist/registry/command/kernel-commands.d.ts.map +1 -1
- package/dist/registry/command/kernel-commands.js +0 -28
- package/dist/registry/command/kernel-commands.js.map +1 -1
- package/dist/runtime/jobs/awaited-jobs.d.ts +6 -0
- package/dist/runtime/jobs/awaited-jobs.d.ts.map +1 -1
- package/dist/runtime/jobs/awaited-jobs.js +11 -0
- package/dist/runtime/jobs/awaited-jobs.js.map +1 -1
- package/dist/runtime/jobs/host-exit-finalizer.d.ts +20 -0
- package/dist/runtime/jobs/host-exit-finalizer.d.ts.map +1 -0
- package/dist/runtime/jobs/host-exit-finalizer.js +57 -0
- package/dist/runtime/jobs/host-exit-finalizer.js.map +1 -0
- package/dist/runtime/jobs/liveness.d.ts +67 -0
- package/dist/runtime/jobs/liveness.d.ts.map +1 -0
- package/dist/runtime/jobs/liveness.js +94 -0
- package/dist/runtime/jobs/liveness.js.map +1 -0
- package/dist/runtime/jobs/registry.d.ts +21 -0
- package/dist/runtime/jobs/registry.d.ts.map +1 -1
- package/dist/runtime/jobs/registry.js +104 -2
- package/dist/runtime/jobs/registry.js.map +1 -1
- package/dist/runtime/jobs/wake-budget.d.ts +35 -0
- package/dist/runtime/jobs/wake-budget.d.ts.map +1 -0
- package/dist/runtime/jobs/wake-budget.js +48 -0
- package/dist/runtime/jobs/wake-budget.js.map +1 -0
- package/dist/runtime/query/detached-calls.d.ts +65 -0
- package/dist/runtime/query/detached-calls.d.ts.map +1 -0
- package/dist/runtime/query/detached-calls.js +116 -0
- package/dist/runtime/query/detached-calls.js.map +1 -0
- package/dist/runtime/query/executor.d.ts +4 -1
- package/dist/runtime/query/executor.d.ts.map +1 -1
- package/dist/runtime/query/executor.js +90 -6
- package/dist/runtime/query/executor.js.map +1 -1
- package/dist/runtime/query/index.d.ts +34 -0
- package/dist/runtime/query/index.d.ts.map +1 -1
- package/dist/runtime/query/index.js +32 -5
- package/dist/runtime/query/index.js.map +1 -1
- package/dist/runtime/query/iteration/effort-clamp.d.ts +13 -0
- package/dist/runtime/query/iteration/effort-clamp.d.ts.map +1 -0
- package/dist/runtime/query/iteration/effort-clamp.js +42 -0
- package/dist/runtime/query/iteration/effort-clamp.js.map +1 -0
- package/dist/runtime/query/iteration/index.d.ts +34 -0
- package/dist/runtime/query/iteration/index.d.ts.map +1 -1
- package/dist/runtime/query/iteration/index.js +199 -6
- package/dist/runtime/query/iteration/index.js.map +1 -1
- package/dist/runtime/query/iteration/outstanding-work.d.ts.map +1 -1
- package/dist/runtime/query/iteration/outstanding-work.js +11 -3
- package/dist/runtime/query/iteration/outstanding-work.js.map +1 -1
- package/dist/runtime/query/iteration/phases/context.d.ts +9 -0
- package/dist/runtime/query/iteration/phases/context.d.ts.map +1 -1
- package/dist/runtime/query/iteration/phases/context.js.map +1 -1
- package/dist/runtime/query/iteration/phases/tool-review.d.ts.map +1 -1
- package/dist/runtime/query/iteration/phases/tool-review.js +63 -1
- package/dist/runtime/query/iteration/phases/tool-review.js.map +1 -1
- package/dist/runtime/query/iteration/stream-turn.d.ts.map +1 -1
- package/dist/runtime/query/iteration/stream-turn.js +6 -0
- package/dist/runtime/query/iteration/stream-turn.js.map +1 -1
- package/dist/runtime/query/session-storage.d.ts.map +1 -1
- package/dist/runtime/query/session-storage.js +8 -3
- package/dist/runtime/query/session-storage.js.map +1 -1
- package/dist/runtime/query/steering.d.ts +29 -3
- package/dist/runtime/query/steering.d.ts.map +1 -1
- package/dist/runtime/query/steering.js +58 -3
- package/dist/runtime/query/steering.js.map +1 -1
- package/dist/runtime/query/tooling.d.ts +1 -0
- package/dist/runtime/query/tooling.d.ts.map +1 -1
- package/dist/runtime/query/tooling.js +1 -0
- package/dist/runtime/query/tooling.js.map +1 -1
- package/dist/runtime/system-events.d.ts +5 -0
- package/dist/runtime/system-events.d.ts.map +1 -1
- package/dist/runtime/system-events.js +7 -1
- package/dist/runtime/system-events.js.map +1 -1
- package/dist/sandbox/denial.d.ts +11 -0
- package/dist/sandbox/denial.d.ts.map +1 -0
- package/dist/sandbox/denial.js +61 -0
- package/dist/sandbox/denial.js.map +1 -0
- package/dist/sandbox/file-walk-program.d.ts.map +1 -1
- package/dist/sandbox/file-walk-program.js +31 -13
- package/dist/sandbox/file-walk-program.js.map +1 -1
- package/dist/sandbox/file-walk.d.ts +2 -0
- package/dist/sandbox/file-walk.d.ts.map +1 -1
- package/dist/sandbox/file-walk.js +64 -13
- package/dist/sandbox/file-walk.js.map +1 -1
- package/dist/sandbox/ignore-matcher.d.ts +30 -0
- package/dist/sandbox/ignore-matcher.d.ts.map +1 -0
- package/dist/sandbox/ignore-matcher.js +11 -0
- package/dist/sandbox/ignore-matcher.js.map +1 -0
- package/dist/sandbox/ignore-source.d.ts +26 -0
- package/dist/sandbox/ignore-source.d.ts.map +1 -0
- package/dist/sandbox/ignore-source.js +221 -0
- package/dist/sandbox/ignore-source.js.map +1 -0
- package/dist/sandbox/provider/local.d.ts.map +1 -1
- package/dist/sandbox/provider/local.js +11 -1
- package/dist/sandbox/provider/local.js.map +1 -1
- package/dist/scheduler/completion-inbox.d.ts.map +1 -1
- package/dist/scheduler/completion-inbox.js +1 -0
- package/dist/scheduler/completion-inbox.js.map +1 -1
- package/dist/session/log-hash.d.ts.map +1 -1
- package/dist/session/log-hash.js +18 -1
- package/dist/session/log-hash.js.map +1 -1
- package/dist/store/memory/disk.d.ts.map +1 -1
- package/dist/store/memory/disk.js +1 -1
- package/dist/store/memory/disk.js.map +1 -1
- package/dist/store/memory/markdown.d.ts.map +1 -1
- package/dist/store/memory/markdown.js +1 -1
- package/dist/store/memory/markdown.js.map +1 -1
- package/dist/store/memory/operation-lock.d.ts +20 -4
- package/dist/store/memory/operation-lock.d.ts.map +1 -1
- package/dist/store/memory/operation-lock.js +235 -58
- package/dist/store/memory/operation-lock.js.map +1 -1
- package/dist/store/session-index/sqlite.d.ts.map +1 -1
- package/dist/store/session-index/sqlite.js +169 -16
- package/dist/store/session-index/sqlite.js.map +1 -1
- package/dist/tools/builtins/bash.d.ts.map +1 -1
- package/dist/tools/builtins/bash.js +10 -4
- package/dist/tools/builtins/bash.js.map +1 -1
- package/dist/tools/builtins/computer-use.d.ts +35 -0
- package/dist/tools/builtins/computer-use.d.ts.map +1 -1
- package/dist/tools/builtins/computer-use.js +512 -67
- package/dist/tools/builtins/computer-use.js.map +1 -1
- package/dist/tools/builtins/glob.d.ts +1 -0
- package/dist/tools/builtins/glob.d.ts.map +1 -1
- package/dist/tools/builtins/glob.js +65 -28
- package/dist/tools/builtins/glob.js.map +1 -1
- package/dist/tools/builtins/grep.d.ts +2 -0
- package/dist/tools/builtins/grep.d.ts.map +1 -1
- package/dist/tools/builtins/grep.js +94 -40
- package/dist/tools/builtins/grep.js.map +1 -1
- package/dist/tools/builtins/job.d.ts +1 -1
- package/dist/tools/builtins/job.d.ts.map +1 -1
- package/dist/tools/builtins/job.js +34 -7
- package/dist/tools/builtins/job.js.map +1 -1
- package/dist/tools/builtins/search-support.d.ts +44 -0
- package/dist/tools/builtins/search-support.d.ts.map +1 -0
- package/dist/tools/builtins/search-support.js +89 -0
- package/dist/tools/builtins/search-support.js.map +1 -0
- package/dist/tools/builtins/wait-for-job-bounds.d.ts +2 -0
- package/dist/tools/builtins/wait-for-job-bounds.d.ts.map +1 -1
- package/dist/tools/builtins/wait-for-job-bounds.js +3 -1
- package/dist/tools/builtins/wait-for-job-bounds.js.map +1 -1
- package/dist/tools/builtins/wait-for-job.d.ts +1 -1
- package/dist/tools/builtins/wait-for-job.d.ts.map +1 -1
- package/dist/tools/builtins/wait-for-job.js +13 -2
- package/dist/tools/builtins/wait-for-job.js.map +1 -1
- package/dist/tools/command-shell.d.ts.map +1 -1
- package/dist/tools/command-shell.js +14 -5
- package/dist/tools/command-shell.js.map +1 -1
- package/dist/turn/reporter.d.ts.map +1 -1
- package/dist/turn/reporter.js +11 -0
- package/dist/turn/reporter.js.map +1 -1
- package/dist/types/agent/query.d.ts +2 -0
- package/dist/types/agent/query.d.ts.map +1 -1
- package/dist/types/computer-use/index.d.ts +129 -13
- package/dist/types/computer-use/index.d.ts.map +1 -1
- package/dist/types/computer-use/index.js.map +1 -1
- package/dist/types/message/index.d.ts +1 -1
- package/dist/types/message/index.d.ts.map +1 -1
- package/dist/types/message/index.js +1 -0
- package/dist/types/message/index.js.map +1 -1
- package/dist/types/plugin/index.d.ts +24 -0
- package/dist/types/plugin/index.d.ts.map +1 -1
- package/dist/types/plugin/index.js +4 -0
- package/dist/types/plugin/index.js.map +1 -1
- package/dist/types/provider/chat.d.ts +19 -0
- package/dist/types/provider/chat.d.ts.map +1 -1
- package/dist/types/provider/config.d.ts +7 -0
- package/dist/types/provider/config.d.ts.map +1 -1
- package/dist/types/provider/index.d.ts +1 -1
- package/dist/types/provider/index.d.ts.map +1 -1
- package/dist/types/provider/interface.d.ts +15 -0
- package/dist/types/provider/interface.d.ts.map +1 -1
- package/dist/types/provider/stream.d.ts +6 -0
- package/dist/types/provider/stream.d.ts.map +1 -1
- package/dist/types/sandbox/index.d.ts +38 -1
- package/dist/types/sandbox/index.d.ts.map +1 -1
- package/dist/types/sandbox/index.js.map +1 -1
- package/dist/types/session/events.d.ts +34 -0
- package/dist/types/session/events.d.ts.map +1 -1
- package/dist/types/session/events.js.map +1 -1
- package/dist/types/session/records.d.ts +298 -34
- package/dist/types/session/records.d.ts.map +1 -1
- package/dist/types/session/records.js +35 -1
- package/dist/types/session/records.js.map +1 -1
- package/dist/types/session/stop-reason.d.ts +11 -0
- package/dist/types/session/stop-reason.d.ts.map +1 -1
- package/dist/types/tool/index.d.ts +30 -0
- package/dist/types/tool/index.d.ts.map +1 -1
- package/dist/types/tool/index.js.map +1 -1
- package/dist/types/tool/presentation.d.ts +8 -0
- package/dist/types/tool/presentation.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/agents/QueryAgent.ts +1 -0
- package/src/bridge/a2a/mapper.ts +19 -0
- package/src/bridge/acp/update.ts +6 -1
- package/src/bridge/sse/mapper.ts +13 -0
- package/src/compaction/context-window.ts +163 -2
- package/src/constants/continuation.ts +13 -0
- package/src/contracts/session/api.ts +6 -0
- package/src/execution/workflow/index.ts +14 -1
- package/src/execution/workflow/journal.ts +161 -11
- package/src/execution/workflow/prelude.ts +51 -8
- package/src/execution/workflow/resume.ts +313 -0
- package/src/execution/workflow/runtime.ts +127 -9
- package/src/execution/workflow/types.ts +109 -1
- package/src/index.ts +17 -0
- package/src/manager/session/turn-recorder.ts +1 -0
- package/src/plugin/compat.ts +173 -0
- package/src/plugin/loader.ts +7 -1
- package/src/pricing/index.ts +2 -1
- package/src/process/kill-tree.ts +106 -4
- package/src/prompt/coding-agent-doctrine.ts +9 -1
- package/src/provider/collect-chat-completion.ts +3 -0
- package/src/provider/fallback.ts +17 -0
- package/src/provider/idle-timeout.ts +9 -0
- package/src/provider/mock.ts +19 -9
- package/src/provider/model-window-suffix.ts +85 -0
- package/src/provider/retry.ts +9 -0
- package/src/public-runtime.ts +21 -2
- package/src/public-types.ts +1 -0
- package/src/registry/command/kernel-commands.ts +1 -32
- package/src/runtime/jobs/awaited-jobs.ts +11 -0
- package/src/runtime/jobs/host-exit-finalizer.ts +67 -0
- package/src/runtime/jobs/liveness.ts +144 -0
- package/src/runtime/jobs/registry.ts +114 -2
- package/src/runtime/jobs/wake-budget.ts +55 -0
- package/src/runtime/query/detached-calls.ts +135 -0
- package/src/runtime/query/executor.ts +92 -10
- package/src/runtime/query/index.ts +65 -7
- package/src/runtime/query/iteration/effort-clamp.ts +46 -0
- package/src/runtime/query/iteration/index.ts +260 -6
- package/src/runtime/query/iteration/outstanding-work.ts +12 -3
- package/src/runtime/query/iteration/phases/context.ts +9 -0
- package/src/runtime/query/iteration/phases/tool-review.ts +73 -8
- package/src/runtime/query/iteration/stream-turn.ts +5 -0
- package/src/runtime/query/session-storage.ts +8 -3
- package/src/runtime/query/steering.ts +77 -6
- package/src/runtime/query/tooling.ts +2 -0
- package/src/runtime/system-events.ts +12 -1
- package/src/sandbox/denial.ts +79 -0
- package/src/sandbox/file-walk-program.ts +32 -13
- package/src/sandbox/file-walk.ts +75 -12
- package/src/sandbox/ignore-matcher.ts +46 -0
- package/src/sandbox/ignore-source.ts +220 -0
- package/src/sandbox/provider/local.ts +11 -1
- package/src/scheduler/completion-inbox.ts +1 -0
- package/src/session/log-hash.ts +21 -0
- package/src/store/memory/disk.ts +1 -0
- package/src/store/memory/markdown.ts +1 -0
- package/src/store/memory/operation-lock.ts +288 -57
- package/src/store/session-index/sqlite.ts +171 -16
- package/src/tools/builtins/bash.ts +11 -4
- package/src/tools/builtins/computer-use.ts +593 -68
- package/src/tools/builtins/glob.ts +79 -32
- package/src/tools/builtins/grep.ts +107 -43
- package/src/tools/builtins/job.ts +42 -12
- package/src/tools/builtins/search-support.ts +107 -0
- package/src/tools/builtins/wait-for-job-bounds.ts +5 -1
- package/src/tools/builtins/wait-for-job.ts +14 -2
- package/src/tools/command-shell.ts +13 -5
- package/src/turn/reporter.ts +12 -0
- package/src/types/agent/query.ts +2 -0
- package/src/types/computer-use/index.ts +157 -15
- package/src/types/message/index.ts +5 -3
- package/src/types/plugin/index.ts +8 -0
- package/src/types/provider/chat.ts +20 -0
- package/src/types/provider/config.ts +7 -0
- package/src/types/provider/index.ts +1 -0
- package/src/types/provider/interface.ts +20 -0
- package/src/types/provider/stream.ts +6 -0
- package/src/types/sandbox/index.ts +40 -1
- package/src/types/session/events.ts +65 -5
- package/src/types/session/records.ts +39 -2
- package/src/types/session/stop-reason.ts +11 -0
- package/src/types/tool/index.ts +30 -0
- package/src/types/tool/presentation.ts +8 -0
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto'
|
|
2
|
-
import
|
|
2
|
+
import type { Stats } from 'node:fs'
|
|
3
|
+
import { lstat, open, readFile, unlink } from 'node:fs/promises'
|
|
4
|
+
import { hostname as osHostname } from 'node:os'
|
|
3
5
|
import { setTimeout as delay } from 'node:timers/promises'
|
|
4
6
|
|
|
5
7
|
import { NamzuError } from '../../types/errors/index.js'
|
|
8
|
+
import type { Logger } from '../../utils/logger.js'
|
|
6
9
|
|
|
7
|
-
/** Wait bound, not a lease: a slow
|
|
10
|
+
/** Wait bound, not a lease: a slow owner that is still running is never replaced. */
|
|
8
11
|
export const DEFAULT_MEMORY_LOCK_TIMEOUT_MS = 10_000
|
|
9
12
|
|
|
10
13
|
export function validateMemoryLockTimeout(timeoutMs: number): void {
|
|
@@ -17,83 +20,311 @@ export function validateMemoryLockTimeout(timeoutMs: number): void {
|
|
|
17
20
|
}
|
|
18
21
|
}
|
|
19
22
|
|
|
20
|
-
|
|
23
|
+
/**
|
|
24
|
+
* An empty or unparsable lock file is only believed abandoned once it is older
|
|
25
|
+
* than this. A live acquirer writes its record immediately after creating the
|
|
26
|
+
* file, so a gap of seconds means the writer died in between (disk full, kill).
|
|
27
|
+
*/
|
|
28
|
+
export const MEMORY_LOCK_RECORD_GRACE_MS = 5_000
|
|
29
|
+
|
|
30
|
+
export interface MemoryOperationLockOptions {
|
|
31
|
+
readonly logger?: Logger
|
|
32
|
+
/** Clock compared against file mtimes. */
|
|
33
|
+
readonly now?: () => number
|
|
34
|
+
/** True when a process with this pid exists on this machine. */
|
|
35
|
+
readonly isPidAlive?: (pid: number) => boolean
|
|
36
|
+
readonly hostname?: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface OwnerRecord {
|
|
40
|
+
readonly pid: number
|
|
41
|
+
readonly token: string
|
|
42
|
+
readonly acquiredAt: number
|
|
43
|
+
readonly hostname?: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface Inspection {
|
|
47
|
+
readonly stats: Stats
|
|
48
|
+
readonly content: string
|
|
49
|
+
readonly pid: number | undefined
|
|
50
|
+
/** Set only when the owner is provably gone. */
|
|
51
|
+
readonly staleReason: string | undefined
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface Resolved {
|
|
55
|
+
readonly logger: Logger | undefined
|
|
56
|
+
readonly now: () => number
|
|
57
|
+
readonly isPidAlive: (pid: number) => boolean
|
|
58
|
+
readonly hostname: string
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Residual risk, deliberately accepted: a pid that is alive is treated as the
|
|
63
|
+
* owner even when it is an unrelated process that reused the number after the
|
|
64
|
+
* real owner died. That cannot be disproved from here, so such a lock stays and
|
|
65
|
+
* the caller gets the plain "another process is using it" error. It is not
|
|
66
|
+
* bounded by `acquiredAt` age: a lock's age is not a lease.
|
|
67
|
+
*/
|
|
68
|
+
function defaultIsPidAlive(pid: number): boolean {
|
|
69
|
+
try {
|
|
70
|
+
process.kill(pid, 0)
|
|
71
|
+
return true
|
|
72
|
+
} catch (error) {
|
|
73
|
+
return (error as NodeJS.ErrnoException).code !== 'ESRCH'
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function lockError(path: string, message: string, timeoutMs: number): NamzuError {
|
|
21
78
|
return new NamzuError({
|
|
22
79
|
code: 'storage_error',
|
|
23
|
-
message
|
|
80
|
+
message,
|
|
24
81
|
details: { lockPath: path, timeoutMs },
|
|
25
82
|
retryable: false,
|
|
26
83
|
})
|
|
27
84
|
}
|
|
28
85
|
|
|
86
|
+
function busyError(path: string, pid: number | undefined, timeoutMs: number): NamzuError {
|
|
87
|
+
const who =
|
|
88
|
+
pid === undefined ? 'Another Namzu process is' : `Another Namzu process (pid ${pid}) is`
|
|
89
|
+
return lockError(
|
|
90
|
+
path,
|
|
91
|
+
`${who} using the stored memory. Close it, or wait for it to finish.`,
|
|
92
|
+
timeoutMs,
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function parseOwner(content: string): OwnerRecord | undefined {
|
|
97
|
+
try {
|
|
98
|
+
const value: unknown = JSON.parse(content)
|
|
99
|
+
if (!value || typeof value !== 'object') return undefined
|
|
100
|
+
const pid = (value as { pid?: unknown }).pid
|
|
101
|
+
if (typeof pid !== 'number' || !Number.isSafeInteger(pid) || pid <= 0) return undefined
|
|
102
|
+
const host = (value as { hostname?: unknown }).hostname
|
|
103
|
+
return {
|
|
104
|
+
pid,
|
|
105
|
+
token: String((value as { token?: unknown }).token ?? ''),
|
|
106
|
+
acquiredAt: Number((value as { acquiredAt?: unknown }).acquiredAt ?? 0),
|
|
107
|
+
...(typeof host === 'string' && host.length > 0 ? { hostname: host } : {}),
|
|
108
|
+
}
|
|
109
|
+
} catch {
|
|
110
|
+
return undefined
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Reads the file at `path`; undefined when it is gone or was swapped while reading. */
|
|
115
|
+
async function inspect(path: string, env: Resolved): Promise<Inspection | undefined> {
|
|
116
|
+
const before = await lstat(path).catch((error: NodeJS.ErrnoException) => {
|
|
117
|
+
if (error.code === 'ENOENT') return undefined
|
|
118
|
+
throw error
|
|
119
|
+
})
|
|
120
|
+
if (!before) return undefined
|
|
121
|
+
if (!before.isFile() || before.isSymbolicLink())
|
|
122
|
+
return { stats: before, content: '', pid: undefined, staleReason: undefined }
|
|
123
|
+
const content = await readFile(path, 'utf8').catch((error: NodeJS.ErrnoException) => {
|
|
124
|
+
if (error.code === 'ENOENT') return undefined
|
|
125
|
+
throw error
|
|
126
|
+
})
|
|
127
|
+
if (content === undefined) return undefined
|
|
128
|
+
const after = await lstat(path).catch(() => undefined)
|
|
129
|
+
if (!after || after.dev !== before.dev || after.ino !== before.ino) return undefined
|
|
130
|
+
|
|
131
|
+
const owner = parseOwner(content)
|
|
132
|
+
if (owner) {
|
|
133
|
+
// A record from another host on a shared filesystem cannot be checked from
|
|
134
|
+
// here; it is live. A record without a host predates the field: local.
|
|
135
|
+
if (owner.hostname !== undefined && owner.hostname !== env.hostname) {
|
|
136
|
+
return { stats: before, content, pid: owner.pid, staleReason: undefined }
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
stats: before,
|
|
140
|
+
content,
|
|
141
|
+
pid: owner.pid,
|
|
142
|
+
staleReason: env.isPidAlive(owner.pid) ? undefined : `owner process ${owner.pid} is gone`,
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const age = env.now() - before.mtimeMs
|
|
146
|
+
return {
|
|
147
|
+
stats: before,
|
|
148
|
+
content,
|
|
149
|
+
pid: undefined,
|
|
150
|
+
staleReason:
|
|
151
|
+
age > MEMORY_LOCK_RECORD_GRACE_MS
|
|
152
|
+
? `lock holds no readable owner record and is ${Math.round(age / 1000)}s old`
|
|
153
|
+
: undefined,
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Creates `path` exclusively and writes this process's owner record. Returns
|
|
159
|
+
* undefined when the path is occupied. A failed write (a full disk) leaves no
|
|
160
|
+
* file behind.
|
|
161
|
+
*/
|
|
162
|
+
async function createOwned(
|
|
163
|
+
path: string,
|
|
164
|
+
env: Resolved,
|
|
165
|
+
): Promise<{ identity: Stats; owner: string } | undefined> {
|
|
166
|
+
let handle: Awaited<ReturnType<typeof open>>
|
|
167
|
+
try {
|
|
168
|
+
handle = await open(path, 'wx', 0o600)
|
|
169
|
+
} catch (error) {
|
|
170
|
+
if ((error as NodeJS.ErrnoException).code === 'EEXIST') return undefined
|
|
171
|
+
throw error
|
|
172
|
+
}
|
|
173
|
+
const owner = `${JSON.stringify({
|
|
174
|
+
pid: process.pid,
|
|
175
|
+
token: randomUUID(),
|
|
176
|
+
acquiredAt: Date.now(),
|
|
177
|
+
hostname: env.hostname,
|
|
178
|
+
})}\n`
|
|
179
|
+
let identity: Stats | undefined
|
|
180
|
+
try {
|
|
181
|
+
identity = await handle.stat()
|
|
182
|
+
await handle.writeFile(owner, 'utf8')
|
|
183
|
+
await handle.sync()
|
|
184
|
+
} catch (error) {
|
|
185
|
+
await handle.close().catch(() => undefined)
|
|
186
|
+
// We created this path exclusively a moment ago; drop it so no empty or
|
|
187
|
+
// partial record is left to block the next start.
|
|
188
|
+
const current = await lstat(path).catch(() => undefined)
|
|
189
|
+
if (!identity || (current?.dev === identity.dev && current.ino === identity.ino)) {
|
|
190
|
+
await unlink(path).catch(() => undefined)
|
|
191
|
+
}
|
|
192
|
+
throw error
|
|
193
|
+
}
|
|
194
|
+
await handle.close()
|
|
195
|
+
return { identity, owner }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function releaseOwned(
|
|
199
|
+
path: string,
|
|
200
|
+
identity: Stats,
|
|
201
|
+
owner: string,
|
|
202
|
+
timeoutMs: number,
|
|
203
|
+
): Promise<void> {
|
|
204
|
+
const current = await lstat(path)
|
|
205
|
+
if (
|
|
206
|
+
!current.isFile() ||
|
|
207
|
+
current.isSymbolicLink() ||
|
|
208
|
+
current.dev !== identity.dev ||
|
|
209
|
+
current.ino !== identity.ino ||
|
|
210
|
+
(await readFile(path, 'utf8')) !== owner
|
|
211
|
+
) {
|
|
212
|
+
throw lockError(
|
|
213
|
+
path,
|
|
214
|
+
`Memory operation lock ${path}: the lock owner changed before release.`,
|
|
215
|
+
timeoutMs,
|
|
216
|
+
)
|
|
217
|
+
}
|
|
218
|
+
await unlink(path)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function sameFile(a: Stats, b: Stats): boolean {
|
|
222
|
+
return a.dev === b.dev && a.ino === b.ino
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Unlinks `path` only if it is still exactly the stale file that was inspected. */
|
|
226
|
+
async function unlinkIfStillStale(path: string, seen: Inspection, env: Resolved): Promise<boolean> {
|
|
227
|
+
const again = await inspect(path, env)
|
|
228
|
+
if (!again || !sameFile(again.stats, seen.stats) || again.content !== seen.content) return false
|
|
229
|
+
if (again.staleReason === undefined) return false
|
|
230
|
+
// Narrow the window further: the inode must still be the inspected one now.
|
|
231
|
+
const last = await lstat(path).catch(() => undefined)
|
|
232
|
+
if (!last || !sameFile(last, seen.stats)) return false
|
|
233
|
+
await unlink(path).catch((error: NodeJS.ErrnoException) => {
|
|
234
|
+
if (error.code !== 'ENOENT') throw error
|
|
235
|
+
})
|
|
236
|
+
return true
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Removes a lock whose owner is provably gone. Two processes that see the same
|
|
241
|
+
* stale lock are serialised through `<lock>.break`; the lock is re-inspected
|
|
242
|
+
* under it and only the inspected inode is unlinked. Returns whether the lock
|
|
243
|
+
* was removed.
|
|
244
|
+
*/
|
|
245
|
+
async function breakStaleLock(
|
|
246
|
+
path: string,
|
|
247
|
+
seen: Inspection,
|
|
248
|
+
timeoutMs: number,
|
|
249
|
+
env: Resolved,
|
|
250
|
+
): Promise<boolean> {
|
|
251
|
+
const breakPath = `${path}.break`
|
|
252
|
+
let held: { identity: Stats; owner: string } | undefined
|
|
253
|
+
for (let attempt = 0; attempt < 2 && !held; attempt++) {
|
|
254
|
+
held = await createOwned(breakPath, env)
|
|
255
|
+
if (held) break
|
|
256
|
+
const breaker = await inspect(breakPath, env)
|
|
257
|
+
if (!breaker) continue
|
|
258
|
+
// A dead breaker's file follows the same rule, once.
|
|
259
|
+
if (breaker.staleReason === undefined || !(await unlinkIfStillStale(breakPath, breaker, env))) {
|
|
260
|
+
return false
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
if (!held) return false
|
|
264
|
+
try {
|
|
265
|
+
const removed = await unlinkIfStillStale(path, seen, env)
|
|
266
|
+
if (removed) {
|
|
267
|
+
env.logger?.info('Broke an abandoned memory operation lock', {
|
|
268
|
+
'namzu.store.lock_path': path,
|
|
269
|
+
'namzu.store.lock_reason': seen.staleReason,
|
|
270
|
+
})
|
|
271
|
+
}
|
|
272
|
+
return removed
|
|
273
|
+
} finally {
|
|
274
|
+
await releaseOwned(breakPath, held.identity, held.owner, timeoutMs).catch(() => undefined)
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
29
278
|
/**
|
|
30
279
|
* Local-filesystem exclusion for cooperating processes. Atomic rename of the
|
|
31
280
|
* index alone cannot serialize the index/content read-modify-write transaction.
|
|
32
|
-
*
|
|
33
|
-
*
|
|
281
|
+
* A lock whose owner is provably gone (dead pid on this host, or an owner-less
|
|
282
|
+
* file older than the grace period) is broken; a live or unverifiable owner is
|
|
283
|
+
* waited for until the timeout, never replaced on a guessed lease.
|
|
34
284
|
*/
|
|
35
285
|
export async function acquireMemoryOperationLock(
|
|
36
286
|
path: string,
|
|
37
287
|
timeoutMs: number,
|
|
288
|
+
options: MemoryOperationLockOptions = {},
|
|
38
289
|
): Promise<() => Promise<void>> {
|
|
290
|
+
const env: Resolved = {
|
|
291
|
+
logger: options.logger,
|
|
292
|
+
now: options.now ?? Date.now,
|
|
293
|
+
isPidAlive: options.isPidAlive ?? defaultIsPidAlive,
|
|
294
|
+
hostname: options.hostname ?? osHostname(),
|
|
295
|
+
}
|
|
39
296
|
const deadline = performance.now() + timeoutMs
|
|
40
297
|
let attempted = false
|
|
298
|
+
let lastPid: number | undefined
|
|
299
|
+
// The wait bound limits waiting for a live owner, not the attempt that
|
|
300
|
+
// follows a successful break: the lock is free then, and breaking it can
|
|
301
|
+
// itself outlast a short bound on a slow disk.
|
|
302
|
+
let justBroke = false
|
|
41
303
|
for (;;) {
|
|
42
|
-
if (attempted && performance.now() >= deadline) {
|
|
43
|
-
throw
|
|
304
|
+
if (attempted && !justBroke && performance.now() >= deadline) {
|
|
305
|
+
throw busyError(path, lastPid, timeoutMs)
|
|
44
306
|
}
|
|
307
|
+
justBroke = false
|
|
45
308
|
attempted = true
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
} catch (error) {
|
|
50
|
-
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
|
|
51
|
-
const existing = await lstat(path).catch((statError: NodeJS.ErrnoException) => {
|
|
52
|
-
if (statError.code === 'ENOENT') return undefined
|
|
53
|
-
throw statError
|
|
54
|
-
})
|
|
55
|
-
if (!existing) continue
|
|
56
|
-
if (!existing.isFile() || existing.isSymbolicLink()) {
|
|
57
|
-
throw lockError(path, 'the occupied lock is not a regular file', timeoutMs)
|
|
58
|
-
}
|
|
59
|
-
if (performance.now() >= deadline) {
|
|
60
|
-
throw lockError(path, `acquisition timed out after ${timeoutMs} ms`, timeoutMs)
|
|
61
|
-
}
|
|
62
|
-
await delay(Math.min(25, Math.max(1, deadline - performance.now())))
|
|
63
|
-
continue
|
|
309
|
+
const created = await createOwned(path, env)
|
|
310
|
+
if (created) {
|
|
311
|
+
return () => releaseOwned(path, created.identity, created.owner, timeoutMs)
|
|
64
312
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
try {
|
|
74
|
-
await handle.writeFile(owner, 'utf8')
|
|
75
|
-
await handle.sync()
|
|
76
|
-
} catch (error) {
|
|
77
|
-
await handle.close()
|
|
78
|
-
const current = await lstat(path).catch(() => undefined)
|
|
79
|
-
if (current?.dev === identity.dev && current.ino === identity.ino) {
|
|
80
|
-
await unlink(path).catch(() => undefined)
|
|
81
|
-
}
|
|
82
|
-
throw error
|
|
313
|
+
const seen = await inspect(path, env)
|
|
314
|
+
if (!seen) continue
|
|
315
|
+
if (!seen.stats.isFile() || seen.stats.isSymbolicLink()) {
|
|
316
|
+
throw lockError(
|
|
317
|
+
path,
|
|
318
|
+
`The stored memory lock ${path} is not a regular file, so Namzu will not use it. Move it aside to continue.`,
|
|
319
|
+
timeoutMs,
|
|
320
|
+
)
|
|
83
321
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
!current.isFile() ||
|
|
89
|
-
current.isSymbolicLink() ||
|
|
90
|
-
current.dev !== identity.dev ||
|
|
91
|
-
current.ino !== identity.ino ||
|
|
92
|
-
(await readFile(path, 'utf8')) !== owner
|
|
93
|
-
) {
|
|
94
|
-
throw lockError(path, 'the lock owner changed before release', timeoutMs)
|
|
95
|
-
}
|
|
96
|
-
await unlink(path)
|
|
322
|
+
lastPid = seen.pid
|
|
323
|
+
if (seen.staleReason !== undefined && (await breakStaleLock(path, seen, timeoutMs, env))) {
|
|
324
|
+
justBroke = true
|
|
325
|
+
continue
|
|
97
326
|
}
|
|
327
|
+
if (performance.now() >= deadline) throw busyError(path, lastPid, timeoutMs)
|
|
328
|
+
await delay(Math.min(25, Math.max(1, deadline - performance.now())))
|
|
98
329
|
}
|
|
99
330
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { existsSync, statSync } from 'node:fs'
|
|
1
2
|
import { mkdir, readdir, rename, rm, stat } from 'node:fs/promises'
|
|
2
3
|
import { createRequire } from 'node:module'
|
|
3
4
|
import { basename, dirname, join } from 'node:path'
|
|
@@ -305,32 +306,135 @@ async function sweepAbandonedRebuilds(path: string): Promise<void> {
|
|
|
305
306
|
}
|
|
306
307
|
}
|
|
307
308
|
|
|
309
|
+
/** `device:inode` of the file at `path`, or undefined when there is none. */
|
|
310
|
+
function fileIdentity(path: string): string | undefined {
|
|
311
|
+
try {
|
|
312
|
+
const { dev, ino } = statSync(path)
|
|
313
|
+
return `${dev}:${ino}`
|
|
314
|
+
} catch {
|
|
315
|
+
return undefined
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
308
319
|
function userVersion(db: DatabaseSync): number {
|
|
309
320
|
return Number(db.prepare('PRAGMA user_version').get()?.user_version)
|
|
310
321
|
}
|
|
311
322
|
|
|
323
|
+
/** SQLite primary result codes for "the image is malformed" and "the file is not a database". */
|
|
324
|
+
const SQLITE_CORRUPT = 11
|
|
325
|
+
const SQLITE_NOTADB = 26
|
|
326
|
+
|
|
327
|
+
/** Whether `error` says the file's content is not a usable SQLite database. */
|
|
328
|
+
function isNotADatabase(error: unknown): boolean {
|
|
329
|
+
const code = (error as { errcode?: unknown } | null)?.errcode
|
|
330
|
+
return typeof code === 'number' && [SQLITE_CORRUPT, SQLITE_NOTADB].includes(code & 0xff)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const SQLITE_BUSY = 5
|
|
334
|
+
const SQLITE_READONLY = 8
|
|
335
|
+
/** `SQLITE_READONLY_DBMOVED`: the file this connection opened was renamed or unlinked. */
|
|
336
|
+
const SQLITE_READONLY_DBMOVED = 1032
|
|
337
|
+
|
|
338
|
+
function errcode(error: unknown): number | undefined {
|
|
339
|
+
const code = (error as { errcode?: unknown } | null)?.errcode
|
|
340
|
+
return typeof code === 'number' ? code : undefined
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Whether `error` says another process holds the index locked past the busy timeout. */
|
|
344
|
+
function isBusy(error: unknown): boolean {
|
|
345
|
+
const code = errcode(error)
|
|
346
|
+
return code !== undefined && (code & 0xff) === SQLITE_BUSY
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** Whether `error` says the file under this connection was replaced by another process. */
|
|
350
|
+
function isMoved(error: unknown): boolean {
|
|
351
|
+
const code = errcode(error)
|
|
352
|
+
return code === SQLITE_READONLY_DBMOVED && (code & 0xff) === SQLITE_READONLY
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** A plain-language error for an index another process keeps locked. */
|
|
356
|
+
function busyError(path: string, cause: unknown): SessionIndexError {
|
|
357
|
+
return new SessionIndexError(
|
|
358
|
+
`The session index at ${path} is in use by another process. Try again in a moment.`,
|
|
359
|
+
{ cause },
|
|
360
|
+
)
|
|
361
|
+
}
|
|
362
|
+
|
|
312
363
|
/**
|
|
313
|
-
* The version of the index file at `path`: -1 when there is none, 0 when
|
|
314
|
-
*
|
|
364
|
+
* The version of the index file at `path`: -1 when there is none, 0 when its
|
|
365
|
+
* content is not a SQLite database, otherwise its `user_version`.
|
|
366
|
+
*
|
|
367
|
+
* The file is opened for writing, never read-only. A writer that died inside
|
|
368
|
+
* a transaction leaves a hot journal beside the file, and only a connection
|
|
369
|
+
* that may write can roll it back; a read-only one fails on it, which used to
|
|
370
|
+
* read as "not a database" and sent the caller to replace a healthy index,
|
|
371
|
+
* leaving the dead writer's journal beside the new file for the next
|
|
372
|
+
* connection to play back into it. Only a SQLite "corrupt" or "not a
|
|
373
|
+
* database" answer means 0; being busy, read-only or unable to read is thrown,
|
|
374
|
+
* never a reason to rebuild.
|
|
315
375
|
*/
|
|
316
376
|
function indexVersion(path: string): number {
|
|
377
|
+
if (!existsSync(path)) return -1
|
|
317
378
|
const { DatabaseSync } = requireSqlite()
|
|
318
|
-
|
|
319
|
-
try {
|
|
320
|
-
db = new DatabaseSync(path, { readOnly: true })
|
|
321
|
-
} catch {
|
|
322
|
-
return -1
|
|
323
|
-
}
|
|
379
|
+
const db = new DatabaseSync(path)
|
|
324
380
|
try {
|
|
325
381
|
db.exec('PRAGMA busy_timeout = 10000')
|
|
326
382
|
return userVersion(db)
|
|
327
|
-
} catch {
|
|
328
|
-
return 0
|
|
383
|
+
} catch (error) {
|
|
384
|
+
if (isNotADatabase(error)) return 0
|
|
385
|
+
throw isBusy(error) ? busyError(path, error) : error
|
|
329
386
|
} finally {
|
|
330
387
|
db.close()
|
|
331
388
|
}
|
|
332
389
|
}
|
|
333
390
|
|
|
391
|
+
/**
|
|
392
|
+
* Put the finished `temporary` file in place of the index at `path`.
|
|
393
|
+
*
|
|
394
|
+
* Takes the old index's exclusive lock first. That waits out a peer that is
|
|
395
|
+
* mid-write and rolls back a hot journal left by one that died, so no journal
|
|
396
|
+
* for the old file survives to be played into the new one. A leftover journal
|
|
397
|
+
* of a file that is not a database is removed too: it can describe nothing
|
|
398
|
+
* but that file.
|
|
399
|
+
*/
|
|
400
|
+
async function replaceIndexFile(temporary: string, path: string, force: boolean): Promise<boolean> {
|
|
401
|
+
let old: DatabaseSync | undefined
|
|
402
|
+
if (existsSync(path)) {
|
|
403
|
+
const { DatabaseSync } = requireSqlite()
|
|
404
|
+
old = new DatabaseSync(path)
|
|
405
|
+
try {
|
|
406
|
+
old.exec('PRAGMA busy_timeout = 10000')
|
|
407
|
+
old.exec('BEGIN EXCLUSIVE')
|
|
408
|
+
// A peer may have finished its own rebuild while this one ran. With the
|
|
409
|
+
// lock held, nobody can change the file between this check and the
|
|
410
|
+
// rename, so a current index is left alone rather than replaced under
|
|
411
|
+
// the connections that already adopted it.
|
|
412
|
+
if (!force && userVersion(old) === SESSION_INDEX_VERSION) {
|
|
413
|
+
old.close()
|
|
414
|
+
old = undefined
|
|
415
|
+
return false
|
|
416
|
+
}
|
|
417
|
+
} catch (error) {
|
|
418
|
+
old?.close()
|
|
419
|
+
old = undefined
|
|
420
|
+
if (!isNotADatabase(error)) throw isBusy(error) ? busyError(path, error) : error
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
try {
|
|
424
|
+
// Windows will not rename over a file that is open, so the lock is let go
|
|
425
|
+
// first there; elsewhere it is held until the new file is in place.
|
|
426
|
+
if (process.platform === 'win32') {
|
|
427
|
+
old?.close()
|
|
428
|
+
old = undefined
|
|
429
|
+
}
|
|
430
|
+
await rm(`${path}-journal`, { force: true })
|
|
431
|
+
await rename(temporary, path)
|
|
432
|
+
return true
|
|
433
|
+
} finally {
|
|
434
|
+
old?.close()
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
334
438
|
export interface SqliteSessionIndexOptions {
|
|
335
439
|
/** The index file, normally `$NAMZU_HOME/index.sqlite`. */
|
|
336
440
|
readonly path: string
|
|
@@ -371,15 +475,21 @@ export function rebuildSqliteSessionIndex(
|
|
|
371
475
|
*/
|
|
372
476
|
export class SqliteSessionIndex extends SessionIndexBase {
|
|
373
477
|
readonly backend = 'sqlite' as const
|
|
374
|
-
|
|
478
|
+
#db: DatabaseSync
|
|
479
|
+
/** Where `#db` was opened, or undefined for a temporary build file that is never watched. */
|
|
480
|
+
readonly #path: string | undefined
|
|
481
|
+
/** Identity of the file `#db` has open, to notice another process replacing it. */
|
|
482
|
+
#identity: string | undefined
|
|
375
483
|
readonly #sink: IndexSink
|
|
376
484
|
readonly #statements = new Map<string, StatementSync>()
|
|
377
485
|
#open = true
|
|
378
486
|
|
|
379
487
|
/** Wraps an open database whose schema is current. Use {@link SqliteSessionIndex.open}. */
|
|
380
|
-
private constructor(db: DatabaseSync) {
|
|
488
|
+
private constructor(db: DatabaseSync, path?: string) {
|
|
381
489
|
super()
|
|
382
490
|
this.#db = db
|
|
491
|
+
this.#path = path
|
|
492
|
+
this.#identity = path === undefined ? undefined : fileIdentity(path)
|
|
383
493
|
const run = (sql: string, ...values: SQLInputValue[]) => {
|
|
384
494
|
this.#statement(sql).run(...values)
|
|
385
495
|
}
|
|
@@ -583,8 +693,7 @@ export class SqliteSessionIndex extends SessionIndexBase {
|
|
|
583
693
|
if (!options.force && (await pathExists(options.path))) {
|
|
584
694
|
if (indexVersion(options.path) === SESSION_INDEX_VERSION) return false
|
|
585
695
|
}
|
|
586
|
-
await
|
|
587
|
-
return true
|
|
696
|
+
return await replaceIndexFile(temporary, options.path, options.force === true)
|
|
588
697
|
} finally {
|
|
589
698
|
await rm(temporary, { force: true })
|
|
590
699
|
await rm(`${temporary}-journal`, { force: true })
|
|
@@ -615,13 +724,55 @@ export class SqliteSessionIndex extends SessionIndexBase {
|
|
|
615
724
|
db.close()
|
|
616
725
|
throw error
|
|
617
726
|
}
|
|
618
|
-
const index = new SqliteSessionIndex(db)
|
|
727
|
+
const index = new SqliteSessionIndex(db, options.path)
|
|
619
728
|
await index.sync(options.home)
|
|
620
729
|
return index
|
|
621
730
|
}
|
|
622
731
|
|
|
732
|
+
/**
|
|
733
|
+
* Another process rebuilt the index and renamed a new file over it: this
|
|
734
|
+
* connection still has the old, unlinked file, whose reads go stale and
|
|
735
|
+
* whose writes fail with SQLITE_READONLY_DBMOVED. Reopen the file now at
|
|
736
|
+
* the path. A no-op for a temporary build file, and when the path holds
|
|
737
|
+
* nothing to adopt.
|
|
738
|
+
*/
|
|
739
|
+
#reopenIfReplaced(force = false): void {
|
|
740
|
+
const path = this.#path
|
|
741
|
+
if (path === undefined || !this.#open) return
|
|
742
|
+
const identity = fileIdentity(path)
|
|
743
|
+
if (identity === undefined || (!force && identity === this.#identity)) return
|
|
744
|
+
const { DatabaseSync } = requireSqlite()
|
|
745
|
+
const db = new DatabaseSync(path)
|
|
746
|
+
try {
|
|
747
|
+
configure(db)
|
|
748
|
+
if (userVersion(db) !== SESSION_INDEX_VERSION) {
|
|
749
|
+
throw new SessionIndexError(`The index at ${path} changed version while in use.`)
|
|
750
|
+
}
|
|
751
|
+
} catch (error) {
|
|
752
|
+
db.close()
|
|
753
|
+
throw error
|
|
754
|
+
}
|
|
755
|
+
const previous = this.#db
|
|
756
|
+
this.#statements.clear()
|
|
757
|
+
this.#db = db
|
|
758
|
+
this.#identity = fileIdentity(path)
|
|
759
|
+
try {
|
|
760
|
+
previous.close()
|
|
761
|
+
} catch {
|
|
762
|
+
// The old file is already unlinked; nothing is lost if it will not close.
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
623
766
|
protected transaction<T>(work: (sink: IndexSink) => T): T {
|
|
624
|
-
this.#
|
|
767
|
+
this.#reopenIfReplaced()
|
|
768
|
+
try {
|
|
769
|
+
this.#db.exec('BEGIN IMMEDIATE')
|
|
770
|
+
} catch (error) {
|
|
771
|
+
// Nothing has run yet, so the work can start again on the new file.
|
|
772
|
+
if (!isMoved(error)) throw error
|
|
773
|
+
this.#reopenIfReplaced(true)
|
|
774
|
+
this.#db.exec('BEGIN IMMEDIATE')
|
|
775
|
+
}
|
|
625
776
|
try {
|
|
626
777
|
const result = work(this.#sink)
|
|
627
778
|
this.#db.exec('COMMIT')
|
|
@@ -643,10 +794,12 @@ export class SqliteSessionIndex extends SessionIndexBase {
|
|
|
643
794
|
}
|
|
644
795
|
|
|
645
796
|
#all(sql: string, ...values: SQLInputValue[]): Row[] {
|
|
797
|
+
this.#reopenIfReplaced()
|
|
646
798
|
return this.#statement(sql).all(...values) as Row[]
|
|
647
799
|
}
|
|
648
800
|
|
|
649
801
|
protected readSession(sessionId: SessionId): SessionRow | undefined {
|
|
802
|
+
this.#reopenIfReplaced()
|
|
650
803
|
const row = this.#statement('SELECT * FROM sessions WHERE id = ?').get(sessionId) as
|
|
651
804
|
| Row
|
|
652
805
|
| undefined
|
|
@@ -707,6 +860,7 @@ export class SqliteSessionIndex extends SessionIndexBase {
|
|
|
707
860
|
kind: ExternalRefKind,
|
|
708
861
|
externalId: string,
|
|
709
862
|
): Promise<ExternalRefTarget | undefined> {
|
|
863
|
+
this.#reopenIfReplaced()
|
|
710
864
|
const row = this.#db
|
|
711
865
|
.prepare(
|
|
712
866
|
`SELECT * FROM external_refs WHERE protocol = ? AND kind = ? AND external_id = ?
|
|
@@ -749,6 +903,7 @@ export class SqliteSessionIndex extends SessionIndexBase {
|
|
|
749
903
|
values.push(options.sessionId)
|
|
750
904
|
}
|
|
751
905
|
const clause = where.length === 0 ? '' : `WHERE ${where.join(' AND ')}`
|
|
906
|
+
this.#reopenIfReplaced()
|
|
752
907
|
const statement = this.#db.prepare(
|
|
753
908
|
`SELECT text, session_id, turn_id, seq, part, source, tool_name, is_error FROM evidence_fts
|
|
754
909
|
${clause} ORDER BY session_id, seq, part`,
|
|
@@ -751,12 +751,14 @@ const BashToolBase = defineTool({
|
|
|
751
751
|
clipped.length > 0
|
|
752
752
|
? `[${clipped.join(' and ')} was truncated by the sandbox output cap. The omitted output is unavailable in this result. Use a saved artifact or a read-only observation; do not repeat a state-changing action to recover its output.]`
|
|
753
753
|
: '',
|
|
754
|
+
result.attribution ? `[${result.attribution.message}]` : '',
|
|
754
755
|
]
|
|
755
756
|
.filter(Boolean)
|
|
756
757
|
.join('\n\n')
|
|
757
758
|
|
|
758
759
|
const judged = result.timedOut ? undefined : judgeExit(input.command, result.exitCode)
|
|
759
|
-
const answered =
|
|
760
|
+
const answered =
|
|
761
|
+
judged !== undefined && !judged.isError && result.attribution?.kind !== 'runner-failure'
|
|
760
762
|
return {
|
|
761
763
|
success: answered,
|
|
762
764
|
output: output || '(no output)',
|
|
@@ -767,12 +769,17 @@ const BashToolBase = defineTool({
|
|
|
767
769
|
timedOut: result.timedOut,
|
|
768
770
|
stdoutTruncated: result.stdoutTruncated ?? false,
|
|
769
771
|
stderrTruncated: result.stderrTruncated ?? false,
|
|
772
|
+
...(result.attribution ? { sandboxAttribution: result.attribution.kind } : {}),
|
|
770
773
|
},
|
|
771
774
|
error: result.timedOut
|
|
772
775
|
? `Command timed out after ${input.timeout}ms. Any captured output before the deadline is above.`
|
|
773
|
-
:
|
|
774
|
-
?
|
|
775
|
-
:
|
|
776
|
+
: result.attribution?.kind === 'runner-failure'
|
|
777
|
+
? 'The sandbox launcher failed before the command ran (runner failure, not a command failure).'
|
|
778
|
+
: !answered
|
|
779
|
+
? result.attribution?.kind === 'denial'
|
|
780
|
+
? `Command exited with code ${result.exitCode}; looks like a sandbox denial`
|
|
781
|
+
: `Command exited with code ${result.exitCode}`
|
|
782
|
+
: undefined,
|
|
776
783
|
facts: commandFacts({
|
|
777
784
|
command: input.command,
|
|
778
785
|
exitCode: result.exitCode,
|