@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.
- package/README.md +200 -99
- package/SPECS.md +121 -19
- package/dist/cli.js +35 -4
- package/dist/cli.js.map +1 -1
- package/dist/config/agent-shorthand.d.ts +25 -5
- package/dist/config/agent-shorthand.d.ts.map +1 -1
- package/dist/config/agent-shorthand.js +65 -21
- 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/home.d.ts +2 -0
- package/dist/config/home.d.ts.map +1 -1
- package/dist/config/home.js +2 -0
- package/dist/config/home.js.map +1 -1
- package/dist/config/load-config.d.ts.map +1 -1
- package/dist/config/load-config.js +301 -62
- package/dist/config/load-config.js.map +1 -1
- package/dist/config/parse-args.d.ts +1 -1
- package/dist/config/parse-args.d.ts.map +1 -1
- package/dist/config/parse-args.js +84 -58
- package/dist/config/parse-args.js.map +1 -1
- package/dist/config/types.d.ts +96 -16
- package/dist/config/types.d.ts.map +1 -1
- package/dist/config/types.js +13 -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 +38 -1
- package/dist/loop/authority.d.ts.map +1 -1
- package/dist/loop/authority.js +41 -2
- package/dist/loop/authority.js.map +1 -1
- package/dist/loop/context.d.ts +16 -1
- package/dist/loop/context.d.ts.map +1 -1
- package/dist/loop/drive.d.ts +9 -3
- package/dist/loop/drive.d.ts.map +1 -1
- package/dist/loop/drive.js +17 -7
- package/dist/loop/drive.js.map +1 -1
- package/dist/loop/features.d.ts +16 -0
- package/dist/loop/features.d.ts.map +1 -1
- package/dist/loop/features.js +404 -62
- package/dist/loop/features.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 +111 -9
- package/dist/loop/open-host.js.map +1 -1
- package/dist/loop/report.d.ts.map +1 -1
- package/dist/loop/report.js +21 -9
- package/dist/loop/report.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 +2 -1
- 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.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 +2 -2
- package/dist/loop/trace.js.map +1 -1
- package/dist/loop/transformers.d.ts +16 -2
- package/dist/loop/transformers.d.ts.map +1 -1
- package/dist/loop/transformers.js +126 -22
- package/dist/loop/transformers.js.map +1 -1
- package/dist/loop/warm.js +1 -1
- package/dist/loop/warm.js.map +1 -1
- package/dist/operator/agent-plugins.d.ts.map +1 -1
- package/dist/operator/agent-plugins.js +14 -8
- package/dist/operator/agent-plugins.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/doctor.d.ts +1 -1
- package/dist/operator/doctor.d.ts.map +1 -1
- package/dist/operator/doctor.js +76 -29
- package/dist/operator/doctor.js.map +1 -1
- package/dist/operator/explain.d.ts.map +1 -1
- package/dist/operator/explain.js +36 -12
- package/dist/operator/explain.js.map +1 -1
- package/dist/operator/film.js +0 -3
- package/dist/operator/film.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 +696 -0
- package/dist/operator/incident-dossier.js.map +1 -0
- package/dist/operator/incident-system.d.ts +48 -0
- package/dist/operator/incident-system.d.ts.map +1 -0
- package/dist/operator/incident-system.js +152 -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 +27 -22
- package/dist/operator/init.js.map +1 -1
- 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 +7 -3
- 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/setup.d.ts.map +1 -1
- package/dist/operator/setup.js +2 -0
- package/dist/operator/setup.js.map +1 -1
- package/package.json +14 -14
package/dist/loop/features.js
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
360
|
-
|
|
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
|
|
370
|
-
|
|
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
|
-
|
|
374
|
-
|
|
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
|
-
|
|
378
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
455
|
-
|
|
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
|
|
459
|
-
|
|
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
|
-
*
|
|
490
|
-
*
|
|
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
|
-
|
|
510
|
-
|
|
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
|
|
530
|
-
if (folded
|
|
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
|
-
|
|
534
|
-
|
|
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);
|