@agimon-ai/po-workflow 0.0.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +29 -0
- package/LICENSE.web +29 -0
- package/README.md +146 -0
- package/dist/apiContracts.cjs +361 -0
- package/dist/apiContracts.cjs.map +1 -0
- package/dist/apiContracts.d.mts +53 -0
- package/dist/apiContracts.d.mts.map +1 -0
- package/dist/apiContracts.mjs +356 -0
- package/dist/apiContracts.mjs.map +1 -0
- package/dist/extensions/server.cjs +4289 -0
- package/dist/extensions/server.cjs.map +1 -0
- package/dist/extensions/server.d.mts +5 -0
- package/dist/extensions/server.d.mts.map +1 -0
- package/dist/extensions/server.mjs +4259 -0
- package/dist/extensions/server.mjs.map +1 -0
- package/dist/extensions/web.mjs +3788 -0
- package/dist/extensions/web.mjs.map +1 -0
- package/dist/index.cjs +4 -0
- package/dist/index.d.mts +59 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +2 -0
- package/dist/workflowLaunchCommand-C69Nn3k5.cjs +139 -0
- package/dist/workflowLaunchCommand-C69Nn3k5.cjs.map +1 -0
- package/dist/workflowLaunchCommand-GGqimmTC.mjs +122 -0
- package/dist/workflowLaunchCommand-GGqimmTC.mjs.map +1 -0
- package/llms.txt +24 -0
- package/package.json +133 -0
- package/skills/workflow-recovery/SKILL.md +85 -0
- package/src/prompts/po-author-workflow/SKILL.md +64 -0
- package/src/prompts/po-use-workflow/SKILL.md +21 -0
- package/src/types/webWorkflowTerminal.ts +114 -0
- package/src/types/webWorkflows.ts +208 -0
|
@@ -0,0 +1,4289 @@
|
|
|
1
|
+
Object.defineProperties(exports, {
|
|
2
|
+
__esModule: { value: true },
|
|
3
|
+
[Symbol.toStringTag]: { value: "Module" }
|
|
4
|
+
});
|
|
5
|
+
//#region \0rolldown/runtime.js
|
|
6
|
+
var __create = Object.create;
|
|
7
|
+
var __defProp = Object.defineProperty;
|
|
8
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
9
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
10
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
11
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
|
|
14
|
+
key = keys[i];
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
|
|
16
|
+
get: ((k) => from[k]).bind(null, key),
|
|
17
|
+
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
return to;
|
|
21
|
+
};
|
|
22
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule || !__hasOwnProp.call(mod, "default") ? __defProp(target, "default", {
|
|
23
|
+
value: mod,
|
|
24
|
+
enumerable: true
|
|
25
|
+
}) : target, mod));
|
|
26
|
+
//#endregion
|
|
27
|
+
const require_workflowLaunchCommand = require("../workflowLaunchCommand-C69Nn3k5.cjs");
|
|
28
|
+
let _agimon_ai_po_core_serverFacet = require("@agimon-ai/po-core/serverFacet");
|
|
29
|
+
let _agimon_ai_po_core_extensionFile = require("@agimon-ai/po-core/extensionFile");
|
|
30
|
+
let node_crypto = require("node:crypto");
|
|
31
|
+
let node_fs_promises = require("node:fs/promises");
|
|
32
|
+
let node_path = require("node:path");
|
|
33
|
+
node_path = __toESM(node_path, 1);
|
|
34
|
+
let _agimon_ai_po_core_backgroundWork = require("@agimon-ai/po-core/backgroundWork");
|
|
35
|
+
let _agimon_ai_po_core_childProcess = require("@agimon-ai/po-core/childProcess");
|
|
36
|
+
let _agimon_ai_po_core_narration = require("@agimon-ai/po-core/narration");
|
|
37
|
+
let _agimon_ai_po_core_notification = require("@agimon-ai/po-core/notification");
|
|
38
|
+
let _agimon_ai_po_core_readiness = require("@agimon-ai/po-core/readiness");
|
|
39
|
+
let _agimon_ai_po_minor_mode = require("@agimon-ai/po-minor-mode");
|
|
40
|
+
let _agimon_ai_po_telemetry = require("@agimon-ai/po-telemetry");
|
|
41
|
+
let _agimon_ai_workflow_mcp = require("@agimon-ai/workflow-mcp");
|
|
42
|
+
let zod = require("zod");
|
|
43
|
+
let node_fs = require("node:fs");
|
|
44
|
+
node_fs = __toESM(node_fs, 1);
|
|
45
|
+
let _agimon_ai_po_core_hubChannel = require("@agimon-ai/po-core/hubChannel");
|
|
46
|
+
let node_child_process = require("node:child_process");
|
|
47
|
+
let node_stream = require("node:stream");
|
|
48
|
+
let _agimon_ai_po_core_packageApi = require("@agimon-ai/po-core/packageApi");
|
|
49
|
+
let hono = require("hono");
|
|
50
|
+
let hono_streaming = require("hono/streaming");
|
|
51
|
+
let _agimon_ai_po_core_web = require("@agimon-ai/po-core/web");
|
|
52
|
+
let node_url = require("node:url");
|
|
53
|
+
let node_os = require("node:os");
|
|
54
|
+
node_os = __toESM(node_os, 1);
|
|
55
|
+
//#region src/constants/workflow.ts
|
|
56
|
+
const LAUNCH_WORKFLOW_TOOL_NAME = "launch_workflow";
|
|
57
|
+
const WORKFLOW_TOOLS_TOOL_NAME = "workflow_tools";
|
|
58
|
+
/**
|
|
59
|
+
* One launch's own id, carried in the run's environment.
|
|
60
|
+
*
|
|
61
|
+
* A launch answers once its run registers; matching on this id, rather than on
|
|
62
|
+
* the workflow and the time, keeps two launches of one workflow apart.
|
|
63
|
+
*/
|
|
64
|
+
const WORKFLOW_LAUNCH_ID_ENV = "PO_WORKFLOW_LAUNCH_ID";
|
|
65
|
+
/**
|
|
66
|
+
* Provenance of the child session a launch creates to own and run one workflow.
|
|
67
|
+
* Not 'workflow', which marks a step's session and keeps it out of the rail.
|
|
68
|
+
*/
|
|
69
|
+
const WORKFLOW_SESSION_PROVENANCE = "workflow-session";
|
|
70
|
+
/**
|
|
71
|
+
* Run env key naming the session that launched a run into its own workflow
|
|
72
|
+
* session. PI_SESSION_ID names the owner; this names the launcher, which lists
|
|
73
|
+
* the run, is told when it ends, and releases the workflow session after success.
|
|
74
|
+
*/
|
|
75
|
+
const WORKFLOW_LAUNCHER_SESSION_ENV = "PO_WORKFLOW_LAUNCHER_SESSION_ID";
|
|
76
|
+
/** Message a workflow session sends its launcher to be released once its run succeeded. */
|
|
77
|
+
const WORKFLOW_SESSION_RELEASE_TYPE = "po-workflow.session-release";
|
|
78
|
+
//#endregion
|
|
79
|
+
//#region src/schemas/runConfig.ts
|
|
80
|
+
/** A YAML list, or one comma-separated string, as `--domains a,b` accepts. */
|
|
81
|
+
const nameList = zod.z.union([zod.z.string(), zod.z.array(zod.z.string())]).transform((value) => (Array.isArray(value) ? value : value.split(",")).map((name) => name.trim()).filter((name) => name.length > 0));
|
|
82
|
+
const singleName = zod.z.string().trim().min(1);
|
|
83
|
+
/**
|
|
84
|
+
* The Po keys of a workflow step's `runConfig`.
|
|
85
|
+
*
|
|
86
|
+
* The engine treats `runConfig` as an opaque map, so this is where a typo such
|
|
87
|
+
* as `majormode` is caught instead of silently running on workspace defaults.
|
|
88
|
+
*/
|
|
89
|
+
const poRunConfigSchema = zod.z.object({
|
|
90
|
+
majorMode: singleName.optional().describe("Major mode the step session runs in."),
|
|
91
|
+
minorModes: nameList.optional().describe("Minor modes activated in the step session."),
|
|
92
|
+
profile: singleName.optional().describe("Profile the step session runs as."),
|
|
93
|
+
domains: nameList.optional().describe("Domains selected for the step session. An empty list selects none."),
|
|
94
|
+
model: singleName.optional().describe("Model selector, provider/id."),
|
|
95
|
+
thinking: singleName.optional().describe("Thinking level for the model."),
|
|
96
|
+
subagentModel: singleName.optional().describe("Preferred Team subagent model, independent of the step model."),
|
|
97
|
+
subagentThinking: singleName.optional().describe("Preferred Team subagent thinking level.")
|
|
98
|
+
}).strict();
|
|
99
|
+
/**
|
|
100
|
+
* The same keys for a templated step, whose `runConfig` is also the data its
|
|
101
|
+
* command template reads: keys Po does not use are left to the template.
|
|
102
|
+
*/
|
|
103
|
+
const poTemplateRunConfigSchema = poRunConfigSchema.strip();
|
|
104
|
+
/** The runConfig keys Po reads itself, the host keys `workflow-mcp doctor` checks steps against. */
|
|
105
|
+
const PO_RUN_CONFIG_KEYS = poRunConfigSchema.keyof().options;
|
|
106
|
+
//#endregion
|
|
107
|
+
//#region src/schemas/workflowPi.ts
|
|
108
|
+
const RUN_SELECTOR_FIELDS = {
|
|
109
|
+
runKey: zod.z.string().min(1).describe("Run key of the workflow run."),
|
|
110
|
+
workspace: zod.z.string().optional().describe("Workspace the run belongs to.")
|
|
111
|
+
};
|
|
112
|
+
zod.z.discriminatedUnion("action", [
|
|
113
|
+
zod.z.object({
|
|
114
|
+
action: zod.z.literal("status"),
|
|
115
|
+
...RUN_SELECTOR_FIELDS
|
|
116
|
+
}).strict(),
|
|
117
|
+
zod.z.object({
|
|
118
|
+
action: zod.z.literal("tail"),
|
|
119
|
+
...RUN_SELECTOR_FIELDS
|
|
120
|
+
}).strict(),
|
|
121
|
+
zod.z.object({
|
|
122
|
+
action: zod.z.literal("recovery-evidence"),
|
|
123
|
+
...RUN_SELECTOR_FIELDS
|
|
124
|
+
}).strict(),
|
|
125
|
+
zod.z.object({
|
|
126
|
+
action: zod.z.literal("follow"),
|
|
127
|
+
...RUN_SELECTOR_FIELDS
|
|
128
|
+
}).strict(),
|
|
129
|
+
zod.z.object({
|
|
130
|
+
action: zod.z.literal("open"),
|
|
131
|
+
...RUN_SELECTOR_FIELDS
|
|
132
|
+
}).strict(),
|
|
133
|
+
zod.z.object({
|
|
134
|
+
action: zod.z.literal("pause"),
|
|
135
|
+
...RUN_SELECTOR_FIELDS,
|
|
136
|
+
reason: zod.z.string().optional()
|
|
137
|
+
}).strict(),
|
|
138
|
+
zod.z.object({
|
|
139
|
+
action: zod.z.literal("resume"),
|
|
140
|
+
...RUN_SELECTOR_FIELDS,
|
|
141
|
+
reason: zod.z.string().optional()
|
|
142
|
+
}).strict(),
|
|
143
|
+
zod.z.object({
|
|
144
|
+
action: zod.z.literal("stop"),
|
|
145
|
+
...RUN_SELECTOR_FIELDS,
|
|
146
|
+
reason: zod.z.string().optional()
|
|
147
|
+
}).strict(),
|
|
148
|
+
zod.z.object({
|
|
149
|
+
action: zod.z.literal("recover"),
|
|
150
|
+
...RUN_SELECTOR_FIELDS,
|
|
151
|
+
dryRun: zod.z.boolean().optional(),
|
|
152
|
+
job: zod.z.string().optional(),
|
|
153
|
+
runner: zod.z.string().optional()
|
|
154
|
+
}).strict()
|
|
155
|
+
]);
|
|
156
|
+
const dispatchInputSchema = zod.z.object({
|
|
157
|
+
description: zod.z.string().optional().describe("Description shown for the workflow input."),
|
|
158
|
+
required: zod.z.boolean().optional().describe("Whether the workflow input must be provided."),
|
|
159
|
+
default: zod.z.string().optional().describe("Default value for the workflow input."),
|
|
160
|
+
type: zod.z.string().optional().describe("Declared workflow input type."),
|
|
161
|
+
options: zod.z.array(zod.z.string()).optional().describe("Allowed values for the workflow input.")
|
|
162
|
+
}).passthrough();
|
|
163
|
+
zod.z.object({ inputs: zod.z.record(zod.z.string(), dispatchInputSchema).optional().describe("Workflow dispatch input definitions.") }).passthrough();
|
|
164
|
+
zod.z.object({
|
|
165
|
+
directory: zod.z.string().min(1).describe("Directory searched for workflow definitions."),
|
|
166
|
+
hasNextPage: zod.z.boolean().describe("Whether another catalog page exists."),
|
|
167
|
+
page: zod.z.number().int().positive().describe("One-based catalog page number."),
|
|
168
|
+
pageSize: zod.z.number().int().positive().describe("Catalog page size."),
|
|
169
|
+
workflows: zod.z.array(zod.z.object({
|
|
170
|
+
path: zod.z.string().min(1).describe("Workflow path relative to the catalog directory."),
|
|
171
|
+
name: zod.z.string().describe("Workflow display name."),
|
|
172
|
+
description: zod.z.string().describe("Workflow description."),
|
|
173
|
+
tags: zod.z.array(zod.z.string()).describe("Workflow tags.")
|
|
174
|
+
}).passthrough()).describe("Workflow definitions on this page.")
|
|
175
|
+
}).passthrough();
|
|
176
|
+
const workflowToolsInputSchema = zod.z.object({
|
|
177
|
+
action: zod.z.enum(["recovery-evidence", "recover"]),
|
|
178
|
+
...RUN_SELECTOR_FIELDS,
|
|
179
|
+
dryRun: zod.z.boolean().optional().describe("recover only: print the replay without running it."),
|
|
180
|
+
runner: zod.z.string().optional().describe("recover only: override the recorded runner.")
|
|
181
|
+
}).strict();
|
|
182
|
+
//#endregion
|
|
183
|
+
//#region src/services/backgroundWork/index.ts
|
|
184
|
+
/**
|
|
185
|
+
* Background-work registration for workflow runs.
|
|
186
|
+
*
|
|
187
|
+
* DESIGN PATTERNS:
|
|
188
|
+
* - Hard dependency binding through Cordis injection
|
|
189
|
+
* - Snapshot pattern: the extension pushes the current run set, the provider
|
|
190
|
+
* only reads it
|
|
191
|
+
*
|
|
192
|
+
* CODING STANDARDS:
|
|
193
|
+
* - Named exports only
|
|
194
|
+
* - Explicit return types on exported functions
|
|
195
|
+
*
|
|
196
|
+
* AVOID:
|
|
197
|
+
* - Process-global symbols or dynamic imports for extension communication
|
|
198
|
+
* - Reporting runs owned by another Pi session
|
|
199
|
+
*/
|
|
200
|
+
const PROVIDER_NAME = "workflow-mcp";
|
|
201
|
+
/**
|
|
202
|
+
* Register a background-work provider for this session's workflow runs.
|
|
203
|
+
*
|
|
204
|
+
* The runtime pins all announcements to the active Pi session and validates
|
|
205
|
+
* each snapshot before it reaches another extension.
|
|
206
|
+
*/
|
|
207
|
+
function registerRunProvider(ctx) {
|
|
208
|
+
let items = [];
|
|
209
|
+
let provider;
|
|
210
|
+
let disposed = false;
|
|
211
|
+
ctx.inject([_agimon_ai_po_core_backgroundWork.DOOM_BACKGROUND_WORK_SERVICE], (serviceContext) => {
|
|
212
|
+
if (disposed) return void 0;
|
|
213
|
+
const service = (0, _agimon_ai_po_core_backgroundWork.readDoomBackgroundWorkService)(serviceContext);
|
|
214
|
+
if (!service) return void 0;
|
|
215
|
+
const registration = service.register({
|
|
216
|
+
provider: PROVIDER_NAME,
|
|
217
|
+
listActiveWork: () => items
|
|
218
|
+
});
|
|
219
|
+
provider = registration;
|
|
220
|
+
return () => {
|
|
221
|
+
registration.dispose();
|
|
222
|
+
if (provider === registration) provider = void 0;
|
|
223
|
+
};
|
|
224
|
+
});
|
|
225
|
+
return {
|
|
226
|
+
update(next) {
|
|
227
|
+
items = next;
|
|
228
|
+
provider?.update();
|
|
229
|
+
},
|
|
230
|
+
dispose() {
|
|
231
|
+
if (disposed) return;
|
|
232
|
+
disposed = true;
|
|
233
|
+
items = [];
|
|
234
|
+
provider?.dispose();
|
|
235
|
+
provider = void 0;
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
//#endregion
|
|
240
|
+
//#region src/services/piToolBridge/index.ts
|
|
241
|
+
/**
|
|
242
|
+
* Environment variable stamped onto every run launched from a Pi session.
|
|
243
|
+
*
|
|
244
|
+
* `RunWorkflowService` forwards `env` to the inner CLI invocation across the
|
|
245
|
+
* `launch-command` delegation boundary and persists it on the run record, so
|
|
246
|
+
* this survives backgrounding and identifies the launching session afterwards.
|
|
247
|
+
*/
|
|
248
|
+
const PI_SESSION_ENV$1 = "PI_SESSION_ID";
|
|
249
|
+
/** Operator override for the per-session launch ceiling. */
|
|
250
|
+
const MAX_CONCURRENT_ENV = "WORKFLOW_MCP_MAX_CONCURRENT";
|
|
251
|
+
const DEFAULT_MAX_CONCURRENT = 5;
|
|
252
|
+
/** Resolve the per-session launch ceiling from the environment. */
|
|
253
|
+
function resolveMaxConcurrent(env = process.env) {
|
|
254
|
+
const parsed = Number(env[MAX_CONCURRENT_ENV]);
|
|
255
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : DEFAULT_MAX_CONCURRENT;
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* True when this session launched the run.
|
|
259
|
+
*
|
|
260
|
+
* Fails closed on a missing session id: an unstamped record (a CLI launch) and
|
|
261
|
+
* a call arriving before the session id is known both match nothing, so no run
|
|
262
|
+
* ever leaks across sessions through an undefined-equals-undefined comparison.
|
|
263
|
+
*/
|
|
264
|
+
function isSessionRun(record, sessionId) {
|
|
265
|
+
return sessionId !== void 0 && record.env?.["PI_SESSION_ID"] === sessionId;
|
|
266
|
+
}
|
|
267
|
+
/** True when this session handed the run to a workflow session of its own: it is told, but does not own it. */
|
|
268
|
+
function isLaunchedRun(record, sessionId) {
|
|
269
|
+
return sessionId !== void 0 && record.env?.["PO_WORKFLOW_LAUNCHER_SESSION_ID"] === sessionId && !isSessionRun(record, sessionId);
|
|
270
|
+
}
|
|
271
|
+
/** The line a launcher's finish notice adds: where the run lived, and where to take it up. */
|
|
272
|
+
function launchedRunHint(record) {
|
|
273
|
+
return `It ran in workflow session ${record.env?.["PI_SESSION_ID"] ?? "unknown"}; troubleshoot and recover it there.`;
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* True when this session owns a run another session handed to it: it is the
|
|
277
|
+
* run's workflow session, so a finished run is posted without starting a turn.
|
|
278
|
+
*/
|
|
279
|
+
function isOwnedDelegatedRun(record, sessionId) {
|
|
280
|
+
const launcher = record.env?.[WORKFLOW_LAUNCHER_SESSION_ENV];
|
|
281
|
+
return isSessionRun(record, sessionId) && typeof launcher === "string" && launcher !== "" && launcher !== sessionId;
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* What the caller is told when the registry, not the launcher, acknowledged a
|
|
285
|
+
* launch.
|
|
286
|
+
*
|
|
287
|
+
* Leads with the run key because it is the handle every other workflow action
|
|
288
|
+
* takes, and it is no longer available any other way: the launcher's own output
|
|
289
|
+
* is not waited for, so nothing else in this result carries it.
|
|
290
|
+
*/
|
|
291
|
+
function launchedRunSummary(record) {
|
|
292
|
+
return [
|
|
293
|
+
`Started ${record.displayName} in workspace ${record.workspace}.`,
|
|
294
|
+
`Run key: ${record.runKey}`,
|
|
295
|
+
"The run is registered and going. This call returned at that point rather than waiting for the launcher process to exit."
|
|
296
|
+
].join("\n");
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* What the caller is told when the launch was handed off but no run has
|
|
300
|
+
* registered inside the acknowledgement budget.
|
|
301
|
+
*
|
|
302
|
+
* Deliberately not an error: the launcher is slow rather than broken often
|
|
303
|
+
* enough that failing here would strand runs that go on to start normally.
|
|
304
|
+
* There is no run key to offer yet, so it points at the surfaces that do not
|
|
305
|
+
* need one.
|
|
306
|
+
*/
|
|
307
|
+
function launchHandoffSummary(workflowPath) {
|
|
308
|
+
return [
|
|
309
|
+
`Launch handed off for ${workflowPath}, but no run has registered yet.`,
|
|
310
|
+
"Nothing was cancelled: the launcher is still starting, and a run that registers later still reports back here when it finishes.",
|
|
311
|
+
"Do not launch it again. Ask the user to check the active workflow list if they need to see it sooner."
|
|
312
|
+
].join("\n");
|
|
313
|
+
}
|
|
314
|
+
new RegExp([
|
|
315
|
+
"\\u001B\\[[0-9;?]*[ -/]*[@-~]",
|
|
316
|
+
"\\u001B\\][^\\u0007\\u001B]*(?:\\u0007|\\u001B\\\\)",
|
|
317
|
+
"\\u001B[@-Z\\\\-_]"
|
|
318
|
+
].join("|"), "g");
|
|
319
|
+
/** Flatten MCP tool content to text for Pi. */
|
|
320
|
+
function toolResultText(result) {
|
|
321
|
+
return result.content.map((part) => part.type === "text" ? part.text : "").filter(Boolean).join("\n").trim();
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Attach a short menu of next steps to a problem.
|
|
325
|
+
*
|
|
326
|
+
* An error with no route forward is where an agent invents one: retrying a
|
|
327
|
+
* capacity limit, relaunching a run that should be recovered, or silently
|
|
328
|
+
* giving up on work the user is waiting for. Naming two or three real options
|
|
329
|
+
* turns an error into a decision, and the decision belongs to the user.
|
|
330
|
+
*/
|
|
331
|
+
function withOptions(problem, options) {
|
|
332
|
+
if (options.length === 0) return problem;
|
|
333
|
+
return [
|
|
334
|
+
problem,
|
|
335
|
+
"",
|
|
336
|
+
"Options, put these to the user rather than picking one yourself:",
|
|
337
|
+
...options.map(bullet)
|
|
338
|
+
].join("\n");
|
|
339
|
+
}
|
|
340
|
+
function bullet(option) {
|
|
341
|
+
return `- ${option}`;
|
|
342
|
+
}
|
|
343
|
+
/** The job and step a run died on, preferring the progress log to the record. */
|
|
344
|
+
function failurePosition(record, jobs) {
|
|
345
|
+
const failedJob = jobs.find((job) => job.status === "failed");
|
|
346
|
+
const failedStep = failedJob?.steps.find((step) => step.status === "failed");
|
|
347
|
+
return {
|
|
348
|
+
job: failedJob?.name ?? record.failedJob,
|
|
349
|
+
step: failedStep?.name,
|
|
350
|
+
reason: failedStep?.reason ?? failedJob?.reason
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Describe a finished run for the agent that launched it.
|
|
355
|
+
*
|
|
356
|
+
* Carries the job identifiers back so a dispatching agent can match the run to
|
|
357
|
+
* the work item it launched it for without another lookup. When the run failed,
|
|
358
|
+
* carries the failing step and a set of options too: the agent is being told
|
|
359
|
+
* about this out of band, with no user question in flight, so without them it
|
|
360
|
+
* has to guess whether to recover, relaunch, or report.
|
|
361
|
+
*/
|
|
362
|
+
function finishedRunSummary(record, jobs = []) {
|
|
363
|
+
const outcome = record.stage === "completed" ? "completed" : `ended in ${record.stage}`;
|
|
364
|
+
const failure = failurePosition(record, jobs);
|
|
365
|
+
const lines = [
|
|
366
|
+
`Workflow run ${record.runKey} in workspace ${record.workspace} ${outcome}.`,
|
|
367
|
+
record.workflowId ? `Workflow: ${record.workflowId}` : void 0,
|
|
368
|
+
failure.job ? `Failed job: ${failure.job}${failure.step ? ` (step: ${failure.step})` : ""}` : void 0,
|
|
369
|
+
record.errorMessage ? `Error: ${record.errorMessage}` : void 0,
|
|
370
|
+
failure.reason && failure.reason !== record.errorMessage ? `Step reported: ${failure.reason}` : void 0
|
|
371
|
+
];
|
|
372
|
+
const jobId = record.env?.AGIFLOW_JOB_ID;
|
|
373
|
+
if (jobId) lines.push(`Agiflow job: ${record.env?.AGIFLOW_JOB_KIND ?? "unknown"} ${jobId}`);
|
|
374
|
+
const summary = lines.filter((line) => Boolean(line)).join("\n");
|
|
375
|
+
if (record.stage === "completed") return summary;
|
|
376
|
+
if (record.outcome === "interrupted") return withOptions(summary, [`workflow_run {"action":"recover","runKey":${JSON.stringify(record.runKey)}}: pick it up where it stopped.`, "Leave it stopped and move on."]);
|
|
377
|
+
const diagnosticText = [record.errorMessage, failure.reason].filter(Boolean).join("\n");
|
|
378
|
+
if (diagnosticText.includes("JOB_ALREADY_CLAIMED")) return withOptions(summary, ["Report contention: another worker owns this job. Do not unlock, release, or retry the claim automatically."]);
|
|
379
|
+
if (diagnosticText.includes("WORKFLOW_NOT_OWNED") || /release failed/i.test(diagnosticText)) return withOptions(summary, ["Inspect the current workflow ownership and ask the user before any release or unlock. Never force another worker’s lock."]);
|
|
380
|
+
if (/running workflow not found/i.test(diagnosticText)) return withOptions(summary, [`workflow_run {"action":"status","runKey":${JSON.stringify(record.runKey)}}: re-check current state before retrying.`, "Report the recorded terminal state when no running owner remains."]);
|
|
381
|
+
return withOptions(summary, [
|
|
382
|
+
`workflow_run {"action":"tail","runKey":${JSON.stringify(record.runKey)}}: read the run's own output first, when the error above does not explain the failure.`,
|
|
383
|
+
`workflow_run {"action":"recover","runKey":${JSON.stringify(record.runKey)}}: resume from the failed job, keeping the work already done. Fits a transient or external cause.`,
|
|
384
|
+
"Report the failure and stop, when it needs a code change or a decision you cannot make."
|
|
385
|
+
]);
|
|
386
|
+
}
|
|
387
|
+
//#endregion
|
|
388
|
+
//#region src/services/workflowExecution/index.ts
|
|
389
|
+
const AGIFLOW_JOB_ID_ENV = "AGIFLOW_JOB_ID";
|
|
390
|
+
const AGIFLOW_JOB_KIND_ENV = "AGIFLOW_JOB_KIND";
|
|
391
|
+
const AGIFLOW_PROJECT_ID_ENV = "AGIFLOW_PROJECT_ID";
|
|
392
|
+
/** How often the registry is asked whether the launch has produced a run. */
|
|
393
|
+
const LAUNCH_ACK_POLL_MS = 500;
|
|
394
|
+
/**
|
|
395
|
+
* How long to wait for that before answering without a run key.
|
|
396
|
+
*
|
|
397
|
+
* Long enough to cover a launcher that is merely slow, short enough that a
|
|
398
|
+
* caller is never left holding a turn open for a launcher that will not answer.
|
|
399
|
+
*/
|
|
400
|
+
const LAUNCH_ACK_TIMEOUT_MS = 15e3;
|
|
401
|
+
function reportProgress(onUpdate, tool, message) {
|
|
402
|
+
onUpdate?.({
|
|
403
|
+
content: [{
|
|
404
|
+
type: "text",
|
|
405
|
+
text: message
|
|
406
|
+
}],
|
|
407
|
+
details: { tool }
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
const launchFailureOptions = [
|
|
411
|
+
"Report the failure to the user with the error above. A launch failure is usually a bad workflow path, a missing launcher, or a workflow whose own pre-conditions refused.",
|
|
412
|
+
"list_workflows: confirm the workflow path exists and its description matches the work, if the path may be wrong.",
|
|
413
|
+
"workflow_run with action status: check whether an earlier attempt is already running before launching again."
|
|
414
|
+
];
|
|
415
|
+
/**
|
|
416
|
+
* An Error, whatever the launch rejected with.
|
|
417
|
+
*
|
|
418
|
+
* A rejection reaches here as `unknown` because it crossed a promise boundary,
|
|
419
|
+
* and rethrowing that as-is hands Pi's tool layer something it cannot read a
|
|
420
|
+
* message or a stack off. The original is kept as the cause rather than
|
|
421
|
+
* flattened into a string.
|
|
422
|
+
*/
|
|
423
|
+
function asError$1(value) {
|
|
424
|
+
return value instanceof Error ? value : new Error(String(value), { cause: value });
|
|
425
|
+
}
|
|
426
|
+
/** Resolves to undefined, so a race against it reads as "nothing settled yet". */
|
|
427
|
+
function sleep(ms) {
|
|
428
|
+
return new Promise((settle) => {
|
|
429
|
+
setTimeout(() => settle(void 0), ms).unref?.();
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Wait for whichever answer arrives first: the launch settling, the run
|
|
434
|
+
* appearing in the registry, or the budget running out.
|
|
435
|
+
*
|
|
436
|
+
* A launch that delegates to a terminal launcher settles when that launcher's
|
|
437
|
+
* whole process chain closes, which on a loaded machine has been measured
|
|
438
|
+
* minutes after the run itself was registered and working. The registry is the
|
|
439
|
+
* earlier and more truthful signal: a run recorded there is a run that started.
|
|
440
|
+
*/
|
|
441
|
+
async function awaitLaunchAck(settled, dependencies, query) {
|
|
442
|
+
const findLaunchedRun = dependencies.findLaunchedRun;
|
|
443
|
+
if (!findLaunchedRun) return {
|
|
444
|
+
kind: "settled",
|
|
445
|
+
outcome: await settled
|
|
446
|
+
};
|
|
447
|
+
return awaitRunAck(settled, () => findLaunchedRun(query), dependencies.launchAckPollMs, dependencies.launchAckTimeoutMs);
|
|
448
|
+
}
|
|
449
|
+
async function awaitRunAck(settled, find, pollMs = LAUNCH_ACK_POLL_MS, timeoutMs = LAUNCH_ACK_TIMEOUT_MS) {
|
|
450
|
+
const deadline = Date.now() + timeoutMs;
|
|
451
|
+
for (;;) {
|
|
452
|
+
const outcome = await Promise.race([settled, sleep(pollMs)]);
|
|
453
|
+
if (outcome) return {
|
|
454
|
+
kind: "settled",
|
|
455
|
+
outcome
|
|
456
|
+
};
|
|
457
|
+
const record = await find().catch(() => void 0);
|
|
458
|
+
if (record) return {
|
|
459
|
+
kind: "record",
|
|
460
|
+
record
|
|
461
|
+
};
|
|
462
|
+
if (Date.now() >= deadline) return { kind: "handoff" };
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Carry a failure that lands after the caller was told the run started.
|
|
467
|
+
*
|
|
468
|
+
* Answering early trades the launcher's exit code for timeliness. Dropping that
|
|
469
|
+
* code entirely is not part of the trade: a launch that fails a minute later
|
|
470
|
+
* would otherwise leave a user waiting on a run that never existed.
|
|
471
|
+
*/
|
|
472
|
+
function reportLateFailure(settled, dependencies, ctx) {
|
|
473
|
+
settled.then((outcome) => {
|
|
474
|
+
if (outcome.kind === "error") {
|
|
475
|
+
dependencies.onLateFailure?.(asError$1(outcome.error), ctx);
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
if (outcome.result.isError) dependencies.onLateFailure?.(new Error(toolResultText(outcome.result) || "Workflow launch failed."), ctx);
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
function createWorkflowLaunchExecutor(dependencies) {
|
|
482
|
+
return { async execute(input, ctx, onUpdate) {
|
|
483
|
+
const sessionId = (0, _agimon_ai_po_core_childProcess.resolveRootSessionId)(ctx.sessionManager.getSessionId(), dependencies.environment);
|
|
484
|
+
dependencies.observeSession?.(sessionId);
|
|
485
|
+
const agiflowJobKind = input.env?.[AGIFLOW_JOB_KIND_ENV]?.trim();
|
|
486
|
+
const agiflowJobId = input.env?.[AGIFLOW_JOB_ID_ENV]?.trim();
|
|
487
|
+
const explicitProjectId = input.env?.[AGIFLOW_PROJECT_ID_ENV]?.trim();
|
|
488
|
+
if (Boolean(agiflowJobKind) !== Boolean(agiflowJobId)) throw new Error("Agiflow workflow launches require AGIFLOW_JOB_KIND and AGIFLOW_JOB_ID together.");
|
|
489
|
+
if (agiflowJobKind && agiflowJobKind !== "task" && agiflowJobKind !== "work-unit") throw new Error("Agiflow workflow launches require AGIFLOW_JOB_KIND to be task or work-unit.");
|
|
490
|
+
if (agiflowJobKind && !explicitProjectId) throw new Error("Agiflow workflow launches require AGIFLOW_PROJECT_ID with the job identity.");
|
|
491
|
+
if (agiflowJobKind && !input.prompt?.trim()) throw new Error("Agiflow workflow launches require a non-empty prompt so user_prompt runs do not wait for terminal input.");
|
|
492
|
+
reportProgress(onUpdate, LAUNCH_WORKFLOW_TOOL_NAME, "Checking workflow capacity...");
|
|
493
|
+
const maxConcurrent = resolveMaxConcurrent(dependencies.environment);
|
|
494
|
+
const active = await dependencies.activeRunCount?.() ?? 0;
|
|
495
|
+
if (active >= maxConcurrent) throw new Error(withOptions(`This session is at capacity: ${active}/${maxConcurrent} workflows running.`, [
|
|
496
|
+
"Wait for a running workflow to finish, then launch again. The extension reports each one as it ends.",
|
|
497
|
+
"workflow_run with action status: show what is running, so the user can judge whether something is stuck.",
|
|
498
|
+
"workflow_run with action stop: free a slot by stopping a run the user no longer wants."
|
|
499
|
+
]));
|
|
500
|
+
if (input.runner) {
|
|
501
|
+
const rejection = dependencies.rejectRunner?.(input.workflowPath, input.runner);
|
|
502
|
+
if (rejection) throw new Error(rejection);
|
|
503
|
+
}
|
|
504
|
+
reportProgress(onUpdate, LAUNCH_WORKFLOW_TOOL_NAME, `Launching workflow ${input.workflowPath}...`);
|
|
505
|
+
const workflowEnv = { ...input.env };
|
|
506
|
+
const dispatcherContextFile = dependencies.environment.AGIFLOW_DISPATCH_CONTEXT_FILE;
|
|
507
|
+
const dispatcherProjectId = dependencies.environment[AGIFLOW_PROJECT_ID_ENV]?.trim();
|
|
508
|
+
if (dispatcherContextFile && agiflowJobKind && dispatcherProjectId && explicitProjectId && dispatcherProjectId !== explicitProjectId) throw new Error(`Agiflow workflow project identity conflicts with the dispatcher context (${explicitProjectId} versus ${dispatcherProjectId}).`);
|
|
509
|
+
if (dispatcherContextFile) for (const key of [
|
|
510
|
+
"AGIFLOW_ORGANIZATION_ID",
|
|
511
|
+
"AGIFLOW_PROJECT_ID",
|
|
512
|
+
"AGIFLOW_DEVICE_ID",
|
|
513
|
+
"BACKEND_AGIFLOW_API_ENDPOINT",
|
|
514
|
+
"AGIFLOW_DISPATCH_SECRET_FILE"
|
|
515
|
+
]) {
|
|
516
|
+
const hostValue = dependencies.environment[key];
|
|
517
|
+
if (hostValue) workflowEnv[key] = hostValue;
|
|
518
|
+
}
|
|
519
|
+
const since = Date.now();
|
|
520
|
+
const launchId = (0, node_crypto.randomUUID)();
|
|
521
|
+
const settled = foldOutcome(dependencies.trackPendingRun(dependencies.runTool.execute({
|
|
522
|
+
...input,
|
|
523
|
+
env: {
|
|
524
|
+
...workflowEnv,
|
|
525
|
+
[PI_SESSION_ENV$1]: sessionId,
|
|
526
|
+
[WORKFLOW_LAUNCH_ID_ENV]: launchId
|
|
527
|
+
}
|
|
528
|
+
})));
|
|
529
|
+
const ack = await awaitLaunchAck(settled, dependencies, {
|
|
530
|
+
launchId,
|
|
531
|
+
sessionId,
|
|
532
|
+
since,
|
|
533
|
+
workflowPath: input.workflowPath
|
|
534
|
+
});
|
|
535
|
+
if (ack.kind === "settled") {
|
|
536
|
+
const outcome = ack.outcome;
|
|
537
|
+
if (outcome.kind === "error") throw asError$1(outcome.error);
|
|
538
|
+
const result = outcome.result;
|
|
539
|
+
if (result.isError) throw new Error(withOptions(toolResultText(result) || "Workflow launch failed.", launchFailureOptions));
|
|
540
|
+
await dependencies.onLaunch?.(ctx);
|
|
541
|
+
return result;
|
|
542
|
+
}
|
|
543
|
+
reportLateFailure(settled, dependencies, ctx);
|
|
544
|
+
await dependencies.onLaunch?.(ctx);
|
|
545
|
+
return { content: [{
|
|
546
|
+
type: "text",
|
|
547
|
+
text: ack.kind === "record" ? launchedRunSummary(ack.record) : launchHandoffSummary(input.workflowPath)
|
|
548
|
+
}] };
|
|
549
|
+
} };
|
|
550
|
+
}
|
|
551
|
+
function foldOutcome(promise) {
|
|
552
|
+
return promise.then((result) => ({
|
|
553
|
+
kind: "value",
|
|
554
|
+
result
|
|
555
|
+
}), (error) => ({
|
|
556
|
+
kind: "error",
|
|
557
|
+
error
|
|
558
|
+
}));
|
|
559
|
+
}
|
|
560
|
+
//#endregion
|
|
561
|
+
//#region src/services/workflowRuns/index.ts
|
|
562
|
+
/**
|
|
563
|
+
* MIRRORS @agimon-ai/workflow-mcp's on-disk registry
|
|
564
|
+
* (src/services/WorkflowRegistryService.ts and WorkflowProgressService.ts in
|
|
565
|
+
* that package). The engine keeps one directory per run under
|
|
566
|
+
* `<home>/workspaces/<workspace>/<stage>/<runKey>/` holding a `run.json`
|
|
567
|
+
* record and an append-only `progress.ndjson` job/step log; the hub only ever
|
|
568
|
+
* reads them. The derivation is duplicated rather than imported because
|
|
569
|
+
* po-web must not depend on the engine at runtime; the unit suite pins
|
|
570
|
+
* this mirror against the package's published types.
|
|
571
|
+
*/
|
|
572
|
+
const DEFAULT_WORKFLOW_HOME_DIR_NAME = ".workflow-mcp";
|
|
573
|
+
const WORKSPACES_DIR_NAME = "workspaces";
|
|
574
|
+
const RUN_RECORD_FILE_NAME = "run.json";
|
|
575
|
+
const PROGRESS_FILE_NAME = "progress.ndjson";
|
|
576
|
+
const WORKFLOW_STAGES = [
|
|
577
|
+
"running",
|
|
578
|
+
"completed",
|
|
579
|
+
"error"
|
|
580
|
+
];
|
|
581
|
+
/**
|
|
582
|
+
* Errored runs stay a day, which is how long recovery has to act on them.
|
|
583
|
+
*
|
|
584
|
+
* Finished runs have no retention of their own: a session keeps every run it
|
|
585
|
+
* launched, grouped by outcome, for as long as the session lives. A run leaves
|
|
586
|
+
* the view when the engine prunes its registry directory, not on a timer here,
|
|
587
|
+
* because "what did this session run today" is a question asked long after the
|
|
588
|
+
* run settled.
|
|
589
|
+
*/
|
|
590
|
+
const WORKFLOW_ERROR_RETENTION_MS = 864e5;
|
|
591
|
+
/** The env key po-workflow stamps on runs it launches; ties a run to a Pi session. */
|
|
592
|
+
const PI_SESSION_ENV = "PI_SESSION_ID";
|
|
593
|
+
const PROGRESS_STATES = /* @__PURE__ */ new Set([
|
|
594
|
+
"running",
|
|
595
|
+
"completed",
|
|
596
|
+
"skipped",
|
|
597
|
+
"failed",
|
|
598
|
+
"pause_requested",
|
|
599
|
+
"paused",
|
|
600
|
+
"resumed"
|
|
601
|
+
]);
|
|
602
|
+
const OUTCOMES = /* @__PURE__ */ new Set([
|
|
603
|
+
"success",
|
|
604
|
+
"skipped",
|
|
605
|
+
"failed",
|
|
606
|
+
"interrupted"
|
|
607
|
+
]);
|
|
608
|
+
const EXECUTION_STATES = /* @__PURE__ */ new Set([
|
|
609
|
+
"running",
|
|
610
|
+
"pause_requested",
|
|
611
|
+
"paused",
|
|
612
|
+
"resume_requested"
|
|
613
|
+
]);
|
|
614
|
+
const STEP_TERMINAL_STATES = /* @__PURE__ */ new Set([
|
|
615
|
+
"completed",
|
|
616
|
+
"skipped",
|
|
617
|
+
"failed"
|
|
618
|
+
]);
|
|
619
|
+
/** The engine records its pre:/post: blocks under these reserved pseudo-job names. */
|
|
620
|
+
const PHASE_JOB_PRE = "pre";
|
|
621
|
+
const PHASE_JOB_POST = "post";
|
|
622
|
+
const ANSI_ESCAPE_PATTERN = /\u001b\[[0-9;]*[A-Za-z]/g;
|
|
623
|
+
/** Where workflow-mcp keeps its registry, mirroring the engine's own resolution. */
|
|
624
|
+
function resolveWorkflowHome(input) {
|
|
625
|
+
return (0, node_path.resolve)(input.envValue !== void 0 && input.envValue !== "" ? input.envValue : (0, node_path.resolve)(input.homeDir, DEFAULT_WORKFLOW_HOME_DIR_NAME));
|
|
626
|
+
}
|
|
627
|
+
function isRecord(value) {
|
|
628
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
629
|
+
}
|
|
630
|
+
function asOptionalString(value) {
|
|
631
|
+
return typeof value === "string" && value !== "" ? value : void 0;
|
|
632
|
+
}
|
|
633
|
+
function asOptionalNumber(value) {
|
|
634
|
+
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
635
|
+
}
|
|
636
|
+
/** Text without its terminal escape codes, as a log file or a reader wants it. */
|
|
637
|
+
function withoutAnsi(text) {
|
|
638
|
+
return text.replace(ANSI_ESCAPE_PATTERN, "");
|
|
639
|
+
}
|
|
640
|
+
function stripAnsi(text) {
|
|
641
|
+
return withoutAnsi(text).trim();
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* Validates one run.json into the wire shape plus scoping facts.
|
|
645
|
+
*
|
|
646
|
+
* Returns undefined for anything unreadable or a foreign format. The record
|
|
647
|
+
* is trusted for its own stage: the registry rewrites it when a run moves
|
|
648
|
+
* between stage directories.
|
|
649
|
+
*/
|
|
650
|
+
function parseWorkflowRunRecord(raw) {
|
|
651
|
+
let parsed;
|
|
652
|
+
try {
|
|
653
|
+
parsed = JSON.parse(raw);
|
|
654
|
+
} catch {
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
if (!isRecord(parsed)) return void 0;
|
|
658
|
+
const runKey = asOptionalString(parsed.runKey);
|
|
659
|
+
const workspace = asOptionalString(parsed.workspace);
|
|
660
|
+
const workflowPath = asOptionalString(parsed.workflowPath);
|
|
661
|
+
const startedAt = asOptionalString(parsed.startedAt);
|
|
662
|
+
const stage = asOptionalString(parsed.stage);
|
|
663
|
+
if (!runKey || !workspace || !workflowPath || !startedAt) return void 0;
|
|
664
|
+
if (stage === void 0 || !WORKFLOW_STAGES.includes(stage)) return void 0;
|
|
665
|
+
const outcome = asOptionalString(parsed.outcome);
|
|
666
|
+
const executionState = asOptionalString(parsed.executionState);
|
|
667
|
+
const rawError = asOptionalString(parsed.errorMessage);
|
|
668
|
+
const env = isRecord(parsed.env) ? parsed.env : void 0;
|
|
669
|
+
const view = {
|
|
670
|
+
...asOptionalString(parsed.runId) === void 0 ? {} : { runId: asOptionalString(parsed.runId) },
|
|
671
|
+
runKey,
|
|
672
|
+
workspace,
|
|
673
|
+
displayName: asOptionalString(parsed.displayName) ?? runKey,
|
|
674
|
+
...asOptionalString(parsed.workflowName) === void 0 ? {} : { workflowName: asOptionalString(parsed.workflowName) },
|
|
675
|
+
workflowPath,
|
|
676
|
+
stage,
|
|
677
|
+
...outcome !== void 0 && OUTCOMES.has(outcome) ? { outcome } : {},
|
|
678
|
+
...executionState !== void 0 && EXECUTION_STATES.has(executionState) ? { executionState } : {},
|
|
679
|
+
...asOptionalString(parsed.prompt) === void 0 ? {} : { prompt: asOptionalString(parsed.prompt) },
|
|
680
|
+
startedAt,
|
|
681
|
+
...asOptionalString(parsed.finishedAt) === void 0 ? {} : { finishedAt: asOptionalString(parsed.finishedAt) },
|
|
682
|
+
...rawError === void 0 ? {} : { errorMessage: stripAnsi(rawError) },
|
|
683
|
+
...asOptionalString(parsed.failedJob) === void 0 ? {} : { failedJob: asOptionalString(parsed.failedJob) },
|
|
684
|
+
...parsed.stale === true ? { stale: true } : {},
|
|
685
|
+
...asOptionalString(parsed.staleReason) === void 0 ? {} : { staleReason: asOptionalString(parsed.staleReason) },
|
|
686
|
+
...asOptionalString(parsed.worktreeBranch) === void 0 ? {} : { worktreeBranch: asOptionalString(parsed.worktreeBranch) },
|
|
687
|
+
jobs: []
|
|
688
|
+
};
|
|
689
|
+
const piSessionId = env === void 0 ? void 0 : asOptionalString(env[PI_SESSION_ENV]);
|
|
690
|
+
const launcherSessionId = env === void 0 ? void 0 : asOptionalString(env[WORKFLOW_LAUNCHER_SESSION_ENV]);
|
|
691
|
+
if (piSessionId !== void 0) view.ownerSessionId = piSessionId;
|
|
692
|
+
if (launcherSessionId !== void 0) view.launcherSessionId = launcherSessionId;
|
|
693
|
+
return {
|
|
694
|
+
view,
|
|
695
|
+
...piSessionId === void 0 ? {} : { piSessionId },
|
|
696
|
+
...launcherSessionId === void 0 ? {} : { launcherSessionId }
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
function parseStepRef(value) {
|
|
700
|
+
if (!isRecord(value)) return void 0;
|
|
701
|
+
const kind = asOptionalString(value.kind);
|
|
702
|
+
const id = asOptionalString(value.id);
|
|
703
|
+
if (!kind || !id) return void 0;
|
|
704
|
+
const label = asOptionalString(value.label);
|
|
705
|
+
return {
|
|
706
|
+
kind,
|
|
707
|
+
id,
|
|
708
|
+
...label === void 0 ? {} : { label }
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
/**
|
|
712
|
+
* Parses the append-only progress log. Malformed lines are skipped rather
|
|
713
|
+
* than failing the file: a reader routinely catches a torn final line
|
|
714
|
+
* mid-append.
|
|
715
|
+
*/
|
|
716
|
+
function parseWorkflowProgress(raw) {
|
|
717
|
+
const events = [];
|
|
718
|
+
for (const line of raw.split("\n")) {
|
|
719
|
+
const trimmed = line.trim();
|
|
720
|
+
if (trimmed === "") continue;
|
|
721
|
+
let parsed;
|
|
722
|
+
try {
|
|
723
|
+
parsed = JSON.parse(trimmed);
|
|
724
|
+
} catch {
|
|
725
|
+
continue;
|
|
726
|
+
}
|
|
727
|
+
if (!isRecord(parsed)) continue;
|
|
728
|
+
const type = parsed.type;
|
|
729
|
+
const status = asOptionalString(parsed.status);
|
|
730
|
+
const job = asOptionalString(parsed.job);
|
|
731
|
+
const at = asOptionalString(parsed.at);
|
|
732
|
+
if (type !== "job" && type !== "step" || !job || !at) continue;
|
|
733
|
+
if (status === void 0 || !PROGRESS_STATES.has(status)) continue;
|
|
734
|
+
const step = asOptionalString(parsed.step);
|
|
735
|
+
const index = asOptionalNumber(parsed.index);
|
|
736
|
+
const total = asOptionalNumber(parsed.total);
|
|
737
|
+
const reason = asOptionalString(parsed.reason);
|
|
738
|
+
const ref = parseStepRef(parsed.ref);
|
|
739
|
+
const group = asOptionalString(parsed.group);
|
|
740
|
+
events.push({
|
|
741
|
+
type,
|
|
742
|
+
status,
|
|
743
|
+
job,
|
|
744
|
+
...step === void 0 ? {} : { step },
|
|
745
|
+
...index === void 0 ? {} : { index },
|
|
746
|
+
...total === void 0 ? {} : { total },
|
|
747
|
+
...reason === void 0 ? {} : { reason },
|
|
748
|
+
...ref === void 0 ? {} : { ref },
|
|
749
|
+
...group === void 0 ? {} : { group },
|
|
750
|
+
at
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
return events;
|
|
754
|
+
}
|
|
755
|
+
function jobPhase(name) {
|
|
756
|
+
if (name === PHASE_JOB_PRE) return PHASE_JOB_PRE;
|
|
757
|
+
if (name === PHASE_JOB_POST) return PHASE_JOB_POST;
|
|
758
|
+
return "job";
|
|
759
|
+
}
|
|
760
|
+
/** Names match the engine's progress log, including unnamed parallel children. */
|
|
761
|
+
function plannedSteps(steps = []) {
|
|
762
|
+
return steps.flatMap((step, index) => {
|
|
763
|
+
const name = step.name ?? `step ${index + 1}`;
|
|
764
|
+
return step.parallel === void 0 ? [{
|
|
765
|
+
name,
|
|
766
|
+
status: "pending"
|
|
767
|
+
}] : step.parallel.map((child, childIndex) => ({
|
|
768
|
+
name: child.name ?? `${name} ${childIndex + 1}`,
|
|
769
|
+
status: "pending",
|
|
770
|
+
group: name
|
|
771
|
+
}));
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
/** The whole declared route, before any job or step has emitted progress. */
|
|
775
|
+
function planWorkflowJobs(workflow, order) {
|
|
776
|
+
const jobs = order.map((name) => {
|
|
777
|
+
const job = workflow.jobs[name];
|
|
778
|
+
return {
|
|
779
|
+
name,
|
|
780
|
+
phase: "job",
|
|
781
|
+
status: "pending",
|
|
782
|
+
steps: [
|
|
783
|
+
...plannedSteps(job.preJob?.steps),
|
|
784
|
+
...plannedSteps(job.steps),
|
|
785
|
+
...plannedSteps(job.postJob?.steps)
|
|
786
|
+
]
|
|
787
|
+
};
|
|
788
|
+
});
|
|
789
|
+
if (workflow.pre !== void 0) jobs.unshift({
|
|
790
|
+
name: PHASE_JOB_PRE,
|
|
791
|
+
phase: "pre",
|
|
792
|
+
status: "pending",
|
|
793
|
+
steps: plannedSteps(workflow.pre.steps)
|
|
794
|
+
});
|
|
795
|
+
if (workflow.post !== void 0) jobs.push({
|
|
796
|
+
name: PHASE_JOB_POST,
|
|
797
|
+
phase: "post",
|
|
798
|
+
status: "pending",
|
|
799
|
+
steps: plannedSteps(workflow.post.steps)
|
|
800
|
+
});
|
|
801
|
+
return jobs;
|
|
802
|
+
}
|
|
803
|
+
/** Overlay progress on the plan, retaining recorded jobs and steps absent from the current definition. */
|
|
804
|
+
function foldWorkflowProgress(events, plan = []) {
|
|
805
|
+
const jobs = plan.map((job) => ({
|
|
806
|
+
...job,
|
|
807
|
+
steps: job.steps.map((step) => ({ ...step }))
|
|
808
|
+
}));
|
|
809
|
+
const jobByName = new Map(jobs.map((job) => [job.name, job]));
|
|
810
|
+
for (const event of events) {
|
|
811
|
+
let job = jobByName.get(event.job);
|
|
812
|
+
if (job === void 0) {
|
|
813
|
+
job = {
|
|
814
|
+
name: event.job,
|
|
815
|
+
phase: jobPhase(event.job),
|
|
816
|
+
status: event.status,
|
|
817
|
+
steps: []
|
|
818
|
+
};
|
|
819
|
+
jobByName.set(event.job, job);
|
|
820
|
+
jobs.push(job);
|
|
821
|
+
}
|
|
822
|
+
if (event.type === "job") {
|
|
823
|
+
if (event.status === "running" && (event.index !== void 0 || job.status !== "running")) {
|
|
824
|
+
delete job.reason;
|
|
825
|
+
delete job.endedAt;
|
|
826
|
+
delete job.startedAt;
|
|
827
|
+
}
|
|
828
|
+
job.status = event.status;
|
|
829
|
+
if (event.reason !== void 0) job.reason = event.reason;
|
|
830
|
+
if (event.index !== void 0) job.index = event.index;
|
|
831
|
+
if (event.total !== void 0) job.total = event.total;
|
|
832
|
+
if (event.status === "running" && job.startedAt === void 0) job.startedAt = event.at;
|
|
833
|
+
if (STEP_TERMINAL_STATES.has(event.status)) job.endedAt = event.at;
|
|
834
|
+
continue;
|
|
835
|
+
}
|
|
836
|
+
if (event.step === void 0) continue;
|
|
837
|
+
if (job.status === "pending") job.status = event.status;
|
|
838
|
+
let step = job.steps.find((candidate) => candidate.name === event.step);
|
|
839
|
+
if (step === void 0) {
|
|
840
|
+
step = {
|
|
841
|
+
name: event.step,
|
|
842
|
+
status: event.status
|
|
843
|
+
};
|
|
844
|
+
job.steps.push(step);
|
|
845
|
+
} else if (event.status === "running" && (event.index !== void 0 || step.status !== "running")) {
|
|
846
|
+
delete step.ref;
|
|
847
|
+
delete step.reason;
|
|
848
|
+
delete step.endedAt;
|
|
849
|
+
delete step.startedAt;
|
|
850
|
+
}
|
|
851
|
+
step.status = event.status;
|
|
852
|
+
if (event.reason !== void 0) step.reason = event.reason;
|
|
853
|
+
if (event.ref !== void 0) step.ref = event.ref;
|
|
854
|
+
if (event.group !== void 0) step.group = event.group;
|
|
855
|
+
if (event.status === "running" && step.startedAt === void 0) step.startedAt = event.at;
|
|
856
|
+
if (STEP_TERMINAL_STATES.has(event.status)) step.endedAt = event.at;
|
|
857
|
+
}
|
|
858
|
+
for (const job of jobs) if (job.status === "skipped") {
|
|
859
|
+
for (const step of job.steps) if (step.status === "pending") step.status = "skipped";
|
|
860
|
+
}
|
|
861
|
+
return jobs;
|
|
862
|
+
}
|
|
863
|
+
const ACTIVE_PROGRESS_STATES = /* @__PURE__ */ new Set([
|
|
864
|
+
"running",
|
|
865
|
+
"pause_requested",
|
|
866
|
+
"paused",
|
|
867
|
+
"resumed"
|
|
868
|
+
]);
|
|
869
|
+
/** The job and step a run is on right now, for the NOW breadcrumb. */
|
|
870
|
+
function workflowPosition(jobs) {
|
|
871
|
+
for (let jobIndex = jobs.length - 1; jobIndex >= 0; jobIndex -= 1) {
|
|
872
|
+
const job = jobs[jobIndex];
|
|
873
|
+
if (!ACTIVE_PROGRESS_STATES.has(job.status)) continue;
|
|
874
|
+
for (let stepIndex = job.steps.length - 1; stepIndex >= 0; stepIndex -= 1) {
|
|
875
|
+
const step = job.steps[stepIndex];
|
|
876
|
+
if (ACTIVE_PROGRESS_STATES.has(step.status)) return {
|
|
877
|
+
job: job.name,
|
|
878
|
+
step: step.name,
|
|
879
|
+
...job.index === void 0 ? {} : { index: job.index },
|
|
880
|
+
...job.total === void 0 ? {} : { total: job.total }
|
|
881
|
+
};
|
|
882
|
+
}
|
|
883
|
+
return {
|
|
884
|
+
job: job.name,
|
|
885
|
+
...job.index === void 0 ? {} : { index: job.index },
|
|
886
|
+
...job.total === void 0 ? {} : { total: job.total }
|
|
887
|
+
};
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
/** The finished view: record fields plus the folded job tree and position. */
|
|
891
|
+
function completeWorkflowRunView(view, jobs) {
|
|
892
|
+
const position = view.stage === "running" ? workflowPosition(jobs) : void 0;
|
|
893
|
+
return {
|
|
894
|
+
...view,
|
|
895
|
+
jobs,
|
|
896
|
+
...position === void 0 ? {} : { position }
|
|
897
|
+
};
|
|
898
|
+
}
|
|
899
|
+
/**
|
|
900
|
+
* Whether a run belongs on a session's workflow tab: launched by that Pi
|
|
901
|
+
* session, which is the same env test po-workflow's isSessionRun applies.
|
|
902
|
+
*
|
|
903
|
+
* The registry is one directory under $HOME shared by every repository and
|
|
904
|
+
* every Pi session on the machine, so repository proximity is not ownership.
|
|
905
|
+
* Scoping on it as well let two sessions open in one repo read each other's
|
|
906
|
+
* history, and kept a dead session's runs on the board of every session that
|
|
907
|
+
* replaced it. Fails closed on an unstamped record, which is what a launch
|
|
908
|
+
* from the CLI rather than from a session leaves behind.
|
|
909
|
+
*/
|
|
910
|
+
function runBelongsToSession(run, sessionId) {
|
|
911
|
+
return run.piSessionId !== void 0 && run.piSessionId === sessionId;
|
|
912
|
+
}
|
|
913
|
+
/** Whether the session handed this run to a workflow session of its own: it lists the run but does not own it. */
|
|
914
|
+
function runLaunchedBySession(run, sessionId) {
|
|
915
|
+
return run.launcherSessionId !== void 0 && run.launcherSessionId === sessionId && !runBelongsToSession(run, sessionId);
|
|
916
|
+
}
|
|
917
|
+
function parseTime(value) {
|
|
918
|
+
if (value === void 0) return 0;
|
|
919
|
+
const time = Date.parse(value);
|
|
920
|
+
return Number.isFinite(time) ? time : 0;
|
|
921
|
+
}
|
|
922
|
+
const STAGE_ORDER = {
|
|
923
|
+
running: 0,
|
|
924
|
+
error: 1,
|
|
925
|
+
completed: 2
|
|
926
|
+
};
|
|
927
|
+
/**
|
|
928
|
+
* Whether a run still belongs in the view.
|
|
929
|
+
*
|
|
930
|
+
* Only errored runs age out here, and slowly: everything else a session
|
|
931
|
+
* started stays until the engine forgets the run itself.
|
|
932
|
+
*/
|
|
933
|
+
function withinRetention(run, now) {
|
|
934
|
+
if (run.stage !== "error") return true;
|
|
935
|
+
return now - (parseTime(run.finishedAt) || parseTime(run.startedAt)) < WORKFLOW_ERROR_RETENTION_MS;
|
|
936
|
+
}
|
|
937
|
+
/**
|
|
938
|
+
* When a run last moved, which is what orders it within its group.
|
|
939
|
+
*
|
|
940
|
+
* A running run has only its start; a settled one is ordered by when it
|
|
941
|
+
* settled, because a list kept for the whole session is read as a history and
|
|
942
|
+
* two runs started together can finish an hour apart.
|
|
943
|
+
*/
|
|
944
|
+
function lastMovedAt(run) {
|
|
945
|
+
return run.stage === "running" ? parseTime(run.startedAt) : parseTime(run.finishedAt) || parseTime(run.startedAt);
|
|
946
|
+
}
|
|
947
|
+
/**
|
|
948
|
+
* Running runs first, then errored (recovery acts on them), then finished;
|
|
949
|
+
* newest first within a group, and each group capped on its own.
|
|
950
|
+
*/
|
|
951
|
+
function presentWorkflowRuns(runs, now) {
|
|
952
|
+
const groups = /* @__PURE__ */ new Map();
|
|
953
|
+
for (const run of runs) {
|
|
954
|
+
if (!withinRetention(run, now)) continue;
|
|
955
|
+
const group = groups.get(run.stage);
|
|
956
|
+
if (group) group.push(run);
|
|
957
|
+
else groups.set(run.stage, [run]);
|
|
958
|
+
}
|
|
959
|
+
return [...groups.entries()].sort(([left], [right]) => STAGE_ORDER[left] - STAGE_ORDER[right]).flatMap(([, group]) => group.sort((left, right) => lastMovedAt(right) - lastMovedAt(left)).slice(0, 24));
|
|
960
|
+
}
|
|
961
|
+
//#endregion
|
|
962
|
+
//#region src/services/serverLaunch/index.ts
|
|
963
|
+
/** Beside the run's own files, so the run's artifacts list and its deletion both include it. */
|
|
964
|
+
const RUN_LOG_FILENAME = "engine.log";
|
|
965
|
+
/** Exit codes a run ends with when nothing went wrong: finished, or skipped by its triggers. */
|
|
966
|
+
const SUCCESS_EXIT_CODES = /* @__PURE__ */ new Set([0, 2]);
|
|
967
|
+
/** Lines kept before a run has a directory to write them to. */
|
|
968
|
+
const PENDING_LINE_LIMIT = 500;
|
|
969
|
+
/** How long closing a session waits for its in-process runs to stop. */
|
|
970
|
+
const STOP_TIMEOUT_MS = 1e4;
|
|
971
|
+
const SESSION_CLOSED_REASON = "The session that runs this workflow closed.";
|
|
972
|
+
const RUNNING_STAGE$1 = "running";
|
|
973
|
+
const LAUNCH_REQUESTED_EVENT = "doom_workflow.launch_requested";
|
|
974
|
+
const RUN_STARTED_EVENT = "doom_workflow.run_started";
|
|
975
|
+
const RUN_FINISHED_EVENT = "doom_workflow.run_finished";
|
|
976
|
+
const RUN_FAILED_EVENT = "doom_workflow.run_failed";
|
|
977
|
+
const ENGINE_ERROR_EVENT = "doom_workflow.engine_error";
|
|
978
|
+
const ENGINE_WARNING_EVENT = "doom_workflow.engine_warning";
|
|
979
|
+
const RUN_LOG_FAILED_EVENT = "doom_workflow.run_log_failed";
|
|
980
|
+
const RUN_STOP_FAILED_EVENT = "doom_workflow.run_stop_failed";
|
|
981
|
+
function delay$1(ms) {
|
|
982
|
+
return new Promise((settle) => {
|
|
983
|
+
setTimeout(settle, ms).unref?.();
|
|
984
|
+
});
|
|
985
|
+
}
|
|
986
|
+
function textResult$1(text, isError = false) {
|
|
987
|
+
return {
|
|
988
|
+
content: [{
|
|
989
|
+
type: "text",
|
|
990
|
+
text
|
|
991
|
+
}],
|
|
992
|
+
...isError ? { isError: true } : {}
|
|
993
|
+
};
|
|
994
|
+
}
|
|
995
|
+
/**
|
|
996
|
+
* The engine's log for one run, where a reader can find it.
|
|
997
|
+
*
|
|
998
|
+
* The engine writes why a step failed as a log line. Without a logger, those
|
|
999
|
+
* lines exist only in the output the run returns at its end, so a run that is
|
|
1000
|
+
* still going, or one read later, has nothing to say about its failures. Here
|
|
1001
|
+
* every line goes to the run directory's `engine.log`, and errors and warnings
|
|
1002
|
+
* also go to telemetry, with the line as the exception so the text survives.
|
|
1003
|
+
*/
|
|
1004
|
+
function createRunLog(telemetry) {
|
|
1005
|
+
const pending = [];
|
|
1006
|
+
let file;
|
|
1007
|
+
let writing = Promise.resolve();
|
|
1008
|
+
const reportLogFailure = (error) => telemetry?.recordWarning(RUN_LOG_FAILED_EVENT, error) ?? Promise.resolve();
|
|
1009
|
+
const append = (line) => {
|
|
1010
|
+
if (file === void 0) {
|
|
1011
|
+
pending.push(line);
|
|
1012
|
+
if (pending.length > PENDING_LINE_LIMIT) pending.shift();
|
|
1013
|
+
return;
|
|
1014
|
+
}
|
|
1015
|
+
const target = file;
|
|
1016
|
+
writing = writing.then(() => node_fs.default.promises.appendFile(target, `${line}\n`, "utf8")).catch(reportLogFailure);
|
|
1017
|
+
};
|
|
1018
|
+
return {
|
|
1019
|
+
logger: {
|
|
1020
|
+
info: (message) => append(withoutAnsi(message)),
|
|
1021
|
+
warn: (message) => {
|
|
1022
|
+
const line = withoutAnsi(message);
|
|
1023
|
+
append(line);
|
|
1024
|
+
telemetry?.recordWarning(ENGINE_WARNING_EVENT, new Error(line.trim()));
|
|
1025
|
+
},
|
|
1026
|
+
error: (message) => {
|
|
1027
|
+
const line = withoutAnsi(message);
|
|
1028
|
+
append(line);
|
|
1029
|
+
telemetry?.recordError(ENGINE_ERROR_EVENT, new Error(line.trim()), {}, { includeException: true });
|
|
1030
|
+
}
|
|
1031
|
+
},
|
|
1032
|
+
attach(target) {
|
|
1033
|
+
file = target;
|
|
1034
|
+
const held = pending.splice(0);
|
|
1035
|
+
if (held.length === 0) return;
|
|
1036
|
+
writing = writing.then(() => node_fs.default.promises.appendFile(target, held.map((line) => `${line}\n`).join(""), "utf8")).catch(reportLogFailure);
|
|
1037
|
+
},
|
|
1038
|
+
flushed: () => writing
|
|
1039
|
+
};
|
|
1040
|
+
}
|
|
1041
|
+
/**
|
|
1042
|
+
* Runs workflows inside the Po server for one session.
|
|
1043
|
+
*
|
|
1044
|
+
* It is the same launch the Pi extension runs, through the same executor:
|
|
1045
|
+
* the same Agiflow checks, capacity limit and runner check, and the same
|
|
1046
|
+
* answer once the run registers. What the server adds is where the run
|
|
1047
|
+
* happens, in this process with the host's step executor, and what it keeps:
|
|
1048
|
+
* each run's engine log, telemetry for its start and end, a notice when a run
|
|
1049
|
+
* fails after its launch already answered, and a stop for every run still
|
|
1050
|
+
* going when the session closes.
|
|
1051
|
+
*/
|
|
1052
|
+
function createServerLauncher(dependencies) {
|
|
1053
|
+
const { feature, telemetry } = dependencies;
|
|
1054
|
+
const active = /* @__PURE__ */ new Map();
|
|
1055
|
+
let closing = false;
|
|
1056
|
+
const stopRun = async (run) => {
|
|
1057
|
+
if (run.stopRequested) return;
|
|
1058
|
+
try {
|
|
1059
|
+
const record = run.registration ?? (run.target ? await feature.registry.readRunByKey(run.target.workspace, RUNNING_STAGE$1, run.target.runKey) : void 0);
|
|
1060
|
+
if (!record || !record.runId || run.target && "env" in record && record.env?.["PI_SESSION_ID"] !== dependencies.sessionId) return;
|
|
1061
|
+
await feature.registry.requestStop(record.workspace, record.runKey, SESSION_CLOSED_REASON, record.runId);
|
|
1062
|
+
run.stopRequested = true;
|
|
1063
|
+
} catch (error) {
|
|
1064
|
+
if (error instanceof Error && ("code" in error && error.code === "ENOENT" || error.message.startsWith("Workflow run not found: "))) return;
|
|
1065
|
+
await telemetry?.recordWarning(RUN_STOP_FAILED_EVENT, error);
|
|
1066
|
+
}
|
|
1067
|
+
};
|
|
1068
|
+
const runInProcess = async (input) => {
|
|
1069
|
+
const launchId = input.env?.[WORKFLOW_LAUNCH_ID_ENV];
|
|
1070
|
+
if (launchId === void 0) throw new Error("An in-process launch needs its launch id.");
|
|
1071
|
+
const log = createRunLog(telemetry);
|
|
1072
|
+
const run = { settled: Promise.resolve() };
|
|
1073
|
+
active.set(launchId, run);
|
|
1074
|
+
const runner = input.runner ?? input.cliAgent;
|
|
1075
|
+
const execution = (async () => {
|
|
1076
|
+
try {
|
|
1077
|
+
const result = await feature.createRunService({ logger: log.logger }).run({
|
|
1078
|
+
...input,
|
|
1079
|
+
...runner === void 0 ? {} : { runner },
|
|
1080
|
+
skipLaunch: true,
|
|
1081
|
+
onRegistered: (registration) => {
|
|
1082
|
+
run.registration = registration;
|
|
1083
|
+
if (closing) stopRun(run);
|
|
1084
|
+
log.attach(node_path.default.join(registration.runDir, RUN_LOG_FILENAME));
|
|
1085
|
+
telemetry?.recordEvent(RUN_STARTED_EVENT, { outcome: "started" });
|
|
1086
|
+
dependencies.onRunsChanged?.();
|
|
1087
|
+
}
|
|
1088
|
+
});
|
|
1089
|
+
if (SUCCESS_EXIT_CODES.has(result.exitCode)) {
|
|
1090
|
+
telemetry?.recordEvent(RUN_FINISHED_EVENT, {
|
|
1091
|
+
outcome: "success",
|
|
1092
|
+
exit_code: result.exitCode
|
|
1093
|
+
});
|
|
1094
|
+
return textResult$1(withoutAnsi(result.output) || "Workflow completed.");
|
|
1095
|
+
}
|
|
1096
|
+
const tail = withoutAnsi(result.output).trim().split("\n").slice(-20).join("\n");
|
|
1097
|
+
telemetry?.recordError(RUN_FAILED_EVENT, new Error(tail || `The workflow exited with code ${String(result.exitCode)}.`), { exit_code: result.exitCode }, { includeException: true });
|
|
1098
|
+
return textResult$1(`Workflow failed (exit code ${String(result.exitCode)}):\n\n${withoutAnsi(result.output)}`, true);
|
|
1099
|
+
} catch (error) {
|
|
1100
|
+
telemetry?.recordError(RUN_FAILED_EVENT, error, { outcome: "error" }, { includeException: true });
|
|
1101
|
+
throw error;
|
|
1102
|
+
} finally {
|
|
1103
|
+
await log.flushed();
|
|
1104
|
+
active.delete(launchId);
|
|
1105
|
+
dependencies.onRunsChanged?.();
|
|
1106
|
+
}
|
|
1107
|
+
})();
|
|
1108
|
+
run.settled = execution.then(() => void 0, () => void 0);
|
|
1109
|
+
return execution;
|
|
1110
|
+
};
|
|
1111
|
+
const executor = createWorkflowLaunchExecutor({
|
|
1112
|
+
environment: dependencies.environment,
|
|
1113
|
+
activeRunCount: async () => active.size,
|
|
1114
|
+
...dependencies.rejectRunner === void 0 ? {} : { rejectRunner: dependencies.rejectRunner },
|
|
1115
|
+
runTool: { execute: runInProcess },
|
|
1116
|
+
trackPendingRun: (pending) => pending,
|
|
1117
|
+
findLaunchedRun: async ({ launchId }) => {
|
|
1118
|
+
const registration = active.get(launchId)?.registration;
|
|
1119
|
+
if (registration === void 0) return void 0;
|
|
1120
|
+
return feature.registry.readRunByKey(registration.workspace, RUNNING_STAGE$1, registration.runKey);
|
|
1121
|
+
},
|
|
1122
|
+
onLateFailure: (error) => {
|
|
1123
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
1124
|
+
dependencies.notify(`Workflow launch failed after it was reported started.\n${detail}`, "warning");
|
|
1125
|
+
},
|
|
1126
|
+
onLaunch: () => {
|
|
1127
|
+
telemetry?.recordEvent(LAUNCH_REQUESTED_EVENT, { outcome: "requested" });
|
|
1128
|
+
dependencies.onRunsChanged?.();
|
|
1129
|
+
},
|
|
1130
|
+
...dependencies.launchAckPollMs === void 0 ? {} : { launchAckPollMs: dependencies.launchAckPollMs },
|
|
1131
|
+
...dependencies.launchAckTimeoutMs === void 0 ? {} : { launchAckTimeoutMs: dependencies.launchAckTimeoutMs }
|
|
1132
|
+
});
|
|
1133
|
+
const context = { sessionManager: { getSessionId: () => dependencies.sessionId } };
|
|
1134
|
+
return {
|
|
1135
|
+
launch: (input) => executor.execute(input, context),
|
|
1136
|
+
async recover(target, options) {
|
|
1137
|
+
if (closing) return textResult$1("The workflow session is closing.", true);
|
|
1138
|
+
const tool = dependencies.createRecoverTool({ ownerSessionId: () => dependencies.sessionId });
|
|
1139
|
+
if (options.dryRun) return tool.execute({
|
|
1140
|
+
...target,
|
|
1141
|
+
...options
|
|
1142
|
+
});
|
|
1143
|
+
const key = `recover:${(0, node_crypto.randomUUID)()}`;
|
|
1144
|
+
const run = {
|
|
1145
|
+
target,
|
|
1146
|
+
settled: Promise.resolve()
|
|
1147
|
+
};
|
|
1148
|
+
active.set(key, run);
|
|
1149
|
+
const execution = Promise.resolve().then(() => tool.execute({
|
|
1150
|
+
...target,
|
|
1151
|
+
...options
|
|
1152
|
+
})).finally(() => {
|
|
1153
|
+
active.delete(key);
|
|
1154
|
+
dependencies.onRunsChanged?.();
|
|
1155
|
+
});
|
|
1156
|
+
run.settled = execution.then(() => void 0, () => void 0);
|
|
1157
|
+
const find = async () => {
|
|
1158
|
+
const record = await feature.registry.readRunByKey(target.workspace, RUNNING_STAGE$1, target.runKey);
|
|
1159
|
+
return record?.env?.["PI_SESSION_ID"] === dependencies.sessionId ? record : void 0;
|
|
1160
|
+
};
|
|
1161
|
+
(async () => {
|
|
1162
|
+
while (active.has(key)) {
|
|
1163
|
+
if (closing) await stopRun(run);
|
|
1164
|
+
await Promise.race([run.settled, delay$1(dependencies.launchAckPollMs ?? 500)]);
|
|
1165
|
+
}
|
|
1166
|
+
})();
|
|
1167
|
+
const settled = foldOutcome(execution);
|
|
1168
|
+
const ack = await awaitRunAck(settled, find, dependencies.launchAckPollMs, dependencies.launchAckTimeoutMs);
|
|
1169
|
+
if (ack.kind === "settled") {
|
|
1170
|
+
if (ack.outcome.kind === "error") throw ack.outcome.error instanceof Error ? ack.outcome.error : new Error(String(ack.outcome.error));
|
|
1171
|
+
return ack.outcome.result;
|
|
1172
|
+
}
|
|
1173
|
+
if (ack.kind === "record") dependencies.onRunsChanged?.();
|
|
1174
|
+
settled.then((outcome) => {
|
|
1175
|
+
if (outcome.kind === "error" || outcome.result.isError) {
|
|
1176
|
+
const detail = outcome.kind === "error" ? String(outcome.error) : JSON.stringify(outcome.result.content);
|
|
1177
|
+
dependencies.notify(`Workflow recovery failed after acknowledgement.\n${detail}`, "warning");
|
|
1178
|
+
}
|
|
1179
|
+
});
|
|
1180
|
+
return textResult$1(ack.kind === "record" ? `Recovery started for ${ack.record.displayName} in workspace ${target.workspace}. Run key: ${target.runKey}. Started is not succeeded; check workflow_run status before reporting.` : "Recovery requested, not running yet; nothing was cancelled; do not recover it again.");
|
|
1181
|
+
},
|
|
1182
|
+
activeRunCount: () => active.size,
|
|
1183
|
+
async dispose() {
|
|
1184
|
+
closing = true;
|
|
1185
|
+
const runs = [...active.values()];
|
|
1186
|
+
if (runs.length === 0) return;
|
|
1187
|
+
await Promise.all(runs.map(stopRun));
|
|
1188
|
+
await Promise.race([Promise.all(runs.map((run) => run.settled)), delay$1(dependencies.stopTimeoutMs ?? STOP_TIMEOUT_MS)]);
|
|
1189
|
+
}
|
|
1190
|
+
};
|
|
1191
|
+
}
|
|
1192
|
+
//#endregion
|
|
1193
|
+
//#region src/types/webWorkflows.ts
|
|
1194
|
+
/** A customRun step ran as this child session. */
|
|
1195
|
+
const STEP_SESSION_REF_KIND = "session";
|
|
1196
|
+
/** A command step ran in this terminal pane. */
|
|
1197
|
+
const STEP_PANE_REF_KIND = "pane";
|
|
1198
|
+
const WORKFLOW_RUNS_TYPE = "workflow_runs";
|
|
1199
|
+
const WORKFLOW_CATALOG_TYPE = "workflow_catalog";
|
|
1200
|
+
//#endregion
|
|
1201
|
+
//#region src/services/stepExecutor/index.ts
|
|
1202
|
+
const STEP_DISPLAY_ENV = "WORKFLOW_STEP_DISPLAY";
|
|
1203
|
+
const WORKFLOW_NAME_ENV = "WORKFLOW_NAME";
|
|
1204
|
+
/** The engine's completion signal for terminal agents; a session this host steers never uses it. */
|
|
1205
|
+
const WORKFLOW_STATUS_FILE_ENV = "WORKFLOW_STATUS_FILE";
|
|
1206
|
+
/** How long a busy step session is left to finish a turn it was given elsewhere, such as web guidance. */
|
|
1207
|
+
const BUSY_RETRY_MS = 2e3;
|
|
1208
|
+
/** How long ending a step's session may take before the step stops waiting for it. */
|
|
1209
|
+
const RELEASE_TIMEOUT_MS = 1e4;
|
|
1210
|
+
/** What the runtime says when a turn is already running; the reminder then waits for it. */
|
|
1211
|
+
const BUSY_SESSION_PATTERN = /\bbusy\b|LaneBusy/i;
|
|
1212
|
+
const STEP_SESSION_STARTED_EVENT = "doom_workflow.step_session_started";
|
|
1213
|
+
const STEP_SESSION_FAILED_EVENT = "doom_workflow.step_session_failed";
|
|
1214
|
+
const STEP_REMINDER_EVENT = "doom_workflow.step_reminder";
|
|
1215
|
+
const STEP_WAITING_EVENT = "doom_workflow.step_waiting";
|
|
1216
|
+
const STEP_SESSION_RELEASE_FAILED_EVENT = "doom_workflow.step_session_release_failed";
|
|
1217
|
+
const STEP_PANE_FAILED_EVENT = "doom_workflow.step_pane_failed";
|
|
1218
|
+
const PO_KEYS = new Set(PO_RUN_CONFIG_KEYS);
|
|
1219
|
+
/**
|
|
1220
|
+
* A step's `runConfig`, validated against the keys Po understands.
|
|
1221
|
+
*
|
|
1222
|
+
* A templated step's may also carry keys its command template reads. A key
|
|
1223
|
+
* that is neither, such as `majormode`, fails the step rather than running it
|
|
1224
|
+
* on workspace defaults. An engine that does not say what its template reads
|
|
1225
|
+
* leaves such keys unchecked.
|
|
1226
|
+
*/
|
|
1227
|
+
function readPoRunConfig(runConfig, template) {
|
|
1228
|
+
const parsed = (template === void 0 ? poRunConfigSchema : poTemplateRunConfigSchema).safeParse(runConfig ?? {});
|
|
1229
|
+
if (!parsed.success) {
|
|
1230
|
+
const problems = parsed.error.issues.map((issue) => `${issue.path.join(".") || "runConfig"}: ${issue.message}`);
|
|
1231
|
+
throw new Error(`Invalid runConfig: ${problems.join("; ")}`);
|
|
1232
|
+
}
|
|
1233
|
+
if (template?.reads !== void 0 && typeof runConfig === "object" && runConfig !== null) {
|
|
1234
|
+
const reads = new Set(template.reads);
|
|
1235
|
+
const unknown = Object.keys(runConfig).filter((key) => !PO_KEYS.has(key) && !reads.has(key));
|
|
1236
|
+
if (unknown.length > 0) throw new Error(`Invalid runConfig: ${unknown.join(", ")} is neither a Po setting nor read by the "${template.name}" command.`);
|
|
1237
|
+
}
|
|
1238
|
+
return parsed.data;
|
|
1239
|
+
}
|
|
1240
|
+
/** The selection a step session is pinned to; unset keys keep the workspace defaults. */
|
|
1241
|
+
function sessionSelection(config) {
|
|
1242
|
+
return {
|
|
1243
|
+
...config.majorMode === void 0 ? {} : { majorMode: config.majorMode },
|
|
1244
|
+
...config.minorModes === void 0 ? {} : { minorModes: config.minorModes },
|
|
1245
|
+
...config.profile === void 0 ? {} : { profile: config.profile },
|
|
1246
|
+
...config.domains === void 0 ? {} : { domains: config.domains }
|
|
1247
|
+
};
|
|
1248
|
+
}
|
|
1249
|
+
/**
|
|
1250
|
+
* What a step adds to the host environment.
|
|
1251
|
+
*
|
|
1252
|
+
* The step environment is the whole process environment plus the run's own
|
|
1253
|
+
* keys. Handing all of it to the session would pin a copy of the host's
|
|
1254
|
+
* variables into it; the difference is the part the agent actually needs, such
|
|
1255
|
+
* as WORKFLOW_RUN_DIR for the artifacts its prompt names.
|
|
1256
|
+
*/
|
|
1257
|
+
function stepEnvironment(env, host) {
|
|
1258
|
+
return Object.fromEntries(Object.entries(env).filter(([key, value]) => host[key] !== value));
|
|
1259
|
+
}
|
|
1260
|
+
function stepSessionName(env, stepName) {
|
|
1261
|
+
const display = env[STEP_DISPLAY_ENV] ?? stepName;
|
|
1262
|
+
const workflow = env[WORKFLOW_NAME_ENV];
|
|
1263
|
+
return workflow ? `${workflow}: ${display}` : display;
|
|
1264
|
+
}
|
|
1265
|
+
function asError(error) {
|
|
1266
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
1267
|
+
}
|
|
1268
|
+
function delay(ms) {
|
|
1269
|
+
return new Promise((settle) => {
|
|
1270
|
+
setTimeout(settle, ms).unref?.();
|
|
1271
|
+
});
|
|
1272
|
+
}
|
|
1273
|
+
/** A promise settled by `settle`, so a stop can end a wait that nothing else would. */
|
|
1274
|
+
function signal() {
|
|
1275
|
+
let settle = () => void 0;
|
|
1276
|
+
return {
|
|
1277
|
+
promise: new Promise((resolve) => {
|
|
1278
|
+
settle = resolve;
|
|
1279
|
+
}),
|
|
1280
|
+
settle
|
|
1281
|
+
};
|
|
1282
|
+
}
|
|
1283
|
+
/**
|
|
1284
|
+
* An execution that already failed, with why.
|
|
1285
|
+
*
|
|
1286
|
+
* Returned instead of throwing: the engine reports a thrown start as a spawn
|
|
1287
|
+
* error about shells and permissions, while a failed completion keeps this
|
|
1288
|
+
* message as the step's reason.
|
|
1289
|
+
*/
|
|
1290
|
+
function failedExecution(error) {
|
|
1291
|
+
return {
|
|
1292
|
+
completion: Promise.resolve({ error: asError(error) }),
|
|
1293
|
+
stop: async () => void 0
|
|
1294
|
+
};
|
|
1295
|
+
}
|
|
1296
|
+
/**
|
|
1297
|
+
* Runs workflow steps inside Po instead of a shell.
|
|
1298
|
+
*
|
|
1299
|
+
* A customRun step becomes a child session of the launching session, with the
|
|
1300
|
+
* step's major mode, minor modes, profile, domains and model, so its messages
|
|
1301
|
+
* stream like any other conversation. Command steps go to their own terminal
|
|
1302
|
+
* pane when a launcher is available and fall back to the engine's spawn when
|
|
1303
|
+
* it is not.
|
|
1304
|
+
*/
|
|
1305
|
+
function createStepExecutor(dependencies) {
|
|
1306
|
+
const { sessionService, launchPane, telemetry } = dependencies;
|
|
1307
|
+
const prompt = sessionService.prompt?.bind(sessionService);
|
|
1308
|
+
const readExecutionState = sessionService.readExecutionState?.bind(sessionService);
|
|
1309
|
+
/**
|
|
1310
|
+
* Run a step as a child session of the launching session, with the step's
|
|
1311
|
+
* major mode, minor modes, profile, domains and model.
|
|
1312
|
+
*
|
|
1313
|
+
* The session keeps going until the step has what the workflow needs: each
|
|
1314
|
+
* time the agent settles, the engine's decision gate is asked, and while
|
|
1315
|
+
* the step still lacks a decision (or its declared artifacts) the session is
|
|
1316
|
+
* prompted with the gate's reminder. The gate counts reminders against its
|
|
1317
|
+
* cap, so a stuck agent ends and the engine fails the step.
|
|
1318
|
+
*/
|
|
1319
|
+
async function runSession(request) {
|
|
1320
|
+
if (prompt === void 0) return failedExecution("This host cannot prompt sessions, so this step cannot run here.");
|
|
1321
|
+
if (readExecutionState === void 0) return failedExecution("This host cannot read execution state, so this step cannot run here.");
|
|
1322
|
+
const attributes = {
|
|
1323
|
+
"workflow.step.name": request.stepName,
|
|
1324
|
+
templated: request.template !== void 0
|
|
1325
|
+
};
|
|
1326
|
+
let scope;
|
|
1327
|
+
let name;
|
|
1328
|
+
try {
|
|
1329
|
+
const config = readPoRunConfig(request.runConfig, request.template);
|
|
1330
|
+
if (config.majorMode === void 0) throw new Error("Agent steps require an effective runConfig.majorMode.");
|
|
1331
|
+
name = stepSessionName(request.env, request.stepName);
|
|
1332
|
+
const environment = stepEnvironment(request.env, dependencies.hostEnvironment);
|
|
1333
|
+
delete environment[WORKFLOW_STATUS_FILE_ENV];
|
|
1334
|
+
environment[_agimon_ai_workflow_mcp.WORKFLOW_DECISION_STEERING_ENV] = _agimon_ai_workflow_mcp.WORKFLOW_DECISION_STEERING_HOST;
|
|
1335
|
+
scope = await sessionService.create({
|
|
1336
|
+
cwd: request.cwd,
|
|
1337
|
+
name,
|
|
1338
|
+
parentSessionId: dependencies.parentSessionId,
|
|
1339
|
+
sessionProvenance: _agimon_ai_po_core_hubChannel.WORKFLOW_STEP_SESSION_PROVENANCE,
|
|
1340
|
+
selection: sessionSelection(config),
|
|
1341
|
+
...config.model === void 0 ? {} : { model: config.model },
|
|
1342
|
+
...config.thinking === void 0 ? {} : { thinking: config.thinking },
|
|
1343
|
+
...request.systemPrompt === void 0 ? {} : { appendSystemPrompt: request.systemPrompt },
|
|
1344
|
+
environment
|
|
1345
|
+
});
|
|
1346
|
+
} catch (error) {
|
|
1347
|
+
telemetry?.recordError(STEP_SESSION_FAILED_EVENT, error, attributes, { includeException: true });
|
|
1348
|
+
return failedExecution(error);
|
|
1349
|
+
}
|
|
1350
|
+
telemetry?.recordEvent(STEP_SESSION_STARTED_EVENT, {
|
|
1351
|
+
...attributes,
|
|
1352
|
+
outcome: "started"
|
|
1353
|
+
});
|
|
1354
|
+
const sessionId = scope.sessionId;
|
|
1355
|
+
const stopped = signal();
|
|
1356
|
+
let stopRequested = false;
|
|
1357
|
+
const decided = () => {
|
|
1358
|
+
const path = request.env[_agimon_ai_workflow_mcp.WORKFLOW_DECISION_FILE_ENV];
|
|
1359
|
+
return path !== void 0 && (0, _agimon_ai_workflow_mcp.readStepDecision)(path) !== void 0;
|
|
1360
|
+
};
|
|
1361
|
+
/**
|
|
1362
|
+
* Give the agent the gate's reminder. A session busy with a turn it was
|
|
1363
|
+
* given elsewhere, like web guidance, is left to finish it, then reminded
|
|
1364
|
+
* unless it decided meanwhile. Undefined means the step needs no more turns.
|
|
1365
|
+
*/
|
|
1366
|
+
const remind = async (message) => {
|
|
1367
|
+
for (;;) try {
|
|
1368
|
+
return await prompt(sessionId, message);
|
|
1369
|
+
} catch (error) {
|
|
1370
|
+
if (!BUSY_SESSION_PATTERN.test(asError(error).message)) throw error;
|
|
1371
|
+
telemetry?.recordEvent(STEP_REMINDER_EVENT, {
|
|
1372
|
+
...attributes,
|
|
1373
|
+
outcome: "busy"
|
|
1374
|
+
});
|
|
1375
|
+
await Promise.race([delay(dependencies.busyRetryMs ?? BUSY_RETRY_MS), stopped.promise]);
|
|
1376
|
+
if (stopRequested || decided()) return void 0;
|
|
1377
|
+
}
|
|
1378
|
+
};
|
|
1379
|
+
/**
|
|
1380
|
+
* End the session once the step is over. Released, it stays readable in the
|
|
1381
|
+
* workflow's view without holding a model, tools and extensions in the host.
|
|
1382
|
+
* A host that cannot release it is asked to abort a stopped turn instead.
|
|
1383
|
+
*/
|
|
1384
|
+
const endSession = async () => {
|
|
1385
|
+
const ending = sessionService.release ? sessionService.release(sessionId) : stopRequested ? sessionService.abort?.(sessionId) ?? Promise.resolve() : Promise.resolve();
|
|
1386
|
+
const timeout = delay(dependencies.releaseTimeoutMs ?? RELEASE_TIMEOUT_MS).then(() => {
|
|
1387
|
+
throw new Error(`The step session did not end within ${String(dependencies.releaseTimeoutMs ?? RELEASE_TIMEOUT_MS)}ms.`);
|
|
1388
|
+
});
|
|
1389
|
+
try {
|
|
1390
|
+
await Promise.race([ending, timeout]);
|
|
1391
|
+
} catch (error) {
|
|
1392
|
+
telemetry?.recordWarning(STEP_SESSION_RELEASE_FAILED_EVENT, error, attributes);
|
|
1393
|
+
}
|
|
1394
|
+
};
|
|
1395
|
+
const completion = async () => {
|
|
1396
|
+
let receipt = await prompt(sessionId, request.prompt);
|
|
1397
|
+
while (receipt !== void 0) {
|
|
1398
|
+
await Promise.race([receipt.settled, stopped.promise]);
|
|
1399
|
+
if (stopRequested) return { exitCode: 0 };
|
|
1400
|
+
let waiting = false;
|
|
1401
|
+
for (;;) {
|
|
1402
|
+
const state = await Promise.race([readExecutionState(sessionId, { parentSessionId: dependencies.parentSessionId }), stopped.promise.then(() => void 0)]);
|
|
1403
|
+
if (stopRequested) return { exitCode: 0 };
|
|
1404
|
+
const snapshot = state?.backgroundWork;
|
|
1405
|
+
const items = snapshot?.items.filter(_agimon_ai_po_core_backgroundWork.holdsSettledSession) ?? [];
|
|
1406
|
+
const errors = snapshot?.errors.filter((error) => _agimon_ai_po_core_backgroundWork.BACKGROUND_WORK_HOLDING_PROVIDERS.includes(error.provider)) ?? [];
|
|
1407
|
+
if (state?.isIdle && !state.hasPendingMessages && snapshot !== void 0 && items.length === 0 && errors.length === 0) break;
|
|
1408
|
+
if (!waiting) {
|
|
1409
|
+
waiting = true;
|
|
1410
|
+
telemetry?.recordEvent(STEP_WAITING_EVENT, {
|
|
1411
|
+
...attributes,
|
|
1412
|
+
items: items.map((item) => `${item.provider}:${item.id}`).join(", "),
|
|
1413
|
+
errors: errors.map((error) => `${error.provider}: ${error.message}`).join(", "),
|
|
1414
|
+
coordinator: snapshot === void 0 ? "missing" : "available"
|
|
1415
|
+
});
|
|
1416
|
+
}
|
|
1417
|
+
await Promise.race([delay(dependencies.busyRetryMs ?? BUSY_RETRY_MS), stopped.promise]);
|
|
1418
|
+
if (stopRequested) return { exitCode: 0 };
|
|
1419
|
+
}
|
|
1420
|
+
const gate = await (0, _agimon_ai_workflow_mcp.gateStepDecision)(request.env);
|
|
1421
|
+
if (gate.action === "allow") return { exitCode: 0 };
|
|
1422
|
+
telemetry?.recordEvent(STEP_REMINDER_EVENT, {
|
|
1423
|
+
...attributes,
|
|
1424
|
+
outcome: "sent"
|
|
1425
|
+
});
|
|
1426
|
+
receipt = await remind(gate.message);
|
|
1427
|
+
}
|
|
1428
|
+
return { exitCode: 0 };
|
|
1429
|
+
};
|
|
1430
|
+
return {
|
|
1431
|
+
ref: {
|
|
1432
|
+
kind: STEP_SESSION_REF_KIND,
|
|
1433
|
+
id: sessionId,
|
|
1434
|
+
label: name
|
|
1435
|
+
},
|
|
1436
|
+
completion: completion().catch((error) => ({ error: asError(error) })).then(async (outcome) => {
|
|
1437
|
+
await endSession();
|
|
1438
|
+
return outcome;
|
|
1439
|
+
}),
|
|
1440
|
+
stop: async () => {
|
|
1441
|
+
stopRequested = true;
|
|
1442
|
+
stopped.settle();
|
|
1443
|
+
}
|
|
1444
|
+
};
|
|
1445
|
+
}
|
|
1446
|
+
return {
|
|
1447
|
+
async custom(request) {
|
|
1448
|
+
return runSession({
|
|
1449
|
+
cwd: request.cwd,
|
|
1450
|
+
env: request.env,
|
|
1451
|
+
stepName: request.stepName,
|
|
1452
|
+
runConfig: request.runConfig,
|
|
1453
|
+
prompt: request.customRun.prompt,
|
|
1454
|
+
...request.customRun.systemPrompt === void 0 ? {} : { systemPrompt: request.customRun.systemPrompt }
|
|
1455
|
+
});
|
|
1456
|
+
},
|
|
1457
|
+
async command(request) {
|
|
1458
|
+
const template = request.template;
|
|
1459
|
+
if (template?.inProcess && prompt !== void 0) return runSession({
|
|
1460
|
+
cwd: request.cwd,
|
|
1461
|
+
env: request.env,
|
|
1462
|
+
stepName: request.stepName,
|
|
1463
|
+
runConfig: request.runConfig,
|
|
1464
|
+
template: {
|
|
1465
|
+
name: template.name,
|
|
1466
|
+
...template.reads === void 0 ? {} : { reads: template.reads }
|
|
1467
|
+
},
|
|
1468
|
+
prompt: template.prompt,
|
|
1469
|
+
...template.systemPrompt === void 0 ? {} : { systemPrompt: template.systemPrompt }
|
|
1470
|
+
});
|
|
1471
|
+
if (launchPane === void 0) return void 0;
|
|
1472
|
+
let pane;
|
|
1473
|
+
try {
|
|
1474
|
+
pane = await launchPane({
|
|
1475
|
+
id: dependencies.createId(),
|
|
1476
|
+
command: request.command,
|
|
1477
|
+
cwd: request.cwd,
|
|
1478
|
+
env: request.env
|
|
1479
|
+
});
|
|
1480
|
+
} catch (error) {
|
|
1481
|
+
telemetry?.recordError(STEP_PANE_FAILED_EVENT, error, { "workflow.step.name": request.stepName }, { includeException: true });
|
|
1482
|
+
return failedExecution(error);
|
|
1483
|
+
}
|
|
1484
|
+
if (pane === void 0) return void 0;
|
|
1485
|
+
return {
|
|
1486
|
+
ref: {
|
|
1487
|
+
kind: STEP_PANE_REF_KIND,
|
|
1488
|
+
id: pane.target,
|
|
1489
|
+
label: request.stepName
|
|
1490
|
+
},
|
|
1491
|
+
completion: pane.completion.then((result) => result, (error) => ({ error: asError(error) })),
|
|
1492
|
+
stop: async () => {
|
|
1493
|
+
await pane.stop();
|
|
1494
|
+
}
|
|
1495
|
+
};
|
|
1496
|
+
}
|
|
1497
|
+
};
|
|
1498
|
+
}
|
|
1499
|
+
/** The shell a command step runs in, the same one the engine spawns it with. */
|
|
1500
|
+
const STEP_SHELL = "/bin/zsh";
|
|
1501
|
+
/** Namespace for step pane sockets, apart from whole-run terminal sockets. */
|
|
1502
|
+
const STEP_PANE_SOCKET_SCOPE = "po-workflow-steps";
|
|
1503
|
+
const PANE_LOG_SUFFIX = ".log";
|
|
1504
|
+
const PANE_CAPTURE_COLUMNS = 120;
|
|
1505
|
+
const PANE_CAPTURE_ROWS = 40;
|
|
1506
|
+
/** Where a finished step pane's last screen is kept: beside the socket that served it. */
|
|
1507
|
+
function stepPaneLogPath(socketPath) {
|
|
1508
|
+
return `${socketPath}${PANE_LOG_SUFFIX}`;
|
|
1509
|
+
}
|
|
1510
|
+
/** The pane's current screen, or undefined once there is no pane left to read. */
|
|
1511
|
+
async function lastScreen(service, key) {
|
|
1512
|
+
try {
|
|
1513
|
+
const viewer = await service.attach(key, PANE_CAPTURE_COLUMNS, PANE_CAPTURE_ROWS);
|
|
1514
|
+
const lines = await viewer.snapshot();
|
|
1515
|
+
viewer.detach();
|
|
1516
|
+
return `${lines.join("\n")}\n`;
|
|
1517
|
+
} catch {
|
|
1518
|
+
return;
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
/**
|
|
1522
|
+
* Opens each command step in its own RMUX pane, through workflow-mcp's terminal host.
|
|
1523
|
+
*
|
|
1524
|
+
* The pane gets the step's whole environment, which is what lets an agent's
|
|
1525
|
+
* Stop hook end an interactive step through WORKFLOW_STATUS_FILE. A unix
|
|
1526
|
+
* socket serves the pane to the run terminal in any scope; the socket path is
|
|
1527
|
+
* the pane's target. When the step ends or is stopped, its last screen is kept
|
|
1528
|
+
* beside the socket and the pane is released, whatever fails on the way.
|
|
1529
|
+
*/
|
|
1530
|
+
function createNativeStepPaneLauncher(service = new _agimon_ai_workflow_mcp.NativeTerminalService(), telemetry) {
|
|
1531
|
+
const report = (stage) => (error) => telemetry?.recordWarning(STEP_PANE_FAILED_EVENT, error, { stage }) ?? Promise.resolve();
|
|
1532
|
+
return async (request) => {
|
|
1533
|
+
if (!await service.available()) return void 0;
|
|
1534
|
+
await service.spawn({
|
|
1535
|
+
command: STEP_SHELL,
|
|
1536
|
+
args: ["-c", request.command],
|
|
1537
|
+
cwd: request.cwd,
|
|
1538
|
+
env: { ...request.env },
|
|
1539
|
+
runKey: request.id
|
|
1540
|
+
});
|
|
1541
|
+
const target = (0, _agimon_ai_workflow_mcp.terminalSocketPath)(STEP_PANE_SOCKET_SCOPE, request.id);
|
|
1542
|
+
let server;
|
|
1543
|
+
try {
|
|
1544
|
+
server = await (0, _agimon_ai_workflow_mcp.serveNativeTerminal)({
|
|
1545
|
+
service,
|
|
1546
|
+
runKey: request.id,
|
|
1547
|
+
socketPath: target
|
|
1548
|
+
});
|
|
1549
|
+
} catch (error) {
|
|
1550
|
+
await service.kill(request.id).catch(report("kill"));
|
|
1551
|
+
throw error;
|
|
1552
|
+
}
|
|
1553
|
+
let finishing;
|
|
1554
|
+
const finish = () => finishing ??= (async () => {
|
|
1555
|
+
let screen;
|
|
1556
|
+
try {
|
|
1557
|
+
screen = await lastScreen(service, request.id);
|
|
1558
|
+
if (screen !== void 0) await node_fs.default.promises.writeFile(stepPaneLogPath(target), screen, "utf8");
|
|
1559
|
+
} catch (error) {
|
|
1560
|
+
await report("keep-screen")(error);
|
|
1561
|
+
} finally {
|
|
1562
|
+
await server.close().catch(report("close-socket"));
|
|
1563
|
+
await service.kill(request.id).catch(report("kill"));
|
|
1564
|
+
}
|
|
1565
|
+
return screen;
|
|
1566
|
+
})();
|
|
1567
|
+
return {
|
|
1568
|
+
target,
|
|
1569
|
+
completion: service.ended(request.id).then(async (exitCode) => {
|
|
1570
|
+
const screen = (await finish())?.trimEnd();
|
|
1571
|
+
return screen ? {
|
|
1572
|
+
exitCode,
|
|
1573
|
+
outputTail: screen
|
|
1574
|
+
} : { exitCode };
|
|
1575
|
+
}),
|
|
1576
|
+
stop: async () => {
|
|
1577
|
+
await finish();
|
|
1578
|
+
return true;
|
|
1579
|
+
}
|
|
1580
|
+
};
|
|
1581
|
+
};
|
|
1582
|
+
}
|
|
1583
|
+
//#endregion
|
|
1584
|
+
//#region src/services/webWorkflowCatalog/index.ts
|
|
1585
|
+
function unchanged(left, right) {
|
|
1586
|
+
return left !== void 0 && right !== void 0 && left.size === right.size && left.modifiedAt === right.modifiedAt;
|
|
1587
|
+
}
|
|
1588
|
+
function createWorkflowCatalogReader(deps) {
|
|
1589
|
+
const cache = /* @__PURE__ */ new Map();
|
|
1590
|
+
const detailFor = (path) => {
|
|
1591
|
+
const stamp = deps.stamp(path);
|
|
1592
|
+
const cached = cache.get(path);
|
|
1593
|
+
if (cached && unchanged(cached.stamp, stamp)) return cached.detail;
|
|
1594
|
+
const detail = deps.summarize(path);
|
|
1595
|
+
if (stamp !== void 0) cache.set(path, {
|
|
1596
|
+
stamp,
|
|
1597
|
+
detail
|
|
1598
|
+
});
|
|
1599
|
+
return detail;
|
|
1600
|
+
};
|
|
1601
|
+
return {
|
|
1602
|
+
async read(directory) {
|
|
1603
|
+
return (await deps.list(directory)).map((entry) => {
|
|
1604
|
+
const detail = detailFor(entry.path);
|
|
1605
|
+
return {
|
|
1606
|
+
path: entry.path,
|
|
1607
|
+
relativePath: entry.relativePath,
|
|
1608
|
+
name: entry.name === "" ? entry.relativePath : entry.name,
|
|
1609
|
+
description: entry.description,
|
|
1610
|
+
tags: entry.tags,
|
|
1611
|
+
triggers: detail.triggers,
|
|
1612
|
+
inputs: detail.inputs,
|
|
1613
|
+
jobs: detail.jobs,
|
|
1614
|
+
artifacts: detail.artifacts,
|
|
1615
|
+
...detail.runners === void 0 ? {} : { runners: detail.runners },
|
|
1616
|
+
...detail.commands === void 0 ? {} : { commands: detail.commands },
|
|
1617
|
+
...detail.choices === void 0 ? {} : { choices: detail.choices },
|
|
1618
|
+
...detail.error === void 0 ? {} : { error: detail.error }
|
|
1619
|
+
};
|
|
1620
|
+
});
|
|
1621
|
+
},
|
|
1622
|
+
forget(keep) {
|
|
1623
|
+
for (const path of cache.keys()) if (!keep.has(path)) cache.delete(path);
|
|
1624
|
+
}
|
|
1625
|
+
};
|
|
1626
|
+
}
|
|
1627
|
+
/** The launchable rows first, then the ones that would not parse, name ordered. */
|
|
1628
|
+
function presentWorkflowCatalog(entries) {
|
|
1629
|
+
return [...entries].sort((left, right) => {
|
|
1630
|
+
if (left.error === void 0 !== (right.error === void 0)) return left.error === void 0 ? -1 : 1;
|
|
1631
|
+
return left.name.localeCompare(right.name);
|
|
1632
|
+
});
|
|
1633
|
+
}
|
|
1634
|
+
//#endregion
|
|
1635
|
+
//#region src/services/workflowCatalogDeps/index.ts
|
|
1636
|
+
/**
|
|
1637
|
+
* Connect the pure catalog projection service to the workflow engine and filesystem.
|
|
1638
|
+
*
|
|
1639
|
+
* A workflow that parses can still be one Po would fail mid-run, such as
|
|
1640
|
+
* a step whose runConfig misspells `majorMode`. The engine's doctor, told the
|
|
1641
|
+
* keys Po reads, finds those; its errors mark the workflow as one that
|
|
1642
|
+
* cannot launch, in the catalog and at every launch, rather than at the step.
|
|
1643
|
+
*/
|
|
1644
|
+
function defaultCatalogDeps() {
|
|
1645
|
+
return {
|
|
1646
|
+
list: (directory) => (0, _agimon_ai_workflow_mcp.readWorkflowCatalog)(directory),
|
|
1647
|
+
summarize: (workflowPath) => {
|
|
1648
|
+
const detail = (0, _agimon_ai_workflow_mcp.summarizeWorkflow)(workflowPath);
|
|
1649
|
+
if (detail.error !== void 0) return detail;
|
|
1650
|
+
const errors = (0, _agimon_ai_workflow_mcp.diagnoseWorkflow)(workflowPath, { runConfigKeys: PO_RUN_CONFIG_KEYS }).diagnostics.filter((diagnostic) => diagnostic.level === "error");
|
|
1651
|
+
if (errors.length === 0) return detail;
|
|
1652
|
+
return {
|
|
1653
|
+
...detail,
|
|
1654
|
+
error: errors.map((diagnostic) => diagnostic.location === void 0 ? diagnostic.message : `${diagnostic.location}: ${diagnostic.message}`).join("\n")
|
|
1655
|
+
};
|
|
1656
|
+
},
|
|
1657
|
+
stamp: (workflowPath) => {
|
|
1658
|
+
try {
|
|
1659
|
+
const stats = (0, node_fs.statSync)(workflowPath);
|
|
1660
|
+
return {
|
|
1661
|
+
size: stats.size,
|
|
1662
|
+
modifiedAt: stats.mtimeMs
|
|
1663
|
+
};
|
|
1664
|
+
} catch {
|
|
1665
|
+
return;
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1668
|
+
};
|
|
1669
|
+
}
|
|
1670
|
+
//#endregion
|
|
1671
|
+
//#region src/services/workflowFence/index.ts
|
|
1672
|
+
const DISPATCHER_AGENT_NAME = "agiflow-dispatcher";
|
|
1673
|
+
function resolveDispatcherParentSession(environment) {
|
|
1674
|
+
return (0, _agimon_ai_po_core_childProcess.readChildProcessContext)(environment)?.parentSessionId ?? (environment[_agimon_ai_po_core_childProcess.SUBAGENT_PARENT_SESSION_ENV]?.trim() || void 0);
|
|
1675
|
+
}
|
|
1676
|
+
function isWorkflowDispatcherProcess(environment) {
|
|
1677
|
+
if ((0, _agimon_ai_po_core_childProcess.readChildProcessContext)(environment)?.mode === DISPATCHER_AGENT_NAME) return true;
|
|
1678
|
+
return environment.PI_SUBAGENT_CHILD_AGENT === DISPATCHER_AGENT_NAME && Boolean(environment[_agimon_ai_po_core_childProcess.SUBAGENT_PARENT_SESSION_ENV]?.trim());
|
|
1679
|
+
}
|
|
1680
|
+
//#endregion
|
|
1681
|
+
//#region src/services/stepPaneTerminal/index.ts
|
|
1682
|
+
const PROGRESS_FILENAME = "progress.ndjson";
|
|
1683
|
+
const NATIVE_LAUNCHER = "native";
|
|
1684
|
+
/** Runs whose folded refs are kept between reads; a screen stream reads its run twice a second. */
|
|
1685
|
+
const REFS_CACHE_LIMIT = 256;
|
|
1686
|
+
const PANE_CAPABILITIES = {
|
|
1687
|
+
readable: true,
|
|
1688
|
+
writable: true,
|
|
1689
|
+
resizable: false
|
|
1690
|
+
};
|
|
1691
|
+
const PANE_LOG_CAPABILITIES = {
|
|
1692
|
+
readable: true,
|
|
1693
|
+
writable: false,
|
|
1694
|
+
resizable: false,
|
|
1695
|
+
reason: "That step has finished; this is the output it left."
|
|
1696
|
+
};
|
|
1697
|
+
const SESSION_STEP_REASON = "This step runs as an agent session; its conversation appears here as soon as the session starts.";
|
|
1698
|
+
const PANE_GONE_LINE = "The step pane has closed.";
|
|
1699
|
+
/**
|
|
1700
|
+
* The steps a run is on and its latest pane, folded from its progress log.
|
|
1701
|
+
*
|
|
1702
|
+
* Later events win, as in the engine's own summary. Steps of a parallel group
|
|
1703
|
+
* run at once, so every running step is kept, and the current one is the
|
|
1704
|
+
* latest-started step still running. A step that starts again is a new
|
|
1705
|
+
* attempt: its previous session or pane is not this attempt's, so the ref is
|
|
1706
|
+
* dropped until the new one is recorded. The latest pane is kept after it
|
|
1707
|
+
* finishes so a run between steps, or one that has ended, can still show what
|
|
1708
|
+
* its last command printed.
|
|
1709
|
+
*/
|
|
1710
|
+
function stepRefsFrom(events) {
|
|
1711
|
+
const steps = /* @__PURE__ */ new Map();
|
|
1712
|
+
const known = /* @__PURE__ */ new Map();
|
|
1713
|
+
let lastPane;
|
|
1714
|
+
for (const event of events) {
|
|
1715
|
+
if (event.type !== "step" || event.step === void 0) continue;
|
|
1716
|
+
const key = `${event.job}\u0000${event.step}`;
|
|
1717
|
+
const previous = steps.get(key);
|
|
1718
|
+
const running = event.status === "running";
|
|
1719
|
+
const restarted = running && (event.index !== void 0 || previous?.running !== true);
|
|
1720
|
+
if (restarted) steps.delete(key);
|
|
1721
|
+
const ref = event.ref ?? (restarted ? void 0 : previous?.ref);
|
|
1722
|
+
steps.set(key, {
|
|
1723
|
+
running,
|
|
1724
|
+
...ref === void 0 ? {} : { ref }
|
|
1725
|
+
});
|
|
1726
|
+
if (event.ref !== void 0) known.set(event.ref.id, event.ref);
|
|
1727
|
+
if (event.ref?.kind === "pane") lastPane = event.ref;
|
|
1728
|
+
}
|
|
1729
|
+
const running = [...steps.values()].filter((step) => step.running && step.ref !== void 0).map((step) => step.ref);
|
|
1730
|
+
const current = running.at(-1);
|
|
1731
|
+
return {
|
|
1732
|
+
...current === void 0 ? {} : { current },
|
|
1733
|
+
...running.length === 0 ? {} : { running },
|
|
1734
|
+
...lastPane === void 0 ? {} : { lastPane },
|
|
1735
|
+
...known.size === 0 ? {} : { known: [...known.values()] }
|
|
1736
|
+
};
|
|
1737
|
+
}
|
|
1738
|
+
/**
|
|
1739
|
+
* Reads a run's refs, folding its progress log again only when the log changed.
|
|
1740
|
+
*
|
|
1741
|
+
* Every screen, keystroke and capability check asks for them, and the log grows
|
|
1742
|
+
* with every fix loop, so an unchanged file (same size and modification time)
|
|
1743
|
+
* answers from the last fold.
|
|
1744
|
+
*/
|
|
1745
|
+
function createStepRefsReader() {
|
|
1746
|
+
const cache = /* @__PURE__ */ new Map();
|
|
1747
|
+
return (file) => {
|
|
1748
|
+
let stats;
|
|
1749
|
+
try {
|
|
1750
|
+
stats = node_fs.default.statSync(file);
|
|
1751
|
+
} catch {
|
|
1752
|
+
return {};
|
|
1753
|
+
}
|
|
1754
|
+
const cached = cache.get(file);
|
|
1755
|
+
if (cached !== void 0 && cached.size === stats.size && cached.mtimeMs === stats.mtimeMs) return cached.refs;
|
|
1756
|
+
let raw;
|
|
1757
|
+
try {
|
|
1758
|
+
raw = node_fs.default.readFileSync(file, "utf8");
|
|
1759
|
+
} catch {
|
|
1760
|
+
return {};
|
|
1761
|
+
}
|
|
1762
|
+
const refs = stepRefsFrom(parseWorkflowProgress(raw));
|
|
1763
|
+
cache.delete(file);
|
|
1764
|
+
cache.set(file, {
|
|
1765
|
+
size: stats.size,
|
|
1766
|
+
mtimeMs: stats.mtimeMs,
|
|
1767
|
+
refs
|
|
1768
|
+
});
|
|
1769
|
+
if (cache.size > REFS_CACHE_LIMIT) cache.delete(cache.keys().next().value);
|
|
1770
|
+
return refs;
|
|
1771
|
+
};
|
|
1772
|
+
}
|
|
1773
|
+
/**
|
|
1774
|
+
* Real dependencies: the registry's run directory, and the step executor's pane sockets.
|
|
1775
|
+
*
|
|
1776
|
+
* A pane is reached through the socket its step was served on, so any scope
|
|
1777
|
+
* of the server can read it, and a finished one through the screen kept
|
|
1778
|
+
* beside that socket.
|
|
1779
|
+
*/
|
|
1780
|
+
function createStepPaneTerminalDependencies(registry) {
|
|
1781
|
+
const native = (0, _agimon_ai_workflow_mcp.connectNativeTerminal)();
|
|
1782
|
+
const readRefs = createStepRefsReader();
|
|
1783
|
+
return {
|
|
1784
|
+
stepRefs: (record) => readRefs(node_path.default.join(registry.runDirectoryFor(record), PROGRESS_FILENAME)),
|
|
1785
|
+
paneClient: () => ({
|
|
1786
|
+
capture: (target) => native.snapshot(target, {}).then((lines) => lines.join("\n"), () => void 0),
|
|
1787
|
+
input: (target, text) => native.write(target, text).then(() => true, () => false)
|
|
1788
|
+
}),
|
|
1789
|
+
paneLog: (_record, pane) => node_fs.default.promises.readFile(stepPaneLogPath(pane.id), "utf8").catch(() => void 0)
|
|
1790
|
+
};
|
|
1791
|
+
}
|
|
1792
|
+
/** Removes the screens a run's finished panes left behind, once the run itself is deleted. */
|
|
1793
|
+
async function removeStepPaneLogs(refs) {
|
|
1794
|
+
const panes = (refs.known ?? []).filter((ref) => ref.kind === STEP_PANE_REF_KIND);
|
|
1795
|
+
await Promise.all(panes.map((pane) => node_fs.default.promises.rm(stepPaneLogPath(pane.id), { force: true })));
|
|
1796
|
+
}
|
|
1797
|
+
function lastLines(text, count) {
|
|
1798
|
+
const lines = text.replace(/\n+$/, "").split("\n");
|
|
1799
|
+
return count === void 0 ? lines : lines.slice(-count);
|
|
1800
|
+
}
|
|
1801
|
+
/**
|
|
1802
|
+
* A run terminal that follows the step a host executor placed.
|
|
1803
|
+
*
|
|
1804
|
+
* A run executing in the server has no launcher pane of its own; each command
|
|
1805
|
+
* step gets one instead. A request naming a step reads that step's pane, which
|
|
1806
|
+
* is how each step of a parallel group gets its own screen and keyboard.
|
|
1807
|
+
* Without one it follows the current step. While a customRun step runs, there
|
|
1808
|
+
* is no terminal to show, so it says where the agent's messages are. Between
|
|
1809
|
+
* steps and after the run, an in-process run shows what its latest command
|
|
1810
|
+
* printed. A run delegated to a terminal launcher keeps the engine's facade.
|
|
1811
|
+
*/
|
|
1812
|
+
function createStepPaneTerminal(facade, dependencies) {
|
|
1813
|
+
const inProcess = (record) => record.launcher?.type === NATIVE_LAUNCHER && record.terminalSocket === void 0;
|
|
1814
|
+
const resolveStep = (target) => {
|
|
1815
|
+
const refs = dependencies.stepRefs(target.record);
|
|
1816
|
+
const isRunning = (ref) => refs.running?.some((candidate) => candidate.id === ref.id) === true;
|
|
1817
|
+
if (target.step !== void 0) {
|
|
1818
|
+
const named = refs.known?.find((ref) => ref.id === target.step);
|
|
1819
|
+
if (named !== void 0) return {
|
|
1820
|
+
ref: named,
|
|
1821
|
+
running: isRunning(named)
|
|
1822
|
+
};
|
|
1823
|
+
}
|
|
1824
|
+
if (refs.current !== void 0) return {
|
|
1825
|
+
ref: refs.current,
|
|
1826
|
+
running: true
|
|
1827
|
+
};
|
|
1828
|
+
if (refs.lastPane !== void 0 && inProcess(target.record)) return {
|
|
1829
|
+
ref: refs.lastPane,
|
|
1830
|
+
running: false
|
|
1831
|
+
};
|
|
1832
|
+
};
|
|
1833
|
+
const readLog = async (record, pane, lines) => {
|
|
1834
|
+
const log = await dependencies.paneLog(record, pane);
|
|
1835
|
+
return log === void 0 ? [PANE_GONE_LINE] : lastLines(log, lines);
|
|
1836
|
+
};
|
|
1837
|
+
return {
|
|
1838
|
+
capabilities(target) {
|
|
1839
|
+
const step = resolveStep(target);
|
|
1840
|
+
if (step === void 0) return facade.capabilities(target.record);
|
|
1841
|
+
if (step.ref.kind === "session") return {
|
|
1842
|
+
readable: false,
|
|
1843
|
+
writable: false,
|
|
1844
|
+
resizable: false,
|
|
1845
|
+
reason: SESSION_STEP_REASON
|
|
1846
|
+
};
|
|
1847
|
+
return step.running ? PANE_CAPABILITIES : PANE_LOG_CAPABILITIES;
|
|
1848
|
+
},
|
|
1849
|
+
async screen(target, options) {
|
|
1850
|
+
const step = resolveStep(target);
|
|
1851
|
+
if (step === void 0) return facade.screen(target.record, options);
|
|
1852
|
+
if (step.ref.kind === "session") return [SESSION_STEP_REASON];
|
|
1853
|
+
if (step.running) {
|
|
1854
|
+
const screen = await dependencies.paneClient(target.record).capture(step.ref.id);
|
|
1855
|
+
if (screen !== void 0) return lastLines(screen, options.lines);
|
|
1856
|
+
}
|
|
1857
|
+
return readLog(target.record, step.ref, options.lines);
|
|
1858
|
+
},
|
|
1859
|
+
async write(target, data) {
|
|
1860
|
+
const step = resolveStep(target);
|
|
1861
|
+
if (step === void 0) return facade.write(target.record, data);
|
|
1862
|
+
if (step.ref.kind !== "pane" || !step.running) throw new Error(PANE_GONE_LINE);
|
|
1863
|
+
if (!await dependencies.paneClient(target.record).input(step.ref.id, data)) throw new Error(PANE_GONE_LINE);
|
|
1864
|
+
},
|
|
1865
|
+
async resize(target, columns, rows) {
|
|
1866
|
+
if (resolveStep(target) !== void 0) return false;
|
|
1867
|
+
return facade.resize(target.record, columns, rows);
|
|
1868
|
+
}
|
|
1869
|
+
};
|
|
1870
|
+
}
|
|
1871
|
+
//#endregion
|
|
1872
|
+
//#region src/services/workflowTerminal/index.ts
|
|
1873
|
+
const DEFAULT_REFRESH_MS = 750;
|
|
1874
|
+
const DEFAULT_LEASE_MS = 6e4;
|
|
1875
|
+
const NOT_HELD = "Another reader holds the keyboard for this run.";
|
|
1876
|
+
function createWorkflowTerminalService(deps) {
|
|
1877
|
+
const refreshMs = deps.refreshMs ?? DEFAULT_REFRESH_MS;
|
|
1878
|
+
const leaseMs = deps.leaseMs ?? DEFAULT_LEASE_MS;
|
|
1879
|
+
const screens = /* @__PURE__ */ new Map();
|
|
1880
|
+
const leases = /* @__PURE__ */ new Map();
|
|
1881
|
+
const heldBy = (identity) => {
|
|
1882
|
+
const lease = leases.get(identity);
|
|
1883
|
+
if (lease === void 0) return void 0;
|
|
1884
|
+
if (lease.expiresAt > deps.now()) return lease;
|
|
1885
|
+
leases.delete(identity);
|
|
1886
|
+
};
|
|
1887
|
+
return {
|
|
1888
|
+
async screen(identity, record, lines) {
|
|
1889
|
+
const entry = screens.get(identity) ?? { lines: [] };
|
|
1890
|
+
screens.set(identity, entry);
|
|
1891
|
+
if (entry.inFlight) return entry.lines;
|
|
1892
|
+
if (entry.at !== void 0 && deps.now() - entry.at < refreshMs) return entry.lines;
|
|
1893
|
+
entry.inFlight = deps.terminal.screen(record, { lines });
|
|
1894
|
+
try {
|
|
1895
|
+
entry.lines = await entry.inFlight;
|
|
1896
|
+
} finally {
|
|
1897
|
+
entry.at = deps.now();
|
|
1898
|
+
entry.inFlight = void 0;
|
|
1899
|
+
}
|
|
1900
|
+
return entry.lines;
|
|
1901
|
+
},
|
|
1902
|
+
capabilities(record) {
|
|
1903
|
+
return deps.terminal.capabilities(record);
|
|
1904
|
+
},
|
|
1905
|
+
takeControl(identity, token) {
|
|
1906
|
+
const holder = heldBy(identity);
|
|
1907
|
+
if (holder !== void 0 && holder.token !== token) return false;
|
|
1908
|
+
leases.set(identity, {
|
|
1909
|
+
token,
|
|
1910
|
+
expiresAt: deps.now() + leaseMs
|
|
1911
|
+
});
|
|
1912
|
+
return true;
|
|
1913
|
+
},
|
|
1914
|
+
releaseControl(identity, token) {
|
|
1915
|
+
if (heldBy(identity)?.token === token) leases.delete(identity);
|
|
1916
|
+
},
|
|
1917
|
+
async write(identity, record, token, data) {
|
|
1918
|
+
const holder = heldBy(identity);
|
|
1919
|
+
if (holder === void 0 || holder.token !== token) throw new Error(NOT_HELD);
|
|
1920
|
+
leases.set(identity, {
|
|
1921
|
+
token,
|
|
1922
|
+
expiresAt: deps.now() + leaseMs
|
|
1923
|
+
});
|
|
1924
|
+
await deps.terminal.write(record, data);
|
|
1925
|
+
},
|
|
1926
|
+
async resize(identity, record, token, columns, rows) {
|
|
1927
|
+
const holder = heldBy(identity);
|
|
1928
|
+
if (holder === void 0 || holder.token !== token) throw new Error(NOT_HELD);
|
|
1929
|
+
return deps.terminal.resize(record, columns, rows);
|
|
1930
|
+
},
|
|
1931
|
+
forget(live) {
|
|
1932
|
+
for (const identity of screens.keys()) if (!live.has(identity)) screens.delete(identity);
|
|
1933
|
+
for (const identity of leases.keys()) if (!live.has(identity)) leases.delete(identity);
|
|
1934
|
+
},
|
|
1935
|
+
forgetRun(identity) {
|
|
1936
|
+
screens.delete(identity);
|
|
1937
|
+
leases.delete(identity);
|
|
1938
|
+
}
|
|
1939
|
+
};
|
|
1940
|
+
}
|
|
1941
|
+
//#endregion
|
|
1942
|
+
//#region src/types/apiRoutes.ts
|
|
1943
|
+
/**
|
|
1944
|
+
* This package's routes, as data.
|
|
1945
|
+
*
|
|
1946
|
+
* No scope and no base path. The build reads both off
|
|
1947
|
+
* `src/extensions/(backend)/api/workflow/`, so the mount is stated once, by the
|
|
1948
|
+
* folder that creates it. The same API answers at all three scopes, because a
|
|
1949
|
+
* global contribution cascades into workspace and session; the folder under
|
|
1950
|
+
* `workspaces/sessions/` only re-mounts it for a session's own server.
|
|
1951
|
+
*
|
|
1952
|
+
* This is the one file the routes, the Hono app and the browser all read, so a
|
|
1953
|
+
* path cannot move on one side alone. The page used to build the hub's own
|
|
1954
|
+
* absolute URL and then rewrite its plugin prefix with String.replace to reach
|
|
1955
|
+
* a session, which is exactly the kind of copy that can move by one character
|
|
1956
|
+
* without anything noticing.
|
|
1957
|
+
*
|
|
1958
|
+
* `:workspace` and `:runKey` are Hono's own parameter syntax, because the app
|
|
1959
|
+
* registers `path` verbatim.
|
|
1960
|
+
*/
|
|
1961
|
+
/** One run, which every route below addresses. */
|
|
1962
|
+
const RUN = "/runs/:workspace/:runKey";
|
|
1963
|
+
/**
|
|
1964
|
+
* The artifact route's parameter, as Hono spells a multi-segment match.
|
|
1965
|
+
*
|
|
1966
|
+
* An artifact is named by its path inside the run directory, so `reports/a.md`
|
|
1967
|
+
* is one parameter across two segments. The client's `params` substitution
|
|
1968
|
+
* fills a single segment and percent-encodes the slash with it, so the page
|
|
1969
|
+
* substitutes this one itself; see the page's `_lib/terminalApi.ts`.
|
|
1970
|
+
*/
|
|
1971
|
+
const ARTIFACT_NAME_PARAM = ":name{.+}";
|
|
1972
|
+
/**
|
|
1973
|
+
* The step whose pane a terminal route reads: a step ref's id. Absent, the
|
|
1974
|
+
* route follows the run's current step, which is one of a parallel group's.
|
|
1975
|
+
*/
|
|
1976
|
+
const STEP_TARGET_PARAM = "step";
|
|
1977
|
+
var apiRoutes_default = (0, _agimon_ai_po_core_web.defineApiRoutes)({
|
|
1978
|
+
/**
|
|
1979
|
+
* The terminal, as server-sent events.
|
|
1980
|
+
*
|
|
1981
|
+
* `stream: true` gets a URL and no call method: the consumer is the browser's
|
|
1982
|
+
* own `EventSource`, and a relayed call would buffer a stream that never ends.
|
|
1983
|
+
*/
|
|
1984
|
+
screen: {
|
|
1985
|
+
method: "GET",
|
|
1986
|
+
path: `${RUN}/screen/stream`,
|
|
1987
|
+
query: [STEP_TARGET_PARAM],
|
|
1988
|
+
stream: true
|
|
1989
|
+
},
|
|
1990
|
+
control: {
|
|
1991
|
+
method: "POST",
|
|
1992
|
+
path: `${RUN}/control`,
|
|
1993
|
+
response: (0, _agimon_ai_po_core_web.apiResponse)()
|
|
1994
|
+
},
|
|
1995
|
+
keys: {
|
|
1996
|
+
method: "POST",
|
|
1997
|
+
path: `${RUN}/keys`
|
|
1998
|
+
},
|
|
1999
|
+
resize: {
|
|
2000
|
+
method: "POST",
|
|
2001
|
+
path: `${RUN}/resize`,
|
|
2002
|
+
response: (0, _agimon_ai_po_core_web.apiResponse)()
|
|
2003
|
+
},
|
|
2004
|
+
remove: {
|
|
2005
|
+
method: "DELETE",
|
|
2006
|
+
path: RUN,
|
|
2007
|
+
response: (0, _agimon_ai_po_core_web.apiResponse)()
|
|
2008
|
+
},
|
|
2009
|
+
stop: {
|
|
2010
|
+
method: "POST",
|
|
2011
|
+
path: `${RUN}/stop`,
|
|
2012
|
+
response: (0, _agimon_ai_po_core_web.apiResponse)()
|
|
2013
|
+
},
|
|
2014
|
+
/** Guidance for the agent session the running step is in, like steering a Team sub-agent. */
|
|
2015
|
+
steer: {
|
|
2016
|
+
method: "POST",
|
|
2017
|
+
path: `${RUN}/steer`,
|
|
2018
|
+
response: (0, _agimon_ai_po_core_web.apiResponse)()
|
|
2019
|
+
},
|
|
2020
|
+
/**
|
|
2021
|
+
* Launch a workflow into the mounting session's own runtime, which then owns
|
|
2022
|
+
* the run. A dispatcher session reaches its root's session mount through this.
|
|
2023
|
+
*/
|
|
2024
|
+
launch: {
|
|
2025
|
+
method: "POST",
|
|
2026
|
+
path: "/launch",
|
|
2027
|
+
response: (0, _agimon_ai_po_core_web.apiResponse)()
|
|
2028
|
+
},
|
|
2029
|
+
openRunDirectory: {
|
|
2030
|
+
method: "POST",
|
|
2031
|
+
path: `${RUN}/open-directory`,
|
|
2032
|
+
response: (0, _agimon_ai_po_core_web.apiResponse)()
|
|
2033
|
+
},
|
|
2034
|
+
artifacts: {
|
|
2035
|
+
method: "GET",
|
|
2036
|
+
path: `${RUN}/artifacts`,
|
|
2037
|
+
query: ["directory"],
|
|
2038
|
+
response: (0, _agimon_ai_po_core_web.apiResponse)()
|
|
2039
|
+
},
|
|
2040
|
+
/** One artifact: its metadata and text, or its raw bytes under `raw=1`. */
|
|
2041
|
+
artifact: {
|
|
2042
|
+
method: "GET",
|
|
2043
|
+
path: `${RUN}/artifacts/${ARTIFACT_NAME_PARAM}`,
|
|
2044
|
+
query: ["raw", "download"],
|
|
2045
|
+
response: (0, _agimon_ai_po_core_web.apiResponse)()
|
|
2046
|
+
}
|
|
2047
|
+
});
|
|
2048
|
+
//#endregion
|
|
2049
|
+
//#region src/types/webWorkflowTerminal.ts
|
|
2050
|
+
/**
|
|
2051
|
+
* The wire contract between this package's hub API and its cockpit client:
|
|
2052
|
+
* one run's terminal, and the files its run directory holds.
|
|
2053
|
+
*
|
|
2054
|
+
* Both halves of the cockpit import these names, so the route strings and the
|
|
2055
|
+
* event names live here rather than being spelled twice.
|
|
2056
|
+
*/
|
|
2057
|
+
/**
|
|
2058
|
+
* Segment this package's API is mounted under, below /api/plugins/.
|
|
2059
|
+
*
|
|
2060
|
+
* The same word names `src/extensions/(backend)/api/workflow/`, which is where
|
|
2061
|
+
* the build reads the mount for the browser's client, so the two cannot drift.
|
|
2062
|
+
* The routes themselves are declared in `apiRoutes.ts`.
|
|
2063
|
+
*/
|
|
2064
|
+
const WORKFLOW_API_BASE_PATH = "workflow";
|
|
2065
|
+
/** The SSE event name the screen stream writes. */
|
|
2066
|
+
const WORKFLOW_SCREEN_EVENT = "screen";
|
|
2067
|
+
//#endregion
|
|
2068
|
+
//#region src/services/workflowHubApi/index.ts
|
|
2069
|
+
/** Stages a run can be recorded under, newest first: a live run is the common case. */
|
|
2070
|
+
const STAGES = [
|
|
2071
|
+
"running",
|
|
2072
|
+
"error",
|
|
2073
|
+
"completed"
|
|
2074
|
+
];
|
|
2075
|
+
/** Lines of screen the stream sends, which is a terminal's visible height plus room. */
|
|
2076
|
+
const SCREEN_LINES = 48;
|
|
2077
|
+
/** How often the stream re-reads; the service coalesces, so this is an upper bound. */
|
|
2078
|
+
const STREAM_TICK_MS = 500;
|
|
2079
|
+
/** How often a settled run is re-checked before the stream closes itself. */
|
|
2080
|
+
const SETTLED_POLL_TICKS = 4;
|
|
2081
|
+
/** Bytes of one textual artifact returned as JSON; binary previews use a stream. */
|
|
2082
|
+
const MAX_ARTIFACT_BYTES = 524288;
|
|
2083
|
+
/** Recorded with a stop the web panel asks for, so the run log says where it came from. */
|
|
2084
|
+
const WEB_STOP_REASON = "Stopped from the web workflow panel.";
|
|
2085
|
+
/** Path segments a client supplies are names, never paths. */
|
|
2086
|
+
const SAFE_SEGMENT = /^[\w.@-]+$/;
|
|
2087
|
+
const ARTIFACT_MIME_TYPES = {
|
|
2088
|
+
".aac": "audio/aac",
|
|
2089
|
+
".bmp": "image/bmp",
|
|
2090
|
+
".css": "text/css",
|
|
2091
|
+
".csv": "text/csv",
|
|
2092
|
+
".gif": "image/gif",
|
|
2093
|
+
".htm": "text/html",
|
|
2094
|
+
".html": "text/html",
|
|
2095
|
+
".ico": "image/x-icon",
|
|
2096
|
+
".jpeg": "image/jpeg",
|
|
2097
|
+
".jpg": "image/jpeg",
|
|
2098
|
+
".js": "text/javascript",
|
|
2099
|
+
".json": "application/json",
|
|
2100
|
+
".jsx": "text/jsx",
|
|
2101
|
+
".log": "text/plain",
|
|
2102
|
+
".m4a": "audio/mp4",
|
|
2103
|
+
".markdown": "text/markdown",
|
|
2104
|
+
".md": "text/markdown",
|
|
2105
|
+
".mov": "video/quicktime",
|
|
2106
|
+
".mp3": "audio/mpeg",
|
|
2107
|
+
".mp4": "video/mp4",
|
|
2108
|
+
".oga": "audio/ogg",
|
|
2109
|
+
".ogg": "audio/ogg",
|
|
2110
|
+
".ogv": "video/ogg",
|
|
2111
|
+
".pdf": "application/pdf",
|
|
2112
|
+
".png": "image/png",
|
|
2113
|
+
".sh": "text/x-shellscript",
|
|
2114
|
+
".svg": "image/svg+xml",
|
|
2115
|
+
".toml": "text/toml",
|
|
2116
|
+
".ts": "text/typescript",
|
|
2117
|
+
".tsv": "text/tab-separated-values",
|
|
2118
|
+
".tsx": "text/tsx",
|
|
2119
|
+
".txt": "text/plain",
|
|
2120
|
+
".wav": "audio/wav",
|
|
2121
|
+
".webm": "video/webm",
|
|
2122
|
+
".webp": "image/webp",
|
|
2123
|
+
".xml": "application/xml",
|
|
2124
|
+
".yaml": "text/yaml",
|
|
2125
|
+
".yml": "text/yaml"
|
|
2126
|
+
};
|
|
2127
|
+
/**
|
|
2128
|
+
* One request's JSON body as a plain record.
|
|
2129
|
+
*
|
|
2130
|
+
* A body that is absent, malformed, or not an object reads as empty rather
|
|
2131
|
+
* than failing the route: every field is checked by the route anyway, and a
|
|
2132
|
+
* 400 naming the missing field beats a parse error naming nothing.
|
|
2133
|
+
*/
|
|
2134
|
+
async function jsonBody(context) {
|
|
2135
|
+
const parsed = await context.req.json().catch(() => void 0);
|
|
2136
|
+
return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
2137
|
+
}
|
|
2138
|
+
/** Rejects anything that could climb out of the registry before a path is built. */
|
|
2139
|
+
function isSafeSegment(value) {
|
|
2140
|
+
return value !== "" && value !== "../adapters" && value !== ".." && SAFE_SEGMENT.test(value);
|
|
2141
|
+
}
|
|
2142
|
+
/**
|
|
2143
|
+
* One terminal's identity for caches and leases, stable across stage moves: a
|
|
2144
|
+
* run's, or one step's pane in it, which keeps its own screen and keyboard.
|
|
2145
|
+
*/
|
|
2146
|
+
function runIdentityOf(workspace, runKey, step) {
|
|
2147
|
+
return step === void 0 ? `${workspace}/${runKey}` : `${workspace}/${runKey}#${step}`;
|
|
2148
|
+
}
|
|
2149
|
+
/** The step a terminal request names, from its query or body; absent follows the current step. */
|
|
2150
|
+
function stepOf(value) {
|
|
2151
|
+
return typeof value === "string" && value !== "" ? value : void 0;
|
|
2152
|
+
}
|
|
2153
|
+
/**
|
|
2154
|
+
* This package's HTTP surface: one workflow run's terminal, and the files its
|
|
2155
|
+
* run directory holds.
|
|
2156
|
+
*
|
|
2157
|
+
* Mounted in the hub rather than in a session's server, because a run's
|
|
2158
|
+
* terminal belongs to its multiplexer rather than to the session that launched
|
|
2159
|
+
* it: any process can read a tmux pane, and the session's own server cannot
|
|
2160
|
+
* reach anything more. The routes therefore name a run, never a session.
|
|
2161
|
+
*/
|
|
2162
|
+
function createWorkflowHubApi(options = {}) {
|
|
2163
|
+
const registry = options.registry ?? new _agimon_ai_workflow_mcp.WorkflowRegistryService();
|
|
2164
|
+
const facade = options.terminal ?? new _agimon_ai_workflow_mcp.WorkflowTerminalService();
|
|
2165
|
+
const stepPanes = createStepPaneTerminalDependencies(registry);
|
|
2166
|
+
const terminal = createWorkflowTerminalService({
|
|
2167
|
+
terminal: createStepPaneTerminal(facade, stepPanes),
|
|
2168
|
+
now: options.now ?? (() => Date.now())
|
|
2169
|
+
});
|
|
2170
|
+
const app = new hono.Hono();
|
|
2171
|
+
/** The record behind a request, whichever stage it now sits in. */
|
|
2172
|
+
const runOf = async (workspace, runKey) => {
|
|
2173
|
+
if (!isSafeSegment(workspace) || !isSafeSegment(runKey)) return void 0;
|
|
2174
|
+
for (const stage of STAGES) try {
|
|
2175
|
+
return await registry.readRunByKey(workspace, stage, runKey);
|
|
2176
|
+
} catch {}
|
|
2177
|
+
};
|
|
2178
|
+
const missing = (workspace, runKey) => ({ error: `No workflow run '${runKey}' in workspace '${workspace}'.` });
|
|
2179
|
+
app.get(apiRoutes_default.screen.path, async (context) => {
|
|
2180
|
+
const workspace = context.req.param("workspace");
|
|
2181
|
+
const runKey = context.req.param("runKey");
|
|
2182
|
+
const record = await runOf(workspace, runKey);
|
|
2183
|
+
if (record === void 0) return context.json(missing(workspace, runKey), 404);
|
|
2184
|
+
const step = stepOf(context.req.query(STEP_TARGET_PARAM));
|
|
2185
|
+
const identity = runIdentityOf(workspace, runKey, step);
|
|
2186
|
+
return (0, hono_streaming.streamSSE)(context, async (stream) => {
|
|
2187
|
+
let settledTicks = 0;
|
|
2188
|
+
let running = true;
|
|
2189
|
+
let latest = record;
|
|
2190
|
+
stream.onAbort(() => {
|
|
2191
|
+
running = false;
|
|
2192
|
+
});
|
|
2193
|
+
while (running) {
|
|
2194
|
+
const current = await runOf(workspace, runKey) ?? record;
|
|
2195
|
+
latest = current;
|
|
2196
|
+
const target = {
|
|
2197
|
+
record: current,
|
|
2198
|
+
...step === void 0 ? {} : { step }
|
|
2199
|
+
};
|
|
2200
|
+
const capabilities = terminal.capabilities(target);
|
|
2201
|
+
const lines = capabilities.readable ? await terminal.screen(identity, target, SCREEN_LINES) : [];
|
|
2202
|
+
const settled = current.stage !== "running";
|
|
2203
|
+
if (settled) settledTicks += 1;
|
|
2204
|
+
const ended = settled && settledTicks >= SETTLED_POLL_TICKS;
|
|
2205
|
+
const event = {
|
|
2206
|
+
lines,
|
|
2207
|
+
capabilities,
|
|
2208
|
+
...ended ? { ended: true } : {}
|
|
2209
|
+
};
|
|
2210
|
+
await stream.writeSSE({
|
|
2211
|
+
event: WORKFLOW_SCREEN_EVENT,
|
|
2212
|
+
data: JSON.stringify(event)
|
|
2213
|
+
});
|
|
2214
|
+
if (ended) break;
|
|
2215
|
+
await stream.sleep(STREAM_TICK_MS);
|
|
2216
|
+
}
|
|
2217
|
+
if (latest.stage !== "running") terminal.forgetRun(identity);
|
|
2218
|
+
});
|
|
2219
|
+
});
|
|
2220
|
+
app.post(apiRoutes_default.control.path, async (context) => {
|
|
2221
|
+
const workspace = context.req.param("workspace");
|
|
2222
|
+
const runKey = context.req.param("runKey");
|
|
2223
|
+
const record = await runOf(workspace, runKey);
|
|
2224
|
+
if (record === void 0) return context.json(missing(workspace, runKey), 404);
|
|
2225
|
+
const body = await jsonBody(context);
|
|
2226
|
+
const step = stepOf(body.step);
|
|
2227
|
+
const identity = runIdentityOf(workspace, runKey, step);
|
|
2228
|
+
const capabilities = terminal.capabilities({
|
|
2229
|
+
record,
|
|
2230
|
+
...step === void 0 ? {} : { step }
|
|
2231
|
+
});
|
|
2232
|
+
if (body.release === true) {
|
|
2233
|
+
if (typeof body.token === "string") terminal.releaseControl(identity, body.token);
|
|
2234
|
+
return context.json({ held: false });
|
|
2235
|
+
}
|
|
2236
|
+
if (!capabilities.writable) {
|
|
2237
|
+
const refused = {
|
|
2238
|
+
held: false,
|
|
2239
|
+
reason: capabilities.reason
|
|
2240
|
+
};
|
|
2241
|
+
return context.json(refused, 409);
|
|
2242
|
+
}
|
|
2243
|
+
const token = typeof body.token === "string" && body.token !== "" ? body.token : (0, node_crypto.randomUUID)();
|
|
2244
|
+
if (!terminal.takeControl(identity, token)) return context.json({
|
|
2245
|
+
held: false,
|
|
2246
|
+
reason: "Another reader holds the keyboard."
|
|
2247
|
+
}, 409);
|
|
2248
|
+
const held = {
|
|
2249
|
+
held: true,
|
|
2250
|
+
token
|
|
2251
|
+
};
|
|
2252
|
+
return context.json(held);
|
|
2253
|
+
});
|
|
2254
|
+
app.post(apiRoutes_default.keys.path, async (context) => {
|
|
2255
|
+
const workspace = context.req.param("workspace");
|
|
2256
|
+
const runKey = context.req.param("runKey");
|
|
2257
|
+
const record = await runOf(workspace, runKey);
|
|
2258
|
+
if (record === void 0) return context.json(missing(workspace, runKey), 404);
|
|
2259
|
+
const body = await jsonBody(context);
|
|
2260
|
+
if (typeof body.token !== "string" || typeof body.data !== "string") return context.json({ error: "A keystroke needs a control token and its data." }, 400);
|
|
2261
|
+
const step = stepOf(body.step);
|
|
2262
|
+
try {
|
|
2263
|
+
await terminal.write(runIdentityOf(workspace, runKey, step), {
|
|
2264
|
+
record,
|
|
2265
|
+
...step === void 0 ? {} : { step }
|
|
2266
|
+
}, body.token, body.data);
|
|
2267
|
+
} catch (error) {
|
|
2268
|
+
return context.json({ error: error instanceof Error ? error.message : String(error) }, 409);
|
|
2269
|
+
}
|
|
2270
|
+
return context.body(null, 204);
|
|
2271
|
+
});
|
|
2272
|
+
app.post(apiRoutes_default.resize.path, async (context) => {
|
|
2273
|
+
const workspace = context.req.param("workspace");
|
|
2274
|
+
const runKey = context.req.param("runKey");
|
|
2275
|
+
const record = await runOf(workspace, runKey);
|
|
2276
|
+
if (record === void 0) return context.json(missing(workspace, runKey), 404);
|
|
2277
|
+
const body = await jsonBody(context);
|
|
2278
|
+
const { token, columns, rows } = body;
|
|
2279
|
+
if (typeof token !== "string" || typeof columns !== "number" || typeof rows !== "number") return context.json({ error: "A resize needs a control token, columns and rows." }, 400);
|
|
2280
|
+
const step = stepOf(body.step);
|
|
2281
|
+
try {
|
|
2282
|
+
const resized = await terminal.resize(runIdentityOf(workspace, runKey, step), {
|
|
2283
|
+
record,
|
|
2284
|
+
...step === void 0 ? {} : { step }
|
|
2285
|
+
}, token, columns, rows);
|
|
2286
|
+
return context.json({ resized });
|
|
2287
|
+
} catch (error) {
|
|
2288
|
+
return context.json({ error: error instanceof Error ? error.message : String(error) }, 409);
|
|
2289
|
+
}
|
|
2290
|
+
});
|
|
2291
|
+
app.post(apiRoutes_default.stop.path, async (context) => {
|
|
2292
|
+
const workspace = context.req.param("workspace");
|
|
2293
|
+
const runKey = context.req.param("runKey");
|
|
2294
|
+
const record = await runOf(workspace, runKey);
|
|
2295
|
+
if (record === void 0) return context.json(missing(workspace, runKey), 404);
|
|
2296
|
+
if (record.stage !== "running" || record.runId === void 0) return context.json({ error: "Only a running workflow can be stopped." }, 409);
|
|
2297
|
+
try {
|
|
2298
|
+
await registry.requestStop(workspace, runKey, WEB_STOP_REASON, record.runId);
|
|
2299
|
+
} catch (error) {
|
|
2300
|
+
return context.json({ error: error instanceof Error ? error.message : String(error) }, 409);
|
|
2301
|
+
}
|
|
2302
|
+
return context.json({ requested: true });
|
|
2303
|
+
});
|
|
2304
|
+
app.post(apiRoutes_default.steer.path, async (context) => {
|
|
2305
|
+
const workspace = context.req.param("workspace");
|
|
2306
|
+
const runKey = context.req.param("runKey");
|
|
2307
|
+
const record = await runOf(workspace, runKey);
|
|
2308
|
+
if (record === void 0) return context.json(missing(workspace, runKey), 404);
|
|
2309
|
+
const body = await jsonBody(context);
|
|
2310
|
+
const message = typeof body.message === "string" ? body.message.trim() : "";
|
|
2311
|
+
if (message === "") return context.json({ error: "Guidance needs a message." }, 400);
|
|
2312
|
+
if (record.stage !== "running") return context.json({ error: "Only a running workflow can be steered." }, 409);
|
|
2313
|
+
const refs = stepPanes.stepRefs(record);
|
|
2314
|
+
const step = typeof body.step === "string" ? body.step : void 0;
|
|
2315
|
+
const current = step === void 0 ? refs.current : (refs.running ?? []).find((candidate) => candidate.id === step);
|
|
2316
|
+
if (current?.kind !== "session") return context.json({ error: "The running step is not an agent session." }, 409);
|
|
2317
|
+
if (options.steer === void 0) return context.json({ error: "Steering is available from the session that launched this run." }, 409);
|
|
2318
|
+
try {
|
|
2319
|
+
await options.steer(current.id, message);
|
|
2320
|
+
} catch (error) {
|
|
2321
|
+
return context.json({ error: error instanceof Error ? error.message : String(error) }, 409);
|
|
2322
|
+
}
|
|
2323
|
+
return context.json({ delivered: true });
|
|
2324
|
+
});
|
|
2325
|
+
app.post(apiRoutes_default.launch.path, async (context) => {
|
|
2326
|
+
if (options.launch === void 0) return context.json({ error: "Launching needs the mount of a session with a workflow runtime." }, 409);
|
|
2327
|
+
const response = await options.launch(await jsonBody(context));
|
|
2328
|
+
return context.json(response);
|
|
2329
|
+
});
|
|
2330
|
+
app.delete(apiRoutes_default.remove.path, async (context) => {
|
|
2331
|
+
const workspace = context.req.param("workspace");
|
|
2332
|
+
const runKey = context.req.param("runKey");
|
|
2333
|
+
const record = await runOf(workspace, runKey);
|
|
2334
|
+
if (record === void 0) return context.json(missing(workspace, runKey), 404);
|
|
2335
|
+
if (record.stage === "running") return context.json({ error: "A running workflow must be stopped before it can be deleted." }, 409);
|
|
2336
|
+
const refs = stepPanes.stepRefs(record);
|
|
2337
|
+
try {
|
|
2338
|
+
node_fs.default.rmSync(registry.runDirectoryFor(record), {
|
|
2339
|
+
recursive: true,
|
|
2340
|
+
force: true
|
|
2341
|
+
});
|
|
2342
|
+
await removeStepPaneLogs(refs);
|
|
2343
|
+
} catch (error) {
|
|
2344
|
+
return context.json({ error: error instanceof Error ? error.message : String(error) }, 409);
|
|
2345
|
+
}
|
|
2346
|
+
return context.json({ deleted: true });
|
|
2347
|
+
});
|
|
2348
|
+
app.post(apiRoutes_default.openRunDirectory.path, async (context) => {
|
|
2349
|
+
if ((0, _agimon_ai_po_core_packageApi.doomApiCallerFrom)(context.req.raw.headers)?.locality !== "local") return context.json({ error: "Opening Finder is only available on the host." }, 403);
|
|
2350
|
+
const workspace = context.req.param("workspace");
|
|
2351
|
+
const runKey = context.req.param("runKey");
|
|
2352
|
+
const record = await runOf(workspace, runKey);
|
|
2353
|
+
if (record === void 0) return context.json(missing(workspace, runKey), 404);
|
|
2354
|
+
if (process.platform !== "darwin") return context.json({ error: "Finder is only available on macOS." }, 409);
|
|
2355
|
+
try {
|
|
2356
|
+
const runDir = node_path.default.resolve(registry.runDirectoryFor(record));
|
|
2357
|
+
if (!node_fs.default.statSync(runDir).isDirectory()) return context.json({ error: "The run directory is not a folder." }, 409);
|
|
2358
|
+
await new Promise((resolve, reject) => {
|
|
2359
|
+
(0, node_child_process.execFile)("/usr/bin/open", [
|
|
2360
|
+
"-a",
|
|
2361
|
+
"Finder",
|
|
2362
|
+
runDir
|
|
2363
|
+
], { timeout: 5e3 }, (error) => {
|
|
2364
|
+
if (error) reject(error);
|
|
2365
|
+
else resolve();
|
|
2366
|
+
});
|
|
2367
|
+
});
|
|
2368
|
+
return context.json({ opened: true });
|
|
2369
|
+
} catch (error) {
|
|
2370
|
+
return context.json({ error: error instanceof Error ? error.message : String(error) }, 409);
|
|
2371
|
+
}
|
|
2372
|
+
});
|
|
2373
|
+
app.get(apiRoutes_default.artifacts.path, async (context) => {
|
|
2374
|
+
const workspace = context.req.param("workspace");
|
|
2375
|
+
const runKey = context.req.param("runKey");
|
|
2376
|
+
const record = await runOf(workspace, runKey);
|
|
2377
|
+
if (record === void 0) return context.json(missing(workspace, runKey), 404);
|
|
2378
|
+
const runDir = registry.runDirectoryFor(record);
|
|
2379
|
+
const directory = context.req.query("directory") ?? "";
|
|
2380
|
+
if (resolveInside(runDir, directory) === void 0) return context.json({ error: "That folder is not inside this run." }, 400);
|
|
2381
|
+
try {
|
|
2382
|
+
const body = {
|
|
2383
|
+
runDir,
|
|
2384
|
+
description: record.runDirectory?.description ?? "",
|
|
2385
|
+
artifacts: readArtifacts(runDir, record, directory)
|
|
2386
|
+
};
|
|
2387
|
+
return context.json(body);
|
|
2388
|
+
} catch (error) {
|
|
2389
|
+
return context.json({ error: error instanceof Error ? error.message : String(error) }, 409);
|
|
2390
|
+
}
|
|
2391
|
+
});
|
|
2392
|
+
app.get(apiRoutes_default.artifact.path, async (context) => {
|
|
2393
|
+
const workspace = context.req.param("workspace");
|
|
2394
|
+
const runKey = context.req.param("runKey");
|
|
2395
|
+
const record = await runOf(workspace, runKey);
|
|
2396
|
+
if (record === void 0) return context.json(missing(workspace, runKey), 404);
|
|
2397
|
+
const runDir = registry.runDirectoryFor(record);
|
|
2398
|
+
const requested = context.req.param("name");
|
|
2399
|
+
const resolved = resolveInside(runDir, requested);
|
|
2400
|
+
if (resolved === void 0) return context.json({ error: `'${requested}' is not inside this run.` }, 400);
|
|
2401
|
+
if (context.req.query("raw") === "1") return streamArtifact(resolved, requested, context.req.header("range"), context.req.query("download") === "1") ?? context.json({ error: `'${requested}' has not been written.` }, 404);
|
|
2402
|
+
const body = readArtifact(resolved, requested);
|
|
2403
|
+
if (body === void 0) return context.json({ error: `'${requested}' has not been written.` }, 404);
|
|
2404
|
+
return context.json(body);
|
|
2405
|
+
});
|
|
2406
|
+
return app;
|
|
2407
|
+
}
|
|
2408
|
+
/**
|
|
2409
|
+
* A path inside the run directory, or undefined when it points anywhere else.
|
|
2410
|
+
*
|
|
2411
|
+
* The client supplies this one verbatim, unlike the run key, so it is resolved
|
|
2412
|
+
* and then checked rather than trusted: without the check the route would read
|
|
2413
|
+
* any file the hub can reach.
|
|
2414
|
+
*/
|
|
2415
|
+
function resolveInside(runDir, requested) {
|
|
2416
|
+
const resolved = node_path.default.resolve(runDir, requested);
|
|
2417
|
+
const root = node_path.default.resolve(runDir);
|
|
2418
|
+
if (resolved !== root && !resolved.startsWith(`${root}${node_path.default.sep}`)) return void 0;
|
|
2419
|
+
try {
|
|
2420
|
+
const realRoot = node_fs.default.realpathSync(root);
|
|
2421
|
+
let ancestor = resolved;
|
|
2422
|
+
while (!node_fs.default.existsSync(ancestor)) {
|
|
2423
|
+
const parent = node_path.default.dirname(ancestor);
|
|
2424
|
+
if (parent === ancestor) return void 0;
|
|
2425
|
+
ancestor = parent;
|
|
2426
|
+
}
|
|
2427
|
+
const real = node_fs.default.realpathSync(ancestor);
|
|
2428
|
+
if (real !== realRoot && !real.startsWith(`${realRoot}${node_path.default.sep}`)) return void 0;
|
|
2429
|
+
return resolved;
|
|
2430
|
+
} catch {
|
|
2431
|
+
return;
|
|
2432
|
+
}
|
|
2433
|
+
}
|
|
2434
|
+
/** Size and modification time of one path, or undefined when it is not there. */
|
|
2435
|
+
function statOf(target) {
|
|
2436
|
+
try {
|
|
2437
|
+
return node_fs.default.statSync(target);
|
|
2438
|
+
} catch {
|
|
2439
|
+
return;
|
|
2440
|
+
}
|
|
2441
|
+
}
|
|
2442
|
+
function stateOf(stats) {
|
|
2443
|
+
if (stats === void 0) return "pending";
|
|
2444
|
+
if (stats.isDirectory()) return "written";
|
|
2445
|
+
return stats.size === 0 ? "empty" : "written";
|
|
2446
|
+
}
|
|
2447
|
+
function viewOf(runDir, entry) {
|
|
2448
|
+
const target = resolveInside(runDir, entry.path);
|
|
2449
|
+
const stats = target === void 0 ? void 0 : statOf(target);
|
|
2450
|
+
return {
|
|
2451
|
+
...entry,
|
|
2452
|
+
kind: stats?.isDirectory() ? "directory" : entry.kind,
|
|
2453
|
+
state: target === void 0 ? "unreadable" : stateOf(stats),
|
|
2454
|
+
...stats === void 0 ? {} : {
|
|
2455
|
+
size: stats.size,
|
|
2456
|
+
modifiedAt: stats.mtime.toISOString()
|
|
2457
|
+
}
|
|
2458
|
+
};
|
|
2459
|
+
}
|
|
2460
|
+
/**
|
|
2461
|
+
* What the run directory holds: the workflow's own declaration first, then the
|
|
2462
|
+
* files nobody declared.
|
|
2463
|
+
*
|
|
2464
|
+
* The declaration is the point of the folder, so it leads and keeps its order
|
|
2465
|
+
* even for entries no job has written yet. The rest follows rather than being
|
|
2466
|
+
* hidden: the engine's own context.md and progress log are often exactly what a
|
|
2467
|
+
* reader is looking for.
|
|
2468
|
+
*/
|
|
2469
|
+
function readArtifacts(runDir, record, directory = "") {
|
|
2470
|
+
const declared = (record.runDirectory?.entries ?? []).map((entry) => viewOf(runDir, {
|
|
2471
|
+
path: entry.path,
|
|
2472
|
+
kind: entry.kind,
|
|
2473
|
+
description: entry.description,
|
|
2474
|
+
producedBy: entry["produced-by"] ?? [],
|
|
2475
|
+
declared: true
|
|
2476
|
+
}));
|
|
2477
|
+
const children = declared.filter((entry) => node_path.default.posix.dirname(entry.path) === (directory || "."));
|
|
2478
|
+
const claimed = new Set(children.map((entry) => entry.path));
|
|
2479
|
+
const found = node_fs.default.readdirSync(node_path.default.join(runDir, directory), { withFileTypes: true }).filter((entry) => !claimed.has(node_path.default.posix.join(directory, entry.name))).map((entry) => viewOf(runDir, {
|
|
2480
|
+
path: node_path.default.posix.join(directory, entry.name),
|
|
2481
|
+
kind: entry.isDirectory() ? "directory" : "file",
|
|
2482
|
+
description: "",
|
|
2483
|
+
producedBy: [],
|
|
2484
|
+
declared: false
|
|
2485
|
+
})).sort((left, right) => left.path.localeCompare(right.path));
|
|
2486
|
+
return [...directory === "" ? declared : children, ...found];
|
|
2487
|
+
}
|
|
2488
|
+
/** Browser content type inferred from a filename without trusting client input. */
|
|
2489
|
+
function artifactMimeType(requested) {
|
|
2490
|
+
return ARTIFACT_MIME_TYPES[node_path.default.extname(requested).toLowerCase()] ?? "application/octet-stream";
|
|
2491
|
+
}
|
|
2492
|
+
function isTextArtifact(mimeType) {
|
|
2493
|
+
return mimeType.startsWith("text/") || mimeType === "application/json" || mimeType === "application/xml";
|
|
2494
|
+
}
|
|
2495
|
+
/** One artifact's metadata and bounded text, or undefined when it is not readable. */
|
|
2496
|
+
function readArtifact(resolved, requested) {
|
|
2497
|
+
const stats = statOf(resolved);
|
|
2498
|
+
if (stats === void 0 || stats.isDirectory()) return void 0;
|
|
2499
|
+
const mimeType = artifactMimeType(requested);
|
|
2500
|
+
let text;
|
|
2501
|
+
if (isTextArtifact(mimeType)) try {
|
|
2502
|
+
const handle = node_fs.default.openSync(resolved, "r");
|
|
2503
|
+
try {
|
|
2504
|
+
const buffer = Buffer.alloc(Math.min(stats.size, MAX_ARTIFACT_BYTES));
|
|
2505
|
+
node_fs.default.readSync(handle, buffer, 0, buffer.length, 0);
|
|
2506
|
+
text = buffer.toString("utf8");
|
|
2507
|
+
} finally {
|
|
2508
|
+
node_fs.default.closeSync(handle);
|
|
2509
|
+
}
|
|
2510
|
+
} catch {
|
|
2511
|
+
return;
|
|
2512
|
+
}
|
|
2513
|
+
return {
|
|
2514
|
+
path: requested,
|
|
2515
|
+
size: stats.size,
|
|
2516
|
+
modifiedAt: stats.mtime.toISOString(),
|
|
2517
|
+
mimeType,
|
|
2518
|
+
...text === void 0 ? {} : { text },
|
|
2519
|
+
truncated: text !== void 0 && stats.size > MAX_ARTIFACT_BYTES
|
|
2520
|
+
};
|
|
2521
|
+
}
|
|
2522
|
+
/** Streams media with byte-range support so browser audio, video, and PDF controls can seek. */
|
|
2523
|
+
function streamArtifact(resolved, requested, rangeHeader, download) {
|
|
2524
|
+
const stats = statOf(resolved);
|
|
2525
|
+
if (stats === void 0 || stats.isDirectory()) return void 0;
|
|
2526
|
+
const mimeType = artifactMimeType(requested);
|
|
2527
|
+
let start = 0;
|
|
2528
|
+
let end = Math.max(0, stats.size - 1);
|
|
2529
|
+
let partial = false;
|
|
2530
|
+
const match = rangeHeader?.match(/^bytes=(\d*)-(\d*)$/);
|
|
2531
|
+
if (match !== null && match !== void 0 && stats.size > 0) {
|
|
2532
|
+
const requestedStart = match[1] === "" ? void 0 : Number(match[1]);
|
|
2533
|
+
const requestedEnd = match[2] === "" ? void 0 : Number(match[2]);
|
|
2534
|
+
if (requestedStart === void 0 && requestedEnd !== void 0) start = Math.max(0, stats.size - requestedEnd);
|
|
2535
|
+
else if (requestedStart !== void 0) {
|
|
2536
|
+
start = requestedStart;
|
|
2537
|
+
end = requestedEnd === void 0 ? end : Math.min(requestedEnd, end);
|
|
2538
|
+
}
|
|
2539
|
+
if (start >= stats.size || start > end) return new Response(null, {
|
|
2540
|
+
status: 416,
|
|
2541
|
+
headers: { "content-range": `bytes */${String(stats.size)}` }
|
|
2542
|
+
});
|
|
2543
|
+
partial = true;
|
|
2544
|
+
}
|
|
2545
|
+
const filename = node_path.default.basename(requested).replace(/["\\\r\n]/g, "_");
|
|
2546
|
+
const headers = new Headers({
|
|
2547
|
+
"accept-ranges": "bytes",
|
|
2548
|
+
"content-disposition": `${download || mimeType === "text/html" ? "attachment" : "inline"}; filename="${filename}"`,
|
|
2549
|
+
"content-length": String(stats.size === 0 ? 0 : end - start + 1),
|
|
2550
|
+
"content-type": mimeType,
|
|
2551
|
+
"x-content-type-options": "nosniff"
|
|
2552
|
+
});
|
|
2553
|
+
if (partial) headers.set("content-range", `bytes ${String(start)}-${String(end)}/${String(stats.size)}`);
|
|
2554
|
+
if (stats.size === 0) return new Response(null, {
|
|
2555
|
+
status: 200,
|
|
2556
|
+
headers
|
|
2557
|
+
});
|
|
2558
|
+
const body = node_stream.Readable.toWeb(node_fs.default.createReadStream(resolved, {
|
|
2559
|
+
start,
|
|
2560
|
+
end
|
|
2561
|
+
}));
|
|
2562
|
+
return new Response(body, {
|
|
2563
|
+
status: partial ? 206 : 200,
|
|
2564
|
+
headers
|
|
2565
|
+
});
|
|
2566
|
+
}
|
|
2567
|
+
/**
|
|
2568
|
+
* This package's API, mounted by a session with its runtime's `launch` and by
|
|
2569
|
+
* the hub without one.
|
|
2570
|
+
*/
|
|
2571
|
+
function createWorkflowApi(launch) {
|
|
2572
|
+
return {
|
|
2573
|
+
basePath: WORKFLOW_API_BASE_PATH,
|
|
2574
|
+
start(context) {
|
|
2575
|
+
const steer = context.sessionService?.steer?.bind(context.sessionService);
|
|
2576
|
+
const app = createWorkflowHubApi({
|
|
2577
|
+
...steer === void 0 ? {} : { steer: (sessionId, message) => steer(sessionId, message) },
|
|
2578
|
+
...launch === void 0 ? {} : { launch }
|
|
2579
|
+
});
|
|
2580
|
+
return {
|
|
2581
|
+
fetch: (request) => app.fetch(request),
|
|
2582
|
+
close: () => void 0
|
|
2583
|
+
};
|
|
2584
|
+
}
|
|
2585
|
+
};
|
|
2586
|
+
}
|
|
2587
|
+
/** The named export a host imports from this package's built hub entry. */
|
|
2588
|
+
const api = createWorkflowApi();
|
|
2589
|
+
//#endregion
|
|
2590
|
+
//#region src/services/workflowNarration/index.ts
|
|
2591
|
+
const RUNNING_STAGE = "running";
|
|
2592
|
+
const COMPLETED_STAGE = "completed";
|
|
2593
|
+
const ERROR_STAGE = "error";
|
|
2594
|
+
/**
|
|
2595
|
+
* Translate an observed workflow lifecycle transition into generic speech.
|
|
2596
|
+
* Voice owns delivery and ignores requests while its minor mode is inactive.
|
|
2597
|
+
*/
|
|
2598
|
+
function narrateWorkflowTransition(requester, record, previousStage, startedDuringSession) {
|
|
2599
|
+
const newlyLaunched = previousStage === void 0 && startedDuringSession;
|
|
2600
|
+
if (newlyLaunched) requester.narrate(`Workflow launched: ${record.displayName}.`);
|
|
2601
|
+
if (previousStage !== RUNNING_STAGE && !newlyLaunched) return;
|
|
2602
|
+
if (record.stage === COMPLETED_STAGE) requester.narrate(`Workflow succeeded: ${record.displayName}.`);
|
|
2603
|
+
if (record.stage === ERROR_STAGE) requester.narrate(`Workflow failed: ${record.displayName}.`);
|
|
2604
|
+
}
|
|
2605
|
+
//#endregion
|
|
2606
|
+
//#region src/services/workflowResource/index.ts
|
|
2607
|
+
function packageRoot() {
|
|
2608
|
+
let directory = node_path.default.dirname((0, node_url.fileURLToPath)(require("url").pathToFileURL(__filename).href));
|
|
2609
|
+
while (!(0, node_fs.existsSync)(node_path.default.join(directory, "package.json"))) {
|
|
2610
|
+
const parent = node_path.default.dirname(directory);
|
|
2611
|
+
if (parent === directory) throw new Error("Workflow package resources are unavailable.");
|
|
2612
|
+
directory = parent;
|
|
2613
|
+
}
|
|
2614
|
+
return directory;
|
|
2615
|
+
}
|
|
2616
|
+
async function readWorkflowSkill(name) {
|
|
2617
|
+
return (0, node_fs_promises.readFile)(node_path.default.join(packageRoot(), name === "workflow-recovery" ? "skills" : "src/prompts", name, "SKILL.md"), "utf8");
|
|
2618
|
+
}
|
|
2619
|
+
//#endregion
|
|
2620
|
+
//#region src/services/workflowSession/index.ts
|
|
2621
|
+
/**
|
|
2622
|
+
* The workflow session: the child session a launch creates to own and run one
|
|
2623
|
+
* workflow, nested under the session that launched it.
|
|
2624
|
+
*
|
|
2625
|
+
* DESIGN PATTERNS:
|
|
2626
|
+
* - Pure presenters for the rail line and the owner agent's brief
|
|
2627
|
+
* - A lifecycle driven by the run snapshots the runtime already publishes,
|
|
2628
|
+
* with every host effect injected
|
|
2629
|
+
*
|
|
2630
|
+
* AVOID:
|
|
2631
|
+
* - Posting or releasing on startup: a woken session reports nothing it did
|
|
2632
|
+
* not see change, so opening a stopped session never stops it again
|
|
2633
|
+
*/
|
|
2634
|
+
const MINUTE_MS = 6e4;
|
|
2635
|
+
const HOUR_MS = 36e5;
|
|
2636
|
+
/** A failure cause is a line in a notice, not the log; a longer one is cut. */
|
|
2637
|
+
const MAX_CAUSE = 1e3;
|
|
2638
|
+
const WORKFLOW_FILE_SUFFIX = /\.workflow\.ya?ml$|\.ya?ml$/u;
|
|
2639
|
+
/** Whether this session was created to own a workflow run. */
|
|
2640
|
+
function isWorkflowSession(context) {
|
|
2641
|
+
return context.sessionContext?.provenance === WORKFLOW_SESSION_PROVENANCE;
|
|
2642
|
+
}
|
|
2643
|
+
/** The rail name of a workflow session: the catalog's name, else the file's stem. */
|
|
2644
|
+
function workflowSessionName(workflowPath, catalogName) {
|
|
2645
|
+
if (catalogName !== void 0 && catalogName.trim() !== "") return catalogName.trim();
|
|
2646
|
+
return (0, node_path.basename)(workflowPath).replace(WORKFLOW_FILE_SUFFIX, "") || "workflow";
|
|
2647
|
+
}
|
|
2648
|
+
function isPaused(run) {
|
|
2649
|
+
return run.stage === "running" && (run.executionState === "paused" || run.executionState === "pause_requested");
|
|
2650
|
+
}
|
|
2651
|
+
function needsAttention(run) {
|
|
2652
|
+
return run.stage === "error" || run.stale === true || isPaused(run);
|
|
2653
|
+
}
|
|
2654
|
+
function succeeded(run) {
|
|
2655
|
+
return run.stage === "completed" && (run.outcome === void 0 || run.outcome === "success" || run.outcome === "skipped");
|
|
2656
|
+
}
|
|
2657
|
+
function duration(run) {
|
|
2658
|
+
const start = Date.parse(run.startedAt);
|
|
2659
|
+
const end = run.finishedAt === void 0 ? NaN : Date.parse(run.finishedAt);
|
|
2660
|
+
if (!Number.isFinite(start) || !Number.isFinite(end)) return void 0;
|
|
2661
|
+
const elapsed = Math.max(0, end - start);
|
|
2662
|
+
if (elapsed < MINUTE_MS) return "under a minute";
|
|
2663
|
+
if (elapsed < HOUR_MS) return `${Math.floor(elapsed / MINUTE_MS)}m`;
|
|
2664
|
+
return `${Math.floor(elapsed / HOUR_MS)}h ${String(Math.floor(elapsed % HOUR_MS / MINUTE_MS)).padStart(2, "0")}m`;
|
|
2665
|
+
}
|
|
2666
|
+
/**
|
|
2667
|
+
* The line the rail shows for a workflow session, in place of an idle agent's
|
|
2668
|
+
* copy. A failure or pause outranks a run in progress, because it is the one
|
|
2669
|
+
* that needs the reader.
|
|
2670
|
+
*/
|
|
2671
|
+
function workflowSessionActivity(runs) {
|
|
2672
|
+
if (runs.length === 0) return void 0;
|
|
2673
|
+
const failed = runs.find((run) => run.stage === "error");
|
|
2674
|
+
if (failed !== void 0) return {
|
|
2675
|
+
label: `workflow failed · ${failed.failedJob ?? failed.displayName}`,
|
|
2676
|
+
attention: true
|
|
2677
|
+
};
|
|
2678
|
+
const paused = runs.find(isPaused);
|
|
2679
|
+
if (paused !== void 0) return {
|
|
2680
|
+
label: paused.position === void 0 ? "workflow paused" : `workflow paused · ${paused.position.job}`,
|
|
2681
|
+
attention: true
|
|
2682
|
+
};
|
|
2683
|
+
if (runs.find((run) => run.stage === "running" && run.stale === true) !== void 0) return {
|
|
2684
|
+
label: "workflow interrupted",
|
|
2685
|
+
attention: true
|
|
2686
|
+
};
|
|
2687
|
+
const running = runs.find((run) => run.stage === "running");
|
|
2688
|
+
if (running !== void 0) {
|
|
2689
|
+
const where = [running.position?.job, running.position?.step].filter(Boolean).join(" › ");
|
|
2690
|
+
return {
|
|
2691
|
+
label: where === "" ? "workflow · starting" : `workflow · ${where}`,
|
|
2692
|
+
since: running.startedAt
|
|
2693
|
+
};
|
|
2694
|
+
}
|
|
2695
|
+
if (runs.every(succeeded)) return { label: "workflow succeeded" };
|
|
2696
|
+
return {
|
|
2697
|
+
label: `workflow ${runs[0].outcome ?? "ended"}`,
|
|
2698
|
+
attention: true
|
|
2699
|
+
};
|
|
2700
|
+
}
|
|
2701
|
+
function runState(run) {
|
|
2702
|
+
if (run.stage === "error") return "failed";
|
|
2703
|
+
if (run.stale === true) return "interrupted (its process is gone)";
|
|
2704
|
+
if (isPaused(run)) return "paused";
|
|
2705
|
+
if (run.stage === "running") return "running";
|
|
2706
|
+
return run.outcome ?? "completed";
|
|
2707
|
+
}
|
|
2708
|
+
/**
|
|
2709
|
+
* The owner agent's live brief, added to its system prompt on every turn.
|
|
2710
|
+
*
|
|
2711
|
+
* Read from the registry each time rather than written once, so it survives a
|
|
2712
|
+
* wake and never describes a run that has since moved on.
|
|
2713
|
+
*/
|
|
2714
|
+
function workflowSessionBrief(runs) {
|
|
2715
|
+
if (runs.length === 0) return "";
|
|
2716
|
+
return [
|
|
2717
|
+
"# Workflow session",
|
|
2718
|
+
"You are the owner agent of this workflow session. It runs the workflow below for the session that launched it, and you troubleshoot it.",
|
|
2719
|
+
...runs.map((run) => {
|
|
2720
|
+
const where = [run.position?.job, run.position?.step].filter(Boolean).join(" › ");
|
|
2721
|
+
const failure = run.stage === "error" ? `; ${run.failedJob === void 0 ? "" : `job '${run.failedJob}' `}failed: ${run.errorMessage ?? "no cause recorded"}` : "";
|
|
2722
|
+
return `- ${run.displayName} (runKey ${run.runKey}, workspace ${run.workspace}): ${runState(run)}${where === "" ? "" : ` at ${where}`}${failure}`;
|
|
2723
|
+
}),
|
|
2724
|
+
"On a workflow error, diagnose it locally and summarize the cause here. Do not wake the launcher to diagnose it, automatically restart it, or diagnose a user-requested stop. When the user or launcher session asks: use workflow_run status with the runKey and workspace for progress, workflow_tools recovery-evidence to read a failure, and load the workflow-recovery skill before workflow_tools recover. Recover, pause, resume or stop a run only when the user asks or the launcher explicitly delegates it through intercom."
|
|
2725
|
+
].join("\n");
|
|
2726
|
+
}
|
|
2727
|
+
function failureNotice(run) {
|
|
2728
|
+
const cause = (run.errorMessage ?? "the run ended in the error stage").slice(0, MAX_CAUSE);
|
|
2729
|
+
const job = run.failedJob === void 0 ? "" : ` at job "${run.failedJob}"`;
|
|
2730
|
+
return {
|
|
2731
|
+
title: "Workflow failed",
|
|
2732
|
+
body: `Workflow "${run.displayName}" failed${job}: ${cause}. Ask me to diagnose or recover it.`,
|
|
2733
|
+
level: "error"
|
|
2734
|
+
};
|
|
2735
|
+
}
|
|
2736
|
+
function finishNotice(run) {
|
|
2737
|
+
const took = duration(run);
|
|
2738
|
+
if (succeeded(run)) return {
|
|
2739
|
+
title: "Workflow succeeded",
|
|
2740
|
+
body: `Workflow "${run.displayName}" ${run.outcome === "skipped" ? "was skipped" : "succeeded"}${took === void 0 ? "" : ` in ${took}`}.`,
|
|
2741
|
+
level: "info"
|
|
2742
|
+
};
|
|
2743
|
+
return {
|
|
2744
|
+
title: "Workflow ended",
|
|
2745
|
+
body: `Workflow "${run.displayName}" ended: ${run.outcome ?? "no outcome recorded"}. Ask me to look into it.`,
|
|
2746
|
+
level: "error"
|
|
2747
|
+
};
|
|
2748
|
+
}
|
|
2749
|
+
const identity = (run) => `${run.workspace}/${run.runKey}`;
|
|
2750
|
+
/**
|
|
2751
|
+
* What a workflow session does as its runs move: keep the rail line current,
|
|
2752
|
+
* post a notice when a handed-off run fails or finishes, and ask its parent to
|
|
2753
|
+
* release it after an untouched success.
|
|
2754
|
+
*
|
|
2755
|
+
* "Untouched" means no run ever needed attention and nobody started a turn
|
|
2756
|
+
* here. A session you are reading or troubleshooting stays live until you
|
|
2757
|
+
* remove it.
|
|
2758
|
+
*/
|
|
2759
|
+
function createWorkflowSessionLifecycle(deps) {
|
|
2760
|
+
const stages = /* @__PURE__ */ new Map();
|
|
2761
|
+
let seeded = false;
|
|
2762
|
+
let attention = false;
|
|
2763
|
+
let engaged = false;
|
|
2764
|
+
let requested = false;
|
|
2765
|
+
let disposed = false;
|
|
2766
|
+
let stopWaiting;
|
|
2767
|
+
const releasable = (runs) => deps.parentSessionId !== void 0 && !attention && !engaged && !disposed && runs.length > 0 && runs.every(succeeded) && runs.some((run) => run.launcherSessionId === deps.parentSessionId);
|
|
2768
|
+
const requestRelease = async (runs) => {
|
|
2769
|
+
const parent = deps.parentSessionId;
|
|
2770
|
+
if (parent === void 0 || requested || deps.requestRelease === void 0 || !releasable(runs)) return;
|
|
2771
|
+
if (!await deps.isIdle() || disposed) return;
|
|
2772
|
+
requested = true;
|
|
2773
|
+
const runKeys = runs.map((run) => run.runKey);
|
|
2774
|
+
if (deps.requestRelease(parent, runKeys)) return;
|
|
2775
|
+
stopWaiting = deps.onPeerReady?.((peer) => {
|
|
2776
|
+
if (peer !== parent) return;
|
|
2777
|
+
stopWaiting?.();
|
|
2778
|
+
stopWaiting = void 0;
|
|
2779
|
+
if (releasable(runs)) deps.requestRelease?.(parent, runKeys);
|
|
2780
|
+
});
|
|
2781
|
+
};
|
|
2782
|
+
return {
|
|
2783
|
+
async observe(runs) {
|
|
2784
|
+
if (disposed) return;
|
|
2785
|
+
deps.publishActivity?.(workflowSessionActivity(runs));
|
|
2786
|
+
if (runs.some(needsAttention)) attention = true;
|
|
2787
|
+
if (!seeded) {
|
|
2788
|
+
for (const run of runs) stages.set(identity(run), run.stage);
|
|
2789
|
+
seeded = true;
|
|
2790
|
+
return;
|
|
2791
|
+
}
|
|
2792
|
+
let finished = false;
|
|
2793
|
+
for (const run of runs) {
|
|
2794
|
+
const previous = stages.get(identity(run));
|
|
2795
|
+
stages.set(identity(run), run.stage);
|
|
2796
|
+
if (previous === run.stage || run.launcherSessionId === void 0) continue;
|
|
2797
|
+
if (run.stage === "error") await deps.postNotice(failureNotice(run));
|
|
2798
|
+
else if (run.stage === "completed") {
|
|
2799
|
+
await deps.postNotice(finishNotice(run));
|
|
2800
|
+
finished = true;
|
|
2801
|
+
}
|
|
2802
|
+
}
|
|
2803
|
+
if (finished) await requestRelease(runs);
|
|
2804
|
+
},
|
|
2805
|
+
agentStarted() {
|
|
2806
|
+
engaged = true;
|
|
2807
|
+
},
|
|
2808
|
+
dispose() {
|
|
2809
|
+
disposed = true;
|
|
2810
|
+
stopWaiting?.();
|
|
2811
|
+
stopWaiting = void 0;
|
|
2812
|
+
}
|
|
2813
|
+
};
|
|
2814
|
+
}
|
|
2815
|
+
//#endregion
|
|
2816
|
+
//#region src/services/workflowWatcher/index.ts
|
|
2817
|
+
const { parser } = (0, _agimon_ai_workflow_mcp.createEmbeddedWorkflowFeature)();
|
|
2818
|
+
/**
|
|
2819
|
+
* Read the durable workflow registry once.
|
|
2820
|
+
*
|
|
2821
|
+
* The registry is an initial snapshot only. Live delivery belongs to the
|
|
2822
|
+
* session lifecycle and is routed through the host-owned direct event bus.
|
|
2823
|
+
*/
|
|
2824
|
+
function readWorkflowRuns(options = {}) {
|
|
2825
|
+
const environment = options.environment;
|
|
2826
|
+
const homeDir = options.homeDir ?? resolveWorkflowHome({
|
|
2827
|
+
envValue: environment?.["WORKFLOW_MCP_HOME"],
|
|
2828
|
+
homeDir: node_os.default.homedir()
|
|
2829
|
+
});
|
|
2830
|
+
const workspacesDir = node_path.default.join(homeDir, WORKSPACES_DIR_NAME);
|
|
2831
|
+
const recordCache = /* @__PURE__ */ new Map();
|
|
2832
|
+
const progressCache = /* @__PURE__ */ new Map();
|
|
2833
|
+
const cachedParse = (cache, filePath, parse, empty) => {
|
|
2834
|
+
let stat;
|
|
2835
|
+
try {
|
|
2836
|
+
stat = node_fs.default.statSync(filePath);
|
|
2837
|
+
} catch {
|
|
2838
|
+
cache.delete(filePath);
|
|
2839
|
+
return empty;
|
|
2840
|
+
}
|
|
2841
|
+
const cached = cache.get(filePath);
|
|
2842
|
+
if (cached && cached.size === stat.size && cached.mtimeMs === stat.mtimeMs) return cached.value;
|
|
2843
|
+
let raw;
|
|
2844
|
+
try {
|
|
2845
|
+
raw = node_fs.default.readFileSync(filePath, "utf8");
|
|
2846
|
+
} catch {
|
|
2847
|
+
cache.delete(filePath);
|
|
2848
|
+
return empty;
|
|
2849
|
+
}
|
|
2850
|
+
const value = parse(raw);
|
|
2851
|
+
cache.set(filePath, {
|
|
2852
|
+
size: stat.size,
|
|
2853
|
+
mtimeMs: stat.mtimeMs,
|
|
2854
|
+
value
|
|
2855
|
+
});
|
|
2856
|
+
return value;
|
|
2857
|
+
};
|
|
2858
|
+
const listDir = (directory) => {
|
|
2859
|
+
try {
|
|
2860
|
+
return node_fs.default.readdirSync(directory).sort();
|
|
2861
|
+
} catch {
|
|
2862
|
+
return [];
|
|
2863
|
+
}
|
|
2864
|
+
};
|
|
2865
|
+
const plans = /* @__PURE__ */ new Map();
|
|
2866
|
+
const planFor = (workflowPath) => {
|
|
2867
|
+
const cached = plans.get(workflowPath);
|
|
2868
|
+
if (cached !== void 0) return cached;
|
|
2869
|
+
let plan = [];
|
|
2870
|
+
try {
|
|
2871
|
+
const workflow = parser.parseWorkflowFile(workflowPath);
|
|
2872
|
+
plan = planWorkflowJobs(workflow, parser.resolveJobOrder(workflow.jobs, null));
|
|
2873
|
+
} catch {}
|
|
2874
|
+
plans.set(workflowPath, plan);
|
|
2875
|
+
return plan;
|
|
2876
|
+
};
|
|
2877
|
+
const runs = [];
|
|
2878
|
+
for (const workspace of listDir(workspacesDir)) for (const stage of WORKFLOW_STAGES) {
|
|
2879
|
+
const stageDir = node_path.default.join(workspacesDir, workspace, stage);
|
|
2880
|
+
for (const runKey of listDir(stageDir)) {
|
|
2881
|
+
const runDir = node_path.default.join(stageDir, runKey);
|
|
2882
|
+
const recordPath = node_path.default.join(runDir, RUN_RECORD_FILE_NAME);
|
|
2883
|
+
const progressPath = node_path.default.join(runDir, PROGRESS_FILE_NAME);
|
|
2884
|
+
const parsed = cachedParse(recordCache, recordPath, parseWorkflowRunRecord, void 0);
|
|
2885
|
+
if (parsed === void 0) continue;
|
|
2886
|
+
const jobs = foldWorkflowProgress(cachedParse(progressCache, progressPath, parseWorkflowProgress, []), planFor(parsed.view.workflowPath));
|
|
2887
|
+
runs.push({
|
|
2888
|
+
...parsed,
|
|
2889
|
+
view: completeWorkflowRunView(parsed.view, jobs)
|
|
2890
|
+
});
|
|
2891
|
+
}
|
|
2892
|
+
}
|
|
2893
|
+
return runs;
|
|
2894
|
+
}
|
|
2895
|
+
//#endregion
|
|
2896
|
+
//#region src/services/serverRuntime/index.ts
|
|
2897
|
+
const SOURCE = "@agimon-ai/po-workflow";
|
|
2898
|
+
const WORKFLOW_MODE_ID = "workflow";
|
|
2899
|
+
const LIST_TOOL = "list_workflows";
|
|
2900
|
+
const LAUNCH_TOOL = "launch_workflow";
|
|
2901
|
+
const RUN_TOOL = "workflow_run";
|
|
2902
|
+
/** Origin of a request dispatched in process to another mount; nothing resolves it. */
|
|
2903
|
+
const IN_PROCESS_ORIGIN = "http://po.local";
|
|
2904
|
+
/** A burst of job and step events is published once, this long after the first. */
|
|
2905
|
+
const RUNS_PUBLISH_DEBOUNCE_MS = 100;
|
|
2906
|
+
/** A notification body's limit; a longer failure is cut to it. */
|
|
2907
|
+
const NOTICE_BODY_LIMIT = 4e3;
|
|
2908
|
+
/**
|
|
2909
|
+
* A launch refused because the workflow needs fixing. The doctor's findings
|
|
2910
|
+
* name only a job and a step, so the notice names the workflow and says
|
|
2911
|
+
* nothing ran; in a conversation it otherwise reads as part of whatever run
|
|
2912
|
+
* was reported just before it.
|
|
2913
|
+
*/
|
|
2914
|
+
function needsFixingNotice(workflow, error) {
|
|
2915
|
+
return `Workflow "${workflow}" was not launched: it needs fixing.\n${error}`;
|
|
2916
|
+
}
|
|
2917
|
+
function textResult(text, isError = false) {
|
|
2918
|
+
return {
|
|
2919
|
+
content: [{
|
|
2920
|
+
type: "text",
|
|
2921
|
+
text
|
|
2922
|
+
}],
|
|
2923
|
+
...isError ? { isError: true } : {}
|
|
2924
|
+
};
|
|
2925
|
+
}
|
|
2926
|
+
function isLaunchResponse(value) {
|
|
2927
|
+
return typeof value === "object" && value !== null && "text" in value && typeof value.text === "string";
|
|
2928
|
+
}
|
|
2929
|
+
function errorOf(value) {
|
|
2930
|
+
return typeof value === "object" && value !== null && "error" in value && typeof value.error === "string" ? value.error : void 0;
|
|
2931
|
+
}
|
|
2932
|
+
function errorMessage(error) {
|
|
2933
|
+
return error instanceof Error ? error.message : String(error);
|
|
2934
|
+
}
|
|
2935
|
+
/** The text of a launch's tool result, as the launch tool and the API report it. */
|
|
2936
|
+
function launchResultOf(result) {
|
|
2937
|
+
return textResult(result.content.map((item) => item.type === "text" ? item.text : "").filter((line) => line !== "").join("\n") || "Workflow launch requested.", result.isError === true);
|
|
2938
|
+
}
|
|
2939
|
+
function callResult(value) {
|
|
2940
|
+
const content = [];
|
|
2941
|
+
if (typeof value === "object" && value !== null && "content" in value && Array.isArray(value.content)) for (const item of value.content) {
|
|
2942
|
+
if (typeof item !== "object" || item === null || !("type" in item)) continue;
|
|
2943
|
+
const record = item;
|
|
2944
|
+
if (record.type === "text" && typeof record.text === "string") content.push({
|
|
2945
|
+
type: "text",
|
|
2946
|
+
text: record.text
|
|
2947
|
+
});
|
|
2948
|
+
else if (record.type === "image" && typeof record.data === "string" && typeof record.mimeType === "string") content.push({
|
|
2949
|
+
type: "image",
|
|
2950
|
+
data: record.data,
|
|
2951
|
+
mimeType: record.mimeType
|
|
2952
|
+
});
|
|
2953
|
+
}
|
|
2954
|
+
if (content.length === 0) content.push({
|
|
2955
|
+
type: "text",
|
|
2956
|
+
text: JSON.stringify(value, null, 2)
|
|
2957
|
+
});
|
|
2958
|
+
return {
|
|
2959
|
+
content,
|
|
2960
|
+
details: value,
|
|
2961
|
+
...typeof value === "object" && value !== null && "isError" in value && value.isError === true ? { isError: true } : {}
|
|
2962
|
+
};
|
|
2963
|
+
}
|
|
2964
|
+
function untilAborted(work, signal) {
|
|
2965
|
+
if (signal.aborted) return Promise.reject(signal.reason);
|
|
2966
|
+
return new Promise((resolveWork, rejectWork) => {
|
|
2967
|
+
const onAbort = () => rejectWork(signal.reason);
|
|
2968
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
2969
|
+
work.then(resolveWork, rejectWork).finally(() => signal.removeEventListener("abort", onAbort));
|
|
2970
|
+
});
|
|
2971
|
+
}
|
|
2972
|
+
function createWorkflowServerRuntime(host, serverHost) {
|
|
2973
|
+
if (serverHost.context.directEvents === void 0) throw new Error("Workflow headless facet requires the session direct event bus.");
|
|
2974
|
+
const directEvents = serverHost.context.directEvents;
|
|
2975
|
+
let disposed = false;
|
|
2976
|
+
let runProvider;
|
|
2977
|
+
let narration;
|
|
2978
|
+
let readiness;
|
|
2979
|
+
let ownedReadiness;
|
|
2980
|
+
let ready;
|
|
2981
|
+
const generation = (0, node_crypto.randomUUID)();
|
|
2982
|
+
const startupAbort = new AbortController();
|
|
2983
|
+
const sessionStartedAt = Date.now();
|
|
2984
|
+
let narrationReady = false;
|
|
2985
|
+
let previousStages = /* @__PURE__ */ new Map();
|
|
2986
|
+
let activeRunCount = 0;
|
|
2987
|
+
let stepGatePending = false;
|
|
2988
|
+
let serviceContext;
|
|
2989
|
+
const runIdentity = ({ view }) => view.runId ?? `${view.workspace}/${view.runKey}/${view.startedAt}`;
|
|
2990
|
+
const pendingCompletions = /* @__PURE__ */ new Map();
|
|
2991
|
+
const completionBatches = [];
|
|
2992
|
+
const deliveredCompletions = /* @__PURE__ */ new Set();
|
|
2993
|
+
let delivery;
|
|
2994
|
+
let deliveryRetry;
|
|
2995
|
+
const catalogDeps = defaultCatalogDeps();
|
|
2996
|
+
const catalogReader = createWorkflowCatalogReader(catalogDeps);
|
|
2997
|
+
const telemetry = (0, _agimon_ai_po_telemetry.createDoomTelemetry)({
|
|
2998
|
+
serviceName: "doom-workflow",
|
|
2999
|
+
packageName: SOURCE,
|
|
3000
|
+
cwd: host.context.cwd,
|
|
3001
|
+
env: host.context.environment,
|
|
3002
|
+
enableLogs: true,
|
|
3003
|
+
enableTraces: true
|
|
3004
|
+
});
|
|
3005
|
+
/** True when this session was created to own one workflow run for the session that launched it. */
|
|
3006
|
+
const workflowSession = isWorkflowSession(host.context);
|
|
3007
|
+
const communication = serverHost.context.sessionCommunication;
|
|
3008
|
+
const lifecycle = workflowSession ? createWorkflowSessionLifecycle({
|
|
3009
|
+
parentSessionId: host.context.sessionContext?.parentSessionId,
|
|
3010
|
+
publishActivity: (activity) => serverHost.context.publishActivity?.(activity),
|
|
3011
|
+
postNotice: async (notice) => {
|
|
3012
|
+
const data = (0, _agimon_ai_po_core_notification.createDoomNotificationEntryData)({
|
|
3013
|
+
title: notice.title,
|
|
3014
|
+
body: notice.body,
|
|
3015
|
+
level: notice.level
|
|
3016
|
+
});
|
|
3017
|
+
if (data !== void 0) await host.context.session.appendCustomEntry(_agimon_ai_po_core_notification.DOOM_NOTIFICATION_ENTRY_TYPE, data);
|
|
3018
|
+
},
|
|
3019
|
+
isIdle: async () => {
|
|
3020
|
+
const activity = await host.context.session.activity();
|
|
3021
|
+
return activity.isIdle && !activity.hasPendingMessages;
|
|
3022
|
+
},
|
|
3023
|
+
...communication === void 0 ? {} : {
|
|
3024
|
+
requestRelease: (parent, runKeys) => communication.publish(parent, WORKFLOW_SESSION_RELEASE_TYPE, { runKeys }),
|
|
3025
|
+
onPeerReady: (listener) => communication.onPeerReady(listener)
|
|
3026
|
+
}
|
|
3027
|
+
}) : void 0;
|
|
3028
|
+
const reportLifecycleFailure = (error) => {
|
|
3029
|
+
telemetry.recordWarning("doom_workflow.session_lifecycle_failed", error);
|
|
3030
|
+
};
|
|
3031
|
+
/** This session's runs, published to its panel and counted as its background work. */
|
|
3032
|
+
const publishRuns = (executionContext) => {
|
|
3033
|
+
const records = readWorkflowRuns({ environment: executionContext.environment }).filter((run) => runBelongsToSession(run, executionContext.sessionId) || runLaunchedBySession(run, executionContext.sessionId));
|
|
3034
|
+
const activeItems = records.filter((run) => run.view.stage === "running" && run.view.stale !== true).map((run) => ({
|
|
3035
|
+
id: `${run.view.workspace}/${run.view.runKey}`,
|
|
3036
|
+
sessionId: executionContext.sessionId
|
|
3037
|
+
}));
|
|
3038
|
+
activeRunCount = activeItems.length;
|
|
3039
|
+
const nextStages = /* @__PURE__ */ new Map();
|
|
3040
|
+
for (const record of records) {
|
|
3041
|
+
const { view } = record;
|
|
3042
|
+
const identity = runIdentity(record);
|
|
3043
|
+
const previous = previousStages.get(identity);
|
|
3044
|
+
nextStages.set(identity, view.stage);
|
|
3045
|
+
if (view.stage === "error" && previous !== "error" && runLaunchedBySession(record, executionContext.sessionId)) Promise.resolve(executionContext.client.notify({
|
|
3046
|
+
body: `Workflow "${view.displayName}" failed: ${view.errorMessage ?? "no cause recorded"}. Diagnose it in its workflow-owner session.`,
|
|
3047
|
+
level: "error"
|
|
3048
|
+
})).catch(reportLifecycleFailure);
|
|
3049
|
+
if (!dispatcher && view.stage !== "running" && !(view.stage === "error" && runLaunchedBySession(record, host.context.sessionId)) && !deliveredCompletions.has(identity) && (previous === "running" || previous === void 0 && Date.parse(view.startedAt) >= sessionStartedAt)) {
|
|
3050
|
+
if (!pendingCompletions.has(identity)) pendingCompletions.set(identity, {
|
|
3051
|
+
run: record,
|
|
3052
|
+
attempts: 0
|
|
3053
|
+
});
|
|
3054
|
+
}
|
|
3055
|
+
if (narrationReady && !disposed && narration !== void 0 && runBelongsToSession(record, executionContext.sessionId)) {
|
|
3056
|
+
const startedAt = Date.parse(view.startedAt);
|
|
3057
|
+
narrateWorkflowTransition({ narrate(text) {
|
|
3058
|
+
const request = (0, _agimon_ai_po_core_narration.createNarrationRequest)(text);
|
|
3059
|
+
if (request === void 0 || narration === void 0) return;
|
|
3060
|
+
try {
|
|
3061
|
+
Promise.resolve(narration.request(request)).catch((error) => telemetry.recordWarning("doom_workflow.narration_failed", error));
|
|
3062
|
+
} catch (error) {
|
|
3063
|
+
telemetry.recordWarning("doom_workflow.narration_failed", error);
|
|
3064
|
+
}
|
|
3065
|
+
} }, view, previous, previous === void 0 && (!Number.isFinite(startedAt) || startedAt >= sessionStartedAt));
|
|
3066
|
+
}
|
|
3067
|
+
}
|
|
3068
|
+
previousStages = nextStages;
|
|
3069
|
+
const retained = new Map(activeItems.map((item) => [item.id, item]));
|
|
3070
|
+
for (const { run } of pendingCompletions.values()) {
|
|
3071
|
+
const id = `${run.view.workspace}/${run.view.runKey}`;
|
|
3072
|
+
retained.set(id, {
|
|
3073
|
+
id,
|
|
3074
|
+
sessionId: executionContext.sessionId
|
|
3075
|
+
});
|
|
3076
|
+
}
|
|
3077
|
+
runProvider?.update([...retained.values()]);
|
|
3078
|
+
const runs = presentWorkflowRuns(records.map((run) => run.view), Date.now());
|
|
3079
|
+
if (disposed) return false;
|
|
3080
|
+
directEvents.publish(WORKFLOW_RUNS_TYPE, executionContext.sessionId, { runs });
|
|
3081
|
+
lifecycle?.observe(runs).catch(reportLifecycleFailure);
|
|
3082
|
+
return retained.size > 0;
|
|
3083
|
+
};
|
|
3084
|
+
/** Retain terminal work until its batch is durably posted or, when loud, admitted. */
|
|
3085
|
+
const deliverCompletions = (automatic = false) => {
|
|
3086
|
+
if (disposed || delivery !== void 0) return delivery ?? Promise.resolve();
|
|
3087
|
+
delivery = (async () => {
|
|
3088
|
+
const prepared = [];
|
|
3089
|
+
for (const [identity, pending] of pendingCompletions) {
|
|
3090
|
+
if (disposed) return;
|
|
3091
|
+
if (pending.batched || automatic && pending.attempts >= 5) continue;
|
|
3092
|
+
try {
|
|
3093
|
+
const view = pending.run.view;
|
|
3094
|
+
const records = await feature.registry.listRuns(view.workspace);
|
|
3095
|
+
if (disposed) return;
|
|
3096
|
+
const record = records.find((candidate) => candidate.runKey === view.runKey && candidate.stage === view.stage && (view.runId !== void 0 ? candidate.runId === view.runId : candidate.startedAt === view.startedAt));
|
|
3097
|
+
if (record === void 0) throw new Error(`Completion record is unavailable: ${identity}`);
|
|
3098
|
+
const events = record.stage === "completed" ? [] : await (0, _agimon_ai_workflow_mcp.readWorkflowProgress)(feature.registry.runDirectoryFor(record));
|
|
3099
|
+
if (disposed) return;
|
|
3100
|
+
const startedAt = Date.parse(record.startedAt);
|
|
3101
|
+
const summary = finishedRunSummary(record, (0, _agimon_ai_workflow_mcp.summarizeWorkflowProgress)(events.filter((event) => !Number.isFinite(startedAt) || !Number.isFinite(Date.parse(event.at)) || Date.parse(event.at) >= startedAt)));
|
|
3102
|
+
const text = isLaunchedRun(record, host.context.sessionId) ? `${summary}\n${launchedRunHint(record)}` : summary;
|
|
3103
|
+
prepared.push({
|
|
3104
|
+
identity,
|
|
3105
|
+
record,
|
|
3106
|
+
text
|
|
3107
|
+
});
|
|
3108
|
+
} catch (error) {
|
|
3109
|
+
if (disposed) return;
|
|
3110
|
+
pending.attempts += 1;
|
|
3111
|
+
await telemetry.recordError("doom_workflow.terminal_delivery_failed", error, { "workflow.pending_terminal_count": pendingCompletions.size });
|
|
3112
|
+
}
|
|
3113
|
+
}
|
|
3114
|
+
for (const quiet of [true, false]) {
|
|
3115
|
+
const members = prepared.filter(({ record }) => (isOwnedDelegatedRun(record, host.context.sessionId) && record.stage !== "error") === quiet);
|
|
3116
|
+
if (members.length === 0) continue;
|
|
3117
|
+
for (const { identity } of members) pendingCompletions.get(identity).batched = true;
|
|
3118
|
+
completionBatches.push({
|
|
3119
|
+
identities: members.map(({ identity }) => identity),
|
|
3120
|
+
outcomes: members.map(({ record }) => record.stage),
|
|
3121
|
+
quiet,
|
|
3122
|
+
requestId: `workflow-finished:${generation}:${members.map(({ identity }) => identity).join(",")}`,
|
|
3123
|
+
message: {
|
|
3124
|
+
type: "workflow-run-finished",
|
|
3125
|
+
text: members.map(({ text }) => text).join("\n\n"),
|
|
3126
|
+
display: true,
|
|
3127
|
+
details: {
|
|
3128
|
+
runIds: members.map(({ identity, record }) => record.runId ?? identity),
|
|
3129
|
+
runs: members.map(({ record }) => ({
|
|
3130
|
+
runKey: record.runKey,
|
|
3131
|
+
workspace: record.workspace,
|
|
3132
|
+
stage: record.stage,
|
|
3133
|
+
...record.workflowId ? { workflowId: record.workflowId } : {},
|
|
3134
|
+
...record.failedJob ? { failedJob: record.failedJob } : {},
|
|
3135
|
+
...record.errorMessage ? { error: record.errorMessage } : {}
|
|
3136
|
+
}))
|
|
3137
|
+
}
|
|
3138
|
+
},
|
|
3139
|
+
attempts: 0
|
|
3140
|
+
});
|
|
3141
|
+
}
|
|
3142
|
+
for (const batch of completionBatches.slice()) {
|
|
3143
|
+
if (disposed) return;
|
|
3144
|
+
if (automatic && batch.attempts >= 5) continue;
|
|
3145
|
+
try {
|
|
3146
|
+
if (batch.quiet) {
|
|
3147
|
+
if (host.context.session.appendMessage === void 0) throw new Error("Workflow completion requires informational message support.");
|
|
3148
|
+
await host.context.session.appendMessage(batch.message);
|
|
3149
|
+
} else {
|
|
3150
|
+
if (host.context.session.admitMessage === void 0) throw new Error("Workflow completion requires custom message admission support.");
|
|
3151
|
+
await host.context.session.admitMessage(batch.message, "steer", batch.requestId);
|
|
3152
|
+
}
|
|
3153
|
+
if (disposed) return;
|
|
3154
|
+
completionBatches.splice(completionBatches.indexOf(batch), 1);
|
|
3155
|
+
for (const identity of batch.identities) {
|
|
3156
|
+
deliveredCompletions.add(identity);
|
|
3157
|
+
pendingCompletions.delete(identity);
|
|
3158
|
+
}
|
|
3159
|
+
for (const outcome of batch.outcomes) telemetry.recordEvent("doom_workflow.run_transition", { outcome });
|
|
3160
|
+
publishRuns(host.context);
|
|
3161
|
+
} catch (error) {
|
|
3162
|
+
if (disposed) return;
|
|
3163
|
+
batch.attempts += 1;
|
|
3164
|
+
await telemetry.recordError("doom_workflow.terminal_delivery_failed", error, { "workflow.pending_terminal_count": pendingCompletions.size });
|
|
3165
|
+
}
|
|
3166
|
+
}
|
|
3167
|
+
})().finally(() => {
|
|
3168
|
+
delivery = void 0;
|
|
3169
|
+
if (!disposed && ([...pendingCompletions.values()].some((pending) => !pending.batched && pending.attempts < 5) || completionBatches.some((batch) => batch.attempts < 5)) && deliveryRetry === void 0) {
|
|
3170
|
+
deliveryRetry = setTimeout(() => {
|
|
3171
|
+
deliveryRetry = void 0;
|
|
3172
|
+
deliverCompletions(true);
|
|
3173
|
+
}, 1e3);
|
|
3174
|
+
deliveryRetry.unref?.();
|
|
3175
|
+
}
|
|
3176
|
+
});
|
|
3177
|
+
return delivery;
|
|
3178
|
+
};
|
|
3179
|
+
/** The workflows this session can launch. Runs moving on do not change them, so their events skip this. */
|
|
3180
|
+
const publishCatalog = async (executionContext) => {
|
|
3181
|
+
try {
|
|
3182
|
+
const workflows = presentWorkflowCatalog(await catalogReader.read(executionContext.cwd));
|
|
3183
|
+
if (disposed) return;
|
|
3184
|
+
directEvents.publish(WORKFLOW_CATALOG_TYPE, executionContext.sessionId, {
|
|
3185
|
+
cwd: executionContext.cwd,
|
|
3186
|
+
workflows
|
|
3187
|
+
});
|
|
3188
|
+
} catch (error) {
|
|
3189
|
+
if (disposed) return;
|
|
3190
|
+
const warning = error instanceof Error ? error.message : String(error);
|
|
3191
|
+
directEvents.publish(WORKFLOW_CATALOG_TYPE, executionContext.sessionId, {
|
|
3192
|
+
cwd: executionContext.cwd,
|
|
3193
|
+
workflows: [],
|
|
3194
|
+
warning
|
|
3195
|
+
});
|
|
3196
|
+
}
|
|
3197
|
+
};
|
|
3198
|
+
const publishLifecycle = async (executionContext) => {
|
|
3199
|
+
publishRuns(executionContext);
|
|
3200
|
+
await deliverCompletions();
|
|
3201
|
+
await publishCatalog(executionContext);
|
|
3202
|
+
return activeRunCount > 0 || pendingCompletions.size > 0;
|
|
3203
|
+
};
|
|
3204
|
+
let runsTimer;
|
|
3205
|
+
let runsAfter;
|
|
3206
|
+
/** Publishes the runs once for a burst of job and step events, which a fix loop can produce by the dozen. */
|
|
3207
|
+
const publishRunsSoon = (executionContext, after) => {
|
|
3208
|
+
if (disposed) return;
|
|
3209
|
+
if (after !== void 0) runsAfter = after;
|
|
3210
|
+
if (runsTimer !== void 0) return;
|
|
3211
|
+
runsTimer = setTimeout(() => {
|
|
3212
|
+
runsTimer = void 0;
|
|
3213
|
+
const done = runsAfter;
|
|
3214
|
+
runsAfter = void 0;
|
|
3215
|
+
try {
|
|
3216
|
+
publishRuns(executionContext);
|
|
3217
|
+
deliverCompletions().then(() => done?.(activeRunCount > 0 || pendingCompletions.size > 0));
|
|
3218
|
+
} catch (error) {
|
|
3219
|
+
telemetry.recordWarning("doom_workflow.publish_failed", error);
|
|
3220
|
+
}
|
|
3221
|
+
}, RUNS_PUBLISH_DEBOUNCE_MS);
|
|
3222
|
+
runsTimer.unref?.();
|
|
3223
|
+
};
|
|
3224
|
+
const sessionService = serverHost.context.sessionService;
|
|
3225
|
+
const featureOptions = sessionService === void 0 ? {} : { stepExecutor: createStepExecutor({
|
|
3226
|
+
sessionService,
|
|
3227
|
+
parentSessionId: host.context.sessionId,
|
|
3228
|
+
hostEnvironment: process.env,
|
|
3229
|
+
launchPane: createNativeStepPaneLauncher(void 0, telemetry),
|
|
3230
|
+
createId: () => `workflow-${(0, node_crypto.randomUUID)()}`,
|
|
3231
|
+
telemetry
|
|
3232
|
+
}) };
|
|
3233
|
+
const feature = (0, _agimon_ai_workflow_mcp.createEmbeddedWorkflowFeature)(featureOptions);
|
|
3234
|
+
const launcher = createServerLauncher({
|
|
3235
|
+
feature,
|
|
3236
|
+
createRecoverTool: (options) => (0, _agimon_ai_workflow_mcp.createEmbeddedWorkflowFeature)({
|
|
3237
|
+
...featureOptions,
|
|
3238
|
+
registry: feature.registry
|
|
3239
|
+
}).createRecoverTool(options),
|
|
3240
|
+
sessionId: host.context.sessionId,
|
|
3241
|
+
environment: host.context.environment,
|
|
3242
|
+
telemetry,
|
|
3243
|
+
notify: (body, level) => host.context.client.notify({
|
|
3244
|
+
body: body.slice(0, NOTICE_BODY_LIMIT),
|
|
3245
|
+
level
|
|
3246
|
+
}),
|
|
3247
|
+
onRunsChanged: () => publishRunsSoon(host.context)
|
|
3248
|
+
});
|
|
3249
|
+
/**
|
|
3250
|
+
* Why this session cannot run a launch, or undefined when it can.
|
|
3251
|
+
*
|
|
3252
|
+
* The checks the slash command makes through the catalog, made for every
|
|
3253
|
+
* launch whatever asked for it: the launch tool, another session, or the
|
|
3254
|
+
* browser. The workflow must be a file in the repository the launch comes
|
|
3255
|
+
* from, a worktree's own when it runs in one, and its runner, command,
|
|
3256
|
+
* choice, inputs and prompt must be ones it declares.
|
|
3257
|
+
*/
|
|
3258
|
+
const refuseLaunch = (input, where) => {
|
|
3259
|
+
if (typeof input.workflowPath !== "string" || input.workflowPath === "") return "A launch needs a workflowPath.";
|
|
3260
|
+
const workflowPath = (0, node_path.resolve)(where.cwd, input.workflowPath);
|
|
3261
|
+
const inside = (0, node_path.relative)(where.repoRoot, workflowPath);
|
|
3262
|
+
if (inside.startsWith("..") || (0, node_path.isAbsolute)(inside)) return `Workflow ${input.workflowPath} is outside this session's repository, so it cannot be launched here.`;
|
|
3263
|
+
const detail = catalogDeps.summarize(workflowPath);
|
|
3264
|
+
if (detail.error !== void 0) return needsFixingNotice(inside, detail.error);
|
|
3265
|
+
const problems = require_workflowLaunchCommand.validateWorkflowLaunch(detail, {
|
|
3266
|
+
inputs: input.inputs ?? {},
|
|
3267
|
+
...input.prompt === void 0 ? {} : { prompt: input.prompt },
|
|
3268
|
+
...(input.runner ?? input.cliAgent) === void 0 ? {} : { runner: input.runner ?? input.cliAgent },
|
|
3269
|
+
...input.command === void 0 ? {} : { command: input.command },
|
|
3270
|
+
...input.choice === void 0 ? {} : { choice: input.choice }
|
|
3271
|
+
});
|
|
3272
|
+
return problems.length === 0 ? void 0 : problems.join("\n");
|
|
3273
|
+
};
|
|
3274
|
+
/**
|
|
3275
|
+
* Run a workflow in this process and answer once it has registered.
|
|
3276
|
+
*
|
|
3277
|
+
* The launch goes through the same executor the Pi extension uses; see
|
|
3278
|
+
* `createServerLauncher` for what the server adds around it.
|
|
3279
|
+
*/
|
|
3280
|
+
const launchHere = async (parameters, where = host.context) => {
|
|
3281
|
+
let input;
|
|
3282
|
+
try {
|
|
3283
|
+
input = feature.runTool.getInputSchema().parse(parameters);
|
|
3284
|
+
} catch (error) {
|
|
3285
|
+
return textResult(`Error: ${errorMessage(error)}`, true);
|
|
3286
|
+
}
|
|
3287
|
+
const refusal = refuseLaunch(input, where);
|
|
3288
|
+
if (refusal !== void 0) return textResult(`Error: ${refusal}`, true);
|
|
3289
|
+
try {
|
|
3290
|
+
return launchResultOf(await launcher.launch({
|
|
3291
|
+
...input,
|
|
3292
|
+
workflowPath: (0, node_path.resolve)(where.cwd, input.workflowPath)
|
|
3293
|
+
}));
|
|
3294
|
+
} catch (error) {
|
|
3295
|
+
return textResult(`Error: ${errorMessage(error)}`, true);
|
|
3296
|
+
}
|
|
3297
|
+
};
|
|
3298
|
+
/**
|
|
3299
|
+
* Hand a launch to the session that owns this session's runs, through that
|
|
3300
|
+
* session's own mount of this package's API. The owner runs it, so the run
|
|
3301
|
+
* and its step sessions outlive this session, as a dispatcher's launches must.
|
|
3302
|
+
*/
|
|
3303
|
+
const launchIn = async (owner, parameters) => {
|
|
3304
|
+
if (serverHost.context.requestApi === void 0) return textResult(`Error: this host cannot reach session ${owner} to launch the workflow there.`, true);
|
|
3305
|
+
const response = await serverHost.context.requestApi({
|
|
3306
|
+
scope: "session",
|
|
3307
|
+
sessionId: owner
|
|
3308
|
+
}, WORKFLOW_API_BASE_PATH, new Request(`${IN_PROCESS_ORIGIN}${apiRoutes_default.launch.path}`, {
|
|
3309
|
+
method: "POST",
|
|
3310
|
+
headers: { "content-type": "application/json" },
|
|
3311
|
+
body: JSON.stringify(parameters)
|
|
3312
|
+
}));
|
|
3313
|
+
const body = await response.json().catch((error) => ({ error: String(error) }));
|
|
3314
|
+
if (response.ok && isLaunchResponse(body)) return textResult(body.text, body.isError === true);
|
|
3315
|
+
return textResult(`Error: session ${owner} did not launch the workflow (${errorOf(body) ?? `HTTP ${String(response.status)}`}).`, true);
|
|
3316
|
+
};
|
|
3317
|
+
/**
|
|
3318
|
+
* A workflow dispatcher launches for its root session, the way a CLI
|
|
3319
|
+
* dispatcher child does: the same markers name it, and the root runs what
|
|
3320
|
+
* it launches. Any other session runs its own launches.
|
|
3321
|
+
*/
|
|
3322
|
+
const dispatcher = isWorkflowDispatcherProcess(host.context.environment);
|
|
3323
|
+
const launchOwner = () => {
|
|
3324
|
+
if (!dispatcher) return void 0;
|
|
3325
|
+
const parent = resolveDispatcherParentSession(host.context.environment);
|
|
3326
|
+
if (parent === void 0) return void 0;
|
|
3327
|
+
const owner = (0, _agimon_ai_po_core_childProcess.resolveRootSessionId)(parent, host.context.environment);
|
|
3328
|
+
return owner === host.context.sessionId ? void 0 : owner;
|
|
3329
|
+
};
|
|
3330
|
+
/** Sessions this one is creating to hand launches to; they count toward its capacity before their runs register. */
|
|
3331
|
+
let creatingWorkflowSessions = 0;
|
|
3332
|
+
/** A host that can create a session and reach its API, for a session that is not itself a workflow session. */
|
|
3333
|
+
const canHandOff = () => !workflowSession && sessionService !== void 0 && serverHost.context.requestApi !== void 0;
|
|
3334
|
+
/**
|
|
3335
|
+
* Create a workflow session under this one and hand it the launch, so the
|
|
3336
|
+
* run, its step sessions and its owner agent live there, nested under this
|
|
3337
|
+
* session in the rail. The launch is checked here first: a refused launch
|
|
3338
|
+
* creates nothing.
|
|
3339
|
+
*/
|
|
3340
|
+
const launchInWorkflowSession = async (parameters, where = host.context) => {
|
|
3341
|
+
let input;
|
|
3342
|
+
try {
|
|
3343
|
+
input = feature.runTool.getInputSchema().parse(parameters);
|
|
3344
|
+
} catch (error) {
|
|
3345
|
+
return textResult(`Error: ${errorMessage(error)}`, true);
|
|
3346
|
+
}
|
|
3347
|
+
const refusal = refuseLaunch(input, where);
|
|
3348
|
+
if (refusal !== void 0) return textResult(`Error: ${refusal}`, true);
|
|
3349
|
+
const self = host.context.sessionId;
|
|
3350
|
+
const running = readWorkflowRuns({ environment: host.context.environment }).filter((run) => run.view.stage === "running" && run.view.stale !== true && (runBelongsToSession(run, self) || runLaunchedBySession(run, self))).length;
|
|
3351
|
+
const ceiling = resolveMaxConcurrent(host.context.environment);
|
|
3352
|
+
if (running + creatingWorkflowSessions >= ceiling) return textResult(`Error: This session is at capacity: ${String(running + creatingWorkflowSessions)}/${String(ceiling)} workflows running.`, true);
|
|
3353
|
+
const workflowPath = (0, node_path.resolve)(where.cwd, input.workflowPath);
|
|
3354
|
+
creatingWorkflowSessions += 1;
|
|
3355
|
+
let child;
|
|
3356
|
+
try {
|
|
3357
|
+
const name = workflowSessionName(workflowPath, (await catalogReader.read(where.cwd)).find((candidate) => candidate.path === workflowPath)?.name);
|
|
3358
|
+
const config = poTemplateRunConfigSchema.parse(feature.parser.parseWorkflowFile(workflowPath).runConfig ?? {});
|
|
3359
|
+
if (config.majorMode === void 0) throw new Error("Delegated workflows require top-level runConfig.majorMode.");
|
|
3360
|
+
const selection = sessionSelection(config);
|
|
3361
|
+
child = (await sessionService.create({
|
|
3362
|
+
cwd: where.cwd,
|
|
3363
|
+
name,
|
|
3364
|
+
parentSessionId: self,
|
|
3365
|
+
sessionProvenance: WORKFLOW_SESSION_PROVENANCE,
|
|
3366
|
+
selection: {
|
|
3367
|
+
...selection,
|
|
3368
|
+
minorModes: [...(selection.minorModes ?? []).filter((mode) => mode !== WORKFLOW_MODE_ID), WORKFLOW_MODE_ID]
|
|
3369
|
+
}
|
|
3370
|
+
})).sessionId;
|
|
3371
|
+
const result = await launchIn(child, {
|
|
3372
|
+
...parameters,
|
|
3373
|
+
workflowPath,
|
|
3374
|
+
env: {
|
|
3375
|
+
...input.env,
|
|
3376
|
+
[WORKFLOW_LAUNCHER_SESSION_ENV]: self
|
|
3377
|
+
}
|
|
3378
|
+
});
|
|
3379
|
+
if (result.isError === true) {
|
|
3380
|
+
await sessionService.close(child).catch(reportLifecycleFailure);
|
|
3381
|
+
return result;
|
|
3382
|
+
}
|
|
3383
|
+
return textResult(`${result.content.map((item) => item.text).join("\n")}\nRuns in workflow session "${name}" (${child}), nested under this session. Failures notify you here without waking this agent. Its owner diagnoses failures locally, but recovers only on request; delegate to its owner with intercom send to "${child}".`);
|
|
3384
|
+
} catch (error) {
|
|
3385
|
+
if (child !== void 0) await sessionService.close(child).catch(reportLifecycleFailure);
|
|
3386
|
+
return textResult(`Error: ${errorMessage(error)}`, true);
|
|
3387
|
+
} finally {
|
|
3388
|
+
creatingWorkflowSessions -= 1;
|
|
3389
|
+
}
|
|
3390
|
+
};
|
|
3391
|
+
const launch = async (parameters, where) => {
|
|
3392
|
+
const owner = launchOwner();
|
|
3393
|
+
if (owner !== void 0) return launchIn(owner, parameters);
|
|
3394
|
+
return canHandOff() ? launchInWorkflowSession(parameters, where) : launchHere(parameters, where);
|
|
3395
|
+
};
|
|
3396
|
+
/**
|
|
3397
|
+
* A launch another session handed this one. A hand-off from a launcher
|
|
3398
|
+
* carries its stamp and runs here; any other, such as a dispatcher's, goes
|
|
3399
|
+
* through the same routing as a launch made here.
|
|
3400
|
+
*/
|
|
3401
|
+
const launchFromApi = async (parameters) => {
|
|
3402
|
+
const env = parameters.env;
|
|
3403
|
+
const result = typeof env === "object" && env !== null && typeof env["PO_WORKFLOW_LAUNCHER_SESSION_ID"] === "string" ? await launchHere(parameters) : await launch(parameters);
|
|
3404
|
+
publishRunsSoon(host.context);
|
|
3405
|
+
return {
|
|
3406
|
+
text: result.content.map((item) => item.text).join("\n"),
|
|
3407
|
+
...result.isError === true ? { isError: true } : {}
|
|
3408
|
+
};
|
|
3409
|
+
};
|
|
3410
|
+
/**
|
|
3411
|
+
* A workflow session asks to be released after an untouched success. Only
|
|
3412
|
+
* the hub-attached source is trusted, and the registry, not the message,
|
|
3413
|
+
* decides: every run it owns succeeded and one was launched from here.
|
|
3414
|
+
*/
|
|
3415
|
+
const releaseWorkflowSession = async (source) => {
|
|
3416
|
+
if (sessionService?.release === void 0) return;
|
|
3417
|
+
const owned = readWorkflowRuns({ environment: host.context.environment }).filter((run) => runBelongsToSession(run, source));
|
|
3418
|
+
const succeeded = (run) => run.view.stage === "completed" && (run.view.outcome === void 0 || run.view.outcome === "success" || run.view.outcome === "skipped");
|
|
3419
|
+
if (owned.length === 0 || !owned.every(succeeded)) return;
|
|
3420
|
+
if (!owned.some((run) => runLaunchedBySession(run, host.context.sessionId))) return;
|
|
3421
|
+
await sessionService.release(source);
|
|
3422
|
+
telemetry.recordEvent("doom_workflow.session_released", { outcome: "released" });
|
|
3423
|
+
};
|
|
3424
|
+
const stopReleaseRequests = communication?.subscribe(WORKFLOW_SESSION_RELEASE_TYPE, (source) => {
|
|
3425
|
+
releaseWorkflowSession(source).catch(reportLifecycleFailure);
|
|
3426
|
+
});
|
|
3427
|
+
const stopPeerReady = communication?.onPeerReady(() => void 0);
|
|
3428
|
+
let control;
|
|
3429
|
+
let controlDisposers = [];
|
|
3430
|
+
const disposeControl = () => {
|
|
3431
|
+
for (const dispose of controlDisposers.splice(0)) dispose();
|
|
3432
|
+
control?.dispose();
|
|
3433
|
+
control = void 0;
|
|
3434
|
+
};
|
|
3435
|
+
let modeOwner;
|
|
3436
|
+
const modeSelected = () => (host.context.selection.state?.["minor-mode"] ?? []).includes(WORKFLOW_MODE_ID);
|
|
3437
|
+
const modeState = () => {
|
|
3438
|
+
const active = modeSelected();
|
|
3439
|
+
return {
|
|
3440
|
+
activation: active ? "active" : "inactive",
|
|
3441
|
+
condition: ready?.snapshot().state === "failed" ? "failed" : "ready",
|
|
3442
|
+
...active ? { detail: "workflow tools available" } : {},
|
|
3443
|
+
actions: [{
|
|
3444
|
+
id: "activate",
|
|
3445
|
+
enabled: !active,
|
|
3446
|
+
...!active ? {} : { disabledReason: "Workflow mode is active." }
|
|
3447
|
+
}, {
|
|
3448
|
+
id: "deactivate",
|
|
3449
|
+
enabled: active,
|
|
3450
|
+
...active ? {} : { disabledReason: "Workflow mode is inactive." }
|
|
3451
|
+
}]
|
|
3452
|
+
};
|
|
3453
|
+
};
|
|
3454
|
+
const selectMode = async (enabled) => {
|
|
3455
|
+
const modes = (host.context.selection.state?.["minor-mode"] ?? []).filter((mode) => mode !== WORKFLOW_MODE_ID);
|
|
3456
|
+
await host.changeSelection({
|
|
3457
|
+
axis: "state",
|
|
3458
|
+
key: "minor-mode",
|
|
3459
|
+
values: enabled ? [...modes, WORKFLOW_MODE_ID] : modes
|
|
3460
|
+
});
|
|
3461
|
+
modeOwner?.publish();
|
|
3462
|
+
};
|
|
3463
|
+
modeOwner = (0, _agimon_ai_po_minor_mode.defineMinorMode)({
|
|
3464
|
+
descriptor: {
|
|
3465
|
+
source: SOURCE,
|
|
3466
|
+
id: WORKFLOW_MODE_ID,
|
|
3467
|
+
label: "Workflow",
|
|
3468
|
+
description: "Workflow discovery, launch, inspection, control, and recovery tools.",
|
|
3469
|
+
order: 20,
|
|
3470
|
+
actions: [{
|
|
3471
|
+
id: "activate",
|
|
3472
|
+
label: "Activate",
|
|
3473
|
+
description: "Enable workflow tools for this session.",
|
|
3474
|
+
contexts: ["headless"],
|
|
3475
|
+
parameters: []
|
|
3476
|
+
}, {
|
|
3477
|
+
id: "deactivate",
|
|
3478
|
+
label: "Deactivate",
|
|
3479
|
+
description: "Disable workflow tools without stopping active runs.",
|
|
3480
|
+
contexts: ["headless"],
|
|
3481
|
+
parameters: []
|
|
3482
|
+
}]
|
|
3483
|
+
},
|
|
3484
|
+
state: modeState,
|
|
3485
|
+
async handleAction(_runtime, actionId, _argumentsValue, execution) {
|
|
3486
|
+
execution.signal.throwIfAborted();
|
|
3487
|
+
if (ready?.snapshot().state !== "ready") await waitForReadiness(execution.signal);
|
|
3488
|
+
if (actionId === "activate") {
|
|
3489
|
+
await selectMode(true);
|
|
3490
|
+
return { message: "Workflow mode activated." };
|
|
3491
|
+
}
|
|
3492
|
+
if (actionId === "deactivate") {
|
|
3493
|
+
await selectMode(false);
|
|
3494
|
+
return { message: "Workflow mode deactivated." };
|
|
3495
|
+
}
|
|
3496
|
+
throw new Error(`Unknown workflow mode action: ${actionId}`);
|
|
3497
|
+
}
|
|
3498
|
+
}).createOwner(void 0);
|
|
3499
|
+
let startingControl;
|
|
3500
|
+
const startControl = (executionContext) => {
|
|
3501
|
+
startingControl ??= (async () => {
|
|
3502
|
+
if (control !== void 0) {
|
|
3503
|
+
const ownedControl = control;
|
|
3504
|
+
await publishLifecycle(executionContext);
|
|
3505
|
+
return ownedControl;
|
|
3506
|
+
}
|
|
3507
|
+
if (disposed) throw new Error("Workflow runtime is disposed.");
|
|
3508
|
+
control = feature.createRunControl({ recordFilter: (record) => isSessionRun(record, host.context.sessionId) || isLaunchedRun(record, host.context.sessionId) });
|
|
3509
|
+
const ownedControl = control;
|
|
3510
|
+
const refresh = () => {
|
|
3511
|
+
publishRunsSoon(executionContext, (active) => {
|
|
3512
|
+
if (!active && !modeSelected() && control === ownedControl) disposeControl();
|
|
3513
|
+
});
|
|
3514
|
+
};
|
|
3515
|
+
controlDisposers = [
|
|
3516
|
+
ownedControl.on("runStarted", refresh),
|
|
3517
|
+
ownedControl.on("runUpdated", refresh),
|
|
3518
|
+
ownedControl.on("runFinished", refresh),
|
|
3519
|
+
ownedControl.on("job", refresh),
|
|
3520
|
+
ownedControl.on("step", refresh)
|
|
3521
|
+
];
|
|
3522
|
+
try {
|
|
3523
|
+
await ownedControl.start();
|
|
3524
|
+
if (disposed || control !== ownedControl) throw new Error("Workflow observer was superseded.");
|
|
3525
|
+
await publishLifecycle(executionContext);
|
|
3526
|
+
return ownedControl;
|
|
3527
|
+
} catch (error) {
|
|
3528
|
+
if (control === ownedControl) disposeControl();
|
|
3529
|
+
throw error;
|
|
3530
|
+
}
|
|
3531
|
+
})().finally(() => {
|
|
3532
|
+
startingControl = void 0;
|
|
3533
|
+
});
|
|
3534
|
+
return startingControl;
|
|
3535
|
+
};
|
|
3536
|
+
const waitForReadiness = async (signal) => {
|
|
3537
|
+
signal?.throwIfAborted();
|
|
3538
|
+
if (disposed) throw new Error("Workflow runtime is disposed.");
|
|
3539
|
+
if (ready === void 0) throw new Error("Workflow session initialization has not started.");
|
|
3540
|
+
await ready.wait(signal === void 0 ? void 0 : { signal });
|
|
3541
|
+
if (disposed) throw new Error("Workflow runtime is disposed.");
|
|
3542
|
+
};
|
|
3543
|
+
return {
|
|
3544
|
+
api: [createWorkflowApi(async (parameters) => {
|
|
3545
|
+
await waitForReadiness();
|
|
3546
|
+
return launchFromApi(parameters);
|
|
3547
|
+
})],
|
|
3548
|
+
async onStart() {
|
|
3549
|
+
readiness ??= ownedReadiness = (0, _agimon_ai_po_core_readiness.createDoomReadinessCoordinator)({ notify: (notice) => {
|
|
3550
|
+
host.context.client.notify({
|
|
3551
|
+
body: `${SOURCE} initialization ${notice.state}: ${notice.error?.message ?? notice.diagnostics.join("; ")}`,
|
|
3552
|
+
level: "warning"
|
|
3553
|
+
});
|
|
3554
|
+
} });
|
|
3555
|
+
ready = readiness.start(SOURCE, generation, async (signal) => {
|
|
3556
|
+
signal.throwIfAborted();
|
|
3557
|
+
await untilAborted(startControl(host.context), AbortSignal.any([signal, startupAbort.signal]));
|
|
3558
|
+
signal.throwIfAborted();
|
|
3559
|
+
if (disposed) throw new Error("Workflow runtime is disposed.");
|
|
3560
|
+
narrationReady = true;
|
|
3561
|
+
return { value: void 0 };
|
|
3562
|
+
});
|
|
3563
|
+
await Promise.allSettled([ready.wait()]);
|
|
3564
|
+
modeOwner?.publish();
|
|
3565
|
+
},
|
|
3566
|
+
services: [
|
|
3567
|
+
(context) => {
|
|
3568
|
+
readiness = (0, _agimon_ai_po_core_readiness.readDoomReadinessCoordinator)(context);
|
|
3569
|
+
context.inject([_agimon_ai_po_core_narration.DOOM_NARRATION_SERVICE], (serviceContext) => {
|
|
3570
|
+
const service = (0, _agimon_ai_po_core_narration.requireDoomNarrationService)(serviceContext);
|
|
3571
|
+
narration = service;
|
|
3572
|
+
return () => {
|
|
3573
|
+
if (narration === service) narration = void 0;
|
|
3574
|
+
};
|
|
3575
|
+
});
|
|
3576
|
+
},
|
|
3577
|
+
(0, _agimon_ai_po_minor_mode.serverMinorModes)([modeOwner]),
|
|
3578
|
+
(context) => {
|
|
3579
|
+
serviceContext = context;
|
|
3580
|
+
const provider = registerRunProvider(context);
|
|
3581
|
+
runProvider = provider;
|
|
3582
|
+
return () => {
|
|
3583
|
+
provider.dispose();
|
|
3584
|
+
if (serviceContext === context) serviceContext = void 0;
|
|
3585
|
+
if (runProvider === provider) runProvider = void 0;
|
|
3586
|
+
};
|
|
3587
|
+
}
|
|
3588
|
+
],
|
|
3589
|
+
hooks: [
|
|
3590
|
+
{
|
|
3591
|
+
event: "tool_execution_end",
|
|
3592
|
+
handle: async (_event, executionContext) => {
|
|
3593
|
+
await publishLifecycle(executionContext);
|
|
3594
|
+
}
|
|
3595
|
+
},
|
|
3596
|
+
{
|
|
3597
|
+
event: "session_shutdown",
|
|
3598
|
+
handle: disposeControl
|
|
3599
|
+
},
|
|
3600
|
+
{
|
|
3601
|
+
event: "session_start",
|
|
3602
|
+
handle: async () => {
|
|
3603
|
+
if ((workflowSession || dispatcher || host.context.environment.WORKFLOW_MCP_MODE === "on") && !modeSelected()) await selectMode(true);
|
|
3604
|
+
}
|
|
3605
|
+
},
|
|
3606
|
+
{
|
|
3607
|
+
event: "agent_start",
|
|
3608
|
+
handle: () => {
|
|
3609
|
+
lifecycle?.agentStarted();
|
|
3610
|
+
}
|
|
3611
|
+
},
|
|
3612
|
+
{
|
|
3613
|
+
event: "agent_settled",
|
|
3614
|
+
handle: async (_event, execution) => {
|
|
3615
|
+
const environment = execution.environment;
|
|
3616
|
+
if (disposed || stepGatePending || !environment[_agimon_ai_workflow_mcp.WORKFLOW_DECISION_FILE_ENV] || environment[_agimon_ai_workflow_mcp.WORKFLOW_DECISION_STEERING_ENV] === _agimon_ai_workflow_mcp.WORKFLOW_DECISION_STEERING_HOST || environment.PI_SUBAGENT_CHILD) return;
|
|
3617
|
+
stepGatePending = true;
|
|
3618
|
+
try {
|
|
3619
|
+
const activity = await execution.session.activity();
|
|
3620
|
+
if (disposed || !activity.isIdle || activity.hasPendingMessages) return;
|
|
3621
|
+
const snapshot = serviceContext === void 0 ? void 0 : (0, _agimon_ai_po_core_backgroundWork.readDoomBackgroundWorkService)(serviceContext)?.snapshot(execution.sessionId);
|
|
3622
|
+
if (snapshot === void 0 || snapshot.items.some(_agimon_ai_po_core_backgroundWork.holdsSettledSession) || snapshot.errors.some((error) => _agimon_ai_po_core_backgroundWork.BACKGROUND_WORK_HOLDING_PROVIDERS.includes(error.provider))) return;
|
|
3623
|
+
const gate = await (0, _agimon_ai_workflow_mcp.gateStepDecision)(environment);
|
|
3624
|
+
if (disposed || gate.action !== "block") return;
|
|
3625
|
+
if (execution.session.admitPrompt === void 0) throw new Error("This host cannot admit workflow reminders.");
|
|
3626
|
+
await execution.session.admitPrompt(gate.message, "steer");
|
|
3627
|
+
} finally {
|
|
3628
|
+
stepGatePending = false;
|
|
3629
|
+
}
|
|
3630
|
+
}
|
|
3631
|
+
}
|
|
3632
|
+
],
|
|
3633
|
+
resources: [
|
|
3634
|
+
{
|
|
3635
|
+
when: {
|
|
3636
|
+
state: { "minor-mode": WORKFLOW_MODE_ID },
|
|
3637
|
+
attribution: {
|
|
3638
|
+
kind: "minor",
|
|
3639
|
+
mode: WORKFLOW_MODE_ID
|
|
3640
|
+
}
|
|
3641
|
+
},
|
|
3642
|
+
name: "po-author-workflow",
|
|
3643
|
+
kind: "skill",
|
|
3644
|
+
read: () => readWorkflowSkill("po-author-workflow")
|
|
3645
|
+
},
|
|
3646
|
+
{
|
|
3647
|
+
when: {
|
|
3648
|
+
state: { "minor-mode": WORKFLOW_MODE_ID },
|
|
3649
|
+
attribution: {
|
|
3650
|
+
kind: "minor",
|
|
3651
|
+
mode: WORKFLOW_MODE_ID
|
|
3652
|
+
}
|
|
3653
|
+
},
|
|
3654
|
+
name: "po-use-workflow",
|
|
3655
|
+
kind: "skill",
|
|
3656
|
+
read: () => readWorkflowSkill("po-use-workflow")
|
|
3657
|
+
},
|
|
3658
|
+
{
|
|
3659
|
+
when: {
|
|
3660
|
+
state: { "minor-mode": WORKFLOW_MODE_ID },
|
|
3661
|
+
attribution: {
|
|
3662
|
+
kind: "minor",
|
|
3663
|
+
mode: WORKFLOW_MODE_ID
|
|
3664
|
+
}
|
|
3665
|
+
},
|
|
3666
|
+
name: "workflow-recovery",
|
|
3667
|
+
kind: "skill",
|
|
3668
|
+
read: () => readWorkflowSkill("workflow-recovery")
|
|
3669
|
+
},
|
|
3670
|
+
{
|
|
3671
|
+
when: {
|
|
3672
|
+
state: { "minor-mode": WORKFLOW_MODE_ID },
|
|
3673
|
+
attribution: {
|
|
3674
|
+
kind: "minor",
|
|
3675
|
+
mode: WORKFLOW_MODE_ID
|
|
3676
|
+
}
|
|
3677
|
+
},
|
|
3678
|
+
name: "workflow-session",
|
|
3679
|
+
kind: "context",
|
|
3680
|
+
read: (executionContext) => workflowSession ? workflowSessionBrief(presentWorkflowRuns(readWorkflowRuns({ environment: executionContext.environment }).filter((run) => runBelongsToSession(run, executionContext.sessionId)).map((run) => run.view), Date.now())) : ""
|
|
3681
|
+
}
|
|
3682
|
+
],
|
|
3683
|
+
activities: [{
|
|
3684
|
+
when: {
|
|
3685
|
+
state: { "minor-mode": WORKFLOW_MODE_ID },
|
|
3686
|
+
attribution: {
|
|
3687
|
+
kind: "minor",
|
|
3688
|
+
mode: WORKFLOW_MODE_ID
|
|
3689
|
+
}
|
|
3690
|
+
},
|
|
3691
|
+
name: SOURCE,
|
|
3692
|
+
async start(executionContext) {
|
|
3693
|
+
const reportFailure = (error) => {
|
|
3694
|
+
process.emitWarning(`Could not publish headless workflow state: ${String(error)}`);
|
|
3695
|
+
};
|
|
3696
|
+
const stopFor = (ownedControl) => () => {
|
|
3697
|
+
publishLifecycle(executionContext).then((active) => {
|
|
3698
|
+
if (!active && !modeSelected() && control === ownedControl) disposeControl();
|
|
3699
|
+
}).catch(reportFailure);
|
|
3700
|
+
};
|
|
3701
|
+
return stopFor(await startControl(executionContext));
|
|
3702
|
+
}
|
|
3703
|
+
}],
|
|
3704
|
+
tools: [
|
|
3705
|
+
{
|
|
3706
|
+
when: {
|
|
3707
|
+
state: { "minor-mode": WORKFLOW_MODE_ID },
|
|
3708
|
+
attribution: {
|
|
3709
|
+
kind: "minor",
|
|
3710
|
+
mode: WORKFLOW_MODE_ID
|
|
3711
|
+
}
|
|
3712
|
+
},
|
|
3713
|
+
name: LIST_TOOL,
|
|
3714
|
+
label: "List Workflows",
|
|
3715
|
+
description: "List workflow definitions in this repository, not existing runs.",
|
|
3716
|
+
promptGuidelines: ["Use list_workflows to discover definitions, not to find an existing run."],
|
|
3717
|
+
parameters: zod.z.toJSONSchema(feature.listWorkflowsTool.getInputSchema()),
|
|
3718
|
+
executionMode: "serial",
|
|
3719
|
+
async execute(_toolCallId, parameters, signal, _onUpdate, context) {
|
|
3720
|
+
signal?.throwIfAborted();
|
|
3721
|
+
await waitForReadiness(signal);
|
|
3722
|
+
const input = parameters;
|
|
3723
|
+
return callResult(await feature.listWorkflowsTool.execute({
|
|
3724
|
+
...input,
|
|
3725
|
+
directory: input?.directory === void 0 ? context.cwd : typeof input.directory === "string" ? (0, node_path.resolve)(context.cwd, input.directory) : input.directory
|
|
3726
|
+
}));
|
|
3727
|
+
}
|
|
3728
|
+
},
|
|
3729
|
+
{
|
|
3730
|
+
when: {
|
|
3731
|
+
state: { "minor-mode": WORKFLOW_MODE_ID },
|
|
3732
|
+
attribution: {
|
|
3733
|
+
kind: "minor",
|
|
3734
|
+
mode: WORKFLOW_MODE_ID
|
|
3735
|
+
}
|
|
3736
|
+
},
|
|
3737
|
+
name: LAUNCH_TOOL,
|
|
3738
|
+
label: "Launch Workflow",
|
|
3739
|
+
description: "Start a workflow run. Prefer in-process Pi execution with command: \"pi\" when supported; runner: \"codex\" is not an alias for it. A successful launch is not a completed workflow.",
|
|
3740
|
+
promptGuidelines: [
|
|
3741
|
+
"Before launching, inspect the workflow and its imports for commands and interactiveRun choices. Prefer a supported Pi template with inProcess: true, selected explicitly with command: \"pi\" (or a supported in-process \"pi-claude\" command when requested). Only use an external CLI when the user requests it or no in-process option exists.",
|
|
3742
|
+
"command selects the exact template. Without it, runner only matches an exact command name; otherwise each step uses its first command. runner: \"codex\" does not imply command: \"pi\", so pass command explicitly to avoid switching to \"claude\" on later steps.",
|
|
3743
|
+
"Launch returns when a run starts, not when it finishes. Use the exact run key and separate workspace from its launch result for workflow_run status, not Agiflow project or job identifiers.",
|
|
3744
|
+
"Do not relaunch a healthy running workflow merely because it has not completed."
|
|
3745
|
+
],
|
|
3746
|
+
parameters: zod.z.toJSONSchema(feature.runTool.getInputSchema()),
|
|
3747
|
+
executionMode: "serial",
|
|
3748
|
+
async execute(_toolCallId, parameters, signal, _onUpdate, context) {
|
|
3749
|
+
signal?.throwIfAborted();
|
|
3750
|
+
await waitForReadiness(signal);
|
|
3751
|
+
const input = parameters;
|
|
3752
|
+
return callResult(await launch({
|
|
3753
|
+
...input,
|
|
3754
|
+
...typeof input.workflowPath === "string" ? { workflowPath: (0, node_path.resolve)(context.cwd, input.workflowPath) } : {}
|
|
3755
|
+
}, context));
|
|
3756
|
+
}
|
|
3757
|
+
},
|
|
3758
|
+
{
|
|
3759
|
+
when: {
|
|
3760
|
+
state: { "minor-mode": WORKFLOW_MODE_ID },
|
|
3761
|
+
attribution: {
|
|
3762
|
+
kind: "minor",
|
|
3763
|
+
mode: WORKFLOW_MODE_ID
|
|
3764
|
+
}
|
|
3765
|
+
},
|
|
3766
|
+
name: RUN_TOOL,
|
|
3767
|
+
label: "Workflow Run",
|
|
3768
|
+
description: "Inspect a session-owned workflow by exact run key and separate workspace, or request cooperative control.",
|
|
3769
|
+
promptGuidelines: [
|
|
3770
|
+
"Use workflow_run status for the recorded stage, outcome, position, jobs, and steps. A successful status lookup is not evidence that the workflow completed.",
|
|
3771
|
+
"For a healthy running workflow, summarize the recorded progress. Do not automatically search CLI commands or filesystem logs, relaunch, or diagnose failure merely because it remains running.",
|
|
3772
|
+
"Investigate further when the run reports failure or staleness, or when the user requests diagnostics. Only status, pause, resume, and stop are supported here; stop only on user request.",
|
|
3773
|
+
"Use exact registry runKey and separate workspace from the launch or status result. Agiflow project and job identifiers are not workflow run keys."
|
|
3774
|
+
],
|
|
3775
|
+
parameters: {
|
|
3776
|
+
type: "object",
|
|
3777
|
+
properties: {
|
|
3778
|
+
action: {
|
|
3779
|
+
type: "string",
|
|
3780
|
+
enum: [
|
|
3781
|
+
"status",
|
|
3782
|
+
"pause",
|
|
3783
|
+
"resume",
|
|
3784
|
+
"stop"
|
|
3785
|
+
]
|
|
3786
|
+
},
|
|
3787
|
+
runKey: {
|
|
3788
|
+
type: "string",
|
|
3789
|
+
minLength: 1,
|
|
3790
|
+
description: "Exact registry run key from the launch result, without the workspace prefix. Not an Agiflow job ID."
|
|
3791
|
+
},
|
|
3792
|
+
workspace: {
|
|
3793
|
+
type: "string",
|
|
3794
|
+
description: "Workspace from the launch result, passed separately from runKey."
|
|
3795
|
+
},
|
|
3796
|
+
reason: { type: "string" },
|
|
3797
|
+
expectedRunId: { type: "string" }
|
|
3798
|
+
},
|
|
3799
|
+
required: ["action", "runKey"],
|
|
3800
|
+
additionalProperties: false
|
|
3801
|
+
},
|
|
3802
|
+
executionMode: "serial",
|
|
3803
|
+
async execute(_toolCallId, parameters, signal) {
|
|
3804
|
+
signal?.throwIfAborted();
|
|
3805
|
+
await waitForReadiness(signal);
|
|
3806
|
+
const input = parameters;
|
|
3807
|
+
const runs = readWorkflowRuns({ environment: host.context.environment }).filter((run) => (runBelongsToSession(run, host.context.sessionId) || runLaunchedBySession(run, host.context.sessionId)) && run.view.runKey === input.runKey && (input.workspace === void 0 || run.view.workspace === input.workspace));
|
|
3808
|
+
if (runs.length !== 1) return {
|
|
3809
|
+
...callResult({ error: runs.length === 0 ? "No matching workflow run was found in this session. This is a lookup failure, not evidence that the workflow failed. Use the exact runKey and separate workspace from its launch result, not Agiflow job identifiers." : "Specify the workspace for this run key." }),
|
|
3810
|
+
isError: true
|
|
3811
|
+
};
|
|
3812
|
+
const run = runs[0].view;
|
|
3813
|
+
if (input.action === "status") return callResult(run);
|
|
3814
|
+
if (!runBelongsToSession(runs[0], host.context.sessionId)) return callResult({ error: `This run belongs to workflow session ${run.ownerSessionId ?? "unknown"}; pause, resume or stop it there.` });
|
|
3815
|
+
if (!control) return callResult({ error: "Workflow activity is not active." });
|
|
3816
|
+
if (typeof input.expectedRunId !== "string" || input.expectedRunId.length === 0) return callResult({ error: "expectedRunId is required for workflow control." });
|
|
3817
|
+
return callResult(input.action === "pause" ? await control.pause(input.runKey, input.expectedRunId, input.workspace, input.reason) : input.action === "resume" ? await control.resume(input.runKey, input.expectedRunId, input.workspace) : await control.stop(input.runKey, input.expectedRunId, input.workspace, input.reason));
|
|
3818
|
+
}
|
|
3819
|
+
},
|
|
3820
|
+
{
|
|
3821
|
+
when: {
|
|
3822
|
+
state: { "minor-mode": WORKFLOW_MODE_ID },
|
|
3823
|
+
attribution: {
|
|
3824
|
+
kind: "minor",
|
|
3825
|
+
mode: WORKFLOW_MODE_ID
|
|
3826
|
+
}
|
|
3827
|
+
},
|
|
3828
|
+
name: WORKFLOW_TOOLS_TOOL_NAME,
|
|
3829
|
+
label: "Workflow Tools",
|
|
3830
|
+
description: "Workflow actions beyond workflow_run: recovery-evidence reads a failed run's durable evidence, including one an earlier session launched; recover resumes it from its active repair in this session.",
|
|
3831
|
+
promptGuidelines: [
|
|
3832
|
+
"Load workflow-recovery and read recovery-evidence before recover; recover is not a fresh launch.",
|
|
3833
|
+
"A recover answer means the replay started, not that it succeeded; verify with workflow_run status.",
|
|
3834
|
+
"Already claimed means another recovery won: do not retry or launch fresh. Never edit issue.md or repair.json."
|
|
3835
|
+
],
|
|
3836
|
+
parameters: zod.z.toJSONSchema(workflowToolsInputSchema),
|
|
3837
|
+
executionMode: "serial",
|
|
3838
|
+
async execute(_toolCallId, parameters, signal) {
|
|
3839
|
+
try {
|
|
3840
|
+
signal?.throwIfAborted();
|
|
3841
|
+
await waitForReadiness(signal);
|
|
3842
|
+
const input = workflowToolsInputSchema.parse(parameters);
|
|
3843
|
+
const matches = (await feature.registry.listRuns(input.workspace)).filter((record) => record.runKey === input.runKey && record.stage === "error");
|
|
3844
|
+
if (matches.length > 1) throw new Error("Run key exists in more than one workspace; retry with its workspace.");
|
|
3845
|
+
const record = matches[0];
|
|
3846
|
+
if (!record) throw new Error("No failed workflow run matches this key; do not recover a running or completed workflow.");
|
|
3847
|
+
const owner = record.env?.PI_SESSION_ID;
|
|
3848
|
+
if (input.action === "recover" && owner !== void 0 && owner !== host.context.sessionId && sessionService?.isLive(owner) === true) throw new Error(`Run ${record.runKey} belongs to live workflow session ${owner}; recover it there.`);
|
|
3849
|
+
if (input.action === "recover") return launchResultOf(await launcher.recover({
|
|
3850
|
+
runKey: record.runKey,
|
|
3851
|
+
workspace: record.workspace
|
|
3852
|
+
}, {
|
|
3853
|
+
dryRun: input.dryRun,
|
|
3854
|
+
runner: input.runner
|
|
3855
|
+
}));
|
|
3856
|
+
telemetry.recordEvent("doom_workflow.recovery_evidence_requested", { outcome: "requested" });
|
|
3857
|
+
const sections = [];
|
|
3858
|
+
for (const name of [
|
|
3859
|
+
"changelog.md",
|
|
3860
|
+
"context.md",
|
|
3861
|
+
"progress.ndjson"
|
|
3862
|
+
]) try {
|
|
3863
|
+
const bytes = await (0, node_fs_promises.readFile)((0, node_path.join)(feature.registry.runDirectoryFor(record), name));
|
|
3864
|
+
sections.push(`--- ${name} ---\n${bytes.subarray(-65536).toString("utf8")}`);
|
|
3865
|
+
} catch (error) {
|
|
3866
|
+
if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error;
|
|
3867
|
+
}
|
|
3868
|
+
return { content: [{
|
|
3869
|
+
type: "text",
|
|
3870
|
+
text: `Workflow: ${record.displayName} (${record.runKey})\n${sections.length ? "" : "No durable evidence files found.\n"}--- run.json ---\n${JSON.stringify(record, null, 2)}${sections.length ? "\n" + sections.join("\n") : ""}`
|
|
3871
|
+
}] };
|
|
3872
|
+
} catch (error) {
|
|
3873
|
+
return {
|
|
3874
|
+
content: [{
|
|
3875
|
+
type: "text",
|
|
3876
|
+
text: `Error: ${error instanceof Error ? error.message : String(error)}`
|
|
3877
|
+
}],
|
|
3878
|
+
isError: true
|
|
3879
|
+
};
|
|
3880
|
+
}
|
|
3881
|
+
}
|
|
3882
|
+
}
|
|
3883
|
+
],
|
|
3884
|
+
commands: [{
|
|
3885
|
+
when: {
|
|
3886
|
+
state: { "minor-mode": WORKFLOW_MODE_ID },
|
|
3887
|
+
attribution: {
|
|
3888
|
+
kind: "minor",
|
|
3889
|
+
mode: WORKFLOW_MODE_ID
|
|
3890
|
+
}
|
|
3891
|
+
},
|
|
3892
|
+
name: "workflow-launch",
|
|
3893
|
+
description: "Launch a workflow from a slash command.",
|
|
3894
|
+
async execute(args, execution) {
|
|
3895
|
+
const parsed = require_workflowLaunchCommand.parseWorkflowLaunchCommand(args);
|
|
3896
|
+
if ("error" in parsed) {
|
|
3897
|
+
await execution.client.notify({
|
|
3898
|
+
body: parsed.error,
|
|
3899
|
+
level: "error"
|
|
3900
|
+
});
|
|
3901
|
+
return;
|
|
3902
|
+
}
|
|
3903
|
+
try {
|
|
3904
|
+
const entry = require_workflowLaunchCommand.resolveWorkflowEntry(await catalogReader.read(execution.cwd), parsed.workflow);
|
|
3905
|
+
if (!entry || entry.error) {
|
|
3906
|
+
await execution.client.notify({
|
|
3907
|
+
body: entry?.error ? needsFixingNotice(entry.name, entry.error) : `Workflow not found: ${parsed.workflow}`,
|
|
3908
|
+
level: "error"
|
|
3909
|
+
});
|
|
3910
|
+
return;
|
|
3911
|
+
}
|
|
3912
|
+
const problems = require_workflowLaunchCommand.validateWorkflowLaunch(entry, parsed);
|
|
3913
|
+
if (problems.length > 0) {
|
|
3914
|
+
await execution.client.notify({
|
|
3915
|
+
body: problems.join("\n"),
|
|
3916
|
+
level: "error"
|
|
3917
|
+
});
|
|
3918
|
+
return;
|
|
3919
|
+
}
|
|
3920
|
+
const result = callResult(await launch({
|
|
3921
|
+
workflowPath: entry.path,
|
|
3922
|
+
...parsed.runner === void 0 ? {} : { runner: parsed.runner },
|
|
3923
|
+
...parsed.command === void 0 ? {} : { command: parsed.command },
|
|
3924
|
+
...parsed.choice === void 0 ? {} : { choice: parsed.choice },
|
|
3925
|
+
...Object.keys(parsed.inputs).length === 0 ? {} : { inputs: parsed.inputs },
|
|
3926
|
+
...parsed.prompt === void 0 ? {} : { prompt: parsed.prompt }
|
|
3927
|
+
}, execution));
|
|
3928
|
+
const rendered = result.content.find((item) => item.type === "text");
|
|
3929
|
+
await execution.client.notify({
|
|
3930
|
+
body: rendered?.type === "text" ? rendered.text : "Workflow launch requested.",
|
|
3931
|
+
level: result.isError ? "error" : "info"
|
|
3932
|
+
});
|
|
3933
|
+
} catch (error) {
|
|
3934
|
+
await execution.client.notify({
|
|
3935
|
+
body: String(error),
|
|
3936
|
+
level: "error"
|
|
3937
|
+
});
|
|
3938
|
+
}
|
|
3939
|
+
}
|
|
3940
|
+
}],
|
|
3941
|
+
...dispatcher ? { toolRestrictions: [{ excludedTools: [RUN_TOOL, WORKFLOW_TOOLS_TOOL_NAME] }] } : {},
|
|
3942
|
+
async onDispose() {
|
|
3943
|
+
disposed = true;
|
|
3944
|
+
startupAbort.abort();
|
|
3945
|
+
if (deliveryRetry !== void 0) clearTimeout(deliveryRetry);
|
|
3946
|
+
deliveryRetry = void 0;
|
|
3947
|
+
pendingCompletions.clear();
|
|
3948
|
+
narrationReady = false;
|
|
3949
|
+
narration = void 0;
|
|
3950
|
+
await ownedReadiness?.dispose();
|
|
3951
|
+
disposeControl();
|
|
3952
|
+
lifecycle?.dispose();
|
|
3953
|
+
stopReleaseRequests?.();
|
|
3954
|
+
stopPeerReady?.();
|
|
3955
|
+
if (runsTimer !== void 0) clearTimeout(runsTimer);
|
|
3956
|
+
runsTimer = void 0;
|
|
3957
|
+
runsAfter = void 0;
|
|
3958
|
+
await launcher.dispose();
|
|
3959
|
+
runProvider?.dispose();
|
|
3960
|
+
runProvider = void 0;
|
|
3961
|
+
await telemetry.shutdown();
|
|
3962
|
+
}
|
|
3963
|
+
};
|
|
3964
|
+
}
|
|
3965
|
+
//#endregion
|
|
3966
|
+
//#region src/extensions/workspaces/sessions/(backend)/_lib/index.server.ts
|
|
3967
|
+
var index_server_default$1 = (({ agent, host }) => agent ? createWorkflowServerRuntime(agent, host) : { api: [api] });
|
|
3968
|
+
//#endregion
|
|
3969
|
+
//#region src/extensions/workspaces/sessions/(backend)/root.server.ts
|
|
3970
|
+
var root_server_default = (0, _agimon_ai_po_core_extensionFile.defineRoot)((context) => {
|
|
3971
|
+
const value = index_server_default$1(context);
|
|
3972
|
+
return {
|
|
3973
|
+
value,
|
|
3974
|
+
services: value.services,
|
|
3975
|
+
activities: value.activities,
|
|
3976
|
+
onStart: value.onStart,
|
|
3977
|
+
onStop: value.onStop,
|
|
3978
|
+
onDispose: value.onDispose
|
|
3979
|
+
};
|
|
3980
|
+
});
|
|
3981
|
+
//#endregion
|
|
3982
|
+
//#region src/services/workflowCatalogChannel/index.ts
|
|
3983
|
+
/**
|
|
3984
|
+
* The workflow catalog channel. Durable state seeds a session once, then
|
|
3985
|
+
* session lifecycle events deliver updates over the direct event bus.
|
|
3986
|
+
*/
|
|
3987
|
+
function createWorkflowCatalogChannel(options = {}) {
|
|
3988
|
+
const reader = createWorkflowCatalogReader(options.deps ?? defaultCatalogDeps());
|
|
3989
|
+
return {
|
|
3990
|
+
frameType: WORKFLOW_CATALOG_TYPE,
|
|
3991
|
+
start(host) {
|
|
3992
|
+
const scopes = /* @__PURE__ */ new Map();
|
|
3993
|
+
const latest = /* @__PURE__ */ new Map();
|
|
3994
|
+
const subscriptions = /* @__PURE__ */ new Map();
|
|
3995
|
+
const publish = (scope, payload) => {
|
|
3996
|
+
latest.set(scope.sessionId, payload);
|
|
3997
|
+
host.publish(scope.sessionId, payload);
|
|
3998
|
+
};
|
|
3999
|
+
const readInitial = async (scope) => {
|
|
4000
|
+
let payload;
|
|
4001
|
+
try {
|
|
4002
|
+
payload = {
|
|
4003
|
+
cwd: scope.cwd,
|
|
4004
|
+
workflows: presentWorkflowCatalog(await reader.read(scope.cwd))
|
|
4005
|
+
};
|
|
4006
|
+
} catch (error) {
|
|
4007
|
+
const warning = error instanceof Error ? error.message : String(error);
|
|
4008
|
+
payload = {
|
|
4009
|
+
cwd: scope.cwd,
|
|
4010
|
+
workflows: [],
|
|
4011
|
+
warning
|
|
4012
|
+
};
|
|
4013
|
+
host.onNotice(`workflow catalog for ${scope.cwd} is unavailable (${warning})`);
|
|
4014
|
+
}
|
|
4015
|
+
if (scopes.get(scope.sessionId) === scope) publish(scope, payload);
|
|
4016
|
+
};
|
|
4017
|
+
return {
|
|
4018
|
+
payloadFor(scope) {
|
|
4019
|
+
return scopes.has(scope.sessionId) ? latest.get(scope.sessionId) : void 0;
|
|
4020
|
+
},
|
|
4021
|
+
sessionAdded(scope) {
|
|
4022
|
+
scopes.set(scope.sessionId, scope);
|
|
4023
|
+
subscriptions.get(scope.sessionId)?.();
|
|
4024
|
+
const unsubscribe = host.directEvents.subscribe(WORKFLOW_CATALOG_TYPE, scope.sessionId, (value) => {
|
|
4025
|
+
if (isWorkflowCatalogPayload(value)) publish(scope, value);
|
|
4026
|
+
}, { replayLatest: true });
|
|
4027
|
+
subscriptions.set(scope.sessionId, unsubscribe);
|
|
4028
|
+
readInitial(scope);
|
|
4029
|
+
},
|
|
4030
|
+
sessionRemoved(sessionId) {
|
|
4031
|
+
subscriptions.get(sessionId)?.();
|
|
4032
|
+
subscriptions.delete(sessionId);
|
|
4033
|
+
scopes.delete(sessionId);
|
|
4034
|
+
latest.delete(sessionId);
|
|
4035
|
+
},
|
|
4036
|
+
close() {
|
|
4037
|
+
for (const unsubscribe of subscriptions.values()) unsubscribe();
|
|
4038
|
+
subscriptions.clear();
|
|
4039
|
+
scopes.clear();
|
|
4040
|
+
latest.clear();
|
|
4041
|
+
}
|
|
4042
|
+
};
|
|
4043
|
+
}
|
|
4044
|
+
};
|
|
4045
|
+
}
|
|
4046
|
+
function isWorkflowCatalogPayload(value) {
|
|
4047
|
+
return typeof value === "object" && value !== null && typeof value.cwd === "string" && Array.isArray(value.workflows);
|
|
4048
|
+
}
|
|
4049
|
+
//#endregion
|
|
4050
|
+
//#region src/services/workflowsHubChannel/index.ts
|
|
4051
|
+
function isWorkflowRunsPayload(value) {
|
|
4052
|
+
return typeof value === "object" && value !== null && Array.isArray(value.runs);
|
|
4053
|
+
}
|
|
4054
|
+
/**
|
|
4055
|
+
* The workflow runs data channel. Durable registry state seeds each session
|
|
4056
|
+
* once; subsequent updates arrive through the host-owned direct event bus.
|
|
4057
|
+
*
|
|
4058
|
+
* A session sees the runs it owns and, beside them, the runs it handed to its
|
|
4059
|
+
* own workflow sessions, so its Activity rows can open each one. Those come
|
|
4060
|
+
* from the owners' updates, routed here by the launcher stamp on each run, and
|
|
4061
|
+
* from the registry for workflow sessions that are no longer live.
|
|
4062
|
+
*/
|
|
4063
|
+
function createWorkflowsChannel(options = {}) {
|
|
4064
|
+
const read = options.read ?? readWorkflowRuns;
|
|
4065
|
+
return {
|
|
4066
|
+
frameType: WORKFLOW_RUNS_TYPE,
|
|
4067
|
+
start(host) {
|
|
4068
|
+
const scopes = /* @__PURE__ */ new Map();
|
|
4069
|
+
const latest = /* @__PURE__ */ new Map();
|
|
4070
|
+
const subscriptions = /* @__PURE__ */ new Map();
|
|
4071
|
+
/** Each session's own runs, as it last reported them. */
|
|
4072
|
+
const own = /* @__PURE__ */ new Map();
|
|
4073
|
+
/** Launcher, then owning workflow session, then the runs one handed the other. */
|
|
4074
|
+
const delegated = /* @__PURE__ */ new Map();
|
|
4075
|
+
const publish = (sessionId) => {
|
|
4076
|
+
if (!scopes.has(sessionId)) return;
|
|
4077
|
+
const handed = [...delegated.get(sessionId)?.values() ?? []].flat();
|
|
4078
|
+
const payload = { runs: presentWorkflowRuns([...own.get(sessionId) ?? [], ...handed], Date.now()) };
|
|
4079
|
+
latest.set(sessionId, payload);
|
|
4080
|
+
host.publish(sessionId, payload);
|
|
4081
|
+
};
|
|
4082
|
+
/** Hands an owner's update to every session that launched one of its runs, or did until now. */
|
|
4083
|
+
const routeToLaunchers = (owner, runs) => {
|
|
4084
|
+
const launchers = /* @__PURE__ */ new Set();
|
|
4085
|
+
for (const run of runs) if (run.launcherSessionId !== void 0 && run.launcherSessionId !== owner) launchers.add(run.launcherSessionId);
|
|
4086
|
+
for (const [launcher, byOwner] of delegated) if (byOwner.has(owner)) launchers.add(launcher);
|
|
4087
|
+
for (const launcher of launchers) {
|
|
4088
|
+
if (!scopes.has(launcher)) continue;
|
|
4089
|
+
const mine = runs.filter((run) => run.launcherSessionId === launcher);
|
|
4090
|
+
const byOwner = delegated.get(launcher) ?? /* @__PURE__ */ new Map();
|
|
4091
|
+
delegated.set(launcher, byOwner);
|
|
4092
|
+
if (mine.length === 0) byOwner.delete(owner);
|
|
4093
|
+
else byOwner.set(owner, mine);
|
|
4094
|
+
publish(launcher);
|
|
4095
|
+
}
|
|
4096
|
+
};
|
|
4097
|
+
return {
|
|
4098
|
+
payloadFor(scope) {
|
|
4099
|
+
return scopes.has(scope.sessionId) ? latest.get(scope.sessionId) : void 0;
|
|
4100
|
+
},
|
|
4101
|
+
sessionAdded(scope) {
|
|
4102
|
+
const sessionId = scope.sessionId;
|
|
4103
|
+
scopes.set(sessionId, scope);
|
|
4104
|
+
const parsed = read({ environment: scope.environment });
|
|
4105
|
+
own.set(sessionId, parsed.filter((run) => runBelongsToSession(run, sessionId)).map((run) => run.view));
|
|
4106
|
+
const byOwner = /* @__PURE__ */ new Map();
|
|
4107
|
+
for (const run of parsed) {
|
|
4108
|
+
if (!runLaunchedBySession(run, sessionId) || run.piSessionId === void 0) continue;
|
|
4109
|
+
byOwner.set(run.piSessionId, [...byOwner.get(run.piSessionId) ?? [], run.view]);
|
|
4110
|
+
}
|
|
4111
|
+
delegated.set(sessionId, byOwner);
|
|
4112
|
+
subscriptions.get(sessionId)?.();
|
|
4113
|
+
const unsubscribe = host.directEvents.subscribe(WORKFLOW_RUNS_TYPE, sessionId, (value) => {
|
|
4114
|
+
if (!isWorkflowRunsPayload(value)) return;
|
|
4115
|
+
own.set(sessionId, value.runs);
|
|
4116
|
+
publish(sessionId);
|
|
4117
|
+
routeToLaunchers(sessionId, value.runs);
|
|
4118
|
+
}, { replayLatest: true });
|
|
4119
|
+
subscriptions.set(sessionId, unsubscribe);
|
|
4120
|
+
publish(sessionId);
|
|
4121
|
+
},
|
|
4122
|
+
sessionRemoved(sessionId) {
|
|
4123
|
+
subscriptions.get(sessionId)?.();
|
|
4124
|
+
subscriptions.delete(sessionId);
|
|
4125
|
+
scopes.delete(sessionId);
|
|
4126
|
+
latest.delete(sessionId);
|
|
4127
|
+
own.delete(sessionId);
|
|
4128
|
+
delegated.delete(sessionId);
|
|
4129
|
+
},
|
|
4130
|
+
close() {
|
|
4131
|
+
for (const unsubscribe of subscriptions.values()) unsubscribe();
|
|
4132
|
+
subscriptions.clear();
|
|
4133
|
+
scopes.clear();
|
|
4134
|
+
latest.clear();
|
|
4135
|
+
own.clear();
|
|
4136
|
+
delegated.clear();
|
|
4137
|
+
}
|
|
4138
|
+
};
|
|
4139
|
+
}
|
|
4140
|
+
};
|
|
4141
|
+
}
|
|
4142
|
+
//#endregion
|
|
4143
|
+
//#region src/extensions/(backend)/_lib/index.server.ts
|
|
4144
|
+
var index_server_default = (({ host }) => host.scope === "session" ? {} : {
|
|
4145
|
+
channels: [createWorkflowsChannel, createWorkflowCatalogChannel],
|
|
4146
|
+
api: [api]
|
|
4147
|
+
});
|
|
4148
|
+
//#endregion
|
|
4149
|
+
//#region src/extensions/(backend)/api/workflow/route.server.ts
|
|
4150
|
+
var route_server_default$1 = (0, _agimon_ai_po_core_extensionFile.defineRoutedContribution)((context) => index_server_default(context).api?.[0], { cardinality: "optional" });
|
|
4151
|
+
//#endregion
|
|
4152
|
+
//#region src/extensions/(backend)/channel/workflow-catalog.server.ts
|
|
4153
|
+
var workflow_catalog_server_default = (0, _agimon_ai_po_core_extensionFile.defineChannel)(createWorkflowCatalogChannel);
|
|
4154
|
+
//#endregion
|
|
4155
|
+
//#region src/extensions/(backend)/channel/workflows.server.ts
|
|
4156
|
+
var workflows_server_default = (0, _agimon_ai_po_core_extensionFile.defineChannel)(createWorkflowsChannel);
|
|
4157
|
+
//#endregion
|
|
4158
|
+
//#region src/extensions/workspaces/sessions/(backend)/api/workflow/route.server.ts
|
|
4159
|
+
var route_server_default = (0, _agimon_ai_po_core_extensionFile.defineRoutedContribution)((context) => context.root.api?.[0], { cardinality: "optional" });
|
|
4160
|
+
//#endregion
|
|
4161
|
+
//#region src/extensions/workspaces/sessions/(backend)/command/workflow-launch.server.ts
|
|
4162
|
+
var workflow_launch_server_default = (0, _agimon_ai_po_core_extensionFile.defineRoutedContribution)((context) => context.root.commands?.[0], { cardinality: "optional" });
|
|
4163
|
+
//#endregion
|
|
4164
|
+
//#region src/extensions/workspaces/sessions/(backend)/hook/agent-settled.server.ts
|
|
4165
|
+
var agent_settled_server_default = (0, _agimon_ai_po_core_extensionFile.defineRoutedContribution)((context) => context.root.hooks?.[4], { cardinality: "optional" });
|
|
4166
|
+
//#endregion
|
|
4167
|
+
//#region src/extensions/workspaces/sessions/(backend)/hook/agent-start.server.ts
|
|
4168
|
+
var agent_start_server_default = (0, _agimon_ai_po_core_extensionFile.defineRoutedContribution)((context) => context.root.hooks?.[3], { cardinality: "optional" });
|
|
4169
|
+
//#endregion
|
|
4170
|
+
//#region src/extensions/workspaces/sessions/(backend)/hook/session-shutdown.server.ts
|
|
4171
|
+
var session_shutdown_server_default = (0, _agimon_ai_po_core_extensionFile.defineRoutedContribution)((context) => context.root.hooks?.[1], { cardinality: "optional" });
|
|
4172
|
+
//#endregion
|
|
4173
|
+
//#region src/extensions/workspaces/sessions/(backend)/hook/session-start.server.ts
|
|
4174
|
+
var session_start_server_default = (0, _agimon_ai_po_core_extensionFile.defineRoutedContribution)((context) => context.root.hooks?.[2], { cardinality: "optional" });
|
|
4175
|
+
//#endregion
|
|
4176
|
+
//#region src/extensions/workspaces/sessions/(backend)/hook/tool-execution-end.server.ts
|
|
4177
|
+
var tool_execution_end_server_default = (0, _agimon_ai_po_core_extensionFile.defineRoutedContribution)((context) => context.root.hooks?.[0], { cardinality: "optional" });
|
|
4178
|
+
//#endregion
|
|
4179
|
+
//#region src/extensions/workspaces/sessions/(backend)/resource/po-author-workflow.server.ts
|
|
4180
|
+
var po_author_workflow_server_default = (0, _agimon_ai_po_core_extensionFile.defineRoutedContribution)((context) => context.root.resources?.[0], { cardinality: "optional" });
|
|
4181
|
+
//#endregion
|
|
4182
|
+
//#region src/extensions/workspaces/sessions/(backend)/resource/po-use-workflow.server.ts
|
|
4183
|
+
var po_use_workflow_server_default = (0, _agimon_ai_po_core_extensionFile.defineRoutedContribution)((context) => context.root.resources?.[1], { cardinality: "optional" });
|
|
4184
|
+
//#endregion
|
|
4185
|
+
//#region src/extensions/workspaces/sessions/(backend)/resource/workflow-recovery.server.ts
|
|
4186
|
+
var workflow_recovery_server_default = (0, _agimon_ai_po_core_extensionFile.defineRoutedContribution)((context) => context.root.resources?.[2], { cardinality: "optional" });
|
|
4187
|
+
//#endregion
|
|
4188
|
+
//#region src/extensions/workspaces/sessions/(backend)/resource/workflow-session.server.ts
|
|
4189
|
+
var workflow_session_server_default = (0, _agimon_ai_po_core_extensionFile.defineRoutedContribution)((context) => context.root.resources?.[3], { cardinality: "optional" });
|
|
4190
|
+
//#endregion
|
|
4191
|
+
//#region src/extensions/workspaces/sessions/(backend)/tool-restriction/dispatcher.server.ts
|
|
4192
|
+
var dispatcher_server_default = (0, _agimon_ai_po_core_extensionFile.defineRoutedContribution)((context) => context.root.toolRestrictions?.[0], { cardinality: "optional" });
|
|
4193
|
+
//#endregion
|
|
4194
|
+
//#region src/extensions/workspaces/sessions/(backend)/tool/launch-workflow.server.ts
|
|
4195
|
+
var launch_workflow_server_default = (0, _agimon_ai_po_core_extensionFile.defineRoutedContribution)((context) => context.root.tools?.[1], { cardinality: "optional" });
|
|
4196
|
+
//#endregion
|
|
4197
|
+
//#region src/extensions/workspaces/sessions/(backend)/tool/list-workflows.server.ts
|
|
4198
|
+
var list_workflows_server_default = (0, _agimon_ai_po_core_extensionFile.defineRoutedContribution)((context) => context.root.tools?.[0], { cardinality: "optional" });
|
|
4199
|
+
//#endregion
|
|
4200
|
+
//#region src/extensions/workspaces/sessions/(backend)/tool/workflow-run.server.ts
|
|
4201
|
+
var workflow_run_server_default = (0, _agimon_ai_po_core_extensionFile.defineRoutedContribution)((context) => context.root.tools?.[2], { cardinality: "optional" });
|
|
4202
|
+
//#endregion
|
|
4203
|
+
//#region src/extensions/workspaces/sessions/(backend)/tool/workflow-tools.server.ts
|
|
4204
|
+
var workflow_tools_server_default = (0, _agimon_ai_po_core_extensionFile.defineRoutedContribution)((context) => context.root.tools?.[3], { cardinality: "optional" });
|
|
4205
|
+
//#endregion
|
|
4206
|
+
//#region generated/server.ts
|
|
4207
|
+
const at = (value, context) => typeof value === "function" ? value(context) : value;
|
|
4208
|
+
/* v8 ignore next -- generated normalization branch */
|
|
4209
|
+
const defined = (values) => values.filter((value) => value !== void 0);
|
|
4210
|
+
const via = (identity, value) => value === void 0 ? void 0 : {
|
|
4211
|
+
...identity,
|
|
4212
|
+
...value
|
|
4213
|
+
};
|
|
4214
|
+
const facet = (0, _agimon_ai_po_core_serverFacet.defineServerPlugin)({
|
|
4215
|
+
name: "@agimon-ai/po-workflow",
|
|
4216
|
+
providerOverrides: (0, _agimon_ai_po_core_serverFacet.composeServerProviderOverrides)(root_server_default),
|
|
4217
|
+
global: (context) => ({
|
|
4218
|
+
get api() {
|
|
4219
|
+
return defined([at(route_server_default$1, context)]);
|
|
4220
|
+
},
|
|
4221
|
+
get channels() {
|
|
4222
|
+
return [() => via({ frameType: "workflow_catalog" }, workflow_catalog_server_default()), () => via({ frameType: "workflows" }, workflows_server_default())];
|
|
4223
|
+
}
|
|
4224
|
+
}),
|
|
4225
|
+
workspace: (context) => ({
|
|
4226
|
+
get api() {
|
|
4227
|
+
return defined([at(route_server_default$1, context)]);
|
|
4228
|
+
},
|
|
4229
|
+
get channels() {
|
|
4230
|
+
return [() => via({ frameType: "workflow_catalog" }, workflow_catalog_server_default()), () => via({ frameType: "workflows" }, workflows_server_default())];
|
|
4231
|
+
}
|
|
4232
|
+
}),
|
|
4233
|
+
session: async (context) => {
|
|
4234
|
+
const scopeSession = await root_server_default(context);
|
|
4235
|
+
const contextSession = {
|
|
4236
|
+
...context,
|
|
4237
|
+
root: scopeSession.value
|
|
4238
|
+
};
|
|
4239
|
+
return {
|
|
4240
|
+
...(0, _agimon_ai_po_core_extensionFile.composeRootHooks)(scopeSession),
|
|
4241
|
+
get api() {
|
|
4242
|
+
return defined([at(route_server_default$1, context), at(route_server_default, contextSession)]);
|
|
4243
|
+
},
|
|
4244
|
+
get channels() {
|
|
4245
|
+
return [() => via({ frameType: "workflow_catalog" }, workflow_catalog_server_default()), () => via({ frameType: "workflows" }, workflows_server_default())];
|
|
4246
|
+
},
|
|
4247
|
+
get commands() {
|
|
4248
|
+
return defined([via({ name: "workflow-launch" }, at(workflow_launch_server_default, contextSession))]);
|
|
4249
|
+
},
|
|
4250
|
+
get hooks() {
|
|
4251
|
+
return defined([
|
|
4252
|
+
via({ event: "agent_settled" }, at(agent_settled_server_default, contextSession)),
|
|
4253
|
+
via({ event: "agent_start" }, at(agent_start_server_default, contextSession)),
|
|
4254
|
+
via({ event: "session_shutdown" }, at(session_shutdown_server_default, contextSession)),
|
|
4255
|
+
via({ event: "session_start" }, at(session_start_server_default, contextSession)),
|
|
4256
|
+
via({ event: "tool_execution_end" }, at(tool_execution_end_server_default, contextSession))
|
|
4257
|
+
]);
|
|
4258
|
+
},
|
|
4259
|
+
get resources() {
|
|
4260
|
+
return defined([
|
|
4261
|
+
at(po_author_workflow_server_default, contextSession),
|
|
4262
|
+
at(po_use_workflow_server_default, contextSession),
|
|
4263
|
+
at(workflow_recovery_server_default, contextSession),
|
|
4264
|
+
at(workflow_session_server_default, contextSession)
|
|
4265
|
+
]);
|
|
4266
|
+
},
|
|
4267
|
+
get toolRestrictions() {
|
|
4268
|
+
return defined([at(dispatcher_server_default, contextSession)]);
|
|
4269
|
+
},
|
|
4270
|
+
get tools() {
|
|
4271
|
+
return defined([
|
|
4272
|
+
via({ name: "launch_workflow" }, at(launch_workflow_server_default, contextSession)),
|
|
4273
|
+
via({ name: "list_workflows" }, at(list_workflows_server_default, contextSession)),
|
|
4274
|
+
via({ name: "workflow_run" }, at(workflow_run_server_default, contextSession)),
|
|
4275
|
+
via({ name: "workflow_tools" }, at(workflow_tools_server_default, contextSession))
|
|
4276
|
+
]);
|
|
4277
|
+
},
|
|
4278
|
+
services: [...scopeSession.services ?? []],
|
|
4279
|
+
get activities() {
|
|
4280
|
+
return [...scopeSession.activities ?? []];
|
|
4281
|
+
}
|
|
4282
|
+
};
|
|
4283
|
+
}
|
|
4284
|
+
});
|
|
4285
|
+
//#endregion
|
|
4286
|
+
exports.default = facet;
|
|
4287
|
+
exports.facet = facet;
|
|
4288
|
+
|
|
4289
|
+
//# sourceMappingURL=server.cjs.map
|