@agentic-surfaces/cli 0.1.29 → 0.1.31

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.
Files changed (2) hide show
  1. package/dist/index.js +243 -52
  2. package/package.json +4 -4
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
- import { readFileSync, readdirSync, existsSync } from "node:fs";
2
- import { join, dirname, resolve } from "node:path";
1
+ import { readFileSync, readdirSync, existsSync, watch as watchFs, } from "node:fs";
2
+ import { join, dirname, resolve, relative, sep } from "node:path";
3
3
  import { spawn } from "node:child_process";
4
- import { loadWorkflow, runWorkflowOnce, Scheduler, loadProjectConfig, buildWorkflowRunner, defaultRegistry, parseAgentFile, FilePauseState } from "@agentic-surfaces/core";
4
+ import { loadWorkflow, runWorkflowOnce, Scheduler, loadProjectConfig, buildWorkflowRunner, defaultRegistry, parseAgentFile, FilePauseState, } from "@agentic-surfaces/core";
5
5
  import { serve, StreamingObserver } from "@agentic-surfaces/server";
6
6
  import { buildServices } from "./services.js";
7
7
  const CONFIG = "agentic-surfaces.config.yaml";
@@ -19,7 +19,8 @@ function loadDotenv(dir = process.cwd()) {
19
19
  if (!m)
20
20
  continue; // skips blanks + `# comments`
21
21
  let val = m[2];
22
- if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'")))
22
+ if ((val.startsWith('"') && val.endsWith('"')) ||
23
+ (val.startsWith("'") && val.endsWith("'")))
23
24
  val = val.slice(1, -1);
24
25
  if (process.env[m[1]] === undefined)
25
26
  process.env[m[1]] = val;
@@ -41,15 +42,63 @@ function discoverProjectDir(start) {
41
42
  dir = parent;
42
43
  }
43
44
  }
44
- // Load every workflow yaml in a folder into a name→Workflow map.
45
+ // Recursively collect every workflow yaml under `dir` as absolute file paths.
46
+ // Subdirectories are walked so workflows can be organised into folders (the
47
+ // folder path becomes the sidebar group when a workflow omits an explicit
48
+ // `group:` field — see loadWorkflows).
49
+ function collectWorkflowFiles(dir) {
50
+ const out = [];
51
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
52
+ const full = join(dir, entry.name);
53
+ if (entry.isDirectory()) {
54
+ out.push(...collectWorkflowFiles(full));
55
+ }
56
+ else if (entry.isFile() &&
57
+ entry.name.endsWith(".yaml") &&
58
+ entry.name !== CONFIG) {
59
+ out.push(full);
60
+ }
61
+ }
62
+ return out;
63
+ }
64
+ // Derive the fallback group for a workflow file from its subdirectory path
65
+ // relative to the workflows dir. `workflows/sentry/poll.yaml` → "sentry";
66
+ // `workflows/sentry/bugs/x.yaml` → "sentry/bugs"; a file at the root → undefined.
67
+ function groupFromPath(rootDir, file) {
68
+ const rel = relative(rootDir, dirname(file));
69
+ if (!rel || rel === "." || rel.startsWith(".."))
70
+ return undefined;
71
+ return rel.split(sep).join("/");
72
+ }
73
+ // Load every workflow yaml under a folder (recursively) into a name→Workflow map.
74
+ // Grouping precedence: an explicit top-level `group:` field on the workflow wins;
75
+ // otherwise the file's subdirectory path becomes the group; a workflow at the root
76
+ // with no `group:` stays ungrouped. The map is keyed by workflow `name`, which must
77
+ // stay globally unique across subdirs (sub-workflow `foreach`/run-workflow refs
78
+ // resolve by name) — a collision is a hard error rather than a silent overwrite.
45
79
  function loadWorkflows(dir) {
46
80
  const map = new Map();
47
- for (const f of readdirSync(dir).filter((f) => f.endsWith(".yaml") && f !== CONFIG)) {
81
+ const seenAt = new Map(); // name file that defined it
82
+ for (const file of collectWorkflowFiles(dir)) {
83
+ let w;
48
84
  try {
49
- const w = loadWorkflow(readFileSync(join(dir, f), "utf8"));
50
- map.set(w.name, w);
85
+ w = loadWorkflow(readFileSync(file, "utf8"));
86
+ }
87
+ catch {
88
+ continue; /* skip unparseable */
51
89
  }
52
- catch { /* skip unparseable */ }
90
+ if (seenAt.has(w.name)) {
91
+ throw new Error(`duplicate workflow name "${w.name}" — defined in both ${relative(dir, seenAt.get(w.name))} and ${relative(dir, file)}. ` +
92
+ `Workflow names must be globally unique (they are how sub-workflows are referenced).`);
93
+ }
94
+ seenAt.set(w.name, file);
95
+ // Explicit `group:` field wins; otherwise fall back to the subdirectory path.
96
+ if (w.group === undefined) {
97
+ const g = groupFromPath(dir, file);
98
+ if (g)
99
+ w = { ...w, group: g };
100
+ }
101
+ map.set(w.name, w);
53
102
  }
54
103
  return map;
55
104
  }
@@ -71,6 +120,74 @@ function loadAgents(projectDir) {
71
120
  }
72
121
  return map;
73
122
  }
123
+ /**
124
+ * Build the in-process hot-reload function for `start`/`ui`. Re-scans the workflows dir (recursively,
125
+ * same loader as startup) + the agents dir, rebuilds the shared maps IN PLACE (so the workflow runner's
126
+ * closure over `workflows` and `services.agents` sees the new set), and re-registers the scheduler's cron
127
+ * timers from the fresh workflow set. Returns the new workflow list for the server to serve.
128
+ *
129
+ * Mutating the existing Map instances (rather than replacing them) is deliberate: the runner built by
130
+ * `buildWorkflowRunner` and the services object both close over those Map references, so `foreach` /
131
+ * run-workflow sub-workflow resolution and agent lookups pick up edits without rebuilding the runner.
132
+ *
133
+ * A reload that fails to parse the dir (e.g. mid-write) is reported and skipped — the previous good set
134
+ * stays live rather than blanking the dashboard.
135
+ */
136
+ function makeReloader(opts) {
137
+ return () => {
138
+ const next = loadWorkflows(opts.workflowsDir); // throws on duplicate names; caught by /api/reload
139
+ // Rebuild the shared workflow map in place.
140
+ opts.workflows.clear();
141
+ for (const [name, wf] of next)
142
+ opts.workflows.set(name, wf);
143
+ // Rebuild the shared agents map in place.
144
+ const nextAgents = loadAgents(opts.projectDir);
145
+ opts.agents.clear();
146
+ for (const [name, def] of nextAgents)
147
+ opts.agents.set(name, def);
148
+ // Re-register cron timers from the fresh set (cancels the old ones — no leaks, no double-fire).
149
+ opts.scheduler.reload([...next.values()]);
150
+ return [...next.values()];
151
+ };
152
+ }
153
+ /**
154
+ * Watch the project dir (recursively) and hot-reload on workflow/agent edits. Editors fire several
155
+ * fs events per save, so the reload is debounced (~400ms). After a successful reload it emits a
156
+ * `workflows:changed` SSE event so the dashboard re-fetches /api/workflows without a refresh.
157
+ *
158
+ * Loop guard: a reload only reads files (the scheduler + maps are rebuilt from them) and never writes
159
+ * back into the watched dir, so a reload cannot retrigger itself; the debounce additionally collapses
160
+ * any burst into a single reload.
161
+ *
162
+ * This is the in-process successor to the old `--watch` (which exited for a supervisor restart). The
163
+ * launchd path that relied on `start --watch` still works — see the `start` command for the flag's
164
+ * retained behavior.
165
+ */
166
+ function watchAndReload(dir, observer, reload) {
167
+ let pending;
168
+ try {
169
+ watchFs(dir, { recursive: true }, (_event, filename) => {
170
+ if (!filename || !/\.(yaml|md)$/.test(filename.toString()))
171
+ return;
172
+ clearTimeout(pending);
173
+ pending = setTimeout(() => {
174
+ try {
175
+ const next = reload();
176
+ observer.notifyWorkflowsChanged();
177
+ console.log(`hot-reload: ${next.length} workflow(s) (changed: ${filename})`);
178
+ }
179
+ catch (err) {
180
+ // Keep the previous good set live; just report (e.g. mid-write parse error or a name clash).
181
+ console.error("hot-reload skipped:", err instanceof Error ? err.message : String(err));
182
+ }
183
+ }, 400); // debounce editor multi-writes
184
+ });
185
+ }
186
+ catch (err) {
187
+ // recursive watch isn't supported everywhere; degrade to manual reload (the Reload button).
188
+ console.error("hot-reload watch unavailable (use the Reload button):", err instanceof Error ? err.message : String(err));
189
+ }
190
+ }
74
191
  // Walk up to the directory that contains agentic-surfaces.config.yaml.
75
192
  function findProjectDir(start) {
76
193
  let dir = resolve(start);
@@ -107,7 +224,13 @@ function launchUi(opts) {
107
224
  cache: opts.cache,
108
225
  pause: opts.pause,
109
226
  planUsage: opts.planUsage,
110
- config: { dryRun: opts.dryRun, agent: opts.agentDefaults, version: VERSION, atlassianSite: opts.atlassianSite },
227
+ onReload: opts.onReload,
228
+ config: {
229
+ dryRun: opts.dryRun,
230
+ agent: opts.agentDefaults,
231
+ version: VERSION,
232
+ atlassianSite: opts.atlassianSite,
233
+ },
111
234
  });
112
235
  const url = `http://127.0.0.1:${p}`;
113
236
  openBrowser(url);
@@ -116,8 +239,10 @@ function launchUi(opts) {
116
239
  function openBrowser(url) {
117
240
  if (process.env["FLOW_NO_OPEN"])
118
241
  return; // headless / CI / don't hijack a browser
119
- const cmd = process.platform === "darwin" ? "open"
120
- : process.platform === "win32" ? "start"
242
+ const cmd = process.platform === "darwin"
243
+ ? "open"
244
+ : process.platform === "win32"
245
+ ? "start"
121
246
  : "xdg-open";
122
247
  try {
123
248
  spawn(cmd, [url], { stdio: "ignore", detached: true }).unref();
@@ -164,8 +289,17 @@ export async function run(argv) {
164
289
  const registry = defaultRegistry();
165
290
  const observer = new StreamingObserver();
166
291
  const services = buildServices({ projectConfig: pc, projectDir: dir });
167
- const runWorkflowFn = buildWorkflowRunner({ workflows: allWorkflows, registry, services: { ...services, agents }, observer });
168
- const servicesWithRunner = { ...services, agents, runWorkflow: runWorkflowFn };
292
+ const runWorkflowFn = buildWorkflowRunner({
293
+ workflows: allWorkflows,
294
+ registry,
295
+ services: { ...services, agents },
296
+ observer,
297
+ });
298
+ const servicesWithRunner = {
299
+ ...services,
300
+ agents,
301
+ runWorkflow: runWorkflowFn,
302
+ };
169
303
  // Platform-wide play/pause (file-backed so it survives a --watch restart).
170
304
  const pause = new FilePauseState();
171
305
  // Cron-triggered workflows fire on schedule too.
@@ -173,8 +307,17 @@ export async function run(argv) {
173
307
  for (const [, w] of allWorkflows)
174
308
  sched.add(w);
175
309
  sched.start();
310
+ const reload = makeReloader({
311
+ workflowsDir,
312
+ projectDir: dir,
313
+ workflows: allWorkflows,
314
+ agents,
315
+ scheduler: sched,
316
+ });
176
317
  const url = launchUi({
177
- observer, workflows: allWorkflows, agents,
318
+ observer,
319
+ workflows: allWorkflows,
320
+ agents,
178
321
  onRun: (name, payload) => runWorkflowFn(name, payload),
179
322
  cache: services.cache,
180
323
  pause,
@@ -182,9 +325,13 @@ export async function run(argv) {
182
325
  dryRun: pc?.dryRun,
183
326
  agentDefaults: { model: pc?.agent?.model, effort: pc?.agent?.effort },
184
327
  atlassianSite: pc?.atlassianSite,
328
+ onReload: reload,
185
329
  });
330
+ // Automatic hot reload: a debounced recursive watch on the project dir re-scans + re-registers
331
+ // the scheduler in place and pushes a `workflows:changed` SSE event so the dashboard re-fetches.
332
+ watchAndReload(dir, observer, reload);
186
333
  console.log(`\n▶ agentic-surfaces ${VERSION} — ${url}`);
187
- console.log(` project: ${dir} · ${allWorkflows.size} workflow(s) · Ctrl-C to exit`);
334
+ console.log(` project: ${dir} · ${allWorkflows.size} workflow(s) · hot-reload on · Ctrl-C to exit`);
188
335
  await new Promise(() => { }); // serve until interrupted
189
336
  return 0;
190
337
  }
@@ -194,7 +341,7 @@ export async function run(argv) {
194
341
  return 0;
195
342
  }
196
343
  if (cmd === "run-once" || cmd === "run") {
197
- const file = rest.find(a => !a.startsWith("--"));
344
+ const file = rest.find((a) => !a.startsWith("--"));
198
345
  if (!file) {
199
346
  console.error("run: missing workflow file");
200
347
  return 1;
@@ -203,36 +350,60 @@ export async function run(argv) {
203
350
  const pc = findProjectConfig(dirname(file));
204
351
  const wf = loadWorkflow(readFileSync(file, "utf8"));
205
352
  const projectDir = findProjectDir(dirname(file)) ?? dirname(file);
206
- const services = buildServices(fake ? { fakeAgent: [{ text: "fake reply" }], projectConfig: pc, projectDir } : { projectConfig: pc, projectDir });
207
- const dir = dirname(file);
208
- const allWorkflows = new Map();
209
- for (const f of readdirSync(dir).filter(f => f.endsWith(".yaml") && f !== "agentic-surfaces.config.yaml")) {
210
- try {
211
- const w = loadWorkflow(readFileSync(join(dir, f), "utf8"));
212
- allWorkflows.set(w.name, w);
353
+ const services = buildServices(fake
354
+ ? {
355
+ fakeAgent: [{ text: "fake reply" }],
356
+ projectConfig: pc,
357
+ projectDir,
213
358
  }
214
- catch { /* skip unparseable */ }
215
- }
359
+ : { projectConfig: pc, projectDir });
360
+ // Scan recursively from the workflows root (so sub-workflow refs resolve
361
+ // across subdir folders + groups come through), falling back to the file's
362
+ // own dir when no project config is present.
363
+ const scanDir = pc?.workflows
364
+ ? join(projectDir, pc.workflows)
365
+ : dirname(file);
366
+ const allWorkflows = loadWorkflows(scanDir);
216
367
  const agents = loadAgents(projectDir);
217
368
  const registry = defaultRegistry();
218
369
  const ui = rest.includes("--ui");
219
370
  const observer = ui ? new StreamingObserver() : undefined;
220
- const runWorkflowFn = buildWorkflowRunner({ workflows: allWorkflows, registry, services: { ...services, agents }, observer });
221
- const servicesWithRunner = { ...services, agents, runWorkflow: runWorkflowFn };
222
- const url = ui ? launchUi({
223
- observer: observer, workflows: allWorkflows, agents,
224
- onRun: (name, payload) => runWorkflowFn(name, payload),
225
- cache: services.cache,
226
- dryRun: pc?.dryRun,
227
- agentDefaults: { model: pc?.agent?.model, effort: pc?.agent?.effort },
228
- atlassianSite: pc?.atlassianSite,
229
- }) : undefined;
371
+ const runWorkflowFn = buildWorkflowRunner({
372
+ workflows: allWorkflows,
373
+ registry,
374
+ services: { ...services, agents },
375
+ observer,
376
+ });
377
+ const servicesWithRunner = {
378
+ ...services,
379
+ agents,
380
+ runWorkflow: runWorkflowFn,
381
+ };
382
+ const url = ui
383
+ ? launchUi({
384
+ observer: observer,
385
+ workflows: allWorkflows,
386
+ agents,
387
+ onRun: (name, payload) => runWorkflowFn(name, payload),
388
+ cache: services.cache,
389
+ dryRun: pc?.dryRun,
390
+ agentDefaults: {
391
+ model: pc?.agent?.model,
392
+ effort: pc?.agent?.effort,
393
+ },
394
+ atlassianSite: pc?.atlassianSite,
395
+ })
396
+ : undefined;
230
397
  // A run error must NOT tear down the --ui server: the failure is already
231
398
  // streamed to the dashboard (the failed node + run), so keep serving so it
232
399
  // stays inspectable. Without --ui, an error still exits non-zero.
233
400
  let failed = false;
234
401
  try {
235
- const outputs = await runWorkflowOnce(wf, { registry, services: servicesWithRunner, observer });
402
+ const outputs = await runWorkflowOnce(wf, {
403
+ registry,
404
+ services: servicesWithRunner,
405
+ observer,
406
+ });
236
407
  console.log("ran", wf.name, "->", [...outputs.keys()].join(", "));
237
408
  }
238
409
  catch (err) {
@@ -246,34 +417,45 @@ export async function run(argv) {
246
417
  return failed ? 1 : 0;
247
418
  }
248
419
  if (cmd === "start") {
249
- const dir = rest.find(a => !a.startsWith("--")) ?? ".";
420
+ const dir = rest.find((a) => !a.startsWith("--")) ?? ".";
250
421
  const pc = loadProjectConfig(dir);
251
422
  const workflowsDir = pc?.workflows ? join(dir, pc.workflows) : dir;
252
423
  const services = buildServices({ projectConfig: pc, projectDir: dir });
253
- const allWorkflows = new Map();
254
- const yamlFiles = readdirSync(workflowsDir).filter(f => f.endsWith(".yaml") && f !== "agentic-surfaces.config.yaml");
255
- for (const f of yamlFiles) {
256
- try {
257
- const w = loadWorkflow(readFileSync(join(workflowsDir, f), "utf8"));
258
- allWorkflows.set(w.name, w);
259
- }
260
- catch { /* skip unparseable */ }
261
- }
424
+ const allWorkflows = loadWorkflows(workflowsDir);
262
425
  const agents = loadAgents(dir);
263
426
  const registry = defaultRegistry();
264
427
  const ui = rest.includes("--ui");
265
428
  const observer = ui ? new StreamingObserver() : undefined;
266
- const runWorkflowFn = buildWorkflowRunner({ workflows: allWorkflows, registry, services: { ...services, agents }, observer });
267
- const servicesWithRunner = { ...services, agents, runWorkflow: runWorkflowFn };
429
+ const runWorkflowFn = buildWorkflowRunner({
430
+ workflows: allWorkflows,
431
+ registry,
432
+ services: { ...services, agents },
433
+ observer,
434
+ });
435
+ const servicesWithRunner = {
436
+ ...services,
437
+ agents,
438
+ runWorkflow: runWorkflowFn,
439
+ };
268
440
  const pause = new FilePauseState();
269
441
  const sched = new Scheduler(servicesWithRunner, registry, observer, pause);
270
442
  for (const [, w] of allWorkflows)
271
443
  sched.add(w);
272
444
  sched.start();
445
+ const reload = makeReloader({
446
+ workflowsDir,
447
+ projectDir: dir,
448
+ workflows: allWorkflows,
449
+ agents,
450
+ scheduler: sched,
451
+ });
273
452
  console.log(`scheduler started; watching ${workflowsDir}${pause.isPaused() ? " (PAUSED)" : ""}`);
453
+ const watchFlag = rest.includes("--watch");
274
454
  if (ui) {
275
455
  const url = launchUi({
276
- observer: observer, workflows: allWorkflows, agents,
456
+ observer: observer,
457
+ workflows: allWorkflows,
458
+ agents,
277
459
  onRun: (name, payload) => runWorkflowFn(name, payload),
278
460
  cache: services.cache,
279
461
  pause,
@@ -281,13 +463,22 @@ export async function run(argv) {
281
463
  dryRun: pc?.dryRun,
282
464
  agentDefaults: { model: pc?.agent?.model, effort: pc?.agent?.effort },
283
465
  atlassianSite: pc?.atlassianSite,
466
+ onReload: reload,
284
467
  });
285
468
  console.log(`▶ agentic-surfaces ${VERSION} — ${url}`);
469
+ // With the dashboard up, prefer in-process hot reload (button + automatic) over the legacy
470
+ // process-exit watcher — unless --watch is explicitly passed (the supervisor-restart contract).
471
+ if (!watchFlag) {
472
+ watchAndReload(dir, observer, reload);
473
+ console.log("hot-reload on (Reload button + automatic file watch)");
474
+ }
286
475
  }
287
476
  // --watch: exit when a config file changes so a supervisor (launchd/pm2/systemd) restarts us
288
477
  // with the fresh config. The seen-cache (.flow-cache.sqlite) persists across the restart, so
289
- // dedup is preserved. Without a supervisor this just exits on the first edit.
290
- if (rest.includes("--watch")) {
478
+ // dedup is preserved. Without a supervisor this just exits on the first edit. RETAINED for the
479
+ // launchd path; the dashboard's in-process hot reload above is the lighter-weight default when
480
+ // --watch is omitted. When --watch is set it wins (a supervisor expects the process to exit).
481
+ if (watchFlag) {
291
482
  const { watch } = await import("node:fs");
292
483
  let pending;
293
484
  watch(dir, { recursive: true }, (_event, filename) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentic-surfaces/cli",
3
- "version": "0.1.29",
3
+ "version": "0.1.31",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -22,9 +22,9 @@
22
22
  "README.md"
23
23
  ],
24
24
  "dependencies": {
25
- "@agentic-surfaces/core": "0.1.29",
26
- "@agentic-surfaces/agent": "0.1.29",
27
- "@agentic-surfaces/server": "0.1.29"
25
+ "@agentic-surfaces/core": "0.1.31",
26
+ "@agentic-surfaces/agent": "0.1.31",
27
+ "@agentic-surfaces/server": "0.1.31"
28
28
  },
29
29
  "repository": {
30
30
  "type": "git",