td-barrage 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +133 -0
- package/dist/client.d.ts +9 -0
- package/dist/client.js +67 -0
- package/dist/deps.d.ts +4 -0
- package/dist/deps.js +41 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +72 -0
- package/dist/log.d.ts +29 -0
- package/dist/log.js +26 -0
- package/dist/orchestrator.d.ts +46 -0
- package/dist/orchestrator.js +119 -0
- package/dist/schema.d.ts +392 -0
- package/dist/schema.js +66 -0
- package/dist/server.d.ts +18 -0
- package/dist/server.js +60 -0
- package/dist/success.d.ts +10 -0
- package/dist/success.js +28 -0
- package/dist/tasks.d.ts +11 -0
- package/dist/tasks.js +30 -0
- package/package.json +46 -0
package/README.md
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# td-barrage
|
|
2
|
+
|
|
3
|
+
A small TypeScript queue runner for OpenCode tasks. It loads a JSON task file, resolves dependencies, runs ready tasks through the OpenCode SDK, persists state after every transition, and resumes interrupted work on the next invocation.
|
|
4
|
+
|
|
5
|
+
## Requirements
|
|
6
|
+
|
|
7
|
+
- Node.js 20.11 or newer
|
|
8
|
+
- npm
|
|
9
|
+
- OpenCode available when running against a real server
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
In a target project:
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
npm install --save-dev td-barrage
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Or run ad-hoc without installing:
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
npx td-barrage --tasks queue.json
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Once installed, the `td-barrage` CLI is on the project `PATH`:
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
npx td-barrage --tasks queue.json
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
For local development on this package:
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
npm install
|
|
35
|
+
npm run build
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Task File
|
|
39
|
+
|
|
40
|
+
Task files use a top-level `tasks` array. A bare array is also accepted when loading.
|
|
41
|
+
|
|
42
|
+
```json
|
|
43
|
+
{
|
|
44
|
+
"tasks": [
|
|
45
|
+
{
|
|
46
|
+
"id": "build",
|
|
47
|
+
"prompt": "Create .queue/results/build.json with {\"status\":\"ok\"}.",
|
|
48
|
+
"success": { "strategy": "result_json", "path": ".queue/results/build.json" }
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"id": "verify",
|
|
52
|
+
"prompt": "Verify the build result and create .queue/results/verify.json with {\"status\":\"ok\"}.",
|
|
53
|
+
"dependsOn": ["build"],
|
|
54
|
+
"timeoutMs": 600000,
|
|
55
|
+
"maxAttempts": 2,
|
|
56
|
+
"success": { "strategy": "result_json", "path": ".queue/results/verify.json" }
|
|
57
|
+
}
|
|
58
|
+
]
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Supported task fields:
|
|
63
|
+
|
|
64
|
+
- `id`: unique task id
|
|
65
|
+
- `prompt`: prompt sent to OpenCode
|
|
66
|
+
- `dependsOn`: dependency ids
|
|
67
|
+
- `status`: `pending`, `running`, `done`, `failed`, or `blocked`
|
|
68
|
+
- `attempts`: persisted attempt counter
|
|
69
|
+
- `maxAttempts`: per-task retry limit
|
|
70
|
+
- `timeoutMs`: per-task prompt timeout
|
|
71
|
+
- `cwd`: per-task working directory
|
|
72
|
+
- `resultPath`: optional metadata field
|
|
73
|
+
- `success`: `default`, `file_exists`, or `result_json`
|
|
74
|
+
- `recoveryNotes`, `error`, `startedAt`, `finishedAt`: runtime metadata
|
|
75
|
+
|
|
76
|
+
## CLI
|
|
77
|
+
|
|
78
|
+
```sh
|
|
79
|
+
npm run dev -- --tasks queue.json
|
|
80
|
+
npm run dev -- queue.json --max-attempts 3 --cwd /path/to/work --results-dir .queue/results
|
|
81
|
+
npm run dev -- --tasks queue.json --dry-run
|
|
82
|
+
npm run dev -- --tasks queue.json --json
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Exit codes:
|
|
86
|
+
|
|
87
|
+
- `0`: all reachable tasks completed
|
|
88
|
+
- `1`: at least one task failed or became blocked
|
|
89
|
+
- `2`: CLI usage or missing task-file error
|
|
90
|
+
|
|
91
|
+
## Success Checks
|
|
92
|
+
|
|
93
|
+
`default` succeeds unconditionally.
|
|
94
|
+
|
|
95
|
+
`file_exists` succeeds when the configured path exists relative to the task cwd.
|
|
96
|
+
|
|
97
|
+
`result_json` succeeds when the configured JSON file parses and contains `"status": "ok"`.
|
|
98
|
+
|
|
99
|
+
## Recovery
|
|
100
|
+
|
|
101
|
+
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.
|
|
102
|
+
|
|
103
|
+
## Scripts
|
|
104
|
+
|
|
105
|
+
```sh
|
|
106
|
+
npm test
|
|
107
|
+
npm run test:watch
|
|
108
|
+
npm run typecheck
|
|
109
|
+
npm run build
|
|
110
|
+
npm run test:integration
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Integration tests are excluded by default. Run them with:
|
|
114
|
+
|
|
115
|
+
```sh
|
|
116
|
+
RUN_INTEGRATION=1 npm run test:integration
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Optional integration environment variables:
|
|
120
|
+
|
|
121
|
+
- `OPENCODE_BIN`: OpenCode executable, default `opencode`
|
|
122
|
+
- `OPENCODE_PORT`: server port, default `4096`
|
|
123
|
+
|
|
124
|
+
## Architecture
|
|
125
|
+
|
|
126
|
+
The orchestrator accepts its dependencies as arguments:
|
|
127
|
+
|
|
128
|
+
- `client`: OpenCode client seam
|
|
129
|
+
- `fs`: filesystem seam
|
|
130
|
+
- `clock`: timer seam for timeout tests
|
|
131
|
+
- `log`: human or JSON event logger
|
|
132
|
+
|
|
133
|
+
This keeps most behavior covered by fast unit tests using a fake client and in-memory filesystem.
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { OpenCodeClient } from "./orchestrator.js";
|
|
2
|
+
export interface ClientOptions {
|
|
3
|
+
baseUrl?: string;
|
|
4
|
+
hostname?: string;
|
|
5
|
+
port?: number;
|
|
6
|
+
timeout?: number;
|
|
7
|
+
config?: unknown;
|
|
8
|
+
}
|
|
9
|
+
export declare function createOpenCodeClient(options?: ClientOptions): Promise<OpenCodeClient>;
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
export async function createOpenCodeClient(options = {}) {
|
|
2
|
+
const importSdk = new Function("specifier", "return import(specifier)");
|
|
3
|
+
const sdk = (await importSdkWithFallback(importSdk));
|
|
4
|
+
if (typeof sdk.createOpencodeClient === "function") {
|
|
5
|
+
return wrapSdkClient(sdk.createOpencodeClient({ ...options, throwOnError: true }));
|
|
6
|
+
}
|
|
7
|
+
if (typeof sdk.createOpencode === "function") {
|
|
8
|
+
const created = (await sdk.createOpencode(options));
|
|
9
|
+
if (created.client)
|
|
10
|
+
return wrapSdkClient(created.client);
|
|
11
|
+
}
|
|
12
|
+
const Client = (sdk.OpenCode ?? sdk.Opencode ?? sdk.Client ?? sdk.default);
|
|
13
|
+
if (!Client) {
|
|
14
|
+
throw new Error("Unable to locate an OpenCode SDK client export.");
|
|
15
|
+
}
|
|
16
|
+
return new Client(options);
|
|
17
|
+
}
|
|
18
|
+
function wrapSdkClient(client) {
|
|
19
|
+
const sessionDirs = new Map();
|
|
20
|
+
const raw = client;
|
|
21
|
+
return {
|
|
22
|
+
session: {
|
|
23
|
+
create: async (input) => {
|
|
24
|
+
const result = unwrapData(await raw.session.create({ directory: input.cwd, title: "orchestrator task" }));
|
|
25
|
+
const id = result.id;
|
|
26
|
+
if (typeof id !== "string" || id.length === 0) {
|
|
27
|
+
throw new Error("OpenCode SDK session.create did not return a session id.");
|
|
28
|
+
}
|
|
29
|
+
sessionDirs.set(id, input.cwd);
|
|
30
|
+
return { id };
|
|
31
|
+
},
|
|
32
|
+
prompt: async (sessionId, input) => {
|
|
33
|
+
const directory = sessionDirs.get(sessionId) ?? process.cwd();
|
|
34
|
+
const abort = async () => {
|
|
35
|
+
await raw.session.abort?.({ sessionID: sessionId, directory });
|
|
36
|
+
};
|
|
37
|
+
if (input.signal?.aborted)
|
|
38
|
+
await abort();
|
|
39
|
+
input.signal?.addEventListener("abort", abort, { once: true });
|
|
40
|
+
try {
|
|
41
|
+
return await raw.session.prompt({
|
|
42
|
+
sessionID: sessionId,
|
|
43
|
+
directory,
|
|
44
|
+
parts: [{ type: "text", text: input.prompt }],
|
|
45
|
+
}, { signal: input.signal });
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
input.signal?.removeEventListener("abort", abort);
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
function unwrapData(value) {
|
|
55
|
+
if (value && typeof value === "object" && "data" in value) {
|
|
56
|
+
return value.data;
|
|
57
|
+
}
|
|
58
|
+
return value;
|
|
59
|
+
}
|
|
60
|
+
async function importSdkWithFallback(importSdk) {
|
|
61
|
+
try {
|
|
62
|
+
return await importSdk("@opencode-ai/sdk/v2");
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return importSdk("@opencode-ai/sdk");
|
|
66
|
+
}
|
|
67
|
+
}
|
package/dist/deps.d.ts
ADDED
package/dist/deps.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export function dependencyState(task, tasks) {
|
|
2
|
+
const byId = new Map(tasks.map((candidate) => [candidate.id, candidate]));
|
|
3
|
+
for (const depId of task.dependsOn) {
|
|
4
|
+
const dep = byId.get(depId);
|
|
5
|
+
if (!dep)
|
|
6
|
+
return "blocked";
|
|
7
|
+
if (dep.status === "failed" || dep.status === "blocked")
|
|
8
|
+
return "blocked";
|
|
9
|
+
if (dep.status !== "done")
|
|
10
|
+
return "waiting";
|
|
11
|
+
}
|
|
12
|
+
return "ready";
|
|
13
|
+
}
|
|
14
|
+
export function topologicalOrder(tasks) {
|
|
15
|
+
const byId = new Map(tasks.map((task) => [task.id, task]));
|
|
16
|
+
const visiting = new Set();
|
|
17
|
+
const visited = new Set();
|
|
18
|
+
const ordered = [];
|
|
19
|
+
function visit(task, stack) {
|
|
20
|
+
if (visited.has(task.id))
|
|
21
|
+
return;
|
|
22
|
+
if (visiting.has(task.id)) {
|
|
23
|
+
const cycleStart = stack.indexOf(task.id);
|
|
24
|
+
const cycle = [...stack.slice(cycleStart), task.id].join(" -> ");
|
|
25
|
+
throw new Error(`Dependency cycle detected: ${cycle}`);
|
|
26
|
+
}
|
|
27
|
+
visiting.add(task.id);
|
|
28
|
+
for (const depId of task.dependsOn) {
|
|
29
|
+
const dep = byId.get(depId);
|
|
30
|
+
if (!dep)
|
|
31
|
+
throw new Error(`Task "${task.id}" depends on missing task "${depId}"`);
|
|
32
|
+
visit(dep, [...stack, task.id]);
|
|
33
|
+
}
|
|
34
|
+
visiting.delete(task.id);
|
|
35
|
+
visited.add(task.id);
|
|
36
|
+
ordered.push(task);
|
|
37
|
+
}
|
|
38
|
+
for (const task of tasks)
|
|
39
|
+
visit(task, []);
|
|
40
|
+
return ordered;
|
|
41
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import { type OpenCodeClient } from "./orchestrator.js";
|
|
4
|
+
export interface MainDeps {
|
|
5
|
+
fs?: typeof fs;
|
|
6
|
+
client?: OpenCodeClient;
|
|
7
|
+
stdout?: Pick<NodeJS.WritableStream, "write">;
|
|
8
|
+
stderr?: Pick<NodeJS.WritableStream, "write">;
|
|
9
|
+
}
|
|
10
|
+
export declare function main(argv?: string[], deps?: MainDeps): Promise<number>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import process from "node:process";
|
|
4
|
+
import { parseArgs } from "node:util";
|
|
5
|
+
import { createOpenCodeClient } from "./client.js";
|
|
6
|
+
import { topologicalOrder } from "./deps.js";
|
|
7
|
+
import { createLogger } from "./log.js";
|
|
8
|
+
import { defaultResultsDir, runOrchestrator } from "./orchestrator.js";
|
|
9
|
+
import { loadTasks } from "./tasks.js";
|
|
10
|
+
export async function main(argv = process.argv.slice(2), deps = {}) {
|
|
11
|
+
const stdout = deps.stdout ?? process.stdout;
|
|
12
|
+
const stderr = deps.stderr ?? process.stderr;
|
|
13
|
+
const parsed = parseArgs({
|
|
14
|
+
args: argv,
|
|
15
|
+
allowPositionals: true,
|
|
16
|
+
options: {
|
|
17
|
+
tasks: { type: "string", short: "t" },
|
|
18
|
+
"max-attempts": { type: "string" },
|
|
19
|
+
cwd: { type: "string" },
|
|
20
|
+
"results-dir": { type: "string" },
|
|
21
|
+
json: { type: "boolean", default: false },
|
|
22
|
+
"dry-run": { type: "boolean", default: false },
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
const tasksPath = parsed.values.tasks ?? parsed.positionals[0];
|
|
26
|
+
if (!tasksPath) {
|
|
27
|
+
stderr.write("Missing tasks file. Pass --tasks <path> or a positional path.\n");
|
|
28
|
+
return 2;
|
|
29
|
+
}
|
|
30
|
+
const queueFs = deps.fs ?? fs;
|
|
31
|
+
const cwd = parsed.values.cwd ?? process.cwd();
|
|
32
|
+
const logger = createLogger(Boolean(parsed.values.json), stdout);
|
|
33
|
+
const maxAttempts = parsed.values["max-attempts"] ? Number(parsed.values["max-attempts"]) : undefined;
|
|
34
|
+
const resultsDir = parsed.values["results-dir"] ?? defaultResultsDir(cwd);
|
|
35
|
+
if (maxAttempts !== undefined && (!Number.isInteger(maxAttempts) || maxAttempts < 1)) {
|
|
36
|
+
stderr.write("--max-attempts must be a positive integer.\n");
|
|
37
|
+
return 2;
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
if (parsed.values["dry-run"]) {
|
|
41
|
+
const taskFile = await loadTasks(tasksPath, queueFs);
|
|
42
|
+
const ordered = topologicalOrder(taskFile.tasks);
|
|
43
|
+
logger.event({ type: "dry_run", taskIds: ordered.map((task) => task.id) });
|
|
44
|
+
return 0;
|
|
45
|
+
}
|
|
46
|
+
const client = deps.client ?? (await createOpenCodeClient());
|
|
47
|
+
const summary = await runOrchestrator({
|
|
48
|
+
tasksPath,
|
|
49
|
+
client,
|
|
50
|
+
fs: queueFs,
|
|
51
|
+
log: logger,
|
|
52
|
+
cwd,
|
|
53
|
+
resultsDir,
|
|
54
|
+
maxAttempts,
|
|
55
|
+
});
|
|
56
|
+
return summary.failed > 0 || summary.blocked > 0 ? 1 : 0;
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
const code = isMissingFile(error) ? 2 : 1;
|
|
60
|
+
stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
61
|
+
return code;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function isMissingFile(error) {
|
|
65
|
+
return Boolean(error &&
|
|
66
|
+
typeof error === "object" &&
|
|
67
|
+
(("code" in error && error.code === "ENOENT") ||
|
|
68
|
+
(error instanceof Error && error.message.includes("ENOENT"))));
|
|
69
|
+
}
|
|
70
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
71
|
+
process.exitCode = await main();
|
|
72
|
+
}
|
package/dist/log.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export type LogEvent = {
|
|
2
|
+
type: "task_started";
|
|
3
|
+
taskId: string;
|
|
4
|
+
attempt: number;
|
|
5
|
+
} | {
|
|
6
|
+
type: "task_done";
|
|
7
|
+
taskId: string;
|
|
8
|
+
} | {
|
|
9
|
+
type: "task_failed";
|
|
10
|
+
taskId: string;
|
|
11
|
+
error: string;
|
|
12
|
+
} | {
|
|
13
|
+
type: "task_blocked";
|
|
14
|
+
taskId: string;
|
|
15
|
+
reason: string;
|
|
16
|
+
} | {
|
|
17
|
+
type: "summary";
|
|
18
|
+
summary: unknown;
|
|
19
|
+
} | {
|
|
20
|
+
type: "dry_run";
|
|
21
|
+
taskIds: string[];
|
|
22
|
+
} | {
|
|
23
|
+
type: "server";
|
|
24
|
+
message: string;
|
|
25
|
+
};
|
|
26
|
+
export interface Logger {
|
|
27
|
+
event(event: LogEvent): void;
|
|
28
|
+
}
|
|
29
|
+
export declare function createLogger(json: boolean, stream?: Pick<NodeJS.WritableStream, "write">): Logger;
|
package/dist/log.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export function createLogger(json, stream = process.stdout) {
|
|
2
|
+
return {
|
|
3
|
+
event(event) {
|
|
4
|
+
if (json) {
|
|
5
|
+
stream.write(`${JSON.stringify({ time: new Date().toISOString(), ...event })}\n`);
|
|
6
|
+
return;
|
|
7
|
+
}
|
|
8
|
+
stream.write(`${formatHuman(event)}\n`);
|
|
9
|
+
},
|
|
10
|
+
};
|
|
11
|
+
}
|
|
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;
|
|
26
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type SuccessFs } from "./success.js";
|
|
2
|
+
import { type TaskFs } from "./tasks.js";
|
|
3
|
+
import type { Logger } from "./log.js";
|
|
4
|
+
export declare const DEFAULT_TIMEOUT_MS: number;
|
|
5
|
+
export declare const DEFAULT_MAX_ATTEMPTS = 2;
|
|
6
|
+
export interface OpenCodeClient {
|
|
7
|
+
session: {
|
|
8
|
+
create(input: {
|
|
9
|
+
cwd: string;
|
|
10
|
+
}): Promise<{
|
|
11
|
+
id: string;
|
|
12
|
+
} | string>;
|
|
13
|
+
prompt(sessionId: string, input: {
|
|
14
|
+
prompt: string;
|
|
15
|
+
signal?: AbortSignal;
|
|
16
|
+
}): Promise<unknown>;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export interface Clock {
|
|
20
|
+
now(): Date;
|
|
21
|
+
setTimeout(callback: () => void, ms: number): ReturnType<typeof setTimeout>;
|
|
22
|
+
clearTimeout(handle: ReturnType<typeof setTimeout>): void;
|
|
23
|
+
}
|
|
24
|
+
export interface OrchestratorFs extends TaskFs, SuccessFs {
|
|
25
|
+
}
|
|
26
|
+
export interface OrchestratorOptions {
|
|
27
|
+
tasksPath: string;
|
|
28
|
+
client: OpenCodeClient;
|
|
29
|
+
fs?: OrchestratorFs;
|
|
30
|
+
clock?: Clock;
|
|
31
|
+
log?: Logger;
|
|
32
|
+
cwd?: string;
|
|
33
|
+
resultsDir?: string;
|
|
34
|
+
maxAttempts?: number;
|
|
35
|
+
timeoutMs?: number;
|
|
36
|
+
}
|
|
37
|
+
export interface Summary {
|
|
38
|
+
total: number;
|
|
39
|
+
pending: number;
|
|
40
|
+
running: number;
|
|
41
|
+
done: number;
|
|
42
|
+
failed: number;
|
|
43
|
+
blocked: number;
|
|
44
|
+
}
|
|
45
|
+
export declare function runOrchestrator(options: OrchestratorOptions): Promise<Summary>;
|
|
46
|
+
export declare function defaultResultsDir(cwd: string): string;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { dependencyState } from "./deps.js";
|
|
4
|
+
import { checkSuccess } from "./success.js";
|
|
5
|
+
import { loadTasks, saveTasks } from "./tasks.js";
|
|
6
|
+
export const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
|
|
7
|
+
export const DEFAULT_MAX_ATTEMPTS = 2;
|
|
8
|
+
const defaultClock = {
|
|
9
|
+
now: () => new Date(),
|
|
10
|
+
setTimeout: (callback, ms) => setTimeout(callback, ms),
|
|
11
|
+
clearTimeout: (handle) => clearTimeout(handle),
|
|
12
|
+
};
|
|
13
|
+
export async function runOrchestrator(options) {
|
|
14
|
+
const queueFs = options.fs ?? fs;
|
|
15
|
+
const taskFile = await loadTasks(options.tasksPath, queueFs);
|
|
16
|
+
await saveTasks(options.tasksPath, taskFile, queueFs);
|
|
17
|
+
while (true) {
|
|
18
|
+
let progressed = false;
|
|
19
|
+
for (const task of taskFile.tasks) {
|
|
20
|
+
if (!isRunnable(task, options.maxAttempts))
|
|
21
|
+
continue;
|
|
22
|
+
const state = dependencyState(task, taskFile.tasks);
|
|
23
|
+
if (state === "waiting")
|
|
24
|
+
continue;
|
|
25
|
+
if (state === "blocked") {
|
|
26
|
+
task.status = "blocked";
|
|
27
|
+
task.error = "Dependency failed, blocked, or missing.";
|
|
28
|
+
task.finishedAt = nowIso(options.clock);
|
|
29
|
+
options.log?.event({ type: "task_blocked", taskId: task.id, reason: task.error });
|
|
30
|
+
await saveTasks(options.tasksPath, taskFile, queueFs);
|
|
31
|
+
progressed = true;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
await runTask(task, taskFile.tasks, options, queueFs);
|
|
35
|
+
progressed = true;
|
|
36
|
+
}
|
|
37
|
+
if (!progressed)
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
const summary = summarize(taskFile.tasks);
|
|
41
|
+
options.log?.event({ type: "summary", summary });
|
|
42
|
+
return summary;
|
|
43
|
+
}
|
|
44
|
+
async function runTask(task, tasks, options, queueFs) {
|
|
45
|
+
const clock = options.clock ?? defaultClock;
|
|
46
|
+
const cwd = task.cwd ?? options.cwd ?? process.cwd();
|
|
47
|
+
const timeoutMs = task.timeoutMs ?? options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
48
|
+
const maxAttempts = task.maxAttempts ?? options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
|
|
49
|
+
task.status = "running";
|
|
50
|
+
task.attempts += 1;
|
|
51
|
+
task.startedAt = clock.now().toISOString();
|
|
52
|
+
task.finishedAt = undefined;
|
|
53
|
+
task.error = undefined;
|
|
54
|
+
options.log?.event({ type: "task_started", taskId: task.id, attempt: task.attempts });
|
|
55
|
+
await saveTasks(options.tasksPath, { tasks }, queueFs);
|
|
56
|
+
try {
|
|
57
|
+
const session = await options.client.session.create({ cwd });
|
|
58
|
+
const sessionId = typeof session === "string" ? session : session.id;
|
|
59
|
+
const controller = new AbortController();
|
|
60
|
+
await withTimeout(options.client.session.prompt(sessionId, { prompt: task.prompt, signal: controller.signal }), timeoutMs, clock, controller);
|
|
61
|
+
const success = await checkSuccess(task.success, { fs: queueFs, cwd });
|
|
62
|
+
if (!success)
|
|
63
|
+
throw new Error("Success check failed.");
|
|
64
|
+
task.status = "done";
|
|
65
|
+
task.finishedAt = clock.now().toISOString();
|
|
66
|
+
options.log?.event({ type: "task_done", taskId: task.id });
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
task.error = error instanceof Error ? error.message : String(error);
|
|
70
|
+
task.status = task.attempts >= maxAttempts ? "failed" : "pending";
|
|
71
|
+
task.finishedAt = clock.now().toISOString();
|
|
72
|
+
options.log?.event({ type: "task_failed", taskId: task.id, error: task.error });
|
|
73
|
+
}
|
|
74
|
+
await saveTasks(options.tasksPath, { tasks }, queueFs);
|
|
75
|
+
}
|
|
76
|
+
function isRunnable(task, globalMaxAttempts = DEFAULT_MAX_ATTEMPTS) {
|
|
77
|
+
if (task.status === "pending")
|
|
78
|
+
return true;
|
|
79
|
+
if (task.status !== "failed")
|
|
80
|
+
return false;
|
|
81
|
+
const maxAttempts = task.maxAttempts ?? globalMaxAttempts;
|
|
82
|
+
return task.attempts < maxAttempts;
|
|
83
|
+
}
|
|
84
|
+
async function withTimeout(promise, timeoutMs, clock, controller) {
|
|
85
|
+
let timeoutHandle;
|
|
86
|
+
const timeout = new Promise((_, reject) => {
|
|
87
|
+
timeoutHandle = clock.setTimeout(() => {
|
|
88
|
+
controller.abort();
|
|
89
|
+
reject(new Error(`Prompt timed out after ${timeoutMs}ms.`));
|
|
90
|
+
}, timeoutMs);
|
|
91
|
+
});
|
|
92
|
+
try {
|
|
93
|
+
return await Promise.race([promise, timeout]);
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
if (timeoutHandle)
|
|
97
|
+
clock.clearTimeout(timeoutHandle);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function summarize(tasks) {
|
|
101
|
+
const summary = {
|
|
102
|
+
total: tasks.length,
|
|
103
|
+
pending: 0,
|
|
104
|
+
running: 0,
|
|
105
|
+
done: 0,
|
|
106
|
+
failed: 0,
|
|
107
|
+
blocked: 0,
|
|
108
|
+
};
|
|
109
|
+
for (const task of tasks) {
|
|
110
|
+
summary[task.status] += 1;
|
|
111
|
+
}
|
|
112
|
+
return summary;
|
|
113
|
+
}
|
|
114
|
+
function nowIso(clock = defaultClock) {
|
|
115
|
+
return clock.now().toISOString();
|
|
116
|
+
}
|
|
117
|
+
export function defaultResultsDir(cwd) {
|
|
118
|
+
return path.join(cwd, ".queue", "results");
|
|
119
|
+
}
|
package/dist/schema.d.ts
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export declare const taskStatuses: readonly ["pending", "running", "done", "failed", "blocked"];
|
|
3
|
+
export declare const successCheckSchema: z.ZodDefault<z.ZodDiscriminatedUnion<"strategy", [z.ZodObject<{
|
|
4
|
+
strategy: z.ZodLiteral<"file_exists">;
|
|
5
|
+
path: z.ZodString;
|
|
6
|
+
}, "strip", z.ZodTypeAny, {
|
|
7
|
+
strategy: "file_exists";
|
|
8
|
+
path: string;
|
|
9
|
+
}, {
|
|
10
|
+
strategy: "file_exists";
|
|
11
|
+
path: string;
|
|
12
|
+
}>, z.ZodObject<{
|
|
13
|
+
strategy: z.ZodLiteral<"result_json">;
|
|
14
|
+
path: z.ZodString;
|
|
15
|
+
}, "strip", z.ZodTypeAny, {
|
|
16
|
+
strategy: "result_json";
|
|
17
|
+
path: string;
|
|
18
|
+
}, {
|
|
19
|
+
strategy: "result_json";
|
|
20
|
+
path: string;
|
|
21
|
+
}>, z.ZodObject<{
|
|
22
|
+
strategy: z.ZodLiteral<"default">;
|
|
23
|
+
}, "strip", z.ZodTypeAny, {
|
|
24
|
+
strategy: "default";
|
|
25
|
+
}, {
|
|
26
|
+
strategy: "default";
|
|
27
|
+
}>]>>;
|
|
28
|
+
export declare const taskSchema: z.ZodObject<{
|
|
29
|
+
id: z.ZodString;
|
|
30
|
+
prompt: z.ZodString;
|
|
31
|
+
dependsOn: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
|
|
32
|
+
status: z.ZodDefault<z.ZodEnum<["pending", "running", "done", "failed", "blocked"]>>;
|
|
33
|
+
attempts: z.ZodDefault<z.ZodNumber>;
|
|
34
|
+
maxAttempts: z.ZodOptional<z.ZodNumber>;
|
|
35
|
+
timeoutMs: z.ZodOptional<z.ZodNumber>;
|
|
36
|
+
cwd: z.ZodOptional<z.ZodString>;
|
|
37
|
+
resultPath: z.ZodOptional<z.ZodString>;
|
|
38
|
+
success: z.ZodOptional<z.ZodDefault<z.ZodDiscriminatedUnion<"strategy", [z.ZodObject<{
|
|
39
|
+
strategy: z.ZodLiteral<"file_exists">;
|
|
40
|
+
path: z.ZodString;
|
|
41
|
+
}, "strip", z.ZodTypeAny, {
|
|
42
|
+
strategy: "file_exists";
|
|
43
|
+
path: string;
|
|
44
|
+
}, {
|
|
45
|
+
strategy: "file_exists";
|
|
46
|
+
path: string;
|
|
47
|
+
}>, z.ZodObject<{
|
|
48
|
+
strategy: z.ZodLiteral<"result_json">;
|
|
49
|
+
path: z.ZodString;
|
|
50
|
+
}, "strip", z.ZodTypeAny, {
|
|
51
|
+
strategy: "result_json";
|
|
52
|
+
path: string;
|
|
53
|
+
}, {
|
|
54
|
+
strategy: "result_json";
|
|
55
|
+
path: string;
|
|
56
|
+
}>, z.ZodObject<{
|
|
57
|
+
strategy: z.ZodLiteral<"default">;
|
|
58
|
+
}, "strip", z.ZodTypeAny, {
|
|
59
|
+
strategy: "default";
|
|
60
|
+
}, {
|
|
61
|
+
strategy: "default";
|
|
62
|
+
}>]>>>;
|
|
63
|
+
recoveryNotes: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
|
|
64
|
+
error: z.ZodOptional<z.ZodString>;
|
|
65
|
+
startedAt: z.ZodOptional<z.ZodString>;
|
|
66
|
+
finishedAt: z.ZodOptional<z.ZodString>;
|
|
67
|
+
}, "strip", z.ZodTypeAny, {
|
|
68
|
+
status: "pending" | "running" | "done" | "failed" | "blocked";
|
|
69
|
+
id: string;
|
|
70
|
+
prompt: string;
|
|
71
|
+
dependsOn: string[];
|
|
72
|
+
attempts: number;
|
|
73
|
+
recoveryNotes: string[];
|
|
74
|
+
maxAttempts?: number | undefined;
|
|
75
|
+
timeoutMs?: number | undefined;
|
|
76
|
+
cwd?: string | undefined;
|
|
77
|
+
resultPath?: string | undefined;
|
|
78
|
+
success?: {
|
|
79
|
+
strategy: "file_exists";
|
|
80
|
+
path: string;
|
|
81
|
+
} | {
|
|
82
|
+
strategy: "result_json";
|
|
83
|
+
path: string;
|
|
84
|
+
} | {
|
|
85
|
+
strategy: "default";
|
|
86
|
+
} | undefined;
|
|
87
|
+
error?: string | undefined;
|
|
88
|
+
startedAt?: string | undefined;
|
|
89
|
+
finishedAt?: string | undefined;
|
|
90
|
+
}, {
|
|
91
|
+
id: string;
|
|
92
|
+
prompt: string;
|
|
93
|
+
status?: "pending" | "running" | "done" | "failed" | "blocked" | undefined;
|
|
94
|
+
dependsOn?: string[] | undefined;
|
|
95
|
+
attempts?: number | undefined;
|
|
96
|
+
maxAttempts?: number | undefined;
|
|
97
|
+
timeoutMs?: number | undefined;
|
|
98
|
+
cwd?: string | undefined;
|
|
99
|
+
resultPath?: string | undefined;
|
|
100
|
+
success?: {
|
|
101
|
+
strategy: "file_exists";
|
|
102
|
+
path: string;
|
|
103
|
+
} | {
|
|
104
|
+
strategy: "result_json";
|
|
105
|
+
path: string;
|
|
106
|
+
} | {
|
|
107
|
+
strategy: "default";
|
|
108
|
+
} | undefined;
|
|
109
|
+
recoveryNotes?: string[] | undefined;
|
|
110
|
+
error?: string | undefined;
|
|
111
|
+
startedAt?: string | undefined;
|
|
112
|
+
finishedAt?: string | undefined;
|
|
113
|
+
}>;
|
|
114
|
+
export declare const taskFileSchema: z.ZodUnion<[z.ZodObject<{
|
|
115
|
+
tasks: z.ZodArray<z.ZodObject<{
|
|
116
|
+
id: z.ZodString;
|
|
117
|
+
prompt: z.ZodString;
|
|
118
|
+
dependsOn: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
|
|
119
|
+
status: z.ZodDefault<z.ZodEnum<["pending", "running", "done", "failed", "blocked"]>>;
|
|
120
|
+
attempts: z.ZodDefault<z.ZodNumber>;
|
|
121
|
+
maxAttempts: z.ZodOptional<z.ZodNumber>;
|
|
122
|
+
timeoutMs: z.ZodOptional<z.ZodNumber>;
|
|
123
|
+
cwd: z.ZodOptional<z.ZodString>;
|
|
124
|
+
resultPath: z.ZodOptional<z.ZodString>;
|
|
125
|
+
success: z.ZodOptional<z.ZodDefault<z.ZodDiscriminatedUnion<"strategy", [z.ZodObject<{
|
|
126
|
+
strategy: z.ZodLiteral<"file_exists">;
|
|
127
|
+
path: z.ZodString;
|
|
128
|
+
}, "strip", z.ZodTypeAny, {
|
|
129
|
+
strategy: "file_exists";
|
|
130
|
+
path: string;
|
|
131
|
+
}, {
|
|
132
|
+
strategy: "file_exists";
|
|
133
|
+
path: string;
|
|
134
|
+
}>, z.ZodObject<{
|
|
135
|
+
strategy: z.ZodLiteral<"result_json">;
|
|
136
|
+
path: z.ZodString;
|
|
137
|
+
}, "strip", z.ZodTypeAny, {
|
|
138
|
+
strategy: "result_json";
|
|
139
|
+
path: string;
|
|
140
|
+
}, {
|
|
141
|
+
strategy: "result_json";
|
|
142
|
+
path: string;
|
|
143
|
+
}>, z.ZodObject<{
|
|
144
|
+
strategy: z.ZodLiteral<"default">;
|
|
145
|
+
}, "strip", z.ZodTypeAny, {
|
|
146
|
+
strategy: "default";
|
|
147
|
+
}, {
|
|
148
|
+
strategy: "default";
|
|
149
|
+
}>]>>>;
|
|
150
|
+
recoveryNotes: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
|
|
151
|
+
error: z.ZodOptional<z.ZodString>;
|
|
152
|
+
startedAt: z.ZodOptional<z.ZodString>;
|
|
153
|
+
finishedAt: z.ZodOptional<z.ZodString>;
|
|
154
|
+
}, "strip", z.ZodTypeAny, {
|
|
155
|
+
status: "pending" | "running" | "done" | "failed" | "blocked";
|
|
156
|
+
id: string;
|
|
157
|
+
prompt: string;
|
|
158
|
+
dependsOn: string[];
|
|
159
|
+
attempts: number;
|
|
160
|
+
recoveryNotes: string[];
|
|
161
|
+
maxAttempts?: number | undefined;
|
|
162
|
+
timeoutMs?: number | undefined;
|
|
163
|
+
cwd?: string | undefined;
|
|
164
|
+
resultPath?: string | undefined;
|
|
165
|
+
success?: {
|
|
166
|
+
strategy: "file_exists";
|
|
167
|
+
path: string;
|
|
168
|
+
} | {
|
|
169
|
+
strategy: "result_json";
|
|
170
|
+
path: string;
|
|
171
|
+
} | {
|
|
172
|
+
strategy: "default";
|
|
173
|
+
} | undefined;
|
|
174
|
+
error?: string | undefined;
|
|
175
|
+
startedAt?: string | undefined;
|
|
176
|
+
finishedAt?: string | undefined;
|
|
177
|
+
}, {
|
|
178
|
+
id: string;
|
|
179
|
+
prompt: string;
|
|
180
|
+
status?: "pending" | "running" | "done" | "failed" | "blocked" | undefined;
|
|
181
|
+
dependsOn?: string[] | undefined;
|
|
182
|
+
attempts?: number | undefined;
|
|
183
|
+
maxAttempts?: number | undefined;
|
|
184
|
+
timeoutMs?: number | undefined;
|
|
185
|
+
cwd?: string | undefined;
|
|
186
|
+
resultPath?: string | undefined;
|
|
187
|
+
success?: {
|
|
188
|
+
strategy: "file_exists";
|
|
189
|
+
path: string;
|
|
190
|
+
} | {
|
|
191
|
+
strategy: "result_json";
|
|
192
|
+
path: string;
|
|
193
|
+
} | {
|
|
194
|
+
strategy: "default";
|
|
195
|
+
} | undefined;
|
|
196
|
+
recoveryNotes?: string[] | undefined;
|
|
197
|
+
error?: string | undefined;
|
|
198
|
+
startedAt?: string | undefined;
|
|
199
|
+
finishedAt?: string | undefined;
|
|
200
|
+
}>, "many">;
|
|
201
|
+
}, "strip", z.ZodTypeAny, {
|
|
202
|
+
tasks: {
|
|
203
|
+
status: "pending" | "running" | "done" | "failed" | "blocked";
|
|
204
|
+
id: string;
|
|
205
|
+
prompt: string;
|
|
206
|
+
dependsOn: string[];
|
|
207
|
+
attempts: number;
|
|
208
|
+
recoveryNotes: string[];
|
|
209
|
+
maxAttempts?: number | undefined;
|
|
210
|
+
timeoutMs?: number | undefined;
|
|
211
|
+
cwd?: string | undefined;
|
|
212
|
+
resultPath?: string | undefined;
|
|
213
|
+
success?: {
|
|
214
|
+
strategy: "file_exists";
|
|
215
|
+
path: string;
|
|
216
|
+
} | {
|
|
217
|
+
strategy: "result_json";
|
|
218
|
+
path: string;
|
|
219
|
+
} | {
|
|
220
|
+
strategy: "default";
|
|
221
|
+
} | undefined;
|
|
222
|
+
error?: string | undefined;
|
|
223
|
+
startedAt?: string | undefined;
|
|
224
|
+
finishedAt?: string | undefined;
|
|
225
|
+
}[];
|
|
226
|
+
}, {
|
|
227
|
+
tasks: {
|
|
228
|
+
id: string;
|
|
229
|
+
prompt: string;
|
|
230
|
+
status?: "pending" | "running" | "done" | "failed" | "blocked" | undefined;
|
|
231
|
+
dependsOn?: string[] | undefined;
|
|
232
|
+
attempts?: number | undefined;
|
|
233
|
+
maxAttempts?: number | undefined;
|
|
234
|
+
timeoutMs?: number | undefined;
|
|
235
|
+
cwd?: string | undefined;
|
|
236
|
+
resultPath?: string | undefined;
|
|
237
|
+
success?: {
|
|
238
|
+
strategy: "file_exists";
|
|
239
|
+
path: string;
|
|
240
|
+
} | {
|
|
241
|
+
strategy: "result_json";
|
|
242
|
+
path: string;
|
|
243
|
+
} | {
|
|
244
|
+
strategy: "default";
|
|
245
|
+
} | undefined;
|
|
246
|
+
recoveryNotes?: string[] | undefined;
|
|
247
|
+
error?: string | undefined;
|
|
248
|
+
startedAt?: string | undefined;
|
|
249
|
+
finishedAt?: string | undefined;
|
|
250
|
+
}[];
|
|
251
|
+
}>, z.ZodEffects<z.ZodArray<z.ZodObject<{
|
|
252
|
+
id: z.ZodString;
|
|
253
|
+
prompt: z.ZodString;
|
|
254
|
+
dependsOn: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
|
|
255
|
+
status: z.ZodDefault<z.ZodEnum<["pending", "running", "done", "failed", "blocked"]>>;
|
|
256
|
+
attempts: z.ZodDefault<z.ZodNumber>;
|
|
257
|
+
maxAttempts: z.ZodOptional<z.ZodNumber>;
|
|
258
|
+
timeoutMs: z.ZodOptional<z.ZodNumber>;
|
|
259
|
+
cwd: z.ZodOptional<z.ZodString>;
|
|
260
|
+
resultPath: z.ZodOptional<z.ZodString>;
|
|
261
|
+
success: z.ZodOptional<z.ZodDefault<z.ZodDiscriminatedUnion<"strategy", [z.ZodObject<{
|
|
262
|
+
strategy: z.ZodLiteral<"file_exists">;
|
|
263
|
+
path: z.ZodString;
|
|
264
|
+
}, "strip", z.ZodTypeAny, {
|
|
265
|
+
strategy: "file_exists";
|
|
266
|
+
path: string;
|
|
267
|
+
}, {
|
|
268
|
+
strategy: "file_exists";
|
|
269
|
+
path: string;
|
|
270
|
+
}>, z.ZodObject<{
|
|
271
|
+
strategy: z.ZodLiteral<"result_json">;
|
|
272
|
+
path: z.ZodString;
|
|
273
|
+
}, "strip", z.ZodTypeAny, {
|
|
274
|
+
strategy: "result_json";
|
|
275
|
+
path: string;
|
|
276
|
+
}, {
|
|
277
|
+
strategy: "result_json";
|
|
278
|
+
path: string;
|
|
279
|
+
}>, z.ZodObject<{
|
|
280
|
+
strategy: z.ZodLiteral<"default">;
|
|
281
|
+
}, "strip", z.ZodTypeAny, {
|
|
282
|
+
strategy: "default";
|
|
283
|
+
}, {
|
|
284
|
+
strategy: "default";
|
|
285
|
+
}>]>>>;
|
|
286
|
+
recoveryNotes: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
|
|
287
|
+
error: z.ZodOptional<z.ZodString>;
|
|
288
|
+
startedAt: z.ZodOptional<z.ZodString>;
|
|
289
|
+
finishedAt: z.ZodOptional<z.ZodString>;
|
|
290
|
+
}, "strip", z.ZodTypeAny, {
|
|
291
|
+
status: "pending" | "running" | "done" | "failed" | "blocked";
|
|
292
|
+
id: string;
|
|
293
|
+
prompt: string;
|
|
294
|
+
dependsOn: string[];
|
|
295
|
+
attempts: number;
|
|
296
|
+
recoveryNotes: string[];
|
|
297
|
+
maxAttempts?: number | undefined;
|
|
298
|
+
timeoutMs?: number | undefined;
|
|
299
|
+
cwd?: string | undefined;
|
|
300
|
+
resultPath?: string | undefined;
|
|
301
|
+
success?: {
|
|
302
|
+
strategy: "file_exists";
|
|
303
|
+
path: string;
|
|
304
|
+
} | {
|
|
305
|
+
strategy: "result_json";
|
|
306
|
+
path: string;
|
|
307
|
+
} | {
|
|
308
|
+
strategy: "default";
|
|
309
|
+
} | undefined;
|
|
310
|
+
error?: string | undefined;
|
|
311
|
+
startedAt?: string | undefined;
|
|
312
|
+
finishedAt?: string | undefined;
|
|
313
|
+
}, {
|
|
314
|
+
id: string;
|
|
315
|
+
prompt: string;
|
|
316
|
+
status?: "pending" | "running" | "done" | "failed" | "blocked" | undefined;
|
|
317
|
+
dependsOn?: string[] | undefined;
|
|
318
|
+
attempts?: number | undefined;
|
|
319
|
+
maxAttempts?: number | undefined;
|
|
320
|
+
timeoutMs?: number | undefined;
|
|
321
|
+
cwd?: string | undefined;
|
|
322
|
+
resultPath?: string | undefined;
|
|
323
|
+
success?: {
|
|
324
|
+
strategy: "file_exists";
|
|
325
|
+
path: string;
|
|
326
|
+
} | {
|
|
327
|
+
strategy: "result_json";
|
|
328
|
+
path: string;
|
|
329
|
+
} | {
|
|
330
|
+
strategy: "default";
|
|
331
|
+
} | undefined;
|
|
332
|
+
recoveryNotes?: string[] | undefined;
|
|
333
|
+
error?: string | undefined;
|
|
334
|
+
startedAt?: string | undefined;
|
|
335
|
+
finishedAt?: string | undefined;
|
|
336
|
+
}>, "many">, {
|
|
337
|
+
tasks: {
|
|
338
|
+
status: "pending" | "running" | "done" | "failed" | "blocked";
|
|
339
|
+
id: string;
|
|
340
|
+
prompt: string;
|
|
341
|
+
dependsOn: string[];
|
|
342
|
+
attempts: number;
|
|
343
|
+
recoveryNotes: string[];
|
|
344
|
+
maxAttempts?: number | undefined;
|
|
345
|
+
timeoutMs?: number | undefined;
|
|
346
|
+
cwd?: string | undefined;
|
|
347
|
+
resultPath?: string | undefined;
|
|
348
|
+
success?: {
|
|
349
|
+
strategy: "file_exists";
|
|
350
|
+
path: string;
|
|
351
|
+
} | {
|
|
352
|
+
strategy: "result_json";
|
|
353
|
+
path: string;
|
|
354
|
+
} | {
|
|
355
|
+
strategy: "default";
|
|
356
|
+
} | undefined;
|
|
357
|
+
error?: string | undefined;
|
|
358
|
+
startedAt?: string | undefined;
|
|
359
|
+
finishedAt?: string | undefined;
|
|
360
|
+
}[];
|
|
361
|
+
}, {
|
|
362
|
+
id: string;
|
|
363
|
+
prompt: string;
|
|
364
|
+
status?: "pending" | "running" | "done" | "failed" | "blocked" | undefined;
|
|
365
|
+
dependsOn?: string[] | undefined;
|
|
366
|
+
attempts?: number | undefined;
|
|
367
|
+
maxAttempts?: number | undefined;
|
|
368
|
+
timeoutMs?: number | undefined;
|
|
369
|
+
cwd?: string | undefined;
|
|
370
|
+
resultPath?: string | undefined;
|
|
371
|
+
success?: {
|
|
372
|
+
strategy: "file_exists";
|
|
373
|
+
path: string;
|
|
374
|
+
} | {
|
|
375
|
+
strategy: "result_json";
|
|
376
|
+
path: string;
|
|
377
|
+
} | {
|
|
378
|
+
strategy: "default";
|
|
379
|
+
} | undefined;
|
|
380
|
+
recoveryNotes?: string[] | undefined;
|
|
381
|
+
error?: string | undefined;
|
|
382
|
+
startedAt?: string | undefined;
|
|
383
|
+
finishedAt?: string | undefined;
|
|
384
|
+
}[]>]>;
|
|
385
|
+
export type TaskStatus = (typeof taskStatuses)[number];
|
|
386
|
+
export type SuccessCheck = z.input<typeof successCheckSchema>;
|
|
387
|
+
export type Task = z.infer<typeof taskSchema>;
|
|
388
|
+
export type TaskFile = {
|
|
389
|
+
tasks: Task[];
|
|
390
|
+
};
|
|
391
|
+
export declare function parseTaskFile(value: unknown): TaskFile;
|
|
392
|
+
export declare function validateTaskGraph(tasks: Task[]): void;
|
package/dist/schema.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const taskStatuses = ["pending", "running", "done", "failed", "blocked"];
|
|
3
|
+
export const successCheckSchema = z
|
|
4
|
+
.discriminatedUnion("strategy", [
|
|
5
|
+
z.object({
|
|
6
|
+
strategy: z.literal("file_exists"),
|
|
7
|
+
path: z.string().min(1),
|
|
8
|
+
}),
|
|
9
|
+
z.object({
|
|
10
|
+
strategy: z.literal("result_json"),
|
|
11
|
+
path: z.string().min(1),
|
|
12
|
+
}),
|
|
13
|
+
z.object({
|
|
14
|
+
strategy: z.literal("default"),
|
|
15
|
+
}),
|
|
16
|
+
])
|
|
17
|
+
.default({ strategy: "default" });
|
|
18
|
+
export const taskSchema = z.object({
|
|
19
|
+
id: z.string().min(1),
|
|
20
|
+
prompt: z.string().min(1),
|
|
21
|
+
dependsOn: z.array(z.string().min(1)).default([]),
|
|
22
|
+
status: z.enum(taskStatuses).default("pending"),
|
|
23
|
+
attempts: z.number().int().min(0).default(0),
|
|
24
|
+
maxAttempts: z.number().int().min(1).optional(),
|
|
25
|
+
timeoutMs: z.number().int().positive().optional(),
|
|
26
|
+
cwd: z.string().min(1).optional(),
|
|
27
|
+
resultPath: z.string().min(1).optional(),
|
|
28
|
+
success: successCheckSchema.optional(),
|
|
29
|
+
recoveryNotes: z.array(z.string()).default([]),
|
|
30
|
+
error: z.string().optional(),
|
|
31
|
+
startedAt: z.string().datetime().optional(),
|
|
32
|
+
finishedAt: z.string().datetime().optional(),
|
|
33
|
+
});
|
|
34
|
+
export const taskFileSchema = z.union([
|
|
35
|
+
z.object({
|
|
36
|
+
tasks: z.array(taskSchema),
|
|
37
|
+
}),
|
|
38
|
+
z.array(taskSchema).transform((tasks) => ({ tasks })),
|
|
39
|
+
]);
|
|
40
|
+
export function parseTaskFile(value) {
|
|
41
|
+
const parsed = taskFileSchema.parse(value);
|
|
42
|
+
validateTaskGraph(parsed.tasks);
|
|
43
|
+
return parsed;
|
|
44
|
+
}
|
|
45
|
+
export function validateTaskGraph(tasks) {
|
|
46
|
+
const seen = new Set();
|
|
47
|
+
const duplicates = new Set();
|
|
48
|
+
for (const task of tasks) {
|
|
49
|
+
if (seen.has(task.id))
|
|
50
|
+
duplicates.add(task.id);
|
|
51
|
+
seen.add(task.id);
|
|
52
|
+
}
|
|
53
|
+
if (duplicates.size > 0) {
|
|
54
|
+
throw new Error(`Duplicate task id: ${Array.from(duplicates).join(", ")}`);
|
|
55
|
+
}
|
|
56
|
+
for (const task of tasks) {
|
|
57
|
+
if (task.dependsOn.includes(task.id)) {
|
|
58
|
+
throw new Error(`Task "${task.id}" cannot depend on itself`);
|
|
59
|
+
}
|
|
60
|
+
for (const dep of task.dependsOn) {
|
|
61
|
+
if (!seen.has(dep)) {
|
|
62
|
+
throw new Error(`Task "${task.id}" depends on missing task "${dep}"`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
|
+
export interface StartServerOptions {
|
|
3
|
+
command?: string;
|
|
4
|
+
args?: string[];
|
|
5
|
+
port: number;
|
|
6
|
+
host?: string;
|
|
7
|
+
timeoutMs?: number;
|
|
8
|
+
pollMs?: number;
|
|
9
|
+
spawnProcess?: typeof spawn;
|
|
10
|
+
portOpen?: (host: string, port: number) => Promise<boolean>;
|
|
11
|
+
sleep?: (ms: number) => Promise<void>;
|
|
12
|
+
now?: () => number;
|
|
13
|
+
}
|
|
14
|
+
export interface StopServerOptions {
|
|
15
|
+
graceMs?: number;
|
|
16
|
+
}
|
|
17
|
+
export declare function startServer(options: StartServerOptions): Promise<ChildProcess>;
|
|
18
|
+
export declare function stopServer(child: Pick<ChildProcess, "kill" | "killed" | "exitCode" | "once">, options?: StopServerOptions): Promise<void>;
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import net from "node:net";
|
|
3
|
+
export async function startServer(options) {
|
|
4
|
+
const command = options.command ?? "opencode";
|
|
5
|
+
const args = options.args ?? ["serve", "--port", String(options.port)];
|
|
6
|
+
const host = options.host ?? "127.0.0.1";
|
|
7
|
+
const timeoutMs = options.timeoutMs ?? 30_000;
|
|
8
|
+
const pollMs = options.pollMs ?? 100;
|
|
9
|
+
const portOpen = options.portOpen ?? isPortOpen;
|
|
10
|
+
const wait = options.sleep ?? sleep;
|
|
11
|
+
const now = options.now ?? Date.now;
|
|
12
|
+
const child = (options.spawnProcess ?? spawn)(command, args, {
|
|
13
|
+
stdio: "ignore",
|
|
14
|
+
detached: false,
|
|
15
|
+
});
|
|
16
|
+
const startedAt = now();
|
|
17
|
+
while (now() - startedAt < timeoutMs) {
|
|
18
|
+
if (await portOpen(host, options.port))
|
|
19
|
+
return child;
|
|
20
|
+
if (child.exitCode !== null)
|
|
21
|
+
throw new Error(`Server exited before port ${options.port} opened.`);
|
|
22
|
+
await wait(pollMs);
|
|
23
|
+
}
|
|
24
|
+
child.kill("SIGTERM");
|
|
25
|
+
throw new Error(`Timed out waiting for ${host}:${options.port}.`);
|
|
26
|
+
}
|
|
27
|
+
export async function stopServer(child, options = {}) {
|
|
28
|
+
const graceMs = options.graceMs ?? 5_000;
|
|
29
|
+
if (child.exitCode !== null)
|
|
30
|
+
return;
|
|
31
|
+
child.kill("SIGTERM");
|
|
32
|
+
await new Promise((resolve) => {
|
|
33
|
+
const timeout = setTimeout(() => {
|
|
34
|
+
if (child.exitCode === null)
|
|
35
|
+
child.kill("SIGKILL");
|
|
36
|
+
resolve();
|
|
37
|
+
}, graceMs);
|
|
38
|
+
child.once("exit", () => {
|
|
39
|
+
clearTimeout(timeout);
|
|
40
|
+
resolve();
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
async function isPortOpen(host, port) {
|
|
45
|
+
return new Promise((resolve) => {
|
|
46
|
+
const socket = net.createConnection({ host, port });
|
|
47
|
+
socket.once("connect", () => {
|
|
48
|
+
socket.destroy();
|
|
49
|
+
resolve(true);
|
|
50
|
+
});
|
|
51
|
+
socket.once("error", () => resolve(false));
|
|
52
|
+
socket.setTimeout(250, () => {
|
|
53
|
+
socket.destroy();
|
|
54
|
+
resolve(false);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
function sleep(ms) {
|
|
59
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
60
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { SuccessCheck } from "./schema.js";
|
|
2
|
+
export interface SuccessFs {
|
|
3
|
+
readFile(path: string, encoding: "utf8"): Promise<string>;
|
|
4
|
+
access(path: string): Promise<void>;
|
|
5
|
+
}
|
|
6
|
+
export interface SuccessOptions {
|
|
7
|
+
fs: SuccessFs;
|
|
8
|
+
cwd: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function checkSuccess(check: SuccessCheck | undefined, options: SuccessOptions): Promise<boolean>;
|
package/dist/success.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
export async function checkSuccess(check, options) {
|
|
3
|
+
if (!check || check.strategy === "default")
|
|
4
|
+
return true;
|
|
5
|
+
if (check.strategy === "file_exists") {
|
|
6
|
+
try {
|
|
7
|
+
await options.fs.access(resolvePath(options.cwd, check.path));
|
|
8
|
+
return true;
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
if (check.strategy === "result_json") {
|
|
15
|
+
try {
|
|
16
|
+
const raw = await options.fs.readFile(resolvePath(options.cwd, check.path), "utf8");
|
|
17
|
+
const parsed = JSON.parse(raw);
|
|
18
|
+
return parsed.status === "ok";
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
throw new Error(`Unknown success strategy: ${check.strategy}`);
|
|
25
|
+
}
|
|
26
|
+
function resolvePath(cwd, candidate) {
|
|
27
|
+
return path.isAbsolute(candidate) ? candidate : path.join(cwd, candidate);
|
|
28
|
+
}
|
package/dist/tasks.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type TaskFile } from "./schema.js";
|
|
2
|
+
export interface TaskFs {
|
|
3
|
+
readFile(path: string, encoding: "utf8"): Promise<string>;
|
|
4
|
+
writeFile(path: string, data: string, encoding: "utf8"): Promise<void>;
|
|
5
|
+
rename(oldPath: string, newPath: string): Promise<void>;
|
|
6
|
+
mkdir(path: string, options: {
|
|
7
|
+
recursive: true;
|
|
8
|
+
}): Promise<unknown>;
|
|
9
|
+
}
|
|
10
|
+
export declare function loadTasks(filePath: string, fs: TaskFs): Promise<TaskFile>;
|
|
11
|
+
export declare function saveTasks(filePath: string, taskFile: TaskFile, fs: TaskFs): Promise<void>;
|
package/dist/tasks.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { parseTaskFile } from "./schema.js";
|
|
3
|
+
export async function loadTasks(filePath, fs) {
|
|
4
|
+
const raw = await fs.readFile(filePath, "utf8");
|
|
5
|
+
const parsed = parseTaskFile(JSON.parse(raw));
|
|
6
|
+
let recovered = false;
|
|
7
|
+
const tasks = parsed.tasks.map((task) => {
|
|
8
|
+
if (task.status !== "running")
|
|
9
|
+
return task;
|
|
10
|
+
recovered = true;
|
|
11
|
+
return {
|
|
12
|
+
...task,
|
|
13
|
+
status: "pending",
|
|
14
|
+
recoveryNotes: [
|
|
15
|
+
...task.recoveryNotes,
|
|
16
|
+
`Recovered running task at ${new Date().toISOString()}; reset to pending.`,
|
|
17
|
+
],
|
|
18
|
+
startedAt: undefined,
|
|
19
|
+
};
|
|
20
|
+
});
|
|
21
|
+
return recovered ? { tasks } : parsed;
|
|
22
|
+
}
|
|
23
|
+
export async function saveTasks(filePath, taskFile, fs) {
|
|
24
|
+
parseTaskFile(taskFile);
|
|
25
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
26
|
+
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
27
|
+
const payload = `${JSON.stringify(taskFile, null, 2)}\n`;
|
|
28
|
+
await fs.writeFile(tempPath, payload, "utf8");
|
|
29
|
+
await fs.rename(tempPath, filePath);
|
|
30
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "td-barrage",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "TypeScript queue runner that loads a JSON task file, resolves dependencies, runs tasks through the OpenCode SDK, and resumes interrupted work.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=20.11"
|
|
8
|
+
},
|
|
9
|
+
"bin": {
|
|
10
|
+
"td-barrage": "dist/index.js"
|
|
11
|
+
},
|
|
12
|
+
"main": "dist/index.js",
|
|
13
|
+
"types": "dist/index.d.ts",
|
|
14
|
+
"files": [
|
|
15
|
+
"dist",
|
|
16
|
+
"README.md"
|
|
17
|
+
],
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/josh-theory/td-barrage.git"
|
|
21
|
+
},
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "tsc -p tsconfig.json",
|
|
28
|
+
"start": "node dist/index.js",
|
|
29
|
+
"dev": "tsx src/index.ts",
|
|
30
|
+
"test": "vitest run",
|
|
31
|
+
"test:watch": "vitest",
|
|
32
|
+
"test:integration": "RUN_INTEGRATION=1 vitest run test/integration.test.ts",
|
|
33
|
+
"typecheck": "tsc -p tsconfig.typecheck.json --noEmit",
|
|
34
|
+
"prepublishOnly": "npm run typecheck && npm test && npm run build"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@opencode-ai/sdk": "^1.15.10",
|
|
38
|
+
"zod": "^3.25.0"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@types/node": "^20.11.0",
|
|
42
|
+
"tsx": "^4.19.0",
|
|
43
|
+
"typescript": "^5.8.0",
|
|
44
|
+
"vitest": "^3.1.0"
|
|
45
|
+
}
|
|
46
|
+
}
|