tinker-agent 1.0.65
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/README.md +173 -0
- package/package.json +78 -0
- package/patches/markdansi@0.3.2.patch +37 -0
- package/src/agent/context-builder.ts +43 -0
- package/src/agent/context-meter.ts +310 -0
- package/src/agent/loop.ts +525 -0
- package/src/agent/runtime-session.ts +1212 -0
- package/src/agent/session-ledger.ts +828 -0
- package/src/agent/turn-cancellation.ts +44 -0
- package/src/agent/types.ts +77 -0
- package/src/cli/config.ts +283 -0
- package/src/cli/index.ts +29 -0
- package/src/cli/model-profiles.ts +289 -0
- package/src/cli/run-runner.ts +107 -0
- package/src/cli/tui-runner.tsx +290 -0
- package/src/context/compiled-context-hash.ts +138 -0
- package/src/context/compiled-context-validator.ts +209 -0
- package/src/context/context-manager.ts +362 -0
- package/src/context/context-policy.ts +8 -0
- package/src/context/context-protocol-validator.ts +463 -0
- package/src/context/context-revision-compiler.ts +281 -0
- package/src/context/context-revision.ts +111 -0
- package/src/context/context-source.ts +30 -0
- package/src/context/context-swap-renderer.ts +272 -0
- package/src/context/protocol-frame.ts +240 -0
- package/src/context/swap-planner.ts +725 -0
- package/src/events/append-private-file.ts +16 -0
- package/src/events/bash-result-detail.ts +70 -0
- package/src/events/composite-event-sink.ts +82 -0
- package/src/events/event-sink.ts +16 -0
- package/src/events/jsonl-event-log.ts +13 -0
- package/src/events/observation-text-log.ts +195 -0
- package/src/events/stdout-event-printer.ts +396 -0
- package/src/events/types.ts +263 -0
- package/src/ids/runtime-id.ts +68 -0
- package/src/ids/uuid-v7.ts +5 -0
- package/src/instructions/project-instructions.ts +242 -0
- package/src/mcp/mcp-config.ts +144 -0
- package/src/mcp/mcp-manager.ts +216 -0
- package/src/mcp/mcp-tool-executor.ts +178 -0
- package/src/model/committed-prefix-auditor.ts +68 -0
- package/src/model/fake-model-client.ts +280 -0
- package/src/model/model-client.ts +64 -0
- package/src/model/model-context-profile.ts +134 -0
- package/src/model/model-request-preflight.ts +120 -0
- package/src/model/openai-chat-mapping.ts +444 -0
- package/src/model/openai-chat-model-client.ts +190 -0
- package/src/model/prompt-prefix-hash.ts +47 -0
- package/src/model/token-estimator.ts +148 -0
- package/src/observation/observation-builder.ts +481 -0
- package/src/session/resume-projection.ts +616 -0
- package/src/session/session-catalog.ts +270 -0
- package/src/session/session-errors.ts +121 -0
- package/src/session/session-history-reader.ts +535 -0
- package/src/session/session-lock.ts +291 -0
- package/src/session/session-schema.ts +741 -0
- package/src/session/session-store.ts +3067 -0
- package/src/session/sqlite-session-ledger.ts +153 -0
- package/src/tools/bash-task.ts +617 -0
- package/src/tools/bash.ts +450 -0
- package/src/tools/cwd-state.ts +22 -0
- package/src/tools/edit.ts +428 -0
- package/src/tools/file-diff.ts +116 -0
- package/src/tools/glob.ts +202 -0
- package/src/tools/grep.ts +550 -0
- package/src/tools/hash.ts +9 -0
- package/src/tools/path-safety.ts +33 -0
- package/src/tools/read.ts +319 -0
- package/src/tools/recall.ts +400 -0
- package/src/tools/registry.ts +213 -0
- package/src/tools/ripgrep.ts +220 -0
- package/src/tools/task-list.ts +59 -0
- package/src/tools/task-output-snapshot.ts +47 -0
- package/src/tools/task-output-tool.ts +62 -0
- package/src/tools/task-output.ts +159 -0
- package/src/tools/task-stop.ts +59 -0
- package/src/tools/task-tool-args.ts +29 -0
- package/src/tools/types.ts +330 -0
- package/src/tools/web-fetch/backend.ts +27 -0
- package/src/tools/web-fetch/browser-backend.ts +126 -0
- package/src/tools/web-fetch/exa-backend.ts +172 -0
- package/src/tools/web-fetch/index.ts +298 -0
- package/src/tools/web-fetch/local-backend.ts +267 -0
- package/src/tools/web-fetch/refiner.ts +78 -0
- package/src/tools/web-fetch/route.ts +95 -0
- package/src/tools/web-search.ts +300 -0
- package/src/tools/write.ts +244 -0
- package/src/tui/app.tsx +497 -0
- package/src/tui/components/assistant-markdown.tsx +47 -0
- package/src/tui/components/background-tasks.tsx +92 -0
- package/src/tui/components/bash-result-view.tsx +47 -0
- package/src/tui/components/context-status.tsx +127 -0
- package/src/tui/components/diff-view.tsx +151 -0
- package/src/tui/components/file-viewer.tsx +212 -0
- package/src/tui/components/footer.tsx +60 -0
- package/src/tui/components/header.tsx +21 -0
- package/src/tui/components/model-picker.tsx +142 -0
- package/src/tui/components/prompt-input.tsx +432 -0
- package/src/tui/components/resume-session-picker.tsx +273 -0
- package/src/tui/components/timeline.tsx +121 -0
- package/src/tui/context-format.ts +24 -0
- package/src/tui/event-store.ts +865 -0
- package/src/tui/git-branch.ts +23 -0
- package/src/tui/line-editor.ts +157 -0
- package/src/tui/prompt-history.ts +94 -0
- package/src/tui/slash-commands.ts +126 -0
- package/src/tui/tui-projection-policy.ts +35 -0
- package/src/tui/tui-projection-store.ts +123 -0
- package/src/tui/tui-session-controller.ts +170 -0
- package/src/tui/view-file.ts +122 -0
|
@@ -0,0 +1,3067 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import {
|
|
3
|
+
chmod,
|
|
4
|
+
lstat,
|
|
5
|
+
mkdir,
|
|
6
|
+
open,
|
|
7
|
+
readdir,
|
|
8
|
+
realpath,
|
|
9
|
+
rename,
|
|
10
|
+
rmdir,
|
|
11
|
+
unlink,
|
|
12
|
+
} from "node:fs/promises";
|
|
13
|
+
import { randomUUID } from "node:crypto";
|
|
14
|
+
import { Database } from "bun:sqlite";
|
|
15
|
+
import type {
|
|
16
|
+
ContextRevisionId,
|
|
17
|
+
IterationId,
|
|
18
|
+
MessageId,
|
|
19
|
+
ProtocolFrameId,
|
|
20
|
+
RuntimeIdFactory,
|
|
21
|
+
SessionId,
|
|
22
|
+
ToolCallId,
|
|
23
|
+
TurnId,
|
|
24
|
+
} from "../ids/runtime-id";
|
|
25
|
+
import type {
|
|
26
|
+
ModelContextBudget,
|
|
27
|
+
ModelContextProfile,
|
|
28
|
+
} from "../model/model-context-profile";
|
|
29
|
+
import type { ToolRawResult } from "../tools/types";
|
|
30
|
+
import { sha256, stableJsonStringify } from "../model/model-request-preflight";
|
|
31
|
+
import {
|
|
32
|
+
ContextProtocolError,
|
|
33
|
+
ContextProtocolValidator,
|
|
34
|
+
} from "../context/context-protocol-validator";
|
|
35
|
+
import {
|
|
36
|
+
activeOverrideManifestHash,
|
|
37
|
+
canonicalSequenceHash,
|
|
38
|
+
renderedMessageHash,
|
|
39
|
+
} from "../context/compiled-context-hash";
|
|
40
|
+
import {
|
|
41
|
+
ContextRevisionCompiler,
|
|
42
|
+
createInitialContextRevision,
|
|
43
|
+
} from "../context/context-revision-compiler";
|
|
44
|
+
import {
|
|
45
|
+
ContextSwapRenderer,
|
|
46
|
+
SWAP_OBSERVATION_FORMAT,
|
|
47
|
+
} from "../context/context-swap-renderer";
|
|
48
|
+
import {
|
|
49
|
+
TOOL_OBSERVATION_FORMAT,
|
|
50
|
+
contentHash,
|
|
51
|
+
immutableCanonicalClone,
|
|
52
|
+
immutableRecord,
|
|
53
|
+
interruptedCompletionInputs,
|
|
54
|
+
observationForCompletion,
|
|
55
|
+
type CanonicalMessageRecord,
|
|
56
|
+
type ProtocolContextView,
|
|
57
|
+
type ProtocolFrame,
|
|
58
|
+
type ToolCompletion,
|
|
59
|
+
type ToolResultRecord,
|
|
60
|
+
} from "../context/protocol-frame";
|
|
61
|
+
import type {
|
|
62
|
+
StoredContextRevisionV5,
|
|
63
|
+
StoredContextSnapshotV5,
|
|
64
|
+
StoredSwapOverrideV5,
|
|
65
|
+
SwapOverride,
|
|
66
|
+
} from "../context/context-revision";
|
|
67
|
+
import type { IterationIdentity, ToolCall } from "../agent/types";
|
|
68
|
+
import type { MeasuredContextAnchor } from "../agent/context-meter";
|
|
69
|
+
import type { ProjectInstructionManifest } from "../instructions/project-instructions";
|
|
70
|
+
import {
|
|
71
|
+
InMemorySessionLedger,
|
|
72
|
+
type LedgerMutation,
|
|
73
|
+
type SessionLedgerCommitter,
|
|
74
|
+
} from "../agent/session-ledger";
|
|
75
|
+
import { SessionError, sessionOpenError, sessionWriteError } from "./session-errors";
|
|
76
|
+
import {
|
|
77
|
+
createSessionHistoryReader,
|
|
78
|
+
type SessionHistoryReader,
|
|
79
|
+
} from "./session-history-reader";
|
|
80
|
+
import { SessionLease } from "./session-lock";
|
|
81
|
+
import {
|
|
82
|
+
SESSION_SCHEMA_V5_FINGERPRINT,
|
|
83
|
+
SESSION_SCHEMA_VERSION,
|
|
84
|
+
configureWritableDatabase,
|
|
85
|
+
createSessionSchema,
|
|
86
|
+
rebuildRecallIndex,
|
|
87
|
+
verifyRecallIndex,
|
|
88
|
+
verifySessionSchema,
|
|
89
|
+
verifySqliteIntegrity,
|
|
90
|
+
} from "./session-schema";
|
|
91
|
+
|
|
92
|
+
export type RuntimeContractV1 = {
|
|
93
|
+
version: 1;
|
|
94
|
+
modelName: string;
|
|
95
|
+
profileName?: string;
|
|
96
|
+
includeReasoningContent: boolean;
|
|
97
|
+
contextProfile: ModelContextProfile;
|
|
98
|
+
contextBudget: ModelContextBudget;
|
|
99
|
+
systemPromptSha256: string;
|
|
100
|
+
toolSchemaSha256: string;
|
|
101
|
+
requestConfigSha256: string;
|
|
102
|
+
observationFormat: typeof TOOL_OBSERVATION_FORMAT;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export type StoredSessionMetaV5 = {
|
|
106
|
+
schemaVersion: 5;
|
|
107
|
+
schemaFingerprint: string;
|
|
108
|
+
initializationState: "creating" | "ready";
|
|
109
|
+
sessionId: SessionId;
|
|
110
|
+
workspaceRoot: string;
|
|
111
|
+
modelName: string;
|
|
112
|
+
systemPromptSha256: string;
|
|
113
|
+
projectInstruction?: ProjectInstructionManifest;
|
|
114
|
+
toolSchemaSha256: string | null;
|
|
115
|
+
runtimeContractJson: string | null;
|
|
116
|
+
runtimeContractSha256: string | null;
|
|
117
|
+
activeRevisionId: ContextRevisionId;
|
|
118
|
+
nextTurnNumber: number;
|
|
119
|
+
nextEventSequence: number;
|
|
120
|
+
openCount: number;
|
|
121
|
+
createdAt: string;
|
|
122
|
+
updatedAt: string;
|
|
123
|
+
lastOpenedAt: string;
|
|
124
|
+
lastClosedAt: string | null;
|
|
125
|
+
lastCloseReason:
|
|
126
|
+
| "oneshot_complete"
|
|
127
|
+
| "tui_exit"
|
|
128
|
+
| "session_switch"
|
|
129
|
+
| "runner_failed"
|
|
130
|
+
| "initialization_failed"
|
|
131
|
+
| null;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
export type SessionCloseReason = NonNullable<StoredSessionMetaV5["lastCloseReason"]>;
|
|
135
|
+
|
|
136
|
+
export type SessionRecoveryResult = {
|
|
137
|
+
recoveredTurnId?: TurnId;
|
|
138
|
+
recoveredFrameId?: ProtocolFrameId;
|
|
139
|
+
syntheticCompletionCount: number;
|
|
140
|
+
recallIndexRebuilt: boolean;
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
type StoredMeasuredContextState = {
|
|
144
|
+
revisionId: ContextRevisionId;
|
|
145
|
+
anchor: MeasuredContextAnchor;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
export type CommitSwapRevisionInput = {
|
|
149
|
+
revisionId: ContextRevisionId;
|
|
150
|
+
expectedBaseRevisionId: ContextRevisionId;
|
|
151
|
+
expectedBaseRevisionNumber: number;
|
|
152
|
+
expectedCanonicalThroughOrdinal: number;
|
|
153
|
+
expectedBaseOverrideManifestSha256: string;
|
|
154
|
+
policyVersion: "swap-only-v1";
|
|
155
|
+
rendererFormat: typeof SWAP_OBSERVATION_FORMAT;
|
|
156
|
+
planHash: string;
|
|
157
|
+
addedOverrides: readonly SwapOverride[];
|
|
158
|
+
nextOverrideManifestSha256: string;
|
|
159
|
+
canonicalSequenceSha256: string;
|
|
160
|
+
renderedMessageSha256: string;
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
export type CommitSwapRevisionFaultStage =
|
|
164
|
+
| "before_revision_insert"
|
|
165
|
+
| "after_revision_insert"
|
|
166
|
+
| "after_first_override_insert"
|
|
167
|
+
| "after_overrides_insert"
|
|
168
|
+
| "after_measurement_delete"
|
|
169
|
+
| "after_active_update";
|
|
170
|
+
|
|
171
|
+
export type CommitSwapRevisionOptions = {
|
|
172
|
+
faultInjector?: (stage: CommitSwapRevisionFaultStage) => void;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
export type CreateNewSessionStoreInput = {
|
|
176
|
+
workspaceRoot: string;
|
|
177
|
+
sessionId: SessionId;
|
|
178
|
+
modelName: string;
|
|
179
|
+
systemPrompt: string;
|
|
180
|
+
projectInstruction?: ProjectInstructionManifest;
|
|
181
|
+
idFactory: RuntimeIdFactory;
|
|
182
|
+
clock?: () => string;
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
export type OpenSessionStoreInput = {
|
|
186
|
+
workspaceRoot: string;
|
|
187
|
+
sessionId: SessionId;
|
|
188
|
+
clock?: () => string;
|
|
189
|
+
allowIncomplete?: boolean;
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
export class SessionStore implements SessionLedgerCommitter {
|
|
193
|
+
readonly sessionId: SessionId;
|
|
194
|
+
readonly workspaceRoot: string;
|
|
195
|
+
readonly sessionDirectory: string;
|
|
196
|
+
readonly databasePath: string;
|
|
197
|
+
private closed = false;
|
|
198
|
+
private recallIndexRebuilt = false;
|
|
199
|
+
private readonly validator = new ContextProtocolValidator();
|
|
200
|
+
private readonly revisionCompiler = new ContextRevisionCompiler();
|
|
201
|
+
private readonly swapRenderer = new ContextSwapRenderer();
|
|
202
|
+
|
|
203
|
+
private constructor(
|
|
204
|
+
private readonly database: Database,
|
|
205
|
+
private readonly lease: SessionLease,
|
|
206
|
+
input: {
|
|
207
|
+
sessionId: SessionId;
|
|
208
|
+
workspaceRoot: string;
|
|
209
|
+
sessionDirectory: string;
|
|
210
|
+
databasePath: string;
|
|
211
|
+
clock: () => string;
|
|
212
|
+
},
|
|
213
|
+
) {
|
|
214
|
+
this.sessionId = input.sessionId;
|
|
215
|
+
this.workspaceRoot = input.workspaceRoot;
|
|
216
|
+
this.sessionDirectory = input.sessionDirectory;
|
|
217
|
+
this.databasePath = input.databasePath;
|
|
218
|
+
this.clock = input.clock;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private readonly clock: () => string;
|
|
222
|
+
|
|
223
|
+
static async createNew(input: CreateNewSessionStoreInput): Promise<SessionStore> {
|
|
224
|
+
const clock = input.clock ?? (() => new Date().toISOString());
|
|
225
|
+
const workspaceRoot = await canonicalWorkspaceRoot(input.workspaceRoot);
|
|
226
|
+
const sessionsRoot = await ensureSessionsRoot(workspaceRoot);
|
|
227
|
+
const sessionDirectory = safeSessionDirectory(sessionsRoot, input.sessionId);
|
|
228
|
+
try {
|
|
229
|
+
await mkdir(sessionDirectory, { mode: 0o700 });
|
|
230
|
+
} catch (error) {
|
|
231
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") {
|
|
232
|
+
throw new SessionError(
|
|
233
|
+
"SESSION_ALREADY_EXISTS",
|
|
234
|
+
"create_session",
|
|
235
|
+
`Session directory already exists: ${sessionDirectory}.`,
|
|
236
|
+
{ sessionId: input.sessionId, cause: error },
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
throw error;
|
|
240
|
+
}
|
|
241
|
+
await chmod(sessionDirectory, 0o700);
|
|
242
|
+
|
|
243
|
+
let lease: SessionLease | undefined;
|
|
244
|
+
let database: Database | undefined;
|
|
245
|
+
const databasePath = path.join(sessionDirectory, "session.sqlite");
|
|
246
|
+
try {
|
|
247
|
+
lease = await SessionLease.acquire({
|
|
248
|
+
sessionDirectory,
|
|
249
|
+
sessionId: input.sessionId,
|
|
250
|
+
});
|
|
251
|
+
const handle = await open(databasePath, "wx", 0o600);
|
|
252
|
+
await handle.close();
|
|
253
|
+
database = openWritableDatabase(databasePath);
|
|
254
|
+
createSessionSchema(database);
|
|
255
|
+
verifySessionSchema(database, input.sessionId);
|
|
256
|
+
|
|
257
|
+
const revisionId = input.idFactory.createContextRevisionId();
|
|
258
|
+
const initialLedger = new InMemorySessionLedger({
|
|
259
|
+
sessionId: input.sessionId,
|
|
260
|
+
systemPrompt: input.systemPrompt,
|
|
261
|
+
idFactory: input.idFactory,
|
|
262
|
+
initialRevisionId: revisionId,
|
|
263
|
+
clock,
|
|
264
|
+
});
|
|
265
|
+
const initialView = initialLedger.snapshot({ fullIntegrity: true });
|
|
266
|
+
const createdAt = clock();
|
|
267
|
+
const initialRevision = createInitialContextRevision({
|
|
268
|
+
revisionId,
|
|
269
|
+
canonical: initialView,
|
|
270
|
+
createdAt,
|
|
271
|
+
});
|
|
272
|
+
runTransaction(database, () => {
|
|
273
|
+
database!
|
|
274
|
+
.query(
|
|
275
|
+
`INSERT INTO session_meta (
|
|
276
|
+
singleton, schema_version, schema_fingerprint, initialization_state,
|
|
277
|
+
session_id, workspace_root, model_name, system_prompt_sha256,
|
|
278
|
+
project_instruction_file, project_instruction_byte_length,
|
|
279
|
+
project_instruction_sha256,
|
|
280
|
+
tool_schema_sha256, runtime_contract_json, runtime_contract_sha256,
|
|
281
|
+
active_revision_id, next_turn_number, next_event_sequence, open_count,
|
|
282
|
+
created_at, updated_at, last_opened_at, last_closed_at, last_close_reason
|
|
283
|
+
) VALUES (1, ?, ?, 'creating', ?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, ?, 1, 1, 1, ?, ?, ?, NULL, NULL)`,
|
|
284
|
+
)
|
|
285
|
+
.run(
|
|
286
|
+
SESSION_SCHEMA_VERSION,
|
|
287
|
+
SESSION_SCHEMA_V5_FINGERPRINT,
|
|
288
|
+
input.sessionId,
|
|
289
|
+
workspaceRoot,
|
|
290
|
+
input.modelName,
|
|
291
|
+
sha256(input.systemPrompt),
|
|
292
|
+
input.projectInstruction?.path ?? null,
|
|
293
|
+
input.projectInstruction?.byteLength ?? null,
|
|
294
|
+
input.projectInstruction?.sha256 ?? null,
|
|
295
|
+
revisionId,
|
|
296
|
+
createdAt,
|
|
297
|
+
createdAt,
|
|
298
|
+
createdAt,
|
|
299
|
+
);
|
|
300
|
+
insertFrame(database!, requireItem(initialView.frames, 0, "system frame"));
|
|
301
|
+
insertMessage(
|
|
302
|
+
database!,
|
|
303
|
+
requireItem(initialView.messages, 0, "system message"),
|
|
304
|
+
);
|
|
305
|
+
database!
|
|
306
|
+
.query(
|
|
307
|
+
`INSERT INTO context_revisions (
|
|
308
|
+
revision_id, session_id, revision_number, parent_revision_id, kind,
|
|
309
|
+
keep_from_ordinal, source_through_ordinal, added_override_count,
|
|
310
|
+
total_override_count, override_manifest_sha256,
|
|
311
|
+
canonical_sequence_sha256, rendered_message_sha256, policy_version,
|
|
312
|
+
renderer_format, plan_sha256, created_at
|
|
313
|
+
) VALUES (?, ?, 1, NULL, 'initial_full', 1, 1, 0, 0, ?, ?, ?, NULL, NULL, NULL, ?)`,
|
|
314
|
+
)
|
|
315
|
+
.run(
|
|
316
|
+
revisionId,
|
|
317
|
+
input.sessionId,
|
|
318
|
+
initialRevision.overrideManifestSha256,
|
|
319
|
+
initialRevision.canonicalSequenceSha256,
|
|
320
|
+
initialRevision.renderedMessageSha256,
|
|
321
|
+
createdAt,
|
|
322
|
+
);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
const store = new SessionStore(database, lease, {
|
|
326
|
+
sessionId: input.sessionId,
|
|
327
|
+
workspaceRoot,
|
|
328
|
+
sessionDirectory,
|
|
329
|
+
databasePath,
|
|
330
|
+
clock,
|
|
331
|
+
});
|
|
332
|
+
await store.correctDatabaseModes();
|
|
333
|
+
store.validateAll({ allowOpenTail: false });
|
|
334
|
+
verifyRecallIndex(database, input.sessionId);
|
|
335
|
+
return store;
|
|
336
|
+
} catch (error) {
|
|
337
|
+
database?.close();
|
|
338
|
+
if (lease !== undefined) {
|
|
339
|
+
await lease.release().catch(() => undefined);
|
|
340
|
+
}
|
|
341
|
+
await removeKnownInitializationFiles(sessionDirectory);
|
|
342
|
+
throw error;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
static async openExisting(input: OpenSessionStoreInput): Promise<SessionStore> {
|
|
347
|
+
const clock = input.clock ?? (() => new Date().toISOString());
|
|
348
|
+
const workspaceRoot = await canonicalWorkspaceRoot(input.workspaceRoot);
|
|
349
|
+
const sessionsRoot = path.join(workspaceRoot, ".tinker", "sessions");
|
|
350
|
+
await validateSessionsRoot(sessionsRoot, input.sessionId);
|
|
351
|
+
const sessionDirectory = safeSessionDirectory(sessionsRoot, input.sessionId);
|
|
352
|
+
await validateSecureDirectory(sessionDirectory, input.sessionId);
|
|
353
|
+
const databasePath = path.join(sessionDirectory, "session.sqlite");
|
|
354
|
+
await validateSecureFile(databasePath, input.sessionId);
|
|
355
|
+
for (const optionalFile of [
|
|
356
|
+
`${databasePath}-wal`,
|
|
357
|
+
`${databasePath}-shm`,
|
|
358
|
+
path.join(sessionDirectory, "events.jsonl"),
|
|
359
|
+
path.join(sessionDirectory, "observations.md"),
|
|
360
|
+
]) {
|
|
361
|
+
await validateSecureOptionalFile(optionalFile, input.sessionId);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const lease = await SessionLease.acquire({
|
|
365
|
+
sessionDirectory,
|
|
366
|
+
sessionId: input.sessionId,
|
|
367
|
+
});
|
|
368
|
+
let database: Database | undefined;
|
|
369
|
+
try {
|
|
370
|
+
database = openWritableDatabase(databasePath);
|
|
371
|
+
verifySessionSchema(database, input.sessionId);
|
|
372
|
+
verifySqliteIntegrity(database, input.sessionId);
|
|
373
|
+
const store = new SessionStore(database, lease, {
|
|
374
|
+
sessionId: input.sessionId,
|
|
375
|
+
workspaceRoot,
|
|
376
|
+
sessionDirectory,
|
|
377
|
+
databasePath,
|
|
378
|
+
clock,
|
|
379
|
+
});
|
|
380
|
+
const meta = store.readMeta();
|
|
381
|
+
if (meta.initializationState !== "ready" && input.allowIncomplete !== true) {
|
|
382
|
+
throw new SessionError(
|
|
383
|
+
"SESSION_INTEGRITY_FAILED",
|
|
384
|
+
"open_session",
|
|
385
|
+
`Session ${input.sessionId} did not finish initialization.`,
|
|
386
|
+
{ sessionId: input.sessionId },
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
if (meta.workspaceRoot !== workspaceRoot) {
|
|
390
|
+
throw new SessionError(
|
|
391
|
+
"SESSION_WORKSPACE_MISMATCH",
|
|
392
|
+
"open_session",
|
|
393
|
+
`Session workspace is ${meta.workspaceRoot}, current workspace is ${workspaceRoot}.`,
|
|
394
|
+
{ sessionId: input.sessionId },
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
store.validateAll({ allowOpenTail: true });
|
|
398
|
+
try {
|
|
399
|
+
verifyRecallIndex(database, input.sessionId);
|
|
400
|
+
} catch (error) {
|
|
401
|
+
if (
|
|
402
|
+
!(error instanceof SessionError) ||
|
|
403
|
+
error.code !== "SESSION_RECALL_INDEX_INVALID"
|
|
404
|
+
) {
|
|
405
|
+
throw error;
|
|
406
|
+
}
|
|
407
|
+
try {
|
|
408
|
+
runTransaction(database, () =>
|
|
409
|
+
rebuildRecallIndex(database!, input.sessionId),
|
|
410
|
+
);
|
|
411
|
+
verifyRecallIndex(database, input.sessionId);
|
|
412
|
+
} catch (rebuildError) {
|
|
413
|
+
if (
|
|
414
|
+
rebuildError instanceof SessionError &&
|
|
415
|
+
rebuildError.code === "SESSION_RECALL_INDEX_INVALID"
|
|
416
|
+
) {
|
|
417
|
+
throw rebuildError;
|
|
418
|
+
}
|
|
419
|
+
throw new SessionError(
|
|
420
|
+
"SESSION_RECALL_INDEX_INVALID",
|
|
421
|
+
"rebuild_recall_index",
|
|
422
|
+
"Session Recall index rebuild transaction failed.",
|
|
423
|
+
{ sessionId: input.sessionId, cause: rebuildError },
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
store.recallIndexRebuilt = true;
|
|
427
|
+
}
|
|
428
|
+
await store.correctDatabaseModes();
|
|
429
|
+
return store;
|
|
430
|
+
} catch (error) {
|
|
431
|
+
database?.close();
|
|
432
|
+
await lease.release().catch(() => undefined);
|
|
433
|
+
throw sessionOpenError("open_session", input.sessionId, error);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
commit(mutation: LedgerMutation): void {
|
|
438
|
+
this.requireOpen();
|
|
439
|
+
const now = this.clock();
|
|
440
|
+
try {
|
|
441
|
+
runTransaction(this.database, () => {
|
|
442
|
+
switch (mutation.kind) {
|
|
443
|
+
case "begin_turn":
|
|
444
|
+
this.commitBeginTurn(mutation, now);
|
|
445
|
+
break;
|
|
446
|
+
case "append_assistant":
|
|
447
|
+
this.commitAssistant(mutation, now);
|
|
448
|
+
break;
|
|
449
|
+
case "commit_tool_completions":
|
|
450
|
+
this.commitToolCompletions(mutation, now);
|
|
451
|
+
break;
|
|
452
|
+
case "finish_turn":
|
|
453
|
+
this.commitFinishTurn(mutation, now);
|
|
454
|
+
break;
|
|
455
|
+
}
|
|
456
|
+
});
|
|
457
|
+
} catch (error) {
|
|
458
|
+
throw sessionWriteError(mutation.kind, this.sessionId, error);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
beginIteration(iteration: IterationIdentity): void {
|
|
463
|
+
this.requireOpen();
|
|
464
|
+
const now = this.clock();
|
|
465
|
+
try {
|
|
466
|
+
runTransaction(this.database, () => {
|
|
467
|
+
const turn = this.requireTurnRow(iteration.turnId);
|
|
468
|
+
if (
|
|
469
|
+
turn.status !== "open" ||
|
|
470
|
+
numberFromSql(turn.next_iteration_number, "next_iteration_number") !==
|
|
471
|
+
iteration.iterationNumber
|
|
472
|
+
) {
|
|
473
|
+
throw new Error(
|
|
474
|
+
`Iteration ${iteration.iterationId} does not match the open turn counter.`,
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
this.database
|
|
478
|
+
.query(
|
|
479
|
+
`INSERT INTO iterations (
|
|
480
|
+
session_id, turn_id, iteration_id, iteration_number, outcome,
|
|
481
|
+
next_tool_call_number, started_at, finished_at
|
|
482
|
+
) VALUES (?, ?, ?, ?, 'open', 1, ?, NULL)`,
|
|
483
|
+
)
|
|
484
|
+
.run(
|
|
485
|
+
this.sessionId,
|
|
486
|
+
iteration.turnId,
|
|
487
|
+
iteration.iterationId,
|
|
488
|
+
iteration.iterationNumber,
|
|
489
|
+
now,
|
|
490
|
+
);
|
|
491
|
+
const updated = this.database
|
|
492
|
+
.query(
|
|
493
|
+
`UPDATE turns SET next_iteration_number = ?, last_iteration_id = ?
|
|
494
|
+
WHERE turn_id = ? AND status = 'open' AND next_iteration_number = ?`,
|
|
495
|
+
)
|
|
496
|
+
.run(
|
|
497
|
+
iteration.iterationNumber + 1,
|
|
498
|
+
iteration.iterationId,
|
|
499
|
+
iteration.turnId,
|
|
500
|
+
iteration.iterationNumber,
|
|
501
|
+
);
|
|
502
|
+
requireSingleChange(
|
|
503
|
+
this.database,
|
|
504
|
+
updated.changes,
|
|
505
|
+
"advance iteration counter",
|
|
506
|
+
);
|
|
507
|
+
});
|
|
508
|
+
} catch (error) {
|
|
509
|
+
throw sessionWriteError("begin_iteration", this.sessionId, error);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
finishIterationForContinuation(iteration: IterationIdentity): void {
|
|
514
|
+
this.requireOpen();
|
|
515
|
+
const now = this.clock();
|
|
516
|
+
try {
|
|
517
|
+
runTransaction(this.database, () => {
|
|
518
|
+
const updated = this.database
|
|
519
|
+
.query(
|
|
520
|
+
`UPDATE iterations SET outcome = 'continue', finished_at = ?
|
|
521
|
+
WHERE iteration_id = ? AND turn_id = ? AND outcome = 'open'`,
|
|
522
|
+
)
|
|
523
|
+
.run(now, iteration.iterationId, iteration.turnId);
|
|
524
|
+
requireSingleChange(
|
|
525
|
+
this.database,
|
|
526
|
+
updated.changes,
|
|
527
|
+
"finish continuing iteration",
|
|
528
|
+
);
|
|
529
|
+
this.touch(now);
|
|
530
|
+
});
|
|
531
|
+
} catch (error) {
|
|
532
|
+
throw sessionWriteError("finish_iteration", this.sessionId, error);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
allocateEventSequence(): number {
|
|
537
|
+
this.requireOpen();
|
|
538
|
+
const now = this.clock();
|
|
539
|
+
try {
|
|
540
|
+
return runTransaction(this.database, () => {
|
|
541
|
+
const meta = this.readMeta();
|
|
542
|
+
const sequence = meta.nextEventSequence;
|
|
543
|
+
const updated = this.database
|
|
544
|
+
.query(
|
|
545
|
+
`UPDATE session_meta SET next_event_sequence = ?, updated_at = ?
|
|
546
|
+
WHERE singleton = 1 AND next_event_sequence = ?`,
|
|
547
|
+
)
|
|
548
|
+
.run(sequence + 1, now, sequence);
|
|
549
|
+
requireSingleChange(this.database, updated.changes, "advance event sequence");
|
|
550
|
+
return sequence;
|
|
551
|
+
});
|
|
552
|
+
} catch (error) {
|
|
553
|
+
throw sessionWriteError("allocate_event_sequence", this.sessionId, error);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
finalizeRuntimeContract(contract: RuntimeContractV1): void {
|
|
558
|
+
this.requireOpen();
|
|
559
|
+
const json = stableJsonStringify(contract);
|
|
560
|
+
const contractSha256 = sha256(json);
|
|
561
|
+
const now = this.clock();
|
|
562
|
+
try {
|
|
563
|
+
runTransaction(this.database, () => {
|
|
564
|
+
const updated = this.database
|
|
565
|
+
.query(
|
|
566
|
+
`UPDATE session_meta
|
|
567
|
+
SET initialization_state = 'ready', tool_schema_sha256 = ?,
|
|
568
|
+
runtime_contract_json = ?, runtime_contract_sha256 = ?, updated_at = ?
|
|
569
|
+
WHERE singleton = 1 AND initialization_state = 'creating'
|
|
570
|
+
AND runtime_contract_json IS NULL`,
|
|
571
|
+
)
|
|
572
|
+
.run(contract.toolSchemaSha256, json, contractSha256, now);
|
|
573
|
+
requireSingleChange(
|
|
574
|
+
this.database,
|
|
575
|
+
updated.changes,
|
|
576
|
+
"finalize runtime contract",
|
|
577
|
+
);
|
|
578
|
+
});
|
|
579
|
+
} catch (error) {
|
|
580
|
+
throw sessionWriteError("finalize_runtime_contract", this.sessionId, error);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
assertRuntimeContract(contract: RuntimeContractV1): void {
|
|
585
|
+
const meta = this.readMeta();
|
|
586
|
+
const current = stableJsonStringify(contract);
|
|
587
|
+
const currentHash = sha256(current);
|
|
588
|
+
if (
|
|
589
|
+
meta.runtimeContractJson !== current ||
|
|
590
|
+
meta.runtimeContractSha256 !== currentHash
|
|
591
|
+
) {
|
|
592
|
+
const changed = runtimeContractDifferences(meta.runtimeContractJson, contract);
|
|
593
|
+
throw new SessionError(
|
|
594
|
+
"SESSION_RUNTIME_MISMATCH",
|
|
595
|
+
"compare_runtime_contract",
|
|
596
|
+
`Session runtime contract changed: ${changed.join(", ") || "stored contract is invalid"}.`,
|
|
597
|
+
{ sessionId: this.sessionId },
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
writeMeasuredContextAnchor(anchor: MeasuredContextAnchor): void {
|
|
603
|
+
this.requireOpen();
|
|
604
|
+
assertMeasuredContextAnchor(anchor);
|
|
605
|
+
const revisionId = this.readMeta().activeRevisionId;
|
|
606
|
+
const now = this.clock();
|
|
607
|
+
try {
|
|
608
|
+
runTransaction(this.database, () => {
|
|
609
|
+
const written = this.database
|
|
610
|
+
.query(
|
|
611
|
+
`INSERT INTO context_measurement_state (
|
|
612
|
+
singleton, session_id, revision_id, total_tokens, prompt_tokens,
|
|
613
|
+
completion_tokens, segment_count, prefix_hash, request_config_hash,
|
|
614
|
+
tool_schema_hash, updated_at
|
|
615
|
+
) VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
616
|
+
ON CONFLICT(singleton) DO UPDATE SET
|
|
617
|
+
revision_id = excluded.revision_id,
|
|
618
|
+
total_tokens = excluded.total_tokens,
|
|
619
|
+
prompt_tokens = excluded.prompt_tokens,
|
|
620
|
+
completion_tokens = excluded.completion_tokens,
|
|
621
|
+
segment_count = excluded.segment_count,
|
|
622
|
+
prefix_hash = excluded.prefix_hash,
|
|
623
|
+
request_config_hash = excluded.request_config_hash,
|
|
624
|
+
tool_schema_hash = excluded.tool_schema_hash,
|
|
625
|
+
updated_at = excluded.updated_at
|
|
626
|
+
WHERE context_measurement_state.session_id = excluded.session_id`,
|
|
627
|
+
)
|
|
628
|
+
.run(
|
|
629
|
+
this.sessionId,
|
|
630
|
+
revisionId,
|
|
631
|
+
anchor.totalTokens,
|
|
632
|
+
anchor.promptTokens,
|
|
633
|
+
anchor.completionTokens,
|
|
634
|
+
anchor.segmentCount,
|
|
635
|
+
anchor.prefixHash,
|
|
636
|
+
anchor.requestConfigHash,
|
|
637
|
+
anchor.toolSchemaHash,
|
|
638
|
+
now,
|
|
639
|
+
);
|
|
640
|
+
requireSingleChange(
|
|
641
|
+
this.database,
|
|
642
|
+
written.changes,
|
|
643
|
+
"write measured context anchor",
|
|
644
|
+
);
|
|
645
|
+
this.touch(now);
|
|
646
|
+
});
|
|
647
|
+
} catch (error) {
|
|
648
|
+
throw sessionWriteError("write_context_measurement", this.sessionId, error);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
readActiveMeasuredContextAnchor(): MeasuredContextAnchor | undefined {
|
|
653
|
+
this.requireOpen();
|
|
654
|
+
const state = this.loadMeasuredContextState();
|
|
655
|
+
if (state === undefined) {
|
|
656
|
+
return undefined;
|
|
657
|
+
}
|
|
658
|
+
if (state.revisionId !== this.readMeta().activeRevisionId) {
|
|
659
|
+
return undefined;
|
|
660
|
+
}
|
|
661
|
+
return state.anchor;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
assertContextRevisionIdle(): void {
|
|
665
|
+
this.requireOpen();
|
|
666
|
+
const row = this.database
|
|
667
|
+
.query(
|
|
668
|
+
`SELECT
|
|
669
|
+
(SELECT COUNT(*) FROM turns WHERE status = 'open') AS open_turns,
|
|
670
|
+
(SELECT COUNT(*) FROM iterations WHERE outcome = 'open') AS open_iterations,
|
|
671
|
+
(SELECT COUNT(*) FROM protocol_frames WHERE state = 'open') AS open_frames`,
|
|
672
|
+
)
|
|
673
|
+
.get() as Record<string, unknown> | null;
|
|
674
|
+
if (
|
|
675
|
+
row === null ||
|
|
676
|
+
numberFromSql(row.open_turns, "open_turns") !== 0 ||
|
|
677
|
+
numberFromSql(row.open_iterations, "open_iterations") !== 0 ||
|
|
678
|
+
numberFromSql(row.open_frames, "open_frames") !== 0
|
|
679
|
+
) {
|
|
680
|
+
throw new SessionError(
|
|
681
|
+
"SESSION_INTEGRITY_FAILED",
|
|
682
|
+
"assert_context_revision_idle",
|
|
683
|
+
"Context revision requires a fully idle session store.",
|
|
684
|
+
{ sessionId: this.sessionId },
|
|
685
|
+
);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
commitSwapRevision(
|
|
690
|
+
input: CommitSwapRevisionInput,
|
|
691
|
+
options: CommitSwapRevisionOptions = {},
|
|
692
|
+
): Extract<StoredContextRevisionV5, { kind: "swap_only" }> {
|
|
693
|
+
this.requireOpen();
|
|
694
|
+
assertCommitSwapRevisionInput(input);
|
|
695
|
+
const now = this.clock();
|
|
696
|
+
try {
|
|
697
|
+
return runTransaction(this.database, () => {
|
|
698
|
+
const snapshot = this.loadContextSnapshot();
|
|
699
|
+
const baseRevision = snapshot.revision;
|
|
700
|
+
if (
|
|
701
|
+
baseRevision.revisionId !== input.expectedBaseRevisionId ||
|
|
702
|
+
baseRevision.revisionNumber !== input.expectedBaseRevisionNumber ||
|
|
703
|
+
snapshot.canonical.messages.length !==
|
|
704
|
+
input.expectedCanonicalThroughOrdinal ||
|
|
705
|
+
baseRevision.overrideManifestSha256 !==
|
|
706
|
+
input.expectedBaseOverrideManifestSha256
|
|
707
|
+
) {
|
|
708
|
+
throw new Error("Context revision commit base is stale.");
|
|
709
|
+
}
|
|
710
|
+
this.assertContextRevisionIdle();
|
|
711
|
+
|
|
712
|
+
const active = this.revisionCompiler.compileActive(snapshot);
|
|
713
|
+
const candidateOverrides = [
|
|
714
|
+
...snapshot.activeOverrides,
|
|
715
|
+
...input.addedOverrides,
|
|
716
|
+
];
|
|
717
|
+
if (
|
|
718
|
+
new Set(candidateOverrides.map((override) => override.messageId)).size !==
|
|
719
|
+
candidateOverrides.length ||
|
|
720
|
+
activeOverrideManifestHash(candidateOverrides) !==
|
|
721
|
+
input.nextOverrideManifestSha256
|
|
722
|
+
) {
|
|
723
|
+
throw new Error("Candidate override manifest is invalid.");
|
|
724
|
+
}
|
|
725
|
+
const candidate = this.revisionCompiler.compileProspective({
|
|
726
|
+
active,
|
|
727
|
+
canonical: snapshot.canonical,
|
|
728
|
+
activeOverrides: snapshot.activeOverrides,
|
|
729
|
+
addedOverrides: input.addedOverrides,
|
|
730
|
+
});
|
|
731
|
+
if (
|
|
732
|
+
canonicalSequenceHash(
|
|
733
|
+
snapshot.canonical,
|
|
734
|
+
input.expectedCanonicalThroughOrdinal,
|
|
735
|
+
) !== input.canonicalSequenceSha256 ||
|
|
736
|
+
renderedMessageHash(
|
|
737
|
+
candidate.entries,
|
|
738
|
+
input.expectedCanonicalThroughOrdinal,
|
|
739
|
+
) !== input.renderedMessageSha256
|
|
740
|
+
) {
|
|
741
|
+
throw new Error("Candidate context revision prefix hash is invalid.");
|
|
742
|
+
}
|
|
743
|
+
this.validateAddedOverrides(input.addedOverrides, snapshot.canonical);
|
|
744
|
+
|
|
745
|
+
const revisionNumber = baseRevision.revisionNumber + 1;
|
|
746
|
+
const totalOverrideCount =
|
|
747
|
+
baseRevision.totalOverrideCount + input.addedOverrides.length;
|
|
748
|
+
options.faultInjector?.("before_revision_insert");
|
|
749
|
+
this.database
|
|
750
|
+
.query(
|
|
751
|
+
`INSERT INTO context_revisions (
|
|
752
|
+
revision_id, session_id, revision_number, parent_revision_id, kind,
|
|
753
|
+
keep_from_ordinal, source_through_ordinal, added_override_count,
|
|
754
|
+
total_override_count, override_manifest_sha256,
|
|
755
|
+
canonical_sequence_sha256, rendered_message_sha256, policy_version,
|
|
756
|
+
renderer_format, plan_sha256, created_at
|
|
757
|
+
) VALUES (?, ?, ?, ?, 'swap_only', 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
758
|
+
)
|
|
759
|
+
.run(
|
|
760
|
+
input.revisionId,
|
|
761
|
+
this.sessionId,
|
|
762
|
+
revisionNumber,
|
|
763
|
+
baseRevision.revisionId,
|
|
764
|
+
input.expectedCanonicalThroughOrdinal,
|
|
765
|
+
input.addedOverrides.length,
|
|
766
|
+
totalOverrideCount,
|
|
767
|
+
input.nextOverrideManifestSha256,
|
|
768
|
+
input.canonicalSequenceSha256,
|
|
769
|
+
input.renderedMessageSha256,
|
|
770
|
+
input.policyVersion,
|
|
771
|
+
input.rendererFormat,
|
|
772
|
+
input.planHash,
|
|
773
|
+
now,
|
|
774
|
+
);
|
|
775
|
+
options.faultInjector?.("after_revision_insert");
|
|
776
|
+
|
|
777
|
+
for (let index = 0; index < input.addedOverrides.length; index += 1) {
|
|
778
|
+
const override = requireItem(
|
|
779
|
+
input.addedOverrides,
|
|
780
|
+
index,
|
|
781
|
+
"added context override",
|
|
782
|
+
);
|
|
783
|
+
this.database
|
|
784
|
+
.query(
|
|
785
|
+
`INSERT INTO context_overrides (
|
|
786
|
+
introduced_revision_id, session_id, message_id, frame_id, ordinal,
|
|
787
|
+
representation, renderer_format, source, original_content_sha256,
|
|
788
|
+
rendered_content, rendered_content_sha256, original_bytes,
|
|
789
|
+
rendered_bytes, byte_savings, created_at
|
|
790
|
+
) VALUES (?, ?, ?, ?, ?, 'swapped', ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
791
|
+
)
|
|
792
|
+
.run(
|
|
793
|
+
input.revisionId,
|
|
794
|
+
this.sessionId,
|
|
795
|
+
override.messageId,
|
|
796
|
+
override.frameId,
|
|
797
|
+
override.ordinal,
|
|
798
|
+
input.rendererFormat,
|
|
799
|
+
override.source,
|
|
800
|
+
override.originalContentSha256,
|
|
801
|
+
override.renderedContent,
|
|
802
|
+
override.renderedContentSha256,
|
|
803
|
+
override.originalBytes,
|
|
804
|
+
override.renderedBytes,
|
|
805
|
+
override.byteSavings,
|
|
806
|
+
now,
|
|
807
|
+
);
|
|
808
|
+
if (index === 0) {
|
|
809
|
+
options.faultInjector?.("after_first_override_insert");
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
options.faultInjector?.("after_overrides_insert");
|
|
813
|
+
|
|
814
|
+
const storedCandidateOverrides = this.database
|
|
815
|
+
.query(
|
|
816
|
+
`SELECT co.* FROM context_overrides co
|
|
817
|
+
JOIN context_revisions cr
|
|
818
|
+
ON cr.revision_id = co.introduced_revision_id
|
|
819
|
+
WHERE cr.revision_number <= ?
|
|
820
|
+
ORDER BY co.ordinal`,
|
|
821
|
+
)
|
|
822
|
+
.all(revisionNumber)
|
|
823
|
+
.map(decodeStoredSwapOverride);
|
|
824
|
+
if (
|
|
825
|
+
storedCandidateOverrides.length !== totalOverrideCount ||
|
|
826
|
+
activeOverrideManifestHash(storedCandidateOverrides) !==
|
|
827
|
+
input.nextOverrideManifestSha256
|
|
828
|
+
) {
|
|
829
|
+
throw new Error("Stored candidate override readback is invalid.");
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
this.database.query("DELETE FROM context_measurement_state").run();
|
|
833
|
+
options.faultInjector?.("after_measurement_delete");
|
|
834
|
+
const switched = this.database
|
|
835
|
+
.query(
|
|
836
|
+
`UPDATE session_meta
|
|
837
|
+
SET active_revision_id = ?, updated_at = ?
|
|
838
|
+
WHERE singleton = 1 AND active_revision_id = ?`,
|
|
839
|
+
)
|
|
840
|
+
.run(input.revisionId, now, baseRevision.revisionId);
|
|
841
|
+
requireSingleChange(
|
|
842
|
+
this.database,
|
|
843
|
+
switched.changes,
|
|
844
|
+
"activate context revision",
|
|
845
|
+
);
|
|
846
|
+
options.faultInjector?.("after_active_update");
|
|
847
|
+
|
|
848
|
+
const readback = this.loadContextSnapshot();
|
|
849
|
+
if (
|
|
850
|
+
readback.revision.kind !== "swap_only" ||
|
|
851
|
+
readback.revision.revisionId !== input.revisionId ||
|
|
852
|
+
this.loadMeasuredContextState() !== undefined
|
|
853
|
+
) {
|
|
854
|
+
throw new Error("Committed context revision readback failed.");
|
|
855
|
+
}
|
|
856
|
+
return readback.revision;
|
|
857
|
+
});
|
|
858
|
+
} catch (error) {
|
|
859
|
+
if (this.readMeta().activeRevisionId !== input.expectedBaseRevisionId) {
|
|
860
|
+
throw new Error("Failed context revision transaction changed active state.", {
|
|
861
|
+
cause: error,
|
|
862
|
+
});
|
|
863
|
+
}
|
|
864
|
+
throw sessionWriteError("commit_context_revision", this.sessionId, error);
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
private validateAddedOverrides(
|
|
869
|
+
overrides: readonly SwapOverride[],
|
|
870
|
+
canonical: ProtocolContextView,
|
|
871
|
+
): void {
|
|
872
|
+
const messages = new Map(
|
|
873
|
+
canonical.messages.map((message) => [message.messageId, message] as const),
|
|
874
|
+
);
|
|
875
|
+
const results = new Map(
|
|
876
|
+
canonical.toolResults.map((result) => [result.toolMessageId, result] as const),
|
|
877
|
+
);
|
|
878
|
+
for (const override of overrides) {
|
|
879
|
+
const message = messages.get(override.messageId);
|
|
880
|
+
const result = results.get(override.messageId);
|
|
881
|
+
if (message?.role !== "tool" || result === undefined) {
|
|
882
|
+
throw new Error("Added context override does not target a tool result.");
|
|
883
|
+
}
|
|
884
|
+
const expected = this.swapRenderer.render({ message, result });
|
|
885
|
+
if (stableJsonStringify(expected) !== stableJsonStringify(override)) {
|
|
886
|
+
throw new Error("Added context override is not deterministic.");
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
markResumed(): number {
|
|
892
|
+
this.requireOpen();
|
|
893
|
+
const now = this.clock();
|
|
894
|
+
try {
|
|
895
|
+
return runTransaction(this.database, () => {
|
|
896
|
+
const meta = this.readMeta();
|
|
897
|
+
const next = meta.openCount + 1;
|
|
898
|
+
const updated = this.database
|
|
899
|
+
.query(
|
|
900
|
+
`UPDATE session_meta
|
|
901
|
+
SET open_count = ?, last_opened_at = ?, updated_at = ?,
|
|
902
|
+
last_closed_at = NULL, last_close_reason = NULL
|
|
903
|
+
WHERE singleton = 1 AND open_count = ?`,
|
|
904
|
+
)
|
|
905
|
+
.run(next, now, now, meta.openCount);
|
|
906
|
+
requireSingleChange(this.database, updated.changes, "increment open count");
|
|
907
|
+
return next;
|
|
908
|
+
});
|
|
909
|
+
} catch (error) {
|
|
910
|
+
throw sessionWriteError("mark_resumed", this.sessionId, error);
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
recoverInterruptedState(idFactory: RuntimeIdFactory): SessionRecoveryResult {
|
|
915
|
+
this.requireOpen();
|
|
916
|
+
const view = this.loadProtocolView();
|
|
917
|
+
const openTurns = this.database
|
|
918
|
+
.query("SELECT turn_id FROM turns WHERE status = 'open' ORDER BY turn_number")
|
|
919
|
+
.all() as Array<{ turn_id: string }>;
|
|
920
|
+
const openFrames = view.frames.filter((frame) => frame.state === "open");
|
|
921
|
+
if (openTurns.length === 0 && openFrames.length === 0) {
|
|
922
|
+
return {
|
|
923
|
+
syntheticCompletionCount: 0,
|
|
924
|
+
recallIndexRebuilt: this.recallIndexRebuilt,
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
if (openTurns.length !== 1 || openFrames.length > 1) {
|
|
928
|
+
throw this.recoveryError(
|
|
929
|
+
"Session has an invalid number of open turns or frames.",
|
|
930
|
+
);
|
|
931
|
+
}
|
|
932
|
+
const turnId = openTurns[0].turn_id as TurnId;
|
|
933
|
+
const openIterations = this.database
|
|
934
|
+
.query(
|
|
935
|
+
"SELECT iteration_id FROM iterations WHERE turn_id = ? AND outcome = 'open' ORDER BY iteration_number",
|
|
936
|
+
)
|
|
937
|
+
.all(turnId) as Array<{ iteration_id: string }>;
|
|
938
|
+
if (openIterations.length > 1) {
|
|
939
|
+
throw this.recoveryError(`Turn ${turnId} has multiple open iterations.`);
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
const frame = openFrames[0];
|
|
943
|
+
if (frame === undefined) {
|
|
944
|
+
this.markOpenTurnInterrupted(
|
|
945
|
+
turnId,
|
|
946
|
+
openIterations[0]?.iteration_id as IterationId | undefined,
|
|
947
|
+
);
|
|
948
|
+
this.validateAll({ allowOpenTail: false });
|
|
949
|
+
return {
|
|
950
|
+
recoveredTurnId: turnId,
|
|
951
|
+
syntheticCompletionCount: 0,
|
|
952
|
+
recallIndexRebuilt: this.recallIndexRebuilt,
|
|
953
|
+
};
|
|
954
|
+
}
|
|
955
|
+
if (
|
|
956
|
+
frame.turnId !== turnId ||
|
|
957
|
+
frame !== view.frames.at(-1) ||
|
|
958
|
+
openIterations.length !== 1 ||
|
|
959
|
+
frame.iterationId !== openIterations[0]?.iteration_id
|
|
960
|
+
) {
|
|
961
|
+
throw this.recoveryError(`Open frame ${frame.frameId} has invalid ownership.`);
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
const frameMessages = view.messages.filter(
|
|
965
|
+
(message) => message.frameId === frame.frameId,
|
|
966
|
+
);
|
|
967
|
+
const assistant = frameMessages[0];
|
|
968
|
+
if (assistant?.role !== "assistant" || assistant.toolCalls === undefined) {
|
|
969
|
+
throw this.recoveryError(`Open frame ${frame.frameId} has no tool calls.`);
|
|
970
|
+
}
|
|
971
|
+
const missingCalls = assistant.toolCalls.slice(frameMessages.length - 1);
|
|
972
|
+
if (missingCalls.length === 0) {
|
|
973
|
+
throw this.recoveryError(`Open frame ${frame.frameId} has no missing call.`);
|
|
974
|
+
}
|
|
975
|
+
const completionInputs = interruptedCompletionInputs(missingCalls);
|
|
976
|
+
const messages: CanonicalMessageRecord[] = [];
|
|
977
|
+
const toolResults: ToolResultRecord[] = [];
|
|
978
|
+
for (const input of completionInputs) {
|
|
979
|
+
const createdAt = this.clock();
|
|
980
|
+
const content = observationForCompletion(input);
|
|
981
|
+
const messageId = idFactory.createMessageId();
|
|
982
|
+
const message = immutableRecord<CanonicalMessageRecord>({
|
|
983
|
+
messageId,
|
|
984
|
+
sessionId: this.sessionId,
|
|
985
|
+
frameId: frame.frameId,
|
|
986
|
+
ordinal: view.messages.length + messages.length + 1,
|
|
987
|
+
contentSha256: contentHash(content),
|
|
988
|
+
createdAt,
|
|
989
|
+
role: "tool",
|
|
990
|
+
turnId,
|
|
991
|
+
iterationId: frame.iterationId,
|
|
992
|
+
toolCallId: input.call.toolCallId,
|
|
993
|
+
providerToolCallId: input.call.providerToolCallId,
|
|
994
|
+
name: input.call.name,
|
|
995
|
+
content,
|
|
996
|
+
origin: "runtime",
|
|
997
|
+
});
|
|
998
|
+
const completion: ToolCompletion = immutableRecord({
|
|
999
|
+
kind: "synthetic",
|
|
1000
|
+
reason: input.reason,
|
|
1001
|
+
});
|
|
1002
|
+
const result = immutableRecord<ToolResultRecord>({
|
|
1003
|
+
sessionId: this.sessionId,
|
|
1004
|
+
frameId: frame.frameId,
|
|
1005
|
+
toolCallId: input.call.toolCallId,
|
|
1006
|
+
toolMessageId: messageId,
|
|
1007
|
+
completion,
|
|
1008
|
+
observationSha256: contentHash(content),
|
|
1009
|
+
createdAt,
|
|
1010
|
+
});
|
|
1011
|
+
messages.push(message);
|
|
1012
|
+
toolResults.push(result);
|
|
1013
|
+
}
|
|
1014
|
+
const closedAt = this.clock();
|
|
1015
|
+
const closedFrame = immutableRecord<ProtocolFrame>({
|
|
1016
|
+
...frame,
|
|
1017
|
+
state: "closed",
|
|
1018
|
+
lastOrdinal: view.messages.length + messages.length,
|
|
1019
|
+
closedAt,
|
|
1020
|
+
});
|
|
1021
|
+
const candidate: ProtocolContextView = Object.freeze({
|
|
1022
|
+
...view,
|
|
1023
|
+
frames: Object.freeze(
|
|
1024
|
+
view.frames.map((entry) =>
|
|
1025
|
+
entry.frameId === frame.frameId ? closedFrame : entry,
|
|
1026
|
+
),
|
|
1027
|
+
),
|
|
1028
|
+
messages: Object.freeze([...view.messages, ...messages]),
|
|
1029
|
+
toolResults: Object.freeze([...view.toolResults, ...toolResults]),
|
|
1030
|
+
});
|
|
1031
|
+
this.validator.validate(candidate, { fullIntegrity: true });
|
|
1032
|
+
|
|
1033
|
+
try {
|
|
1034
|
+
runTransaction(this.database, () => {
|
|
1035
|
+
for (let index = 0; index < messages.length; index += 1) {
|
|
1036
|
+
insertMessage(
|
|
1037
|
+
this.database,
|
|
1038
|
+
requireItem(messages, index, "recovery message"),
|
|
1039
|
+
);
|
|
1040
|
+
insertToolResult(
|
|
1041
|
+
this.database,
|
|
1042
|
+
requireItem(toolResults, index, "recovery tool result"),
|
|
1043
|
+
);
|
|
1044
|
+
}
|
|
1045
|
+
const frameUpdate = this.database
|
|
1046
|
+
.query(
|
|
1047
|
+
`UPDATE protocol_frames SET state = 'closed', last_ordinal = ?, closed_at = ?
|
|
1048
|
+
WHERE frame_id = ? AND state = 'open' AND last_ordinal IS NULL`,
|
|
1049
|
+
)
|
|
1050
|
+
.run(closedFrame.lastOrdinal!, closedAt, frame.frameId);
|
|
1051
|
+
requireSingleChange(
|
|
1052
|
+
this.database,
|
|
1053
|
+
frameUpdate.changes,
|
|
1054
|
+
"close recovered frame",
|
|
1055
|
+
);
|
|
1056
|
+
this.markTerminalRows(
|
|
1057
|
+
turnId,
|
|
1058
|
+
frame.iterationId!,
|
|
1059
|
+
"interrupted",
|
|
1060
|
+
"interrupted",
|
|
1061
|
+
null,
|
|
1062
|
+
stableJsonStringify({ version: 1, reason: "process_interrupted" }),
|
|
1063
|
+
closedAt,
|
|
1064
|
+
);
|
|
1065
|
+
});
|
|
1066
|
+
} catch (error) {
|
|
1067
|
+
throw new SessionError(
|
|
1068
|
+
"SESSION_RECOVERY_FAILED",
|
|
1069
|
+
"recover_open_frame",
|
|
1070
|
+
`Failed to recover open frame ${frame.frameId}.`,
|
|
1071
|
+
{ sessionId: this.sessionId, frameId: frame.frameId, cause: error },
|
|
1072
|
+
);
|
|
1073
|
+
}
|
|
1074
|
+
this.validateAll({ allowOpenTail: false });
|
|
1075
|
+
return {
|
|
1076
|
+
recoveredTurnId: turnId,
|
|
1077
|
+
recoveredFrameId: frame.frameId,
|
|
1078
|
+
syntheticCompletionCount: messages.length,
|
|
1079
|
+
recallIndexRebuilt: this.recallIndexRebuilt,
|
|
1080
|
+
};
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
historyReader(): SessionHistoryReader {
|
|
1084
|
+
this.requireOpen();
|
|
1085
|
+
return createSessionHistoryReader({
|
|
1086
|
+
database: this.database,
|
|
1087
|
+
sessionId: this.sessionId,
|
|
1088
|
+
requireOpen: () => this.requireOpen(),
|
|
1089
|
+
});
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
loadProtocolView(): ProtocolContextView {
|
|
1093
|
+
this.requireOpen();
|
|
1094
|
+
const frames = this.database
|
|
1095
|
+
.query("SELECT * FROM protocol_frames ORDER BY first_ordinal")
|
|
1096
|
+
.all()
|
|
1097
|
+
.map(decodeFrame);
|
|
1098
|
+
const messages = this.database
|
|
1099
|
+
.query("SELECT * FROM messages ORDER BY ordinal")
|
|
1100
|
+
.all()
|
|
1101
|
+
.map(decodeMessage);
|
|
1102
|
+
const toolResults = this.database
|
|
1103
|
+
.query(
|
|
1104
|
+
`SELECT tr.* FROM tool_results tr
|
|
1105
|
+
JOIN messages m ON m.message_id = tr.tool_message_id
|
|
1106
|
+
ORDER BY m.ordinal`,
|
|
1107
|
+
)
|
|
1108
|
+
.all()
|
|
1109
|
+
.map(decodeToolResult);
|
|
1110
|
+
return Object.freeze({
|
|
1111
|
+
sessionId: this.sessionId,
|
|
1112
|
+
faulted: false,
|
|
1113
|
+
frames: Object.freeze(frames),
|
|
1114
|
+
messages: Object.freeze(messages),
|
|
1115
|
+
toolResults: Object.freeze(toolResults),
|
|
1116
|
+
});
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
loadContextSnapshot(): StoredContextSnapshotV5 {
|
|
1120
|
+
this.requireOpen();
|
|
1121
|
+
const meta = this.readMeta();
|
|
1122
|
+
try {
|
|
1123
|
+
if (
|
|
1124
|
+
meta.sessionId !== this.sessionId ||
|
|
1125
|
+
meta.schemaFingerprint !== SESSION_SCHEMA_V5_FINGERPRINT
|
|
1126
|
+
) {
|
|
1127
|
+
throw new Error("Session metadata identity or schema fingerprint changed.");
|
|
1128
|
+
}
|
|
1129
|
+
const canonical = this.loadProtocolView();
|
|
1130
|
+
this.validator.validate(canonical, { fullIntegrity: true });
|
|
1131
|
+
const systemFrame = canonical.frames[0];
|
|
1132
|
+
const systemMessage = canonical.messages[0];
|
|
1133
|
+
if (
|
|
1134
|
+
systemFrame?.kind !== "system" ||
|
|
1135
|
+
systemFrame.firstOrdinal !== 1 ||
|
|
1136
|
+
systemMessage?.role !== "system" ||
|
|
1137
|
+
systemMessage.ordinal !== 1 ||
|
|
1138
|
+
sha256(systemMessage.content) !== meta.systemPromptSha256 ||
|
|
1139
|
+
canonical.messages.at(-1)?.ordinal !== canonical.messages.length
|
|
1140
|
+
) {
|
|
1141
|
+
throw new Error("Stored context snapshot ordinal or system invariant failed.");
|
|
1142
|
+
}
|
|
1143
|
+
return this.loadValidatedContextSnapshot(meta, canonical);
|
|
1144
|
+
} catch (error) {
|
|
1145
|
+
if (error instanceof SessionError) {
|
|
1146
|
+
throw error;
|
|
1147
|
+
}
|
|
1148
|
+
if (error instanceof ContextProtocolError) {
|
|
1149
|
+
throw new SessionError(
|
|
1150
|
+
"SESSION_PROTOCOL_INVALID",
|
|
1151
|
+
"load_context_snapshot",
|
|
1152
|
+
error.message,
|
|
1153
|
+
{
|
|
1154
|
+
sessionId: this.sessionId,
|
|
1155
|
+
frameId: error.frameId,
|
|
1156
|
+
messageId: error.messageId,
|
|
1157
|
+
toolCallId: error.toolCallId,
|
|
1158
|
+
cause: error,
|
|
1159
|
+
},
|
|
1160
|
+
);
|
|
1161
|
+
}
|
|
1162
|
+
throw new SessionError(
|
|
1163
|
+
"SESSION_INTEGRITY_FAILED",
|
|
1164
|
+
"load_context_snapshot",
|
|
1165
|
+
`Session context snapshot validation failed: ${errorMessage(error)}.`,
|
|
1166
|
+
{ sessionId: this.sessionId, cause: error },
|
|
1167
|
+
);
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
readMeta(): StoredSessionMetaV5 {
|
|
1172
|
+
this.requireOpen();
|
|
1173
|
+
const rows = this.database.query("SELECT * FROM session_meta").all();
|
|
1174
|
+
if (rows.length !== 1) {
|
|
1175
|
+
throw new SessionError(
|
|
1176
|
+
"SESSION_INTEGRITY_FAILED",
|
|
1177
|
+
"read_meta",
|
|
1178
|
+
`Session metadata must contain exactly one row; found ${rows.length}.`,
|
|
1179
|
+
{ sessionId: this.sessionId },
|
|
1180
|
+
);
|
|
1181
|
+
}
|
|
1182
|
+
return decodeMeta(rows[0], this.sessionId);
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
readStoredSystemPrompt(): string {
|
|
1186
|
+
this.requireOpen();
|
|
1187
|
+
try {
|
|
1188
|
+
const frames = this.database
|
|
1189
|
+
.query("SELECT * FROM protocol_frames WHERE kind = 'system'")
|
|
1190
|
+
.all();
|
|
1191
|
+
if (frames.length !== 1) {
|
|
1192
|
+
throw new Error(`Expected one stored system frame; found ${frames.length}.`);
|
|
1193
|
+
}
|
|
1194
|
+
const frame = recordFromSql(frames[0], "stored system frame");
|
|
1195
|
+
if (
|
|
1196
|
+
frame.state !== "closed" ||
|
|
1197
|
+
numberFromSql(frame.first_ordinal, "first_ordinal") !== 1 ||
|
|
1198
|
+
numberFromSql(frame.last_ordinal, "last_ordinal") !== 1
|
|
1199
|
+
) {
|
|
1200
|
+
throw new Error("Stored system frame invariant failed.");
|
|
1201
|
+
}
|
|
1202
|
+
const frameId = stringFromSql(frame.frame_id, "frame_id");
|
|
1203
|
+
const messages = this.database
|
|
1204
|
+
.query("SELECT * FROM messages WHERE frame_id = ?")
|
|
1205
|
+
.all(frameId);
|
|
1206
|
+
if (messages.length !== 1) {
|
|
1207
|
+
throw new Error(
|
|
1208
|
+
`Expected one stored system message; found ${messages.length}.`,
|
|
1209
|
+
);
|
|
1210
|
+
}
|
|
1211
|
+
const row = recordFromSql(messages[0], "stored system message");
|
|
1212
|
+
if (
|
|
1213
|
+
numberFromSql(row.ordinal, "ordinal") !== 1 ||
|
|
1214
|
+
row.role !== "system" ||
|
|
1215
|
+
row.origin !== "runtime"
|
|
1216
|
+
) {
|
|
1217
|
+
throw new Error("Stored system message invariant failed.");
|
|
1218
|
+
}
|
|
1219
|
+
const content = stringFromSql(row.content, "content");
|
|
1220
|
+
if (content.trim() === "") {
|
|
1221
|
+
throw new Error("Stored system prompt must not be empty.");
|
|
1222
|
+
}
|
|
1223
|
+
if (
|
|
1224
|
+
stringFromSql(row.content_sha256, "content_sha256") !== contentHash(content)
|
|
1225
|
+
) {
|
|
1226
|
+
throw new Error("Stored system message content hash does not match.");
|
|
1227
|
+
}
|
|
1228
|
+
if (this.readMeta().systemPromptSha256 !== sha256(content)) {
|
|
1229
|
+
throw new Error("Stored system prompt metadata hash does not match.");
|
|
1230
|
+
}
|
|
1231
|
+
return content;
|
|
1232
|
+
} catch (error) {
|
|
1233
|
+
if (error instanceof SessionError && error.code === "SESSION_RECOVERY_FAILED") {
|
|
1234
|
+
throw error;
|
|
1235
|
+
}
|
|
1236
|
+
throw new SessionError(
|
|
1237
|
+
"SESSION_RECOVERY_FAILED",
|
|
1238
|
+
"read_stored_system_prompt",
|
|
1239
|
+
"Stored system prompt is missing or invalid.",
|
|
1240
|
+
{ sessionId: this.sessionId, cause: error },
|
|
1241
|
+
);
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
readProjectInstructionManifest(): ProjectInstructionManifest | undefined {
|
|
1246
|
+
return this.readMeta().projectInstruction;
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
nextTurnNumber(): number {
|
|
1250
|
+
return this.readMeta().nextTurnNumber;
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
validateAll(options: { allowOpenTail: boolean }): ProtocolContextView {
|
|
1254
|
+
const meta = this.readMeta();
|
|
1255
|
+
if (
|
|
1256
|
+
meta.sessionId !== this.sessionId ||
|
|
1257
|
+
meta.schemaFingerprint !== SESSION_SCHEMA_V5_FINGERPRINT
|
|
1258
|
+
) {
|
|
1259
|
+
throw new SessionError(
|
|
1260
|
+
"SESSION_SCHEMA_INVALID",
|
|
1261
|
+
"validate_store",
|
|
1262
|
+
"Session metadata identity or schema fingerprint does not match.",
|
|
1263
|
+
{ sessionId: this.sessionId },
|
|
1264
|
+
);
|
|
1265
|
+
}
|
|
1266
|
+
this.readStoredSystemPrompt();
|
|
1267
|
+
const view = this.loadProtocolView();
|
|
1268
|
+
try {
|
|
1269
|
+
this.validator.validate(view, {
|
|
1270
|
+
allowOpenTail: options.allowOpenTail,
|
|
1271
|
+
fullIntegrity: true,
|
|
1272
|
+
});
|
|
1273
|
+
this.loadValidatedContextSnapshot(meta, view);
|
|
1274
|
+
this.validateCounters(meta, view);
|
|
1275
|
+
} catch (error) {
|
|
1276
|
+
if (error instanceof SessionError) {
|
|
1277
|
+
throw error;
|
|
1278
|
+
}
|
|
1279
|
+
if (error instanceof ContextProtocolError) {
|
|
1280
|
+
throw new SessionError(
|
|
1281
|
+
"SESSION_PROTOCOL_INVALID",
|
|
1282
|
+
"validate_store",
|
|
1283
|
+
error.message,
|
|
1284
|
+
{
|
|
1285
|
+
sessionId: this.sessionId,
|
|
1286
|
+
frameId: error.frameId,
|
|
1287
|
+
messageId: error.messageId,
|
|
1288
|
+
toolCallId: error.toolCallId,
|
|
1289
|
+
cause: error,
|
|
1290
|
+
},
|
|
1291
|
+
);
|
|
1292
|
+
}
|
|
1293
|
+
throw new SessionError(
|
|
1294
|
+
"SESSION_INTEGRITY_FAILED",
|
|
1295
|
+
"validate_store",
|
|
1296
|
+
`Session record validation failed: ${errorMessage(error)}.`,
|
|
1297
|
+
{ sessionId: this.sessionId, cause: error },
|
|
1298
|
+
);
|
|
1299
|
+
}
|
|
1300
|
+
return view;
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
async close(reason: SessionCloseReason): Promise<void> {
|
|
1304
|
+
if (this.closed) {
|
|
1305
|
+
return;
|
|
1306
|
+
}
|
|
1307
|
+
let primaryError: unknown;
|
|
1308
|
+
const now = this.clock();
|
|
1309
|
+
try {
|
|
1310
|
+
runTransaction(this.database, () => {
|
|
1311
|
+
const updated = this.database
|
|
1312
|
+
.query(
|
|
1313
|
+
`UPDATE session_meta SET last_closed_at = ?, last_close_reason = ?, updated_at = ?
|
|
1314
|
+
WHERE singleton = 1`,
|
|
1315
|
+
)
|
|
1316
|
+
.run(now, reason, now);
|
|
1317
|
+
requireSingleChange(this.database, updated.changes, "close session activation");
|
|
1318
|
+
});
|
|
1319
|
+
} catch (error) {
|
|
1320
|
+
primaryError = sessionWriteError("close_session", this.sessionId, error);
|
|
1321
|
+
}
|
|
1322
|
+
try {
|
|
1323
|
+
this.database.close();
|
|
1324
|
+
} catch (error) {
|
|
1325
|
+
primaryError ??= error;
|
|
1326
|
+
}
|
|
1327
|
+
this.closed = true;
|
|
1328
|
+
try {
|
|
1329
|
+
await this.lease.release();
|
|
1330
|
+
} catch (error) {
|
|
1331
|
+
primaryError ??= error;
|
|
1332
|
+
}
|
|
1333
|
+
if (primaryError !== undefined) {
|
|
1334
|
+
throw asError(primaryError);
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
async abandon(): Promise<void> {
|
|
1339
|
+
if (this.closed) {
|
|
1340
|
+
return;
|
|
1341
|
+
}
|
|
1342
|
+
try {
|
|
1343
|
+
this.database.close();
|
|
1344
|
+
} catch {
|
|
1345
|
+
// A failed delete path may already have closed the connection.
|
|
1346
|
+
}
|
|
1347
|
+
this.closed = true;
|
|
1348
|
+
await this.lease.release();
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
async deleteFromDisk(): Promise<void> {
|
|
1352
|
+
this.requireOpen();
|
|
1353
|
+
const known = new Set([
|
|
1354
|
+
"session.sqlite",
|
|
1355
|
+
"session.sqlite-wal",
|
|
1356
|
+
"session.sqlite-shm",
|
|
1357
|
+
"events.jsonl",
|
|
1358
|
+
"observations.md",
|
|
1359
|
+
"active.lock",
|
|
1360
|
+
"active.lock.reclaim",
|
|
1361
|
+
]);
|
|
1362
|
+
const entries = await readdir(this.sessionDirectory);
|
|
1363
|
+
const unknown = entries.filter((entry) => !known.has(entry));
|
|
1364
|
+
if (unknown.length > 0) {
|
|
1365
|
+
throw new SessionError(
|
|
1366
|
+
"SESSION_DELETE_BLOCKED",
|
|
1367
|
+
"delete_session",
|
|
1368
|
+
`Session directory contains unknown files: ${unknown.join(", ")}.`,
|
|
1369
|
+
{ sessionId: this.sessionId },
|
|
1370
|
+
);
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
this.database.exec("PRAGMA wal_checkpoint(TRUNCATE)");
|
|
1374
|
+
this.database.close();
|
|
1375
|
+
const tombstone = `${this.sessionDirectory}.deleting-${randomUUID()}`;
|
|
1376
|
+
try {
|
|
1377
|
+
await rename(this.sessionDirectory, tombstone);
|
|
1378
|
+
} catch (error) {
|
|
1379
|
+
this.closed = true;
|
|
1380
|
+
await this.lease.release().catch(() => undefined);
|
|
1381
|
+
throw error;
|
|
1382
|
+
}
|
|
1383
|
+
this.lease.relocate(tombstone);
|
|
1384
|
+
await this.lease.release();
|
|
1385
|
+
this.closed = true;
|
|
1386
|
+
|
|
1387
|
+
try {
|
|
1388
|
+
for (const name of known) {
|
|
1389
|
+
await unlinkIfExists(path.join(tombstone, name));
|
|
1390
|
+
}
|
|
1391
|
+
await rmdir(tombstone);
|
|
1392
|
+
} catch (error) {
|
|
1393
|
+
throw new SessionError(
|
|
1394
|
+
"SESSION_DELETE_BLOCKED",
|
|
1395
|
+
"delete_session_cleanup",
|
|
1396
|
+
`Session was removed from the catalog, but tombstone cleanup failed: ${tombstone}.`,
|
|
1397
|
+
{ sessionId: this.sessionId, cause: error },
|
|
1398
|
+
);
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
private commitBeginTurn(
|
|
1403
|
+
mutation: Extract<LedgerMutation, { kind: "begin_turn" }>,
|
|
1404
|
+
now: string,
|
|
1405
|
+
): void {
|
|
1406
|
+
const meta = this.readMeta();
|
|
1407
|
+
if (
|
|
1408
|
+
meta.initializationState !== "ready" ||
|
|
1409
|
+
meta.nextTurnNumber !== mutation.turn.turnNumber
|
|
1410
|
+
) {
|
|
1411
|
+
throw new Error("Session turn counter or state changed before begin_turn.");
|
|
1412
|
+
}
|
|
1413
|
+
this.database
|
|
1414
|
+
.query(
|
|
1415
|
+
`INSERT INTO turns (
|
|
1416
|
+
session_id, turn_id, turn_number, status, next_iteration_number,
|
|
1417
|
+
last_iteration_id, final_message_id, terminal_detail_json, started_at, finished_at
|
|
1418
|
+
) VALUES (?, ?, ?, 'open', 1, NULL, NULL, NULL, ?, NULL)`,
|
|
1419
|
+
)
|
|
1420
|
+
.run(this.sessionId, mutation.turn.turnId, mutation.turn.turnNumber, now);
|
|
1421
|
+
insertFrame(this.database, mutation.frame);
|
|
1422
|
+
insertMessage(this.database, mutation.message);
|
|
1423
|
+
const updated = this.database
|
|
1424
|
+
.query(
|
|
1425
|
+
`UPDATE session_meta SET next_turn_number = ?, updated_at = ?
|
|
1426
|
+
WHERE singleton = 1 AND next_turn_number = ?`,
|
|
1427
|
+
)
|
|
1428
|
+
.run(mutation.turn.turnNumber + 1, now, mutation.turn.turnNumber);
|
|
1429
|
+
requireSingleChange(this.database, updated.changes, "advance turn counter");
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
private commitAssistant(
|
|
1433
|
+
mutation: Extract<LedgerMutation, { kind: "append_assistant" }>,
|
|
1434
|
+
now: string,
|
|
1435
|
+
): void {
|
|
1436
|
+
const iteration = this.requireIterationRow(mutation.iteration.iterationId);
|
|
1437
|
+
if (iteration.outcome !== "open") {
|
|
1438
|
+
throw new Error(`Iteration ${mutation.iteration.iterationId} is not open.`);
|
|
1439
|
+
}
|
|
1440
|
+
insertFrame(this.database, mutation.frame);
|
|
1441
|
+
insertMessage(this.database, mutation.message);
|
|
1442
|
+
if (
|
|
1443
|
+
mutation.message.role === "assistant" &&
|
|
1444
|
+
mutation.message.toolCalls !== undefined
|
|
1445
|
+
) {
|
|
1446
|
+
const expected = numberFromSql(
|
|
1447
|
+
iteration.next_tool_call_number,
|
|
1448
|
+
"next_tool_call_number",
|
|
1449
|
+
);
|
|
1450
|
+
if (expected !== 1) {
|
|
1451
|
+
throw new Error(
|
|
1452
|
+
"Assistant tool calls were already allocated for this iteration.",
|
|
1453
|
+
);
|
|
1454
|
+
}
|
|
1455
|
+
const updated = this.database
|
|
1456
|
+
.query(
|
|
1457
|
+
`UPDATE iterations SET next_tool_call_number = ?
|
|
1458
|
+
WHERE iteration_id = ? AND outcome = 'open' AND next_tool_call_number = 1`,
|
|
1459
|
+
)
|
|
1460
|
+
.run(mutation.message.toolCalls.length + 1, mutation.iteration.iterationId);
|
|
1461
|
+
requireSingleChange(this.database, updated.changes, "advance tool call counter");
|
|
1462
|
+
}
|
|
1463
|
+
this.touch(now);
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
private commitToolCompletions(
|
|
1467
|
+
mutation: Extract<LedgerMutation, { kind: "commit_tool_completions" }>,
|
|
1468
|
+
now: string,
|
|
1469
|
+
): void {
|
|
1470
|
+
const current = this.database
|
|
1471
|
+
.query("SELECT state, last_ordinal FROM protocol_frames WHERE frame_id = ?")
|
|
1472
|
+
.get(mutation.frameBefore.frameId) as {
|
|
1473
|
+
state: string;
|
|
1474
|
+
last_ordinal: unknown;
|
|
1475
|
+
} | null;
|
|
1476
|
+
if (current?.state !== "open" || current.last_ordinal !== null) {
|
|
1477
|
+
throw new Error(`Frame ${mutation.frameBefore.frameId} is not open.`);
|
|
1478
|
+
}
|
|
1479
|
+
for (let index = 0; index < mutation.messages.length; index += 1) {
|
|
1480
|
+
insertMessage(
|
|
1481
|
+
this.database,
|
|
1482
|
+
requireItem(mutation.messages, index, "tool message"),
|
|
1483
|
+
);
|
|
1484
|
+
insertToolResult(
|
|
1485
|
+
this.database,
|
|
1486
|
+
requireItem(mutation.toolResults, index, "tool result"),
|
|
1487
|
+
);
|
|
1488
|
+
}
|
|
1489
|
+
if (mutation.frameAfter.state === "closed") {
|
|
1490
|
+
const updated = this.database
|
|
1491
|
+
.query(
|
|
1492
|
+
`UPDATE protocol_frames SET state = 'closed', last_ordinal = ?, closed_at = ?
|
|
1493
|
+
WHERE frame_id = ? AND state = 'open' AND last_ordinal IS NULL`,
|
|
1494
|
+
)
|
|
1495
|
+
.run(
|
|
1496
|
+
mutation.frameAfter.lastOrdinal!,
|
|
1497
|
+
mutation.frameAfter.closedAt!,
|
|
1498
|
+
mutation.frameAfter.frameId,
|
|
1499
|
+
);
|
|
1500
|
+
requireSingleChange(this.database, updated.changes, "close tool exchange frame");
|
|
1501
|
+
}
|
|
1502
|
+
this.touch(now);
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
private commitFinishTurn(
|
|
1506
|
+
mutation: Extract<LedgerMutation, { kind: "finish_turn" }>,
|
|
1507
|
+
now: string,
|
|
1508
|
+
): void {
|
|
1509
|
+
const result = mutation.result;
|
|
1510
|
+
const turnStatus = result.status;
|
|
1511
|
+
const iterationOutcome = result.status;
|
|
1512
|
+
const detail =
|
|
1513
|
+
result.status === "completed"
|
|
1514
|
+
? stableJsonStringify({ version: 1, finalTextLength: result.finalText.length })
|
|
1515
|
+
: result.status === "failed"
|
|
1516
|
+
? stableJsonStringify({ version: 1, error: result.error.slice(0, 2_000) })
|
|
1517
|
+
: stableJsonStringify({ version: 1, cancellation: result.cancellation });
|
|
1518
|
+
this.markTerminalRows(
|
|
1519
|
+
mutation.turn.turnId,
|
|
1520
|
+
result.lastIteration.iterationId,
|
|
1521
|
+
turnStatus,
|
|
1522
|
+
iterationOutcome,
|
|
1523
|
+
mutation.finalMessageId ?? null,
|
|
1524
|
+
detail,
|
|
1525
|
+
now,
|
|
1526
|
+
);
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
private markTerminalRows(
|
|
1530
|
+
turnId: TurnId,
|
|
1531
|
+
iterationId: IterationId,
|
|
1532
|
+
turnStatus: "completed" | "failed" | "cancelled" | "interrupted",
|
|
1533
|
+
iterationOutcome: "completed" | "failed" | "cancelled" | "interrupted",
|
|
1534
|
+
finalMessageId: MessageId | null,
|
|
1535
|
+
terminalDetailJson: string,
|
|
1536
|
+
now: string,
|
|
1537
|
+
): void {
|
|
1538
|
+
const iteration = this.database
|
|
1539
|
+
.query(
|
|
1540
|
+
`UPDATE iterations SET outcome = ?, finished_at = ?
|
|
1541
|
+
WHERE iteration_id = ? AND turn_id = ? AND outcome = 'open'`,
|
|
1542
|
+
)
|
|
1543
|
+
.run(iterationOutcome, now, iterationId, turnId);
|
|
1544
|
+
requireSingleChange(this.database, iteration.changes, "finish iteration");
|
|
1545
|
+
const turn = this.database
|
|
1546
|
+
.query(
|
|
1547
|
+
`UPDATE turns SET status = ?, last_iteration_id = ?, final_message_id = ?,
|
|
1548
|
+
terminal_detail_json = ?, finished_at = ?
|
|
1549
|
+
WHERE turn_id = ? AND status = 'open'`,
|
|
1550
|
+
)
|
|
1551
|
+
.run(turnStatus, iterationId, finalMessageId, terminalDetailJson, now, turnId);
|
|
1552
|
+
requireSingleChange(this.database, turn.changes, "finish turn");
|
|
1553
|
+
this.touch(now);
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
private markOpenTurnInterrupted(
|
|
1557
|
+
turnId: TurnId,
|
|
1558
|
+
iterationId: IterationId | undefined,
|
|
1559
|
+
): void {
|
|
1560
|
+
const now = this.clock();
|
|
1561
|
+
try {
|
|
1562
|
+
runTransaction(this.database, () => {
|
|
1563
|
+
if (iterationId !== undefined) {
|
|
1564
|
+
const iteration = this.database
|
|
1565
|
+
.query(
|
|
1566
|
+
`UPDATE iterations SET outcome = 'interrupted', finished_at = ?
|
|
1567
|
+
WHERE iteration_id = ? AND outcome = 'open'`,
|
|
1568
|
+
)
|
|
1569
|
+
.run(now, iterationId);
|
|
1570
|
+
requireSingleChange(this.database, iteration.changes, "interrupt iteration");
|
|
1571
|
+
}
|
|
1572
|
+
const turn = this.database
|
|
1573
|
+
.query(
|
|
1574
|
+
`UPDATE turns SET status = 'interrupted', finished_at = ?,
|
|
1575
|
+
terminal_detail_json = ?
|
|
1576
|
+
WHERE turn_id = ? AND status = 'open'`,
|
|
1577
|
+
)
|
|
1578
|
+
.run(
|
|
1579
|
+
now,
|
|
1580
|
+
stableJsonStringify({ version: 1, reason: "process_interrupted" }),
|
|
1581
|
+
turnId,
|
|
1582
|
+
);
|
|
1583
|
+
requireSingleChange(this.database, turn.changes, "interrupt turn");
|
|
1584
|
+
this.touch(now);
|
|
1585
|
+
});
|
|
1586
|
+
} catch (error) {
|
|
1587
|
+
throw new SessionError(
|
|
1588
|
+
"SESSION_RECOVERY_FAILED",
|
|
1589
|
+
"recover_open_turn",
|
|
1590
|
+
`Failed to mark turn ${turnId} interrupted.`,
|
|
1591
|
+
{ sessionId: this.sessionId, cause: error },
|
|
1592
|
+
);
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
private loadValidatedContextSnapshot(
|
|
1597
|
+
meta: StoredSessionMetaV5,
|
|
1598
|
+
canonical: ProtocolContextView,
|
|
1599
|
+
): StoredContextSnapshotV5 {
|
|
1600
|
+
const revisions = this.database
|
|
1601
|
+
.query("SELECT * FROM context_revisions ORDER BY revision_number")
|
|
1602
|
+
.all()
|
|
1603
|
+
.map(decodeContextRevision);
|
|
1604
|
+
if (revisions.length === 0) {
|
|
1605
|
+
throw new Error("Session has no context revision.");
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
const revisionNumberById = new Map<ContextRevisionId, number>();
|
|
1609
|
+
for (let index = 0; index < revisions.length; index += 1) {
|
|
1610
|
+
const revision = requireItem(revisions, index, "context revision");
|
|
1611
|
+
const previous = revisions[index - 1];
|
|
1612
|
+
if (
|
|
1613
|
+
revision.sessionId !== this.sessionId ||
|
|
1614
|
+
revision.revisionNumber !== index + 1 ||
|
|
1615
|
+
(index === 0
|
|
1616
|
+
? revision.kind !== "initial_full" || revision.parentRevisionId !== null
|
|
1617
|
+
: revision.kind !== "swap_only" ||
|
|
1618
|
+
revision.parentRevisionId !== previous?.revisionId)
|
|
1619
|
+
) {
|
|
1620
|
+
throw new Error("Context revision chain is not linear and contiguous.");
|
|
1621
|
+
}
|
|
1622
|
+
const boundary = canonical.frames.find(
|
|
1623
|
+
(frame) => frame.lastOrdinal === revision.sourceThroughOrdinal,
|
|
1624
|
+
);
|
|
1625
|
+
if (
|
|
1626
|
+
revision.sourceThroughOrdinal > canonical.messages.length ||
|
|
1627
|
+
boundary?.state !== "closed"
|
|
1628
|
+
) {
|
|
1629
|
+
throw new Error(
|
|
1630
|
+
`Context revision ${revision.revisionId} has an invalid source boundary.`,
|
|
1631
|
+
);
|
|
1632
|
+
}
|
|
1633
|
+
revisionNumberById.set(revision.revisionId, revision.revisionNumber);
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
const activeRevision = requireItem(
|
|
1637
|
+
revisions,
|
|
1638
|
+
revisions.length - 1,
|
|
1639
|
+
"active context revision",
|
|
1640
|
+
);
|
|
1641
|
+
if (activeRevision.revisionId !== meta.activeRevisionId) {
|
|
1642
|
+
throw new Error("Active context revision is not the latest committed revision.");
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
const overrides = this.database
|
|
1646
|
+
.query(
|
|
1647
|
+
`SELECT co.* FROM context_overrides co
|
|
1648
|
+
JOIN context_revisions cr
|
|
1649
|
+
ON cr.revision_id = co.introduced_revision_id
|
|
1650
|
+
ORDER BY cr.revision_number, co.ordinal`,
|
|
1651
|
+
)
|
|
1652
|
+
.all()
|
|
1653
|
+
.map(decodeStoredSwapOverride);
|
|
1654
|
+
this.validateStoredOverrides(overrides, canonical, revisions, revisionNumberById);
|
|
1655
|
+
|
|
1656
|
+
let cumulativeCount = 0;
|
|
1657
|
+
for (const revision of revisions) {
|
|
1658
|
+
const activeOverrides = overrides.filter(
|
|
1659
|
+
(override) =>
|
|
1660
|
+
(revisionNumberById.get(override.introducedRevisionId) ??
|
|
1661
|
+
Number.POSITIVE_INFINITY) <= revision.revisionNumber,
|
|
1662
|
+
);
|
|
1663
|
+
const introducedCount = overrides.filter(
|
|
1664
|
+
(override) => override.introducedRevisionId === revision.revisionId,
|
|
1665
|
+
).length;
|
|
1666
|
+
cumulativeCount += introducedCount;
|
|
1667
|
+
if (
|
|
1668
|
+
introducedCount !== revision.addedOverrideCount ||
|
|
1669
|
+
cumulativeCount !== revision.totalOverrideCount ||
|
|
1670
|
+
activeOverrideManifestHash(activeOverrides) !== revision.overrideManifestSha256
|
|
1671
|
+
) {
|
|
1672
|
+
throw new Error(
|
|
1673
|
+
`Context revision ${revision.revisionId} override manifest is invalid.`,
|
|
1674
|
+
);
|
|
1675
|
+
}
|
|
1676
|
+
const prefix = protocolPrefixView(canonical, revision.sourceThroughOrdinal);
|
|
1677
|
+
this.revisionCompiler.compileActive({
|
|
1678
|
+
meta: Object.freeze({
|
|
1679
|
+
sessionId: this.sessionId,
|
|
1680
|
+
activeRevisionId: revision.revisionId,
|
|
1681
|
+
}),
|
|
1682
|
+
revision,
|
|
1683
|
+
activeOverrides,
|
|
1684
|
+
canonical: prefix,
|
|
1685
|
+
});
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
const measurement = this.loadMeasuredContextState();
|
|
1689
|
+
if (
|
|
1690
|
+
measurement !== undefined &&
|
|
1691
|
+
measurement.revisionId !== activeRevision.revisionId
|
|
1692
|
+
) {
|
|
1693
|
+
throw new Error("Context measurement is not bound to the active revision.");
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
return Object.freeze({
|
|
1697
|
+
meta: Object.freeze({
|
|
1698
|
+
sessionId: meta.sessionId,
|
|
1699
|
+
activeRevisionId: meta.activeRevisionId,
|
|
1700
|
+
}),
|
|
1701
|
+
revision: activeRevision,
|
|
1702
|
+
activeOverrides: Object.freeze(overrides),
|
|
1703
|
+
canonical,
|
|
1704
|
+
});
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
private validateStoredOverrides(
|
|
1708
|
+
overrides: readonly StoredSwapOverrideV5[],
|
|
1709
|
+
canonical: ProtocolContextView,
|
|
1710
|
+
revisions: readonly StoredContextRevisionV5[],
|
|
1711
|
+
revisionNumberById: ReadonlyMap<ContextRevisionId, number>,
|
|
1712
|
+
): void {
|
|
1713
|
+
const messages = new Map(
|
|
1714
|
+
canonical.messages.map((message) => [message.messageId, message] as const),
|
|
1715
|
+
);
|
|
1716
|
+
const frames = new Map(
|
|
1717
|
+
canonical.frames.map((frame) => [frame.frameId, frame] as const),
|
|
1718
|
+
);
|
|
1719
|
+
const results = new Map(
|
|
1720
|
+
canonical.toolResults.map((result) => [result.toolMessageId, result] as const),
|
|
1721
|
+
);
|
|
1722
|
+
const revisionsById = new Map(
|
|
1723
|
+
revisions.map((revision) => [revision.revisionId, revision] as const),
|
|
1724
|
+
);
|
|
1725
|
+
const seenMessages = new Set<MessageId>();
|
|
1726
|
+
for (const override of overrides) {
|
|
1727
|
+
const revision = revisionsById.get(override.introducedRevisionId);
|
|
1728
|
+
const message = messages.get(override.messageId);
|
|
1729
|
+
const frame = frames.get(override.frameId);
|
|
1730
|
+
const result = results.get(override.messageId);
|
|
1731
|
+
if (
|
|
1732
|
+
revision?.kind !== "swap_only" ||
|
|
1733
|
+
revisionNumberById.get(revision.revisionId) === undefined ||
|
|
1734
|
+
override.ordinal > revision.sourceThroughOrdinal ||
|
|
1735
|
+
seenMessages.has(override.messageId) ||
|
|
1736
|
+
message?.role !== "tool" ||
|
|
1737
|
+
message.frameId !== override.frameId ||
|
|
1738
|
+
message.ordinal !== override.ordinal ||
|
|
1739
|
+
frame?.kind !== "tool_exchange" ||
|
|
1740
|
+
frame.state !== "closed" ||
|
|
1741
|
+
result === undefined
|
|
1742
|
+
) {
|
|
1743
|
+
throw new Error("Stored context override canonical identity is invalid.");
|
|
1744
|
+
}
|
|
1745
|
+
const rendered = this.swapRenderer.render({ message, result });
|
|
1746
|
+
if (
|
|
1747
|
+
stableJsonStringify(rendered) !==
|
|
1748
|
+
stableJsonStringify(stripStoredOverride(override))
|
|
1749
|
+
) {
|
|
1750
|
+
throw new Error(
|
|
1751
|
+
`Stored context override ${override.messageId} does not match deterministic rendering.`,
|
|
1752
|
+
);
|
|
1753
|
+
}
|
|
1754
|
+
seenMessages.add(override.messageId);
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
private loadMeasuredContextState(): StoredMeasuredContextState | undefined {
|
|
1759
|
+
const rows = this.database.query("SELECT * FROM context_measurement_state").all();
|
|
1760
|
+
if (rows.length > 1) {
|
|
1761
|
+
throw new Error(
|
|
1762
|
+
`Expected at most one context measurement row; found ${rows.length}.`,
|
|
1763
|
+
);
|
|
1764
|
+
}
|
|
1765
|
+
const row = rows[0];
|
|
1766
|
+
return row === undefined
|
|
1767
|
+
? undefined
|
|
1768
|
+
: decodeMeasuredContextState(row, this.sessionId);
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
private validateCounters(meta: StoredSessionMetaV5, view: ProtocolContextView): void {
|
|
1772
|
+
const turns = this.database
|
|
1773
|
+
.query("SELECT * FROM turns ORDER BY turn_number")
|
|
1774
|
+
.all() as Array<Record<string, unknown>>;
|
|
1775
|
+
for (let index = 0; index < turns.length; index += 1) {
|
|
1776
|
+
const turn = turns[index];
|
|
1777
|
+
if (numberFromSql(turn.turn_number, "turn_number") !== index + 1) {
|
|
1778
|
+
throw new Error("Turn number sequence has a gap.");
|
|
1779
|
+
}
|
|
1780
|
+
const turnId = stringFromSql(turn.turn_id, "turn_id");
|
|
1781
|
+
const turnStatus = enumFromSql(
|
|
1782
|
+
turn.status,
|
|
1783
|
+
["open", "completed", "failed", "cancelled", "interrupted"] as const,
|
|
1784
|
+
"turn status",
|
|
1785
|
+
);
|
|
1786
|
+
const iterations = this.database
|
|
1787
|
+
.query("SELECT * FROM iterations WHERE turn_id = ? ORDER BY iteration_number")
|
|
1788
|
+
.all(turnId) as Array<Record<string, unknown>>;
|
|
1789
|
+
const storedLastIterationId = nullableStringFromSql(
|
|
1790
|
+
turn.last_iteration_id,
|
|
1791
|
+
"last_iteration_id",
|
|
1792
|
+
);
|
|
1793
|
+
const actualLastIterationId =
|
|
1794
|
+
iterations.length === 0
|
|
1795
|
+
? null
|
|
1796
|
+
: stringFromSql(iterations.at(-1)!.iteration_id, "iteration_id");
|
|
1797
|
+
if (storedLastIterationId !== actualLastIterationId) {
|
|
1798
|
+
throw new Error(`Last iteration identity is invalid in turn ${turnId}.`);
|
|
1799
|
+
}
|
|
1800
|
+
for (
|
|
1801
|
+
let iterationIndex = 0;
|
|
1802
|
+
iterationIndex < iterations.length;
|
|
1803
|
+
iterationIndex += 1
|
|
1804
|
+
) {
|
|
1805
|
+
const iteration = iterations[iterationIndex];
|
|
1806
|
+
if (
|
|
1807
|
+
numberFromSql(iteration.iteration_number, "iteration_number") !==
|
|
1808
|
+
iterationIndex + 1
|
|
1809
|
+
) {
|
|
1810
|
+
throw new Error(`Iteration number sequence has a gap in turn ${turnId}.`);
|
|
1811
|
+
}
|
|
1812
|
+
const iterationId = stringFromSql(iteration.iteration_id, "iteration_id");
|
|
1813
|
+
const outcome = enumFromSql(
|
|
1814
|
+
iteration.outcome,
|
|
1815
|
+
[
|
|
1816
|
+
"open",
|
|
1817
|
+
"continue",
|
|
1818
|
+
"completed",
|
|
1819
|
+
"failed",
|
|
1820
|
+
"cancelled",
|
|
1821
|
+
"interrupted",
|
|
1822
|
+
] as const,
|
|
1823
|
+
"iteration outcome",
|
|
1824
|
+
);
|
|
1825
|
+
if (iterationIndex < iterations.length - 1 && outcome !== "continue") {
|
|
1826
|
+
throw new Error(
|
|
1827
|
+
`Non-final iteration ${iterationId} must have continue outcome.`,
|
|
1828
|
+
);
|
|
1829
|
+
}
|
|
1830
|
+
const toolCalls = view.messages.flatMap((message) =>
|
|
1831
|
+
message.role === "assistant" && message.iterationId === iterationId
|
|
1832
|
+
? (message.toolCalls ?? [])
|
|
1833
|
+
: [],
|
|
1834
|
+
);
|
|
1835
|
+
if (
|
|
1836
|
+
numberFromSql(iteration.next_tool_call_number, "next_tool_call_number") !==
|
|
1837
|
+
toolCalls.length + 1
|
|
1838
|
+
) {
|
|
1839
|
+
throw new Error(`Tool call counter is invalid in iteration ${iterationId}.`);
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
if (
|
|
1843
|
+
numberFromSql(turn.next_iteration_number, "next_iteration_number") !==
|
|
1844
|
+
iterations.length + 1
|
|
1845
|
+
) {
|
|
1846
|
+
throw new Error(`Iteration counter is invalid in turn ${turnId}.`);
|
|
1847
|
+
}
|
|
1848
|
+
const openIterationCount = iterations.filter(
|
|
1849
|
+
(iteration) => iteration.outcome === "open",
|
|
1850
|
+
).length;
|
|
1851
|
+
if (turnStatus === "open" && openIterationCount > 1) {
|
|
1852
|
+
throw new Error(`Open turn ${turnId} has multiple open iterations.`);
|
|
1853
|
+
}
|
|
1854
|
+
if (turnStatus !== "open" && openIterationCount !== 0) {
|
|
1855
|
+
throw new Error(`Terminal turn ${turnId} still has an open iteration.`);
|
|
1856
|
+
}
|
|
1857
|
+
const lastOutcome = iterations.at(-1)?.outcome;
|
|
1858
|
+
if (
|
|
1859
|
+
turnStatus !== "open" &&
|
|
1860
|
+
iterations.length > 0 &&
|
|
1861
|
+
lastOutcome !== turnStatus &&
|
|
1862
|
+
!(turnStatus === "interrupted" && lastOutcome === "continue")
|
|
1863
|
+
) {
|
|
1864
|
+
throw new Error(
|
|
1865
|
+
`Terminal turn ${turnId} does not match its last iteration outcome.`,
|
|
1866
|
+
);
|
|
1867
|
+
}
|
|
1868
|
+
const finalMessageId = nullableStringFromSql(
|
|
1869
|
+
turn.final_message_id,
|
|
1870
|
+
"final_message_id",
|
|
1871
|
+
);
|
|
1872
|
+
if (turnStatus === "completed") {
|
|
1873
|
+
const finalMessage = view.messages.find(
|
|
1874
|
+
(message) => message.messageId === finalMessageId,
|
|
1875
|
+
);
|
|
1876
|
+
const lastTurnMessage = [...view.messages]
|
|
1877
|
+
.reverse()
|
|
1878
|
+
.find((message) => "turnId" in message && message.turnId === turnId);
|
|
1879
|
+
if (
|
|
1880
|
+
finalMessage?.role !== "assistant" ||
|
|
1881
|
+
finalMessage.turnId !== turnId ||
|
|
1882
|
+
(finalMessage.toolCalls?.length ?? 0) !== 0 ||
|
|
1883
|
+
lastTurnMessage?.messageId !== finalMessage.messageId
|
|
1884
|
+
) {
|
|
1885
|
+
throw new Error(`Final message identity is invalid in turn ${turnId}.`);
|
|
1886
|
+
}
|
|
1887
|
+
} else if (finalMessageId !== null) {
|
|
1888
|
+
throw new Error(`Non-completed turn ${turnId} has a final message.`);
|
|
1889
|
+
}
|
|
1890
|
+
}
|
|
1891
|
+
if (meta.nextTurnNumber !== turns.length + 1) {
|
|
1892
|
+
throw new Error("Session turn counter is invalid.");
|
|
1893
|
+
}
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
private requireTurnRow(turnId: TurnId): Record<string, unknown> {
|
|
1897
|
+
const row = this.database
|
|
1898
|
+
.query("SELECT * FROM turns WHERE turn_id = ?")
|
|
1899
|
+
.get(turnId) as Record<string, unknown> | null;
|
|
1900
|
+
if (row === null) {
|
|
1901
|
+
throw new Error(`Unknown turn ${turnId}.`);
|
|
1902
|
+
}
|
|
1903
|
+
return row;
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
private requireIterationRow(iterationId: IterationId): Record<string, unknown> {
|
|
1907
|
+
const row = this.database
|
|
1908
|
+
.query("SELECT * FROM iterations WHERE iteration_id = ?")
|
|
1909
|
+
.get(iterationId) as Record<string, unknown> | null;
|
|
1910
|
+
if (row === null) {
|
|
1911
|
+
throw new Error(`Unknown iteration ${iterationId}.`);
|
|
1912
|
+
}
|
|
1913
|
+
return row;
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
private touch(timestamp: string): void {
|
|
1917
|
+
const updated = this.database
|
|
1918
|
+
.query("UPDATE session_meta SET updated_at = ? WHERE singleton = 1")
|
|
1919
|
+
.run(timestamp);
|
|
1920
|
+
requireSingleChange(this.database, updated.changes, "touch session");
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
private recoveryError(message: string): SessionError {
|
|
1924
|
+
return new SessionError("SESSION_RECOVERY_FAILED", "recover_session", message, {
|
|
1925
|
+
sessionId: this.sessionId,
|
|
1926
|
+
});
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
private requireOpen(): void {
|
|
1930
|
+
if (this.closed) {
|
|
1931
|
+
throw new Error(`SessionStore ${this.sessionId} is closed.`);
|
|
1932
|
+
}
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
private async correctDatabaseModes(): Promise<void> {
|
|
1936
|
+
await chmod(this.databasePath, 0o600);
|
|
1937
|
+
await chmodIfExists(`${this.databasePath}-wal`, 0o600);
|
|
1938
|
+
await chmodIfExists(`${this.databasePath}-shm`, 0o600);
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
export function createRuntimeContract(input: {
|
|
1943
|
+
modelName: string;
|
|
1944
|
+
profileName?: string;
|
|
1945
|
+
includeReasoningContent: boolean;
|
|
1946
|
+
contextProfile: ModelContextProfile;
|
|
1947
|
+
contextBudget: ModelContextBudget;
|
|
1948
|
+
systemPrompt: string;
|
|
1949
|
+
toolSchemaSha256: string;
|
|
1950
|
+
requestConfigSha256: string;
|
|
1951
|
+
}): RuntimeContractV1 {
|
|
1952
|
+
return Object.freeze({
|
|
1953
|
+
version: 1,
|
|
1954
|
+
modelName: input.modelName,
|
|
1955
|
+
...(input.profileName === undefined ? {} : { profileName: input.profileName }),
|
|
1956
|
+
includeReasoningContent: input.includeReasoningContent,
|
|
1957
|
+
contextProfile: immutableCanonicalClone(input.contextProfile),
|
|
1958
|
+
contextBudget: immutableCanonicalClone(input.contextBudget),
|
|
1959
|
+
systemPromptSha256: sha256(input.systemPrompt),
|
|
1960
|
+
toolSchemaSha256: input.toolSchemaSha256,
|
|
1961
|
+
requestConfigSha256: input.requestConfigSha256,
|
|
1962
|
+
observationFormat: TOOL_OBSERVATION_FORMAT,
|
|
1963
|
+
});
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
export function sessionDatabasePath(
|
|
1967
|
+
workspaceRoot: string,
|
|
1968
|
+
sessionId: SessionId,
|
|
1969
|
+
): string {
|
|
1970
|
+
return path.join(workspaceRoot, ".tinker", "sessions", sessionId, "session.sqlite");
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
function insertFrame(database: Database, frame: ProtocolFrame): void {
|
|
1974
|
+
database
|
|
1975
|
+
.query(
|
|
1976
|
+
`INSERT INTO protocol_frames (
|
|
1977
|
+
frame_id, session_id, turn_id, iteration_id, kind, state,
|
|
1978
|
+
first_ordinal, last_ordinal, created_at, closed_at
|
|
1979
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1980
|
+
)
|
|
1981
|
+
.run(
|
|
1982
|
+
frame.frameId,
|
|
1983
|
+
frame.sessionId,
|
|
1984
|
+
frame.turnId ?? null,
|
|
1985
|
+
frame.iterationId ?? null,
|
|
1986
|
+
frame.kind,
|
|
1987
|
+
frame.state,
|
|
1988
|
+
frame.firstOrdinal,
|
|
1989
|
+
frame.lastOrdinal ?? null,
|
|
1990
|
+
frame.createdAt,
|
|
1991
|
+
frame.closedAt ?? null,
|
|
1992
|
+
);
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
function insertMessage(database: Database, message: CanonicalMessageRecord): void {
|
|
1996
|
+
const assistant = message.role === "assistant" ? message : undefined;
|
|
1997
|
+
const tool = message.role === "tool" ? message : undefined;
|
|
1998
|
+
const turnId = "turnId" in message ? message.turnId : null;
|
|
1999
|
+
const iterationId = "iterationId" in message ? message.iterationId : null;
|
|
2000
|
+
const reasoningPresent =
|
|
2001
|
+
assistant !== undefined && assistant.reasoningContent !== undefined ? 1 : 0;
|
|
2002
|
+
database
|
|
2003
|
+
.query(
|
|
2004
|
+
`INSERT INTO messages (
|
|
2005
|
+
message_id, session_id, frame_id, ordinal, role, turn_id, iteration_id,
|
|
2006
|
+
content, content_sha256, reasoning_content, reasoning_content_present,
|
|
2007
|
+
tool_calls_json, provider, model, tool_call_id, provider_tool_call_id,
|
|
2008
|
+
name, origin, created_at
|
|
2009
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
2010
|
+
)
|
|
2011
|
+
.run(
|
|
2012
|
+
message.messageId,
|
|
2013
|
+
message.sessionId,
|
|
2014
|
+
message.frameId,
|
|
2015
|
+
message.ordinal,
|
|
2016
|
+
message.role,
|
|
2017
|
+
turnId,
|
|
2018
|
+
iterationId,
|
|
2019
|
+
message.content,
|
|
2020
|
+
message.contentSha256,
|
|
2021
|
+
assistant?.reasoningContent ?? null,
|
|
2022
|
+
reasoningPresent,
|
|
2023
|
+
assistant?.toolCalls === undefined
|
|
2024
|
+
? null
|
|
2025
|
+
: stableJsonStringify(assistant.toolCalls),
|
|
2026
|
+
assistant?.provider ?? null,
|
|
2027
|
+
assistant?.model ?? null,
|
|
2028
|
+
tool?.toolCallId ?? null,
|
|
2029
|
+
tool?.providerToolCallId ?? null,
|
|
2030
|
+
tool?.name ?? null,
|
|
2031
|
+
message.origin,
|
|
2032
|
+
message.createdAt,
|
|
2033
|
+
);
|
|
2034
|
+
}
|
|
2035
|
+
|
|
2036
|
+
function insertToolResult(database: Database, result: ToolResultRecord): void {
|
|
2037
|
+
const returned = result.completion.kind === "returned" ? result.completion : null;
|
|
2038
|
+
const synthetic = result.completion.kind === "synthetic" ? result.completion : null;
|
|
2039
|
+
database
|
|
2040
|
+
.query(
|
|
2041
|
+
`INSERT INTO tool_results (
|
|
2042
|
+
tool_call_id, session_id, frame_id, tool_message_id, completion_kind,
|
|
2043
|
+
raw_json, raw_sha256, observation_format, synthetic_reason,
|
|
2044
|
+
synthetic_detail, observation_sha256, created_at
|
|
2045
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
2046
|
+
)
|
|
2047
|
+
.run(
|
|
2048
|
+
result.toolCallId,
|
|
2049
|
+
result.sessionId,
|
|
2050
|
+
result.frameId,
|
|
2051
|
+
result.toolMessageId,
|
|
2052
|
+
result.completion.kind,
|
|
2053
|
+
returned === null ? null : stableJsonStringify(returned.raw),
|
|
2054
|
+
returned?.rawSha256 ?? null,
|
|
2055
|
+
returned?.observationFormat ?? null,
|
|
2056
|
+
synthetic?.reason ?? null,
|
|
2057
|
+
synthetic?.detail ?? null,
|
|
2058
|
+
result.observationSha256,
|
|
2059
|
+
result.createdAt,
|
|
2060
|
+
);
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
function decodeFrame(rowValue: unknown): ProtocolFrame {
|
|
2064
|
+
const row = recordFromSql(rowValue, "protocol frame");
|
|
2065
|
+
const state = enumFromSql(row.state, ["open", "closed"] as const, "frame state");
|
|
2066
|
+
const kind = enumFromSql(
|
|
2067
|
+
row.kind,
|
|
2068
|
+
["system", "user", "assistant_text", "tool_exchange"] as const,
|
|
2069
|
+
"frame kind",
|
|
2070
|
+
);
|
|
2071
|
+
const lastOrdinal = nullableNumberFromSql(row.last_ordinal, "last_ordinal");
|
|
2072
|
+
const closedAt = nullableStringFromSql(row.closed_at, "closed_at");
|
|
2073
|
+
return immutableRecord({
|
|
2074
|
+
frameId: stringFromSql(row.frame_id, "frame_id") as ProtocolFrameId,
|
|
2075
|
+
sessionId: stringFromSql(row.session_id, "session_id") as SessionId,
|
|
2076
|
+
...(row.turn_id === null
|
|
2077
|
+
? {}
|
|
2078
|
+
: { turnId: stringFromSql(row.turn_id, "turn_id") as TurnId }),
|
|
2079
|
+
...(row.iteration_id === null
|
|
2080
|
+
? {}
|
|
2081
|
+
: {
|
|
2082
|
+
iterationId: stringFromSql(row.iteration_id, "iteration_id") as IterationId,
|
|
2083
|
+
}),
|
|
2084
|
+
kind,
|
|
2085
|
+
state,
|
|
2086
|
+
firstOrdinal: numberFromSql(row.first_ordinal, "first_ordinal"),
|
|
2087
|
+
...(lastOrdinal === null ? {} : { lastOrdinal }),
|
|
2088
|
+
createdAt: timestampFromSql(row.created_at, "created_at"),
|
|
2089
|
+
...(closedAt === null ? {} : { closedAt: timestampValue(closedAt, "closed_at") }),
|
|
2090
|
+
});
|
|
2091
|
+
}
|
|
2092
|
+
|
|
2093
|
+
function decodeMessage(rowValue: unknown): CanonicalMessageRecord {
|
|
2094
|
+
const row = recordFromSql(rowValue, "message");
|
|
2095
|
+
const base = {
|
|
2096
|
+
messageId: stringFromSql(row.message_id, "message_id") as MessageId,
|
|
2097
|
+
sessionId: stringFromSql(row.session_id, "session_id") as SessionId,
|
|
2098
|
+
frameId: stringFromSql(row.frame_id, "frame_id") as ProtocolFrameId,
|
|
2099
|
+
ordinal: numberFromSql(row.ordinal, "ordinal"),
|
|
2100
|
+
contentSha256: stringFromSql(row.content_sha256, "content_sha256"),
|
|
2101
|
+
createdAt: timestampFromSql(row.created_at, "created_at"),
|
|
2102
|
+
};
|
|
2103
|
+
const role = enumFromSql(
|
|
2104
|
+
row.role,
|
|
2105
|
+
["system", "user", "assistant", "tool"] as const,
|
|
2106
|
+
"message role",
|
|
2107
|
+
);
|
|
2108
|
+
switch (role) {
|
|
2109
|
+
case "system":
|
|
2110
|
+
return immutableRecord({
|
|
2111
|
+
...base,
|
|
2112
|
+
role,
|
|
2113
|
+
content: stringFromSql(row.content, "content"),
|
|
2114
|
+
origin: "runtime",
|
|
2115
|
+
});
|
|
2116
|
+
case "user":
|
|
2117
|
+
return immutableRecord({
|
|
2118
|
+
...base,
|
|
2119
|
+
role,
|
|
2120
|
+
turnId: stringFromSql(row.turn_id, "turn_id") as TurnId,
|
|
2121
|
+
content: stringFromSql(row.content, "content"),
|
|
2122
|
+
origin: "user",
|
|
2123
|
+
});
|
|
2124
|
+
case "assistant": {
|
|
2125
|
+
const content = nullableTextFromSql(row.content, "content");
|
|
2126
|
+
const reasoningPresent = numberFromSql(
|
|
2127
|
+
row.reasoning_content_present,
|
|
2128
|
+
"reasoning_content_present",
|
|
2129
|
+
);
|
|
2130
|
+
if (reasoningPresent !== 0 && reasoningPresent !== 1) {
|
|
2131
|
+
throw new Error("reasoning_content_present must be 0 or 1.");
|
|
2132
|
+
}
|
|
2133
|
+
const toolCalls =
|
|
2134
|
+
row.tool_calls_json === null
|
|
2135
|
+
? undefined
|
|
2136
|
+
: decodeStoredToolCalls(
|
|
2137
|
+
stringFromSql(row.tool_calls_json, "tool_calls_json"),
|
|
2138
|
+
);
|
|
2139
|
+
return immutableRecord({
|
|
2140
|
+
...base,
|
|
2141
|
+
role,
|
|
2142
|
+
turnId: stringFromSql(row.turn_id, "turn_id") as TurnId,
|
|
2143
|
+
iterationId: stringFromSql(row.iteration_id, "iteration_id") as IterationId,
|
|
2144
|
+
content,
|
|
2145
|
+
...(reasoningPresent === 0
|
|
2146
|
+
? {}
|
|
2147
|
+
: {
|
|
2148
|
+
reasoningContent: nullableTextFromSql(
|
|
2149
|
+
row.reasoning_content,
|
|
2150
|
+
"reasoning_content",
|
|
2151
|
+
),
|
|
2152
|
+
}),
|
|
2153
|
+
...(toolCalls === undefined ? {} : { toolCalls }),
|
|
2154
|
+
provider: stringFromSql(row.provider, "provider"),
|
|
2155
|
+
model: stringFromSql(row.model, "model"),
|
|
2156
|
+
origin: "model",
|
|
2157
|
+
});
|
|
2158
|
+
}
|
|
2159
|
+
case "tool":
|
|
2160
|
+
return immutableRecord({
|
|
2161
|
+
...base,
|
|
2162
|
+
role,
|
|
2163
|
+
turnId: stringFromSql(row.turn_id, "turn_id") as TurnId,
|
|
2164
|
+
iterationId: stringFromSql(row.iteration_id, "iteration_id") as IterationId,
|
|
2165
|
+
toolCallId: stringFromSql(row.tool_call_id, "tool_call_id") as ToolCallId,
|
|
2166
|
+
providerToolCallId: stringFromSql(
|
|
2167
|
+
row.provider_tool_call_id,
|
|
2168
|
+
"provider_tool_call_id",
|
|
2169
|
+
),
|
|
2170
|
+
name: stringFromSql(row.name, "name"),
|
|
2171
|
+
content: stringFromSql(row.content, "content"),
|
|
2172
|
+
origin: enumFromSql(row.origin, ["tool", "runtime"] as const, "tool origin"),
|
|
2173
|
+
});
|
|
2174
|
+
}
|
|
2175
|
+
}
|
|
2176
|
+
|
|
2177
|
+
function decodeToolResult(rowValue: unknown): ToolResultRecord {
|
|
2178
|
+
const row = recordFromSql(rowValue, "tool result");
|
|
2179
|
+
const kind = enumFromSql(
|
|
2180
|
+
row.completion_kind,
|
|
2181
|
+
["returned", "synthetic"] as const,
|
|
2182
|
+
"completion kind",
|
|
2183
|
+
);
|
|
2184
|
+
let completion: ToolCompletion;
|
|
2185
|
+
if (kind === "returned") {
|
|
2186
|
+
completion = immutableRecord({
|
|
2187
|
+
kind,
|
|
2188
|
+
raw: decodeStoredToolRawResult(
|
|
2189
|
+
parseJson(stringFromSql(row.raw_json, "raw_json"), "raw_json"),
|
|
2190
|
+
),
|
|
2191
|
+
rawSha256: stringFromSql(row.raw_sha256, "raw_sha256"),
|
|
2192
|
+
observationFormat: enumFromSql(
|
|
2193
|
+
row.observation_format,
|
|
2194
|
+
[TOOL_OBSERVATION_FORMAT] as const,
|
|
2195
|
+
"observation format",
|
|
2196
|
+
),
|
|
2197
|
+
});
|
|
2198
|
+
} else {
|
|
2199
|
+
const reason = enumFromSql(
|
|
2200
|
+
row.synthetic_reason,
|
|
2201
|
+
[
|
|
2202
|
+
"cancelled_active",
|
|
2203
|
+
"skipped_after_cancel",
|
|
2204
|
+
"failed_active",
|
|
2205
|
+
"skipped_after_failure",
|
|
2206
|
+
"interrupted_active",
|
|
2207
|
+
"skipped_after_interruption",
|
|
2208
|
+
] as const,
|
|
2209
|
+
"synthetic reason",
|
|
2210
|
+
);
|
|
2211
|
+
completion = immutableRecord({
|
|
2212
|
+
kind,
|
|
2213
|
+
reason,
|
|
2214
|
+
...(row.synthetic_detail === null
|
|
2215
|
+
? {}
|
|
2216
|
+
: {
|
|
2217
|
+
detail: stringFromSql(row.synthetic_detail, "synthetic_detail"),
|
|
2218
|
+
}),
|
|
2219
|
+
});
|
|
2220
|
+
}
|
|
2221
|
+
return immutableRecord({
|
|
2222
|
+
sessionId: stringFromSql(row.session_id, "session_id") as SessionId,
|
|
2223
|
+
frameId: stringFromSql(row.frame_id, "frame_id") as ProtocolFrameId,
|
|
2224
|
+
toolCallId: stringFromSql(row.tool_call_id, "tool_call_id") as ToolCallId,
|
|
2225
|
+
toolMessageId: stringFromSql(row.tool_message_id, "tool_message_id") as MessageId,
|
|
2226
|
+
completion,
|
|
2227
|
+
observationSha256: stringFromSql(row.observation_sha256, "observation_sha256"),
|
|
2228
|
+
createdAt: timestampFromSql(row.created_at, "created_at"),
|
|
2229
|
+
});
|
|
2230
|
+
}
|
|
2231
|
+
|
|
2232
|
+
function decodeContextRevision(rowValue: unknown): StoredContextRevisionV5 {
|
|
2233
|
+
const row = recordFromSql(rowValue, "context revision");
|
|
2234
|
+
const kind = enumFromSql(
|
|
2235
|
+
row.kind,
|
|
2236
|
+
["initial_full", "swap_only"] as const,
|
|
2237
|
+
"context revision kind",
|
|
2238
|
+
);
|
|
2239
|
+
const common = {
|
|
2240
|
+
revisionId: stringFromSql(row.revision_id, "revision_id") as ContextRevisionId,
|
|
2241
|
+
sessionId: stringFromSql(row.session_id, "session_id") as SessionId,
|
|
2242
|
+
keepFromOrdinal: numberFromSql(row.keep_from_ordinal, "keep_from_ordinal"),
|
|
2243
|
+
sourceThroughOrdinal: numberFromSql(
|
|
2244
|
+
row.source_through_ordinal,
|
|
2245
|
+
"source_through_ordinal",
|
|
2246
|
+
),
|
|
2247
|
+
addedOverrideCount: numberFromSql(row.added_override_count, "added_override_count"),
|
|
2248
|
+
totalOverrideCount: numberFromSql(row.total_override_count, "total_override_count"),
|
|
2249
|
+
overrideManifestSha256: sha256FromSql(
|
|
2250
|
+
row.override_manifest_sha256,
|
|
2251
|
+
"override_manifest_sha256",
|
|
2252
|
+
),
|
|
2253
|
+
canonicalSequenceSha256: sha256FromSql(
|
|
2254
|
+
row.canonical_sequence_sha256,
|
|
2255
|
+
"canonical_sequence_sha256",
|
|
2256
|
+
),
|
|
2257
|
+
renderedMessageSha256: sha256FromSql(
|
|
2258
|
+
row.rendered_message_sha256,
|
|
2259
|
+
"rendered_message_sha256",
|
|
2260
|
+
),
|
|
2261
|
+
createdAt: timestampFromSql(row.created_at, "created_at"),
|
|
2262
|
+
};
|
|
2263
|
+
if (common.keepFromOrdinal !== 1) {
|
|
2264
|
+
throw new Error("Context revision keep_from_ordinal must be 1 in schema v5.");
|
|
2265
|
+
}
|
|
2266
|
+
const revisionNumber = numberFromSql(row.revision_number, "revision_number");
|
|
2267
|
+
const parentRevisionId = nullableStringFromSql(
|
|
2268
|
+
row.parent_revision_id,
|
|
2269
|
+
"parent_revision_id",
|
|
2270
|
+
) as ContextRevisionId | null;
|
|
2271
|
+
if (kind === "initial_full") {
|
|
2272
|
+
if (
|
|
2273
|
+
revisionNumber !== 1 ||
|
|
2274
|
+
parentRevisionId !== null ||
|
|
2275
|
+
common.sourceThroughOrdinal !== 1 ||
|
|
2276
|
+
common.addedOverrideCount !== 0 ||
|
|
2277
|
+
common.totalOverrideCount !== 0 ||
|
|
2278
|
+
row.policy_version !== null ||
|
|
2279
|
+
row.renderer_format !== null ||
|
|
2280
|
+
row.plan_sha256 !== null
|
|
2281
|
+
) {
|
|
2282
|
+
throw new Error("Initial context revision row is invalid.");
|
|
2283
|
+
}
|
|
2284
|
+
return Object.freeze({
|
|
2285
|
+
...common,
|
|
2286
|
+
revisionNumber: 1,
|
|
2287
|
+
parentRevisionId: null,
|
|
2288
|
+
kind,
|
|
2289
|
+
keepFromOrdinal: 1,
|
|
2290
|
+
sourceThroughOrdinal: 1,
|
|
2291
|
+
addedOverrideCount: 0,
|
|
2292
|
+
totalOverrideCount: 0,
|
|
2293
|
+
});
|
|
2294
|
+
}
|
|
2295
|
+
if (
|
|
2296
|
+
revisionNumber < 2 ||
|
|
2297
|
+
parentRevisionId === null ||
|
|
2298
|
+
common.addedOverrideCount < 1 ||
|
|
2299
|
+
common.totalOverrideCount < common.addedOverrideCount
|
|
2300
|
+
) {
|
|
2301
|
+
throw new Error("Swap context revision row is invalid.");
|
|
2302
|
+
}
|
|
2303
|
+
return Object.freeze({
|
|
2304
|
+
...common,
|
|
2305
|
+
revisionNumber,
|
|
2306
|
+
parentRevisionId,
|
|
2307
|
+
kind,
|
|
2308
|
+
keepFromOrdinal: 1,
|
|
2309
|
+
policyVersion: enumFromSql(
|
|
2310
|
+
row.policy_version,
|
|
2311
|
+
["swap-only-v1"] as const,
|
|
2312
|
+
"context revision policy",
|
|
2313
|
+
),
|
|
2314
|
+
rendererFormat: enumFromSql(
|
|
2315
|
+
row.renderer_format,
|
|
2316
|
+
[SWAP_OBSERVATION_FORMAT] as const,
|
|
2317
|
+
"context revision renderer format",
|
|
2318
|
+
),
|
|
2319
|
+
planSha256: sha256FromSql(row.plan_sha256, "plan_sha256"),
|
|
2320
|
+
});
|
|
2321
|
+
}
|
|
2322
|
+
|
|
2323
|
+
function decodeStoredSwapOverride(rowValue: unknown): StoredSwapOverrideV5 {
|
|
2324
|
+
const row = recordFromSql(rowValue, "context override");
|
|
2325
|
+
if (row.representation !== "swapped") {
|
|
2326
|
+
throw new Error("Context override representation must be swapped.");
|
|
2327
|
+
}
|
|
2328
|
+
return Object.freeze({
|
|
2329
|
+
introducedRevisionId: stringFromSql(
|
|
2330
|
+
row.introduced_revision_id,
|
|
2331
|
+
"introduced_revision_id",
|
|
2332
|
+
) as ContextRevisionId,
|
|
2333
|
+
frameId: stringFromSql(row.frame_id, "frame_id") as ProtocolFrameId,
|
|
2334
|
+
messageId: stringFromSql(row.message_id, "message_id") as MessageId,
|
|
2335
|
+
ordinal: numberFromSql(row.ordinal, "ordinal"),
|
|
2336
|
+
rendererFormat: enumFromSql(
|
|
2337
|
+
row.renderer_format,
|
|
2338
|
+
[SWAP_OBSERVATION_FORMAT] as const,
|
|
2339
|
+
"context override renderer format",
|
|
2340
|
+
),
|
|
2341
|
+
source: stringFromSql(row.source, "source") as StoredSwapOverrideV5["source"],
|
|
2342
|
+
originalContentSha256: sha256FromSql(
|
|
2343
|
+
row.original_content_sha256,
|
|
2344
|
+
"original_content_sha256",
|
|
2345
|
+
),
|
|
2346
|
+
renderedContent: stringFromSql(row.rendered_content, "rendered_content"),
|
|
2347
|
+
renderedContentSha256: sha256FromSql(
|
|
2348
|
+
row.rendered_content_sha256,
|
|
2349
|
+
"rendered_content_sha256",
|
|
2350
|
+
),
|
|
2351
|
+
originalBytes: numberFromSql(row.original_bytes, "original_bytes"),
|
|
2352
|
+
renderedBytes: numberFromSql(row.rendered_bytes, "rendered_bytes"),
|
|
2353
|
+
byteSavings: numberFromSql(row.byte_savings, "byte_savings"),
|
|
2354
|
+
createdAt: timestampFromSql(row.created_at, "created_at"),
|
|
2355
|
+
});
|
|
2356
|
+
}
|
|
2357
|
+
|
|
2358
|
+
function stripStoredOverride(override: StoredSwapOverrideV5): SwapOverride {
|
|
2359
|
+
return Object.freeze({
|
|
2360
|
+
frameId: override.frameId,
|
|
2361
|
+
messageId: override.messageId,
|
|
2362
|
+
ordinal: override.ordinal,
|
|
2363
|
+
source: override.source,
|
|
2364
|
+
originalContentSha256: override.originalContentSha256,
|
|
2365
|
+
renderedContent: override.renderedContent,
|
|
2366
|
+
renderedContentSha256: override.renderedContentSha256,
|
|
2367
|
+
originalBytes: override.originalBytes,
|
|
2368
|
+
renderedBytes: override.renderedBytes,
|
|
2369
|
+
byteSavings: override.byteSavings,
|
|
2370
|
+
});
|
|
2371
|
+
}
|
|
2372
|
+
|
|
2373
|
+
function protocolPrefixView(
|
|
2374
|
+
canonical: ProtocolContextView,
|
|
2375
|
+
throughOrdinal: number,
|
|
2376
|
+
): ProtocolContextView {
|
|
2377
|
+
const messages = canonical.messages.filter(
|
|
2378
|
+
(message) => message.ordinal <= throughOrdinal,
|
|
2379
|
+
);
|
|
2380
|
+
const messageIds = new Set(messages.map((message) => message.messageId));
|
|
2381
|
+
return Object.freeze({
|
|
2382
|
+
sessionId: canonical.sessionId,
|
|
2383
|
+
faulted: false,
|
|
2384
|
+
frames: Object.freeze(
|
|
2385
|
+
canonical.frames.filter(
|
|
2386
|
+
(frame) =>
|
|
2387
|
+
frame.state === "closed" &&
|
|
2388
|
+
frame.lastOrdinal !== undefined &&
|
|
2389
|
+
frame.lastOrdinal <= throughOrdinal,
|
|
2390
|
+
),
|
|
2391
|
+
),
|
|
2392
|
+
messages: Object.freeze(messages),
|
|
2393
|
+
toolResults: Object.freeze(
|
|
2394
|
+
canonical.toolResults.filter((result) => messageIds.has(result.toolMessageId)),
|
|
2395
|
+
),
|
|
2396
|
+
});
|
|
2397
|
+
}
|
|
2398
|
+
|
|
2399
|
+
export function decodeStoredToolCalls(json: string): readonly ToolCall[] {
|
|
2400
|
+
const value = parseJson(json, "tool_calls_json");
|
|
2401
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
2402
|
+
throw new Error("tool_calls_json must contain a non-empty array.");
|
|
2403
|
+
}
|
|
2404
|
+
const calls = value.map((entry, index): ToolCall => {
|
|
2405
|
+
const call = recordFromSql(entry, `tool call ${index}`);
|
|
2406
|
+
assertObjectKeys(
|
|
2407
|
+
call,
|
|
2408
|
+
[
|
|
2409
|
+
"args",
|
|
2410
|
+
"argsParseError",
|
|
2411
|
+
"iterationId",
|
|
2412
|
+
"iterationNumber",
|
|
2413
|
+
"name",
|
|
2414
|
+
"providerToolCallId",
|
|
2415
|
+
"rawArgs",
|
|
2416
|
+
"sessionId",
|
|
2417
|
+
"toolCallId",
|
|
2418
|
+
"toolCallNumber",
|
|
2419
|
+
"turnId",
|
|
2420
|
+
"turnNumber",
|
|
2421
|
+
],
|
|
2422
|
+
[
|
|
2423
|
+
"args",
|
|
2424
|
+
"iterationId",
|
|
2425
|
+
"iterationNumber",
|
|
2426
|
+
"name",
|
|
2427
|
+
"providerToolCallId",
|
|
2428
|
+
"sessionId",
|
|
2429
|
+
"toolCallId",
|
|
2430
|
+
"toolCallNumber",
|
|
2431
|
+
"turnId",
|
|
2432
|
+
"turnNumber",
|
|
2433
|
+
],
|
|
2434
|
+
`tool call ${index}`,
|
|
2435
|
+
);
|
|
2436
|
+
const toolCallNumber = numberFromJson(call.toolCallNumber, "toolCallNumber");
|
|
2437
|
+
return {
|
|
2438
|
+
sessionId: stringFromSql(call.sessionId, "sessionId") as SessionId,
|
|
2439
|
+
turnId: stringFromSql(call.turnId, "turnId") as TurnId,
|
|
2440
|
+
turnNumber: numberFromJson(call.turnNumber, "turnNumber"),
|
|
2441
|
+
iterationId: stringFromSql(call.iterationId, "iterationId") as IterationId,
|
|
2442
|
+
iterationNumber: numberFromJson(call.iterationNumber, "iterationNumber"),
|
|
2443
|
+
toolCallId: stringFromSql(call.toolCallId, "toolCallId") as ToolCallId,
|
|
2444
|
+
toolCallNumber,
|
|
2445
|
+
providerToolCallId: stringFromSql(call.providerToolCallId, "providerToolCallId"),
|
|
2446
|
+
name: stringFromSql(call.name, "name"),
|
|
2447
|
+
args: immutableCanonicalClone(call.args),
|
|
2448
|
+
...(call.rawArgs === undefined
|
|
2449
|
+
? {}
|
|
2450
|
+
: { rawArgs: stringFromSql(call.rawArgs, "rawArgs") }),
|
|
2451
|
+
...(call.argsParseError === undefined
|
|
2452
|
+
? {}
|
|
2453
|
+
: {
|
|
2454
|
+
argsParseError: stringFromSql(call.argsParseError, "argsParseError"),
|
|
2455
|
+
}),
|
|
2456
|
+
};
|
|
2457
|
+
});
|
|
2458
|
+
return Object.freeze(calls);
|
|
2459
|
+
}
|
|
2460
|
+
|
|
2461
|
+
export function decodeStoredToolRawResult(value: unknown): ToolRawResult {
|
|
2462
|
+
const raw = recordFromSql(value, "tool raw result");
|
|
2463
|
+
enumFromSql(
|
|
2464
|
+
raw.kind,
|
|
2465
|
+
[
|
|
2466
|
+
"read",
|
|
2467
|
+
"write",
|
|
2468
|
+
"edit",
|
|
2469
|
+
"glob",
|
|
2470
|
+
"grep",
|
|
2471
|
+
"bash",
|
|
2472
|
+
"task_list",
|
|
2473
|
+
"task_output",
|
|
2474
|
+
"task_stop",
|
|
2475
|
+
"web_search",
|
|
2476
|
+
"web_fetch",
|
|
2477
|
+
"recall",
|
|
2478
|
+
"mcp",
|
|
2479
|
+
"generic",
|
|
2480
|
+
] as const,
|
|
2481
|
+
"tool raw result kind",
|
|
2482
|
+
);
|
|
2483
|
+
if (typeof raw.ok !== "boolean") {
|
|
2484
|
+
throw new Error("tool raw result ok must be a boolean.");
|
|
2485
|
+
}
|
|
2486
|
+
return immutableCanonicalClone(raw) as ToolRawResult;
|
|
2487
|
+
}
|
|
2488
|
+
|
|
2489
|
+
function decodeMeasuredContextState(
|
|
2490
|
+
value: unknown,
|
|
2491
|
+
expectedSessionId: SessionId,
|
|
2492
|
+
): StoredMeasuredContextState {
|
|
2493
|
+
const row = recordFromSql(value, "context measurement state");
|
|
2494
|
+
const sessionId = stringFromSql(row.session_id, "session_id") as SessionId;
|
|
2495
|
+
if (sessionId !== expectedSessionId) {
|
|
2496
|
+
throw new Error(
|
|
2497
|
+
`Context measurement session ID ${sessionId} does not match store.`,
|
|
2498
|
+
);
|
|
2499
|
+
}
|
|
2500
|
+
const promptTokens = numberFromSql(row.prompt_tokens, "prompt_tokens");
|
|
2501
|
+
const completionTokens = numberFromSql(row.completion_tokens, "completion_tokens");
|
|
2502
|
+
const totalTokens = numberFromSql(row.total_tokens, "total_tokens");
|
|
2503
|
+
if (totalTokens !== promptTokens + completionTokens) {
|
|
2504
|
+
throw new Error(
|
|
2505
|
+
"Context measurement total_tokens must equal prompt_tokens + completion_tokens.",
|
|
2506
|
+
);
|
|
2507
|
+
}
|
|
2508
|
+
timestampFromSql(row.updated_at, "updated_at");
|
|
2509
|
+
return Object.freeze({
|
|
2510
|
+
revisionId: stringFromSql(row.revision_id, "revision_id") as ContextRevisionId,
|
|
2511
|
+
anchor: Object.freeze({
|
|
2512
|
+
totalTokens,
|
|
2513
|
+
promptTokens,
|
|
2514
|
+
completionTokens,
|
|
2515
|
+
segmentCount: numberFromSql(row.segment_count, "segment_count"),
|
|
2516
|
+
prefixHash: sha256FromSql(row.prefix_hash, "prefix_hash"),
|
|
2517
|
+
requestConfigHash: sha256FromSql(row.request_config_hash, "request_config_hash"),
|
|
2518
|
+
toolSchemaHash: sha256FromSql(row.tool_schema_hash, "tool_schema_hash"),
|
|
2519
|
+
}),
|
|
2520
|
+
});
|
|
2521
|
+
}
|
|
2522
|
+
|
|
2523
|
+
function assertCommitSwapRevisionInput(input: CommitSwapRevisionInput): void {
|
|
2524
|
+
if (
|
|
2525
|
+
input.revisionId.trim() === "" ||
|
|
2526
|
+
input.expectedBaseRevisionId.trim() === "" ||
|
|
2527
|
+
!Number.isSafeInteger(input.expectedBaseRevisionNumber) ||
|
|
2528
|
+
input.expectedBaseRevisionNumber < 1 ||
|
|
2529
|
+
!Number.isSafeInteger(input.expectedCanonicalThroughOrdinal) ||
|
|
2530
|
+
input.expectedCanonicalThroughOrdinal < 1 ||
|
|
2531
|
+
input.addedOverrides.length < 1 ||
|
|
2532
|
+
input.policyVersion !== "swap-only-v1" ||
|
|
2533
|
+
input.rendererFormat !== SWAP_OBSERVATION_FORMAT
|
|
2534
|
+
) {
|
|
2535
|
+
throw new Error("Commit swap revision input is invalid.");
|
|
2536
|
+
}
|
|
2537
|
+
for (const [name, hash] of [
|
|
2538
|
+
["expectedBaseOverrideManifestSha256", input.expectedBaseOverrideManifestSha256],
|
|
2539
|
+
["planHash", input.planHash],
|
|
2540
|
+
["nextOverrideManifestSha256", input.nextOverrideManifestSha256],
|
|
2541
|
+
["canonicalSequenceSha256", input.canonicalSequenceSha256],
|
|
2542
|
+
["renderedMessageSha256", input.renderedMessageSha256],
|
|
2543
|
+
] as const) {
|
|
2544
|
+
if (!/^[0-9a-f]{64}$/.test(hash)) {
|
|
2545
|
+
throw new Error(`Commit swap revision ${name} must be a SHA-256 hash.`);
|
|
2546
|
+
}
|
|
2547
|
+
}
|
|
2548
|
+
}
|
|
2549
|
+
|
|
2550
|
+
function decodeMeta(value: unknown, expectedSessionId: SessionId): StoredSessionMetaV5 {
|
|
2551
|
+
const row = recordFromSql(value, "session metadata");
|
|
2552
|
+
const sessionId = stringFromSql(row.session_id, "session_id") as SessionId;
|
|
2553
|
+
if (sessionId !== expectedSessionId) {
|
|
2554
|
+
throw new Error(`Metadata session ID ${sessionId} does not match directory.`);
|
|
2555
|
+
}
|
|
2556
|
+
const schemaVersion = numberFromSql(row.schema_version, "schema_version");
|
|
2557
|
+
if (schemaVersion !== 5) {
|
|
2558
|
+
throw new Error(
|
|
2559
|
+
`Session metadata schema version must be 5; received ${schemaVersion}.`,
|
|
2560
|
+
);
|
|
2561
|
+
}
|
|
2562
|
+
const projectInstructionFile = nullableStringFromSql(
|
|
2563
|
+
row.project_instruction_file,
|
|
2564
|
+
"project_instruction_file",
|
|
2565
|
+
);
|
|
2566
|
+
const projectInstructionByteLength = nullableNumberFromSql(
|
|
2567
|
+
row.project_instruction_byte_length,
|
|
2568
|
+
"project_instruction_byte_length",
|
|
2569
|
+
);
|
|
2570
|
+
const projectInstructionSha256 = nullableStringFromSql(
|
|
2571
|
+
row.project_instruction_sha256,
|
|
2572
|
+
"project_instruction_sha256",
|
|
2573
|
+
);
|
|
2574
|
+
if (
|
|
2575
|
+
(projectInstructionFile === null) !== (projectInstructionByteLength === null) ||
|
|
2576
|
+
(projectInstructionFile === null) !== (projectInstructionSha256 === null)
|
|
2577
|
+
) {
|
|
2578
|
+
throw new Error("Project instruction metadata must be entirely set or null.");
|
|
2579
|
+
}
|
|
2580
|
+
if (
|
|
2581
|
+
projectInstructionFile !== null &&
|
|
2582
|
+
projectInstructionFile !== "AGENTS.md" &&
|
|
2583
|
+
projectInstructionFile !== "CLAUDE.md"
|
|
2584
|
+
) {
|
|
2585
|
+
throw new Error(`Invalid project instruction file ${projectInstructionFile}.`);
|
|
2586
|
+
}
|
|
2587
|
+
const projectInstruction: ProjectInstructionManifest | undefined =
|
|
2588
|
+
projectInstructionFile === null ||
|
|
2589
|
+
projectInstructionByteLength === null ||
|
|
2590
|
+
projectInstructionSha256 === null
|
|
2591
|
+
? undefined
|
|
2592
|
+
: {
|
|
2593
|
+
path: projectInstructionFile === "AGENTS.md" ? "AGENTS.md" : "CLAUDE.md",
|
|
2594
|
+
byteLength: projectInstructionByteLength,
|
|
2595
|
+
sha256: sha256FromSql(projectInstructionSha256, "project_instruction_sha256"),
|
|
2596
|
+
};
|
|
2597
|
+
return {
|
|
2598
|
+
schemaVersion,
|
|
2599
|
+
schemaFingerprint: stringFromSql(row.schema_fingerprint, "schema_fingerprint"),
|
|
2600
|
+
initializationState: enumFromSql(
|
|
2601
|
+
row.initialization_state,
|
|
2602
|
+
["creating", "ready"] as const,
|
|
2603
|
+
"initialization_state",
|
|
2604
|
+
),
|
|
2605
|
+
sessionId,
|
|
2606
|
+
workspaceRoot: stringFromSql(row.workspace_root, "workspace_root"),
|
|
2607
|
+
modelName: stringFromSql(row.model_name, "model_name"),
|
|
2608
|
+
systemPromptSha256: stringFromSql(row.system_prompt_sha256, "system_prompt_sha256"),
|
|
2609
|
+
...(projectInstruction === undefined ? {} : { projectInstruction }),
|
|
2610
|
+
toolSchemaSha256: nullableStringFromSql(
|
|
2611
|
+
row.tool_schema_sha256,
|
|
2612
|
+
"tool_schema_sha256",
|
|
2613
|
+
),
|
|
2614
|
+
runtimeContractJson: nullableStringFromSql(
|
|
2615
|
+
row.runtime_contract_json,
|
|
2616
|
+
"runtime_contract_json",
|
|
2617
|
+
),
|
|
2618
|
+
runtimeContractSha256: nullableStringFromSql(
|
|
2619
|
+
row.runtime_contract_sha256,
|
|
2620
|
+
"runtime_contract_sha256",
|
|
2621
|
+
),
|
|
2622
|
+
activeRevisionId: stringFromSql(
|
|
2623
|
+
row.active_revision_id,
|
|
2624
|
+
"active_revision_id",
|
|
2625
|
+
) as ContextRevisionId,
|
|
2626
|
+
nextTurnNumber: numberFromSql(row.next_turn_number, "next_turn_number"),
|
|
2627
|
+
nextEventSequence: numberFromSql(row.next_event_sequence, "next_event_sequence"),
|
|
2628
|
+
openCount: numberFromSql(row.open_count, "open_count"),
|
|
2629
|
+
createdAt: timestampFromSql(row.created_at, "created_at"),
|
|
2630
|
+
updatedAt: timestampFromSql(row.updated_at, "updated_at"),
|
|
2631
|
+
lastOpenedAt: timestampFromSql(row.last_opened_at, "last_opened_at"),
|
|
2632
|
+
lastClosedAt:
|
|
2633
|
+
row.last_closed_at === null
|
|
2634
|
+
? null
|
|
2635
|
+
: timestampFromSql(row.last_closed_at, "last_closed_at"),
|
|
2636
|
+
lastCloseReason:
|
|
2637
|
+
row.last_close_reason === null
|
|
2638
|
+
? null
|
|
2639
|
+
: enumFromSql(
|
|
2640
|
+
row.last_close_reason,
|
|
2641
|
+
[
|
|
2642
|
+
"oneshot_complete",
|
|
2643
|
+
"tui_exit",
|
|
2644
|
+
"session_switch",
|
|
2645
|
+
"runner_failed",
|
|
2646
|
+
"initialization_failed",
|
|
2647
|
+
] as const,
|
|
2648
|
+
"last_close_reason",
|
|
2649
|
+
),
|
|
2650
|
+
};
|
|
2651
|
+
}
|
|
2652
|
+
|
|
2653
|
+
function runtimeContractDifferences(
|
|
2654
|
+
storedJson: string | null,
|
|
2655
|
+
current: RuntimeContractV1,
|
|
2656
|
+
): string[] {
|
|
2657
|
+
if (storedJson === null) {
|
|
2658
|
+
return ["runtimeContract"];
|
|
2659
|
+
}
|
|
2660
|
+
const stored = parseJson(storedJson, "runtime_contract_json");
|
|
2661
|
+
if (typeof stored !== "object" || stored === null || Array.isArray(stored)) {
|
|
2662
|
+
return ["runtimeContract"];
|
|
2663
|
+
}
|
|
2664
|
+
const record = stored as Record<string, unknown>;
|
|
2665
|
+
return Object.keys(current).filter(
|
|
2666
|
+
(key) =>
|
|
2667
|
+
stableJsonStringify(record[key]) !==
|
|
2668
|
+
stableJsonStringify(current[key as keyof RuntimeContractV1]),
|
|
2669
|
+
);
|
|
2670
|
+
}
|
|
2671
|
+
|
|
2672
|
+
function openWritableDatabase(databasePath: string): Database {
|
|
2673
|
+
const database = new Database(databasePath, {
|
|
2674
|
+
create: false,
|
|
2675
|
+
readwrite: true,
|
|
2676
|
+
strict: true,
|
|
2677
|
+
safeIntegers: true,
|
|
2678
|
+
});
|
|
2679
|
+
configureWritableDatabase(database);
|
|
2680
|
+
return database;
|
|
2681
|
+
}
|
|
2682
|
+
|
|
2683
|
+
function runTransaction<T>(database: Database, operation: () => T): T {
|
|
2684
|
+
database.exec("BEGIN IMMEDIATE");
|
|
2685
|
+
try {
|
|
2686
|
+
const result = operation();
|
|
2687
|
+
database.exec("COMMIT");
|
|
2688
|
+
return result;
|
|
2689
|
+
} catch (error) {
|
|
2690
|
+
try {
|
|
2691
|
+
database.exec("ROLLBACK");
|
|
2692
|
+
} catch {
|
|
2693
|
+
// Preserve the mutation error; the session will fault and close the database.
|
|
2694
|
+
}
|
|
2695
|
+
throw error;
|
|
2696
|
+
}
|
|
2697
|
+
}
|
|
2698
|
+
|
|
2699
|
+
async function canonicalWorkspaceRoot(workspaceRoot: string): Promise<string> {
|
|
2700
|
+
if (!path.isAbsolute(workspaceRoot)) {
|
|
2701
|
+
throw new Error("Session workspace root must be absolute.");
|
|
2702
|
+
}
|
|
2703
|
+
return realpath(workspaceRoot);
|
|
2704
|
+
}
|
|
2705
|
+
|
|
2706
|
+
async function ensureSessionsRoot(workspaceRoot: string): Promise<string> {
|
|
2707
|
+
const tinkerRoot = path.join(workspaceRoot, ".tinker");
|
|
2708
|
+
const sessionsRoot = path.join(tinkerRoot, "sessions");
|
|
2709
|
+
await mkdir(sessionsRoot, { recursive: true, mode: 0o700 });
|
|
2710
|
+
await validateSessionsRoot(sessionsRoot);
|
|
2711
|
+
await chmod(tinkerRoot, 0o700);
|
|
2712
|
+
await chmod(sessionsRoot, 0o700);
|
|
2713
|
+
return sessionsRoot;
|
|
2714
|
+
}
|
|
2715
|
+
|
|
2716
|
+
async function validateSessionsRoot(
|
|
2717
|
+
sessionsRoot: string,
|
|
2718
|
+
sessionId?: SessionId,
|
|
2719
|
+
): Promise<void> {
|
|
2720
|
+
const tinkerRoot = path.dirname(sessionsRoot);
|
|
2721
|
+
for (const directory of [tinkerRoot, sessionsRoot]) {
|
|
2722
|
+
let stats;
|
|
2723
|
+
try {
|
|
2724
|
+
stats = await lstat(directory);
|
|
2725
|
+
} catch (error) {
|
|
2726
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
2727
|
+
throw new SessionError(
|
|
2728
|
+
"SESSION_STORE_NOT_FOUND",
|
|
2729
|
+
"validate_session_root",
|
|
2730
|
+
`Session root does not exist: ${directory}.`,
|
|
2731
|
+
{ sessionId, cause: error },
|
|
2732
|
+
);
|
|
2733
|
+
}
|
|
2734
|
+
throw error;
|
|
2735
|
+
}
|
|
2736
|
+
if (!stats.isDirectory() || stats.isSymbolicLink()) {
|
|
2737
|
+
throw new SessionError(
|
|
2738
|
+
"SESSION_PERMISSION_INVALID",
|
|
2739
|
+
"validate_session_root",
|
|
2740
|
+
`Session root must not be a symlink: ${directory}.`,
|
|
2741
|
+
{ sessionId },
|
|
2742
|
+
);
|
|
2743
|
+
}
|
|
2744
|
+
}
|
|
2745
|
+
if ((await realpath(sessionsRoot)) !== sessionsRoot) {
|
|
2746
|
+
throw new SessionError(
|
|
2747
|
+
"SESSION_PERMISSION_INVALID",
|
|
2748
|
+
"validate_session_root",
|
|
2749
|
+
`Session root resolves outside its canonical path: ${sessionsRoot}.`,
|
|
2750
|
+
{ sessionId },
|
|
2751
|
+
);
|
|
2752
|
+
}
|
|
2753
|
+
}
|
|
2754
|
+
|
|
2755
|
+
function safeSessionDirectory(sessionsRoot: string, sessionId: SessionId): string {
|
|
2756
|
+
const value = String(sessionId);
|
|
2757
|
+
if (
|
|
2758
|
+
value.trim() === "" ||
|
|
2759
|
+
value !== value.trim() ||
|
|
2760
|
+
value.includes("/") ||
|
|
2761
|
+
value.includes("\\") ||
|
|
2762
|
+
value === "." ||
|
|
2763
|
+
value === ".."
|
|
2764
|
+
) {
|
|
2765
|
+
throw new SessionError(
|
|
2766
|
+
"SESSION_ID_INVALID",
|
|
2767
|
+
"resolve_session_path",
|
|
2768
|
+
`Unsafe session ID: ${JSON.stringify(value)}.`,
|
|
2769
|
+
{ sessionId },
|
|
2770
|
+
);
|
|
2771
|
+
}
|
|
2772
|
+
const directory = path.join(sessionsRoot, value);
|
|
2773
|
+
if (path.dirname(directory) !== sessionsRoot) {
|
|
2774
|
+
throw new SessionError(
|
|
2775
|
+
"SESSION_ID_INVALID",
|
|
2776
|
+
"resolve_session_path",
|
|
2777
|
+
`Session ID escapes the sessions directory: ${JSON.stringify(value)}.`,
|
|
2778
|
+
{ sessionId },
|
|
2779
|
+
);
|
|
2780
|
+
}
|
|
2781
|
+
return directory;
|
|
2782
|
+
}
|
|
2783
|
+
|
|
2784
|
+
async function validateSecureDirectory(
|
|
2785
|
+
directory: string,
|
|
2786
|
+
sessionId: SessionId,
|
|
2787
|
+
): Promise<void> {
|
|
2788
|
+
let stats;
|
|
2789
|
+
try {
|
|
2790
|
+
stats = await lstat(directory);
|
|
2791
|
+
} catch (error) {
|
|
2792
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
2793
|
+
throw new SessionError(
|
|
2794
|
+
"SESSION_STORE_NOT_FOUND",
|
|
2795
|
+
"open_session",
|
|
2796
|
+
`Session directory does not exist: ${directory}.`,
|
|
2797
|
+
{ sessionId, cause: error },
|
|
2798
|
+
);
|
|
2799
|
+
}
|
|
2800
|
+
throw error;
|
|
2801
|
+
}
|
|
2802
|
+
if (
|
|
2803
|
+
!stats.isDirectory() ||
|
|
2804
|
+
stats.isSymbolicLink() ||
|
|
2805
|
+
(stats.mode & 0o077) !== 0 ||
|
|
2806
|
+
(typeof process.getuid === "function" && stats.uid !== process.getuid())
|
|
2807
|
+
) {
|
|
2808
|
+
throw new SessionError(
|
|
2809
|
+
"SESSION_PERMISSION_INVALID",
|
|
2810
|
+
"open_session",
|
|
2811
|
+
`Session directory must be an owner-only real directory: ${directory}.`,
|
|
2812
|
+
{ sessionId },
|
|
2813
|
+
);
|
|
2814
|
+
}
|
|
2815
|
+
}
|
|
2816
|
+
|
|
2817
|
+
async function validateSecureFile(
|
|
2818
|
+
filePath: string,
|
|
2819
|
+
sessionId: SessionId,
|
|
2820
|
+
): Promise<void> {
|
|
2821
|
+
let stats;
|
|
2822
|
+
try {
|
|
2823
|
+
stats = await lstat(filePath);
|
|
2824
|
+
} catch (error) {
|
|
2825
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
2826
|
+
throw new SessionError(
|
|
2827
|
+
"SESSION_STORE_NOT_FOUND",
|
|
2828
|
+
"open_session",
|
|
2829
|
+
`Session database does not exist: ${filePath}.`,
|
|
2830
|
+
{ sessionId, cause: error },
|
|
2831
|
+
);
|
|
2832
|
+
}
|
|
2833
|
+
throw error;
|
|
2834
|
+
}
|
|
2835
|
+
if (
|
|
2836
|
+
!stats.isFile() ||
|
|
2837
|
+
stats.isSymbolicLink() ||
|
|
2838
|
+
(stats.mode & 0o077) !== 0 ||
|
|
2839
|
+
(typeof process.getuid === "function" && stats.uid !== process.getuid())
|
|
2840
|
+
) {
|
|
2841
|
+
throw new SessionError(
|
|
2842
|
+
"SESSION_PERMISSION_INVALID",
|
|
2843
|
+
"open_session",
|
|
2844
|
+
`Session database must be an owner-only regular file: ${filePath}.`,
|
|
2845
|
+
{ sessionId },
|
|
2846
|
+
);
|
|
2847
|
+
}
|
|
2848
|
+
}
|
|
2849
|
+
|
|
2850
|
+
async function validateSecureOptionalFile(
|
|
2851
|
+
filePath: string,
|
|
2852
|
+
sessionId: SessionId,
|
|
2853
|
+
): Promise<void> {
|
|
2854
|
+
try {
|
|
2855
|
+
await lstat(filePath);
|
|
2856
|
+
} catch (error) {
|
|
2857
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
2858
|
+
return;
|
|
2859
|
+
}
|
|
2860
|
+
throw error;
|
|
2861
|
+
}
|
|
2862
|
+
await validateSecureFile(filePath, sessionId);
|
|
2863
|
+
}
|
|
2864
|
+
|
|
2865
|
+
async function removeKnownInitializationFiles(sessionDirectory: string): Promise<void> {
|
|
2866
|
+
for (const name of [
|
|
2867
|
+
"session.sqlite-wal",
|
|
2868
|
+
"session.sqlite-shm",
|
|
2869
|
+
"session.sqlite",
|
|
2870
|
+
"events.jsonl",
|
|
2871
|
+
"observations.md",
|
|
2872
|
+
"active.lock.reclaim",
|
|
2873
|
+
"active.lock",
|
|
2874
|
+
]) {
|
|
2875
|
+
await unlinkIfExists(path.join(sessionDirectory, name));
|
|
2876
|
+
}
|
|
2877
|
+
try {
|
|
2878
|
+
await rmdir(sessionDirectory);
|
|
2879
|
+
} catch (error) {
|
|
2880
|
+
if (
|
|
2881
|
+
!new Set(["ENOENT", "ENOTEMPTY"]).has((error as NodeJS.ErrnoException).code ?? "")
|
|
2882
|
+
) {
|
|
2883
|
+
throw error;
|
|
2884
|
+
}
|
|
2885
|
+
}
|
|
2886
|
+
}
|
|
2887
|
+
|
|
2888
|
+
async function chmodIfExists(filePath: string, mode: number): Promise<void> {
|
|
2889
|
+
try {
|
|
2890
|
+
await chmod(filePath, mode);
|
|
2891
|
+
} catch (error) {
|
|
2892
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
2893
|
+
throw error;
|
|
2894
|
+
}
|
|
2895
|
+
}
|
|
2896
|
+
}
|
|
2897
|
+
|
|
2898
|
+
async function unlinkIfExists(filePath: string): Promise<void> {
|
|
2899
|
+
try {
|
|
2900
|
+
await unlink(filePath);
|
|
2901
|
+
} catch (error) {
|
|
2902
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
2903
|
+
throw error;
|
|
2904
|
+
}
|
|
2905
|
+
}
|
|
2906
|
+
}
|
|
2907
|
+
|
|
2908
|
+
function requireSingleChange(
|
|
2909
|
+
database: Database,
|
|
2910
|
+
reportedChanges: number | bigint,
|
|
2911
|
+
operation: string,
|
|
2912
|
+
): void {
|
|
2913
|
+
const row = database.query("SELECT changes() AS changes").get() as {
|
|
2914
|
+
changes: number | bigint;
|
|
2915
|
+
};
|
|
2916
|
+
if (Number(row.changes) !== 1) {
|
|
2917
|
+
throw new Error(
|
|
2918
|
+
`${operation} must change exactly one row; changed ${row.changes} (driver reported ${reportedChanges}).`,
|
|
2919
|
+
);
|
|
2920
|
+
}
|
|
2921
|
+
}
|
|
2922
|
+
|
|
2923
|
+
function recordFromSql(value: unknown, name: string): Record<string, unknown> {
|
|
2924
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
2925
|
+
throw new Error(`${name} must be an object.`);
|
|
2926
|
+
}
|
|
2927
|
+
return value as Record<string, unknown>;
|
|
2928
|
+
}
|
|
2929
|
+
|
|
2930
|
+
function assertObjectKeys(
|
|
2931
|
+
record: Record<string, unknown>,
|
|
2932
|
+
allowed: readonly string[],
|
|
2933
|
+
required: readonly string[],
|
|
2934
|
+
name: string,
|
|
2935
|
+
): void {
|
|
2936
|
+
const allowedSet = new Set(allowed);
|
|
2937
|
+
const unknown = Object.keys(record).filter((key) => !allowedSet.has(key));
|
|
2938
|
+
const missing = required.filter((key) => !(key in record));
|
|
2939
|
+
if (unknown.length > 0 || missing.length > 0) {
|
|
2940
|
+
throw new Error(
|
|
2941
|
+
`${name} has invalid keys; unknown=${unknown.join(",") || "none"} missing=${missing.join(",") || "none"}.`,
|
|
2942
|
+
);
|
|
2943
|
+
}
|
|
2944
|
+
}
|
|
2945
|
+
|
|
2946
|
+
function stringFromSql(value: unknown, name: string): string {
|
|
2947
|
+
if (typeof value !== "string" || value === "") {
|
|
2948
|
+
throw new Error(`${name} must be a non-empty string.`);
|
|
2949
|
+
}
|
|
2950
|
+
return value;
|
|
2951
|
+
}
|
|
2952
|
+
|
|
2953
|
+
function sha256FromSql(value: unknown, name: string): string {
|
|
2954
|
+
const hash = stringFromSql(value, name);
|
|
2955
|
+
if (!/^[0-9a-f]{64}$/.test(hash)) {
|
|
2956
|
+
throw new Error(`${name} must be a lowercase SHA-256 digest.`);
|
|
2957
|
+
}
|
|
2958
|
+
return hash;
|
|
2959
|
+
}
|
|
2960
|
+
|
|
2961
|
+
function assertMeasuredContextAnchor(anchor: MeasuredContextAnchor): void {
|
|
2962
|
+
for (const [name, value] of [
|
|
2963
|
+
["promptTokens", anchor.promptTokens],
|
|
2964
|
+
["completionTokens", anchor.completionTokens],
|
|
2965
|
+
["totalTokens", anchor.totalTokens],
|
|
2966
|
+
["segmentCount", anchor.segmentCount],
|
|
2967
|
+
] as const) {
|
|
2968
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
2969
|
+
throw new Error(
|
|
2970
|
+
`Measured context anchor ${name} must be a non-negative safe integer; received ${value}.`,
|
|
2971
|
+
);
|
|
2972
|
+
}
|
|
2973
|
+
}
|
|
2974
|
+
if (anchor.totalTokens !== anchor.promptTokens + anchor.completionTokens) {
|
|
2975
|
+
throw new Error(
|
|
2976
|
+
"Measured context anchor totalTokens must equal promptTokens + completionTokens.",
|
|
2977
|
+
);
|
|
2978
|
+
}
|
|
2979
|
+
for (const [name, value] of [
|
|
2980
|
+
["prefixHash", anchor.prefixHash],
|
|
2981
|
+
["requestConfigHash", anchor.requestConfigHash],
|
|
2982
|
+
["toolSchemaHash", anchor.toolSchemaHash],
|
|
2983
|
+
] as const) {
|
|
2984
|
+
if (!/^[0-9a-f]{64}$/.test(value)) {
|
|
2985
|
+
throw new Error(`Measured context anchor ${name} must be a SHA-256 digest.`);
|
|
2986
|
+
}
|
|
2987
|
+
}
|
|
2988
|
+
}
|
|
2989
|
+
|
|
2990
|
+
function nullableStringFromSql(value: unknown, name: string): string | null {
|
|
2991
|
+
return value === null ? null : stringFromSql(value, name);
|
|
2992
|
+
}
|
|
2993
|
+
|
|
2994
|
+
function nullableTextFromSql(value: unknown, name: string): string | null {
|
|
2995
|
+
if (value === null) {
|
|
2996
|
+
return null;
|
|
2997
|
+
}
|
|
2998
|
+
if (typeof value !== "string") {
|
|
2999
|
+
throw new Error(`${name} must be a string or null.`);
|
|
3000
|
+
}
|
|
3001
|
+
return value;
|
|
3002
|
+
}
|
|
3003
|
+
|
|
3004
|
+
function numberFromSql(value: unknown, name: string): number {
|
|
3005
|
+
const number = typeof value === "bigint" ? Number(value) : value;
|
|
3006
|
+
if (typeof number !== "number" || !Number.isSafeInteger(number) || number < 0) {
|
|
3007
|
+
throw new Error(`${name} must be a safe non-negative integer.`);
|
|
3008
|
+
}
|
|
3009
|
+
return number;
|
|
3010
|
+
}
|
|
3011
|
+
|
|
3012
|
+
function nullableNumberFromSql(value: unknown, name: string): number | null {
|
|
3013
|
+
return value === null ? null : numberFromSql(value, name);
|
|
3014
|
+
}
|
|
3015
|
+
|
|
3016
|
+
function numberFromJson(value: unknown, name: string): number {
|
|
3017
|
+
if (!Number.isSafeInteger(value) || (value as number) < 1) {
|
|
3018
|
+
throw new Error(`${name} must be a positive safe integer.`);
|
|
3019
|
+
}
|
|
3020
|
+
return value as number;
|
|
3021
|
+
}
|
|
3022
|
+
|
|
3023
|
+
function enumFromSql<const T extends readonly string[]>(
|
|
3024
|
+
value: unknown,
|
|
3025
|
+
values: T,
|
|
3026
|
+
name: string,
|
|
3027
|
+
): T[number] {
|
|
3028
|
+
if (typeof value !== "string" || !values.includes(value)) {
|
|
3029
|
+
throw new Error(`${name} has unsupported value ${JSON.stringify(value)}.`);
|
|
3030
|
+
}
|
|
3031
|
+
return value;
|
|
3032
|
+
}
|
|
3033
|
+
|
|
3034
|
+
function timestampFromSql(value: unknown, name: string): string {
|
|
3035
|
+
return timestampValue(stringFromSql(value, name), name);
|
|
3036
|
+
}
|
|
3037
|
+
|
|
3038
|
+
function timestampValue(value: string, name: string): string {
|
|
3039
|
+
if (Number.isNaN(Date.parse(value)) || !value.endsWith("Z")) {
|
|
3040
|
+
throw new Error(`${name} must be a UTC ISO-8601 timestamp.`);
|
|
3041
|
+
}
|
|
3042
|
+
return value;
|
|
3043
|
+
}
|
|
3044
|
+
|
|
3045
|
+
function parseJson(value: string, name: string): unknown {
|
|
3046
|
+
try {
|
|
3047
|
+
return JSON.parse(value);
|
|
3048
|
+
} catch (error) {
|
|
3049
|
+
throw new Error(`${name} is not valid JSON.`, { cause: error });
|
|
3050
|
+
}
|
|
3051
|
+
}
|
|
3052
|
+
|
|
3053
|
+
function requireItem<T>(items: readonly T[], index: number, name: string): T {
|
|
3054
|
+
const item = items[index];
|
|
3055
|
+
if (item === undefined) {
|
|
3056
|
+
throw new Error(`Missing ${name} at index ${index}.`);
|
|
3057
|
+
}
|
|
3058
|
+
return item;
|
|
3059
|
+
}
|
|
3060
|
+
|
|
3061
|
+
function errorMessage(error: unknown): string {
|
|
3062
|
+
return error instanceof Error ? error.message : String(error);
|
|
3063
|
+
}
|
|
3064
|
+
|
|
3065
|
+
function asError(error: unknown): Error {
|
|
3066
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
3067
|
+
}
|