@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.
- package/README.md +202 -94
- package/SPECS.md +151 -25
- package/dist/cli.js +95 -1
- package/dist/cli.js.map +1 -1
- package/dist/config/agent-shorthand.d.ts +3 -4
- package/dist/config/agent-shorthand.d.ts.map +1 -1
- package/dist/config/agent-shorthand.js +3 -4
- package/dist/config/agent-shorthand.js.map +1 -1
- package/dist/config/fixture-yaml.d.ts +8 -0
- package/dist/config/fixture-yaml.d.ts.map +1 -0
- package/dist/config/fixture-yaml.js +30 -0
- package/dist/config/fixture-yaml.js.map +1 -0
- package/dist/config/load-config.d.ts.map +1 -1
- package/dist/config/load-config.js +90 -102
- package/dist/config/load-config.js.map +1 -1
- package/dist/config/parse-args.d.ts.map +1 -1
- package/dist/config/parse-args.js +3 -4
- package/dist/config/parse-args.js.map +1 -1
- package/dist/config/types.d.ts +15 -16
- package/dist/config/types.d.ts.map +1 -1
- package/dist/config/types.js +1 -1
- package/dist/config/types.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/loop/authority.d.ts +2 -0
- package/dist/loop/authority.d.ts.map +1 -1
- package/dist/loop/authority.js +11 -1
- package/dist/loop/authority.js.map +1 -1
- package/dist/loop/deliveries.d.ts.map +1 -1
- package/dist/loop/deliveries.js +19 -0
- package/dist/loop/deliveries.js.map +1 -1
- package/dist/loop/features.d.ts +30 -0
- package/dist/loop/features.d.ts.map +1 -1
- package/dist/loop/features.js +195 -73
- package/dist/loop/features.js.map +1 -1
- package/dist/loop/journal.d.ts +7 -0
- package/dist/loop/journal.d.ts.map +1 -1
- package/dist/loop/journal.js +23 -1
- package/dist/loop/journal.js.map +1 -1
- package/dist/loop/lock.d.ts +2 -0
- package/dist/loop/lock.d.ts.map +1 -1
- package/dist/loop/lock.js +10 -0
- package/dist/loop/lock.js.map +1 -1
- package/dist/loop/open-host.d.ts +10 -0
- package/dist/loop/open-host.d.ts.map +1 -1
- package/dist/loop/open-host.js +8 -8
- package/dist/loop/open-host.js.map +1 -1
- package/dist/loop/run-slot.d.ts +14 -0
- package/dist/loop/run-slot.d.ts.map +1 -1
- package/dist/loop/run-slot.js +53 -5
- package/dist/loop/run-slot.js.map +1 -1
- package/dist/loop/streams.d.ts +3 -0
- package/dist/loop/streams.d.ts.map +1 -1
- package/dist/loop/streams.js +4 -3
- package/dist/loop/streams.js.map +1 -1
- package/dist/loop/tick.d.ts +3 -0
- package/dist/loop/tick.d.ts.map +1 -1
- package/dist/loop/tick.js +2 -1
- package/dist/loop/tick.js.map +1 -1
- package/dist/loop/trace.d.ts +1 -0
- package/dist/loop/trace.d.ts.map +1 -1
- package/dist/loop/trace.js +1 -1
- package/dist/loop/trace.js.map +1 -1
- package/dist/loop/transformers.d.ts +4 -4
- package/dist/loop/transformers.d.ts.map +1 -1
- package/dist/loop/transformers.js +16 -25
- package/dist/loop/transformers.js.map +1 -1
- package/dist/operator/board.d.ts +43 -4
- package/dist/operator/board.d.ts.map +1 -1
- package/dist/operator/board.js +153 -10
- package/dist/operator/board.js.map +1 -1
- package/dist/operator/dash-model.d.ts +65 -0
- package/dist/operator/dash-model.d.ts.map +1 -0
- package/dist/operator/dash-model.js +209 -0
- package/dist/operator/dash-model.js.map +1 -0
- package/dist/operator/dash-view.d.ts +16 -0
- package/dist/operator/dash-view.d.ts.map +1 -0
- package/dist/operator/dash-view.js +226 -0
- package/dist/operator/dash-view.js.map +1 -0
- package/dist/operator/dash.d.ts +34 -0
- package/dist/operator/dash.d.ts.map +1 -0
- package/dist/operator/dash.js +141 -0
- package/dist/operator/dash.js.map +1 -0
- package/dist/operator/doctor.d.ts.map +1 -1
- package/dist/operator/doctor.js +48 -8
- package/dist/operator/doctor.js.map +1 -1
- package/dist/operator/explain.d.ts.map +1 -1
- package/dist/operator/explain.js +6 -11
- package/dist/operator/explain.js.map +1 -1
- package/dist/operator/incident-dossier.d.ts +154 -0
- package/dist/operator/incident-dossier.d.ts.map +1 -0
- package/dist/operator/incident-dossier.js +692 -0
- package/dist/operator/incident-dossier.js.map +1 -0
- package/dist/operator/incident-system.d.ts +57 -0
- package/dist/operator/incident-system.d.ts.map +1 -0
- package/dist/operator/incident-system.js +214 -0
- package/dist/operator/incident-system.js.map +1 -0
- package/dist/operator/incident.d.ts +27 -0
- package/dist/operator/incident.d.ts.map +1 -0
- package/dist/operator/incident.js +220 -0
- package/dist/operator/incident.js.map +1 -0
- package/dist/operator/init.js +24 -24
- package/dist/operator/init.js.map +1 -1
- package/dist/operator/install.d.ts +9 -0
- package/dist/operator/install.d.ts.map +1 -0
- package/dist/operator/install.js +14 -0
- package/dist/operator/install.js.map +1 -0
- package/dist/operator/live.js +4 -4
- package/dist/operator/live.js.map +1 -1
- package/dist/operator/log.d.ts +2 -0
- package/dist/operator/log.d.ts.map +1 -1
- package/dist/operator/log.js +2 -5
- package/dist/operator/log.js.map +1 -1
- package/dist/operator/restart.d.ts +20 -0
- package/dist/operator/restart.d.ts.map +1 -0
- package/dist/operator/restart.js +63 -0
- package/dist/operator/restart.js.map +1 -0
- package/dist/operator/supervise/act.d.ts +37 -0
- package/dist/operator/supervise/act.d.ts.map +1 -0
- package/dist/operator/supervise/act.js +354 -0
- package/dist/operator/supervise/act.js.map +1 -0
- package/dist/operator/supervise/config.d.ts +74 -0
- package/dist/operator/supervise/config.d.ts.map +1 -0
- package/dist/operator/supervise/config.js +175 -0
- package/dist/operator/supervise/config.js.map +1 -0
- package/dist/operator/supervise/council.d.ts +45 -0
- package/dist/operator/supervise/council.d.ts.map +1 -0
- package/dist/operator/supervise/council.js +97 -0
- package/dist/operator/supervise/council.js.map +1 -0
- package/dist/operator/supervise/decision.d.ts +46 -0
- package/dist/operator/supervise/decision.d.ts.map +1 -0
- package/dist/operator/supervise/decision.js +137 -0
- package/dist/operator/supervise/decision.js.map +1 -0
- package/dist/operator/supervise/exec.d.ts +28 -0
- package/dist/operator/supervise/exec.d.ts.map +1 -0
- package/dist/operator/supervise/exec.js +47 -0
- package/dist/operator/supervise/exec.js.map +1 -0
- package/dist/operator/supervise/memory.d.ts +33 -0
- package/dist/operator/supervise/memory.d.ts.map +1 -0
- package/dist/operator/supervise/memory.js +73 -0
- package/dist/operator/supervise/memory.js.map +1 -0
- package/dist/operator/supervise/observe.d.ts +47 -0
- package/dist/operator/supervise/observe.d.ts.map +1 -0
- package/dist/operator/supervise/observe.js +73 -0
- package/dist/operator/supervise/observe.js.map +1 -0
- package/dist/operator/supervise/prompts.d.ts +60 -0
- package/dist/operator/supervise/prompts.d.ts.map +1 -0
- package/dist/operator/supervise/prompts.js +338 -0
- package/dist/operator/supervise/prompts.js.map +1 -0
- package/dist/operator/supervise/review.d.ts +24 -0
- package/dist/operator/supervise/review.d.ts.map +1 -0
- package/dist/operator/supervise/review.js +51 -0
- package/dist/operator/supervise/review.js.map +1 -0
- package/dist/operator/supervise/supervise.d.ts +26 -0
- package/dist/operator/supervise/supervise.d.ts.map +1 -0
- package/dist/operator/supervise/supervise.js +484 -0
- package/dist/operator/supervise/supervise.js.map +1 -0
- package/package.json +14 -14
package/dist/loop/features.js
CHANGED
|
@@ -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
|
|
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.
|
|
79
|
-
const moved = await input.ledger.
|
|
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.
|
|
83
|
+
skip: feature.skip_feature_test,
|
|
82
84
|
});
|
|
83
85
|
if (moved.ok) {
|
|
84
|
-
input.journal.append({
|
|
85
|
-
|
|
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.
|
|
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 {
|
|
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
|
-
|
|
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
|
|
237
|
-
* no
|
|
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
|
|
241
|
-
if (input.options.builder.
|
|
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.
|
|
245
|
-
const head = "This Project proves each Feature by
|
|
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
|
|
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}
|
|
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.
|
|
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
|
|
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
|
|
261
|
-
async function
|
|
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 &&
|
|
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 =
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
449
|
-
if (feature.intention.
|
|
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: "
|
|
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
|
|
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
|
-
*
|
|
503
|
-
*
|
|
504
|
-
*
|
|
505
|
-
*
|
|
506
|
-
*
|
|
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
|
-
|
|
518
|
-
|
|
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
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
return;
|
|
528
|
-
}
|
|
543
|
+
const started = drive(key).finally(() => {
|
|
544
|
+
inFlight.delete(started);
|
|
545
|
+
});
|
|
546
|
+
inFlight.add(started);
|
|
529
547
|
}
|
|
530
548
|
};
|
|
531
|
-
await
|
|
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
|
-
|
|
556
|
-
|
|
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
|
-
|
|
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
|
-
|
|
609
|
-
|
|
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.
|
|
657
|
-
feature.intention.
|
|
658
|
-
!(await
|
|
659
|
-
await escalateFeature(input, feature, "This Feature is not marked skip-test
|
|
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
|
-
|
|
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({
|
|
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?.({
|
|
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);
|