@fungi.computer/trellis 0.2.2 → 0.3.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,156 @@
1
+ import "./run-timeline.css";
2
+ import { useState } from "react";
3
+ import { Check } from "lucide-react";
4
+ import { Button } from "@fungi.computer/caps/components/button";
5
+ import { Disclosure } from "@fungi.computer/caps/components/disclosure";
6
+ import { Monogram } from "@fungi.computer/caps/components/monogram";
7
+ import { isSigilName, Sigil } from "@fungi.computer/sigils";
8
+ import { NodeStatus } from "./node-status.js";
9
+ import { MediaThumb, StepOutput, type ReadMedia } from "./output-preview.js";
10
+ import { formatDuration, type TimelineItem } from "./timeline-items.js";
11
+
12
+ /** Open another App at what a step produced. */
13
+ export type OpenInApp = (
14
+ appId: string,
15
+ params: readonly Readonly<{ key: string; value: string }>[],
16
+ ) => Promise<void>;
17
+
18
+ /** Steps beyond this many are folded until asked for, like a tool group. */
19
+ const RECENT = 8;
20
+
21
+ function Mark({ item }: { item: TimelineItem }) {
22
+ if (item.app === undefined)
23
+ return <Check size={20} aria-hidden="true" className="wf-control-mark" />;
24
+ return item.app.icon !== undefined && isSigilName(item.app.icon) ? (
25
+ <Sigil name={item.app.icon} size={20} />
26
+ ) : (
27
+ <Monogram name={item.app.displayName} subject="app" />
28
+ );
29
+ }
30
+
31
+ function Step({
32
+ item,
33
+ open,
34
+ onToggle,
35
+ readMedia,
36
+ openIn,
37
+ }: {
38
+ item: TimelineItem;
39
+ open: boolean;
40
+ onToggle: () => void;
41
+ readMedia?: ReadMedia | undefined;
42
+ openIn?: OpenInApp | undefined;
43
+ }) {
44
+ const [openError, setOpenError] = useState<Error | null>(null);
45
+ const target = item.open;
46
+ return (
47
+ <li className="wf-step" data-state={item.state}>
48
+ <div className="wf-step-row" data-open={open ? "" : undefined}>
49
+ <Disclosure
50
+ size="group"
51
+ tone={item.state === "failed" ? "error" : "neutral"}
52
+ open={open}
53
+ onOpenChange={onToggle}
54
+ leading={
55
+ <>
56
+ <Mark item={item} />
57
+ {item.media === undefined ? null : (
58
+ <MediaThumb media={item.media} readMedia={readMedia} />
59
+ )}
60
+ </>
61
+ }
62
+ summary={
63
+ <>
64
+ <span className="text-base-content">{item.title}</span>
65
+ {item.detail === undefined ? null : (
66
+ <span className="wf-muted"> · {item.detail}</span>
67
+ )}
68
+ </>
69
+ }
70
+ trailing={
71
+ <>
72
+ {item.durationMs === undefined || item.durationMs === 0 ? null : (
73
+ <span className="text-xs">
74
+ {formatDuration(item.durationMs)}
75
+ </span>
76
+ )}
77
+ <NodeStatus state={item.state} bare />
78
+ </>
79
+ }
80
+ >
81
+ <StepOutput step={item.step} readMedia={readMedia} />
82
+ </Disclosure>
83
+ {target === undefined || item.app === undefined ? null : (
84
+ <Button
85
+ variant="ghost"
86
+ size="sm"
87
+ className="wf-step-open"
88
+ disabled={openIn === undefined}
89
+ onClick={() => {
90
+ setOpenError(null);
91
+ openIn?.(target.appId, target.params).catch((cause: unknown) => {
92
+ setOpenError(
93
+ new Error(
94
+ `Could not open ${item.app?.displayName ?? "the App"}.`,
95
+ { cause },
96
+ ),
97
+ );
98
+ });
99
+ }}
100
+ >
101
+ Open in {item.app.displayName}
102
+ </Button>
103
+ )}
104
+ </div>
105
+ {openError === null ? null : <p role="alert">{openError.message}</p>}
106
+ </li>
107
+ );
108
+ }
109
+
110
+ /** What happened in a run, newest last: App rows with a raw view behind each. */
111
+ export function RunTimeline({
112
+ items,
113
+ readMedia,
114
+ openIn,
115
+ }: {
116
+ items: readonly TimelineItem[];
117
+ readMedia?: ReadMedia | undefined;
118
+ openIn?: OpenInApp | undefined;
119
+ }) {
120
+ const [opened, setOpened] = useState<ReadonlySet<string>>(new Set());
121
+ const [all, setAll] = useState(false);
122
+ const shown = all ? items : items.slice(-RECENT);
123
+ return (
124
+ <>
125
+ {items.length > RECENT && !all ? (
126
+ <Button
127
+ variant="ghost"
128
+ size="sm"
129
+ onClick={() => {
130
+ setAll(true);
131
+ }}
132
+ >
133
+ Show {String(items.length - RECENT)} earlier steps
134
+ </Button>
135
+ ) : null}
136
+ <ol className="wf-timeline" aria-label="Steps">
137
+ {shown.map((item) => (
138
+ <Step
139
+ key={item.key}
140
+ item={item}
141
+ open={opened.has(item.key)}
142
+ onToggle={() => {
143
+ setOpened((previous) => {
144
+ const next = new Set(previous);
145
+ if (!next.delete(item.key)) next.add(item.key);
146
+ return next;
147
+ });
148
+ }}
149
+ readMedia={readMedia}
150
+ openIn={openIn}
151
+ />
152
+ ))}
153
+ </ol>
154
+ </>
155
+ );
156
+ }
@@ -0,0 +1,199 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { workflowRunRequestIds } from "@fungi.computer/workflows-wire";
3
+ import * as z from "zod";
4
+ import { field, SchemaForm } from "@fungi.computer/caps/form";
5
+ import {
6
+ workflowDefinitionSchema,
7
+ parseProjection,
8
+ type PortSchema,
9
+ type WorkflowDefinition,
10
+ } from "@fungi.computer/braid";
11
+ import * as S from "effect/Schema";
12
+ import { workflowInputField } from "./input-form.js";
13
+ import type { WorkflowClient } from "../workflow-client.js";
14
+ export function RunWorkflow({
15
+ client,
16
+ workflowId,
17
+ version,
18
+ onRun,
19
+ }: {
20
+ client: WorkflowClient;
21
+ workflowId: string;
22
+ version: number | undefined;
23
+ onRun: (id: string) => void;
24
+ }) {
25
+ const request = useRef<{ key: string; id: string } | null>(null);
26
+ const [ready, setReady] = useState<{
27
+ definition: WorkflowDefinition;
28
+ input: PortSchema;
29
+ apps: Awaited<ReturnType<WorkflowClient["catalog"]>>["apps"];
30
+ chats: Awaited<ReturnType<WorkflowClient["chatChoices"]>>;
31
+ } | null>(null);
32
+ const [error, setError] = useState<Error | null>(null);
33
+ useEffect(() => {
34
+ let active = true;
35
+ setReady(null);
36
+ setError(null);
37
+ if (version === undefined) return;
38
+ void Promise.all([
39
+ client.listVersions(workflowId),
40
+ client.catalog(),
41
+ client.chatChoices(),
42
+ ])
43
+ .then(async ([versions, { apps }, chats]) => {
44
+ const chosen = versions.find((item) => item.version === version);
45
+ if (!chosen) throw new Error("Version missing");
46
+ const doc = await client.loadWorkflow(workflowId, {
47
+ commit: chosen.commit,
48
+ });
49
+ if (!doc) throw new Error("Version missing");
50
+ const definition = S.decodeUnknownSync(workflowDefinitionSchema)(
51
+ doc.definition,
52
+ );
53
+ const input = parseProjection(definition.input);
54
+ if (active) setReady({ definition, input, apps, chats });
55
+ })
56
+ .catch((cause: unknown) => {
57
+ if (active)
58
+ setError(
59
+ new Error("The published workflow could not be loaded.", { cause }),
60
+ );
61
+ });
62
+ return () => {
63
+ active = false;
64
+ };
65
+ }, [client, workflowId, version]);
66
+ if (version === undefined)
67
+ return <p>Publish a version before running this workflow.</p>;
68
+ if (error) return <p role="alert">{error.message}</p>;
69
+ if (!ready) return <p>Loading published workflow…</p>;
70
+ const bindings = Object.entries(ready.definition.apps);
71
+ const bindingFields: Record<string, z.ZodString> = {};
72
+ for (const [alias, spec] of bindings) {
73
+ const apps = ready.apps.filter((app) => app.appId === spec.appId);
74
+ bindingFields[`binding_${alias}`] = z
75
+ .string()
76
+ .min(1)
77
+ .meta(
78
+ field({
79
+ label: apps.at(0)?.displayName ?? spec.appId,
80
+ kind: "select",
81
+ options: Object.fromEntries(
82
+ apps.map((app) => [app.installationId, app.displayName]),
83
+ ),
84
+ }),
85
+ );
86
+ }
87
+ const chats = new Map(
88
+ (ready.chats.kind === "ready" ? ready.chats.chats : []).map(
89
+ (chat) => [chatKey(chat), chat] as const,
90
+ ),
91
+ );
92
+ if (chats.size > 0)
93
+ bindingFields.reportTo = z.string().meta(
94
+ field({
95
+ label: "Report to chat",
96
+ help: "Post this run's result to a chat when it ends.",
97
+ kind: "select",
98
+ options: {
99
+ "": "Don't report",
100
+ ...Object.fromEntries(
101
+ [...chats].map(([key, chat]) => [
102
+ key,
103
+ chat.title || "Untitled chat",
104
+ ]),
105
+ ),
106
+ },
107
+ }),
108
+ );
109
+ const inputField = workflowInputField(ready.input);
110
+ const schema = z.object({ input: inputField.schema, ...bindingFields });
111
+ const defaults = Object.fromEntries([
112
+ ["input", inputField.defaultValue] as const,
113
+ ["reportTo", ""] as const,
114
+ ...bindings.map(([alias, spec]) => {
115
+ const matches = ready.apps.filter((app) => app.appId === spec.appId);
116
+ return [
117
+ `binding_${alias}`,
118
+ matches.length === 1 ? matches[0].installationId : "",
119
+ ] as const;
120
+ }),
121
+ ]);
122
+ return (
123
+ <>
124
+ {ready.chats.kind === "unavailable" ? (
125
+ <p>Chats could not be loaded, so this run cannot report to a chat.</p>
126
+ ) : null}
127
+ <SchemaForm
128
+ schema={schema}
129
+ defaultValues={defaults}
130
+ allowClean
131
+ submitLabel={`Run v${String(version)}`}
132
+ aria-label="Run workflow"
133
+ onSubmit={async (values, raw) => {
134
+ const selectedBindings = Object.fromEntries(
135
+ bindings.map(([alias]) => [alias, raw[`binding_${alias}`]]),
136
+ );
137
+ const chat = chats.get(raw.reportTo);
138
+ try {
139
+ const key = JSON.stringify({
140
+ workflowId,
141
+ version,
142
+ values,
143
+ selectedBindings,
144
+ reportTo: raw.reportTo,
145
+ });
146
+ if (request.current?.key !== key)
147
+ request.current = {
148
+ key,
149
+ id: workflowRunRequestIds.request.generate(),
150
+ };
151
+ const run = {
152
+ requestId: request.current.id,
153
+ workflowId,
154
+ version,
155
+ bindings: selectedBindings,
156
+ input: values.input,
157
+ };
158
+ const result = await client.runWorkflow(
159
+ chat === undefined
160
+ ? run
161
+ : {
162
+ ...run,
163
+ delivery: {
164
+ agentId: chat.agentId,
165
+ sessionId: chat.sessionId,
166
+ },
167
+ },
168
+ );
169
+ if (
170
+ "kind" in result &&
171
+ (result.kind === "started" || result.kind === "replay")
172
+ ) {
173
+ onRun(result.runId);
174
+ return;
175
+ }
176
+ return {
177
+ kind: "refused",
178
+ error:
179
+ "error" in result
180
+ ? result.error
181
+ : result.kind === "invalid"
182
+ ? result.issues.map((issue) => issue.message).join("; ")
183
+ : "Run identity conflict.",
184
+ };
185
+ } catch {
186
+ return {
187
+ kind: "refused",
188
+ error: "Could not start the workflow. Try again.",
189
+ };
190
+ }
191
+ }}
192
+ />
193
+ </>
194
+ );
195
+ }
196
+
197
+ /** One select value per chat; Session ids are opaque, so both parts are kept. */
198
+ export const chatKey = (chat: { agentId: string; sessionId: string }) =>
199
+ JSON.stringify([chat.agentId, chat.sessionId]);