@fungi.computer/trellis 0.2.2 → 0.2.3

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,196 @@
1
+ import {
2
+ fillReviewText,
3
+ type LoopPosition,
4
+ type Node,
5
+ type WorkflowDefinition,
6
+ } from "@fungi.computer/braid";
7
+ import {
8
+ stepPosition,
9
+ stepRow,
10
+ type NodeState,
11
+ type RunStep,
12
+ } from "./run-state.js";
13
+
14
+ /** What the timeline needs to know about one installed App. */
15
+ export type AppDisplay = Readonly<
16
+ Partial<Record<string, Readonly<{ displayName: string; icon?: string }>>>
17
+ >;
18
+
19
+ /** One thing that happened in a run, in words a person reads. */
20
+ export type TimelineItem = Readonly<{
21
+ key: string;
22
+ state: NodeState;
23
+ title: string;
24
+ detail?: string | undefined;
25
+ /** The installed App that did it, when an App did. */
26
+ app?: Readonly<{ appId: string; displayName: string; icon?: string }>;
27
+ durationMs?: number | undefined;
28
+ media?: Readonly<{ path: string; format: "fungi:image" | "fungi:video" }>;
29
+ /** Where "Open in <App>" goes. */
30
+ open?: Readonly<{
31
+ appId: string;
32
+ params: readonly Readonly<{ key: string; value: string }>[];
33
+ }>;
34
+ /** The recorded step, for the raw ▸ view. */
35
+ step: RunStep;
36
+ }>;
37
+
38
+ function scopeOf(definition: WorkflowDefinition, step: RunStep) {
39
+ if (step.graphId === undefined) return definition;
40
+ const body = new Map(Object.entries(definition.subgraphs ?? {})).get(
41
+ step.graphId,
42
+ );
43
+ return body ? { ...definition, ...body } : undefined;
44
+ }
45
+
46
+ /** "18s", "4m", "6m 12s": the same short durations the tool timeline uses. */
47
+ export function formatDuration(ms: number) {
48
+ const seconds = Math.round(ms / 1000);
49
+ if (seconds < 1) return "under 1s";
50
+ if (seconds < 60) return `${String(seconds)}s`;
51
+ const minutes = Math.floor(seconds / 60);
52
+ const rest = seconds % 60;
53
+ if (minutes < 60)
54
+ return rest === 0
55
+ ? `${String(minutes)}m`
56
+ : `${String(minutes)}m ${String(rest)}s`;
57
+ const hours = Math.floor(minutes / 60);
58
+ return `${String(hours)}h ${String(minutes % 60)}m`;
59
+ }
60
+
61
+ /** "item 2 · try 3": which Map item and Repeat try a step ran in, when it ran in one. */
62
+ function placeWords(position: LoopPosition) {
63
+ return position.flatMap((place) =>
64
+ place.loop === "map"
65
+ ? [`item ${String(place.index + 1)}`]
66
+ : place.count > 1
67
+ ? [`try ${String(place.index + 1)}`]
68
+ : [],
69
+ );
70
+ }
71
+
72
+ /** Join the parts of a muted detail line, leaving out the missing ones. */
73
+ function detailLine(...parts: readonly (string | undefined)[]) {
74
+ const present = parts.filter(
75
+ (part): part is string => part !== undefined && part.length > 0,
76
+ );
77
+ return present.length > 0 ? present.join(" · ") : undefined;
78
+ }
79
+
80
+ function decision(node: Extract<Node, { kind: "review" }>, step: RunStep) {
81
+ const output = "reviewOutput" in step ? step.reviewOutput : undefined;
82
+ if (output === undefined) return { title: "You answered the review" };
83
+ const choice = node.choices?.find((item) => item.id === output.choice);
84
+ const label =
85
+ choice?.label ??
86
+ (output.choice === "approved"
87
+ ? "Approve"
88
+ : output.choice === "rejected"
89
+ ? "Reject"
90
+ : output.choice);
91
+ const note = output.note.length > 0 ? `“${output.note}”` : undefined;
92
+ const consequence =
93
+ choice?.description === undefined
94
+ ? undefined
95
+ : fillReviewText(choice.description, stepPosition(step));
96
+ return { title: `You chose ${label}`, detail: detailLine(consequence, note) };
97
+ }
98
+
99
+ /**
100
+ * Turn recorded steps into what a person reads: each App step says what the
101
+ * App did (its own row when it declared one), each answered review says what
102
+ * you chose. Plumbing that went well, and steps that never ran, stay out.
103
+ */
104
+ export function timelineItems(
105
+ steps: readonly RunStep[],
106
+ definition: WorkflowDefinition,
107
+ appDisplay: AppDisplay,
108
+ ): TimelineItem[] {
109
+ const items: TimelineItem[] = [];
110
+ for (const step of steps) {
111
+ if (step.state === "queued" || step.state === "skipped") continue;
112
+ const scope = scopeOf(definition, step);
113
+ const node = scope?.nodes.find((candidate) => candidate.id === step.nodeId);
114
+ if (node === undefined) continue;
115
+ const key = JSON.stringify([
116
+ step.graphId ?? null,
117
+ step.nodeId,
118
+ step.iteration,
119
+ ]);
120
+ const base = { key, state: step.state, durationMs: step.durationMs, step };
121
+ if (node.kind === "review") {
122
+ // A waiting review is the docked question, not a timeline entry.
123
+ if (step.state === "complete")
124
+ items.push({ ...base, ...decision(node, step) });
125
+ continue;
126
+ }
127
+ if (node.kind !== "app") {
128
+ if (step.state === "failed")
129
+ items.push({
130
+ ...base,
131
+ title: `${node.id} failed`,
132
+ detail: step.errorMessage ?? step.refusal,
133
+ });
134
+ continue;
135
+ }
136
+ const appId = new Map(Object.entries(definition.apps)).get(node.app)?.appId;
137
+ const display = appId === undefined ? undefined : appDisplay[appId];
138
+ const name = display?.displayName ?? node.app;
139
+ const app =
140
+ appId === undefined
141
+ ? undefined
142
+ : display?.icon === undefined
143
+ ? { appId, displayName: name }
144
+ : { appId, displayName: name, icon: display.icon };
145
+ const withApp = app === undefined ? base : { ...base, app };
146
+ if (step.state === "failed") {
147
+ items.push({
148
+ ...withApp,
149
+ title: `${name} could not run ${node.method}`,
150
+ detail: step.errorMessage ?? step.refusal,
151
+ });
152
+ continue;
153
+ }
154
+ if (step.state !== "complete") {
155
+ items.push({ ...withApp, title: `${name} is running ${node.method}` });
156
+ continue;
157
+ }
158
+ const row = stepRow(step);
159
+ if (row === undefined) {
160
+ items.push({ ...withApp, title: `${name} ran ${node.method}` });
161
+ continue;
162
+ }
163
+ let item: TimelineItem = {
164
+ ...withApp,
165
+ title: row.title,
166
+ detail: detailLine(...placeWords(stepPosition(step)), row.detail),
167
+ };
168
+ if (row.media !== undefined) item = { ...item, media: row.media };
169
+ if (row.open !== undefined && appId !== undefined)
170
+ item = { ...item, open: { appId, params: row.open.params } };
171
+ items.push(item);
172
+ }
173
+ return items;
174
+ }
175
+
176
+ /** Facts for the closing line of a finished run. */
177
+ export function runFinish(
178
+ steps: readonly RunStep[],
179
+ items: readonly TimelineItem[],
180
+ costMilliunits: number | null,
181
+ ) {
182
+ const times = steps.flatMap((step) =>
183
+ step.recordedAt === undefined ? [] : [step.recordedAt],
184
+ );
185
+ const elapsed =
186
+ times.length > 1 ? Math.max(...times) - Math.min(...times) : undefined;
187
+ const apps = new Set(
188
+ items.flatMap((item) => (item.app ? [item.app.appId] : [])),
189
+ );
190
+ return {
191
+ elapsed,
192
+ apps: apps.size,
193
+ // Only a recorded, non-zero cost is worth a line.
194
+ cost: costMilliunits !== null && costMilliunits > 0 ? costMilliunits : null,
195
+ };
196
+ }
@@ -0,0 +1,84 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ import type { WorkflowRunListItem } from "@fungi.computer/workflows-wire";
3
+ import type { WorkflowClient } from "../workflow-client.js";
4
+ import { terminal, type RunStatus } from "./run-state.js";
5
+
6
+ const READS_AT_ONCE = 4;
7
+ const POLL_MS = 3000;
8
+
9
+ /**
10
+ * The status of every listed run, so the list can say which ones need you.
11
+ * Finished runs are read once; active ones are read again until they end.
12
+ * A run that cannot be read stays unsorted, with its read error for the list.
13
+ */
14
+ export function useRunSummaries(
15
+ client: WorkflowClient,
16
+ items: readonly WorkflowRunListItem[] | null,
17
+ ) {
18
+ const [runs, setRuns] = useState<Readonly<Record<string, RunStatus>>>({});
19
+ // A run whose status could not be read says so in the list, not silently unsorted.
20
+ const [readErrors, setReadErrors] = useState<ReadonlyMap<string, Error>>(
21
+ new Map(),
22
+ );
23
+ const setReadError = useCallback((runId: string, error: Error | null) => {
24
+ setReadErrors((previous) => {
25
+ if (error === null && !previous.has(runId)) return previous;
26
+ const next = new Map(previous);
27
+ if (error === null) next.delete(runId);
28
+ else next.set(runId, error);
29
+ return next;
30
+ });
31
+ }, []);
32
+ // The open run reports its own reads here too, so a run that ended there is not read again.
33
+ const known = useRef(new Map<string, RunStatus>());
34
+ const record = useCallback((run: RunStatus) => {
35
+ known.current.set(run.runId, run);
36
+ setRuns((previous) => ({ ...previous, [run.runId]: run }));
37
+ }, []);
38
+ // A refreshed list of the same runs keeps its reads, so depend on the ids.
39
+ const ids = items?.map((item) => item.runId).join("\n") ?? "";
40
+ useEffect(() => {
41
+ const listed = ids === "" ? [] : ids.split("\n");
42
+ let active = true;
43
+ let timer: ReturnType<typeof setTimeout> | undefined;
44
+ async function readAll(due: readonly string[]) {
45
+ const queue = [...due];
46
+ await Promise.all(
47
+ Array.from({ length: READS_AT_ONCE }, async () => {
48
+ for (let id = queue.shift(); id !== undefined; id = queue.shift()) {
49
+ try {
50
+ const run = await client.runStatus(id);
51
+ if (!active) return;
52
+ record(run);
53
+ setReadError(id, null);
54
+ } catch (cause) {
55
+ if (active)
56
+ setReadError(
57
+ id,
58
+ new Error("This run could not be read.", { cause }),
59
+ );
60
+ }
61
+ }
62
+ }),
63
+ );
64
+ }
65
+ async function cycle() {
66
+ const due = listed.filter((runId) => {
67
+ const run = known.current.get(runId);
68
+ return (
69
+ run === undefined ||
70
+ (run.outcome === "active" && !terminal(run.status))
71
+ );
72
+ });
73
+ if (due.length === 0) return;
74
+ await readAll(due);
75
+ if (active) timer = setTimeout(() => void cycle(), POLL_MS);
76
+ }
77
+ void cycle();
78
+ return () => {
79
+ active = false;
80
+ clearTimeout(timer);
81
+ };
82
+ }, [client, ids, record, setReadError]);
83
+ return { runs, record, readErrors };
84
+ }
@@ -0,0 +1,39 @@
1
+ import { useEffect, useState } from "react";
2
+ import type { WorkflowClient } from "../workflow-client.js";
3
+ import { terminal, type RunStatus } from "./run-state.js";
4
+ /** Schedule the next read after this one settles; never overlap reads. */
5
+ export function useRun(client: WorkflowClient, runId: string) {
6
+ const [run, setRun] = useState<RunStatus | null>(null);
7
+ const [error, setError] = useState(false);
8
+ const [attempt, setAttempt] = useState(0);
9
+ useEffect(() => {
10
+ let active = true;
11
+ let timer: ReturnType<typeof setTimeout> | undefined;
12
+ setRun(null);
13
+ setError(false);
14
+ async function read() {
15
+ try {
16
+ const next = await client.runStatus(runId);
17
+ if (!active) return;
18
+ setRun(next);
19
+ setError(false);
20
+ if (next.outcome === "active" && !terminal(next.status))
21
+ timer = setTimeout(() => void read(), 2000);
22
+ } catch {
23
+ if (active) setError(true);
24
+ }
25
+ }
26
+ void read();
27
+ return () => {
28
+ active = false;
29
+ clearTimeout(timer);
30
+ };
31
+ }, [client, runId, attempt]);
32
+ return {
33
+ run,
34
+ error,
35
+ refresh: () => {
36
+ setAttempt((value) => value + 1);
37
+ },
38
+ };
39
+ }
@@ -0,0 +1,134 @@
1
+ {
2
+ "id": "bonsai-build-on-merge",
3
+ "definition": {
4
+ "format": "fungi.workflow/v1",
5
+ "title": "Build on merge",
6
+ "description": "An accepted main push for a registered App starts this Workflow through the Workflows capability. Build the exact accepted commit, verify its completed build and Release provenance, then install with Workflow-owned authority. Every step records its result. A failed build or verification never installs; new permissions preserve the running Release and require owner consent.",
7
+ "apps": {
8
+ "bonsai": {
9
+ "appId": "app_bonsai"
10
+ }
11
+ },
12
+ "input": {
13
+ "type": "object",
14
+ "properties": {
15
+ "appId": {
16
+ "type": "string",
17
+ "minLength": 1,
18
+ "maxLength": 256
19
+ },
20
+ "sourceRevision": {
21
+ "type": "string",
22
+ "pattern": "^git:[0-9a-f]{40}$",
23
+ "minLength": 44,
24
+ "maxLength": 44
25
+ },
26
+ "installationId": {
27
+ "type": "string",
28
+ "minLength": 1,
29
+ "maxLength": 256
30
+ }
31
+ },
32
+ "required": ["appId", "sourceRevision", "installationId"],
33
+ "additionalProperties": false
34
+ },
35
+ "nodes": [
36
+ {
37
+ "id": "build",
38
+ "kind": "app",
39
+ "app": "bonsai",
40
+ "method": "apps.build",
41
+ "methodDigest": "sha256:9be16b868eec164c504a30d37dd3739461fc33bf721699e49566ac2126f0a7fb",
42
+ "values": {
43
+ "appId": {
44
+ "from": {
45
+ "node": "$input",
46
+ "port": "appId"
47
+ }
48
+ },
49
+ "sourceRevision": {
50
+ "from": {
51
+ "node": "$input",
52
+ "port": "sourceRevision"
53
+ }
54
+ },
55
+ "installationId": {
56
+ "from": {
57
+ "node": "$input",
58
+ "port": "installationId"
59
+ }
60
+ }
61
+ }
62
+ },
63
+ {
64
+ "id": "verify",
65
+ "kind": "app",
66
+ "app": "bonsai",
67
+ "method": "apps.verifyBuild",
68
+ "methodDigest": "sha256:0ca6e1984a98c75ff18788dc1997c00496ae84a3723b74004ac325a095f72fb5",
69
+ "values": {
70
+ "jobId": {
71
+ "from": {
72
+ "node": "build",
73
+ "port": "jobId"
74
+ }
75
+ },
76
+ "sourceRevision": {
77
+ "from": {
78
+ "node": "$input",
79
+ "port": "sourceRevision"
80
+ }
81
+ }
82
+ }
83
+ },
84
+ {
85
+ "id": "install",
86
+ "kind": "app",
87
+ "app": "bonsai",
88
+ "method": "apps.install",
89
+ "methodDigest": "sha256:3dcf4113653bf8427bbeb4351fc79649108d4624e3c5aa8a5148d0d323501fc6",
90
+ "values": {
91
+ "installationId": {
92
+ "from": {
93
+ "node": "$input",
94
+ "port": "installationId"
95
+ }
96
+ },
97
+ "release": {
98
+ "from": {
99
+ "node": "verify",
100
+ "port": "release"
101
+ }
102
+ },
103
+ "jobId": {
104
+ "from": {
105
+ "node": "build",
106
+ "port": "jobId"
107
+ }
108
+ }
109
+ }
110
+ }
111
+ ],
112
+ "edges": [],
113
+ "output": {
114
+ "installation": {
115
+ "node": "install",
116
+ "port": "value"
117
+ }
118
+ }
119
+ },
120
+ "layout": {
121
+ "build": {
122
+ "x": 40,
123
+ "y": 100
124
+ },
125
+ "verify": {
126
+ "x": 340,
127
+ "y": 100
128
+ },
129
+ "install": {
130
+ "x": 640,
131
+ "y": 100
132
+ }
133
+ }
134
+ }