@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.
- package/bin/server.ts +7 -4
- package/console/canvas.ts +27 -14
- package/console/components/app.ts +10 -1
- package/console/components/fleet.ts +39 -6
- package/console/components/machine-pane.ts +89 -2
- package/console/components/nav.ts +1 -1
- package/console/components/schema-form.ts +12 -4
- package/console/json-field.ts +23 -0
- package/console/main.ts +110 -39
- package/console/store.ts +221 -17
- package/console/style.css +90 -0
- package/package.json +4 -4
- package/src/actor.ts +502 -49
- package/src/ambient.ts +2 -0
- package/src/assembly-checks.ts +165 -0
- package/src/assembly-tar.ts +494 -0
- package/src/assembly.ts +721 -0
- package/src/chart.ts +2050 -0
- package/src/charts.ts +720 -0
- package/src/config.ts +152 -46
- package/src/content-hash.ts +105 -0
- package/src/custodian.lua +6 -5
- package/src/custodian.ts +71 -19
- package/src/customize.ts +216 -25
- package/src/effect-checks.ts +557 -0
- package/src/effect.ts +905 -0
- package/src/effector-wire.ts +525 -0
- package/src/fingerprint.ts +4 -3
- package/src/gate.ts +8 -0
- package/src/git-clone.ts +65 -0
- package/src/git-push.ts +336 -0
- package/src/harness-client.ts +245 -23
- package/src/held-secrets.ts +82 -26
- package/src/holds.ts +156 -0
- package/src/http-effect.ts +981 -0
- package/src/http.ts +496 -104
- package/src/images.ts +47 -21
- package/src/index.ts +43 -2
- package/src/instance.ts +444 -58
- package/src/kube-client.ts +333 -0
- package/src/machine-doc.ts +88 -6
- package/src/mount-digest.ts +53 -0
- package/src/names.ts +66 -8
- package/src/notices.ts +88 -0
- package/src/open-body.ts +1444 -0
- package/src/open-effect.ts +144 -0
- package/src/open.ts +4 -4
- package/src/parts.ts +530 -33
- package/src/party.ts +468 -0
- package/src/pick.ts +309 -0
- package/src/pool.ts +1 -1
- package/src/registration.ts +301 -17
- package/src/repo-fetch.ts +60 -85
- package/src/repos.ts +63 -74
- package/src/rest.ts +133 -0
- package/src/run-host.ts +1832 -98
- package/src/sandbox-effect.ts +891 -0
- package/src/sandbox-effector.ts +111 -0
- package/src/sandbox-kube.ts +1719 -0
- package/src/sandbox-watch.ts +260 -0
- package/src/secret-placement.ts +494 -0
- package/src/server.ts +281 -84
- package/src/setup.ts +589 -70
- package/src/size.ts +342 -0
- package/src/snapshot-store.ts +64 -13
- package/src/stub-harness.ts +5 -0
- package/src/sync-client.ts +54 -0
- package/src/timers.ts +178 -0
- package/src/tokens.ts +35 -4
- package/src/vocabulary.ts +66 -1
- package/src/wire.ts +156 -2
- package/src/workspace.ts +529 -194
- 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
|
+
}
|
package/src/snapshot-store.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
// Persistence for durable Machine snapshots (ADR-0007). A `SnapshotStore` is keyed by `runId
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
-
/**
|
|
13
|
-
|
|
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
|
|
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
|
|
28
|
-
*
|
|
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
|
|
103
|
+
async live(): Promise<StoredSnapshot[]> {
|
|
79
104
|
const rows = this.db
|
|
80
|
-
.prepare(
|
|
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
|
}
|
package/src/stub-harness.ts
CHANGED
|
@@ -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 } : {}) });
|