td-barrage 0.1.1 → 0.1.2
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 +32 -0
- package/dist/index.js +37 -1
- package/dist/log.d.ts +50 -2
- package/dist/log.js +236 -17
- package/dist/orchestrator.d.ts +1 -0
- package/dist/orchestrator.js +77 -6
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -80,14 +80,46 @@ npm run dev -- --tasks queue.json
|
|
|
80
80
|
npm run dev -- queue.json --max-attempts 3 --cwd /path/to/work --results-dir .queue/results
|
|
81
81
|
npm run dev -- --tasks queue.json --dry-run
|
|
82
82
|
npm run dev -- --tasks queue.json --json
|
|
83
|
+
npm run dev -- --tasks queue.json --verbose --timestamps
|
|
83
84
|
```
|
|
84
85
|
|
|
86
|
+
Options:
|
|
87
|
+
|
|
88
|
+
- `--tasks, -t <path>`: task file (also accepted as a positional argument)
|
|
89
|
+
- `--max-attempts <n>`: global retry limit (per-task `maxAttempts` overrides it)
|
|
90
|
+
- `--cwd <path>`: working directory for tasks that do not set their own
|
|
91
|
+
- `--results-dir <path>`: where result files are expected, default `.queue/results`
|
|
92
|
+
- `--dry-run`: print the topological plan (one id per line) and exit without running
|
|
93
|
+
- `--json`: emit newline-delimited JSON events instead of human output
|
|
94
|
+
|
|
85
95
|
Exit codes:
|
|
86
96
|
|
|
87
97
|
- `0`: all reachable tasks completed
|
|
88
98
|
- `1`: at least one task failed or became blocked
|
|
89
99
|
- `2`: CLI usage or missing task-file error
|
|
90
100
|
|
|
101
|
+
## Telemetry & output
|
|
102
|
+
|
|
103
|
+
By default the runner is deliberately verbose, narrating progress as it happens:
|
|
104
|
+
a startup header (version, task file, cwd, results dir, limits), the resolved
|
|
105
|
+
execution plan, a line as each task starts, and a line as each task finishes with
|
|
106
|
+
its duration and overall progress (`2/5 complete`). Failures, retries, and blocks
|
|
107
|
+
are always reported, along with a closing summary and total elapsed time.
|
|
108
|
+
|
|
109
|
+
Verbosity flags:
|
|
110
|
+
|
|
111
|
+
- `--verbose, -v`: add per-step traces — the OpenCode session id and the
|
|
112
|
+
success-check result for every task
|
|
113
|
+
- `--quiet, -q`: show only failures, blocks, and the final summary
|
|
114
|
+
- `--timestamps`: prefix each line with a wall-clock time
|
|
115
|
+
- `--color` / `--no-color`: force or disable ANSI color (auto-detected from the
|
|
116
|
+
TTY otherwise; the `NO_COLOR` and `FORCE_COLOR` environment variables are honored)
|
|
117
|
+
|
|
118
|
+
`--json` emits every event — including the enriched fields above — as one JSON
|
|
119
|
+
object per line, regardless of verbosity, which is the format to pipe into log
|
|
120
|
+
aggregators or `jq`. `--dry-run` always prints a bare, uncolored id list so it
|
|
121
|
+
stays easy to pipe.
|
|
122
|
+
|
|
91
123
|
## Success Checks
|
|
92
124
|
|
|
93
125
|
`default` succeeds unconditionally.
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import { realpathSync } from "node:fs";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
4
5
|
import process from "node:process";
|
|
5
6
|
import { pathToFileURL } from "node:url";
|
|
6
7
|
import { parseArgs } from "node:util";
|
|
@@ -22,6 +23,11 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
|
|
|
22
23
|
"results-dir": { type: "string" },
|
|
23
24
|
json: { type: "boolean", default: false },
|
|
24
25
|
"dry-run": { type: "boolean", default: false },
|
|
26
|
+
verbose: { type: "boolean", short: "v", default: false },
|
|
27
|
+
quiet: { type: "boolean", short: "q", default: false },
|
|
28
|
+
color: { type: "boolean", default: false },
|
|
29
|
+
"no-color": { type: "boolean", default: false },
|
|
30
|
+
timestamps: { type: "boolean", default: false },
|
|
25
31
|
},
|
|
26
32
|
});
|
|
27
33
|
const tasksPath = parsed.values.tasks ?? parsed.positionals[0];
|
|
@@ -31,7 +37,17 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
|
|
|
31
37
|
}
|
|
32
38
|
const queueFs = deps.fs ?? fs;
|
|
33
39
|
const cwd = parsed.values.cwd ?? process.cwd();
|
|
34
|
-
|
|
40
|
+
if (parsed.values.quiet && parsed.values.verbose) {
|
|
41
|
+
stderr.write("--quiet and --verbose cannot be combined.\n");
|
|
42
|
+
return 2;
|
|
43
|
+
}
|
|
44
|
+
const level = parsed.values.quiet ? 0 : parsed.values.verbose ? 2 : 1;
|
|
45
|
+
const logger = createLogger({
|
|
46
|
+
json: Boolean(parsed.values.json),
|
|
47
|
+
level,
|
|
48
|
+
color: resolveColor(parsed.values, stdout),
|
|
49
|
+
timestamps: Boolean(parsed.values.timestamps),
|
|
50
|
+
}, stdout);
|
|
35
51
|
const maxAttempts = parsed.values["max-attempts"] ? Number(parsed.values["max-attempts"]) : undefined;
|
|
36
52
|
const resultsDir = parsed.values["results-dir"] ?? defaultResultsDir(cwd);
|
|
37
53
|
if (maxAttempts !== undefined && (!Number.isInteger(maxAttempts) || maxAttempts < 1)) {
|
|
@@ -54,6 +70,7 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
|
|
|
54
70
|
cwd,
|
|
55
71
|
resultsDir,
|
|
56
72
|
maxAttempts,
|
|
73
|
+
version: readVersion(),
|
|
57
74
|
});
|
|
58
75
|
return summary.failed > 0 || summary.blocked > 0 ? 1 : 0;
|
|
59
76
|
}
|
|
@@ -63,6 +80,25 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
|
|
|
63
80
|
return code;
|
|
64
81
|
}
|
|
65
82
|
}
|
|
83
|
+
// Color is on when the stream is a TTY, unless overridden. NO_COLOR disables and
|
|
84
|
+
// FORCE_COLOR / --color force it on, matching the conventions other CLIs follow.
|
|
85
|
+
function resolveColor(values, stdout) {
|
|
86
|
+
if (values["no-color"] || process.env.NO_COLOR)
|
|
87
|
+
return false;
|
|
88
|
+
if (values.color || process.env.FORCE_COLOR)
|
|
89
|
+
return true;
|
|
90
|
+
return Boolean(stdout.isTTY);
|
|
91
|
+
}
|
|
92
|
+
function readVersion() {
|
|
93
|
+
try {
|
|
94
|
+
const require = createRequire(import.meta.url);
|
|
95
|
+
const pkg = require("../package.json");
|
|
96
|
+
return pkg.version ?? "";
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return "";
|
|
100
|
+
}
|
|
101
|
+
}
|
|
66
102
|
function isMissingFile(error) {
|
|
67
103
|
return Boolean(error &&
|
|
68
104
|
typeof error === "object" &&
|
package/dist/log.d.ts
CHANGED
|
@@ -1,21 +1,58 @@
|
|
|
1
|
+
import type { Summary } from "./orchestrator.js";
|
|
2
|
+
export interface RunInfo {
|
|
3
|
+
version: string;
|
|
4
|
+
tasksPath: string;
|
|
5
|
+
total: number;
|
|
6
|
+
maxAttempts: number;
|
|
7
|
+
timeoutMs: number;
|
|
8
|
+
cwd: string;
|
|
9
|
+
resultsDir: string;
|
|
10
|
+
}
|
|
1
11
|
export type LogEvent = {
|
|
12
|
+
type: "run_started";
|
|
13
|
+
info: RunInfo;
|
|
14
|
+
} | {
|
|
15
|
+
type: "plan";
|
|
16
|
+
taskIds: string[];
|
|
17
|
+
} | {
|
|
2
18
|
type: "task_started";
|
|
3
19
|
taskId: string;
|
|
4
20
|
attempt: number;
|
|
21
|
+
maxAttempts: number;
|
|
22
|
+
cwd: string;
|
|
23
|
+
timeoutMs: number;
|
|
24
|
+
} | {
|
|
25
|
+
type: "task_session";
|
|
26
|
+
taskId: string;
|
|
27
|
+
sessionId: string;
|
|
28
|
+
} | {
|
|
29
|
+
type: "task_check";
|
|
30
|
+
taskId: string;
|
|
31
|
+
strategy: string;
|
|
32
|
+
ok: boolean;
|
|
5
33
|
} | {
|
|
6
34
|
type: "task_done";
|
|
7
35
|
taskId: string;
|
|
36
|
+
durationMs: number;
|
|
37
|
+
done: number;
|
|
38
|
+
total: number;
|
|
8
39
|
} | {
|
|
9
40
|
type: "task_failed";
|
|
10
41
|
taskId: string;
|
|
11
42
|
error: string;
|
|
43
|
+
attempt: number;
|
|
44
|
+
maxAttempts: number;
|
|
45
|
+
willRetry: boolean;
|
|
46
|
+
durationMs: number;
|
|
12
47
|
} | {
|
|
13
48
|
type: "task_blocked";
|
|
14
49
|
taskId: string;
|
|
15
50
|
reason: string;
|
|
51
|
+
blockedBy?: string;
|
|
16
52
|
} | {
|
|
17
53
|
type: "summary";
|
|
18
|
-
summary:
|
|
54
|
+
summary: Summary;
|
|
55
|
+
durationMs: number;
|
|
19
56
|
} | {
|
|
20
57
|
type: "dry_run";
|
|
21
58
|
taskIds: string[];
|
|
@@ -26,4 +63,15 @@ export type LogEvent = {
|
|
|
26
63
|
export interface Logger {
|
|
27
64
|
event(event: LogEvent): void;
|
|
28
65
|
}
|
|
29
|
-
|
|
66
|
+
/** 0 = quiet (problems + summary only), 1 = normal, 2 = verbose. */
|
|
67
|
+
export type Verbosity = 0 | 1 | 2;
|
|
68
|
+
export interface LoggerOptions {
|
|
69
|
+
json?: boolean;
|
|
70
|
+
level?: Verbosity;
|
|
71
|
+
color?: boolean;
|
|
72
|
+
timestamps?: boolean;
|
|
73
|
+
now?: () => Date;
|
|
74
|
+
}
|
|
75
|
+
export declare function createLogger(options?: LoggerOptions, stream?: Pick<NodeJS.WritableStream, "write"> & {
|
|
76
|
+
isTTY?: boolean;
|
|
77
|
+
}): Logger;
|
package/dist/log.js
CHANGED
|
@@ -1,26 +1,245 @@
|
|
|
1
|
-
|
|
1
|
+
import { intro, outro, note, log as clack, spinner } from "@clack/prompts";
|
|
2
|
+
// Lowest verbosity level at which each event is shown in human mode.
|
|
3
|
+
// Level 0 events (failures, blocks, summary, dry-run) always surface.
|
|
4
|
+
const EVENT_LEVEL = {
|
|
5
|
+
run_started: 1,
|
|
6
|
+
plan: 1,
|
|
7
|
+
task_started: 1,
|
|
8
|
+
task_session: 2,
|
|
9
|
+
task_check: 2,
|
|
10
|
+
task_done: 1,
|
|
11
|
+
task_failed: 0,
|
|
12
|
+
task_blocked: 0,
|
|
13
|
+
summary: 0,
|
|
14
|
+
dry_run: 0,
|
|
15
|
+
server: 1,
|
|
16
|
+
};
|
|
17
|
+
export function createLogger(options = {}, stream = process.stdout) {
|
|
18
|
+
const json = Boolean(options.json);
|
|
19
|
+
const level = options.level ?? 1;
|
|
20
|
+
const styles = makeStyles(Boolean(options.color));
|
|
21
|
+
const now = options.now ?? (() => new Date());
|
|
22
|
+
// The clack treatment is reserved for an interactive, colored terminal: it
|
|
23
|
+
// animates spinners and draws connecting gutters that only make sense live.
|
|
24
|
+
// Pipes, CI, --no-color, --timestamps, and tests fall through to the plain
|
|
25
|
+
// line renderer so output stays deterministic and grep/pipe-friendly.
|
|
26
|
+
const pretty = !json && !options.timestamps && Boolean(options.color) && Boolean(stream.isTTY);
|
|
27
|
+
const renderPretty = pretty ? createPrettyRenderer(stream, styles) : null;
|
|
2
28
|
return {
|
|
3
29
|
event(event) {
|
|
4
30
|
if (json) {
|
|
5
|
-
stream.write(`${JSON.stringify({ time:
|
|
31
|
+
stream.write(`${JSON.stringify({ time: now().toISOString(), ...event })}\n`);
|
|
6
32
|
return;
|
|
7
33
|
}
|
|
8
|
-
|
|
34
|
+
if (EVENT_LEVEL[event.type] > level)
|
|
35
|
+
return;
|
|
36
|
+
// The plan preview is meant to be piped, so it stays bare even in pretty mode.
|
|
37
|
+
if (renderPretty && event.type !== "dry_run") {
|
|
38
|
+
renderPretty(event);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const text = formatHuman(event, styles);
|
|
42
|
+
if (text === null)
|
|
43
|
+
return;
|
|
44
|
+
const stamp = options.timestamps ? `${styles.gray(formatClock(now()))} ` : "";
|
|
45
|
+
const out = text
|
|
46
|
+
.split("\n")
|
|
47
|
+
.map((line) => `${stamp}${line}`)
|
|
48
|
+
.join("\n");
|
|
49
|
+
stream.write(`${out}\n`);
|
|
9
50
|
},
|
|
10
51
|
};
|
|
11
52
|
}
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
53
|
+
/**
|
|
54
|
+
* Renders events through @clack/prompts: an `intro` banner, a boxed `note` of
|
|
55
|
+
* run details, a live spinner for the task in flight (the orchestrator runs
|
|
56
|
+
* tasks sequentially, so at most one is active), and an `outro` summary.
|
|
57
|
+
*/
|
|
58
|
+
function createPrettyRenderer(output, s) {
|
|
59
|
+
const opts = { output };
|
|
60
|
+
let active = null;
|
|
61
|
+
return (event) => {
|
|
62
|
+
switch (event.type) {
|
|
63
|
+
case "run_started": {
|
|
64
|
+
const i = event.info;
|
|
65
|
+
intro(s.bold(`td-barrage ${i.version}`), opts);
|
|
66
|
+
const details = [
|
|
67
|
+
`${s.dim("tasks ")} ${i.tasksPath} ${s.dim(`(${i.total} ${plural(i.total, "task")})`)}`,
|
|
68
|
+
`${s.dim("cwd ")} ${i.cwd}`,
|
|
69
|
+
`${s.dim("results")} ${i.resultsDir}`,
|
|
70
|
+
`${s.dim("limits ")} ${i.maxAttempts} ${plural(i.maxAttempts, "attempt")} ${s.dim("·")} ${fmtDuration(i.timeoutMs)} timeout`,
|
|
71
|
+
].join("\n");
|
|
72
|
+
note(details, s.dim("run details"), opts);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
case "plan": {
|
|
76
|
+
const body = event.taskIds.length === 0
|
|
77
|
+
? s.dim("(no tasks)")
|
|
78
|
+
: event.taskIds.map((id) => s.cyan(id)).join(s.dim(" → "));
|
|
79
|
+
clack.step(`${s.dim("plan")} ${body}`, opts);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
case "task_started":
|
|
83
|
+
active = spinner(opts);
|
|
84
|
+
active.start(`${s.bold(event.taskId)} ${s.dim(`running (attempt ${event.attempt}/${event.maxAttempts})`)}`);
|
|
85
|
+
return;
|
|
86
|
+
case "task_session":
|
|
87
|
+
if (active)
|
|
88
|
+
active.message(s.dim(`${event.taskId} · session ${event.sessionId}`));
|
|
89
|
+
else
|
|
90
|
+
clack.message(s.dim(`↳ session ${event.sessionId}`), opts);
|
|
91
|
+
return;
|
|
92
|
+
case "task_check":
|
|
93
|
+
if (active)
|
|
94
|
+
active.message(s.dim(`${event.taskId} · check ${event.strategy} → ${event.ok ? "ok" : "fail"}`));
|
|
95
|
+
else
|
|
96
|
+
clack.message(s.dim(`↳ check ${event.strategy} → ${event.ok ? "ok" : "fail"}`), opts);
|
|
97
|
+
return;
|
|
98
|
+
case "task_done": {
|
|
99
|
+
const msg = `${s.bold(event.taskId)} ${s.green("done")} ${s.dim(`in ${fmtDuration(event.durationMs)} · ${event.done}/${event.total} complete`)}`;
|
|
100
|
+
if (active) {
|
|
101
|
+
active.stop(msg);
|
|
102
|
+
active = null;
|
|
103
|
+
}
|
|
104
|
+
else
|
|
105
|
+
clack.success(msg, opts);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
case "task_failed": {
|
|
109
|
+
const tail = event.willRetry
|
|
110
|
+
? s.yellow(`will retry (attempt ${event.attempt}/${event.maxAttempts})`)
|
|
111
|
+
: s.red(`gave up after ${event.attempt} ${plural(event.attempt, "attempt")}`);
|
|
112
|
+
const msg = `${s.bold(event.taskId)} ${s.red("failed")} ${s.dim(`in ${fmtDuration(event.durationMs)}`)}: ${event.error} ${tail}`;
|
|
113
|
+
if (active) {
|
|
114
|
+
// cancel() finalizes the spinner with a red ■ — distinct from the
|
|
115
|
+
// ▲ clack uses for a blocked task, and matching clack.error's glyph
|
|
116
|
+
// below. Called directly it only selects the symbol (no onCancel).
|
|
117
|
+
active.cancel(msg);
|
|
118
|
+
active = null;
|
|
119
|
+
}
|
|
120
|
+
else
|
|
121
|
+
clack.error(msg, opts);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
case "task_blocked": {
|
|
125
|
+
const by = event.blockedBy ? s.dim(` (by ${event.blockedBy})`) : "";
|
|
126
|
+
clack.warn(`${s.bold(event.taskId)} ${s.yellow("blocked")}${by} ${s.dim(event.reason)}`, opts);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
case "summary": {
|
|
130
|
+
if (active) {
|
|
131
|
+
active.stop();
|
|
132
|
+
active = null;
|
|
133
|
+
}
|
|
134
|
+
const sm = event.summary;
|
|
135
|
+
const parts = [
|
|
136
|
+
s.green(`${sm.done} done`),
|
|
137
|
+
count(sm.failed, "failed", s.red, s),
|
|
138
|
+
count(sm.blocked, "blocked", s.yellow, s),
|
|
139
|
+
count(sm.pending, "pending", s.yellow, s),
|
|
140
|
+
];
|
|
141
|
+
const ok = sm.failed === 0 && sm.blocked === 0;
|
|
142
|
+
const head = ok ? s.green("done") : s.red("finished with issues");
|
|
143
|
+
outro(`${head} ${parts.join(s.dim(" · "))} ${s.dim(`(${fmtDuration(event.durationMs)})`)}`, opts);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
case "server":
|
|
147
|
+
if (active)
|
|
148
|
+
active.message(s.dim(event.message));
|
|
149
|
+
else
|
|
150
|
+
clack.info(s.dim(event.message), opts);
|
|
151
|
+
return;
|
|
152
|
+
case "dry_run":
|
|
153
|
+
// Handled before reaching the pretty renderer; kept exhaustive for the type checker.
|
|
154
|
+
output.write(`${event.taskIds.join("\n")}\n`);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
function formatHuman(event, s) {
|
|
160
|
+
switch (event.type) {
|
|
161
|
+
case "run_started": {
|
|
162
|
+
const i = event.info;
|
|
163
|
+
const lines = [
|
|
164
|
+
s.bold(`▶ td-barrage ${i.version}`),
|
|
165
|
+
` ${s.dim("tasks ")} ${i.tasksPath} ${s.dim(`(${i.total} ${plural(i.total, "task")})`)}`,
|
|
166
|
+
` ${s.dim("cwd ")} ${i.cwd}`,
|
|
167
|
+
` ${s.dim("results")} ${i.resultsDir}`,
|
|
168
|
+
` ${s.dim("limits ")} ${i.maxAttempts} ${plural(i.maxAttempts, "attempt")} ${s.dim("·")} ${fmtDuration(i.timeoutMs)} timeout`,
|
|
169
|
+
];
|
|
170
|
+
return lines.join("\n");
|
|
171
|
+
}
|
|
172
|
+
case "plan":
|
|
173
|
+
if (event.taskIds.length === 0)
|
|
174
|
+
return s.dim("plan (no tasks)");
|
|
175
|
+
return `${s.dim("plan ")} ${event.taskIds.map((id) => s.cyan(id)).join(s.dim(" → "))}`;
|
|
176
|
+
case "task_started":
|
|
177
|
+
return `${s.blue("▶")} ${s.bold(event.taskId)} ${s.dim(`starting (attempt ${event.attempt}/${event.maxAttempts})`)}`;
|
|
178
|
+
case "task_session":
|
|
179
|
+
return s.dim(` ↳ session ${event.sessionId}`);
|
|
180
|
+
case "task_check":
|
|
181
|
+
return s.dim(` ↳ check ${event.strategy} → ${event.ok ? "ok" : "fail"}`);
|
|
182
|
+
case "task_done":
|
|
183
|
+
return `${s.green("✔")} ${s.bold(event.taskId)} ${s.green("done")} ${s.dim(`in ${fmtDuration(event.durationMs)} · ${event.done}/${event.total} complete`)}`;
|
|
184
|
+
case "task_failed": {
|
|
185
|
+
const tail = event.willRetry
|
|
186
|
+
? s.yellow(`will retry (attempt ${event.attempt}/${event.maxAttempts})`)
|
|
187
|
+
: s.red(`gave up after ${event.attempt} ${plural(event.attempt, "attempt")}`);
|
|
188
|
+
return `${s.red("✖")} ${s.bold(event.taskId)} ${s.red("failed")} ${s.dim(`in ${fmtDuration(event.durationMs)}`)}: ${event.error} ${tail}`;
|
|
189
|
+
}
|
|
190
|
+
case "task_blocked": {
|
|
191
|
+
const by = event.blockedBy ? s.dim(` (by ${event.blockedBy})`) : "";
|
|
192
|
+
return `${s.yellow("⊘")} ${s.bold(event.taskId)} ${s.yellow("blocked")}${by} ${s.dim(event.reason)}`;
|
|
193
|
+
}
|
|
194
|
+
case "summary": {
|
|
195
|
+
const sm = event.summary;
|
|
196
|
+
const parts = [
|
|
197
|
+
s.green(`${sm.done} done`),
|
|
198
|
+
count(sm.failed, "failed", s.red, s),
|
|
199
|
+
count(sm.blocked, "blocked", s.yellow, s),
|
|
200
|
+
count(sm.pending, "pending", s.yellow, s),
|
|
201
|
+
];
|
|
202
|
+
const ok = sm.failed === 0 && sm.blocked === 0;
|
|
203
|
+
const head = ok ? s.green("✔ done") : s.red("✖ finished with issues");
|
|
204
|
+
return `${head} ${parts.join(s.dim(" · "))} ${s.dim(`(${fmtDuration(event.durationMs)})`)}`;
|
|
205
|
+
}
|
|
206
|
+
case "dry_run":
|
|
207
|
+
// Plain, uncolored, pipeable plan — one task id per line.
|
|
208
|
+
return event.taskIds.join("\n");
|
|
209
|
+
case "server":
|
|
210
|
+
return s.dim(`• ${event.message}`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
function count(n, label, color, s) {
|
|
214
|
+
const text = `${n} ${label}`;
|
|
215
|
+
return n > 0 ? color(text) : s.dim(text);
|
|
216
|
+
}
|
|
217
|
+
function plural(n, word) {
|
|
218
|
+
return n === 1 ? word : `${word}s`;
|
|
219
|
+
}
|
|
220
|
+
function fmtDuration(ms) {
|
|
221
|
+
if (ms < 1000)
|
|
222
|
+
return `${Math.round(ms)}ms`;
|
|
223
|
+
const seconds = ms / 1000;
|
|
224
|
+
if (seconds < 60)
|
|
225
|
+
return `${seconds.toFixed(1)}s`;
|
|
226
|
+
const minutes = Math.floor(seconds / 60);
|
|
227
|
+
const rest = Math.round(seconds % 60);
|
|
228
|
+
return `${minutes}m${rest.toString().padStart(2, "0")}s`;
|
|
229
|
+
}
|
|
230
|
+
function formatClock(date) {
|
|
231
|
+
return date.toTimeString().slice(0, 8);
|
|
232
|
+
}
|
|
233
|
+
function makeStyles(enabled) {
|
|
234
|
+
const wrap = (open, close) => (text) => enabled ? `[${open}m${text}[${close}m` : text;
|
|
235
|
+
return {
|
|
236
|
+
bold: wrap(1, 22),
|
|
237
|
+
dim: wrap(2, 22),
|
|
238
|
+
red: wrap(31, 39),
|
|
239
|
+
green: wrap(32, 39),
|
|
240
|
+
yellow: wrap(33, 39),
|
|
241
|
+
blue: wrap(34, 39),
|
|
242
|
+
cyan: wrap(36, 39),
|
|
243
|
+
gray: wrap(90, 39),
|
|
244
|
+
};
|
|
26
245
|
}
|
package/dist/orchestrator.d.ts
CHANGED
package/dist/orchestrator.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { dependencyState } from "./deps.js";
|
|
3
|
+
import { dependencyState, topologicalOrder } from "./deps.js";
|
|
4
4
|
import { checkSuccess } from "./success.js";
|
|
5
5
|
import { loadTasks, saveTasks } from "./tasks.js";
|
|
6
6
|
export const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
|
|
@@ -12,8 +12,34 @@ const defaultClock = {
|
|
|
12
12
|
};
|
|
13
13
|
export async function runOrchestrator(options) {
|
|
14
14
|
const queueFs = options.fs ?? fs;
|
|
15
|
+
const clock = options.clock ?? defaultClock;
|
|
16
|
+
const runStart = clock.now().getTime();
|
|
15
17
|
const taskFile = await loadTasks(options.tasksPath, queueFs);
|
|
16
18
|
await saveTasks(options.tasksPath, taskFile, queueFs);
|
|
19
|
+
if (options.log) {
|
|
20
|
+
const cwd = options.cwd ?? process.cwd();
|
|
21
|
+
options.log.event({
|
|
22
|
+
type: "run_started",
|
|
23
|
+
info: {
|
|
24
|
+
version: options.version ?? "",
|
|
25
|
+
tasksPath: options.tasksPath,
|
|
26
|
+
total: taskFile.tasks.length,
|
|
27
|
+
maxAttempts: options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS,
|
|
28
|
+
timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
29
|
+
cwd,
|
|
30
|
+
resultsDir: options.resultsDir ?? defaultResultsDir(cwd),
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
// The plan is a courtesy preview; a dependency cycle is handled gracefully
|
|
34
|
+
// by the loop below, so never let topological ordering abort the run.
|
|
35
|
+
try {
|
|
36
|
+
const ordered = topologicalOrder(taskFile.tasks);
|
|
37
|
+
options.log.event({ type: "plan", taskIds: ordered.map((task) => task.id) });
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// Cycle or missing dependency — the loop reports it as a deadlock/block.
|
|
41
|
+
}
|
|
42
|
+
}
|
|
17
43
|
while (true) {
|
|
18
44
|
let progressed = false;
|
|
19
45
|
for (const task of taskFile.tasks) {
|
|
@@ -26,7 +52,12 @@ export async function runOrchestrator(options) {
|
|
|
26
52
|
task.status = "blocked";
|
|
27
53
|
task.error = "Dependency failed, blocked, or missing.";
|
|
28
54
|
task.finishedAt = nowIso(options.clock);
|
|
29
|
-
options.log?.event({
|
|
55
|
+
options.log?.event({
|
|
56
|
+
type: "task_blocked",
|
|
57
|
+
taskId: task.id,
|
|
58
|
+
reason: task.error,
|
|
59
|
+
blockedBy: blockingDependency(task, taskFile.tasks),
|
|
60
|
+
});
|
|
30
61
|
await saveTasks(options.tasksPath, taskFile, queueFs);
|
|
31
62
|
progressed = true;
|
|
32
63
|
continue;
|
|
@@ -38,7 +69,7 @@ export async function runOrchestrator(options) {
|
|
|
38
69
|
break;
|
|
39
70
|
}
|
|
40
71
|
const summary = summarize(taskFile.tasks);
|
|
41
|
-
options.log?.event({ type: "summary", summary });
|
|
72
|
+
options.log?.event({ type: "summary", summary, durationMs: clock.now().getTime() - runStart });
|
|
42
73
|
return summary;
|
|
43
74
|
}
|
|
44
75
|
async function runTask(task, tasks, options, queueFs) {
|
|
@@ -48,31 +79,71 @@ async function runTask(task, tasks, options, queueFs) {
|
|
|
48
79
|
const maxAttempts = task.maxAttempts ?? options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
|
|
49
80
|
task.status = "running";
|
|
50
81
|
task.attempts += 1;
|
|
82
|
+
const startMs = clock.now().getTime();
|
|
51
83
|
task.startedAt = clock.now().toISOString();
|
|
52
84
|
task.finishedAt = undefined;
|
|
53
85
|
task.error = undefined;
|
|
54
|
-
options.log?.event({
|
|
86
|
+
options.log?.event({
|
|
87
|
+
type: "task_started",
|
|
88
|
+
taskId: task.id,
|
|
89
|
+
attempt: task.attempts,
|
|
90
|
+
maxAttempts,
|
|
91
|
+
cwd,
|
|
92
|
+
timeoutMs,
|
|
93
|
+
});
|
|
55
94
|
await saveTasks(options.tasksPath, { tasks }, queueFs);
|
|
56
95
|
try {
|
|
57
96
|
const session = await options.client.session.create({ cwd });
|
|
58
97
|
const sessionId = typeof session === "string" ? session : session.id;
|
|
98
|
+
options.log?.event({ type: "task_session", taskId: task.id, sessionId });
|
|
59
99
|
const controller = new AbortController();
|
|
60
100
|
await withTimeout(options.client.session.prompt(sessionId, { prompt: task.prompt, signal: controller.signal }), timeoutMs, clock, controller);
|
|
101
|
+
const strategy = task.success?.strategy ?? "default";
|
|
61
102
|
const success = await checkSuccess(task.success, { fs: queueFs, cwd });
|
|
103
|
+
options.log?.event({ type: "task_check", taskId: task.id, strategy, ok: success });
|
|
62
104
|
if (!success)
|
|
63
105
|
throw new Error("Success check failed.");
|
|
64
106
|
task.status = "done";
|
|
65
107
|
task.finishedAt = clock.now().toISOString();
|
|
66
|
-
options.log?.event({
|
|
108
|
+
options.log?.event({
|
|
109
|
+
type: "task_done",
|
|
110
|
+
taskId: task.id,
|
|
111
|
+
durationMs: clock.now().getTime() - startMs,
|
|
112
|
+
done: countDone(tasks),
|
|
113
|
+
total: tasks.length,
|
|
114
|
+
});
|
|
67
115
|
}
|
|
68
116
|
catch (error) {
|
|
69
117
|
task.error = error instanceof Error ? error.message : String(error);
|
|
70
118
|
task.status = task.attempts >= maxAttempts ? "failed" : "pending";
|
|
71
119
|
task.finishedAt = clock.now().toISOString();
|
|
72
|
-
options.log?.event({
|
|
120
|
+
options.log?.event({
|
|
121
|
+
type: "task_failed",
|
|
122
|
+
taskId: task.id,
|
|
123
|
+
error: task.error,
|
|
124
|
+
attempt: task.attempts,
|
|
125
|
+
maxAttempts,
|
|
126
|
+
willRetry: task.status === "pending",
|
|
127
|
+
durationMs: clock.now().getTime() - startMs,
|
|
128
|
+
});
|
|
73
129
|
}
|
|
74
130
|
await saveTasks(options.tasksPath, { tasks }, queueFs);
|
|
75
131
|
}
|
|
132
|
+
function countDone(tasks) {
|
|
133
|
+
return tasks.reduce((total, task) => (task.status === "done" ? total + 1 : total), 0);
|
|
134
|
+
}
|
|
135
|
+
// Names the first dependency that is failed, blocked, or missing, for telemetry.
|
|
136
|
+
function blockingDependency(task, tasks) {
|
|
137
|
+
const byId = new Map(tasks.map((candidate) => [candidate.id, candidate]));
|
|
138
|
+
for (const depId of task.dependsOn) {
|
|
139
|
+
const dep = byId.get(depId);
|
|
140
|
+
if (!dep)
|
|
141
|
+
return `${depId} (missing)`;
|
|
142
|
+
if (dep.status === "failed" || dep.status === "blocked")
|
|
143
|
+
return `${depId} (${dep.status})`;
|
|
144
|
+
}
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
76
147
|
function isRunnable(task, globalMaxAttempts = DEFAULT_MAX_ATTEMPTS) {
|
|
77
148
|
if (task.status === "pending")
|
|
78
149
|
return true;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "td-barrage",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "TypeScript queue runner that loads a JSON task file, resolves dependencies, runs tasks through the OpenCode SDK, and resumes interrupted work.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
"prepublishOnly": "npm run typecheck && npm test && npm run build"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
+
"@clack/prompts": "^1.4.0",
|
|
37
38
|
"@opencode-ai/sdk": "^1.15.10",
|
|
38
39
|
"zod": "^3.25.0"
|
|
39
40
|
},
|