@bluewombat/runtime 0.9.0 → 0.11.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.
Files changed (113) hide show
  1. package/README.md +200 -99
  2. package/SPECS.md +121 -19
  3. package/dist/cli.js +35 -4
  4. package/dist/cli.js.map +1 -1
  5. package/dist/config/agent-shorthand.d.ts +25 -5
  6. package/dist/config/agent-shorthand.d.ts.map +1 -1
  7. package/dist/config/agent-shorthand.js +65 -21
  8. package/dist/config/agent-shorthand.js.map +1 -1
  9. package/dist/config/fixture-yaml.d.ts +8 -0
  10. package/dist/config/fixture-yaml.d.ts.map +1 -0
  11. package/dist/config/fixture-yaml.js +30 -0
  12. package/dist/config/fixture-yaml.js.map +1 -0
  13. package/dist/config/home.d.ts +2 -0
  14. package/dist/config/home.d.ts.map +1 -1
  15. package/dist/config/home.js +2 -0
  16. package/dist/config/home.js.map +1 -1
  17. package/dist/config/load-config.d.ts.map +1 -1
  18. package/dist/config/load-config.js +301 -62
  19. package/dist/config/load-config.js.map +1 -1
  20. package/dist/config/parse-args.d.ts +1 -1
  21. package/dist/config/parse-args.d.ts.map +1 -1
  22. package/dist/config/parse-args.js +84 -58
  23. package/dist/config/parse-args.js.map +1 -1
  24. package/dist/config/types.d.ts +96 -16
  25. package/dist/config/types.d.ts.map +1 -1
  26. package/dist/config/types.js +13 -1
  27. package/dist/config/types.js.map +1 -1
  28. package/dist/index.d.ts +3 -1
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +1 -0
  31. package/dist/index.js.map +1 -1
  32. package/dist/loop/authority.d.ts +38 -1
  33. package/dist/loop/authority.d.ts.map +1 -1
  34. package/dist/loop/authority.js +41 -2
  35. package/dist/loop/authority.js.map +1 -1
  36. package/dist/loop/context.d.ts +16 -1
  37. package/dist/loop/context.d.ts.map +1 -1
  38. package/dist/loop/drive.d.ts +9 -3
  39. package/dist/loop/drive.d.ts.map +1 -1
  40. package/dist/loop/drive.js +17 -7
  41. package/dist/loop/drive.js.map +1 -1
  42. package/dist/loop/features.d.ts +16 -0
  43. package/dist/loop/features.d.ts.map +1 -1
  44. package/dist/loop/features.js +404 -62
  45. package/dist/loop/features.js.map +1 -1
  46. package/dist/loop/open-host.d.ts +10 -0
  47. package/dist/loop/open-host.d.ts.map +1 -1
  48. package/dist/loop/open-host.js +111 -9
  49. package/dist/loop/open-host.js.map +1 -1
  50. package/dist/loop/report.d.ts.map +1 -1
  51. package/dist/loop/report.js +21 -9
  52. package/dist/loop/report.js.map +1 -1
  53. package/dist/loop/streams.d.ts +3 -0
  54. package/dist/loop/streams.d.ts.map +1 -1
  55. package/dist/loop/streams.js +2 -1
  56. package/dist/loop/streams.js.map +1 -1
  57. package/dist/loop/tick.d.ts +3 -0
  58. package/dist/loop/tick.d.ts.map +1 -1
  59. package/dist/loop/tick.js.map +1 -1
  60. package/dist/loop/trace.d.ts +1 -0
  61. package/dist/loop/trace.d.ts.map +1 -1
  62. package/dist/loop/trace.js +2 -2
  63. package/dist/loop/trace.js.map +1 -1
  64. package/dist/loop/transformers.d.ts +16 -2
  65. package/dist/loop/transformers.d.ts.map +1 -1
  66. package/dist/loop/transformers.js +126 -22
  67. package/dist/loop/transformers.js.map +1 -1
  68. package/dist/loop/warm.js +1 -1
  69. package/dist/loop/warm.js.map +1 -1
  70. package/dist/operator/agent-plugins.d.ts.map +1 -1
  71. package/dist/operator/agent-plugins.js +14 -8
  72. package/dist/operator/agent-plugins.js.map +1 -1
  73. package/dist/operator/board.d.ts +43 -4
  74. package/dist/operator/board.d.ts.map +1 -1
  75. package/dist/operator/board.js +153 -10
  76. package/dist/operator/board.js.map +1 -1
  77. package/dist/operator/doctor.d.ts +1 -1
  78. package/dist/operator/doctor.d.ts.map +1 -1
  79. package/dist/operator/doctor.js +76 -29
  80. package/dist/operator/doctor.js.map +1 -1
  81. package/dist/operator/explain.d.ts.map +1 -1
  82. package/dist/operator/explain.js +36 -12
  83. package/dist/operator/explain.js.map +1 -1
  84. package/dist/operator/film.js +0 -3
  85. package/dist/operator/film.js.map +1 -1
  86. package/dist/operator/incident-dossier.d.ts +154 -0
  87. package/dist/operator/incident-dossier.d.ts.map +1 -0
  88. package/dist/operator/incident-dossier.js +696 -0
  89. package/dist/operator/incident-dossier.js.map +1 -0
  90. package/dist/operator/incident-system.d.ts +48 -0
  91. package/dist/operator/incident-system.d.ts.map +1 -0
  92. package/dist/operator/incident-system.js +152 -0
  93. package/dist/operator/incident-system.js.map +1 -0
  94. package/dist/operator/incident.d.ts +27 -0
  95. package/dist/operator/incident.d.ts.map +1 -0
  96. package/dist/operator/incident.js +220 -0
  97. package/dist/operator/incident.js.map +1 -0
  98. package/dist/operator/init.js +27 -22
  99. package/dist/operator/init.js.map +1 -1
  100. package/dist/operator/live.js +4 -4
  101. package/dist/operator/live.js.map +1 -1
  102. package/dist/operator/log.d.ts +2 -0
  103. package/dist/operator/log.d.ts.map +1 -1
  104. package/dist/operator/log.js +7 -3
  105. package/dist/operator/log.js.map +1 -1
  106. package/dist/operator/restart.d.ts +20 -0
  107. package/dist/operator/restart.d.ts.map +1 -0
  108. package/dist/operator/restart.js +63 -0
  109. package/dist/operator/restart.js.map +1 -0
  110. package/dist/operator/setup.d.ts.map +1 -1
  111. package/dist/operator/setup.js +2 -0
  112. package/dist/operator/setup.js.map +1 -1
  113. package/package.json +14 -14
@@ -1,4 +1,9 @@
1
- import { refreshWorkLine } from "./authority.js";
1
+ import { existsSync } from "node:fs";
2
+ import { rm } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { pathSegment } from "@bluewombat/conductor";
5
+ import { CLI_NAME, } from "@bluewombat/manager-kit";
6
+ import { publish, refreshWorkLine } from "./authority.js";
2
7
  import { cancelTicket, handleCancel as cancelTicketByKey, handleResume as resumeTicketByKey, } from "./deliveries.js";
3
8
  import { driveUntilBlocked } from "./drive.js";
4
9
  import { pushReport } from "./report.js";
@@ -69,6 +74,23 @@ async function admitFeature(input, feature) {
69
74
  // about work already made: nothing here restarts it.
70
75
  const before = await input.ledger.getFeature(feature.key);
71
76
  if (before.ok && before.aggregate.state !== "waiting") {
77
+ // The label that leaves the feature-test check out is the one thing a human can still change: it is
78
+ // honoured until the Feature is submitted, and read again by the check each time it runs.
79
+ if (before.aggregate.intention.skip_feature_test !== feature.skip_feature_test) {
80
+ const moved = await input.ledger.setFeatureSkipFeatureTest({
81
+ key: feature.key,
82
+ skip: feature.skip_feature_test,
83
+ });
84
+ if (moved.ok) {
85
+ input.journal.append({
86
+ event: "skip-feature-test",
87
+ key: feature.key,
88
+ skip: feature.skip_feature_test,
89
+ });
90
+ input.trace(`feature ${feature.key} ${feature.skip_feature_test ? "now" : "no longer"} skips the feature-test check`);
91
+ return;
92
+ }
93
+ }
72
94
  input.journal.append({ event: "skipped", key: feature.key, state: before.aggregate.state });
73
95
  return;
74
96
  }
@@ -82,6 +104,7 @@ async function admitFeature(input, feature) {
82
104
  external_id: feature.external_id,
83
105
  blocked_by: feature.blocked_by,
84
106
  merge_when_ready: feature.merge_when_ready,
107
+ ...(feature.skip_feature_test ? { skip_feature_test: true } : {}),
85
108
  ...(feature.title === undefined ? {} : { title: feature.title }),
86
109
  ...(feature.source === undefined ? {} : { source: feature.source }),
87
110
  };
@@ -131,6 +154,9 @@ export async function cancelFeatureFlow(input, key) {
131
154
  if (feature.line !== undefined) {
132
155
  await input.tickets.dropLine?.(feature.line);
133
156
  }
157
+ if (input.featureDelivery !== undefined) {
158
+ await rm(featureWorkspace(input.featureDelivery, key), { recursive: true, force: true });
159
+ }
134
160
  const cancelled = await input.ledger.cancelFeature(key);
135
161
  if (!cancelled.ok) {
136
162
  return;
@@ -174,6 +200,10 @@ export async function advanceProject(input, project) {
174
200
  return undefined;
175
201
  }
176
202
  }
203
+ /** What the tracker says of a Ticket beside its words. */
204
+ function marksOf(entry) {
205
+ return { featureTest: entry.featureTest === true };
206
+ }
177
207
  function refOf(feature) {
178
208
  const { intention } = feature;
179
209
  return {
@@ -188,7 +218,7 @@ function lineOf(feature) {
188
218
  const id = feature.intention.external_id ?? feature.intention.key;
189
219
  return `feature/${id.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "")}`;
190
220
  }
191
- function admissionOf(ticket, feature, among) {
221
+ function admissionOf(ticket, feature, among, marks) {
192
222
  return {
193
223
  key: ticket.key,
194
224
  project: ticket.project,
@@ -202,8 +232,47 @@ function admissionOf(ticket, feature, among) {
202
232
  ...(feature.line === undefined ? {} : { line: feature.line }),
203
233
  ...(ticket.title === undefined ? {} : { title: ticket.title }),
204
234
  ...(ticket.source === undefined ? {} : { source: ticket.source }),
235
+ ...(marks.featureTest ? { featureTest: true } : {}),
205
236
  };
206
237
  }
238
+ /**
239
+ * What is wrong with a Feature of a Project that proves its Features by a feature-test suite: it holds
240
+ * no feature-test Ticket and is not marked to skip the check, holds several, or its feature-test Ticket may start
241
+ * before the others have landed. Nothing: a Project without the check, or a Feature that skips it.
242
+ */
243
+ function featureTestRefusal(input, feature, live) {
244
+ if (input.options.builder.featureTest === undefined ||
245
+ feature.intention.skip_feature_test === true) {
246
+ return undefined;
247
+ }
248
+ const holding = live.filter((entry) => entry.featureTest);
249
+ const head = "This Project proves each Feature by a feature tests, run once on the whole Feature.";
250
+ if (holding.length === 0) {
251
+ return `${head} The Feature has no feature-test Ticket and is not marked skip-feature-test: add the feature-test Ticket (the scenarios agreed with the operator, blocked by every other Ticket), or mark the Feature to skip the check.`;
252
+ }
253
+ if (holding.length > 1) {
254
+ return `${head} The Feature holds ${holding.length} feature-test Tickets: it holds one, with all its scenarios.`;
255
+ }
256
+ const only = holding[0];
257
+ const others = live.filter((entry) => !entry.featureTest).map((entry) => entry.ticket.key);
258
+ const missing = others.filter((key) => !only?.ticket.blocked_by.includes(key));
259
+ if (only !== undefined && missing.length > 0) {
260
+ return `${head} The feature-test Ticket ${only.ticket.key} must be blocked by every other Ticket of the Feature, so that it lands last. It is not blocked by: ${missing.join(", ")}.`;
261
+ }
262
+ return undefined;
263
+ }
264
+ /** Whether a Feature still holds a feature-test Ticket that was not cancelled. */
265
+ async function holdsLiveFeatureTestTicket(input, feature) {
266
+ for (const key of feature.tickets) {
267
+ const got = await input.ledger.get(key);
268
+ if (got.ok &&
269
+ got.aggregate.intention.featureTest === true &&
270
+ got.aggregate.state !== "cancelled") {
271
+ return true;
272
+ }
273
+ }
274
+ return false;
275
+ }
207
276
  /**
208
277
  * Open the next Feature that may: waiting, nothing blocking it, and every one
209
278
  * of its Tickets written. Until then it waits and nothing is made.
@@ -232,6 +301,18 @@ async function openNextFeature(input, project) {
232
301
  });
233
302
  return;
234
303
  }
304
+ const refusal = featureTestRefusal(input, feature, live);
305
+ if (refusal !== undefined) {
306
+ input.journal.append({ event: "invalid", key: feature.intention.key, reason: refusal });
307
+ await pushReport(input, {
308
+ event: "invalid",
309
+ key: feature.intention.key,
310
+ project,
311
+ eventId: `${feature.intention.key}:invalid:feature-test:${refusal}`,
312
+ fields: { reason: refusal },
313
+ }, TO);
314
+ return;
315
+ }
235
316
  const target = input.workLine.target;
236
317
  let line;
237
318
  if (input.authority !== undefined &&
@@ -269,7 +350,7 @@ async function openNextFeature(input, project) {
269
350
  }
270
351
  const withLine = { ...feature, ...(line === undefined ? {} : { line }) };
271
352
  for (const entry of live) {
272
- await input.ledger.admit(admissionOf(entry.ticket, withLine, among));
353
+ await input.ledger.admit(admissionOf(entry.ticket, withLine, among, marksOf(entry)));
273
354
  }
274
355
  input.journal.append({
275
356
  event: "feature-opened",
@@ -306,6 +387,9 @@ async function refreshOnto(input, branch) {
306
387
  const refreshed = await refreshWorkLine({
307
388
  workLineStable: input.workLine.stable,
308
389
  workLineTarget: branch,
390
+ // A Feature's line that has not moved is brought up to the final branch
391
+ // first, so its Tickets read the code as it now is.
392
+ workLineBase: branch === input.workLine.target ? undefined : input.workLine.target,
309
393
  refreshArgv: input.options.authority?.refreshArgv ?? [],
310
394
  timeoutMs: input.options.timeoutMs,
311
395
  env: input.workLine.env,
@@ -356,8 +440,25 @@ async function syncTicket(input, feature, entry, known) {
356
440
  }
357
441
  // Only a Ticket that has not started takes the new words: the tracker's
358
442
  // fingerprint moves with every comment, and a unit in flight is not rewritten.
359
- if (state === "received" && current.aggregate.intention.fingerprint !== ticket.fingerprint) {
360
- await input.ledger.admit(admissionOf(ticket, feature, known));
443
+ // The label that marks the feature-test Ticket is not in the words, so it moves no
444
+ // fingerprint: it is compared on its own.
445
+ const marks = marksOf(entry);
446
+ const holdsFeatureTest = current.aggregate.intention.featureTest === true;
447
+ if (state === "received" &&
448
+ (current.aggregate.intention.fingerprint !== ticket.fingerprint ||
449
+ holdsFeatureTest !== marks.featureTest)) {
450
+ await input.ledger.admit(admissionOf(ticket, feature, known, marks));
451
+ }
452
+ // A Feature that is not put through the feature-test check has no use of the Ticket that holds it.
453
+ if (feature.intention.skip_feature_test === true && marks.featureTest && LIVE.has(state)) {
454
+ input.journal.append({
455
+ event: "feature-test-ticket-cancelled",
456
+ key: ticket.key,
457
+ feature: feature.intention.key,
458
+ });
459
+ input.trace(`feature ${feature.intention.key} skips the feature-test check: ticket ${ticket.key} is cancelled`);
460
+ await cancelTicketByKey({ key: ticket.key, project: ticket.project }, input);
461
+ return;
361
462
  }
362
463
  if (entry.resume && state === "escalated") {
363
464
  await resumeTicketByKey({ key: ticket.key, project: ticket.project }, input);
@@ -366,31 +467,21 @@ async function syncTicket(input, feature, entry, known) {
366
467
  async function advanceOpenFeature(input, feature) {
367
468
  const project = feature.intention.project;
368
469
  await syncTickets(input, feature);
369
- const held = await input.ledger.activeOn(project);
370
- if (held?.state === "escalated") {
470
+ const limit = input.options.ticket.maxParallel ?? 1;
471
+ // A Ticket waiting for a person holds its place. When every place is held
472
+ // that way, nothing can move until someone answers.
473
+ const holding = await input.ledger.activeTickets(project);
474
+ if (holding.length >= limit && holding.every((summary) => summary.state === "escalated")) {
371
475
  return undefined;
372
476
  }
373
- const refreshed = await refreshOnto(input, feature.line ?? input.workLine.target);
374
- if (!refreshed) {
477
+ // Nothing is taken if the line cannot be reached. Conductor refreshes it again
478
+ // for each Ticket it cuts and aligns, which is what keeps a Ticket current
479
+ // while others land; this is the check that starts nothing at all.
480
+ if (!(await refreshOnto(input, feature.line ?? input.workLine.target))) {
375
481
  return undefined;
376
482
  }
377
- let tickets = await ticketsOf(input, feature);
378
- let run;
379
- let pending = tickets.find((summary) => LIVE.has(summary.state));
380
- // A Ticket that landed moved the line: the next one is cut from where it is
381
- // now, in this same pass, not a poll later.
382
- while (pending !== undefined) {
383
- run = await driveUntilBlocked(input, project, pending.key);
384
- tickets = await ticketsOf(input, feature);
385
- pending = tickets.find((summary) => LIVE.has(summary.state));
386
- const interrupted = input.options.interruptFlag?.interrupted === true;
387
- if (run.outcome !== "done" ||
388
- interrupted ||
389
- pending === undefined ||
390
- !(await refreshOnto(input, feature.line ?? input.workLine.target))) {
391
- break;
392
- }
393
- }
483
+ const run = await driveTickets(input, feature, project, limit);
484
+ const tickets = await ticketsOf(input, feature);
394
485
  await reportLanded(input, feature, tickets);
395
486
  const settled = tickets.every((summary) => summary.state === "done" || summary.state === "cancelled");
396
487
  if (settled && tickets.length > 0) {
@@ -402,6 +493,53 @@ async function advanceOpenFeature(input, feature) {
402
493
  }
403
494
  return run;
404
495
  }
496
+ /** Where a Ticket is while Host has something to drive it through. */
497
+ const DRIVEN = new Set([
498
+ "running",
499
+ "integrating",
500
+ "submitted",
501
+ "merging",
502
+ ]);
503
+ /**
504
+ * Work the Tickets of an open Feature, up to `limit` at a time.
505
+ *
506
+ * Each place is a worker: it drives a Ticket the Project already holds, else
507
+ * takes the next one that may start, and goes on to another when its own is
508
+ * done or is only waiting for the outside. A Ticket waiting on a check holds a
509
+ * place but not a worker, so the others do not wait with it. With a limit of 1
510
+ * this is one Ticket after the other.
511
+ */
512
+ async function driveTickets(input, feature, project, limit) {
513
+ // Once, before anything runs at the same time: it clears what a dead process
514
+ // left, which must not be done under a Ticket that is being driven.
515
+ await input.conductor.reconcile(project);
516
+ const interrupted = () => input.options.interruptFlag?.interrupted === true;
517
+ const queue = (await ticketsOf(input, feature))
518
+ .filter((summary) => DRIVEN.has(summary.state))
519
+ .map((summary) => summary.key);
520
+ let last;
521
+ const worker = async () => {
522
+ while (!interrupted()) {
523
+ const key = queue.shift() ?? (await input.conductor.claimNext(project))?.key;
524
+ if (key === undefined) {
525
+ return;
526
+ }
527
+ const run = await driveUntilBlocked(input, project, key, true);
528
+ last = run;
529
+ // A refusal is about the run, not the Ticket: the next one would meet it too.
530
+ if (run.outcome === "refused") {
531
+ return;
532
+ }
533
+ }
534
+ };
535
+ await Promise.all(Array.from({ length: Math.max(1, limit) }, worker));
536
+ if (last === undefined && (await input.ledger.activeTickets(project)).length === 0) {
537
+ const waiting = (await ticketsOf(input, feature)).some((s) => s.state === "received");
538
+ // Nothing held and nothing could start: what remains waits on what cannot finish.
539
+ return waiting ? { outcome: "idle" } : undefined;
540
+ }
541
+ return last;
542
+ }
405
543
  /** One `progress` per Ticket that landed, in the words the tracker already has. */
406
544
  async function reportLanded(input, feature, tickets) {
407
545
  const total = feature.tickets.length;
@@ -418,8 +556,15 @@ async function reportLanded(input, feature, tickets) {
418
556
  }, TO);
419
557
  }
420
558
  }
421
- async function escalateFeature(input, feature, report) {
422
- const escalated = await input.ledger.escalateFeature({ key: feature.intention.key, report });
559
+ async function escalateFeature(input, feature, report,
560
+ /** Where the line stopped and who refused it, when the caller knows. */
561
+ origin = undefined) {
562
+ const escalated = await input.ledger.escalateFeature({
563
+ key: feature.intention.key,
564
+ report,
565
+ ...(origin === undefined ? {} : { kind: origin.kind }),
566
+ ...(origin?.refusedBy === undefined ? {} : { refusedBy: origin.refusedBy }),
567
+ });
423
568
  if (!escalated.ok) {
424
569
  return;
425
570
  }
@@ -431,12 +576,87 @@ async function escalateFeature(input, feature, report) {
431
576
  key: feature.intention.key,
432
577
  project: feature.intention.project,
433
578
  eventId: `${feature.intention.key}:escalated:${round}`,
434
- fields: { reason: report, stage: "submitting" },
579
+ // The assembled line refused by a check is not a Submission refused: say which it was.
580
+ fields: { reason: report, stage: origin?.kind === "assembly" ? "integrating" : "submitting" },
435
581
  }, TO);
436
582
  }
583
+ /** Where a Feature's own work is done: apart from every Ticket's workspace, which Host clears of strays. */
584
+ function featureWorkspace(delivery, key) {
585
+ return join(delivery.workspaces, pathSegment(key));
586
+ }
587
+ /** Where the Feature's work is, and where it goes: the Feature stands where a Ticket stood, the final line where the Feature's was. */
588
+ function featureUnit(input, delivery, feature) {
589
+ return {
590
+ key: feature.intention.key,
591
+ intention: feature.intention.intention,
592
+ title: feature.intention.title,
593
+ noun: "feature",
594
+ workspace: featureWorkspace(delivery, feature.intention.key),
595
+ // The Gates read the published branch, and the copy of the Feature's line is on it.
596
+ judgeWorkspace: input.workLine.stable,
597
+ target: input.workLine.target ?? "",
598
+ line: delivery.targetStable,
599
+ };
600
+ }
601
+ /** What a Feature's delivery reads of it, and how the Feature keeps what the delivery decides. */
602
+ function featureStore(input, feature) {
603
+ const key = feature.intention.key;
604
+ const failed = (result) => result.ok ? undefined : { outcome: "refused", code: "illegal-transition" };
605
+ return {
606
+ async read() {
607
+ const got = await input.ledger.getFeature(key);
608
+ if (!got.ok) {
609
+ return undefined;
610
+ }
611
+ const aggregate = got.aggregate;
612
+ const parked = aggregate.parked_refusal;
613
+ const report = parked?.report ?? aggregate.submission?.last_report;
614
+ const refusedBy = parked?.refused_by ?? aggregate.submission?.last_refused_by;
615
+ return {
616
+ sentBack: report === undefined ? undefined : { report, refusedBy },
617
+ spent: (aggregate.submission?.refusals ?? 0) +
618
+ (aggregate.parked_refusals ?? 0) +
619
+ (aggregate.submission?.realigns ?? 0),
620
+ submission: aggregate.submission === undefined
621
+ ? undefined
622
+ : { reference: aggregate.submission.reference },
623
+ };
624
+ },
625
+ async freeze(frozen) {
626
+ await escalateFeature(input, feature, frozen.report ?? "The line was stopped, with no reason given.", { kind: frozen.kind, refusedBy: frozen.refusedBy });
627
+ return { outcome: "escalated", key };
628
+ },
629
+ async park(why) {
630
+ return failed(await input.ledger.recordFeatureParkedRefusal({
631
+ key,
632
+ report: why.report,
633
+ ...(why.refusedBy === undefined ? {} : { refusedBy: why.refusedBy }),
634
+ }));
635
+ },
636
+ async unpark() {
637
+ return failed(await input.ledger.clearFeatureParkedRefusal(key));
638
+ },
639
+ async refuse(why) {
640
+ return failed(await input.ledger.recordFeatureRefusal({
641
+ key,
642
+ report: why.report,
643
+ ...(why.refusedBy === undefined ? {} : { refusedBy: why.refusedBy }),
644
+ }));
645
+ },
646
+ async realign() {
647
+ return failed(await input.ledger.recordFeatureRealign(key));
648
+ },
649
+ };
650
+ }
437
651
  /**
438
- * Every Ticket has landed: the line goes to the Authority. With none, the work
652
+ * Every Ticket has landed: the line is brought up to date with the one it is
653
+ * folded into, checked, and put in front of the Authority. With none, the work
439
654
  * is already in the work line and the Feature is done.
655
+ *
656
+ * It is delivered the way a Ticket is — see `openDelivery` — with the final line
657
+ * merged into the Feature's work, its own checks run on the result, and a fix
658
+ * when something refuses it. Only where the work is offered differs: the line
659
+ * is pushed under its own name rather than a name made for it.
440
660
  */
441
661
  async function closeFeature(input, feature, tickets) {
442
662
  const key = feature.intention.key;
@@ -445,18 +665,100 @@ async function closeFeature(input, feature, tickets) {
445
665
  await escalateFeature(input, feature, "Every Ticket was abandoned, so nothing was made.");
446
666
  return;
447
667
  }
668
+ if (input.options.builder.featureTest !== undefined &&
669
+ feature.intention.skip_feature_test !== true &&
670
+ !(await holdsLiveFeatureTestTicket(input, feature))) {
671
+ await escalateFeature(input, feature, "This Feature is not marked skip-feature-test and holds no feature-test Ticket: it was cancelled while the Feature skipped the check, and the mark was taken off since. Mark the Feature skip-feature-test again to leave the check out.");
672
+ return;
673
+ }
448
674
  if (input.authority === undefined || feature.line === undefined) {
449
675
  await input.ledger.markFeatureSubmitted({ key, reference: "local" });
450
676
  await input.ledger.markFeatureDone(key);
451
677
  await reportDone(input, feature, undefined, undefined);
452
678
  return;
453
679
  }
454
- if (feature.submission !== undefined) {
455
- await input.ledger.markFeatureSubmitted({ key, reference: feature.submission.reference });
680
+ const delivery = input.featureDelivery;
681
+ const target = input.workLine.target;
682
+ const publishArgv = input.options.authority?.publishArgv;
683
+ if (delivery === undefined || target === undefined || publishArgv === undefined) {
456
684
  return;
457
685
  }
458
- const target = input.workLine.target;
459
- if (target === undefined) {
686
+ const unit = featureUnit(input, delivery, feature);
687
+ const clean = async () => {
688
+ await rm(unit.workspace, { recursive: true, force: true });
689
+ };
690
+ // The Feature's work is cut from its line the way a Ticket's is, once; a
691
+ // pass that sent it to the fix finds it where it was left. A resume is the
692
+ // one time the line may have moved under it, by a person: what it gained is
693
+ // then merged into the work, which keeps what it holds.
694
+ if (existsSync(unit.workspace)) {
695
+ if (feature.workspace_stale === true && !(await takeInLine(input, feature, unit))) {
696
+ return;
697
+ }
698
+ }
699
+ else {
700
+ if (!(await refreshOnto(input, feature.line))) {
701
+ return;
702
+ }
703
+ const isolated = await input.transformers.isolate({
704
+ id: `${key}:close`,
705
+ context: key,
706
+ parent: input.workLine.stable,
707
+ child: unit.workspace,
708
+ durationMs: input.options.timeoutMs,
709
+ });
710
+ if (isolated.outcome !== "isolated") {
711
+ // Nothing was spent: the next pass tries again.
712
+ await clean();
713
+ return;
714
+ }
715
+ // The checks run here, so it is prepared the way a Ticket's workspace is.
716
+ const warmed = await delivery.warm?.prepare({
717
+ workspace: unit.workspace,
718
+ durationMs: delivery.warm.durationMs,
719
+ });
720
+ if (warmed !== undefined && warmed.outcome !== "warmed") {
721
+ await clean();
722
+ return;
723
+ }
724
+ }
725
+ if (feature.workspace_stale === true) {
726
+ await input.ledger.markFeatureWorkspaceCurrent(key);
727
+ }
728
+ const prepared = await delivery.delivery.prepare(unit, featureStore(input, feature));
729
+ if (prepared !== "ready") {
730
+ return;
731
+ }
732
+ // Offer it. What was made in the workspace goes back into the Feature's line
733
+ // and the line is pushed under its own name, which is where the Authority
734
+ // reads it and where its Submission is.
735
+ const folded = await input.transformers.integrate({
736
+ id: `${key}:close`,
737
+ context: key,
738
+ parent: input.workLine.stable,
739
+ child: unit.workspace,
740
+ durationMs: input.options.timeoutMs,
741
+ subject: `Merge ${target} into ${feature.line}`,
742
+ mergeSubject: `Merge ${target} into ${feature.line}`,
743
+ });
744
+ if (folded.outcome === "interrupted") {
745
+ return;
746
+ }
747
+ if (folded.outcome !== "integrated") {
748
+ await escalateFeature(input, feature, "The Feature's work could not be folded into its line.");
749
+ return;
750
+ }
751
+ const published = await publish({
752
+ argv: publishArgv,
753
+ cwd: input.workLine.stable,
754
+ id: key,
755
+ ref: feature.line,
756
+ target,
757
+ timeoutMs: input.options.timeoutMs,
758
+ env: input.workLine.env,
759
+ });
760
+ if (!published.ok) {
761
+ await escalateFeature(input, feature, published.reason);
460
762
  return;
461
763
  }
462
764
  const submitted = await input.authority.submit({
@@ -477,6 +779,7 @@ async function closeFeature(input, feature, tickets) {
477
779
  return;
478
780
  }
479
781
  await input.ledger.markFeatureSubmitted({ key, reference: submitted.reference });
782
+ await clean();
480
783
  await pushReport(input, {
481
784
  event: "submitted",
482
785
  key,
@@ -486,8 +789,44 @@ async function closeFeature(input, feature, tickets) {
486
789
  }, TO);
487
790
  }
488
791
  /**
489
- * The line is with the Authority. If the Feature merges by itself, the checks
490
- * decide; otherwise a human folds it and the system waits, starting nothing.
792
+ * Bring what a resumed Feature's line gained since its work was cut into that
793
+ * work, without discarding what the work holds (the fix's own commits, even
794
+ * refused ones: a person may want them). Answers whether the pass goes on:
795
+ * not when the line could not be reached or the merge was interrupted (the next
796
+ * pass tries again), nor when it conflicts — that is for a person, and the
797
+ * work is left as it was.
798
+ */
799
+ async function takeInLine(input, feature, unit) {
800
+ if (!(await refreshOnto(input, feature.line))) {
801
+ return false;
802
+ }
803
+ const taken = await input.transformers.integrate({
804
+ id: `${unit.key}:resume`,
805
+ context: unit.key,
806
+ parent: unit.workspace,
807
+ child: input.workLine.stable,
808
+ durationMs: input.options.timeoutMs,
809
+ subject: "Work left when the Feature stopped",
810
+ mergeSubject: `Merge ${feature.line} into the feature`,
811
+ });
812
+ switch (taken.outcome) {
813
+ case "integrated":
814
+ return true;
815
+ case "interrupted":
816
+ return false;
817
+ case "conflict":
818
+ await escalateFeature(input, feature, `The line ${feature.line} gained commits since the Feature's work was made, and they conflict with it: both changed the same places. The work was left as it was. Settle it on the line, or run \`${CLI_NAME} restart ${unit.key}\` to make the work again from the line, then resume.`, { kind: "assembly" });
819
+ return false;
820
+ default:
821
+ await escalateFeature(input, feature, `What the line ${feature.line} gained could not be merged into the Feature's work. The work was left as it was.`, { kind: "assembly" });
822
+ return false;
823
+ }
824
+ }
825
+ /**
826
+ * The line is with the Authority. If the Feature merges by itself, its gates
827
+ * decide, and a line the target moved on from goes back to be brought up to
828
+ * date, exactly as a Ticket does; otherwise a human folds it and the system
829
+ * waits, starting nothing.
491
830
  */
492
831
  async function settleFeature(input, feature) {
493
832
  const submission = feature.submission;
@@ -506,36 +845,16 @@ async function settleFeature(input, feature) {
506
845
  }
507
846
  return;
508
847
  }
509
- if (!(await refreshOnto(input, feature.line))) {
510
- return;
511
- }
512
- const judged = await input.transformers.implement({
513
- id: `${key}:judgement`,
514
- context: key,
515
- stage: "assembly",
516
- produce: false,
517
- intention: feature.intention.intention,
518
- workspace: input.workLine.stable,
519
- });
520
- if (judged.outcome === "interrupted" || judged.outcome === "invalid-invocation") {
521
- return;
522
- }
523
- if (judged.outcome === "escalated") {
524
- const last = judged.traces.at(-1);
525
- const why = last?.report ?? "The line was refused, with no reason given.";
526
- await escalateFeature(input, feature, last?.refusedBy === undefined ? why : `${last.refusedBy}: ${why}`);
848
+ const delivery = input.featureDelivery;
849
+ if (delivery === undefined || !(await refreshOnto(input, feature.line))) {
527
850
  return;
528
851
  }
529
- const folded = await input.authority.fold({ reference: submission.reference });
530
- if (folded.outcome === "unavailable") {
852
+ const settled = await delivery.delivery.settle(featureUnit(input, delivery, feature), featureStore(input, feature));
853
+ if (!("folded" in settled)) {
531
854
  return;
532
855
  }
533
- if (folded.outcome === "conflict") {
534
- await escalateFeature(input, feature, folded.reason);
535
- return;
536
- }
537
- await input.ledger.markFeatureDone(key, { reference: folded.reference });
538
- await reportDone(input, feature, submission.reference, folded.reference);
856
+ await input.ledger.markFeatureDone(key, { reference: settled.reference });
857
+ await reportDone(input, feature, submission.reference, settled.reference);
539
858
  }
540
859
  async function reportDone(input, feature, reference, integrationReference) {
541
860
  const key = feature.intention.key;
@@ -553,6 +872,29 @@ async function reportDone(input, feature, reference, integrationReference) {
553
872
  },
554
873
  }, TO);
555
874
  }
875
+ /**
876
+ * Restart an escalated Feature with a fresh workspace: what it holds is removed
877
+ * and the Feature is resumed, so the next pass cuts its work again from the
878
+ * line and judges that. Nothing is said to the tracker and nothing is fetched.
879
+ */
880
+ export async function restartFeature(input, key) {
881
+ const got = await input.ledger.getFeature(key);
882
+ if (!got.ok) {
883
+ return { ok: false, code: "not-found" };
884
+ }
885
+ if (got.aggregate.state !== "escalated") {
886
+ return { ok: false, code: "not-escalated", state: got.aggregate.state };
887
+ }
888
+ if (input.featureDelivery !== undefined) {
889
+ await rm(featureWorkspace(input.featureDelivery, key), { recursive: true, force: true });
890
+ }
891
+ const resumed = await input.ledger.resumeFeature(key);
892
+ if (!resumed.ok) {
893
+ return { ok: false, code: "not-escalated", state: got.aggregate.state };
894
+ }
895
+ input.journal.append({ event: "restarted", key });
896
+ return { ok: true };
897
+ }
556
898
  /** Abandon a Feature or a Ticket by key: whichever the ledger knows. */
557
899
  export async function cancelKey(input, key) {
558
900
  const feature = await input.ledger.getFeature(key);