@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/README.md +311 -211
- package/index.ts +309 -41
- package/package.json +1 -1
- package/src/acp-widget.ts +197 -0
- package/src/config/config.ts +9 -0
- package/src/config/types.ts +96 -0
- package/src/dag/dag-executor.ts +966 -0
- package/src/dag/dag-store.ts +408 -0
- package/src/dag/dag-validator.ts +202 -0
- package/src/dag/template-resolver.ts +174 -0
- package/src/management/governance-store.ts +10 -3
- package/src/management/legacy-migration.ts +79 -0
- package/src/management/mailbox-manager.ts +10 -3
- package/src/management/runtime-paths.ts +18 -7
- package/src/management/session-archive-store.ts +1 -1
- package/src/management/session-store-factory.ts +58 -0
- package/src/management/task-store.ts +10 -3
- package/src/management/worker-store.ts +10 -3
- package/src/settings/config.ts +3 -0
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DagStore — File-backed DAG state persistence.
|
|
3
|
+
*
|
|
4
|
+
* One JSON file per DAG lives under `~/.pi/acp-agents/dag/<dagId>.json`,
|
|
5
|
+
* plus a `dag-index.json` tracking summary status for all DAGs.
|
|
6
|
+
*
|
|
7
|
+
* This module is the persistence layer for the `acp-dag-delegation` change
|
|
8
|
+
* (design.md D1, D7). It is intentionally kept separate from `AcpTaskStore`
|
|
9
|
+
* (which manages manually-created tasks) so that wave-based, auto-managed
|
|
10
|
+
* DAG step state does not pollute the manual task namespace.
|
|
11
|
+
*
|
|
12
|
+
* Task 2.1 scope: create the class with a constructor that ensures the
|
|
13
|
+
* DAG directory exists via `safeMkdir`. The full persistence API
|
|
14
|
+
* (create / get / updateStep / updateDagStatus / listAll / findRunning)
|
|
15
|
+
* is implemented in subsequent tasks 2.1a–2.7.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { randomUUID } from "node:crypto";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Validate that a dagId is safe for use in file paths — prevents path
|
|
24
|
+
* traversal attacks via crafted dagId values like `../../etc/passwd`.
|
|
25
|
+
* Allows any alphanumeric + hyphen/underscore string but rejects path
|
|
26
|
+
* separators, dots, and null bytes.
|
|
27
|
+
*/
|
|
28
|
+
const SAFE_DAG_ID_RE = /^[a-zA-Z0-9_-]+$/;
|
|
29
|
+
function assertValidDagId(dagId: string): void {
|
|
30
|
+
if (!SAFE_DAG_ID_RE.test(dagId)) {
|
|
31
|
+
throw new Error(`Invalid dagId format: "${dagId}" — must be alphanumeric/hyphen/underscore only`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
import { safeMkdir } from "../management/safe-mkdir.js";
|
|
35
|
+
import type {
|
|
36
|
+
DagIndexEntry,
|
|
37
|
+
DagRecord,
|
|
38
|
+
DagStatus,
|
|
39
|
+
DagStepRecord,
|
|
40
|
+
DagTaskDefinition,
|
|
41
|
+
} from "../config/types.js";
|
|
42
|
+
|
|
43
|
+
/** Constructor options for {@link DagStore}. */
|
|
44
|
+
export interface DagStoreOptions {
|
|
45
|
+
/** Directory holding `<dagId>.json` files + `dag-index.json`. */
|
|
46
|
+
dagDir: string;
|
|
47
|
+
/** Absolute path to the `dag-index.json` summary file. */
|
|
48
|
+
dagIndexFile: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export class DagStore {
|
|
52
|
+
/** Directory holding `<dagId>.json` files + `dag-index.json`. */
|
|
53
|
+
readonly dagDir: string;
|
|
54
|
+
/** Absolute path to the `dag-index.json` summary file. */
|
|
55
|
+
readonly dagIndexFile: string;
|
|
56
|
+
|
|
57
|
+
constructor(options: DagStoreOptions) {
|
|
58
|
+
this.dagDir = options.dagDir;
|
|
59
|
+
this.dagIndexFile = options.dagIndexFile;
|
|
60
|
+
// Per task 2.1: ensure the DAG directory exists at construction time.
|
|
61
|
+
this.ensureDagDir();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Ensure the DAG directory exists. Idempotent: creates the directory
|
|
66
|
+
* (and any missing parents) if absent, no-op if it already exists.
|
|
67
|
+
*
|
|
68
|
+
* Task 2.1a: exposed publicly so other DagStore operations and the
|
|
69
|
+
* executor's resume path can guarantee the directory exists before
|
|
70
|
+
* touching DAG files.
|
|
71
|
+
*/
|
|
72
|
+
ensureDagDir(): void {
|
|
73
|
+
if (!existsSync(this.dagDir)) {
|
|
74
|
+
safeMkdir(this.dagDir);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Create a new DAG record from a submission definition.
|
|
80
|
+
*
|
|
81
|
+
* Task 2.2: generates a `dagId`, initializes every task as a `pending`
|
|
82
|
+
* step, persists the record to `<dagDir>/<dagId>.json`, and appends a
|
|
83
|
+
* summary entry to `dag-index.json`. Returns the persisted record.
|
|
84
|
+
*/
|
|
85
|
+
create(definition: {
|
|
86
|
+
tasks: DagTaskDefinition[];
|
|
87
|
+
args?: Record<string, string>;
|
|
88
|
+
options?: DagRecord["options"];
|
|
89
|
+
}): DagRecord {
|
|
90
|
+
this.ensureDagDir();
|
|
91
|
+
|
|
92
|
+
const now = new Date().toISOString();
|
|
93
|
+
const dagId = randomUUID();
|
|
94
|
+
|
|
95
|
+
const steps: Record<string, DagStepRecord> = {};
|
|
96
|
+
for (const task of definition.tasks) {
|
|
97
|
+
steps[task.id] = {
|
|
98
|
+
id: task.id,
|
|
99
|
+
agent: task.agent,
|
|
100
|
+
prompt: task.prompt,
|
|
101
|
+
dependsOn: task.dependsOn ?? [],
|
|
102
|
+
gate: task.gate ?? "needs",
|
|
103
|
+
status: "pending",
|
|
104
|
+
output: null,
|
|
105
|
+
retryCount: 0,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const record: DagRecord = {
|
|
110
|
+
dagId,
|
|
111
|
+
tasks: definition.tasks,
|
|
112
|
+
args: definition.args,
|
|
113
|
+
options: definition.options,
|
|
114
|
+
status: "pending",
|
|
115
|
+
steps,
|
|
116
|
+
currentWave: 0,
|
|
117
|
+
totalWaves: 0,
|
|
118
|
+
createdAt: now,
|
|
119
|
+
updatedAt: now,
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
writeFileSync(
|
|
123
|
+
join(this.dagDir, `${dagId}.json`),
|
|
124
|
+
JSON.stringify(record, null, 2) + "\n",
|
|
125
|
+
"utf-8",
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
const entry: DagIndexEntry = {
|
|
129
|
+
dagId,
|
|
130
|
+
status: "pending",
|
|
131
|
+
totalSteps: definition.tasks.length,
|
|
132
|
+
completedSteps: 0,
|
|
133
|
+
failedSteps: 0,
|
|
134
|
+
createdAt: now,
|
|
135
|
+
updatedAt: now,
|
|
136
|
+
};
|
|
137
|
+
this.appendToIndex(entry);
|
|
138
|
+
|
|
139
|
+
return record;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Read and return the {@link DagRecord} for the given `dagId`.
|
|
144
|
+
*
|
|
145
|
+
* Task 2.3: reads `<dagDir>/<dagId>.json` from disk and returns the
|
|
146
|
+
* parsed record. Returns `null` when the file does not exist or is
|
|
147
|
+
* unreadable (corrupt JSON), so callers can treat a missing DAG as a
|
|
148
|
+
* null result rather than a thrown error.
|
|
149
|
+
*/
|
|
150
|
+
get(dagId: string): DagRecord | null {
|
|
151
|
+
assertValidDagId(dagId);
|
|
152
|
+
const file = join(this.dagDir, `${dagId}.json`);
|
|
153
|
+
if (!existsSync(file)) return null;
|
|
154
|
+
try {
|
|
155
|
+
return JSON.parse(readFileSync(file, "utf-8")) as DagRecord;
|
|
156
|
+
} catch {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Apply a state transition to a single step and persist the change.
|
|
163
|
+
*
|
|
164
|
+
* Task 2.4: reads the DAG record, invokes `mutate` with a deep copy of
|
|
165
|
+
* the current step, writes the resulting step (and bumped `updatedAt`)
|
|
166
|
+
* back to `<dagId>.json`, and reconciles `dag-index.json` counters
|
|
167
|
+
* (`completedSteps` / `failedSteps`) against the previous vs. new status.
|
|
168
|
+
*
|
|
169
|
+
* Returns the mutated step on success, or `null` when the DAG or step
|
|
170
|
+
* does not exist (no disk change in that case).
|
|
171
|
+
*/
|
|
172
|
+
updateStep(
|
|
173
|
+
dagId: string,
|
|
174
|
+
stepId: string,
|
|
175
|
+
mutate: (step: DagStepRecord) => DagStepRecord,
|
|
176
|
+
): DagStepRecord | null {
|
|
177
|
+
const record = this.get(dagId);
|
|
178
|
+
if (!record) return null;
|
|
179
|
+
const existing = record.steps[stepId];
|
|
180
|
+
if (!existing) return null;
|
|
181
|
+
|
|
182
|
+
// Hand the mutate callback a deep copy so external mutation of the
|
|
183
|
+
// snapshot cannot corrupt persisted state.
|
|
184
|
+
const previousStatus = existing.status;
|
|
185
|
+
const next = mutate(JSON.parse(JSON.stringify(existing)) as DagStepRecord);
|
|
186
|
+
|
|
187
|
+
record.steps[stepId] = next;
|
|
188
|
+
record.updatedAt = new Date().toISOString();
|
|
189
|
+
|
|
190
|
+
writeFileSync(
|
|
191
|
+
join(this.dagDir, `${dagId}.json`),
|
|
192
|
+
JSON.stringify(record, null, 2) + "\n",
|
|
193
|
+
"utf-8",
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
this.reconcileIndexStepTransition(dagId, previousStatus, next.status, record.updatedAt);
|
|
197
|
+
return next;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Transition the DAG-level status and persist the change.
|
|
202
|
+
*
|
|
203
|
+
* Task 2.5: writes the new `status` + bumped `updatedAt` to
|
|
204
|
+
* `<dagId>.json`, reflects the transition in `dag-index.json` (status +
|
|
205
|
+
* `updatedAt`), and stamps `completedAt` on both stores when the new
|
|
206
|
+
* status is terminal (`completed` / `failed` / `cancelled`). Returns
|
|
207
|
+
* the updated record, or `null` when the DAG does not exist.
|
|
208
|
+
*/
|
|
209
|
+
updateDagStatus(dagId: string, status: DagStatus): DagRecord | null {
|
|
210
|
+
const record = this.get(dagId);
|
|
211
|
+
if (!record) return null;
|
|
212
|
+
|
|
213
|
+
const now = new Date().toISOString();
|
|
214
|
+
record.status = status;
|
|
215
|
+
record.updatedAt = now;
|
|
216
|
+
if (isTerminalDagStatus(status) && !record.completedAt) {
|
|
217
|
+
record.completedAt = now;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
writeFileSync(
|
|
221
|
+
join(this.dagDir, `${dagId}.json`),
|
|
222
|
+
JSON.stringify(record, null, 2) + "\n",
|
|
223
|
+
"utf-8",
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
this.reflectIndexDagStatus(dagId, status, now);
|
|
227
|
+
return record;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Reflect a DAG-level status transition in `dag-index.json`.
|
|
232
|
+
*/
|
|
233
|
+
private reflectIndexDagStatus(
|
|
234
|
+
dagId: string,
|
|
235
|
+
status: DagStatus,
|
|
236
|
+
updatedAt: string,
|
|
237
|
+
): void {
|
|
238
|
+
const index = this.readIndex();
|
|
239
|
+
const entry = index.find((e) => e.dagId === dagId);
|
|
240
|
+
if (!entry) return;
|
|
241
|
+
entry.status = status;
|
|
242
|
+
entry.updatedAt = updatedAt;
|
|
243
|
+
if (isTerminalDagStatus(status) && !entry.completedAt) {
|
|
244
|
+
entry.completedAt = updatedAt;
|
|
245
|
+
}
|
|
246
|
+
writeFileSync(
|
|
247
|
+
this.dagIndexFile,
|
|
248
|
+
JSON.stringify(index, null, 2) + "\n",
|
|
249
|
+
"utf-8",
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Reconcile a single step's status transition against the matching
|
|
255
|
+
* `dag-index.json` entry. Adjusts `completedSteps` / `failedSteps`
|
|
256
|
+
* (incrementing on entry to a tracked terminal state, decrementing on
|
|
257
|
+
* exit) and bumps `updatedAt`.
|
|
258
|
+
*/
|
|
259
|
+
private reconcileIndexStepTransition(
|
|
260
|
+
dagId: string,
|
|
261
|
+
previousStatus: DagStepRecord["status"],
|
|
262
|
+
nextStatus: DagStepRecord["status"],
|
|
263
|
+
updatedAt: string,
|
|
264
|
+
): void {
|
|
265
|
+
if (previousStatus === nextStatus) {
|
|
266
|
+
// Only refresh the timestamp on a no-op status transition.
|
|
267
|
+
this.touchIndexEntry(dagId, updatedAt);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
const index = this.readIndex();
|
|
271
|
+
const entry = index.find((e) => e.dagId === dagId);
|
|
272
|
+
if (!entry) return;
|
|
273
|
+
if (previousStatus === "completed") entry.completedSteps -= 1;
|
|
274
|
+
if (previousStatus === "failed") entry.failedSteps -= 1;
|
|
275
|
+
if (nextStatus === "completed") entry.completedSteps += 1;
|
|
276
|
+
if (nextStatus === "failed") entry.failedSteps += 1;
|
|
277
|
+
entry.updatedAt = updatedAt;
|
|
278
|
+
writeFileSync(
|
|
279
|
+
this.dagIndexFile,
|
|
280
|
+
JSON.stringify(index, null, 2) + "\n",
|
|
281
|
+
"utf-8",
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Bump the `updatedAt` timestamp on a single index entry without
|
|
287
|
+
* touching counters.
|
|
288
|
+
*/
|
|
289
|
+
private touchIndexEntry(dagId: string, updatedAt: string): void {
|
|
290
|
+
const index = this.readIndex();
|
|
291
|
+
const entry = index.find((e) => e.dagId === dagId);
|
|
292
|
+
if (!entry) return;
|
|
293
|
+
entry.updatedAt = updatedAt;
|
|
294
|
+
writeFileSync(
|
|
295
|
+
this.dagIndexFile,
|
|
296
|
+
JSON.stringify(index, null, 2) + "\n",
|
|
297
|
+
"utf-8",
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Return a summary list of every DAG tracked in `dag-index.json`.
|
|
303
|
+
*
|
|
304
|
+
* Task 2.6: pure read over `dag-index.json`. Returns the entries as
|
|
305
|
+
* persisted (in submission order). Returns an empty array when the
|
|
306
|
+
* index file does not yet exist (no DAGs submitted) or is unreadable
|
|
307
|
+
* (missing/corrupt), so callers can branch on length without try/catch.
|
|
308
|
+
* This method MUST NOT mutate the index file.
|
|
309
|
+
*/
|
|
310
|
+
listAll(): DagIndexEntry[] {
|
|
311
|
+
return this.readIndex();
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Scan the DAG directory on disk and return every persisted DAG whose
|
|
316
|
+
* top-level status is `running`.
|
|
317
|
+
*
|
|
318
|
+
* Task 2.7: this is the resume-on-restart entry point. It MUST be a pure
|
|
319
|
+
* disk scan over the per-DAG `<dagId>.json` files — there is no in-memory
|
|
320
|
+
* state to consult after a fresh process start. It returns the full
|
|
321
|
+
* {@link DagRecord} (not just an ID) so the executor can resume from the
|
|
322
|
+
* persisted step states.
|
|
323
|
+
*
|
|
324
|
+
* `stale` DAGs are naturally excluded because `stale` is a distinct
|
|
325
|
+
* status value from `running`; the spec requires stale DAGs to NOT
|
|
326
|
+
* auto-resume. The `dag-index.json` summary file and any malformed or
|
|
327
|
+
* non-DagRecord JSON files are skipped without throwing so a single bad
|
|
328
|
+
* file cannot prevent resume of the others.
|
|
329
|
+
*/
|
|
330
|
+
findRunning(): DagRecord[] {
|
|
331
|
+
if (!existsSync(this.dagDir)) return [];
|
|
332
|
+
const running: DagRecord[] = [];
|
|
333
|
+
for (const entry of readdirSync(this.dagDir)) {
|
|
334
|
+
// Only per-DAG record files are candidates. The index file and any
|
|
335
|
+
// non-JSON files are skipped.
|
|
336
|
+
if (!entry.endsWith(".json")) continue;
|
|
337
|
+
if (entry === "dag-index.json") continue;
|
|
338
|
+
|
|
339
|
+
const file = join(this.dagDir, entry);
|
|
340
|
+
let parsed: unknown;
|
|
341
|
+
try {
|
|
342
|
+
parsed = JSON.parse(readFileSync(file, "utf-8"));
|
|
343
|
+
} catch {
|
|
344
|
+
// Malformed JSON — skip rather than crash the whole resume scan.
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
if (!isDagRecord(parsed)) continue;
|
|
348
|
+
if (parsed.status === "running") {
|
|
349
|
+
running.push(parsed);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return running;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Read the current index, returning an empty list when the file does
|
|
357
|
+
* not yet exist (no DAGs submitted).
|
|
358
|
+
*/
|
|
359
|
+
private readIndex(): DagIndexEntry[] {
|
|
360
|
+
if (!existsSync(this.dagIndexFile)) return [];
|
|
361
|
+
try {
|
|
362
|
+
return JSON.parse(readFileSync(this.dagIndexFile, "utf-8")) as DagIndexEntry[];
|
|
363
|
+
} catch {
|
|
364
|
+
return [];
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Atomically rewrite `dag-index.json` with the appended entry.
|
|
370
|
+
*/
|
|
371
|
+
private appendToIndex(entry: DagIndexEntry): void {
|
|
372
|
+
const index = this.readIndex();
|
|
373
|
+
index.push(entry);
|
|
374
|
+
writeFileSync(
|
|
375
|
+
this.dagIndexFile,
|
|
376
|
+
JSON.stringify(index, null, 2) + "\n",
|
|
377
|
+
"utf-8",
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Whether a DAG-level status marks the DAG as terminal (no further
|
|
384
|
+
* transitions expected). `stale` is intentionally excluded — a stale DAG
|
|
385
|
+
* can be manually re-submitted, but the state itself is not a terminal
|
|
386
|
+
* completion stamp.
|
|
387
|
+
*/
|
|
388
|
+
function isTerminalDagStatus(status: DagStatus): boolean {
|
|
389
|
+
return status === "completed" || status === "failed" || status === "cancelled";
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Runtime type guard used by {@link DagStore.findRunning} to safely narrow
|
|
394
|
+
* an unknown `JSON.parse` result to a {@link DagRecord}. A per-DAG file that
|
|
395
|
+
* fails this check (e.g. a stray config dump) is skipped during the resume
|
|
396
|
+
* scan instead of crashing it.
|
|
397
|
+
*/
|
|
398
|
+
function isDagRecord(value: unknown): value is DagRecord {
|
|
399
|
+
if (typeof value !== "object" || value === null) return false;
|
|
400
|
+
const r = value as Record<string, unknown>;
|
|
401
|
+
return (
|
|
402
|
+
typeof r.dagId === "string" &&
|
|
403
|
+
Array.isArray(r.tasks) &&
|
|
404
|
+
typeof r.status === "string" &&
|
|
405
|
+
typeof r.steps === "object" &&
|
|
406
|
+
r.steps !== null
|
|
407
|
+
);
|
|
408
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DagValidator — static validation of a DAG definition before execution.
|
|
3
|
+
*
|
|
4
|
+
* Runs ahead of any dispatch: cycle detection (DFS), dangling-reference
|
|
5
|
+
* detection, duplicate step ID detection, agent availability check, and
|
|
6
|
+
* reserved step ID rejection. Aligned with the `dag-submission` spec
|
|
7
|
+
* ("Static validation before execution") and design.md risk R4
|
|
8
|
+
* (template-variable collision on reserved prefixes).
|
|
9
|
+
*
|
|
10
|
+
* The `validate()` method is the public entry point (task 3.2). It
|
|
11
|
+
* normalises the agent set, runs an ordered pipeline of internal checks,
|
|
12
|
+
* and returns `{valid, errors}`. Each per-violation `errors[i]` carries
|
|
13
|
+
* the suffix documented in the spec (e.g. `cycle detected: a → b → a`);
|
|
14
|
+
* the tool layer prepends the `DAG validation failed: ` wrapper.
|
|
15
|
+
*
|
|
16
|
+
* Tasks 3.3–3.7 add dedicated per-rule test coverage and may extract the
|
|
17
|
+
* inline checks into named helpers; task 3.8 consolidates coverage.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { DagTaskDefinition } from "../config/types.js";
|
|
21
|
+
|
|
22
|
+
/** Result of validating a DAG definition. */
|
|
23
|
+
export interface DagValidationResult {
|
|
24
|
+
/** `true` when the definition has no violations. */
|
|
25
|
+
valid: boolean;
|
|
26
|
+
/** Human-readable violation messages (one per detected problem). */
|
|
27
|
+
errors: string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Step IDs reserved for template variables; cannot be used as task IDs. */
|
|
31
|
+
const RESERVED_STEP_IDS = new Set(["dag", "step", "agent"]);
|
|
32
|
+
|
|
33
|
+
export class DagValidator {
|
|
34
|
+
/**
|
|
35
|
+
* Validate a DAG task definition against the configured agent set.
|
|
36
|
+
*
|
|
37
|
+
* @param tasks The declarative DAG task list from `acp_dag_submit`.
|
|
38
|
+
* @param agentNames Configured agent names (from `agent_servers`).
|
|
39
|
+
* Accepted as either a `Set` or an array for
|
|
40
|
+
* caller convenience.
|
|
41
|
+
* @returns `{valid, errors}`. `valid === errors.length === 0`.
|
|
42
|
+
*/
|
|
43
|
+
validate(
|
|
44
|
+
tasks: DagTaskDefinition[],
|
|
45
|
+
agentNames: ReadonlySet<string> | string[],
|
|
46
|
+
): DagValidationResult {
|
|
47
|
+
const agents =
|
|
48
|
+
agentNames instanceof Set
|
|
49
|
+
? (agentNames as ReadonlySet<string>)
|
|
50
|
+
: new Set(agentNames);
|
|
51
|
+
|
|
52
|
+
const errors: string[] = [];
|
|
53
|
+
for (const check of this.checks(agents)) {
|
|
54
|
+
errors.push(...check(tasks));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return { valid: errors.length === 0, errors };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Ordered pipeline of validation checks. Order is chosen so cheaper
|
|
62
|
+
* structural checks run before graph traversal:
|
|
63
|
+
* 1. duplicate IDs
|
|
64
|
+
* 2. reserved IDs
|
|
65
|
+
* 3. dangling references (prerequisite for meaningful cycle detection)
|
|
66
|
+
* 4. agent availability
|
|
67
|
+
* 5. cycle detection via DFS
|
|
68
|
+
*/
|
|
69
|
+
private checks(agents: ReadonlySet<string>): Array<(tasks: DagTaskDefinition[]) => string[]> {
|
|
70
|
+
return [
|
|
71
|
+
(tasks) => this.detectDuplicateIds(tasks),
|
|
72
|
+
(tasks) => this.detectReservedIds(tasks),
|
|
73
|
+
(tasks) => this.detectDanglingRefs(tasks),
|
|
74
|
+
(tasks) => this.detectUnknownAgents(tasks, agents),
|
|
75
|
+
(tasks) => this.detectCycles(tasks),
|
|
76
|
+
];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Reject step IDs that appear more than once in the task list. */
|
|
80
|
+
private detectDuplicateIds(tasks: DagTaskDefinition[]): string[] {
|
|
81
|
+
const seen = new Set<string>();
|
|
82
|
+
const reported = new Set<string>();
|
|
83
|
+
const errors: string[] = [];
|
|
84
|
+
for (const task of tasks) {
|
|
85
|
+
if (seen.has(task.id) && !reported.has(task.id)) {
|
|
86
|
+
errors.push(`duplicate step ID: "${task.id}"`);
|
|
87
|
+
reported.add(task.id);
|
|
88
|
+
}
|
|
89
|
+
seen.add(task.id);
|
|
90
|
+
}
|
|
91
|
+
return errors;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Reject step IDs that collide with reserved template-variable prefixes. */
|
|
95
|
+
private detectReservedIds(tasks: DagTaskDefinition[]): string[] {
|
|
96
|
+
const errors: string[] = [];
|
|
97
|
+
const reported = new Set<string>();
|
|
98
|
+
for (const task of tasks) {
|
|
99
|
+
if (RESERVED_STEP_IDS.has(task.id) && !reported.has(task.id)) {
|
|
100
|
+
errors.push(`reserved step ID: "${task.id}"`);
|
|
101
|
+
reported.add(task.id);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return errors;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Reject `dependsOn` entries that point at non-existent step IDs. */
|
|
108
|
+
private detectDanglingRefs(tasks: DagTaskDefinition[]): string[] {
|
|
109
|
+
const ids = new Set(tasks.map((t) => t.id));
|
|
110
|
+
const errors: string[] = [];
|
|
111
|
+
for (const task of tasks) {
|
|
112
|
+
for (const dep of task.dependsOn ?? []) {
|
|
113
|
+
if (!ids.has(dep)) {
|
|
114
|
+
errors.push(
|
|
115
|
+
`dangling reference: task "${task.id}" depends on unknown step "${dep}"`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return errors;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Reject agents not present in the configured `agent_servers` set. */
|
|
124
|
+
private detectUnknownAgents(
|
|
125
|
+
tasks: DagTaskDefinition[],
|
|
126
|
+
agents: ReadonlySet<string>,
|
|
127
|
+
): string[] {
|
|
128
|
+
const errors: string[] = [];
|
|
129
|
+
const reported = new Set<string>();
|
|
130
|
+
for (const task of tasks) {
|
|
131
|
+
if (!agents.has(task.agent) && !reported.has(task.agent)) {
|
|
132
|
+
errors.push(`unknown agent: "${task.agent}"`);
|
|
133
|
+
reported.add(task.agent);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return errors;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Detect cycles via DFS, aligned with the `AcpTaskStore.findDependencyPath()`
|
|
141
|
+
* pattern: a recursive DFS that tracks the current path and a visited set,
|
|
142
|
+
* walking dependency edges (task → `dependsOn`), mirroring how
|
|
143
|
+
* `findDependencyPath()` walks `blockedBy` edges. When the DFS reaches a
|
|
144
|
+
* node already on the active path, the cycle is reconstructed from the
|
|
145
|
+
* path slice and reported as `cycle detected: a → b → a`. Edges to unknown
|
|
146
|
+
* steps are ignored here (dangling refs are reported separately).
|
|
147
|
+
*
|
|
148
|
+
* Uses the classic path + visited-on-stack coloring: `GRAY` = on the
|
|
149
|
+
* current recursion stack (used to detect a back-edge), `BLACK` = fully
|
|
150
|
+
* explored. This is the same visited/path bookkeeping as
|
|
151
|
+
* `findDependencyPath()`, extended with on-stack marking for cycle
|
|
152
|
+
* detection.
|
|
153
|
+
*/
|
|
154
|
+
private detectCycles(tasks: DagTaskDefinition[]): string[] {
|
|
155
|
+
const deps = new Map<string, string[]>();
|
|
156
|
+
for (const task of tasks) {
|
|
157
|
+
deps.set(task.id, task.dependsOn ?? []);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const WHITE = 0; // unvisited
|
|
161
|
+
const GRAY = 1; // on current DFS path (stack)
|
|
162
|
+
const BLACK = 2; // fully explored
|
|
163
|
+
const color = new Map<string, number>();
|
|
164
|
+
for (const task of tasks) color.set(task.id, WHITE);
|
|
165
|
+
|
|
166
|
+
const errors: string[] = [];
|
|
167
|
+
const path: string[] = [];
|
|
168
|
+
|
|
169
|
+
const dfs = (id: string): boolean => {
|
|
170
|
+
color.set(id, GRAY);
|
|
171
|
+
path.push(id);
|
|
172
|
+
for (const dep of deps.get(id) ?? []) {
|
|
173
|
+
if (!color.has(dep)) continue; // dangling — handled elsewhere
|
|
174
|
+
const c = color.get(dep);
|
|
175
|
+
if (c === GRAY) {
|
|
176
|
+
// Back-edge: dep is on the current path → reconstruct the
|
|
177
|
+
// closed loop from the path slice (same path-array
|
|
178
|
+
// technique used by findDependencyPath()).
|
|
179
|
+
const start = path.indexOf(dep);
|
|
180
|
+
const cycle = path.slice(start).join(" → ") + " → " + dep;
|
|
181
|
+
errors.push(`cycle detected: ${cycle}`);
|
|
182
|
+
path.pop();
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
if (c === WHITE && dfs(dep)) {
|
|
186
|
+
path.pop();
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
color.set(id, BLACK);
|
|
191
|
+
path.pop();
|
|
192
|
+
return false;
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
for (const task of tasks) {
|
|
196
|
+
if (color.get(task.id) === WHITE && dfs(task.id)) {
|
|
197
|
+
break; // report the first cycle deterministically
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return errors;
|
|
201
|
+
}
|
|
202
|
+
}
|