2026-10-16 11:01:53 -07:00
import { get , type ByreEvent , type Schemas } from "./api.js" ;
import { errorText , h , replace } from "./dom.js" ;
import { markdown } from "./markdown.js" ;
2026-10-16 11:25:53 -07:00
import { diagram , redraw } from "./mermaid.js" ;
2026-10-16 11:01:53 -07:00
import { findTask , focusTask , onEach , state } from "./state.js" ;
type WorkflowRun = Schemas [ "WorkflowRun" ];
type RunStage = Schemas [ "WorkflowRunStage" ];
type Attempt = Schemas [ "StageAttempt" ];
2026-10-16 11:25:53 -07:00
const stateColors : Record < RunStage [ "state" ] , [ fill : string , stroke : string , text : string ] > = {
waiting : [ "--surface" , "--border" , "--muted" ],
running : [ "--accent-tint" , "--accent" , "--text" ],
idle : [ "--warn-tint" , "--warn" , "--text" ],
finished : [ "--ok-tint" , "--ok" , "--text" ],
merged : [ "--ok-tint" , "--ok" , "--text" ],
cleaned_up : [ "--ok-tint" , "--ok" , "--text" ],
discarded : [ "--danger-tint" , "--danger" , "--text" ],
};
2026-10-16 11:01:53 -07:00
function graph ( run : WorkflowRun ) : string {
2026-10-16 11:25:53 -07:00
const css = getComputedStyle ( document . documentElement );
const v = ( name : string ) => css . getPropertyValue ( name ). trim ();
2026-10-16 11:01:53 -07:00
const id = new Map ( run . stages . map (( s , i ) => [ s . name , `s ${ i } ` ]));
const lines = [ "flowchart TD" ];
2026-10-16 11:25:53 -07:00
for ( const [ state , [ fill , stroke , text ]] of Object . entries ( stateColors )) {
lines . push ( ` classDef ${ state } fill: ${ v ( fill ) } ,stroke: ${ v ( stroke ) } ,color: ${ v ( text ) } ` );
}
2026-10-16 11:01:53 -07:00
for ( const s of run . stages ) {
2026-10-16 11:25:53 -07:00
lines . push ( ` ${ id . get ( s . name ) } [" ${ s . name . replaceAll ( '"' , "#quot;" ) } "]::: ${ s . state } ` );
2026-10-16 11:01:53 -07:00
}
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 = "" ;
2026-10-16 11:25:53 -07:00
let drawnSrc = "" ;
let drawn : HTMLElement | undefined ;
2026-10-16 11:01:53 -07:00
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 = "" ;
2026-10-16 11:25:53 -07:00
drawn = undefined ;
2026-10-16 11:01:53 -07:00
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" ),
);
2026-10-16 11:25:53 -07:00
const src = graph ( run );
if ( drawnRun !== run . run || ! drawn ) {
2026-10-16 11:01:53 -07:00
drawnRun = run . run ;
2026-10-16 11:25:53 -07:00
drawnSrc = src ;
drawn = diagram ( src );
replace ( graphBox , drawn );
} else if ( drawnSrc !== src ) {
drawnSrc = src ;
void redraw ( drawn , src );
2026-10-16 11:01:53 -07:00
}
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 ();
}
};
}