@namzu/sdk 20.0.0 → 20.1.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 +61 -0
- package/dist/contracts/a2a.d.ts +2 -2
- package/dist/manager/run/persistence.d.ts.map +1 -1
- package/dist/manager/run/persistence.js +11 -0
- package/dist/manager/run/persistence.js.map +1 -1
- package/dist/public-runtime.d.ts +3 -1
- package/dist/public-runtime.d.ts.map +1 -1
- package/dist/public-runtime.js +7 -1
- package/dist/public-runtime.js.map +1 -1
- package/dist/store/index.d.ts +3 -0
- package/dist/store/index.d.ts.map +1 -1
- package/dist/store/index.js +12 -0
- package/dist/store/index.js.map +1 -1
- package/dist/store/run/checkpoint-disk.d.ts +56 -2
- package/dist/store/run/checkpoint-disk.d.ts.map +1 -1
- package/dist/store/run/checkpoint-disk.js +83 -2
- package/dist/store/run/checkpoint-disk.js.map +1 -1
- package/dist/store/run/checkpoint-memory.d.ts +31 -0
- package/dist/store/run/checkpoint-memory.d.ts.map +1 -0
- package/dist/store/run/checkpoint-memory.js +83 -0
- package/dist/store/run/checkpoint-memory.js.map +1 -0
- package/dist/store/run/disk.d.ts +53 -0
- package/dist/store/run/disk.d.ts.map +1 -1
- package/dist/store/run/disk.js +73 -30
- package/dist/store/run/disk.js.map +1 -1
- package/dist/store/run/listing.d.ts +75 -0
- package/dist/store/run/listing.d.ts.map +1 -0
- package/dist/store/run/listing.js +183 -0
- package/dist/store/run/listing.js.map +1 -0
- package/dist/types/run/checkpoint-store.d.ts +210 -1
- package/dist/types/run/checkpoint-store.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/manager/run/persistence.ts +18 -4
- package/src/public-runtime.ts +13 -0
- package/src/store/index.ts +18 -0
- package/src/store/run/checkpoint-disk.ts +128 -4
- package/src/store/run/checkpoint-memory.ts +101 -0
- package/src/store/run/disk.ts +72 -27
- package/src/store/run/listing.ts +229 -0
- package/src/types/run/checkpoint-store.ts +221 -1
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* {@link CheckpointRunScope} so a shared backend can key rows by the full
|
|
9
9
|
* five-layer attribution (Convention #17) instead of a filesystem path.
|
|
10
10
|
*/
|
|
11
|
-
import type { CheckpointId, IterationCheckpoint } from '../hitl/index.js';
|
|
11
|
+
import type { CheckpointId, HITLDecisionRequest, IterationCheckpoint } from '../hitl/index.js';
|
|
12
12
|
import type { RunId, SessionId, TenantId } from '../ids/index.js';
|
|
13
13
|
import type { ProjectId } from '../session/ids.js';
|
|
14
14
|
/**
|
|
@@ -36,6 +36,153 @@ export interface CheckpointRunScope {
|
|
|
36
36
|
*/
|
|
37
37
|
parentRunId?: RunId;
|
|
38
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* A CONTIGUOUS PREFIX of the run attribution hierarchy, addressing a SET of
|
|
41
|
+
* runs rather than one.
|
|
42
|
+
*
|
|
43
|
+
* A separate type from {@link CheckpointRunScope} on purpose. That type
|
|
44
|
+
* addresses exactly one run and four accessors depend on it doing so; making
|
|
45
|
+
* its one distinguishing field optional in place would turn "the scope of a
|
|
46
|
+
* run" into "some identifiers, maybe", and every accessor's guarantee with
|
|
47
|
+
* it.
|
|
48
|
+
*
|
|
49
|
+
* Three properties, each deliberate:
|
|
50
|
+
*
|
|
51
|
+
* - **`tenantId` is required.** Isolation is the one boundary that is never
|
|
52
|
+
* optional here. An untenanted listing is a cross-tenant read with a
|
|
53
|
+
* friendly name.
|
|
54
|
+
* - **It stops ABOVE the run.** No `runId`, no `parentRunId`. A caller
|
|
55
|
+
* holding a run id already has a full {@link CheckpointRunScope} and four
|
|
56
|
+
* accessors that take it; admitting one here would make
|
|
57
|
+
* `CheckpointRunScope` structurally assignable to this type and re-merge
|
|
58
|
+
* the two ideas the split exists to keep apart.
|
|
59
|
+
* - **The prefix must be contiguous.** A `sessionId` with no `projectId` is
|
|
60
|
+
* REFUSED, not silently widened to "that session under whichever project
|
|
61
|
+
* holds it". A flat backend can answer it and a hierarchical one cannot,
|
|
62
|
+
* so the answer would depend on the backend's storage shape — which is
|
|
63
|
+
* the one thing a store contract exists to hide.
|
|
64
|
+
*/
|
|
65
|
+
export interface CheckpointListingScope {
|
|
66
|
+
/** Isolation boundary (Convention #17). Never optional. */
|
|
67
|
+
readonly tenantId: TenantId;
|
|
68
|
+
/** Narrow to one project. Absent = every project of the tenant. */
|
|
69
|
+
readonly projectId?: ProjectId;
|
|
70
|
+
/** Narrow to one session. Requires `projectId`. */
|
|
71
|
+
readonly sessionId?: SessionId;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* What a run's human-in-the-loop park is doing, as far as durable state can
|
|
75
|
+
* tell.
|
|
76
|
+
*
|
|
77
|
+
* A closed union rather than a boolean because the two unanswered states are
|
|
78
|
+
* drained by DIFFERENT operators: `outstanding` is an approval inbox's queue
|
|
79
|
+
* and `expired` is a reclamation sweep's, and serving one to the other either
|
|
80
|
+
* re-presents a dead approval forever or discards a live one.
|
|
81
|
+
*/
|
|
82
|
+
export type ParkState =
|
|
83
|
+
/** `pending` set, no `resolvedAt`, deadline not passed. A human owes an answer. */
|
|
84
|
+
'outstanding'
|
|
85
|
+
/** `pending` set, no `resolvedAt`, deadline passed. Nobody will answer it. */
|
|
86
|
+
| 'expired'
|
|
87
|
+
/** `pending` set with `resolvedAt`. Kept as evidence of who decided what. */
|
|
88
|
+
| 'resolved';
|
|
89
|
+
/**
|
|
90
|
+
* **Do not widen this union to say who is working on the run.**
|
|
91
|
+
*
|
|
92
|
+
* A consumer switches over `ParkState` exhaustively, so a fourth member is a
|
|
93
|
+
* backward-incompatible change and a `major` — and the pull to add one is
|
|
94
|
+
* real, because the next capability this contract takes is a cross-process
|
|
95
|
+
* claim, and a queue worker draining the inbox wants to skip runs another
|
|
96
|
+
* worker already holds.
|
|
97
|
+
*
|
|
98
|
+
* That is a different fact about a different subject. A park is a question
|
|
99
|
+
* put to a HUMAN; a claim is a lease held by a PROCESS, and one run can have
|
|
100
|
+
* both, neither, or either. Encoding them in one union makes the pair
|
|
101
|
+
* unsayable and loses the state a worker needs most: parked AND unclaimed.
|
|
102
|
+
*
|
|
103
|
+
* The additive shape is a sibling optional field — `claim?: …` on
|
|
104
|
+
* {@link DurableRunEntry}, `claimed?: …` on {@link ListDurableRunsOptions}.
|
|
105
|
+
* A consumer reading rows is not broken by a new optional field, so the
|
|
106
|
+
* claim ships as a second `minor` on this contract rather than a second
|
|
107
|
+
* migration of it. This note exists because the union is the obvious place
|
|
108
|
+
* to reach for and the wrong one.
|
|
109
|
+
*/
|
|
110
|
+
/** A run's park disposition, projected from the checkpoint that carries it. */
|
|
111
|
+
export interface ParkSummary {
|
|
112
|
+
readonly state: ParkState;
|
|
113
|
+
/** The parked checkpoint — address it directly with `readCheckpoint`. */
|
|
114
|
+
readonly checkpointId: CheckpointId;
|
|
115
|
+
/** What the human was asked. Enough to route an inbox without a second read. */
|
|
116
|
+
readonly requestType: HITLDecisionRequest['type'];
|
|
117
|
+
/** Epoch ms at which the run parked. */
|
|
118
|
+
readonly parkedAt: number;
|
|
119
|
+
/** Absolute expiry, when the park carries one. */
|
|
120
|
+
readonly deadlineAt?: number;
|
|
121
|
+
/** Epoch ms at which the answer arrived. Only on `resolved`. */
|
|
122
|
+
readonly resolvedAt?: number;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* One run that has durable checkpoint state under the queried scope.
|
|
126
|
+
*
|
|
127
|
+
* **Extends {@link CheckpointRunScope}, and that is the load-bearing part.**
|
|
128
|
+
* A listing whose rows cannot be turned back into an addressable scope is a
|
|
129
|
+
* report, not a work queue. Because an entry IS a run scope,
|
|
130
|
+
* `findPendingCheckpoint(store, entry)`, `new CheckpointManager(store, entry)`
|
|
131
|
+
* and `resumeRun({ scope: entry, … })` all accept a row straight out of the
|
|
132
|
+
* listing — with no re-assembly, and so no chance of assembling it wrong.
|
|
133
|
+
*
|
|
134
|
+
* ### What an entry deliberately does NOT carry
|
|
135
|
+
*
|
|
136
|
+
* A run STATUS. A checkpoint is written mid-flight, so nothing in this store
|
|
137
|
+
* distinguishes a run that finished from one that died — the same fact
|
|
138
|
+
* {@link import('../../runtime/query/run-state.js').loadRunState} already
|
|
139
|
+
* states, where a rebuilt snapshot always reports `running` and the host's
|
|
140
|
+
* own record stays the authority. A `status` field here would answer
|
|
141
|
+
* "mid-flight" for every run that ever succeeded, and a sweeper built on it
|
|
142
|
+
* would resume finished work.
|
|
143
|
+
*
|
|
144
|
+
* A crash sweep is therefore: list every run with durable state under the
|
|
145
|
+
* scope, intersect with the host's own run records, resume the difference.
|
|
146
|
+
*/
|
|
147
|
+
export interface DurableRunEntry extends CheckpointRunScope {
|
|
148
|
+
/** How many checkpoints the run has right now. Pruning lowers it. */
|
|
149
|
+
readonly checkpointCount: number;
|
|
150
|
+
/** Newest checkpoint by `createdAt` — the one a resume restores by default. */
|
|
151
|
+
readonly latestCheckpointId: CheckpointId;
|
|
152
|
+
/** `createdAt` of {@link DurableRunEntry.latestCheckpointId}. */
|
|
153
|
+
readonly latestCheckpointAt: number;
|
|
154
|
+
/** Absent when the run has never parked. */
|
|
155
|
+
readonly park?: ParkSummary;
|
|
156
|
+
}
|
|
157
|
+
/** Filters and paging for {@link CheckpointStore.listDurableRuns}. */
|
|
158
|
+
export interface ListDurableRunsOptions {
|
|
159
|
+
/**
|
|
160
|
+
* Keep only runs whose park is in one of these states. A run that never
|
|
161
|
+
* parked has no state and is excluded by ANY value here; omit the filter
|
|
162
|
+
* to include it.
|
|
163
|
+
*/
|
|
164
|
+
readonly park?: readonly ParkState[];
|
|
165
|
+
/** Page size. Defaults to 100, clamped to at least 1. */
|
|
166
|
+
readonly limit?: number;
|
|
167
|
+
/** Resume token from the previous page's {@link DurableRunPage.cursor}. */
|
|
168
|
+
readonly cursor?: string;
|
|
169
|
+
/**
|
|
170
|
+
* Clock for expiry, so a sweep can be tested and so every entry in one
|
|
171
|
+
* page is judged against the same instant. Defaults to `Date.now()` — the
|
|
172
|
+
* same seam `findPendingCheckpoint` already takes.
|
|
173
|
+
*/
|
|
174
|
+
readonly now?: number;
|
|
175
|
+
}
|
|
176
|
+
/** One page of {@link DurableRunEntry}. */
|
|
177
|
+
export interface DurableRunPage {
|
|
178
|
+
readonly entries: readonly DurableRunEntry[];
|
|
179
|
+
/**
|
|
180
|
+
* Pass to the next call. **Absent means the listing is exhausted**, so
|
|
181
|
+
* `while (cursor)` terminates; a store never returns a cursor it already
|
|
182
|
+
* knows yields nothing.
|
|
183
|
+
*/
|
|
184
|
+
readonly cursor?: string;
|
|
185
|
+
}
|
|
39
186
|
/**
|
|
40
187
|
* Persistence contract consumed by
|
|
41
188
|
* {@link import('../../runtime/query/checkpoint.js').CheckpointManager} and
|
|
@@ -46,6 +193,27 @@ export interface CheckpointRunScope {
|
|
|
46
193
|
* not-found error. `deleteCheckpoint` is idempotent: deleting an absent
|
|
47
194
|
* checkpoint succeeds as a no-op (mirrors the disk store's ENOENT
|
|
48
195
|
* swallowing).
|
|
196
|
+
*
|
|
197
|
+
* ## Optional capabilities, and the rule that comes with them
|
|
198
|
+
*
|
|
199
|
+
* {@link CheckpointStore.listDurableRuns} is optional, following
|
|
200
|
+
* `SessionStore.listSessionsByProject`. A required method would break every
|
|
201
|
+
* host that has already implemented this interface, which is a `major` for
|
|
202
|
+
* what is otherwise an additive capability.
|
|
203
|
+
*
|
|
204
|
+
* The rule optionality obliges: **a caller of an optional capability REFUSES
|
|
205
|
+
* when it is absent; it never degrades.** An approval inbox built on a store
|
|
206
|
+
* that cannot list has to throw, because an empty page would say "nothing is
|
|
207
|
+
* waiting on a human" when the truth is "I cannot tell" — an optional
|
|
208
|
+
* dependency degrading a check, which this repository has been bitten by
|
|
209
|
+
* before. Reach the capability through
|
|
210
|
+
* {@link import('../../store/run/listing.js').listDurableRuns}, which
|
|
211
|
+
* refuses on absence rather than answering.
|
|
212
|
+
*
|
|
213
|
+
* Any capability added here later takes the same shape — optional method,
|
|
214
|
+
* refusing helper. A cross-process claim is the next one, and a two-worker
|
|
215
|
+
* deployment against a store with no lease has to fail loudly rather than
|
|
216
|
+
* proceed.
|
|
49
217
|
*/
|
|
50
218
|
export interface CheckpointStore {
|
|
51
219
|
/** Persist one checkpoint. Overwrites an existing checkpoint with the same id. */
|
|
@@ -60,5 +228,46 @@ export interface CheckpointStore {
|
|
|
60
228
|
listCheckpoints(scope: CheckpointRunScope): Promise<IterationCheckpoint[]>;
|
|
61
229
|
/** Delete a checkpoint by id. Absent checkpoints succeed as a no-op. */
|
|
62
230
|
deleteCheckpoint(scope: CheckpointRunScope, checkpointId: CheckpointId): Promise<void>;
|
|
231
|
+
/**
|
|
232
|
+
* Every run with durable checkpoint state under a scope ABOVE the run.
|
|
233
|
+
* OPTIONAL — see the optional-capability rule on this interface.
|
|
234
|
+
*
|
|
235
|
+
* This is the read an approval inbox and a park sweep are built from, and
|
|
236
|
+
* the one thing this contract had no way to express: every other accessor
|
|
237
|
+
* needs a `runId`, so a host could only ask about runs it already knew
|
|
238
|
+
* about. `hitlParkTtlMs` documents a host sweep as the reclamation path
|
|
239
|
+
* for an unanswered park, and until this existed the sweep had no way to
|
|
240
|
+
* enumerate what to sweep.
|
|
241
|
+
*
|
|
242
|
+
* ### Ordering, and why it is not chronological
|
|
243
|
+
*
|
|
244
|
+
* Rows come back ordered by `runId` ascending, and the cursor is a
|
|
245
|
+
* position in that order.
|
|
246
|
+
*
|
|
247
|
+
* A cursor has to sort on a key that cannot move, or a paging caller
|
|
248
|
+
* skips rows and repeats rows. Every time-valued key this store can
|
|
249
|
+
* derive per run DOES move: the newest checkpoint's timestamp advances
|
|
250
|
+
* whenever the run checkpoints again, and the oldest one's advances
|
|
251
|
+
* whenever `CheckpointManager.prune` deletes oldest-first, which is what
|
|
252
|
+
* pruning does. `runId` is the only immutable, unique per-run key
|
|
253
|
+
* available, and being unique it is already a total order — the
|
|
254
|
+
* degenerate case of the rule `orderChildren` follows (sort on a key that
|
|
255
|
+
* cannot move, make the order total with an id), not a departure from it.
|
|
256
|
+
*
|
|
257
|
+
* The cost is that page order is arbitrary rather than oldest-first,
|
|
258
|
+
* because run ids carry no timestamp. Entries carry `latestCheckpointAt`
|
|
259
|
+
* and `park.parkedAt` so a caller can sort what it has read.
|
|
260
|
+
*
|
|
261
|
+
* A run whose FIRST checkpoint is written after paging began may be
|
|
262
|
+
* missed by that pass — it lands at whatever `runId` it minted, possibly
|
|
263
|
+
* behind the cursor. That is the right trade for a queue: the sweep runs
|
|
264
|
+
* again and picks it up next pass, whereas a moving sort key loses runs
|
|
265
|
+
* that already existed.
|
|
266
|
+
*
|
|
267
|
+
* @param scope contiguous prefix; `tenantId` required. Implementations
|
|
268
|
+
* reject a hole (`sessionId` with no `projectId`) rather than guessing.
|
|
269
|
+
* @param options filters and paging. See {@link ListDurableRunsOptions}.
|
|
270
|
+
*/
|
|
271
|
+
listDurableRuns?(scope: CheckpointListingScope, options?: ListDurableRunsOptions): Promise<DurableRunPage>;
|
|
63
272
|
}
|
|
64
273
|
//# sourceMappingURL=checkpoint-store.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"checkpoint-store.d.ts","sourceRoot":"","sources":["../../../src/types/run/checkpoint-store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,mBAAmB,EAAE,MAAM,kBAAkB,CAAA;
|
|
1
|
+
{"version":3,"file":"checkpoint-store.d.ts","sourceRoot":"","sources":["../../../src/types/run/checkpoint-store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,mBAAmB,EAAE,mBAAmB,EAAE,MAAM,kBAAkB,CAAA;AAC9F,OAAO,KAAK,EAAE,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,iBAAiB,CAAA;AACjE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAA;AAElD;;;;;;;;GAQG;AACH,MAAM,WAAW,kBAAkB;IAClC,2CAA2C;IAC3C,QAAQ,EAAE,QAAQ,CAAA;IAClB,gDAAgD;IAChD,SAAS,EAAE,SAAS,CAAA;IACpB,wCAAwC;IACxC,SAAS,EAAE,SAAS,CAAA;IACpB,2CAA2C;IAC3C,KAAK,EAAE,KAAK,CAAA;IACZ;;;;OAIG;IACH,WAAW,CAAC,EAAE,KAAK,CAAA;CACnB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,MAAM,WAAW,sBAAsB;IACtC,2DAA2D;IAC3D,QAAQ,CAAC,QAAQ,EAAE,QAAQ,CAAA;IAC3B,mEAAmE;IACnE,QAAQ,CAAC,SAAS,CAAC,EAAE,SAAS,CAAA;IAC9B,mDAAmD;IACnD,QAAQ,CAAC,SAAS,CAAC,EAAE,SAAS,CAAA;CAC9B;AAED;;;;;;;;GAQG;AACH,MAAM,MAAM,SAAS;AACpB,mFAAmF;AACjF,aAAa;AACf,8EAA8E;GAC5E,SAAS;AACX,6EAA6E;GAC3E,UAAU,CAAA;AAEb;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,+EAA+E;AAC/E,MAAM,WAAW,WAAW;IAC3B,QAAQ,CAAC,KAAK,EAAE,SAAS,CAAA;IACzB,yEAAyE;IACzE,QAAQ,CAAC,YAAY,EAAE,YAAY,CAAA;IACnC,gFAAgF;IAChF,QAAQ,CAAC,WAAW,EAAE,mBAAmB,CAAC,MAAM,CAAC,CAAA;IACjD,wCAAwC;IACxC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;IACzB,kDAAkD;IAClD,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAA;IAC5B,gEAAgE;IAChE,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAA;CAC5B;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,MAAM,WAAW,eAAgB,SAAQ,kBAAkB;IAC1D,qEAAqE;IACrE,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAA;IAChC,+EAA+E;IAC/E,QAAQ,CAAC,kBAAkB,EAAE,YAAY,CAAA;IACzC,iEAAiE;IACjE,QAAQ,CAAC,kBAAkB,EAAE,MAAM,CAAA;IACnC,4CAA4C;IAC5C,QAAQ,CAAC,IAAI,CAAC,EAAE,WAAW,CAAA;CAC3B;AAED,sEAAsE;AACtE,MAAM,WAAW,sBAAsB;IACtC;;;;OAIG;IACH,QAAQ,CAAC,IAAI,CAAC,EAAE,SAAS,SAAS,EAAE,CAAA;IACpC,yDAAyD;IACzD,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAA;IACvB,2EAA2E;IAC3E,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAA;IACxB;;;;OAIG;IACH,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAA;CACrB;AAED,2CAA2C;AAC3C,MAAM,WAAW,cAAc;IAC9B,QAAQ,CAAC,OAAO,EAAE,SAAS,eAAe,EAAE,CAAA;IAC5C;;;;OAIG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAA;CACxB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,MAAM,WAAW,eAAe;IAC/B,kFAAkF;IAClF,eAAe,CAAC,KAAK,EAAE,kBAAkB,EAAE,UAAU,EAAE,mBAAmB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAE1F,6EAA6E;IAC7E,cAAc,CACb,KAAK,EAAE,kBAAkB,EACzB,YAAY,EAAE,YAAY,GACxB,OAAO,CAAC,mBAAmB,GAAG,IAAI,CAAC,CAAA;IAEtC;;;;OAIG;IACH,eAAe,CAAC,KAAK,EAAE,kBAAkB,GAAG,OAAO,CAAC,mBAAmB,EAAE,CAAC,CAAA;IAE1E,wEAAwE;IACxE,gBAAgB,CAAC,KAAK,EAAE,kBAAkB,EAAE,YAAY,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAEtF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAuCG;IACH,eAAe,CAAC,CACf,KAAK,EAAE,sBAAsB,EAC7B,OAAO,CAAC,EAAE,sBAAsB,GAC9B,OAAO,CAAC,cAAc,CAAC,CAAA;CAC1B"}
|
package/package.json
CHANGED
|
@@ -45,12 +45,26 @@ export class RunPersistence {
|
|
|
45
45
|
|
|
46
46
|
// Checkpoints go through the injectable seam; the disk layout under
|
|
47
47
|
// `outputDir` (same tree the runStore writes to) stays the default.
|
|
48
|
+
//
|
|
49
|
+
// The attribution is handed over because the layout does not record
|
|
50
|
+
// it — there is no tenant segment in the path at all — and without it
|
|
51
|
+
// the default store can persist checkpoints but cannot ENUMERATE
|
|
52
|
+
// them, which is the state the contract just stopped being in. A
|
|
53
|
+
// listing capability the default store declines is a capability no
|
|
54
|
+
// host reaches.
|
|
48
55
|
this.checkpointStore =
|
|
49
56
|
config.checkpointStore ??
|
|
50
|
-
new DiskCheckpointStore(
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
57
|
+
new DiskCheckpointStore(
|
|
58
|
+
{
|
|
59
|
+
baseDir: config.outputDir,
|
|
60
|
+
logger: config.log,
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
tenantId: config.tenantId,
|
|
64
|
+
projectId: config.projectId,
|
|
65
|
+
sessionId: config.sessionId,
|
|
66
|
+
},
|
|
67
|
+
)
|
|
54
68
|
|
|
55
69
|
this.run = {
|
|
56
70
|
id: config.runId,
|
package/src/public-runtime.ts
CHANGED
|
@@ -198,12 +198,25 @@ export {
|
|
|
198
198
|
DiskCheckpointStore,
|
|
199
199
|
DiskMemoryStore,
|
|
200
200
|
DiskTaskStore,
|
|
201
|
+
InMemoryCheckpointStore,
|
|
201
202
|
InMemoryMemoryIndex,
|
|
202
203
|
InMemoryMemoryStore,
|
|
203
204
|
InMemoryStore,
|
|
204
205
|
InMemoryTaskStore,
|
|
205
206
|
RunDiskStore,
|
|
206
207
|
} from './store/index.js'
|
|
208
|
+
export type { DiskCheckpointStoreAttribution } from './store/index.js'
|
|
209
|
+
// Enumerating runs above a run id — the read an approval inbox and a park
|
|
210
|
+
// sweep are built from, and the one the contract had no way to express.
|
|
211
|
+
// `listDurableRuns` REFUSES on a store that cannot list rather than
|
|
212
|
+
// reporting an empty page, because "nothing is waiting on a human" is not
|
|
213
|
+
// what "I cannot tell" means.
|
|
214
|
+
export {
|
|
215
|
+
assertContiguousListingScope,
|
|
216
|
+
listDurableRuns,
|
|
217
|
+
paginateDurableRuns,
|
|
218
|
+
toDurableRunEntry,
|
|
219
|
+
} from './store/index.js'
|
|
207
220
|
|
|
208
221
|
export {
|
|
209
222
|
AgentRegistry,
|
package/src/store/index.ts
CHANGED
|
@@ -3,6 +3,24 @@ export type { Identifiable, Timestamped } from './InMemoryStore.js'
|
|
|
3
3
|
|
|
4
4
|
export { RunDiskStore } from './run/disk.js'
|
|
5
5
|
export { DiskCheckpointStore } from './run/checkpoint-disk.js'
|
|
6
|
+
export type { DiskCheckpointStoreAttribution } from './run/checkpoint-disk.js'
|
|
7
|
+
export { InMemoryCheckpointStore } from './run/checkpoint-memory.js'
|
|
8
|
+
// The refusing entry point to the optional listing capability, plus the two
|
|
9
|
+
// projections a host implementing its own backend actually calls: one turns
|
|
10
|
+
// a run's checkpoints into a row, the other applies the contract's filter,
|
|
11
|
+
// ordering and cursor. Re-deriving either is how two stores start
|
|
12
|
+
// disagreeing about what "outstanding" means or where a page ends.
|
|
13
|
+
//
|
|
14
|
+
// `summarizePark` and `DEFAULT_DURABLE_RUN_LIMIT` are deliberately NOT here.
|
|
15
|
+
// The first is an internal of `toDurableRunEntry` and no caller wants half a
|
|
16
|
+
// row; the second is a number a host reads by omitting `limit`. A name a
|
|
17
|
+
// host has no use for is surface to keep correct forever for nobody.
|
|
18
|
+
export {
|
|
19
|
+
assertContiguousListingScope,
|
|
20
|
+
listDurableRuns,
|
|
21
|
+
paginateDurableRuns,
|
|
22
|
+
toDurableRunEntry,
|
|
23
|
+
} from './run/listing.js'
|
|
6
24
|
|
|
7
25
|
export { ActivityStore } from './activity/memory.js'
|
|
8
26
|
export type { ActivityEvent, ActivityEventListener } from './activity/memory.js'
|
|
@@ -1,8 +1,40 @@
|
|
|
1
|
+
import { readdir } from 'node:fs/promises'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { NamzuError } from '../../types/errors/index.js'
|
|
1
4
|
import type { CheckpointId, IterationCheckpoint } from '../../types/hitl/index.js'
|
|
2
|
-
import type { RunId } from '../../types/ids/index.js'
|
|
3
|
-
import type {
|
|
5
|
+
import type { RunId, SessionId, TenantId } from '../../types/ids/index.js'
|
|
6
|
+
import type {
|
|
7
|
+
CheckpointListingScope,
|
|
8
|
+
CheckpointRunScope,
|
|
9
|
+
CheckpointStore,
|
|
10
|
+
DurableRunEntry,
|
|
11
|
+
DurableRunPage,
|
|
12
|
+
ListDurableRunsOptions,
|
|
13
|
+
} from '../../types/run/checkpoint-store.js'
|
|
4
14
|
import type { RunStoreConfig } from '../../types/run/index.js'
|
|
5
|
-
import {
|
|
15
|
+
import type { ProjectId } from '../../types/session/ids.js'
|
|
16
|
+
import { RunDiskStore, readCheckpointsIn } from './disk.js'
|
|
17
|
+
import { assertContiguousListingScope, paginateDurableRuns, toDurableRunEntry } from './listing.js'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The attribution a disk store's own layout does not record.
|
|
21
|
+
*
|
|
22
|
+
* The canonical layout is
|
|
23
|
+
* `{root}/projects/{projectId}/sessions/{sessionId}/runs/{runId}` — there is
|
|
24
|
+
* no tenant segment anywhere in it, and `baseDir` is already one session's
|
|
25
|
+
* `runs/` directory, so the project and session are implicit in a string the
|
|
26
|
+
* store cannot parse back out without knowing the layout that built it.
|
|
27
|
+
*
|
|
28
|
+
* A per-run read never needed any of it: the caller supplies a full
|
|
29
|
+
* `CheckpointRunScope` and the store only uses `runId`. A LISTING does — its
|
|
30
|
+
* rows have to be addressable, and a row with no tenant is a row nothing can
|
|
31
|
+
* be resumed from. So the store is told, once, what tree it is holding.
|
|
32
|
+
*/
|
|
33
|
+
export interface DiskCheckpointStoreAttribution {
|
|
34
|
+
readonly tenantId: TenantId
|
|
35
|
+
readonly projectId: ProjectId
|
|
36
|
+
readonly sessionId: SessionId
|
|
37
|
+
}
|
|
6
38
|
|
|
7
39
|
/**
|
|
8
40
|
* Disk conformance layer for {@link CheckpointStore}: adapts the existing
|
|
@@ -20,10 +52,20 @@ import { RunDiskStore } from './disk.js'
|
|
|
20
52
|
*/
|
|
21
53
|
export class DiskCheckpointStore implements CheckpointStore {
|
|
22
54
|
private readonly config: RunStoreConfig
|
|
55
|
+
private readonly attribution?: DiskCheckpointStoreAttribution
|
|
23
56
|
private readonly bound = new Map<RunId, Promise<RunDiskStore>>()
|
|
24
57
|
|
|
25
|
-
|
|
58
|
+
/**
|
|
59
|
+
* @param config the run-store config; `baseDir` is one session's `runs/`
|
|
60
|
+
* directory.
|
|
61
|
+
* @param attribution what tree this is, for
|
|
62
|
+
* {@link DiskCheckpointStore.listDurableRuns}. Optional so that adding
|
|
63
|
+
* the listing did not change an existing construction; a store built
|
|
64
|
+
* without it refuses to list rather than inventing a tenant.
|
|
65
|
+
*/
|
|
66
|
+
constructor(config: RunStoreConfig, attribution?: DiskCheckpointStoreAttribution) {
|
|
26
67
|
this.config = config
|
|
68
|
+
this.attribution = attribution
|
|
27
69
|
}
|
|
28
70
|
|
|
29
71
|
private bind(scope: CheckpointRunScope): Promise<RunDiskStore> {
|
|
@@ -64,4 +106,86 @@ export class DiskCheckpointStore implements CheckpointStore {
|
|
|
64
106
|
const store = await this.bind(scope)
|
|
65
107
|
await store.deleteCheckpoint(checkpointId)
|
|
66
108
|
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Every run with checkpoints under this store's tree.
|
|
112
|
+
*
|
|
113
|
+
* Reads the directories rather than binding a {@link RunDiskStore} per
|
|
114
|
+
* run, because binding CREATES the run directory — a listing that
|
|
115
|
+
* materialized a directory for every run it looked at would grow the tree
|
|
116
|
+
* it is reporting on.
|
|
117
|
+
*
|
|
118
|
+
* ### Why a two-level walk reaches every depth
|
|
119
|
+
*
|
|
120
|
+
* `initRun` nests exactly one level: a run with a parent goes to
|
|
121
|
+
* `{baseDir}/{parentRunId}/children/{runId}`, and a grandchild goes to
|
|
122
|
+
* `{baseDir}/{itsOwnParentRunId}/children/{runId}` — beside the top-level
|
|
123
|
+
* runs, not beneath its grandparent. So the tree is flat-with-one-nesting
|
|
124
|
+
* at every depth, `{baseDir}/*` plus `{baseDir}/* /children/*` enumerates
|
|
125
|
+
* all of it, and each run's `parentRunId` is the directory it sits under.
|
|
126
|
+
* A deep run leaves a bare shell directory under its own id at the top
|
|
127
|
+
* level (`{baseDir}/{parentRunId}` created by `mkdir -p` for a child of a
|
|
128
|
+
* run whose own data lives elsewhere); those hold no `checkpoints/` and
|
|
129
|
+
* drop out as entries with no durable state.
|
|
130
|
+
*/
|
|
131
|
+
async listDurableRuns(
|
|
132
|
+
scope: CheckpointListingScope,
|
|
133
|
+
options?: ListDurableRunsOptions,
|
|
134
|
+
): Promise<DurableRunPage> {
|
|
135
|
+
assertContiguousListingScope(scope, 'DiskCheckpointStore.listDurableRuns')
|
|
136
|
+
|
|
137
|
+
const attribution = this.attribution
|
|
138
|
+
if (!attribution) {
|
|
139
|
+
throw new NamzuError({
|
|
140
|
+
code: 'invalid_config',
|
|
141
|
+
message:
|
|
142
|
+
'DiskCheckpointStore.listDurableRuns: this store was constructed without attribution, so it cannot say which tenant, project or session its runs belong to — and a listing row that carries no scope is a row nothing can be resumed or swept from. Pass the second constructor argument. Refusing rather than returning rows stamped with a guessed tenant.',
|
|
143
|
+
details: { baseDir: this.config.baseDir },
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// A listing is scoped, not addressed: a query for another tenant is a
|
|
148
|
+
// question this tree has no rows for, not an isolation violation. Same
|
|
149
|
+
// reasoning `SessionStore.listSessions` already states for sessions
|
|
150
|
+
// that happen to share a thread id across tenants.
|
|
151
|
+
if (
|
|
152
|
+
scope.tenantId !== attribution.tenantId ||
|
|
153
|
+
(scope.projectId !== undefined && scope.projectId !== attribution.projectId) ||
|
|
154
|
+
(scope.sessionId !== undefined && scope.sessionId !== attribution.sessionId)
|
|
155
|
+
) {
|
|
156
|
+
return { entries: [] }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const now = options?.now ?? Date.now()
|
|
160
|
+
const entries: DurableRunEntry[] = []
|
|
161
|
+
|
|
162
|
+
for (const runId of await this.readRunDirs(this.config.baseDir)) {
|
|
163
|
+
const runDir = join(this.config.baseDir, runId)
|
|
164
|
+
|
|
165
|
+
const own = toDurableRunEntry({ ...attribution, runId }, await readCheckpointsIn(runDir), now)
|
|
166
|
+
if (own) entries.push(own)
|
|
167
|
+
|
|
168
|
+
for (const childId of await this.readRunDirs(join(runDir, 'children'))) {
|
|
169
|
+
const child = toDurableRunEntry(
|
|
170
|
+
{ ...attribution, runId: childId, parentRunId: runId },
|
|
171
|
+
await readCheckpointsIn(join(runDir, 'children', childId)),
|
|
172
|
+
now,
|
|
173
|
+
)
|
|
174
|
+
if (child) entries.push(child)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return paginateDurableRuns(entries, options)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Directory names under `dir`, or none when `dir` does not exist. */
|
|
182
|
+
private async readRunDirs(dir: string): Promise<RunId[]> {
|
|
183
|
+
try {
|
|
184
|
+
const found = await readdir(dir, { withFileTypes: true })
|
|
185
|
+
return found.filter((e) => e.isDirectory()).map((e) => e.name as RunId)
|
|
186
|
+
} catch (err) {
|
|
187
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return []
|
|
188
|
+
throw err
|
|
189
|
+
}
|
|
190
|
+
}
|
|
67
191
|
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { CheckpointId, IterationCheckpoint } from '../../types/hitl/index.js'
|
|
2
|
+
import type {
|
|
3
|
+
CheckpointListingScope,
|
|
4
|
+
CheckpointRunScope,
|
|
5
|
+
CheckpointStore,
|
|
6
|
+
DurableRunEntry,
|
|
7
|
+
DurableRunPage,
|
|
8
|
+
ListDurableRunsOptions,
|
|
9
|
+
} from '../../types/run/checkpoint-store.js'
|
|
10
|
+
import { assertContiguousListingScope, paginateDurableRuns, toDurableRunEntry } from './listing.js'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Process-local {@link CheckpointStore}, keyed by the full five-layer scope.
|
|
14
|
+
*
|
|
15
|
+
* Shipped rather than left as a test fixture for two reasons. It is the
|
|
16
|
+
* reference a host reads when writing a backend of its own — the disk store
|
|
17
|
+
* is path-addressed and answers "what does an attribution-keyed store look
|
|
18
|
+
* like" with a directory layout, which is the wrong lesson. And it is the
|
|
19
|
+
* only implementation that can hold more than one tenant at once, because
|
|
20
|
+
* the disk layout has no tenant in it: a test that two tenants' listings
|
|
21
|
+
* stay separate is not expressible against disk, and a rule that cannot be
|
|
22
|
+
* tested on the store a host will actually inject is a rule on paper.
|
|
23
|
+
*
|
|
24
|
+
* Not durable, deliberately: it is for tests, for a single-process host that
|
|
25
|
+
* genuinely wants checkpoints to die with the process, and as the parity
|
|
26
|
+
* partner that proves the listing contract is not a filesystem in disguise.
|
|
27
|
+
*/
|
|
28
|
+
export class InMemoryCheckpointStore implements CheckpointStore {
|
|
29
|
+
/** `tenant/project/session/run` → checkpoint id → checkpoint. */
|
|
30
|
+
private readonly runs = new Map<string, Map<CheckpointId, IterationCheckpoint>>()
|
|
31
|
+
/** Same key → the run's scope, so a listing can rebuild an addressable entry. */
|
|
32
|
+
private readonly scopes = new Map<string, CheckpointRunScope>()
|
|
33
|
+
|
|
34
|
+
private key(scope: CheckpointRunScope): string {
|
|
35
|
+
return [scope.tenantId, scope.projectId, scope.sessionId, scope.runId].join('/')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async writeCheckpoint(scope: CheckpointRunScope, checkpoint: IterationCheckpoint): Promise<void> {
|
|
39
|
+
const key = this.key(scope)
|
|
40
|
+
let run = this.runs.get(key)
|
|
41
|
+
if (!run) {
|
|
42
|
+
run = new Map()
|
|
43
|
+
this.runs.set(key, run)
|
|
44
|
+
}
|
|
45
|
+
// The run's scope is kept beside its checkpoints because the key is a
|
|
46
|
+
// joined string and a listing has to hand back the parts — above all
|
|
47
|
+
// `parentRunId`, which is what makes a sub-run's row addressable.
|
|
48
|
+
//
|
|
49
|
+
// Written on every call rather than only the first, and that is
|
|
50
|
+
// simplicity, not defence: a run's scope is fixed when the run is
|
|
51
|
+
// constructed, so the two cannot differ, and a `has` guard here would
|
|
52
|
+
// be a branch no input can take.
|
|
53
|
+
this.scopes.set(key, {
|
|
54
|
+
tenantId: scope.tenantId,
|
|
55
|
+
projectId: scope.projectId,
|
|
56
|
+
sessionId: scope.sessionId,
|
|
57
|
+
runId: scope.runId,
|
|
58
|
+
...(scope.parentRunId ? { parentRunId: scope.parentRunId } : {}),
|
|
59
|
+
})
|
|
60
|
+
run.set(checkpoint.id, checkpoint)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async readCheckpoint(
|
|
64
|
+
scope: CheckpointRunScope,
|
|
65
|
+
checkpointId: CheckpointId,
|
|
66
|
+
): Promise<IterationCheckpoint | null> {
|
|
67
|
+
return this.runs.get(this.key(scope))?.get(checkpointId) ?? null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async listCheckpoints(scope: CheckpointRunScope): Promise<IterationCheckpoint[]> {
|
|
71
|
+
const run = this.runs.get(this.key(scope))
|
|
72
|
+
if (!run) return []
|
|
73
|
+
return [...run.values()].sort((a, b) => a.createdAt - b.createdAt)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async deleteCheckpoint(scope: CheckpointRunScope, checkpointId: CheckpointId): Promise<void> {
|
|
77
|
+
this.runs.get(this.key(scope))?.delete(checkpointId)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async listDurableRuns(
|
|
81
|
+
scope: CheckpointListingScope,
|
|
82
|
+
options?: ListDurableRunsOptions,
|
|
83
|
+
): Promise<DurableRunPage> {
|
|
84
|
+
assertContiguousListingScope(scope, 'InMemoryCheckpointStore.listDurableRuns')
|
|
85
|
+
const now = options?.now ?? Date.now()
|
|
86
|
+
|
|
87
|
+
const entries: DurableRunEntry[] = []
|
|
88
|
+
for (const [key, checkpoints] of this.runs) {
|
|
89
|
+
const runScope = this.scopes.get(key)
|
|
90
|
+
if (!runScope) continue
|
|
91
|
+
if (runScope.tenantId !== scope.tenantId) continue
|
|
92
|
+
if (scope.projectId !== undefined && runScope.projectId !== scope.projectId) continue
|
|
93
|
+
if (scope.sessionId !== undefined && runScope.sessionId !== scope.sessionId) continue
|
|
94
|
+
|
|
95
|
+
const entry = toDurableRunEntry(runScope, [...checkpoints.values()], now)
|
|
96
|
+
if (entry) entries.push(entry)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return paginateDurableRuns(entries, options)
|
|
100
|
+
}
|
|
101
|
+
}
|