@welluable/orch 1.3.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,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,45 @@
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
+
8
43
  /**
9
44
  * Compact outcome written onto `run.json` with every terminal state transition.
10
45
  * Shared so all call sites (pipelines, shutdown, reconcile) keep one shape.
@@ -41,6 +76,7 @@ export function jobPaths(cwd, slug) {
41
76
  runJsonPath: path.join(dir, 'run.json'),
42
77
  lockPath: path.join(dir, '.run.lock'),
43
78
  logPath: path.join(dir, 'orch.log'),
79
+ failureLogPath: path.join(dir, 'failure.log'),
44
80
  };
45
81
  }
46
82
 
@@ -132,20 +168,57 @@ function acquireLock(lockPath, { timeoutMs = 5000, retryMs = 5 } = {}) {
132
168
  }
133
169
  }
134
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
+
135
200
  /**
136
201
  * Locks, re-reads, shallow-merges `patchFnOrObject` (an object, or a
137
202
  * `(current) => partialPatch` function) over the latest record, atomically
138
- * 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 `{}`.
139
206
  */
140
207
  export function patchJob(cwd, slug, patchFnOrObject) {
141
208
  const { dir, runJsonPath, lockPath } = jobPaths(cwd, slug);
142
209
  fs.mkdirSync(dir, { recursive: true });
143
210
  acquireLock(lockPath);
144
211
  try {
145
- 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;
146
218
  const patch = typeof patchFnOrObject === 'function' ? patchFnOrObject(current) : patchFnOrObject;
147
- const updated = { ...current, ...patch };
219
+ const updated = { ...current, ...patch, slug };
148
220
  atomicWriteJson(dir, runJsonPath, updated);
221
+ maybeNotifyTerminalTransition(previousState, updated);
149
222
  return updated;
150
223
  } finally {
151
224
  try {
@@ -166,10 +239,29 @@ export function reconcileJob(cwd, slug, record) {
166
239
  if (isPidAlive(record.pid)) return record;
167
240
  return patchJob(cwd, slug, (current) => {
168
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
+ );
169
260
  return {
170
261
  state: 'crashed',
171
262
  finishedAt,
172
263
  exitCode: null,
264
+ failureLogPath,
173
265
  lastOutcome: buildLastOutcome({
174
266
  state: 'crashed',
175
267
  phase: current.phase,
@@ -179,7 +271,7 @@ export function reconcileJob(cwd, slug, record) {
179
271
  finishedAt,
180
272
  task: current.task,
181
273
  summary: '',
182
- error: null,
274
+ error: failureLogPointer(slug),
183
275
  }),
184
276
  };
185
277
  });
@@ -233,7 +325,9 @@ export function listJobs(cwd) {
233
325
  for (const slug of slugs) {
234
326
  const record = readJob(cwd, slug);
235
327
  if (!record) continue;
236
- 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));
237
331
  }
238
332
 
239
333
  jobs.sort((a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime());
@@ -344,15 +438,46 @@ export function cascadeResume(cwd, parentSlug) {
344
438
  return { childrenSignaled };
345
439
  }
346
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
+
347
461
  /**
348
462
  * The pure operation behind `orch jobs clean`: removes every entry under
349
463
  * `<cwd>/.orch/`. Returns the names that were deleted (empty if `.orch`
350
- * 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.
351
468
  */
352
469
  export function cleanJobs(cwd) {
353
470
  const orchDir = path.join(path.resolve(cwd), '.orch');
354
471
  if (!fs.existsSync(orchDir)) return [];
355
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
+
356
481
  const names = fs.readdirSync(orchDir);
357
482
  for (const name of names) {
358
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
+ }