Add Workflow tab and run-progress API for workflow runs
This commit is contained in:
+93
-1
@@ -268,6 +268,25 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/tasks/{taskId}/workflow/run": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
taskId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
/** @description The progress of the workflow run the task belongs to: the definition the task froze, and for each stage its state and every task that attempted it, with the attempts whose outputs each one started from. */
|
||||
get: operations["getWorkflowRun"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/tasks/{taskId}/unmerged": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1011,6 +1030,56 @@ export interface components {
|
||||
/** @description The WorkflowDefinition as YAML. */
|
||||
definition: string;
|
||||
};
|
||||
WorkflowRun: {
|
||||
name: string;
|
||||
run: string;
|
||||
title: string;
|
||||
brief: string;
|
||||
definition: components["schemas"]["WorkflowDefinition"];
|
||||
/** @description Whether the final stage finished, which ends the run. */
|
||||
finished: boolean;
|
||||
/** @description In the definition's order. */
|
||||
stages: components["schemas"]["WorkflowRunStage"][];
|
||||
};
|
||||
WorkflowRunStage: {
|
||||
name: string;
|
||||
/** @description The stages it comes after, with the definition's defaults applied. */
|
||||
after: string[];
|
||||
/**
|
||||
* @description waiting: no task yet. Otherwise the state of its latest finished attempt, or of its latest attempt when none has finished.
|
||||
* @enum {string}
|
||||
*/
|
||||
state: "waiting" | "running" | "idle" | "finished" | "merged" | "discarded" | "cleaned_up";
|
||||
/** @description The after stages with no finished attempt yet. */
|
||||
waiting_on: string[];
|
||||
/** @description The tasks that ran the stage (the first, and forks of it), oldest first. */
|
||||
attempts: components["schemas"]["StageAttempt"][];
|
||||
};
|
||||
StageAttempt: {
|
||||
task_id: string;
|
||||
title: string;
|
||||
/**
|
||||
* @description running: its agent is mid-turn. idle: active, and finish_stage hasn't taken. finished: active, its output recorded. merged. discarded: discarded before finishing. cleaned_up: discarded after finishing, as byre does to the run's other tasks when the final stage finishes.
|
||||
* @enum {string}
|
||||
*/
|
||||
state: "running" | "idle" | "finished" | "merged" | "discarded" | "cleaned_up";
|
||||
/** @description Whether a Discard was started and the task isn't closed yet. */
|
||||
discarding: boolean;
|
||||
/** @description The task this attempt was forked from; absent for the stage's first attempt. */
|
||||
forked_from?: string;
|
||||
/** @description The finish_stage output the stages after it get; absent until it finishes. */
|
||||
output?: string;
|
||||
/** @description With output: the commit the stage finished at. */
|
||||
commit?: string;
|
||||
/** @description discarded and cleaned_up: who discarded it; byre for the cleanup. */
|
||||
discarded_by?: string;
|
||||
/** @description The attempts whose outputs it started from, one per after stage; a fork has its source's. */
|
||||
inputs: components["schemas"]["StageInput"][];
|
||||
};
|
||||
StageInput: {
|
||||
stage: string;
|
||||
task_id: string;
|
||||
};
|
||||
TaskChanges: {
|
||||
/** @description The commit or tree compared with: the merge-base of the task branch and the default branch, since, or the previous snapshot's tree. */
|
||||
base: string;
|
||||
@@ -1288,7 +1357,7 @@ export interface components {
|
||||
drafting: boolean;
|
||||
};
|
||||
/** @enum {string} */
|
||||
TaskTab: "transcript" | "changes" | "files" | "terminal" | "inbox" | "reviews" | "project";
|
||||
TaskTab: "transcript" | "changes" | "files" | "terminal" | "inbox" | "reviews" | "workflow" | "project";
|
||||
TerminalSize: {
|
||||
cols: number;
|
||||
rows: number;
|
||||
@@ -1830,6 +1899,29 @@ export interface operations {
|
||||
404: components["responses"]["Error"];
|
||||
};
|
||||
};
|
||||
getWorkflowRun: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
taskId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description The run's progress. */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["WorkflowRun"];
|
||||
};
|
||||
};
|
||||
404: components["responses"]["Error"];
|
||||
};
|
||||
};
|
||||
getUnmerged: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
+5
-1
@@ -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: Record<TaskTab, { label: string; mount: (root: HTMLElement) => () => void }> = {
|
||||
transcript: { label: "Transcript", mount: mountTranscript },
|
||||
@@ -21,6 +22,7 @@ const views: Record<TaskTab, { label: string; mount: (root: HTMLElement) => () =
|
||||
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 },
|
||||
};
|
||||
|
||||
@@ -189,6 +191,8 @@ export function taskPane(): HTMLElement {
|
||||
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;
|
||||
}
|
||||
@@ -235,7 +239,7 @@ export function taskPane(): HTMLElement {
|
||||
};
|
||||
|
||||
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
@@ -21,7 +21,7 @@ export type Topic =
|
||||
| "ready"
|
||||
| "lead.ready";
|
||||
|
||||
export const tabs: TaskTab[] = ["transcript", "changes", "files", "terminal", "inbox", "reviews", "project"];
|
||||
export const tabs: TaskTab[] = ["transcript", "changes", "files", "terminal", "inbox", "reviews", "workflow", "project"];
|
||||
|
||||
export const state = {
|
||||
me: undefined as User | undefined,
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
import { get, type ByreEvent, type Schemas } 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";
|
||||
|
||||
type WorkflowRun = Schemas["WorkflowRun"];
|
||||
type RunStage = Schemas["WorkflowRunStage"];
|
||||
type Attempt = Schemas["StageAttempt"];
|
||||
|
||||
function graph(run: WorkflowRun): string {
|
||||
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: Attempt): string {
|
||||
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: RunStage): string {
|
||||
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: string, label: string): HTMLElement {
|
||||
const a = h("a", { href: `#/task/${taskId}`, class: "workflow-task" }, label);
|
||||
a.onclick = (e) => {
|
||||
e.preventDefault();
|
||||
focusTask(taskId);
|
||||
};
|
||||
return a;
|
||||
}
|
||||
|
||||
function attemptName(run: WorkflowRun, taskId: string): string {
|
||||
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: WorkflowRun, stage: RunStage, a: Attempt, index: number): HTMLElement {
|
||||
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: WorkflowRun, s: RunStage): HTMLElement {
|
||||
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: HTMLElement): () => void {
|
||||
const head = h("header", { class: "workflow-header" });
|
||||
const graphBox = h("div", { class: "workflow-graph" });
|
||||
const list = h("div", { class: "workflow-body" });
|
||||
let run: WorkflowRun | undefined;
|
||||
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: ByreEvent): boolean => {
|
||||
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 as ByreEvent)) {
|
||||
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();
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user