2026-09-29 20:08:17 +02:00
""" UI for project runs: start one from a project, follow it member by member, in manual or auto-confirm mode. """
from __future__ import annotations
import html
from . import projectrun as PR , run as R
from . run import fmt_duration
MODE_HELP = {
" manual " : " You start each member yourself, one at a time, in the project ' s order. Every run inside it is manual. " ,
" auto-confirm " : ( " The engine starts the next ready member, confirms what it can, closes each member run when it finishes cleanly, and moves on. "
" It stops wherever a person is needed. It never runs commands, and v0.1 members are always run by you. " ) ,
}
def e ( s ) - > str :
return html . escape ( str ( s ) )
def pill ( text : str ) - > str :
return f " <span class=pill data-s= ' { e ( text ) } ' > { e ( text ) } </span> "
def stamp ( iso : str | None ) - > str :
return iso [ : 16 ] . replace ( " T " , " " ) + " UTC " if iso else " - "
# ---- on the project page ---------------------------------------------------------------------------------------------------
def project_section ( app , reg , doc , needs : list [ dict ] ) - > str :
pid = doc . id
active = PR . active_run ( reg , pid )
out = [ " <h2>Run the whole project</h2> " ]
if active :
out . append ( f " <p>A project run is in progress ( { pill ( active . data [ ' mode ' ] ) } ): "
f " <a href= ' /project-run/ { e ( active . id ) } ' >continue it</a>. Only one runs at a time.</p> " )
elif needs :
out . append ( f " <p class=note>Give the project the attributes it still needs first: { e ( ' , ' . join ( sorted ( { n [ ' attribute ' ] for n in needs } ) ) ) } .</p> " )
else :
opts = " " . join ( f " <label><input type=radio name=mode value= ' { m } ' { ' checked ' if m == ' manual ' else ' ' } > <b> { m } </b>: { e ( t ) } </label><br> "
for m , t in MODE_HELP . items ( ) )
out . append ( f " <form method=post action= ' /project/ { e ( pid ) } /runall ' > { app . hidden ( ) } <fieldset><legend>Mode</legend> { opts } </fieldset> "
f " <p class=note>The project ' s attributes are snapshotted when it starts, so every member run uses the same values even if "
f " you edit the project meanwhile.</p> { app . who ( ) } <button>Start project run</button></form> " )
past = PR . list_runs ( reg , pid )
if past :
rows = " "
for p in past :
v = PR . view ( reg , p )
t = PR . times ( p )
took = f " took { fmt_duration ( t [ ' elapsed ' ] ) } " if t [ " finished " ] else f " running { fmt_duration ( t [ ' elapsed ' ] ) } "
rows + = ( f " <tr><td><a href= ' /project-run/ { e ( p . id ) } ' > { e ( p . id ) } </a></td><td> { pill ( p . data [ ' mode ' ] ) } </td> "
f " <td> { pill ( p . data [ ' outcome ' ] or ' in progress ' ) } </td><td> { v [ ' done ' ] } of { v [ ' total ' ] } members</td> "
f " <td class=note> { e ( stamp ( t [ ' started ' ] ) ) } · { e ( took ) } · { e ( p . data [ ' operator ' ] ) } </td></tr> " )
out . append ( " <h3>Project runs</h3><table><tr><th>Run</th><th>Mode</th><th>Outcome</th><th>Progress</th><th>When</th></tr> " + rows + " </table> " )
return " " . join ( out )
# ---- the project run page ------------------------------------------------------------------------------------------------------
def page_for ( app , prid : str , msg : str = " " ) - > bytes | None :
from . ui import page
reg = app . registry ( )
try :
pr = PR . load ( reg , prid )
except R . RunError :
return None
doc = reg . projects . get ( pr . data [ " project " ] )
d = pr . data
closed = pr . closed
v = PR . view ( reg , pr )
t = PR . times ( pr )
manual = d [ " mode " ] == " manual "
took = ( f " finished { e ( stamp ( t [ ' finished ' ] ) ) } · took <b> { e ( fmt_duration ( t [ ' elapsed ' ] ) ) } </b> " if t [ " finished " ]
else f " running for <b> { e ( fmt_duration ( t [ ' elapsed ' ] ) ) } </b> " )
out = [ f " <h1>Project run: { e ( d [ ' title ' ] ) } </h1> " ,
f " <p> { pill ( d [ ' mode ' ] ) } { pill ( d [ ' outcome ' ] ) if d [ ' outcome ' ] else pill ( ' in progress ' ) } { pill ( d [ ' environment ' ] ) } "
f " <span class=note> { e ( d [ ' id ' ] ) } · operator { e ( d [ ' operator ' ] ) } · <a href= ' /project/ { e ( d [ ' project ' ] ) } ' >project { e ( d [ ' project ' ] ) } </a></span></p> " ,
f " <p class=note>Started { e ( stamp ( t [ ' started ' ] ) ) } · { took } </p> " ,
f " <p class=eyebrow> { v [ ' done ' ] } of { v [ ' total ' ] } members done</p><progress max= { v [ ' total ' ] } value= { v [ ' done ' ] } style= ' width:100% ' ></progress> " ]
if msg :
out . append ( f " <p class=bad> { e ( msg ) } </p> " )
if doc is not None and not closed :
changed = PR . attributes_changed ( reg , doc , pr )
if changed :
out . append ( " <p class=note>The project ' s attributes have changed since this project run started. It keeps the values it started with "
f " ( { e ( ' , ' . join ( changed ) ) } ); a new project run picks up the new ones.</p> " )
if d [ " deviations " ] :
out . append ( " <p class=note>⚑ Deviations: " + " ; " . join ( f " { e ( x [ ' kind ' ] ) } { e ( x [ ' item ' ] ) } ( { e ( x [ ' reason ' ] ) } ) " for x in d [ " deviations " ] ) + " </p> " )
if not closed :
out . append ( _controls ( app , pr , v ) )
out . append ( _members ( app , reg , pr , v , manual and not closed ) )
out . append ( " <details><summary><b>Log</b></summary><ul class=feed> " + " " . join (
f " <li><span class=note> { e ( x [ ' at ' ] [ : 19 ] . replace ( ' T ' , ' ' ) ) } </span> · <b> { e ( x [ ' who ' ] ) } </b> { e ( x [ ' text ' ] ) } </li> " for x in reversed ( d [ " events " ] ) ) + " </ul></details> " )
out . append ( _close ( app , reg , pr , v ) )
return page ( f " Project run { d [ ' id ' ] } " , " " . join ( out ) )
def _controls ( app , pr , v : dict ) - > str :
d = pr . data
base = f " /project-run/ { e ( pr . id ) } "
if d [ " mode " ] == " manual " :
if v [ " open " ] :
row = next ( r for r in v [ " members " ] if r [ " key " ] == v [ " open " ] )
return ( f " <div class=banner><b>Continue:</b> { e ( row [ ' key ' ] ) } <span class=note>(its run is open)</span></div> "
f " <div class=panel><b>Manual mode.</b> <span class=note>You do the member run; when it is closed the next member becomes ready.</span> "
f " <form method=post action= ' { base } /mode ' class=row> { app . hidden ( ) } <input type=hidden name=to value=auto-confirm> "
" <button>Hand over to auto-confirm</button></form></div> " )
if v [ " ready " ] :
return ( f " <div class=banner><b>Next:</b> { e ( v [ ' ready ' ] [ 0 ] ) } <span class=note>(ready)</span></div> "
f " <div class=panel><b>Manual mode.</b> <span class=note>Start the next member below.</span> "
f " <form method=post action= ' { base } /mode ' class=row> { app . hidden ( ) } <input type=hidden name=to value=auto-confirm> "
" <button>Hand over to auto-confirm</button></form></div> " )
if v [ " done " ] == v [ " total " ] :
return " <div class=banner><b>Every member is done.</b> Close the project run below.</div> "
return " <div class=banner><b>Nothing can start.</b> A member it depends on did not succeed. Close the project run, or start a member with an override.</div> "
last = d . get ( " last " )
status = f " <p class=note>Last: { e ( last [ ' action ' ] ) } { e ( last . get ( ' member ' ) or ' ' ) } { e ( last . get ( ' detail ' ) or ' ' ) } </p> " if last else " "
return ( f " <div class=panel data-s= ' auto ' ><b>Auto-confirm mode.</b> <span class=note> { e ( MODE_HELP [ ' auto-confirm ' ] ) } </span> { status } "
f " <form method=post action= ' { base } /tick ' class=row> { app . hidden ( ) } "
" <button name=n value=1>Check once</button> <button name=n value=300>Run until it waits</button></form> "
f " <form method=post action= ' { base } /mode ' class=row> { app . hidden ( ) } <input type=hidden name=to value=manual><button>Take over (manual)</button></form></div> " )
def _members ( app , reg , pr , v : dict , manual : bool ) - > str :
base = f " /project-run/ { e ( pr . id ) } "
rows = " "
for n , r in enumerate ( v [ " members " ] , 1 ) :
pack = reg . packs . get ( r [ " pack " ] )
i = app . pack_index ( r [ " pack " ] )
title = e ( pack . data [ " title " ] ) if pack else e ( r [ " pack " ] )
link = f " <a href= ' /pack/ { i } ' > { title } </a> " if i is not None else title
prog = f " { r [ ' progress ' ] [ ' done ' ] } / { r [ ' progress ' ] [ ' total ' ] } " if r . get ( " progress " ) else " - "
run = f " · <a href= ' /run/ { i } / { e ( r [ ' run_id ' ] ) } ' >open run</a> " if r . get ( " run_id " ) and i is not None else " "
extra = " "
if r [ " status " ] == " waiting " :
extra = f " <br><span class=note>waiting for { e ( ' , ' . join ( r [ ' blocked_by ' ] ) ) } </span> "
if r . get ( " detail " ) :
extra + = f " <br><span class=note> { e ( r [ ' detail ' ] ) } </span> "
if r [ " deviations " ] :
extra + = f " <br><span class=note>⚑ { r [ ' deviations ' ] } deviation(s)</span> "
action = " "
if manual and not r . get ( " run_id " ) and not v [ " open " ] :
key = e ( r [ " key " ] )
if r [ " status " ] == " ready " :
action = f " <form method=post action= ' { base } /start ' > { app . hidden ( ) } <input type=hidden name=key value= ' { key } ' ><button>Start</button></form> "
elif r [ " status " ] == " waiting " :
action = ( f " <details class=override><summary>Start anyway</summary><form method=post action= ' { base } /start ' > { app . hidden ( ) } "
f " <input type=hidden name=key value= ' { key } ' ><p class=bad>It waits for { e ( ' , ' . join ( r [ ' blocked_by ' ] ) ) } ; starting it now is recorded as a deviation.</p> "
" <p><input name=override_reason placeholder= ' why (required) ' required></p> "
" <p><label><input type=checkbox name=acknowledge_risk value=1 required> I acknowledge the risk.</label></p> "
" <button>Override and start</button></form></details> " )
rows + = ( f " <tr><td> { n } </td><td> { link } <br><span class=note> { e ( r [ ' key ' ] ) } </span> { extra } </td><td> { pill ( r [ ' status ' ] ) } </td> "
f " <td> { prog } { run } </td><td> { action } </td></tr> " )
return ( " <h2>Members</h2><table><tr><th>#</th><th>Runbook</th><th>Status</th><th>Progress</th><th></th></tr> " + rows + " </table> " )
def _close ( app , reg , pr , v : dict ) - > str :
d = pr . data
if pr . closed :
state = PR . seal_state ( pr )
note = f " <p>Comment: { e ( d [ ' outcome_note ' ] ) } </p> " if d . get ( " outcome_note " ) else " "
broken = ( " <p class=bad>⚠ This project run ' s record was changed outside the app after it was sealed.</p> " if state == " broken " else
" <p class=note>This project run is <b>closed and locked</b>: its record is sealed and cannot be changed. Start a new project run to try again.</p> " )
2026-09-29 21:35:03 +02:00
return ( f " <div id=close class=step><h2>Project run finished: { e ( d [ ' outcome ' ] ) } </h2> { note } { broken } "
f " <p><a href= ' /project-run/ { e ( pr . id ) } /record.json ' >Download the project run record (JSON)</a> "
" <span class=note>Check it anywhere with <code>rtut seal <file></code>.</span></p></div> " )
2026-09-29 20:08:17 +02:00
btns = [ ]
if all ( r [ " status " ] == " succeeded " for r in v [ " members " ] ) :
btns . append ( " <button name=outcome value=succeeded>Finish: succeeded</button> " )
if v [ " problems " ] or v [ " open " ] :
btns . append ( " <button name=outcome value=failed onclick= \" return confirm( ' Finish this project run as failed? An open member run is abandoned. ' ) \" >Finish: failed</button> " )
btns . append ( " <button name=outcome value=abandoned onclick= \" return confirm( ' Abandon this project run? An open member run is abandoned. ' ) \" >Abandon project run</button> " )
return ( f " <div id=close class=step><h2>Close this project run</h2><p class=note>Closing records the outcome, abandons an open member run, and locks the record.</p> "
f " <form method=post action= ' /project-run/ { e ( pr . id ) } /finalize ' > { app . hidden ( ) } "
f " <p><input name=note class=grow style= ' width:100% ' placeholder= ' comment (optional) ' ></p><p class=row> { ' ' . join ( btns ) } </p></form></div> " )
# ---- actions ---------------------------------------------------------------------------------------------------------------------------
def handle_post ( app , parts : list [ str ] , form : dict ) - > str :
""" Returns the URL to go to; raises RunError/ValueError/KeyError with the reason if the action is refused. """
reg = app . registry ( )
if len ( parts ) == 3 and parts [ 0 ] == " project " and parts [ 2 ] == " runall " :
doc = reg . projects [ parts [ 1 ] ]
pr = PR . create ( reg , doc , form . get ( " operator " , " " ) , form . get ( " mode " , " manual " ) )
return f " /project-run/ { pr . id } "
if len ( parts ) == 3 and parts [ 0 ] == " project-run " :
pr = PR . load ( reg , parts [ 1 ] )
doc = reg . projects [ pr . data [ " project " ] ]
action = parts [ 2 ]
if action == " start " :
run = PR . start_member ( reg , doc , pr , form . get ( " key " , " " ) , form . get ( " override_reason " , " " ) , bool ( form . get ( " acknowledge_risk " ) ) )
i = app . pack_index ( run . pack . id )
return f " /run/ { i } / { run . data [ ' run_id ' ] } #current "
if action == " tick " :
n = max ( 1 , min ( int ( form . get ( " n " , " 1 " ) or 1 ) , 300 ) )
if n == 1 :
pr . data [ " last " ] = PR . tick ( reg , doc , pr )
pr . save ( )
else :
PR . run_until_waits ( reg , doc , pr , n )
return f " /project-run/ { pr . id } "
if action == " mode " :
PR . switch_mode ( reg , pr , form . get ( " to " , " " ) )
return f " /project-run/ { pr . id } "
if action == " finalize " :
PR . finalize ( reg , pr , form . get ( " outcome " , " " ) , form . get ( " note " , " " ) )
return f " /project-run/ { pr . id } #close "
raise ValueError ( " unknown action " )