brl-subagent 2.3.4
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 +257 -0
- package/package.json +49 -0
- package/presets/code-reviewer.md +37 -0
- package/presets/data-analyst.md +33 -0
- package/presets/debugger.md +36 -0
- package/presets/dev-agent.md +30 -0
- package/presets/rapid-prototyper.md +25 -0
- package/presets/refactorer.md +34 -0
- package/presets/security-auditor.md +39 -0
- package/presets/tech-writer.md +33 -0
- package/presets/test-engineer.md +34 -0
- package/src/concurrency.ts +186 -0
- package/src/diff.ts +102 -0
- package/src/event-bus.ts +86 -0
- package/src/git.ts +187 -0
- package/src/history.ts +177 -0
- package/src/index.ts +3824 -0
- package/src/logging.ts +135 -0
- package/src/messaging.ts +164 -0
- package/src/metrics.ts +221 -0
- package/src/model-availability.ts +73 -0
- package/src/notify-completion.ts +207 -0
- package/src/params.ts +225 -0
- package/src/preflight.ts +150 -0
- package/src/presets.ts +425 -0
- package/src/prompt.ts +169 -0
- package/src/reports.ts +325 -0
- package/src/router.ts +133 -0
- package/src/runner.ts +877 -0
- package/src/sanitize.ts +365 -0
- package/src/scheduler.ts +187 -0
- package/src/session-manager.ts +1065 -0
- package/src/state.ts +590 -0
- package/src/templates.ts +469 -0
- package/src/transcript-tail.ts +327 -0
- package/src/transcript.ts +119 -0
- package/src/tui-format.ts +51 -0
- package/src/tui.ts +3103 -0
- package/src/types.ts +803 -0
- package/src/unit-run.ts +269 -0
- package/src/update.ts +109 -0
- package/src/validate.ts +302 -0
- package/templates/analyze-data.md +9 -0
- package/templates/code-review.md +10 -0
- package/templates/debug-issue.md +9 -0
- package/templates/implement-feature.md +9 -0
- package/templates/prototype.md +9 -0
- package/templates/refactor.md +9 -0
- package/templates/security-audit.md +10 -0
- package/templates/write-docs.md +9 -0
- package/templates/write-tests.md +10 -0
package/src/sanitize.ts
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* brl-subagent — Sanitization (F1, F2, F3)
|
|
3
|
+
*
|
|
4
|
+
* Input validation, environment isolation, and output sanitization
|
|
5
|
+
* to ensure security and robustness of subagent execution.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as path from "node:path";
|
|
9
|
+
import * as fs from "node:fs";
|
|
10
|
+
import { EMPTY_USAGE } from "./types";
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// F1: Input sanitization
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Shell metacharacters that could enable command injection when passed
|
|
18
|
+
* as CLI arguments. We aggressively reject any task containing these.
|
|
19
|
+
*/
|
|
20
|
+
// Intentional no-op: safe with spawn shell:false
|
|
21
|
+
const SHELL_METACHARS = /(?!)/; // matches nothing — safe with shell: false
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Maximum allowed task length to prevent memory exhaustion from
|
|
25
|
+
* excessively long task descriptions.
|
|
26
|
+
*/
|
|
27
|
+
const MAX_TASK_LENGTH = 50_000; // 50KB
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Validate and sanitize the task string.
|
|
31
|
+
* Rejects tasks containing shell metacharacters or exceeding max length.
|
|
32
|
+
*/
|
|
33
|
+
export function sanitizeTask(task: string): { ok: true; value: string } | { ok: false; error: string } {
|
|
34
|
+
if (!task || task.trim().length === 0) {
|
|
35
|
+
return { ok: false, error: "Task must not be empty." };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (task.length > MAX_TASK_LENGTH) {
|
|
39
|
+
return {
|
|
40
|
+
ok: false,
|
|
41
|
+
error: `Task too long (${task.length} chars). Maximum is ${MAX_TASK_LENGTH}.`,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (SHELL_METACHARS.test(task)) {
|
|
46
|
+
const matches = task.match(SHELL_METACHARS);
|
|
47
|
+
const found = [...new Set(matches)].map((c) => `'${c}'`).join(", ");
|
|
48
|
+
return {
|
|
49
|
+
ok: false,
|
|
50
|
+
error: `Task contains disallowed characters: ${found}. Remove shell metacharacters and try again.`,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return { ok: true, value: task.trim() };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Validate and resolve a CWD path for subagent execution.
|
|
59
|
+
* - Must exist as a directory
|
|
60
|
+
* - Must be an absolute path (within reasonable bounds)
|
|
61
|
+
*/
|
|
62
|
+
export function validateCwd(raw: string | undefined, defaultCwd: string): { ok: true; value: string } | { ok: false; error: string } {
|
|
63
|
+
if (!raw) return { ok: true, value: defaultCwd };
|
|
64
|
+
|
|
65
|
+
const resolved = path.resolve(defaultCwd, raw);
|
|
66
|
+
|
|
67
|
+
// Prevent traversal to sensitive system directories
|
|
68
|
+
const dangerousPrefixes = ["/etc", "/sys", "/proc", "/dev", "/root"];
|
|
69
|
+
for (const prefix of dangerousPrefixes) {
|
|
70
|
+
if (resolved === prefix || resolved.startsWith(prefix + "/")) {
|
|
71
|
+
return { ok: false, error: `CWD path "${raw}" resolves to restricted system directory "${resolved}".` };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
const stat = fs.statSync(resolved);
|
|
77
|
+
if (!stat.isDirectory()) {
|
|
78
|
+
return { ok: false, error: `CWD path "${raw}" is not a directory.` };
|
|
79
|
+
}
|
|
80
|
+
} catch {
|
|
81
|
+
return { ok: false, error: `CWD path "${raw}" does not exist.` };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { ok: true, value: resolved };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Validate an output file path to prevent writing outside the project.
|
|
89
|
+
* Path must resolve within the project root directory.
|
|
90
|
+
*/
|
|
91
|
+
export function validateOutputFile(raw: string, projectRoot: string): { ok: true; value: string } | { ok: false; error: string } {
|
|
92
|
+
const resolved = path.resolve(projectRoot, raw);
|
|
93
|
+
|
|
94
|
+
// Must be within the project root
|
|
95
|
+
const rel = path.relative(projectRoot, resolved);
|
|
96
|
+
if (rel.startsWith("..") || path.isAbsolute(rel)) {
|
|
97
|
+
return {
|
|
98
|
+
ok: false,
|
|
99
|
+
error: `Output file path "${raw}" escapes the project root. Must be within the project directory.`,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Must not point to a directory
|
|
104
|
+
try {
|
|
105
|
+
const stat = fs.statSync(resolved);
|
|
106
|
+
if (stat.isDirectory()) {
|
|
107
|
+
return { ok: false, error: `Output file path "${raw}" is a directory, not a file.` };
|
|
108
|
+
}
|
|
109
|
+
} catch {
|
|
110
|
+
// File doesn't exist yet — that's fine, it'll be created
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return { ok: true, value: resolved };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// F24: Agent id validation
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* F24: Agent ids are generated by this module's callers (generateUUID) and
|
|
122
|
+
* reach file paths via join(). path.join does NOT sanitize "../" or absolute
|
|
123
|
+
* segments, so any externally-supplied id (LLM tool params such as
|
|
124
|
+
* get_agent_status/steer_subagent/get_agent_result) must be validated before
|
|
125
|
+
* touching the filesystem — otherwise arbitrary .json read/overwrite via
|
|
126
|
+
* `join(STORAGE_DIR, \`${id}.json\`)`.
|
|
127
|
+
*
|
|
128
|
+
* Only UUIDs generated by this codebase are ever used as agent ids.
|
|
129
|
+
*/
|
|
130
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Validate an agent id before it is interpolated into a file path.
|
|
134
|
+
* Throws on any non-UUID value (path traversal, absolute paths, etc.).
|
|
135
|
+
*/
|
|
136
|
+
export function assertSafeAgentId(id: string): void {
|
|
137
|
+
if (!UUID_RE.test(id)) {
|
|
138
|
+
throw new Error(`Invalid agent id: ${id}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// F2: Environment isolation
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Safe environment variables to pass to subagent processes.
|
|
148
|
+
* Only variables needed for the subagent to function are included.
|
|
149
|
+
* API keys, secrets, and other sensitive env vars are NOT propagated.
|
|
150
|
+
*/
|
|
151
|
+
const SAFE_ENV_KEYS = new Set([
|
|
152
|
+
"PATH",
|
|
153
|
+
"HOME",
|
|
154
|
+
"USER",
|
|
155
|
+
"LANG",
|
|
156
|
+
"LC_ALL",
|
|
157
|
+
"LC_CTYPE",
|
|
158
|
+
"TMPDIR",
|
|
159
|
+
"TEMP",
|
|
160
|
+
"TMP",
|
|
161
|
+
"SHELL",
|
|
162
|
+
"TERM",
|
|
163
|
+
"COLORTERM",
|
|
164
|
+
"NO_COLOR",
|
|
165
|
+
"FORCE_COLOR",
|
|
166
|
+
"NODE_ENV",
|
|
167
|
+
// brl-subagent recursion depth tracker
|
|
168
|
+
"BRL_SUBAGENT_DEPTH",
|
|
169
|
+
]);
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Build a filtered environment object containing only safe variables.
|
|
173
|
+
* Accepts optional overrides for keys that are computed at spawn time
|
|
174
|
+
* (e.g., BRL_SUBAGENT_DEPTH) rather than inherited from the parent process.
|
|
175
|
+
*/
|
|
176
|
+
export function getSafeEnv(overrides?: Record<string, string>): Record<string, string> {
|
|
177
|
+
const env: Record<string, string> = {};
|
|
178
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
179
|
+
if (value !== undefined && SAFE_ENV_KEYS.has(key)) {
|
|
180
|
+
env[key] = value;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (overrides) {
|
|
184
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
185
|
+
if (value !== undefined) {
|
|
186
|
+
env[key] = value;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return env;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
// F3: Output sanitization
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Regex to match ANSI escape sequences (CSI, OSC, etc.)
|
|
199
|
+
*/
|
|
200
|
+
const ANSI_ESCAPE_RE = /[\u001B\u009B][[\]()#;?]*(?:(?:(?:[a-zA-Z\d]*(?:;[-a-zA-Z\d/#&.:=?%@~_]*)*)?\u0007)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-ntqry=><~]))/g;
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Strip ANSI escape sequences from a string.
|
|
204
|
+
* Subagent output may contain terminal control codes that could
|
|
205
|
+
* interfere with the TUI or be used maliciously.
|
|
206
|
+
*/
|
|
207
|
+
export function stripAnsi(str: string): string {
|
|
208
|
+
return str.replace(ANSI_ESCAPE_RE, "");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Cap output size to prevent subagent results from overwhelming
|
|
213
|
+
* the conductor's context window or TUI.
|
|
214
|
+
*
|
|
215
|
+
* Returns the original string if within limits, or a truncated version
|
|
216
|
+
* with a clear notice about how much was omitted.
|
|
217
|
+
*/
|
|
218
|
+
export function capOutput(output: string, maxBytes: number = 100 * 1024): string {
|
|
219
|
+
const byteLength = Buffer.byteLength(output, "utf8");
|
|
220
|
+
if (byteLength <= maxBytes) return output;
|
|
221
|
+
|
|
222
|
+
// Truncate while keeping valid UTF-8
|
|
223
|
+
let truncated = output.slice(0, maxBytes);
|
|
224
|
+
while (Buffer.byteLength(truncated, "utf8") > maxBytes) {
|
|
225
|
+
truncated = truncated.slice(0, -1);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const omitted = byteLength - Buffer.byteLength(truncated, "utf8");
|
|
229
|
+
return `${truncated}\n\n[Output truncated: ${formatBytes(omitted)} omitted. Full output available in run history details.]`;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function formatBytes(bytes: number): string {
|
|
233
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
234
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
235
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Maximum length for a sanitized error message (issue #30). Error text is
|
|
240
|
+
* persisted to disk AND echoed into the main agent's context — a runaway
|
|
241
|
+
* message (e.g. a swallowed tool dump) would bloat both.
|
|
242
|
+
*/
|
|
243
|
+
const MAX_ERROR_MESSAGE_LENGTH = 2000;
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Replace every occurrence of an absolute path prefix with a neutral
|
|
247
|
+
* placeholder, but ONLY at path boundaries (followed by a path separator or
|
|
248
|
+
* the end of the string). Boundary matching means neutralizing the cwd
|
|
249
|
+
* `/home/user/project` does not clobber a sibling like `/home/user/project2`,
|
|
250
|
+
* and a bare `/` root cwd is left untouched (it would otherwise match every
|
|
251
|
+
* separator in the message).
|
|
252
|
+
*/
|
|
253
|
+
function neutralizePathPrefix(text: string, prefix: string, replacement: string): string {
|
|
254
|
+
if (!prefix || prefix === "/") return text;
|
|
255
|
+
const escaped = prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
256
|
+
// The boundary class must contain BOTH separators: in this template literal,
|
|
257
|
+
// `\\\\` is the escaped backslash in the compiled regex source, so the
|
|
258
|
+
// character class matches `\` (Windows) and `/` (POSIX). With only `/`, a
|
|
259
|
+
// Windows path like `C:\Users\me\project\node_modules\...` would never
|
|
260
|
+
// match mid-string (Fix 2, PR #63 review) and the raw cwd would leak.
|
|
261
|
+
return text.replace(new RegExp(`${escaped}(?=[\\\\/]|$)`, "g"), replacement);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Sanitize an error message before persisting/echoing (issue #30, F7).
|
|
266
|
+
*
|
|
267
|
+
* 1. Strips ANSI escape sequences (reuses `stripAnsi`).
|
|
268
|
+
* 2. Neutralizes the project root: the cwd absolute prefix (the passed `cwd`
|
|
269
|
+
* or `process.cwd()`) becomes `<cwd>` — `/home/user/project/src/foo.ts` →
|
|
270
|
+
* `<cwd>/src/foo.ts`.
|
|
271
|
+
* 3. Neutralizes the directory CONTAINING the project root (typically the
|
|
272
|
+
* user's home in a `~/project` layout): it becomes `<home>`. The label is
|
|
273
|
+
* a neutral placeholder — when the project does not live directly under
|
|
274
|
+
* the home dir (e.g. `/var/www/app` → parent `/var/www`), the parent is
|
|
275
|
+
* still replaced so the workspace root never leaks.
|
|
276
|
+
* 4. Caps length at 2000 chars with an ellipsis marker.
|
|
277
|
+
*
|
|
278
|
+
* Remaining-absolute-path policy: paths OUTSIDE the project root and its
|
|
279
|
+
* parent are left as-is (e.g. `/etc/passwd`, `/usr/bin/node`, `/tmp/x.log`).
|
|
280
|
+
* Issue #30 is specifically about CWD/home disclosure; system paths carry no
|
|
281
|
+
* personal structure and keeping them preserves debugging value. Messages
|
|
282
|
+
* that are short and clean (no ANSI, no cwd/parent prefixes) pass through
|
|
283
|
+
* unchanged. Pure and dependency-free.
|
|
284
|
+
*/
|
|
285
|
+
export function sanitizeErrorMessage(msg: string, cwd?: string): string {
|
|
286
|
+
let out = stripAnsi(msg);
|
|
287
|
+
let base = cwd ? path.normalize(cwd) : process.cwd();
|
|
288
|
+
// path.normalize preserves a trailing separator, which defeats the boundary
|
|
289
|
+
// lookahead mid-string (Fix 1, PR #63 review): a cwd of `/home/u/project/`
|
|
290
|
+
// would never match `/home/u/project/src/...` and the raw cwd would leak as
|
|
291
|
+
// `<home>/project/src/...`. Strip trailing separators — but keep `/` for a
|
|
292
|
+
// bare root cwd (the root-cwd test relies on `/` being left untouched).
|
|
293
|
+
base = base.replace(/[\\/]+$/, "");
|
|
294
|
+
if (!base) base = "/";
|
|
295
|
+
out = neutralizePathPrefix(out, base, "<cwd>");
|
|
296
|
+
const parent = path.dirname(base);
|
|
297
|
+
if (parent !== base) {
|
|
298
|
+
out = neutralizePathPrefix(out, parent, "<home>");
|
|
299
|
+
}
|
|
300
|
+
if (out.length > MAX_ERROR_MESSAGE_LENGTH) {
|
|
301
|
+
out = `${out.slice(0, MAX_ERROR_MESSAGE_LENGTH)}…[truncated]`;
|
|
302
|
+
}
|
|
303
|
+
return out;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ---------------------------------------------------------------------------
|
|
307
|
+
// Crash-result builder (DRY, issue #68)
|
|
308
|
+
// ---------------------------------------------------------------------------
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Build the shared crash-result envelope used by the chain, graph, and single
|
|
312
|
+
* mode catches in index.ts (previously 3× duplicated construction, issue #68).
|
|
313
|
+
*
|
|
314
|
+
* - `details.errorMessage` is sanitized (F7, issues #30/#65) so absolute paths
|
|
315
|
+
* never reach the main agent's context or the persisted errorMessage field.
|
|
316
|
+
* - `details.stderr` is intentionally the RAW error (`String(err)`) — pi only
|
|
317
|
+
* serializes `content` into the LLM context, while the raw text persists to
|
|
318
|
+
* the session file for post-mortems (issue #68 framing: extraction is DRY,
|
|
319
|
+
* not a stderr-content change).
|
|
320
|
+
*/
|
|
321
|
+
export function buildCrashResult(mode: string, err: unknown, cwd: string): {
|
|
322
|
+
content: Array<{ type: "text"; text: string }>;
|
|
323
|
+
details: {
|
|
324
|
+
messages: [];
|
|
325
|
+
usage: typeof EMPTY_USAGE;
|
|
326
|
+
exitCode: number;
|
|
327
|
+
stderr: string;
|
|
328
|
+
errorMessage: string;
|
|
329
|
+
};
|
|
330
|
+
isError: true;
|
|
331
|
+
} {
|
|
332
|
+
const errorMessage = sanitizeErrorMessage(
|
|
333
|
+
err instanceof Error ? (err.message || String(err)) : String(err),
|
|
334
|
+
cwd,
|
|
335
|
+
);
|
|
336
|
+
return {
|
|
337
|
+
content: [{ type: "text", text: `${mode} crashed: ${errorMessage}` }],
|
|
338
|
+
details: {
|
|
339
|
+
messages: [],
|
|
340
|
+
usage: { ...EMPTY_USAGE },
|
|
341
|
+
exitCode: 1,
|
|
342
|
+
stderr: String(err),
|
|
343
|
+
errorMessage,
|
|
344
|
+
},
|
|
345
|
+
isError: true,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// ---------------------------------------------------------------------------
|
|
350
|
+
// Recursion depth tracking
|
|
351
|
+
// ---------------------------------------------------------------------------
|
|
352
|
+
|
|
353
|
+
/** Env var used to track subagent nesting depth. */
|
|
354
|
+
export const DEPTH_ENV_KEY = "BRL_SUBAGENT_DEPTH";
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Read the current subagent depth from the environment.
|
|
358
|
+
* Returns 0 for the main (conductor) process, 1+ for nested subagents.
|
|
359
|
+
*/
|
|
360
|
+
export function getCurrentDepth(): number {
|
|
361
|
+
const raw = process.env[DEPTH_ENV_KEY];
|
|
362
|
+
if (!raw) return 0;
|
|
363
|
+
const parsed = parseInt(raw, 10);
|
|
364
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
|
|
365
|
+
}
|
package/src/scheduler.ts
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* brl-subagent — Dependency Graph Scheduler
|
|
3
|
+
*
|
|
4
|
+
* Cycle detection (DFS), topological sort (Kahn's algorithm),
|
|
5
|
+
* and graph validation for P10 dependency graph mode.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { GraphTask } from "./types";
|
|
9
|
+
import { MAX_GRAPH_TASKS } from "./types";
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Cycle detection — three-color DFS
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Detect cycles in the dependency graph.
|
|
17
|
+
* Returns the task IDs forming the cycle if found, or null if acyclic.
|
|
18
|
+
*/
|
|
19
|
+
export function detectCycle(tasks: GraphTask[]): string[] | null {
|
|
20
|
+
const byId = new Map<string, GraphTask>();
|
|
21
|
+
for (const t of tasks) byId.set(t.id, t);
|
|
22
|
+
|
|
23
|
+
// 0 = white (unvisited), 1 = gray (in current path), 2 = black (done)
|
|
24
|
+
const color = new Map<string, number>();
|
|
25
|
+
for (const t of tasks) color.set(t.id, 0);
|
|
26
|
+
|
|
27
|
+
const path: string[] = [];
|
|
28
|
+
|
|
29
|
+
function dfs(nodeId: string): boolean {
|
|
30
|
+
color.set(nodeId, 1);
|
|
31
|
+
path.push(nodeId);
|
|
32
|
+
|
|
33
|
+
const task = byId.get(nodeId);
|
|
34
|
+
if (task) {
|
|
35
|
+
for (const dep of task.dependsOn ?? []) {
|
|
36
|
+
const depColor = color.get(dep);
|
|
37
|
+
if (depColor === undefined) continue; // reference to non-existent id (validated elsewhere)
|
|
38
|
+
if (depColor === 1) {
|
|
39
|
+
// Found cycle — extract cycle path
|
|
40
|
+
const cycleStart = path.indexOf(dep);
|
|
41
|
+
path.push(dep);
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
if (depColor === 0 && dfs(dep)) {
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
path.pop();
|
|
51
|
+
color.set(nodeId, 2);
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
for (const t of tasks) {
|
|
56
|
+
if (color.get(t.id) === 0) {
|
|
57
|
+
if (dfs(t.id)) {
|
|
58
|
+
return [...path];
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// Topological sort — Kahn's algorithm → execution waves
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Perform topological sort returning execution waves.
|
|
72
|
+
* Each wave contains tasks whose all dependencies are satisfied by previous waves.
|
|
73
|
+
* Tasks within a wave have no dependencies on each other and can run in parallel.
|
|
74
|
+
*/
|
|
75
|
+
export function topologicalSort(tasks: GraphTask[]):
|
|
76
|
+
| { ok: true; waves: GraphTask[][] }
|
|
77
|
+
| { ok: false; error: string } {
|
|
78
|
+
// Check for cycle first
|
|
79
|
+
const cycle = detectCycle(tasks);
|
|
80
|
+
if (cycle) {
|
|
81
|
+
return {
|
|
82
|
+
ok: false,
|
|
83
|
+
error: `Dependency cycle detected: ${cycle.join(" → ")}`,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const byId = new Map<string, GraphTask>();
|
|
88
|
+
for (const t of tasks) byId.set(t.id, t);
|
|
89
|
+
|
|
90
|
+
// Count incoming edges (dependsOn)
|
|
91
|
+
const inDegree = new Map<string, number>();
|
|
92
|
+
for (const t of tasks) {
|
|
93
|
+
inDegree.set(t.id, 0);
|
|
94
|
+
}
|
|
95
|
+
for (const t of tasks) {
|
|
96
|
+
for (const dep of t.dependsOn ?? []) {
|
|
97
|
+
if (byId.has(dep)) {
|
|
98
|
+
inDegree.set(t.id, (inDegree.get(t.id) ?? 0) + 1);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Build adjacency: dep → dependent tasks
|
|
104
|
+
const dependents = new Map<string, string[]>();
|
|
105
|
+
for (const t of tasks) {
|
|
106
|
+
for (const dep of t.dependsOn ?? []) {
|
|
107
|
+
if (byId.has(dep)) {
|
|
108
|
+
if (!dependents.has(dep)) dependents.set(dep, []);
|
|
109
|
+
dependents.get(dep)!.push(t.id);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const waves: GraphTask[][] = [];
|
|
115
|
+
const processed = new Set<string>();
|
|
116
|
+
|
|
117
|
+
// Initial wave: tasks with in-degree 0
|
|
118
|
+
let currentWave = tasks
|
|
119
|
+
.filter((t) => (inDegree.get(t.id) ?? 0) === 0)
|
|
120
|
+
.sort((a, b) => a.id.localeCompare(b.id));
|
|
121
|
+
|
|
122
|
+
while (currentWave.length > 0) {
|
|
123
|
+
waves.push(currentWave);
|
|
124
|
+
|
|
125
|
+
const nextWave: GraphTask[] = [];
|
|
126
|
+
for (const task of currentWave) {
|
|
127
|
+
processed.add(task.id);
|
|
128
|
+
const deps = dependents.get(task.id) ?? [];
|
|
129
|
+
for (const depId of deps) {
|
|
130
|
+
if (processed.has(depId)) continue;
|
|
131
|
+
const newDegree = (inDegree.get(depId) ?? 1) - 1;
|
|
132
|
+
inDegree.set(depId, newDegree);
|
|
133
|
+
if (newDegree === 0) {
|
|
134
|
+
const depTask = byId.get(depId);
|
|
135
|
+
if (depTask) nextWave.push(depTask);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
currentWave = nextWave.sort((a, b) => a.id.localeCompare(b.id));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return { ok: true, waves };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
// Graph validation
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Validate the graph before scheduling.
|
|
152
|
+
* Returns array of error strings (empty = valid).
|
|
153
|
+
*/
|
|
154
|
+
export function validateGraph(tasks: GraphTask[]): string[] {
|
|
155
|
+
const errors: string[] = [];
|
|
156
|
+
|
|
157
|
+
if (tasks.length === 0) {
|
|
158
|
+
errors.push("Graph is empty — provide at least one task");
|
|
159
|
+
return errors;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (tasks.length > MAX_GRAPH_TASKS) {
|
|
163
|
+
errors.push(
|
|
164
|
+
`Graph exceeds maximum of ${MAX_GRAPH_TASKS} tasks (${tasks.length} provided)`,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const ids = new Set<string>();
|
|
169
|
+
for (const t of tasks) {
|
|
170
|
+
if (ids.has(t.id)) {
|
|
171
|
+
errors.push(`Duplicate task ID: "${t.id}"`);
|
|
172
|
+
}
|
|
173
|
+
ids.add(t.id);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
for (const t of tasks) {
|
|
177
|
+
for (const dep of t.dependsOn ?? []) {
|
|
178
|
+
if (!ids.has(dep)) {
|
|
179
|
+
errors.push(
|
|
180
|
+
`Task "${t.id}" depends on non-existent task "${dep}"`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return errors;
|
|
187
|
+
}
|