@buihongduc132/pi-acp-agents 0.3.1 → 0.4.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/src/acp-widget.ts CHANGED
@@ -8,9 +8,39 @@
8
8
  import type { Theme, ThemeColor } from "@mariozechner/pi-coding-agent";
9
9
  import type { Component } from "@mariozechner/pi-tui";
10
10
  import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
11
+ import type { DagIndexEntry } from "./config/types.js";
11
12
 
12
13
  // ── Types ──────────────────────────────────────────────────────────
13
14
 
15
+ /** DAG lifecycle status. Mirrors `DagStore` / `DagIndexEntry` status values. */
16
+ export type DagStatus =
17
+ | "pending"
18
+ | "running"
19
+ | "completed"
20
+ | "failed"
21
+ | "cancelled"
22
+ | "stale";
23
+
24
+ /**
25
+ * DAG summary row for the ACP widget.
26
+ *
27
+ * Mapped from `DagIndexEntry` (see `index.ts` wiring): `totalSteps` → `total`,
28
+ * `completedSteps` → `completed`, `failedSteps` → `failed`. Wave info is
29
+ * optional since the index entry doesn't always carry it.
30
+ */
31
+ export interface AcpWidgetDag {
32
+ dagId: string;
33
+ status: DagStatus;
34
+ total: number;
35
+ completed: number;
36
+ failed: number;
37
+ cancelled: number;
38
+ currentWave?: number;
39
+ totalWaves?: number;
40
+ createdAt: Date;
41
+ updatedAt: Date;
42
+ }
43
+
14
44
  export type AcpSessionStatus = "active" | "idle" | "stale" | "error";
15
45
 
16
46
  export interface AcpWidgetSession {
@@ -70,6 +100,8 @@ export interface AcpWidgetState {
70
100
  defaultAgent?: string;
71
101
  activity: AcpWidgetActivity;
72
102
  workers?: AcpWidgetWorker[];
103
+ /** DAG progress rows, populated from `DagStore.listAll()` in `getWidgetState()`. Optional for backwards-compat with fixtures that predate DAGs. */
104
+ dags?: AcpWidgetDag[];
73
105
  }
74
106
 
75
107
  // ── Status styling ──────────────────────────────────────────────────
@@ -101,6 +133,19 @@ const WORKER_STATUS_ICON: Record<string, { icon: string; color: ThemeColor }> =
101
133
  offline: { icon: "✕", color: "dim" },
102
134
  };
103
135
 
136
+ /**
137
+ * DAG status → `{ icon, color }` styling. Reuses the existing widget palette
138
+ * (`success`/`warning`/`error`/`muted`/`dim`/`accent`) — no new colors introduced.
139
+ */
140
+ export const DAG_STATUS_ICON: Record<DagStatus, { icon: string; color: ThemeColor }> = {
141
+ running: { icon: "●", color: "accent" },
142
+ completed: { icon: "✓", color: "success" },
143
+ failed: { icon: "✕", color: "error" },
144
+ cancelled: { icon: "◻", color: "dim" },
145
+ pending: { icon: "·", color: "muted" },
146
+ stale: { icon: "◻", color: "warning" },
147
+ };
148
+
104
149
  // ── Helpers ─────────────────────────────────────────────────────────
105
150
 
106
151
  /** Pad a string to a visible width, accounting for ANSI escape codes. */
@@ -122,6 +167,146 @@ function shortId(id: string): string {
122
167
  return id.length <= 8 ? id : id.slice(0, 8) + "…";
123
168
  }
124
169
 
170
+ /**
171
+ * Format a DAG progress bar.
172
+ *
173
+ * Filled blocks (`█`) = `completed + failed`; empty blocks (`░`) = the
174
+ * remainder up to the bar width (`min(total, 8)`). When `total === 0` returns
175
+ * an empty string (no progress to show).
176
+ *
177
+ * Example: `formatProgress(2, 0, 5)` → `[██░░░] 2/5`.
178
+ */
179
+ /**
180
+ * Map a persisted `DagIndexEntry` (summary row from `dag-index.json`) into an
181
+ * `AcpWidgetDag` row for the TUI widget.
182
+ *
183
+ * This is the single authoritative place where the two shapes are bridged.
184
+ * Field-name remapping (task 3.4 verification):
185
+ *
186
+ * DagIndexEntry → AcpWidgetDag
187
+ * ────────────────────────────────────────────
188
+ * dagId → dagId (identity)
189
+ * status → status (identity)
190
+ * totalSteps → total (RENAMED)
191
+ * completedSteps → completed (RENAMED)
192
+ * failedSteps → failed (RENAMED)
193
+ * (absent) → cancelled = 0 (index carries no cancelled count)
194
+ * (absent) → currentWave? (index carries no wave info → undefined)
195
+ * (absent) → totalWaves? (index carries no wave info → undefined)
196
+ * createdAt: string → createdAt: Date (ISO → Date)
197
+ * updatedAt: string → updatedAt: Date (ISO → Date)
198
+ *
199
+ * Wave info (`currentWave`, `totalWaves`) is intentionally NOT pulled from the
200
+ * index — `DagIndexEntry` does not carry it. The widget renders wave info only
201
+ * when richer sources populate it; for the `DagStore.listAll()` path they
202
+ * remain undefined and the row omits the wave segment.
203
+ */
204
+ export function dagIndexEntryToWidgetDag(entry: DagIndexEntry): AcpWidgetDag {
205
+ return {
206
+ dagId: entry.dagId,
207
+ status: entry.status,
208
+ total: entry.totalSteps,
209
+ completed: entry.completedSteps,
210
+ failed: entry.failedSteps,
211
+ cancelled: 0,
212
+ currentWave: undefined,
213
+ totalWaves: undefined,
214
+ createdAt: new Date(entry.createdAt),
215
+ updatedAt: new Date(entry.updatedAt),
216
+ };
217
+ }
218
+
219
+ export function formatProgress(
220
+ completed: number,
221
+ failed: number,
222
+ total: number,
223
+ ): string {
224
+ if (total <= 0) return "";
225
+ const width = Math.min(total, 8);
226
+ const filled = Math.max(0, Math.min(completed + failed, width));
227
+ const empty = width - filled;
228
+ const bar = "█".repeat(filled) + "░".repeat(empty);
229
+ return `[${bar}] ${completed}/${total}`;
230
+ }
231
+
232
+ /**
233
+ * Render a single DAG summary row (plain text — no theme coloring).
234
+ *
235
+ * Format: `<icon> <dagId> <progress> wave <w>/<totalW> <age> [fail:<failed>]`
236
+ * - the `wave <w>/<totalW>` segment is omitted when `totalWaves` is absent
237
+ * - the `[fail:<failed>]` segment is omitted when `failed === 0`
238
+ *
239
+ * The `<icon>` comes from `DAG_STATUS_ICON[dag.status]`. `<progress>` comes
240
+ * from `formatProgress`. `<age>` comes from `timeAgo(dag.updatedAt)`.
241
+ */
242
+ export function renderDagRow(dag: AcpWidgetDag): string {
243
+ const icon = DAG_STATUS_ICON[dag.status]?.icon ?? "○";
244
+ const parts: string[] = [icon, dag.dagId];
245
+
246
+ const progress = formatProgress(dag.completed, dag.failed, dag.total);
247
+ if (progress) parts.push(progress);
248
+
249
+ if (dag.totalWaves !== undefined) {
250
+ parts.push(`wave ${dag.currentWave ?? 0}/${dag.totalWaves}`);
251
+ }
252
+
253
+ parts.push(timeAgo(dag.updatedAt));
254
+
255
+ if (dag.failed > 0) {
256
+ parts.push(`[fail:${dag.failed}]`);
257
+ }
258
+
259
+ return parts.join(" ");
260
+ }
261
+
262
+ /**
263
+ * Render a collapsed one-line summary of recent DAGs (D2).
264
+ *
265
+ * Each entry renders as `<dagId>:<icon>` joined by single spaces. The list is
266
+ * capped at 5 entries (preserving input order) to keep the widget bounded.
267
+ *
268
+ * Example: `renderDagSummary([completed, failed])` → `a1b2c3:✓ d4e5f6:✕`.
269
+ */
270
+ export function renderDagSummary(dags: AcpWidgetDag[]): string {
271
+ return dags
272
+ .slice(0, 5)
273
+ .map((dag) => `${dag.dagId}:${DAG_STATUS_ICON[dag.status]?.icon ?? "○"}`)
274
+ .join(" ");
275
+ }
276
+
277
+ /**
278
+ * Render the full DAG section for the widget state (task 2.3).
279
+ *
280
+ * Decision rules (see `design.md` D2/D3/D4):
281
+ * - When `dags` is absent or empty → return `""` (no DAG section rendered).
282
+ * - When any DAG has `status === "running"` → return one `renderDagRow` line per
283
+ * entry (joined by `\n`), so users get live per-DAG progress.
284
+ * - Otherwise (no running DAGs but some completed/failed/cancelled) → return a
285
+ * collapsed `renderDagSummary` of the recent DAGs, capped at 5 entries.
286
+ *
287
+ * `pending` DAGs never contribute a row (they carry no progress worth surfacing).
288
+ */
289
+ export function renderDagSection(state: AcpWidgetState): string {
290
+ const dags = state.dags;
291
+ if (!dags || dags.length === 0) return "";
292
+
293
+ const visible = dags.filter((d) => d.status !== "pending");
294
+ if (visible.length === 0) return "";
295
+
296
+ // D2 — cap the render list at 5 entries, ordered by `updatedAt` descending
297
+ // (most-recent first). Prevents pathological render cases when many DAGs
298
+ // exist, and matches the widget's bounded-list pattern.
299
+ const recent = [...visible]
300
+ .sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime())
301
+ .slice(0, 5);
302
+
303
+ if (recent.some((d) => d.status === "running")) {
304
+ return recent.map(renderDagRow).join("\n");
305
+ }
306
+
307
+ return renderDagSummary(recent);
308
+ }
309
+
125
310
  /** Time ago string. */
126
311
  function timeAgo(date: Date): string {
127
312
  const ms = Date.now() - date.getTime();
@@ -287,6 +472,18 @@ export function createAcpWidget(deps: AcpWidgetDeps): AcpWidgetFactory {
287
472
  lines.push(truncateToWidth(row, width));
288
473
  }
289
474
 
475
+ // ── DAG progress rows (between sessions and workers — D4) ──
476
+ const dagSection = renderDagSection(state);
477
+ if (dagSection !== "") {
478
+ // Header line only when DAG rows are rendered (task 2.5)
479
+ lines.push(
480
+ truncateToWidth(" " + theme.fg("dim", "─ DAGs ─"), width),
481
+ );
482
+ for (const dagLine of dagSection.split("\n")) {
483
+ lines.push(truncateToWidth(` ${dagLine}`, width));
484
+ }
485
+ }
486
+
290
487
  // ── Worker rows (persistent workers) ──
291
488
  if (state.workers && state.workers.length > 0) {
292
489
  lines.push(
@@ -28,6 +28,8 @@ export const DEFAULT_CONFIG: AcpConfig = {
28
28
  workerShutdownTimeoutMs: 30_000,
29
29
  workerOnlineMs: 60_000,
30
30
  workerStaleMs: 60_000,
31
+ dagStaleTimeoutMs: 3_600_000, // 1 hour default DAG stale timeout
32
+ dagOutputTruncateChars: 8_000, // default truncation limit for injected step outputs
31
33
  modelPolicy: {
32
34
  allowedModels: [],
33
35
  blockedModels: [],
@@ -132,6 +134,11 @@ export function validateConfig(partial: Partial<AcpConfig>): AcpConfig {
132
134
  DEFAULT_CONFIG.circuitBreakerMaxFailures,
133
135
  circuitBreakerResetMs:
134
136
  partial.circuitBreakerResetMs ?? DEFAULT_CONFIG.circuitBreakerResetMs,
137
+ dagStaleTimeoutMs:
138
+ partial.dagStaleTimeoutMs ?? DEFAULT_CONFIG.dagStaleTimeoutMs,
139
+ dagOutputTruncateChars:
140
+ partial.dagOutputTruncateChars ??
141
+ DEFAULT_CONFIG.dagOutputTruncateChars,
135
142
  modelPolicy: {
136
143
  ...DEFAULT_CONFIG.modelPolicy,
137
144
  ...partial.modelPolicy,
@@ -159,6 +166,8 @@ function validateNumericFields(resolved: AcpConfig): void {
159
166
  ["staleTimeoutMs", resolved.staleTimeoutMs],
160
167
  ["healthCheckIntervalMs", resolved.healthCheckIntervalMs],
161
168
  ["circuitBreakerResetMs", resolved.circuitBreakerResetMs],
169
+ ["dagStaleTimeoutMs", resolved.dagStaleTimeoutMs],
170
+ ["dagOutputTruncateChars", resolved.dagOutputTruncateChars],
162
171
  ];
163
172
  for (const [field, val] of numericFields) {
164
173
  if (val !== undefined && val < 0) {
@@ -62,6 +62,10 @@ export interface AcpConfig {
62
62
  agent_aliases?: Record<string, AcpAliasConfig>;
63
63
  /** Timeout for race strategy in ms (default: 30_000 = 30s) */
64
64
  raceTimeoutMs?: number;
65
+ /** DAG stale timeout in ms — a DAG with no step transitions for this long is marked `stale` (default: 3_600_000 = 1 hour). */
66
+ dagStaleTimeoutMs?: number;
67
+ /** Maximum chars of a step output injected into downstream prompts before truncation (default: 8000). */
68
+ dagOutputTruncateChars?: number;
65
69
  runtimeDir?: string;
66
70
  modelPolicy?: {
67
71
  allowedModels?: string[];
@@ -201,3 +205,95 @@ export interface Logger {
201
205
  error(msg: string, data?: unknown): void;
202
206
  debug(msg: string, data?: unknown): void;
203
207
  }
208
+
209
+ // --- DAG delegation types (acp-dag-delegation) ---
210
+
211
+ /**
212
+ * A single declarative task within a submitted DAG.
213
+ *
214
+ * Mirrors the `acp_dag_submit` task shape from design.md (D8).
215
+ */
216
+ export interface DagTaskDefinition {
217
+ /** Unique step identifier (must not be a reserved word: dag, step, agent). */
218
+ id: string;
219
+ /** Agent name; must exist in `agent_servers` config. */
220
+ agent: string;
221
+ /** Prompt text; may contain `{<id>.output}`, `{<id>.status}`, `{dag.args.<key>}` template vars. */
222
+ prompt: string;
223
+ /** Step IDs this step depends on. Default: []. */
224
+ dependsOn?: string[];
225
+ /** Gate type applied to ALL dependencies. Default: "needs". */
226
+ gate?: "needs" | "after";
227
+ }
228
+
229
+ /** Lifecycle status for a DAG step. */
230
+ export type DagStepStatus =
231
+ | "pending"
232
+ | "running"
233
+ | "completed"
234
+ | "failed"
235
+ | "skipped"
236
+ | "cancelled";
237
+
238
+ /** Lifecycle status for a DAG as a whole. */
239
+ export type DagStatus =
240
+ | "pending"
241
+ | "running"
242
+ | "completed"
243
+ | "failed"
244
+ | "cancelled"
245
+ | "stale";
246
+
247
+ /** Runtime execution state for a single DAG step. */
248
+ export interface DagStepRecord {
249
+ id: string;
250
+ agent: string;
251
+ prompt: string;
252
+ dependsOn: string[];
253
+ gate: "needs" | "after";
254
+ status: DagStepStatus;
255
+ /** Text result on success; null/undefined otherwise. */
256
+ output?: string | null;
257
+ /** Error message on failure. */
258
+ error?: string;
259
+ startedAt?: string;
260
+ completedAt?: string;
261
+ durationMs?: number;
262
+ /** Number of retries already attempted for this step. */
263
+ retryCount?: number;
264
+ }
265
+
266
+ /** DAG-level submission options. */
267
+ export interface DagOptions {
268
+ /** Default: true. */
269
+ failFast?: boolean;
270
+ /** Default: 0. */
271
+ maxRetries?: number;
272
+ }
273
+
274
+ /** Full file-backed DAG record persisted to `~/.pi/acp-agents/dag/<dagId>.json`. */
275
+ export interface DagRecord {
276
+ dagId: string;
277
+ tasks: DagTaskDefinition[];
278
+ args?: Record<string, string>;
279
+ options?: DagOptions;
280
+ status: DagStatus;
281
+ steps: Record<string, DagStepRecord>;
282
+ currentWave: number;
283
+ totalWaves: number;
284
+ createdAt: string;
285
+ updatedAt: string;
286
+ completedAt?: string;
287
+ }
288
+
289
+ /** Summary entry stored in `~/.pi/acp-agents/dag/dag-index.json`. */
290
+ export interface DagIndexEntry {
291
+ dagId: string;
292
+ status: DagStatus;
293
+ totalSteps: number;
294
+ completedSteps: number;
295
+ failedSteps: number;
296
+ createdAt: string;
297
+ updatedAt: string;
298
+ completedAt?: string;
299
+ }