@gajae-code/coding-agent 0.12.19 → 0.12.20
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 +20 -9
- package/dist/types/commands/gc.d.ts +4 -0
- package/dist/types/commands/sdk.d.ts +66 -0
- package/dist/types/config/settings-schema.d.ts +34 -0
- package/dist/types/gjc-runtime/gc-runtime.d.ts +97 -1
- package/dist/types/modes/acp/acp-agent.d.ts +33 -1
- package/dist/types/modes/components/bash-execution.d.ts +7 -0
- package/dist/types/modes/components/eval-execution.d.ts +7 -0
- package/dist/types/modes/utils/ui-helpers.d.ts +10 -1
- package/dist/types/sdk/acp/adapter.d.ts +9 -17
- package/dist/types/sdk/acp/index.d.ts +1 -1
- package/dist/types/sdk/broker/broker.d.ts +93 -1
- package/dist/types/sdk/broker/ensure.d.ts +17 -0
- package/dist/types/sdk/broker/index.d.ts +1 -0
- package/dist/types/sdk/broker/lifecycle-ledger.d.ts +10 -1
- package/dist/types/sdk/broker/lifecycle.d.ts +81 -2
- package/dist/types/sdk/broker/startup-budget.d.ts +44 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +7 -1
- package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +11 -1
- package/dist/types/sdk/bus/discord-daemon.d.ts +1 -0
- package/dist/types/sdk/bus/index.d.ts +20 -9
- package/dist/types/sdk/bus/prompt-reconciliation.d.ts +2 -6
- package/dist/types/sdk/bus/slack-daemon.d.ts +2 -2
- package/dist/types/sdk/host/query/revision-store.d.ts +5 -0
- package/dist/types/sdk/host/session-runtime.d.ts +4 -0
- package/dist/types/sdk/lifecycle-session.d.ts +13 -2
- package/dist/types/sdk/prompt-failure.d.ts +13 -0
- package/dist/types/sdk/prompt-watchdog.d.ts +82 -0
- package/dist/types/sdk/session-reconnect.d.ts +20 -0
- package/dist/types/sdk/startup-capability.d.ts +1 -1
- package/dist/types/session/agent-session.d.ts +14 -4
- package/dist/types/session/blob-store.d.ts +32 -0
- package/dist/types/session/internal/managed-session-storage.d.ts +40 -2
- package/dist/types/session/internal/session-memory-sidecar.d.ts +881 -0
- package/dist/types/session/session-manager.d.ts +301 -27
- package/dist/types/session/session-storage.d.ts +170 -0
- package/dist/types/task/executor.d.ts +2 -1
- package/dist/types/tools/bash.d.ts +1 -0
- package/dist/types/tui/output-block.d.ts +6 -0
- package/package.json +8 -8
- package/src/cli/args.ts +75 -34
- package/src/commands/acp.ts +7 -1
- package/src/commands/gc.ts +31 -2
- package/src/commands/sdk.ts +141 -7
- package/src/config/settings-schema.ts +43 -0
- package/src/coordinator-mcp/server.ts +6 -1
- package/src/export/html/index.ts +83 -39
- package/src/gjc-runtime/gc-runtime.ts +1283 -4
- package/src/internal-urls/docs-index.generated.ts +1 -1
- package/src/internal-urls/local-protocol.ts +14 -0
- package/src/main.ts +40 -9
- package/src/modes/acp/acp-agent.ts +564 -37
- package/src/modes/acp/terminal-auth.ts +2 -2
- package/src/modes/components/bash-execution.ts +14 -0
- package/src/modes/components/eval-execution.ts +14 -0
- package/src/modes/controllers/command-controller.ts +63 -8
- package/src/modes/utils/ui-helpers.ts +81 -11
- package/src/sdk/acp/adapter.ts +46 -45
- package/src/sdk/acp/index.ts +1 -1
- package/src/sdk/broker/broker.ts +392 -11
- package/src/sdk/broker/ensure.ts +131 -61
- package/src/sdk/broker/index.ts +1 -0
- package/src/sdk/broker/lifecycle-ledger.ts +59 -5
- package/src/sdk/broker/lifecycle.ts +278 -63
- package/src/sdk/broker/startup-budget.ts +103 -0
- package/src/sdk/bus/chat-daemon-control.ts +9 -3
- package/src/sdk/bus/chat-daemon-runtime.ts +565 -29
- package/src/sdk/bus/conversation-store.ts +1 -1
- package/src/sdk/bus/discord-daemon.ts +2 -1
- package/src/sdk/bus/discord-live-provider.ts +34 -6
- package/src/sdk/bus/index.ts +118 -20
- package/src/sdk/bus/kind-aware-reconciliation.ts +2 -1
- package/src/sdk/bus/prompt-reconciliation.ts +4 -9
- package/src/sdk/bus/slack-daemon.ts +91 -18
- package/src/sdk/cli/session-cli.ts +8 -1
- package/src/sdk/host/query/handlers.ts +1 -1
- package/src/sdk/host/query/revision-store.ts +48 -12
- package/src/sdk/host/session-runtime.test.ts +50 -0
- package/src/sdk/host/session-runtime.ts +12 -2
- package/src/sdk/lifecycle-session.ts +26 -3
- package/src/sdk/mcp/server.ts +6 -1
- package/src/sdk/prompt-failure.ts +42 -0
- package/src/sdk/prompt-watchdog.ts +162 -0
- package/src/sdk/session-reconnect.ts +43 -0
- package/src/sdk/startup-capability.ts +13 -4
- package/src/session/agent-session.ts +525 -222
- package/src/session/blob-store.ts +89 -0
- package/src/session/internal/managed-session-scope.ts +36 -26
- package/src/session/internal/managed-session-storage.ts +303 -32
- package/src/session/internal/session-memory-sidecar.ts +2160 -0
- package/src/session/session-manager.ts +7027 -394
- package/src/session/session-storage.ts +1134 -11
- package/src/task/executor.ts +17 -4
- package/src/tools/bash.ts +109 -7
- package/src/tools/find.ts +55 -26
- package/src/tools/todo-contract.ts +40 -8
- package/src/tui/output-block.ts +11 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
|
|
3
3
|
import * as fs from "node:fs";
|
|
4
4
|
import * as path from "node:path";
|
|
@@ -18,10 +18,19 @@ import { isEnoent, pathIsWithin, peekFile, toError } from "@gajae-code/utils";
|
|
|
18
18
|
import {
|
|
19
19
|
assertManagedDirectoryRoot,
|
|
20
20
|
type ManagedDirectoryRoot,
|
|
21
|
+
renameFlagsUnsupported,
|
|
22
|
+
shouldFsyncManagedDirectory,
|
|
21
23
|
validateNativeSecurityResult,
|
|
22
24
|
} from "./internal/managed-session-storage";
|
|
25
|
+
import {
|
|
26
|
+
classifyNativePublishOutcome,
|
|
27
|
+
mayCleanCurrentStaging,
|
|
28
|
+
type NativePublishOutcome,
|
|
29
|
+
} from "./internal/native-publish-outcome";
|
|
30
|
+
import { isDerivedSessionMemoryFile } from "./internal/session-memory-sidecar";
|
|
23
31
|
|
|
24
32
|
const utf8Decoder = new TextDecoder("utf-8");
|
|
33
|
+
const newlineBuffer = Buffer.from("\n", "utf8");
|
|
25
34
|
function canonicalPathSync(value: string): string {
|
|
26
35
|
try {
|
|
27
36
|
return fs.realpathSync.native(value);
|
|
@@ -49,6 +58,31 @@ export interface SessionStorageSnapshot {
|
|
|
49
58
|
stat: SessionStorageStat;
|
|
50
59
|
}
|
|
51
60
|
|
|
61
|
+
export interface SessionStorageExactReplacementExpectation {
|
|
62
|
+
readonly stat: SessionStorageStat;
|
|
63
|
+
readonly sha256: string;
|
|
64
|
+
}
|
|
65
|
+
/** Upper bound for one descriptor-validated recorded range read. */
|
|
66
|
+
export const SESSION_RANGE_READ_MAX_BYTES = 64 * 1024 * 1024;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* One bounded recorded-length read validated against a single opened descriptor.
|
|
70
|
+
* `bytes` is exactly `length` bytes from `[start, start + length)` of the same
|
|
71
|
+
* regular-file object; `stat` is the fresh post-read descriptor snapshot so the
|
|
72
|
+
* caller can compare dev/ino/nlink against the pathname before committing an index.
|
|
73
|
+
*/
|
|
74
|
+
export interface SessionStorageRangeSnapshot {
|
|
75
|
+
stat: SessionStorageStat;
|
|
76
|
+
bytes: Uint8Array;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function validateRangeReadBounds(start: number, length: number): void {
|
|
80
|
+
if (!Number.isSafeInteger(start) || start < 0 || !Number.isSafeInteger(length) || length < 0)
|
|
81
|
+
throw new RangeError("Invalid session range read bounds");
|
|
82
|
+
if (start > Number.MAX_SAFE_INTEGER - length) throw new RangeError("Session range read start overflows");
|
|
83
|
+
if (length > SESSION_RANGE_READ_MAX_BYTES) throw new RangeError("Session range read exceeds the bounded maximum");
|
|
84
|
+
}
|
|
85
|
+
|
|
52
86
|
function statFromNode(stats: fs.BigIntStats): SessionStorageStat {
|
|
53
87
|
return {
|
|
54
88
|
dev: stats.dev,
|
|
@@ -163,6 +197,10 @@ export interface SessionStorageWriter {
|
|
|
163
197
|
writeLineSync(line: string): void;
|
|
164
198
|
flush(): Promise<void>;
|
|
165
199
|
fsync(): Promise<void>;
|
|
200
|
+
/** Synchronously fsync all prior writes when the backend supports durable sidecar publication. */
|
|
201
|
+
fsyncSync?(): void;
|
|
202
|
+
/** Descriptor-bound identity captured from the still-open writer after fsync. */
|
|
203
|
+
statSync?(): SessionStorageStat;
|
|
166
204
|
close(): Promise<void>;
|
|
167
205
|
/**
|
|
168
206
|
* Synchronously close the underlying descriptor. The certainty-aware close
|
|
@@ -177,6 +215,51 @@ export interface SessionStorageWriter {
|
|
|
177
215
|
/** Stored error for non-success close states (`close_failed_retryable`/`close_unknown`). */
|
|
178
216
|
getCloseError(): Error | undefined;
|
|
179
217
|
}
|
|
218
|
+
// =============================================================================
|
|
219
|
+
// Staged streaming writer contract (two-pass fork publication, immutable destinations)
|
|
220
|
+
// =============================================================================
|
|
221
|
+
|
|
222
|
+
/** Upper bound for one staged line (excluding the trailing newline). */
|
|
223
|
+
export const STAGED_WRITER_LINE_MAX_BYTES = 64 * 1024 * 1024;
|
|
224
|
+
/** Upper bound for aggregated different-length patches buffered for the publish-time overlay pass. */
|
|
225
|
+
export const STAGED_WRITER_PATCH_LIMIT_BYTES = 8 * 1024 * 1024;
|
|
226
|
+
export const STAGED_WRITER_PATCH_MAX_COUNT = 65_536;
|
|
227
|
+
export const STAGED_MEMORY_WRITER_MAX_BYTES = 20 * 1024 * 1024;
|
|
228
|
+
export const STAGED_MEMORY_WRITER_MAX_LINES = STAGED_WRITER_PATCH_MAX_COUNT + 1;
|
|
229
|
+
const STAGED_WRITER_COPY_CHUNK_BYTES = 64 * 1024;
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Bounded staged streaming writer for one immutable one-shot destination (fork /
|
|
233
|
+
* capture). Lines are streamed to a sibling staging file; {@link publishNoReplace}
|
|
234
|
+
* atomically publishes the staged file only while the destination is still absent,
|
|
235
|
+
* so publication never materializes the whole file in memory. Different-length
|
|
236
|
+
* {@link patchLine} replacements are buffered (bounded) and applied by a second
|
|
237
|
+
* bounded streaming pass at publish time.
|
|
238
|
+
*
|
|
239
|
+
* `publishNoReplace` is reserved for immutable one-shot destinations and must never
|
|
240
|
+
* be used for the mutable `.spill.commit` marker (checked create/replace helpers
|
|
241
|
+
* exist for that path).
|
|
242
|
+
*/
|
|
243
|
+
export interface StagedStreamingWriter {
|
|
244
|
+
/** Append one complete line; the writer adds the trailing newline. */
|
|
245
|
+
writeLine(bytes: Uint8Array): void;
|
|
246
|
+
/** Move the line cursor to `ordinal` (0-based) so a later patchLine targets it. */
|
|
247
|
+
seekToLine(ordinal: number): void;
|
|
248
|
+
/**
|
|
249
|
+
* Replace the line at `ordinal` with `bytes`. Same-length replacements are
|
|
250
|
+
* applied in place; different-length replacements are buffered (bounded) and
|
|
251
|
+
* applied by the publish-time overlay pass.
|
|
252
|
+
*/
|
|
253
|
+
patchLine(ordinal: number, bytes: Uint8Array): void;
|
|
254
|
+
/** Hand buffered writes to the kernel (the staged descriptor is unbuffered). */
|
|
255
|
+
flush(): void;
|
|
256
|
+
/** Synchronize the staged file. */
|
|
257
|
+
fsync(): void;
|
|
258
|
+
/** Close the staged descriptor; required before {@link publishNoReplace}. */
|
|
259
|
+
closeSync(): void;
|
|
260
|
+
/** Atomically publish the staged file at the destination only while it is absent. */
|
|
261
|
+
publishNoReplace(): void;
|
|
262
|
+
}
|
|
180
263
|
|
|
181
264
|
export interface SessionStorage {
|
|
182
265
|
ensureDirSync(dir: string): void;
|
|
@@ -203,6 +286,12 @@ export interface SessionStorage {
|
|
|
203
286
|
writeText(path: string, content: string): Promise<void>;
|
|
204
287
|
rename(path: string, nextPath: string): Promise<void>;
|
|
205
288
|
renameSync(path: string, nextPath: string): void;
|
|
289
|
+
/** Replace only while the destination still has the expected exact identity and bytes. */
|
|
290
|
+
replaceExactSync?(
|
|
291
|
+
sourcePath: string,
|
|
292
|
+
destinationPath: string,
|
|
293
|
+
expected: SessionStorageExactReplacementExpectation,
|
|
294
|
+
): boolean;
|
|
206
295
|
unlink(path: string): Promise<void>;
|
|
207
296
|
unlinkSync(path: string): void;
|
|
208
297
|
deleteSessionWithArtifacts(sessionPath: string): Promise<void>;
|
|
@@ -214,6 +303,12 @@ export interface SessionStorage {
|
|
|
214
303
|
*/
|
|
215
304
|
deleteSessionVerified?(target: VerifiedSessionDeleteTarget): Promise<VerifiedSessionDeleteResult>;
|
|
216
305
|
openWriter(path: string, options?: SessionStorageWriterOpenOptions): SessionStorageWriter;
|
|
306
|
+
/** Bounded recorded-length read with descriptor identity validation (additive). */
|
|
307
|
+
readRangeSync?(path: string, start: number, length: number): SessionStorageRangeSnapshot;
|
|
308
|
+
/** Async bounded recorded-length read with descriptor identity validation (additive). */
|
|
309
|
+
readRange?(path: string, start: number, length: number): Promise<SessionStorageRangeSnapshot>;
|
|
310
|
+
/** Open a staged streaming writer for one immutable one-shot destination (additive). */
|
|
311
|
+
openStagedWriter?(path: string, options?: SessionStorageWriterOpenOptions): StagedStreamingWriter;
|
|
217
312
|
}
|
|
218
313
|
|
|
219
314
|
// =============================================================================
|
|
@@ -557,6 +652,270 @@ function assertNoReparsePath(pathname: string): void {
|
|
|
557
652
|
}
|
|
558
653
|
}
|
|
559
654
|
}
|
|
655
|
+
// =============================================================================
|
|
656
|
+
// Commit-marker checked create/replace (mutable `.spill.commit` publication)
|
|
657
|
+
// =============================================================================
|
|
658
|
+
|
|
659
|
+
function fsyncDirectorySync(pathname: string): void {
|
|
660
|
+
if (!shouldFsyncManagedDirectory()) return;
|
|
661
|
+
const fd = fs.openSync(pathname, fs.constants.O_RDONLY | fs.constants.O_DIRECTORY);
|
|
662
|
+
try {
|
|
663
|
+
fs.fsyncSync(fd);
|
|
664
|
+
} finally {
|
|
665
|
+
fs.closeSync(fd);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/** Best-effort identity-checked removal of one of our own staged temp names. */
|
|
670
|
+
function unlinkOwnedStagedSync(stagingPath: string, expected: { dev: bigint; ino: bigint }): void {
|
|
671
|
+
try {
|
|
672
|
+
const named = fs.lstatSync(stagingPath, { bigint: true });
|
|
673
|
+
if (named.dev !== expected.dev || named.ino !== expected.ino) return;
|
|
674
|
+
fs.unlinkSync(stagingPath);
|
|
675
|
+
} catch {
|
|
676
|
+
// ENOENT means the name was already consumed or removed; cleanup is best-effort.
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function sameDescriptorIdentity(left: SessionStorageStat, right: SessionStorageStat): boolean {
|
|
682
|
+
return (
|
|
683
|
+
left.dev === right.dev &&
|
|
684
|
+
left.ino === right.ino &&
|
|
685
|
+
left.nlink === right.nlink &&
|
|
686
|
+
left.size === right.size &&
|
|
687
|
+
left.mtimeNs === right.mtimeNs &&
|
|
688
|
+
left.ctimeNs === right.ctimeNs
|
|
689
|
+
);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/** Physically observed commit-marker state; corrupt JSON is still `present`. */
|
|
693
|
+
export type SessionCommitMarkerState =
|
|
694
|
+
| { kind: "missing" }
|
|
695
|
+
| { kind: "present"; rawBytesSha256: string; stat: SessionStorageStat };
|
|
696
|
+
|
|
697
|
+
/** Exact `present` expectation for one checked commit-marker replacement. */
|
|
698
|
+
export interface SessionCommitMarkerPresentExpectation {
|
|
699
|
+
/** SHA-256 of the exact raw marker bytes physically on disk (corrupt JSON included). */
|
|
700
|
+
rawBytesSha256: string;
|
|
701
|
+
/** Descriptor snapshot of the marker object expected to be replaced. */
|
|
702
|
+
descriptorIdentity: SessionStorageStat;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/** Snapshot one commit marker's physical state without granting write authority. */
|
|
706
|
+
export function readSessionCommitMarkerSync(storage: SessionStorage, markerPath: string): SessionCommitMarkerState {
|
|
707
|
+
if (!storage.existsSync(markerPath)) return { kind: "missing" };
|
|
708
|
+
if (!storage.readBytesSync) throw new Error("Commit marker reads require exact-bytes storage");
|
|
709
|
+
const bytes = storage.readBytesSync(markerPath);
|
|
710
|
+
const stat = storage.statSync(markerPath);
|
|
711
|
+
return { kind: "present", rawBytesSha256: createHash("sha256").update(bytes).digest("hex"), stat };
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* Checked commit-marker create: publishes only while the marker is still `missing`.
|
|
716
|
+
* Temp + fsync + atomic create-if-absent + directory fsync (file backend); the
|
|
717
|
+
* in-memory backend mirrors the same missing-expectation abort. Leftover temps are
|
|
718
|
+
* removed on any failure. Runs inside the caller's persistence fence.
|
|
719
|
+
*/
|
|
720
|
+
export function createSessionCommitMarkerCheckedSync(
|
|
721
|
+
storage: SessionStorage,
|
|
722
|
+
markerPath: string,
|
|
723
|
+
bytes: Uint8Array,
|
|
724
|
+
options?: { securityContext?: SessionStorageSecurityContext },
|
|
725
|
+
): void {
|
|
726
|
+
if (storage instanceof FileSessionStorage) {
|
|
727
|
+
createFileCommitMarkerCheckedSync(storage, markerPath, bytes, options?.securityContext);
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
if (storage instanceof MemorySessionStorage) {
|
|
731
|
+
if (storage.existsSync(markerPath)) throw new Error("commit_marker_expected_missing");
|
|
732
|
+
storage.writeTextSync(markerPath, Buffer.from(bytes).toString("utf8"));
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
throw new Error("Commit marker checked publication requires a file or memory storage backend");
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* Checked commit-marker replace: replaces only on an exact `present` raw/hash +
|
|
740
|
+
* descriptor identity match (corrupt-present included). Temp + fsync + checked
|
|
741
|
+
* atomic rename + directory fsync (file backend); any mismatch aborts with the
|
|
742
|
+
* current marker untouched. The in-memory backend mirrors the same aborts. Runs
|
|
743
|
+
* inside the caller's persistence fence.
|
|
744
|
+
*/
|
|
745
|
+
export function replaceSessionCommitMarkerCheckedSync(
|
|
746
|
+
storage: SessionStorage,
|
|
747
|
+
markerPath: string,
|
|
748
|
+
bytes: Uint8Array,
|
|
749
|
+
expected: SessionCommitMarkerPresentExpectation,
|
|
750
|
+
options?: { securityContext?: SessionStorageSecurityContext },
|
|
751
|
+
): void {
|
|
752
|
+
if (storage instanceof FileSessionStorage) {
|
|
753
|
+
replaceFileCommitMarkerCheckedSync(storage, markerPath, bytes, expected, options?.securityContext);
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
if (storage instanceof MemorySessionStorage) {
|
|
757
|
+
replaceMemoryCommitMarkerCheckedSync(storage, markerPath, bytes, expected);
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
throw new Error("Commit marker checked publication requires a file or memory storage backend");
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function createFileCommitMarkerCheckedSync(
|
|
764
|
+
_storage: FileSessionStorage,
|
|
765
|
+
markerPath: string,
|
|
766
|
+
bytes: Uint8Array,
|
|
767
|
+
securityContext: SessionStorageSecurityContext,
|
|
768
|
+
): void {
|
|
769
|
+
const dir = path.dirname(markerPath);
|
|
770
|
+
assertNoReparsePath(dir);
|
|
771
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
772
|
+
assertNoReparsePath(markerPath);
|
|
773
|
+
const tempPath = path.join(dir, `.${path.basename(markerPath)}.${randomUUID()}.tmp`);
|
|
774
|
+
let fd: number | undefined;
|
|
775
|
+
let stagedIdentity: { dev: bigint; ino: bigint } | undefined;
|
|
776
|
+
let failure: unknown;
|
|
777
|
+
let outcome: NativePublishOutcome | undefined;
|
|
778
|
+
let linkPublished = false;
|
|
779
|
+
try {
|
|
780
|
+
fd = fs.openSync(
|
|
781
|
+
tempPath,
|
|
782
|
+
fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY | (fs.constants.O_NOFOLLOW ?? 0),
|
|
783
|
+
0o600,
|
|
784
|
+
);
|
|
785
|
+
secureOwnerOnlyFileDescriptor(tempPath, fd, "apply", securityContext);
|
|
786
|
+
const opened = fs.fstatSync(fd, { bigint: true });
|
|
787
|
+
stagedIdentity = { dev: opened.dev, ino: opened.ino };
|
|
788
|
+
let offset = 0;
|
|
789
|
+
while (offset < bytes.byteLength) offset += fs.writeSync(fd, bytes, offset, bytes.byteLength - offset);
|
|
790
|
+
fs.fsyncSync(fd);
|
|
791
|
+
secureOwnerOnlyFileDescriptor(tempPath, fd, "verify", securityContext);
|
|
792
|
+
const staged = fs.fstatSync(fd, { bigint: true });
|
|
793
|
+
stagedIdentity = { dev: staged.dev, ino: staged.ino };
|
|
794
|
+
fs.closeSync(fd);
|
|
795
|
+
fd = undefined;
|
|
796
|
+
|
|
797
|
+
outcome = classifyNativePublishOutcome(nativeSessionStorage().renameNoReplacePath(tempPath, markerPath));
|
|
798
|
+
if (renameFlagsUnsupported(outcome)) {
|
|
799
|
+
outcome = classifyNativePublishOutcome(nativeSessionStorage().linkNoReplacePath(tempPath, markerPath));
|
|
800
|
+
linkPublished = outcome.ok;
|
|
801
|
+
}
|
|
802
|
+
if (!outcome.ok) {
|
|
803
|
+
if (outcome.reason === "destination_exists") throw new Error("commit_marker_expected_missing");
|
|
804
|
+
throw new Error(`commit_marker_create_rejected:${outcome.reason}`);
|
|
805
|
+
}
|
|
806
|
+
const named = fs.lstatSync(markerPath, { bigint: true });
|
|
807
|
+
if (
|
|
808
|
+
!named.isFile() ||
|
|
809
|
+
named.isSymbolicLink() ||
|
|
810
|
+
named.dev !== stagedIdentity.dev ||
|
|
811
|
+
named.ino !== stagedIdentity.ino
|
|
812
|
+
)
|
|
813
|
+
throw new Error("destination_identity_changed");
|
|
814
|
+
fsyncDirectorySync(dir);
|
|
815
|
+
} catch (error) {
|
|
816
|
+
failure = error;
|
|
817
|
+
} finally {
|
|
818
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
819
|
+
if (stagedIdentity && (outcome === undefined || linkPublished || mayCleanCurrentStaging(outcome))) {
|
|
820
|
+
unlinkOwnedStagedSync(tempPath, stagedIdentity);
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
if (failure !== undefined) throw failure;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
function replaceFileCommitMarkerCheckedSync(
|
|
827
|
+
storage: FileSessionStorage,
|
|
828
|
+
markerPath: string,
|
|
829
|
+
bytes: Uint8Array,
|
|
830
|
+
expected: SessionCommitMarkerPresentExpectation,
|
|
831
|
+
securityContext: SessionStorageSecurityContext,
|
|
832
|
+
): void {
|
|
833
|
+
// Expected-state check runs before any mutation (inside the caller's fence).
|
|
834
|
+
const current = readSessionCommitMarkerSync(storage, markerPath);
|
|
835
|
+
if (current.kind !== "present") throw new Error("commit_marker_expected_present");
|
|
836
|
+
if (current.rawBytesSha256 !== expected.rawBytesSha256) throw new Error("commit_marker_raw_hash_mismatch");
|
|
837
|
+
if (!sameDescriptorIdentity(current.stat, expected.descriptorIdentity))
|
|
838
|
+
throw new Error("commit_marker_identity_mismatch");
|
|
839
|
+
|
|
840
|
+
const dir = path.dirname(markerPath);
|
|
841
|
+
const parentIdentity = fs.statSync(dir, { bigint: true });
|
|
842
|
+
const tempPath = path.join(dir, `.${path.basename(markerPath)}.${randomUUID()}.tmp`);
|
|
843
|
+
let fd: number | undefined;
|
|
844
|
+
let staged: fs.BigIntStats | undefined;
|
|
845
|
+
let stagedIdentity: { dev: bigint; ino: bigint } | undefined;
|
|
846
|
+
const stagedSha256 = createHash("sha256").update(bytes).digest("hex");
|
|
847
|
+
let failure: unknown;
|
|
848
|
+
try {
|
|
849
|
+
fd = fs.openSync(
|
|
850
|
+
tempPath,
|
|
851
|
+
fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY | (fs.constants.O_NOFOLLOW ?? 0),
|
|
852
|
+
0o600,
|
|
853
|
+
);
|
|
854
|
+
secureOwnerOnlyFileDescriptor(tempPath, fd, "apply", securityContext);
|
|
855
|
+
const opened = fs.fstatSync(fd, { bigint: true });
|
|
856
|
+
stagedIdentity = { dev: opened.dev, ino: opened.ino };
|
|
857
|
+
let offset = 0;
|
|
858
|
+
while (offset < bytes.byteLength) offset += fs.writeSync(fd, bytes, offset, bytes.byteLength - offset);
|
|
859
|
+
fs.fsyncSync(fd);
|
|
860
|
+
secureOwnerOnlyFileDescriptor(tempPath, fd, "verify", securityContext);
|
|
861
|
+
staged = fs.fstatSync(fd, { bigint: true });
|
|
862
|
+
fs.closeSync(fd);
|
|
863
|
+
fd = undefined;
|
|
864
|
+
|
|
865
|
+
// Checked atomic rename: replaces the destination only while it is still the
|
|
866
|
+
// expected marker object (exact dev/ino/nlink/size/mtimeNs + raw sha256).
|
|
867
|
+
const replaced = nativeSessionStorage().exactReplacePath(
|
|
868
|
+
tempPath,
|
|
869
|
+
markerPath,
|
|
870
|
+
{
|
|
871
|
+
dev: staged.dev,
|
|
872
|
+
ino: staged.ino,
|
|
873
|
+
nlink: staged.nlink,
|
|
874
|
+
parentDev: parentIdentity.dev,
|
|
875
|
+
parentIno: parentIdentity.ino,
|
|
876
|
+
size: BigInt(bytes.byteLength),
|
|
877
|
+
mtimeNs: staged.mtimeNs,
|
|
878
|
+
sha256: stagedSha256,
|
|
879
|
+
},
|
|
880
|
+
{
|
|
881
|
+
dev: expected.descriptorIdentity.dev,
|
|
882
|
+
ino: expected.descriptorIdentity.ino,
|
|
883
|
+
nlink: expected.descriptorIdentity.nlink,
|
|
884
|
+
parentDev: parentIdentity.dev,
|
|
885
|
+
parentIno: parentIdentity.ino,
|
|
886
|
+
size: BigInt(expected.descriptorIdentity.size),
|
|
887
|
+
mtimeNs: expected.descriptorIdentity.mtimeNs,
|
|
888
|
+
sha256: expected.rawBytesSha256,
|
|
889
|
+
},
|
|
890
|
+
);
|
|
891
|
+
if (!replaced.ok) throw new Error(`commit_marker_replace_rejected:${replaced.code ?? "unknown"}`);
|
|
892
|
+
const named = fs.lstatSync(markerPath, { bigint: true });
|
|
893
|
+
if (!named.isFile() || named.isSymbolicLink() || named.dev !== staged.dev || named.ino !== staged.ino)
|
|
894
|
+
throw new Error("destination_identity_changed");
|
|
895
|
+
fsyncDirectorySync(dir);
|
|
896
|
+
} catch (error) {
|
|
897
|
+
failure = error;
|
|
898
|
+
} finally {
|
|
899
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
900
|
+
if (staged) unlinkOwnedStagedSync(tempPath, { dev: staged.dev, ino: staged.ino });
|
|
901
|
+
else if (stagedIdentity) unlinkOwnedStagedSync(tempPath, stagedIdentity);
|
|
902
|
+
}
|
|
903
|
+
if (failure !== undefined) throw failure;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
function replaceMemoryCommitMarkerCheckedSync(
|
|
907
|
+
storage: MemorySessionStorage,
|
|
908
|
+
markerPath: string,
|
|
909
|
+
bytes: Uint8Array,
|
|
910
|
+
expected: SessionCommitMarkerPresentExpectation,
|
|
911
|
+
): void {
|
|
912
|
+
const current = readSessionCommitMarkerSync(storage, markerPath);
|
|
913
|
+
if (current.kind !== "present") throw new Error("commit_marker_expected_present");
|
|
914
|
+
if (current.rawBytesSha256 !== expected.rawBytesSha256) throw new Error("commit_marker_raw_hash_mismatch");
|
|
915
|
+
if (!sameDescriptorIdentity(current.stat, expected.descriptorIdentity))
|
|
916
|
+
throw new Error("commit_marker_identity_mismatch");
|
|
917
|
+
storage.writeTextSync(markerPath, Buffer.from(bytes).toString("utf8"));
|
|
918
|
+
}
|
|
560
919
|
|
|
561
920
|
// FinalizationRegistry to clean up leaked file descriptors
|
|
562
921
|
const writerRegistry = new FinalizationRegistry<number>(fd => {
|
|
@@ -656,7 +1015,7 @@ class FileSessionStorageWriter implements SessionStorageWriter {
|
|
|
656
1015
|
// OS buffers are flushed on fsync, nothing to do here
|
|
657
1016
|
}
|
|
658
1017
|
|
|
659
|
-
|
|
1018
|
+
fsyncSync(): void {
|
|
660
1019
|
if (this.#closeState !== "open") throw this.#nonOpenWriteError();
|
|
661
1020
|
if (this.#error) throw this.#error;
|
|
662
1021
|
try {
|
|
@@ -666,6 +1025,15 @@ class FileSessionStorageWriter implements SessionStorageWriter {
|
|
|
666
1025
|
throw this.#recordError(err);
|
|
667
1026
|
}
|
|
668
1027
|
}
|
|
1028
|
+
statSync(): SessionStorageStat {
|
|
1029
|
+
if (this.#closeState !== "open") throw this.#nonOpenWriteError();
|
|
1030
|
+
if (this.#error) throw this.#error;
|
|
1031
|
+
return statFromNode(fs.fstatSync(this.#fd, { bigint: true }));
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
async fsync(): Promise<void> {
|
|
1035
|
+
this.fsyncSync();
|
|
1036
|
+
}
|
|
669
1037
|
|
|
670
1038
|
closeSync(): void {
|
|
671
1039
|
// Repeated close after success is a harmless idempotent no-op.
|
|
@@ -725,6 +1093,293 @@ class FileSessionStorageWriter implements SessionStorageWriter {
|
|
|
725
1093
|
return this.#closeError;
|
|
726
1094
|
}
|
|
727
1095
|
}
|
|
1096
|
+
/**
|
|
1097
|
+
* File-backend staged streaming writer: streams lines to a sibling staging file and
|
|
1098
|
+
* publishes no-replace to the immutable destination. Different-length patchLine
|
|
1099
|
+
* replacements are applied by a bounded publish-time second pass (64 KiB chunks),
|
|
1100
|
+
* so publication never materializes the whole file in memory.
|
|
1101
|
+
*/
|
|
1102
|
+
class FileStagedStreamingWriter implements StagedStreamingWriter {
|
|
1103
|
+
#fd: number;
|
|
1104
|
+
#stagingPath: string;
|
|
1105
|
+
#destinationPath: string;
|
|
1106
|
+
#securityContext: SessionStorageSecurityContext;
|
|
1107
|
+
#lineCount = 0;
|
|
1108
|
+
#pendingPatches = new Map<number, Uint8Array>();
|
|
1109
|
+
#pendingPatchBytes = 0;
|
|
1110
|
+
#closed = false;
|
|
1111
|
+
#published = false;
|
|
1112
|
+
#error: Error | undefined;
|
|
1113
|
+
|
|
1114
|
+
constructor(destinationPath: string, options?: SessionStorageWriterOpenOptions) {
|
|
1115
|
+
this.#securityContext = options?.securityContext;
|
|
1116
|
+
const dir = path.dirname(destinationPath);
|
|
1117
|
+
assertNoReparsePath(dir);
|
|
1118
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
1119
|
+
assertNoReparsePath(dir);
|
|
1120
|
+
this.#destinationPath = destinationPath;
|
|
1121
|
+
this.#stagingPath = path.join(dir, `.${path.basename(destinationPath)}.${randomUUID()}.staged`);
|
|
1122
|
+
const fd = fs.openSync(
|
|
1123
|
+
this.#stagingPath,
|
|
1124
|
+
fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY | (fs.constants.O_NOFOLLOW ?? 0),
|
|
1125
|
+
0o600,
|
|
1126
|
+
);
|
|
1127
|
+
try {
|
|
1128
|
+
secureOwnerOnlyFileDescriptor(this.#stagingPath, fd, "apply", this.#securityContext);
|
|
1129
|
+
} catch (error) {
|
|
1130
|
+
fs.closeSync(fd);
|
|
1131
|
+
throw error;
|
|
1132
|
+
}
|
|
1133
|
+
this.#fd = fd;
|
|
1134
|
+
writerRegistry.register(this, this.#fd, this);
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
#recordError(err: unknown): Error {
|
|
1138
|
+
const error = toError(err);
|
|
1139
|
+
if (!this.#error) this.#error = error;
|
|
1140
|
+
return error;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
#assertOpen(): void {
|
|
1144
|
+
if (this.#closed) throw new Error("Staged writer is closed");
|
|
1145
|
+
if (this.#error) throw this.#error;
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
writeLine(bytes: Uint8Array): void {
|
|
1149
|
+
this.#assertOpen();
|
|
1150
|
+
if (bytes.byteLength > STAGED_WRITER_LINE_MAX_BYTES)
|
|
1151
|
+
throw new RangeError("Staged line exceeds the bounded maximum");
|
|
1152
|
+
const line = Buffer.concat([Buffer.from(bytes), newlineBuffer]);
|
|
1153
|
+
try {
|
|
1154
|
+
let written = 0;
|
|
1155
|
+
while (written < line.byteLength) {
|
|
1156
|
+
const count = fs.writeSync(this.#fd, line, written, line.byteLength - written);
|
|
1157
|
+
if (count === 0) throw new Error("Short write");
|
|
1158
|
+
written += count;
|
|
1159
|
+
}
|
|
1160
|
+
this.#lineCount++;
|
|
1161
|
+
} catch (err) {
|
|
1162
|
+
throw this.#recordError(err);
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
seekToLine(ordinal: number): void {
|
|
1167
|
+
this.#assertOpen();
|
|
1168
|
+
this.#findLine(ordinal);
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
patchLine(ordinal: number, bytes: Uint8Array): void {
|
|
1172
|
+
this.#assertOpen();
|
|
1173
|
+
const existing = this.#findLine(ordinal);
|
|
1174
|
+
const lineLength = bytes.byteLength + 1;
|
|
1175
|
+
const prior = this.#pendingPatches.get(ordinal);
|
|
1176
|
+
if (prior) {
|
|
1177
|
+
const nextPatchBytes = this.#pendingPatchBytes - prior.byteLength + bytes.byteLength;
|
|
1178
|
+
if (nextPatchBytes > STAGED_WRITER_PATCH_LIMIT_BYTES) {
|
|
1179
|
+
this.#error = new Error("staged_overlay_capacity_exceeded");
|
|
1180
|
+
throw this.#error;
|
|
1181
|
+
}
|
|
1182
|
+
this.#pendingPatches.set(ordinal, Buffer.from(bytes));
|
|
1183
|
+
this.#pendingPatchBytes = nextPatchBytes;
|
|
1184
|
+
return;
|
|
1185
|
+
}
|
|
1186
|
+
if (lineLength === existing.length) {
|
|
1187
|
+
try {
|
|
1188
|
+
fs.writeSync(this.#fd, Buffer.from(bytes), 0, bytes.byteLength, existing.offset);
|
|
1189
|
+
} catch (err) {
|
|
1190
|
+
throw this.#recordError(err);
|
|
1191
|
+
}
|
|
1192
|
+
return;
|
|
1193
|
+
}
|
|
1194
|
+
if (
|
|
1195
|
+
this.#pendingPatches.size >= STAGED_WRITER_PATCH_MAX_COUNT ||
|
|
1196
|
+
this.#pendingPatchBytes + bytes.byteLength > STAGED_WRITER_PATCH_LIMIT_BYTES
|
|
1197
|
+
) {
|
|
1198
|
+
this.#error = new Error("staged_overlay_capacity_exceeded");
|
|
1199
|
+
throw this.#error;
|
|
1200
|
+
}
|
|
1201
|
+
this.#pendingPatches.set(ordinal, Buffer.from(bytes));
|
|
1202
|
+
this.#pendingPatchBytes += bytes.byteLength;
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
#findLine(ordinal: number): { offset: number; length: number } {
|
|
1206
|
+
if (ordinal < 0 || ordinal >= this.#lineCount) throw new RangeError("Line ordinal is not staged");
|
|
1207
|
+
let fd: number | undefined;
|
|
1208
|
+
try {
|
|
1209
|
+
fd = fs.openSync(this.#stagingPath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
|
|
1210
|
+
const writerIdentity = fs.fstatSync(this.#fd, { bigint: true });
|
|
1211
|
+
const readerIdentity = fs.fstatSync(fd, { bigint: true });
|
|
1212
|
+
if (writerIdentity.dev !== readerIdentity.dev || writerIdentity.ino !== readerIdentity.ino)
|
|
1213
|
+
throw new Error("staged_source_identity_changed");
|
|
1214
|
+
const chunk = Buffer.alloc(STAGED_WRITER_COPY_CHUNK_BYTES);
|
|
1215
|
+
let offset = 0;
|
|
1216
|
+
let lineStart = 0;
|
|
1217
|
+
let current = 0;
|
|
1218
|
+
for (;;) {
|
|
1219
|
+
const count = fs.readSync(fd, chunk, 0, chunk.byteLength, offset);
|
|
1220
|
+
if (count === 0) break;
|
|
1221
|
+
for (let index = 0; index < count; index++) {
|
|
1222
|
+
if (chunk[index] !== 0x0a) continue;
|
|
1223
|
+
const lineEnd = offset + index + 1;
|
|
1224
|
+
if (current === ordinal) return { offset: lineStart, length: lineEnd - lineStart };
|
|
1225
|
+
current++;
|
|
1226
|
+
lineStart = lineEnd;
|
|
1227
|
+
}
|
|
1228
|
+
offset += count;
|
|
1229
|
+
}
|
|
1230
|
+
throw new RangeError("Line ordinal is not staged");
|
|
1231
|
+
} finally {
|
|
1232
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
flush(): void {
|
|
1237
|
+
this.#assertOpen();
|
|
1238
|
+
// writeSync already handed the bytes to the kernel; nothing is buffered here.
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
fsync(): void {
|
|
1242
|
+
this.#assertOpen();
|
|
1243
|
+
try {
|
|
1244
|
+
fs.fsyncSync(this.#fd);
|
|
1245
|
+
secureOwnerOnlyFileDescriptor(this.#stagingPath, this.#fd, "verify", this.#securityContext);
|
|
1246
|
+
} catch (err) {
|
|
1247
|
+
throw this.#recordError(err);
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
closeSync(): void {
|
|
1252
|
+
if (this.#closed) return;
|
|
1253
|
+
if (this.#error) throw this.#error;
|
|
1254
|
+
try {
|
|
1255
|
+
secureOwnerOnlyFileDescriptor(this.#stagingPath, this.#fd, "verify", this.#securityContext);
|
|
1256
|
+
fs.closeSync(this.#fd);
|
|
1257
|
+
} catch (err) {
|
|
1258
|
+
throw this.#recordError(err);
|
|
1259
|
+
}
|
|
1260
|
+
this.#closed = true;
|
|
1261
|
+
writerRegistry.unregister(this);
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
publishNoReplace(): void {
|
|
1265
|
+
if (this.#published) throw new Error("Staged writer already published");
|
|
1266
|
+
if (!this.#closed) throw new Error("Staged writer must be closed before publication");
|
|
1267
|
+
if (this.#error) throw this.#error;
|
|
1268
|
+
const dir = path.dirname(this.#destinationPath);
|
|
1269
|
+
const originalStaging = fs.lstatSync(this.#stagingPath, { bigint: true });
|
|
1270
|
+
const originalStagingIdentity = { dev: originalStaging.dev, ino: originalStaging.ino };
|
|
1271
|
+
let publishSource = this.#stagingPath;
|
|
1272
|
+
let materialized: string | undefined;
|
|
1273
|
+
let stagedIdentity: { dev: bigint; ino: bigint } | undefined;
|
|
1274
|
+
let outcome: NativePublishOutcome | undefined;
|
|
1275
|
+
let linkPublished = false;
|
|
1276
|
+
let failure: unknown;
|
|
1277
|
+
try {
|
|
1278
|
+
if (this.#pendingPatches.size > 0) {
|
|
1279
|
+
materialized = this.#materializePatchedCopy();
|
|
1280
|
+
publishSource = materialized;
|
|
1281
|
+
}
|
|
1282
|
+
const staged = fs.lstatSync(publishSource, { bigint: true });
|
|
1283
|
+
stagedIdentity = { dev: staged.dev, ino: staged.ino };
|
|
1284
|
+
outcome = classifyNativePublishOutcome(
|
|
1285
|
+
nativeSessionStorage().renameNoReplacePath(publishSource, this.#destinationPath),
|
|
1286
|
+
);
|
|
1287
|
+
if (renameFlagsUnsupported(outcome)) {
|
|
1288
|
+
outcome = classifyNativePublishOutcome(
|
|
1289
|
+
nativeSessionStorage().linkNoReplacePath(publishSource, this.#destinationPath),
|
|
1290
|
+
);
|
|
1291
|
+
linkPublished = outcome.ok;
|
|
1292
|
+
}
|
|
1293
|
+
if (!outcome.ok) throw new Error(`staged_publish_rejected:${outcome.reason}`);
|
|
1294
|
+
const named = fs.lstatSync(this.#destinationPath, { bigint: true });
|
|
1295
|
+
if (
|
|
1296
|
+
!named.isFile() ||
|
|
1297
|
+
named.isSymbolicLink() ||
|
|
1298
|
+
named.dev !== stagedIdentity.dev ||
|
|
1299
|
+
named.ino !== stagedIdentity.ino
|
|
1300
|
+
)
|
|
1301
|
+
throw new Error("destination_identity_changed");
|
|
1302
|
+
fsyncDirectorySync(dir);
|
|
1303
|
+
this.#published = true;
|
|
1304
|
+
} catch (error) {
|
|
1305
|
+
failure = error;
|
|
1306
|
+
} finally {
|
|
1307
|
+
// Only a validated pre-mutation outcome authorizes removing our own staged
|
|
1308
|
+
// name: a committed outcome may have made publishSource the destination.
|
|
1309
|
+
if (materialized) {
|
|
1310
|
+
if (stagedIdentity && (linkPublished || (outcome && mayCleanCurrentStaging(outcome))))
|
|
1311
|
+
unlinkOwnedStagedSync(materialized, stagedIdentity);
|
|
1312
|
+
unlinkOwnedStagedSync(this.#stagingPath, originalStagingIdentity);
|
|
1313
|
+
} else if (stagedIdentity && (linkPublished || (outcome && mayCleanCurrentStaging(outcome)))) {
|
|
1314
|
+
unlinkOwnedStagedSync(this.#stagingPath, stagedIdentity);
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
if (failure !== undefined) throw failure;
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
/** Second bounded streaming pass: materialize staged lines with pending patches applied. */
|
|
1321
|
+
#materializePatchedCopy(): string {
|
|
1322
|
+
const dir = path.dirname(this.#stagingPath);
|
|
1323
|
+
const copyPath = path.join(dir, `.${path.basename(this.#destinationPath)}.${randomUUID()}.staged-final`);
|
|
1324
|
+
let copyFd: number | undefined;
|
|
1325
|
+
let sourceFd: number | undefined;
|
|
1326
|
+
let copyIdentity: { dev: bigint; ino: bigint } | undefined;
|
|
1327
|
+
try {
|
|
1328
|
+
copyFd = fs.openSync(
|
|
1329
|
+
copyPath,
|
|
1330
|
+
fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY | (fs.constants.O_NOFOLLOW ?? 0),
|
|
1331
|
+
0o600,
|
|
1332
|
+
);
|
|
1333
|
+
secureOwnerOnlyFileDescriptor(copyPath, copyFd, "apply", this.#securityContext);
|
|
1334
|
+
const created = fs.fstatSync(copyFd, { bigint: true });
|
|
1335
|
+
copyIdentity = { dev: created.dev, ino: created.ino };
|
|
1336
|
+
sourceFd = fs.openSync(this.#stagingPath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
1337
|
+
let ordinal = 0;
|
|
1338
|
+
let carry = Buffer.alloc(0);
|
|
1339
|
+
const chunk = Buffer.alloc(STAGED_WRITER_COPY_CHUNK_BYTES);
|
|
1340
|
+
for (;;) {
|
|
1341
|
+
const count = fs.readSync(sourceFd, chunk, 0, chunk.byteLength, null);
|
|
1342
|
+
if (count === 0) break;
|
|
1343
|
+
const combined =
|
|
1344
|
+
carry.byteLength === 0 ? chunk.subarray(0, count) : Buffer.concat([carry, chunk.subarray(0, count)]);
|
|
1345
|
+
let lineStart = 0;
|
|
1346
|
+
for (;;) {
|
|
1347
|
+
const newline = combined.indexOf(0x0a, lineStart);
|
|
1348
|
+
if (newline === -1) break;
|
|
1349
|
+
const line = this.#pendingPatches.get(ordinal) ?? combined.subarray(lineStart, newline);
|
|
1350
|
+
this.#writeCopyLine(copyFd, line);
|
|
1351
|
+
ordinal++;
|
|
1352
|
+
lineStart = newline + 1;
|
|
1353
|
+
}
|
|
1354
|
+
carry = Buffer.from(combined.subarray(lineStart));
|
|
1355
|
+
}
|
|
1356
|
+
if (carry.byteLength > 0) throw new Error("staged_file_malformed_tail");
|
|
1357
|
+
fs.fsyncSync(copyFd);
|
|
1358
|
+
return copyPath;
|
|
1359
|
+
} catch (error) {
|
|
1360
|
+
if (copyIdentity) unlinkOwnedStagedSync(copyPath, copyIdentity);
|
|
1361
|
+
throw this.#recordError(error);
|
|
1362
|
+
} finally {
|
|
1363
|
+
if (sourceFd !== undefined) fs.closeSync(sourceFd);
|
|
1364
|
+
if (copyFd !== undefined) fs.closeSync(copyFd);
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
#writeCopyLine(fd: number, line: Uint8Array): void {
|
|
1369
|
+
let written = 0;
|
|
1370
|
+
while (written < line.byteLength) {
|
|
1371
|
+
const count = fs.writeSync(fd, line, written, line.byteLength - written);
|
|
1372
|
+
if (count === 0) throw new Error("Short write");
|
|
1373
|
+
written += count;
|
|
1374
|
+
}
|
|
1375
|
+
let nlWritten = 0;
|
|
1376
|
+
while (nlWritten < newlineBuffer.byteLength) {
|
|
1377
|
+
const count = fs.writeSync(fd, newlineBuffer, nlWritten, newlineBuffer.byteLength - nlWritten);
|
|
1378
|
+
if (count === 0) throw new Error("Short write");
|
|
1379
|
+
nlWritten += count;
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
728
1383
|
|
|
729
1384
|
export class FileSessionStorage implements SessionStorage {
|
|
730
1385
|
ensureDirSync(dir: string): void {
|
|
@@ -762,6 +1417,65 @@ export class FileSessionStorage implements SessionStorage {
|
|
|
762
1417
|
fs.closeSync(fd);
|
|
763
1418
|
}
|
|
764
1419
|
}
|
|
1420
|
+
/**
|
|
1421
|
+
* Bounded recorded-length read with descriptor identity validation: opens one
|
|
1422
|
+
* no-follow descriptor, verifies the requested range is fully present, reads
|
|
1423
|
+
* exactly `length` bytes, and revalidates dev/ino/nlink on the same descriptor
|
|
1424
|
+
* plus the pathname (append-only size growth is tolerated; an object swap is
|
|
1425
|
+
* rejected). No path-based Bun Blob reads for managed authority.
|
|
1426
|
+
*/
|
|
1427
|
+
readRangeSync(fpath: string, start: number, length: number): SessionStorageRangeSnapshot {
|
|
1428
|
+
validateRangeReadBounds(start, length);
|
|
1429
|
+
const flags = fs.constants.O_RDONLY | fs.constants.O_NONBLOCK | (fs.constants.O_NOFOLLOW ?? 0);
|
|
1430
|
+
const fd = fs.openSync(fpath, flags);
|
|
1431
|
+
try {
|
|
1432
|
+
const before = fs.fstatSync(fd, { bigint: true });
|
|
1433
|
+
if (!before.isFile() || before.nlink > 1) throw new Error("source_changed");
|
|
1434
|
+
if (Number(before.size) < start + length) throw new Error("range_not_present");
|
|
1435
|
+
const bytes = Buffer.alloc(length);
|
|
1436
|
+
let offset = 0;
|
|
1437
|
+
while (offset < length) {
|
|
1438
|
+
const count = fs.readSync(fd, bytes, offset, length - offset, start + offset);
|
|
1439
|
+
if (count === 0) throw new Error("range_not_present");
|
|
1440
|
+
offset += count;
|
|
1441
|
+
}
|
|
1442
|
+
const after = fs.fstatSync(fd, { bigint: true });
|
|
1443
|
+
if (after.dev !== before.dev || after.ino !== before.ino || after.nlink !== before.nlink)
|
|
1444
|
+
throw new Error("source_changed");
|
|
1445
|
+
const named = fs.lstatSync(fpath, { bigint: true });
|
|
1446
|
+
if (!named.isFile() || named.isSymbolicLink() || named.dev !== before.dev || named.ino !== before.ino)
|
|
1447
|
+
throw new Error("source_changed");
|
|
1448
|
+
return { bytes, stat: statFromNode(after) };
|
|
1449
|
+
} finally {
|
|
1450
|
+
fs.closeSync(fd);
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
async readRange(fpath: string, start: number, length: number): Promise<SessionStorageRangeSnapshot> {
|
|
1455
|
+
validateRangeReadBounds(start, length);
|
|
1456
|
+
const handle = await fs.promises.open(fpath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
|
|
1457
|
+
try {
|
|
1458
|
+
const before = await handle.stat({ bigint: true });
|
|
1459
|
+
if (!before.isFile() || before.nlink > 1) throw new Error("source_changed");
|
|
1460
|
+
if (Number(before.size) < start + length) throw new Error("range_not_present");
|
|
1461
|
+
const bytes = Buffer.alloc(length);
|
|
1462
|
+
let offset = 0;
|
|
1463
|
+
while (offset < length) {
|
|
1464
|
+
const { bytesRead } = await handle.read(bytes, offset, length - offset, start + offset);
|
|
1465
|
+
if (bytesRead === 0) throw new Error("range_not_present");
|
|
1466
|
+
offset += bytesRead;
|
|
1467
|
+
}
|
|
1468
|
+
const after = await handle.stat({ bigint: true });
|
|
1469
|
+
if (after.dev !== before.dev || after.ino !== before.ino || after.nlink !== before.nlink)
|
|
1470
|
+
throw new Error("source_changed");
|
|
1471
|
+
const named = fs.lstatSync(fpath, { bigint: true });
|
|
1472
|
+
if (!named.isFile() || named.isSymbolicLink() || named.dev !== before.dev || named.ino !== before.ino)
|
|
1473
|
+
throw new Error("source_changed");
|
|
1474
|
+
return { bytes, stat: statFromNode(after) };
|
|
1475
|
+
} finally {
|
|
1476
|
+
await handle.close();
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
765
1479
|
|
|
766
1480
|
statSync(path: string): SessionStorageStat {
|
|
767
1481
|
return statFromNode(fs.statSync(path, { bigint: true }));
|
|
@@ -769,7 +1483,9 @@ export class FileSessionStorage implements SessionStorage {
|
|
|
769
1483
|
|
|
770
1484
|
listFilesSync(dir: string, pattern: string): string[] {
|
|
771
1485
|
try {
|
|
772
|
-
return Array.from(new Bun.Glob(pattern).scanSync(dir)).map(name =>
|
|
1486
|
+
return Array.from(new Bun.Glob(pattern).scanSync({ cwd: dir, dot: pattern.startsWith(".") })).map(name =>
|
|
1487
|
+
path.join(dir, name),
|
|
1488
|
+
);
|
|
773
1489
|
} catch {
|
|
774
1490
|
return [];
|
|
775
1491
|
}
|
|
@@ -835,6 +1551,68 @@ export class FileSessionStorage implements SessionStorage {
|
|
|
835
1551
|
}
|
|
836
1552
|
}
|
|
837
1553
|
|
|
1554
|
+
replaceExactSync(
|
|
1555
|
+
sourcePath: string,
|
|
1556
|
+
destinationPath: string,
|
|
1557
|
+
expected: SessionStorageExactReplacementExpectation,
|
|
1558
|
+
): boolean {
|
|
1559
|
+
const dir = path.dirname(destinationPath);
|
|
1560
|
+
const parent = fs.statSync(dir, { bigint: true });
|
|
1561
|
+
let fd: number | undefined;
|
|
1562
|
+
try {
|
|
1563
|
+
fd = fs.openSync(sourcePath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
|
|
1564
|
+
const before = fs.fstatSync(fd, { bigint: true });
|
|
1565
|
+
if (!before.isFile()) return false;
|
|
1566
|
+
const hash = createHash("sha256");
|
|
1567
|
+
const chunk = Buffer.alloc(STAGED_WRITER_COPY_CHUNK_BYTES);
|
|
1568
|
+
for (;;) {
|
|
1569
|
+
const count = fs.readSync(fd, chunk, 0, chunk.byteLength, null);
|
|
1570
|
+
if (count === 0) break;
|
|
1571
|
+
hash.update(chunk.subarray(0, count));
|
|
1572
|
+
}
|
|
1573
|
+
const after = fs.fstatSync(fd, { bigint: true });
|
|
1574
|
+
if (
|
|
1575
|
+
before.dev !== after.dev ||
|
|
1576
|
+
before.ino !== after.ino ||
|
|
1577
|
+
before.nlink !== after.nlink ||
|
|
1578
|
+
before.size !== after.size ||
|
|
1579
|
+
before.mtimeNs !== after.mtimeNs
|
|
1580
|
+
)
|
|
1581
|
+
return false;
|
|
1582
|
+
fs.closeSync(fd);
|
|
1583
|
+
fd = undefined;
|
|
1584
|
+
const outcome = nativeSessionStorage().exactReplacePath(
|
|
1585
|
+
sourcePath,
|
|
1586
|
+
destinationPath,
|
|
1587
|
+
{
|
|
1588
|
+
dev: before.dev,
|
|
1589
|
+
ino: before.ino,
|
|
1590
|
+
nlink: before.nlink,
|
|
1591
|
+
parentDev: parent.dev,
|
|
1592
|
+
parentIno: parent.ino,
|
|
1593
|
+
size: before.size,
|
|
1594
|
+
mtimeNs: before.mtimeNs,
|
|
1595
|
+
sha256: hash.digest("hex"),
|
|
1596
|
+
},
|
|
1597
|
+
{
|
|
1598
|
+
dev: expected.stat.dev,
|
|
1599
|
+
ino: expected.stat.ino,
|
|
1600
|
+
nlink: expected.stat.nlink ?? 1n,
|
|
1601
|
+
parentDev: parent.dev,
|
|
1602
|
+
parentIno: parent.ino,
|
|
1603
|
+
size: BigInt(expected.stat.size),
|
|
1604
|
+
mtimeNs: expected.stat.mtimeNs,
|
|
1605
|
+
sha256: expected.sha256,
|
|
1606
|
+
},
|
|
1607
|
+
);
|
|
1608
|
+
if (!outcome.ok) return false;
|
|
1609
|
+
fsyncDirectorySync(dir);
|
|
1610
|
+
return true;
|
|
1611
|
+
} finally {
|
|
1612
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
|
|
838
1616
|
unlink(path: string): Promise<void> {
|
|
839
1617
|
return fs.promises.unlink(path);
|
|
840
1618
|
}
|
|
@@ -847,6 +1625,10 @@ export class FileSessionStorage implements SessionStorage {
|
|
|
847
1625
|
return new FileSessionStorageWriter(path, options);
|
|
848
1626
|
}
|
|
849
1627
|
|
|
1628
|
+
openStagedWriter(path: string, options?: SessionStorageWriterOpenOptions): StagedStreamingWriter {
|
|
1629
|
+
return new FileStagedStreamingWriter(path, options);
|
|
1630
|
+
}
|
|
1631
|
+
|
|
850
1632
|
/**
|
|
851
1633
|
* Delete a session and sibling artifacts in an operator-selected explicit directory.
|
|
852
1634
|
* Default managed roots use deleteSessionVerified and never call this path.
|
|
@@ -857,6 +1639,15 @@ export class FileSessionStorage implements SessionStorage {
|
|
|
857
1639
|
} catch (error) {
|
|
858
1640
|
if (!isEnoent(error)) throw error;
|
|
859
1641
|
}
|
|
1642
|
+
for (const candidate of this.listFilesSync(path.dirname(sessionPath), `${path.basename(sessionPath)}.spill.*`)) {
|
|
1643
|
+
if (candidate.startsWith(`${sessionPath}.spill.`) && isDerivedSessionMemoryFile(candidate)) {
|
|
1644
|
+
try {
|
|
1645
|
+
await this.unlink(candidate);
|
|
1646
|
+
} catch (error) {
|
|
1647
|
+
if (!isEnoent(error)) throw error;
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
860
1651
|
const artifactsDir = sessionPath.slice(0, -6);
|
|
861
1652
|
try {
|
|
862
1653
|
await fs.promises.rm(artifactsDir, { recursive: true, force: true });
|
|
@@ -1524,11 +2315,125 @@ function parseFirstJsonlLine(bytes: Uint8Array): Record<string, unknown> | undef
|
|
|
1524
2315
|
|
|
1525
2316
|
function matchesPattern(name: string, pattern: string): boolean {
|
|
1526
2317
|
if (pattern === "*") return true;
|
|
2318
|
+
if (pattern === ".*") return name.startsWith(".");
|
|
1527
2319
|
if (pattern.startsWith("*.")) {
|
|
1528
2320
|
return name.endsWith(pattern.slice(1));
|
|
1529
2321
|
}
|
|
1530
2322
|
return name === pattern;
|
|
1531
2323
|
}
|
|
2324
|
+
/**
|
|
2325
|
+
* In-memory staged streaming writer: mirrors the file-backend contract exactly
|
|
2326
|
+
* (line streaming, in-place same-length patchLine, bounded buffered overlay for
|
|
2327
|
+
* different-length patches, missing-only no-replace publication) against the
|
|
2328
|
+
* backing MemorySessionStorage so parity tests can exercise the full surface
|
|
2329
|
+
* without touching the filesystem.
|
|
2330
|
+
*/
|
|
2331
|
+
class MemoryStagedStreamingWriter implements StagedStreamingWriter {
|
|
2332
|
+
#storage: MemorySessionStorage;
|
|
2333
|
+
#path: string;
|
|
2334
|
+
#lines: Buffer[] = [];
|
|
2335
|
+
#pendingPatches = new Map<number, Uint8Array>();
|
|
2336
|
+
#pendingPatchBytes = 0;
|
|
2337
|
+
#retainedLineBytes = 0;
|
|
2338
|
+
#closed = false;
|
|
2339
|
+
#published = false;
|
|
2340
|
+
#error: Error | undefined;
|
|
2341
|
+
|
|
2342
|
+
constructor(storage: MemorySessionStorage, path: string) {
|
|
2343
|
+
this.#storage = storage;
|
|
2344
|
+
this.#path = path;
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2347
|
+
#assertOpen(): void {
|
|
2348
|
+
if (this.#closed) throw new Error("Staged writer is closed");
|
|
2349
|
+
if (this.#error) throw this.#error;
|
|
2350
|
+
}
|
|
2351
|
+
|
|
2352
|
+
writeLine(bytes: Uint8Array): void {
|
|
2353
|
+
this.#assertOpen();
|
|
2354
|
+
if (bytes.byteLength > STAGED_WRITER_LINE_MAX_BYTES)
|
|
2355
|
+
throw new RangeError("Staged line exceeds the bounded maximum");
|
|
2356
|
+
if (
|
|
2357
|
+
this.#lines.length >= STAGED_MEMORY_WRITER_MAX_LINES ||
|
|
2358
|
+
this.#retainedLineBytes + bytes.byteLength + 1 > STAGED_MEMORY_WRITER_MAX_BYTES
|
|
2359
|
+
)
|
|
2360
|
+
throw new Error("staged_memory_capacity_exceeded");
|
|
2361
|
+
this.#lines.push(Buffer.from(bytes));
|
|
2362
|
+
this.#retainedLineBytes += bytes.byteLength + 1;
|
|
2363
|
+
}
|
|
2364
|
+
|
|
2365
|
+
seekToLine(ordinal: number): void {
|
|
2366
|
+
this.#assertOpen();
|
|
2367
|
+
if (ordinal < 0 || ordinal >= this.#lines.length) throw new RangeError("Line ordinal is not staged");
|
|
2368
|
+
}
|
|
2369
|
+
|
|
2370
|
+
patchLine(ordinal: number, bytes: Uint8Array): void {
|
|
2371
|
+
this.#assertOpen();
|
|
2372
|
+
if (ordinal < 0 || ordinal >= this.#lines.length) throw new RangeError("Line ordinal is not staged");
|
|
2373
|
+
const existing = this.#lines[ordinal];
|
|
2374
|
+
if (!existing) throw new RangeError("Line ordinal is not staged");
|
|
2375
|
+
if (bytes.byteLength === existing.byteLength) {
|
|
2376
|
+
this.#lines[ordinal] = Buffer.from(bytes);
|
|
2377
|
+
return;
|
|
2378
|
+
}
|
|
2379
|
+
const prior = this.#pendingPatches.get(ordinal);
|
|
2380
|
+
if (prior) {
|
|
2381
|
+
const nextPatchBytes = this.#pendingPatchBytes - prior.byteLength + bytes.byteLength;
|
|
2382
|
+
if (nextPatchBytes > STAGED_WRITER_PATCH_LIMIT_BYTES) {
|
|
2383
|
+
this.#error = new Error("staged_overlay_capacity_exceeded");
|
|
2384
|
+
throw this.#error;
|
|
2385
|
+
}
|
|
2386
|
+
this.#pendingPatches.set(ordinal, Buffer.from(bytes));
|
|
2387
|
+
this.#pendingPatchBytes = nextPatchBytes;
|
|
2388
|
+
return;
|
|
2389
|
+
}
|
|
2390
|
+
if (
|
|
2391
|
+
this.#pendingPatches.size >= STAGED_WRITER_PATCH_MAX_COUNT ||
|
|
2392
|
+
this.#pendingPatchBytes + bytes.byteLength > STAGED_WRITER_PATCH_LIMIT_BYTES
|
|
2393
|
+
) {
|
|
2394
|
+
this.#error = new Error("staged_overlay_capacity_exceeded");
|
|
2395
|
+
throw this.#error;
|
|
2396
|
+
}
|
|
2397
|
+
this.#pendingPatches.set(ordinal, Buffer.from(bytes));
|
|
2398
|
+
this.#pendingPatchBytes += bytes.byteLength;
|
|
2399
|
+
}
|
|
2400
|
+
|
|
2401
|
+
flush(): void {
|
|
2402
|
+
this.#assertOpen();
|
|
2403
|
+
}
|
|
2404
|
+
|
|
2405
|
+
fsync(): void {
|
|
2406
|
+
this.#assertOpen();
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2409
|
+
closeSync(): void {
|
|
2410
|
+
if (this.#closed) return;
|
|
2411
|
+
if (this.#error) throw this.#error;
|
|
2412
|
+
this.#closed = true;
|
|
2413
|
+
}
|
|
2414
|
+
|
|
2415
|
+
publishNoReplace(): void {
|
|
2416
|
+
if (this.#published) throw new Error("Staged writer already published");
|
|
2417
|
+
if (!this.#closed) throw new Error("Staged writer must be closed before publication");
|
|
2418
|
+
if (this.#error) throw this.#error;
|
|
2419
|
+
if (this.#storage.existsSync(this.#path)) throw new Error("destination_conflict");
|
|
2420
|
+
const selectedLines = this.#lines.map((line, index) => this.#pendingPatches.get(index) ?? line);
|
|
2421
|
+
const totalBytes = selectedLines.reduce((total, line) => total + line.byteLength + 1, 0);
|
|
2422
|
+
if (totalBytes > STAGED_MEMORY_WRITER_MAX_BYTES + STAGED_WRITER_PATCH_LIMIT_BYTES)
|
|
2423
|
+
throw new Error("staged_memory_capacity_exceeded");
|
|
2424
|
+
const content = Buffer.allocUnsafe(totalBytes);
|
|
2425
|
+
let offset = 0;
|
|
2426
|
+
for (const line of selectedLines) {
|
|
2427
|
+
Buffer.from(line).copy(content, offset);
|
|
2428
|
+
offset += line.byteLength;
|
|
2429
|
+
content[offset++] = 0x0a;
|
|
2430
|
+
}
|
|
2431
|
+
this.#lines.length = 0;
|
|
2432
|
+
this.#pendingPatches.clear();
|
|
2433
|
+
this.#storage.writeBytesOwnedSync(this.#path, content);
|
|
2434
|
+
this.#published = true;
|
|
2435
|
+
}
|
|
2436
|
+
}
|
|
1532
2437
|
|
|
1533
2438
|
class MemorySessionStorageWriter implements SessionStorageWriter {
|
|
1534
2439
|
#storage: MemorySessionStorage;
|
|
@@ -1539,15 +2444,22 @@ class MemorySessionStorageWriter implements SessionStorageWriter {
|
|
|
1539
2444
|
#onError: ((err: Error) => void) | undefined;
|
|
1540
2445
|
|
|
1541
2446
|
#closeAdapter: SessionStorageWriterCloseAdapter | undefined;
|
|
2447
|
+
#bytes: Buffer;
|
|
2448
|
+
#length = 0;
|
|
1542
2449
|
|
|
1543
2450
|
constructor(storage: MemorySessionStorage, path: string, options?: SessionStorageWriterOpenOptions) {
|
|
1544
2451
|
this.#storage = storage;
|
|
1545
2452
|
this.#path = path;
|
|
1546
2453
|
this.#onError = options?.onError;
|
|
1547
2454
|
this.#closeAdapter = options?.closeAdapter;
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
2455
|
+
const existing =
|
|
2456
|
+
options?.flags === "w" || !storage.existsSync(path)
|
|
2457
|
+
? Buffer.alloc(0)
|
|
2458
|
+
: Buffer.from(storage.readBytesSync(path));
|
|
2459
|
+
this.#length = existing.byteLength;
|
|
2460
|
+
this.#bytes = Buffer.allocUnsafe(Math.max(existing.byteLength, 4096));
|
|
2461
|
+
existing.copy(this.#bytes);
|
|
2462
|
+
if (options?.flags === "w") this.#storage.writeBytesOwnedSync(path, this.#bytes.subarray(0, 0));
|
|
1551
2463
|
}
|
|
1552
2464
|
|
|
1553
2465
|
#recordError(err: unknown): Error {
|
|
@@ -1561,8 +2473,16 @@ class MemorySessionStorageWriter implements SessionStorageWriter {
|
|
|
1561
2473
|
if (this.#closeState !== "open") throw new Error("Writer closed");
|
|
1562
2474
|
if (this.#error) throw this.#error;
|
|
1563
2475
|
try {
|
|
1564
|
-
const
|
|
1565
|
-
this.#
|
|
2476
|
+
const bytes = Buffer.from(line, "utf8");
|
|
2477
|
+
const nextLength = this.#length + bytes.byteLength;
|
|
2478
|
+
if (nextLength > this.#bytes.byteLength) {
|
|
2479
|
+
const expanded = Buffer.allocUnsafe(Math.max(nextLength, this.#bytes.byteLength * 2));
|
|
2480
|
+
this.#bytes.copy(expanded, 0, 0, this.#length);
|
|
2481
|
+
this.#bytes = expanded;
|
|
2482
|
+
}
|
|
2483
|
+
bytes.copy(this.#bytes, this.#length);
|
|
2484
|
+
this.#length = nextLength;
|
|
2485
|
+
this.#storage.writeBytesOwnedSync(this.#path, this.#bytes.subarray(0, this.#length));
|
|
1566
2486
|
} catch (err) {
|
|
1567
2487
|
throw this.#recordError(err);
|
|
1568
2488
|
}
|
|
@@ -1576,11 +2496,18 @@ class MemorySessionStorageWriter implements SessionStorageWriter {
|
|
|
1576
2496
|
if (this.#closeState !== "open") throw new Error("Writer closed");
|
|
1577
2497
|
if (this.#error) throw this.#error;
|
|
1578
2498
|
}
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
2499
|
+
fsyncSync(): void {
|
|
2500
|
+
if (this.#closeState !== "open") throw new Error("Writer closed");
|
|
2501
|
+
if (this.#error) throw this.#error;
|
|
2502
|
+
}
|
|
2503
|
+
statSync(): SessionStorageStat {
|
|
1582
2504
|
if (this.#closeState !== "open") throw new Error("Writer closed");
|
|
1583
2505
|
if (this.#error) throw this.#error;
|
|
2506
|
+
return this.#storage.statSync(this.#path);
|
|
2507
|
+
}
|
|
2508
|
+
|
|
2509
|
+
async fsync(): Promise<void> {
|
|
2510
|
+
this.fsyncSync();
|
|
1584
2511
|
}
|
|
1585
2512
|
|
|
1586
2513
|
closeSync(): void {
|
|
@@ -1651,6 +2578,15 @@ export class MemorySessionStorage implements SessionStorage {
|
|
|
1651
2578
|
return this.#files.has(path);
|
|
1652
2579
|
}
|
|
1653
2580
|
|
|
2581
|
+
writeBytesOwnedSync(path: string, content: Buffer): void {
|
|
2582
|
+
const existing = this.#files.get(path);
|
|
2583
|
+
this.#files.set(path, {
|
|
2584
|
+
content,
|
|
2585
|
+
mtimeMs: Date.now(),
|
|
2586
|
+
ino: existing?.ino ?? this.#nextInode++,
|
|
2587
|
+
});
|
|
2588
|
+
}
|
|
2589
|
+
|
|
1654
2590
|
writeTextSync(path: string, content: string): void {
|
|
1655
2591
|
const existing = this.#files.get(path);
|
|
1656
2592
|
this.#files.set(path, {
|
|
@@ -1675,6 +2611,22 @@ export class MemorySessionStorage implements SessionStorage {
|
|
|
1675
2611
|
if (!entry) throw new Error(`File not found: ${path}`);
|
|
1676
2612
|
return { bytes: Buffer.from(entry.content), stat: this.#statFor(entry) };
|
|
1677
2613
|
}
|
|
2614
|
+
/**
|
|
2615
|
+
* Bounded recorded-length read with descriptor identity validation: mirrors the
|
|
2616
|
+
* file backend's contract (dev/ino/nlink identity, exact `length` bytes present)
|
|
2617
|
+
* against the in-memory file model so parity tests can compare backends.
|
|
2618
|
+
*/
|
|
2619
|
+
readRangeSync(path: string, start: number, length: number): SessionStorageRangeSnapshot {
|
|
2620
|
+
validateRangeReadBounds(start, length);
|
|
2621
|
+
const entry = this.#files.get(path);
|
|
2622
|
+
if (!entry) throw new Error(`File not found: ${path}`);
|
|
2623
|
+
if (entry.content.byteLength < start + length) throw new Error("range_not_present");
|
|
2624
|
+
return { bytes: Buffer.from(entry.content.subarray(start, start + length)), stat: this.#statFor(entry) };
|
|
2625
|
+
}
|
|
2626
|
+
|
|
2627
|
+
async readRange(path: string, start: number, length: number): Promise<SessionStorageRangeSnapshot> {
|
|
2628
|
+
return this.readRangeSync(path, start, length);
|
|
2629
|
+
}
|
|
1678
2630
|
|
|
1679
2631
|
statSync(path: string): SessionStorageStat {
|
|
1680
2632
|
const entry = this.#files.get(path);
|
|
@@ -1741,6 +2693,21 @@ export class MemorySessionStorage implements SessionStorage {
|
|
|
1741
2693
|
this.#files.delete(path);
|
|
1742
2694
|
}
|
|
1743
2695
|
|
|
2696
|
+
replaceExactSync(
|
|
2697
|
+
sourcePath: string,
|
|
2698
|
+
destinationPath: string,
|
|
2699
|
+
expected: SessionStorageExactReplacementExpectation,
|
|
2700
|
+
): boolean {
|
|
2701
|
+
const source = this.#files.get(sourcePath);
|
|
2702
|
+
const destination = this.#files.get(destinationPath);
|
|
2703
|
+
if (!source || !destination) return false;
|
|
2704
|
+
if (!sameDescriptorIdentity(this.#statFor(destination), expected.stat)) return false;
|
|
2705
|
+
if (createHash("sha256").update(destination.content).digest("hex") !== expected.sha256) return false;
|
|
2706
|
+
this.#files.set(destinationPath, source);
|
|
2707
|
+
this.#files.delete(sourcePath);
|
|
2708
|
+
return true;
|
|
2709
|
+
}
|
|
2710
|
+
|
|
1744
2711
|
unlink(path: string): Promise<void> {
|
|
1745
2712
|
this.#files.delete(path);
|
|
1746
2713
|
return Promise.resolve();
|
|
@@ -1752,6 +2719,10 @@ export class MemorySessionStorage implements SessionStorage {
|
|
|
1752
2719
|
|
|
1753
2720
|
deleteSessionWithArtifacts(sessionPath: string): Promise<void> {
|
|
1754
2721
|
this.#files.delete(sessionPath);
|
|
2722
|
+
for (const candidate of [...this.#files.keys()]) {
|
|
2723
|
+
if (candidate.startsWith(`${sessionPath}.spill.`) && isDerivedSessionMemoryFile(candidate))
|
|
2724
|
+
this.#files.delete(candidate);
|
|
2725
|
+
}
|
|
1755
2726
|
return Promise.resolve();
|
|
1756
2727
|
}
|
|
1757
2728
|
|
|
@@ -1810,6 +2781,10 @@ export class MemorySessionStorage implements SessionStorage {
|
|
|
1810
2781
|
new SessionDeleteVerificationError("artifacts", "Artifact path exists but is not a directory"),
|
|
1811
2782
|
);
|
|
1812
2783
|
}
|
|
2784
|
+
for (const candidate of [...this.#files.keys()]) {
|
|
2785
|
+
if (candidate.startsWith(`${transcriptPath}.spill.`) && isDerivedSessionMemoryFile(candidate))
|
|
2786
|
+
this.#files.delete(candidate);
|
|
2787
|
+
}
|
|
1813
2788
|
this.#files.delete(transcriptPath);
|
|
1814
2789
|
return Promise.resolve({ kind: "deleted" });
|
|
1815
2790
|
}
|
|
@@ -1817,4 +2792,152 @@ export class MemorySessionStorage implements SessionStorage {
|
|
|
1817
2792
|
openWriter(path: string, options?: SessionStorageWriterOpenOptions): SessionStorageWriter {
|
|
1818
2793
|
return new MemorySessionStorageWriter(this, path, options);
|
|
1819
2794
|
}
|
|
2795
|
+
|
|
2796
|
+
openStagedWriter(path: string): StagedStreamingWriter {
|
|
2797
|
+
return new MemoryStagedStreamingWriter(this, path);
|
|
2798
|
+
}
|
|
2799
|
+
}
|
|
2800
|
+
|
|
2801
|
+
/**
|
|
2802
|
+
* Outcome of a disk-retention retirement attempt.
|
|
2803
|
+
*
|
|
2804
|
+
* `kept` means the transcript survived and nothing of the session was
|
|
2805
|
+
* destroyed, with the exact reason so `gjc gc --disk` can report it.
|
|
2806
|
+
* `cleanup_pending` means the delete authority already detached or removed the
|
|
2807
|
+
* session's artifact tree (or quarantined the transcript) before it stopped:
|
|
2808
|
+
* the record survives, the session does not, and a caller must NOT report that
|
|
2809
|
+
* as an ordinary keep.
|
|
2810
|
+
*/
|
|
2811
|
+
export type SessionRetirementOutcome =
|
|
2812
|
+
| { kind: "retired" }
|
|
2813
|
+
| { kind: "kept"; reason: string }
|
|
2814
|
+
| { kind: "cleanup_pending"; reason: string };
|
|
2815
|
+
|
|
2816
|
+
/** A retirement that cleared every precondition the retention pass itself owns. */
|
|
2817
|
+
type SessionRetirementPlan =
|
|
2818
|
+
| { kind: "retirable"; target: VerifiedSessionDeleteTarget; artifactsPresent: boolean }
|
|
2819
|
+
| { kind: "kept"; reason: string };
|
|
2820
|
+
|
|
2821
|
+
/**
|
|
2822
|
+
* Evaluate the preconditions retirement owns — the transcript must be readable
|
|
2823
|
+
* and carry a usable session header — and bind the delete target to the exact
|
|
2824
|
+
* bytes just read. Nothing here mutates the store.
|
|
2825
|
+
*/
|
|
2826
|
+
function planSessionRetirement(
|
|
2827
|
+
storage: FileSessionStorage,
|
|
2828
|
+
sessionsRoot: string,
|
|
2829
|
+
transcriptPath: string,
|
|
2830
|
+
): SessionRetirementPlan {
|
|
2831
|
+
let snapshot: SessionStorageSnapshot;
|
|
2832
|
+
try {
|
|
2833
|
+
snapshot = storage.readSnapshotSync(transcriptPath);
|
|
2834
|
+
} catch (err) {
|
|
2835
|
+
return { kind: "kept", reason: `transcript_unreadable: ${toError(err).message}` };
|
|
2836
|
+
}
|
|
2837
|
+
|
|
2838
|
+
const header = parseFirstJsonlLine(snapshot.bytes);
|
|
2839
|
+
if (header?.type !== "session" || typeof header.id !== "string" || typeof header.cwd !== "string") {
|
|
2840
|
+
return { kind: "kept", reason: "transcript_header_unusable" };
|
|
2841
|
+
}
|
|
2842
|
+
|
|
2843
|
+
// The verified delete authority only unlinks a single-link transcript, so a
|
|
2844
|
+
// hard-linked one can never be retired. Refusing here keeps the dry run from
|
|
2845
|
+
// promising bytes prune cannot release, and — because this runs before the
|
|
2846
|
+
// artifact phase — keeps a doomed retirement from destroying the artifact
|
|
2847
|
+
// tree of a session whose transcript will survive anyway.
|
|
2848
|
+
if (snapshot.stat.nlink === undefined || snapshot.stat.nlink !== 1n) {
|
|
2849
|
+
return { kind: "kept", reason: "transcript_not_single_link" };
|
|
2850
|
+
}
|
|
2851
|
+
|
|
2852
|
+
const directory = path.dirname(transcriptPath);
|
|
2853
|
+
return {
|
|
2854
|
+
kind: "retirable",
|
|
2855
|
+
artifactsPresent: storage.existsSync(transcriptPath.slice(0, -".jsonl".length)),
|
|
2856
|
+
target: {
|
|
2857
|
+
sessionsRoot,
|
|
2858
|
+
transcriptPath,
|
|
2859
|
+
sessionId: header.id,
|
|
2860
|
+
cwd: header.cwd,
|
|
2861
|
+
transcriptIdentity: {
|
|
2862
|
+
dev: snapshot.stat.dev,
|
|
2863
|
+
ino: snapshot.stat.ino,
|
|
2864
|
+
nlink: snapshot.stat.nlink,
|
|
2865
|
+
size: snapshot.stat.size,
|
|
2866
|
+
mtimeNs: snapshot.stat.mtimeNs,
|
|
2867
|
+
sha256: createHash("sha256").update(snapshot.bytes).digest("hex"),
|
|
2868
|
+
},
|
|
2869
|
+
plannedArtifactsPath: path.join(directory, `.gjc-delete-gc-${randomUUID()}-artifacts`),
|
|
2870
|
+
plannedTranscriptPath: path.join(directory, `.gjc-delete-gc-${randomUUID()}-transcript`),
|
|
2871
|
+
},
|
|
2872
|
+
};
|
|
2873
|
+
}
|
|
2874
|
+
|
|
2875
|
+
/**
|
|
2876
|
+
* Non-mutating projection of {@link retireSessionTranscript}: would the
|
|
2877
|
+
* retention pass's own preconditions let this transcript be retired at all?
|
|
2878
|
+
*
|
|
2879
|
+
* `gjc gc --disk` runs it so a dry run reports the verdict a prune would reach
|
|
2880
|
+
* instead of promising bytes the delete authority will refuse to release. The
|
|
2881
|
+
* authority's verdict (containment, identity, artifact tree) is deliberately
|
|
2882
|
+
* not predicted here — it is re-derived against live state at delete time.
|
|
2883
|
+
*/
|
|
2884
|
+
export function probeSessionRetirement(
|
|
2885
|
+
storage: FileSessionStorage,
|
|
2886
|
+
sessionsRoot: string,
|
|
2887
|
+
transcriptPath: string,
|
|
2888
|
+
): { kind: "retirable" } | { kind: "kept"; reason: string } {
|
|
2889
|
+
const plan = planSessionRetirement(storage, sessionsRoot, transcriptPath);
|
|
2890
|
+
return plan.kind === "retirable" ? { kind: "retirable" } : plan;
|
|
2891
|
+
}
|
|
2892
|
+
|
|
2893
|
+
/**
|
|
2894
|
+
* A transcript that survives after its artifacts are already gone is not a
|
|
2895
|
+
* benign keep: the session is half-destroyed and the caller has to surface it.
|
|
2896
|
+
*/
|
|
2897
|
+
function retirementIncomplete(artifactsRemoved: boolean, reason: string): SessionRetirementOutcome {
|
|
2898
|
+
return artifactsRemoved ? { kind: "cleanup_pending", reason } : { kind: "kept", reason };
|
|
2899
|
+
}
|
|
2900
|
+
|
|
2901
|
+
/**
|
|
2902
|
+
* Retire one managed session transcript through the verified hard-delete
|
|
2903
|
+
* authority.
|
|
2904
|
+
*
|
|
2905
|
+
* This is the only supported way for a retention pass to remove a transcript:
|
|
2906
|
+
* identity, containment, header id/cwd, artifact tree and parent directory are
|
|
2907
|
+
* all re-verified inside {@link FileSessionStorage.deleteSessionVerified}, and
|
|
2908
|
+
* anything ambiguous fails closed as `kept` rather than deleting bytes. The
|
|
2909
|
+
* caller owns the retention policy; this function owns nothing but the delete
|
|
2910
|
+
* authority.
|
|
2911
|
+
*/
|
|
2912
|
+
export async function retireSessionTranscript(
|
|
2913
|
+
storage: FileSessionStorage,
|
|
2914
|
+
sessionsRoot: string,
|
|
2915
|
+
transcriptPath: string,
|
|
2916
|
+
): Promise<SessionRetirementOutcome> {
|
|
2917
|
+
const plan = planSessionRetirement(storage, sessionsRoot, transcriptPath);
|
|
2918
|
+
if (plan.kind === "kept") return plan;
|
|
2919
|
+
|
|
2920
|
+
let artifactsRemoved = false;
|
|
2921
|
+
try {
|
|
2922
|
+
// Phase 1 removes the sibling artifact directory (or proves it absent);
|
|
2923
|
+
// phase 2 unlinks the transcript only against the same bound identity.
|
|
2924
|
+
const artifacts = await storage.deleteSessionVerified(plan.target);
|
|
2925
|
+
if (artifacts.kind === "deleted") return { kind: "retired" };
|
|
2926
|
+
if (artifacts.kind === "cleanup_pending") {
|
|
2927
|
+
return { kind: "cleanup_pending", reason: `cleanup_pending_${artifacts.phase}: ${artifacts.error.message}` };
|
|
2928
|
+
}
|
|
2929
|
+
// Phase 1 is durable: whatever artifact tree the session had is gone, so
|
|
2930
|
+
// every later refusal leaves a record without its artifacts.
|
|
2931
|
+
artifactsRemoved = plan.artifactsPresent;
|
|
2932
|
+
const deletion = await storage.deleteSessionVerified({ ...plan.target, artifactsRemoved: true });
|
|
2933
|
+
if (deletion.kind === "deleted") return { kind: "retired" };
|
|
2934
|
+
if (deletion.kind === "cleanup_pending") {
|
|
2935
|
+
return { kind: "cleanup_pending", reason: `cleanup_pending_${deletion.phase}: ${deletion.error.message}` };
|
|
2936
|
+
}
|
|
2937
|
+
return retirementIncomplete(artifactsRemoved, "transcript_not_deleted");
|
|
2938
|
+
} catch (err) {
|
|
2939
|
+
const error = toError(err);
|
|
2940
|
+
const kind = err instanceof SessionDeleteVerificationError ? err.kind : "error";
|
|
2941
|
+
return retirementIncomplete(artifactsRemoved, `verified_delete_rejected(${kind}): ${error.message}`);
|
|
2942
|
+
}
|
|
1820
2943
|
}
|