@fungi.computer/trellis 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.
@@ -0,0 +1,594 @@
1
+ import { workflowIdSchema } from "@fungi.computer/braid";
2
+ import * as S from "effect/Schema";
3
+ import { countingGit } from "@fungi.computer/agar/read-copy";
4
+ import { sha256DigestSchema } from "@fungi.computer/puffball/effect";
5
+ import type { GitBinding } from "@fungi.computer/agar/git";
6
+ import {
7
+ mayEnqueueBackground,
8
+ type BackgroundBinding,
9
+ } from "@fungi.computer/puffball/background-replay";
10
+ import type { AppMethodCallContext } from "@fungi.computer/puffball/app-method-runtime";
11
+ import {
12
+ commitFiles,
13
+ moveRefs,
14
+ type GitIdentity,
15
+ type MoveRefRequest,
16
+ type GitRefMovesOutcome,
17
+ } from "@fungi.computer/agar/git-commit";
18
+ import { validate, contentDigest, type Catalog } from "@fungi.computer/braid";
19
+
20
+ import type { WorkflowIndexes } from "./indexes.js";
21
+ import { definitionAppIds } from "./catalog.js";
22
+ import {
23
+ draftDocument,
24
+ listingGit,
25
+ readPublishedWorkflow,
26
+ readWorkflowAt as readAt,
27
+ versionRefs,
28
+ workflowDraftSchema as draftSchema,
29
+ workflowFilePath,
30
+ workflowFiles,
31
+ workflowLibraryRepository,
32
+ type WorkflowDraft,
33
+ } from "@fungi.computer/workflows-wire";
34
+ export function retainedDraft(draft: {
35
+ readonly definition: WorkflowDraft["definition"];
36
+ readonly layout: WorkflowDraft["layout"];
37
+ readonly provenance?: WorkflowDraft["provenance"];
38
+ }): WorkflowDraft {
39
+ const content = { definition: draft.definition, layout: draft.layout };
40
+ if (draft.provenance !== undefined)
41
+ return { ...content, provenance: draft.provenance };
42
+ return content;
43
+ }
44
+ export function serializedDraft(draft: WorkflowDraft) {
45
+ const content = {
46
+ ...draft.definition,
47
+ layout: draft.layout,
48
+ };
49
+ if (draft.provenance !== undefined)
50
+ return JSON.stringify({ ...content, provenance: draft.provenance });
51
+ return JSON.stringify(content);
52
+ }
53
+ const repository = workflowLibraryRepository;
54
+ const filePath = workflowFilePath;
55
+
56
+ /** Installation-local git owns documents; the catalog owns method validation. */
57
+ export function createWorkflowLibrary({
58
+ git: gitAccess,
59
+ author,
60
+ catalog,
61
+ background,
62
+ index,
63
+ }: {
64
+ git?: GitBinding;
65
+ author: GitIdentity;
66
+ catalog: (appIds: readonly string[]) => Promise<Catalog>;
67
+ background?: BackgroundBinding;
68
+ index?: Pick<
69
+ WorkflowIndexes,
70
+ | "recordPublication"
71
+ | "publications"
72
+ | "drafts"
73
+ | "librarySnapshot"
74
+ | "recordLibrary"
75
+ | "invalidateLibrary"
76
+ | "documents"
77
+ | "versionCopies"
78
+ | "frontier"
79
+ | "libraryList"
80
+ | "publicationJobs"
81
+ >;
82
+ }) {
83
+ function requireGit() {
84
+ if (!gitAccess) throw new Error("Git access is not granted");
85
+ return gitAccess;
86
+ }
87
+ async function refs() {
88
+ return (await requireGit().info(repository))
89
+ ? requireGit().refs(repository)
90
+ : [];
91
+ }
92
+ async function head(ref: string) {
93
+ return (await refs()).find((entry) => entry.name === ref)?.hash ?? null;
94
+ }
95
+ async function move(
96
+ request: MoveRefRequest,
97
+ next: readonly Pick<MoveRefRequest, "ref" | "expected">[] = [],
98
+ ): Promise<GitRefMovesOutcome> {
99
+ try {
100
+ return await moveRefs(requireGit(), {
101
+ repository: request.repository,
102
+ source: request.source,
103
+ commit: request.commit,
104
+ updates: [{ ref: request.ref, expected: request.expected }, ...next],
105
+ });
106
+ } catch (cause) {
107
+ if (!(cause instanceof RangeError)) throw cause;
108
+ const current = await head(`refs/heads/${request.source}`);
109
+ if (current === request.commit) throw cause;
110
+ return { kind: "stale", ref: request.ref, head: current };
111
+ }
112
+ }
113
+ function read(workflowId: string, at: "draft" | { commit: string }) {
114
+ workflowIdSchema.parse(workflowId);
115
+ if (at === "draft")
116
+ return Promise.resolve(index?.drafts.read(workflowId) ?? null);
117
+ const key = `at:${workflowId}:${at.commit}`;
118
+ const copied = index?.documents.read(key);
119
+ if (copied?.kind !== "ready")
120
+ return Promise.resolve({ kind: "rebuilding" });
121
+ return Promise.resolve(
122
+ copied.value === null
123
+ ? null
124
+ : {
125
+ ...S.decodeUnknownSync(draftSchema)(retainedDraft(copied.value)),
126
+ commit: copied.value.commit,
127
+ },
128
+ );
129
+ }
130
+ async function saveDraft(
131
+ workflowId: string,
132
+ raw: unknown,
133
+ expectedRevision: number,
134
+ ) {
135
+ workflowIdSchema.parse(workflowId);
136
+ let draft: WorkflowDraft;
137
+ try {
138
+ draft = S.decodeUnknownSync(draftSchema)(raw, {
139
+ onExcessProperty: "error",
140
+ });
141
+ } catch (cause) {
142
+ if (!S.isSchemaError(cause)) throw cause;
143
+ return {
144
+ kind: "invalid" as const,
145
+ issues: [
146
+ {
147
+ code: "schema",
148
+ message: "Workflow definition or layout failed decoding",
149
+ },
150
+ ],
151
+ };
152
+ }
153
+ const available = await catalog(definitionAppIds(draft.definition));
154
+ const issues = validate(draft.definition, available);
155
+ if (issues.length) return { kind: "invalid" as const, issues };
156
+ if (!index) throw new Error("Workflow row storage unavailable");
157
+ const body = draftDocument.omit({ revision: true }).parse(draft);
158
+ return index.drafts.save(workflowId, body, expectedRevision);
159
+ }
160
+ function versions(workflowId: string) {
161
+ workflowIdSchema.parse(workflowId);
162
+ const copied = index?.versionCopies.read(workflowId);
163
+ if (copied?.kind === "ready") return Promise.resolve(copied.value);
164
+ const snapshot = index?.librarySnapshot().snapshot;
165
+ if (
166
+ snapshot &&
167
+ versionRefs(
168
+ workflowId,
169
+ Object.entries(snapshot.refs).map(([name, hash]) => ({ name, hash })),
170
+ ).length === 0
171
+ )
172
+ return Promise.resolve([]);
173
+ return Promise.resolve({ kind: "rebuilding" as const });
174
+ }
175
+ const fileContent = (draft: WorkflowDraft) =>
176
+ S.decodeUnknownSync(S.Json)(retainedDraft(draft));
177
+ async function sameFile(left: WorkflowDraft, right: WorkflowDraft) {
178
+ return (
179
+ (await contentDigest(fileContent(left))) ===
180
+ (await contentDigest(fileContent(right)))
181
+ );
182
+ }
183
+ async function publish(
184
+ workflowId: string,
185
+ expectedRevision: number,
186
+ caller?: AppMethodCallContext,
187
+ ) {
188
+ if (!index) throw new Error("Workflow row storage unavailable");
189
+ const draft = index.drafts.read(workflowId);
190
+ if (draft?.revision !== expectedRevision)
191
+ return { kind: "conflict" as const, head: null };
192
+ const retainedPublication = index.publicationJobs.existing(
193
+ workflowId,
194
+ expectedRevision,
195
+ caller,
196
+ );
197
+ if (retainedPublication !== null) return retainedPublication;
198
+ const issues = validate(
199
+ draft.definition,
200
+ await catalog(definitionAppIds(draft.definition)),
201
+ );
202
+ if (issues.length) return { kind: "invalid" as const, issues };
203
+ const snapshot = index.librarySnapshot();
204
+ const mainCopy = index.documents.read(`main:${workflowId}`);
205
+ const versionsCopy = index.versionCopies.read(workflowId);
206
+ if (
207
+ mainCopy.kind === "ready" &&
208
+ mainCopy.value &&
209
+ versionsCopy.kind === "ready" &&
210
+ (await sameFile(retainedDraft(draft), retainedDraft(mainCopy.value)))
211
+ ) {
212
+ for (const previous of versionsCopy.value) {
213
+ const publication = index.publications.read(
214
+ `${workflowId}:v${String(previous.version)}`,
215
+ );
216
+ if (
217
+ publication.kind === "ready" &&
218
+ (await sameFile(
219
+ retainedDraft(draft),
220
+ retainedDraft(publication.value),
221
+ ))
222
+ )
223
+ return {
224
+ kind: "published" as const,
225
+ commit: publication.value.commit,
226
+ version: publication.value.version,
227
+ };
228
+ }
229
+ }
230
+ if (caller && mayEnqueueBackground(caller))
231
+ return index.publicationJobs.enqueue(
232
+ workflowId,
233
+ expectedRevision,
234
+ caller,
235
+ );
236
+ if (!(await requireGit().info(repository)))
237
+ await requireGit().create(repository, { defaultBranch: "main" });
238
+ const references = await requireGit().refs(repository);
239
+ const main =
240
+ references.find((ref) => ref.name === "refs/heads/main")?.hash ?? null;
241
+ const existing = versionRefs(workflowId, references);
242
+ const result = await commitFiles(requireGit(), {
243
+ repository,
244
+ branch: "main",
245
+ expectedHead: main,
246
+ changes: [
247
+ {
248
+ path: filePath(workflowId),
249
+ content: new TextEncoder().encode(
250
+ serializedDraft(retainedDraft(draft)),
251
+ ),
252
+ mode: "100644",
253
+ },
254
+ ],
255
+ message: `Publish ${workflowId}`,
256
+ author,
257
+ });
258
+ if (result.kind === "stale")
259
+ return { kind: "conflict" as const, head: result.head };
260
+ if (result.kind === "updated")
261
+ throw new Error("Unexpected ref-only publication");
262
+ const version = (existing[0]?.version ?? 0) + 1;
263
+ const tagRef = `refs/tags/${workflowId}@v${String(version)}`;
264
+ const tag = await move({
265
+ repository,
266
+ ref: tagRef,
267
+ expected: null,
268
+ commit: result.commit,
269
+ source: "main",
270
+ });
271
+ if (tag.kind === "stale" && tag.head !== result.commit)
272
+ return { kind: "conflict" as const, head: tag.head };
273
+ const saved = { ...retainedDraft(draft), commit: result.commit };
274
+ const publication = {
275
+ ...saved,
276
+ workflowId,
277
+ version,
278
+ digest: S.decodeUnknownSync(sha256DigestSchema)(
279
+ await contentDigest(S.decodeUnknownSync(S.Json)(draft.definition)),
280
+ ),
281
+ };
282
+ index.recordPublication(publication, snapshot.revision);
283
+ return { kind: "published" as const, commit: result.commit, version };
284
+ }
285
+ async function rebuildCopies(
286
+ cursor: string | null,
287
+ target?: { workflowId: string; at: { commit: string } },
288
+ ) {
289
+ if (!index) throw new Error("Workflow copy storage unavailable");
290
+ const controller = new AbortController();
291
+ const counted = countingGit(requireGit(), {
292
+ limit: 40,
293
+ signal: controller.signal,
294
+ });
295
+ const source = listingGit(counted.git);
296
+ try {
297
+ if (target) {
298
+ workflowIdSchema.parse(target.workflowId);
299
+ const at = target.at;
300
+ const key = `at:${target.workflowId}:${at.commit}`;
301
+ const copied = await index.documents.load(key, {
302
+ git: requireGit(),
303
+ signal: controller.signal,
304
+ async rebuild(currentGit) {
305
+ const available = await currentGit.info(repository);
306
+ const commit = available ? at.commit : null;
307
+ const value = available
308
+ ? await readAt(listingGit(currentGit), target.workflowId, commit)
309
+ : null;
310
+ return { head: commit, value };
311
+ },
312
+ });
313
+ if (copied.kind !== "ready") return { done: false, cursor: null };
314
+ const doc = copied.value;
315
+ if (doc)
316
+ index.documents.write(
317
+ `at:${target.workflowId}:${doc.commit}`,
318
+ doc.commit,
319
+ doc,
320
+ );
321
+ return { done: true, cursor: null };
322
+ }
323
+ const epoch = index.librarySnapshot().revision;
324
+ const progress = cursor === null ? null : index.frontier.read(cursor);
325
+ let state =
326
+ progress?.kind === "ready" && progress.value.epoch === epoch
327
+ ? progress.value
328
+ : null;
329
+ const key = String(epoch);
330
+ if (!state) {
331
+ const references = (await counted.git.info(repository))
332
+ ? await counted.git.refs(repository)
333
+ : [];
334
+ const main =
335
+ references.find((ref) => ref.name === "refs/heads/main")?.hash ??
336
+ null;
337
+ const ids = new Set<string>();
338
+ for (const file of await workflowFiles(source, main)) {
339
+ const id = file.name.endsWith(".json") ? file.name.slice(0, -5) : "";
340
+ if (workflowIdSchema.safeParse(id).success) ids.add(id);
341
+ }
342
+ for (const ref of references)
343
+ if (ref.name.startsWith("refs/tags/")) {
344
+ const id = ref.name.slice("refs/tags/".length).split("@v")[0];
345
+ if (
346
+ id !== undefined &&
347
+ workflowIdSchema.safeParse(id).success &&
348
+ versionRefs(id, [ref]).length
349
+ )
350
+ ids.add(id);
351
+ }
352
+ state = {
353
+ epoch,
354
+ refs: references,
355
+ ids: [...ids].sort().slice(0, 100),
356
+ offset: 0,
357
+ items: [],
358
+ current: null,
359
+ queue: [],
360
+ };
361
+ }
362
+ const main =
363
+ state.refs.find((ref) => ref.name === "refs/heads/main")?.hash ?? null;
364
+ while (state.offset < state.ids.length) {
365
+ const workflowId = state.ids[state.offset];
366
+ if (workflowId === undefined)
367
+ throw new Error("Workflow frontier is incomplete");
368
+ if (state.current === null) {
369
+ if (state.queue.length === 0) {
370
+ const width = Math.min(4, Math.floor((40 - counted.total()) / 8));
371
+ if (width === 0) break;
372
+ state.queue = await Promise.all(
373
+ state.ids
374
+ .slice(state.offset, state.offset + width)
375
+ .map(async (id) => {
376
+ const published = await readAt(source, id, main);
377
+ return {
378
+ workflowId: id,
379
+ main: published,
380
+ versions: [],
381
+ versionOffset: 0,
382
+ };
383
+ }),
384
+ );
385
+ }
386
+ const prepared = state.queue.shift();
387
+ if (!prepared) throw new Error("Workflow cursor has no pending key");
388
+ state.current = prepared;
389
+ }
390
+ const current = state.current;
391
+ const tags = versionRefs(workflowId, state.refs).slice(0, 1000);
392
+ while (current.versionOffset < tags.length && counted.total() < 36) {
393
+ const tag = tags[current.versionOffset];
394
+ if (tag === undefined)
395
+ throw new Error("Workflow version frontier is incomplete");
396
+ const metadata = await source.readCommit(repository, tag.commit);
397
+ const historical = await readAt(source, workflowId, tag.commit);
398
+ if (metadata)
399
+ current.versions.push({
400
+ ...tag,
401
+ committedAt: metadata.committedAt,
402
+ message: metadata.message,
403
+ });
404
+ if (historical) {
405
+ index.documents.write(
406
+ `at:${workflowId}:${tag.commit}`,
407
+ tag.commit,
408
+ historical,
409
+ );
410
+ const stored = {
411
+ ...historical,
412
+ workflowId,
413
+ version: tag.version,
414
+ digest: S.decodeUnknownSync(sha256DigestSchema)(
415
+ await contentDigest(
416
+ S.decodeUnknownSync(S.Json)(historical.definition),
417
+ ),
418
+ ),
419
+ };
420
+ index.publications.write(
421
+ `${workflowId}:v${String(tag.version)}`,
422
+ tag.commit,
423
+ stored,
424
+ );
425
+ index.publications.write(
426
+ `${workflowId}:${tag.commit}`,
427
+ tag.commit,
428
+ stored,
429
+ );
430
+ }
431
+ current.versionOffset++;
432
+ }
433
+ if (index.librarySnapshot().revision !== epoch)
434
+ return { done: false, cursor: null };
435
+ if (current.versionOffset < tags.length) break;
436
+ const doc = current.main;
437
+ index.documents.write(`main:${workflowId}`, main, current.main);
438
+ for (const selected of [current.main])
439
+ if (selected)
440
+ index.documents.write(
441
+ `at:${workflowId}:${selected.commit}`,
442
+ selected.commit,
443
+ selected,
444
+ );
445
+ index.versionCopies.write(workflowId, main, current.versions);
446
+ if (doc) {
447
+ let latest: { version: number; commit: string } | null = null;
448
+ if (current.main)
449
+ for (const version of current.versions.slice(0, 1)) {
450
+ const historical = index.documents.read(
451
+ `at:${workflowId}:${version.commit}`,
452
+ );
453
+ if (
454
+ historical.kind === "ready" &&
455
+ historical.value &&
456
+ (await sameFile(
457
+ retainedDraft(current.main),
458
+ retainedDraft(historical.value),
459
+ ))
460
+ ) {
461
+ latest = version;
462
+ break;
463
+ }
464
+ }
465
+ state.items.push({
466
+ workflowId,
467
+ title: doc.definition.title,
468
+ draftRevision: index.drafts.read(workflowId)?.revision ?? null,
469
+ published: current.main
470
+ ? {
471
+ commit: latest?.commit ?? current.main.commit,
472
+ version: latest?.version ?? null,
473
+ }
474
+ : null,
475
+ });
476
+ }
477
+ state.offset++;
478
+ state.current = null;
479
+ }
480
+ if (index.librarySnapshot().revision !== epoch)
481
+ return { done: false, cursor: null };
482
+ if (state.offset === state.ids.length) {
483
+ index.recordLibrary(epoch, {
484
+ refs: Object.fromEntries(
485
+ state.refs.map((ref) => [ref.name, ref.hash]),
486
+ ),
487
+ items: state.items,
488
+ });
489
+ index.frontier.stale(key);
490
+ return { done: true, cursor: null };
491
+ }
492
+ index.frontier.write(key, main, state);
493
+ return { done: false, cursor: key };
494
+ } finally {
495
+ controller.abort();
496
+ }
497
+ }
498
+ function list() {
499
+ const cached = index?.libraryList();
500
+ return Promise.resolve(
501
+ cached?.kind === "ready"
502
+ ? cached.value.items
503
+ : { kind: "unindexed" as const },
504
+ );
505
+ }
506
+ async function history(workflowId: string) {
507
+ const saved = await versions(workflowId);
508
+ return Array.isArray(saved)
509
+ ? saved.slice(0, 50).map(({ commit, committedAt, message }) => ({
510
+ commit,
511
+ committedAt,
512
+ message,
513
+ }))
514
+ : [];
515
+ }
516
+ async function revert(
517
+ workflowId: string,
518
+ toCommit: string,
519
+ expectedRevision: number,
520
+ ) {
521
+ const previous = await read(workflowId, { commit: toCommit });
522
+ return previous && !("kind" in previous)
523
+ ? saveDraft(workflowId, retainedDraft(previous), expectedRevision)
524
+ : {
525
+ kind: "invalid",
526
+ issues: [
527
+ { code: "schema", message: "Workflow file not found at commit" },
528
+ ],
529
+ };
530
+ }
531
+ async function published(
532
+ workflowId: string,
533
+ at: { version: number } | { commit: string },
534
+ ) {
535
+ return readPublishedWorkflow(requireGit(), workflowId, at, (commit) => {
536
+ const copied = index?.documents.read(`at:${workflowId}:${commit}`);
537
+ return copied?.kind === "ready" &&
538
+ copied.head === commit &&
539
+ copied.value?.commit === commit
540
+ ? { ...retainedDraft(copied.value), commit }
541
+ : null;
542
+ });
543
+ }
544
+ function publishedRow(
545
+ workflowId: string,
546
+ at: { version: number } | { commit: string },
547
+ ) {
548
+ workflowIdSchema.parse(workflowId);
549
+ if (!index) throw new Error("Workflow row storage unavailable");
550
+ const key =
551
+ "version" in at
552
+ ? `${workflowId}:v${String(at.version)}`
553
+ : `${workflowId}:${at.commit}`;
554
+ const row = index.publications.read(key);
555
+ if (row.kind !== "ready" || row.value.workflowId !== workflowId)
556
+ return null;
557
+ if (
558
+ "version" in at
559
+ ? row.value.version !== at.version
560
+ : row.value.commit !== at.commit
561
+ )
562
+ return null;
563
+ return row.value;
564
+ }
565
+ return {
566
+ publishedRow,
567
+ read,
568
+ saveDraft,
569
+ publish,
570
+ versions,
571
+ list,
572
+ rebuildCopies,
573
+ history,
574
+ revert,
575
+ published,
576
+ continuePublication: (caller: AppMethodCallContext) => {
577
+ if (!index) throw new Error("Workflow row storage unavailable");
578
+ return index.publicationJobs.continue(caller, {
579
+ git: requireGit(),
580
+ author,
581
+ background,
582
+ catalog,
583
+ });
584
+ },
585
+ publicationStatus: (
586
+ workflowId: string,
587
+ publicationId: string,
588
+ caller: AppMethodCallContext,
589
+ ) => {
590
+ if (!index) throw new Error("Workflow row storage unavailable");
591
+ return index.publicationJobs.status(workflowId, publicationId, caller);
592
+ },
593
+ };
594
+ }