@bridge_gpt/mcp-server 0.1.14 → 0.1.17
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 +80 -0
- package/build/agent-launchers/claude.js +85 -0
- package/build/agent-launchers/index.js +17 -0
- package/build/agent-launchers/types.js +1 -0
- package/build/agent-registry.js +68 -0
- package/build/chain-orchestrator.js +1150 -0
- package/build/chain-utils.js +68 -0
- package/build/commands.generated.js +3 -1
- package/build/doctor.js +172 -0
- package/build/fetch-stub.js +139 -0
- package/build/index.js +321 -38
- package/build/pipeline-orchestrator.js +57 -0
- package/build/pipelines.generated.js +133 -4
- package/build/schedule-run.js +951 -0
- package/build/schedule-store.js +132 -0
- package/build/scheduler-backends/at-fallback.js +144 -0
- package/build/scheduler-backends/escaping.js +113 -0
- package/build/scheduler-backends/index.js +72 -0
- package/build/scheduler-backends/launchd.js +216 -0
- package/build/scheduler-backends/systemd-user.js +237 -0
- package/build/scheduler-backends/task-scheduler.js +219 -0
- package/build/scheduler-backends/types.js +23 -0
- package/build/start-tickets-prereqs.js +346 -0
- package/build/start-tickets.js +1270 -0
- package/build/version.generated.js +1 -1
- package/package.json +9 -7
- package/pipelines/full-automation.json +47 -0
- package/pipelines/idea-to-ticket.json +71 -0
- package/smoke-test/SMOKE-TEST.md +509 -0
- package/smoke-test/smoke-test-mcp.md +23 -0
|
@@ -0,0 +1,1150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chain orchestrator — server-side sequential execution for the new
|
|
3
|
+
* ``run_full_automation`` / ``resume_full_automation`` MCP tools (BAPI-326).
|
|
4
|
+
*
|
|
5
|
+
* Design rules (mirrors pipeline-orchestrator.ts):
|
|
6
|
+
* - Never imports ``mcp_server/src/index.ts``. All runtime dependencies are
|
|
7
|
+
* injected through ``ChainOrchestratorDeps`` (which extends
|
|
8
|
+
* ``PipelineOrchestratorDeps`` with the chain-recipe registry).
|
|
9
|
+
* - Chain runs are persisted server-side via the Bridge API routes at
|
|
10
|
+
* ``/jira/chain-runs/runs``. Every state transition extends the idle TTL.
|
|
11
|
+
* - The chain drives existing child pipelines via ``runPipeline`` /
|
|
12
|
+
* ``resumePipeline``. Only compact per-stage metadata + heuristic
|
|
13
|
+
* (no-LLM) summaries are persisted in ``chain_runs.stages`` — never the
|
|
14
|
+
* full child pipeline ``results`` arrays.
|
|
15
|
+
* - Stage 3 (start-tickets) is special-cased: the orchestrator does NOT call
|
|
16
|
+
* ``runPipeline``; it emits an ``agent_task`` envelope naming the exact
|
|
17
|
+
* ``/start-tickets ...`` command for the host agent to invoke in-session.
|
|
18
|
+
* - Cross-stage variables (created_ticket_keys -> review fan-out ->
|
|
19
|
+
* reviewed_ticket_keys -> start-tickets) are resolved server-side. Missing
|
|
20
|
+
* cross-stage variables produce a typed ``VALIDATION`` failure — never a
|
|
21
|
+
* placeholder value.
|
|
22
|
+
*/
|
|
23
|
+
import { runPipeline, resumePipeline, peekPipelineRun } from "./pipeline-orchestrator.js";
|
|
24
|
+
import { toStringVariable, substituteChainValue } from "./chain-utils.js";
|
|
25
|
+
const CHAIN_NAME = "full-automation";
|
|
26
|
+
const START_TICKETS_PIPELINE = "start-tickets";
|
|
27
|
+
// Display titles for preambles, keyed by child pipeline name.
|
|
28
|
+
const STAGE_TITLES = {
|
|
29
|
+
"idea-to-ticket": "Idea to ticket",
|
|
30
|
+
"review-ticket": "Review ticket",
|
|
31
|
+
"start-tickets": "Start tickets",
|
|
32
|
+
};
|
|
33
|
+
export class ChainPersistenceError extends Error {
|
|
34
|
+
code;
|
|
35
|
+
constructor(code, message) {
|
|
36
|
+
super(message);
|
|
37
|
+
this.code = code;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export function createChainPersistenceClient(deps) {
|
|
41
|
+
const headers = {
|
|
42
|
+
"X-API-Key": deps.apiKey,
|
|
43
|
+
"Content-Type": "application/json",
|
|
44
|
+
};
|
|
45
|
+
const base = deps.baseUrl.replace(/\/+$/, "");
|
|
46
|
+
function urlFor(suffix, query) {
|
|
47
|
+
const u = new URL(`${base}/jira/chain-runs${suffix}`);
|
|
48
|
+
if (query) {
|
|
49
|
+
for (const [k, v] of Object.entries(query)) {
|
|
50
|
+
u.searchParams.set(k, v);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return u.toString();
|
|
54
|
+
}
|
|
55
|
+
async function readError(resp) {
|
|
56
|
+
const raw = await resp.text();
|
|
57
|
+
let codeFromBody = null;
|
|
58
|
+
let messageFromBody = null;
|
|
59
|
+
try {
|
|
60
|
+
const parsed = JSON.parse(raw);
|
|
61
|
+
const detail = parsed?.detail;
|
|
62
|
+
if (detail && typeof detail === "object") {
|
|
63
|
+
if (typeof detail.error_code === "string") {
|
|
64
|
+
codeFromBody = detail.error_code;
|
|
65
|
+
}
|
|
66
|
+
if (typeof detail.error === "string") {
|
|
67
|
+
messageFromBody = detail.error;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// not JSON
|
|
73
|
+
}
|
|
74
|
+
const codeFromStatus = resp.status === 404
|
|
75
|
+
? "NOT_FOUND"
|
|
76
|
+
: resp.status === 410
|
|
77
|
+
? "EXPIRED"
|
|
78
|
+
: resp.status === 403
|
|
79
|
+
? "REPO_MISMATCH"
|
|
80
|
+
: resp.status === 400 || resp.status === 422
|
|
81
|
+
? "VALIDATION"
|
|
82
|
+
: "TOOL_ERROR";
|
|
83
|
+
return {
|
|
84
|
+
code: codeFromBody ?? codeFromStatus,
|
|
85
|
+
message: messageFromBody ?? (raw || `HTTP ${resp.status}`),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
async function createRun(input) {
|
|
89
|
+
const resp = await fetch(urlFor("/runs"), {
|
|
90
|
+
method: "POST",
|
|
91
|
+
headers,
|
|
92
|
+
body: JSON.stringify({
|
|
93
|
+
repo_name: deps.repoName,
|
|
94
|
+
chain_name: input.chain_name,
|
|
95
|
+
args: input.args,
|
|
96
|
+
current_stage_index: input.current_stage_index,
|
|
97
|
+
stages: input.stages,
|
|
98
|
+
status: input.status,
|
|
99
|
+
ttl_seconds: input.ttl_seconds,
|
|
100
|
+
}),
|
|
101
|
+
});
|
|
102
|
+
if (!resp.ok) {
|
|
103
|
+
const err = await readError(resp);
|
|
104
|
+
throw new ChainPersistenceError(err.code, err.message);
|
|
105
|
+
}
|
|
106
|
+
return (await resp.json());
|
|
107
|
+
}
|
|
108
|
+
async function getRun(chainRunId) {
|
|
109
|
+
const resp = await fetch(urlFor(`/runs/${encodeURIComponent(chainRunId)}`, {
|
|
110
|
+
repo_name: deps.repoName,
|
|
111
|
+
}), { headers: { "X-API-Key": deps.apiKey } });
|
|
112
|
+
if (!resp.ok) {
|
|
113
|
+
const err = await readError(resp);
|
|
114
|
+
throw new ChainPersistenceError(err.code, err.message);
|
|
115
|
+
}
|
|
116
|
+
return (await resp.json());
|
|
117
|
+
}
|
|
118
|
+
async function patchRun(chainRunId, body) {
|
|
119
|
+
const resp = await fetch(urlFor(`/runs/${encodeURIComponent(chainRunId)}`), {
|
|
120
|
+
method: "PATCH",
|
|
121
|
+
headers,
|
|
122
|
+
body: JSON.stringify({ repo_name: deps.repoName, ...body }),
|
|
123
|
+
});
|
|
124
|
+
if (!resp.ok) {
|
|
125
|
+
const err = await readError(resp);
|
|
126
|
+
throw new ChainPersistenceError(err.code, err.message);
|
|
127
|
+
}
|
|
128
|
+
return (await resp.json());
|
|
129
|
+
}
|
|
130
|
+
async function listRuns(status) {
|
|
131
|
+
const query = { repo_name: deps.repoName };
|
|
132
|
+
if (status)
|
|
133
|
+
query.status = status;
|
|
134
|
+
const resp = await fetch(urlFor("/runs", query), {
|
|
135
|
+
headers: { "X-API-Key": deps.apiKey },
|
|
136
|
+
});
|
|
137
|
+
if (!resp.ok) {
|
|
138
|
+
const err = await readError(resp);
|
|
139
|
+
throw new ChainPersistenceError(err.code, err.message);
|
|
140
|
+
}
|
|
141
|
+
const body = (await resp.json());
|
|
142
|
+
return body.runs ?? [];
|
|
143
|
+
}
|
|
144
|
+
async function deleteRun(chainRunId) {
|
|
145
|
+
const resp = await fetch(urlFor(`/runs/${encodeURIComponent(chainRunId)}`, {
|
|
146
|
+
repo_name: deps.repoName,
|
|
147
|
+
}), {
|
|
148
|
+
method: "DELETE",
|
|
149
|
+
headers: { "X-API-Key": deps.apiKey },
|
|
150
|
+
});
|
|
151
|
+
if (!resp.ok) {
|
|
152
|
+
const err = await readError(resp);
|
|
153
|
+
throw new ChainPersistenceError(err.code, err.message);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return { createRun, getRun, patchRun, listRuns, deleteRun };
|
|
157
|
+
}
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
// Pure helpers
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
function normalizeAutoApprove(value) {
|
|
162
|
+
if (value === true || value === "true")
|
|
163
|
+
return true;
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
const JIRA_KEY_EXACT = /^[A-Z][A-Z0-9]+-\d+$/;
|
|
167
|
+
/**
|
|
168
|
+
* Extract created Jira ticket keys from a STRUCTURED payload (an object/array
|
|
169
|
+
* holding `created_ticket_keys` / `ticket_key` / etc.). Collects values from the
|
|
170
|
+
* preferred key fields in a fixed preference order, validates each against
|
|
171
|
+
* `JIRA_KEY_EXACT`, and de-dupes in encounter order. There is deliberately NO
|
|
172
|
+
* free-text regex fallback: pass the parsed upload-step payload (via
|
|
173
|
+
* `extractCreatedKeysFromCompletedChild`), never a whole pipeline transcript, so
|
|
174
|
+
* keys merely *mentioned* by an earlier step (e.g. duplicate-detection search
|
|
175
|
+
* hits) can never be mistaken for this run's creations. A string / no-field
|
|
176
|
+
* input returns `[]`, which the caller treats as fail-closed.
|
|
177
|
+
*/
|
|
178
|
+
export function extractCreatedTicketKeys(source) {
|
|
179
|
+
const preferredKeys = [
|
|
180
|
+
"created_ticket_keys",
|
|
181
|
+
"ticket_keys",
|
|
182
|
+
"child_ticket_keys",
|
|
183
|
+
"epic_key",
|
|
184
|
+
"ticket_key",
|
|
185
|
+
];
|
|
186
|
+
const buckets = {};
|
|
187
|
+
for (const k of preferredKeys)
|
|
188
|
+
buckets[k] = [];
|
|
189
|
+
const visit = (value) => {
|
|
190
|
+
if (Array.isArray(value)) {
|
|
191
|
+
for (const item of value)
|
|
192
|
+
visit(item);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (value && typeof value === "object") {
|
|
196
|
+
const obj = value;
|
|
197
|
+
for (const key of preferredKeys) {
|
|
198
|
+
if (key in obj) {
|
|
199
|
+
const v = obj[key];
|
|
200
|
+
if (typeof v === "string") {
|
|
201
|
+
buckets[key].push(v);
|
|
202
|
+
}
|
|
203
|
+
else if (Array.isArray(v)) {
|
|
204
|
+
for (const elem of v) {
|
|
205
|
+
if (typeof elem === "string")
|
|
206
|
+
buckets[key].push(elem);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
for (const v of Object.values(obj))
|
|
212
|
+
visit(v);
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
visit(source);
|
|
216
|
+
const ordered = [];
|
|
217
|
+
for (const key of preferredKeys) {
|
|
218
|
+
for (const candidate of buckets[key]) {
|
|
219
|
+
if (JIRA_KEY_EXACT.test(candidate))
|
|
220
|
+
ordered.push(candidate);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
const seen = new Set();
|
|
224
|
+
const result = [];
|
|
225
|
+
for (const key of ordered) {
|
|
226
|
+
if (!seen.has(key)) {
|
|
227
|
+
seen.add(key);
|
|
228
|
+
result.push(key);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return result;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Pull a JSON object out of an agent's free-text result. Prefers the LAST fenced
|
|
235
|
+
* ```json … ``` block (the structured payload the upload step is told to emit as
|
|
236
|
+
* its final content); otherwise tries the last balanced `{ … }` slice that
|
|
237
|
+
* `JSON.parse`s. Returns the parsed value or `null`.
|
|
238
|
+
*/
|
|
239
|
+
export function parseStructuredAgentResult(text) {
|
|
240
|
+
if (typeof text !== "string" || text.trim() === "")
|
|
241
|
+
return null;
|
|
242
|
+
// Prefer fenced ```json blocks; take the last one.
|
|
243
|
+
const fences = [...text.matchAll(/```(?:json)?\s*([\s\S]*?)```/gi)];
|
|
244
|
+
for (let i = fences.length - 1; i >= 0; i--) {
|
|
245
|
+
const parsed = tryParseJson(fences[i][1]);
|
|
246
|
+
if (parsed !== undefined)
|
|
247
|
+
return parsed;
|
|
248
|
+
}
|
|
249
|
+
// Fallback: try each `{ … }` candidate. For each opening brace (last first),
|
|
250
|
+
// walk closing braces from the last back toward it, so a balanced object is
|
|
251
|
+
// found even when a stray `}` trails the valid JSON (using the loop-local
|
|
252
|
+
// `end`, not a single text-wide `lastIndexOf("}")`).
|
|
253
|
+
for (let start = text.lastIndexOf("{"); start >= 0; start = text.lastIndexOf("{", start - 1)) {
|
|
254
|
+
for (let end = text.lastIndexOf("}"); end > start; end = text.lastIndexOf("}", end - 1)) {
|
|
255
|
+
const parsed = tryParseJson(text.slice(start, end + 1));
|
|
256
|
+
if (parsed !== undefined)
|
|
257
|
+
return parsed;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
function tryParseJson(raw) {
|
|
263
|
+
try {
|
|
264
|
+
return JSON.parse(raw.trim());
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
return undefined;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Derive the keys created by a completed idea-to-ticket child run AUTHORITATIVELY
|
|
272
|
+
* from the upload step's structured output — never by scanning the whole
|
|
273
|
+
* transcript. The upload step (`upload-and-track.md`) is the LAST step of the
|
|
274
|
+
* idea-to-ticket pipeline and the chain never passes `skip_steps`, so it is the
|
|
275
|
+
* final entry of `results`. Its `result` is parsed for a `{ created_ticket_keys }`
|
|
276
|
+
* payload. Returns `[]` (→ caller fails closed) if the payload is absent or
|
|
277
|
+
* malformed, so dedup-search hits from earlier steps can never become targets.
|
|
278
|
+
*/
|
|
279
|
+
export function extractCreatedKeysFromCompletedChild(childEnv) {
|
|
280
|
+
const results = childEnv.results;
|
|
281
|
+
if (!Array.isArray(results) || results.length === 0)
|
|
282
|
+
return [];
|
|
283
|
+
const uploadResult = results[results.length - 1]?.result;
|
|
284
|
+
const payload = typeof uploadResult === "string"
|
|
285
|
+
? parseStructuredAgentResult(uploadResult)
|
|
286
|
+
: uploadResult;
|
|
287
|
+
return extractCreatedTicketKeys(payload);
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* True when ``token`` appears in a failed child envelope's failure-text fields:
|
|
291
|
+
* the top-level ``error`` message and each step result's ``error`` / string
|
|
292
|
+
* ``result``. Deliberately does NOT scan echoed input, args, or other free-form
|
|
293
|
+
* fields, so a coincidental token elsewhere cannot misclassify the failure.
|
|
294
|
+
*/
|
|
295
|
+
function failedEnvelopeMentions(env, token) {
|
|
296
|
+
if (env.status !== "failed")
|
|
297
|
+
return false;
|
|
298
|
+
if (typeof env.error === "string" && env.error.includes(token))
|
|
299
|
+
return true;
|
|
300
|
+
for (const r of env.results ?? []) {
|
|
301
|
+
if (typeof r.error === "string" && r.error.includes(token))
|
|
302
|
+
return true;
|
|
303
|
+
if (typeof r.result === "string" && r.result.includes(token))
|
|
304
|
+
return true;
|
|
305
|
+
}
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
/** Heuristic, no-LLM one-line stage summary. */
|
|
309
|
+
export function summarizeStageCompletion(pipelineName, keys) {
|
|
310
|
+
const joined = keys.join(", ");
|
|
311
|
+
if (pipelineName === "idea-to-ticket") {
|
|
312
|
+
return `Created tickets: ${joined}`;
|
|
313
|
+
}
|
|
314
|
+
if (pipelineName === "review-ticket") {
|
|
315
|
+
return `Reviewed tickets: ${joined}`;
|
|
316
|
+
}
|
|
317
|
+
if (pipelineName === START_TICKETS_PIPELINE) {
|
|
318
|
+
return `Spawned worktrees for ${keys.length} ticket(s): ${joined}`;
|
|
319
|
+
}
|
|
320
|
+
return `Completed ${pipelineName}: ${joined}`;
|
|
321
|
+
}
|
|
322
|
+
function stageTitle(pipelineName) {
|
|
323
|
+
return STAGE_TITLES[pipelineName] ?? pipelineName;
|
|
324
|
+
}
|
|
325
|
+
/** Build the markdown preamble for the active (or final) stage. */
|
|
326
|
+
export function buildPreamble(recipe, stageIndex, stages) {
|
|
327
|
+
const total = recipe.stages.length;
|
|
328
|
+
const lines = [];
|
|
329
|
+
if (stageIndex >= total) {
|
|
330
|
+
lines.push(`### Full automation complete (${total} of ${total} stages)`);
|
|
331
|
+
}
|
|
332
|
+
else {
|
|
333
|
+
const title = stageTitle(recipe.stages[stageIndex].pipeline_name);
|
|
334
|
+
lines.push(`### Stage ${stageIndex + 1} of ${total} — ${title}`);
|
|
335
|
+
}
|
|
336
|
+
const priorSummaries = [];
|
|
337
|
+
const upTo = Math.min(stageIndex, stages.length);
|
|
338
|
+
for (let i = 0; i < upTo; i++) {
|
|
339
|
+
const s = stages[i];
|
|
340
|
+
if (s && typeof s.summary === "string" && s.summary.trim() !== "") {
|
|
341
|
+
priorSummaries.push(`- ${s.summary}`);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
if (priorSummaries.length > 0) {
|
|
345
|
+
lines.push("", "Completed so far:", ...priorSummaries);
|
|
346
|
+
}
|
|
347
|
+
return lines.join("\n");
|
|
348
|
+
}
|
|
349
|
+
const PRIOR_RESULT_MAX_CHARS = 2000;
|
|
350
|
+
/**
|
|
351
|
+
* Build a compact, read-only appendix surfacing the OUTPUTS of prior server-side
|
|
352
|
+
* `mcp_call` steps (e.g. `get_project_standards`) so the chain's driving agent
|
|
353
|
+
* can see context it would otherwise miss — the chain forwards only the
|
|
354
|
+
* agent_task `instruction`, unlike the standalone pipeline path whose envelope
|
|
355
|
+
* carries `results`. Skips agent_task results (the agent produced those) and
|
|
356
|
+
* failed steps; truncates each output. Returns "" when there is nothing to add,
|
|
357
|
+
* so the appendix is purely additive.
|
|
358
|
+
*/
|
|
359
|
+
function buildPriorResultsAppendix(results) {
|
|
360
|
+
if (!Array.isArray(results))
|
|
361
|
+
return "";
|
|
362
|
+
const lines = [];
|
|
363
|
+
for (const r of results) {
|
|
364
|
+
if (r.type !== "mcp_call" || !r.ok)
|
|
365
|
+
continue;
|
|
366
|
+
const raw = typeof r.result === "string" ? r.result : stringifyResult(r.result);
|
|
367
|
+
if (raw.trim() === "")
|
|
368
|
+
continue;
|
|
369
|
+
const truncated = raw.length > PRIOR_RESULT_MAX_CHARS
|
|
370
|
+
? `${raw.slice(0, PRIOR_RESULT_MAX_CHARS)}… [truncated]`
|
|
371
|
+
: raw;
|
|
372
|
+
lines.push(`- \`${r.tool ?? r.description}\`: ${truncated}`);
|
|
373
|
+
}
|
|
374
|
+
if (lines.length === 0)
|
|
375
|
+
return "";
|
|
376
|
+
return (`\n\n## Prior step outputs (read-only context)\n` +
|
|
377
|
+
`Server-side step outputs from earlier in this pipeline run that you cannot ` +
|
|
378
|
+
`fetch yourself:\n${lines.join("\n")}`);
|
|
379
|
+
}
|
|
380
|
+
function stringifyResult(value) {
|
|
381
|
+
if (value === undefined || value === null)
|
|
382
|
+
return "";
|
|
383
|
+
try {
|
|
384
|
+
return JSON.stringify(value);
|
|
385
|
+
}
|
|
386
|
+
catch {
|
|
387
|
+
return String(value);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
// ---------------------------------------------------------------------------
|
|
391
|
+
// Envelope builders
|
|
392
|
+
// ---------------------------------------------------------------------------
|
|
393
|
+
function failedEnvelope(code, message, extras = {}) {
|
|
394
|
+
return {
|
|
395
|
+
status: "failed",
|
|
396
|
+
error_code: code,
|
|
397
|
+
error: message,
|
|
398
|
+
...extras,
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
function buildNeedsAgentTaskEnvelope(args) {
|
|
402
|
+
const env = {
|
|
403
|
+
status: "needs_agent_task",
|
|
404
|
+
chain_run_id: args.chainRunId,
|
|
405
|
+
chain_stage: args.chainStage,
|
|
406
|
+
chain_step: args.chainStep,
|
|
407
|
+
chain_total: args.chainTotal,
|
|
408
|
+
preamble: args.preamble,
|
|
409
|
+
next_action: { kind: "agent_task", instruction: args.instruction },
|
|
410
|
+
};
|
|
411
|
+
if (args.pipelineRunId)
|
|
412
|
+
env.pipeline_run_id = args.pipelineRunId;
|
|
413
|
+
if (typeof args.pipelineStep === "number")
|
|
414
|
+
env.pipeline_step = args.pipelineStep;
|
|
415
|
+
if (typeof args.pipelineTotal === "number")
|
|
416
|
+
env.pipeline_total = args.pipelineTotal;
|
|
417
|
+
return env;
|
|
418
|
+
}
|
|
419
|
+
function buildCompletedEnvelope(recipe, row) {
|
|
420
|
+
const total = recipe.stages.length;
|
|
421
|
+
return {
|
|
422
|
+
status: "completed",
|
|
423
|
+
chain_run_id: row.chain_run_id,
|
|
424
|
+
chain_stage: CHAIN_NAME,
|
|
425
|
+
chain_step: total,
|
|
426
|
+
chain_total: total,
|
|
427
|
+
preamble: buildPreamble(recipe, total, row.stages),
|
|
428
|
+
next_action: { kind: "complete" },
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
/** Patch the chain + current stage as failed and return a VALIDATION envelope. */
|
|
432
|
+
async function failChainValidation(persistence, recipe, row, message) {
|
|
433
|
+
const idx = row.current_stage_index;
|
|
434
|
+
const stages = cloneStages(row.stages);
|
|
435
|
+
if (stages[idx])
|
|
436
|
+
stages[idx].status = "failed";
|
|
437
|
+
try {
|
|
438
|
+
await persistence.patchRun(row.chain_run_id, {
|
|
439
|
+
stages,
|
|
440
|
+
status: "failed",
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
catch {
|
|
444
|
+
// best-effort — the failure is what matters
|
|
445
|
+
}
|
|
446
|
+
return failedEnvelope("VALIDATION", message, {
|
|
447
|
+
chain_run_id: row.chain_run_id,
|
|
448
|
+
chain_stage: recipe.stages[idx]?.pipeline_name,
|
|
449
|
+
chain_step: idx + 1,
|
|
450
|
+
chain_total: recipe.stages.length,
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
function cloneStages(stages) {
|
|
454
|
+
return stages.map((s) => ({ ...s }));
|
|
455
|
+
}
|
|
456
|
+
/** True when ``value`` is a string whose trimmed content is non-empty. */
|
|
457
|
+
function isNonEmpty(value) {
|
|
458
|
+
return typeof value === "string" && value.trim() !== "";
|
|
459
|
+
}
|
|
460
|
+
const SLUG_STOP_WORDS = new Set([
|
|
461
|
+
"the", "a", "an", "of", "to", "and", "or", "for", "in", "on", "with",
|
|
462
|
+
]);
|
|
463
|
+
/**
|
|
464
|
+
* Derive a kebab-case ``slug`` from an idea — the same shape the standalone
|
|
465
|
+
* idea-to-ticket command produces (lowercase, ~6-8 meaningful words, stop-words
|
|
466
|
+
* skipped, non-alphanumerics dropped, truncated to ~60 chars). Pure and
|
|
467
|
+
* deterministic so the chain can supply idea-to-ticket's required ``slug``
|
|
468
|
+
* variable without consulting the agent.
|
|
469
|
+
*/
|
|
470
|
+
export function deriveSlug(idea) {
|
|
471
|
+
const words = idea
|
|
472
|
+
.toLowerCase()
|
|
473
|
+
.replace(/[^a-z0-9\s-]/g, " ")
|
|
474
|
+
.split(/[\s-]+/)
|
|
475
|
+
.filter((w) => w.length > 0 && !SLUG_STOP_WORDS.has(w));
|
|
476
|
+
let slug = words.slice(0, 8).join("-");
|
|
477
|
+
if (slug.length > 60) {
|
|
478
|
+
slug = slug.slice(0, 60).replace(/-+$/g, "");
|
|
479
|
+
}
|
|
480
|
+
return slug || "idea";
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* Derive idea-to-ticket's required ``run_id`` (the per-run artifact-directory
|
|
484
|
+
* id) from the already-unique ``chain_run_id``. Deterministic — no clock or RNG
|
|
485
|
+
* — so the value is stable across any re-derivation within a chain run.
|
|
486
|
+
*/
|
|
487
|
+
export function deriveRunId(chainRunId) {
|
|
488
|
+
return isNonEmpty(chainRunId) ? `fa-${chainRunId}` : "fa-run";
|
|
489
|
+
}
|
|
490
|
+
/** Build the string variable map for a stage's child pipeline. */
|
|
491
|
+
function buildStageVariables(stage, args, extra = {}) {
|
|
492
|
+
const baseVars = {
|
|
493
|
+
idea: toStringVariable(args.idea),
|
|
494
|
+
auto_approve: toStringVariable(args.auto_approve),
|
|
495
|
+
scheduled_at: toStringVariable(args.scheduled_at),
|
|
496
|
+
max_children: toStringVariable(args.max_children),
|
|
497
|
+
allow_duplicate: toStringVariable(args.allow_duplicate),
|
|
498
|
+
agent: toStringVariable(args.agent),
|
|
499
|
+
...extra,
|
|
500
|
+
};
|
|
501
|
+
const resolved = {};
|
|
502
|
+
for (const [k, template] of Object.entries(stage.variables ?? {})) {
|
|
503
|
+
resolved[k] = toStringVariable(substituteChainValue(template, baseVars));
|
|
504
|
+
}
|
|
505
|
+
return resolved;
|
|
506
|
+
}
|
|
507
|
+
/** Resolve a fan-out / cross-stage input list from prior stage outputs. */
|
|
508
|
+
function resolveCrossStageList(row, stageIndex, key) {
|
|
509
|
+
for (let i = stageIndex - 1; i >= 0; i--) {
|
|
510
|
+
const outputs = row.stages[i]?.outputs;
|
|
511
|
+
if (outputs && Array.isArray(outputs[key])) {
|
|
512
|
+
return outputs[key].filter((v) => typeof v === "string");
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
return null;
|
|
516
|
+
}
|
|
517
|
+
// ---------------------------------------------------------------------------
|
|
518
|
+
// Child-pipeline envelope handling
|
|
519
|
+
// ---------------------------------------------------------------------------
|
|
520
|
+
/**
|
|
521
|
+
* Process a child pipeline envelope (from runPipeline/resumePipeline) for the
|
|
522
|
+
* current stage. Returns a StageOutcome:
|
|
523
|
+
* - pause: child needs an agent task — chain paused.
|
|
524
|
+
* - fail: child failed (or produced no usable cross-stage data).
|
|
525
|
+
* - advanced: child completed; chain row was patched to reflect progress.
|
|
526
|
+
*
|
|
527
|
+
* For single-child stages (idea-to-ticket), "advanced" finalizes the stage and
|
|
528
|
+
* bumps current_stage_index. For fan-out stages (review-ticket), "advanced"
|
|
529
|
+
* only records the completed child and increments current_child_index; the
|
|
530
|
+
* caller's loop decides when the whole stage is finished.
|
|
531
|
+
*/
|
|
532
|
+
async function handleChildPipelineEnvelope(persistence, recipe, row, childEnv, opts) {
|
|
533
|
+
const idx = row.current_stage_index;
|
|
534
|
+
const stageRecipe = recipe.stages[idx];
|
|
535
|
+
const total = recipe.stages.length;
|
|
536
|
+
if (childEnv.status === "needs_agent_task") {
|
|
537
|
+
const stages = cloneStages(row.stages);
|
|
538
|
+
const stage = stages[idx];
|
|
539
|
+
stage.status = "paused";
|
|
540
|
+
stage.pipeline_run_id = childEnv.pipeline_run_id;
|
|
541
|
+
if (opts.fanOut) {
|
|
542
|
+
stage.current_child_index = opts.childIndex ?? stage.current_child_index ?? 0;
|
|
543
|
+
stage.child_runs = upsertChildRun(stage.child_runs, {
|
|
544
|
+
ticket_key: opts.ticketKey,
|
|
545
|
+
pipeline_run_id: childEnv.pipeline_run_id,
|
|
546
|
+
status: "paused",
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
let updated;
|
|
550
|
+
try {
|
|
551
|
+
updated = await persistence.patchRun(row.chain_run_id, {
|
|
552
|
+
stages,
|
|
553
|
+
status: "paused",
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
catch (err) {
|
|
557
|
+
return { kind: "fail", envelope: persistenceFailEnvelope(err, row, recipe) };
|
|
558
|
+
}
|
|
559
|
+
const instruction = `${childEnv.instruction}` +
|
|
560
|
+
`${buildPriorResultsAppendix(childEnv.results)}\n\n` +
|
|
561
|
+
`When done, call \`resume_full_automation\` with \`chain_run_id\` ` +
|
|
562
|
+
`"${row.chain_run_id}" and \`agent_result\` set to the result described above.`;
|
|
563
|
+
return {
|
|
564
|
+
kind: "pause",
|
|
565
|
+
envelope: buildNeedsAgentTaskEnvelope({
|
|
566
|
+
chainRunId: updated.chain_run_id,
|
|
567
|
+
chainStage: stageRecipe.pipeline_name,
|
|
568
|
+
chainStep: idx + 1,
|
|
569
|
+
chainTotal: total,
|
|
570
|
+
preamble: buildPreamble(recipe, idx, updated.stages),
|
|
571
|
+
instruction,
|
|
572
|
+
pipelineRunId: childEnv.pipeline_run_id,
|
|
573
|
+
pipelineStep: childEnv.step_index,
|
|
574
|
+
pipelineTotal: childEnv.total_steps,
|
|
575
|
+
}),
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
if (childEnv.status === "failed") {
|
|
579
|
+
// Stage 0 vagueness or no-keys produced => VALIDATION. Anchor the sentinel
|
|
580
|
+
// check to the failure-text fields (the halt error + per-step error/result
|
|
581
|
+
// strings) rather than the whole stringified envelope, so an echoed input,
|
|
582
|
+
// agent_result, or arg that happens to contain the token can't misclassify.
|
|
583
|
+
if (idx === 0 && failedEnvelopeMentions(childEnv, "too_vague_to_ticket")) {
|
|
584
|
+
return {
|
|
585
|
+
kind: "fail",
|
|
586
|
+
envelope: await failChainValidation(persistence, recipe, row, "Idea was too vague to produce a ticket (too_vague_to_ticket)."),
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
const code = isChainErrorCode(childEnv.error_code)
|
|
590
|
+
? childEnv.error_code
|
|
591
|
+
: "TOOL_ERROR";
|
|
592
|
+
const stages = cloneStages(row.stages);
|
|
593
|
+
if (stages[idx])
|
|
594
|
+
stages[idx].status = "failed";
|
|
595
|
+
try {
|
|
596
|
+
await persistence.patchRun(row.chain_run_id, { stages, status: "failed" });
|
|
597
|
+
}
|
|
598
|
+
catch {
|
|
599
|
+
// best-effort
|
|
600
|
+
}
|
|
601
|
+
return {
|
|
602
|
+
kind: "fail",
|
|
603
|
+
envelope: failedEnvelope(code, childEnv.error, {
|
|
604
|
+
chain_run_id: row.chain_run_id,
|
|
605
|
+
chain_stage: stageRecipe.pipeline_name,
|
|
606
|
+
chain_step: idx + 1,
|
|
607
|
+
chain_total: total,
|
|
608
|
+
}),
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
// childEnv.status === "completed"
|
|
612
|
+
if (opts.fanOut) {
|
|
613
|
+
const stages = cloneStages(row.stages);
|
|
614
|
+
const stage = stages[idx];
|
|
615
|
+
const childIndex = opts.childIndex ?? stage.current_child_index ?? 0;
|
|
616
|
+
stage.child_runs = upsertChildRun(stage.child_runs, {
|
|
617
|
+
ticket_key: opts.ticketKey,
|
|
618
|
+
pipeline_run_id: childEnv.pipeline_run_id,
|
|
619
|
+
status: "completed",
|
|
620
|
+
});
|
|
621
|
+
stage.current_child_index = childIndex + 1;
|
|
622
|
+
stage.pipeline_run_id = null;
|
|
623
|
+
stage.status = "running";
|
|
624
|
+
let updated;
|
|
625
|
+
try {
|
|
626
|
+
updated = await persistence.patchRun(row.chain_run_id, {
|
|
627
|
+
stages,
|
|
628
|
+
status: "running",
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
catch (err) {
|
|
632
|
+
return { kind: "fail", envelope: persistenceFailEnvelope(err, row, recipe) };
|
|
633
|
+
}
|
|
634
|
+
return { kind: "advanced", row: updated };
|
|
635
|
+
}
|
|
636
|
+
// Single-child stage (idea-to-ticket): finalize. Derive created keys ONLY from
|
|
637
|
+
// the upload step's structured payload — never the whole transcript — so
|
|
638
|
+
// duplicate-detection search hits cannot become review/start-tickets targets.
|
|
639
|
+
const keys = extractCreatedKeysFromCompletedChild(childEnv);
|
|
640
|
+
if (keys.length === 0) {
|
|
641
|
+
return {
|
|
642
|
+
kind: "fail",
|
|
643
|
+
envelope: await failChainValidation(persistence, recipe, row, "idea-to-ticket produced no structured created_ticket_keys payload; cannot continue the chain."),
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
const stages = cloneStages(row.stages);
|
|
647
|
+
const stage = stages[idx];
|
|
648
|
+
stage.status = "completed";
|
|
649
|
+
stage.pipeline_run_id = null;
|
|
650
|
+
stage.outputs = { created_ticket_keys: keys };
|
|
651
|
+
stage.summary = summarizeStageCompletion(stageRecipe.pipeline_name, keys);
|
|
652
|
+
let updated;
|
|
653
|
+
try {
|
|
654
|
+
updated = await persistence.patchRun(row.chain_run_id, {
|
|
655
|
+
stages,
|
|
656
|
+
current_stage_index: idx + 1,
|
|
657
|
+
status: "running",
|
|
658
|
+
expected_status: "running",
|
|
659
|
+
expected_current_stage_index: idx,
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
catch (err) {
|
|
663
|
+
return { kind: "fail", envelope: persistenceFailEnvelope(err, row, recipe) };
|
|
664
|
+
}
|
|
665
|
+
return { kind: "advanced", row: updated };
|
|
666
|
+
}
|
|
667
|
+
function upsertChildRun(existing, entry) {
|
|
668
|
+
const list = existing ? [...existing] : [];
|
|
669
|
+
const i = list.findIndex((c) => c.ticket_key !== undefined && c.ticket_key === entry.ticket_key);
|
|
670
|
+
if (i >= 0) {
|
|
671
|
+
list[i] = { ...list[i], ...entry };
|
|
672
|
+
}
|
|
673
|
+
else {
|
|
674
|
+
list.push(entry);
|
|
675
|
+
}
|
|
676
|
+
return list;
|
|
677
|
+
}
|
|
678
|
+
function isChainErrorCode(value) {
|
|
679
|
+
return (value === "VALIDATION" ||
|
|
680
|
+
value === "NOT_FOUND" ||
|
|
681
|
+
value === "EXPIRED" ||
|
|
682
|
+
value === "REPO_MISMATCH" ||
|
|
683
|
+
value === "TOOL_ERROR");
|
|
684
|
+
}
|
|
685
|
+
function persistenceFailEnvelope(err, row, recipe) {
|
|
686
|
+
const idx = row.current_stage_index;
|
|
687
|
+
if (err instanceof ChainPersistenceError) {
|
|
688
|
+
return failedEnvelope(err.code, err.message, {
|
|
689
|
+
chain_run_id: row.chain_run_id,
|
|
690
|
+
chain_stage: recipe.stages[idx]?.pipeline_name,
|
|
691
|
+
chain_step: idx + 1,
|
|
692
|
+
chain_total: recipe.stages.length,
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
return failedEnvelope("TOOL_ERROR", "An unexpected persistence error occurred.", {
|
|
696
|
+
chain_run_id: row.chain_run_id,
|
|
697
|
+
chain_stage: recipe.stages[idx]?.pipeline_name,
|
|
698
|
+
chain_step: idx + 1,
|
|
699
|
+
chain_total: recipe.stages.length,
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
// ---------------------------------------------------------------------------
|
|
703
|
+
// Stage starters
|
|
704
|
+
// ---------------------------------------------------------------------------
|
|
705
|
+
async function startOrContinueIdeaToTicketStage(deps, persistence, recipe, row, autoApprove) {
|
|
706
|
+
const idx = row.current_stage_index;
|
|
707
|
+
const stageRecipe = recipe.stages[idx];
|
|
708
|
+
const resolved = buildStageVariables(stageRecipe, row.args);
|
|
709
|
+
// idea-to-ticket declares [idea, slug, run_id, docs_dir, allow_duplicate,
|
|
710
|
+
// auto_approve_external, max_children]. `docs_dir` is auto-injected by the
|
|
711
|
+
// pipeline runner; the rest must be supplied here. `slug`/`run_id` are
|
|
712
|
+
// DERIVED (not chain inputs), so they cannot be recipe templates — inject
|
|
713
|
+
// them in code. We also backfill sane defaults so an omitted/empty chain
|
|
714
|
+
// input still satisfies the child pipeline's required-variable contract.
|
|
715
|
+
const variables = {
|
|
716
|
+
...resolved,
|
|
717
|
+
slug: deriveSlug(toStringVariable(row.args.idea)),
|
|
718
|
+
run_id: deriveRunId(row.chain_run_id),
|
|
719
|
+
auto_approve_external: isNonEmpty(resolved.auto_approve_external)
|
|
720
|
+
? resolved.auto_approve_external
|
|
721
|
+
: toStringVariable(row.args.auto_approve) || "false",
|
|
722
|
+
allow_duplicate: isNonEmpty(resolved.allow_duplicate)
|
|
723
|
+
? resolved.allow_duplicate
|
|
724
|
+
: "false",
|
|
725
|
+
max_children: isNonEmpty(resolved.max_children)
|
|
726
|
+
? resolved.max_children
|
|
727
|
+
: "10",
|
|
728
|
+
};
|
|
729
|
+
const stages = cloneStages(row.stages);
|
|
730
|
+
stages[idx].status = "running";
|
|
731
|
+
stages[idx].input_values = variables;
|
|
732
|
+
try {
|
|
733
|
+
row = await persistence.patchRun(row.chain_run_id, { stages, status: "running" });
|
|
734
|
+
}
|
|
735
|
+
catch (err) {
|
|
736
|
+
return { kind: "fail", envelope: persistenceFailEnvelope(err, row, recipe) };
|
|
737
|
+
}
|
|
738
|
+
const childEnv = await runPipeline(deps, {
|
|
739
|
+
pipeline: stageRecipe.pipeline_name,
|
|
740
|
+
variables,
|
|
741
|
+
auto_approve: autoApprove,
|
|
742
|
+
ttl_seconds: numericArg(row.args.ttl_seconds),
|
|
743
|
+
});
|
|
744
|
+
return handleChildPipelineEnvelope(persistence, recipe, row, childEnv, {
|
|
745
|
+
fanOut: false,
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
async function startOrContinueReviewTicketStage(deps, persistence, recipe, row, autoApprove) {
|
|
749
|
+
const idx = row.current_stage_index;
|
|
750
|
+
const stageRecipe = recipe.stages[idx];
|
|
751
|
+
const fanOutInput = stageRecipe.fan_out_input ?? "created_ticket_keys";
|
|
752
|
+
const keys = resolveCrossStageList(row, idx, fanOutInput);
|
|
753
|
+
if (!keys || keys.length === 0) {
|
|
754
|
+
return {
|
|
755
|
+
kind: "fail",
|
|
756
|
+
envelope: await failChainValidation(persistence, recipe, row, `Missing cross-stage variable "${fanOutInput}" for review fan-out.`),
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
const childIndex = row.stages[idx].current_child_index ?? 0;
|
|
760
|
+
if (childIndex >= keys.length) {
|
|
761
|
+
// All children reviewed — finalize the stage.
|
|
762
|
+
const stages = cloneStages(row.stages);
|
|
763
|
+
const stage = stages[idx];
|
|
764
|
+
stage.status = "completed";
|
|
765
|
+
stage.pipeline_run_id = null;
|
|
766
|
+
stage.outputs = { reviewed_ticket_keys: keys };
|
|
767
|
+
stage.summary = summarizeStageCompletion(stageRecipe.pipeline_name, keys);
|
|
768
|
+
let updated;
|
|
769
|
+
try {
|
|
770
|
+
updated = await persistence.patchRun(row.chain_run_id, {
|
|
771
|
+
stages,
|
|
772
|
+
current_stage_index: idx + 1,
|
|
773
|
+
status: "running",
|
|
774
|
+
expected_status: "running",
|
|
775
|
+
expected_current_stage_index: idx,
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
catch (err) {
|
|
779
|
+
return { kind: "fail", envelope: persistenceFailEnvelope(err, row, recipe) };
|
|
780
|
+
}
|
|
781
|
+
return { kind: "advanced", row: updated };
|
|
782
|
+
}
|
|
783
|
+
// Start the next pending child review.
|
|
784
|
+
const ticketKey = keys[childIndex];
|
|
785
|
+
const fanOutVariable = stageRecipe.fan_out_variable ?? "ticket_key";
|
|
786
|
+
const variables = buildStageVariables(stageRecipe, row.args, {
|
|
787
|
+
item: ticketKey,
|
|
788
|
+
[fanOutVariable]: ticketKey,
|
|
789
|
+
});
|
|
790
|
+
const stages = cloneStages(row.stages);
|
|
791
|
+
const stage = stages[idx];
|
|
792
|
+
stage.status = "running";
|
|
793
|
+
stage.current_child_index = childIndex;
|
|
794
|
+
stage.input_values = variables;
|
|
795
|
+
try {
|
|
796
|
+
row = await persistence.patchRun(row.chain_run_id, { stages, status: "running" });
|
|
797
|
+
}
|
|
798
|
+
catch (err) {
|
|
799
|
+
return { kind: "fail", envelope: persistenceFailEnvelope(err, row, recipe) };
|
|
800
|
+
}
|
|
801
|
+
const childEnv = await runPipeline(deps, {
|
|
802
|
+
pipeline: stageRecipe.pipeline_name,
|
|
803
|
+
variables,
|
|
804
|
+
auto_approve: autoApprove,
|
|
805
|
+
ttl_seconds: numericArg(row.args.ttl_seconds),
|
|
806
|
+
});
|
|
807
|
+
return handleChildPipelineEnvelope(persistence, recipe, row, childEnv, {
|
|
808
|
+
fanOut: true,
|
|
809
|
+
ticketKey,
|
|
810
|
+
childIndex,
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
async function startStartTicketsStage(persistence, recipe, row) {
|
|
814
|
+
const idx = row.current_stage_index;
|
|
815
|
+
const stageRecipe = recipe.stages[idx];
|
|
816
|
+
const total = recipe.stages.length;
|
|
817
|
+
const fanOutInput = stageRecipe.fan_out_input ?? "reviewed_ticket_keys";
|
|
818
|
+
const keys = resolveCrossStageList(row, idx, fanOutInput);
|
|
819
|
+
if (!keys || keys.length === 0) {
|
|
820
|
+
return failChainValidation(persistence, recipe, row, `Missing cross-stage variable "${fanOutInput}" for start-tickets.`);
|
|
821
|
+
}
|
|
822
|
+
// Hands-off chain runs (auto_approve=true, the default) spawn the
|
|
823
|
+
// implementation agents auto-approved too, so they don't stall on approval
|
|
824
|
+
// gates. A --require-approval run (auto_approve=false) spawns them interactive.
|
|
825
|
+
const autoApproveFlag = row.args.auto_approve === true ? " --auto-approve" : "";
|
|
826
|
+
const command = `/start-tickets ${keys.join(" ")}${autoApproveFlag}`;
|
|
827
|
+
const stages = cloneStages(row.stages);
|
|
828
|
+
stages[idx].status = "paused";
|
|
829
|
+
stages[idx].pipeline_run_id = null;
|
|
830
|
+
stages[idx].input_values = { reviewed_ticket_keys: keys.join(" ") };
|
|
831
|
+
let updated;
|
|
832
|
+
try {
|
|
833
|
+
updated = await persistence.patchRun(row.chain_run_id, {
|
|
834
|
+
stages,
|
|
835
|
+
status: "paused",
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
catch (err) {
|
|
839
|
+
return persistenceFailEnvelope(err, row, recipe);
|
|
840
|
+
}
|
|
841
|
+
const instruction = `Invoke the start-tickets command in this same session to spawn one ` +
|
|
842
|
+
`worktree per reviewed ticket:\n\n${command}\n\n` +
|
|
843
|
+
`When the worktrees have been spawned, call \`resume_full_automation\` with ` +
|
|
844
|
+
`\`chain_run_id\` "${row.chain_run_id}" and \`agent_result\` set to a short ` +
|
|
845
|
+
`summary of what start-tickets reported.`;
|
|
846
|
+
return buildNeedsAgentTaskEnvelope({
|
|
847
|
+
chainRunId: updated.chain_run_id,
|
|
848
|
+
chainStage: START_TICKETS_PIPELINE,
|
|
849
|
+
chainStep: idx + 1,
|
|
850
|
+
chainTotal: total,
|
|
851
|
+
preamble: buildPreamble(recipe, idx, updated.stages),
|
|
852
|
+
instruction,
|
|
853
|
+
});
|
|
854
|
+
}
|
|
855
|
+
function numericArg(value) {
|
|
856
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
857
|
+
return value;
|
|
858
|
+
return undefined;
|
|
859
|
+
}
|
|
860
|
+
// ---------------------------------------------------------------------------
|
|
861
|
+
// Chain driver
|
|
862
|
+
// ---------------------------------------------------------------------------
|
|
863
|
+
/**
|
|
864
|
+
* Drive the chain forward by starting the current stage's next unit of work.
|
|
865
|
+
* Called after a fresh create, or after a child completes and we advance.
|
|
866
|
+
* Stops at: a child pipeline pause, the start-tickets seam, completion, a
|
|
867
|
+
* typed validation failure, or a child pipeline failure.
|
|
868
|
+
*/
|
|
869
|
+
async function continueChainExecution(deps, persistence, recipe, row, autoApprove) {
|
|
870
|
+
// Bounded by total stages * (max children + 1); guard against runaway loops.
|
|
871
|
+
let guard = 0;
|
|
872
|
+
const guardMax = 10000;
|
|
873
|
+
while (guard++ < guardMax) {
|
|
874
|
+
const idx = row.current_stage_index;
|
|
875
|
+
const total = recipe.stages.length;
|
|
876
|
+
if (idx >= total) {
|
|
877
|
+
try {
|
|
878
|
+
row = await persistence.patchRun(row.chain_run_id, { status: "completed" });
|
|
879
|
+
}
|
|
880
|
+
catch (err) {
|
|
881
|
+
return persistenceFailEnvelope(err, row, recipe);
|
|
882
|
+
}
|
|
883
|
+
return buildCompletedEnvelope(recipe, row);
|
|
884
|
+
}
|
|
885
|
+
const stageRecipe = recipe.stages[idx];
|
|
886
|
+
let outcome = null;
|
|
887
|
+
if (stageRecipe.pipeline_name === START_TICKETS_PIPELINE) {
|
|
888
|
+
return startStartTicketsStage(persistence, recipe, row);
|
|
889
|
+
}
|
|
890
|
+
else if (stageRecipe.fan_out_input) {
|
|
891
|
+
outcome = await startOrContinueReviewTicketStage(deps, persistence, recipe, row, autoApprove);
|
|
892
|
+
}
|
|
893
|
+
else {
|
|
894
|
+
outcome = await startOrContinueIdeaToTicketStage(deps, persistence, recipe, row, autoApprove);
|
|
895
|
+
}
|
|
896
|
+
if (outcome.kind === "pause" || outcome.kind === "fail") {
|
|
897
|
+
return outcome.envelope;
|
|
898
|
+
}
|
|
899
|
+
row = outcome.row;
|
|
900
|
+
}
|
|
901
|
+
return failedEnvelope("TOOL_ERROR", "Chain execution exceeded its step guard.", {
|
|
902
|
+
chain_run_id: row.chain_run_id,
|
|
903
|
+
chain_total: recipe.stages.length,
|
|
904
|
+
});
|
|
905
|
+
}
|
|
906
|
+
// ---------------------------------------------------------------------------
|
|
907
|
+
// Public entry points
|
|
908
|
+
// ---------------------------------------------------------------------------
|
|
909
|
+
export async function runFullAutomation(deps, input) {
|
|
910
|
+
try {
|
|
911
|
+
if (typeof input.idea !== "string" || input.idea.trim() === "") {
|
|
912
|
+
return failedEnvelope("VALIDATION", "idea must be a non-empty string.");
|
|
913
|
+
}
|
|
914
|
+
const agent = input.agent ?? "claude";
|
|
915
|
+
if (agent !== "claude") {
|
|
916
|
+
return failedEnvelope("VALIDATION", `Unsupported agent "${String(input.agent)}". Only "claude" is supported.`);
|
|
917
|
+
}
|
|
918
|
+
const recipe = deps.chainRecipes[CHAIN_NAME];
|
|
919
|
+
if (!recipe) {
|
|
920
|
+
return failedEnvelope("VALIDATION", `Chain recipe "${CHAIN_NAME}" not found.`);
|
|
921
|
+
}
|
|
922
|
+
// Full automation is hands-off by default: when the caller omits
|
|
923
|
+
// auto_approve, run without approval gates. The /full-automation command
|
|
924
|
+
// sends an explicit boolean (false only for --require-approval); this
|
|
925
|
+
// default governs direct run_full_automation callers.
|
|
926
|
+
const autoApprove = input.auto_approve === undefined
|
|
927
|
+
? true
|
|
928
|
+
: normalizeAutoApprove(input.auto_approve);
|
|
929
|
+
const args = {
|
|
930
|
+
idea: input.idea,
|
|
931
|
+
auto_approve: autoApprove,
|
|
932
|
+
scheduled_at: input.scheduled_at ?? "",
|
|
933
|
+
max_children: input.max_children,
|
|
934
|
+
allow_duplicate: input.allow_duplicate,
|
|
935
|
+
agent,
|
|
936
|
+
ttl_seconds: input.ttl_seconds,
|
|
937
|
+
};
|
|
938
|
+
const initialStages = recipe.stages.map((stage) => ({
|
|
939
|
+
pipeline_name: stage.pipeline_name,
|
|
940
|
+
status: "pending",
|
|
941
|
+
}));
|
|
942
|
+
const persistence = createChainPersistenceClient({
|
|
943
|
+
baseUrl: deps.baseUrl,
|
|
944
|
+
apiKey: deps.apiKey,
|
|
945
|
+
repoName: deps.repoName,
|
|
946
|
+
});
|
|
947
|
+
let row;
|
|
948
|
+
try {
|
|
949
|
+
row = await persistence.createRun({
|
|
950
|
+
chain_name: CHAIN_NAME,
|
|
951
|
+
args,
|
|
952
|
+
current_stage_index: 0,
|
|
953
|
+
stages: initialStages,
|
|
954
|
+
status: "running",
|
|
955
|
+
ttl_seconds: input.ttl_seconds,
|
|
956
|
+
});
|
|
957
|
+
}
|
|
958
|
+
catch (err) {
|
|
959
|
+
if (err instanceof ChainPersistenceError) {
|
|
960
|
+
return failedEnvelope(err.code, err.message);
|
|
961
|
+
}
|
|
962
|
+
return failedEnvelope("TOOL_ERROR", "An unexpected error occurred while creating the chain run.");
|
|
963
|
+
}
|
|
964
|
+
return continueChainExecution(deps, persistence, recipe, row, autoApprove);
|
|
965
|
+
}
|
|
966
|
+
catch (err) {
|
|
967
|
+
// The public envelope is intentionally sanitized (no stack traces), but
|
|
968
|
+
// log to stderr so smoke-testers / operators have a forensic trail (the
|
|
969
|
+
// MCP server runs outside Sentry).
|
|
970
|
+
console.error("[chain-orchestrator] unexpected error in runFullAutomation:", err);
|
|
971
|
+
return failedEnvelope("TOOL_ERROR", "An unexpected error occurred while executing the full-automation chain.");
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
export async function resumeFullAutomation(deps, input) {
|
|
975
|
+
try {
|
|
976
|
+
const recipe = deps.chainRecipes[CHAIN_NAME];
|
|
977
|
+
if (!recipe) {
|
|
978
|
+
return failedEnvelope("VALIDATION", `Chain recipe "${CHAIN_NAME}" not found.`, { chain_run_id: input.chain_run_id });
|
|
979
|
+
}
|
|
980
|
+
const persistence = createChainPersistenceClient({
|
|
981
|
+
baseUrl: deps.baseUrl,
|
|
982
|
+
apiKey: deps.apiKey,
|
|
983
|
+
repoName: deps.repoName,
|
|
984
|
+
});
|
|
985
|
+
let row;
|
|
986
|
+
try {
|
|
987
|
+
row = await persistence.getRun(input.chain_run_id);
|
|
988
|
+
}
|
|
989
|
+
catch (err) {
|
|
990
|
+
if (err instanceof ChainPersistenceError) {
|
|
991
|
+
return failedEnvelope(err.code, err.message, {
|
|
992
|
+
chain_run_id: input.chain_run_id,
|
|
993
|
+
});
|
|
994
|
+
}
|
|
995
|
+
return failedEnvelope("TOOL_ERROR", "An unexpected error occurred while fetching the chain run.", { chain_run_id: input.chain_run_id });
|
|
996
|
+
}
|
|
997
|
+
if (row.status === "expired") {
|
|
998
|
+
return failedEnvelope("EXPIRED", "Chain run has expired.", {
|
|
999
|
+
chain_run_id: row.chain_run_id,
|
|
1000
|
+
chain_total: recipe.stages.length,
|
|
1001
|
+
});
|
|
1002
|
+
}
|
|
1003
|
+
const autoApprove = normalizeAutoApprove(row.args.auto_approve);
|
|
1004
|
+
const idx = row.current_stage_index;
|
|
1005
|
+
const stageRecipe = recipe.stages[idx];
|
|
1006
|
+
const total = recipe.stages.length;
|
|
1007
|
+
if (!stageRecipe) {
|
|
1008
|
+
return failedEnvelope("VALIDATION", `Chain run has no active stage at index ${idx}.`, { chain_run_id: row.chain_run_id, chain_total: total });
|
|
1009
|
+
}
|
|
1010
|
+
// Stage-3 start-tickets seam: complete the chain on a non-empty result.
|
|
1011
|
+
if (stageRecipe.pipeline_name === START_TICKETS_PIPELINE) {
|
|
1012
|
+
if (typeof input.agent_result !== "string" ||
|
|
1013
|
+
input.agent_result.trim() === "") {
|
|
1014
|
+
return failedEnvelope("VALIDATION", "agent_result must be a non-empty string to complete the start-tickets stage.", {
|
|
1015
|
+
chain_run_id: row.chain_run_id,
|
|
1016
|
+
chain_stage: START_TICKETS_PIPELINE,
|
|
1017
|
+
chain_step: idx + 1,
|
|
1018
|
+
chain_total: total,
|
|
1019
|
+
});
|
|
1020
|
+
}
|
|
1021
|
+
const startedKeys = resolveCrossStageList(row, idx, stageRecipe.fan_out_input ?? "reviewed_ticket_keys") ?? [];
|
|
1022
|
+
const stages = cloneStages(row.stages);
|
|
1023
|
+
stages[idx].status = "completed";
|
|
1024
|
+
stages[idx].pipeline_run_id = null;
|
|
1025
|
+
stages[idx].outputs = { started_ticket_keys: startedKeys };
|
|
1026
|
+
stages[idx].summary = summarizeStageCompletion(START_TICKETS_PIPELINE, startedKeys);
|
|
1027
|
+
let updated;
|
|
1028
|
+
try {
|
|
1029
|
+
updated = await persistence.patchRun(row.chain_run_id, {
|
|
1030
|
+
stages,
|
|
1031
|
+
current_stage_index: idx + 1,
|
|
1032
|
+
status: "completed",
|
|
1033
|
+
expected_status: "paused",
|
|
1034
|
+
expected_current_stage_index: idx,
|
|
1035
|
+
});
|
|
1036
|
+
}
|
|
1037
|
+
catch (err) {
|
|
1038
|
+
return persistenceFailEnvelope(err, row, recipe);
|
|
1039
|
+
}
|
|
1040
|
+
return buildCompletedEnvelope(recipe, updated);
|
|
1041
|
+
}
|
|
1042
|
+
// Otherwise resume the active paused child pipeline.
|
|
1043
|
+
const activePipelineRunId = row.stages[idx]?.pipeline_run_id;
|
|
1044
|
+
if (!activePipelineRunId) {
|
|
1045
|
+
return failedEnvelope("VALIDATION", `No active child pipeline to resume for stage ${idx + 1}.`, {
|
|
1046
|
+
chain_run_id: row.chain_run_id,
|
|
1047
|
+
chain_stage: stageRecipe.pipeline_name,
|
|
1048
|
+
chain_step: idx + 1,
|
|
1049
|
+
chain_total: total,
|
|
1050
|
+
});
|
|
1051
|
+
}
|
|
1052
|
+
// Snapshot the inner pipeline run before touching anything. This lets us
|
|
1053
|
+
// resume the normal `paused` case, recover idempotently when the inner run
|
|
1054
|
+
// already reached a terminal state on a prior (failed-to-advance) resume,
|
|
1055
|
+
// and refuse cleanly when it is in an indeterminate state.
|
|
1056
|
+
const peek = await peekPipelineRun(deps, activePipelineRunId);
|
|
1057
|
+
if ("error_code" in peek) {
|
|
1058
|
+
return failedEnvelope(peek.error_code, peek.error, {
|
|
1059
|
+
chain_run_id: row.chain_run_id,
|
|
1060
|
+
chain_stage: stageRecipe.pipeline_name,
|
|
1061
|
+
chain_step: idx + 1,
|
|
1062
|
+
chain_total: total,
|
|
1063
|
+
});
|
|
1064
|
+
}
|
|
1065
|
+
if (peek.status !== "paused" &&
|
|
1066
|
+
peek.status !== "completed" &&
|
|
1067
|
+
peek.status !== "failed") {
|
|
1068
|
+
// running / expired — results are not known-final, so we can neither
|
|
1069
|
+
// resume (only paused runs resume) nor safely synthesize an advance.
|
|
1070
|
+
return {
|
|
1071
|
+
status: "failed",
|
|
1072
|
+
error_code: "VALIDATION",
|
|
1073
|
+
error: `Inner pipeline run is in status "${peek.status}" and cannot be ` +
|
|
1074
|
+
`safely resumed or recovered. Inspect pipeline_run_id ` +
|
|
1075
|
+
`${activePipelineRunId}.`,
|
|
1076
|
+
chain_run_id: row.chain_run_id,
|
|
1077
|
+
chain_stage: stageRecipe.pipeline_name,
|
|
1078
|
+
chain_step: idx + 1,
|
|
1079
|
+
chain_total: total,
|
|
1080
|
+
pipeline_run_id: activePipelineRunId,
|
|
1081
|
+
resumable: false,
|
|
1082
|
+
};
|
|
1083
|
+
}
|
|
1084
|
+
// Mirror the start path's invariant: the chain row must be "running" while
|
|
1085
|
+
// its inner pipeline is being advanced. The approval-gate pause left the
|
|
1086
|
+
// chain "paused"; flip it back BEFORE the completed-advance patch (which
|
|
1087
|
+
// asserts expected_status "running"). Idempotent (no expected_status) so a
|
|
1088
|
+
// recovery resume of an already-"running" chain does not conflict.
|
|
1089
|
+
try {
|
|
1090
|
+
row = await persistence.patchRun(row.chain_run_id, { status: "running" });
|
|
1091
|
+
}
|
|
1092
|
+
catch (err) {
|
|
1093
|
+
return persistenceFailEnvelope(err, row, recipe);
|
|
1094
|
+
}
|
|
1095
|
+
let childEnv;
|
|
1096
|
+
if (peek.status === "paused") {
|
|
1097
|
+
childEnv = await resumePipeline(deps, {
|
|
1098
|
+
pipeline_run_id: activePipelineRunId,
|
|
1099
|
+
agent_result: input.agent_result,
|
|
1100
|
+
});
|
|
1101
|
+
}
|
|
1102
|
+
else if (peek.status === "completed") {
|
|
1103
|
+
// Idempotent recovery: the inner pipeline already completed on a prior
|
|
1104
|
+
// resume; synthesize its completed envelope (no re-execution) so the
|
|
1105
|
+
// stage advances from the persisted results.
|
|
1106
|
+
childEnv = {
|
|
1107
|
+
status: "completed",
|
|
1108
|
+
pipeline_run_id: peek.pipeline_run_id,
|
|
1109
|
+
pipeline: peek.pipeline,
|
|
1110
|
+
total_steps: peek.total_steps,
|
|
1111
|
+
results: peek.results,
|
|
1112
|
+
};
|
|
1113
|
+
}
|
|
1114
|
+
else {
|
|
1115
|
+
// peek.status === "failed" — let handleChildPipelineEnvelope map it to a
|
|
1116
|
+
// stage failure from the persisted results. The pipeline run row does NOT
|
|
1117
|
+
// persist a top-level error_code (only `status: "failed"` + per-step
|
|
1118
|
+
// results), so the original code cannot be reconstructed here; surface the
|
|
1119
|
+
// failed step's recorded error text for diagnostics and default the code
|
|
1120
|
+
// to TOOL_ERROR.
|
|
1121
|
+
const failedStepError = peek.results.find((r) => !r.ok && typeof r.error === "string")?.error;
|
|
1122
|
+
childEnv = {
|
|
1123
|
+
status: "failed",
|
|
1124
|
+
error_code: "TOOL_ERROR",
|
|
1125
|
+
error: failedStepError
|
|
1126
|
+
? `Inner pipeline run failed before the chain could advance: ${failedStepError}`
|
|
1127
|
+
: "Inner pipeline run failed before the chain could advance.",
|
|
1128
|
+
pipeline_run_id: peek.pipeline_run_id,
|
|
1129
|
+
pipeline: peek.pipeline,
|
|
1130
|
+
results: peek.results,
|
|
1131
|
+
};
|
|
1132
|
+
}
|
|
1133
|
+
const fanOut = !!stageRecipe.fan_out_input;
|
|
1134
|
+
const childIndex = row.stages[idx]?.current_child_index ?? 0;
|
|
1135
|
+
const ticketKey = fanOut
|
|
1136
|
+
? (resolveCrossStageList(row, idx, stageRecipe.fan_out_input) ?? [])[childIndex]
|
|
1137
|
+
: undefined;
|
|
1138
|
+
const outcome = await handleChildPipelineEnvelope(persistence, recipe, row, childEnv, { fanOut, ticketKey, childIndex });
|
|
1139
|
+
if (outcome.kind === "pause" || outcome.kind === "fail") {
|
|
1140
|
+
return outcome.envelope;
|
|
1141
|
+
}
|
|
1142
|
+
return continueChainExecution(deps, persistence, recipe, outcome.row, autoApprove);
|
|
1143
|
+
}
|
|
1144
|
+
catch (err) {
|
|
1145
|
+
// Sanitized public envelope; log to stderr for a forensic trail (see
|
|
1146
|
+
// runFullAutomation's catch — the MCP server runs outside Sentry).
|
|
1147
|
+
console.error("[chain-orchestrator] unexpected error in resumeFullAutomation:", err);
|
|
1148
|
+
return failedEnvelope("TOOL_ERROR", "An unexpected error occurred while resuming the full-automation chain.", { chain_run_id: input.chain_run_id });
|
|
1149
|
+
}
|
|
1150
|
+
}
|