@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.
@@ -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