@gajae-code/coding-agent 0.18.5 → 0.18.7
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 +140 -0
- package/dist/types/capability/types.d.ts +4 -0
- package/dist/types/cli-commands.d.ts +10 -0
- package/dist/types/cli-ordinary.d.ts +13 -0
- package/dist/types/cli.d.ts +12 -3
- package/dist/types/commands/contribution-prep.d.ts +0 -3
- package/dist/types/commands/sdk.d.ts +3 -0
- package/dist/types/commands/update.d.ts +1 -0
- package/dist/types/config/model-profile-activation.d.ts +19 -0
- package/dist/types/config/settings.d.ts +4 -0
- package/dist/types/coordinator-mcp/session-reaper.d.ts +5 -0
- package/dist/types/discovery/helpers.d.ts +2 -2
- package/dist/types/edit/modes/replace.d.ts +3 -3
- package/dist/types/edit/streaming.d.ts +2 -2
- package/dist/types/exec/bash-shell-supervisor.d.ts +12 -0
- package/dist/types/runtime-mcp/elicitation.d.ts +12 -0
- package/dist/types/sdk/broker/discovery.d.ts +8 -0
- package/dist/types/sdk/broker/ensure.d.ts +1 -0
- package/dist/types/sdk/broker/lifecycle-ledger.d.ts +4 -0
- package/dist/types/sdk/broker/lifecycle.d.ts +9 -0
- package/dist/types/sdk/broker/runtime.d.ts +1 -1
- package/dist/types/sdk/broker/task-artifact-owner-validation.d.ts +55 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
- package/dist/types/sdk/bus/telegram-daemon.d.ts +4 -2
- package/dist/types/sdk/cli/diagnostics-cli.d.ts +18 -0
- package/dist/types/sdk/diagnostics/observe-broker.d.ts +185 -0
- package/dist/types/sdk/host/control/operations.d.ts +1 -1
- package/dist/types/sdk/host/host.d.ts +6 -0
- package/dist/types/sdk/index.d.ts +1 -0
- package/dist/types/sdk/router/session-router.d.ts +9 -0
- package/dist/types/sdk/session.d.ts +13 -0
- package/dist/types/session/agent-session.d.ts +5 -1
- package/dist/types/session/internal/managed-session-storage.d.ts +11 -1
- package/dist/types/session/internal/task-artifact-owner-access.d.ts +17 -0
- package/dist/types/session/internal/task-artifact-owner-transcript.d.ts +3 -0
- package/dist/types/session/session-retirement.d.ts +47 -0
- package/dist/types/session/session-storage.d.ts +0 -46
- package/dist/types/session/session-transcript-header.d.ts +2 -0
- package/dist/types/session/task-artifact-owner-codec.d.ts +107 -0
- package/dist/types/session/task-artifact-owner-retirement.d.ts +7 -0
- package/dist/types/session/task-artifact-owner.d.ts +10 -0
- package/dist/types/tools/ask-contract.d.ts +75 -254
- package/dist/types/tools/bash.d.ts +1 -1
- package/package.json +7 -7
- package/scripts/build-sdk-package-smoke.ts +5 -0
- package/scripts/resident-memory-bench.ts +174 -20
- package/src/capability/index.ts +1 -0
- package/src/capability/types.ts +4 -0
- package/src/cli/config-cli.ts +8 -18
- package/src/cli/public-command-entry.ts +5 -0
- package/src/cli/ssh-cli.ts +6 -3
- package/src/cli/web-search-cli.ts +9 -3
- package/src/cli-commands.ts +14 -0
- package/src/cli-main.ts +7 -8
- package/src/cli-ordinary.ts +197 -0
- package/src/cli.ts +25 -182
- package/src/commands/contribution-prep.ts +1 -3
- package/src/commands/sdk.ts +171 -21
- package/src/commands/update.ts +15 -1
- package/src/config/file-lock.ts +91 -2
- package/src/config/model-profile-activation.ts +70 -4
- package/src/config/model-profile-contract.ts +4 -1
- package/src/config/model-profiles.ts +23 -31
- package/src/config/model-resolver.ts +9 -1
- package/src/config/settings.ts +44 -4
- package/src/coordinator-mcp/event-webhook.ts +19 -14
- package/src/coordinator-mcp/policy.ts +14 -4
- package/src/coordinator-mcp/server.ts +142 -7
- package/src/coordinator-mcp/session-reaper.ts +9 -1
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -1
- package/src/discovery/helpers.ts +9 -3
- package/src/discovery/ssh.ts +8 -3
- package/src/edit/modes/replace.ts +6 -6
- package/src/edit/streaming.ts +15 -15
- package/src/exec/bash-shell-supervisor.ts +60 -3
- package/src/gjc-runtime/gc-runtime.ts +2 -1
- package/src/hindsight/config.ts +9 -3
- package/src/internal-urls/docs-index.generated.ts +11 -10
- package/src/memories/index.ts +8 -2
- package/src/modes/acp/acp-agent.ts +724 -370
- package/src/modes/components/tool-execution.ts +1 -1
- package/src/prompts/tools/ask.md +2 -0
- package/src/runtime-mcp/client.ts +13 -4
- package/src/runtime-mcp/elicitation.ts +142 -0
- package/src/sdk/broker/broker.ts +54 -1
- package/src/sdk/broker/daemon-entry.ts +67 -6
- package/src/sdk/broker/discovery.ts +8 -0
- package/src/sdk/broker/ensure.ts +156 -20
- package/src/sdk/broker/lifecycle-ledger.ts +110 -12
- package/src/sdk/broker/lifecycle.ts +334 -348
- package/src/sdk/broker/runtime.ts +5 -1
- package/src/sdk/broker/task-artifact-owner-validation.ts +259 -0
- package/src/sdk/broker/transport.ts +5 -1
- package/src/sdk/bus/chat-daemon-control.ts +2 -2
- package/src/sdk/bus/chat-daemon-runtime.ts +3 -0
- package/src/sdk/bus/index.ts +3 -3
- package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
- package/src/sdk/bus/telegram-daemon-control.ts +15 -12
- package/src/sdk/bus/telegram-daemon.ts +38 -3
- package/src/sdk/cli/diagnostics-cli.ts +247 -0
- package/src/sdk/diagnostics/observe-broker.ts +654 -0
- package/src/sdk/host/control/dispatch.ts +52 -4
- package/src/sdk/host/control/operations.ts +1 -1
- package/src/sdk/host/host.ts +6 -0
- package/src/sdk/host/session-runtime.test.ts +83 -4
- package/src/sdk/host/session-runtime.ts +15 -7
- package/src/sdk/index.ts +1 -0
- package/src/sdk/lifecycle-session.ts +1 -0
- package/src/sdk/mcp/server.ts +15 -3
- package/src/sdk/protocol/operation-registry.ts +1 -1
- package/src/sdk/router/session-router.ts +26 -3
- package/src/sdk/session.ts +51 -8
- package/src/session/agent-session.ts +169 -43
- package/src/session/blob-store.ts +78 -33
- package/src/session/internal/managed-session-storage.ts +203 -4
- package/src/session/internal/task-artifact-owner-access.ts +229 -0
- package/src/session/internal/task-artifact-owner-transcript.ts +68 -0
- package/src/session/session-retirement.ts +155 -0
- package/src/session/session-storage.ts +1 -160
- package/src/session/session-transcript-header.ts +17 -0
- package/src/session/task-artifact-owner-codec.ts +578 -0
- package/src/session/task-artifact-owner-retirement.ts +475 -0
- package/src/session/task-artifact-owner.ts +289 -0
- package/src/setup/hermes/templates/operator-instructions.v1.md +19 -1
- package/src/setup/hermes-setup.ts +4 -1
- package/src/slash-commands/builtin-registry.ts +8 -5
- package/src/tools/ask-contract.test.ts +76 -1
- package/src/tools/ask-contract.ts +57 -6
- package/src/tools/ask.ts +16 -7
- package/src/tools/bash.ts +38 -29
- package/src/tools/descriptors.test.ts +29 -0
- package/src/tools/tool-catalog.generated.ts +7 -4
- package/src/web/search/index.ts +2 -2
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import type { NativeDirectoryTreeSnapshot } from "@gajae-code/natives";
|
|
4
|
+
import {
|
|
5
|
+
assertSafeRelativePath,
|
|
6
|
+
assertSessionRoot,
|
|
7
|
+
freezeTreeSnapshot,
|
|
8
|
+
immutableDeletionEvidence,
|
|
9
|
+
isOwnerRetainedRoot,
|
|
10
|
+
manifestRelativePath,
|
|
11
|
+
OWNER_DIRECTORY,
|
|
12
|
+
OWNER_MANIFEST,
|
|
13
|
+
OWNER_SCHEMA_VERSION,
|
|
14
|
+
ownerIdForSession,
|
|
15
|
+
ownerRelativePath,
|
|
16
|
+
parseTaskArtifactOwnerLocator,
|
|
17
|
+
sameOwnerParentIdentity,
|
|
18
|
+
type TaskArtifactOwnerDeletionEvidence,
|
|
19
|
+
type TaskArtifactOwnerLocator,
|
|
20
|
+
type TaskArtifactOwnerParentIdentity,
|
|
21
|
+
type TaskArtifactOwnerStorageContext,
|
|
22
|
+
} from "../task-artifact-owner-codec";
|
|
23
|
+
import { ManagedSessionDescendantStore } from "./managed-session-storage";
|
|
24
|
+
|
|
25
|
+
export interface OwnerManifest extends TaskArtifactOwnerLocator {
|
|
26
|
+
readonly sessionId: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function newSessionRootStore(context: TaskArtifactOwnerStorageContext): ManagedSessionDescendantStore {
|
|
30
|
+
assertSessionRoot(context);
|
|
31
|
+
if (path.resolve(context.profileAgentDir) !== context.profileAgentDir)
|
|
32
|
+
throw new Error("task_artifact_owner_profile_invalid");
|
|
33
|
+
const profile = fs.lstatSync(context.profileAgentDir, { bigint: true });
|
|
34
|
+
if (!profile.isDirectory() || profile.isSymbolicLink()) throw new Error("task_artifact_owner_profile_invalid");
|
|
35
|
+
const stat = fs.lstatSync(context.sessionsRoot, { bigint: true });
|
|
36
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("task_artifact_owner_root_invalid");
|
|
37
|
+
return new ManagedSessionDescendantStore(
|
|
38
|
+
context.rootAuthority,
|
|
39
|
+
context.sessionsRoot,
|
|
40
|
+
undefined,
|
|
41
|
+
context.securityPolicy,
|
|
42
|
+
context.profileAgentDir,
|
|
43
|
+
{
|
|
44
|
+
canonicalPath: context.sessionsRoot,
|
|
45
|
+
dev: BigInt.asUintN(64, stat.dev),
|
|
46
|
+
ino: BigInt.asUintN(64, stat.ino),
|
|
47
|
+
},
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function parseOwnerManifest(bytes: Uint8Array): OwnerManifest {
|
|
52
|
+
let parsed: unknown;
|
|
53
|
+
try {
|
|
54
|
+
parsed = JSON.parse(Buffer.from(bytes).toString("utf8"));
|
|
55
|
+
} catch {
|
|
56
|
+
throw new Error("task_artifact_owner_manifest_invalid");
|
|
57
|
+
}
|
|
58
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed) || Object.keys(parsed).length !== 5)
|
|
59
|
+
throw new Error("task_artifact_owner_manifest_invalid");
|
|
60
|
+
const record = parsed as Record<string, unknown>;
|
|
61
|
+
if (typeof record.sessionId !== "string" || record.sessionId.length === 0)
|
|
62
|
+
throw new Error("task_artifact_owner_manifest_invalid");
|
|
63
|
+
let locator: TaskArtifactOwnerLocator | undefined;
|
|
64
|
+
try {
|
|
65
|
+
locator = parseTaskArtifactOwnerLocator({
|
|
66
|
+
schemaVersion: record.schemaVersion,
|
|
67
|
+
ownerId: record.ownerId,
|
|
68
|
+
directoryDev: record.directoryDev,
|
|
69
|
+
directoryIno: record.directoryIno,
|
|
70
|
+
});
|
|
71
|
+
} catch {
|
|
72
|
+
throw new Error("task_artifact_owner_manifest_invalid");
|
|
73
|
+
}
|
|
74
|
+
if (!locator) throw new Error("task_artifact_owner_manifest_invalid");
|
|
75
|
+
return { ...locator, sessionId: record.sessionId };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function readOwnerManifest(
|
|
79
|
+
store: ManagedSessionDescendantStore,
|
|
80
|
+
locator: TaskArtifactOwnerLocator,
|
|
81
|
+
relativePath = manifestRelativePath(locator.ownerId),
|
|
82
|
+
): OwnerManifest {
|
|
83
|
+
const manifest = store.readExpected(relativePath);
|
|
84
|
+
if (!manifest) throw new Error("task_artifact_owner_manifest_missing");
|
|
85
|
+
const parsed = parseOwnerManifest(manifest.bytes);
|
|
86
|
+
if (
|
|
87
|
+
parsed.ownerId !== locator.ownerId ||
|
|
88
|
+
parsed.directoryDev !== locator.directoryDev ||
|
|
89
|
+
parsed.directoryIno !== locator.directoryIno
|
|
90
|
+
)
|
|
91
|
+
throw new Error("task_artifact_owner_manifest_mismatch");
|
|
92
|
+
return parsed;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function assertOwnerDirectoryExists(context: TaskArtifactOwnerStorageContext, ownerId: string): void {
|
|
96
|
+
const parent = path.join(context.sessionsRoot, OWNER_DIRECTORY);
|
|
97
|
+
const owner = path.join(parent, ownerId);
|
|
98
|
+
for (const candidate of [parent, owner]) {
|
|
99
|
+
let stat: fs.BigIntStats;
|
|
100
|
+
try {
|
|
101
|
+
stat = fs.lstatSync(candidate, { bigint: true });
|
|
102
|
+
} catch (error) {
|
|
103
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") throw new Error("task_artifact_owner_missing");
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("task_artifact_owner_replaced");
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function assertOwnerIdentity(store: ManagedSessionDescendantStore, locator: TaskArtifactOwnerLocator): void {
|
|
111
|
+
const identity = store.subtreeRootAuthority;
|
|
112
|
+
if (identity.dev.toString() !== locator.directoryDev || identity.ino.toString() !== locator.directoryIno)
|
|
113
|
+
throw new Error("task_artifact_owner_identity_mismatch");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function openOwnerStore(
|
|
117
|
+
context: TaskArtifactOwnerStorageContext,
|
|
118
|
+
rootStore: ManagedSessionDescendantStore,
|
|
119
|
+
locator: TaskArtifactOwnerLocator,
|
|
120
|
+
sessionId: string,
|
|
121
|
+
): ManagedSessionDescendantStore {
|
|
122
|
+
if (locator.ownerId !== ownerIdForSession(sessionId)) throw new Error("task_artifact_owner_session_mismatch");
|
|
123
|
+
assertOwnerDirectoryExists(context, locator.ownerId);
|
|
124
|
+
const manifest = readOwnerManifest(rootStore, locator);
|
|
125
|
+
if (manifest.sessionId !== sessionId) throw new Error("task_artifact_owner_session_mismatch");
|
|
126
|
+
const identity = rootStore.captureDirectoryIdentity(ownerRelativePath(locator.ownerId));
|
|
127
|
+
if (identity.dev !== locator.directoryDev || identity.ino !== locator.directoryIno)
|
|
128
|
+
throw new Error("task_artifact_owner_identity_mismatch");
|
|
129
|
+
const ownerPath = path.join(context.sessionsRoot, ownerRelativePath(locator.ownerId));
|
|
130
|
+
const ownerStore = new ManagedSessionDescendantStore(
|
|
131
|
+
context.rootAuthority,
|
|
132
|
+
ownerPath,
|
|
133
|
+
undefined,
|
|
134
|
+
context.securityPolicy,
|
|
135
|
+
context.profileAgentDir,
|
|
136
|
+
{
|
|
137
|
+
canonicalPath: ownerPath,
|
|
138
|
+
dev: BigInt(locator.directoryDev),
|
|
139
|
+
ino: BigInt(locator.directoryIno),
|
|
140
|
+
},
|
|
141
|
+
);
|
|
142
|
+
try {
|
|
143
|
+
assertOwnerIdentity(ownerStore, locator);
|
|
144
|
+
readOwnerManifest(ownerStore, locator, OWNER_MANIFEST);
|
|
145
|
+
return ownerStore;
|
|
146
|
+
} catch (error) {
|
|
147
|
+
ownerStore.close();
|
|
148
|
+
throw error;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function locatorFromManifest(manifest: OwnerManifest): TaskArtifactOwnerLocator {
|
|
153
|
+
return {
|
|
154
|
+
schemaVersion: OWNER_SCHEMA_VERSION,
|
|
155
|
+
ownerId: manifest.ownerId,
|
|
156
|
+
directoryDev: manifest.directoryDev,
|
|
157
|
+
directoryIno: manifest.directoryIno,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function captureValidatedOwnerTree(
|
|
162
|
+
context: TaskArtifactOwnerStorageContext,
|
|
163
|
+
rootStore: ManagedSessionDescendantStore,
|
|
164
|
+
sessionId: string,
|
|
165
|
+
locator: TaskArtifactOwnerLocator,
|
|
166
|
+
): NativeDirectoryTreeSnapshot {
|
|
167
|
+
if (locator.ownerId !== ownerIdForSession(sessionId)) throw new Error("task_artifact_owner_session_mismatch");
|
|
168
|
+
assertOwnerDirectoryExists(context, locator.ownerId);
|
|
169
|
+
const manifest = readOwnerManifest(rootStore, locator);
|
|
170
|
+
if (manifest.sessionId !== sessionId) throw new Error("task_artifact_owner_session_mismatch");
|
|
171
|
+
const tree = rootStore.captureTree(ownerRelativePath(locator.ownerId));
|
|
172
|
+
if (tree.rootDev !== locator.directoryDev || tree.rootIno !== locator.directoryIno)
|
|
173
|
+
throw new Error("task_artifact_owner_identity_mismatch");
|
|
174
|
+
return tree;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function captureOwnerTreeIfPresent(
|
|
178
|
+
context: TaskArtifactOwnerStorageContext,
|
|
179
|
+
rootStore: ManagedSessionDescendantStore,
|
|
180
|
+
ownerId: string,
|
|
181
|
+
pathname: string,
|
|
182
|
+
): NativeDirectoryTreeSnapshot | undefined {
|
|
183
|
+
if (!isOwnerRetainedRoot(context, ownerId, pathname))
|
|
184
|
+
throw new Error("task_artifact_owner_retained_root_unrecognized");
|
|
185
|
+
let named: fs.BigIntStats;
|
|
186
|
+
try {
|
|
187
|
+
named = fs.lstatSync(pathname, { bigint: true });
|
|
188
|
+
} catch (error) {
|
|
189
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
190
|
+
throw error;
|
|
191
|
+
}
|
|
192
|
+
if (!named.isDirectory() || named.isSymbolicLink()) throw new Error("task_artifact_owner_retained_root_replaced");
|
|
193
|
+
const relative = path.relative(context.sessionsRoot, pathname).split(path.sep).join("/");
|
|
194
|
+
assertSafeRelativePath(relative);
|
|
195
|
+
return freezeTreeSnapshot(rootStore.captureTree(relative));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function ownerTreeHasPendingManagedPublication(snapshot: NativeDirectoryTreeSnapshot): boolean {
|
|
199
|
+
return snapshot.entries.some(entry =>
|
|
200
|
+
/^\..+\.[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.(?:staging|replacement)$/u.test(
|
|
201
|
+
path.posix.basename(entry.relativePath),
|
|
202
|
+
),
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Capture exact owner evidence without repairing its security, timestamps, or namespace. */
|
|
207
|
+
export function captureTaskArtifactOwnerDeletionEvidence(
|
|
208
|
+
context: TaskArtifactOwnerStorageContext,
|
|
209
|
+
sessionId: string,
|
|
210
|
+
locatorValue: unknown,
|
|
211
|
+
): TaskArtifactOwnerDeletionEvidence | undefined {
|
|
212
|
+
const locator = parseTaskArtifactOwnerLocator(locatorValue);
|
|
213
|
+
if (!locator) return undefined;
|
|
214
|
+
const rootStore = newSessionRootStore(context);
|
|
215
|
+
try {
|
|
216
|
+
rootStore.verifyRootSecurity();
|
|
217
|
+
const parentIdentity: TaskArtifactOwnerParentIdentity = rootStore.captureDirectoryIdentity(OWNER_DIRECTORY);
|
|
218
|
+
const treeSnapshot = captureValidatedOwnerTree(context, rootStore, sessionId, locator);
|
|
219
|
+
// This refuses to capture during publication; it does not revoke independent open descriptors.
|
|
220
|
+
if (ownerTreeHasPendingManagedPublication(treeSnapshot))
|
|
221
|
+
throw new Error("task_artifact_owner_writer_not_quiescent");
|
|
222
|
+
const parentAfter = rootStore.captureDirectoryIdentity(OWNER_DIRECTORY);
|
|
223
|
+
if (!sameOwnerParentIdentity(parentIdentity, parentAfter))
|
|
224
|
+
throw new Error("task_artifact_owner_parent_changed_during_capture");
|
|
225
|
+
return immutableDeletionEvidence(sessionId, locator, parentIdentity, treeSnapshot);
|
|
226
|
+
} finally {
|
|
227
|
+
rootStore.close();
|
|
228
|
+
}
|
|
229
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { parseFirstJsonlLine } from "../session-transcript-header";
|
|
2
|
+
import { parseTaskArtifactOwnerLocator, type TaskArtifactOwnerLocator } from "../task-artifact-owner-codec";
|
|
3
|
+
|
|
4
|
+
const transcriptDecoder = new TextDecoder("utf-8", { fatal: true });
|
|
5
|
+
|
|
6
|
+
/** Resolve the path-free owner locator using transcript replay's header-patch semantics. */
|
|
7
|
+
export function taskArtifactOwnerLocatorFromTranscriptBytes(
|
|
8
|
+
bytes: Uint8Array,
|
|
9
|
+
expectedSessionId: string,
|
|
10
|
+
): TaskArtifactOwnerLocator | undefined {
|
|
11
|
+
const header = parseFirstJsonlLine(bytes);
|
|
12
|
+
if (header?.type !== "session" || header.id !== expectedSessionId)
|
|
13
|
+
throw new Error("task_artifact_owner_transcript_header_invalid");
|
|
14
|
+
let locator = parseTaskArtifactOwnerLocator(header.taskArtifactOwner);
|
|
15
|
+
if (typeof header.version !== "number" || header.version < 4) return locator;
|
|
16
|
+
const firstEnd = bytes.indexOf(0x0a);
|
|
17
|
+
let start = firstEnd < 0 ? bytes.byteLength : firstEnd + 1;
|
|
18
|
+
while (start < bytes.byteLength) {
|
|
19
|
+
const newline = bytes.indexOf(0x0a, start);
|
|
20
|
+
const end = newline < 0 ? bytes.byteLength : newline;
|
|
21
|
+
const line = bytes.subarray(start, end);
|
|
22
|
+
let record: Record<string, unknown> | undefined;
|
|
23
|
+
try {
|
|
24
|
+
if (line.byteLength > 0) {
|
|
25
|
+
const parsed: unknown = JSON.parse(transcriptDecoder.decode(line));
|
|
26
|
+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed))
|
|
27
|
+
record = parsed as Record<string, unknown>;
|
|
28
|
+
}
|
|
29
|
+
} catch {
|
|
30
|
+
const text = Buffer.from(line).toString("utf8");
|
|
31
|
+
if (text.includes("header_patch") && text.includes("taskArtifactOwner"))
|
|
32
|
+
throw new Error("task_artifact_owner_patch_invalid");
|
|
33
|
+
}
|
|
34
|
+
if (
|
|
35
|
+
record?.type === "header_patch" &&
|
|
36
|
+
typeof record.patch === "object" &&
|
|
37
|
+
record.patch !== null &&
|
|
38
|
+
!Array.isArray(record.patch) &&
|
|
39
|
+
Object.hasOwn(record.patch, "taskArtifactOwner")
|
|
40
|
+
) {
|
|
41
|
+
const patch = record.patch as Record<string, unknown>;
|
|
42
|
+
if (
|
|
43
|
+
!Object.keys(record).every(key => key === "type" || key === "patch") ||
|
|
44
|
+
!Object.keys(patch).every(
|
|
45
|
+
key =>
|
|
46
|
+
key === "cwd" ||
|
|
47
|
+
key === "title" ||
|
|
48
|
+
key === "titleSource" ||
|
|
49
|
+
key === "starred" ||
|
|
50
|
+
key === "taskArtifactOwner",
|
|
51
|
+
) ||
|
|
52
|
+
(patch.cwd !== undefined && typeof patch.cwd !== "string") ||
|
|
53
|
+
(patch.title !== undefined && typeof patch.title !== "string") ||
|
|
54
|
+
(patch.titleSource !== undefined && patch.titleSource !== "auto" && patch.titleSource !== "user") ||
|
|
55
|
+
(patch.starred !== undefined && typeof patch.starred !== "boolean")
|
|
56
|
+
)
|
|
57
|
+
throw new Error("task_artifact_owner_patch_invalid");
|
|
58
|
+
try {
|
|
59
|
+
locator = parseTaskArtifactOwnerLocator(patch.taskArtifactOwner);
|
|
60
|
+
} catch {
|
|
61
|
+
throw new Error("task_artifact_owner_patch_invalid");
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (newline < 0) break;
|
|
65
|
+
start = newline + 1;
|
|
66
|
+
}
|
|
67
|
+
return locator;
|
|
68
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import * as crypto from "node:crypto";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { toError } from "@gajae-code/utils";
|
|
5
|
+
import {
|
|
6
|
+
type FileSessionStorage,
|
|
7
|
+
SessionDeleteVerificationError,
|
|
8
|
+
type SessionStorageSnapshot,
|
|
9
|
+
type VerifiedSessionDeleteTarget,
|
|
10
|
+
} from "./session-storage";
|
|
11
|
+
import { parseFirstJsonlLine } from "./session-transcript-header";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Outcome of a disk-retention retirement attempt.
|
|
15
|
+
*
|
|
16
|
+
* `kept` means the transcript survived and nothing of the session was
|
|
17
|
+
* destroyed, with the exact reason so `gjc gc --disk` can report it.
|
|
18
|
+
* `cleanup_pending` means the delete authority already detached or removed the
|
|
19
|
+
* session's artifact tree (or quarantined the transcript) before it stopped:
|
|
20
|
+
* the record survives, the session does not, and a caller must NOT report that
|
|
21
|
+
* as an ordinary keep.
|
|
22
|
+
*/
|
|
23
|
+
export type SessionRetirementOutcome =
|
|
24
|
+
| { kind: "retired" }
|
|
25
|
+
| { kind: "kept"; reason: string }
|
|
26
|
+
| { kind: "cleanup_pending"; reason: string };
|
|
27
|
+
|
|
28
|
+
/** A retirement that cleared every precondition the retention pass itself owns. */
|
|
29
|
+
type SessionRetirementPlan =
|
|
30
|
+
| { kind: "retirable"; target: VerifiedSessionDeleteTarget; artifactsPresent: boolean }
|
|
31
|
+
| { kind: "kept"; reason: string };
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Evaluate the preconditions retirement owns — the transcript must be readable
|
|
35
|
+
* and carry a usable session header — and bind the delete target to the exact
|
|
36
|
+
* bytes just read. Nothing here mutates the store.
|
|
37
|
+
*/
|
|
38
|
+
function planSessionRetirement(
|
|
39
|
+
storage: FileSessionStorage,
|
|
40
|
+
sessionsRoot: string,
|
|
41
|
+
transcriptPath: string,
|
|
42
|
+
): SessionRetirementPlan {
|
|
43
|
+
let snapshot: SessionStorageSnapshot;
|
|
44
|
+
try {
|
|
45
|
+
snapshot = storage.readSnapshotSync(transcriptPath);
|
|
46
|
+
} catch (err) {
|
|
47
|
+
return { kind: "kept", reason: `transcript_unreadable: ${toError(err).message}` };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const header = parseFirstJsonlLine(snapshot.bytes);
|
|
51
|
+
if (header?.type !== "session" || typeof header.id !== "string" || typeof header.cwd !== "string") {
|
|
52
|
+
return { kind: "kept", reason: "transcript_header_unusable" };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// The verified delete authority only unlinks a single-link transcript, so a
|
|
56
|
+
// hard-linked one can never be retired. Refusing here keeps the dry run from
|
|
57
|
+
// promising bytes prune cannot release, and — because this runs before the
|
|
58
|
+
// artifact phase — keeps a doomed retirement from destroying the artifact
|
|
59
|
+
// tree of a session whose transcript will survive anyway.
|
|
60
|
+
if (snapshot.stat.nlink === undefined || snapshot.stat.nlink !== 1n) {
|
|
61
|
+
return { kind: "kept", reason: "transcript_not_single_link" };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const directory = path.dirname(transcriptPath);
|
|
65
|
+
return {
|
|
66
|
+
kind: "retirable",
|
|
67
|
+
artifactsPresent: storage.existsSync(transcriptPath.slice(0, -".jsonl".length)),
|
|
68
|
+
target: {
|
|
69
|
+
sessionsRoot,
|
|
70
|
+
transcriptPath,
|
|
71
|
+
sessionId: header.id,
|
|
72
|
+
cwd: header.cwd,
|
|
73
|
+
transcriptIdentity: {
|
|
74
|
+
dev: snapshot.stat.dev,
|
|
75
|
+
ino: snapshot.stat.ino,
|
|
76
|
+
nlink: snapshot.stat.nlink,
|
|
77
|
+
size: snapshot.stat.size,
|
|
78
|
+
mtimeNs: snapshot.stat.mtimeNs,
|
|
79
|
+
sha256: crypto.createHash("sha256").update(snapshot.bytes).digest("hex"),
|
|
80
|
+
},
|
|
81
|
+
plannedArtifactsPath: path.join(directory, `.gjc-delete-gc-${crypto.randomUUID()}-artifacts`),
|
|
82
|
+
plannedTranscriptPath: path.join(directory, `.gjc-delete-gc-${crypto.randomUUID()}-transcript`),
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Non-mutating projection of {@link retireSessionTranscript}: would the
|
|
89
|
+
* retention pass's own preconditions let this transcript be retired at all?
|
|
90
|
+
*
|
|
91
|
+
* `gjc gc --disk` runs it so a dry run reports the verdict a prune would reach
|
|
92
|
+
* instead of promising bytes the delete authority will refuse to release. The
|
|
93
|
+
* authority's verdict (containment, identity, artifact tree) is deliberately
|
|
94
|
+
* not predicted here — it is re-derived against live state at delete time.
|
|
95
|
+
*/
|
|
96
|
+
export function probeSessionRetirement(
|
|
97
|
+
storage: FileSessionStorage,
|
|
98
|
+
sessionsRoot: string,
|
|
99
|
+
transcriptPath: string,
|
|
100
|
+
): { kind: "retirable" } | { kind: "kept"; reason: string } {
|
|
101
|
+
const plan = planSessionRetirement(storage, sessionsRoot, transcriptPath);
|
|
102
|
+
return plan.kind === "retirable" ? { kind: "retirable" } : plan;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* A transcript that survives after its artifacts are already gone is not a
|
|
107
|
+
* benign keep: the session is half-destroyed and the caller has to surface it.
|
|
108
|
+
*/
|
|
109
|
+
function retirementIncomplete(artifactsRemoved: boolean, reason: string): SessionRetirementOutcome {
|
|
110
|
+
return artifactsRemoved ? { kind: "cleanup_pending", reason } : { kind: "kept", reason };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Retire one managed session transcript through the verified hard-delete
|
|
115
|
+
* authority.
|
|
116
|
+
*
|
|
117
|
+
* This is the only supported way for a retention pass to remove a transcript:
|
|
118
|
+
* identity, containment, header id/cwd, artifact tree and parent directory are
|
|
119
|
+
* all re-verified inside {@link FileSessionStorage.deleteSessionVerified}, and
|
|
120
|
+
* anything ambiguous fails closed as `kept` rather than deleting bytes. The
|
|
121
|
+
* caller owns the retention policy; this function owns nothing but the delete
|
|
122
|
+
* authority.
|
|
123
|
+
*/
|
|
124
|
+
export async function retireSessionTranscript(
|
|
125
|
+
storage: FileSessionStorage,
|
|
126
|
+
sessionsRoot: string,
|
|
127
|
+
transcriptPath: string,
|
|
128
|
+
): Promise<SessionRetirementOutcome> {
|
|
129
|
+
const plan = planSessionRetirement(storage, sessionsRoot, transcriptPath);
|
|
130
|
+
if (plan.kind === "kept") return plan;
|
|
131
|
+
|
|
132
|
+
let artifactsRemoved = false;
|
|
133
|
+
try {
|
|
134
|
+
// Phase 1 removes the sibling artifact directory (or proves it absent);
|
|
135
|
+
// phase 2 unlinks the transcript only against the same bound identity.
|
|
136
|
+
const artifacts = await storage.deleteSessionVerified(plan.target);
|
|
137
|
+
if (artifacts.kind === "deleted") return { kind: "retired" };
|
|
138
|
+
if (artifacts.kind === "cleanup_pending") {
|
|
139
|
+
return { kind: "cleanup_pending", reason: `cleanup_pending_${artifacts.phase}: ${artifacts.error.message}` };
|
|
140
|
+
}
|
|
141
|
+
// Phase 1 is durable: whatever artifact tree the session had is gone, so
|
|
142
|
+
// every later refusal leaves a record without its artifacts.
|
|
143
|
+
artifactsRemoved = plan.artifactsPresent;
|
|
144
|
+
const deletion = await storage.deleteSessionVerified({ ...plan.target, artifactsRemoved: true });
|
|
145
|
+
if (deletion.kind === "deleted") return { kind: "retired" };
|
|
146
|
+
if (deletion.kind === "cleanup_pending") {
|
|
147
|
+
return { kind: "cleanup_pending", reason: `cleanup_pending_${deletion.phase}: ${deletion.error.message}` };
|
|
148
|
+
}
|
|
149
|
+
return retirementIncomplete(artifactsRemoved, "transcript_not_deleted");
|
|
150
|
+
} catch (err) {
|
|
151
|
+
const error = toError(err);
|
|
152
|
+
const kind = err instanceof SessionDeleteVerificationError ? err.kind : "error";
|
|
153
|
+
return retirementIncomplete(artifactsRemoved, `verified_delete_rejected(${kind}): ${error.message}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
type NativePublishOutcome,
|
|
29
29
|
} from "./internal/native-publish-outcome";
|
|
30
30
|
import { isDerivedSessionMemoryFile } from "./internal/session-memory-sidecar";
|
|
31
|
+
import { parseFirstJsonlLine } from "./session-transcript-header";
|
|
31
32
|
|
|
32
33
|
const utf8Decoder = new TextDecoder("utf-8");
|
|
33
34
|
const newlineBuffer = Buffer.from("\n", "utf8");
|
|
@@ -2883,22 +2884,6 @@ export class FileSessionStorage implements SessionStorage {
|
|
|
2883
2884
|
}
|
|
2884
2885
|
}
|
|
2885
2886
|
|
|
2886
|
-
/** Parse the first JSONL line as a generic record; returns undefined on parse failure. */
|
|
2887
|
-
function parseFirstJsonlLine(bytes: Uint8Array): Record<string, unknown> | undefined {
|
|
2888
|
-
const NL = 0x0a;
|
|
2889
|
-
const end = bytes.indexOf(NL);
|
|
2890
|
-
const firstLine = end === -1 ? bytes : bytes.subarray(0, end);
|
|
2891
|
-
if (firstLine.length === 0) return undefined;
|
|
2892
|
-
try {
|
|
2893
|
-
const text = utf8Decoder.decode(firstLine).trim();
|
|
2894
|
-
if (!text) return undefined;
|
|
2895
|
-
const value: unknown = JSON.parse(text);
|
|
2896
|
-
return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : undefined;
|
|
2897
|
-
} catch {
|
|
2898
|
-
return undefined;
|
|
2899
|
-
}
|
|
2900
|
-
}
|
|
2901
|
-
|
|
2902
2887
|
function matchesPattern(name: string, pattern: string): boolean {
|
|
2903
2888
|
if (pattern === "*") return true;
|
|
2904
2889
|
if (pattern === ".*") return name.startsWith(".");
|
|
@@ -3531,147 +3516,3 @@ export class MemorySessionStorage implements SessionStorage {
|
|
|
3531
3516
|
return new MemoryStagedStreamingWriter(this, path);
|
|
3532
3517
|
}
|
|
3533
3518
|
}
|
|
3534
|
-
|
|
3535
|
-
/**
|
|
3536
|
-
* Outcome of a disk-retention retirement attempt.
|
|
3537
|
-
*
|
|
3538
|
-
* `kept` means the transcript survived and nothing of the session was
|
|
3539
|
-
* destroyed, with the exact reason so `gjc gc --disk` can report it.
|
|
3540
|
-
* `cleanup_pending` means the delete authority already detached or removed the
|
|
3541
|
-
* session's artifact tree (or quarantined the transcript) before it stopped:
|
|
3542
|
-
* the record survives, the session does not, and a caller must NOT report that
|
|
3543
|
-
* as an ordinary keep.
|
|
3544
|
-
*/
|
|
3545
|
-
export type SessionRetirementOutcome =
|
|
3546
|
-
| { kind: "retired" }
|
|
3547
|
-
| { kind: "kept"; reason: string }
|
|
3548
|
-
| { kind: "cleanup_pending"; reason: string };
|
|
3549
|
-
|
|
3550
|
-
/** A retirement that cleared every precondition the retention pass itself owns. */
|
|
3551
|
-
type SessionRetirementPlan =
|
|
3552
|
-
| { kind: "retirable"; target: VerifiedSessionDeleteTarget; artifactsPresent: boolean }
|
|
3553
|
-
| { kind: "kept"; reason: string };
|
|
3554
|
-
|
|
3555
|
-
/**
|
|
3556
|
-
* Evaluate the preconditions retirement owns — the transcript must be readable
|
|
3557
|
-
* and carry a usable session header — and bind the delete target to the exact
|
|
3558
|
-
* bytes just read. Nothing here mutates the store.
|
|
3559
|
-
*/
|
|
3560
|
-
function planSessionRetirement(
|
|
3561
|
-
storage: FileSessionStorage,
|
|
3562
|
-
sessionsRoot: string,
|
|
3563
|
-
transcriptPath: string,
|
|
3564
|
-
): SessionRetirementPlan {
|
|
3565
|
-
let snapshot: SessionStorageSnapshot;
|
|
3566
|
-
try {
|
|
3567
|
-
snapshot = storage.readSnapshotSync(transcriptPath);
|
|
3568
|
-
} catch (err) {
|
|
3569
|
-
return { kind: "kept", reason: `transcript_unreadable: ${toError(err).message}` };
|
|
3570
|
-
}
|
|
3571
|
-
|
|
3572
|
-
const header = parseFirstJsonlLine(snapshot.bytes);
|
|
3573
|
-
if (header?.type !== "session" || typeof header.id !== "string" || typeof header.cwd !== "string") {
|
|
3574
|
-
return { kind: "kept", reason: "transcript_header_unusable" };
|
|
3575
|
-
}
|
|
3576
|
-
|
|
3577
|
-
// The verified delete authority only unlinks a single-link transcript, so a
|
|
3578
|
-
// hard-linked one can never be retired. Refusing here keeps the dry run from
|
|
3579
|
-
// promising bytes prune cannot release, and — because this runs before the
|
|
3580
|
-
// artifact phase — keeps a doomed retirement from destroying the artifact
|
|
3581
|
-
// tree of a session whose transcript will survive anyway.
|
|
3582
|
-
if (snapshot.stat.nlink === undefined || snapshot.stat.nlink !== 1n) {
|
|
3583
|
-
return { kind: "kept", reason: "transcript_not_single_link" };
|
|
3584
|
-
}
|
|
3585
|
-
|
|
3586
|
-
const directory = path.dirname(transcriptPath);
|
|
3587
|
-
return {
|
|
3588
|
-
kind: "retirable",
|
|
3589
|
-
artifactsPresent: storage.existsSync(transcriptPath.slice(0, -".jsonl".length)),
|
|
3590
|
-
target: {
|
|
3591
|
-
sessionsRoot,
|
|
3592
|
-
transcriptPath,
|
|
3593
|
-
sessionId: header.id,
|
|
3594
|
-
cwd: header.cwd,
|
|
3595
|
-
transcriptIdentity: {
|
|
3596
|
-
dev: snapshot.stat.dev,
|
|
3597
|
-
ino: snapshot.stat.ino,
|
|
3598
|
-
nlink: snapshot.stat.nlink,
|
|
3599
|
-
size: snapshot.stat.size,
|
|
3600
|
-
mtimeNs: snapshot.stat.mtimeNs,
|
|
3601
|
-
sha256: createHash("sha256").update(snapshot.bytes).digest("hex"),
|
|
3602
|
-
},
|
|
3603
|
-
plannedArtifactsPath: path.join(directory, `.gjc-delete-gc-${randomUUID()}-artifacts`),
|
|
3604
|
-
plannedTranscriptPath: path.join(directory, `.gjc-delete-gc-${randomUUID()}-transcript`),
|
|
3605
|
-
},
|
|
3606
|
-
};
|
|
3607
|
-
}
|
|
3608
|
-
|
|
3609
|
-
/**
|
|
3610
|
-
* Non-mutating projection of {@link retireSessionTranscript}: would the
|
|
3611
|
-
* retention pass's own preconditions let this transcript be retired at all?
|
|
3612
|
-
*
|
|
3613
|
-
* `gjc gc --disk` runs it so a dry run reports the verdict a prune would reach
|
|
3614
|
-
* instead of promising bytes the delete authority will refuse to release. The
|
|
3615
|
-
* authority's verdict (containment, identity, artifact tree) is deliberately
|
|
3616
|
-
* not predicted here — it is re-derived against live state at delete time.
|
|
3617
|
-
*/
|
|
3618
|
-
export function probeSessionRetirement(
|
|
3619
|
-
storage: FileSessionStorage,
|
|
3620
|
-
sessionsRoot: string,
|
|
3621
|
-
transcriptPath: string,
|
|
3622
|
-
): { kind: "retirable" } | { kind: "kept"; reason: string } {
|
|
3623
|
-
const plan = planSessionRetirement(storage, sessionsRoot, transcriptPath);
|
|
3624
|
-
return plan.kind === "retirable" ? { kind: "retirable" } : plan;
|
|
3625
|
-
}
|
|
3626
|
-
|
|
3627
|
-
/**
|
|
3628
|
-
* A transcript that survives after its artifacts are already gone is not a
|
|
3629
|
-
* benign keep: the session is half-destroyed and the caller has to surface it.
|
|
3630
|
-
*/
|
|
3631
|
-
function retirementIncomplete(artifactsRemoved: boolean, reason: string): SessionRetirementOutcome {
|
|
3632
|
-
return artifactsRemoved ? { kind: "cleanup_pending", reason } : { kind: "kept", reason };
|
|
3633
|
-
}
|
|
3634
|
-
|
|
3635
|
-
/**
|
|
3636
|
-
* Retire one managed session transcript through the verified hard-delete
|
|
3637
|
-
* authority.
|
|
3638
|
-
*
|
|
3639
|
-
* This is the only supported way for a retention pass to remove a transcript:
|
|
3640
|
-
* identity, containment, header id/cwd, artifact tree and parent directory are
|
|
3641
|
-
* all re-verified inside {@link FileSessionStorage.deleteSessionVerified}, and
|
|
3642
|
-
* anything ambiguous fails closed as `kept` rather than deleting bytes. The
|
|
3643
|
-
* caller owns the retention policy; this function owns nothing but the delete
|
|
3644
|
-
* authority.
|
|
3645
|
-
*/
|
|
3646
|
-
export async function retireSessionTranscript(
|
|
3647
|
-
storage: FileSessionStorage,
|
|
3648
|
-
sessionsRoot: string,
|
|
3649
|
-
transcriptPath: string,
|
|
3650
|
-
): Promise<SessionRetirementOutcome> {
|
|
3651
|
-
const plan = planSessionRetirement(storage, sessionsRoot, transcriptPath);
|
|
3652
|
-
if (plan.kind === "kept") return plan;
|
|
3653
|
-
|
|
3654
|
-
let artifactsRemoved = false;
|
|
3655
|
-
try {
|
|
3656
|
-
// Phase 1 removes the sibling artifact directory (or proves it absent);
|
|
3657
|
-
// phase 2 unlinks the transcript only against the same bound identity.
|
|
3658
|
-
const artifacts = await storage.deleteSessionVerified(plan.target);
|
|
3659
|
-
if (artifacts.kind === "deleted") return { kind: "retired" };
|
|
3660
|
-
if (artifacts.kind === "cleanup_pending") {
|
|
3661
|
-
return { kind: "cleanup_pending", reason: `cleanup_pending_${artifacts.phase}: ${artifacts.error.message}` };
|
|
3662
|
-
}
|
|
3663
|
-
// Phase 1 is durable: whatever artifact tree the session had is gone, so
|
|
3664
|
-
// every later refusal leaves a record without its artifacts.
|
|
3665
|
-
artifactsRemoved = plan.artifactsPresent;
|
|
3666
|
-
const deletion = await storage.deleteSessionVerified({ ...plan.target, artifactsRemoved: true });
|
|
3667
|
-
if (deletion.kind === "deleted") return { kind: "retired" };
|
|
3668
|
-
if (deletion.kind === "cleanup_pending") {
|
|
3669
|
-
return { kind: "cleanup_pending", reason: `cleanup_pending_${deletion.phase}: ${deletion.error.message}` };
|
|
3670
|
-
}
|
|
3671
|
-
return retirementIncomplete(artifactsRemoved, "transcript_not_deleted");
|
|
3672
|
-
} catch (err) {
|
|
3673
|
-
const error = toError(err);
|
|
3674
|
-
const kind = err instanceof SessionDeleteVerificationError ? err.kind : "error";
|
|
3675
|
-
return retirementIncomplete(artifactsRemoved, `verified_delete_rejected(${kind}): ${error.message}`);
|
|
3676
|
-
}
|
|
3677
|
-
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
const utf8Decoder = new TextDecoder("utf-8");
|
|
2
|
+
|
|
3
|
+
/** Parse the first JSONL line as a generic record; returns undefined on parse failure. */
|
|
4
|
+
export function parseFirstJsonlLine(bytes: Uint8Array): Record<string, unknown> | undefined {
|
|
5
|
+
const NL = 0x0a;
|
|
6
|
+
const end = bytes.indexOf(NL);
|
|
7
|
+
const firstLine = end === -1 ? bytes : bytes.subarray(0, end);
|
|
8
|
+
if (firstLine.length === 0) return undefined;
|
|
9
|
+
try {
|
|
10
|
+
const text = utf8Decoder.decode(firstLine).trim();
|
|
11
|
+
if (!text) return undefined;
|
|
12
|
+
const value: unknown = JSON.parse(text);
|
|
13
|
+
return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : undefined;
|
|
14
|
+
} catch {
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
}
|