@gobing-ai/spur 0.4.26 → 0.4.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/spur.js CHANGED
@@ -45392,6 +45392,32 @@ var init_forensic_query = __esm(() => {
45392
45392
  END`;
45393
45393
  });
45394
45394
 
45395
+ // ../../packages/domain/src/analytics/selector-time-range.ts
45396
+ function buildTimeRangeClauses(range, target) {
45397
+ if (!COLUMN.test(target.column))
45398
+ throw new Error(`invalid time-range column: ${target.column}`);
45399
+ const bound = BOUND_SQL[target.grain];
45400
+ const clauses = [];
45401
+ const params = [];
45402
+ if (range.since !== null) {
45403
+ clauses.push(`${target.column} >= ${bound}`);
45404
+ params.push(range.since);
45405
+ }
45406
+ if (range.until !== null) {
45407
+ clauses.push(`${target.column} <= ${bound}`);
45408
+ params.push(range.until);
45409
+ }
45410
+ return { clauses, params };
45411
+ }
45412
+ var BOUND_SQL, COLUMN;
45413
+ var init_selector_time_range = __esm(() => {
45414
+ BOUND_SQL = {
45415
+ day: "DATE(?)",
45416
+ timestamp: "strftime('%Y-%m-%dT%H:%M:%SZ', ?)"
45417
+ };
45418
+ COLUMN = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?$/;
45419
+ });
45420
+
45395
45421
  // ../../packages/domain/src/analytics/history-board-marts.ts
45396
45422
  function resolveSummaryReadPath(input) {
45397
45423
  if (!input.fresh)
@@ -45544,17 +45570,7 @@ async function deriveDimensionMarts(db, days) {
45544
45570
  await db.batch(deriveDimensionMartsOps(days));
45545
45571
  }
45546
45572
  function dayClauses(sel) {
45547
- const clauses = [];
45548
- const params = [];
45549
- if (sel.since !== null) {
45550
- clauses.push("d.day >= DATE(?)");
45551
- params.push(sel.since);
45552
- }
45553
- if (sel.until !== null) {
45554
- clauses.push("d.day <= DATE(?)");
45555
- params.push(sel.until);
45556
- }
45557
- return { clauses, params };
45573
+ return buildTimeRangeClauses(sel, { column: "d.day", grain: "day" });
45558
45574
  }
45559
45575
  function dimensionKeyClauses(sel, dimension) {
45560
45576
  let values = null;
@@ -45771,6 +45787,7 @@ async function historyBoardSummaryFromMart(db, sel, dimension) {
45771
45787
  }
45772
45788
  var MART_DIMENSIONS, MART_MIN_RANGE_DAYS = 7, MEASURE_COLS = "messages, tool_calls, skill_calls, fresh_input_tokens, cache_read_tokens, cache_write_tokens, output_tokens, duration_ms, duration_samples", MART_INSERT_COLS, MSG_DAY_PRED = "(m.bucket_start >= ? AND m.bucket_start < DATE(?, '+1 day'))", TOOL_DAY_PRED = "(t.bucket_start >= ? AND t.bucket_start < DATE(?, '+1 day'))", SKILL_DAY_PRED = "(s.bucket_start >= ? AND s.bucket_start < DATE(?, '+1 day'))", KPI_RANGE_KEY = "trend";
45773
45789
  var init_history_board_marts = __esm(() => {
45790
+ init_selector_time_range();
45774
45791
  MART_DIMENSIONS = ["model", "source", "tool", "skill"];
45775
45792
  MART_INSERT_COLS = `${MEASURE_COLS}, errors`;
45776
45793
  });
@@ -46201,16 +46218,10 @@ function appendRankedSteps(operations, kind, rows) {
46201
46218
  });
46202
46219
  }
46203
46220
  function buildRollupWhere(sel, alias, options) {
46204
- const clauses = [];
46205
- const params = [];
46206
- if (sel.since !== null) {
46207
- clauses.push(`${alias}.${options.timestamp} >= ${options.dateOnly ? "DATE(?)" : "strftime('%Y-%m-%dT%H:%M:%SZ', ?)"}`);
46208
- params.push(sel.since);
46209
- }
46210
- if (sel.until !== null) {
46211
- clauses.push(`${alias}.${options.timestamp} <= ${options.dateOnly ? "DATE(?)" : "strftime('%Y-%m-%dT%H:%M:%SZ', ?)"}`);
46212
- params.push(sel.until);
46213
- }
46221
+ const { clauses, params } = buildTimeRangeClauses(sel, {
46222
+ column: `${alias}.${options.timestamp}`,
46223
+ grain: options.dateOnly ? "day" : "timestamp"
46224
+ });
46214
46225
  if (sel.sources !== null && sel.sources.length > 0) {
46215
46226
  clauses.push(`${alias}.source IN (${sel.sources.map(() => "?").join(", ")})`);
46216
46227
  params.push(...sel.sources);
@@ -47363,6 +47374,7 @@ var init_history_board_rollup = __esm(() => {
47363
47374
  init_forensic_query();
47364
47375
  init_history_board_marts();
47365
47376
  init_rollup_watermark();
47377
+ init_selector_time_range();
47366
47378
  init_tool_alias();
47367
47379
  init_tool_name_sql();
47368
47380
  init_tool_name_sql();
@@ -55594,6 +55606,38 @@ async function readWorkflowStateVisitRows(db, workflows, opts = {}) {
55594
55606
  }
55595
55607
  return rows;
55596
55608
  }
55609
+ async function readWorkflowHostPauseRows(db, workflows, opts = {}) {
55610
+ const sql = `SELECT json_extract(r.metadata_json, '$.hostPauses') AS hostPausesJson
55611
+ FROM runs r
55612
+ WHERE r.workflow_name = ?
55613
+ AND ${perStatePopulation(opts.sinceMs)}
55614
+ AND json_extract(r.metadata_json, '$.hostPauses') IS NOT NULL`;
55615
+ const rows = [];
55616
+ for (const workflow of workflows) {
55617
+ const found = await db.queryAll(sql, workflow, ...TERMINAL_RUN_STATUSES, ...opts.sinceMs !== undefined ? [opts.sinceMs] : []);
55618
+ for (const r of found)
55619
+ rows.push({ workflow, hostPausesJson: r.hostPausesJson });
55620
+ }
55621
+ return rows;
55622
+ }
55623
+ async function readWorkflowOpenPauseRows(db, workflows) {
55624
+ const sql = `SELECT r.id AS runId, r.updated_at AS updatedAt,
55625
+ (SELECT a.result_json FROM action_runs a
55626
+ WHERE a.run_id = r.id AND (a.status = 'paused' OR a.result_json LIKE '%"pause":true%')
55627
+ ORDER BY a.created_at DESC, a.rowid DESC LIMIT 1) AS pausedResultJson
55628
+ FROM runs r
55629
+ WHERE r.workflow_name = ?
55630
+ AND r.status IN ('paused', 'interrupted')
55631
+ AND json_extract(r.metadata_json, '$.dryRun') IS NOT 1
55632
+ ORDER BY r.id`;
55633
+ const rows = [];
55634
+ for (const workflow of workflows) {
55635
+ const found = await db.queryAll(sql, workflow);
55636
+ for (const r of found)
55637
+ rows.push({ workflow, ...r });
55638
+ }
55639
+ return rows;
55640
+ }
55597
55641
  var TERMINAL_RUN_STATUSES;
55598
55642
  var init_workflow_measurements = __esm(() => {
55599
55643
  TERMINAL_RUN_STATUSES = ["done", "failed", "cancelled"];
@@ -58105,7 +58149,7 @@ var init_run_dao = __esm(() => {
58105
58149
  });
58106
58150
  }
58107
58151
  traceRows(filter) {
58108
- return this.adapter.queryAll(`SELECT id, workflow_name, mode, status, agent, started_at, completed_at, metadata_json
58152
+ return this.adapter.queryAll(`SELECT id, workflow_name, mode, status, agent, started_at, completed_at, metadata_json, updated_at
58109
58153
  FROM runs
58110
58154
  WHERE (?1 IS NULL OR workflow_name = ?1)
58111
58155
  AND (?2 IS NULL OR status = ?2)
@@ -58119,7 +58163,7 @@ var init_run_dao = __esm(() => {
58119
58163
  LIMIT ?6`, filter.workflow ?? null, filter.status ?? null, filter.since ?? null, filter.before?.started_at ?? null, filter.before?.id ?? null, filter.limit);
58120
58164
  }
58121
58165
  traceRowById(runId) {
58122
- return this.adapter.queryFirst("SELECT id, workflow_name, mode, status, agent, started_at, completed_at, terminal_reason, metadata_json FROM runs WHERE id = ?", runId);
58166
+ return this.adapter.queryFirst("SELECT id, workflow_name, mode, status, agent, started_at, completed_at, updated_at, terminal_reason, metadata_json, pid FROM runs WHERE id = ?", runId);
58123
58167
  }
58124
58168
  mergeMetadata(runId, patch) {
58125
58169
  return this.adapter.run(`UPDATE runs
@@ -58132,6 +58176,17 @@ var init_run_dao = __esm(() => {
58132
58176
  stampMetadata(runId, metadata) {
58133
58177
  return this.mergeMetadata(runId, metadata);
58134
58178
  }
58179
+ mergeMetadataIfStatus(runId, patch, statuses) {
58180
+ if (statuses.length === 0)
58181
+ return Promise.resolve();
58182
+ const placeholders = statuses.map((_, i) => `?${i + 3}`).join(", ");
58183
+ return this.adapter.run(`UPDATE runs
58184
+ SET metadata_json = json_patch(
58185
+ COALESCE(NULLIF(metadata_json, ''), '{}'),
58186
+ ?1
58187
+ )
58188
+ WHERE id = ?2 AND status IN (${placeholders})`, JSON.stringify(patch), runId, ...statuses);
58189
+ }
58135
58190
  stampRunIdentity(runId, definitionDigest, workflowVersion, source) {
58136
58191
  const sourceSet = source === undefined ? "" : `,
58137
58192
  '$.definitionSource.path', ?4,
@@ -72279,6 +72334,64 @@ var init_dist11 = __esm(() => {
72279
72334
  init_types3();
72280
72335
  });
72281
72336
 
72337
+ // ../../packages/domain/src/migration-sql.generated.ts
72338
+ var HISTORY_DIMENSION_MARTS_SCHEMA_SQL = `-- 0743: Dimension marts (task 0743).
72339
+ -- Two day-grain mart tables carrying the ADR-106 nine-measure additive vector for the
72340
+ -- four materialized Summary dimensions. Every measure column is nullable for exactly one
72341
+ -- reason: ADR-106 records a measure that is not well defined at a dimension as NULL, never
72342
+ -- as a zero that would be indistinguishable from a measured absence of activity.
72343
+ --
72344
+ -- - tool dimension: skill_calls is not applicable -> NULL
72345
+ -- - skill dimension: tool_calls is not applicable -> NULL
72346
+ -- - source dimension: duration_ms / duration_samples are not applicable -> NULL
72347
+ --
72348
+ -- history_board_kpi_window stores the current and previous aggregate window KPIs for a
72349
+ -- named range window. All statements are idempotent (CREATE TABLE IF NOT EXISTS).
72350
+
72351
+ CREATE TABLE IF NOT EXISTS history_board_dimension_daily (
72352
+ dimension TEXT NOT NULL,
72353
+ dimension_key TEXT NOT NULL,
72354
+ day TEXT NOT NULL,
72355
+ -- ADR-106 nine-member additive measure vector. NULLABLE, never a NOT NULL column with a
72356
+ -- zero default: a not-applicable measure is stored as NULL.
72357
+ messages INTEGER,
72358
+ tool_calls INTEGER,
72359
+ skill_calls INTEGER,
72360
+ fresh_input_tokens REAL,
72361
+ cache_read_tokens REAL,
72362
+ cache_write_tokens REAL,
72363
+ output_tokens REAL,
72364
+ duration_ms INTEGER,
72365
+ duration_samples INTEGER,
72366
+ -- Per-(dimension, key, day) tool-error count (not part of the ADR-106 additive vector but
72367
+ -- needed by the top-tools error-rate projection. NULLABLE like every other scalar.
72368
+ errors INTEGER,
72369
+ PRIMARY KEY (dimension, dimension_key, day)
72370
+ );
72371
+
72372
+ CREATE TABLE IF NOT EXISTS history_board_kpi_window (
72373
+ range_key TEXT NOT NULL,
72374
+ window_kind TEXT NOT NULL CHECK (window_kind IN ('current', 'previous')),
72375
+ -- ADR-106 nine-member additive measure vector, plus the non-additive totals the KPI
72376
+ -- projection needs (sessions and tool_errors are counts, never summed as means).
72377
+ messages INTEGER,
72378
+ tool_calls INTEGER,
72379
+ skill_calls INTEGER,
72380
+ fresh_input_tokens REAL,
72381
+ cache_read_tokens REAL,
72382
+ cache_write_tokens REAL,
72383
+ output_tokens REAL,
72384
+ duration_ms INTEGER,
72385
+ duration_samples INTEGER,
72386
+ sessions INTEGER,
72387
+ tool_errors INTEGER,
72388
+ PRIMARY KEY (range_key, window_kind)
72389
+ );
72390
+
72391
+ CREATE INDEX IF NOT EXISTS idx_history_board_dimension_daily_day
72392
+ ON history_board_dimension_daily (dimension, day);
72393
+ `;
72394
+
72282
72395
  // ../../packages/domain/src/schema/inbox-messages.ts
72283
72396
  var inboxMessagesTable, inboxMessages4, INBOX_MESSAGES_TABLE_SQL;
72284
72397
  var init_inbox_messages3 = __esm(() => {
@@ -73224,49 +73337,6 @@ CREATE TABLE IF NOT EXISTS history_board_rollup_bucket (
73224
73337
 
73225
73338
  CREATE INDEX IF NOT EXISTS idx_history_message_imported_at
73226
73339
  ON history_message (imported_at);
73227
- `, HISTORY_DIMENSION_MARTS_SCHEMA_SQL = `
73228
- CREATE TABLE IF NOT EXISTS history_board_dimension_daily (
73229
- dimension TEXT NOT NULL,
73230
- dimension_key TEXT NOT NULL,
73231
- day TEXT NOT NULL,
73232
- -- ADR-106 nine-member additive measure vector. NULLABLE, never a NOT NULL column with a
73233
- -- zero default: a not-applicable measure is stored as NULL.
73234
- messages INTEGER,
73235
- tool_calls INTEGER,
73236
- skill_calls INTEGER,
73237
- fresh_input_tokens REAL,
73238
- cache_read_tokens REAL,
73239
- cache_write_tokens REAL,
73240
- output_tokens REAL,
73241
- duration_ms INTEGER,
73242
- duration_samples INTEGER,
73243
- -- Per-(dimension, key, day) tool-error count (not part of the ADR-106 additive vector but
73244
- -- needed by the top-tools error-rate projection. NULLABLE like every other scalar.
73245
- errors INTEGER,
73246
- PRIMARY KEY (dimension, dimension_key, day)
73247
- );
73248
-
73249
- CREATE TABLE IF NOT EXISTS history_board_kpi_window (
73250
- range_key TEXT NOT NULL,
73251
- window_kind TEXT NOT NULL CHECK (window_kind IN ('current', 'previous')),
73252
- -- ADR-106 nine-member additive measure vector, plus the non-additive totals the KPI
73253
- -- projection needs (sessions and tool_errors are counts, never summed as means).
73254
- messages INTEGER,
73255
- tool_calls INTEGER,
73256
- skill_calls INTEGER,
73257
- fresh_input_tokens REAL,
73258
- cache_read_tokens REAL,
73259
- cache_write_tokens REAL,
73260
- output_tokens REAL,
73261
- duration_ms INTEGER,
73262
- duration_samples INTEGER,
73263
- sessions INTEGER,
73264
- tool_errors INTEGER,
73265
- PRIMARY KEY (range_key, window_kind)
73266
- );
73267
-
73268
- CREATE INDEX IF NOT EXISTS idx_history_board_dimension_daily_day
73269
- ON history_board_dimension_daily (dimension, day);
73270
73340
  `, RETENTION_COMPACTION_META_SCHEMA_SQL = `
73271
73341
  CREATE TABLE IF NOT EXISTS spur_retention_meta (
73272
73342
  kind TEXT NOT NULL,
@@ -100265,6 +100335,31 @@ import {
100265
100335
  writeSync as writeSync2
100266
100336
  } from "fs";
100267
100337
  import { dirname as dirname8, join as join18 } from "path";
100338
+ function writeStateFile(statePath, state) {
100339
+ const temp = `${statePath}.tmp`;
100340
+ try {
100341
+ writeFileSync3(temp, `${JSON.stringify(state, null, 2)}
100342
+ `);
100343
+ renameSync3(temp, statePath);
100344
+ } catch {
100345
+ try {
100346
+ unlinkSync2(temp);
100347
+ } catch {}
100348
+ }
100349
+ }
100350
+ function projectRunStateStatus(dir, runId, status, at) {
100351
+ const statePath = join18(dir, `${runId}.state.json`);
100352
+ let prior;
100353
+ try {
100354
+ const parsed = JSON.parse(readFileSync5(statePath, "utf8"));
100355
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
100356
+ return;
100357
+ prior = parsed;
100358
+ } catch {
100359
+ return;
100360
+ }
100361
+ writeStateFile(statePath, { ...prior, status, updatedAt: at, finalizedAt: at });
100362
+ }
100268
100363
 
100269
100364
  class WorkflowRunLogSink {
100270
100365
  filePath;
@@ -100404,16 +100499,7 @@ class WorkflowRunLogSink {
100404
100499
  ...this.lineage?.startState !== undefined ? { startState: this.lineage.startState } : {},
100405
100500
  ...this.lineage?.continuedFrom !== undefined ? { continuedFrom: this.lineage.continuedFrom } : {}
100406
100501
  };
100407
- const temp = `${this.statePath}.tmp`;
100408
- try {
100409
- writeFileSync3(temp, `${JSON.stringify(state, null, 2)}
100410
- `);
100411
- renameSync3(temp, this.statePath);
100412
- } catch {
100413
- try {
100414
- unlinkSync2(temp);
100415
- } catch {}
100416
- }
100502
+ writeStateFile(this.statePath, state);
100417
100503
  }
100418
100504
  onSteering(ack) {
100419
100505
  const note = ack.note === undefined ? "" : ` \xB7 ${bounded3(ack.note, MAX_STEERING_NOTE_CHARS)}`;
@@ -104841,7 +104927,16 @@ class AgentRunActionRunner {
104841
104927
  error: `agent.run: work item for '${context.stateOrNodeId}' could not be written: ${error.message}`
104842
104928
  };
104843
104929
  }
104844
- return { ok: true, pause: true, data: { surface: "host", workItem, reason: "host-work-item" } };
104930
+ return {
104931
+ ok: true,
104932
+ pause: true,
104933
+ data: {
104934
+ surface: "host",
104935
+ workItem,
104936
+ reason: "host-work-item",
104937
+ ...invocation.timeoutMs !== undefined ? { timeoutMs: invocation.timeoutMs } : {}
104938
+ }
104939
+ };
104845
104940
  }
104846
104941
  async execute(options, context, execution) {
104847
104942
  const startedAt = new Date().toISOString();
@@ -121325,6 +121420,102 @@ var init_process_inventory_service = __esm(() => {
121325
121420
  init_process_inspector();
121326
121421
  });
121327
121422
 
121423
+ // ../../packages/app/src/workflow/host-pause.ts
121424
+ function readHostPauses(metadata) {
121425
+ if (metadata === null || typeof metadata !== "object")
121426
+ return [];
121427
+ const raw = metadata[HOST_PAUSES_KEY];
121428
+ if (!Array.isArray(raw))
121429
+ return [];
121430
+ const out = [];
121431
+ for (const entry of raw) {
121432
+ if (entry === null || typeof entry !== "object")
121433
+ continue;
121434
+ const row = entry;
121435
+ const { node, action, pausedAt, resumedAt, hostDurationMs } = row;
121436
+ if (typeof node !== "string" || typeof pausedAt !== "string" || typeof resumedAt !== "string")
121437
+ continue;
121438
+ if (typeof hostDurationMs !== "number" || !Number.isFinite(hostDurationMs))
121439
+ continue;
121440
+ out.push({
121441
+ node,
121442
+ action: typeof action === "string" ? action : "agent.run",
121443
+ pausedAt,
121444
+ resumedAt,
121445
+ hostDurationMs,
121446
+ ...typeof row.executor === "string" ? { executor: row.executor } : {}
121447
+ });
121448
+ }
121449
+ return out;
121450
+ }
121451
+ function appendHostPause(metadata, pause) {
121452
+ return [...readHostPauses(metadata), pause];
121453
+ }
121454
+ function hostPauseMsByNode(pauses) {
121455
+ const totals = new Map;
121456
+ for (const pause of pauses)
121457
+ totals.set(pause.node, (totals.get(pause.node) ?? 0) + pause.hostDurationMs);
121458
+ return totals;
121459
+ }
121460
+ function hostDurationMs(pausedAt, resumedAt) {
121461
+ const start = Date.parse(pausedAt);
121462
+ const end = Date.parse(resumedAt);
121463
+ if (!Number.isFinite(start) || !Number.isFinite(end) || end < start)
121464
+ return null;
121465
+ return end - start;
121466
+ }
121467
+ function isDegenerateReceiptInterval(startedAt, finishedAt) {
121468
+ if (startedAt === undefined || finishedAt === undefined)
121469
+ return true;
121470
+ const start = Date.parse(startedAt);
121471
+ const end = Date.parse(finishedAt);
121472
+ if (!Number.isFinite(start) || !Number.isFinite(end))
121473
+ return true;
121474
+ return end <= start;
121475
+ }
121476
+ function resolveStallThresholdMs(nodeTimeoutMs, readEnvVar = getEnvVar) {
121477
+ if (nodeTimeoutMs !== undefined && Number.isFinite(nodeTimeoutMs) && nodeTimeoutMs > 0)
121478
+ return nodeTimeoutMs;
121479
+ const raw = readEnvVar(STALL_THRESHOLD_MS_ENV);
121480
+ if (raw !== undefined) {
121481
+ const parsed = Number(raw);
121482
+ if (Number.isFinite(parsed) && parsed > 0)
121483
+ return Math.trunc(parsed);
121484
+ }
121485
+ return STALL_THRESHOLD_MS_DEFAULT;
121486
+ }
121487
+ function openPauseMs(pausedAt, nowMs) {
121488
+ if (pausedAt === null || pausedAt === undefined)
121489
+ return null;
121490
+ const start = typeof pausedAt === "number" ? pausedAt : /^\d+$/.test(pausedAt) ? Number(pausedAt) : Date.parse(pausedAt);
121491
+ if (!Number.isFinite(start))
121492
+ return null;
121493
+ return Math.max(0, nowMs - start);
121494
+ }
121495
+ function pausedNodeBudgetMs(rows) {
121496
+ for (let i = rows.length - 1;i >= 0; i--) {
121497
+ const row = rows[i];
121498
+ if (row === undefined || row.result_json === null)
121499
+ continue;
121500
+ if (row.status !== "paused" && !/"pause"\s*:\s*true/.test(row.result_json))
121501
+ continue;
121502
+ try {
121503
+ const budget = JSON.parse(row.result_json)?.data?.timeoutMs;
121504
+ return typeof budget === "number" && Number.isFinite(budget) && budget > 0 ? budget : undefined;
121505
+ } catch {
121506
+ return;
121507
+ }
121508
+ }
121509
+ return;
121510
+ }
121511
+ function stalledLabel(runId, pauseMs) {
121512
+ return `stalled \u2014 paused ${Math.round(pauseMs / 1000)}s; resume: spur workflow continue ${runId}`;
121513
+ }
121514
+ var HOST_PAUSES_KEY = "hostPauses", STALL_THRESHOLD_MS_DEFAULT = 1800000, STALL_THRESHOLD_MS_ENV = "SPUR_WORKFLOW_STALL_MS";
121515
+ var init_host_pause = __esm(() => {
121516
+ init_src();
121517
+ });
121518
+
121328
121519
  // ../../packages/app/src/workflow/run-measurements.ts
121329
121520
  function median(values) {
121330
121521
  if (values.length === 0)
@@ -121425,6 +121616,18 @@ async function readStateMetrics(db, workflows, opts = {}) {
121425
121616
  row.visits += t.visitsInRun;
121426
121617
  row.retries += Math.max(0, t.visitsInRun - 1);
121427
121618
  }
121619
+ for (const h of await readWorkflowHostPauseRows(db, workflows, opts)) {
121620
+ let parsed;
121621
+ try {
121622
+ parsed = JSON.parse(h.hostPausesJson);
121623
+ } catch {
121624
+ continue;
121625
+ }
121626
+ for (const [node, ms] of hostPauseMsByNode(readHostPauses({ hostPauses: parsed }))) {
121627
+ const row = rowFor(h.workflow, node);
121628
+ row.hostMs = (row.hostMs ?? 0) + ms;
121629
+ }
121630
+ }
121428
121631
  const rows = [];
121429
121632
  for (const [workflow, states] of byWorkflow) {
121430
121633
  for (const [state, row] of states) {
@@ -121435,14 +121638,27 @@ async function readStateMetrics(db, workflows, opts = {}) {
121435
121638
  agentRunCount: row.agentRunCount,
121436
121639
  wallMsP50: nearestRankPercentile(row.wallSamples, 50),
121437
121640
  wallMsP90: nearestRankPercentile(row.wallSamples, 90),
121438
- retries: row.retries
121641
+ retries: row.retries,
121642
+ ...row.hostMs !== undefined ? { hostMs: row.hostMs } : {}
121439
121643
  });
121440
121644
  }
121441
121645
  }
121442
121646
  return rows.sort((a, b) => a.workflow === b.workflow ? a.state < b.state ? -1 : a.state > b.state ? 1 : 0 : a.workflow < b.workflow ? -1 : 1);
121443
121647
  }
121648
+ async function readStalledRuns(db, workflows, nowMs = Date.now()) {
121649
+ const stalled = [];
121650
+ for (const row of await readWorkflowOpenPauseRows(db, workflows)) {
121651
+ const ageMs = openPauseMs(row.updatedAt, nowMs);
121652
+ const budget = pausedNodeBudgetMs([{ status: "paused", result_json: row.pausedResultJson }]);
121653
+ if (ageMs !== null && ageMs > resolveStallThresholdMs(budget)) {
121654
+ stalled.push({ workflow: row.workflow, runId: row.runId, stalledMs: ageMs });
121655
+ }
121656
+ }
121657
+ return stalled;
121658
+ }
121444
121659
  var init_run_measurements = __esm(() => {
121445
121660
  init_src2();
121661
+ init_host_pause();
121446
121662
  init_terminal_reason();
121447
121663
  });
121448
121664
 
@@ -130909,6 +131125,174 @@ var init_token_ledger_watcher = __esm(() => {
130909
131125
  init_token_ledger_service();
130910
131126
  });
130911
131127
 
131128
+ // ../../packages/app/src/services/sdlc-actions.ts
131129
+ function isReservedAction(name) {
131130
+ return RESERVED_ACTIONS.includes(name);
131131
+ }
131132
+ function resolveAction(builder, name) {
131133
+ const reserved = isReservedAction(name);
131134
+ const declared = builder?.actions?.[name];
131135
+ if (declared !== undefined) {
131136
+ return Array.isArray(declared) ? { name, reserved, source: "config", kind: "composite", steps: [...declared] } : { name, reserved, source: "config", kind: "command", command: declared };
131137
+ }
131138
+ if ((builder?.preset ?? "bun") === "bun") {
131139
+ if (reserved)
131140
+ return { name, reserved, source: "preset", kind: "command", command: BUN_PRESET[name] };
131141
+ } else {
131142
+ const command = reserved ? STACK_NEUTRAL_DEFAULTS[name] : undefined;
131143
+ if (command !== undefined)
131144
+ return { name, reserved, source: "default", kind: "command", command };
131145
+ }
131146
+ return { name, reserved, source: "unbound", kind: "unbound" };
131147
+ }
131148
+ function listActions(builder) {
131149
+ const custom = Object.keys(builder?.actions ?? {}).filter((name) => !isReservedAction(name)).sort();
131150
+ return [...RESERVED_ACTIONS, ...custom].map((name) => resolveAction(builder, name));
131151
+ }
131152
+ function renderScoped(command, values) {
131153
+ let rendered = command;
131154
+ for (const [placeholder, items] of [
131155
+ ["{files}", values.files],
131156
+ ["{paths}", values.paths]
131157
+ ]) {
131158
+ if (!rendered.includes(placeholder))
131159
+ continue;
131160
+ if (items === undefined)
131161
+ throw new Error(`renderScoped: ${placeholder} has no value`);
131162
+ if (items.length === 0)
131163
+ throw new Error(`renderScoped: ${placeholder} has an empty list`);
131164
+ rendered = rendered.replaceAll(placeholder, () => items.map(shQuote2).join(" "));
131165
+ }
131166
+ if (rendered.includes("{workspace}")) {
131167
+ const workspace = values.workspace;
131168
+ if (workspace === undefined)
131169
+ throw new Error("renderScoped: {workspace} has no value");
131170
+ if (workspace.length === 0)
131171
+ throw new Error("renderScoped: {workspace} is empty");
131172
+ rendered = rendered.replaceAll("{workspace}", () => shQuote2(workspace));
131173
+ }
131174
+ return rendered;
131175
+ }
131176
+ var RESERVED_ACTIONS, BUN_PRESET, STACK_NEUTRAL_DEFAULTS;
131177
+ var init_sdlc_actions = __esm(() => {
131178
+ init_src();
131179
+ RESERVED_ACTIONS = SDLC_ACTIONS;
131180
+ BUN_PRESET = {
131181
+ build: "bun run build",
131182
+ dev: "bun run dev",
131183
+ clean: "bun run clean",
131184
+ format: "bun run format",
131185
+ autofix: "bun run autofix",
131186
+ lint: "bun run lint",
131187
+ typecheck: "bun run typecheck",
131188
+ test: "bun run test",
131189
+ "test-full": "bun run test:full",
131190
+ "test-heavy": "bun run test:heavy",
131191
+ "rules-pre": "bun run test-pre-check",
131192
+ "rules-post": "bun run test-post-check",
131193
+ check: "bun run spur-check",
131194
+ "check-full": "bun run spur-check:full",
131195
+ "check-feature": "bun run spur-check-feature",
131196
+ "lint-changed": "bunx biome check {files}",
131197
+ "typecheck-changed": "cd {workspace} && bun run typecheck",
131198
+ "test-changed": "cd {workspace} && bun test {paths}"
131199
+ };
131200
+ STACK_NEUTRAL_DEFAULTS = SDLC_STACK_NEUTRAL_ACTIONS;
131201
+ });
131202
+
131203
+ // ../../packages/app/src/services/verdict-replay.ts
131204
+ function citedTestAnchors(rows, max) {
131205
+ const seen = new Map;
131206
+ for (const row of rows) {
131207
+ for (const anchor of extractBacktickLineAnchors(row.evidence ?? "")) {
131208
+ if (!TEST_FILE_PATTERN.test(anchor.path) || seen.has(anchor.path))
131209
+ continue;
131210
+ seen.set(anchor.path, anchor.raw);
131211
+ }
131212
+ }
131213
+ return new Map([...seen].slice(0, max));
131214
+ }
131215
+ function replayWorkspace(file) {
131216
+ const parts = file.split("/");
131217
+ if (parts.length >= 3 && (parts[0] === "apps" || parts[0] === "packages"))
131218
+ return `${parts[0]}/${parts[1]}`;
131219
+ return ".";
131220
+ }
131221
+ async function replayCitedTests(rows, options = {}) {
131222
+ const cwd = options.cwd ?? process.cwd();
131223
+ const timeoutMs = options.timeoutMs ?? REPLAY_TIMEOUT_MS;
131224
+ const anchors = citedTestAnchors(rows, options.maxFiles ?? REPLAY_MAX_FILES);
131225
+ const files = [...anchors.keys()];
131226
+ const executor = options.executor ?? new NodeProcessExecutor2;
131227
+ const fileSystem = options.fs ?? createNodeFileSystem2();
131228
+ const resolved = resolveAction(options.builder, "test-changed");
131229
+ const template = resolved.kind === "command" ? resolved.command : resolved.kind === "composite" ? resolved.steps.join(" && ") : "";
131230
+ const ran = [];
131231
+ const skipped = [];
131232
+ const findings = [];
131233
+ const thresholdByWorkspace = new Map;
131234
+ for (const file of files) {
131235
+ const anchor = anchors.get(file) ?? file;
131236
+ const exists = await fileSystem.exists(`${cwd}/${file}`);
131237
+ if (!exists) {
131238
+ findings.push({ anchor, file, missing: true });
131239
+ continue;
131240
+ }
131241
+ if (template.length === 0)
131242
+ continue;
131243
+ const workspace = replayWorkspace(file);
131244
+ let threshold = thresholdByWorkspace.get(workspace);
131245
+ if (threshold === undefined) {
131246
+ threshold = await declaresCoverageThreshold(fileSystem, `${cwd}/${workspace}/bunfig.toml`);
131247
+ thresholdByWorkspace.set(workspace, threshold);
131248
+ }
131249
+ if (threshold) {
131250
+ skipped.push({ file, reason: `coverageThreshold in ${workspace}/bunfig.toml` });
131251
+ continue;
131252
+ }
131253
+ const path = workspace === "." ? file : file.slice(workspace.length + 1);
131254
+ const command = renderScoped(template, { workspace, paths: [path] });
131255
+ const result = await executor.run({
131256
+ command: "sh",
131257
+ args: ["-c", command],
131258
+ cwd,
131259
+ forceBuffered: true,
131260
+ rejectOnError: false,
131261
+ timeout: timeoutMs
131262
+ });
131263
+ const exitCode = result.exitCode ?? null;
131264
+ ran.push({ file, exitCode });
131265
+ if (result.outcome === "timeout")
131266
+ findings.push({ anchor, file, timedOut: true, exitCode });
131267
+ else if (exitCode !== 0)
131268
+ findings.push({ anchor, file, exitCode });
131269
+ }
131270
+ return { files, ran, skipped, findings };
131271
+ }
131272
+ async function declaresCoverageThreshold(fileSystem, bunfigPath) {
131273
+ try {
131274
+ if (!await fileSystem.exists(bunfigPath))
131275
+ return false;
131276
+ return COVERAGE_THRESHOLD_PATTERN.test(await fileSystem.readFile(bunfigPath));
131277
+ } catch {
131278
+ return false;
131279
+ }
131280
+ }
131281
+ function formatReplayReport(replay) {
131282
+ return {
131283
+ summary: `Replay: ${replay.files.length} cited test file(s), ${replay.findings.length} finding(s)${replay.skipped.length > 0 ? `, ${replay.skipped.length} skipped` : ""}`,
131284
+ findings: replay.findings.map((finding) => ` ${finding.anchor}: ${finding.missing ? "missing" : finding.timedOut ? "timed out" : `exit ${finding.exitCode ?? "unknown"}`}`)
131285
+ };
131286
+ }
131287
+ var REPLAY_MAX_FILES = 5, REPLAY_TIMEOUT_MS = 120000, TEST_FILE_PATTERN, COVERAGE_THRESHOLD_PATTERN;
131288
+ var init_verdict_replay = __esm(() => {
131289
+ init_dist5();
131290
+ init_sdlc_actions();
131291
+ init_task_check();
131292
+ TEST_FILE_PATTERN = /\.(?:test|spec)\.[cm]?[jt]sx?$/;
131293
+ COVERAGE_THRESHOLD_PATTERN = /^\s*coverageThreshold\s*=/m;
131294
+ });
131295
+
130912
131296
  // ../../packages/app/src/workflow/checkpoint-contract.ts
130913
131297
  function parseCheckpointMetadata(raw) {
130914
131298
  if (!raw.startsWith("---"))
@@ -131099,6 +131483,32 @@ var init_workflow_catalog_scan = __esm(() => {
131099
131483
  import { realpathSync as realpathSync8, statSync as statSync12 } from "fs";
131100
131484
  import { readdir as readdir3, rm as rm2 } from "fs/promises";
131101
131485
  import { dirname as dirname32, join as join58, relative as relative14, resolve as resolve21, sep as sep11 } from "path";
131486
+ async function reapRun(db, engine, run, reasons, cwd) {
131487
+ const dao = new RunDao(db);
131488
+ const interrupted = run.status === "running" ? await engine.interruptRun(run.id, "interrupted") : undefined;
131489
+ if (interrupted === undefined) {
131490
+ await dao.finalizeStale(run.id, reasons.finalized);
131491
+ } else {
131492
+ await dao.mergeMetadataIfStatus(run.id, { staleReason: reasons.interrupted }, ["interrupted"]);
131493
+ }
131494
+ projectRunStateStatus(runStoragePaths(cwd).recordsDir, run.id, interrupted === undefined ? "failed" : "interrupted", new Date().toISOString());
131495
+ return interrupted === undefined ? "failed" : "interrupted";
131496
+ }
131497
+ async function reapIfWorkerLost(db, cwd, runId) {
131498
+ const dao = new RunDao(db);
131499
+ const row = await dao.traceRowById(runId);
131500
+ if (row === undefined)
131501
+ return { row, reaped: false };
131502
+ if (row.status !== "running" && row.status !== "pending")
131503
+ return { row, reaped: false };
131504
+ const pid = row.pid;
131505
+ if (pid === null || isPidAlive(pid))
131506
+ return { row, reaped: false };
131507
+ const reason = `worker-lost: pid ${pid} not alive`;
131508
+ const engine = new WorkflowService(createDefaultWorkflowEngineHost({}), new DbWorkflowPersistenceAdapter(db));
131509
+ await reapRun(db, engine, row, { interrupted: reason, finalized: reason }, cwd);
131510
+ return { row, reaped: true };
131511
+ }
131102
131512
 
131103
131513
  class WorkflowCleanOps {
131104
131514
  ctx;
@@ -131113,14 +131523,10 @@ class WorkflowCleanOps {
131113
131523
  if (!dryRun) {
131114
131524
  const engine = new WorkflowService(createDefaultWorkflowEngineHost({}), new DbWorkflowPersistenceAdapter(db));
131115
131525
  for (const run of stale) {
131116
- const interrupted = run.status === "running" ? await engine.interruptRun(run.id, "interrupted") : undefined;
131117
- if (interrupted === undefined) {
131118
- await dao.finalizeStale(run.id, `stale: non-terminal > ${olderThanMinutes}m (spur workflow clean)`);
131119
- } else {
131120
- await dao.mergeMetadata(run.id, {
131121
- staleReason: `stale: owner lost > ${olderThanMinutes}m (spur workflow clean)`
131122
- });
131123
- }
131526
+ await reapRun(db, engine, run, {
131527
+ interrupted: `stale: owner lost > ${olderThanMinutes}m (spur workflow clean)`,
131528
+ finalized: `stale: non-terminal > ${olderThanMinutes}m (spur workflow clean)`
131529
+ }, this.ctx.cwd);
131124
131530
  }
131125
131531
  }
131126
131532
  return {
@@ -131337,7 +131743,9 @@ var init_workflow_clean_ops = __esm(() => {
131337
131743
  init_src2();
131338
131744
  init_dist9();
131339
131745
  init_dist5();
131746
+ init_workflow_run_log_sink();
131340
131747
  init_checkpoint_contract();
131748
+ init_project_process();
131341
131749
  init_run_storage();
131342
131750
  init_workflow_catalog_scan();
131343
131751
  });
@@ -131351,6 +131759,16 @@ function commandGateOutcome(exitCode) {
131351
131759
  return "FAIL";
131352
131760
  return "BLOCKED";
131353
131761
  }
131762
+ function asOptionalTimeoutMs(value) {
131763
+ if (value === undefined || value === null)
131764
+ return { ok: true, ms: undefined };
131765
+ if (typeof value === "string" && value.trim() === "")
131766
+ return { ok: true, ms: undefined };
131767
+ const parsed = typeof value === "number" ? value : Number(value);
131768
+ if (!Number.isFinite(parsed) || parsed <= 0)
131769
+ return { ok: false };
131770
+ return { ok: true, ms: Math.trunc(parsed) };
131771
+ }
131354
131772
  function sleep4(ms) {
131355
131773
  return new Promise((resolve) => setTimeout(resolve, ms));
131356
131774
  }
@@ -131420,13 +131838,21 @@ class CommandGateActionRunner {
131420
131838
  const maxAttempts = Math.max(1, typeof retry.maxAttempts === "number" ? retry.maxAttempts : 1);
131421
131839
  const delayMs = Math.max(0, typeof retry.delayMs === "number" ? retry.delayMs : 1000);
131422
131840
  const retryOn = Array.isArray(retry.on) ? retry.on : [];
131423
- const timeoutMs = typeof options.timeoutMs === "number" ? options.timeoutMs : undefined;
131841
+ const timeout = asOptionalTimeoutMs(options.timeoutMs);
131842
+ if (!timeout.ok) {
131843
+ return {
131844
+ ok: false,
131845
+ error: `command.gate: timeoutMs must be a positive number, got "${String(options.timeoutMs)}"`
131846
+ };
131847
+ }
131848
+ const timeoutMs = timeout.ms;
131424
131849
  const env = childProcessEnv(context.vars);
131425
131850
  await this.fileSystem.ensureDir(normalize3(join59(normalized, "..")));
131426
131851
  let lastStdout = "";
131427
131852
  let lastStderr = "";
131428
131853
  let lastExitCode = -1;
131429
131854
  let attemptsRun = 0;
131855
+ let timedOut = false;
131430
131856
  for (let attempt = 1;attempt <= maxAttempts; attempt++) {
131431
131857
  attemptsRun = attempt;
131432
131858
  const res = await this.processExecutor.run({
@@ -131441,6 +131867,7 @@ class CommandGateActionRunner {
131441
131867
  lastStdout = res.stdout;
131442
131868
  lastStderr = res.stderr;
131443
131869
  lastExitCode = res.exitCode ?? -1;
131870
+ timedOut = res.outcome === "timeout";
131444
131871
  if (res.exitCode === 0) {
131445
131872
  await this.fileSystem.writeFile(normalized, `PASS
131446
131873
  `);
@@ -131475,7 +131902,8 @@ ${lastStderr}`;
131475
131902
  attempts: attemptsRun,
131476
131903
  stdout: lastStdout,
131477
131904
  stderr: lastStderr,
131478
- resultFile: normalized
131905
+ resultFile: normalized,
131906
+ ...timedOut ? { timedOut: true, timeoutMs } : {}
131479
131907
  };
131480
131908
  if (softFail) {
131481
131909
  return { ok: true, data: failureData };
@@ -135101,18 +135529,21 @@ async function outputArtifactForRun(cwd, runId) {
135101
135529
  }
135102
135530
  return;
135103
135531
  }
135104
- function rowToTraceEntry(row, cwd) {
135532
+ function rowToTraceEntry(row, cwd, nowMs = Date.now(), nodeBudgetMs) {
135105
135533
  let isDryRun = false;
135106
135534
  let failureReason;
135107
135535
  let version;
135108
135536
  let definitionDigest;
135109
135537
  let startState;
135110
135538
  let continuedFrom;
135539
+ let hostPauses;
135111
135540
  try {
135112
135541
  const meta = JSON.parse(row.metadata_json);
135113
135542
  isDryRun = meta.dryRun === true;
135114
135543
  if (typeof meta.failureReason === "string" && meta.failureReason !== "") {
135115
135544
  failureReason = meta.failureReason;
135545
+ } else if (typeof meta.staleReason === "string" && meta.staleReason !== "") {
135546
+ failureReason = meta.staleReason;
135116
135547
  }
135117
135548
  if ("workflowVersion" in meta && (typeof meta.workflowVersion === "string" || meta.workflowVersion === null)) {
135118
135549
  version = meta.workflowVersion;
@@ -135126,6 +135557,9 @@ function rowToTraceEntry(row, cwd) {
135126
135557
  if (typeof meta.continuedFrom === "string" && meta.continuedFrom !== "") {
135127
135558
  continuedFrom = meta.continuedFrom;
135128
135559
  }
135560
+ const pauses = readHostPauses(meta);
135561
+ if (pauses.length > 0)
135562
+ hostPauses = pauses;
135129
135563
  } catch {}
135130
135564
  const entry = {
135131
135565
  runId: row.id,
@@ -135143,8 +135577,14 @@ function rowToTraceEntry(row, cwd) {
135143
135577
  ...version !== undefined ? { version } : {},
135144
135578
  ...definitionDigest !== undefined ? { definitionDigest } : {},
135145
135579
  ...startState !== undefined ? { startState } : {},
135146
- ...continuedFrom !== undefined ? { continuedFrom } : {}
135580
+ ...continuedFrom !== undefined ? { continuedFrom } : {},
135581
+ ...hostPauses !== undefined ? { hostPauses } : {}
135147
135582
  };
135583
+ const pauseAgeMs = row.status === "paused" || row.status === "interrupted" ? openPauseMs(row.updated_at ?? null, nowMs) : null;
135584
+ if (pauseAgeMs !== null && pauseAgeMs > resolveStallThresholdMs(nodeBudgetMs)) {
135585
+ entry.stalled = true;
135586
+ entry.stalledMs = pauseAgeMs;
135587
+ }
135148
135588
  const nextAction = traceNextAction(entry);
135149
135589
  if (nextAction !== undefined)
135150
135590
  entry.nextAction = nextAction;
@@ -135189,7 +135629,11 @@ function traceNextAction(run, outputArtifact) {
135189
135629
  return { label: "Follow run", kind: "command", value: `spur workflow trace ${run.runId} --follow` };
135190
135630
  }
135191
135631
  if (run.status === "paused" || run.status === "interrupted") {
135192
- return { label: "Continue run", kind: "command", value: `spur workflow continue ${run.runId}` };
135632
+ return {
135633
+ label: run.stalled === true ? "Resume stalled run" : "Continue run",
135634
+ kind: "command",
135635
+ value: `spur workflow continue ${run.runId}`
135636
+ };
135193
135637
  }
135194
135638
  if (run.status === "failed" && outputArtifact !== undefined) {
135195
135639
  return { label: "Inspect run log", kind: "path", value: outputArtifact };
@@ -135300,6 +135744,7 @@ var TRACE_IDENTIFIER, TRACE_RESULT_FIELDS, TRACE_INVOCATION_FIELDS;
135300
135744
  var init_workflow_trace_projection = __esm(() => {
135301
135745
  init_dist5();
135302
135746
  init_agent_execution();
135747
+ init_host_pause();
135303
135748
  init_run_storage();
135304
135749
  init_system_event_envelope();
135305
135750
  TRACE_IDENTIFIER = /^[A-Za-z0-9._:-]+$/;
@@ -135478,20 +135923,19 @@ class WorkflowInspectOps {
135478
135923
  since: filter.since,
135479
135924
  limit: filter.last ?? 20
135480
135925
  });
135481
- return {
135482
- entries: rows.map((row) => rowToTraceEntry(row, this.ctx.cwd)),
135483
- total: rows.length
135484
- };
135926
+ const actionDao = new ActionRunDao(db);
135927
+ const entries = await Promise.all(rows.map(async (row) => rowToTraceEntry(row, this.ctx.cwd, Date.now(), row.status === "paused" || row.status === "interrupted" ? pausedNodeBudgetMs(await actionDao.actionRowsByRunId(row.id)) : undefined)));
135928
+ return { entries, total: rows.length };
135485
135929
  }
135486
- async traceRun(db, runId) {
135930
+ async traceRun(db, runId, preloadedRow) {
135487
135931
  const runDao = new RunDao(db);
135488
- const row = await runDao.traceRowById(runId);
135932
+ const row = preloadedRow ?? await runDao.traceRowById(runId);
135489
135933
  if (!row)
135490
135934
  throw new Error(`Run not found: ${runId}`);
135491
- const run = rowToTraceEntry(row, this.ctx.cwd);
135935
+ const actionRows = await new ActionRunDao(db).actionRowsByRunId(runId);
135936
+ const run = rowToTraceEntry(row, this.ctx.cwd, Date.now(), pausedNodeBudgetMs(actionRows));
135492
135937
  const phaseRows = await new PhaseRunDao(db).phaseRowsByRunId(runId);
135493
135938
  const transitionRows = await new TransitionRunDao(db).transitionRowsByRunId(runId);
135494
- const actionRows = await new ActionRunDao(db).actionRowsByRunId(runId);
135495
135939
  const costByActionId = new Map;
135496
135940
  if (actionRows.some((a) => a.kind === "agent.run")) {
135497
135941
  for (const a of actionRows) {
@@ -135599,6 +136043,7 @@ var init_workflow_inspect_ops = __esm(() => {
135599
136043
  init_dist5();
135600
136044
  init_decision_service();
135601
136045
  init_composition_lint();
136046
+ init_host_pause();
135602
136047
  init_plan_projection();
135603
136048
  init_run_record();
135604
136049
  init_workflow_resolver();
@@ -136118,8 +136563,9 @@ class WorkflowResumeOps {
136118
136563
  if (persistedDigest !== null && persistedDigest !== resolved.digest && "resumeDefinitionDigest" in rawMeta) {
136119
136564
  resumeVars.__definitionDigest = resolved.digest;
136120
136565
  }
136121
- if (hostPause.receipt !== undefined) {
136122
- await writeReceipt(createNodeFileSystem2(launchWorkdir), launchWorkdir, hostPause.receipt);
136566
+ const stamped = await this.recordHostPause(runId, hostPause.receipt, opts?.now);
136567
+ if (stamped !== undefined) {
136568
+ await writeReceipt(createNodeFileSystem2(launchWorkdir), launchWorkdir, stamped);
136123
136569
  }
136124
136570
  const result = await withWorktreeRunLease(await this.ctx.getDb(), launchWorkdir, workflow.name, runId, async (signal) => svc.resumeRun(workflow, runId, {
136125
136571
  ...signal !== undefined ? { signal } : {},
@@ -136141,6 +136587,39 @@ class WorkflowResumeOps {
136141
136587
  runResult.warnings = warnings;
136142
136588
  return runResult;
136143
136589
  }
136590
+ async recordHostPause(runId, receipt, now) {
136591
+ const db = await this.ctx.getDb();
136592
+ const rows = await new ActionRunDao(db).actionRowsByRunId(runId);
136593
+ const paused = [...rows].reverse().find((row) => row.status === "paused" || row.result_json !== null && /"pause"\s*:\s*true/.test(row.result_json));
136594
+ const pausedAt = paused?.started_at ?? null;
136595
+ if (pausedAt === null)
136596
+ return receipt;
136597
+ const resumedAt = (now ?? new Date).toISOString();
136598
+ const pauseMs = hostDurationMs(pausedAt, resumedAt);
136599
+ if (pauseMs === null)
136600
+ return receipt;
136601
+ const run = await new RunDao(db).traceRowById(runId);
136602
+ let metadata = {};
136603
+ try {
136604
+ metadata = JSON.parse(run?.metadata_json ?? "{}");
136605
+ } catch {
136606
+ metadata = {};
136607
+ }
136608
+ const pauses = appendHostPause(metadata, {
136609
+ node: paused?.node ?? "unknown",
136610
+ action: paused?.kind ?? "agent.run",
136611
+ pausedAt,
136612
+ resumedAt,
136613
+ hostDurationMs: pauseMs,
136614
+ executor: receipt?.executor ?? "host"
136615
+ });
136616
+ await new RunDao(db).mergeMetadata(runId, { [HOST_PAUSES_KEY]: pauses });
136617
+ if (receipt === undefined)
136618
+ return;
136619
+ if (!isDegenerateReceiptInterval(receipt.startedAt, receipt.finishedAt ?? undefined))
136620
+ return receipt;
136621
+ return { ...receipt, startedAt: pausedAt, finishedAt: resumedAt };
136622
+ }
136144
136623
  async resolveHostPauseReceipt(runId, workdir, opts, workflow) {
136145
136624
  const db = await this.ctx.getDb();
136146
136625
  if (!await hostExecutorPersisted(db, runId))
@@ -136372,6 +136851,7 @@ var init_workflow_resume_ops = __esm(() => {
136372
136851
  init_checkpoint_contract();
136373
136852
  init_composition_lint();
136374
136853
  init_host_executor();
136854
+ init_host_pause();
136375
136855
  init_progress_projection();
136376
136856
  init_receipt();
136377
136857
  init_workflow_resolver();
@@ -136636,8 +137116,10 @@ class WorkflowRunOps {
136636
137116
  await dao.cancelRun(runId);
136637
137117
  const after = await dao.traceRowById(runId);
136638
137118
  const finalized = after?.status === "failed" && isNonTerminal;
136639
- if (finalized)
137119
+ if (finalized) {
137120
+ projectRunStateStatus(runStoragePaths(this.ctx.cwd).recordsDir, runId, "failed", new Date().toISOString());
136640
137121
  await releaseWorktreeRunLease(db, this.ctx.cwd, runId);
137122
+ }
136641
137123
  return { runId, finalized, status: after?.status ?? "not_found", killed };
136642
137124
  }
136643
137125
  async stampFailureReason(runId, result) {
@@ -136657,11 +137139,13 @@ var init_workflow_run_ops = __esm(() => {
136657
137139
  init_src2();
136658
137140
  init_dist9();
136659
137141
  init_dist5();
137142
+ init_workflow_run_log_sink();
136660
137143
  init_composition_baseline();
136661
137144
  init_host_executor();
136662
137145
  init_start_state();
136663
137146
  init_workflow_resolver();
136664
137147
  init_event_bridge();
137148
+ init_run_storage();
136665
137149
  init_workflow_run_recording();
136666
137150
  init_workflow_run_vars();
136667
137151
  init_workflow_worktree_lease();
@@ -136722,7 +137206,8 @@ class WorkflowAppService {
136722
137206
  async trace(runIdOrFilter) {
136723
137207
  const db = await this.ctx.getDb();
136724
137208
  if (typeof runIdOrFilter === "string") {
136725
- return this.inspectOps.traceRun(db, runIdOrFilter);
137209
+ const probe = await reapIfWorkerLost(db, this.ctx.cwd, runIdOrFilter);
137210
+ return this.inspectOps.traceRun(db, runIdOrFilter, probe.reaped ? undefined : probe.row);
136726
137211
  }
136727
137212
  return this.inspectOps.traceList(db, runIdOrFilter ?? {});
136728
137213
  }
@@ -138261,8 +138746,10 @@ var init_src4 = __esm(() => {
138261
138746
  init_task_verdict();
138262
138747
  init_token_ledger_service();
138263
138748
  init_token_ledger_watcher();
138749
+ init_verdict_replay();
138264
138750
  init_verify_answer_lint();
138265
138751
  init_warm_turn_dispatch();
138752
+ init_workflow_clean_ops();
138266
138753
  init_workflow_service();
138267
138754
  init_worktree_persist();
138268
138755
  init_write_slot_service();
@@ -145488,81 +145975,6 @@ var init_task_diffstat = __esm(() => {
145488
145975
  init_task_triage();
145489
145976
  });
145490
145977
 
145491
- // ../../packages/app/src/services/sdlc-actions.ts
145492
- function isReservedAction(name) {
145493
- return RESERVED_ACTIONS.includes(name);
145494
- }
145495
- function resolveAction(builder, name) {
145496
- const reserved = isReservedAction(name);
145497
- const declared = builder?.actions?.[name];
145498
- if (declared !== undefined) {
145499
- return Array.isArray(declared) ? { name, reserved, source: "config", kind: "composite", steps: [...declared] } : { name, reserved, source: "config", kind: "command", command: declared };
145500
- }
145501
- if ((builder?.preset ?? "bun") === "bun") {
145502
- if (reserved)
145503
- return { name, reserved, source: "preset", kind: "command", command: BUN_PRESET[name] };
145504
- } else {
145505
- const command = reserved ? STACK_NEUTRAL_DEFAULTS[name] : undefined;
145506
- if (command !== undefined)
145507
- return { name, reserved, source: "default", kind: "command", command };
145508
- }
145509
- return { name, reserved, source: "unbound", kind: "unbound" };
145510
- }
145511
- function listActions(builder) {
145512
- const custom = Object.keys(builder?.actions ?? {}).filter((name) => !isReservedAction(name)).sort();
145513
- return [...RESERVED_ACTIONS, ...custom].map((name) => resolveAction(builder, name));
145514
- }
145515
- function renderScoped(command, values) {
145516
- let rendered = command;
145517
- for (const [placeholder, items] of [
145518
- ["{files}", values.files],
145519
- ["{paths}", values.paths]
145520
- ]) {
145521
- if (!rendered.includes(placeholder))
145522
- continue;
145523
- if (items === undefined)
145524
- throw new Error(`renderScoped: ${placeholder} has no value`);
145525
- if (items.length === 0)
145526
- throw new Error(`renderScoped: ${placeholder} has an empty list`);
145527
- rendered = rendered.replaceAll(placeholder, () => items.map(shQuote2).join(" "));
145528
- }
145529
- if (rendered.includes("{workspace}")) {
145530
- const workspace = values.workspace;
145531
- if (workspace === undefined)
145532
- throw new Error("renderScoped: {workspace} has no value");
145533
- if (workspace.length === 0)
145534
- throw new Error("renderScoped: {workspace} is empty");
145535
- rendered = rendered.replaceAll("{workspace}", () => shQuote2(workspace));
145536
- }
145537
- return rendered;
145538
- }
145539
- var RESERVED_ACTIONS, BUN_PRESET, STACK_NEUTRAL_DEFAULTS;
145540
- var init_sdlc_actions = __esm(() => {
145541
- init_src();
145542
- RESERVED_ACTIONS = SDLC_ACTIONS;
145543
- BUN_PRESET = {
145544
- build: "bun run build",
145545
- dev: "bun run dev",
145546
- clean: "bun run clean",
145547
- format: "bun run format",
145548
- autofix: "bun run autofix",
145549
- lint: "bun run lint",
145550
- typecheck: "bun run typecheck",
145551
- test: "bun run test",
145552
- "test-full": "bun run test:full",
145553
- "test-heavy": "bun run test:heavy",
145554
- "rules-pre": "bun run test-pre-check",
145555
- "rules-post": "bun run test-post-check",
145556
- check: "bun run spur-check",
145557
- "check-full": "bun run spur-check:full",
145558
- "check-feature": "bun run spur-check-feature",
145559
- "lint-changed": "bunx biome check {files}",
145560
- "typecheck-changed": "cd {workspace} && bun run typecheck",
145561
- "test-changed": "cd {workspace} && bun test {paths}"
145562
- };
145563
- STACK_NEUTRAL_DEFAULTS = SDLC_STACK_NEUTRAL_ACTIONS;
145564
- });
145565
-
145566
145978
  // ../../packages/app/src/services/quality-gate.ts
145567
145979
  import { spawnSync as spawnSync6 } from "child_process";
145568
145980
  import { randomUUID as randomUUID12 } from "crypto";
@@ -145583,6 +145995,21 @@ import {
145583
145995
  } from "fs";
145584
145996
  import { homedir as homedir13, tmpdir as tmpdir5 } from "os";
145585
145997
  import { dirname as dirname38, join as join78 } from "path";
145998
+ function resolveGateTimeoutMs(env) {
145999
+ const raw = env[GATE_TIMEOUT_MS_ENV];
146000
+ if (raw === undefined || raw.trim() === "")
146001
+ return { ok: true, ms: GATE_TIMEOUT_MS_DEFAULT };
146002
+ const parsed = Number(raw);
146003
+ if (!Number.isFinite(parsed) || parsed <= 0)
146004
+ return { ok: false, error: `quality-gate: ${GATE_TIMEOUT_MS_ENV} must be a positive number, got "${raw}"` };
146005
+ return { ok: true, ms: Math.trunc(parsed) };
146006
+ }
146007
+ function gateTimeoutMsFor(env, options) {
146008
+ if (options.gateTimeoutMs !== undefined && options.gateTimeoutMs > 0)
146009
+ return options.gateTimeoutMs;
146010
+ const resolved = resolveGateTimeoutMs(env);
146011
+ return resolved.ok ? resolved.ms : GATE_TIMEOUT_MS_DEFAULT;
146012
+ }
145586
146013
  function isTransientLock(attemptOutput) {
145587
146014
  return LOCKED_PATTERN.test(attemptOutput);
145588
146015
  }
@@ -145781,7 +146208,7 @@ function lightScope(changedFiles, exists = existsSync23) {
145781
146208
  tests.add(candidate);
145782
146209
  matched = true;
145783
146210
  }
145784
- } else if (rest.startsWith("tests/") && TEST_FILE_PATTERN.test(rest)) {
146211
+ } else if (rest.startsWith("tests/") && TEST_FILE_PATTERN2.test(rest)) {
145785
146212
  tests.add(file);
145786
146213
  matched = true;
145787
146214
  }
@@ -145798,7 +146225,7 @@ function lightScope(changedFiles, exists = existsSync23) {
145798
146225
  const candidate = `${workspace}/tests/${rest.slice("src/".length).replace(/\.tsx?$/, (ext) => `.test${ext}`)}`;
145799
146226
  if (exists(candidate))
145800
146227
  tests.add(candidate);
145801
- } else if (rest.startsWith("tests/") && TEST_FILE_PATTERN.test(rest)) {
146228
+ } else if (rest.startsWith("tests/") && TEST_FILE_PATTERN2.test(rest)) {
145802
146229
  tests.add(file);
145803
146230
  }
145804
146231
  }
@@ -145845,11 +146272,13 @@ function planLightChecks(scope, hasTypecheck = workspaceHasTypecheck, builder) {
145845
146272
  }
145846
146273
  return plans;
145847
146274
  }
145848
- function gitChangedFiles(cwd) {
146275
+ function gitChangedFiles(cwd, timeoutMs) {
145849
146276
  const abs = (p) => cwd ? join78(cwd, p) : p;
145850
146277
  const changed = new Set;
145851
146278
  for (const cmd of ["git diff --name-only HEAD", "git ls-files --others --exclude-standard"]) {
145852
- const result = runShellCommand(cmd, cwd);
146279
+ const result = runShellCommand(cmd, cwd, undefined, timeoutMs);
146280
+ if (result.timedOut)
146281
+ return null;
145853
146282
  for (const line of result.output.split(`
145854
146283
  `)) {
145855
146284
  const file = line.trim();
@@ -145888,13 +146317,29 @@ function runLightGate(env, options = {}) {
145888
146317
  const logFile = join78(runDir, `${env.wbs}-light-gate.log`);
145889
146318
  const receiptFile = join78(runDir, `${env.wbs}-check-receipt.json`);
145890
146319
  writeFileSync14(abs(logFile), "");
146320
+ const timeoutMs = gateTimeoutMsFor(env, options);
146321
+ const changed = gitChangedFiles(cwd, timeoutMs);
146322
+ if (changed === null) {
146323
+ const line = `--- light gate BLOCKED: changed-path git read timed out after ${timeoutMs}ms
146324
+ `;
146325
+ appendFileSync7(abs(logFile), line);
146326
+ process.stderr.write(line);
146327
+ return {
146328
+ status: "FAIL",
146329
+ scope: { files: [], workspaces: [], tests: [] },
146330
+ checks: [],
146331
+ logFile,
146332
+ receiptFile,
146333
+ blocked: line.trim()
146334
+ };
146335
+ }
145891
146336
  const capture = (options.captureGitTree ?? captureGateGitTree)(cwd ?? process.cwd());
145892
146337
  const digest = capture.ok ? capture.tree : "";
145893
146338
  if (!capture.ok) {
145894
146339
  appendFileSync7(abs(logFile), `--- light receipt: no git tree identity \u2014 ${capture.error}
145895
146340
  `);
145896
146341
  }
145897
- const scope = lightScope(gitChangedFiles(cwd), (p) => existsSync23(abs(p)));
146342
+ const scope = lightScope(changed, (p) => existsSync23(abs(p)));
145898
146343
  const plans = planLightChecks(scope, (workspace) => workspaceHasTypecheck(abs(workspace)), options.builder);
145899
146344
  const reusable = new Map;
145900
146345
  let preserveFullReceipt = false;
@@ -145935,11 +146380,13 @@ function runLightGate(env, options = {}) {
145935
146380
  continue;
145936
146381
  }
145937
146382
  const startedAtMs = Date.now();
145938
- const result = runShellCommand(plan.cmd, cwd);
146383
+ const result = runShellCommand(plan.cmd, cwd, undefined, timeoutMs);
145939
146384
  const durationMs = Date.now() - startedAtMs;
145940
- const status = result.code === 0 ? "PASS" : "FAIL";
145941
- appendFileSync7(abs(logFile), `--- light ${plan.id}: ${status} (${durationMs}ms)
145942
- ${result.output}`);
146385
+ const status = result.code === 0 && !result.timedOut ? "PASS" : "FAIL";
146386
+ const header = result.timedOut ? `--- light ${plan.id}: ${status} (timeout after ${timeoutMs}ms, ${durationMs}ms elapsed)
146387
+ ` : `--- light ${plan.id}: ${status} (${durationMs}ms)
146388
+ `;
146389
+ appendFileSync7(abs(logFile), `${header}${result.output}`);
145943
146390
  checks.push({ id: plan.id, cmd: plan.cmd, status, durationMs, logPath: logFile });
145944
146391
  }
145945
146392
  const receipt = buildReceipt({
@@ -145977,7 +146424,7 @@ function gateSleep(ms) {
145977
146424
  }
145978
146425
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
145979
146426
  }
145980
- function runShellCommand(cmd, cwd, extraEnv) {
146427
+ function runShellCommand(cmd, cwd, extraEnv, timeoutMs) {
145981
146428
  const dir = mkdtempSync(join78(tmpdir5(), "spur-quality-gate-"));
145982
146429
  const path = join78(dir, "output");
145983
146430
  const fd = openSync8(path, "w");
@@ -145985,13 +146432,15 @@ function runShellCommand(cmd, cwd, extraEnv) {
145985
146432
  const result = spawnSync6("sh", ["-c", cmd], {
145986
146433
  cwd,
145987
146434
  stdio: ["ignore", fd, fd],
145988
- env: extraEnv === undefined ? undefined : { ...getEnvVars(), ...extraEnv }
146435
+ env: extraEnv === undefined ? undefined : { ...getEnvVars(), ...extraEnv },
146436
+ ...timeoutMs !== undefined ? { timeout: timeoutMs, killSignal: "SIGKILL" } : {}
145989
146437
  });
145990
- if (result.error !== undefined) {
146438
+ const timedOut = result.error?.code === "ETIMEDOUT" || result.signal === "SIGKILL";
146439
+ if (result.error !== undefined && !timedOut) {
145991
146440
  return { output: `sh -c failed: ${result.error.message}
145992
- `, code: 1 };
146441
+ `, code: 1, timedOut: false };
145993
146442
  }
145994
- return { output: readFileSync21(path, "utf8"), code: result.status ?? 1 };
146443
+ return { output: readFileSync21(path, "utf8"), code: result.status ?? 1, timedOut };
145995
146444
  } finally {
145996
146445
  closeSync8(fd);
145997
146446
  rmSync5(dir, { recursive: true, force: true });
@@ -146205,6 +146654,8 @@ function runQualityGate(mode, env, options = {}) {
146205
146654
  const attemptFile = rel("-test-fix-attempt");
146206
146655
  const abs = (p) => cwd ? join78(cwd, p) : p;
146207
146656
  const priorLog = mode === "recheck" && existsSync23(abs(logFile)) ? readFileSync21(abs(logFile), "utf8") : "";
146657
+ const timeoutMs = gateTimeoutMsFor(env, options);
146658
+ let timedOut;
146208
146659
  writeFileSync14(abs(logFile), "");
146209
146660
  if (mode === "run")
146210
146661
  writeFileSync14(abs(attemptFile), `0
@@ -146259,15 +146710,17 @@ function runQualityGate(mode, env, options = {}) {
146259
146710
  gateRc = 1;
146260
146711
  }
146261
146712
  if (mode === "recheck" && gateRc === 0 && (env.gateProbeCmd ?? "").length > 0) {
146262
- const probe = runShellCommand(env.gateProbeCmd ?? "", cwd);
146713
+ const probe = runShellCommand(env.gateProbeCmd ?? "", cwd, undefined, timeoutMs);
146263
146714
  writeFileSync14(abs(`${logFile}.probe`), probe.output);
146264
146715
  gateRc = probe.code;
146716
+ if (probe.timedOut)
146717
+ timedOut = { cmd: env.gateProbeCmd ?? "" };
146265
146718
  appendFileSync7(abs(logFile), probe.output);
146266
146719
  rmSync5(abs(`${logFile}.probe`), { force: true });
146267
146720
  }
146268
146721
  let gateRuntimeMs = 0;
146269
146722
  let queueWaitMs = 0;
146270
- if (gateRc === 0) {
146723
+ if (gateRc === 0 && timedOut === undefined) {
146271
146724
  const delayMs = retryDelayMs2(env);
146272
146725
  const lockLine = (line) => {
146273
146726
  process.stdout.write(line);
@@ -146281,12 +146734,16 @@ function runQualityGate(mode, env, options = {}) {
146281
146734
  try {
146282
146735
  for (gateAttempt = 1;gateAttempt <= MAX_GATE_ATTEMPTS; gateAttempt++) {
146283
146736
  const attemptLogPath = `${logFile}.attempt-${gateAttempt}`;
146284
- const attempt = runShellCommand(env.qualityGateCmd ?? "", cwd, lock.token.length > 0 ? { [GATE_LOCK_TOKEN_ENV]: lock.token } : undefined);
146737
+ const attempt = runShellCommand(env.qualityGateCmd ?? "", cwd, lock.token.length > 0 ? { [GATE_LOCK_TOKEN_ENV]: lock.token } : undefined, timeoutMs);
146285
146738
  writeFileSync14(abs(attemptLogPath), attempt.output);
146286
146739
  const locked = isTransientLock(attempt.output);
146287
146740
  appendFileSync7(abs(logFile), attempt.output);
146288
146741
  rmSync5(abs(attemptLogPath), { force: true });
146289
146742
  gateRc = attempt.code;
146743
+ if (attempt.timedOut) {
146744
+ timedOut = { cmd: env.qualityGateCmd ?? "" };
146745
+ break;
146746
+ }
146290
146747
  if (gateRc === 0 || !locked || gateAttempt >= MAX_GATE_ATTEMPTS)
146291
146748
  break;
146292
146749
  const line = retryMessage(gateAttempt);
@@ -146322,6 +146779,26 @@ function runQualityGate(mode, env, options = {}) {
146322
146779
  }
146323
146780
  }
146324
146781
  }
146782
+ if (timedOut !== undefined) {
146783
+ const header = `gate timeout after ${timeoutMs}ms: ${timedOut.cmd}
146784
+ `;
146785
+ const tail = tailLines(readFileSync21(abs(logFile), "utf8"), 40);
146786
+ writeFileSync14(abs(findingsFile), `${header}${tail}`);
146787
+ writeFileSync14(abs(statusFile), `BLOCKED
146788
+ `);
146789
+ appendFileSync7(abs(logFile), `git-tree: ${tree.ok ? tree.tree : "unbound"}
146790
+ `);
146791
+ process.stderr.write(`quality gate BLOCKED \u2014 ${header}`);
146792
+ return {
146793
+ status: "BLOCKED",
146794
+ attempts: gateAttempt,
146795
+ logFile,
146796
+ findingsFile,
146797
+ statusFile,
146798
+ attemptFile,
146799
+ receiptFile: undefined
146800
+ };
146801
+ }
146325
146802
  if (gateRc === 0) {
146326
146803
  const bytes = existsSync23(abs(logFile)) ? statSync15(abs(logFile)).size : 0;
146327
146804
  const attemptsLabel = gateAttempt > 0 ? String(gateAttempt) : "";
@@ -146382,12 +146859,19 @@ function qualityGateMain(argv, rawEnv = getEnvVars(), options = {}) {
146382
146859
  return 2;
146383
146860
  }
146384
146861
  const env = { ...rawEnv, wbs };
146862
+ const timeout = resolveGateTimeoutMs(env);
146863
+ if (!timeout.ok) {
146864
+ process.stderr.write(`${timeout.error}
146865
+ `);
146866
+ return 2;
146867
+ }
146868
+ const gateOptions = { ...options, gateTimeoutMs: timeout.ms };
146385
146869
  if (mode === "light") {
146386
- runLightGate(env, options);
146387
- return 0;
146870
+ const light = runLightGate(env, gateOptions);
146871
+ return light.blocked === undefined ? 0 : 2;
146388
146872
  }
146389
146873
  if (mode === "deferred") {
146390
- return runDeferredGate(env, options).status === "DEFERRED" ? 0 : 1;
146874
+ return runDeferredGate(env, gateOptions).status === "DEFERRED" ? 0 : 1;
146391
146875
  }
146392
146876
  if (mode === "status") {
146393
146877
  const runDir = join78(options.cwd ?? ".", ".spur", "run");
@@ -146410,7 +146894,10 @@ function qualityGateMain(argv, rawEnv = getEnvVars(), options = {}) {
146410
146894
  return 0;
146411
146895
  }
146412
146896
  try {
146413
- return runQualityGate(mode, env, options).status === "PASS" ? 0 : 1;
146897
+ const result = runQualityGate(mode, env, gateOptions);
146898
+ if (result.status === "PASS")
146899
+ return 0;
146900
+ return result.status === "BLOCKED" ? 2 : 1;
146414
146901
  } catch (error) {
146415
146902
  if (!(error instanceof GateLockTimeoutError))
146416
146903
  throw error;
@@ -146418,7 +146905,7 @@ function qualityGateMain(argv, rawEnv = getEnvVars(), options = {}) {
146418
146905
  return 2;
146419
146906
  }
146420
146907
  }
146421
- var MAX_GATE_ATTEMPTS = 5, MAX_FINDINGS = 20, RETRY_DELAY_MS_DEFAULT = 1e4, RETRY_DELAY_MS_ENV = "SPUR_QUALITY_GATE_RETRY_DELAY_MS", LOCKED_PATTERN, FINDINGS_PATTERN, COVERAGE_ROW_PATTERN, RECEIPT_SCHEMA_VERSION = "check-receipt/v1", TEST_FILE_PATTERN, GATE_LOCK_DIR_ENV = "SPUR_GATE_LOCK_DIR", GATE_LOCK_POLL_MS_ENV = "SPUR_GATE_LOCK_POLL_MS", GATE_LOCK_OFF_ENV = "SPUR_GATE_LOCK", GATE_LOCK_TOKEN_ENV = "SPUR_GATE_LOCK_TOKEN", GATE_LOCK_POLL_MS_DEFAULT = 5000, GATE_LOCK_TIMEOUT_MS_ENV = "SPUR_GATE_LOCK_TIMEOUT_MS", GATE_LOCK_TIMEOUT_MS_DEFAULT, GATE_LOCK_MKDIR_HOLD_MS_ENV = "SPUR_GATE_LOCK_TEST_MKDIR_HOLD_MS", GATE_LOCK_LOST_RACE_SLEEP_MS = 20, GATE_LOCK_LOST_RACE_LOG_AT = 250, GateLockTimeoutError, QUALITY_GATE_MODES, QUALITY_GATE_USAGE = "usage: spur task gate <wbs> <run|recheck|light|deferred|status> (env: qualityGateCmd, gateProbeCmd, runId)";
146908
+ var MAX_GATE_ATTEMPTS = 5, MAX_FINDINGS = 20, RETRY_DELAY_MS_DEFAULT = 1e4, RETRY_DELAY_MS_ENV = "SPUR_QUALITY_GATE_RETRY_DELAY_MS", GATE_TIMEOUT_MS_ENV = "gateTimeoutMs", GATE_TIMEOUT_MS_DEFAULT = 900000, LOCKED_PATTERN, FINDINGS_PATTERN, COVERAGE_ROW_PATTERN, RECEIPT_SCHEMA_VERSION = "check-receipt/v1", TEST_FILE_PATTERN2, GATE_LOCK_DIR_ENV = "SPUR_GATE_LOCK_DIR", GATE_LOCK_POLL_MS_ENV = "SPUR_GATE_LOCK_POLL_MS", GATE_LOCK_OFF_ENV = "SPUR_GATE_LOCK", GATE_LOCK_TOKEN_ENV = "SPUR_GATE_LOCK_TOKEN", GATE_LOCK_POLL_MS_DEFAULT = 5000, GATE_LOCK_TIMEOUT_MS_ENV = "SPUR_GATE_LOCK_TIMEOUT_MS", GATE_LOCK_TIMEOUT_MS_DEFAULT, GATE_LOCK_MKDIR_HOLD_MS_ENV = "SPUR_GATE_LOCK_TEST_MKDIR_HOLD_MS", GATE_LOCK_LOST_RACE_SLEEP_MS = 20, GATE_LOCK_LOST_RACE_LOG_AT = 250, GateLockTimeoutError, QUALITY_GATE_MODES, QUALITY_GATE_USAGE = "usage: spur task gate <wbs> <run|recheck|light|deferred|status> (env: qualityGateCmd, gateProbeCmd, runId)";
146422
146909
  var init_quality_gate = __esm(() => {
146423
146910
  init_dist();
146424
146911
  init_proof_input_fingerprint();
@@ -146426,7 +146913,7 @@ var init_quality_gate = __esm(() => {
146426
146913
  LOCKED_PATTERN = /SQLiteError: database is locked|SQLite database .*is busy|SQLITE_BUSY/;
146427
146914
  FINDINGS_PATTERN = /[A-Za-z0-9_./-]+\.[A-Za-z]+:[0-9]+/g;
146428
146915
  COVERAGE_ROW_PATTERN = /^\s*(\S+\.[A-Za-z]+)\s*\|\s*([\d.]+)\s*\|\s*([\d.]+)\s*\|\s*(\S*)/;
146429
- TEST_FILE_PATTERN = /\.test\.tsx?$/;
146916
+ TEST_FILE_PATTERN2 = /\.test\.tsx?$/;
146430
146917
  GATE_LOCK_TIMEOUT_MS_DEFAULT = 2 * 60 * 60 * 1000;
146431
146918
  GateLockTimeoutError = class GateLockTimeoutError extends Error {
146432
146919
  constructor(message) {
@@ -148318,6 +148805,17 @@ var require_src10 = __commonJS(function(exports, module) {
148318
148805
  });
148319
148806
 
148320
148807
  // ../../packages/app/src/workflow/step-profile.ts
148808
+ function rowKey(node, actionKind) {
148809
+ return `${node}\x00${actionKind}`;
148810
+ }
148811
+ function hostMsByRow(pauses) {
148812
+ const totals = new Map;
148813
+ for (const pause of pauses) {
148814
+ const key = rowKey(pause.node, pause.action ?? "agent.run");
148815
+ totals.set(key, (totals.get(key) ?? 0) + pause.hostDurationMs);
148816
+ }
148817
+ return totals;
148818
+ }
148321
148819
  function nearestRankP50(values) {
148322
148820
  if (values.length === 0)
148323
148821
  return null;
@@ -148441,11 +148939,27 @@ function buildRows(executions, windowSec) {
148441
148939
  }
148442
148940
  function buildStepProfile(input) {
148443
148941
  const executions = input.runs.flatMap((run) => extractExecutions(run.runId, run.events));
148942
+ const hostTotals = new Map;
148943
+ for (const run of input.runs) {
148944
+ for (const [key, ms] of hostMsByRow(run.hostPauses ?? [])) {
148945
+ hostTotals.set(key, (hostTotals.get(key) ?? 0) + ms);
148946
+ }
148947
+ }
148948
+ const rows = buildRows(executions, input.windowSec).map((row) => {
148949
+ const hostMs = hostTotals.get(rowKey(row.node, row.actionKind));
148950
+ return hostMs === undefined ? row : { ...row, hostMs };
148951
+ });
148952
+ const stalledRuns = nonDryRuns(input.paused ?? []).filter((entry) => entry.stalled === true).map((entry) => ({
148953
+ runId: entry.runId,
148954
+ stalledMs: entry.stalledMs ?? 0,
148955
+ resume: `spur workflow continue ${entry.runId}`
148956
+ }));
148444
148957
  return {
148445
148958
  workflow: input.workflow,
148446
148959
  windowSec: input.windowSec,
148447
148960
  sampledRuns: input.runs.length,
148448
- rows: buildRows(executions, input.windowSec)
148961
+ rows,
148962
+ ...stalledRuns.length > 0 ? { stalledRuns } : {}
148449
148963
  };
148450
148964
  }
148451
148965
  function nonDryRuns(entries) {
@@ -148469,16 +148983,22 @@ function formatStepProfileHuman(profile) {
148469
148983
  `p50=${seconds(row.durationMs.p50)}`.padEnd(12),
148470
148984
  `max=${seconds(row.durationMs.max)}`.padEnd(12),
148471
148985
  `idle=${seconds(row.idleGapMs.p50)}`.padEnd(12),
148986
+ ...row.hostMs === undefined ? [] : [`host=${seconds(row.hostMs)}`.padEnd(12)],
148472
148987
  `session=${row.session ?? "?"}`.padEnd(15),
148473
148988
  `cacheHit=${ratio(row.cacheHit.p50)} (${row.cacheHit.known}/${row.cacheHit.of})`.padEnd(20),
148474
148989
  `flags=${row.flags.length === 0 ? "-" : row.flags.join(",")}`
148475
148990
  ].join(" "));
148476
148991
  }
148992
+ for (const run of profile.stalledRuns ?? [])
148993
+ lines.push(`${run.runId}: ${stalledLabel(run.runId, run.stalledMs)}`);
148477
148994
  return `${lines.join(`
148478
148995
  `)}
148479
148996
  `;
148480
148997
  }
148481
148998
  var DEFAULT_LAST = 20, DEFAULT_WINDOW_SEC = 300;
148999
+ var init_step_profile = __esm(() => {
149000
+ init_host_pause();
149001
+ });
148482
149002
 
148483
149003
  // ../../packages/app/src/workflow/step-profile-cli.ts
148484
149004
  function positiveInt(raw, fallback) {
@@ -148525,6 +149045,12 @@ function runFailure(spurBin, result, what) {
148525
149045
  `).slice(-1)[0] ?? "";
148526
149046
  return failure2(`${what} failed (exit ${result.exitCode}${detail === "" ? "" : `: ${detail}`}) [${spurBin}]`);
148527
149047
  }
149048
+ function parseTraceEntries(stdout) {
149049
+ const parsed = JSON.parse(stdout);
149050
+ if (!Array.isArray(parsed.entries))
149051
+ throw new Error('no "entries" array');
149052
+ return parsed.entries;
149053
+ }
148528
149054
  function runStepProfileCli(argv, defaultSpurBin = "spur", defaultSpurArgv) {
148529
149055
  const spurArgv = argv.includes("--spur-bin") ? undefined : defaultSpurArgv;
148530
149056
  const args = parseStepProfileCliArgs(argv, defaultSpurBin);
@@ -148550,10 +149076,7 @@ ${STEP_PROFILE_USAGE}`);
148550
149076
  return runFailure(args.spurBin, list, `spur ${listArgs.join(" ")}`);
148551
149077
  let entries;
148552
149078
  try {
148553
- const parsed = JSON.parse(list.stdout);
148554
- if (!Array.isArray(parsed.entries))
148555
- throw new Error('no "entries" array');
148556
- entries = parsed.entries;
149079
+ entries = parseTraceEntries(list.stdout);
148557
149080
  } catch (err) {
148558
149081
  return failure2(`spur workflow trace output did not parse as JSON: ${String(err)}`);
148559
149082
  }
@@ -148567,12 +149090,27 @@ ${STEP_PROFILE_USAGE}`);
148567
149090
  const parsed = JSON.parse(timeline.stdout);
148568
149091
  if (!Array.isArray(parsed.events))
148569
149092
  throw new Error('no "events" array');
148570
- runs.push({ runId: entry.runId, events: parsed.events });
149093
+ runs.push({
149094
+ runId: entry.runId,
149095
+ events: parsed.events,
149096
+ ...entry.hostPauses !== undefined ? { hostPauses: entry.hostPauses } : {}
149097
+ });
148571
149098
  } catch (err) {
148572
149099
  return failure2(`spur workflow trace ${entry.runId} output did not parse as JSON: ${String(err)}`);
148573
149100
  }
148574
149101
  }
148575
- const profile = buildStepProfile({ workflow: args.workflow, windowSec: args.windowSec, runs });
149102
+ const pausedArgs = [...listArgs];
149103
+ pausedArgs[pausedArgs.indexOf("done")] = "paused";
149104
+ const pausedList = runSpurJson(args.spurBin, pausedArgs, spurArgv);
149105
+ if (!pausedList.ok)
149106
+ return runFailure(args.spurBin, pausedList, `spur ${pausedArgs.join(" ")}`);
149107
+ let paused;
149108
+ try {
149109
+ paused = parseTraceEntries(pausedList.stdout);
149110
+ } catch (err) {
149111
+ return failure2(`spur workflow trace output did not parse as JSON: ${String(err)}`);
149112
+ }
149113
+ const profile = buildStepProfile({ workflow: args.workflow, windowSec: args.windowSec, runs, paused });
148576
149114
  return {
148577
149115
  exitCode: 0,
148578
149116
  stdout: args.json ? `${JSON.stringify(profile, null, 2)}
@@ -148583,6 +149121,7 @@ ${STEP_PROFILE_USAGE}`);
148583
149121
  var STEP_PROFILE_USAGE;
148584
149122
  var init_step_profile_cli = __esm(() => {
148585
149123
  init_dist5();
149124
+ init_step_profile();
148586
149125
  STEP_PROFILE_USAGE = `usage: spur workflow profile <workflow> [--last <N>] [--window <sec>] [--json] [--spur-bin <cmd>]
148587
149126
 
148588
149127
  Read-only step profile from \`spur workflow trace\`: per node and action kind it reports runs,
@@ -156487,7 +157026,7 @@ var SHARED_OPTIONS = {
156487
157026
  "pi|claude|codex|gemini|opencode|antigravity|openclaw|omp|grok|agy|all"
156488
157027
  ],
156489
157028
  statusDoneFailed: ["--status <status>", "Filter by status: done, failed"],
156490
- statusDoneFailedRunning: ["--status <status>", "Filter by status: done, failed, running"],
157029
+ statusWorkflowRun: ["--status <status>", "Filter by status: done, failed, running, paused, interrupted"],
156491
157030
  untilAgent: ["--until <state>", "Lifecycle state to wait for (repeatable OR)"],
156492
157031
  untilMessage: ["--until <state>", "Wait target for --wait: injected|invoke-exit"],
156493
157032
  verboseWorkflow: ["--verbose", "Include transitions and correlation diagnostics in human progress"],
@@ -159678,7 +160217,7 @@ function registerTaskUpdateCommand(task, context) {
159678
160217
  // src/commands/task/verdict.ts
159679
160218
  init_src4();
159680
160219
  function registerTaskVerdictCommand(task, context) {
159681
- task.command("verdict").summary("Derive PASS/PARTIAL/FAIL/UNKNOWN verdict from verify answer text (replaces pipeline grep/shell).").argument("<wbs>", "Task WBS number").option("--from-answer <path>", "Path to verify answer text file").option(...SHARED_OPTIONS.folderTasks).option(...SHARED_OPTIONS.json).option(...SHARED_OPTIONS.jsonEnvelope).action(async (wbs, options) => {
160220
+ task.command("verdict").summary("Derive PASS/PARTIAL/FAIL/UNKNOWN verdict from verify answer text (replaces pipeline grep/shell).").argument("<wbs>", "Task WBS number").option("--from-answer <path>", "Path to verify answer text file").option("--replay", "Re-execute the test files the verdict rows cite; a failure downgrades PASS to PARTIAL").option(...SHARED_OPTIONS.folderTasks).option(...SHARED_OPTIONS.json).option(...SHARED_OPTIONS.jsonEnvelope).action(async (wbs, options) => {
159682
160221
  await Promise.resolve().then(() => init_src4());
159683
160222
  const answerPath = options.fromAnswer ?? `.spur/run/${wbs}-verify-answer.txt`;
159684
160223
  let answerText;
@@ -159697,6 +160236,12 @@ function registerTaskVerdictCommand(task, context) {
159697
160236
  featuresDir: context.fs.resolve(featuresDir)
159698
160237
  };
159699
160238
  const lintFindings = await lintVerifyAnswerForTask(wbs, answerText, corpusDeps);
160239
+ const lintPath = `.spur/run/${wbs}-verdict-lint.txt`;
160240
+ const clearLintFile = async () => {
160241
+ try {
160242
+ await context.fs.deleteFile(lintPath);
160243
+ } catch {}
160244
+ };
159700
160245
  if (lintFindings.length > 0) {
159701
160246
  if (options.json) {
159702
160247
  context.output.write(JSON.stringify({ wbs, lintFindings }, null, 2));
@@ -159705,9 +160250,14 @@ function registerTaskVerdictCommand(task, context) {
159705
160250
  for (const f of lintFindings)
159706
160251
  context.output.error(` line ${f.line}: ${f.message}`);
159707
160252
  }
160253
+ await context.fs.ensureDir(".spur/run");
160254
+ await context.fs.writeFile(lintPath, `${lintFindings.map((f) => `line ${f.line}: ${f.message}`).join(`
160255
+ `)}
160256
+ `);
159708
160257
  context.setExitCode(1);
159709
160258
  return;
159710
160259
  }
160260
+ await clearLintFile();
159711
160261
  const taskCheckPassed = true;
159712
160262
  const taskAc = await readTaskAcceptanceCriteria(wbs, corpusDeps);
159713
160263
  const result = deriveVerdict(answerText, taskCheckPassed, taskAc);
@@ -159717,7 +160267,19 @@ function registerTaskVerdictCommand(task, context) {
159717
160267
  context.setExitCode(1);
159718
160268
  return;
159719
160269
  }
159720
- const jsonOut = JSON.stringify({ wbs, ...result, source: "spur-task-verdict" }, null, 2);
160270
+ const replay = options.replay === true ? await replayCitedTests([...result.requirements, ...result.acceptanceCriteria ?? []], {
160271
+ cwd: context.cwd,
160272
+ builder: context.spurConfig?.builder
160273
+ }) : undefined;
160274
+ const downgraded = replay !== undefined && replay.findings.length > 0 && result.verdict === "PASS";
160275
+ if (downgraded)
160276
+ result.verdict = "PARTIAL";
160277
+ const jsonOut = JSON.stringify({
160278
+ wbs,
160279
+ ...result,
160280
+ ...replay !== undefined ? { replay } : {},
160281
+ source: "spur-task-verdict"
160282
+ }, null, 2);
159721
160283
  await context.fs.ensureDir(".spur/run");
159722
160284
  await context.fs.writeFile(`.spur/run/${wbs}-verdict.json`, `${jsonOut}
159723
160285
  `);
@@ -159725,6 +160287,12 @@ function registerTaskVerdictCommand(task, context) {
159725
160287
  context.output.write(jsonOut);
159726
160288
  } else {
159727
160289
  context.output.write(`Verdict: ${result.verdict} (${result.requirements.length} requirements, ${result.checks.length} checks)`);
160290
+ if (replay !== undefined) {
160291
+ const report = formatReplayReport(replay);
160292
+ context.output.write(report.summary);
160293
+ for (const line of report.findings)
160294
+ context.output.error(line);
160295
+ }
159728
160296
  }
159729
160297
  if (result.verdict !== "PASS") {
159730
160298
  context.setExitCode(1);
@@ -164625,7 +165193,7 @@ init_src2();
164625
165193
  var CLI_CONFIG = {
164626
165194
  binaryName: "spur",
164627
165195
  binaryLabel: "spur",
164628
- binaryVersion: "0.4.26",
165196
+ binaryVersion: "0.4.28",
164629
165197
  configDir: ".spur",
164630
165198
  configFile: ".spur/config.yaml",
164631
165199
  databaseFile: ".spur/spur.db"
@@ -172364,7 +172932,7 @@ init_dist5();
172364
172932
  import { homedir as homedir17 } from "os";
172365
172933
  import { basename as basename23, join as join95, relative as relative19, resolve as resolve37 } from "path";
172366
172934
  // ../server/package.json
172367
- var version4 = "0.4.26";
172935
+ var version4 = "0.4.28";
172368
172936
 
172369
172937
  // ../server/src/modules/health/index.ts
172370
172938
  var startedAt = Date.now();
@@ -177922,6 +178490,7 @@ init_src2();
177922
178490
  init_dist5();
177923
178491
  init_dist2();
177924
178492
  init_composition_baseline();
178493
+ init_host_pause();
177925
178494
  init_run_measurements();
177926
178495
  init_terminal_reason();
177927
178496
  init_run_measurements();
@@ -177959,9 +178528,13 @@ async function buildReportJson(db, workflows, opts = {}) {
177959
178528
  function renderStateLines(rows) {
177960
178529
  return rows.map((s) => {
177961
178530
  const wall = s.wallMsP50 === null || s.wallMsP90 === null ? "n/a" : `p50=${s.wallMsP50}ms p90=${s.wallMsP90}ms`;
177962
- return `${s.workflow}/${s.state}: visits=${s.visits} agent.run=${s.agentRunCount} wall=${wall} retries=${s.retries}`;
178531
+ const host = s.hostMs === undefined ? "" : ` host=${s.hostMs}ms`;
178532
+ return `${s.workflow}/${s.state}: visits=${s.visits} agent.run=${s.agentRunCount} wall=${wall}${host} retries=${s.retries}`;
177963
178533
  });
177964
178534
  }
178535
+ function renderStalledLines(rows) {
178536
+ return rows.map((r) => `${r.workflow}: ${stalledLabel(r.runId, r.stalledMs)}`);
178537
+ }
177965
178538
  function renderWorkflowLines(rows) {
177966
178539
  return rows.map((m) => {
177967
178540
  const excluded = m.dryRuns > 0 || m.nonTerminalRuns > 0 ? ` [excluded ${m.dryRuns} dry, ${m.nonTerminalRuns} non-terminal]` : "";
@@ -178177,6 +178750,11 @@ function registerWorkflowCostCommand(workflow, context, deps = {}) {
178177
178750
  context.output.write(line);
178178
178751
  }
178179
178752
  }
178753
+ if (options.json !== true) {
178754
+ for (const line of renderStalledLines(await readStalledRuns(db, targets))) {
178755
+ context.output.write(line);
178756
+ }
178757
+ }
178180
178758
  } finally {
178181
178759
  db.close();
178182
178760
  }
@@ -178537,8 +179115,10 @@ function formatWorkflowProgress(projection) {
178537
179115
  }
178538
179116
  function registerWorkflowProgressCommand(workflow, context) {
178539
179117
  workflow.command("progress").description("Project the structured progress of a workflow run: current state, per-action attempts, and candidate next transitions.").argument("<run-id>", "Run id to project").option(...SHARED_OPTIONS.jsonSupported).option(...SHARED_OPTIONS.jsonEnvelope).action(async (runId, options) => {
179118
+ const db = await context.getDb();
179119
+ await reapIfWorkerLost(db, context.cwd, runId);
178540
179120
  const projection = await projectWorkflowProgress(runId, {
178541
- db: await context.getDb(),
179121
+ db,
178542
179122
  projectRoot: context.cwd
178543
179123
  });
178544
179124
  if (projection.diagnostics.some((diagnostic) => diagnostic.code === "orphan-row")) {
@@ -179152,6 +179732,7 @@ init_src4();
179152
179732
  import { closeSync as closeSync10, fstatSync as fstatSync5, openSync as openSync10, readFileSync as readFileSync32, readSync as readSync3 } from "fs";
179153
179733
  import { join as join107 } from "path";
179154
179734
  import { setTimeout as sleep7 } from "timers/promises";
179735
+ var TRACE_STATUSES = ["done", "failed", "running", "paused", "interrupted"];
179155
179736
  function formatTraceList2(result) {
179156
179737
  if (result.entries.length === 0) {
179157
179738
  return "No workflow runs.";
@@ -179180,6 +179761,9 @@ function formatTraceTimeline(result) {
179180
179761
  `Outcome: ${run.outcome} Events: ${events.length}`,
179181
179762
  ""
179182
179763
  ];
179764
+ if (run.stalled === true && run.stalledMs !== undefined) {
179765
+ lines.splice(4, 0, `Stalled: paused ${Math.round(run.stalledMs / 1000)}s \u2014 resume: spur workflow continue ${run.runId}`);
179766
+ }
179183
179767
  if (run.nextAction !== undefined)
179184
179768
  lines.splice(4, 0, `Next: ${run.nextAction.label} \u2014 ${run.nextAction.value}`);
179185
179769
  if (result.outputArtifact !== undefined) {
@@ -179238,6 +179822,14 @@ function formatTimelineEvent(event) {
179238
179822
  function followDeadlineLine(runId, elapsedMs, status) {
179239
179823
  return `Follow timed out after ${elapsedMs}ms: run ${runId} status=${status} \u2014 run continues; resume with spur workflow trace ${runId} --follow`;
179240
179824
  }
179825
+ function followFinalizedLine(timeline) {
179826
+ const run = timeline.run;
179827
+ const reason = run.failureReason ? ` \u2014 ${run.failureReason}` : "";
179828
+ const duration = run.durationMs === null ? "unavailable" : `${run.durationMs}ms`;
179829
+ const next = run.nextAction === undefined ? "" : `
179830
+ Next: ${run.nextAction.label} \u2014 ${run.nextAction.value}`;
179831
+ return `Run finalized: ${run.status}${reason} \u2014 outcome=${run.outcome} duration=${duration}${next}`;
179832
+ }
179241
179833
  async function followTrace(service, runId, pollMs, write, wait = (ms) => sleep7(ms), now = Date.now, deadlineMs) {
179242
179834
  const start = now();
179243
179835
  const deadline = deadlineMs === undefined ? Number.POSITIVE_INFINITY : start + deadlineMs;
@@ -179260,8 +179852,10 @@ async function followTrace(service, runId, pollMs, write, wait = (ms) => sleep7(
179260
179852
  }
179261
179853
  write(formatTraceTimeline(timeline));
179262
179854
  const seen = new Set(timeline.events.map((event) => JSON.stringify(event)));
179263
- if (isTerminalTraceStatus(timeline.run.status))
179855
+ if (isTerminalTraceStatus(timeline.run.status)) {
179856
+ write(followFinalizedLine(timeline));
179264
179857
  return false;
179858
+ }
179265
179859
  while (true) {
179266
179860
  await wait(pollMs);
179267
179861
  const next = await service.trace(runId);
@@ -179273,12 +179867,7 @@ async function followTrace(service, runId, pollMs, write, wait = (ms) => sleep7(
179273
179867
  write(formatTimelineEvent(event));
179274
179868
  }
179275
179869
  if (isTerminalTraceStatus(next.run.status)) {
179276
- const reason = next.run.failureReason ? ` \u2014 ${next.run.failureReason}` : "";
179277
- const duration = next.run.durationMs === null ? "unavailable" : `${next.run.durationMs}ms`;
179278
- write(`Run finalized: ${next.run.status}${reason} \u2014 outcome=${next.run.outcome} duration=${duration}`);
179279
- if (next.run.nextAction !== undefined) {
179280
- write(`Next: ${next.run.nextAction.label} \u2014 ${next.run.nextAction.value}`);
179281
- }
179870
+ write(followFinalizedLine(next));
179282
179871
  return false;
179283
179872
  }
179284
179873
  const at = now();
@@ -179410,7 +179999,7 @@ function formatTokens2(n) {
179410
179999
  return String(n);
179411
180000
  }
179412
180001
  function registerWorkflowTraceCommand(workflow, context) {
179413
- workflow.command("trace").description("Show persisted workflow run history.").argument("[run-id]", "Run ID for per-run timeline detail").option("--workflow <name>", "Filter by workflow name").option(...SHARED_OPTIONS.statusDoneFailedRunning).option(...SHARED_OPTIONS.since).option(...SHARED_OPTIONS.last, "20").option("--follow", "Replay a run timeline and poll persisted state until it becomes terminal").option(...SHARED_OPTIONS.pollWorkflow, "1000").option("--output", "With --follow: stream .spur/memory/runs/<RUNID>.md instead of the DB timeline").option(...SHARED_OPTIONS.timeout).option(...SHARED_OPTIONS.jsonSupported).option(...SHARED_OPTIONS.jsonEnvelope).action(async (runId, options) => {
180002
+ workflow.command("trace").description("Show persisted workflow run history.").argument("[run-id]", "Run ID for per-run timeline detail").option("--workflow <name>", "Filter by workflow name").option(...SHARED_OPTIONS.statusWorkflowRun).option(...SHARED_OPTIONS.since).option(...SHARED_OPTIONS.last, "20").option("--follow", "Replay a run timeline and poll persisted state until it becomes terminal").option(...SHARED_OPTIONS.pollWorkflow, "1000").option("--output", "With --follow: stream .spur/memory/runs/<RUNID>.md instead of the DB timeline").option(...SHARED_OPTIONS.timeout).option(...SHARED_OPTIONS.jsonSupported).option(...SHARED_OPTIONS.jsonEnvelope).action(async (runId, options) => {
179414
180003
  const svc = makeWorkflowService(context);
179415
180004
  const last = parseInt(options.last, 10);
179416
180005
  if (Number.isNaN(last) || last < 1) {
@@ -179455,8 +180044,8 @@ function registerWorkflowTraceCommand(workflow, context) {
179455
180044
  context.setExitCode(1);
179456
180045
  return;
179457
180046
  }
179458
- if (options.status !== undefined && !["done", "failed", "running"].includes(options.status)) {
179459
- writeJsonError(context.output, options, "--status must be one of: done, failed, running", "VALIDATION_FAILED");
180047
+ if (options.status !== undefined && !TRACE_STATUSES.includes(options.status)) {
180048
+ writeJsonError(context.output, options, `--status must be one of: ${TRACE_STATUSES.join(", ")}`, "VALIDATION_FAILED");
179460
180049
  context.setExitCode(1);
179461
180050
  return;
179462
180051
  }