@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.
- package/README.md +94 -18
- package/agents/adjust.js +66 -0
- package/agents/ask.js +3 -5
- package/agents/boundaries.js +3 -4
- package/agents/code-writer.js +3 -5
- package/agents/decomposer.js +5 -5
- package/agents/index.js +2 -0
- package/agents/integrator.js +3 -4
- package/agents/planner.js +3 -5
- package/agents/quick-fix.js +3 -5
- package/agents/research.js +3 -5
- package/agents/seq-decomposer.js +58 -0
- package/agents/summary-footer.js +23 -0
- package/agents/test-critic.js +5 -6
- package/agents/test-runner.js +5 -6
- package/agents/test-writer.js +3 -5
- package/agents/triage.js +5 -6
- package/lib/agent-agn.js +4 -2
- package/lib/agent-cursor.js +4 -2
- package/lib/agent-opencode.js +177 -0
- package/lib/agent.js +150 -12
- package/lib/config.js +92 -17
- package/lib/continue.js +25 -7
- package/lib/failure-log.js +72 -0
- package/lib/file-tracker.js +68 -12
- package/lib/jobs.js +131 -6
- package/lib/notify.js +71 -0
- package/lib/resume.js +264 -0
- package/lib/seq.js +281 -0
- package/lib/stage-summary.js +24 -0
- package/lib/tool-status.js +53 -6
- package/main.js +2571 -412
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/lib/file-tracker.js
CHANGED
|
@@ -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
|
-
/**
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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:
|
|
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 =
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
+
}
|