@jr2/orchestrator 0.3.0 → 0.4.1

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.
Files changed (73) hide show
  1. package/bin/server.ts +7 -4
  2. package/console/canvas.ts +27 -14
  3. package/console/components/app.ts +10 -1
  4. package/console/components/fleet.ts +39 -6
  5. package/console/components/machine-pane.ts +89 -2
  6. package/console/components/nav.ts +1 -1
  7. package/console/components/schema-form.ts +12 -4
  8. package/console/json-field.ts +23 -0
  9. package/console/main.ts +110 -39
  10. package/console/store.ts +221 -17
  11. package/console/style.css +90 -0
  12. package/package.json +4 -4
  13. package/src/actor.ts +502 -49
  14. package/src/ambient.ts +2 -0
  15. package/src/assembly-checks.ts +165 -0
  16. package/src/assembly-tar.ts +494 -0
  17. package/src/assembly.ts +721 -0
  18. package/src/chart.ts +2050 -0
  19. package/src/charts.ts +720 -0
  20. package/src/config.ts +152 -46
  21. package/src/content-hash.ts +105 -0
  22. package/src/custodian.lua +6 -5
  23. package/src/custodian.ts +71 -19
  24. package/src/customize.ts +216 -25
  25. package/src/effect-checks.ts +557 -0
  26. package/src/effect.ts +905 -0
  27. package/src/effector-wire.ts +525 -0
  28. package/src/fingerprint.ts +4 -3
  29. package/src/gate.ts +8 -0
  30. package/src/git-clone.ts +65 -0
  31. package/src/git-push.ts +336 -0
  32. package/src/harness-client.ts +245 -23
  33. package/src/held-secrets.ts +82 -26
  34. package/src/holds.ts +156 -0
  35. package/src/http-effect.ts +981 -0
  36. package/src/http.ts +496 -104
  37. package/src/images.ts +47 -21
  38. package/src/index.ts +43 -2
  39. package/src/instance.ts +444 -58
  40. package/src/kube-client.ts +333 -0
  41. package/src/machine-doc.ts +88 -6
  42. package/src/mount-digest.ts +53 -0
  43. package/src/names.ts +66 -8
  44. package/src/notices.ts +88 -0
  45. package/src/open-body.ts +1444 -0
  46. package/src/open-effect.ts +144 -0
  47. package/src/open.ts +4 -4
  48. package/src/parts.ts +530 -33
  49. package/src/party.ts +468 -0
  50. package/src/pick.ts +309 -0
  51. package/src/pool.ts +1 -1
  52. package/src/registration.ts +301 -17
  53. package/src/repo-fetch.ts +60 -85
  54. package/src/repos.ts +63 -74
  55. package/src/rest.ts +133 -0
  56. package/src/run-host.ts +1832 -98
  57. package/src/sandbox-effect.ts +891 -0
  58. package/src/sandbox-effector.ts +111 -0
  59. package/src/sandbox-kube.ts +1719 -0
  60. package/src/sandbox-watch.ts +260 -0
  61. package/src/secret-placement.ts +494 -0
  62. package/src/server.ts +281 -84
  63. package/src/setup.ts +589 -70
  64. package/src/size.ts +342 -0
  65. package/src/snapshot-store.ts +64 -13
  66. package/src/stub-harness.ts +5 -0
  67. package/src/sync-client.ts +54 -0
  68. package/src/timers.ts +178 -0
  69. package/src/tokens.ts +35 -4
  70. package/src/vocabulary.ts +66 -1
  71. package/src/wire.ts +156 -2
  72. package/src/workspace.ts +529 -194
  73. package/src/sandbox-kubectl.ts +0 -1108
package/src/size.ts ADDED
@@ -0,0 +1,342 @@
1
+ // A Workspace's SIZE (ADR-0060): the ceiling it states for its whole Sandbox, cpu and memory and
2
+ // nothing else, which jr2 reserves in full. The shape is Agent Substrate's `ActorTemplate.resources`
3
+ // — a Kubernetes `ResourceRequirements` of which only `limits.cpu` and `limits.memory` are
4
+ // accepted — so a Workspace moves there unchanged.
5
+ //
6
+ // This module is the arithmetic and the refusals, with no Kubernetes client and no Machine in it:
7
+ //
8
+ // - `assertSize` refuses anything but the two limits WHERE IT IS READ (`workspace()`,
9
+ // `customize()`, `jr2.config.ts`), naming the field — a request below the limit, a missing
10
+ // memory limit, or a key Substrate refuses would make the Machine depend on something the move
11
+ // drops.
12
+ // - `resolveSize` walks the chain, most specific first, one field at a time: the Machine's Size
13
+ // (a `customize()` layered over the `workspace()`), then the Instance's `sandbox.resources` — a
14
+ // default for a Workspace that states none, never an override of one it states — then the kit.
15
+ // - `splitSize` divides the Size inside the pod: the Custodian's fixed slice, the User
16
+ // Container's split if the Machine states one, and the Harness gets the rest as its own limit,
17
+ // so an OOM stays per container where it can. It refuses a split that leaves the Harness below
18
+ // its floor, naming where each number came from, because the fix is an edit of one of them.
19
+ //
20
+ // Both the provision (sandbox-kube.ts) and `jr2 up` call the same two functions, so a Size the
21
+ // converge admits is a Size the provision composes.
22
+
23
+ /** A Kubernetes quantity: `"2Gi"`, `"1500m"`, `"2"`, or a plain number (`2` cpus, bytes of memory). */
24
+ export type Quantity = string | number;
25
+
26
+ /**
27
+ * A Size (ADR-0060, GLOSSARY.md "Size"): `limits.memory` and `limits.cpu`, nothing else. A field
28
+ * left out resolves down the chain (`resolveSize`) — the Instance's `sandbox.resources`, then the
29
+ * kit default of 2Gi and 1 cpu.
30
+ *
31
+ * The same shape is the User Container's split (`user: { image, resources }`), where a field left
32
+ * out means the seat has no limit of its own for it and shares the pod's budget.
33
+ */
34
+ export type Size = { limits: { memory?: Quantity; cpu?: Quantity } };
35
+
36
+ /** The kit's default Size (ADR-0060). Node gives each process a V8 heap of about 55% of its
37
+ * container limit, and `tsc` on a mid-size repo aborted below about 1.1Gi, so 1Gi fails a common
38
+ * task; 2Gi covers `tsc` + vitest, `cargo build` and Playwright at 2 workers. */
39
+ export const KIT_SIZE = { memory: "2Gi", cpu: "1" } as const;
40
+
41
+ /** The Custodian's fixed slice of every Sandbox's Size (ADR-0060). */
42
+ export const CUSTODIAN_SLICE = { memory: "64Mi", cpu: "50m" } as const;
43
+
44
+ /** The Harness container's floor: a split that leaves it less memory than this is refused. */
45
+ export const HARNESS_FLOOR = "256Mi";
46
+
47
+ /** `/dev/shm` is 25% of the Harness container's share, never below this (ADR-0060). */
48
+ export const SHM_FLOOR = "64Mi";
49
+
50
+ /** The `/work` disk: an ephemeral-storage REQUEST on the Harness container, with no limit and no
51
+ * `sizeLimit` — passing either evicts the pod and loses `/work` (ADR-0060). Kit-fixed. */
52
+ export const WORK_DISK_REQUEST = "10Gi";
53
+
54
+ /** The preflight init step's resources (ADR-0037): small and fixed, and inside the pod's budget —
55
+ * an init container runs before the others start, so it borrows the pod's Size, it adds nothing. */
56
+ export const PREFLIGHT_RESOURCES = {
57
+ requests: { cpu: "10m", memory: "32Mi" },
58
+ limits: { memory: "128Mi" },
59
+ } as const;
60
+
61
+ /** A container's or a pod's resources, in the Kubernetes (corev1.ResourceRequirements) shape. */
62
+ export type ResourceRequirements = {
63
+ requests?: Record<string, string>;
64
+ limits?: Record<string, string>;
65
+ };
66
+
67
+ // --- quantities --------------------------------------------------------------------------------
68
+
69
+ const QUANTITY = /^(\d+(?:\.\d+)?|\.\d+)(m|k|M|G|T|P|E|Ki|Mi|Gi|Ti|Pi|Ei)?$/;
70
+ const SCALE: Record<string, number> = {
71
+ "": 1,
72
+ m: 1e-3,
73
+ k: 1e3,
74
+ M: 1e6,
75
+ G: 1e9,
76
+ T: 1e12,
77
+ P: 1e15,
78
+ E: 1e18,
79
+ Ki: 2 ** 10,
80
+ Mi: 2 ** 20,
81
+ Gi: 2 ** 30,
82
+ Ti: 2 ** 40,
83
+ Pi: 2 ** 50,
84
+ Ei: 2 ** 60,
85
+ };
86
+
87
+ /** The quantity as a plain number of base units, or undefined when it is not one Kubernetes reads.
88
+ * Zero and negatives are not a Size: a zero limit is a container that cannot start. */
89
+ function baseUnits(q: unknown): number | undefined {
90
+ if (typeof q === "number") return Number.isFinite(q) && q > 0 ? q : undefined;
91
+ if (typeof q !== "string") return undefined;
92
+ const m = QUANTITY.exec(q.trim());
93
+ if (!m) return undefined;
94
+ const value = Number(m[1]) * SCALE[m[2] ?? ""]!;
95
+ return value > 0 ? value : undefined;
96
+ }
97
+
98
+ /** Memory in bytes, rounded up as Kubernetes rounds it. */
99
+ export function memoryBytes(q: Quantity): number {
100
+ const v = baseUnits(q);
101
+ if (v === undefined) throw new Error(`${JSON.stringify(q)} is not a memory quantity (e.g. "2Gi", "512Mi")`);
102
+ return Math.ceil(v - 1e-9);
103
+ }
104
+
105
+ /** CPU in millicores, rounded up to 1m as Kubernetes rounds it. */
106
+ export function cpuMillis(q: Quantity): number {
107
+ const v = baseUnits(q);
108
+ if (v === undefined) throw new Error(`${JSON.stringify(q)} is not a cpu quantity (e.g. "2", "1500m")`);
109
+ return Math.ceil(v * 1000 - 1e-9);
110
+ }
111
+
112
+ /** Bytes as the quantity a human would write: `Gi`, `Mi` or `Ki` when exact, else plain bytes. */
113
+ export function formatMemory(bytes: number): string {
114
+ for (const [unit, scale] of [
115
+ ["Gi", 2 ** 30],
116
+ ["Mi", 2 ** 20],
117
+ ["Ki", 2 ** 10],
118
+ ] as const) {
119
+ if (bytes % scale === 0) return `${bytes / scale}${unit}`;
120
+ }
121
+ return String(bytes);
122
+ }
123
+
124
+ /** Millicores as the quantity a human would write: whole cpus when exact, else `m`. */
125
+ export function formatCpu(millis: number): string {
126
+ return millis % 1000 === 0 ? String(millis / 1000) : `${millis}m`;
127
+ }
128
+
129
+ // --- the refusal where a Size is read ----------------------------------------------------------
130
+
131
+ /**
132
+ * Refuse anything but a Size, BY FIELD (ADR-0060): `requests`, any key beside `limits`, any limit
133
+ * beside `memory` and `cpu`, or a value Kubernetes would not read. `where` names the reader and the
134
+ * field's own path (`workspace(): resources`, `jr2.config.ts at sandbox.resources`), so the message
135
+ * points at the text to edit. An empty `limits` is legal and states nothing: every field resolves
136
+ * down the chain.
137
+ */
138
+ export function assertSize(where: string, value: unknown): asserts value is Size {
139
+ const hint = "a Size is { limits: { memory?, cpu? } } and nothing else — Agent Substrate's template size (ADR-0060)";
140
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
141
+ throw new Error(`${where}: expected an object (got ${JSON.stringify(value)}) — ${hint}`);
142
+ }
143
+ for (const key of Object.keys(value)) {
144
+ if (key === "limits") continue;
145
+ const why =
146
+ key === "requests"
147
+ ? "is not accepted — jr2 reserves the whole Size, so every request equals its limit"
148
+ : "is not accepted";
149
+ throw new Error(`${where}.${key} ${why} — ${hint}`);
150
+ }
151
+ const limits = (value as { limits?: unknown }).limits;
152
+ if (typeof limits !== "object" || limits === null || Array.isArray(limits)) {
153
+ throw new Error(`${where}.limits: expected an object (got ${JSON.stringify(limits)}) — ${hint}`);
154
+ }
155
+ for (const [key, q] of Object.entries(limits)) {
156
+ if (key !== "memory" && key !== "cpu") {
157
+ throw new Error(`${where}.limits.${key} is not accepted — ${hint}`);
158
+ }
159
+ if (q === undefined) continue;
160
+ if (baseUnits(q) === undefined) {
161
+ const eg = key === "memory" ? '"2Gi", "512Mi"' : '"2", "1500m"';
162
+ throw new Error(`${where}.limits.${key}: ${JSON.stringify(q)} is not a positive quantity (e.g. ${eg})`);
163
+ }
164
+ }
165
+ }
166
+
167
+ /** Layer one Size over another, field by field — `customize(machine, { resources })` over the
168
+ * `workspace()`'s own, as an Agent override layers over its stock definition. */
169
+ export function layerSize(base: Size | undefined, over: Size | undefined): Size | undefined {
170
+ if (over === undefined) return base;
171
+ if (base === undefined) return over;
172
+ return { limits: { ...base.limits, ...over.limits } };
173
+ }
174
+
175
+ // --- resolution --------------------------------------------------------------------------------
176
+
177
+ /** Where a resolved number came from — what a refusal names, because the fix is an edit THERE. */
178
+ export type SizeSource = "machine" | "instance" | "kit";
179
+
180
+ /** A Size resolved to numbers, each with its source. */
181
+ export type ResolvedSize = {
182
+ memory: { bytes: number; from: SizeSource };
183
+ cpu: { millis: number; from: SizeSource };
184
+ };
185
+
186
+ /**
187
+ * The chain (ADR-0060): the Machine's Size (its `workspace()`, with any `customize()` already
188
+ * layered over it), else the Instance's `sandbox.resources`, then the kit for a field neither
189
+ * states. The Instance default applies only to a Workspace that states NO Size — never field by
190
+ * field under a stated one — so a Machine's Size resolves the same on every Instance, and each
191
+ * fact has one override path, `customize()`.
192
+ */
193
+ export function resolveSize(machine: Size | undefined, instance: Size | undefined): ResolvedSize {
194
+ const stated = (size: Size | undefined) =>
195
+ size !== undefined && (size.limits.memory !== undefined || size.limits.cpu !== undefined);
196
+ const [chosen, source]: [Size | undefined, SizeSource] = stated(machine)
197
+ ? [machine, "machine"]
198
+ : [instance, "instance"];
199
+ const pick = (field: "memory" | "cpu"): { q: Quantity; from: SizeSource } => {
200
+ const q = chosen?.limits[field];
201
+ return q !== undefined ? { q, from: source } : { q: KIT_SIZE[field], from: "kit" };
202
+ };
203
+ const memory = pick("memory");
204
+ const cpu = pick("cpu");
205
+ return {
206
+ memory: { bytes: memoryBytes(memory.q), from: memory.from },
207
+ cpu: { millis: cpuMillis(cpu.q), from: cpu.from },
208
+ };
209
+ }
210
+
211
+ // --- the split inside the pod ------------------------------------------------------------------
212
+
213
+ /**
214
+ * One Sandbox pod's resources, as the CR carries them (ADR-0060). Every request equals its limit —
215
+ * the pod is Guaranteed, and the CPU limit is enforced — except the Harness container's
216
+ * `ephemeral-storage`, a request with no limit, and the preflight's small fixed step.
217
+ */
218
+ export type SizeSplit = {
219
+ /** The whole Size, pod-level (KEP-2837): the ceiling the pod never passes. */
220
+ pod: ResourceRequirements;
221
+ /** The rest of the Size, after the Custodian and the User Container. */
222
+ harness: ResourceRequirements;
223
+ custodian: ResourceRequirements;
224
+ /** Only when the Machine states a split for the User Container; otherwise it has no limit of its
225
+ * own and shares the pod's budget. */
226
+ user?: ResourceRequirements;
227
+ preflight: ResourceRequirements;
228
+ /** The `/dev/shm` emptyDir's `sizeLimit`: 25% of the Harness share, minimum 64Mi. */
229
+ shmSizeLimit: string;
230
+ /** The Harness container's memory limit in bytes — what layer 4 of ADR-0061 guards. */
231
+ harnessMemoryBytes: number;
232
+ };
233
+
234
+ const SOURCE_TEXT: Record<SizeSource, (field: "memory" | "cpu") => string> = {
235
+ machine: (f) => `the Machine's Size — its workspace()'s resources.limits.${f}, or a customize() of it`,
236
+ instance: (f) =>
237
+ `sandbox.resources.limits.${f} in jr2.config.ts, the Instance's default for a Workspace that states none`,
238
+ kit: () => "the kit default — the Workspace states no Size",
239
+ };
240
+
241
+ /**
242
+ * Split a resolved Size inside the pod (ADR-0060), or refuse. `user` is the User Container's split
243
+ * as the Machine stated it. The refusal names each number and where it came from, because the fix
244
+ * is one of them: a larger Size, or a smaller split.
245
+ */
246
+ export function splitSize(size: ResolvedSize, user?: Size): SizeSplit {
247
+ const custodian = { memory: memoryBytes(CUSTODIAN_SLICE.memory), cpu: cpuMillis(CUSTODIAN_SLICE.cpu) };
248
+ const userMemory = user?.limits.memory !== undefined ? memoryBytes(user.limits.memory) : undefined;
249
+ const userCpu = user?.limits.cpu !== undefined ? cpuMillis(user.limits.cpu) : undefined;
250
+ const harnessMemory = size.memory.bytes - custodian.memory - (userMemory ?? 0);
251
+ const harnessCpu = size.cpu.millis - custodian.cpu - (userCpu ?? 0);
252
+
253
+ const floor = memoryBytes(HARNESS_FLOOR);
254
+ if (harnessMemory < floor) {
255
+ throw new Error(
256
+ `the Size leaves the Harness container ${harnessMemory > 0 ? formatMemory(harnessMemory) : "no"} memory, below its ` +
257
+ `${HARNESS_FLOOR} floor: memory ${formatMemory(size.memory.bytes)} (${SOURCE_TEXT[size.memory.from]("memory")}) ` +
258
+ `− the Custodian's ${CUSTODIAN_SLICE.memory}` +
259
+ (userMemory !== undefined
260
+ ? ` − the User Container's ${formatMemory(userMemory)} (its user.resources.limits.memory)`
261
+ : "") +
262
+ " — raise the Size's limits.memory, or shrink the User Container's split (ADR-0060)",
263
+ );
264
+ }
265
+ if (harnessCpu <= 0) {
266
+ throw new Error(
267
+ `the Size leaves the Harness container no cpu: cpu ${formatCpu(size.cpu.millis)} ` +
268
+ `(${SOURCE_TEXT[size.cpu.from]("cpu")}) − the Custodian's ${CUSTODIAN_SLICE.cpu}` +
269
+ (userCpu !== undefined ? ` − the User Container's ${formatCpu(userCpu)} (its user.resources.limits.cpu)` : "") +
270
+ " — raise the Size's limits.cpu, or shrink the User Container's split (ADR-0060)",
271
+ );
272
+ }
273
+
274
+ const exact = (memory: number, cpu: number): ResourceRequirements => {
275
+ const limits = { cpu: formatCpu(cpu), memory: formatMemory(memory) };
276
+ return { requests: { ...limits }, limits };
277
+ };
278
+ const harness = exact(harnessMemory, harnessCpu);
279
+ harness.requests!["ephemeral-storage"] = WORK_DISK_REQUEST;
280
+
281
+ let userResources: ResourceRequirements | undefined;
282
+ if (userMemory !== undefined || userCpu !== undefined) {
283
+ const limits: Record<string, string> = {
284
+ ...(userCpu !== undefined ? { cpu: formatCpu(userCpu) } : {}),
285
+ ...(userMemory !== undefined ? { memory: formatMemory(userMemory) } : {}),
286
+ };
287
+ userResources = { requests: { ...limits }, limits };
288
+ }
289
+
290
+ return {
291
+ pod: exact(size.memory.bytes, size.cpu.millis),
292
+ harness,
293
+ custodian: exact(custodian.memory, custodian.cpu),
294
+ ...(userResources ? { user: userResources } : {}),
295
+ preflight: {
296
+ requests: { ...PREFLIGHT_RESOURCES.requests },
297
+ limits: { ...PREFLIGHT_RESOURCES.limits },
298
+ },
299
+ shmSizeLimit: formatMemory(Math.max(memoryBytes(SHM_FLOOR), Math.floor(harnessMemory / 4))),
300
+ harnessMemoryBytes: harnessMemory,
301
+ };
302
+ }
303
+
304
+ // --- the User Container seat -------------------------------------------------------------------
305
+
306
+ /**
307
+ * The User Container as a Machine states it (ADR-0005, ADR-0060): an image in ADR-0037's two
308
+ * shapes, and optionally its split of the Size. The object form exists for the split alone.
309
+ */
310
+ export type UserContainer = { image: string; resources?: Size };
311
+
312
+ /** The `user` option in either shape, read as its two halves. */
313
+ export function userSeatOf(user: string | UserContainer | undefined): { image?: string; resources?: Size } {
314
+ if (user === undefined) return {};
315
+ if (typeof user === "string") return { image: user };
316
+ return { image: user.image, ...(user.resources !== undefined ? { resources: user.resources } : {}) };
317
+ }
318
+
319
+ /**
320
+ * Refuse a malformed `user` where it is written — a non-empty image string, or `{ image,
321
+ * resources? }` whose split is a Size. `where` names the caller (`workspace()`, `customize()`).
322
+ */
323
+ export function assertUserSeat(where: string, value: unknown): asserts value is string | UserContainer {
324
+ const image = typeof value === "object" && value !== null ? (value as { image?: unknown }).image : value;
325
+ if (typeof image !== "string" || !image) {
326
+ throw new Error(
327
+ `${where}: \`user\` must be a non-empty string (an image), or { image, resources? } (got ${JSON.stringify(value)}) — ` +
328
+ 'either a `file:` URL to a docker context this module ships (`import.meta.resolve("./image")`) or a ' +
329
+ "registry ref (ADR-0037, ADR-0060).",
330
+ );
331
+ }
332
+ if (typeof value !== "object" || value === null) return;
333
+ for (const key of Object.keys(value)) {
334
+ if (key !== "image" && key !== "resources") {
335
+ throw new Error(
336
+ `${where}: user.${key} is not accepted — the User Container is { image, resources? } (ADR-0005, ADR-0060)`,
337
+ );
338
+ }
339
+ }
340
+ const resources = (value as { resources?: unknown }).resources;
341
+ if (resources !== undefined) assertSize(`${where} user.resources`, resources);
342
+ }
@@ -1,7 +1,10 @@
1
- // Persistence for durable Machine snapshots (ADR-0007). A `SnapshotStore` is keyed by `runId`;
2
- // `SqliteSnapshotStore` is the default backing for a deployed Orchestrator, with `:memory:` for
3
- // tests. `markLost` records a run whose live state could not be re-hydrated (e.g. its flue
4
- // handle is gone) without deleting its history.
1
+ // Persistence for durable Machine snapshots (ADR-0007). A `SnapshotStore` is keyed by `runId`, one
2
+ // blob per run: the snapshot is a tree xstate restores from its root, and one row keeps it atomic
3
+ // (ADR-0065). `SqliteSnapshotStore` on the Instance's volume is the one implementation — no other
4
+ // store is owed — with `:memory:` for tests. A persist survives a process crash, not a power loss
5
+ // (WAL, `synchronous=NORMAL`), and the write stays synchronous so the row is on disk before the
6
+ // Machine's next side effect runs. `markLost` records a run whose live state could not be
7
+ // re-hydrated (e.g. its flue handle is gone); a non-live row lives a week, then `sweep` deletes it.
5
8
 
6
9
  import { DatabaseSync } from "node:sqlite";
7
10
  import type { StoredSnapshot } from "./durability.ts";
@@ -9,8 +12,24 @@ import type { StoredSnapshot } from "./durability.ts";
9
12
  export interface SnapshotStore {
10
13
  init(): Promise<void>;
11
14
  load(runId: string): Promise<StoredSnapshot | null>;
12
- /** Every persisted run, in insertion order. The host filters by `status` on restore. */
13
- list(): Promise<StoredSnapshot[]>;
15
+ /** The `live` runs, in insertion order — what `restore()` reads (ADR-0065). Terminal rows are
16
+ * filtered by the store, not the host, so a boot never parses a week of finished runs. */
17
+ live(): Promise<StoredSnapshot[]>;
18
+ /**
19
+ * The `live` runs by the Assembly hash their blob names (ADR-0069), each list in insertion order:
20
+ * what holds an Assembly. A hold is the ROW, not the process: a run this process failed to
21
+ * restore is still live here, so it still holds its Assembly. Read without parsing a snapshot,
22
+ * because `GET /assembly` and every collection ask. A live row that names no Assembly is not
23
+ * listed.
24
+ */
25
+ liveAssemblies(): Promise<Map<string, string[]>>;
26
+ /**
27
+ * Delete every non-live row last written before `olderThan`, and answer how many (ADR-0065). A
28
+ * `live` row is never swept, whatever its age: a run parked on a Gate for a month is restored.
29
+ * A swept id is gone as if it never ran — `read()` answers undefined, and its abbreviations are
30
+ * free again (ADR-0009).
31
+ */
32
+ sweep(olderThan: Date): Promise<number>;
14
33
  /**
15
34
  * Persisted run ids sharing a prefix, sorted, capped at `limit` — the store half of abbreviated
16
35
  * run ids (ADR-0009). Includes `lost` rows: the ambiguity set is the ids that EXIST, not the ones
@@ -21,11 +40,13 @@ export interface SnapshotStore {
21
40
  save(runId: string, snapshot: unknown, status?: string): Promise<void>;
22
41
  markLost(runId: string, reason: string): Promise<void>;
23
42
  /**
24
- * Record a run whose Machine changed shape underneath it (ADR-0030) — refused, not resumed.
43
+ * Record a run this Orchestrator can no longer read (ADR-0030, ADR-0069) — refused, not resumed:
44
+ * the Assembly its snapshot names is not on the volume, was published for another Kit version or
45
+ * does not load, or its Machine's shape does not match the snapshot's stamp.
25
46
  *
26
47
  * Unlike {@link markLost} this KEEPS the snapshot. A lost run has nothing left to look at, so
27
- * nulling it costs nothing; a drifted run is intact and merely unreadable by the Machine now
28
- * loaded, and it is the one a human most needs to inspect. Nulling it would also hide the run
48
+ * nulling it costs nothing; a drifted run is intact and only unreadable by this Orchestrator,
49
+ * and it is the one a human most needs to inspect. Nulling it would also hide the run
29
50
  * entirely: `read()` returns undefined for a null blob, so `jr2 status <id>` would answer
30
51
  * `no run` — a refusal indistinguishable from a run that never existed.
31
52
  */
@@ -49,6 +70,10 @@ export class SqliteSnapshotStore implements SnapshotStore {
49
70
  }
50
71
 
51
72
  async init(): Promise<void> {
73
+ // A persist survives a process crash, not a power loss (ADR-0065): a write is durable once the
74
+ // OS holds it, and sqlite fsyncs only at a checkpoint. FULL cost 4.5 ms a persist, all fsync.
75
+ this.db.exec(`PRAGMA journal_mode = WAL`);
76
+ this.db.exec(`PRAGMA synchronous = NORMAL`);
52
77
  this.db.exec(`
53
78
  CREATE TABLE IF NOT EXISTS machine_snapshots (
54
79
  run_id TEXT PRIMARY KEY,
@@ -75,9 +100,12 @@ export class SqliteSnapshotStore implements SnapshotStore {
75
100
  return stored;
76
101
  }
77
102
 
78
- async list(): Promise<StoredSnapshot[]> {
103
+ async live(): Promise<StoredSnapshot[]> {
79
104
  const rows = this.db
80
- .prepare(`SELECT run_id, status, snapshot, reason, updated_at FROM machine_snapshots ORDER BY rowid`)
105
+ .prepare(
106
+ `SELECT run_id, status, snapshot, reason, updated_at FROM machine_snapshots
107
+ WHERE status = 'live' ORDER BY rowid`,
108
+ )
81
109
  .all() as Row[];
82
110
  return rows.map((row) => {
83
111
  const stored: StoredSnapshot = {
@@ -90,13 +118,27 @@ export class SqliteSnapshotStore implements SnapshotStore {
90
118
  });
91
119
  }
92
120
 
121
+ async liveAssemblies(): Promise<Map<string, string[]>> {
122
+ const rows = this.db
123
+ .prepare(
124
+ `SELECT run_id, json_extract(snapshot, '$.assembly') AS assembly FROM machine_snapshots
125
+ WHERE status = 'live' AND json_valid(snapshot) ORDER BY rowid`,
126
+ )
127
+ .all() as Array<{ run_id: string; assembly: unknown }>;
128
+ const held = new Map<string, string[]>();
129
+ for (const { run_id, assembly } of rows) {
130
+ if (typeof assembly !== "string") continue;
131
+ held.set(assembly, [...(held.get(assembly) ?? []), run_id]);
132
+ }
133
+ return held;
134
+ }
135
+
93
136
  /**
94
137
  * A range scan rather than `LIKE`/`GLOB`. `run_id` is the BINARY-collated PRIMARY KEY, but
95
138
  * SQLite's `LIKE` is ASCII-case-insensitive by default, so the planner declines the index range
96
139
  * and falls back to a full scan. `GLOB` would seek correctly but obliges every caller to escape
97
140
  * `*?[]` first — a store shouldn't have to trust that. The `CHAR(0x10FFFF)` sentinel as the upper
98
- * bound also avoids incrementing the prefix's last character, which has edge cases. `>=`/`<`
99
- * carries to Postgres unchanged, where `LIKE 'x%'` would need `text_pattern_ops` to use an index.
141
+ * bound also avoids incrementing the prefix's last character, which has edge cases.
100
142
  */
101
143
  async findIdsByPrefix(prefix: string, limit: number): Promise<string[]> {
102
144
  const rows = this.db
@@ -144,6 +186,15 @@ export class SqliteSnapshotStore implements SnapshotStore {
144
186
  .run(reason, new Date().toISOString(), runId);
145
187
  }
146
188
 
189
+ /** `updated_at` is an ISO-8601 UTC string, so a string comparison is a time comparison. A
190
+ * terminal row's `updated_at` is its terminal write — the age the week counts from. */
191
+ async sweep(olderThan: Date): Promise<number> {
192
+ const result = this.db
193
+ .prepare(`DELETE FROM machine_snapshots WHERE status != 'live' AND updated_at < ?`)
194
+ .run(olderThan.toISOString());
195
+ return Number(result.changes);
196
+ }
197
+
147
198
  async close(): Promise<void> {
148
199
  this.db.close();
149
200
  }
@@ -32,6 +32,7 @@
32
32
 
33
33
  import { createServer } from "node:http";
34
34
  import type { AddressInfo, Socket } from "node:net";
35
+ import type { Notice } from "./wire.ts";
35
36
 
36
37
  /** One admission of an Agent: which slot, which durable exchange, this Turn's FRAME (the prompt
37
38
  * and where it works — ADR-0057), the DEFINITION the Machine's slot carried here (ADR-0049), and
@@ -49,6 +50,8 @@ export type Admission = {
49
50
  definition?: Record<string, unknown>;
50
51
  model?: string;
51
52
  thinkingLevel?: string;
53
+ /** The notices the admission carried (ADR-0062). Absent when it carried none. */
54
+ notices?: Notice[];
52
55
  };
53
56
 
54
57
  /** One settled submission, in the shape `history()` reports it. The stub settles submissions for
@@ -133,6 +136,7 @@ export async function startStubHarness(opts: StubHarnessOptions = {}): Promise<R
133
136
  definition?: Record<string, unknown>;
134
137
  model?: string;
135
138
  thinkingLevel?: string;
139
+ notices?: Notice[];
136
140
  }
137
141
  | undefined;
138
142
  const admission: Admission = {
@@ -145,6 +149,7 @@ export async function startStubHarness(opts: StubHarnessOptions = {}): Promise<R
145
149
  ...(sent?.definition ? { definition: sent.definition } : {}),
146
150
  ...(sent?.model ? { model: sent.model } : {}),
147
151
  ...(sent?.thinkingLevel ? { thinkingLevel: sent.thinkingLevel } : {}),
152
+ ...(sent?.notices?.length ? { notices: sent.notices } : {}),
148
153
  };
149
154
  admissions.push(admission);
150
155
  const submissionId = `stub-${++submissionSeq}`;
@@ -0,0 +1,54 @@
1
+ // The sync's client (ADR-0067): after a push whose published commit is not the Agent's own, the
2
+ // Orchestrator asks the Workspace's Harness to move the Agent's branch to it (`POST /sync`), carrying the bundle the Sandbox
3
+ // Effector wrote. The bundle travels through here, never through a shared volume: the Worktree is
4
+ // read-only on the credentialed side, and nothing the Effector writes is a channel into the Agent's
5
+ // container but this one call, which moves one ref. The Harness's answer is a report the next push
6
+ // never trusts.
7
+
8
+ import type { SyncAnswer, SyncRequest } from "./wire.ts";
9
+
10
+ /** The Harness's sync, as a push drives it. It never throws: a Harness that cannot be reached, or
11
+ * that refuses, answers `not-synced` with the reason — the push's outcome stands either way. */
12
+ export interface SyncPort {
13
+ sync(req: SyncRequest, signal: AbortSignal): Promise<SyncAnswer>;
14
+ }
15
+ export type SyncPortFactory = (endpoint: string, bearer?: string) => SyncPort;
16
+
17
+ /** How long a sync may take before it answers `not-synced`: its git steps are bounded at 120 s each. */
18
+ const SYNC_TIMEOUT_MS = 5 * 60_000;
19
+
20
+ export function createSyncPort(options: {
21
+ baseUrl: string;
22
+ /** The Workspace's Harness bearer (ADR-0058). */
23
+ token?: string;
24
+ /** Injectable for socket-free tests. Default: global `fetch`. */
25
+ fetch?: typeof fetch;
26
+ }): SyncPort {
27
+ const fetchImpl = options.fetch ?? fetch;
28
+ return {
29
+ sync: async (req, signal) => {
30
+ const url = new URL("/sync", options.baseUrl).toString();
31
+ try {
32
+ const res = await fetchImpl(url, {
33
+ method: "POST",
34
+ headers: {
35
+ "content-type": "application/json",
36
+ ...(options.token ? { authorization: `Bearer ${options.token}` } : {}),
37
+ },
38
+ body: JSON.stringify(req),
39
+ // Bounded: a Harness that never answers is a sync that did not finish, never a push held open.
40
+ signal: AbortSignal.any([signal, AbortSignal.timeout(SYNC_TIMEOUT_MS)]),
41
+ });
42
+ const text = await res.text();
43
+ if (!res.ok) return { sync: "not-synced", reason: `the Harness refused the sync (${res.status}): ${text}` };
44
+ return JSON.parse(text) as SyncAnswer;
45
+ } catch (err) {
46
+ return { sync: "not-synced", reason: `the Harness could not be reached: ${(err as Error).message}` };
47
+ }
48
+ },
49
+ };
50
+ }
51
+
52
+ /** The real factory: the Workspace's Harness, with its bearer. */
53
+ export const harnessSyncPort: SyncPortFactory = (endpoint, bearer) =>
54
+ createSyncPort({ baseUrl: endpoint, ...(bearer ? { token: bearer } : {}) });