@bluewombat/conductor 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,775 @@
1
+ import { existsSync } from "node:fs";
2
+ import { readdir, rm, stat } from "node:fs/promises";
3
+ import { isAbsolute, join, resolve } from "node:path";
4
+ import { featureWorkspacePath, subtaskWorkspacePath } from "./paths.js";
5
+ const DEFAULT_MAX_REFUSALS = 3;
6
+ const DEFAULT_TRANSFORMER_DURATION_MS = 30_000;
7
+ function featureJsonOf(admission) {
8
+ const document = {
9
+ key: admission.key,
10
+ intention: admission.intention,
11
+ };
12
+ if (admission.title !== undefined) {
13
+ document.title = admission.title;
14
+ }
15
+ return JSON.stringify(document);
16
+ }
17
+ async function isDirectory(path) {
18
+ try {
19
+ return (await stat(path)).isDirectory();
20
+ }
21
+ catch {
22
+ return false;
23
+ }
24
+ }
25
+ async function destroy(path) {
26
+ await rm(path, { recursive: true, force: true });
27
+ }
28
+ async function destroyOrphans(workspaceRoot, allowed) {
29
+ if (!(await isDirectory(workspaceRoot))) {
30
+ return;
31
+ }
32
+ const top = await readdir(workspaceRoot, { withFileTypes: true });
33
+ for (const entry of top) {
34
+ const keyDir = resolve(join(workspaceRoot, entry.name));
35
+ if (!entry.isDirectory()) {
36
+ await destroy(keyDir);
37
+ continue;
38
+ }
39
+ const keepsKeyDir = [...allowed].some((path) => path === keyDir || path.startsWith(`${keyDir}/`));
40
+ if (!keepsKeyDir) {
41
+ await destroy(keyDir);
42
+ continue;
43
+ }
44
+ const inner = await readdir(keyDir, { withFileTypes: true });
45
+ for (const child of inner) {
46
+ const childPath = resolve(join(keyDir, child.name));
47
+ if (!allowed.has(childPath)) {
48
+ await destroy(childPath);
49
+ }
50
+ }
51
+ }
52
+ }
53
+ const SUBJECT_MAX = 72;
54
+ /**
55
+ * A fold's subject out of prose. Text written the way a commit is — a headline,
56
+ * a blank line, the rest — gives its headline. Otherwise the first sentence,
57
+ * cut at a word when even that runs past what one line of history shows. A
58
+ * subject is not markdown, so the backticks prose uses for names come off; and
59
+ * it ends on a word, not on an ellipsis — the whole text follows in the body
60
+ * for whoever wants it.
61
+ */
62
+ export function subjectOf(text) {
63
+ const trimmed = text.trim();
64
+ const headline = /^([^\n]+)\n\s*\n/.exec(trimmed)?.[1];
65
+ const oneLine = (headline ?? trimmed).replace(/\s+/g, " ");
66
+ // A sentence ends where the next one starts with a capital, or where the
67
+ // text ends: "e.g. foo" and "src/index.js" are not sentence ends.
68
+ const sentence = /^(.*?[.!?])(?:\s+(?=[A-Z\p{Lu}])|$)/u.exec(oneLine)?.[1] ?? oneLine;
69
+ const candidate = sentence.replace(/`/g, "").replace(/[.:;,\s]+$/, "");
70
+ if (candidate.length <= SUBJECT_MAX) {
71
+ return candidate;
72
+ }
73
+ const cut = candidate.slice(0, SUBJECT_MAX);
74
+ const atWord = cut.lastIndexOf(" ");
75
+ return (atWord > SUBJECT_MAX / 2 ? cut.slice(0, atWord) : cut).replace(/[.:;,\s]+$/, "");
76
+ }
77
+ /** Subject line, then the whole text when the subject did not carry it all. */
78
+ export function foldMessageOf(text) {
79
+ const subject = subjectOf(text);
80
+ const whole = text.trim();
81
+ return subject === whole || subject === whole.replace(/[.\s]+$/, "")
82
+ ? subject
83
+ : `${subject}\n\n${whole}`;
84
+ }
85
+ export function openConductor(options) {
86
+ if (!isAbsolute(options.workLineStable) || !isAbsolute(options.workspaceRoot)) {
87
+ throw new Error("Conductor workLineStable and workspaceRoot must be absolute directories.");
88
+ }
89
+ if (resolve(options.workLineStable) === resolve(options.workspaceRoot)) {
90
+ throw new Error("Conductor workspaceRoot must not be the WorkLineStable path.");
91
+ }
92
+ const ledger = options.ledger;
93
+ const observe = async (moment) => {
94
+ try {
95
+ await options.observe?.(moment);
96
+ }
97
+ catch {
98
+ // The watcher's failure is not the work's.
99
+ }
100
+ };
101
+ const transformers = options.transformers;
102
+ const workLineStable = resolve(options.workLineStable);
103
+ const workspaceRoot = resolve(options.workspaceRoot);
104
+ const durationMs = options.transformerDurationMs ?? DEFAULT_TRANSFORMER_DURATION_MS;
105
+ const authority = options.authority;
106
+ const workLineTarget = options.workLineTarget ?? "";
107
+ const maxRefusals = options.maxRefusals ?? DEFAULT_MAX_REFUSALS;
108
+ let paused = false;
109
+ const refused = (code) => ({
110
+ outcome: "refused",
111
+ code,
112
+ });
113
+ /** What the last Attempt ended on, for whoever reads the escalation. */
114
+ const reasonOfTraces = (traces) => {
115
+ const last = traces.at(-1);
116
+ const report = last?.report?.trim();
117
+ if (report === undefined || report.length === 0) {
118
+ return undefined;
119
+ }
120
+ return last?.refusedBy === undefined ? report : `${last.refusedBy}: ${report}`;
121
+ };
122
+ const fromLedger = (result) => {
123
+ if (result.ok) {
124
+ return undefined;
125
+ }
126
+ if (result.code === "persist-failed") {
127
+ return refused("persist-failed");
128
+ }
129
+ if (result.code === "point-of-no-return" ||
130
+ result.code === "project-busy" ||
131
+ result.code === "not-found" ||
132
+ result.code === "illegal-transition") {
133
+ return refused(result.code);
134
+ }
135
+ return refused("illegal-transition");
136
+ };
137
+ /**
138
+ * Freeze the Feature for a human. If a newer fingerprint arrived in flight,
139
+ * it is named on the escalation and consumed by the ledger.
140
+ */
141
+ async function freeze(key, input) {
142
+ const got = await ledger.get(key);
143
+ const pending = got.ok && got.aggregate.pending_fingerprint !== undefined
144
+ ? `The intention was edited while the work was in flight (now fingerprint ${got.aggregate.pending_fingerprint}). The work stops here for a human to decide: on resume it is planned again from the new words, and what already landed is kept.`
145
+ : undefined;
146
+ const report = [input.report, pending]
147
+ .filter((part) => part !== undefined && part.length > 0)
148
+ .join(" ");
149
+ const escalated = await ledger.escalate({
150
+ key,
151
+ kind: input.kind,
152
+ ...(input.subtaskId === undefined ? {} : { subtaskId: input.subtaskId }),
153
+ ...(report.length === 0 ? {} : { report }),
154
+ });
155
+ const failure = fromLedger(escalated);
156
+ if (failure !== undefined) {
157
+ return failure;
158
+ }
159
+ return { outcome: "escalated", key };
160
+ }
161
+ async function freezeIfPending(key) {
162
+ const got = await ledger.get(key);
163
+ if (!got.ok || got.aggregate.pending_fingerprint === undefined) {
164
+ return undefined;
165
+ }
166
+ return await freeze(key, { kind: "plan" });
167
+ }
168
+ async function expireIfHeld(key) {
169
+ const expired = await ledger.expireBail(key);
170
+ if (expired.ok) {
171
+ const got = await ledger.get(key);
172
+ if (got.ok && got.aggregate.workspaces?.subtask !== undefined) {
173
+ await destroy(got.aggregate.workspaces.subtask);
174
+ }
175
+ }
176
+ }
177
+ async function reconcile(project) {
178
+ const declared = await ledger.listDeclaredWorkspaces();
179
+ const allowed = new Set();
180
+ for (const row of declared) {
181
+ if (row.feature !== undefined) {
182
+ allowed.add(resolve(row.feature));
183
+ }
184
+ if (row.subtask !== undefined) {
185
+ allowed.add(resolve(row.subtask));
186
+ }
187
+ }
188
+ await destroyOrphans(workspaceRoot, allowed);
189
+ for (const row of declared) {
190
+ await expireIfHeld(row.key);
191
+ }
192
+ const active = await ledger.activeOn(project);
193
+ if (active !== undefined) {
194
+ await expireIfHeld(active.key);
195
+ }
196
+ }
197
+ async function destroyDeclared(key) {
198
+ const got = await ledger.get(key);
199
+ if (!got.ok) {
200
+ return;
201
+ }
202
+ const feature = got.aggregate.workspaces?.feature;
203
+ const subtask = got.aggregate.workspaces?.subtask;
204
+ if (subtask !== undefined) {
205
+ await destroy(subtask);
206
+ }
207
+ if (feature !== undefined) {
208
+ await destroy(feature);
209
+ }
210
+ await ledger.clearWorkspace({ key, feature: true, subtask: true });
211
+ }
212
+ async function ensureFeatureIsolated(key) {
213
+ const child = featureWorkspacePath(workspaceRoot, key);
214
+ const got = await ledger.get(key);
215
+ if (!got.ok) {
216
+ return refused("not-found");
217
+ }
218
+ if (got.aggregate.workspaces?.feature === undefined) {
219
+ const declared = await ledger.declareWorkspace({ key, feature: child });
220
+ const failure = fromLedger(declared);
221
+ if (failure !== undefined) {
222
+ return failure;
223
+ }
224
+ }
225
+ if (existsSync(child)) {
226
+ return undefined;
227
+ }
228
+ if (got.aggregate.submission !== undefined) {
229
+ // The Authority is holding a Submission built from a workspace that is no
230
+ // longer here. Isolating again would rebuild the feature from the work
231
+ // line and publish it over that Submission, which would drop whatever the
232
+ // Authority has already been shown. That is a human's call, not this
233
+ // loop's.
234
+ const escalated = await freeze(key, { kind: "submitted" });
235
+ return escalated;
236
+ }
237
+ const isolated = await transformers.isolate({
238
+ id: key,
239
+ parent: workLineStable,
240
+ child,
241
+ durationMs,
242
+ });
243
+ if (isolated.outcome === "interrupted") {
244
+ return refused("interrupted");
245
+ }
246
+ if (isolated.outcome !== "isolated") {
247
+ return refused(isolated.outcome === "invalid-invocation" ? "transformer-invalid" : "isolate-failed");
248
+ }
249
+ return undefined;
250
+ }
251
+ async function doPlan(key) {
252
+ const got = await ledger.get(key);
253
+ if (!got.ok) {
254
+ return refused("not-found");
255
+ }
256
+ if (got.aggregate.state !== "planning") {
257
+ return undefined;
258
+ }
259
+ const broken = await transformers.breakDown({
260
+ featureJson: featureJsonOf(got.aggregate.intention),
261
+ });
262
+ if (broken.outcome === "refused") {
263
+ const recorded = await ledger.refusePlan({
264
+ key,
265
+ code: broken.code,
266
+ reason: broken.reason,
267
+ });
268
+ const failure = fromLedger(recorded);
269
+ if (failure !== undefined) {
270
+ return failure;
271
+ }
272
+ return { outcome: "escalated", key };
273
+ }
274
+ if (broken.outcome === "interrupted") {
275
+ return refused("interrupted");
276
+ }
277
+ if (broken.outcome === "unavailable") {
278
+ return refused("unavailable");
279
+ }
280
+ if (broken.outcome !== "planned") {
281
+ return refused("transformer-invalid");
282
+ }
283
+ const recorded = await ledger.recordPlan({
284
+ key,
285
+ plannedAt: broken.plannedAt,
286
+ subtasks: broken.subtasks,
287
+ });
288
+ const recordFail = fromLedger(recorded);
289
+ if (recordFail !== undefined) {
290
+ return recordFail;
291
+ }
292
+ await observe({ kind: "planned", key });
293
+ return undefined;
294
+ }
295
+ async function runOneSubtask(key, subtaskId) {
296
+ const started = await ledger.startSubtask({ key, subtaskId });
297
+ const startFail = fromLedger(started);
298
+ if (startFail !== undefined) {
299
+ return startFail;
300
+ }
301
+ const child = subtaskWorkspacePath(workspaceRoot, key, subtaskId);
302
+ if (existsSync(child)) {
303
+ // Resume from zero: Isolator refuses an existing Child.
304
+ await destroy(child);
305
+ }
306
+ const declared = await ledger.declareWorkspace({ key, subtask: child });
307
+ const declareFail = fromLedger(declared);
308
+ if (declareFail !== undefined) {
309
+ return declareFail;
310
+ }
311
+ const parent = featureWorkspacePath(workspaceRoot, key);
312
+ const isolated = await transformers.isolate({
313
+ // Scoped to the feature: a Subtask id is unique inside its plan and
314
+ // nowhere else, so "A" alone would name every feature's first Subtask.
315
+ id: `${key}:${subtaskId}`,
316
+ parent,
317
+ child,
318
+ durationMs,
319
+ });
320
+ if (isolated.outcome === "interrupted") {
321
+ return refused("interrupted");
322
+ }
323
+ if (isolated.outcome !== "isolated") {
324
+ return refused(isolated.outcome === "invalid-invocation" ? "transformer-invalid" : "isolate-failed");
325
+ }
326
+ const got = await ledger.get(key);
327
+ if (!got.ok) {
328
+ return refused("not-found");
329
+ }
330
+ const subtask = got.aggregate.plan?.subtasks.find((st) => st.id === subtaskId);
331
+ if (subtask === undefined) {
332
+ return refused("not-found");
333
+ }
334
+ const implemented = await transformers.implement({
335
+ id: subtaskId,
336
+ context: key,
337
+ intention: subtask.intention,
338
+ definitionOfDone: subtask.definition_of_done,
339
+ workspace: child,
340
+ });
341
+ if (implemented.outcome === "interrupted") {
342
+ return refused("interrupted");
343
+ }
344
+ if (implemented.outcome === "invalid-invocation") {
345
+ return refused("transformer-invalid");
346
+ }
347
+ let number = got.aggregate.attempts.filter((a) => a.subtask_id === subtaskId).length + 1;
348
+ for (const trace of implemented.traces) {
349
+ const recorded = await ledger.recordAttempt({
350
+ key,
351
+ subtaskId,
352
+ number,
353
+ trace: {
354
+ ended: trace.ended,
355
+ ...(trace.report === undefined || trace.report.length === 0
356
+ ? {}
357
+ : { report: trace.report }),
358
+ ...(trace.refusedBy === undefined ? {} : { refusedBy: trace.refusedBy }),
359
+ },
360
+ });
361
+ const failure = fromLedger(recorded);
362
+ if (failure !== undefined) {
363
+ return failure;
364
+ }
365
+ number += 1;
366
+ }
367
+ if (implemented.outcome === "escalated") {
368
+ const why = reasonOfTraces(implemented.traces);
369
+ return await freeze(key, {
370
+ kind: "subtask",
371
+ subtaskId,
372
+ ...(why === undefined ? {} : { report: why }),
373
+ });
374
+ }
375
+ const folded = await transformers.integrate({
376
+ id: subtaskId,
377
+ parent,
378
+ child,
379
+ durationMs,
380
+ // What carries the work into the feature says what the work was.
381
+ subject: foldMessageOf(subtask.intention),
382
+ mergeSubject: `Merge ${subtaskId} into the feature`,
383
+ });
384
+ if (folded.outcome === "interrupted") {
385
+ return refused("interrupted");
386
+ }
387
+ if (folded.outcome === "conflict") {
388
+ // The unit passed its Gates; what stopped it is the fold, which leaves
389
+ // no trace of its own. Said here, or the escalation says nothing.
390
+ return await freeze(key, {
391
+ kind: "subtask",
392
+ subtaskId,
393
+ report: `Folding ${subtaskId} into the feature hit a conflict.`,
394
+ });
395
+ }
396
+ if (folded.outcome !== "integrated") {
397
+ return refused("transformer-invalid");
398
+ }
399
+ const marked = await ledger.markSubtaskIntegrated({ key, subtaskId });
400
+ const markFail = fromLedger(marked);
401
+ if (markFail !== undefined) {
402
+ return markFail;
403
+ }
404
+ await destroy(child);
405
+ await ledger.clearWorkspace({ key, subtask: true });
406
+ const after = await ledger.get(key);
407
+ if (after.ok && after.aggregate.plan !== undefined) {
408
+ const subtasks = after.aggregate.plan.subtasks;
409
+ await observe({
410
+ kind: "subtask-integrated",
411
+ key,
412
+ subtaskId,
413
+ integrated: subtasks.filter((subtask) => subtask.state === "integrated").length,
414
+ total: subtasks.length,
415
+ });
416
+ }
417
+ return await freezeIfPending(key);
418
+ }
419
+ async function doRunning(key) {
420
+ const isolated = await ensureFeatureIsolated(key);
421
+ if (isolated !== undefined) {
422
+ return isolated;
423
+ }
424
+ while (!paused) {
425
+ const next = await ledger.nextRunnable(key);
426
+ if (next === undefined) {
427
+ break;
428
+ }
429
+ const step = await runOneSubtask(key, next);
430
+ if (step !== undefined) {
431
+ return step;
432
+ }
433
+ }
434
+ if (paused) {
435
+ return { outcome: "paused", key };
436
+ }
437
+ return undefined;
438
+ }
439
+ async function doIntegrating(key) {
440
+ if (paused) {
441
+ return { outcome: "paused", key };
442
+ }
443
+ const pending = await freezeIfPending(key);
444
+ if (pending !== undefined) {
445
+ return pending;
446
+ }
447
+ const featurePath = featureWorkspacePath(workspaceRoot, key);
448
+ const got = await ledger.get(key);
449
+ if (!got.ok) {
450
+ return refused("not-found");
451
+ }
452
+ const sentBack = got.aggregate.submission?.last_report;
453
+ if (sentBack !== undefined) {
454
+ // Something refused the last round and said why. That report is the only
455
+ // reason this stage has to produce: the Subtasks are already validated.
456
+ //
457
+ // Before the Integration below, and that order is the whole point. What a
458
+ // producer leaves is loose in the workspace; the Integration is the only
459
+ // thing that takes it up. Producing after it means the repair waits for
460
+ // the next round to be taken up at all, and what goes out meanwhile is the
461
+ // state that was already refused — the same refusal comes back, and the
462
+ // budget empties on the delay.
463
+ const remade = await transformers.implement({
464
+ id: `${key}:assembly`,
465
+ context: key,
466
+ stage: "assembly",
467
+ produce: true,
468
+ intention: got.aggregate.intention.intention,
469
+ workspace: featurePath,
470
+ report: sentBack,
471
+ ...(got.aggregate.submission?.last_refused_by === undefined
472
+ ? {}
473
+ : { reportFrom: got.aggregate.submission.last_refused_by }),
474
+ });
475
+ if (remade.outcome === "interrupted") {
476
+ return refused("interrupted");
477
+ }
478
+ if (remade.outcome === "invalid-invocation") {
479
+ return refused("transformer-invalid");
480
+ }
481
+ if (remade.outcome === "escalated") {
482
+ // The whole, not one of its parts: naming a Subtask here sends a reader
483
+ // to look at work that did not fail.
484
+ const why = reasonOfTraces(remade.traces);
485
+ return await freeze(key, {
486
+ kind: "assembly",
487
+ ...(why === undefined ? {} : { report: why }),
488
+ });
489
+ }
490
+ }
491
+ const aligned = await transformers.integrate({
492
+ id: `${key}:align`,
493
+ parent: featurePath,
494
+ child: workLineStable,
495
+ durationMs,
496
+ // What is loose in the feature workspace here is a repair, if anything.
497
+ subject: sentBack === undefined
498
+ ? `Align with ${workLineTarget || "the work line"}`
499
+ : "Repair after the Submission was refused",
500
+ mergeSubject: `Merge ${workLineTarget || "the work line"} into the feature`,
501
+ });
502
+ if (aligned.outcome === "interrupted") {
503
+ return refused("interrupted");
504
+ }
505
+ if (aligned.outcome === "conflict") {
506
+ return refused("align-conflict");
507
+ }
508
+ if (aligned.outcome !== "integrated") {
509
+ return refused("transformer-invalid");
510
+ }
511
+ if (paused) {
512
+ return { outcome: "paused", key };
513
+ }
514
+ if (authority === undefined) {
515
+ // Nobody outside will look at it, so the Project's own sequence is the
516
+ // only judgement the assembled feature gets. Skipping it here would let
517
+ // work into the work line that nothing ever judged as a whole.
518
+ const judged = await judge(key, got.aggregate);
519
+ if (judged.outcome === "interrupted") {
520
+ return refused("interrupted");
521
+ }
522
+ if (judged.outcome === "invalid-invocation") {
523
+ return refused("transformer-invalid");
524
+ }
525
+ if (judged.outcome === "escalated") {
526
+ const why = reasonOfTraces(judged.traces);
527
+ return await freeze(key, {
528
+ kind: "assembly",
529
+ ...(why === undefined ? {} : { report: why }),
530
+ });
531
+ }
532
+ const merging = await ledger.markMerging(key);
533
+ return fromLedger(merging);
534
+ }
535
+ return await offer(key, got.aggregate);
536
+ }
537
+ /**
538
+ * Put the assembled feature in front of the Authority.
539
+ *
540
+ * A feature already submitted is offered again rather than a second time:
541
+ * the work was published to the same place, so the Authority is judging the
542
+ * same Submission with new content.
543
+ */
544
+ async function offer(key, aggregate) {
545
+ if (authority === undefined) {
546
+ return refused("transformer-invalid");
547
+ }
548
+ const title = aggregate.intention.title;
549
+ const intention = aggregate.intention.intention;
550
+ const steps = aggregate.plan?.subtasks.map((subtask) => subtask.intention) ?? [];
551
+ const submitted = await authority.submit({
552
+ key,
553
+ project: aggregate.intention.project,
554
+ ref: key,
555
+ target: workLineTarget,
556
+ ...(title === undefined ? {} : { title }),
557
+ ...(intention === undefined ? {} : { intention }),
558
+ ...(steps.length === 0 ? {} : { steps }),
559
+ });
560
+ if (submitted.outcome === "unavailable") {
561
+ // Nothing is lost: the state still says integrating, and the next pass
562
+ // offers it again.
563
+ return { outcome: "paused", key };
564
+ }
565
+ if (submitted.outcome === "refused") {
566
+ return await freeze(key, { kind: "submitted" });
567
+ }
568
+ const marked = await ledger.markSubmitted({ key, reference: submitted.reference });
569
+ const failure = fromLedger(marked);
570
+ if (failure !== undefined) {
571
+ return failure;
572
+ }
573
+ await observe({ kind: "submitted", key, reference: submitted.reference });
574
+ return { outcome: "paused", key };
575
+ }
576
+ /**
577
+ * Judge the assembled feature with the Project's Gate sequence for it.
578
+ *
579
+ * Nothing is produced: the work is done, and this pass only decides whether it
580
+ * may enter the work line. A Gate that reads an outside answer belongs in that
581
+ * sequence like any other — it says ok or not, and it does not act.
582
+ */
583
+ async function judge(key, aggregate) {
584
+ return await transformers.implement({
585
+ id: `${key}:judgement`,
586
+ context: key,
587
+ stage: "assembly",
588
+ produce: false,
589
+ intention: aggregate.intention.intention,
590
+ workspace: featureWorkspacePath(workspaceRoot, key),
591
+ });
592
+ }
593
+ async function doSubmitted(key) {
594
+ const got = await ledger.get(key);
595
+ if (!got.ok) {
596
+ return refused("not-found");
597
+ }
598
+ const submission = got.aggregate.submission;
599
+ if (authority === undefined || submission === undefined) {
600
+ return refused("transformer-invalid");
601
+ }
602
+ const featurePath = featureWorkspacePath(workspaceRoot, key);
603
+ const judged = await judge(key, got.aggregate);
604
+ if (judged.outcome === "interrupted") {
605
+ return refused("interrupted");
606
+ }
607
+ if (judged.outcome === "invalid-invocation") {
608
+ return refused("transformer-invalid");
609
+ }
610
+ if (judged.outcome === "escalated") {
611
+ const last = judged.traces.at(-1);
612
+ const report = last?.report ?? "The Submission was refused, with no reason given.";
613
+ // A blocking refusal is not something another round repairs, and neither
614
+ // is one more refusal than the budget allows.
615
+ if (last?.ended === "fail-blocking" || submission.refusals + 1 >= maxRefusals) {
616
+ // The refusal that stopped it is never recorded as one — this is the
617
+ // only place it can be kept for the person who reads the escalation.
618
+ return await freeze(key, {
619
+ kind: "submitted",
620
+ report: last?.refusedBy === undefined ? report : `${last.refusedBy}: ${report}`,
621
+ });
622
+ }
623
+ const recorded = await ledger.recordRefusal({
624
+ key,
625
+ report,
626
+ ...(last?.refusedBy === undefined ? {} : { refusedBy: last.refusedBy }),
627
+ });
628
+ const failure = fromLedger(recorded);
629
+ if (failure !== undefined) {
630
+ return failure;
631
+ }
632
+ // Back to where the work is made. The next pass produces with that report
633
+ // in hand and publishes to the same place.
634
+ return { outcome: "paused", key };
635
+ }
636
+ const folded = await authority.fold({ reference: submission.reference });
637
+ if (folded.outcome === "unavailable") {
638
+ return { outcome: "paused", key };
639
+ }
640
+ if (folded.outcome === "conflict") {
641
+ return await freeze(key, { kind: "submitted" });
642
+ }
643
+ // The Authority folded it. The work line moved; this system's copy has not,
644
+ // and whoever wired the Authority is the one that brings it up to date.
645
+ const merging = await ledger.markMerging(key);
646
+ const mergingFailure = fromLedger(merging);
647
+ if (mergingFailure !== undefined) {
648
+ return mergingFailure;
649
+ }
650
+ const done = await ledger.markDone(key, { reference: folded.reference });
651
+ const doneFailure = fromLedger(done);
652
+ if (doneFailure !== undefined) {
653
+ return doneFailure;
654
+ }
655
+ await destroy(featurePath);
656
+ await ledger.clearWorkspace({ key, feature: true, subtask: true });
657
+ return { outcome: "done", key };
658
+ }
659
+ async function doMerging(key) {
660
+ const featurePath = featureWorkspacePath(workspaceRoot, key);
661
+ const got = await ledger.get(key);
662
+ const title = got.ok
663
+ ? (got.aggregate.intention.title ?? got.aggregate.intention.intention)
664
+ : key;
665
+ const folded = await transformers.integrate({
666
+ id: key,
667
+ parent: workLineStable,
668
+ child: featurePath,
669
+ durationMs,
670
+ subject: foldMessageOf(title),
671
+ mergeSubject: `Merge ${subjectOf(title)}`,
672
+ });
673
+ if (folded.outcome === "interrupted") {
674
+ return refused("interrupted");
675
+ }
676
+ if (folded.outcome === "conflict") {
677
+ return await freeze(key, { kind: "merging" });
678
+ }
679
+ if (folded.outcome !== "integrated") {
680
+ return refused("transformer-invalid");
681
+ }
682
+ const done = await ledger.markDone(key);
683
+ const failure = fromLedger(done);
684
+ if (failure !== undefined) {
685
+ return failure;
686
+ }
687
+ await destroy(featurePath);
688
+ await ledger.clearWorkspace({ key, feature: true, subtask: true });
689
+ return { outcome: "done", key };
690
+ }
691
+ async function drive(key) {
692
+ const planResult = await doPlan(key);
693
+ if (planResult !== undefined) {
694
+ return planResult;
695
+ }
696
+ const runningResult = await doRunning(key);
697
+ if (runningResult !== undefined) {
698
+ return runningResult;
699
+ }
700
+ const got = await ledger.get(key);
701
+ if (!got.ok) {
702
+ return refused("not-found");
703
+ }
704
+ if (got.aggregate.state === "escalated") {
705
+ return { outcome: "escalated", key };
706
+ }
707
+ if (got.aggregate.state === "running") {
708
+ return { outcome: "paused", key };
709
+ }
710
+ if (got.aggregate.state === "integrating") {
711
+ const integratingResult = await doIntegrating(key);
712
+ if (integratingResult !== undefined) {
713
+ return integratingResult;
714
+ }
715
+ }
716
+ const after = await ledger.get(key);
717
+ if (!after.ok) {
718
+ return refused("not-found");
719
+ }
720
+ if (after.aggregate.state === "submitted") {
721
+ return await doSubmitted(key);
722
+ }
723
+ if (after.aggregate.state === "merging") {
724
+ return await doMerging(key);
725
+ }
726
+ if (after.aggregate.state === "done") {
727
+ return { outcome: "done", key };
728
+ }
729
+ if (after.aggregate.state === "escalated") {
730
+ return { outcome: "escalated", key };
731
+ }
732
+ return { outcome: "paused", key };
733
+ }
734
+ return {
735
+ async reconcile(project) {
736
+ await reconcile(project);
737
+ },
738
+ pause() {
739
+ paused = true;
740
+ },
741
+ resume() {
742
+ paused = false;
743
+ },
744
+ async runProject(project) {
745
+ if (!(await isDirectory(workLineStable))) {
746
+ return refused("stable-missing");
747
+ }
748
+ await reconcile(project);
749
+ const active = await ledger.activeOn(project);
750
+ if (active !== undefined) {
751
+ return await drive(active.key);
752
+ }
753
+ if (paused) {
754
+ return { outcome: "paused" };
755
+ }
756
+ const claimed = await ledger.claim(project);
757
+ if (!claimed.ok) {
758
+ const failure = fromLedger(claimed);
759
+ return failure ?? refused("illegal-transition");
760
+ }
761
+ if ("empty" in claimed) {
762
+ return { outcome: "idle" };
763
+ }
764
+ return await drive(claimed.key);
765
+ },
766
+ async cancel(key) {
767
+ const cancelled = await ledger.cancel(key);
768
+ if (cancelled.ok) {
769
+ await destroyDeclared(key);
770
+ }
771
+ return cancelled;
772
+ },
773
+ };
774
+ }
775
+ //# sourceMappingURL=open-conductor.js.map