@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.
- package/package.json +8 -5
- package/src/history/relative-time.ts +17 -0
- package/src/runs/input-form.tsx +545 -0
- package/src/runs/node-status.tsx +41 -0
- package/src/runs/output-preview.tsx +181 -0
- package/src/runs/public.ts +5 -0
- package/src/runs/review-dock.tsx +147 -0
- package/src/runs/run-state.ts +187 -0
- package/src/runs/run-timeline.css +46 -0
- package/src/runs/run-timeline.tsx +156 -0
- package/src/runs/run-workflow.tsx +199 -0
- package/src/runs/runs.tsx +544 -0
- package/src/runs/timeline-items.ts +196 -0
- package/src/runs/use-run-summaries.ts +84 -0
- package/src/runs/use-run.ts +39 -0
- package/templates/bonsai-build-on-merge.json +134 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import type { Json } from "@fungi.computer/braid";
|
|
4
|
+
import type { RunStep } from "./run-state.js";
|
|
5
|
+
import { versionRelativeTime } from "../history/relative-time.js";
|
|
6
|
+
|
|
7
|
+
export type ReadMedia = (path: string, signal: AbortSignal) => Promise<Blob>;
|
|
8
|
+
|
|
9
|
+
const mediaSchema = z.object({
|
|
10
|
+
destination: z.object({ path: z.string().startsWith("/").min(2).max(4096) }),
|
|
11
|
+
format: z.enum(["png", "jpeg", "gif", "webp", "mp4"]),
|
|
12
|
+
});
|
|
13
|
+
const messageSchema = z.object({ message: z.string() });
|
|
14
|
+
|
|
15
|
+
/** Read one Computer file through the admitted reader into an object URL while mounted. */
|
|
16
|
+
function useMediaPreview(path: string | undefined, readMedia?: ReadMedia) {
|
|
17
|
+
const [preview, setPreview] = useState<string | null>(null);
|
|
18
|
+
const [failed, setFailed] = useState(false);
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
setPreview(null);
|
|
21
|
+
setFailed(false);
|
|
22
|
+
if (!path || !readMedia) return;
|
|
23
|
+
const controller = new AbortController();
|
|
24
|
+
let url: string | undefined;
|
|
25
|
+
void readMedia(path, controller.signal).then(
|
|
26
|
+
(blob) => {
|
|
27
|
+
if (controller.signal.aborted) return;
|
|
28
|
+
url = URL.createObjectURL(blob);
|
|
29
|
+
setPreview(url);
|
|
30
|
+
},
|
|
31
|
+
() => {
|
|
32
|
+
if (!controller.signal.aborted) setFailed(true);
|
|
33
|
+
},
|
|
34
|
+
);
|
|
35
|
+
return () => {
|
|
36
|
+
controller.abort();
|
|
37
|
+
if (url) URL.revokeObjectURL(url);
|
|
38
|
+
};
|
|
39
|
+
}, [path, readMedia]);
|
|
40
|
+
return { preview, failed };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A small still of what a step made, for the leading edge of its timeline row. */
|
|
44
|
+
export function MediaThumb({
|
|
45
|
+
media,
|
|
46
|
+
readMedia,
|
|
47
|
+
}: {
|
|
48
|
+
media: Readonly<{ path: string; format: "fungi:image" | "fungi:video" }>;
|
|
49
|
+
readMedia?: ReadMedia | undefined;
|
|
50
|
+
}) {
|
|
51
|
+
const { preview } = useMediaPreview(media.path, readMedia);
|
|
52
|
+
return (
|
|
53
|
+
<span className="wf-thumb" aria-hidden="true">
|
|
54
|
+
{preview === null ? null : media.format === "fungi:video" ? (
|
|
55
|
+
<video src={preview} muted preload="metadata" />
|
|
56
|
+
) : (
|
|
57
|
+
<img src={preview} alt="" />
|
|
58
|
+
)}
|
|
59
|
+
</span>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Only the existing admitted Computer reader resolves media; result strings are never URLs. */
|
|
64
|
+
export function OutputPreview({
|
|
65
|
+
value,
|
|
66
|
+
format,
|
|
67
|
+
readMedia,
|
|
68
|
+
}: {
|
|
69
|
+
value: Json;
|
|
70
|
+
format?: string | undefined;
|
|
71
|
+
readMedia?: ReadMedia | undefined;
|
|
72
|
+
}) {
|
|
73
|
+
const media = mediaSchema.safeParse(value);
|
|
74
|
+
const text = z.string().safeParse(value);
|
|
75
|
+
const path = media.success
|
|
76
|
+
? media.data.destination.path
|
|
77
|
+
: text.success && ["fungi:image", "fungi:video"].includes(format ?? "")
|
|
78
|
+
? text.data
|
|
79
|
+
: undefined;
|
|
80
|
+
const video = media.success
|
|
81
|
+
? media.data.format === "mp4"
|
|
82
|
+
: format === "fungi:video";
|
|
83
|
+
const { preview, failed } = useMediaPreview(path, readMedia);
|
|
84
|
+
return (
|
|
85
|
+
<div className="wf-output-preview">
|
|
86
|
+
{preview ? (
|
|
87
|
+
video ? (
|
|
88
|
+
<video src={preview} controls aria-label="Step video output" />
|
|
89
|
+
) : (
|
|
90
|
+
<img src={preview} alt="Step image output" />
|
|
91
|
+
)
|
|
92
|
+
) : (
|
|
93
|
+
<pre>{text.success ? text.data : JSON.stringify(value, null, 2)}</pre>
|
|
94
|
+
)}
|
|
95
|
+
{path && !preview ? (
|
|
96
|
+
<p className="wf-muted">
|
|
97
|
+
{failed || !readMedia ? "Preview unavailable." : "Loading preview…"}
|
|
98
|
+
</p>
|
|
99
|
+
) : null}
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
export function OutputError({ error }: { error: Json }) {
|
|
104
|
+
const message = messageSchema.safeParse(error);
|
|
105
|
+
const text = z.string().safeParse(error);
|
|
106
|
+
return (
|
|
107
|
+
<p role="alert">
|
|
108
|
+
{message.success
|
|
109
|
+
? message.data.message
|
|
110
|
+
: text.success
|
|
111
|
+
? text.data
|
|
112
|
+
: JSON.stringify(error, null, 2)}
|
|
113
|
+
</p>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Shared result presentation for graph cards and the step list. */
|
|
118
|
+
export function StepOutput({
|
|
119
|
+
step,
|
|
120
|
+
formats,
|
|
121
|
+
readMedia,
|
|
122
|
+
}: {
|
|
123
|
+
step: RunStep;
|
|
124
|
+
formats?: Readonly<Record<string, string | undefined>>;
|
|
125
|
+
readMedia?: ReadMedia | undefined;
|
|
126
|
+
}) {
|
|
127
|
+
const review = "reviewOutput" in step ? step.reviewOutput : undefined;
|
|
128
|
+
const recordedAt =
|
|
129
|
+
review && "recordedAt" in review ? review.recordedAt : undefined;
|
|
130
|
+
const decision =
|
|
131
|
+
review?.choice === "approved"
|
|
132
|
+
? "Approved"
|
|
133
|
+
: review?.choice === "rejected"
|
|
134
|
+
? "Rejected"
|
|
135
|
+
: review?.choice;
|
|
136
|
+
return (
|
|
137
|
+
<div className="wf-step-output">
|
|
138
|
+
{review ? (
|
|
139
|
+
<div>
|
|
140
|
+
<strong>
|
|
141
|
+
{decision}
|
|
142
|
+
{review.note ? `: ${review.note}` : ""}
|
|
143
|
+
</strong>
|
|
144
|
+
{recordedAt !== undefined ? (
|
|
145
|
+
<>
|
|
146
|
+
{" · "}
|
|
147
|
+
<time dateTime={new Date(recordedAt).toISOString()}>
|
|
148
|
+
{versionRelativeTime(new Date(recordedAt).toISOString())}
|
|
149
|
+
</time>
|
|
150
|
+
</>
|
|
151
|
+
) : null}
|
|
152
|
+
<OutputPreview
|
|
153
|
+
value={review.value}
|
|
154
|
+
format={formats?.[review.choice]}
|
|
155
|
+
readMedia={readMedia}
|
|
156
|
+
/>
|
|
157
|
+
</div>
|
|
158
|
+
) : null}
|
|
159
|
+
{step.output !== undefined && review === undefined
|
|
160
|
+
? Object.entries(step.output).map(([port, value]) => (
|
|
161
|
+
<div key={port}>
|
|
162
|
+
<strong>{port}</strong>
|
|
163
|
+
<OutputPreview
|
|
164
|
+
value={value}
|
|
165
|
+
format={formats?.[port]}
|
|
166
|
+
readMedia={readMedia}
|
|
167
|
+
/>
|
|
168
|
+
</div>
|
|
169
|
+
))
|
|
170
|
+
: null}
|
|
171
|
+
{step.state === "failed" ? (
|
|
172
|
+
<OutputError
|
|
173
|
+
error={step.errorMessage ?? step.refusal ?? "This step failed."}
|
|
174
|
+
/>
|
|
175
|
+
) : null}
|
|
176
|
+
{step.state === "complete" && step.output === undefined ? (
|
|
177
|
+
<p className="wf-muted">No output was recorded for this step.</p>
|
|
178
|
+
) : null}
|
|
179
|
+
</div>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** The existing Trellis run/step presentation, shared by first-party callers. */
|
|
2
|
+
export { RunTimeline } from "./run-timeline.js";
|
|
3
|
+
export { OutputError } from "./output-preview.js";
|
|
4
|
+
export { runStatusSchema, runSteps, terminal } from "./run-state.js";
|
|
5
|
+
export { timelineItems } from "./timeline-items.js";
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import * as S from "effect/Schema";
|
|
2
|
+
import { fillReviewText, reviewAnswerSchema } from "@fungi.computer/braid";
|
|
3
|
+
import { Input } from "@fungi.computer/caps/components/input";
|
|
4
|
+
import { useState } from "react";
|
|
5
|
+
import { Button } from "@fungi.computer/caps/components/button";
|
|
6
|
+
import type { WorkflowClient } from "../workflow-client.js";
|
|
7
|
+
import { stepPosition, type Review } from "./run-state.js";
|
|
8
|
+
import { OutputPreview, type ReadMedia } from "./output-preview.js";
|
|
9
|
+
import { NodeStatus } from "./node-status.js";
|
|
10
|
+
/** The question a run is waiting on, docked where a composer sits. */
|
|
11
|
+
export function ReviewDock({
|
|
12
|
+
runId,
|
|
13
|
+
review,
|
|
14
|
+
detail,
|
|
15
|
+
choices,
|
|
16
|
+
format,
|
|
17
|
+
readMedia,
|
|
18
|
+
answerReview,
|
|
19
|
+
onAnswered,
|
|
20
|
+
}: {
|
|
21
|
+
runId: string;
|
|
22
|
+
review: Review;
|
|
23
|
+
/** The review node's line about where the run is, before its placeholders are filled. */
|
|
24
|
+
detail?: string | undefined;
|
|
25
|
+
choices?: readonly { id: string; label: string; description?: string }[];
|
|
26
|
+
format?: string;
|
|
27
|
+
readMedia?: ReadMedia;
|
|
28
|
+
answerReview: WorkflowClient["answerReview"];
|
|
29
|
+
onAnswered: () => void;
|
|
30
|
+
}) {
|
|
31
|
+
const [note, setNote] = useState("");
|
|
32
|
+
const [sending, setSending] = useState(false);
|
|
33
|
+
const [error, setError] = useState<Error | null>(null);
|
|
34
|
+
async function answer(answer: typeof reviewAnswerSchema.Type) {
|
|
35
|
+
const decoded = S.decodeUnknownOption(reviewAnswerSchema)(answer, {
|
|
36
|
+
onExcessProperty: "error",
|
|
37
|
+
});
|
|
38
|
+
if (decoded._tag === "None") {
|
|
39
|
+
setError(
|
|
40
|
+
new Error(
|
|
41
|
+
"The review answer is invalid. Shorten your note and try again.",
|
|
42
|
+
),
|
|
43
|
+
);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
setSending(true);
|
|
47
|
+
setError(null);
|
|
48
|
+
try {
|
|
49
|
+
const result = await answerReview({
|
|
50
|
+
runId,
|
|
51
|
+
nodeId: review.nodeId,
|
|
52
|
+
iteration: review.iteration,
|
|
53
|
+
answer: decoded.value,
|
|
54
|
+
});
|
|
55
|
+
if (result === "accepted") onAnswered();
|
|
56
|
+
else setError(new Error(result));
|
|
57
|
+
} catch (cause) {
|
|
58
|
+
setError(new Error("Could not send your answer. Try again.", { cause }));
|
|
59
|
+
} finally {
|
|
60
|
+
setSending(false);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const position = stepPosition(review);
|
|
64
|
+
const fill = (text: string | undefined) =>
|
|
65
|
+
text === undefined ? undefined : fillReviewText(text, position);
|
|
66
|
+
const where = fill(detail);
|
|
67
|
+
const choiceFor = (id: string) => choices?.find((choice) => choice.id === id);
|
|
68
|
+
const options = review.choices?.length
|
|
69
|
+
? review.choices.map((id) => ({
|
|
70
|
+
id,
|
|
71
|
+
label: choiceFor(id)?.label ?? id,
|
|
72
|
+
description: fill(choiceFor(id)?.description),
|
|
73
|
+
answer: (): typeof reviewAnswerSchema.Type =>
|
|
74
|
+
note.length ? { choice: id, note } : { choice: id },
|
|
75
|
+
}))
|
|
76
|
+
: [
|
|
77
|
+
{
|
|
78
|
+
id: "approved",
|
|
79
|
+
label: "Approve",
|
|
80
|
+
description: undefined,
|
|
81
|
+
answer: (): typeof reviewAnswerSchema.Type => ({ approved: true }),
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
id: "rejected",
|
|
85
|
+
label: "Reject",
|
|
86
|
+
description: undefined,
|
|
87
|
+
answer: (): typeof reviewAnswerSchema.Type => ({ approved: false }),
|
|
88
|
+
},
|
|
89
|
+
];
|
|
90
|
+
return (
|
|
91
|
+
<section className="wf-dock" aria-label="Review">
|
|
92
|
+
{review.show.length > 0 ? (
|
|
93
|
+
<div className="wf-dock-media">
|
|
94
|
+
{review.show.map((value, index) => (
|
|
95
|
+
<OutputPreview
|
|
96
|
+
key={index}
|
|
97
|
+
value={value}
|
|
98
|
+
format={index === 0 ? format : undefined}
|
|
99
|
+
readMedia={readMedia}
|
|
100
|
+
/>
|
|
101
|
+
))}
|
|
102
|
+
</div>
|
|
103
|
+
) : null}
|
|
104
|
+
<div className="wf-dock-body">
|
|
105
|
+
<div className="wf-dock-waiting">
|
|
106
|
+
<NodeStatus state="waiting" label="Waiting on you" />
|
|
107
|
+
</div>
|
|
108
|
+
<h3>{review.title}</h3>
|
|
109
|
+
{where === undefined ? null : (
|
|
110
|
+
<p className="wf-dock-where wf-muted">{where}</p>
|
|
111
|
+
)}
|
|
112
|
+
<ul className="wf-dock-choices">
|
|
113
|
+
{options.map((option, index) => (
|
|
114
|
+
<li key={option.id}>
|
|
115
|
+
<Button
|
|
116
|
+
className="wf-dock-key"
|
|
117
|
+
variant={index === 0 ? "primary" : "outline"}
|
|
118
|
+
disabled={sending}
|
|
119
|
+
onClick={() => {
|
|
120
|
+
void answer(option.answer());
|
|
121
|
+
}}
|
|
122
|
+
>
|
|
123
|
+
{option.label}
|
|
124
|
+
</Button>
|
|
125
|
+
{option.description === undefined ? null : (
|
|
126
|
+
<span className="wf-muted">{option.description}</span>
|
|
127
|
+
)}
|
|
128
|
+
</li>
|
|
129
|
+
))}
|
|
130
|
+
</ul>
|
|
131
|
+
{review.choices?.length ? (
|
|
132
|
+
<Input
|
|
133
|
+
className="wf-dock-note"
|
|
134
|
+
aria-label="Note"
|
|
135
|
+
placeholder="Add a note (optional)"
|
|
136
|
+
value={note}
|
|
137
|
+
disabled={sending}
|
|
138
|
+
onChange={(event) => {
|
|
139
|
+
setNote(event.target.value);
|
|
140
|
+
}}
|
|
141
|
+
/>
|
|
142
|
+
) : null}
|
|
143
|
+
{error ? <p role="alert">{error.message}</p> : null}
|
|
144
|
+
</div>
|
|
145
|
+
</section>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
import * as S from "effect/Schema";
|
|
3
|
+
import { resultRowSchemaV1 } from "@fungi.computer/agar";
|
|
4
|
+
import { runOutcomeSchema } from "@fungi.computer/workflows-wire";
|
|
5
|
+
import { loopPositionSchema } from "@fungi.computer/braid";
|
|
6
|
+
const nodeStateSchema = z.enum([
|
|
7
|
+
"queued",
|
|
8
|
+
"running",
|
|
9
|
+
"waiting",
|
|
10
|
+
"complete",
|
|
11
|
+
"failed",
|
|
12
|
+
"skipped",
|
|
13
|
+
]);
|
|
14
|
+
const event = z.object({
|
|
15
|
+
nodeId: z.string(),
|
|
16
|
+
graphId: z.string().optional(),
|
|
17
|
+
iteration: z.string(),
|
|
18
|
+
/** Which Map item and Repeat try the runtime ran this in; decoded where it is shown. */
|
|
19
|
+
position: z.json().optional(),
|
|
20
|
+
state: nodeStateSchema,
|
|
21
|
+
attempt: z.number(),
|
|
22
|
+
recordedAt: z.number().int().nonnegative().optional(),
|
|
23
|
+
durationMs: z.number().optional(),
|
|
24
|
+
costMilliunits: z.number().optional(),
|
|
25
|
+
output: z.record(z.string(), z.json()).optional(),
|
|
26
|
+
/** How the App says this finished call reads; decoded where it is shown. */
|
|
27
|
+
row: z.json().optional(),
|
|
28
|
+
refusal: z.string().optional(),
|
|
29
|
+
errorMessage: z.string().max(1024).optional(),
|
|
30
|
+
});
|
|
31
|
+
const reviewSchema = z.object({
|
|
32
|
+
nodeId: z.string(),
|
|
33
|
+
iteration: z.string(),
|
|
34
|
+
position: z.json().optional(),
|
|
35
|
+
title: z.string(),
|
|
36
|
+
show: z.array(z.json()),
|
|
37
|
+
choices: z.array(z.string()).optional(),
|
|
38
|
+
});
|
|
39
|
+
export const runStatusSchema = z.object({
|
|
40
|
+
runId: z.string(),
|
|
41
|
+
planDigest: z.string(),
|
|
42
|
+
outcome: runOutcomeSchema,
|
|
43
|
+
title: z.string(),
|
|
44
|
+
events: z.array(event),
|
|
45
|
+
reviews: z.array(z.object({ review: reviewSchema, status: z.string() })),
|
|
46
|
+
status: z.string(),
|
|
47
|
+
output: z.json(),
|
|
48
|
+
error: z.json(),
|
|
49
|
+
});
|
|
50
|
+
export type RunStatus = z.output<typeof runStatusSchema>;
|
|
51
|
+
export type Review = z.output<typeof reviewSchema>;
|
|
52
|
+
export type NodeState = z.output<typeof nodeStateSchema> | "cancelled";
|
|
53
|
+
/** Owner cancellation stops unfinished presentation without rewriting recorded events. */
|
|
54
|
+
function runNodeState(
|
|
55
|
+
state: NodeState,
|
|
56
|
+
outcome: RunStatus["outcome"],
|
|
57
|
+
): NodeState {
|
|
58
|
+
return outcome === "cancelled" &&
|
|
59
|
+
["queued", "running", "waiting"].includes(state)
|
|
60
|
+
? "cancelled"
|
|
61
|
+
: state;
|
|
62
|
+
}
|
|
63
|
+
export const terminal = (status: string) =>
|
|
64
|
+
["complete", "errored", "failed", "terminated", "cancelled"].includes(status);
|
|
65
|
+
/** Collapse each iteration/attempt to its latest event before summing facts. */
|
|
66
|
+
export function runProgress(
|
|
67
|
+
events: RunStatus["events"],
|
|
68
|
+
outcome: RunStatus["outcome"] = "active",
|
|
69
|
+
) {
|
|
70
|
+
const latest = new Map<string, RunStatus["events"][number]>();
|
|
71
|
+
const nodes: Record<string, NodeState> = {};
|
|
72
|
+
for (const event of events) {
|
|
73
|
+
latest.set(
|
|
74
|
+
`${event.nodeId}:${event.iteration}:${String(event.attempt)}`,
|
|
75
|
+
event,
|
|
76
|
+
);
|
|
77
|
+
nodes[event.nodeId] = runNodeState(event.state, outcome);
|
|
78
|
+
}
|
|
79
|
+
const recorded = [...latest.values()];
|
|
80
|
+
return {
|
|
81
|
+
nodes,
|
|
82
|
+
durationMs: recorded.some((e) => e.durationMs !== undefined)
|
|
83
|
+
? recorded.reduce((sum, e) => sum + (e.durationMs ?? 0), 0)
|
|
84
|
+
: null,
|
|
85
|
+
costMilliunits: recorded.some((e) => e.costMilliunits !== undefined)
|
|
86
|
+
? recorded.reduce((sum, e) => sum + (e.costMilliunits ?? 0), 0)
|
|
87
|
+
: null,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Latest lifecycle fact per graph node and iteration; each Map item keeps its own result. */
|
|
92
|
+
export function runSteps(
|
|
93
|
+
events: RunStatus["events"],
|
|
94
|
+
outcome: RunStatus["outcome"],
|
|
95
|
+
reviews: RunStatus["reviews"] = [],
|
|
96
|
+
) {
|
|
97
|
+
const latest = new Map<string, RunStatus["events"][number]>();
|
|
98
|
+
for (const event of events)
|
|
99
|
+
latest.set(
|
|
100
|
+
JSON.stringify([event.graphId ?? null, event.nodeId, event.iteration]),
|
|
101
|
+
event,
|
|
102
|
+
);
|
|
103
|
+
return [...latest.values()].map((event) => {
|
|
104
|
+
const reviewOutput = recordedReviewOutput(event, reviews);
|
|
105
|
+
const state = runNodeState(event.state, outcome);
|
|
106
|
+
if (reviewOutput === undefined) return { ...event, state };
|
|
107
|
+
return { ...event, state, reviewOutput };
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const reviewOutputSchema = z.object({ choice: z.string(), note: z.string() });
|
|
112
|
+
function recordedReviewOutput(
|
|
113
|
+
event: RunStatus["events"][number],
|
|
114
|
+
reviews: RunStatus["reviews"],
|
|
115
|
+
) {
|
|
116
|
+
if (event.state !== "complete" || event.output === undefined) return;
|
|
117
|
+
const review = reviews.find(
|
|
118
|
+
({ review }) =>
|
|
119
|
+
review.nodeId === event.nodeId && review.iteration === event.iteration,
|
|
120
|
+
);
|
|
121
|
+
if (review === undefined) return;
|
|
122
|
+
const answer = reviewOutputSchema.safeParse(event.output);
|
|
123
|
+
if (!answer.success) return;
|
|
124
|
+
const choices = review.review.choices ?? ["approved", "rejected"];
|
|
125
|
+
if (!choices.includes(answer.data.choice)) return;
|
|
126
|
+
const chosenOutput = Object.entries(event.output).find(
|
|
127
|
+
([port]) => port === answer.data.choice,
|
|
128
|
+
);
|
|
129
|
+
if (chosenOutput === undefined) return;
|
|
130
|
+
return {
|
|
131
|
+
...answer.data,
|
|
132
|
+
value: chosenOutput[1],
|
|
133
|
+
recordedAt: event.recordedAt,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export type RunStep = ReturnType<typeof runSteps>[number];
|
|
138
|
+
|
|
139
|
+
/** The App's own reading of a finished step, when it declared one and it decodes. */
|
|
140
|
+
export function stepRow(step: Readonly<{ row?: unknown }>) {
|
|
141
|
+
if (step.row === undefined) return undefined;
|
|
142
|
+
const decoded = S.decodeUnknownOption(resultRowSchemaV1)(step.row);
|
|
143
|
+
return decoded._tag === "Some" ? decoded.value : undefined;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Where the runtime ran a step or review; no position when it is unreadable. */
|
|
147
|
+
export function stepPosition(step: Readonly<{ position?: unknown }>) {
|
|
148
|
+
if (step.position === undefined) return [];
|
|
149
|
+
const decoded = S.decodeUnknownOption(loopPositionSchema)(step.position);
|
|
150
|
+
return decoded._tag === "Some" ? decoded.value : [];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Which part of the Runs list a run belongs in. */
|
|
154
|
+
export function runSection(run: RunStatus | undefined) {
|
|
155
|
+
if (run === undefined) return "running" as const;
|
|
156
|
+
if (run.outcome !== "active" || terminal(run.status))
|
|
157
|
+
return "finished" as const;
|
|
158
|
+
return run.reviews.some(({ status }) => status === "pending")
|
|
159
|
+
? ("needs-you" as const)
|
|
160
|
+
: ("running" as const);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The bead a run wears in the list. */
|
|
164
|
+
export function runBeadState(run: RunStatus | undefined): NodeState {
|
|
165
|
+
const section = runSection(run);
|
|
166
|
+
if (section === "needs-you") return "waiting";
|
|
167
|
+
if (section === "running") return "running";
|
|
168
|
+
if (run?.outcome === "cancelled") return "cancelled";
|
|
169
|
+
return run !== undefined &&
|
|
170
|
+
["errored", "failed", "terminated"].includes(run.status)
|
|
171
|
+
? "failed"
|
|
172
|
+
: "complete";
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** One plain line for the list: the open question, else the newest thing an App did. */
|
|
176
|
+
export function runLatestLine(run: RunStatus | undefined) {
|
|
177
|
+
if (run === undefined) return undefined;
|
|
178
|
+
const pending = run.reviews.find(({ status }) => status === "pending");
|
|
179
|
+
if (run.outcome === "active" && pending) return pending.review.title;
|
|
180
|
+
for (const event of [...run.events].reverse()) {
|
|
181
|
+
const row = stepRow(event);
|
|
182
|
+
if (row) return row.title;
|
|
183
|
+
}
|
|
184
|
+
if (run.outcome === "cancelled") return "Cancelled";
|
|
185
|
+
if (["errored", "failed", "terminated"].includes(run.status)) return "Failed";
|
|
186
|
+
return runSection(run) === "finished" ? "Finished" : "Running";
|
|
187
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/* Steps are Parakeet call rows (Caps Disclosure + Status) at body size. */
|
|
2
|
+
.wf-timeline {
|
|
3
|
+
--caps-disclosure-font-size: var(--text-sm);
|
|
4
|
+
list-style: none;
|
|
5
|
+
margin: 0;
|
|
6
|
+
padding: 0;
|
|
7
|
+
display: grid;
|
|
8
|
+
}
|
|
9
|
+
.wf-step {
|
|
10
|
+
display: grid;
|
|
11
|
+
gap: var(--spacing-2xs);
|
|
12
|
+
min-width: 0;
|
|
13
|
+
padding-block: var(--spacing-2xs);
|
|
14
|
+
border-bottom: 1px solid
|
|
15
|
+
color-mix(in oklab, var(--color-base-content) 8%, transparent);
|
|
16
|
+
}
|
|
17
|
+
/* Muted details read at the row's own size, as in the tool timeline. */
|
|
18
|
+
.wf-timeline .wf-muted {
|
|
19
|
+
font-size: inherit;
|
|
20
|
+
color: color-mix(in oklab, var(--color-base-content) 62%, transparent);
|
|
21
|
+
}
|
|
22
|
+
/* The quiet "Open in <App>" action trails its row, outside the toggle; the
|
|
23
|
+
action column is reserved on every row so beads line up. */
|
|
24
|
+
.wf-step-row {
|
|
25
|
+
display: grid;
|
|
26
|
+
grid-template-columns: minmax(0, 1fr) 7.5rem;
|
|
27
|
+
align-items: center;
|
|
28
|
+
gap: var(--spacing-xs);
|
|
29
|
+
min-width: 0;
|
|
30
|
+
}
|
|
31
|
+
.wf-step-row[data-open] {
|
|
32
|
+
align-items: start;
|
|
33
|
+
}
|
|
34
|
+
.wf-step-open {
|
|
35
|
+
justify-self: end;
|
|
36
|
+
}
|
|
37
|
+
@media (max-width: 720px) {
|
|
38
|
+
/* A phone keeps the title whole: the action sits under it, no thumbnail. */
|
|
39
|
+
.wf-step-row {
|
|
40
|
+
grid-template-columns: minmax(0, 1fr);
|
|
41
|
+
}
|
|
42
|
+
.wf-step-open {
|
|
43
|
+
justify-self: start;
|
|
44
|
+
margin-inline-start: var(--spacing-xl);
|
|
45
|
+
}
|
|
46
|
+
}
|