pi-long-task 0.1.3 → 0.3.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.
- package/README.md +39 -8
- package/package.json +1 -1
- package/src/coordinator.ts +222 -15
- package/src/index.ts +64 -0
- package/src/render.ts +287 -9
- package/src/task_progress.ts +187 -0
- package/src/types.ts +3 -0
- package/src/worker_session.ts +142 -2
package/src/render.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { AgentToolResult, Theme, ToolRenderResultOptions } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { Text } from "@earendil-works/pi-tui";
|
|
2
|
+
import { Text, truncateToWidth, visibleWidth, type Component } from "@earendil-works/pi-tui";
|
|
3
3
|
|
|
4
|
+
import type { TaskProgressModel, TaskProgressStatus, TaskProgressTask } from "./task_progress.ts";
|
|
4
5
|
import type { CoordinatorCommitSummary, CoordinatorRemainingTask, CoordinatorStatus } from "./types.ts";
|
|
5
6
|
|
|
6
7
|
export interface CoordinatorResultForRendering {
|
|
@@ -15,6 +16,8 @@ export interface CoordinatorResultForRendering {
|
|
|
15
16
|
taskResultPath?: string;
|
|
16
17
|
commits?: CoordinatorCommitSummary[];
|
|
17
18
|
remainingTasks?: CoordinatorRemainingTask[];
|
|
19
|
+
taskProgress?: TaskProgressModel;
|
|
20
|
+
workerCostTotal?: number;
|
|
18
21
|
error?: string;
|
|
19
22
|
}
|
|
20
23
|
|
|
@@ -22,7 +25,7 @@ export interface CoordinatorToolRenderDetails extends CoordinatorResultForRender
|
|
|
22
25
|
runId?: string;
|
|
23
26
|
}
|
|
24
27
|
|
|
25
|
-
type ProgressItemStatus = "empty" | "in_progress" | "done";
|
|
28
|
+
type ProgressItemStatus = "empty" | "in_progress" | "done" | "failed" | "blocked";
|
|
26
29
|
|
|
27
30
|
interface ProgressTaskRenderDetails {
|
|
28
31
|
taskId: string;
|
|
@@ -35,6 +38,72 @@ interface ProgressSubtaskRenderDetails {
|
|
|
35
38
|
status: ProgressItemStatus;
|
|
36
39
|
}
|
|
37
40
|
|
|
41
|
+
const SIDEBAR_MIN_SIDE_BY_SIDE_WIDTH = 84;
|
|
42
|
+
const SIDEBAR_MIN_WIDTH = 26;
|
|
43
|
+
const SIDEBAR_MAX_WIDTH = 40;
|
|
44
|
+
const SIDEBAR_GAP = 2;
|
|
45
|
+
|
|
46
|
+
class LongTaskSidebarShell implements Component {
|
|
47
|
+
private readonly mainText: string;
|
|
48
|
+
private readonly taskProgress: TaskProgressModel;
|
|
49
|
+
private readonly workerCostTotal: number | undefined;
|
|
50
|
+
private readonly theme: Theme;
|
|
51
|
+
|
|
52
|
+
constructor(mainText: string, taskProgress: TaskProgressModel, theme: Theme, workerCostTotal?: number) {
|
|
53
|
+
this.mainText = mainText;
|
|
54
|
+
this.taskProgress = taskProgress;
|
|
55
|
+
this.workerCostTotal = workerCostTotal;
|
|
56
|
+
this.theme = theme;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
render(width: number): string[] {
|
|
60
|
+
if (width < SIDEBAR_MIN_SIDE_BY_SIDE_WIDTH) {
|
|
61
|
+
return this.renderStacked(width);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const sidebarWidth = clamp(Math.floor(width * 0.32), SIDEBAR_MIN_WIDTH, SIDEBAR_MAX_WIDTH);
|
|
65
|
+
const mainWidth = width - sidebarWidth - SIDEBAR_GAP;
|
|
66
|
+
if (mainWidth < 40) {
|
|
67
|
+
return this.renderStacked(width);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const mainLines = renderWrappedLines(this.mainText, mainWidth);
|
|
71
|
+
const sidebarLines = this.renderSidebar(sidebarWidth);
|
|
72
|
+
const height = Math.max(mainLines.length, sidebarLines.length);
|
|
73
|
+
const lines: string[] = [];
|
|
74
|
+
for (let idx = 0; idx < height; idx += 1) {
|
|
75
|
+
const main = padLine(mainLines[idx] ?? "", mainWidth);
|
|
76
|
+
const sidebar = sidebarLines[idx] ?? "";
|
|
77
|
+
lines.push(truncateToWidth(`${main}${" ".repeat(SIDEBAR_GAP)}${sidebar}`, width));
|
|
78
|
+
}
|
|
79
|
+
return lines;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
invalidate(): void {
|
|
83
|
+
// Rendering is computed from current state on each pass.
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private renderStacked(width: number): string[] {
|
|
87
|
+
const mainLines = renderWrappedLines(this.mainText, width);
|
|
88
|
+
const sidebarLines = this.renderSidebar(width);
|
|
89
|
+
return [...mainLines, ...sidebarLines].map((line) => truncateToWidth(line, width));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private renderSidebar(width: number): string[] {
|
|
93
|
+
if (width < 8) {
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const innerWidth = Math.max(0, width - 2);
|
|
98
|
+
const rows = sidebarRows(this.taskProgress, this.theme, this.workerCostTotal);
|
|
99
|
+
return [
|
|
100
|
+
sidebarBorder("Long Task", width, this.theme),
|
|
101
|
+
...rows.map((row) => sidebarRow(row, innerWidth, this.theme)),
|
|
102
|
+
this.theme.fg("borderMuted", `└${"─".repeat(innerWidth)}┘`),
|
|
103
|
+
];
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
38
107
|
export function formatCoordinatorResultMessage(result: CoordinatorResultForRendering): string {
|
|
39
108
|
const resultPath = result.resultPath ?? result.taskResultPath ?? "unknown";
|
|
40
109
|
const remaining = result.remainingTasks ?? [];
|
|
@@ -47,6 +116,10 @@ export function formatCoordinatorResultMessage(result: CoordinatorResultForRende
|
|
|
47
116
|
`TODO file: ${result.todoPath}`,
|
|
48
117
|
];
|
|
49
118
|
|
|
119
|
+
if (result.workerCostTotal) {
|
|
120
|
+
lines.push(`Worker spend: ${formatCost(result.workerCostTotal)}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
50
123
|
const commitLines = commits
|
|
51
124
|
.filter((commit) => commit.hash || commit.error)
|
|
52
125
|
.map((commit) => {
|
|
@@ -84,10 +157,13 @@ export function renderLongTaskToolResult(
|
|
|
84
157
|
result: AgentToolResult<unknown>,
|
|
85
158
|
options: ToolRenderResultOptions,
|
|
86
159
|
theme: Theme,
|
|
87
|
-
):
|
|
160
|
+
): Component {
|
|
88
161
|
const details = recordOrUndefined(result.details);
|
|
89
162
|
if (options.isPartial) {
|
|
90
|
-
|
|
163
|
+
const taskProgress = taskProgressModel(details?.taskProgress);
|
|
164
|
+
const workerCostTotal = numberValue(details?.workerCostTotal);
|
|
165
|
+
const main = renderLongTaskProgress(details, contentText(result), theme);
|
|
166
|
+
return taskProgress ? new LongTaskSidebarShell(main, taskProgress, theme, workerCostTotal) : new Text(main, 0, 0);
|
|
91
167
|
}
|
|
92
168
|
|
|
93
169
|
const finalDetails = longTaskDetails(details);
|
|
@@ -95,7 +171,10 @@ export function renderLongTaskToolResult(
|
|
|
95
171
|
return new Text(contentText(result), 0, 0);
|
|
96
172
|
}
|
|
97
173
|
|
|
98
|
-
|
|
174
|
+
const main = renderLongTaskSummary(finalDetails, options.expanded, theme);
|
|
175
|
+
return finalDetails.taskProgress
|
|
176
|
+
? new LongTaskSidebarShell(main, finalDetails.taskProgress, theme, finalDetails.workerCostTotal)
|
|
177
|
+
: new Text(main, 0, 0);
|
|
99
178
|
}
|
|
100
179
|
|
|
101
180
|
function renderLongTaskProgress(details: Record<string, unknown> | undefined, fallback: string, theme: Theme): string {
|
|
@@ -137,6 +216,7 @@ function renderLongTaskSummary(details: CoordinatorToolRenderDetails, expanded:
|
|
|
137
216
|
details.blockedTasks ? theme.fg("warning", `${details.blockedTasks} blocked`) : undefined,
|
|
138
217
|
remainingCount ? theme.fg("muted", `${remainingCount} remaining`) : undefined,
|
|
139
218
|
commitCount ? theme.fg("success", `${commitCount} commit${commitCount === 1 ? "" : "s"}`) : undefined,
|
|
219
|
+
details.workerCostTotal ? theme.fg("muted", `worker ${formatCost(details.workerCostTotal)}`) : undefined,
|
|
140
220
|
].filter(Boolean);
|
|
141
221
|
|
|
142
222
|
if (!expanded) {
|
|
@@ -173,6 +253,174 @@ function renderLongTaskSummary(details: CoordinatorToolRenderDetails, expanded:
|
|
|
173
253
|
return lines.join("\n");
|
|
174
254
|
}
|
|
175
255
|
|
|
256
|
+
function renderWrappedLines(text: string, width: number): string[] {
|
|
257
|
+
return new Text(text, 0, 0).render(Math.max(1, width)).map((line) => truncateToWidth(line, Math.max(1, width)));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function padLine(line: string, width: number): string {
|
|
261
|
+
return truncateToWidth(line, width, "…", true);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function sidebarRows(taskProgress: TaskProgressModel, theme: Theme, workerCostTotal?: number): string[] {
|
|
265
|
+
const summary = normalizedTaskProgressSummary(taskProgress);
|
|
266
|
+
const rows = [theme.fg("toolTitle", theme.bold("Task sidebar")), theme.fg("dim", "Centered timeline")];
|
|
267
|
+
if (workerCostTotal) {
|
|
268
|
+
rows.push(theme.fg("muted", `Worker spend: ${formatCost(workerCostTotal)}`));
|
|
269
|
+
}
|
|
270
|
+
if (summary.totalTasks === 0) {
|
|
271
|
+
rows.push("", theme.fg("muted", "Waiting for TODO plan..."));
|
|
272
|
+
return rows;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
rows.push("", progressBarLine(summary.completedTasks, summary.totalTasks, summary.completedPercent, theme));
|
|
276
|
+
rows.push(progressCountsLine(summary, theme));
|
|
277
|
+
|
|
278
|
+
const currentIndex = focusedTaskIndex(taskProgress);
|
|
279
|
+
if (currentIndex >= 0) {
|
|
280
|
+
rows.push(theme.fg("warning", `Focus: TODO ${taskProgress.tasks[currentIndex]?.taskId ?? "?"}`));
|
|
281
|
+
} else {
|
|
282
|
+
rows.push(theme.fg("success", "No active task"));
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
rows.push("", theme.fg("muted", "Timeline"));
|
|
286
|
+
for (const [index, task] of taskProgress.tasks.entries()) {
|
|
287
|
+
if (currentIndex >= 0 && index === currentIndex && index > 0) {
|
|
288
|
+
rows.push(theme.fg("dim", "──── current ────"));
|
|
289
|
+
}
|
|
290
|
+
rows.push(renderSidebarTaskRow(task, theme));
|
|
291
|
+
if (currentIndex >= 0 && index === currentIndex && index < taskProgress.tasks.length - 1) {
|
|
292
|
+
rows.push(theme.fg("dim", "──── future ─────"));
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return rows;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
interface NormalizedTaskProgressSummary {
|
|
300
|
+
totalTasks: number;
|
|
301
|
+
completedTasks: number;
|
|
302
|
+
failedTasks: number;
|
|
303
|
+
blockedTasks: number;
|
|
304
|
+
pendingTasks: number;
|
|
305
|
+
currentTasks: number;
|
|
306
|
+
completedPercent: number;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function normalizedTaskProgressSummary(taskProgress: TaskProgressModel): NormalizedTaskProgressSummary {
|
|
310
|
+
const totalTasks = taskProgress.summary?.totalTasks ?? taskProgress.tasks.length;
|
|
311
|
+
const completedTasks = taskProgress.summary?.completedTasks ?? countTasksByStatus(taskProgress.tasks, "completed");
|
|
312
|
+
const failedTasks = taskProgress.summary?.failedTasks ?? countTasksByStatus(taskProgress.tasks, "failed");
|
|
313
|
+
const blockedTasks = taskProgress.summary?.blockedTasks ?? countTasksByStatus(taskProgress.tasks, "blocked");
|
|
314
|
+
const pendingTasks = taskProgress.summary?.pendingTasks ?? countTasksByStatus(taskProgress.tasks, "pending");
|
|
315
|
+
const currentTasks = taskProgress.summary?.currentTasks ?? countTasksByStatus(taskProgress.tasks, "current");
|
|
316
|
+
const completedPercent =
|
|
317
|
+
taskProgress.summary?.completedPercent ??
|
|
318
|
+
(totalTasks === 0 ? 100 : Math.round((completedTasks / totalTasks) * 100));
|
|
319
|
+
|
|
320
|
+
return {
|
|
321
|
+
totalTasks,
|
|
322
|
+
completedTasks,
|
|
323
|
+
failedTasks,
|
|
324
|
+
blockedTasks,
|
|
325
|
+
pendingTasks,
|
|
326
|
+
currentTasks,
|
|
327
|
+
completedPercent,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function countTasksByStatus(tasks: readonly TaskProgressTask[], status: TaskProgressStatus): number {
|
|
332
|
+
return tasks.filter((task) => task.status === status).length;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function progressBarLine(completedTasks: number, totalTasks: number, percent: number, theme: Theme): string {
|
|
336
|
+
const width = 10;
|
|
337
|
+
const filled = clamp(totalTasks === 0 ? width : Math.round((completedTasks / totalTasks) * width), 0, width);
|
|
338
|
+
const empty = Math.max(0, width - filled);
|
|
339
|
+
return `${theme.fg("muted", "Progress")} [${theme.fg("success", "#".repeat(filled))}${theme.fg(
|
|
340
|
+
"dim",
|
|
341
|
+
"-".repeat(empty),
|
|
342
|
+
)}] ${completedTasks}/${totalTasks} ${percent}%`;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function progressCountsLine(summary: NormalizedTaskProgressSummary, theme: Theme): string {
|
|
346
|
+
const parts = [
|
|
347
|
+
theme.fg("success", `✓ ${summary.completedTasks}`),
|
|
348
|
+
summary.currentTasks ? theme.fg("warning", `▶ ${summary.currentTasks}`) : undefined,
|
|
349
|
+
summary.pendingTasks ? theme.fg("dim", `○ ${summary.pendingTasks}`) : undefined,
|
|
350
|
+
summary.failedTasks ? theme.fg("error", `✗ ${summary.failedTasks}`) : undefined,
|
|
351
|
+
summary.blockedTasks ? theme.fg("warning", `! ${summary.blockedTasks}`) : undefined,
|
|
352
|
+
].filter(Boolean);
|
|
353
|
+
return parts.join(" · ");
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function focusedTaskIndex(taskProgress: TaskProgressModel): number {
|
|
357
|
+
if (typeof taskProgress.currentIndex === "number" && taskProgress.currentIndex >= 0) {
|
|
358
|
+
return taskProgress.currentIndex;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const currentIndex = taskProgress.tasks.findIndex((task) => task.status === "current" || task.position === "current");
|
|
362
|
+
if (currentIndex >= 0) {
|
|
363
|
+
return currentIndex;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (typeof taskProgress.nextIndex === "number" && taskProgress.nextIndex >= 0) {
|
|
367
|
+
return taskProgress.nextIndex;
|
|
368
|
+
}
|
|
369
|
+
return taskProgress.tasks.findIndex((task) => task.status === "pending");
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function renderSidebarTaskRow(task: TaskProgressTask, theme: Theme): string {
|
|
373
|
+
const { icon, color, label } = sidebarTaskStatusDetails(task.status);
|
|
374
|
+
const attempts =
|
|
375
|
+
task.attempts > 0 && task.status !== "completed"
|
|
376
|
+
? ` · ${task.attempts} attempt${task.attempts === 1 ? "" : "s"}`
|
|
377
|
+
: "";
|
|
378
|
+
const text = `${icon} [${label}] TODO ${task.taskId} — ${task.title}${attempts}`;
|
|
379
|
+
return task.status === "current" ? theme.fg(color, theme.bold(text)) : theme.fg(color, text);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function sidebarTaskStatusDetails(status: TaskProgressStatus): {
|
|
383
|
+
icon: string;
|
|
384
|
+
color: "success" | "warning" | "error" | "dim" | "muted";
|
|
385
|
+
label: string;
|
|
386
|
+
} {
|
|
387
|
+
switch (status) {
|
|
388
|
+
case "completed":
|
|
389
|
+
return { icon: "✓", color: "success", label: "completed" };
|
|
390
|
+
case "current":
|
|
391
|
+
return { icon: "▶", color: "warning", label: "current" };
|
|
392
|
+
case "failed":
|
|
393
|
+
return { icon: "✗", color: "error", label: "failed" };
|
|
394
|
+
case "blocked":
|
|
395
|
+
return { icon: "!", color: "warning", label: "blocked" };
|
|
396
|
+
case "pending":
|
|
397
|
+
return { icon: "○", color: "dim", label: "pending" };
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function sidebarBorder(title: string, width: number, theme: Theme): string {
|
|
402
|
+
const innerWidth = Math.max(0, width - 2);
|
|
403
|
+
const titleText = truncateToWidth(` ${title} `, innerWidth, "");
|
|
404
|
+
const remaining = Math.max(0, innerWidth - visibleWidth(titleText));
|
|
405
|
+
return theme.fg("borderMuted", `┌${titleText}${"─".repeat(remaining)}┐`);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function sidebarRow(row: string, innerWidth: number, theme: Theme): string {
|
|
409
|
+
return `${theme.fg("borderMuted", "│")}${truncateToWidth(row, innerWidth, "…", true)}${theme.fg("borderMuted", "│")}`;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function taskProgressModel(value: unknown): TaskProgressModel | undefined {
|
|
413
|
+
const record = recordOrUndefined(value);
|
|
414
|
+
if (!record || !Array.isArray(record.tasks)) {
|
|
415
|
+
return undefined;
|
|
416
|
+
}
|
|
417
|
+
return value as TaskProgressModel;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function clamp(value: number, min: number, max: number): number {
|
|
421
|
+
return Math.min(max, Math.max(min, value));
|
|
422
|
+
}
|
|
423
|
+
|
|
176
424
|
function progressTaskDetails(value: unknown): ProgressTaskRenderDetails | undefined {
|
|
177
425
|
const record = recordOrUndefined(value);
|
|
178
426
|
const taskId = stringValue(record?.taskId);
|
|
@@ -200,18 +448,32 @@ function progressSubtaskDetails(value: unknown): ProgressSubtaskRenderDetails[]
|
|
|
200
448
|
}
|
|
201
449
|
|
|
202
450
|
function progressItemStatus(value: unknown): ProgressItemStatus | undefined {
|
|
203
|
-
return value === "empty" || value === "in_progress" || value === "done"
|
|
451
|
+
return value === "empty" || value === "in_progress" || value === "done" || value === "failed" || value === "blocked"
|
|
452
|
+
? value
|
|
453
|
+
: undefined;
|
|
204
454
|
}
|
|
205
455
|
|
|
206
456
|
function progressBubble(status: ProgressItemStatus, theme: Theme): string {
|
|
207
|
-
|
|
457
|
+
switch (status) {
|
|
458
|
+
case "empty":
|
|
459
|
+
return theme.fg("dim", "○");
|
|
460
|
+
case "failed":
|
|
461
|
+
return theme.fg("error", "✗");
|
|
462
|
+
case "blocked":
|
|
463
|
+
return theme.fg("warning", "!");
|
|
464
|
+
default:
|
|
465
|
+
return theme.fg(progressTextColor(status), "●");
|
|
466
|
+
}
|
|
208
467
|
}
|
|
209
468
|
|
|
210
|
-
function progressTextColor(status: ProgressItemStatus): "success" | "warning" | "dim" {
|
|
469
|
+
function progressTextColor(status: ProgressItemStatus): "success" | "warning" | "dim" | "error" {
|
|
211
470
|
if (status === "done") {
|
|
212
471
|
return "success";
|
|
213
472
|
}
|
|
214
|
-
if (status === "
|
|
473
|
+
if (status === "failed") {
|
|
474
|
+
return "error";
|
|
475
|
+
}
|
|
476
|
+
if (status === "in_progress" || status === "blocked") {
|
|
215
477
|
return "warning";
|
|
216
478
|
}
|
|
217
479
|
return "dim";
|
|
@@ -252,6 +514,8 @@ function longTaskDetails(details: Record<string, unknown> | undefined): Coordina
|
|
|
252
514
|
runId: stringValue(details.runId),
|
|
253
515
|
commits: commitSummaries(details.commits),
|
|
254
516
|
remainingTasks: remainingTaskSummaries(details.remainingTasks),
|
|
517
|
+
taskProgress: taskProgressModel(details.taskProgress),
|
|
518
|
+
workerCostTotal: nonNegativeNumberValue(details.workerCostTotal),
|
|
255
519
|
error: stringValue(details.error),
|
|
256
520
|
};
|
|
257
521
|
}
|
|
@@ -341,6 +605,20 @@ function numberValue(value: unknown): number | undefined {
|
|
|
341
605
|
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
342
606
|
}
|
|
343
607
|
|
|
608
|
+
function nonNegativeNumberValue(value: unknown): number | undefined {
|
|
609
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function formatCost(value: number): string {
|
|
613
|
+
if (value === 0) {
|
|
614
|
+
return "$0";
|
|
615
|
+
}
|
|
616
|
+
if (value < 0.01) {
|
|
617
|
+
return `$${value.toFixed(4)}`;
|
|
618
|
+
}
|
|
619
|
+
return `$${value.toFixed(2)}`;
|
|
620
|
+
}
|
|
621
|
+
|
|
344
622
|
function recordOrUndefined(value: unknown): Record<string, unknown> | undefined {
|
|
345
623
|
return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : undefined;
|
|
346
624
|
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import type { Task, TaskStatusItem } from "./todo_parser.ts";
|
|
2
|
+
|
|
3
|
+
export const TASK_PROGRESS_STATUS_VALUES = ["pending", "current", "completed", "failed", "blocked"] as const;
|
|
4
|
+
export type TaskProgressStatus = (typeof TASK_PROGRESS_STATUS_VALUES)[number];
|
|
5
|
+
|
|
6
|
+
export const TASK_PROGRESS_POSITION_VALUES = ["past", "current", "future"] as const;
|
|
7
|
+
export type TaskProgressPosition = (typeof TASK_PROGRESS_POSITION_VALUES)[number];
|
|
8
|
+
|
|
9
|
+
export interface TaskProgressAttempt {
|
|
10
|
+
taskId: string;
|
|
11
|
+
attempt?: number;
|
|
12
|
+
reportedStatus?: string;
|
|
13
|
+
done?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type TaskProgressTaskStatusItem = TaskStatusItem;
|
|
17
|
+
|
|
18
|
+
export interface TaskProgressTask {
|
|
19
|
+
taskId: string;
|
|
20
|
+
title: string;
|
|
21
|
+
status: TaskProgressStatus;
|
|
22
|
+
position: TaskProgressPosition;
|
|
23
|
+
done: boolean;
|
|
24
|
+
statusItems: TaskProgressTaskStatusItem[];
|
|
25
|
+
attempts: number;
|
|
26
|
+
lastReportedStatus?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface TaskProgressSummary {
|
|
30
|
+
totalTasks: number;
|
|
31
|
+
completedTasks: number;
|
|
32
|
+
failedTasks: number;
|
|
33
|
+
blockedTasks: number;
|
|
34
|
+
pendingTasks: number;
|
|
35
|
+
currentTasks: number;
|
|
36
|
+
attemptedTasks: number;
|
|
37
|
+
completionRatio: number;
|
|
38
|
+
completedPercent: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface TaskProgressModel {
|
|
42
|
+
tasks: TaskProgressTask[];
|
|
43
|
+
summary: TaskProgressSummary;
|
|
44
|
+
currentTaskId?: string;
|
|
45
|
+
currentIndex?: number;
|
|
46
|
+
currentTask?: TaskProgressTask;
|
|
47
|
+
nextTaskId?: string;
|
|
48
|
+
nextIndex?: number;
|
|
49
|
+
nextTask?: TaskProgressTask;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface BuildTaskProgressModelOptions {
|
|
53
|
+
tasks: readonly Task[];
|
|
54
|
+
attempts?: readonly TaskProgressAttempt[];
|
|
55
|
+
currentTaskId?: string;
|
|
56
|
+
currentTaskStatus?: TaskProgressStatus;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function buildTaskProgressModel(options: BuildTaskProgressModelOptions): TaskProgressModel {
|
|
60
|
+
const attempts = options.attempts ?? [];
|
|
61
|
+
const attemptStats = attemptsByTask(attempts);
|
|
62
|
+
const activeIndex = activeTaskIndex(options.tasks, options.currentTaskId);
|
|
63
|
+
|
|
64
|
+
const progressTasks = options.tasks.map((task, index) => {
|
|
65
|
+
const stats = attemptStats.get(task.taskId);
|
|
66
|
+
const isActive = index === activeIndex;
|
|
67
|
+
const status = taskProgressStatus(task, stats?.lastAttempt, isActive, options.currentTaskStatus);
|
|
68
|
+
|
|
69
|
+
const progressTask: TaskProgressTask = {
|
|
70
|
+
taskId: task.taskId,
|
|
71
|
+
title: task.title,
|
|
72
|
+
status,
|
|
73
|
+
position: taskProgressPosition(index, activeIndex, status),
|
|
74
|
+
done: status === "completed",
|
|
75
|
+
statusItems: task.statusItems.map((item) => ({ ...item })),
|
|
76
|
+
attempts: stats?.attempts ?? 0,
|
|
77
|
+
};
|
|
78
|
+
if (stats?.lastAttempt.reportedStatus) {
|
|
79
|
+
progressTask.lastReportedStatus = stats.lastAttempt.reportedStatus;
|
|
80
|
+
}
|
|
81
|
+
return progressTask;
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const currentIndex = progressTasks.findIndex((task) => task.position === "current");
|
|
85
|
+
const nextIndex = progressTasks.findIndex((task) => task.status === "pending");
|
|
86
|
+
const summary = taskProgressSummary(progressTasks, attempts.length);
|
|
87
|
+
|
|
88
|
+
const model: TaskProgressModel = {
|
|
89
|
+
tasks: progressTasks,
|
|
90
|
+
summary,
|
|
91
|
+
};
|
|
92
|
+
if (currentIndex >= 0) {
|
|
93
|
+
model.currentIndex = currentIndex;
|
|
94
|
+
model.currentTask = progressTasks[currentIndex];
|
|
95
|
+
model.currentTaskId = progressTasks[currentIndex].taskId;
|
|
96
|
+
}
|
|
97
|
+
if (nextIndex >= 0) {
|
|
98
|
+
model.nextIndex = nextIndex;
|
|
99
|
+
model.nextTask = progressTasks[nextIndex];
|
|
100
|
+
model.nextTaskId = progressTasks[nextIndex].taskId;
|
|
101
|
+
}
|
|
102
|
+
return model;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
interface TaskAttemptStats {
|
|
106
|
+
attempts: number;
|
|
107
|
+
lastAttempt: TaskProgressAttempt;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function attemptsByTask(attempts: readonly TaskProgressAttempt[]): Map<string, TaskAttemptStats> {
|
|
111
|
+
const stats = new Map<string, TaskAttemptStats>();
|
|
112
|
+
for (const attempt of attempts) {
|
|
113
|
+
const existing = stats.get(attempt.taskId);
|
|
114
|
+
stats.set(attempt.taskId, {
|
|
115
|
+
attempts: (existing?.attempts ?? 0) + 1,
|
|
116
|
+
lastAttempt: attempt,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
return stats;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function activeTaskIndex(tasks: readonly Task[], currentTaskId: string | undefined): number {
|
|
123
|
+
if (!currentTaskId) {
|
|
124
|
+
return -1;
|
|
125
|
+
}
|
|
126
|
+
return tasks.findIndex((task) => task.taskId === currentTaskId);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function taskProgressStatus(
|
|
130
|
+
task: Task,
|
|
131
|
+
lastAttempt: TaskProgressAttempt | undefined,
|
|
132
|
+
isActive: boolean,
|
|
133
|
+
currentTaskStatus: TaskProgressStatus | undefined,
|
|
134
|
+
): TaskProgressStatus {
|
|
135
|
+
if (task.done || lastAttempt?.done || (isActive && currentTaskStatus === "completed")) {
|
|
136
|
+
return "completed";
|
|
137
|
+
}
|
|
138
|
+
if (isActive) {
|
|
139
|
+
if (currentTaskStatus === "failed" || currentTaskStatus === "blocked") {
|
|
140
|
+
return currentTaskStatus;
|
|
141
|
+
}
|
|
142
|
+
return "current";
|
|
143
|
+
}
|
|
144
|
+
if (lastAttempt?.reportedStatus === "blocked") {
|
|
145
|
+
return "blocked";
|
|
146
|
+
}
|
|
147
|
+
if (lastAttempt) {
|
|
148
|
+
return "failed";
|
|
149
|
+
}
|
|
150
|
+
return "pending";
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function taskProgressPosition(index: number, activeIndex: number, status: TaskProgressStatus): TaskProgressPosition {
|
|
154
|
+
if (activeIndex >= 0) {
|
|
155
|
+
if (index < activeIndex) {
|
|
156
|
+
return "past";
|
|
157
|
+
}
|
|
158
|
+
if (index === activeIndex) {
|
|
159
|
+
return "current";
|
|
160
|
+
}
|
|
161
|
+
return "future";
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return status === "pending" || status === "current" ? "future" : "past";
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function taskProgressSummary(tasks: readonly TaskProgressTask[], attemptedTasks: number): TaskProgressSummary {
|
|
168
|
+
const totalTasks = tasks.length;
|
|
169
|
+
const completedTasks = tasks.filter((task) => task.status === "completed").length;
|
|
170
|
+
const failedTasks = tasks.filter((task) => task.status === "failed").length;
|
|
171
|
+
const blockedTasks = tasks.filter((task) => task.status === "blocked").length;
|
|
172
|
+
const pendingTasks = tasks.filter((task) => task.status === "pending").length;
|
|
173
|
+
const currentTasks = tasks.filter((task) => task.status === "current").length;
|
|
174
|
+
const completionRatio = totalTasks === 0 ? 1 : completedTasks / totalTasks;
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
totalTasks,
|
|
178
|
+
completedTasks,
|
|
179
|
+
failedTasks,
|
|
180
|
+
blockedTasks,
|
|
181
|
+
pendingTasks,
|
|
182
|
+
currentTasks,
|
|
183
|
+
attemptedTasks,
|
|
184
|
+
completionRatio,
|
|
185
|
+
completedPercent: Math.round(completionRatio * 100),
|
|
186
|
+
};
|
|
187
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Static } from "typebox";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
3
|
|
|
4
|
+
import type { TaskProgressModel } from "./task_progress.ts";
|
|
4
5
|
import type { SessionOutcome } from "./worker_session.ts";
|
|
5
6
|
|
|
6
7
|
export const PiLongTaskParams = Type.Object(
|
|
@@ -60,6 +61,8 @@ export interface PiLongTaskResult {
|
|
|
60
61
|
commitError?: string;
|
|
61
62
|
commitSkipped?: string;
|
|
62
63
|
}>;
|
|
64
|
+
taskProgress: TaskProgressModel;
|
|
65
|
+
workerCostTotal: number;
|
|
63
66
|
commit: boolean;
|
|
64
67
|
error?: string;
|
|
65
68
|
}
|