@agentstorm/server 0.2.5
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/LICENSE +671 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/pipeline-service.d.ts +58 -0
- package/dist/pipeline-service.js +2844 -0
- package/dist/pipeline-status.d.ts +20 -0
- package/dist/pipeline-status.js +75 -0
- package/dist/template-library.d.ts +87 -0
- package/dist/template-library.js +572 -0
- package/dist/templates/general-workflow/layout.json +88 -0
- package/dist/templates/general-workflow/manifest.json +9 -0
- package/dist/templates/general-workflow/prompts/code-reviewer.md +20 -0
- package/dist/templates/general-workflow/prompts/plan-reviewer.md +21 -0
- package/dist/templates/general-workflow/prompts/planner.md +29 -0
- package/dist/templates/general-workflow/prompts/tester.md +21 -0
- package/dist/templates/general-workflow/prompts/worker.md +21 -0
- package/dist/templates/general-workflow/workflow.json +153 -0
- package/dist/templates/sonic-pipeline/layout.json +96 -0
- package/dist/templates/sonic-pipeline/manifest.json +9 -0
- package/dist/templates/sonic-pipeline/prompts/analysis.md +32 -0
- package/dist/templates/sonic-pipeline/prompts/code-reviewer.md +21 -0
- package/dist/templates/sonic-pipeline/prompts/planner.md +37 -0
- package/dist/templates/sonic-pipeline/prompts/worker.md +25 -0
- package/dist/templates/sonic-pipeline/workflow.json +166 -0
- package/dist/workflow-package.d.ts +49 -0
- package/dist/workflow-package.js +113 -0
- package/package.json +35 -0
|
@@ -0,0 +1,2844 @@
|
|
|
1
|
+
// AgentStorm resident PipelineService implementation.
|
|
2
|
+
// The product daemon owns workspace, agent, chat, pipeline, task, workflow and run lifecycles.
|
|
3
|
+
import express from "express";
|
|
4
|
+
import * as fs from "node:fs";
|
|
5
|
+
import * as path from "node:path";
|
|
6
|
+
import { randomUUID, createHash } from "node:crypto";
|
|
7
|
+
import { ArtifactStore, Kernel, PipelineCoordinator, PipelineRunStore, TaskRunner, nowBeijing, toBeijingIso, } from "@agentstorm/kernel";
|
|
8
|
+
import { ensurePipelineSessionPaths, pipelineSessionPaths, writeSessionJsonAtomic, } from "@agentstorm/kernel";
|
|
9
|
+
import { safeParseWorkflowSpec, parseTaskList, ApprovalCommentSchema, HumanInterventionActionSchema, PipelineStartInputSchema, ResourceRefSchema, WorkflowBundleSchema, } from "@agentstorm/protocol";
|
|
10
|
+
import { buildPipelineStatusSummary } from "./pipeline-status.js";
|
|
11
|
+
import { GlobalTemplateLibrary, } from "./template-library.js";
|
|
12
|
+
import { assertInsidePackage, assertWorkflowDisplayName, latestWorkflowPackageVersion, listWorkflowPackageNames, readJsonFile, recordWorkflowPackageVersion, safePackageRelativePath, workflowPackageExists, workflowPackagePaths, workflowPromptRelativePath, writeJsonAtomic as writePackageJsonAtomic, } from "./workflow-package.js";
|
|
13
|
+
export function statusWithRunKind(status, events) {
|
|
14
|
+
const created = events.find((event) => event.t === "run_created");
|
|
15
|
+
if (!created)
|
|
16
|
+
return status;
|
|
17
|
+
const runKind = created.runKind ?? (created.taskId ? "execute" : "plan");
|
|
18
|
+
return { ...status, runKind };
|
|
19
|
+
}
|
|
20
|
+
export function taskBelongsToMainAgent(task, mainAgentId) {
|
|
21
|
+
return (task.mainAgentId ?? task.agentId) === mainAgentId;
|
|
22
|
+
}
|
|
23
|
+
export async function createPipelineService(opts) {
|
|
24
|
+
// A fresh identity lets clients distinguish a daemon restart from a healthy
|
|
25
|
+
// continuation of the same in-process Pipeline runtime.
|
|
26
|
+
const serverId = randomUUID();
|
|
27
|
+
// Map workspaceId → Kernel. Default workspace = cwd.
|
|
28
|
+
const kernels = new Map();
|
|
29
|
+
// The template library is daemon-host scoped, not Workspace scoped. Its
|
|
30
|
+
// default root is $AGENTSTORM_HOME/templates and can be overridden by
|
|
31
|
+
// AGENTSTORM_TEMPLATE_HOME for an intentionally shared test/development
|
|
32
|
+
// installation.
|
|
33
|
+
// Do not touch the daemon user's template home until templates are requested.
|
|
34
|
+
// Apart from avoiding unnecessary disk work, lazy construction keeps health
|
|
35
|
+
// and existing Workflow routes usable in restricted test containers where
|
|
36
|
+
// the optional global template directory is not writable yet.
|
|
37
|
+
let templateLibrary = null;
|
|
38
|
+
const getTemplateLibrary = () => (templateLibrary ??= new GlobalTemplateLibrary());
|
|
39
|
+
function getOrCreateKernel(workspaceId, rootPath) {
|
|
40
|
+
const existing = kernels.get(workspaceId);
|
|
41
|
+
if (existing)
|
|
42
|
+
return existing;
|
|
43
|
+
const agentstormDir = path.join(rootPath, ".agentstorm");
|
|
44
|
+
const unboundRoot = path.join(agentstormDir, "unbound");
|
|
45
|
+
const unboundPipelineRoot = path.join(unboundRoot, "pipeline");
|
|
46
|
+
fs.mkdirSync(unboundPipelineRoot, { recursive: true });
|
|
47
|
+
const backend = opts.agentRuntime;
|
|
48
|
+
const kernel = new Kernel({
|
|
49
|
+
runsRoot: unboundPipelineRoot,
|
|
50
|
+
tasksRoot: path.join(unboundPipelineRoot, "tasks"),
|
|
51
|
+
layout: "session",
|
|
52
|
+
sessionRoot: unboundPipelineRoot,
|
|
53
|
+
backend,
|
|
54
|
+
cwd: rootPath,
|
|
55
|
+
});
|
|
56
|
+
const wk = {
|
|
57
|
+
workspaceId,
|
|
58
|
+
rootPath,
|
|
59
|
+
kernel,
|
|
60
|
+
backend,
|
|
61
|
+
taskRunners: new Map(),
|
|
62
|
+
coordinators: new Map(),
|
|
63
|
+
pipelineStore: new PipelineRunStore(unboundPipelineRoot, {
|
|
64
|
+
layout: "session",
|
|
65
|
+
}),
|
|
66
|
+
artifacts: new ArtifactStore(unboundPipelineRoot, { layout: "session" }),
|
|
67
|
+
agentIds: new Set(),
|
|
68
|
+
sessions: new Map(),
|
|
69
|
+
};
|
|
70
|
+
kernels.set(workspaceId, wk);
|
|
71
|
+
return wk;
|
|
72
|
+
}
|
|
73
|
+
function getOrCreateSession(workspace, agentId) {
|
|
74
|
+
const existing = workspace.sessions.get(agentId);
|
|
75
|
+
if (existing)
|
|
76
|
+
return existing;
|
|
77
|
+
const agentstormDir = path.join(workspace.rootPath, ".agentstorm");
|
|
78
|
+
const paths = pipelineSessionPaths(agentstormDir, agentId);
|
|
79
|
+
ensurePipelineSessionPaths(paths);
|
|
80
|
+
const kernel = new Kernel({
|
|
81
|
+
runsRoot: paths.pipelineRoot,
|
|
82
|
+
tasksRoot: paths.tasksRoot,
|
|
83
|
+
layout: "session",
|
|
84
|
+
sessionRoot: paths.pipelineRoot,
|
|
85
|
+
backend: workspace.backend,
|
|
86
|
+
cwd: workspace.rootPath,
|
|
87
|
+
mainAgentId: agentId,
|
|
88
|
+
});
|
|
89
|
+
const session = {
|
|
90
|
+
workspaceId: workspace.workspaceId,
|
|
91
|
+
rootPath: workspace.rootPath,
|
|
92
|
+
kernel,
|
|
93
|
+
backend: workspace.backend,
|
|
94
|
+
taskRunners: new Map(),
|
|
95
|
+
coordinators: new Map(),
|
|
96
|
+
pipelineStore: new PipelineRunStore(paths.pipelineRoot, {
|
|
97
|
+
layout: "session",
|
|
98
|
+
}),
|
|
99
|
+
artifacts: new ArtifactStore(paths.pipelineRoot, { layout: "session" }),
|
|
100
|
+
agentIds: new Set([agentId]),
|
|
101
|
+
sessions: new Map(),
|
|
102
|
+
sessionAgentId: agentId,
|
|
103
|
+
};
|
|
104
|
+
const existingSession = fs.existsSync(paths.sessionFile)
|
|
105
|
+
? readSessionRecord(paths.sessionFile)
|
|
106
|
+
: null;
|
|
107
|
+
const legacyBinding = readLegacyAgentWorkflowBindings(workspace.rootPath)[agentId];
|
|
108
|
+
writeSessionJsonAtomic(paths.sessionFile, {
|
|
109
|
+
...(existingSession ?? {}),
|
|
110
|
+
sessionId: agentId,
|
|
111
|
+
mainAgentId: agentId,
|
|
112
|
+
workspaceId: workspace.workspaceId,
|
|
113
|
+
...(typeof existingSession?.workflowId === "string"
|
|
114
|
+
? { workflowId: existingSession.workflowId }
|
|
115
|
+
: legacyBinding
|
|
116
|
+
? { workflowId: legacyBinding }
|
|
117
|
+
: {}),
|
|
118
|
+
updatedAt: nowBeijing(),
|
|
119
|
+
});
|
|
120
|
+
workspace.sessions.set(agentId, session);
|
|
121
|
+
return session;
|
|
122
|
+
}
|
|
123
|
+
// Default workspace = cwd
|
|
124
|
+
getOrCreateKernel("default", opts.cwd);
|
|
125
|
+
// Find kernel by runId
|
|
126
|
+
function findKernelByRunId(runId) {
|
|
127
|
+
for (const wk of kernels.values()) {
|
|
128
|
+
try {
|
|
129
|
+
wk.kernel.status(runId);
|
|
130
|
+
return wk;
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
/* not here */
|
|
134
|
+
}
|
|
135
|
+
for (const session of wk.sessions.values()) {
|
|
136
|
+
try {
|
|
137
|
+
session.kernel.status(runId);
|
|
138
|
+
return session;
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
/* not here */
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
// Find kernel by agentId. A main Agent exists before it has any tasks, so
|
|
148
|
+
// explicit bindings must be consulted before the legacy task scan.
|
|
149
|
+
function findKernelByAgentId(agentId) {
|
|
150
|
+
for (const wk of kernels.values()) {
|
|
151
|
+
const session = wk.sessions.get(agentId);
|
|
152
|
+
if (session)
|
|
153
|
+
return session;
|
|
154
|
+
if (wk.agentIds.has(agentId))
|
|
155
|
+
return wk;
|
|
156
|
+
const tasks = wk.kernel.tasks.list();
|
|
157
|
+
if (tasks.some((t) => t.agentId === agentId))
|
|
158
|
+
return wk;
|
|
159
|
+
for (const candidate of wk.sessions.values()) {
|
|
160
|
+
if (candidate.agentIds.has(agentId) ||
|
|
161
|
+
candidate.kernel.tasks.list().some((t) => t.agentId === agentId))
|
|
162
|
+
return candidate;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
// === Routes ===
|
|
168
|
+
const router = new PipelineRouteRegistry();
|
|
169
|
+
router.get("/health", (_req, res) => {
|
|
170
|
+
res.json({
|
|
171
|
+
serverId,
|
|
172
|
+
version: "0.0.0",
|
|
173
|
+
tokenRequired: false,
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
// Paseo owns Workspace creation. The embedded Pipeline UI registers the
|
|
177
|
+
// daemon-issued workspace ID with its already-resolved directory before it
|
|
178
|
+
// accesses workflow or run routes. Keeping the mapping here gives every
|
|
179
|
+
// Workspace a distinct Kernel and .agentstorm directory instead of silently
|
|
180
|
+
// putting all sessions in the fallback `default` runtime.
|
|
181
|
+
router.post("/workspaces/:wid/register", (req, res) => {
|
|
182
|
+
const rootPath = typeof req.body?.rootPath === "string" ? req.body.rootPath.trim() : "";
|
|
183
|
+
if (!rootPath || !path.isAbsolute(rootPath) || !fs.existsSync(rootPath)) {
|
|
184
|
+
res
|
|
185
|
+
.status(400)
|
|
186
|
+
.json({ error: "rootPath must be an existing absolute directory" });
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (!fs.statSync(rootPath).isDirectory()) {
|
|
190
|
+
res.status(400).json({ error: "rootPath must be a directory" });
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const normalizedRootPath = path.resolve(rootPath);
|
|
194
|
+
const existing = kernels.get(req.params.wid);
|
|
195
|
+
if (existing && existing.rootPath !== normalizedRootPath) {
|
|
196
|
+
res.status(409).json({
|
|
197
|
+
error: "workspace is already registered to another directory",
|
|
198
|
+
});
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const workspace = getOrCreateKernel(req.params.wid, normalizedRootPath);
|
|
202
|
+
res.json({
|
|
203
|
+
workspaceId: workspace.workspaceId,
|
|
204
|
+
rootPath: workspace.rootPath,
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
// The Pipeline UI registers its Paseo main Agent after the Workspace mapping.
|
|
208
|
+
// This lets an empty Pipeline resolve to the right Kernel before the first
|
|
209
|
+
// Task is added or a run starts.
|
|
210
|
+
router.post("/workspaces/:wid/agents/:agentId/register", (req, res) => {
|
|
211
|
+
const workspace = kernels.get(req.params.wid);
|
|
212
|
+
if (!workspace) {
|
|
213
|
+
res.status(404).json({ error: "workspace not found" });
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
workspace.agentIds.add(req.params.agentId);
|
|
217
|
+
getOrCreateSession(workspace, req.params.agentId);
|
|
218
|
+
res.json({
|
|
219
|
+
workspaceId: workspace.workspaceId,
|
|
220
|
+
agentId: req.params.agentId,
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
// --- Global template library ---
|
|
224
|
+
// These routes are still reached through Paseo's authenticated daemon proxy;
|
|
225
|
+
// the browser never gets a filesystem path or direct storage access.
|
|
226
|
+
router.get("/templates", (_req, res) => {
|
|
227
|
+
try {
|
|
228
|
+
res.json(getTemplateLibrary().list());
|
|
229
|
+
}
|
|
230
|
+
catch (error) {
|
|
231
|
+
res.status(500).json({
|
|
232
|
+
error: error instanceof Error ? error.message : String(error),
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
router.get("/templates/:id", (req, res) => {
|
|
237
|
+
try {
|
|
238
|
+
res.json(getTemplateLibrary().get(req.params.id));
|
|
239
|
+
}
|
|
240
|
+
catch (error) {
|
|
241
|
+
res.status(404).json({
|
|
242
|
+
error: error instanceof Error ? error.message : String(error),
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
router.post("/templates", (req, res) => {
|
|
247
|
+
try {
|
|
248
|
+
res
|
|
249
|
+
.status(201)
|
|
250
|
+
.json(getTemplateLibrary().create(req.body));
|
|
251
|
+
}
|
|
252
|
+
catch (error) {
|
|
253
|
+
res.status(400).json({
|
|
254
|
+
error: error instanceof Error ? error.message : String(error),
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
router.put("/templates/:id", (req, res) => {
|
|
259
|
+
try {
|
|
260
|
+
res.json(getTemplateLibrary().update(req.params.id, req.body, req.header("if-match") ?? undefined));
|
|
261
|
+
}
|
|
262
|
+
catch (error) {
|
|
263
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
264
|
+
res
|
|
265
|
+
.status(message.includes("ETag mismatch") ? 409 : 400)
|
|
266
|
+
.json({ error: message });
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
router.delete("/templates/:id", (req, res) => {
|
|
270
|
+
try {
|
|
271
|
+
getTemplateLibrary().delete(req.params.id);
|
|
272
|
+
res.json({ ok: true, id: req.params.id });
|
|
273
|
+
}
|
|
274
|
+
catch (error) {
|
|
275
|
+
res.status(404).json({
|
|
276
|
+
error: error instanceof Error ? error.message : String(error),
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
// Paseo calls this before archiving a managed Pipeline Workspace. Pausing is
|
|
281
|
+
// deliberate: the kernel writes the normal human_command/node_paused audit
|
|
282
|
+
// events before the Workspace record disappears, and never silently lets a
|
|
283
|
+
// child Agent continue after its parent Workspace has been archived.
|
|
284
|
+
router.post("/workspaces/:wid/archive", async (req, res) => {
|
|
285
|
+
const workspace = kernels.get(req.params.wid);
|
|
286
|
+
if (!workspace) {
|
|
287
|
+
res.status(404).json({ error: "workspace not found" });
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
const runIds = [];
|
|
291
|
+
const archivedAgentIds = [];
|
|
292
|
+
const runtimes = [workspace, ...workspace.sessions.values()];
|
|
293
|
+
for (const runtime of runtimes) {
|
|
294
|
+
for (const runner of runtime.taskRunners.values())
|
|
295
|
+
runner.pause();
|
|
296
|
+
for (const task of runtime.kernel.tasks.list()) {
|
|
297
|
+
if (!task.runId)
|
|
298
|
+
continue;
|
|
299
|
+
try {
|
|
300
|
+
runtime.kernel.pause(task.runId);
|
|
301
|
+
runIds.push(task.runId);
|
|
302
|
+
archivedAgentIds.push(...(await runtime.kernel.archiveRunAgents(task.runId)));
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
// A persisted task can reference a run deleted by a previous cleanup.
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
res.json({
|
|
310
|
+
runIds,
|
|
311
|
+
archivedAgentIds: Array.from(new Set(archivedAgentIds)),
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
// --- Workflows ---
|
|
315
|
+
router.get("/workspaces/:wid/workflows", (req, res) => {
|
|
316
|
+
const wk = kernels.get(req.params.wid);
|
|
317
|
+
if (!wk) {
|
|
318
|
+
res.status(404).json({ error: "workspace not found" });
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
const documents = listWorkflowPackageNames(wk.rootPath).flatMap((id) => {
|
|
322
|
+
try {
|
|
323
|
+
const files = workflowPackagePaths(wk.rootPath, id);
|
|
324
|
+
const raw = fs.readFileSync(files.workflow, "utf8");
|
|
325
|
+
const manifest = fs.existsSync(files.manifest)
|
|
326
|
+
? readJsonFile(files.manifest)
|
|
327
|
+
: {};
|
|
328
|
+
const source = manifest.source;
|
|
329
|
+
return [
|
|
330
|
+
{
|
|
331
|
+
id,
|
|
332
|
+
spec: JSON.parse(raw),
|
|
333
|
+
etag: etagOf(raw),
|
|
334
|
+
updatedAt: toBeijingIso(fs.statSync(files.workflow).mtime),
|
|
335
|
+
version: latestWorkflowPackageVersion(files),
|
|
336
|
+
...(typeof manifest.description === "string"
|
|
337
|
+
? { description: manifest.description }
|
|
338
|
+
: {}),
|
|
339
|
+
...(source &&
|
|
340
|
+
typeof source === "object" &&
|
|
341
|
+
!Array.isArray(source) &&
|
|
342
|
+
typeof source.templateId === "string"
|
|
343
|
+
? {
|
|
344
|
+
templateId: source
|
|
345
|
+
.templateId,
|
|
346
|
+
...(Number.isSafeInteger(source.templateVersion)
|
|
347
|
+
? {
|
|
348
|
+
templateVersion: Number(source.templateVersion),
|
|
349
|
+
}
|
|
350
|
+
: {}),
|
|
351
|
+
}
|
|
352
|
+
: {}),
|
|
353
|
+
},
|
|
354
|
+
];
|
|
355
|
+
}
|
|
356
|
+
catch {
|
|
357
|
+
return [];
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
res.json(documents);
|
|
361
|
+
});
|
|
362
|
+
router.post("/workspaces/:wid/workflows", (req, res) => {
|
|
363
|
+
const wk = kernels.get(req.params.wid);
|
|
364
|
+
if (!wk) {
|
|
365
|
+
res.status(404).json({ error: "workspace not found" });
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
const result = safeParseWorkflowSpec(req.body);
|
|
369
|
+
if (!result.success) {
|
|
370
|
+
res.status(400).json({ error: result.error });
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
let id;
|
|
374
|
+
try {
|
|
375
|
+
id = assertWorkflowDisplayName(result.data.name || `workflow-${Date.now()}`);
|
|
376
|
+
}
|
|
377
|
+
catch (error) {
|
|
378
|
+
res.status(400).json({
|
|
379
|
+
error: error instanceof Error ? error.message : String(error),
|
|
380
|
+
});
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
if (workflowPackageExists(wk.rootPath, id)) {
|
|
384
|
+
res.status(409).json({ error: `Workflow “${id}” 已存在` });
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
try {
|
|
388
|
+
validateWorkflowPromptReferences(result.data);
|
|
389
|
+
}
|
|
390
|
+
catch (error) {
|
|
391
|
+
res.status(400).json({
|
|
392
|
+
error: error instanceof Error ? error.message : String(error),
|
|
393
|
+
});
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
const files = workflowPackagePaths(wk.rootPath, id);
|
|
397
|
+
const raw = JSON.stringify(result.data, null, 2);
|
|
398
|
+
writePackageJsonAtomic(files.workflow, result.data);
|
|
399
|
+
writePackageJsonAtomic(files.layout, {});
|
|
400
|
+
writePackageJsonAtomic(files.manifest, {
|
|
401
|
+
schemaVersion: 1,
|
|
402
|
+
name: id,
|
|
403
|
+
description: "",
|
|
404
|
+
version: 1,
|
|
405
|
+
source: { kind: "blank" },
|
|
406
|
+
createdAt: nowBeijing(),
|
|
407
|
+
updatedAt: nowBeijing(),
|
|
408
|
+
});
|
|
409
|
+
ensureWorkflowPromptFiles(files.root, result.data);
|
|
410
|
+
const version = recordWorkflowPackageVersion(files, raw);
|
|
411
|
+
res.json({
|
|
412
|
+
id,
|
|
413
|
+
spec: result.data,
|
|
414
|
+
etag: etagOf(raw),
|
|
415
|
+
updatedAt: nowBeijing(),
|
|
416
|
+
version,
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
router.post("/workspaces/:wid/workflows/from-template", (req, res) => {
|
|
420
|
+
const wk = kernels.get(req.params.wid);
|
|
421
|
+
if (!wk) {
|
|
422
|
+
res.status(404).json({ error: "workspace not found" });
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
const templateId = typeof req.body?.templateId === "string"
|
|
426
|
+
? req.body.templateId.trim()
|
|
427
|
+
: "";
|
|
428
|
+
if (!templateId) {
|
|
429
|
+
res.status(400).json({ error: "templateId is required" });
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
try {
|
|
433
|
+
const result = getTemplateLibrary().materialize(templateId, wk.rootPath, {
|
|
434
|
+
...(req.body?.name ? { name: String(req.body.name) } : {}),
|
|
435
|
+
...(req.body?.agentConfig
|
|
436
|
+
? { agentConfig: req.body.agentConfig }
|
|
437
|
+
: {}),
|
|
438
|
+
});
|
|
439
|
+
res.status(201).json(result);
|
|
440
|
+
}
|
|
441
|
+
catch (error) {
|
|
442
|
+
res.status(400).json({
|
|
443
|
+
error: error instanceof Error ? error.message : String(error),
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
// Portable P4 bundle: graph + Prompt assets. Everything is kept inside one
|
|
448
|
+
// display-name Workflow package; the runtime later uses that package root
|
|
449
|
+
// as specDir, so @prompts/... is stable after export/import.
|
|
450
|
+
router.post("/workspaces/:wid/workflow-bundles", (req, res) => {
|
|
451
|
+
const wk = kernels.get(req.params.wid);
|
|
452
|
+
if (!wk) {
|
|
453
|
+
res.status(404).json({ error: "workspace not found" });
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
const parsed = WorkflowBundleSchema.safeParse(req.body);
|
|
457
|
+
if (!parsed.success) {
|
|
458
|
+
res.status(400).json({ error: parsed.error });
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
try {
|
|
462
|
+
const id = assertWorkflowDisplayName(parsed.data.spec.name);
|
|
463
|
+
if (workflowPackageExists(wk.rootPath, id)) {
|
|
464
|
+
res.status(409).json({ error: `Workflow “${id}” 已存在` });
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
const files = workflowPackagePaths(wk.rootPath, id);
|
|
468
|
+
for (const [assetName, content] of Object.entries(parsed.data.assets)) {
|
|
469
|
+
const relative = safePackageRelativePath(assetName);
|
|
470
|
+
if (!relative || content.length > 8 * 1024 * 1024)
|
|
471
|
+
throw new Error(`invalid workflow asset: ${assetName}`);
|
|
472
|
+
const target = path.resolve(files.root, relative);
|
|
473
|
+
assertInsidePackage(files.root, target);
|
|
474
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
475
|
+
fs.writeFileSync(target, content, "utf8");
|
|
476
|
+
}
|
|
477
|
+
const spec = rewriteBundlePromptRefs(parsed.data.spec, Object.keys(parsed.data.assets));
|
|
478
|
+
validateWorkflowPromptReferences(spec);
|
|
479
|
+
const raw = JSON.stringify(spec, null, 2);
|
|
480
|
+
writePackageJsonAtomic(files.workflow, spec);
|
|
481
|
+
writePackageJsonAtomic(files.layout, parsed.data.layout ?? {});
|
|
482
|
+
writePackageJsonAtomic(files.manifest, {
|
|
483
|
+
schemaVersion: 1,
|
|
484
|
+
name: id,
|
|
485
|
+
description: "",
|
|
486
|
+
version: 1,
|
|
487
|
+
source: { kind: "bundle" },
|
|
488
|
+
createdAt: nowBeijing(),
|
|
489
|
+
updatedAt: nowBeijing(),
|
|
490
|
+
});
|
|
491
|
+
ensureWorkflowPromptFiles(files.root, spec);
|
|
492
|
+
const version = recordWorkflowPackageVersion(files, raw);
|
|
493
|
+
res.json({
|
|
494
|
+
id,
|
|
495
|
+
spec,
|
|
496
|
+
etag: etagOf(raw),
|
|
497
|
+
updatedAt: nowBeijing(),
|
|
498
|
+
version,
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
catch (error) {
|
|
502
|
+
res.status(400).json({
|
|
503
|
+
error: error instanceof Error ? error.message : String(error),
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
});
|
|
507
|
+
router.get("/workspaces/:wid/workflows/:id/bundle", (req, res) => {
|
|
508
|
+
const wk = kernels.get(req.params.wid);
|
|
509
|
+
if (!wk) {
|
|
510
|
+
res.status(404).json({ error: "workspace not found" });
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
if (!isSafeWorkflowId(req.params.id)) {
|
|
514
|
+
res.status(400).json({ error: "invalid workflow id" });
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
const files = workflowPackagePaths(wk.rootPath, req.params.id);
|
|
518
|
+
if (!fs.existsSync(files.workflow)) {
|
|
519
|
+
res.status(404).json({ error: "workflow not found" });
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
const specResult = safeParseWorkflowSpec(readJsonFile(files.workflow));
|
|
523
|
+
if (!specResult.success) {
|
|
524
|
+
res.status(500).json({ error: "stored workflow is invalid" });
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
try {
|
|
528
|
+
res.json({
|
|
529
|
+
format: "agentstorm.workflow.bundle.v1",
|
|
530
|
+
spec: specResult.data,
|
|
531
|
+
assets: collectPromptAssets(files.root, specResult.data),
|
|
532
|
+
layout: fs.existsSync(files.layout)
|
|
533
|
+
? readJsonFile(files.layout)
|
|
534
|
+
: {},
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
catch (error) {
|
|
538
|
+
res.status(400).json({
|
|
539
|
+
error: error instanceof Error ? error.message : String(error),
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
});
|
|
543
|
+
// Provider-aware skill discovery. The old mixed-directory scan is gone: the
|
|
544
|
+
// Paseo Provider owns discovery and only reports skills it can actually load
|
|
545
|
+
// for this workspace/model/mode combination.
|
|
546
|
+
router.post("/workspaces/:wid/skills", async (req, res) => {
|
|
547
|
+
const wk = kernels.get(req.params.wid);
|
|
548
|
+
if (!wk) {
|
|
549
|
+
res.status(404).json({ error: "workspace not found" });
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
const provider = typeof req.body?.provider === "string" ? req.body.provider.trim() : "";
|
|
553
|
+
const model = typeof req.body?.model === "string" ? req.body.model.trim() : "";
|
|
554
|
+
if (!provider || !model) {
|
|
555
|
+
res.status(400).json({ error: "provider and model are required" });
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
if (!wk.backend.listSkills) {
|
|
559
|
+
res.json({ provider, status: "unsupported", skills: [] });
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
try {
|
|
563
|
+
const skills = await wk.backend.listSkills({
|
|
564
|
+
provider,
|
|
565
|
+
model,
|
|
566
|
+
cwd: wk.rootPath,
|
|
567
|
+
...(typeof req.body?.mode === "string" && req.body.mode.trim()
|
|
568
|
+
? { mode: req.body.mode.trim() }
|
|
569
|
+
: {}),
|
|
570
|
+
...(typeof req.body?.thinking === "string" && req.body.thinking.trim()
|
|
571
|
+
? { thinking: req.body.thinking.trim() }
|
|
572
|
+
: {}),
|
|
573
|
+
});
|
|
574
|
+
res.json({
|
|
575
|
+
provider,
|
|
576
|
+
status: "ready",
|
|
577
|
+
skills: skills.map((skill) => ({
|
|
578
|
+
name: skill.name,
|
|
579
|
+
description: skill.description ?? "",
|
|
580
|
+
argumentHint: skill.argumentHint ?? "",
|
|
581
|
+
source: "provider",
|
|
582
|
+
loadable: true,
|
|
583
|
+
})),
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
catch (error) {
|
|
587
|
+
res.json({
|
|
588
|
+
provider,
|
|
589
|
+
status: "unavailable",
|
|
590
|
+
skills: [],
|
|
591
|
+
error: error instanceof Error ? error.message : String(error),
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
});
|
|
595
|
+
// Compatibility response for old clients. It deliberately returns no
|
|
596
|
+
// mixed skill list; callers must provide a Provider-aware request instead.
|
|
597
|
+
router.get("/workspaces/:wid/skills", (req, res) => {
|
|
598
|
+
const wk = kernels.get(req.params.wid);
|
|
599
|
+
if (!wk) {
|
|
600
|
+
res.status(404).json({ error: "workspace not found" });
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
res.json({ provider: null, status: "unsupported", skills: [] });
|
|
604
|
+
});
|
|
605
|
+
// A Workflow belongs to a Workspace, but the selected Workflow belongs to
|
|
606
|
+
// its persistent main Agent (the Paseo conversation). Keeping the binding
|
|
607
|
+
// per Agent prevents two Pipeline conversations in one Workspace from
|
|
608
|
+
// silently changing each other's next run.
|
|
609
|
+
router.get("/workspaces/:wid/agents/:agentId/workflow", async (req, res) => {
|
|
610
|
+
const workspace = scopedAgent(req.params.wid, req.params.agentId);
|
|
611
|
+
if (!workspace) {
|
|
612
|
+
res.status(404).json({ error: "pipeline agent not found" });
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
// A Pipeline session has no implicit Workflow. Only an explicit
|
|
616
|
+
// per-Agent binding is a selection; even a Workspace with one Workflow
|
|
617
|
+
// must remain unbound until the user chooses “设为当前”.
|
|
618
|
+
const workflowId = readAgentWorkflowBindings(workspace.rootPath)[req.params.agentId] ?? null;
|
|
619
|
+
if (!workflowId) {
|
|
620
|
+
res.json({ workflowId });
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
try {
|
|
624
|
+
const workflowPath = workflowPackagePaths(workspace.rootPath, workflowId).workflow;
|
|
625
|
+
const parsed = safeParseWorkflowSpec(readJsonFile(workflowPath));
|
|
626
|
+
res.json({
|
|
627
|
+
workflowId,
|
|
628
|
+
readiness: parsed.success
|
|
629
|
+
? await workflowReadinessWithBackend(path.dirname(workflowPath), parsed.data, workspace.backend, workspace.rootPath)
|
|
630
|
+
: {
|
|
631
|
+
ready: false,
|
|
632
|
+
errors: ["stored workflow is invalid"],
|
|
633
|
+
missingPromptAssets: [],
|
|
634
|
+
missingSkills: [],
|
|
635
|
+
agents: [],
|
|
636
|
+
},
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
catch {
|
|
640
|
+
res.json({
|
|
641
|
+
workflowId,
|
|
642
|
+
readiness: {
|
|
643
|
+
ready: false,
|
|
644
|
+
errors: ["workflow definition cannot be read"],
|
|
645
|
+
missingPromptAssets: [],
|
|
646
|
+
missingSkills: [],
|
|
647
|
+
agents: [],
|
|
648
|
+
},
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
});
|
|
652
|
+
router.put("/workspaces/:wid/agents/:agentId/workflow", (req, res) => {
|
|
653
|
+
const workspace = scopedAgent(req.params.wid, req.params.agentId);
|
|
654
|
+
if (!workspace) {
|
|
655
|
+
res.status(404).json({ error: "pipeline agent not found" });
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
const workflowId = req.body?.workflowId;
|
|
659
|
+
if (typeof workflowId !== "string" || !isSafeWorkflowId(workflowId)) {
|
|
660
|
+
res.status(400).json({ error: "invalid workflow id" });
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
const workflowPath = workflowPackagePaths(workspace.rootPath, workflowId).workflow;
|
|
664
|
+
if (!fs.existsSync(workflowPath)) {
|
|
665
|
+
res.status(404).json({ error: "workflow not found" });
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
writeAgentWorkflowBinding(workspace.rootPath, req.params.agentId, workflowId);
|
|
669
|
+
res.json({ workflowId });
|
|
670
|
+
});
|
|
671
|
+
// Scoped workflow reads/writes are used by the Paseo daemon proxy. Unlike
|
|
672
|
+
// the legacy global workflow routes below, these can never cross a Workspace
|
|
673
|
+
// boundary when two projects happen to use the same workflow name.
|
|
674
|
+
router.get("/workspaces/:wid/workflows/:id", (req, res) => {
|
|
675
|
+
const wk = kernels.get(req.params.wid);
|
|
676
|
+
if (!wk) {
|
|
677
|
+
res.status(404).json({ error: "workspace not found" });
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
if (!isSafeWorkflowId(req.params.id)) {
|
|
681
|
+
res.status(400).json({ error: "invalid workflow id" });
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
const files = workflowPackagePaths(wk.rootPath, req.params.id);
|
|
685
|
+
const fp = files.workflow;
|
|
686
|
+
if (!fs.existsSync(fp)) {
|
|
687
|
+
res.status(404).json({ error: "workflow not found" });
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
const raw = fs.readFileSync(fp, "utf8");
|
|
691
|
+
const manifest = fs.existsSync(files.manifest)
|
|
692
|
+
? readJsonFile(files.manifest)
|
|
693
|
+
: {};
|
|
694
|
+
const source = manifest.source;
|
|
695
|
+
res.json({
|
|
696
|
+
id: req.params.id,
|
|
697
|
+
spec: JSON.parse(raw),
|
|
698
|
+
etag: etagOf(raw),
|
|
699
|
+
updatedAt: toBeijingIso(fs.statSync(fp).mtime),
|
|
700
|
+
version: latestWorkflowPackageVersion(workflowPackagePaths(wk.rootPath, req.params.id)),
|
|
701
|
+
...(typeof manifest.description === "string"
|
|
702
|
+
? { description: manifest.description }
|
|
703
|
+
: {}),
|
|
704
|
+
...(source &&
|
|
705
|
+
typeof source === "object" &&
|
|
706
|
+
!Array.isArray(source) &&
|
|
707
|
+
typeof source.templateId === "string"
|
|
708
|
+
? {
|
|
709
|
+
templateId: source
|
|
710
|
+
.templateId,
|
|
711
|
+
...(Number.isSafeInteger(source.templateVersion)
|
|
712
|
+
? {
|
|
713
|
+
templateVersion: Number(source.templateVersion),
|
|
714
|
+
}
|
|
715
|
+
: {}),
|
|
716
|
+
}
|
|
717
|
+
: {}),
|
|
718
|
+
});
|
|
719
|
+
});
|
|
720
|
+
router.delete("/workspaces/:wid/workflows/:id", (req, res) => {
|
|
721
|
+
const wk = kernels.get(req.params.wid);
|
|
722
|
+
if (!wk) {
|
|
723
|
+
res.status(404).json({ error: "workspace not found" });
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
if (!isSafeWorkflowId(req.params.id)) {
|
|
727
|
+
res.status(400).json({ error: "invalid workflow id" });
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
const workflowId = req.params.id;
|
|
731
|
+
const files = workflowPackagePaths(wk.rootPath, workflowId);
|
|
732
|
+
const fp = files.workflow;
|
|
733
|
+
if (!fs.existsSync(fp)) {
|
|
734
|
+
res.status(404).json({ error: "workflow not found" });
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
// Deleting a template is an explicit user action. Clear any persistent
|
|
738
|
+
// session bindings together with the file instead of leaving a dangling
|
|
739
|
+
// Workflow ID that makes the next Pipeline page look broken.
|
|
740
|
+
const clearedAgentIds = clearAgentWorkflowBindings(wk.rootPath, workflowId);
|
|
741
|
+
invalidateWorkflowCoordinators(wk, workflowId);
|
|
742
|
+
fs.rmSync(files.root, { recursive: true, force: true });
|
|
743
|
+
res.json({ ok: true, workflowId, clearedAgentIds });
|
|
744
|
+
});
|
|
745
|
+
router.put("/workspaces/:wid/workflows/:id", (req, res) => {
|
|
746
|
+
const wk = kernels.get(req.params.wid);
|
|
747
|
+
if (!wk) {
|
|
748
|
+
res.status(404).json({ error: "workspace not found" });
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
if (!isSafeWorkflowId(req.params.id)) {
|
|
752
|
+
res.status(400).json({ error: "invalid workflow id" });
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
const result = safeParseWorkflowSpec(req.body);
|
|
756
|
+
if (!result.success) {
|
|
757
|
+
res.status(400).json({ error: result.error });
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
const oldId = req.params.id;
|
|
761
|
+
const files = workflowPackagePaths(wk.rootPath, oldId);
|
|
762
|
+
const fp = files.workflow;
|
|
763
|
+
if (!fs.existsSync(fp)) {
|
|
764
|
+
res.status(404).json({ error: "workflow not found" });
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
const existing = fs.readFileSync(fp, "utf8");
|
|
768
|
+
const ifMatch = req.headers["if-match"];
|
|
769
|
+
if (ifMatch && ifMatch !== etagOf(existing)) {
|
|
770
|
+
res.status(409).json({ error: "ETag mismatch" });
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
let newId;
|
|
774
|
+
try {
|
|
775
|
+
newId = assertWorkflowDisplayName(result.data.name);
|
|
776
|
+
}
|
|
777
|
+
catch (error) {
|
|
778
|
+
res.status(400).json({
|
|
779
|
+
error: error instanceof Error ? error.message : String(error),
|
|
780
|
+
});
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
let targetFiles = files;
|
|
784
|
+
try {
|
|
785
|
+
validateWorkflowPromptReferences(result.data);
|
|
786
|
+
}
|
|
787
|
+
catch (error) {
|
|
788
|
+
res.status(400).json({
|
|
789
|
+
error: error instanceof Error ? error.message : String(error),
|
|
790
|
+
});
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
if (newId !== oldId) {
|
|
794
|
+
if (workflowPackageExists(wk.rootPath, newId)) {
|
|
795
|
+
res.status(409).json({ error: `Workflow “${newId}” 已存在` });
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
targetFiles = workflowPackagePaths(wk.rootPath, newId);
|
|
799
|
+
fs.renameSync(files.root, targetFiles.root);
|
|
800
|
+
renameAgentWorkflowBindings(wk.rootPath, oldId, newId);
|
|
801
|
+
}
|
|
802
|
+
const raw = JSON.stringify(result.data, null, 2);
|
|
803
|
+
writePackageJsonAtomic(targetFiles.workflow, result.data);
|
|
804
|
+
const manifest = fs.existsSync(targetFiles.manifest)
|
|
805
|
+
? readJsonFile(targetFiles.manifest)
|
|
806
|
+
: {};
|
|
807
|
+
writePackageJsonAtomic(targetFiles.manifest, {
|
|
808
|
+
schemaVersion: 1,
|
|
809
|
+
name: newId,
|
|
810
|
+
description: typeof manifest.description === "string" ? manifest.description : "",
|
|
811
|
+
version: Number.isSafeInteger(manifest.version)
|
|
812
|
+
? Number(manifest.version) + 1
|
|
813
|
+
: 1,
|
|
814
|
+
...(manifest.source && typeof manifest.source === "object"
|
|
815
|
+
? { source: manifest.source }
|
|
816
|
+
: {}),
|
|
817
|
+
createdAt: typeof manifest.createdAt === "string"
|
|
818
|
+
? manifest.createdAt
|
|
819
|
+
: nowBeijing(),
|
|
820
|
+
updatedAt: nowBeijing(),
|
|
821
|
+
});
|
|
822
|
+
ensureWorkflowPromptFiles(targetFiles.root, result.data);
|
|
823
|
+
const version = recordWorkflowPackageVersion(targetFiles, raw);
|
|
824
|
+
// Same-ID saves stay attached to the existing coordinator. Its active
|
|
825
|
+
// Schedulers reread this package at node boundaries, and future starts
|
|
826
|
+
// already load the file. A rename changes the coordinator key/specDir and
|
|
827
|
+
// therefore still requires invalidation.
|
|
828
|
+
if (newId !== oldId) {
|
|
829
|
+
invalidateWorkflowCoordinators(wk, oldId);
|
|
830
|
+
invalidateWorkflowCoordinators(wk, newId);
|
|
831
|
+
}
|
|
832
|
+
res.json({
|
|
833
|
+
id: newId,
|
|
834
|
+
spec: result.data,
|
|
835
|
+
etag: etagOf(raw),
|
|
836
|
+
updatedAt: nowBeijing(),
|
|
837
|
+
version,
|
|
838
|
+
});
|
|
839
|
+
});
|
|
840
|
+
router.get("/workspaces/:wid/workflows/:id/versions", (req, res) => {
|
|
841
|
+
const wk = kernels.get(req.params.wid);
|
|
842
|
+
if (!wk) {
|
|
843
|
+
res.status(404).json({ error: "workspace not found" });
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
if (!isSafeWorkflowId(req.params.id)) {
|
|
847
|
+
res.status(400).json({ error: "invalid workflow id" });
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
const fp = workflowPackagePaths(wk.rootPath, req.params.id).workflow;
|
|
851
|
+
if (!fs.existsSync(fp)) {
|
|
852
|
+
res.status(404).json({ error: "workflow not found" });
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
res.json(listWorkflowVersions(workflowPackagePaths(wk.rootPath, req.params.id), fs.readFileSync(fp, "utf8")));
|
|
856
|
+
});
|
|
857
|
+
router.post("/workspaces/:wid/workflows/:id/versions/:version/restore", (req, res) => {
|
|
858
|
+
const wk = kernels.get(req.params.wid);
|
|
859
|
+
if (!wk) {
|
|
860
|
+
res.status(404).json({ error: "workspace not found" });
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
if (!isSafeWorkflowId(req.params.id)) {
|
|
864
|
+
res.status(400).json({ error: "invalid workflow id" });
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
const files = workflowPackagePaths(wk.rootPath, req.params.id);
|
|
868
|
+
const fp = files.workflow;
|
|
869
|
+
if (!fs.existsSync(fp)) {
|
|
870
|
+
res.status(404).json({ error: "workflow not found" });
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
const existing = fs.readFileSync(fp, "utf8");
|
|
874
|
+
const ifMatch = req.headers["if-match"];
|
|
875
|
+
if (ifMatch && ifMatch !== etagOf(existing)) {
|
|
876
|
+
res.status(409).json({ error: "ETag mismatch" });
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
const raw = readWorkflowVersion(files, Number(req.params.version));
|
|
880
|
+
if (!raw) {
|
|
881
|
+
res.status(404).json({ error: "workflow version not found" });
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
const result = safeParseWorkflowSpec(JSON.parse(raw));
|
|
885
|
+
if (!result.success) {
|
|
886
|
+
res.status(400).json({ error: "workflow version is invalid" });
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
ensureWorkflowPromptFiles(files.root, result.data);
|
|
890
|
+
const previousManifest = fs.existsSync(files.manifest)
|
|
891
|
+
? readJsonFile(files.manifest)
|
|
892
|
+
: {};
|
|
893
|
+
writePackageJsonAtomic(files.workflow, result.data);
|
|
894
|
+
writePackageJsonAtomic(files.manifest, {
|
|
895
|
+
schemaVersion: 1,
|
|
896
|
+
name: req.params.id,
|
|
897
|
+
description: typeof previousManifest.description === "string"
|
|
898
|
+
? previousManifest.description
|
|
899
|
+
: "",
|
|
900
|
+
version: Number.isSafeInteger(previousManifest.version)
|
|
901
|
+
? Number(previousManifest.version) + 1
|
|
902
|
+
: 1,
|
|
903
|
+
...(previousManifest.source &&
|
|
904
|
+
typeof previousManifest.source === "object"
|
|
905
|
+
? { source: previousManifest.source }
|
|
906
|
+
: {}),
|
|
907
|
+
createdAt: typeof previousManifest.createdAt === "string"
|
|
908
|
+
? previousManifest.createdAt
|
|
909
|
+
: nowBeijing(),
|
|
910
|
+
updatedAt: nowBeijing(),
|
|
911
|
+
});
|
|
912
|
+
const version = recordWorkflowPackageVersion(files, raw);
|
|
913
|
+
res.json({
|
|
914
|
+
id: req.params.id,
|
|
915
|
+
spec: result.data,
|
|
916
|
+
etag: etagOf(raw),
|
|
917
|
+
updatedAt: nowBeijing(),
|
|
918
|
+
version,
|
|
919
|
+
});
|
|
920
|
+
});
|
|
921
|
+
router.get("/workflows/:id", (req, res) => {
|
|
922
|
+
if (!isSafeWorkflowId(req.params.id)) {
|
|
923
|
+
res.status(400).json({ error: "invalid workflow id" });
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
for (const wk of kernels.values()) {
|
|
927
|
+
const fp = workflowPackagePaths(wk.rootPath, req.params.id).workflow;
|
|
928
|
+
if (fs.existsSync(fp)) {
|
|
929
|
+
const raw = fs.readFileSync(fp, "utf8");
|
|
930
|
+
res.json({
|
|
931
|
+
id: req.params.id,
|
|
932
|
+
spec: JSON.parse(raw),
|
|
933
|
+
etag: etagOf(raw),
|
|
934
|
+
updatedAt: toBeijingIso(fs.statSync(fp).mtime),
|
|
935
|
+
});
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
res.status(404).json({ error: "workflow not found" });
|
|
940
|
+
});
|
|
941
|
+
router.put("/workflows/:id", (req, res) => {
|
|
942
|
+
const result = safeParseWorkflowSpec(req.body);
|
|
943
|
+
if (!result.success) {
|
|
944
|
+
res.status(400).json({ error: result.error });
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
947
|
+
if (!isSafeWorkflowId(req.params.id)) {
|
|
948
|
+
res.status(400).json({ error: "invalid workflow id" });
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
const ifMatch = req.headers["if-match"];
|
|
952
|
+
for (const wk of kernels.values()) {
|
|
953
|
+
const files = workflowPackagePaths(wk.rootPath, req.params.id);
|
|
954
|
+
const fp = files.workflow;
|
|
955
|
+
if (!fs.existsSync(fp))
|
|
956
|
+
continue;
|
|
957
|
+
const existing = fs.readFileSync(fp, "utf8");
|
|
958
|
+
if (ifMatch && ifMatch !== etagOf(existing)) {
|
|
959
|
+
res.status(409).json({ error: "ETag mismatch" });
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
const raw = JSON.stringify(result.data, null, 2);
|
|
963
|
+
const tmp = `${fp}.${process.pid}.${Date.now()}.tmp`;
|
|
964
|
+
fs.writeFileSync(tmp, raw, "utf8");
|
|
965
|
+
fs.renameSync(tmp, fp);
|
|
966
|
+
const version = recordWorkflowPackageVersion(files, raw);
|
|
967
|
+
res.json({
|
|
968
|
+
id: req.params.id,
|
|
969
|
+
spec: result.data,
|
|
970
|
+
etag: etagOf(raw),
|
|
971
|
+
updatedAt: nowBeijing(),
|
|
972
|
+
version,
|
|
973
|
+
});
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
res.status(404).json({ error: "workflow not found" });
|
|
977
|
+
});
|
|
978
|
+
// Workspace-scoped task and run routes are the daemon-proxy contract. They
|
|
979
|
+
// deliberately do not fall back to the default kernel or scan every kernel:
|
|
980
|
+
// callers must prove their Workspace ID and its registered main Agent.
|
|
981
|
+
function scopedWorkspace(workspaceId) {
|
|
982
|
+
return kernels.get(workspaceId) ?? null;
|
|
983
|
+
}
|
|
984
|
+
function scopedAgent(workspaceId, agentId) {
|
|
985
|
+
const workspace = scopedWorkspace(workspaceId);
|
|
986
|
+
if (!workspace || !workspace.agentIds.has(agentId))
|
|
987
|
+
return null;
|
|
988
|
+
return getOrCreateSession(workspace, agentId);
|
|
989
|
+
}
|
|
990
|
+
function pipelineRunFor(workspace, pipelineRunId) {
|
|
991
|
+
const runtimes = [workspace, ...workspace.sessions.values()];
|
|
992
|
+
for (const runtime of runtimes) {
|
|
993
|
+
const run = runtime.pipelineStore.get(pipelineRunId);
|
|
994
|
+
if (run?.workspaceId === workspace.workspaceId)
|
|
995
|
+
return run;
|
|
996
|
+
}
|
|
997
|
+
return null;
|
|
998
|
+
}
|
|
999
|
+
function pipelineRuntimeFor(workspace, pipelineRunId) {
|
|
1000
|
+
const runtimes = [workspace, ...workspace.sessions.values()];
|
|
1001
|
+
for (const runtime of runtimes) {
|
|
1002
|
+
const run = runtime.pipelineStore.get(pipelineRunId);
|
|
1003
|
+
if (run?.workspaceId === workspace.workspaceId)
|
|
1004
|
+
return runtime;
|
|
1005
|
+
}
|
|
1006
|
+
return null;
|
|
1007
|
+
}
|
|
1008
|
+
// Generic Artifact API. Node Agents call this through the authenticated
|
|
1009
|
+
// Paseo tool catalog; the UI can use the same read endpoints for timelines
|
|
1010
|
+
// and reports. Content is UTF-8 in this MVP, with a bounded atomic write.
|
|
1011
|
+
router.post("/workspaces/:wid/pipelines/:pipelineRunId/artifacts", (req, res) => {
|
|
1012
|
+
const workspace = scopedWorkspace(req.params.wid);
|
|
1013
|
+
if (!workspace) {
|
|
1014
|
+
res.status(404).json({ error: "workspace not found" });
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
const runtime = pipelineRuntimeFor(workspace, req.params.pipelineRunId);
|
|
1018
|
+
if (!runtime) {
|
|
1019
|
+
res.status(404).json({ error: "PipelineRun not found" });
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
const body = req.body ?? {};
|
|
1023
|
+
if (typeof body.nodeId !== "string" ||
|
|
1024
|
+
typeof body.kind !== "string" ||
|
|
1025
|
+
typeof body.content !== "string") {
|
|
1026
|
+
res
|
|
1027
|
+
.status(400)
|
|
1028
|
+
.json({ error: "nodeId, kind and UTF-8 content are required" });
|
|
1029
|
+
return;
|
|
1030
|
+
}
|
|
1031
|
+
try {
|
|
1032
|
+
if (body.runId !== undefined && typeof body.runId !== "string")
|
|
1033
|
+
throw new Error("runId must be a string");
|
|
1034
|
+
if (body.taskId !== undefined && typeof body.taskId !== "string")
|
|
1035
|
+
throw new Error("taskId must be a string");
|
|
1036
|
+
const ref = runtime.artifacts.publish({
|
|
1037
|
+
pipelineRunId: req.params.pipelineRunId,
|
|
1038
|
+
nodeId: body.nodeId,
|
|
1039
|
+
kind: body.kind,
|
|
1040
|
+
content: body.content,
|
|
1041
|
+
...(typeof body.name === "string" ? { name: body.name } : {}),
|
|
1042
|
+
...(typeof body.mimeType === "string"
|
|
1043
|
+
? { mimeType: body.mimeType }
|
|
1044
|
+
: {}),
|
|
1045
|
+
...(typeof body.runId === "string" ? { runId: body.runId } : {}),
|
|
1046
|
+
...(typeof body.taskId === "string" ? { taskId: body.taskId } : {}),
|
|
1047
|
+
});
|
|
1048
|
+
res.status(201).json({ artifact: ref });
|
|
1049
|
+
}
|
|
1050
|
+
catch (error) {
|
|
1051
|
+
res.status(400).json({
|
|
1052
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1053
|
+
});
|
|
1054
|
+
}
|
|
1055
|
+
});
|
|
1056
|
+
router.get("/workspaces/:wid/pipelines/:pipelineRunId/artifacts", (req, res) => {
|
|
1057
|
+
const workspace = scopedWorkspace(req.params.wid);
|
|
1058
|
+
if (!workspace) {
|
|
1059
|
+
res.status(404).json({ error: "workspace not found" });
|
|
1060
|
+
return;
|
|
1061
|
+
}
|
|
1062
|
+
const runtime = pipelineRuntimeFor(workspace, req.params.pipelineRunId);
|
|
1063
|
+
if (!runtime) {
|
|
1064
|
+
res.status(404).json({ error: "PipelineRun not found" });
|
|
1065
|
+
return;
|
|
1066
|
+
}
|
|
1067
|
+
res.json({ artifacts: runtime.artifacts.list(req.params.pipelineRunId) });
|
|
1068
|
+
});
|
|
1069
|
+
router.get("/workspaces/:wid/pipelines/:pipelineRunId/artifacts/:artifactId", (req, res) => {
|
|
1070
|
+
const workspace = scopedWorkspace(req.params.wid);
|
|
1071
|
+
if (!workspace) {
|
|
1072
|
+
res.status(404).json({ error: "workspace not found" });
|
|
1073
|
+
return;
|
|
1074
|
+
}
|
|
1075
|
+
const runtime = pipelineRuntimeFor(workspace, req.params.pipelineRunId);
|
|
1076
|
+
if (!runtime) {
|
|
1077
|
+
res.status(404).json({ error: "PipelineRun not found" });
|
|
1078
|
+
return;
|
|
1079
|
+
}
|
|
1080
|
+
try {
|
|
1081
|
+
const stored = runtime.artifacts.get(req.params.pipelineRunId, req.params.artifactId);
|
|
1082
|
+
if (!stored) {
|
|
1083
|
+
res.status(404).json({ error: "artifact not found" });
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
res.json({
|
|
1087
|
+
artifact: stored.ref,
|
|
1088
|
+
content: stored.content.toString("utf8"),
|
|
1089
|
+
});
|
|
1090
|
+
}
|
|
1091
|
+
catch (error) {
|
|
1092
|
+
res.status(400).json({
|
|
1093
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1094
|
+
});
|
|
1095
|
+
}
|
|
1096
|
+
});
|
|
1097
|
+
function coordinatorFor(workspace, agentId, workflowId, resources = []) {
|
|
1098
|
+
const key = `${agentId}:${workflowId}`;
|
|
1099
|
+
const existing = workspace.coordinators.get(key);
|
|
1100
|
+
if (existing)
|
|
1101
|
+
return existing;
|
|
1102
|
+
const workflowFiles = workflowPackagePaths(workspace.rootPath, workflowId);
|
|
1103
|
+
const workflowPath = workflowFiles.workflow;
|
|
1104
|
+
if (!fs.existsSync(workflowPath))
|
|
1105
|
+
throw new Error(`workflow not found: ${workflowId}`);
|
|
1106
|
+
const parsed = safeParseWorkflowSpec(readJsonFile(workflowPath));
|
|
1107
|
+
if (!parsed.success)
|
|
1108
|
+
throw new Error(`invalid workflow spec: ${JSON.stringify(parsed.error)}`);
|
|
1109
|
+
const coordinator = new PipelineCoordinator({
|
|
1110
|
+
kernel: workspace.kernel,
|
|
1111
|
+
agentstormRoot: workspace.pipelineStore.root,
|
|
1112
|
+
workspaceId: workspace.workspaceId,
|
|
1113
|
+
mainAgentId: agentId,
|
|
1114
|
+
workflowId,
|
|
1115
|
+
workflowVersion: latestWorkflowPackageVersion(workflowFiles),
|
|
1116
|
+
spec: parsed.data,
|
|
1117
|
+
specDir: workflowFiles.root,
|
|
1118
|
+
resources,
|
|
1119
|
+
layout: "session",
|
|
1120
|
+
});
|
|
1121
|
+
workspace.coordinators.set(key, coordinator);
|
|
1122
|
+
// Reattach persisted non-terminal runs once per coordinator. This is
|
|
1123
|
+
// intentionally fire-and-forget: the HTTP request can serve the durable
|
|
1124
|
+
// PipelineRun immediately while reconciliation resumes in the background.
|
|
1125
|
+
void coordinator.reconcile();
|
|
1126
|
+
return coordinator;
|
|
1127
|
+
}
|
|
1128
|
+
function invalidateWorkflowCoordinators(workspace, workflowId) {
|
|
1129
|
+
for (const key of workspace.coordinators.keys()) {
|
|
1130
|
+
if (key.endsWith(`:${workflowId}`))
|
|
1131
|
+
workspace.coordinators.delete(key);
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
function resolveBoundWorkflowId(workspace, agentId, requested) {
|
|
1135
|
+
if (typeof requested === "string" && isSafeWorkflowId(requested))
|
|
1136
|
+
return requested;
|
|
1137
|
+
const bound = readAgentWorkflowBindings(workspace.rootPath)[agentId];
|
|
1138
|
+
if (bound)
|
|
1139
|
+
return bound;
|
|
1140
|
+
throw new Error("no workflow bound to this Pipeline Agent");
|
|
1141
|
+
}
|
|
1142
|
+
function scopedRun(workspaceId, runId) {
|
|
1143
|
+
const workspace = scopedWorkspace(workspaceId);
|
|
1144
|
+
if (!workspace)
|
|
1145
|
+
return null;
|
|
1146
|
+
try {
|
|
1147
|
+
workspace.kernel.status(runId);
|
|
1148
|
+
return workspace;
|
|
1149
|
+
}
|
|
1150
|
+
catch {
|
|
1151
|
+
for (const session of workspace.sessions.values()) {
|
|
1152
|
+
try {
|
|
1153
|
+
session.kernel.status(runId);
|
|
1154
|
+
return session;
|
|
1155
|
+
}
|
|
1156
|
+
catch {
|
|
1157
|
+
/* continue */
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
return null;
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
// === Generic PipelineRun lifecycle ===
|
|
1164
|
+
// This is the one entry point used by both the Pipeline UI and the Paseo
|
|
1165
|
+
// Main Agent tool. Legacy /plan and /run routes below remain for migration,
|
|
1166
|
+
// but new callers must not compose them themselves.
|
|
1167
|
+
router.post("/workspaces/:wid/agents/:agentId/pipeline/start", async (req, res) => {
|
|
1168
|
+
const workspace = scopedAgent(req.params.wid, req.params.agentId);
|
|
1169
|
+
if (!workspace) {
|
|
1170
|
+
res.status(404).json({ error: "pipeline agent not found" });
|
|
1171
|
+
return;
|
|
1172
|
+
}
|
|
1173
|
+
const parsedInput = PipelineStartInputSchema.safeParse(req.body ?? {});
|
|
1174
|
+
if (!parsedInput.success) {
|
|
1175
|
+
res.status(400).json({ error: parsedInput.error });
|
|
1176
|
+
return;
|
|
1177
|
+
}
|
|
1178
|
+
let resources = parsedInput.data.resources ?? [];
|
|
1179
|
+
for (const resource of resources) {
|
|
1180
|
+
const relativePath = resource.workspaceRelativePath;
|
|
1181
|
+
if (relativePath !== undefined &&
|
|
1182
|
+
!isSafeWorkspaceRelativePath(relativePath)) {
|
|
1183
|
+
res.status(400).json({
|
|
1184
|
+
error: "resource workspaceRelativePath must stay inside the workspace",
|
|
1185
|
+
});
|
|
1186
|
+
return;
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
const resourceIds = parsedInput.data.resourceIds;
|
|
1190
|
+
if (resourceIds !== undefined) {
|
|
1191
|
+
if (!Array.isArray(resourceIds) ||
|
|
1192
|
+
resourceIds.some((id) => typeof id !== "string" || id.trim().length === 0)) {
|
|
1193
|
+
res
|
|
1194
|
+
.status(400)
|
|
1195
|
+
.json({ error: "resourceIds must be a non-empty string array" });
|
|
1196
|
+
return;
|
|
1197
|
+
}
|
|
1198
|
+
// IDs are opaque to AgentStorm. Paseo or a Workflow-specific tool owns
|
|
1199
|
+
// resolving them to files/attachments; keeping them as attachment refs
|
|
1200
|
+
// avoids accepting arbitrary host paths at this boundary.
|
|
1201
|
+
resources = [
|
|
1202
|
+
...resources,
|
|
1203
|
+
...resourceIds.map((id) => ({
|
|
1204
|
+
id,
|
|
1205
|
+
type: "attachment",
|
|
1206
|
+
})),
|
|
1207
|
+
];
|
|
1208
|
+
}
|
|
1209
|
+
const requestedTaskIds = req.body?.taskIds;
|
|
1210
|
+
if (requestedTaskIds !== undefined &&
|
|
1211
|
+
(!Array.isArray(requestedTaskIds) ||
|
|
1212
|
+
requestedTaskIds.some((id) => typeof id !== "string" || id.trim().length === 0))) {
|
|
1213
|
+
res.status(400).json({ error: "taskIds must be a string array" });
|
|
1214
|
+
return;
|
|
1215
|
+
}
|
|
1216
|
+
try {
|
|
1217
|
+
const workflowId = resolveBoundWorkflowId(workspace, req.params.agentId, req.body?.workflowId);
|
|
1218
|
+
const workflowPath = workflowPackagePaths(workspace.rootPath, workflowId).workflow;
|
|
1219
|
+
const parsedWorkflow = safeParseWorkflowSpec(readJsonFile(workflowPath));
|
|
1220
|
+
if (!parsedWorkflow.success) {
|
|
1221
|
+
res.status(409).json({
|
|
1222
|
+
error: "workflow is not ready",
|
|
1223
|
+
readiness: {
|
|
1224
|
+
ready: false,
|
|
1225
|
+
errors: ["stored workflow is invalid"],
|
|
1226
|
+
missingPromptAssets: [],
|
|
1227
|
+
missingSkills: [],
|
|
1228
|
+
agents: [],
|
|
1229
|
+
},
|
|
1230
|
+
});
|
|
1231
|
+
return;
|
|
1232
|
+
}
|
|
1233
|
+
const readiness = await workflowReadinessWithBackend(path.dirname(workflowPath), parsedWorkflow.data, workspace.backend, workspace.rootPath);
|
|
1234
|
+
if (!readiness.ready) {
|
|
1235
|
+
res.status(409).json({ error: "workflow is not ready", readiness });
|
|
1236
|
+
return;
|
|
1237
|
+
}
|
|
1238
|
+
const coordinator = coordinatorFor(workspace, req.params.agentId, workflowId, resources);
|
|
1239
|
+
const started = coordinator.start({
|
|
1240
|
+
goal: parsedInput.data.goal,
|
|
1241
|
+
...(parsedInput.data.idempotencyKey
|
|
1242
|
+
? { idempotencyKey: parsedInput.data.idempotencyKey }
|
|
1243
|
+
: {}),
|
|
1244
|
+
...(resources.length > 0 ? { resources } : {}),
|
|
1245
|
+
...(Array.isArray(requestedTaskIds)
|
|
1246
|
+
? { taskIds: requestedTaskIds }
|
|
1247
|
+
: {}),
|
|
1248
|
+
});
|
|
1249
|
+
res.status(202).json({
|
|
1250
|
+
run: started.run,
|
|
1251
|
+
...(started.planRunId ? { planRunId: started.planRunId } : {}),
|
|
1252
|
+
});
|
|
1253
|
+
}
|
|
1254
|
+
catch (error) {
|
|
1255
|
+
res.status(400).json({
|
|
1256
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1257
|
+
});
|
|
1258
|
+
}
|
|
1259
|
+
});
|
|
1260
|
+
// Extend the one current PipelineRun owned by this Main Agent. A completed
|
|
1261
|
+
// run returns to planning on its existing PlanRun; no second PipelineRun is
|
|
1262
|
+
// created and completed Execute tasks remain completed.
|
|
1263
|
+
router.post("/workspaces/:wid/agents/:agentId/pipeline/extend", (req, res) => {
|
|
1264
|
+
const workspace = scopedAgent(req.params.wid, req.params.agentId);
|
|
1265
|
+
if (!workspace) {
|
|
1266
|
+
res.status(404).json({ error: "pipeline agent not found" });
|
|
1267
|
+
return;
|
|
1268
|
+
}
|
|
1269
|
+
const text = typeof req.body?.request === "string" ? req.body.request.trim() : "";
|
|
1270
|
+
if (!text) {
|
|
1271
|
+
res.status(400).json({ error: "request is required" });
|
|
1272
|
+
return;
|
|
1273
|
+
}
|
|
1274
|
+
try {
|
|
1275
|
+
const workflowId = resolveBoundWorkflowId(workspace, req.params.agentId);
|
|
1276
|
+
let coordinator = coordinatorFor(workspace, req.params.agentId, workflowId);
|
|
1277
|
+
let current = coordinator.list()[0];
|
|
1278
|
+
if (!current) {
|
|
1279
|
+
res.status(404).json({ error: "PipelineRun not found" });
|
|
1280
|
+
return;
|
|
1281
|
+
}
|
|
1282
|
+
// The user may have selected a different template for the next start
|
|
1283
|
+
// after this run completed. Extension still belongs to the Workflow
|
|
1284
|
+
// recorded on the existing PipelineRun.
|
|
1285
|
+
if (current.workflowId !== workflowId) {
|
|
1286
|
+
coordinator = coordinatorFor(workspace, req.params.agentId, current.workflowId);
|
|
1287
|
+
current = coordinator.get(current.id) ?? current;
|
|
1288
|
+
}
|
|
1289
|
+
res.status(202).json({
|
|
1290
|
+
run: coordinator.extend(current.id, { text }),
|
|
1291
|
+
planRunId: current.planRunId,
|
|
1292
|
+
});
|
|
1293
|
+
}
|
|
1294
|
+
catch (error) {
|
|
1295
|
+
res.status(409).json({
|
|
1296
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1297
|
+
});
|
|
1298
|
+
}
|
|
1299
|
+
});
|
|
1300
|
+
router.get("/workspaces/:wid/agents/:agentId/pipelines", (req, res) => {
|
|
1301
|
+
const workspace = scopedAgent(req.params.wid, req.params.agentId);
|
|
1302
|
+
if (!workspace) {
|
|
1303
|
+
res.status(404).json({ error: "pipeline agent not found" });
|
|
1304
|
+
return;
|
|
1305
|
+
}
|
|
1306
|
+
const boundWorkflowId = readAgentWorkflowBindings(workspace.rootPath)[req.params.agentId];
|
|
1307
|
+
const workflows = boundWorkflowId ? [boundWorkflowId] : [];
|
|
1308
|
+
const runs = new Map();
|
|
1309
|
+
for (const workflowId of workflows) {
|
|
1310
|
+
const coordinator = workspace.coordinators.get(`${req.params.agentId}:${workflowId}`);
|
|
1311
|
+
for (const run of coordinator?.list() ?? [])
|
|
1312
|
+
runs.set(run.id, run);
|
|
1313
|
+
}
|
|
1314
|
+
// A newly restarted daemon may not have a live Coordinator yet. Read
|
|
1315
|
+
// persisted PipelineRun files through a temporary bound Coordinator.
|
|
1316
|
+
if (runs.size === 0) {
|
|
1317
|
+
for (const workflowId of workflows) {
|
|
1318
|
+
try {
|
|
1319
|
+
for (const run of coordinatorFor(workspace, req.params.agentId, workflowId).list())
|
|
1320
|
+
runs.set(run.id, run);
|
|
1321
|
+
}
|
|
1322
|
+
catch {
|
|
1323
|
+
/* stale binding is reported by readiness, not list */
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
res.json([...runs.values()]);
|
|
1328
|
+
});
|
|
1329
|
+
router.get("/workspaces/:wid/agents/:agentId/pipelines/:pipelineRunId", (req, res) => {
|
|
1330
|
+
const workspace = scopedAgent(req.params.wid, req.params.agentId);
|
|
1331
|
+
if (!workspace) {
|
|
1332
|
+
res.status(404).json({ error: "pipeline agent not found" });
|
|
1333
|
+
return;
|
|
1334
|
+
}
|
|
1335
|
+
const run = findPipelineRun(workspace, req.params.agentId, req.params.pipelineRunId);
|
|
1336
|
+
if (!run) {
|
|
1337
|
+
res.status(404).json({ error: "PipelineRun not found" });
|
|
1338
|
+
return;
|
|
1339
|
+
}
|
|
1340
|
+
const coordinator = run.coordinator;
|
|
1341
|
+
const approvals = coordinator.approvals(req.params.pipelineRunId);
|
|
1342
|
+
res.json({
|
|
1343
|
+
run: run.value,
|
|
1344
|
+
approvals,
|
|
1345
|
+
...buildPipelineStatusSummary(run.value, approvals),
|
|
1346
|
+
});
|
|
1347
|
+
});
|
|
1348
|
+
router.get("/workspaces/:wid/agents/:agentId/pipelines/:pipelineRunId/events", (req, res) => {
|
|
1349
|
+
const workspace = scopedAgent(req.params.wid, req.params.agentId);
|
|
1350
|
+
if (!workspace) {
|
|
1351
|
+
res.status(404).json({ error: "pipeline agent not found" });
|
|
1352
|
+
return;
|
|
1353
|
+
}
|
|
1354
|
+
const run = findPipelineRun(workspace, req.params.agentId, req.params.pipelineRunId);
|
|
1355
|
+
if (!run) {
|
|
1356
|
+
res.status(404).json({ error: "PipelineRun not found" });
|
|
1357
|
+
return;
|
|
1358
|
+
}
|
|
1359
|
+
const requestedOffset = Number.parseInt(String(req.query.from ?? "0"), 10);
|
|
1360
|
+
const fromOffset = Number.isFinite(requestedOffset) && requestedOffset > 0
|
|
1361
|
+
? requestedOffset
|
|
1362
|
+
: 0;
|
|
1363
|
+
res.json(workspace.kernel.store.readPipelineEventsFromOffset(req.params.pipelineRunId, fromOffset));
|
|
1364
|
+
});
|
|
1365
|
+
router.get("/workspaces/:wid/agents/:agentId/pipelines/:pipelineRunId/approvals", (req, res) => {
|
|
1366
|
+
const workspace = scopedAgent(req.params.wid, req.params.agentId);
|
|
1367
|
+
if (!workspace) {
|
|
1368
|
+
res.status(404).json({ error: "pipeline agent not found" });
|
|
1369
|
+
return;
|
|
1370
|
+
}
|
|
1371
|
+
const found = findPipelineRun(workspace, req.params.agentId, req.params.pipelineRunId);
|
|
1372
|
+
if (!found) {
|
|
1373
|
+
res.status(404).json({ error: "PipelineRun not found" });
|
|
1374
|
+
return;
|
|
1375
|
+
}
|
|
1376
|
+
res.json(found.coordinator.approvals(req.params.pipelineRunId));
|
|
1377
|
+
});
|
|
1378
|
+
router.post("/workspaces/:wid/agents/:agentId/pipelines/:pipelineRunId/approve", (req, res) => {
|
|
1379
|
+
const workspace = scopedAgent(req.params.wid, req.params.agentId);
|
|
1380
|
+
if (!workspace) {
|
|
1381
|
+
res.status(404).json({ error: "pipeline agent not found" });
|
|
1382
|
+
return;
|
|
1383
|
+
}
|
|
1384
|
+
const found = findPipelineRun(workspace, req.params.agentId, req.params.pipelineRunId);
|
|
1385
|
+
if (!found) {
|
|
1386
|
+
res.status(404).json({ error: "PipelineRun not found" });
|
|
1387
|
+
return;
|
|
1388
|
+
}
|
|
1389
|
+
try {
|
|
1390
|
+
const approvalId = typeof req.body?.approvalId === "string" ? req.body.approvalId : "";
|
|
1391
|
+
const planRevision = Number.isInteger(req.body?.planRevision)
|
|
1392
|
+
? Number(req.body.planRevision)
|
|
1393
|
+
: undefined;
|
|
1394
|
+
const comments = ApprovalCommentSchema.array().parse(req.body?.comments ?? []);
|
|
1395
|
+
res.json(found.coordinator.approve(approvalId, typeof req.body?.decidedBy === "string"
|
|
1396
|
+
? req.body.decidedBy
|
|
1397
|
+
: "user", planRevision, comments));
|
|
1398
|
+
}
|
|
1399
|
+
catch (error) {
|
|
1400
|
+
res.status(409).json({
|
|
1401
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1402
|
+
});
|
|
1403
|
+
}
|
|
1404
|
+
});
|
|
1405
|
+
router.post("/workspaces/:wid/agents/:agentId/pipelines/:pipelineRunId/reject", (req, res) => {
|
|
1406
|
+
const workspace = scopedAgent(req.params.wid, req.params.agentId);
|
|
1407
|
+
if (!workspace) {
|
|
1408
|
+
res.status(404).json({ error: "pipeline agent not found" });
|
|
1409
|
+
return;
|
|
1410
|
+
}
|
|
1411
|
+
const found = findPipelineRun(workspace, req.params.agentId, req.params.pipelineRunId);
|
|
1412
|
+
if (!found) {
|
|
1413
|
+
res.status(404).json({ error: "PipelineRun not found" });
|
|
1414
|
+
return;
|
|
1415
|
+
}
|
|
1416
|
+
try {
|
|
1417
|
+
const approvalId = typeof req.body?.approvalId === "string" ? req.body.approvalId : "";
|
|
1418
|
+
const feedback = typeof req.body?.feedback === "string" ? req.body.feedback : "";
|
|
1419
|
+
const planRevision = Number.isInteger(req.body?.planRevision)
|
|
1420
|
+
? Number(req.body.planRevision)
|
|
1421
|
+
: undefined;
|
|
1422
|
+
const comments = ApprovalCommentSchema.array().parse(req.body?.comments ?? []);
|
|
1423
|
+
res.json(found.coordinator.reject(approvalId, feedback, typeof req.body?.decidedBy === "string"
|
|
1424
|
+
? req.body.decidedBy
|
|
1425
|
+
: "user", planRevision, comments));
|
|
1426
|
+
}
|
|
1427
|
+
catch (error) {
|
|
1428
|
+
res.status(409).json({
|
|
1429
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1430
|
+
});
|
|
1431
|
+
}
|
|
1432
|
+
});
|
|
1433
|
+
router.get("/workspaces/:wid/agents/:agentId/pipelines/:pipelineRunId/approvals/:approvalId", (req, res) => {
|
|
1434
|
+
const workspace = scopedAgent(req.params.wid, req.params.agentId);
|
|
1435
|
+
if (!workspace) {
|
|
1436
|
+
res.status(404).json({ error: "pipeline agent not found" });
|
|
1437
|
+
return;
|
|
1438
|
+
}
|
|
1439
|
+
const found = findPipelineRun(workspace, req.params.agentId, req.params.pipelineRunId);
|
|
1440
|
+
if (!found) {
|
|
1441
|
+
res.status(404).json({ error: "PipelineRun not found" });
|
|
1442
|
+
return;
|
|
1443
|
+
}
|
|
1444
|
+
try {
|
|
1445
|
+
const detail = found.coordinator.approvalPackage(req.params.approvalId);
|
|
1446
|
+
if (detail.approval.pipelineRunId !== req.params.pipelineRunId) {
|
|
1447
|
+
res.status(404).json({ error: "approval not found" });
|
|
1448
|
+
return;
|
|
1449
|
+
}
|
|
1450
|
+
res.json(detail);
|
|
1451
|
+
}
|
|
1452
|
+
catch (error) {
|
|
1453
|
+
res.status(404).json({
|
|
1454
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1455
|
+
});
|
|
1456
|
+
}
|
|
1457
|
+
});
|
|
1458
|
+
router.post("/workspaces/:wid/agents/:agentId/pipelines/:pipelineRunId/control", (req, res) => {
|
|
1459
|
+
const workspace = scopedAgent(req.params.wid, req.params.agentId);
|
|
1460
|
+
if (!workspace) {
|
|
1461
|
+
res.status(404).json({ error: "pipeline agent not found" });
|
|
1462
|
+
return;
|
|
1463
|
+
}
|
|
1464
|
+
const found = findPipelineRun(workspace, req.params.agentId, req.params.pipelineRunId);
|
|
1465
|
+
if (!found) {
|
|
1466
|
+
res.status(404).json({ error: "PipelineRun not found" });
|
|
1467
|
+
return;
|
|
1468
|
+
}
|
|
1469
|
+
const action = req.body?.action;
|
|
1470
|
+
if (!["pause", "resume", "cancel"].includes(action)) {
|
|
1471
|
+
res.status(400).json({ error: "invalid Pipeline control action" });
|
|
1472
|
+
return;
|
|
1473
|
+
}
|
|
1474
|
+
try {
|
|
1475
|
+
res.json(found.coordinator.control(req.params.pipelineRunId, action));
|
|
1476
|
+
}
|
|
1477
|
+
catch (error) {
|
|
1478
|
+
res.status(409).json({
|
|
1479
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1480
|
+
});
|
|
1481
|
+
}
|
|
1482
|
+
});
|
|
1483
|
+
router.post("/workspaces/:wid/agents/:agentId/pipelines/:pipelineRunId/resume", (req, res) => {
|
|
1484
|
+
const workspace = scopedAgent(req.params.wid, req.params.agentId);
|
|
1485
|
+
if (!workspace) {
|
|
1486
|
+
res.status(404).json({ error: "pipeline agent not found" });
|
|
1487
|
+
return;
|
|
1488
|
+
}
|
|
1489
|
+
const found = findPipelineRun(workspace, req.params.agentId, req.params.pipelineRunId);
|
|
1490
|
+
if (!found) {
|
|
1491
|
+
res.status(404).json({ error: "PipelineRun not found" });
|
|
1492
|
+
return;
|
|
1493
|
+
}
|
|
1494
|
+
const taskId = typeof req.body?.taskId === "string" ? req.body.taskId.trim() : "";
|
|
1495
|
+
const nodeId = typeof req.body?.nodeId === "string" ? req.body.nodeId.trim() : "";
|
|
1496
|
+
const text = typeof req.body?.text === "string" ? req.body.text.trim() : "";
|
|
1497
|
+
if (!taskId || !text) {
|
|
1498
|
+
res.status(400).json({ error: "taskId and text are required" });
|
|
1499
|
+
return;
|
|
1500
|
+
}
|
|
1501
|
+
// Older Paseo clients used the generic Pipeline resume endpoint for the
|
|
1502
|
+
// synthetic `planner:<PipelineRunId>` card. A Plan Run is not a Task, so
|
|
1503
|
+
// keep that request on the original Plan Run instead of asking the
|
|
1504
|
+
// Execute task scope to resolve it.
|
|
1505
|
+
if (taskId.startsWith("planner:") || taskId === found.value.planRunId) {
|
|
1506
|
+
if (!nodeId) {
|
|
1507
|
+
res.status(400).json({ error: "nodeId and text are required" });
|
|
1508
|
+
return;
|
|
1509
|
+
}
|
|
1510
|
+
try {
|
|
1511
|
+
res.json(found.coordinator.reopenPlan(req.params.pipelineRunId, {
|
|
1512
|
+
nodeId,
|
|
1513
|
+
text,
|
|
1514
|
+
}));
|
|
1515
|
+
}
|
|
1516
|
+
catch (error) {
|
|
1517
|
+
res.status(409).json({
|
|
1518
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1519
|
+
});
|
|
1520
|
+
}
|
|
1521
|
+
return;
|
|
1522
|
+
}
|
|
1523
|
+
try {
|
|
1524
|
+
res.json(found.coordinator.resumeBlocked(req.params.pipelineRunId, {
|
|
1525
|
+
taskId,
|
|
1526
|
+
...(nodeId ? { nodeId } : {}),
|
|
1527
|
+
text,
|
|
1528
|
+
}));
|
|
1529
|
+
}
|
|
1530
|
+
catch (error) {
|
|
1531
|
+
res.status(409).json({
|
|
1532
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1533
|
+
});
|
|
1534
|
+
}
|
|
1535
|
+
});
|
|
1536
|
+
// Continue a completed Execute Run in place. This is deliberately separate
|
|
1537
|
+
// from blocked resume: the task keeps the same runId and the Kernel appends
|
|
1538
|
+
// a run_reopened event instead of creating another Execute attempt.
|
|
1539
|
+
router.post("/workspaces/:wid/agents/:agentId/pipelines/:pipelineRunId/reopen", (req, res) => {
|
|
1540
|
+
const workspace = scopedAgent(req.params.wid, req.params.agentId);
|
|
1541
|
+
if (!workspace) {
|
|
1542
|
+
res.status(404).json({ error: "pipeline agent not found" });
|
|
1543
|
+
return;
|
|
1544
|
+
}
|
|
1545
|
+
const found = findPipelineRun(workspace, req.params.agentId, req.params.pipelineRunId);
|
|
1546
|
+
if (!found) {
|
|
1547
|
+
res.status(404).json({ error: "PipelineRun not found" });
|
|
1548
|
+
return;
|
|
1549
|
+
}
|
|
1550
|
+
const taskId = typeof req.body?.taskId === "string" ? req.body.taskId.trim() : "";
|
|
1551
|
+
const nodeId = typeof req.body?.nodeId === "string" ? req.body.nodeId.trim() : "";
|
|
1552
|
+
const text = typeof req.body?.text === "string" ? req.body.text.trim() : "";
|
|
1553
|
+
if (!taskId || !nodeId || !text) {
|
|
1554
|
+
res.status(400).json({ error: "taskId, nodeId and text are required" });
|
|
1555
|
+
return;
|
|
1556
|
+
}
|
|
1557
|
+
// See the corresponding `pipeline.resume` compatibility branch above:
|
|
1558
|
+
// reopening a planner must preserve the Plan Run identity and never
|
|
1559
|
+
// require an Execute Task record.
|
|
1560
|
+
if (taskId.startsWith("planner:") || taskId === found.value.planRunId) {
|
|
1561
|
+
try {
|
|
1562
|
+
res.json(found.coordinator.reopenPlan(req.params.pipelineRunId, {
|
|
1563
|
+
nodeId,
|
|
1564
|
+
text,
|
|
1565
|
+
}));
|
|
1566
|
+
}
|
|
1567
|
+
catch (error) {
|
|
1568
|
+
res.status(409).json({
|
|
1569
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1570
|
+
});
|
|
1571
|
+
}
|
|
1572
|
+
return;
|
|
1573
|
+
}
|
|
1574
|
+
try {
|
|
1575
|
+
res.json(found.coordinator.reopenDone(req.params.pipelineRunId, {
|
|
1576
|
+
taskId,
|
|
1577
|
+
nodeId,
|
|
1578
|
+
text,
|
|
1579
|
+
}));
|
|
1580
|
+
}
|
|
1581
|
+
catch (error) {
|
|
1582
|
+
res.status(409).json({
|
|
1583
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1584
|
+
});
|
|
1585
|
+
}
|
|
1586
|
+
});
|
|
1587
|
+
// Structured Node Agent handoffs. The Node tool supplies the immutable
|
|
1588
|
+
// PipelineRun/Kernel run/node scope; the coordinator remains the only code
|
|
1589
|
+
// that turns the resulting Kernel event into Tasks or Execute work.
|
|
1590
|
+
router.post("/workspaces/:wid/agents/:agentId/pipelines/:pipelineRunId/plan-result", (req, res) => {
|
|
1591
|
+
const workspace = scopedAgent(req.params.wid, req.params.agentId);
|
|
1592
|
+
if (!workspace) {
|
|
1593
|
+
res.status(404).json({ error: "pipeline agent not found" });
|
|
1594
|
+
return;
|
|
1595
|
+
}
|
|
1596
|
+
const found = findPipelineRun(workspace, req.params.agentId, req.params.pipelineRunId);
|
|
1597
|
+
if (!found) {
|
|
1598
|
+
res.status(404).json({ error: "PipelineRun not found" });
|
|
1599
|
+
return;
|
|
1600
|
+
}
|
|
1601
|
+
const runId = typeof req.body?.runId === "string" ? req.body.runId : "";
|
|
1602
|
+
const nodeId = typeof req.body?.nodeId === "string" ? req.body.nodeId : "";
|
|
1603
|
+
if (!runId || !nodeId || found.value.planRunId !== runId) {
|
|
1604
|
+
res
|
|
1605
|
+
.status(400)
|
|
1606
|
+
.json({ error: "runId/nodeId do not match the Pipeline plan" });
|
|
1607
|
+
return;
|
|
1608
|
+
}
|
|
1609
|
+
try {
|
|
1610
|
+
workspace.kernel.submitPlanResult(runId, nodeId, req.body?.planResult);
|
|
1611
|
+
res.status(202).json({
|
|
1612
|
+
accepted: true,
|
|
1613
|
+
pipelineRunId: req.params.pipelineRunId,
|
|
1614
|
+
runId,
|
|
1615
|
+
nodeId,
|
|
1616
|
+
nodeCompleted: true,
|
|
1617
|
+
handoffRequired: false,
|
|
1618
|
+
});
|
|
1619
|
+
}
|
|
1620
|
+
catch (error) {
|
|
1621
|
+
res.status(409).json({
|
|
1622
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1623
|
+
});
|
|
1624
|
+
}
|
|
1625
|
+
});
|
|
1626
|
+
router.post("/workspaces/:wid/agents/:agentId/pipelines/:pipelineRunId/handoff", (req, res) => {
|
|
1627
|
+
const workspace = scopedAgent(req.params.wid, req.params.agentId);
|
|
1628
|
+
if (!workspace) {
|
|
1629
|
+
res.status(404).json({ error: "pipeline agent not found" });
|
|
1630
|
+
return;
|
|
1631
|
+
}
|
|
1632
|
+
const found = findPipelineRun(workspace, req.params.agentId, req.params.pipelineRunId);
|
|
1633
|
+
if (!found) {
|
|
1634
|
+
res.status(404).json({ error: "PipelineRun not found" });
|
|
1635
|
+
return;
|
|
1636
|
+
}
|
|
1637
|
+
const runId = typeof req.body?.runId === "string" ? req.body.runId : "";
|
|
1638
|
+
const nodeId = typeof req.body?.nodeId === "string" ? req.body.nodeId : "";
|
|
1639
|
+
const outcome = typeof req.body?.outcome === "string" ? req.body.outcome.trim() : "";
|
|
1640
|
+
if (!runId || !nodeId || !outcome) {
|
|
1641
|
+
res
|
|
1642
|
+
.status(400)
|
|
1643
|
+
.json({ error: "runId, nodeId and outcome are required" });
|
|
1644
|
+
return;
|
|
1645
|
+
}
|
|
1646
|
+
try {
|
|
1647
|
+
workspace.kernel.submitHandoff(runId, nodeId, outcome, req.body?.payload);
|
|
1648
|
+
res.status(202).json({
|
|
1649
|
+
accepted: true,
|
|
1650
|
+
pipelineRunId: req.params.pipelineRunId,
|
|
1651
|
+
runId,
|
|
1652
|
+
nodeId,
|
|
1653
|
+
});
|
|
1654
|
+
}
|
|
1655
|
+
catch (error) {
|
|
1656
|
+
res.status(409).json({
|
|
1657
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1658
|
+
});
|
|
1659
|
+
}
|
|
1660
|
+
});
|
|
1661
|
+
router.post("/workspaces/:wid/agents/:agentId/pipelines/:pipelineRunId/human-required", (req, res) => {
|
|
1662
|
+
const workspace = scopedAgent(req.params.wid, req.params.agentId);
|
|
1663
|
+
if (!workspace) {
|
|
1664
|
+
res.status(404).json({ error: "pipeline agent not found" });
|
|
1665
|
+
return;
|
|
1666
|
+
}
|
|
1667
|
+
const found = findPipelineRun(workspace, req.params.agentId, req.params.pipelineRunId);
|
|
1668
|
+
if (!found) {
|
|
1669
|
+
res.status(404).json({ error: "PipelineRun not found" });
|
|
1670
|
+
return;
|
|
1671
|
+
}
|
|
1672
|
+
const runId = typeof req.body?.runId === "string" ? req.body.runId : "";
|
|
1673
|
+
const nodeId = typeof req.body?.nodeId === "string" ? req.body.nodeId : "";
|
|
1674
|
+
const title = typeof req.body?.title === "string" ? req.body.title.trim() : "";
|
|
1675
|
+
const instructions = typeof req.body?.instructions === "string"
|
|
1676
|
+
? req.body.instructions.trim()
|
|
1677
|
+
: "";
|
|
1678
|
+
if (!runId || !nodeId || !title || !instructions) {
|
|
1679
|
+
res.status(400).json({
|
|
1680
|
+
error: "runId, nodeId, title and instructions are required",
|
|
1681
|
+
});
|
|
1682
|
+
return;
|
|
1683
|
+
}
|
|
1684
|
+
try {
|
|
1685
|
+
const created = workspace.kernel.store
|
|
1686
|
+
.events(runId)
|
|
1687
|
+
.find((event) => event.t === "run_created");
|
|
1688
|
+
if (!created ||
|
|
1689
|
+
created.pipelineRunId !== req.params.pipelineRunId ||
|
|
1690
|
+
created.mainAgentId !== req.params.agentId) {
|
|
1691
|
+
res.status(400).json({
|
|
1692
|
+
error: "runId/nodeId do not match the current Pipeline Node",
|
|
1693
|
+
});
|
|
1694
|
+
return;
|
|
1695
|
+
}
|
|
1696
|
+
const action = HumanInterventionActionSchema.optional().parse(req.body?.action);
|
|
1697
|
+
const request = workspace.kernel.requestHuman(runId, nodeId, {
|
|
1698
|
+
title,
|
|
1699
|
+
instructions,
|
|
1700
|
+
...(action ? { action } : {}),
|
|
1701
|
+
});
|
|
1702
|
+
res.status(202).json({
|
|
1703
|
+
accepted: true,
|
|
1704
|
+
pipelineRunId: req.params.pipelineRunId,
|
|
1705
|
+
runId,
|
|
1706
|
+
nodeId,
|
|
1707
|
+
request,
|
|
1708
|
+
});
|
|
1709
|
+
}
|
|
1710
|
+
catch (error) {
|
|
1711
|
+
res.status(409).json({
|
|
1712
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1713
|
+
});
|
|
1714
|
+
}
|
|
1715
|
+
});
|
|
1716
|
+
router.post("/workspaces/:wid/agents/:agentId/pipelines/:pipelineRunId/human-resolve", (req, res) => {
|
|
1717
|
+
const workspace = scopedAgent(req.params.wid, req.params.agentId);
|
|
1718
|
+
if (!workspace) {
|
|
1719
|
+
res.status(404).json({ error: "pipeline agent not found" });
|
|
1720
|
+
return;
|
|
1721
|
+
}
|
|
1722
|
+
const found = findPipelineRun(workspace, req.params.agentId, req.params.pipelineRunId);
|
|
1723
|
+
if (!found) {
|
|
1724
|
+
res.status(404).json({ error: "PipelineRun not found" });
|
|
1725
|
+
return;
|
|
1726
|
+
}
|
|
1727
|
+
const runId = typeof req.body?.runId === "string" ? req.body.runId : "";
|
|
1728
|
+
const nodeId = typeof req.body?.nodeId === "string" ? req.body.nodeId : "";
|
|
1729
|
+
const requestId = typeof req.body?.requestId === "string" ? req.body.requestId : "";
|
|
1730
|
+
const resolution = req.body?.resolution;
|
|
1731
|
+
const text = typeof req.body?.text === "string" ? req.body.text.trim() : "";
|
|
1732
|
+
if (!runId ||
|
|
1733
|
+
!nodeId ||
|
|
1734
|
+
!requestId ||
|
|
1735
|
+
(resolution !== "continue" && resolution !== "reject")) {
|
|
1736
|
+
res.status(400).json({
|
|
1737
|
+
error: "runId, nodeId, requestId and a valid resolution are required",
|
|
1738
|
+
});
|
|
1739
|
+
return;
|
|
1740
|
+
}
|
|
1741
|
+
try {
|
|
1742
|
+
res.json(found.coordinator.resolveHuman(req.params.pipelineRunId, {
|
|
1743
|
+
runId,
|
|
1744
|
+
nodeId,
|
|
1745
|
+
requestId,
|
|
1746
|
+
resolution,
|
|
1747
|
+
...(text ? { text } : {}),
|
|
1748
|
+
}));
|
|
1749
|
+
}
|
|
1750
|
+
catch (error) {
|
|
1751
|
+
res.status(409).json({
|
|
1752
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1753
|
+
});
|
|
1754
|
+
}
|
|
1755
|
+
});
|
|
1756
|
+
function findPipelineRun(workspace, agentId, pipelineRunId) {
|
|
1757
|
+
for (const [coordinatorKey, coordinator,] of workspace.coordinators.entries()) {
|
|
1758
|
+
// PipelineRunStore is shared by a Workspace, so a Coordinator can see
|
|
1759
|
+
// persisted runs belonging to another Main Agent. The map key is the
|
|
1760
|
+
// authoritative in-memory owner; do not return an old Agent's
|
|
1761
|
+
// Coordinator merely because its shared store contains this run id.
|
|
1762
|
+
if (!coordinatorKey.startsWith(`${agentId}:`))
|
|
1763
|
+
continue;
|
|
1764
|
+
const value = coordinator.get(pipelineRunId);
|
|
1765
|
+
if (value && value.mainAgentId === agentId)
|
|
1766
|
+
return { coordinator, value };
|
|
1767
|
+
}
|
|
1768
|
+
const bindings = readAgentWorkflowBindings(workspace.rootPath);
|
|
1769
|
+
for (const [boundAgentId, workflowId] of Object.entries(bindings)) {
|
|
1770
|
+
if (boundAgentId !== agentId)
|
|
1771
|
+
continue;
|
|
1772
|
+
try {
|
|
1773
|
+
const coordinator = coordinatorFor(workspace, agentId, workflowId);
|
|
1774
|
+
const value = coordinator.get(pipelineRunId);
|
|
1775
|
+
if (value)
|
|
1776
|
+
return { coordinator, value };
|
|
1777
|
+
}
|
|
1778
|
+
catch {
|
|
1779
|
+
/* continue to 404 */
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
return null;
|
|
1783
|
+
}
|
|
1784
|
+
router.get("/workspaces/:wid/agents/:agentId/tasks", (req, res) => {
|
|
1785
|
+
const workspace = scopedAgent(req.params.wid, req.params.agentId);
|
|
1786
|
+
if (!workspace) {
|
|
1787
|
+
res.status(404).json({ error: "pipeline agent not found" });
|
|
1788
|
+
return;
|
|
1789
|
+
}
|
|
1790
|
+
res.json(workspace.kernel.tasks
|
|
1791
|
+
.list()
|
|
1792
|
+
.filter((task) => taskBelongsToMainAgent(task, req.params.agentId)));
|
|
1793
|
+
});
|
|
1794
|
+
router.post("/workspaces/:wid/agents/:agentId/tasks", (req, res) => {
|
|
1795
|
+
const workspace = scopedAgent(req.params.wid, req.params.agentId);
|
|
1796
|
+
if (!workspace) {
|
|
1797
|
+
res.status(404).json({ error: "pipeline agent not found" });
|
|
1798
|
+
return;
|
|
1799
|
+
}
|
|
1800
|
+
const { id, title } = req.body ?? {};
|
|
1801
|
+
res.json(workspace.kernel.tasks.add(id, title, undefined, req.params.agentId));
|
|
1802
|
+
});
|
|
1803
|
+
router.get("/workspaces/:wid/runs/:runId", (req, res) => {
|
|
1804
|
+
const workspace = scopedRun(req.params.wid, req.params.runId);
|
|
1805
|
+
if (!workspace) {
|
|
1806
|
+
res.status(404).json({ error: "run not found" });
|
|
1807
|
+
return;
|
|
1808
|
+
}
|
|
1809
|
+
res.json(statusWithRunKind(workspace.kernel.status(req.params.runId), workspace.kernel.store.events(req.params.runId)));
|
|
1810
|
+
});
|
|
1811
|
+
router.get("/workspaces/:wid/runs/:runId/events", (req, res) => {
|
|
1812
|
+
const workspace = scopedRun(req.params.wid, req.params.runId);
|
|
1813
|
+
if (!workspace) {
|
|
1814
|
+
res.status(404).json({ error: "run not found" });
|
|
1815
|
+
return;
|
|
1816
|
+
}
|
|
1817
|
+
const fromOffset = parseInt(req.query.from) || 0;
|
|
1818
|
+
res.json(workspace.kernel.store.readEventsFromOffset(req.params.runId, fromOffset));
|
|
1819
|
+
});
|
|
1820
|
+
for (const [endpoint, method] of [
|
|
1821
|
+
["interrupt", "interrupt"],
|
|
1822
|
+
["approve", "approve"],
|
|
1823
|
+
["reject", "reject"],
|
|
1824
|
+
["pause", "pause"],
|
|
1825
|
+
["resume", "resume"],
|
|
1826
|
+
["goto", "goto"],
|
|
1827
|
+
]) {
|
|
1828
|
+
router.post(`/workspaces/:wid/runs/:runId/${endpoint}`, (req, res) => {
|
|
1829
|
+
const workspace = scopedRun(req.params.wid, req.params.runId);
|
|
1830
|
+
if (!workspace) {
|
|
1831
|
+
res.status(404).json({ error: "run not found" });
|
|
1832
|
+
return;
|
|
1833
|
+
}
|
|
1834
|
+
const { nodeId, text, feedback } = req.body ?? {};
|
|
1835
|
+
try {
|
|
1836
|
+
if (method === "goto" || method === "interrupt") {
|
|
1837
|
+
const snapshot = workspace.kernel.status(req.params.runId);
|
|
1838
|
+
if (snapshot.status === "done" || snapshot.status === "blocked") {
|
|
1839
|
+
const created = workspace.kernel.store
|
|
1840
|
+
.events(req.params.runId)
|
|
1841
|
+
.find((event) => event.t === "run_created");
|
|
1842
|
+
const ownerAgentId = created?.mainAgentId ?? created?.agentId;
|
|
1843
|
+
if (!created?.pipelineRunId || !ownerAgentId) {
|
|
1844
|
+
res.status(409).json({
|
|
1845
|
+
error: "completed run cannot be reopened without Pipeline scope",
|
|
1846
|
+
});
|
|
1847
|
+
return;
|
|
1848
|
+
}
|
|
1849
|
+
const found = findPipelineRun(workspace, ownerAgentId, created.pipelineRunId);
|
|
1850
|
+
if (!found) {
|
|
1851
|
+
res.status(409).json({
|
|
1852
|
+
error: "Pipeline coordinator is unavailable for terminal run",
|
|
1853
|
+
});
|
|
1854
|
+
return;
|
|
1855
|
+
}
|
|
1856
|
+
// Plan Runs are intentionally not Tasks. The planner card in the
|
|
1857
|
+
// UI uses a synthetic `planner:<PipelineRunId>` id, which must not
|
|
1858
|
+
// be passed to Execute task-scope recovery. Re-open the original
|
|
1859
|
+
// Plan Run directly and keep its identity.
|
|
1860
|
+
if (created.runKind === "plan" ||
|
|
1861
|
+
(!created.runKind && !created.taskId)) {
|
|
1862
|
+
if (!nodeId || typeof text !== "string" || !text.trim()) {
|
|
1863
|
+
res.status(400).json({ error: "nodeId and text are required" });
|
|
1864
|
+
return;
|
|
1865
|
+
}
|
|
1866
|
+
res.json(found.coordinator.reopenPlan(created.pipelineRunId, {
|
|
1867
|
+
nodeId,
|
|
1868
|
+
text,
|
|
1869
|
+
}));
|
|
1870
|
+
return;
|
|
1871
|
+
}
|
|
1872
|
+
if (!created.taskId) {
|
|
1873
|
+
res.status(409).json({
|
|
1874
|
+
error: "completed run cannot be reopened without Pipeline task scope",
|
|
1875
|
+
});
|
|
1876
|
+
return;
|
|
1877
|
+
}
|
|
1878
|
+
if (snapshot.status === "done") {
|
|
1879
|
+
res.json(found.coordinator.reopenDone(created.pipelineRunId, {
|
|
1880
|
+
taskId: created.taskId,
|
|
1881
|
+
nodeId,
|
|
1882
|
+
text,
|
|
1883
|
+
}));
|
|
1884
|
+
}
|
|
1885
|
+
else {
|
|
1886
|
+
res.json(found.coordinator.resumeBlocked(created.pipelineRunId, {
|
|
1887
|
+
taskId: created.taskId,
|
|
1888
|
+
nodeId,
|
|
1889
|
+
text,
|
|
1890
|
+
}));
|
|
1891
|
+
}
|
|
1892
|
+
return;
|
|
1893
|
+
}
|
|
1894
|
+
}
|
|
1895
|
+
switch (method) {
|
|
1896
|
+
case "interrupt":
|
|
1897
|
+
workspace.kernel.interrupt(req.params.runId, nodeId, text);
|
|
1898
|
+
break;
|
|
1899
|
+
case "approve":
|
|
1900
|
+
workspace.kernel.approve(req.params.runId, nodeId);
|
|
1901
|
+
break;
|
|
1902
|
+
case "reject":
|
|
1903
|
+
workspace.kernel.reject(req.params.runId, nodeId, feedback);
|
|
1904
|
+
break;
|
|
1905
|
+
case "pause":
|
|
1906
|
+
workspace.kernel.pause(req.params.runId);
|
|
1907
|
+
break;
|
|
1908
|
+
case "resume":
|
|
1909
|
+
workspace.kernel.resume(req.params.runId);
|
|
1910
|
+
break;
|
|
1911
|
+
case "goto":
|
|
1912
|
+
workspace.kernel.goto(req.params.runId, nodeId, text);
|
|
1913
|
+
break;
|
|
1914
|
+
}
|
|
1915
|
+
res.json({ ok: true });
|
|
1916
|
+
}
|
|
1917
|
+
catch (error) {
|
|
1918
|
+
res.status(500).json({
|
|
1919
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1920
|
+
});
|
|
1921
|
+
}
|
|
1922
|
+
});
|
|
1923
|
+
}
|
|
1924
|
+
router.post("/workspaces/:wid/agents/:agentId/plan", (req, res) => {
|
|
1925
|
+
const workspace = scopedAgent(req.params.wid, req.params.agentId);
|
|
1926
|
+
if (!workspace) {
|
|
1927
|
+
res.status(404).json({ error: "pipeline agent not found" });
|
|
1928
|
+
return;
|
|
1929
|
+
}
|
|
1930
|
+
const { spec: bodySpec, vars, workflowId } = req.body ?? {};
|
|
1931
|
+
let spec = bodySpec;
|
|
1932
|
+
if (!spec && workflowId) {
|
|
1933
|
+
if (typeof workflowId !== "string" || !isSafeWorkflowId(workflowId)) {
|
|
1934
|
+
res.status(400).json({ error: "invalid workflow id" });
|
|
1935
|
+
return;
|
|
1936
|
+
}
|
|
1937
|
+
const fp = workflowPackagePaths(workspace.rootPath, workflowId).workflow;
|
|
1938
|
+
if (fs.existsSync(fp))
|
|
1939
|
+
spec = readJsonFile(fp);
|
|
1940
|
+
}
|
|
1941
|
+
if (!spec) {
|
|
1942
|
+
res.status(400).json({ error: "no spec and no workflow" });
|
|
1943
|
+
return;
|
|
1944
|
+
}
|
|
1945
|
+
const workflowRoot = workflowId
|
|
1946
|
+
? workflowPackagePaths(workspace.rootPath, workflowId).root
|
|
1947
|
+
: workspace.rootPath;
|
|
1948
|
+
res.json(workspace.kernel.startPlan(spec, vars ?? {}, workflowRoot, req.params.agentId, workspace.workspaceId));
|
|
1949
|
+
});
|
|
1950
|
+
router.post("/workspaces/:wid/agents/:agentId/run", (req, res) => {
|
|
1951
|
+
const workspace = scopedAgent(req.params.wid, req.params.agentId);
|
|
1952
|
+
if (!workspace) {
|
|
1953
|
+
res.status(404).json({ error: "pipeline agent not found" });
|
|
1954
|
+
return;
|
|
1955
|
+
}
|
|
1956
|
+
const workflowId = req.body?.workflowId;
|
|
1957
|
+
if (!workflowId) {
|
|
1958
|
+
res.status(400).json({ error: "no workflowId" });
|
|
1959
|
+
return;
|
|
1960
|
+
}
|
|
1961
|
+
if (!isSafeWorkflowId(workflowId)) {
|
|
1962
|
+
res.status(400).json({ error: "invalid workflow id" });
|
|
1963
|
+
return;
|
|
1964
|
+
}
|
|
1965
|
+
const fp = workflowPackagePaths(workspace.rootPath, workflowId).workflow;
|
|
1966
|
+
if (!fs.existsSync(fp)) {
|
|
1967
|
+
res.status(404).json({ error: "workflow not found" });
|
|
1968
|
+
return;
|
|
1969
|
+
}
|
|
1970
|
+
const spec = readJsonFile(fp);
|
|
1971
|
+
const workflowRoot = path.dirname(fp);
|
|
1972
|
+
const runner = new TaskRunner({
|
|
1973
|
+
kernel: workspace.kernel,
|
|
1974
|
+
spec,
|
|
1975
|
+
vars: {},
|
|
1976
|
+
specDir: workflowRoot,
|
|
1977
|
+
pipelineId: req.params.agentId,
|
|
1978
|
+
parentAgentId: req.params.agentId,
|
|
1979
|
+
workspaceId: workspace.workspaceId,
|
|
1980
|
+
});
|
|
1981
|
+
workspace.taskRunners.set(req.params.agentId, runner);
|
|
1982
|
+
runner.start().catch(() => { });
|
|
1983
|
+
res.json({ runnerId: `runner-${Date.now()}`, runIds: [] });
|
|
1984
|
+
});
|
|
1985
|
+
for (const operation of ["pause", "resume"]) {
|
|
1986
|
+
router.post(`/workspaces/:wid/agents/:agentId/pipeline/${operation}`, (req, res) => {
|
|
1987
|
+
const workspace = scopedAgent(req.params.wid, req.params.agentId);
|
|
1988
|
+
if (!workspace) {
|
|
1989
|
+
res.status(404).json({ error: "pipeline agent not found" });
|
|
1990
|
+
return;
|
|
1991
|
+
}
|
|
1992
|
+
if (operation === "pause")
|
|
1993
|
+
workspace.taskRunners.get(req.params.agentId)?.pause();
|
|
1994
|
+
const runIds = [];
|
|
1995
|
+
for (const task of workspace.kernel.tasks
|
|
1996
|
+
.list()
|
|
1997
|
+
.filter((item) => item.agentId === req.params.agentId)) {
|
|
1998
|
+
if (!task.runId)
|
|
1999
|
+
continue;
|
|
2000
|
+
try {
|
|
2001
|
+
workspace.kernel[operation](task.runId);
|
|
2002
|
+
runIds.push(task.runId);
|
|
2003
|
+
}
|
|
2004
|
+
catch {
|
|
2005
|
+
/* task can point at a stale run */
|
|
2006
|
+
}
|
|
2007
|
+
}
|
|
2008
|
+
res.json({ runIds });
|
|
2009
|
+
});
|
|
2010
|
+
}
|
|
2011
|
+
router.post("/workflows/:id/validate", (req, res) => {
|
|
2012
|
+
const result = safeParseWorkflowSpec(req.body);
|
|
2013
|
+
if (!result.success) {
|
|
2014
|
+
res.status(400).json({ success: false, error: result.error });
|
|
2015
|
+
return;
|
|
2016
|
+
}
|
|
2017
|
+
res.json({ success: true });
|
|
2018
|
+
});
|
|
2019
|
+
// --- Tasks ---
|
|
2020
|
+
router.get("/agents/:agentId/tasks", (req, res) => {
|
|
2021
|
+
const wk = findKernelByAgentId(req.params.agentId);
|
|
2022
|
+
if (!wk) {
|
|
2023
|
+
res.json([]);
|
|
2024
|
+
return;
|
|
2025
|
+
}
|
|
2026
|
+
res.json(wk.kernel.tasks.list().filter((t) => t.agentId === req.params.agentId));
|
|
2027
|
+
});
|
|
2028
|
+
router.post("/agents/:agentId/tasks", (req, res) => {
|
|
2029
|
+
let wk = findKernelByAgentId(req.params.agentId);
|
|
2030
|
+
if (!wk)
|
|
2031
|
+
wk = kernels.get("default");
|
|
2032
|
+
const { id, title } = req.body ?? {};
|
|
2033
|
+
const task = wk.kernel.tasks.add(id, title, undefined, req.params.agentId);
|
|
2034
|
+
res.json(task);
|
|
2035
|
+
});
|
|
2036
|
+
router.post("/agents/:agentId/tasks/import", (req, res) => {
|
|
2037
|
+
let wk = findKernelByAgentId(req.params.agentId);
|
|
2038
|
+
if (!wk)
|
|
2039
|
+
wk = kernels.get("default");
|
|
2040
|
+
const { file } = req.body ?? {};
|
|
2041
|
+
const raw = fs.readFileSync(file, "utf8");
|
|
2042
|
+
const items = parseTaskList(raw);
|
|
2043
|
+
let added = 0, skipped = 0;
|
|
2044
|
+
for (const item of items) {
|
|
2045
|
+
if (wk.kernel.tasks.get(item.id)) {
|
|
2046
|
+
skipped++;
|
|
2047
|
+
continue;
|
|
2048
|
+
}
|
|
2049
|
+
wk.kernel.tasks.add(item.id, item.title, undefined, req.params.agentId);
|
|
2050
|
+
added++;
|
|
2051
|
+
}
|
|
2052
|
+
res.json({ added, skipped });
|
|
2053
|
+
});
|
|
2054
|
+
router.delete("/tasks/:id", (req, res) => {
|
|
2055
|
+
for (const wk of kernels.values()) {
|
|
2056
|
+
if (wk.kernel.tasks.get(req.params.id)) {
|
|
2057
|
+
wk.kernel.tasks.remove(req.params.id);
|
|
2058
|
+
res.json({ ok: true });
|
|
2059
|
+
return;
|
|
2060
|
+
}
|
|
2061
|
+
}
|
|
2062
|
+
res.status(404).json({ error: "task not found" });
|
|
2063
|
+
});
|
|
2064
|
+
router.get("/tasks/:id", (req, res) => {
|
|
2065
|
+
for (const wk of kernels.values()) {
|
|
2066
|
+
const task = wk.kernel.tasks.get(req.params.id);
|
|
2067
|
+
if (task) {
|
|
2068
|
+
const status = wk.kernel.taskStatus(req.params.id);
|
|
2069
|
+
let run = null;
|
|
2070
|
+
if (task.runId) {
|
|
2071
|
+
try {
|
|
2072
|
+
run = wk.kernel.status(task.runId);
|
|
2073
|
+
}
|
|
2074
|
+
catch { }
|
|
2075
|
+
}
|
|
2076
|
+
res.json({ task, status, run });
|
|
2077
|
+
return;
|
|
2078
|
+
}
|
|
2079
|
+
}
|
|
2080
|
+
res.status(404).json({ error: "task not found" });
|
|
2081
|
+
});
|
|
2082
|
+
router.post("/tasks/:id/runs", (req, res) => {
|
|
2083
|
+
for (const wk of kernels.values()) {
|
|
2084
|
+
const task = wk.kernel.tasks.get(req.params.id);
|
|
2085
|
+
if (task) {
|
|
2086
|
+
const { spec, vars } = req.body ?? {};
|
|
2087
|
+
const { runId } = wk.kernel.startRun(spec, vars ?? {}, wk.rootPath, req.params.id, task.agentId, wk.workspaceId);
|
|
2088
|
+
res.json({ runId });
|
|
2089
|
+
return;
|
|
2090
|
+
}
|
|
2091
|
+
}
|
|
2092
|
+
res.status(404).json({ error: "task not found" });
|
|
2093
|
+
});
|
|
2094
|
+
// --- Runs ---
|
|
2095
|
+
router.get("/runs/:runId", (req, res) => {
|
|
2096
|
+
const wk = findKernelByRunId(req.params.runId);
|
|
2097
|
+
if (!wk) {
|
|
2098
|
+
res.status(404).json({ error: "run not found" });
|
|
2099
|
+
return;
|
|
2100
|
+
}
|
|
2101
|
+
res.json(statusWithRunKind(wk.kernel.status(req.params.runId), wk.kernel.store.events(req.params.runId)));
|
|
2102
|
+
});
|
|
2103
|
+
router.get("/runs/:runId/events", (req, res) => {
|
|
2104
|
+
const wk = findKernelByRunId(req.params.runId);
|
|
2105
|
+
if (!wk) {
|
|
2106
|
+
res.status(404).json({ error: "run not found" });
|
|
2107
|
+
return;
|
|
2108
|
+
}
|
|
2109
|
+
const fromOffset = parseInt(req.query.from) || 0;
|
|
2110
|
+
res.json(wk.kernel.store.readEventsFromOffset(req.params.runId, fromOffset));
|
|
2111
|
+
});
|
|
2112
|
+
// Run commands
|
|
2113
|
+
for (const [ep, method] of [
|
|
2114
|
+
["interrupt", "interrupt"],
|
|
2115
|
+
["approve", "approve"],
|
|
2116
|
+
["reject", "reject"],
|
|
2117
|
+
["pause", "pause"],
|
|
2118
|
+
["resume", "resume"],
|
|
2119
|
+
["goto", "goto"],
|
|
2120
|
+
]) {
|
|
2121
|
+
router.post(`/runs/:runId/${ep}`, (req, res) => {
|
|
2122
|
+
const wk = findKernelByRunId(req.params.runId);
|
|
2123
|
+
if (!wk) {
|
|
2124
|
+
res.status(404).json({ error: "run not found" });
|
|
2125
|
+
return;
|
|
2126
|
+
}
|
|
2127
|
+
const { nodeId, text, feedback } = req.body ?? {};
|
|
2128
|
+
try {
|
|
2129
|
+
switch (method) {
|
|
2130
|
+
case "interrupt":
|
|
2131
|
+
wk.kernel.interrupt(req.params.runId, nodeId, text);
|
|
2132
|
+
break;
|
|
2133
|
+
case "approve":
|
|
2134
|
+
wk.kernel.approve(req.params.runId, nodeId);
|
|
2135
|
+
break;
|
|
2136
|
+
case "reject":
|
|
2137
|
+
wk.kernel.reject(req.params.runId, nodeId, feedback);
|
|
2138
|
+
break;
|
|
2139
|
+
case "pause":
|
|
2140
|
+
wk.kernel.pause(req.params.runId);
|
|
2141
|
+
break;
|
|
2142
|
+
case "resume":
|
|
2143
|
+
wk.kernel.resume(req.params.runId);
|
|
2144
|
+
break;
|
|
2145
|
+
case "goto":
|
|
2146
|
+
wk.kernel.goto(req.params.runId, nodeId, text);
|
|
2147
|
+
break;
|
|
2148
|
+
}
|
|
2149
|
+
res.json({ ok: true });
|
|
2150
|
+
}
|
|
2151
|
+
catch (e) {
|
|
2152
|
+
res
|
|
2153
|
+
.status(500)
|
|
2154
|
+
.json({ error: e instanceof Error ? e.message : String(e) });
|
|
2155
|
+
}
|
|
2156
|
+
});
|
|
2157
|
+
}
|
|
2158
|
+
// --- Pipeline control ---
|
|
2159
|
+
router.post("/agents/:agentId/plan", (req, res) => {
|
|
2160
|
+
let wk = findKernelByAgentId(req.params.agentId);
|
|
2161
|
+
if (!wk)
|
|
2162
|
+
wk = kernels.get("default");
|
|
2163
|
+
const { spec: bodySpec, vars, workflowId } = req.body ?? {};
|
|
2164
|
+
let spec = bodySpec;
|
|
2165
|
+
if (!spec && workflowId) {
|
|
2166
|
+
if (typeof workflowId !== "string" || !isSafeWorkflowId(workflowId)) {
|
|
2167
|
+
res.status(400).json({ error: "invalid workflow id" });
|
|
2168
|
+
return;
|
|
2169
|
+
}
|
|
2170
|
+
const fp = workflowPackagePaths(wk.rootPath, workflowId).workflow;
|
|
2171
|
+
if (fs.existsSync(fp)) {
|
|
2172
|
+
spec = readJsonFile(fp);
|
|
2173
|
+
}
|
|
2174
|
+
}
|
|
2175
|
+
if (!spec) {
|
|
2176
|
+
res.status(400).json({ error: "no spec and no workflow" });
|
|
2177
|
+
return;
|
|
2178
|
+
}
|
|
2179
|
+
const workflowRoot = workflowId
|
|
2180
|
+
? workflowPackagePaths(wk.rootPath, workflowId).root
|
|
2181
|
+
: wk.rootPath;
|
|
2182
|
+
const { runId } = wk.kernel.startPlan(spec, vars ?? {}, workflowRoot, req.params.agentId, wk.workspaceId);
|
|
2183
|
+
res.json({ runId });
|
|
2184
|
+
});
|
|
2185
|
+
router.post("/agents/:agentId/run", (req, res) => {
|
|
2186
|
+
let wk = findKernelByAgentId(req.params.agentId);
|
|
2187
|
+
if (!wk)
|
|
2188
|
+
wk = kernels.get("default");
|
|
2189
|
+
const wfId = req.body?.workflowId;
|
|
2190
|
+
if (!wfId) {
|
|
2191
|
+
res.status(400).json({ error: "no workflowId" });
|
|
2192
|
+
return;
|
|
2193
|
+
}
|
|
2194
|
+
if (!isSafeWorkflowId(wfId)) {
|
|
2195
|
+
res.status(400).json({ error: "invalid workflow id" });
|
|
2196
|
+
return;
|
|
2197
|
+
}
|
|
2198
|
+
const fp = workflowPackagePaths(wk.rootPath, wfId).workflow;
|
|
2199
|
+
if (!fs.existsSync(fp)) {
|
|
2200
|
+
res.status(404).json({ error: "workflow not found" });
|
|
2201
|
+
return;
|
|
2202
|
+
}
|
|
2203
|
+
const spec = readJsonFile(fp);
|
|
2204
|
+
const runner = new TaskRunner({
|
|
2205
|
+
kernel: wk.kernel,
|
|
2206
|
+
spec,
|
|
2207
|
+
vars: {},
|
|
2208
|
+
specDir: path.dirname(fp),
|
|
2209
|
+
pipelineId: req.params.agentId,
|
|
2210
|
+
parentAgentId: req.params.agentId,
|
|
2211
|
+
workspaceId: wk.workspaceId,
|
|
2212
|
+
});
|
|
2213
|
+
wk.taskRunners.set(req.params.agentId, runner);
|
|
2214
|
+
runner.start().catch(() => { });
|
|
2215
|
+
res.json({ runnerId: `runner-${Date.now()}`, runIds: [] });
|
|
2216
|
+
});
|
|
2217
|
+
router.post("/agents/:agentId/pipeline/pause", (req, res) => {
|
|
2218
|
+
let wk = findKernelByAgentId(req.params.agentId);
|
|
2219
|
+
if (!wk)
|
|
2220
|
+
wk = kernels.get("default");
|
|
2221
|
+
const runner = wk.taskRunners.get(req.params.agentId);
|
|
2222
|
+
if (runner)
|
|
2223
|
+
runner.pause();
|
|
2224
|
+
const tasks = wk.kernel.tasks
|
|
2225
|
+
.list()
|
|
2226
|
+
.filter((t) => t.agentId === req.params.agentId);
|
|
2227
|
+
const runIds = [];
|
|
2228
|
+
for (const t of tasks) {
|
|
2229
|
+
if (t.runId) {
|
|
2230
|
+
try {
|
|
2231
|
+
wk.kernel.pause(t.runId);
|
|
2232
|
+
runIds.push(t.runId);
|
|
2233
|
+
}
|
|
2234
|
+
catch { }
|
|
2235
|
+
}
|
|
2236
|
+
}
|
|
2237
|
+
res.json({ runIds });
|
|
2238
|
+
});
|
|
2239
|
+
router.post("/agents/:agentId/pipeline/resume", (req, res) => {
|
|
2240
|
+
let wk = findKernelByAgentId(req.params.agentId);
|
|
2241
|
+
if (!wk)
|
|
2242
|
+
wk = kernels.get("default");
|
|
2243
|
+
const tasks = wk.kernel.tasks
|
|
2244
|
+
.list()
|
|
2245
|
+
.filter((t) => t.agentId === req.params.agentId);
|
|
2246
|
+
const runIds = [];
|
|
2247
|
+
for (const t of tasks) {
|
|
2248
|
+
if (t.runId) {
|
|
2249
|
+
try {
|
|
2250
|
+
wk.kernel.resume(t.runId);
|
|
2251
|
+
runIds.push(t.runId);
|
|
2252
|
+
}
|
|
2253
|
+
catch { }
|
|
2254
|
+
}
|
|
2255
|
+
}
|
|
2256
|
+
res.json({ runIds });
|
|
2257
|
+
});
|
|
2258
|
+
return {
|
|
2259
|
+
serverId,
|
|
2260
|
+
async request(pathname, options = {}) {
|
|
2261
|
+
const url = new URL(pathname, "http://agentstorm.internal");
|
|
2262
|
+
const routePath = url.pathname.startsWith("/api/")
|
|
2263
|
+
? url.pathname.slice("/api".length)
|
|
2264
|
+
: url.pathname;
|
|
2265
|
+
const result = await router.dispatch({
|
|
2266
|
+
method: options.method ?? "GET",
|
|
2267
|
+
pathname: routePath,
|
|
2268
|
+
query: url.searchParams,
|
|
2269
|
+
body: options.body,
|
|
2270
|
+
...(options.etag ? { headers: { "if-match": options.etag } } : {}),
|
|
2271
|
+
});
|
|
2272
|
+
if (result.status >= 400) {
|
|
2273
|
+
throw new PipelineServiceRequestError(result.status, result.body);
|
|
2274
|
+
}
|
|
2275
|
+
return result.body;
|
|
2276
|
+
},
|
|
2277
|
+
readEventsFromOffset(runId, offset) {
|
|
2278
|
+
for (const wk of kernels.values()) {
|
|
2279
|
+
for (const runtime of [wk, ...wk.sessions.values()]) {
|
|
2280
|
+
const result = runtime.kernel.store.readEventsFromOffset(runId, offset);
|
|
2281
|
+
if (result.nextOffset !== offset || result.events.length > 0)
|
|
2282
|
+
return result;
|
|
2283
|
+
}
|
|
2284
|
+
}
|
|
2285
|
+
return { events: [], nextOffset: offset };
|
|
2286
|
+
},
|
|
2287
|
+
async close() { },
|
|
2288
|
+
};
|
|
2289
|
+
}
|
|
2290
|
+
export class PipelineServiceRequestError extends Error {
|
|
2291
|
+
status;
|
|
2292
|
+
body;
|
|
2293
|
+
constructor(status, body) {
|
|
2294
|
+
super(`Pipeline service ${status}: ${JSON.stringify(body)}`);
|
|
2295
|
+
this.status = status;
|
|
2296
|
+
this.body = body;
|
|
2297
|
+
}
|
|
2298
|
+
}
|
|
2299
|
+
class PipelineRouteRegistry {
|
|
2300
|
+
routes = [];
|
|
2301
|
+
get(template, handler) {
|
|
2302
|
+
this.add("GET", template, handler);
|
|
2303
|
+
}
|
|
2304
|
+
post(template, handler) {
|
|
2305
|
+
this.add("POST", template, handler);
|
|
2306
|
+
}
|
|
2307
|
+
put(template, handler) {
|
|
2308
|
+
this.add("PUT", template, handler);
|
|
2309
|
+
}
|
|
2310
|
+
delete(template, handler) {
|
|
2311
|
+
this.add("DELETE", template, handler);
|
|
2312
|
+
}
|
|
2313
|
+
async dispatch(input) {
|
|
2314
|
+
for (const route of this.routes) {
|
|
2315
|
+
if (route.method !== input.method)
|
|
2316
|
+
continue;
|
|
2317
|
+
const params = matchPipelineRoute(route.template, input.pathname);
|
|
2318
|
+
if (!params)
|
|
2319
|
+
continue;
|
|
2320
|
+
return invokePipelineRoute(route.handler, params, input);
|
|
2321
|
+
}
|
|
2322
|
+
return { status: 404, body: { error: "Pipeline route not found" } };
|
|
2323
|
+
}
|
|
2324
|
+
add(method, template, handler) {
|
|
2325
|
+
this.routes.push({ method, template, handler: handler });
|
|
2326
|
+
}
|
|
2327
|
+
}
|
|
2328
|
+
async function invokePipelineRoute(handler, params, input) {
|
|
2329
|
+
let status = 200;
|
|
2330
|
+
let body;
|
|
2331
|
+
let responded = false;
|
|
2332
|
+
const headers = Object.fromEntries(Object.entries(input.headers ?? {}).map(([name, value]) => [name.toLowerCase(), value]));
|
|
2333
|
+
const request = {
|
|
2334
|
+
params,
|
|
2335
|
+
body: input.body,
|
|
2336
|
+
query: Object.fromEntries(input.query.entries()),
|
|
2337
|
+
headers,
|
|
2338
|
+
header(name) {
|
|
2339
|
+
return headers[name.toLowerCase()];
|
|
2340
|
+
},
|
|
2341
|
+
};
|
|
2342
|
+
const response = {
|
|
2343
|
+
status(code) {
|
|
2344
|
+
status = code;
|
|
2345
|
+
return response;
|
|
2346
|
+
},
|
|
2347
|
+
json(value) {
|
|
2348
|
+
body = value;
|
|
2349
|
+
responded = true;
|
|
2350
|
+
return response;
|
|
2351
|
+
},
|
|
2352
|
+
};
|
|
2353
|
+
await handler(request, response, () => undefined);
|
|
2354
|
+
if (!responded)
|
|
2355
|
+
throw new Error(`Pipeline route ${input.method} ${input.pathname} did not respond`);
|
|
2356
|
+
return { status, body };
|
|
2357
|
+
}
|
|
2358
|
+
function matchPipelineRoute(template, pathname) {
|
|
2359
|
+
const templateParts = template.split("/").filter(Boolean);
|
|
2360
|
+
const pathParts = pathname.split("/").filter(Boolean);
|
|
2361
|
+
if (templateParts.length !== pathParts.length)
|
|
2362
|
+
return null;
|
|
2363
|
+
const params = {};
|
|
2364
|
+
for (const [index, templatePart] of templateParts.entries()) {
|
|
2365
|
+
const pathPart = pathParts[index];
|
|
2366
|
+
if (templatePart.startsWith(":")) {
|
|
2367
|
+
params[templatePart.slice(1)] = decodeURIComponent(pathPart);
|
|
2368
|
+
}
|
|
2369
|
+
else if (templatePart !== pathPart) {
|
|
2370
|
+
return null;
|
|
2371
|
+
}
|
|
2372
|
+
}
|
|
2373
|
+
return params;
|
|
2374
|
+
}
|
|
2375
|
+
function etagOf(raw) {
|
|
2376
|
+
// Package writes append one final newline; callers that just serialised the
|
|
2377
|
+
// same JSON may not. Treat that formatting difference as the same document
|
|
2378
|
+
// so a freshly-created Workflow can be updated with the returned ETag.
|
|
2379
|
+
const canonical = raw.replace(/\r?\n$/, "");
|
|
2380
|
+
return `"${createHash("sha256")
|
|
2381
|
+
.update(canonical)
|
|
2382
|
+
.digest("hex")
|
|
2383
|
+
.slice(0, 16)}"`;
|
|
2384
|
+
}
|
|
2385
|
+
function isSafeWorkflowId(value) {
|
|
2386
|
+
// Workflow names are user-facing, so Unicode and spaces are deliberately
|
|
2387
|
+
// allowed. Only reject strings that could make the JSON file escape its
|
|
2388
|
+
// workspace-local .agentstorm/workflows directory.
|
|
2389
|
+
return (value.length > 0 &&
|
|
2390
|
+
value !== "." &&
|
|
2391
|
+
value !== ".." &&
|
|
2392
|
+
!/[\\/\0]/.test(value));
|
|
2393
|
+
}
|
|
2394
|
+
/** Read-only compatibility for Workflow files written before Prompt and
|
|
2395
|
+
* Skills moved from graph nodes to the Agent registry. New saves never emit
|
|
2396
|
+
* these node fields, but old packages remain runnable while they migrate. */
|
|
2397
|
+
function legacyAgentNode(spec, agentId) {
|
|
2398
|
+
for (const graph of [spec.plan, spec.execute]) {
|
|
2399
|
+
const node = graph?.nodes.find((candidate) => candidate.type !== "approval" && candidate.agent === agentId);
|
|
2400
|
+
if (node)
|
|
2401
|
+
return node;
|
|
2402
|
+
}
|
|
2403
|
+
return undefined;
|
|
2404
|
+
}
|
|
2405
|
+
function effectiveAgentPrompt(spec, agentId, config) {
|
|
2406
|
+
return config.prompt ?? legacyAgentNode(spec, agentId)?.prompt;
|
|
2407
|
+
}
|
|
2408
|
+
function effectiveAgentSkills(spec, agentId, config) {
|
|
2409
|
+
if (config.skills !== undefined)
|
|
2410
|
+
return config.skills;
|
|
2411
|
+
return legacyAgentNode(spec, agentId)?.skills ?? [];
|
|
2412
|
+
}
|
|
2413
|
+
function rewriteBundlePromptRefs(spec, assets) {
|
|
2414
|
+
const assetNames = new Set(assets);
|
|
2415
|
+
const agents = Object.fromEntries(Object.entries(spec.agents).map(([agentId, config]) => {
|
|
2416
|
+
const prompt = effectiveAgentPrompt(spec, agentId, config);
|
|
2417
|
+
if (!prompt?.startsWith("@"))
|
|
2418
|
+
return [agentId, config];
|
|
2419
|
+
const reference = prompt.slice(1).replaceAll("\\", "/");
|
|
2420
|
+
if (!reference.startsWith("prompts/") || !assetNames.has(reference)) {
|
|
2421
|
+
throw new Error(`Workflow Bundle 的 Prompt 必须引用 prompts/<Agent ID>.md:${reference}`);
|
|
2422
|
+
}
|
|
2423
|
+
return [agentId, { ...config, prompt: `@${reference}` }];
|
|
2424
|
+
}));
|
|
2425
|
+
return {
|
|
2426
|
+
...spec,
|
|
2427
|
+
agents,
|
|
2428
|
+
};
|
|
2429
|
+
}
|
|
2430
|
+
function ensureWorkflowPromptFiles(packageRoot, spec) {
|
|
2431
|
+
validateWorkflowPromptReferences(spec);
|
|
2432
|
+
for (const [agentId, config] of Object.entries(spec.agents)) {
|
|
2433
|
+
const prompt = effectiveAgentPrompt(spec, agentId, config);
|
|
2434
|
+
if (!prompt?.startsWith("@"))
|
|
2435
|
+
continue;
|
|
2436
|
+
const reference = prompt.slice(1).replaceAll("\\", "/");
|
|
2437
|
+
const safe = safePackageRelativePath(reference);
|
|
2438
|
+
if (!safe)
|
|
2439
|
+
throw new Error(`Prompt 路径无效:${reference}`); // validated above
|
|
2440
|
+
const target = path.resolve(packageRoot, safe);
|
|
2441
|
+
assertInsidePackage(packageRoot, target);
|
|
2442
|
+
if (!fs.existsSync(target)) {
|
|
2443
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
2444
|
+
fs.writeFileSync(target, "# Agent instructions\n\n根据当前任务描述完成工作,并记录可复核的结果。\n", "utf8");
|
|
2445
|
+
}
|
|
2446
|
+
}
|
|
2447
|
+
}
|
|
2448
|
+
function validateWorkflowPromptReferences(spec) {
|
|
2449
|
+
for (const [agentId, config] of Object.entries(spec.agents)) {
|
|
2450
|
+
const prompt = effectiveAgentPrompt(spec, agentId, config);
|
|
2451
|
+
if (!prompt?.startsWith("@prompts/"))
|
|
2452
|
+
continue;
|
|
2453
|
+
const reference = prompt.slice(1).replaceAll("\\", "/");
|
|
2454
|
+
const safe = safePackageRelativePath(reference);
|
|
2455
|
+
if (!safe)
|
|
2456
|
+
throw new Error(`Prompt 路径无效:${reference}`);
|
|
2457
|
+
const expected = workflowPromptRelativePath(agentId).replaceAll("\\", "/");
|
|
2458
|
+
if (reference !== expected) {
|
|
2459
|
+
throw new Error(`Prompt 文件必须与 Agent ID 绑定:${agentId} → @${expected}`);
|
|
2460
|
+
}
|
|
2461
|
+
}
|
|
2462
|
+
}
|
|
2463
|
+
function collectPromptAssets(rootPath, spec) {
|
|
2464
|
+
const assets = {};
|
|
2465
|
+
for (const [agentId, config] of Object.entries(spec.agents)) {
|
|
2466
|
+
const prompt = effectiveAgentPrompt(spec, agentId, config);
|
|
2467
|
+
if (!prompt?.startsWith("@"))
|
|
2468
|
+
continue;
|
|
2469
|
+
const reference = prompt.slice(1).replaceAll("\\", "/");
|
|
2470
|
+
const safe = safePackageRelativePath(reference);
|
|
2471
|
+
if (!safe)
|
|
2472
|
+
throw new Error(`prompt asset escapes Workspace: ${reference}`);
|
|
2473
|
+
const target = path.resolve(rootPath, safe);
|
|
2474
|
+
const root = path.resolve(rootPath);
|
|
2475
|
+
if (target !== root && !target.startsWith(`${root}${path.sep}`))
|
|
2476
|
+
throw new Error(`prompt asset escapes Workspace: ${reference}`);
|
|
2477
|
+
if (!fs.existsSync(target) || !fs.statSync(target).isFile())
|
|
2478
|
+
throw new Error(`prompt asset not found: ${reference}`);
|
|
2479
|
+
assets[reference] = fs.readFileSync(target, "utf8");
|
|
2480
|
+
}
|
|
2481
|
+
return assets;
|
|
2482
|
+
}
|
|
2483
|
+
/**
|
|
2484
|
+
* Check the parts of a Workflow that AgentStorm can prove before spawning a
|
|
2485
|
+
* Paseo Agent. Provider/model resolution remains delegated to the Paseo
|
|
2486
|
+
* daemon's live provider snapshot; this check deliberately reports the
|
|
2487
|
+
* configured values rather than pretending PipelineService owns that registry.
|
|
2488
|
+
*/
|
|
2489
|
+
export function workflowReadiness(rootPath, spec) {
|
|
2490
|
+
const missingPromptAssets = [];
|
|
2491
|
+
const missingSkills = [];
|
|
2492
|
+
const root = path.resolve(rootPath);
|
|
2493
|
+
for (const [agentId, config] of Object.entries(spec.agents)) {
|
|
2494
|
+
const prompt = effectiveAgentPrompt(spec, agentId, config);
|
|
2495
|
+
if (prompt?.startsWith("@")) {
|
|
2496
|
+
const reference = prompt.slice(1).replaceAll("\\", "/");
|
|
2497
|
+
const safe = safePackageRelativePath(reference);
|
|
2498
|
+
const expected = workflowPromptRelativePath(agentId).replaceAll("\\", "/");
|
|
2499
|
+
if (!safe ||
|
|
2500
|
+
!reference.startsWith("prompts/") ||
|
|
2501
|
+
reference !== expected) {
|
|
2502
|
+
missingPromptAssets.push({
|
|
2503
|
+
agentId,
|
|
2504
|
+
reference,
|
|
2505
|
+
reason: "outside_workspace",
|
|
2506
|
+
});
|
|
2507
|
+
}
|
|
2508
|
+
else {
|
|
2509
|
+
const target = path.resolve(root, safe);
|
|
2510
|
+
if (target !== root && !target.startsWith(`${root}${path.sep}`)) {
|
|
2511
|
+
missingPromptAssets.push({
|
|
2512
|
+
agentId,
|
|
2513
|
+
reference,
|
|
2514
|
+
reason: "outside_workspace",
|
|
2515
|
+
});
|
|
2516
|
+
}
|
|
2517
|
+
else {
|
|
2518
|
+
try {
|
|
2519
|
+
if (!fs.existsSync(target) || !fs.statSync(target).isFile()) {
|
|
2520
|
+
missingPromptAssets.push({
|
|
2521
|
+
agentId,
|
|
2522
|
+
reference,
|
|
2523
|
+
reason: "not_found",
|
|
2524
|
+
});
|
|
2525
|
+
continue;
|
|
2526
|
+
}
|
|
2527
|
+
const realRoot = fs.realpathSync(root);
|
|
2528
|
+
const realTarget = fs.realpathSync(target);
|
|
2529
|
+
if (realTarget !== realRoot &&
|
|
2530
|
+
!realTarget.startsWith(`${realRoot}${path.sep}`)) {
|
|
2531
|
+
missingPromptAssets.push({
|
|
2532
|
+
agentId,
|
|
2533
|
+
reference,
|
|
2534
|
+
reason: "outside_workspace",
|
|
2535
|
+
});
|
|
2536
|
+
}
|
|
2537
|
+
}
|
|
2538
|
+
catch {
|
|
2539
|
+
missingPromptAssets.push({
|
|
2540
|
+
agentId,
|
|
2541
|
+
reference,
|
|
2542
|
+
reason: "not_found",
|
|
2543
|
+
});
|
|
2544
|
+
}
|
|
2545
|
+
}
|
|
2546
|
+
}
|
|
2547
|
+
}
|
|
2548
|
+
}
|
|
2549
|
+
const agents = Object.entries(spec.agents).map(([agentId, config]) => ({
|
|
2550
|
+
agentId,
|
|
2551
|
+
provider: config.provider,
|
|
2552
|
+
model: config.model,
|
|
2553
|
+
status: "configured",
|
|
2554
|
+
}));
|
|
2555
|
+
return {
|
|
2556
|
+
ready: missingPromptAssets.length === 0 && missingSkills.length === 0,
|
|
2557
|
+
errors: [],
|
|
2558
|
+
missingPromptAssets,
|
|
2559
|
+
missingSkills,
|
|
2560
|
+
agents,
|
|
2561
|
+
};
|
|
2562
|
+
}
|
|
2563
|
+
export async function workflowReadinessWithBackend(rootPath, spec, backend, skillCwd = rootPath) {
|
|
2564
|
+
const skillsByAgent = new Map();
|
|
2565
|
+
const errors = [];
|
|
2566
|
+
const agentsWithSkills = new Set();
|
|
2567
|
+
for (const [agentId, config] of Object.entries(spec.agents)) {
|
|
2568
|
+
if (effectiveAgentSkills(spec, agentId, config).length > 0)
|
|
2569
|
+
agentsWithSkills.add(agentId);
|
|
2570
|
+
}
|
|
2571
|
+
for (const agentId of agentsWithSkills) {
|
|
2572
|
+
const config = spec.agents[agentId];
|
|
2573
|
+
if (!config) {
|
|
2574
|
+
skillsByAgent.set(agentId, new Set());
|
|
2575
|
+
continue;
|
|
2576
|
+
}
|
|
2577
|
+
if (!backend.listSkills) {
|
|
2578
|
+
errors.push(agentId +
|
|
2579
|
+
" (" +
|
|
2580
|
+
config.provider +
|
|
2581
|
+
") Skill 能力不可用:当前 AgentRuntime 未提供 Provider Skill 列表");
|
|
2582
|
+
skillsByAgent.set(agentId, new Set());
|
|
2583
|
+
continue;
|
|
2584
|
+
}
|
|
2585
|
+
try {
|
|
2586
|
+
const skills = await backend.listSkills({
|
|
2587
|
+
provider: config.provider,
|
|
2588
|
+
model: config.model,
|
|
2589
|
+
// Prompt assets live inside the Workflow package, while Provider
|
|
2590
|
+
// skills are discovered from the Workspace (and its provider-specific
|
|
2591
|
+
// .<provider>/ directories). These roots are intentionally distinct.
|
|
2592
|
+
cwd: skillCwd,
|
|
2593
|
+
...(config.mode ? { mode: config.mode } : {}),
|
|
2594
|
+
...(config.thinking ? { thinking: config.thinking } : {}),
|
|
2595
|
+
});
|
|
2596
|
+
skillsByAgent.set(agentId, new Set(skills.map((skill) => skill.name)));
|
|
2597
|
+
}
|
|
2598
|
+
catch (error) {
|
|
2599
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2600
|
+
errors.push(`${agentId} (${config.provider}) Skill 能力不可用:${message}`);
|
|
2601
|
+
skillsByAgent.set(agentId, new Set());
|
|
2602
|
+
}
|
|
2603
|
+
}
|
|
2604
|
+
const readiness = workflowReadiness(rootPath, spec);
|
|
2605
|
+
const missingSkills = Object.entries(spec.agents).flatMap(([agentId, config]) => {
|
|
2606
|
+
const available = skillsByAgent.get(agentId);
|
|
2607
|
+
return effectiveAgentSkills(spec, agentId, config)
|
|
2608
|
+
.filter((skill) => available === undefined || !available.has(skill))
|
|
2609
|
+
.map((skill) => ({ agentId, skill }));
|
|
2610
|
+
});
|
|
2611
|
+
return {
|
|
2612
|
+
...readiness,
|
|
2613
|
+
errors: [...readiness.errors, ...errors],
|
|
2614
|
+
missingSkills,
|
|
2615
|
+
ready: readiness.ready && errors.length === 0 && missingSkills.length === 0,
|
|
2616
|
+
};
|
|
2617
|
+
}
|
|
2618
|
+
function isSafeWorkspaceRelativePath(value) {
|
|
2619
|
+
const normalized = value.replaceAll("\\", "/");
|
|
2620
|
+
if (!normalized ||
|
|
2621
|
+
normalized.startsWith("/") ||
|
|
2622
|
+
/^[A-Za-z]:\//.test(normalized))
|
|
2623
|
+
return false;
|
|
2624
|
+
return normalized
|
|
2625
|
+
.split("/")
|
|
2626
|
+
.every((segment) => segment.length > 0 && segment !== "." && segment !== "..");
|
|
2627
|
+
}
|
|
2628
|
+
/** Workspace-local session → selected Workflow mapping. This lives outside
|
|
2629
|
+
* `workflows/` so it cannot be mistaken for a Workflow document by list or
|
|
2630
|
+
* versioning APIs. */
|
|
2631
|
+
function agentWorkflowBindingsPath(rootPath) {
|
|
2632
|
+
return path.join(rootPath, ".agentstorm", "agent-workflows.json");
|
|
2633
|
+
}
|
|
2634
|
+
function readLegacyAgentWorkflowBindings(rootPath) {
|
|
2635
|
+
const bindingsPath = agentWorkflowBindingsPath(rootPath);
|
|
2636
|
+
if (!fs.existsSync(bindingsPath))
|
|
2637
|
+
return {};
|
|
2638
|
+
try {
|
|
2639
|
+
const parsed = JSON.parse(fs.readFileSync(bindingsPath, "utf8"));
|
|
2640
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
2641
|
+
return {};
|
|
2642
|
+
return Object.fromEntries(Object.entries(parsed).filter((entry) => typeof entry[1] === "string" && isSafeWorkflowId(entry[1])));
|
|
2643
|
+
}
|
|
2644
|
+
catch {
|
|
2645
|
+
return {};
|
|
2646
|
+
}
|
|
2647
|
+
}
|
|
2648
|
+
function sessionPathSegment(agentId) {
|
|
2649
|
+
return encodeURIComponent(agentId).replace(/[.']/g, (char) => char === "." ? "%2E" : "%27");
|
|
2650
|
+
}
|
|
2651
|
+
function sessionWorkflowFiles(rootPath) {
|
|
2652
|
+
const sessionsRoot = path.join(rootPath, ".agentstorm", "sessions");
|
|
2653
|
+
if (!fs.existsSync(sessionsRoot))
|
|
2654
|
+
return [];
|
|
2655
|
+
return fs
|
|
2656
|
+
.readdirSync(sessionsRoot, { withFileTypes: true })
|
|
2657
|
+
.filter((entry) => entry.isDirectory())
|
|
2658
|
+
.map((entry) => path.join(sessionsRoot, entry.name, "session.json"))
|
|
2659
|
+
.filter((file) => fs.existsSync(file));
|
|
2660
|
+
}
|
|
2661
|
+
function readSessionRecord(file) {
|
|
2662
|
+
try {
|
|
2663
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
2664
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
2665
|
+
? parsed
|
|
2666
|
+
: null;
|
|
2667
|
+
}
|
|
2668
|
+
catch {
|
|
2669
|
+
return null;
|
|
2670
|
+
}
|
|
2671
|
+
}
|
|
2672
|
+
function readSessionWorkflowBindings(rootPath) {
|
|
2673
|
+
const files = sessionWorkflowFiles(rootPath);
|
|
2674
|
+
if (files.length === 0)
|
|
2675
|
+
return null;
|
|
2676
|
+
const bindings = {};
|
|
2677
|
+
for (const file of files) {
|
|
2678
|
+
const record = readSessionRecord(file);
|
|
2679
|
+
if (record &&
|
|
2680
|
+
typeof record.mainAgentId === "string" &&
|
|
2681
|
+
typeof record.workflowId === "string" &&
|
|
2682
|
+
isSafeWorkflowId(record.workflowId)) {
|
|
2683
|
+
bindings[record.mainAgentId] = record.workflowId;
|
|
2684
|
+
}
|
|
2685
|
+
}
|
|
2686
|
+
return bindings;
|
|
2687
|
+
}
|
|
2688
|
+
function readAgentWorkflowBindings(rootPath) {
|
|
2689
|
+
const sessionBindings = readSessionWorkflowBindings(rootPath);
|
|
2690
|
+
if (sessionBindings !== null)
|
|
2691
|
+
return sessionBindings;
|
|
2692
|
+
const bindingsPath = agentWorkflowBindingsPath(rootPath);
|
|
2693
|
+
if (!fs.existsSync(bindingsPath))
|
|
2694
|
+
return {};
|
|
2695
|
+
try {
|
|
2696
|
+
const parsed = JSON.parse(fs.readFileSync(bindingsPath, "utf8"));
|
|
2697
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
2698
|
+
return {};
|
|
2699
|
+
const bindings = {};
|
|
2700
|
+
for (const [agentId, workflowId] of Object.entries(parsed)) {
|
|
2701
|
+
if (typeof workflowId === "string" && isSafeWorkflowId(workflowId)) {
|
|
2702
|
+
bindings[agentId] = workflowId;
|
|
2703
|
+
}
|
|
2704
|
+
}
|
|
2705
|
+
return bindings;
|
|
2706
|
+
}
|
|
2707
|
+
catch {
|
|
2708
|
+
// A bad local preference must not make stored Workflows inaccessible.
|
|
2709
|
+
return {};
|
|
2710
|
+
}
|
|
2711
|
+
}
|
|
2712
|
+
function writeAgentWorkflowBinding(rootPath, agentId, workflowId) {
|
|
2713
|
+
const sessionsRoot = path.join(rootPath, ".agentstorm", "sessions");
|
|
2714
|
+
fs.mkdirSync(path.join(sessionsRoot, sessionPathSegment(agentId)), {
|
|
2715
|
+
recursive: true,
|
|
2716
|
+
});
|
|
2717
|
+
const file = path.join(sessionsRoot, sessionPathSegment(agentId), "session.json");
|
|
2718
|
+
let current = {};
|
|
2719
|
+
try {
|
|
2720
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
2721
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
|
|
2722
|
+
current = parsed;
|
|
2723
|
+
}
|
|
2724
|
+
catch {
|
|
2725
|
+
/* first binding */
|
|
2726
|
+
}
|
|
2727
|
+
writeSessionJsonAtomic(file, {
|
|
2728
|
+
...current,
|
|
2729
|
+
sessionId: typeof current.sessionId === "string" ? current.sessionId : agentId,
|
|
2730
|
+
mainAgentId: agentId,
|
|
2731
|
+
workflowId,
|
|
2732
|
+
updatedAt: nowBeijing(),
|
|
2733
|
+
});
|
|
2734
|
+
}
|
|
2735
|
+
function clearAgentWorkflowBindings(rootPath, workflowId) {
|
|
2736
|
+
const sessionFiles = sessionWorkflowFiles(rootPath);
|
|
2737
|
+
if (sessionFiles.length > 0) {
|
|
2738
|
+
const cleared = [];
|
|
2739
|
+
for (const file of sessionFiles) {
|
|
2740
|
+
const record = readSessionRecord(file);
|
|
2741
|
+
if (!record || record.workflowId !== workflowId)
|
|
2742
|
+
continue;
|
|
2743
|
+
delete record.workflowId;
|
|
2744
|
+
record.updatedAt = nowBeijing();
|
|
2745
|
+
writeSessionJsonAtomic(file, record);
|
|
2746
|
+
if (typeof record.mainAgentId === "string")
|
|
2747
|
+
cleared.push(record.mainAgentId);
|
|
2748
|
+
}
|
|
2749
|
+
return cleared;
|
|
2750
|
+
}
|
|
2751
|
+
const bindingsPath = agentWorkflowBindingsPath(rootPath);
|
|
2752
|
+
const current = readAgentWorkflowBindings(rootPath);
|
|
2753
|
+
const clearedAgentIds = Object.entries(current)
|
|
2754
|
+
.filter(([, boundWorkflowId]) => boundWorkflowId === workflowId)
|
|
2755
|
+
.map(([agentId]) => agentId);
|
|
2756
|
+
if (clearedAgentIds.length === 0)
|
|
2757
|
+
return [];
|
|
2758
|
+
const next = Object.fromEntries(Object.entries(current).filter(([, boundWorkflowId]) => boundWorkflowId !== workflowId));
|
|
2759
|
+
if (Object.keys(next).length === 0) {
|
|
2760
|
+
fs.rmSync(bindingsPath, { force: true });
|
|
2761
|
+
return clearedAgentIds;
|
|
2762
|
+
}
|
|
2763
|
+
const tmp = `${bindingsPath}.${process.pid}.${Date.now()}.tmp`;
|
|
2764
|
+
fs.writeFileSync(tmp, JSON.stringify(next, null, 2), "utf8");
|
|
2765
|
+
fs.renameSync(tmp, bindingsPath);
|
|
2766
|
+
return clearedAgentIds;
|
|
2767
|
+
}
|
|
2768
|
+
function renameAgentWorkflowBindings(rootPath, oldWorkflowId, newWorkflowId) {
|
|
2769
|
+
const sessionFiles = sessionWorkflowFiles(rootPath);
|
|
2770
|
+
if (sessionFiles.length > 0) {
|
|
2771
|
+
for (const file of sessionFiles) {
|
|
2772
|
+
const record = readSessionRecord(file);
|
|
2773
|
+
if (!record || record.workflowId !== oldWorkflowId)
|
|
2774
|
+
continue;
|
|
2775
|
+
record.workflowId = newWorkflowId;
|
|
2776
|
+
record.updatedAt = nowBeijing();
|
|
2777
|
+
writeSessionJsonAtomic(file, record);
|
|
2778
|
+
}
|
|
2779
|
+
return;
|
|
2780
|
+
}
|
|
2781
|
+
const bindingsPath = agentWorkflowBindingsPath(rootPath);
|
|
2782
|
+
const current = readAgentWorkflowBindings(rootPath);
|
|
2783
|
+
let changed = false;
|
|
2784
|
+
const next = Object.fromEntries(Object.entries(current).map(([agentId, workflowId]) => {
|
|
2785
|
+
if (workflowId !== oldWorkflowId)
|
|
2786
|
+
return [agentId, workflowId];
|
|
2787
|
+
changed = true;
|
|
2788
|
+
return [agentId, newWorkflowId];
|
|
2789
|
+
}));
|
|
2790
|
+
if (!changed)
|
|
2791
|
+
return;
|
|
2792
|
+
const tmp = `${bindingsPath}.${process.pid}.${Date.now()}.tmp`;
|
|
2793
|
+
fs.writeFileSync(tmp, JSON.stringify(next, null, 2), "utf8");
|
|
2794
|
+
fs.renameSync(tmp, bindingsPath);
|
|
2795
|
+
}
|
|
2796
|
+
function workflowVersionNumbers(files) {
|
|
2797
|
+
const dir = files.versions;
|
|
2798
|
+
if (!fs.existsSync(dir))
|
|
2799
|
+
return [];
|
|
2800
|
+
return fs
|
|
2801
|
+
.readdirSync(dir)
|
|
2802
|
+
.flatMap((file) => {
|
|
2803
|
+
const match = /^(\d+)\.json$/.exec(file);
|
|
2804
|
+
return match ? [Number(match[1])] : [];
|
|
2805
|
+
})
|
|
2806
|
+
.filter((version) => Number.isSafeInteger(version) && version > 0)
|
|
2807
|
+
.sort((a, b) => a - b);
|
|
2808
|
+
}
|
|
2809
|
+
function readWorkflowVersion(files, version) {
|
|
2810
|
+
if (!Number.isSafeInteger(version) || version < 1)
|
|
2811
|
+
return null;
|
|
2812
|
+
const fp = path.join(files.versions, `${version}.json`);
|
|
2813
|
+
return fs.existsSync(fp) ? fs.readFileSync(fp, "utf8") : null;
|
|
2814
|
+
}
|
|
2815
|
+
function listWorkflowVersions(files, currentRaw) {
|
|
2816
|
+
const versions = workflowVersionNumbers(files).flatMap((version) => {
|
|
2817
|
+
const raw = readWorkflowVersion(files, version);
|
|
2818
|
+
if (!raw)
|
|
2819
|
+
return [];
|
|
2820
|
+
const fp = path.join(files.versions, `${version}.json`);
|
|
2821
|
+
return [
|
|
2822
|
+
{
|
|
2823
|
+
version,
|
|
2824
|
+
spec: JSON.parse(raw),
|
|
2825
|
+
etag: etagOf(raw),
|
|
2826
|
+
updatedAt: toBeijingIso(fs.statSync(fp).mtime),
|
|
2827
|
+
},
|
|
2828
|
+
];
|
|
2829
|
+
});
|
|
2830
|
+
// Workflows written before versioning remains readable and restorable as a
|
|
2831
|
+
// synthetic v0 until their next save materializes a durable snapshot.
|
|
2832
|
+
if (versions.length === 0) {
|
|
2833
|
+
return [
|
|
2834
|
+
{
|
|
2835
|
+
version: 0,
|
|
2836
|
+
spec: JSON.parse(currentRaw),
|
|
2837
|
+
etag: etagOf(currentRaw),
|
|
2838
|
+
updatedAt: nowBeijing(),
|
|
2839
|
+
},
|
|
2840
|
+
];
|
|
2841
|
+
}
|
|
2842
|
+
return versions;
|
|
2843
|
+
}
|
|
2844
|
+
//# sourceMappingURL=pipeline-service.js.map
|