@welluable/orch 1.1.0 → 1.2.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/lib/fanout.js ADDED
@@ -0,0 +1,355 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ import { execFileSync as nodeExecFileSync } from 'node:child_process';
5
+ import { isPidAlive } from './jobs.js';
6
+
7
+ const SCAFFOLD_PREREGISTER_TEXT = 'pre-register every shared registry, barrel, and route-table entry wired to stubs so they never need to touch those files.';
8
+
9
+ /** Absolute paths for a fan-out's on-disk state under `<cwd>/.orch/<parentSlug>/`. */
10
+ function fanoutPaths(cwd, parentSlug) {
11
+ const dir = path.join(path.resolve(cwd), '.orch', parentSlug);
12
+ return {
13
+ dir,
14
+ fanoutJsonPath: path.join(dir, 'fanout.json'),
15
+ lockPath: path.join(dir, '.fanout.lock'),
16
+ };
17
+ }
18
+
19
+ function atomicWriteJson(dir, filePath, data) {
20
+ fs.mkdirSync(dir, { recursive: true });
21
+ const tmpPath = path.join(
22
+ dir,
23
+ `.${path.basename(filePath)}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`,
24
+ );
25
+ fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2));
26
+ fs.renameSync(tmpPath, filePath);
27
+ }
28
+
29
+ function sleepSync(ms) {
30
+ const view = new Int32Array(new SharedArrayBuffer(4));
31
+ Atomics.wait(view, 0, 0, ms);
32
+ }
33
+
34
+ /**
35
+ * Acquires `.fanout.lock` via exclusive create, busy-waiting on contention.
36
+ * A lock whose owner pid is no longer alive is treated as stale and removed.
37
+ */
38
+ function acquireLock(lockPath, { timeoutMs = 5000, retryMs = 5 } = {}) {
39
+ const start = Date.now();
40
+ for (;;) {
41
+ try {
42
+ const fd = fs.openSync(lockPath, 'wx');
43
+ fs.writeSync(fd, JSON.stringify({ pid: process.pid }));
44
+ fs.closeSync(fd);
45
+ return;
46
+ } catch (err) {
47
+ if (err.code !== 'EEXIST') throw err;
48
+
49
+ let ownerPid = null;
50
+ try {
51
+ ownerPid = JSON.parse(fs.readFileSync(lockPath, 'utf8')).pid;
52
+ } catch {
53
+ // Lock file mid-write or briefly unreadable; treat as contention.
54
+ }
55
+
56
+ if (ownerPid != null && !isPidAlive(ownerPid)) {
57
+ try {
58
+ fs.unlinkSync(lockPath);
59
+ } catch {
60
+ // Another process may have removed it first; retry.
61
+ }
62
+ continue;
63
+ }
64
+
65
+ if (Date.now() - start > timeoutMs) {
66
+ throw new Error(`patchWorker/patchIntegration: timed out waiting for lock ${lockPath}`);
67
+ }
68
+ sleepSync(retryMs);
69
+ }
70
+ }
71
+ }
72
+
73
+ /** Reads and parses `fanout.json`; `null` if missing; throws on invalid JSON. */
74
+ export function readFanout(cwd, parentSlug) {
75
+ const { fanoutJsonPath } = fanoutPaths(cwd, parentSlug);
76
+ let content;
77
+ try {
78
+ content = fs.readFileSync(fanoutJsonPath, 'utf8');
79
+ } catch (err) {
80
+ if (err.code === 'ENOENT') return null;
81
+ throw err;
82
+ }
83
+ return JSON.parse(content);
84
+ }
85
+
86
+ /** Atomically (write-temp + rename) writes the full fan-out document. */
87
+ export function writeFanout(cwd, parentSlug, data) {
88
+ const { dir, fanoutJsonPath } = fanoutPaths(cwd, parentSlug);
89
+ atomicWriteJson(dir, fanoutJsonPath, data);
90
+ }
91
+
92
+ /**
93
+ * Locks, re-reads the latest document, shallow-merges `patchFnOrObject` (an
94
+ * object, or a `(currentWorker) => partialPatch` function) onto the matching
95
+ * entry in `workers[]`, atomically writes the whole document back, unlocks,
96
+ * and returns the updated full document.
97
+ */
98
+ export function patchWorker(cwd, parentSlug, workerId, patchFnOrObject) {
99
+ const { dir, fanoutJsonPath, lockPath } = fanoutPaths(cwd, parentSlug);
100
+ fs.mkdirSync(dir, { recursive: true });
101
+ acquireLock(lockPath);
102
+ try {
103
+ const current = readFanout(cwd, parentSlug);
104
+ const workers = current.workers.map((worker) => {
105
+ if (worker.id !== workerId) return worker;
106
+ const patch = typeof patchFnOrObject === 'function' ? patchFnOrObject(worker) : patchFnOrObject;
107
+ return { ...worker, ...patch };
108
+ });
109
+ const updated = { ...current, workers };
110
+ atomicWriteJson(dir, fanoutJsonPath, updated);
111
+ return updated;
112
+ } finally {
113
+ try {
114
+ fs.unlinkSync(lockPath);
115
+ } catch {
116
+ // Already gone; nothing to clean up.
117
+ }
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Same lock/read/merge/write/unlock shape as `patchWorker`, applied to the
123
+ * top-level `integration` object.
124
+ */
125
+ export function patchIntegration(cwd, parentSlug, patchFnOrObject) {
126
+ const { dir, fanoutJsonPath, lockPath } = fanoutPaths(cwd, parentSlug);
127
+ fs.mkdirSync(dir, { recursive: true });
128
+ acquireLock(lockPath);
129
+ try {
130
+ const current = readFanout(cwd, parentSlug);
131
+ const patch = typeof patchFnOrObject === 'function' ? patchFnOrObject(current.integration) : patchFnOrObject;
132
+ const updated = { ...current, integration: { ...current.integration, ...patch } };
133
+ atomicWriteJson(dir, fanoutJsonPath, updated);
134
+ return updated;
135
+ } finally {
136
+ try {
137
+ fs.unlinkSync(lockPath);
138
+ } catch {
139
+ // Already gone; nothing to clean up.
140
+ }
141
+ }
142
+ }
143
+
144
+ function pathsOverlap(a, b) {
145
+ if (a === b) return true;
146
+ const aDir = a.endsWith('/') ? a : `${a}/`;
147
+ const bDir = b.endsWith('/') ? b : `${b}/`;
148
+ return b.startsWith(aDir) || a.startsWith(bDir);
149
+ }
150
+
151
+ function ownsOverlap(ownsA, ownsB) {
152
+ for (const a of ownsA) {
153
+ for (const b of ownsB) {
154
+ if (pathsOverlap(a, b)) return true;
155
+ }
156
+ }
157
+ return false;
158
+ }
159
+
160
+ function hasDependencyCycle(workers) {
161
+ const byId = new Map(workers.map((w) => [w.id, w]));
162
+ const WHITE = 0;
163
+ const GRAY = 1;
164
+ const BLACK = 2;
165
+ const color = new Map(workers.map((w) => [w.id, WHITE]));
166
+
167
+ function visit(id) {
168
+ color.set(id, GRAY);
169
+ const worker = byId.get(id);
170
+ for (const dep of worker.dependsOn || []) {
171
+ const depColor = color.get(dep);
172
+ if (depColor === GRAY) return true;
173
+ if (depColor === WHITE && visit(dep)) return true;
174
+ }
175
+ color.set(id, BLACK);
176
+ return false;
177
+ }
178
+
179
+ for (const worker of workers) {
180
+ if (color.get(worker.id) === WHITE && visit(worker.id)) return true;
181
+ }
182
+ return false;
183
+ }
184
+
185
+ /** Returns a violation list for a decomposition (empty array = valid). */
186
+ export function validateDecomposition(decomposition, { maxWorkers } = {}) {
187
+ const violations = [];
188
+ const workers = decomposition?.workers ?? [];
189
+
190
+ if (workers.length < 2) {
191
+ violations.push('fewer than two workers; not decomposable');
192
+ }
193
+ if (typeof maxWorkers === 'number' && workers.length > maxWorkers) {
194
+ violations.push(`too many workers: ${workers.length} exceeds maxWorkers ${maxWorkers}`);
195
+ }
196
+
197
+ const ids = new Set(workers.map((w) => w.id));
198
+ let hasUnknownDep = false;
199
+ for (const worker of workers) {
200
+ if (!worker.owns || worker.owns.length === 0) {
201
+ violations.push(`worker ${worker.id} has no owns`);
202
+ }
203
+ if (!worker.area) {
204
+ violations.push(`worker ${worker.id} has no area`);
205
+ }
206
+ for (const dep of worker.dependsOn || []) {
207
+ if (!ids.has(dep)) {
208
+ violations.push(`worker ${worker.id} depends on unknown id ${dep}`);
209
+ hasUnknownDep = true;
210
+ }
211
+ }
212
+ }
213
+
214
+ const cyclic = !hasUnknownDep && hasDependencyCycle(workers);
215
+ if (cyclic) {
216
+ violations.push('dependsOn graph has a cycle');
217
+ }
218
+
219
+ if (!hasUnknownDep && !cyclic) {
220
+ const layers = planLayers(workers);
221
+ for (const layer of layers) {
222
+ const layerWorkers = layer.map((id) => workers.find((w) => w.id === id));
223
+ for (let i = 0; i < layerWorkers.length; i += 1) {
224
+ for (let j = i + 1; j < layerWorkers.length; j += 1) {
225
+ if (ownsOverlap(layerWorkers[i].owns || [], layerWorkers[j].owns || [])) {
226
+ violations.push(
227
+ `workers ${layerWorkers[i].id} and ${layerWorkers[j].id} have overlapping owns in the same layer`,
228
+ );
229
+ }
230
+ }
231
+ }
232
+ }
233
+ }
234
+
235
+ const scaffolds = workers.filter((w) => w.scaffold);
236
+ if (scaffolds.length > 1) {
237
+ violations.push('more than one worker marked scaffold');
238
+ }
239
+ for (const scaffold of scaffolds) {
240
+ if (scaffold.dependsOn && scaffold.dependsOn.length > 0) {
241
+ violations.push(`scaffold worker ${scaffold.id} has dependencies`);
242
+ }
243
+ }
244
+
245
+ return violations;
246
+ }
247
+
248
+ /**
249
+ * Topological layering over `dependsOn`: layer 0 is every worker with no
250
+ * dependencies, each subsequent layer is workers whose deps are all already
251
+ * placed. Preserves the input array's relative order within each layer.
252
+ */
253
+ export function planLayers(workers) {
254
+ const remaining = new Set(workers.map((w) => w.id));
255
+ const done = new Set();
256
+ const layers = [];
257
+
258
+ while (remaining.size > 0) {
259
+ const layer = [];
260
+ for (const worker of workers) {
261
+ if (!remaining.has(worker.id)) continue;
262
+ const deps = worker.dependsOn || [];
263
+ if (deps.every((dep) => done.has(dep))) {
264
+ layer.push(worker.id);
265
+ }
266
+ }
267
+ if (layer.length === 0) break;
268
+ for (const id of layer) {
269
+ remaining.delete(id);
270
+ done.add(id);
271
+ }
272
+ layers.push(layer);
273
+ }
274
+
275
+ return layers;
276
+ }
277
+
278
+ /** Coordinator concurrency rule: layer size, capped when `maxConcurrency` is a number. */
279
+ export function chooseConcurrency({ layerSize, maxConcurrency }) {
280
+ if (typeof maxConcurrency !== 'number') return layerSize;
281
+ return Math.min(layerSize, maxConcurrency);
282
+ }
283
+
284
+ /** Thin per-worker prompt: subtask, area, sibling titles; no `owns`, no `boundaries.md`. */
285
+ export function buildWorkerEnvelope({ subtask, area, scaffold, siblingTitles }) {
286
+ const siblings = (siblingTitles || []).join(', ');
287
+ const lines = [
288
+ subtask,
289
+ '',
290
+ `This is one worker in a parallel orch run. Sibling workers are handling: ${siblings}. Keep your changes within ${area} and do not refactor or reorganize anything outside it.`,
291
+ ];
292
+ lines.push(
293
+ scaffold
294
+ ? `Parallel workers will follow; ${SCAFFOLD_PREREGISTER_TEXT}`
295
+ : 'Shared types, interfaces, and stubs already exist on your base commit — use them as they are rather than redefining them.',
296
+ );
297
+ return lines.join('\n');
298
+ }
299
+
300
+ /** Thin integration prompt: ordered branches, overlapping paths or "none"; no `owns`. */
301
+ export function buildIntegrationEnvelope({ task, branches, overlappingFiles }) {
302
+ const branchList = (branches || []).join(', ');
303
+ const overlapList = overlappingFiles && overlappingFiles.length > 0 ? overlappingFiles.join(', ') : 'none';
304
+ return [
305
+ `Combine the completed worker branches for "${task}" into one coherent branch and make the full test suite pass.`,
306
+ '',
307
+ `Branches to merge, in order: ${branchList}. Files more than one worker changed: ${overlapList}. Resolve merge fallout only — do not redesign or reimplement what the workers built. Shared types, interfaces, and stubs already exist on the base commit.`,
308
+ ].join('\n');
309
+ }
310
+
311
+ function defaultExecFile(command, args, options = {}) {
312
+ return nodeExecFileSync(command, args, { encoding: 'utf8', ...options });
313
+ }
314
+
315
+ /** Runs `git diff --name-only <base>..<branch>` and returns the parsed file-path array. */
316
+ export function recordChangedFiles({ repoRoot, base, branch, execFile = defaultExecFile }) {
317
+ const output = execFile('git', ['-C', repoRoot, 'diff', '--name-only', `${base}..${branch}`]);
318
+ return output.split('\n').map((line) => line.trim()).filter(Boolean);
319
+ }
320
+
321
+ /**
322
+ * For every file appearing in ≥2 workers' `changedFiles`, appends that path
323
+ * onto each of those workers' `overlaps` arrays (mutated in place) and
324
+ * returns the deduped union of overlapping paths.
325
+ */
326
+ export function detectOverlaps(workers) {
327
+ const countByFile = new Map();
328
+ for (const worker of workers) {
329
+ for (const file of new Set(worker.changedFiles || [])) {
330
+ countByFile.set(file, (countByFile.get(file) || 0) + 1);
331
+ }
332
+ }
333
+
334
+ const union = [];
335
+ for (const [file, count] of countByFile.entries()) {
336
+ if (count >= 2) union.push(file);
337
+ }
338
+
339
+ for (const worker of workers) {
340
+ const changed = new Set(worker.changedFiles || []);
341
+ for (const file of union) {
342
+ if (changed.has(file) && !worker.overlaps.includes(file)) {
343
+ worker.overlaps.push(file);
344
+ }
345
+ }
346
+ }
347
+
348
+ return union;
349
+ }
350
+
351
+ /** Appends the pre-register wording to a scaffold worker's subtask if not already present. */
352
+ export function ensureScaffoldSubtask(text) {
353
+ if (text && /pre-regist/i.test(text)) return text;
354
+ return `${text} Pre-register shared registries, barrels, or route tables so parallel workers can fill in bodies without touching those files.`;
355
+ }
@@ -0,0 +1,89 @@
1
+ import path from 'node:path';
2
+
3
+ /** @param {string} name */
4
+ function markerForName(name) {
5
+ const key = String(name || '').toLowerCase();
6
+ if (key === 'write') return '+';
7
+ if (key === 'edit' || key === 'multiedit') return '~';
8
+ if (key === 'delete') return '-';
9
+ return null;
10
+ }
11
+
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;
17
+ }
18
+
19
+ /**
20
+ * Collect Write/Edit/Delete completions into an ordered, deduped file list
21
+ * for live sticky lines and stage-summary `Files (N)`.
22
+ */
23
+ export class FileTracker {
24
+ /** @param {{ cwd: string }} opts */
25
+ constructor({ cwd }) {
26
+ this.cwd = path.resolve(cwd);
27
+ /** @type {Map<string, { name: string, marker: string, path: string|null }>} */
28
+ this.pending = new Map();
29
+ /** @type {{ marker: string, path: string }[]} */
30
+ this.files = [];
31
+ /** @type {Map<string, number>} */
32
+ this.indexByPath = new Map();
33
+ }
34
+
35
+ /**
36
+ * @param {{ name: string, args: Record<string, unknown>, phase: 'started'|'completed', callId: string }} event
37
+ * @returns {{ marker: string, path: string, isNew: boolean }|null}
38
+ */
39
+ record({ name, args, phase, callId }) {
40
+ if (phase === 'started') {
41
+ const marker = markerForName(name);
42
+ if (!marker) return null;
43
+ this.pending.set(callId, {
44
+ name,
45
+ marker,
46
+ path: extractPath(args),
47
+ });
48
+ return null;
49
+ }
50
+
51
+ if (phase !== 'completed') return null;
52
+
53
+ const prior = this.pending.get(callId);
54
+ this.pending.delete(callId);
55
+
56
+ const toolName = name || prior?.name || '';
57
+ const marker = markerForName(toolName) || prior?.marker || null;
58
+ if (!marker) return null;
59
+
60
+ const rawPath = extractPath(args) || prior?.path || null;
61
+ if (!rawPath) return null;
62
+
63
+ const relPath = this.#toRelative(rawPath);
64
+ const existingIdx = this.indexByPath.get(relPath);
65
+ const isNew = existingIdx === undefined;
66
+
67
+ if (isNew) {
68
+ this.indexByPath.set(relPath, this.files.length);
69
+ this.files.push({ marker, path: relPath });
70
+ } else {
71
+ this.files[existingIdx].marker = marker;
72
+ }
73
+
74
+ return { marker, path: relPath, isNew };
75
+ }
76
+
77
+ /** @returns {{ marker: string, path: string }[]} */
78
+ getFiles() {
79
+ return this.files.map((f) => ({ marker: f.marker, path: f.path }));
80
+ }
81
+
82
+ /** @param {string} filePath */
83
+ #toRelative(filePath) {
84
+ if (path.isAbsolute(filePath)) {
85
+ return path.relative(this.cwd, filePath) || path.basename(filePath);
86
+ }
87
+ return filePath;
88
+ }
89
+ }
@@ -0,0 +1,51 @@
1
+ import { execFileSync as nodeExecFileSync } from 'node:child_process';
2
+
3
+ function defaultExecFile(command, args, options = {}) {
4
+ return nodeExecFileSync(command, args, { encoding: 'utf8', ...options });
5
+ }
6
+
7
+ function parseLines(output) {
8
+ return output.split('\n').map((line) => line.trim()).filter(Boolean);
9
+ }
10
+
11
+ /**
12
+ * Sequentially merges `candidates` (in order) into `cwd` via `git merge
13
+ * --no-ff`, skipping branches already present in `merged`. Stops advancing on
14
+ * the first conflict, leaving the tree conflicted for the caller to repair or
15
+ * abort. Returns the per-branch results accumulated so far.
16
+ */
17
+ export function mergeBranches({ cwd, candidates, merged = [], overlappingFiles = [], execFile = defaultExecFile }) {
18
+ const results = [];
19
+ for (const branch of candidates) {
20
+ if (merged.includes(branch)) {
21
+ results.push({ branch, status: 'skipped' });
22
+ continue;
23
+ }
24
+
25
+ try {
26
+ const output = execFile('git', ['-C', cwd, 'merge', '--no-ff', branch]);
27
+ results.push({ branch, status: 'merged', output });
28
+ } catch (err) {
29
+ results.push({ branch, status: 'conflict', output: err.stderr || err.message });
30
+ break;
31
+ }
32
+ }
33
+ return results;
34
+ }
35
+
36
+ /** Runs `git merge --abort` in `cwd`. */
37
+ export function abortMerge({ cwd, execFile = defaultExecFile }) {
38
+ return execFile('git', ['-C', cwd, 'merge', '--abort']);
39
+ }
40
+
41
+ /** Runs `git diff --name-only --diff-filter=U` in `cwd`; returns the parsed path array. */
42
+ export function conflictedFiles({ cwd, execFile = defaultExecFile }) {
43
+ const output = execFile('git', ['-C', cwd, 'diff', '--name-only', '--diff-filter=U']);
44
+ return parseLines(output);
45
+ }
46
+
47
+ /** True if `git diff` in `cwd` still shows an unresolved `<<<<<<<` marker. */
48
+ export function hasConflictMarkers({ cwd, execFile = defaultExecFile }) {
49
+ const output = execFile('git', ['-C', cwd, 'diff']);
50
+ return output.includes('<<<<<<<');
51
+ }
@@ -0,0 +1,53 @@
1
+ import { generateSlug as generateSlugDefault } from './slug.js';
2
+ import { createRunContext as createRunContextDefault } from './run-context.js';
3
+ import { writeJob as writeJobDefault, jobPaths } from './jobs.js';
4
+
5
+ /**
6
+ * Shared job-allocation helper: generates a slug, creates the run directory,
7
+ * and writes the initial `run.json`. Used by both `runDetached` (which starts
8
+ * a job `"starting"` with no pid yet, since a separate child process still
9
+ * has to start) and the Commander action's non-detached branch (which starts
10
+ * a job `"running"` with `process.pid`, since there is no separate child to
11
+ * wait on).
12
+ */
13
+ export function allocateJob({
14
+ cwd,
15
+ prompt,
16
+ agent,
17
+ maxRounds = null,
18
+ state = 'starting',
19
+ pid = null,
20
+ parent = null,
21
+ role = null,
22
+ workerId = null,
23
+ generateSlug = generateSlugDefault,
24
+ createRunContext = createRunContextDefault,
25
+ writeJob = writeJobDefault,
26
+ }) {
27
+ const slug = generateSlug();
28
+ const runContext = createRunContext({ cwd, slug });
29
+ const record = {
30
+ slug,
31
+ task: prompt,
32
+ agent,
33
+ maxRounds,
34
+ cwd,
35
+ pauseRequested: false,
36
+ branch: null,
37
+ worktree: null,
38
+ startedAt: new Date().toISOString(),
39
+ finishedAt: null,
40
+ exitCode: null,
41
+ logPath: jobPaths(cwd, slug).logPath,
42
+ pid,
43
+ state,
44
+ phase: null,
45
+ stage: null,
46
+ round: null,
47
+ parent,
48
+ role,
49
+ workerId,
50
+ };
51
+ writeJob(cwd, slug, record);
52
+ return { slug, runContext, record };
53
+ }