forgedock 1.0.21 → 1.1.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/README.md +227 -174
- package/bin/cinema.mjs +320 -0
- package/bin/demo.mjs +386 -0
- package/bin/engine/phases.mjs +148 -0
- package/bin/engine/projector.mjs +32 -0
- package/bin/engine/reconcile.mjs +11 -0
- package/bin/engine/runlog.mjs +79 -0
- package/bin/engine/state.mjs +27 -0
- package/bin/engine-cli.mjs +50 -0
- package/bin/engine.mjs +138 -0
- package/bin/forgedock.mjs +693 -1393
- package/bin/init-detect.mjs +95 -0
- package/bin/journey.mjs +783 -0
- package/bin/runner.mjs +847 -0
- package/bin/settings-hook.mjs +112 -0
- package/bin/tui.mjs +6 -3
- package/commands/autopilot.md +4 -4
- package/commands/cleanup.md +85 -39
- package/commands/deploy-info.md +11 -5
- package/commands/design-bench.md +1 -0
- package/commands/design-render-critique-loop.md +1 -0
- package/commands/design.md +1 -0
- package/commands/forge-stats.md +2 -1
- package/commands/issue.md +1 -1
- package/commands/optimize.md +1 -1
- package/commands/orchestrate.md +346 -34
- package/commands/quality-gate.md +168 -80
- package/commands/review-pr-agents.md +69 -45
- package/commands/review-pr.md +54 -6
- package/commands/scope.md +11 -3
- package/commands/security-audit.md +24 -16
- package/commands/sync-ecosystem.md +2 -2
- package/commands/work-on/build/architect.md +47 -3
- package/commands/work-on/build/context.md +36 -5
- package/commands/work-on/build/implement.md +16 -4
- package/commands/work-on/build/validate.md +54 -8
- package/commands/work-on/build.md +27 -9
- package/commands/work-on/close.md +131 -3
- package/commands/work-on/investigate.md +24 -1
- package/commands/work-on/review.md +24 -0
- package/commands/work-on-monolithic.md +1 -0
- package/commands/work-on.md +271 -11
- package/docs/BRAND.md +6 -2
- package/docs/CONFIG.md +39 -34
- package/docs/site/.vitepress/config.mjs +85 -0
- package/docs/site/annotations-explained.md +55 -0
- package/docs/site/command-learning-path.md +104 -0
- package/docs/site/command-reference.md +3 -0
- package/docs/site/getting-started.md +42 -13
- package/docs/site/index.md +1 -1
- package/docs/site/troubleshooting.md +208 -0
- package/docs/spec/forge-protocol-v1.md +768 -0
- package/docs/superpowers/plans/2026-07-03-cinematic-onboarding-tui.md +2339 -0
- package/docs/superpowers/plans/2026-07-03-durable-execution-engine.md +1223 -0
- package/docs/superpowers/specs/2026-07-03-cinematic-onboarding-tui-design.md +157 -0
- package/docs/superpowers/specs/2026-07-03-durable-execution-engine-design.md +194 -0
- package/examples/forgedock-demo/README.md +113 -0
- package/examples/forgedock-demo/bootstrap.sh +125 -0
- package/examples/forgedock-demo/issues/01-bug-delete-missing-auth.md +37 -0
- package/examples/forgedock-demo/issues/02-feature-filter-notes.md +37 -0
- package/examples/forgedock-demo/issues/03-refactor-router-table.md +33 -0
- package/examples/forgedock-demo/issues/04-perf-index-lookup.md +35 -0
- package/examples/forgedock-demo/issues/05-docs-api-reference.md +39 -0
- package/examples/forgedock-demo/labels.json +21 -0
- package/examples/forgedock-demo/package.json +16 -0
- package/examples/forgedock-demo/scripts/smoke.js +61 -0
- package/examples/forgedock-demo/src/auth.js +28 -0
- package/examples/forgedock-demo/src/db.js +66 -0
- package/examples/forgedock-demo/src/routes/notes.js +64 -0
- package/examples/forgedock-demo/src/server.js +79 -0
- package/package.json +13 -3
- package/scripts/verify-env-vars.sh +18 -2
- package/scripts/verify-host-headers.sh +4 -4
- package/scripts/verify-route-registration.sh +2 -2
- package/scripts/worktree-lifecycle.sh +202 -0
- package/templates/forge.yaml.minimal +44 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Declarative phase table for the headless work-on pipeline. The ENGINE (not an
|
|
3
|
+
* LLM) chooses the next phase via pickPhase. Each phase's outcome is read from
|
|
4
|
+
* GitHub/git AFTER the run (detectOutcome); the runner's return is advisory.
|
|
5
|
+
* @typedef ... (see plan "Shared types")
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const TERMINAL_REASONS = ["merged", "invalid", "needs-human", "decomposed"];
|
|
9
|
+
|
|
10
|
+
/** Fetch the issue's comment bodies as one blob for marker checks. */
|
|
11
|
+
async function issueMarkers(issue, io) {
|
|
12
|
+
const out = await io.gh(["api", `repos/{owner}/{repo}/issues/${issue}/comments`, "--jq", ".[].body"]);
|
|
13
|
+
return out || "";
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Count commits on branch ahead of the lane base. On the first build the
|
|
17
|
+
* branch does not exist yet, so real git rejects the ref range — swallow
|
|
18
|
+
* that (and any other git failure) as "0 ahead" rather than letting it
|
|
19
|
+
* propagate and crash runIssue (C1).
|
|
20
|
+
*/
|
|
21
|
+
async function commitsAhead(state, io) {
|
|
22
|
+
try {
|
|
23
|
+
const n = await io.git(["rev-list", "--count", `origin/${state.lane}..${state.branch}`]);
|
|
24
|
+
return parseInt(String(n).trim(), 10) || 0;
|
|
25
|
+
} catch {
|
|
26
|
+
return 0;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function has(blob, marker) { return blob.includes(marker); }
|
|
30
|
+
|
|
31
|
+
/** @type {Phase[]} */
|
|
32
|
+
export const PHASES = [
|
|
33
|
+
{
|
|
34
|
+
id: "investigate",
|
|
35
|
+
command: "work-on/investigate",
|
|
36
|
+
entryCondition: () => true,
|
|
37
|
+
async detectOutcome(state, io) {
|
|
38
|
+
const m = await issueMarkers(state.issue, io);
|
|
39
|
+
if (has(m, "INVESTIGATION:INVALID"))
|
|
40
|
+
return { status: "committed", terminalReason: "invalid", outputs: { verdict: "INVALID" } };
|
|
41
|
+
if (has(m, "DECOMPOSE:YES"))
|
|
42
|
+
return { status: "committed", terminalReason: "decomposed", outputs: { decompose: true } };
|
|
43
|
+
if (has(m, "INVESTIGATION:COMPLETE"))
|
|
44
|
+
return { status: "committed", outputs: { verdict: "CONFIRMED" } };
|
|
45
|
+
return { status: "failed", detail: "no INVESTIGATION:COMPLETE marker" };
|
|
46
|
+
},
|
|
47
|
+
isTerminalAfter: (s) => s.terminalReason === "invalid" || s.terminalReason === "decomposed",
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
id: "context",
|
|
51
|
+
command: "work-on/build/context",
|
|
52
|
+
entryCondition: (s) => s.committed.includes("investigate"),
|
|
53
|
+
async detectOutcome(state, io) {
|
|
54
|
+
const m = await issueMarkers(state.issue, io);
|
|
55
|
+
// Context is non-critical: a missing marker is a VISIBLE skip, not a hard fail (spec §7).
|
|
56
|
+
if (has(m, "FORGE:CONTEXT")) return { status: "committed", outputs: {} };
|
|
57
|
+
return { status: "committed", outputs: { skipped: true, which: "context" } };
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
id: "architect",
|
|
62
|
+
command: "work-on/build/architect",
|
|
63
|
+
entryCondition: (s) => s.committed.includes("context"),
|
|
64
|
+
async detectOutcome(state, io) {
|
|
65
|
+
const m = await issueMarkers(state.issue, io);
|
|
66
|
+
return has(m, "FORGE:ARCHITECT")
|
|
67
|
+
? { status: "committed", outputs: {} }
|
|
68
|
+
: { status: "failed", detail: "no FORGE:ARCHITECT" };
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
id: "build",
|
|
73
|
+
command: "work-on/build",
|
|
74
|
+
entryCondition: (s) => s.committed.includes("architect"),
|
|
75
|
+
async reconcile(state, io) {
|
|
76
|
+
// Idempotent resume: branch already ahead of base → treat as done, skip the LLM.
|
|
77
|
+
if (state.branch && (await commitsAhead(state, io)) > 0) {
|
|
78
|
+
const m = await issueMarkers(state.issue, io);
|
|
79
|
+
if (has(m, "FORGE:BUILDER:COMPLETE")) return { satisfied: true, outputs: { branch: state.branch } };
|
|
80
|
+
}
|
|
81
|
+
return { satisfied: false };
|
|
82
|
+
},
|
|
83
|
+
async detectOutcome(state, io) {
|
|
84
|
+
const m = await issueMarkers(state.issue, io);
|
|
85
|
+
const complete = has(m, "FORGE:BUILDER:COMPLETE"); // #1305: require :COMPLETE …
|
|
86
|
+
const ahead = state.branch ? await commitsAhead(state, io) : 0; // … AND real commits
|
|
87
|
+
if (complete && ahead > 0) return { status: "committed", outputs: { branch: state.branch } };
|
|
88
|
+
return { status: "failed", detail: `builder complete=${complete} commitsAhead=${ahead}` };
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
id: "review",
|
|
93
|
+
command: "work-on/review",
|
|
94
|
+
entryCondition: (s) => s.committed.includes("build"),
|
|
95
|
+
async reconcile(state, io) {
|
|
96
|
+
const pr = await openPrFor(state, io); // adopt an existing PR instead of opening a second
|
|
97
|
+
return pr ? { satisfied: false, outputs: { pr } } : { satisfied: false };
|
|
98
|
+
},
|
|
99
|
+
async detectOutcome(state, io) {
|
|
100
|
+
const pr = await prStatusFor(state, io);
|
|
101
|
+
if (!pr) return { status: "failed", detail: "no PR created" };
|
|
102
|
+
if (pr.merged) return { status: "committed", outputs: { pr: pr.number } };
|
|
103
|
+
if (pr.needsHuman) return { status: "blocked", detail: "review escalated", outputs: { pr: pr.number } };
|
|
104
|
+
return { status: "failed", detail: "PR open, not merged" };
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
id: "close",
|
|
109
|
+
command: "work-on/close",
|
|
110
|
+
entryCondition: (s) => s.committed.includes("review"),
|
|
111
|
+
async detectOutcome(state, io) {
|
|
112
|
+
const out = await io.gh(["issue", "view", String(state.issue), "--json", "state,labels"]);
|
|
113
|
+
let j;
|
|
114
|
+
try { j = JSON.parse(out || "{}"); } catch { return { status: "failed", detail: "malformed gh response" }; }
|
|
115
|
+
const labels = (j.labels || []).map((l) => l.name || l);
|
|
116
|
+
if (j.state === "CLOSED" || labels.includes("workflow:merged"))
|
|
117
|
+
return { status: "committed", terminalReason: "merged", outputs: {} };
|
|
118
|
+
return { status: "failed", detail: "issue not closed" };
|
|
119
|
+
},
|
|
120
|
+
isTerminalAfter: () => true,
|
|
121
|
+
},
|
|
122
|
+
];
|
|
123
|
+
|
|
124
|
+
async function openPrFor(state, io) {
|
|
125
|
+
if (!state.branch) return null;
|
|
126
|
+
const out = await io.gh(["pr", "list", "--head", state.branch, "--json", "number", "--state", "all"]);
|
|
127
|
+
try { const a = JSON.parse(out || "[]"); return a[0]?.number ?? null; } catch { return null; }
|
|
128
|
+
}
|
|
129
|
+
async function prStatusFor(state, io) {
|
|
130
|
+
const n = await openPrFor(state, io);
|
|
131
|
+
if (!n) return null;
|
|
132
|
+
const out = await io.gh(["pr", "view", String(n), "--json", "number,state,labels,mergedAt"]);
|
|
133
|
+
let j;
|
|
134
|
+
try { j = JSON.parse(out || "{}"); } catch { return null; }
|
|
135
|
+
const labels = (j.labels || []).map((l) => l.name || l);
|
|
136
|
+
return { number: j.number, merged: !!j.mergedAt || j.state === "MERGED",
|
|
137
|
+
needsHuman: labels.includes("needs-human") };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The engine's transition function: first uncommitted phase whose gate holds. */
|
|
141
|
+
export function pickPhase(state) {
|
|
142
|
+
if (state.terminal) return null;
|
|
143
|
+
for (const p of PHASES) {
|
|
144
|
+
if (state.committed.includes(p.id)) continue;
|
|
145
|
+
if (p.entryCondition(state)) return p;
|
|
146
|
+
}
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub adapter: reads/writes the FORGE:STATE block and workflow labels.
|
|
3
|
+
* `io.gh(args)` is injected (async, returns stdout) so this is testable offline.
|
|
4
|
+
*/
|
|
5
|
+
import { parseState, upsertStateBlock } from "./state.mjs";
|
|
6
|
+
|
|
7
|
+
export function makeProjector(io) {
|
|
8
|
+
const gh = io.gh;
|
|
9
|
+
|
|
10
|
+
async function getBody(issue) {
|
|
11
|
+
const out = await gh(["issue", "view", String(issue), "--json", "body"]);
|
|
12
|
+
try { return JSON.parse(out).body ?? ""; } catch { return ""; }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
/** @returns {Promise<import("./phases.mjs").RunState|null>} */
|
|
17
|
+
async readState(issue) { return parseState(await getBody(issue)); },
|
|
18
|
+
|
|
19
|
+
async writeState(issue, index) {
|
|
20
|
+
const body = upsertStateBlock(await getBody(issue), index);
|
|
21
|
+
await gh(["issue", "edit", String(issue), "--body", body]);
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
async setLabel(issue, label) {
|
|
25
|
+
await gh(["issue", "edit", String(issue), "--add-label", label]);
|
|
26
|
+
},
|
|
27
|
+
|
|
28
|
+
async removeLabel(issue, label) {
|
|
29
|
+
await gh(["issue", "edit", String(issue), "--remove-label", label]);
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Merge the local run-log state and the remote FORGE:STATE index.
|
|
3
|
+
* Rule: GitHub wins on divergence (spec §5.3).
|
|
4
|
+
*/
|
|
5
|
+
export function reconcileState(local, remote) {
|
|
6
|
+
if (!remote) return { state: local ?? null, action: local ? "local" : "fresh" };
|
|
7
|
+
if (!local) return { state: remote, action: "hydrate" };
|
|
8
|
+
if (remote.v > local.v) return { state: remote, action: "hydrate" }; // advanced elsewhere
|
|
9
|
+
if (remote.v < local.v) return { state: local, action: "remirror" }; // crashed pre-mirror
|
|
10
|
+
return { state: local, action: "local" };
|
|
11
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Append-only per-issue run-log (the crash-safe local hot path).
|
|
3
|
+
* One JSON event per line at {dir}/{issue}.jsonl. seq is assigned monotonically.
|
|
4
|
+
*/
|
|
5
|
+
import { readFileSync, appendFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
|
|
8
|
+
function logPath(dir, issue) { return join(dir, `${issue}.jsonl`); }
|
|
9
|
+
|
|
10
|
+
/** Append an event; assigns the next seq based on the current line count. */
|
|
11
|
+
export function appendEvent(dir, issue, event) {
|
|
12
|
+
mkdirSync(dir, { recursive: true });
|
|
13
|
+
const path = logPath(dir, issue);
|
|
14
|
+
const seq = readLog(dir, issue).length + 1;
|
|
15
|
+
appendFileSync(path, JSON.stringify({ seq, ...event }) + "\n");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Overwrite the local run-log with a fresh set of events (fresh seq each).
|
|
20
|
+
* Used to reconstruct the local log from a remote FORGE:STATE index on
|
|
21
|
+
* hydrate (C2), so the local log and the GitHub-wins state stay consistent.
|
|
22
|
+
*/
|
|
23
|
+
export function rewriteLog(dir, issue, events) {
|
|
24
|
+
mkdirSync(dir, { recursive: true });
|
|
25
|
+
const path = logPath(dir, issue);
|
|
26
|
+
const lines = events.map((e, i) => JSON.stringify({ seq: i + 1, ...e }));
|
|
27
|
+
writeFileSync(path, lines.length ? lines.join("\n") + "\n" : "");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Read all complete events; a truncated final line (crash mid-write) is ignored. */
|
|
31
|
+
export function readLog(dir, issue) {
|
|
32
|
+
const path = logPath(dir, issue);
|
|
33
|
+
if (!existsSync(path)) return [];
|
|
34
|
+
const lines = readFileSync(path, "utf-8").split("\n");
|
|
35
|
+
// Drop trailing empty lines to find the last non-empty line.
|
|
36
|
+
while (lines.length > 0 && !lines[lines.length - 1].trim()) lines.pop();
|
|
37
|
+
|
|
38
|
+
const out = [];
|
|
39
|
+
for (let i = 0; i < lines.length; i++) {
|
|
40
|
+
const line = lines[i];
|
|
41
|
+
if (!line.trim()) continue;
|
|
42
|
+
try {
|
|
43
|
+
out.push(JSON.parse(line));
|
|
44
|
+
} catch (err) {
|
|
45
|
+
// Only tolerate parse failure on the final non-empty line (crash mid-write).
|
|
46
|
+
if (i === lines.length - 1) {
|
|
47
|
+
break; // Ignore truncated final line.
|
|
48
|
+
}
|
|
49
|
+
throw new Error(`corrupt run-log line ${i + 1} in ${path}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Fold events into a RunState. The commit rule lives here. */
|
|
56
|
+
export function deriveState(events) {
|
|
57
|
+
/** @type {import("./phases.mjs").RunState} */
|
|
58
|
+
const s = { v: 0, run: null, issue: null, lane: "staging", committed: [],
|
|
59
|
+
phase: null, branch: null, pr: null, terminal: false,
|
|
60
|
+
terminalReason: null, lease: null };
|
|
61
|
+
for (const e of events) {
|
|
62
|
+
switch (e.event) {
|
|
63
|
+
case "RUN_START":
|
|
64
|
+
s.run = e.run ?? s.run; s.issue = e.issue ?? s.issue; s.lane = e.lane ?? s.lane;
|
|
65
|
+
break;
|
|
66
|
+
case "PHASE_COMMIT":
|
|
67
|
+
if (!s.committed.includes(e.phase)) s.committed.push(e.phase);
|
|
68
|
+
s.v = e.seq;
|
|
69
|
+
if (e.outputs?.branch) s.branch = e.outputs.branch;
|
|
70
|
+
if (e.outputs?.pr != null) s.pr = e.outputs.pr;
|
|
71
|
+
break;
|
|
72
|
+
case "RUN_TERMINAL":
|
|
73
|
+
s.terminal = true; s.terminalReason = e.reason ?? "done"; s.v = e.seq;
|
|
74
|
+
break;
|
|
75
|
+
// PHASE_START / PHASE_FAILED do not change committed state
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return s;
|
|
79
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codec for the single machine-readable FORGE:STATE HTML-comment block on an
|
|
3
|
+
* issue body. Carries the COMPACT INDEX only (no rich per-phase outputs).
|
|
4
|
+
*/
|
|
5
|
+
const OPEN = "<!-- FORGE:STATE";
|
|
6
|
+
const CLOSE = "-->";
|
|
7
|
+
// NOTE: a literal "-->" inside a serialized value would close the block early; terminalReason etc. should not contain it. Tracked as a follow-up.
|
|
8
|
+
const BLOCK_RE = /<!-- FORGE:STATE\s*([\s\S]*?)-->/;
|
|
9
|
+
|
|
10
|
+
/** @param {import("./phases.mjs").RunState} index */
|
|
11
|
+
export function serializeState(index) {
|
|
12
|
+
return `${OPEN}\n${JSON.stringify(index)}\n${CLOSE}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** @returns {import("./phases.mjs").RunState|null} */
|
|
16
|
+
export function parseState(issueBody) {
|
|
17
|
+
const m = BLOCK_RE.exec(issueBody || "");
|
|
18
|
+
if (!m) return null;
|
|
19
|
+
try { return JSON.parse(m[1].trim()); } catch { return null; }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Replace the FORGE:STATE block in place, or append one if absent. */
|
|
23
|
+
export function upsertStateBlock(body, index) {
|
|
24
|
+
const block = serializeState(index);
|
|
25
|
+
if (BLOCK_RE.test(body || "")) return body.replace(BLOCK_RE, () => block);
|
|
26
|
+
return `${body || ""}\n\n${block}`.trimStart();
|
|
27
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headless entry point: `forgedock run-issue <issue>` drives one issue through the
|
|
3
|
+
* durable engine; scanStalls finds dead-lease issues for the orchestrator to resume.
|
|
4
|
+
*/
|
|
5
|
+
import { execFile } from "node:child_process";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { runIssue } from "./engine.mjs";
|
|
10
|
+
|
|
11
|
+
const pexec = promisify(execFile);
|
|
12
|
+
|
|
13
|
+
/** Real gh/git accessors. */
|
|
14
|
+
export function makeIo() {
|
|
15
|
+
const run = (bin) => async (args) => {
|
|
16
|
+
const { stdout } = await pexec(bin, args, { maxBuffer: 100 * 1024 * 1024 });
|
|
17
|
+
return stdout;
|
|
18
|
+
};
|
|
19
|
+
return { gh: run("gh"), git: run("git") };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function runDir() { return join(homedir(), ".forge", "runs"); }
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {number[]} issues
|
|
26
|
+
* @param {{readState:(i:number)=>Promise<{terminal:boolean,lease:?{until:number}}|null>}} io
|
|
27
|
+
* @param {number} now
|
|
28
|
+
* @returns {Promise<number[]>} issues that appear stalled (expired lease, non-terminal)
|
|
29
|
+
*/
|
|
30
|
+
export async function scanStalls(issues, io, now) {
|
|
31
|
+
const stalled = [];
|
|
32
|
+
for (const i of issues) {
|
|
33
|
+
const s = await io.readState(i);
|
|
34
|
+
if (s && !s.terminal && s.lease && s.lease.until < now) stalled.push(i);
|
|
35
|
+
}
|
|
36
|
+
return stalled;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function runFromCli(argv) {
|
|
40
|
+
const issue = parseInt(argv[0], 10);
|
|
41
|
+
if (!Number.isInteger(issue)) throw new Error("usage: forgedock run-issue <issue-number>");
|
|
42
|
+
const lane = flag(argv, "--lane") || "staging";
|
|
43
|
+
const io = makeIo();
|
|
44
|
+
const agentId = `cli_${process.pid}`;
|
|
45
|
+
const res = await runIssue({ issue, dir: runDir(), agentId, lane, io,
|
|
46
|
+
runner: (await import("./runner.mjs")).runCommand, now: () => Date.now() });
|
|
47
|
+
console.log(`issue #${issue} → ${res.terminalReason}`);
|
|
48
|
+
return res;
|
|
49
|
+
}
|
|
50
|
+
function flag(argv, name) { const i = argv.indexOf(name); return i >= 0 ? argv[i + 1] : null; }
|
package/bin/engine.mjs
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable per-phase engine loop. Drives one pipeline phase at a time via an
|
|
3
|
+
* injected runner (runCommand-shaped), determining each phase's outcome from
|
|
4
|
+
* GitHub state (phase.detectOutcome). All effects are injected → fully testable.
|
|
5
|
+
*/
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { appendEvent, readLog, deriveState, rewriteLog } from "./engine/runlog.mjs";
|
|
8
|
+
import { pickPhase, TERMINAL_REASONS } from "./engine/phases.mjs";
|
|
9
|
+
import { reconcileState } from "./engine/reconcile.mjs";
|
|
10
|
+
import { makeProjector } from "./engine/projector.mjs";
|
|
11
|
+
|
|
12
|
+
const DEFAULT_MAX_ATTEMPTS = 3;
|
|
13
|
+
|
|
14
|
+
export async function runIssue(opts) {
|
|
15
|
+
const { issue, dir, agentId, lane = "staging", io, runner,
|
|
16
|
+
now = () => Date.now(), maxAttempts = DEFAULT_MAX_ATTEMPTS,
|
|
17
|
+
commandsDir = fileURLToPath(new URL("../commands", import.meta.url)) } = opts;
|
|
18
|
+
const projector = makeProjector(io);
|
|
19
|
+
|
|
20
|
+
// 1. Load + reconcile (GitHub wins).
|
|
21
|
+
const local = readLog(dir, issue).length ? deriveState(readLog(dir, issue)) : null;
|
|
22
|
+
const remote = await projector.readState(issue);
|
|
23
|
+
let { state, action } = reconcileState(local, remote);
|
|
24
|
+
|
|
25
|
+
// I3 (best-effort): GitHub issue-edit is not an atomic CAS, so this is a
|
|
26
|
+
// courtesy check, not a hard mutual-exclusion guarantee — a concurrent
|
|
27
|
+
// start can still race between this read and the next lease write.
|
|
28
|
+
// MUST come before any projector.writeState() — remirror/hydrate branches
|
|
29
|
+
// must not overwrite GitHub state when another agent holds a valid lease.
|
|
30
|
+
if (remote?.lease && remote.lease.until > now() && remote.lease.by !== agentId) {
|
|
31
|
+
return { terminalReason: "deferred", detail: `issue ${issue} leased by ${remote.lease.by}` };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (!state) {
|
|
35
|
+
state = freshState(issue, lane);
|
|
36
|
+
appendEvent(dir, issue, { event: "RUN_START", issue, run: state.run, lane });
|
|
37
|
+
await projector.writeState(issue, state);
|
|
38
|
+
} else if (action === "remirror") {
|
|
39
|
+
await projector.writeState(issue, state);
|
|
40
|
+
} else if (action === "hydrate") {
|
|
41
|
+
// GitHub is ahead of (or the only source of) local state. Rebuild the
|
|
42
|
+
// local run-log to match the remote compact index so downstream
|
|
43
|
+
// `deriveState(readLog(...))` calls stay consistent — otherwise the
|
|
44
|
+
// post-commit re-derive below would fold over an empty/stale local log
|
|
45
|
+
// and regress committed/issue/v (C2).
|
|
46
|
+
rewriteLog(dir, issue, eventsFromIndex(state));
|
|
47
|
+
state = deriveState(readLog(dir, issue));
|
|
48
|
+
await projector.writeState(issue, state);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 2. Drive phases until terminal.
|
|
52
|
+
let phase;
|
|
53
|
+
while ((phase = pickPhase(state))) {
|
|
54
|
+
// Every issue's build works on a deterministic branch; set it before build runs.
|
|
55
|
+
if (phase.id === "build" && !state.branch) state.branch = `fix/pipeline-${issue}`;
|
|
56
|
+
|
|
57
|
+
let reconciled;
|
|
58
|
+
try {
|
|
59
|
+
reconciled = phase.reconcile ? await phase.reconcile(state, io) : { satisfied: false };
|
|
60
|
+
} catch {
|
|
61
|
+
// A reconcile probe (gh/git) failing must not crash the run — degrade
|
|
62
|
+
// to "not satisfied" so the phase runs normally instead (C1).
|
|
63
|
+
reconciled = { satisfied: false };
|
|
64
|
+
}
|
|
65
|
+
let outcome;
|
|
66
|
+
if (reconciled.satisfied) {
|
|
67
|
+
outcome = { status: "committed", outputs: reconciled.outputs || {} };
|
|
68
|
+
} else {
|
|
69
|
+
if (reconciled.outputs?.pr) state.pr = reconciled.outputs.pr;
|
|
70
|
+
outcome = await runPhaseWithRetry(phase, state, { io, runner, dir, issue, commandsDir, maxAttempts });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (outcome.status === "blocked") return await terminate(state, "needs-human", outcome.detail);
|
|
74
|
+
|
|
75
|
+
// committed
|
|
76
|
+
appendEvent(dir, issue, { event: "PHASE_COMMIT", phase: phase.id, outputs: outcome.outputs || {} });
|
|
77
|
+
state = deriveState(readLog(dir, issue));
|
|
78
|
+
if (outcome.terminalReason) state.terminalReason = outcome.terminalReason;
|
|
79
|
+
await projector.writeState(issue, { ...state, lease: { by: agentId, until: now() + 600000 } });
|
|
80
|
+
|
|
81
|
+
if (outcome.terminalReason && TERMINAL_REASONS.includes(outcome.terminalReason))
|
|
82
|
+
return await terminate(state, outcome.terminalReason);
|
|
83
|
+
if (phase.isTerminalAfter && phase.isTerminalAfter(state))
|
|
84
|
+
return await terminate(state, state.terminalReason || "merged");
|
|
85
|
+
}
|
|
86
|
+
return await terminate(state, state.terminalReason || "merged");
|
|
87
|
+
|
|
88
|
+
async function terminate(s, reason, detail) {
|
|
89
|
+
appendEvent(dir, issue, { event: "RUN_TERMINAL", reason });
|
|
90
|
+
const final = { ...deriveState(readLog(dir, issue)), terminal: true, terminalReason: reason, lease: null };
|
|
91
|
+
await projector.writeState(issue, final);
|
|
92
|
+
if (reason === "needs-human") await projector.setLabel(issue, "needs-human");
|
|
93
|
+
return { terminalReason: reason, detail };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function runPhaseWithRetry(phase, state, ctx) {
|
|
98
|
+
const { io, runner, dir, issue, commandsDir, maxAttempts } = ctx;
|
|
99
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
100
|
+
appendEvent(dir, issue, { event: "PHASE_START", phase: phase.id, attempt });
|
|
101
|
+
try {
|
|
102
|
+
await runner({ commandsDir, commandName: phase.command, args: [String(issue)] });
|
|
103
|
+
} catch (e) {
|
|
104
|
+
// A missing API key / SDK is a config error, not a transient phase
|
|
105
|
+
// failure — surface it distinctly instead of burning retries and
|
|
106
|
+
// escalating to needs-human as if the LLM run itself misbehaved.
|
|
107
|
+
if (e.code === "NO_API_KEY" || e.code === "NO_SDK") throw e;
|
|
108
|
+
appendEvent(dir, issue, { event: "PHASE_FAILED", phase: phase.id, attempt, reason: e.message });
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const outcome = await phase.detectOutcome(state, io);
|
|
112
|
+
if (outcome.status === "committed" || outcome.status === "blocked") return outcome;
|
|
113
|
+
appendEvent(dir, issue, { event: "PHASE_FAILED", phase: phase.id, attempt, reason: outcome.detail });
|
|
114
|
+
}
|
|
115
|
+
// Exhausted transient retries → escalate (spec §7).
|
|
116
|
+
return { status: "blocked", detail: `phase ${phase.id} failed after ${maxAttempts} attempts` };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Build a run-log event sequence that reproduces a compact FORGE:STATE index,
|
|
121
|
+
* used to reconstruct the local run-log on hydrate (C2).
|
|
122
|
+
*/
|
|
123
|
+
function eventsFromIndex(idx) {
|
|
124
|
+
const events = [{ event: "RUN_START", issue: idx.issue, run: idx.run, lane: idx.lane }];
|
|
125
|
+
for (const phase of idx.committed) {
|
|
126
|
+
const outputs = {};
|
|
127
|
+
if (phase === "build" && idx.branch) outputs.branch = idx.branch;
|
|
128
|
+
if (phase === "review" && idx.pr != null) outputs.pr = idx.pr;
|
|
129
|
+
events.push({ event: "PHASE_COMMIT", phase, outputs });
|
|
130
|
+
}
|
|
131
|
+
if (idx.terminal) events.push({ event: "RUN_TERMINAL", reason: idx.terminalReason });
|
|
132
|
+
return events;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function freshState(issue, lane) {
|
|
136
|
+
return { v: 0, run: `r_${issue}_${lane}`, issue, lane, committed: [], phase: null,
|
|
137
|
+
branch: null, pr: null, terminal: false, terminalReason: null, lease: null };
|
|
138
|
+
}
|