@namzu/sdk 20.1.0 → 20.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +90 -0
- package/dist/manager/run/persistence.d.ts +2 -2
- package/dist/manager/run/persistence.d.ts.map +1 -1
- package/dist/manager/run/persistence.js +14 -5
- package/dist/manager/run/persistence.js.map +1 -1
- package/dist/public-runtime.d.ts +1 -1
- package/dist/public-runtime.d.ts.map +1 -1
- package/dist/public-runtime.js +1 -1
- package/dist/public-runtime.js.map +1 -1
- package/dist/runtime/query/checkpoint.d.ts +20 -0
- package/dist/runtime/query/checkpoint.d.ts.map +1 -1
- package/dist/runtime/query/checkpoint.js +47 -0
- package/dist/runtime/query/checkpoint.js.map +1 -1
- package/dist/runtime/query/context.d.ts +2 -0
- package/dist/runtime/query/context.d.ts.map +1 -1
- package/dist/runtime/query/context.js +1 -0
- package/dist/runtime/query/context.js.map +1 -1
- package/dist/runtime/query/index.d.ts +11 -0
- package/dist/runtime/query/index.d.ts.map +1 -1
- package/dist/runtime/query/index.js +1 -0
- package/dist/runtime/query/index.js.map +1 -1
- package/dist/store/index.d.ts +1 -0
- package/dist/store/index.d.ts.map +1 -1
- package/dist/store/index.js +1 -0
- package/dist/store/index.js.map +1 -1
- package/dist/store/run/disk.d.ts +10 -8
- package/dist/store/run/disk.d.ts.map +1 -1
- package/dist/store/run/disk.js.map +1 -1
- package/dist/store/run/listing.d.ts.map +1 -1
- package/dist/store/run/listing.js +65 -6
- package/dist/store/run/listing.js.map +1 -1
- package/dist/store/run/memory.d.ts +46 -0
- package/dist/store/run/memory.d.ts.map +1 -0
- package/dist/store/run/memory.js +104 -0
- package/dist/store/run/memory.js.map +1 -0
- package/dist/types/hitl/index.d.ts +27 -0
- package/dist/types/hitl/index.d.ts.map +1 -1
- package/dist/types/hitl/index.js.map +1 -1
- package/dist/types/run/checkpoint-store.d.ts +69 -19
- package/dist/types/run/checkpoint-store.d.ts.map +1 -1
- package/dist/types/run/config.d.ts +12 -0
- package/dist/types/run/config.d.ts.map +1 -1
- package/dist/types/run/index.d.ts +1 -0
- package/dist/types/run/index.d.ts.map +1 -1
- package/dist/types/run/index.js +1 -0
- package/dist/types/run/index.js.map +1 -1
- package/dist/types/run/store.d.ts +103 -0
- package/dist/types/run/store.d.ts.map +1 -0
- package/dist/types/run/store.js +30 -0
- package/dist/types/run/store.js.map +1 -0
- package/package.json +1 -1
- package/src/manager/run/persistence.ts +17 -7
- package/src/public-runtime.ts +1 -0
- package/src/runtime/query/checkpoint.ts +51 -0
- package/src/runtime/query/context.ts +3 -0
- package/src/runtime/query/index.ts +13 -0
- package/src/store/index.ts +1 -0
- package/src/store/run/disk.ts +10 -8
- package/src/store/run/listing.ts +84 -6
- package/src/store/run/memory.ts +121 -0
- package/src/types/hitl/index.ts +28 -0
- package/src/types/run/checkpoint-store.ts +72 -19
- package/src/types/run/config.ts +13 -0
- package/src/types/run/index.ts +1 -0
- package/src/types/run/store.ts +112 -0
package/src/store/run/disk.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { appendFile, mkdir, readFile, readdir, unlink } from 'node:fs/promises'
|
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
import type { CheckpointId, IterationCheckpoint } from '../../types/hitl/index.js'
|
|
4
4
|
import type { Run, RunEvent, RunStoreConfig } from '../../types/run/index.js'
|
|
5
|
+
import type { CompletedToolRecord, RunStore } from '../../types/run/store.js'
|
|
5
6
|
import { atomicWriteFile } from '../../utils/atomic-write.js'
|
|
6
7
|
import { type Logger, getRootLogger } from '../../utils/logger.js'
|
|
7
8
|
import { defineSchema, migrate, stamp } from '../schema.js'
|
|
@@ -16,15 +17,16 @@ import { defineSchema, migrate, stamp } from '../schema.js'
|
|
|
16
17
|
*/
|
|
17
18
|
const SCHEMA = defineSchema({ kind: 'run-store', current: 1, migrations: {} })
|
|
18
19
|
|
|
19
|
-
/**
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
20
|
+
/**
|
|
21
|
+
* One finished tool call, recovered from the transcript.
|
|
22
|
+
*
|
|
23
|
+
* Re-exported from the store contract rather than declared twice. Two
|
|
24
|
+
* declarations of one concept, each populated by its own mapper, is the shape
|
|
25
|
+
* this repository has a rule about.
|
|
26
|
+
*/
|
|
27
|
+
export type { CompletedToolRecord }
|
|
26
28
|
|
|
27
|
-
export class RunDiskStore {
|
|
29
|
+
export class RunDiskStore implements RunStore {
|
|
28
30
|
private baseDir: string
|
|
29
31
|
private runDir: string | null = null
|
|
30
32
|
private log: Logger
|
package/src/store/run/listing.ts
CHANGED
|
@@ -15,6 +15,7 @@ import type {
|
|
|
15
15
|
CheckpointRunScope,
|
|
16
16
|
CheckpointStore,
|
|
17
17
|
DurableRunEntry,
|
|
18
|
+
DurableRunOrder,
|
|
18
19
|
DurableRunPage,
|
|
19
20
|
ListDurableRunsOptions,
|
|
20
21
|
ParkState,
|
|
@@ -139,8 +140,21 @@ export function toDurableRunEntry(
|
|
|
139
140
|
if (checkpoints.length === 0) return null
|
|
140
141
|
|
|
141
142
|
let latest = checkpoints[0] as IterationCheckpoint
|
|
143
|
+
// The EARLIEST recorded stamp, not the one on any particular checkpoint.
|
|
144
|
+
// Every checkpoint of a run carries the same value, so under that
|
|
145
|
+
// invariant the minimum is that value. Taking the minimum rather than
|
|
146
|
+
// reading one checkpoint is what makes the read safe if the invariant is
|
|
147
|
+
// ever broken: it can only err toward the run's true attribution, never
|
|
148
|
+
// away from it, and it cannot move when a later checkpoint is added.
|
|
149
|
+
let runCreatedAt: number | undefined
|
|
142
150
|
for (const cp of checkpoints) {
|
|
143
151
|
if (cp.createdAt > latest.createdAt) latest = cp
|
|
152
|
+
if (
|
|
153
|
+
cp.runCreatedAt !== undefined &&
|
|
154
|
+
(runCreatedAt === undefined || cp.runCreatedAt < runCreatedAt)
|
|
155
|
+
) {
|
|
156
|
+
runCreatedAt = cp.runCreatedAt
|
|
157
|
+
}
|
|
144
158
|
}
|
|
145
159
|
|
|
146
160
|
const park = summarizePark(checkpoints, now)
|
|
@@ -151,6 +165,7 @@ export function toDurableRunEntry(
|
|
|
151
165
|
sessionId: scope.sessionId,
|
|
152
166
|
runId: scope.runId,
|
|
153
167
|
...(scope.parentRunId ? { parentRunId: scope.parentRunId } : {}),
|
|
168
|
+
...(runCreatedAt !== undefined ? { runCreatedAt } : {}),
|
|
154
169
|
checkpointCount: checkpoints.length,
|
|
155
170
|
latestCheckpointId: latest.id,
|
|
156
171
|
latestCheckpointAt: latest.createdAt,
|
|
@@ -176,12 +191,16 @@ export function paginateDurableRuns(
|
|
|
176
191
|
? entries.filter((e) => e.park !== undefined && wanted.includes(e.park.state))
|
|
177
192
|
: entries
|
|
178
193
|
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
const
|
|
194
|
+
// Both orders sort on a key that cannot move under a paging caller — see
|
|
195
|
+
// the contract comment on `listDurableRuns`.
|
|
196
|
+
const orderBy = options?.orderBy ?? 'runId'
|
|
197
|
+
const ordered = [...filtered].sort((a, b) =>
|
|
198
|
+
compareKeys(sortKey(a, orderBy), sortKey(b, orderBy)),
|
|
199
|
+
)
|
|
182
200
|
|
|
183
|
-
const after = options?.cursor
|
|
184
|
-
const start =
|
|
201
|
+
const after = options?.cursor === undefined ? undefined : decodeCursor(options.cursor, orderBy)
|
|
202
|
+
const start =
|
|
203
|
+
after === undefined ? 0 : ordered.findIndex((e) => compareKeys(sortKey(e, orderBy), after) > 0)
|
|
185
204
|
const from = start < 0 ? ordered.length : start
|
|
186
205
|
|
|
187
206
|
const limit = Math.max(1, Math.trunc(options?.limit ?? DEFAULT_DURABLE_RUN_LIMIT))
|
|
@@ -194,8 +213,67 @@ export function paginateDurableRuns(
|
|
|
194
213
|
// terminates rather than fetching one empty page to find out.
|
|
195
214
|
...(exhausted || page.length === 0
|
|
196
215
|
? {}
|
|
197
|
-
: { cursor: (page[page.length - 1] as DurableRunEntry)
|
|
216
|
+
: { cursor: encodeCursor(sortKey(page[page.length - 1] as DurableRunEntry, orderBy)) }),
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* A row's position in the requested order, as a comparable tuple.
|
|
222
|
+
*
|
|
223
|
+
* The first element is a rank rather than the timestamp itself, so that
|
|
224
|
+
* "never recorded" is a position in its own right instead of a number
|
|
225
|
+
* standing in for one. In `createdAt` order it ranks 0 and everything
|
|
226
|
+
* stamped ranks 1 — unrecorded runs first, and truthfully so: the stamp is
|
|
227
|
+
* written by the checkpoint manager, so a run without one was checkpointed
|
|
228
|
+
* by a build that predates it, and predates every run that has one.
|
|
229
|
+
*/
|
|
230
|
+
type SortKey = readonly [number, number, string]
|
|
231
|
+
|
|
232
|
+
function sortKey(entry: DurableRunEntry, orderBy: DurableRunOrder): SortKey {
|
|
233
|
+
if (orderBy === 'runId') return [0, 0, entry.runId]
|
|
234
|
+
return entry.runCreatedAt === undefined
|
|
235
|
+
? [0, 0, entry.runId]
|
|
236
|
+
: [1, entry.runCreatedAt, entry.runId]
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function compareKeys(a: SortKey, b: SortKey): number {
|
|
240
|
+
if (a[0] !== b[0]) return a[0] - b[0]
|
|
241
|
+
if (a[1] !== b[1]) return a[1] - b[1]
|
|
242
|
+
return a[2] < b[2] ? -1 : a[2] > b[2] ? 1 : 0
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* The cursor is the last row's key, and nothing else.
|
|
247
|
+
*
|
|
248
|
+
* Opaque to callers by contract — the shape is written down here rather than
|
|
249
|
+
* in the type so a host is not tempted to construct one. Run ids come from a
|
|
250
|
+
* 36-character lowercase alphabet with a `run_` prefix and contain no
|
|
251
|
+
* separator, so joining on `:` is unambiguous.
|
|
252
|
+
*/
|
|
253
|
+
function encodeCursor(key: SortKey): string {
|
|
254
|
+
return `${key[0]}:${key[1]}:${key[2]}`
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function decodeCursor(cursor: string, orderBy: DurableRunOrder): SortKey {
|
|
258
|
+
const first = cursor.indexOf(':')
|
|
259
|
+
const second = cursor.indexOf(':', first + 1)
|
|
260
|
+
if (first < 0 || second < 0) {
|
|
261
|
+
throw new NamzuError({
|
|
262
|
+
code: 'invalid_config',
|
|
263
|
+
message: `listDurableRuns: "${cursor}" is not a cursor this listing issued. Pass back the \`cursor\` from the previous page rather than constructing one; its shape is not part of the contract.`,
|
|
264
|
+
details: { cursor, orderBy },
|
|
265
|
+
})
|
|
266
|
+
}
|
|
267
|
+
const rank = Number(cursor.slice(0, first))
|
|
268
|
+
const stamp = Number(cursor.slice(first + 1, second))
|
|
269
|
+
if (!Number.isFinite(rank) || !Number.isFinite(stamp)) {
|
|
270
|
+
throw new NamzuError({
|
|
271
|
+
code: 'invalid_config',
|
|
272
|
+
message: `listDurableRuns: cursor "${cursor}" is malformed — its position fields are not numbers. Pass back the \`cursor\` from the previous page.`,
|
|
273
|
+
details: { cursor, orderBy },
|
|
274
|
+
})
|
|
198
275
|
}
|
|
276
|
+
return [rank, stamp, cursor.slice(second + 1)]
|
|
199
277
|
}
|
|
200
278
|
|
|
201
279
|
/**
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { Run } from '../../types/run/entity.js'
|
|
2
|
+
import type { RunEvent } from '../../types/run/events.js'
|
|
3
|
+
import type { CompletedToolRecord, RunStore } from '../../types/run/store.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Process-local {@link RunStore}: a run's evidence with no filesystem.
|
|
7
|
+
*
|
|
8
|
+
* The reason it ships rather than living in a test file is that it is the
|
|
9
|
+
* only way to demonstrate the seam actually is one. A contract with a single
|
|
10
|
+
* implementation is a refactor; the second implementation is what proves a
|
|
11
|
+
* host could supply a third. It is also the parity partner for the disk
|
|
12
|
+
* store — a memory store that answers differently from disk is worse than
|
|
13
|
+
* none, because a host tests against one and ships the other.
|
|
14
|
+
*
|
|
15
|
+
* Deliberately not durable. It is for tests, for a single-process host that
|
|
16
|
+
* genuinely wants a run's evidence to die with the process, and for
|
|
17
|
+
* environments with no writable filesystem at all.
|
|
18
|
+
*/
|
|
19
|
+
export class InMemoryRunStore implements RunStore {
|
|
20
|
+
private runId: string | null = null
|
|
21
|
+
private parentRunId: string | undefined
|
|
22
|
+
private meta: Run | null = null
|
|
23
|
+
private messages: Run['messages'] = []
|
|
24
|
+
private report: string | null = null
|
|
25
|
+
private readonly events: RunEvent[] = []
|
|
26
|
+
|
|
27
|
+
async initRun(runId: string, parentRunId?: string): Promise<string | null> {
|
|
28
|
+
this.runId = runId
|
|
29
|
+
this.parentRunId = parentRunId
|
|
30
|
+
// No location, and that is the honest answer rather than a defect.
|
|
31
|
+
// Callers render `null` as "this run is not on a filesystem"; a
|
|
32
|
+
// synthesized path would put a directory that does not exist in front
|
|
33
|
+
// of an operator.
|
|
34
|
+
return null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
private requireInit(): string {
|
|
38
|
+
if (this.runId === null) {
|
|
39
|
+
throw new Error('InMemoryRunStore not initialized — call initRun() first')
|
|
40
|
+
}
|
|
41
|
+
return this.runId
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The run this store is bound to, and its parent when it has one. */
|
|
45
|
+
get boundTo(): { runId: string; parentRunId?: string } | null {
|
|
46
|
+
return this.runId === null
|
|
47
|
+
? null
|
|
48
|
+
: { runId: this.runId, ...(this.parentRunId ? { parentRunId: this.parentRunId } : {}) }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async writeRunMeta(run: Run): Promise<void> {
|
|
52
|
+
this.requireInit()
|
|
53
|
+
// Copied, not referenced. The caller keeps mutating this object for
|
|
54
|
+
// the rest of the run, so storing it by reference would make every
|
|
55
|
+
// historical read return the run's present state — a transcript that
|
|
56
|
+
// silently rewrites itself is worse than no transcript.
|
|
57
|
+
this.meta = structuredClone(run)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async writeMessages(run: Run): Promise<void> {
|
|
61
|
+
this.requireInit()
|
|
62
|
+
this.messages = structuredClone(run.messages)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async appendEvent(event: RunEvent): Promise<void> {
|
|
66
|
+
this.requireInit()
|
|
67
|
+
// Stamped on write, exactly as the disk store stamps its transcript
|
|
68
|
+
// line — a parity test compares the two read-backs, and a timestamp
|
|
69
|
+
// present in one medium and absent in the other would make identical
|
|
70
|
+
// runs look different depending on where they were recorded.
|
|
71
|
+
this.events.push({ ...event, timestamp: Date.now() } as unknown as RunEvent)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async writeReport(content: string): Promise<string | null> {
|
|
75
|
+
this.requireInit()
|
|
76
|
+
this.report = content
|
|
77
|
+
return null
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async readCompletedTools(): Promise<Map<string, CompletedToolRecord>> {
|
|
81
|
+
this.requireInit()
|
|
82
|
+
const completed = new Map<string, CompletedToolRecord>()
|
|
83
|
+
for (const event of this.events) {
|
|
84
|
+
const e = event as unknown as Record<string, unknown>
|
|
85
|
+
if (e.type !== 'tool_completed') continue
|
|
86
|
+
const toolUseId = e.toolUseId
|
|
87
|
+
const toolName = e.toolName
|
|
88
|
+
if (typeof toolUseId !== 'string' || typeof toolName !== 'string') continue
|
|
89
|
+
// Last write wins: a retried tool emits one event per attempt and
|
|
90
|
+
// the final one is what actually answered the call. Same rule the
|
|
91
|
+
// disk store applies, and it has to be the same rule — a resumed
|
|
92
|
+
// run must not depend on which backend it was recorded with.
|
|
93
|
+
completed.set(toolUseId, {
|
|
94
|
+
toolUseId,
|
|
95
|
+
toolName,
|
|
96
|
+
result: typeof e.result === 'string' ? e.result : '',
|
|
97
|
+
isError: e.isError === true,
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
return completed
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
getRunDir(): string | null {
|
|
104
|
+
return null
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// `addToIndex` is deliberately not implemented. It maintains a browsable
|
|
108
|
+
// catalogue for a human reading a directory, and there is no directory
|
|
109
|
+
// here. The optional method exists on the contract precisely so a backend
|
|
110
|
+
// can decline it rather than implement a no-op that looks like a listing.
|
|
111
|
+
|
|
112
|
+
/** Everything recorded for the bound run, for tests and parity checks. */
|
|
113
|
+
snapshot(): {
|
|
114
|
+
meta: Run | null
|
|
115
|
+
messages: Run['messages']
|
|
116
|
+
report: string | null
|
|
117
|
+
events: readonly RunEvent[]
|
|
118
|
+
} {
|
|
119
|
+
return { meta: this.meta, messages: this.messages, report: this.report, events: this.events }
|
|
120
|
+
}
|
|
121
|
+
}
|
package/src/types/hitl/index.ts
CHANGED
|
@@ -210,6 +210,34 @@ export interface IterationCheckpoint {
|
|
|
210
210
|
*/
|
|
211
211
|
planStatus?: PlanStatus
|
|
212
212
|
|
|
213
|
+
/**
|
|
214
|
+
* When the RUN was attributed — not when this checkpoint was written.
|
|
215
|
+
* See {@link IterationCheckpoint.createdAt} for the latter.
|
|
216
|
+
*
|
|
217
|
+
* Denormalized onto every checkpoint of the run, identically, and that
|
|
218
|
+
* repetition is the whole point. A listing above the run needs a key it
|
|
219
|
+
* can order by, and a key a paging caller can trust is one that cannot
|
|
220
|
+
* MOVE. Every other time a checkpoint store can derive per run moves: the
|
|
221
|
+
* newest checkpoint's `createdAt` advances every time the run checkpoints
|
|
222
|
+
* again, and the oldest one's advances every time `prune` deletes
|
|
223
|
+
* oldest-first. Carried on all of them, this one survives both — pruning
|
|
224
|
+
* cannot reach a value every survivor also holds.
|
|
225
|
+
*
|
|
226
|
+
* `readonly`, and written exactly once per run by
|
|
227
|
+
* {@link import('../../runtime/query/checkpoint.js').CheckpointManager},
|
|
228
|
+
* which settles it on whichever comes first — adopting it from the
|
|
229
|
+
* checkpoint a resume restores, or minting it from the run's own start
|
|
230
|
+
* instant — and never reassigns after. A field that COULD be updated is
|
|
231
|
+
* one edit away from moving again, which would put the ordering back
|
|
232
|
+
* where it started.
|
|
233
|
+
*
|
|
234
|
+
* Absent on checkpoints written before this existed. That absence is
|
|
235
|
+
* information, not a gap: a run with no stamp on any of its checkpoints
|
|
236
|
+
* was attributed before the stamp existed, and therefore before every
|
|
237
|
+
* run that has one.
|
|
238
|
+
*/
|
|
239
|
+
readonly runCreatedAt?: number
|
|
240
|
+
|
|
213
241
|
/**
|
|
214
242
|
* Present when the run parked at this checkpoint awaiting a human.
|
|
215
243
|
* See {@link PendingDecision}.
|
|
@@ -152,6 +152,21 @@ export interface ParkSummary {
|
|
|
152
152
|
* scope, intersect with the host's own run records, resume the difference.
|
|
153
153
|
*/
|
|
154
154
|
export interface DurableRunEntry extends CheckpointRunScope {
|
|
155
|
+
/**
|
|
156
|
+
* When the run was attributed. Absent when it was never recorded.
|
|
157
|
+
*
|
|
158
|
+
* The only per-run key this store holds that does not move, which is why
|
|
159
|
+
* it is the one an oldest-first listing can page over — see
|
|
160
|
+
* {@link CheckpointStore.listDurableRuns} and
|
|
161
|
+
* `IterationCheckpoint.runCreatedAt`.
|
|
162
|
+
*
|
|
163
|
+
* **Absent means "not recorded", and a caller should render it that way
|
|
164
|
+
* rather than as a date it invents.** Every run checkpointed by a build
|
|
165
|
+
* carrying the stamp has one; a run that has none was checkpointed
|
|
166
|
+
* before the stamp existed.
|
|
167
|
+
*/
|
|
168
|
+
readonly runCreatedAt?: number
|
|
169
|
+
|
|
155
170
|
/** How many checkpoints the run has right now. Pruning lowers it. */
|
|
156
171
|
readonly checkpointCount: number
|
|
157
172
|
/** Newest checkpoint by `createdAt` — the one a resume restores by default. */
|
|
@@ -162,8 +177,46 @@ export interface DurableRunEntry extends CheckpointRunScope {
|
|
|
162
177
|
readonly park?: ParkSummary
|
|
163
178
|
}
|
|
164
179
|
|
|
180
|
+
/**
|
|
181
|
+
* Which order a listing comes back in.
|
|
182
|
+
*
|
|
183
|
+
* Explicit rather than implied, because the two available orders answer
|
|
184
|
+
* different questions and neither is right for both. "Show me every run
|
|
185
|
+
* waiting on a human" wants stable paging; "show me the one that has been
|
|
186
|
+
* waiting longest" wants chronology. A listing that silently picked one
|
|
187
|
+
* would be the same ambiguity the scope type removed by splitting.
|
|
188
|
+
*/
|
|
189
|
+
export type DurableRunOrder =
|
|
190
|
+
/**
|
|
191
|
+
* By `runId` ascending. Stable and total, and meaningless as chronology —
|
|
192
|
+
* run ids carry no timestamp. The default, because it is what shipped.
|
|
193
|
+
*/
|
|
194
|
+
| 'runId'
|
|
195
|
+
/**
|
|
196
|
+
* Oldest first, by {@link DurableRunEntry.runCreatedAt} then `runId`.
|
|
197
|
+
* This is the triage order: it answers which run has been waiting
|
|
198
|
+
* longest. Safe to page over, because the stamp is recorded once at
|
|
199
|
+
* attribution and never rewritten.
|
|
200
|
+
*
|
|
201
|
+
* **Runs whose creation was never recorded come FIRST**, ordered among
|
|
202
|
+
* themselves by `runId`. That is not a guess dressed up as a date: the
|
|
203
|
+
* stamp is written by the checkpoint manager, so a run lacking it on
|
|
204
|
+
* every checkpoint was checkpointed by a build that predates the stamp,
|
|
205
|
+
* and therefore predates every run that has one. Their
|
|
206
|
+
* `runCreatedAt` is absent on the row, so a caller can render "unknown"
|
|
207
|
+
* instead of a date nobody recorded.
|
|
208
|
+
*/
|
|
209
|
+
| 'createdAt'
|
|
210
|
+
|
|
165
211
|
/** Filters and paging for {@link CheckpointStore.listDurableRuns}. */
|
|
166
212
|
export interface ListDurableRunsOptions {
|
|
213
|
+
/**
|
|
214
|
+
* Ordering, and therefore what the cursor is a position in. Defaults to
|
|
215
|
+
* `'runId'`. A cursor is only meaningful within one order — do not carry
|
|
216
|
+
* one across a change of `orderBy`.
|
|
217
|
+
*/
|
|
218
|
+
readonly orderBy?: DurableRunOrder
|
|
219
|
+
|
|
167
220
|
/**
|
|
168
221
|
* Keep only runs whose park is in one of these states. A run that never
|
|
169
222
|
* parked has no state and is excluded by ANY value here; omit the filter
|
|
@@ -256,30 +309,30 @@ export interface CheckpointStore {
|
|
|
256
309
|
* for an unanswered park, and until this existed the sweep had no way to
|
|
257
310
|
* enumerate what to sweep.
|
|
258
311
|
*
|
|
259
|
-
* ### Ordering
|
|
312
|
+
* ### Ordering
|
|
260
313
|
*
|
|
261
|
-
*
|
|
262
|
-
*
|
|
314
|
+
* Two orders, named by `options.orderBy`, and the cursor is a position in
|
|
315
|
+
* whichever one was asked for. See {@link DurableRunOrder}.
|
|
263
316
|
*
|
|
264
|
-
*
|
|
265
|
-
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
*
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
*
|
|
317
|
+
* Both sort on a key that cannot MOVE, which is the property a cursor
|
|
318
|
+
* needs — sort on a moving key and a paging caller skips rows and repeats
|
|
319
|
+
* rows. That rules out every time a checkpoint store can derive on its
|
|
320
|
+
* own: the newest checkpoint's timestamp advances whenever the run
|
|
321
|
+
* checkpoints again, and the oldest one's advances whenever
|
|
322
|
+
* `CheckpointManager.prune` deletes oldest-first, which is what pruning
|
|
323
|
+
* does. It leaves `runId`, which is immutable and unique but carries no
|
|
324
|
+
* timestamp, and `runCreatedAt`, which is recorded once at attribution
|
|
325
|
+
* and denormalized onto every checkpoint so pruning cannot reach it.
|
|
273
326
|
*
|
|
274
|
-
*
|
|
275
|
-
*
|
|
276
|
-
*
|
|
327
|
+
* `'runId'` alone is already a total order. `'createdAt'` tiebreaks on
|
|
328
|
+
* `runId`, which is the rule `orderChildren` follows: sort on a key that
|
|
329
|
+
* cannot move, make the order total with an id.
|
|
277
330
|
*
|
|
278
331
|
* A run whose FIRST checkpoint is written after paging began may be
|
|
279
|
-
* missed by that pass — it lands
|
|
280
|
-
* behind the cursor. That is the right trade for a queue:
|
|
281
|
-
* again and picks it up next pass, whereas a moving sort
|
|
282
|
-
* that already existed.
|
|
332
|
+
* missed by that pass, in either order — it lands wherever its key puts
|
|
333
|
+
* it, possibly behind the cursor. That is the right trade for a queue:
|
|
334
|
+
* the sweep runs again and picks it up next pass, whereas a moving sort
|
|
335
|
+
* key loses runs that already existed.
|
|
283
336
|
*
|
|
284
337
|
* @param scope contiguous prefix; `tenantId` required. Implementations
|
|
285
338
|
* reject a hole (`sessionId` with no `projectId`) rather than guessing.
|
package/src/types/run/config.ts
CHANGED
|
@@ -136,6 +136,19 @@ export interface RunPersistenceConfig {
|
|
|
136
136
|
* hosts inject a scope-keyed backend (e.g. Postgres) here.
|
|
137
137
|
*/
|
|
138
138
|
checkpointStore?: CheckpointStore
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Optional run-evidence persistence override. Defaults to the disk layout
|
|
142
|
+
* under `outputDir` (a
|
|
143
|
+
* {@link import('../../store/run/disk.js').RunDiskStore}); hosts inject
|
|
144
|
+
* their own backend here.
|
|
145
|
+
*
|
|
146
|
+
* The sibling of `checkpointStore`, and it should have been one from the
|
|
147
|
+
* start: checkpoints got an injectable seam while the run record, its
|
|
148
|
+
* messages, its transcript and its report did not, so the evidence was
|
|
149
|
+
* the one part of a run that could not leave the local filesystem.
|
|
150
|
+
*/
|
|
151
|
+
runStore?: import('./store.js').RunStore
|
|
139
152
|
}
|
|
140
153
|
|
|
141
154
|
export interface RunStoreConfig {
|
package/src/types/run/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ export * from './prepare-step.js'
|
|
|
3
3
|
export * from './stop-reason.js'
|
|
4
4
|
export * from './config.js'
|
|
5
5
|
export * from './checkpoint-store.js'
|
|
6
|
+
export * from './store.js'
|
|
6
7
|
export * from './entity.js'
|
|
7
8
|
export * from './replay.js'
|
|
8
9
|
// Domain `RunStatus` (session-hierarchy.md §4.6 state machine). Safe to
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RunStore — persistence contract for a run's own evidence.
|
|
3
|
+
*
|
|
4
|
+
* The checkpoint store got an injectable seam and this did not, which left
|
|
5
|
+
* the run record, its messages, its transcript and its report reachable only
|
|
6
|
+
* through a concrete filesystem class. For a kernel whose stated purpose is
|
|
7
|
+
* auditable evidence, the evidence was the one thing that could not be
|
|
8
|
+
* pointed at durable storage: on ephemeral infrastructure the transcript dies
|
|
9
|
+
* with the container, and behind a load balancer two replicas write two
|
|
10
|
+
* disjoint run trees for one tenant.
|
|
11
|
+
*
|
|
12
|
+
* The location was already injectable through a path builder — but that
|
|
13
|
+
* returns filesystem path strings, so it relocates the directory without
|
|
14
|
+
* changing the medium.
|
|
15
|
+
*
|
|
16
|
+
* ## Bound to one run, unlike {@link CheckpointStore}
|
|
17
|
+
*
|
|
18
|
+
* Every accessor here addresses the run the store was bound to by
|
|
19
|
+
* {@link RunStore.initRun}, where a `CheckpointStore` takes an explicit scope
|
|
20
|
+
* per call. That asymmetry is inherited rather than chosen: this contract is
|
|
21
|
+
* extracted from a class the runtime already constructs per run and holds for
|
|
22
|
+
* the run's lifetime, and re-keying it would change every call site in the
|
|
23
|
+
* same change that introduces the seam — two risks where one will do.
|
|
24
|
+
*
|
|
25
|
+
* A host implementing a shared backend therefore keys its rows by the
|
|
26
|
+
* attribution it was constructed with plus the bound run id. If this is later
|
|
27
|
+
* re-keyed per call, it happens once, deliberately, as its own change.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import type { Run } from './entity.js'
|
|
31
|
+
import type { RunEvent } from './events.js'
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* One finished tool call, recovered from the run's own transcript.
|
|
35
|
+
*
|
|
36
|
+
* Re-declared here rather than imported from the disk store so the contract
|
|
37
|
+
* does not depend on an implementation of itself.
|
|
38
|
+
*/
|
|
39
|
+
export interface CompletedToolRecord {
|
|
40
|
+
readonly toolUseId: string
|
|
41
|
+
readonly toolName: string
|
|
42
|
+
readonly result: string
|
|
43
|
+
readonly isError: boolean
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface RunStore {
|
|
47
|
+
/**
|
|
48
|
+
* Bind this store to a run, before any other call.
|
|
49
|
+
*
|
|
50
|
+
* Returns a location when the backend has one — the built-in disk store
|
|
51
|
+
* returns the run's directory — and `null` when it does not. A caller
|
|
52
|
+
* that renders the value must treat `null` as "this run is not on a
|
|
53
|
+
* filesystem" rather than as an error: an in-memory or object-storage
|
|
54
|
+
* backend has nothing to print, and inventing a path for it would put a
|
|
55
|
+
* directory that does not exist in front of an operator.
|
|
56
|
+
*/
|
|
57
|
+
initRun(runId: string, parentRunId?: string): Promise<string | null>
|
|
58
|
+
|
|
59
|
+
/** Persist the run record: status, metadata, usage, timings. */
|
|
60
|
+
writeRunMeta(run: Run): Promise<void>
|
|
61
|
+
|
|
62
|
+
/** Persist the run's full message history. */
|
|
63
|
+
writeMessages(run: Run): Promise<void>
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Append one event to the run's durable event log.
|
|
67
|
+
*
|
|
68
|
+
* High-frequency streaming deltas are excluded before they reach here —
|
|
69
|
+
* that exclusion is a deliberate trade and belongs to the emitter, not to
|
|
70
|
+
* the backend, so a store must not re-filter.
|
|
71
|
+
*/
|
|
72
|
+
appendEvent(event: RunEvent): Promise<void>
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Persist the run's final report. Returns a location, or `null` when the
|
|
76
|
+
* backend has none. See {@link RunStore.initRun}.
|
|
77
|
+
*/
|
|
78
|
+
writeReport(content: string): Promise<string | null>
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Every tool call this run has already finished, keyed by `toolUseId`.
|
|
82
|
+
*
|
|
83
|
+
* A batch's results reach the message history only once the WHOLE batch
|
|
84
|
+
* settles, so a hard kill part-way through loses every result that had
|
|
85
|
+
* already come back, and the resumed run re-executes those calls. For a
|
|
86
|
+
* file write that is waste; for a payment or an email it is a second one.
|
|
87
|
+
*
|
|
88
|
+
* A backend that does not retain individual events answers with an empty
|
|
89
|
+
* map, which costs re-execution and is honest. It must not answer with a
|
|
90
|
+
* PARTIAL map: a caller reads a present entry as "this call is already
|
|
91
|
+
* answered", so a half-remembered batch is worse than a forgotten one.
|
|
92
|
+
*/
|
|
93
|
+
readCompletedTools(): Promise<Map<string, CompletedToolRecord>>
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Where this run's evidence lives, or `null` when it is not on a
|
|
97
|
+
* filesystem. Valid only after {@link RunStore.initRun}.
|
|
98
|
+
*/
|
|
99
|
+
getRunDir(): string | null
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Record the run in a browsable catalogue of runs. OPTIONAL.
|
|
103
|
+
*
|
|
104
|
+
* Optional because it is the one method here that is not evidence: it
|
|
105
|
+
* maintains a convenience listing for a human reading the directory, and
|
|
106
|
+
* a backend whose runs are already queryable has nothing to add. The
|
|
107
|
+
* programmatic answer to "which runs are there" is
|
|
108
|
+
* `CheckpointStore.listDurableRuns`, which carries attribution and
|
|
109
|
+
* includes sub-runs; this does neither.
|
|
110
|
+
*/
|
|
111
|
+
addToIndex?(run: Run): Promise<void>
|
|
112
|
+
}
|