omp-conductor 0.15.10 → 0.15.12
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 +273 -2544
- package/REFERENCE.md +2680 -0
- package/package.json +3 -2
- package/schema/config.schema.json +11 -23
- package/src/arm-challenge.ts +112 -0
- package/src/ask.ts +434 -0
- package/src/board.ts +81 -15
- package/src/brief-upgrade.ts +114 -8
- package/src/briefs/orchestrator.md +113 -34
- package/src/briefs/policy.md +33 -8
- package/src/briefs/worker.md +18 -9
- package/src/chain-check.ts +1 -1
- package/src/check-trailing-newlines.ts +82 -0
- package/src/cli.ts +225 -1406
- package/src/commands/arm.ts +21 -0
- package/src/commands/board.ts +23 -0
- package/src/commands/brief-upgrade.ts +186 -0
- package/src/commands/context.ts +150 -0
- package/src/commands/daemon.ts +71 -0
- package/src/commands/dashboard.ts +74 -0
- package/src/commands/decision.ts +103 -0
- package/src/commands/disarm.ts +21 -0
- package/src/commands/doctor.ts +100 -0
- package/src/commands/event.ts +62 -0
- package/src/commands/extend.ts +64 -0
- package/src/commands/friction.ts +56 -0
- package/src/commands/help.ts +9 -0
- package/src/commands/hold.ts +26 -0
- package/src/commands/intake.ts +155 -0
- package/src/commands/ledger.ts +69 -0
- package/src/commands/message.ts +96 -0
- package/src/commands/report.ts +206 -0
- package/src/commands/restart.ts +76 -0
- package/src/commands/resume.ts +58 -0
- package/src/commands/setup.ts +143 -0
- package/src/commands/start.ts +23 -0
- package/src/commands/stats.ts +131 -0
- package/src/commands/status.ts +48 -0
- package/src/commands/stop.ts +51 -0
- package/src/commands/tail.ts +109 -0
- package/src/commands/unblock.ts +39 -0
- package/src/commands/upgrade-install.ts +31 -0
- package/src/commands/upgrade-rollback.ts +32 -0
- package/src/commands/upgrade.ts +25 -0
- package/src/commands/verb.ts +83 -0
- package/src/commands/version.ts +30 -0
- package/src/commands/worker.ts +100 -0
- package/src/config-schema.ts +47 -1
- package/src/config.ts +45 -4
- package/src/daemon.ts +972 -101
- package/src/dashboard/app.js +459 -0
- package/src/dashboard/index.html +61 -0
- package/src/dashboard/server.ts +481 -0
- package/src/dashboard/style.css +348 -0
- package/src/decisions.ts +39 -14
- package/src/diff-flags.ts +131 -241
- package/src/doctor.ts +932 -0
- package/src/escalate.ts +2 -2
- package/src/failure-class.ts +66 -3
- package/src/fleet.ts +58 -1
- package/src/gitops.ts +157 -0
- package/src/graph-health.ts +1 -1
- package/src/label-projection.ts +1 -1
- package/src/lifecycle.ts +198 -2
- package/src/model-fallback.ts +177 -0
- package/src/notices.ts +9 -0
- package/src/omp.ts +93 -13
- package/src/orchestrator-tick.ts +414 -20
- package/src/orchestrator.ts +4 -4
- package/src/privileged.ts +10 -0
- package/src/release-policy.ts +342 -30
- package/src/reports.ts +19 -5
- package/src/session-host.ts +11 -5
- package/src/setup-host.ts +663 -25
- package/src/setup-install.ts +292 -28
- package/src/setup-wizard.ts +255 -74
- package/src/setup.ts +156 -35
- package/src/stats.ts +331 -0
- package/src/store.ts +219 -22
- package/src/tracker/github.ts +74 -4
- package/src/types.ts +215 -31
- package/src/unblock.ts +55 -11
- package/src/upgrade-journal.ts +220 -0
- package/src/upgrade-verify.ts +506 -0
- package/src/upgrade.ts +385 -58
- package/src/verbs/actions.ts +73 -1
- package/src/verbs/protocol.ts +29 -4
- package/src/verbs/server.ts +183 -20
- package/src/worker.ts +3 -3
- package/systemd/omp-conductor-recover.sh +433 -0
- package/systemd/omp-conductor.service.example +14 -3
- package/systemd/recover-unit-test.sh +428 -0
package/src/setup.ts
CHANGED
|
@@ -29,8 +29,10 @@ import {
|
|
|
29
29
|
POLICY_BRIEF_NAME,
|
|
30
30
|
composeOrchestrator,
|
|
31
31
|
policyPathForRoot,
|
|
32
|
+
readSharedPolicy,
|
|
32
33
|
refreshComposedBrief,
|
|
33
34
|
renderBriefTemplate,
|
|
35
|
+
sharedPolicyPath,
|
|
34
36
|
} from "./brief-upgrade.ts";
|
|
35
37
|
import {
|
|
36
38
|
clonePolicy,
|
|
@@ -55,6 +57,7 @@ import {
|
|
|
55
57
|
WEEKDAYS,
|
|
56
58
|
type BaseFreshness,
|
|
57
59
|
type BehindBaseAction,
|
|
60
|
+
type AuthorityHolder,
|
|
58
61
|
type Caps,
|
|
59
62
|
type ConductorConfig,
|
|
60
63
|
type DigestCadence,
|
|
@@ -152,6 +155,14 @@ export interface SetupAnswers {
|
|
|
152
155
|
* survive an amend of some other area (#369).
|
|
153
156
|
*/
|
|
154
157
|
groomBelow?: number;
|
|
158
|
+
/**
|
|
159
|
+
* Ordered fallback worker models and the provider-failure threshold that
|
|
160
|
+
* starts them. The wizard never asks for either — they are hand-edited —
|
|
161
|
+
* so they exist here only to survive an amend of some other area (#369,
|
|
162
|
+
* #286).
|
|
163
|
+
*/
|
|
164
|
+
modelFallbacks?: string[];
|
|
165
|
+
modelFallbackThreshold?: number;
|
|
155
166
|
telegramChatId?: string;
|
|
156
167
|
/** Forum topic for tier-2 Telegram pages; absent keeps flat-chat 0.13 behaviour. */
|
|
157
168
|
telegramTopicId?: number;
|
|
@@ -190,6 +201,13 @@ export interface SetupAnswers {
|
|
|
190
201
|
* merge provenance gate.
|
|
191
202
|
*/
|
|
192
203
|
recoveryMerges?: ProjectConfig["recoveryMerges"];
|
|
204
|
+
/**
|
|
205
|
+
* Hand-edited critical-base/safety markers (commit SHAs or refs) carried
|
|
206
|
+
* through setup unchanged, like {@link recoveryMerges}. The wizard never
|
|
207
|
+
* invents one; forgetting them during an unrelated amend would drop the
|
|
208
|
+
* stale-base interlock from a project that relies on it (#428).
|
|
209
|
+
*/
|
|
210
|
+
criticalBase?: ProjectConfig["criticalBase"];
|
|
193
211
|
/**
|
|
194
212
|
* Whether to render `ORCHESTRATOR.md` into the project's workspace root. Not
|
|
195
213
|
* part of the config — the brief is the operator's file, and the conductor
|
|
@@ -411,6 +429,31 @@ export const MERGE_DUTY: { readonly [K in ProjectConfig["authority"]["merge"]]:
|
|
|
411
429
|
" one is a hard boundary, not a preference.",
|
|
412
430
|
};
|
|
413
431
|
|
|
432
|
+
/**
|
|
433
|
+
* The Promotion paragraph, worded from `authority.promotion`.
|
|
434
|
+
*
|
|
435
|
+
* The queue label is a sign-off: adding it to an issue lets a worker claim it
|
|
436
|
+
* and starts spend. POLICY.md must state who may do that — never a blank the
|
|
437
|
+
* floor defers into — and an unstated owner reads operator-only, because a
|
|
438
|
+
* gate nobody claimed must not resolve to the session that wants to use it.
|
|
439
|
+
*
|
|
440
|
+
* The paragraph refers to "the queue label" rather than naming it: the label
|
|
441
|
+
* itself is rendered into the section heading from config, and a second copy
|
|
442
|
+
* here would be a label that can disagree with the one the daemon reads.
|
|
443
|
+
*/
|
|
444
|
+
export const PROMOTION_DUTIES: { readonly [K in AuthorityHolder]: string } = {
|
|
445
|
+
human:
|
|
446
|
+
"**Default: the operator promotes.** Adding the queue label to an issue is promotion, and\n" +
|
|
447
|
+
"promotion is the operator's: the orchestrator grooms, routes and proposes, but the label\n" +
|
|
448
|
+
"goes on only when the operator puts it there — it is the sign-off that starts spend. To\n" +
|
|
449
|
+
"delegate, re-run `omp-conductor setup` or replace this paragraph with one naming the\n" +
|
|
450
|
+
"orchestrator as promoter.",
|
|
451
|
+
orchestrator:
|
|
452
|
+
"**Delegated in setup: the orchestrator promotes.** Adding the queue label to an issue is\n" +
|
|
453
|
+
"promotion, and the orchestrator may do it after grooming the issue, one at a time. The\n" +
|
|
454
|
+
"operator can take it back by editing this paragraph.",
|
|
455
|
+
};
|
|
456
|
+
|
|
414
457
|
export { ORCHESTRATOR_BRIEF_NAME, POLICY_BRIEF_NAME };
|
|
415
458
|
|
|
416
459
|
/** Shipped floor template — duties, tiers, hard boundaries, Learning loop. */
|
|
@@ -427,12 +470,14 @@ const POLICY_TEMPLATE_PATH = join(import.meta.dir, "briefs", "policy.md");
|
|
|
427
470
|
const REQUIRED_SCOPES = ["repo", "project"] as const;
|
|
428
471
|
|
|
429
472
|
/** GitHub's own palette, so the tracker reads at a glance: green means queued,
|
|
430
|
-
* blue means moving, amber means waiting on you, red means it gave up
|
|
473
|
+
* blue means moving, amber means waiting on you, red means it gave up, and
|
|
474
|
+
* purple is routing — the operator's input, the one label the loop never writes. */
|
|
431
475
|
const LABEL_COLOURS = {
|
|
432
476
|
queue: "0e8a16",
|
|
433
477
|
inProgress: "1d76db",
|
|
434
478
|
blocked: "fbca04",
|
|
435
479
|
failed: "b60205",
|
|
480
|
+
routing: "6f42c1",
|
|
436
481
|
} as const;
|
|
437
482
|
|
|
438
483
|
interface GhResult {
|
|
@@ -514,11 +559,18 @@ export async function checkTokenScopes(): Promise<ScopeCheck> {
|
|
|
514
559
|
}
|
|
515
560
|
|
|
516
561
|
/**
|
|
517
|
-
*
|
|
518
|
-
*
|
|
562
|
+
* Every label setup wants on the tracker, before existence is known: the queue
|
|
563
|
+
* and state labels the loop reads and writes, plus one routing label per routed
|
|
564
|
+
* repo. Pure — the prefix and the repo keys are already answered at this point
|
|
565
|
+
* in the interview — which is what lets a test pin the whole set without `gh`.
|
|
566
|
+
*
|
|
567
|
+
* Routing labels are provisioned, never applied: `route()` requires exactly one
|
|
568
|
+
* `<prefix><repo>` label per issue and treats zero or two as unroutable, so a
|
|
569
|
+
* tracker without them can queue nothing. Creating them queues nothing either —
|
|
570
|
+
* applying one, with the queue label, stays the operator's sign-off.
|
|
519
571
|
*/
|
|
520
|
-
export
|
|
521
|
-
|
|
572
|
+
export function wantedLabels(a: SetupAnswers): Omit<LabelPlan, "exists">[] {
|
|
573
|
+
return [
|
|
522
574
|
{
|
|
523
575
|
name: a.queueLabel,
|
|
524
576
|
colour: LABEL_COLOURS.queue,
|
|
@@ -539,13 +591,28 @@ export async function planLabels(trackerRepo: string, a: SetupAnswers): Promise<
|
|
|
539
591
|
colour: LABEL_COLOURS.failed,
|
|
540
592
|
description: "The conductor gave up on this issue after its retry budget",
|
|
541
593
|
},
|
|
594
|
+
...a.targetRepos.map((r) => ({
|
|
595
|
+
name: `${a.routingLabelPrefix}${r.name}`,
|
|
596
|
+
colour: LABEL_COLOURS.routing,
|
|
597
|
+
description: `Routes this issue to the ${r.name} checkout`,
|
|
598
|
+
})),
|
|
542
599
|
];
|
|
600
|
+
}
|
|
543
601
|
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
602
|
+
/**
|
|
603
|
+
* Turns wanted labels into the plan the operator is shown, deduped
|
|
604
|
+
* case-insensitively and marked against what already exists.
|
|
605
|
+
*
|
|
606
|
+
* GitHub label names are unique case-insensitively, so an operator who answers
|
|
607
|
+
* "Ready-For-Agent" against an existing "ready-for-agent" must not be told a
|
|
608
|
+
* creation is pending that would then fail — and a multi-repo project whose
|
|
609
|
+
* names case-fold onto each other or onto a lifecycle label wants one creation,
|
|
610
|
+
* not two.
|
|
611
|
+
*/
|
|
612
|
+
export function planAgainstLabels(
|
|
613
|
+
wanted: readonly Omit<LabelPlan, "exists">[],
|
|
614
|
+
existing: ReadonlySet<string>,
|
|
615
|
+
): LabelPlan[] {
|
|
549
616
|
const seen = new Set<string>();
|
|
550
617
|
const plan: LabelPlan[] = [];
|
|
551
618
|
for (const w of wanted) {
|
|
@@ -557,6 +624,18 @@ export async function planLabels(trackerRepo: string, a: SetupAnswers): Promise<
|
|
|
557
624
|
return plan;
|
|
558
625
|
}
|
|
559
626
|
|
|
627
|
+
/**
|
|
628
|
+
* The labels a tracker needs, and which already exist. Read-only: this is what
|
|
629
|
+
* the operator is shown before being asked to consent to creation.
|
|
630
|
+
*
|
|
631
|
+
* The queue and state labels are the ones the loop reads and writes; the
|
|
632
|
+
* routing labels (one per repo) are the operator's input that makes an issue
|
|
633
|
+
* routable, and setup provisions them only so there is something to apply.
|
|
634
|
+
*/
|
|
635
|
+
export async function planLabels(trackerRepo: string, a: SetupAnswers): Promise<LabelPlan[]> {
|
|
636
|
+
return planAgainstLabels(wantedLabels(a), await listLabelNames(trackerRepo));
|
|
637
|
+
}
|
|
638
|
+
|
|
560
639
|
/** Lower-cased label names currently on the repo. Throws rather than guessing:
|
|
561
640
|
* a repo whose labels cannot be listed cannot be serviced either. */
|
|
562
641
|
async function listLabelNames(trackerRepo: string): Promise<Set<string>> {
|
|
@@ -772,6 +851,10 @@ export function buildProject(a: SetupAnswers): ProjectConfig {
|
|
|
772
851
|
: {}),
|
|
773
852
|
// A documented hand-edited key, so an unrelated amend must not delete it.
|
|
774
853
|
...(a.groomBelow === undefined ? {} : { groomBelow: a.groomBelow }),
|
|
854
|
+
// The failover chain is hand-edited too: dropping it on an unrelated amend
|
|
855
|
+
// would pin every run of the project onto a dead provider again (#286).
|
|
856
|
+
...(a.modelFallbacks === undefined ? {} : { modelFallbacks: [...a.modelFallbacks] }),
|
|
857
|
+
...(a.modelFallbackThreshold === undefined ? {} : { modelFallbackThreshold: a.modelFallbackThreshold }),
|
|
775
858
|
escalation,
|
|
776
859
|
authority: { ...a.authority },
|
|
777
860
|
// Written out in full, never as the legacy string: the file then says which
|
|
@@ -783,6 +866,9 @@ export function buildProject(a: SetupAnswers): ProjectConfig {
|
|
|
783
866
|
...(a.recoveryMerges === undefined
|
|
784
867
|
? {}
|
|
785
868
|
: { recoveryMerges: a.recoveryMerges.map((entry) => ({ ...entry })) }),
|
|
869
|
+
// Hand-edited safety markers carried unchanged on an unrelated amend
|
|
870
|
+
// (#428): dropping them would silently disarm the stale-base interlock.
|
|
871
|
+
...(a.criticalBase === undefined ? {} : { criticalBase: [...a.criticalBase] }),
|
|
786
872
|
// Written out even when it is the default, so an operator amending the
|
|
787
873
|
// volume has a line in the file to point at. Opting into availability makes
|
|
788
874
|
// the schedule explicit and daily; omitting it preserves the preset's
|
|
@@ -973,11 +1059,14 @@ export function answersFromProject(p: ProjectConfig): SetupAnswers {
|
|
|
973
1059
|
// what keeps the rewritten config identical to the one that was read.
|
|
974
1060
|
if (p.workerModel !== undefined) answers.workerModel = p.workerModel;
|
|
975
1061
|
if (p.groomBelow !== undefined) answers.groomBelow = p.groomBelow;
|
|
1062
|
+
if (p.modelFallbacks !== undefined) answers.modelFallbacks = [...p.modelFallbacks];
|
|
1063
|
+
if (p.modelFallbackThreshold !== undefined) answers.modelFallbackThreshold = p.modelFallbackThreshold;
|
|
976
1064
|
if (p.escalation.telegramChatId !== undefined) answers.telegramChatId = p.escalation.telegramChatId;
|
|
977
1065
|
if (p.escalation.telegramTopicId !== undefined) answers.telegramTopicId = p.escalation.telegramTopicId;
|
|
978
1066
|
if (p.recoveryMerges !== undefined) {
|
|
979
1067
|
answers.recoveryMerges = p.recoveryMerges.map((entry) => ({ ...entry }));
|
|
980
1068
|
}
|
|
1069
|
+
if (p.criticalBase !== undefined) answers.criticalBase = [...p.criticalBase];
|
|
981
1070
|
if (p.reporting?.digest.at !== undefined) answers.dailyDigestAt = p.reporting.digest.at;
|
|
982
1071
|
if (p.reporting?.digest.timezone !== undefined) {
|
|
983
1072
|
answers.reportingTimezone = p.reporting.digest.timezone;
|
|
@@ -1023,6 +1112,7 @@ function briefVarsForProject(p: ProjectConfig): Record<string, string> {
|
|
|
1023
1112
|
QUEUE_LABEL: p.queueLabel,
|
|
1024
1113
|
RELEASES_DEFAULT: RELEASES_DEFAULTS[`${p.authority.merge}/${p.authority.release}`],
|
|
1025
1114
|
MERGE_DUTY: MERGE_DUTY[p.authority.merge],
|
|
1115
|
+
PROMOTION_DUTY: PROMOTION_DUTIES[p.authority.promotion ?? DEFAULT_AUTHORITY.promotion],
|
|
1026
1116
|
POLICY_SOURCE: policySourceLine(p),
|
|
1027
1117
|
};
|
|
1028
1118
|
}
|
|
@@ -1073,14 +1163,19 @@ export function renderPolicyForProject(p: ProjectConfig): string {
|
|
|
1073
1163
|
}
|
|
1074
1164
|
|
|
1075
1165
|
/**
|
|
1076
|
-
* Composed session brief: rendered floor +
|
|
1166
|
+
* Composed session brief: rendered floor + shared policy (when present) +
|
|
1167
|
+
* POLICY scaffold (or a caller's policy).
|
|
1077
1168
|
*
|
|
1078
1169
|
* Setup writes the policy half to `POLICY.md` and this compose to
|
|
1079
|
-
* `ORCHESTRATOR.md`. Later ticks recompose from the live POLICY.md
|
|
1080
|
-
* floor updates apply without brief-upgrade.
|
|
1170
|
+
* `ORCHESTRATOR.md`. Later ticks recompose from the live POLICY.md and the live
|
|
1171
|
+
* host-wide shared policy, so package floor updates apply without brief-upgrade.
|
|
1081
1172
|
*/
|
|
1082
1173
|
export function renderBriefForProject(p: ProjectConfig, policyText?: string): string {
|
|
1083
|
-
return composeOrchestrator(
|
|
1174
|
+
return composeOrchestrator(
|
|
1175
|
+
renderFloorForProject(p),
|
|
1176
|
+
policyText ?? renderPolicyForProject(p),
|
|
1177
|
+
readSharedPolicy(),
|
|
1178
|
+
);
|
|
1084
1179
|
}
|
|
1085
1180
|
|
|
1086
1181
|
/** Wizard-time path, via the project the answers describe. */
|
|
@@ -1094,7 +1189,7 @@ export function renderOrchestratorBrief(a: SetupAnswers, prose: ProbedProse = {}
|
|
|
1094
1189
|
// would write" and "what setup writes" cannot diverge — the preview an operator
|
|
1095
1190
|
// consents to is the file they get.
|
|
1096
1191
|
const project = buildProject(a);
|
|
1097
|
-
return composeOrchestrator(renderFloorForProject(project), renderPolicy(a, prose));
|
|
1192
|
+
return composeOrchestrator(renderFloorForProject(project), renderPolicy(a, prose), readSharedPolicy());
|
|
1098
1193
|
}
|
|
1099
1194
|
|
|
1100
1195
|
/** `POLICY.md` for these answers: the project's own template plus the interview. */
|
|
@@ -1149,7 +1244,7 @@ export function writeOrchestratorBrief(a: SetupAnswers, prose: ProbedProse = {})
|
|
|
1149
1244
|
mkdirSync(dirname(orchestratorPath), { recursive: true });
|
|
1150
1245
|
const policy = renderPolicy(a, prose);
|
|
1151
1246
|
writeFileSync(policyPath, policy);
|
|
1152
|
-
writeFileSync(orchestratorPath, composeOrchestrator(renderFloorForProject(project), policy));
|
|
1247
|
+
writeFileSync(orchestratorPath, composeOrchestrator(renderFloorForProject(project), policy, readSharedPolicy()));
|
|
1153
1248
|
return orchestratorPath;
|
|
1154
1249
|
}
|
|
1155
1250
|
|
|
@@ -1246,7 +1341,8 @@ function releaseLines(j: OperatorJudgment): string[] {
|
|
|
1246
1341
|
}
|
|
1247
1342
|
|
|
1248
1343
|
/**
|
|
1249
|
-
* Recompose `ORCHESTRATOR.md` from the package floor +
|
|
1344
|
+
* Recompose `ORCHESTRATOR.md` from the package floor + host-wide shared policy
|
|
1345
|
+
* (when present) + live `POLICY.md`.
|
|
1250
1346
|
*
|
|
1251
1347
|
* Returns false when POLICY.md is missing (caller should migrate or set up).
|
|
1252
1348
|
*/
|
|
@@ -1255,6 +1351,7 @@ export function refreshComposedBriefForProject(p: ProjectConfig): boolean {
|
|
|
1255
1351
|
policyPath: policyPathForProject(p),
|
|
1256
1352
|
orchestratorPath: briefPathForProject(p),
|
|
1257
1353
|
floor: renderFloorForProject(p),
|
|
1354
|
+
sharedPolicyPath: sharedPolicyPath(),
|
|
1258
1355
|
});
|
|
1259
1356
|
}
|
|
1260
1357
|
|
|
@@ -1355,6 +1452,15 @@ function describeAvailabilityDays(days: readonly Weekday[]): string {
|
|
|
1355
1452
|
: days.join(",");
|
|
1356
1453
|
}
|
|
1357
1454
|
|
|
1455
|
+
/**
|
|
1456
|
+
* The caps table's key column. Wide enough that the longest cap key,
|
|
1457
|
+
* `maxConcurrentWorkersPerRepo`, cannot fuse with its value — the very defect
|
|
1458
|
+
* that made #448's consent screen read `maxConcurrentWorkersPerRepo1` while
|
|
1459
|
+
* the daemon ran 3. Exported so tests pin the rendered table against the same
|
|
1460
|
+
* column the renderer uses.
|
|
1461
|
+
*/
|
|
1462
|
+
export const CAPS_PLAN_KEY_PAD = 28;
|
|
1463
|
+
|
|
1358
1464
|
/**
|
|
1359
1465
|
* Everything that would change, as plain text, with no side effects at all.
|
|
1360
1466
|
*
|
|
@@ -1368,11 +1474,15 @@ export function summarisePlan(
|
|
|
1368
1474
|
scopes: ScopeCheck,
|
|
1369
1475
|
labels: LabelPlan[],
|
|
1370
1476
|
tg: TelegramPresence,
|
|
1477
|
+
defaults: Caps = DEFAULT_CAPS,
|
|
1371
1478
|
): string {
|
|
1372
1479
|
const project = buildProject(a);
|
|
1373
|
-
// Caps are
|
|
1374
|
-
//
|
|
1375
|
-
|
|
1480
|
+
// Caps are resolved against the caller's defaults — the loaded config's
|
|
1481
|
+
// global block on a re-run, the shipped baseline on a first run, where the
|
|
1482
|
+
// two are the same object. That is the exact baseline the daemon resolves
|
|
1483
|
+
// against, so the consent screen and runtime cannot disagree (#372/#448).
|
|
1484
|
+
// The shipped baseline itself is never labelled "effective".
|
|
1485
|
+
const effective = resolveCaps(project, defaults);
|
|
1376
1486
|
const states = Object.values(a.stateLabels).join(", ");
|
|
1377
1487
|
|
|
1378
1488
|
const lines: string[] = [];
|
|
@@ -1441,10 +1551,10 @@ export function summarisePlan(
|
|
|
1441
1551
|
lines.push("", "caps (effective)");
|
|
1442
1552
|
for (const [key, value] of Object.entries(effective)) {
|
|
1443
1553
|
const answered = Object.hasOwn(project.caps, key) ? " (answered)" : "";
|
|
1444
|
-
lines.push(` ${key.padEnd(
|
|
1554
|
+
lines.push(` ${key.padEnd(CAPS_PLAN_KEY_PAD)}${String(value)}${answered}`);
|
|
1445
1555
|
}
|
|
1446
1556
|
if (a.workerModel !== undefined && a.workerModel.trim().length > 0) {
|
|
1447
|
-
lines.push(` ${"worker model".padEnd(
|
|
1557
|
+
lines.push(` ${"worker model".padEnd(CAPS_PLAN_KEY_PAD)}${a.workerModel.trim()} (answered)`);
|
|
1448
1558
|
}
|
|
1449
1559
|
|
|
1450
1560
|
lines.push("", "escalation");
|
|
@@ -1482,7 +1592,7 @@ export function summarisePlan(
|
|
|
1482
1592
|
const granted = RELEASE_SHAPES.filter((shape) => a.releaseGrants[shape] === "orchestrator");
|
|
1483
1593
|
lines.push(
|
|
1484
1594
|
"",
|
|
1485
|
-
`authority merge=${a.authority.merge} release=${a.authority.release}`,
|
|
1595
|
+
`authority merge=${a.authority.merge} release=${a.authority.release} promotion=${a.authority.promotion ?? DEFAULT_AUTHORITY.promotion}`,
|
|
1486
1596
|
`tool gate ${
|
|
1487
1597
|
granted.length === 0
|
|
1488
1598
|
? "every release/deploy shape blocked for workers and the orchestrator"
|
|
@@ -1602,13 +1712,16 @@ export type AmendAreaId = (typeof AMEND_AREA_IDS)[number];
|
|
|
1602
1712
|
* `describe` is the reason the pick-list is worth anything — an operator picking
|
|
1603
1713
|
* blind from eight nouns cannot tell which one holds the setting they came to
|
|
1604
1714
|
* change, so every row carries its own current value. It reads only the config,
|
|
1605
|
-
* so the whole menu can be rendered and reviewed without a terminal.
|
|
1715
|
+
* so the whole menu can be rendered and reviewed without a terminal. The caps
|
|
1716
|
+
* row resolves inherited values against the defaults the caller is amending —
|
|
1717
|
+
* the loaded config's global block on a re-run — so the menu and the daemon
|
|
1718
|
+
* agree on what a project actually runs (#448).
|
|
1606
1719
|
*/
|
|
1607
1720
|
export const AMEND_AREAS: {
|
|
1608
1721
|
readonly [K in AmendAreaId]: {
|
|
1609
1722
|
readonly name: string;
|
|
1610
1723
|
readonly asks: string;
|
|
1611
|
-
readonly describe: (p: ProjectConfig) => string;
|
|
1724
|
+
readonly describe: (p: ProjectConfig, defaults?: Caps) => string;
|
|
1612
1725
|
};
|
|
1613
1726
|
} = {
|
|
1614
1727
|
tracker: {
|
|
@@ -1636,8 +1749,8 @@ export const AMEND_AREAS: {
|
|
|
1636
1749
|
// an area no menu offers is a setting only a full re-interview can reach.
|
|
1637
1750
|
name: "caps & worker model",
|
|
1638
1751
|
asks: "concurrency, spend, turn base and extension ceiling, wall clock, failed attempts, continuations — then the worker model",
|
|
1639
|
-
describe: (p) => {
|
|
1640
|
-
const c = resolveCaps(p,
|
|
1752
|
+
describe: (p, defaults = DEFAULT_CAPS) => {
|
|
1753
|
+
const c = resolveCaps(p, defaults);
|
|
1641
1754
|
const answered = Object.keys(p.caps).length > 0;
|
|
1642
1755
|
const spend =
|
|
1643
1756
|
c.dailySpendUsd === null ? "no spend cap" : `$${c.dailySpendUsd}/day`;
|
|
@@ -1664,7 +1777,7 @@ export const AMEND_AREAS: {
|
|
|
1664
1777
|
},
|
|
1665
1778
|
authority: {
|
|
1666
1779
|
name: "authority",
|
|
1667
|
-
asks: "who lands green PRs, who cuts releases, then one question per release/deploy shape",
|
|
1780
|
+
asks: "who lands green PRs, who cuts releases, who promotes, then one question per release/deploy shape",
|
|
1668
1781
|
describe: (p) => {
|
|
1669
1782
|
const grants = resolveReleaseGrants(p);
|
|
1670
1783
|
const granted = RELEASE_SHAPES.filter((shape) => grants[shape] === "orchestrator");
|
|
@@ -1673,7 +1786,8 @@ export const AMEND_AREAS: {
|
|
|
1673
1786
|
// set that mutates a running environment and the one #122 cost us. The full
|
|
1674
1787
|
// table is in `status` and in the amend summary.
|
|
1675
1788
|
return (
|
|
1676
|
-
`merge=${p.authority.merge}, release=${p.authority.release},
|
|
1789
|
+
`merge=${p.authority.merge}, release=${p.authority.release}, ` +
|
|
1790
|
+
`promote=${p.authority.promotion ?? DEFAULT_AUTHORITY.promotion}, tools: ` +
|
|
1677
1791
|
`${granted.length === 0 ? "all blocked" : `${granted.length}/${RELEASE_SHAPES.length} granted`}` +
|
|
1678
1792
|
`, deploy=${grants.deploy}`
|
|
1679
1793
|
);
|
|
@@ -1742,10 +1856,10 @@ const AMEND_LABEL_MAX = 96;
|
|
|
1742
1856
|
* its own text alone — and it is the value, not the noun, that tells them
|
|
1743
1857
|
* whether this is the row they came for.
|
|
1744
1858
|
*/
|
|
1745
|
-
export function amendChoices(p: ProjectConfig): { id: AmendAreaId; label: string; description: string }[] {
|
|
1859
|
+
export function amendChoices(p: ProjectConfig, defaults: Caps = DEFAULT_CAPS): { id: AmendAreaId; label: string; description: string }[] {
|
|
1746
1860
|
return AMEND_AREA_IDS.map((id) => {
|
|
1747
1861
|
const area = AMEND_AREAS[id];
|
|
1748
|
-
const current = area.describe(p);
|
|
1862
|
+
const current = area.describe(p, defaults);
|
|
1749
1863
|
return {
|
|
1750
1864
|
id,
|
|
1751
1865
|
label: `${area.name} — ${current.length > AMEND_LABEL_MAX ? `${current.slice(0, AMEND_LABEL_MAX - 1).trimEnd()}…` : current}`,
|
|
@@ -1762,11 +1876,18 @@ export function amendChoices(p: ProjectConfig): { id: AmendAreaId; label: string
|
|
|
1762
1876
|
* mutation it authorises — creating labels, writing the config, replacing a
|
|
1763
1877
|
* brief — and a delta alone names none of them. What this adds is the sentence
|
|
1764
1878
|
* the operator is actually looking for: one area changed, everything else came
|
|
1765
|
-
* back off disk.
|
|
1879
|
+
* back off disk. Inherited caps on both sides of the "was"/"now" compare
|
|
1880
|
+
* against the same `defaults`, so a changed per-repo limit reads as a change
|
|
1881
|
+
* and a carried value reads as no change (#448).
|
|
1766
1882
|
*/
|
|
1767
|
-
export function summariseAmend(
|
|
1883
|
+
export function summariseAmend(
|
|
1884
|
+
area: AmendAreaId,
|
|
1885
|
+
before: ProjectConfig,
|
|
1886
|
+
a: SetupAnswers,
|
|
1887
|
+
defaults: Caps = DEFAULT_CAPS,
|
|
1888
|
+
): string {
|
|
1768
1889
|
const it = AMEND_AREAS[area];
|
|
1769
|
-
const was = it.describe(before);
|
|
1890
|
+
const was = it.describe(before, defaults);
|
|
1770
1891
|
// The brief is a decision, not a config key, so its "after" is what the wizard
|
|
1771
1892
|
// is about to do rather than what a rebuilt project would say.
|
|
1772
1893
|
const now =
|
|
@@ -1774,7 +1895,7 @@ export function summariseAmend(area: AmendAreaId, before: ProjectConfig, a: Setu
|
|
|
1774
1895
|
? a.writeOrchestratorBrief
|
|
1775
1896
|
? `would ${existsSync(orchestratorBriefPath(a)) ? "OVERWRITE" : "write"} ${orchestratorBriefPath(a)}`
|
|
1776
1897
|
: "not written — left exactly as it is"
|
|
1777
|
-
: it.describe(buildProject(a));
|
|
1898
|
+
: it.describe(buildProject(a), defaults);
|
|
1778
1899
|
|
|
1779
1900
|
const others = AMEND_AREA_IDS.filter((o) => o !== area).map((o) => AMEND_AREAS[o].name);
|
|
1780
1901
|
const lines = [`amending ${it.name} — project ${before.name}`];
|