@gajae-code/coding-agent 0.11.11 → 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 +131 -4
- package/dist/types/async/job-manager.d.ts +17 -3
- package/dist/types/cli/read-cli.d.ts +1 -0
- package/dist/types/commands/deep-interview.d.ts +9 -0
- package/dist/types/commands/harness.d.ts +22 -0
- package/dist/types/commands/read.d.ts +6 -0
- package/dist/types/config/model-registry.d.ts +4 -0
- package/dist/types/config/provider-auth-health.d.ts +14 -0
- package/dist/types/config/provider-ranking.d.ts +59 -0
- package/dist/types/config/settings-schema.d.ts +83 -1
- package/dist/types/config/settings.d.ts +2 -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 +38 -4
- 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/extensibility/shared-events.d.ts +6 -0
- package/dist/types/gjc-runtime/deep-interview-ambiguity.d.ts +5 -12
- package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +17 -32
- package/dist/types/gjc-runtime/deep-interview-stage.d.ts +37 -0
- package/dist/types/gjc-runtime/deep-interview-state.d.ts +4 -135
- package/dist/types/gjc-runtime/gc-runtime.d.ts +1 -1
- package/dist/types/gjc-runtime/launch-tmux.d.ts +1 -0
- package/dist/types/gjc-runtime/memory-guard-owner-claims.d.ts +47 -0
- package/dist/types/gjc-runtime/ralplan-runtime.d.ts +70 -1
- package/dist/types/gjc-runtime/state-writer.d.ts +5 -46
- package/dist/types/gjc-runtime/team-launch.d.ts +1 -1
- package/dist/types/gjc-runtime/team-runtime.d.ts +27 -1
- package/dist/types/gjc-runtime/team-store.d.ts +12 -0
- package/dist/types/gjc-runtime/team-worker-heartbeat.d.ts +43 -0
- package/dist/types/gjc-runtime/team-worker-memory-guard.d.ts +92 -0
- package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +17 -0
- package/dist/types/gjc-runtime/ultragoal-receipt-freshness.d.ts +8 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +31 -80
- package/dist/types/gjc-runtime/workflow-manifest.d.ts +3 -5
- package/dist/types/internal-urls/local-root-gc.d.ts +2 -0
- package/dist/types/main.d.ts +1 -1
- package/dist/types/memories/index.d.ts +2 -0
- package/dist/types/modes/acp/acp-agent.d.ts +10 -19
- package/dist/types/modes/components/assistant-message.d.ts +3 -1
- package/dist/types/modes/components/gjc-bundle-settings.d.ts +34 -0
- package/dist/types/modes/components/irc-sidebar.d.ts +41 -0
- package/dist/types/modes/components/notifications-settings-editor.d.ts +1 -0
- package/dist/types/modes/components/read-tool-group.d.ts +4 -4
- package/dist/types/modes/components/session-observer-overlay.d.ts +22 -0
- package/dist/types/modes/components/settings-selector.d.ts +11 -0
- package/dist/types/modes/components/status-line/gh.d.ts +5 -0
- package/dist/types/modes/components/tool-execution.d.ts +8 -0
- package/dist/types/modes/components/transcript-viewer-overlay.d.ts +18 -1
- package/dist/types/modes/controllers/event-controller.d.ts +3 -0
- package/dist/types/modes/interactive-mode.d.ts +2 -0
- package/dist/types/modes/irc-observation-ledger.d.ts +17 -1
- package/dist/types/modes/types.d.ts +2 -0
- package/dist/types/modes/utils/ui-helpers.d.ts +16 -0
- package/dist/types/reminders/star-reminder.d.ts +3 -13
- package/dist/types/runtime-mcp/manager.d.ts +1 -0
- package/dist/types/runtime-mcp/smithery-auth.d.ts +5 -0
- package/dist/types/sdk/acp/adapter.d.ts +3 -1
- package/dist/types/sdk/broker/lifecycle.d.ts +5 -0
- 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/control-drain-lease.d.ts +52 -0
- package/dist/types/sdk/bus/index.d.ts +44 -1
- package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +44 -0
- package/dist/types/sdk/bus/lifecycle-control-runtime.d.ts +4 -0
- package/dist/types/sdk/bus/prompt-reconciliation.d.ts +2 -1
- package/dist/types/sdk/bus/reconciliation-store.d.ts +69 -0
- 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/control/operations.d.ts +1 -1
- package/dist/types/sdk/host/host.d.ts +2 -0
- package/dist/types/sdk/host/query/handlers.d.ts +5 -0
- 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 +82 -12
- 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/memory-guard-checkpoint-participant.d.ts +69 -0
- package/dist/types/session/session-manager.d.ts +48 -19
- package/dist/types/session/session-storage.d.ts +4 -0
- package/dist/types/session/streaming-output.d.ts +96 -12
- package/dist/types/setup/credential-import.d.ts +12 -0
- package/dist/types/skill-state/active-state.d.ts +12 -4
- package/dist/types/skill-state/workflow-hud.d.ts +9 -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/browser/launch.d.ts +7 -0
- package/dist/types/tools/fetch.d.ts +17 -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 +44 -3
- package/dist/types/tools/path-utils.d.ts +1 -0
- package/dist/types/tools/read-internals.d.ts +12 -0
- package/dist/types/tools/read.d.ts +45 -7
- package/dist/types/tools/resource-gc.d.ts +4 -0
- package/dist/types/tools/review.d.ts +5 -7
- package/dist/types/tools/subagent.d.ts +2 -0
- package/dist/types/tools/tool-result.d.ts +7 -1
- 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 +11 -13
- package/scripts/acp-conformance-agent.ts +218 -0
- package/scripts/benchmark-sticky-viewport-pr1.ts +239 -0
- package/scripts/capture-gjc-bundle-settings.ts +306 -0
- package/scripts/capture-sticky-viewport-showcase.ts +292 -0
- package/scripts/generate-sdk-operation-inventory.ts +5 -0
- package/scripts/resident-memory-bench.ts +1341 -0
- package/scripts/run-acp-conformance.ts +285 -0
- package/scripts/verify-gjc-sdk-canonicalization.ts +122 -18
- package/scripts/verify-sticky-viewport-showcase.ts +611 -0
- package/src/async/job-manager.ts +78 -20
- package/src/capability/index.ts +8 -0
- package/src/cli/fast-help.ts +1 -1
- package/src/cli/plugin-cli.ts +176 -18
- package/src/cli/read-cli.ts +2 -1
- package/src/cli.ts +1 -0
- package/src/commands/deep-interview.ts +29 -9
- package/src/commands/harness.ts +35 -12
- package/src/commands/read.ts +11 -2
- package/src/commands/team.ts +20 -2
- package/src/config/file-lock.ts +11 -2
- package/src/config/model-registry.ts +21 -2
- package/src/config/provider-auth-health.ts +42 -0
- package/src/config/provider-ranking.ts +119 -0
- package/src/config/settings-schema.ts +74 -1
- package/src/config/settings.ts +246 -108
- 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 +230 -82
- package/src/defaults/gjc/skills/ralplan/SKILL.md +77 -41
- package/src/defaults/gjc/skills/team/SKILL.md +3 -1
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +62 -39
- package/src/defaults/gjc/skills/ultragoal/validation-batch-contracts.md +12 -0
- package/src/defaults/gjc-defaults.ts +4 -4
- package/src/eval/py/executor.ts +7 -2
- 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 +46 -5
- 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/extensibility/shared-events.ts +5 -0
- package/src/gjc-runtime/deep-interview-ambiguity.ts +47 -82
- package/src/gjc-runtime/deep-interview-recorder.ts +227 -310
- package/src/gjc-runtime/deep-interview-runtime.ts +109 -270
- package/src/gjc-runtime/deep-interview-stage.ts +1016 -0
- package/src/gjc-runtime/deep-interview-state.ts +12 -943
- package/src/gjc-runtime/gc-render.ts +1 -0
- package/src/gjc-runtime/gc-runtime.ts +11 -1
- package/src/gjc-runtime/launch-tmux.ts +25 -6
- package/src/gjc-runtime/managed-owner-admission.ts +1 -1
- package/src/gjc-runtime/memory-guard-owner-claims.ts +389 -0
- package/src/gjc-runtime/ralplan-runtime.ts +1149 -98
- package/src/gjc-runtime/state-runtime.ts +2 -1
- package/src/gjc-runtime/state-writer.ts +70 -222
- package/src/gjc-runtime/team-launch.ts +42 -5
- package/src/gjc-runtime/team-runtime.ts +1085 -40
- package/src/gjc-runtime/team-store.ts +32 -0
- package/src/gjc-runtime/team-worker-heartbeat.ts +149 -0
- package/src/gjc-runtime/team-worker-memory-guard.ts +363 -0
- package/src/gjc-runtime/team-workers.ts +1 -0
- package/src/gjc-runtime/tmux-owner-isolation.ts +28 -0
- package/src/gjc-runtime/tmux-sessions.ts +58 -13
- package/src/gjc-runtime/ultragoal-receipt-freshness.ts +20 -0
- package/src/gjc-runtime/ultragoal-runtime.ts +414 -618
- package/src/gjc-runtime/workflow-manifest.generated.json +105 -449
- package/src/gjc-runtime/workflow-manifest.ts +38 -247
- package/src/hooks/native-skill-hook.ts +17 -4
- package/src/hooks/skill-state.ts +1 -23
- package/src/internal-urls/docs-index.generated.ts +21 -22
- package/src/internal-urls/local-root-gc.ts +127 -0
- package/src/main.ts +9 -5
- package/src/memories/index.ts +10 -0
- package/src/modes/DESIGN.md +98 -0
- package/src/modes/acp/acp-agent.ts +339 -191
- package/src/modes/acp/acp-event-mapper.ts +14 -7
- package/src/modes/acp/acp-mode.ts +39 -3
- package/src/modes/components/assistant-message.ts +123 -22
- package/src/modes/components/gjc-bundle-settings.ts +508 -0
- package/src/modes/components/irc-sidebar.ts +176 -62
- package/src/modes/components/model-selector.ts +61 -4
- package/src/modes/components/notifications-settings-editor.ts +12 -1
- package/src/modes/components/oauth-selector.ts +96 -28
- package/src/modes/components/read-tool-group.ts +82 -14
- package/src/modes/components/session-observer-overlay.ts +457 -83
- package/src/modes/components/settings-selector.ts +116 -23
- package/src/modes/components/status-line/gh.ts +16 -0
- package/src/modes/components/tool-execution.ts +138 -27
- 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/event-controller.ts +109 -27
- package/src/modes/controllers/extension-ui-controller.ts +26 -2
- package/src/modes/controllers/input-controller.ts +18 -3
- package/src/modes/controllers/selector-controller.ts +127 -36
- package/src/modes/interactive-mode.ts +62 -3
- package/src/modes/irc-observation-ledger.ts +71 -15
- package/src/modes/runtime-init.ts +22 -2
- package/src/modes/types.ts +2 -0
- package/src/modes/utils/ui-helpers.ts +94 -1
- package/src/prompts/agent-fragments/ralplan-persistence.md +4 -3
- package/src/prompts/agents/architect.md +9 -1
- package/src/prompts/agents/critic.md +9 -0
- package/src/prompts/system/eager-todo.md +8 -4
- package/src/prompts/system/system-prompt.md +11 -5
- package/src/prompts/tools/bash.md +5 -5
- package/src/prompts/tools/read.md +4 -2
- 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/runtime-mcp/smithery-auth.ts +17 -2
- package/src/runtime-mcp/smithery-connect.ts +11 -1
- package/src/sdk/acp/adapter.ts +22 -6
- package/src/sdk/broker/lifecycle.ts +25 -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/control-drain-lease.ts +104 -0
- package/src/sdk/bus/index.ts +984 -146
- package/src/sdk/bus/kind-aware-reconciliation.ts +317 -0
- package/src/sdk/bus/lifecycle-commands.ts +4 -1
- package/src/sdk/bus/lifecycle-control-runtime.ts +403 -88
- package/src/sdk/bus/prompt-reconciliation.ts +2 -1
- package/src/sdk/bus/reconciliation-store.ts +303 -0
- 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/control/dispatch.ts +5 -1
- package/src/sdk/host/control/operations.ts +1 -1
- package/src/sdk/host/host.ts +23 -1
- package/src/sdk/host/query/handlers.ts +55 -0
- package/src/sdk/host/query/revision-store.ts +36 -2
- package/src/sdk/host/reverse-leases.ts +44 -7
- package/src/sdk/mcp/server.ts +46 -18
- package/src/sdk/prompt-status.ts +16 -3
- package/src/sdk/protocol/operation-inventory.generated.json +51 -0
- package/src/sdk/protocol/operation-registry.ts +7 -2
- package/src/sdk/session.ts +101 -10
- package/src/session/agent-session.ts +1286 -220
- 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/memory-guard-checkpoint-participant.ts +131 -0
- package/src/session/session-manager.ts +2364 -438
- package/src/session/session-storage.ts +9 -1
- package/src/session/streaming-output.ts +500 -111
- package/src/setup/credential-import.ts +75 -13
- package/src/setup/provider-onboarding.ts +19 -4
- package/src/skill-state/active-state.ts +32 -10
- package/src/skill-state/workflow-hud.ts +42 -2
- package/src/skill-state/workflow-mutation-guard.ts +560 -8
- 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 +169 -118
- 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/browser/launch.ts +41 -6
- package/src/tools/fetch.ts +114 -23
- 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 +281 -20
- package/src/tools/path-utils.ts +8 -3
- package/src/tools/read-internals.ts +44 -0
- package/src/tools/read.ts +1560 -310
- package/src/tools/resource-gc.ts +182 -18
- 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/tools/tool-result.ts +14 -1
- 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/codex.ts +1 -1
- 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/deep-interview-draft.d.ts +0 -8
- package/dist/types/gjc-runtime/deep-interview-payload.d.ts +0 -41
- package/dist/types/gjc-runtime/deep-interview-repair.d.ts +0 -17
- 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/deep-interview-draft.ts +0 -843
- package/src/gjc-runtime/deep-interview-payload.ts +0 -262
- package/src/gjc-runtime/deep-interview-repair.ts +0 -1674
- 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,
|
|
@@ -31,9 +33,9 @@ import {
|
|
|
31
33
|
toError,
|
|
32
34
|
} from "@gajae-code/utils";
|
|
33
35
|
import type { TtsrInjectionRecord } from "../export/ttsr";
|
|
36
|
+
import { assertSafePathComponent } from "../gjc-runtime/session-layout";
|
|
34
37
|
import { writeTextAtomic } from "../gjc-runtime/state-writer";
|
|
35
38
|
import type { ManagedLegacyLocalMigrationSource } from "../internal-urls/local-protocol";
|
|
36
|
-
|
|
37
39
|
import * as git from "../utils/git";
|
|
38
40
|
import { ArtifactManager } from "./artifacts";
|
|
39
41
|
import {
|
|
@@ -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,7 +83,25 @@ 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";
|
|
88
|
+
import {
|
|
89
|
+
hasOnlyKeys as hasOnlyMemoryGuardKeys,
|
|
90
|
+
isMemoryGuardDecimalString,
|
|
91
|
+
isMemoryGuardRelativePath,
|
|
92
|
+
isMemoryGuardSha256Hex,
|
|
93
|
+
type MemoryGuardCheckpointBlobAuthorityV1,
|
|
94
|
+
type MemoryGuardCheckpointBlobManifestEntryV1,
|
|
95
|
+
type MemoryGuardCheckpointBlobManifestV1,
|
|
96
|
+
type MemoryGuardCreateCheckpointInput,
|
|
97
|
+
type MemoryGuardParticipantDescriptorV1,
|
|
98
|
+
type MemoryGuardParticipantIngressLease,
|
|
99
|
+
type MemoryGuardRestoreInput,
|
|
100
|
+
type MemoryGuardRestoreResult,
|
|
101
|
+
type MemoryGuardSessionManagerCheckpointV1,
|
|
102
|
+
memoryGuardCanonicalJson,
|
|
103
|
+
memoryGuardSha256Hex,
|
|
104
|
+
} from "./memory-guard-checkpoint-participant";
|
|
82
105
|
|
|
83
106
|
import {
|
|
84
107
|
type BashExecutionMessage,
|
|
@@ -164,6 +187,107 @@ interface PreparedNewSessionState extends PreparedNewSession {
|
|
|
164
187
|
persistenceTempPath?: string;
|
|
165
188
|
}
|
|
166
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
|
+
|
|
167
291
|
export interface SessionEntryBase {
|
|
168
292
|
type: string;
|
|
169
293
|
id: string;
|
|
@@ -205,11 +329,16 @@ export interface SessionManagerObservabilityStats {
|
|
|
205
329
|
coldSpillReadCount: number;
|
|
206
330
|
residentTextReadCount: number;
|
|
207
331
|
residentImageReadCount: number;
|
|
332
|
+
residentCacheAdoptFallbackCount: number;
|
|
333
|
+
residentCacheTrustRejectCount: number;
|
|
334
|
+
residentCacheWin32FallbackCount: number;
|
|
335
|
+
residentCacheDegradedReason?: string;
|
|
208
336
|
publicMaterializerCallCount: number;
|
|
209
337
|
getEntryMaterializerCallCount: number;
|
|
210
338
|
getBranchMaterializerCallCount: number;
|
|
211
339
|
getEntriesMaterializerCallCount: number;
|
|
212
340
|
materializedEntriesCachePopulateCount: number;
|
|
341
|
+
materializedCacheDemotedCount: number;
|
|
213
342
|
pathOnlyContextBuildCount: number;
|
|
214
343
|
}
|
|
215
344
|
|
|
@@ -480,6 +609,238 @@ export type DefaultModelSelectionStage = {
|
|
|
480
609
|
readonly tempPath: string | undefined;
|
|
481
610
|
readonly persistsToExistingFile: boolean;
|
|
482
611
|
};
|
|
612
|
+
export interface SessionManagerRevisionSnapshot {
|
|
613
|
+
entry: number;
|
|
614
|
+
leaf: number;
|
|
615
|
+
headerExport: number;
|
|
616
|
+
label: number;
|
|
617
|
+
replayMetadata: number;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
export interface SessionManagerCheckpointRevisionStrings {
|
|
621
|
+
entry: string;
|
|
622
|
+
leaf: string;
|
|
623
|
+
headerExport: string;
|
|
624
|
+
label: string;
|
|
625
|
+
replayMetadata: string;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function assertMemoryGuardSessionId(sessionId: string): void {
|
|
629
|
+
assertSafePathComponent(sessionId, "memory guard checkpoint session id");
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function memoryGuardParticipantRoot(checkpointRoot: string, sessionId: string): string {
|
|
633
|
+
assertMemoryGuardSessionId(sessionId);
|
|
634
|
+
return path.join(checkpointRoot, "participants", sessionId);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function memoryGuardParticipantRelativePath(sessionId: string, suffix: string): string {
|
|
638
|
+
assertMemoryGuardSessionId(sessionId);
|
|
639
|
+
return `participants/${sessionId}/${suffix}`;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
async function fsyncDirectoryPath(directoryPath: string): Promise<void> {
|
|
643
|
+
const directory = await fs.promises.open(directoryPath, "r");
|
|
644
|
+
try {
|
|
645
|
+
await directory.sync();
|
|
646
|
+
} finally {
|
|
647
|
+
await directory.close();
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
async function ensureOwnerOnlyDirectory(directoryPath: string): Promise<void> {
|
|
652
|
+
await fs.promises.mkdir(directoryPath, { recursive: true, mode: 0o700 });
|
|
653
|
+
await fs.promises.chmod(directoryPath, 0o700).catch(() => undefined);
|
|
654
|
+
await fsyncDirectoryPath(directoryPath);
|
|
655
|
+
}
|
|
656
|
+
interface CreatedDirectoryIdentity {
|
|
657
|
+
dev: bigint;
|
|
658
|
+
ino: bigint;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
async function ensureOwnerOnlyDirectoryTracked(directoryPath: string): Promise<CreatedDirectoryIdentity | undefined> {
|
|
662
|
+
const createdPath = await fs.promises.mkdir(directoryPath, { recursive: true, mode: 0o700 });
|
|
663
|
+
await fs.promises.chmod(directoryPath, 0o700).catch(() => undefined);
|
|
664
|
+
await fsyncDirectoryPath(directoryPath);
|
|
665
|
+
if (createdPath === undefined) return undefined;
|
|
666
|
+
const stat = await fs.promises.lstat(directoryPath, { bigint: true });
|
|
667
|
+
return { dev: stat.dev, ino: stat.ino };
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
async function removeCreatedDirectoryIfEmpty(
|
|
671
|
+
directoryPath: string,
|
|
672
|
+
created: CreatedDirectoryIdentity | undefined,
|
|
673
|
+
): Promise<void> {
|
|
674
|
+
if (!created) return;
|
|
675
|
+
const stat = await fs.promises.lstat(directoryPath, { bigint: true }).catch(() => undefined);
|
|
676
|
+
if (!stat || stat.dev !== created.dev || stat.ino !== created.ino) return;
|
|
677
|
+
await fs.promises.rmdir(directoryPath).catch(() => undefined);
|
|
678
|
+
await fsyncDirectoryPath(path.dirname(directoryPath)).catch(() => undefined);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
async function writeOwnerOnlyFileNoReplace(filePath: string, content: Uint8Array | string): Promise<void> {
|
|
682
|
+
await ensureOwnerOnlyDirectory(path.dirname(filePath));
|
|
683
|
+
const handle = await fs.promises.open(filePath, "wx", 0o600);
|
|
684
|
+
try {
|
|
685
|
+
await handle.writeFile(content);
|
|
686
|
+
await handle.sync();
|
|
687
|
+
} finally {
|
|
688
|
+
await handle.close();
|
|
689
|
+
}
|
|
690
|
+
await fs.promises.chmod(filePath, 0o600).catch(() => undefined);
|
|
691
|
+
await fsyncDirectoryPath(path.dirname(filePath));
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const MEMORY_GUARD_CHECKPOINT_FILE_MAX_BYTES = 64 * 1024 * 1024;
|
|
695
|
+
const MEMORY_GUARD_CHECKPOINT_BLOB_MAX_ENTRIES = 4096;
|
|
696
|
+
const MEMORY_GUARD_CHECKPOINT_BLOB_TOTAL_MAX_BYTES = 64 * 1024 * 1024;
|
|
697
|
+
|
|
698
|
+
function collectCheckpointBlobRefs(value: unknown, refs: Set<string> = new Set(), key?: string): Set<string> {
|
|
699
|
+
const addRef = (candidate: unknown): void => {
|
|
700
|
+
if (typeof candidate === "string" && parseBlobRef(candidate)) refs.add(candidate);
|
|
701
|
+
};
|
|
702
|
+
if (Array.isArray(value)) {
|
|
703
|
+
for (const item of value) collectCheckpointBlobRefs(item, refs, key);
|
|
704
|
+
return refs;
|
|
705
|
+
}
|
|
706
|
+
if (!value || typeof value !== "object") return refs;
|
|
707
|
+
if (isRecord(value) && value.kind === "cold_spill" && typeof value.ref === "string" && parseBlobRef(value.ref))
|
|
708
|
+
refs.add(value.ref);
|
|
709
|
+
if (isImageBlock(value) && key === TEXT_CONTENT_KEY) addRef(value.data);
|
|
710
|
+
if (hasImageUrl(value)) {
|
|
711
|
+
if (typeof value.image_url === "string") addRef(value.image_url);
|
|
712
|
+
else addRef(value.image_url.url);
|
|
713
|
+
}
|
|
714
|
+
for (const [childKey, item] of Object.entries(value as Record<string, unknown>)) {
|
|
715
|
+
if (childKey === "data" && key !== TEXT_CONTENT_KEY) addRef(item);
|
|
716
|
+
collectCheckpointBlobRefs(item, refs, childKey);
|
|
717
|
+
}
|
|
718
|
+
return refs;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function decodeCheckpointUtf8(data: Uint8Array): string | null {
|
|
722
|
+
try {
|
|
723
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(data);
|
|
724
|
+
} catch {
|
|
725
|
+
return null;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function validateMemoryGuardBlobAuthority(value: unknown): value is MemoryGuardCheckpointBlobAuthorityV1 {
|
|
730
|
+
if (
|
|
731
|
+
!isRecord(value) ||
|
|
732
|
+
!hasOnlyMemoryGuardKeys(value, ["kind", "manifest_relative_path", "manifest_sha256", "root_relative_path"])
|
|
733
|
+
)
|
|
734
|
+
return false;
|
|
735
|
+
return (
|
|
736
|
+
value.kind === "checkpoint_blob_tree_v1" &&
|
|
737
|
+
isMemoryGuardRelativePath(value.manifest_relative_path) &&
|
|
738
|
+
isMemoryGuardSha256Hex(value.manifest_sha256) &&
|
|
739
|
+
isMemoryGuardRelativePath(value.root_relative_path)
|
|
740
|
+
);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function validateMemoryGuardTranscriptDescriptor(
|
|
744
|
+
value: unknown,
|
|
745
|
+
): value is MemoryGuardSessionManagerCheckpointV1["transcript"] {
|
|
746
|
+
if (!isRecord(value) || !hasOnlyMemoryGuardKeys(value, ["bytes", "relative_path", "sha256"])) return false;
|
|
747
|
+
return (
|
|
748
|
+
isMemoryGuardDecimalString(value.bytes) &&
|
|
749
|
+
isMemoryGuardRelativePath(value.relative_path) &&
|
|
750
|
+
isMemoryGuardSha256Hex(value.sha256)
|
|
751
|
+
);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
function validateMemoryGuardRevisions(value: unknown): value is SessionManagerCheckpointRevisionStrings {
|
|
755
|
+
if (!isRecord(value) || !hasOnlyMemoryGuardKeys(value, ["entry", "leaf", "headerExport", "label", "replayMetadata"]))
|
|
756
|
+
return false;
|
|
757
|
+
return (
|
|
758
|
+
isMemoryGuardDecimalString(value.entry) &&
|
|
759
|
+
isMemoryGuardDecimalString(value.leaf) &&
|
|
760
|
+
isMemoryGuardDecimalString(value.headerExport) &&
|
|
761
|
+
isMemoryGuardDecimalString(value.label) &&
|
|
762
|
+
isMemoryGuardDecimalString(value.replayMetadata)
|
|
763
|
+
);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function validateMemoryGuardCheckpoint(value: unknown): value is MemoryGuardSessionManagerCheckpointV1 {
|
|
767
|
+
if (
|
|
768
|
+
!isRecord(value) ||
|
|
769
|
+
!hasOnlyMemoryGuardKeys(value, [
|
|
770
|
+
"blob_authority",
|
|
771
|
+
"revisions",
|
|
772
|
+
"schema_version",
|
|
773
|
+
"session_id",
|
|
774
|
+
"session_name",
|
|
775
|
+
"transcript",
|
|
776
|
+
])
|
|
777
|
+
)
|
|
778
|
+
return false;
|
|
779
|
+
return (
|
|
780
|
+
value.schema_version === 1 &&
|
|
781
|
+
typeof value.session_id === "string" &&
|
|
782
|
+
value.session_id.length > 0 &&
|
|
783
|
+
(value.session_name === null || typeof value.session_name === "string") &&
|
|
784
|
+
validateMemoryGuardBlobAuthority(value.blob_authority) &&
|
|
785
|
+
validateMemoryGuardRevisions(value.revisions) &&
|
|
786
|
+
validateMemoryGuardTranscriptDescriptor(value.transcript)
|
|
787
|
+
);
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
function validateMemoryGuardBlobManifestEntry(value: unknown): value is MemoryGuardCheckpointBlobManifestEntryV1 {
|
|
791
|
+
if (!isRecord(value) || !hasOnlyMemoryGuardKeys(value, ["bytes", "relative_path", "sha256"])) return false;
|
|
792
|
+
return (
|
|
793
|
+
isMemoryGuardDecimalString(value.bytes) &&
|
|
794
|
+
isMemoryGuardRelativePath(value.relative_path) &&
|
|
795
|
+
isMemoryGuardSha256Hex(value.sha256)
|
|
796
|
+
);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
function validateMemoryGuardBlobManifest(value: unknown): value is MemoryGuardCheckpointBlobManifestV1 {
|
|
800
|
+
if (!isRecord(value) || !hasOnlyMemoryGuardKeys(value, ["entries", "schema_version"])) return false;
|
|
801
|
+
if (value.schema_version !== 1 || !Array.isArray(value.entries)) return false;
|
|
802
|
+
return value.entries.every(validateMemoryGuardBlobManifestEntry);
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
function memoryGuardParticipantMatchesCheckpoint(
|
|
806
|
+
participant: MemoryGuardParticipantDescriptorV1,
|
|
807
|
+
checkpoint: MemoryGuardSessionManagerCheckpointV1,
|
|
808
|
+
): boolean {
|
|
809
|
+
return (
|
|
810
|
+
participant.session_id === checkpoint.session_id &&
|
|
811
|
+
participant.session_name === checkpoint.session_name &&
|
|
812
|
+
JSON.stringify(participant.checkpoint) === JSON.stringify(checkpoint.blob_authority) &&
|
|
813
|
+
JSON.stringify(participant.revisions) === JSON.stringify(checkpoint.revisions) &&
|
|
814
|
+
JSON.stringify(participant.transcript) === JSON.stringify(checkpoint.transcript)
|
|
815
|
+
);
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function readCheckpointAuthorityFile(
|
|
819
|
+
authority: native.RecoveryFsRoot,
|
|
820
|
+
relativePath: string,
|
|
821
|
+
maxBytes: number,
|
|
822
|
+
): Uint8Array | null {
|
|
823
|
+
const result = authority.readManaged(relativePath);
|
|
824
|
+
if (!result.ok || !result.data || result.data.byteLength > maxBytes) return null;
|
|
825
|
+
return result.data;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
function toCanonicalRevisionString(name: keyof SessionManagerRevisionSnapshot, value: number): string {
|
|
829
|
+
if (!Number.isSafeInteger(value) || value < 0) throw new Error(`invalid_session_manager_revision:${name}`);
|
|
830
|
+
return String(value);
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
export function toSessionManagerCheckpointRevisionStrings(
|
|
834
|
+
snapshot: SessionManagerRevisionSnapshot,
|
|
835
|
+
): SessionManagerCheckpointRevisionStrings {
|
|
836
|
+
return {
|
|
837
|
+
entry: toCanonicalRevisionString("entry", snapshot.entry),
|
|
838
|
+
leaf: toCanonicalRevisionString("leaf", snapshot.leaf),
|
|
839
|
+
headerExport: toCanonicalRevisionString("headerExport", snapshot.headerExport),
|
|
840
|
+
label: toCanonicalRevisionString("label", snapshot.label),
|
|
841
|
+
replayMetadata: toCanonicalRevisionString("replayMetadata", snapshot.replayMetadata),
|
|
842
|
+
};
|
|
843
|
+
}
|
|
483
844
|
|
|
484
845
|
export type DefaultModelSelectionPromotion =
|
|
485
846
|
| { readonly kind: "promoted" }
|
|
@@ -1366,10 +1727,17 @@ function managedScopeStartupError(
|
|
|
1366
1727
|
action: "resolve" | "prepare",
|
|
1367
1728
|
failure: Extract<ReturnType<typeof resolveManagedScopeForWrite>, { kind: "error" }>,
|
|
1368
1729
|
): Error {
|
|
1369
|
-
|
|
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, {
|
|
1370
1738
|
cause: {
|
|
1371
|
-
classification
|
|
1372
|
-
...(
|
|
1739
|
+
classification,
|
|
1740
|
+
...(diagnostic === undefined ? {} : { diagnostic }),
|
|
1373
1741
|
},
|
|
1374
1742
|
});
|
|
1375
1743
|
}
|
|
@@ -1397,11 +1765,54 @@ export type SessionDestination =
|
|
|
1397
1765
|
| { readonly kind: "explicit"; readonly directory: string };
|
|
1398
1766
|
export type SessionDestinationInput = string | SessionDestination | undefined;
|
|
1399
1767
|
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
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
|
+
};
|
|
1405
1816
|
}
|
|
1406
1817
|
|
|
1407
1818
|
function retainedTreeSnapshotEquals(
|
|
@@ -1454,6 +1865,51 @@ function retainedTreeSnapshotEqualsAfterRename(
|
|
|
1454
1865
|
);
|
|
1455
1866
|
}
|
|
1456
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
|
+
|
|
1457
1913
|
function managedFileSnapshotEquals(left: ManagedFileSnapshot | null, right: ManagedFileSnapshot | null): boolean {
|
|
1458
1914
|
return (
|
|
1459
1915
|
left !== null &&
|
|
@@ -1468,6 +1924,7 @@ function managedFileSnapshotEquals(left: ManagedFileSnapshot | null, right: Mana
|
|
|
1468
1924
|
);
|
|
1469
1925
|
}
|
|
1470
1926
|
const trustedSessionDestinations = new WeakSet<SessionDestination>();
|
|
1927
|
+
const explicitProfileAgentDirs = new WeakMap<SessionDestination, string>();
|
|
1471
1928
|
const managedSecurityPolicies = new WeakMap<ManagedSessionSecurityContext, ManagedSessionSecurityPolicy>();
|
|
1472
1929
|
|
|
1473
1930
|
function managedSecurityPolicyForContext(securityContext: ManagedSessionSecurityContext): ManagedSessionSecurityPolicy {
|
|
@@ -1480,20 +1937,26 @@ function managedStoreFromContext(
|
|
|
1480
1937
|
securityContext: ManagedSessionSecurityContext,
|
|
1481
1938
|
directory: string,
|
|
1482
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);
|
|
1483
1944
|
return new ManagedSessionDescendantStore(
|
|
1484
1945
|
securityContext.rootAuthority,
|
|
1485
1946
|
directory,
|
|
1486
|
-
securityContext.retainedAuthority
|
|
1947
|
+
securityContext.retainedAuthority && ownsRetainedAuthority
|
|
1487
1948
|
? { authority: securityContext.retainedAuthority, authorityBaseDir: directory }
|
|
1488
1949
|
: undefined,
|
|
1489
1950
|
managedSecurityPolicyForContext(securityContext),
|
|
1951
|
+
securityContext.profileAgentDir,
|
|
1490
1952
|
);
|
|
1491
1953
|
}
|
|
1492
1954
|
|
|
1493
|
-
function freezeManagedDestination(scope: ManagedScope): SessionDestination {
|
|
1955
|
+
function freezeManagedDestination(scope: ManagedScope, profileAgentDir: string): SessionDestination {
|
|
1494
1956
|
const rootAuthority = managedRootForScope(scope);
|
|
1495
1957
|
const securityContext = createManagedSessionSecurityContext({
|
|
1496
1958
|
agentDir: scope.agentDir,
|
|
1959
|
+
profileAgentDir,
|
|
1497
1960
|
sessionsRoot: scope.sessionsRoot,
|
|
1498
1961
|
sessionDir: scope.directoryPath,
|
|
1499
1962
|
rootAuthority,
|
|
@@ -1511,6 +1974,7 @@ function freezeManagedDestination(scope: ManagedScope): SessionDestination {
|
|
|
1511
1974
|
function explicitDestination(directory: string): SessionDestination {
|
|
1512
1975
|
const destination = Object.freeze({ kind: "explicit" as const, directory });
|
|
1513
1976
|
trustedSessionDestinations.add(destination);
|
|
1977
|
+
explicitProfileAgentDirs.set(destination, getAgentDir());
|
|
1514
1978
|
return destination;
|
|
1515
1979
|
}
|
|
1516
1980
|
|
|
@@ -1528,7 +1992,7 @@ function managedDestination(cwd: string, storage: SessionStorage, agentDir?: str
|
|
|
1528
1992
|
process.platform === "win32" ? "windows-existing-verify-first" : "default",
|
|
1529
1993
|
);
|
|
1530
1994
|
if (prepared.kind === "error") throw managedScopeStartupError("prepare", prepared);
|
|
1531
|
-
return freezeManagedDestination(prepared.scope);
|
|
1995
|
+
return freezeManagedDestination(prepared.scope, agentDir ?? getAgentDir());
|
|
1532
1996
|
}
|
|
1533
1997
|
|
|
1534
1998
|
function destinationFor(
|
|
@@ -1702,6 +2166,10 @@ function sameResumeStat(left: SessionStorageStat, right: SessionStorageStat): bo
|
|
|
1702
2166
|
);
|
|
1703
2167
|
}
|
|
1704
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
|
+
|
|
1705
2173
|
interface ResumeInspectionSnapshot {
|
|
1706
2174
|
identity: ResumeSessionIdentity;
|
|
1707
2175
|
content: Uint8Array;
|
|
@@ -2431,6 +2899,48 @@ function collectResidentImageRefs(value: unknown, refs: Set<string>, seen = new
|
|
|
2431
2899
|
for (const child of Object.values(value)) collectResidentImageRefs(child, refs, seen);
|
|
2432
2900
|
}
|
|
2433
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
|
+
|
|
2434
2944
|
/**
|
|
2435
2945
|
* Recursively truncate large strings in an object for session persistence.
|
|
2436
2946
|
* - Truncates any oversized string fields (key-agnostic)
|
|
@@ -2527,13 +3037,13 @@ function shouldExternalizeResidentString(key: string | undefined): boolean {
|
|
|
2527
3037
|
interface ResidentBlobStores {
|
|
2528
3038
|
textStore: BlobStore;
|
|
2529
3039
|
imageStore: BlobStore;
|
|
3040
|
+
/** Trusted alternate bytes used only while demoting an invalidated resident store. */
|
|
3041
|
+
textFallback?: (hash: string) => Buffer | null;
|
|
2530
3042
|
sessionId?: string;
|
|
2531
3043
|
sessionFile?: string;
|
|
2532
3044
|
onResidentBlobRead?: (kind: ResidentBlobKind) => void;
|
|
2533
3045
|
}
|
|
2534
3046
|
|
|
2535
|
-
type ResidentBlobMissingPolicy = "throw" | "placeholder";
|
|
2536
|
-
|
|
2537
3047
|
function residentBlobMissingPlaceholder(error: ResidentBlobMissingError): string {
|
|
2538
3048
|
return `[Session resident ${error.kind} blob missing: sha256:${error.hash}; original content unavailable]`;
|
|
2539
3049
|
}
|
|
@@ -2544,7 +3054,7 @@ function externalizeResidentValueSync(obj: unknown, stores: ResidentBlobStores,
|
|
|
2544
3054
|
if (key === "image_url" && isImageDataUrl(obj) && obj.length >= BLOB_EXTERNALIZE_THRESHOLD)
|
|
2545
3055
|
return residentBlobSentinel("imageUrl", externalizeImageDataUrlSync(stores.imageStore, obj));
|
|
2546
3056
|
if (shouldExternalizeResidentString(key) && obj.length >= BLOB_EXTERNALIZE_THRESHOLD)
|
|
2547
|
-
return residentBlobSentinel("text", stores.textStore.
|
|
3057
|
+
return residentBlobSentinel("text", stores.textStore.putOwnedSync(Buffer.from(obj, "utf8")).ref);
|
|
2548
3058
|
return obj;
|
|
2549
3059
|
}
|
|
2550
3060
|
if (Array.isArray(obj)) {
|
|
@@ -2603,12 +3113,16 @@ function materializeResidentValueSync(
|
|
|
2603
3113
|
if (cached !== undefined) return cached;
|
|
2604
3114
|
let resolved: string;
|
|
2605
3115
|
try {
|
|
2606
|
-
|
|
2607
|
-
obj.
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
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
|
+
}
|
|
2612
3126
|
} catch (err) {
|
|
2613
3127
|
if (missingPolicy === "placeholder" && err instanceof ResidentBlobMissingError) {
|
|
2614
3128
|
resolved = residentBlobMissingPlaceholder(err);
|
|
@@ -2616,6 +3130,7 @@ function materializeResidentValueSync(
|
|
|
2616
3130
|
throw err;
|
|
2617
3131
|
}
|
|
2618
3132
|
}
|
|
3133
|
+
|
|
2619
3134
|
cache.set(cacheKey, resolved);
|
|
2620
3135
|
stores.onResidentBlobRead?.(obj.kind);
|
|
2621
3136
|
return resolved;
|
|
@@ -2659,6 +3174,35 @@ function materializeResidentEntriesSync<T extends FileEntry | SessionEntry>(
|
|
|
2659
3174
|
return entries.map(entry => materializeResidentEntrySync(entry, stores, cache, missingPolicy));
|
|
2660
3175
|
}
|
|
2661
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
|
+
|
|
2662
3206
|
function materializeResidentEntryForReadSync<T extends FileEntry | SessionEntry>(
|
|
2663
3207
|
entry: T,
|
|
2664
3208
|
stores: ResidentBlobStores,
|
|
@@ -2667,6 +3211,63 @@ function materializeResidentEntryForReadSync<T extends FileEntry | SessionEntry>
|
|
|
2667
3211
|
return materializeResidentEntrySync(entry, stores, cache, "placeholder");
|
|
2668
3212
|
}
|
|
2669
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
|
+
|
|
2670
3271
|
function materializeResidentEntriesForReadSync<T extends FileEntry | SessionEntry>(
|
|
2671
3272
|
entries: T[],
|
|
2672
3273
|
stores: ResidentBlobStores,
|
|
@@ -3506,6 +4107,81 @@ class NdjsonFileWriter {
|
|
|
3506
4107
|
}
|
|
3507
4108
|
}
|
|
3508
4109
|
|
|
4110
|
+
const PROJECT_SESSION_SCAN_MAX_DIRECTORIES = 4096;
|
|
4111
|
+
const PROJECT_SESSION_SCAN_MAX_FILES = 1000;
|
|
4112
|
+
|
|
4113
|
+
function isProjectSessionTranscriptPath(projectGjcDir: string, filePath: string): boolean {
|
|
4114
|
+
const relative = path.relative(projectGjcDir, filePath);
|
|
4115
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) return false;
|
|
4116
|
+
const segments = relative.split(path.sep);
|
|
4117
|
+
if (segments.length === 1) return true;
|
|
4118
|
+
const parent = segments.at(-2);
|
|
4119
|
+
return parent === "agent-session" || segments.includes("sessions");
|
|
4120
|
+
}
|
|
4121
|
+
|
|
4122
|
+
/**
|
|
4123
|
+
* Discover resumable transcripts intentionally stored inside a project's `.gjc`.
|
|
4124
|
+
* Runtime token/audit JSONL files are excluded by requiring a known transcript
|
|
4125
|
+
* container (`agent-session` or `sessions`).
|
|
4126
|
+
*/
|
|
4127
|
+
function listProjectSessionTranscriptFiles(cwd: string): string[] {
|
|
4128
|
+
const projectGjcDir = path.join(path.resolve(cwd), ".gjc");
|
|
4129
|
+
let rootStat: fs.Stats;
|
|
4130
|
+
try {
|
|
4131
|
+
rootStat = fs.lstatSync(projectGjcDir);
|
|
4132
|
+
} catch {
|
|
4133
|
+
return [];
|
|
4134
|
+
}
|
|
4135
|
+
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) return [];
|
|
4136
|
+
|
|
4137
|
+
const directories = [projectGjcDir];
|
|
4138
|
+
const files: string[] = [];
|
|
4139
|
+
let scannedDirectories = 0;
|
|
4140
|
+
while (directories.length > 0 && scannedDirectories < PROJECT_SESSION_SCAN_MAX_DIRECTORIES) {
|
|
4141
|
+
const directory = directories.pop()!;
|
|
4142
|
+
scannedDirectories++;
|
|
4143
|
+
let entries: fs.Dirent[];
|
|
4144
|
+
try {
|
|
4145
|
+
entries = fs.readdirSync(directory, { withFileTypes: true });
|
|
4146
|
+
} catch {
|
|
4147
|
+
continue;
|
|
4148
|
+
}
|
|
4149
|
+
for (const entry of entries) {
|
|
4150
|
+
if (entry.isSymbolicLink()) continue;
|
|
4151
|
+
const entryPath = path.join(directory, entry.name);
|
|
4152
|
+
if (entry.isDirectory()) {
|
|
4153
|
+
directories.push(entryPath);
|
|
4154
|
+
continue;
|
|
4155
|
+
}
|
|
4156
|
+
if (
|
|
4157
|
+
entry.isFile() &&
|
|
4158
|
+
!entry.name.startsWith(".") &&
|
|
4159
|
+
entry.name.endsWith(".jsonl") &&
|
|
4160
|
+
isProjectSessionTranscriptPath(projectGjcDir, entryPath)
|
|
4161
|
+
) {
|
|
4162
|
+
files.push(entryPath);
|
|
4163
|
+
if (files.length >= PROJECT_SESSION_SCAN_MAX_FILES) return files;
|
|
4164
|
+
}
|
|
4165
|
+
}
|
|
4166
|
+
}
|
|
4167
|
+
return files;
|
|
4168
|
+
}
|
|
4169
|
+
|
|
4170
|
+
async function collectProjectSessions(cwd: string, storage: FileSessionStorage): Promise<SessionInfo[]> {
|
|
4171
|
+
return await collectSessionsFromFiles(listProjectSessionTranscriptFiles(cwd), storage);
|
|
4172
|
+
}
|
|
4173
|
+
|
|
4174
|
+
function mergeSessionInventories(...inventories: SessionInfo[][]): SessionInfo[] {
|
|
4175
|
+
const sessions = new Map<string, SessionInfo>();
|
|
4176
|
+
for (const inventory of inventories) {
|
|
4177
|
+
for (const session of inventory) {
|
|
4178
|
+
const current = sessions.get(session.id);
|
|
4179
|
+
if (!current || session.modified.getTime() > current.modified.getTime()) sessions.set(session.id, session);
|
|
4180
|
+
}
|
|
4181
|
+
}
|
|
4182
|
+
return [...sessions.values()].sort((left, right) => right.modified.getTime() - left.modified.getTime());
|
|
4183
|
+
}
|
|
4184
|
+
|
|
3509
4185
|
const DEFAULT_WELCOME_RECENT_SESSION_LIMIT = 20;
|
|
3510
4186
|
|
|
3511
4187
|
/** Get recent sessions for display in welcome screen */
|
|
@@ -3517,6 +4193,21 @@ export async function getRecentSessions(
|
|
|
3517
4193
|
return getSortedSessions(sessionDir, storage, limit);
|
|
3518
4194
|
}
|
|
3519
4195
|
|
|
4196
|
+
export function getRecentSessionDisplay(
|
|
4197
|
+
sessions: readonly SessionInfo[],
|
|
4198
|
+
limit = DEFAULT_WELCOME_RECENT_SESSION_LIMIT,
|
|
4199
|
+
): Array<{ name: string; timeAgo: string }> {
|
|
4200
|
+
return sessions.slice(0, limit).map(session => {
|
|
4201
|
+
const recent = new RecentSessionInfo(
|
|
4202
|
+
session.path,
|
|
4203
|
+
session.modified.getTime(),
|
|
4204
|
+
{ title: session.title, timestamp: session.created.toISOString() },
|
|
4205
|
+
session.firstMessage,
|
|
4206
|
+
);
|
|
4207
|
+
return { name: recent.name, timeAgo: recent.timeAgo };
|
|
4208
|
+
});
|
|
4209
|
+
}
|
|
4210
|
+
|
|
3520
4211
|
/**
|
|
3521
4212
|
* Manages conversation sessions as append-only trees stored in JSONL files.
|
|
3522
4213
|
*
|
|
@@ -3623,7 +4314,6 @@ const SESSION_NAME_MAX_CHARS = 1_000;
|
|
|
3623
4314
|
const SESSION_LIST_PARALLEL_THRESHOLD = 64;
|
|
3624
4315
|
const SESSION_LIST_MAX_WORKERS = 16;
|
|
3625
4316
|
const sessionListPrefixDecoder = new TextDecoder("utf-8", { fatal: false });
|
|
3626
|
-
let residentCacheInstanceCounter = 0;
|
|
3627
4317
|
|
|
3628
4318
|
async function readSessionListPrefix(file: string, storage: SessionStorage, buffer: Buffer): Promise<string> {
|
|
3629
4319
|
if (!(storage instanceof FileSessionStorage)) {
|
|
@@ -4003,6 +4693,34 @@ interface SessionManagerStateSnapshot {
|
|
|
4003
4693
|
materializedFileEntries: FileEntry[];
|
|
4004
4694
|
}
|
|
4005
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
|
+
|
|
4006
4724
|
export class SessionManager {
|
|
4007
4725
|
#sessionId: string = "";
|
|
4008
4726
|
/** True once a lifecycle pre-allocated id has been adopted (consume-once). */
|
|
@@ -4015,6 +4733,8 @@ export class SessionManager {
|
|
|
4015
4733
|
#needsFullRewriteOnNextPersist: boolean = false;
|
|
4016
4734
|
#ensuredOnDisk: boolean = false;
|
|
4017
4735
|
#recoveryHydrationContext: RecoveryHydrationContext | undefined;
|
|
4736
|
+
#recoveryPromotionTranscriptPath: string | undefined;
|
|
4737
|
+
#memoryGuardParticipantIngressToken: symbol | undefined;
|
|
4018
4738
|
#fileEntries: FileEntry[] = [];
|
|
4019
4739
|
#pendingStrictAdoption: { canonicalPath: string; identity: ResumeSessionIdentity } | undefined;
|
|
4020
4740
|
#byId: Map<string, SessionEntry> = new Map();
|
|
@@ -4039,6 +4759,7 @@ export class SessionManager {
|
|
|
4039
4759
|
#artifactManager: ArtifactManager | null = null;
|
|
4040
4760
|
#artifactManagerSessionFile: string | null = null;
|
|
4041
4761
|
#managedTranscriptStoreCache: { directory: string; store: ManagedSessionDescendantStore } | null = null;
|
|
4762
|
+
#ownedManagedAuthority: ManagedSessionDescendantStore | undefined;
|
|
4042
4763
|
// When set, take precedence over the lazily-derived per-session manager.
|
|
4043
4764
|
// Subagents adopt the parent's manager so artifact IDs are unique across the
|
|
4044
4765
|
// whole agent tree and all files land in the parent's artifacts dir.
|
|
@@ -4049,7 +4770,8 @@ export class SessionManager {
|
|
|
4049
4770
|
#inMemoryArtifactCounter = 0;
|
|
4050
4771
|
readonly #blobStore: BlobStore;
|
|
4051
4772
|
#residentTextBlobStore: BlobStore = new MemoryBlobStore();
|
|
4052
|
-
|
|
4773
|
+
#residentImageBlobStore: BlobStore;
|
|
4774
|
+
#memoryGuardCheckpointBlobs: Map<string, Buffer> | undefined;
|
|
4053
4775
|
#entryRevision = 0;
|
|
4054
4776
|
#leafRevision = 0;
|
|
4055
4777
|
/** Export/header cache invalidation contract; consumers may arrive after the revision field. */
|
|
@@ -4058,8 +4780,9 @@ export class SessionManager {
|
|
|
4058
4780
|
#labelRevision = 0;
|
|
4059
4781
|
#replayMetadataRevision = 0;
|
|
4060
4782
|
#materializedEntriesRevision = -1;
|
|
4061
|
-
#materializedEntriesCache: SessionEntry[] | undefined;
|
|
4062
|
-
#sessionContextCache: SessionContext | undefined;
|
|
4783
|
+
#materializedEntriesCache: MaterializedCacheReference<SessionEntry[]> | undefined;
|
|
4784
|
+
#sessionContextCache: MaterializedCacheReference<SessionContext> | undefined;
|
|
4785
|
+
#materializedCachesWeaklyHeld = false;
|
|
4063
4786
|
#sessionContextEntryRevision = -1;
|
|
4064
4787
|
#sessionContextLeafRevision = -1;
|
|
4065
4788
|
#sessionContextReplayMetadataRevision = -1;
|
|
@@ -4067,11 +4790,15 @@ export class SessionManager {
|
|
|
4067
4790
|
#coldSpillReadCount = 0;
|
|
4068
4791
|
#residentTextReadCount = 0;
|
|
4069
4792
|
#residentImageReadCount = 0;
|
|
4793
|
+
#residentCacheAdoptFallbackCount = 0;
|
|
4794
|
+
#residentCacheTrustRejectCount = 0;
|
|
4795
|
+
#residentCacheWin32FallbackCount = 0;
|
|
4070
4796
|
#publicMaterializerCallCount = 0;
|
|
4071
4797
|
#getEntryMaterializerCallCount = 0;
|
|
4072
4798
|
#getBranchMaterializerCallCount = 0;
|
|
4073
4799
|
#getEntriesMaterializerCallCount = 0;
|
|
4074
4800
|
#materializedEntriesCachePopulateCount = 0;
|
|
4801
|
+
#materializedCacheDemotedCount = 0;
|
|
4075
4802
|
#pathOnlyContextBuildCount = 0;
|
|
4076
4803
|
#internalReadAccess: SessionManagerReadAccess = {
|
|
4077
4804
|
getEntries: () => freezeInternalReadSnapshot(this.#getMaterializedEntriesInternal()),
|
|
@@ -4110,37 +4837,202 @@ export class SessionManager {
|
|
|
4110
4837
|
};
|
|
4111
4838
|
}
|
|
4112
4839
|
|
|
4113
|
-
|
|
4114
|
-
|
|
4115
|
-
|
|
4116
|
-
|
|
4117
|
-
|
|
4118
|
-
|
|
4119
|
-
|
|
4120
|
-
|
|
4121
|
-
|
|
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
|
+
|
|
4875
|
+
#residentBlobStoresForColdRehydrate(): ResidentBlobStores {
|
|
4876
|
+
return {
|
|
4877
|
+
...this.#residentBlobStores(),
|
|
4878
|
+
onResidentBlobRead: kind => {
|
|
4879
|
+
if (kind === "text") {
|
|
4880
|
+
this.#residentTextReadCount++;
|
|
4881
|
+
} else {
|
|
4882
|
+
this.#residentImageReadCount++;
|
|
4883
|
+
}
|
|
4122
4884
|
},
|
|
4123
4885
|
};
|
|
4124
4886
|
}
|
|
4887
|
+
#coldSpillReadStore(): BlobStore {
|
|
4888
|
+
return this.#memoryGuardCheckpointBlobs ? this.#residentImageBlobStore : this.#blobStore;
|
|
4889
|
+
}
|
|
4125
4890
|
|
|
4126
|
-
#
|
|
4127
|
-
|
|
4128
|
-
|
|
4129
|
-
|
|
4130
|
-
"resident-cache",
|
|
4131
|
-
`${this.#sessionId || "pending"}-${process.pid}-${instance}`,
|
|
4132
|
-
);
|
|
4891
|
+
#residentCacheProfileAgentDir(): string {
|
|
4892
|
+
return this.destination.kind === "managed"
|
|
4893
|
+
? this.destination.securityContext.profileAgentDir
|
|
4894
|
+
: (explicitProfileAgentDirs.get(this.destination) ?? getAgentDir());
|
|
4133
4895
|
}
|
|
4134
4896
|
|
|
4135
|
-
#
|
|
4136
|
-
|
|
4137
|
-
|
|
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,
|
|
4945
|
+
);
|
|
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,
|
|
4138
4960
|
);
|
|
4139
|
-
|
|
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
|
+
}
|
|
4140
5032
|
}
|
|
4141
5033
|
|
|
4142
5034
|
#preparePreparedNewSessionForCommit(stage: PreparedNewSessionState): void {
|
|
4143
|
-
const residentTextBlobStore =
|
|
5035
|
+
const residentTextBlobStore = this.#newCanonicalResidentTextStore();
|
|
4144
5036
|
const residentFileEntries = stage.fileEntries.map(entry =>
|
|
4145
5037
|
prepareEntryForResidentSync(entry, {
|
|
4146
5038
|
textStore: residentTextBlobStore,
|
|
@@ -4157,6 +5049,21 @@ export class SessionManager {
|
|
|
4157
5049
|
#resetMaterializedCaches(): void {
|
|
4158
5050
|
this.#materializedEntriesRevision = -1;
|
|
4159
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);
|
|
4160
5067
|
}
|
|
4161
5068
|
|
|
4162
5069
|
#bumpEntryRevision(): void {
|
|
@@ -4178,13 +5085,7 @@ export class SessionManager {
|
|
|
4178
5085
|
* revision contract). Tests assert the invalidation mapping through this;
|
|
4179
5086
|
* future export/label-view caches key off their respective domains.
|
|
4180
5087
|
*/
|
|
4181
|
-
revisionSnapshot(): {
|
|
4182
|
-
entry: number;
|
|
4183
|
-
leaf: number;
|
|
4184
|
-
headerExport: number;
|
|
4185
|
-
label: number;
|
|
4186
|
-
replayMetadata: number;
|
|
4187
|
-
} {
|
|
5088
|
+
revisionSnapshot(): SessionManagerRevisionSnapshot {
|
|
4188
5089
|
return {
|
|
4189
5090
|
entry: this.#entryRevision,
|
|
4190
5091
|
leaf: this.#leafRevision,
|
|
@@ -4194,26 +5095,170 @@ export class SessionManager {
|
|
|
4194
5095
|
};
|
|
4195
5096
|
}
|
|
4196
5097
|
|
|
4197
|
-
#
|
|
4198
|
-
if (
|
|
4199
|
-
|
|
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");
|
|
4200
5164
|
}
|
|
4201
|
-
this.#residentTextBlobStore = new MemoryBlobStore();
|
|
4202
|
-
this.#resetMaterializedCaches();
|
|
4203
5165
|
}
|
|
4204
5166
|
|
|
4205
|
-
#
|
|
4206
|
-
|
|
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);
|
|
4207
5173
|
if (
|
|
4208
|
-
|
|
4209
|
-
|
|
4210
|
-
|
|
4211
|
-
|
|
5174
|
+
!removed.ok &&
|
|
5175
|
+
!(
|
|
5176
|
+
removed.code === "cleanup_pending" &&
|
|
5177
|
+
(removed.detachedPath ??
|
|
5178
|
+
removed.retainedSuccessorPath ??
|
|
5179
|
+
removed.retainedPlaceholderPath ??
|
|
5180
|
+
removed.retainedUnknownPath) !== undefined
|
|
5181
|
+
)
|
|
4212
5182
|
) {
|
|
4213
|
-
|
|
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();
|
|
4214
5249
|
}
|
|
4215
5250
|
}
|
|
4216
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
|
+
|
|
4217
5262
|
/** Puts a binary blob into the blob store and returns the blob reference */
|
|
4218
5263
|
async putBlob(data: Buffer): Promise<BlobPutResult> {
|
|
4219
5264
|
return this.#blobStore.put(data);
|
|
@@ -4243,6 +5288,17 @@ export class SessionManager {
|
|
|
4243
5288
|
|
|
4244
5289
|
restoreState(snapshot: SessionManagerStateSnapshot): void {
|
|
4245
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
|
+
);
|
|
4246
5302
|
const retainsPersistWriter =
|
|
4247
5303
|
this.#persistWriter?.isOpen() === true &&
|
|
4248
5304
|
this.#sessionId === snapshot.sessionId &&
|
|
@@ -4255,7 +5311,6 @@ export class SessionManager {
|
|
|
4255
5311
|
this.#flushed = snapshot.flushed;
|
|
4256
5312
|
this.#ensuredOnDisk = snapshot.ensuredOnDisk;
|
|
4257
5313
|
this.#needsFullRewriteOnNextPersist = snapshot.needsFullRewriteOnNextPersist;
|
|
4258
|
-
this.#fileEntries = restoredFileEntries;
|
|
4259
5314
|
if (!retainsPersistWriter) {
|
|
4260
5315
|
this.#persistWriter = undefined;
|
|
4261
5316
|
this.#persistWriterPath = undefined;
|
|
@@ -4266,101 +5321,312 @@ export class SessionManager {
|
|
|
4266
5321
|
this.#artifactManager = null;
|
|
4267
5322
|
this.#artifactManagerSessionFile = null;
|
|
4268
5323
|
this.#adoptedArtifactManager = null;
|
|
4269
|
-
this.#
|
|
4270
|
-
this.#
|
|
4271
|
-
this.#bumpAllRevisions();
|
|
4272
|
-
if (this.#sessionFile) {
|
|
4273
|
-
writeTerminalBreadcrumb(this.cwd, this.#sessionFile);
|
|
4274
|
-
}
|
|
5324
|
+
this.#commitResidentTextStoreTransition(prepared);
|
|
5325
|
+
if (this.#sessionFile) writeTerminalBreadcrumb(this.cwd, this.#sessionFile);
|
|
4275
5326
|
}
|
|
4276
5327
|
|
|
4277
|
-
|
|
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
|
+
};
|
|
5350
|
+
}
|
|
5351
|
+
|
|
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). */
|
|
4278
5389
|
async #initSessionFile(sessionFile: string): Promise<void> {
|
|
4279
|
-
|
|
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();
|
|
4280
5429
|
}
|
|
4281
5430
|
|
|
4282
|
-
async #hydrateExistingSession(
|
|
5431
|
+
async #hydrateExistingSession(
|
|
5432
|
+
sessionFile: string,
|
|
5433
|
+
entries: FileEntry[],
|
|
5434
|
+
migrationApplied: boolean,
|
|
5435
|
+
policy: Exclude<ResidentTransitionFailurePolicy, "install-staged"> = "memory-fallback",
|
|
5436
|
+
): Promise<void> {
|
|
4283
5437
|
// Strict inspection creates these entries solely for this hydration path. Adopt that
|
|
4284
5438
|
// final, validated representation instead of cloning the complete transcript again.
|
|
4285
5439
|
const header = entries[0] as SessionHeader;
|
|
4286
|
-
|
|
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;
|
|
4287
5455
|
this.#sessionId = header.id;
|
|
4288
5456
|
this.#sessionName = header.title;
|
|
4289
5457
|
this.#titleSource = header.titleSource;
|
|
4290
5458
|
this.#needsFullRewriteOnNextPersist = migrationApplied;
|
|
4291
|
-
|
|
4292
|
-
this.#fileEntries = entries;
|
|
4293
|
-
this.#resetResidentTextBlobStore();
|
|
4294
|
-
this.#fileEntries = this.#fileEntries.map(entry =>
|
|
4295
|
-
prepareEntryForResidentSync(entry, this.#residentBlobStores()),
|
|
4296
|
-
);
|
|
4297
|
-
this.#buildIndex();
|
|
4298
|
-
this.#bumpAllRevisions();
|
|
5459
|
+
this.#commitResidentTextStoreTransition(prepared);
|
|
4299
5460
|
this.#flushed = true;
|
|
4300
5461
|
this.#ensuredOnDisk = true;
|
|
4301
5462
|
}
|
|
4302
5463
|
|
|
4303
|
-
/** Initialize with a new session (used by factory methods) */
|
|
5464
|
+
/** Initialize with a new session (used by factory methods). */
|
|
4304
5465
|
#initNewSession(): void {
|
|
4305
5466
|
this.#newSessionSync();
|
|
4306
|
-
this.#bumpAllRevisions();
|
|
4307
5467
|
}
|
|
4308
5468
|
|
|
4309
|
-
/** Switch to a different session file (used for resume and branching) */
|
|
5469
|
+
/** Switch to a different session file (used for resume and branching). */
|
|
4310
5470
|
async setSessionFile(sessionFile: string): Promise<void> {
|
|
5471
|
+
this.#assertRecoveryHydrationWritable();
|
|
4311
5472
|
const resolvedSessionFile = this.storage instanceof FileSessionStorage ? path.resolve(sessionFile) : sessionFile;
|
|
4312
5473
|
const strictAdoption = this.#pendingStrictAdoption;
|
|
4313
|
-
|
|
4314
|
-
if (
|
|
4315
|
-
const
|
|
4316
|
-
if (
|
|
4317
|
-
|
|
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
|
+
}
|
|
4318
5484
|
}
|
|
4319
|
-
|
|
4320
|
-
|
|
4321
|
-
|
|
4322
|
-
|
|
4323
|
-
|
|
4324
|
-
|
|
4325
|
-
|
|
4326
|
-
|
|
4327
|
-
if (
|
|
4328
|
-
|
|
4329
|
-
|
|
4330
|
-
|
|
4331
|
-
|
|
4332
|
-
|
|
4333
|
-
|
|
4334
|
-
|
|
4335
|
-
|
|
4336
|
-
|
|
4337
|
-
|
|
4338
|
-
|
|
4339
|
-
|
|
4340
|
-
|
|
4341
|
-
|
|
4342
|
-
|
|
4343
|
-
|
|
4344
|
-
|
|
4345
|
-
|
|
4346
|
-
|
|
4347
|
-
|
|
4348
|
-
|
|
4349
|
-
|
|
4350
|
-
|
|
4351
|
-
|
|
4352
|
-
|
|
4353
|
-
|
|
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;
|
|
4354
5618
|
await this.#rewriteFile();
|
|
4355
5619
|
this.#flushed = true;
|
|
4356
5620
|
this.#ensuredOnDisk = true;
|
|
4357
|
-
|
|
4358
|
-
|
|
5621
|
+
} catch (error) {
|
|
5622
|
+
managedTransition?.dispose();
|
|
5623
|
+
throw error;
|
|
4359
5624
|
}
|
|
4360
5625
|
}
|
|
4361
5626
|
|
|
4362
5627
|
/** Start a new session. Closes any existing writer first. */
|
|
4363
5628
|
async newSession(options?: NewSessionOptions): Promise<string | undefined> {
|
|
5629
|
+
this.#assertRecoveryHydrationWritable();
|
|
4364
5630
|
const prepared = await this.prepareNewSession(options);
|
|
4365
5631
|
try {
|
|
4366
5632
|
this.commitPreparedNewSession(prepared);
|
|
@@ -4385,6 +5651,7 @@ export class SessionManager {
|
|
|
4385
5651
|
* @internal
|
|
4386
5652
|
*/
|
|
4387
5653
|
async prepareNewSession(options?: NewSessionOptions): Promise<PreparedNewSession> {
|
|
5654
|
+
this.#assertRecoveryHydrationWritable();
|
|
4388
5655
|
await this.#retryPreparedNewSessionCleanups();
|
|
4389
5656
|
await this.#closePersistWriter();
|
|
4390
5657
|
const preallocated = this.#lifecycleIdAdopted ? undefined : lifecyclePreallocatedSessionId();
|
|
@@ -4655,6 +5922,7 @@ export class SessionManager {
|
|
|
4655
5922
|
|
|
4656
5923
|
/** Prepare a path-only branch successor without publishing it through public manager state. @internal */
|
|
4657
5924
|
async prepareBranchedSession(leafId: string): Promise<PreparedNewSession> {
|
|
5925
|
+
this.#assertRecoveryHydrationWritable();
|
|
4658
5926
|
await this.#retryPreparedNewSessionCleanups();
|
|
4659
5927
|
const branchPath = this.#getCanonicalBranchClones(leafId);
|
|
4660
5928
|
if (branchPath.length === 0) throw new Error(`Entry ${leafId} not found`);
|
|
@@ -4738,6 +6006,7 @@ export class SessionManager {
|
|
|
4738
6006
|
|
|
4739
6007
|
/** Publish a prepared successor synchronously after all readiness awaits succeed. @internal */
|
|
4740
6008
|
commitPreparedNewSession(prepared: PreparedNewSession): void {
|
|
6009
|
+
this.#assertRecoveryHydrationWritable();
|
|
4741
6010
|
const stage = prepared as PreparedNewSessionState;
|
|
4742
6011
|
if (!this.#preparedNewSessions.has(stage) || stage.committed || stage.discarded) {
|
|
4743
6012
|
throw new Error("Prepared session is no longer available.");
|
|
@@ -4748,10 +6017,29 @@ export class SessionManager {
|
|
|
4748
6017
|
const index = stage.index;
|
|
4749
6018
|
if (!residentFileEntries || !residentTextBlobStore || !index)
|
|
4750
6019
|
throw new Error("Prepared session adoption is incomplete.");
|
|
4751
|
-
|
|
4752
|
-
|
|
4753
|
-
|
|
4754
|
-
|
|
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
|
+
}
|
|
4755
6043
|
if (!this.#lifecycleIdAdopted && stage.sessionId === lifecyclePreallocatedSessionId())
|
|
4756
6044
|
this.#lifecycleIdAdopted = true;
|
|
4757
6045
|
this.#persistChain = Promise.resolve();
|
|
@@ -4761,12 +6049,6 @@ export class SessionManager {
|
|
|
4761
6049
|
this.#sessionName = stage.sessionName;
|
|
4762
6050
|
this.#titleSource = stage.titleSource;
|
|
4763
6051
|
this.#sessionFile = stage.sessionFile;
|
|
4764
|
-
this.#fileEntries = residentFileEntries;
|
|
4765
|
-
this.#residentTextBlobStore = residentTextBlobStore;
|
|
4766
|
-
this.#byId = index.byId;
|
|
4767
|
-
this.#labelsById = index.labelsById;
|
|
4768
|
-
this.#leafId = index.leafId;
|
|
4769
|
-
this.#usageStatistics = index.usageStatistics;
|
|
4770
6052
|
this.#flushed = stage.flushed;
|
|
4771
6053
|
this.#needsFullRewriteOnNextPersist = false;
|
|
4772
6054
|
this.#ensuredOnDisk = stage.flushed;
|
|
@@ -4775,7 +6057,9 @@ export class SessionManager {
|
|
|
4775
6057
|
this.#artifactManager = null;
|
|
4776
6058
|
this.#artifactManagerSessionFile = null;
|
|
4777
6059
|
this.#adoptedArtifactManager = null;
|
|
4778
|
-
this.#
|
|
6060
|
+
this.#commitResidentTextStoreTransition(transition);
|
|
6061
|
+
stage.committed = true;
|
|
6062
|
+
this.#preparedNewSessions.delete(stage);
|
|
4779
6063
|
}
|
|
4780
6064
|
|
|
4781
6065
|
/** Exact-discard only an uncommitted successor prepared by this manager. @internal */
|
|
@@ -4890,9 +6174,9 @@ export class SessionManager {
|
|
|
4890
6174
|
* managed logical authorization. This is only for discarding an UNCOMMITTED
|
|
4891
6175
|
* successor that a transaction (e.g. handoff) created via `newSession()` but
|
|
4892
6176
|
* never durably authorized — such a session has no managed candidate listing
|
|
4893
|
-
* yet, so `dropSession` would refuse it and leak its
|
|
4894
|
-
*
|
|
4895
|
-
*
|
|
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.
|
|
4896
6180
|
*/
|
|
4897
6181
|
async discardUncommittedSession(sessionPath: string): Promise<void> {
|
|
4898
6182
|
// Close the writer FIRST so the canonical checks and the delete run with no
|
|
@@ -4931,110 +6215,154 @@ export class SessionManager {
|
|
|
4931
6215
|
* @returns { oldSessionFile, newSessionFile } or undefined if not persisting
|
|
4932
6216
|
*/
|
|
4933
6217
|
async fork(): Promise<{ oldSessionFile: string; newSessionFile: string } | undefined> {
|
|
4934
|
-
if (!this.persist || !this.#sessionFile)
|
|
4935
|
-
return undefined;
|
|
4936
|
-
}
|
|
6218
|
+
if (!this.persist || !this.#sessionFile) return undefined;
|
|
4937
6219
|
|
|
4938
6220
|
const oldSessionFile = this.#sessionFile;
|
|
4939
6221
|
const oldSessionId = this.#sessionId;
|
|
4940
6222
|
const materializedEntries = materializeResidentEntriesForReadSync(this.#fileEntries, this.#residentBlobStores());
|
|
4941
|
-
const oldFileEntries = this.#fileEntries;
|
|
4942
|
-
const oldSessionName = this.#sessionName;
|
|
4943
|
-
const oldTitleSource = this.#titleSource;
|
|
4944
|
-
|
|
4945
|
-
// Close the current writer
|
|
4946
|
-
await this.#closePersistWriter();
|
|
4947
|
-
this.#persistChain = Promise.resolve();
|
|
4948
|
-
this.#persistError = undefined;
|
|
4949
|
-
this.#persistErrorReported = false;
|
|
4950
|
-
|
|
4951
|
-
// Create new session ID and header
|
|
4952
|
-
this.#sessionId = createSessionId();
|
|
4953
6223
|
const timestamp = new Date().toISOString();
|
|
4954
|
-
const
|
|
4955
|
-
|
|
4956
|
-
|
|
4957
|
-
|
|
4958
|
-
|
|
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;
|
|
4959
6230
|
const newHeader: SessionHeader = {
|
|
4960
6231
|
type: "session",
|
|
4961
6232
|
version: CURRENT_SESSION_VERSION,
|
|
4962
|
-
id:
|
|
6233
|
+
id: newSessionId,
|
|
4963
6234
|
title: oldHeader?.title ?? this.#sessionName,
|
|
4964
6235
|
titleSource: oldHeader?.titleSource ?? this.#titleSource,
|
|
4965
6236
|
timestamp,
|
|
4966
6237
|
cwd: this.cwd,
|
|
4967
6238
|
parentSession: oldSessionId,
|
|
4968
6239
|
};
|
|
4969
|
-
|
|
4970
|
-
|
|
4971
|
-
|
|
4972
|
-
|
|
4973
|
-
const entries = materializedEntries.filter((e): e is SessionEntry => e.type !== "session");
|
|
4974
|
-
this.#fileEntries = [newHeader, ...entries];
|
|
4975
|
-
this.#resetResidentTextBlobStore();
|
|
4976
|
-
this.#reexternalizeFileEntriesForResidentStore();
|
|
4977
|
-
this.#bumpAllRevisions();
|
|
6240
|
+
const entries: FileEntry[] = [
|
|
6241
|
+
newHeader,
|
|
6242
|
+
...materializedEntries.filter((entry): entry is SessionEntry => entry.type !== "session"),
|
|
6243
|
+
];
|
|
4978
6244
|
|
|
4979
|
-
|
|
4980
|
-
|
|
4981
|
-
this.#
|
|
6245
|
+
await this.#closePersistWriter();
|
|
6246
|
+
this.#persistChain = Promise.resolve();
|
|
6247
|
+
this.#persistError = undefined;
|
|
6248
|
+
this.#persistErrorReported = false;
|
|
4982
6249
|
let forkArtifactPublication: ForkArtifactPublication | undefined;
|
|
4983
|
-
let forkTranscriptPublication:
|
|
4984
|
-
let
|
|
6250
|
+
let forkTranscriptPublication: ForkTranscriptPublication | undefined;
|
|
6251
|
+
let transition: PreparedResidentStoreTransition | undefined;
|
|
4985
6252
|
try {
|
|
4986
|
-
forkArtifactPublication = await this.copyArtifactsForFork(oldSessionFile,
|
|
4987
|
-
|
|
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");
|
|
4988
6256
|
if (this.destination.kind === "managed") {
|
|
4989
|
-
|
|
4990
|
-
|
|
4991
|
-
|
|
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 };
|
|
4992
6286
|
}
|
|
4993
|
-
forkArtifactPublication?.
|
|
4994
|
-
|
|
4995
|
-
|
|
6287
|
+
if (forkArtifactPublication?.kind === "managed") {
|
|
6288
|
+
forkArtifactPublication.store.verifyRootSecurity();
|
|
6289
|
+
forkArtifactPublication.store.assertBound();
|
|
4996
6290
|
const adoptedSnapshot = forkArtifactPublication.store.fsyncTree();
|
|
4997
|
-
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
|
+
) {
|
|
4998
6300
|
throw new Error("artifact_destination_changed_during_transcript_publication");
|
|
4999
6301
|
}
|
|
5000
6302
|
}
|
|
5001
|
-
|
|
5002
|
-
|
|
5003
|
-
|
|
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
|
+
) {
|
|
5004
6313
|
throw new Error("managed_fork_transcript_changed");
|
|
6314
|
+
}
|
|
5005
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
|
+
);
|
|
5006
6327
|
} catch (error) {
|
|
5007
|
-
|
|
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
|
+
}
|
|
5008
6338
|
if (forkArtifactPublication) {
|
|
5009
6339
|
try {
|
|
5010
|
-
forkArtifactPublication
|
|
5011
|
-
forkArtifactPublication.cleanupRelativePath,
|
|
5012
|
-
forkArtifactPublication.snapshot,
|
|
5013
|
-
);
|
|
6340
|
+
this.#cleanupForkArtifactPublication(forkArtifactPublication);
|
|
5014
6341
|
} catch (cleanupError) {
|
|
5015
|
-
|
|
5016
|
-
if (cleanupMessage !== "cleanup_pending") {
|
|
5017
|
-
throw new Error(`Failed to clean up fork artifacts: ${cleanupMessage}`, {
|
|
5018
|
-
cause: toError(failure),
|
|
5019
|
-
});
|
|
5020
|
-
}
|
|
6342
|
+
cleanupErrors.push(toError(cleanupError));
|
|
5021
6343
|
}
|
|
5022
6344
|
}
|
|
5023
|
-
|
|
5024
|
-
|
|
5025
|
-
|
|
5026
|
-
|
|
5027
|
-
|
|
5028
|
-
|
|
5029
|
-
|
|
5030
|
-
|
|
5031
|
-
|
|
5032
|
-
|
|
5033
|
-
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
|
+
}
|
|
5034
6355
|
throw failure;
|
|
5035
6356
|
}
|
|
5036
|
-
|
|
5037
|
-
|
|
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 };
|
|
5038
6366
|
}
|
|
5039
6367
|
|
|
5040
6368
|
async copyArtifactsForFork(
|
|
@@ -5044,15 +6372,98 @@ export class SessionManager {
|
|
|
5044
6372
|
const sourceDir = oldSessionFile.slice(0, -6);
|
|
5045
6373
|
const finalDestinationDir = newSessionFile.slice(0, -6);
|
|
5046
6374
|
if (this.destination.kind !== "managed") {
|
|
6375
|
+
let sourceSnapshot: native.NativeDirectoryTreeSnapshot;
|
|
5047
6376
|
try {
|
|
5048
6377
|
const sourceStat = fs.lstatSync(sourceDir);
|
|
5049
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;
|
|
5050
6382
|
} catch (error) {
|
|
5051
6383
|
if (isEnoent(error)) return;
|
|
5052
6384
|
throw error;
|
|
5053
6385
|
}
|
|
5054
|
-
|
|
5055
|
-
|
|
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
|
+
}
|
|
5056
6467
|
}
|
|
5057
6468
|
|
|
5058
6469
|
const sourceName = path.basename(sourceDir);
|
|
@@ -5066,12 +6477,13 @@ export class SessionManager {
|
|
|
5066
6477
|
if (error instanceof Error && error.message === "not_found") return;
|
|
5067
6478
|
throw error;
|
|
5068
6479
|
}
|
|
6480
|
+
const expectedManifest = pruneResidentCacheEntries(sourceSnapshot);
|
|
5069
6481
|
let stagingSnapshot: native.NativeDirectoryTreeSnapshot | undefined;
|
|
5070
6482
|
let publishedSnapshot: native.NativeDirectoryTreeSnapshot | undefined;
|
|
5071
6483
|
try {
|
|
5072
6484
|
parentStore.ensureDirectory(stagingName);
|
|
5073
6485
|
stagingSnapshot = parentStore.captureTree(stagingName);
|
|
5074
|
-
for (const entry of
|
|
6486
|
+
for (const entry of expectedManifest.entries) {
|
|
5075
6487
|
if (entry.relativePath === "") continue;
|
|
5076
6488
|
const destinationRelativePath = path.posix.join(stagingName, entry.relativePath);
|
|
5077
6489
|
if (entry.kind === "directory") {
|
|
@@ -5104,7 +6516,7 @@ export class SessionManager {
|
|
|
5104
6516
|
size: entry.kind === "file" ? entry.size : undefined,
|
|
5105
6517
|
sha256: entry.sha256,
|
|
5106
6518
|
}));
|
|
5107
|
-
if (JSON.stringify(comparable(publishedSnapshot)) !== JSON.stringify(comparable(
|
|
6519
|
+
if (JSON.stringify(comparable(publishedSnapshot)) !== JSON.stringify(comparable(expectedManifest)))
|
|
5108
6520
|
throw new Error("artifact_destination_terminal_mismatch");
|
|
5109
6521
|
const publishedStore = parentStore.deriveSubtree(destinationName);
|
|
5110
6522
|
publishedStore.verifyRootSecurity();
|
|
@@ -5113,6 +6525,7 @@ export class SessionManager {
|
|
|
5113
6525
|
throw new Error("artifact_destination_retained_identity_changed");
|
|
5114
6526
|
publishedSnapshot = retainedPublishedSnapshot;
|
|
5115
6527
|
return {
|
|
6528
|
+
kind: "managed",
|
|
5116
6529
|
snapshot: publishedSnapshot,
|
|
5117
6530
|
store: publishedStore,
|
|
5118
6531
|
cleanupStore: parentStore,
|
|
@@ -5152,7 +6565,7 @@ export class SessionManager {
|
|
|
5152
6565
|
? managedDestination(
|
|
5153
6566
|
resolvedCwd,
|
|
5154
6567
|
this.storage,
|
|
5155
|
-
this.destination.kind === "managed" ? this.destination.securityContext.
|
|
6568
|
+
this.destination.kind === "managed" ? this.destination.securityContext.profileAgentDir : undefined,
|
|
5156
6569
|
)
|
|
5157
6570
|
: this.destination;
|
|
5158
6571
|
const newSessionDir = nextDestination.directory;
|
|
@@ -5161,6 +6574,7 @@ export class SessionManager {
|
|
|
5161
6574
|
let managedSourceStore: ManagedSessionDescendantStore | undefined;
|
|
5162
6575
|
let managedDestinationStore: ManagedSessionDescendantStore | undefined;
|
|
5163
6576
|
let rollbackManagedMove: (() => Promise<void>) | undefined;
|
|
6577
|
+
let residentTransition: PreparedResidentStoreTransition | undefined;
|
|
5164
6578
|
|
|
5165
6579
|
if (this.persist && this.#sessionFile) {
|
|
5166
6580
|
// Close the persist writer before moving files
|
|
@@ -5184,20 +6598,22 @@ export class SessionManager {
|
|
|
5184
6598
|
this.#fileEntries,
|
|
5185
6599
|
this.#residentBlobStores(),
|
|
5186
6600
|
);
|
|
5187
|
-
const
|
|
5188
|
-
|
|
5189
|
-
|
|
5190
|
-
|
|
5191
|
-
|
|
5192
|
-
|
|
5193
|
-
|
|
5194
|
-
|
|
5195
|
-
|
|
5196
|
-
|
|
5197
|
-
|
|
5198
|
-
|
|
5199
|
-
|
|
5200
|
-
|
|
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();
|
|
5201
6617
|
throw error;
|
|
5202
6618
|
};
|
|
5203
6619
|
const copyManagedTree = async (
|
|
@@ -5238,7 +6654,6 @@ export class SessionManager {
|
|
|
5238
6654
|
throw new Error("artifact_destination_mismatch");
|
|
5239
6655
|
destination.fsyncTree();
|
|
5240
6656
|
};
|
|
5241
|
-
this.#disposeResidentTextBlobStore();
|
|
5242
6657
|
try {
|
|
5243
6658
|
if (managedMove) {
|
|
5244
6659
|
if (previousDestination.kind !== "managed" || nextDestination.kind !== "managed")
|
|
@@ -5365,7 +6780,7 @@ export class SessionManager {
|
|
|
5365
6780
|
)
|
|
5366
6781
|
await managedSourceStore.publishNoReplace(path.basename(oldSessionFile), managedTranscript.bytes);
|
|
5367
6782
|
} catch (rollbackErr) {
|
|
5368
|
-
|
|
6783
|
+
discardResidentTransitionAndThrow(
|
|
5369
6784
|
new Error(`Failed to rollback managed move: ${toError(rollbackErr).message}`, {
|
|
5370
6785
|
cause: toError(err),
|
|
5371
6786
|
}),
|
|
@@ -5375,13 +6790,9 @@ export class SessionManager {
|
|
|
5375
6790
|
if (movedArtifactDir) await movePathAcrossDevicesSafe(newArtifactDir, oldArtifactDir);
|
|
5376
6791
|
if (movedSessionFile) await movePathAcrossDevicesSafe(newSessionFile, oldSessionFile);
|
|
5377
6792
|
}
|
|
5378
|
-
|
|
6793
|
+
discardResidentTransitionAndThrow(err);
|
|
5379
6794
|
}
|
|
5380
6795
|
this.#sessionFile = newSessionFile;
|
|
5381
|
-
this.#fileEntries = materializedEntries;
|
|
5382
|
-
this.#resetResidentTextBlobStore();
|
|
5383
|
-
this.#reexternalizeFileEntriesForResidentStore();
|
|
5384
|
-
this.#bumpAllRevisions();
|
|
5385
6796
|
}
|
|
5386
6797
|
|
|
5387
6798
|
// Update cwd and sessionDir after physical publication succeeds. Metadata failures restore the source
|
|
@@ -5406,6 +6817,7 @@ export class SessionManager {
|
|
|
5406
6817
|
await this.#appendHeaderPatch({ cwd: resolvedCwd });
|
|
5407
6818
|
}
|
|
5408
6819
|
} catch (error) {
|
|
6820
|
+
residentTransition?.dispose();
|
|
5409
6821
|
await this.#closePersistWriter().catch(() => {});
|
|
5410
6822
|
this.#persistChain = Promise.resolve();
|
|
5411
6823
|
this.#persistError = undefined;
|
|
@@ -5439,6 +6851,7 @@ export class SessionManager {
|
|
|
5439
6851
|
this.#headerExportRevision++;
|
|
5440
6852
|
throw error;
|
|
5441
6853
|
}
|
|
6854
|
+
if (residentTransition) this.#commitResidentTextStoreTransition(residentTransition);
|
|
5442
6855
|
|
|
5443
6856
|
// Update terminal breadcrumb only after the durable cwd transition succeeds.
|
|
5444
6857
|
if (this.#sessionFile) {
|
|
@@ -5446,57 +6859,14 @@ export class SessionManager {
|
|
|
5446
6859
|
}
|
|
5447
6860
|
}
|
|
5448
6861
|
|
|
5449
|
-
/** Sync version for initial creation (no existing writer to close) */
|
|
6862
|
+
/** Sync version for initial creation (no existing writer to close). */
|
|
5450
6863
|
#newSessionSync(options?: NewSessionOptions, writeBreadcrumb = true): string | undefined {
|
|
5451
|
-
|
|
5452
|
-
this.#
|
|
5453
|
-
this.#
|
|
5454
|
-
|
|
5455
|
-
|
|
5456
|
-
|
|
5457
|
-
const preallocated = this.#lifecycleIdAdopted ? undefined : lifecyclePreallocatedSessionId();
|
|
5458
|
-
if (preallocated) this.#lifecycleIdAdopted = true;
|
|
5459
|
-
this.#sessionId = preallocated ?? createSessionId();
|
|
5460
|
-
this.#sessionName = undefined;
|
|
5461
|
-
this.#titleSource = undefined;
|
|
5462
|
-
const timestamp = new Date().toISOString();
|
|
5463
|
-
const header: SessionHeader = {
|
|
5464
|
-
type: "session",
|
|
5465
|
-
version: CURRENT_SESSION_VERSION,
|
|
5466
|
-
id: this.#sessionId,
|
|
5467
|
-
timestamp,
|
|
5468
|
-
cwd: this.cwd,
|
|
5469
|
-
parentSession: options?.parentSession,
|
|
5470
|
-
};
|
|
5471
|
-
this.#fileEntries = [header];
|
|
5472
|
-
this.#byId.clear();
|
|
5473
|
-
this.#labelsById.clear();
|
|
5474
|
-
this.#leafId = null;
|
|
5475
|
-
this.#flushed = false;
|
|
5476
|
-
this.#needsFullRewriteOnNextPersist = false;
|
|
5477
|
-
this.#ensuredOnDisk = false;
|
|
5478
|
-
this.#usageStatistics = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, premiumRequests: 0, cost: 0 };
|
|
5479
|
-
this.#inMemoryArtifacts = null;
|
|
5480
|
-
this.#inMemoryArtifactCounter = 0;
|
|
5481
|
-
this.#artifactManager = null;
|
|
5482
|
-
this.#artifactManagerSessionFile = null;
|
|
5483
|
-
this.#adoptedArtifactManager = null;
|
|
5484
|
-
|
|
5485
|
-
if (this.persist) {
|
|
5486
|
-
const fileTimestamp = timestamp.replace(/[:.]/g, "-");
|
|
5487
|
-
this.#sessionFile = path.join(this.getSessionDir(), `${fileTimestamp}_${this.#sessionId}.jsonl`);
|
|
5488
|
-
if (writeBreadcrumb) writeTerminalBreadcrumb(this.cwd, this.#sessionFile);
|
|
5489
|
-
}
|
|
5490
|
-
this.#resetResidentTextBlobStore();
|
|
5491
|
-
return this.#sessionFile;
|
|
5492
|
-
}
|
|
5493
|
-
|
|
5494
|
-
#buildIndex(): void {
|
|
5495
|
-
const index = this.#buildIndexForEntries(this.#fileEntries, this.#sessionFile);
|
|
5496
|
-
this.#byId = index.byId;
|
|
5497
|
-
this.#labelsById = index.labelsById;
|
|
5498
|
-
this.#leafId = index.leafId;
|
|
5499
|
-
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;
|
|
5500
6870
|
}
|
|
5501
6871
|
|
|
5502
6872
|
#buildIndexForEntries(fileEntries: FileEntry[], sessionFile: string | undefined): PreparedSessionIndex {
|
|
@@ -5812,6 +7182,82 @@ export class SessionManager {
|
|
|
5812
7182
|
}
|
|
5813
7183
|
}
|
|
5814
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
|
+
|
|
5815
7261
|
#managedTranscriptStore(sessionFile = this.#sessionFile): ManagedSessionDescendantStore {
|
|
5816
7262
|
if (this.destination.kind !== "managed" || !sessionFile) {
|
|
5817
7263
|
throw new Error("Managed transcript authority is unavailable");
|
|
@@ -6033,8 +7479,27 @@ export class SessionManager {
|
|
|
6033
7479
|
) {
|
|
6034
7480
|
return { kind: "not_promoted" };
|
|
6035
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
|
+
}
|
|
6036
7498
|
if (stage.persistsToExistingFile) {
|
|
6037
|
-
if (!this.#sessionFile)
|
|
7499
|
+
if (!this.#sessionFile) {
|
|
7500
|
+
transition.dispose();
|
|
7501
|
+
return { kind: "unknown", error: new Error("Missing staged session replacement") };
|
|
7502
|
+
}
|
|
6038
7503
|
if (this.destination.kind === "managed") {
|
|
6039
7504
|
try {
|
|
6040
7505
|
const persistedEntries = materializeResidentEntriesForPersistenceSync(
|
|
@@ -6043,17 +7508,23 @@ export class SessionManager {
|
|
|
6043
7508
|
).map(entry => prepareEntryForPersistenceSync(entry, this.#blobStore));
|
|
6044
7509
|
this.#writeEntriesAtomicallySync(persistedEntries);
|
|
6045
7510
|
} catch (error) {
|
|
7511
|
+
transition.dispose();
|
|
6046
7512
|
return { kind: "unknown", error: toError(error) };
|
|
6047
7513
|
}
|
|
6048
7514
|
} else {
|
|
6049
|
-
if (!stage.tempPath)
|
|
7515
|
+
if (!stage.tempPath) {
|
|
7516
|
+
transition.dispose();
|
|
7517
|
+
return { kind: "unknown", error: new Error("Missing staged session replacement") };
|
|
7518
|
+
}
|
|
6050
7519
|
try {
|
|
6051
7520
|
this.#closePersistWriterInternalSync();
|
|
6052
7521
|
const replacement = this.#replaceSessionFileSync(stage.tempPath, this.#sessionFile);
|
|
6053
7522
|
if (replacement.kind === "restored_previous") {
|
|
7523
|
+
transition.dispose();
|
|
6054
7524
|
return { kind: "not_promoted", error: replacement.error };
|
|
6055
7525
|
}
|
|
6056
7526
|
} catch (error) {
|
|
7527
|
+
transition.dispose();
|
|
6057
7528
|
if (this.#persistWriter?.getCloseState() === "close_failed_retryable") {
|
|
6058
7529
|
return { kind: "not_promoted", error: new Error("Session replacement could not be completed.") };
|
|
6059
7530
|
}
|
|
@@ -6061,13 +7532,10 @@ export class SessionManager {
|
|
|
6061
7532
|
}
|
|
6062
7533
|
}
|
|
6063
7534
|
}
|
|
6064
|
-
this.#fileEntries = [...stage.entries];
|
|
6065
|
-
this.#resetResidentTextBlobStore();
|
|
6066
|
-
this.#reexternalizeFileEntriesForResidentStore();
|
|
6067
7535
|
this.#needsFullRewriteOnNextPersist = false;
|
|
6068
7536
|
this.#flushed = stage.persistsToExistingFile;
|
|
6069
7537
|
this.#ensuredOnDisk = stage.persistsToExistingFile;
|
|
6070
|
-
this.#
|
|
7538
|
+
this.#commitResidentTextStoreTransition(transition);
|
|
6071
7539
|
return { kind: "promoted" };
|
|
6072
7540
|
}
|
|
6073
7541
|
|
|
@@ -6119,7 +7587,8 @@ export class SessionManager {
|
|
|
6119
7587
|
this.#flushed = true;
|
|
6120
7588
|
}
|
|
6121
7589
|
});
|
|
6122
|
-
this.#
|
|
7590
|
+
this.#releaseResidentTextStore();
|
|
7591
|
+
this.#releaseOwnedManagedAuthority();
|
|
6123
7592
|
if (this.#persistError) throw this.#persistError;
|
|
6124
7593
|
}
|
|
6125
7594
|
/** Flush while open, then strictly close; retryable close skips the invalid second flush. */
|
|
@@ -6176,7 +7645,7 @@ export class SessionManager {
|
|
|
6176
7645
|
// Only tear down the resident blob store on a terminal outcome; a retryable
|
|
6177
7646
|
// close leaves the session live for a genuine retry.
|
|
6178
7647
|
if (!this.#persistWriter) {
|
|
6179
|
-
this.#
|
|
7648
|
+
this.#releaseResidentTextStore();
|
|
6180
7649
|
}
|
|
6181
7650
|
return outcome;
|
|
6182
7651
|
}
|
|
@@ -6230,6 +7699,160 @@ export class SessionManager {
|
|
|
6230
7699
|
return this.#sessionFile;
|
|
6231
7700
|
}
|
|
6232
7701
|
|
|
7702
|
+
acquireMemoryGuardParticipantIngressLease(): MemoryGuardParticipantIngressLease {
|
|
7703
|
+
if (this.#memoryGuardParticipantIngressToken)
|
|
7704
|
+
throw new Error("memory_guard_participant_ingress_lease_already_held");
|
|
7705
|
+
const token = Symbol("memory_guard_participant_ingress_lease");
|
|
7706
|
+
this.#memoryGuardParticipantIngressToken = token;
|
|
7707
|
+
let released = false;
|
|
7708
|
+
return Object.freeze({
|
|
7709
|
+
token,
|
|
7710
|
+
release: () => {
|
|
7711
|
+
if (released) return;
|
|
7712
|
+
released = true;
|
|
7713
|
+
if (this.#memoryGuardParticipantIngressToken === token)
|
|
7714
|
+
this.#memoryGuardParticipantIngressToken = undefined;
|
|
7715
|
+
},
|
|
7716
|
+
});
|
|
7717
|
+
}
|
|
7718
|
+
|
|
7719
|
+
#assertMemoryGuardParticipantIngressLease(lease: MemoryGuardParticipantIngressLease): void {
|
|
7720
|
+
if (this.#memoryGuardParticipantIngressToken !== lease.token) {
|
|
7721
|
+
throw new Error("memory_guard_participant_ingress_lease_invalid");
|
|
7722
|
+
}
|
|
7723
|
+
}
|
|
7724
|
+
|
|
7725
|
+
#stageMemoryGuardCheckpointBlobs(blobs: Map<string, Buffer>): void {
|
|
7726
|
+
const stagedImageStore = new MemoryBlobStore();
|
|
7727
|
+
for (const blob of blobs.values()) {
|
|
7728
|
+
this.#putResidentTextBlobSync(blob);
|
|
7729
|
+
stagedImageStore.putSync(blob);
|
|
7730
|
+
}
|
|
7731
|
+
this.#residentImageBlobStore = stagedImageStore;
|
|
7732
|
+
this.#memoryGuardCheckpointBlobs = blobs;
|
|
7733
|
+
}
|
|
7734
|
+
|
|
7735
|
+
async createMemoryGuardCheckpoint(
|
|
7736
|
+
input: MemoryGuardCreateCheckpointInput,
|
|
7737
|
+
): Promise<MemoryGuardSessionManagerCheckpointV1> {
|
|
7738
|
+
this.#assertMemoryGuardParticipantIngressLease(input.ingressLease);
|
|
7739
|
+
if (!this.#sessionFile) throw new Error("memory_guard_checkpoint_session_file_unavailable");
|
|
7740
|
+
await this.flush();
|
|
7741
|
+
const captured = SessionManager.captureTranscriptStrict(this.#sessionFile, this.storage);
|
|
7742
|
+
if (captured.kind !== "captured") {
|
|
7743
|
+
throw new Error(`memory_guard_checkpoint_capture_failed:${captured.reason}`);
|
|
7744
|
+
}
|
|
7745
|
+
if (captured.snapshot.content.byteLength > MEMORY_GUARD_CHECKPOINT_FILE_MAX_BYTES)
|
|
7746
|
+
throw new Error("memory_guard_checkpoint_transcript_capacity_exceeded");
|
|
7747
|
+
const transcriptText = decodeCheckpointUtf8(captured.snapshot.content);
|
|
7748
|
+
if (transcriptText === null) throw new Error("memory_guard_checkpoint_transcript_unreadable");
|
|
7749
|
+
const entries = parseSessionEntries(transcriptText);
|
|
7750
|
+
const sessionId = this.getSessionId();
|
|
7751
|
+
assertMemoryGuardSessionId(sessionId);
|
|
7752
|
+
const sessionName = this.getSessionName() ?? null;
|
|
7753
|
+
const revisions = this.revisionSnapshot();
|
|
7754
|
+
const participantRoot = memoryGuardParticipantRoot(input.checkpointRoot, sessionId);
|
|
7755
|
+
const transcriptRelativePath = memoryGuardParticipantRelativePath(sessionId, "transcript.jsonl");
|
|
7756
|
+
const blobRootRelativePath = memoryGuardParticipantRelativePath(sessionId, "blobs");
|
|
7757
|
+
const blobManifestRelativePath = memoryGuardParticipantRelativePath(sessionId, "blob-manifest.json");
|
|
7758
|
+
const refs = [...collectCheckpointBlobRefs(entries)].sort();
|
|
7759
|
+
if (refs.length > MEMORY_GUARD_CHECKPOINT_BLOB_MAX_ENTRIES)
|
|
7760
|
+
throw new Error("memory_guard_checkpoint_blob_count_exceeded");
|
|
7761
|
+
const blobManifestEntries: MemoryGuardCheckpointBlobManifestEntryV1[] = [];
|
|
7762
|
+
const blobWrites: Array<{ data: Buffer; relativePath: string }> = [];
|
|
7763
|
+
let aggregateBlobBytes = 0;
|
|
7764
|
+
for (const ref of refs) {
|
|
7765
|
+
const hash = parseBlobRef(ref);
|
|
7766
|
+
if (!hash) continue;
|
|
7767
|
+
const data =
|
|
7768
|
+
this.#residentTextBlobStore.getCheckedSync(hash) ??
|
|
7769
|
+
this.#residentImageBlobStore.getCheckedSync(hash) ??
|
|
7770
|
+
this.#blobStore.getCheckedSync(hash);
|
|
7771
|
+
if (!data) throw new Error(`memory_guard_checkpoint_blob_missing:${hash}`);
|
|
7772
|
+
if (data.byteLength > MEMORY_GUARD_CHECKPOINT_FILE_MAX_BYTES)
|
|
7773
|
+
throw new Error(`memory_guard_checkpoint_blob_capacity_exceeded:${hash}`);
|
|
7774
|
+
aggregateBlobBytes += data.byteLength;
|
|
7775
|
+
if (aggregateBlobBytes > MEMORY_GUARD_CHECKPOINT_BLOB_TOTAL_MAX_BYTES)
|
|
7776
|
+
throw new Error("memory_guard_checkpoint_blob_total_capacity_exceeded");
|
|
7777
|
+
const relativePath = hash;
|
|
7778
|
+
blobWrites.push({ data, relativePath });
|
|
7779
|
+
blobManifestEntries.push({
|
|
7780
|
+
bytes: String(data.byteLength),
|
|
7781
|
+
relative_path: relativePath,
|
|
7782
|
+
sha256: memoryGuardSha256Hex(data),
|
|
7783
|
+
});
|
|
7784
|
+
}
|
|
7785
|
+
const publishedCheckpointPaths: string[] = [];
|
|
7786
|
+
const cleanupPublishedCheckpointPaths = async (): Promise<void> => {
|
|
7787
|
+
for (const publishedPath of publishedCheckpointPaths.reverse())
|
|
7788
|
+
await fs.promises.rm(publishedPath, { force: true }).catch(() => undefined);
|
|
7789
|
+
await fs.promises.rmdir(path.join(input.checkpointRoot, blobRootRelativePath)).catch(() => undefined);
|
|
7790
|
+
await fs.promises.rmdir(participantRoot).catch(() => undefined);
|
|
7791
|
+
};
|
|
7792
|
+
const publishCheckpointFile = async (filePath: string, data: Uint8Array | string): Promise<void> => {
|
|
7793
|
+
try {
|
|
7794
|
+
await writeOwnerOnlyFileNoReplace(filePath, data);
|
|
7795
|
+
publishedCheckpointPaths.push(filePath);
|
|
7796
|
+
} catch (error) {
|
|
7797
|
+
await cleanupPublishedCheckpointPaths();
|
|
7798
|
+
throw error;
|
|
7799
|
+
}
|
|
7800
|
+
};
|
|
7801
|
+
await ensureOwnerOnlyDirectory(participantRoot);
|
|
7802
|
+
await publishCheckpointFile(path.join(input.checkpointRoot, transcriptRelativePath), captured.snapshot.content);
|
|
7803
|
+
for (const write of blobWrites)
|
|
7804
|
+
await publishCheckpointFile(
|
|
7805
|
+
path.join(input.checkpointRoot, blobRootRelativePath, write.relativePath),
|
|
7806
|
+
write.data,
|
|
7807
|
+
);
|
|
7808
|
+
blobManifestEntries.sort((left, right) => left.relative_path.localeCompare(right.relative_path));
|
|
7809
|
+
const blobManifest: MemoryGuardCheckpointBlobManifestV1 = {
|
|
7810
|
+
entries: blobManifestEntries,
|
|
7811
|
+
schema_version: 1,
|
|
7812
|
+
};
|
|
7813
|
+
const blobManifestText = memoryGuardCanonicalJson(blobManifest);
|
|
7814
|
+
await publishCheckpointFile(path.join(input.checkpointRoot, blobManifestRelativePath), blobManifestText);
|
|
7815
|
+
const checkpoint: MemoryGuardSessionManagerCheckpointV1 = {
|
|
7816
|
+
blob_authority: {
|
|
7817
|
+
kind: "checkpoint_blob_tree_v1",
|
|
7818
|
+
manifest_relative_path: blobManifestRelativePath,
|
|
7819
|
+
manifest_sha256: memoryGuardSha256Hex(blobManifestText),
|
|
7820
|
+
root_relative_path: blobRootRelativePath,
|
|
7821
|
+
},
|
|
7822
|
+
revisions: toSessionManagerCheckpointRevisionStrings(revisions),
|
|
7823
|
+
schema_version: 1,
|
|
7824
|
+
session_id: sessionId,
|
|
7825
|
+
session_name: sessionName,
|
|
7826
|
+
transcript: {
|
|
7827
|
+
bytes: String(captured.snapshot.content.byteLength),
|
|
7828
|
+
relative_path: transcriptRelativePath,
|
|
7829
|
+
sha256: captured.snapshot.identity.sha256,
|
|
7830
|
+
},
|
|
7831
|
+
};
|
|
7832
|
+
const checkpointPath = path.join(
|
|
7833
|
+
input.checkpointRoot,
|
|
7834
|
+
memoryGuardParticipantRelativePath(sessionId, "session-manager.json"),
|
|
7835
|
+
);
|
|
7836
|
+
await publishCheckpointFile(checkpointPath, memoryGuardCanonicalJson(checkpoint));
|
|
7837
|
+
const currentRevisions = this.revisionSnapshot();
|
|
7838
|
+
const recaptured = SessionManager.captureTranscriptStrict(this.#sessionFile, this.storage);
|
|
7839
|
+
if (
|
|
7840
|
+
this.getSessionId() !== sessionId ||
|
|
7841
|
+
(this.getSessionName() ?? null) !== sessionName ||
|
|
7842
|
+
Object.keys(revisions).some(
|
|
7843
|
+
key =>
|
|
7844
|
+
currentRevisions[key as keyof SessionManagerRevisionSnapshot] !==
|
|
7845
|
+
revisions[key as keyof SessionManagerRevisionSnapshot],
|
|
7846
|
+
) ||
|
|
7847
|
+
recaptured.kind !== "captured" ||
|
|
7848
|
+
!sameResumeIdentity(captured.snapshot.identity, recaptured.snapshot.identity)
|
|
7849
|
+
) {
|
|
7850
|
+
await cleanupPublishedCheckpointPaths();
|
|
7851
|
+
throw new Error("memory_guard_checkpoint_state_changed_during_capture");
|
|
7852
|
+
}
|
|
7853
|
+
return checkpoint;
|
|
7854
|
+
}
|
|
7855
|
+
|
|
6233
7856
|
/**
|
|
6234
7857
|
* Returns the session artifacts directory path (session file path without .jsonl).
|
|
6235
7858
|
* Returns null when the session is not persisted to a file.
|
|
@@ -6491,7 +8114,11 @@ export class SessionManager {
|
|
|
6491
8114
|
* @param source - "user" for explicit renames (/rename command, RPC); "auto" for generated titles.
|
|
6492
8115
|
* Auto-generated titles are silently ignored when the user has already set a name.
|
|
6493
8116
|
*/
|
|
8117
|
+
#assertRecoveryHydrationWritable(): void {
|
|
8118
|
+
if (this.#recoveryHydrationContext) throw new Error("recovery_hydration_not_promoted");
|
|
8119
|
+
}
|
|
6494
8120
|
async setSessionName(name: string, source: "auto" | "user" = "auto"): Promise<boolean> {
|
|
8121
|
+
this.#assertRecoveryHydrationWritable();
|
|
6495
8122
|
// User-set names take permanent precedence over auto-generated ones.
|
|
6496
8123
|
if (this.#titleSource === "user" && source === "auto") return false;
|
|
6497
8124
|
|
|
@@ -6617,8 +8244,9 @@ export class SessionManager {
|
|
|
6617
8244
|
}
|
|
6618
8245
|
|
|
6619
8246
|
#appendEntry(entry: SessionEntry): void {
|
|
8247
|
+
this.#assertRecoveryHydrationWritable();
|
|
6620
8248
|
const normalizedEntry = normalizeSessionEntryForStorage(entry);
|
|
6621
|
-
const residentEntry =
|
|
8249
|
+
const residentEntry = this.#prepareEntryForCurrentResidentStore(normalizedEntry) as SessionEntry;
|
|
6622
8250
|
this.#fileEntries.push(residentEntry);
|
|
6623
8251
|
this.#byId.set(residentEntry.id, residentEntry);
|
|
6624
8252
|
this.#leafId = residentEntry.id;
|
|
@@ -6835,13 +8463,14 @@ export class SessionManager {
|
|
|
6835
8463
|
* the canonical store. This applies such mutations for real.
|
|
6836
8464
|
*/
|
|
6837
8465
|
applyEntryMessageUpdates(entries: readonly SessionMessageEntry[]): void {
|
|
8466
|
+
this.#assertRecoveryHydrationWritable();
|
|
6838
8467
|
for (const updated of entries) {
|
|
6839
8468
|
const canonical = this.#byId.get(updated.id);
|
|
6840
8469
|
if (canonical?.type !== "message") continue;
|
|
6841
|
-
const residentEntry =
|
|
6842
|
-
|
|
6843
|
-
|
|
6844
|
-
) as SessionMessageEntry;
|
|
8470
|
+
const residentEntry = this.#prepareEntryForCurrentResidentStore({
|
|
8471
|
+
...canonical,
|
|
8472
|
+
message: updated.message,
|
|
8473
|
+
}) as SessionMessageEntry;
|
|
6845
8474
|
canonical.message = residentEntry.message;
|
|
6846
8475
|
}
|
|
6847
8476
|
this.#needsFullRewriteOnNextPersist = true;
|
|
@@ -6851,6 +8480,7 @@ export class SessionManager {
|
|
|
6851
8480
|
|
|
6852
8481
|
/** Write mutated custom-message entries back into the canonical entry store by id. */
|
|
6853
8482
|
applyCustomMessageEntryUpdates(entries: readonly CustomMessageEntry[]): void {
|
|
8483
|
+
this.#assertRecoveryHydrationWritable();
|
|
6854
8484
|
for (const updated of entries) {
|
|
6855
8485
|
const canonical = this.#byId.get(updated.id);
|
|
6856
8486
|
if (canonical?.type !== "custom_message") continue;
|
|
@@ -6870,6 +8500,7 @@ export class SessionManager {
|
|
|
6870
8500
|
* Use sparingly (e.g., pruning old tool outputs).
|
|
6871
8501
|
*/
|
|
6872
8502
|
async rewriteEntries(): Promise<void> {
|
|
8503
|
+
this.#assertRecoveryHydrationWritable();
|
|
6873
8504
|
if (!this.persist || !this.#sessionFile) return;
|
|
6874
8505
|
await this.#rewriteFile();
|
|
6875
8506
|
}
|
|
@@ -7035,6 +8666,7 @@ export class SessionManager {
|
|
|
7035
8666
|
}
|
|
7036
8667
|
|
|
7037
8668
|
evictCompactedContent(firstKeptEntryId: string, compactionEntryId: string): EvictCompactedContentResult {
|
|
8669
|
+
this.#assertRecoveryHydrationWritable();
|
|
7038
8670
|
const firstKept = this.#byId.get(firstKeptEntryId);
|
|
7039
8671
|
const compaction = this.#byId.get(compactionEntryId);
|
|
7040
8672
|
if (!firstKept) throw new Error(`Entry ${firstKeptEntryId} not found`);
|
|
@@ -7156,11 +8788,16 @@ export class SessionManager {
|
|
|
7156
8788
|
coldSpillReadCount: this.#coldSpillReadCount,
|
|
7157
8789
|
residentTextReadCount: this.#residentTextReadCount,
|
|
7158
8790
|
residentImageReadCount: this.#residentImageReadCount,
|
|
8791
|
+
residentCacheAdoptFallbackCount: this.#residentCacheAdoptFallbackCount,
|
|
8792
|
+
residentCacheTrustRejectCount: this.#residentCacheTrustRejectCount,
|
|
8793
|
+
residentCacheWin32FallbackCount: this.#residentCacheWin32FallbackCount,
|
|
8794
|
+
residentCacheDegradedReason: (this.#residentTextBlobStore as ResidentCacheDegradedStore).degradedReason,
|
|
7159
8795
|
publicMaterializerCallCount: this.#publicMaterializerCallCount,
|
|
7160
8796
|
getEntryMaterializerCallCount: this.#getEntryMaterializerCallCount,
|
|
7161
8797
|
getBranchMaterializerCallCount: this.#getBranchMaterializerCallCount,
|
|
7162
8798
|
getEntriesMaterializerCallCount: this.#getEntriesMaterializerCallCount,
|
|
7163
8799
|
materializedEntriesCachePopulateCount: this.#materializedEntriesCachePopulateCount,
|
|
8800
|
+
materializedCacheDemotedCount: this.#materializedCacheDemotedCount,
|
|
7164
8801
|
pathOnlyContextBuildCount: this.#pathOnlyContextBuildCount,
|
|
7165
8802
|
};
|
|
7166
8803
|
}
|
|
@@ -7185,7 +8822,7 @@ export class SessionManager {
|
|
|
7185
8822
|
? cloneSessionEntry(
|
|
7186
8823
|
rehydrateColdSpillEntry(
|
|
7187
8824
|
materializeResidentEntryForReadSync(entry, this.#residentBlobStores(), new Map()),
|
|
7188
|
-
this.#
|
|
8825
|
+
this.#coldSpillReadStore(),
|
|
7189
8826
|
this.#residentBlobStoresForColdRehydrate(),
|
|
7190
8827
|
),
|
|
7191
8828
|
)
|
|
@@ -7204,7 +8841,7 @@ export class SessionManager {
|
|
|
7204
8841
|
cloneSessionEntry(
|
|
7205
8842
|
rehydrateColdSpillEntry(
|
|
7206
8843
|
materializeResidentEntryForReadSync(current, this.#residentBlobStores(), cache),
|
|
7207
|
-
this.#
|
|
8844
|
+
this.#coldSpillReadStore(),
|
|
7208
8845
|
this.#residentBlobStoresForColdRehydrate(),
|
|
7209
8846
|
),
|
|
7210
8847
|
),
|
|
@@ -7246,7 +8883,7 @@ export class SessionManager {
|
|
|
7246
8883
|
cloneSessionEntry(
|
|
7247
8884
|
rehydrateColdSpillEntry(
|
|
7248
8885
|
materializeResidentEntryForReadSync(entry, this.#residentBlobStores(), cache),
|
|
7249
|
-
this.#
|
|
8886
|
+
this.#coldSpillReadStore(),
|
|
7250
8887
|
this.#residentBlobStoresForColdRehydrate(),
|
|
7251
8888
|
),
|
|
7252
8889
|
),
|
|
@@ -7347,10 +8984,11 @@ export class SessionManager {
|
|
|
7347
8984
|
|
|
7348
8985
|
/**
|
|
7349
8986
|
* Return the revision-keyed context cache for internal read-only consumers.
|
|
7350
|
-
*
|
|
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.
|
|
7351
8989
|
*/
|
|
7352
8990
|
#getSessionContextForRead(): Readonly<SessionContext> {
|
|
7353
|
-
const cached = this.#sessionContextCache;
|
|
8991
|
+
const cached = dereferenceMaterializedCache(this.#sessionContextCache);
|
|
7354
8992
|
if (
|
|
7355
8993
|
cached &&
|
|
7356
8994
|
this.#sessionContextEntryRevision === this.#entryRevision &&
|
|
@@ -7360,18 +8998,22 @@ export class SessionManager {
|
|
|
7360
8998
|
return cached;
|
|
7361
8999
|
}
|
|
7362
9000
|
this.#pathOnlyContextBuildCount++;
|
|
7363
|
-
const
|
|
9001
|
+
const builtContext = buildSessionContext(
|
|
7364
9002
|
this.#getActivePathEntriesForProviderContext(),
|
|
7365
9003
|
this.#leafId,
|
|
7366
9004
|
undefined,
|
|
7367
9005
|
this.#sessionId,
|
|
7368
9006
|
);
|
|
7369
|
-
|
|
7370
|
-
|
|
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);
|
|
7371
9013
|
this.#sessionContextEntryRevision = this.#entryRevision;
|
|
7372
9014
|
this.#sessionContextLeafRevision = this.#leafRevision;
|
|
7373
9015
|
this.#sessionContextReplayMetadataRevision = this.#replayMetadataRevision;
|
|
7374
|
-
return
|
|
9016
|
+
return context;
|
|
7375
9017
|
}
|
|
7376
9018
|
|
|
7377
9019
|
#getActivePathEntriesForProviderContext(fromId?: string | null): SessionEntry[] {
|
|
@@ -7412,7 +9054,7 @@ export class SessionManager {
|
|
|
7412
9054
|
const materialized = materializeResidentEntryForReadSync(entry, this.#residentBlobStores(), new Map());
|
|
7413
9055
|
const rehydrated = rehydrateColdSpillEntry(
|
|
7414
9056
|
materialized,
|
|
7415
|
-
this.#
|
|
9057
|
+
this.#coldSpillReadStore(),
|
|
7416
9058
|
this.#residentBlobStoresForColdRehydrate(),
|
|
7417
9059
|
);
|
|
7418
9060
|
if (rehydrated !== materialized) this.#coldSpillReadCount += this.#countColdSpillPayloads(entry);
|
|
@@ -7428,6 +9070,7 @@ export class SessionManager {
|
|
|
7428
9070
|
}
|
|
7429
9071
|
/** Strip stale OpenAI Responses assistant replay metadata from loaded in-memory entries without persisting it. */
|
|
7430
9072
|
sanitizeLoadedOpenAIResponsesReplayMetadata(): boolean {
|
|
9073
|
+
this.#assertRecoveryHydrationWritable();
|
|
7431
9074
|
return this.#sanitizeLoadedOpenAIResponsesReplayMetadata().length > 0;
|
|
7432
9075
|
}
|
|
7433
9076
|
|
|
@@ -7467,17 +9110,21 @@ export class SessionManager {
|
|
|
7467
9110
|
* Internal materialized entry cache. Public getters clone this snapshot before returning it.
|
|
7468
9111
|
*/
|
|
7469
9112
|
#getMaterializedEntriesInternal(): SessionEntry[] {
|
|
7470
|
-
|
|
7471
|
-
|
|
7472
|
-
}
|
|
9113
|
+
const cached = dereferenceMaterializedCache(this.#materializedEntriesCache);
|
|
9114
|
+
if (this.#materializedEntriesRevision === this.#entryRevision && cached) return cached;
|
|
7473
9115
|
this.#materializedEntriesCachePopulateCount++;
|
|
7474
9116
|
const resolvedTextBlobCache = new Map<string, string>();
|
|
7475
9117
|
const sourceEntries = this.#fileEntries.filter((e): e is SessionEntry => e.type !== "session");
|
|
7476
9118
|
const materializedEntries = sourceEntries.map(entry =>
|
|
7477
9119
|
materializeResidentEntryForReadSync(entry, this.#residentBlobStores(), resolvedTextBlobCache),
|
|
7478
9120
|
);
|
|
9121
|
+
if (jsonLikeValueExceedsCacheLimit(materializedEntries, materializedCacheMaxBytes())) {
|
|
9122
|
+
this.#holdMaterializedCachesWeakly();
|
|
9123
|
+
}
|
|
7479
9124
|
if (!sourceEntries.some(entry => containsResidentImageSentinel(entry))) {
|
|
7480
|
-
this.#materializedEntriesCache =
|
|
9125
|
+
this.#materializedEntriesCache = this.#materializedCachesWeaklyHeld
|
|
9126
|
+
? new WeakRef(materializedEntries)
|
|
9127
|
+
: materializedEntries;
|
|
7481
9128
|
this.#materializedEntriesRevision = this.#entryRevision;
|
|
7482
9129
|
}
|
|
7483
9130
|
return materializedEntries;
|
|
@@ -7583,6 +9230,7 @@ export class SessionManager {
|
|
|
7583
9230
|
* are not modified or deleted.
|
|
7584
9231
|
*/
|
|
7585
9232
|
branch(branchFromId: string): void {
|
|
9233
|
+
this.#assertRecoveryHydrationWritable();
|
|
7586
9234
|
if (!this.#byId.has(branchFromId)) {
|
|
7587
9235
|
throw new Error(`Entry ${branchFromId} not found`);
|
|
7588
9236
|
}
|
|
@@ -7596,6 +9244,7 @@ export class SessionManager {
|
|
|
7596
9244
|
* Use this when navigating to re-edit the first user message.
|
|
7597
9245
|
*/
|
|
7598
9246
|
resetLeaf(): void {
|
|
9247
|
+
this.#assertRecoveryHydrationWritable();
|
|
7599
9248
|
this.#leafId = null;
|
|
7600
9249
|
this.#leafRevision++;
|
|
7601
9250
|
}
|
|
@@ -7606,6 +9255,7 @@ export class SessionManager {
|
|
|
7606
9255
|
* context from the abandoned conversation path.
|
|
7607
9256
|
*/
|
|
7608
9257
|
branchWithSummary(branchFromId: string | null, summary: string, details?: unknown, fromExtension?: boolean): string {
|
|
9258
|
+
this.#assertRecoveryHydrationWritable();
|
|
7609
9259
|
if (branchFromId !== null && !this.#byId.has(branchFromId)) {
|
|
7610
9260
|
throw new Error(`Entry ${branchFromId} not found`);
|
|
7611
9261
|
}
|
|
@@ -7630,6 +9280,7 @@ export class SessionManager {
|
|
|
7630
9280
|
* Returns the new session file path, or undefined if not persisting.
|
|
7631
9281
|
*/
|
|
7632
9282
|
createBranchedSession(leafId: string): string | undefined {
|
|
9283
|
+
this.#assertRecoveryHydrationWritable();
|
|
7633
9284
|
const previousSessionFile = this.#sessionFile;
|
|
7634
9285
|
const branchPath = this.#getCanonicalBranchClones(leafId);
|
|
7635
9286
|
if (branchPath.length === 0) {
|
|
@@ -7689,27 +9340,35 @@ export class SessionManager {
|
|
|
7689
9340
|
labelEntries.push(labelEntry);
|
|
7690
9341
|
parentId = labelEntry.id;
|
|
7691
9342
|
}
|
|
7692
|
-
|
|
7693
|
-
|
|
7694
|
-
|
|
7695
|
-
|
|
7696
|
-
|
|
7697
|
-
|
|
7698
|
-
|
|
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;
|
|
7699
9367
|
}
|
|
7700
9368
|
this.#sessionId = newSessionId;
|
|
7701
9369
|
this.#sessionFile = newSessionFile;
|
|
7702
|
-
this.#resetResidentTextBlobStore();
|
|
7703
|
-
this.#fileEntries = [
|
|
7704
|
-
header,
|
|
7705
|
-
...materializedPathWithoutLabels.map(
|
|
7706
|
-
entry => prepareEntryForResidentSync(entry, this.#residentBlobStores()) as SessionEntry,
|
|
7707
|
-
),
|
|
7708
|
-
...labelEntries,
|
|
7709
|
-
];
|
|
7710
9370
|
this.#flushed = true;
|
|
7711
|
-
this.#
|
|
7712
|
-
this.#bumpAllRevisions();
|
|
9371
|
+
this.#commitResidentTextStoreTransition(transition);
|
|
7713
9372
|
return newSessionFile;
|
|
7714
9373
|
}
|
|
7715
9374
|
|
|
@@ -7728,17 +9387,20 @@ export class SessionManager {
|
|
|
7728
9387
|
labelEntries.push(labelEntry);
|
|
7729
9388
|
parentId = labelEntry.id;
|
|
7730
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
|
+
);
|
|
7731
9402
|
this.#sessionId = newSessionId;
|
|
7732
|
-
this.#
|
|
7733
|
-
this.#fileEntries = [
|
|
7734
|
-
header,
|
|
7735
|
-
...materializedPathWithoutLabels.map(
|
|
7736
|
-
entry => prepareEntryForResidentSync(entry, this.#residentBlobStores()) as SessionEntry,
|
|
7737
|
-
),
|
|
7738
|
-
...labelEntries,
|
|
7739
|
-
];
|
|
7740
|
-
this.#buildIndex();
|
|
7741
|
-
this.#bumpAllRevisions();
|
|
9403
|
+
this.#commitResidentTextStoreTransition(transition);
|
|
7742
9404
|
return undefined;
|
|
7743
9405
|
}
|
|
7744
9406
|
|
|
@@ -7786,8 +9448,11 @@ export class SessionManager {
|
|
|
7786
9448
|
: process.platform === "win32"
|
|
7787
9449
|
? "windows-existing-verify-first"
|
|
7788
9450
|
: "default";
|
|
9451
|
+
const profileAgentDir =
|
|
9452
|
+
authority instanceof ManagedSessionDescendantStore ? authority.profileAgentDir : getAgentDir();
|
|
7789
9453
|
const securityContext = createManagedSessionSecurityContext({
|
|
7790
9454
|
agentDir: rootAuthority.canonicalPath,
|
|
9455
|
+
profileAgentDir,
|
|
7791
9456
|
sessionsRoot: rootAuthority.canonicalPath,
|
|
7792
9457
|
sessionDir: directory,
|
|
7793
9458
|
rootAuthority,
|
|
@@ -7966,25 +9631,26 @@ export class SessionManager {
|
|
|
7966
9631
|
const forkEntries = structuredClone(await loadEntriesFromFile(managedSourcePath, storage)) as FileEntry[];
|
|
7967
9632
|
migrateToCurrentVersion(forkEntries);
|
|
7968
9633
|
await resolveBlobRefsInEntries(forkEntries, manager.#blobStore);
|
|
7969
|
-
|
|
7970
|
-
const
|
|
7971
|
-
const
|
|
7972
|
-
|
|
7973
|
-
|
|
7974
|
-
const
|
|
7975
|
-
|
|
7976
|
-
|
|
7977
|
-
|
|
7978
|
-
|
|
7979
|
-
|
|
7980
|
-
|
|
7981
|
-
|
|
7982
|
-
|
|
7983
|
-
|
|
7984
|
-
|
|
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);
|
|
7985
9653
|
manager.sanitizeLoadedOpenAIResponsesReplayMetadata();
|
|
7986
|
-
manager.#buildIndex();
|
|
7987
|
-
manager.#bumpAllRevisions();
|
|
7988
9654
|
await manager.#rewriteFile();
|
|
7989
9655
|
return manager;
|
|
7990
9656
|
}
|
|
@@ -8062,32 +9728,20 @@ export class SessionManager {
|
|
|
8062
9728
|
storage,
|
|
8063
9729
|
destination,
|
|
8064
9730
|
);
|
|
8065
|
-
manager.#sessionFile = resolved;
|
|
8066
|
-
writeTerminalBreadcrumb(manager.cwd, resolved);
|
|
8067
|
-
manager.#fileEntries = entries;
|
|
8068
9731
|
if (entries.length > 0) {
|
|
8069
|
-
|
|
8070
|
-
manager.#
|
|
8071
|
-
manager
|
|
8072
|
-
manager.#needsFullRewriteOnNextPersist = migrateToCurrentVersion(manager.#fileEntries);
|
|
8073
|
-
await resolveBlobRefsInEntries(manager.#fileEntries, manager.#blobStore);
|
|
8074
|
-
manager.#resetResidentTextBlobStore();
|
|
8075
|
-
manager.#fileEntries = manager.#fileEntries.map(entry =>
|
|
8076
|
-
prepareEntryForResidentSync(entry, manager.#residentBlobStores()),
|
|
8077
|
-
);
|
|
8078
|
-
manager.#buildIndex();
|
|
8079
|
-
manager.#bumpAllRevisions();
|
|
8080
|
-
manager.#flushed = true;
|
|
8081
|
-
manager.#ensuredOnDisk = true;
|
|
9732
|
+
const migrationApplied = migrateToCurrentVersion(entries);
|
|
9733
|
+
await manager.#hydrateExistingSession(resolved, entries, migrationApplied, "memory-fallback");
|
|
9734
|
+
writeTerminalBreadcrumb(manager.cwd, resolved);
|
|
8082
9735
|
await manager.#sanitizeLoadedOpenAIResponsesReplayMetadataAndPersist();
|
|
8083
9736
|
} else {
|
|
8084
|
-
manager.#
|
|
8085
|
-
manager.#
|
|
8086
|
-
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);
|
|
8087
9742
|
await manager.#rewriteFile();
|
|
8088
9743
|
manager.#flushed = true;
|
|
8089
9744
|
manager.#ensuredOnDisk = true;
|
|
8090
|
-
manager.#bumpAllRevisions();
|
|
8091
9745
|
}
|
|
8092
9746
|
store.assertBound();
|
|
8093
9747
|
return manager;
|
|
@@ -8112,10 +9766,11 @@ export class SessionManager {
|
|
|
8112
9766
|
if (resolved.kind === "error") return [];
|
|
8113
9767
|
const listing = listManagedCandidates(resolved.scope);
|
|
8114
9768
|
if (listing.kind === "error") return [];
|
|
8115
|
-
|
|
9769
|
+
const managed = await collectSessionsFromFiles(
|
|
8116
9770
|
listing.owned.map(candidate => candidate.path),
|
|
8117
9771
|
storage,
|
|
8118
9772
|
);
|
|
9773
|
+
return mergeSessionInventories(managed, await collectProjectSessions(cwd, storage));
|
|
8119
9774
|
}
|
|
8120
9775
|
|
|
8121
9776
|
/**
|
|
@@ -8136,12 +9791,35 @@ export class SessionManager {
|
|
|
8136
9791
|
}
|
|
8137
9792
|
}
|
|
8138
9793
|
|
|
8139
|
-
/**
|
|
9794
|
+
/** Delete an authorized managed or project-local picker candidate. */
|
|
8140
9795
|
static async deleteManagedCandidate(sessionPath: string): Promise<void> {
|
|
8141
9796
|
const storage = new FileSessionStorage();
|
|
8142
9797
|
const entries = await loadEntriesFromFile(sessionPath, storage);
|
|
8143
9798
|
const header = entries.find(entry => entry.type === "session") as SessionHeader | undefined;
|
|
8144
|
-
if (!header?.cwd) throw new Error("Session has no valid
|
|
9799
|
+
if (!header?.cwd) throw new Error("Session has no valid workspace header.");
|
|
9800
|
+
const projectGjcDir = path.join(path.resolve(header.cwd), ".gjc");
|
|
9801
|
+
if (isProjectSessionTranscriptPath(projectGjcDir, sessionPath)) {
|
|
9802
|
+
const relativePath = path.relative(projectGjcDir, path.resolve(sessionPath)).split(path.sep).join("/");
|
|
9803
|
+
const authority = native.openRecoveryFsRoot(projectGjcDir);
|
|
9804
|
+
try {
|
|
9805
|
+
const captured = authority.readManaged(relativePath);
|
|
9806
|
+
if (!captured.ok || !captured.identity || !captured.data || !captured.identity.sha256)
|
|
9807
|
+
throw new Error("Project session is no longer an authorized candidate.");
|
|
9808
|
+
const removed = authority.removeManaged(
|
|
9809
|
+
relativePath,
|
|
9810
|
+
captured.identity.dev,
|
|
9811
|
+
captured.identity.ino,
|
|
9812
|
+
captured.identity.size,
|
|
9813
|
+
captured.identity.mtimeNs,
|
|
9814
|
+
captured.identity.ctimeNs,
|
|
9815
|
+
captured.identity.sha256,
|
|
9816
|
+
);
|
|
9817
|
+
if (!removed.ok) throw new Error(removed.code ?? "Could not delete project session.");
|
|
9818
|
+
return;
|
|
9819
|
+
} finally {
|
|
9820
|
+
authority.close();
|
|
9821
|
+
}
|
|
9822
|
+
}
|
|
8145
9823
|
const sessionsRoot = path.resolve(sessionPath, "../..");
|
|
8146
9824
|
const resolved = resolveManagedScope({
|
|
8147
9825
|
cwd: header.cwd,
|
|
@@ -8232,24 +9910,25 @@ export class SessionManager {
|
|
|
8232
9910
|
try {
|
|
8233
9911
|
manager = new SessionManager(cwd, privateStagingDir ?? dir, true, snapshot.storage, forkDestination);
|
|
8234
9912
|
await resolveBlobRefsInEntries(forkEntries, manager.#blobStore);
|
|
8235
|
-
|
|
8236
|
-
const
|
|
8237
|
-
const
|
|
8238
|
-
|
|
8239
|
-
|
|
8240
|
-
const
|
|
8241
|
-
|
|
8242
|
-
|
|
8243
|
-
|
|
8244
|
-
|
|
8245
|
-
|
|
8246
|
-
|
|
8247
|
-
|
|
8248
|
-
|
|
8249
|
-
|
|
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);
|
|
8250
9931
|
manager.sanitizeLoadedOpenAIResponsesReplayMetadata();
|
|
8251
|
-
manager.#buildIndex();
|
|
8252
|
-
manager.#bumpAllRevisions();
|
|
8253
9932
|
const beforeWrite = inspectResumeSessionFile(snapshot.sourcePath, snapshot.storage);
|
|
8254
9933
|
if ("kind" in beforeWrite) {
|
|
8255
9934
|
authorityFailure = beforeWrite;
|
|
@@ -8287,41 +9966,57 @@ export class SessionManager {
|
|
|
8287
9966
|
throw new Error("managed_fork_transcript_changed");
|
|
8288
9967
|
}
|
|
8289
9968
|
if (privateStagingDir && manager.#sessionFile) {
|
|
8290
|
-
const
|
|
8291
|
-
|
|
8292
|
-
|
|
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",
|
|
8293
9984
|
);
|
|
8294
|
-
|
|
8295
|
-
|
|
8296
|
-
|
|
8297
|
-
|
|
8298
|
-
|
|
8299
|
-
|
|
8300
|
-
|
|
8301
|
-
|
|
8302
|
-
|
|
8303
|
-
|
|
8304
|
-
|
|
8305
|
-
|
|
8306
|
-
|
|
8307
|
-
|
|
8308
|
-
|
|
8309
|
-
|
|
8310
|
-
|
|
8311
|
-
|
|
8312
|
-
|
|
8313
|
-
|
|
8314
|
-
|
|
8315
|
-
|
|
8316
|
-
|
|
8317
|
-
|
|
8318
|
-
|
|
8319
|
-
|
|
8320
|
-
|
|
8321
|
-
|
|
8322
|
-
|
|
8323
|
-
|
|
8324
|
-
|
|
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
|
+
}
|
|
8325
10020
|
}
|
|
8326
10021
|
if (manager.#sessionFile) writeTerminalBreadcrumb(manager.cwd, manager.#sessionFile);
|
|
8327
10022
|
return { kind: "forked", manager };
|
|
@@ -8349,6 +10044,194 @@ export class SessionManager {
|
|
|
8349
10044
|
}
|
|
8350
10045
|
}
|
|
8351
10046
|
|
|
10047
|
+
static async restoreMemoryGuardCheckpoint(input: MemoryGuardRestoreInput): Promise<MemoryGuardRestoreResult> {
|
|
10048
|
+
try {
|
|
10049
|
+
assertMemoryGuardSessionId(input.checkpoint.session_id);
|
|
10050
|
+
} catch {
|
|
10051
|
+
return { kind: "blocked", reason: "checkpoint-mismatch" };
|
|
10052
|
+
}
|
|
10053
|
+
if (!validateMemoryGuardCheckpoint(input.checkpoint)) return { kind: "blocked", reason: "checkpoint-mismatch" };
|
|
10054
|
+
if (!memoryGuardParticipantMatchesCheckpoint(input.participant, input.checkpoint)) {
|
|
10055
|
+
return { kind: "blocked", reason: "participant-mismatch" };
|
|
10056
|
+
}
|
|
10057
|
+
const checkpointText = readCheckpointAuthorityFile(
|
|
10058
|
+
input.incidentAuthority,
|
|
10059
|
+
memoryGuardParticipantRelativePath(input.checkpoint.session_id, "session-manager.json"),
|
|
10060
|
+
256 * 1024,
|
|
10061
|
+
);
|
|
10062
|
+
if (!checkpointText) return { kind: "blocked", reason: "checkpoint-mismatch" };
|
|
10063
|
+
const checkpointCanonical = memoryGuardCanonicalJson(input.checkpoint);
|
|
10064
|
+
if (decodeCheckpointUtf8(checkpointText) !== checkpointCanonical) {
|
|
10065
|
+
return { kind: "blocked", reason: "checkpoint-mismatch" };
|
|
10066
|
+
}
|
|
10067
|
+
const transcriptBytes = Number(input.checkpoint.transcript.bytes);
|
|
10068
|
+
if (!Number.isSafeInteger(transcriptBytes) || transcriptBytes < 0) {
|
|
10069
|
+
return { kind: "blocked", reason: "transcript-mismatch" };
|
|
10070
|
+
}
|
|
10071
|
+
const transcriptData = readCheckpointAuthorityFile(
|
|
10072
|
+
input.incidentAuthority,
|
|
10073
|
+
input.checkpoint.transcript.relative_path,
|
|
10074
|
+
transcriptBytes + 1,
|
|
10075
|
+
);
|
|
10076
|
+
if (!transcriptData || transcriptData.byteLength !== transcriptBytes) {
|
|
10077
|
+
return { kind: "blocked", reason: "transcript-mismatch" };
|
|
10078
|
+
}
|
|
10079
|
+
if (memoryGuardSha256Hex(transcriptData) !== input.checkpoint.transcript.sha256) {
|
|
10080
|
+
return { kind: "blocked", reason: "transcript-mismatch" };
|
|
10081
|
+
}
|
|
10082
|
+
const transcriptText = decodeCheckpointUtf8(transcriptData);
|
|
10083
|
+
if (transcriptText === null) return { kind: "blocked", reason: "transcript-mismatch" };
|
|
10084
|
+
let transcriptEntries: FileEntry[];
|
|
10085
|
+
try {
|
|
10086
|
+
transcriptEntries = parseSessionEntries(transcriptText);
|
|
10087
|
+
} catch {
|
|
10088
|
+
return { kind: "blocked", reason: "malformed" };
|
|
10089
|
+
}
|
|
10090
|
+
const transcriptHeader = transcriptEntries[0] as SessionHeader | undefined;
|
|
10091
|
+
if (
|
|
10092
|
+
transcriptHeader?.type !== "session" ||
|
|
10093
|
+
transcriptHeader.id !== input.checkpoint.session_id ||
|
|
10094
|
+
(transcriptHeader.title ?? null) !== input.checkpoint.session_name
|
|
10095
|
+
) {
|
|
10096
|
+
return { kind: "blocked", reason: "transcript-mismatch" };
|
|
10097
|
+
}
|
|
10098
|
+
const manifestData = readCheckpointAuthorityFile(
|
|
10099
|
+
input.incidentAuthority,
|
|
10100
|
+
input.checkpoint.blob_authority.manifest_relative_path,
|
|
10101
|
+
8 * 1024 * 1024,
|
|
10102
|
+
);
|
|
10103
|
+
if (!manifestData) return { kind: "blocked", reason: "blob-authority-mismatch" };
|
|
10104
|
+
if (memoryGuardSha256Hex(manifestData) !== input.checkpoint.blob_authority.manifest_sha256) {
|
|
10105
|
+
return { kind: "blocked", reason: "blob-authority-mismatch" };
|
|
10106
|
+
}
|
|
10107
|
+
const manifestText = decodeCheckpointUtf8(manifestData);
|
|
10108
|
+
if (manifestText === null) return { kind: "blocked", reason: "blob-manifest-mismatch" };
|
|
10109
|
+
let blobManifest: unknown;
|
|
10110
|
+
try {
|
|
10111
|
+
blobManifest = JSON.parse(manifestText);
|
|
10112
|
+
} catch {
|
|
10113
|
+
return { kind: "blocked", reason: "blob-manifest-mismatch" };
|
|
10114
|
+
}
|
|
10115
|
+
if (!validateMemoryGuardBlobManifest(blobManifest)) return { kind: "blocked", reason: "blob-manifest-mismatch" };
|
|
10116
|
+
if (memoryGuardCanonicalJson(blobManifest) !== manifestText)
|
|
10117
|
+
return { kind: "blocked", reason: "blob-manifest-mismatch" };
|
|
10118
|
+
if (blobManifest.entries.length > MEMORY_GUARD_CHECKPOINT_BLOB_MAX_ENTRIES)
|
|
10119
|
+
return { kind: "blocked", reason: "blob-manifest-mismatch" };
|
|
10120
|
+
let checkpointBlobBytes = 0;
|
|
10121
|
+
const seenBlobPaths = new Set<string>();
|
|
10122
|
+
const checkpointBlobs = new Map<string, Buffer>();
|
|
10123
|
+
for (const entry of blobManifest.entries) {
|
|
10124
|
+
if (
|
|
10125
|
+
entry.relative_path !== entry.sha256 ||
|
|
10126
|
+
seenBlobPaths.has(entry.relative_path) ||
|
|
10127
|
+
checkpointBlobs.has(entry.sha256)
|
|
10128
|
+
)
|
|
10129
|
+
return { kind: "blocked", reason: "blob-manifest-mismatch" };
|
|
10130
|
+
seenBlobPaths.add(entry.relative_path);
|
|
10131
|
+
const blobBytes = Number(entry.bytes);
|
|
10132
|
+
if (!Number.isSafeInteger(blobBytes) || blobBytes < 0)
|
|
10133
|
+
return { kind: "blocked", reason: "blob-manifest-mismatch" };
|
|
10134
|
+
checkpointBlobBytes += blobBytes;
|
|
10135
|
+
if (checkpointBlobBytes > MEMORY_GUARD_CHECKPOINT_BLOB_TOTAL_MAX_BYTES)
|
|
10136
|
+
return { kind: "blocked", reason: "blob-manifest-mismatch" };
|
|
10137
|
+
const blobPath = `${input.checkpoint.blob_authority.root_relative_path}/${entry.relative_path}`;
|
|
10138
|
+
const blobData = readCheckpointAuthorityFile(input.incidentAuthority, blobPath, blobBytes + 1);
|
|
10139
|
+
if (!blobData || blobData.byteLength !== blobBytes) return { kind: "blocked", reason: "blob-missing" };
|
|
10140
|
+
if (memoryGuardSha256Hex(blobData) !== entry.sha256) return { kind: "blocked", reason: "blob-hash-mismatch" };
|
|
10141
|
+
checkpointBlobs.set(entry.sha256, Buffer.from(blobData));
|
|
10142
|
+
}
|
|
10143
|
+
for (const ref of collectCheckpointBlobRefs(transcriptEntries)) {
|
|
10144
|
+
const hash = parseBlobRef(ref);
|
|
10145
|
+
if (!hash || !checkpointBlobs.has(hash)) return { kind: "blocked", reason: "blob-manifest-mismatch" };
|
|
10146
|
+
}
|
|
10147
|
+
const storage = new FileSessionStorage();
|
|
10148
|
+
let destination: SessionDestination;
|
|
10149
|
+
try {
|
|
10150
|
+
destination =
|
|
10151
|
+
input.destination === undefined
|
|
10152
|
+
? explicitDestination(path.join(os.tmpdir(), `gjc-memory-guard-${input.checkpoint.session_id}`))
|
|
10153
|
+
: destinationFor(getProjectDir(), input.destination, storage);
|
|
10154
|
+
} catch {
|
|
10155
|
+
return { kind: "blocked", reason: "destination-unavailable" };
|
|
10156
|
+
}
|
|
10157
|
+
const transcriptPath = path.join(
|
|
10158
|
+
destination.directory,
|
|
10159
|
+
`.${input.checkpoint.session_id}.memory-guard.${crypto.randomUUID()}.jsonl`,
|
|
10160
|
+
);
|
|
10161
|
+
const transcriptName = path.basename(transcriptPath);
|
|
10162
|
+
const managedStagingStore =
|
|
10163
|
+
destination.kind === "managed"
|
|
10164
|
+
? managedStoreFromContext(destination.securityContext, destination.directory)
|
|
10165
|
+
: undefined;
|
|
10166
|
+
const cleanupTranscript = async (): Promise<void> => {
|
|
10167
|
+
if (managedStagingStore) {
|
|
10168
|
+
await managedStagingStore.remove(transcriptName);
|
|
10169
|
+
return;
|
|
10170
|
+
}
|
|
10171
|
+
await fs.promises.rm(transcriptPath, { force: true });
|
|
10172
|
+
};
|
|
10173
|
+
let opened: RecoveryHydrationOpenResult;
|
|
10174
|
+
let transcriptIdentity: ResumeSessionIdentity;
|
|
10175
|
+
let createdDestination: CreatedDirectoryIdentity | undefined;
|
|
10176
|
+
try {
|
|
10177
|
+
if (managedStagingStore) await managedStagingStore.publishNoReplace(transcriptName, transcriptData);
|
|
10178
|
+
else {
|
|
10179
|
+
createdDestination = await ensureOwnerOnlyDirectoryTracked(destination.directory);
|
|
10180
|
+
await writeOwnerOnlyFileNoReplace(transcriptPath, transcriptData);
|
|
10181
|
+
}
|
|
10182
|
+
const captured = SessionManager.captureTranscriptStrict(transcriptPath, storage);
|
|
10183
|
+
if (captured.kind !== "captured") {
|
|
10184
|
+
await cleanupTranscript();
|
|
10185
|
+
await removeCreatedDirectoryIfEmpty(destination.directory, createdDestination);
|
|
10186
|
+
return { kind: "blocked", reason: captured.reason };
|
|
10187
|
+
}
|
|
10188
|
+
transcriptIdentity = captured.snapshot.identity;
|
|
10189
|
+
opened = await SessionManager.openExistingForRecoveryHydrationStrict(
|
|
10190
|
+
transcriptIdentity,
|
|
10191
|
+
input.destination,
|
|
10192
|
+
storage,
|
|
10193
|
+
);
|
|
10194
|
+
} catch {
|
|
10195
|
+
await cleanupTranscript();
|
|
10196
|
+
await removeCreatedDirectoryIfEmpty(destination.directory, createdDestination);
|
|
10197
|
+
return { kind: "blocked", reason: "destination-unavailable" };
|
|
10198
|
+
}
|
|
10199
|
+
if (opened.kind === "error") {
|
|
10200
|
+
await cleanupTranscript();
|
|
10201
|
+
await removeCreatedDirectoryIfEmpty(destination.directory, createdDestination);
|
|
10202
|
+
return { kind: "blocked", reason: opened.reason };
|
|
10203
|
+
}
|
|
10204
|
+
if (
|
|
10205
|
+
opened.manager.getSessionId() !== input.checkpoint.session_id ||
|
|
10206
|
+
(opened.manager.getSessionName() ?? null) !== input.checkpoint.session_name
|
|
10207
|
+
) {
|
|
10208
|
+
await opened.manager.close();
|
|
10209
|
+
await cleanupTranscript();
|
|
10210
|
+
await removeCreatedDirectoryIfEmpty(destination.directory, createdDestination);
|
|
10211
|
+
return { kind: "blocked", reason: "transcript-mismatch" };
|
|
10212
|
+
}
|
|
10213
|
+
opened.manager.#stageMemoryGuardCheckpointBlobs(checkpointBlobs);
|
|
10214
|
+
opened.manager.#recoveryPromotionTranscriptPath = path.join(
|
|
10215
|
+
destination.directory,
|
|
10216
|
+
`${new Date().toISOString().replace(/[:.]/g, "-")}_${input.checkpoint.session_id}.jsonl`,
|
|
10217
|
+
);
|
|
10218
|
+
let cleanupConsumed = false;
|
|
10219
|
+
const cleanup = async (): Promise<void> => {
|
|
10220
|
+
if (cleanupConsumed) return;
|
|
10221
|
+
await opened.manager.close();
|
|
10222
|
+
await cleanupTranscript();
|
|
10223
|
+
await removeCreatedDirectoryIfEmpty(destination.directory, createdDestination);
|
|
10224
|
+
cleanupConsumed = true;
|
|
10225
|
+
};
|
|
10226
|
+
return {
|
|
10227
|
+
kind: "staged",
|
|
10228
|
+
manager: opened.manager,
|
|
10229
|
+
hydrationContext: opened.context,
|
|
10230
|
+
transcriptIdentity,
|
|
10231
|
+
cleanup,
|
|
10232
|
+
};
|
|
10233
|
+
}
|
|
10234
|
+
|
|
8352
10235
|
/** Inspect a selected session without acquiring write-capable ownership. */
|
|
8353
10236
|
static async inspectSessionTailReadOnly(
|
|
8354
10237
|
filePath: string,
|
|
@@ -8389,7 +10272,7 @@ export class SessionManager {
|
|
|
8389
10272
|
storage,
|
|
8390
10273
|
destination,
|
|
8391
10274
|
);
|
|
8392
|
-
await manager.#hydrateExistingSession(identity.canonicalPath, inspected.entries, false);
|
|
10275
|
+
await manager.#hydrateExistingSession(identity.canonicalPath, inspected.entries, false, "memory-only");
|
|
8393
10276
|
const revalidated = inspectResumeSessionFile(identity.canonicalPath, storage);
|
|
8394
10277
|
if ("kind" in revalidated || !sameResumeIdentity(identity, revalidated.identity)) {
|
|
8395
10278
|
await manager.close();
|
|
@@ -8417,8 +10300,51 @@ export class SessionManager {
|
|
|
8417
10300
|
if ("kind" in inspected || !sameResumeIdentity(context.identity, inspected.identity)) {
|
|
8418
10301
|
throw new Error("Recovery transcript authority changed before promotion.");
|
|
8419
10302
|
}
|
|
10303
|
+
if (this.#memoryGuardCheckpointBlobs) {
|
|
10304
|
+
for (const blob of this.#memoryGuardCheckpointBlobs.values()) this.#blobStore.putImmutableSync(blob);
|
|
10305
|
+
this.#residentImageBlobStore = this.#blobStore;
|
|
10306
|
+
this.#memoryGuardCheckpointBlobs = undefined;
|
|
10307
|
+
}
|
|
8420
10308
|
await this.#sanitizeLoadedOpenAIResponsesReplayMetadataAndPersist();
|
|
8421
|
-
|
|
10309
|
+
const promotionPath = this.#recoveryPromotionTranscriptPath;
|
|
10310
|
+
if (!promotionPath) throw new Error("Recovery transcript promotion path is unavailable.");
|
|
10311
|
+
const promotedSource = inspectResumeSessionFile(context.identity.canonicalPath, this.storage);
|
|
10312
|
+
if ("kind" in promotedSource) throw new Error("Recovery transcript became unavailable before publication.");
|
|
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;
|
|
10343
|
+
}
|
|
10344
|
+
this.#sessionFile = promotionPath;
|
|
10345
|
+
this.#recoveryPromotionTranscriptPath = undefined;
|
|
10346
|
+
this.#commitResidentTextStoreTransition(transition);
|
|
10347
|
+
writeTerminalBreadcrumb(this.cwd, promotionPath);
|
|
8422
10348
|
this.#recoveryHydrationContext = undefined;
|
|
8423
10349
|
}
|
|
8424
10350
|
|