@fungi.computer/trellis 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 +125 -2
- package/backend/catalog.ts +58 -0
- package/backend/gills.ts +56 -0
- package/backend/index.ts +40 -0
- package/backend/indexes.ts +419 -0
- package/backend/library-methods.ts +287 -0
- package/backend/library.ts +594 -0
- package/backend/methods.ts +221 -0
- package/backend/publication-job.ts +625 -0
- package/backend/push.ts +287 -0
- package/backend/schedule-methods.ts +120 -0
- package/backend/share.ts +221 -0
- package/backend/tool-family.ts +124 -0
- package/backend/tsconfig.build.json +11 -0
- package/backend/tsconfig.json +15 -0
- package/dist/backend/catalog.js +42 -0
- package/dist/backend/gills.js +49 -0
- package/dist/backend/indexes.js +294 -0
- package/dist/backend/library-methods.js +219 -0
- package/dist/backend/library.js +454 -0
- package/dist/backend/methods.js +141 -0
- package/dist/backend/publication-job.js +451 -0
- package/dist/backend/schedule-methods.js +90 -0
- package/dist/backend/share.js +140 -0
- package/dist/backend/tool-family.js +98 -0
- package/dist/shared/tool-contracts.js +32 -0
- package/package.json +166 -3
- package/shared/tool-contracts.ts +35 -0
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
import * as S from "effect/Schema";
|
|
3
|
+
import {
|
|
4
|
+
Watchdog,
|
|
5
|
+
watchdogSqliteRowSchema,
|
|
6
|
+
type ConcurrentWatchdogRuntime,
|
|
7
|
+
type WatchdogQueuedJob,
|
|
8
|
+
} from "@fungi.computer/watchdog";
|
|
9
|
+
import {
|
|
10
|
+
begin,
|
|
11
|
+
GitStepMaterialError,
|
|
12
|
+
runStep,
|
|
13
|
+
gitStepSchema,
|
|
14
|
+
sqliteGitStepMaterial,
|
|
15
|
+
dropMaterial,
|
|
16
|
+
type GitStep,
|
|
17
|
+
type GitStepResult,
|
|
18
|
+
} from "@fungi.computer/agar/git-steps";
|
|
19
|
+
import type { GitBinding } from "@fungi.computer/agar/git";
|
|
20
|
+
import type { GitIdentity } from "@fungi.computer/agar/git-commit";
|
|
21
|
+
import { countingGit } from "@fungi.computer/agar/read-copy";
|
|
22
|
+
import { advertisedRefs } from "@fungi.computer/agar/git-wire";
|
|
23
|
+
import {
|
|
24
|
+
contentDigest,
|
|
25
|
+
validate,
|
|
26
|
+
workflowDefinitionSchema,
|
|
27
|
+
type Catalog,
|
|
28
|
+
} from "@fungi.computer/braid";
|
|
29
|
+
import {
|
|
30
|
+
actorLane,
|
|
31
|
+
backgroundOriginSchema,
|
|
32
|
+
type BackgroundBinding,
|
|
33
|
+
type BackgroundOrigin,
|
|
34
|
+
backgroundSyncRefusalSchema,
|
|
35
|
+
type BackgroundSyncRefusal,
|
|
36
|
+
} from "@fungi.computer/puffball/background-replay";
|
|
37
|
+
import type { AppMethodCallContext } from "@fungi.computer/puffball/app-method-runtime";
|
|
38
|
+
import { sha256DigestSchema } from "@fungi.computer/puffball/effect";
|
|
39
|
+
import {
|
|
40
|
+
draftDocument,
|
|
41
|
+
publicationIds,
|
|
42
|
+
publishedDocument,
|
|
43
|
+
publicationFailure,
|
|
44
|
+
publicationStatus,
|
|
45
|
+
publicationComplete,
|
|
46
|
+
publicationFailed,
|
|
47
|
+
publishReply,
|
|
48
|
+
workflowIdSchema,
|
|
49
|
+
} from "@fungi.computer/workflows-wire";
|
|
50
|
+
import type { WorkflowStorage } from "./indexes.js";
|
|
51
|
+
import { versionRefs } from "@fungi.computer/workflows-wire";
|
|
52
|
+
import { serializedDraft, retainedDraft } from "./library.js";
|
|
53
|
+
import { definitionAppIds } from "./catalog.js";
|
|
54
|
+
|
|
55
|
+
export type PublicationDraft = z.output<typeof draftDocument>;
|
|
56
|
+
export type StoredPublication = z.output<typeof publishedDocument>;
|
|
57
|
+
type PublicationStatus = z.output<typeof publicationStatus>;
|
|
58
|
+
type PublicationPorts = Readonly<{
|
|
59
|
+
git: GitBinding;
|
|
60
|
+
author: GitIdentity;
|
|
61
|
+
background: BackgroundBinding | undefined;
|
|
62
|
+
catalog: (appIds: readonly string[]) => Promise<Catalog>;
|
|
63
|
+
}>;
|
|
64
|
+
export type PublicationStore = Readonly<{
|
|
65
|
+
readDraft: (workflowId: string) => PublicationDraft | null;
|
|
66
|
+
revision: () => number;
|
|
67
|
+
publish: (
|
|
68
|
+
publication: StoredPublication,
|
|
69
|
+
epoch: number,
|
|
70
|
+
refs: Awaited<ReturnType<GitBinding["refs"]>>,
|
|
71
|
+
) => void;
|
|
72
|
+
}>;
|
|
73
|
+
const cargo = z.strictObject({
|
|
74
|
+
publicationId: publicationIds.publication.schema,
|
|
75
|
+
step: z.number().int().nonnegative(),
|
|
76
|
+
});
|
|
77
|
+
const rowSchema = z.strictObject({
|
|
78
|
+
id: publicationIds.publication.schema,
|
|
79
|
+
workflow_id: workflowIdSchema,
|
|
80
|
+
revision: z.number().int().positive(),
|
|
81
|
+
lane: z.string(),
|
|
82
|
+
origin: z.string(),
|
|
83
|
+
body: z.string().nullable(),
|
|
84
|
+
epoch: z.number().int().nonnegative(),
|
|
85
|
+
step: z.number().int().nonnegative(),
|
|
86
|
+
attempts: z.number().int().min(0).max(2),
|
|
87
|
+
phase: z.enum(["inspect", "create", "prepare", "git"]),
|
|
88
|
+
checkpoint: z.string().nullable(),
|
|
89
|
+
version: z.number().int().positive().nullable(),
|
|
90
|
+
status: z.enum(["publishing", "published", "failed"]),
|
|
91
|
+
reason: publicationFailure.nullable(),
|
|
92
|
+
commit_oid: z.string().nullable(),
|
|
93
|
+
request_key: z.string().nullable(),
|
|
94
|
+
digest: z.string().refine(S.is(sha256DigestSchema)),
|
|
95
|
+
});
|
|
96
|
+
type Row = z.output<typeof rowSchema>;
|
|
97
|
+
type Progress = Readonly<{
|
|
98
|
+
checkpoint: GitStep | null;
|
|
99
|
+
version: number | null;
|
|
100
|
+
result?: PublicationStatus;
|
|
101
|
+
refs?: Awaited<ReturnType<GitBinding["refs"]>>;
|
|
102
|
+
attempts?: number;
|
|
103
|
+
phase?: Row["phase"];
|
|
104
|
+
}>;
|
|
105
|
+
const queue = (id: string) => `publication:${id}`;
|
|
106
|
+
|
|
107
|
+
/** StoredPublication cargo and status share the facet's existing SQLite owner with Watchdog. */
|
|
108
|
+
export function createPublicationJobs(
|
|
109
|
+
storage: WorkflowStorage,
|
|
110
|
+
store: PublicationStore,
|
|
111
|
+
) {
|
|
112
|
+
const sql = {
|
|
113
|
+
exec(
|
|
114
|
+
statement: string,
|
|
115
|
+
...bindings: (string | number | ArrayBuffer | null)[]
|
|
116
|
+
) {
|
|
117
|
+
const cursor = storage.sql.exec(statement, ...bindings);
|
|
118
|
+
return {
|
|
119
|
+
toArray: () =>
|
|
120
|
+
cursor
|
|
121
|
+
.toArray()
|
|
122
|
+
.map((row) => S.decodeUnknownSync(watchdogSqliteRowSchema)(row)),
|
|
123
|
+
};
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
const owner = {
|
|
127
|
+
sql,
|
|
128
|
+
transactionSync: <T>(operation: () => T) =>
|
|
129
|
+
storage.transactionSync(operation),
|
|
130
|
+
};
|
|
131
|
+
const material = sqliteGitStepMaterial(sql);
|
|
132
|
+
sql.exec(
|
|
133
|
+
"CREATE TABLE IF NOT EXISTS workflow_publication_jobs (id TEXT PRIMARY KEY, workflow_id TEXT NOT NULL, revision INTEGER NOT NULL, lane TEXT NOT NULL, origin TEXT NOT NULL, body TEXT, epoch INTEGER NOT NULL, step INTEGER NOT NULL, attempts INTEGER NOT NULL, phase TEXT NOT NULL, checkpoint TEXT, version INTEGER, status TEXT NOT NULL, reason TEXT, commit_oid TEXT, request_key TEXT, digest TEXT NOT NULL)",
|
|
134
|
+
);
|
|
135
|
+
sql.exec(
|
|
136
|
+
"CREATE INDEX IF NOT EXISTS workflow_publication_pending ON workflow_publication_jobs(lane,status)",
|
|
137
|
+
);
|
|
138
|
+
const live = new Set<string>();
|
|
139
|
+
function read(id: string): Row | null {
|
|
140
|
+
const raw = sql
|
|
141
|
+
.exec("SELECT * FROM workflow_publication_jobs WHERE id = ?", id)
|
|
142
|
+
.toArray()[0];
|
|
143
|
+
return raw === undefined ? null : rowSchema.parse(raw);
|
|
144
|
+
}
|
|
145
|
+
function status(row: Row): Exclude<PublicationStatus, { kind: "not_found" }> {
|
|
146
|
+
if (row.status === "published")
|
|
147
|
+
return publicationComplete.parse({
|
|
148
|
+
kind: "published",
|
|
149
|
+
commit: row.commit_oid,
|
|
150
|
+
version: row.version,
|
|
151
|
+
});
|
|
152
|
+
if (row.status === "failed")
|
|
153
|
+
return publicationFailed.parse({ kind: "failed", reason: row.reason });
|
|
154
|
+
return { kind: "publishing", publicationId: row.id };
|
|
155
|
+
}
|
|
156
|
+
function forDraft(workflowId: string, revision: number): Row | null {
|
|
157
|
+
const raw = sql
|
|
158
|
+
.exec(
|
|
159
|
+
"SELECT * FROM workflow_publication_jobs WHERE workflow_id = ? AND revision = ? ORDER BY rowid DESC LIMIT 1",
|
|
160
|
+
workflowId,
|
|
161
|
+
revision,
|
|
162
|
+
)
|
|
163
|
+
.toArray()[0];
|
|
164
|
+
return raw === undefined ? null : rowSchema.parse(raw);
|
|
165
|
+
}
|
|
166
|
+
function existing(
|
|
167
|
+
workflowId: string,
|
|
168
|
+
revision: number,
|
|
169
|
+
caller?: AppMethodCallContext,
|
|
170
|
+
): z.output<typeof publishReply> | null {
|
|
171
|
+
const row = forDraft(workflowId, revision);
|
|
172
|
+
if (row === null) return null;
|
|
173
|
+
const lane =
|
|
174
|
+
caller?.actor.kind === "member" ? actorLane(caller.actor) : null;
|
|
175
|
+
if (row.status === "publishing" && row.lane !== lane)
|
|
176
|
+
return { kind: "publishing_elsewhere" };
|
|
177
|
+
if (
|
|
178
|
+
row.status !== "failed" ||
|
|
179
|
+
(row.lane === lane &&
|
|
180
|
+
caller?.idempotencyKey !== undefined &&
|
|
181
|
+
row.request_key === caller.idempotencyKey)
|
|
182
|
+
)
|
|
183
|
+
return status(row);
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
const queued = (row: Row): WatchdogQueuedJob => ({
|
|
187
|
+
jobId: `${row.id}:${String(row.step)}`,
|
|
188
|
+
queue: queue(row.id),
|
|
189
|
+
lane: row.lane,
|
|
190
|
+
priority: 0,
|
|
191
|
+
state: "queued",
|
|
192
|
+
payload: { publicationId: row.id, step: row.step },
|
|
193
|
+
recovery: { maxRecoveries: 3 },
|
|
194
|
+
});
|
|
195
|
+
function storedOrigin(value: string): BackgroundOrigin | null {
|
|
196
|
+
try {
|
|
197
|
+
const decoded = backgroundOriginSchema.safeParse(JSON.parse(value));
|
|
198
|
+
return decoded.success ? decoded.data : null;
|
|
199
|
+
} catch (cause) {
|
|
200
|
+
if (cause instanceof SyntaxError) return null;
|
|
201
|
+
throw cause;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const frameClosed = (signal: AbortSignal) => signal.aborted;
|
|
205
|
+
async function runtime(lane: string, ports?: PublicationPorts) {
|
|
206
|
+
const progress = new Map<string, Progress>();
|
|
207
|
+
const terminal = new Set<string>();
|
|
208
|
+
const claimed = new Set<string>();
|
|
209
|
+
const retry = (jobId: string, row: Row, interrupted = false) => {
|
|
210
|
+
if (!interrupted && row.attempts === 2)
|
|
211
|
+
return { kind: "failed" as const, reason: "publication_failed" };
|
|
212
|
+
progress.set(jobId, {
|
|
213
|
+
checkpoint:
|
|
214
|
+
row.checkpoint === null
|
|
215
|
+
? null
|
|
216
|
+
: gitStepSchema.parse(JSON.parse(row.checkpoint)),
|
|
217
|
+
version: row.version,
|
|
218
|
+
attempts: interrupted ? row.attempts : row.attempts + 1,
|
|
219
|
+
});
|
|
220
|
+
return { kind: "completed" as const };
|
|
221
|
+
};
|
|
222
|
+
const runner: ConcurrentWatchdogRuntime = await Watchdog.make({
|
|
223
|
+
owner,
|
|
224
|
+
concurrentLane: lane,
|
|
225
|
+
isAlive: (job) => Promise.resolve(live.has(job.jobId)),
|
|
226
|
+
wake: { recompute: () => Promise.resolve() },
|
|
227
|
+
execute: async (job, signal) => {
|
|
228
|
+
const input = cargo.parse(job.payload);
|
|
229
|
+
const row = read(input.publicationId);
|
|
230
|
+
if (
|
|
231
|
+
row?.status !== "publishing" ||
|
|
232
|
+
row.lane !== lane ||
|
|
233
|
+
job.lane !== lane ||
|
|
234
|
+
row.step !== input.step
|
|
235
|
+
)
|
|
236
|
+
return { kind: "failed", reason: "publication_changed" };
|
|
237
|
+
if (!ports) throw new Error("StoredPublication transport unavailable");
|
|
238
|
+
live.add(job.jobId);
|
|
239
|
+
claimed.add(job.jobId);
|
|
240
|
+
try {
|
|
241
|
+
if (frameClosed(signal)) return retry(job.jobId, row, true);
|
|
242
|
+
const origin = storedOrigin(row.origin);
|
|
243
|
+
if (origin === null || !ports.background)
|
|
244
|
+
return { kind: "failed", reason: "access_changed" };
|
|
245
|
+
const admission = await ports.background.admit(origin);
|
|
246
|
+
if (admission.kind === "refused")
|
|
247
|
+
return { kind: "failed", reason: admission.code };
|
|
248
|
+
const git = countingGit(ports.git, { limit: 1, signal }).git;
|
|
249
|
+
if (row.body === null)
|
|
250
|
+
return { kind: "failed", reason: "publication_changed" };
|
|
251
|
+
const draft = draftDocument.parse(JSON.parse(row.body));
|
|
252
|
+
const definition = S.decodeUnknownSync(workflowDefinitionSchema)(
|
|
253
|
+
draft.definition,
|
|
254
|
+
);
|
|
255
|
+
const catalog = await ports.catalog(definitionAppIds(definition));
|
|
256
|
+
if (validate(definition, catalog).length) {
|
|
257
|
+
progress.set(job.jobId, {
|
|
258
|
+
checkpoint: null,
|
|
259
|
+
version: row.version,
|
|
260
|
+
result: { kind: "failed", reason: "publication_failed" },
|
|
261
|
+
});
|
|
262
|
+
return { kind: "completed" };
|
|
263
|
+
}
|
|
264
|
+
if (row.checkpoint === null) {
|
|
265
|
+
if (frameClosed(signal)) return retry(job.jobId, row, true);
|
|
266
|
+
if (row.phase === "inspect") {
|
|
267
|
+
const exists = await git.info("trellis");
|
|
268
|
+
progress.set(job.jobId, {
|
|
269
|
+
checkpoint: null,
|
|
270
|
+
version: null,
|
|
271
|
+
phase: exists ? "prepare" : "create",
|
|
272
|
+
});
|
|
273
|
+
return { kind: "completed" };
|
|
274
|
+
}
|
|
275
|
+
if (row.phase === "create") {
|
|
276
|
+
await git.create("trellis", { defaultBranch: "main" });
|
|
277
|
+
progress.set(job.jobId, {
|
|
278
|
+
checkpoint: null,
|
|
279
|
+
version: null,
|
|
280
|
+
phase: "prepare",
|
|
281
|
+
});
|
|
282
|
+
return { kind: "completed" };
|
|
283
|
+
}
|
|
284
|
+
const refs = await git.refs("trellis");
|
|
285
|
+
if (frameClosed(signal)) return retry(job.jobId, row, true);
|
|
286
|
+
const version =
|
|
287
|
+
(versionRefs(row.workflow_id, refs)[0]?.version ?? 0) + 1;
|
|
288
|
+
const checkpoint = await begin(
|
|
289
|
+
{
|
|
290
|
+
kind: "commit",
|
|
291
|
+
requestKey: row.id,
|
|
292
|
+
request: {
|
|
293
|
+
repository: "trellis",
|
|
294
|
+
branch: "main",
|
|
295
|
+
expectedHead:
|
|
296
|
+
refs.find((ref) => ref.name === "refs/heads/main")?.hash ??
|
|
297
|
+
null,
|
|
298
|
+
changes: [
|
|
299
|
+
{
|
|
300
|
+
path: `workflows/${row.workflow_id}.json`,
|
|
301
|
+
content: new TextEncoder().encode(
|
|
302
|
+
serializedDraft(
|
|
303
|
+
retainedDraft({ ...draft, definition }),
|
|
304
|
+
),
|
|
305
|
+
),
|
|
306
|
+
mode: "100644",
|
|
307
|
+
},
|
|
308
|
+
],
|
|
309
|
+
message: `Publish ${row.workflow_id}`,
|
|
310
|
+
author: ports.author,
|
|
311
|
+
},
|
|
312
|
+
tags: [
|
|
313
|
+
{
|
|
314
|
+
ref: `refs/tags/${row.workflow_id}@v${String(version)}`,
|
|
315
|
+
expected: null,
|
|
316
|
+
},
|
|
317
|
+
],
|
|
318
|
+
},
|
|
319
|
+
material,
|
|
320
|
+
);
|
|
321
|
+
progress.set(job.jobId, { checkpoint, version, phase: "git" });
|
|
322
|
+
} else {
|
|
323
|
+
const checkpoint = gitStepSchema.parse(JSON.parse(row.checkpoint));
|
|
324
|
+
let witnessedRefs:
|
|
325
|
+
Awaited<ReturnType<GitBinding["refs"]>> | undefined;
|
|
326
|
+
const witnessedGit: Pick<GitBinding, "exchange"> = {
|
|
327
|
+
exchange: async (repository, request) => {
|
|
328
|
+
const response = await git.exchange(repository, request);
|
|
329
|
+
if (
|
|
330
|
+
request.service === "upload-pack" &&
|
|
331
|
+
request.phase === "advertise" &&
|
|
332
|
+
response.status === 200
|
|
333
|
+
)
|
|
334
|
+
witnessedRefs = [...advertisedRefs(response.body)].map(
|
|
335
|
+
([name, hash]) => ({ name, hash }),
|
|
336
|
+
);
|
|
337
|
+
return response;
|
|
338
|
+
},
|
|
339
|
+
};
|
|
340
|
+
const result: GitStepResult = await runStep(
|
|
341
|
+
checkpoint,
|
|
342
|
+
{ git: witnessedGit, material },
|
|
343
|
+
signal,
|
|
344
|
+
);
|
|
345
|
+
if ("done" in result) {
|
|
346
|
+
const done = result.done;
|
|
347
|
+
// Reuse the helper's fresh confirmation witness. An extra read
|
|
348
|
+
// after the tag lands could fail or observe a different main.
|
|
349
|
+
const refs =
|
|
350
|
+
done.kind === "committed" ? witnessedRefs : undefined;
|
|
351
|
+
const tag = `refs/tags/${row.workflow_id}@v${String(row.version)}`;
|
|
352
|
+
const final: PublicationStatus =
|
|
353
|
+
done.kind === "committed" &&
|
|
354
|
+
refs?.some(
|
|
355
|
+
(ref) => ref.name === tag && ref.hash === done.commit,
|
|
356
|
+
)
|
|
357
|
+
? publicationComplete.parse({
|
|
358
|
+
kind: "published",
|
|
359
|
+
commit: done.commit,
|
|
360
|
+
version: row.version,
|
|
361
|
+
})
|
|
362
|
+
: {
|
|
363
|
+
kind: "failed",
|
|
364
|
+
reason:
|
|
365
|
+
done.kind === "too_large"
|
|
366
|
+
? "too_large"
|
|
367
|
+
: "git_conflict",
|
|
368
|
+
};
|
|
369
|
+
const completed = {
|
|
370
|
+
checkpoint,
|
|
371
|
+
version: row.version,
|
|
372
|
+
result: final,
|
|
373
|
+
};
|
|
374
|
+
if (refs === undefined) progress.set(job.jobId, completed);
|
|
375
|
+
else progress.set(job.jobId, { ...completed, refs });
|
|
376
|
+
} else
|
|
377
|
+
progress.set(job.jobId, {
|
|
378
|
+
checkpoint: "next" in result ? result.next : checkpoint,
|
|
379
|
+
version: row.version,
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
if (frameClosed(signal)) return retry(job.jobId, row, true);
|
|
383
|
+
return { kind: "completed" };
|
|
384
|
+
} catch (cause) {
|
|
385
|
+
if (
|
|
386
|
+
frameClosed(signal) ||
|
|
387
|
+
(cause instanceof Error && cause.name === "AbortError")
|
|
388
|
+
)
|
|
389
|
+
return retry(job.jobId, row, true);
|
|
390
|
+
if (cause instanceof GitStepMaterialError)
|
|
391
|
+
return { kind: "failed", reason: "publication_failed" };
|
|
392
|
+
return retry(job.jobId, row);
|
|
393
|
+
}
|
|
394
|
+
},
|
|
395
|
+
project: (transition) => {
|
|
396
|
+
const settled = transition.settled;
|
|
397
|
+
if (!settled) return;
|
|
398
|
+
const input = cargo.parse(settled.payload);
|
|
399
|
+
const row = read(input.publicationId);
|
|
400
|
+
if (row?.status !== "publishing" || row.step !== input.step) return;
|
|
401
|
+
let next =
|
|
402
|
+
settled.outcome === "completed"
|
|
403
|
+
? progress.get(settled.jobId)
|
|
404
|
+
: undefined;
|
|
405
|
+
if (
|
|
406
|
+
!next &&
|
|
407
|
+
(settled.outcome === "cancelled" ||
|
|
408
|
+
settled.failureReason === "execution_interrupted")
|
|
409
|
+
) {
|
|
410
|
+
retry(settled.jobId, row, true);
|
|
411
|
+
next = progress.get(settled.jobId);
|
|
412
|
+
}
|
|
413
|
+
if (!next || next.result?.kind === "failed") {
|
|
414
|
+
const reason =
|
|
415
|
+
next?.result?.kind === "failed"
|
|
416
|
+
? next.result.reason
|
|
417
|
+
: settled.failureReason === "access_changed"
|
|
418
|
+
? "access_changed"
|
|
419
|
+
: "publication_failed";
|
|
420
|
+
sql.exec(
|
|
421
|
+
"UPDATE workflow_publication_jobs SET status = 'failed', reason = ?, body = NULL WHERE id = ?",
|
|
422
|
+
reason,
|
|
423
|
+
row.id,
|
|
424
|
+
);
|
|
425
|
+
terminal.add(row.id);
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
if (next.result?.kind === "published") {
|
|
429
|
+
if (row.body === null)
|
|
430
|
+
throw new Error("StoredPublication draft missing");
|
|
431
|
+
const { revision, ...draft } = draftDocument.parse(
|
|
432
|
+
JSON.parse(row.body),
|
|
433
|
+
);
|
|
434
|
+
if (revision !== row.revision)
|
|
435
|
+
throw new Error("StoredPublication revision mismatch");
|
|
436
|
+
if (!next.refs) throw new Error("Confirmed publication refs missing");
|
|
437
|
+
store.publish(
|
|
438
|
+
publishedDocument.parse({
|
|
439
|
+
...draft,
|
|
440
|
+
workflowId: row.workflow_id,
|
|
441
|
+
commit: next.result.commit,
|
|
442
|
+
version: next.result.version,
|
|
443
|
+
digest: row.digest,
|
|
444
|
+
}),
|
|
445
|
+
row.epoch,
|
|
446
|
+
next.refs,
|
|
447
|
+
);
|
|
448
|
+
sql.exec(
|
|
449
|
+
"UPDATE workflow_publication_jobs SET status = 'published', commit_oid = ?, version = ?, body = NULL WHERE id = ?",
|
|
450
|
+
next.result.commit,
|
|
451
|
+
next.result.version,
|
|
452
|
+
row.id,
|
|
453
|
+
);
|
|
454
|
+
terminal.add(row.id);
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
const successor = {
|
|
458
|
+
...row,
|
|
459
|
+
step: row.step + 1,
|
|
460
|
+
attempts: next.attempts ?? 0,
|
|
461
|
+
phase: next.phase ?? row.phase,
|
|
462
|
+
version: next.version,
|
|
463
|
+
checkpoint:
|
|
464
|
+
next.checkpoint === null ? null : JSON.stringify(next.checkpoint),
|
|
465
|
+
};
|
|
466
|
+
sql.exec(
|
|
467
|
+
"UPDATE workflow_publication_jobs SET step = ?, attempts = ?, phase = ?, checkpoint = ?, version = ? WHERE id = ?",
|
|
468
|
+
successor.step,
|
|
469
|
+
successor.attempts,
|
|
470
|
+
successor.phase,
|
|
471
|
+
successor.checkpoint,
|
|
472
|
+
successor.version,
|
|
473
|
+
row.id,
|
|
474
|
+
);
|
|
475
|
+
runner.transactional.enqueueAcceptedJob(queued(successor));
|
|
476
|
+
},
|
|
477
|
+
});
|
|
478
|
+
return {
|
|
479
|
+
runner,
|
|
480
|
+
release: () => {
|
|
481
|
+
for (const id of claimed) live.delete(id);
|
|
482
|
+
},
|
|
483
|
+
cleanup: async () => {
|
|
484
|
+
for (const id of terminal) {
|
|
485
|
+
const row = read(id);
|
|
486
|
+
if (row && row.status !== "publishing" && row.checkpoint !== null)
|
|
487
|
+
await dropMaterial(
|
|
488
|
+
gitStepSchema.parse(JSON.parse(row.checkpoint)),
|
|
489
|
+
material,
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
terminal.clear();
|
|
493
|
+
},
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
return {
|
|
497
|
+
existing,
|
|
498
|
+
async enqueue(
|
|
499
|
+
workflowId: string,
|
|
500
|
+
revision: number,
|
|
501
|
+
caller: AppMethodCallContext,
|
|
502
|
+
): Promise<z.output<typeof publishReply>> {
|
|
503
|
+
const lane = actorLane(caller.actor);
|
|
504
|
+
const draft = store.readDraft(workflowId);
|
|
505
|
+
if (draft?.revision !== revision) return { kind: "conflict", head: null };
|
|
506
|
+
const { runner } = await runtime(lane);
|
|
507
|
+
const digest = S.decodeUnknownSync(sha256DigestSchema)(
|
|
508
|
+
await contentDigest(S.decodeUnknownSync(S.Json)(draft.definition)),
|
|
509
|
+
);
|
|
510
|
+
return storage.transactionSync(() => {
|
|
511
|
+
if (store.readDraft(workflowId)?.revision !== revision)
|
|
512
|
+
return { kind: "conflict" as const, head: null };
|
|
513
|
+
const retained = existing(workflowId, revision, caller);
|
|
514
|
+
if (retained !== null) return retained;
|
|
515
|
+
const row: Row = {
|
|
516
|
+
id: publicationIds.publication.generate(),
|
|
517
|
+
workflow_id: workflowId,
|
|
518
|
+
revision,
|
|
519
|
+
lane,
|
|
520
|
+
origin: JSON.stringify(
|
|
521
|
+
backgroundOriginSchema.parse(caller.backgroundOrigin),
|
|
522
|
+
),
|
|
523
|
+
body: JSON.stringify(draft),
|
|
524
|
+
epoch: store.revision(),
|
|
525
|
+
step: 0,
|
|
526
|
+
attempts: 0,
|
|
527
|
+
phase: "inspect",
|
|
528
|
+
checkpoint: null,
|
|
529
|
+
version: null,
|
|
530
|
+
status: "publishing",
|
|
531
|
+
reason: null,
|
|
532
|
+
commit_oid: null,
|
|
533
|
+
request_key: caller.idempotencyKey ?? null,
|
|
534
|
+
digest,
|
|
535
|
+
};
|
|
536
|
+
sql.exec(
|
|
537
|
+
"INSERT INTO workflow_publication_jobs(id,workflow_id,revision,lane,origin,body,epoch,step,attempts,phase,checkpoint,version,status,reason,commit_oid,request_key,digest) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
|
538
|
+
row.id,
|
|
539
|
+
workflowId,
|
|
540
|
+
revision,
|
|
541
|
+
lane,
|
|
542
|
+
row.origin,
|
|
543
|
+
row.body,
|
|
544
|
+
row.epoch,
|
|
545
|
+
row.step,
|
|
546
|
+
row.attempts,
|
|
547
|
+
row.phase,
|
|
548
|
+
row.checkpoint,
|
|
549
|
+
row.version,
|
|
550
|
+
row.status,
|
|
551
|
+
row.reason,
|
|
552
|
+
row.commit_oid,
|
|
553
|
+
row.request_key,
|
|
554
|
+
row.digest,
|
|
555
|
+
);
|
|
556
|
+
runner.transactional.enqueueAcceptedJob(queued(row));
|
|
557
|
+
return status(row);
|
|
558
|
+
});
|
|
559
|
+
},
|
|
560
|
+
async continue(caller: AppMethodCallContext, ports: PublicationPorts) {
|
|
561
|
+
if (caller.actor.kind !== "member" || caller.workflow !== undefined)
|
|
562
|
+
return { more: false };
|
|
563
|
+
const lane = actorLane(caller.actor);
|
|
564
|
+
if (caller.backgroundLane !== undefined && caller.backgroundLane !== lane)
|
|
565
|
+
return { more: false };
|
|
566
|
+
// R0 strips enqueue markers on readmission. A sealed foreground write
|
|
567
|
+
// is still foreground, even if its host exposes a refusing binding.
|
|
568
|
+
if (
|
|
569
|
+
caller.backgroundOrigin !== undefined ||
|
|
570
|
+
caller.backgroundLane !== undefined ||
|
|
571
|
+
!ports.background
|
|
572
|
+
)
|
|
573
|
+
return {
|
|
574
|
+
more:
|
|
575
|
+
sql
|
|
576
|
+
.exec(
|
|
577
|
+
"SELECT id FROM workflow_publication_jobs WHERE lane = ? AND status = 'publishing' LIMIT 1",
|
|
578
|
+
lane,
|
|
579
|
+
)
|
|
580
|
+
.toArray().length !== 0,
|
|
581
|
+
};
|
|
582
|
+
const { runner, cleanup, release } = await runtime(lane, ports);
|
|
583
|
+
try {
|
|
584
|
+
await runner.tickConcurrent();
|
|
585
|
+
} finally {
|
|
586
|
+
release();
|
|
587
|
+
}
|
|
588
|
+
await cleanup();
|
|
589
|
+
return {
|
|
590
|
+
more:
|
|
591
|
+
sql
|
|
592
|
+
.exec(
|
|
593
|
+
"SELECT id FROM workflow_publication_jobs WHERE lane = ? AND status = 'publishing' LIMIT 1",
|
|
594
|
+
lane,
|
|
595
|
+
)
|
|
596
|
+
.toArray().length !== 0,
|
|
597
|
+
};
|
|
598
|
+
},
|
|
599
|
+
status(
|
|
600
|
+
workflowId: string,
|
|
601
|
+
publicationId: string,
|
|
602
|
+
caller: AppMethodCallContext,
|
|
603
|
+
): PublicationStatus {
|
|
604
|
+
const row = read(publicationId);
|
|
605
|
+
return caller.actor.kind === "member" &&
|
|
606
|
+
row?.workflow_id === workflowId &&
|
|
607
|
+
row.lane === actorLane(caller.actor)
|
|
608
|
+
? status(row)
|
|
609
|
+
: { kind: "not_found" };
|
|
610
|
+
},
|
|
611
|
+
async refused(input: BackgroundSyncRefusal) {
|
|
612
|
+
const refusal = backgroundSyncRefusalSchema.parse(input);
|
|
613
|
+
const { runner, cleanup } = await runtime(refusal.lane);
|
|
614
|
+
let more: boolean;
|
|
615
|
+
do {
|
|
616
|
+
const result = await runner.failLane({
|
|
617
|
+
lane: refusal.lane,
|
|
618
|
+
reason: refusal.code,
|
|
619
|
+
});
|
|
620
|
+
more = result.more;
|
|
621
|
+
await cleanup();
|
|
622
|
+
} while (more);
|
|
623
|
+
},
|
|
624
|
+
};
|
|
625
|
+
}
|