omp-conductor 0.15.11 → 0.15.13
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/REFERENCE.md +107 -60
- package/package.json +1 -1
- package/schema/config.schema.json +3 -0
- package/src/briefs/orchestrator.md +64 -11
- package/src/briefs/policy.md +19 -3
- package/src/briefs/worker.md +11 -8
- package/src/cli.ts +41 -21
- package/src/commands/context.ts +102 -1
- package/src/commands/doctor.ts +4 -2
- package/src/commands/intake.ts +26 -5
- package/src/commands/message.ts +80 -32
- package/src/commands/report.ts +38 -2
- package/src/commands/restart.ts +81 -54
- package/src/commands/setup.ts +61 -11
- package/src/commands/stop.ts +45 -22
- package/src/commands/upgrade-rollback.ts +9 -0
- package/src/config-schema.ts +9 -0
- package/src/config.ts +35 -1
- package/src/daemon.ts +588 -37
- package/src/dashboard/app.js +398 -59
- package/src/dashboard/index.html +27 -0
- package/src/dashboard/server.ts +219 -5
- package/src/dashboard/style.css +169 -1
- package/src/doctor.ts +419 -45
- package/src/escalate.ts +8 -0
- package/src/failure-class.ts +37 -0
- package/src/fleet.ts +49 -2
- package/src/gitops.ts +157 -0
- package/src/lifecycle.ts +113 -2
- package/src/model-fallback.ts +177 -0
- package/src/omp.ts +115 -13
- package/src/orchestrator-down.ts +231 -0
- package/src/orchestrator-tick.ts +108 -5
- package/src/orchestrator.ts +18 -4
- package/src/privileged.ts +10 -0
- package/src/release-policy.ts +373 -28
- package/src/session-host.ts +11 -5
- package/src/setup-host.ts +665 -70
- package/src/setup-install.ts +275 -28
- package/src/setup-wizard.ts +339 -126
- package/src/setup.ts +25 -0
- package/src/stop-provenance.ts +66 -0
- package/src/store.ts +194 -1
- package/src/tracker/github.ts +47 -0
- package/src/types.ts +182 -0
- package/src/upgrade.ts +110 -32
- package/src/verbs/protocol.ts +16 -3
- package/src/verbs/server.ts +27 -1
- package/src/wizard-ui.ts +261 -46
- package/src/worker.ts +24 -3
- package/systemd/omp-conductor.service.example +7 -3
package/src/commands/restart.ts
CHANGED
|
@@ -7,70 +7,97 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { CommandContext } from "./context.ts";
|
|
10
|
+
import { buildStopProvenance } from "../stop-provenance.ts";
|
|
10
11
|
import { setPaused } from "../daemon.ts";
|
|
11
|
-
import { restartDaemon, type RestartResult } from "../lifecycle.ts";
|
|
12
|
+
import { restartDaemon, type RestartResult, type StopDelivery } from "../lifecycle.ts";
|
|
13
|
+
import { openStore, dbPath } from "../store.ts";
|
|
12
14
|
import { DEFAULT_DEPS, drainAndRestart, type UpgradeDeps } from "../upgrade.ts";
|
|
13
15
|
|
|
14
16
|
export async function restartCommand(ctx: CommandContext): Promise<void> {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
project: ctx.projectFlag,
|
|
17
|
+
const store = openStore(dbPath());
|
|
18
|
+
try {
|
|
19
|
+
// Provenance (#378): one record naming the request, every configured
|
|
20
|
+
// project with its live-run count, and — via the lifecycle chokepoint — the
|
|
21
|
+
// delivery method used. The shared daemon serves all projects, so a restart
|
|
22
|
+
// is never silent even when the operator narrows nothing.
|
|
23
|
+
const provenance = buildStopProvenance({
|
|
24
|
+
controlPath: "cli restart",
|
|
25
|
+
reason: "operator restart",
|
|
26
|
+
scope: "global",
|
|
26
27
|
});
|
|
27
|
-
|
|
28
|
-
process.stdout.write(
|
|
29
|
-
`stopped — pid ${previous.pid}${via === "systemctl" ? " (via systemctl)" : ""}\n`,
|
|
30
|
-
);
|
|
31
|
-
}
|
|
32
|
-
process.stdout.write(
|
|
33
|
-
`started — pid ${record.pid}, /healthz on :${record.port}` +
|
|
34
|
-
`${record.project === undefined ? "" : `, project ${record.project}`}` +
|
|
35
|
-
`${via === "systemctl" ? " (via systemctl)" : ""}\nlog ${record.logFile}\n`,
|
|
36
|
-
);
|
|
37
|
-
return;
|
|
38
|
-
}
|
|
28
|
+
const record = (signal: StopDelivery) => store.recordDaemonStop(signal.provenance);
|
|
39
29
|
|
|
40
|
-
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const deps: UpgradeDeps = {
|
|
50
|
-
...DEFAULT_DEPS,
|
|
51
|
-
setPaused: (v, project) =>
|
|
52
|
-
setPaused(v, { source: "restart", reason: "restart, draining" }, project),
|
|
53
|
-
restartDaemon: async () => {
|
|
54
|
-
result = await restartDaemon({
|
|
30
|
+
if (ctx.argv.includes("--now")) {
|
|
31
|
+
// Inherit the running daemon's port and project: a restart that quietly
|
|
32
|
+
// moved to the default port would leave every existing health check
|
|
33
|
+
// pointing at nothing. When the unit owns the live pid, restartDaemon
|
|
34
|
+
// goes through systemctl so the replacement stays supervised; a failed
|
|
35
|
+
// installed unit is reset and started through systemd the same way and
|
|
36
|
+
// is never replaced by an unmanaged daemon. Success is only reported
|
|
37
|
+
// after the manager is proven to own the reported pid.
|
|
38
|
+
const { previous, record: newRecord, via } = await restartDaemon({
|
|
55
39
|
port: ctx.portFlag(),
|
|
56
40
|
project: ctx.projectFlag,
|
|
41
|
+
provenance,
|
|
42
|
+
record,
|
|
57
43
|
});
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
}
|
|
66
|
-
|
|
44
|
+
if (previous !== undefined) {
|
|
45
|
+
process.stdout.write(
|
|
46
|
+
`stopped — pid ${previous.pid}${via === "systemctl" ? " (via systemctl)" : ""}\n`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
process.stdout.write(
|
|
50
|
+
`started — pid ${newRecord.pid}, /healthz on :${newRecord.port}` +
|
|
51
|
+
`${newRecord.project === undefined ? "" : `, project ${newRecord.project}`}` +
|
|
52
|
+
`${via === "systemctl" ? " (via systemctl)" : ""}\nlog ${newRecord.logFile}\n`,
|
|
53
|
+
);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Default: drain the fleet before restarting — pause new claims, wait for
|
|
58
|
+
// live workers to reach 0/N (bounded by --timeout), restart, then restore
|
|
59
|
+
// the prior dispatch state. A timed-out drain restarts nothing and leaves
|
|
60
|
+
// dispatch paused, so it is safe to run while the daemon is wedged.
|
|
61
|
+
const timeoutRaw = ctx.flag("timeout");
|
|
62
|
+
const timeoutSeconds =
|
|
63
|
+
timeoutRaw === undefined ? 1800 : Number.parseInt(timeoutRaw, 10);
|
|
64
|
+
const timeoutMs = (Number.isNaN(timeoutSeconds) ? 1800 : timeoutSeconds) * 1000;
|
|
65
|
+
let result!: RestartResult;
|
|
66
|
+
const deps: UpgradeDeps = {
|
|
67
|
+
...DEFAULT_DEPS,
|
|
68
|
+
setPaused: (v, project) =>
|
|
69
|
+
setPaused(v, { source: "restart", reason: "restart, draining" }, project),
|
|
70
|
+
restartDaemon: async () => {
|
|
71
|
+
result = await restartDaemon({
|
|
72
|
+
port: ctx.portFlag(),
|
|
73
|
+
project: ctx.projectFlag,
|
|
74
|
+
provenance,
|
|
75
|
+
record,
|
|
76
|
+
});
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
// Record the request at ENTRY, before the drain: a caller that disconnects
|
|
80
|
+
// or hits --timeout while workers drain never reaches the lifecycle
|
|
81
|
+
// chokepoint, and the durable provenance must survive that — it is exactly
|
|
82
|
+
// the stop-with-no-witness incident #378 exists for (#378).
|
|
83
|
+
store.recordDaemonStop(provenance);
|
|
84
|
+
try {
|
|
85
|
+
await drainAndRestart(deps, { project: ctx.projectFlag, timeoutMs });
|
|
86
|
+
} catch (err) {
|
|
87
|
+
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
if (result.previous !== undefined) {
|
|
91
|
+
process.stdout.write(
|
|
92
|
+
`stopped — pid ${result.previous.pid}${result.via === "systemctl" ? " (via systemctl)" : ""}\n`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
67
95
|
process.stdout.write(
|
|
68
|
-
`
|
|
96
|
+
`started — pid ${result.record.pid}, /healthz on :${result.record.port}` +
|
|
97
|
+
`${result.record.project === undefined ? "" : `, project ${result.record.project}`}` +
|
|
98
|
+
`${result.via === "systemctl" ? " (via systemctl)" : ""}\nlog ${result.record.logFile}\n`,
|
|
69
99
|
);
|
|
100
|
+
} finally {
|
|
101
|
+
store.close();
|
|
70
102
|
}
|
|
71
|
-
process.stdout.write(
|
|
72
|
-
`started — pid ${result.record.pid}, /healthz on :${result.record.port}` +
|
|
73
|
-
`${result.record.project === undefined ? "" : `, project ${result.record.project}`}` +
|
|
74
|
-
`${result.via === "systemctl" ? " (via systemctl)" : ""}\nlog ${result.record.logFile}\n`,
|
|
75
|
-
);
|
|
76
103
|
}
|
package/src/commands/setup.ts
CHANGED
|
@@ -9,10 +9,11 @@
|
|
|
9
9
|
import type { CommandContext } from "./context.ts";
|
|
10
10
|
import { findProject, loadConfig, resolveCaps } from "../config.ts";
|
|
11
11
|
import { AMEND_AREA_IDS, type AmendAreaId } from "../setup.ts";
|
|
12
|
-
import { runGraphInstall, runHostInstall } from "../setup-install.ts";
|
|
12
|
+
import { runGraphInstall, runHostInstall, type InstallOutcome } from "../setup-install.ts";
|
|
13
13
|
import { DEFAULT_PROBES, NO_PROBES, setup } from "../setup-wizard.ts";
|
|
14
14
|
import { telegramStateDir } from "../fleet.ts";
|
|
15
15
|
import { terminalUi } from "../wizard-ui.ts";
|
|
16
|
+
import type { ConductorConfig, ProjectConfig } from "../types.ts";
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* The setup verb's own usage, printed for `setup --help` / `setup -h` before
|
|
@@ -25,18 +26,43 @@ and the staged host files behind one confirm.
|
|
|
25
26
|
|
|
26
27
|
usage:
|
|
27
28
|
omp-conductor setup [area] [--no-ai] [--project NAME]
|
|
29
|
+
omp-conductor setup host [NAME] [--project NAME]
|
|
30
|
+
omp-conductor setup graph [--no-seed] [--print] [--project NAME]
|
|
28
31
|
|
|
29
32
|
Bare setup runs the full interview — or, when the project already exists,
|
|
30
33
|
asks which area to amend. Naming an area positionally skips that chooser and
|
|
31
|
-
amends only that area.
|
|
34
|
+
amends only that area. \`setup host\` and \`setup graph\` are install
|
|
35
|
+
subcommands, not areas: the NAME (or --project NAME) says which project to
|
|
36
|
+
install for.
|
|
32
37
|
|
|
33
38
|
amend areas:
|
|
34
39
|
${AMEND_AREA_IDS.join(", ")}
|
|
35
40
|
|
|
36
41
|
flags:
|
|
37
|
-
--project NAME the project to configure or amend
|
|
42
|
+
--project NAME the project to configure or amend (or NAME positionally
|
|
43
|
+
for \`setup host\`)
|
|
38
44
|
--no-ai ask every question, propose nothing (no AI repo reads)`;
|
|
39
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Resolve the project an install subcommand (`setup host`, `setup graph`)
|
|
48
|
+
* installs for — the flag, or the positional form an operator naturally
|
|
49
|
+
* types on a multi-project host (`setup host conductor`). The pair may not
|
|
50
|
+
* disagree, and an unknown name fails loudly as "Unknown project …" rather
|
|
51
|
+
* than being silently ignored (#514).
|
|
52
|
+
*/
|
|
53
|
+
export function setupInstallProject(
|
|
54
|
+
cfg: ConductorConfig,
|
|
55
|
+
projectFlag: string | undefined,
|
|
56
|
+
positionalName: string | undefined,
|
|
57
|
+
): ProjectConfig {
|
|
58
|
+
if (projectFlag !== undefined && positionalName !== undefined && projectFlag !== positionalName) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`setup names two different projects (--project ${projectFlag} and ${positionalName}) — pass one`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
return findProject(cfg, projectFlag ?? positionalName);
|
|
64
|
+
}
|
|
65
|
+
|
|
40
66
|
export async function setupCommand(ctx: CommandContext): Promise<void> {
|
|
41
67
|
// Help first, and only in the first trailing position: a help request
|
|
42
68
|
// must never open a UI, read config, probe GitHub or pause dispatch.
|
|
@@ -58,14 +84,38 @@ try {
|
|
|
58
84
|
// is how `setup host` came to exit 2 as an "unknown setup area".
|
|
59
85
|
if (positional === "host" || positional === "graph") {
|
|
60
86
|
const cfg = loadConfig();
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
87
|
+
// `setup host <name>`: the project an operator would naturally type.
|
|
88
|
+
// The positional only ever names a project; anything else that does not
|
|
89
|
+
// look like a flag is refused by findProject below rather than being
|
|
90
|
+
// quietly ignored.
|
|
91
|
+
const positionalName =
|
|
92
|
+
positional === "host" && ctx.argv[2] !== undefined && !ctx.argv[2].startsWith("--")
|
|
93
|
+
? ctx.argv[2]
|
|
94
|
+
: undefined;
|
|
95
|
+
let outcome: InstallOutcome;
|
|
96
|
+
if (positional === "host") {
|
|
97
|
+
// No `--project` and no positional → a host-global install (#530): the
|
|
98
|
+
// units are host-global, so a multi-project host installs them with no
|
|
99
|
+
// name and skips the per-project tail (tick config, brief link) with a
|
|
100
|
+
// named note; a name resolves and wires the tail.
|
|
101
|
+
const project =
|
|
102
|
+
ctx.projectFlag === undefined && positionalName === undefined
|
|
103
|
+
? undefined
|
|
104
|
+
: setupInstallProject(cfg, ctx.projectFlag, positionalName);
|
|
105
|
+
outcome = await runHostInstall(
|
|
106
|
+
project,
|
|
107
|
+
project === undefined ? cfg.defaults : resolveCaps(project, cfg.defaults),
|
|
108
|
+
telegramStateDir(),
|
|
109
|
+
ui,
|
|
110
|
+
);
|
|
111
|
+
} else {
|
|
112
|
+
// `setup graph` is inherently per-project repositories; there is no
|
|
113
|
+
// host-global graph to install, so a project must resolve.
|
|
114
|
+
outcome = await runGraphInstall(findProject(cfg, ctx.projectFlag), ui, {
|
|
115
|
+
noSeed: ctx.argv.includes("--no-seed"),
|
|
116
|
+
print: ctx.argv.includes("--print"),
|
|
117
|
+
});
|
|
118
|
+
}
|
|
69
119
|
// `staged` is a success on a host that has no systemd: the files are
|
|
70
120
|
// real, only the enable step is impossible.
|
|
71
121
|
if (outcome.kind === "refused" || outcome.kind === "failed") process.exit(1);
|
package/src/commands/stop.ts
CHANGED
|
@@ -7,8 +7,10 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { CommandContext } from "./context.ts";
|
|
10
|
+
import { buildStopProvenance } from "../stop-provenance.ts";
|
|
10
11
|
import { hold, pinPaneHalt, stopConductorPane } from "../fleet.ts";
|
|
11
12
|
import { stopDaemon } from "../lifecycle.ts";
|
|
13
|
+
import { openStore, dbPath } from "../store.ts";
|
|
12
14
|
|
|
13
15
|
export async function stopCommand(ctx: CommandContext): Promise<void> {
|
|
14
16
|
const withPane = ctx.argv.includes("--pane");
|
|
@@ -24,28 +26,49 @@ const targets = ctx.targetProjects().map((project) => {
|
|
|
24
26
|
pin: withPane ? pinPaneHalt(project.name).path : undefined,
|
|
25
27
|
};
|
|
26
28
|
});
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
29
|
+
const store = openStore(dbPath());
|
|
30
|
+
try {
|
|
31
|
+
// Provenance (#378): the shared daemon serves every configured project, so
|
|
32
|
+
// the record names the request's own project (or the global scope) AND every
|
|
33
|
+
// sibling with its live-run count — no silent cross-project stop.
|
|
34
|
+
const provenance = buildStopProvenance({
|
|
35
|
+
controlPath: "cli stop",
|
|
36
|
+
reason: "operator stop",
|
|
37
|
+
scope: targets.length > 1 ? "global" : "project",
|
|
38
|
+
project: targets.length === 1 ? targets[0]!.project.name : undefined,
|
|
39
|
+
});
|
|
40
|
+
const stop = await stopDaemon({
|
|
41
|
+
provenance,
|
|
42
|
+
// The durable row is written by the lifecycle at the exact chokepoint —
|
|
43
|
+
// immediately before systemctl/SIGTERM, with the delivery method that is
|
|
44
|
+
// about to be used — which is what makes "provenance precedes signalling"
|
|
45
|
+
// provable rather than claimed.
|
|
46
|
+
record: (signal) => store.recordDaemonStop(signal.provenance),
|
|
47
|
+
});
|
|
48
|
+
const stopLine =
|
|
49
|
+
stop.kind === "not-running"
|
|
50
|
+
? "daemon was not running"
|
|
51
|
+
: `daemon stopped — pid ${stop.pid}${stop.via === "systemctl" ? " (via systemctl)" : ""}`;
|
|
52
|
+
for (const target of targets) {
|
|
53
|
+
if (target.pin !== undefined) {
|
|
54
|
+
const pane = await stopConductorPane(target.project.name);
|
|
55
|
+
process.stdout.write(
|
|
56
|
+
`stopped — claiming paused; ticks disarmed at ${target.hold.disarmed.path}\n` +
|
|
57
|
+
`${stopLine}\n` +
|
|
58
|
+
`pane recovery pinned at ${target.pin}\n` +
|
|
59
|
+
`pane stop: ${pane.stopped} — ${pane.detail}\n` +
|
|
60
|
+
` (conductor agent "${pane.agentName}" only — herdr-fleet.service was NOT stopped;\n` +
|
|
61
|
+
` resume clears the pin when you want recovery again)\n`,
|
|
62
|
+
);
|
|
63
|
+
} else {
|
|
64
|
+
process.stdout.write(
|
|
65
|
+
`stopped — claiming paused; ticks disarmed at ${target.hold.disarmed.path}\n` +
|
|
66
|
+
`${stopLine}\n` +
|
|
67
|
+
`pane left running (pass --pane to stop the conductor agent and pin recovery)\n`,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
49
70
|
}
|
|
71
|
+
} finally {
|
|
72
|
+
store.close();
|
|
50
73
|
}
|
|
51
74
|
}
|
|
@@ -13,6 +13,15 @@ import type { CommandContext } from "./context.ts";
|
|
|
13
13
|
import { rollbackFromJournal } from "../upgrade.ts";
|
|
14
14
|
|
|
15
15
|
export async function upgradeRollbackCommand(ctx: CommandContext): Promise<void> {
|
|
16
|
+
// Nothing here varies by project (#514): the rollback restores every
|
|
17
|
+
// surface the failed install touched, from the host-wide journal. The
|
|
18
|
+
// flag only ever misled, so refuse it with a reason.
|
|
19
|
+
if (ctx.projectFlag !== undefined) {
|
|
20
|
+
process.stderr.write(
|
|
21
|
+
`omp-conductor: upgrade-rollback does not take a project — it restores the whole host from the upgrade journal\n`,
|
|
22
|
+
);
|
|
23
|
+
process.exit(2);
|
|
24
|
+
}
|
|
16
25
|
const result = await rollbackFromJournal();
|
|
17
26
|
process.stdout.write(
|
|
18
27
|
`rolled back to omp-conductor@${result.restoredVersion} after the failed ` +
|
package/src/config-schema.ts
CHANGED
|
@@ -316,12 +316,21 @@ const projectSchema = z
|
|
|
316
316
|
routing: routingSchema.optional(),
|
|
317
317
|
caps: capsSchema.optional(),
|
|
318
318
|
workerModel: z.unknown().optional(),
|
|
319
|
+
// The loader normalises both of these (trimmed entries / positive integer),
|
|
320
|
+
// so the schema admits any shape and lets the normaliser decide what is
|
|
321
|
+
// usable, exactly like `workerModel`.
|
|
322
|
+
modelFallbacks: z.unknown().optional(),
|
|
323
|
+
modelFallbackThreshold: z.unknown().optional(),
|
|
319
324
|
escalation: escalationSchema.optional(),
|
|
320
325
|
authority: authoritySchema.optional(),
|
|
321
326
|
releasePolicy: releasePolicySchema.optional(),
|
|
322
327
|
policy: projectPolicySchema.optional(),
|
|
323
328
|
recoveryMerges: recoveryMergesSchema.optional(),
|
|
324
329
|
reporting: reportingSchema.optional(),
|
|
330
|
+
// Hand-edited safety markers (commit SHAs or refs) a preserved continuation
|
|
331
|
+
// branch must contain before it may be reattached; the loader normalises
|
|
332
|
+
// them like `modelFallbacks`.
|
|
333
|
+
criticalBase: z.unknown().optional(),
|
|
325
334
|
// Roots silently fall back to the default when unusable, so the schema
|
|
326
335
|
// admits any shape and the normaliser picks the usable path.
|
|
327
336
|
workspaceRoot: z.unknown().optional(),
|
package/src/config.ts
CHANGED
|
@@ -355,8 +355,11 @@ export function findProject(c: ConductorConfig, name?: string): ProjectConfig {
|
|
|
355
355
|
if (name === undefined) {
|
|
356
356
|
const only = c.projects[0];
|
|
357
357
|
if (c.projects.length !== 1 || only === undefined) {
|
|
358
|
+
// #514: every operator hitting this has to guess the spelling of the
|
|
359
|
+
// flag that names a project — say it outright. Doctor's project finding
|
|
360
|
+
// quotes this text, so keep the "name one explicitly" phrase.
|
|
358
361
|
throw new Error(
|
|
359
|
-
`Ambiguous project: config has ${c.projects.length} projects (${names.join(", ") || "none"}) — name one explicitly.`,
|
|
362
|
+
`Ambiguous project: config has ${c.projects.length} projects (${names.join(", ") || "none"}) — name one explicitly with --project NAME.`,
|
|
360
363
|
);
|
|
361
364
|
}
|
|
362
365
|
return only;
|
|
@@ -1080,6 +1083,34 @@ function finalizeProject(
|
|
|
1080
1083
|
const workerModel =
|
|
1081
1084
|
typeof rawWorkerModel === "string" && rawWorkerModel.trim() !== "" ? rawWorkerModel : undefined;
|
|
1082
1085
|
|
|
1086
|
+
// The failover chain is a hint like workerModel, not a ceiling: an unusable
|
|
1087
|
+
// entry or a malformed threshold is dropped rather than failing the load,
|
|
1088
|
+
// and absent/empty `modelFallbacks` keeps today's dispatch byte for byte.
|
|
1089
|
+
const rawFallbacks = p["modelFallbacks"];
|
|
1090
|
+
const modelFallbacks = Array.isArray(rawFallbacks)
|
|
1091
|
+
? rawFallbacks
|
|
1092
|
+
.filter((entry): entry is string => typeof entry === "string")
|
|
1093
|
+
.map((entry) => entry.trim())
|
|
1094
|
+
.filter((entry) => entry !== "")
|
|
1095
|
+
: [];
|
|
1096
|
+
const rawThreshold = p["modelFallbackThreshold"];
|
|
1097
|
+
const modelFallbackThreshold =
|
|
1098
|
+
typeof rawThreshold === "number" && Number.isInteger(rawThreshold) && rawThreshold >= 1
|
|
1099
|
+
? rawThreshold
|
|
1100
|
+
: undefined;
|
|
1101
|
+
|
|
1102
|
+
// Marked critical-base/safety markers (commit SHAs or refs). A continuation
|
|
1103
|
+
// branch must contain every one before the dispatcher may reattach it;
|
|
1104
|
+
// unusable entries are dropped like `modelFallbacks` and an absent or empty
|
|
1105
|
+
// list keeps today's behaviour byte for byte.
|
|
1106
|
+
const rawCriticalBase = p["criticalBase"];
|
|
1107
|
+
const criticalBase = Array.isArray(rawCriticalBase)
|
|
1108
|
+
? rawCriticalBase
|
|
1109
|
+
.filter((entry): entry is string => typeof entry === "string")
|
|
1110
|
+
.map((entry) => entry.trim())
|
|
1111
|
+
.filter((entry) => entry !== "")
|
|
1112
|
+
: [];
|
|
1113
|
+
|
|
1083
1114
|
if (problems.length > before) return undefined;
|
|
1084
1115
|
|
|
1085
1116
|
return {
|
|
@@ -1095,12 +1126,15 @@ function finalizeProject(
|
|
|
1095
1126
|
routing: { labelPrefix, repos },
|
|
1096
1127
|
caps,
|
|
1097
1128
|
...(workerModel === undefined ? {} : { workerModel }),
|
|
1129
|
+
...(modelFallbacks.length === 0 ? {} : { modelFallbacks }),
|
|
1130
|
+
...(modelFallbackThreshold === undefined ? {} : { modelFallbackThreshold }),
|
|
1098
1131
|
escalation,
|
|
1099
1132
|
authority,
|
|
1100
1133
|
releasePolicy,
|
|
1101
1134
|
policy,
|
|
1102
1135
|
...(recoveryMerges === undefined ? {} : { recoveryMerges }),
|
|
1103
1136
|
reporting,
|
|
1137
|
+
...(criticalBase.length === 0 ? {} : { criticalBase }),
|
|
1104
1138
|
workspaceRoot: expandHome(pickString(p["workspaceRoot"], defaultWorkspaceRoot())),
|
|
1105
1139
|
mirrorRoot: expandHome(pickString(p["mirrorRoot"], defaultMirrorRoot())),
|
|
1106
1140
|
};
|