@bridge_gpt/mcp-server 0.1.13 → 0.1.14
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 +13 -0
- package/build/commands.generated.js +4 -3
- package/build/index.js +634 -67
- package/build/pipeline-orchestrator.js +593 -0
- package/build/pipeline-utils.js +50 -12
- package/build/pipelines.generated.js +44 -29
- package/build/version.generated.js +1 -1
- package/package.json +5 -7
- package/pipelines/check-ci-ticket.json +8 -2
- package/pipelines/implement-ticket.json +8 -2
- package/pipelines/pr-ticket.json +1 -2
- package/build/decision-page-schema.test.js +0 -248
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pipeline orchestrator — sequential server-side execution for the new
|
|
3
|
+
* ``run_pipeline`` / ``resume_pipeline`` / ``list_pipeline_runs`` MCP tools.
|
|
4
|
+
*
|
|
5
|
+
* Design rules (BAPI-275):
|
|
6
|
+
* - The orchestrator never imports ``mcp_server/src/index.ts``. All runtime
|
|
7
|
+
* dependencies (Bridge API config, pipelines/instructions registries, the
|
|
8
|
+
* tool-handler map) are injected through ``PipelineOrchestratorDeps``.
|
|
9
|
+
* - Pipeline runs are persisted server-side via the Bridge API routes at
|
|
10
|
+
* ``/jira/pipelines/runs``. Every state transition extends the idle TTL.
|
|
11
|
+
* - ``mcp_call`` failure detection uses a strict envelope match: the parsed
|
|
12
|
+
* ``content[0].text`` value must be an object with a string ``error`` key
|
|
13
|
+
* AND a numeric ``status`` key (matches ``handleResponse`` in index.ts).
|
|
14
|
+
* - Approval-gated ``mcp_call`` steps synthesise a ``needs_agent_task``
|
|
15
|
+
* pause unless ``auto_approve`` is true; the resolved tool name + params
|
|
16
|
+
* are inlined into the instruction string so the agent can review and
|
|
17
|
+
* confirm before resuming. (BAPI-275 decisions E-4 / E-36.)
|
|
18
|
+
*/
|
|
19
|
+
import { resolveRecipe } from "./pipeline-utils.js";
|
|
20
|
+
class PipelinePersistenceError extends Error {
|
|
21
|
+
code;
|
|
22
|
+
constructor(code, message) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.code = code;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export function createPipelinePersistenceClient(deps) {
|
|
28
|
+
const headers = {
|
|
29
|
+
"X-API-Key": deps.apiKey,
|
|
30
|
+
"Content-Type": "application/json",
|
|
31
|
+
};
|
|
32
|
+
const base = deps.baseUrl.replace(/\/+$/, "");
|
|
33
|
+
function urlFor(suffix, query) {
|
|
34
|
+
const u = new URL(`${base}/jira/pipelines${suffix}`);
|
|
35
|
+
if (query) {
|
|
36
|
+
for (const [k, v] of Object.entries(query)) {
|
|
37
|
+
u.searchParams.set(k, v);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return u.toString();
|
|
41
|
+
}
|
|
42
|
+
async function readError(resp) {
|
|
43
|
+
const raw = await resp.text();
|
|
44
|
+
let codeFromBody = null;
|
|
45
|
+
let messageFromBody = null;
|
|
46
|
+
try {
|
|
47
|
+
const parsed = JSON.parse(raw);
|
|
48
|
+
const detail = parsed?.detail;
|
|
49
|
+
if (detail && typeof detail === "object") {
|
|
50
|
+
if (typeof detail.error_code === "string") {
|
|
51
|
+
codeFromBody = detail.error_code;
|
|
52
|
+
}
|
|
53
|
+
if (typeof detail.error === "string") {
|
|
54
|
+
messageFromBody = detail.error;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// not JSON
|
|
60
|
+
}
|
|
61
|
+
const codeFromStatus = resp.status === 404
|
|
62
|
+
? "NOT_FOUND"
|
|
63
|
+
: resp.status === 410
|
|
64
|
+
? "EXPIRED"
|
|
65
|
+
: resp.status === 403
|
|
66
|
+
? "REPO_MISMATCH"
|
|
67
|
+
: resp.status === 400 || resp.status === 422
|
|
68
|
+
? "VALIDATION"
|
|
69
|
+
: "TOOL_ERROR";
|
|
70
|
+
return {
|
|
71
|
+
code: codeFromBody ?? codeFromStatus,
|
|
72
|
+
message: messageFromBody ?? (raw || `HTTP ${resp.status}`),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
async function createRun(input) {
|
|
76
|
+
const resp = await fetch(urlFor("/runs"), {
|
|
77
|
+
method: "POST",
|
|
78
|
+
headers,
|
|
79
|
+
body: JSON.stringify({
|
|
80
|
+
repo_name: deps.repoName,
|
|
81
|
+
pipeline_name: input.pipeline_name,
|
|
82
|
+
resolved_recipe: input.resolved_recipe,
|
|
83
|
+
current_step_index: input.current_step_index,
|
|
84
|
+
results: input.results,
|
|
85
|
+
status: input.status,
|
|
86
|
+
ttl_seconds: input.ttl_seconds,
|
|
87
|
+
}),
|
|
88
|
+
});
|
|
89
|
+
if (!resp.ok) {
|
|
90
|
+
const err = await readError(resp);
|
|
91
|
+
throw new PipelinePersistenceError(err.code, err.message);
|
|
92
|
+
}
|
|
93
|
+
return (await resp.json());
|
|
94
|
+
}
|
|
95
|
+
async function getRun(pipelineRunId) {
|
|
96
|
+
const resp = await fetch(urlFor(`/runs/${encodeURIComponent(pipelineRunId)}`, {
|
|
97
|
+
repo_name: deps.repoName,
|
|
98
|
+
}), { headers: { "X-API-Key": deps.apiKey } });
|
|
99
|
+
if (!resp.ok) {
|
|
100
|
+
const err = await readError(resp);
|
|
101
|
+
throw new PipelinePersistenceError(err.code, err.message);
|
|
102
|
+
}
|
|
103
|
+
return (await resp.json());
|
|
104
|
+
}
|
|
105
|
+
async function patchRun(pipelineRunId, body) {
|
|
106
|
+
const resp = await fetch(urlFor(`/runs/${encodeURIComponent(pipelineRunId)}`), {
|
|
107
|
+
method: "PATCH",
|
|
108
|
+
headers,
|
|
109
|
+
body: JSON.stringify({ repo_name: deps.repoName, ...body }),
|
|
110
|
+
});
|
|
111
|
+
if (!resp.ok) {
|
|
112
|
+
const err = await readError(resp);
|
|
113
|
+
throw new PipelinePersistenceError(err.code, err.message);
|
|
114
|
+
}
|
|
115
|
+
return (await resp.json());
|
|
116
|
+
}
|
|
117
|
+
async function listRuns(status) {
|
|
118
|
+
const query = { repo_name: deps.repoName };
|
|
119
|
+
if (status)
|
|
120
|
+
query.status = status;
|
|
121
|
+
const resp = await fetch(urlFor("/runs", query), {
|
|
122
|
+
headers: { "X-API-Key": deps.apiKey },
|
|
123
|
+
});
|
|
124
|
+
if (!resp.ok) {
|
|
125
|
+
const err = await readError(resp);
|
|
126
|
+
throw new PipelinePersistenceError(err.code, err.message);
|
|
127
|
+
}
|
|
128
|
+
const body = (await resp.json());
|
|
129
|
+
return body.runs ?? [];
|
|
130
|
+
}
|
|
131
|
+
async function deleteRun(pipelineRunId) {
|
|
132
|
+
const resp = await fetch(urlFor(`/runs/${encodeURIComponent(pipelineRunId)}`, {
|
|
133
|
+
repo_name: deps.repoName,
|
|
134
|
+
}), {
|
|
135
|
+
method: "DELETE",
|
|
136
|
+
headers: { "X-API-Key": deps.apiKey },
|
|
137
|
+
});
|
|
138
|
+
if (!resp.ok) {
|
|
139
|
+
const err = await readError(resp);
|
|
140
|
+
throw new PipelinePersistenceError(err.code, err.message);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return { createRun, getRun, patchRun, listRuns, deleteRun };
|
|
144
|
+
}
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
// Helpers
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
function normalizeAutoApprove(value) {
|
|
149
|
+
if (value === true || value === "true")
|
|
150
|
+
return true;
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
function failedEnvelope(code, message, extras = {}) {
|
|
154
|
+
return {
|
|
155
|
+
status: "failed",
|
|
156
|
+
error_code: code,
|
|
157
|
+
error: message,
|
|
158
|
+
...extras,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
function isToolFailureEnvelope(text) {
|
|
162
|
+
try {
|
|
163
|
+
const parsed = JSON.parse(text);
|
|
164
|
+
return (parsed !== null &&
|
|
165
|
+
typeof parsed === "object" &&
|
|
166
|
+
typeof parsed.error === "string" &&
|
|
167
|
+
typeof parsed.status === "number");
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const ORCHESTRATION_TOOLS = new Set([
|
|
174
|
+
"run_pipeline",
|
|
175
|
+
"resume_pipeline",
|
|
176
|
+
"list_pipeline_runs",
|
|
177
|
+
"delete_pipeline_run",
|
|
178
|
+
]);
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
// runPipeline
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
export async function runPipeline(deps, input) {
|
|
183
|
+
const pipelineDef = deps.pipelines[input.pipeline];
|
|
184
|
+
if (!pipelineDef) {
|
|
185
|
+
const available = Object.keys(deps.pipelines).join(", ");
|
|
186
|
+
return failedEnvelope("VALIDATION", `Pipeline "${input.pipeline}" not found. Available pipelines: ${available || "(none)"}`);
|
|
187
|
+
}
|
|
188
|
+
if (input.variables && "auto_approve" in input.variables) {
|
|
189
|
+
return failedEnvelope("VALIDATION", "Pass auto_approve via the top-level parameter, not via the variables map.");
|
|
190
|
+
}
|
|
191
|
+
const autoApprove = normalizeAutoApprove(input.auto_approve);
|
|
192
|
+
const mergedVariables = {
|
|
193
|
+
docs_dir: deps.docsDir,
|
|
194
|
+
provider: "",
|
|
195
|
+
second_opinion: "",
|
|
196
|
+
auto_approve: autoApprove ? "true" : "",
|
|
197
|
+
...(input.variables ?? {}),
|
|
198
|
+
};
|
|
199
|
+
let recipe;
|
|
200
|
+
try {
|
|
201
|
+
recipe = resolveRecipe(pipelineDef, deps.instructions, mergedVariables, undefined, autoApprove);
|
|
202
|
+
}
|
|
203
|
+
catch (err) {
|
|
204
|
+
return failedEnvelope("VALIDATION", err instanceof Error ? err.message : String(err), { pipeline: input.pipeline });
|
|
205
|
+
}
|
|
206
|
+
const persistence = createPipelinePersistenceClient({
|
|
207
|
+
baseUrl: deps.baseUrl,
|
|
208
|
+
apiKey: deps.apiKey,
|
|
209
|
+
repoName: deps.repoName,
|
|
210
|
+
});
|
|
211
|
+
let row;
|
|
212
|
+
try {
|
|
213
|
+
row = await persistence.createRun({
|
|
214
|
+
pipeline_name: input.pipeline,
|
|
215
|
+
resolved_recipe: recipe,
|
|
216
|
+
current_step_index: 0,
|
|
217
|
+
results: [],
|
|
218
|
+
status: "running",
|
|
219
|
+
ttl_seconds: input.ttl_seconds,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
catch (err) {
|
|
223
|
+
if (err instanceof PipelinePersistenceError) {
|
|
224
|
+
return failedEnvelope(err.code, err.message, { pipeline: input.pipeline });
|
|
225
|
+
}
|
|
226
|
+
return failedEnvelope("TOOL_ERROR", err instanceof Error ? err.message : String(err), { pipeline: input.pipeline });
|
|
227
|
+
}
|
|
228
|
+
return continuePipelineExecution(deps, persistence, row, recipe, autoApprove);
|
|
229
|
+
}
|
|
230
|
+
// ---------------------------------------------------------------------------
|
|
231
|
+
// resumePipeline
|
|
232
|
+
// ---------------------------------------------------------------------------
|
|
233
|
+
export async function resumePipeline(deps, input) {
|
|
234
|
+
const persistence = createPipelinePersistenceClient({
|
|
235
|
+
baseUrl: deps.baseUrl,
|
|
236
|
+
apiKey: deps.apiKey,
|
|
237
|
+
repoName: deps.repoName,
|
|
238
|
+
});
|
|
239
|
+
let row;
|
|
240
|
+
try {
|
|
241
|
+
row = await persistence.getRun(input.pipeline_run_id);
|
|
242
|
+
}
|
|
243
|
+
catch (err) {
|
|
244
|
+
if (err instanceof PipelinePersistenceError) {
|
|
245
|
+
return failedEnvelope(err.code, err.message, {
|
|
246
|
+
pipeline_run_id: input.pipeline_run_id,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
return failedEnvelope("TOOL_ERROR", err instanceof Error ? err.message : String(err), { pipeline_run_id: input.pipeline_run_id });
|
|
250
|
+
}
|
|
251
|
+
if (row.status !== "paused") {
|
|
252
|
+
return failedEnvelope("VALIDATION", `Cannot resume pipeline run in status "${row.status}". Only paused runs can be resumed.`, {
|
|
253
|
+
pipeline_run_id: row.pipeline_run_id,
|
|
254
|
+
pipeline: row.pipeline_name,
|
|
255
|
+
results: row.results,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
const recipe = row.resolved_recipe;
|
|
259
|
+
const stepIndex = row.current_step_index;
|
|
260
|
+
const step = recipe.steps[stepIndex];
|
|
261
|
+
if (!step) {
|
|
262
|
+
return failedEnvelope("VALIDATION", `Paused step index ${stepIndex} is out of bounds for resolved recipe (total_steps=${recipe.total_steps}).`, {
|
|
263
|
+
pipeline_run_id: row.pipeline_run_id,
|
|
264
|
+
pipeline: row.pipeline_name,
|
|
265
|
+
results: row.results,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
const accumulatedResults = [...row.results];
|
|
269
|
+
let nextIndex = stepIndex;
|
|
270
|
+
let approvalAgentResult;
|
|
271
|
+
if (step.type === "agent_task") {
|
|
272
|
+
accumulatedResults.push({
|
|
273
|
+
step: step.step,
|
|
274
|
+
type: "agent_task",
|
|
275
|
+
ok: true,
|
|
276
|
+
description: step.description,
|
|
277
|
+
result: input.agent_result,
|
|
278
|
+
});
|
|
279
|
+
nextIndex = stepIndex + 1;
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
// Approval-gated mcp_call: don't push a synthetic acknowledgement here —
|
|
283
|
+
// ``continuePipelineExecution`` will push exactly one result entry for
|
|
284
|
+
// this step after executing the tool. Carry the confirmation forward so
|
|
285
|
+
// the eventual entry can record it via ``approval_agent_result``.
|
|
286
|
+
approvalAgentResult = input.agent_result;
|
|
287
|
+
nextIndex = stepIndex;
|
|
288
|
+
}
|
|
289
|
+
let updated;
|
|
290
|
+
try {
|
|
291
|
+
updated = await persistence.patchRun(row.pipeline_run_id, {
|
|
292
|
+
current_step_index: nextIndex,
|
|
293
|
+
results: accumulatedResults,
|
|
294
|
+
status: "running",
|
|
295
|
+
expected_status: "paused",
|
|
296
|
+
expected_current_step_index: stepIndex,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
catch (err) {
|
|
300
|
+
if (err instanceof PipelinePersistenceError) {
|
|
301
|
+
return failedEnvelope(err.code, err.message, {
|
|
302
|
+
pipeline_run_id: row.pipeline_run_id,
|
|
303
|
+
pipeline: row.pipeline_name,
|
|
304
|
+
results: accumulatedResults,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
return failedEnvelope("TOOL_ERROR", err instanceof Error ? err.message : String(err), {
|
|
308
|
+
pipeline_run_id: row.pipeline_run_id,
|
|
309
|
+
pipeline: row.pipeline_name,
|
|
310
|
+
results: accumulatedResults,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
const autoApprove = recipe.auto_approve;
|
|
314
|
+
return continuePipelineExecution(deps, persistence, updated, recipe, autoApprove, step.type === "mcp_call" ? stepIndex : undefined, approvalAgentResult);
|
|
315
|
+
}
|
|
316
|
+
// ---------------------------------------------------------------------------
|
|
317
|
+
// listPipelineRuns
|
|
318
|
+
// ---------------------------------------------------------------------------
|
|
319
|
+
export async function listPipelineRuns(deps, input = {}) {
|
|
320
|
+
const persistence = createPipelinePersistenceClient({
|
|
321
|
+
baseUrl: deps.baseUrl,
|
|
322
|
+
apiKey: deps.apiKey,
|
|
323
|
+
repoName: deps.repoName,
|
|
324
|
+
});
|
|
325
|
+
let metadata;
|
|
326
|
+
try {
|
|
327
|
+
metadata = await persistence.listRuns(input.status);
|
|
328
|
+
}
|
|
329
|
+
catch (err) {
|
|
330
|
+
if (err instanceof PipelinePersistenceError) {
|
|
331
|
+
return failedEnvelope(err.code, err.message);
|
|
332
|
+
}
|
|
333
|
+
return failedEnvelope("TOOL_ERROR", err instanceof Error ? err.message : String(err));
|
|
334
|
+
}
|
|
335
|
+
return {
|
|
336
|
+
status: "completed",
|
|
337
|
+
runs: metadata.map((row) => ({
|
|
338
|
+
pipeline_run_id: row.pipeline_run_id,
|
|
339
|
+
pipeline: row.pipeline_name,
|
|
340
|
+
step_index: row.total_steps !== null && row.status === "completed"
|
|
341
|
+
? row.total_steps
|
|
342
|
+
: row.current_step_index + 1,
|
|
343
|
+
total_steps: row.total_steps,
|
|
344
|
+
status: row.status,
|
|
345
|
+
created_at: row.created_at,
|
|
346
|
+
updated_at: row.updated_at,
|
|
347
|
+
expires_at: row.expires_at,
|
|
348
|
+
})),
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
// ---------------------------------------------------------------------------
|
|
352
|
+
// deletePipelineRun
|
|
353
|
+
// ---------------------------------------------------------------------------
|
|
354
|
+
export async function deletePipelineRun(deps, input) {
|
|
355
|
+
if (!input ||
|
|
356
|
+
typeof input.pipeline_run_id !== "string" ||
|
|
357
|
+
input.pipeline_run_id.length === 0) {
|
|
358
|
+
return failedEnvelope("VALIDATION", "pipeline_run_id is required.");
|
|
359
|
+
}
|
|
360
|
+
const persistence = createPipelinePersistenceClient({
|
|
361
|
+
baseUrl: deps.baseUrl,
|
|
362
|
+
apiKey: deps.apiKey,
|
|
363
|
+
repoName: deps.repoName,
|
|
364
|
+
});
|
|
365
|
+
try {
|
|
366
|
+
await persistence.deleteRun(input.pipeline_run_id);
|
|
367
|
+
}
|
|
368
|
+
catch (err) {
|
|
369
|
+
if (err instanceof PipelinePersistenceError) {
|
|
370
|
+
return failedEnvelope(err.code, err.message);
|
|
371
|
+
}
|
|
372
|
+
return failedEnvelope("TOOL_ERROR", err instanceof Error ? err.message : String(err));
|
|
373
|
+
}
|
|
374
|
+
return {
|
|
375
|
+
status: "completed",
|
|
376
|
+
pipeline_run_id: input.pipeline_run_id,
|
|
377
|
+
deleted: true,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
// ---------------------------------------------------------------------------
|
|
381
|
+
// continuePipelineExecution — shared by run + resume
|
|
382
|
+
// ---------------------------------------------------------------------------
|
|
383
|
+
async function continuePipelineExecution(deps, persistence, row, recipe, autoApprove, forceExecuteAtIndex, approvalAgentResult) {
|
|
384
|
+
let pipelineRunId = row.pipeline_run_id;
|
|
385
|
+
let stepIndex = row.current_step_index;
|
|
386
|
+
let results = [...row.results];
|
|
387
|
+
const totalSteps = recipe.total_steps;
|
|
388
|
+
while (stepIndex < totalSteps) {
|
|
389
|
+
const step = recipe.steps[stepIndex];
|
|
390
|
+
// Approval gate for mcp_call (E-4 / E-36): when the step declared
|
|
391
|
+
// requires_approval and auto_approve is false, synthesise a
|
|
392
|
+
// needs_agent_task pause unless we just resumed onto this same step.
|
|
393
|
+
if (step.type === "mcp_call" &&
|
|
394
|
+
step.requires_approval &&
|
|
395
|
+
!autoApprove &&
|
|
396
|
+
forceExecuteAtIndex !== stepIndex) {
|
|
397
|
+
const approvalInstruction = `Approval gate for pipeline step ${step.step} of ${totalSteps}: "${step.description}".\n\n` +
|
|
398
|
+
`The pipeline is about to call MCP tool \`${step.tool}\` with these resolved params:\n\n` +
|
|
399
|
+
`\`\`\`json\n${JSON.stringify(step.params ?? {}, null, 2)}\n\`\`\`\n\n` +
|
|
400
|
+
`Review the tool and params, then confirm with the user before resuming. ` +
|
|
401
|
+
`When confirmed, call \`resume_pipeline\` with \`pipeline_run_id\` "${pipelineRunId}" and ` +
|
|
402
|
+
`\`agent_result\` describing the confirmation (e.g. "approved" or a brief reason).`;
|
|
403
|
+
try {
|
|
404
|
+
await persistence.patchRun(pipelineRunId, {
|
|
405
|
+
current_step_index: stepIndex,
|
|
406
|
+
results,
|
|
407
|
+
status: "paused",
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
catch (err) {
|
|
411
|
+
return handlePersistenceErrorForEnvelope(err, {
|
|
412
|
+
pipeline_run_id: pipelineRunId,
|
|
413
|
+
pipeline: recipe.pipeline,
|
|
414
|
+
results,
|
|
415
|
+
step_index: step.step,
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
return {
|
|
419
|
+
status: "needs_agent_task",
|
|
420
|
+
pipeline_run_id: pipelineRunId,
|
|
421
|
+
pipeline: recipe.pipeline,
|
|
422
|
+
step_index: step.step,
|
|
423
|
+
total_steps: totalSteps,
|
|
424
|
+
step_description: step.description,
|
|
425
|
+
instruction: approvalInstruction,
|
|
426
|
+
results,
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
if (step.type === "agent_task") {
|
|
430
|
+
try {
|
|
431
|
+
await persistence.patchRun(pipelineRunId, {
|
|
432
|
+
current_step_index: stepIndex,
|
|
433
|
+
results,
|
|
434
|
+
status: "paused",
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
catch (err) {
|
|
438
|
+
return handlePersistenceErrorForEnvelope(err, {
|
|
439
|
+
pipeline_run_id: pipelineRunId,
|
|
440
|
+
pipeline: recipe.pipeline,
|
|
441
|
+
results,
|
|
442
|
+
step_index: step.step,
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
return {
|
|
446
|
+
status: "needs_agent_task",
|
|
447
|
+
pipeline_run_id: pipelineRunId,
|
|
448
|
+
pipeline: recipe.pipeline,
|
|
449
|
+
step_index: step.step,
|
|
450
|
+
total_steps: totalSteps,
|
|
451
|
+
step_description: step.description,
|
|
452
|
+
instruction: step.instruction ?? "",
|
|
453
|
+
results,
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
// mcp_call branch (executing now)
|
|
457
|
+
const callResult = await executeMcpCallStep(deps, step);
|
|
458
|
+
const capturedApprovalAgentResult = approvalAgentResult;
|
|
459
|
+
forceExecuteAtIndex = undefined;
|
|
460
|
+
approvalAgentResult = undefined;
|
|
461
|
+
if (callResult.ok) {
|
|
462
|
+
const entry = {
|
|
463
|
+
step: step.step,
|
|
464
|
+
type: "mcp_call",
|
|
465
|
+
ok: true,
|
|
466
|
+
description: step.description,
|
|
467
|
+
tool: step.tool,
|
|
468
|
+
result: callResult.value,
|
|
469
|
+
};
|
|
470
|
+
if (capturedApprovalAgentResult !== undefined) {
|
|
471
|
+
entry.approval_agent_result = capturedApprovalAgentResult;
|
|
472
|
+
}
|
|
473
|
+
results.push(entry);
|
|
474
|
+
stepIndex += 1;
|
|
475
|
+
try {
|
|
476
|
+
await persistence.patchRun(pipelineRunId, {
|
|
477
|
+
current_step_index: stepIndex,
|
|
478
|
+
results,
|
|
479
|
+
status: stepIndex >= totalSteps ? "completed" : "running",
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
catch (err) {
|
|
483
|
+
return handlePersistenceErrorForEnvelope(err, {
|
|
484
|
+
pipeline_run_id: pipelineRunId,
|
|
485
|
+
pipeline: recipe.pipeline,
|
|
486
|
+
results,
|
|
487
|
+
step_index: step.step,
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
// Failure path
|
|
493
|
+
const failureEntry = {
|
|
494
|
+
step: step.step,
|
|
495
|
+
type: "mcp_call",
|
|
496
|
+
ok: false,
|
|
497
|
+
description: step.description,
|
|
498
|
+
tool: step.tool,
|
|
499
|
+
error: callResult.error,
|
|
500
|
+
};
|
|
501
|
+
if (capturedApprovalAgentResult !== undefined) {
|
|
502
|
+
failureEntry.approval_agent_result = capturedApprovalAgentResult;
|
|
503
|
+
}
|
|
504
|
+
results.push(failureEntry);
|
|
505
|
+
if (step.on_error === "warn_and_continue") {
|
|
506
|
+
stepIndex += 1;
|
|
507
|
+
try {
|
|
508
|
+
await persistence.patchRun(pipelineRunId, {
|
|
509
|
+
current_step_index: stepIndex,
|
|
510
|
+
results,
|
|
511
|
+
status: stepIndex >= totalSteps ? "completed" : "running",
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
catch (err) {
|
|
515
|
+
return handlePersistenceErrorForEnvelope(err, {
|
|
516
|
+
pipeline_run_id: pipelineRunId,
|
|
517
|
+
pipeline: recipe.pipeline,
|
|
518
|
+
results,
|
|
519
|
+
step_index: step.step,
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
// halt
|
|
525
|
+
try {
|
|
526
|
+
await persistence.patchRun(pipelineRunId, {
|
|
527
|
+
current_step_index: stepIndex,
|
|
528
|
+
results,
|
|
529
|
+
status: "failed",
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
catch {
|
|
533
|
+
// best-effort — failure already happened
|
|
534
|
+
}
|
|
535
|
+
return failedEnvelope("TOOL_ERROR", callResult.error, {
|
|
536
|
+
pipeline_run_id: pipelineRunId,
|
|
537
|
+
pipeline: recipe.pipeline,
|
|
538
|
+
results,
|
|
539
|
+
step_index: step.step,
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
return {
|
|
543
|
+
status: "completed",
|
|
544
|
+
pipeline_run_id: pipelineRunId,
|
|
545
|
+
pipeline: recipe.pipeline,
|
|
546
|
+
total_steps: totalSteps,
|
|
547
|
+
results,
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
function handlePersistenceErrorForEnvelope(err, extras) {
|
|
551
|
+
if (err instanceof PipelinePersistenceError) {
|
|
552
|
+
return failedEnvelope(err.code, err.message, extras);
|
|
553
|
+
}
|
|
554
|
+
return failedEnvelope("TOOL_ERROR", err instanceof Error ? err.message : String(err), extras);
|
|
555
|
+
}
|
|
556
|
+
async function executeMcpCallStep(deps, step) {
|
|
557
|
+
if (!step.tool) {
|
|
558
|
+
return { ok: false, error: "mcp_call step missing 'tool' field" };
|
|
559
|
+
}
|
|
560
|
+
if (ORCHESTRATION_TOOLS.has(step.tool)) {
|
|
561
|
+
return {
|
|
562
|
+
ok: false,
|
|
563
|
+
error: `Refusing to invoke orchestration tool "${step.tool}" from within a pipeline step.`,
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
const entry = deps.toolHandlers.get(step.tool);
|
|
567
|
+
if (!entry) {
|
|
568
|
+
return { ok: false, error: `Unknown MCP tool "${step.tool}"` };
|
|
569
|
+
}
|
|
570
|
+
if (!entry.isEnabled()) {
|
|
571
|
+
return {
|
|
572
|
+
ok: false,
|
|
573
|
+
error: `MCP tool "${step.tool}" is currently disabled by server policy.`,
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
let toolResult;
|
|
577
|
+
try {
|
|
578
|
+
toolResult = await entry.handler(step.params ?? {});
|
|
579
|
+
}
|
|
580
|
+
catch (err) {
|
|
581
|
+
return {
|
|
582
|
+
ok: false,
|
|
583
|
+
error: err instanceof Error ? err.message : String(err),
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
const text = toolResult?.content?.[0]?.type === "text"
|
|
587
|
+
? toolResult.content[0].text
|
|
588
|
+
: "";
|
|
589
|
+
if (typeof text === "string" && isToolFailureEnvelope(text)) {
|
|
590
|
+
return { ok: false, error: text };
|
|
591
|
+
}
|
|
592
|
+
return { ok: true, value: text };
|
|
593
|
+
}
|