@agimon-ai/doompi-workflow 0.0.1-alpha.113 → 0.0.1-alpha.115

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.
@@ -8,12 +8,14 @@ import path, { basename, isAbsolute, join, relative, resolve } from "node:path";
8
8
  import { NativeTerminalService, WORKFLOW_DECISION_FILE_ENV, WORKFLOW_DECISION_STEERING_ENV, WORKFLOW_DECISION_STEERING_HOST, WorkflowRegistryService, WorkflowTerminalService, connectNativeTerminal, createEmbeddedWorkflowFeature, diagnoseWorkflow, gateStepDecision, readStepDecision, readWorkflowCatalog, serveNativeTerminal, summarizeWorkflow, terminalSocketPath } from "@agimon-ai/workflow-mcp";
9
9
  import { z } from "zod";
10
10
  import { randomUUID } from "node:crypto";
11
+ import { execFile } from "node:child_process";
11
12
  import { defineMinorMode, serverMinorModes } from "@agimon-ai/doompi-minor-mode";
12
13
  import { createDoomTelemetry } from "@agimon-ai/doompi-telemetry";
13
14
  import { defineServerPlugin } from "@agimon-ai/doompi-core/serverFacet";
14
15
  import { DOOM_NOTIFICATION_ENTRY_TYPE, createDoomNotificationEntryData } from "@agimon-ai/doompi-core/notification";
15
16
  import { WORKFLOW_STEP_SESSION_PROVENANCE } from "@agimon-ai/doompi-core/hubChannel";
16
17
  import { Readable } from "node:stream";
18
+ import { doomApiCallerFrom } from "@agimon-ai/doompi-core/packageApi";
17
19
  import { Hono } from "hono";
18
20
  import { streamSSE } from "hono/streaming";
19
21
  import { apiResponse, defineApiRoutes } from "@agimon-ai/doompi-core/web";
@@ -216,14 +218,56 @@ function jobPhase(name) {
216
218
  if (name === PHASE_JOB_POST) return PHASE_JOB_POST;
217
219
  return "job";
218
220
  }
219
- /**
220
- * Folds the event log into the current job tree; later events win, so a job
221
- * that started and later failed reads as failed. Jobs and steps keep the
222
- * order of their first appearance.
223
- */
224
- function foldWorkflowProgress(events) {
225
- const jobs = [];
226
- const jobByName = /* @__PURE__ */ new Map();
221
+ /** Names match the engine's progress log, including unnamed parallel children. */
222
+ function plannedSteps(steps = []) {
223
+ return steps.flatMap((step, index) => {
224
+ const name = step.name ?? `step ${index + 1}`;
225
+ return step.parallel === void 0 ? [{
226
+ name,
227
+ status: "pending"
228
+ }] : step.parallel.map((child, childIndex) => ({
229
+ name: child.name ?? `${name} ${childIndex + 1}`,
230
+ status: "pending",
231
+ group: name
232
+ }));
233
+ });
234
+ }
235
+ /** The whole declared route, before any job or step has emitted progress. */
236
+ function planWorkflowJobs(workflow, order) {
237
+ const jobs = order.map((name) => {
238
+ const job = workflow.jobs[name];
239
+ return {
240
+ name,
241
+ phase: "job",
242
+ status: "pending",
243
+ steps: [
244
+ ...plannedSteps(job.preJob?.steps),
245
+ ...plannedSteps(job.steps),
246
+ ...plannedSteps(job.postJob?.steps)
247
+ ]
248
+ };
249
+ });
250
+ if (workflow.pre !== void 0) jobs.unshift({
251
+ name: PHASE_JOB_PRE,
252
+ phase: "pre",
253
+ status: "pending",
254
+ steps: plannedSteps(workflow.pre.steps)
255
+ });
256
+ if (workflow.post !== void 0) jobs.push({
257
+ name: PHASE_JOB_POST,
258
+ phase: "post",
259
+ status: "pending",
260
+ steps: plannedSteps(workflow.post.steps)
261
+ });
262
+ return jobs;
263
+ }
264
+ /** Overlay progress on the plan, retaining recorded jobs and steps absent from the current definition. */
265
+ function foldWorkflowProgress(events, plan = []) {
266
+ const jobs = plan.map((job) => ({
267
+ ...job,
268
+ steps: job.steps.map((step) => ({ ...step }))
269
+ }));
270
+ const jobByName = new Map(jobs.map((job) => [job.name, job]));
227
271
  for (const event of events) {
228
272
  let job = jobByName.get(event.job);
229
273
  if (job === void 0) {
@@ -251,6 +295,7 @@ function foldWorkflowProgress(events) {
251
295
  continue;
252
296
  }
253
297
  if (event.step === void 0) continue;
298
+ if (job.status === "pending") job.status = event.status;
254
299
  let step = job.steps.find((candidate) => candidate.name === event.step);
255
300
  if (step === void 0) {
256
301
  step = {
@@ -271,6 +316,9 @@ function foldWorkflowProgress(events) {
271
316
  if (event.status === "running" && step.startedAt === void 0) step.startedAt = event.at;
272
317
  if (STEP_TERMINAL_STATES.has(event.status)) step.endedAt = event.at;
273
318
  }
319
+ for (const job of jobs) if (job.status === "skipped") {
320
+ for (const step of job.steps) if (step.status === "pending") step.status = "skipped";
321
+ }
274
322
  return jobs;
275
323
  }
276
324
  const ACTIVE_PROGRESS_STATES = /* @__PURE__ */ new Set([
@@ -1361,9 +1409,15 @@ var apiRoutes_default = defineApiRoutes({
1361
1409
  path: "/launch",
1362
1410
  response: apiResponse()
1363
1411
  },
1412
+ openRunDirectory: {
1413
+ method: "POST",
1414
+ path: `${RUN}/open-directory`,
1415
+ response: apiResponse()
1416
+ },
1364
1417
  artifacts: {
1365
1418
  method: "GET",
1366
1419
  path: `${RUN}/artifacts`,
1420
+ query: ["directory"],
1367
1421
  response: apiResponse()
1368
1422
  },
1369
1423
  /** One artifact: its metadata and text, or its raw bytes under `raw=1`. */
@@ -1674,18 +1728,49 @@ function createWorkflowHubApi(options = {}) {
1674
1728
  }
1675
1729
  return context.json({ deleted: true });
1676
1730
  });
1731
+ app.post(apiRoutes_default.openRunDirectory.path, async (context) => {
1732
+ if (doomApiCallerFrom(context.req.raw.headers)?.locality !== "local") return context.json({ error: "Opening Finder is only available on the host." }, 403);
1733
+ const workspace = context.req.param("workspace");
1734
+ const runKey = context.req.param("runKey");
1735
+ const record = await runOf(workspace, runKey);
1736
+ if (record === void 0) return context.json(missing(workspace, runKey), 404);
1737
+ if (process.platform !== "darwin") return context.json({ error: "Finder is only available on macOS." }, 409);
1738
+ try {
1739
+ const runDir = path.resolve(registry.runDirectoryFor(record));
1740
+ if (!fs.statSync(runDir).isDirectory()) return context.json({ error: "The run directory is not a folder." }, 409);
1741
+ await new Promise((resolve, reject) => {
1742
+ execFile("/usr/bin/open", [
1743
+ "-a",
1744
+ "Finder",
1745
+ runDir
1746
+ ], { timeout: 5e3 }, (error) => {
1747
+ if (error) reject(error);
1748
+ else resolve();
1749
+ });
1750
+ });
1751
+ return context.json({ opened: true });
1752
+ } catch (error) {
1753
+ return context.json({ error: error instanceof Error ? error.message : String(error) }, 409);
1754
+ }
1755
+ });
1677
1756
  app.get(apiRoutes_default.artifacts.path, async (context) => {
1678
1757
  const workspace = context.req.param("workspace");
1679
1758
  const runKey = context.req.param("runKey");
1680
1759
  const record = await runOf(workspace, runKey);
1681
1760
  if (record === void 0) return context.json(missing(workspace, runKey), 404);
1682
1761
  const runDir = registry.runDirectoryFor(record);
1683
- const body = {
1684
- runDir,
1685
- description: record.runDirectory?.description ?? "",
1686
- artifacts: readArtifacts(runDir, record)
1687
- };
1688
- return context.json(body);
1762
+ const directory = context.req.query("directory") ?? "";
1763
+ if (resolveInside(runDir, directory) === void 0) return context.json({ error: "That folder is not inside this run." }, 400);
1764
+ try {
1765
+ const body = {
1766
+ runDir,
1767
+ description: record.runDirectory?.description ?? "",
1768
+ artifacts: readArtifacts(runDir, record, directory)
1769
+ };
1770
+ return context.json(body);
1771
+ } catch (error) {
1772
+ return context.json({ error: error instanceof Error ? error.message : String(error) }, 409);
1773
+ }
1689
1774
  });
1690
1775
  app.get(apiRoutes_default.artifact.path, async (context) => {
1691
1776
  const workspace = context.req.param("workspace");
@@ -1714,7 +1799,20 @@ function resolveInside(runDir, requested) {
1714
1799
  const resolved = path.resolve(runDir, requested);
1715
1800
  const root = path.resolve(runDir);
1716
1801
  if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) return void 0;
1717
- return resolved;
1802
+ try {
1803
+ const realRoot = fs.realpathSync(root);
1804
+ let ancestor = resolved;
1805
+ while (!fs.existsSync(ancestor)) {
1806
+ const parent = path.dirname(ancestor);
1807
+ if (parent === ancestor) return void 0;
1808
+ ancestor = parent;
1809
+ }
1810
+ const real = fs.realpathSync(ancestor);
1811
+ if (real !== realRoot && !real.startsWith(`${realRoot}${path.sep}`)) return void 0;
1812
+ return resolved;
1813
+ } catch {
1814
+ return;
1815
+ }
1718
1816
  }
1719
1817
  /** Size and modification time of one path, or undefined when it is not there. */
1720
1818
  function statOf(target) {
@@ -1730,10 +1828,12 @@ function stateOf(stats) {
1730
1828
  return stats.size === 0 ? "empty" : "written";
1731
1829
  }
1732
1830
  function viewOf(runDir, entry) {
1733
- const stats = statOf(path.join(runDir, entry.path));
1831
+ const target = resolveInside(runDir, entry.path);
1832
+ const stats = target === void 0 ? void 0 : statOf(target);
1734
1833
  return {
1735
1834
  ...entry,
1736
- state: stateOf(stats),
1835
+ kind: stats?.isDirectory() ? "directory" : entry.kind,
1836
+ state: target === void 0 ? "unreadable" : stateOf(stats),
1737
1837
  ...stats === void 0 ? {} : {
1738
1838
  size: stats.size,
1739
1839
  modifiedAt: stats.mtime.toISOString()
@@ -1749,7 +1849,7 @@ function viewOf(runDir, entry) {
1749
1849
  * hidden: the engine's own context.md and progress log are often exactly what a
1750
1850
  * reader is looking for.
1751
1851
  */
1752
- function readArtifacts(runDir, record) {
1852
+ function readArtifacts(runDir, record, directory = "") {
1753
1853
  const declared = (record.runDirectory?.entries ?? []).map((entry) => viewOf(runDir, {
1754
1854
  path: entry.path,
1755
1855
  kind: entry.kind,
@@ -1757,21 +1857,16 @@ function readArtifacts(runDir, record) {
1757
1857
  producedBy: entry["produced-by"] ?? [],
1758
1858
  declared: true
1759
1859
  }));
1760
- const claimed = new Set(declared.map((entry) => entry.path));
1761
- let names;
1762
- try {
1763
- names = fs.readdirSync(runDir, { withFileTypes: true });
1764
- } catch {
1765
- return declared;
1766
- }
1767
- const found = names.filter((entry) => !claimed.has(entry.name)).map((entry) => viewOf(runDir, {
1768
- path: entry.name,
1860
+ const children = declared.filter((entry) => path.posix.dirname(entry.path) === (directory || "."));
1861
+ const claimed = new Set(children.map((entry) => entry.path));
1862
+ const found = fs.readdirSync(path.join(runDir, directory), { withFileTypes: true }).filter((entry) => !claimed.has(path.posix.join(directory, entry.name))).map((entry) => viewOf(runDir, {
1863
+ path: path.posix.join(directory, entry.name),
1769
1864
  kind: entry.isDirectory() ? "directory" : "file",
1770
1865
  description: "",
1771
1866
  producedBy: [],
1772
1867
  declared: false
1773
1868
  })).sort((left, right) => left.path.localeCompare(right.path));
1774
- return [...declared, ...found];
1869
+ return [...directory === "" ? declared : children, ...found];
1775
1870
  }
1776
1871
  /** Browser content type inferred from a filename without trusting client input. */
1777
1872
  function artifactMimeType(requested) {
@@ -2072,6 +2167,7 @@ function createWorkflowSessionLifecycle(deps) {
2072
2167
  }
2073
2168
  //#endregion
2074
2169
  //#region src/services/workflowWatcher/index.ts
2170
+ const { parser } = createEmbeddedWorkflowFeature();
2075
2171
  /**
2076
2172
  * Read the durable workflow registry once.
2077
2173
  *
@@ -2119,6 +2215,18 @@ function readWorkflowRuns(options = {}) {
2119
2215
  return [];
2120
2216
  }
2121
2217
  };
2218
+ const plans = /* @__PURE__ */ new Map();
2219
+ const planFor = (workflowPath) => {
2220
+ const cached = plans.get(workflowPath);
2221
+ if (cached !== void 0) return cached;
2222
+ let plan = [];
2223
+ try {
2224
+ const workflow = parser.parseWorkflowFile(workflowPath);
2225
+ plan = planWorkflowJobs(workflow, parser.resolveJobOrder(workflow.jobs, null));
2226
+ } catch {}
2227
+ plans.set(workflowPath, plan);
2228
+ return plan;
2229
+ };
2122
2230
  const runs = [];
2123
2231
  for (const workspace of listDir(workspacesDir)) for (const stage of WORKFLOW_STAGES) {
2124
2232
  const stageDir = path.join(workspacesDir, workspace, stage);
@@ -2128,7 +2236,7 @@ function readWorkflowRuns(options = {}) {
2128
2236
  const progressPath = path.join(runDir, PROGRESS_FILE_NAME);
2129
2237
  const parsed = cachedParse(recordCache, recordPath, parseWorkflowRunRecord, void 0);
2130
2238
  if (parsed === void 0) continue;
2131
- const jobs = cachedParse(progressCache, progressPath, (raw) => foldWorkflowProgress(parseWorkflowProgress(raw)), []);
2239
+ const jobs = foldWorkflowProgress(cachedParse(progressCache, progressPath, parseWorkflowProgress, []), planFor(parsed.view.workflowPath));
2132
2240
  runs.push({
2133
2241
  ...parsed,
2134
2242
  view: completeWorkflowRunView(parsed.view, jobs)
@@ -2734,8 +2842,13 @@ function createWorkflowServerRuntime(host, serverHost) {
2734
2842
  },
2735
2843
  name: LAUNCH_TOOL,
2736
2844
  label: "Launch Workflow",
2737
- description: "Start a workflow run. A successful launch is not a completed workflow.",
2738
- promptGuidelines: ["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.", "Do not relaunch a healthy running workflow merely because it has not completed."],
2845
+ 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.",
2846
+ promptGuidelines: [
2847
+ "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.",
2848
+ "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.",
2849
+ "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.",
2850
+ "Do not relaunch a healthy running workflow merely because it has not completed."
2851
+ ],
2739
2852
  parameters: z.toJSONSchema(feature.runTool.getInputSchema()),
2740
2853
  executionMode: "serial",
2741
2854
  async execute(_toolCallId, parameters, signal, _onUpdate, context) {