@bridge_gpt/mcp-server 0.2.38 → 0.2.39
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 +189 -14
- package/build/agent-capabilities/probe-context.js +2 -1
- package/build/agent-launchers/claude-executor-adapter.js +392 -0
- package/build/agent-launchers/executor-adapter-inspection.js +163 -0
- package/build/agent-launchers/executor-adapter-registry.js +90 -0
- package/build/agent-launchers/executor-adapter.js +136 -0
- package/build/agent-registry.js +28 -0
- package/build/agents.generated.js +1 -1
- package/build/claude-login.js +85 -0
- package/build/claude-user-config-doctor.js +59 -33
- package/build/commands.generated.js +12 -11
- package/build/conduct-epic/bridge-client.js +345 -0
- package/build/conduct-epic/checkpoint-store.js +423 -0
- package/build/conduct-epic/cli.js +1732 -0
- package/build/conduct-epic/lock.js +302 -0
- package/build/conduct-epic/pr-state.js +197 -0
- package/build/conduct-epic/spawn.js +101 -0
- package/build/conductor/bridge-api-client.js +37 -2
- package/build/conductor/doctor.js +11 -1
- package/build/conductor/install-doctor.js +184 -10
- package/build/conductor-bin.js +7 -7
- package/build/credential-store.js +10 -4
- package/build/credentials-cli.js +34 -19
- package/build/docs.generated.js +1 -1
- package/build/doctor.js +579 -88
- package/build/executor/agent-identity.js +32 -0
- package/build/executor/cli.js +50 -39
- package/build/executor/deps.js +15 -1
- package/build/executor/env.js +56 -45
- package/build/executor/index.js +9 -1
- package/build/executor/install-preflight.js +138 -0
- package/build/executor/job-errors.js +200 -0
- package/build/executor/job-runner.js +619 -268
- package/build/executor/observation.js +165 -0
- package/build/executor/permissions.js +163 -36
- package/build/executor/platform.js +54 -0
- package/build/executor/preflight.js +175 -67
- package/build/executor/process.js +39 -7
- package/build/executor/runner.js +19 -0
- package/build/executor/service-lifecycle.js +269 -0
- package/build/executor/service-unit.js +121 -12
- package/build/executor/stale-artifacts.js +70 -0
- package/build/executor/test-clock.js +188 -24
- package/build/executor/worker-command.js +22 -58
- package/build/executor/worker-log.js +82 -0
- package/build/executor/worktree-lock.js +264 -0
- package/build/index.js +527 -357
- package/build/install-bridge-conductor.js +376 -38
- package/build/install-bridge.js +414 -114
- package/build/install-doctor.js +13 -0
- package/build/install-reexec.js +5 -3
- package/build/mcp-install-state.js +130 -0
- package/build/mcp-profile.js +11 -2
- package/build/mcp-provisioning.js +15 -0
- package/build/merge-pull-request.js +562 -0
- package/build/phase-result-artifacts.js +450 -0
- package/build/pipeline-orchestrator.js +4 -0
- package/build/pipeline-utils.js +16 -0
- package/build/pipelines.generated.js +7 -7
- package/build/plane/preflight.js +18 -14
- package/build/plane/supervisor.js +8 -1
- package/build/project-root.js +34 -0
- package/build/readme.generated.js +1 -1
- package/build/run-unit-tests-launcher.js +36 -9
- package/build/setup-epic.js +57 -4
- package/build/sfcc/permissions.js +25 -6
- package/build/sfcc/reads-site-preference.js +6 -0
- package/build/sfcc/register.js +61 -23
- package/build/sfcc/registration-inventory.js +89 -0
- package/build/sfcc/setup-status.js +18 -34
- package/build/sfcc/tool-wrapper.js +294 -17
- package/build/sfcc/write-grants.js +33 -1
- package/build/sfcc/write-guard.js +41 -12
- package/build/sfcc/writes-custom-object-def.js +6 -2
- package/build/sfcc/writes-site-preference.js +6 -1
- package/build/sfcc/writes-system-object.js +11 -2
- package/build/sfcc/writes.js +13 -8
- package/build/start-tickets-prereqs.js +25 -15
- package/build/start-tickets.js +123 -21
- package/build/version.generated.js +1 -1
- package/build/worktree-core.js +9 -3
- package/docs/install/mcp-tool-integrations.md +54 -9
- package/docs/install/sfcc-integration.md +71 -24
- package/package.json +3 -3
- package/build/executor/worker-config-isolation.js +0 -287
|
@@ -0,0 +1,1732 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The packaged `conduct-epic` CLI (BAPI-803).
|
|
3
|
+
*
|
|
4
|
+
* `/conduct-epic` (BAPI-804) is prose. Prose cannot be unit-tested, and the
|
|
5
|
+
* guarantees the loop depends on — that the epic branch was created from the
|
|
6
|
+
* right base, that a checkpoint write is atomic, that two sessions cannot edit
|
|
7
|
+
* one checkpoint, that a `MERGED` PR is visible, that `auto_merge_enabled` is on
|
|
8
|
+
* before the first ticket is dispatched — are exactly the kind that fail
|
|
9
|
+
* silently when they live in an instruction file. So they live here, behind five
|
|
10
|
+
* verbs the loop calls and this file's tests pin:
|
|
11
|
+
*
|
|
12
|
+
* init | status | checkpoint set | finish | spawn
|
|
13
|
+
*
|
|
14
|
+
* Design rules that hold across all five:
|
|
15
|
+
*
|
|
16
|
+
* - **Nothing creates or mutates an `epic_run`.** This CLI drives a LOCAL loop.
|
|
17
|
+
* The one epic-run call it makes (`getEpicRunState`) is a read whose only
|
|
18
|
+
* purpose is to REFUSE when the server-side v2 reconciler is already
|
|
19
|
+
* conducting the same epic — two conductors on one epic is the failure mode.
|
|
20
|
+
* - **Every process/network/filesystem boundary is injected** through
|
|
21
|
+
* {@link ConductEpicDeps}, so the whole surface is testable without a real
|
|
22
|
+
* `gh`, terminal, home directory, or Bridge server.
|
|
23
|
+
* - **`init` accumulates.** Its preflight runs every independent check and
|
|
24
|
+
* prints every failure in one pass. Failing on the first one makes an operator
|
|
25
|
+
* discover a five-problem environment five invocations at a time.
|
|
26
|
+
* - **`status` fails open per probe, `status` never takes the lock.** A `gh`
|
|
27
|
+
* outage must not stop the loop from seeing its checkpoint; a read-only
|
|
28
|
+
* surface that took a lock would deadlock against the verb that needs it.
|
|
29
|
+
* - **Credentials resolve only through `resolveConductorBridgeApiAccess`** and
|
|
30
|
+
* never enter argv, stdout, stderr, a journal line, or an error string.
|
|
31
|
+
*/
|
|
32
|
+
import { execFile } from "node:child_process";
|
|
33
|
+
import { promises as nodeFs } from "node:fs";
|
|
34
|
+
import os from "node:os";
|
|
35
|
+
import path from "node:path";
|
|
36
|
+
import { validateBranchName } from "../base-ref.js";
|
|
37
|
+
import { parseDoneGateConfig } from "../conductor/done-gate.js";
|
|
38
|
+
import { resolveConductorBridgeApiAccess, } from "../conductor/bridge-api-client.js";
|
|
39
|
+
import { runGhCommand } from "../conductor/pr-discovery.js";
|
|
40
|
+
import { getDefaultSpawnTerminalTabForPlatform, detectTerminal, createDefaultStartTicketsDeps, } from "../start-tickets.js";
|
|
41
|
+
import { resolveWorktrunkBinary } from "../start-tickets-prereqs.js";
|
|
42
|
+
import { resolveRequiredStartTicketsRepoName } from "../start-tickets-repo.js";
|
|
43
|
+
import { getConfigFieldBaseBranch, getConductorReadiness, getEffectiveSupervisorConfig, getEffectiveSupervisorSetup, getEpicRunState, getIndexBranch, getParseStatus, getPrReviewStatus, pollCiChecks, putSupervisorConfigDefaults, repointIndexBranch, resolveCiChecks, restoreIndexBranch, } from "./bridge-client.js";
|
|
44
|
+
import { appendTicketJournal, createInitialConductEpicCheckpoint, readConductEpicCheckpoint, resolveConductEpicCheckpointPath, resolveConductEpicLockPath, writeConductEpicCheckpointAtomic, CONDUCT_EPIC_TICKET_STATUSES, } from "./checkpoint-store.js";
|
|
45
|
+
import { acquireConductEpicLock, inspectConductEpicLock, isConductEpicLockOwnerAlive, } from "./lock.js";
|
|
46
|
+
import { discoverConductEpicPrState, discoverTicketWorktree, parseGitWorktreePorcelain, } from "./pr-state.js";
|
|
47
|
+
import { spawnConductEpicAgentTab, CONDUCT_EPIC_AGENTS, } from "./spawn.js";
|
|
48
|
+
/** Epic and ticket keys accepted by every verb. */
|
|
49
|
+
export const CONDUCT_EPIC_KEY_PATTERN = /^[A-Z]+-[0-9]+$/;
|
|
50
|
+
/** The five verb families. `checkpoint set` is two tokens, one verb. */
|
|
51
|
+
export const CONDUCT_EPIC_VERBS = ["init", "status", "checkpoint set", "finish", "spawn"];
|
|
52
|
+
/** Per-ticket fields `checkpoint set` may assign. */
|
|
53
|
+
const TICKET_FIELDS = [
|
|
54
|
+
"status",
|
|
55
|
+
"branch",
|
|
56
|
+
"pr_number",
|
|
57
|
+
"spawned_at",
|
|
58
|
+
"respawns",
|
|
59
|
+
"conflict_attempts",
|
|
60
|
+
"counters.sessions_spawned",
|
|
61
|
+
"counters.plan_generations_observed",
|
|
62
|
+
"counters.merge_attempts",
|
|
63
|
+
];
|
|
64
|
+
/** Top-level fields `checkpoint set` may assign. */
|
|
65
|
+
const TOP_LEVEL_FIELDS = ["needs_human", "counters.iterations", "counters.merges"];
|
|
66
|
+
/** Build the production dependency set. */
|
|
67
|
+
export function createDefaultConductEpicDeps() {
|
|
68
|
+
const runCommand = (file, args, options) => new Promise((resolve) => {
|
|
69
|
+
execFile(file, args, {
|
|
70
|
+
cwd: options?.cwd,
|
|
71
|
+
// Git porcelain output for a many-worktree checkout can be large.
|
|
72
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
73
|
+
encoding: "utf-8",
|
|
74
|
+
timeout: options?.timeoutMs,
|
|
75
|
+
// Explicit: arguments are a list, never a concatenated shell string.
|
|
76
|
+
shell: false,
|
|
77
|
+
}, (error, stdout, stderr) => {
|
|
78
|
+
const code = error?.code;
|
|
79
|
+
resolve({
|
|
80
|
+
stdout: stdout ?? "",
|
|
81
|
+
stderr: stderr ?? "",
|
|
82
|
+
exitCode: typeof code === "number" ? code : error ? 1 : 0,
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
const spawner = getDefaultSpawnTerminalTabForPlatform(process.platform);
|
|
87
|
+
const startTicketsDeps = createDefaultStartTicketsDeps();
|
|
88
|
+
return {
|
|
89
|
+
runCommand,
|
|
90
|
+
runGh: (args, options) => runGhCommand(args, options ?? {}),
|
|
91
|
+
spawnTab: (shellCommand, context) => spawner(startTicketsDeps, detectTerminal(undefined, process.env), shellCommand, context),
|
|
92
|
+
fetchImpl: globalThis.fetch,
|
|
93
|
+
fs: {
|
|
94
|
+
mkdir: (dirPath, options) => nodeFs.mkdir(dirPath, options),
|
|
95
|
+
readFile: (filePath) => nodeFs.readFile(filePath, "utf-8"),
|
|
96
|
+
writeFile: (filePath, data, options) => nodeFs.writeFile(filePath, data, { encoding: "utf-8", mode: options?.mode }),
|
|
97
|
+
// Real exclusive create: `wx` fails EEXIST in the kernel, so two racing
|
|
98
|
+
// callers cannot both believe they created the file. Never reconstruct
|
|
99
|
+
// this from `stat` + `writeFile`.
|
|
100
|
+
writeFileExclusive: async (filePath, data, options) => {
|
|
101
|
+
const handle = await nodeFs.open(filePath, "wx", options?.mode);
|
|
102
|
+
try {
|
|
103
|
+
await handle.writeFile(data, "utf-8");
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
await handle.close();
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
rename: (fromPath, toPath) => nodeFs.rename(fromPath, toPath),
|
|
110
|
+
chmod: (filePath, mode) => nodeFs.chmod(filePath, mode),
|
|
111
|
+
unlink: (filePath) => nodeFs.unlink(filePath),
|
|
112
|
+
stat: (filePath) => nodeFs.stat(filePath),
|
|
113
|
+
},
|
|
114
|
+
now: () => new Date(),
|
|
115
|
+
env: process.env,
|
|
116
|
+
homedir: os.homedir,
|
|
117
|
+
hostname: os.hostname,
|
|
118
|
+
platform: process.platform,
|
|
119
|
+
cwd: process.cwd(),
|
|
120
|
+
pid: process.pid,
|
|
121
|
+
isProcessAlive: isConductEpicLockOwnerAlive,
|
|
122
|
+
log: (m) => console.log(m),
|
|
123
|
+
errorLog: (m) => console.error(m),
|
|
124
|
+
resolveAccess: resolveConductorBridgeApiAccess,
|
|
125
|
+
resolveRepoName: resolveRequiredStartTicketsRepoName,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// Usage + argument parsing
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
/** User-facing usage text for the packaged `conduct-epic` subcommand. */
|
|
132
|
+
export function getConductEpicUsage() {
|
|
133
|
+
return [
|
|
134
|
+
"Usage:",
|
|
135
|
+
" npx -y @bridge_gpt/mcp-server conduct-epic <verb> [flags]",
|
|
136
|
+
"",
|
|
137
|
+
"Verbs:",
|
|
138
|
+
" init <EPIC> --tickets K1,K2,... [--base-branch <b>] [--checkpoint-path <p>] [--dry-run] [--json]",
|
|
139
|
+
" Run the full preflight, create epic/<EPIC> on origin at the fetched base",
|
|
140
|
+
" tip, repoint the indexed branch, write the checkpoint, and take the lock.",
|
|
141
|
+
" Every preflight failure is printed in one pass; nothing is written unless",
|
|
142
|
+
" all of them pass. --dry-run prints the validated plan and writes nothing.",
|
|
143
|
+
"",
|
|
144
|
+
" status <EPIC> [--checkpoint-path <p>] --json",
|
|
145
|
+
" Print one JSON object describing the in-flight ticket, its worktree, PR,",
|
|
146
|
+
" CI, review, parse, deadline, and lock state. --json is required. A missing",
|
|
147
|
+
" checkpoint exits 0 with checkpoint_exists:false. A failed probe leaves its",
|
|
148
|
+
" sub-object null and is listed in probe_errors; it never fails the command.",
|
|
149
|
+
"",
|
|
150
|
+
" checkpoint set <EPIC> --ticket <KEY> [--field <name> <value>]... [--journal <line>]",
|
|
151
|
+
" [--checkpoint-path <p>]",
|
|
152
|
+
" Apply ABSOLUTE field values (the caller computes n+1 from status).",
|
|
153
|
+
` Ticket fields: ${TICKET_FIELDS.join(", ")}.`,
|
|
154
|
+
` Top-level fields: ${TOP_LEVEL_FIELDS.join(", ")}.`,
|
|
155
|
+
"",
|
|
156
|
+
" finish <EPIC> [--checkpoint-path <p>] [--json]",
|
|
157
|
+
" Restore the server's indexed base branch (idempotent), release the owned",
|
|
158
|
+
" lock, and print the final summary. A second finish succeeds.",
|
|
159
|
+
"",
|
|
160
|
+
` spawn <EPIC> --ticket <KEY> --prompt-file <path> [--agent ${CONDUCT_EPIC_AGENTS.join("|")}]`,
|
|
161
|
+
" [--checkpoint-path <p>] [--json]",
|
|
162
|
+
" Open exactly ONE agent tab in the ticket's worktree running the prompt",
|
|
163
|
+
" file's contents, then increment counters.sessions_spawned and append a",
|
|
164
|
+
" journal line. Respawn and conflict budgets are the CALLER's job.",
|
|
165
|
+
"",
|
|
166
|
+
"Common:",
|
|
167
|
+
" -h, --help Show this help",
|
|
168
|
+
"",
|
|
169
|
+
"State lives outside the repository, at ~/.config/bridge/conduct/<repo>/ ",
|
|
170
|
+
"(honoring XDG_CONFIG_HOME): <EPIC>.json, <EPIC>.json.prev, <EPIC>.lock, and",
|
|
171
|
+
"<EPIC>/prompts/. The directory is 0700 and files are 0600.",
|
|
172
|
+
"",
|
|
173
|
+
"Exit codes: 0 on success (including checkpoint_exists:false and an idempotent",
|
|
174
|
+
"finish); non-zero on any failure, with a one-line reason on stderr. With --json,",
|
|
175
|
+
"stdout is exactly one JSON object carrying ok.",
|
|
176
|
+
].join("\n");
|
|
177
|
+
}
|
|
178
|
+
function parseError(message) {
|
|
179
|
+
return { status: "error", message };
|
|
180
|
+
}
|
|
181
|
+
/** Which flags each verb accepts. An unknown flag for the verb is rejected. */
|
|
182
|
+
const VERB_FLAGS = {
|
|
183
|
+
init: ["--tickets", "--base-branch", "--checkpoint-path", "--dry-run", "--json"],
|
|
184
|
+
status: ["--checkpoint-path", "--json"],
|
|
185
|
+
"checkpoint-set": ["--ticket", "--field", "--journal", "--checkpoint-path"],
|
|
186
|
+
finish: ["--checkpoint-path", "--json"],
|
|
187
|
+
spawn: ["--ticket", "--prompt-file", "--agent", "--checkpoint-path", "--json"],
|
|
188
|
+
};
|
|
189
|
+
/**
|
|
190
|
+
* Parse and fully validate argv BEFORE any I/O.
|
|
191
|
+
*
|
|
192
|
+
* Rejects unknown verbs, unknown flags, duplicate singleton options, missing
|
|
193
|
+
* values, malformed keys, invalid branch names, and stray positionals. Every one
|
|
194
|
+
* of those is a mistake a caller can make in a generated shell command, and
|
|
195
|
+
* catching them here means a bad invocation never spawns a process, resolves a
|
|
196
|
+
* credential, or touches the checkpoint.
|
|
197
|
+
*/
|
|
198
|
+
export function parseConductEpicArgs(argv) {
|
|
199
|
+
if (argv.includes("-h") || argv.includes("--help")) {
|
|
200
|
+
return { status: "help", usage: getConductEpicUsage() };
|
|
201
|
+
}
|
|
202
|
+
if (argv.length === 0)
|
|
203
|
+
return parseError("A verb is required.");
|
|
204
|
+
let verb;
|
|
205
|
+
let rest;
|
|
206
|
+
if (argv[0] === "checkpoint") {
|
|
207
|
+
if (argv[1] !== "set") {
|
|
208
|
+
return parseError("Unknown verb 'checkpoint " + (argv[1] ?? "") + "'. Expected 'checkpoint set'.");
|
|
209
|
+
}
|
|
210
|
+
verb = "checkpoint-set";
|
|
211
|
+
rest = argv.slice(2);
|
|
212
|
+
}
|
|
213
|
+
else if (argv[0] === "init" || argv[0] === "status" || argv[0] === "finish" || argv[0] === "spawn") {
|
|
214
|
+
verb = argv[0];
|
|
215
|
+
rest = argv.slice(1);
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
return parseError(`Unknown verb '${argv[0]}'. Expected one of: ${CONDUCT_EPIC_VERBS.join(", ")}.`);
|
|
219
|
+
}
|
|
220
|
+
const allowed = VERB_FLAGS[verb];
|
|
221
|
+
const options = {
|
|
222
|
+
verb,
|
|
223
|
+
epicKey: "",
|
|
224
|
+
tickets: [],
|
|
225
|
+
fields: [],
|
|
226
|
+
dryRun: false,
|
|
227
|
+
json: false,
|
|
228
|
+
};
|
|
229
|
+
const seen = new Set();
|
|
230
|
+
let epicKey;
|
|
231
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
232
|
+
const arg = rest[i];
|
|
233
|
+
if (!arg.startsWith("-")) {
|
|
234
|
+
if (epicKey !== undefined) {
|
|
235
|
+
return parseError(`Unexpected argument '${arg}'.`);
|
|
236
|
+
}
|
|
237
|
+
epicKey = arg;
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
if (!allowed.includes(arg)) {
|
|
241
|
+
return parseError(`Unknown flag '${arg}' for '${verbLabel(verb)}'.`);
|
|
242
|
+
}
|
|
243
|
+
// `--field` is the one repeatable flag; everything else is a singleton.
|
|
244
|
+
if (arg !== "--field") {
|
|
245
|
+
if (seen.has(arg))
|
|
246
|
+
return parseError(`Duplicate flag '${arg}'.`);
|
|
247
|
+
seen.add(arg);
|
|
248
|
+
}
|
|
249
|
+
switch (arg) {
|
|
250
|
+
case "--dry-run":
|
|
251
|
+
options.dryRun = true;
|
|
252
|
+
break;
|
|
253
|
+
case "--json":
|
|
254
|
+
options.json = true;
|
|
255
|
+
break;
|
|
256
|
+
case "--field": {
|
|
257
|
+
const name = rest[i + 1];
|
|
258
|
+
const value = rest[i + 2];
|
|
259
|
+
if (name === undefined || value === undefined) {
|
|
260
|
+
return parseError("--field requires a name and a value.");
|
|
261
|
+
}
|
|
262
|
+
options.fields.push({ name, value });
|
|
263
|
+
i += 2;
|
|
264
|
+
break;
|
|
265
|
+
}
|
|
266
|
+
default: {
|
|
267
|
+
const value = rest[i + 1];
|
|
268
|
+
if (value === undefined)
|
|
269
|
+
return parseError(`${arg} requires a value.`);
|
|
270
|
+
i += 1;
|
|
271
|
+
const assigned = assignFlagValue(options, arg, value);
|
|
272
|
+
if (assigned !== null)
|
|
273
|
+
return parseError(assigned);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (epicKey === undefined)
|
|
278
|
+
return parseError(`'${verbLabel(verb)}' requires an <EPIC> key.`);
|
|
279
|
+
if (!CONDUCT_EPIC_KEY_PATTERN.test(epicKey)) {
|
|
280
|
+
return parseError(`Invalid epic key '${epicKey}'. Expected the form PROJ-123.`);
|
|
281
|
+
}
|
|
282
|
+
options.epicKey = epicKey;
|
|
283
|
+
const missing = requiredFlagError(options);
|
|
284
|
+
if (missing !== null)
|
|
285
|
+
return parseError(missing);
|
|
286
|
+
return { status: "ok", options };
|
|
287
|
+
}
|
|
288
|
+
/** Human label for a verb in an error message. */
|
|
289
|
+
function verbLabel(verb) {
|
|
290
|
+
return verb === "checkpoint-set" ? "checkpoint set" : verb;
|
|
291
|
+
}
|
|
292
|
+
/** Apply one value-taking flag, returning an error message or `null`. */
|
|
293
|
+
function assignFlagValue(options, flag, value) {
|
|
294
|
+
switch (flag) {
|
|
295
|
+
case "--tickets": {
|
|
296
|
+
const keys = value
|
|
297
|
+
.split(",")
|
|
298
|
+
.map((k) => k.trim())
|
|
299
|
+
.filter((k) => k.length > 0);
|
|
300
|
+
if (keys.length === 0)
|
|
301
|
+
return "--tickets requires at least one ticket key.";
|
|
302
|
+
for (const key of keys) {
|
|
303
|
+
if (!CONDUCT_EPIC_KEY_PATTERN.test(key)) {
|
|
304
|
+
return `Invalid ticket key '${key}' in --tickets. Expected the form PROJ-123.`;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (new Set(keys).size !== keys.length)
|
|
308
|
+
return "--tickets must not repeat a ticket key.";
|
|
309
|
+
options.tickets = keys;
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
case "--ticket": {
|
|
313
|
+
if (!CONDUCT_EPIC_KEY_PATTERN.test(value)) {
|
|
314
|
+
return `Invalid ticket key '${value}'. Expected the form PROJ-123.`;
|
|
315
|
+
}
|
|
316
|
+
options.ticket = value;
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
case "--base-branch": {
|
|
320
|
+
const reason = validateBranchName(value);
|
|
321
|
+
if (reason)
|
|
322
|
+
return `Invalid --base-branch value: ${reason}`;
|
|
323
|
+
options.baseBranch = value;
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
case "--checkpoint-path":
|
|
327
|
+
if (value.trim().length === 0)
|
|
328
|
+
return "--checkpoint-path requires a path.";
|
|
329
|
+
options.checkpointPath = value;
|
|
330
|
+
return null;
|
|
331
|
+
case "--prompt-file":
|
|
332
|
+
if (value.trim().length === 0)
|
|
333
|
+
return "--prompt-file requires a path.";
|
|
334
|
+
options.promptFile = value;
|
|
335
|
+
return null;
|
|
336
|
+
case "--agent":
|
|
337
|
+
if (!CONDUCT_EPIC_AGENTS.includes(value)) {
|
|
338
|
+
return `Unsupported agent '${value}'. Expected one of: ${CONDUCT_EPIC_AGENTS.join(", ")}`;
|
|
339
|
+
}
|
|
340
|
+
options.agent = value;
|
|
341
|
+
return null;
|
|
342
|
+
case "--journal":
|
|
343
|
+
options.journal = value;
|
|
344
|
+
return null;
|
|
345
|
+
default:
|
|
346
|
+
return `Unknown flag '${flag}'.`;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
/** Verb-specific required-flag checks. */
|
|
350
|
+
function requiredFlagError(options) {
|
|
351
|
+
if (options.verb === "init" && options.tickets.length === 0) {
|
|
352
|
+
return "init requires --tickets K1,K2,...";
|
|
353
|
+
}
|
|
354
|
+
if (options.verb === "status" && !options.json) {
|
|
355
|
+
return "status requires --json.";
|
|
356
|
+
}
|
|
357
|
+
if (options.verb === "checkpoint-set") {
|
|
358
|
+
if (options.ticket === undefined)
|
|
359
|
+
return "checkpoint set requires --ticket <KEY>.";
|
|
360
|
+
if (options.fields.length === 0 && options.journal === undefined) {
|
|
361
|
+
return "checkpoint set requires at least one --field or --journal.";
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
if (options.verb === "spawn") {
|
|
365
|
+
if (options.ticket === undefined)
|
|
366
|
+
return "spawn requires --ticket <KEY>.";
|
|
367
|
+
if (options.promptFile === undefined)
|
|
368
|
+
return "spawn requires --prompt-file <path>.";
|
|
369
|
+
}
|
|
370
|
+
return null;
|
|
371
|
+
}
|
|
372
|
+
// ---------------------------------------------------------------------------
|
|
373
|
+
// Output discipline
|
|
374
|
+
// ---------------------------------------------------------------------------
|
|
375
|
+
/**
|
|
376
|
+
* Emit one success result.
|
|
377
|
+
*
|
|
378
|
+
* With `--json`, stdout carries EXACTLY one JSON object and every human line is
|
|
379
|
+
* diverted to stderr — including the `announced:` line, so a caller parsing
|
|
380
|
+
* stdout never has to strip narration.
|
|
381
|
+
*/
|
|
382
|
+
function emitSuccess(deps, json, payload, humanLines = []) {
|
|
383
|
+
if (json) {
|
|
384
|
+
for (const line of humanLines)
|
|
385
|
+
deps.errorLog(line);
|
|
386
|
+
deps.log(JSON.stringify(payload, null, 2));
|
|
387
|
+
}
|
|
388
|
+
else {
|
|
389
|
+
for (const line of humanLines)
|
|
390
|
+
deps.log(line);
|
|
391
|
+
}
|
|
392
|
+
return 0;
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Emit one failure. Reasons go to stderr as concise lines; with `--json`, stdout
|
|
396
|
+
* still carries exactly one object, so a caller can parse a failure the same way
|
|
397
|
+
* it parses a success.
|
|
398
|
+
*/
|
|
399
|
+
function emitFailure(deps, json, reasons, payload = {}) {
|
|
400
|
+
for (const reason of reasons)
|
|
401
|
+
deps.errorLog(reason);
|
|
402
|
+
if (json) {
|
|
403
|
+
deps.log(JSON.stringify({ ok: false, ...payload, failures: reasons }, null, 2));
|
|
404
|
+
}
|
|
405
|
+
return 1;
|
|
406
|
+
}
|
|
407
|
+
// ---------------------------------------------------------------------------
|
|
408
|
+
// Shared helpers
|
|
409
|
+
// ---------------------------------------------------------------------------
|
|
410
|
+
/** The epic's own branch name. Fixed by contract; never operator-supplied. */
|
|
411
|
+
function epicBranchFor(epicKey) {
|
|
412
|
+
return `epic/${epicKey}`;
|
|
413
|
+
}
|
|
414
|
+
/** Resolve the checkpoint path from `--checkpoint-path` or the default. */
|
|
415
|
+
function resolveCheckpointPath(deps, repoName, epicKey, override) {
|
|
416
|
+
if (override !== undefined)
|
|
417
|
+
return path.resolve(override);
|
|
418
|
+
return resolveConductEpicCheckpointPath(repoName, epicKey, {
|
|
419
|
+
env: deps.env,
|
|
420
|
+
homedir: deps.homedir,
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* The lock seams built from the injected deps.
|
|
425
|
+
*
|
|
426
|
+
* Exported so a test can drive concurrent acquisitions through the exact bridge
|
|
427
|
+
* every mutating verb uses — the production default in `lock.ts` is never
|
|
428
|
+
* reached from here, so this bridge is what the mutual-exclusion guarantee
|
|
429
|
+
* actually rests on.
|
|
430
|
+
*/
|
|
431
|
+
export function buildConductEpicLockSeams(deps) {
|
|
432
|
+
return {
|
|
433
|
+
readFile: (filePath) => deps.fs.readFile(filePath),
|
|
434
|
+
removeFile: (filePath) => deps.fs.unlink(filePath),
|
|
435
|
+
mkdir: (dirPath, options) => deps.fs.mkdir(dirPath, options),
|
|
436
|
+
isProcessAlive: deps.isProcessAlive,
|
|
437
|
+
// The atomic exclusive-create primitive itself, not an exists-then-write
|
|
438
|
+
// pair: contention must surface as an EEXIST rejection from a single
|
|
439
|
+
// syscall, which is the whole mutual-exclusion signal.
|
|
440
|
+
writeFileExclusive: (filePath, data) => deps.fs.writeFileExclusive(filePath, data, { mode: 0o600 }),
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
/** This invocation's lock identity. */
|
|
444
|
+
function lockRequest(deps) {
|
|
445
|
+
return {
|
|
446
|
+
ownerPid: deps.pid,
|
|
447
|
+
host: deps.hostname(),
|
|
448
|
+
acquiredAt: deps.now().toISOString(),
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* Resolve the repository name for PATH derivation, independent of credentials.
|
|
453
|
+
*
|
|
454
|
+
* Falls back to `"unknown"` only when the identity genuinely cannot be resolved
|
|
455
|
+
* from `BAPI_REPO_NAME` or `.bridge/config` — at which point no checkpoint could
|
|
456
|
+
* have been written under a real name either, so the two agree.
|
|
457
|
+
*/
|
|
458
|
+
async function resolveRepoNameForPath(deps) {
|
|
459
|
+
try {
|
|
460
|
+
const result = await deps.resolveRepoName({
|
|
461
|
+
env: deps.env,
|
|
462
|
+
cwd: deps.cwd,
|
|
463
|
+
readFile: (filePath) => deps.fs.readFile(filePath),
|
|
464
|
+
});
|
|
465
|
+
if (result.ok)
|
|
466
|
+
return result.repoName;
|
|
467
|
+
}
|
|
468
|
+
catch {
|
|
469
|
+
/* sanitized: an identity failure degrades to the placeholder below */
|
|
470
|
+
}
|
|
471
|
+
return "unknown";
|
|
472
|
+
}
|
|
473
|
+
/** Resolve Bridge access, or a sanitized reason. Never returns the key. */
|
|
474
|
+
async function resolveAccess(deps) {
|
|
475
|
+
const result = await deps.resolveAccess({
|
|
476
|
+
env: deps.env,
|
|
477
|
+
cwd: deps.cwd,
|
|
478
|
+
homedir: deps.homedir,
|
|
479
|
+
platform: deps.platform,
|
|
480
|
+
readFile: (filePath) => deps.fs.readFile(filePath),
|
|
481
|
+
stat: (filePath) => deps.fs.stat(filePath),
|
|
482
|
+
});
|
|
483
|
+
if (result.ok)
|
|
484
|
+
return { ok: true, access: result.access };
|
|
485
|
+
return { ok: false, error: `Bridge credentials could not be resolved: ${result.error}` };
|
|
486
|
+
}
|
|
487
|
+
/** Run `git` with list args in the repository working directory. */
|
|
488
|
+
function git(deps, args) {
|
|
489
|
+
return Promise.resolve(deps.runCommand("git", args, { cwd: deps.cwd }));
|
|
490
|
+
}
|
|
491
|
+
/** The single trimmed line a `git rev-parse`-style command produced, or null. */
|
|
492
|
+
function firstLine(result) {
|
|
493
|
+
const value = result.stdout.split("\n")[0]?.trim() ?? "";
|
|
494
|
+
return value.length === 0 ? null : value;
|
|
495
|
+
}
|
|
496
|
+
/** The SHA from `git ls-remote --heads origin <ref>` output, or null. */
|
|
497
|
+
function lsRemoteSha(result) {
|
|
498
|
+
const line = firstLine(result);
|
|
499
|
+
if (line === null)
|
|
500
|
+
return null;
|
|
501
|
+
const sha = line.split(/\s+/)[0]?.trim() ?? "";
|
|
502
|
+
return sha.length === 0 ? null : sha;
|
|
503
|
+
}
|
|
504
|
+
function isRecord(value) {
|
|
505
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
506
|
+
}
|
|
507
|
+
/** Seconds between two ISO timestamps and `now`, clamped at zero. */
|
|
508
|
+
function elapsedSeconds(from, now) {
|
|
509
|
+
if (from === null)
|
|
510
|
+
return null;
|
|
511
|
+
const start = Date.parse(from);
|
|
512
|
+
if (Number.isNaN(start))
|
|
513
|
+
return null;
|
|
514
|
+
return Math.max(0, Math.floor((now.getTime() - start) / 1000));
|
|
515
|
+
}
|
|
516
|
+
/** The in-flight ticket: the FIRST entry that is not `done`. */
|
|
517
|
+
function inFlightTicket(checkpoint) {
|
|
518
|
+
return checkpoint.tickets.find((ticket) => ticket.status !== "done") ?? null;
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* Run every independent `init` check and ACCUMULATE the failures.
|
|
522
|
+
*
|
|
523
|
+
* The accumulation is the feature. A first-failure-wins preflight turns a
|
|
524
|
+
* five-problem environment into five invocations, each revealing one problem,
|
|
525
|
+
* and the operator fixes them in the order the code happens to check rather than
|
|
526
|
+
* the order that matters. Checks that genuinely depend on an earlier result
|
|
527
|
+
* (everything needing credentials, everything needing a base branch) are skipped
|
|
528
|
+
* with their own recorded failure rather than crashing.
|
|
529
|
+
*
|
|
530
|
+
* NOTHING here mutates: no push, no repoint, no checkpoint write, no lock, and
|
|
531
|
+
* no supervisor-config PUT. The one write this function used to perform — turning
|
|
532
|
+
* `auto_merge_enabled` on — is returned as {@link ConductEpicInitPreflight.pendingSupervisorConfig}
|
|
533
|
+
* for the caller to execute on the success path, because a preflight that
|
|
534
|
+
* accumulates failures must not have already changed a project-wide default by
|
|
535
|
+
* the time it refuses.
|
|
536
|
+
*/
|
|
537
|
+
export async function collectConductEpicInitPreflight(deps, options) {
|
|
538
|
+
const failures = [];
|
|
539
|
+
const announcements = [];
|
|
540
|
+
const epicBranch = epicBranchFor(options.epicKey);
|
|
541
|
+
let pendingSupervisorConfig = null;
|
|
542
|
+
// (1) gh authentication.
|
|
543
|
+
try {
|
|
544
|
+
const gh = await deps.runGh(["auth", "status"], { cwd: deps.cwd });
|
|
545
|
+
if (!gh.ok)
|
|
546
|
+
failures.push("gh is not authenticated. Run `gh auth login`.");
|
|
547
|
+
}
|
|
548
|
+
catch {
|
|
549
|
+
failures.push("gh could not be run. Install the GitHub CLI and run `gh auth login`.");
|
|
550
|
+
}
|
|
551
|
+
// (2) Worktrunk, honoring BAPI_WORKTRUNK_BIN.
|
|
552
|
+
const worktrunk = resolveWorktrunkBinary(deps.platform, deps.env);
|
|
553
|
+
try {
|
|
554
|
+
const probe = await deps.runCommand(worktrunk, ["--version"], { cwd: deps.cwd });
|
|
555
|
+
if (probe.exitCode !== 0) {
|
|
556
|
+
failures.push(`Worktrunk ('${worktrunk}') is not available on PATH.`);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
catch {
|
|
560
|
+
failures.push(`Worktrunk ('${worktrunk}') is not available on PATH.`);
|
|
561
|
+
}
|
|
562
|
+
// (3) Bridge credentials.
|
|
563
|
+
const accessResult = await resolveAccess(deps);
|
|
564
|
+
const access = accessResult.ok ? accessResult.access : null;
|
|
565
|
+
if (!accessResult.ok)
|
|
566
|
+
failures.push(accessResult.error);
|
|
567
|
+
let baseBranch = options.baseBranch ?? null;
|
|
568
|
+
let baseSha = null;
|
|
569
|
+
let epicBranchAlreadyAtBase = false;
|
|
570
|
+
if (access !== null) {
|
|
571
|
+
// (4) auto_merge_enabled, and (5) a non-vacuous required-check set.
|
|
572
|
+
const readiness = await getConductorReadiness(access, deps.fetchImpl);
|
|
573
|
+
if (!readiness.ok) {
|
|
574
|
+
failures.push(`Conductor readiness could not be read: ${readiness.error}`);
|
|
575
|
+
}
|
|
576
|
+
else if (!readiness.value.supervisor.auto_merge_enabled) {
|
|
577
|
+
const current = await getEffectiveSupervisorConfig(access, deps.fetchImpl);
|
|
578
|
+
if (!current.ok) {
|
|
579
|
+
failures.push(`auto_merge_enabled is not set and the effective supervisor config could not be read: ${current.error}`);
|
|
580
|
+
}
|
|
581
|
+
else {
|
|
582
|
+
// Wholesale upsert: copy every field, flip exactly one. Omitting a field
|
|
583
|
+
// would reset it to the request model's default.
|
|
584
|
+
const body = {
|
|
585
|
+
teardown_enabled: current.value.teardown_enabled,
|
|
586
|
+
auto_rereview_enabled: current.value.auto_rereview_enabled,
|
|
587
|
+
max_remediation_attempts: current.value.max_remediation_attempts,
|
|
588
|
+
max_remediation_no_progress_attempts: current.value.max_remediation_no_progress_attempts,
|
|
589
|
+
worker_liveness_window_seconds: current.value.worker_liveness_window_seconds,
|
|
590
|
+
auto_merge_enabled: true,
|
|
591
|
+
merge_approval_required: current.value.merge_approval_required,
|
|
592
|
+
};
|
|
593
|
+
if (options.dryRun) {
|
|
594
|
+
announcements.push(`announced: would enable auto_merge_enabled on the ${access.repoName} project defaults (--dry-run: not sent).`);
|
|
595
|
+
}
|
|
596
|
+
else {
|
|
597
|
+
// Deferred, not sent. The success announcement is added by
|
|
598
|
+
// `runConductEpicInit` only after the PUT actually succeeds, so the
|
|
599
|
+
// `announced:` line never claims a settings change that did not happen.
|
|
600
|
+
pendingSupervisorConfig = body;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
// (5) Required checks. `resolveCiChecks` is called EXACTLY ONCE regardless of
|
|
605
|
+
// the readiness shortcut, because its side effect — warming the
|
|
606
|
+
// `poll-ci-checks` cache — is what makes the first `status` poll answer at
|
|
607
|
+
// all. Skipping it when readiness already looks healthy is the bug that
|
|
608
|
+
// makes tick 1 report `available:false` for no reason.
|
|
609
|
+
const resolved = await resolveCiChecks(access, undefined, deps.fetchImpl);
|
|
610
|
+
const resolvedRequired = requiredCheckNamesFromResolve(resolved.ok ? resolved.value : null);
|
|
611
|
+
if (!readiness.ok) {
|
|
612
|
+
// Already recorded above; nothing further can be concluded.
|
|
613
|
+
}
|
|
614
|
+
else if (readiness.value.supervisor.required_checks_empty) {
|
|
615
|
+
if (!resolved.ok) {
|
|
616
|
+
failures.push(`No required CI checks are configured and resolve-ci-checks failed: ${resolved.error}`);
|
|
617
|
+
}
|
|
618
|
+
else if (resolvedRequired.length === 0) {
|
|
619
|
+
failures.push("No required CI checks are configured and resolve-ci-checks returned none. " +
|
|
620
|
+
"A done gate with an empty required set passes vacuously.");
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
// (6) No active server-side epic run for this key.
|
|
624
|
+
const runState = await getEpicRunState(access, options.epicKey, deps.fetchImpl);
|
|
625
|
+
if (runState.ok) {
|
|
626
|
+
const status = runState.value.epic_run?.status;
|
|
627
|
+
if (status !== "done" && status !== "abandoned") {
|
|
628
|
+
failures.push(`A server-side epic run for ${options.epicKey} is ${String(status)}. ` +
|
|
629
|
+
"conduct-epic will not drive an epic the v2 reconciler is already conducting.");
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
else if (runState.status !== 404) {
|
|
633
|
+
failures.push(`The epic-run state for ${options.epicKey} could not be read: ${runState.error}`);
|
|
634
|
+
}
|
|
635
|
+
// (10) Index-branch override: absent, or this epic's own (a re-init after a
|
|
636
|
+
// crash). A foreign override is named so the operator knows which epic still
|
|
637
|
+
// holds the repository's index.
|
|
638
|
+
const indexBranch = await getIndexBranch(access, deps.fetchImpl);
|
|
639
|
+
if (!indexBranch.ok) {
|
|
640
|
+
failures.push(`The indexed-branch state could not be read: ${indexBranch.error}`);
|
|
641
|
+
}
|
|
642
|
+
else {
|
|
643
|
+
const override = indexBranch.value.override;
|
|
644
|
+
if (override !== null && override.override_branch !== epicBranch) {
|
|
645
|
+
failures.push(`The repository index is already repointed to '${override.override_branch}' by another epic. ` +
|
|
646
|
+
"Run `conduct-epic finish` for that epic first.");
|
|
647
|
+
}
|
|
648
|
+
else if (override !== null && baseBranch === null) {
|
|
649
|
+
// (8) default base: this epic's own override remembers the real original.
|
|
650
|
+
baseBranch = override.original_base_branch;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
// (8) default base, continued: the configured base branch, then `main`.
|
|
654
|
+
if (baseBranch === null) {
|
|
655
|
+
const configured = await getConfigFieldBaseBranch(access, deps.fetchImpl);
|
|
656
|
+
if (configured.ok && configured.value.base_branch !== null) {
|
|
657
|
+
baseBranch = configured.value.base_branch;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
if (baseBranch === null)
|
|
662
|
+
baseBranch = "main";
|
|
663
|
+
const branchReason = validateBranchName(baseBranch);
|
|
664
|
+
if (branchReason) {
|
|
665
|
+
failures.push(`The resolved base branch is unusable: ${branchReason}`);
|
|
666
|
+
}
|
|
667
|
+
else {
|
|
668
|
+
// (8) The base must exist on origin AFTER a fetch — an epic branch cut from a
|
|
669
|
+
// stale local ref silently starts the epic behind main.
|
|
670
|
+
const fetched = await git(deps, ["fetch", "origin", baseBranch]);
|
|
671
|
+
if (fetched.exitCode !== 0) {
|
|
672
|
+
failures.push(`git fetch origin ${baseBranch} failed.`);
|
|
673
|
+
}
|
|
674
|
+
const tip = await git(deps, ["rev-parse", `refs/remotes/origin/${baseBranch}`]);
|
|
675
|
+
baseSha = tip.exitCode === 0 ? firstLine(tip) : null;
|
|
676
|
+
if (baseSha === null) {
|
|
677
|
+
failures.push(`origin/${baseBranch} does not exist after fetching.`);
|
|
678
|
+
}
|
|
679
|
+
// (9) `epic/<EPIC>` must be absent on origin, or already at the base tip.
|
|
680
|
+
const existing = await git(deps, ["ls-remote", "--heads", "origin", `refs/heads/${epicBranch}`]);
|
|
681
|
+
if (existing.exitCode !== 0) {
|
|
682
|
+
failures.push(`git ls-remote could not read origin/${epicBranch}.`);
|
|
683
|
+
}
|
|
684
|
+
else {
|
|
685
|
+
const existingSha = lsRemoteSha(existing);
|
|
686
|
+
if (existingSha !== null) {
|
|
687
|
+
if (baseSha !== null && existingSha === baseSha) {
|
|
688
|
+
epicBranchAlreadyAtBase = true;
|
|
689
|
+
}
|
|
690
|
+
else {
|
|
691
|
+
failures.push(`origin/${epicBranch} already exists at a commit other than the ${baseBranch} tip. ` +
|
|
692
|
+
"Delete it or finish the previous run before re-initializing.");
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
// (7) The lock must be free or recoverably dead. Inspected READ-ONLY here;
|
|
698
|
+
// acquisition happens only after every check passes.
|
|
699
|
+
const checkpointPath = resolveCheckpointPath(deps, await resolveRepoNameForPath(deps), options.epicKey, options.checkpointPath);
|
|
700
|
+
const lockState = await inspectConductEpicLock(resolveConductEpicLockPath(checkpointPath), lockRequest(deps), buildConductEpicLockSeams(deps));
|
|
701
|
+
if (lockState.kind === "live-foreign") {
|
|
702
|
+
failures.push(`The epic lock is held by live process ${lockState.owner.owner_pid} on ${lockState.owner.host}.`);
|
|
703
|
+
}
|
|
704
|
+
else if (lockState.kind === "remote-host") {
|
|
705
|
+
failures.push(`The epic lock was taken on host ${lockState.owner.host} and cannot be recovered here.`);
|
|
706
|
+
}
|
|
707
|
+
else if (lockState.kind === "unknown") {
|
|
708
|
+
failures.push(`The epic lock is unusable: ${lockState.reason}`);
|
|
709
|
+
}
|
|
710
|
+
return {
|
|
711
|
+
failures,
|
|
712
|
+
announcements,
|
|
713
|
+
access,
|
|
714
|
+
baseBranch,
|
|
715
|
+
baseSha,
|
|
716
|
+
epicBranchAlreadyAtBase,
|
|
717
|
+
pendingSupervisorConfig,
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
/** Extract required check NAMES from a `resolve-ci-checks` soft envelope. */
|
|
721
|
+
function requiredCheckNamesFromResolve(value) {
|
|
722
|
+
if (!isRecord(value))
|
|
723
|
+
return [];
|
|
724
|
+
const detail = value["detail"];
|
|
725
|
+
if (!isRecord(detail))
|
|
726
|
+
return [];
|
|
727
|
+
const checks = detail["checks"];
|
|
728
|
+
if (!Array.isArray(checks))
|
|
729
|
+
return [];
|
|
730
|
+
const names = [];
|
|
731
|
+
for (const check of checks) {
|
|
732
|
+
if (!isRecord(check))
|
|
733
|
+
continue;
|
|
734
|
+
if (check["required"] !== true)
|
|
735
|
+
continue;
|
|
736
|
+
const name = check["name"];
|
|
737
|
+
if (typeof name === "string" && name.trim().length > 0)
|
|
738
|
+
names.push(name.trim());
|
|
739
|
+
}
|
|
740
|
+
return names;
|
|
741
|
+
}
|
|
742
|
+
/**
|
|
743
|
+
* `conduct-epic init` — the only verb that provisions.
|
|
744
|
+
*
|
|
745
|
+
* Refuses a second run outright: an existing checkpoint means an epic is already
|
|
746
|
+
* under way, and re-initializing would discard its ticket statuses and counters.
|
|
747
|
+
* That refusal deliberately applies to a CORRUPT existing checkpoint too — the
|
|
748
|
+
* bytes may be all that is left of a half-finished run, and overwriting them as
|
|
749
|
+
* "repair" is worse than making a human look.
|
|
750
|
+
*/
|
|
751
|
+
export async function runConductEpicInit(deps, options) {
|
|
752
|
+
// Credential resolution is the PREFLIGHT's job (check 3). Probing here as well
|
|
753
|
+
// would resolve the credential twice and report a failure the preflight is
|
|
754
|
+
// about to report properly, so `init` reads the access it established instead.
|
|
755
|
+
const repoName = await resolveRepoNameForPath(deps);
|
|
756
|
+
const checkpointPath = resolveCheckpointPath(deps, repoName, options.epicKey, options.checkpointPath);
|
|
757
|
+
const epicBranch = epicBranchFor(options.epicKey);
|
|
758
|
+
const existing = await readConductEpicCheckpoint(checkpointPath, deps.fs);
|
|
759
|
+
if (existing.kind !== "missing") {
|
|
760
|
+
return emitFailure(deps, options.json, [
|
|
761
|
+
`${options.epicKey} is already initialized: a checkpoint exists at ${checkpointPath}. ` +
|
|
762
|
+
"Nothing was changed.",
|
|
763
|
+
], { epic_key: options.epicKey, checkpoint_path: checkpointPath });
|
|
764
|
+
}
|
|
765
|
+
const preflight = await collectConductEpicInitPreflight(deps, options);
|
|
766
|
+
if (preflight.failures.length > 0) {
|
|
767
|
+
for (const line of preflight.announcements)
|
|
768
|
+
deps.errorLog(line);
|
|
769
|
+
return emitFailure(deps, options.json, preflight.failures, {
|
|
770
|
+
epic_key: options.epicKey,
|
|
771
|
+
checkpoint_path: checkpointPath,
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
const access = preflight.access;
|
|
775
|
+
if (access === null || preflight.baseBranch === null || preflight.baseSha === null) {
|
|
776
|
+
// Unreachable: any of these being absent records a failure above. Guarded so
|
|
777
|
+
// a future edit cannot turn a missing precondition into a push.
|
|
778
|
+
return emitFailure(deps, options.json, ["init preflight completed without a usable plan."], {
|
|
779
|
+
epic_key: options.epicKey,
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
// Starts as the preflight's own lines (which in `--dry-run` already include the
|
|
783
|
+
// would-enable notice) and grows by at most the one success line below.
|
|
784
|
+
const announcements = [...preflight.announcements];
|
|
785
|
+
// A function, not a value: the deterministic plan-line ordering is fixed, but
|
|
786
|
+
// the trailing announcements are only final once the deferred PUT has settled.
|
|
787
|
+
const describePlan = () => [
|
|
788
|
+
`epic: ${options.epicKey}`,
|
|
789
|
+
`repo: ${access.repoName}`,
|
|
790
|
+
`base: ${preflight.baseBranch} @ ${preflight.baseSha}`,
|
|
791
|
+
`branch: ${epicBranch}${preflight.epicBranchAlreadyAtBase ? " (already at the base tip)" : ""}`,
|
|
792
|
+
`tickets: ${options.tickets.join(", ")}`,
|
|
793
|
+
`checkpoint: ${checkpointPath}`,
|
|
794
|
+
...announcements,
|
|
795
|
+
];
|
|
796
|
+
if (options.dryRun) {
|
|
797
|
+
return emitSuccess(deps, options.json, {
|
|
798
|
+
ok: true,
|
|
799
|
+
dry_run: true,
|
|
800
|
+
epic_key: options.epicKey,
|
|
801
|
+
epic_branch: epicBranch,
|
|
802
|
+
base_branch: preflight.baseBranch,
|
|
803
|
+
base_sha: preflight.baseSha,
|
|
804
|
+
tickets: options.tickets,
|
|
805
|
+
checkpoint_path: checkpointPath,
|
|
806
|
+
announcements,
|
|
807
|
+
}, ["Planned (dry run — nothing was pushed, repointed, or written):", ...describePlan()]);
|
|
808
|
+
}
|
|
809
|
+
// The FIRST durable mutation of the whole verb, deliberately placed here: every
|
|
810
|
+
// preflight check has passed, and nothing has been pushed, repointed, written,
|
|
811
|
+
// or locked yet. A failed PUT therefore leaves the environment exactly as `init`
|
|
812
|
+
// found it.
|
|
813
|
+
if (preflight.pendingSupervisorConfig !== null) {
|
|
814
|
+
const put = await putSupervisorConfigDefaults(access, preflight.pendingSupervisorConfig, deps.fetchImpl);
|
|
815
|
+
if (!put.ok) {
|
|
816
|
+
return emitFailure(deps, options.json, [`auto_merge_enabled could not be enabled: ${put.error}`], {
|
|
817
|
+
epic_key: options.epicKey,
|
|
818
|
+
checkpoint_path: checkpointPath,
|
|
819
|
+
});
|
|
820
|
+
}
|
|
821
|
+
announcements.push(`announced: auto_merge_enabled was OFF and has been enabled on the ${access.repoName} project defaults.`);
|
|
822
|
+
}
|
|
823
|
+
// Push the epic branch WITHOUT a local checkout: the remote ref is created
|
|
824
|
+
// directly from the fetched remote-tracking ref, so no local branch, worktree,
|
|
825
|
+
// or checked-out state is touched.
|
|
826
|
+
const pushed = await git(deps, [
|
|
827
|
+
"push",
|
|
828
|
+
"origin",
|
|
829
|
+
`refs/remotes/origin/${preflight.baseBranch}:refs/heads/${epicBranch}`,
|
|
830
|
+
]);
|
|
831
|
+
if (pushed.exitCode !== 0) {
|
|
832
|
+
return emitFailure(deps, options.json, [`Could not create origin/${epicBranch} from the ${preflight.baseBranch} tip.`], {
|
|
833
|
+
epic_key: options.epicKey,
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
const repointed = await repointIndexBranch(access, { branch: epicBranch }, deps.fetchImpl);
|
|
837
|
+
if (!repointed.ok) {
|
|
838
|
+
return emitFailure(deps, options.json, [`The repository index could not be repointed to ${epicBranch}: ${repointed.error}`], {
|
|
839
|
+
epic_key: options.epicKey,
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
const request = lockRequest(deps);
|
|
843
|
+
const checkpoint = createInitialConductEpicCheckpoint({
|
|
844
|
+
epicKey: options.epicKey,
|
|
845
|
+
repoName: access.repoName,
|
|
846
|
+
epicBranch,
|
|
847
|
+
baseBranchOriginal: preflight.baseBranch,
|
|
848
|
+
ticketKeys: options.tickets,
|
|
849
|
+
now: deps.now().toISOString(),
|
|
850
|
+
lock: { owner_pid: request.ownerPid, host: request.host, acquired_at: request.acquiredAt },
|
|
851
|
+
});
|
|
852
|
+
const written = await writeConductEpicCheckpointAtomic(checkpointPath, checkpoint, deps.fs, {
|
|
853
|
+
skipChmod: deps.platform === "win32",
|
|
854
|
+
});
|
|
855
|
+
if (!written.ok) {
|
|
856
|
+
return emitFailure(deps, options.json, [written.error], { epic_key: options.epicKey });
|
|
857
|
+
}
|
|
858
|
+
// The lock is taken LAST: a lock without a checkpoint is a state no verb can
|
|
859
|
+
// interpret, whereas a checkpoint without a lock is simply an unlocked epic.
|
|
860
|
+
const lock = await acquireConductEpicLock(resolveConductEpicLockPath(checkpointPath), request, buildConductEpicLockSeams(deps));
|
|
861
|
+
if (!lock.acquired) {
|
|
862
|
+
return emitFailure(deps, options.json, [`The epic lock could not be acquired: ${lock.reason}`], {
|
|
863
|
+
epic_key: options.epicKey,
|
|
864
|
+
checkpoint_path: checkpointPath,
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
return emitSuccess(deps, options.json, {
|
|
868
|
+
ok: true,
|
|
869
|
+
epic_key: options.epicKey,
|
|
870
|
+
epic_branch: epicBranch,
|
|
871
|
+
base_branch: preflight.baseBranch,
|
|
872
|
+
base_sha: preflight.baseSha,
|
|
873
|
+
tickets: options.tickets,
|
|
874
|
+
checkpoint_path: checkpointPath,
|
|
875
|
+
lock_path: resolveConductEpicLockPath(checkpointPath),
|
|
876
|
+
index_repointed: true,
|
|
877
|
+
announcements,
|
|
878
|
+
}, ["Initialized:", ...describePlan()]);
|
|
879
|
+
}
|
|
880
|
+
/**
|
|
881
|
+
* `conduct-epic status <EPIC> --json` — the ONLY evidence a tick acts on.
|
|
882
|
+
*
|
|
883
|
+
* Two rules shape everything below:
|
|
884
|
+
*
|
|
885
|
+
* - **Every probe fails open.** `gh` down, CI unreachable, the review endpoint
|
|
886
|
+
* unavailable — each leaves its sub-object `null`, adds a `probe_errors` entry,
|
|
887
|
+
* and the command still exits 0 with a complete object. The loop must be able
|
|
888
|
+
* to see its own checkpoint during a GitHub outage.
|
|
889
|
+
* - **The write allowlist is exactly four fields** (`ci_last_poll`,
|
|
890
|
+
* `last_seen_head`, `last_state_change_at`, and a newly discovered
|
|
891
|
+
* `ticket.branch`) plus the normal `updated_at`. Everything else is
|
|
892
|
+
* observational. In particular the checkpoint stores no "expected head":
|
|
893
|
+
* merge identity always comes from a fresh `pr.head_sha`, and a stored
|
|
894
|
+
* expectation would be a second source of truth that goes stale.
|
|
895
|
+
*/
|
|
896
|
+
export async function runConductEpicStatus(deps, options) {
|
|
897
|
+
const accessProbe = await resolveAccess(deps);
|
|
898
|
+
const repoName = await resolveRepoNameForPath(deps);
|
|
899
|
+
const checkpointPath = resolveCheckpointPath(deps, repoName, options.epicKey, options.checkpointPath);
|
|
900
|
+
const read = await readConductEpicCheckpoint(checkpointPath, deps.fs);
|
|
901
|
+
if (read.kind === "missing") {
|
|
902
|
+
// Exactly four keys. BAPI-804 asserts this shape to decide whether to run
|
|
903
|
+
// `init`, so extra narration here would be a contract break.
|
|
904
|
+
return emitSuccess(deps, options.json, {
|
|
905
|
+
ok: true,
|
|
906
|
+
epic_key: options.epicKey,
|
|
907
|
+
checkpoint_path: checkpointPath,
|
|
908
|
+
checkpoint_exists: false,
|
|
909
|
+
});
|
|
910
|
+
}
|
|
911
|
+
if (read.kind !== "ok") {
|
|
912
|
+
return emitFailure(deps, options.json, [read.error], {
|
|
913
|
+
epic_key: options.epicKey,
|
|
914
|
+
checkpoint_path: checkpointPath,
|
|
915
|
+
});
|
|
916
|
+
}
|
|
917
|
+
const checkpoint = read.checkpoint;
|
|
918
|
+
const now = deps.now();
|
|
919
|
+
const probeErrors = [];
|
|
920
|
+
const ticket = inFlightTicket(checkpoint);
|
|
921
|
+
const allDone = ticket === null;
|
|
922
|
+
// --- worktree + branch discovery -----------------------------------------
|
|
923
|
+
let discoveredBranch = ticket?.branch ?? null;
|
|
924
|
+
let worktreePath = null;
|
|
925
|
+
let worktreeExists = false;
|
|
926
|
+
if (ticket !== null) {
|
|
927
|
+
try {
|
|
928
|
+
const listed = await git(deps, ["worktree", "list", "--porcelain"]);
|
|
929
|
+
if (listed.exitCode !== 0) {
|
|
930
|
+
probeErrors.push({ probe: "worktree", reason: "git worktree list failed" });
|
|
931
|
+
}
|
|
932
|
+
else {
|
|
933
|
+
const found = discoverTicketWorktree(parseGitWorktreePorcelain(listed.stdout), ticket.key, ticket.branch);
|
|
934
|
+
if (found !== null) {
|
|
935
|
+
discoveredBranch = found.branch;
|
|
936
|
+
worktreePath = found.path;
|
|
937
|
+
try {
|
|
938
|
+
await deps.fs.stat(found.path);
|
|
939
|
+
worktreeExists = true;
|
|
940
|
+
}
|
|
941
|
+
catch {
|
|
942
|
+
worktreeExists = false;
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
catch {
|
|
948
|
+
probeErrors.push({ probe: "worktree", reason: "git worktree list could not be run" });
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
// --- git facts ------------------------------------------------------------
|
|
952
|
+
let branchHead = null;
|
|
953
|
+
let workerCommits = 0;
|
|
954
|
+
if (discoveredBranch !== null) {
|
|
955
|
+
try {
|
|
956
|
+
const remote = await git(deps, ["ls-remote", "--heads", "origin", discoveredBranch]);
|
|
957
|
+
if (remote.exitCode !== 0) {
|
|
958
|
+
probeErrors.push({ probe: "branch_head", reason: "git ls-remote failed" });
|
|
959
|
+
}
|
|
960
|
+
else {
|
|
961
|
+
branchHead = lsRemoteSha(remote);
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
catch {
|
|
965
|
+
probeErrors.push({ probe: "branch_head", reason: "git ls-remote could not be run" });
|
|
966
|
+
}
|
|
967
|
+
if (branchHead !== null) {
|
|
968
|
+
try {
|
|
969
|
+
await git(deps, ["fetch", "origin"]);
|
|
970
|
+
const counted = await git(deps, [
|
|
971
|
+
"rev-list",
|
|
972
|
+
"--count",
|
|
973
|
+
`origin/${checkpoint.epic_branch}..origin/${discoveredBranch}`,
|
|
974
|
+
]);
|
|
975
|
+
if (counted.exitCode === 0) {
|
|
976
|
+
const parsedCount = Number.parseInt(firstLine(counted) ?? "", 10);
|
|
977
|
+
workerCommits = Number.isSafeInteger(parsedCount) && parsedCount >= 0 ? parsedCount : 0;
|
|
978
|
+
}
|
|
979
|
+
else {
|
|
980
|
+
probeErrors.push({ probe: "worker_commits", reason: "git rev-list failed" });
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
catch {
|
|
984
|
+
probeErrors.push({ probe: "worker_commits", reason: "git rev-list could not be run" });
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
// --- PR state -------------------------------------------------------------
|
|
989
|
+
let pr = null;
|
|
990
|
+
if (discoveredBranch !== null) {
|
|
991
|
+
const probe = await discoverConductEpicPrState(discoveredBranch, {
|
|
992
|
+
runGh: deps.runGh,
|
|
993
|
+
cwd: deps.cwd,
|
|
994
|
+
});
|
|
995
|
+
if (probe.kind === "ok")
|
|
996
|
+
pr = probe.pr;
|
|
997
|
+
else if (probe.kind === "error")
|
|
998
|
+
probeErrors.push({ probe: "pr", reason: probe.reason });
|
|
999
|
+
}
|
|
1000
|
+
const mergedExternally = pr?.state === "MERGED" && ticket !== null && ticket.status !== "merged" && ticket.status !== "done";
|
|
1001
|
+
// --- Bridge-backed facts --------------------------------------------------
|
|
1002
|
+
const access = accessProbe.ok ? accessProbe.access : null;
|
|
1003
|
+
if (!accessProbe.ok) {
|
|
1004
|
+
probeErrors.push({ probe: "credentials", reason: accessProbe.error });
|
|
1005
|
+
}
|
|
1006
|
+
let doneGateRequired = null;
|
|
1007
|
+
let reviewOptedIn = false;
|
|
1008
|
+
let reviewSource = null;
|
|
1009
|
+
if (access !== null) {
|
|
1010
|
+
const setup = await getEffectiveSupervisorSetup(access, deps.fetchImpl);
|
|
1011
|
+
if (!setup.ok) {
|
|
1012
|
+
probeErrors.push({ probe: "supervisor_setup", reason: setup.error });
|
|
1013
|
+
}
|
|
1014
|
+
else {
|
|
1015
|
+
const gate = parseDoneGateConfig(setup.value.done_gate_config);
|
|
1016
|
+
for (const condition of gate.conditions) {
|
|
1017
|
+
if (condition.type === "required_ci_checks_green") {
|
|
1018
|
+
doneGateRequired = [...condition.required_checks];
|
|
1019
|
+
}
|
|
1020
|
+
else if (condition.type === "review_state") {
|
|
1021
|
+
reviewOptedIn = true;
|
|
1022
|
+
reviewSource = condition.source;
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
const ci = access === null || pr?.head_sha == null
|
|
1028
|
+
? null
|
|
1029
|
+
: await collectCiFacts(deps, access, pr.head_sha, doneGateRequired, checkpoint.ci_last_poll, probeErrors);
|
|
1030
|
+
let review = {
|
|
1031
|
+
opted_in: reviewOptedIn,
|
|
1032
|
+
source: reviewSource,
|
|
1033
|
+
available: null,
|
|
1034
|
+
verdict: null,
|
|
1035
|
+
head_sha: null,
|
|
1036
|
+
};
|
|
1037
|
+
if (access !== null && reviewOptedIn && pr?.number != null) {
|
|
1038
|
+
const status = await getPrReviewStatus(access, pr.number, deps.fetchImpl);
|
|
1039
|
+
if (!status.ok) {
|
|
1040
|
+
probeErrors.push({ probe: "review", reason: status.error });
|
|
1041
|
+
review = { opted_in: true, source: reviewSource, available: null, verdict: null, head_sha: null };
|
|
1042
|
+
}
|
|
1043
|
+
else {
|
|
1044
|
+
review = normalizeReviewStatus(status.value, reviewOptedIn, reviewSource);
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
let parse = null;
|
|
1048
|
+
if (access !== null) {
|
|
1049
|
+
const parseStatus = await getParseStatus(access, deps.fetchImpl);
|
|
1050
|
+
if (!parseStatus.ok) {
|
|
1051
|
+
probeErrors.push({ probe: "parse", reason: parseStatus.error });
|
|
1052
|
+
}
|
|
1053
|
+
else {
|
|
1054
|
+
parse = normalizeParseStatus(parseStatus.value);
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
// --- lock (inspected, NEVER acquired) ------------------------------------
|
|
1058
|
+
const lockState = await inspectConductEpicLock(resolveConductEpicLockPath(checkpointPath), lockRequest(deps), buildConductEpicLockSeams(deps));
|
|
1059
|
+
const lock = {
|
|
1060
|
+
held_by_me: lockState.kind === "owned",
|
|
1061
|
+
owner_pid: "owner" in lockState ? lockState.owner.owner_pid : null,
|
|
1062
|
+
host: "owner" in lockState ? lockState.owner.host : null,
|
|
1063
|
+
alive: lockState.kind === "owned" || lockState.kind === "live-foreign"
|
|
1064
|
+
? true
|
|
1065
|
+
: lockState.kind === "dead-local"
|
|
1066
|
+
? false
|
|
1067
|
+
: null,
|
|
1068
|
+
};
|
|
1069
|
+
// --- the four permitted writes -------------------------------------------
|
|
1070
|
+
let lastSeenHead = ticket?.last_seen_head ?? null;
|
|
1071
|
+
let lastStateChangeAt = ticket?.last_state_change_at ?? null;
|
|
1072
|
+
if (ticket !== null) {
|
|
1073
|
+
const next = { ...checkpoint, tickets: [...checkpoint.tickets] };
|
|
1074
|
+
const index = next.tickets.findIndex((entry) => entry.key === ticket.key);
|
|
1075
|
+
let dirty = false;
|
|
1076
|
+
const updatedTicket = { ...next.tickets[index] };
|
|
1077
|
+
if (updatedTicket.branch === null && discoveredBranch !== null) {
|
|
1078
|
+
updatedTicket.branch = discoveredBranch;
|
|
1079
|
+
dirty = true;
|
|
1080
|
+
}
|
|
1081
|
+
if (branchHead !== null && branchHead !== updatedTicket.last_seen_head) {
|
|
1082
|
+
updatedTicket.last_state_change_at = now.toISOString();
|
|
1083
|
+
updatedTicket.last_seen_head = branchHead;
|
|
1084
|
+
lastSeenHead = branchHead;
|
|
1085
|
+
lastStateChangeAt = updatedTicket.last_state_change_at;
|
|
1086
|
+
dirty = true;
|
|
1087
|
+
}
|
|
1088
|
+
next.tickets[index] = updatedTicket;
|
|
1089
|
+
if (ci?.ci_last_poll) {
|
|
1090
|
+
next.ci_last_poll = ci.ci_last_poll;
|
|
1091
|
+
dirty = true;
|
|
1092
|
+
}
|
|
1093
|
+
if (dirty) {
|
|
1094
|
+
next.updated_at = now.toISOString();
|
|
1095
|
+
const written = await writeConductEpicCheckpointAtomic(checkpointPath, next, deps.fs, {
|
|
1096
|
+
skipChmod: deps.platform === "win32",
|
|
1097
|
+
});
|
|
1098
|
+
if (!written.ok)
|
|
1099
|
+
probeErrors.push({ probe: "checkpoint_write", reason: written.error });
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
const payload = {
|
|
1103
|
+
ok: true,
|
|
1104
|
+
epic_key: checkpoint.epic_key,
|
|
1105
|
+
epic_branch: checkpoint.epic_branch,
|
|
1106
|
+
checkpoint_path: checkpointPath,
|
|
1107
|
+
checkpoint_exists: true,
|
|
1108
|
+
all_done: allDone,
|
|
1109
|
+
ticket: ticket === null
|
|
1110
|
+
? null
|
|
1111
|
+
: {
|
|
1112
|
+
key: ticket.key,
|
|
1113
|
+
status: ticket.status,
|
|
1114
|
+
branch: discoveredBranch,
|
|
1115
|
+
pr_number: ticket.pr_number,
|
|
1116
|
+
spawned_at: ticket.spawned_at,
|
|
1117
|
+
respawns: ticket.respawns,
|
|
1118
|
+
conflict_attempts: ticket.conflict_attempts,
|
|
1119
|
+
counters: { ...ticket.counters },
|
|
1120
|
+
},
|
|
1121
|
+
worktree_path: worktreePath,
|
|
1122
|
+
worktree_exists: worktreeExists,
|
|
1123
|
+
branch_head: branchHead,
|
|
1124
|
+
worker_commits_since_spawn: workerCommits,
|
|
1125
|
+
last_seen_head: lastSeenHead,
|
|
1126
|
+
last_state_change_at: lastStateChangeAt,
|
|
1127
|
+
stale_for_seconds: elapsedSeconds(lastStateChangeAt, now),
|
|
1128
|
+
pr,
|
|
1129
|
+
merged_externally: Boolean(mergedExternally),
|
|
1130
|
+
ci: ci?.ci ?? null,
|
|
1131
|
+
review,
|
|
1132
|
+
parse,
|
|
1133
|
+
deadlines: {
|
|
1134
|
+
soft_seconds: checkpoint.deadlines.soft_seconds,
|
|
1135
|
+
hard_seconds: checkpoint.deadlines.hard_seconds,
|
|
1136
|
+
elapsed_since_spawn_seconds: elapsedSeconds(ticket?.spawned_at ?? null, now),
|
|
1137
|
+
},
|
|
1138
|
+
lock,
|
|
1139
|
+
needs_human: checkpoint.needs_human,
|
|
1140
|
+
probe_errors: probeErrors,
|
|
1141
|
+
};
|
|
1142
|
+
return emitSuccess(deps, options.json, payload);
|
|
1143
|
+
}
|
|
1144
|
+
/**
|
|
1145
|
+
* Poll CI for `headSha`, warming the resolve cache at most once.
|
|
1146
|
+
*
|
|
1147
|
+
* `poll-ci-checks` answers `available: false` with a "call resolve-ci-checks
|
|
1148
|
+
* first" action until the repository's check config has been cached. That is an
|
|
1149
|
+
* INSTRUCTION, not an error: resolve once, poll once more, and proceed. Resolve
|
|
1150
|
+
* is also what supplies the required names when the done gate does not.
|
|
1151
|
+
*/
|
|
1152
|
+
async function collectCiFacts(deps, access, headSha, doneGateRequired, previous, probeErrors) {
|
|
1153
|
+
let resolvedValue = null;
|
|
1154
|
+
let resolveCalled = false;
|
|
1155
|
+
const ensureResolved = async () => {
|
|
1156
|
+
if (resolveCalled)
|
|
1157
|
+
return;
|
|
1158
|
+
resolveCalled = true;
|
|
1159
|
+
const resolved = await resolveCiChecks(access, headSha, deps.fetchImpl);
|
|
1160
|
+
if (resolved.ok)
|
|
1161
|
+
resolvedValue = resolved.value;
|
|
1162
|
+
else
|
|
1163
|
+
probeErrors.push({ probe: "ci_resolve", reason: resolved.error });
|
|
1164
|
+
};
|
|
1165
|
+
let polled = await pollCiChecks(access, headSha, deps.fetchImpl);
|
|
1166
|
+
if (!polled.ok) {
|
|
1167
|
+
probeErrors.push({ probe: "ci", reason: polled.error });
|
|
1168
|
+
return null;
|
|
1169
|
+
}
|
|
1170
|
+
if (isRecord(polled.value) && polled.value["available"] === false) {
|
|
1171
|
+
await ensureResolved();
|
|
1172
|
+
polled = await pollCiChecks(access, headSha, deps.fetchImpl);
|
|
1173
|
+
if (!polled.ok) {
|
|
1174
|
+
probeErrors.push({ probe: "ci", reason: polled.error });
|
|
1175
|
+
return null;
|
|
1176
|
+
}
|
|
1177
|
+
// Still unavailable after the one resolve-and-re-poll: CI state is UNKNOWN
|
|
1178
|
+
// (BAPI-814/B3). Falling through would build `{complete: false, checks: []}`,
|
|
1179
|
+
// which is indistinguishable from "polled fine, nothing is green yet" — and
|
|
1180
|
+
// would persist a fingerprint derived from an answer that carried no checks,
|
|
1181
|
+
// so the NEXT poll would compare against it and could report
|
|
1182
|
+
// `stable_across_two_polls`. Return before either happens.
|
|
1183
|
+
if (isRecord(polled.value) && polled.value["available"] === false) {
|
|
1184
|
+
probeErrors.push({
|
|
1185
|
+
probe: "ci",
|
|
1186
|
+
reason: "CI checks are unavailable after resolve-ci-checks and a second poll",
|
|
1187
|
+
});
|
|
1188
|
+
return null;
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
const detail = isRecord(polled.value) ? polled.value["detail"] : null;
|
|
1192
|
+
const rawChecks = isRecord(detail) && Array.isArray(detail["checks"]) ? detail["checks"] : [];
|
|
1193
|
+
const checks = rawChecks.filter(isRecord).map((check) => ({
|
|
1194
|
+
name: typeof check["name"] === "string" ? check["name"] : "",
|
|
1195
|
+
status: check["status"] ?? null,
|
|
1196
|
+
conclusion: check["conclusion"] ?? null,
|
|
1197
|
+
required: check["required"] !== false,
|
|
1198
|
+
}));
|
|
1199
|
+
let required = doneGateRequired;
|
|
1200
|
+
if (required === null) {
|
|
1201
|
+
await ensureResolved();
|
|
1202
|
+
required = requiredCheckNamesFromResolve(resolvedValue);
|
|
1203
|
+
}
|
|
1204
|
+
// Sorted so the fingerprint is order-independent: GitHub returns checks in an
|
|
1205
|
+
// arbitrary order, and an order flip must not read as an unstable poll.
|
|
1206
|
+
const requiredSorted = [...new Set(required)].sort();
|
|
1207
|
+
const complete = requiredSorted.length > 0 &&
|
|
1208
|
+
requiredSorted.every((name) => {
|
|
1209
|
+
const raw = rawChecks.filter(isRecord).find((check) => check["name"] === name);
|
|
1210
|
+
return raw !== undefined && raw["complete"] === true && raw["passed"] === true;
|
|
1211
|
+
});
|
|
1212
|
+
const fingerprint = JSON.stringify(checks
|
|
1213
|
+
.filter((check) => requiredSorted.includes(check.name))
|
|
1214
|
+
.map((check) => [check.name, check.status ?? null, check.conclusion ?? null])
|
|
1215
|
+
.sort((a, b) => String(a[0]).localeCompare(String(b[0]))));
|
|
1216
|
+
// Stability is a THREE-part identity: same head, same required set, same
|
|
1217
|
+
// per-check conclusions. A head change resets it, which is the point — a green
|
|
1218
|
+
// result observed twice for an old SHA says nothing about the current one.
|
|
1219
|
+
const stable = previous !== null &&
|
|
1220
|
+
previous.head_sha === headSha &&
|
|
1221
|
+
previous.results_fingerprint === fingerprint &&
|
|
1222
|
+
previous.required.length === requiredSorted.length &&
|
|
1223
|
+
previous.required.every((name, i) => name === requiredSorted[i]);
|
|
1224
|
+
return {
|
|
1225
|
+
ci: {
|
|
1226
|
+
required: requiredSorted,
|
|
1227
|
+
complete,
|
|
1228
|
+
stable_across_two_polls: stable,
|
|
1229
|
+
head_sha: headSha,
|
|
1230
|
+
checks,
|
|
1231
|
+
},
|
|
1232
|
+
ci_last_poll: {
|
|
1233
|
+
head_sha: headSha,
|
|
1234
|
+
required: requiredSorted,
|
|
1235
|
+
results_fingerprint: fingerprint,
|
|
1236
|
+
at: deps.now().toISOString(),
|
|
1237
|
+
},
|
|
1238
|
+
};
|
|
1239
|
+
}
|
|
1240
|
+
/** Normalize the review-status envelope into the contract's five fields. */
|
|
1241
|
+
function normalizeReviewStatus(value, optedIn, source) {
|
|
1242
|
+
const available = isRecord(value) && value["available"] === true;
|
|
1243
|
+
const detail = isRecord(value) && isRecord(value["detail"]) ? value["detail"] : null;
|
|
1244
|
+
let verdict = null;
|
|
1245
|
+
if (detail !== null) {
|
|
1246
|
+
const sticky = detail["sticky_verdict"];
|
|
1247
|
+
const native = detail["review_decision"];
|
|
1248
|
+
if (typeof sticky === "string" && sticky.trim().length > 0) {
|
|
1249
|
+
verdict = normalizeVerdict(sticky);
|
|
1250
|
+
}
|
|
1251
|
+
else if (typeof native === "string" && native.trim().length > 0) {
|
|
1252
|
+
verdict = normalizeVerdict(native);
|
|
1253
|
+
}
|
|
1254
|
+
else {
|
|
1255
|
+
verdict = "unknown";
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
return {
|
|
1259
|
+
opted_in: optedIn,
|
|
1260
|
+
source,
|
|
1261
|
+
available: isRecord(value) ? Boolean(value["available"]) : null,
|
|
1262
|
+
// An `available: false` envelope still carries whatever verdict the server
|
|
1263
|
+
// could derive; it is passed through rather than nulled, because the caller
|
|
1264
|
+
// reads `available` to decide how much to trust it.
|
|
1265
|
+
verdict: available || verdict !== null ? verdict : null,
|
|
1266
|
+
head_sha: detail !== null && typeof detail["head_sha"] === "string" ? detail["head_sha"] : null,
|
|
1267
|
+
};
|
|
1268
|
+
}
|
|
1269
|
+
/** Map every known verdict spelling onto the contract's three values. */
|
|
1270
|
+
function normalizeVerdict(raw) {
|
|
1271
|
+
const value = raw.trim().toLowerCase();
|
|
1272
|
+
if (value === "approved")
|
|
1273
|
+
return "approved";
|
|
1274
|
+
if (value === "changes_requested")
|
|
1275
|
+
return "changes_requested";
|
|
1276
|
+
return "unknown";
|
|
1277
|
+
}
|
|
1278
|
+
/**
|
|
1279
|
+
* Normalize `/jira/parse-status` into the `parse` sub-object.
|
|
1280
|
+
*
|
|
1281
|
+
* The two timestamps are what make the post-merge barrier CAUSAL rather than
|
|
1282
|
+
* coincidental (BAPI-814/A2). `parse.status` is repository-level and stays
|
|
1283
|
+
* `succeeded` from any earlier parse, so "succeeded" alone cannot tell the loop
|
|
1284
|
+
* whether THIS ticket's merge has been indexed; a start or finish strictly later
|
|
1285
|
+
* than the journaled request time can.
|
|
1286
|
+
*
|
|
1287
|
+
* Malformed primitives become `null` rather than being coerced. A blank string
|
|
1288
|
+
* or a number where a timestamp belongs is unavailable evidence, and coercing it
|
|
1289
|
+
* to something truthy would let the barrier pass on a value that means nothing.
|
|
1290
|
+
*/
|
|
1291
|
+
export function normalizeParseStatus(value) {
|
|
1292
|
+
const known = ["idle", "queued", "in_progress", "succeeded", "failed"];
|
|
1293
|
+
const raw = isRecord(value) ? value["status"] : null;
|
|
1294
|
+
const status = typeof raw === "string" && known.includes(raw) ? raw : null;
|
|
1295
|
+
const optionalText = (key) => {
|
|
1296
|
+
const field = isRecord(value) ? value[key] : null;
|
|
1297
|
+
if (typeof field !== "string")
|
|
1298
|
+
return null;
|
|
1299
|
+
const trimmed = field.trim();
|
|
1300
|
+
return trimmed.length === 0 ? null : trimmed;
|
|
1301
|
+
};
|
|
1302
|
+
return {
|
|
1303
|
+
status,
|
|
1304
|
+
terminal: status === "succeeded" || status === "failed",
|
|
1305
|
+
started_at: optionalText("started_at"),
|
|
1306
|
+
finished_at: optionalText("finished_at"),
|
|
1307
|
+
index_branch_override: optionalText("index_branch_override"),
|
|
1308
|
+
};
|
|
1309
|
+
}
|
|
1310
|
+
// ---------------------------------------------------------------------------
|
|
1311
|
+
// checkpoint set
|
|
1312
|
+
// ---------------------------------------------------------------------------
|
|
1313
|
+
/**
|
|
1314
|
+
* `conduct-epic checkpoint set` — schema-validated ABSOLUTE edits under the lock.
|
|
1315
|
+
*
|
|
1316
|
+
* Absolute, not relative: the caller reads `status`, computes `n + 1`, and writes
|
|
1317
|
+
* the result. A `--increment` flag would need the CLI to re-read between the
|
|
1318
|
+
* caller's read and its write, which is exactly the lost-update race the lock
|
|
1319
|
+
* exists to prevent.
|
|
1320
|
+
*/
|
|
1321
|
+
export async function runConductEpicCheckpointSet(deps, options) {
|
|
1322
|
+
// No credential probe here: `checkpoint set` is entirely local — a file read,
|
|
1323
|
+
// a schema-validated edit, and an atomic write. Resolving Bridge access would
|
|
1324
|
+
// make a purely local operation fail-prone during a credential outage.
|
|
1325
|
+
const repoName = await resolveRepoNameForPath(deps);
|
|
1326
|
+
const checkpointPath = resolveCheckpointPath(deps, repoName, options.epicKey, options.checkpointPath);
|
|
1327
|
+
// LOCK FIRST, then read (BAPI-814/B5). Reading before acquiring is a
|
|
1328
|
+
// read-before-lock race: the bytes this call validates and rewrites could be
|
|
1329
|
+
// replaced by another process between the read and the acquisition, and the
|
|
1330
|
+
// write would then silently discard that process's update. Everything that
|
|
1331
|
+
// observes or mutates checkpoint state now happens inside one lock lifetime.
|
|
1332
|
+
const lock = await acquireConductEpicLock(resolveConductEpicLockPath(checkpointPath), lockRequest(deps), buildConductEpicLockSeams(deps));
|
|
1333
|
+
if (!lock.acquired) {
|
|
1334
|
+
return emitFailure(deps, options.json, [`The epic lock could not be acquired: ${lock.reason}`]);
|
|
1335
|
+
}
|
|
1336
|
+
try {
|
|
1337
|
+
const read = await readConductEpicCheckpoint(checkpointPath, deps.fs);
|
|
1338
|
+
if (read.kind === "missing") {
|
|
1339
|
+
return emitFailure(deps, options.json, [
|
|
1340
|
+
`No checkpoint exists at ${checkpointPath}. Run \`conduct-epic init\` first.`,
|
|
1341
|
+
]);
|
|
1342
|
+
}
|
|
1343
|
+
if (read.kind !== "ok")
|
|
1344
|
+
return emitFailure(deps, options.json, [read.error]);
|
|
1345
|
+
const checkpoint = read.checkpoint;
|
|
1346
|
+
const ticketKey = options.ticket;
|
|
1347
|
+
const index = checkpoint.tickets.findIndex((entry) => entry.key === ticketKey);
|
|
1348
|
+
if (index === -1) {
|
|
1349
|
+
return emitFailure(deps, options.json, [
|
|
1350
|
+
`${ticketKey} is not one of this epic's tickets (${checkpoint.tickets.map((t) => t.key).join(", ")}).`,
|
|
1351
|
+
]);
|
|
1352
|
+
}
|
|
1353
|
+
const next = {
|
|
1354
|
+
...checkpoint,
|
|
1355
|
+
counters: { ...checkpoint.counters },
|
|
1356
|
+
tickets: checkpoint.tickets.map((entry) => ({
|
|
1357
|
+
...entry,
|
|
1358
|
+
counters: { ...entry.counters },
|
|
1359
|
+
journal: [...entry.journal],
|
|
1360
|
+
})),
|
|
1361
|
+
};
|
|
1362
|
+
const now = deps.now().toISOString();
|
|
1363
|
+
const statusBefore = next.tickets[index].status;
|
|
1364
|
+
// Applied in argv order, so a repeated field's LAST assignment wins — the
|
|
1365
|
+
// same rule a shell applies to repeated options.
|
|
1366
|
+
for (const assignment of options.fields) {
|
|
1367
|
+
const applied = applyFieldAssignment(next, index, assignment);
|
|
1368
|
+
if (applied !== null) {
|
|
1369
|
+
return emitFailure(deps, options.json, [applied]);
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
if (next.tickets[index].status !== statusBefore) {
|
|
1373
|
+
next.tickets[index].last_state_change_at = now;
|
|
1374
|
+
}
|
|
1375
|
+
if (options.journal !== undefined) {
|
|
1376
|
+
next.tickets[index] = appendTicketJournal(next.tickets[index], options.journal);
|
|
1377
|
+
}
|
|
1378
|
+
next.updated_at = now;
|
|
1379
|
+
const written = await writeConductEpicCheckpointAtomic(checkpointPath, next, deps.fs, {
|
|
1380
|
+
skipChmod: deps.platform === "win32",
|
|
1381
|
+
});
|
|
1382
|
+
if (!written.ok)
|
|
1383
|
+
return emitFailure(deps, options.json, [written.error]);
|
|
1384
|
+
// A concise mutation summary: the FIELD NAMES that changed, never the
|
|
1385
|
+
// checkpoint body, which can carry operator-authored `needs_human` evidence.
|
|
1386
|
+
const changed = options.fields.map((f) => f.name);
|
|
1387
|
+
if (options.journal !== undefined)
|
|
1388
|
+
changed.push("journal");
|
|
1389
|
+
return emitSuccess(deps, options.json, {
|
|
1390
|
+
ok: true,
|
|
1391
|
+
epic_key: checkpoint.epic_key,
|
|
1392
|
+
ticket: ticketKey,
|
|
1393
|
+
updated_fields: changed,
|
|
1394
|
+
checkpoint_path: checkpointPath,
|
|
1395
|
+
}, [`Updated ${ticketKey}: ${changed.join(", ")}`]);
|
|
1396
|
+
}
|
|
1397
|
+
finally {
|
|
1398
|
+
await releaseAcquired(lock);
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
/** Release a lock we acquired, never one a successor replaced it with. */
|
|
1402
|
+
async function releaseAcquired(lock) {
|
|
1403
|
+
await lock.release();
|
|
1404
|
+
}
|
|
1405
|
+
/**
|
|
1406
|
+
* Apply one `--field name value` assignment in place, returning an error message
|
|
1407
|
+
* or `null`.
|
|
1408
|
+
*
|
|
1409
|
+
* Every value is parsed by TYPE, not coerced: `"null"` is only null where null is
|
|
1410
|
+
* a legal value, a counter must be a non-negative integer, a branch must survive
|
|
1411
|
+
* `validateBranchName`, and a status must be in the closed vocabulary. A
|
|
1412
|
+
* permissive parser here would let a typo silently write a nonsense state that
|
|
1413
|
+
* the loop then acts on.
|
|
1414
|
+
*/
|
|
1415
|
+
function applyFieldAssignment(checkpoint, ticketIndex, assignment) {
|
|
1416
|
+
const { name, value } = assignment;
|
|
1417
|
+
const ticket = checkpoint.tickets[ticketIndex];
|
|
1418
|
+
switch (name) {
|
|
1419
|
+
case "status": {
|
|
1420
|
+
if (!CONDUCT_EPIC_TICKET_STATUSES.includes(value)) {
|
|
1421
|
+
return `Invalid status '${value}'. Expected one of: ${CONDUCT_EPIC_TICKET_STATUSES.join(", ")}`;
|
|
1422
|
+
}
|
|
1423
|
+
ticket.status = value;
|
|
1424
|
+
return null;
|
|
1425
|
+
}
|
|
1426
|
+
case "branch": {
|
|
1427
|
+
if (value === "null") {
|
|
1428
|
+
ticket.branch = null;
|
|
1429
|
+
return null;
|
|
1430
|
+
}
|
|
1431
|
+
const reason = validateBranchName(value);
|
|
1432
|
+
if (reason)
|
|
1433
|
+
return `Invalid branch: ${reason}`;
|
|
1434
|
+
ticket.branch = value;
|
|
1435
|
+
return null;
|
|
1436
|
+
}
|
|
1437
|
+
case "pr_number": {
|
|
1438
|
+
if (value === "null") {
|
|
1439
|
+
ticket.pr_number = null;
|
|
1440
|
+
return null;
|
|
1441
|
+
}
|
|
1442
|
+
const parsed = parseIntegerField(value);
|
|
1443
|
+
if (parsed === null || parsed <= 0)
|
|
1444
|
+
return "pr_number must be a positive integer or null.";
|
|
1445
|
+
ticket.pr_number = parsed;
|
|
1446
|
+
return null;
|
|
1447
|
+
}
|
|
1448
|
+
case "spawned_at": {
|
|
1449
|
+
if (value === "null") {
|
|
1450
|
+
ticket.spawned_at = null;
|
|
1451
|
+
return null;
|
|
1452
|
+
}
|
|
1453
|
+
if (value.trim().length === 0)
|
|
1454
|
+
return "spawned_at must be a non-empty timestamp or null.";
|
|
1455
|
+
ticket.spawned_at = value;
|
|
1456
|
+
return null;
|
|
1457
|
+
}
|
|
1458
|
+
case "respawns":
|
|
1459
|
+
case "conflict_attempts": {
|
|
1460
|
+
const parsed = parseIntegerField(value);
|
|
1461
|
+
if (parsed === null)
|
|
1462
|
+
return `${name} must be a non-negative integer.`;
|
|
1463
|
+
ticket[name] = parsed;
|
|
1464
|
+
return null;
|
|
1465
|
+
}
|
|
1466
|
+
case "counters.sessions_spawned":
|
|
1467
|
+
case "counters.plan_generations_observed":
|
|
1468
|
+
case "counters.merge_attempts": {
|
|
1469
|
+
const parsed = parseIntegerField(value);
|
|
1470
|
+
if (parsed === null)
|
|
1471
|
+
return `${name} must be a non-negative integer.`;
|
|
1472
|
+
const key = name.slice("counters.".length);
|
|
1473
|
+
ticket.counters[key] = parsed;
|
|
1474
|
+
return null;
|
|
1475
|
+
}
|
|
1476
|
+
case "counters.iterations":
|
|
1477
|
+
case "counters.merges": {
|
|
1478
|
+
const parsed = parseIntegerField(value);
|
|
1479
|
+
if (parsed === null)
|
|
1480
|
+
return `${name} must be a non-negative integer.`;
|
|
1481
|
+
checkpoint.counters[name.slice("counters.".length)] = parsed;
|
|
1482
|
+
return null;
|
|
1483
|
+
}
|
|
1484
|
+
case "needs_human": {
|
|
1485
|
+
if (value === "null") {
|
|
1486
|
+
checkpoint.needs_human = null;
|
|
1487
|
+
return null;
|
|
1488
|
+
}
|
|
1489
|
+
let parsed;
|
|
1490
|
+
try {
|
|
1491
|
+
parsed = JSON.parse(value);
|
|
1492
|
+
}
|
|
1493
|
+
catch {
|
|
1494
|
+
return "needs_human must be JSON null or an object with reason, evidence, and at.";
|
|
1495
|
+
}
|
|
1496
|
+
if (parsed === null) {
|
|
1497
|
+
checkpoint.needs_human = null;
|
|
1498
|
+
return null;
|
|
1499
|
+
}
|
|
1500
|
+
if (!isRecord(parsed) ||
|
|
1501
|
+
typeof parsed["reason"] !== "string" ||
|
|
1502
|
+
typeof parsed["evidence"] !== "string" ||
|
|
1503
|
+
typeof parsed["at"] !== "string") {
|
|
1504
|
+
return "needs_human must be JSON null or an object with string reason, evidence, and at.";
|
|
1505
|
+
}
|
|
1506
|
+
checkpoint.needs_human = {
|
|
1507
|
+
reason: parsed["reason"],
|
|
1508
|
+
evidence: parsed["evidence"],
|
|
1509
|
+
at: parsed["at"],
|
|
1510
|
+
};
|
|
1511
|
+
return null;
|
|
1512
|
+
}
|
|
1513
|
+
default:
|
|
1514
|
+
return (`Unknown field '${name}'. Ticket fields: ${TICKET_FIELDS.join(", ")}. ` +
|
|
1515
|
+
`Top-level fields: ${TOP_LEVEL_FIELDS.join(", ")}.`);
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
/** Parse a non-negative integer, rejecting `"1.5"`, `"1e3"`, `"0x1"`, and `""`. */
|
|
1519
|
+
function parseIntegerField(value) {
|
|
1520
|
+
if (!/^\d+$/.test(value.trim()))
|
|
1521
|
+
return null;
|
|
1522
|
+
const parsed = Number.parseInt(value.trim(), 10);
|
|
1523
|
+
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null;
|
|
1524
|
+
}
|
|
1525
|
+
// ---------------------------------------------------------------------------
|
|
1526
|
+
// spawn
|
|
1527
|
+
// ---------------------------------------------------------------------------
|
|
1528
|
+
/**
|
|
1529
|
+
* `conduct-epic spawn` — open exactly ONE agent tab for a ticket.
|
|
1530
|
+
*
|
|
1531
|
+
* Respawn and conflict budgets are deliberately NOT enforced here. The caller
|
|
1532
|
+
* owns them: it reads `status`, decides whether another attempt is warranted,
|
|
1533
|
+
* and only then calls `spawn`. Enforcing a budget in both places means two
|
|
1534
|
+
* thresholds that drift, and the one in prose wins by accident.
|
|
1535
|
+
*/
|
|
1536
|
+
export async function runConductEpicSpawn(deps, options) {
|
|
1537
|
+
// Local-only, like `checkpoint set`: no credential probe. The spawned agent
|
|
1538
|
+
// resolves its own credentials.
|
|
1539
|
+
const repoName = await resolveRepoNameForPath(deps);
|
|
1540
|
+
const checkpointPath = resolveCheckpointPath(deps, repoName, options.epicKey, options.checkpointPath);
|
|
1541
|
+
// LOCK FIRST (BAPI-814/B5). Checkpoint read, ticket validation, worktree
|
|
1542
|
+
// resolution, prompt read, the spawn itself, and the counter write all live
|
|
1543
|
+
// inside one lock lifetime — so no other process can advance the counters
|
|
1544
|
+
// between the read this call validates and the write it performs.
|
|
1545
|
+
const lock = await acquireConductEpicLock(resolveConductEpicLockPath(checkpointPath), lockRequest(deps), buildConductEpicLockSeams(deps));
|
|
1546
|
+
if (!lock.acquired) {
|
|
1547
|
+
return emitFailure(deps, options.json, [`The epic lock could not be acquired: ${lock.reason}`]);
|
|
1548
|
+
}
|
|
1549
|
+
try {
|
|
1550
|
+
const read = await readConductEpicCheckpoint(checkpointPath, deps.fs);
|
|
1551
|
+
if (read.kind === "missing") {
|
|
1552
|
+
return emitFailure(deps, options.json, [
|
|
1553
|
+
`No checkpoint exists at ${checkpointPath}. Run \`conduct-epic init\` first.`,
|
|
1554
|
+
]);
|
|
1555
|
+
}
|
|
1556
|
+
if (read.kind !== "ok")
|
|
1557
|
+
return emitFailure(deps, options.json, [read.error]);
|
|
1558
|
+
const checkpoint = read.checkpoint;
|
|
1559
|
+
const ticketKey = options.ticket;
|
|
1560
|
+
const index = checkpoint.tickets.findIndex((entry) => entry.key === ticketKey);
|
|
1561
|
+
if (index === -1) {
|
|
1562
|
+
return emitFailure(deps, options.json, [`${ticketKey} is not one of this epic's tickets.`]);
|
|
1563
|
+
}
|
|
1564
|
+
// Resolve the worktree BEFORE reading the prompt and spawning: a tab opened
|
|
1565
|
+
// in the wrong directory does real work in the wrong place.
|
|
1566
|
+
const listed = await git(deps, ["worktree", "list", "--porcelain"]);
|
|
1567
|
+
if (listed.exitCode !== 0) {
|
|
1568
|
+
return emitFailure(deps, options.json, ["git worktree list failed; the ticket worktree could not be resolved."]);
|
|
1569
|
+
}
|
|
1570
|
+
const found = discoverTicketWorktree(parseGitWorktreePorcelain(listed.stdout), ticketKey, checkpoint.tickets[index].branch);
|
|
1571
|
+
if (found === null) {
|
|
1572
|
+
return emitFailure(deps, options.json, [
|
|
1573
|
+
`No worktree was found for ${ticketKey}. Expected a worktree on the ticket's branch, ` +
|
|
1574
|
+
`feature/${ticketKey}, or feature/${ticketKey}-<slug>.`,
|
|
1575
|
+
]);
|
|
1576
|
+
}
|
|
1577
|
+
try {
|
|
1578
|
+
await deps.fs.stat(found.path);
|
|
1579
|
+
}
|
|
1580
|
+
catch {
|
|
1581
|
+
return emitFailure(deps, options.json, [`The worktree path for ${ticketKey} is not accessible.`]);
|
|
1582
|
+
}
|
|
1583
|
+
let prompt;
|
|
1584
|
+
try {
|
|
1585
|
+
prompt = await deps.fs.readFile(options.promptFile);
|
|
1586
|
+
}
|
|
1587
|
+
catch {
|
|
1588
|
+
return emitFailure(deps, options.json, [`The prompt file '${options.promptFile}' could not be read.`]);
|
|
1589
|
+
}
|
|
1590
|
+
const spawned = await spawnConductEpicAgentTab({
|
|
1591
|
+
ticketKey,
|
|
1592
|
+
worktreePath: found.path,
|
|
1593
|
+
prompt,
|
|
1594
|
+
agent: options.agent,
|
|
1595
|
+
platform: deps.platform,
|
|
1596
|
+
}, deps.spawnTab);
|
|
1597
|
+
if (!spawned.ok)
|
|
1598
|
+
return emitFailure(deps, options.json, [spawned.error]);
|
|
1599
|
+
// Counters advance only AFTER the tab actually opened. Incrementing first
|
|
1600
|
+
// would let a failed spawn consume the caller's respawn budget.
|
|
1601
|
+
const now = deps.now().toISOString();
|
|
1602
|
+
const next = {
|
|
1603
|
+
...checkpoint,
|
|
1604
|
+
counters: { ...checkpoint.counters },
|
|
1605
|
+
tickets: checkpoint.tickets.map((entry) => ({
|
|
1606
|
+
...entry,
|
|
1607
|
+
counters: { ...entry.counters },
|
|
1608
|
+
journal: [...entry.journal],
|
|
1609
|
+
})),
|
|
1610
|
+
};
|
|
1611
|
+
next.tickets[index].branch = found.branch;
|
|
1612
|
+
next.tickets[index].counters.sessions_spawned += 1;
|
|
1613
|
+
next.tickets[index] = appendTicketJournal(next.tickets[index], `${now} spawned ${options.agent ?? "claude"} in ${found.branch}`);
|
|
1614
|
+
next.updated_at = now;
|
|
1615
|
+
const written = await writeConductEpicCheckpointAtomic(checkpointPath, next, deps.fs, {
|
|
1616
|
+
skipChmod: deps.platform === "win32",
|
|
1617
|
+
});
|
|
1618
|
+
if (!written.ok)
|
|
1619
|
+
return emitFailure(deps, options.json, [written.error]);
|
|
1620
|
+
return emitSuccess(deps, options.json, {
|
|
1621
|
+
ok: true,
|
|
1622
|
+
epic_key: checkpoint.epic_key,
|
|
1623
|
+
ticket: ticketKey,
|
|
1624
|
+
branch: found.branch,
|
|
1625
|
+
worktree_path: found.path,
|
|
1626
|
+
sessions_spawned: next.tickets[index].counters.sessions_spawned,
|
|
1627
|
+
}, [`Spawned one agent tab for ${ticketKey} in ${found.path}`]);
|
|
1628
|
+
}
|
|
1629
|
+
finally {
|
|
1630
|
+
await releaseAcquired(lock);
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
// ---------------------------------------------------------------------------
|
|
1634
|
+
// finish
|
|
1635
|
+
// ---------------------------------------------------------------------------
|
|
1636
|
+
/**
|
|
1637
|
+
* `conduct-epic finish` — restore the server's indexed base branch and wind down.
|
|
1638
|
+
*
|
|
1639
|
+
* Restoration uses `restoreIndexBranch(access)` and NOTHING else. The
|
|
1640
|
+
* checkpoint's `base_branch_original` is display-only: the server holds the
|
|
1641
|
+
* durable override row with the true original, and a local copy that drifted
|
|
1642
|
+
* (because the operator changed the repository's base mid-run) would restore the
|
|
1643
|
+
* index to the wrong branch. `{ ok: true, changed: false }` is a success — that
|
|
1644
|
+
* is what a second `finish` sees.
|
|
1645
|
+
*/
|
|
1646
|
+
export async function runConductEpicFinish(deps, options) {
|
|
1647
|
+
const accessProbe = await resolveAccess(deps);
|
|
1648
|
+
if (!accessProbe.ok)
|
|
1649
|
+
return emitFailure(deps, options.json, [accessProbe.error]);
|
|
1650
|
+
const access = accessProbe.access;
|
|
1651
|
+
const checkpointPath = resolveCheckpointPath(deps, await resolveRepoNameForPath(deps), options.epicKey, options.checkpointPath);
|
|
1652
|
+
const read = await readConductEpicCheckpoint(checkpointPath, deps.fs);
|
|
1653
|
+
if (read.kind === "missing") {
|
|
1654
|
+
return emitFailure(deps, options.json, [`No checkpoint exists at ${checkpointPath}.`]);
|
|
1655
|
+
}
|
|
1656
|
+
if (read.kind !== "ok")
|
|
1657
|
+
return emitFailure(deps, options.json, [read.error]);
|
|
1658
|
+
const checkpoint = read.checkpoint;
|
|
1659
|
+
const lock = await acquireConductEpicLock(resolveConductEpicLockPath(checkpointPath), lockRequest(deps), buildConductEpicLockSeams(deps));
|
|
1660
|
+
if (!lock.acquired) {
|
|
1661
|
+
return emitFailure(deps, options.json, [`The epic lock could not be acquired: ${lock.reason}`]);
|
|
1662
|
+
}
|
|
1663
|
+
const restored = await restoreIndexBranch(access, deps.fetchImpl);
|
|
1664
|
+
if (!restored.ok) {
|
|
1665
|
+
await releaseAcquired(lock);
|
|
1666
|
+
return emitFailure(deps, options.json, [`The repository index could not be restored: ${restored.error}`]);
|
|
1667
|
+
}
|
|
1668
|
+
await releaseAcquired(lock);
|
|
1669
|
+
const summary = {
|
|
1670
|
+
ok: true,
|
|
1671
|
+
epic_key: checkpoint.epic_key,
|
|
1672
|
+
epic_branch: checkpoint.epic_branch,
|
|
1673
|
+
index_restored: true,
|
|
1674
|
+
index_changed: restored.value.changed,
|
|
1675
|
+
current_base_branch: restored.value.current_base_branch,
|
|
1676
|
+
counters: { ...checkpoint.counters },
|
|
1677
|
+
needs_human: checkpoint.needs_human,
|
|
1678
|
+
tickets: checkpoint.tickets.map((ticket) => ({
|
|
1679
|
+
key: ticket.key,
|
|
1680
|
+
status: ticket.status,
|
|
1681
|
+
pr_number: ticket.pr_number,
|
|
1682
|
+
counters: { ...ticket.counters },
|
|
1683
|
+
})),
|
|
1684
|
+
};
|
|
1685
|
+
const humanLines = [
|
|
1686
|
+
`Finished ${checkpoint.epic_key} (${checkpoint.epic_branch})`,
|
|
1687
|
+
`index restore: ${restored.value.changed ? "restored" : "already restored"}`,
|
|
1688
|
+
`iterations: ${checkpoint.counters.iterations} merges: ${checkpoint.counters.merges}`,
|
|
1689
|
+
...checkpoint.tickets.map((ticket) => ` ${ticket.key} ${ticket.status} PR ${ticket.pr_number ?? "-"} ` +
|
|
1690
|
+
`spawned ${ticket.counters.sessions_spawned} plans ${ticket.counters.plan_generations_observed} ` +
|
|
1691
|
+
`merges ${ticket.counters.merge_attempts}`),
|
|
1692
|
+
`needs_human: ${checkpoint.needs_human === null ? "none" : checkpoint.needs_human.reason}`,
|
|
1693
|
+
];
|
|
1694
|
+
return emitSuccess(deps, options.json, summary, humanLines);
|
|
1695
|
+
}
|
|
1696
|
+
// ---------------------------------------------------------------------------
|
|
1697
|
+
// Entry point
|
|
1698
|
+
// ---------------------------------------------------------------------------
|
|
1699
|
+
/**
|
|
1700
|
+
* Entry point for the packaged `conduct-epic` subcommand.
|
|
1701
|
+
*
|
|
1702
|
+
* Returns a numeric exit code and never throws for user-facing failure: `0` for
|
|
1703
|
+
* help and success, `1` for everything else with a concise stderr reason.
|
|
1704
|
+
*/
|
|
1705
|
+
export async function runConductEpicCli(argv, overrides = {}) {
|
|
1706
|
+
const deps = { ...createDefaultConductEpicDeps(), ...overrides };
|
|
1707
|
+
const parsed = parseConductEpicArgs(argv);
|
|
1708
|
+
if (parsed.status === "help") {
|
|
1709
|
+
deps.log(parsed.usage);
|
|
1710
|
+
return 0;
|
|
1711
|
+
}
|
|
1712
|
+
if (parsed.status === "error") {
|
|
1713
|
+
deps.errorLog(parsed.message);
|
|
1714
|
+
// `--json` is parsed alongside the failing token, so a malformed invocation
|
|
1715
|
+
// cannot be trusted to have set it. Failures from the parser are therefore
|
|
1716
|
+
// reported on stderr only, and the exit code carries the outcome.
|
|
1717
|
+
return 1;
|
|
1718
|
+
}
|
|
1719
|
+
const options = parsed.options;
|
|
1720
|
+
switch (options.verb) {
|
|
1721
|
+
case "init":
|
|
1722
|
+
return runConductEpicInit(deps, options);
|
|
1723
|
+
case "status":
|
|
1724
|
+
return runConductEpicStatus(deps, options);
|
|
1725
|
+
case "checkpoint-set":
|
|
1726
|
+
return runConductEpicCheckpointSet(deps, options);
|
|
1727
|
+
case "spawn":
|
|
1728
|
+
return runConductEpicSpawn(deps, options);
|
|
1729
|
+
case "finish":
|
|
1730
|
+
return runConductEpicFinish(deps, options);
|
|
1731
|
+
}
|
|
1732
|
+
}
|