@bridge_gpt/mcp-server 0.2.37 → 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 +193 -16
- 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/ocapi-shape.js +23 -4
- package/build/sfcc/permissions.js +25 -6
- package/build/sfcc/read-body.js +92 -0
- package/build/sfcc/read-projection.js +6 -2
- package/build/sfcc/reads-custom-object-def.js +33 -21
- package/build/sfcc/reads-site-preference.js +20 -7
- package/build/sfcc/reads-system-object.js +11 -5
- 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/write-result.js +16 -7
- package/build/sfcc/writes-custom-object-def.js +12 -4
- 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,423 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Versioned, user-scoped, atomically-written checkpoint store for the
|
|
3
|
+
* `conduct-epic` CLI (BAPI-803).
|
|
4
|
+
*
|
|
5
|
+
* The checkpoint is the loop's only durable memory. `/conduct-epic` (BAPI-804)
|
|
6
|
+
* derives every decision from it plus a freshly probed world, so three
|
|
7
|
+
* properties are load-bearing and the rest of this module exists to hold them:
|
|
8
|
+
*
|
|
9
|
+
* - **A corrupt checkpoint is a hard error, never a fresh start.** Re-creating
|
|
10
|
+
* an unreadable file would silently discard the record of which tickets are
|
|
11
|
+
* merged and how many attempts they have consumed, and the loop would redo
|
|
12
|
+
* work it already paid for. Only `init` may create a checkpoint, and only when
|
|
13
|
+
* none exists.
|
|
14
|
+
* - **A write is all-or-nothing.** The complete next document is validated
|
|
15
|
+
* BEFORE a single byte is written, then written to a same-directory temporary
|
|
16
|
+
* file and `rename`d over the primary. A crash mid-write leaves the previous
|
|
17
|
+
* valid document, never a truncated one — and the previous document is also
|
|
18
|
+
* retained at `<EPIC>.json.prev` so an operator has something to fall back to.
|
|
19
|
+
* - **It lives outside the repository.** `~/.config/bridge/conduct/<repo>/`
|
|
20
|
+
* (honoring `XDG_CONFIG_HOME`, mode `0700`, files `0600`) resolves identically
|
|
21
|
+
* from the main checkout and from every worktree, needs no `.gitignore` rule,
|
|
22
|
+
* and cannot be committed by an agent running `git add`.
|
|
23
|
+
*
|
|
24
|
+
* The directory derivation is COPIED from `credential-store.ts`'s
|
|
25
|
+
* `getPrimaryCredentialStorePath` rather than imported, because it is driven
|
|
26
|
+
* here by injected `env`/`homedir` seams so tests can exercise both the XDG and
|
|
27
|
+
* home-relative branches without touching a real home directory.
|
|
28
|
+
*/
|
|
29
|
+
import path from "node:path";
|
|
30
|
+
/** The only checkpoint schema version this module reads or writes. */
|
|
31
|
+
export const CONDUCT_EPIC_CHECKPOINT_VERSION = 1;
|
|
32
|
+
/** Default soft deadline (seconds) written at `init`; operator-editable. */
|
|
33
|
+
export const CONDUCT_EPIC_DEFAULT_SOFT_SECONDS = 3600;
|
|
34
|
+
/** Default hard deadline (seconds) written at `init`; operator-editable. */
|
|
35
|
+
export const CONDUCT_EPIC_DEFAULT_HARD_SECONDS = 10800;
|
|
36
|
+
/** Retained journal lines per ticket. The oldest are dropped first. */
|
|
37
|
+
export const CONDUCT_EPIC_MAX_JOURNAL_LINES = 50;
|
|
38
|
+
/** The closed per-ticket status vocabulary. */
|
|
39
|
+
export const CONDUCT_EPIC_TICKET_STATUSES = [
|
|
40
|
+
"pending",
|
|
41
|
+
"in_progress",
|
|
42
|
+
"merged",
|
|
43
|
+
"done",
|
|
44
|
+
"needs_human",
|
|
45
|
+
];
|
|
46
|
+
/** Monotonic suffix source for atomic temp files (avoids same-tick collisions). */
|
|
47
|
+
let atomicWriteCounter = 0;
|
|
48
|
+
/**
|
|
49
|
+
* `~/.config/bridge/conduct/<repo>/` — the per-repository conduct-epic state
|
|
50
|
+
* directory. Copies `getPrimaryCredentialStorePath`'s two-branch derivation so
|
|
51
|
+
* conduct state sits beside the credential store and honors `XDG_CONFIG_HOME`
|
|
52
|
+
* identically.
|
|
53
|
+
*/
|
|
54
|
+
export function resolveConductEpicStateDirectory(repoName, deps) {
|
|
55
|
+
const xdg = deps.env.XDG_CONFIG_HOME;
|
|
56
|
+
const bridgeDir = xdg && xdg.trim().length > 0
|
|
57
|
+
? path.join(xdg, "bridge")
|
|
58
|
+
: path.join(deps.homedir(), ".config", "bridge");
|
|
59
|
+
return path.join(bridgeDir, "conduct", repoName);
|
|
60
|
+
}
|
|
61
|
+
/** `<state dir>/<EPIC>.json` — the default checkpoint path. */
|
|
62
|
+
export function resolveConductEpicCheckpointPath(repoName, epicKey, deps) {
|
|
63
|
+
return path.join(resolveConductEpicStateDirectory(repoName, deps), `${epicKey}.json`);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* `<EPIC>.lock` beside the checkpoint. Derived from the RESOLVED checkpoint path
|
|
67
|
+
* rather than re-derived from the repo/epic, so `--checkpoint-path` moves the
|
|
68
|
+
* lock with it and two invocations pointed at one checkpoint always contend on
|
|
69
|
+
* one lock.
|
|
70
|
+
*/
|
|
71
|
+
export function resolveConductEpicLockPath(checkpointPath) {
|
|
72
|
+
const dir = path.dirname(checkpointPath);
|
|
73
|
+
const base = path.basename(checkpointPath).replace(/\.json$/, "");
|
|
74
|
+
return path.join(dir, `${base}.lock`);
|
|
75
|
+
}
|
|
76
|
+
/** `<EPIC>.json.prev` — the retained previous document. */
|
|
77
|
+
export function resolveConductEpicBackupPath(checkpointPath) {
|
|
78
|
+
return `${checkpointPath}.prev`;
|
|
79
|
+
}
|
|
80
|
+
/** `<state dir>/<EPIC>/prompts/` — where the caller writes spawn prompt files. */
|
|
81
|
+
export function resolveConductEpicPromptsDirectory(repoName, epicKey, deps) {
|
|
82
|
+
return path.join(resolveConductEpicStateDirectory(repoName, deps), epicKey, "prompts");
|
|
83
|
+
}
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// Validation
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
function isRecord(value) {
|
|
88
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
89
|
+
}
|
|
90
|
+
/** A non-empty string. Blank-only values are rejected everywhere. */
|
|
91
|
+
function isText(value) {
|
|
92
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
93
|
+
}
|
|
94
|
+
/** A non-negative safe integer. */
|
|
95
|
+
function isCount(value) {
|
|
96
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
97
|
+
}
|
|
98
|
+
/** A non-empty string or an explicit `null` — the shape every nullable field has. */
|
|
99
|
+
function isNullableText(value) {
|
|
100
|
+
return value === null || isText(value);
|
|
101
|
+
}
|
|
102
|
+
function fail(error) {
|
|
103
|
+
return { ok: false, error };
|
|
104
|
+
}
|
|
105
|
+
function validateCounters(value, where) {
|
|
106
|
+
if (!isRecord(value))
|
|
107
|
+
return fail(`${where}.counters must be an object`);
|
|
108
|
+
for (const key of ["sessions_spawned", "plan_generations_observed", "merge_attempts"]) {
|
|
109
|
+
if (!isCount(value[key])) {
|
|
110
|
+
return fail(`${where}.counters.${key} must be a non-negative integer`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (Object.keys(value).length !== 3) {
|
|
114
|
+
return fail(`${where}.counters carries unexpected fields`);
|
|
115
|
+
}
|
|
116
|
+
return { ok: true };
|
|
117
|
+
}
|
|
118
|
+
function validateTicket(value, index) {
|
|
119
|
+
const where = `tickets[${index}]`;
|
|
120
|
+
if (!isRecord(value))
|
|
121
|
+
return fail(`${where} must be an object`);
|
|
122
|
+
if (!isText(value.key))
|
|
123
|
+
return fail(`${where}.key must be a non-empty string`);
|
|
124
|
+
if (typeof value.status !== "string" ||
|
|
125
|
+
!CONDUCT_EPIC_TICKET_STATUSES.includes(value.status)) {
|
|
126
|
+
return fail(`${where}.status must be one of: ${CONDUCT_EPIC_TICKET_STATUSES.join(", ")}`);
|
|
127
|
+
}
|
|
128
|
+
if (!isNullableText(value.branch))
|
|
129
|
+
return fail(`${where}.branch must be a non-empty string or null`);
|
|
130
|
+
if (value.pr_number !== null && !(isCount(value.pr_number) && value.pr_number > 0)) {
|
|
131
|
+
return fail(`${where}.pr_number must be a positive integer or null`);
|
|
132
|
+
}
|
|
133
|
+
if (!isNullableText(value.last_seen_head)) {
|
|
134
|
+
return fail(`${where}.last_seen_head must be a non-empty string or null`);
|
|
135
|
+
}
|
|
136
|
+
if (!isNullableText(value.last_state_change_at)) {
|
|
137
|
+
return fail(`${where}.last_state_change_at must be a timestamp string or null`);
|
|
138
|
+
}
|
|
139
|
+
if (!isNullableText(value.spawned_at)) {
|
|
140
|
+
return fail(`${where}.spawned_at must be a timestamp string or null`);
|
|
141
|
+
}
|
|
142
|
+
if (!isCount(value.respawns))
|
|
143
|
+
return fail(`${where}.respawns must be a non-negative integer`);
|
|
144
|
+
if (!isCount(value.conflict_attempts)) {
|
|
145
|
+
return fail(`${where}.conflict_attempts must be a non-negative integer`);
|
|
146
|
+
}
|
|
147
|
+
const counters = validateCounters(value.counters, where);
|
|
148
|
+
if (!counters.ok)
|
|
149
|
+
return counters;
|
|
150
|
+
if (!Array.isArray(value.journal) || value.journal.some((line) => typeof line !== "string")) {
|
|
151
|
+
return fail(`${where}.journal must be an array of strings`);
|
|
152
|
+
}
|
|
153
|
+
if (value.journal.length > CONDUCT_EPIC_MAX_JOURNAL_LINES) {
|
|
154
|
+
return fail(`${where}.journal must hold at most ${CONDUCT_EPIC_MAX_JOURNAL_LINES} entries`);
|
|
155
|
+
}
|
|
156
|
+
return { ok: true };
|
|
157
|
+
}
|
|
158
|
+
function validateCiLastPoll(value) {
|
|
159
|
+
if (value === null)
|
|
160
|
+
return { ok: true };
|
|
161
|
+
if (!isRecord(value))
|
|
162
|
+
return fail("ci_last_poll must be an object or null");
|
|
163
|
+
if (!isText(value.head_sha))
|
|
164
|
+
return fail("ci_last_poll.head_sha must be a non-empty string");
|
|
165
|
+
if (!Array.isArray(value.required) || value.required.some((n) => typeof n !== "string")) {
|
|
166
|
+
return fail("ci_last_poll.required must be an array of strings");
|
|
167
|
+
}
|
|
168
|
+
if (typeof value.results_fingerprint !== "string") {
|
|
169
|
+
return fail("ci_last_poll.results_fingerprint must be a string");
|
|
170
|
+
}
|
|
171
|
+
if (!isText(value.at))
|
|
172
|
+
return fail("ci_last_poll.at must be a timestamp string");
|
|
173
|
+
return { ok: true };
|
|
174
|
+
}
|
|
175
|
+
function validateNeedsHuman(value) {
|
|
176
|
+
if (value === null)
|
|
177
|
+
return { ok: true };
|
|
178
|
+
if (!isRecord(value))
|
|
179
|
+
return fail("needs_human must be an object or null");
|
|
180
|
+
for (const key of ["reason", "evidence", "at"]) {
|
|
181
|
+
if (typeof value[key] !== "string")
|
|
182
|
+
return fail(`needs_human.${key} must be a string`);
|
|
183
|
+
}
|
|
184
|
+
return { ok: true };
|
|
185
|
+
}
|
|
186
|
+
function validateLock(value) {
|
|
187
|
+
if (!isRecord(value))
|
|
188
|
+
return fail("lock must be an object");
|
|
189
|
+
if (!isCount(value.owner_pid))
|
|
190
|
+
return fail("lock.owner_pid must be a non-negative integer");
|
|
191
|
+
if (typeof value.host !== "string")
|
|
192
|
+
return fail("lock.host must be a string");
|
|
193
|
+
if (typeof value.acquired_at !== "string")
|
|
194
|
+
return fail("lock.acquired_at must be a string");
|
|
195
|
+
if (Object.keys(value).length !== 3)
|
|
196
|
+
return fail("lock carries unexpected fields");
|
|
197
|
+
return { ok: true };
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Validate a complete candidate document against the version-1 schema.
|
|
201
|
+
*
|
|
202
|
+
* Deliberately exhaustive rather than a top-level presence check: the failure
|
|
203
|
+
* this guards against is a partially-hand-edited checkpoint (an operator
|
|
204
|
+
* unparking a run edits this file by hand) being accepted because its top-level
|
|
205
|
+
* keys still exist, and then driving the loop from a nonsense value.
|
|
206
|
+
*/
|
|
207
|
+
export function validateConductEpicCheckpoint(value) {
|
|
208
|
+
if (!isRecord(value))
|
|
209
|
+
return fail("checkpoint must be a JSON object");
|
|
210
|
+
if (value.version !== CONDUCT_EPIC_CHECKPOINT_VERSION) {
|
|
211
|
+
return fail(`checkpoint version must be ${CONDUCT_EPIC_CHECKPOINT_VERSION}`);
|
|
212
|
+
}
|
|
213
|
+
for (const key of ["epic_key", "repo_name", "epic_branch", "base_branch_original"]) {
|
|
214
|
+
if (!isText(value[key]))
|
|
215
|
+
return fail(`${key} must be a non-empty string`);
|
|
216
|
+
}
|
|
217
|
+
for (const key of ["created_at", "updated_at"]) {
|
|
218
|
+
if (!isText(value[key]))
|
|
219
|
+
return fail(`${key} must be a timestamp string`);
|
|
220
|
+
}
|
|
221
|
+
const deadlines = value.deadlines;
|
|
222
|
+
if (!isRecord(deadlines))
|
|
223
|
+
return fail("deadlines must be an object");
|
|
224
|
+
if (!isCount(deadlines.soft_seconds))
|
|
225
|
+
return fail("deadlines.soft_seconds must be a non-negative integer");
|
|
226
|
+
if (!isCount(deadlines.hard_seconds))
|
|
227
|
+
return fail("deadlines.hard_seconds must be a non-negative integer");
|
|
228
|
+
if (!Array.isArray(value.tickets) || value.tickets.length === 0) {
|
|
229
|
+
return fail("tickets must be a non-empty array");
|
|
230
|
+
}
|
|
231
|
+
const seen = new Set();
|
|
232
|
+
for (let i = 0; i < value.tickets.length; i += 1) {
|
|
233
|
+
const result = validateTicket(value.tickets[i], i);
|
|
234
|
+
if (!result.ok)
|
|
235
|
+
return result;
|
|
236
|
+
const key = value.tickets[i].key;
|
|
237
|
+
if (seen.has(key))
|
|
238
|
+
return fail(`tickets contains a duplicate key: ${key}`);
|
|
239
|
+
seen.add(key);
|
|
240
|
+
}
|
|
241
|
+
const counters = value.counters;
|
|
242
|
+
if (!isRecord(counters))
|
|
243
|
+
return fail("counters must be an object");
|
|
244
|
+
if (!isCount(counters.iterations))
|
|
245
|
+
return fail("counters.iterations must be a non-negative integer");
|
|
246
|
+
if (!isCount(counters.merges))
|
|
247
|
+
return fail("counters.merges must be a non-negative integer");
|
|
248
|
+
const needsHuman = validateNeedsHuman(value.needs_human);
|
|
249
|
+
if (!needsHuman.ok)
|
|
250
|
+
return needsHuman;
|
|
251
|
+
const ciLastPoll = validateCiLastPoll(value.ci_last_poll);
|
|
252
|
+
if (!ciLastPoll.ok)
|
|
253
|
+
return ciLastPoll;
|
|
254
|
+
const lock = validateLock(value.lock);
|
|
255
|
+
if (!lock.ok)
|
|
256
|
+
return lock;
|
|
257
|
+
return { ok: true };
|
|
258
|
+
}
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
// Read / create
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
function errorCode(err) {
|
|
263
|
+
const code = err?.code;
|
|
264
|
+
return typeof code === "string" ? code : undefined;
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Read and validate the checkpoint at `checkpointPath`.
|
|
268
|
+
*
|
|
269
|
+
* `ENOENT` is the ONLY input that produces `missing`. An unreadable file
|
|
270
|
+
* (permissions, an I/O error) is `unreadable`, not `missing` — reporting
|
|
271
|
+
* "no checkpoint" for a file that exists but cannot be read would let `init`
|
|
272
|
+
* conclude the epic was never started and re-provision over live state.
|
|
273
|
+
*/
|
|
274
|
+
export async function readConductEpicCheckpoint(checkpointPath, fs) {
|
|
275
|
+
let raw;
|
|
276
|
+
try {
|
|
277
|
+
raw = await fs.readFile(checkpointPath);
|
|
278
|
+
}
|
|
279
|
+
catch (err) {
|
|
280
|
+
if (errorCode(err) === "ENOENT")
|
|
281
|
+
return { kind: "missing" };
|
|
282
|
+
return { kind: "unreadable", error: `checkpoint at ${checkpointPath} could not be read` };
|
|
283
|
+
}
|
|
284
|
+
let parsed;
|
|
285
|
+
try {
|
|
286
|
+
parsed = JSON.parse(raw);
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
return { kind: "corrupt", error: `checkpoint at ${checkpointPath} is not valid JSON` };
|
|
290
|
+
}
|
|
291
|
+
// Version is checked before the schema so a future version reports as such
|
|
292
|
+
// rather than as a pile of unrelated field errors.
|
|
293
|
+
if (isRecord(parsed) && parsed.version !== CONDUCT_EPIC_CHECKPOINT_VERSION) {
|
|
294
|
+
return {
|
|
295
|
+
kind: "unsupported-version",
|
|
296
|
+
error: `checkpoint at ${checkpointPath} has version ${JSON.stringify(parsed.version)}; ` +
|
|
297
|
+
`this CLI only understands version ${CONDUCT_EPIC_CHECKPOINT_VERSION}`,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
const validation = validateConductEpicCheckpoint(parsed);
|
|
301
|
+
if (!validation.ok) {
|
|
302
|
+
return { kind: "invalid", error: `checkpoint at ${checkpointPath} is invalid: ${validation.error}` };
|
|
303
|
+
}
|
|
304
|
+
return { kind: "ok", checkpoint: parsed };
|
|
305
|
+
}
|
|
306
|
+
/** Build the initial document: every ticket `pending`, every counter zero. */
|
|
307
|
+
export function createInitialConductEpicCheckpoint(input) {
|
|
308
|
+
return {
|
|
309
|
+
version: CONDUCT_EPIC_CHECKPOINT_VERSION,
|
|
310
|
+
epic_key: input.epicKey,
|
|
311
|
+
repo_name: input.repoName,
|
|
312
|
+
epic_branch: input.epicBranch,
|
|
313
|
+
base_branch_original: input.baseBranchOriginal,
|
|
314
|
+
created_at: input.now,
|
|
315
|
+
updated_at: input.now,
|
|
316
|
+
deadlines: input.deadlines ?? {
|
|
317
|
+
soft_seconds: CONDUCT_EPIC_DEFAULT_SOFT_SECONDS,
|
|
318
|
+
hard_seconds: CONDUCT_EPIC_DEFAULT_HARD_SECONDS,
|
|
319
|
+
},
|
|
320
|
+
tickets: input.ticketKeys.map((key) => ({
|
|
321
|
+
key,
|
|
322
|
+
status: "pending",
|
|
323
|
+
branch: null,
|
|
324
|
+
pr_number: null,
|
|
325
|
+
last_seen_head: null,
|
|
326
|
+
last_state_change_at: null,
|
|
327
|
+
spawned_at: null,
|
|
328
|
+
respawns: 0,
|
|
329
|
+
conflict_attempts: 0,
|
|
330
|
+
counters: { sessions_spawned: 0, plan_generations_observed: 0, merge_attempts: 0 },
|
|
331
|
+
journal: [],
|
|
332
|
+
})),
|
|
333
|
+
counters: { iterations: 0, merges: 0 },
|
|
334
|
+
needs_human: null,
|
|
335
|
+
ci_last_poll: null,
|
|
336
|
+
lock: input.lock,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Append one journal line to `ticket`, keeping only the newest
|
|
341
|
+
* {@link CONDUCT_EPIC_MAX_JOURNAL_LINES}. Returns a new ticket object; the input
|
|
342
|
+
* is not mutated, so a caller that fails validation later cannot leave a
|
|
343
|
+
* half-updated document behind.
|
|
344
|
+
*/
|
|
345
|
+
export function appendTicketJournal(ticket, line) {
|
|
346
|
+
const journal = [...ticket.journal, line];
|
|
347
|
+
return {
|
|
348
|
+
...ticket,
|
|
349
|
+
journal: journal.length > CONDUCT_EPIC_MAX_JOURNAL_LINES
|
|
350
|
+
? journal.slice(journal.length - CONDUCT_EPIC_MAX_JOURNAL_LINES)
|
|
351
|
+
: journal,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Validate, then write `checkpoint` to `checkpointPath` atomically.
|
|
356
|
+
*
|
|
357
|
+
* Order matters and is asserted by tests:
|
|
358
|
+
*
|
|
359
|
+
* 1. validate the COMPLETE next document — an invalid document never reaches
|
|
360
|
+
* the filesystem, so a rejected write cannot damage the existing state;
|
|
361
|
+
* 2. create the parent directory `0700`;
|
|
362
|
+
* 3. write `<path>.tmp-<pid>-<n>` `0600` in the SAME directory (a cross-device
|
|
363
|
+
* temp file would make step 5 a copy, which is not atomic);
|
|
364
|
+
* 4. copy the previous VALID primary to `<path>.prev`;
|
|
365
|
+
* 5. `rename` the temp file over the primary.
|
|
366
|
+
*
|
|
367
|
+
* On failure only the temporary artifact is removed. The pre-existing primary is
|
|
368
|
+
* never deleted, truncated, or recreated — including when it is itself corrupt,
|
|
369
|
+
* because "repair by overwriting" is exactly how the operator's record of a
|
|
370
|
+
* half-finished epic would disappear.
|
|
371
|
+
*/
|
|
372
|
+
export async function writeConductEpicCheckpointAtomic(checkpointPath, checkpoint, fs, options = {}) {
|
|
373
|
+
const validation = validateConductEpicCheckpoint(checkpoint);
|
|
374
|
+
if (!validation.ok) {
|
|
375
|
+
return { ok: false, error: `refusing to write an invalid checkpoint: ${validation.error}` };
|
|
376
|
+
}
|
|
377
|
+
const dir = path.dirname(checkpointPath);
|
|
378
|
+
try {
|
|
379
|
+
await fs.mkdir(dir, { recursive: true });
|
|
380
|
+
if (!options.skipChmod)
|
|
381
|
+
await fs.chmod(dir, 0o700);
|
|
382
|
+
}
|
|
383
|
+
catch {
|
|
384
|
+
return { ok: false, error: `could not prepare the checkpoint directory ${dir}` };
|
|
385
|
+
}
|
|
386
|
+
const content = `${JSON.stringify(checkpoint, null, 2)}\n`;
|
|
387
|
+
const tmpPath = path.join(dir, `${path.basename(checkpointPath)}.tmp-${process.pid}-${atomicWriteCounter++}`);
|
|
388
|
+
try {
|
|
389
|
+
await fs.writeFile(tmpPath, content, { mode: 0o600 });
|
|
390
|
+
if (!options.skipChmod)
|
|
391
|
+
await fs.chmod(tmpPath, 0o600);
|
|
392
|
+
// Retain the previous document — but only if it is a document worth
|
|
393
|
+
// retaining. Copying corrupt bytes over a good `.prev` would destroy the
|
|
394
|
+
// last recoverable state at precisely the moment it is needed.
|
|
395
|
+
let previous = null;
|
|
396
|
+
try {
|
|
397
|
+
previous = await fs.readFile(checkpointPath);
|
|
398
|
+
}
|
|
399
|
+
catch {
|
|
400
|
+
previous = null;
|
|
401
|
+
}
|
|
402
|
+
if (previous !== null) {
|
|
403
|
+
let previousIsValid = false;
|
|
404
|
+
try {
|
|
405
|
+
previousIsValid = validateConductEpicCheckpoint(JSON.parse(previous)).ok;
|
|
406
|
+
}
|
|
407
|
+
catch {
|
|
408
|
+
previousIsValid = false;
|
|
409
|
+
}
|
|
410
|
+
if (previousIsValid) {
|
|
411
|
+
await fs.writeFile(resolveConductEpicBackupPath(checkpointPath), previous, { mode: 0o600 });
|
|
412
|
+
if (!options.skipChmod)
|
|
413
|
+
await fs.chmod(resolveConductEpicBackupPath(checkpointPath), 0o600);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
await fs.rename(tmpPath, checkpointPath);
|
|
417
|
+
return { ok: true, path: checkpointPath };
|
|
418
|
+
}
|
|
419
|
+
catch {
|
|
420
|
+
await fs.unlink(tmpPath).catch(() => undefined);
|
|
421
|
+
return { ok: false, error: `could not write the checkpoint at ${checkpointPath}` };
|
|
422
|
+
}
|
|
423
|
+
}
|