@gajae-code/coding-agent 0.11.7 → 0.11.9
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 +73 -1
- package/dist/types/cli/args.d.ts +0 -2
- package/dist/types/cli.d.ts +1 -0
- package/dist/types/config/keybindings.d.ts +33 -3
- package/dist/types/config/model-profile-activation.d.ts +4 -3
- package/dist/types/config/model-profile-contract.d.ts +38 -0
- package/dist/types/config/model-profiles.d.ts +2 -3
- package/dist/types/config/settings-schema.d.ts +15 -2
- package/dist/types/edit/modes/patch.d.ts +13 -0
- package/dist/types/edit/path-mutation-lock.d.ts +15 -0
- package/dist/types/eval/py/executor.d.ts +1 -1
- package/dist/types/eval/py/tool-bridge.d.ts +1 -2
- package/dist/types/gjc-runtime/deep-interview-ambiguity.d.ts +12 -5
- package/dist/types/gjc-runtime/deep-interview-draft.d.ts +8 -0
- package/dist/types/gjc-runtime/deep-interview-payload.d.ts +41 -0
- package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +32 -17
- package/dist/types/gjc-runtime/deep-interview-repair.d.ts +17 -0
- package/dist/types/gjc-runtime/deep-interview-state.d.ts +135 -4
- package/dist/types/gjc-runtime/repository-binding.d.ts +63 -0
- package/dist/types/gjc-runtime/state-writer.d.ts +44 -0
- package/dist/types/gjc-runtime/ultragoal-guard.d.ts +1 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +24 -0
- package/dist/types/gjc-runtime/workflow-manifest.d.ts +5 -3
- package/dist/types/main.d.ts +8 -0
- package/dist/types/modes/components/runtime-mcp-add-wizard.d.ts +2 -1
- package/dist/types/modes/components/tool-status-header.d.ts +3 -2
- package/dist/types/modes/components/welcome.d.ts +4 -0
- package/dist/types/modes/controllers/command-controller.d.ts +2 -0
- package/dist/types/modes/controllers/selector-controller.d.ts +2 -1
- package/dist/types/modes/interactive-mode.d.ts +5 -2
- package/dist/types/modes/prompt-action-autocomplete.d.ts +6 -1
- package/dist/types/modes/prompt-suggestion-controller.d.ts +35 -0
- package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +2 -2
- package/dist/types/modes/types.d.ts +3 -0
- package/dist/types/modes/utils/hotkeys-markdown.d.ts +2 -1
- package/dist/types/runtime-mcp/manager.d.ts +1 -1
- package/dist/types/runtime-mcp/oauth-flow.d.ts +0 -5
- package/dist/types/runtime-mcp/plugin-network-boundary.d.ts +14 -0
- package/dist/types/runtime-mcp/redaction.d.ts +2 -0
- package/dist/types/sdk/broker/broker.d.ts +2 -0
- package/dist/types/sdk/broker/lifecycle.d.ts +6 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +7 -1
- package/dist/types/sdk/bus/config-commands.d.ts +2 -2
- package/dist/types/sdk/bus/config.d.ts +2 -0
- package/dist/types/sdk/bus/conversation-store.d.ts +1 -0
- package/dist/types/sdk/bus/index.d.ts +25 -0
- package/dist/types/sdk/bus/notification-orchestration.d.ts +1 -0
- package/dist/types/sdk/bus/prompt-reconciliation.d.ts +105 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +15 -3
- package/dist/types/sdk/bus/telegram-daemon-control.d.ts +5 -1
- package/dist/types/sdk/bus/telegram-daemon.d.ts +128 -1
- package/dist/types/sdk/bus/telegram-reference.d.ts +2 -0
- package/dist/types/sdk/host/control/operations.d.ts +1 -1
- package/dist/types/sdk/host/host.d.ts +1 -1
- package/dist/types/sdk/host/query/handlers.d.ts +9 -0
- package/dist/types/sdk/index.d.ts +2 -0
- package/dist/types/sdk/prompt-status.d.ts +88 -0
- package/dist/types/sdk/session-directory.d.ts +1 -1
- package/dist/types/sdk/startup-capability.d.ts +3 -0
- package/dist/types/session/agent-session.d.ts +2 -0
- package/dist/types/session/internal/managed-session-scope.d.ts +22 -3
- package/dist/types/session/internal/managed-session-storage.d.ts +24 -4
- package/dist/types/session/internal/native-publish-outcome.d.ts +48 -0
- package/dist/types/session/session-manager.d.ts +5 -1
- package/dist/types/session/session-storage.d.ts +31 -0
- package/dist/types/skill-state/active-state.d.ts +4 -0
- package/dist/types/task/index.d.ts +3 -1
- package/dist/types/task/receipt.d.ts +2 -0
- package/dist/types/task/types.d.ts +94 -0
- package/dist/types/tools/ask.d.ts +454 -174
- package/dist/types/tools/image-gen.d.ts +47 -1
- package/dist/types/tools/index.d.ts +4 -0
- package/dist/types/utils/prompt-suggestion.d.ts +33 -0
- package/package.json +8 -8
- package/scripts/capture-platform-shortcut-labels-showcase.ts +342 -0
- package/scripts/dogfood-repository-binding.ts +209 -0
- package/scripts/generate-sdk-operation-inventory.ts +2 -0
- package/scripts/run-test-manifest.ts +4 -4
- package/src/cli/args.ts +2 -92
- package/src/cli/mcp-cli.ts +4 -21
- package/src/cli/notify-cli.ts +5 -38
- package/src/cli.ts +50 -0
- package/src/commands/deep-interview.ts +9 -4
- package/src/commands/sdk.ts +2 -2
- package/src/commit/agentic/index.ts +3 -3
- package/src/commit/map-reduce/index.ts +2 -2
- package/src/config/keybindings.ts +157 -24
- package/src/config/model-profile-activation.ts +7 -18
- package/src/config/model-profile-contract.ts +161 -0
- package/src/config/model-profiles.ts +2 -3
- package/src/config/model-registry.ts +11 -1
- package/src/config/settings-schema.ts +17 -2
- package/src/coordinator-mcp/model-preset.ts +21 -74
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +80 -165
- package/src/defaults/gjc/skills/ralplan/SKILL.md +3 -1
- package/src/edit/index.ts +3 -6
- package/src/edit/modes/patch.ts +38 -1
- package/src/edit/modes/replace.ts +42 -0
- package/src/edit/path-mutation-lock.ts +112 -0
- package/src/eval/py/executor.ts +30 -11
- package/src/eval/py/prelude.py +2 -2
- package/src/eval/py/tool-bridge.ts +34 -18
- package/src/extensibility/gjc-plugins/runtime-adapters.ts +2 -1
- package/src/gjc-runtime/deep-interview-ambiguity.ts +68 -47
- package/src/gjc-runtime/deep-interview-draft.ts +842 -0
- package/src/gjc-runtime/deep-interview-payload.ts +262 -0
- package/src/gjc-runtime/deep-interview-recorder.ts +294 -226
- package/src/gjc-runtime/deep-interview-repair.ts +1674 -0
- package/src/gjc-runtime/deep-interview-runtime.ts +264 -103
- package/src/gjc-runtime/deep-interview-state.ts +916 -4
- package/src/gjc-runtime/launch-tmux.ts +1 -1
- package/src/gjc-runtime/managed-owner-supervisor.ts +83 -4
- package/src/gjc-runtime/ralplan-runtime.ts +78 -4
- package/src/gjc-runtime/repository-binding.ts +267 -0
- package/src/gjc-runtime/session-state-sidecar.ts +25 -9
- package/src/gjc-runtime/state-writer.ts +166 -2
- package/src/gjc-runtime/tmux-owner-isolation.ts +2 -1
- package/src/gjc-runtime/tmux-sessions.ts +45 -6
- package/src/gjc-runtime/ultragoal-guard.ts +5 -5
- package/src/gjc-runtime/ultragoal-runtime.ts +181 -19
- package/src/gjc-runtime/workflow-manifest.generated.json +386 -1
- package/src/gjc-runtime/workflow-manifest.ts +191 -5
- package/src/internal-urls/docs-index.generated.ts +8 -6
- package/src/internal-urls/local-protocol.ts +33 -16
- package/src/internal-urls/mcp-protocol.ts +73 -11
- package/src/main.ts +25 -10
- package/src/modes/DESIGN.md +5 -0
- package/src/modes/acp/acp-agent.ts +50 -16
- package/src/modes/components/model-selector.ts +42 -8
- package/src/modes/components/plan-preview-overlay.ts +1 -1
- package/src/modes/components/runtime-mcp-add-wizard.ts +5 -0
- package/src/modes/components/tool-status-header.ts +20 -4
- package/src/modes/components/welcome.ts +32 -20
- package/src/modes/controllers/command-controller.ts +97 -54
- package/src/modes/controllers/event-controller.ts +10 -0
- package/src/modes/controllers/input-controller.ts +7 -2
- package/src/modes/controllers/runtime-mcp-command-controller.ts +29 -6
- package/src/modes/controllers/selector-controller.ts +46 -54
- package/src/modes/interactive-mode.ts +70 -36
- package/src/modes/prompt-action-autocomplete.ts +32 -13
- package/src/modes/prompt-suggestion-controller.ts +96 -0
- package/src/modes/shared/agent-wire/workflow-gate-broker.ts +2 -2
- package/src/modes/types.ts +3 -0
- package/src/modes/utils/hotkeys-markdown.ts +64 -36
- package/src/modes/utils/ui-helpers.ts +5 -3
- package/src/prompts/agents/architect.md +0 -1
- package/src/prompts/agents/critic.md +0 -1
- package/src/prompts/system/prompt-suggestion-system.md +32 -0
- package/src/prompts/system/system-prompt.md +7 -4
- package/src/prompts/tools/cron.md +3 -1
- package/src/runtime-mcp/json-rpc.ts +7 -4
- package/src/runtime-mcp/manager.ts +51 -53
- package/src/runtime-mcp/oauth-flow.ts +0 -72
- package/src/runtime-mcp/plugin-network-boundary.ts +101 -0
- package/src/runtime-mcp/redaction.ts +30 -0
- package/src/runtime-mcp/transports/http.ts +13 -6
- package/src/sdk/broker/broker.ts +34 -20
- package/src/sdk/broker/lifecycle.ts +185 -36
- package/src/sdk/broker/session-index.ts +11 -1
- package/src/sdk/bus/chat-daemon-control.ts +20 -5
- package/src/sdk/bus/config-commands.ts +2 -2
- package/src/sdk/bus/config.ts +9 -1
- package/src/sdk/bus/conversation-store.ts +40 -6
- package/src/sdk/bus/index.ts +240 -65
- package/src/sdk/bus/lifecycle-commands.ts +47 -3
- package/src/sdk/bus/lifecycle-control-runtime.ts +11 -2
- package/src/sdk/bus/lifecycle-orchestrator.ts +32 -1
- package/src/sdk/bus/notification-orchestration.ts +12 -0
- package/src/sdk/bus/notification-service.ts +27 -2
- package/src/sdk/bus/prompt-reconciliation.ts +239 -0
- package/src/sdk/bus/recent-activity.ts +90 -26
- package/src/sdk/bus/telegram-daemon-contract.ts +15 -3
- package/src/sdk/bus/telegram-daemon-control.ts +145 -33
- package/src/sdk/bus/telegram-daemon.ts +853 -112
- package/src/sdk/bus/telegram-reference.ts +20 -2
- package/src/sdk/cli/session-cli.ts +15 -2
- package/src/sdk/host/control/dispatch.ts +1 -1
- package/src/sdk/host/control/operations.ts +1 -1
- package/src/sdk/host/host.ts +2 -6
- package/src/sdk/host/query/handlers.ts +70 -5
- package/src/sdk/index.ts +12 -0
- package/src/sdk/prompt-status.ts +97 -0
- package/src/sdk/protocol/operation-inventory.generated.json +47 -0
- package/src/sdk/protocol/operation-registry.ts +18 -4
- package/src/sdk/session-directory.ts +5 -1
- package/src/sdk/session.ts +2 -0
- package/src/sdk/startup-capability.ts +9 -1
- package/src/session/agent-session.ts +138 -5
- package/src/session/blob-store.ts +7 -2
- package/src/session/internal/managed-session-scope.ts +998 -133
- package/src/session/internal/managed-session-storage.ts +268 -203
- package/src/session/internal/native-publish-outcome.ts +266 -0
- package/src/session/session-manager.ts +406 -360
- package/src/session/session-storage.ts +151 -11
- package/src/skill-state/active-state.ts +10 -0
- package/src/task/executor.ts +13 -5
- package/src/task/gjc-command.ts +2 -2
- package/src/task/index.ts +121 -15
- package/src/task/receipt.ts +3 -0
- package/src/task/types.ts +34 -3
- package/src/tools/ask.ts +199 -88
- package/src/tools/ast-edit.ts +2 -2
- package/src/tools/bash-pty-selection.ts +2 -2
- package/src/tools/browser/screenshot-format.ts +6 -1
- package/src/tools/cron.ts +1 -1
- package/src/tools/image-gen.ts +178 -6
- package/src/tools/index.ts +4 -0
- package/src/tools/render-utils.ts +7 -1
- package/src/utils/edit-mode.ts +2 -2
- package/src/utils/prompt-suggestion.ts +265 -0
- package/src/utils/sixel.ts +3 -3
- package/src/web/insane/url-guard.ts +3 -1
- package/src/web/scrapers/docs-rs.ts +30 -5
|
@@ -59,6 +59,7 @@ import {
|
|
|
59
59
|
listManagedCandidates,
|
|
60
60
|
type ManagedCandidateWriteAuthority,
|
|
61
61
|
type ManagedMigrationPolicy,
|
|
62
|
+
type ManagedOpenCandidateResult,
|
|
62
63
|
type ManagedScope,
|
|
63
64
|
managedDirectoryAuthorityForScope,
|
|
64
65
|
managedRootForScope,
|
|
@@ -69,12 +70,15 @@ import {
|
|
|
69
70
|
} from "./internal/managed-session-scope";
|
|
70
71
|
import {
|
|
71
72
|
assertManagedDirectoryRoot,
|
|
73
|
+
fsyncManagedArtifactTree,
|
|
72
74
|
type ManagedDirectoryRoot,
|
|
73
75
|
type ManagedFileSnapshot,
|
|
74
76
|
ManagedSessionDescendantStore,
|
|
75
77
|
type ManagedSessionSecurityPolicy,
|
|
78
|
+
mayCleanManagedTreeStaging,
|
|
76
79
|
retainManagedDirectoryAuthority,
|
|
77
80
|
} from "./internal/managed-session-storage";
|
|
81
|
+
import { classifyNativePublishOutcome, formatNativePublishDiagnostic } from "./internal/native-publish-outcome";
|
|
78
82
|
|
|
79
83
|
import {
|
|
80
84
|
type BashExecutionMessage,
|
|
@@ -543,6 +547,13 @@ export class SessionMigrationPolicyError extends Error {
|
|
|
543
547
|
}
|
|
544
548
|
}
|
|
545
549
|
|
|
550
|
+
export class SessionArtifactCapacityError extends Error {
|
|
551
|
+
constructor(message: string) {
|
|
552
|
+
super(message);
|
|
553
|
+
this.name = "SessionArtifactCapacityError";
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
546
557
|
export type ResumeTailInspection = ResumeTailResumable | ResumeTailTerminal | ResumeTailError;
|
|
547
558
|
export interface StrictSessionOpenSuccess {
|
|
548
559
|
kind: "opened";
|
|
@@ -551,7 +562,8 @@ export interface StrictSessionOpenSuccess {
|
|
|
551
562
|
|
|
552
563
|
export interface StrictSessionOpenFailure {
|
|
553
564
|
kind: "error";
|
|
554
|
-
reason: ResumeTailError["reason"] | "identity-mismatch" | "migration-required";
|
|
565
|
+
reason: ResumeTailError["reason"] | "identity-mismatch" | "migration-required" | "artifact_capacity_exceeded";
|
|
566
|
+
message?: string;
|
|
555
567
|
}
|
|
556
568
|
|
|
557
569
|
/** Exact transcript bytes and authority captured from one descriptor-bound read. */
|
|
@@ -1320,6 +1332,18 @@ function freezeInternalReadSnapshot<T>(value: T): T {
|
|
|
1320
1332
|
return copy;
|
|
1321
1333
|
}
|
|
1322
1334
|
|
|
1335
|
+
function managedScopeStartupError(
|
|
1336
|
+
action: "resolve" | "prepare",
|
|
1337
|
+
failure: Extract<ReturnType<typeof resolveManagedScopeForWrite>, { kind: "error" }>,
|
|
1338
|
+
): Error {
|
|
1339
|
+
return new Error(`Could not ${action} managed session scope.`, {
|
|
1340
|
+
cause: {
|
|
1341
|
+
classification: failure.cause?.classification ?? failure.code,
|
|
1342
|
+
...(failure.cause?.diagnostic === undefined ? {} : { diagnostic: failure.cause.diagnostic }),
|
|
1343
|
+
},
|
|
1344
|
+
});
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1323
1347
|
/** Resolve and prepare the default v2 session scope before any managed writer exists. */
|
|
1324
1348
|
function computeDefaultSessionDir(
|
|
1325
1349
|
cwd: string,
|
|
@@ -1328,12 +1352,12 @@ function computeDefaultSessionDir(
|
|
|
1328
1352
|
): string {
|
|
1329
1353
|
if (!(storage instanceof FileSessionStorage)) throw new SessionManagedStorageError();
|
|
1330
1354
|
const resolved = resolveManagedScopeForWrite({ cwd, agentDir: path.resolve(sessionsRoot, ".."), sessionsRoot });
|
|
1331
|
-
if (resolved.kind === "error") throw
|
|
1355
|
+
if (resolved.kind === "error") throw managedScopeStartupError("resolve", resolved);
|
|
1332
1356
|
const prepared = prepareManagedSessionScopeForWriteSync(
|
|
1333
1357
|
resolved.scope,
|
|
1334
1358
|
process.platform === "win32" ? "windows-existing-verify-first" : "default",
|
|
1335
1359
|
);
|
|
1336
|
-
if (prepared.kind === "error") throw
|
|
1360
|
+
if (prepared.kind === "error") throw managedScopeStartupError("prepare", prepared);
|
|
1337
1361
|
return prepared.scope.directoryPath;
|
|
1338
1362
|
}
|
|
1339
1363
|
|
|
@@ -1468,12 +1492,12 @@ function managedDestination(cwd: string, storage: SessionStorage, agentDir?: str
|
|
|
1468
1492
|
agentDir: agentDir ?? path.resolve(sessionsRoot, ".."),
|
|
1469
1493
|
sessionsRoot,
|
|
1470
1494
|
});
|
|
1471
|
-
if (resolved.kind === "error") throw
|
|
1495
|
+
if (resolved.kind === "error") throw managedScopeStartupError("resolve", resolved);
|
|
1472
1496
|
const prepared = prepareManagedSessionScopeForWriteSync(
|
|
1473
1497
|
resolved.scope,
|
|
1474
1498
|
process.platform === "win32" ? "windows-existing-verify-first" : "default",
|
|
1475
1499
|
);
|
|
1476
|
-
if (prepared.kind === "error") throw
|
|
1500
|
+
if (prepared.kind === "error") throw managedScopeStartupError("prepare", prepared);
|
|
1477
1501
|
return freezeManagedDestination(prepared.scope);
|
|
1478
1502
|
}
|
|
1479
1503
|
|
|
@@ -2094,41 +2118,90 @@ export async function recoverOrphanedBackups(sessionDir: string, storage: Sessio
|
|
|
2094
2118
|
}
|
|
2095
2119
|
|
|
2096
2120
|
/**
|
|
2097
|
-
*
|
|
2098
|
-
*
|
|
2099
|
-
*
|
|
2121
|
+
* Returns session metadata sorted by mtime (newest first).
|
|
2122
|
+
*
|
|
2123
|
+
* Directory entries are cheap to stat, but opening every transcript before applying a
|
|
2124
|
+
* small caller limit makes welcome-screen startup scale with the entire session history.
|
|
2125
|
+
* Rank paths first, then read bounded batches until the requested number of valid
|
|
2126
|
+
* sessions has been found. Invalid or future-version files do not prevent older valid
|
|
2127
|
+
* sessions from filling the result.
|
|
2100
2128
|
*/
|
|
2101
|
-
async function getSortedSessions(
|
|
2129
|
+
async function getSortedSessions(
|
|
2130
|
+
sessionDir: string,
|
|
2131
|
+
storage: SessionStorage,
|
|
2132
|
+
limit?: number,
|
|
2133
|
+
): Promise<RecentSessionInfo[]> {
|
|
2102
2134
|
await recoverOrphanedBackups(sessionDir, storage);
|
|
2103
2135
|
try {
|
|
2104
|
-
|
|
2136
|
+
let candidates: Array<{ path: string; mtime: number }> | undefined;
|
|
2137
|
+
if (storage.listFilesByMtime) {
|
|
2138
|
+
try {
|
|
2139
|
+
candidates = (await storage.listFilesByMtime(sessionDir, "*.jsonl")).map(candidate => ({
|
|
2140
|
+
path: candidate.path,
|
|
2141
|
+
mtime: candidate.mtimeMs,
|
|
2142
|
+
}));
|
|
2143
|
+
} catch (error) {
|
|
2144
|
+
logger.warn("Native session mtime listing failed; using JavaScript fallback", {
|
|
2145
|
+
sessionDir,
|
|
2146
|
+
error: toError(error).message,
|
|
2147
|
+
});
|
|
2148
|
+
}
|
|
2149
|
+
} else {
|
|
2150
|
+
logger.debug("Native session mtime listing unavailable; using JavaScript fallback", { sessionDir });
|
|
2151
|
+
}
|
|
2152
|
+
candidates ??= storage.listFilesSync(sessionDir, "*.jsonl").flatMap(path => {
|
|
2153
|
+
try {
|
|
2154
|
+
return [{ path, mtime: storage.statSync(path).mtimeMs }];
|
|
2155
|
+
} catch {
|
|
2156
|
+
return [];
|
|
2157
|
+
}
|
|
2158
|
+
});
|
|
2159
|
+
candidates.sort((left, right) => {
|
|
2160
|
+
if (left.mtime !== right.mtime) return right.mtime - left.mtime;
|
|
2161
|
+
return left.path < right.path ? -1 : left.path > right.path ? 1 : 0;
|
|
2162
|
+
});
|
|
2163
|
+
|
|
2105
2164
|
const sessions: RecentSessionInfo[] = [];
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2165
|
+
const batchSize = Math.max(32, limit ?? 0);
|
|
2166
|
+
|
|
2167
|
+
for (
|
|
2168
|
+
let offset = 0;
|
|
2169
|
+
offset < candidates.length && (limit === undefined || sessions.length < limit);
|
|
2170
|
+
offset += batchSize
|
|
2171
|
+
) {
|
|
2172
|
+
const batch = candidates.slice(offset, offset + batchSize);
|
|
2173
|
+
const parsed = await Promise.all(
|
|
2174
|
+
batch.map(async candidate => {
|
|
2175
|
+
try {
|
|
2176
|
+
const buffer = Buffer.allocUnsafe(SESSION_LIST_PREFIX_BYTES);
|
|
2177
|
+
const content = await readSessionListPrefix(candidate.path, storage, buffer);
|
|
2178
|
+
const entries = parseJsonlLenient<Record<string, unknown>>(content);
|
|
2179
|
+
if (entries.length === 0) return undefined;
|
|
2180
|
+
const header = entries[0] as Record<string, unknown>;
|
|
2181
|
+
if (
|
|
2182
|
+
header.type !== "session" ||
|
|
2183
|
+
typeof header.id !== "string" ||
|
|
2184
|
+
!isSupportedSessionVersion(header.version)
|
|
2185
|
+
)
|
|
2186
|
+
return undefined;
|
|
2187
|
+
if (typeof header.version === "number" && header.version >= 4) {
|
|
2188
|
+
for (const patch of await readSessionListTrailingPatches(candidate.path, storage, buffer)) {
|
|
2189
|
+
applySessionListHeaderPatch(header as unknown as SessionListHeader, patch);
|
|
2190
|
+
}
|
|
2123
2191
|
}
|
|
2192
|
+
const firstPrompt = header.title ? undefined : extractFirstUserPrompt(entries);
|
|
2193
|
+
return new RecentSessionInfo(candidate.path, candidate.mtime, header, firstPrompt);
|
|
2194
|
+
} catch {
|
|
2195
|
+
return undefined;
|
|
2124
2196
|
}
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2197
|
+
}),
|
|
2198
|
+
);
|
|
2199
|
+
for (const session of parsed) {
|
|
2200
|
+
if (session) sessions.push(session);
|
|
2201
|
+
if (limit !== undefined && sessions.length >= limit) break;
|
|
2202
|
+
}
|
|
2203
|
+
}
|
|
2204
|
+
return sessions;
|
|
2132
2205
|
} catch {
|
|
2133
2206
|
return [];
|
|
2134
2207
|
}
|
|
@@ -2139,7 +2212,7 @@ export async function findMostRecentSession(
|
|
|
2139
2212
|
sessionDir: string,
|
|
2140
2213
|
storage: SessionStorage = new FileSessionStorage(),
|
|
2141
2214
|
): Promise<string | null> {
|
|
2142
|
-
const sessions = await getSortedSessions(sessionDir, storage);
|
|
2215
|
+
const sessions = await getSortedSessions(sessionDir, storage, 1);
|
|
2143
2216
|
return sessions[0]?.path || null;
|
|
2144
2217
|
}
|
|
2145
2218
|
|
|
@@ -2158,23 +2231,6 @@ function formatTimeAgo(date: Date): string {
|
|
|
2158
2231
|
return date.toLocaleDateString();
|
|
2159
2232
|
}
|
|
2160
2233
|
|
|
2161
|
-
async function syncPathAndParent(filePath: string): Promise<void> {
|
|
2162
|
-
const file = await fs.promises.open(filePath, "r");
|
|
2163
|
-
try {
|
|
2164
|
-
await file.sync();
|
|
2165
|
-
} finally {
|
|
2166
|
-
await file.close();
|
|
2167
|
-
}
|
|
2168
|
-
if (process.platform !== "win32") {
|
|
2169
|
-
const parent = await fs.promises.open(path.dirname(filePath), "r");
|
|
2170
|
-
try {
|
|
2171
|
-
await parent.sync();
|
|
2172
|
-
} finally {
|
|
2173
|
-
await parent.close();
|
|
2174
|
-
}
|
|
2175
|
-
}
|
|
2176
|
-
}
|
|
2177
|
-
|
|
2178
2234
|
interface SessionMoveDirectoryHandle {
|
|
2179
2235
|
sync(): Promise<void>;
|
|
2180
2236
|
close(): Promise<void>;
|
|
@@ -2194,40 +2250,6 @@ export async function syncSessionMoveDirectory(
|
|
|
2194
2250
|
}
|
|
2195
2251
|
}
|
|
2196
2252
|
|
|
2197
|
-
/** Durably flush every copied file and directory before an EXDEV source removal. */
|
|
2198
|
-
async function syncCopiedTree(treePath: string): Promise<void> {
|
|
2199
|
-
const stat = await fs.promises.lstat(treePath);
|
|
2200
|
-
if (stat.isSymbolicLink()) throw new Error("Refusing to fsync a copied symbolic link during a cross-device move");
|
|
2201
|
-
if (stat.isFile()) {
|
|
2202
|
-
const file = await fs.promises.open(treePath, "r");
|
|
2203
|
-
try {
|
|
2204
|
-
await file.sync();
|
|
2205
|
-
} finally {
|
|
2206
|
-
await file.close();
|
|
2207
|
-
}
|
|
2208
|
-
return;
|
|
2209
|
-
}
|
|
2210
|
-
if (!stat.isDirectory()) throw new Error("Copied cross-device move contains an unsupported filesystem entry");
|
|
2211
|
-
const directory = await fs.promises.opendir(treePath);
|
|
2212
|
-
try {
|
|
2213
|
-
for (;;) {
|
|
2214
|
-
const entry = await directory.read();
|
|
2215
|
-
if (entry === null) break;
|
|
2216
|
-
await syncCopiedTree(path.join(treePath, entry.name));
|
|
2217
|
-
}
|
|
2218
|
-
} finally {
|
|
2219
|
-
await directory.close();
|
|
2220
|
-
}
|
|
2221
|
-
if (process.platform !== "win32") {
|
|
2222
|
-
const handle = await fs.promises.open(treePath, "r");
|
|
2223
|
-
try {
|
|
2224
|
-
await handle.sync();
|
|
2225
|
-
} finally {
|
|
2226
|
-
await handle.close();
|
|
2227
|
-
}
|
|
2228
|
-
}
|
|
2229
|
-
}
|
|
2230
|
-
|
|
2231
2253
|
type CrossDeviceTreeIdentity = string;
|
|
2232
2254
|
|
|
2233
2255
|
/** Captures content plus topology; a later capture must match before source removal. */
|
|
@@ -2275,14 +2297,6 @@ async function captureCrossDeviceTreeIdentity(treePath: string): Promise<CrossDe
|
|
|
2275
2297
|
return `directory:${before.dev}:${before.ino}:${before.size}:${before.mtimeNs}:[${children.join(",")}]`;
|
|
2276
2298
|
}
|
|
2277
2299
|
|
|
2278
|
-
async function removeCrossDeviceSourceIfUnchanged(source: string, expected: CrossDeviceTreeIdentity): Promise<void> {
|
|
2279
|
-
if ((await captureCrossDeviceTreeIdentity(source)) !== expected)
|
|
2280
|
-
throw new Error("Cross-device move source changed before removal");
|
|
2281
|
-
const stat = await fs.promises.lstat(source);
|
|
2282
|
-
if (stat.isDirectory()) await fs.promises.rm(source, { recursive: true, force: false });
|
|
2283
|
-
else await fs.promises.unlink(source);
|
|
2284
|
-
}
|
|
2285
|
-
|
|
2286
2300
|
/**
|
|
2287
2301
|
* An EXDEV retirement must be driven by a native descriptor/handle-bound
|
|
2288
2302
|
* snapshot-copy-verify transaction. Do not fall back to pathname copy/remove:
|
|
@@ -2298,83 +2312,25 @@ class CrossDeviceMoveUnsupportedError extends Error {
|
|
|
2298
2312
|
}
|
|
2299
2313
|
}
|
|
2300
2314
|
|
|
2301
|
-
class PathMoveCompletedWithSyncFailure extends Error {
|
|
2302
|
-
constructor(readonly cause: unknown) {
|
|
2303
|
-
super("Path move completed but final source-directory sync failed.", { cause });
|
|
2304
|
-
}
|
|
2305
|
-
}
|
|
2306
|
-
|
|
2307
2315
|
async function movePathAcrossDevicesSafe(source: string, destination: string): Promise<void> {
|
|
2308
2316
|
const sourceIdentity = await captureCrossDeviceTreeIdentity(source);
|
|
2309
|
-
const
|
|
2310
|
-
if (
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
if (path.dirname(source) !== path.dirname(destination)) await syncSessionMoveDirectory(path.dirname(source));
|
|
2316
|
-
} catch (error) {
|
|
2317
|
-
throw new PathMoveCompletedWithSyncFailure(error);
|
|
2318
|
-
}
|
|
2317
|
+
const outcome = classifyNativePublishOutcome(native.renameNoReplacePath(source, destination));
|
|
2318
|
+
if (outcome.ok) {
|
|
2319
|
+
if ((await captureCrossDeviceTreeIdentity(destination)) !== sourceIdentity)
|
|
2320
|
+
throw new Error("Atomic session rename did not preserve the captured source identity");
|
|
2321
|
+
await syncSessionMoveDirectory(path.dirname(destination));
|
|
2322
|
+
if (path.dirname(source) !== path.dirname(destination)) await syncSessionMoveDirectory(path.dirname(source));
|
|
2319
2323
|
return;
|
|
2320
2324
|
}
|
|
2321
|
-
if (
|
|
2325
|
+
if (outcome.reason === "destination_exists") {
|
|
2322
2326
|
const error = new Error(`Session move destination already exists: ${destination}`) as NodeJS.ErrnoException;
|
|
2323
2327
|
error.code = "EEXIST";
|
|
2324
2328
|
throw error;
|
|
2325
2329
|
}
|
|
2326
|
-
if (
|
|
2327
|
-
throw new
|
|
2328
|
-
const
|
|
2329
|
-
|
|
2330
|
-
let destinationCreated = false;
|
|
2331
|
-
let sourceRemoved = false;
|
|
2332
|
-
try {
|
|
2333
|
-
try {
|
|
2334
|
-
await fs.promises.link(source, destination);
|
|
2335
|
-
destinationCreated = true;
|
|
2336
|
-
} catch (error) {
|
|
2337
|
-
if (hasFsCode(error, "EXDEV")) throw new CrossDeviceMoveUnsupportedError(source, destination, error);
|
|
2338
|
-
if (
|
|
2339
|
-
!hasFsCode(error, "EPERM") &&
|
|
2340
|
-
!hasFsCode(error, "EACCES") &&
|
|
2341
|
-
!hasFsCode(error, "ENOTSUP") &&
|
|
2342
|
-
!hasFsCode(error, "EOPNOTSUPP")
|
|
2343
|
-
)
|
|
2344
|
-
throw error;
|
|
2345
|
-
await fs.promises.copyFile(source, destination, fs.constants.COPYFILE_EXCL);
|
|
2346
|
-
destinationCreated = true;
|
|
2347
|
-
}
|
|
2348
|
-
await syncPathAndParent(destination);
|
|
2349
|
-
await removeCrossDeviceSourceIfUnchanged(source, sourceIdentity);
|
|
2350
|
-
sourceRemoved = true;
|
|
2351
|
-
await syncSessionMoveDirectory(path.dirname(source));
|
|
2352
|
-
return;
|
|
2353
|
-
} catch (error) {
|
|
2354
|
-
if (!sourceRemoved && destinationCreated) await fs.promises.unlink(destination).catch(() => undefined);
|
|
2355
|
-
if (sourceRemoved && !(error instanceof PathMoveCompletedWithSyncFailure))
|
|
2356
|
-
throw new PathMoveCompletedWithSyncFailure(error);
|
|
2357
|
-
throw error;
|
|
2358
|
-
}
|
|
2359
|
-
}
|
|
2360
|
-
|
|
2361
|
-
const destinationParent = await fs.promises.lstat(path.dirname(destination));
|
|
2362
|
-
if (sourceStat.dev !== destinationParent.dev) throw new CrossDeviceMoveUnsupportedError(source, destination);
|
|
2363
|
-
|
|
2364
|
-
try {
|
|
2365
|
-
await fs.promises.cp(source, destination, { recursive: true, force: false, errorOnExist: true });
|
|
2366
|
-
await syncCopiedTree(destination);
|
|
2367
|
-
await removeCrossDeviceSourceIfUnchanged(source, sourceIdentity);
|
|
2368
|
-
try {
|
|
2369
|
-
await syncSessionMoveDirectory(path.dirname(source));
|
|
2370
|
-
} catch (error) {
|
|
2371
|
-
throw new PathMoveCompletedWithSyncFailure(error);
|
|
2372
|
-
}
|
|
2373
|
-
} catch (error) {
|
|
2374
|
-
if (!(error instanceof PathMoveCompletedWithSyncFailure))
|
|
2375
|
-
await fs.promises.rm(destination, { recursive: true, force: true }).catch(() => undefined);
|
|
2376
|
-
throw error;
|
|
2377
|
-
}
|
|
2330
|
+
if (outcome.reason === "atomic_unavailable")
|
|
2331
|
+
throw new CrossDeviceMoveUnsupportedError(source, destination, new Error(formatNativePublishDiagnostic(outcome)));
|
|
2332
|
+
const message = `Atomic session rename failed: ${outcome.code ?? outcome.reason}`;
|
|
2333
|
+
throw new Error(message, { cause: new Error(formatNativePublishDiagnostic(outcome)) });
|
|
2378
2334
|
}
|
|
2379
2335
|
|
|
2380
2336
|
const MAX_PERSIST_CHARS = 500_000;
|
|
@@ -3528,8 +3484,7 @@ export async function getRecentSessions(
|
|
|
3528
3484
|
limit = DEFAULT_WELCOME_RECENT_SESSION_LIMIT,
|
|
3529
3485
|
storage: SessionStorage = new FileSessionStorage(),
|
|
3530
3486
|
): Promise<RecentSessionInfo[]> {
|
|
3531
|
-
|
|
3532
|
-
return sessions.slice(0, limit);
|
|
3487
|
+
return getSortedSessions(sessionDir, storage, limit);
|
|
3533
3488
|
}
|
|
3534
3489
|
|
|
3535
3490
|
/**
|
|
@@ -3560,6 +3515,64 @@ function getTaskToolUsage(details: unknown): Usage | undefined {
|
|
|
3560
3515
|
return usage as Usage;
|
|
3561
3516
|
}
|
|
3562
3517
|
|
|
3518
|
+
interface ValidatedUsageTotals {
|
|
3519
|
+
input: number;
|
|
3520
|
+
output: number;
|
|
3521
|
+
cacheRead: number;
|
|
3522
|
+
cacheWrite: number;
|
|
3523
|
+
premiumRequests: number;
|
|
3524
|
+
cost: number;
|
|
3525
|
+
}
|
|
3526
|
+
|
|
3527
|
+
/** Max malformed-usage entry ids sampled for a single resume-time report. */
|
|
3528
|
+
const MALFORMED_USAGE_SAMPLE_LIMIT = 8;
|
|
3529
|
+
|
|
3530
|
+
function isFiniteNonNegativeNumber(value: unknown): value is number {
|
|
3531
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
3532
|
+
}
|
|
3533
|
+
|
|
3534
|
+
/**
|
|
3535
|
+
* Validate an untrusted persisted `usage` record before it is aggregated into
|
|
3536
|
+
* session usage statistics. `parseSessionEntries` accepts any parseable JSON, so a
|
|
3537
|
+
* torn/corrupt record can carry a `usage` that is still valid JSON yet malformed:
|
|
3538
|
+
* absent (`{}` yields NaN), numeric strings (`"10"` coerces sums into strings), or
|
|
3539
|
+
* negative buckets that silently reduce totals. Returns the validated finite
|
|
3540
|
+
* non-negative totals, or null when any bucket or `cost.total` is malformed — the
|
|
3541
|
+
* caller then skips and reports that record rather than poisoning every
|
|
3542
|
+
* getUsageStatistics() consumer. Absent `premiumRequests`/`cost` default to 0
|
|
3543
|
+
* (backward-compatible with older transcripts); a present-but-invalid field is
|
|
3544
|
+
* rejected.
|
|
3545
|
+
*/
|
|
3546
|
+
function validatePersistedUsageTotals(usage: unknown): ValidatedUsageTotals | null {
|
|
3547
|
+
if (typeof usage !== "object" || usage === null) return null;
|
|
3548
|
+
const record = usage as Record<string, unknown>;
|
|
3549
|
+
const input = record.input;
|
|
3550
|
+
const output = record.output;
|
|
3551
|
+
const cacheRead = record.cacheRead;
|
|
3552
|
+
const cacheWrite = record.cacheWrite;
|
|
3553
|
+
if (
|
|
3554
|
+
!isFiniteNonNegativeNumber(input) ||
|
|
3555
|
+
!isFiniteNonNegativeNumber(output) ||
|
|
3556
|
+
!isFiniteNonNegativeNumber(cacheRead) ||
|
|
3557
|
+
!isFiniteNonNegativeNumber(cacheWrite)
|
|
3558
|
+
)
|
|
3559
|
+
return null;
|
|
3560
|
+
// Default only on truly absent (=== undefined) properties; a present-but-null/invalid
|
|
3561
|
+
// premiumRequests is malformed, not zero.
|
|
3562
|
+
const premiumRequests = record.premiumRequests === undefined ? 0 : record.premiumRequests;
|
|
3563
|
+
if (!isFiniteNonNegativeNumber(premiumRequests)) return null;
|
|
3564
|
+
const rawCost = record.cost;
|
|
3565
|
+
let cost: unknown = 0;
|
|
3566
|
+
if (rawCost !== undefined) {
|
|
3567
|
+
// A present `cost` must be a non-array record carrying a finite non-negative `total`;
|
|
3568
|
+
// null, arrays, and an absent/invalid `total` are malformed — never silently zero.
|
|
3569
|
+
if (typeof rawCost !== "object" || rawCost === null || Array.isArray(rawCost)) return null;
|
|
3570
|
+
cost = (rawCost as Record<string, unknown>).total;
|
|
3571
|
+
}
|
|
3572
|
+
if (!isFiniteNonNegativeNumber(cost)) return null;
|
|
3573
|
+
return { input, output, cacheRead, cacheWrite, premiumRequests, cost };
|
|
3574
|
+
}
|
|
3575
|
+
|
|
3563
3576
|
function extractTextFromContent(content: Message["content"]): string {
|
|
3564
3577
|
if (typeof content === "string") return content;
|
|
3565
3578
|
return content
|
|
@@ -4331,7 +4344,8 @@ export class SessionManager {
|
|
|
4331
4344
|
const candidate = listing.owned.find(candidate => path.resolve(candidate.path) === requestedPath);
|
|
4332
4345
|
if (!candidate) throw new Error("Managed session deletion requires exact logical authorization.");
|
|
4333
4346
|
const deleted = await deleteManagedSessionCandidate(resolved.scope, candidate);
|
|
4334
|
-
if (deleted.kind
|
|
4347
|
+
if (deleted.kind !== "deleted" && deleted.kind !== "already_deleted")
|
|
4348
|
+
throw new Error(`Could not delete managed session: ${deleted.message}`);
|
|
4335
4349
|
} else {
|
|
4336
4350
|
await this.storage.deleteSessionWithArtifacts(sessionPath);
|
|
4337
4351
|
}
|
|
@@ -4460,27 +4474,20 @@ export class SessionManager {
|
|
|
4460
4474
|
throw new Error("managed_fork_transcript_changed");
|
|
4461
4475
|
}
|
|
4462
4476
|
} catch (error) {
|
|
4463
|
-
|
|
4464
|
-
|
|
4465
|
-
if (this.destination.kind === "managed") {
|
|
4477
|
+
const failure = error;
|
|
4478
|
+
if (forkArtifactPublication) {
|
|
4466
4479
|
try {
|
|
4467
|
-
|
|
4468
|
-
|
|
4469
|
-
|
|
4470
|
-
|
|
4471
|
-
forkArtifactPublication.cleanupRelativePath,
|
|
4472
|
-
forkArtifactPublication.snapshot,
|
|
4473
|
-
);
|
|
4480
|
+
forkArtifactPublication.cleanupStore.removeTreeExpected(
|
|
4481
|
+
forkArtifactPublication.cleanupRelativePath,
|
|
4482
|
+
forkArtifactPublication.snapshot,
|
|
4483
|
+
);
|
|
4474
4484
|
} catch (cleanupError) {
|
|
4475
|
-
|
|
4476
|
-
|
|
4477
|
-
|
|
4478
|
-
|
|
4479
|
-
|
|
4480
|
-
|
|
4481
|
-
await this.storage.unlink(failedSessionFile);
|
|
4482
|
-
} catch {
|
|
4483
|
-
// Preserve ambiguous cleanup evidence while restoring the active manager.
|
|
4485
|
+
const cleanupMessage = toError(cleanupError).message;
|
|
4486
|
+
if (cleanupMessage !== "cleanup_pending") {
|
|
4487
|
+
throw new Error(`Failed to clean up fork artifacts: ${cleanupMessage}`, {
|
|
4488
|
+
cause: toError(failure),
|
|
4489
|
+
});
|
|
4490
|
+
}
|
|
4484
4491
|
}
|
|
4485
4492
|
}
|
|
4486
4493
|
this.#sessionId = oldSessionId;
|
|
@@ -4559,8 +4566,7 @@ export class SessionManager {
|
|
|
4559
4566
|
}
|
|
4560
4567
|
if (JSON.stringify(parentStore.captureTree(sourceName)) !== JSON.stringify(sourceSnapshot))
|
|
4561
4568
|
throw new Error("artifact_source_changed");
|
|
4562
|
-
parentStore.moveTreeNoReplace(stagingName, destinationName, stagingSnapshot);
|
|
4563
|
-
publishedSnapshot = parentStore.captureTree(destinationName);
|
|
4569
|
+
publishedSnapshot = parentStore.moveTreeNoReplace(stagingName, destinationName, stagingSnapshot);
|
|
4564
4570
|
const comparable = (tree: native.NativeDirectoryTreeSnapshot) =>
|
|
4565
4571
|
tree.entries.map(entry => ({
|
|
4566
4572
|
relativePath: entry.relativePath,
|
|
@@ -4584,12 +4590,15 @@ export class SessionManager {
|
|
|
4584
4590
|
};
|
|
4585
4591
|
} catch (error) {
|
|
4586
4592
|
try {
|
|
4587
|
-
if (
|
|
4588
|
-
|
|
4593
|
+
if (stagingSnapshot && mayCleanManagedTreeStaging(error))
|
|
4594
|
+
parentStore.removeTreeExpected(stagingName, stagingSnapshot);
|
|
4589
4595
|
} catch (cleanupError) {
|
|
4590
|
-
|
|
4591
|
-
|
|
4592
|
-
|
|
4596
|
+
const cleanupMessage = toError(cleanupError).message;
|
|
4597
|
+
if (cleanupMessage !== "cleanup_pending") {
|
|
4598
|
+
throw new Error(`Failed to clean up managed fork artifacts: ${cleanupMessage}`, {
|
|
4599
|
+
cause: toError(error),
|
|
4600
|
+
});
|
|
4601
|
+
}
|
|
4593
4602
|
}
|
|
4594
4603
|
throw error;
|
|
4595
4604
|
}
|
|
@@ -4607,7 +4616,6 @@ export class SessionManager {
|
|
|
4607
4616
|
const previousSessionDir = this.sessionDir;
|
|
4608
4617
|
const previousSessionFile = this.#sessionFile;
|
|
4609
4618
|
const previousDestination = this.destination;
|
|
4610
|
-
const previousArtifactDir = previousSessionFile?.slice(0, -6);
|
|
4611
4619
|
|
|
4612
4620
|
const nextDestination =
|
|
4613
4621
|
this.storage instanceof FileSessionStorage
|
|
@@ -4719,15 +4727,8 @@ export class SessionManager {
|
|
|
4719
4727
|
managedPublishedArtifacts,
|
|
4720
4728
|
);
|
|
4721
4729
|
}
|
|
4722
|
-
if (managedTranscript)
|
|
4730
|
+
if (managedTranscript && !managedSourceStore.readExpected(path.basename(oldSessionFile)))
|
|
4723
4731
|
await managedSourceStore.publishNoReplace(path.basename(oldSessionFile), managedTranscript.bytes);
|
|
4724
|
-
if (managedPublishedArtifacts)
|
|
4725
|
-
managedDestinationStore.removeTreeExpected(
|
|
4726
|
-
path.basename(newArtifactDir),
|
|
4727
|
-
managedPublishedArtifacts,
|
|
4728
|
-
);
|
|
4729
|
-
if (managedPublishedTranscript)
|
|
4730
|
-
managedDestinationStore.removeExpected(path.basename(newSessionFile), managedPublishedTranscript);
|
|
4731
4732
|
};
|
|
4732
4733
|
managedTranscript = managedSourceStore.readExpected(path.basename(oldSessionFile));
|
|
4733
4734
|
hadSessionFile = managedTranscript !== null;
|
|
@@ -4764,10 +4765,20 @@ export class SessionManager {
|
|
|
4764
4765
|
if (!retainedTreeSnapshotEquals(terminalArtifacts, managedPublishedArtifacts))
|
|
4765
4766
|
throw new Error("managed_move_destination_artifacts_changed");
|
|
4766
4767
|
}
|
|
4767
|
-
if (managedArtifacts)
|
|
4768
|
-
|
|
4769
|
-
|
|
4770
|
-
|
|
4768
|
+
if (managedArtifacts) {
|
|
4769
|
+
try {
|
|
4770
|
+
managedSourceStore.removeTreeExpected(path.basename(oldArtifactDir), managedArtifacts);
|
|
4771
|
+
} catch (cleanupError) {
|
|
4772
|
+
if (toError(cleanupError).message !== "cleanup_pending") throw cleanupError;
|
|
4773
|
+
}
|
|
4774
|
+
}
|
|
4775
|
+
if (managedTranscript) {
|
|
4776
|
+
try {
|
|
4777
|
+
managedSourceStore.removeExpected(path.basename(oldSessionFile), managedTranscript);
|
|
4778
|
+
} catch (cleanupError) {
|
|
4779
|
+
if (toError(cleanupError).message !== "cleanup_pending") throw cleanupError;
|
|
4780
|
+
}
|
|
4781
|
+
}
|
|
4771
4782
|
} else {
|
|
4772
4783
|
if (hadSessionFile && oldSessionFile !== newSessionFile) {
|
|
4773
4784
|
await movePathAcrossDevicesSafe(oldSessionFile, newSessionFile);
|
|
@@ -4823,13 +4834,6 @@ export class SessionManager {
|
|
|
4823
4834
|
!managedSourceStore.readExpected(path.basename(oldSessionFile))
|
|
4824
4835
|
)
|
|
4825
4836
|
await managedSourceStore.publishNoReplace(path.basename(oldSessionFile), managedTranscript.bytes);
|
|
4826
|
-
if (managedPublishedArtifacts)
|
|
4827
|
-
managedDestinationStore.removeTreeExpected(
|
|
4828
|
-
path.basename(newArtifactDir),
|
|
4829
|
-
managedPublishedArtifacts,
|
|
4830
|
-
);
|
|
4831
|
-
if (managedPublishedTranscript)
|
|
4832
|
-
managedDestinationStore.removeExpected(path.basename(newSessionFile), managedPublishedTranscript);
|
|
4833
4837
|
} catch (rollbackErr) {
|
|
4834
4838
|
restoreResidentStateAndThrow(
|
|
4835
4839
|
new Error(`Failed to rollback managed move: ${toError(rollbackErr).message}`, {
|
|
@@ -4850,7 +4854,8 @@ export class SessionManager {
|
|
|
4850
4854
|
this.#bumpAllRevisions();
|
|
4851
4855
|
}
|
|
4852
4856
|
|
|
4853
|
-
// Update cwd and sessionDir after
|
|
4857
|
+
// Update cwd and sessionDir after physical publication succeeds. Metadata failures restore the source
|
|
4858
|
+
// authority but deliberately retain any destination publication evidence rather than deleting it.
|
|
4854
4859
|
this.cwd = resolvedCwd;
|
|
4855
4860
|
this.sessionDir = newSessionDir;
|
|
4856
4861
|
this.destination = nextDestination;
|
|
@@ -4871,60 +4876,37 @@ export class SessionManager {
|
|
|
4871
4876
|
await this.#appendHeaderPatch({ cwd: resolvedCwd });
|
|
4872
4877
|
}
|
|
4873
4878
|
} catch (error) {
|
|
4874
|
-
|
|
4875
|
-
|
|
4876
|
-
|
|
4877
|
-
|
|
4878
|
-
|
|
4879
|
-
|
|
4880
|
-
this.#persistErrorReported = false;
|
|
4881
|
-
if (rollbackManagedMove) {
|
|
4879
|
+
await this.#closePersistWriter().catch(() => {});
|
|
4880
|
+
this.#persistChain = Promise.resolve();
|
|
4881
|
+
this.#persistError = undefined;
|
|
4882
|
+
this.#persistErrorReported = false;
|
|
4883
|
+
if (rollbackManagedMove) {
|
|
4884
|
+
try {
|
|
4882
4885
|
await rollbackManagedMove();
|
|
4883
|
-
|
|
4884
|
-
|
|
4885
|
-
|
|
4886
|
-
|
|
4887
|
-
|
|
4888
|
-
|
|
4889
|
-
? { directory: path.resolve(previousSessionDir), store: managedSourceStore }
|
|
4890
|
-
: null;
|
|
4891
|
-
const header = this.#fileEntries.find(entry => entry.type === "session") as SessionHeader | undefined;
|
|
4892
|
-
if (header) applyHeaderPatch(header, { cwd: previousCwd });
|
|
4893
|
-
this.#headerExportRevision++;
|
|
4894
|
-
throw error;
|
|
4895
|
-
}
|
|
4896
|
-
if (
|
|
4897
|
-
this.storage instanceof FileSessionStorage &&
|
|
4898
|
-
failedArtifactDir &&
|
|
4899
|
-
previousArtifactDir &&
|
|
4900
|
-
failedArtifactDir !== previousArtifactDir &&
|
|
4901
|
-
fs.existsSync(failedArtifactDir)
|
|
4902
|
-
) {
|
|
4903
|
-
await movePathAcrossDevicesSafe(failedArtifactDir, previousArtifactDir);
|
|
4904
|
-
}
|
|
4905
|
-
if (
|
|
4906
|
-
this.storage instanceof FileSessionStorage &&
|
|
4907
|
-
failedSessionFile &&
|
|
4908
|
-
previousSessionFile &&
|
|
4909
|
-
failedSessionFile !== previousSessionFile &&
|
|
4910
|
-
fs.existsSync(failedSessionFile)
|
|
4911
|
-
) {
|
|
4912
|
-
await movePathAcrossDevicesSafe(failedSessionFile, previousSessionFile);
|
|
4886
|
+
} catch (rollbackError) {
|
|
4887
|
+
if (toError(rollbackError).message !== "cleanup_pending") {
|
|
4888
|
+
throw new Error(`Failed to rollback managed move: ${toError(rollbackError).message}`, {
|
|
4889
|
+
cause: toError(error),
|
|
4890
|
+
});
|
|
4891
|
+
}
|
|
4913
4892
|
}
|
|
4914
4893
|
this.#sessionFile = previousSessionFile;
|
|
4915
4894
|
this.cwd = previousCwd;
|
|
4916
4895
|
this.sessionDir = previousSessionDir;
|
|
4917
4896
|
this.destination = previousDestination;
|
|
4918
|
-
this.#managedTranscriptStoreCache =
|
|
4897
|
+
this.#managedTranscriptStoreCache =
|
|
4898
|
+
managedMove && managedSourceStore
|
|
4899
|
+
? { directory: path.resolve(previousSessionDir), store: managedSourceStore }
|
|
4900
|
+
: null;
|
|
4919
4901
|
const header = this.#fileEntries.find(entry => entry.type === "session") as SessionHeader | undefined;
|
|
4920
4902
|
if (header) applyHeaderPatch(header, { cwd: previousCwd });
|
|
4921
4903
|
this.#headerExportRevision++;
|
|
4922
|
-
|
|
4923
|
-
} catch (rollbackError) {
|
|
4924
|
-
throw new Error(`Failed to rollback session move: ${toError(rollbackError).message}`, {
|
|
4925
|
-
cause: toError(error),
|
|
4926
|
-
});
|
|
4904
|
+
throw error;
|
|
4927
4905
|
}
|
|
4906
|
+
// The destination has already been published. Retain it rather than moving it
|
|
4907
|
+
// back over the source after a metadata failure.
|
|
4908
|
+
this.#managedTranscriptStoreCache = null;
|
|
4909
|
+
this.#headerExportRevision++;
|
|
4928
4910
|
throw error;
|
|
4929
4911
|
}
|
|
4930
4912
|
|
|
@@ -4984,6 +4966,8 @@ export class SessionManager {
|
|
|
4984
4966
|
this.#labelsById.clear();
|
|
4985
4967
|
this.#leafId = null;
|
|
4986
4968
|
this.#usageStatistics = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, premiumRequests: 0, cost: 0 };
|
|
4969
|
+
let malformedUsageRecords = 0;
|
|
4970
|
+
const malformedUsageSample: string[] = [];
|
|
4987
4971
|
for (const entry of this.#fileEntries) {
|
|
4988
4972
|
if (entry.type === "session") continue;
|
|
4989
4973
|
this.#byId.set(entry.id, entry);
|
|
@@ -4995,28 +4979,80 @@ export class SessionManager {
|
|
|
4995
4979
|
this.#labelsById.delete(entry.targetId);
|
|
4996
4980
|
}
|
|
4997
4981
|
}
|
|
4998
|
-
|
|
4999
|
-
|
|
5000
|
-
|
|
5001
|
-
|
|
5002
|
-
|
|
5003
|
-
|
|
5004
|
-
|
|
5005
|
-
|
|
4982
|
+
// Aggregate this entry's persisted usage through the shared validated,
|
|
4983
|
+
// overflow-guarded path (see #accumulateEntryUsage). parseSessionEntries accepts
|
|
4984
|
+
// any parseable JSON, so a torn/corrupt record can carry a still-valid-JSON but
|
|
4985
|
+
// malformed `usage` (absent, {}, numeric strings, negatives, a non-record cost, or
|
|
4986
|
+
// a cumulative overflow); such a record is skipped and reported below instead of
|
|
4987
|
+
// poisoning every getUsageStatistics() consumer.
|
|
4988
|
+
if (this.#accumulateEntryUsage(entry)) {
|
|
4989
|
+
malformedUsageRecords++;
|
|
4990
|
+
if (malformedUsageSample.length < MALFORMED_USAGE_SAMPLE_LIMIT) malformedUsageSample.push(entry.id);
|
|
5006
4991
|
}
|
|
4992
|
+
}
|
|
4993
|
+
if (malformedUsageRecords > 0) {
|
|
4994
|
+
logger.warn("Skipped malformed or overflowing persisted usage records during resume aggregation", {
|
|
4995
|
+
sessionFile: this.#sessionFile,
|
|
4996
|
+
count: malformedUsageRecords,
|
|
4997
|
+
sampleEntryIds: malformedUsageSample,
|
|
4998
|
+
});
|
|
4999
|
+
}
|
|
5000
|
+
}
|
|
5007
5001
|
|
|
5008
|
-
|
|
5009
|
-
|
|
5010
|
-
|
|
5011
|
-
|
|
5012
|
-
|
|
5013
|
-
|
|
5014
|
-
|
|
5015
|
-
|
|
5016
|
-
|
|
5017
|
-
|
|
5018
|
-
|
|
5002
|
+
/**
|
|
5003
|
+
* Commit a validated record's totals only if every cumulative sum stays finite.
|
|
5004
|
+
* Individually-finite buckets can still overflow to Infinity in aggregate (e.g. two
|
|
5005
|
+
* Number.MAX_VALUE records), which would poison getUsageStatistics(); such a record is
|
|
5006
|
+
* rejected atomically (no partial commit). Returns true when committed, false when
|
|
5007
|
+
* rejected for cumulative overflow.
|
|
5008
|
+
*/
|
|
5009
|
+
#addValidatedUsage(totals: ValidatedUsageTotals): boolean {
|
|
5010
|
+
const next = {
|
|
5011
|
+
input: this.#usageStatistics.input + totals.input,
|
|
5012
|
+
output: this.#usageStatistics.output + totals.output,
|
|
5013
|
+
cacheRead: this.#usageStatistics.cacheRead + totals.cacheRead,
|
|
5014
|
+
cacheWrite: this.#usageStatistics.cacheWrite + totals.cacheWrite,
|
|
5015
|
+
premiumRequests: this.#usageStatistics.premiumRequests + totals.premiumRequests,
|
|
5016
|
+
cost: this.#usageStatistics.cost + totals.cost,
|
|
5017
|
+
};
|
|
5018
|
+
if (
|
|
5019
|
+
!Number.isFinite(next.input) ||
|
|
5020
|
+
!Number.isFinite(next.output) ||
|
|
5021
|
+
!Number.isFinite(next.cacheRead) ||
|
|
5022
|
+
!Number.isFinite(next.cacheWrite) ||
|
|
5023
|
+
!Number.isFinite(next.premiumRequests) ||
|
|
5024
|
+
!Number.isFinite(next.cost)
|
|
5025
|
+
)
|
|
5026
|
+
return false;
|
|
5027
|
+
this.#usageStatistics.input = next.input;
|
|
5028
|
+
this.#usageStatistics.output = next.output;
|
|
5029
|
+
this.#usageStatistics.cacheRead = next.cacheRead;
|
|
5030
|
+
this.#usageStatistics.cacheWrite = next.cacheWrite;
|
|
5031
|
+
this.#usageStatistics.premiumRequests = next.premiumRequests;
|
|
5032
|
+
this.#usageStatistics.cost = next.cost;
|
|
5033
|
+
return true;
|
|
5034
|
+
}
|
|
5035
|
+
|
|
5036
|
+
/**
|
|
5037
|
+
* Validate and accumulate one entry's persisted usage through the single shared,
|
|
5038
|
+
* overflow-guarded aggregation path used by both #buildIndex (resume) and #appendEntry
|
|
5039
|
+
* (runtime), covering the assistant and `task` tool-result shapes. Returns true when a
|
|
5040
|
+
* present usage record was skipped as malformed or overflowing (so the caller can report
|
|
5041
|
+
* it); false when the entry has no usage or was aggregated cleanly.
|
|
5042
|
+
*/
|
|
5043
|
+
#accumulateEntryUsage(entry: SessionEntry): boolean {
|
|
5044
|
+
if (entry.type !== "message") return false;
|
|
5045
|
+
if (entry.message.role === "assistant") {
|
|
5046
|
+
const totals = validatePersistedUsageTotals(entry.message.usage);
|
|
5047
|
+
return !(totals && this.#addValidatedUsage(totals));
|
|
5048
|
+
}
|
|
5049
|
+
if (entry.message.role === "toolResult" && entry.message.toolName === "task") {
|
|
5050
|
+
const rawTaskUsage = getTaskToolUsage(entry.message.details);
|
|
5051
|
+
if (rawTaskUsage === undefined) return false;
|
|
5052
|
+
const totals = validatePersistedUsageTotals(rawTaskUsage);
|
|
5053
|
+
return !(totals && this.#addValidatedUsage(totals));
|
|
5019
5054
|
}
|
|
5055
|
+
return false;
|
|
5020
5056
|
}
|
|
5021
5057
|
|
|
5022
5058
|
#recordPersistError(err: unknown): Error {
|
|
@@ -6007,26 +6043,14 @@ export class SessionManager {
|
|
|
6007
6043
|
this.#leafRevision++;
|
|
6008
6044
|
if (entry.type === "label") this.#labelRevision++;
|
|
6009
6045
|
this._persist(residentEntry);
|
|
6010
|
-
|
|
6011
|
-
|
|
6012
|
-
|
|
6013
|
-
|
|
6014
|
-
|
|
6015
|
-
|
|
6016
|
-
|
|
6017
|
-
|
|
6018
|
-
}
|
|
6019
|
-
|
|
6020
|
-
if (entry.type === "message" && entry.message.role === "toolResult" && entry.message.toolName === "task") {
|
|
6021
|
-
const usage = getTaskToolUsage(entry.message.details);
|
|
6022
|
-
if (usage) {
|
|
6023
|
-
this.#usageStatistics.input += usage.input;
|
|
6024
|
-
this.#usageStatistics.output += usage.output;
|
|
6025
|
-
this.#usageStatistics.cacheRead += usage.cacheRead;
|
|
6026
|
-
this.#usageStatistics.cacheWrite += usage.cacheWrite;
|
|
6027
|
-
this.#usageStatistics.premiumRequests += usage.premiumRequests ?? 0;
|
|
6028
|
-
this.#usageStatistics.cost += usage.cost.total;
|
|
6029
|
-
}
|
|
6046
|
+
// Same validated, overflow-guarded aggregation as the resume path (#buildIndex): a
|
|
6047
|
+
// malformed task-result or assistant usage shape reaching the append path must not
|
|
6048
|
+
// crash or poison getUsageStatistics() either.
|
|
6049
|
+
if (this.#accumulateEntryUsage(entry)) {
|
|
6050
|
+
logger.warn("Skipped malformed or overflowing usage on appended entry", {
|
|
6051
|
+
sessionFile: this.#sessionFile,
|
|
6052
|
+
entryId: entry.id,
|
|
6053
|
+
});
|
|
6030
6054
|
}
|
|
6031
6055
|
}
|
|
6032
6056
|
|
|
@@ -7254,9 +7278,11 @@ export class SessionManager {
|
|
|
7254
7278
|
const storage = new FileSessionStorage();
|
|
7255
7279
|
const managedDestinationStore = managedStoreFromContext(destination.securityContext, destination.directory);
|
|
7256
7280
|
const assertManagedDestinationBound = (): void => {
|
|
7257
|
-
assertManagedDirectoryRoot(destination.securityContext.rootAuthority);
|
|
7258
7281
|
managedDestinationStore.assertBound();
|
|
7282
|
+
if (!destination.securityContext.retainedAuthority)
|
|
7283
|
+
assertManagedDirectoryRoot(destination.securityContext.rootAuthority);
|
|
7259
7284
|
};
|
|
7285
|
+
|
|
7260
7286
|
assertManagedDestinationBound();
|
|
7261
7287
|
const inspected = inspectResumeSessionFile(filePath, storage);
|
|
7262
7288
|
if ("kind" in inspected) {
|
|
@@ -7312,18 +7338,27 @@ export class SessionManager {
|
|
|
7312
7338
|
}
|
|
7313
7339
|
: {}),
|
|
7314
7340
|
};
|
|
7315
|
-
|
|
7316
|
-
|
|
7317
|
-
|
|
7318
|
-
|
|
7319
|
-
|
|
7320
|
-
|
|
7321
|
-
|
|
7322
|
-
|
|
7341
|
+
let opened: ManagedOpenCandidateResult;
|
|
7342
|
+
try {
|
|
7343
|
+
opened = await openManagedCandidateForWrite(
|
|
7344
|
+
resolved.scope,
|
|
7345
|
+
candidate,
|
|
7346
|
+
expectedIdentity ?? inspected.identity,
|
|
7347
|
+
migrationPolicy,
|
|
7348
|
+
authority,
|
|
7349
|
+
);
|
|
7350
|
+
} catch (error) {
|
|
7351
|
+
if (error instanceof Error && error.message.startsWith("Managed root authority changed"))
|
|
7352
|
+
managedDestinationStore.assertBound();
|
|
7353
|
+
throw error;
|
|
7354
|
+
}
|
|
7323
7355
|
if (opened.kind === "error") {
|
|
7356
|
+
managedDestinationStore.assertBound();
|
|
7324
7357
|
if (opened.code === "legacy_migration_disabled") throw new SessionMigrationPolicyError();
|
|
7358
|
+
if (opened.code === "artifact_capacity_exceeded") throw new SessionArtifactCapacityError(opened.message);
|
|
7325
7359
|
throw new Error(`Could not open managed session: ${opened.message}`);
|
|
7326
7360
|
}
|
|
7361
|
+
assertManagedDestinationBound();
|
|
7327
7362
|
return opened.path;
|
|
7328
7363
|
}
|
|
7329
7364
|
|
|
@@ -7536,7 +7571,8 @@ export class SessionManager {
|
|
|
7536
7571
|
const candidate = listing.owned.find(item => path.resolve(item.path) === path.resolve(sessionPath));
|
|
7537
7572
|
if (!candidate) throw new Error("Session is not an authorized managed candidate.");
|
|
7538
7573
|
const deleted = await deleteManagedSessionCandidate(resolved.scope, candidate);
|
|
7539
|
-
if (deleted.kind
|
|
7574
|
+
if (deleted.kind !== "deleted" && deleted.kind !== "already_deleted")
|
|
7575
|
+
throw new Error(`Could not delete managed session: ${deleted.message}`);
|
|
7540
7576
|
}
|
|
7541
7577
|
|
|
7542
7578
|
/** Capture exact source content for a strict fork without granting write ownership. */
|
|
@@ -7596,16 +7632,22 @@ export class SessionManager {
|
|
|
7596
7632
|
}
|
|
7597
7633
|
|
|
7598
7634
|
const dir = destination.directory;
|
|
7599
|
-
const
|
|
7635
|
+
const privateStagingDir =
|
|
7600
7636
|
destination.kind !== "managed" &&
|
|
7601
7637
|
snapshot.storage instanceof FileSessionStorage &&
|
|
7602
|
-
!snapshot.storage.existsSync(dir)
|
|
7638
|
+
!snapshot.storage.existsSync(dir)
|
|
7639
|
+
? fs.mkdtempSync(path.join(path.dirname(dir), `.${path.basename(dir)}.fork-staging-`))
|
|
7640
|
+
: undefined;
|
|
7641
|
+
const forkDestination = privateStagingDir ? explicitDestination(privateStagingDir) : destination;
|
|
7603
7642
|
let managedForkStore: ManagedSessionDescendantStore | undefined;
|
|
7604
7643
|
let managedForkTranscript: ManagedFileSnapshot | null = null;
|
|
7605
7644
|
let manager: SessionManager | undefined;
|
|
7606
7645
|
let authorityFailure: StrictSessionOpenFailure | undefined;
|
|
7646
|
+
let privateStagingSnapshot: native.NativeDirectoryTreeSnapshot | undefined;
|
|
7647
|
+
let privateStagingPublished = false;
|
|
7648
|
+
|
|
7607
7649
|
try {
|
|
7608
|
-
manager = new SessionManager(cwd, dir, true, snapshot.storage,
|
|
7650
|
+
manager = new SessionManager(cwd, privateStagingDir ?? dir, true, snapshot.storage, forkDestination);
|
|
7609
7651
|
await resolveBlobRefsInEntries(forkEntries, manager.#blobStore);
|
|
7610
7652
|
manager.#fileEntries = forkEntries;
|
|
7611
7653
|
const sourceHeader = manager.#fileEntries.find(e => e.type === "session") as SessionHeader | undefined;
|
|
@@ -7635,6 +7677,13 @@ export class SessionManager {
|
|
|
7635
7677
|
throw new Error("Captured fork source authority changed before destination write.");
|
|
7636
7678
|
}
|
|
7637
7679
|
await manager.#rewriteFile();
|
|
7680
|
+
if (privateStagingDir) {
|
|
7681
|
+
const capturedStaging = native.snapshotDirectoryTree(privateStagingDir);
|
|
7682
|
+
if (!capturedStaging.ok || !capturedStaging.snapshot)
|
|
7683
|
+
throw new Error(capturedStaging.code ?? "fork_staging_snapshot_failed");
|
|
7684
|
+
privateStagingSnapshot = capturedStaging.snapshot;
|
|
7685
|
+
}
|
|
7686
|
+
|
|
7638
7687
|
if (destination.kind === "managed" && manager.#sessionFile) {
|
|
7639
7688
|
managedForkStore = manager.#managedTranscriptStore();
|
|
7640
7689
|
managedForkTranscript = managedForkStore.readExpected(path.basename(manager.#sessionFile));
|
|
@@ -7654,66 +7703,57 @@ export class SessionManager {
|
|
|
7654
7703
|
if (!managedFileSnapshotEquals(terminalTranscript, managedForkTranscript))
|
|
7655
7704
|
throw new Error("managed_fork_transcript_changed");
|
|
7656
7705
|
}
|
|
7706
|
+
if (privateStagingDir && manager.#sessionFile) {
|
|
7707
|
+
const materializedEntries = materializeResidentEntriesForReadSync(
|
|
7708
|
+
manager.#fileEntries,
|
|
7709
|
+
manager.#residentBlobStores(),
|
|
7710
|
+
);
|
|
7711
|
+
manager.#fileEntries = materializedEntries;
|
|
7712
|
+
manager.#disposeResidentTextBlobStore();
|
|
7713
|
+
const capturedStaging = native.snapshotDirectoryTree(privateStagingDir);
|
|
7714
|
+
if (!capturedStaging.ok || !capturedStaging.snapshot)
|
|
7715
|
+
throw new Error(capturedStaging.code ?? "fork_staging_snapshot_failed");
|
|
7716
|
+
privateStagingSnapshot = capturedStaging.snapshot;
|
|
7717
|
+
fsyncManagedArtifactTree(privateStagingDir);
|
|
7718
|
+
const outcome = classifyNativePublishOutcome(native.renameNoReplacePath(privateStagingDir, dir));
|
|
7719
|
+
if (!outcome.ok) throw new Error(outcome.code ?? "fork_destination_publish_failed");
|
|
7720
|
+
privateStagingPublished = true;
|
|
7721
|
+
const terminal = native.snapshotDirectoryTree(dir);
|
|
7722
|
+
if (
|
|
7723
|
+
!terminal.ok ||
|
|
7724
|
+
!terminal.snapshot ||
|
|
7725
|
+
!retainedTreeSnapshotEqualsAfterRename(terminal.snapshot, privateStagingSnapshot)
|
|
7726
|
+
)
|
|
7727
|
+
throw new Error("fork_destination_terminal_identity_changed");
|
|
7728
|
+
await syncSessionMoveDirectory(path.dirname(dir));
|
|
7729
|
+
const durableTerminal = native.snapshotDirectoryTree(dir);
|
|
7730
|
+
if (
|
|
7731
|
+
!durableTerminal.ok ||
|
|
7732
|
+
!durableTerminal.snapshot ||
|
|
7733
|
+
!retainedTreeSnapshotEqualsAfterRename(durableTerminal.snapshot, privateStagingSnapshot)
|
|
7734
|
+
)
|
|
7735
|
+
throw new Error("fork_destination_durability_identity_changed");
|
|
7736
|
+
manager.sessionDir = dir;
|
|
7737
|
+
manager.destination = destination;
|
|
7738
|
+
manager.#sessionFile = path.join(dir, path.basename(manager.#sessionFile));
|
|
7739
|
+
manager.#resetResidentTextBlobStore();
|
|
7740
|
+
manager.#reexternalizeFileEntriesForResidentStore();
|
|
7741
|
+
manager.#bumpAllRevisions();
|
|
7742
|
+
}
|
|
7657
7743
|
if (manager.#sessionFile) writeTerminalBreadcrumb(manager.cwd, manager.#sessionFile);
|
|
7658
7744
|
return { kind: "forked", manager };
|
|
7659
7745
|
} catch (error) {
|
|
7660
7746
|
if (manager) {
|
|
7661
|
-
const sessionFile = manager.#sessionFile;
|
|
7662
7747
|
const cleanupErrors: Error[] = [];
|
|
7663
7748
|
try {
|
|
7664
7749
|
await manager.close();
|
|
7665
7750
|
} catch (cleanupError) {
|
|
7666
|
-
cleanupErrors.push(toError(cleanupError));
|
|
7751
|
+
if (toError(cleanupError).message !== "cleanup_pending") cleanupErrors.push(toError(cleanupError));
|
|
7667
7752
|
}
|
|
7668
|
-
if (
|
|
7669
|
-
|
|
7670
|
-
|
|
7671
|
-
|
|
7672
|
-
managedForkStore.removeExpected(path.basename(sessionFile), managedForkTranscript);
|
|
7673
|
-
} catch (cleanupError) {
|
|
7674
|
-
cleanupErrors.push(toError(cleanupError));
|
|
7675
|
-
}
|
|
7676
|
-
} else {
|
|
7677
|
-
try {
|
|
7678
|
-
await manager.storage.unlink(sessionFile);
|
|
7679
|
-
} catch (cleanupError) {
|
|
7680
|
-
if (!isEnoent(cleanupError)) cleanupErrors.push(toError(cleanupError));
|
|
7681
|
-
}
|
|
7682
|
-
if (manager.storage instanceof FileSessionStorage) {
|
|
7683
|
-
try {
|
|
7684
|
-
await fs.promises.rm(sessionFile.slice(0, -6), { recursive: true, force: true });
|
|
7685
|
-
} catch (cleanupError) {
|
|
7686
|
-
cleanupErrors.push(toError(cleanupError));
|
|
7687
|
-
}
|
|
7688
|
-
try {
|
|
7689
|
-
const sessionFileName = path.basename(sessionFile);
|
|
7690
|
-
const transientPrefix = `.${sessionFileName}.`;
|
|
7691
|
-
const backupPrefix = `${sessionFileName}.`;
|
|
7692
|
-
for (const name of await fs.promises.readdir(path.dirname(sessionFile))) {
|
|
7693
|
-
if (
|
|
7694
|
-
(name.startsWith(transientPrefix) && name.endsWith(".tmp")) ||
|
|
7695
|
-
(name.startsWith(backupPrefix) && name.endsWith(".bak"))
|
|
7696
|
-
) {
|
|
7697
|
-
await fs.promises.rm(path.join(path.dirname(sessionFile), name), {
|
|
7698
|
-
recursive: true,
|
|
7699
|
-
force: true,
|
|
7700
|
-
});
|
|
7701
|
-
}
|
|
7702
|
-
}
|
|
7703
|
-
} catch (cleanupError) {
|
|
7704
|
-
if (!isEnoent(cleanupError)) cleanupErrors.push(toError(cleanupError));
|
|
7705
|
-
}
|
|
7706
|
-
}
|
|
7707
|
-
}
|
|
7708
|
-
}
|
|
7709
|
-
if (removeSessionDirOnFailure) {
|
|
7710
|
-
try {
|
|
7711
|
-
await fs.promises.rmdir(dir);
|
|
7712
|
-
} catch (cleanupError) {
|
|
7713
|
-
const code = (cleanupError as NodeJS.ErrnoException).code;
|
|
7714
|
-
if (code !== "ENOENT" && code !== "ENOTEMPTY" && code !== "EEXIST")
|
|
7715
|
-
cleanupErrors.push(toError(cleanupError));
|
|
7716
|
-
}
|
|
7753
|
+
if (!privateStagingPublished && privateStagingDir && privateStagingSnapshot) {
|
|
7754
|
+
const removed = native.exactRemoveDirectoryTree(privateStagingDir, privateStagingSnapshot);
|
|
7755
|
+
if (!removed.ok && removed.code !== "not_found" && removed.code !== "cleanup_pending")
|
|
7756
|
+
cleanupErrors.push(new Error(removed.code ?? "fork_staging_cleanup_failed"));
|
|
7717
7757
|
}
|
|
7718
7758
|
if (cleanupErrors.length > 0) {
|
|
7719
7759
|
throw new Error(`Failed to clean up fork destination: ${cleanupErrors[0]!.message}`, {
|
|
@@ -7826,6 +7866,8 @@ export class SessionManager {
|
|
|
7826
7866
|
} catch (error) {
|
|
7827
7867
|
if (error instanceof SessionMigrationPolicyError)
|
|
7828
7868
|
return { kind: "error", reason: "legacy_migration_disabled" };
|
|
7869
|
+
if (error instanceof SessionArtifactCapacityError)
|
|
7870
|
+
return { kind: "error", reason: "artifact_capacity_exceeded", message: error.message };
|
|
7829
7871
|
if (
|
|
7830
7872
|
error instanceof Error &&
|
|
7831
7873
|
(error.message.includes("source_changed") || error.message.includes("changed before migration"))
|
|
@@ -7884,6 +7926,10 @@ export class SessionManager {
|
|
|
7884
7926
|
);
|
|
7885
7927
|
if (opened.kind === "error") {
|
|
7886
7928
|
if (opened.reason === "legacy_migration_disabled") throw new SessionMigrationPolicyError();
|
|
7929
|
+
if (opened.reason === "artifact_capacity_exceeded")
|
|
7930
|
+
throw new SessionArtifactCapacityError(
|
|
7931
|
+
opened.message ?? "Session artifacts exceed the migration capacity.",
|
|
7932
|
+
);
|
|
7887
7933
|
return undefined;
|
|
7888
7934
|
}
|
|
7889
7935
|
return opened.manager;
|