@bluewombat/runtime 0.10.0 → 0.11.1

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 (160) hide show
  1. package/README.md +202 -94
  2. package/SPECS.md +151 -25
  3. package/dist/cli.js +95 -1
  4. package/dist/cli.js.map +1 -1
  5. package/dist/config/agent-shorthand.d.ts +3 -4
  6. package/dist/config/agent-shorthand.d.ts.map +1 -1
  7. package/dist/config/agent-shorthand.js +3 -4
  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/load-config.d.ts.map +1 -1
  14. package/dist/config/load-config.js +90 -102
  15. package/dist/config/load-config.js.map +1 -1
  16. package/dist/config/parse-args.d.ts.map +1 -1
  17. package/dist/config/parse-args.js +3 -4
  18. package/dist/config/parse-args.js.map +1 -1
  19. package/dist/config/types.d.ts +15 -16
  20. package/dist/config/types.d.ts.map +1 -1
  21. package/dist/config/types.js +1 -1
  22. package/dist/config/types.js.map +1 -1
  23. package/dist/index.d.ts +3 -1
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +1 -0
  26. package/dist/index.js.map +1 -1
  27. package/dist/loop/authority.d.ts +2 -0
  28. package/dist/loop/authority.d.ts.map +1 -1
  29. package/dist/loop/authority.js +11 -1
  30. package/dist/loop/authority.js.map +1 -1
  31. package/dist/loop/deliveries.d.ts.map +1 -1
  32. package/dist/loop/deliveries.js +19 -0
  33. package/dist/loop/deliveries.js.map +1 -1
  34. package/dist/loop/features.d.ts +30 -0
  35. package/dist/loop/features.d.ts.map +1 -1
  36. package/dist/loop/features.js +195 -73
  37. package/dist/loop/features.js.map +1 -1
  38. package/dist/loop/journal.d.ts +7 -0
  39. package/dist/loop/journal.d.ts.map +1 -1
  40. package/dist/loop/journal.js +23 -1
  41. package/dist/loop/journal.js.map +1 -1
  42. package/dist/loop/lock.d.ts +2 -0
  43. package/dist/loop/lock.d.ts.map +1 -1
  44. package/dist/loop/lock.js +10 -0
  45. package/dist/loop/lock.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 +8 -8
  49. package/dist/loop/open-host.js.map +1 -1
  50. package/dist/loop/run-slot.d.ts +14 -0
  51. package/dist/loop/run-slot.d.ts.map +1 -1
  52. package/dist/loop/run-slot.js +53 -5
  53. package/dist/loop/run-slot.js.map +1 -1
  54. package/dist/loop/streams.d.ts +3 -0
  55. package/dist/loop/streams.d.ts.map +1 -1
  56. package/dist/loop/streams.js +4 -3
  57. package/dist/loop/streams.js.map +1 -1
  58. package/dist/loop/tick.d.ts +3 -0
  59. package/dist/loop/tick.d.ts.map +1 -1
  60. package/dist/loop/tick.js +2 -1
  61. package/dist/loop/tick.js.map +1 -1
  62. package/dist/loop/trace.d.ts +1 -0
  63. package/dist/loop/trace.d.ts.map +1 -1
  64. package/dist/loop/trace.js +1 -1
  65. package/dist/loop/trace.js.map +1 -1
  66. package/dist/loop/transformers.d.ts +4 -4
  67. package/dist/loop/transformers.d.ts.map +1 -1
  68. package/dist/loop/transformers.js +16 -25
  69. package/dist/loop/transformers.js.map +1 -1
  70. package/dist/operator/board.d.ts +43 -4
  71. package/dist/operator/board.d.ts.map +1 -1
  72. package/dist/operator/board.js +153 -10
  73. package/dist/operator/board.js.map +1 -1
  74. package/dist/operator/dash-model.d.ts +65 -0
  75. package/dist/operator/dash-model.d.ts.map +1 -0
  76. package/dist/operator/dash-model.js +209 -0
  77. package/dist/operator/dash-model.js.map +1 -0
  78. package/dist/operator/dash-view.d.ts +16 -0
  79. package/dist/operator/dash-view.d.ts.map +1 -0
  80. package/dist/operator/dash-view.js +226 -0
  81. package/dist/operator/dash-view.js.map +1 -0
  82. package/dist/operator/dash.d.ts +34 -0
  83. package/dist/operator/dash.d.ts.map +1 -0
  84. package/dist/operator/dash.js +141 -0
  85. package/dist/operator/dash.js.map +1 -0
  86. package/dist/operator/doctor.d.ts.map +1 -1
  87. package/dist/operator/doctor.js +48 -8
  88. package/dist/operator/doctor.js.map +1 -1
  89. package/dist/operator/explain.d.ts.map +1 -1
  90. package/dist/operator/explain.js +6 -11
  91. package/dist/operator/explain.js.map +1 -1
  92. package/dist/operator/incident-dossier.d.ts +154 -0
  93. package/dist/operator/incident-dossier.d.ts.map +1 -0
  94. package/dist/operator/incident-dossier.js +692 -0
  95. package/dist/operator/incident-dossier.js.map +1 -0
  96. package/dist/operator/incident-system.d.ts +57 -0
  97. package/dist/operator/incident-system.d.ts.map +1 -0
  98. package/dist/operator/incident-system.js +214 -0
  99. package/dist/operator/incident-system.js.map +1 -0
  100. package/dist/operator/incident.d.ts +27 -0
  101. package/dist/operator/incident.d.ts.map +1 -0
  102. package/dist/operator/incident.js +220 -0
  103. package/dist/operator/incident.js.map +1 -0
  104. package/dist/operator/init.js +24 -24
  105. package/dist/operator/init.js.map +1 -1
  106. package/dist/operator/install.d.ts +9 -0
  107. package/dist/operator/install.d.ts.map +1 -0
  108. package/dist/operator/install.js +14 -0
  109. package/dist/operator/install.js.map +1 -0
  110. package/dist/operator/live.js +4 -4
  111. package/dist/operator/live.js.map +1 -1
  112. package/dist/operator/log.d.ts +2 -0
  113. package/dist/operator/log.d.ts.map +1 -1
  114. package/dist/operator/log.js +2 -5
  115. package/dist/operator/log.js.map +1 -1
  116. package/dist/operator/restart.d.ts +20 -0
  117. package/dist/operator/restart.d.ts.map +1 -0
  118. package/dist/operator/restart.js +63 -0
  119. package/dist/operator/restart.js.map +1 -0
  120. package/dist/operator/supervise/act.d.ts +37 -0
  121. package/dist/operator/supervise/act.d.ts.map +1 -0
  122. package/dist/operator/supervise/act.js +354 -0
  123. package/dist/operator/supervise/act.js.map +1 -0
  124. package/dist/operator/supervise/config.d.ts +74 -0
  125. package/dist/operator/supervise/config.d.ts.map +1 -0
  126. package/dist/operator/supervise/config.js +175 -0
  127. package/dist/operator/supervise/config.js.map +1 -0
  128. package/dist/operator/supervise/council.d.ts +45 -0
  129. package/dist/operator/supervise/council.d.ts.map +1 -0
  130. package/dist/operator/supervise/council.js +97 -0
  131. package/dist/operator/supervise/council.js.map +1 -0
  132. package/dist/operator/supervise/decision.d.ts +46 -0
  133. package/dist/operator/supervise/decision.d.ts.map +1 -0
  134. package/dist/operator/supervise/decision.js +137 -0
  135. package/dist/operator/supervise/decision.js.map +1 -0
  136. package/dist/operator/supervise/exec.d.ts +28 -0
  137. package/dist/operator/supervise/exec.d.ts.map +1 -0
  138. package/dist/operator/supervise/exec.js +47 -0
  139. package/dist/operator/supervise/exec.js.map +1 -0
  140. package/dist/operator/supervise/memory.d.ts +33 -0
  141. package/dist/operator/supervise/memory.d.ts.map +1 -0
  142. package/dist/operator/supervise/memory.js +73 -0
  143. package/dist/operator/supervise/memory.js.map +1 -0
  144. package/dist/operator/supervise/observe.d.ts +47 -0
  145. package/dist/operator/supervise/observe.d.ts.map +1 -0
  146. package/dist/operator/supervise/observe.js +73 -0
  147. package/dist/operator/supervise/observe.js.map +1 -0
  148. package/dist/operator/supervise/prompts.d.ts +60 -0
  149. package/dist/operator/supervise/prompts.d.ts.map +1 -0
  150. package/dist/operator/supervise/prompts.js +338 -0
  151. package/dist/operator/supervise/prompts.js.map +1 -0
  152. package/dist/operator/supervise/review.d.ts +24 -0
  153. package/dist/operator/supervise/review.d.ts.map +1 -0
  154. package/dist/operator/supervise/review.js +51 -0
  155. package/dist/operator/supervise/review.js.map +1 -0
  156. package/dist/operator/supervise/supervise.d.ts +26 -0
  157. package/dist/operator/supervise/supervise.d.ts.map +1 -0
  158. package/dist/operator/supervise/supervise.js +484 -0
  159. package/dist/operator/supervise/supervise.js.map +1 -0
  160. package/package.json +14 -14
@@ -2,9 +2,11 @@ import { existsSync } from "node:fs";
2
2
  import { rm } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { pathSegment } from "@bluewombat/conductor";
5
+ import { CLI_NAME, } from "@bluewombat/manager-kit";
5
6
  import { publish, refreshWorkLine } from "./authority.js";
6
7
  import { cancelTicket, handleCancel as cancelTicketByKey, handleResume as resumeTicketByKey, } from "./deliveries.js";
7
8
  import { driveUntilBlocked } from "./drive.js";
9
+ import { passReportsOf } from "./journal.js";
8
10
  import { pushReport } from "./report.js";
9
11
  /** Where a Ticket is while it still counts against its Feature. */
10
12
  const LIVE = new Set([
@@ -73,16 +75,20 @@ async function admitFeature(input, feature) {
73
75
  // about work already made: nothing here restarts it.
74
76
  const before = await input.ledger.getFeature(feature.key);
75
77
  if (before.ok && before.aggregate.state !== "waiting") {
76
- // The label that leaves the e2e check out is the one thing a human can still change: it is
78
+ // The label that leaves the feature-test check out is the one thing a human can still change: it is
77
79
  // honoured until the Feature is submitted, and read again by the check each time it runs.
78
- if (before.aggregate.intention.skip_e2e !== feature.skip_e2e) {
79
- const moved = await input.ledger.setFeatureSkipE2e({
80
+ if (before.aggregate.intention.skip_feature_test !== feature.skip_feature_test) {
81
+ const moved = await input.ledger.setFeatureSkipFeatureTest({
80
82
  key: feature.key,
81
- skip: feature.skip_e2e,
83
+ skip: feature.skip_feature_test,
82
84
  });
83
85
  if (moved.ok) {
84
- input.journal.append({ event: "skip-e2e", key: feature.key, skip: feature.skip_e2e });
85
- input.trace(`feature ${feature.key} ${feature.skip_e2e ? "now" : "no longer"} skips the e2e check`);
86
+ input.journal.append({
87
+ event: "skip-feature-test",
88
+ key: feature.key,
89
+ skip: feature.skip_feature_test,
90
+ });
91
+ input.trace(`feature ${feature.key} ${feature.skip_feature_test ? "now" : "no longer"} skips the feature-test check`);
86
92
  return;
87
93
  }
88
94
  }
@@ -99,7 +105,7 @@ async function admitFeature(input, feature) {
99
105
  external_id: feature.external_id,
100
106
  blocked_by: feature.blocked_by,
101
107
  merge_when_ready: feature.merge_when_ready,
102
- ...(feature.skip_e2e ? { skip_e2e: true } : {}),
108
+ ...(feature.skip_feature_test ? { skip_feature_test: true } : {}),
103
109
  ...(feature.title === undefined ? {} : { title: feature.title }),
104
110
  ...(feature.source === undefined ? {} : { source: feature.source }),
105
111
  };
@@ -197,7 +203,7 @@ export async function advanceProject(input, project) {
197
203
  }
198
204
  /** What the tracker says of a Ticket beside its words. */
199
205
  function marksOf(entry) {
200
- return { noTests: entry.no_tests, e2e: entry.e2e === true };
206
+ return { featureTest: entry.featureTest === true };
201
207
  }
202
208
  function refOf(feature) {
203
209
  const { intention } = feature;
@@ -227,41 +233,42 @@ function admissionOf(ticket, feature, among, marks) {
227
233
  ...(feature.line === undefined ? {} : { line: feature.line }),
228
234
  ...(ticket.title === undefined ? {} : { title: ticket.title }),
229
235
  ...(ticket.source === undefined ? {} : { source: ticket.source }),
230
- // The Ticket that holds the acceptance suite writes the tests itself: none are written first.
231
- ...(marks.noTests || marks.e2e ? { no_tests: true } : {}),
232
- ...(marks.e2e ? { e2e: true } : {}),
236
+ ...(marks.featureTest ? { featureTest: true } : {}),
233
237
  };
234
238
  }
235
239
  /**
236
- * What is wrong with a Feature of a Project that proves its Features by an e2e suite: it holds
237
- * no e2e Ticket and is not marked to skip the check, holds several, or its e2e Ticket may start
240
+ * What is wrong with a Feature of a Project that proves its Features by a feature-test suite: it holds
241
+ * no feature-test Ticket and is not marked to skip the check, holds several, or its feature-test Ticket may start
238
242
  * before the others have landed. Nothing: a Project without the check, or a Feature that skips it.
239
243
  */
240
- function e2eRefusal(input, feature, live) {
241
- if (input.options.builder.e2e === undefined || feature.intention.skip_e2e === true) {
244
+ function featureTestRefusal(input, feature, live) {
245
+ if (input.options.builder.featureTest === undefined ||
246
+ feature.intention.skip_feature_test === true) {
242
247
  return undefined;
243
248
  }
244
- const holding = live.filter((entry) => entry.e2e);
245
- const head = "This Project proves each Feature by an e2e acceptance suite, run once on the whole Feature.";
249
+ const holding = live.filter((entry) => entry.featureTest);
250
+ const head = "This Project proves each Feature by a feature tests, run once on the whole Feature.";
246
251
  if (holding.length === 0) {
247
- return `${head} The Feature has no e2e Ticket and is not marked skip-test-e2e: add the e2e Ticket (the scenarios agreed with the operator, blocked by every other Ticket), or mark the Feature to skip the check.`;
252
+ 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.`;
248
253
  }
249
254
  if (holding.length > 1) {
250
- return `${head} The Feature holds ${holding.length} e2e Tickets: it holds one, with all its scenarios.`;
255
+ return `${head} The Feature holds ${holding.length} feature-test Tickets: it holds one, with all its scenarios.`;
251
256
  }
252
257
  const only = holding[0];
253
- const others = live.filter((entry) => !entry.e2e).map((entry) => entry.ticket.key);
258
+ const others = live.filter((entry) => !entry.featureTest).map((entry) => entry.ticket.key);
254
259
  const missing = others.filter((key) => !only?.ticket.blocked_by.includes(key));
255
260
  if (only !== undefined && missing.length > 0) {
256
- return `${head} The e2e 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
+ 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(", ")}.`;
257
262
  }
258
263
  return undefined;
259
264
  }
260
- /** Whether a Feature still holds an e2e Ticket that was not cancelled. */
261
- async function holdsLiveE2eTicket(input, feature) {
265
+ /** Whether a Feature still holds a feature-test Ticket that was not cancelled. */
266
+ async function holdsLiveFeatureTestTicket(input, feature) {
262
267
  for (const key of feature.tickets) {
263
268
  const got = await input.ledger.get(key);
264
- if (got.ok && got.aggregate.intention.e2e === true && got.aggregate.state !== "cancelled") {
269
+ if (got.ok &&
270
+ got.aggregate.intention.featureTest === true &&
271
+ got.aggregate.state !== "cancelled") {
265
272
  return true;
266
273
  }
267
274
  }
@@ -295,14 +302,14 @@ async function openNextFeature(input, project) {
295
302
  });
296
303
  return;
297
304
  }
298
- const refusal = e2eRefusal(input, feature, live);
305
+ const refusal = featureTestRefusal(input, feature, live);
299
306
  if (refusal !== undefined) {
300
307
  input.journal.append({ event: "invalid", key: feature.intention.key, reason: refusal });
301
308
  await pushReport(input, {
302
309
  event: "invalid",
303
310
  key: feature.intention.key,
304
311
  project,
305
- eventId: `${feature.intention.key}:invalid:e2e:${refusal}`,
312
+ eventId: `${feature.intention.key}:invalid:feature-test:${refusal}`,
306
313
  fields: { reason: refusal },
307
314
  }, TO);
308
315
  return;
@@ -434,25 +441,23 @@ async function syncTicket(input, feature, entry, known) {
434
441
  }
435
442
  // Only a Ticket that has not started takes the new words: the tracker's
436
443
  // fingerprint moves with every comment, and a unit in flight is not rewritten.
437
- // The label that says no tests are written first is not in the words, so it moves no
444
+ // The label that marks the feature-test Ticket is not in the words, so it moves no
438
445
  // fingerprint: it is compared on its own.
439
446
  const marks = marksOf(entry);
440
- const flagged = current.aggregate.intention.no_tests === true;
441
- const holdsE2e = current.aggregate.intention.e2e === true;
447
+ const holdsFeatureTest = current.aggregate.intention.featureTest === true;
442
448
  if (state === "received" &&
443
449
  (current.aggregate.intention.fingerprint !== ticket.fingerprint ||
444
- flagged !== (marks.noTests || marks.e2e) ||
445
- holdsE2e !== marks.e2e)) {
450
+ holdsFeatureTest !== marks.featureTest)) {
446
451
  await input.ledger.admit(admissionOf(ticket, feature, known, marks));
447
452
  }
448
- // A Feature that is not put through the e2e check has no use of the Ticket that holds it.
449
- if (feature.intention.skip_e2e === true && marks.e2e && LIVE.has(state)) {
453
+ // A Feature that is not put through the feature-test check has no use of the Ticket that holds it.
454
+ if (feature.intention.skip_feature_test === true && marks.featureTest && LIVE.has(state)) {
450
455
  input.journal.append({
451
- event: "e2e-ticket-cancelled",
456
+ event: "feature-test-ticket-cancelled",
452
457
  key: ticket.key,
453
458
  feature: feature.intention.key,
454
459
  });
455
- input.trace(`feature ${feature.intention.key} skips the e2e check: ticket ${ticket.key} is cancelled`);
460
+ input.trace(`feature ${feature.intention.key} skips the feature-test check: ticket ${ticket.key} is cancelled`);
456
461
  await cancelTicketByKey({ key: ticket.key, project: ticket.project }, input);
457
462
  return;
458
463
  }
@@ -485,7 +490,7 @@ async function advanceOpenFeature(input, feature) {
485
490
  return run;
486
491
  }
487
492
  if (run?.outcome === "idle" && tickets.some((summary) => summary.state === "received")) {
488
- await escalateFeature(input, feature, "No Ticket can start: each waits on a Ticket that is not done.");
493
+ await escalateFeature(input, feature, "No Ticket can start: each waits on a Ticket that is not done.", { kind: "tickets" });
489
494
  }
490
495
  return run;
491
496
  }
@@ -499,13 +504,16 @@ const DRIVEN = new Set([
499
504
  /**
500
505
  * Work the Tickets of an open Feature, up to `limit` at a time.
501
506
  *
502
- * Each place is a worker: it drives a Ticket the Project already holds, else
503
- * takes the next one that may start, and goes on to another when its own is
504
- * done or is only waiting for the outside. A Ticket waiting on a check holds a
505
- * place but not a worker, so the others do not wait with it. With a limit of 1
506
- * this is one Ticket after the other.
507
+ * A Ticket the Project already holds is driven first, then the next one that
508
+ * may start. Whenever a drive ends or a place is free, the places are filled
509
+ * again, so a Ticket that was blocked at the start is taken as soon as what
510
+ * blocked it is done and not at the next pass. A Ticket waiting on a check
511
+ * holds a place but is not driven, so the others do not wait with it. Nothing
512
+ * new is taken once a run is refused, and the call returns when nothing is
513
+ * driven and nothing can start. With a limit of 1 this is one Ticket after the
514
+ * other.
507
515
  */
508
- async function driveTickets(input, feature, project, limit) {
516
+ export async function driveTickets(input, feature, project, limit) {
509
517
  // Once, before anything runs at the same time: it clears what a dead process
510
518
  // left, which must not be done under a Ticket that is being driven.
511
519
  await input.conductor.reconcile(project);
@@ -514,21 +522,35 @@ async function driveTickets(input, feature, project, limit) {
514
522
  .filter((summary) => DRIVEN.has(summary.state))
515
523
  .map((summary) => summary.key);
516
524
  let last;
517
- const worker = async () => {
518
- while (!interrupted()) {
525
+ let refused = false;
526
+ const places = Math.max(1, limit);
527
+ const inFlight = new Set();
528
+ const drive = async (key) => {
529
+ const run = await driveUntilBlocked(input, project, key, true);
530
+ last = run;
531
+ // A refusal is about the run, not the Ticket: the next one would meet it too.
532
+ if (run.outcome === "refused") {
533
+ refused = true;
534
+ }
535
+ };
536
+ // Starts drives until the places are full or nothing can start.
537
+ const fill = async () => {
538
+ while (!interrupted() && !refused && inFlight.size < places) {
519
539
  const key = queue.shift() ?? (await input.conductor.claimNext(project))?.key;
520
540
  if (key === undefined) {
521
541
  return;
522
542
  }
523
- const run = await driveUntilBlocked(input, project, key, true);
524
- last = run;
525
- // A refusal is about the run, not the Ticket: the next one would meet it too.
526
- if (run.outcome === "refused") {
527
- return;
528
- }
543
+ const started = drive(key).finally(() => {
544
+ inFlight.delete(started);
545
+ });
546
+ inFlight.add(started);
529
547
  }
530
548
  };
531
- await Promise.all(Array.from({ length: Math.max(1, limit) }, worker));
549
+ await fill();
550
+ while (inFlight.size > 0) {
551
+ await Promise.race(inFlight);
552
+ await fill();
553
+ }
532
554
  if (last === undefined && (await input.ledger.activeTickets(project)).length === 0) {
533
555
  const waiting = (await ticketsOf(input, feature)).some((s) => s.state === "received");
534
556
  // Nothing held and nothing could start: what remains waits on what cannot finish.
@@ -552,8 +574,20 @@ async function reportLanded(input, feature, tickets) {
552
574
  }, TO);
553
575
  }
554
576
  }
555
- async function escalateFeature(input, feature, report) {
556
- const escalated = await input.ledger.escalateFeature({ key: feature.intention.key, report });
577
+ const STAGE_OF = {
578
+ tickets: "tickets",
579
+ assembly: "integrating",
580
+ submitted: "submitting",
581
+ };
582
+ async function escalateFeature(input, feature, report,
583
+ /** Where the Feature stopped, and who refused it when something named itself. */
584
+ origin) {
585
+ const escalated = await input.ledger.escalateFeature({
586
+ key: feature.intention.key,
587
+ report,
588
+ kind: origin.kind,
589
+ ...(origin.refusedBy === undefined ? {} : { refusedBy: origin.refusedBy }),
590
+ });
557
591
  if (!escalated.ok) {
558
592
  return;
559
593
  }
@@ -565,7 +599,8 @@ async function escalateFeature(input, feature, report) {
565
599
  key: feature.intention.key,
566
600
  project: feature.intention.project,
567
601
  eventId: `${feature.intention.key}:escalated:${round}`,
568
- fields: { reason: report, stage: "submitting" },
602
+ // Each stop says where it happened: Tickets, the assembled line, or the offer.
603
+ fields: { reason: report, stage: STAGE_OF[origin.kind] },
569
604
  }, TO);
570
605
  }
571
606
  /** Where a Feature's own work is done: apart from every Ticket's workspace, which Host clears of strays. */
@@ -605,13 +640,12 @@ function featureStore(input, feature) {
605
640
  spent: (aggregate.submission?.refusals ?? 0) +
606
641
  (aggregate.parked_refusals ?? 0) +
607
642
  (aggregate.submission?.realigns ?? 0),
608
- submission: aggregate.submission === undefined
609
- ? undefined
610
- : { reference: aggregate.submission.reference },
643
+ realigned: aggregate.submission?.realigns ?? 0,
644
+ submission: aggregate.submission === undefined ? undefined : { id: aggregate.submission.id },
611
645
  };
612
646
  },
613
647
  async freeze(frozen) {
614
- await escalateFeature(input, feature, frozen.report ?? "The line was stopped, with no reason given.");
648
+ await escalateFeature(input, feature, frozen.report ?? "The line was stopped, with no reason given.", { kind: frozen.kind, refusedBy: frozen.refusedBy });
615
649
  return { outcome: "escalated", key };
616
650
  },
617
651
  async park(why) {
@@ -650,17 +684,19 @@ async function closeFeature(input, feature, tickets) {
650
684
  const key = feature.intention.key;
651
685
  const project = feature.intention.project;
652
686
  if (!tickets.some((summary) => summary.state === "done")) {
653
- await escalateFeature(input, feature, "Every Ticket was abandoned, so nothing was made.");
687
+ await escalateFeature(input, feature, "Every Ticket was abandoned, so nothing was made.", {
688
+ kind: "tickets",
689
+ });
654
690
  return;
655
691
  }
656
- if (input.options.builder.e2e !== undefined &&
657
- feature.intention.skip_e2e !== true &&
658
- !(await holdsLiveE2eTicket(input, feature))) {
659
- await escalateFeature(input, feature, "This Feature is not marked skip-test-e2e and holds no e2e Ticket: it was cancelled while the Feature skipped the check, and the mark was taken off since. Mark the Feature skip-test-e2e again to leave the check out.");
692
+ if (input.options.builder.featureTest !== undefined &&
693
+ feature.intention.skip_feature_test !== true &&
694
+ !(await holdsLiveFeatureTestTicket(input, feature))) {
695
+ 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.", { kind: "tickets" });
660
696
  return;
661
697
  }
662
698
  if (input.authority === undefined || feature.line === undefined) {
663
- await input.ledger.markFeatureSubmitted({ key, reference: "local" });
699
+ await input.ledger.markFeatureSubmitted({ key, reference: "local", id: "local" });
664
700
  await input.ledger.markFeatureDone(key);
665
701
  await reportDone(input, feature, undefined, undefined);
666
702
  return;
@@ -676,8 +712,15 @@ async function closeFeature(input, feature, tickets) {
676
712
  await rm(unit.workspace, { recursive: true, force: true });
677
713
  };
678
714
  // The Feature's work is cut from its line the way a Ticket's is, once; a
679
- // pass that sent it to the fix finds it where it was left.
680
- if (!existsSync(unit.workspace)) {
715
+ // pass that sent it to the fix finds it where it was left. A resume is the
716
+ // one time the line may have moved under it, by a person: what it gained is
717
+ // then merged into the work, which keeps what it holds.
718
+ if (existsSync(unit.workspace)) {
719
+ if (feature.workspace_stale === true && !(await takeInLine(input, feature, unit))) {
720
+ return;
721
+ }
722
+ }
723
+ else {
681
724
  if (!(await refreshOnto(input, feature.line))) {
682
725
  return;
683
726
  }
@@ -703,6 +746,9 @@ async function closeFeature(input, feature, tickets) {
703
746
  return;
704
747
  }
705
748
  }
749
+ if (feature.workspace_stale === true) {
750
+ await input.ledger.markFeatureWorkspaceCurrent(key);
751
+ }
706
752
  const prepared = await delivery.delivery.prepare(unit, featureStore(input, feature));
707
753
  if (prepared !== "ready") {
708
754
  return;
@@ -723,7 +769,9 @@ async function closeFeature(input, feature, tickets) {
723
769
  return;
724
770
  }
725
771
  if (folded.outcome !== "integrated") {
726
- await escalateFeature(input, feature, "The Feature's work could not be folded into its line.");
772
+ await escalateFeature(input, feature, "The Feature's work could not be folded into its line.", {
773
+ kind: "assembly",
774
+ });
727
775
  return;
728
776
  }
729
777
  const published = await publish({
@@ -732,13 +780,16 @@ async function closeFeature(input, feature, tickets) {
732
780
  id: key,
733
781
  ref: feature.line,
734
782
  target,
783
+ kind: "line",
735
784
  timeoutMs: input.options.timeoutMs,
736
785
  env: input.workLine.env,
737
786
  });
738
787
  if (!published.ok) {
739
- await escalateFeature(input, feature, published.reason);
788
+ await escalateFeature(input, feature, published.reason, { kind: "submitted" });
740
789
  return;
741
790
  }
791
+ // What the Feature's Gates said when they passed it goes with the Submission.
792
+ const checks = passReportsOf(input.options.ledgerRoot, key);
742
793
  const submitted = await input.authority.submit({
743
794
  key,
744
795
  project,
@@ -748,15 +799,20 @@ async function closeFeature(input, feature, tickets) {
748
799
  workspace: input.workLine.stable,
749
800
  ...(feature.intention.title === undefined ? {} : { title: feature.intention.title }),
750
801
  intention: feature.intention.intention,
802
+ ...(checks.length === 0 ? {} : { checks }),
751
803
  });
752
804
  if (submitted.outcome === "unavailable") {
753
805
  return;
754
806
  }
755
807
  if (submitted.outcome === "refused") {
756
- await escalateFeature(input, feature, submitted.reason);
808
+ await escalateFeature(input, feature, submitted.reason, { kind: "submitted" });
757
809
  return;
758
810
  }
759
- await input.ledger.markFeatureSubmitted({ key, reference: submitted.reference });
811
+ await input.ledger.markFeatureSubmitted({
812
+ key,
813
+ reference: submitted.reference,
814
+ id: submitted.id,
815
+ });
760
816
  await clean();
761
817
  await pushReport(input, {
762
818
  event: "submitted",
@@ -766,11 +822,48 @@ async function closeFeature(input, feature, tickets) {
766
822
  fields: { reference: submitted.reference },
767
823
  }, TO);
768
824
  }
825
+ /**
826
+ * Bring what a resumed Feature's line gained since its work was cut into that
827
+ * work, without discarding what the work holds (the fix's own commits, even
828
+ * refused ones: a person may want them). Answers whether the pass goes on:
829
+ * not when the line could not be reached or the merge was interrupted (the next
830
+ * pass tries again), nor when it conflicts — that is for a person, and the
831
+ * work is left as it was.
832
+ */
833
+ async function takeInLine(input, feature, unit) {
834
+ if (!(await refreshOnto(input, feature.line))) {
835
+ return false;
836
+ }
837
+ const taken = await input.transformers.integrate({
838
+ id: `${unit.key}:resume`,
839
+ context: unit.key,
840
+ parent: unit.workspace,
841
+ child: input.workLine.stable,
842
+ durationMs: input.options.timeoutMs,
843
+ subject: "Work left when the Feature stopped",
844
+ mergeSubject: `Merge ${feature.line} into the feature`,
845
+ });
846
+ switch (taken.outcome) {
847
+ case "integrated":
848
+ return true;
849
+ case "interrupted":
850
+ return false;
851
+ case "conflict":
852
+ 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" });
853
+ return false;
854
+ default:
855
+ 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" });
856
+ return false;
857
+ }
858
+ }
859
+ /** How long a Submission's state may stay unreadable before its Feature escalates. */
860
+ const DEFAULT_SETTLED_TIMEOUT_MS = 4 * 60 * 60 * 1000;
769
861
  /**
770
862
  * The line is with the Authority. If the Feature merges by itself, its gates
771
863
  * decide, and a line the target moved on from goes back to be brought up to
772
864
  * date, exactly as a Ticket does; otherwise a human folds it and the system
773
- * waits, starting nothing.
865
+ * waits, starting nothing. A wait on a Submission whose state cannot be read
866
+ * ends in an escalation after `feature.settledTimeoutMs`.
774
867
  */
775
868
  async function settleFeature(input, feature) {
776
869
  const submission = feature.submission;
@@ -779,13 +872,19 @@ async function settleFeature(input, feature) {
779
872
  }
780
873
  const key = feature.intention.key;
781
874
  if (!feature.intention.merge_when_ready) {
782
- const settled = (await input.tickets.settled?.({ reference: submission.reference })) ?? "unavailable";
875
+ const settled = (await input.tickets.settled?.({ id: submission.id })) ?? "unavailable";
783
876
  if (settled === "folded") {
784
877
  await input.ledger.markFeatureDone(key);
785
878
  await reportDone(input, feature, submission.reference, undefined);
786
879
  }
787
880
  else if (settled === "closed") {
788
- await escalateFeature(input, feature, `The Submission ${submission.reference} was closed without being folded.`);
881
+ await escalateFeature(input, feature, `The Submission ${submission.reference} was closed without being folded.`, { kind: "submitted" });
882
+ }
883
+ else if (settled === "unavailable") {
884
+ const limit = input.options.feature?.settledTimeoutMs ?? DEFAULT_SETTLED_TIMEOUT_MS;
885
+ if (Date.now() - submission.submitted_at > limit) {
886
+ await escalateFeature(input, feature, `The state of the Submission ${submission.reference} could not be read for over ${Math.round(limit / 60_000)} minutes. Merge or close it, then resume the Feature.`, { kind: "submitted" });
887
+ }
789
888
  }
790
889
  return;
791
890
  }
@@ -816,6 +915,29 @@ async function reportDone(input, feature, reference, integrationReference) {
816
915
  },
817
916
  }, TO);
818
917
  }
918
+ /**
919
+ * Restart an escalated Feature with a fresh workspace: what it holds is removed
920
+ * and the Feature is resumed, so the next pass cuts its work again from the
921
+ * line and judges that. Nothing is said to the tracker and nothing is fetched.
922
+ */
923
+ export async function restartFeature(input, key) {
924
+ const got = await input.ledger.getFeature(key);
925
+ if (!got.ok) {
926
+ return { ok: false, code: "not-found" };
927
+ }
928
+ if (got.aggregate.state !== "escalated") {
929
+ return { ok: false, code: "not-escalated", state: got.aggregate.state };
930
+ }
931
+ if (input.featureDelivery !== undefined) {
932
+ await rm(featureWorkspace(input.featureDelivery, key), { recursive: true, force: true });
933
+ }
934
+ const resumed = await input.ledger.resumeFeature(key);
935
+ if (!resumed.ok) {
936
+ return { ok: false, code: "not-escalated", state: got.aggregate.state };
937
+ }
938
+ input.journal.append({ event: "restarted", key });
939
+ return { ok: true };
940
+ }
819
941
  /** Abandon a Feature or a Ticket by key: whichever the ledger knows. */
820
942
  export async function cancelKey(input, key) {
821
943
  const feature = await input.ledger.getFeature(key);