@nutteen/conductor-ui 0.1.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.
@@ -0,0 +1,1608 @@
1
+ import { app, nativeImage, BrowserWindow, ipcMain, dialog, shell, nativeTheme } from "electron";
2
+ import { fileURLToPath } from "node:url";
3
+ import { readdir, readFile, writeFile, rm, stat, copyFile, chmod, rename, mkdir, open } from "node:fs/promises";
4
+ import { resolve, basename, delimiter, join, dirname } from "node:path";
5
+ import { loadManagedConfig, resolveRuntimeDir, readRuntimeRecords, loadWorkflow, redactSensitive, serializableGraphSpec, buildRunSnapshotsFromLogLines, isProcessRunning, replayWalks, parseRunEventLines, readGraphCheckpoint, buildControlTowerReport, readLogLines, preflightFlows, readRuntimeRecord, watchWorkflow } from "@nutteen/conductor/lib";
6
+ import { execFile } from "node:child_process";
7
+ import { createRequire } from "node:module";
8
+ import { statSync, accessSync, constants, mkdirSync, writeFileSync, readFileSync, watch } from "node:fs";
9
+ import { createHash } from "node:crypto";
10
+ const CHANNELS = {
11
+ getFlows: "conductor:get-flows",
12
+ getWorkflow: "conductor:get-workflow",
13
+ getTickets: "conductor:get-tickets",
14
+ getTicket: "conductor:get-ticket",
15
+ getReport: "conductor:get-report",
16
+ getInitialView: "conductor:get-initial-view",
17
+ setTheme: "conductor:set-theme",
18
+ openWorkflowFile: "conductor:open-workflow-file",
19
+ revealPath: "conductor:reveal-path",
20
+ subscribe: "conductor:subscribe",
21
+ pushTickets: "conductor:push-tickets",
22
+ pushEvents: "conductor:push-events",
23
+ pushWorkflow: "conductor:push-workflow",
24
+ // Lifecycle and config. Each one is five mechanical edits — this list, a
25
+ // request/response type, a `ConductorBridge` method, a preload line and an
26
+ // `ipcMain.handle` — and because `App.test.tsx` builds a fully typed bridge, a
27
+ // missing stub is a compile error there rather than an empty panel at runtime.
28
+ preflight: "conductor:preflight",
29
+ startFlow: "conductor:start-flow",
30
+ stopFlow: "conductor:stop-flow",
31
+ restartFlow: "conductor:restart-flow",
32
+ getDaemonConfig: "conductor:get-daemon-config",
33
+ chooseDaemonConfig: "conductor:choose-daemon-config",
34
+ readFlowsConfig: "conductor:read-flows-config",
35
+ previewFlowsEdit: "conductor:preview-flows-edit",
36
+ commitFlowsEdit: "conductor:commit-flows-edit",
37
+ pushFlows: "conductor:push-flows",
38
+ pushDaemon: "conductor:push-daemon"
39
+ };
40
+ const LIST_LOG_LIMIT = 4e3;
41
+ const TICKET_LOG_LIMIT = 6e4;
42
+ const STALE_AFTER_MS = 3e5;
43
+ function runtimeDirFor(options = {}) {
44
+ return resolveRuntimeDir({ cliRuntimeDir: options.runtimeDir ?? null, configRuntimeDir: null });
45
+ }
46
+ async function resolveSourceRuntimeDir(configPath, cliRuntimeDir) {
47
+ let configRuntimeDir = null;
48
+ if (configPath) {
49
+ try {
50
+ configRuntimeDir = (await loadManagedConfig(configPath)).runtime_dir ?? null;
51
+ } catch {
52
+ }
53
+ }
54
+ return resolveRuntimeDir({ cliRuntimeDir, configRuntimeDir });
55
+ }
56
+ function flowFromRecord(record) {
57
+ return {
58
+ name: record.name,
59
+ workflow: record.workflow || null,
60
+ log_file: record.log_file || null,
61
+ runtime_dir: record.runtime_dir,
62
+ pid: record.pid,
63
+ // Liveness, not a precondition: a stopped flow still has a log, and the whole
64
+ // point of replaying from the log is that the app works with no daemon running.
65
+ //
66
+ // `isProcessRunning` alone, deliberately **not** `&& !record.stopped_at`.
67
+ // `conductor stop` writes `stopped_at` and removes the pid file synchronously
68
+ // and unconditionally, while the daemon's `shutdown()` aborts in-flight graphs
69
+ // and awaits them — which can take minutes. A record that says stopped is
70
+ // therefore routinely a record whose process is still very much alive, still
71
+ // holding a workspace. `stopped_at` crosses the wire too, but as the thing that
72
+ // distinguishes a clean stop from a crash, never as liveness.
73
+ //
74
+ // `record.pid > 0` is not defensive noise: `isProcessRunning(0)` returns **true**,
75
+ // because `process.kill(0, sig)` signals the whole process group.
76
+ running: record.pid > 0 && isProcessRunning(record.pid),
77
+ started_at: record.started_at || null,
78
+ stopped_at: record.stopped_at ?? null,
79
+ assignee: record.assignee ?? null,
80
+ labels: record.labels ?? []
81
+ };
82
+ }
83
+ function flowFromDeclaration(flow, runtimeDir) {
84
+ return {
85
+ name: flow.name,
86
+ workflow: flow.workflow,
87
+ // No record means no log yet. The panels handle this: it is the same state as a
88
+ // flow whose log has been deleted.
89
+ log_file: null,
90
+ runtime_dir: runtimeDir,
91
+ pid: null,
92
+ running: false,
93
+ started_at: null,
94
+ stopped_at: null,
95
+ assignee: flow.assignee ?? null,
96
+ labels: flow.labels ?? []
97
+ };
98
+ }
99
+ async function declaredFlows(options) {
100
+ if (!options.configPath) return [];
101
+ try {
102
+ return (await loadManagedConfig(options.configPath)).flows;
103
+ } catch {
104
+ return [];
105
+ }
106
+ }
107
+ async function listFlows(options = {}) {
108
+ const runtimeDir = runtimeDirFor(options);
109
+ const records = await readRuntimeRecords(runtimeDir);
110
+ const flows = records.map(flowFromRecord);
111
+ const known = new Set(flows.map((flow) => flow.name));
112
+ for (const declared of await declaredFlows(options)) {
113
+ if (!known.has(declared.name)) flows.push(flowFromDeclaration(declared, runtimeDir));
114
+ }
115
+ return flows.sort((a, b) => a.name.localeCompare(b.name));
116
+ }
117
+ async function findRecord(flow, options) {
118
+ const records = await readRuntimeRecords(runtimeDirFor(options));
119
+ return records.find((record) => record.name === flow) ?? null;
120
+ }
121
+ function isValidationError(value) {
122
+ return typeof value === "object" && value !== null && "code" in value && "message" in value;
123
+ }
124
+ function errorMessage$1(err) {
125
+ if (isValidationError(err)) return `[${err.code}] ${err.message}`;
126
+ return err instanceof Error ? err.message : String(err);
127
+ }
128
+ function attributeDiagnostic(message, level, knownNodes) {
129
+ const quoted = [...message.matchAll(/'([^']+)'/g)].map((match) => match[1]);
130
+ const edgeMatch = /'([^']+)'\s*->\s*'([^']+)'/.exec(message);
131
+ if (edgeMatch) {
132
+ return { level, message, node: null, edge: { from: edgeMatch[1], to: edgeMatch[2] } };
133
+ }
134
+ const named = quoted.find((name) => knownNodes.includes(name));
135
+ return { level, message, node: named ?? null, edge: null };
136
+ }
137
+ function agentProfilesFrom(config) {
138
+ const agents = config["agents"];
139
+ if (typeof agents !== "object" || agents === null || Array.isArray(agents)) return {};
140
+ const profiles = {};
141
+ for (const [name, raw] of Object.entries(agents)) {
142
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
143
+ profiles[name] = { provider: null, model: null };
144
+ continue;
145
+ }
146
+ const entry = raw;
147
+ profiles[name] = {
148
+ provider: typeof entry["provider"] === "string" ? entry["provider"] : null,
149
+ model: typeof entry["model"] === "string" ? entry["model"] : null
150
+ };
151
+ }
152
+ return profiles;
153
+ }
154
+ async function readWorkflow(input, options = {}) {
155
+ let path = input.path ? resolve(input.path) : null;
156
+ let flow = input.flow ?? null;
157
+ if (!path && flow) {
158
+ const record = await findRecord(flow, options);
159
+ const declared = record?.workflow ? null : (await declaredFlows(options)).find((item) => item.name === flow)?.workflow ?? null;
160
+ if (declared) {
161
+ path = declared;
162
+ } else if (!record?.workflow) {
163
+ return {
164
+ flow,
165
+ path: "",
166
+ graph: null,
167
+ agent_profiles: {},
168
+ diagnostics: [
169
+ {
170
+ level: "error",
171
+ message: `Nothing names a workflow file for flow '${flow}': no runtime record, and no matching entry in the selected flows.yaml. Start the flow once, choose a flows.yaml, or open the workflow file directly.`,
172
+ node: null,
173
+ edge: null
174
+ }
175
+ ],
176
+ loaded_at: (/* @__PURE__ */ new Date()).toISOString()
177
+ };
178
+ } else {
179
+ path = record.workflow;
180
+ }
181
+ }
182
+ if (!path) {
183
+ throw new Error("readWorkflow needs either a flow name or a workflow path");
184
+ }
185
+ flow ??= basename(path).replace(/\.md$/i, "");
186
+ try {
187
+ const definition = await loadWorkflow(path);
188
+ const knownNodes = definition.graph.nodes.map((node) => node.name);
189
+ return {
190
+ flow,
191
+ path,
192
+ graph: serializableGraphSpec(definition.graph),
193
+ agent_profiles: agentProfilesFrom(definition.config),
194
+ diagnostics: definition.warnings.map(
195
+ (warning) => attributeDiagnostic(redactSensitive(warning), "warning", knownNodes)
196
+ ),
197
+ loaded_at: (/* @__PURE__ */ new Date()).toISOString()
198
+ };
199
+ } catch (err) {
200
+ return {
201
+ flow,
202
+ path,
203
+ graph: null,
204
+ agent_profiles: {},
205
+ // No graph loaded means no node list to attribute against, so the message
206
+ // stands on its own — which is still strictly more than the log gave you.
207
+ diagnostics: [attributeDiagnostic(redactSensitive(errorMessage$1(err)), "error", [])],
208
+ loaded_at: (/* @__PURE__ */ new Date()).toISOString()
209
+ };
210
+ }
211
+ }
212
+ async function logLinesFor(flow, limit, options) {
213
+ const record = await findRecord(flow, options);
214
+ if (!record) {
215
+ return { lines: [], record: null, errors: [] };
216
+ }
217
+ if (!record.log_file) {
218
+ return { lines: [], record, errors: [`The runtime record for flow '${flow}' names no log file.`] };
219
+ }
220
+ try {
221
+ return { lines: await readLogLines(record.log_file, { limit }), record, errors: [] };
222
+ } catch (err) {
223
+ return { lines: [], record, errors: [redactSensitive(`Cannot read ${record.log_file}: ${errorMessage$1(err)}`)] };
224
+ }
225
+ }
226
+ async function readTickets(flow, options = {}) {
227
+ const { lines, record, errors } = await logLinesFor(flow, LIST_LOG_LIMIT, options);
228
+ const tickets = buildRunSnapshotsFromLogLines(lines, {
229
+ flowName: flow,
230
+ logFile: record?.log_file ?? null,
231
+ processRunning: record ? isProcessRunning(record.pid) : false,
232
+ now: /* @__PURE__ */ new Date(),
233
+ staleAfterMs: STALE_AFTER_MS
234
+ });
235
+ const walks = {};
236
+ const runs = {};
237
+ for (const walk of replayWalks(lines)) {
238
+ walks[walk.run_id] = walk;
239
+ (runs[walk.identifier] ??= []).push(walk);
240
+ }
241
+ return { flow, tickets, walks, runs, generated_at: (/* @__PURE__ */ new Date()).toISOString(), errors };
242
+ }
243
+ function sortableTime(value) {
244
+ if (!value) return 0;
245
+ const parsed = Date.parse(value);
246
+ return Number.isNaN(parsed) ? 0 : parsed;
247
+ }
248
+ function scopeEventsToRun(events, walks, runId) {
249
+ const chronological = [...walks].sort((a, b) => sortableTime(a.started_at) - sortableTime(b.started_at));
250
+ const index = chronological.findIndex((walk) => walk.run_id === runId);
251
+ if (index < 0) return [];
252
+ const from = sortableTime(chronological[index].started_at);
253
+ const next = chronological[index + 1];
254
+ const until = next ? sortableTime(next.started_at) : Number.POSITIVE_INFINITY;
255
+ return events.filter((event) => {
256
+ const at = sortableTime(event.ts);
257
+ return at >= from && at < until;
258
+ });
259
+ }
260
+ async function readTicket(input, options = {}) {
261
+ const { flow, identifier } = input;
262
+ const { lines, record } = await logLinesFor(flow, TICKET_LOG_LIMIT, options);
263
+ const walks = replayWalks(lines, { identifier });
264
+ const walk = (input.runId ? walks.find((item) => item.run_id === input.runId) : walks[0]) ?? null;
265
+ const mine = parseRunEventLines(lines).filter((event) => event.identifier === identifier);
266
+ const events = walk ? scopeEventsToRun(mine, walks, walk.run_id) : [];
267
+ const snapshots = buildRunSnapshotsFromLogLines(lines, {
268
+ flowName: flow,
269
+ logFile: record?.log_file ?? null,
270
+ processRunning: record ? isProcessRunning(record.pid) : false,
271
+ now: /* @__PURE__ */ new Date(),
272
+ staleAfterMs: STALE_AFTER_MS
273
+ });
274
+ const snapshot = snapshots.find(
275
+ (item) => item.issue_key === identifier && (walk === null || item.attempt === walk.attempt)
276
+ ) ?? null;
277
+ const isLatestRun = walk === null || walks[0]?.run_id === walk.run_id;
278
+ const checkpoint = isLatestRun ? await readGraphCheckpoint(runtimeDirFor(options), flow, identifier) : null;
279
+ return {
280
+ flow,
281
+ identifier,
282
+ snapshot,
283
+ walk,
284
+ source: checkpoint ? "checkpoint" : walk ? "log" : "none",
285
+ state: checkpoint?.state ?? null,
286
+ state_updated_at: checkpoint?.updated_at ?? null,
287
+ checkpoint_cursor: checkpoint?.cursor ?? null,
288
+ events
289
+ };
290
+ }
291
+ async function readReport(input, options = {}) {
292
+ return buildControlTowerReport({
293
+ configPath: options.configPath ?? null,
294
+ runtimeDir: options.runtimeDir ?? null,
295
+ flowName: input.flow ?? null,
296
+ json: true,
297
+ recentLimit: 50,
298
+ staleAfterMs: null
299
+ });
300
+ }
301
+ const PATH_MARKER_START = "__CONDUCTOR_PATH__";
302
+ const PATH_MARKER_END = "__CONDUCTOR_PATH_END__";
303
+ const GUI_DEFAULT_PATH = "/usr/bin:/bin:/usr/sbin:/sbin";
304
+ const PROBE_TIMEOUT_MS = 5e3;
305
+ function isExecutableFile(candidate) {
306
+ try {
307
+ if (!statSync(candidate).isFile()) return false;
308
+ accessSync(candidate, constants.X_OK);
309
+ return true;
310
+ } catch {
311
+ return false;
312
+ }
313
+ }
314
+ function runShell(shell2, script, timeoutMs) {
315
+ return new Promise((resolve2) => {
316
+ execFile(
317
+ shell2,
318
+ // `-lc`, never `-ilc`. Interactive mode can draw a prompt, wait on one, or
319
+ // source a config that expects a tty — and this probe runs before a window
320
+ // the user can cancel from.
321
+ ["-lc", script],
322
+ { timeout: timeoutMs, maxBuffer: 1024 * 1024, encoding: "utf-8" },
323
+ (error, stdout, stderr) => {
324
+ const code = error && typeof error.code === "number" ? error.code : error ? null : 0;
325
+ resolve2({ code, stdout: String(stdout), stderr: String(stderr) });
326
+ }
327
+ );
328
+ });
329
+ }
330
+ function defaultIo$1() {
331
+ return { env: process.env, runShell, isExecutableFile };
332
+ }
333
+ function extractMarked(stdout) {
334
+ const start = stdout.lastIndexOf(PATH_MARKER_START);
335
+ if (start < 0) return null;
336
+ const from = start + PATH_MARKER_START.length;
337
+ const end = stdout.indexOf(PATH_MARKER_END, from);
338
+ if (end < 0) return null;
339
+ const value = stdout.slice(from, end).trim();
340
+ return value === "" ? null : value;
341
+ }
342
+ function resolveBinary(name, path, io2 = { isExecutableFile }) {
343
+ for (const dir of path.split(delimiter)) {
344
+ if (dir === "") continue;
345
+ const candidate = join(dir, name);
346
+ if (io2.isExecutableFile(candidate)) return candidate;
347
+ }
348
+ return null;
349
+ }
350
+ async function probeHostEnv(overrides = {}) {
351
+ const io2 = { ...defaultIo$1(), ...overrides };
352
+ const inherited = io2.env["PATH"] ?? "";
353
+ const settle = (path2, source, note) => ({
354
+ path: path2,
355
+ source,
356
+ note,
357
+ node: resolveBinary("node", path2, io2),
358
+ gui_default: path2 === GUI_DEFAULT_PATH
359
+ });
360
+ const shell2 = io2.env["SHELL"];
361
+ if (!shell2) {
362
+ return settle(inherited, "inherited", "No $SHELL is set, so no login shell could be asked for its PATH.");
363
+ }
364
+ let result;
365
+ try {
366
+ result = await io2.runShell(
367
+ shell2,
368
+ `printf '%s%s%s' '${PATH_MARKER_START}' "$PATH" '${PATH_MARKER_END}'`,
369
+ PROBE_TIMEOUT_MS
370
+ );
371
+ } catch (err) {
372
+ const message = err instanceof Error ? err.message : String(err);
373
+ return settle(inherited, "inherited", `Could not run ${shell2} -lc: ${message}`);
374
+ }
375
+ if (result.code !== 0) {
376
+ const detail = result.stderr.trim() || `exit ${String(result.code ?? "timeout")}`;
377
+ return settle(inherited, "inherited", `${shell2} -lc exited without a PATH (${detail}).`);
378
+ }
379
+ const path = extractMarked(result.stdout);
380
+ if (path === null) {
381
+ return settle(
382
+ inherited,
383
+ "inherited",
384
+ `${shell2} -lc produced no ${PATH_MARKER_START} marker, so its output could not be trusted as a PATH.`
385
+ );
386
+ }
387
+ return settle(path, "login-shell", null);
388
+ }
389
+ let cached = null;
390
+ function hostEnv() {
391
+ cached ??= probeHostEnv();
392
+ return cached;
393
+ }
394
+ function buildDaemonEnv(options) {
395
+ const env = {};
396
+ for (const [key, value] of Object.entries(options.base)) {
397
+ if (value === void 0) continue;
398
+ if (!options.electronAsNode && key.startsWith("ELECTRON_")) continue;
399
+ env[key] = value;
400
+ }
401
+ env["PATH"] = options.path;
402
+ if (options.electronAsNode) {
403
+ env["ELECTRON_RUN_AS_NODE"] = "1";
404
+ }
405
+ return env;
406
+ }
407
+ const CLI_TIMEOUT_MS = 6e4;
408
+ const STOP_TIMEOUT_MS = 12e4;
409
+ const POLL_INTERVAL_MS$1 = 250;
410
+ const require$1 = createRequire(import.meta.url);
411
+ function conductorEntry() {
412
+ return require$1.resolve("@nutteen/conductor/dist/src/index.js");
413
+ }
414
+ function runCommand(command, args, env) {
415
+ return new Promise((resolve2) => {
416
+ execFile(
417
+ command,
418
+ args,
419
+ { env, timeout: CLI_TIMEOUT_MS, maxBuffer: 4 * 1024 * 1024, encoding: "utf-8" },
420
+ (error, stdout, stderr) => {
421
+ const code = error && typeof error.code === "number" ? error.code : error ? null : 0;
422
+ resolve2({ code, stdout: String(stdout), stderr: String(stderr) });
423
+ }
424
+ );
425
+ });
426
+ }
427
+ function defaultIo() {
428
+ return {
429
+ hostEnv,
430
+ run: runCommand,
431
+ isRunning: isProcessRunning,
432
+ readRecord: readRuntimeRecord,
433
+ sleep: (ms) => new Promise((resolve2) => setTimeout(resolve2, ms)),
434
+ now: () => Date.now(),
435
+ listDirs: async (path) => {
436
+ try {
437
+ const entries = await readdir(path, { withFileTypes: true });
438
+ return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
439
+ } catch {
440
+ return [];
441
+ }
442
+ },
443
+ isExecutableFile
444
+ };
445
+ }
446
+ function io(overrides) {
447
+ return { ...defaultIo(), ...overrides };
448
+ }
449
+ function isLivePid(pid, isRunning = isProcessRunning) {
450
+ if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) return false;
451
+ return isRunning(pid);
452
+ }
453
+ function globalArgs(context) {
454
+ return [
455
+ ...context.configPath ? ["--config", context.configPath] : [],
456
+ ...context.runtimeDir ? ["--runtime-dir", context.runtimeDir] : []
457
+ ];
458
+ }
459
+ function startArgv(flow, context) {
460
+ return [...globalArgs(context), "start", flow];
461
+ }
462
+ function stopArgv(flow, context) {
463
+ return [...globalArgs(context), "stop", flow];
464
+ }
465
+ async function launcher(deps) {
466
+ const host = await deps.hostEnv();
467
+ const electronAsNode = host.node === null;
468
+ return {
469
+ interpreter: host.node ?? process.execPath,
470
+ entry: conductorEntry(),
471
+ env: buildDaemonEnv({ base: process.env, path: host.path, electronAsNode }),
472
+ host
473
+ };
474
+ }
475
+ function outcome(argv, result) {
476
+ return {
477
+ argv,
478
+ exit_code: result.code,
479
+ // Redacted before crossing IPC, so the README's "every rendered string passes
480
+ // through `redactSensitive` in the main process first" stays true. `argv` is
481
+ // not — it is the command to paste into a terminal.
482
+ stdout: redactSensitive(result.stdout.trim()),
483
+ stderr: redactSensitive(result.stderr.trim())
484
+ };
485
+ }
486
+ async function runConductor(args, deps) {
487
+ const { interpreter, entry, env } = await launcher(deps);
488
+ const result = await deps.run(interpreter, [entry, ...args], env);
489
+ return { cli: outcome([interpreter, entry, ...args], result), ok: result.code === 0 };
490
+ }
491
+ const CORE_BINARIES = ["ist", "git", "gh"];
492
+ async function agentCommands(workflowPath) {
493
+ try {
494
+ const definition = await loadWorkflow(workflowPath);
495
+ const agents = definition.config["agents"];
496
+ if (typeof agents !== "object" || agents === null || Array.isArray(agents)) return [];
497
+ const commands = /* @__PURE__ */ new Set();
498
+ for (const raw of Object.values(agents)) {
499
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue;
500
+ const command = raw["command"];
501
+ const provider = raw["provider"];
502
+ const name = typeof command === "string" ? command : typeof provider === "string" ? provider : null;
503
+ if (name) commands.add(name);
504
+ }
505
+ return [...commands];
506
+ } catch {
507
+ return [];
508
+ }
509
+ }
510
+ function hostEnvCheck(host) {
511
+ if (host.gui_default) {
512
+ return {
513
+ id: "host-env",
514
+ label: "Host PATH",
515
+ status: "warn",
516
+ // The whole reason this module captures a login shell's PATH.
517
+ detail: `PATH is the bare macOS GUI default (${GUI_DEFAULT_PATH}) — nothing user-installed is on it. A daemon started with this PATH polls forever and dispatches nothing.` + (host.note ? ` ${host.note}` : "")
518
+ };
519
+ }
520
+ if (host.source === "inherited") {
521
+ return {
522
+ id: "host-env",
523
+ label: "Host PATH",
524
+ status: "warn",
525
+ detail: `Using this app's own PATH. ${host.note ?? "The login shell could not be asked."}`
526
+ };
527
+ }
528
+ return { id: "host-env", label: "Host PATH", status: "pass", detail: `From your login shell (${host.path})` };
529
+ }
530
+ async function authCheck(env, ist, deps) {
531
+ if (!ist) {
532
+ return {
533
+ id: "ist-auth",
534
+ label: "ist session",
535
+ status: "fail",
536
+ detail: "`ist` is not on the PATH, so the session cannot be checked."
537
+ };
538
+ }
539
+ const result = await deps.run(ist, ["auth", "whoami", "--json"], env);
540
+ if (result.code === 0) {
541
+ return {
542
+ id: "ist-auth",
543
+ label: "ist session",
544
+ status: "pass",
545
+ detail: describeWhoami(result.stdout)
546
+ };
547
+ }
548
+ return {
549
+ id: "ist-auth",
550
+ label: "ist session",
551
+ status: "fail",
552
+ // An `ist auth` failure is *not* fatal once the daemon is polling — it logs
553
+ // "Dispatch skipped: ist auth check failed" and keeps going forever, doing
554
+ // nothing. Catching it here is the entire point of a preflight.
555
+ detail: `${redactSensitive(result.stderr.trim() || result.stdout.trim()) || `exit ${String(result.code)}`} — run \`ist auth login\` in a terminal, then try again.`
556
+ };
557
+ }
558
+ function describeWhoami(stdout) {
559
+ try {
560
+ const user = JSON.parse(stdout);
561
+ if (typeof user.email === "string") {
562
+ const name = typeof user.display_name === "string" ? ` (${user.display_name})` : "";
563
+ return `Authenticated as ${user.email}${name}`;
564
+ }
565
+ } catch {
566
+ }
567
+ return redactSensitive(stdout.trim()) || "Authenticated.";
568
+ }
569
+ async function preflightFlow(flow, context, overrides = {}) {
570
+ const deps = io(overrides);
571
+ const { interpreter, env, host, entry } = await launcher(deps);
572
+ const checks = [hostEnvCheck(host)];
573
+ const report = context.configPath ? await preflightFlows(context.configPath, { runtimeDir: context.runtimeDir }) : null;
574
+ const entryFlow = report?.flows.find((item) => item.name === flow) ?? null;
575
+ const needed = [...CORE_BINARIES, ...entryFlow ? await agentCommands(entryFlow.workflow) : []];
576
+ const resolved = new Map(needed.map((name) => [name, resolveBinary(name, host.path, deps)]));
577
+ const missing = needed.filter((name) => resolved.get(name) === null);
578
+ checks.push({
579
+ id: "binaries",
580
+ label: "Binaries",
581
+ status: missing.length === 0 ? "pass" : "fail",
582
+ detail: missing.length === 0 ? needed.map((name) => `${name} → ${resolved.get(name)}`).join("\n") : `Not on the PATH: ${missing.join(", ")}`
583
+ });
584
+ checks.push(await authCheck(env, resolved.get("ist") ?? null, deps));
585
+ if (!context.configPath) {
586
+ checks.push({
587
+ id: "graph",
588
+ label: "Graph",
589
+ status: "fail",
590
+ detail: "No flows.yaml is selected, so there is nothing to start. Choose one from the Config tab."
591
+ });
592
+ } else if (!entryFlow) {
593
+ checks.push({
594
+ id: "graph",
595
+ label: "Graph",
596
+ status: "fail",
597
+ detail: `${context.configPath} declares no flow named '${flow}'.`
598
+ });
599
+ } else {
600
+ checks.push({
601
+ id: "graph",
602
+ label: "Graph",
603
+ status: entryFlow.error ? "fail" : "pass",
604
+ detail: entryFlow.error ?? `${entryFlow.workflow} loads.`
605
+ });
606
+ }
607
+ const collisions = report?.collisions ?? [];
608
+ checks.push({
609
+ id: "collisions",
610
+ label: "Flow collisions",
611
+ status: collisions.length === 0 ? "pass" : "fail",
612
+ detail: collisions.length === 0 ? "None between the declared flows." : collisions.join("\n")
613
+ });
614
+ const workspaceRoot = entryFlow?.scope?.workspace_root ?? null;
615
+ const dirs = workspaceRoot ? await deps.listDirs(workspaceRoot) : [];
616
+ return {
617
+ flow,
618
+ config_path: context.configPath,
619
+ runtime_dir: report?.runtime_dir ?? resolveRuntimeDir({ cliRuntimeDir: context.runtimeDir, configRuntimeDir: null }),
620
+ workspace_root: workspaceRoot,
621
+ workspace_dirs: dirs.length,
622
+ interpreter,
623
+ path: host.path,
624
+ path_source: host.source,
625
+ checks,
626
+ ok: checks.every((check) => check.status !== "fail"),
627
+ argv: [interpreter, entry, ...startArgv(flow, context)]
628
+ };
629
+ }
630
+ function failed(message, cli = null) {
631
+ return { ok: false, message, cli, stop_timed_out: false, waiting_on_pid: null };
632
+ }
633
+ async function startFlow(flow, context, overrides = {}) {
634
+ const deps = io(overrides);
635
+ if (!context.configPath) return failed("Starting a flow needs a flows.yaml. Choose one from the Config tab.");
636
+ const { cli, ok } = await runConductor(startArgv(flow, context), deps);
637
+ return {
638
+ ok,
639
+ message: ok ? cli.stdout || `Started ${flow}.` : cli.stderr || `conductor start ${flow} exited ${String(cli.exit_code)}.`,
640
+ cli,
641
+ stop_timed_out: false,
642
+ waiting_on_pid: null
643
+ };
644
+ }
645
+ async function stopFlow(flow, context, overrides = {}) {
646
+ const deps = io(overrides);
647
+ const { cli, ok } = await runConductor(stopArgv(flow, context), deps);
648
+ return {
649
+ ok,
650
+ message: ok ? cli.stdout || `Stopping ${flow}.` : cli.stderr || `conductor stop ${flow} exited ${String(cli.exit_code)}.`,
651
+ cli,
652
+ stop_timed_out: false,
653
+ waiting_on_pid: null
654
+ };
655
+ }
656
+ async function waitForExit(pid, timeoutMs, overrides = {}) {
657
+ const deps = io(overrides);
658
+ const deadline = deps.now() + timeoutMs;
659
+ while (isLivePid(pid, deps.isRunning)) {
660
+ if (deps.now() >= deadline) return false;
661
+ await deps.sleep(POLL_INTERVAL_MS$1);
662
+ }
663
+ return true;
664
+ }
665
+ async function restartFlow(flow, context, overrides = {}) {
666
+ const deps = io(overrides);
667
+ if (!context.configPath) return failed("Restarting a flow needs a flows.yaml. Choose one from the Config tab.");
668
+ const runtimeDir = resolveRuntimeDir({ cliRuntimeDir: context.runtimeDir, configRuntimeDir: null });
669
+ const record = await deps.readRecord(runtimeDir, flow);
670
+ const pid = record?.pid ?? null;
671
+ const stopped = await stopFlow(flow, context, overrides);
672
+ if (!stopped.ok) return stopped;
673
+ if (isLivePid(pid, deps.isRunning)) {
674
+ const exited = await waitForExit(pid, STOP_TIMEOUT_MS, overrides);
675
+ if (!exited) {
676
+ return {
677
+ ok: false,
678
+ // The daemon logs `Shutdown: waiting for N in-flight run(s) to complete`,
679
+ // which is exactly the sentence that explains the wait.
680
+ message: `${flow} (PID ${String(pid)}) is still draining in-flight runs, so nothing was restarted. Its log says how many are left. It will exit on its own; restart again once it has.`,
681
+ cli: stopped.cli,
682
+ stop_timed_out: true,
683
+ waiting_on_pid: pid
684
+ };
685
+ }
686
+ }
687
+ return startFlow(flow, context, overrides);
688
+ }
689
+ const EMPTY$1 = { theme: null };
690
+ function preferencesPath() {
691
+ return join(app.getPath("userData"), "preferences.json");
692
+ }
693
+ function readPreferences() {
694
+ try {
695
+ const parsed = JSON.parse(readFileSync(preferencesPath(), "utf8"));
696
+ if (typeof parsed !== "object" || parsed === null) return EMPTY$1;
697
+ const theme = parsed.theme;
698
+ return { theme: theme === "dark" || theme === "light" ? theme : null };
699
+ } catch {
700
+ return EMPTY$1;
701
+ }
702
+ }
703
+ function writePreferences(preferences) {
704
+ try {
705
+ const path = preferencesPath();
706
+ mkdirSync(dirname(path), { recursive: true });
707
+ writeFileSync(path, `${JSON.stringify(preferences, null, 2)}
708
+ `, "utf8");
709
+ } catch {
710
+ }
711
+ }
712
+ const FIELD_RE = /^(\s*(?:-\s+)?)([A-Za-z_][A-Za-z0-9_-]*)(:\s*)(.*)$/;
713
+ const DASH_RE = /^(\s*)-\s+/;
714
+ const COMMENT_RE = /^(\s*)#/;
715
+ const BLANK_RE = /^\s*$/;
716
+ const MERGE_KEY_RE = /^\s*(?:-\s+)?<<\s*:/;
717
+ const DOC_MARKER_RE = /^(?:---|\.\.\.)\s*$/;
718
+ const BLOCK_SCALAR_RE = /^[|>][-+0-9]*\s*$/;
719
+ const ANCHOR_RE = /^[&*]\S/;
720
+ function indentOf(line) {
721
+ return line.length - line.trimStart().length;
722
+ }
723
+ function splitTrailingComment(rest) {
724
+ const quote = rest[0];
725
+ if (quote === '"' || quote === "'") {
726
+ for (let index = 1; index < rest.length; index++) {
727
+ if (rest[index] === "\\" && quote === '"') {
728
+ index++;
729
+ continue;
730
+ }
731
+ if (rest[index] === quote) {
732
+ const after = rest.slice(index + 1);
733
+ return { value: rest.slice(0, index + 1), trailing: after };
734
+ }
735
+ }
736
+ return { value: rest, trailing: "" };
737
+ }
738
+ const match = /\s+#/.exec(rest);
739
+ if (!match) return { value: rest.trimEnd(), trailing: "" };
740
+ return { value: rest.slice(0, match.index).trimEnd(), trailing: rest.slice(match.index) };
741
+ }
742
+ function commentsAbove(lines, line, indent, floor) {
743
+ const found = [];
744
+ for (let index = line - 1; index >= floor; index--) {
745
+ const text = lines[index];
746
+ if (BLANK_RE.test(text)) break;
747
+ const match = COMMENT_RE.exec(text);
748
+ if (!match || match[1].length !== indent) break;
749
+ found.push(index);
750
+ }
751
+ return found;
752
+ }
753
+ function scanRefusals(lines) {
754
+ const refusals = [];
755
+ const refuse = (index, reason) => {
756
+ refusals.push({ line: index + 1, text: lines[index], reason });
757
+ };
758
+ for (const [index, text] of lines.entries()) {
759
+ if (BLANK_RE.test(text) || COMMENT_RE.test(text)) continue;
760
+ if (DOC_MARKER_RE.test(text)) {
761
+ refuse(index, "a document marker — this editor handles single-document files only");
762
+ continue;
763
+ }
764
+ if (MERGE_KEY_RE.test(text)) {
765
+ refuse(index, "a merge key (`<<:`) — the effective config is not the lines shown, so a line edit cannot be trusted");
766
+ continue;
767
+ }
768
+ const field = FIELD_RE.exec(text);
769
+ if (!field) continue;
770
+ const { value } = splitTrailingComment(field[4]);
771
+ if (BLOCK_SCALAR_RE.test(value)) {
772
+ refuse(index, "a block scalar (`|` / `>`) — its body spans lines this editor does not model");
773
+ continue;
774
+ }
775
+ if (ANCHOR_RE.test(value)) {
776
+ refuse(index, "a YAML anchor or alias — what it resolves to is not on this line");
777
+ }
778
+ }
779
+ return refusals;
780
+ }
781
+ function flowsBlockEnd(lines, from) {
782
+ for (let index = from; index < lines.length; index++) {
783
+ const text = lines[index];
784
+ if (BLANK_RE.test(text) || COMMENT_RE.test(text)) continue;
785
+ if (indentOf(text) === 0) return index;
786
+ }
787
+ return lines.length;
788
+ }
789
+ function trimBlockEnd(lines, start, end) {
790
+ let last = end;
791
+ while (last > start && BLANK_RE.test(lines[last - 1])) last--;
792
+ return last;
793
+ }
794
+ function parseFlowsFile(text) {
795
+ const crlf = text.includes("\r\n");
796
+ const bareNewline = text.replace(/\r\n/g, "").includes("\n");
797
+ const eol = crlf ? "\r\n" : "\n";
798
+ const raw = text.split(/\r?\n/);
799
+ const trailingNewline = raw.length > 1 && raw[raw.length - 1] === "";
800
+ const lines = trailingNewline ? raw.slice(0, -1) : raw;
801
+ const refusals = scanRefusals(lines);
802
+ if (crlf && bareNewline) {
803
+ refusals.push({
804
+ line: 1,
805
+ text: lines[0] ?? "",
806
+ reason: "mixed CRLF and LF line endings — rewriting would normalise every line, not just the edited one"
807
+ });
808
+ }
809
+ const flowsLine = lines.findIndex((line) => /^flows\s*:\s*(#.*)?$/.test(line));
810
+ const inlineFlows = lines.findIndex((line) => /^flows\s*:\s*\S/.test(line));
811
+ if (flowsLine < 0) {
812
+ refusals.push({
813
+ line: inlineFlows >= 0 ? inlineFlows + 1 : 1,
814
+ text: inlineFlows >= 0 ? lines[inlineFlows] : "",
815
+ reason: inlineFlows >= 0 ? "`flows:` has an inline value — this editor handles a block sequence of flows" : "no top-level `flows:` block sequence"
816
+ });
817
+ return { lines, eol, trailingNewline, flowsLine: -1, flowsEnd: lines.length, flows: [], refusals };
818
+ }
819
+ const flowsEnd = flowsBlockEnd(lines, flowsLine + 1);
820
+ const starts = [];
821
+ for (let index = flowsLine + 1; index < flowsEnd; index++) {
822
+ if (DASH_RE.test(lines[index])) starts.push(index);
823
+ }
824
+ const flows = [];
825
+ for (const [order, start] of starts.entries()) {
826
+ const end = trimBlockEnd(lines, start, starts[order + 1] ?? flowsEnd);
827
+ const dashIndent = indentOf(lines[start]);
828
+ const fields = {};
829
+ let fieldIndent = dashIndent + 2;
830
+ for (let index = start; index < end; index++) {
831
+ const text2 = lines[index];
832
+ if (BLANK_RE.test(text2) || COMMENT_RE.test(text2)) continue;
833
+ const match = FIELD_RE.exec(text2);
834
+ if (!match) {
835
+ refusals.push({
836
+ line: index + 1,
837
+ text: text2,
838
+ reason: "not a `key: value` line — nested mappings and block sequences inside a flow are not modelled"
839
+ });
840
+ continue;
841
+ }
842
+ const [, prefix, key, separator, rest] = match;
843
+ const { value, trailing } = splitTrailingComment(rest);
844
+ if (index > start) fieldIndent = prefix.length;
845
+ if (value === "") {
846
+ refusals.push({
847
+ line: index + 1,
848
+ text: text2,
849
+ reason: `\`${key}:\` has no inline value, so its value spans lines this editor does not model`
850
+ });
851
+ continue;
852
+ }
853
+ fields[key] = {
854
+ key,
855
+ line: index,
856
+ prefix,
857
+ separator,
858
+ value,
859
+ trailing,
860
+ comments: commentsAbove(lines, index, prefix.length, start + 1)
861
+ };
862
+ }
863
+ const name = fields["name"] ? unquote$1(fields["name"].value) : "";
864
+ if (name === "") {
865
+ refusals.push({ line: start + 1, text: lines[start], reason: "a flow with no `name:` on its first line" });
866
+ }
867
+ flows.push({
868
+ name,
869
+ start,
870
+ end,
871
+ dashIndent,
872
+ fieldIndent,
873
+ fields,
874
+ comments: commentsAbove(lines, start, dashIndent, flowsLine + 1)
875
+ });
876
+ }
877
+ return { lines, eol, trailingNewline, flowsLine, flowsEnd, flows, refusals };
878
+ }
879
+ function unquote$1(value) {
880
+ if (value.length >= 2 && (value[0] === '"' || value[0] === "'") && value.at(-1) === value[0]) {
881
+ return value.slice(1, -1);
882
+ }
883
+ return value;
884
+ }
885
+ const NEEDS_QUOTES = /^$|^\s|\s$|^[-?:,[\]{}#&*!|>'"%@`]|:\s|\s#|^(?:true|false|yes|no|on|off|null|~)$/i;
886
+ function renderScalar(value) {
887
+ if (NEEDS_QUOTES.test(value) || /^[-+]?\d/.test(value)) {
888
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
889
+ }
890
+ return value;
891
+ }
892
+ function renderValue(value) {
893
+ return Array.isArray(value) ? `[${value.map(renderScalar).join(", ")}]` : renderScalar(value);
894
+ }
895
+ function applySplices(lines, splices) {
896
+ const sorted = [...splices].sort((a, b) => a.start - b.start);
897
+ const out = [];
898
+ let cursor = 0;
899
+ for (const splice of sorted) {
900
+ for (let index = cursor; index < splice.start; index++) out.push(lines[index]);
901
+ out.push(...splice.insert);
902
+ cursor = Math.max(cursor, splice.start + splice.deleteCount);
903
+ }
904
+ for (let index = cursor; index < lines.length; index++) out.push(lines[index]);
905
+ return out;
906
+ }
907
+ function renderFlowsFile(lines, eol, trailingNewline) {
908
+ return lines.join(eol) + (trailingNewline ? eol : "");
909
+ }
910
+ function diffFromSplices(lines, splices, label = "flows.yaml", context = 3) {
911
+ if (splices.length === 0) return "";
912
+ const sorted = [...splices].sort((a, b) => a.start - b.start);
913
+ const merged = [];
914
+ for (const splice of sorted) {
915
+ const last = merged.at(-1);
916
+ if (last && splice.start <= last.start + last.deleteCount + context * 2) {
917
+ const gap = lines.slice(last.start + last.deleteCount, splice.start);
918
+ last.insert = [...last.insert, ...gap, ...splice.insert];
919
+ last.deleteCount = splice.start + splice.deleteCount - last.start;
920
+ continue;
921
+ }
922
+ merged.push({ start: splice.start, deleteCount: splice.deleteCount, insert: [...splice.insert] });
923
+ }
924
+ const out = [`--- a/${label}`, `+++ b/${label}`];
925
+ let drift = 0;
926
+ for (const hunk of merged) {
927
+ const from = Math.max(0, hunk.start - context);
928
+ const to = Math.min(lines.length, hunk.start + hunk.deleteCount + context);
929
+ const before = lines.slice(from, hunk.start);
930
+ const after = lines.slice(hunk.start + hunk.deleteCount, to);
931
+ const oldCount = before.length + hunk.deleteCount + after.length;
932
+ const newCount = before.length + hunk.insert.length + after.length;
933
+ out.push(
934
+ `@@ -${String(from + 1)},${String(oldCount)} +${String(from + 1 + drift)},${String(newCount)} @@`
935
+ );
936
+ for (const line of before) out.push(` ${line}`);
937
+ for (const line of lines.slice(hunk.start, hunk.start + hunk.deleteCount)) out.push(`-${line}`);
938
+ for (const line of hunk.insert) out.push(`+${line}`);
939
+ for (const line of after) out.push(` ${line}`);
940
+ drift += hunk.insert.length - hunk.deleteCount;
941
+ }
942
+ return out.join("\n");
943
+ }
944
+ function fieldLine(field, value) {
945
+ return `${field.prefix}${field.key}${field.separator}${renderValue(value)}${field.trailing}`;
946
+ }
947
+ function newFieldLine(flow, key, value) {
948
+ return `${" ".repeat(flow.fieldIndent)}${key}: ${renderValue(value)}`;
949
+ }
950
+ function insertPointFor(flow) {
951
+ const lastField = Object.values(flow.fields).reduce((max, field) => Math.max(max, field.line), flow.start);
952
+ return lastField + 1;
953
+ }
954
+ function renderNewFlow(flow, dashIndent, fieldIndent) {
955
+ const pad = " ".repeat(fieldIndent);
956
+ const out = [`${" ".repeat(dashIndent)}- name: ${renderScalar(flow.name)}`, `${pad}workflow: ${renderScalar(flow.workflow)}`];
957
+ if (flow.workspace_root) out.push(`${pad}workspace_root: ${renderScalar(flow.workspace_root)}`);
958
+ if (flow.assignee) out.push(`${pad}assignee: ${renderScalar(flow.assignee)}`);
959
+ if (flow.labels && flow.labels.length > 0) out.push(`${pad}labels: ${renderValue(flow.labels)}`);
960
+ return out;
961
+ }
962
+ function planFlowsEdit(text, edits, options = {}) {
963
+ const parsed = parseFlowsFile(text);
964
+ const label = options.label ?? "flows.yaml";
965
+ const nothing = (errors2) => ({
966
+ ok: errors2.length === 0 && parsed.refusals.length === 0,
967
+ refusals: parsed.refusals,
968
+ errors: errors2,
969
+ splices: [],
970
+ text,
971
+ diff: "",
972
+ changed: false
973
+ });
974
+ if (parsed.refusals.length > 0) return nothing([]);
975
+ const errors = [];
976
+ const splices = [];
977
+ const byName = new Map(parsed.flows.map((flow) => [flow.name, flow]));
978
+ const addedNames = /* @__PURE__ */ new Set();
979
+ const appendAt = parsed.flows.at(-1)?.end ?? parsed.flowsLine + 1;
980
+ for (const edit of edits) {
981
+ if (edit.op === "add-flow") {
982
+ if (byName.has(edit.flow.name) || addedNames.has(edit.flow.name)) {
983
+ errors.push(`A flow named '${edit.flow.name}' is already declared.`);
984
+ continue;
985
+ }
986
+ const template = parsed.flows.at(-1);
987
+ const insert = renderNewFlow(edit.flow, template?.dashIndent ?? 2, template?.fieldIndent ?? 4);
988
+ splices.push({ start: appendAt, deleteCount: 0, insert });
989
+ addedNames.add(edit.flow.name);
990
+ continue;
991
+ }
992
+ const flow = byName.get(edit.flow);
993
+ if (!flow) {
994
+ errors.push(`No flow named '${edit.flow}' in ${label}.`);
995
+ continue;
996
+ }
997
+ if (edit.op === "remove-flow") {
998
+ const from = flow.comments.length > 0 ? Math.min(...flow.comments) : flow.start;
999
+ splices.push({ start: from, deleteCount: flow.end - from, insert: [] });
1000
+ continue;
1001
+ }
1002
+ const field = flow.fields[edit.field];
1003
+ if (edit.op === "unset") {
1004
+ if (!field) continue;
1005
+ const from = field.comments.length > 0 ? Math.min(...field.comments) : field.line;
1006
+ splices.push({ start: from, deleteCount: field.line + 1 - from, insert: [] });
1007
+ continue;
1008
+ }
1009
+ if (edit.field === "name") {
1010
+ errors.push("A flow's `name:` is its runtime key — renaming it orphans its record, log and checkpoints.");
1011
+ continue;
1012
+ }
1013
+ if (field) {
1014
+ const next = fieldLine(field, edit.value);
1015
+ if (next === parsed.lines[field.line]) continue;
1016
+ splices.push({ start: field.line, deleteCount: 1, insert: [next] });
1017
+ } else {
1018
+ splices.push({ start: insertPointFor(flow), deleteCount: 0, insert: [newFieldLine(flow, edit.field, edit.value)] });
1019
+ }
1020
+ }
1021
+ if (errors.length > 0) return nothing(errors);
1022
+ if (splices.length === 0) return nothing([]);
1023
+ const lines = applySplices(parsed.lines, splices);
1024
+ return {
1025
+ ok: true,
1026
+ refusals: [],
1027
+ errors: [],
1028
+ splices,
1029
+ text: renderFlowsFile(lines, parsed.eol, parsed.trailingNewline),
1030
+ diff: diffFromSplices(parsed.lines, splices, label),
1031
+ changed: true
1032
+ };
1033
+ }
1034
+ function errorMessage(err) {
1035
+ return err instanceof Error ? err.message : String(err);
1036
+ }
1037
+ function unquote(value) {
1038
+ if (value.length >= 2 && (value[0] === '"' || value[0] === "'") && value.at(-1) === value[0]) {
1039
+ return value.slice(1, -1);
1040
+ }
1041
+ return value;
1042
+ }
1043
+ async function readFlowsConfig(context) {
1044
+ const empty = {
1045
+ config_path: context.configPath,
1046
+ runtime_dir: null,
1047
+ text: "",
1048
+ flows: [],
1049
+ refusals: [],
1050
+ errors: [],
1051
+ error: null
1052
+ };
1053
+ if (!context.configPath) {
1054
+ return { ...empty, error: "No flows.yaml is selected. Choose one to see and edit the declared flows." };
1055
+ }
1056
+ let text;
1057
+ try {
1058
+ text = await readFile(context.configPath, "utf-8");
1059
+ } catch (err) {
1060
+ return { ...empty, error: `Cannot read ${context.configPath}: ${errorMessage(err)}` };
1061
+ }
1062
+ const parsed = parseFlowsFile(text);
1063
+ const report = await preflightFlows(context.configPath, { runtimeDir: context.runtimeDir });
1064
+ const running = new Set(
1065
+ (await listFlows({ runtimeDir: context.runtimeDir })).filter((flow) => flow.running).map((flow) => flow.name)
1066
+ );
1067
+ let resolvedByName = /* @__PURE__ */ new Map();
1068
+ try {
1069
+ const config = await loadManagedConfig(context.configPath);
1070
+ resolvedByName = new Map(
1071
+ config.flows.map((flow) => [flow.name, { workflow: flow.workflow, workspace_root: flow.workspace_root ?? null }])
1072
+ );
1073
+ } catch {
1074
+ }
1075
+ const flows = parsed.flows.map((flow) => {
1076
+ const declared = {};
1077
+ const comments = {};
1078
+ for (const [key, field] of Object.entries(flow.fields)) {
1079
+ declared[key] = unquote(field.value);
1080
+ if (field.comments.length > 0) {
1081
+ comments[key] = [...field.comments].reverse().map((line) => parsed.lines[line].trim());
1082
+ }
1083
+ }
1084
+ return {
1085
+ name: flow.name,
1086
+ declared,
1087
+ resolved: resolvedByName.get(flow.name) ?? { workflow: null, workspace_root: null },
1088
+ comments,
1089
+ error: report.flows.find((entry) => entry.name === flow.name)?.error ?? null,
1090
+ running: running.has(flow.name)
1091
+ };
1092
+ });
1093
+ return {
1094
+ config_path: context.configPath,
1095
+ runtime_dir: report.runtime_dir ?? resolveRuntimeDir({ cliRuntimeDir: context.runtimeDir, configRuntimeDir: null }),
1096
+ text,
1097
+ flows,
1098
+ refusals: parsed.refusals,
1099
+ errors: report.errors,
1100
+ error: null
1101
+ };
1102
+ }
1103
+ const pending = /* @__PURE__ */ new Map();
1104
+ function tokenFor(pendingWrite) {
1105
+ const hash = createHash("sha256");
1106
+ hash.update(pendingWrite.configPath);
1107
+ hash.update(" ");
1108
+ hash.update(pendingWrite.before);
1109
+ hash.update(" ");
1110
+ hash.update(pendingWrite.after);
1111
+ return hash.digest("hex");
1112
+ }
1113
+ async function validateCandidate(configPath, candidate, runtimeDir) {
1114
+ const previewPath = `${configPath}.preview.${String(process.pid)}`;
1115
+ try {
1116
+ await writeFile(previewPath, candidate, "utf-8");
1117
+ const report = await preflightFlows(previewPath, { runtimeDir });
1118
+ return { ok: report.ok, errors: report.errors.map((message) => message.replaceAll(previewPath, configPath)) };
1119
+ } catch (err) {
1120
+ return { ok: false, errors: [`Could not validate the edit: ${errorMessage(err)}`] };
1121
+ } finally {
1122
+ await rm(previewPath, { force: true });
1123
+ }
1124
+ }
1125
+ async function previewFlowsEdit(context, edits) {
1126
+ const nothing = (problems2) => ({
1127
+ ok: false,
1128
+ changed: false,
1129
+ diff: "",
1130
+ problems: problems2,
1131
+ validation: { ok: false, errors: [] },
1132
+ token: null
1133
+ });
1134
+ if (!context.configPath) return nothing(["No flows.yaml is selected."]);
1135
+ let before;
1136
+ try {
1137
+ before = await readFile(context.configPath, "utf-8");
1138
+ } catch (err) {
1139
+ return nothing([`Cannot read ${context.configPath}: ${errorMessage(err)}`]);
1140
+ }
1141
+ const plan = planFlowsEdit(before, edits, { label: context.configPath });
1142
+ const problems = [
1143
+ ...plan.refusals.map((refusal) => `Line ${String(refusal.line)}: ${refusal.reason} — ${refusal.text.trim()}`),
1144
+ ...plan.errors
1145
+ ];
1146
+ if (!plan.ok) return nothing(problems);
1147
+ if (!plan.changed) {
1148
+ return { ok: true, changed: false, diff: "", problems: [], validation: { ok: true, errors: [] }, token: null };
1149
+ }
1150
+ const validation = await validateCandidate(context.configPath, plan.text, context.runtimeDir);
1151
+ const write = { configPath: context.configPath, before, after: plan.text };
1152
+ const token = validation.ok ? tokenFor(write) : null;
1153
+ if (token) pending.set(token, write);
1154
+ return { ok: true, changed: true, diff: plan.diff, problems, validation, token };
1155
+ }
1156
+ async function applyEdit(configPath, text) {
1157
+ const backupPath = `${configPath}.bak`;
1158
+ const tempPath = `${configPath}.tmp.${String(process.pid)}`;
1159
+ let mode;
1160
+ try {
1161
+ mode = (await stat(configPath)).mode & 511;
1162
+ } catch {
1163
+ mode = void 0;
1164
+ }
1165
+ try {
1166
+ await copyFile(configPath, backupPath);
1167
+ await writeFile(tempPath, text, "utf-8");
1168
+ if (mode !== void 0) await chmod(tempPath, mode);
1169
+ await rename(tempPath, configPath);
1170
+ } catch (err) {
1171
+ await rm(tempPath, { force: true });
1172
+ return { ok: false, message: `Could not write ${configPath}: ${errorMessage(err)}` };
1173
+ }
1174
+ try {
1175
+ await loadManagedConfig(configPath);
1176
+ } catch (err) {
1177
+ await copyFile(backupPath, configPath);
1178
+ return {
1179
+ ok: false,
1180
+ message: `${configPath} did not load after the edit and has been restored from ${backupPath}: ${errorMessage(err)}`
1181
+ };
1182
+ }
1183
+ return { ok: true, message: `Wrote ${configPath}. The previous version is at ${backupPath}.` };
1184
+ }
1185
+ async function commitFlowsEdit(token) {
1186
+ const write = pending.get(token);
1187
+ if (!write) {
1188
+ return { ok: false, message: "That edit is no longer pending — preview it again before writing." };
1189
+ }
1190
+ let current;
1191
+ try {
1192
+ current = await readFile(write.configPath, "utf-8");
1193
+ } catch (err) {
1194
+ return { ok: false, message: `Cannot read ${write.configPath}: ${errorMessage(err)}` };
1195
+ }
1196
+ if (current !== write.before) {
1197
+ pending.delete(token);
1198
+ return {
1199
+ ok: false,
1200
+ message: `${write.configPath} changed on disk since this edit was previewed. Nothing was written — preview it again.`
1201
+ };
1202
+ }
1203
+ pending.delete(token);
1204
+ return applyEdit(write.configPath, write.after);
1205
+ }
1206
+ const EMPTY = { config_path: null };
1207
+ function settingsPath(userDataDir2) {
1208
+ return join(userDataDir2, "control-room.json");
1209
+ }
1210
+ async function readSettings(userDataDir2) {
1211
+ try {
1212
+ const parsed = JSON.parse(await readFile(settingsPath(userDataDir2), "utf-8"));
1213
+ return { config_path: typeof parsed.config_path === "string" ? parsed.config_path : null };
1214
+ } catch {
1215
+ return { ...EMPTY };
1216
+ }
1217
+ }
1218
+ async function writeSettings(userDataDir2, settings) {
1219
+ const path = settingsPath(userDataDir2);
1220
+ await mkdir(dirname(path), { recursive: true });
1221
+ await writeFile(path, `${JSON.stringify(settings, null, 2)}
1222
+ `, "utf-8");
1223
+ }
1224
+ const POLL_INTERVAL_MS = 1e3;
1225
+ function tailFile(path, onLines, options = {}) {
1226
+ const debounceMs = options.debounceMs ?? 120;
1227
+ let offset = 0;
1228
+ let carry = "";
1229
+ let inode = null;
1230
+ let stopped = false;
1231
+ let reading = false;
1232
+ let pending2 = null;
1233
+ let watcher = null;
1234
+ let timer = null;
1235
+ const readNew = async () => {
1236
+ if (stopped || reading) return;
1237
+ reading = true;
1238
+ try {
1239
+ const info = await stat(path);
1240
+ if (info.size < offset || inode !== null && info.ino !== inode) {
1241
+ offset = 0;
1242
+ carry = "";
1243
+ }
1244
+ inode = info.ino;
1245
+ if (info.size === offset) return;
1246
+ const handle = await open(path, "r");
1247
+ try {
1248
+ const length = info.size - offset;
1249
+ const buffer = Buffer.allocUnsafe(length);
1250
+ const { bytesRead } = await handle.read(buffer, 0, length, offset);
1251
+ offset += bytesRead;
1252
+ const text = carry + buffer.subarray(0, bytesRead).toString("utf-8");
1253
+ const parts = text.split("\n");
1254
+ carry = parts.pop() ?? "";
1255
+ const lines = parts.filter((line) => line.length > 0);
1256
+ if (lines.length > 0 && !stopped) onLines(lines);
1257
+ } finally {
1258
+ await handle.close();
1259
+ }
1260
+ } catch {
1261
+ } finally {
1262
+ reading = false;
1263
+ }
1264
+ };
1265
+ const schedule = () => {
1266
+ if (stopped || pending2) return;
1267
+ pending2 = setTimeout(() => {
1268
+ pending2 = null;
1269
+ void readNew();
1270
+ }, debounceMs);
1271
+ pending2.unref?.();
1272
+ };
1273
+ void (async () => {
1274
+ try {
1275
+ const info = await stat(path);
1276
+ inode = info.ino;
1277
+ if (options.from !== "start") offset = info.size;
1278
+ } catch {
1279
+ offset = 0;
1280
+ inode = null;
1281
+ }
1282
+ if (stopped) return;
1283
+ try {
1284
+ watcher = watch(path, { persistent: false }, schedule);
1285
+ } catch {
1286
+ }
1287
+ timer = setInterval(schedule, POLL_INTERVAL_MS);
1288
+ timer.unref?.();
1289
+ if (options.from === "start") schedule();
1290
+ })();
1291
+ return {
1292
+ stop: () => {
1293
+ stopped = true;
1294
+ if (pending2) clearTimeout(pending2);
1295
+ if (timer) clearInterval(timer);
1296
+ watcher?.close();
1297
+ watcher = null;
1298
+ },
1299
+ poll: readNew
1300
+ };
1301
+ }
1302
+ app.name = "Conductor";
1303
+ const here = dirname(fileURLToPath(import.meta.url));
1304
+ function argValue(flag) {
1305
+ const index = process.argv.indexOf(flag);
1306
+ return index >= 0 ? process.argv[index + 1] ?? null : null;
1307
+ }
1308
+ function runtimeDirArg() {
1309
+ return argValue("--runtime-dir");
1310
+ }
1311
+ async function captureAndExit(window, target) {
1312
+ const delay = Number(argValue("--capture-delay") ?? 2500);
1313
+ await new Promise((resolve2) => setTimeout(resolve2, Number.isFinite(delay) ? delay : 2500));
1314
+ const image = await window.webContents.capturePage();
1315
+ await writeFile(target, image.toPNG());
1316
+ process.stdout.write(`captured ${target}
1317
+ `);
1318
+ app.exit(0);
1319
+ }
1320
+ const sourceOptions = {
1321
+ runtimeDir: runtimeDirArg(),
1322
+ configPath: argValue("--config")
1323
+ };
1324
+ async function syncRuntimeDir() {
1325
+ sourceOptions.runtimeDir = await resolveSourceRuntimeDir(sourceOptions.configPath, runtimeDirArg());
1326
+ }
1327
+ function userDataDir() {
1328
+ return app.getPath("userData");
1329
+ }
1330
+ function daemonConfigView(error = null) {
1331
+ return {
1332
+ config_path: sourceOptions.configPath,
1333
+ runtime_dir: resolveRuntimeDir({ cliRuntimeDir: sourceOptions.runtimeDir, configRuntimeDir: null }),
1334
+ error
1335
+ };
1336
+ }
1337
+ function broadcast(channel, payload) {
1338
+ for (const window of BrowserWindow.getAllWindows()) {
1339
+ if (!window.isDestroyed()) window.webContents.send(channel, payload);
1340
+ }
1341
+ }
1342
+ const LIVENESS_INTERVAL_MS = 2e3;
1343
+ const subscriptions = /* @__PURE__ */ new Map();
1344
+ function clearSubscription(windowId) {
1345
+ const existing = subscriptions.get(windowId);
1346
+ if (!existing) return;
1347
+ existing.tail?.stop();
1348
+ existing.workflow?.stop();
1349
+ if (existing.liveness) clearInterval(existing.liveness);
1350
+ subscriptions.delete(windowId);
1351
+ }
1352
+ async function pushFlows(window, subscription) {
1353
+ const flows = await listFlows(sourceOptions);
1354
+ const json = JSON.stringify(flows);
1355
+ if (json !== subscription.flowsJson) {
1356
+ subscription.flowsJson = json;
1357
+ if (!window.isDestroyed()) window.webContents.send(CHANNELS.pushFlows, flows);
1358
+ }
1359
+ return flows;
1360
+ }
1361
+ async function refreshFlows(sender) {
1362
+ const window = sender ?? BrowserWindow.getAllWindows()[0] ?? null;
1363
+ if (!window) return;
1364
+ const subscription = subscriptions.get(window.id);
1365
+ if (!subscription) return;
1366
+ await pushFlows(window, subscription);
1367
+ }
1368
+ async function subscribe(window, request) {
1369
+ clearSubscription(window.id);
1370
+ const subscription = {
1371
+ flow: request.flow,
1372
+ tail: null,
1373
+ workflow: null,
1374
+ liveness: null,
1375
+ flowsJson: ""
1376
+ };
1377
+ subscriptions.set(window.id, subscription);
1378
+ subscription.liveness = setInterval(() => {
1379
+ void pushFlows(window, subscription);
1380
+ }, LIVENESS_INTERVAL_MS);
1381
+ const flows = await listFlows(sourceOptions);
1382
+ const flow = flows.find((item) => item.name === request.flow);
1383
+ const send = (channel, payload) => {
1384
+ if (!window.isDestroyed()) window.webContents.send(channel, payload);
1385
+ };
1386
+ if (flow?.log_file) {
1387
+ subscription.tail = tailFile(flow.log_file, (lines) => {
1388
+ const events = parseRunEventLines(lines);
1389
+ if (events.length > 0) send(CHANNELS.pushEvents, { flow: request.flow, events });
1390
+ void readTickets(request.flow, sourceOptions).then((view) => {
1391
+ send(CHANNELS.pushTickets, view);
1392
+ });
1393
+ });
1394
+ }
1395
+ if (request.watchWorkflow && flow?.workflow) {
1396
+ subscription.workflow = watchWorkflow(flow.workflow, () => {
1397
+ void readWorkflow({ flow: request.flow }, sourceOptions).then((view) => {
1398
+ send(CHANNELS.pushWorkflow, view);
1399
+ });
1400
+ });
1401
+ }
1402
+ }
1403
+ function registerHandlers() {
1404
+ ipcMain.handle(CHANNELS.getFlows, () => listFlows(sourceOptions));
1405
+ ipcMain.handle(
1406
+ CHANNELS.getWorkflow,
1407
+ (_event, input) => readWorkflow(input, sourceOptions)
1408
+ );
1409
+ ipcMain.handle(
1410
+ CHANNELS.getTickets,
1411
+ (_event, input) => readTickets(input.flow, sourceOptions)
1412
+ );
1413
+ ipcMain.handle(
1414
+ CHANNELS.getTicket,
1415
+ (_event, input) => readTicket(input, sourceOptions)
1416
+ );
1417
+ ipcMain.handle(
1418
+ CHANNELS.getReport,
1419
+ (_event, input) => readReport(input, sourceOptions)
1420
+ );
1421
+ ipcMain.handle(CHANNELS.openWorkflowFile, async (event) => {
1422
+ const window = BrowserWindow.fromWebContents(event.sender);
1423
+ const result = await dialog.showOpenDialog(window ?? void 0, {
1424
+ title: "Open a Conductor workflow",
1425
+ properties: ["openFile"],
1426
+ filters: [{ name: "Workflow", extensions: ["md"] }]
1427
+ });
1428
+ const path = result.filePaths[0];
1429
+ if (result.canceled || !path) return null;
1430
+ return readWorkflow({ path }, sourceOptions);
1431
+ });
1432
+ ipcMain.handle(CHANNELS.getInitialView, () => ({ ticket: initialTicketArg(), tab: initialTabArg() }));
1433
+ ipcMain.handle(CHANNELS.setTheme, (_event, theme) => {
1434
+ writePreferences({ theme: theme === "dark" || theme === "light" ? theme : null });
1435
+ });
1436
+ ipcMain.handle(CHANNELS.revealPath, (_event, path) => {
1437
+ shell.showItemInFolder(path);
1438
+ });
1439
+ ipcMain.handle(CHANNELS.subscribe, async (event, request) => {
1440
+ const window = BrowserWindow.fromWebContents(event.sender);
1441
+ if (window) await subscribe(window, request);
1442
+ });
1443
+ const lifecycleFailure = (err) => ({
1444
+ ok: false,
1445
+ message: err instanceof Error ? err.message : String(err),
1446
+ cli: null,
1447
+ stop_timed_out: false,
1448
+ waiting_on_pid: null
1449
+ });
1450
+ const runLifecycle = async (event, action, flow) => {
1451
+ try {
1452
+ return await action(flow);
1453
+ } catch (err) {
1454
+ return lifecycleFailure(err);
1455
+ } finally {
1456
+ await refreshFlows(BrowserWindow.fromWebContents(event.sender));
1457
+ }
1458
+ };
1459
+ ipcMain.handle(CHANNELS.preflight, async (_event, input) => {
1460
+ try {
1461
+ return await preflightFlow(input.flow, sourceOptions);
1462
+ } catch (err) {
1463
+ return {
1464
+ flow: input.flow,
1465
+ config_path: sourceOptions.configPath,
1466
+ runtime_dir: null,
1467
+ workspace_root: null,
1468
+ workspace_dirs: 0,
1469
+ interpreter: process.execPath,
1470
+ path: process.env["PATH"] ?? "",
1471
+ path_source: "inherited",
1472
+ checks: [
1473
+ {
1474
+ id: "preflight",
1475
+ label: "Preflight",
1476
+ status: "fail",
1477
+ detail: err instanceof Error ? err.message : String(err)
1478
+ }
1479
+ ],
1480
+ ok: false,
1481
+ argv: []
1482
+ };
1483
+ }
1484
+ });
1485
+ ipcMain.handle(
1486
+ CHANNELS.startFlow,
1487
+ (event, input) => runLifecycle(event, (flow) => startFlow(flow, sourceOptions), input.flow)
1488
+ );
1489
+ ipcMain.handle(
1490
+ CHANNELS.stopFlow,
1491
+ (event, input) => runLifecycle(event, (flow) => stopFlow(flow, sourceOptions), input.flow)
1492
+ );
1493
+ ipcMain.handle(
1494
+ CHANNELS.restartFlow,
1495
+ (event, input) => runLifecycle(event, (flow) => restartFlow(flow, sourceOptions), input.flow)
1496
+ );
1497
+ ipcMain.handle(CHANNELS.getDaemonConfig, () => daemonConfigView());
1498
+ ipcMain.handle(CHANNELS.chooseDaemonConfig, async (event) => {
1499
+ const window = BrowserWindow.fromWebContents(event.sender);
1500
+ const result = await dialog.showOpenDialog(window ?? void 0, {
1501
+ title: "Choose a Conductor flows.yaml",
1502
+ properties: ["openFile"],
1503
+ filters: [{ name: "Flows config", extensions: ["yaml", "yml"] }]
1504
+ });
1505
+ const path = result.filePaths[0];
1506
+ if (result.canceled || !path) return daemonConfigView();
1507
+ sourceOptions.configPath = path;
1508
+ await syncRuntimeDir();
1509
+ try {
1510
+ await writeSettings(userDataDir(), { config_path: path });
1511
+ } catch (err) {
1512
+ return daemonConfigView(`Chose ${path}, but it could not be remembered: ${String(err)}`);
1513
+ }
1514
+ const view = daemonConfigView();
1515
+ broadcast(CHANNELS.pushDaemon, view);
1516
+ return view;
1517
+ });
1518
+ ipcMain.handle(CHANNELS.readFlowsConfig, () => readFlowsConfig(sourceOptions));
1519
+ ipcMain.handle(CHANNELS.previewFlowsEdit, async (_event, input) => {
1520
+ try {
1521
+ return await previewFlowsEdit(sourceOptions, input.edits);
1522
+ } catch (err) {
1523
+ return {
1524
+ ok: false,
1525
+ changed: false,
1526
+ diff: "",
1527
+ problems: [err instanceof Error ? err.message : String(err)],
1528
+ validation: { ok: false, errors: [] },
1529
+ token: null
1530
+ };
1531
+ }
1532
+ });
1533
+ ipcMain.handle(CHANNELS.commitFlowsEdit, async (_event, input) => {
1534
+ try {
1535
+ return await commitFlowsEdit(input.token);
1536
+ } catch (err) {
1537
+ return { ok: false, message: err instanceof Error ? err.message : String(err) };
1538
+ }
1539
+ });
1540
+ }
1541
+ const ICON_PATH = join(dirname(fileURLToPath(import.meta.url)), "../../resources/icon.png");
1542
+ const APP_ICON = nativeImage.createFromPath(ICON_PATH);
1543
+ function createWindow() {
1544
+ const theme = readPreferences().theme ?? (nativeTheme.shouldUseDarkColors ? "dark" : "light");
1545
+ const window = new BrowserWindow({
1546
+ width: 1440,
1547
+ height: 900,
1548
+ minWidth: 1024,
1549
+ minHeight: 640,
1550
+ title: "Conductor Control Room",
1551
+ icon: APP_ICON,
1552
+ // Painted before any HTML exists, so a fixed colour here is a flash of the wrong
1553
+ // theme on every launch under the other one.
1554
+ backgroundColor: theme === "dark" ? "#0d0e11" : "#f0f0f3",
1555
+ titleBarStyle: process.platform === "darwin" ? "hiddenInset" : "default",
1556
+ webPreferences: {
1557
+ preload: join(here, "../preload/index.cjs"),
1558
+ additionalArguments: [`--conductor-theme=${theme}`],
1559
+ // The renderer gets no Node and no direct access to `~/.conductor`. Everything
1560
+ // it shows arrives over the typed IPC surface in `shared/contract.ts`.
1561
+ contextIsolation: true,
1562
+ nodeIntegration: false,
1563
+ sandbox: false
1564
+ }
1565
+ });
1566
+ window.on("closed", () => clearSubscription(window.id));
1567
+ const devServer = process.env["ELECTRON_RENDERER_URL"];
1568
+ if (devServer) {
1569
+ void window.loadURL(devServer);
1570
+ } else {
1571
+ void window.loadFile(join(here, "../renderer/index.html"));
1572
+ }
1573
+ const capture = argValue("--capture");
1574
+ if (capture) {
1575
+ window.webContents.once("did-finish-load", () => {
1576
+ void captureAndExit(window, capture);
1577
+ });
1578
+ }
1579
+ }
1580
+ function initialTicketArg() {
1581
+ return argValue("--ticket");
1582
+ }
1583
+ function initialTabArg() {
1584
+ const value = argValue("--tab");
1585
+ return value === "graph" || value === "state" || value === "audit" || value === "config" ? value : null;
1586
+ }
1587
+ void app.whenReady().then(async () => {
1588
+ if (process.platform === "darwin" && app.dock) {
1589
+ app.dock.setIcon(ICON_PATH);
1590
+ }
1591
+ if (!sourceOptions.configPath) {
1592
+ sourceOptions.configPath = (await readSettings(userDataDir())).config_path;
1593
+ }
1594
+ await syncRuntimeDir();
1595
+ registerHandlers();
1596
+ createWindow();
1597
+ app.on("activate", () => {
1598
+ if (BrowserWindow.getAllWindows().length === 0) createWindow();
1599
+ });
1600
+ });
1601
+ app.on("window-all-closed", () => {
1602
+ for (const id of [...subscriptions.keys()]) clearSubscription(id);
1603
+ if (process.platform !== "darwin") app.quit();
1604
+ });
1605
+ export {
1606
+ initialTabArg,
1607
+ initialTicketArg
1608
+ };