@mcrescenzo/opencode-workflows 0.1.0

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.
Files changed (71) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/CODE_OF_CONDUCT.md +134 -0
  3. package/CONTRIBUTING.md +95 -0
  4. package/LICENSE +21 -0
  5. package/README.md +746 -0
  6. package/SECURITY.md +38 -0
  7. package/commands/repo-bughunt.md +94 -0
  8. package/commands/repo-review.md +148 -0
  9. package/commands/workflow-live-gates-release-check.md +143 -0
  10. package/docs/workflow-plugin.md +400 -0
  11. package/opencode-workflows.js +5 -0
  12. package/package.json +86 -0
  13. package/skills/opencode-workflow-authoring/SKILL.md +180 -0
  14. package/skills/repo-review-command-protocol/SKILL.md +56 -0
  15. package/skills/workflow-model-tiering/SKILL.md +57 -0
  16. package/skills/workflow-plan-review/SKILL.md +91 -0
  17. package/workflow-kernel/approval-hashing.js +39 -0
  18. package/workflow-kernel/async-util.js +33 -0
  19. package/workflow-kernel/audited-shell-policy.js +200 -0
  20. package/workflow-kernel/authority-policy.js +670 -0
  21. package/workflow-kernel/budget-accounting.js +142 -0
  22. package/workflow-kernel/capability-adapter.js +753 -0
  23. package/workflow-kernel/child-agent-runner.js +1264 -0
  24. package/workflow-kernel/constants.js +117 -0
  25. package/workflow-kernel/diagnostics.js +152 -0
  26. package/workflow-kernel/drain-runtime.js +421 -0
  27. package/workflow-kernel/errors.js +181 -0
  28. package/workflow-kernel/event-journal.js +487 -0
  29. package/workflow-kernel/extension-registry.js +144 -0
  30. package/workflow-kernel/free-text-redactor.js +91 -0
  31. package/workflow-kernel/gate-shapes.js +82 -0
  32. package/workflow-kernel/git-util.js +45 -0
  33. package/workflow-kernel/index.js +72 -0
  34. package/workflow-kernel/integration-mode.js +155 -0
  35. package/workflow-kernel/lane-effort-policy.js +134 -0
  36. package/workflow-kernel/lifecycle-control.js +608 -0
  37. package/workflow-kernel/live-gate-probes.js +916 -0
  38. package/workflow-kernel/notification-toast-cards.js +393 -0
  39. package/workflow-kernel/notification-toast-policy.js +179 -0
  40. package/workflow-kernel/notification-toast-scope.js +100 -0
  41. package/workflow-kernel/notification-toast.js +287 -0
  42. package/workflow-kernel/path-policy.js +219 -0
  43. package/workflow-kernel/result-readback.js +106 -0
  44. package/workflow-kernel/role-template-loading.js +606 -0
  45. package/workflow-kernel/run-context.js +139 -0
  46. package/workflow-kernel/run-observability.js +43 -0
  47. package/workflow-kernel/run-store-fs.js +231 -0
  48. package/workflow-kernel/run-store-locks.js +180 -0
  49. package/workflow-kernel/run-store-projections.js +421 -0
  50. package/workflow-kernel/run-store-rehydrate.js +68 -0
  51. package/workflow-kernel/run-store-state.js +147 -0
  52. package/workflow-kernel/run-store-status-format.js +1154 -0
  53. package/workflow-kernel/run-store-status.js +107 -0
  54. package/workflow-kernel/sandbox-executor.js +1131 -0
  55. package/workflow-kernel/session-access.js +56 -0
  56. package/workflow-kernel/structured-output.js +143 -0
  57. package/workflow-kernel/test-fix-drain-adapter.js +119 -0
  58. package/workflow-kernel/text-json.js +136 -0
  59. package/workflow-kernel/workflow-plugin.js +3017 -0
  60. package/workflow-kernel/workflow-source.js +444 -0
  61. package/workflow-kernel/worktree-adapter.js +256 -0
  62. package/workflow-kernel/worktree-git.js +147 -0
  63. package/workflows/repo-bughunt.js +362 -0
  64. package/workflows/repo-cleanup.js +383 -0
  65. package/workflows/repo-complexity.js +404 -0
  66. package/workflows/repo-deps.js +457 -0
  67. package/workflows/repo-modernize.js +395 -0
  68. package/workflows/repo-perf.js +394 -0
  69. package/workflows/repo-review.js +831 -0
  70. package/workflows/repo-security-audit.js +466 -0
  71. package/workflows/repo-test-gaps.js +377 -0
@@ -0,0 +1,393 @@
1
+ import { addTokens, zeroTokens } from "./budget-accounting.js";
2
+ import { redactFreeTextSecrets } from "./free-text-redactor.js";
3
+ import { deriveScopeItemProgress } from "./notification-toast-scope.js";
4
+ import { stableStringify, truncateText } from "./text-json.js";
5
+
6
+ const CARD_LINE_MAX = 44;
7
+ const CARD_MAX_LANES = 4;
8
+ const LANE_IDLE_MS = 2 * 60 * 1000;
9
+ const STALE_PROGRESS_THRESHOLD_MS = 10 * 60 * 1000;
10
+ const ACTIVE_LANE_STATUSES = new Set(["worktree-created", "running", "committed", "permission-mismatch"]);
11
+ const FAILURE_OUTCOMES = ["failure", "timeout", "cancelled", "budget_stopped"];
12
+
13
+ const GLYPHS = {
14
+ unicode: {
15
+ play: "▶",
16
+ running: "⟳",
17
+ warning: "⚠",
18
+ success: "✓",
19
+ failure: "✗",
20
+ queued: "⧗",
21
+ log: "»",
22
+ phase: "▸",
23
+ root: "└",
24
+ branch: "├",
25
+ last: "└",
26
+ sep: " · ",
27
+ dash: " — ",
28
+ },
29
+ ascii: {
30
+ play: ">",
31
+ running: "~",
32
+ warning: "!",
33
+ success: "ok",
34
+ failure: "x",
35
+ queued: "q",
36
+ log: ">",
37
+ phase: ">",
38
+ root: "\\",
39
+ branch: "+",
40
+ last: "\\",
41
+ sep: " | ",
42
+ dash: " - ",
43
+ },
44
+ };
45
+
46
+ function glyphs(options = {}) {
47
+ return options.ascii === true ? GLYPHS.ascii : GLYPHS.unicode;
48
+ }
49
+
50
+ function cleanText(value, max = 80) {
51
+ return truncateText(redactFreeTextSecrets(String(value ?? "").replace(/\s+/g, " ").trim()), max);
52
+ }
53
+
54
+ function line(text, options = {}) {
55
+ return truncateText(String(text ?? ""), Number.isFinite(options.maxLine) ? options.maxLine : CARD_LINE_MAX);
56
+ }
57
+
58
+ function compactDuration(ms) {
59
+ if (!Number.isFinite(ms) || ms < 0) return "-";
60
+ const seconds = Math.floor(ms / 1000);
61
+ if (seconds < 60) return `${seconds}s`;
62
+ const minutes = Math.floor(seconds / 60);
63
+ if (minutes < 60) return `${minutes}m${seconds % 60 ? `${seconds % 60}s` : ""}`;
64
+ const hours = Math.floor(minutes / 60);
65
+ return `${hours}h${minutes % 60}m`;
66
+ }
67
+
68
+ function parseTime(value) {
69
+ const parsed = typeof value === "string" ? Date.parse(value) : Number.NaN;
70
+ return Number.isFinite(parsed) ? parsed : undefined;
71
+ }
72
+
73
+ function laneRecordsForCards(run) {
74
+ if (run?.laneRecords instanceof Map) return [...run.laneRecords.values()];
75
+ return Array.isArray(run?.laneRecords) ? run.laneRecords : [];
76
+ }
77
+
78
+ function workflowDisplayName(run) {
79
+ const name = typeof run?.meta?.name === "string" ? run.meta.name.trim() : "";
80
+ return name || "unnamed";
81
+ }
82
+
83
+ function workflowQueuedAgents(run) {
84
+ return run?.waitingAgents?.length ?? run?.queuedAgents ?? 0;
85
+ }
86
+
87
+ function workflowTotalCost(run) {
88
+ const cost = Number(run?.cost ?? 0) + Number(run?.replayedCost ?? 0);
89
+ return Number.isFinite(cost) ? cost : 0;
90
+ }
91
+
92
+ function workflowTotalTokens(run) {
93
+ const total = addTokens(run?.tokens ?? zeroTokens(), run?.replayedTokens ?? zeroTokens());
94
+ return (total.input ?? 0) + (total.output ?? 0) + (total.reasoning ?? 0);
95
+ }
96
+
97
+ function workflowCostLabel(run) {
98
+ return workflowTotalCost(run).toFixed(4).replace(/\.?0+$/, "") || "0";
99
+ }
100
+
101
+ function budgetPercent(run, tokens, cost) {
102
+ const values = [];
103
+ const maxTokens = Number(run?.budgetCeilings?.maxTokens);
104
+ if (Number.isFinite(maxTokens) && maxTokens > 0) values.push((tokens / maxTokens) * 100);
105
+ const maxCost = Number(run?.budgetCeilings?.maxCost);
106
+ if (Number.isFinite(maxCost) && maxCost > 0) values.push((cost / maxCost) * 100);
107
+ if (values.length === 0) return undefined;
108
+ return Math.max(0, Math.round(Math.max(...values)));
109
+ }
110
+
111
+ function phaseSnapshot(run) {
112
+ const phases = Array.isArray(run?.meta?.phases) ? run.meta.phases.map((item) => String(item)) : [];
113
+ const name = run?.currentPhase ? String(run.currentPhase) : phases[0] || "phase";
114
+ const index = phases.indexOf(name);
115
+ return {
116
+ name,
117
+ phases,
118
+ index: index >= 0 ? index : undefined,
119
+ total: phases.length || undefined,
120
+ label: index >= 0 && phases.length > 0 ? `${name} (${index + 1}/${phases.length})` : name,
121
+ };
122
+ }
123
+
124
+ function laneLabel(record) {
125
+ const value = record?.taskSummary || record?.title || record?.label || record?.callId || "lane";
126
+ return cleanText(value, 28);
127
+ }
128
+
129
+ function laneSnapshot(record, now) {
130
+ const startedMs = parseTime(record?.startedAt);
131
+ const lastActivityMs = parseTime(record?.lastActivityAt ?? record?.updatedAt ?? record?.completedAt ?? record?.startedAt);
132
+ const status = String(record?.outcome ?? record?.status ?? "unknown");
133
+ const ageMs = Number.isFinite(startedMs) ? Math.max(0, now - startedMs) : undefined;
134
+ const idleMs = Number.isFinite(lastActivityMs) ? Math.max(0, now - lastActivityMs) : undefined;
135
+ return {
136
+ callId: record?.callId,
137
+ label: laneLabel(record),
138
+ status,
139
+ outcome: record?.outcome,
140
+ ageMs,
141
+ idleMs,
142
+ age: compactDuration(ageMs),
143
+ idle: Number.isFinite(idleMs) && idleMs >= LANE_IDLE_MS,
144
+ attempt: Number.isInteger(record?.attempt) ? record.attempt : undefined,
145
+ maxAttempts: Number.isInteger(record?.maxAttempts) ? record.maxAttempts : undefined,
146
+ errorSummary: record?.errorSummary ? cleanText(record.errorSummary, 36) : undefined,
147
+ retryable: record?.retryable,
148
+ };
149
+ }
150
+
151
+ function activeLaneRows(lanes, max = CARD_MAX_LANES) {
152
+ const active = lanes.filter((lane) => ACTIVE_LANE_STATUSES.has(lane.status) && !lane.outcome);
153
+ const sorted = active.sort((a, b) => (b.ageMs ?? 0) - (a.ageMs ?? 0));
154
+ const selected = sorted.slice(0, max);
155
+ for (const idle of sorted.filter((lane) => lane.idle)) {
156
+ if (selected.some((item) => item.callId === idle.callId)) continue;
157
+ const replaceAt = selected.findLastIndex((lane) => !lane.idle);
158
+ if (replaceAt === -1) break;
159
+ selected[replaceAt] = idle;
160
+ }
161
+ return selected.sort((a, b) => (b.ageMs ?? 0) - (a.ageMs ?? 0));
162
+ }
163
+
164
+ function latestLog(run) {
165
+ const logs = Array.isArray(run?.recentLogs) ? run.recentLogs : [];
166
+ return logs.length ? cleanText(logs.at(-1), 58) : undefined;
167
+ }
168
+
169
+ function stalenessSnapshot(run, lanes, now) {
170
+ const candidates = [parseTime(run?.startedAt), parseTime(run?.resumedAt), parseTime(run?.lastEventAt)];
171
+ for (const lane of lanes) {
172
+ candidates.push(parseTime(lane.lastActivityAt ?? lane.updatedAt ?? lane.completedAt ?? lane.startedAt));
173
+ }
174
+ const latest = candidates.filter(Number.isFinite).sort((a, b) => b - a)[0];
175
+ const ageMs = Number.isFinite(latest) ? Math.max(0, now - latest) : null;
176
+ return {
177
+ ageMs,
178
+ thresholdMs: STALE_PROGRESS_THRESHOLD_MS,
179
+ stale: run?.status === "running" && ageMs !== null && ageMs > STALE_PROGRESS_THRESHOLD_MS,
180
+ };
181
+ }
182
+
183
+ function workflowToastCardSnapshot(run, options = {}) {
184
+ const now = Number.isFinite(options.now) ? options.now : Date.now();
185
+ const startedMs = parseTime(run?.startedAt);
186
+ const finishedMs = parseTime(run?.finishedAt);
187
+ const endMs = Number.isFinite(finishedMs) ? finishedMs : now;
188
+ const tokens = workflowTotalTokens(run);
189
+ const cost = workflowTotalCost(run);
190
+ const laneRecords = laneRecordsForCards(run);
191
+ const lanes = laneRecords.map((record) => laneSnapshot(record, now));
192
+ const outcomes = run?.laneOutcomes ?? {};
193
+ const failed = FAILURE_OUTCOMES.reduce((sum, key) => sum + (Number(outcomes[key]) || 0), 0);
194
+ return {
195
+ id: run?.id,
196
+ shortId: String(run?.id ?? "").slice(0, 8),
197
+ name: workflowDisplayName(run),
198
+ status: run?.status ?? "unknown",
199
+ elapsedMs: Number.isFinite(startedMs) ? Math.max(0, endMs - startedMs) : undefined,
200
+ elapsed: Number.isFinite(startedMs) ? compactDuration(endMs - startedMs) : "-",
201
+ elapsedBucket: Number.isFinite(startedMs) ? Math.floor((endMs - startedMs) / 30_000) : 0,
202
+ phase: phaseSnapshot(run),
203
+ agentsStarted: run?.agentsStarted ?? 0,
204
+ activeAgents: run?.activeAgents ?? 0,
205
+ queuedAgents: workflowQueuedAgents(run),
206
+ outcomes,
207
+ done: Number(outcomes.success) || 0,
208
+ failed,
209
+ droppedLaneCount: run?.droppedLaneCount ?? 0,
210
+ tokens,
211
+ tokenLabel: tokens >= 1000 ? `${Math.round(tokens / 1000)}k` : String(tokens),
212
+ cost,
213
+ costLabel: workflowCostLabel(run),
214
+ budgetPercent: budgetPercent(run, tokens, cost),
215
+ lanes,
216
+ activeLanes: activeLaneRows(lanes, CARD_MAX_LANES),
217
+ itemProgress: deriveScopeItemProgress(laneRecords),
218
+ recentLog: latestLog(run),
219
+ staleness: stalenessSnapshot(run, laneRecords, now),
220
+ recoveredCount: lanes.filter((lane) => lane.status === "success" || lane.status === "completed").filter((lane) => (lane.attempt ?? 1) > 1).length,
221
+ error: run?.error ? cleanText(run.error, 80) : undefined,
222
+ diffPlanHash: run?.editPlan?.diffPlanHash,
223
+ patchCount: Array.isArray(run?.editPlan?.patches) ? run.editPlan.patches.length : run?.editPlan?.patchCount,
224
+ };
225
+ }
226
+
227
+ function counterLine(snapshot, g) {
228
+ const parts = [`done ${snapshot.done}`, `queued ${snapshot.queuedAgents}`];
229
+ if (snapshot.failed > 0) parts.push(`fail ${snapshot.failed}`);
230
+ if (snapshot.droppedLaneCount > 0) parts.push(`drop ${snapshot.droppedLaneCount}`);
231
+ return parts.join(g.sep);
232
+ }
233
+
234
+ function progressLine(snapshot, g) {
235
+ const parts = [];
236
+ if (snapshot.itemProgress) parts.push(`items ${snapshot.itemProgress.done}/${snapshot.itemProgress.total}`);
237
+ if (Number.isFinite(snapshot.budgetPercent)) parts.push(`budget ${snapshot.budgetPercent}%`);
238
+ return parts.length ? parts.join(g.sep) : undefined;
239
+ }
240
+
241
+ function renderWorkflowHeartbeatCard(snapshot, options = {}) {
242
+ const g = glyphs(options);
243
+ const title = line(`${g.play} ${snapshot.name}${g.sep}${snapshot.elapsed}`, options);
244
+ const lines = [
245
+ `${g.root} ${snapshot.phase.label}`,
246
+ ];
247
+ snapshot.activeLanes.forEach((lane, index) => {
248
+ const branch = index === snapshot.activeLanes.length - 1 ? g.last : g.branch;
249
+ lines.push(` ${branch} ${g.running} ${lane.label} ${lane.age}${lane.idle ? `${g.sep}${g.warning}idle` : ""}`);
250
+ });
251
+ lines.push(` ${counterLine(snapshot, g)}`);
252
+ const progress = progressLine(snapshot, g);
253
+ if (progress) lines.push(` ${progress}`);
254
+ if (snapshot.recentLog) lines.push(`${g.log} ${snapshot.recentLog}`);
255
+ return {
256
+ variant: "info",
257
+ title,
258
+ message: lines.map((item) => line(item, options)).join("\n"),
259
+ };
260
+ }
261
+
262
+ function statusCounters(snapshot, g) {
263
+ return `${g.success}${snapshot.done} ${g.running}${snapshot.activeAgents} ${g.queued}${snapshot.queuedAgents}`;
264
+ }
265
+
266
+ function renderWorkflowProblemCard(snapshot, problem = {}, options = {}) {
267
+ const g = glyphs(options);
268
+ const kind = problem.kind || "lane-failure";
269
+ const severity = problem.variant || (kind === "budget-100" ? "error" : "warning");
270
+ const titleSubject = kind === "stall" ? "workflow stalled"
271
+ : kind === "budget-80" ? "budget 80%"
272
+ : kind === "budget-100" ? "budget exhausted"
273
+ : problem.count > 1 ? `${problem.count} lanes failed`
274
+ : "lane failed";
275
+ const title = line(`${kind === "budget-100" || kind === "lane-failure" ? g.failure : g.warning} ${titleSubject}${g.sep}${snapshot.name}`, options);
276
+ const label = cleanText(problem.label || problem.callId || "workflow", 28);
277
+ const reason = cleanText(problem.reason || snapshot.error || "needs attention", 42);
278
+ const attempt = Number.isInteger(problem.attempt) && Number.isInteger(problem.maxAttempts)
279
+ ? ` (attempt ${problem.attempt}/${problem.maxAttempts})`
280
+ : "";
281
+ const lines = [
282
+ `${label}${g.dash}${reason}${attempt}`,
283
+ ];
284
+ if (Number.isFinite(problem.retryInMs)) lines.push(`retrying in ${compactDuration(problem.retryInMs)}${problem.ordinal ? `${g.sep}${problem.ordinal}` : ""}`);
285
+ if (kind === "stall" && Number.isFinite(problem.idleMs)) lines.push(`no progress ${compactDuration(problem.idleMs)}${g.sep}${problem.idleLaneCount ?? 0} lanes idle`);
286
+ if (kind.startsWith("budget") && Number.isFinite(snapshot.budgetPercent)) lines.push(`budget ${snapshot.budgetPercent}%${g.sep}${snapshot.tokenLabel} tok`);
287
+ lines.push(`${g.phase} ${snapshot.phase.name}: ${statusCounters(snapshot, g)}`);
288
+ lines.push(...inspectLines(snapshot));
289
+ return {
290
+ variant: severity,
291
+ title,
292
+ message: lines.map((item) => line(item, options)).join("\n"),
293
+ };
294
+ }
295
+
296
+ function terminalTitleStatus(snapshot, g) {
297
+ if (snapshot.status === "completed" || snapshot.status === "applied") return `${g.success} ${snapshot.name} done`;
298
+ if (snapshot.status === "awaiting-diff-approval") return `${g.warning} ${snapshot.name} awaits apply`;
299
+ if (snapshot.status === "review-required") return `${g.warning} ${snapshot.name} review`;
300
+ if (snapshot.status === "apply-failed" || snapshot.status === "failed-with-diff-plan" || snapshot.status === "failed") return `${g.failure} ${snapshot.name} failed`;
301
+ return `${g.warning} ${snapshot.name} ${snapshot.status}`;
302
+ }
303
+
304
+ function phaseBreadcrumb(snapshot, g) {
305
+ const phases = snapshot.phase.phases;
306
+ if (!phases.length) return `${g.phase} ${snapshot.phase.name}`;
307
+ const failed = ["failed", "apply-failed", "failed-with-diff-plan", "timed-out", "cancelled", "budget_stopped"].includes(snapshot.status);
308
+ const current = snapshot.phase.index ?? phases.length - 1;
309
+ return phases.map((phase, index) => {
310
+ const mark = failed && index === current ? g.failure : index <= current || !failed ? g.success : g.queued;
311
+ return `${mark} ${phase}`;
312
+ }).join(" ");
313
+ }
314
+
315
+ function renderWorkflowTerminalCard(snapshot, options = {}) {
316
+ const g = glyphs(options);
317
+ const title = line(`${terminalTitleStatus(snapshot, g)}${g.sep}${snapshot.elapsed}`, options);
318
+ const laneTotal = Object.values(snapshot.outcomes).reduce((sum, value) => sum + (Number(value) || 0), 0);
319
+ const laneLine = `${laneTotal} lanes: ${g.success}${snapshot.done} ${g.failure}${snapshot.failed}${snapshot.recoveredCount ? ` (${snapshot.recoveredCount} recovered)` : ""}`;
320
+ const usage = [`${snapshot.tokenLabel} tok`, `$${snapshot.costLabel}`];
321
+ if (Number.isFinite(snapshot.budgetPercent)) usage.push(`${snapshot.budgetPercent}% of budget`);
322
+ const lines = [
323
+ phaseBreadcrumb(snapshot, g),
324
+ laneLine,
325
+ usage.join(g.sep),
326
+ ];
327
+ if (snapshot.recentLog) lines.push(`${g.log} ${snapshot.recentLog}`);
328
+ lines.push(...inspectLines(snapshot));
329
+ const variant = snapshot.status === "completed" || snapshot.status === "applied" ? "success"
330
+ : snapshot.status === "failed" || snapshot.status === "apply-failed" ? "error"
331
+ : "warning";
332
+ return {
333
+ variant,
334
+ title,
335
+ message: lines.map((item) => line(item, options)).join("\n"),
336
+ };
337
+ }
338
+
339
+ function renderWorkflowApplyCard(snapshot, options = {}) {
340
+ const g = glyphs(options);
341
+ const descriptor = snapshot.status === "apply-running" ? { variant: "info", label: "apply running" }
342
+ : snapshot.status === "applied" ? { variant: "success", label: "applied" }
343
+ : snapshot.status === "review-required" ? { variant: "warning", label: "apply review" }
344
+ : snapshot.status === "apply-failed" ? { variant: "error", label: "apply failed" }
345
+ : { variant: "warning", label: snapshot.status };
346
+ const patchCount = snapshot.patchCount;
347
+ const lines = [
348
+ `${g.root} ${descriptor.label}`,
349
+ ];
350
+ if (Number.isFinite(patchCount)) lines.push(` patches ${patchCount}`);
351
+ if (snapshot.diffPlanHash) lines.push(` diff ${String(snapshot.diffPlanHash).slice(0, 12)}`);
352
+ if (snapshot.error) lines.push(`${g.warning} ${snapshot.error}`);
353
+ lines.push(...inspectLines(snapshot));
354
+ return {
355
+ variant: descriptor.variant,
356
+ title: line(`${g.play} ${snapshot.name}${g.sep}${descriptor.label}`, options),
357
+ message: lines.map((item) => line(item, options)).join("\n"),
358
+ };
359
+ }
360
+
361
+ function inspectLines(snapshot) {
362
+ const id = String(snapshot.id ?? "");
363
+ const oneLine = `inspect: workflow_status ${id}`;
364
+ if (oneLine.length <= CARD_LINE_MAX) return [oneLine];
365
+ return ["inspect: workflow_status", ` ${id}`];
366
+ }
367
+
368
+ function workflowToastCardSignature(snapshot) {
369
+ return stableStringify({
370
+ status: snapshot.status,
371
+ phase: snapshot.phase.label,
372
+ elapsedBucket: snapshot.elapsedBucket,
373
+ agents: [snapshot.activeAgents, snapshot.queuedAgents, snapshot.agentsStarted],
374
+ outcomes: snapshot.outcomes,
375
+ dropped: snapshot.droppedLaneCount,
376
+ budgetPercent: snapshot.budgetPercent,
377
+ itemProgress: snapshot.itemProgress ? [snapshot.itemProgress.done, snapshot.itemProgress.total, snapshot.itemProgress.currentStage, snapshot.itemProgress.totalStages] : null,
378
+ recentLog: snapshot.recentLog,
379
+ lanes: snapshot.activeLanes.map((lane) => [lane.callId, lane.status, lane.age, lane.idle, lane.label]),
380
+ });
381
+ }
382
+
383
+ export {
384
+ CARD_LINE_MAX,
385
+ CARD_MAX_LANES,
386
+ laneRecordsForCards,
387
+ workflowToastCardSnapshot,
388
+ workflowToastCardSignature,
389
+ renderWorkflowHeartbeatCard,
390
+ renderWorkflowProblemCard,
391
+ renderWorkflowTerminalCard,
392
+ renderWorkflowApplyCard,
393
+ };
@@ -0,0 +1,179 @@
1
+ const DEFAULT_FORCE_MS = 75_000;
2
+ const DEFAULT_PROBLEM_COOLDOWN_MS = 30_000;
3
+
4
+ const LANE_FAILURE_EVENTS = new Set([
5
+ "agent.failure",
6
+ "agent.timeout",
7
+ "agent.cancelled",
8
+ "agent.budget_stopped",
9
+ "agent.retry",
10
+ "agent.salvageable_dirty_failure",
11
+ ]);
12
+
13
+ function requireNow(now) {
14
+ if (!Number.isFinite(now)) throw new Error("workflow toast policy requires an injected numeric now");
15
+ return now;
16
+ }
17
+
18
+ function createWorkflowToastPolicyState() {
19
+ return {
20
+ lastHeartbeatSignature: undefined,
21
+ lastHeartbeatAt: undefined,
22
+ lastProblemAt: {},
23
+ pendingFailures: {},
24
+ budgetThresholds: {},
25
+ };
26
+ }
27
+
28
+ function problemReady(state, key, now, cooldownMs) {
29
+ const last = state.lastProblemAt[key];
30
+ return !Number.isFinite(last) || now - last >= cooldownMs;
31
+ }
32
+
33
+ function markProblem(state, key, now) {
34
+ state.lastProblemAt[key] = now;
35
+ }
36
+
37
+ function evaluateWorkflowHeartbeatPolicy(state, snapshot, options = {}) {
38
+ const now = requireNow(options.now);
39
+ const forceMs = Number.isFinite(options.forceMs) && options.forceMs > 0 ? options.forceMs : DEFAULT_FORCE_MS;
40
+ const signature = String(options.signature ?? "");
41
+ const changed = signature !== state.lastHeartbeatSignature;
42
+ const forced = options.force === true || (Number.isFinite(state.lastHeartbeatAt) && now - state.lastHeartbeatAt >= forceMs);
43
+ if (!changed && !forced) return null;
44
+ state.lastHeartbeatSignature = signature;
45
+ state.lastHeartbeatAt = now;
46
+ return { card: "heartbeat", snapshot };
47
+ }
48
+
49
+ function evaluateBudgetPolicy(state, snapshot) {
50
+ const percent = snapshot.budgetPercent;
51
+ if (!Number.isFinite(percent)) return [];
52
+ if (percent >= 100 && state.budgetThresholds["100"] !== true) {
53
+ state.budgetThresholds["100"] = true;
54
+ state.budgetThresholds["80"] = true;
55
+ return [{ card: "problem", snapshot, problem: { kind: "budget-100", variant: "error", label: "budget", reason: "budget exhausted" } }];
56
+ }
57
+ if (percent >= 80 && state.budgetThresholds["80"] !== true) {
58
+ state.budgetThresholds["80"] = true;
59
+ return [{ card: "problem", snapshot, problem: { kind: "budget-80", variant: "warning", label: "budget", reason: "budget 80%" } }];
60
+ }
61
+ return [];
62
+ }
63
+
64
+ function evaluateStallPolicy(state, snapshot, options = {}) {
65
+ const now = requireNow(options.now);
66
+ if (!snapshot.staleness?.stale) return [];
67
+ const cooldownMs = Number.isFinite(options.problemCooldownMs) ? options.problemCooldownMs : DEFAULT_PROBLEM_COOLDOWN_MS;
68
+ const key = "stall";
69
+ if (!problemReady(state, key, now, cooldownMs)) return [];
70
+ markProblem(state, key, now);
71
+ const idleLaneCount = snapshot.activeLanes.filter((lane) => lane.idle).length;
72
+ return [{
73
+ card: "problem",
74
+ snapshot,
75
+ problem: {
76
+ kind: "stall",
77
+ variant: "warning",
78
+ label: "workflow",
79
+ reason: "stalled",
80
+ idleMs: snapshot.staleness.ageMs,
81
+ idleLaneCount,
82
+ },
83
+ }];
84
+ }
85
+
86
+ function failureCategory(snapshot) {
87
+ return `lane-failure:${snapshot.phase?.name || "phase"}`;
88
+ }
89
+
90
+ function pendingFailure(state, key) {
91
+ const pending = state.pendingFailures[key];
92
+ if (!pending) return undefined;
93
+ delete state.pendingFailures[key];
94
+ return pending;
95
+ }
96
+
97
+ function addPendingFailure(state, key, event) {
98
+ const pending = state.pendingFailures[key] ?? { count: 0, sample: event };
99
+ pending.count += 1;
100
+ pending.sample = pending.sample ?? event;
101
+ state.pendingFailures[key] = pending;
102
+ }
103
+
104
+ function laneFailureProblem(event, snapshot, count = 1) {
105
+ return {
106
+ kind: "lane-failure",
107
+ count,
108
+ label: count > 1 ? `${count} lanes failed in ${snapshot.phase?.name || "phase"}` : event.callId,
109
+ callId: event.callId,
110
+ reason: event.error || event.failureClass || event.type,
111
+ attempt: Number.isInteger(event.attempt) ? event.attempt : undefined,
112
+ maxAttempts: Number.isInteger(event.maxAttempts) ? event.maxAttempts : undefined,
113
+ retryInMs: Number.isFinite(event.delayMs) ? event.delayMs : undefined,
114
+ ordinal: count > 1 ? `${count} failures this run` : undefined,
115
+ };
116
+ }
117
+
118
+ function evaluateFailurePolicy(state, event, snapshot, options = {}) {
119
+ if (!LANE_FAILURE_EVENTS.has(event?.type)) return [];
120
+ const now = requireNow(options.now);
121
+ const cooldownMs = Number.isFinite(options.problemCooldownMs) ? options.problemCooldownMs : DEFAULT_PROBLEM_COOLDOWN_MS;
122
+ const key = failureCategory(snapshot);
123
+ if (!problemReady(state, key, now, cooldownMs)) {
124
+ addPendingFailure(state, key, event);
125
+ return [];
126
+ }
127
+ const pending = pendingFailure(state, key);
128
+ const count = 1 + (pending?.count ?? 0);
129
+ markProblem(state, key, now);
130
+ return [{ card: "problem", snapshot, problem: laneFailureProblem(event, snapshot, count) }];
131
+ }
132
+
133
+ function flushWorkflowToastPolicy(state, snapshot, options = {}) {
134
+ const now = requireNow(options.now);
135
+ const cooldownMs = Number.isFinite(options.problemCooldownMs) ? options.problemCooldownMs : DEFAULT_PROBLEM_COOLDOWN_MS;
136
+ const decisions = [];
137
+ for (const [key, pending] of Object.entries(state.pendingFailures)) {
138
+ if (!pending || !problemReady(state, key, now, cooldownMs)) continue;
139
+ delete state.pendingFailures[key];
140
+ markProblem(state, key, now);
141
+ decisions.push({ card: "problem", snapshot, problem: laneFailureProblem(pending.sample, snapshot, pending.count) });
142
+ }
143
+ return decisions;
144
+ }
145
+
146
+ function evaluateWorkflowToastEventPolicy(state, event, snapshot, options = {}) {
147
+ requireNow(options.now);
148
+ const decisions = [
149
+ ...evaluateBudgetPolicy(state, snapshot),
150
+ ...evaluateStallPolicy(state, snapshot, options),
151
+ ...evaluateFailurePolicy(state, event, snapshot, options),
152
+ ];
153
+ if (event?.type === "phase") {
154
+ const heartbeat = evaluateWorkflowHeartbeatPolicy(state, snapshot, { ...options, force: true });
155
+ if (heartbeat) decisions.push(heartbeat);
156
+ }
157
+ return decisions;
158
+ }
159
+
160
+ function evaluateWorkflowToastTickPolicy(state, snapshot, options = {}) {
161
+ const decisions = [
162
+ ...evaluateBudgetPolicy(state, snapshot),
163
+ ...evaluateStallPolicy(state, snapshot, options),
164
+ ...flushWorkflowToastPolicy(state, snapshot, options),
165
+ ];
166
+ const heartbeat = evaluateWorkflowHeartbeatPolicy(state, snapshot, options);
167
+ if (heartbeat) decisions.push(heartbeat);
168
+ return decisions;
169
+ }
170
+
171
+ export {
172
+ DEFAULT_FORCE_MS,
173
+ DEFAULT_PROBLEM_COOLDOWN_MS,
174
+ createWorkflowToastPolicyState,
175
+ evaluateWorkflowHeartbeatPolicy,
176
+ evaluateWorkflowToastEventPolicy,
177
+ evaluateWorkflowToastTickPolicy,
178
+ flushWorkflowToastPolicy,
179
+ };
@@ -0,0 +1,100 @@
1
+ const SCOPE_KIND_PATTERN = /^(pipeline|parallel):(\d+)$/;
2
+ const ITEM_PATTERN = /^item:(\d+)$/;
3
+ const STAGE_PATTERN = /^stage:(\d+)$/;
4
+ const DONE_STATUSES = new Set(["completed", "success", "applied"]);
5
+ const TERMINAL_STATUSES = new Set(["completed", "success", "applied", "failure", "failed", "timeout", "cancelled", "budget_stopped"]);
6
+
7
+ function laneRecordsArray(input) {
8
+ if (input instanceof Map) return [...input.values()];
9
+ return Array.isArray(input) ? input : [];
10
+ }
11
+
12
+ function parseScopePath(callId) {
13
+ const segments = String(callId ?? "").split("/").filter(Boolean);
14
+ let scopeEnd = -1;
15
+ let itemIndex;
16
+ let stageIndex;
17
+ for (let index = 0; index < segments.length; index += 1) {
18
+ if (SCOPE_KIND_PATTERN.test(segments[index])) scopeEnd = index;
19
+ const item = ITEM_PATTERN.exec(segments[index]);
20
+ if (item && scopeEnd >= 0 && itemIndex === undefined) itemIndex = Number(item[1]);
21
+ const stage = STAGE_PATTERN.exec(segments[index]);
22
+ if (stage && itemIndex !== undefined && stageIndex === undefined) stageIndex = Number(stage[1]);
23
+ }
24
+ if (scopeEnd < 0 || !Number.isInteger(itemIndex)) return null;
25
+ const containerPath = segments.slice(0, scopeEnd + 1).join("/");
26
+ return {
27
+ containerPath,
28
+ itemIndex,
29
+ itemKey: `${containerPath}/item:${itemIndex}`,
30
+ stageIndex: Number.isInteger(stageIndex) ? stageIndex : undefined,
31
+ };
32
+ }
33
+
34
+ function laneDone(record) {
35
+ return DONE_STATUSES.has(String(record?.outcome ?? record?.status ?? ""));
36
+ }
37
+
38
+ function laneTerminal(record) {
39
+ return TERMINAL_STATUSES.has(String(record?.outcome ?? record?.status ?? ""));
40
+ }
41
+
42
+ function deriveScopeItemProgress(laneRecords) {
43
+ const items = new Map();
44
+ const scoped = [];
45
+ for (const record of laneRecordsArray(laneRecords)) {
46
+ const parsed = parseScopePath(record?.callId);
47
+ if (!parsed) continue;
48
+ scoped.push({ record, parsed });
49
+ const item = items.get(parsed.itemKey) ?? {
50
+ key: parsed.itemKey,
51
+ containerPath: parsed.containerPath,
52
+ itemIndex: parsed.itemIndex,
53
+ laneCount: 0,
54
+ doneLaneCount: 0,
55
+ terminalLaneCount: 0,
56
+ maxStageIndex: undefined,
57
+ activeStageIndex: undefined,
58
+ };
59
+ item.laneCount += 1;
60
+ if (laneDone(record)) item.doneLaneCount += 1;
61
+ if (laneTerminal(record)) item.terminalLaneCount += 1;
62
+ if (Number.isInteger(parsed.stageIndex)) {
63
+ item.maxStageIndex = Number.isInteger(item.maxStageIndex) ? Math.max(item.maxStageIndex, parsed.stageIndex) : parsed.stageIndex;
64
+ if (!laneTerminal(record)) {
65
+ item.activeStageIndex = Number.isInteger(item.activeStageIndex)
66
+ ? Math.min(item.activeStageIndex, parsed.stageIndex)
67
+ : parsed.stageIndex;
68
+ }
69
+ }
70
+ items.set(parsed.itemKey, item);
71
+ }
72
+ if (scoped.length === 0) return null;
73
+ const itemList = [...items.values()].sort((a, b) => a.containerPath.localeCompare(b.containerPath) || a.itemIndex - b.itemIndex);
74
+ const done = itemList.filter((item) => item.laneCount > 0 && item.doneLaneCount === item.laneCount).length;
75
+ const failed = itemList.filter((item) => item.terminalLaneCount === item.laneCount && item.doneLaneCount < item.laneCount).length;
76
+ const activeStages = itemList
77
+ .map((item) => item.activeStageIndex)
78
+ .filter(Number.isInteger);
79
+ const maxStageIndex = itemList.reduce((max, item) => Number.isInteger(item.maxStageIndex) ? Math.max(max, item.maxStageIndex) : max, -1);
80
+ const currentStageIndex = activeStages.length > 0 ? Math.min(...activeStages) : undefined;
81
+ return {
82
+ done,
83
+ total: itemList.length,
84
+ failed,
85
+ currentStage: Number.isInteger(currentStageIndex) ? currentStageIndex + 1 : undefined,
86
+ totalStages: maxStageIndex >= 0 ? maxStageIndex + 1 : undefined,
87
+ items: itemList.map((item) => ({
88
+ key: item.key,
89
+ containerPath: item.containerPath,
90
+ itemIndex: item.itemIndex,
91
+ done: item.laneCount > 0 && item.doneLaneCount === item.laneCount,
92
+ failed: item.terminalLaneCount === item.laneCount && item.doneLaneCount < item.laneCount,
93
+ })),
94
+ };
95
+ }
96
+
97
+ export {
98
+ deriveScopeItemProgress,
99
+ parseScopePath,
100
+ };