Add Workflow tab and run-progress API for workflow runs

This commit is contained in:
Ian Gulliver
2026-10-16 11:01:53 -07:00
parent eb0c80a645
commit ec169583ff
19 changed files with 1285 additions and 209 deletions
+5 -1
View File
@@ -13,6 +13,7 @@ import { mountStatus } from "./status.js";
import { openSwitcher } from "./switcher.js";
import { mountTranscript } from "./task.js";
import { mountTerminal } from "./terminal.js";
import { mountWorkflow } from "./workflow.js";
const views = {
transcript: { label: "Transcript", mount: mountTranscript },
changes: { label: "Changes", mount: (root) => mountChanges(root) },
@@ -20,6 +21,7 @@ const views = {
terminal: { label: "Terminal", mount: mountTerminal },
inbox: { label: "Inbox", mount: mountInbox },
reviews: { label: "Reviews", mount: mountReviews },
workflow: { label: "Workflow", mount: mountWorkflow },
project: { label: "Project", mount: mountProject },
};
const leftViews = {
@@ -166,6 +168,8 @@ export function taskPane() {
return waiting > 0;
case "reviews":
return state.transcript.some(isReviewEvent);
case "workflow":
return state.selectedTaskId !== undefined && findTask(state.selectedTaskId)?.workflow_run !== undefined;
default:
return true;
}
@@ -209,7 +213,7 @@ export function taskPane() {
}
};
on("inbox", openAsked);
onEach(["focus", "inbox", "selection", "transcript"], render);
onEach(["focus", "inbox", "selection", "transcript", "tasks"], render);
onEach(["projects", "tasks", "selection"], renderPicker);
new ResizeObserver(layout).observe(strip);
render();
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,6 +1,6 @@
import { get, put, subscribe } from "./api.js";
import { notifyInbox, notifyTurn } from "./notifications.js";
export const tabs = ["transcript", "changes", "files", "terminal", "inbox", "reviews", "project"];
export const tabs = ["transcript", "changes", "files", "terminal", "inbox", "reviews", "workflow", "project"];
export const state = {
me: undefined,
projects: [],
+1 -1
View File
File diff suppressed because one or more lines are too long
+182
View File
@@ -0,0 +1,182 @@
import { get } from "./api.js";
import { errorText, h, replace } from "./dom.js";
import { markdown } from "./markdown.js";
import { diagram } from "./mermaid.js";
import { findTask, focusTask, onEach, state } from "./state.js";
function graph(run) {
const id = new Map(run.stages.map((s, i) => [s.name, `s${i}`]));
const lines = ["flowchart TD"];
for (const s of run.stages) {
lines.push(` ${id.get(s.name)}["${s.name.replaceAll('"', "#quot;")}"]`);
}
for (const s of run.stages) {
for (const a of s.after) {
lines.push(` ${id.get(a)} --> ${id.get(s.name)}`);
}
}
return lines.join("\n");
}
function attemptLabel(a) {
switch (a.state) {
case "running":
return "running";
case "idle":
return "idle, not finished";
case "finished":
return "finished";
case "merged":
return "merged";
case "discarded":
return `discarded before finishing${a.discarded_by ? ` by ${a.discarded_by}` : ""}`;
case "cleaned_up":
return a.discarded_by === "byre" ? "finished, then discarded by the run's cleanup" : `finished, then discarded${a.discarded_by ? ` by ${a.discarded_by}` : ""}`;
}
}
function stageLabel(s) {
if (s.state !== "waiting") {
return attemptLabel(s.attempts.findLast((a) => a.output !== undefined) ?? s.attempts.at(-1));
}
if (s.waiting_on.length > 0) {
return `waiting on ${s.waiting_on.map((n) => `“${n}”`).join(", ")}`;
}
return "waiting to start";
}
function taskLink(taskId, label) {
const a = h("a", { href: `#/task/${taskId}`, class: "workflow-task" }, label);
a.onclick = (e) => {
e.preventDefault();
focusTask(taskId);
};
return a;
}
function attemptName(run, taskId) {
for (const s of run.stages) {
const i = s.attempts.findIndex((a) => a.task_id === taskId);
if (i >= 0) {
return s.attempts.length > 1 ? `“${s.name}” attempt ${i + 1}` : `“${s.name}”`;
}
}
return findTask(taskId)?.title ?? taskId;
}
function attemptRow(run, stage, a, index) {
const fed = run.stages.flatMap((s) => s.attempts.filter((d) => d.inputs.some((i) => i.task_id === a.task_id)).map((d) => d.task_id));
return h("li", { class: `workflow-attempt ${a.state}${a.task_id === state.selectedTaskId ? " current" : ""}` }, h("div", { class: "workflow-attempt-head" }, stage.attempts.length > 1 ? h("span", { class: "workflow-attempt-n" }, `#${index + 1}`) : null, taskLink(a.task_id, a.title), h("span", { class: `workflow-state ${a.state}` }, attemptLabel(a)), a.discarding ? h("span", { class: "workflow-state discarding" }, "discarding…") : null), a.forked_from ? h("div", { class: "workflow-line" }, "fork of ", taskLink(a.forked_from, attemptName(run, a.forked_from))) : null, a.inputs.length > 0
? h("div", { class: "workflow-line" }, "started from ", ...a.inputs.flatMap((i, n) => [n > 0 ? ", " : null, taskLink(i.task_id, attemptName(run, i.task_id))]))
: null, fed.length > 0 ? h("div", { class: "workflow-line fed" }, "fed ", ...fed.flatMap((id, n) => [n > 0 ? ", " : null, taskLink(id, attemptName(run, id))])) : null, a.output !== undefined
? h("details", { class: "workflow-output" }, h("summary", {}, "finish_stage output", a.commit ? h("code", {}, ` ${a.commit.slice(0, 10)}`) : null), markdown(a.output, "md", { taskId: a.task_id }))
: null);
}
function stageCard(run, s) {
return h("li", { class: `workflow-stage ${s.state}` }, h("header", {}, h("span", { class: "workflow-stage-name" }, s.name), h("span", { class: `workflow-state ${s.state}` }, stageLabel(s))), s.after.length > 0 ? h("div", { class: "workflow-line" }, `after ${s.after.map((n) => `“${n}”`).join(", ")}`) : h("div", { class: "workflow-line" }, "starts from the brief"), s.attempts.length > 0 ? h("ol", { class: "workflow-attempts" }, ...s.attempts.map((a, i) => attemptRow(run, s, a, i))) : null);
}
export function mountWorkflow(root) {
const head = h("header", { class: "workflow-header" });
const graphBox = h("div", { class: "workflow-graph" });
const list = h("div", { class: "workflow-body" });
let run;
let drawnRun = "";
let error = "";
let target = 0;
let loading = false;
let again = false;
let disposed = false;
const load = async () => {
const taskId = state.selectedTaskId;
if (!taskId || !findTask(taskId)?.workflow_run) {
render();
return;
}
if (loading) {
again = true;
return;
}
loading = true;
const started = ++target;
try {
const fresh = await get("/api/tasks/{taskId}/workflow/run", { path: { taskId } });
if (started === target) {
run = fresh;
error = "";
}
}
catch (err) {
if (started === target) {
error = errorText(err);
}
}
loading = false;
if (disposed) {
return;
}
if (started === target) {
render();
}
if (again) {
again = false;
void load();
}
};
const render = () => {
const task = state.selectedTaskId ? findTask(state.selectedTaskId) : undefined;
if (!task?.workflow_run) {
replace(head);
replace(graphBox);
drawnRun = "";
replace(list, h("div", { class: "empty" }, task ? "This task isn't part of a workflow run." : "No task selected."));
return;
}
if (error) {
replace(list, h("div", { class: "empty error-text" }, error));
return;
}
if (!run || run.run !== task.workflow_run) {
replace(list, h("div", { class: "empty" }, "Loading…"));
return;
}
replace(head, h("span", { class: "workflow-name" }, run.name), h("span", { class: "workflow-title" }, run.title), h("span", { class: `workflow-state ${run.finished ? "finished" : "running"}` }, run.finished ? "workflow finished" : "in progress"));
if (drawnRun !== run.run) {
drawnRun = run.run;
replace(graphBox, diagram(graph(run)));
}
replace(list, run.brief.trim() ? h("details", { class: "workflow-brief" }, h("summary", {}, "Brief"), markdown(run.brief, "md", { taskId: task.id })) : null, h("ol", { class: "workflow-stages" }, ...run.stages.map((s) => stageCard(run, s))));
};
const relevant = (ev) => {
if (ev.seq === 0 || !run) {
return false;
}
if (ev.kind === "task.created") {
return ev.payload["workflow_run"] === run.run;
}
const ids = new Set(run.stages.flatMap((s) => s.attempts.map((a) => a.task_id)));
return ev.task_id !== undefined && ids.has(ev.task_id) && (ev.kind.startsWith("task.") || ev.kind.startsWith("session."));
};
const offs = [
onEach(["selection"], () => {
target++;
run = undefined;
error = "";
render();
void load();
}),
onEach(["tasks"], () => {
if (!run && !loading && !error) {
void load();
}
}),
onEach(["event"], (ev) => {
if (relevant(ev)) {
void load();
}
}),
];
replace(root, h("div", { class: "workflow-view" }, head, h("div", { class: "workflow-scroll" }, graphBox, list)));
render();
void load();
return () => {
disposed = true;
for (const off of offs) {
off();
}
};
}
//# sourceMappingURL=workflow.js.map
+1
View File
File diff suppressed because one or more lines are too long