@fungi.computer/trellis 0.2.1 → 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,544 @@
1
+ import { formatMoney } from "@fungi.computer/caps/lib/format-money";
2
+ import { useCallback, useEffect, useRef, useState } from "react";
3
+ import {
4
+ Dialog,
5
+ DialogContent,
6
+ DialogDescription,
7
+ DialogFooter,
8
+ DialogHeader,
9
+ DialogTitle,
10
+ DialogTrigger,
11
+ } from "@fungi.computer/caps/components/dialog";
12
+ import { ActionRow } from "@fungi.computer/caps/components/action-row";
13
+ import { Button } from "@fungi.computer/caps/components/button";
14
+ import {
15
+ DataList,
16
+ DataListHeading,
17
+ DataRow,
18
+ } from "@fungi.computer/caps/components/data-list";
19
+ import { Surface } from "@fungi.computer/caps/components/surface";
20
+ import { EmptyState } from "@fungi.computer/caps/blocks/empty-state";
21
+ import type { WorkflowRunListItem } from "@fungi.computer/workflows-wire";
22
+ import { versionRelativeTime } from "../history/relative-time.js";
23
+ import { nodePorts } from "../editor/graph.js";
24
+ import type { Draft, DraftCatalog } from "../editor/draft-store.js";
25
+ import type { WorkflowClient } from "../workflow-client.js";
26
+ import { definitionAppIds } from "../../backend/catalog.js";
27
+ import { useChrome } from "../chrome.js";
28
+ import { ReviewDock } from "./review-dock.js";
29
+ import { NodeStatus } from "./node-status.js";
30
+ import { OutputError, type ReadMedia } from "./output-preview.js";
31
+ import {
32
+ runBeadState,
33
+ runLatestLine,
34
+ runProgress,
35
+ runSection,
36
+ runSteps,
37
+ type RunStatus,
38
+ } from "./run-state.js";
39
+ import { RunTimeline, type OpenInApp } from "./run-timeline.js";
40
+ import { formatDuration, runFinish, timelineItems } from "./timeline-items.js";
41
+ import { useRun } from "./use-run.js";
42
+ import { useRunSummaries } from "./use-run-summaries.js";
43
+ export type { OpenInApp } from "./run-timeline.js";
44
+ const sourceLabel = {
45
+ manual: "Manual",
46
+ schedule: "Schedule",
47
+ event: "Event",
48
+ agent: "Agent",
49
+ key: "Key",
50
+ } satisfies Record<WorkflowRunListItem["source"], string>;
51
+ /**
52
+ * The trigger's own label, or the source's generic name while it loads.
53
+ * An owner now sees every run in the installation, so a manual run's
54
+ * "Started by" must name who actually ran it: "You" for the viewer's own
55
+ * run, the other member's id otherwise. `viewerId` is null until the
56
+ * viewer's own identity loads, so nothing is misreported as "You" early.
57
+ */
58
+ function startedByLabel(
59
+ item: WorkflowRunListItem,
60
+ definition:
61
+ | { triggers?: readonly { id: string; on: string; event?: string }[] }
62
+ | undefined,
63
+ viewerId: string | null,
64
+ ): string {
65
+ if (item.source === "manual") {
66
+ if (item.initiatedBy.kind !== "member") return "Unknown";
67
+ return item.initiatedBy.memberId === viewerId
68
+ ? "You"
69
+ : item.initiatedBy.memberId;
70
+ }
71
+ if (item.source === "agent")
72
+ return item.initiatedBy.kind === "agent"
73
+ ? `Agent ${item.initiatedBy.agentId} with ${item.initiatedBy.grantorMemberId}'s access`
74
+ : "Agent";
75
+ if (item.source === "key")
76
+ return item.initiatedBy.kind === "key"
77
+ ? `Key ${item.initiatedBy.keyId} with ${item.initiatedBy.grantorMemberId}'s access`
78
+ : "Key";
79
+ const trigger = definition?.triggers?.find((t) => t.id === item.triggerId);
80
+ if (!trigger) return sourceLabel[item.source];
81
+ if (trigger.on === "schedule") return "schedule";
82
+ if (trigger.on === "event" && trigger.event !== undefined)
83
+ return trigger.event;
84
+ return trigger.on;
85
+ }
86
+ function RunDetail({
87
+ client,
88
+ item,
89
+ readMedia,
90
+ openIn,
91
+ onStatus,
92
+ viewerId,
93
+ }: {
94
+ client: WorkflowClient;
95
+ item: WorkflowRunListItem;
96
+ readMedia?: ReadMedia;
97
+ openIn?: OpenInApp;
98
+ onStatus: (run: RunStatus) => void;
99
+ viewerId: string | null;
100
+ }) {
101
+ const { run: loaded, error, refresh } = useRun(client, item.runId);
102
+ const run = loaded ?? undefined;
103
+ useEffect(() => {
104
+ if (run) onStatus(run);
105
+ }, [run, onStatus]);
106
+ const [graph, setGraph] = useState<
107
+ (Pick<DraftCatalog, "catalog" | "appDisplay"> & { draft: Draft }) | null
108
+ >(null);
109
+ const [graphError, setGraphError] = useState(false);
110
+ const [confirming, setConfirming] = useState(false);
111
+ const [cancelling, setCancelling] = useState(false);
112
+ const [cancelError, setCancelError] = useState(false);
113
+ useEffect(() => {
114
+ let active = true;
115
+ setGraph(null);
116
+ setGraphError(false);
117
+ void client
118
+ .loadWorkflow(item.workflowId, { commit: item.commit })
119
+ .then(async (draft) => {
120
+ if (!draft) {
121
+ if (active) setGraphError(true);
122
+ return;
123
+ }
124
+ const { catalog, apps } = await client.catalog(
125
+ definitionAppIds(draft.definition),
126
+ );
127
+ if (!active) return;
128
+ setGraph({
129
+ draft,
130
+ catalog,
131
+ appDisplay: Object.fromEntries(
132
+ apps.map(({ appId, displayName, icon }) => [
133
+ appId,
134
+ icon === undefined ? { displayName } : { displayName, icon },
135
+ ]),
136
+ ),
137
+ });
138
+ })
139
+ .catch((cause: unknown) => {
140
+ void cause;
141
+ if (active) setGraphError(true);
142
+ });
143
+ return () => {
144
+ active = false;
145
+ };
146
+ }, [client, item.workflowId, item.commit]);
147
+ async function cancel() {
148
+ setCancelling(true);
149
+ setCancelError(false);
150
+ try {
151
+ const result = await client.cancel(item.runId);
152
+ if ("error" in result || result.kind === "refused") setCancelError(true);
153
+ else {
154
+ setConfirming(false);
155
+ refresh();
156
+ }
157
+ } catch {
158
+ setCancelError(true);
159
+ } finally {
160
+ setCancelling(false);
161
+ }
162
+ }
163
+ const progress = runProgress(run?.events ?? [], run?.outcome ?? "active");
164
+ const steps = runSteps(
165
+ run?.events ?? [],
166
+ run?.outcome ?? "active",
167
+ run?.reviews ?? [],
168
+ );
169
+ const items = graph
170
+ ? timelineItems(steps, graph.draft.definition, graph.appDisplay)
171
+ : [];
172
+ const pending = (run?.outcome === "active" ? run.reviews : []).filter(
173
+ (review) => review.status === "pending",
174
+ );
175
+ const finished = run !== undefined && runSection(run) === "finished";
176
+ const finish = runFinish(steps, items, progress.costMilliunits);
177
+ const scroller = useRef<HTMLDivElement>(null);
178
+ const newest = `${String(items.length)}:${String(pending.length)}`;
179
+ useEffect(() => {
180
+ const element = scroller.current;
181
+ if (element && newest !== "") element.scrollTop = element.scrollHeight;
182
+ }, [newest]);
183
+ return (
184
+ <section
185
+ className="wf-run-detail"
186
+ aria-label="Selected run"
187
+ data-run-id={item.runId}
188
+ >
189
+ <header className="wf-actions">
190
+ <h2>{run?.title ?? item.title ?? "Workflow title unavailable"}</h2>
191
+ <span className="wf-muted">
192
+ Started by {startedByLabel(item, graph?.draft.definition, viewerId)} ·{" "}
193
+ <time dateTime={new Date(item.startedAt).toISOString()}>
194
+ {versionRelativeTime(new Date(item.startedAt).toISOString())}
195
+ </time>
196
+ </span>
197
+ {run?.outcome === "active" ? (
198
+ <Dialog
199
+ open={confirming}
200
+ onOpenChange={(next, details) => {
201
+ if (cancelling) {
202
+ details.cancel();
203
+ return;
204
+ }
205
+ setConfirming(next);
206
+ }}
207
+ >
208
+ <DialogTrigger render={<Button variant="ghost" size="sm" />}>
209
+ Cancel run
210
+ </DialogTrigger>
211
+ <DialogContent>
212
+ <DialogHeader>
213
+ <DialogTitle>Cancel this run?</DialogTitle>
214
+ <DialogDescription>
215
+ Cancellation cannot be undone. Completed work is kept;
216
+ unfinished steps stop.
217
+ </DialogDescription>
218
+ </DialogHeader>
219
+ {cancelError ? (
220
+ <p role="alert">Run could not be cancelled. Try again.</p>
221
+ ) : null}
222
+ <DialogFooter>
223
+ <Button
224
+ variant="ghost"
225
+ disabled={cancelling}
226
+ onClick={() => {
227
+ setConfirming(false);
228
+ }}
229
+ >
230
+ Keep running
231
+ </Button>
232
+ <Button
233
+ variant="destructive"
234
+ disabled={cancelling}
235
+ onClick={() => void cancel()}
236
+ >
237
+ {cancelling ? "Cancelling…" : "Confirm cancellation"}
238
+ </Button>
239
+ </DialogFooter>
240
+ </DialogContent>
241
+ </Dialog>
242
+ ) : null}
243
+ </header>
244
+ {error ? (
245
+ <p role="alert">
246
+ Run could not be loaded.{" "}
247
+ <Button variant="ghost" onClick={refresh}>
248
+ Retry
249
+ </Button>
250
+ </p>
251
+ ) : null}
252
+ {graphError ? (
253
+ <p role="alert">
254
+ The published graph or App catalog could not be loaded.
255
+ </p>
256
+ ) : null}
257
+ <div className="wf-run-timeline" ref={scroller}>
258
+ {!graph && !graphError ? (
259
+ <p className="wf-muted">Loading steps…</p>
260
+ ) : null}
261
+ <RunTimeline items={items} readMedia={readMedia} openIn={openIn} />
262
+ {run?.error !== null && run?.error !== undefined ? (
263
+ <OutputError error={run.error} />
264
+ ) : null}
265
+ {finished ? (
266
+ <p className="wf-finish">
267
+ <NodeStatus
268
+ state={runBeadState(run)}
269
+ bare
270
+ label={run.outcome === "cancelled" ? "cancelled" : run.status}
271
+ />
272
+ <span>
273
+ {run.outcome === "cancelled"
274
+ ? "Cancelled"
275
+ : runBeadState(run) === "failed"
276
+ ? "Failed"
277
+ : `Done${finish.elapsed === undefined ? "" : ` in ${formatDuration(finish.elapsed)}`}`}
278
+ {finish.apps > 0
279
+ ? ` · ${String(finish.apps)} ${finish.apps === 1 ? "App" : "Apps"}`
280
+ : ""}
281
+ {finish.cost === null
282
+ ? ""
283
+ : ` · ${formatMoney(finish.cost / 100_000)}`}
284
+ </span>
285
+ </p>
286
+ ) : null}
287
+ </div>
288
+ {pending.map(({ review }) => {
289
+ const definition = graph?.draft.definition;
290
+ const candidates = definition
291
+ ? [
292
+ definition,
293
+ ...Object.values(definition.subgraphs ?? {}).map((subgraph) => ({
294
+ ...definition,
295
+ ...subgraph,
296
+ })),
297
+ ].flatMap((scope) =>
298
+ scope.nodes
299
+ .filter(
300
+ (node) => node.kind === "review" && node.id === review.nodeId,
301
+ )
302
+ .map((node) => ({ scope, node })),
303
+ )
304
+ : [];
305
+ // Run reviews identify a node and iteration; repeated ids cannot identify a graph.
306
+ const resolved = candidates.length === 1 ? candidates[0] : undefined;
307
+ const node = resolved?.node;
308
+ const ref = node?.kind === "review" ? node.show[0] : undefined;
309
+ const format =
310
+ ref && graph && resolved
311
+ ? Object.entries(
312
+ nodePorts(resolved.scope, graph.catalog, ref.node).outputs,
313
+ ).find(([port]) => port === ref.port)?.[1].format
314
+ : undefined;
315
+ return (
316
+ <ReviewDock
317
+ key={`${review.nodeId}:${review.iteration}`}
318
+ runId={item.runId}
319
+ review={review}
320
+ detail={node?.kind === "review" ? node.detail : undefined}
321
+ choices={node?.kind === "review" ? node.choices : undefined}
322
+ format={format}
323
+ readMedia={readMedia}
324
+ answerReview={client.answerReview}
325
+ onAnswered={refresh}
326
+ />
327
+ );
328
+ })}
329
+ </section>
330
+ );
331
+ }
332
+
333
+ const sectionTitles = {
334
+ "needs-you": "Needs you",
335
+ running: "Running",
336
+ finished: "Finished",
337
+ } as const;
338
+
339
+ /** True at phone width, where the list and a run are separate pages. */
340
+ function useCompact() {
341
+ const query = "(max-width: 720px)";
342
+ const [compact, setCompact] = useState(
343
+ () => window.matchMedia(query).matches,
344
+ );
345
+ useEffect(() => {
346
+ const list = window.matchMedia(query);
347
+ const change = () => {
348
+ setCompact(list.matches);
349
+ };
350
+ list.addEventListener("change", change);
351
+ return () => {
352
+ list.removeEventListener("change", change);
353
+ };
354
+ }, []);
355
+ return compact;
356
+ }
357
+
358
+ export function Runs({
359
+ client,
360
+ workflowId,
361
+ selectedRun,
362
+ onSelect,
363
+ readMedia,
364
+ openIn,
365
+ }: {
366
+ client: WorkflowClient;
367
+ workflowId?: string;
368
+ selectedRun: string | null;
369
+ onSelect: (id: string, workflowId: string) => void;
370
+ readMedia?: ReadMedia;
371
+ openIn?: OpenInApp;
372
+ }) {
373
+ const [items, setItems] = useState<WorkflowRunListItem[] | null>(null);
374
+ const [error, setError] = useState(false);
375
+ const [attempt, setAttempt] = useState(0);
376
+ const [viewerId, setViewerId] = useState<string | null>(null);
377
+ const compact = useCompact();
378
+ const { runs, record, readErrors } = useRunSummaries(client, items);
379
+ const recordStatus = useCallback(
380
+ (run: RunStatus) => {
381
+ record(run);
382
+ },
383
+ [record],
384
+ );
385
+ useEffect(() => {
386
+ let active = true;
387
+ // Optional, like chatChoices: a failed read only means "Started by"
388
+ // shows an id instead of "You" a little longer, never a page failure.
389
+ void client.viewerId().then(
390
+ (reply) => {
391
+ if (active && "memberId" in reply) setViewerId(reply.memberId);
392
+ },
393
+ () => undefined,
394
+ );
395
+ return () => {
396
+ active = false;
397
+ };
398
+ }, [client]);
399
+ useEffect(() => {
400
+ let active = true;
401
+ setItems(null);
402
+ setError(false);
403
+ void client.listRuns(workflowId).then(
404
+ (rows) => {
405
+ if (active) setItems(rows);
406
+ },
407
+ () => {
408
+ if (active) setError(true);
409
+ },
410
+ );
411
+ return () => {
412
+ active = false;
413
+ };
414
+ }, [client, workflowId, selectedRun, attempt]);
415
+ const chosen = items?.find((item) => item.runId === selectedRun);
416
+ // Resolve a run-only deep link using this list's existing metadata, without
417
+ // another lookup or a guessed workflow scope.
418
+ useEffect(() => {
419
+ if (chosen && workflowId === undefined)
420
+ onSelect(chosen.runId, chosen.workflowId);
421
+ }, [chosen, workflowId, onSelect]);
422
+ // A person's own pick wins; otherwise the first run waiting on them, then the newest.
423
+ const preferred =
424
+ items?.find((item) => runSection(runs[item.runId]) === "needs-you") ??
425
+ items?.[0];
426
+ const selected = chosen ?? (compact ? undefined : preferred);
427
+ useChrome("runs", {
428
+ crumbs: chosen
429
+ ? [
430
+ {
431
+ id: "run",
432
+ label: `Run · ${versionRelativeTime(new Date(chosen.startedAt).toISOString())}`,
433
+ },
434
+ ]
435
+ : [],
436
+ });
437
+ const showList = !compact || selected === undefined;
438
+ const showDetail = !compact || selected !== undefined;
439
+ return (
440
+ <main className="wf-runs" data-compact={compact ? "" : undefined}>
441
+ {showList ? (
442
+ <Surface placement="embedded" size="sm" className="wf-run-list">
443
+ {error ? (
444
+ <p role="alert">
445
+ Runs could not be loaded.{" "}
446
+ <Button
447
+ variant="ghost"
448
+ onClick={() => {
449
+ setAttempt((value) => value + 1);
450
+ }}
451
+ >
452
+ Retry
453
+ </Button>
454
+ </p>
455
+ ) : null}
456
+ {!items && !error ? <p>Loading runs…</p> : null}
457
+ {items?.length === 0 ? (
458
+ <EmptyState
459
+ headingLevel={3}
460
+ variant="quiet"
461
+ title="No runs yet"
462
+ description="Runs from every workflow appear here."
463
+ />
464
+ ) : null}
465
+ {(["needs-you", "running", "finished"] as const).map((section) => {
466
+ const rows = (items ?? []).filter(
467
+ (item) => runSection(runs[item.runId]) === section,
468
+ );
469
+ return rows.length === 0 ? null : (
470
+ <section key={section} aria-label={sectionTitles[section]}>
471
+ <DataListHeading level={3}>
472
+ {sectionTitles[section]}
473
+ </DataListHeading>
474
+ <DataList density="compact">
475
+ {rows.map((item) => {
476
+ const when = new Date(item.startedAt).toISOString();
477
+ const line =
478
+ runLatestLine(runs[item.runId]) ??
479
+ readErrors.get(item.runId)?.message ??
480
+ sourceLabel[item.source];
481
+ return (
482
+ <DataRow key={item.runId} data-run-id={item.runId}>
483
+ <ActionRow
484
+ appearance="plain"
485
+ density="compact"
486
+ variant="ghost"
487
+ className={
488
+ selected?.runId === item.runId
489
+ ? "wf-run-choice bg-base-300"
490
+ : "wf-run-choice"
491
+ }
492
+ aria-current={
493
+ selected?.runId === item.runId ? "page" : undefined
494
+ }
495
+ onClick={() => {
496
+ onSelect(item.runId, item.workflowId);
497
+ }}
498
+ label={`${item.title ?? "Workflow title unavailable"}${item.version ? ` · v${String(item.version)}` : ""}`}
499
+ description={
500
+ item.source === "manual"
501
+ ? line
502
+ : `${sourceLabel[item.source]} · ${line}`
503
+ }
504
+ trailing={
505
+ <span className="inline-flex items-center gap-1.5">
506
+ <NodeStatus
507
+ state={runBeadState(runs[item.runId])}
508
+ bare
509
+ />
510
+ <time dateTime={when}>
511
+ {versionRelativeTime(when)}
512
+ </time>
513
+ </span>
514
+ }
515
+ />
516
+ </DataRow>
517
+ );
518
+ })}
519
+ </DataList>
520
+ </section>
521
+ );
522
+ })}
523
+ </Surface>
524
+ ) : null}
525
+ {showDetail && selected ? (
526
+ <RunDetail
527
+ key={selected.runId}
528
+ client={client}
529
+ item={selected}
530
+ readMedia={readMedia}
531
+ openIn={openIn}
532
+ onStatus={recordStatus}
533
+ viewerId={viewerId}
534
+ />
535
+ ) : showDetail ? (
536
+ <EmptyState
537
+ variant="panel"
538
+ title="Choose a run"
539
+ description="Select a run from the list to see what happened."
540
+ />
541
+ ) : null}
542
+ </main>
543
+ );
544
+ }