@@ -47,52 +47,52 @@ export {
4747 * cells win over the exec metadata, so deleting an output value re-arms the
4848 * row for the cascade and for manual incomplete-mode runs.
4949 */
50- export function isGroupEligible (
50+ /**
51+ * Reason codes the eligibility predicate emits. Stable strings so the caller
52+ * can aggregate skip reasons into one summary log per scheduler call instead
53+ * of allocating a per-cell debug line.
54+ */
55+ export type EligibilityReason =
56+ | 'eligible'
57+ | 'autoRun-off'
58+ | 'in-flight'
59+ | 'completed-on-auto'
60+ | 'error-on-auto'
61+ | 'completed-on-incomplete'
62+ | 'manual-bypass'
63+ | 'deps-unmet'
64+
65+ export function classifyEligibility (
5166 group : WorkflowGroup ,
5267 row : TableRow ,
5368 opts ?: { isManualRun ?: boolean ; mode ?: 'all' | 'incomplete' }
54- ) : boolean {
69+ ) : EligibilityReason {
5570 const isManualRun = opts ?. isManualRun ?? false
5671 const mode = opts ?. mode ?? 'all'
57- const tag = `[Eligibility] row=${ row . id } group=${ group . id } manual=${ isManualRun } mode=${ mode } `
5872
59- if ( group . autoRun === false && ! isManualRun ) {
60- logger . debug ( `${ tag } → skip: autoRun=false on auto-fire` )
61- return false
62- }
73+ if ( group . autoRun === false && ! isManualRun ) return 'autoRun-off'
6374
6475 const exec = row . executions ?. [ group . id ]
65- if ( isExecInFlight ( exec ) ) {
66- logger . debug ( `${ tag } → skip: in-flight (status=${ exec ?. status } )` )
67- return false
68- }
76+ if ( isExecInFlight ( exec ) ) return 'in-flight'
6977 const status = exec ?. status
7078
7179 const completedAndFilled = status === 'completed' && areOutputsFilled ( group , row )
72- if ( ! isManualRun && completedAndFilled ) {
73- logger . debug ( `${ tag } → skip: completed+filled on auto-fire` )
74- return false
75- }
80+ if ( ! isManualRun && completedAndFilled ) return 'completed-on-auto'
7681 // Auto-fire skips `error` to avoid infinite-retry loops on a deterministic
77- // failure. `cancelled` doesn't get the same treatment — cancellation is
78- // user-initiated, and once an upstream dep re-fires the cascade, the user
79- // almost certainly wants the chain to continue.
80- if ( ! isManualRun && status === 'error' ) {
81- logger . debug ( `${ tag } → skip: terminal status=error on auto-fire` )
82- return false
83- }
84- if ( mode === 'incomplete' && completedAndFilled ) {
85- logger . debug ( `${ tag } → skip: completed+filled on mode=incomplete` )
86- return false
87- }
82+ // failure. `cancelled` is left runnable — cancellation is user-initiated.
83+ if ( ! isManualRun && status === 'error' ) return 'error-on-auto'
84+ if ( mode === 'incomplete' && completedAndFilled ) return 'completed-on-incomplete'
8885
89- if ( isManualRun && group . autoRun === false ) {
90- logger . debug ( `${ tag } → eligible: manual on autoRun=false group (deps bypassed)` )
91- return true
92- }
93- const depsOk = areGroupDepsSatisfied ( group , row )
94- logger . debug ( `${ tag } → ${ depsOk ? 'eligible: deps satisfied' : 'skip: deps unmet' } ` )
95- return depsOk
86+ if ( isManualRun && group . autoRun === false ) return 'manual-bypass'
87+ return areGroupDepsSatisfied ( group , row ) ? 'eligible' : 'deps-unmet'
88+ }
89+
90+ export function isGroupEligible (
91+ group : WorkflowGroup ,
92+ row : TableRow ,
93+ opts ?: { isManualRun ?: boolean ; mode ?: 'all' | 'incomplete' }
94+ ) : boolean {
95+ return classifyEligibility ( group , row , opts ) === 'eligible'
9696}
9797
9898/**
@@ -130,18 +130,19 @@ export async function scheduleRunsForRows(
130130 const groups = groupIdFilter ? allGroups . filter ( ( g ) => groupIdFilter . has ( g . id ) ) : allGroups
131131 if ( groups . length === 0 ) return { triggered : 0 }
132132
133- logger . debug (
134- `[Cascade] scheduleRunsForRows table=${ table . id } rows=${ rows . length } groups=${ groups . length } manual=${ opts ?. isManualRun ?? false } mode=${ opts ?. mode ?? 'all' } scoped=${ groupIdFilter ? 'yes' : 'no' } `
135- )
136-
137133 const orderedRows = rows . length <= 1 ? rows : [ ...rows ] . sort ( ( a , b ) => a . position - b . position )
138134
139135 const pendingRuns : RunGroupCellOptions [ ] = [ ]
136+ const reasonCounts : Partial < Record < EligibilityReason , number > > = { }
140137
141138 for ( const row of orderedRows ) {
142139 for ( const group of groups ) {
143- if ( ! isGroupEligible ( group , row , { isManualRun : opts ?. isManualRun , mode : opts ?. mode } ) )
144- continue
140+ const reason = classifyEligibility ( group , row , {
141+ isManualRun : opts ?. isManualRun ,
142+ mode : opts ?. mode ,
143+ } )
144+ reasonCounts [ reason ] = ( reasonCounts [ reason ] ?? 0 ) + 1
145+ if ( reason !== 'eligible' ) continue
145146 pendingRuns . push ( {
146147 tableId : table . id ,
147148 tableName : table . name ,
@@ -154,6 +155,10 @@ export async function scheduleRunsForRows(
154155 }
155156 }
156157
158+ logger . debug (
159+ `[Cascade] table=${ table . id } rows=${ rows . length } groups=${ groups . length } manual=${ opts ?. isManualRun ?? false } mode=${ opts ?. mode ?? 'all' } reasons=${ JSON . stringify ( reasonCounts ) } `
160+ )
161+
157162 if ( pendingRuns . length === 0 ) return { triggered : 0 }
158163
159164 logger . info ( `Scheduling ${ pendingRuns . length } workflow group cell run(s) for table=${ table . id } ` )
@@ -418,14 +423,13 @@ export async function cancelWorkflowGroupRuns(tableId: string, rowId?: string):
418423}
419424
420425/**
421- * Generalized run helper. Three public wrappers below pin specific
422- * argument shapes; this is the shared inner primitive.
423- *
424- * `groupIds` omitted = every workflow group on the table.
425- * `rowIds` omitted = every row (with `mode === 'incomplete'` filter applied
426- * per group when targeting specific groups).
426+ * Run a set of groups across the table or a row subset. Single canonical
427+ * user-driven run op — every UI gesture (single cell, per-row Play, action-bar
428+ * Play/Refresh, column-header menu) reduces to this. `mode: 'all'` re-runs
429+ * completed cells; `mode: 'incomplete'` skips them. `groupIds` omitted = every
430+ * workflow group on the table. `rowIds` omitted = every row.
427431 */
428- async function runWorkflowGroupsInternal ( opts : {
432+ export async function runWorkflowColumn ( opts : {
429433 tableId : string
430434 workspaceId : string
431435 mode : 'all' | 'incomplete'
@@ -444,7 +448,7 @@ async function runWorkflowGroupsInternal(opts: {
444448 if ( targetGroups . length === 0 ) return { triggered : 0 }
445449
446450 logger . info (
447- `[Cascade] [${ requestId } ] runWorkflowColumn table=${ tableId } groups=[${ targetGroups . map ( ( g ) => g . id ) . join ( ',' ) } ] rows=${ rowIds ? `[${ rowIds . join ( ',' ) } ]` : 'all' } mode=${ mode } `
451+ `[Cascade] [${ requestId } ] manual run table=${ tableId } groups=[${ targetGroups . map ( ( g ) => g . id ) . join ( ',' ) } ] rows=${ rowIds ? `[${ rowIds . join ( ',' ) } ]` : 'all' } mode=${ mode } `
448452 )
449453
450454 const filters = [ eq ( userTableRows . tableId , tableId ) , eq ( userTableRows . workspaceId , workspaceId ) ]
@@ -520,32 +524,28 @@ async function runWorkflowGroupsInternal(opts: {
520524 } )
521525}
522526
527+ // ───────────────────────────── Validation ─────────────────────────────
528+
523529/**
524- * Run a set of groups across the table or a row subset. Single canonical
525- * user-driven run op — every UI gesture (single cell, per-row Play, action-bar
526- * Play/Refresh, column-header menu) reduces to this. `mode: 'all'` re-runs
527- * completed cells; `mode: 'incomplete'` skips them.
530+ /**
531+ * Removes the given column names from a group's `dependencies.columns`. When
532+ * the resulting list is empty, drops the `dependencies` field entirely so
533+ * schema validation doesn't see an empty-deps object. Returns the same group
534+ * reference when nothing changed.
528535 */
529- export async function runWorkflowColumn ( opts : {
530- tableId : string
531- workspaceId : string
532- groupIds : string [ ]
533- mode : 'all' | 'incomplete'
534- rowIds ?: string [ ]
535- requestId : string
536- } ) : Promise < { triggered : number } > {
537- return runWorkflowGroupsInternal ( {
538- tableId : opts . tableId ,
539- workspaceId : opts . workspaceId ,
540- groupIds : opts . groupIds ,
541- rowIds : opts . rowIds ,
542- mode : opts . mode ,
543- requestId : opts . requestId ,
544- } )
536+ export function stripGroupDeps ( group : WorkflowGroup , removed : ReadonlySet < string > ) : WorkflowGroup {
537+ const cols = group . dependencies ?. columns
538+ if ( ! cols || cols . length === 0 ) return group
539+ const filtered = cols . filter ( ( d ) => ! removed . has ( d ) )
540+ if ( filtered . length === cols . length ) return group
541+ return {
542+ ...group ,
543+ ...( filtered . length > 0
544+ ? { dependencies : { columns : filtered } }
545+ : { dependencies : undefined } ) ,
546+ }
545547}
546548
547- // ───────────────────────────── Validation ─────────────────────────────
548-
549549/**
550550 * Validates schema-level invariants. Run on every `addTableColumn`,
551551 * `addWorkflowGroup`, `updateWorkflowGroup`, `renameColumn`, `reorderColumns`,
0 commit comments