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 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
- const logger = createLogger(Boolean(parsed.values.json), stdout);
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: unknown;
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
- export declare function createLogger(json: boolean, stream?: Pick<NodeJS.WritableStream, "write">): Logger;
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
- export function createLogger(json, stream = process.stdout) {
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: new Date().toISOString(), ...event })}\n`);
31
+ stream.write(`${JSON.stringify({ time: now().toISOString(), ...event })}\n`);
6
32
  return;
7
33
  }
8
- stream.write(`${formatHuman(event)}\n`);
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
- function formatHuman(event) {
13
- if (event.type === "task_started")
14
- return `starting ${event.taskId} (attempt ${event.attempt})`;
15
- if (event.type === "task_done")
16
- return `done ${event.taskId}`;
17
- if (event.type === "task_failed")
18
- return `failed ${event.taskId}: ${event.error}`;
19
- if (event.type === "task_blocked")
20
- return `blocked ${event.taskId}: ${event.reason}`;
21
- if (event.type === "summary")
22
- return `summary ${JSON.stringify(event.summary)}`;
23
- if (event.type === "dry_run")
24
- return event.taskIds.join("\n");
25
- return event.message;
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
  }
@@ -33,6 +33,7 @@ export interface OrchestratorOptions {
33
33
  resultsDir?: string;
34
34
  maxAttempts?: number;
35
35
  timeoutMs?: number;
36
+ version?: string;
36
37
  }
37
38
  export interface Summary {
38
39
  total: number;
@@ -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({ type: "task_blocked", taskId: task.id, reason: task.error });
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({ type: "task_started", taskId: task.id, attempt: task.attempts });
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({ type: "task_done", taskId: task.id });
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({ type: "task_failed", taskId: task.id, error: task.error });
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.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
  },