@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,124 @@
1
+ import * as z from "zod";
2
+ import { defineAppMethod } from "@fungi.computer/agar/backend-definition";
3
+ import { catalogReplySchema } from "@fungi.computer/workflows-wire";
4
+ import { runReply } from "@fungi.computer/workflows-wire";
5
+ import { workflowToolListing } from "../shared/tool-contracts.js";
6
+ import type { WorkflowIndexes } from "./indexes.js";
7
+ import type { WorkflowMethodContext } from "./methods.js";
8
+
9
+ const output = z.toJSONSchema(runReply, { io: "output" });
10
+
11
+ // Workflow ids have no underscores or upper-case letters. Preserve dash cases
12
+ // that cannot become camel case, so distinct ids cannot alias the same tool.
13
+ function toolName(workflowId: string): string {
14
+ const camel = workflowId.replace(
15
+ /-([a-z])|-/g,
16
+ (_, letter: string | undefined) =>
17
+ letter === undefined ? "_" : letter.toUpperCase(),
18
+ );
19
+ return /^[0-9]/.test(camel) ? `Workflow_${camel}` : camel;
20
+ }
21
+
22
+ /** A Workflow's own declared App roles: `{ alias: { appId, role? } }`. */
23
+ const appRoleSchema = z.record(
24
+ z.string(),
25
+ z.object({ appId: z.string(), role: z.string().optional() }),
26
+ );
27
+ type InstalledApp = Readonly<{ installationId: string; appId: string }>;
28
+
29
+ /**
30
+ * Resolve each declared App role to exactly one installed App: the same
31
+ * unambiguous match the Run dialog's own default picks for a person. A role
32
+ * with zero or more than one installed candidate has no single correct
33
+ * answer without a person choosing, so the caller withholds the whole row
34
+ * rather than guessing a binding.
35
+ */
36
+ function resolveBindings(
37
+ apps: z.output<typeof appRoleSchema>,
38
+ installed: readonly InstalledApp[],
39
+ ): Record<string, string> | null {
40
+ const bindings: Record<string, string> = {};
41
+ for (const [alias, spec] of Object.entries(apps)) {
42
+ const matches = installed.filter((app) => app.appId === spec.appId);
43
+ const [match] = matches;
44
+ if (matches.length !== 1 || match === undefined) return null;
45
+ bindings[alias] = match.installationId;
46
+ }
47
+ return bindings;
48
+ }
49
+
50
+ async function listTools(
51
+ indexes: WorkflowIndexes | undefined,
52
+ workflows: WorkflowMethodContext["env"]["WORKFLOWS"],
53
+ ): Promise<z.output<typeof workflowToolListing>> {
54
+ const library = indexes?.libraryList();
55
+ if (!indexes || library?.kind !== "ready") return { kind: "unindexed" };
56
+ // Read the Team's installed Apps once for every row, not once per row.
57
+ const catalog = workflows
58
+ ? catalogReplySchema.parse(await workflows.catalog({}))
59
+ : { error: "not_granted" as const };
60
+ const installed: readonly InstalledApp[] =
61
+ "apps" in catalog
62
+ ? catalog.apps.map((app) => ({
63
+ installationId: app.installationId,
64
+ appId: app.appId,
65
+ }))
66
+ : [];
67
+ const tools: unknown[] = [];
68
+ for (const item of [...library.value.items].sort((a, b) =>
69
+ a.workflowId.localeCompare(b.workflowId),
70
+ )) {
71
+ const published = item.published;
72
+ if (published?.version == null) continue;
73
+ // The immutable publication row written at publish; no Git is read here.
74
+ const row = indexes.publications.read(
75
+ `${item.workflowId}:v${String(published.version)}`,
76
+ );
77
+ if (
78
+ row.kind !== "ready" ||
79
+ row.value.workflowId !== item.workflowId ||
80
+ row.value.version !== published.version ||
81
+ row.value.commit !== published.commit
82
+ )
83
+ return { kind: "unindexed" };
84
+ const definition = row.value.definition;
85
+ const apps = appRoleSchema.safeParse(definition.apps);
86
+ if (!apps.success) continue;
87
+ const bindings = resolveBindings(apps.data, installed);
88
+ // No single correct binding today (a role is unmet or ambiguous): never
89
+ // guess one, so withhold this one Workflow's tool, not the whole family.
90
+ if (bindings === null) continue;
91
+ tools.push({
92
+ workflowId: item.workflowId,
93
+ tool: toolName(item.workflowId),
94
+ title: definition.title,
95
+ description: definition.description ?? "",
96
+ version: published.version,
97
+ commit: published.commit,
98
+ input: definition.input,
99
+ output,
100
+ // The exact extra arguments a generic tool-family host merges with the
101
+ // caller's own `input` when it calls `runWorkflow`; see tool-contracts.
102
+ call: {
103
+ workflowId: item.workflowId,
104
+ version: published.version,
105
+ bindings,
106
+ },
107
+ });
108
+ }
109
+ return workflowToolListing.parse({ kind: "ready", tools });
110
+ }
111
+
112
+ export const toolFamilyMethods = {
113
+ listWorkflowTools: defineAppMethod({
114
+ description:
115
+ "List typed tools from saved published Workflow versions; drafts are excluded.",
116
+ hidden: true,
117
+ effect: "read",
118
+ requires: ["workflow.run"],
119
+ input: z.strictObject({}),
120
+ output: workflowToolListing,
121
+ handler: (_input, context: WorkflowMethodContext) =>
122
+ listTools(context.indexes, context.env.WORKFLOWS),
123
+ }),
124
+ };
@@ -0,0 +1,11 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "noEmit": false,
5
+ "declaration": false,
6
+ "sourceMap": false,
7
+ "rootDir": "..",
8
+ "outDir": "../dist"
9
+ },
10
+ "include": ["library.ts"]
11
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "strict": true,
7
+ "skipLibCheck": true,
8
+ "types": ["@cloudflare/workers-types"],
9
+ "lib": ["ES2022"],
10
+ "noEmit": true,
11
+ "resolveJsonModule": true,
12
+ "noUncheckedIndexedAccess": true
13
+ },
14
+ "include": ["*.ts", "../host"]
15
+ }
@@ -0,0 +1,42 @@
1
+ import * as S from "effect/Schema";
2
+ import { catalogSchema } from "@fungi.computer/braid";
3
+ import { catalogReplySchema } from "@fungi.computer/workflows-wire";
4
+ export { catalogReplySchema } from "@fungi.computer/workflows-wire";
5
+ /** The unique App ids a definition's node bindings actually name. */
6
+ export function definitionAppIds(definition) {
7
+ return [...new Set(Object.values(definition.apps).map((app) => app.appId))];
8
+ }
9
+ /**
10
+ * The host selects member or lifecycle authority; the App never invents
11
+ * grants. Reads the palette index once to resolve installations, then reads
12
+ * each named App's full ports only -- never the whole installed set -- so a
13
+ * large installed-App count never grows this reply with ports no caller asked
14
+ * for.
15
+ */
16
+ export async function readWorkflowCatalog(workflows, appIds) {
17
+ const needed = new Set(appIds);
18
+ // No App is named: skip the read entirely, not just the port detail --
19
+ // never requiring the WORKFLOWS binding just to save an App-free draft.
20
+ if (needed.size === 0)
21
+ return {};
22
+ if (!workflows)
23
+ return null;
24
+ const index = catalogReplySchema.parse(await workflows.catalog({}));
25
+ if (!("apps" in index))
26
+ return null;
27
+ const matches = index.apps.filter((app) => needed.has(app.appId));
28
+ const entries = await Promise.all(matches.map(async (match) => {
29
+ const full = catalogReplySchema.parse(await workflows.catalog({ installationId: match.installationId }));
30
+ return "app" in full ? full.app : null;
31
+ }));
32
+ return S.decodeUnknownSync(catalogSchema)(Object.fromEntries(entries.flatMap((app) => app === null
33
+ ? []
34
+ : [
35
+ [
36
+ app.appId,
37
+ app.events === undefined
38
+ ? { methods: app.methods }
39
+ : { methods: app.methods, events: app.events },
40
+ ],
41
+ ])), { onExcessProperty: "error" });
42
+ }
@@ -0,0 +1,49 @@
1
+ import { defineAppGill } from "@fungi.computer/agar/gills";
2
+ import { libraryListing } from "@fungi.computer/workflows-wire";
3
+ /** The installation's saved library supplies one page, without a Git rebuild. */
4
+ export const workflowLibraryGill = defineAppGill({
5
+ id: "workflow-library",
6
+ title: "Workflow Library",
7
+ source: "listWorkflows",
8
+ limit: 20,
9
+ sizes: ["M", "L"],
10
+ input: ({ limit, after }) => after === undefined ? { limit } : { limit, after },
11
+ rows: (output) => {
12
+ const identity = { viewId: "workflow-library", title: "Workflow Library" };
13
+ const page = libraryListing.parse(output);
14
+ if ("kind" in page)
15
+ return {
16
+ ...identity,
17
+ state: "error",
18
+ rows: [],
19
+ error: "The saved Workflow library isn't ready. Open Trellis to update it.",
20
+ recovery: { kind: "open" },
21
+ };
22
+ if (page.items.length === 0)
23
+ return { ...identity, state: "empty", rows: [] };
24
+ const rows = page.items.map((item) => {
25
+ const row = {
26
+ id: item.workflowId,
27
+ title: item.title.trim() || "Untitled Workflow",
28
+ status: item.published === null
29
+ ? {
30
+ label: item.draftRevision === null ? "Not published" : "Draft",
31
+ tone: "neutral",
32
+ }
33
+ : {
34
+ label: item.published.version === null
35
+ ? "Published"
36
+ : `Published v${String(item.published.version)}`,
37
+ tone: "success",
38
+ },
39
+ open: { params: [{ key: "workflow", value: item.workflowId }] },
40
+ };
41
+ return item.draftRevision === null
42
+ ? row
43
+ : { ...row, detail: "Draft available" };
44
+ });
45
+ return page.next === undefined
46
+ ? { ...identity, state: "full", rows }
47
+ : { ...identity, state: "full", rows, next: page.next };
48
+ },
49
+ });
@@ -0,0 +1,294 @@
1
+ import { workflowIdSchema } from "@fungi.computer/braid";
2
+ import * as z from "zod";
3
+ import * as Schema from "effect/Schema";
4
+ import { workflowDefinitionSchema } from "@fungi.computer/braid";
5
+ import { createReadCopies } from "@fungi.computer/agar/read-copy";
6
+ import { gitSha1Schema } from "@fungi.computer/puffball/git-representation";
7
+ import { createPublicationJobs } from "./publication-job.js";
8
+ import { versionRefs } from "@fungi.computer/workflows-wire";
9
+ import { document, publishedDocument, draftDocument, version, listings, } from "@fungi.computer/workflows-wire";
10
+ const issueSchema = z.object({
11
+ code: z.string(),
12
+ message: z.string(),
13
+ graph: z.string().optional(),
14
+ node: z.string().optional(),
15
+ trigger: z.string().optional(),
16
+ port: z.string().optional(),
17
+ edge: z.number().optional(),
18
+ });
19
+ const pushRowSchema = z.strictObject({
20
+ eventId: z.string(),
21
+ commit: z.string(),
22
+ workflowId: z.string(),
23
+ issues: z.string(),
24
+ });
25
+ const copiedDocument = document.transform((raw) => ({
26
+ ...raw,
27
+ definition: Schema.decodeUnknownSync(workflowDefinitionSchema)(raw.definition),
28
+ }));
29
+ const librarySnapshotSchema = z.strictObject({
30
+ refs: z.record(z.string(), gitSha1Schema),
31
+ items: listings,
32
+ });
33
+ export function createWorkflowIndexes(storage) {
34
+ const { sql } = storage;
35
+ sql.exec("CREATE TABLE IF NOT EXISTS workflow_drafts (workflow_id TEXT PRIMARY KEY, revision INTEGER NOT NULL, body TEXT NOT NULL, updated_at INTEGER NOT NULL)");
36
+ const draftRow = z.strictObject({
37
+ revision: z.number().int().positive(),
38
+ body: z.string(),
39
+ });
40
+ const decodeDraft = (raw) => {
41
+ const row = draftRow.parse(raw);
42
+ const value = draftDocument.parse({
43
+ ...z.record(z.string(), z.json()).parse(JSON.parse(row.body)),
44
+ revision: row.revision,
45
+ });
46
+ return {
47
+ ...value,
48
+ definition: Schema.decodeUnknownSync(workflowDefinitionSchema)(value.definition),
49
+ };
50
+ };
51
+ function readDraft(workflowId) {
52
+ workflowIdSchema.parse(workflowId);
53
+ const raw = sql
54
+ .exec("SELECT revision, body FROM workflow_drafts WHERE workflow_id = ?", workflowId)
55
+ .toArray()[0];
56
+ return raw === undefined ? null : decodeDraft(raw);
57
+ }
58
+ sql.exec('CREATE TABLE IF NOT EXISTS workflow_push_issues (eventId TEXT NOT NULL, "commit" TEXT NOT NULL, workflowId TEXT NOT NULL, issues TEXT NOT NULL, PRIMARY KEY (eventId, "commit", workflowId))');
59
+ const copies = createReadCopies(sql, {
60
+ version: 2,
61
+ maxRows: 4096,
62
+ maxValueBytes: 1048576,
63
+ maxBlobBytes: 1048576,
64
+ maxBlobTotalBytes: 8388608,
65
+ });
66
+ const library = copies.open("workflow.library", librarySnapshotSchema);
67
+ const generation = copies.open("workflow.revision", z.number().int().nonnegative());
68
+ const documents = copies.open("workflow.document", copiedDocument.nullable());
69
+ const publications = copies.open("workflow.publication", publishedDocument.transform((raw) => ({
70
+ ...raw,
71
+ definition: Schema.decodeUnknownSync(workflowDefinitionSchema)(raw.definition),
72
+ })));
73
+ const versionCopies = copies.open("workflow.versions", z.array(version).max(1000));
74
+ const rebuildingRow = z.strictObject({
75
+ workflowId: z.string(),
76
+ main: copiedDocument.nullable(),
77
+ versions: z.array(version).max(1000),
78
+ versionOffset: z.number().int().nonnegative(),
79
+ });
80
+ const frontier = copies.open("workflow.frontier", z.strictObject({
81
+ epoch: z.number().int().nonnegative(),
82
+ refs: z.array(z.strictObject({ name: z.string(), hash: gitSha1Schema })),
83
+ ids: z.array(z.string()).max(100),
84
+ offset: z.number().int().nonnegative(),
85
+ items: listings,
86
+ current: rebuildingRow.nullable(),
87
+ queue: z.array(rebuildingRow).max(4),
88
+ }));
89
+ function mergeDraftListings(items) {
90
+ const saved = new Map(items.map((item) => [item.workflowId, item]));
91
+ for (const raw of sql
92
+ .exec("SELECT workflow_id, revision, body FROM workflow_drafts ORDER BY workflow_id LIMIT 100")
93
+ .toArray()) {
94
+ const row = z
95
+ .object({
96
+ workflow_id: workflowIdSchema,
97
+ revision: z.number().int().positive(),
98
+ body: z.string(),
99
+ })
100
+ .parse(raw);
101
+ const draft = decodeDraft({ revision: row.revision, body: row.body });
102
+ const previous = saved.get(row.workflow_id);
103
+ saved.set(row.workflow_id, {
104
+ workflowId: row.workflow_id,
105
+ title: draft.definition.title,
106
+ draftRevision: row.revision,
107
+ published: previous?.published ?? null,
108
+ });
109
+ }
110
+ return [...saved.values()]
111
+ .sort((a, b) => a.workflowId.localeCompare(b.workflowId))
112
+ .slice(0, 100);
113
+ }
114
+ function librarySnapshot() {
115
+ const saved = library.read("all");
116
+ const epoch = generation.read("all");
117
+ return {
118
+ revision: epoch.kind === "ready" ? epoch.value : 0,
119
+ snapshot: saved.kind === "ready" ? saved.value : null,
120
+ };
121
+ }
122
+ const indexes = {
123
+ drafts: {
124
+ read: readDraft,
125
+ save(workflowId, body, expectedRevision) {
126
+ workflowIdSchema.parse(workflowId);
127
+ return storage.transactionSync(() => {
128
+ const current = readDraft(workflowId);
129
+ if ((current?.revision ?? 0) !== expectedRevision)
130
+ return { kind: "conflict", current };
131
+ const revision = expectedRevision + 1;
132
+ const saved = draftDocument.parse({ ...body, revision });
133
+ const { revision: savedRevision, ...content } = saved;
134
+ sql.exec("INSERT INTO workflow_drafts (workflow_id, revision, body, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT(workflow_id) DO UPDATE SET revision = excluded.revision, body = excluded.body, updated_at = excluded.updated_at", workflowId, savedRevision, JSON.stringify(content), Date.now());
135
+ const currentLibrary = librarySnapshot();
136
+ const previous = currentLibrary.snapshot?.items.find((item) => item.workflowId === workflowId);
137
+ library.write("all", currentLibrary.snapshot?.refs["refs/heads/main"] ?? null, {
138
+ refs: currentLibrary.snapshot?.refs ?? {},
139
+ items: [
140
+ ...(currentLibrary.snapshot?.items ?? []).filter((item) => item.workflowId !== workflowId),
141
+ {
142
+ workflowId,
143
+ title: Schema.decodeUnknownSync(workflowDefinitionSchema)(content.definition).title,
144
+ draftRevision: revision,
145
+ published: previous?.published ?? null,
146
+ },
147
+ ]
148
+ .sort((a, b) => a.workflowId.localeCompare(b.workflowId))
149
+ .slice(0, 100),
150
+ });
151
+ return { kind: "saved", revision };
152
+ });
153
+ },
154
+ },
155
+ libraryList: () => library.read("all"),
156
+ documents,
157
+ versionCopies,
158
+ publications,
159
+ recordPublication(value, epoch) {
160
+ storage.transactionSync(() => {
161
+ const { workflowId, version: number, commit } = value;
162
+ const saved = {
163
+ definition: value.definition,
164
+ layout: value.layout,
165
+ commit,
166
+ };
167
+ if (value.provenance !== undefined)
168
+ saved.provenance = value.provenance;
169
+ publications.write(`${workflowId}:v${String(number)}`, commit, value);
170
+ publications.write(`${workflowId}:${commit}`, commit, value);
171
+ documents.write(`main:${workflowId}`, commit, saved);
172
+ documents.write(`at:${workflowId}:${commit}`, commit, saved);
173
+ const previous = versionCopies.read(workflowId);
174
+ versionCopies.write(workflowId, commit, [
175
+ {
176
+ version: number,
177
+ commit,
178
+ committedAt: Math.floor(Date.now() / 1000),
179
+ message: `Publish ${workflowId}`,
180
+ },
181
+ ...(previous.kind === "ready" ? previous.value : []),
182
+ ].slice(0, 1000));
183
+ const current = librarySnapshot();
184
+ if (current.revision === epoch && current.snapshot)
185
+ library.write("all", commit, {
186
+ refs: {
187
+ ...current.snapshot.refs,
188
+ "refs/heads/main": commit,
189
+ [`refs/tags/${workflowId}@v${String(number)}`]: commit,
190
+ },
191
+ items: current.snapshot.items.map((item) => item.workflowId === workflowId
192
+ ? { ...item, published: { commit, version: number } }
193
+ : item),
194
+ });
195
+ });
196
+ },
197
+ recordBackgroundPublication(value, epoch, references) {
198
+ storage.transactionSync(() => {
199
+ const { workflowId, version: number, commit } = value;
200
+ const saved = { definition: value.definition, layout: value.layout, commit };
201
+ if (value.provenance !== undefined)
202
+ saved.provenance = value.provenance;
203
+ publications.write(`${workflowId}:v${String(number)}`, commit, value);
204
+ publications.write(`${workflowId}:${commit}`, commit, value);
205
+ documents.write(`at:${workflowId}:${commit}`, commit, saved);
206
+ const previous = versionCopies.read(workflowId);
207
+ const retained = previous.kind === "ready" ? previous.value : [];
208
+ const next = [
209
+ {
210
+ version: number,
211
+ commit,
212
+ committedAt: Math.floor(Date.now() / 1000),
213
+ message: `Publish ${workflowId}`,
214
+ },
215
+ ...retained.filter((version) => version.version !== number),
216
+ ]
217
+ .sort((a, b) => b.version - a.version)
218
+ .slice(0, 1000);
219
+ versionCopies.write(workflowId, next[0]?.commit ?? commit, next);
220
+ const main = references.find((ref) => ref.name === "refs/heads/main")?.hash ??
221
+ null;
222
+ const copiedMain = documents.read(`main:${workflowId}`);
223
+ if (main === commit && (retained[0]?.version ?? 0) <= number)
224
+ documents.write(`main:${workflowId}`, commit, saved);
225
+ else if (copiedMain.kind === "ready" && copiedMain.head !== main)
226
+ documents.stale(`main:${workflowId}`);
227
+ const current = librarySnapshot();
228
+ if (current.revision === epoch && current.snapshot) {
229
+ const latest = versionRefs(workflowId, references)[0];
230
+ library.write("all", main, {
231
+ refs: Object.fromEntries(references.map((ref) => [ref.name, ref.hash])),
232
+ items: current.snapshot.items.map((item) => {
233
+ if (item.workflowId !== workflowId ||
234
+ !latest ||
235
+ (item.published?.version ?? 0) > latest.version)
236
+ return item;
237
+ return {
238
+ ...item,
239
+ published: { commit: latest.commit, version: latest.version },
240
+ };
241
+ }),
242
+ });
243
+ }
244
+ });
245
+ },
246
+ frontier,
247
+ librarySnapshot,
248
+ recordLibrary(revision, snapshot) {
249
+ if (librarySnapshot().revision === revision)
250
+ library.write("all", snapshot.refs["refs/heads/main"] ?? null, {
251
+ ...snapshot,
252
+ items: mergeDraftListings(snapshot.items),
253
+ });
254
+ },
255
+ invalidateLibrary(updates) {
256
+ const current = librarySnapshot();
257
+ if (updates &&
258
+ current.snapshot &&
259
+ updates.every(({ ref, after }) => (current.snapshot?.refs[ref] ?? "0".repeat(40)) === after))
260
+ return;
261
+ generation.write("all", null, current.revision + 1);
262
+ library.stale("all");
263
+ },
264
+ recordIssues(eventId, commit, workflowId, issues) {
265
+ sql.exec('INSERT INTO workflow_push_issues (eventId, "commit", workflowId, issues) VALUES (?, ?, ?, ?) ON CONFLICT(eventId, "commit", workflowId) DO NOTHING', eventId, commit, workflowId, JSON.stringify(issues));
266
+ },
267
+ issues(eventId) {
268
+ return sql
269
+ .exec('SELECT eventId, "commit", workflowId, issues FROM workflow_push_issues WHERE eventId = ? ORDER BY workflowId, "commit"', eventId)
270
+ .toArray()
271
+ .map((raw) => {
272
+ const row = pushRowSchema.parse(raw);
273
+ return {
274
+ ...row,
275
+ issues: z.array(issueSchema).parse(JSON.parse(row.issues)),
276
+ };
277
+ });
278
+ },
279
+ };
280
+ const publicationJobs = createPublicationJobs(storage, {
281
+ readDraft: (workflowId) => {
282
+ const draft = readDraft(workflowId);
283
+ return draft === null ? null : draftDocument.parse(draft);
284
+ },
285
+ revision: () => librarySnapshot().revision,
286
+ publish: (value, epoch, refs) => {
287
+ indexes.recordBackgroundPublication({
288
+ ...value,
289
+ definition: Schema.decodeUnknownSync(workflowDefinitionSchema)(value.definition),
290
+ }, epoch, refs);
291
+ },
292
+ });
293
+ return { ...indexes, publicationJobs };
294
+ }