@fungi.computer/shed 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +122 -2
- package/backend/gills.ts +74 -0
- package/backend/index.ts +15 -0
- package/backend/methods.ts +319 -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/present.test.ts +40 -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/index-2RtcU_CN.css +1 -0
- package/dist/assets/index-bW5A7RuN.js +1769 -0
- package/dist/index.html +15 -0
- package/package.json +159 -3
package/backend/runs.ts
ADDED
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
import { Hex } from "effect/encoding";
|
|
3
|
+
import type { HostActor } from "@fungi.computer/puffball/app-method-runtime";
|
|
4
|
+
import {
|
|
5
|
+
workflowGitPassResultSchema,
|
|
6
|
+
type WorkflowGitPassResult,
|
|
7
|
+
} from "@fungi.computer/agar/computer-contracts";
|
|
8
|
+
import { resolvedGitAccessSchema } from "@fungi.computer/braid/git-access";
|
|
9
|
+
import { pending } from "@fungi.computer/agar/backend-definition";
|
|
10
|
+
import type { WhistleJsonValue } from "@fungi.computer/whistle";
|
|
11
|
+
import { AppMethodRefusal } from "@fungi.computer/agar/backend";
|
|
12
|
+
import {
|
|
13
|
+
computerProcessSchema,
|
|
14
|
+
maximumProcessRecords,
|
|
15
|
+
writeOutputSchema,
|
|
16
|
+
type ComputerFileWriteInput,
|
|
17
|
+
processStartInputSchema,
|
|
18
|
+
processListOutputSchema,
|
|
19
|
+
type ComputerProcess,
|
|
20
|
+
} from "@fungi.computer/computer/operations";
|
|
21
|
+
import {
|
|
22
|
+
runCommandInputSchema,
|
|
23
|
+
runCommandResultSchema,
|
|
24
|
+
shedComputerRefusalSchema,
|
|
25
|
+
} from "../src/light-shell/run-command-contract.js";
|
|
26
|
+
|
|
27
|
+
/** What a Git command needs from a pass: its remote and any header credential. */
|
|
28
|
+
type GitCredential = Pick<WorkflowGitPassResult, "remote" | "bearer">;
|
|
29
|
+
const processInputSchema = processStartInputSchema.omit({
|
|
30
|
+
requestId: true,
|
|
31
|
+
requestScope: true,
|
|
32
|
+
});
|
|
33
|
+
export const shedRunInputSchema = processInputSchema
|
|
34
|
+
.omit({ port: true })
|
|
35
|
+
.extend({
|
|
36
|
+
gitAccess: resolvedGitAccessSchema.optional(),
|
|
37
|
+
});
|
|
38
|
+
export const shedStartInputSchema = processInputSchema.extend({
|
|
39
|
+
gitAccess: resolvedGitAccessSchema.optional(),
|
|
40
|
+
});
|
|
41
|
+
export type ShedStartInput = z.output<typeof shedStartInputSchema>;
|
|
42
|
+
export type ShedRunInput = z.output<typeof shedRunInputSchema>;
|
|
43
|
+
|
|
44
|
+
/** Facet-owned records associate one trusted logical call with one native process. */
|
|
45
|
+
export type ShedSql = {
|
|
46
|
+
exec(
|
|
47
|
+
query: string,
|
|
48
|
+
...values: (string | number | null)[]
|
|
49
|
+
): { toArray(): unknown[] };
|
|
50
|
+
};
|
|
51
|
+
export type ShedComputer = Readonly<{
|
|
52
|
+
workflowGitPass?:
|
|
53
|
+
| ((input: Record<string, never>) => Promise<z.core.util.JSONType>)
|
|
54
|
+
| undefined;
|
|
55
|
+
write?:
|
|
56
|
+
| ((input: ComputerFileWriteInput) => Promise<z.core.util.JSONType>)
|
|
57
|
+
| undefined;
|
|
58
|
+
processStart(input: {
|
|
59
|
+
command: string;
|
|
60
|
+
cwd?: string | undefined;
|
|
61
|
+
port?: number | undefined;
|
|
62
|
+
}): Promise<z.core.util.JSONType>;
|
|
63
|
+
processes(input: Record<string, never>): Promise<z.core.util.JSONType>;
|
|
64
|
+
}>;
|
|
65
|
+
const rowSchema = z.strictObject({
|
|
66
|
+
requestKey: z.string(),
|
|
67
|
+
computerId: z.string(),
|
|
68
|
+
command: z.string(),
|
|
69
|
+
cwd: z.string().nullable(),
|
|
70
|
+
processId: z.string().nullable(),
|
|
71
|
+
phase: z.enum(["starting", "running", "exited"]),
|
|
72
|
+
startedAt: z.number().int().nullable(),
|
|
73
|
+
exitCode: z.number().int().nullable(),
|
|
74
|
+
logPath: z.string(),
|
|
75
|
+
gitAccess: z.string().nullable(),
|
|
76
|
+
passId: z.string().nullable(),
|
|
77
|
+
remote: z.string().nullable(),
|
|
78
|
+
expiresAt: z.number().int().nullable(),
|
|
79
|
+
});
|
|
80
|
+
// A header-admitted pass reaches git only as command-local
|
|
81
|
+
// http.<remote>.extraHeader config, read from a one-shot file the command
|
|
82
|
+
// deletes; neither the command nor any repository config ever holds it.
|
|
83
|
+
function gitCredentialFile(pass: GitCredential) {
|
|
84
|
+
return pass.bearer === undefined
|
|
85
|
+
? `${pass.remote}\n`
|
|
86
|
+
: `${pass.remote}\n${pass.bearer}\n`;
|
|
87
|
+
}
|
|
88
|
+
function gitCredentialCommand(
|
|
89
|
+
remotePath: string,
|
|
90
|
+
pass: GitCredential,
|
|
91
|
+
command: string,
|
|
92
|
+
) {
|
|
93
|
+
const read = `{ IFS= read -r _fungi_git_remote && IFS= read -r _fungi_git_bearer; } < ${quote(remotePath)}; rm -f -- ${quote(remotePath)}; test -n "$_fungi_git_remote"`;
|
|
94
|
+
return pass.bearer === undefined
|
|
95
|
+
? `${read} && FUNGI_GIT_REMOTE="$_fungi_git_remote" bash -c ${quote(command)}`
|
|
96
|
+
: `${read} && test -n "$_fungi_git_bearer" && FUNGI_GIT_REMOTE="$_fungi_git_remote" GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0="http.$_fungi_git_remote.extraHeader" GIT_CONFIG_VALUE_0="Authorization: Bearer $_fungi_git_bearer" bash -c ${quote(command)}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export const shedRunCommandInputSchema = runCommandInputSchema.extend({
|
|
100
|
+
gitAccess: resolvedGitAccessSchema.optional(),
|
|
101
|
+
});
|
|
102
|
+
export type ShedRunCommandInput = z.output<typeof shedRunCommandInputSchema>;
|
|
103
|
+
export type ShedRunCommandResult = z.output<typeof runCommandResultSchema>;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* One bounded exec that presents the step's Workflow Git pass exactly as
|
|
107
|
+
* `run` does: `$FUNGI_GIT_REMOTE` plus command-local header config. Without
|
|
108
|
+
* `gitAccess` it is the plain exec.
|
|
109
|
+
*/
|
|
110
|
+
export async function runShedCommand(
|
|
111
|
+
input: ShedRunCommandInput,
|
|
112
|
+
computer: Readonly<{
|
|
113
|
+
exec(
|
|
114
|
+
input: Omit<ShedRunCommandInput, "gitAccess">,
|
|
115
|
+
): Promise<WhistleJsonValue>;
|
|
116
|
+
workflowGitPass?: ShedComputer["workflowGitPass"];
|
|
117
|
+
write?: ShedComputer["write"];
|
|
118
|
+
}>,
|
|
119
|
+
now: () => number = Date.now,
|
|
120
|
+
): Promise<ShedRunCommandResult> {
|
|
121
|
+
const { gitAccess, ...exec } = input;
|
|
122
|
+
if (gitAccess === undefined)
|
|
123
|
+
return runCommandResultSchema.parse(await computer.exec(exec));
|
|
124
|
+
if (!computer.workflowGitPass || !computer.write)
|
|
125
|
+
return shedComputerRefusalSchema.parse({ error: "not_granted" });
|
|
126
|
+
const minted = z
|
|
127
|
+
.union([workflowGitPassResultSchema, shedComputerRefusalSchema])
|
|
128
|
+
.parse(await computer.workflowGitPass({}));
|
|
129
|
+
if ("error" in minted) return minted;
|
|
130
|
+
if (minted.expiresAt <= now() + 5000)
|
|
131
|
+
throw new Error("Workflow Git pass expires before the command starts");
|
|
132
|
+
const remotePath = `/workspace/.fungi-git-${Hex.encode(crypto.getRandomValues(new Uint8Array(16)))}.remote`;
|
|
133
|
+
const content = gitCredentialFile(minted);
|
|
134
|
+
const written = await computer.write({ path: remotePath, content });
|
|
135
|
+
const refused = shedComputerRefusalSchema.safeParse(written);
|
|
136
|
+
if (refused.success) return refused.data;
|
|
137
|
+
const reply = writeOutputSchema.parse(written);
|
|
138
|
+
if (
|
|
139
|
+
reply.path !== remotePath ||
|
|
140
|
+
reply.bytes !== new TextEncoder().encode(content).byteLength
|
|
141
|
+
)
|
|
142
|
+
throw new Error("Invalid Computer credential file reply");
|
|
143
|
+
return runCommandResultSchema.parse(
|
|
144
|
+
await computer.exec({
|
|
145
|
+
...exec,
|
|
146
|
+
command: gitCredentialCommand(remotePath, minted, exec.command),
|
|
147
|
+
}),
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function claimKey(actor: HostActor, method: "start" | "run", key: string) {
|
|
152
|
+
return JSON.stringify([actor, method, key]);
|
|
153
|
+
}
|
|
154
|
+
const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
|
|
155
|
+
// A command that ran to completion is a normal result at any exit code,
|
|
156
|
+
// zero or not: the caller (a Workflow step, for example) tells "the command
|
|
157
|
+
// failed" from "the platform failed" only if a non-zero exit comes back as
|
|
158
|
+
// a value, never as a thrown dispatch failure. Only a genuinely missing
|
|
159
|
+
// exit status -- the Computer's own record of this process was replaced or
|
|
160
|
+
// expired before it observed the exit -- is a platform-level failure to
|
|
161
|
+
// report completion at all, and stays an exception.
|
|
162
|
+
function completion(exitCode: number | null, logPath: string) {
|
|
163
|
+
if (exitCode === null)
|
|
164
|
+
throw new Error(`Process exit status is unavailable. Log: ${logPath}`);
|
|
165
|
+
return { exitCode, logPath };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The Computer owns execution and exit state; Shed only remembers the call-to-process link. */
|
|
169
|
+
export function createShedRuns(sql: ShedSql, now: () => number = Date.now) {
|
|
170
|
+
// Unscoped records are not current claims; do not read or import the old ledger.
|
|
171
|
+
sql.exec(
|
|
172
|
+
"CREATE TABLE IF NOT EXISTS shed_process_claims (requestKey TEXT PRIMARY KEY, computerId TEXT NOT NULL, command TEXT NOT NULL, cwd TEXT, processId TEXT, phase TEXT NOT NULL, startedAt INTEGER, exitCode INTEGER, logPath TEXT NOT NULL, gitAccess TEXT, passId TEXT, remote TEXT, expiresAt INTEGER)",
|
|
173
|
+
);
|
|
174
|
+
sql.exec(
|
|
175
|
+
"CREATE TABLE IF NOT EXISTS shed_retired_claim_keys (requestKey TEXT PRIMARY KEY)",
|
|
176
|
+
);
|
|
177
|
+
sql.exec(
|
|
178
|
+
"DELETE FROM shed_process_claims WHERE requestKey IN (SELECT requestKey FROM shed_retired_claim_keys)",
|
|
179
|
+
);
|
|
180
|
+
const starting = new Map<string, number | null>();
|
|
181
|
+
function retired(requestKey: string) {
|
|
182
|
+
return (
|
|
183
|
+
sql
|
|
184
|
+
.exec(
|
|
185
|
+
"SELECT requestKey FROM shed_retired_claim_keys WHERE requestKey=?",
|
|
186
|
+
requestKey,
|
|
187
|
+
)
|
|
188
|
+
.toArray().length !== 0
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
function scopeRows(
|
|
192
|
+
actor: HostActor,
|
|
193
|
+
mode: "start" | "run",
|
|
194
|
+
computerId: string,
|
|
195
|
+
) {
|
|
196
|
+
return z
|
|
197
|
+
.array(rowSchema)
|
|
198
|
+
.parse(
|
|
199
|
+
sql
|
|
200
|
+
.exec(
|
|
201
|
+
"SELECT * FROM shed_process_claims WHERE computerId=? AND json_extract(requestKey,'$[0]')=? AND json_extract(requestKey,'$[1]')=? ORDER BY startedAt ASC, processId ASC, rowid ASC",
|
|
202
|
+
computerId,
|
|
203
|
+
JSON.stringify(actor),
|
|
204
|
+
mode,
|
|
205
|
+
)
|
|
206
|
+
.toArray(),
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
function retire(requestKey: string, actor: HostActor, mode: "start" | "run") {
|
|
210
|
+
// Record rejection first, so an interruption cannot turn a retired key into a new start.
|
|
211
|
+
sql.exec(
|
|
212
|
+
"INSERT OR IGNORE INTO shed_retired_claim_keys (requestKey) VALUES (?)",
|
|
213
|
+
requestKey,
|
|
214
|
+
);
|
|
215
|
+
sql.exec("DELETE FROM shed_process_claims WHERE requestKey=?", requestKey);
|
|
216
|
+
// Replay refusal covers the latest 64 retired calls per member/method.
|
|
217
|
+
// Older opaque keys are outside the retained replay horizon, like native history.
|
|
218
|
+
sql.exec(
|
|
219
|
+
"DELETE FROM shed_retired_claim_keys WHERE rowid IN (SELECT rowid FROM shed_retired_claim_keys WHERE json_extract(requestKey,'$[0]')=? AND json_extract(requestKey,'$[1]')=? ORDER BY rowid DESC LIMIT -1 OFFSET ?)",
|
|
220
|
+
JSON.stringify(actor),
|
|
221
|
+
mode,
|
|
222
|
+
maximumProcessRecords,
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
function trimScope(
|
|
226
|
+
actor: HostActor,
|
|
227
|
+
mode: "start" | "run",
|
|
228
|
+
computerId: string,
|
|
229
|
+
current: string,
|
|
230
|
+
) {
|
|
231
|
+
const rows = scopeRows(actor, mode, computerId);
|
|
232
|
+
let remaining = rows.length;
|
|
233
|
+
for (const row of rows) {
|
|
234
|
+
if (remaining <= maximumProcessRecords) break;
|
|
235
|
+
if (
|
|
236
|
+
row.requestKey === current ||
|
|
237
|
+
row.phase === "running" ||
|
|
238
|
+
starting.has(row.requestKey)
|
|
239
|
+
)
|
|
240
|
+
continue;
|
|
241
|
+
retire(row.requestKey, actor, mode);
|
|
242
|
+
remaining--;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
async function refreshScope(
|
|
246
|
+
actor: HostActor,
|
|
247
|
+
mode: "start" | "run",
|
|
248
|
+
computerId: string,
|
|
249
|
+
computer: ShedComputer,
|
|
250
|
+
) {
|
|
251
|
+
const rows = scopeRows(actor, mode, computerId);
|
|
252
|
+
if (rows.length < maximumProcessRecords) return null;
|
|
253
|
+
const reply = await computer.processes({});
|
|
254
|
+
const refused = shedComputerRefusalSchema.safeParse(reply);
|
|
255
|
+
if (refused.success) return refused.data;
|
|
256
|
+
const inventory = processListOutputSchema.parse(reply);
|
|
257
|
+
for (const row of rows) {
|
|
258
|
+
if (row.processId === null || starting.has(row.requestKey)) continue;
|
|
259
|
+
const process = inventory.processes.find(
|
|
260
|
+
(item) => item.id === row.processId,
|
|
261
|
+
);
|
|
262
|
+
if (process) observe(row.requestKey, process, row.logPath);
|
|
263
|
+
}
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
function read(requestKey: string) {
|
|
267
|
+
if (retired(requestKey)) return null;
|
|
268
|
+
const row = sql
|
|
269
|
+
.exec("SELECT * FROM shed_process_claims WHERE requestKey=?", requestKey)
|
|
270
|
+
.toArray()[0];
|
|
271
|
+
return row === undefined ? null : rowSchema.parse(row);
|
|
272
|
+
}
|
|
273
|
+
function observe(
|
|
274
|
+
requestKey: string,
|
|
275
|
+
process: ComputerProcess,
|
|
276
|
+
logPath: string,
|
|
277
|
+
) {
|
|
278
|
+
sql.exec(
|
|
279
|
+
"UPDATE shed_process_claims SET processId=?,phase=?,exitCode=?,startedAt=? WHERE requestKey=?",
|
|
280
|
+
process.id,
|
|
281
|
+
process.state === "exited" ? "exited" : "running",
|
|
282
|
+
process.exitCode,
|
|
283
|
+
process.startedAt,
|
|
284
|
+
requestKey,
|
|
285
|
+
);
|
|
286
|
+
return process.port === null
|
|
287
|
+
? { process, logPath }
|
|
288
|
+
: { process, logPath, port: process.port };
|
|
289
|
+
}
|
|
290
|
+
function verify(
|
|
291
|
+
prior: z.output<typeof rowSchema>,
|
|
292
|
+
input: ShedRunInput,
|
|
293
|
+
computerId: string,
|
|
294
|
+
) {
|
|
295
|
+
if (
|
|
296
|
+
prior.computerId !== computerId ||
|
|
297
|
+
prior.command !== input.command ||
|
|
298
|
+
prior.cwd !== (input.cwd ?? null) ||
|
|
299
|
+
prior.gitAccess !==
|
|
300
|
+
(input.gitAccess === undefined ? null : JSON.stringify(input.gitAccess))
|
|
301
|
+
)
|
|
302
|
+
throw new AppMethodRefusal(
|
|
303
|
+
"conflict",
|
|
304
|
+
"Run identity conflicts with its original command, Git scope or Computer",
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
async function claim(
|
|
308
|
+
input: ShedStartInput,
|
|
309
|
+
computerId: string,
|
|
310
|
+
rawKey: string,
|
|
311
|
+
actor: HostActor,
|
|
312
|
+
computer: ShedComputer,
|
|
313
|
+
mode: "start" | "run",
|
|
314
|
+
) {
|
|
315
|
+
if (actor.kind !== "member")
|
|
316
|
+
return shedComputerRefusalSchema.parse({ error: "not_granted" });
|
|
317
|
+
const requestKey = claimKey(actor, mode, rawKey);
|
|
318
|
+
if (retired(requestKey))
|
|
319
|
+
return shedComputerRefusalSchema.parse({ error: "not_found" });
|
|
320
|
+
const unavailable = await refreshScope(actor, mode, computerId, computer);
|
|
321
|
+
if (unavailable !== null) return unavailable;
|
|
322
|
+
if (retired(requestKey))
|
|
323
|
+
return shedComputerRefusalSchema.parse({ error: "not_found" });
|
|
324
|
+
const digest = await crypto.subtle.digest(
|
|
325
|
+
"SHA-256",
|
|
326
|
+
new TextEncoder().encode(requestKey),
|
|
327
|
+
);
|
|
328
|
+
const directory = `/workspace/.fungi/runs/call-${Hex.encode(new Uint8Array(digest))}`;
|
|
329
|
+
const remotePath = `/workspace/.fungi-git-${Hex.encode(new Uint8Array(digest))}.remote`;
|
|
330
|
+
const credentialFile = gitCredentialFile;
|
|
331
|
+
function prepare(pass: GitCredential | null) {
|
|
332
|
+
const logPath = `${directory}/out.log`;
|
|
333
|
+
const command =
|
|
334
|
+
pass === null
|
|
335
|
+
? `bash -c ${quote(input.command)}`
|
|
336
|
+
: gitCredentialCommand(remotePath, pass, input.command);
|
|
337
|
+
const script = `mkdir -p ${quote(directory)} && ${command} 2>&1 | tee ${quote(logPath)}`;
|
|
338
|
+
const processInput: z.input<typeof processInputSchema> = {
|
|
339
|
+
command: `bash -o pipefail -c ${quote(script)}`,
|
|
340
|
+
};
|
|
341
|
+
if (input.cwd !== undefined) processInput.cwd = input.cwd;
|
|
342
|
+
if (input.port !== undefined) processInput.port = input.port;
|
|
343
|
+
return { start: processInputSchema.parse(processInput), logPath };
|
|
344
|
+
}
|
|
345
|
+
async function dispatch(
|
|
346
|
+
start: z.output<typeof processInputSchema>,
|
|
347
|
+
logPath: string,
|
|
348
|
+
pass: GitCredential | null,
|
|
349
|
+
expiresAt: number | null,
|
|
350
|
+
) {
|
|
351
|
+
const remote = pass?.remote ?? null;
|
|
352
|
+
starting.set(requestKey, input.port ?? null);
|
|
353
|
+
try {
|
|
354
|
+
function checkLease() {
|
|
355
|
+
if (remote !== null && (expiresAt === null || expiresAt <= now()))
|
|
356
|
+
throw new Error(
|
|
357
|
+
`Workflow Git pass expired before process dispatch. Log: ${logPath}`,
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
checkLease();
|
|
361
|
+
if (pass !== null) {
|
|
362
|
+
if (!computer.write)
|
|
363
|
+
throw new Error("Computer credential file access is unavailable");
|
|
364
|
+
const content = credentialFile(pass);
|
|
365
|
+
const reply = await computer.write({
|
|
366
|
+
path: remotePath,
|
|
367
|
+
content,
|
|
368
|
+
});
|
|
369
|
+
const refused = shedComputerRefusalSchema.safeParse(reply);
|
|
370
|
+
if (refused.success) {
|
|
371
|
+
sql.exec(
|
|
372
|
+
"DELETE FROM shed_process_claims WHERE requestKey=?",
|
|
373
|
+
requestKey,
|
|
374
|
+
);
|
|
375
|
+
return refused.data;
|
|
376
|
+
}
|
|
377
|
+
const written = writeOutputSchema.parse(reply);
|
|
378
|
+
if (
|
|
379
|
+
written.path !== remotePath ||
|
|
380
|
+
written.bytes !== new TextEncoder().encode(content).byteLength
|
|
381
|
+
)
|
|
382
|
+
throw new Error("Invalid Computer credential file reply");
|
|
383
|
+
}
|
|
384
|
+
checkLease();
|
|
385
|
+
const reply = await computer.processStart(start);
|
|
386
|
+
const refused = shedComputerRefusalSchema.safeParse(reply);
|
|
387
|
+
if (refused.success) {
|
|
388
|
+
sql.exec(
|
|
389
|
+
"DELETE FROM shed_process_claims WHERE requestKey=?",
|
|
390
|
+
requestKey,
|
|
391
|
+
);
|
|
392
|
+
return refused.data;
|
|
393
|
+
}
|
|
394
|
+
const process = computerProcessSchema.parse(reply);
|
|
395
|
+
if (process.port !== (input.port ?? null))
|
|
396
|
+
throw new Error("Run identity conflicts with its declared port");
|
|
397
|
+
return observe(requestKey, process, logPath);
|
|
398
|
+
} finally {
|
|
399
|
+
starting.delete(requestKey);
|
|
400
|
+
trimScope(actor, mode, computerId, requestKey);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
const gitAccess =
|
|
404
|
+
input.gitAccess === undefined ? null : JSON.stringify(input.gitAccess);
|
|
405
|
+
async function mint() {
|
|
406
|
+
if (!computer.workflowGitPass)
|
|
407
|
+
throw new Error("Workflow Git pass binding is unavailable");
|
|
408
|
+
const pass = workflowGitPassResultSchema.parse(
|
|
409
|
+
await computer.workflowGitPass({}),
|
|
410
|
+
);
|
|
411
|
+
if (pass.expiresAt <= now() + 5000)
|
|
412
|
+
throw new Error("Workflow Git pass expires before the next poll");
|
|
413
|
+
return pass;
|
|
414
|
+
}
|
|
415
|
+
async function resume(prior: z.output<typeof rowSchema>) {
|
|
416
|
+
verify(prior, input, computerId);
|
|
417
|
+
if (prior.phase === "starting") {
|
|
418
|
+
const activePort = starting.get(requestKey);
|
|
419
|
+
if (activePort !== undefined) {
|
|
420
|
+
if (activePort !== (input.port ?? null))
|
|
421
|
+
throw new AppMethodRefusal(
|
|
422
|
+
"conflict",
|
|
423
|
+
"Run identity conflicts with its declared port",
|
|
424
|
+
);
|
|
425
|
+
return pending(5000);
|
|
426
|
+
}
|
|
427
|
+
// The host binding retains this call's native idempotency key across retries.
|
|
428
|
+
if (mode === "start") {
|
|
429
|
+
// Renew the same selector rather than storing a header credential.
|
|
430
|
+
let pass: GitCredential | null = null;
|
|
431
|
+
if (prior.remote !== null) {
|
|
432
|
+
if (prior.expiresAt === null || prior.expiresAt <= now())
|
|
433
|
+
throw new Error(
|
|
434
|
+
`Workflow Git pass expired before process dispatch. Log: ${prior.logPath}`,
|
|
435
|
+
);
|
|
436
|
+
const renewed = await mint();
|
|
437
|
+
if (
|
|
438
|
+
renewed.passId !== prior.passId ||
|
|
439
|
+
renewed.remote !== prior.remote
|
|
440
|
+
)
|
|
441
|
+
throw new Error(
|
|
442
|
+
`Workflow Git renewal changed its original selector. Log: ${prior.logPath}`,
|
|
443
|
+
);
|
|
444
|
+
pass = renewed;
|
|
445
|
+
}
|
|
446
|
+
const prepared = prepare(pass);
|
|
447
|
+
return dispatch(prepared.start, prior.logPath, pass, prior.expiresAt);
|
|
448
|
+
}
|
|
449
|
+
throw new Error(
|
|
450
|
+
`Process start outcome is unavailable. Log: ${prior.logPath}`,
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
const { processes } = processListOutputSchema.parse(
|
|
454
|
+
await computer.processes({}),
|
|
455
|
+
);
|
|
456
|
+
const process = processes.find((item) => item.id === prior.processId);
|
|
457
|
+
if (!process)
|
|
458
|
+
throw new Error(
|
|
459
|
+
`Process is no longer in the Computer inventory. Log: ${prior.logPath}`,
|
|
460
|
+
);
|
|
461
|
+
if (process.port !== (input.port ?? null))
|
|
462
|
+
throw new AppMethodRefusal(
|
|
463
|
+
"conflict",
|
|
464
|
+
"Run identity conflicts with its declared port",
|
|
465
|
+
);
|
|
466
|
+
if (mode === "run" && process.state !== "exited" && gitAccess !== null) {
|
|
467
|
+
if (prior.expiresAt === null || prior.expiresAt <= now())
|
|
468
|
+
throw new Error(`Workflow Git pass expired. Log: ${prior.logPath}`);
|
|
469
|
+
const pass = await mint();
|
|
470
|
+
if (
|
|
471
|
+
prior.expiresAt <= now() ||
|
|
472
|
+
pass.passId !== prior.passId ||
|
|
473
|
+
pass.remote !== prior.remote ||
|
|
474
|
+
pass.expiresAt < prior.expiresAt
|
|
475
|
+
)
|
|
476
|
+
throw new Error(
|
|
477
|
+
`Workflow Git renewal changed or expired its original selector. Log: ${prior.logPath}`,
|
|
478
|
+
);
|
|
479
|
+
sql.exec(
|
|
480
|
+
"UPDATE shed_process_claims SET expiresAt=MAX(expiresAt,?) WHERE requestKey=?",
|
|
481
|
+
pass.expiresAt,
|
|
482
|
+
requestKey,
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
return observe(requestKey, process, prior.logPath);
|
|
486
|
+
}
|
|
487
|
+
const prior = read(requestKey);
|
|
488
|
+
if (prior) return resume(prior);
|
|
489
|
+
// Mint can yield; recheck the durable claim before starting a process.
|
|
490
|
+
const pass: WorkflowGitPassResult | null =
|
|
491
|
+
gitAccess === null ? null : await mint();
|
|
492
|
+
const claimed = read(requestKey);
|
|
493
|
+
if (claimed) return resume(claimed);
|
|
494
|
+
const { start, logPath } = prepare(pass);
|
|
495
|
+
sql.exec(
|
|
496
|
+
"INSERT INTO shed_process_claims (requestKey,computerId,command,cwd,phase,logPath,gitAccess,passId,remote,expiresAt) VALUES (?,?,?,?,'starting',?,?,?,?,?)",
|
|
497
|
+
requestKey,
|
|
498
|
+
computerId,
|
|
499
|
+
input.command,
|
|
500
|
+
input.cwd ?? null,
|
|
501
|
+
logPath,
|
|
502
|
+
gitAccess,
|
|
503
|
+
pass?.passId ?? null,
|
|
504
|
+
pass?.remote ?? null,
|
|
505
|
+
pass?.expiresAt ?? null,
|
|
506
|
+
);
|
|
507
|
+
return dispatch(start, logPath, pass, pass?.expiresAt ?? null);
|
|
508
|
+
}
|
|
509
|
+
return {
|
|
510
|
+
owns(processId: string, computerId: string, actor: HostActor) {
|
|
511
|
+
if (actor.kind !== "member") return false;
|
|
512
|
+
return (
|
|
513
|
+
sql
|
|
514
|
+
.exec(
|
|
515
|
+
"SELECT requestKey FROM shed_process_claims WHERE processId=? AND computerId=? AND json_extract(requestKey,'$[0]')=?",
|
|
516
|
+
processId,
|
|
517
|
+
computerId,
|
|
518
|
+
JSON.stringify(actor),
|
|
519
|
+
)
|
|
520
|
+
.toArray().length !== 0
|
|
521
|
+
);
|
|
522
|
+
},
|
|
523
|
+
logPath(
|
|
524
|
+
rawKey: string,
|
|
525
|
+
computerId: string,
|
|
526
|
+
actor: HostActor,
|
|
527
|
+
): { path: string } | { error: "not_found" | "ambiguous" } {
|
|
528
|
+
const rows = [
|
|
529
|
+
read(claimKey(actor, "start", rawKey)),
|
|
530
|
+
read(claimKey(actor, "run", rawKey)),
|
|
531
|
+
].filter((row) => row !== null && row.processId !== null);
|
|
532
|
+
if (rows.length > 1) return { error: "ambiguous" };
|
|
533
|
+
const row = rows[0];
|
|
534
|
+
return row?.computerId === computerId
|
|
535
|
+
? { path: row.logPath }
|
|
536
|
+
: { error: "not_found" };
|
|
537
|
+
},
|
|
538
|
+
start(
|
|
539
|
+
input: ShedStartInput,
|
|
540
|
+
computerId: string,
|
|
541
|
+
requestKey: string,
|
|
542
|
+
actor: HostActor,
|
|
543
|
+
computer: ShedComputer,
|
|
544
|
+
) {
|
|
545
|
+
return claim(input, computerId, requestKey, actor, computer, "start");
|
|
546
|
+
},
|
|
547
|
+
async run(
|
|
548
|
+
input: ShedRunInput,
|
|
549
|
+
computerId: string,
|
|
550
|
+
requestKey: string,
|
|
551
|
+
actor: HostActor,
|
|
552
|
+
computer: ShedComputer,
|
|
553
|
+
) {
|
|
554
|
+
if (actor.kind !== "member")
|
|
555
|
+
return shedComputerRefusalSchema.parse({ error: "not_granted" });
|
|
556
|
+
if (retired(claimKey(actor, "run", requestKey)))
|
|
557
|
+
return shedComputerRefusalSchema.parse({ error: "not_found" });
|
|
558
|
+
const prior = read(claimKey(actor, "run", requestKey));
|
|
559
|
+
if (prior) {
|
|
560
|
+
verify(prior, input, computerId);
|
|
561
|
+
if (prior.phase === "exited")
|
|
562
|
+
return completion(prior.exitCode, prior.logPath);
|
|
563
|
+
}
|
|
564
|
+
const result = await claim(
|
|
565
|
+
input,
|
|
566
|
+
computerId,
|
|
567
|
+
requestKey,
|
|
568
|
+
actor,
|
|
569
|
+
computer,
|
|
570
|
+
"run",
|
|
571
|
+
);
|
|
572
|
+
if ("kind" in result) return result;
|
|
573
|
+
if ("error" in result) return result;
|
|
574
|
+
return result.process.state === "exited"
|
|
575
|
+
? completion(result.process.exitCode, result.logPath)
|
|
576
|
+
: pending(5000);
|
|
577
|
+
},
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
export type ShedRuns = ReturnType<typeof createShedRuns>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { DurableObject } from "../../../../packages/shiitake/test/fixtures/cloudflare-workers.fixture.js";
|