@sayknow-cli/coding-agent 0.3.12 → 0.3.15
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/dist/types/autoresearch/tools/init-experiment.d.ts +1 -1
- package/dist/types/autoresearch/tools/log-experiment.d.ts +2 -2
- package/dist/types/cli/classify-install-target.d.ts +0 -10
- package/dist/types/cli/daemon-cli.d.ts +2 -0
- package/dist/types/cli/initial-message.d.ts +1 -1
- package/dist/types/cli/malloc-env-guard.d.ts +65 -0
- package/dist/types/cli/malloc-env-guard.test.d.ts +1 -0
- package/dist/types/cli/notify-cli.d.ts +3 -1
- package/dist/types/cli/session-picker.d.ts +4 -3
- package/dist/types/cli/update-cli.d.ts +22 -3
- package/dist/types/cli.d.ts +5 -0
- package/dist/types/commands/daemon.d.ts +6 -2
- package/dist/types/commands/launch.d.ts +1 -0
- package/dist/types/commands/notify.d.ts +6 -0
- package/dist/types/commands/web-search.d.ts +1 -1
- package/dist/types/commit/agentic/tools/propose-changelog.d.ts +4 -4
- package/dist/types/commit/agentic/tools/propose-commit.d.ts +9 -9
- package/dist/types/commit/agentic/tools/schemas.d.ts +9 -9
- package/dist/types/commit/agentic/tools/split-commit.d.ts +9 -9
- package/dist/types/commit/analysis/conventional.d.ts +1 -1
- package/dist/types/commit/analysis/summary.d.ts +1 -1
- package/dist/types/commit/changelog/generate.d.ts +3 -3
- package/dist/types/commit/changelog/index.d.ts +2 -2
- package/dist/types/commit/map-reduce/map-phase.d.ts +1 -1
- package/dist/types/commit/map-reduce/reduce-phase.d.ts +1 -1
- package/dist/types/commit/shared-llm.d.ts +9 -9
- package/dist/types/config/keybindings.d.ts +134 -129
- package/dist/types/config/model-profile-activation.d.ts +1 -0
- package/dist/types/config/model-registry.d.ts +35 -35
- package/dist/types/config/models-config-schema.d.ts +111 -111
- package/dist/types/config/settings-schema.d.ts +56 -16
- package/dist/types/config/settings.d.ts +2 -0
- package/dist/types/coordinator/contract.d.ts +1 -1
- package/dist/types/coordinator-mcp/model-preset.d.ts +45 -0
- package/dist/types/coordinator-mcp/policy.d.ts +3 -0
- package/dist/types/coordinator-mcp/server.d.ts +79 -6
- package/dist/types/coordinator-mcp/session-reaper.d.ts +53 -0
- package/dist/types/daemon/control-types.d.ts +10 -0
- package/dist/types/daemon/operator-contract.d.ts +47 -0
- package/dist/types/defaults/skc/extensions/grok-cli-vendor/src/models/catalog.d.ts +4 -0
- package/dist/types/defaults/skc/extensions/grok-cli-vendor/src/shared/errors.d.ts +12 -12
- package/dist/types/discovery/helpers.d.ts +1 -1
- package/dist/types/discovery/substitute-plugin-root.d.ts +0 -4
- package/dist/types/edit/modes/patch.d.ts +2 -2
- package/dist/types/extensibility/extensions/types.d.ts +3 -1
- package/dist/types/goals/tools/goal-tool.d.ts +6 -6
- package/dist/types/i18n/messages/en.d.ts +8 -8
- package/dist/types/internal-urls/local-protocol.d.ts +13 -9
- package/dist/types/lsp/index.d.ts +9 -9
- package/dist/types/lsp/types.d.ts +9 -9
- package/dist/types/main.d.ts +66 -2
- package/dist/types/modes/acp/acp-agent.d.ts +19 -2
- package/dist/types/modes/acp/acp-mode.d.ts +12 -0
- package/dist/types/modes/components/assistant-message.d.ts +1 -1
- package/dist/types/modes/components/custom-editor.d.ts +2 -0
- package/dist/types/modes/components/hook-selector.d.ts +5 -0
- package/dist/types/modes/components/irc-sidebar.d.ts +32 -0
- package/dist/types/modes/components/pet-selector.d.ts +11 -0
- package/dist/types/modes/components/sayknow-pet-widget.d.ts +59 -0
- package/dist/types/modes/components/session-selector.d.ts +22 -9
- package/dist/types/modes/components/settings-selector.d.ts +2 -0
- package/dist/types/modes/components/status-line/types.d.ts +1 -1
- package/dist/types/modes/components/status-line.d.ts +0 -10
- package/dist/types/modes/components/tool-execution.d.ts +1 -0
- package/dist/types/modes/components/user-message.d.ts +2 -1
- package/dist/types/modes/controllers/event-controller.d.ts +4 -0
- package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
- package/dist/types/modes/interactive-mode.d.ts +24 -2
- package/dist/types/modes/irc-observation-ledger.d.ts +22 -0
- package/dist/types/modes/print-mode.d.ts +3 -4
- package/dist/types/modes/rpc/rpc-client.d.ts +7 -1
- package/dist/types/modes/rpc/rpc-mode.d.ts +2 -1
- package/dist/types/modes/rpc/rpc-types.d.ts +17 -1
- package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +8 -0
- package/dist/types/modes/shared/agent-wire/unattended-run-controller.d.ts +5 -0
- package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +45 -0
- package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +42 -2
- package/dist/types/modes/theme/defaults/index.d.ts +568 -568
- package/dist/types/modes/types.d.ts +28 -2
- package/dist/types/modes/utils/abort-message.d.ts +1 -1
- package/dist/types/modes/utils/context-usage.d.ts +4 -19
- package/dist/types/modes/utils/irc-message.d.ts +38 -0
- package/dist/types/modes/utils/ui-helpers.d.ts +13 -4
- package/dist/types/notifications/config.d.ts +34 -0
- package/dist/types/notifications/daemon-paths.d.ts +7 -0
- package/dist/types/notifications/index.d.ts +27 -0
- package/dist/types/notifications/lifecycle-control-runtime.d.ts +12 -4
- package/dist/types/notifications/notification-service.d.ts +144 -0
- package/dist/types/notifications/rate-limit-pool.d.ts +27 -9
- package/dist/types/notifications/telegram-daemon-control.d.ts +6 -0
- package/dist/types/notifications/telegram-daemon.d.ts +130 -3
- package/dist/types/notifications/telegram-reference.d.ts +19 -2
- package/dist/types/notifications/topic-registry.d.ts +24 -4
- package/dist/types/registry/agent-registry.d.ts +2 -0
- package/dist/types/rlm/index.d.ts +1 -1
- package/dist/types/sdk.d.ts +2 -0
- package/dist/types/secrets/obfuscator.d.ts +1 -1
- package/dist/types/session/agent-session.d.ts +40 -3
- package/dist/types/session/cache-economics.d.ts +79 -0
- package/dist/types/session/context-estimation.d.ts +31 -0
- package/dist/types/session/provider-safety-stop.d.ts +5 -0
- package/dist/types/session/session-manager.d.ts +124 -4
- package/dist/types/session/session-storage.d.ts +155 -13
- package/dist/types/setup/credential-auto-import.d.ts +2 -2
- package/dist/types/skc-runtime/session-state-sidecar.d.ts +34 -3
- package/dist/types/skc-runtime/state-schema.d.ts +19 -19
- package/dist/types/skc-runtime/tmux-common.d.ts +6 -0
- package/dist/types/skc-runtime/tmux-owner-isolation-cli.d.ts +5 -0
- package/dist/types/skc-runtime/tmux-owner-isolation.d.ts +309 -0
- package/dist/types/skc-runtime/tmux-sessions.d.ts +49 -17
- package/dist/types/task/agents.d.ts +2 -2
- package/dist/types/task/executor.d.ts +3 -0
- package/dist/types/task/receipt.d.ts +3 -1
- package/dist/types/task/types.d.ts +35 -27
- package/dist/types/tools/ask-answer-registry.d.ts +5 -0
- package/dist/types/tools/bash.d.ts +1 -1
- package/dist/types/tools/browser/tab-supervisor.d.ts +6 -0
- package/dist/types/tools/browser.d.ts +28 -28
- package/dist/types/tools/computer.d.ts +20 -20
- package/dist/types/tools/cron.d.ts +2 -2
- package/dist/types/tools/debug.d.ts +40 -40
- package/dist/types/tools/gh.d.ts +10 -10
- package/dist/types/tools/image-gen.d.ts +2 -2
- package/dist/types/tools/index.d.ts +58 -6
- package/dist/types/tools/monitor.d.ts +6 -6
- package/dist/types/tools/skill-discovery.d.ts +4 -4
- package/dist/types/tools/subagent.d.ts +12 -12
- package/dist/types/tools/telegram-send.d.ts +7 -0
- package/dist/types/tools/todo-write.d.ts +8 -8
- package/dist/types/utils/external-editor.d.ts +2 -0
- package/dist/types/web/insane/bridge.d.ts +9 -9
- package/dist/types/web/search/index.d.ts +4 -4
- package/package.json +8 -8
- package/scripts/g004-tmux-smoke.ts +196 -36
- package/src/cli/daemon-cli.ts +19 -29
- package/src/cli/malloc-env-guard.test.ts +104 -0
- package/src/cli/malloc-env-guard.ts +149 -0
- package/src/cli/notify-cli.ts +78 -14
- package/src/cli/session-picker.ts +23 -40
- package/src/cli/update-cli.ts +56 -37
- package/src/cli.ts +51 -10
- package/src/commands/daemon.ts +19 -5
- package/src/commands/harness.ts +515 -47
- package/src/commands/launch.ts +11 -3
- package/src/commands/notify.ts +5 -1
- package/src/commands/session.ts +6 -1
- package/src/config/keybindings.ts +5 -0
- package/src/config/model-profile-activation.ts +1 -1
- package/src/config/model-profiles.ts +14 -14
- package/src/config/settings-schema.ts +41 -1
- package/src/config/settings.ts +96 -45
- package/src/coordinator/contract.ts +1 -0
- package/src/coordinator-mcp/model-preset.ts +100 -0
- package/src/coordinator-mcp/policy.ts +30 -0
- package/src/coordinator-mcp/server.ts +2958 -430
- package/src/coordinator-mcp/session-reaper.ts +129 -0
- package/src/daemon/control-types.ts +11 -0
- package/src/daemon/operator-contract.ts +121 -0
- package/src/defaults/skc/extensions/grok-cli-vendor/src/models/catalog.ts +57 -17
- package/src/defaults/skc/extensions/grok-cli-vendor/src/payload/sanitize.ts +15 -2
- package/src/defaults/skc/extensions/grok-cli-vendor/src/provider/register.ts +5 -1
- package/src/extensibility/extensions/runner.ts +1 -1
- package/src/extensibility/extensions/types.ts +3 -1
- package/src/internal-urls/docs-index.generated.ts +10 -9
- package/src/internal-urls/local-protocol.ts +29 -10
- package/src/main.ts +301 -80
- package/src/modes/acp/acp-agent.ts +730 -100
- package/src/modes/acp/acp-mode.ts +29 -2
- package/src/modes/components/assistant-message.ts +32 -3
- package/src/modes/components/bash-execution.ts +43 -13
- package/src/modes/components/custom-editor.ts +5 -0
- package/src/modes/components/footer.ts +3 -3
- package/src/modes/components/hook-selector.ts +12 -0
- package/src/modes/components/irc-sidebar.ts +206 -0
- package/src/modes/components/pet-selector.ts +55 -0
- package/src/modes/components/sayknow-pet-widget.ts +360 -0
- package/src/modes/components/session-selector.ts +222 -221
- package/src/modes/components/settings-selector.ts +10 -0
- package/src/modes/components/status-line/segments.ts +14 -7
- package/src/modes/components/status-line/types.ts +1 -1
- package/src/modes/components/status-line.ts +4 -171
- package/src/modes/components/tool-execution.ts +47 -6
- package/src/modes/components/user-message.ts +26 -9
- package/src/modes/controllers/command-controller.ts +28 -2
- package/src/modes/controllers/event-controller.ts +159 -36
- package/src/modes/controllers/extension-ui-controller.ts +37 -18
- package/src/modes/controllers/input-controller.ts +8 -2
- package/src/modes/controllers/selector-controller.ts +62 -15
- package/src/modes/interactive-mode.ts +199 -7
- package/src/modes/irc-observation-ledger.ts +127 -0
- package/src/modes/print-mode.ts +228 -92
- package/src/modes/rpc/rpc-client.ts +58 -1
- package/src/modes/rpc/rpc-mode.ts +286 -85
- package/src/modes/rpc/rpc-types.ts +15 -1
- package/src/modes/shared/agent-wire/command-dispatch.ts +33 -1
- package/src/modes/shared/agent-wire/command-validation.ts +10 -0
- package/src/modes/shared/agent-wire/deep-interview-gate.ts +45 -2
- package/src/modes/shared/agent-wire/scopes.ts +1 -0
- package/src/modes/shared/agent-wire/unattended-session.ts +288 -1
- package/src/modes/shared/agent-wire/workflow-gate-broker.ts +130 -20
- package/src/modes/types.ts +34 -2
- package/src/modes/utils/context-usage.ts +56 -92
- package/src/modes/utils/hotkeys-markdown.ts +1 -0
- package/src/modes/utils/irc-message.ts +138 -0
- package/src/modes/utils/ui-helpers.ts +216 -50
- package/src/notifications/config.ts +44 -0
- package/src/notifications/daemon-paths.ts +8 -0
- package/src/notifications/index.ts +535 -75
- package/src/notifications/lifecycle-control-runtime.ts +930 -77
- package/src/notifications/notification-service.ts +710 -0
- package/src/notifications/rate-limit-pool.ts +46 -12
- package/src/notifications/telegram-daemon-control.ts +27 -2
- package/src/notifications/telegram-daemon.ts +635 -94
- package/src/notifications/telegram-reference.ts +73 -13
- package/src/notifications/topic-registry.ts +86 -9
- package/src/prompts/system/irc-peer-roster.md +1 -0
- package/src/prompts/system/subagent-system-prompt.md +1 -0
- package/src/prompts/system/system-prompt.md +1 -0
- package/src/prompts/tools/irc.md +1 -1
- package/src/registry/agent-registry.ts +3 -0
- package/src/sdk.ts +52 -1
- package/src/secrets/obfuscator.ts +4 -2
- package/src/session/agent-session.ts +670 -217
- package/src/session/cache-economics.ts +275 -0
- package/src/session/context-estimation.ts +91 -0
- package/src/session/provider-safety-stop.ts +9 -0
- package/src/session/session-dump-format.ts +6 -0
- package/src/session/session-manager.ts +809 -39
- package/src/session/session-storage.ts +582 -38
- package/src/skc-runtime/session-state-sidecar.ts +756 -135
- package/src/skc-runtime/tmux-common.ts +26 -7
- package/src/skc-runtime/tmux-gc.ts +52 -9
- package/src/skc-runtime/tmux-owner-isolation-cli.ts +185 -0
- package/src/skc-runtime/tmux-owner-isolation.ts +2060 -0
- package/src/skc-runtime/tmux-sessions.ts +871 -68
- package/src/slash-commands/builtin-registry.ts +153 -9
- package/src/slash-commands/helpers/context-report.ts +26 -4
- package/src/task/executor.ts +12 -3
- package/src/task/index.ts +8 -0
- package/src/task/receipt.ts +7 -2
- package/src/task/types.ts +26 -0
- package/src/tools/ask-answer-registry.ts +25 -0
- package/src/tools/browser/tab-supervisor.ts +63 -6
- package/src/tools/browser.ts +8 -3
- package/src/tools/index.ts +81 -6
- package/src/tools/sqlite-reader.ts +8 -4
- package/src/tui/output-block.ts +18 -6
- package/src/utils/external-editor.ts +6 -1
|
@@ -1,14 +1,84 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as fsp from "node:fs/promises";
|
|
3
3
|
import * as path from "node:path";
|
|
4
|
-
import { isEnoent, peekFile, toError } from "@sayknow-cli/utils";
|
|
4
|
+
import { isEnoent, pathIsWithin, peekFile, toError } from "@sayknow-cli/utils";
|
|
5
5
|
|
|
6
6
|
const utf8Decoder = new TextDecoder("utf-8");
|
|
7
7
|
|
|
8
8
|
export interface SessionStorageStat {
|
|
9
|
+
dev: bigint;
|
|
10
|
+
ino: bigint;
|
|
11
|
+
|
|
9
12
|
size: number;
|
|
10
13
|
mtimeMs: number;
|
|
14
|
+
mtimeNs: bigint;
|
|
11
15
|
mtime: Date;
|
|
16
|
+
isFile: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Exact bytes and identity captured from one opened regular-file descriptor. */
|
|
20
|
+
export interface SessionStorageSnapshot {
|
|
21
|
+
bytes: Uint8Array;
|
|
22
|
+
stat: SessionStorageStat;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function statFromNode(stats: fs.BigIntStats): SessionStorageStat {
|
|
26
|
+
return {
|
|
27
|
+
dev: stats.dev,
|
|
28
|
+
ino: stats.ino,
|
|
29
|
+
|
|
30
|
+
size: Number(stats.size),
|
|
31
|
+
mtimeMs: Number(stats.mtimeMs),
|
|
32
|
+
mtimeNs: stats.mtimeNs,
|
|
33
|
+
mtime: stats.mtime,
|
|
34
|
+
isFile: stats.isFile(),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// =============================================================================
|
|
39
|
+
// Certainty-aware writer close contract (ACP fail-closed deletion foundation)
|
|
40
|
+
// =============================================================================
|
|
41
|
+
/**
|
|
42
|
+
* Four-state writer close lifecycle. Only a successful underlying close confirms
|
|
43
|
+
* `closed`. A failure certified to have happened BEFORE the OS close was dispatched
|
|
44
|
+
* is `close_failed_retryable` (ownership of the numeric fd is still proven, so a
|
|
45
|
+
* later retry/finalizer close is safe). Any exception from an actually dispatched
|
|
46
|
+
* close call is terminal `close_unknown`: the numeric fd cannot be safely retried
|
|
47
|
+
* or finalizer-closed, and the writer blocks strict deletion.
|
|
48
|
+
*/
|
|
49
|
+
export type SessionStorageWriterCloseState = "open" | "close_failed_retryable" | "close_unknown" | "closed";
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Thrown by a {@link SessionStorageWriterCloseAdapter} to certify that a close
|
|
53
|
+
* failure occurred BEFORE the real OS close (`fs.closeSync`-equivalent) was ever
|
|
54
|
+
* dispatched. Because no OS close ran, the numeric fd is still owned and a retry
|
|
55
|
+
* is safe. Any other thrown value is treated as a dispatched close failure
|
|
56
|
+
* (`close_unknown`) and forbids retry/finalizer close of that fd.
|
|
57
|
+
*/
|
|
58
|
+
export class SessionStorageWriterRetryableCloseError extends Error {
|
|
59
|
+
override readonly name = "SessionStorageWriterRetryableCloseError";
|
|
60
|
+
constructor(message?: string, options?: ErrorOptions) {
|
|
61
|
+
super(message ?? "Certified pre-dispatch writer close failure", options);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Injectable dispatcher for the numeric-fd OS close. The default implementation
|
|
67
|
+
* calls `fs.closeSync(fd)`. Tests inject adapters that throw
|
|
68
|
+
* {@link SessionStorageWriterRetryableCloseError} to certify a pre-dispatch
|
|
69
|
+
* failure, or that call the real close and throw to simulate a dispatched
|
|
70
|
+
* failure (`close_unknown`).
|
|
71
|
+
*/
|
|
72
|
+
export interface SessionStorageWriterCloseAdapter {
|
|
73
|
+
close(fd: number): void;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Options for opening a {@link SessionStorageWriter}. */
|
|
77
|
+
export interface SessionStorageWriterOpenOptions {
|
|
78
|
+
flags?: "a" | "w";
|
|
79
|
+
onError?: (err: Error) => void;
|
|
80
|
+
/** Injectable OS-close dispatcher; defaults to `fs.closeSync`. */
|
|
81
|
+
closeAdapter?: SessionStorageWriterCloseAdapter;
|
|
12
82
|
}
|
|
13
83
|
|
|
14
84
|
export interface SessionStorageWriter {
|
|
@@ -24,7 +94,18 @@ export interface SessionStorageWriter {
|
|
|
24
94
|
flush(): Promise<void>;
|
|
25
95
|
fsync(): Promise<void>;
|
|
26
96
|
close(): Promise<void>;
|
|
97
|
+
/**
|
|
98
|
+
* Synchronously close the underlying descriptor. The certainty-aware close
|
|
99
|
+
* state is updated synchronously and any close failure throws before this
|
|
100
|
+
* returns, so sync callers (atomic rewrite) can observe a close failure
|
|
101
|
+
* before proceeding to rename. Mirrors {@link close} semantics exactly.
|
|
102
|
+
*/
|
|
103
|
+
closeSync(): void;
|
|
27
104
|
getError(): Error | undefined;
|
|
105
|
+
/** Current certainty-aware close lifecycle state. */
|
|
106
|
+
getCloseState(): SessionStorageWriterCloseState;
|
|
107
|
+
/** Stored error for non-success close states (`close_failed_retryable`/`close_unknown`). */
|
|
108
|
+
getCloseError(): Error | undefined;
|
|
28
109
|
}
|
|
29
110
|
|
|
30
111
|
export interface SessionStorage {
|
|
@@ -32,8 +113,17 @@ export interface SessionStorage {
|
|
|
32
113
|
existsSync(path: string): boolean;
|
|
33
114
|
writeTextSync(path: string, content: string): void;
|
|
34
115
|
readTextSync(path: string): string;
|
|
116
|
+
/** Exact on-disk bytes for strict read-only session inspection. */
|
|
117
|
+
readBytesSync?(path: string): Uint8Array;
|
|
118
|
+
/** Exact bytes and descriptor-bound identity captured from one opened regular file. */
|
|
119
|
+
readSnapshotSync?(path: string): SessionStorageSnapshot;
|
|
35
120
|
statSync(path: string): SessionStorageStat;
|
|
36
121
|
listFilesSync(dir: string, pattern: string): string[];
|
|
122
|
+
/**
|
|
123
|
+
* Strict directory scan that never suppresses scan/root errors. Used by strict
|
|
124
|
+
* authorization inventory; the forgiving {@link listFilesSync} stays display-only.
|
|
125
|
+
*/
|
|
126
|
+
listFilesStrictSync?(dir: string, pattern: string): string[];
|
|
37
127
|
|
|
38
128
|
exists(path: string): Promise<boolean>;
|
|
39
129
|
readText(path: string): Promise<string>;
|
|
@@ -44,9 +134,108 @@ export interface SessionStorage {
|
|
|
44
134
|
unlink(path: string): Promise<void>;
|
|
45
135
|
unlinkSync(path: string): void;
|
|
46
136
|
deleteSessionWithArtifacts(sessionPath: string): Promise<void>;
|
|
47
|
-
|
|
137
|
+
/**
|
|
138
|
+
* Verified hard delete bound to exact identity evidence. Removes the verified
|
|
139
|
+
* artifact directory first, revalidates, and unlinks the transcript last. Returns
|
|
140
|
+
* typed partial-cleanup evidence for exact-identity retry; never returns success
|
|
141
|
+
* for a partial deletion.
|
|
142
|
+
*/
|
|
143
|
+
deleteSessionVerified?(target: VerifiedSessionDeleteTarget): Promise<VerifiedSessionDeleteResult>;
|
|
144
|
+
openWriter(path: string, options?: SessionStorageWriterOpenOptions): SessionStorageWriter;
|
|
48
145
|
}
|
|
49
146
|
|
|
147
|
+
// =============================================================================
|
|
148
|
+
// Verified hard-delete identity + typed partial-cleanup evidence
|
|
149
|
+
// =============================================================================
|
|
150
|
+
|
|
151
|
+
/** Exact (dev, ino) identity fields used for ACP authorization binding. */
|
|
152
|
+
export interface SessionStorageFileIdentity {
|
|
153
|
+
dev: bigint;
|
|
154
|
+
ino: bigint;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Kind of verification failure surfaced by {@link deleteSessionVerified}. */
|
|
158
|
+
export type VerifiedDeleteFailureKind =
|
|
159
|
+
| "containment"
|
|
160
|
+
| "symlink"
|
|
161
|
+
| "stat"
|
|
162
|
+
| "identity"
|
|
163
|
+
| "header"
|
|
164
|
+
| "cwd"
|
|
165
|
+
| "artifacts";
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Thrown by {@link deleteSessionVerified} when canonical containment, transcript
|
|
169
|
+
* non-symlink/identity, header id/cwd, parent identity, or artifact identity
|
|
170
|
+
* verification fails. These are visible, sanitized failures: they never mutate
|
|
171
|
+
* the transcript or artifacts and grant zero authority.
|
|
172
|
+
*/
|
|
173
|
+
export class SessionDeleteVerificationError extends Error {
|
|
174
|
+
readonly kind: VerifiedDeleteFailureKind;
|
|
175
|
+
constructor(kind: VerifiedDeleteFailureKind, message: string, options?: ErrorOptions) {
|
|
176
|
+
super(message, options);
|
|
177
|
+
this.name = "SessionDeleteVerificationError";
|
|
178
|
+
this.kind = kind;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Exact identity evidence a verified hard delete binds to. All fields are captured
|
|
184
|
+
* at authorization time; delete revalidates each one before any mutation. Retry
|
|
185
|
+
* after a partial cleanup supplies the recorded artifact identity via
|
|
186
|
+
* {@link expectedArtifactsIdentity}.
|
|
187
|
+
*/
|
|
188
|
+
export interface VerifiedSessionDeleteTarget {
|
|
189
|
+
/** Canonical sessions root; the transcript must be contained within it. */
|
|
190
|
+
sessionsRoot: string;
|
|
191
|
+
/** Canonical transcript path (absolute `*.jsonl`). */
|
|
192
|
+
transcriptPath: string;
|
|
193
|
+
/** Expected session id parsed from the header. */
|
|
194
|
+
sessionId: string;
|
|
195
|
+
/** Expected canonical cwd parsed from the header. */
|
|
196
|
+
cwd: string;
|
|
197
|
+
/** Expected transcript file `(dev, ino)` captured at authorization. */
|
|
198
|
+
transcriptIdentity: SessionStorageFileIdentity;
|
|
199
|
+
/**
|
|
200
|
+
* For retry after an `artifacts` `cleanup_pending`: the recorded artifact
|
|
201
|
+
* directory identity to re-accept. A replacement/different artifact directory
|
|
202
|
+
* fails closed. Omit on first attempt or to accept recorded absence.
|
|
203
|
+
*/
|
|
204
|
+
expectedArtifactsIdentity?: SessionStorageFileIdentity;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Outcome of a verified hard delete. Artifact removal happens first; only after
|
|
209
|
+
* revalidation is the transcript unlinked last. A partial deletion returns
|
|
210
|
+
* `cleanup_pending` with exact evidence for same-connection retry — never
|
|
211
|
+
* `deleted` and never `{}`.
|
|
212
|
+
*/
|
|
213
|
+
export type VerifiedSessionDeleteResult =
|
|
214
|
+
| { kind: "deleted" }
|
|
215
|
+
| {
|
|
216
|
+
kind: "cleanup_pending";
|
|
217
|
+
phase: "artifacts";
|
|
218
|
+
error: Error;
|
|
219
|
+
/** Artifact directory identity at failure time; undefined when absent. */
|
|
220
|
+
artifactsIdentity: SessionStorageFileIdentity | undefined;
|
|
221
|
+
/** Transcript identity (unchanged) for retry binding. */
|
|
222
|
+
transcriptIdentity: SessionStorageFileIdentity;
|
|
223
|
+
}
|
|
224
|
+
| {
|
|
225
|
+
kind: "cleanup_pending";
|
|
226
|
+
phase: "transcript";
|
|
227
|
+
error: Error;
|
|
228
|
+
/** Transcript identity at failure time for retry binding. */
|
|
229
|
+
transcriptIdentity: SessionStorageFileIdentity;
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
/** Default OS-close dispatcher: a direct `fs.closeSync`. */
|
|
233
|
+
const defaultCloseAdapter: SessionStorageWriterCloseAdapter = {
|
|
234
|
+
close(fd: number): void {
|
|
235
|
+
fs.closeSync(fd);
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
|
|
50
239
|
// FinalizationRegistry to clean up leaked file descriptors
|
|
51
240
|
const writerRegistry = new FinalizationRegistry<number>(fd => {
|
|
52
241
|
try {
|
|
@@ -58,12 +247,15 @@ const writerRegistry = new FinalizationRegistry<number>(fd => {
|
|
|
58
247
|
|
|
59
248
|
class FileSessionStorageWriter implements SessionStorageWriter {
|
|
60
249
|
#fd: number;
|
|
61
|
-
#
|
|
250
|
+
#closeState: SessionStorageWriterCloseState = "open";
|
|
251
|
+
#closeError: Error | undefined;
|
|
62
252
|
#error: Error | undefined;
|
|
63
253
|
#onError: ((err: Error) => void) | undefined;
|
|
254
|
+
#closeAdapter: SessionStorageWriterCloseAdapter;
|
|
64
255
|
|
|
65
|
-
constructor(fpath: string, options?:
|
|
256
|
+
constructor(fpath: string, options?: SessionStorageWriterOpenOptions) {
|
|
66
257
|
this.#onError = options?.onError;
|
|
258
|
+
this.#closeAdapter = options?.closeAdapter ?? defaultCloseAdapter;
|
|
67
259
|
const flags = options?.flags ?? "a";
|
|
68
260
|
// Ensure parent directory exists
|
|
69
261
|
const dir = path.dirname(fpath);
|
|
@@ -83,8 +275,22 @@ class FileSessionStorageWriter implements SessionStorageWriter {
|
|
|
83
275
|
return error;
|
|
84
276
|
}
|
|
85
277
|
|
|
278
|
+
/** Deterministic error for any non-open state: writes/flush reject without append/reopen. */
|
|
279
|
+
#nonOpenWriteError(): Error {
|
|
280
|
+
switch (this.#closeState) {
|
|
281
|
+
case "closed":
|
|
282
|
+
return new Error("Writer closed");
|
|
283
|
+
case "close_unknown":
|
|
284
|
+
return this.#closeError ?? new Error("Writer close outcome is unknown; descriptor quarantined");
|
|
285
|
+
case "close_failed_retryable":
|
|
286
|
+
return this.#closeError ?? new Error("Writer close failed before dispatch (retryable); writes rejected");
|
|
287
|
+
default:
|
|
288
|
+
return new Error("Writer closed");
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
86
292
|
writeLineSync(line: string): void {
|
|
87
|
-
if (this.#
|
|
293
|
+
if (this.#closeState !== "open") throw this.#nonOpenWriteError();
|
|
88
294
|
if (this.#error) throw this.#error;
|
|
89
295
|
try {
|
|
90
296
|
const buf = Buffer.from(line, "utf-8");
|
|
@@ -106,12 +312,13 @@ class FileSessionStorageWriter implements SessionStorageWriter {
|
|
|
106
312
|
}
|
|
107
313
|
|
|
108
314
|
async flush(): Promise<void> {
|
|
315
|
+
if (this.#closeState !== "open") throw this.#nonOpenWriteError();
|
|
109
316
|
if (this.#error) throw this.#error;
|
|
110
317
|
// OS buffers are flushed on fsync, nothing to do here
|
|
111
318
|
}
|
|
112
319
|
|
|
113
320
|
async fsync(): Promise<void> {
|
|
114
|
-
if (this.#
|
|
321
|
+
if (this.#closeState !== "open") throw this.#nonOpenWriteError();
|
|
115
322
|
if (this.#error) throw this.#error;
|
|
116
323
|
try {
|
|
117
324
|
fs.fsyncSync(this.#fd);
|
|
@@ -120,21 +327,55 @@ class FileSessionStorageWriter implements SessionStorageWriter {
|
|
|
120
327
|
}
|
|
121
328
|
}
|
|
122
329
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
this.#closed
|
|
126
|
-
//
|
|
127
|
-
|
|
330
|
+
closeSync(): void {
|
|
331
|
+
// Repeated close after success is a harmless idempotent no-op.
|
|
332
|
+
if (this.#closeState === "closed") return;
|
|
333
|
+
// Dispatched close already threw: outcome is uncertain. Never dispatch OS close
|
|
334
|
+
// for this numeric fd again; surface the stored non-quiescent error.
|
|
335
|
+
if (this.#closeState === "close_unknown") throw this.#closeError!;
|
|
336
|
+
// State is "open" or "close_failed_retryable": a close may be dispatched.
|
|
128
337
|
try {
|
|
129
|
-
|
|
130
|
-
} catch {
|
|
131
|
-
|
|
338
|
+
this.#closeAdapter.close(this.#fd);
|
|
339
|
+
} catch (err) {
|
|
340
|
+
if (err instanceof SessionStorageWriterRetryableCloseError) {
|
|
341
|
+
// Certified pre-dispatch failure: no OS close ran, ownership remains proven.
|
|
342
|
+
// Keep the FinalizationRegistry registration so an abandoned retryable writer
|
|
343
|
+
// can still be finalizer-closed.
|
|
344
|
+
this.#closeState = "close_failed_retryable";
|
|
345
|
+
this.#closeError = toError(err);
|
|
346
|
+
throw this.#closeError;
|
|
347
|
+
}
|
|
348
|
+
// An actual close was dispatched then threw (or the adapter threw after
|
|
349
|
+
// dispatching): ownership/outcome of the numeric fd is uncertain. Quarantine
|
|
350
|
+
// the fd and suppress finalizer close so a reused fd is never closed twice.
|
|
351
|
+
this.#closeState = "close_unknown";
|
|
352
|
+
this.#closeError = toError(err);
|
|
353
|
+
writerRegistry.unregister(this);
|
|
354
|
+
throw this.#closeError;
|
|
132
355
|
}
|
|
356
|
+
// Successful underlying close confirms closed.
|
|
357
|
+
this.#closeState = "closed";
|
|
358
|
+
this.#closeError = undefined;
|
|
359
|
+
writerRegistry.unregister(this);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async close(): Promise<void> {
|
|
363
|
+
// The synchronous dispatch above has no internal await; delegating keeps the
|
|
364
|
+
// async and sync close contracts observationally identical.
|
|
365
|
+
this.closeSync();
|
|
133
366
|
}
|
|
134
367
|
|
|
135
368
|
getError(): Error | undefined {
|
|
136
369
|
return this.#error;
|
|
137
370
|
}
|
|
371
|
+
|
|
372
|
+
getCloseState(): SessionStorageWriterCloseState {
|
|
373
|
+
return this.#closeState;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
getCloseError(): Error | undefined {
|
|
377
|
+
return this.#closeError;
|
|
378
|
+
}
|
|
138
379
|
}
|
|
139
380
|
|
|
140
381
|
export class FileSessionStorage implements SessionStorage {
|
|
@@ -157,9 +398,25 @@ export class FileSessionStorage implements SessionStorage {
|
|
|
157
398
|
return fs.readFileSync(fpath, "utf-8");
|
|
158
399
|
}
|
|
159
400
|
|
|
401
|
+
readBytesSync(fpath: string): Uint8Array {
|
|
402
|
+
return this.readSnapshotSync(fpath).bytes;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
readSnapshotSync(fpath: string): SessionStorageSnapshot {
|
|
406
|
+
const flags = fs.constants.O_RDONLY | fs.constants.O_NONBLOCK | (fs.constants.O_NOFOLLOW ?? 0);
|
|
407
|
+
const fd = fs.openSync(fpath, flags);
|
|
408
|
+
try {
|
|
409
|
+
const stat = statFromNode(fs.fstatSync(fd, { bigint: true }));
|
|
410
|
+
|
|
411
|
+
if (!stat.isFile) throw new Error(`Not a regular file: ${fpath}`);
|
|
412
|
+
return { bytes: fs.readFileSync(fd), stat };
|
|
413
|
+
} finally {
|
|
414
|
+
fs.closeSync(fd);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
160
418
|
statSync(path: string): SessionStorageStat {
|
|
161
|
-
|
|
162
|
-
return { size: stats.size, mtimeMs: stats.mtimeMs, mtime: stats.mtime };
|
|
419
|
+
return statFromNode(fs.statSync(path, { bigint: true }));
|
|
163
420
|
}
|
|
164
421
|
|
|
165
422
|
listFilesSync(dir: string, pattern: string): string[] {
|
|
@@ -170,6 +427,12 @@ export class FileSessionStorage implements SessionStorage {
|
|
|
170
427
|
}
|
|
171
428
|
}
|
|
172
429
|
|
|
430
|
+
listFilesStrictSync(dir: string, pattern: string): string[] {
|
|
431
|
+
// Strict: never suppress scan/root errors. Authorization inventory depends on
|
|
432
|
+
// a complete enumeration; a swallowed error here would grant partial authority.
|
|
433
|
+
return Array.from(new Bun.Glob(pattern).scanSync(dir)).map(name => path.join(dir, name));
|
|
434
|
+
}
|
|
435
|
+
|
|
173
436
|
async exists(path: string): Promise<boolean> {
|
|
174
437
|
try {
|
|
175
438
|
await fs.promises.access(path);
|
|
@@ -216,7 +479,7 @@ export class FileSessionStorage implements SessionStorage {
|
|
|
216
479
|
fs.unlinkSync(path);
|
|
217
480
|
}
|
|
218
481
|
|
|
219
|
-
openWriter(path: string, options?:
|
|
482
|
+
openWriter(path: string, options?: SessionStorageWriterOpenOptions): SessionStorageWriter {
|
|
220
483
|
return new FileSessionStorageWriter(path, options);
|
|
221
484
|
}
|
|
222
485
|
|
|
@@ -245,6 +508,169 @@ export class FileSessionStorage implements SessionStorage {
|
|
|
245
508
|
);
|
|
246
509
|
}
|
|
247
510
|
}
|
|
511
|
+
/**
|
|
512
|
+
* Verified hard delete bound to exact identity evidence. Artifact directory first,
|
|
513
|
+
* revalidate, transcript last. Partial deletion returns typed cleanup_pending
|
|
514
|
+
* evidence; identity/symlink/containment/header/cwd mismatch throws.
|
|
515
|
+
*/
|
|
516
|
+
async deleteSessionVerified(target: VerifiedSessionDeleteTarget): Promise<VerifiedSessionDeleteResult> {
|
|
517
|
+
const { sessionsRoot, transcriptPath, sessionId, cwd, transcriptIdentity, expectedArtifactsIdentity } = target;
|
|
518
|
+
if (!transcriptPath.endsWith(".jsonl")) {
|
|
519
|
+
throw new SessionDeleteVerificationError("containment", "Transcript path is not a .jsonl file");
|
|
520
|
+
}
|
|
521
|
+
if (!pathIsWithin(sessionsRoot, transcriptPath)) {
|
|
522
|
+
throw new SessionDeleteVerificationError("containment", "Transcript is outside the sessions root");
|
|
523
|
+
}
|
|
524
|
+
const initial = this.#verifiedReadAndHeader(transcriptPath, sessionId, cwd);
|
|
525
|
+
const initialStat = initial.snapshot.stat;
|
|
526
|
+
if (initialStat.dev !== transcriptIdentity.dev || initialStat.ino !== transcriptIdentity.ino) {
|
|
527
|
+
throw new SessionDeleteVerificationError("identity", "Transcript identity does not match authorization");
|
|
528
|
+
}
|
|
529
|
+
const parentIdentity = this.#directoryIdentity(path.dirname(transcriptPath));
|
|
530
|
+
|
|
531
|
+
const artifactsDir = transcriptPath.slice(0, -6);
|
|
532
|
+
const artifactsIdentity = this.#optionalDirectoryIdentity(artifactsDir);
|
|
533
|
+
if (artifactsIdentity) {
|
|
534
|
+
if (
|
|
535
|
+
expectedArtifactsIdentity &&
|
|
536
|
+
(artifactsIdentity.dev !== expectedArtifactsIdentity.dev ||
|
|
537
|
+
artifactsIdentity.ino !== expectedArtifactsIdentity.ino)
|
|
538
|
+
) {
|
|
539
|
+
throw new SessionDeleteVerificationError(
|
|
540
|
+
"artifacts",
|
|
541
|
+
"Artifact directory identity does not match recorded cleanup evidence",
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
try {
|
|
545
|
+
await fsp.rm(artifactsDir, { recursive: true, force: true });
|
|
546
|
+
} catch (err) {
|
|
547
|
+
return {
|
|
548
|
+
kind: "cleanup_pending",
|
|
549
|
+
phase: "artifacts",
|
|
550
|
+
error: toError(err),
|
|
551
|
+
artifactsIdentity,
|
|
552
|
+
transcriptIdentity: { dev: initialStat.dev, ino: initialStat.ino },
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const revalidate = this.#verifiedReadAndHeader(transcriptPath, sessionId, cwd);
|
|
558
|
+
const revalidateStat = revalidate.snapshot.stat;
|
|
559
|
+
if (revalidateStat.dev !== initialStat.dev || revalidateStat.ino !== initialStat.ino) {
|
|
560
|
+
throw new SessionDeleteVerificationError(
|
|
561
|
+
"identity",
|
|
562
|
+
"Transcript identity changed after artifact removal (replacement detected)",
|
|
563
|
+
);
|
|
564
|
+
}
|
|
565
|
+
const parentIdentityNow = this.#directoryIdentity(path.dirname(transcriptPath));
|
|
566
|
+
if (parentIdentityNow.dev !== parentIdentity.dev || parentIdentityNow.ino !== parentIdentity.ino) {
|
|
567
|
+
throw new SessionDeleteVerificationError("identity", "Parent directory identity changed during deletion");
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
try {
|
|
571
|
+
await this.unlink(transcriptPath);
|
|
572
|
+
} catch (err) {
|
|
573
|
+
if (isEnoent(err)) return { kind: "deleted" };
|
|
574
|
+
return {
|
|
575
|
+
kind: "cleanup_pending",
|
|
576
|
+
phase: "transcript",
|
|
577
|
+
error: toError(err),
|
|
578
|
+
transcriptIdentity: { dev: revalidateStat.dev, ino: revalidateStat.ino },
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
return { kind: "deleted" };
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
#verifiedReadAndHeader(
|
|
585
|
+
transcriptPath: string,
|
|
586
|
+
expectedSessionId: string,
|
|
587
|
+
expectedCwd: string,
|
|
588
|
+
): { snapshot: SessionStorageSnapshot } {
|
|
589
|
+
let snapshot: SessionStorageSnapshot;
|
|
590
|
+
try {
|
|
591
|
+
snapshot = this.readSnapshotSync(transcriptPath);
|
|
592
|
+
} catch (err) {
|
|
593
|
+
const code = (err as NodeJS.ErrnoException)?.code;
|
|
594
|
+
if (code === "ELOOP" || code === "SYMLINK") {
|
|
595
|
+
throw new SessionDeleteVerificationError("symlink", "Transcript path is a symlink");
|
|
596
|
+
}
|
|
597
|
+
throw new SessionDeleteVerificationError("stat", "Transcript could not be opened or read", {
|
|
598
|
+
cause: toError(err),
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
if (!snapshot.stat.isFile) {
|
|
602
|
+
throw new SessionDeleteVerificationError("symlink", "Transcript is not a regular file");
|
|
603
|
+
}
|
|
604
|
+
const header = parseFirstJsonlLine(snapshot.bytes);
|
|
605
|
+
if (!header) {
|
|
606
|
+
throw new SessionDeleteVerificationError("header", "Transcript header is missing or unreadable");
|
|
607
|
+
}
|
|
608
|
+
if (header.type !== "session" || typeof header.id !== "string") {
|
|
609
|
+
throw new SessionDeleteVerificationError("header", "Transcript header is not a valid session header");
|
|
610
|
+
}
|
|
611
|
+
if (header.id !== expectedSessionId) {
|
|
612
|
+
throw new SessionDeleteVerificationError("identity", "Transcript header id does not match authorization");
|
|
613
|
+
}
|
|
614
|
+
if (typeof header.cwd !== "string") {
|
|
615
|
+
throw new SessionDeleteVerificationError("cwd", "Transcript header is missing a cwd");
|
|
616
|
+
}
|
|
617
|
+
if (path.resolve(header.cwd) !== path.resolve(expectedCwd)) {
|
|
618
|
+
throw new SessionDeleteVerificationError("cwd", "Transcript header cwd does not match authorization");
|
|
619
|
+
}
|
|
620
|
+
return { snapshot };
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
#directoryIdentity(dirPath: string): SessionStorageFileIdentity {
|
|
624
|
+
let stat: fs.BigIntStats;
|
|
625
|
+
try {
|
|
626
|
+
stat = fs.lstatSync(dirPath, { bigint: true });
|
|
627
|
+
} catch (err) {
|
|
628
|
+
throw new SessionDeleteVerificationError("stat", "Directory could not be inspected", { cause: toError(err) });
|
|
629
|
+
}
|
|
630
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
631
|
+
throw new SessionDeleteVerificationError("symlink", "Directory is a symlink or not a directory");
|
|
632
|
+
}
|
|
633
|
+
return { dev: stat.dev, ino: stat.ino };
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
#optionalDirectoryIdentity(dirPath: string): SessionStorageFileIdentity | undefined {
|
|
637
|
+
let stat: fs.BigIntStats;
|
|
638
|
+
try {
|
|
639
|
+
stat = fs.lstatSync(dirPath, { bigint: true });
|
|
640
|
+
} catch (err) {
|
|
641
|
+
if (isEnoent(err)) return undefined;
|
|
642
|
+
throw new SessionDeleteVerificationError("artifacts", "Artifact directory could not be inspected", {
|
|
643
|
+
cause: toError(err),
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
if (stat.isSymbolicLink()) {
|
|
647
|
+
throw new SessionDeleteVerificationError("symlink", "Artifact directory is a symlink");
|
|
648
|
+
}
|
|
649
|
+
if (!stat.isDirectory()) {
|
|
650
|
+
// A non-directory artifact sibling (regular file, socket, device, ...) must
|
|
651
|
+
// not be silently treated as an absent artifacts directory: doing so would
|
|
652
|
+
// let the verified delete report success while a foreign artifact remains.
|
|
653
|
+
// Fail closed before any mutation.
|
|
654
|
+
throw new SessionDeleteVerificationError("artifacts", "Artifact path exists but is not a directory");
|
|
655
|
+
}
|
|
656
|
+
return { dev: stat.dev, ino: stat.ino };
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/** Parse the first JSONL line as a generic record; returns undefined on parse failure. */
|
|
661
|
+
function parseFirstJsonlLine(bytes: Uint8Array): Record<string, unknown> | undefined {
|
|
662
|
+
const NL = 0x0a;
|
|
663
|
+
const end = bytes.indexOf(NL);
|
|
664
|
+
const firstLine = end === -1 ? bytes : bytes.subarray(0, end);
|
|
665
|
+
if (firstLine.length === 0) return undefined;
|
|
666
|
+
try {
|
|
667
|
+
const text = utf8Decoder.decode(firstLine).trim();
|
|
668
|
+
if (!text) return undefined;
|
|
669
|
+
const value: unknown = JSON.parse(text);
|
|
670
|
+
return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : undefined;
|
|
671
|
+
} catch {
|
|
672
|
+
return undefined;
|
|
673
|
+
}
|
|
248
674
|
}
|
|
249
675
|
|
|
250
676
|
function matchesPattern(name: string, pattern: string): boolean {
|
|
@@ -258,18 +684,18 @@ function matchesPattern(name: string, pattern: string): boolean {
|
|
|
258
684
|
class MemorySessionStorageWriter implements SessionStorageWriter {
|
|
259
685
|
#storage: MemorySessionStorage;
|
|
260
686
|
#path: string;
|
|
261
|
-
#
|
|
687
|
+
#closeState: SessionStorageWriterCloseState = "open";
|
|
688
|
+
#closeError: Error | undefined;
|
|
262
689
|
#error: Error | undefined;
|
|
263
690
|
#onError: ((err: Error) => void) | undefined;
|
|
264
691
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
options?: { flags?: "a" | "w"; onError?: (err: Error) => void },
|
|
269
|
-
) {
|
|
692
|
+
#closeAdapter: SessionStorageWriterCloseAdapter | undefined;
|
|
693
|
+
|
|
694
|
+
constructor(storage: MemorySessionStorage, path: string, options?: SessionStorageWriterOpenOptions) {
|
|
270
695
|
this.#storage = storage;
|
|
271
696
|
this.#path = path;
|
|
272
697
|
this.#onError = options?.onError;
|
|
698
|
+
this.#closeAdapter = options?.closeAdapter;
|
|
273
699
|
if ((options?.flags ?? "a") === "w") {
|
|
274
700
|
this.#storage.writeTextSync(path, "");
|
|
275
701
|
}
|
|
@@ -283,7 +709,7 @@ class MemorySessionStorageWriter implements SessionStorageWriter {
|
|
|
283
709
|
}
|
|
284
710
|
|
|
285
711
|
writeLineSync(line: string): void {
|
|
286
|
-
if (this.#
|
|
712
|
+
if (this.#closeState !== "open") throw new Error("Writer closed");
|
|
287
713
|
if (this.#error) throw this.#error;
|
|
288
714
|
try {
|
|
289
715
|
const existing = this.#storage.existsSync(this.#path) ? this.#storage.readTextSync(this.#path) : "";
|
|
@@ -298,26 +724,73 @@ class MemorySessionStorageWriter implements SessionStorageWriter {
|
|
|
298
724
|
}
|
|
299
725
|
|
|
300
726
|
async flush(): Promise<void> {
|
|
727
|
+
if (this.#closeState !== "open") throw new Error("Writer closed");
|
|
301
728
|
if (this.#error) throw this.#error;
|
|
302
729
|
}
|
|
303
730
|
|
|
304
731
|
async fsync(): Promise<void> {
|
|
305
732
|
// No-op for in-memory storage
|
|
733
|
+
if (this.#closeState !== "open") throw new Error("Writer closed");
|
|
306
734
|
if (this.#error) throw this.#error;
|
|
307
735
|
}
|
|
308
736
|
|
|
737
|
+
closeSync(): void {
|
|
738
|
+
// In-memory close has no numeric fd. When a close adapter is injected it
|
|
739
|
+
// controls the certainty-aware lifecycle (used to exercise retryable /
|
|
740
|
+
// quarantined close paths end-to-end); without one the close always
|
|
741
|
+
// succeeds. The sentinel fd (-1) signals "no real descriptor".
|
|
742
|
+
if (this.#closeState === "closed") return;
|
|
743
|
+
if (this.#closeState === "close_unknown") throw this.#closeError!;
|
|
744
|
+
if (this.#closeAdapter) {
|
|
745
|
+
try {
|
|
746
|
+
this.#closeAdapter.close(-1);
|
|
747
|
+
} catch (err) {
|
|
748
|
+
if (err instanceof SessionStorageWriterRetryableCloseError) {
|
|
749
|
+
this.#closeState = "close_failed_retryable";
|
|
750
|
+
this.#closeError = toError(err);
|
|
751
|
+
throw this.#closeError;
|
|
752
|
+
}
|
|
753
|
+
this.#closeState = "close_unknown";
|
|
754
|
+
this.#closeError = toError(err);
|
|
755
|
+
throw this.#closeError;
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
this.#closeState = "closed";
|
|
759
|
+
this.#closeError = undefined;
|
|
760
|
+
}
|
|
761
|
+
|
|
309
762
|
async close(): Promise<void> {
|
|
310
|
-
|
|
311
|
-
this.#closed = true;
|
|
763
|
+
this.closeSync();
|
|
312
764
|
}
|
|
313
765
|
|
|
314
766
|
getError(): Error | undefined {
|
|
315
767
|
return this.#error;
|
|
316
768
|
}
|
|
769
|
+
|
|
770
|
+
getCloseState(): SessionStorageWriterCloseState {
|
|
771
|
+
return this.#closeState;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
getCloseError(): Error | undefined {
|
|
775
|
+
return this.#closeError;
|
|
776
|
+
}
|
|
317
777
|
}
|
|
318
778
|
|
|
319
779
|
export class MemorySessionStorage implements SessionStorage {
|
|
320
|
-
#files = new Map<string, { content:
|
|
780
|
+
#files = new Map<string, { content: Buffer; mtimeMs: number; ino: bigint }>();
|
|
781
|
+
#nextInode = 1n;
|
|
782
|
+
|
|
783
|
+
#statFor(entry: { content: Buffer; mtimeMs: number; ino: bigint }): SessionStorageStat {
|
|
784
|
+
return {
|
|
785
|
+
dev: 0n,
|
|
786
|
+
ino: entry.ino,
|
|
787
|
+
size: entry.content.byteLength,
|
|
788
|
+
mtimeMs: entry.mtimeMs,
|
|
789
|
+
mtimeNs: BigInt(entry.mtimeMs) * 1_000_000n,
|
|
790
|
+
mtime: new Date(entry.mtimeMs),
|
|
791
|
+
isFile: true,
|
|
792
|
+
};
|
|
793
|
+
}
|
|
321
794
|
|
|
322
795
|
ensureDirSync(_dir: string): void {
|
|
323
796
|
// No-op for in-memory storage.
|
|
@@ -328,23 +801,34 @@ export class MemorySessionStorage implements SessionStorage {
|
|
|
328
801
|
}
|
|
329
802
|
|
|
330
803
|
writeTextSync(path: string, content: string): void {
|
|
331
|
-
this.#files.
|
|
804
|
+
const existing = this.#files.get(path);
|
|
805
|
+
this.#files.set(path, {
|
|
806
|
+
content: Buffer.from(content, "utf-8"),
|
|
807
|
+
mtimeMs: Date.now(),
|
|
808
|
+
ino: existing?.ino ?? this.#nextInode++,
|
|
809
|
+
});
|
|
332
810
|
}
|
|
333
811
|
|
|
334
812
|
readTextSync(path: string): string {
|
|
335
813
|
const entry = this.#files.get(path);
|
|
336
814
|
if (!entry) throw new Error(`File not found: ${path}`);
|
|
337
|
-
return entry.content;
|
|
815
|
+
return entry.content.toString("utf-8");
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
readBytesSync(path: string): Uint8Array {
|
|
819
|
+
return this.readSnapshotSync(path).bytes;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
readSnapshotSync(path: string): SessionStorageSnapshot {
|
|
823
|
+
const entry = this.#files.get(path);
|
|
824
|
+
if (!entry) throw new Error(`File not found: ${path}`);
|
|
825
|
+
return { bytes: Buffer.from(entry.content), stat: this.#statFor(entry) };
|
|
338
826
|
}
|
|
339
827
|
|
|
340
828
|
statSync(path: string): SessionStorageStat {
|
|
341
829
|
const entry = this.#files.get(path);
|
|
342
830
|
if (!entry) throw new Error(`File not found: ${path}`);
|
|
343
|
-
return
|
|
344
|
-
size: entry.content.length,
|
|
345
|
-
mtimeMs: entry.mtimeMs,
|
|
346
|
-
mtime: new Date(entry.mtimeMs),
|
|
347
|
-
};
|
|
831
|
+
return this.#statFor(entry);
|
|
348
832
|
}
|
|
349
833
|
|
|
350
834
|
listFilesSync(dir: string, pattern: string): string[] {
|
|
@@ -359,6 +843,10 @@ export class MemorySessionStorage implements SessionStorage {
|
|
|
359
843
|
}
|
|
360
844
|
return files;
|
|
361
845
|
}
|
|
846
|
+
listFilesStrictSync(dir: string, pattern: string): string[] {
|
|
847
|
+
// In-memory scan never suppresses; identical to the display scan.
|
|
848
|
+
return this.listFilesSync(dir, pattern);
|
|
849
|
+
}
|
|
362
850
|
|
|
363
851
|
exists(path: string): Promise<boolean> {
|
|
364
852
|
return Promise.resolve(this.existsSync(path));
|
|
@@ -367,13 +855,13 @@ export class MemorySessionStorage implements SessionStorage {
|
|
|
367
855
|
readText(path: string): Promise<string> {
|
|
368
856
|
const entry = this.#files.get(path);
|
|
369
857
|
if (!entry) return Promise.reject(new Error(`File not found: ${path}`));
|
|
370
|
-
return Promise.resolve(entry.content);
|
|
858
|
+
return Promise.resolve(entry.content.toString("utf-8"));
|
|
371
859
|
}
|
|
372
860
|
|
|
373
861
|
readTextPrefix(path: string, maxBytes: number): Promise<string> {
|
|
374
862
|
const entry = this.#files.get(path);
|
|
375
863
|
if (!entry) return Promise.reject(new Error(`File not found: ${path}`));
|
|
376
|
-
return Promise.resolve(entry.content.
|
|
864
|
+
return Promise.resolve(entry.content.subarray(0, maxBytes).toString("utf-8"));
|
|
377
865
|
}
|
|
378
866
|
|
|
379
867
|
writeText(path: string, content: string): Promise<void> {
|
|
@@ -405,11 +893,67 @@ export class MemorySessionStorage implements SessionStorage {
|
|
|
405
893
|
this.#files.delete(path);
|
|
406
894
|
}
|
|
407
895
|
|
|
408
|
-
deleteSessionWithArtifacts(
|
|
896
|
+
deleteSessionWithArtifacts(sessionPath: string): Promise<void> {
|
|
897
|
+
this.#files.delete(sessionPath);
|
|
409
898
|
return Promise.resolve();
|
|
410
899
|
}
|
|
411
900
|
|
|
412
|
-
|
|
901
|
+
deleteSessionVerified(target: VerifiedSessionDeleteTarget): Promise<VerifiedSessionDeleteResult> {
|
|
902
|
+
const { sessionsRoot, transcriptPath, sessionId, cwd, transcriptIdentity } = target;
|
|
903
|
+
// Canonical containment: same gate as the file backend so the memory backend
|
|
904
|
+
// cannot grant deletion authority outside the sessions root.
|
|
905
|
+
if (!transcriptPath.endsWith(".jsonl")) {
|
|
906
|
+
return Promise.reject(
|
|
907
|
+
new SessionDeleteVerificationError("containment", "Transcript path is not a .jsonl file"),
|
|
908
|
+
);
|
|
909
|
+
}
|
|
910
|
+
if (!pathIsWithin(sessionsRoot, transcriptPath)) {
|
|
911
|
+
return Promise.reject(
|
|
912
|
+
new SessionDeleteVerificationError("containment", "Transcript is outside the sessions root"),
|
|
913
|
+
);
|
|
914
|
+
}
|
|
915
|
+
const entry = this.#files.get(transcriptPath);
|
|
916
|
+
if (!entry) return Promise.resolve({ kind: "deleted" });
|
|
917
|
+
const snapshot = this.readSnapshotSync(transcriptPath);
|
|
918
|
+
if (snapshot.stat.dev !== transcriptIdentity.dev || snapshot.stat.ino !== transcriptIdentity.ino) {
|
|
919
|
+
return Promise.reject(new SessionDeleteVerificationError("identity", "Transcript identity mismatch"));
|
|
920
|
+
}
|
|
921
|
+
const header = parseFirstJsonlLine(snapshot.bytes);
|
|
922
|
+
if (!header) {
|
|
923
|
+
return Promise.reject(
|
|
924
|
+
new SessionDeleteVerificationError("header", "Transcript header is missing or unreadable"),
|
|
925
|
+
);
|
|
926
|
+
}
|
|
927
|
+
// Require the typed session header exactly like the file backend: a memory
|
|
928
|
+
// backend must not accept a non-session artifact as a deletable transcript.
|
|
929
|
+
if (header.type !== "session" || typeof header.id !== "string") {
|
|
930
|
+
return Promise.reject(
|
|
931
|
+
new SessionDeleteVerificationError("header", "Transcript header is not a valid session header"),
|
|
932
|
+
);
|
|
933
|
+
}
|
|
934
|
+
if (header.id !== sessionId) {
|
|
935
|
+
return Promise.reject(new SessionDeleteVerificationError("identity", "Transcript header id mismatch"));
|
|
936
|
+
}
|
|
937
|
+
if (typeof header.cwd !== "string") {
|
|
938
|
+
return Promise.reject(new SessionDeleteVerificationError("cwd", "Transcript header is missing a cwd"));
|
|
939
|
+
}
|
|
940
|
+
if (path.resolve(header.cwd) !== path.resolve(cwd)) {
|
|
941
|
+
return Promise.reject(new SessionDeleteVerificationError("cwd", "Transcript header cwd mismatch"));
|
|
942
|
+
}
|
|
943
|
+
// Compatible artifact semantics: the memory backend models no directories, so
|
|
944
|
+
// a key at the artifact path is a non-directory sibling that must fail closed
|
|
945
|
+
// rather than be silently treated as an absent artifacts directory.
|
|
946
|
+
const artifactsPath = transcriptPath.slice(0, -6);
|
|
947
|
+
if (artifactsPath !== transcriptPath && this.#files.has(artifactsPath)) {
|
|
948
|
+
return Promise.reject(
|
|
949
|
+
new SessionDeleteVerificationError("artifacts", "Artifact path exists but is not a directory"),
|
|
950
|
+
);
|
|
951
|
+
}
|
|
952
|
+
this.#files.delete(transcriptPath);
|
|
953
|
+
return Promise.resolve({ kind: "deleted" });
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
openWriter(path: string, options?: SessionStorageWriterOpenOptions): SessionStorageWriter {
|
|
413
957
|
return new MemorySessionStorageWriter(this, path, options);
|
|
414
958
|
}
|
|
415
959
|
}
|