@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
package/backend/push.ts
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import {
|
|
2
|
+
countingGit,
|
|
3
|
+
ReadCopyBudgetExceeded,
|
|
4
|
+
} from "@fungi.computer/agar/read-copy";
|
|
5
|
+
import { validate, type Catalog } from "@fungi.computer/braid";
|
|
6
|
+
import {
|
|
7
|
+
dispatchAppGitPush,
|
|
8
|
+
type AppGitPushFact,
|
|
9
|
+
type GitBinding,
|
|
10
|
+
} from "@fungi.computer/agar/git";
|
|
11
|
+
import {
|
|
12
|
+
changedWorkflowIds,
|
|
13
|
+
listingGit,
|
|
14
|
+
versionRefs,
|
|
15
|
+
inspectWorkflowFile,
|
|
16
|
+
} from "@fungi.computer/workflows-wire";
|
|
17
|
+
import { definitionAppIds } from "./catalog.js";
|
|
18
|
+
import type { WorkflowIndexes, WorkflowPushIssue } from "./indexes.js";
|
|
19
|
+
|
|
20
|
+
/** Accepted push facts invalidate the list and refresh validation; they cannot start runs. */
|
|
21
|
+
export async function onWorkflowGitPush(
|
|
22
|
+
{
|
|
23
|
+
git,
|
|
24
|
+
indexes,
|
|
25
|
+
catalog,
|
|
26
|
+
}: {
|
|
27
|
+
git: GitBinding;
|
|
28
|
+
indexes: WorkflowIndexes;
|
|
29
|
+
catalog: (appIds: readonly string[]) => Promise<Catalog | null>;
|
|
30
|
+
},
|
|
31
|
+
raw: AppGitPushFact,
|
|
32
|
+
): Promise<void> {
|
|
33
|
+
await dispatchAppGitPush(
|
|
34
|
+
{
|
|
35
|
+
onGitPush: async (fact) => {
|
|
36
|
+
if (fact.repository !== "trellis") return;
|
|
37
|
+
const updates = fact.updates.filter(
|
|
38
|
+
(update) =>
|
|
39
|
+
update.ref === "refs/heads/main" ||
|
|
40
|
+
update.ref.startsWith("refs/tags/"),
|
|
41
|
+
);
|
|
42
|
+
if (updates.length === 0 && !fact.unexplained?.length) return;
|
|
43
|
+
const snapshot = indexes.librarySnapshot();
|
|
44
|
+
if (
|
|
45
|
+
snapshot.snapshot &&
|
|
46
|
+
updates.every(
|
|
47
|
+
(update) => snapshot.snapshot?.refs[update.ref] === update.after,
|
|
48
|
+
) &&
|
|
49
|
+
!fact.unexplained?.length
|
|
50
|
+
)
|
|
51
|
+
return;
|
|
52
|
+
indexes.invalidateLibrary();
|
|
53
|
+
const revision = indexes.librarySnapshot().revision;
|
|
54
|
+
const controller = new AbortController();
|
|
55
|
+
const counted = countingGit(git, {
|
|
56
|
+
limit: 40,
|
|
57
|
+
signal: controller.signal,
|
|
58
|
+
});
|
|
59
|
+
const source = listingGit(counted.git);
|
|
60
|
+
// Accumulates only the App ids this push's own files actually name;
|
|
61
|
+
// a later file in the same push extends it instead of refetching.
|
|
62
|
+
let known: Catalog | null = null;
|
|
63
|
+
const catalogFor = async (appIds: readonly string[]) => {
|
|
64
|
+
const current = known;
|
|
65
|
+
if (current === null) {
|
|
66
|
+
known = await catalog(appIds);
|
|
67
|
+
return known;
|
|
68
|
+
}
|
|
69
|
+
const missing = appIds.filter((id) => !(id in current));
|
|
70
|
+
if (missing.length === 0) return current;
|
|
71
|
+
const fetched = await catalog(missing);
|
|
72
|
+
if (fetched === null) {
|
|
73
|
+
known = null;
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
known = { ...current, ...fetched };
|
|
77
|
+
return known;
|
|
78
|
+
};
|
|
79
|
+
const zero = "0".repeat(40);
|
|
80
|
+
const nextRefs = new Map(Object.entries(snapshot.snapshot?.refs ?? {}));
|
|
81
|
+
const items = [...(snapshot.snapshot?.items ?? [])];
|
|
82
|
+
const invalidate = () => {
|
|
83
|
+
for (const item of snapshot.snapshot?.items ?? []) {
|
|
84
|
+
indexes.documents.stale(`main:${item.workflowId}`);
|
|
85
|
+
indexes.versionCopies.stale(item.workflowId);
|
|
86
|
+
}
|
|
87
|
+
indexes.invalidateLibrary();
|
|
88
|
+
};
|
|
89
|
+
try {
|
|
90
|
+
if (fact.unexplained?.length) {
|
|
91
|
+
invalidate();
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const recorded = new Set(
|
|
95
|
+
indexes
|
|
96
|
+
.issues(fact.eventId)
|
|
97
|
+
.map((row) => `${row.commit}:${row.workflowId}`),
|
|
98
|
+
);
|
|
99
|
+
for (const update of updates) {
|
|
100
|
+
if (update.ref !== "refs/heads/main") continue;
|
|
101
|
+
for (const workflowId of await changedWorkflowIds(
|
|
102
|
+
source,
|
|
103
|
+
update.before,
|
|
104
|
+
update.after,
|
|
105
|
+
)) {
|
|
106
|
+
const key = `${update.after}:${workflowId}`;
|
|
107
|
+
if (recorded.has(key)) continue;
|
|
108
|
+
const file = await inspectWorkflowFile(
|
|
109
|
+
source,
|
|
110
|
+
workflowId,
|
|
111
|
+
update.after,
|
|
112
|
+
);
|
|
113
|
+
const issues: WorkflowPushIssue[] =
|
|
114
|
+
file.kind === "invalid" ? [...file.issues] : [];
|
|
115
|
+
if (file.kind !== "missing") {
|
|
116
|
+
const needed =
|
|
117
|
+
file.kind === "valid"
|
|
118
|
+
? definitionAppIds(file.draft.definition)
|
|
119
|
+
: [];
|
|
120
|
+
const resolved = await catalogFor(needed);
|
|
121
|
+
if (resolved === null)
|
|
122
|
+
issues.push({
|
|
123
|
+
code: "catalog_unavailable",
|
|
124
|
+
message:
|
|
125
|
+
"App-method validation requires the lifecycle installation catalog",
|
|
126
|
+
});
|
|
127
|
+
else if (file.kind === "valid")
|
|
128
|
+
issues.push(...validate(file.draft.definition, resolved));
|
|
129
|
+
}
|
|
130
|
+
if (indexes.librarySnapshot().revision !== revision) return;
|
|
131
|
+
indexes.recordIssues(
|
|
132
|
+
fact.eventId,
|
|
133
|
+
update.after,
|
|
134
|
+
workflowId,
|
|
135
|
+
issues,
|
|
136
|
+
);
|
|
137
|
+
recorded.add(key);
|
|
138
|
+
const scope = "main";
|
|
139
|
+
const copied = indexes.documents.read(`${scope}:${workflowId}`);
|
|
140
|
+
const known =
|
|
141
|
+
(snapshot.snapshot?.refs[update.ref] ?? zero) ===
|
|
142
|
+
update.before ||
|
|
143
|
+
(copied.kind === "ready" && copied.head === update.after);
|
|
144
|
+
if (!known) {
|
|
145
|
+
invalidate();
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (update.after === zero) {
|
|
149
|
+
indexes.documents.stale(`${scope}:${workflowId}`);
|
|
150
|
+
const remaining = indexes.drafts.read(workflowId);
|
|
151
|
+
const position = items.findIndex(
|
|
152
|
+
(item) => item.workflowId === workflowId,
|
|
153
|
+
);
|
|
154
|
+
if (remaining) {
|
|
155
|
+
const row = {
|
|
156
|
+
workflowId,
|
|
157
|
+
title: remaining.definition.title,
|
|
158
|
+
draftRevision: remaining.revision,
|
|
159
|
+
published: null,
|
|
160
|
+
};
|
|
161
|
+
if (position < 0) items.push(row);
|
|
162
|
+
else items[position] = row;
|
|
163
|
+
} else if (position >= 0) items.splice(position, 1);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
const doc =
|
|
167
|
+
file.kind === "valid"
|
|
168
|
+
? { ...file.draft, commit: update.after }
|
|
169
|
+
: null;
|
|
170
|
+
indexes.documents.write(
|
|
171
|
+
`${scope}:${workflowId}`,
|
|
172
|
+
update.after,
|
|
173
|
+
doc,
|
|
174
|
+
);
|
|
175
|
+
if (doc)
|
|
176
|
+
indexes.documents.write(
|
|
177
|
+
`at:${workflowId}:${update.after}`,
|
|
178
|
+
update.after,
|
|
179
|
+
doc,
|
|
180
|
+
);
|
|
181
|
+
const position = items.findIndex(
|
|
182
|
+
(item) => item.workflowId === workflowId,
|
|
183
|
+
);
|
|
184
|
+
const draft = indexes.drafts.read(workflowId);
|
|
185
|
+
const title =
|
|
186
|
+
draft?.definition.title ?? doc?.definition.title ?? null;
|
|
187
|
+
if (title !== null) {
|
|
188
|
+
const row = {
|
|
189
|
+
workflowId,
|
|
190
|
+
title,
|
|
191
|
+
draftRevision: draft?.revision ?? null,
|
|
192
|
+
published: doc
|
|
193
|
+
? { commit: update.after, version: null }
|
|
194
|
+
: null,
|
|
195
|
+
};
|
|
196
|
+
if (position < 0) items.push(row);
|
|
197
|
+
else items[position] = row;
|
|
198
|
+
} else if (position >= 0) items.splice(position, 1);
|
|
199
|
+
}
|
|
200
|
+
if (update.after === zero) nextRefs.delete(update.ref);
|
|
201
|
+
else nextRefs.set(update.ref, update.after);
|
|
202
|
+
}
|
|
203
|
+
for (const update of updates) {
|
|
204
|
+
if (!update.ref.startsWith("refs/tags/")) continue;
|
|
205
|
+
const candidateIds = items.map((item) => item.workflowId);
|
|
206
|
+
const selected = candidateIds.flatMap((workflowId) =>
|
|
207
|
+
versionRefs(workflowId, [
|
|
208
|
+
{ name: update.ref, hash: update.after },
|
|
209
|
+
]).map((tag) => ({ workflowId, ...tag })),
|
|
210
|
+
)[0];
|
|
211
|
+
if (!selected) continue;
|
|
212
|
+
const { workflowId, version } = selected;
|
|
213
|
+
const copied = indexes.versionCopies.read(workflowId);
|
|
214
|
+
if (copied.kind !== "ready") {
|
|
215
|
+
indexes.versionCopies.stale(workflowId);
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
if (update.after === zero) {
|
|
219
|
+
indexes.versionCopies.stale(workflowId);
|
|
220
|
+
nextRefs.delete(update.ref);
|
|
221
|
+
const row = items.find((item) => item.workflowId === workflowId);
|
|
222
|
+
if (row?.published?.version === version)
|
|
223
|
+
row.published.version = null;
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
if (
|
|
227
|
+
copied.value.some(
|
|
228
|
+
(item) =>
|
|
229
|
+
item.version === version && item.commit === update.after,
|
|
230
|
+
)
|
|
231
|
+
)
|
|
232
|
+
continue;
|
|
233
|
+
if (
|
|
234
|
+
update.before !== zero &&
|
|
235
|
+
!copied.value.some(
|
|
236
|
+
(item) =>
|
|
237
|
+
item.version === version && item.commit === update.before,
|
|
238
|
+
)
|
|
239
|
+
) {
|
|
240
|
+
indexes.versionCopies.stale(workflowId);
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
const metadata = await source.readCommit(
|
|
244
|
+
fact.repository,
|
|
245
|
+
update.after,
|
|
246
|
+
);
|
|
247
|
+
if (indexes.librarySnapshot().revision !== revision) return;
|
|
248
|
+
if (!metadata) {
|
|
249
|
+
indexes.versionCopies.stale(workflowId);
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
indexes.versionCopies.write(
|
|
253
|
+
workflowId,
|
|
254
|
+
update.after,
|
|
255
|
+
[
|
|
256
|
+
...copied.value.filter((item) => item.version !== version),
|
|
257
|
+
{
|
|
258
|
+
version,
|
|
259
|
+
commit: update.after,
|
|
260
|
+
committedAt: metadata.committedAt,
|
|
261
|
+
message: metadata.message,
|
|
262
|
+
},
|
|
263
|
+
].sort((a, b) => b.version - a.version),
|
|
264
|
+
);
|
|
265
|
+
const row = items.find((item) => item.workflowId === workflowId);
|
|
266
|
+
if (row?.published?.commit === update.after)
|
|
267
|
+
row.published.version = version;
|
|
268
|
+
nextRefs.set(update.ref, update.after);
|
|
269
|
+
}
|
|
270
|
+
if (snapshot.snapshot)
|
|
271
|
+
indexes.recordLibrary(revision, {
|
|
272
|
+
refs: Object.fromEntries(nextRefs),
|
|
273
|
+
items: items
|
|
274
|
+
.sort((a, b) => a.workflowId.localeCompare(b.workflowId))
|
|
275
|
+
.slice(0, 100),
|
|
276
|
+
});
|
|
277
|
+
} catch (error) {
|
|
278
|
+
if (!(error instanceof ReadCopyBudgetExceeded)) throw error;
|
|
279
|
+
invalidate();
|
|
280
|
+
} finally {
|
|
281
|
+
controller.abort();
|
|
282
|
+
}
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
raw,
|
|
286
|
+
);
|
|
287
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import {
|
|
2
|
+
triggerTargetSchema,
|
|
3
|
+
scheduleArmReplySchema,
|
|
4
|
+
triggerDisarmReplySchema,
|
|
5
|
+
schedulesReplySchema,
|
|
6
|
+
scheduleDisarmInputSchema,
|
|
7
|
+
eventDisarmInputSchema,
|
|
8
|
+
triggerListInputSchema,
|
|
9
|
+
eventArmReplySchema,
|
|
10
|
+
eventTriggersReplySchema,
|
|
11
|
+
type WorkflowTriggerTarget,
|
|
12
|
+
} from "@fungi.computer/workflows-wire";
|
|
13
|
+
import { defineAppMethod } from "@fungi.computer/agar/backend-definition";
|
|
14
|
+
import { createWorkflowLibrary } from "./library.js";
|
|
15
|
+
import { readWorkflowCatalog } from "./catalog.js";
|
|
16
|
+
import type { WorkflowMethodContext } from "./methods.js";
|
|
17
|
+
|
|
18
|
+
const granted = (context: WorkflowMethodContext) => {
|
|
19
|
+
if (!context.env.WORKFLOWS) throw new Error("Workflow access is not granted");
|
|
20
|
+
return context.env.WORKFLOWS;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** Read only this App's immutable publication rows; no Git repair occurs in arming. */
|
|
24
|
+
function publication(
|
|
25
|
+
context: WorkflowMethodContext,
|
|
26
|
+
target: WorkflowTriggerTarget,
|
|
27
|
+
) {
|
|
28
|
+
if (context.indexes === undefined) return null;
|
|
29
|
+
return createWorkflowLibrary({
|
|
30
|
+
index: context.indexes,
|
|
31
|
+
author: { name: "Trellis", email: "trellis@app.local" },
|
|
32
|
+
catalog: async (appIds) => {
|
|
33
|
+
const catalog = await readWorkflowCatalog(context.env.WORKFLOWS, appIds);
|
|
34
|
+
if (catalog === null) throw new Error("Workflow catalog unavailable");
|
|
35
|
+
return catalog;
|
|
36
|
+
},
|
|
37
|
+
}).publishedRow(target.workflowId, { commit: target.commit });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const unavailablePublication = {
|
|
41
|
+
kind: "refused" as const,
|
|
42
|
+
reason: "Workflow publication is unavailable; re-arm after publishing",
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const scheduleMethods = {
|
|
46
|
+
arm: defineAppMethod({
|
|
47
|
+
description:
|
|
48
|
+
"Arm a published Workflow schedule as you on the pinned Computer. An optional delivery chat receives every run's ending.",
|
|
49
|
+
effect: "write",
|
|
50
|
+
requires: ["workflow.run", "workflow.arm"],
|
|
51
|
+
input: triggerTargetSchema,
|
|
52
|
+
output: scheduleArmReplySchema,
|
|
53
|
+
handler: async (input, context: WorkflowMethodContext) => {
|
|
54
|
+
const workflows = granted(context);
|
|
55
|
+
const stored = publication(context, input);
|
|
56
|
+
if (stored === null) return unavailablePublication;
|
|
57
|
+
return scheduleArmReplySchema.parse(
|
|
58
|
+
await workflows.armSchedule({ ...input, digest: stored.digest }),
|
|
59
|
+
);
|
|
60
|
+
},
|
|
61
|
+
}),
|
|
62
|
+
disarm: defineAppMethod({
|
|
63
|
+
description: "Cancel an armed Workflow schedule.",
|
|
64
|
+
effect: "write",
|
|
65
|
+
requires: ["workflow.run", "workflow.arm"],
|
|
66
|
+
input: scheduleDisarmInputSchema,
|
|
67
|
+
output: triggerDisarmReplySchema,
|
|
68
|
+
handler: async (input, context: WorkflowMethodContext) =>
|
|
69
|
+
triggerDisarmReplySchema.parse(
|
|
70
|
+
await granted(context).disarmSchedule(input),
|
|
71
|
+
),
|
|
72
|
+
}),
|
|
73
|
+
listTriggers: defineAppMethod({
|
|
74
|
+
description:
|
|
75
|
+
"List this installation's schedules, next run times and paused reasons.",
|
|
76
|
+
effect: "read",
|
|
77
|
+
requires: ["workflow.run"],
|
|
78
|
+
input: triggerListInputSchema,
|
|
79
|
+
output: schedulesReplySchema,
|
|
80
|
+
handler: async (_input, context: WorkflowMethodContext) =>
|
|
81
|
+
schedulesReplySchema.parse(await granted(context).schedules({})),
|
|
82
|
+
}),
|
|
83
|
+
armEventTrigger: defineAppMethod({
|
|
84
|
+
description:
|
|
85
|
+
"Arm a published Workflow App-event trigger as you on the pinned Computer. An optional delivery chat receives every run's ending.",
|
|
86
|
+
effect: "write",
|
|
87
|
+
requires: ["workflow.run", "workflow.arm"],
|
|
88
|
+
input: triggerTargetSchema,
|
|
89
|
+
output: eventArmReplySchema,
|
|
90
|
+
handler: async (input, context: WorkflowMethodContext) => {
|
|
91
|
+
const workflows = granted(context);
|
|
92
|
+
const stored = publication(context, input);
|
|
93
|
+
if (stored === null) return unavailablePublication;
|
|
94
|
+
return eventArmReplySchema.parse(
|
|
95
|
+
await workflows.armEventTrigger({ ...input, digest: stored.digest }),
|
|
96
|
+
);
|
|
97
|
+
},
|
|
98
|
+
}),
|
|
99
|
+
disarmEventTrigger: defineAppMethod({
|
|
100
|
+
description: "Disarm this installation's App-event trigger.",
|
|
101
|
+
effect: "write",
|
|
102
|
+
requires: ["workflow.run", "workflow.arm"],
|
|
103
|
+
input: eventDisarmInputSchema,
|
|
104
|
+
output: triggerDisarmReplySchema,
|
|
105
|
+
handler: async (input, context: WorkflowMethodContext) =>
|
|
106
|
+
triggerDisarmReplySchema.parse(
|
|
107
|
+
await granted(context).disarmEventTrigger(input),
|
|
108
|
+
),
|
|
109
|
+
}),
|
|
110
|
+
eventTriggers: defineAppMethod({
|
|
111
|
+
description:
|
|
112
|
+
"List this installation's App-event triggers and paused reasons.",
|
|
113
|
+
effect: "read",
|
|
114
|
+
requires: ["workflow.run"],
|
|
115
|
+
input: triggerListInputSchema,
|
|
116
|
+
output: eventTriggersReplySchema,
|
|
117
|
+
handler: async (_input, context: WorkflowMethodContext) =>
|
|
118
|
+
eventTriggersReplySchema.parse(await granted(context).eventTriggers({})),
|
|
119
|
+
}),
|
|
120
|
+
};
|
package/backend/share.ts
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { workflowIdSchema } from "@fungi.computer/braid";
|
|
2
|
+
import * as S from "effect/Schema";
|
|
3
|
+
import * as Result from "effect/Result";
|
|
4
|
+
import type * as z from "zod";
|
|
5
|
+
import { gitSha1Schema } from "@fungi.computer/puffball/git-representation";
|
|
6
|
+
import { sha256DigestSchema } from "@fungi.computer/puffball/effect";
|
|
7
|
+
import {
|
|
8
|
+
workflowDefinitionSchema,
|
|
9
|
+
validate,
|
|
10
|
+
type Catalog,
|
|
11
|
+
type WorkflowDefinition,
|
|
12
|
+
type Issue,
|
|
13
|
+
} from "@fungi.computer/braid";
|
|
14
|
+
import {
|
|
15
|
+
provenanceSchema,
|
|
16
|
+
shareReview,
|
|
17
|
+
shareCopyReply,
|
|
18
|
+
shareExportReply,
|
|
19
|
+
} from "@fungi.computer/workflows-wire";
|
|
20
|
+
import type { WorkflowDraft } from "@fungi.computer/workflows-wire";
|
|
21
|
+
import type { createWorkflowLibrary } from "./library.js";
|
|
22
|
+
|
|
23
|
+
const workflowId = S.declare(
|
|
24
|
+
(value): value is string => workflowIdSchema.safeParse(value).success,
|
|
25
|
+
);
|
|
26
|
+
const commit = S.declare(
|
|
27
|
+
(value): value is string => gitSha1Schema.safeParse(value).success,
|
|
28
|
+
);
|
|
29
|
+
export const shareBundleSchema = S.Struct({
|
|
30
|
+
format: S.Literal("fungi.workflow-share/v1"),
|
|
31
|
+
definition: workflowDefinitionSchema,
|
|
32
|
+
layout: S.Record(S.String, S.Struct({ x: S.Finite, y: S.Finite })),
|
|
33
|
+
source: S.Struct({
|
|
34
|
+
workflowId,
|
|
35
|
+
version: S.Number.pipe(S.check(S.isInt()), S.check(S.isGreaterThan(0))),
|
|
36
|
+
commit,
|
|
37
|
+
origin: S.optionalKey(S.String.pipe(S.check(S.isMaxLength(256)))),
|
|
38
|
+
}),
|
|
39
|
+
});
|
|
40
|
+
type Bundle = typeof shareBundleSchema.Type;
|
|
41
|
+
type Provenance = z.output<typeof provenanceSchema>;
|
|
42
|
+
const maxShareBytes = 512 * 1024;
|
|
43
|
+
|
|
44
|
+
function review(definition: WorkflowDefinition, catalog: Catalog) {
|
|
45
|
+
const graphs = [
|
|
46
|
+
{ id: "root", graph: definition },
|
|
47
|
+
...Object.entries(definition.subgraphs ?? {}).map(([id, graph]) => ({
|
|
48
|
+
id,
|
|
49
|
+
graph,
|
|
50
|
+
})),
|
|
51
|
+
];
|
|
52
|
+
return shareReview.parse({
|
|
53
|
+
gitAccess: graphs.flatMap(({ id, graph }) =>
|
|
54
|
+
graph.nodes.flatMap((node) =>
|
|
55
|
+
node.kind === "app" && node.gitAccess
|
|
56
|
+
? [{ graph: id, node: node.id, ...node.gitAccess }]
|
|
57
|
+
: [],
|
|
58
|
+
),
|
|
59
|
+
),
|
|
60
|
+
writeNodes: graphs.flatMap(({ id, graph }) =>
|
|
61
|
+
graph.nodes.flatMap((node) => {
|
|
62
|
+
if (node.kind !== "app") return [];
|
|
63
|
+
const appId = definition.apps[node.app]?.appId;
|
|
64
|
+
const method =
|
|
65
|
+
appId === undefined
|
|
66
|
+
? undefined
|
|
67
|
+
: catalog[appId]?.methods[node.method];
|
|
68
|
+
return method?.effect === "write" || method?.effect === "external"
|
|
69
|
+
? [
|
|
70
|
+
{
|
|
71
|
+
graph: id,
|
|
72
|
+
node: node.id,
|
|
73
|
+
appId,
|
|
74
|
+
method: node.method,
|
|
75
|
+
effect: method.effect,
|
|
76
|
+
},
|
|
77
|
+
]
|
|
78
|
+
: [];
|
|
79
|
+
}),
|
|
80
|
+
),
|
|
81
|
+
triggers: definition.triggers ?? [],
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** A copy is named after its source; an over-long title keeps the source's. */
|
|
86
|
+
function copyTitled(definition: WorkflowDefinition): WorkflowDefinition {
|
|
87
|
+
const named = { ...definition, title: `${definition.title} (copy)` };
|
|
88
|
+
return S.is(workflowDefinitionSchema)(named) ? named : definition;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function digest(bytes: Uint8Array) {
|
|
92
|
+
const hash = new Uint8Array(
|
|
93
|
+
await crypto.subtle.digest("SHA-256", Uint8Array.from(bytes)),
|
|
94
|
+
);
|
|
95
|
+
return S.decodeUnknownSync(sha256DigestSchema)(
|
|
96
|
+
`sha256:${Array.from(hash, (byte) => byte.toString(16).padStart(2, "0")).join("")}`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Copies published content into a new draft; provenance is a claim, never authority. */
|
|
101
|
+
export function createWorkflowShare({
|
|
102
|
+
library,
|
|
103
|
+
catalog,
|
|
104
|
+
origin,
|
|
105
|
+
}: {
|
|
106
|
+
library: Pick<
|
|
107
|
+
ReturnType<typeof createWorkflowLibrary>,
|
|
108
|
+
"published" | "saveDraft"
|
|
109
|
+
>;
|
|
110
|
+
catalog: (appIds: readonly string[]) => Promise<Catalog>;
|
|
111
|
+
origin?: string;
|
|
112
|
+
}) {
|
|
113
|
+
async function save(
|
|
114
|
+
workflowId: string,
|
|
115
|
+
draft: Pick<WorkflowDraft, "definition" | "layout">,
|
|
116
|
+
provenance: Provenance,
|
|
117
|
+
) {
|
|
118
|
+
const wanted = [
|
|
119
|
+
...new Set(Object.values(draft.definition.apps).map((app) => app.appId)),
|
|
120
|
+
];
|
|
121
|
+
const available = await catalog(wanted);
|
|
122
|
+
const appIds = wanted.filter((appId) => !Object.hasOwn(available, appId));
|
|
123
|
+
if (appIds.length)
|
|
124
|
+
return shareCopyReply.parse({ kind: "missing_apps", appIds });
|
|
125
|
+
const issues: readonly Issue[] = validate(draft.definition, available);
|
|
126
|
+
if (issues.length) return shareCopyReply.parse({ kind: "invalid", issues });
|
|
127
|
+
const result = await library.saveDraft(
|
|
128
|
+
workflowId,
|
|
129
|
+
{ definition: draft.definition, layout: draft.layout, provenance },
|
|
130
|
+
0,
|
|
131
|
+
);
|
|
132
|
+
return shareCopyReply.parse(
|
|
133
|
+
result.kind === "saved"
|
|
134
|
+
? {
|
|
135
|
+
...result,
|
|
136
|
+
title: draft.definition.title,
|
|
137
|
+
review: review(draft.definition, available),
|
|
138
|
+
}
|
|
139
|
+
: result,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
async exportWorkflow(input: {
|
|
144
|
+
workflowId: string;
|
|
145
|
+
version: number;
|
|
146
|
+
}): Promise<z.output<typeof shareExportReply>> {
|
|
147
|
+
const document = await library.published(input.workflowId, {
|
|
148
|
+
version: input.version,
|
|
149
|
+
});
|
|
150
|
+
if (!document) return { kind: "not_found" as const };
|
|
151
|
+
let source: Bundle["source"] = { ...input, commit: document.commit };
|
|
152
|
+
if (origin !== undefined) source = { ...source, origin };
|
|
153
|
+
const bundle: Bundle = S.decodeUnknownSync(shareBundleSchema)(
|
|
154
|
+
{
|
|
155
|
+
format: "fungi.workflow-share/v1",
|
|
156
|
+
definition: document.definition,
|
|
157
|
+
layout: document.layout,
|
|
158
|
+
source,
|
|
159
|
+
},
|
|
160
|
+
{ onExcessProperty: "error" },
|
|
161
|
+
);
|
|
162
|
+
const bytes = JSON.stringify(bundle);
|
|
163
|
+
const encoded = new TextEncoder().encode(bytes);
|
|
164
|
+
if (encoded.byteLength > maxShareBytes)
|
|
165
|
+
return { kind: "too_large" as const };
|
|
166
|
+
return { bytes, sha256: await digest(encoded) };
|
|
167
|
+
},
|
|
168
|
+
async importWorkflow(input: {
|
|
169
|
+
bytes: string;
|
|
170
|
+
sha256: string;
|
|
171
|
+
workflowId: string;
|
|
172
|
+
}) {
|
|
173
|
+
const bytes = new TextEncoder().encode(input.bytes);
|
|
174
|
+
if (bytes.byteLength > maxShareBytes)
|
|
175
|
+
return { kind: "too_large" as const };
|
|
176
|
+
if (
|
|
177
|
+
!S.is(sha256DigestSchema)(input.sha256) ||
|
|
178
|
+
(await digest(bytes)) !== input.sha256
|
|
179
|
+
)
|
|
180
|
+
return { kind: "digest_mismatch" as const };
|
|
181
|
+
const parsed = S.decodeUnknownResult(S.fromJsonString(shareBundleSchema))(
|
|
182
|
+
input.bytes,
|
|
183
|
+
{ onExcessProperty: "error" },
|
|
184
|
+
);
|
|
185
|
+
if (Result.isFailure(parsed)) return { kind: "invalid_bundle" as const };
|
|
186
|
+
const bundle = parsed.success;
|
|
187
|
+
return save(
|
|
188
|
+
input.workflowId,
|
|
189
|
+
{ definition: copyTitled(bundle.definition), layout: bundle.layout },
|
|
190
|
+
provenanceSchema.parse({
|
|
191
|
+
kind: "import",
|
|
192
|
+
...bundle.source,
|
|
193
|
+
sha256: input.sha256,
|
|
194
|
+
}),
|
|
195
|
+
);
|
|
196
|
+
},
|
|
197
|
+
async forkPublishedWorkflow(input: {
|
|
198
|
+
sourceWorkflowId: string;
|
|
199
|
+
at: { version: number } | { commit: string };
|
|
200
|
+
workflowId: string;
|
|
201
|
+
}) {
|
|
202
|
+
const document = await library.published(
|
|
203
|
+
input.sourceWorkflowId,
|
|
204
|
+
input.at,
|
|
205
|
+
);
|
|
206
|
+
if (!document) return { kind: "not_found" as const };
|
|
207
|
+
return save(
|
|
208
|
+
input.workflowId,
|
|
209
|
+
{
|
|
210
|
+
definition: copyTitled(document.definition),
|
|
211
|
+
layout: document.layout,
|
|
212
|
+
},
|
|
213
|
+
provenanceSchema.parse({
|
|
214
|
+
kind: "fork",
|
|
215
|
+
workflowId: input.sourceWorkflowId,
|
|
216
|
+
commit: document.commit,
|
|
217
|
+
}),
|
|
218
|
+
);
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
}
|