@fungi.computer/shed 0.0.0-stage → 0.0.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/LICENSE +21 -0
- package/README.md +122 -2
- package/backend/gills.ts +74 -0
- package/backend/index.ts +15 -0
- package/backend/methods.ts +278 -0
- package/backend/process-page.ts +44 -0
- package/backend/runs.ts +580 -0
- package/backend/test/cloudflare-workers.fixture.ts +1 -0
- package/backend/test/methods.test.ts +662 -0
- package/backend/test/runs.node.test.ts +1770 -0
- package/backend/tsconfig.json +10 -0
- package/backend/vitest.config.ts +16 -0
- package/dist/assets/Anton-5.3.0-latin-Byf51wtH.woff2 +0 -0
- package/dist/assets/BricolageGrotesque-VF-latin-Cre6nC2_.woff2 +0 -0
- package/dist/assets/DepartureMono-Regular-2BZob_Zz.woff2 +0 -0
- package/dist/assets/Fraunces-Italic-VF-latin-D2JnYqd-.woff2 +0 -0
- package/dist/assets/Fraunces-VF-latin-CFFu7zhK.woff2 +0 -0
- package/dist/assets/GrenzeGotisch-VF-latin-BhUVw2FV.woff2 +0 -0
- package/dist/assets/InstrumentSans-Italic-VF-latin-BlcgWI5J.woff2 +0 -0
- package/dist/assets/InstrumentSans-VF-latin-BbzFLZTg.woff2 +0 -0
- package/dist/assets/MapleMono-VF-CCDrizxw.woff2 +0 -0
- package/dist/assets/NothingYouCouldDo-Regular-B7VfwEBS.woff2 +0 -0
- package/dist/assets/Nunito-VF-8c6a9bb-cyrillic-BsWUdSU9.woff2 +0 -0
- package/dist/assets/Nunito-VF-8c6a9bb-cyrillic-ext-Cb1j0nGJ.woff2 +0 -0
- package/dist/assets/Nunito-VF-8c6a9bb-latin-BYs3GYNf.woff2 +0 -0
- package/dist/assets/Nunito-VF-8c6a9bb-latin-ext-0RvtKGDy.woff2 +0 -0
- package/dist/assets/Nunito-VF-8c6a9bb-vietnamese-cS2dauUA.woff2 +0 -0
- package/dist/assets/__vite-browser-external-2447137e-BvRk9kiK.js +0 -0
- package/dist/assets/ghostty-vt-DOMeXDrv.wasm +0 -0
- package/dist/assets/index-C5jExGkL.js +1764 -0
- package/dist/assets/index-Dm2F0ptq.css +1 -0
- package/dist/index.html +15 -0
- package/package.json +158 -3
|
@@ -0,0 +1,1770 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { readFile, writeFile, rm, mkdtemp } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
import type { HostActor } from "@fungi.computer/puffball/app-method-runtime";
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import * as z from "zod";
|
|
9
|
+
import { it, type TestContext } from "node:test";
|
|
10
|
+
import { DatabaseSync } from "node:sqlite";
|
|
11
|
+
import {
|
|
12
|
+
mintComputerProcessId,
|
|
13
|
+
type ComputerProcess,
|
|
14
|
+
} from "@fungi.computer/computer/operations";
|
|
15
|
+
import { appBackend } from "../methods.js";
|
|
16
|
+
import {
|
|
17
|
+
createShedRuns,
|
|
18
|
+
type ShedRunInput,
|
|
19
|
+
type ShedStartInput,
|
|
20
|
+
} from "../runs.js";
|
|
21
|
+
import {
|
|
22
|
+
buildAppApi,
|
|
23
|
+
buildAppDeclaration,
|
|
24
|
+
} from "@fungi.computer/agar/backend-declaration";
|
|
25
|
+
import { appBackend as shedBackend } from "../methods.js";
|
|
26
|
+
import shedMetadata from "../../package.json" with { type: "json" };
|
|
27
|
+
const appApi = buildAppApi(shedBackend);
|
|
28
|
+
const appDeclaration = buildAppDeclaration(shedBackend, shedMetadata.fungi.app);
|
|
29
|
+
/** A `run` App method reply that completed with a definite exit code. */
|
|
30
|
+
const completedRun = z.object({ exitCode: z.number(), logPath: z.string() });
|
|
31
|
+
|
|
32
|
+
void it("reuses one process across repeated and restored calls, then returns its exit and log", async (t) => {
|
|
33
|
+
const database = new DatabaseSync(":memory:");
|
|
34
|
+
t.after(() => {
|
|
35
|
+
database.close();
|
|
36
|
+
});
|
|
37
|
+
const sql = {
|
|
38
|
+
exec(query: string, ...values: (string | number | null)[]) {
|
|
39
|
+
const rows = database.prepare(query).all(...values);
|
|
40
|
+
return { toArray: () => rows };
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
let starts = 0;
|
|
44
|
+
let process: ComputerProcess = {
|
|
45
|
+
id: mintComputerProcessId(),
|
|
46
|
+
port: null,
|
|
47
|
+
command: "sleep 120",
|
|
48
|
+
cwd: "/workspace",
|
|
49
|
+
state: "running",
|
|
50
|
+
exitCode: null,
|
|
51
|
+
sync: "synced",
|
|
52
|
+
startedAt: 1,
|
|
53
|
+
endedAt: null,
|
|
54
|
+
};
|
|
55
|
+
const computer = {
|
|
56
|
+
processStart: (input: { command: string; cwd?: string }) => {
|
|
57
|
+
starts++;
|
|
58
|
+
assert.match(input.command, /tee/);
|
|
59
|
+
assert.match(input.command, /out\.log/);
|
|
60
|
+
assert.equal(input.cwd, "/workspace");
|
|
61
|
+
return Promise.resolve(process);
|
|
62
|
+
},
|
|
63
|
+
processes: () => Promise.resolve({ processes: [process] }),
|
|
64
|
+
};
|
|
65
|
+
const caller = {
|
|
66
|
+
actor: { kind: "member", memberId: "member-one" },
|
|
67
|
+
computerId: "computer-one",
|
|
68
|
+
idempotencyKey: "run-one:node-one:0",
|
|
69
|
+
} satisfies Parameters<typeof appBackend.call>[3];
|
|
70
|
+
const input = { command: "sleep 120", cwd: "/workspace" };
|
|
71
|
+
const context = { runs: createShedRuns(sql), env: { COMPUTER: computer } };
|
|
72
|
+
assert.deepEqual(await appBackend.call("run", input, context, caller), {
|
|
73
|
+
kind: "pending",
|
|
74
|
+
retryAfterMs: 5000,
|
|
75
|
+
});
|
|
76
|
+
assert.deepEqual(await appBackend.call("run", input, context, caller), {
|
|
77
|
+
kind: "pending",
|
|
78
|
+
retryAfterMs: 5000,
|
|
79
|
+
});
|
|
80
|
+
const restored = { runs: createShedRuns(sql), env: { COMPUTER: computer } };
|
|
81
|
+
assert.deepEqual(await appBackend.call("run", input, restored, caller), {
|
|
82
|
+
kind: "pending",
|
|
83
|
+
retryAfterMs: 5000,
|
|
84
|
+
});
|
|
85
|
+
process = { ...process, state: "exited", exitCode: 0, endedAt: 2 };
|
|
86
|
+
const result = await appBackend.call("run", input, restored, caller);
|
|
87
|
+
assert.deepEqual(result, {
|
|
88
|
+
exitCode: 0,
|
|
89
|
+
logPath:
|
|
90
|
+
"/workspace/.fungi/runs/call-65a2ca4d35e831fc10077ccde277c7b1e87568b118b8a81bb8f9f9a13c9a15db/out.log",
|
|
91
|
+
});
|
|
92
|
+
assert.equal(starts, 1);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
void it("tails only the recorded run log on its pinned Computer within the byte bound", async (t) => {
|
|
96
|
+
const database = new DatabaseSync(":memory:");
|
|
97
|
+
t.after(() => {
|
|
98
|
+
database.close();
|
|
99
|
+
});
|
|
100
|
+
const runs = createShedRuns({
|
|
101
|
+
exec(query, ...values) {
|
|
102
|
+
const rows = database.prepare(query).all(...values);
|
|
103
|
+
return { toArray: () => rows };
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
const process: ComputerProcess = {
|
|
107
|
+
id: mintComputerProcessId(),
|
|
108
|
+
port: null,
|
|
109
|
+
command: "printf hello",
|
|
110
|
+
cwd: null,
|
|
111
|
+
state: "running",
|
|
112
|
+
exitCode: null,
|
|
113
|
+
sync: "synced",
|
|
114
|
+
startedAt: 1,
|
|
115
|
+
endedAt: null,
|
|
116
|
+
};
|
|
117
|
+
const reads: unknown[] = [];
|
|
118
|
+
const context = {
|
|
119
|
+
runs,
|
|
120
|
+
env: {
|
|
121
|
+
COMPUTER: {
|
|
122
|
+
processStart: () => Promise.resolve(process),
|
|
123
|
+
processes: () => Promise.resolve({ processes: [process] }),
|
|
124
|
+
read: (input: {
|
|
125
|
+
path: string;
|
|
126
|
+
byteOffset: number;
|
|
127
|
+
byteLength: number;
|
|
128
|
+
}) => {
|
|
129
|
+
reads.push(input);
|
|
130
|
+
return Promise.resolve({
|
|
131
|
+
path: input.path,
|
|
132
|
+
byteOffset: input.byteOffset,
|
|
133
|
+
content: "aGVsbG8=",
|
|
134
|
+
encoding: "base64",
|
|
135
|
+
truncated: true,
|
|
136
|
+
});
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
const caller = {
|
|
142
|
+
actor: { kind: "member", memberId: "member-one" },
|
|
143
|
+
computerId: "computer-one",
|
|
144
|
+
idempotencyKey: "log-run",
|
|
145
|
+
} satisfies Parameters<typeof appBackend.call>[3];
|
|
146
|
+
await appBackend.call("run", { command: "printf hello" }, context, caller);
|
|
147
|
+
const result = await appBackend.call(
|
|
148
|
+
"logs",
|
|
149
|
+
{ run: "log-run", offset: 3, length: 8 },
|
|
150
|
+
context,
|
|
151
|
+
caller,
|
|
152
|
+
);
|
|
153
|
+
assert.deepEqual(result, {
|
|
154
|
+
run: "log-run",
|
|
155
|
+
path: "/workspace/.fungi/runs/call-d60b717068a52378cd93c7967b4dea8b3ca36f28f75c60b920023b34fbbc1bd7/out.log",
|
|
156
|
+
byteOffset: 3,
|
|
157
|
+
content: "aGVsbG8=",
|
|
158
|
+
encoding: "base64",
|
|
159
|
+
truncated: true,
|
|
160
|
+
});
|
|
161
|
+
assert.deepEqual(reads, [
|
|
162
|
+
{
|
|
163
|
+
path: "/workspace/.fungi/runs/call-d60b717068a52378cd93c7967b4dea8b3ca36f28f75c60b920023b34fbbc1bd7/out.log",
|
|
164
|
+
byteOffset: 3,
|
|
165
|
+
byteLength: 8,
|
|
166
|
+
},
|
|
167
|
+
]);
|
|
168
|
+
await assert.rejects(
|
|
169
|
+
appBackend.call(
|
|
170
|
+
"logs",
|
|
171
|
+
{ run: "log-run", offset: 0, length: 131073 },
|
|
172
|
+
context,
|
|
173
|
+
caller,
|
|
174
|
+
),
|
|
175
|
+
);
|
|
176
|
+
assert.deepEqual(
|
|
177
|
+
await appBackend.call(
|
|
178
|
+
"logs",
|
|
179
|
+
{ run: "log-run", offset: 0, length: 8 },
|
|
180
|
+
context,
|
|
181
|
+
{
|
|
182
|
+
...caller,
|
|
183
|
+
computerId: "computer-other",
|
|
184
|
+
},
|
|
185
|
+
),
|
|
186
|
+
{ error: "not_found" },
|
|
187
|
+
);
|
|
188
|
+
assert.deepEqual(
|
|
189
|
+
await appBackend.call(
|
|
190
|
+
"logs",
|
|
191
|
+
{ run: "../../secret", offset: 0, length: 8 },
|
|
192
|
+
context,
|
|
193
|
+
caller,
|
|
194
|
+
),
|
|
195
|
+
{ error: "not_found" },
|
|
196
|
+
);
|
|
197
|
+
assert.equal(reads.length, 1);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
void it("returns only an admitted preview and refuses an unexposed port", async (t) => {
|
|
201
|
+
const database = new DatabaseSync(":memory:");
|
|
202
|
+
t.after(() => {
|
|
203
|
+
database.close();
|
|
204
|
+
});
|
|
205
|
+
const requests: number[] = [];
|
|
206
|
+
const context = {
|
|
207
|
+
runs: createShedRuns({
|
|
208
|
+
exec(query, ...values) {
|
|
209
|
+
const rows = database.prepare(query).all(...values);
|
|
210
|
+
return { toArray: () => rows };
|
|
211
|
+
},
|
|
212
|
+
}),
|
|
213
|
+
env: {
|
|
214
|
+
COMPUTER: {
|
|
215
|
+
processStart: () =>
|
|
216
|
+
Promise.reject(new Error("unexpected process start")),
|
|
217
|
+
processes: () => Promise.reject(new Error("unexpected process read")),
|
|
218
|
+
preview: (input: { port: number }): Promise<z.core.util.JSONType> => {
|
|
219
|
+
requests.push(input.port);
|
|
220
|
+
if (input.port === 3000)
|
|
221
|
+
return Promise.resolve({
|
|
222
|
+
port: input.port,
|
|
223
|
+
previewUrl: "https://fixture.preview.example/",
|
|
224
|
+
expiresAt: 5000,
|
|
225
|
+
openMode: "external-only",
|
|
226
|
+
});
|
|
227
|
+
return Promise.resolve({ error: "not_granted" });
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
const caller = {
|
|
233
|
+
actor: { kind: "member", memberId: "member-one" },
|
|
234
|
+
computerId: "computer-one",
|
|
235
|
+
} satisfies Parameters<typeof appBackend.call>[3];
|
|
236
|
+
assert.deepEqual(
|
|
237
|
+
await appBackend.call("expose", { port: 3000 }, context, caller),
|
|
238
|
+
{ previewUrl: "https://fixture.preview.example/", expiresAt: 5000 },
|
|
239
|
+
);
|
|
240
|
+
await assert.rejects(
|
|
241
|
+
appBackend.call("expose", { port: 3001 }, context, caller),
|
|
242
|
+
);
|
|
243
|
+
await assert.rejects(appBackend.call("expose", { port: 0 }, context, caller));
|
|
244
|
+
await assert.rejects(
|
|
245
|
+
appBackend.call("expose", { port: 3000 }, context, {
|
|
246
|
+
...caller,
|
|
247
|
+
computerId: null,
|
|
248
|
+
}),
|
|
249
|
+
);
|
|
250
|
+
assert.deepEqual(requests, [3000, 3001]);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
void it("tees both real shell streams, returns a nonzero exit as a normal result, and never repeats a concurrent or restored call", async (t) => {
|
|
254
|
+
const database = new DatabaseSync(":memory:");
|
|
255
|
+
t.after(() => {
|
|
256
|
+
database.close();
|
|
257
|
+
});
|
|
258
|
+
const sql = {
|
|
259
|
+
exec(query: string, ...values: (string | number | null)[]) {
|
|
260
|
+
const rows = database.prepare(query).all(...values);
|
|
261
|
+
return { toArray: () => rows };
|
|
262
|
+
},
|
|
263
|
+
};
|
|
264
|
+
const key = `shell-test-${randomUUID()}`;
|
|
265
|
+
const root = await mkdtemp(join(tmpdir(), "shed-shell-"));
|
|
266
|
+
t.after(() => rm(root, { recursive: true, force: true }));
|
|
267
|
+
let starts = 0;
|
|
268
|
+
const computer = {
|
|
269
|
+
processStart: (input: { command: string; cwd?: string }) =>
|
|
270
|
+
new Promise<ComputerProcess>((resolve, reject) => {
|
|
271
|
+
starts++;
|
|
272
|
+
// Map the fixture Computer workspace onto an isolated local directory.
|
|
273
|
+
const process = spawn(
|
|
274
|
+
"bash",
|
|
275
|
+
["-c", input.command.replaceAll("/workspace", root)],
|
|
276
|
+
{
|
|
277
|
+
cwd: input.cwd,
|
|
278
|
+
stdio: "ignore",
|
|
279
|
+
},
|
|
280
|
+
);
|
|
281
|
+
process.once("error", reject);
|
|
282
|
+
process.once("close", (exitCode) => {
|
|
283
|
+
resolve({
|
|
284
|
+
id: mintComputerProcessId(),
|
|
285
|
+
port: null,
|
|
286
|
+
command: input.command,
|
|
287
|
+
cwd: input.cwd ?? null,
|
|
288
|
+
state: "exited",
|
|
289
|
+
exitCode,
|
|
290
|
+
sync: "unknown",
|
|
291
|
+
startedAt: 1,
|
|
292
|
+
endedAt: 2,
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
}),
|
|
296
|
+
processes: () =>
|
|
297
|
+
Promise.reject(new Error("completed exit must be remembered")),
|
|
298
|
+
};
|
|
299
|
+
const caller = {
|
|
300
|
+
actor: { kind: "member", memberId: "member-one" },
|
|
301
|
+
computerId: "computer-one",
|
|
302
|
+
idempotencyKey: key,
|
|
303
|
+
} satisfies Parameters<typeof appBackend.call>[3];
|
|
304
|
+
const context = { runs: createShedRuns(sql), env: { COMPUTER: computer } };
|
|
305
|
+
const input = {
|
|
306
|
+
command: `printf '%s\\n' "out with quote: '"; printf 'err\\n' >&2; exit 7`,
|
|
307
|
+
cwd: root,
|
|
308
|
+
};
|
|
309
|
+
const first = appBackend.call("run", input, context, caller);
|
|
310
|
+
const completed = first.then((reply) => {
|
|
311
|
+
const result = completedRun.parse(reply);
|
|
312
|
+
assert.equal(result.exitCode, 7);
|
|
313
|
+
assert.match(result.logPath, /out\.log$/);
|
|
314
|
+
});
|
|
315
|
+
assert.deepEqual(await appBackend.call("run", input, context, caller), {
|
|
316
|
+
kind: "pending",
|
|
317
|
+
retryAfterMs: 5000,
|
|
318
|
+
});
|
|
319
|
+
await completed;
|
|
320
|
+
const log = context.runs.logPath(key, caller.computerId, caller.actor);
|
|
321
|
+
assert.ok("path" in log);
|
|
322
|
+
assert.match(
|
|
323
|
+
await readFile(log.path.replaceAll("/workspace", root), "utf8"),
|
|
324
|
+
/out with quote:.*\nerr\n/,
|
|
325
|
+
);
|
|
326
|
+
const restored = { runs: createShedRuns(sql), env: { COMPUTER: computer } };
|
|
327
|
+
const resumed = completedRun.parse(
|
|
328
|
+
await appBackend.call("run", input, restored, caller),
|
|
329
|
+
);
|
|
330
|
+
assert.equal(resumed.exitCode, 7);
|
|
331
|
+
assert.equal(starts, 1);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
void it("rejects a command whose quoted wrapper exceeds the native bound before claiming a run", async (t) => {
|
|
335
|
+
const { processStartInputSchema } =
|
|
336
|
+
await import("@fungi.computer/computer/operations");
|
|
337
|
+
const database = new DatabaseSync(":memory:");
|
|
338
|
+
t.after(() => {
|
|
339
|
+
database.close();
|
|
340
|
+
});
|
|
341
|
+
let starts = 0;
|
|
342
|
+
const context = {
|
|
343
|
+
runs: createShedRuns({
|
|
344
|
+
exec(query, ...values) {
|
|
345
|
+
const rows = database.prepare(query).all(...values);
|
|
346
|
+
return { toArray: () => rows };
|
|
347
|
+
},
|
|
348
|
+
}),
|
|
349
|
+
env: {
|
|
350
|
+
COMPUTER: {
|
|
351
|
+
processStart: (input: { command: string; cwd?: string }) => {
|
|
352
|
+
starts++;
|
|
353
|
+
processStartInputSchema
|
|
354
|
+
.omit({ requestId: true, requestScope: true })
|
|
355
|
+
.parse(input);
|
|
356
|
+
return Promise.reject(new Error("unexpected dispatch"));
|
|
357
|
+
},
|
|
358
|
+
processes: () => Promise.reject(new Error("unexpected process read")),
|
|
359
|
+
read: () => Promise.reject(new Error("unexpected log read")),
|
|
360
|
+
},
|
|
361
|
+
},
|
|
362
|
+
};
|
|
363
|
+
const caller = {
|
|
364
|
+
actor: { kind: "member", memberId: "member-one" },
|
|
365
|
+
computerId: "computer-one",
|
|
366
|
+
idempotencyKey: "oversized-wrapper",
|
|
367
|
+
} satisfies Parameters<typeof appBackend.call>[3];
|
|
368
|
+
await assert.rejects(
|
|
369
|
+
appBackend.call("run", { command: "'".repeat(3000) }, context, caller),
|
|
370
|
+
);
|
|
371
|
+
assert.equal(starts, 0);
|
|
372
|
+
assert.deepEqual(
|
|
373
|
+
await appBackend.call(
|
|
374
|
+
"logs",
|
|
375
|
+
{ run: "oversized-wrapper" },
|
|
376
|
+
context,
|
|
377
|
+
caller,
|
|
378
|
+
),
|
|
379
|
+
{ error: "not_found" },
|
|
380
|
+
);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
void it("requires the host key and Computer, and refuses to restart after a lost start reply", async (t) => {
|
|
384
|
+
const database = new DatabaseSync(":memory:");
|
|
385
|
+
t.after(() => {
|
|
386
|
+
database.close();
|
|
387
|
+
});
|
|
388
|
+
const sql = {
|
|
389
|
+
exec(query: string, ...values: (string | number | null)[]) {
|
|
390
|
+
const rows = database.prepare(query).all(...values);
|
|
391
|
+
return { toArray: () => rows };
|
|
392
|
+
},
|
|
393
|
+
};
|
|
394
|
+
let starts = 0;
|
|
395
|
+
const computer = {
|
|
396
|
+
processStart: () => {
|
|
397
|
+
starts++;
|
|
398
|
+
return Promise.reject(new Error("Start reply lost"));
|
|
399
|
+
},
|
|
400
|
+
processes: () =>
|
|
401
|
+
Promise.reject(new Error("unidentified process must not be guessed")),
|
|
402
|
+
};
|
|
403
|
+
const caller = {
|
|
404
|
+
actor: { kind: "member", memberId: "member-one" },
|
|
405
|
+
computerId: "computer-one",
|
|
406
|
+
} satisfies Parameters<typeof appBackend.call>[3];
|
|
407
|
+
const context = { runs: createShedRuns(sql), env: { COMPUTER: computer } };
|
|
408
|
+
const input = { command: "printf once" };
|
|
409
|
+
await assert.rejects(
|
|
410
|
+
appBackend.call("run", input, context, caller),
|
|
411
|
+
/host idempotency key/,
|
|
412
|
+
);
|
|
413
|
+
await assert.rejects(
|
|
414
|
+
appBackend.call("run", input, context, {
|
|
415
|
+
...caller,
|
|
416
|
+
computerId: null,
|
|
417
|
+
idempotencyKey: "lost-reply",
|
|
418
|
+
}),
|
|
419
|
+
/Computer access/,
|
|
420
|
+
);
|
|
421
|
+
assert.equal(starts, 0);
|
|
422
|
+
const identified = { ...caller, idempotencyKey: "lost-reply" };
|
|
423
|
+
await assert.rejects(
|
|
424
|
+
appBackend.call("run", input, context, identified),
|
|
425
|
+
/Start reply lost/,
|
|
426
|
+
);
|
|
427
|
+
const restored = { runs: createShedRuns(sql), env: { COMPUTER: computer } };
|
|
428
|
+
await assert.rejects(
|
|
429
|
+
appBackend.call("run", input, restored, identified),
|
|
430
|
+
/Process start outcome is unavailable\. Log: .*out\.log/,
|
|
431
|
+
);
|
|
432
|
+
await assert.rejects(
|
|
433
|
+
appBackend.call("run", { command: "different" }, restored, identified),
|
|
434
|
+
/Run identity conflicts/,
|
|
435
|
+
);
|
|
436
|
+
await assert.rejects(
|
|
437
|
+
appBackend.call("run", input, restored, {
|
|
438
|
+
...identified,
|
|
439
|
+
computerId: "computer-other",
|
|
440
|
+
}),
|
|
441
|
+
/Run identity conflicts/,
|
|
442
|
+
);
|
|
443
|
+
assert.equal(starts, 1);
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
void it("publishes run as Pending-capable with its required Computer authority", () => {
|
|
447
|
+
const { run, logs, expose } = appApi.methods;
|
|
448
|
+
assert.ok(run);
|
|
449
|
+
assert.ok(logs);
|
|
450
|
+
assert.ok(expose);
|
|
451
|
+
assert.equal(run.pending, true);
|
|
452
|
+
assert.equal(run.effect, "write");
|
|
453
|
+
assert.deepEqual(run.requires, ["computer.execute", "computer.writeFile"]);
|
|
454
|
+
assert.deepEqual(logs.requires, ["computer.readFile"]);
|
|
455
|
+
assert.equal(logs.effect, "read");
|
|
456
|
+
assert.equal(expose.effect, "write");
|
|
457
|
+
assert.deepEqual(expose.requires, ["computer.execute"]);
|
|
458
|
+
assert.deepEqual(appDeclaration.api, appApi);
|
|
459
|
+
assert.deepEqual(appDeclaration.backend?.bindings, [
|
|
460
|
+
{
|
|
461
|
+
name: "COMPUTER",
|
|
462
|
+
operations: [
|
|
463
|
+
"ports",
|
|
464
|
+
"processes",
|
|
465
|
+
"exec",
|
|
466
|
+
"processStart",
|
|
467
|
+
"processKill",
|
|
468
|
+
"write",
|
|
469
|
+
"read",
|
|
470
|
+
"preview",
|
|
471
|
+
"workflowGitPass",
|
|
472
|
+
],
|
|
473
|
+
},
|
|
474
|
+
]);
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
void it("renews one Workflow selector while retaining the original process across polls and reopen", async (t) => {
|
|
478
|
+
const database = new DatabaseSync(":memory:");
|
|
479
|
+
t.after(() => {
|
|
480
|
+
database.close();
|
|
481
|
+
});
|
|
482
|
+
const sql = {
|
|
483
|
+
exec(query: string, ...values: (string | number | null)[]) {
|
|
484
|
+
const rows = database.prepare(query).all(...values);
|
|
485
|
+
return { toArray: () => rows };
|
|
486
|
+
},
|
|
487
|
+
};
|
|
488
|
+
let clock = 1000;
|
|
489
|
+
const remote =
|
|
490
|
+
"https://git.team.internal/workflow/pass_workflow/app_source.git";
|
|
491
|
+
const gitAccess = {
|
|
492
|
+
repository: {
|
|
493
|
+
kind: "installation" as const,
|
|
494
|
+
installationId: "installation_one",
|
|
495
|
+
repository: "app_source",
|
|
496
|
+
},
|
|
497
|
+
branches: ["refs/heads/main"],
|
|
498
|
+
};
|
|
499
|
+
const calls: string[] = [];
|
|
500
|
+
let starts = 0;
|
|
501
|
+
let renewals = 0;
|
|
502
|
+
const process: ComputerProcess = {
|
|
503
|
+
id: mintComputerProcessId(),
|
|
504
|
+
port: null,
|
|
505
|
+
command: "git push",
|
|
506
|
+
cwd: "/workspace",
|
|
507
|
+
state: "running",
|
|
508
|
+
exitCode: null,
|
|
509
|
+
sync: "synced",
|
|
510
|
+
startedAt: 1000,
|
|
511
|
+
endedAt: null,
|
|
512
|
+
};
|
|
513
|
+
const computer = {
|
|
514
|
+
write: (input: { path: string; content: string }) =>
|
|
515
|
+
Promise.resolve({
|
|
516
|
+
path: input.path,
|
|
517
|
+
bytes: new TextEncoder().encode(input.content).byteLength,
|
|
518
|
+
}),
|
|
519
|
+
workflowGitPass: () => {
|
|
520
|
+
renewals++;
|
|
521
|
+
return Promise.resolve({
|
|
522
|
+
passId: "pass_workflow",
|
|
523
|
+
remote,
|
|
524
|
+
expiresAt: clock + 60000,
|
|
525
|
+
});
|
|
526
|
+
},
|
|
527
|
+
processStart: (input: { command: string; cwd?: string }) => {
|
|
528
|
+
starts++;
|
|
529
|
+
calls.push(input.command);
|
|
530
|
+
return Promise.resolve(process);
|
|
531
|
+
},
|
|
532
|
+
processes: () => Promise.resolve({ processes: [process] }),
|
|
533
|
+
};
|
|
534
|
+
const caller = {
|
|
535
|
+
actor: { kind: "member" as const, memberId: "member_one" },
|
|
536
|
+
computerId: "computer_one",
|
|
537
|
+
idempotencyKey: "opaque_call_key",
|
|
538
|
+
};
|
|
539
|
+
const input = {
|
|
540
|
+
command: 'git push "$FUNGI_GIT_REMOTE" HEAD:refs/heads/main',
|
|
541
|
+
cwd: "/workspace",
|
|
542
|
+
gitAccess,
|
|
543
|
+
};
|
|
544
|
+
await appBackend.call(
|
|
545
|
+
"run",
|
|
546
|
+
input,
|
|
547
|
+
{ runs: createShedRuns(sql, () => clock), env: { COMPUTER: computer } },
|
|
548
|
+
caller,
|
|
549
|
+
);
|
|
550
|
+
clock = 6000;
|
|
551
|
+
await appBackend.call(
|
|
552
|
+
"run",
|
|
553
|
+
input,
|
|
554
|
+
{ runs: createShedRuns(sql, () => clock), env: { COMPUTER: computer } },
|
|
555
|
+
caller,
|
|
556
|
+
);
|
|
557
|
+
assert.equal(starts, 1);
|
|
558
|
+
assert.equal(renewals, 2);
|
|
559
|
+
assert.ok(calls[0]?.includes("FUNGI_GIT_REMOTE"));
|
|
560
|
+
clock = 61001;
|
|
561
|
+
await appBackend.call(
|
|
562
|
+
"run",
|
|
563
|
+
input,
|
|
564
|
+
{ runs: createShedRuns(sql, () => clock), env: { COMPUTER: computer } },
|
|
565
|
+
caller,
|
|
566
|
+
);
|
|
567
|
+
assert.equal(starts, 1);
|
|
568
|
+
assert.equal(renewals, 3);
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
function workflowRunFixture(t: TestContext) {
|
|
572
|
+
const database = new DatabaseSync(":memory:");
|
|
573
|
+
t.after(() => {
|
|
574
|
+
database.close();
|
|
575
|
+
});
|
|
576
|
+
const sql = {
|
|
577
|
+
exec(query: string, ...values: (string | number | null)[]) {
|
|
578
|
+
const rows = database.prepare(query).all(...values);
|
|
579
|
+
return { toArray: () => rows };
|
|
580
|
+
},
|
|
581
|
+
};
|
|
582
|
+
let clock = 1000;
|
|
583
|
+
let starts = 0;
|
|
584
|
+
let mints = 0;
|
|
585
|
+
const remote =
|
|
586
|
+
"https://git.team.internal/workflow/pass_workflow/app_source.git";
|
|
587
|
+
let pass: z.core.util.JSONType = {
|
|
588
|
+
passId: "pass_workflow",
|
|
589
|
+
remote,
|
|
590
|
+
expiresAt: 61000,
|
|
591
|
+
};
|
|
592
|
+
const process: ComputerProcess = {
|
|
593
|
+
id: mintComputerProcessId(),
|
|
594
|
+
port: null,
|
|
595
|
+
command: "git push",
|
|
596
|
+
cwd: null,
|
|
597
|
+
state: "running",
|
|
598
|
+
exitCode: null,
|
|
599
|
+
sync: "synced",
|
|
600
|
+
startedAt: 1000,
|
|
601
|
+
endedAt: null,
|
|
602
|
+
};
|
|
603
|
+
const delivered = new Map<string, string>();
|
|
604
|
+
const computer = {
|
|
605
|
+
write: (input: { path: string; content: string }) => {
|
|
606
|
+
delivered.set(input.path, input.content);
|
|
607
|
+
return Promise.resolve({
|
|
608
|
+
path: input.path,
|
|
609
|
+
bytes: new TextEncoder().encode(input.content).byteLength,
|
|
610
|
+
});
|
|
611
|
+
},
|
|
612
|
+
processStart: (input: { command: string; cwd?: string }) => {
|
|
613
|
+
assert.equal(input.command.includes(remote), false);
|
|
614
|
+
assert.ok([...delivered.values()].includes(`${remote}\n`));
|
|
615
|
+
starts++;
|
|
616
|
+
return Promise.resolve(process);
|
|
617
|
+
},
|
|
618
|
+
processes: () => Promise.resolve({ processes: [process] }),
|
|
619
|
+
workflowGitPass: () => {
|
|
620
|
+
mints++;
|
|
621
|
+
return Promise.resolve(pass);
|
|
622
|
+
},
|
|
623
|
+
};
|
|
624
|
+
const context = {
|
|
625
|
+
runs: createShedRuns(sql, () => clock),
|
|
626
|
+
env: { COMPUTER: computer },
|
|
627
|
+
};
|
|
628
|
+
const caller = {
|
|
629
|
+
actor: { kind: "member" as const, memberId: "member_one" },
|
|
630
|
+
computerId: "computer_one",
|
|
631
|
+
idempotencyKey: "opaque_call_key",
|
|
632
|
+
};
|
|
633
|
+
const input: ShedRunInput = {
|
|
634
|
+
command: "git push",
|
|
635
|
+
gitAccess: {
|
|
636
|
+
repository: {
|
|
637
|
+
kind: "installation",
|
|
638
|
+
installationId: "installation_one",
|
|
639
|
+
repository: "app_source",
|
|
640
|
+
},
|
|
641
|
+
branches: ["refs/heads/main"],
|
|
642
|
+
},
|
|
643
|
+
};
|
|
644
|
+
return {
|
|
645
|
+
sql,
|
|
646
|
+
context,
|
|
647
|
+
caller,
|
|
648
|
+
input,
|
|
649
|
+
computer,
|
|
650
|
+
process,
|
|
651
|
+
remote,
|
|
652
|
+
starts: () => starts,
|
|
653
|
+
written: () => [...delivered.values()],
|
|
654
|
+
mints: () => mints,
|
|
655
|
+
setClock: (value: number) => {
|
|
656
|
+
clock = value;
|
|
657
|
+
},
|
|
658
|
+
setPass: (value: z.core.util.JSONType) => {
|
|
659
|
+
pass = value;
|
|
660
|
+
},
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
void it("refuses expired or changed selectors without replacing the original process", async (t) => {
|
|
665
|
+
const f = workflowRunFixture(t);
|
|
666
|
+
await appBackend.call("run", f.input, f.context, f.caller);
|
|
667
|
+
f.setClock(61000);
|
|
668
|
+
await assert.rejects(
|
|
669
|
+
appBackend.call("run", f.input, f.context, f.caller),
|
|
670
|
+
/Workflow Git pass expired/,
|
|
671
|
+
);
|
|
672
|
+
assert.equal(f.mints(), 1);
|
|
673
|
+
assert.equal(f.starts(), 1);
|
|
674
|
+
f.setClock(6000);
|
|
675
|
+
f.setPass({
|
|
676
|
+
passId: "pass_other",
|
|
677
|
+
remote: "https://git.team.internal/workflow/pass_other/app_source.git",
|
|
678
|
+
expiresAt: 66000,
|
|
679
|
+
});
|
|
680
|
+
await assert.rejects(
|
|
681
|
+
appBackend.call("run", f.input, f.context, f.caller),
|
|
682
|
+
/changed or expired/,
|
|
683
|
+
);
|
|
684
|
+
f.setPass({ error: "revoked" });
|
|
685
|
+
await assert.rejects(appBackend.call("run", f.input, f.context, f.caller));
|
|
686
|
+
assert.equal(f.starts(), 1);
|
|
687
|
+
await assert.rejects(
|
|
688
|
+
appBackend.call(
|
|
689
|
+
"run",
|
|
690
|
+
{
|
|
691
|
+
...f.input,
|
|
692
|
+
gitAccess: {
|
|
693
|
+
repository: {
|
|
694
|
+
kind: "installation",
|
|
695
|
+
installationId: "installation_one",
|
|
696
|
+
repository: "app_source",
|
|
697
|
+
},
|
|
698
|
+
branches: ["refs/heads/other"],
|
|
699
|
+
},
|
|
700
|
+
},
|
|
701
|
+
f.context,
|
|
702
|
+
f.caller,
|
|
703
|
+
),
|
|
704
|
+
/Run identity conflicts/,
|
|
705
|
+
);
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
// Regression coverage for the Storyboard Save defect: a Workflow step's
|
|
709
|
+
// `git push` through Shed `run` exiting non-zero must surface as a normal
|
|
710
|
+
// result the Workflow can read, never as an opaque dispatch failure. Exit 0
|
|
711
|
+
// and a real dispatch failure are the two cases a non-zero exit must stay
|
|
712
|
+
// distinguishable from.
|
|
713
|
+
void it("returns exit 0 as a normal run result", async (t) => {
|
|
714
|
+
const f = workflowRunFixture(t);
|
|
715
|
+
assert.deepEqual(await appBackend.call("run", f.input, f.context, f.caller), {
|
|
716
|
+
kind: "pending",
|
|
717
|
+
retryAfterMs: 5000,
|
|
718
|
+
});
|
|
719
|
+
f.process.state = "exited";
|
|
720
|
+
f.process.exitCode = 0;
|
|
721
|
+
f.process.endedAt = 2000;
|
|
722
|
+
const result = completedRun.parse(
|
|
723
|
+
await appBackend.call("run", f.input, f.context, f.caller),
|
|
724
|
+
);
|
|
725
|
+
assert.equal(result.exitCode, 0);
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
void it("returns a non-zero git push exit as a normal run result, never dispatch_failed", async (t) => {
|
|
729
|
+
const f = workflowRunFixture(t);
|
|
730
|
+
assert.deepEqual(await appBackend.call("run", f.input, f.context, f.caller), {
|
|
731
|
+
kind: "pending",
|
|
732
|
+
retryAfterMs: 5000,
|
|
733
|
+
});
|
|
734
|
+
f.process.state = "exited";
|
|
735
|
+
f.process.exitCode = 1;
|
|
736
|
+
f.process.endedAt = 2000;
|
|
737
|
+
// The fix is exactly this: the call resolves with the failed exit, it
|
|
738
|
+
// does not reject. A caller that told dispatch failure from command
|
|
739
|
+
// failure by catching a throw would mistake this `git push` for a
|
|
740
|
+
// platform failure.
|
|
741
|
+
const result = completedRun.parse(
|
|
742
|
+
await appBackend.call("run", f.input, f.context, f.caller),
|
|
743
|
+
);
|
|
744
|
+
assert.equal(result.exitCode, 1);
|
|
745
|
+
// Resuming an already-exited claim returns the same result, not a throw.
|
|
746
|
+
const resumed = completedRun.parse(
|
|
747
|
+
await appBackend.call("run", f.input, f.context, f.caller),
|
|
748
|
+
);
|
|
749
|
+
assert.deepEqual(resumed, result);
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
void it("still throws on a real dispatch failure, distinct from a non-zero exit", async (t) => {
|
|
753
|
+
const f = workflowRunFixture(t);
|
|
754
|
+
f.computer.processStart = () =>
|
|
755
|
+
Promise.reject(new Error("the Computer could not be reached"));
|
|
756
|
+
await assert.rejects(
|
|
757
|
+
appBackend.call("run", f.input, f.context, f.caller),
|
|
758
|
+
/the Computer could not be reached/,
|
|
759
|
+
);
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
void it("claims no process when the Workflow binding is missing or refuses", async (t) => {
|
|
763
|
+
const f = workflowRunFixture(t);
|
|
764
|
+
const { workflowGitPass: omitted, ...withoutPass } = f.computer;
|
|
765
|
+
assert.ok(omitted);
|
|
766
|
+
await assert.rejects(
|
|
767
|
+
appBackend.call(
|
|
768
|
+
"run",
|
|
769
|
+
f.input,
|
|
770
|
+
{ ...f.context, env: { COMPUTER: withoutPass } },
|
|
771
|
+
f.caller,
|
|
772
|
+
),
|
|
773
|
+
/binding is unavailable/,
|
|
774
|
+
);
|
|
775
|
+
f.setPass({ error: "not_granted" });
|
|
776
|
+
await assert.rejects(appBackend.call("run", f.input, f.context, f.caller));
|
|
777
|
+
assert.equal(f.starts(), 0);
|
|
778
|
+
f.setPass({ passId: "pass_workflow", remote: f.remote, expiresAt: 61000 });
|
|
779
|
+
await appBackend.call(
|
|
780
|
+
"run",
|
|
781
|
+
{ ...f.input, command: "printf fixed" },
|
|
782
|
+
f.context,
|
|
783
|
+
f.caller,
|
|
784
|
+
);
|
|
785
|
+
assert.equal(f.starts(), 1);
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
void it("uses the canonical resolved scope grammar before minting", async (t) => {
|
|
789
|
+
const f = workflowRunFixture(t);
|
|
790
|
+
for (const gitAccess of [
|
|
791
|
+
{ repository: "$trigger.repository", branches: ["refs/heads/main"] },
|
|
792
|
+
{
|
|
793
|
+
repository: {
|
|
794
|
+
kind: "installation",
|
|
795
|
+
installationId: "installation_one",
|
|
796
|
+
repository: "app_source",
|
|
797
|
+
},
|
|
798
|
+
branches: ["refs/heads/*"],
|
|
799
|
+
},
|
|
800
|
+
{
|
|
801
|
+
repository: {
|
|
802
|
+
kind: "installation",
|
|
803
|
+
installationId: "installation_one",
|
|
804
|
+
repository: "app_source",
|
|
805
|
+
},
|
|
806
|
+
branches: ["refs/heads/main"],
|
|
807
|
+
memberId: "untrusted",
|
|
808
|
+
},
|
|
809
|
+
])
|
|
810
|
+
await assert.rejects(
|
|
811
|
+
appBackend.call(
|
|
812
|
+
"run",
|
|
813
|
+
z.json().parse({ ...f.input, gitAccess }),
|
|
814
|
+
f.context,
|
|
815
|
+
f.caller,
|
|
816
|
+
),
|
|
817
|
+
);
|
|
818
|
+
assert.equal(f.starts(), 0);
|
|
819
|
+
assert.equal(f.mints(), 0);
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
void it("passes the complete Workflow URL only through the command-local shell environment", async (t) => {
|
|
823
|
+
const root = await mkdtemp(join(tmpdir(), "shed-workflow-remote-"));
|
|
824
|
+
t.after(() => rm(root, { recursive: true, force: true }));
|
|
825
|
+
const f = workflowRunFixture(t);
|
|
826
|
+
let credentialPath = "";
|
|
827
|
+
f.computer.write = (input: { path: string; content: string }) => {
|
|
828
|
+
credentialPath = input.path.replaceAll("/workspace", root);
|
|
829
|
+
return writeFile(
|
|
830
|
+
input.path.replaceAll("/workspace", root),
|
|
831
|
+
input.content,
|
|
832
|
+
).then(() => ({
|
|
833
|
+
path: input.path,
|
|
834
|
+
bytes: new TextEncoder().encode(input.content).byteLength,
|
|
835
|
+
}));
|
|
836
|
+
};
|
|
837
|
+
f.computer.processStart = (input: { command: string; cwd?: string }) =>
|
|
838
|
+
new Promise((resolve, reject) => {
|
|
839
|
+
const child = spawn(
|
|
840
|
+
"bash",
|
|
841
|
+
["-c", input.command.replaceAll("/workspace", root)],
|
|
842
|
+
{ cwd: root, stdio: "ignore" },
|
|
843
|
+
);
|
|
844
|
+
child.once("error", reject);
|
|
845
|
+
child.once("exit", (code) => {
|
|
846
|
+
resolve({
|
|
847
|
+
...f.process,
|
|
848
|
+
state: "exited",
|
|
849
|
+
exitCode: code,
|
|
850
|
+
endedAt: 1001,
|
|
851
|
+
});
|
|
852
|
+
});
|
|
853
|
+
});
|
|
854
|
+
const input = {
|
|
855
|
+
...f.input,
|
|
856
|
+
command:
|
|
857
|
+
'test "$FUNGI_GIT_REMOTE" = "https://git.team.internal/workflow/pass_workflow/app_source.git" && ! test -e .fungi-git-58fc43972eaef9e869f7a384f4da68f65c6b660f84e3d7fa4332f89ac6ceb7e3.remote && printf remote-matched',
|
|
858
|
+
};
|
|
859
|
+
const result = await appBackend.call("run", input, f.context, f.caller);
|
|
860
|
+
assert.deepEqual(result, {
|
|
861
|
+
exitCode: 0,
|
|
862
|
+
logPath:
|
|
863
|
+
"/workspace/.fungi/runs/call-58fc43972eaef9e869f7a384f4da68f65c6b660f84e3d7fa4332f89ac6ceb7e3/out.log",
|
|
864
|
+
});
|
|
865
|
+
assert.equal(
|
|
866
|
+
await readFile(
|
|
867
|
+
join(
|
|
868
|
+
root,
|
|
869
|
+
".fungi/runs/call-58fc43972eaef9e869f7a384f4da68f65c6b660f84e3d7fa4332f89ac6ceb7e3/out.log",
|
|
870
|
+
),
|
|
871
|
+
"utf8",
|
|
872
|
+
),
|
|
873
|
+
"remote-matched",
|
|
874
|
+
);
|
|
875
|
+
await assert.rejects(readFile(credentialPath), { code: "ENOENT" });
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
void it("gives a bearer pass to git only as command-local extraHeader config", async (t) => {
|
|
879
|
+
const root = await mkdtemp(join(tmpdir(), "shed-workflow-bearer-"));
|
|
880
|
+
t.after(() => rm(root, { recursive: true, force: true }));
|
|
881
|
+
const f = workflowRunFixture(t);
|
|
882
|
+
const remote = "https://oyster.test/git/bonsai/pond.git";
|
|
883
|
+
const bearer = `wgp.workflows.${"b".repeat(64)}`;
|
|
884
|
+
f.setPass({ passId: "pass_workflow", remote, bearer, expiresAt: 61000 });
|
|
885
|
+
await writeFile(
|
|
886
|
+
join(root, "expected-header"),
|
|
887
|
+
`Authorization: Bearer ${bearer}`,
|
|
888
|
+
);
|
|
889
|
+
await writeFile(join(root, "expected-secret"), "b".repeat(64));
|
|
890
|
+
let credentialPath = "";
|
|
891
|
+
let started = "";
|
|
892
|
+
f.computer.write = (input: { path: string; content: string }) => {
|
|
893
|
+
credentialPath = input.path.replaceAll("/workspace", root);
|
|
894
|
+
return writeFile(credentialPath, input.content).then(() => ({
|
|
895
|
+
path: input.path,
|
|
896
|
+
bytes: new TextEncoder().encode(input.content).byteLength,
|
|
897
|
+
}));
|
|
898
|
+
};
|
|
899
|
+
f.computer.processStart = (input: { command: string; cwd?: string }) => {
|
|
900
|
+
started = input.command;
|
|
901
|
+
return new Promise((resolve, reject) => {
|
|
902
|
+
const child = spawn(
|
|
903
|
+
"bash",
|
|
904
|
+
["-c", input.command.replaceAll("/workspace", root)],
|
|
905
|
+
{ cwd: root, stdio: "ignore", env: { PATH: process.env.PATH } },
|
|
906
|
+
);
|
|
907
|
+
child.once("error", reject);
|
|
908
|
+
child.once("exit", (code) => {
|
|
909
|
+
resolve({
|
|
910
|
+
...f.process,
|
|
911
|
+
state: "exited",
|
|
912
|
+
exitCode: code,
|
|
913
|
+
endedAt: 1001,
|
|
914
|
+
});
|
|
915
|
+
});
|
|
916
|
+
});
|
|
917
|
+
};
|
|
918
|
+
const input = {
|
|
919
|
+
...f.input,
|
|
920
|
+
command: [
|
|
921
|
+
"git init -q repo",
|
|
922
|
+
'test "$FUNGI_GIT_REMOTE" = "https://oyster.test/git/bonsai/pond.git"',
|
|
923
|
+
// The expected header comes from a file, so the command never names it.
|
|
924
|
+
'test "$(git -C repo config --get-urlmatch http.extraHeader "$FUNGI_GIT_REMOTE")" = "$(cat expected-header)"',
|
|
925
|
+
'test -z "$(git -C repo config --get-urlmatch http.extraHeader https://elsewhere.test/x.git)"',
|
|
926
|
+
'! grep -qF "$(cat expected-secret)" repo/.git/config',
|
|
927
|
+
"printf header-scoped",
|
|
928
|
+
].join(" && "),
|
|
929
|
+
};
|
|
930
|
+
const result = await appBackend.call("run", input, f.context, f.caller);
|
|
931
|
+
assert.equal(z.object({ exitCode: z.number() }).parse(result).exitCode, 0);
|
|
932
|
+
assert.equal(started.includes("b".repeat(64)), false);
|
|
933
|
+
await assert.rejects(readFile(credentialPath), { code: "ENOENT" });
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
void it("returns a running process at start and reuses its durable claim after reopening", async (t) => {
|
|
937
|
+
const database = new DatabaseSync(":memory:");
|
|
938
|
+
t.after(() => {
|
|
939
|
+
database.close();
|
|
940
|
+
});
|
|
941
|
+
const sql = {
|
|
942
|
+
exec(query: string, ...values: (string | number | null)[]) {
|
|
943
|
+
const rows = database.prepare(query).all(...values);
|
|
944
|
+
return { toArray: () => rows };
|
|
945
|
+
},
|
|
946
|
+
};
|
|
947
|
+
const process: ComputerProcess = {
|
|
948
|
+
id: mintComputerProcessId(),
|
|
949
|
+
port: null,
|
|
950
|
+
command: "sleep 120",
|
|
951
|
+
cwd: "/workspace",
|
|
952
|
+
state: "running",
|
|
953
|
+
exitCode: null,
|
|
954
|
+
sync: "synced",
|
|
955
|
+
startedAt: 1,
|
|
956
|
+
endedAt: null,
|
|
957
|
+
};
|
|
958
|
+
let starts = 0;
|
|
959
|
+
const computer = {
|
|
960
|
+
processStart: (input: { command: string; cwd?: string }) => {
|
|
961
|
+
starts++;
|
|
962
|
+
assert.match(input.command, /tee/);
|
|
963
|
+
assert.equal(input.cwd, "/workspace");
|
|
964
|
+
return Promise.resolve(process);
|
|
965
|
+
},
|
|
966
|
+
processes: () => Promise.resolve({ processes: [process] }),
|
|
967
|
+
};
|
|
968
|
+
const caller = {
|
|
969
|
+
actor: { kind: "member", memberId: "member-one" },
|
|
970
|
+
computerId: "computer-one",
|
|
971
|
+
idempotencyKey: "server-one",
|
|
972
|
+
} satisfies Parameters<typeof appBackend.call>[3];
|
|
973
|
+
const input = { command: "sleep 120", cwd: "/workspace" };
|
|
974
|
+
const context = { runs: createShedRuns(sql), env: { COMPUTER: computer } };
|
|
975
|
+
const expected = {
|
|
976
|
+
process,
|
|
977
|
+
logPath:
|
|
978
|
+
"/workspace/.fungi/runs/call-0f0f9a06650856cadfd8d3abfa0af4470f4640be6edaecd068a39e221522acb6/out.log",
|
|
979
|
+
};
|
|
980
|
+
assert.deepEqual(
|
|
981
|
+
await appBackend.call("start", input, context, caller),
|
|
982
|
+
expected,
|
|
983
|
+
);
|
|
984
|
+
const restored = { runs: createShedRuns(sql), env: { COMPUTER: computer } };
|
|
985
|
+
assert.deepEqual(
|
|
986
|
+
await appBackend.call("start", input, restored, caller),
|
|
987
|
+
expected,
|
|
988
|
+
);
|
|
989
|
+
assert.equal(starts, 1);
|
|
990
|
+
await assert.rejects(
|
|
991
|
+
appBackend.call("start", { command: "different" }, restored, caller),
|
|
992
|
+
/identity conflicts/,
|
|
993
|
+
);
|
|
994
|
+
assert.equal(starts, 1);
|
|
995
|
+
});
|
|
996
|
+
|
|
997
|
+
void it("recovers a lost start reply with the same native input instead of starting another process", async (t) => {
|
|
998
|
+
const database = new DatabaseSync(":memory:");
|
|
999
|
+
t.after(() => {
|
|
1000
|
+
database.close();
|
|
1001
|
+
});
|
|
1002
|
+
const sql = {
|
|
1003
|
+
exec(query: string, ...values: (string | number | null)[]) {
|
|
1004
|
+
const rows = database.prepare(query).all(...values);
|
|
1005
|
+
return { toArray: () => rows };
|
|
1006
|
+
},
|
|
1007
|
+
};
|
|
1008
|
+
const process: ComputerProcess = {
|
|
1009
|
+
id: mintComputerProcessId(),
|
|
1010
|
+
port: null,
|
|
1011
|
+
command: "sleep 120",
|
|
1012
|
+
cwd: null,
|
|
1013
|
+
state: "running",
|
|
1014
|
+
exitCode: null,
|
|
1015
|
+
sync: "synced",
|
|
1016
|
+
startedAt: 1,
|
|
1017
|
+
endedAt: null,
|
|
1018
|
+
};
|
|
1019
|
+
const requests: unknown[] = [];
|
|
1020
|
+
const computer = {
|
|
1021
|
+
// The native Computer owns replay identity; the binding retains the host call key.
|
|
1022
|
+
processStart: (input: { command: string; cwd?: string }) => {
|
|
1023
|
+
requests.push(input);
|
|
1024
|
+
if (requests.length === 1)
|
|
1025
|
+
return Promise.reject(new Error("Start reply lost"));
|
|
1026
|
+
return Promise.resolve(process);
|
|
1027
|
+
},
|
|
1028
|
+
processes: () => Promise.resolve({ processes: [process] }),
|
|
1029
|
+
};
|
|
1030
|
+
const caller = {
|
|
1031
|
+
actor: { kind: "member", memberId: "member-one" },
|
|
1032
|
+
computerId: "computer-one",
|
|
1033
|
+
idempotencyKey: "lost-server-start",
|
|
1034
|
+
} satisfies Parameters<typeof appBackend.call>[3];
|
|
1035
|
+
const input = { command: "sleep 120" };
|
|
1036
|
+
await assert.rejects(
|
|
1037
|
+
appBackend.call(
|
|
1038
|
+
"start",
|
|
1039
|
+
input,
|
|
1040
|
+
{ runs: createShedRuns(sql), env: { COMPUTER: computer } },
|
|
1041
|
+
caller,
|
|
1042
|
+
),
|
|
1043
|
+
/Start reply lost/,
|
|
1044
|
+
);
|
|
1045
|
+
const reply = await appBackend.call(
|
|
1046
|
+
"start",
|
|
1047
|
+
input,
|
|
1048
|
+
{ runs: createShedRuns(sql), env: { COMPUTER: computer } },
|
|
1049
|
+
caller,
|
|
1050
|
+
);
|
|
1051
|
+
assert.deepEqual(reply, {
|
|
1052
|
+
process,
|
|
1053
|
+
logPath:
|
|
1054
|
+
"/workspace/.fungi/runs/call-e0f1dcd3d2ff59ec53abdaaf0e9686d4b6d0ccb461cd8f8de397fb59cc5719bc/out.log",
|
|
1055
|
+
});
|
|
1056
|
+
assert.equal(requests.length, 2);
|
|
1057
|
+
assert.deepEqual(requests[1], requests[0]);
|
|
1058
|
+
});
|
|
1059
|
+
|
|
1060
|
+
void it("starts a declared port and binds retry identity to the native process port", async (t) => {
|
|
1061
|
+
const database = new DatabaseSync(":memory:");
|
|
1062
|
+
t.after(() => {
|
|
1063
|
+
database.close();
|
|
1064
|
+
});
|
|
1065
|
+
const sql = {
|
|
1066
|
+
exec(query: string, ...values: (string | number | null)[]) {
|
|
1067
|
+
const rows = database.prepare(query).all(...values);
|
|
1068
|
+
return { toArray: () => rows };
|
|
1069
|
+
},
|
|
1070
|
+
};
|
|
1071
|
+
const process = {
|
|
1072
|
+
id: mintComputerProcessId(),
|
|
1073
|
+
command: "node server.js",
|
|
1074
|
+
cwd: null,
|
|
1075
|
+
port: 3000,
|
|
1076
|
+
state: "running",
|
|
1077
|
+
exitCode: null,
|
|
1078
|
+
sync: "unknown",
|
|
1079
|
+
startedAt: 1,
|
|
1080
|
+
endedAt: null,
|
|
1081
|
+
};
|
|
1082
|
+
const requests: unknown[] = [];
|
|
1083
|
+
const computer = {
|
|
1084
|
+
processStart: (input: { command: string; cwd?: string; port?: number }) => {
|
|
1085
|
+
requests.push(input);
|
|
1086
|
+
assert.equal(input.port, 3000);
|
|
1087
|
+
assert.equal("requestId" in input, false);
|
|
1088
|
+
assert.equal("requestScope" in input, false);
|
|
1089
|
+
return Promise.resolve(process);
|
|
1090
|
+
},
|
|
1091
|
+
processes: () => Promise.resolve({ processes: [process] }),
|
|
1092
|
+
};
|
|
1093
|
+
const caller = {
|
|
1094
|
+
actor: { kind: "member", memberId: "member-one" },
|
|
1095
|
+
computerId: "computer-one",
|
|
1096
|
+
idempotencyKey: "port-server",
|
|
1097
|
+
} satisfies Parameters<typeof appBackend.call>[3];
|
|
1098
|
+
const input: ShedStartInput = { command: "node server.js", port: 3000 };
|
|
1099
|
+
const result = await appBackend.call(
|
|
1100
|
+
"start",
|
|
1101
|
+
input,
|
|
1102
|
+
{ runs: createShedRuns(sql), env: { COMPUTER: computer } },
|
|
1103
|
+
caller,
|
|
1104
|
+
);
|
|
1105
|
+
assert.deepEqual(result, {
|
|
1106
|
+
process,
|
|
1107
|
+
logPath:
|
|
1108
|
+
"/workspace/.fungi/runs/call-2cdee24467fcd4a5ee2327b807f614582c9e2cc2d42f90f577fe001d5a0ca118/out.log",
|
|
1109
|
+
port: 3000,
|
|
1110
|
+
});
|
|
1111
|
+
const restored = { runs: createShedRuns(sql), env: { COMPUTER: computer } };
|
|
1112
|
+
assert.deepEqual(
|
|
1113
|
+
await appBackend.call("start", input, restored, caller),
|
|
1114
|
+
result,
|
|
1115
|
+
);
|
|
1116
|
+
await assert.rejects(
|
|
1117
|
+
appBackend.call("start", { ...input, port: 3001 }, restored, caller),
|
|
1118
|
+
/identity conflicts/,
|
|
1119
|
+
);
|
|
1120
|
+
for (const selector of [
|
|
1121
|
+
{ requestId: "guest-selected" },
|
|
1122
|
+
{ requestScope: "installation:other" },
|
|
1123
|
+
{ computerId: "computer-other" },
|
|
1124
|
+
{ method: "guest-selected" },
|
|
1125
|
+
]) {
|
|
1126
|
+
await assert.rejects(
|
|
1127
|
+
appBackend.call(
|
|
1128
|
+
"start",
|
|
1129
|
+
z.json().parse({ ...input, ...selector }),
|
|
1130
|
+
restored,
|
|
1131
|
+
caller,
|
|
1132
|
+
),
|
|
1133
|
+
/Unrecognized key/,
|
|
1134
|
+
);
|
|
1135
|
+
}
|
|
1136
|
+
await assert.rejects(
|
|
1137
|
+
appBackend.call("run", input, restored, caller),
|
|
1138
|
+
/Unrecognized key/,
|
|
1139
|
+
);
|
|
1140
|
+
assert.equal(requests.length, 1);
|
|
1141
|
+
});
|
|
1142
|
+
|
|
1143
|
+
void it("replays an already started process without renewing its expired Workflow Git pass", async (t) => {
|
|
1144
|
+
const fixture = workflowRunFixture(t);
|
|
1145
|
+
const expected = {
|
|
1146
|
+
process: fixture.process,
|
|
1147
|
+
logPath:
|
|
1148
|
+
"/workspace/.fungi/runs/call-9a64fff978ad485e46f9452873bb5507312585c79c4265d164886c2e7cdc347c/out.log",
|
|
1149
|
+
};
|
|
1150
|
+
assert.deepEqual(
|
|
1151
|
+
await appBackend.call(
|
|
1152
|
+
"start",
|
|
1153
|
+
fixture.input,
|
|
1154
|
+
fixture.context,
|
|
1155
|
+
fixture.caller,
|
|
1156
|
+
),
|
|
1157
|
+
expected,
|
|
1158
|
+
);
|
|
1159
|
+
fixture.setClock(61000);
|
|
1160
|
+
assert.deepEqual(
|
|
1161
|
+
await appBackend.call(
|
|
1162
|
+
"start",
|
|
1163
|
+
fixture.input,
|
|
1164
|
+
fixture.context,
|
|
1165
|
+
fixture.caller,
|
|
1166
|
+
),
|
|
1167
|
+
expected,
|
|
1168
|
+
);
|
|
1169
|
+
assert.equal(fixture.starts(), 1);
|
|
1170
|
+
assert.equal(fixture.mints(), 1);
|
|
1171
|
+
});
|
|
1172
|
+
|
|
1173
|
+
void it("starts a separate native process and Git pass for another member with the same raw key", async (t) => {
|
|
1174
|
+
const f = workflowRunFixture(t);
|
|
1175
|
+
const commands: string[] = [];
|
|
1176
|
+
const processes: ComputerProcess[] = [];
|
|
1177
|
+
f.computer.processes = () => Promise.resolve({ processes });
|
|
1178
|
+
f.computer.processStart = (input: { command: string; cwd?: string }) => {
|
|
1179
|
+
commands.push(input.command);
|
|
1180
|
+
const process = { ...f.process, id: mintComputerProcessId() };
|
|
1181
|
+
processes.push(process);
|
|
1182
|
+
return Promise.resolve(process);
|
|
1183
|
+
};
|
|
1184
|
+
const first = await appBackend.call("start", f.input, f.context, f.caller);
|
|
1185
|
+
const secondRemote = "https://git.fixture/member-b.git";
|
|
1186
|
+
f.setPass({
|
|
1187
|
+
passId: "pass_member_b",
|
|
1188
|
+
remote: secondRemote,
|
|
1189
|
+
expiresAt: 60000,
|
|
1190
|
+
});
|
|
1191
|
+
const secondCaller = {
|
|
1192
|
+
...f.caller,
|
|
1193
|
+
actor: { kind: "member", memberId: "member_b" },
|
|
1194
|
+
} satisfies Parameters<typeof appBackend.call>[3];
|
|
1195
|
+
const second = await appBackend.call(
|
|
1196
|
+
"start",
|
|
1197
|
+
f.input,
|
|
1198
|
+
f.context,
|
|
1199
|
+
secondCaller,
|
|
1200
|
+
);
|
|
1201
|
+
assert.equal(commands.length, 2, "member-b must reach its own native start");
|
|
1202
|
+
assert.equal(f.mints(), 2);
|
|
1203
|
+
assert.equal(f.written().length, 2);
|
|
1204
|
+
assert.equal(f.written()[0], `${f.remote}\n`);
|
|
1205
|
+
assert.equal(f.written()[1], `${secondRemote}\n`);
|
|
1206
|
+
assert.equal(commands[0]?.includes(f.remote), false);
|
|
1207
|
+
assert.equal(commands[1]?.includes(secondRemote), false);
|
|
1208
|
+
assert.notEqual(commands[0], commands[1]);
|
|
1209
|
+
assert.equal(commands[1].includes(f.remote), false);
|
|
1210
|
+
assert.notDeepEqual(second, first);
|
|
1211
|
+
});
|
|
1212
|
+
|
|
1213
|
+
void it("does not read another member's raw-key log or dispatch a file read", async (t) => {
|
|
1214
|
+
const f = workflowRunFixture(t);
|
|
1215
|
+
await appBackend.call("start", f.input, f.context, f.caller);
|
|
1216
|
+
let reads = 0;
|
|
1217
|
+
const context = {
|
|
1218
|
+
...f.context,
|
|
1219
|
+
env: {
|
|
1220
|
+
COMPUTER: {
|
|
1221
|
+
...f.computer,
|
|
1222
|
+
read: (input: {
|
|
1223
|
+
path: string;
|
|
1224
|
+
byteOffset: number;
|
|
1225
|
+
byteLength: number;
|
|
1226
|
+
}) => {
|
|
1227
|
+
reads++;
|
|
1228
|
+
return Promise.resolve({
|
|
1229
|
+
path: input.path,
|
|
1230
|
+
byteOffset: input.byteOffset,
|
|
1231
|
+
content: "",
|
|
1232
|
+
encoding: "base64",
|
|
1233
|
+
truncated: false,
|
|
1234
|
+
});
|
|
1235
|
+
},
|
|
1236
|
+
},
|
|
1237
|
+
},
|
|
1238
|
+
};
|
|
1239
|
+
const caller = {
|
|
1240
|
+
...f.caller,
|
|
1241
|
+
actor: { kind: "member", memberId: "member_b" },
|
|
1242
|
+
} satisfies Parameters<typeof appBackend.call>[3];
|
|
1243
|
+
assert.deepEqual(
|
|
1244
|
+
await appBackend.call(
|
|
1245
|
+
"logs",
|
|
1246
|
+
{ run: f.caller.idempotencyKey },
|
|
1247
|
+
context,
|
|
1248
|
+
caller,
|
|
1249
|
+
),
|
|
1250
|
+
{ error: "not_found" },
|
|
1251
|
+
);
|
|
1252
|
+
assert.equal(reads, 0);
|
|
1253
|
+
});
|
|
1254
|
+
|
|
1255
|
+
void it("separates start and run claims, refusing an ambiguous member raw-key log without reading", async (t) => {
|
|
1256
|
+
const f = workflowRunFixture(t);
|
|
1257
|
+
await appBackend.call("start", f.input, f.context, f.caller);
|
|
1258
|
+
await appBackend.call("run", f.input, f.context, f.caller);
|
|
1259
|
+
assert.equal(f.starts(), 2);
|
|
1260
|
+
let reads = 0;
|
|
1261
|
+
const context = {
|
|
1262
|
+
...f.context,
|
|
1263
|
+
env: {
|
|
1264
|
+
COMPUTER: {
|
|
1265
|
+
...f.computer,
|
|
1266
|
+
read: (input: {
|
|
1267
|
+
path: string;
|
|
1268
|
+
byteOffset: number;
|
|
1269
|
+
byteLength: number;
|
|
1270
|
+
}) => {
|
|
1271
|
+
reads++;
|
|
1272
|
+
return Promise.resolve({
|
|
1273
|
+
path: input.path,
|
|
1274
|
+
byteOffset: input.byteOffset,
|
|
1275
|
+
content: "",
|
|
1276
|
+
encoding: "base64",
|
|
1277
|
+
truncated: false,
|
|
1278
|
+
});
|
|
1279
|
+
},
|
|
1280
|
+
},
|
|
1281
|
+
},
|
|
1282
|
+
};
|
|
1283
|
+
assert.deepEqual(
|
|
1284
|
+
await appBackend.call(
|
|
1285
|
+
"logs",
|
|
1286
|
+
{ run: f.caller.idempotencyKey },
|
|
1287
|
+
context,
|
|
1288
|
+
f.caller,
|
|
1289
|
+
),
|
|
1290
|
+
{ error: "ambiguous" },
|
|
1291
|
+
);
|
|
1292
|
+
assert.equal(reads, 0);
|
|
1293
|
+
});
|
|
1294
|
+
|
|
1295
|
+
void it("does not read an old unscoped claim whose raw key resembles a scoped identity", async (t) => {
|
|
1296
|
+
const f = workflowRunFixture(t);
|
|
1297
|
+
const scopedKey = JSON.stringify([
|
|
1298
|
+
f.caller.actor,
|
|
1299
|
+
"run",
|
|
1300
|
+
f.caller.idempotencyKey,
|
|
1301
|
+
]);
|
|
1302
|
+
// Seed the previous shipped unscoped ledger, including an arbitrary raw key.
|
|
1303
|
+
const database = new DatabaseSync(":memory:");
|
|
1304
|
+
t.after(() => {
|
|
1305
|
+
database.close();
|
|
1306
|
+
});
|
|
1307
|
+
database.exec(
|
|
1308
|
+
"CREATE TABLE shed_runs (requestKey TEXT PRIMARY KEY, computerId TEXT NOT NULL, command TEXT NOT NULL, cwd TEXT, processId TEXT, phase TEXT NOT NULL, exitCode INTEGER, logPath TEXT NOT NULL, gitAccess TEXT, passId TEXT, remote TEXT, expiresAt INTEGER)",
|
|
1309
|
+
);
|
|
1310
|
+
database
|
|
1311
|
+
.prepare(
|
|
1312
|
+
"INSERT INTO shed_runs (requestKey, computerId, command, phase, exitCode, logPath) VALUES (?, ?, ?, 'exited', 0, ?)",
|
|
1313
|
+
)
|
|
1314
|
+
.run(
|
|
1315
|
+
scopedKey,
|
|
1316
|
+
f.caller.computerId,
|
|
1317
|
+
"printf fresh",
|
|
1318
|
+
"/workspace/.fungi/runs/old-owner/out.log",
|
|
1319
|
+
);
|
|
1320
|
+
const runs = createShedRuns({
|
|
1321
|
+
exec(query, ...values) {
|
|
1322
|
+
const rows = database.prepare(query).all(...values);
|
|
1323
|
+
return { toArray: () => rows };
|
|
1324
|
+
},
|
|
1325
|
+
});
|
|
1326
|
+
let starts = 0;
|
|
1327
|
+
const context = {
|
|
1328
|
+
runs,
|
|
1329
|
+
env: {
|
|
1330
|
+
COMPUTER: {
|
|
1331
|
+
...f.computer,
|
|
1332
|
+
processStart: () => {
|
|
1333
|
+
starts++;
|
|
1334
|
+
return Promise.resolve(f.process);
|
|
1335
|
+
},
|
|
1336
|
+
},
|
|
1337
|
+
},
|
|
1338
|
+
};
|
|
1339
|
+
assert.deepEqual(
|
|
1340
|
+
await appBackend.call(
|
|
1341
|
+
"run",
|
|
1342
|
+
{ command: "printf fresh" },
|
|
1343
|
+
context,
|
|
1344
|
+
f.caller,
|
|
1345
|
+
),
|
|
1346
|
+
{ kind: "pending", retryAfterMs: 5000 },
|
|
1347
|
+
);
|
|
1348
|
+
assert.equal(starts, 1);
|
|
1349
|
+
});
|
|
1350
|
+
|
|
1351
|
+
void it("recovers run after a parsed pre-dispatch refusal without starting twice", async (t) => {
|
|
1352
|
+
const f = workflowRunFixture(t);
|
|
1353
|
+
let requests = 0;
|
|
1354
|
+
let starts = 0;
|
|
1355
|
+
const context = {
|
|
1356
|
+
...f.context,
|
|
1357
|
+
env: {
|
|
1358
|
+
COMPUTER: {
|
|
1359
|
+
...f.computer,
|
|
1360
|
+
processStart: () => {
|
|
1361
|
+
requests++;
|
|
1362
|
+
if (requests === 1) return Promise.resolve({ error: "unavailable" });
|
|
1363
|
+
starts++;
|
|
1364
|
+
return Promise.resolve(f.process);
|
|
1365
|
+
},
|
|
1366
|
+
},
|
|
1367
|
+
},
|
|
1368
|
+
};
|
|
1369
|
+
const input = { command: "sleep 120" };
|
|
1370
|
+
assert.deepEqual(await appBackend.call("run", input, context, f.caller), {
|
|
1371
|
+
error: "unavailable",
|
|
1372
|
+
});
|
|
1373
|
+
assert.deepEqual(await appBackend.call("run", input, context, f.caller), {
|
|
1374
|
+
kind: "pending",
|
|
1375
|
+
retryAfterMs: 5000,
|
|
1376
|
+
});
|
|
1377
|
+
assert.equal(starts, 1);
|
|
1378
|
+
});
|
|
1379
|
+
|
|
1380
|
+
void it("returns not_found without reading a log after start is refused", async (t) => {
|
|
1381
|
+
const f = workflowRunFixture(t);
|
|
1382
|
+
let reads = 0;
|
|
1383
|
+
const context = {
|
|
1384
|
+
...f.context,
|
|
1385
|
+
env: {
|
|
1386
|
+
COMPUTER: {
|
|
1387
|
+
...f.computer,
|
|
1388
|
+
processStart: () => Promise.resolve({ error: "revoked" }),
|
|
1389
|
+
read: () => {
|
|
1390
|
+
reads++;
|
|
1391
|
+
return Promise.reject(new Error("No log was written"));
|
|
1392
|
+
},
|
|
1393
|
+
},
|
|
1394
|
+
},
|
|
1395
|
+
};
|
|
1396
|
+
assert.deepEqual(
|
|
1397
|
+
await appBackend.call("start", { command: "sleep 120" }, context, f.caller),
|
|
1398
|
+
{ error: "revoked" },
|
|
1399
|
+
);
|
|
1400
|
+
assert.deepEqual(
|
|
1401
|
+
await appBackend.call(
|
|
1402
|
+
"logs",
|
|
1403
|
+
{ run: f.caller.idempotencyKey },
|
|
1404
|
+
context,
|
|
1405
|
+
f.caller,
|
|
1406
|
+
),
|
|
1407
|
+
{ error: "not_found" },
|
|
1408
|
+
);
|
|
1409
|
+
assert.equal(reads, 0);
|
|
1410
|
+
});
|
|
1411
|
+
|
|
1412
|
+
void it("refuses the same member raw-key log on a different Computer with zero reads", async (t) => {
|
|
1413
|
+
const f = workflowRunFixture(t);
|
|
1414
|
+
await appBackend.call("start", f.input, f.context, f.caller);
|
|
1415
|
+
let reads = 0;
|
|
1416
|
+
const context = {
|
|
1417
|
+
...f.context,
|
|
1418
|
+
env: {
|
|
1419
|
+
COMPUTER: {
|
|
1420
|
+
...f.computer,
|
|
1421
|
+
read: () => {
|
|
1422
|
+
reads++;
|
|
1423
|
+
return Promise.reject(new Error("Wrong Computer read"));
|
|
1424
|
+
},
|
|
1425
|
+
},
|
|
1426
|
+
},
|
|
1427
|
+
};
|
|
1428
|
+
assert.deepEqual(
|
|
1429
|
+
await appBackend.call("logs", { run: f.caller.idempotencyKey }, context, {
|
|
1430
|
+
...f.caller,
|
|
1431
|
+
computerId: "computer_other",
|
|
1432
|
+
}),
|
|
1433
|
+
{ error: "not_found" },
|
|
1434
|
+
);
|
|
1435
|
+
assert.equal(reads, 0);
|
|
1436
|
+
});
|
|
1437
|
+
|
|
1438
|
+
const refusedActors: HostActor[] = [
|
|
1439
|
+
{ kind: "agent", agentId: "agent_other" },
|
|
1440
|
+
{ kind: "team-key", keyId: "key_other" },
|
|
1441
|
+
];
|
|
1442
|
+
for (const actor of refusedActors)
|
|
1443
|
+
for (const method of ["start", "run"]) {
|
|
1444
|
+
void it(`refuses ${actor.kind} ${method} before a native start or dangling claim`, async (t) => {
|
|
1445
|
+
const f = workflowRunFixture(t);
|
|
1446
|
+
let starts = 0;
|
|
1447
|
+
const context = {
|
|
1448
|
+
...f.context,
|
|
1449
|
+
env: {
|
|
1450
|
+
COMPUTER: {
|
|
1451
|
+
...f.computer,
|
|
1452
|
+
processStart: () => {
|
|
1453
|
+
starts++;
|
|
1454
|
+
return Promise.resolve(f.process);
|
|
1455
|
+
},
|
|
1456
|
+
},
|
|
1457
|
+
},
|
|
1458
|
+
};
|
|
1459
|
+
assert.deepEqual(
|
|
1460
|
+
await appBackend.call(method, { command: "sleep 120" }, context, {
|
|
1461
|
+
...f.caller,
|
|
1462
|
+
actor,
|
|
1463
|
+
}),
|
|
1464
|
+
{ error: "not_granted" },
|
|
1465
|
+
);
|
|
1466
|
+
assert.equal(starts, 0);
|
|
1467
|
+
assert.equal(
|
|
1468
|
+
f.sql.exec("SELECT * FROM shed_process_claims").toArray().length,
|
|
1469
|
+
0,
|
|
1470
|
+
);
|
|
1471
|
+
});
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
void it("stops only a process claimed by this actor on this Computer", async (t) => {
|
|
1475
|
+
const f = workflowRunFixture(t);
|
|
1476
|
+
await appBackend.call("start", f.input, f.context, f.caller);
|
|
1477
|
+
let kills = 0;
|
|
1478
|
+
const reply = { process: f.process, signalled: true };
|
|
1479
|
+
const context = {
|
|
1480
|
+
...f.context,
|
|
1481
|
+
env: {
|
|
1482
|
+
COMPUTER: {
|
|
1483
|
+
...f.computer,
|
|
1484
|
+
processKill: () => {
|
|
1485
|
+
kills++;
|
|
1486
|
+
return Promise.resolve(reply);
|
|
1487
|
+
},
|
|
1488
|
+
},
|
|
1489
|
+
},
|
|
1490
|
+
};
|
|
1491
|
+
assert.deepEqual(
|
|
1492
|
+
await appBackend.call("stop", { id: f.process.id }, context, {
|
|
1493
|
+
...f.caller,
|
|
1494
|
+
actor: { kind: "member", memberId: "member_other" },
|
|
1495
|
+
}),
|
|
1496
|
+
{ error: "not_found" },
|
|
1497
|
+
);
|
|
1498
|
+
assert.deepEqual(
|
|
1499
|
+
await appBackend.call(
|
|
1500
|
+
"stop",
|
|
1501
|
+
{ id: mintComputerProcessId() },
|
|
1502
|
+
context,
|
|
1503
|
+
f.caller,
|
|
1504
|
+
),
|
|
1505
|
+
{ error: "not_found" },
|
|
1506
|
+
);
|
|
1507
|
+
assert.deepEqual(
|
|
1508
|
+
await appBackend.call("stop", { id: f.process.id }, context, {
|
|
1509
|
+
...f.caller,
|
|
1510
|
+
computerId: "computer_other",
|
|
1511
|
+
}),
|
|
1512
|
+
{ error: "not_found" },
|
|
1513
|
+
);
|
|
1514
|
+
assert.equal(kills, 0);
|
|
1515
|
+
assert.deepEqual(
|
|
1516
|
+
await appBackend.call("stop", { id: f.process.id }, context, f.caller),
|
|
1517
|
+
reply,
|
|
1518
|
+
);
|
|
1519
|
+
assert.equal(kills, 1);
|
|
1520
|
+
});
|
|
1521
|
+
|
|
1522
|
+
void it("keeps the bearer Git remote out of native stored command metadata", async (t) => {
|
|
1523
|
+
const f = workflowRunFixture(t);
|
|
1524
|
+
let storedCommand = "";
|
|
1525
|
+
let delivered = "";
|
|
1526
|
+
const context = {
|
|
1527
|
+
...f.context,
|
|
1528
|
+
env: {
|
|
1529
|
+
COMPUTER: {
|
|
1530
|
+
...f.computer,
|
|
1531
|
+
write: (input: { path: string; content: string }) => {
|
|
1532
|
+
delivered = input.content;
|
|
1533
|
+
return Promise.resolve({
|
|
1534
|
+
path: input.path,
|
|
1535
|
+
bytes: new TextEncoder().encode(input.content).byteLength,
|
|
1536
|
+
});
|
|
1537
|
+
},
|
|
1538
|
+
processStart: (input: { command: string; cwd?: string }) => {
|
|
1539
|
+
storedCommand = input.command;
|
|
1540
|
+
return Promise.resolve({ ...f.process, command: storedCommand });
|
|
1541
|
+
},
|
|
1542
|
+
},
|
|
1543
|
+
},
|
|
1544
|
+
};
|
|
1545
|
+
await appBackend.call("start", f.input, context, f.caller);
|
|
1546
|
+
assert.equal(storedCommand.includes(f.remote), false);
|
|
1547
|
+
assert.equal(delivered, `${f.remote}\n`);
|
|
1548
|
+
});
|
|
1549
|
+
|
|
1550
|
+
void it("does not reuse an expired Git remote while redispatching an uncertain start", async (t) => {
|
|
1551
|
+
const f = workflowRunFixture(t);
|
|
1552
|
+
let starts = 0;
|
|
1553
|
+
let writes = 0;
|
|
1554
|
+
const write = f.computer.write;
|
|
1555
|
+
f.computer.write = (input) => {
|
|
1556
|
+
writes++;
|
|
1557
|
+
return write(input);
|
|
1558
|
+
};
|
|
1559
|
+
f.computer.processStart = () => {
|
|
1560
|
+
starts++;
|
|
1561
|
+
return Promise.reject(new Error("Reply lost"));
|
|
1562
|
+
};
|
|
1563
|
+
await assert.rejects(
|
|
1564
|
+
appBackend.call("start", f.input, f.context, f.caller),
|
|
1565
|
+
/Reply lost/,
|
|
1566
|
+
);
|
|
1567
|
+
const writesBeforeExpiry = writes;
|
|
1568
|
+
f.setClock(61000);
|
|
1569
|
+
await assert.rejects(
|
|
1570
|
+
appBackend.call("start", f.input, f.context, f.caller),
|
|
1571
|
+
/expired/,
|
|
1572
|
+
);
|
|
1573
|
+
assert.equal(starts, 1);
|
|
1574
|
+
assert.equal(writes, writesBeforeExpiry);
|
|
1575
|
+
});
|
|
1576
|
+
|
|
1577
|
+
void it("retires full claims with the native window and never starts a second process for a pruned uncertain key", async (t) => {
|
|
1578
|
+
const f = workflowRunFixture(t);
|
|
1579
|
+
const retained = new Map<string, ComputerProcess>();
|
|
1580
|
+
let starts = 0;
|
|
1581
|
+
const computer = {
|
|
1582
|
+
processStart(input: { command: string; cwd?: string }) {
|
|
1583
|
+
let process = retained.get(input.command);
|
|
1584
|
+
if (!process) {
|
|
1585
|
+
starts++;
|
|
1586
|
+
process = {
|
|
1587
|
+
...f.process,
|
|
1588
|
+
id: mintComputerProcessId(),
|
|
1589
|
+
command: input.command,
|
|
1590
|
+
state: "exited",
|
|
1591
|
+
exitCode: 0,
|
|
1592
|
+
startedAt: starts,
|
|
1593
|
+
endedAt: starts,
|
|
1594
|
+
};
|
|
1595
|
+
retained.set(input.command, process);
|
|
1596
|
+
while (retained.size > 64) {
|
|
1597
|
+
const oldest = retained.keys().next().value;
|
|
1598
|
+
assert.notEqual(oldest, undefined);
|
|
1599
|
+
if (oldest !== undefined) retained.delete(oldest);
|
|
1600
|
+
}
|
|
1601
|
+
if (starts === 1) return Promise.reject(new Error("Reply lost"));
|
|
1602
|
+
}
|
|
1603
|
+
return Promise.resolve(process);
|
|
1604
|
+
},
|
|
1605
|
+
processes: () => Promise.resolve({ processes: [...retained.values()] }),
|
|
1606
|
+
};
|
|
1607
|
+
const context = { ...f.context, env: { COMPUTER: computer } };
|
|
1608
|
+
const input = { command: "printf done" };
|
|
1609
|
+
await assert.rejects(
|
|
1610
|
+
appBackend.call("start", input, context, f.caller),
|
|
1611
|
+
/Reply lost/,
|
|
1612
|
+
);
|
|
1613
|
+
for (let index = 0; index < 64; index++)
|
|
1614
|
+
await appBackend.call("start", input, context, {
|
|
1615
|
+
...f.caller,
|
|
1616
|
+
idempotencyKey: `fresh-${String(index)}`,
|
|
1617
|
+
});
|
|
1618
|
+
assert.deepEqual(await appBackend.call("start", input, context, f.caller), {
|
|
1619
|
+
error: "not_found",
|
|
1620
|
+
});
|
|
1621
|
+
assert.equal(starts, 65);
|
|
1622
|
+
assert.equal(
|
|
1623
|
+
f.sql.exec("SELECT * FROM shed_process_claims").toArray().length,
|
|
1624
|
+
64,
|
|
1625
|
+
);
|
|
1626
|
+
});
|
|
1627
|
+
|
|
1628
|
+
for (const mode of ["start", "run"] as const)
|
|
1629
|
+
void it(`preserves a running ${mode} claim omitted from the global newest-64 listing`, async (t) => {
|
|
1630
|
+
const f = workflowRunFixture(t);
|
|
1631
|
+
const owned = new Map<string, ComputerProcess>();
|
|
1632
|
+
const foreign = Array.from({ length: 64 }, (_, index) => ({
|
|
1633
|
+
...f.process,
|
|
1634
|
+
id: mintComputerProcessId(),
|
|
1635
|
+
startedAt: 1000 + index,
|
|
1636
|
+
state: "exited" as const,
|
|
1637
|
+
exitCode: 0,
|
|
1638
|
+
endedAt: 2000,
|
|
1639
|
+
}));
|
|
1640
|
+
let truncated = false;
|
|
1641
|
+
const computer = {
|
|
1642
|
+
processStart: (input: { command: string }) => {
|
|
1643
|
+
const process: ComputerProcess = {
|
|
1644
|
+
...f.process,
|
|
1645
|
+
id: mintComputerProcessId(),
|
|
1646
|
+
command: input.command,
|
|
1647
|
+
state: owned.size === 0 ? "running" : "exited",
|
|
1648
|
+
exitCode: owned.size === 0 ? null : 0,
|
|
1649
|
+
endedAt: owned.size === 0 ? null : 2000,
|
|
1650
|
+
};
|
|
1651
|
+
owned.set(input.command, process);
|
|
1652
|
+
return Promise.resolve(process);
|
|
1653
|
+
},
|
|
1654
|
+
processes: () =>
|
|
1655
|
+
Promise.resolve({
|
|
1656
|
+
processes: truncated ? foreign : [...owned.values()].slice(0, 64),
|
|
1657
|
+
}),
|
|
1658
|
+
processKill: () =>
|
|
1659
|
+
Promise.resolve({ process: f.process, signalled: true }),
|
|
1660
|
+
};
|
|
1661
|
+
const context = { ...f.context, env: { COMPUTER: computer } };
|
|
1662
|
+
const input = { command: "sleep 120" };
|
|
1663
|
+
const first = await appBackend.call(mode, input, context, f.caller);
|
|
1664
|
+
const firstProcess = [...owned.values()][0];
|
|
1665
|
+
assert.ok(firstProcess);
|
|
1666
|
+
// Simulate a scope at capacity; other scopes displace its oldest live record.
|
|
1667
|
+
for (let index = 0; index < 63; index++) {
|
|
1668
|
+
await appBackend.call(mode, input, context, {
|
|
1669
|
+
...f.caller,
|
|
1670
|
+
idempotencyKey: `owned-${String(index)}`,
|
|
1671
|
+
});
|
|
1672
|
+
}
|
|
1673
|
+
truncated = true;
|
|
1674
|
+
await appBackend.call(mode, input, context, {
|
|
1675
|
+
...f.caller,
|
|
1676
|
+
idempotencyKey: "next-call",
|
|
1677
|
+
});
|
|
1678
|
+
assert.equal(
|
|
1679
|
+
f.context.runs.owns(firstProcess.id, f.caller.computerId, f.caller.actor),
|
|
1680
|
+
true,
|
|
1681
|
+
);
|
|
1682
|
+
assert.ok(
|
|
1683
|
+
"path" in
|
|
1684
|
+
f.context.runs.logPath(
|
|
1685
|
+
f.caller.idempotencyKey,
|
|
1686
|
+
f.caller.computerId,
|
|
1687
|
+
f.caller.actor,
|
|
1688
|
+
),
|
|
1689
|
+
);
|
|
1690
|
+
assert.deepEqual(
|
|
1691
|
+
await appBackend.call("stop", { id: firstProcess.id }, context, f.caller),
|
|
1692
|
+
{ process: f.process, signalled: true },
|
|
1693
|
+
);
|
|
1694
|
+
truncated = false;
|
|
1695
|
+
assert.deepEqual(
|
|
1696
|
+
await appBackend.call(mode, input, context, f.caller),
|
|
1697
|
+
first,
|
|
1698
|
+
);
|
|
1699
|
+
});
|
|
1700
|
+
|
|
1701
|
+
void it("bounds retired markers per member and method to the latest 64 retired calls", async (t) => {
|
|
1702
|
+
const f = workflowRunFixture(t);
|
|
1703
|
+
let starts = 0;
|
|
1704
|
+
const computer = {
|
|
1705
|
+
processStart: (input: { command: string }) => {
|
|
1706
|
+
starts++;
|
|
1707
|
+
return Promise.resolve({
|
|
1708
|
+
...f.process,
|
|
1709
|
+
id: mintComputerProcessId(),
|
|
1710
|
+
command: input.command,
|
|
1711
|
+
state: "exited",
|
|
1712
|
+
exitCode: 0,
|
|
1713
|
+
startedAt: starts,
|
|
1714
|
+
endedAt: starts,
|
|
1715
|
+
});
|
|
1716
|
+
},
|
|
1717
|
+
processes: () => Promise.resolve({ processes: [] }),
|
|
1718
|
+
};
|
|
1719
|
+
const context = { ...f.context, env: { COMPUTER: computer } };
|
|
1720
|
+
for (let index = 0; index < 260; index++) {
|
|
1721
|
+
await appBackend.call("start", { command: "true" }, context, {
|
|
1722
|
+
...f.caller,
|
|
1723
|
+
idempotencyKey: `bounded-${String(index)}`,
|
|
1724
|
+
});
|
|
1725
|
+
}
|
|
1726
|
+
assert.equal(
|
|
1727
|
+
f.sql.exec("SELECT * FROM shed_retired_claim_keys").toArray().length,
|
|
1728
|
+
64,
|
|
1729
|
+
);
|
|
1730
|
+
assert.deepEqual(
|
|
1731
|
+
await appBackend.call("start", { command: "true" }, context, {
|
|
1732
|
+
...f.caller,
|
|
1733
|
+
idempotencyKey: "bounded-195",
|
|
1734
|
+
}),
|
|
1735
|
+
{ error: "not_found" },
|
|
1736
|
+
);
|
|
1737
|
+
assert.equal(starts, 260);
|
|
1738
|
+
});
|
|
1739
|
+
|
|
1740
|
+
void it("keeps the native command identity stable across a Git refusal and re-mint", async (t) => {
|
|
1741
|
+
const f = workflowRunFixture(t);
|
|
1742
|
+
const accepted = new Map<string, ComputerProcess>();
|
|
1743
|
+
let calls = 0;
|
|
1744
|
+
const processStart = (input: { command: string }) => {
|
|
1745
|
+
calls++;
|
|
1746
|
+
let process = accepted.get(input.command);
|
|
1747
|
+
if (!process) {
|
|
1748
|
+
process = { ...f.process, command: input.command };
|
|
1749
|
+
accepted.set(input.command, process);
|
|
1750
|
+
}
|
|
1751
|
+
// Simulate a refusal after acceptance; the native key/command pair must replay.
|
|
1752
|
+
return Promise.resolve(calls === 1 ? { error: "unavailable" } : process);
|
|
1753
|
+
};
|
|
1754
|
+
const context = {
|
|
1755
|
+
...f.context,
|
|
1756
|
+
env: { COMPUTER: { ...f.computer, processStart } },
|
|
1757
|
+
};
|
|
1758
|
+
assert.deepEqual(await appBackend.call("start", f.input, context, f.caller), {
|
|
1759
|
+
error: "unavailable",
|
|
1760
|
+
});
|
|
1761
|
+
f.setPass({
|
|
1762
|
+
passId: "pass_reminted",
|
|
1763
|
+
remote: "https://git.team.internal/workflow/pass_reminted/app_source.git",
|
|
1764
|
+
expiresAt: 61000,
|
|
1765
|
+
});
|
|
1766
|
+
await appBackend.call("start", f.input, context, f.caller);
|
|
1767
|
+
assert.equal(f.mints(), 2);
|
|
1768
|
+
assert.equal(accepted.size, 1);
|
|
1769
|
+
assert.equal(calls, 2);
|
|
1770
|
+
});
|