taskplane 0.0.1 → 0.1.1
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/LICENSE +21 -0
- package/README.md +2 -20
- package/bin/taskplane.mjs +706 -0
- package/dashboard/public/app.js +900 -0
- package/dashboard/public/index.html +92 -0
- package/dashboard/public/style.css +924 -0
- package/dashboard/server.cjs +531 -0
- package/extensions/task-orchestrator.ts +28 -0
- package/extensions/task-runner.ts +1923 -0
- package/extensions/taskplane/abort.ts +466 -0
- package/extensions/taskplane/config.ts +102 -0
- package/extensions/taskplane/discovery.ts +988 -0
- package/extensions/taskplane/engine.ts +758 -0
- package/extensions/taskplane/execution.ts +1752 -0
- package/extensions/taskplane/extension.ts +577 -0
- package/extensions/taskplane/formatting.ts +718 -0
- package/extensions/taskplane/git.ts +38 -0
- package/extensions/taskplane/index.ts +22 -0
- package/extensions/taskplane/merge.ts +795 -0
- package/extensions/taskplane/messages.ts +134 -0
- package/extensions/taskplane/persistence.ts +1121 -0
- package/extensions/taskplane/resume.ts +1092 -0
- package/extensions/taskplane/sessions.ts +92 -0
- package/extensions/taskplane/types.ts +1514 -0
- package/extensions/taskplane/waves.ts +900 -0
- package/extensions/taskplane/worktree.ts +1624 -0
- package/package.json +50 -4
- package/skills/create-taskplane-task/SKILL.md +326 -0
- package/skills/create-taskplane-task/references/context-template.md +78 -0
- package/skills/create-taskplane-task/references/prompt-template.md +246 -0
- package/templates/agents/task-merger.md +256 -0
- package/templates/agents/task-reviewer.md +81 -0
- package/templates/agents/task-worker.md +140 -0
- package/templates/config/task-orchestrator.yaml +89 -0
- package/templates/config/task-runner.yaml +99 -0
- package/templates/tasks/CONTEXT.md +31 -0
- package/templates/tasks/EXAMPLE-001-hello-world/PROMPT.md +90 -0
- package/templates/tasks/EXAMPLE-001-hello-world/STATUS.md +73 -0
|
@@ -0,0 +1,718 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Output formatting, dashboard widget, wave plan display
|
|
3
|
+
* @module orch/formatting
|
|
4
|
+
*/
|
|
5
|
+
import { join } from "path";
|
|
6
|
+
import { truncateToWidth } from "@mariozechner/pi-tui";
|
|
7
|
+
|
|
8
|
+
import { parseDependencyReference } from "./discovery.ts";
|
|
9
|
+
import type { LaneAssignment, MonitorState, OrchBatchRuntimeState, OrchDashboardViewModel, OrchLaneCardData, OrchSummaryCounts, ParsedTask, WaveComputationResult } from "./types.ts";
|
|
10
|
+
import { getTaskDurationMinutes, SIZE_DURATION_MINUTES } from "./types.ts";
|
|
11
|
+
|
|
12
|
+
// ── Wave Output Formatting ───────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
// ── Dependency Graph Formatting ──────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Format a dependency graph for display.
|
|
18
|
+
*
|
|
19
|
+
* Shows both upstream (what each task depends on) and downstream
|
|
20
|
+
* (what depends on each task) views. Output is deterministic:
|
|
21
|
+
* tasks sorted by ID, edges sorted by target ID.
|
|
22
|
+
*
|
|
23
|
+
* If `filterTaskId` is provided, only shows edges involving that task.
|
|
24
|
+
*/
|
|
25
|
+
export function formatDependencyGraph(
|
|
26
|
+
pending: Map<string, ParsedTask>,
|
|
27
|
+
completed: Set<string>,
|
|
28
|
+
filterTaskId?: string,
|
|
29
|
+
): string {
|
|
30
|
+
const lines: string[] = [];
|
|
31
|
+
|
|
32
|
+
// Sort tasks deterministically by ID
|
|
33
|
+
const sortedTasks = [...pending.values()].sort((a, b) =>
|
|
34
|
+
a.taskId.localeCompare(b.taskId),
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
// Build downstream index: taskID → tasks that depend on it
|
|
38
|
+
const downstream = new Map<string, string[]>();
|
|
39
|
+
for (const task of sortedTasks) {
|
|
40
|
+
for (const depRaw of task.dependencies) {
|
|
41
|
+
const depId = parseDependencyReference(depRaw).taskId;
|
|
42
|
+
const existing = downstream.get(depId) || [];
|
|
43
|
+
existing.push(task.taskId);
|
|
44
|
+
downstream.set(depId, existing);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// If filtering to a single task
|
|
49
|
+
if (filterTaskId) {
|
|
50
|
+
const task = pending.get(filterTaskId);
|
|
51
|
+
if (!task) {
|
|
52
|
+
lines.push(`❌ Task "${filterTaskId}" not found in pending tasks.`);
|
|
53
|
+
return lines.join("\n");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
lines.push(`🔗 Dependencies for ${filterTaskId} (${task.taskName}):`);
|
|
57
|
+
lines.push("");
|
|
58
|
+
|
|
59
|
+
// Upstream: what this task depends on
|
|
60
|
+
lines.push(" ⬆ Upstream (depends on):");
|
|
61
|
+
if (task.dependencies.length === 0) {
|
|
62
|
+
lines.push(" (none — no dependencies)");
|
|
63
|
+
} else {
|
|
64
|
+
const sortedDeps = [...task.dependencies].sort();
|
|
65
|
+
for (const depRaw of sortedDeps) {
|
|
66
|
+
const depId = parseDependencyReference(depRaw).taskId;
|
|
67
|
+
const status = completed.has(depId)
|
|
68
|
+
? "✅ complete"
|
|
69
|
+
: pending.has(depId)
|
|
70
|
+
? "⏳ pending"
|
|
71
|
+
: "❓ unknown";
|
|
72
|
+
lines.push(` ${filterTaskId} → ${depRaw} (${status})`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Downstream: what depends on this task
|
|
77
|
+
lines.push("");
|
|
78
|
+
lines.push(" ⬇ Downstream (depended on by):");
|
|
79
|
+
const downstreamTasks = (downstream.get(filterTaskId) || []).sort();
|
|
80
|
+
if (downstreamTasks.length === 0) {
|
|
81
|
+
lines.push(" (none — no tasks depend on this)");
|
|
82
|
+
} else {
|
|
83
|
+
for (const dep of downstreamTasks) {
|
|
84
|
+
lines.push(` ${dep} → ${filterTaskId}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return lines.join("\n");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Full graph view
|
|
92
|
+
lines.push("🔗 Dependency Graph:");
|
|
93
|
+
lines.push("");
|
|
94
|
+
|
|
95
|
+
let hasDeps = false;
|
|
96
|
+
|
|
97
|
+
// Section 1: Upstream view (what each task depends on)
|
|
98
|
+
lines.push(" ⬆ Upstream (task → depends on):");
|
|
99
|
+
for (const task of sortedTasks) {
|
|
100
|
+
if (task.dependencies.length > 0) {
|
|
101
|
+
hasDeps = true;
|
|
102
|
+
const sortedDeps = [...task.dependencies].sort();
|
|
103
|
+
for (const depRaw of sortedDeps) {
|
|
104
|
+
const depId = parseDependencyReference(depRaw).taskId;
|
|
105
|
+
const status = completed.has(depId)
|
|
106
|
+
? "✅ complete"
|
|
107
|
+
: pending.has(depId)
|
|
108
|
+
? "⏳ pending"
|
|
109
|
+
: "❓ unknown";
|
|
110
|
+
lines.push(` ${task.taskId} → ${depRaw} (${status})`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (!hasDeps) {
|
|
115
|
+
lines.push(" (none — all tasks are independent)");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Section 2: Downstream view (what depends on each task)
|
|
119
|
+
lines.push("");
|
|
120
|
+
lines.push(" ⬇ Downstream (task ← depended on by):");
|
|
121
|
+
let hasDownstream = false;
|
|
122
|
+
const allTargets = new Set<string>();
|
|
123
|
+
for (const task of sortedTasks) {
|
|
124
|
+
for (const depRaw of task.dependencies) {
|
|
125
|
+
allTargets.add(parseDependencyReference(depRaw).taskId);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const sortedTargets = [...allTargets].sort();
|
|
129
|
+
for (const target of sortedTargets) {
|
|
130
|
+
const dependents = (downstream.get(target) || []).sort();
|
|
131
|
+
if (dependents.length > 0) {
|
|
132
|
+
hasDownstream = true;
|
|
133
|
+
const status = completed.has(target)
|
|
134
|
+
? "✅"
|
|
135
|
+
: pending.has(target)
|
|
136
|
+
? "⏳"
|
|
137
|
+
: "❓";
|
|
138
|
+
lines.push(
|
|
139
|
+
` ${target} ${status} ← ${dependents.join(", ")}`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (!hasDownstream) {
|
|
144
|
+
lines.push(" (none — no downstream dependencies)");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Section 3: Independent tasks (no deps, nothing depends on them)
|
|
148
|
+
const independentTasks = sortedTasks.filter(
|
|
149
|
+
(t) =>
|
|
150
|
+
t.dependencies.length === 0 &&
|
|
151
|
+
!(downstream.get(t.taskId)?.length),
|
|
152
|
+
);
|
|
153
|
+
if (independentTasks.length > 0) {
|
|
154
|
+
lines.push("");
|
|
155
|
+
lines.push(" ○ Independent (no dependencies, nothing depends on them):");
|
|
156
|
+
for (const task of independentTasks) {
|
|
157
|
+
lines.push(` ${task.taskId} [${task.size}] ${task.taskName}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return lines.join("\n");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Format wave computation results as a readable execution plan.
|
|
166
|
+
*
|
|
167
|
+
* Output sections (fixed order):
|
|
168
|
+
* 1. Wave overview header
|
|
169
|
+
* 2. Per-wave: task count, lane count, parallel/serial indicator
|
|
170
|
+
* 3. Per-lane within wave: tasks with sizes, serial notes, lane weight
|
|
171
|
+
* 4. Per-wave: estimated duration (critical path = max lane duration)
|
|
172
|
+
* 5. Summary: total estimated duration, size-to-duration table
|
|
173
|
+
*
|
|
174
|
+
* Duration calculation:
|
|
175
|
+
* - Per lane: sum of task durations for tasks in that lane
|
|
176
|
+
* - Per wave: max lane duration (parallel bottleneck / critical path)
|
|
177
|
+
* - Total: sum of wave durations (waves run sequentially)
|
|
178
|
+
*/
|
|
179
|
+
export function formatWavePlan(
|
|
180
|
+
result: WaveComputationResult,
|
|
181
|
+
sizeWeights: Record<string, number>,
|
|
182
|
+
): string {
|
|
183
|
+
const lines: string[] = [];
|
|
184
|
+
|
|
185
|
+
if (result.errors.length > 0) {
|
|
186
|
+
lines.push("❌ Wave Computation Errors:");
|
|
187
|
+
for (const err of result.errors) {
|
|
188
|
+
lines.push(` [${err.code}] ${err.message}`);
|
|
189
|
+
}
|
|
190
|
+
return lines.join("\n");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (result.waves.length === 0) {
|
|
194
|
+
lines.push("No waves to schedule.");
|
|
195
|
+
return lines.join("\n");
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Count total tasks
|
|
199
|
+
const totalTasks = result.waves.reduce((sum, w) => sum + w.tasks.length, 0);
|
|
200
|
+
const maxLanesUsed = Math.max(
|
|
201
|
+
...result.waves.map((w) => {
|
|
202
|
+
const lanes = new Set(w.tasks.map((t) => t.lane));
|
|
203
|
+
return lanes.size;
|
|
204
|
+
}),
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
lines.push(
|
|
208
|
+
`🌊 Execution Plan: ${result.waves.length} wave(s), ` +
|
|
209
|
+
`${totalTasks} task(s), up to ${maxLanesUsed} lane(s)`,
|
|
210
|
+
);
|
|
211
|
+
lines.push("");
|
|
212
|
+
|
|
213
|
+
let totalEstimate = 0;
|
|
214
|
+
for (const wave of result.waves) {
|
|
215
|
+
// Group tasks by lane (deterministic: Map preserves insertion order)
|
|
216
|
+
const laneGroups = new Map<number, LaneAssignment[]>();
|
|
217
|
+
for (const assignment of wave.tasks) {
|
|
218
|
+
const existing = laneGroups.get(assignment.lane) || [];
|
|
219
|
+
existing.push(assignment);
|
|
220
|
+
laneGroups.set(assignment.lane, existing);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const laneCount = laneGroups.size;
|
|
224
|
+
const taskCount = wave.tasks.length;
|
|
225
|
+
const parallel = laneCount > 1 ? "parallel" : "serial";
|
|
226
|
+
|
|
227
|
+
lines.push(
|
|
228
|
+
` Wave ${wave.waveNumber}: ${taskCount} task(s) across ` +
|
|
229
|
+
`${laneCount} lane(s) [${parallel}]`,
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
// Calculate wave duration: critical path = max lane duration
|
|
233
|
+
let maxLaneDuration = 0;
|
|
234
|
+
|
|
235
|
+
// Sort lanes deterministically by lane number
|
|
236
|
+
const sortedLanes = [...laneGroups.entries()].sort(
|
|
237
|
+
(a, b) => a[0] - b[0],
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
for (const [lane, assignments] of sortedLanes) {
|
|
241
|
+
// Sort tasks within lane by task ID for deterministic output
|
|
242
|
+
const sortedAssignments = [...assignments].sort((a, b) =>
|
|
243
|
+
a.taskId.localeCompare(b.taskId),
|
|
244
|
+
);
|
|
245
|
+
const taskList = sortedAssignments
|
|
246
|
+
.map((a) => `${a.taskId} [${a.task.size}]`)
|
|
247
|
+
.join(", ");
|
|
248
|
+
const laneDuration = sortedAssignments.reduce(
|
|
249
|
+
(sum, a) =>
|
|
250
|
+
sum + getTaskDurationMinutes(a.task.size, sizeWeights),
|
|
251
|
+
0,
|
|
252
|
+
);
|
|
253
|
+
if (laneDuration > maxLaneDuration) maxLaneDuration = laneDuration;
|
|
254
|
+
const serialNote =
|
|
255
|
+
sortedAssignments.length > 1 ? " (serial)" : "";
|
|
256
|
+
lines.push(
|
|
257
|
+
` Lane ${lane}: ${taskList}${serialNote} ` +
|
|
258
|
+
`[est. ${laneDuration} min]`,
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Critical path for this wave
|
|
263
|
+
totalEstimate += maxLaneDuration;
|
|
264
|
+
lines.push(
|
|
265
|
+
` ⏱ Wave duration: ${maxLaneDuration} min ` +
|
|
266
|
+
`(critical path: longest lane)`,
|
|
267
|
+
);
|
|
268
|
+
lines.push("");
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Summary with size-to-duration table
|
|
272
|
+
const totalHours = (totalEstimate / 60).toFixed(1);
|
|
273
|
+
lines.push(`📊 Total estimated duration: ${totalEstimate} min (~${totalHours} hours)`);
|
|
274
|
+
lines.push(
|
|
275
|
+
` Duration model: S=${SIZE_DURATION_MINUTES["S"]}m, ` +
|
|
276
|
+
`M=${SIZE_DURATION_MINUTES["M"]}m, L=${SIZE_DURATION_MINUTES["L"]}m`,
|
|
277
|
+
);
|
|
278
|
+
lines.push(
|
|
279
|
+
" Critical path: sum of per-wave bottleneck lanes " +
|
|
280
|
+
"(waves sequential, lanes parallel)",
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
return lines.join("\n");
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
// ── Summary Helpers ──────────────────────────────────────────────────
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Compute summary counts from batch state + optional monitor state.
|
|
291
|
+
*
|
|
292
|
+
* Pure function — no side effects, deterministic output.
|
|
293
|
+
*/
|
|
294
|
+
export function computeOrchSummaryCounts(
|
|
295
|
+
batchState: OrchBatchRuntimeState,
|
|
296
|
+
monitorState?: MonitorState | null,
|
|
297
|
+
): OrchSummaryCounts {
|
|
298
|
+
let running = 0;
|
|
299
|
+
let stalled = 0;
|
|
300
|
+
|
|
301
|
+
// If we have live monitor data, count running/stalled from it
|
|
302
|
+
if (monitorState) {
|
|
303
|
+
for (const lane of monitorState.lanes) {
|
|
304
|
+
if (lane.currentTaskSnapshot) {
|
|
305
|
+
if (lane.currentTaskSnapshot.status === "stalled") {
|
|
306
|
+
stalled++;
|
|
307
|
+
} else if (lane.currentTaskSnapshot.status === "running") {
|
|
308
|
+
running++;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const completed = batchState.succeededTasks;
|
|
315
|
+
const failed = batchState.failedTasks;
|
|
316
|
+
const blocked = batchState.blockedTasks;
|
|
317
|
+
const total = batchState.totalTasks;
|
|
318
|
+
const queued = Math.max(0, total - completed - failed - blocked - stalled - running - batchState.skippedTasks);
|
|
319
|
+
|
|
320
|
+
return { completed, running, queued, failed, blocked, stalled, total };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Format elapsed time from start/end timestamps.
|
|
325
|
+
*
|
|
326
|
+
* @param startMs - Start epoch ms
|
|
327
|
+
* @param endMs - End epoch ms (null = use current time)
|
|
328
|
+
* @returns Human-readable string, e.g., "2m 14s" or "1h 5m 30s"
|
|
329
|
+
*/
|
|
330
|
+
export function formatElapsedTime(startMs: number, endMs?: number | null): string {
|
|
331
|
+
if (startMs <= 0) return "0s";
|
|
332
|
+
const elapsed = (endMs ?? Date.now()) - startMs;
|
|
333
|
+
if (elapsed < 0) return "0s";
|
|
334
|
+
|
|
335
|
+
const totalSec = Math.floor(elapsed / 1000);
|
|
336
|
+
const hours = Math.floor(totalSec / 3600);
|
|
337
|
+
const minutes = Math.floor((totalSec % 3600) / 60);
|
|
338
|
+
const seconds = totalSec % 60;
|
|
339
|
+
|
|
340
|
+
if (hours > 0) return `${hours}h ${minutes}m ${seconds}s`;
|
|
341
|
+
if (minutes > 0) return `${minutes}m ${seconds}s`;
|
|
342
|
+
return `${seconds}s`;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Build the dashboard view-model from runtime state.
|
|
347
|
+
*
|
|
348
|
+
* Pure function — deterministic mapping from OrchBatchRuntimeState +
|
|
349
|
+
* optional MonitorState to render-ready OrchDashboardViewModel.
|
|
350
|
+
*
|
|
351
|
+
* Fallback behavior:
|
|
352
|
+
* - No batch → idle view with zeroed counts
|
|
353
|
+
* - No monitor data → empty lane cards, counts from batch state only
|
|
354
|
+
* - Missing STATUS.md → "no data" in lane card
|
|
355
|
+
*/
|
|
356
|
+
export function buildDashboardViewModel(
|
|
357
|
+
batchState: OrchBatchRuntimeState,
|
|
358
|
+
monitorState?: MonitorState | null,
|
|
359
|
+
): OrchDashboardViewModel {
|
|
360
|
+
const summary = computeOrchSummaryCounts(batchState, monitorState);
|
|
361
|
+
const elapsed = formatElapsedTime(batchState.startedAt, batchState.endedAt);
|
|
362
|
+
|
|
363
|
+
const waveProgress = batchState.totalWaves > 0
|
|
364
|
+
? `${Math.max(0, batchState.currentWaveIndex + 1)}/${batchState.totalWaves}`
|
|
365
|
+
: "0/0";
|
|
366
|
+
|
|
367
|
+
// Build lane cards from monitor state (if available) or current lanes
|
|
368
|
+
const laneCards: OrchLaneCardData[] = [];
|
|
369
|
+
|
|
370
|
+
if (monitorState && monitorState.lanes.length > 0) {
|
|
371
|
+
// Sort lanes by laneNumber (deterministic)
|
|
372
|
+
const sortedLanes = [...monitorState.lanes].sort((a, b) => a.laneNumber - b.laneNumber);
|
|
373
|
+
|
|
374
|
+
for (const lane of sortedLanes) {
|
|
375
|
+
const snap = lane.currentTaskSnapshot;
|
|
376
|
+
let status: OrchLaneCardData["status"] = "idle";
|
|
377
|
+
if (lane.failedTasks.length > 0) status = "failed";
|
|
378
|
+
else if (snap?.status === "stalled") status = "stalled";
|
|
379
|
+
else if (snap?.status === "running") status = "running";
|
|
380
|
+
else if (lane.completedTasks.length > 0 && lane.remainingTasks.length === 0 && !lane.currentTaskId) status = "succeeded";
|
|
381
|
+
|
|
382
|
+
laneCards.push({
|
|
383
|
+
laneNumber: lane.laneNumber,
|
|
384
|
+
laneId: lane.laneId,
|
|
385
|
+
sessionName: lane.sessionName,
|
|
386
|
+
sessionAlive: lane.sessionAlive,
|
|
387
|
+
currentTaskId: lane.currentTaskId,
|
|
388
|
+
currentStepName: snap?.currentStepName || null,
|
|
389
|
+
totalChecked: snap?.totalChecked || 0,
|
|
390
|
+
totalItems: snap?.totalItems || 0,
|
|
391
|
+
completedTasks: lane.completedTasks.length,
|
|
392
|
+
totalLaneTasks: lane.completedTasks.length + lane.failedTasks.length + lane.remainingTasks.length + (lane.currentTaskId ? 1 : 0),
|
|
393
|
+
status,
|
|
394
|
+
stallReason: snap?.stallReason || null,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
} else if (batchState.currentLanes.length > 0) {
|
|
398
|
+
// No monitor data yet — show lanes from allocation
|
|
399
|
+
const sortedLanes = [...batchState.currentLanes].sort((a, b) => a.laneNumber - b.laneNumber);
|
|
400
|
+
for (const lane of sortedLanes) {
|
|
401
|
+
laneCards.push({
|
|
402
|
+
laneNumber: lane.laneNumber,
|
|
403
|
+
laneId: lane.laneId,
|
|
404
|
+
sessionName: lane.tmuxSessionName,
|
|
405
|
+
sessionAlive: true, // assumed alive during allocation
|
|
406
|
+
currentTaskId: lane.tasks.length > 0 ? lane.tasks[0].taskId : null,
|
|
407
|
+
currentStepName: null,
|
|
408
|
+
totalChecked: 0,
|
|
409
|
+
totalItems: 0,
|
|
410
|
+
completedTasks: 0,
|
|
411
|
+
totalLaneTasks: lane.tasks.length,
|
|
412
|
+
status: "running",
|
|
413
|
+
stallReason: null,
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Determine attach hint
|
|
419
|
+
let attachHint = "";
|
|
420
|
+
const aliveLane = laneCards.find(l => l.sessionAlive && l.status === "running");
|
|
421
|
+
if (aliveLane) {
|
|
422
|
+
attachHint = `tmux attach -t ${aliveLane.sessionName}`;
|
|
423
|
+
} else if (laneCards.length > 0) {
|
|
424
|
+
attachHint = "/orch-sessions for session list";
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Determine failure policy if batch was stopped
|
|
428
|
+
let failurePolicy: string | null = null;
|
|
429
|
+
if (batchState.phase === "stopped" && batchState.waveResults.length > 0) {
|
|
430
|
+
const lastWave = batchState.waveResults[batchState.waveResults.length - 1];
|
|
431
|
+
if (lastWave.stoppedEarly && lastWave.policyApplied) {
|
|
432
|
+
failurePolicy = lastWave.policyApplied;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
return {
|
|
437
|
+
phase: batchState.phase,
|
|
438
|
+
batchId: batchState.batchId,
|
|
439
|
+
waveProgress,
|
|
440
|
+
elapsed,
|
|
441
|
+
summary,
|
|
442
|
+
laneCards,
|
|
443
|
+
attachHint,
|
|
444
|
+
errors: batchState.errors,
|
|
445
|
+
failurePolicy,
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// ── Lane Card Rendering ──────────────────────────────────────────────
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Render a single lane card for the dashboard.
|
|
453
|
+
*
|
|
454
|
+
* Follows the task-runner `renderStepCard` pattern:
|
|
455
|
+
* bordered box with lane info, status icon, task progress.
|
|
456
|
+
*
|
|
457
|
+
* @param card - Lane card data from view-model
|
|
458
|
+
* @param colWidth - Available width for the card (including borders)
|
|
459
|
+
* @param theme - Pi theme object for color styling
|
|
460
|
+
* @returns Array of styled string lines (one per card row)
|
|
461
|
+
*/
|
|
462
|
+
export function renderLaneCard(card: OrchLaneCardData, colWidth: number, theme: any): string[] {
|
|
463
|
+
const w = colWidth - 2; // inner width (excluding │ borders)
|
|
464
|
+
const trunc = (s: string, max: number) => s.length > max ? s.slice(0, max - 3) + "..." : s;
|
|
465
|
+
|
|
466
|
+
// Status icon and color
|
|
467
|
+
const statusIcon = card.status === "succeeded" ? "✓"
|
|
468
|
+
: card.status === "running" ? "●"
|
|
469
|
+
: card.status === "failed" ? "✗"
|
|
470
|
+
: card.status === "stalled" ? "⚠"
|
|
471
|
+
: "○";
|
|
472
|
+
const statusColor = card.status === "succeeded" ? "success"
|
|
473
|
+
: card.status === "running" ? "accent"
|
|
474
|
+
: card.status === "failed" ? "error"
|
|
475
|
+
: card.status === "stalled" ? "warning"
|
|
476
|
+
: "dim";
|
|
477
|
+
|
|
478
|
+
// Line 1: Session name (e.g., "⎡orch-lane-1⎤")
|
|
479
|
+
const sessionLabel = `⎡${card.sessionName}⎤`;
|
|
480
|
+
const sessionStr = theme.fg("accent", theme.bold(trunc(sessionLabel, w)));
|
|
481
|
+
const sessionVis = Math.min(sessionLabel.length, w);
|
|
482
|
+
|
|
483
|
+
// Line 2: Status + current task
|
|
484
|
+
const taskInfo = card.currentTaskId
|
|
485
|
+
? `${statusIcon} ${card.currentTaskId}`
|
|
486
|
+
: card.status === "succeeded" ? `${statusIcon} done`
|
|
487
|
+
: card.status === "failed" ? `${statusIcon} failed`
|
|
488
|
+
: `${statusIcon} idle`;
|
|
489
|
+
const taskStr = theme.fg(statusColor, trunc(taskInfo, w));
|
|
490
|
+
const taskVis = Math.min(taskInfo.length, w);
|
|
491
|
+
|
|
492
|
+
// Line 3: Step progress
|
|
493
|
+
let stepInfo = "";
|
|
494
|
+
if (card.currentStepName) {
|
|
495
|
+
stepInfo = trunc(card.currentStepName, w - 2);
|
|
496
|
+
} else if (card.currentTaskId && card.totalItems === 0) {
|
|
497
|
+
stepInfo = "waiting for data...";
|
|
498
|
+
} else if (!card.currentTaskId && card.status !== "idle") {
|
|
499
|
+
stepInfo = `${card.completedTasks}/${card.totalLaneTasks} tasks`;
|
|
500
|
+
}
|
|
501
|
+
const stepStr = theme.fg("muted", trunc(stepInfo, w));
|
|
502
|
+
const stepVis = Math.min(stepInfo.length, w);
|
|
503
|
+
|
|
504
|
+
// Line 4: Checkbox progress or stall reason
|
|
505
|
+
let extraInfo = "";
|
|
506
|
+
let extraColor = "dim";
|
|
507
|
+
if (card.stallReason) {
|
|
508
|
+
extraInfo = `⚠ ${trunc(card.stallReason, w - 4)}`;
|
|
509
|
+
extraColor = "warning";
|
|
510
|
+
} else if (card.totalItems > 0) {
|
|
511
|
+
extraInfo = `${card.totalChecked}/${card.totalItems} ✓`;
|
|
512
|
+
extraColor = card.totalChecked === card.totalItems ? "success" : "muted";
|
|
513
|
+
} else if (!card.sessionAlive && card.status === "running") {
|
|
514
|
+
extraInfo = "session dead";
|
|
515
|
+
extraColor = "error";
|
|
516
|
+
}
|
|
517
|
+
const extraStr = theme.fg(extraColor, trunc(extraInfo, w));
|
|
518
|
+
const extraVis = Math.min(extraInfo.length, w);
|
|
519
|
+
|
|
520
|
+
// Build bordered card
|
|
521
|
+
const top = "┌" + "─".repeat(w) + "┐";
|
|
522
|
+
const bot = "└" + "─".repeat(w) + "┘";
|
|
523
|
+
const border = (content: string, vis: number) =>
|
|
524
|
+
theme.fg("dim", "│") + content + " ".repeat(Math.max(0, w - vis)) + theme.fg("dim", "│");
|
|
525
|
+
|
|
526
|
+
return [
|
|
527
|
+
theme.fg("dim", top),
|
|
528
|
+
border(" " + sessionStr, 1 + sessionVis),
|
|
529
|
+
border(" " + taskStr, 1 + taskVis),
|
|
530
|
+
border(" " + stepStr, 1 + stepVis),
|
|
531
|
+
border(extraInfo ? " " + extraStr : "", extraVis ? 1 + extraVis : 0),
|
|
532
|
+
theme.fg("dim", bot),
|
|
533
|
+
];
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// ── Core Widget ──────────────────────────────────────────────────────
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Create the widget registration callback for the orchestrator dashboard.
|
|
540
|
+
*
|
|
541
|
+
* This is the main entry point for the dashboard widget. It captures
|
|
542
|
+
* batchState and monitorState references and returns a widget that
|
|
543
|
+
* re-renders on each paint cycle using the latest state.
|
|
544
|
+
*
|
|
545
|
+
* @param getBatchState - Getter for current batch state
|
|
546
|
+
* @param getMonitorState - Getter for current monitor state (may be null)
|
|
547
|
+
* @param tmuxPrefix - TMUX session prefix for attach hints
|
|
548
|
+
*/
|
|
549
|
+
export function createOrchWidget(
|
|
550
|
+
getBatchState: () => OrchBatchRuntimeState,
|
|
551
|
+
getMonitorState: () => MonitorState | null,
|
|
552
|
+
tmuxPrefix: string,
|
|
553
|
+
): (_tui: any, theme: any) => { render(width: number): string[]; invalidate(): void } {
|
|
554
|
+
return (_tui: any, theme: any) => {
|
|
555
|
+
return {
|
|
556
|
+
render(width: number): string[] {
|
|
557
|
+
const batchState = getBatchState();
|
|
558
|
+
const monitorState = getMonitorState();
|
|
559
|
+
const vm = buildDashboardViewModel(batchState, monitorState);
|
|
560
|
+
|
|
561
|
+
// ── Idle state ─────────────────────────────────
|
|
562
|
+
if (vm.phase === "idle") {
|
|
563
|
+
return [
|
|
564
|
+
"",
|
|
565
|
+
truncateToWidth(
|
|
566
|
+
theme.fg("dim", " No active batch. Use /orch <areas|all> to start."),
|
|
567
|
+
width,
|
|
568
|
+
),
|
|
569
|
+
];
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const lines: string[] = [""];
|
|
573
|
+
|
|
574
|
+
// ── Phase-specific rendering ──────────────────
|
|
575
|
+
const phaseIcon =
|
|
576
|
+
vm.phase === "planning" ? "◌"
|
|
577
|
+
: vm.phase === "executing" ? "●"
|
|
578
|
+
: vm.phase === "merging" ? "🔀"
|
|
579
|
+
: vm.phase === "paused" ? "⏸"
|
|
580
|
+
: vm.phase === "stopped" ? "⛔"
|
|
581
|
+
: vm.phase === "completed" ? "✓"
|
|
582
|
+
: vm.phase === "failed" ? "✗"
|
|
583
|
+
: "○";
|
|
584
|
+
const phaseColor =
|
|
585
|
+
vm.phase === "executing" ? "accent"
|
|
586
|
+
: vm.phase === "merging" ? "accent"
|
|
587
|
+
: vm.phase === "completed" ? "success"
|
|
588
|
+
: vm.phase === "failed" || vm.phase === "stopped" ? "error"
|
|
589
|
+
: vm.phase === "paused" ? "warning"
|
|
590
|
+
: "dim";
|
|
591
|
+
|
|
592
|
+
// Header: phase icon + batch ID + wave + elapsed
|
|
593
|
+
const header =
|
|
594
|
+
theme.fg(phaseColor, ` ${phaseIcon} `) +
|
|
595
|
+
theme.fg("accent", theme.bold(vm.batchId || "—")) +
|
|
596
|
+
theme.fg("dim", " ") +
|
|
597
|
+
theme.fg("warning", `W${vm.waveProgress}`) +
|
|
598
|
+
theme.fg("dim", " · ") +
|
|
599
|
+
theme.fg("muted", vm.elapsed);
|
|
600
|
+
lines.push(truncateToWidth(header, width));
|
|
601
|
+
|
|
602
|
+
// ── Planning state ────────────────────────────
|
|
603
|
+
if (vm.phase === "planning") {
|
|
604
|
+
lines.push(truncateToWidth(
|
|
605
|
+
theme.fg("dim", " ◌ Planning batch..."),
|
|
606
|
+
width,
|
|
607
|
+
));
|
|
608
|
+
return lines;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// ── Progress bar ──────────────────────────────
|
|
612
|
+
const { completed, failed, total } = vm.summary;
|
|
613
|
+
const done = completed + failed;
|
|
614
|
+
const pct = total > 0 ? Math.round((done / total) * 100) : 0;
|
|
615
|
+
const barWidth = Math.min(30, width - 20);
|
|
616
|
+
const filled = Math.round((pct / 100) * barWidth);
|
|
617
|
+
const progressBar =
|
|
618
|
+
theme.fg("dim", " ") +
|
|
619
|
+
theme.fg("warning", "[") +
|
|
620
|
+
theme.fg("success", "█".repeat(filled)) +
|
|
621
|
+
theme.fg("dim", "░".repeat(Math.max(0, barWidth - filled))) +
|
|
622
|
+
theme.fg("warning", "]") +
|
|
623
|
+
theme.fg("dim", " ") +
|
|
624
|
+
theme.fg("accent", `${done}/${total}`) +
|
|
625
|
+
theme.fg("dim", ` (${pct}%)`);
|
|
626
|
+
lines.push(truncateToWidth(progressBar, width));
|
|
627
|
+
|
|
628
|
+
// ── Summary counts line ───────────────────────
|
|
629
|
+
const countParts: string[] = [];
|
|
630
|
+
if (vm.summary.completed > 0) countParts.push(theme.fg("success", `${vm.summary.completed} ✓`));
|
|
631
|
+
if (vm.summary.running > 0) countParts.push(theme.fg("accent", `${vm.summary.running} running`));
|
|
632
|
+
if (vm.summary.queued > 0) countParts.push(theme.fg("dim", `${vm.summary.queued} queued`));
|
|
633
|
+
if (vm.summary.failed > 0) countParts.push(theme.fg("error", `${vm.summary.failed} ✗`));
|
|
634
|
+
if (vm.summary.blocked > 0) countParts.push(theme.fg("warning", `${vm.summary.blocked} blocked`));
|
|
635
|
+
if (vm.summary.stalled > 0) countParts.push(theme.fg("warning", `${vm.summary.stalled} stalled`));
|
|
636
|
+
if (countParts.length > 0) {
|
|
637
|
+
lines.push(truncateToWidth(" " + countParts.join(theme.fg("dim", " · ")), width));
|
|
638
|
+
}
|
|
639
|
+
lines.push("");
|
|
640
|
+
|
|
641
|
+
// ── Lane cards ─────────────────────────────────
|
|
642
|
+
if (vm.laneCards.length > 0 && (vm.phase === "executing" || vm.phase === "merging" || vm.phase === "paused")) {
|
|
643
|
+
const arrowWidth = 3;
|
|
644
|
+
const minCardWidth = 18;
|
|
645
|
+
const maxCols = Math.max(1, Math.floor((width + arrowWidth) / (minCardWidth + arrowWidth)));
|
|
646
|
+
const cols = Math.min(vm.laneCards.length, maxCols);
|
|
647
|
+
const colWidth = Math.max(minCardWidth, Math.floor((width - arrowWidth * (cols - 1)) / cols));
|
|
648
|
+
|
|
649
|
+
for (let rowStart = 0; rowStart < vm.laneCards.length; rowStart += cols) {
|
|
650
|
+
const rowCards = vm.laneCards.slice(rowStart, rowStart + cols);
|
|
651
|
+
const rendered = rowCards.map(c => renderLaneCard(c, colWidth, theme));
|
|
652
|
+
|
|
653
|
+
if (rendered.length > 0) {
|
|
654
|
+
const cardHeight = rendered[0].length;
|
|
655
|
+
for (let line = 0; line < cardHeight; line++) {
|
|
656
|
+
let row = rendered[0][line];
|
|
657
|
+
for (let c = 1; c < rendered.length; c++) {
|
|
658
|
+
row += " "; // spacer between cards
|
|
659
|
+
row += rendered[c][line];
|
|
660
|
+
}
|
|
661
|
+
lines.push(truncateToWidth(row, width));
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// ── Terminal states (completed/failed/stopped) ──
|
|
668
|
+
if (vm.phase === "completed") {
|
|
669
|
+
lines.push(truncateToWidth(
|
|
670
|
+
theme.fg("success", " ✅ Batch complete"),
|
|
671
|
+
width,
|
|
672
|
+
));
|
|
673
|
+
} else if (vm.phase === "failed") {
|
|
674
|
+
lines.push(truncateToWidth(
|
|
675
|
+
theme.fg("error", " ❌ Batch failed"),
|
|
676
|
+
width,
|
|
677
|
+
));
|
|
678
|
+
for (const err of vm.errors.slice(0, 3)) {
|
|
679
|
+
lines.push(truncateToWidth(
|
|
680
|
+
theme.fg("error", ` ${err.slice(0, 80)}`),
|
|
681
|
+
width,
|
|
682
|
+
));
|
|
683
|
+
}
|
|
684
|
+
} else if (vm.phase === "stopped") {
|
|
685
|
+
lines.push(truncateToWidth(
|
|
686
|
+
theme.fg("error", ` ⛔ Stopped by ${vm.failurePolicy || "policy"}`),
|
|
687
|
+
width,
|
|
688
|
+
));
|
|
689
|
+
} else if (vm.phase === "merging") {
|
|
690
|
+
lines.push("");
|
|
691
|
+
lines.push(truncateToWidth(
|
|
692
|
+
theme.fg("accent", " 🔀 Merging lane branches into develop..."),
|
|
693
|
+
width,
|
|
694
|
+
));
|
|
695
|
+
} else if (vm.phase === "paused") {
|
|
696
|
+
lines.push("");
|
|
697
|
+
lines.push(truncateToWidth(
|
|
698
|
+
theme.fg("warning", " ⏸ Batch paused — lanes will stop after current tasks"),
|
|
699
|
+
width,
|
|
700
|
+
));
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// ── Footer: attach hint ───────────────────────
|
|
704
|
+
if (vm.attachHint && (vm.phase === "executing" || vm.phase === "merging" || vm.phase === "paused")) {
|
|
705
|
+
lines.push("");
|
|
706
|
+
lines.push(truncateToWidth(
|
|
707
|
+
theme.fg("dim", ` 💡 ${vm.attachHint}`),
|
|
708
|
+
width,
|
|
709
|
+
));
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
return lines;
|
|
713
|
+
},
|
|
714
|
+
invalidate() {},
|
|
715
|
+
};
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
|