@tingrudeng/worker-review-orchestrator-cli 0.1.0-beta.1
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/PUBLISHING.md +32 -0
- package/README.md +68 -0
- package/dist/cli.d.ts +25 -0
- package/dist/cli.js +248 -0
- package/dist/decide.d.ts +4 -0
- package/dist/decide.js +143 -0
- package/dist/dispatch.d.ts +14 -0
- package/dist/dispatch.js +105 -0
- package/dist/http.d.ts +10 -0
- package/dist/http.js +88 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/inspect.d.ts +4 -0
- package/dist/inspect.js +118 -0
- package/dist/redrive.d.ts +2 -0
- package/dist/redrive.js +182 -0
- package/dist/types.d.ts +147 -0
- package/dist/types.js +1 -0
- package/dist/watch.d.ts +5 -0
- package/dist/watch.js +78 -0
- package/package.json +56 -0
package/PUBLISHING.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Publishing @tingrudeng/worker-review-orchestrator-cli
|
|
2
|
+
|
|
3
|
+
## Release Gate
|
|
4
|
+
|
|
5
|
+
Before publishing:
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm --filter @tingrudeng/worker-review-orchestrator-cli typecheck
|
|
9
|
+
pnpm --filter @tingrudeng/worker-review-orchestrator-cli test
|
|
10
|
+
pnpm --filter @tingrudeng/worker-review-orchestrator-cli build
|
|
11
|
+
git diff --check
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
If the package behavior depends on live dispatcher flows, also run the relevant focused dispatcher tests from the repository root.
|
|
15
|
+
|
|
16
|
+
## Publish
|
|
17
|
+
|
|
18
|
+
Build and publish from the repository root:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pnpm --filter @tingrudeng/worker-review-orchestrator-cli build
|
|
22
|
+
pnpm --filter @tingrudeng/worker-review-orchestrator-cli publish --access public --no-git-checks
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Intended Install Shape
|
|
26
|
+
|
|
27
|
+
After publish, users should be able to install the CLI globally with:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npm install -g @tingrudeng/worker-review-orchestrator-cli
|
|
31
|
+
forgeflow-review-orchestrator --help
|
|
32
|
+
```
|
package/README.md
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# @tingrudeng/worker-review-orchestrator-cli
|
|
2
|
+
|
|
3
|
+
ForgeFlow control-layer CLI for dispatching tasks to workers, watching their status, and submitting review decisions.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
Preferred global install after publish:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install -g @tingrudeng/worker-review-orchestrator-cli
|
|
11
|
+
forgeflow-review-orchestrator --help
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Repository-local fallback:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pnpm --filter @tingrudeng/worker-review-orchestrator-cli build
|
|
18
|
+
node packages/worker-review-orchestrator-cli/dist/cli.js --help
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
This package is meant to pair with the `worker-review-orchestrator` skill, but it is installed separately from the skill itself.
|
|
22
|
+
|
|
23
|
+
## Commands
|
|
24
|
+
|
|
25
|
+
- `dispatch`
|
|
26
|
+
- POST a dispatch payload to `/api/dispatches`
|
|
27
|
+
- Supports `--target-worker-id` to pin all tasks in the payload to one worker
|
|
28
|
+
- `dispatch-task`
|
|
29
|
+
- Build and POST a single-task dispatch payload from CLI flags
|
|
30
|
+
- Preferred when the control layer wants to send one focused task without hand-writing `dispatch.json`
|
|
31
|
+
- Supports `--worker-prompt` and `--context-markdown` for inline string inputs
|
|
32
|
+
- Does NOT support file paths for prompt or context; use `--worker-prompt` or `--context-markdown` with literal strings
|
|
33
|
+
- `watch`
|
|
34
|
+
- Poll `/api/dashboard/snapshot` until a task reaches `review`, `failed`, `merged`, or `blocked`
|
|
35
|
+
- `decide`
|
|
36
|
+
- Submit `merge` or `block` decisions through the dispatcher review flow
|
|
37
|
+
- Supports dispatcher HTTP or local `state-dir` fallback
|
|
38
|
+
- Optional parameters: `--actor`, `--notes`, `--at`
|
|
39
|
+
- `inspect`
|
|
40
|
+
- Retrieve review material for a task from the dispatcher snapshot
|
|
41
|
+
- Fetches task, assignment, reviews, pull request, and events to help a control-layer reviewer summarize before merge/block decision
|
|
42
|
+
- Use `--summary` flag to get concise output with task status, branch, worker, result evidence, recent events, and review/PR state
|
|
43
|
+
|
|
44
|
+
## Current Boundary
|
|
45
|
+
|
|
46
|
+
- This package only orchestrates existing ForgeFlow dispatcher flows.
|
|
47
|
+
- It does not implement worker execution.
|
|
48
|
+
- It does not rewrite task state arbitrarily.
|
|
49
|
+
|
|
50
|
+
## Minimal Use
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
forgeflow-review-orchestrator dispatch --dispatcher-url http://127.0.0.1:8787 --input dispatch.json
|
|
54
|
+
forgeflow-review-orchestrator dispatch --dispatcher-url http://127.0.0.1:8787 --input dispatch.json --target-worker-id trae-remote-forgeflow
|
|
55
|
+
forgeflow-review-orchestrator dispatch-task --dispatcher-url http://127.0.0.1:8787 --repo TingRuDeng/ForgeFlow --default-branch main --task-id task-1 --title "Update docs" --pool trae --branch-name ai/trae/task-1 --allowed-paths docs/**,README.md --acceptance "pnpm typecheck,git diff --check" --target-worker-id trae-remote-forgeflow
|
|
56
|
+
forgeflow-review-orchestrator dispatch-task --dispatcher-url http://127.0.0.1:8787 --repo TingRuDeng/ForgeFlow --default-branch main --task-id task-1 --title "Refactor auth" --pool codex --branch-name ai/codex/auth --allowed-paths "packages/auth/**" --acceptance "pnpm typecheck" --worker-prompt "You are a codex worker. Refactor the auth module." --context-markdown "# Context\n\nFocus on packages/auth only."
|
|
57
|
+
forgeflow-review-orchestrator watch --dispatcher-url http://127.0.0.1:8787 --task-id dispatch-1:task-1
|
|
58
|
+
forgeflow-review-orchestrator decide --dispatcher-url http://127.0.0.1:8787 --task-id dispatch-1:task-1 --decision merge
|
|
59
|
+
forgeflow-review-orchestrator decide --state-dir /path/to/.forgeflow-dispatcher --task-id dispatch-1:task-1 --decision block --notes "Scope exceeded"
|
|
60
|
+
forgeflow-review-orchestrator inspect --dispatcher-url http://127.0.0.1:8787 --task-id dispatch-1:task-1
|
|
61
|
+
forgeflow-review-orchestrator inspect --dispatcher-url http://127.0.0.1:8787 --task-id dispatch-1:task-1 --summary
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
When `--target-worker-id` is set, the CLI injects that worker id into every task and assignment package before posting the dispatch. This keeps the existing `dispatch.json` shape but gives the control layer an explicit way to target `trae-local-forgeflow` or `trae-remote-forgeflow`.
|
|
65
|
+
|
|
66
|
+
Use `dispatch-task` for the common case of one worker task with one branch, one `allowedPaths` list, and one `acceptance` list. Keep `dispatch --input` for more complex multi-task or prebuilt payloads.
|
|
67
|
+
|
|
68
|
+
For control-layer review flow, prefer this CLI over calling lower-level review-decision scripts directly. Keep the scripts as compatibility and implementation detail, but use `forgeflow-review-orchestrator decide` as the default operator entrypoint.
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { runDispatch } from "./dispatch.js";
|
|
3
|
+
import { runDecide } from "./decide.js";
|
|
4
|
+
import { runInspect } from "./inspect.js";
|
|
5
|
+
import { runRedrive } from "./redrive.js";
|
|
6
|
+
import { watchTask } from "./watch.js";
|
|
7
|
+
export interface CliDeps {
|
|
8
|
+
runDispatch: typeof runDispatch;
|
|
9
|
+
watchTask: typeof watchTask;
|
|
10
|
+
runDecide: typeof runDecide;
|
|
11
|
+
runInspect: typeof runInspect;
|
|
12
|
+
runRedrive: typeof runRedrive;
|
|
13
|
+
log: (message: string) => void;
|
|
14
|
+
}
|
|
15
|
+
export interface ParsedCliArgs {
|
|
16
|
+
command: "dispatch" | "dispatch-task" | "watch" | "decide" | "inspect" | "redrive";
|
|
17
|
+
options: Record<string, string | number | boolean>;
|
|
18
|
+
}
|
|
19
|
+
export declare function parseCliArgs(argv: string[]): ParsedCliArgs;
|
|
20
|
+
export declare function runCli(argv: string[], partialDeps?: Partial<CliDeps>): Promise<import("./types.js").DispatchResult | import("./types.js").WatchSummaryResult | import("./types.js").DecideResult | import("./types.js").InspectResult | import("./types.js").InspectSummaryResult | import("./types.js").RedriveResult | {
|
|
21
|
+
dryRun: boolean;
|
|
22
|
+
dispatcherUrl: string;
|
|
23
|
+
payload: import("./types.js").DispatchInput;
|
|
24
|
+
} | null>;
|
|
25
|
+
export declare function isCliEntrypoint(scriptPath?: string): boolean;
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
4
|
+
import { buildSingleTaskDispatchInput, runDispatch } from "./dispatch.js";
|
|
5
|
+
import { runDecide } from "./decide.js";
|
|
6
|
+
import { runInspect } from "./inspect.js";
|
|
7
|
+
import { runRedrive } from "./redrive.js";
|
|
8
|
+
import { watchTask } from "./watch.js";
|
|
9
|
+
function parseValue(raw) {
|
|
10
|
+
if (raw === "true") {
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
if (raw === "false") {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
if (/^-?\d+$/.test(raw)) {
|
|
17
|
+
return Number(raw);
|
|
18
|
+
}
|
|
19
|
+
return raw;
|
|
20
|
+
}
|
|
21
|
+
export function parseCliArgs(argv) {
|
|
22
|
+
const [command, ...rest] = argv;
|
|
23
|
+
if (!command) {
|
|
24
|
+
throw new Error("command is required");
|
|
25
|
+
}
|
|
26
|
+
if (!["dispatch", "dispatch-task", "watch", "decide", "inspect", "redrive"].includes(command)) {
|
|
27
|
+
throw new Error(`unknown command: ${command}`);
|
|
28
|
+
}
|
|
29
|
+
const options = {};
|
|
30
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
31
|
+
const arg = rest[index];
|
|
32
|
+
if (!arg || !arg.startsWith("--")) {
|
|
33
|
+
throw new Error(`unknown argument: ${arg}`);
|
|
34
|
+
}
|
|
35
|
+
const key = arg.slice(2).replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
|
|
36
|
+
const next = rest[index + 1];
|
|
37
|
+
if (!next || next.startsWith("--")) {
|
|
38
|
+
options[key] = true;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
options[key] = parseValue(next);
|
|
42
|
+
index += 1;
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
command: command,
|
|
46
|
+
options,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function printHelp() {
|
|
50
|
+
console.log(`
|
|
51
|
+
Usage:
|
|
52
|
+
forgeflow-review-orchestrator dispatch --dispatcher-url http://127.0.0.1:8787 --input dispatch.json
|
|
53
|
+
forgeflow-review-orchestrator dispatch --dispatcher-url http://127.0.0.1:8787 --input dispatch.json --target-worker-id trae-remote-forgeflow
|
|
54
|
+
forgeflow-review-orchestrator dispatch-task --dispatcher-url http://127.0.0.1:8787 --repo TingRuDeng/ForgeFlow --default-branch main --task-id task-1 --title "Update docs" --pool trae --branch-name ai/trae/task-1 --allowed-paths docs/**,README.md --acceptance "pnpm typecheck,git diff --check"
|
|
55
|
+
forgeflow-review-orchestrator dispatch-task --dispatcher-url http://127.0.0.1:8787 --repo TingRuDeng/ForgeFlow --default-branch main --task-id task-1 --title "Update docs" --pool trae --branch-name ai/trae/task-1 --worker-prompt-file prompts/worker.md --context-markdown-file context/task.md
|
|
56
|
+
forgeflow-review-orchestrator watch --dispatcher-url http://127.0.0.1:8787 --task-id dispatch-1:task-1
|
|
57
|
+
forgeflow-review-orchestrator watch --dispatcher-url http://127.0.0.1:8787 --task-id dispatch-1:task-1 --summary
|
|
58
|
+
forgeflow-review-orchestrator decide --dispatcher-url http://127.0.0.1:8787 --task-id dispatch-1:task-1 --decision merge
|
|
59
|
+
forgeflow-review-orchestrator decide --state-dir /path/to/.forgeflow-dispatcher --task-id dispatch-1:task-1 --decision block
|
|
60
|
+
forgeflow-review-orchestrator inspect --dispatcher-url http://127.0.0.1:8787 --task-id dispatch-1:task-1
|
|
61
|
+
forgeflow-review-orchestrator inspect --dispatcher-url http://127.0.0.1:8787 --task-id dispatch-1:task-1 --summary
|
|
62
|
+
forgeflow-review-orchestrator inspect --state-dir /path/to/.forgeflow-dispatcher --task-id dispatch-1:task-1
|
|
63
|
+
forgeflow-review-orchestrator inspect --state-dir /path/to/.forgeflow-dispatcher --task-id dispatch-1:task-1 --summary
|
|
64
|
+
forgeflow-review-orchestrator redrive --dispatcher-url http://127.0.0.1:8787 --task-id dispatch-1:task-1
|
|
65
|
+
`);
|
|
66
|
+
}
|
|
67
|
+
export async function runCli(argv, partialDeps = {}) {
|
|
68
|
+
const deps = {
|
|
69
|
+
runDispatch,
|
|
70
|
+
watchTask,
|
|
71
|
+
runDecide,
|
|
72
|
+
runInspect,
|
|
73
|
+
runRedrive,
|
|
74
|
+
log: (message) => console.log(message),
|
|
75
|
+
...partialDeps,
|
|
76
|
+
};
|
|
77
|
+
const parsed = parseCliArgs(argv);
|
|
78
|
+
const options = parsed.options;
|
|
79
|
+
if (options.help === true) {
|
|
80
|
+
printHelp();
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
if (parsed.command === "dispatch") {
|
|
84
|
+
const dispatcherUrl = typeof options.dispatcherUrl === "string" ? options.dispatcherUrl : undefined;
|
|
85
|
+
const input = typeof options.input === "string" ? options.input : "-";
|
|
86
|
+
if (!dispatcherUrl) {
|
|
87
|
+
throw new Error("--dispatcher-url is required");
|
|
88
|
+
}
|
|
89
|
+
const result = await deps.runDispatch({
|
|
90
|
+
dispatcherUrl,
|
|
91
|
+
input,
|
|
92
|
+
targetWorkerId: typeof options.targetWorkerId === "string" ? options.targetWorkerId : undefined,
|
|
93
|
+
requestTimeoutMs: typeof options.requestTimeoutMs === "number" ? options.requestTimeoutMs : undefined,
|
|
94
|
+
});
|
|
95
|
+
deps.log(JSON.stringify(result, null, 2));
|
|
96
|
+
return result;
|
|
97
|
+
}
|
|
98
|
+
if (parsed.command === "dispatch-task") {
|
|
99
|
+
const dispatcherUrl = typeof options.dispatcherUrl === "string" ? options.dispatcherUrl : undefined;
|
|
100
|
+
const repo = typeof options.repo === "string" ? options.repo : undefined;
|
|
101
|
+
const defaultBranch = typeof options.defaultBranch === "string" ? options.defaultBranch : undefined;
|
|
102
|
+
const taskId = typeof options.taskId === "string" ? options.taskId : undefined;
|
|
103
|
+
const title = typeof options.title === "string" ? options.title : undefined;
|
|
104
|
+
const pool = typeof options.pool === "string" ? options.pool : undefined;
|
|
105
|
+
const branchName = typeof options.branchName === "string" ? options.branchName : undefined;
|
|
106
|
+
if (!dispatcherUrl) {
|
|
107
|
+
throw new Error("--dispatcher-url is required");
|
|
108
|
+
}
|
|
109
|
+
if (!repo || !defaultBranch || !taskId || !title || !pool || !branchName) {
|
|
110
|
+
throw new Error("--repo, --default-branch, --task-id, --title, --pool, and --branch-name are required");
|
|
111
|
+
}
|
|
112
|
+
const payload = buildSingleTaskDispatchInput({
|
|
113
|
+
repo,
|
|
114
|
+
defaultBranch,
|
|
115
|
+
taskId,
|
|
116
|
+
title,
|
|
117
|
+
pool,
|
|
118
|
+
branchName,
|
|
119
|
+
requestedBy: typeof options.requestedBy === "string" ? options.requestedBy : undefined,
|
|
120
|
+
allowedPaths: typeof options.allowedPaths === "string" ? options.allowedPaths : undefined,
|
|
121
|
+
acceptance: typeof options.acceptance === "string" ? options.acceptance : undefined,
|
|
122
|
+
dependsOn: typeof options.dependsOn === "string" ? options.dependsOn : undefined,
|
|
123
|
+
targetWorkerId: typeof options.targetWorkerId === "string" ? options.targetWorkerId : undefined,
|
|
124
|
+
verificationMode: typeof options.verificationMode === "string" ? options.verificationMode : undefined,
|
|
125
|
+
workerPrompt: typeof options.workerPrompt === "string" ? options.workerPrompt : undefined,
|
|
126
|
+
contextMarkdown: typeof options.contextMarkdown === "string" ? options.contextMarkdown : undefined,
|
|
127
|
+
workerPromptFile: typeof options.workerPromptFile === "string" ? options.workerPromptFile : undefined,
|
|
128
|
+
contextMarkdownFile: typeof options.contextMarkdownFile === "string" ? options.contextMarkdownFile : undefined,
|
|
129
|
+
});
|
|
130
|
+
if (options.dryRun === true) {
|
|
131
|
+
const dryRunResult = { dryRun: true, dispatcherUrl, payload };
|
|
132
|
+
deps.log(JSON.stringify(dryRunResult, null, 2));
|
|
133
|
+
return dryRunResult;
|
|
134
|
+
}
|
|
135
|
+
const result = await deps.runDispatch({
|
|
136
|
+
dispatcherUrl,
|
|
137
|
+
input: "-",
|
|
138
|
+
payload,
|
|
139
|
+
requestTimeoutMs: typeof options.requestTimeoutMs === "number" ? options.requestTimeoutMs : undefined,
|
|
140
|
+
});
|
|
141
|
+
deps.log(JSON.stringify(result, null, 2));
|
|
142
|
+
return result;
|
|
143
|
+
}
|
|
144
|
+
if (parsed.command === "watch") {
|
|
145
|
+
const dispatcherUrl = typeof options.dispatcherUrl === "string" ? options.dispatcherUrl : undefined;
|
|
146
|
+
const taskId = typeof options.taskId === "string" ? options.taskId : undefined;
|
|
147
|
+
if (!dispatcherUrl) {
|
|
148
|
+
throw new Error("--dispatcher-url is required");
|
|
149
|
+
}
|
|
150
|
+
if (!taskId) {
|
|
151
|
+
throw new Error("--task-id is required");
|
|
152
|
+
}
|
|
153
|
+
const result = await deps.watchTask({
|
|
154
|
+
dispatcherUrl,
|
|
155
|
+
taskId,
|
|
156
|
+
intervalMs: typeof options.intervalMs === "number" ? options.intervalMs : undefined,
|
|
157
|
+
timeoutMs: typeof options.timeoutMs === "number" ? options.timeoutMs : undefined,
|
|
158
|
+
summary: options.summary === true,
|
|
159
|
+
});
|
|
160
|
+
deps.log(JSON.stringify(result, null, 2));
|
|
161
|
+
return result;
|
|
162
|
+
}
|
|
163
|
+
if (parsed.command === "decide") {
|
|
164
|
+
const taskId = typeof options.taskId === "string" ? options.taskId : undefined;
|
|
165
|
+
const decision = typeof options.decision === "string" ? options.decision : undefined;
|
|
166
|
+
if (!taskId) {
|
|
167
|
+
throw new Error("--task-id is required");
|
|
168
|
+
}
|
|
169
|
+
if (!decision) {
|
|
170
|
+
throw new Error("--decision is required");
|
|
171
|
+
}
|
|
172
|
+
const result = await deps.runDecide({
|
|
173
|
+
taskId,
|
|
174
|
+
decision: decision,
|
|
175
|
+
actor: typeof options.actor === "string" ? options.actor : undefined,
|
|
176
|
+
notes: typeof options.notes === "string" ? options.notes : undefined,
|
|
177
|
+
at: typeof options.at === "string" ? options.at : undefined,
|
|
178
|
+
dispatcherUrl: typeof options.dispatcherUrl === "string" ? options.dispatcherUrl : undefined,
|
|
179
|
+
stateDir: typeof options.stateDir === "string" ? options.stateDir : undefined,
|
|
180
|
+
});
|
|
181
|
+
deps.log(JSON.stringify(result, null, 2));
|
|
182
|
+
return result;
|
|
183
|
+
}
|
|
184
|
+
if (parsed.command === "inspect") {
|
|
185
|
+
const dispatcherUrl = typeof options.dispatcherUrl === "string" ? options.dispatcherUrl : undefined;
|
|
186
|
+
const taskId = typeof options.taskId === "string" ? options.taskId : undefined;
|
|
187
|
+
const stateDir = typeof options.stateDir === "string" ? options.stateDir : undefined;
|
|
188
|
+
if (!dispatcherUrl && !stateDir) {
|
|
189
|
+
throw new Error("--dispatcher-url or --state-dir is required");
|
|
190
|
+
}
|
|
191
|
+
if (!taskId) {
|
|
192
|
+
throw new Error("--task-id is required");
|
|
193
|
+
}
|
|
194
|
+
const result = await deps.runInspect({
|
|
195
|
+
dispatcherUrl,
|
|
196
|
+
taskId,
|
|
197
|
+
summary: options.summary === true,
|
|
198
|
+
stateDir,
|
|
199
|
+
});
|
|
200
|
+
deps.log(JSON.stringify(result, null, 2));
|
|
201
|
+
return result;
|
|
202
|
+
}
|
|
203
|
+
if (parsed.command === "redrive") {
|
|
204
|
+
const dispatcherUrl = typeof options.dispatcherUrl === "string" ? options.dispatcherUrl : undefined;
|
|
205
|
+
const taskId = typeof options.taskId === "string" ? options.taskId : undefined;
|
|
206
|
+
if (!dispatcherUrl) {
|
|
207
|
+
throw new Error("--dispatcher-url is required");
|
|
208
|
+
}
|
|
209
|
+
if (!taskId) {
|
|
210
|
+
throw new Error("--task-id is required");
|
|
211
|
+
}
|
|
212
|
+
const result = await deps.runRedrive({
|
|
213
|
+
dispatcherUrl,
|
|
214
|
+
taskId,
|
|
215
|
+
});
|
|
216
|
+
deps.log(JSON.stringify(result, null, 2));
|
|
217
|
+
return result;
|
|
218
|
+
}
|
|
219
|
+
throw new Error(`unknown command: ${parsed.command}`);
|
|
220
|
+
}
|
|
221
|
+
function main() {
|
|
222
|
+
const args = process.argv.slice(2);
|
|
223
|
+
if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
|
|
224
|
+
printHelp();
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
runCli(args).catch((error) => {
|
|
228
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
229
|
+
process.exit(1);
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
export function isCliEntrypoint(scriptPath = process.argv[1]) {
|
|
233
|
+
if (!scriptPath) {
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
const resolvedPath = scriptPath.startsWith("file:")
|
|
237
|
+
? fileURLToPath(scriptPath)
|
|
238
|
+
: scriptPath;
|
|
239
|
+
try {
|
|
240
|
+
return import.meta.url === pathToFileURL(fs.realpathSync(resolvedPath)).href;
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
return import.meta.url === pathToFileURL(resolvedPath).href;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
if (isCliEntrypoint()) {
|
|
247
|
+
main();
|
|
248
|
+
}
|
package/dist/decide.d.ts
ADDED
package/dist/decide.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { createJsonHttpClient, loadRuntimeState, saveRuntimeState } from "./http.js";
|
|
3
|
+
function normalizeDecision(decision) {
|
|
4
|
+
if (decision === "merge") {
|
|
5
|
+
return "merge";
|
|
6
|
+
}
|
|
7
|
+
return "block";
|
|
8
|
+
}
|
|
9
|
+
function readNowIso() {
|
|
10
|
+
return new Date().toISOString();
|
|
11
|
+
}
|
|
12
|
+
function upsertByTaskId(items, payload) {
|
|
13
|
+
const index = items.findIndex((item) => item.taskId === payload.taskId);
|
|
14
|
+
if (index === -1) {
|
|
15
|
+
return [...items, payload];
|
|
16
|
+
}
|
|
17
|
+
const next = [...items];
|
|
18
|
+
next[index] = {
|
|
19
|
+
...next[index],
|
|
20
|
+
...payload,
|
|
21
|
+
};
|
|
22
|
+
return next;
|
|
23
|
+
}
|
|
24
|
+
function buildLocalDecisionState(state, input) {
|
|
25
|
+
const task = state.tasks.find((candidate) => candidate.id === input.taskId);
|
|
26
|
+
if (!task) {
|
|
27
|
+
throw new Error(`task not found: ${input.taskId}`);
|
|
28
|
+
}
|
|
29
|
+
const assignment = state.assignments.find((candidate) => candidate.taskId === input.taskId);
|
|
30
|
+
if (!assignment) {
|
|
31
|
+
throw new Error(`assignment not found: ${input.taskId}`);
|
|
32
|
+
}
|
|
33
|
+
if (task.status !== "review") {
|
|
34
|
+
throw new Error(`task not in review: ${input.taskId}`);
|
|
35
|
+
}
|
|
36
|
+
const nextStatus = input.decision === "merge" ? "merged" : "blocked";
|
|
37
|
+
const at = input.at || readNowIso();
|
|
38
|
+
const nextEvents = [
|
|
39
|
+
...state.events,
|
|
40
|
+
{
|
|
41
|
+
taskId: input.taskId,
|
|
42
|
+
type: "status_changed",
|
|
43
|
+
at,
|
|
44
|
+
payload: {
|
|
45
|
+
from: "review",
|
|
46
|
+
to: nextStatus,
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
];
|
|
50
|
+
const nextTasks = state.tasks.map((candidate) => candidate.id === input.taskId
|
|
51
|
+
? {
|
|
52
|
+
...candidate,
|
|
53
|
+
status: nextStatus,
|
|
54
|
+
}
|
|
55
|
+
: candidate);
|
|
56
|
+
const nextAssignments = state.assignments.map((candidate) => candidate.taskId === input.taskId
|
|
57
|
+
? {
|
|
58
|
+
...candidate,
|
|
59
|
+
status: nextStatus,
|
|
60
|
+
assignment: {
|
|
61
|
+
...candidate.assignment,
|
|
62
|
+
status: nextStatus,
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
: candidate);
|
|
66
|
+
const nextReviews = upsertByTaskId(state.reviews, {
|
|
67
|
+
taskId: input.taskId,
|
|
68
|
+
decision: input.decision,
|
|
69
|
+
actor: input.actor ?? "codex-control",
|
|
70
|
+
notes: input.notes ?? "",
|
|
71
|
+
decidedAt: at,
|
|
72
|
+
});
|
|
73
|
+
const nextPullRequests = state.pullRequests.map((pullRequest) => pullRequest.taskId === input.taskId
|
|
74
|
+
? {
|
|
75
|
+
...pullRequest,
|
|
76
|
+
status: input.decision === "merge" ? "merged" : "changes_requested",
|
|
77
|
+
updatedAt: at,
|
|
78
|
+
}
|
|
79
|
+
: pullRequest);
|
|
80
|
+
const nextState = {
|
|
81
|
+
...state,
|
|
82
|
+
updatedAt: at,
|
|
83
|
+
events: nextEvents,
|
|
84
|
+
tasks: nextTasks,
|
|
85
|
+
assignments: nextAssignments,
|
|
86
|
+
reviews: nextReviews,
|
|
87
|
+
pullRequests: nextPullRequests,
|
|
88
|
+
};
|
|
89
|
+
return {
|
|
90
|
+
state: nextState,
|
|
91
|
+
result: {
|
|
92
|
+
taskId: input.taskId,
|
|
93
|
+
decision: input.decision,
|
|
94
|
+
status: nextStatus,
|
|
95
|
+
actor: input.actor ?? "codex-control",
|
|
96
|
+
notes: input.notes ?? "",
|
|
97
|
+
at,
|
|
98
|
+
task,
|
|
99
|
+
assignment,
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
export async function runDecide(options) {
|
|
104
|
+
const decision = normalizeDecision(options.decision);
|
|
105
|
+
const payload = {
|
|
106
|
+
actor: options.actor ?? "codex-control",
|
|
107
|
+
decision,
|
|
108
|
+
notes: options.notes ?? "",
|
|
109
|
+
at: options.at ?? readNowIso(),
|
|
110
|
+
};
|
|
111
|
+
if (options.dispatcherUrl) {
|
|
112
|
+
const client = createJsonHttpClient(options.dispatcherUrl, {
|
|
113
|
+
fetchImpl: options.fetchImpl,
|
|
114
|
+
});
|
|
115
|
+
const result = await client.request(`/api/reviews/${encodeURIComponent(options.taskId)}/decision`, {
|
|
116
|
+
method: "POST",
|
|
117
|
+
body: payload,
|
|
118
|
+
});
|
|
119
|
+
return {
|
|
120
|
+
taskId: options.taskId,
|
|
121
|
+
decision,
|
|
122
|
+
status: decision === "merge" ? "merged" : "blocked",
|
|
123
|
+
source: "dispatcher",
|
|
124
|
+
payload: result,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
if (!options.stateDir) {
|
|
128
|
+
throw new Error("dispatcherUrl or stateDir is required");
|
|
129
|
+
}
|
|
130
|
+
const state = loadRuntimeState(path.resolve(options.stateDir));
|
|
131
|
+
const result = buildLocalDecisionState(state, {
|
|
132
|
+
...options,
|
|
133
|
+
decision,
|
|
134
|
+
});
|
|
135
|
+
saveRuntimeState(path.resolve(options.stateDir), result.state);
|
|
136
|
+
return {
|
|
137
|
+
taskId: options.taskId,
|
|
138
|
+
decision,
|
|
139
|
+
status: decision === "merge" ? "merged" : "blocked",
|
|
140
|
+
source: "state-dir",
|
|
141
|
+
payload: result.result,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { DispatchInput, DispatchResult, DispatchTaskInputOptions } from "./types.js";
|
|
2
|
+
export interface DispatchOptions {
|
|
3
|
+
dispatcherUrl: string;
|
|
4
|
+
input: string;
|
|
5
|
+
payload?: DispatchInput;
|
|
6
|
+
targetWorkerId?: string;
|
|
7
|
+
requestTimeoutMs?: number;
|
|
8
|
+
fetchImpl?: typeof globalThis.fetch;
|
|
9
|
+
readStdin?: () => Promise<string>;
|
|
10
|
+
}
|
|
11
|
+
export declare function loadDispatchInput(source: string, readStdin?: () => Promise<string>): Promise<DispatchInput>;
|
|
12
|
+
export declare function buildSingleTaskDispatchInput(options: DispatchTaskInputOptions): DispatchInput;
|
|
13
|
+
export declare function applyDispatchTargetWorker(payload: DispatchInput, targetWorkerId?: string): DispatchInput;
|
|
14
|
+
export declare function runDispatch(options: DispatchOptions): Promise<DispatchResult>;
|
package/dist/dispatch.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { createJsonHttpClient, readJsonInput } from "./http.js";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
export async function loadDispatchInput(source, readStdin) {
|
|
4
|
+
return readJsonInput(source, { readStdin });
|
|
5
|
+
}
|
|
6
|
+
function splitCsv(input) {
|
|
7
|
+
return String(input || "")
|
|
8
|
+
.split(",")
|
|
9
|
+
.map((item) => item.trim())
|
|
10
|
+
.filter(Boolean);
|
|
11
|
+
}
|
|
12
|
+
function readFileContent(filePath) {
|
|
13
|
+
if (!filePath) {
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
try {
|
|
17
|
+
return readFileSync(filePath, "utf-8").trim();
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export function buildSingleTaskDispatchInput(options) {
|
|
24
|
+
const targetWorkerId = String(options.targetWorkerId || "").trim() || undefined;
|
|
25
|
+
const allowedPaths = splitCsv(options.allowedPaths);
|
|
26
|
+
const acceptance = splitCsv(options.acceptance);
|
|
27
|
+
const dependsOn = splitCsv(options.dependsOn);
|
|
28
|
+
const workerPromptFromFile = readFileContent(options.workerPromptFile);
|
|
29
|
+
const contextMarkdownFromFile = readFileContent(options.contextMarkdownFile);
|
|
30
|
+
const workerPrompt = workerPromptFromFile || options.workerPrompt || "You are a ForgeFlow worker. Stay within allowedPaths and satisfy acceptance.";
|
|
31
|
+
const contextMarkdown = contextMarkdownFromFile || options.contextMarkdown || "# Context\n\nComplete the assigned task within scope.";
|
|
32
|
+
return {
|
|
33
|
+
repo: options.repo,
|
|
34
|
+
defaultBranch: options.defaultBranch,
|
|
35
|
+
requestedBy: options.requestedBy || "codex-control",
|
|
36
|
+
tasks: [
|
|
37
|
+
{
|
|
38
|
+
id: options.taskId,
|
|
39
|
+
title: options.title,
|
|
40
|
+
pool: options.pool,
|
|
41
|
+
allowedPaths,
|
|
42
|
+
acceptance,
|
|
43
|
+
dependsOn,
|
|
44
|
+
branchName: options.branchName,
|
|
45
|
+
verification: {
|
|
46
|
+
mode: String(options.verificationMode || "run"),
|
|
47
|
+
},
|
|
48
|
+
...(targetWorkerId ? { targetWorkerId } : {}),
|
|
49
|
+
...(options.continuationMode ? { continuationMode: options.continuationMode } : {}),
|
|
50
|
+
...(options.continueFromTaskId ? { continueFromTaskId: options.continueFromTaskId } : {}),
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
packages: [
|
|
54
|
+
{
|
|
55
|
+
taskId: options.taskId,
|
|
56
|
+
assignment: {
|
|
57
|
+
taskId: options.taskId,
|
|
58
|
+
workerId: null,
|
|
59
|
+
pool: options.pool,
|
|
60
|
+
status: "pending",
|
|
61
|
+
branchName: options.branchName,
|
|
62
|
+
allowedPaths,
|
|
63
|
+
repo: options.repo,
|
|
64
|
+
defaultBranch: options.defaultBranch,
|
|
65
|
+
...(targetWorkerId ? { targetWorkerId } : {}),
|
|
66
|
+
...(options.continuationMode ? { continuationMode: options.continuationMode } : {}),
|
|
67
|
+
...(options.continueFromTaskId ? { continueFromTaskId: options.continueFromTaskId } : {}),
|
|
68
|
+
},
|
|
69
|
+
workerPrompt,
|
|
70
|
+
contextMarkdown,
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
function applyTargetWorkerToRecords(records, targetWorkerId) {
|
|
76
|
+
return records.map((record) => ({
|
|
77
|
+
...record,
|
|
78
|
+
targetWorkerId,
|
|
79
|
+
target_worker_id: targetWorkerId,
|
|
80
|
+
}));
|
|
81
|
+
}
|
|
82
|
+
export function applyDispatchTargetWorker(payload, targetWorkerId) {
|
|
83
|
+
const normalizedTargetWorkerId = String(targetWorkerId || "").trim();
|
|
84
|
+
if (!normalizedTargetWorkerId) {
|
|
85
|
+
return payload;
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
...payload,
|
|
89
|
+
tasks: applyTargetWorkerToRecords(payload.tasks, normalizedTargetWorkerId),
|
|
90
|
+
packages: applyTargetWorkerToRecords(payload.packages, normalizedTargetWorkerId),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
export async function runDispatch(options) {
|
|
94
|
+
const payload = options.payload
|
|
95
|
+
? applyDispatchTargetWorker(options.payload, options.targetWorkerId)
|
|
96
|
+
: applyDispatchTargetWorker(await loadDispatchInput(options.input, options.readStdin), options.targetWorkerId);
|
|
97
|
+
const client = createJsonHttpClient(options.dispatcherUrl, {
|
|
98
|
+
fetchImpl: options.fetchImpl,
|
|
99
|
+
requestTimeoutMs: options.requestTimeoutMs,
|
|
100
|
+
});
|
|
101
|
+
return client.request("/api/dispatches", {
|
|
102
|
+
method: "POST",
|
|
103
|
+
body: payload,
|
|
104
|
+
});
|
|
105
|
+
}
|
package/dist/http.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { JsonHttpClientOptions, JsonHttpRequestOptions, LocalRuntimeState } from "./types.js";
|
|
2
|
+
export declare function createJsonHttpClient(baseUrl: string, options?: JsonHttpClientOptions): {
|
|
3
|
+
request: (pathname: string, init?: JsonHttpRequestOptions) => Promise<any>;
|
|
4
|
+
};
|
|
5
|
+
export declare function createEmptyRuntimeState(): LocalRuntimeState;
|
|
6
|
+
export declare function loadRuntimeState(stateDir: string): LocalRuntimeState;
|
|
7
|
+
export declare function saveRuntimeState(stateDir: string, state: LocalRuntimeState): void;
|
|
8
|
+
export declare function readJsonInput(source: string, options?: {
|
|
9
|
+
readStdin?: () => Promise<string>;
|
|
10
|
+
}): Promise<any>;
|