@gajae-code/coding-agent 0.12.0 → 0.12.1
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 +82 -1
- package/dist/types/async/job-manager.d.ts +17 -3
- package/dist/types/config/settings-schema.d.ts +54 -0
- package/dist/types/exa/mcp-client.d.ts +10 -1
- package/dist/types/exec/bash-executor.d.ts +45 -12
- package/dist/types/extensibility/extensions/types.d.ts +16 -2
- package/dist/types/extensibility/gjc-plugins/index.d.ts +12 -2
- package/dist/types/extensibility/gjc-plugins/installer.d.ts +63 -7
- package/dist/types/extensibility/gjc-plugins/lifecycle-reconciliation.d.ts +55 -0
- package/dist/types/extensibility/gjc-plugins/lifecycle.d.ts +64 -0
- package/dist/types/extensibility/gjc-plugins/runtime-quarantine.d.ts +67 -0
- package/dist/types/extensibility/gjc-plugins/session-validation.d.ts +3 -1
- package/dist/types/extensibility/gjc-plugins/types.d.ts +124 -0
- package/dist/types/gjc-runtime/launch-tmux.d.ts +1 -0
- package/dist/types/gjc-runtime/ralplan-runtime.d.ts +18 -0
- package/dist/types/gjc-runtime/team-launch.d.ts +1 -1
- package/dist/types/gjc-runtime/team-runtime.d.ts +25 -0
- package/dist/types/gjc-runtime/team-worker-heartbeat.d.ts +43 -0
- package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +10 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +31 -80
- package/dist/types/modes/acp/acp-agent.d.ts +2 -19
- package/dist/types/modes/components/gjc-bundle-settings.d.ts +34 -0
- package/dist/types/modes/components/notifications-settings-editor.d.ts +1 -0
- package/dist/types/modes/components/session-observer-overlay.d.ts +18 -0
- package/dist/types/modes/components/settings-selector.d.ts +9 -0
- package/dist/types/modes/components/status-line/gh.d.ts +5 -0
- package/dist/types/modes/components/transcript-viewer-overlay.d.ts +18 -1
- package/dist/types/reminders/star-reminder.d.ts +3 -13
- package/dist/types/runtime-mcp/manager.d.ts +1 -0
- package/dist/types/sdk/acp/adapter.d.ts +3 -1
- package/dist/types/sdk/broker/transport.d.ts +2 -0
- package/dist/types/sdk/bus/attachment-registry.d.ts +1 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +4 -1
- package/dist/types/sdk/bus/config.d.ts +6 -2
- package/dist/types/sdk/bus/index.d.ts +29 -0
- package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +8 -1
- package/dist/types/sdk/bus/lifecycle-control-runtime.d.ts +4 -0
- package/dist/types/sdk/bus/reconciliation-store.d.ts +7 -3
- package/dist/types/sdk/bus/rich-draft.d.ts +1 -0
- package/dist/types/sdk/bus/rich-render.d.ts +2 -0
- package/dist/types/sdk/bus/telegram-adoption-intent.d.ts +165 -0
- package/dist/types/sdk/bus/telegram-cli.d.ts +12 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +11 -4
- package/dist/types/sdk/bus/telegram-daemon.d.ts +25 -1
- package/dist/types/sdk/bus/telegram-reference.d.ts +11 -1
- package/dist/types/sdk/bus/threaded-render.d.ts +3 -2
- package/dist/types/sdk/bus/topic-registry.d.ts +11 -1
- package/dist/types/sdk/host/query/revision-store.d.ts +2 -0
- package/dist/types/sdk/host/reverse-leases.d.ts +1 -1
- package/dist/types/sdk/prompt-status.d.ts +23 -3
- package/dist/types/sdk/session.d.ts +6 -0
- package/dist/types/session/agent-session.d.ts +38 -2
- package/dist/types/session/artifacts.d.ts +8 -0
- package/dist/types/session/blob-store.d.ts +57 -4
- package/dist/types/session/internal/managed-session-storage.d.ts +8 -1
- package/dist/types/session/session-manager.d.ts +23 -6
- package/dist/types/session/session-storage.d.ts +4 -0
- package/dist/types/session/streaming-output.d.ts +36 -7
- package/dist/types/setup/credential-import.d.ts +12 -0
- package/dist/types/skill-state/active-state.d.ts +12 -0
- package/dist/types/skill-state/workflow-hud.d.ts +6 -0
- package/dist/types/task/executor.d.ts +3 -2
- package/dist/types/task/index.d.ts +3 -1
- package/dist/types/task/provider-retry-status.d.ts +8 -0
- package/dist/types/task/receipt.d.ts +7 -1
- package/dist/types/task/types.d.ts +23 -0
- package/dist/types/tools/ask-answer-registry.d.ts +2 -2
- package/dist/types/tools/bash-interactive.d.ts +4 -1
- package/dist/types/tools/bash.d.ts +3 -1
- package/dist/types/tools/image-gen.d.ts +4 -0
- package/dist/types/tools/index.d.ts +4 -0
- package/dist/types/tools/output-meta.d.ts +23 -2
- package/dist/types/tools/path-utils.d.ts +1 -0
- package/dist/types/tools/read.d.ts +0 -6
- package/dist/types/tools/review.d.ts +5 -7
- package/dist/types/tools/subagent.d.ts +2 -0
- package/dist/types/utils/gh.d.ts +13 -0
- package/dist/types/utils/image-resize.d.ts +1 -0
- package/dist/types/web/search/providers/kimi.d.ts +2 -0
- package/dist/types/web/search/providers/searxng.d.ts +7 -0
- package/dist/types/web/search/providers/xai.d.ts +2 -0
- package/package.json +10 -8
- package/scripts/acp-conformance-agent.ts +218 -0
- package/scripts/capture-gjc-bundle-settings.ts +306 -0
- package/scripts/generate-sdk-operation-inventory.ts +3 -0
- package/scripts/resident-memory-bench.ts +1341 -0
- package/scripts/run-acp-conformance.ts +285 -0
- package/src/async/job-manager.ts +78 -20
- package/src/cli/fast-help.ts +1 -1
- package/src/cli/plugin-cli.ts +176 -18
- package/src/commands/team.ts +19 -1
- package/src/config/file-lock.ts +11 -2
- package/src/config/settings-schema.ts +51 -0
- package/src/coordinator-mcp/question-gate-codec.ts +1 -1
- package/src/coordinator-mcp/server.ts +5 -12
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -0
- package/src/defaults/gjc/skills/ralplan/SKILL.md +19 -16
- package/src/defaults/gjc/skills/team/SKILL.md +2 -0
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +37 -24
- package/src/defaults/gjc/skills/ultragoal/validation-batch-contracts.md +12 -0
- package/src/defaults/gjc-defaults.ts +4 -4
- package/src/exa/mcp-client.ts +12 -3
- package/src/exec/bash-executor.ts +144 -27
- package/src/extensibility/extensions/runner.ts +15 -0
- package/src/extensibility/extensions/types.ts +18 -1
- package/src/extensibility/gjc-plugins/constrained-hooks.ts +8 -2
- package/src/extensibility/gjc-plugins/index.ts +19 -2
- package/src/extensibility/gjc-plugins/installer.ts +189 -50
- package/src/extensibility/gjc-plugins/lifecycle-reconciliation.ts +199 -0
- package/src/extensibility/gjc-plugins/lifecycle.ts +626 -0
- package/src/extensibility/gjc-plugins/observability.ts +3 -3
- package/src/extensibility/gjc-plugins/prompt-appendix.ts +5 -1
- package/src/extensibility/gjc-plugins/runtime-adapters.ts +12 -2
- package/src/extensibility/gjc-plugins/runtime-quarantine.ts +121 -0
- package/src/extensibility/gjc-plugins/schema.ts +84 -12
- package/src/extensibility/gjc-plugins/session-validation.ts +9 -3
- package/src/extensibility/gjc-plugins/types.ts +141 -0
- package/src/extensibility/gjc-plugins/validation.ts +9 -0
- package/src/gjc-runtime/launch-tmux.ts +25 -6
- package/src/gjc-runtime/managed-owner-admission.ts +1 -1
- package/src/gjc-runtime/ralplan-runtime.ts +411 -17
- package/src/gjc-runtime/team-launch.ts +8 -0
- package/src/gjc-runtime/team-runtime.ts +140 -32
- package/src/gjc-runtime/team-worker-heartbeat.ts +149 -0
- package/src/gjc-runtime/tmux-owner-isolation.ts +10 -0
- package/src/gjc-runtime/tmux-sessions.ts +58 -13
- package/src/gjc-runtime/ultragoal-runtime.ts +401 -611
- package/src/gjc-runtime/workflow-manifest.generated.json +8 -99
- package/src/gjc-runtime/workflow-manifest.ts +4 -68
- package/src/internal-urls/docs-index.generated.ts +15 -15
- package/src/modes/DESIGN.md +51 -0
- package/src/modes/acp/acp-agent.ts +298 -186
- package/src/modes/components/gjc-bundle-settings.ts +508 -0
- package/src/modes/components/notifications-settings-editor.ts +12 -1
- package/src/modes/components/oauth-selector.ts +68 -30
- package/src/modes/components/session-observer-overlay.ts +305 -86
- package/src/modes/components/settings-selector.ts +56 -5
- package/src/modes/components/status-line/gh.ts +16 -0
- package/src/modes/components/tool-status-header.ts +3 -14
- package/src/modes/components/transcript-viewer-overlay.ts +99 -24
- package/src/modes/controllers/command-controller.ts +4 -0
- package/src/modes/controllers/extension-ui-controller.ts +7 -0
- package/src/modes/controllers/selector-controller.ts +45 -0
- package/src/modes/runtime-init.ts +3 -0
- package/src/prompts/agent-fragments/ralplan-persistence.md +4 -3
- package/src/prompts/system/eager-todo.md +8 -4
- package/src/prompts/system/system-prompt.md +11 -4
- package/src/prompts/tools/bash.md +5 -5
- package/src/prompts/tools/search-tool-bm25.md +5 -0
- package/src/prompts/tools/task.md +1 -1
- package/src/reminders/star-reminder.ts +4 -42
- package/src/runtime-mcp/manager.ts +19 -4
- package/src/sdk/acp/adapter.ts +22 -6
- package/src/sdk/broker/transport.ts +35 -3
- package/src/sdk/bus/attachment-registry.ts +5 -1
- package/src/sdk/bus/chat-daemon-cli.ts +4 -39
- package/src/sdk/bus/chat-daemon-control.ts +6 -3
- package/src/sdk/bus/config.ts +18 -2
- package/src/sdk/bus/index.ts +661 -126
- package/src/sdk/bus/kind-aware-reconciliation.ts +148 -73
- package/src/sdk/bus/lifecycle-commands.ts +4 -1
- package/src/sdk/bus/lifecycle-control-runtime.ts +403 -88
- package/src/sdk/bus/reconciliation-store.ts +95 -16
- package/src/sdk/bus/rich-draft.ts +1 -1
- package/src/sdk/bus/rich-render.ts +2 -2
- package/src/sdk/bus/telegram-adoption-intent.ts +574 -0
- package/src/sdk/bus/telegram-cli.ts +33 -8
- package/src/sdk/bus/telegram-daemon-cli.ts +1 -0
- package/src/sdk/bus/telegram-daemon-contract.ts +11 -4
- package/src/sdk/bus/telegram-daemon.ts +916 -23
- package/src/sdk/bus/telegram-reference.ts +40 -3
- package/src/sdk/bus/threaded-render.ts +48 -3
- package/src/sdk/bus/topic-registry.ts +20 -0
- package/src/sdk/host/query/revision-store.ts +36 -2
- package/src/sdk/host/reverse-leases.ts +44 -7
- package/src/sdk/prompt-status.ts +16 -3
- package/src/sdk/protocol/operation-inventory.generated.json +22 -0
- package/src/sdk/session.ts +88 -9
- package/src/session/agent-session.ts +822 -172
- package/src/session/artifacts.ts +22 -0
- package/src/session/auth-broker-config.ts +10 -3
- package/src/session/blob-store.ts +915 -24
- package/src/session/internal/managed-session-scope.ts +44 -6
- package/src/session/internal/managed-session-storage.ts +48 -6
- package/src/session/session-manager.ts +1614 -435
- package/src/session/session-storage.ts +9 -1
- package/src/session/streaming-output.ts +238 -68
- package/src/setup/credential-import.ts +75 -13
- package/src/skill-state/active-state.ts +32 -0
- package/src/skill-state/workflow-hud.ts +17 -0
- package/src/skill-state/workflow-mutation-guard.ts +496 -2
- package/src/task/executor.ts +122 -39
- package/src/task/index.ts +23 -14
- package/src/task/provider-retry-status.ts +16 -0
- package/src/task/receipt.ts +23 -7
- package/src/task/render.ts +6 -2
- package/src/task/types.ts +108 -0
- package/src/tools/ask-answer-registry.ts +12 -6
- package/src/tools/ask.ts +86 -55
- package/src/tools/ast-edit.ts +1 -0
- package/src/tools/ast-grep.ts +1 -0
- package/src/tools/bash-interactive.ts +8 -3
- package/src/tools/bash.ts +503 -60
- package/src/tools/find.ts +1 -0
- package/src/tools/image-gen.ts +29 -5
- package/src/tools/index.ts +4 -0
- package/src/tools/output-meta.ts +108 -14
- package/src/tools/path-utils.ts +3 -2
- package/src/tools/read.ts +18 -0
- package/src/tools/review.ts +5 -7
- package/src/tools/search.ts +1 -0
- package/src/tools/subagent-render.ts +6 -2
- package/src/tools/subagent.ts +6 -0
- package/src/tools/telegram-send.ts +6 -3
- package/src/utils/gh.ts +39 -0
- package/src/utils/image-resize.ts +3 -0
- package/src/web/search/providers/anthropic.ts +4 -4
- package/src/web/search/providers/kimi.ts +15 -5
- package/src/web/search/providers/searxng.ts +47 -4
- package/src/web/search/providers/xai.ts +7 -2
- package/dist/types/gjc-runtime/ultragoal-pipeline.d.ts +0 -23
- package/src/defaults/gjc/skills/ultragoal/pipeline-validation-contracts.md +0 -30
- package/src/gjc-runtime/ultragoal-pipeline.ts +0 -306
|
@@ -17,8 +17,10 @@ import type {
|
|
|
17
17
|
import * as native from "@gajae-code/natives";
|
|
18
18
|
import { getTerminalId } from "@gajae-code/tui";
|
|
19
19
|
import {
|
|
20
|
+
getAgentDir,
|
|
20
21
|
getBlobsDir,
|
|
21
22
|
getProjectDir,
|
|
23
|
+
getResidentCacheRootDir,
|
|
22
24
|
getSessionsDir,
|
|
23
25
|
getTerminalSessionsDir,
|
|
24
26
|
hasFsCode,
|
|
@@ -47,8 +49,10 @@ import {
|
|
|
47
49
|
isBlobRef,
|
|
48
50
|
isImageDataUrl,
|
|
49
51
|
MemoryBlobStore,
|
|
52
|
+
openVerifiedResidentCacheInstanceDir,
|
|
50
53
|
parseBlobRef,
|
|
51
54
|
ResidentBlobMissingError,
|
|
55
|
+
ResidentCacheTrustError,
|
|
52
56
|
resolveResidentImageDataSync,
|
|
53
57
|
resolveResidentImageDataUrlSync,
|
|
54
58
|
resolveTextBlobSync,
|
|
@@ -70,6 +74,7 @@ import {
|
|
|
70
74
|
} from "./internal/managed-session-scope";
|
|
71
75
|
import {
|
|
72
76
|
assertManagedDirectoryRoot,
|
|
77
|
+
captureManagedFileNoFollow,
|
|
73
78
|
fsyncManagedArtifactTree,
|
|
74
79
|
type ManagedDirectoryRoot,
|
|
75
80
|
type ManagedFileSnapshot,
|
|
@@ -78,6 +83,7 @@ import {
|
|
|
78
83
|
mayCleanManagedTreeStaging,
|
|
79
84
|
retainManagedDirectoryAuthority,
|
|
80
85
|
} from "./internal/managed-session-storage";
|
|
86
|
+
|
|
81
87
|
import { classifyNativePublishOutcome, formatNativePublishDiagnostic } from "./internal/native-publish-outcome";
|
|
82
88
|
import {
|
|
83
89
|
hasOnlyKeys as hasOnlyMemoryGuardKeys,
|
|
@@ -181,6 +187,107 @@ interface PreparedNewSessionState extends PreparedNewSession {
|
|
|
181
187
|
persistenceTempPath?: string;
|
|
182
188
|
}
|
|
183
189
|
|
|
190
|
+
type ResidentTransitionFailurePolicy = "install-staged" | "memory-fallback" | "retain-and-throw" | "memory-only";
|
|
191
|
+
type ResidentBlobMissingPolicy = "throw" | "placeholder";
|
|
192
|
+
|
|
193
|
+
type ResidentTransitionSource =
|
|
194
|
+
| {
|
|
195
|
+
mode: "materialize";
|
|
196
|
+
sourceEntries: FileEntry[];
|
|
197
|
+
sourceStores: {
|
|
198
|
+
textStore: BlobStore | null;
|
|
199
|
+
imageStore: BlobStore;
|
|
200
|
+
textFallback?: (hash: string) => Buffer | null;
|
|
201
|
+
};
|
|
202
|
+
missingPolicy?: ResidentBlobMissingPolicy;
|
|
203
|
+
}
|
|
204
|
+
| {
|
|
205
|
+
mode: "adopt-staged";
|
|
206
|
+
stagedEntries: FileEntry[];
|
|
207
|
+
stagedStore: BlobStore;
|
|
208
|
+
stagedIndex?: PreparedSessionIndex;
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
interface ResidentTransitionInput {
|
|
212
|
+
readonly target: { sessionId: string; sessionFile: string };
|
|
213
|
+
readonly primary: ResidentTransitionSource;
|
|
214
|
+
/** A read-only load may demote an unavailable disposable resident cache to memory. */
|
|
215
|
+
readonly allowUnwritableResidentCacheFallback?: boolean;
|
|
216
|
+
|
|
217
|
+
readonly fallback?: Extract<ResidentTransitionSource, { mode: "adopt-staged" }>;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
class PreparedResidentStoreTransition {
|
|
221
|
+
#active = true;
|
|
222
|
+
|
|
223
|
+
constructor(
|
|
224
|
+
readonly entries: FileEntry[],
|
|
225
|
+
readonly store: BlobStore,
|
|
226
|
+
readonly index: PreparedSessionIndex,
|
|
227
|
+
private readonly ownsStore: boolean,
|
|
228
|
+
) {}
|
|
229
|
+
|
|
230
|
+
dispose(): void {
|
|
231
|
+
if (!this.#active) return;
|
|
232
|
+
this.#active = false;
|
|
233
|
+
if (!this.ownsStore || !(this.store instanceof EphemeralBlobStore)) return;
|
|
234
|
+
try {
|
|
235
|
+
this.store.dispose();
|
|
236
|
+
} catch (error) {
|
|
237
|
+
logger.warn("Failed to dispose an uncommitted resident cache candidate", {
|
|
238
|
+
error: toError(error).message,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
adopt(): void {
|
|
244
|
+
this.#active = false;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
interface FreshSessionState {
|
|
249
|
+
readonly sessionId: string;
|
|
250
|
+
readonly sessionFile: string | undefined;
|
|
251
|
+
readonly header: SessionHeader;
|
|
252
|
+
readonly adoptsLifecycleId: boolean;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
type ResidentCacheDegradedStore = BlobStore & { degradedReason?: string };
|
|
256
|
+
|
|
257
|
+
const RESIDENT_CACHE_WRITE_FAILURE_REASONS = new Set([
|
|
258
|
+
"root_create_failed",
|
|
259
|
+
"instance_create_failed",
|
|
260
|
+
"owner_write_failed",
|
|
261
|
+
"owner_close_failed",
|
|
262
|
+
"blob_create_failed",
|
|
263
|
+
"blob_write_failed",
|
|
264
|
+
"blob_close_failed",
|
|
265
|
+
]);
|
|
266
|
+
const RESIDENT_CACHE_UNWRITABLE_CODES = ["EACCES", "EPERM", "EROFS"] as const;
|
|
267
|
+
|
|
268
|
+
function hasUnwritableResidentCacheFsCause(error: unknown): boolean {
|
|
269
|
+
const seen = new Set<unknown>();
|
|
270
|
+
let current: unknown = error;
|
|
271
|
+
while (current !== undefined && !seen.has(current)) {
|
|
272
|
+
seen.add(current);
|
|
273
|
+
if (RESIDENT_CACHE_UNWRITABLE_CODES.some(code => hasFsCode(current, code))) return true;
|
|
274
|
+
if (typeof current !== "object" || current === null || !("cause" in current)) return false;
|
|
275
|
+
current = current.cause;
|
|
276
|
+
}
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function isResidentCacheProvisioningFailure(error: unknown): boolean {
|
|
281
|
+
return error instanceof ResidentCacheTrustError || hasUnwritableResidentCacheFsCause(error);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function isUnwritableResidentCacheFailure(error: unknown): boolean {
|
|
285
|
+
return (
|
|
286
|
+
hasUnwritableResidentCacheFsCause(error) &&
|
|
287
|
+
(!(error instanceof ResidentCacheTrustError) || RESIDENT_CACHE_WRITE_FAILURE_REASONS.has(error.reason))
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
184
291
|
export interface SessionEntryBase {
|
|
185
292
|
type: string;
|
|
186
293
|
id: string;
|
|
@@ -222,11 +329,16 @@ export interface SessionManagerObservabilityStats {
|
|
|
222
329
|
coldSpillReadCount: number;
|
|
223
330
|
residentTextReadCount: number;
|
|
224
331
|
residentImageReadCount: number;
|
|
332
|
+
residentCacheAdoptFallbackCount: number;
|
|
333
|
+
residentCacheTrustRejectCount: number;
|
|
334
|
+
residentCacheWin32FallbackCount: number;
|
|
335
|
+
residentCacheDegradedReason?: string;
|
|
225
336
|
publicMaterializerCallCount: number;
|
|
226
337
|
getEntryMaterializerCallCount: number;
|
|
227
338
|
getBranchMaterializerCallCount: number;
|
|
228
339
|
getEntriesMaterializerCallCount: number;
|
|
229
340
|
materializedEntriesCachePopulateCount: number;
|
|
341
|
+
materializedCacheDemotedCount: number;
|
|
230
342
|
pathOnlyContextBuildCount: number;
|
|
231
343
|
}
|
|
232
344
|
|
|
@@ -1615,10 +1727,17 @@ function managedScopeStartupError(
|
|
|
1615
1727
|
action: "resolve" | "prepare",
|
|
1616
1728
|
failure: Extract<ReturnType<typeof resolveManagedScopeForWrite>, { kind: "error" }>,
|
|
1617
1729
|
): Error {
|
|
1618
|
-
|
|
1730
|
+
const classification = failure.cause?.classification ?? failure.code;
|
|
1731
|
+
const diagnostic = failure.cause?.diagnostic;
|
|
1732
|
+
const detail = diagnostic === undefined ? classification : `${classification}: ${diagnostic}`;
|
|
1733
|
+
const message =
|
|
1734
|
+
action === "prepare"
|
|
1735
|
+
? `Could not prepare managed session scope (${detail}).`
|
|
1736
|
+
: "Could not resolve managed session scope.";
|
|
1737
|
+
return new Error(message, {
|
|
1619
1738
|
cause: {
|
|
1620
|
-
classification
|
|
1621
|
-
...(
|
|
1739
|
+
classification,
|
|
1740
|
+
...(diagnostic === undefined ? {} : { diagnostic }),
|
|
1622
1741
|
},
|
|
1623
1742
|
});
|
|
1624
1743
|
}
|
|
@@ -1646,11 +1765,54 @@ export type SessionDestination =
|
|
|
1646
1765
|
| { readonly kind: "explicit"; readonly directory: string };
|
|
1647
1766
|
export type SessionDestinationInput = string | SessionDestination | undefined;
|
|
1648
1767
|
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1768
|
+
type ForkArtifactPublication =
|
|
1769
|
+
| {
|
|
1770
|
+
readonly kind: "managed";
|
|
1771
|
+
readonly snapshot: native.NativeDirectoryTreeSnapshot;
|
|
1772
|
+
readonly store: ManagedSessionDescendantStore;
|
|
1773
|
+
readonly cleanupStore: ManagedSessionDescendantStore;
|
|
1774
|
+
readonly cleanupRelativePath: string;
|
|
1775
|
+
}
|
|
1776
|
+
| {
|
|
1777
|
+
readonly kind: "explicit";
|
|
1778
|
+
readonly artifactsDir: string;
|
|
1779
|
+
readonly snapshot: native.NativeDirectoryTreeSnapshot;
|
|
1780
|
+
};
|
|
1781
|
+
|
|
1782
|
+
type ForkTranscriptPublication =
|
|
1783
|
+
| {
|
|
1784
|
+
readonly kind: "managed";
|
|
1785
|
+
readonly store: ManagedSessionDescendantStore;
|
|
1786
|
+
readonly relativePath: string;
|
|
1787
|
+
/** Bytes successfully installed by no-replace publication. */
|
|
1788
|
+
readonly publishedBytes: Buffer;
|
|
1789
|
+
/** Absent only while post-publication identity capture is failing. */
|
|
1790
|
+
readonly snapshot?: ManagedFileSnapshot;
|
|
1791
|
+
}
|
|
1792
|
+
| {
|
|
1793
|
+
readonly kind: "explicit-file";
|
|
1794
|
+
readonly sessionFile: string;
|
|
1795
|
+
/** Bytes successfully installed by no-replace publication. */
|
|
1796
|
+
readonly publishedBytes: Buffer;
|
|
1797
|
+
/** Absent only while post-publication identity capture is failing. */
|
|
1798
|
+
readonly snapshot?: ManagedFileSnapshot;
|
|
1799
|
+
}
|
|
1800
|
+
| {
|
|
1801
|
+
readonly kind: "explicit-storage";
|
|
1802
|
+
readonly sessionFile: string;
|
|
1803
|
+
/** Bytes successfully installed by transcript publication. */
|
|
1804
|
+
readonly publishedBytes: Buffer;
|
|
1805
|
+
/** Absent only while post-publication identity capture is failing. */
|
|
1806
|
+
readonly snapshot?: SessionStorageSnapshot;
|
|
1807
|
+
};
|
|
1808
|
+
|
|
1809
|
+
function pruneResidentCacheEntries(snapshot: native.NativeDirectoryTreeSnapshot): native.NativeDirectoryTreeSnapshot {
|
|
1810
|
+
return {
|
|
1811
|
+
...snapshot,
|
|
1812
|
+
entries: snapshot.entries.filter(
|
|
1813
|
+
entry => entry.relativePath !== "resident-cache" && !entry.relativePath.startsWith("resident-cache/"),
|
|
1814
|
+
),
|
|
1815
|
+
};
|
|
1654
1816
|
}
|
|
1655
1817
|
|
|
1656
1818
|
function retainedTreeSnapshotEquals(
|
|
@@ -1703,6 +1865,51 @@ function retainedTreeSnapshotEqualsAfterRename(
|
|
|
1703
1865
|
);
|
|
1704
1866
|
}
|
|
1705
1867
|
|
|
1868
|
+
/**
|
|
1869
|
+
* True when a cleanup error only reports the authorized POSIX quarantine.
|
|
1870
|
+
*
|
|
1871
|
+
* `exact_remove_directory_tree` and `removeManagedTree` cannot bind the final
|
|
1872
|
+
* unlink to the verified root descriptor on POSIX, so they detach the tree to a
|
|
1873
|
+
* no-replace `<name>.removing` name and report `cleanup_pending`. No live
|
|
1874
|
+
* artifact survives that outcome, so it is a SUCCESSFUL cleanup and must never
|
|
1875
|
+
* supersede the primary failure that triggered it.
|
|
1876
|
+
*/
|
|
1877
|
+
function isAuthorizedPendingCleanup(cleanupError: Error): boolean {
|
|
1878
|
+
return cleanupError.message === "cleanup_pending";
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
/**
|
|
1882
|
+
* Remove a fork-staging root this process created, re-proving ownership first.
|
|
1883
|
+
* Returns undefined when the root is gone or authorized-pending; otherwise returns
|
|
1884
|
+
* a stable cleanup code the caller wraps with the original failure as `cause`.
|
|
1885
|
+
*/
|
|
1886
|
+
function removeOwnedForkStaging(stagingDir: string, ownedRoot: native.NativeDirectoryTreeSnapshot): string | undefined {
|
|
1887
|
+
let current: native.NativeDirectoryTreeResult;
|
|
1888
|
+
try {
|
|
1889
|
+
current = native.snapshotDirectoryTree(stagingDir);
|
|
1890
|
+
} catch (snapshotError) {
|
|
1891
|
+
return `staging_snapshot_threw:${toError(snapshotError).message}`;
|
|
1892
|
+
}
|
|
1893
|
+
if (!current.ok || !current.snapshot) {
|
|
1894
|
+
// Only VERIFIED ABSENCE means there is nothing to clean.
|
|
1895
|
+
return current.code === "not_found" ? undefined : (current.code ?? "staging_snapshot_failed");
|
|
1896
|
+
}
|
|
1897
|
+
// Rule R: identity, not pathname, authorizes the delete. Entries may be enumerated
|
|
1898
|
+
// now because they live inside a root we created; the ROOT must be the one we made.
|
|
1899
|
+
if (current.snapshot.rootDev !== ownedRoot.rootDev || current.snapshot.rootIno !== ownedRoot.rootIno)
|
|
1900
|
+
return "staging_identity_mismatch";
|
|
1901
|
+
const removed = native.exactRemoveDirectoryTree(stagingDir, current.snapshot);
|
|
1902
|
+
if (removed.ok) return undefined;
|
|
1903
|
+
if (removed.code === "not_found") return undefined;
|
|
1904
|
+
const cleanupPending =
|
|
1905
|
+
removed.code === "cleanup_pending" &&
|
|
1906
|
+
(removed.detachedPath ??
|
|
1907
|
+
removed.retainedSuccessorPath ??
|
|
1908
|
+
removed.retainedPlaceholderPath ??
|
|
1909
|
+
removed.retainedUnknownPath) !== undefined;
|
|
1910
|
+
return cleanupPending ? undefined : (removed.code ?? "staging_remove_failed");
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1706
1913
|
function managedFileSnapshotEquals(left: ManagedFileSnapshot | null, right: ManagedFileSnapshot | null): boolean {
|
|
1707
1914
|
return (
|
|
1708
1915
|
left !== null &&
|
|
@@ -1717,6 +1924,7 @@ function managedFileSnapshotEquals(left: ManagedFileSnapshot | null, right: Mana
|
|
|
1717
1924
|
);
|
|
1718
1925
|
}
|
|
1719
1926
|
const trustedSessionDestinations = new WeakSet<SessionDestination>();
|
|
1927
|
+
const explicitProfileAgentDirs = new WeakMap<SessionDestination, string>();
|
|
1720
1928
|
const managedSecurityPolicies = new WeakMap<ManagedSessionSecurityContext, ManagedSessionSecurityPolicy>();
|
|
1721
1929
|
|
|
1722
1930
|
function managedSecurityPolicyForContext(securityContext: ManagedSessionSecurityContext): ManagedSessionSecurityPolicy {
|
|
@@ -1729,20 +1937,26 @@ function managedStoreFromContext(
|
|
|
1729
1937
|
securityContext: ManagedSessionSecurityContext,
|
|
1730
1938
|
directory: string,
|
|
1731
1939
|
): ManagedSessionDescendantStore {
|
|
1940
|
+
// A retained fd authority is bound to the exact directory it was retained for.
|
|
1941
|
+
// For any other directory the store must re-derive its own authority from the
|
|
1942
|
+
// shared root instead of inheriting a binding that describes a different inode.
|
|
1943
|
+
const ownsRetainedAuthority = path.resolve(directory) === path.resolve(securityContext.sessionDir);
|
|
1732
1944
|
return new ManagedSessionDescendantStore(
|
|
1733
1945
|
securityContext.rootAuthority,
|
|
1734
1946
|
directory,
|
|
1735
|
-
securityContext.retainedAuthority
|
|
1947
|
+
securityContext.retainedAuthority && ownsRetainedAuthority
|
|
1736
1948
|
? { authority: securityContext.retainedAuthority, authorityBaseDir: directory }
|
|
1737
1949
|
: undefined,
|
|
1738
1950
|
managedSecurityPolicyForContext(securityContext),
|
|
1951
|
+
securityContext.profileAgentDir,
|
|
1739
1952
|
);
|
|
1740
1953
|
}
|
|
1741
1954
|
|
|
1742
|
-
function freezeManagedDestination(scope: ManagedScope): SessionDestination {
|
|
1955
|
+
function freezeManagedDestination(scope: ManagedScope, profileAgentDir: string): SessionDestination {
|
|
1743
1956
|
const rootAuthority = managedRootForScope(scope);
|
|
1744
1957
|
const securityContext = createManagedSessionSecurityContext({
|
|
1745
1958
|
agentDir: scope.agentDir,
|
|
1959
|
+
profileAgentDir,
|
|
1746
1960
|
sessionsRoot: scope.sessionsRoot,
|
|
1747
1961
|
sessionDir: scope.directoryPath,
|
|
1748
1962
|
rootAuthority,
|
|
@@ -1760,6 +1974,7 @@ function freezeManagedDestination(scope: ManagedScope): SessionDestination {
|
|
|
1760
1974
|
function explicitDestination(directory: string): SessionDestination {
|
|
1761
1975
|
const destination = Object.freeze({ kind: "explicit" as const, directory });
|
|
1762
1976
|
trustedSessionDestinations.add(destination);
|
|
1977
|
+
explicitProfileAgentDirs.set(destination, getAgentDir());
|
|
1763
1978
|
return destination;
|
|
1764
1979
|
}
|
|
1765
1980
|
|
|
@@ -1777,7 +1992,7 @@ function managedDestination(cwd: string, storage: SessionStorage, agentDir?: str
|
|
|
1777
1992
|
process.platform === "win32" ? "windows-existing-verify-first" : "default",
|
|
1778
1993
|
);
|
|
1779
1994
|
if (prepared.kind === "error") throw managedScopeStartupError("prepare", prepared);
|
|
1780
|
-
return freezeManagedDestination(prepared.scope);
|
|
1995
|
+
return freezeManagedDestination(prepared.scope, agentDir ?? getAgentDir());
|
|
1781
1996
|
}
|
|
1782
1997
|
|
|
1783
1998
|
function destinationFor(
|
|
@@ -1951,6 +2166,10 @@ function sameResumeStat(left: SessionStorageStat, right: SessionStorageStat): bo
|
|
|
1951
2166
|
);
|
|
1952
2167
|
}
|
|
1953
2168
|
|
|
2169
|
+
function sameSessionStorageSnapshot(left: SessionStorageSnapshot, right: SessionStorageSnapshot): boolean {
|
|
2170
|
+
return sameResumeStat(left.stat, right.stat) && Buffer.from(left.bytes).equals(Buffer.from(right.bytes));
|
|
2171
|
+
}
|
|
2172
|
+
|
|
1954
2173
|
interface ResumeInspectionSnapshot {
|
|
1955
2174
|
identity: ResumeSessionIdentity;
|
|
1956
2175
|
content: Uint8Array;
|
|
@@ -2680,6 +2899,48 @@ function collectResidentImageRefs(value: unknown, refs: Set<string>, seen = new
|
|
|
2680
2899
|
for (const child of Object.values(value)) collectResidentImageRefs(child, refs, seen);
|
|
2681
2900
|
}
|
|
2682
2901
|
|
|
2902
|
+
function collectResidentTextBlobHashes(value: unknown, hashes: Set<string>, seen = new WeakSet<object>()): void {
|
|
2903
|
+
if (value === null || value === undefined || typeof value !== "object") return;
|
|
2904
|
+
if (isResidentBlobSentinel(value)) {
|
|
2905
|
+
if (value.kind === "text") {
|
|
2906
|
+
const hash = parseBlobRef(value.ref);
|
|
2907
|
+
if (hash) hashes.add(hash);
|
|
2908
|
+
}
|
|
2909
|
+
return;
|
|
2910
|
+
}
|
|
2911
|
+
if (seen.has(value)) return;
|
|
2912
|
+
seen.add(value);
|
|
2913
|
+
if (Array.isArray(value)) {
|
|
2914
|
+
for (const item of value) collectResidentTextBlobHashes(item, hashes, seen);
|
|
2915
|
+
return;
|
|
2916
|
+
}
|
|
2917
|
+
for (const child of Object.values(value)) collectResidentTextBlobHashes(child, hashes, seen);
|
|
2918
|
+
}
|
|
2919
|
+
|
|
2920
|
+
function recoverPersistedResidentTextBuffers(
|
|
2921
|
+
value: unknown,
|
|
2922
|
+
requested: ReadonlySet<string>,
|
|
2923
|
+
recovered: Map<string, Buffer>,
|
|
2924
|
+
key?: string,
|
|
2925
|
+
seen = new WeakSet<object>(),
|
|
2926
|
+
): void {
|
|
2927
|
+
if (typeof value === "string") {
|
|
2928
|
+
if (value.length < BLOB_EXTERNALIZE_THRESHOLD || !shouldExternalizeResidentString(key)) return;
|
|
2929
|
+
const hash = crypto.createHash("sha256").update(value, "utf8").digest("hex");
|
|
2930
|
+
if (requested.has(hash)) recovered.set(hash, Buffer.from(value, "utf8"));
|
|
2931
|
+
return;
|
|
2932
|
+
}
|
|
2933
|
+
if (value === null || value === undefined || typeof value !== "object" || seen.has(value)) return;
|
|
2934
|
+
seen.add(value);
|
|
2935
|
+
if (Array.isArray(value)) {
|
|
2936
|
+
for (const item of value) recoverPersistedResidentTextBuffers(item, requested, recovered, key, seen);
|
|
2937
|
+
return;
|
|
2938
|
+
}
|
|
2939
|
+
for (const [childKey, child] of Object.entries(value)) {
|
|
2940
|
+
recoverPersistedResidentTextBuffers(child, requested, recovered, childKey, seen);
|
|
2941
|
+
}
|
|
2942
|
+
}
|
|
2943
|
+
|
|
2683
2944
|
/**
|
|
2684
2945
|
* Recursively truncate large strings in an object for session persistence.
|
|
2685
2946
|
* - Truncates any oversized string fields (key-agnostic)
|
|
@@ -2776,13 +3037,13 @@ function shouldExternalizeResidentString(key: string | undefined): boolean {
|
|
|
2776
3037
|
interface ResidentBlobStores {
|
|
2777
3038
|
textStore: BlobStore;
|
|
2778
3039
|
imageStore: BlobStore;
|
|
3040
|
+
/** Trusted alternate bytes used only while demoting an invalidated resident store. */
|
|
3041
|
+
textFallback?: (hash: string) => Buffer | null;
|
|
2779
3042
|
sessionId?: string;
|
|
2780
3043
|
sessionFile?: string;
|
|
2781
3044
|
onResidentBlobRead?: (kind: ResidentBlobKind) => void;
|
|
2782
3045
|
}
|
|
2783
3046
|
|
|
2784
|
-
type ResidentBlobMissingPolicy = "throw" | "placeholder";
|
|
2785
|
-
|
|
2786
3047
|
function residentBlobMissingPlaceholder(error: ResidentBlobMissingError): string {
|
|
2787
3048
|
return `[Session resident ${error.kind} blob missing: sha256:${error.hash}; original content unavailable]`;
|
|
2788
3049
|
}
|
|
@@ -2793,7 +3054,7 @@ function externalizeResidentValueSync(obj: unknown, stores: ResidentBlobStores,
|
|
|
2793
3054
|
if (key === "image_url" && isImageDataUrl(obj) && obj.length >= BLOB_EXTERNALIZE_THRESHOLD)
|
|
2794
3055
|
return residentBlobSentinel("imageUrl", externalizeImageDataUrlSync(stores.imageStore, obj));
|
|
2795
3056
|
if (shouldExternalizeResidentString(key) && obj.length >= BLOB_EXTERNALIZE_THRESHOLD)
|
|
2796
|
-
return residentBlobSentinel("text", stores.textStore.
|
|
3057
|
+
return residentBlobSentinel("text", stores.textStore.putOwnedSync(Buffer.from(obj, "utf8")).ref);
|
|
2797
3058
|
return obj;
|
|
2798
3059
|
}
|
|
2799
3060
|
if (Array.isArray(obj)) {
|
|
@@ -2852,12 +3113,16 @@ function materializeResidentValueSync(
|
|
|
2852
3113
|
if (cached !== undefined) return cached;
|
|
2853
3114
|
let resolved: string;
|
|
2854
3115
|
try {
|
|
2855
|
-
|
|
2856
|
-
obj.
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
|
|
3116
|
+
if (obj.kind === "text") {
|
|
3117
|
+
const hash = parseBlobRef(obj.ref);
|
|
3118
|
+
const buffered = hash ? stores.textFallback?.(hash) : null;
|
|
3119
|
+
resolved = buffered ? buffered.toString("utf8") : resolveTextBlobSync(stores.textStore, obj.ref, stores);
|
|
3120
|
+
} else {
|
|
3121
|
+
resolved =
|
|
3122
|
+
obj.kind === "imageUrl"
|
|
3123
|
+
? resolveResidentImageDataUrlSync(stores.imageStore, obj.ref, stores)
|
|
3124
|
+
: resolveResidentImageDataSync(stores.imageStore, obj.ref, stores);
|
|
3125
|
+
}
|
|
2861
3126
|
} catch (err) {
|
|
2862
3127
|
if (missingPolicy === "placeholder" && err instanceof ResidentBlobMissingError) {
|
|
2863
3128
|
resolved = residentBlobMissingPlaceholder(err);
|
|
@@ -2865,6 +3130,7 @@ function materializeResidentValueSync(
|
|
|
2865
3130
|
throw err;
|
|
2866
3131
|
}
|
|
2867
3132
|
}
|
|
3133
|
+
|
|
2868
3134
|
cache.set(cacheKey, resolved);
|
|
2869
3135
|
stores.onResidentBlobRead?.(obj.kind);
|
|
2870
3136
|
return resolved;
|
|
@@ -2908,6 +3174,35 @@ function materializeResidentEntriesSync<T extends FileEntry | SessionEntry>(
|
|
|
2908
3174
|
return entries.map(entry => materializeResidentEntrySync(entry, stores, cache, missingPolicy));
|
|
2909
3175
|
}
|
|
2910
3176
|
|
|
3177
|
+
/** Verify staged references without materializing their full strings before commit. */
|
|
3178
|
+
function assertResidentReferencesResolvableSync(entries: readonly FileEntry[], stores: ResidentBlobStores): void {
|
|
3179
|
+
const seen = new WeakSet<object>();
|
|
3180
|
+
const resolved = new Set<string>();
|
|
3181
|
+
const visit = (value: unknown): void => {
|
|
3182
|
+
if (value === null || value === undefined || typeof value !== "object") return;
|
|
3183
|
+
if (isResidentBlobSentinel(value)) {
|
|
3184
|
+
const key = `${value.kind}:${value.ref}`;
|
|
3185
|
+
if (resolved.has(key)) return;
|
|
3186
|
+
resolved.add(key);
|
|
3187
|
+
const hash = parseBlobRef(value.ref);
|
|
3188
|
+
if (hash === null) throw new Error("Staged resident entry has an invalid blob reference.");
|
|
3189
|
+
const store = value.kind === "text" ? stores.textStore : stores.imageStore;
|
|
3190
|
+
if (store.getSync(hash) === null) {
|
|
3191
|
+
throw new ResidentBlobMissingError(hash, value.kind, stores.sessionId, stores.sessionFile);
|
|
3192
|
+
}
|
|
3193
|
+
return;
|
|
3194
|
+
}
|
|
3195
|
+
if (ArrayBuffer.isView(value) || seen.has(value)) return;
|
|
3196
|
+
seen.add(value);
|
|
3197
|
+
if (Array.isArray(value)) {
|
|
3198
|
+
for (const item of value) visit(item);
|
|
3199
|
+
return;
|
|
3200
|
+
}
|
|
3201
|
+
for (const item of Object.values(value)) visit(item);
|
|
3202
|
+
};
|
|
3203
|
+
for (const entry of entries) visit(entry);
|
|
3204
|
+
}
|
|
3205
|
+
|
|
2911
3206
|
function materializeResidentEntryForReadSync<T extends FileEntry | SessionEntry>(
|
|
2912
3207
|
entry: T,
|
|
2913
3208
|
stores: ResidentBlobStores,
|
|
@@ -2916,6 +3211,63 @@ function materializeResidentEntryForReadSync<T extends FileEntry | SessionEntry>
|
|
|
2916
3211
|
return materializeResidentEntrySync(entry, stores, cache, "placeholder");
|
|
2917
3212
|
}
|
|
2918
3213
|
|
|
3214
|
+
/**
|
|
3215
|
+
* Approximate the UTF-8 footprint of a JSON-like snapshot without allocating a
|
|
3216
|
+
* serialized copy. The cap check can stop as soon as it is known to exceed it.
|
|
3217
|
+
*/
|
|
3218
|
+
function jsonLikeValueExceedsCacheLimit(value: unknown, limit: number): boolean {
|
|
3219
|
+
let bytes = 0;
|
|
3220
|
+
const seen = new WeakSet<object>();
|
|
3221
|
+
const add = (amount: number): void => {
|
|
3222
|
+
bytes += amount;
|
|
3223
|
+
};
|
|
3224
|
+
const visit = (value: unknown): void => {
|
|
3225
|
+
if (bytes > limit || value === null || value === undefined) return;
|
|
3226
|
+
if (typeof value === "string") {
|
|
3227
|
+
add(Buffer.byteLength(value, "utf8"));
|
|
3228
|
+
return;
|
|
3229
|
+
}
|
|
3230
|
+
if (typeof value === "number" || typeof value === "bigint") {
|
|
3231
|
+
add(8);
|
|
3232
|
+
return;
|
|
3233
|
+
}
|
|
3234
|
+
if (typeof value === "boolean") {
|
|
3235
|
+
add(4);
|
|
3236
|
+
return;
|
|
3237
|
+
}
|
|
3238
|
+
if (typeof value !== "object" || seen.has(value)) return;
|
|
3239
|
+
seen.add(value);
|
|
3240
|
+
if (ArrayBuffer.isView(value)) {
|
|
3241
|
+
add(value.byteLength);
|
|
3242
|
+
return;
|
|
3243
|
+
}
|
|
3244
|
+
if (value instanceof ArrayBuffer) {
|
|
3245
|
+
add(value.byteLength);
|
|
3246
|
+
return;
|
|
3247
|
+
}
|
|
3248
|
+
if (Array.isArray(value)) {
|
|
3249
|
+
add(8);
|
|
3250
|
+
for (const item of value) visit(item);
|
|
3251
|
+
return;
|
|
3252
|
+
}
|
|
3253
|
+
add(16);
|
|
3254
|
+
for (const [key, child] of Object.entries(value)) {
|
|
3255
|
+
add(Buffer.byteLength(key, "utf8"));
|
|
3256
|
+
visit(child);
|
|
3257
|
+
}
|
|
3258
|
+
};
|
|
3259
|
+
visit(value);
|
|
3260
|
+
return bytes > limit;
|
|
3261
|
+
}
|
|
3262
|
+
|
|
3263
|
+
type MaterializedCacheReference<T extends object> = T | WeakRef<T>;
|
|
3264
|
+
|
|
3265
|
+
function dereferenceMaterializedCache<T extends object>(
|
|
3266
|
+
cache: MaterializedCacheReference<T> | undefined,
|
|
3267
|
+
): T | undefined {
|
|
3268
|
+
return cache instanceof WeakRef ? cache.deref() : cache;
|
|
3269
|
+
}
|
|
3270
|
+
|
|
2919
3271
|
function materializeResidentEntriesForReadSync<T extends FileEntry | SessionEntry>(
|
|
2920
3272
|
entries: T[],
|
|
2921
3273
|
stores: ResidentBlobStores,
|
|
@@ -3962,7 +4314,6 @@ const SESSION_NAME_MAX_CHARS = 1_000;
|
|
|
3962
4314
|
const SESSION_LIST_PARALLEL_THRESHOLD = 64;
|
|
3963
4315
|
const SESSION_LIST_MAX_WORKERS = 16;
|
|
3964
4316
|
const sessionListPrefixDecoder = new TextDecoder("utf-8", { fatal: false });
|
|
3965
|
-
let residentCacheInstanceCounter = 0;
|
|
3966
4317
|
|
|
3967
4318
|
async function readSessionListPrefix(file: string, storage: SessionStorage, buffer: Buffer): Promise<string> {
|
|
3968
4319
|
if (!(storage instanceof FileSessionStorage)) {
|
|
@@ -4342,6 +4693,34 @@ interface SessionManagerStateSnapshot {
|
|
|
4342
4693
|
materializedFileEntries: FileEntry[];
|
|
4343
4694
|
}
|
|
4344
4695
|
|
|
4696
|
+
/** Benchmark-derived cap for strong materialized session snapshots. */
|
|
4697
|
+
export const MATERIALIZED_CACHE_MAX_BYTES = 32 * 1024 * 1024;
|
|
4698
|
+
|
|
4699
|
+
/** Test-only cache retention and transition seams; these are intentionally not user settings. */
|
|
4700
|
+
export const SessionManagerTestHooks: {
|
|
4701
|
+
materializedCacheMaxBytesOverride?: number;
|
|
4702
|
+
beforeResidentTransitionIndexBuild?: () => void;
|
|
4703
|
+
} = {};
|
|
4704
|
+
|
|
4705
|
+
function materializedCacheMaxBytes(): number {
|
|
4706
|
+
const override = SessionManagerTestHooks.materializedCacheMaxBytesOverride;
|
|
4707
|
+
if (override === undefined) return MATERIALIZED_CACHE_MAX_BYTES;
|
|
4708
|
+
if (!Number.isSafeInteger(override) || override < 0)
|
|
4709
|
+
throw new RangeError("materializedCacheMaxBytesOverride must be a non-negative safe integer.");
|
|
4710
|
+
return override;
|
|
4711
|
+
}
|
|
4712
|
+
|
|
4713
|
+
type ManagedDestinationTransition = {
|
|
4714
|
+
readonly directory: string;
|
|
4715
|
+
readonly destination: SessionDestination;
|
|
4716
|
+
readonly store: ManagedSessionDescendantStore;
|
|
4717
|
+
adopt(): void;
|
|
4718
|
+
/** Release the superseded authority once rollback is no longer possible. */
|
|
4719
|
+
settle(): void;
|
|
4720
|
+
rollback(): void;
|
|
4721
|
+
dispose(): void;
|
|
4722
|
+
};
|
|
4723
|
+
|
|
4345
4724
|
export class SessionManager {
|
|
4346
4725
|
#sessionId: string = "";
|
|
4347
4726
|
/** True once a lifecycle pre-allocated id has been adopted (consume-once). */
|
|
@@ -4380,6 +4759,7 @@ export class SessionManager {
|
|
|
4380
4759
|
#artifactManager: ArtifactManager | null = null;
|
|
4381
4760
|
#artifactManagerSessionFile: string | null = null;
|
|
4382
4761
|
#managedTranscriptStoreCache: { directory: string; store: ManagedSessionDescendantStore } | null = null;
|
|
4762
|
+
#ownedManagedAuthority: ManagedSessionDescendantStore | undefined;
|
|
4383
4763
|
// When set, take precedence over the lazily-derived per-session manager.
|
|
4384
4764
|
// Subagents adopt the parent's manager so artifact IDs are unique across the
|
|
4385
4765
|
// whole agent tree and all files land in the parent's artifacts dir.
|
|
@@ -4400,8 +4780,9 @@ export class SessionManager {
|
|
|
4400
4780
|
#labelRevision = 0;
|
|
4401
4781
|
#replayMetadataRevision = 0;
|
|
4402
4782
|
#materializedEntriesRevision = -1;
|
|
4403
|
-
#materializedEntriesCache: SessionEntry[] | undefined;
|
|
4404
|
-
#sessionContextCache: SessionContext | undefined;
|
|
4783
|
+
#materializedEntriesCache: MaterializedCacheReference<SessionEntry[]> | undefined;
|
|
4784
|
+
#sessionContextCache: MaterializedCacheReference<SessionContext> | undefined;
|
|
4785
|
+
#materializedCachesWeaklyHeld = false;
|
|
4405
4786
|
#sessionContextEntryRevision = -1;
|
|
4406
4787
|
#sessionContextLeafRevision = -1;
|
|
4407
4788
|
#sessionContextReplayMetadataRevision = -1;
|
|
@@ -4409,11 +4790,15 @@ export class SessionManager {
|
|
|
4409
4790
|
#coldSpillReadCount = 0;
|
|
4410
4791
|
#residentTextReadCount = 0;
|
|
4411
4792
|
#residentImageReadCount = 0;
|
|
4793
|
+
#residentCacheAdoptFallbackCount = 0;
|
|
4794
|
+
#residentCacheTrustRejectCount = 0;
|
|
4795
|
+
#residentCacheWin32FallbackCount = 0;
|
|
4412
4796
|
#publicMaterializerCallCount = 0;
|
|
4413
4797
|
#getEntryMaterializerCallCount = 0;
|
|
4414
4798
|
#getBranchMaterializerCallCount = 0;
|
|
4415
4799
|
#getEntriesMaterializerCallCount = 0;
|
|
4416
4800
|
#materializedEntriesCachePopulateCount = 0;
|
|
4801
|
+
#materializedCacheDemotedCount = 0;
|
|
4417
4802
|
#pathOnlyContextBuildCount = 0;
|
|
4418
4803
|
#internalReadAccess: SessionManagerReadAccess = {
|
|
4419
4804
|
getEntries: () => freezeInternalReadSnapshot(this.#getMaterializedEntriesInternal()),
|
|
@@ -4452,6 +4837,41 @@ export class SessionManager {
|
|
|
4452
4837
|
};
|
|
4453
4838
|
}
|
|
4454
4839
|
|
|
4840
|
+
/**
|
|
4841
|
+
* Build a one-shot fallback from the materialized transcript. Resident sentinels
|
|
4842
|
+
* are expanded before persistence, so a persisted value is accepted only when its
|
|
4843
|
+
* hash matches a currently referenced resident text blob.
|
|
4844
|
+
*/
|
|
4845
|
+
#createPersistedResidentTextFallback(): ((hash: string) => Buffer | null) | undefined {
|
|
4846
|
+
if (!this.#sessionFile || !(this.storage instanceof FileSessionStorage)) return undefined;
|
|
4847
|
+
const requested = new Set<string>();
|
|
4848
|
+
for (const entry of this.#fileEntries) collectResidentTextBlobHashes(entry, requested);
|
|
4849
|
+
if (requested.size === 0) return undefined;
|
|
4850
|
+
|
|
4851
|
+
let recovered: Map<string, Buffer> | undefined;
|
|
4852
|
+
return hash => {
|
|
4853
|
+
if (!requested.has(hash)) return null;
|
|
4854
|
+
if (!recovered) {
|
|
4855
|
+
recovered = new Map<string, Buffer>();
|
|
4856
|
+
try {
|
|
4857
|
+
const bytes =
|
|
4858
|
+
this.destination.kind === "managed"
|
|
4859
|
+
? this.#managedTranscriptStore().readExpected(path.basename(this.#sessionFile!))?.bytes
|
|
4860
|
+
: captureManagedFileNoFollow(this.#sessionFile!).bytes;
|
|
4861
|
+
if (!bytes) return null;
|
|
4862
|
+
const content = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
4863
|
+
for (const entry of parseSessionEntries(content)) {
|
|
4864
|
+
recoverPersistedResidentTextBuffers(entry, requested, recovered);
|
|
4865
|
+
}
|
|
4866
|
+
} catch {
|
|
4867
|
+
// The normal source lookup reports a missing resident blob if the canonical
|
|
4868
|
+
// transcript cannot be captured safely or does not contain the full value.
|
|
4869
|
+
}
|
|
4870
|
+
}
|
|
4871
|
+
return recovered.get(hash) ?? null;
|
|
4872
|
+
};
|
|
4873
|
+
}
|
|
4874
|
+
|
|
4455
4875
|
#residentBlobStoresForColdRehydrate(): ResidentBlobStores {
|
|
4456
4876
|
return {
|
|
4457
4877
|
...this.#residentBlobStores(),
|
|
@@ -4468,24 +4888,151 @@ export class SessionManager {
|
|
|
4468
4888
|
return this.#memoryGuardCheckpointBlobs ? this.#residentImageBlobStore : this.#blobStore;
|
|
4469
4889
|
}
|
|
4470
4890
|
|
|
4471
|
-
#
|
|
4472
|
-
|
|
4473
|
-
|
|
4474
|
-
|
|
4475
|
-
"resident-cache",
|
|
4476
|
-
`${this.#sessionId || "pending"}-${process.pid}-${instance}`,
|
|
4477
|
-
);
|
|
4891
|
+
#residentCacheProfileAgentDir(): string {
|
|
4892
|
+
return this.destination.kind === "managed"
|
|
4893
|
+
? this.destination.securityContext.profileAgentDir
|
|
4894
|
+
: (explicitProfileAgentDirs.get(this.destination) ?? getAgentDir());
|
|
4478
4895
|
}
|
|
4479
4896
|
|
|
4480
|
-
#
|
|
4481
|
-
|
|
4482
|
-
|
|
4897
|
+
#newCanonicalResidentTextStore(): MemoryBlobStore {
|
|
4898
|
+
return new MemoryBlobStore({ ownership: "canonical" });
|
|
4899
|
+
}
|
|
4900
|
+
|
|
4901
|
+
#newResidentTextStoreCandidate(target: ResidentTransitionInput["target"]): { store: BlobStore; ownsStore: boolean } {
|
|
4902
|
+
if (!this.persist || !target.sessionFile || !(this.storage instanceof FileSessionStorage)) {
|
|
4903
|
+
return { store: this.#newCanonicalResidentTextStore(), ownsStore: false };
|
|
4904
|
+
}
|
|
4905
|
+
if (process.platform === "win32") {
|
|
4906
|
+
this.#residentCacheWin32FallbackCount++;
|
|
4907
|
+
return { store: this.#newCanonicalResidentTextStore(), ownsStore: false };
|
|
4908
|
+
}
|
|
4909
|
+
const instanceDir = openVerifiedResidentCacheInstanceDir(
|
|
4910
|
+
getResidentCacheRootDir(this.#residentCacheProfileAgentDir()),
|
|
4911
|
+
);
|
|
4912
|
+
return { store: EphemeralBlobStore.adoptVerifiedDir(instanceDir), ownsStore: true };
|
|
4913
|
+
}
|
|
4914
|
+
|
|
4915
|
+
#preparedResidentTransitionFromSource(
|
|
4916
|
+
source: ResidentTransitionSource,
|
|
4917
|
+
target: ResidentTransitionInput["target"],
|
|
4918
|
+
store: BlobStore,
|
|
4919
|
+
ownsStore: boolean,
|
|
4920
|
+
): PreparedResidentStoreTransition {
|
|
4921
|
+
if (source.mode === "adopt-staged") {
|
|
4922
|
+
assertResidentReferencesResolvableSync(source.stagedEntries, {
|
|
4923
|
+
textStore: source.stagedStore,
|
|
4924
|
+
imageStore: this.#residentImageBlobStore,
|
|
4925
|
+
sessionId: target.sessionId,
|
|
4926
|
+
sessionFile: target.sessionFile || undefined,
|
|
4927
|
+
});
|
|
4928
|
+
return new PreparedResidentStoreTransition(
|
|
4929
|
+
source.stagedEntries,
|
|
4930
|
+
source.stagedStore,
|
|
4931
|
+
source.stagedIndex ?? this.#buildIndexForEntries(source.stagedEntries, target.sessionFile),
|
|
4932
|
+
false,
|
|
4933
|
+
);
|
|
4934
|
+
}
|
|
4935
|
+
const sourceTextStore = source.sourceStores.textStore ?? new MemoryBlobStore();
|
|
4936
|
+
const materializedEntries = materializeResidentEntriesSync(
|
|
4937
|
+
source.sourceEntries,
|
|
4938
|
+
{
|
|
4939
|
+
textStore: sourceTextStore,
|
|
4940
|
+
imageStore: source.sourceStores.imageStore,
|
|
4941
|
+
textFallback: source.sourceStores.textFallback,
|
|
4942
|
+
},
|
|
4943
|
+
|
|
4944
|
+
source.missingPolicy,
|
|
4483
4945
|
);
|
|
4484
|
-
|
|
4946
|
+
const entries = materializedEntries.map(entry =>
|
|
4947
|
+
prepareEntryForResidentSync(entry, {
|
|
4948
|
+
textStore: store,
|
|
4949
|
+
imageStore: source.sourceStores.imageStore,
|
|
4950
|
+
sessionId: target.sessionId,
|
|
4951
|
+
sessionFile: target.sessionFile || undefined,
|
|
4952
|
+
}),
|
|
4953
|
+
);
|
|
4954
|
+
SessionManagerTestHooks.beforeResidentTransitionIndexBuild?.();
|
|
4955
|
+
return new PreparedResidentStoreTransition(
|
|
4956
|
+
entries,
|
|
4957
|
+
store,
|
|
4958
|
+
this.#buildIndexForEntries(entries, target.sessionFile || undefined),
|
|
4959
|
+
ownsStore,
|
|
4960
|
+
);
|
|
4961
|
+
}
|
|
4962
|
+
|
|
4963
|
+
#prepareResidentTextStoreTransition(
|
|
4964
|
+
input: ResidentTransitionInput,
|
|
4965
|
+
policy: ResidentTransitionFailurePolicy,
|
|
4966
|
+
): PreparedResidentStoreTransition {
|
|
4967
|
+
if (policy === "install-staged" && !input.fallback)
|
|
4968
|
+
throw new Error("install-staged resident transition requires a staged fallback.");
|
|
4969
|
+
if (input.primary.mode === "adopt-staged") {
|
|
4970
|
+
return this.#preparedResidentTransitionFromSource(
|
|
4971
|
+
input.primary,
|
|
4972
|
+
input.target,
|
|
4973
|
+
input.primary.stagedStore,
|
|
4974
|
+
false,
|
|
4975
|
+
);
|
|
4976
|
+
}
|
|
4977
|
+
if (policy === "memory-only") {
|
|
4978
|
+
return this.#preparedResidentTransitionFromSource(
|
|
4979
|
+
input.primary,
|
|
4980
|
+
input.target,
|
|
4981
|
+
this.#newCanonicalResidentTextStore(),
|
|
4982
|
+
false,
|
|
4983
|
+
);
|
|
4984
|
+
}
|
|
4985
|
+
let candidate: { store: BlobStore; ownsStore: boolean } | undefined;
|
|
4986
|
+
try {
|
|
4987
|
+
candidate = this.#newResidentTextStoreCandidate(input.target);
|
|
4988
|
+
return this.#preparedResidentTransitionFromSource(
|
|
4989
|
+
input.primary,
|
|
4990
|
+
input.target,
|
|
4991
|
+
candidate.store,
|
|
4992
|
+
candidate.ownsStore,
|
|
4993
|
+
);
|
|
4994
|
+
} catch (error) {
|
|
4995
|
+
if (candidate?.ownsStore && candidate.store instanceof EphemeralBlobStore) {
|
|
4996
|
+
try {
|
|
4997
|
+
candidate.store.dispose();
|
|
4998
|
+
} catch (disposeError) {
|
|
4999
|
+
logger.warn("Failed to dispose a rejected resident cache candidate", {
|
|
5000
|
+
error: toError(disposeError).message,
|
|
5001
|
+
});
|
|
5002
|
+
}
|
|
5003
|
+
}
|
|
5004
|
+
if (error instanceof ResidentCacheTrustError) this.#residentCacheTrustRejectCount++;
|
|
5005
|
+
if (input.allowUnwritableResidentCacheFallback && isUnwritableResidentCacheFailure(error)) {
|
|
5006
|
+
this.#residentCacheAdoptFallbackCount++;
|
|
5007
|
+
return this.#preparedResidentTransitionFromSource(
|
|
5008
|
+
input.primary,
|
|
5009
|
+
input.target,
|
|
5010
|
+
this.#newCanonicalResidentTextStore(),
|
|
5011
|
+
false,
|
|
5012
|
+
);
|
|
5013
|
+
}
|
|
5014
|
+
if (policy === "retain-and-throw" || !isResidentCacheProvisioningFailure(error)) throw error;
|
|
5015
|
+
if (policy === "install-staged") {
|
|
5016
|
+
const fallback = input.fallback;
|
|
5017
|
+
if (!fallback) throw new Error("install-staged resident transition requires a staged fallback.");
|
|
5018
|
+
this.#residentCacheAdoptFallbackCount++;
|
|
5019
|
+
return this.#preparedResidentTransitionFromSource(fallback, input.target, fallback.stagedStore, false);
|
|
5020
|
+
}
|
|
5021
|
+
if (policy === "memory-fallback") {
|
|
5022
|
+
this.#residentCacheAdoptFallbackCount++;
|
|
5023
|
+
return this.#preparedResidentTransitionFromSource(
|
|
5024
|
+
input.primary,
|
|
5025
|
+
input.target,
|
|
5026
|
+
this.#newCanonicalResidentTextStore(),
|
|
5027
|
+
false,
|
|
5028
|
+
);
|
|
5029
|
+
}
|
|
5030
|
+
throw error;
|
|
5031
|
+
}
|
|
4485
5032
|
}
|
|
4486
5033
|
|
|
4487
5034
|
#preparePreparedNewSessionForCommit(stage: PreparedNewSessionState): void {
|
|
4488
|
-
const residentTextBlobStore =
|
|
5035
|
+
const residentTextBlobStore = this.#newCanonicalResidentTextStore();
|
|
4489
5036
|
const residentFileEntries = stage.fileEntries.map(entry =>
|
|
4490
5037
|
prepareEntryForResidentSync(entry, {
|
|
4491
5038
|
textStore: residentTextBlobStore,
|
|
@@ -4502,6 +5049,21 @@ export class SessionManager {
|
|
|
4502
5049
|
#resetMaterializedCaches(): void {
|
|
4503
5050
|
this.#materializedEntriesRevision = -1;
|
|
4504
5051
|
this.#materializedEntriesCache = undefined;
|
|
5052
|
+
this.#materializedCachesWeaklyHeld = false;
|
|
5053
|
+
this.#sessionContextCache = undefined;
|
|
5054
|
+
this.#sessionContextEntryRevision = -1;
|
|
5055
|
+
this.#sessionContextLeafRevision = -1;
|
|
5056
|
+
this.#sessionContextReplayMetadataRevision = -1;
|
|
5057
|
+
}
|
|
5058
|
+
|
|
5059
|
+
#holdMaterializedCachesWeakly(): void {
|
|
5060
|
+
if (this.#materializedCachesWeaklyHeld) return;
|
|
5061
|
+
this.#materializedCachesWeaklyHeld = true;
|
|
5062
|
+
this.#materializedCacheDemotedCount++;
|
|
5063
|
+
const entries = dereferenceMaterializedCache(this.#materializedEntriesCache);
|
|
5064
|
+
if (entries) this.#materializedEntriesCache = new WeakRef(entries);
|
|
5065
|
+
const context = dereferenceMaterializedCache(this.#sessionContextCache);
|
|
5066
|
+
if (context) this.#sessionContextCache = new WeakRef(context);
|
|
4505
5067
|
}
|
|
4506
5068
|
|
|
4507
5069
|
#bumpEntryRevision(): void {
|
|
@@ -4533,26 +5095,170 @@ export class SessionManager {
|
|
|
4533
5095
|
};
|
|
4534
5096
|
}
|
|
4535
5097
|
|
|
4536
|
-
#
|
|
4537
|
-
if (
|
|
4538
|
-
|
|
5098
|
+
#disposeResidentTextStore(store: BlobStore): void {
|
|
5099
|
+
if (!(store instanceof EphemeralBlobStore)) return;
|
|
5100
|
+
try {
|
|
5101
|
+
store.dispose();
|
|
5102
|
+
} catch (error) {
|
|
5103
|
+
logger.warn("Failed to dispose a replaced resident cache store", {
|
|
5104
|
+
error: toError(error).message,
|
|
5105
|
+
});
|
|
5106
|
+
}
|
|
5107
|
+
}
|
|
5108
|
+
|
|
5109
|
+
async #cleanupForkTranscriptPublication(publication: ForkTranscriptPublication): Promise<void> {
|
|
5110
|
+
if (publication.kind === "managed") {
|
|
5111
|
+
const snapshot = publication.snapshot ?? publication.store.readExpected(publication.relativePath);
|
|
5112
|
+
if (!snapshot) return;
|
|
5113
|
+
if (!snapshot.bytes.equals(publication.publishedBytes)) throw new Error("fork_transcript_changed");
|
|
5114
|
+
publication.store.removeExpected(publication.relativePath, snapshot);
|
|
5115
|
+
return;
|
|
5116
|
+
}
|
|
5117
|
+
if (publication.kind === "explicit-storage") {
|
|
5118
|
+
if (publication.snapshot) {
|
|
5119
|
+
const current = this.storage.readSnapshotSync?.(publication.sessionFile);
|
|
5120
|
+
if (
|
|
5121
|
+
!current ||
|
|
5122
|
+
!sameSessionStorageSnapshot(current, publication.snapshot) ||
|
|
5123
|
+
!Buffer.from(current.bytes).equals(publication.publishedBytes)
|
|
5124
|
+
) {
|
|
5125
|
+
throw new Error("fork_transcript_changed");
|
|
5126
|
+
}
|
|
5127
|
+
} else {
|
|
5128
|
+
if (!this.storage.existsSync(publication.sessionFile)) return;
|
|
5129
|
+
const current = Buffer.from(this.storage.readTextSync(publication.sessionFile), "utf8");
|
|
5130
|
+
if (!current.equals(publication.publishedBytes)) throw new Error("fork_transcript_changed");
|
|
5131
|
+
}
|
|
5132
|
+
await this.storage.unlink(publication.sessionFile);
|
|
5133
|
+
return;
|
|
5134
|
+
}
|
|
5135
|
+
let snapshot = publication.snapshot;
|
|
5136
|
+
if (!snapshot) {
|
|
5137
|
+
try {
|
|
5138
|
+
snapshot = captureManagedFileNoFollow(publication.sessionFile);
|
|
5139
|
+
} catch (error) {
|
|
5140
|
+
if (isEnoent(error)) return;
|
|
5141
|
+
throw error;
|
|
5142
|
+
}
|
|
5143
|
+
}
|
|
5144
|
+
if (!snapshot.bytes.equals(publication.publishedBytes)) throw new Error("fork_transcript_changed");
|
|
5145
|
+
const removed = native.exactUnlink(publication.sessionFile, {
|
|
5146
|
+
dev: snapshot.identity.dev,
|
|
5147
|
+
ino: snapshot.identity.ino,
|
|
5148
|
+
size: BigInt(snapshot.identity.size),
|
|
5149
|
+
mtimeNs: snapshot.identity.mtimeNs,
|
|
5150
|
+
sha256: snapshot.identity.sha256,
|
|
5151
|
+
quarantineName: `.gjc-fork-${process.pid}-${crypto.randomUUID()}`,
|
|
5152
|
+
});
|
|
5153
|
+
if (
|
|
5154
|
+
!removed.ok &&
|
|
5155
|
+
!(
|
|
5156
|
+
removed.code === "cleanup_pending" &&
|
|
5157
|
+
(removed.detachedPath ??
|
|
5158
|
+
removed.retainedSuccessorPath ??
|
|
5159
|
+
removed.retainedPlaceholderPath ??
|
|
5160
|
+
removed.retainedUnknownPath) !== undefined
|
|
5161
|
+
)
|
|
5162
|
+
) {
|
|
5163
|
+
throw new Error(removed.code ?? "fork_transcript_cleanup_failed");
|
|
4539
5164
|
}
|
|
4540
|
-
this.#residentTextBlobStore = new MemoryBlobStore();
|
|
4541
|
-
this.#resetMaterializedCaches();
|
|
4542
5165
|
}
|
|
4543
5166
|
|
|
4544
|
-
#
|
|
4545
|
-
|
|
5167
|
+
#cleanupForkArtifactPublication(publication: ForkArtifactPublication): void {
|
|
5168
|
+
if (publication.kind === "managed") {
|
|
5169
|
+
publication.cleanupStore.removeTreeExpected(publication.cleanupRelativePath, publication.snapshot);
|
|
5170
|
+
return;
|
|
5171
|
+
}
|
|
5172
|
+
const removed = native.exactRemoveDirectoryTree(publication.artifactsDir, publication.snapshot);
|
|
4546
5173
|
if (
|
|
4547
|
-
|
|
4548
|
-
|
|
4549
|
-
|
|
4550
|
-
|
|
5174
|
+
!removed.ok &&
|
|
5175
|
+
!(
|
|
5176
|
+
removed.code === "cleanup_pending" &&
|
|
5177
|
+
(removed.detachedPath ??
|
|
5178
|
+
removed.retainedSuccessorPath ??
|
|
5179
|
+
removed.retainedPlaceholderPath ??
|
|
5180
|
+
removed.retainedUnknownPath) !== undefined
|
|
5181
|
+
)
|
|
4551
5182
|
) {
|
|
4552
|
-
|
|
5183
|
+
throw new Error(removed.code ?? "fork_artifact_cleanup_failed");
|
|
5184
|
+
}
|
|
5185
|
+
}
|
|
5186
|
+
|
|
5187
|
+
#commitResidentTextStoreTransition(prepared: PreparedResidentStoreTransition): void {
|
|
5188
|
+
const predecessor = this.#residentTextBlobStore;
|
|
5189
|
+
this.#fileEntries = prepared.entries;
|
|
5190
|
+
this.#residentTextBlobStore = prepared.store;
|
|
5191
|
+
this.#byId = prepared.index.byId;
|
|
5192
|
+
this.#labelsById = prepared.index.labelsById;
|
|
5193
|
+
this.#leafId = prepared.index.leafId;
|
|
5194
|
+
this.#usageStatistics = prepared.index.usageStatistics;
|
|
5195
|
+
this.#bumpAllRevisions();
|
|
5196
|
+
prepared.adopt();
|
|
5197
|
+
if (predecessor !== prepared.store) this.#disposeResidentTextStore(predecessor);
|
|
5198
|
+
}
|
|
5199
|
+
|
|
5200
|
+
#releaseResidentTextStore(): void {
|
|
5201
|
+
const predecessor = this.#residentTextBlobStore;
|
|
5202
|
+
this.#residentTextBlobStore = new MemoryBlobStore();
|
|
5203
|
+
this.#resetMaterializedCaches();
|
|
5204
|
+
this.#disposeResidentTextStore(predecessor);
|
|
5205
|
+
}
|
|
5206
|
+
|
|
5207
|
+
#demoteResidentTextStoreAfterTrustReject(error: ResidentCacheTrustError): void {
|
|
5208
|
+
const predecessor = this.#residentTextBlobStore;
|
|
5209
|
+
(predecessor as ResidentCacheDegradedStore).degradedReason = error.reason;
|
|
5210
|
+
this.#residentCacheTrustRejectCount++;
|
|
5211
|
+
logger.warn("Resident cache trust rejection; demoting resident text store", {
|
|
5212
|
+
sessionId: this.#sessionId,
|
|
5213
|
+
sessionFile: this.#sessionFile,
|
|
5214
|
+
reason: error.reason,
|
|
5215
|
+
});
|
|
5216
|
+
const persistedTextFallback =
|
|
5217
|
+
predecessor instanceof EphemeralBlobStore ? this.#createPersistedResidentTextFallback() : undefined;
|
|
5218
|
+
const prepared = this.#prepareResidentTextStoreTransition(
|
|
5219
|
+
{
|
|
5220
|
+
target: { sessionId: this.#sessionId || "pending", sessionFile: this.#sessionFile ?? "" },
|
|
5221
|
+
primary: {
|
|
5222
|
+
mode: "materialize",
|
|
5223
|
+
sourceEntries: this.#fileEntries,
|
|
5224
|
+
sourceStores: {
|
|
5225
|
+
textStore: predecessor,
|
|
5226
|
+
imageStore: this.#residentImageBlobStore,
|
|
5227
|
+
// Read buffered bytes before EphemeralBlobStore revalidates a root that may have been swapped.
|
|
5228
|
+
// Persisted JSONL is a canonical fallback for resident entries evicted from the LRU.
|
|
5229
|
+
textFallback:
|
|
5230
|
+
predecessor instanceof EphemeralBlobStore
|
|
5231
|
+
? hash => predecessor.getBufferedSync(hash) ?? persistedTextFallback?.(hash) ?? null
|
|
5232
|
+
: undefined,
|
|
5233
|
+
},
|
|
5234
|
+
},
|
|
5235
|
+
},
|
|
5236
|
+
"memory-only",
|
|
5237
|
+
);
|
|
5238
|
+
(prepared.store as ResidentCacheDegradedStore).degradedReason = error.reason;
|
|
5239
|
+
this.#commitResidentTextStoreTransition(prepared);
|
|
5240
|
+
}
|
|
5241
|
+
|
|
5242
|
+
#retryResidentPutAfterTrustReject<T>(operation: () => T): T {
|
|
5243
|
+
try {
|
|
5244
|
+
return operation();
|
|
5245
|
+
} catch (error) {
|
|
5246
|
+
if (!(error instanceof ResidentCacheTrustError)) throw error;
|
|
5247
|
+
this.#demoteResidentTextStoreAfterTrustReject(error);
|
|
5248
|
+
return operation();
|
|
4553
5249
|
}
|
|
4554
5250
|
}
|
|
4555
5251
|
|
|
5252
|
+
#prepareEntryForCurrentResidentStore(entry: FileEntry): FileEntry {
|
|
5253
|
+
return this.#retryResidentPutAfterTrustReject(() =>
|
|
5254
|
+
prepareEntryForResidentSync(entry, this.#residentBlobStores()),
|
|
5255
|
+
);
|
|
5256
|
+
}
|
|
5257
|
+
|
|
5258
|
+
#putResidentTextBlobSync(data: Buffer): BlobPutResult {
|
|
5259
|
+
return this.#retryResidentPutAfterTrustReject(() => this.#residentTextBlobStore.putSync(data));
|
|
5260
|
+
}
|
|
5261
|
+
|
|
4556
5262
|
/** Puts a binary blob into the blob store and returns the blob reference */
|
|
4557
5263
|
async putBlob(data: Buffer): Promise<BlobPutResult> {
|
|
4558
5264
|
return this.#blobStore.put(data);
|
|
@@ -4582,6 +5288,17 @@ export class SessionManager {
|
|
|
4582
5288
|
|
|
4583
5289
|
restoreState(snapshot: SessionManagerStateSnapshot): void {
|
|
4584
5290
|
const restoredFileEntries = [...snapshot.materializedFileEntries];
|
|
5291
|
+
const prepared = this.#prepareResidentTextStoreTransition(
|
|
5292
|
+
{
|
|
5293
|
+
target: { sessionId: snapshot.sessionId, sessionFile: snapshot.sessionFile ?? "" },
|
|
5294
|
+
primary: {
|
|
5295
|
+
mode: "materialize",
|
|
5296
|
+
sourceEntries: restoredFileEntries,
|
|
5297
|
+
sourceStores: { textStore: null, imageStore: this.#residentImageBlobStore },
|
|
5298
|
+
},
|
|
5299
|
+
},
|
|
5300
|
+
"retain-and-throw",
|
|
5301
|
+
);
|
|
4585
5302
|
const retainsPersistWriter =
|
|
4586
5303
|
this.#persistWriter?.isOpen() === true &&
|
|
4587
5304
|
this.#sessionId === snapshot.sessionId &&
|
|
@@ -4594,7 +5311,6 @@ export class SessionManager {
|
|
|
4594
5311
|
this.#flushed = snapshot.flushed;
|
|
4595
5312
|
this.#ensuredOnDisk = snapshot.ensuredOnDisk;
|
|
4596
5313
|
this.#needsFullRewriteOnNextPersist = snapshot.needsFullRewriteOnNextPersist;
|
|
4597
|
-
this.#fileEntries = restoredFileEntries;
|
|
4598
5314
|
if (!retainsPersistWriter) {
|
|
4599
5315
|
this.#persistWriter = undefined;
|
|
4600
5316
|
this.#persistWriterPath = undefined;
|
|
@@ -4605,97 +5321,306 @@ export class SessionManager {
|
|
|
4605
5321
|
this.#artifactManager = null;
|
|
4606
5322
|
this.#artifactManagerSessionFile = null;
|
|
4607
5323
|
this.#adoptedArtifactManager = null;
|
|
4608
|
-
this.#
|
|
4609
|
-
this.#
|
|
4610
|
-
this.#bumpAllRevisions();
|
|
4611
|
-
if (this.#sessionFile) {
|
|
4612
|
-
writeTerminalBreadcrumb(this.cwd, this.#sessionFile);
|
|
4613
|
-
}
|
|
5324
|
+
this.#commitResidentTextStoreTransition(prepared);
|
|
5325
|
+
if (this.#sessionFile) writeTerminalBreadcrumb(this.cwd, this.#sessionFile);
|
|
4614
5326
|
}
|
|
4615
5327
|
|
|
4616
|
-
|
|
4617
|
-
|
|
4618
|
-
|
|
5328
|
+
#freshSessionState(options?: NewSessionOptions, sessionFileOverride?: string): FreshSessionState {
|
|
5329
|
+
const preallocated = this.#lifecycleIdAdopted ? undefined : lifecyclePreallocatedSessionId();
|
|
5330
|
+
const sessionId = preallocated ?? createSessionId();
|
|
5331
|
+
const timestamp = new Date().toISOString();
|
|
5332
|
+
const sessionFile =
|
|
5333
|
+
sessionFileOverride ??
|
|
5334
|
+
(this.persist
|
|
5335
|
+
? path.join(this.getSessionDir(), `${timestamp.replace(/[:.]/g, "-")}_${sessionId}.jsonl`)
|
|
5336
|
+
: undefined);
|
|
5337
|
+
return {
|
|
5338
|
+
sessionId,
|
|
5339
|
+
sessionFile,
|
|
5340
|
+
header: {
|
|
5341
|
+
type: "session",
|
|
5342
|
+
version: CURRENT_SESSION_VERSION,
|
|
5343
|
+
id: sessionId,
|
|
5344
|
+
timestamp,
|
|
5345
|
+
cwd: this.cwd,
|
|
5346
|
+
parentSession: options?.parentSession,
|
|
5347
|
+
},
|
|
5348
|
+
adoptsLifecycleId: preallocated !== undefined,
|
|
5349
|
+
};
|
|
4619
5350
|
}
|
|
4620
5351
|
|
|
4621
|
-
|
|
4622
|
-
|
|
4623
|
-
|
|
4624
|
-
|
|
4625
|
-
this.#
|
|
4626
|
-
this.#sessionId =
|
|
4627
|
-
this.#sessionName = header.title;
|
|
4628
|
-
this.#titleSource = header.titleSource;
|
|
4629
|
-
this.#
|
|
4630
|
-
|
|
4631
|
-
this.#
|
|
4632
|
-
this.#
|
|
4633
|
-
this.#
|
|
4634
|
-
|
|
4635
|
-
|
|
4636
|
-
this.#
|
|
4637
|
-
this.#
|
|
4638
|
-
|
|
4639
|
-
|
|
4640
|
-
|
|
5352
|
+
#applyFreshSessionMetadata(state: FreshSessionState): void {
|
|
5353
|
+
if (state.adoptsLifecycleId) this.#lifecycleIdAdopted = true;
|
|
5354
|
+
this.#persistChain = Promise.resolve();
|
|
5355
|
+
this.#persistError = undefined;
|
|
5356
|
+
this.#persistErrorReported = false;
|
|
5357
|
+
this.#sessionId = state.sessionId;
|
|
5358
|
+
this.#sessionName = state.header.title;
|
|
5359
|
+
this.#titleSource = state.header.titleSource;
|
|
5360
|
+
this.#sessionFile = state.sessionFile;
|
|
5361
|
+
this.#flushed = false;
|
|
5362
|
+
this.#needsFullRewriteOnNextPersist = false;
|
|
5363
|
+
this.#ensuredOnDisk = false;
|
|
5364
|
+
this.#inMemoryArtifacts = null;
|
|
5365
|
+
this.#inMemoryArtifactCounter = 0;
|
|
5366
|
+
this.#artifactManager = null;
|
|
5367
|
+
this.#artifactManagerSessionFile = null;
|
|
5368
|
+
this.#adoptedArtifactManager = null;
|
|
5369
|
+
}
|
|
5370
|
+
|
|
5371
|
+
#prepareFreshSessionTransition(
|
|
5372
|
+
state: FreshSessionState,
|
|
5373
|
+
policy: Exclude<ResidentTransitionFailurePolicy, "install-staged">,
|
|
5374
|
+
): PreparedResidentStoreTransition {
|
|
5375
|
+
return this.#prepareResidentTextStoreTransition(
|
|
5376
|
+
{
|
|
5377
|
+
target: { sessionId: state.sessionId, sessionFile: state.sessionFile ?? "" },
|
|
5378
|
+
primary: {
|
|
5379
|
+
mode: "materialize",
|
|
5380
|
+
sourceEntries: [state.header],
|
|
5381
|
+
sourceStores: { textStore: null, imageStore: this.#residentImageBlobStore },
|
|
5382
|
+
},
|
|
5383
|
+
},
|
|
5384
|
+
policy,
|
|
5385
|
+
);
|
|
5386
|
+
}
|
|
5387
|
+
|
|
5388
|
+
/** Initialize with a specific session file (used by factory methods). */
|
|
5389
|
+
async #initSessionFile(sessionFile: string): Promise<void> {
|
|
5390
|
+
const resolvedSessionFile = this.storage instanceof FileSessionStorage ? path.resolve(sessionFile) : sessionFile;
|
|
5391
|
+
const entries = await loadEntriesFromFile(resolvedSessionFile, this.storage);
|
|
5392
|
+
if (entries.length === 0) {
|
|
5393
|
+
const fresh = this.#freshSessionState(undefined, resolvedSessionFile);
|
|
5394
|
+
const prepared = this.#prepareFreshSessionTransition(fresh, "memory-fallback");
|
|
5395
|
+
this.#applyFreshSessionMetadata(fresh);
|
|
5396
|
+
this.#commitResidentTextStoreTransition(prepared);
|
|
5397
|
+
writeTerminalBreadcrumb(this.cwd, resolvedSessionFile);
|
|
5398
|
+
await this.#rewriteFile();
|
|
5399
|
+
this.#flushed = true;
|
|
5400
|
+
this.#ensuredOnDisk = true;
|
|
5401
|
+
return;
|
|
5402
|
+
}
|
|
5403
|
+
const header = entries.find(entry => entry.type === "session") as SessionHeader | undefined;
|
|
5404
|
+
const sessionId = header?.id ?? createSessionId();
|
|
5405
|
+
const migrationApplied = migrateToCurrentVersion(entries);
|
|
5406
|
+
await resolveBlobRefsInEntries(entries, this.#blobStore);
|
|
5407
|
+
const prepared = this.#prepareResidentTextStoreTransition(
|
|
5408
|
+
{
|
|
5409
|
+
target: { sessionId, sessionFile: resolvedSessionFile },
|
|
5410
|
+
primary: {
|
|
5411
|
+
mode: "materialize",
|
|
5412
|
+
sourceEntries: entries,
|
|
5413
|
+
sourceStores: { textStore: null, imageStore: this.#residentImageBlobStore },
|
|
5414
|
+
missingPolicy: "placeholder",
|
|
5415
|
+
},
|
|
5416
|
+
},
|
|
5417
|
+
"memory-fallback",
|
|
5418
|
+
);
|
|
5419
|
+
this.#sessionFile = resolvedSessionFile;
|
|
5420
|
+
this.#sessionId = sessionId;
|
|
5421
|
+
this.#sessionName = header?.title;
|
|
5422
|
+
this.#titleSource = header?.titleSource;
|
|
5423
|
+
this.#needsFullRewriteOnNextPersist = migrationApplied;
|
|
5424
|
+
this.#commitResidentTextStoreTransition(prepared);
|
|
5425
|
+
writeTerminalBreadcrumb(this.cwd, resolvedSessionFile);
|
|
5426
|
+
this.#flushed = true;
|
|
5427
|
+
this.#ensuredOnDisk = true;
|
|
5428
|
+
await this.#sanitizeLoadedOpenAIResponsesReplayMetadataAndPersist();
|
|
5429
|
+
}
|
|
4641
5430
|
|
|
4642
|
-
|
|
5431
|
+
async #hydrateExistingSession(
|
|
5432
|
+
sessionFile: string,
|
|
5433
|
+
entries: FileEntry[],
|
|
5434
|
+
migrationApplied: boolean,
|
|
5435
|
+
policy: Exclude<ResidentTransitionFailurePolicy, "install-staged"> = "memory-fallback",
|
|
5436
|
+
): Promise<void> {
|
|
5437
|
+
// Strict inspection creates these entries solely for this hydration path. Adopt that
|
|
5438
|
+
// final, validated representation instead of cloning the complete transcript again.
|
|
5439
|
+
const header = entries[0] as SessionHeader;
|
|
5440
|
+
const resolvedSessionFile = this.storage instanceof FileSessionStorage ? path.resolve(sessionFile) : sessionFile;
|
|
5441
|
+
await resolveBlobRefsInEntries(entries, this.#blobStore);
|
|
5442
|
+
const prepared = this.#prepareResidentTextStoreTransition(
|
|
5443
|
+
{
|
|
5444
|
+
target: { sessionId: header.id, sessionFile: resolvedSessionFile },
|
|
5445
|
+
primary: {
|
|
5446
|
+
mode: "materialize",
|
|
5447
|
+
sourceEntries: entries,
|
|
5448
|
+
sourceStores: { textStore: null, imageStore: this.#residentImageBlobStore },
|
|
5449
|
+
missingPolicy: "placeholder",
|
|
5450
|
+
},
|
|
5451
|
+
},
|
|
5452
|
+
policy,
|
|
5453
|
+
);
|
|
5454
|
+
this.#sessionFile = resolvedSessionFile;
|
|
5455
|
+
this.#sessionId = header.id;
|
|
5456
|
+
this.#sessionName = header.title;
|
|
5457
|
+
this.#titleSource = header.titleSource;
|
|
5458
|
+
this.#needsFullRewriteOnNextPersist = migrationApplied;
|
|
5459
|
+
this.#commitResidentTextStoreTransition(prepared);
|
|
5460
|
+
this.#flushed = true;
|
|
5461
|
+
this.#ensuredOnDisk = true;
|
|
5462
|
+
}
|
|
5463
|
+
|
|
5464
|
+
/** Initialize with a new session (used by factory methods). */
|
|
4643
5465
|
#initNewSession(): void {
|
|
4644
5466
|
this.#newSessionSync();
|
|
4645
|
-
this.#bumpAllRevisions();
|
|
4646
5467
|
}
|
|
4647
5468
|
|
|
4648
|
-
/** Switch to a different session file (used for resume and branching) */
|
|
5469
|
+
/** Switch to a different session file (used for resume and branching). */
|
|
4649
5470
|
async setSessionFile(sessionFile: string): Promise<void> {
|
|
4650
5471
|
this.#assertRecoveryHydrationWritable();
|
|
4651
5472
|
const resolvedSessionFile = this.storage instanceof FileSessionStorage ? path.resolve(sessionFile) : sessionFile;
|
|
4652
5473
|
const strictAdoption = this.#pendingStrictAdoption;
|
|
4653
|
-
|
|
4654
|
-
if (
|
|
4655
|
-
const
|
|
4656
|
-
if (
|
|
4657
|
-
|
|
5474
|
+
let managedTransition: ManagedDestinationTransition | undefined;
|
|
5475
|
+
if (this.destination.kind === "managed") {
|
|
5476
|
+
const candidateDirectory = path.resolve(path.dirname(resolvedSessionFile));
|
|
5477
|
+
if (candidateDirectory === path.resolve(this.destination.directory)) {
|
|
5478
|
+
const candidateStore = this.#managedTranscriptStore(resolvedSessionFile);
|
|
5479
|
+
candidateStore.verifyRootSecurity();
|
|
5480
|
+
candidateStore.assertBound();
|
|
5481
|
+
} else {
|
|
5482
|
+
managedTransition = this.#prepareManagedDestinationTransition(candidateDirectory);
|
|
5483
|
+
}
|
|
4658
5484
|
}
|
|
4659
|
-
|
|
4660
|
-
|
|
4661
|
-
|
|
4662
|
-
|
|
4663
|
-
|
|
4664
|
-
|
|
4665
|
-
|
|
4666
|
-
|
|
4667
|
-
if (
|
|
4668
|
-
|
|
4669
|
-
|
|
4670
|
-
|
|
4671
|
-
|
|
4672
|
-
|
|
4673
|
-
|
|
4674
|
-
|
|
4675
|
-
|
|
4676
|
-
|
|
4677
|
-
|
|
4678
|
-
|
|
4679
|
-
|
|
4680
|
-
|
|
4681
|
-
|
|
4682
|
-
|
|
4683
|
-
|
|
4684
|
-
|
|
4685
|
-
|
|
4686
|
-
|
|
4687
|
-
|
|
4688
|
-
|
|
4689
|
-
|
|
4690
|
-
|
|
4691
|
-
|
|
4692
|
-
|
|
4693
|
-
|
|
5485
|
+
try {
|
|
5486
|
+
if (strictAdoption && resolvedSessionFile === strictAdoption.canonicalPath) {
|
|
5487
|
+
const inspected = inspectResumeSessionFile(resolvedSessionFile, this.storage);
|
|
5488
|
+
if ("kind" in inspected || !sameResumeIdentity(strictAdoption.identity, inspected.identity))
|
|
5489
|
+
throw new Error("Prepared managed session changed before strict adoption.");
|
|
5490
|
+
}
|
|
5491
|
+
let entries: FileEntry[];
|
|
5492
|
+
let candidateMigrationApplied = false;
|
|
5493
|
+
if (this.storage.existsSync(resolvedSessionFile)) {
|
|
5494
|
+
const inspected = inspectResumeSessionFile(resolvedSessionFile, this.storage);
|
|
5495
|
+
if ("kind" in inspected) throw new Error(`Could not switch session: ${inspected.reason}`);
|
|
5496
|
+
entries = inspected.entries;
|
|
5497
|
+
candidateMigrationApplied = inspected.migrationApplied;
|
|
5498
|
+
} else {
|
|
5499
|
+
entries = await loadEntriesFromFile(resolvedSessionFile, this.storage);
|
|
5500
|
+
}
|
|
5501
|
+
if (strictAdoption) {
|
|
5502
|
+
const inspected = inspectResumeSessionFile(resolvedSessionFile, this.storage);
|
|
5503
|
+
if ("kind" in inspected || !sameResumeIdentity(strictAdoption.identity, inspected.identity))
|
|
5504
|
+
throw new Error("Prepared managed session changed during strict adoption.");
|
|
5505
|
+
}
|
|
5506
|
+
if (entries.length > 0) {
|
|
5507
|
+
const header = entries.find(entry => entry.type === "session") as SessionHeader | undefined;
|
|
5508
|
+
const sessionId = header?.id ?? createSessionId();
|
|
5509
|
+
const migrationApplied = candidateMigrationApplied || migrateToCurrentVersion(entries);
|
|
5510
|
+
await resolveBlobRefsInEntries(entries, this.#blobStore);
|
|
5511
|
+
const prepared = this.#prepareResidentTextStoreTransition(
|
|
5512
|
+
{
|
|
5513
|
+
target: { sessionId, sessionFile: resolvedSessionFile },
|
|
5514
|
+
allowUnwritableResidentCacheFallback: true,
|
|
5515
|
+
primary: {
|
|
5516
|
+
mode: "materialize",
|
|
5517
|
+
sourceEntries: entries,
|
|
5518
|
+
sourceStores: { textStore: null, imageStore: this.#residentImageBlobStore },
|
|
5519
|
+
},
|
|
5520
|
+
},
|
|
5521
|
+
"retain-and-throw",
|
|
5522
|
+
);
|
|
5523
|
+
try {
|
|
5524
|
+
await this.#closePersistWriter();
|
|
5525
|
+
} catch (error) {
|
|
5526
|
+
prepared.dispose();
|
|
5527
|
+
throw error;
|
|
5528
|
+
}
|
|
5529
|
+
const previous = {
|
|
5530
|
+
sessionId: this.#sessionId,
|
|
5531
|
+
sessionName: this.#sessionName,
|
|
5532
|
+
titleSource: this.#titleSource,
|
|
5533
|
+
sessionFile: this.#sessionFile,
|
|
5534
|
+
needsFullRewriteOnNextPersist: this.#needsFullRewriteOnNextPersist,
|
|
5535
|
+
};
|
|
5536
|
+
this.#persistError = undefined;
|
|
5537
|
+
this.#persistErrorReported = false;
|
|
5538
|
+
this.#sessionFile = resolvedSessionFile;
|
|
5539
|
+
this.#sessionId = sessionId;
|
|
5540
|
+
this.#sessionName = header?.title;
|
|
5541
|
+
this.#titleSource = header?.titleSource;
|
|
5542
|
+
this.#needsFullRewriteOnNextPersist = migrationApplied;
|
|
5543
|
+
try {
|
|
5544
|
+
managedTransition?.adopt();
|
|
5545
|
+
writeTerminalBreadcrumb(this.cwd, resolvedSessionFile);
|
|
5546
|
+
this.#commitResidentTextStoreTransition(prepared);
|
|
5547
|
+
} catch (error) {
|
|
5548
|
+
managedTransition?.rollback();
|
|
5549
|
+
this.#sessionId = previous.sessionId;
|
|
5550
|
+
this.#sessionName = previous.sessionName;
|
|
5551
|
+
this.#titleSource = previous.titleSource;
|
|
5552
|
+
this.#sessionFile = previous.sessionFile;
|
|
5553
|
+
this.#needsFullRewriteOnNextPersist = previous.needsFullRewriteOnNextPersist;
|
|
5554
|
+
prepared.dispose();
|
|
5555
|
+
throw error;
|
|
5556
|
+
}
|
|
5557
|
+
managedTransition?.settle();
|
|
5558
|
+
this.#pendingStrictAdoption = undefined;
|
|
5559
|
+
this.#flushed = true;
|
|
5560
|
+
this.#ensuredOnDisk = true;
|
|
5561
|
+
await this.#sanitizeLoadedOpenAIResponsesReplayMetadataAndPersist();
|
|
5562
|
+
return;
|
|
5563
|
+
}
|
|
5564
|
+
const fresh = this.#freshSessionState(undefined, resolvedSessionFile);
|
|
5565
|
+
const prepared = this.#prepareFreshSessionTransition(fresh, "retain-and-throw");
|
|
5566
|
+
try {
|
|
5567
|
+
await this.#closePersistWriter();
|
|
5568
|
+
} catch (error) {
|
|
5569
|
+
prepared.dispose();
|
|
5570
|
+
throw error;
|
|
5571
|
+
}
|
|
5572
|
+
const previous = {
|
|
5573
|
+
lifecycleIdAdopted: this.#lifecycleIdAdopted,
|
|
5574
|
+
persistChain: this.#persistChain,
|
|
5575
|
+
persistError: this.#persistError,
|
|
5576
|
+
persistErrorReported: this.#persistErrorReported,
|
|
5577
|
+
sessionId: this.#sessionId,
|
|
5578
|
+
sessionName: this.#sessionName,
|
|
5579
|
+
titleSource: this.#titleSource,
|
|
5580
|
+
sessionFile: this.#sessionFile,
|
|
5581
|
+
flushed: this.#flushed,
|
|
5582
|
+
needsFullRewriteOnNextPersist: this.#needsFullRewriteOnNextPersist,
|
|
5583
|
+
ensuredOnDisk: this.#ensuredOnDisk,
|
|
5584
|
+
inMemoryArtifacts: this.#inMemoryArtifacts,
|
|
5585
|
+
inMemoryArtifactCounter: this.#inMemoryArtifactCounter,
|
|
5586
|
+
artifactManager: this.#artifactManager,
|
|
5587
|
+
artifactManagerSessionFile: this.#artifactManagerSessionFile,
|
|
5588
|
+
adoptedArtifactManager: this.#adoptedArtifactManager,
|
|
5589
|
+
};
|
|
5590
|
+
this.#applyFreshSessionMetadata(fresh);
|
|
5591
|
+
try {
|
|
5592
|
+
managedTransition?.adopt();
|
|
5593
|
+
writeTerminalBreadcrumb(this.cwd, resolvedSessionFile);
|
|
5594
|
+
this.#commitResidentTextStoreTransition(prepared);
|
|
5595
|
+
} catch (error) {
|
|
5596
|
+
managedTransition?.rollback();
|
|
5597
|
+
this.#lifecycleIdAdopted = previous.lifecycleIdAdopted;
|
|
5598
|
+
this.#persistChain = previous.persistChain;
|
|
5599
|
+
this.#persistError = previous.persistError;
|
|
5600
|
+
this.#persistErrorReported = previous.persistErrorReported;
|
|
5601
|
+
this.#sessionId = previous.sessionId;
|
|
5602
|
+
this.#sessionName = previous.sessionName;
|
|
5603
|
+
this.#titleSource = previous.titleSource;
|
|
5604
|
+
this.#sessionFile = previous.sessionFile;
|
|
5605
|
+
this.#flushed = previous.flushed;
|
|
5606
|
+
this.#needsFullRewriteOnNextPersist = previous.needsFullRewriteOnNextPersist;
|
|
5607
|
+
this.#ensuredOnDisk = previous.ensuredOnDisk;
|
|
5608
|
+
this.#inMemoryArtifacts = previous.inMemoryArtifacts;
|
|
5609
|
+
this.#inMemoryArtifactCounter = previous.inMemoryArtifactCounter;
|
|
5610
|
+
this.#artifactManager = previous.artifactManager;
|
|
5611
|
+
this.#artifactManagerSessionFile = previous.artifactManagerSessionFile;
|
|
5612
|
+
this.#adoptedArtifactManager = previous.adoptedArtifactManager;
|
|
5613
|
+
prepared.dispose();
|
|
5614
|
+
throw error;
|
|
5615
|
+
}
|
|
5616
|
+
managedTransition?.settle();
|
|
5617
|
+
this.#pendingStrictAdoption = undefined;
|
|
4694
5618
|
await this.#rewriteFile();
|
|
4695
5619
|
this.#flushed = true;
|
|
4696
5620
|
this.#ensuredOnDisk = true;
|
|
4697
|
-
|
|
4698
|
-
|
|
5621
|
+
} catch (error) {
|
|
5622
|
+
managedTransition?.dispose();
|
|
5623
|
+
throw error;
|
|
4699
5624
|
}
|
|
4700
5625
|
}
|
|
4701
5626
|
|
|
@@ -5092,10 +6017,29 @@ export class SessionManager {
|
|
|
5092
6017
|
const index = stage.index;
|
|
5093
6018
|
if (!residentFileEntries || !residentTextBlobStore || !index)
|
|
5094
6019
|
throw new Error("Prepared session adoption is incomplete.");
|
|
5095
|
-
|
|
5096
|
-
|
|
5097
|
-
|
|
5098
|
-
|
|
6020
|
+
const transition = this.#prepareResidentTextStoreTransition(
|
|
6021
|
+
{
|
|
6022
|
+
target: { sessionId: stage.sessionId, sessionFile: stage.sessionFile ?? "" },
|
|
6023
|
+
primary: {
|
|
6024
|
+
mode: "materialize",
|
|
6025
|
+
sourceEntries: stage.fileEntries,
|
|
6026
|
+
sourceStores: { textStore: residentTextBlobStore, imageStore: this.#residentImageBlobStore },
|
|
6027
|
+
},
|
|
6028
|
+
fallback: {
|
|
6029
|
+
mode: "adopt-staged",
|
|
6030
|
+
stagedEntries: residentFileEntries,
|
|
6031
|
+
stagedStore: residentTextBlobStore,
|
|
6032
|
+
stagedIndex: index,
|
|
6033
|
+
},
|
|
6034
|
+
},
|
|
6035
|
+
"install-staged",
|
|
6036
|
+
);
|
|
6037
|
+
try {
|
|
6038
|
+
if (stage.sessionFile) writeTerminalBreadcrumb(this.cwd, stage.sessionFile);
|
|
6039
|
+
} catch (error) {
|
|
6040
|
+
transition.dispose();
|
|
6041
|
+
throw error;
|
|
6042
|
+
}
|
|
5099
6043
|
if (!this.#lifecycleIdAdopted && stage.sessionId === lifecyclePreallocatedSessionId())
|
|
5100
6044
|
this.#lifecycleIdAdopted = true;
|
|
5101
6045
|
this.#persistChain = Promise.resolve();
|
|
@@ -5105,12 +6049,6 @@ export class SessionManager {
|
|
|
5105
6049
|
this.#sessionName = stage.sessionName;
|
|
5106
6050
|
this.#titleSource = stage.titleSource;
|
|
5107
6051
|
this.#sessionFile = stage.sessionFile;
|
|
5108
|
-
this.#fileEntries = residentFileEntries;
|
|
5109
|
-
this.#residentTextBlobStore = residentTextBlobStore;
|
|
5110
|
-
this.#byId = index.byId;
|
|
5111
|
-
this.#labelsById = index.labelsById;
|
|
5112
|
-
this.#leafId = index.leafId;
|
|
5113
|
-
this.#usageStatistics = index.usageStatistics;
|
|
5114
6052
|
this.#flushed = stage.flushed;
|
|
5115
6053
|
this.#needsFullRewriteOnNextPersist = false;
|
|
5116
6054
|
this.#ensuredOnDisk = stage.flushed;
|
|
@@ -5119,7 +6057,9 @@ export class SessionManager {
|
|
|
5119
6057
|
this.#artifactManager = null;
|
|
5120
6058
|
this.#artifactManagerSessionFile = null;
|
|
5121
6059
|
this.#adoptedArtifactManager = null;
|
|
5122
|
-
this.#
|
|
6060
|
+
this.#commitResidentTextStoreTransition(transition);
|
|
6061
|
+
stage.committed = true;
|
|
6062
|
+
this.#preparedNewSessions.delete(stage);
|
|
5123
6063
|
}
|
|
5124
6064
|
|
|
5125
6065
|
/** Exact-discard only an uncommitted successor prepared by this manager. @internal */
|
|
@@ -5234,9 +6174,9 @@ export class SessionManager {
|
|
|
5234
6174
|
* managed logical authorization. This is only for discarding an UNCOMMITTED
|
|
5235
6175
|
* successor that a transaction (e.g. handoff) created via `newSession()` but
|
|
5236
6176
|
* never durably authorized — such a session has no managed candidate listing
|
|
5237
|
-
* yet, so `dropSession` would refuse it and leak its
|
|
5238
|
-
*
|
|
5239
|
-
*
|
|
6177
|
+
* yet, so `dropSession` would refuse it and leak its artifact root. Bounded to
|
|
6178
|
+
* this manager's configured session root and refuses to touch the active session;
|
|
6179
|
+
* tolerates missing files.
|
|
5240
6180
|
*/
|
|
5241
6181
|
async discardUncommittedSession(sessionPath: string): Promise<void> {
|
|
5242
6182
|
// Close the writer FIRST so the canonical checks and the delete run with no
|
|
@@ -5275,110 +6215,154 @@ export class SessionManager {
|
|
|
5275
6215
|
* @returns { oldSessionFile, newSessionFile } or undefined if not persisting
|
|
5276
6216
|
*/
|
|
5277
6217
|
async fork(): Promise<{ oldSessionFile: string; newSessionFile: string } | undefined> {
|
|
5278
|
-
if (!this.persist || !this.#sessionFile)
|
|
5279
|
-
return undefined;
|
|
5280
|
-
}
|
|
6218
|
+
if (!this.persist || !this.#sessionFile) return undefined;
|
|
5281
6219
|
|
|
5282
6220
|
const oldSessionFile = this.#sessionFile;
|
|
5283
6221
|
const oldSessionId = this.#sessionId;
|
|
5284
6222
|
const materializedEntries = materializeResidentEntriesForReadSync(this.#fileEntries, this.#residentBlobStores());
|
|
5285
|
-
const oldFileEntries = this.#fileEntries;
|
|
5286
|
-
const oldSessionName = this.#sessionName;
|
|
5287
|
-
const oldTitleSource = this.#titleSource;
|
|
5288
|
-
|
|
5289
|
-
// Close the current writer
|
|
5290
|
-
await this.#closePersistWriter();
|
|
5291
|
-
this.#persistChain = Promise.resolve();
|
|
5292
|
-
this.#persistError = undefined;
|
|
5293
|
-
this.#persistErrorReported = false;
|
|
5294
|
-
|
|
5295
|
-
// Create new session ID and header
|
|
5296
|
-
this.#sessionId = createSessionId();
|
|
5297
6223
|
const timestamp = new Date().toISOString();
|
|
5298
|
-
const
|
|
5299
|
-
|
|
5300
|
-
|
|
5301
|
-
|
|
5302
|
-
|
|
6224
|
+
const newSessionId = createSessionId();
|
|
6225
|
+
const newSessionFile = path.join(
|
|
6226
|
+
this.getSessionDir(),
|
|
6227
|
+
`${timestamp.replace(/[:.]/g, "-")}_${newSessionId}.jsonl`,
|
|
6228
|
+
);
|
|
6229
|
+
const oldHeader = this.#fileEntries.find(entry => entry.type === "session") as SessionHeader | undefined;
|
|
5303
6230
|
const newHeader: SessionHeader = {
|
|
5304
6231
|
type: "session",
|
|
5305
6232
|
version: CURRENT_SESSION_VERSION,
|
|
5306
|
-
id:
|
|
6233
|
+
id: newSessionId,
|
|
5307
6234
|
title: oldHeader?.title ?? this.#sessionName,
|
|
5308
6235
|
titleSource: oldHeader?.titleSource ?? this.#titleSource,
|
|
5309
6236
|
timestamp,
|
|
5310
6237
|
cwd: this.cwd,
|
|
5311
6238
|
parentSession: oldSessionId,
|
|
5312
6239
|
};
|
|
5313
|
-
|
|
5314
|
-
|
|
5315
|
-
|
|
5316
|
-
|
|
5317
|
-
const entries = materializedEntries.filter((e): e is SessionEntry => e.type !== "session");
|
|
5318
|
-
this.#fileEntries = [newHeader, ...entries];
|
|
5319
|
-
this.#resetResidentTextBlobStore();
|
|
5320
|
-
this.#reexternalizeFileEntriesForResidentStore();
|
|
5321
|
-
this.#bumpAllRevisions();
|
|
6240
|
+
const entries: FileEntry[] = [
|
|
6241
|
+
newHeader,
|
|
6242
|
+
...materializedEntries.filter((entry): entry is SessionEntry => entry.type !== "session"),
|
|
6243
|
+
];
|
|
5322
6244
|
|
|
5323
|
-
|
|
5324
|
-
|
|
5325
|
-
this.#
|
|
6245
|
+
await this.#closePersistWriter();
|
|
6246
|
+
this.#persistChain = Promise.resolve();
|
|
6247
|
+
this.#persistError = undefined;
|
|
6248
|
+
this.#persistErrorReported = false;
|
|
5326
6249
|
let forkArtifactPublication: ForkArtifactPublication | undefined;
|
|
5327
|
-
let forkTranscriptPublication:
|
|
5328
|
-
let
|
|
6250
|
+
let forkTranscriptPublication: ForkTranscriptPublication | undefined;
|
|
6251
|
+
let transition: PreparedResidentStoreTransition | undefined;
|
|
5329
6252
|
try {
|
|
5330
|
-
forkArtifactPublication = await this.copyArtifactsForFork(oldSessionFile,
|
|
5331
|
-
|
|
6253
|
+
forkArtifactPublication = await this.copyArtifactsForFork(oldSessionFile, newSessionFile);
|
|
6254
|
+
const content = `${entries.map(entry => JSON.stringify(prepareEntryForPersistenceSync(entry, this.#blobStore))).join("\n")}\n`;
|
|
6255
|
+
const contentBytes = Buffer.from(content, "utf8");
|
|
5332
6256
|
if (this.destination.kind === "managed") {
|
|
5333
|
-
|
|
5334
|
-
|
|
5335
|
-
|
|
6257
|
+
const store = this.#managedTranscriptStore(newSessionFile);
|
|
6258
|
+
const relativePath = path.basename(newSessionFile);
|
|
6259
|
+
await store.publishNoReplace(relativePath, contentBytes);
|
|
6260
|
+
const publication = { kind: "managed" as const, store, relativePath, publishedBytes: contentBytes };
|
|
6261
|
+
forkTranscriptPublication = publication;
|
|
6262
|
+
const snapshot = store.readExpected(relativePath);
|
|
6263
|
+
if (!snapshot) throw new Error("managed_fork_transcript_publish_missing");
|
|
6264
|
+
forkTranscriptPublication = { ...publication, snapshot };
|
|
6265
|
+
} else if (this.storage instanceof FileSessionStorage) {
|
|
6266
|
+
await writeOwnerOnlyFileNoReplace(newSessionFile, content);
|
|
6267
|
+
const publication = {
|
|
6268
|
+
kind: "explicit-file" as const,
|
|
6269
|
+
sessionFile: newSessionFile,
|
|
6270
|
+
publishedBytes: contentBytes,
|
|
6271
|
+
};
|
|
6272
|
+
forkTranscriptPublication = publication;
|
|
6273
|
+
forkTranscriptPublication = { ...publication, snapshot: captureManagedFileNoFollow(newSessionFile) };
|
|
6274
|
+
} else {
|
|
6275
|
+
if (this.storage.existsSync(newSessionFile)) throw new Error("destination_conflict");
|
|
6276
|
+
await this.storage.writeText(newSessionFile, content);
|
|
6277
|
+
const publication = {
|
|
6278
|
+
kind: "explicit-storage" as const,
|
|
6279
|
+
sessionFile: newSessionFile,
|
|
6280
|
+
publishedBytes: contentBytes,
|
|
6281
|
+
};
|
|
6282
|
+
forkTranscriptPublication = publication;
|
|
6283
|
+
const snapshot = this.storage.readSnapshotSync?.(newSessionFile);
|
|
6284
|
+
if (!snapshot) throw new Error("fork_transcript_identity_unavailable");
|
|
6285
|
+
forkTranscriptPublication = { ...publication, snapshot };
|
|
5336
6286
|
}
|
|
5337
|
-
forkArtifactPublication?.
|
|
5338
|
-
|
|
5339
|
-
|
|
6287
|
+
if (forkArtifactPublication?.kind === "managed") {
|
|
6288
|
+
forkArtifactPublication.store.verifyRootSecurity();
|
|
6289
|
+
forkArtifactPublication.store.assertBound();
|
|
5340
6290
|
const adoptedSnapshot = forkArtifactPublication.store.fsyncTree();
|
|
5341
|
-
if (!retainedTreeSnapshotEquals(adoptedSnapshot, forkArtifactPublication.snapshot))
|
|
6291
|
+
if (!retainedTreeSnapshotEquals(adoptedSnapshot, forkArtifactPublication.snapshot))
|
|
6292
|
+
throw new Error("artifact_destination_changed_during_transcript_publication");
|
|
6293
|
+
} else if (forkArtifactPublication) {
|
|
6294
|
+
const terminalArtifacts = native.snapshotDirectoryTree(forkArtifactPublication.artifactsDir);
|
|
6295
|
+
if (
|
|
6296
|
+
!terminalArtifacts.ok ||
|
|
6297
|
+
!terminalArtifacts.snapshot ||
|
|
6298
|
+
!retainedTreeSnapshotEquals(terminalArtifacts.snapshot, forkArtifactPublication.snapshot)
|
|
6299
|
+
) {
|
|
5342
6300
|
throw new Error("artifact_destination_changed_during_transcript_publication");
|
|
5343
6301
|
}
|
|
5344
6302
|
}
|
|
5345
|
-
|
|
5346
|
-
|
|
5347
|
-
|
|
6303
|
+
|
|
6304
|
+
if (forkTranscriptPublication?.kind === "managed") {
|
|
6305
|
+
const snapshot = forkTranscriptPublication.snapshot;
|
|
6306
|
+
const terminalTranscript = forkTranscriptPublication.store.readExpected(
|
|
6307
|
+
forkTranscriptPublication.relativePath,
|
|
6308
|
+
);
|
|
6309
|
+
if (
|
|
6310
|
+
!snapshot?.bytes.equals(forkTranscriptPublication.publishedBytes) ||
|
|
6311
|
+
!managedFileSnapshotEquals(terminalTranscript, snapshot ?? null)
|
|
6312
|
+
) {
|
|
5348
6313
|
throw new Error("managed_fork_transcript_changed");
|
|
6314
|
+
}
|
|
5349
6315
|
}
|
|
6316
|
+
transition = this.#prepareResidentTextStoreTransition(
|
|
6317
|
+
{
|
|
6318
|
+
target: { sessionId: newSessionId, sessionFile: newSessionFile },
|
|
6319
|
+
primary: {
|
|
6320
|
+
mode: "materialize",
|
|
6321
|
+
sourceEntries: entries,
|
|
6322
|
+
sourceStores: { textStore: null, imageStore: this.#residentImageBlobStore },
|
|
6323
|
+
},
|
|
6324
|
+
},
|
|
6325
|
+
"retain-and-throw",
|
|
6326
|
+
);
|
|
5350
6327
|
} catch (error) {
|
|
5351
|
-
|
|
6328
|
+
transition?.dispose();
|
|
6329
|
+
const failure = toError(error);
|
|
6330
|
+
const cleanupErrors: Error[] = [];
|
|
6331
|
+
if (forkTranscriptPublication) {
|
|
6332
|
+
try {
|
|
6333
|
+
await this.#cleanupForkTranscriptPublication(forkTranscriptPublication);
|
|
6334
|
+
} catch (cleanupError) {
|
|
6335
|
+
cleanupErrors.push(toError(cleanupError));
|
|
6336
|
+
}
|
|
6337
|
+
}
|
|
5352
6338
|
if (forkArtifactPublication) {
|
|
5353
6339
|
try {
|
|
5354
|
-
forkArtifactPublication
|
|
5355
|
-
forkArtifactPublication.cleanupRelativePath,
|
|
5356
|
-
forkArtifactPublication.snapshot,
|
|
5357
|
-
);
|
|
6340
|
+
this.#cleanupForkArtifactPublication(forkArtifactPublication);
|
|
5358
6341
|
} catch (cleanupError) {
|
|
5359
|
-
|
|
5360
|
-
if (cleanupMessage !== "cleanup_pending") {
|
|
5361
|
-
throw new Error(`Failed to clean up fork artifacts: ${cleanupMessage}`, {
|
|
5362
|
-
cause: toError(failure),
|
|
5363
|
-
});
|
|
5364
|
-
}
|
|
6342
|
+
cleanupErrors.push(toError(cleanupError));
|
|
5365
6343
|
}
|
|
5366
6344
|
}
|
|
5367
|
-
|
|
5368
|
-
|
|
5369
|
-
|
|
5370
|
-
|
|
5371
|
-
|
|
5372
|
-
|
|
5373
|
-
|
|
5374
|
-
|
|
5375
|
-
|
|
5376
|
-
|
|
5377
|
-
this.#bumpAllRevisions();
|
|
6345
|
+
// A POSIX quarantine (`cleanup_pending` with a retained recovery path) IS a
|
|
6346
|
+
// successful cleanup: the tree was detached to `<name>.removing` and no live
|
|
6347
|
+
// artifact survives. Only an independently real cleanup failure may supersede
|
|
6348
|
+
// the primary error; otherwise the original failure must reach the caller.
|
|
6349
|
+
const realCleanupErrors = cleanupErrors.filter(cleanupError => !isAuthorizedPendingCleanup(cleanupError));
|
|
6350
|
+
if (realCleanupErrors.length > 0) {
|
|
6351
|
+
throw new Error(`Failed to clean up fork publication: ${realCleanupErrors[0]!.message}`, {
|
|
6352
|
+
cause: failure,
|
|
6353
|
+
});
|
|
6354
|
+
}
|
|
5378
6355
|
throw failure;
|
|
5379
6356
|
}
|
|
5380
|
-
|
|
5381
|
-
|
|
6357
|
+
this.#sessionId = newSessionId;
|
|
6358
|
+
this.#sessionFile = newSessionFile;
|
|
6359
|
+
this.#sessionName = newHeader.title;
|
|
6360
|
+
this.#titleSource = newHeader.titleSource;
|
|
6361
|
+
this.#flushed = true;
|
|
6362
|
+
this.#needsFullRewriteOnNextPersist = false;
|
|
6363
|
+
this.#ensuredOnDisk = true;
|
|
6364
|
+
this.#commitResidentTextStoreTransition(transition);
|
|
6365
|
+
return { oldSessionFile, newSessionFile };
|
|
5382
6366
|
}
|
|
5383
6367
|
|
|
5384
6368
|
async copyArtifactsForFork(
|
|
@@ -5388,15 +6372,98 @@ export class SessionManager {
|
|
|
5388
6372
|
const sourceDir = oldSessionFile.slice(0, -6);
|
|
5389
6373
|
const finalDestinationDir = newSessionFile.slice(0, -6);
|
|
5390
6374
|
if (this.destination.kind !== "managed") {
|
|
6375
|
+
let sourceSnapshot: native.NativeDirectoryTreeSnapshot;
|
|
5391
6376
|
try {
|
|
5392
6377
|
const sourceStat = fs.lstatSync(sourceDir);
|
|
5393
6378
|
if (!sourceStat.isDirectory() || sourceStat.isSymbolicLink()) throw new Error("unsafe_artifacts");
|
|
6379
|
+
const captured = native.snapshotDirectoryTree(sourceDir);
|
|
6380
|
+
if (!captured.ok || !captured.snapshot) throw new Error(captured.code ?? "unsafe_artifacts");
|
|
6381
|
+
sourceSnapshot = captured.snapshot;
|
|
5394
6382
|
} catch (error) {
|
|
5395
6383
|
if (isEnoent(error)) return;
|
|
5396
6384
|
throw error;
|
|
5397
6385
|
}
|
|
5398
|
-
|
|
5399
|
-
|
|
6386
|
+
try {
|
|
6387
|
+
fs.lstatSync(finalDestinationDir);
|
|
6388
|
+
throw new Error("destination_conflict");
|
|
6389
|
+
} catch (error) {
|
|
6390
|
+
if (error instanceof Error && error.message === "destination_conflict") throw error;
|
|
6391
|
+
if (!isEnoent(error)) throw error;
|
|
6392
|
+
}
|
|
6393
|
+
const expectedManifest = pruneResidentCacheEntries(sourceSnapshot);
|
|
6394
|
+
const parentDir = path.dirname(finalDestinationDir);
|
|
6395
|
+
const stagingDir = path.join(
|
|
6396
|
+
parentDir,
|
|
6397
|
+
`.${path.basename(finalDestinationDir)}.${crypto.randomUUID()}.fork-staging`,
|
|
6398
|
+
);
|
|
6399
|
+
// Establish deletion authority BEFORE any content exists: we create this root
|
|
6400
|
+
// ourselves under an unguessable name and capture its identity while empty.
|
|
6401
|
+
// Only a root whose identity still matches this capture may ever be removed.
|
|
6402
|
+
fs.mkdirSync(stagingDir, { mode: 0o700 });
|
|
6403
|
+
const ownedStaging = native.snapshotDirectoryTree(stagingDir);
|
|
6404
|
+
if (!ownedStaging.ok || !ownedStaging.snapshot) {
|
|
6405
|
+
throw new Error(ownedStaging.code ?? "artifact_staging_snapshot_failed");
|
|
6406
|
+
}
|
|
6407
|
+
const ownedStagingRoot = ownedStaging.snapshot;
|
|
6408
|
+
let published = false;
|
|
6409
|
+
try {
|
|
6410
|
+
await fs.promises.cp(sourceDir, stagingDir, {
|
|
6411
|
+
recursive: true,
|
|
6412
|
+
filter: source => {
|
|
6413
|
+
const relativePath = path.relative(sourceDir, source);
|
|
6414
|
+
return relativePath !== "resident-cache" && !relativePath.startsWith(`resident-cache${path.sep}`);
|
|
6415
|
+
},
|
|
6416
|
+
});
|
|
6417
|
+
const capturedStaging = native.snapshotDirectoryTree(stagingDir);
|
|
6418
|
+
if (!capturedStaging.ok || !capturedStaging.snapshot)
|
|
6419
|
+
throw new Error(capturedStaging.code ?? "artifact_destination_snapshot_failed");
|
|
6420
|
+
const stagedSnapshot = capturedStaging.snapshot;
|
|
6421
|
+
if (
|
|
6422
|
+
stagedSnapshot.rootDev !== ownedStagingRoot.rootDev ||
|
|
6423
|
+
stagedSnapshot.rootIno !== ownedStagingRoot.rootIno
|
|
6424
|
+
)
|
|
6425
|
+
throw new Error("artifact_staging_identity_changed");
|
|
6426
|
+
const terminalSource = native.snapshotDirectoryTree(sourceDir);
|
|
6427
|
+
if (!terminalSource.ok || !terminalSource.snapshot)
|
|
6428
|
+
throw new Error(terminalSource.code ?? "artifact_source_changed");
|
|
6429
|
+
if (JSON.stringify(terminalSource.snapshot) !== JSON.stringify(sourceSnapshot))
|
|
6430
|
+
throw new Error("artifact_source_changed");
|
|
6431
|
+
const comparable = (tree: native.NativeDirectoryTreeSnapshot) =>
|
|
6432
|
+
tree.entries.map(entry => ({
|
|
6433
|
+
relativePath: entry.relativePath,
|
|
6434
|
+
kind: entry.kind,
|
|
6435
|
+
size: entry.kind === "file" ? entry.size : undefined,
|
|
6436
|
+
sha256: entry.sha256,
|
|
6437
|
+
}));
|
|
6438
|
+
if (JSON.stringify(comparable(stagedSnapshot)) !== JSON.stringify(comparable(expectedManifest)))
|
|
6439
|
+
throw new Error("artifact_destination_terminal_mismatch");
|
|
6440
|
+
// No-replace publication: a directory that appeared at the final name after the
|
|
6441
|
+
// preflight is never replaced and never touched.
|
|
6442
|
+
const outcome = classifyNativePublishOutcome(native.renameNoReplacePath(stagingDir, finalDestinationDir));
|
|
6443
|
+
if (!outcome.ok) {
|
|
6444
|
+
if (outcome.reason === "destination_exists") throw new Error("destination_conflict");
|
|
6445
|
+
throw new Error(outcome.code ?? "artifact_destination_publish_failed");
|
|
6446
|
+
}
|
|
6447
|
+
published = true;
|
|
6448
|
+
const terminal = native.snapshotDirectoryTree(finalDestinationDir);
|
|
6449
|
+
if (
|
|
6450
|
+
!terminal.ok ||
|
|
6451
|
+
!terminal.snapshot ||
|
|
6452
|
+
!retainedTreeSnapshotEqualsAfterRename(terminal.snapshot, stagedSnapshot)
|
|
6453
|
+
)
|
|
6454
|
+
throw new Error("artifact_destination_terminal_mismatch");
|
|
6455
|
+
return { kind: "explicit", artifactsDir: finalDestinationDir, snapshot: terminal.snapshot };
|
|
6456
|
+
} catch (error) {
|
|
6457
|
+
if (!published) {
|
|
6458
|
+
const cleanupError = removeOwnedForkStaging(stagingDir, ownedStagingRoot);
|
|
6459
|
+
if (cleanupError) {
|
|
6460
|
+
throw new Error(`Failed to clean up explicit fork artifacts: ${cleanupError}`, {
|
|
6461
|
+
cause: toError(error),
|
|
6462
|
+
});
|
|
6463
|
+
}
|
|
6464
|
+
}
|
|
6465
|
+
throw error;
|
|
6466
|
+
}
|
|
5400
6467
|
}
|
|
5401
6468
|
|
|
5402
6469
|
const sourceName = path.basename(sourceDir);
|
|
@@ -5410,12 +6477,13 @@ export class SessionManager {
|
|
|
5410
6477
|
if (error instanceof Error && error.message === "not_found") return;
|
|
5411
6478
|
throw error;
|
|
5412
6479
|
}
|
|
6480
|
+
const expectedManifest = pruneResidentCacheEntries(sourceSnapshot);
|
|
5413
6481
|
let stagingSnapshot: native.NativeDirectoryTreeSnapshot | undefined;
|
|
5414
6482
|
let publishedSnapshot: native.NativeDirectoryTreeSnapshot | undefined;
|
|
5415
6483
|
try {
|
|
5416
6484
|
parentStore.ensureDirectory(stagingName);
|
|
5417
6485
|
stagingSnapshot = parentStore.captureTree(stagingName);
|
|
5418
|
-
for (const entry of
|
|
6486
|
+
for (const entry of expectedManifest.entries) {
|
|
5419
6487
|
if (entry.relativePath === "") continue;
|
|
5420
6488
|
const destinationRelativePath = path.posix.join(stagingName, entry.relativePath);
|
|
5421
6489
|
if (entry.kind === "directory") {
|
|
@@ -5448,7 +6516,7 @@ export class SessionManager {
|
|
|
5448
6516
|
size: entry.kind === "file" ? entry.size : undefined,
|
|
5449
6517
|
sha256: entry.sha256,
|
|
5450
6518
|
}));
|
|
5451
|
-
if (JSON.stringify(comparable(publishedSnapshot)) !== JSON.stringify(comparable(
|
|
6519
|
+
if (JSON.stringify(comparable(publishedSnapshot)) !== JSON.stringify(comparable(expectedManifest)))
|
|
5452
6520
|
throw new Error("artifact_destination_terminal_mismatch");
|
|
5453
6521
|
const publishedStore = parentStore.deriveSubtree(destinationName);
|
|
5454
6522
|
publishedStore.verifyRootSecurity();
|
|
@@ -5457,6 +6525,7 @@ export class SessionManager {
|
|
|
5457
6525
|
throw new Error("artifact_destination_retained_identity_changed");
|
|
5458
6526
|
publishedSnapshot = retainedPublishedSnapshot;
|
|
5459
6527
|
return {
|
|
6528
|
+
kind: "managed",
|
|
5460
6529
|
snapshot: publishedSnapshot,
|
|
5461
6530
|
store: publishedStore,
|
|
5462
6531
|
cleanupStore: parentStore,
|
|
@@ -5496,7 +6565,7 @@ export class SessionManager {
|
|
|
5496
6565
|
? managedDestination(
|
|
5497
6566
|
resolvedCwd,
|
|
5498
6567
|
this.storage,
|
|
5499
|
-
this.destination.kind === "managed" ? this.destination.securityContext.
|
|
6568
|
+
this.destination.kind === "managed" ? this.destination.securityContext.profileAgentDir : undefined,
|
|
5500
6569
|
)
|
|
5501
6570
|
: this.destination;
|
|
5502
6571
|
const newSessionDir = nextDestination.directory;
|
|
@@ -5505,6 +6574,7 @@ export class SessionManager {
|
|
|
5505
6574
|
let managedSourceStore: ManagedSessionDescendantStore | undefined;
|
|
5506
6575
|
let managedDestinationStore: ManagedSessionDescendantStore | undefined;
|
|
5507
6576
|
let rollbackManagedMove: (() => Promise<void>) | undefined;
|
|
6577
|
+
let residentTransition: PreparedResidentStoreTransition | undefined;
|
|
5508
6578
|
|
|
5509
6579
|
if (this.persist && this.#sessionFile) {
|
|
5510
6580
|
// Close the persist writer before moving files
|
|
@@ -5528,20 +6598,22 @@ export class SessionManager {
|
|
|
5528
6598
|
this.#fileEntries,
|
|
5529
6599
|
this.#residentBlobStores(),
|
|
5530
6600
|
);
|
|
5531
|
-
const
|
|
5532
|
-
|
|
5533
|
-
|
|
5534
|
-
|
|
5535
|
-
|
|
5536
|
-
|
|
5537
|
-
|
|
5538
|
-
|
|
5539
|
-
|
|
5540
|
-
|
|
5541
|
-
|
|
5542
|
-
|
|
5543
|
-
|
|
5544
|
-
|
|
6601
|
+
const transitionEntries: FileEntry[] = materializedEntries.map(entry =>
|
|
6602
|
+
entry.type === "session" ? { ...entry, cwd: resolvedCwd } : entry,
|
|
6603
|
+
);
|
|
6604
|
+
residentTransition = this.#prepareResidentTextStoreTransition(
|
|
6605
|
+
{
|
|
6606
|
+
target: { sessionId: this.#sessionId, sessionFile: newSessionFile },
|
|
6607
|
+
primary: {
|
|
6608
|
+
mode: "materialize",
|
|
6609
|
+
sourceEntries: transitionEntries,
|
|
6610
|
+
sourceStores: { textStore: null, imageStore: this.#residentImageBlobStore },
|
|
6611
|
+
},
|
|
6612
|
+
},
|
|
6613
|
+
"retain-and-throw",
|
|
6614
|
+
);
|
|
6615
|
+
const discardResidentTransitionAndThrow = (error: unknown): never => {
|
|
6616
|
+
residentTransition?.dispose();
|
|
5545
6617
|
throw error;
|
|
5546
6618
|
};
|
|
5547
6619
|
const copyManagedTree = async (
|
|
@@ -5582,7 +6654,6 @@ export class SessionManager {
|
|
|
5582
6654
|
throw new Error("artifact_destination_mismatch");
|
|
5583
6655
|
destination.fsyncTree();
|
|
5584
6656
|
};
|
|
5585
|
-
this.#disposeResidentTextBlobStore();
|
|
5586
6657
|
try {
|
|
5587
6658
|
if (managedMove) {
|
|
5588
6659
|
if (previousDestination.kind !== "managed" || nextDestination.kind !== "managed")
|
|
@@ -5709,7 +6780,7 @@ export class SessionManager {
|
|
|
5709
6780
|
)
|
|
5710
6781
|
await managedSourceStore.publishNoReplace(path.basename(oldSessionFile), managedTranscript.bytes);
|
|
5711
6782
|
} catch (rollbackErr) {
|
|
5712
|
-
|
|
6783
|
+
discardResidentTransitionAndThrow(
|
|
5713
6784
|
new Error(`Failed to rollback managed move: ${toError(rollbackErr).message}`, {
|
|
5714
6785
|
cause: toError(err),
|
|
5715
6786
|
}),
|
|
@@ -5719,13 +6790,9 @@ export class SessionManager {
|
|
|
5719
6790
|
if (movedArtifactDir) await movePathAcrossDevicesSafe(newArtifactDir, oldArtifactDir);
|
|
5720
6791
|
if (movedSessionFile) await movePathAcrossDevicesSafe(newSessionFile, oldSessionFile);
|
|
5721
6792
|
}
|
|
5722
|
-
|
|
6793
|
+
discardResidentTransitionAndThrow(err);
|
|
5723
6794
|
}
|
|
5724
6795
|
this.#sessionFile = newSessionFile;
|
|
5725
|
-
this.#fileEntries = materializedEntries;
|
|
5726
|
-
this.#resetResidentTextBlobStore();
|
|
5727
|
-
this.#reexternalizeFileEntriesForResidentStore();
|
|
5728
|
-
this.#bumpAllRevisions();
|
|
5729
6796
|
}
|
|
5730
6797
|
|
|
5731
6798
|
// Update cwd and sessionDir after physical publication succeeds. Metadata failures restore the source
|
|
@@ -5750,6 +6817,7 @@ export class SessionManager {
|
|
|
5750
6817
|
await this.#appendHeaderPatch({ cwd: resolvedCwd });
|
|
5751
6818
|
}
|
|
5752
6819
|
} catch (error) {
|
|
6820
|
+
residentTransition?.dispose();
|
|
5753
6821
|
await this.#closePersistWriter().catch(() => {});
|
|
5754
6822
|
this.#persistChain = Promise.resolve();
|
|
5755
6823
|
this.#persistError = undefined;
|
|
@@ -5783,6 +6851,7 @@ export class SessionManager {
|
|
|
5783
6851
|
this.#headerExportRevision++;
|
|
5784
6852
|
throw error;
|
|
5785
6853
|
}
|
|
6854
|
+
if (residentTransition) this.#commitResidentTextStoreTransition(residentTransition);
|
|
5786
6855
|
|
|
5787
6856
|
// Update terminal breadcrumb only after the durable cwd transition succeeds.
|
|
5788
6857
|
if (this.#sessionFile) {
|
|
@@ -5790,57 +6859,14 @@ export class SessionManager {
|
|
|
5790
6859
|
}
|
|
5791
6860
|
}
|
|
5792
6861
|
|
|
5793
|
-
/** Sync version for initial creation (no existing writer to close) */
|
|
6862
|
+
/** Sync version for initial creation (no existing writer to close). */
|
|
5794
6863
|
#newSessionSync(options?: NewSessionOptions, writeBreadcrumb = true): string | undefined {
|
|
5795
|
-
|
|
5796
|
-
this.#
|
|
5797
|
-
this.#
|
|
5798
|
-
|
|
5799
|
-
|
|
5800
|
-
|
|
5801
|
-
const preallocated = this.#lifecycleIdAdopted ? undefined : lifecyclePreallocatedSessionId();
|
|
5802
|
-
if (preallocated) this.#lifecycleIdAdopted = true;
|
|
5803
|
-
this.#sessionId = preallocated ?? createSessionId();
|
|
5804
|
-
this.#sessionName = undefined;
|
|
5805
|
-
this.#titleSource = undefined;
|
|
5806
|
-
const timestamp = new Date().toISOString();
|
|
5807
|
-
const header: SessionHeader = {
|
|
5808
|
-
type: "session",
|
|
5809
|
-
version: CURRENT_SESSION_VERSION,
|
|
5810
|
-
id: this.#sessionId,
|
|
5811
|
-
timestamp,
|
|
5812
|
-
cwd: this.cwd,
|
|
5813
|
-
parentSession: options?.parentSession,
|
|
5814
|
-
};
|
|
5815
|
-
this.#fileEntries = [header];
|
|
5816
|
-
this.#byId.clear();
|
|
5817
|
-
this.#labelsById.clear();
|
|
5818
|
-
this.#leafId = null;
|
|
5819
|
-
this.#flushed = false;
|
|
5820
|
-
this.#needsFullRewriteOnNextPersist = false;
|
|
5821
|
-
this.#ensuredOnDisk = false;
|
|
5822
|
-
this.#usageStatistics = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, premiumRequests: 0, cost: 0 };
|
|
5823
|
-
this.#inMemoryArtifacts = null;
|
|
5824
|
-
this.#inMemoryArtifactCounter = 0;
|
|
5825
|
-
this.#artifactManager = null;
|
|
5826
|
-
this.#artifactManagerSessionFile = null;
|
|
5827
|
-
this.#adoptedArtifactManager = null;
|
|
5828
|
-
|
|
5829
|
-
if (this.persist) {
|
|
5830
|
-
const fileTimestamp = timestamp.replace(/[:.]/g, "-");
|
|
5831
|
-
this.#sessionFile = path.join(this.getSessionDir(), `${fileTimestamp}_${this.#sessionId}.jsonl`);
|
|
5832
|
-
if (writeBreadcrumb) writeTerminalBreadcrumb(this.cwd, this.#sessionFile);
|
|
5833
|
-
}
|
|
5834
|
-
this.#resetResidentTextBlobStore();
|
|
5835
|
-
return this.#sessionFile;
|
|
5836
|
-
}
|
|
5837
|
-
|
|
5838
|
-
#buildIndex(): void {
|
|
5839
|
-
const index = this.#buildIndexForEntries(this.#fileEntries, this.#sessionFile);
|
|
5840
|
-
this.#byId = index.byId;
|
|
5841
|
-
this.#labelsById = index.labelsById;
|
|
5842
|
-
this.#leafId = index.leafId;
|
|
5843
|
-
this.#usageStatistics = index.usageStatistics;
|
|
6864
|
+
const fresh = this.#freshSessionState(options);
|
|
6865
|
+
const prepared = this.#prepareFreshSessionTransition(fresh, "memory-fallback");
|
|
6866
|
+
this.#applyFreshSessionMetadata(fresh);
|
|
6867
|
+
this.#commitResidentTextStoreTransition(prepared);
|
|
6868
|
+
if (writeBreadcrumb && fresh.sessionFile) writeTerminalBreadcrumb(this.cwd, fresh.sessionFile);
|
|
6869
|
+
return fresh.sessionFile;
|
|
5844
6870
|
}
|
|
5845
6871
|
|
|
5846
6872
|
#buildIndexForEntries(fileEntries: FileEntry[], sessionFile: string | undefined): PreparedSessionIndex {
|
|
@@ -6156,6 +7182,82 @@ export class SessionManager {
|
|
|
6156
7182
|
}
|
|
6157
7183
|
}
|
|
6158
7184
|
|
|
7185
|
+
/**
|
|
7186
|
+
* Verify a foreign managed candidate directory under this manager's own root and
|
|
7187
|
+
* prepare an owned destination rebind for it. Returns undefined when the candidate
|
|
7188
|
+
* is this manager's current managed directory (nothing to rebind).
|
|
7189
|
+
*/
|
|
7190
|
+
#prepareManagedDestinationTransition(candidateDirectory: string): ManagedDestinationTransition | undefined {
|
|
7191
|
+
if (this.destination.kind !== "managed") throw new Error("Managed transcript authority is unavailable");
|
|
7192
|
+
const current = this.destination;
|
|
7193
|
+
if (path.resolve(candidateDirectory) === path.resolve(current.directory)) return undefined;
|
|
7194
|
+
// Independent verification: the non-retained constructor applies managedRelativePath
|
|
7195
|
+
// containment, assertManagedDirectoryRoot, full-chain ensureManagedDirectory owner-only
|
|
7196
|
+
// ACL verification, and a fresh fd identity binding on linux.
|
|
7197
|
+
const store = managedStoreFromContext(current.securityContext, candidateDirectory);
|
|
7198
|
+
let adopted = false;
|
|
7199
|
+
try {
|
|
7200
|
+
store.verifyRootSecurity();
|
|
7201
|
+
store.assertBound();
|
|
7202
|
+
} catch (error) {
|
|
7203
|
+
store.close();
|
|
7204
|
+
throw error;
|
|
7205
|
+
}
|
|
7206
|
+
const securityContext = createManagedSessionSecurityContext({
|
|
7207
|
+
agentDir: current.securityContext.agentDir,
|
|
7208
|
+
profileAgentDir: current.securityContext.profileAgentDir,
|
|
7209
|
+
sessionsRoot: current.securityContext.sessionsRoot,
|
|
7210
|
+
sessionDir: candidateDirectory,
|
|
7211
|
+
rootAuthority: current.securityContext.rootAuthority,
|
|
7212
|
+
// The candidate store owns its own authority; the context must not hand out a
|
|
7213
|
+
// borrowed retained fd for a directory it was not retained for.
|
|
7214
|
+
retainedAuthority: undefined,
|
|
7215
|
+
});
|
|
7216
|
+
managedSecurityPolicies.set(securityContext, managedSecurityPolicyForContext(current.securityContext));
|
|
7217
|
+
const destination = Object.freeze({ kind: "managed" as const, directory: candidateDirectory, securityContext });
|
|
7218
|
+
trustedSessionDestinations.add(destination);
|
|
7219
|
+
const previousDestination = current;
|
|
7220
|
+
const previousCache = this.#managedTranscriptStoreCache;
|
|
7221
|
+
const previousOwned = this.#ownedManagedAuthority;
|
|
7222
|
+
return {
|
|
7223
|
+
directory: candidateDirectory,
|
|
7224
|
+
destination,
|
|
7225
|
+
store,
|
|
7226
|
+
adopt: () => {
|
|
7227
|
+
adopted = true;
|
|
7228
|
+
this.destination = destination;
|
|
7229
|
+
this.#managedTranscriptStoreCache = { directory: path.resolve(candidateDirectory), store };
|
|
7230
|
+
this.#ownedManagedAuthority = store;
|
|
7231
|
+
// A superseded authority is NOT closed here: rollback() may still have to
|
|
7232
|
+
// restore it. Closing at adoption time would hand a closed store back to
|
|
7233
|
+
// the manager when a later cross-workspace switch fails after adoption.
|
|
7234
|
+
},
|
|
7235
|
+
settle: () => {
|
|
7236
|
+
if (!adopted) return;
|
|
7237
|
+
// The transition can no longer be rolled back, so the superseded authority
|
|
7238
|
+
// is now unreachable. Release it to keep exactly one owned fd per manager.
|
|
7239
|
+
if (previousOwned && previousOwned !== store) previousOwned.close();
|
|
7240
|
+
},
|
|
7241
|
+
rollback: () => {
|
|
7242
|
+
if (!adopted) return;
|
|
7243
|
+
adopted = false;
|
|
7244
|
+
this.destination = previousDestination;
|
|
7245
|
+
this.#managedTranscriptStoreCache = previousCache;
|
|
7246
|
+
this.#ownedManagedAuthority = previousOwned;
|
|
7247
|
+
store.close();
|
|
7248
|
+
},
|
|
7249
|
+
dispose: () => {
|
|
7250
|
+
if (adopted) return;
|
|
7251
|
+
store.close();
|
|
7252
|
+
},
|
|
7253
|
+
};
|
|
7254
|
+
}
|
|
7255
|
+
|
|
7256
|
+
#releaseOwnedManagedAuthority(): void {
|
|
7257
|
+
this.#ownedManagedAuthority?.close();
|
|
7258
|
+
this.#ownedManagedAuthority = undefined;
|
|
7259
|
+
}
|
|
7260
|
+
|
|
6159
7261
|
#managedTranscriptStore(sessionFile = this.#sessionFile): ManagedSessionDescendantStore {
|
|
6160
7262
|
if (this.destination.kind !== "managed" || !sessionFile) {
|
|
6161
7263
|
throw new Error("Managed transcript authority is unavailable");
|
|
@@ -6377,8 +7479,27 @@ export class SessionManager {
|
|
|
6377
7479
|
) {
|
|
6378
7480
|
return { kind: "not_promoted" };
|
|
6379
7481
|
}
|
|
7482
|
+
let transition: PreparedResidentStoreTransition;
|
|
7483
|
+
try {
|
|
7484
|
+
transition = this.#prepareResidentTextStoreTransition(
|
|
7485
|
+
{
|
|
7486
|
+
target: { sessionId: this.#sessionId, sessionFile: this.#sessionFile ?? "" },
|
|
7487
|
+
primary: {
|
|
7488
|
+
mode: "materialize",
|
|
7489
|
+
sourceEntries: [...stage.entries],
|
|
7490
|
+
sourceStores: { textStore: this.#residentTextBlobStore, imageStore: this.#residentImageBlobStore },
|
|
7491
|
+
},
|
|
7492
|
+
},
|
|
7493
|
+
"retain-and-throw",
|
|
7494
|
+
);
|
|
7495
|
+
} catch (error) {
|
|
7496
|
+
return { kind: "unknown", error: toError(error) };
|
|
7497
|
+
}
|
|
6380
7498
|
if (stage.persistsToExistingFile) {
|
|
6381
|
-
if (!this.#sessionFile)
|
|
7499
|
+
if (!this.#sessionFile) {
|
|
7500
|
+
transition.dispose();
|
|
7501
|
+
return { kind: "unknown", error: new Error("Missing staged session replacement") };
|
|
7502
|
+
}
|
|
6382
7503
|
if (this.destination.kind === "managed") {
|
|
6383
7504
|
try {
|
|
6384
7505
|
const persistedEntries = materializeResidentEntriesForPersistenceSync(
|
|
@@ -6387,17 +7508,23 @@ export class SessionManager {
|
|
|
6387
7508
|
).map(entry => prepareEntryForPersistenceSync(entry, this.#blobStore));
|
|
6388
7509
|
this.#writeEntriesAtomicallySync(persistedEntries);
|
|
6389
7510
|
} catch (error) {
|
|
7511
|
+
transition.dispose();
|
|
6390
7512
|
return { kind: "unknown", error: toError(error) };
|
|
6391
7513
|
}
|
|
6392
7514
|
} else {
|
|
6393
|
-
if (!stage.tempPath)
|
|
7515
|
+
if (!stage.tempPath) {
|
|
7516
|
+
transition.dispose();
|
|
7517
|
+
return { kind: "unknown", error: new Error("Missing staged session replacement") };
|
|
7518
|
+
}
|
|
6394
7519
|
try {
|
|
6395
7520
|
this.#closePersistWriterInternalSync();
|
|
6396
7521
|
const replacement = this.#replaceSessionFileSync(stage.tempPath, this.#sessionFile);
|
|
6397
7522
|
if (replacement.kind === "restored_previous") {
|
|
7523
|
+
transition.dispose();
|
|
6398
7524
|
return { kind: "not_promoted", error: replacement.error };
|
|
6399
7525
|
}
|
|
6400
7526
|
} catch (error) {
|
|
7527
|
+
transition.dispose();
|
|
6401
7528
|
if (this.#persistWriter?.getCloseState() === "close_failed_retryable") {
|
|
6402
7529
|
return { kind: "not_promoted", error: new Error("Session replacement could not be completed.") };
|
|
6403
7530
|
}
|
|
@@ -6405,13 +7532,10 @@ export class SessionManager {
|
|
|
6405
7532
|
}
|
|
6406
7533
|
}
|
|
6407
7534
|
}
|
|
6408
|
-
this.#fileEntries = [...stage.entries];
|
|
6409
|
-
this.#resetResidentTextBlobStore();
|
|
6410
|
-
this.#reexternalizeFileEntriesForResidentStore();
|
|
6411
7535
|
this.#needsFullRewriteOnNextPersist = false;
|
|
6412
7536
|
this.#flushed = stage.persistsToExistingFile;
|
|
6413
7537
|
this.#ensuredOnDisk = stage.persistsToExistingFile;
|
|
6414
|
-
this.#
|
|
7538
|
+
this.#commitResidentTextStoreTransition(transition);
|
|
6415
7539
|
return { kind: "promoted" };
|
|
6416
7540
|
}
|
|
6417
7541
|
|
|
@@ -6463,7 +7587,8 @@ export class SessionManager {
|
|
|
6463
7587
|
this.#flushed = true;
|
|
6464
7588
|
}
|
|
6465
7589
|
});
|
|
6466
|
-
this.#
|
|
7590
|
+
this.#releaseResidentTextStore();
|
|
7591
|
+
this.#releaseOwnedManagedAuthority();
|
|
6467
7592
|
if (this.#persistError) throw this.#persistError;
|
|
6468
7593
|
}
|
|
6469
7594
|
/** Flush while open, then strictly close; retryable close skips the invalid second flush. */
|
|
@@ -6520,7 +7645,7 @@ export class SessionManager {
|
|
|
6520
7645
|
// Only tear down the resident blob store on a terminal outcome; a retryable
|
|
6521
7646
|
// close leaves the session live for a genuine retry.
|
|
6522
7647
|
if (!this.#persistWriter) {
|
|
6523
|
-
this.#
|
|
7648
|
+
this.#releaseResidentTextStore();
|
|
6524
7649
|
}
|
|
6525
7650
|
return outcome;
|
|
6526
7651
|
}
|
|
@@ -6600,7 +7725,7 @@ export class SessionManager {
|
|
|
6600
7725
|
#stageMemoryGuardCheckpointBlobs(blobs: Map<string, Buffer>): void {
|
|
6601
7726
|
const stagedImageStore = new MemoryBlobStore();
|
|
6602
7727
|
for (const blob of blobs.values()) {
|
|
6603
|
-
this.#
|
|
7728
|
+
this.#putResidentTextBlobSync(blob);
|
|
6604
7729
|
stagedImageStore.putSync(blob);
|
|
6605
7730
|
}
|
|
6606
7731
|
this.#residentImageBlobStore = stagedImageStore;
|
|
@@ -7121,7 +8246,7 @@ export class SessionManager {
|
|
|
7121
8246
|
#appendEntry(entry: SessionEntry): void {
|
|
7122
8247
|
this.#assertRecoveryHydrationWritable();
|
|
7123
8248
|
const normalizedEntry = normalizeSessionEntryForStorage(entry);
|
|
7124
|
-
const residentEntry =
|
|
8249
|
+
const residentEntry = this.#prepareEntryForCurrentResidentStore(normalizedEntry) as SessionEntry;
|
|
7125
8250
|
this.#fileEntries.push(residentEntry);
|
|
7126
8251
|
this.#byId.set(residentEntry.id, residentEntry);
|
|
7127
8252
|
this.#leafId = residentEntry.id;
|
|
@@ -7342,10 +8467,10 @@ export class SessionManager {
|
|
|
7342
8467
|
for (const updated of entries) {
|
|
7343
8468
|
const canonical = this.#byId.get(updated.id);
|
|
7344
8469
|
if (canonical?.type !== "message") continue;
|
|
7345
|
-
const residentEntry =
|
|
7346
|
-
|
|
7347
|
-
|
|
7348
|
-
) as SessionMessageEntry;
|
|
8470
|
+
const residentEntry = this.#prepareEntryForCurrentResidentStore({
|
|
8471
|
+
...canonical,
|
|
8472
|
+
message: updated.message,
|
|
8473
|
+
}) as SessionMessageEntry;
|
|
7349
8474
|
canonical.message = residentEntry.message;
|
|
7350
8475
|
}
|
|
7351
8476
|
this.#needsFullRewriteOnNextPersist = true;
|
|
@@ -7663,11 +8788,16 @@ export class SessionManager {
|
|
|
7663
8788
|
coldSpillReadCount: this.#coldSpillReadCount,
|
|
7664
8789
|
residentTextReadCount: this.#residentTextReadCount,
|
|
7665
8790
|
residentImageReadCount: this.#residentImageReadCount,
|
|
8791
|
+
residentCacheAdoptFallbackCount: this.#residentCacheAdoptFallbackCount,
|
|
8792
|
+
residentCacheTrustRejectCount: this.#residentCacheTrustRejectCount,
|
|
8793
|
+
residentCacheWin32FallbackCount: this.#residentCacheWin32FallbackCount,
|
|
8794
|
+
residentCacheDegradedReason: (this.#residentTextBlobStore as ResidentCacheDegradedStore).degradedReason,
|
|
7666
8795
|
publicMaterializerCallCount: this.#publicMaterializerCallCount,
|
|
7667
8796
|
getEntryMaterializerCallCount: this.#getEntryMaterializerCallCount,
|
|
7668
8797
|
getBranchMaterializerCallCount: this.#getBranchMaterializerCallCount,
|
|
7669
8798
|
getEntriesMaterializerCallCount: this.#getEntriesMaterializerCallCount,
|
|
7670
8799
|
materializedEntriesCachePopulateCount: this.#materializedEntriesCachePopulateCount,
|
|
8800
|
+
materializedCacheDemotedCount: this.#materializedCacheDemotedCount,
|
|
7671
8801
|
pathOnlyContextBuildCount: this.#pathOnlyContextBuildCount,
|
|
7672
8802
|
};
|
|
7673
8803
|
}
|
|
@@ -7854,10 +8984,11 @@ export class SessionManager {
|
|
|
7854
8984
|
|
|
7855
8985
|
/**
|
|
7856
8986
|
* Return the revision-keyed context cache for internal read-only consumers.
|
|
7857
|
-
*
|
|
8987
|
+
* Normal snapshots remain strongly held; snapshots over the materialized cache
|
|
8988
|
+
* cap are weakly held so a GC cycle can reclaim them between reads.
|
|
7858
8989
|
*/
|
|
7859
8990
|
#getSessionContextForRead(): Readonly<SessionContext> {
|
|
7860
|
-
const cached = this.#sessionContextCache;
|
|
8991
|
+
const cached = dereferenceMaterializedCache(this.#sessionContextCache);
|
|
7861
8992
|
if (
|
|
7862
8993
|
cached &&
|
|
7863
8994
|
this.#sessionContextEntryRevision === this.#entryRevision &&
|
|
@@ -7867,18 +8998,22 @@ export class SessionManager {
|
|
|
7867
8998
|
return cached;
|
|
7868
8999
|
}
|
|
7869
9000
|
this.#pathOnlyContextBuildCount++;
|
|
7870
|
-
const
|
|
9001
|
+
const builtContext = buildSessionContext(
|
|
7871
9002
|
this.#getActivePathEntriesForProviderContext(),
|
|
7872
9003
|
this.#leafId,
|
|
7873
9004
|
undefined,
|
|
7874
9005
|
this.#sessionId,
|
|
7875
9006
|
);
|
|
7876
|
-
|
|
7877
|
-
|
|
9007
|
+
if (jsonLikeValueExceedsCacheLimit(builtContext, materializedCacheMaxBytes())) {
|
|
9008
|
+
this.#holdMaterializedCachesWeakly();
|
|
9009
|
+
}
|
|
9010
|
+
const context = freezeInternalReadSnapshot(builtContext);
|
|
9011
|
+
this.#sessionContextCache = this.#materializedCachesWeaklyHeld ? new WeakRef(context) : context;
|
|
9012
|
+
transferSessionMessageIdentity(builtContext.messages, context.messages);
|
|
7878
9013
|
this.#sessionContextEntryRevision = this.#entryRevision;
|
|
7879
9014
|
this.#sessionContextLeafRevision = this.#leafRevision;
|
|
7880
9015
|
this.#sessionContextReplayMetadataRevision = this.#replayMetadataRevision;
|
|
7881
|
-
return
|
|
9016
|
+
return context;
|
|
7882
9017
|
}
|
|
7883
9018
|
|
|
7884
9019
|
#getActivePathEntriesForProviderContext(fromId?: string | null): SessionEntry[] {
|
|
@@ -7975,17 +9110,21 @@ export class SessionManager {
|
|
|
7975
9110
|
* Internal materialized entry cache. Public getters clone this snapshot before returning it.
|
|
7976
9111
|
*/
|
|
7977
9112
|
#getMaterializedEntriesInternal(): SessionEntry[] {
|
|
7978
|
-
|
|
7979
|
-
|
|
7980
|
-
}
|
|
9113
|
+
const cached = dereferenceMaterializedCache(this.#materializedEntriesCache);
|
|
9114
|
+
if (this.#materializedEntriesRevision === this.#entryRevision && cached) return cached;
|
|
7981
9115
|
this.#materializedEntriesCachePopulateCount++;
|
|
7982
9116
|
const resolvedTextBlobCache = new Map<string, string>();
|
|
7983
9117
|
const sourceEntries = this.#fileEntries.filter((e): e is SessionEntry => e.type !== "session");
|
|
7984
9118
|
const materializedEntries = sourceEntries.map(entry =>
|
|
7985
9119
|
materializeResidentEntryForReadSync(entry, this.#residentBlobStores(), resolvedTextBlobCache),
|
|
7986
9120
|
);
|
|
9121
|
+
if (jsonLikeValueExceedsCacheLimit(materializedEntries, materializedCacheMaxBytes())) {
|
|
9122
|
+
this.#holdMaterializedCachesWeakly();
|
|
9123
|
+
}
|
|
7987
9124
|
if (!sourceEntries.some(entry => containsResidentImageSentinel(entry))) {
|
|
7988
|
-
this.#materializedEntriesCache =
|
|
9125
|
+
this.#materializedEntriesCache = this.#materializedCachesWeaklyHeld
|
|
9126
|
+
? new WeakRef(materializedEntries)
|
|
9127
|
+
: materializedEntries;
|
|
7989
9128
|
this.#materializedEntriesRevision = this.#entryRevision;
|
|
7990
9129
|
}
|
|
7991
9130
|
return materializedEntries;
|
|
@@ -8201,27 +9340,35 @@ export class SessionManager {
|
|
|
8201
9340
|
labelEntries.push(labelEntry);
|
|
8202
9341
|
parentId = labelEntry.id;
|
|
8203
9342
|
}
|
|
8204
|
-
|
|
8205
|
-
|
|
8206
|
-
|
|
8207
|
-
|
|
8208
|
-
|
|
8209
|
-
|
|
8210
|
-
|
|
9343
|
+
const transitionEntries: FileEntry[] = [header, ...materializedPathWithoutLabels, ...labelEntries];
|
|
9344
|
+
const transition = this.#prepareResidentTextStoreTransition(
|
|
9345
|
+
{
|
|
9346
|
+
target: { sessionId: newSessionId, sessionFile: newSessionFile },
|
|
9347
|
+
primary: {
|
|
9348
|
+
mode: "materialize",
|
|
9349
|
+
sourceEntries: transitionEntries,
|
|
9350
|
+
sourceStores: { textStore: null, imageStore: this.#residentImageBlobStore },
|
|
9351
|
+
},
|
|
9352
|
+
},
|
|
9353
|
+
"retain-and-throw",
|
|
9354
|
+
);
|
|
9355
|
+
try {
|
|
9356
|
+
if (this.destination.kind === "managed") {
|
|
9357
|
+
this.#managedTranscriptStore(newSessionFile).publishNoReplaceSync(
|
|
9358
|
+
path.basename(newSessionFile),
|
|
9359
|
+
Buffer.from(`${lines.join("\n")}\n`, "utf8"),
|
|
9360
|
+
);
|
|
9361
|
+
} else {
|
|
9362
|
+
this.storage.writeTextSync(newSessionFile, `${lines.join("\n")}\n`);
|
|
9363
|
+
}
|
|
9364
|
+
} catch (error) {
|
|
9365
|
+
transition.dispose();
|
|
9366
|
+
throw error;
|
|
8211
9367
|
}
|
|
8212
9368
|
this.#sessionId = newSessionId;
|
|
8213
9369
|
this.#sessionFile = newSessionFile;
|
|
8214
|
-
this.#resetResidentTextBlobStore();
|
|
8215
|
-
this.#fileEntries = [
|
|
8216
|
-
header,
|
|
8217
|
-
...materializedPathWithoutLabels.map(
|
|
8218
|
-
entry => prepareEntryForResidentSync(entry, this.#residentBlobStores()) as SessionEntry,
|
|
8219
|
-
),
|
|
8220
|
-
...labelEntries,
|
|
8221
|
-
];
|
|
8222
9370
|
this.#flushed = true;
|
|
8223
|
-
this.#
|
|
8224
|
-
this.#bumpAllRevisions();
|
|
9371
|
+
this.#commitResidentTextStoreTransition(transition);
|
|
8225
9372
|
return newSessionFile;
|
|
8226
9373
|
}
|
|
8227
9374
|
|
|
@@ -8240,17 +9387,20 @@ export class SessionManager {
|
|
|
8240
9387
|
labelEntries.push(labelEntry);
|
|
8241
9388
|
parentId = labelEntry.id;
|
|
8242
9389
|
}
|
|
9390
|
+
const transitionEntries: FileEntry[] = [header, ...materializedPathWithoutLabels, ...labelEntries];
|
|
9391
|
+
const transition = this.#prepareResidentTextStoreTransition(
|
|
9392
|
+
{
|
|
9393
|
+
target: { sessionId: newSessionId, sessionFile: newSessionFile },
|
|
9394
|
+
primary: {
|
|
9395
|
+
mode: "materialize",
|
|
9396
|
+
sourceEntries: transitionEntries,
|
|
9397
|
+
sourceStores: { textStore: null, imageStore: this.#residentImageBlobStore },
|
|
9398
|
+
},
|
|
9399
|
+
},
|
|
9400
|
+
"retain-and-throw",
|
|
9401
|
+
);
|
|
8243
9402
|
this.#sessionId = newSessionId;
|
|
8244
|
-
this.#
|
|
8245
|
-
this.#fileEntries = [
|
|
8246
|
-
header,
|
|
8247
|
-
...materializedPathWithoutLabels.map(
|
|
8248
|
-
entry => prepareEntryForResidentSync(entry, this.#residentBlobStores()) as SessionEntry,
|
|
8249
|
-
),
|
|
8250
|
-
...labelEntries,
|
|
8251
|
-
];
|
|
8252
|
-
this.#buildIndex();
|
|
8253
|
-
this.#bumpAllRevisions();
|
|
9403
|
+
this.#commitResidentTextStoreTransition(transition);
|
|
8254
9404
|
return undefined;
|
|
8255
9405
|
}
|
|
8256
9406
|
|
|
@@ -8298,8 +9448,11 @@ export class SessionManager {
|
|
|
8298
9448
|
: process.platform === "win32"
|
|
8299
9449
|
? "windows-existing-verify-first"
|
|
8300
9450
|
: "default";
|
|
9451
|
+
const profileAgentDir =
|
|
9452
|
+
authority instanceof ManagedSessionDescendantStore ? authority.profileAgentDir : getAgentDir();
|
|
8301
9453
|
const securityContext = createManagedSessionSecurityContext({
|
|
8302
9454
|
agentDir: rootAuthority.canonicalPath,
|
|
9455
|
+
profileAgentDir,
|
|
8303
9456
|
sessionsRoot: rootAuthority.canonicalPath,
|
|
8304
9457
|
sessionDir: directory,
|
|
8305
9458
|
rootAuthority,
|
|
@@ -8478,25 +9631,26 @@ export class SessionManager {
|
|
|
8478
9631
|
const forkEntries = structuredClone(await loadEntriesFromFile(managedSourcePath, storage)) as FileEntry[];
|
|
8479
9632
|
migrateToCurrentVersion(forkEntries);
|
|
8480
9633
|
await resolveBlobRefsInEntries(forkEntries, manager.#blobStore);
|
|
8481
|
-
|
|
8482
|
-
const
|
|
8483
|
-
const
|
|
8484
|
-
|
|
8485
|
-
|
|
8486
|
-
const
|
|
8487
|
-
|
|
8488
|
-
|
|
8489
|
-
|
|
8490
|
-
|
|
8491
|
-
|
|
8492
|
-
|
|
8493
|
-
|
|
8494
|
-
|
|
8495
|
-
|
|
8496
|
-
|
|
9634
|
+
const sourceHeader = forkEntries.find(entry => entry.type === "session") as SessionHeader | undefined;
|
|
9635
|
+
const historyEntries = forkEntries.filter(entry => entry.type !== "session") as SessionEntry[];
|
|
9636
|
+
const fresh = manager.#freshSessionState({ parentSession: sourceHeader?.id });
|
|
9637
|
+
fresh.header.title = sourceHeader?.title;
|
|
9638
|
+
fresh.header.titleSource = sourceHeader?.titleSource;
|
|
9639
|
+
const transition = manager.#prepareResidentTextStoreTransition(
|
|
9640
|
+
{
|
|
9641
|
+
target: { sessionId: fresh.sessionId, sessionFile: fresh.sessionFile ?? "" },
|
|
9642
|
+
primary: {
|
|
9643
|
+
mode: "materialize",
|
|
9644
|
+
sourceEntries: [fresh.header, ...historyEntries],
|
|
9645
|
+
sourceStores: { textStore: null, imageStore: manager.#residentImageBlobStore },
|
|
9646
|
+
},
|
|
9647
|
+
},
|
|
9648
|
+
"memory-fallback",
|
|
9649
|
+
);
|
|
9650
|
+
manager.#applyFreshSessionMetadata(fresh);
|
|
9651
|
+
manager.#commitResidentTextStoreTransition(transition);
|
|
9652
|
+
if (fresh.sessionFile) writeTerminalBreadcrumb(manager.cwd, fresh.sessionFile);
|
|
8497
9653
|
manager.sanitizeLoadedOpenAIResponsesReplayMetadata();
|
|
8498
|
-
manager.#buildIndex();
|
|
8499
|
-
manager.#bumpAllRevisions();
|
|
8500
9654
|
await manager.#rewriteFile();
|
|
8501
9655
|
return manager;
|
|
8502
9656
|
}
|
|
@@ -8574,32 +9728,20 @@ export class SessionManager {
|
|
|
8574
9728
|
storage,
|
|
8575
9729
|
destination,
|
|
8576
9730
|
);
|
|
8577
|
-
manager.#sessionFile = resolved;
|
|
8578
|
-
writeTerminalBreadcrumb(manager.cwd, resolved);
|
|
8579
|
-
manager.#fileEntries = entries;
|
|
8580
9731
|
if (entries.length > 0) {
|
|
8581
|
-
|
|
8582
|
-
manager.#
|
|
8583
|
-
manager
|
|
8584
|
-
manager.#needsFullRewriteOnNextPersist = migrateToCurrentVersion(manager.#fileEntries);
|
|
8585
|
-
await resolveBlobRefsInEntries(manager.#fileEntries, manager.#blobStore);
|
|
8586
|
-
manager.#resetResidentTextBlobStore();
|
|
8587
|
-
manager.#fileEntries = manager.#fileEntries.map(entry =>
|
|
8588
|
-
prepareEntryForResidentSync(entry, manager.#residentBlobStores()),
|
|
8589
|
-
);
|
|
8590
|
-
manager.#buildIndex();
|
|
8591
|
-
manager.#bumpAllRevisions();
|
|
8592
|
-
manager.#flushed = true;
|
|
8593
|
-
manager.#ensuredOnDisk = true;
|
|
9732
|
+
const migrationApplied = migrateToCurrentVersion(entries);
|
|
9733
|
+
await manager.#hydrateExistingSession(resolved, entries, migrationApplied, "memory-fallback");
|
|
9734
|
+
writeTerminalBreadcrumb(manager.cwd, resolved);
|
|
8594
9735
|
await manager.#sanitizeLoadedOpenAIResponsesReplayMetadataAndPersist();
|
|
8595
9736
|
} else {
|
|
8596
|
-
manager.#
|
|
8597
|
-
manager.#
|
|
8598
|
-
manager.#
|
|
9737
|
+
const fresh = manager.#freshSessionState(undefined, resolved);
|
|
9738
|
+
const transition = manager.#prepareFreshSessionTransition(fresh, "memory-fallback");
|
|
9739
|
+
manager.#applyFreshSessionMetadata(fresh);
|
|
9740
|
+
manager.#commitResidentTextStoreTransition(transition);
|
|
9741
|
+
writeTerminalBreadcrumb(manager.cwd, resolved);
|
|
8599
9742
|
await manager.#rewriteFile();
|
|
8600
9743
|
manager.#flushed = true;
|
|
8601
9744
|
manager.#ensuredOnDisk = true;
|
|
8602
|
-
manager.#bumpAllRevisions();
|
|
8603
9745
|
}
|
|
8604
9746
|
store.assertBound();
|
|
8605
9747
|
return manager;
|
|
@@ -8768,24 +9910,25 @@ export class SessionManager {
|
|
|
8768
9910
|
try {
|
|
8769
9911
|
manager = new SessionManager(cwd, privateStagingDir ?? dir, true, snapshot.storage, forkDestination);
|
|
8770
9912
|
await resolveBlobRefsInEntries(forkEntries, manager.#blobStore);
|
|
8771
|
-
|
|
8772
|
-
const
|
|
8773
|
-
const
|
|
8774
|
-
|
|
8775
|
-
|
|
8776
|
-
const
|
|
8777
|
-
|
|
8778
|
-
|
|
8779
|
-
|
|
8780
|
-
|
|
8781
|
-
|
|
8782
|
-
|
|
8783
|
-
|
|
8784
|
-
|
|
8785
|
-
|
|
9913
|
+
const sourceHeader = forkEntries.find(entry => entry.type === "session") as SessionHeader | undefined;
|
|
9914
|
+
const historyEntries = forkEntries.filter(entry => entry.type !== "session") as SessionEntry[];
|
|
9915
|
+
const fresh = manager.#freshSessionState({ parentSession: sourceHeader?.id });
|
|
9916
|
+
fresh.header.title = sourceHeader?.title;
|
|
9917
|
+
fresh.header.titleSource = sourceHeader?.titleSource;
|
|
9918
|
+
const transition = manager.#prepareResidentTextStoreTransition(
|
|
9919
|
+
{
|
|
9920
|
+
target: { sessionId: fresh.sessionId, sessionFile: fresh.sessionFile ?? "" },
|
|
9921
|
+
primary: {
|
|
9922
|
+
mode: "materialize",
|
|
9923
|
+
sourceEntries: [fresh.header, ...historyEntries],
|
|
9924
|
+
sourceStores: { textStore: null, imageStore: manager.#residentImageBlobStore },
|
|
9925
|
+
},
|
|
9926
|
+
},
|
|
9927
|
+
"memory-fallback",
|
|
9928
|
+
);
|
|
9929
|
+
manager.#applyFreshSessionMetadata(fresh);
|
|
9930
|
+
manager.#commitResidentTextStoreTransition(transition);
|
|
8786
9931
|
manager.sanitizeLoadedOpenAIResponsesReplayMetadata();
|
|
8787
|
-
manager.#buildIndex();
|
|
8788
|
-
manager.#bumpAllRevisions();
|
|
8789
9932
|
const beforeWrite = inspectResumeSessionFile(snapshot.sourcePath, snapshot.storage);
|
|
8790
9933
|
if ("kind" in beforeWrite) {
|
|
8791
9934
|
authorityFailure = beforeWrite;
|
|
@@ -8823,41 +9966,57 @@ export class SessionManager {
|
|
|
8823
9966
|
throw new Error("managed_fork_transcript_changed");
|
|
8824
9967
|
}
|
|
8825
9968
|
if (privateStagingDir && manager.#sessionFile) {
|
|
8826
|
-
const
|
|
8827
|
-
|
|
8828
|
-
|
|
9969
|
+
const stagedSessionFile = manager.#sessionFile;
|
|
9970
|
+
const finalSessionFile = path.join(dir, path.basename(stagedSessionFile));
|
|
9971
|
+
const finalTransition = manager.#prepareResidentTextStoreTransition(
|
|
9972
|
+
{
|
|
9973
|
+
target: { sessionId: manager.#sessionId, sessionFile: finalSessionFile },
|
|
9974
|
+
primary: {
|
|
9975
|
+
mode: "materialize",
|
|
9976
|
+
sourceEntries: manager.#fileEntries,
|
|
9977
|
+
sourceStores: {
|
|
9978
|
+
textStore: manager.#residentTextBlobStore,
|
|
9979
|
+
imageStore: manager.#residentImageBlobStore,
|
|
9980
|
+
},
|
|
9981
|
+
},
|
|
9982
|
+
},
|
|
9983
|
+
"retain-and-throw",
|
|
8829
9984
|
);
|
|
8830
|
-
|
|
8831
|
-
|
|
8832
|
-
|
|
8833
|
-
|
|
8834
|
-
|
|
8835
|
-
|
|
8836
|
-
|
|
8837
|
-
|
|
8838
|
-
|
|
8839
|
-
|
|
8840
|
-
|
|
8841
|
-
|
|
8842
|
-
|
|
8843
|
-
|
|
8844
|
-
|
|
8845
|
-
|
|
8846
|
-
|
|
8847
|
-
|
|
8848
|
-
|
|
8849
|
-
|
|
8850
|
-
|
|
8851
|
-
|
|
8852
|
-
|
|
8853
|
-
|
|
8854
|
-
|
|
8855
|
-
|
|
8856
|
-
|
|
8857
|
-
|
|
8858
|
-
|
|
8859
|
-
|
|
8860
|
-
|
|
9985
|
+
// The staged store is no longer needed once the final candidate has copied its entries.
|
|
9986
|
+
// Dispose it before the staging tree becomes published evidence.
|
|
9987
|
+
manager.#disposeResidentTextStore(manager.#residentTextBlobStore);
|
|
9988
|
+
try {
|
|
9989
|
+
const capturedStaging = native.snapshotDirectoryTree(privateStagingDir);
|
|
9990
|
+
if (!capturedStaging.ok || !capturedStaging.snapshot)
|
|
9991
|
+
throw new Error(capturedStaging.code ?? "fork_staging_snapshot_failed");
|
|
9992
|
+
privateStagingSnapshot = capturedStaging.snapshot;
|
|
9993
|
+
fsyncManagedArtifactTree(privateStagingDir);
|
|
9994
|
+
const outcome = classifyNativePublishOutcome(native.renameNoReplacePath(privateStagingDir, dir));
|
|
9995
|
+
if (!outcome.ok) throw new Error(outcome.code ?? "fork_destination_publish_failed");
|
|
9996
|
+
privateStagingPublished = true;
|
|
9997
|
+
const terminal = native.snapshotDirectoryTree(dir);
|
|
9998
|
+
if (
|
|
9999
|
+
!terminal.ok ||
|
|
10000
|
+
!terminal.snapshot ||
|
|
10001
|
+
!retainedTreeSnapshotEqualsAfterRename(terminal.snapshot, privateStagingSnapshot)
|
|
10002
|
+
)
|
|
10003
|
+
throw new Error("fork_destination_terminal_identity_changed");
|
|
10004
|
+
await syncSessionMoveDirectory(path.dirname(dir));
|
|
10005
|
+
const durableTerminal = native.snapshotDirectoryTree(dir);
|
|
10006
|
+
if (
|
|
10007
|
+
!durableTerminal.ok ||
|
|
10008
|
+
!durableTerminal.snapshot ||
|
|
10009
|
+
!retainedTreeSnapshotEqualsAfterRename(durableTerminal.snapshot, privateStagingSnapshot)
|
|
10010
|
+
)
|
|
10011
|
+
throw new Error("fork_destination_durability_identity_changed");
|
|
10012
|
+
manager.sessionDir = dir;
|
|
10013
|
+
manager.destination = destination;
|
|
10014
|
+
manager.#sessionFile = finalSessionFile;
|
|
10015
|
+
manager.#commitResidentTextStoreTransition(finalTransition);
|
|
10016
|
+
} catch (error) {
|
|
10017
|
+
finalTransition.dispose();
|
|
10018
|
+
throw error;
|
|
10019
|
+
}
|
|
8861
10020
|
}
|
|
8862
10021
|
if (manager.#sessionFile) writeTerminalBreadcrumb(manager.cwd, manager.#sessionFile);
|
|
8863
10022
|
return { kind: "forked", manager };
|
|
@@ -9113,7 +10272,7 @@ export class SessionManager {
|
|
|
9113
10272
|
storage,
|
|
9114
10273
|
destination,
|
|
9115
10274
|
);
|
|
9116
|
-
await manager.#hydrateExistingSession(identity.canonicalPath, inspected.entries, false);
|
|
10275
|
+
await manager.#hydrateExistingSession(identity.canonicalPath, inspected.entries, false, "memory-only");
|
|
9117
10276
|
const revalidated = inspectResumeSessionFile(identity.canonicalPath, storage);
|
|
9118
10277
|
if ("kind" in revalidated || !sameResumeIdentity(identity, revalidated.identity)) {
|
|
9119
10278
|
await manager.close();
|
|
@@ -9151,20 +10310,40 @@ export class SessionManager {
|
|
|
9151
10310
|
if (!promotionPath) throw new Error("Recovery transcript promotion path is unavailable.");
|
|
9152
10311
|
const promotedSource = inspectResumeSessionFile(context.identity.canonicalPath, this.storage);
|
|
9153
10312
|
if ("kind" in promotedSource) throw new Error("Recovery transcript became unavailable before publication.");
|
|
9154
|
-
|
|
9155
|
-
|
|
9156
|
-
|
|
9157
|
-
|
|
9158
|
-
|
|
9159
|
-
|
|
9160
|
-
|
|
9161
|
-
|
|
9162
|
-
|
|
9163
|
-
|
|
9164
|
-
|
|
10313
|
+
const transition = this.#prepareResidentTextStoreTransition(
|
|
10314
|
+
{
|
|
10315
|
+
target: { sessionId: this.#sessionId, sessionFile: promotionPath },
|
|
10316
|
+
primary: {
|
|
10317
|
+
mode: "materialize",
|
|
10318
|
+
sourceEntries: this.#fileEntries,
|
|
10319
|
+
sourceStores: {
|
|
10320
|
+
textStore: this.#residentTextBlobStore,
|
|
10321
|
+
imageStore: this.#residentImageBlobStore,
|
|
10322
|
+
},
|
|
10323
|
+
},
|
|
10324
|
+
},
|
|
10325
|
+
"retain-and-throw",
|
|
10326
|
+
);
|
|
10327
|
+
try {
|
|
10328
|
+
if (this.destination.kind === "managed") {
|
|
10329
|
+
const store = this.#managedTranscriptStore();
|
|
10330
|
+
const sourceName = path.basename(context.identity.canonicalPath);
|
|
10331
|
+
const sourceSnapshot = store.readExpected(sourceName);
|
|
10332
|
+
if (!sourceSnapshot) throw new Error("Recovery transcript authority changed before publication.");
|
|
10333
|
+
await store.publishNoReplace(path.basename(promotionPath), promotedSource.content);
|
|
10334
|
+
store.removeExpected(sourceName, sourceSnapshot);
|
|
10335
|
+
} else {
|
|
10336
|
+
await writeOwnerOnlyFileNoReplace(promotionPath, promotedSource.content);
|
|
10337
|
+
await fs.promises.rm(context.identity.canonicalPath, { force: true });
|
|
10338
|
+
await fsyncDirectoryPath(path.dirname(context.identity.canonicalPath));
|
|
10339
|
+
}
|
|
10340
|
+
} catch (error) {
|
|
10341
|
+
transition.dispose();
|
|
10342
|
+
throw error;
|
|
9165
10343
|
}
|
|
9166
10344
|
this.#sessionFile = promotionPath;
|
|
9167
10345
|
this.#recoveryPromotionTranscriptPath = undefined;
|
|
10346
|
+
this.#commitResidentTextStoreTransition(transition);
|
|
9168
10347
|
writeTerminalBreadcrumb(this.cwd, promotionPath);
|
|
9169
10348
|
this.#recoveryHydrationContext = undefined;
|
|
9170
10349
|
}
|