@welluable/orch 1.2.0 → 1.4.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,150 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { readJob, reconcileJob, jobPaths } from './jobs.js';
4
+ import { readSeq } from './seq.js';
5
+
6
+ const CONTINUE_ELIGIBLE = new Set(['done']);
7
+ const FAILURE_TERMINALS = new Set(['failed', 'stopped', 'crashed']);
8
+
9
+ /** Plain Error whose `toString()` is just the message (no `Error:` prefix), so
10
+ * `assert.throws(fn, /^exact message$/)` contracts match Node's RegExp check. */
11
+ function fail(message) {
12
+ const err = new Error(message);
13
+ err.name = '';
14
+ return err;
15
+ }
16
+
17
+ /**
18
+ * Pure eligibility gate for `orch continue`. Reconciles first; never mutates
19
+ * beyond reconcile's dead-pid → crashed rewrite. Throws plain Error messages
20
+ * (CLI wraps with `Error: ${err.message}`).
21
+ *
22
+ * Continue is follow-up work on primarily `done` jobs. Terminal failures
23
+ * refuse by default and point at `orch resume` (see `.spec/resume.md`).
24
+ */
25
+ export function validateContinue(cwd, slug, { task, ask, quick } = {}) {
26
+ const existing = readJob(cwd, slug);
27
+ if (!existing) throw fail(`unknown run ${slug}`);
28
+
29
+ if (ask) throw fail('orch continue does not support --ask; use the default orch command');
30
+ if (quick) throw fail('orch continue does not support --quick; use the default orch command');
31
+
32
+ if (typeof task !== 'string' || !task.trim()) {
33
+ throw fail('task cannot be empty');
34
+ }
35
+
36
+ const record = reconcileJob(cwd, slug, existing);
37
+
38
+ if (record.role === 'coordinator') {
39
+ if (readSeq(cwd, slug)) {
40
+ throw fail(
41
+ `cannot continue coordinator ${slug}; continue each failed unit slug, then orch --seq-continue ${slug} or orch resume ${slug}`,
42
+ );
43
+ }
44
+ throw fail(
45
+ `cannot continue coordinator ${slug}; continue each failed worker slug, then orch --integrate ${slug}`,
46
+ );
47
+ }
48
+
49
+ if (record.role === 'integration') {
50
+ const parent = record.parent ?? '<parent-slug>';
51
+ throw fail(
52
+ `cannot continue integration ${slug}; use orch --integrate ${parent}`,
53
+ );
54
+ }
55
+
56
+ if (FAILURE_TERMINALS.has(record.state)) {
57
+ const phase = record.phase ?? record.lastOutcome?.phase ?? '?';
58
+ const stage = record.stage ?? record.lastOutcome?.stage ?? '?';
59
+ throw fail(
60
+ `${slug} is ${record.state} at ${phase}/${stage};\nuse: orch resume ${slug}`,
61
+ );
62
+ }
63
+
64
+ if (!CONTINUE_ELIGIBLE.has(record.state)) {
65
+ throw fail(
66
+ `cannot continue ${slug} while state is ${record.state}; use orch resume / orch stop`,
67
+ );
68
+ }
69
+
70
+ if (!record.worktree || !record.branch) {
71
+ throw fail(`${slug} has no worktree; continue only applies to complex runs`);
72
+ }
73
+
74
+ if (!fs.existsSync(record.worktree)) {
75
+ throw fail(`worktree missing at ${record.worktree}; cannot continue ${slug}`);
76
+ }
77
+
78
+ return record;
79
+ }
80
+
81
+ /**
82
+ * Snapshot prior outcome for continue reopen. Prefer `record.lastOutcome`;
83
+ * otherwise synthesize from terminal fields (+ best-effort status.md scrape).
84
+ * Never throws.
85
+ */
86
+ export function snapshotPriorOutcome(cwd, slug, record) {
87
+ if (record?.lastOutcome) return record.lastOutcome;
88
+
89
+ let summary = '';
90
+ try {
91
+ const statusPath = path.join(jobPaths(cwd, slug).dir, 'status.md');
92
+ if (fs.existsSync(statusPath)) {
93
+ const content = fs.readFileSync(statusPath, 'utf8');
94
+ const matches = [...content.matchAll(/^- Summary:\s*(.*)$/gm)];
95
+ if (matches.length > 0) {
96
+ summary = (matches[matches.length - 1][1] || '').trim();
97
+ }
98
+ }
99
+ } catch {
100
+ summary = '';
101
+ }
102
+
103
+ return {
104
+ state: record.state,
105
+ phase: record.phase ?? null,
106
+ stage: record.stage ?? null,
107
+ round: record.round ?? null,
108
+ exitCode: record.exitCode ?? null,
109
+ finishedAt: record.finishedAt ?? null,
110
+ task: record.task ?? null,
111
+ summary,
112
+ error: null,
113
+ };
114
+ }
115
+
116
+ function displayOrNone(value) {
117
+ if (value == null || value === '') return '(none recorded)';
118
+ return String(value);
119
+ }
120
+
121
+ /**
122
+ * Build the `[Prior run outcome]…[/Prior run outcome]` block injected into
123
+ * research/planner prompts on continue.
124
+ */
125
+ export function buildPriorOutcomeText(prior, {
126
+ slug,
127
+ continuation,
128
+ worktreePath,
129
+ branch,
130
+ parentSlug,
131
+ workerId,
132
+ } = {}) {
133
+ const p = prior ?? {};
134
+ const lines = [
135
+ '[Prior run outcome]',
136
+ `- Continuation: this is continue ${continuation} on slug ${slug}`,
137
+ `- Prior state: ${displayOrNone(p.state)}`,
138
+ `- Prior phase: ${displayOrNone(p.phase)}`,
139
+ `- Prior stage: ${displayOrNone(p.stage)}`,
140
+ `- Prior round: ${p.round == null ? 'null' : p.round}`,
141
+ `- Prior task: ${displayOrNone(p.task)}`,
142
+ `- Summary: ${displayOrNone(p.summary)}`,
143
+ `- Error: ${displayOrNone(p.error)}`,
144
+ `- Worktree: ${worktreePath} (branch ${branch} tip is the starting point; do not assume a clean tree)`,
145
+ ];
146
+ if (parentSlug) lines.push(`- Fan-out parent: ${parentSlug}`);
147
+ if (workerId) lines.push(`- Worker id: ${workerId}`);
148
+ lines.push('[/Prior run outcome]');
149
+ return lines.join('\n');
150
+ }
@@ -0,0 +1,72 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ /**
5
+ * Pure failure.log section formatting + append. No jobs imports — safe for
6
+ * both lib/agent.js (in-process buffer flush) and lib/jobs.js (crashed
7
+ * header-only write) without circular dependencies.
8
+ */
9
+
10
+ /** Relative pointer for status / lastOutcome.error (matches resume.md sample). */
11
+ export function failureLogPointer(slug) {
12
+ return `see .orch/${slug}/failure.log`;
13
+ }
14
+
15
+ function fmtField(label, value) {
16
+ const rendered = value == null || value === '' ? '' : String(value);
17
+ return `${label}:${' '.repeat(Math.max(1, 11 - label.length))}${rendered}`;
18
+ }
19
+
20
+ /**
21
+ * Build one `=== orch failure ===` section (header + stage verbose + optional
22
+ * prior summaries). `stageVerbose` may be empty (crashed / header-only).
23
+ */
24
+ export function formatFailureSection({
25
+ slug,
26
+ state,
27
+ phase = null,
28
+ stage = null,
29
+ round = null,
30
+ exitCode = null,
31
+ finishedAt,
32
+ task = null,
33
+ error = null,
34
+ stageVerbose = '',
35
+ priorStages = [],
36
+ }) {
37
+ const stageLabel = stage ?? 'unknown';
38
+ const lines = [
39
+ '=== orch failure ===',
40
+ fmtField('slug', slug),
41
+ fmtField('state', state),
42
+ fmtField('phase', phase),
43
+ fmtField('stage', stage),
44
+ fmtField('round', round),
45
+ fmtField('exitCode', exitCode),
46
+ fmtField('finishedAt', finishedAt),
47
+ fmtField('task', task),
48
+ fmtField('error', error),
49
+ '',
50
+ `=== stage verbose (${stageLabel}) ===`,
51
+ stageVerbose.endsWith('\n') || stageVerbose === '' ? stageVerbose : `${stageVerbose}\n`,
52
+ ];
53
+
54
+ if (priorStages.length > 0) {
55
+ lines.push('=== prior stage summaries (best-effort) ===');
56
+ for (const prior of priorStages) {
57
+ const label = prior.stage ?? prior.phase ?? 'prior';
58
+ lines.push(`--- ${label} ---`);
59
+ const body = prior.verbose ?? '';
60
+ lines.push(body.endsWith('\n') || body === '' ? body : `${body}\n`);
61
+ }
62
+ }
63
+
64
+ return lines.join('\n');
65
+ }
66
+
67
+ /** Append a section to `failureLogPath`, creating the file/dir if needed. */
68
+ export function appendFailureLog(failureLogPath, section) {
69
+ fs.mkdirSync(path.dirname(failureLogPath), { recursive: true });
70
+ const prefix = fs.existsSync(failureLogPath) ? '\n' : '';
71
+ fs.appendFileSync(failureLogPath, prefix + section, 'utf8');
72
+ }
@@ -1,4 +1,5 @@
1
1
  import path from 'node:path';
2
+ import { toolPath } from './tool-status.js';
2
3
 
3
4
  /** @param {string} name */
4
5
  function markerForName(name) {
@@ -9,11 +10,42 @@ function markerForName(name) {
9
10
  return null;
10
11
  }
11
12
 
12
- /** @param {Record<string, unknown>|null|undefined} args */
13
- function extractPath(args) {
14
- if (!args || typeof args !== 'object') return null;
15
- const p = args.path ?? args.file_path;
16
- return typeof p === 'string' && p ? p : null;
13
+ /**
14
+ * Parse OpenCode `apply_patch` marker lines into file-trail entries.
15
+ * @param {unknown} patchText
16
+ * @returns {{ marker: string, path: string }[]}
17
+ */
18
+ export function parseApplyPatchPaths(patchText) {
19
+ if (typeof patchText !== 'string' || !patchText) return [];
20
+
21
+ /** @type {{ marker: string, path: string }[]} */
22
+ const entries = [];
23
+ for (const line of patchText.split('\n')) {
24
+ const add = line.match(/^\*\*\* Add File:\s*(.+?)\s*$/);
25
+ if (add) {
26
+ entries.push({ marker: '+', path: add[1] });
27
+ continue;
28
+ }
29
+ const update = line.match(/^\*\*\* Update File:\s*(.+?)\s*$/);
30
+ if (update) {
31
+ entries.push({ marker: '~', path: update[1] });
32
+ continue;
33
+ }
34
+ const del = line.match(/^\*\*\* Delete File:\s*(.+?)\s*$/);
35
+ if (del) {
36
+ entries.push({ marker: '-', path: del[1] });
37
+ continue;
38
+ }
39
+ const move = line.match(/^\*\*\* Move to:\s*(.+?)\s*$/);
40
+ if (move) {
41
+ // Destination only; drop the preceding Update File source entry.
42
+ if (entries.length > 0 && entries[entries.length - 1].marker === '~') {
43
+ entries.pop();
44
+ }
45
+ entries.push({ marker: '~', path: move[1] });
46
+ }
47
+ }
48
+ return entries;
17
49
  }
18
50
 
19
51
  /**
@@ -43,7 +75,7 @@ export class FileTracker {
43
75
  this.pending.set(callId, {
44
76
  name,
45
77
  marker,
46
- path: extractPath(args),
78
+ path: toolPath(args),
47
79
  });
48
80
  return null;
49
81
  }
@@ -57,9 +89,38 @@ export class FileTracker {
57
89
  const marker = markerForName(toolName) || prior?.marker || null;
58
90
  if (!marker) return null;
59
91
 
60
- const rawPath = extractPath(args) || prior?.path || null;
92
+ const rawPath = toolPath(args) || prior?.path || null;
61
93
  if (!rawPath) return null;
62
94
 
95
+ return this.#upsert(marker, rawPath);
96
+ }
97
+
98
+ /**
99
+ * Record paths from a completed OpenCode `apply_patch` into the trail.
100
+ * @param {unknown} patchText
101
+ * @returns {{ marker: string, path: string, isNew: boolean }[]}
102
+ */
103
+ recordApplyPatch(patchText) {
104
+ /** @type {{ marker: string, path: string, isNew: boolean }[]} */
105
+ const out = [];
106
+ for (const entry of parseApplyPatchPaths(patchText)) {
107
+ const result = this.#upsert(entry.marker, entry.path);
108
+ if (result) out.push(result);
109
+ }
110
+ return out;
111
+ }
112
+
113
+ /** @returns {{ marker: string, path: string }[]} */
114
+ getFiles() {
115
+ return this.files.map((f) => ({ marker: f.marker, path: f.path }));
116
+ }
117
+
118
+ /**
119
+ * @param {string} marker
120
+ * @param {string} rawPath
121
+ * @returns {{ marker: string, path: string, isNew: boolean }}
122
+ */
123
+ #upsert(marker, rawPath) {
63
124
  const relPath = this.#toRelative(rawPath);
64
125
  const existingIdx = this.indexByPath.get(relPath);
65
126
  const isNew = existingIdx === undefined;
@@ -74,11 +135,6 @@ export class FileTracker {
74
135
  return { marker, path: relPath, isNew };
75
136
  }
76
137
 
77
- /** @returns {{ marker: string, path: string }[]} */
78
- getFiles() {
79
- return this.files.map((f) => ({ marker: f.marker, path: f.path }));
80
- }
81
-
82
138
  /** @param {string} filePath */
83
139
  #toRelative(filePath) {
84
140
  if (path.isAbsolute(filePath)) {
package/lib/jobs.js CHANGED
@@ -1,10 +1,73 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import crypto from 'node:crypto';
4
+ import {
5
+ appendFailureLog,
6
+ failureLogPointer,
7
+ formatFailureSection,
8
+ } from './failure-log.js';
9
+ import { getNotifyEnabled, notifyJob as defaultNotifyJob } from './notify.js';
4
10
 
5
11
  const ACTIVE_LIVE_STATES = ['running', 'pausing', 'paused'];
6
12
  const TERMINAL_STATES = ['done', 'failed', 'stopped', 'crashed'];
7
13
 
14
+ /** Injectable notify hook for tests; default is `notifyJob` from lib/notify.js. */
15
+ let notifyJobHook = defaultNotifyJob;
16
+
17
+ /** Override the notify helper used on terminal state transitions (tests). */
18
+ export function setNotifyJob(fn) {
19
+ notifyJobHook = typeof fn === 'function' ? fn : defaultNotifyJob;
20
+ }
21
+
22
+ /** Restore the default notify hook. */
23
+ export function resetNotifyHooks() {
24
+ notifyJobHook = defaultNotifyJob;
25
+ }
26
+
27
+ function maybeNotifyTerminalTransition(previousState, updated) {
28
+ if (!getNotifyEnabled()) return;
29
+ if (!TERMINAL_STATES.includes(updated.state)) return;
30
+ if (TERMINAL_STATES.includes(previousState)) return;
31
+ try {
32
+ notifyJobHook({
33
+ slug: updated.slug,
34
+ state: updated.state,
35
+ task: updated.task,
36
+ enabled: true,
37
+ });
38
+ } catch {
39
+ // Notification failures must never affect job writes.
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Compact outcome written onto `run.json` with every terminal state transition.
45
+ * Shared so all call sites (pipelines, shutdown, reconcile) keep one shape.
46
+ */
47
+ export function buildLastOutcome({
48
+ state,
49
+ phase = null,
50
+ stage = null,
51
+ round = null,
52
+ exitCode = null,
53
+ finishedAt,
54
+ task = null,
55
+ summary = '',
56
+ error = null,
57
+ }) {
58
+ return {
59
+ state,
60
+ phase: phase ?? null,
61
+ stage: stage ?? null,
62
+ round: round ?? null,
63
+ exitCode: exitCode ?? null,
64
+ finishedAt,
65
+ task: task ?? null,
66
+ summary: summary ?? '',
67
+ error: error ?? null,
68
+ };
69
+ }
70
+
8
71
  /** Absolute paths for a job's on-disk artifacts under `<cwd>/.orch/<slug>/`. */
9
72
  export function jobPaths(cwd, slug) {
10
73
  const dir = path.join(path.resolve(cwd), '.orch', slug);
@@ -13,6 +76,7 @@ export function jobPaths(cwd, slug) {
13
76
  runJsonPath: path.join(dir, 'run.json'),
14
77
  lockPath: path.join(dir, '.run.lock'),
15
78
  logPath: path.join(dir, 'orch.log'),
79
+ failureLogPath: path.join(dir, 'failure.log'),
16
80
  };
17
81
  }
18
82
 
@@ -104,20 +168,57 @@ function acquireLock(lockPath, { timeoutMs = 5000, retryMs = 5 } = {}) {
104
168
  }
105
169
  }
106
170
 
171
+ /**
172
+ * Minimal valid job record used when `patchJob` rehydrates a missing
173
+ * `run.json`. Mirrors `allocateJob` defaults and always includes `slug`.
174
+ */
175
+ function rehydrateJobRecord(cwd, slug) {
176
+ return {
177
+ slug,
178
+ task: null,
179
+ agent: null,
180
+ maxRounds: null,
181
+ cwd: path.resolve(cwd),
182
+ pauseRequested: false,
183
+ branch: null,
184
+ worktree: null,
185
+ startedAt: new Date().toISOString(),
186
+ finishedAt: null,
187
+ exitCode: null,
188
+ logPath: jobPaths(cwd, slug).logPath,
189
+ pid: null,
190
+ state: 'starting',
191
+ phase: null,
192
+ stage: null,
193
+ round: null,
194
+ parent: null,
195
+ role: null,
196
+ workerId: null,
197
+ };
198
+ }
199
+
107
200
  /**
108
201
  * Locks, re-reads, shallow-merges `patchFnOrObject` (an object, or a
109
202
  * `(current) => partialPatch` function) over the latest record, atomically
110
- * writes, unlocks, and returns the updated record.
203
+ * writes, unlocks, and returns the updated record. When `run.json` is
204
+ * missing, rehydrates a minimal valid base (always including `slug`) before
205
+ * merging — never writes a slug-less stub from `{}`.
111
206
  */
112
207
  export function patchJob(cwd, slug, patchFnOrObject) {
113
208
  const { dir, runJsonPath, lockPath } = jobPaths(cwd, slug);
114
209
  fs.mkdirSync(dir, { recursive: true });
115
210
  acquireLock(lockPath);
116
211
  try {
117
- const current = readJob(cwd, slug) ?? {};
212
+ let current = readJob(cwd, slug);
213
+ if (!current) {
214
+ console.warn(`patchJob: rehydrating missing run.json for ${slug}`);
215
+ current = rehydrateJobRecord(cwd, slug);
216
+ }
217
+ const previousState = current.state;
118
218
  const patch = typeof patchFnOrObject === 'function' ? patchFnOrObject(current) : patchFnOrObject;
119
- const updated = { ...current, ...patch };
219
+ const updated = { ...current, ...patch, slug };
120
220
  atomicWriteJson(dir, runJsonPath, updated);
221
+ maybeNotifyTerminalTransition(previousState, updated);
121
222
  return updated;
122
223
  } finally {
123
224
  try {
@@ -136,10 +237,78 @@ export function patchJob(cwd, slug, patchFnOrObject) {
136
237
  export function reconcileJob(cwd, slug, record) {
137
238
  if (!ACTIVE_LIVE_STATES.includes(record.state)) return record;
138
239
  if (isPidAlive(record.pid)) return record;
240
+ return patchJob(cwd, slug, (current) => {
241
+ const finishedAt = new Date().toISOString();
242
+ const { failureLogPath } = jobPaths(cwd, slug);
243
+ // Header-only: out-of-process crash has no in-memory stage buffer.
244
+ appendFailureLog(
245
+ failureLogPath,
246
+ formatFailureSection({
247
+ slug,
248
+ state: 'crashed',
249
+ phase: current.phase,
250
+ stage: current.stage,
251
+ round: current.round,
252
+ exitCode: null,
253
+ finishedAt,
254
+ task: current.task,
255
+ error: 'process died',
256
+ stageVerbose: '',
257
+ priorStages: [],
258
+ }),
259
+ );
260
+ return {
261
+ state: 'crashed',
262
+ finishedAt,
263
+ exitCode: null,
264
+ failureLogPath,
265
+ lastOutcome: buildLastOutcome({
266
+ state: 'crashed',
267
+ phase: current.phase,
268
+ stage: current.stage,
269
+ round: current.round,
270
+ exitCode: null,
271
+ finishedAt,
272
+ task: current.task,
273
+ summary: '',
274
+ error: failureLogPointer(slug),
275
+ }),
276
+ };
277
+ });
278
+ }
279
+
280
+ /**
281
+ * Reopen an existing terminal job for `orch continue`. Patches in place —
282
+ * never allocates a new slug/directory. Bumps `continuation`, appends
283
+ * `continuations[]`, clears live `lastOutcome`, resets to running/research.
284
+ */
285
+ export function reopenJob(cwd, slug, { task, agent, maxRounds, pid, prior, startedAt } = {}) {
286
+ const existing = readJob(cwd, slug);
287
+ if (!existing) throw new Error(`reopenJob: unknown job ${slug}`);
288
+
289
+ const started = startedAt ?? new Date().toISOString();
290
+ const continuation = (existing.continuation ?? 1) + 1;
291
+ const continuations = Array.isArray(existing.continuations)
292
+ ? [...existing.continuations]
293
+ : [];
294
+ continuations.push({ n: continuation, task, startedAt: started, prior: prior ?? null });
295
+
139
296
  return patchJob(cwd, slug, {
140
- state: 'crashed',
141
- finishedAt: new Date().toISOString(),
297
+ task,
298
+ agent,
299
+ maxRounds,
300
+ pid,
301
+ startedAt: started,
302
+ state: 'running',
303
+ phase: 'research',
304
+ stage: null,
305
+ round: null,
306
+ finishedAt: null,
142
307
  exitCode: null,
308
+ pauseRequested: false,
309
+ lastOutcome: null,
310
+ continuation,
311
+ continuations,
143
312
  });
144
313
  }
145
314
 
@@ -156,7 +325,9 @@ export function listJobs(cwd) {
156
325
  for (const slug of slugs) {
157
326
  const record = readJob(cwd, slug);
158
327
  if (!record) continue;
159
- jobs.push(reconcileJob(cwd, slug, record));
328
+ // Directory name is the authority when run.json omits/corrupts slug.
329
+ const normalized = { ...record, slug: record.slug ?? slug };
330
+ jobs.push(reconcileJob(cwd, slug, normalized));
160
331
  }
161
332
 
162
333
  jobs.sort((a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime());
@@ -267,15 +438,46 @@ export function cascadeResume(cwd, parentSlug) {
267
438
  return { childrenSignaled };
268
439
  }
269
440
 
441
+ /**
442
+ * Slugs of jobs in an active live state whose pid is still alive.
443
+ * Used by `cleanJobs` / `orch jobs clean` to refuse wiping live runs.
444
+ */
445
+ export function liveSlugsBlockingClean(cwd) {
446
+ const orchDir = path.join(path.resolve(cwd), '.orch');
447
+ if (!fs.existsSync(orchDir)) return [];
448
+
449
+ const live = [];
450
+ for (const entry of fs.readdirSync(orchDir, { withFileTypes: true })) {
451
+ if (!entry.isDirectory()) continue;
452
+ const record = readJob(cwd, entry.name);
453
+ if (!record) continue;
454
+ if (!ACTIVE_LIVE_STATES.includes(record.state)) continue;
455
+ if (!isPidAlive(record.pid)) continue;
456
+ live.push(record.slug ?? entry.name);
457
+ }
458
+ return live;
459
+ }
460
+
270
461
  /**
271
462
  * The pure operation behind `orch jobs clean`: removes every entry under
272
463
  * `<cwd>/.orch/`. Returns the names that were deleted (empty if `.orch`
273
- * is missing or already empty).
464
+ * is missing or already empty). Refuses (throws) when any job is in an
465
+ * active live state (`running`/`pausing`/`paused`) with an alive pid —
466
+ * caller must `orch stop <slug>` first. Dead-pid "live" states may still
467
+ * be cleaned.
274
468
  */
275
469
  export function cleanJobs(cwd) {
276
470
  const orchDir = path.join(path.resolve(cwd), '.orch');
277
471
  if (!fs.existsSync(orchDir)) return [];
278
472
 
473
+ const live = liveSlugsBlockingClean(cwd);
474
+ if (live.length > 0) {
475
+ throw new Error(
476
+ `cannot clean while live jobs exist: ${live.join(', ')}. ` +
477
+ `Stop them first with: orch stop <slug>`,
478
+ );
479
+ }
480
+
279
481
  const names = fs.readdirSync(orchDir);
280
482
  for (const name of names) {
281
483
  fs.rmSync(path.join(orchDir, name), { recursive: true, force: true });
package/lib/notify.js ADDED
@@ -0,0 +1,71 @@
1
+ import { spawn as defaultSpawn } from 'node:child_process';
2
+
3
+ const TERMINAL_STATES = new Set(['done', 'failed', 'stopped', 'crashed']);
4
+
5
+ /** Process-level gate; default off so unit tests that patch jobs stay silent. */
6
+ let notifyEnabled = false;
7
+
8
+ /** Sets whether terminal job transitions should fire desktop notifications. */
9
+ export function setNotifyEnabled(enabled) {
10
+ notifyEnabled = Boolean(enabled);
11
+ }
12
+
13
+ /** Current process-level notify gate. */
14
+ export function getNotifyEnabled() {
15
+ return notifyEnabled;
16
+ }
17
+
18
+ function shortTask(task) {
19
+ if (task == null) return '';
20
+ const text = String(task).trim();
21
+ if (!text) return '';
22
+ if (text.length <= 80) return text;
23
+ return `${text.slice(0, 80)}…`;
24
+ }
25
+
26
+ function buildBody(state, task) {
27
+ const short = shortTask(task);
28
+ return short ? `${state} — ${short}` : state;
29
+ }
30
+
31
+ /**
32
+ * Fire-and-forget desktop notification for a terminal job state.
33
+ * Inject `spawn` / `platform` in tests. Never throws.
34
+ */
35
+ export function notifyJob({
36
+ slug,
37
+ state,
38
+ task,
39
+ enabled,
40
+ spawn: spawnFn = defaultSpawn,
41
+ platform = process.platform,
42
+ } = {}) {
43
+ try {
44
+ if (enabled === false) return;
45
+ if (!TERMINAL_STATES.has(state)) return;
46
+
47
+ const title = `orch · ${slug}`;
48
+ const body = buildBody(state, task);
49
+
50
+ if (platform === 'darwin') {
51
+ const script = `display notification ${JSON.stringify(body)} with title ${JSON.stringify(title)}`;
52
+ const child = spawnFn('osascript', ['-e', script], {
53
+ stdio: 'ignore',
54
+ detached: true,
55
+ });
56
+ child.unref?.();
57
+ return;
58
+ }
59
+
60
+ if (platform === 'linux') {
61
+ const child = spawnFn('notify-send', ['--', title, body], {
62
+ stdio: 'ignore',
63
+ detached: true,
64
+ });
65
+ child.unref?.();
66
+ return;
67
+ }
68
+ } catch {
69
+ // Best-effort: never fatal.
70
+ }
71
+ }