td-barrage 0.1.1 → 0.1.3
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 +41 -0
- package/dist/git.d.ts +28 -0
- package/dist/git.js +47 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +41 -1
- package/dist/log.d.ts +61 -2
- package/dist/log.js +254 -17
- package/dist/orchestrator.d.ts +6 -0
- package/dist/orchestrator.js +109 -6
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -80,14 +80,47 @@ 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
|
+
- `--no-commit`: skip the git commit stage that otherwise runs after each successful task
|
|
94
|
+
- `--json`: emit newline-delimited JSON events instead of human output
|
|
95
|
+
|
|
85
96
|
Exit codes:
|
|
86
97
|
|
|
87
98
|
- `0`: all reachable tasks completed
|
|
88
99
|
- `1`: at least one task failed or became blocked
|
|
89
100
|
- `2`: CLI usage or missing task-file error
|
|
90
101
|
|
|
102
|
+
## Telemetry & output
|
|
103
|
+
|
|
104
|
+
By default the runner is deliberately verbose, narrating progress as it happens:
|
|
105
|
+
a startup header (version, task file, cwd, results dir, limits), the resolved
|
|
106
|
+
execution plan, a line as each task starts, and a line as each task finishes with
|
|
107
|
+
its duration and overall progress (`2/5 complete`). Failures, retries, and blocks
|
|
108
|
+
are always reported, along with a closing summary and total elapsed time.
|
|
109
|
+
|
|
110
|
+
Verbosity flags:
|
|
111
|
+
|
|
112
|
+
- `--verbose, -v`: add per-step traces — the OpenCode session id and the
|
|
113
|
+
success-check result for every task
|
|
114
|
+
- `--quiet, -q`: show only failures, blocks, and the final summary
|
|
115
|
+
- `--timestamps`: prefix each line with a wall-clock time
|
|
116
|
+
- `--color` / `--no-color`: force or disable ANSI color (auto-detected from the
|
|
117
|
+
TTY otherwise; the `NO_COLOR` and `FORCE_COLOR` environment variables are honored)
|
|
118
|
+
|
|
119
|
+
`--json` emits every event — including the enriched fields above — as one JSON
|
|
120
|
+
object per line, regardless of verbosity, which is the format to pipe into log
|
|
121
|
+
aggregators or `jq`. `--dry-run` always prints a bare, uncolored id list so it
|
|
122
|
+
stays easy to pipe.
|
|
123
|
+
|
|
91
124
|
## Success Checks
|
|
92
125
|
|
|
93
126
|
`default` succeeds unconditionally.
|
|
@@ -96,6 +129,14 @@ Exit codes:
|
|
|
96
129
|
|
|
97
130
|
`result_json` succeeds when the configured JSON file parses and contains `"status": "ok"`.
|
|
98
131
|
|
|
132
|
+
## Commits
|
|
133
|
+
|
|
134
|
+
After a task passes its success check, the orchestrator stages everything with
|
|
135
|
+
`git add -A` and commits it in the task's cwd with the message `feat: <id>`. A
|
|
136
|
+
clean working tree (nothing to commit) is a quiet no-op, and a commit that fails
|
|
137
|
+
outright is reported as a warning but never flips the task back to failed — the
|
|
138
|
+
task already succeeded. Pass `--no-commit` to skip this stage entirely.
|
|
139
|
+
|
|
99
140
|
## Recovery
|
|
100
141
|
|
|
101
142
|
On startup, any task persisted as `running` is reset to `pending`, annotated in `recoveryNotes`, and retried. The `attempts` counter is preserved, so a task that crashed mid-run still counts that attempt.
|
package/dist/git.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export interface GitExecResult {
|
|
2
|
+
code: number;
|
|
3
|
+
stdout: string;
|
|
4
|
+
stderr: string;
|
|
5
|
+
}
|
|
6
|
+
/** Runs `git <args>` in `cwd` and resolves with its exit code and output (never rejects). */
|
|
7
|
+
export type GitExec = (args: string[], cwd: string) => Promise<GitExecResult>;
|
|
8
|
+
export interface CommitInput {
|
|
9
|
+
cwd: string;
|
|
10
|
+
message: string;
|
|
11
|
+
}
|
|
12
|
+
export interface CommitResult {
|
|
13
|
+
/** "committed" — a new commit was made; "nothing" — clean tree, nothing staged; "error" — git failed. */
|
|
14
|
+
status: "committed" | "nothing" | "error";
|
|
15
|
+
/** Short SHA of the new commit, when status is "committed". */
|
|
16
|
+
commit?: string;
|
|
17
|
+
/** Human-readable detail, primarily for the "error" status. */
|
|
18
|
+
detail?: string;
|
|
19
|
+
}
|
|
20
|
+
export interface GitCommitter {
|
|
21
|
+
commit(input: CommitInput): Promise<CommitResult>;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Stages every change with `git add -A` and commits it. A clean working tree
|
|
25
|
+
* (nothing to commit) is reported as "nothing" rather than an error, since a
|
|
26
|
+
* task can legitimately succeed without touching tracked files.
|
|
27
|
+
*/
|
|
28
|
+
export declare function createGitCommitter(exec?: GitExec): GitCommitter;
|
package/dist/git.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
/**
|
|
3
|
+
* Stages every change with `git add -A` and commits it. A clean working tree
|
|
4
|
+
* (nothing to commit) is reported as "nothing" rather than an error, since a
|
|
5
|
+
* task can legitimately succeed without touching tracked files.
|
|
6
|
+
*/
|
|
7
|
+
export function createGitCommitter(exec = defaultExec) {
|
|
8
|
+
return {
|
|
9
|
+
async commit({ cwd, message }) {
|
|
10
|
+
const add = await exec(["add", "-A"], cwd);
|
|
11
|
+
if (add.code !== 0) {
|
|
12
|
+
return { status: "error", detail: detailOf(add) || "git add failed" };
|
|
13
|
+
}
|
|
14
|
+
const commit = await exec(["commit", "-m", message], cwd);
|
|
15
|
+
if (commit.code === 0) {
|
|
16
|
+
const head = await exec(["rev-parse", "--short", "HEAD"], cwd);
|
|
17
|
+
return { status: "committed", commit: head.code === 0 ? head.stdout.trim() : undefined };
|
|
18
|
+
}
|
|
19
|
+
if (isNothingToCommit(commit)) {
|
|
20
|
+
return { status: "nothing" };
|
|
21
|
+
}
|
|
22
|
+
return { status: "error", detail: detailOf(commit) || `git commit exited with code ${commit.code}` };
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
// `git commit` exits non-zero with this phrasing when the index has no staged
|
|
27
|
+
// changes — a no-op we treat as success, not failure.
|
|
28
|
+
function isNothingToCommit(result) {
|
|
29
|
+
const text = `${result.stdout}\n${result.stderr}`;
|
|
30
|
+
return /nothing to commit|no changes added to commit|nothing added to commit/i.test(text);
|
|
31
|
+
}
|
|
32
|
+
function detailOf(result) {
|
|
33
|
+
return (result.stderr.trim() || result.stdout.trim()).split("\n")[0] ?? "";
|
|
34
|
+
}
|
|
35
|
+
const defaultExec = (args, cwd) => new Promise((resolve) => {
|
|
36
|
+
execFile("git", args, { cwd }, (error, stdout, stderr) => {
|
|
37
|
+
if (!error) {
|
|
38
|
+
resolve({ code: 0, stdout: String(stdout), stderr: String(stderr) });
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
// A process that exits non-zero surfaces a numeric `code`; a spawn
|
|
42
|
+
// failure (e.g. git not installed) surfaces a string code like "ENOENT",
|
|
43
|
+
// in which case we fall back to 1 and lean on the error message.
|
|
44
|
+
const code = typeof error.code === "number" ? error.code : 1;
|
|
45
|
+
resolve({ code, stdout: String(stdout), stderr: String(stderr) || error.message });
|
|
46
|
+
});
|
|
47
|
+
});
|
package/dist/index.d.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
|
+
import { type GitCommitter } from "./git.js";
|
|
3
4
|
import { type OpenCodeClient } from "./orchestrator.js";
|
|
4
5
|
export interface MainDeps {
|
|
5
6
|
fs?: typeof fs;
|
|
6
7
|
client?: OpenCodeClient;
|
|
8
|
+
git?: GitCommitter;
|
|
7
9
|
stdout?: Pick<NodeJS.WritableStream, "write">;
|
|
8
10
|
stderr?: Pick<NodeJS.WritableStream, "write">;
|
|
9
11
|
}
|
package/dist/index.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
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";
|
|
7
8
|
import { createOpenCodeClient } from "./client.js";
|
|
8
9
|
import { topologicalOrder } from "./deps.js";
|
|
10
|
+
import { createGitCommitter } from "./git.js";
|
|
9
11
|
import { createLogger } from "./log.js";
|
|
10
12
|
import { defaultResultsDir, runOrchestrator } from "./orchestrator.js";
|
|
11
13
|
import { loadTasks } from "./tasks.js";
|
|
@@ -22,6 +24,12 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
|
|
|
22
24
|
"results-dir": { type: "string" },
|
|
23
25
|
json: { type: "boolean", default: false },
|
|
24
26
|
"dry-run": { type: "boolean", default: false },
|
|
27
|
+
"no-commit": { type: "boolean", default: false },
|
|
28
|
+
verbose: { type: "boolean", short: "v", default: false },
|
|
29
|
+
quiet: { type: "boolean", short: "q", default: false },
|
|
30
|
+
color: { type: "boolean", default: false },
|
|
31
|
+
"no-color": { type: "boolean", default: false },
|
|
32
|
+
timestamps: { type: "boolean", default: false },
|
|
25
33
|
},
|
|
26
34
|
});
|
|
27
35
|
const tasksPath = parsed.values.tasks ?? parsed.positionals[0];
|
|
@@ -31,7 +39,17 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
|
|
|
31
39
|
}
|
|
32
40
|
const queueFs = deps.fs ?? fs;
|
|
33
41
|
const cwd = parsed.values.cwd ?? process.cwd();
|
|
34
|
-
|
|
42
|
+
if (parsed.values.quiet && parsed.values.verbose) {
|
|
43
|
+
stderr.write("--quiet and --verbose cannot be combined.\n");
|
|
44
|
+
return 2;
|
|
45
|
+
}
|
|
46
|
+
const level = parsed.values.quiet ? 0 : parsed.values.verbose ? 2 : 1;
|
|
47
|
+
const logger = createLogger({
|
|
48
|
+
json: Boolean(parsed.values.json),
|
|
49
|
+
level,
|
|
50
|
+
color: resolveColor(parsed.values, stdout),
|
|
51
|
+
timestamps: Boolean(parsed.values.timestamps),
|
|
52
|
+
}, stdout);
|
|
35
53
|
const maxAttempts = parsed.values["max-attempts"] ? Number(parsed.values["max-attempts"]) : undefined;
|
|
36
54
|
const resultsDir = parsed.values["results-dir"] ?? defaultResultsDir(cwd);
|
|
37
55
|
if (maxAttempts !== undefined && (!Number.isInteger(maxAttempts) || maxAttempts < 1)) {
|
|
@@ -46,6 +64,7 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
|
|
|
46
64
|
return 0;
|
|
47
65
|
}
|
|
48
66
|
const client = deps.client ?? (await createOpenCodeClient());
|
|
67
|
+
const git = parsed.values["no-commit"] ? undefined : deps.git ?? createGitCommitter();
|
|
49
68
|
const summary = await runOrchestrator({
|
|
50
69
|
tasksPath,
|
|
51
70
|
client,
|
|
@@ -54,6 +73,8 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
|
|
|
54
73
|
cwd,
|
|
55
74
|
resultsDir,
|
|
56
75
|
maxAttempts,
|
|
76
|
+
git,
|
|
77
|
+
version: readVersion(),
|
|
57
78
|
});
|
|
58
79
|
return summary.failed > 0 || summary.blocked > 0 ? 1 : 0;
|
|
59
80
|
}
|
|
@@ -63,6 +84,25 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
|
|
|
63
84
|
return code;
|
|
64
85
|
}
|
|
65
86
|
}
|
|
87
|
+
// Color is on when the stream is a TTY, unless overridden. NO_COLOR disables and
|
|
88
|
+
// FORCE_COLOR / --color force it on, matching the conventions other CLIs follow.
|
|
89
|
+
function resolveColor(values, stdout) {
|
|
90
|
+
if (values["no-color"] || process.env.NO_COLOR)
|
|
91
|
+
return false;
|
|
92
|
+
if (values.color || process.env.FORCE_COLOR)
|
|
93
|
+
return true;
|
|
94
|
+
return Boolean(stdout.isTTY);
|
|
95
|
+
}
|
|
96
|
+
function readVersion() {
|
|
97
|
+
try {
|
|
98
|
+
const require = createRequire(import.meta.url);
|
|
99
|
+
const pkg = require("../package.json");
|
|
100
|
+
return pkg.version ?? "";
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return "";
|
|
104
|
+
}
|
|
105
|
+
}
|
|
66
106
|
function isMissingFile(error) {
|
|
67
107
|
return Boolean(error &&
|
|
68
108
|
typeof error === "object" &&
|
package/dist/log.d.ts
CHANGED
|
@@ -1,21 +1,69 @@
|
|
|
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;
|
|
39
|
+
} | {
|
|
40
|
+
type: "task_committed";
|
|
41
|
+
taskId: string;
|
|
42
|
+
message: string;
|
|
43
|
+
status: "committed" | "nothing";
|
|
44
|
+
commit?: string;
|
|
45
|
+
} | {
|
|
46
|
+
type: "task_commit_failed";
|
|
47
|
+
taskId: string;
|
|
48
|
+
message: string;
|
|
49
|
+
detail: string;
|
|
8
50
|
} | {
|
|
9
51
|
type: "task_failed";
|
|
10
52
|
taskId: string;
|
|
11
53
|
error: string;
|
|
54
|
+
attempt: number;
|
|
55
|
+
maxAttempts: number;
|
|
56
|
+
willRetry: boolean;
|
|
57
|
+
durationMs: number;
|
|
12
58
|
} | {
|
|
13
59
|
type: "task_blocked";
|
|
14
60
|
taskId: string;
|
|
15
61
|
reason: string;
|
|
62
|
+
blockedBy?: string;
|
|
16
63
|
} | {
|
|
17
64
|
type: "summary";
|
|
18
|
-
summary:
|
|
65
|
+
summary: Summary;
|
|
66
|
+
durationMs: number;
|
|
19
67
|
} | {
|
|
20
68
|
type: "dry_run";
|
|
21
69
|
taskIds: string[];
|
|
@@ -26,4 +74,15 @@ export type LogEvent = {
|
|
|
26
74
|
export interface Logger {
|
|
27
75
|
event(event: LogEvent): void;
|
|
28
76
|
}
|
|
29
|
-
|
|
77
|
+
/** 0 = quiet (problems + summary only), 1 = normal, 2 = verbose. */
|
|
78
|
+
export type Verbosity = 0 | 1 | 2;
|
|
79
|
+
export interface LoggerOptions {
|
|
80
|
+
json?: boolean;
|
|
81
|
+
level?: Verbosity;
|
|
82
|
+
color?: boolean;
|
|
83
|
+
timestamps?: boolean;
|
|
84
|
+
now?: () => Date;
|
|
85
|
+
}
|
|
86
|
+
export declare function createLogger(options?: LoggerOptions, stream?: Pick<NodeJS.WritableStream, "write"> & {
|
|
87
|
+
isTTY?: boolean;
|
|
88
|
+
}): Logger;
|
package/dist/log.js
CHANGED
|
@@ -1,26 +1,263 @@
|
|
|
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_committed: 1,
|
|
12
|
+
task_commit_failed: 0,
|
|
13
|
+
task_failed: 0,
|
|
14
|
+
task_blocked: 0,
|
|
15
|
+
summary: 0,
|
|
16
|
+
dry_run: 0,
|
|
17
|
+
server: 1,
|
|
18
|
+
};
|
|
19
|
+
export function createLogger(options = {}, stream = process.stdout) {
|
|
20
|
+
const json = Boolean(options.json);
|
|
21
|
+
const level = options.level ?? 1;
|
|
22
|
+
const styles = makeStyles(Boolean(options.color));
|
|
23
|
+
const now = options.now ?? (() => new Date());
|
|
24
|
+
// The clack treatment is reserved for an interactive, colored terminal: it
|
|
25
|
+
// animates spinners and draws connecting gutters that only make sense live.
|
|
26
|
+
// Pipes, CI, --no-color, --timestamps, and tests fall through to the plain
|
|
27
|
+
// line renderer so output stays deterministic and grep/pipe-friendly.
|
|
28
|
+
const pretty = !json && !options.timestamps && Boolean(options.color) && Boolean(stream.isTTY);
|
|
29
|
+
const renderPretty = pretty ? createPrettyRenderer(stream, styles) : null;
|
|
2
30
|
return {
|
|
3
31
|
event(event) {
|
|
4
32
|
if (json) {
|
|
5
|
-
stream.write(`${JSON.stringify({ time:
|
|
33
|
+
stream.write(`${JSON.stringify({ time: now().toISOString(), ...event })}\n`);
|
|
6
34
|
return;
|
|
7
35
|
}
|
|
8
|
-
|
|
36
|
+
if (EVENT_LEVEL[event.type] > level)
|
|
37
|
+
return;
|
|
38
|
+
// The plan preview is meant to be piped, so it stays bare even in pretty mode.
|
|
39
|
+
if (renderPretty && event.type !== "dry_run") {
|
|
40
|
+
renderPretty(event);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const text = formatHuman(event, styles);
|
|
44
|
+
if (text === null)
|
|
45
|
+
return;
|
|
46
|
+
const stamp = options.timestamps ? `${styles.gray(formatClock(now()))} ` : "";
|
|
47
|
+
const out = text
|
|
48
|
+
.split("\n")
|
|
49
|
+
.map((line) => `${stamp}${line}`)
|
|
50
|
+
.join("\n");
|
|
51
|
+
stream.write(`${out}\n`);
|
|
9
52
|
},
|
|
10
53
|
};
|
|
11
54
|
}
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
55
|
+
/**
|
|
56
|
+
* Renders events through @clack/prompts: an `intro` banner, a boxed `note` of
|
|
57
|
+
* run details, a live spinner for the task in flight (the orchestrator runs
|
|
58
|
+
* tasks sequentially, so at most one is active), and an `outro` summary.
|
|
59
|
+
*/
|
|
60
|
+
function createPrettyRenderer(output, s) {
|
|
61
|
+
const opts = { output };
|
|
62
|
+
let active = null;
|
|
63
|
+
return (event) => {
|
|
64
|
+
switch (event.type) {
|
|
65
|
+
case "run_started": {
|
|
66
|
+
const i = event.info;
|
|
67
|
+
intro(s.bold(`td-barrage ${i.version}`), opts);
|
|
68
|
+
const details = [
|
|
69
|
+
`${s.dim("tasks ")} ${i.tasksPath} ${s.dim(`(${i.total} ${plural(i.total, "task")})`)}`,
|
|
70
|
+
`${s.dim("cwd ")} ${i.cwd}`,
|
|
71
|
+
`${s.dim("results")} ${i.resultsDir}`,
|
|
72
|
+
`${s.dim("limits ")} ${i.maxAttempts} ${plural(i.maxAttempts, "attempt")} ${s.dim("·")} ${fmtDuration(i.timeoutMs)} timeout`,
|
|
73
|
+
].join("\n");
|
|
74
|
+
note(details, s.dim("run details"), opts);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
case "plan": {
|
|
78
|
+
const body = event.taskIds.length === 0
|
|
79
|
+
? s.dim("(no tasks)")
|
|
80
|
+
: event.taskIds.map((id) => s.cyan(id)).join(s.dim(" → "));
|
|
81
|
+
clack.step(`${s.dim("plan")} ${body}`, opts);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
case "task_started":
|
|
85
|
+
active = spinner(opts);
|
|
86
|
+
active.start(`${s.bold(event.taskId)} ${s.dim(`running (attempt ${event.attempt}/${event.maxAttempts})`)}`);
|
|
87
|
+
return;
|
|
88
|
+
case "task_session":
|
|
89
|
+
if (active)
|
|
90
|
+
active.message(s.dim(`${event.taskId} · session ${event.sessionId}`));
|
|
91
|
+
else
|
|
92
|
+
clack.message(s.dim(`↳ session ${event.sessionId}`), opts);
|
|
93
|
+
return;
|
|
94
|
+
case "task_check":
|
|
95
|
+
if (active)
|
|
96
|
+
active.message(s.dim(`${event.taskId} · check ${event.strategy} → ${event.ok ? "ok" : "fail"}`));
|
|
97
|
+
else
|
|
98
|
+
clack.message(s.dim(`↳ check ${event.strategy} → ${event.ok ? "ok" : "fail"}`), opts);
|
|
99
|
+
return;
|
|
100
|
+
case "task_done": {
|
|
101
|
+
const msg = `${s.bold(event.taskId)} ${s.green("done")} ${s.dim(`in ${fmtDuration(event.durationMs)} · ${event.done}/${event.total} complete`)}`;
|
|
102
|
+
if (active) {
|
|
103
|
+
active.stop(msg);
|
|
104
|
+
active = null;
|
|
105
|
+
}
|
|
106
|
+
else
|
|
107
|
+
clack.success(msg, opts);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
case "task_committed": {
|
|
111
|
+
const detail = event.status === "nothing"
|
|
112
|
+
? s.dim("no changes to commit")
|
|
113
|
+
: `${s.dim("committed")} ${event.commit ? s.cyan(event.commit) : ""} ${s.dim(event.message)}`;
|
|
114
|
+
clack.message(detail, opts);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
case "task_commit_failed":
|
|
118
|
+
clack.warn(`${s.bold(event.taskId)} ${s.yellow("commit failed")} ${s.dim(event.detail)}`, opts);
|
|
119
|
+
return;
|
|
120
|
+
case "task_failed": {
|
|
121
|
+
const tail = event.willRetry
|
|
122
|
+
? s.yellow(`will retry (attempt ${event.attempt}/${event.maxAttempts})`)
|
|
123
|
+
: s.red(`gave up after ${event.attempt} ${plural(event.attempt, "attempt")}`);
|
|
124
|
+
const msg = `${s.bold(event.taskId)} ${s.red("failed")} ${s.dim(`in ${fmtDuration(event.durationMs)}`)}: ${event.error} ${tail}`;
|
|
125
|
+
if (active) {
|
|
126
|
+
// cancel() finalizes the spinner with a red ■ — distinct from the
|
|
127
|
+
// ▲ clack uses for a blocked task, and matching clack.error's glyph
|
|
128
|
+
// below. Called directly it only selects the symbol (no onCancel).
|
|
129
|
+
active.cancel(msg);
|
|
130
|
+
active = null;
|
|
131
|
+
}
|
|
132
|
+
else
|
|
133
|
+
clack.error(msg, opts);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
case "task_blocked": {
|
|
137
|
+
const by = event.blockedBy ? s.dim(` (by ${event.blockedBy})`) : "";
|
|
138
|
+
clack.warn(`${s.bold(event.taskId)} ${s.yellow("blocked")}${by} ${s.dim(event.reason)}`, opts);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
case "summary": {
|
|
142
|
+
if (active) {
|
|
143
|
+
active.stop();
|
|
144
|
+
active = null;
|
|
145
|
+
}
|
|
146
|
+
const sm = event.summary;
|
|
147
|
+
const parts = [
|
|
148
|
+
s.green(`${sm.done} done`),
|
|
149
|
+
count(sm.failed, "failed", s.red, s),
|
|
150
|
+
count(sm.blocked, "blocked", s.yellow, s),
|
|
151
|
+
count(sm.pending, "pending", s.yellow, s),
|
|
152
|
+
];
|
|
153
|
+
const ok = sm.failed === 0 && sm.blocked === 0;
|
|
154
|
+
const head = ok ? s.green("done") : s.red("finished with issues");
|
|
155
|
+
outro(`${head} ${parts.join(s.dim(" · "))} ${s.dim(`(${fmtDuration(event.durationMs)})`)}`, opts);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
case "server":
|
|
159
|
+
if (active)
|
|
160
|
+
active.message(s.dim(event.message));
|
|
161
|
+
else
|
|
162
|
+
clack.info(s.dim(event.message), opts);
|
|
163
|
+
return;
|
|
164
|
+
case "dry_run":
|
|
165
|
+
// Handled before reaching the pretty renderer; kept exhaustive for the type checker.
|
|
166
|
+
output.write(`${event.taskIds.join("\n")}\n`);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
function formatHuman(event, s) {
|
|
172
|
+
switch (event.type) {
|
|
173
|
+
case "run_started": {
|
|
174
|
+
const i = event.info;
|
|
175
|
+
const lines = [
|
|
176
|
+
s.bold(`▶ td-barrage ${i.version}`),
|
|
177
|
+
` ${s.dim("tasks ")} ${i.tasksPath} ${s.dim(`(${i.total} ${plural(i.total, "task")})`)}`,
|
|
178
|
+
` ${s.dim("cwd ")} ${i.cwd}`,
|
|
179
|
+
` ${s.dim("results")} ${i.resultsDir}`,
|
|
180
|
+
` ${s.dim("limits ")} ${i.maxAttempts} ${plural(i.maxAttempts, "attempt")} ${s.dim("·")} ${fmtDuration(i.timeoutMs)} timeout`,
|
|
181
|
+
];
|
|
182
|
+
return lines.join("\n");
|
|
183
|
+
}
|
|
184
|
+
case "plan":
|
|
185
|
+
if (event.taskIds.length === 0)
|
|
186
|
+
return s.dim("plan (no tasks)");
|
|
187
|
+
return `${s.dim("plan ")} ${event.taskIds.map((id) => s.cyan(id)).join(s.dim(" → "))}`;
|
|
188
|
+
case "task_started":
|
|
189
|
+
return `${s.blue("▶")} ${s.bold(event.taskId)} ${s.dim(`starting (attempt ${event.attempt}/${event.maxAttempts})`)}`;
|
|
190
|
+
case "task_session":
|
|
191
|
+
return s.dim(` ↳ session ${event.sessionId}`);
|
|
192
|
+
case "task_check":
|
|
193
|
+
return s.dim(` ↳ check ${event.strategy} → ${event.ok ? "ok" : "fail"}`);
|
|
194
|
+
case "task_done":
|
|
195
|
+
return `${s.green("✔")} ${s.bold(event.taskId)} ${s.green("done")} ${s.dim(`in ${fmtDuration(event.durationMs)} · ${event.done}/${event.total} complete`)}`;
|
|
196
|
+
case "task_committed":
|
|
197
|
+
return event.status === "nothing"
|
|
198
|
+
? s.dim(" ↳ no changes to commit")
|
|
199
|
+
: s.dim(` ↳ committed ${event.commit ? `${event.commit} ` : ""}${event.message}`);
|
|
200
|
+
case "task_commit_failed":
|
|
201
|
+
return `${s.yellow("⚠")} ${s.bold(event.taskId)} ${s.yellow("commit failed")} ${s.dim(event.detail)}`;
|
|
202
|
+
case "task_failed": {
|
|
203
|
+
const tail = event.willRetry
|
|
204
|
+
? s.yellow(`will retry (attempt ${event.attempt}/${event.maxAttempts})`)
|
|
205
|
+
: s.red(`gave up after ${event.attempt} ${plural(event.attempt, "attempt")}`);
|
|
206
|
+
return `${s.red("✖")} ${s.bold(event.taskId)} ${s.red("failed")} ${s.dim(`in ${fmtDuration(event.durationMs)}`)}: ${event.error} ${tail}`;
|
|
207
|
+
}
|
|
208
|
+
case "task_blocked": {
|
|
209
|
+
const by = event.blockedBy ? s.dim(` (by ${event.blockedBy})`) : "";
|
|
210
|
+
return `${s.yellow("⊘")} ${s.bold(event.taskId)} ${s.yellow("blocked")}${by} ${s.dim(event.reason)}`;
|
|
211
|
+
}
|
|
212
|
+
case "summary": {
|
|
213
|
+
const sm = event.summary;
|
|
214
|
+
const parts = [
|
|
215
|
+
s.green(`${sm.done} done`),
|
|
216
|
+
count(sm.failed, "failed", s.red, s),
|
|
217
|
+
count(sm.blocked, "blocked", s.yellow, s),
|
|
218
|
+
count(sm.pending, "pending", s.yellow, s),
|
|
219
|
+
];
|
|
220
|
+
const ok = sm.failed === 0 && sm.blocked === 0;
|
|
221
|
+
const head = ok ? s.green("✔ done") : s.red("✖ finished with issues");
|
|
222
|
+
return `${head} ${parts.join(s.dim(" · "))} ${s.dim(`(${fmtDuration(event.durationMs)})`)}`;
|
|
223
|
+
}
|
|
224
|
+
case "dry_run":
|
|
225
|
+
// Plain, uncolored, pipeable plan — one task id per line.
|
|
226
|
+
return event.taskIds.join("\n");
|
|
227
|
+
case "server":
|
|
228
|
+
return s.dim(`• ${event.message}`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
function count(n, label, color, s) {
|
|
232
|
+
const text = `${n} ${label}`;
|
|
233
|
+
return n > 0 ? color(text) : s.dim(text);
|
|
234
|
+
}
|
|
235
|
+
function plural(n, word) {
|
|
236
|
+
return n === 1 ? word : `${word}s`;
|
|
237
|
+
}
|
|
238
|
+
function fmtDuration(ms) {
|
|
239
|
+
if (ms < 1000)
|
|
240
|
+
return `${Math.round(ms)}ms`;
|
|
241
|
+
const seconds = ms / 1000;
|
|
242
|
+
if (seconds < 60)
|
|
243
|
+
return `${seconds.toFixed(1)}s`;
|
|
244
|
+
const minutes = Math.floor(seconds / 60);
|
|
245
|
+
const rest = Math.round(seconds % 60);
|
|
246
|
+
return `${minutes}m${rest.toString().padStart(2, "0")}s`;
|
|
247
|
+
}
|
|
248
|
+
function formatClock(date) {
|
|
249
|
+
return date.toTimeString().slice(0, 8);
|
|
250
|
+
}
|
|
251
|
+
function makeStyles(enabled) {
|
|
252
|
+
const wrap = (open, close) => (text) => enabled ? `[${open}m${text}[${close}m` : text;
|
|
253
|
+
return {
|
|
254
|
+
bold: wrap(1, 22),
|
|
255
|
+
dim: wrap(2, 22),
|
|
256
|
+
red: wrap(31, 39),
|
|
257
|
+
green: wrap(32, 39),
|
|
258
|
+
yellow: wrap(33, 39),
|
|
259
|
+
blue: wrap(34, 39),
|
|
260
|
+
cyan: wrap(36, 39),
|
|
261
|
+
gray: wrap(90, 39),
|
|
262
|
+
};
|
|
26
263
|
}
|
package/dist/orchestrator.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { GitCommitter } from "./git.js";
|
|
1
2
|
import { type SuccessFs } from "./success.js";
|
|
2
3
|
import { type TaskFs } from "./tasks.js";
|
|
3
4
|
import type { Logger } from "./log.js";
|
|
@@ -33,6 +34,11 @@ export interface OrchestratorOptions {
|
|
|
33
34
|
resultsDir?: string;
|
|
34
35
|
maxAttempts?: number;
|
|
35
36
|
timeoutMs?: number;
|
|
37
|
+
version?: string;
|
|
38
|
+
/** Commit the working tree after each task succeeds. Defaults to true. */
|
|
39
|
+
commit?: boolean;
|
|
40
|
+
/** Commits the working tree after each successful task. Omit to skip committing entirely. */
|
|
41
|
+
git?: GitCommitter;
|
|
36
42
|
}
|
|
37
43
|
export interface Summary {
|
|
38
44
|
total: number;
|
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,30 +79,102 @@ 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);
|
|
131
|
+
// Commit the work the task produced. This runs after the save above so the
|
|
132
|
+
// commit captures the task file in its final "done" state. A failed commit
|
|
133
|
+
// is reported but never flips the task back to failed — the task itself
|
|
134
|
+
// already passed its success check. Skipped when no committer is wired in.
|
|
135
|
+
if (task.status === "done" && options.commit !== false && options.git) {
|
|
136
|
+
await commitTask(task, options.git, options.log, cwd);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
async function commitTask(task, committer, log, cwd) {
|
|
140
|
+
const message = `feat: ${task.id}`;
|
|
141
|
+
try {
|
|
142
|
+
const result = await committer.commit({ cwd, message });
|
|
143
|
+
if (result.status === "error") {
|
|
144
|
+
log?.event({ type: "task_commit_failed", taskId: task.id, message, detail: result.detail ?? "" });
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
log?.event({
|
|
148
|
+
type: "task_committed",
|
|
149
|
+
taskId: task.id,
|
|
150
|
+
message,
|
|
151
|
+
status: result.status,
|
|
152
|
+
commit: result.commit,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
log?.event({
|
|
157
|
+
type: "task_commit_failed",
|
|
158
|
+
taskId: task.id,
|
|
159
|
+
message,
|
|
160
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
function countDone(tasks) {
|
|
165
|
+
return tasks.reduce((total, task) => (task.status === "done" ? total + 1 : total), 0);
|
|
166
|
+
}
|
|
167
|
+
// Names the first dependency that is failed, blocked, or missing, for telemetry.
|
|
168
|
+
function blockingDependency(task, tasks) {
|
|
169
|
+
const byId = new Map(tasks.map((candidate) => [candidate.id, candidate]));
|
|
170
|
+
for (const depId of task.dependsOn) {
|
|
171
|
+
const dep = byId.get(depId);
|
|
172
|
+
if (!dep)
|
|
173
|
+
return `${depId} (missing)`;
|
|
174
|
+
if (dep.status === "failed" || dep.status === "blocked")
|
|
175
|
+
return `${depId} (${dep.status})`;
|
|
176
|
+
}
|
|
177
|
+
return undefined;
|
|
75
178
|
}
|
|
76
179
|
function isRunnable(task, globalMaxAttempts = DEFAULT_MAX_ATTEMPTS) {
|
|
77
180
|
if (task.status === "pending")
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "td-barrage",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
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
|
},
|