omp-conductor 0.4.2 → 0.4.4
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 +189 -50
- package/package.json +1 -1
- package/src/board.ts +6 -1
- package/src/brief-upgrade.ts +42 -105
- package/src/briefs/orchestrator.md +51 -13
- package/src/briefs/policy.md +6 -2
- package/src/cli.ts +147 -30
- package/src/config.ts +23 -52
- package/src/confinement.ts +22 -472
- package/src/daemon.ts +391 -9
- package/src/decisions.ts +164 -0
- package/src/failure-class.ts +172 -0
- package/src/fleet.ts +192 -51
- package/src/omp.ts +8 -54
- package/src/orchestrator-tick.ts +33 -0
- package/src/plugin.ts +9 -20
- package/src/session-host.ts +10 -22
- package/src/setup.ts +22 -4
- package/src/store.ts +230 -4
- package/src/tracker/github.ts +188 -5
- package/src/types.ts +183 -13
- package/src/upgrade.ts +27 -3
|
@@ -267,6 +267,15 @@ Not yours to relax:
|
|
|
267
267
|
redo the merge costs a whole attempt to buy what one call does in minutes — do
|
|
268
268
|
not. Bypassing branch protection with admin rights is still forbidden;
|
|
269
269
|
updating the branch is how you satisfy it, not how you dodge it.
|
|
270
|
+
When `conductor_pr_update_branch` cannot resolve it — a real conflict, or a
|
|
271
|
+
generated file a server-side merge cannot recompute — and the run has settled
|
|
272
|
+
so no worker is coming, repairing the branch from a **fresh clone** is yours
|
|
273
|
+
and is expected: clone outside every worktree, merge the base, resolve,
|
|
274
|
+
regenerate what must be regenerated, verify locally, and push without force.
|
|
275
|
+
Take the commit identity from the repository's own history; never invent one.
|
|
276
|
+
The prohibition is the worktree, the force-push and the fabricated identity —
|
|
277
|
+
not the branch. A settled run means nobody else is coming:
|
|
278
|
+
waiting is not caution, it is a stall.
|
|
270
279
|
|
|
271
280
|
## Your verb surface
|
|
272
281
|
|
|
@@ -297,16 +306,21 @@ to read the refusal instead of retrying in a loop.
|
|
|
297
306
|
the SHA that produced it. Never work around one: there is no path around it, and
|
|
298
307
|
the attempt is in the ledger.
|
|
299
308
|
|
|
300
|
-
**
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
309
|
+
**Your file tools are not gated — the workers' are.** Every worker session runs
|
|
310
|
+
under a mechanical worktree gate and, under `credentials.isolation: "per-run"`,
|
|
311
|
+
its own OS principal. Your structured tools are gated by nothing, and that is a
|
|
312
|
+
deliberate operator decision rather than an oversight. What holds YOUR
|
|
313
|
+
boundaries is this brief, the verb ledger every `conductor_*` call writes, and
|
|
314
|
+
the operator reading both. Never read or edit a worker's checkout or the mirror
|
|
315
|
+
cache — when you need a run's code, read its PR. Unconfined means auditable, not
|
|
316
|
+
licensed.
|
|
317
|
+
|
|
318
|
+
One caveat, so you never assume more protection than you have: if this daemon
|
|
319
|
+
started you *and* the fleet runs an isolating credentials mechanism, you are
|
|
320
|
+
also a separate OS principal that genuinely cannot reach a run checkout, the
|
|
321
|
+
mirror or the host credential — `omp-conductor status` names that mechanism on
|
|
322
|
+
its `boundary` line. Started by your operator, or on a host that reports
|
|
323
|
+
`unprotected`, nothing stops those reads but the paragraph above.
|
|
310
324
|
|
|
311
325
|
**Your own** merge and release authority is not decided here, and not by this
|
|
312
326
|
brief either. It is whatever your operator granted at setup time, and the
|
|
@@ -367,9 +381,7 @@ The protocol, in order:
|
|
|
367
381
|
`telegram_ask` is a delivery failure, not an operator answer. Re-deliver the
|
|
368
382
|
question with `telegram_send`, or report the channel as broken. Never infer
|
|
369
383
|
rejection or “not now” from failed delivery. On an explicit “not now”, park
|
|
370
|
-
it
|
|
371
|
-
`pending amendment: <one-liner> — say 'apply it' or 'drop it'`, never re-open
|
|
372
|
-
the yes/no dialog, and drop it if still unanswered after 7 days.
|
|
384
|
+
it in the ledger rather than in your head — see below.
|
|
373
385
|
4. **Log it.** Append one line to **Amendments** at the bottom of `POLICY.md`:
|
|
374
386
|
the date, what triggered it, a one-sentence summary.
|
|
375
387
|
5. **Offer general fixes upstream.** Ask one question of the amendment you just
|
|
@@ -381,6 +393,32 @@ The protocol, in order:
|
|
|
381
393
|
diff and the incident that triggered it. File it only when your operator says
|
|
382
394
|
yes — it is their name on the account.
|
|
383
395
|
|
|
396
|
+
**Every question you put to your operator is recorded, not remembered.** The
|
|
397
|
+
moment you send one — an amendment, a tier-2 decision, anything you are waiting
|
|
398
|
+
on an answer for — write it down:
|
|
399
|
+
|
|
400
|
+
```
|
|
401
|
+
omp-conductor decision open --question "<the question as you sent it>" \
|
|
402
|
+
[--blocks "<what is waiting on it>"] [--resolves-when <condition>]
|
|
403
|
+
omp-conductor decision resolve <id> --answer "<what they decided>"
|
|
404
|
+
omp-conductor decision withdraw <id> --reason "<why it stopped mattering>"
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
Use `--resolves-when` whenever the answer only becomes actionable once something
|
|
408
|
+
observable happens: `pr-merged:<url>`, `issue-closed:<n>`,
|
|
409
|
+
`npm-version:<pkg>@<version>`. The daemon checks it for you and flags the row as
|
|
410
|
+
`[CONDITION MET — act on this now]` in your tick digest, so a parked question
|
|
411
|
+
wakes up on its own instead of waiting for you to think of it.
|
|
412
|
+
|
|
413
|
+
**Trust the digest, never your recollection.** Every tick's prompt lists what is
|
|
414
|
+
still open. A question that exists only in your context is gone at the next
|
|
415
|
+
compaction, and you will either ask it twice or drop it silently — both of which
|
|
416
|
+
look, to your operator, like a fleet that does not follow up. Resolve or withdraw
|
|
417
|
+
each row explicitly; expiry is enforced for you at seven days, so an unanswered
|
|
418
|
+
question closes itself rather than accumulating. Re-mention a parked item once in
|
|
419
|
+
your next report as `pending amendment: <one-liner> — say 'apply it' or 'drop
|
|
420
|
+
it'`, and never re-open the yes/no dialog.
|
|
421
|
+
|
|
384
422
|
Two limits. You never propose relaxing **Hard boundaries** — that section changes
|
|
385
423
|
only in the shipped package floor, never via this loop. And at most one proposal
|
|
386
424
|
per tick: an amendment waits for the three duties to finish, it never interrupts
|
package/src/briefs/policy.md
CHANGED
|
@@ -59,12 +59,16 @@ and spot issues that would collide. Ask an omp session to read
|
|
|
59
59
|
|
|
60
60
|
## Reporting
|
|
61
61
|
|
|
62
|
-
Your report scope is **`{{REPORT_SCOPE}}`**.
|
|
62
|
+
Your report scope is **`{{REPORT_SCOPE}}`**. All three scopes, spelled out:
|
|
63
63
|
|
|
64
64
|
- **`escalations`** — you speak when a human is needed, and once a day otherwise.
|
|
65
65
|
That is: every tier-2 escalation immediately, carrying the issue link and the
|
|
66
66
|
single question; plus one daily digest naming what merged, what is green and
|
|
67
67
|
waiting on a merge, and what is stuck and why. Every other tick is silent.
|
|
68
|
+
- **`decisions`** — tier-2 escalations immediately, and a fleet-stopping
|
|
69
|
+
condition immediately; every other material event is held and delivered as one
|
|
70
|
+
message with the next tick report. A merge at 10:04 and a green PR at 10:09
|
|
71
|
+
arrive together at the 10:15 tick, not as two pings.
|
|
68
72
|
- **`material`** — everything in `escalations`, plus each material event as it
|
|
69
73
|
happens: a run reaching a green PR (with the link), a run that failed twice, an
|
|
70
74
|
issue you pulled off the queue, a cap that stopped the fleet. A tick where
|
|
@@ -102,7 +106,7 @@ leaves no record that anything went out. And a `cancelled` or errored
|
|
|
102
106
|
`telegram_send`, or report the channel as broken. It is never "asked once, no
|
|
103
107
|
reply, dropped".
|
|
104
108
|
|
|
105
|
-
|
|
109
|
+
No scope licenses narration. No progress updates, no "checking the queue
|
|
106
110
|
now", no restating this brief back. Evidence, or silence.
|
|
107
111
|
|
|
108
112
|
## Amendments
|
package/src/cli.ts
CHANGED
|
@@ -11,18 +11,18 @@ import { dirname, join } from "node:path";
|
|
|
11
11
|
import { runBoard } from "./board.ts";
|
|
12
12
|
import {
|
|
13
13
|
applyRetrofit,
|
|
14
|
-
|
|
15
|
-
formatBriefStatus,
|
|
14
|
+
formatBriefReport,
|
|
16
15
|
formatMigrateResult,
|
|
17
16
|
formatRetrofitProposal,
|
|
18
17
|
formatRetrofitRefusal,
|
|
19
18
|
inspectBriefLayout,
|
|
20
19
|
migrateToPolicy,
|
|
20
|
+
missingSections,
|
|
21
21
|
proposeRetrofit,
|
|
22
22
|
repairPolicyBannerCrumbs,
|
|
23
|
-
writeMergedBrief,
|
|
24
23
|
} from "./brief-upgrade.ts";
|
|
25
24
|
import { findProject, loadConfig, resolveCaps, sharedRoot, stateDir } from "./config.ts";
|
|
25
|
+
import { CONDITION_FORMS, parseCondition } from "./decisions.ts";
|
|
26
26
|
import { boundarySetupScript } from "./credentials.ts";
|
|
27
27
|
import { runDaemon, setPaused } from "./daemon.ts";
|
|
28
28
|
import {
|
|
@@ -105,9 +105,14 @@ usage:
|
|
|
105
105
|
omp-conductor pause
|
|
106
106
|
omp-conductor resume
|
|
107
107
|
omp-conductor graph-setup [--project NAME] [--write]
|
|
108
|
+
omp-conductor boundary-setup [--slots N] [--daemon-user USER] [--shared-root PATH]
|
|
108
109
|
omp-conductor brief-upgrade [--migrate|--retrofit] [--apply] [--file PATH] [--project NAME]
|
|
109
110
|
omp-conductor friction <escalation-digest|report-noise|report-surprise> --detail TEXT [--issue N] [--project NAME]
|
|
110
111
|
omp-conductor report --text TEXT [--kind material|digest] [--project NAME]
|
|
112
|
+
omp-conductor decision open --question TEXT [--blocks TEXT] [--resolves-when COND] [--project NAME]
|
|
113
|
+
omp-conductor decision resolve <id> --answer TEXT [--project NAME]
|
|
114
|
+
omp-conductor decision withdraw <id> [--reason TEXT] [--project NAME]
|
|
115
|
+
omp-conductor decision list [--project NAME]
|
|
111
116
|
omp-conductor help
|
|
112
117
|
|
|
113
118
|
upgrade update the Bun-global CLI, omp plugin, Herdr recovery plugin, and
|
|
@@ -171,6 +176,17 @@ usage:
|
|
|
171
176
|
at-least-once: a crash mid-send is retried and the retry says it may
|
|
172
177
|
be a repeat. --kind digest is accepted at most once per local day,
|
|
173
178
|
decided from the ledger rather than from what you remember sending.
|
|
179
|
+
decision record, list and close the questions you have put to your operator.
|
|
180
|
+
A question that lives only in a session's context is lost to the next
|
|
181
|
+
compaction, so \`decision open\` writes it down and every tick's prompt
|
|
182
|
+
carries what is still owed. \`--resolves-when\` attaches a condition the
|
|
183
|
+
daemon checks for you (pr-merged:<url>, issue-closed:<n>,
|
|
184
|
+
npm-version:<pkg>@<version>); a met one is flagged in that digest.
|
|
185
|
+
Unanswered rows expire after seven days rather than accumulating.
|
|
186
|
+
decision open --question TEXT [--blocks TEXT] [--resolves-when COND]
|
|
187
|
+
decision resolve <id> --answer TEXT
|
|
188
|
+
decision withdraw <id> [--reason TEXT]
|
|
189
|
+
decision list
|
|
174
190
|
friction record a bounded observation the daemon cannot classify itself:
|
|
175
191
|
an escalation that belonged in a digest, or a tick report that was
|
|
176
192
|
noise/surprising. Repeated observations feed the existing Learning
|
|
@@ -186,14 +202,23 @@ usage:
|
|
|
186
202
|
current. --write writes the two units and the script they run, and
|
|
187
203
|
prints the systemctl line to run — it never runs systemctl itself.
|
|
188
204
|
Exits 1 when no repo in the project has graphProject configured.
|
|
205
|
+
boundary-setup
|
|
206
|
+
print the root commands that provision the credential boundary
|
|
207
|
+
(#125): the per-run slot accounts, the conductor-daemon and
|
|
208
|
+
conductor-runs groups, the 0711 shared root, and the daemon home's
|
|
209
|
+
mode. Prints; never runs them — it creates system accounts, so it is
|
|
210
|
+
your sudo and your chance to read it first. Needs no config, and
|
|
211
|
+
must run BEFORE setup, which writes worktree and mirror paths into
|
|
212
|
+
the root this creates. --slots defaults to maxConcurrentWorkers.
|
|
189
213
|
brief-upgrade
|
|
190
214
|
inspect the brief overlay (package floor + POLICY.md). Reports by
|
|
191
215
|
default. --migrate lifts a bannered ORCHESTRATOR.md owned half into
|
|
192
216
|
POLICY.md and recomposes. --retrofit proposes inserting the YOURS TO
|
|
193
217
|
EDIT banner before the first Releases/Project context/Reporting/
|
|
194
|
-
Amendments heading (#20); --retrofit --apply writes it.
|
|
195
|
-
|
|
196
|
-
brief that is not where the wizard
|
|
218
|
+
Amendments heading (#20); --retrofit --apply writes it. --apply only
|
|
219
|
+
ever confirms one of those two — the legacy single-file merge was
|
|
220
|
+
removed in 0.4.3. --file checks a brief that is not where the wizard
|
|
221
|
+
would have put it.
|
|
197
222
|
help print this text (also --help, -h).
|
|
198
223
|
--version
|
|
199
224
|
print the installed omp-conductor package version (also -V, version).
|
|
@@ -804,6 +829,98 @@ try {
|
|
|
804
829
|
break;
|
|
805
830
|
}
|
|
806
831
|
|
|
832
|
+
case "decision": {
|
|
833
|
+
const sub = argv[1];
|
|
834
|
+
const project = findProject(loadConfig(), flag(argv, "project"));
|
|
835
|
+
const store = openStore(dbPath());
|
|
836
|
+
try {
|
|
837
|
+
if (sub === "open") {
|
|
838
|
+
const question = flag(argv, "question")?.trim();
|
|
839
|
+
if (question === undefined || question.length === 0 || question.startsWith("--")) {
|
|
840
|
+
process.stderr.write(
|
|
841
|
+
"omp-conductor: decision open needs --question with the question you sent\n",
|
|
842
|
+
);
|
|
843
|
+
process.exit(2);
|
|
844
|
+
}
|
|
845
|
+
const condition = flag(argv, "resolves-when")?.trim();
|
|
846
|
+
if (condition !== undefined && parseCondition(condition) === undefined) {
|
|
847
|
+
process.stderr.write(
|
|
848
|
+
`omp-conductor: --resolves-when must be one of:\n${CONDITION_FORMS.map((f) => ` ${f}`).join("\n")}\n`,
|
|
849
|
+
);
|
|
850
|
+
process.exit(2);
|
|
851
|
+
}
|
|
852
|
+
const blocks = flag(argv, "blocks")?.trim();
|
|
853
|
+
const decision = store.createDecision({
|
|
854
|
+
project: project.name,
|
|
855
|
+
question,
|
|
856
|
+
...(blocks === undefined || blocks.length === 0 ? {} : { blocks }),
|
|
857
|
+
...(condition === undefined ? {} : { condition }),
|
|
858
|
+
at: Date.now(),
|
|
859
|
+
});
|
|
860
|
+
process.stdout.write(
|
|
861
|
+
`decision ${decision.id} recorded — resolve with: omp-conductor decision resolve ${decision.id} --answer "..."\n`,
|
|
862
|
+
);
|
|
863
|
+
break;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
if (sub === "resolve" || sub === "withdraw") {
|
|
867
|
+
const id = argv[2];
|
|
868
|
+
if (id === undefined || id.startsWith("--")) {
|
|
869
|
+
process.stderr.write(`omp-conductor: decision ${sub} needs the decision id\n`);
|
|
870
|
+
process.exit(2);
|
|
871
|
+
}
|
|
872
|
+
const answered = sub === "resolve";
|
|
873
|
+
const text = answered ? flag(argv, "answer")?.trim() : flag(argv, "reason")?.trim();
|
|
874
|
+
if (answered && (text === undefined || text.length === 0)) {
|
|
875
|
+
process.stderr.write("omp-conductor: decision resolve needs --answer with what was decided\n");
|
|
876
|
+
process.exit(2);
|
|
877
|
+
}
|
|
878
|
+
const ok = store.resolveDecision(
|
|
879
|
+
id,
|
|
880
|
+
answered ? "answered" : "withdrawn",
|
|
881
|
+
text === undefined || text.length === 0 ? "withdrawn" : text,
|
|
882
|
+
Date.now(),
|
|
883
|
+
);
|
|
884
|
+
if (!ok) {
|
|
885
|
+
// A decision that is not open is a different mistake from an id that
|
|
886
|
+
// never existed, and the operator can only act on one of them.
|
|
887
|
+
process.stderr.write(
|
|
888
|
+
`omp-conductor: no open decision ${id} for ${project.name} — it was answered, withdrawn or expired, or the id is wrong\n`,
|
|
889
|
+
);
|
|
890
|
+
process.exit(1);
|
|
891
|
+
}
|
|
892
|
+
process.stdout.write(`decision ${id} ${answered ? "answered" : "withdrawn"}\n`);
|
|
893
|
+
break;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
if (sub === "list" || sub === undefined) {
|
|
897
|
+
const open = store.openDecisions(project.name);
|
|
898
|
+
if (open.length === 0) {
|
|
899
|
+
process.stdout.write("no open decisions\n");
|
|
900
|
+
break;
|
|
901
|
+
}
|
|
902
|
+
const now = Date.now();
|
|
903
|
+
for (const d of open) {
|
|
904
|
+
const condition =
|
|
905
|
+
d.condition === undefined ? "-" : d.conditionMetAt === undefined ? "pending" : "met";
|
|
906
|
+
const hours = Math.max(0, Math.round((now - d.askedAt) / 3_600_000));
|
|
907
|
+
process.stdout.write(
|
|
908
|
+
`${d.id} ${hours}h blocks:${d.blocks ?? "-"} condition:${condition} ${d.question}\n`,
|
|
909
|
+
);
|
|
910
|
+
}
|
|
911
|
+
break;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
process.stderr.write(
|
|
915
|
+
`omp-conductor: unknown decision subcommand "${sub}" — expected open, resolve, withdraw or list\n`,
|
|
916
|
+
);
|
|
917
|
+
process.exit(2);
|
|
918
|
+
} finally {
|
|
919
|
+
store.close();
|
|
920
|
+
}
|
|
921
|
+
break;
|
|
922
|
+
}
|
|
923
|
+
|
|
807
924
|
case "friction": {
|
|
808
925
|
const name = argv[1] as FrictionFeedbackName | undefined;
|
|
809
926
|
if (name === undefined || !Object.hasOwn(FRICTION_FEEDBACK_KINDS, name)) {
|
|
@@ -962,7 +1079,7 @@ try {
|
|
|
962
1079
|
if (layout.kind === "overlay") {
|
|
963
1080
|
if (!argv.includes("--apply")) {
|
|
964
1081
|
process.stdout.write(
|
|
965
|
-
`${
|
|
1082
|
+
`${formatBriefReport(path, layout, [])}\n\n` +
|
|
966
1083
|
"POLICY.md already present. --migrate --apply will strip any leading\n" +
|
|
967
1084
|
"banner-comment crumbs from POLICY.md and recompose ORCHESTRATOR.md.\n",
|
|
968
1085
|
);
|
|
@@ -981,7 +1098,7 @@ try {
|
|
|
981
1098
|
});
|
|
982
1099
|
if (repaired === undefined) {
|
|
983
1100
|
process.stdout.write(
|
|
984
|
-
`${
|
|
1101
|
+
`${formatBriefReport(path, layout, [])}\n\nrecomposed ORCHESTRATOR.md — POLICY.md needed no crumb strip.\n`,
|
|
985
1102
|
);
|
|
986
1103
|
} else {
|
|
987
1104
|
process.stdout.write(`${formatMigrateResult(repaired)}\n`);
|
|
@@ -993,9 +1110,7 @@ try {
|
|
|
993
1110
|
process.exit(1);
|
|
994
1111
|
}
|
|
995
1112
|
if (layout.kind === "legacy-handwritten") {
|
|
996
|
-
process.stdout.write(
|
|
997
|
-
`${formatBriefStatus(path, { kind: "unsplittable", missing: layout.missing })}\n`,
|
|
998
|
-
);
|
|
1113
|
+
process.stdout.write(`${formatBriefReport(path, layout, layout.missing)}\n`);
|
|
999
1114
|
process.exit(1);
|
|
1000
1115
|
}
|
|
1001
1116
|
if (project === undefined && /\{\{[A-Za-z0-9_]+\}\}/.test(floor)) {
|
|
@@ -1028,36 +1143,38 @@ try {
|
|
|
1028
1143
|
break;
|
|
1029
1144
|
}
|
|
1030
1145
|
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1146
|
+
// No `--migrate` / `--retrofit`: report the layout and what to run. The
|
|
1147
|
+
// legacy single-file merge that used to live here is gone (#131) — a bare
|
|
1148
|
+
// `--apply` now says so rather than silently doing nothing.
|
|
1149
|
+
if (argv.includes("--apply")) {
|
|
1035
1150
|
process.stderr.write(
|
|
1036
|
-
|
|
1037
|
-
`${override === undefined ? " — run /conductor setup and say yes to writing ORCHESTRATOR.md." : "."}\n`,
|
|
1151
|
+
"brief-upgrade: the legacy single-file merge was removed in 0.4.3 — use --migrate --apply (bannered) or --retrofit --apply (hand-written)\n",
|
|
1038
1152
|
);
|
|
1039
|
-
process.exit(
|
|
1153
|
+
process.exit(2);
|
|
1040
1154
|
}
|
|
1041
1155
|
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1156
|
+
let missing: string[] = [];
|
|
1157
|
+
if (layout.kind !== "overlay" && layout.kind !== "missing") {
|
|
1158
|
+
let live: string;
|
|
1159
|
+
try {
|
|
1160
|
+
live = readFileSync(path, "utf8");
|
|
1161
|
+
} catch {
|
|
1162
|
+
process.stderr.write(
|
|
1163
|
+
`omp-conductor: no brief at ${path}` +
|
|
1164
|
+
`${override === undefined ? " — run /conductor setup and say yes to writing ORCHESTRATOR.md." : "."}\n`,
|
|
1165
|
+
);
|
|
1166
|
+
process.exit(1);
|
|
1167
|
+
}
|
|
1168
|
+
missing = missingSections(live, rendered);
|
|
1047
1169
|
}
|
|
1048
1170
|
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
if (project === undefined) {
|
|
1171
|
+
process.stdout.write(`${formatBriefReport(path, layout, missing)}\n`);
|
|
1172
|
+
if (layout.kind !== "overlay" && project === undefined) {
|
|
1052
1173
|
process.stdout.write(
|
|
1053
1174
|
"\nnote: no conductor config resolved on this host, so the template's\n" +
|
|
1054
1175
|
"{{PLACEHOLDER}} coordinates are unsubstituted. Section names are unaffected.\n",
|
|
1055
1176
|
);
|
|
1056
1177
|
}
|
|
1057
|
-
if (argv.includes("--apply") && status.kind === "mergeable") {
|
|
1058
|
-
const backup = writeMergedBrief(path, status.merged);
|
|
1059
|
-
process.stdout.write(`\napplied — previous brief kept at ${backup}\n`);
|
|
1060
|
-
}
|
|
1061
1178
|
break;
|
|
1062
1179
|
}
|
|
1063
1180
|
|
package/src/config.ts
CHANGED
|
@@ -231,6 +231,19 @@ export function saveConfig(c: ConductorConfig): void {
|
|
|
231
231
|
* rewrote a live fleet's config into a dialect the previous release rejected.
|
|
232
232
|
*/
|
|
233
233
|
export function writeConfigFile(c: unknown): void {
|
|
234
|
+
writeConfigRaw(`${JSON.stringify(c, null, 2)}\n`);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Atomic write of exact bytes.
|
|
239
|
+
*
|
|
240
|
+
* The rollback path needs this rather than {@link writeConfigFile}: it captured
|
|
241
|
+
* a snapshot as text, and putting it back through `JSON.parse` and
|
|
242
|
+
* `JSON.stringify` returns canonically formatted output, not the operator's
|
|
243
|
+
* file. Keys and values would survive; their formatting would not — and a
|
|
244
|
+
* rollback that reformats the thing it is restoring is not a restore.
|
|
245
|
+
*/
|
|
246
|
+
export function writeConfigRaw(text: string): void {
|
|
234
247
|
const dir = stateDir();
|
|
235
248
|
const created = mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
236
249
|
// mkdir's mode is masked by umask; chmod only what we just created so an
|
|
@@ -240,7 +253,7 @@ export function writeConfigFile(c: unknown): void {
|
|
|
240
253
|
const target = configPath();
|
|
241
254
|
const tmp = join(dir, `.config.json.${process.pid.toString(36)}.${Date.now().toString(36)}.tmp`);
|
|
242
255
|
try {
|
|
243
|
-
writeFileSync(tmp,
|
|
256
|
+
writeFileSync(tmp, text, { mode: 0o600 });
|
|
244
257
|
renameSync(tmp, target);
|
|
245
258
|
} catch (err) {
|
|
246
259
|
rmSync(tmp, { force: true });
|
|
@@ -478,11 +491,6 @@ function normalizeProject(
|
|
|
478
491
|
// (logged by `runWorker`) is what tells the operator the pattern missed.
|
|
479
492
|
const rawWorkerModel = raw["workerModel"];
|
|
480
493
|
const workerModel = nonEmptyString(rawWorkerModel) ? rawWorkerModel : undefined;
|
|
481
|
-
const orchestratorReadPaths = normalizeOrchestratorReadPaths(
|
|
482
|
-
raw["orchestratorReadPaths"],
|
|
483
|
-
label,
|
|
484
|
-
problems,
|
|
485
|
-
);
|
|
486
494
|
|
|
487
495
|
if (problems.length > before) return undefined;
|
|
488
496
|
|
|
@@ -504,7 +512,6 @@ function normalizeProject(
|
|
|
504
512
|
policy,
|
|
505
513
|
credentials,
|
|
506
514
|
reporting,
|
|
507
|
-
...(orchestratorReadPaths === undefined ? {} : { orchestratorReadPaths }),
|
|
508
515
|
// Derived from this project's own isolation, not from `stateDir()`: a
|
|
509
516
|
// hand-written `per-run` config that omits these optional keys would
|
|
510
517
|
// otherwise resolve under the 0700 private tree and be refused at dispatch,
|
|
@@ -814,8 +821,7 @@ function normalizeReleaseRequirements(
|
|
|
814
821
|
* Open-ended by necessity — this package cannot know what a fleet publishes —
|
|
815
822
|
* but a malformed entry still rejects the whole config rather than being
|
|
816
823
|
* dropped. A silently dropped environment name is an operator debugging a
|
|
817
|
-
* refusal that reads exactly like a correctly-denied one
|
|
818
|
-
* trap `orchestratorReadPaths` documents.
|
|
824
|
+
* refusal that reads exactly like a correctly-denied one.
|
|
819
825
|
*/
|
|
820
826
|
function normalizeNameList(parsed: unknown, at: string, problems: string[]): string[] {
|
|
821
827
|
if (parsed === undefined) return [];
|
|
@@ -1074,51 +1080,16 @@ function normalizeGraphProject(parsed: unknown, label: string, problems: string[
|
|
|
1074
1080
|
}
|
|
1075
1081
|
|
|
1076
1082
|
/**
|
|
1077
|
-
*
|
|
1083
|
+
* `orchestratorReadPaths` was the allowlist extension for the orchestrator's
|
|
1084
|
+
* file gate (#127). The gate is gone — the orchestrator is unconfined by
|
|
1085
|
+
* operator ruling (#143) — and with it every reader of this key.
|
|
1078
1086
|
*
|
|
1079
|
-
*
|
|
1080
|
-
*
|
|
1081
|
-
*
|
|
1082
|
-
*
|
|
1083
|
-
*
|
|
1084
|
-
* somewhere unintended is worse than no entry at all.
|
|
1085
|
-
*
|
|
1086
|
-
* A malformed entry rejects the whole config rather than being dropped: an
|
|
1087
|
-
* operator who mistyped a path they believed they had granted would otherwise
|
|
1088
|
-
* debug a refusal that reads exactly like a correctly-denied one.
|
|
1087
|
+
* It is deliberately *not* validated, and not rejected either: project-level
|
|
1088
|
+
* keys this build does not know are ignored, so a live fleet whose config still
|
|
1089
|
+
* carries it keeps loading. A retired key that failed validation would brick
|
|
1090
|
+
* exactly the fleets that adopted it, which is the 0.4.1→0.4.2 outage repeated
|
|
1091
|
+
* on purpose.
|
|
1089
1092
|
*/
|
|
1090
|
-
function normalizeOrchestratorReadPaths(
|
|
1091
|
-
parsed: unknown,
|
|
1092
|
-
label: string,
|
|
1093
|
-
problems: string[],
|
|
1094
|
-
): string[] | undefined {
|
|
1095
|
-
if (parsed === undefined) return undefined;
|
|
1096
|
-
if (!Array.isArray(parsed)) {
|
|
1097
|
-
problems.push(`${label}.orchestratorReadPaths must be an array of absolute paths`);
|
|
1098
|
-
return undefined;
|
|
1099
|
-
}
|
|
1100
|
-
|
|
1101
|
-
const paths: string[] = [];
|
|
1102
|
-
parsed.forEach((entry: unknown, i) => {
|
|
1103
|
-
if (!nonEmptyString(entry)) {
|
|
1104
|
-
problems.push(
|
|
1105
|
-
`${label}.orchestratorReadPaths[${i}] must be a non-empty absolute path, found ${JSON.stringify(entry)}`,
|
|
1106
|
-
);
|
|
1107
|
-
return;
|
|
1108
|
-
}
|
|
1109
|
-
const path = expandHome(entry.trim());
|
|
1110
|
-
if (!isAbsolute(path)) {
|
|
1111
|
-
problems.push(
|
|
1112
|
-
`${label}.orchestratorReadPaths[${i}] must be an absolute path (or start with "~") — the ` +
|
|
1113
|
-
`orchestrator session's cwd is the state directory, not wherever this file was edited — ` +
|
|
1114
|
-
`found ${JSON.stringify(entry)}`,
|
|
1115
|
-
);
|
|
1116
|
-
return;
|
|
1117
|
-
}
|
|
1118
|
-
paths.push(path);
|
|
1119
|
-
});
|
|
1120
|
-
return paths.length === 0 ? undefined : paths;
|
|
1121
|
-
}
|
|
1122
1093
|
|
|
1123
1094
|
/**
|
|
1124
1095
|
* Gates are the pre-push CI equivalent, so a malformed entry is an error, not
|