clearotron 0.3.0-beta.2 → 0.3.0-beta.3
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/INSTALL.md +19 -0
- package/bin/onboard.mjs +65 -6
- package/bin/start.mjs +7 -1
- package/build-info.json +2 -2
- package/driver/CHANGELOG.md +22 -0
- package/driver/driver.config.mjs +5 -0
- package/driver/engine/anthropic-agent.mjs +34 -17
- package/driver/gateway.mjs +76 -15
- package/driver/package.json +1 -1
- package/driver/portal-service.mjs +1 -1
- package/driver/profile-service.mjs +31 -3
- package/driver/publish/index.mjs +12 -9
- package/driver/publish/knockout.mjs +4 -2
- package/driver/publish/office-record-links.mjs +56 -21
- package/driver/recipe-service.mjs +14 -5
- package/driver/record-origins.mjs +14 -0
- package/driver/suite-census.json +28 -22
- package/mcp-server/CHANGELOG.md +4 -0
- package/mcp-server/lib/driver.mjs +2 -0
- package/mcp-server/lib/knockout.mjs +2 -2
- package/mcp-server/lib/options.mjs +9 -2
- package/mcp-server/package.json +1 -1
- package/package.json +1 -1
- package/portal-ui/dist/assets/{index-CcFjgM78.js → index-C1gpdkyH.js} +17 -8
- package/portal-ui/dist/index.html +1 -1
- package/portal-ui/package.json +1 -1
- package/providers/clarivate/src/core.js +5 -0
- package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/scripts/e2e-unread-terminals.mjs +1 -1
- package/scripts/e2e.mjs +114 -9
- package/scripts/revisit-render-check.mjs +22 -4
- package/scripts/travelling-predicates.mjs +1 -1
- package/shared/store-in-repo.mjs +50 -2
package/scripts/e2e.mjs
CHANGED
|
@@ -1002,11 +1002,59 @@ export function doorUnavailableLabel(a, { max = 90 } = {}) {
|
|
|
1002
1002
|
return "no status and no reason recorded";
|
|
1003
1003
|
}
|
|
1004
1004
|
|
|
1005
|
-
|
|
1005
|
+
/**
|
|
1006
|
+
* ── THE ASYMMETRY RULE IS WRITTEN FOR A CASE BOTH DOORS SHOULD REFUSE ───────────────────────────
|
|
1007
|
+
*
|
|
1008
|
+
* "The one that ACCEPTED is the defect" rests on a premise nobody wrote down: that the case EXPECTS a
|
|
1009
|
+
* refusal. Eight of R0's nine cases do, so the premise held by accident for as long as the ninth could
|
|
1010
|
+
* not be measured.
|
|
1011
|
+
*
|
|
1012
|
+
* R0e is the one admit case. Its own `expect.terminal` is `"delivered"` and it asserts the run resolves
|
|
1013
|
+
* to `generic`. On 2026-09-10, the first round in which both doors actually ANSWERED it, the cli door
|
|
1014
|
+
* accepted and delivered `profileKey: "generic"` — the case's whole contract, met — and the ops-mcp door
|
|
1015
|
+
* refused at `assertScopedProfileKey`, a deliberate cross-customer bypass gate doing its job for an
|
|
1016
|
+
* accounts-scoped session. Both doors were right. The rule named the one that behaved.
|
|
1017
|
+
*
|
|
1018
|
+
* The three earlier rounds hid it: the ops-mcp door was lost to the transport every time, so the case
|
|
1019
|
+
* never reached this comparison as two real answers.
|
|
1020
|
+
*
|
|
1021
|
+
* SO THE POLARITY IS NOT INVERTED HERE, and that matters. "On an admit case the door that REFUSED is the
|
|
1022
|
+
* defect" would blame a security gate for holding. What is wrong is the VERDICT, not its direction: on an
|
|
1023
|
+
* admit case this comparison does not know which door is at fault, and it says so instead of guessing.
|
|
1024
|
+
* A case that declares which doors can admit it (`doors`) stops the guess entirely.
|
|
1025
|
+
*/
|
|
1026
|
+
export function doorAsymmetry(answers, { expectTerminal = null } = {}) {
|
|
1006
1027
|
const compared = answers.filter((a) => doorAnswerClass(a) === DOOR_ANSWER.ANSWERED);
|
|
1007
1028
|
const unavailable = answers.filter((a) => doorAnswerClass(a) === DOOR_ANSWER.INFRA_UNAVAILABLE);
|
|
1008
1029
|
const agreed = compared.length < 2 ? true : compared.every((a) => a.ok === compared[0].ok);
|
|
1009
|
-
return { agreed, compared, unavailable, reducedCoverage: unavailable.length > 0 };
|
|
1030
|
+
return { agreed, compared, unavailable, reducedCoverage: unavailable.length > 0, admit: isAdmitCase(expectTerminal) };
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
/** A case whose own contract is that a door ACCEPTS. `delivered` and `duplicate` both admit; the rest refuse. */
|
|
1034
|
+
export function isAdmitCase(expectTerminal) {
|
|
1035
|
+
const t = String(expectTerminal ?? "").trim().toLowerCase();
|
|
1036
|
+
return t === "delivered" || t === "duplicate";
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
/**
|
|
1040
|
+
* The sentence a disagreement gets — ONE author, because the run printed it and the report pushed it into
|
|
1041
|
+
* `toInvestigate` from two separate literals, and a fix to either would have left the other saying the
|
|
1042
|
+
* old thing.
|
|
1043
|
+
*
|
|
1044
|
+
* `accepted`/`refused` read `ok` at run time and `accepted` on the receipt; both spellings are the same
|
|
1045
|
+
* fact and one formatter has to serve both surfaces or they drift.
|
|
1046
|
+
*/
|
|
1047
|
+
export function doorDisagreementLine(caseId, answers, { expectTerminal = null } = {}) {
|
|
1048
|
+
const took = (a) => (a?.ok ?? a?.accepted) === true;
|
|
1049
|
+
const accepted = answers.filter(took).map((a) => a.door);
|
|
1050
|
+
const refused = answers.filter((a) => !took(a)).map((a) => a.door);
|
|
1051
|
+
const both = `accepted by ${accepted.join(", ") || "(none)"}, refused by ${refused.join(", ") || "(none)"}`;
|
|
1052
|
+
if (!isAdmitCase(expectTerminal))
|
|
1053
|
+
return `${caseId}: THE DOORS DISAGREE — ${both}; the door that ACCEPTED is the defect`;
|
|
1054
|
+
return `${caseId}: THE DOORS DISAGREE on a case whose own expect.terminal ADMITS — ${both}. `
|
|
1055
|
+
+ `The accepting door met this case's stated contract; the refusing door answered a question the case did not ask. `
|
|
1056
|
+
+ `WHICH IS AT FAULT IS NOT DECIDED HERE — a door may refuse for a reason this case cannot satisfy at that door, `
|
|
1057
|
+
+ `and naming the accepter would blame the door that behaved. Declare \`doors\` on the case to say which doors can admit it.`;
|
|
1010
1058
|
}
|
|
1011
1059
|
|
|
1012
1060
|
const enqueue = (job, door) =>
|
|
@@ -1115,6 +1163,46 @@ const OPS = {
|
|
|
1115
1163
|
length: (v, want) => ({ ok: Array.isArray(v) && v.length === want, saw: Array.isArray(v) ? `${v.length}` : JSON.stringify(v) }),
|
|
1116
1164
|
};
|
|
1117
1165
|
|
|
1166
|
+
/**
|
|
1167
|
+
* The file `delivery-settled` reads, and the ONLY one it reads.
|
|
1168
|
+
*
|
|
1169
|
+
* Named rather than inlined because two places have to agree about it: the op itself, and the check that
|
|
1170
|
+
* asks every scenario whether the path it declared for an op is one that op can read. A literal in both
|
|
1171
|
+
* is the shape that produced the defect this constant exists to prevent.
|
|
1172
|
+
*/
|
|
1173
|
+
export const DELIVERY_STATUS_FILE = "status.json";
|
|
1174
|
+
|
|
1175
|
+
/**
|
|
1176
|
+
* Which ops read a FIXED file rather than the path the scenario declares, and what that file is.
|
|
1177
|
+
*
|
|
1178
|
+
* DERIVED CHECKS NEED THIS TABLE, not a hand-written list of known-bad scenarios: a scenario nobody
|
|
1179
|
+
* thought to add is exactly the one that goes unnoticed, and R13 went unnoticed for four rounds.
|
|
1180
|
+
*/
|
|
1181
|
+
export const FIXED_FILE_OPS = Object.freeze({ "delivery-settled": DELIVERY_STATUS_FILE });
|
|
1182
|
+
|
|
1183
|
+
/**
|
|
1184
|
+
* Every scenario case whose declared `path` names a file its `op` does not read.
|
|
1185
|
+
*
|
|
1186
|
+
* Not a pass/fail — the op reads the right file either way. It is a REPORT, so a scenario stating a wrong
|
|
1187
|
+
* belief about an op is visible to whoever runs it. Built from the scenario set, never from a list of
|
|
1188
|
+
* names: the point is to find the next one.
|
|
1189
|
+
*/
|
|
1190
|
+
export function pathsAnOpDoesNotRead(scenarios) {
|
|
1191
|
+
const out = [];
|
|
1192
|
+
for (const s of scenarios ?? []) {
|
|
1193
|
+
const cases = s?.cases ?? (s?.expect ? [{ id: s.id, expect: s.expect }] : []);
|
|
1194
|
+
for (const c of cases) {
|
|
1195
|
+
for (const a of c?.expect?.assert ?? []) {
|
|
1196
|
+
const fixed = FIXED_FILE_OPS[a?.op];
|
|
1197
|
+
if (!fixed) continue;
|
|
1198
|
+
const declared = String(a?.path ?? "").split(":")[0];
|
|
1199
|
+
if (declared && declared !== fixed) out.push({ scenario: s.id, case: c.id ?? s.id, op: a.op, declared, reads: fixed });
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
return out;
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1118
1206
|
function evalAssertion(a, runDir) {
|
|
1119
1207
|
const [file, field] = String(a.path ?? "").split(":");
|
|
1120
1208
|
const full = join(runDir, file || "");
|
|
@@ -1335,8 +1423,21 @@ function evalAssertion(a, runDir) {
|
|
|
1335
1423
|
// exist. A run that flipped sendPending without writing one would have passed the old assertion and
|
|
1336
1424
|
// fails this one.
|
|
1337
1425
|
if (a.op === "delivery-settled") {
|
|
1338
|
-
|
|
1339
|
-
|
|
1426
|
+
// — THIS OP READS `status.json`, WHATEVER PATH THE SCENARIO DECLARED, and the error text three
|
|
1427
|
+
// lines below always said so while the read above it took `full`. R13 declared `_driver/delivery.json`,
|
|
1428
|
+
// which parses cleanly and carries no `sendPending`, so the assertion read `undefined`, failed a run
|
|
1429
|
+
// that had delivered correctly, and pushed a product defect that did not exist into `INVESTIGATE`.
|
|
1430
|
+
// It went unseen for four rounds because R13 had been REPORTED once.
|
|
1431
|
+
//
|
|
1432
|
+
// Reading the run's own status is not a correction of the scenario — it is what the op has always
|
|
1433
|
+
// meant. The scenario's `path` is still ANSWERED FOR rather than ignored: `declaredPathNote` says so
|
|
1434
|
+
// in the line, so a scenario stating a belief about this op that is wrong is visible to the reader
|
|
1435
|
+
// who runs it, not only to whoever next reads this function.
|
|
1436
|
+
const st = readJson(join(runDir, DELIVERY_STATUS_FILE));
|
|
1437
|
+
const declaredPathNote = (file && file !== DELIVERY_STATUS_FILE)
|
|
1438
|
+
? ` — NOTE: this scenario declares \`path: "${file}"\` for \`delivery-settled\`, which reads \`${DELIVERY_STATUS_FILE}\` and nothing else. The declared path was not read.`
|
|
1439
|
+
: "";
|
|
1440
|
+
if (!st) return { ok: false, saw: `${DELIVERY_STATUS_FILE} absent or unparseable${declaredPathNote}` };
|
|
1340
1441
|
// — THERE IS NO MODE TO READ ANY MORE, and this is the one place where that mattered rather
|
|
1341
1442
|
// than being a comment fix. This used to open `process.env.CLEAROTRON_DELIVERY || "email"` and take a
|
|
1342
1443
|
// `sendPending === false` branch for anything that was not `handoff`. With the variable deleted that
|
|
@@ -1392,7 +1493,7 @@ function evalAssertion(a, runDir) {
|
|
|
1392
1493
|
const { packets, unreadable } = outboxPackets({ runIds: runIdForms(st, runDir) });
|
|
1393
1494
|
return { ok: st.sendPending === true && packets.length > 0,
|
|
1394
1495
|
saw: `mode=handoff sendPending=${JSON.stringify(st.sendPending)} packet=${
|
|
1395
|
-
packets.length ? packets.join(", ") : unreadable ? `NOT LOOKED FOR — ${unreadable}` : "NONE WRITTEN"}` };
|
|
1496
|
+
packets.length ? packets.join(", ") : unreadable ? `NOT LOOKED FOR — ${unreadable}` : "NONE WRITTEN"}${declaredPathNote}` };
|
|
1396
1497
|
}
|
|
1397
1498
|
|
|
1398
1499
|
// `absent` — the file must NOT exist, and its absence is the asserted state, not a silent pass.
|
|
@@ -1811,7 +1912,7 @@ async function cmdRun(id) {
|
|
|
1811
1912
|
// ever wants the launch to stop.
|
|
1812
1913
|
printPreviousRoundNotice(s);
|
|
1813
1914
|
|
|
1814
|
-
const jobs = s.job ? [{ id: s.id, job: s.job }] : (s.cases ?? []).map((c) => ({ id: c.id, job: c.job, what: c.what, oneMatterAcrossDoors: c.oneMatterAcrossDoors === true }));
|
|
1915
|
+
const jobs = s.job ? [{ id: s.id, job: s.job }] : (s.cases ?? []).map((c) => ({ id: c.id, job: c.job, what: c.what, oneMatterAcrossDoors: c.oneMatterAcrossDoors === true, expectTerminal: (c.expect ?? {}).terminal ?? null }));
|
|
1815
1916
|
|
|
1816
1917
|
// door: "all" is the point of R0 — a rule enforced in one door and not another is exactly the #98
|
|
1817
1918
|
// asymmetry. Every case goes through EVERY drivable door and the answers are compared. runner.mjs's
|
|
@@ -1827,7 +1928,7 @@ async function cmdRun(id) {
|
|
|
1827
1928
|
// over whatever was there, so the second run of a pair destroyed the first round's token and the
|
|
1828
1929
|
// first half became unreportable with nothing saying so.
|
|
1829
1930
|
const round = { token: RUN_TOKEN, startedAt: new Date().toISOString(), startedAtSource: "run", doors, cases: [] };
|
|
1830
|
-
for (const { id: caseId, job, what, oneMatterAcrossDoors } of jobs) {
|
|
1931
|
+
for (const { id: caseId, job, what, oneMatterAcrossDoors, expectTerminal } of jobs) {
|
|
1831
1932
|
// The round token goes on the BASE ref, so the door suffix (and R0d's opt-out from it) still decides
|
|
1832
1933
|
// whether the doors are one matter or two. See refForRun and refForDoor.
|
|
1833
1934
|
const roundRef = refForRun(job.ref);
|
|
@@ -1857,7 +1958,7 @@ async function cmdRun(id) {
|
|
|
1857
1958
|
console.log(` [${a.door}] ${a.ok ? "accepted" : "refused"}: ${a.out.split("\n").slice(0, 2).join(" ").slice(0, 200)}`);
|
|
1858
1959
|
}
|
|
1859
1960
|
}
|
|
1860
|
-
if (!agreed) console.log(` ⚠
|
|
1961
|
+
if (!agreed) console.log(` ⚠ ${doorDisagreementLine(caseId, answers, { expectTerminal })}`);
|
|
1861
1962
|
// — named, never silent. A door lost to the transport costs this case a share of its door
|
|
1862
1963
|
// coverage, and that is a fact about the ROUND, not about the product. Saying nothing here is how
|
|
1863
1964
|
// the R0e 429 cost a third of a scenario's coverage without appearing anywhere.
|
|
@@ -3083,9 +3184,13 @@ async function cmdReport(id, { round: requestedToken = null } = {}) {
|
|
|
3083
3184
|
.filter((x) => x.out.length);
|
|
3084
3185
|
console.log(`\ndoors: ${rec.doors.join(", ") || "(not recorded)"} — ${rec.cases.length} case(s), ${dis.length} disagreement(s), ${unavailable.length} case(s) with reduced door coverage`);
|
|
3085
3186
|
for (const c of dis) {
|
|
3187
|
+
// — the RECEIPT does not carry the case's contract, so it is joined back to the scenario by
|
|
3188
|
+
// id. A receipt written before this field existed reads null and gets the old sentence, which is
|
|
3189
|
+
// the right reading for it rather than a retrospective one.
|
|
3190
|
+
const expectTerminal = ((s.cases ?? []).find((x) => x.id === c.id)?.expect ?? {}).terminal ?? null;
|
|
3086
3191
|
const accepted = c.answers.filter((a) => a.accepted).map((a) => a.door);
|
|
3087
3192
|
console.log(` ⚠ ${c.id}: accepted by ${accepted.join(", ") || "(none)"}, refused by ${c.answers.filter((a) => !a.accepted).map((a) => a.door).join(", ") || "(none)"}`);
|
|
3088
|
-
toInvestigate.push(
|
|
3193
|
+
toInvestigate.push(doorDisagreementLine(c.id, c.answers, { expectTerminal }));
|
|
3089
3194
|
}
|
|
3090
3195
|
for (const { c, out } of unavailable) {
|
|
3091
3196
|
const who = out.map((a) => `${a.door} (${doorUnavailableLabel(a)})`).join(", ");
|
|
@@ -221,14 +221,31 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
|
221
221
|
// the truncation as a lower request count — the failure mode that would make this whole check lie in the
|
|
222
222
|
// safe-looking direction. Wait until the server has been silent for `quiet` ms, capped so a screen that
|
|
223
223
|
// requests forever fails loudly instead of hanging.
|
|
224
|
-
|
|
224
|
+
//
|
|
225
|
+
// SILENCE BEFORE THE FIRST REQUEST IS NOT QUIET, WHERE A REQUEST IS OWED. `Page.navigate` returns as soon
|
|
226
|
+
// as the navigation is committed, long before the app's bundle has run, so the quiet clock above used to
|
|
227
|
+
// start while the server had heard nothing at all — and a first fetch that arrived later than `quiet`
|
|
228
|
+
// closed the window empty. Every request that followed was then counted into the NEXT window, so the
|
|
229
|
+
// whole drive read one bucket late: visit 1 empty, the revisit holding what boot had asked for. On a warm
|
|
230
|
+
// machine the bundle runs in well under a second and it never showed; on a slow runner it failed nine
|
|
231
|
+
// assertions about a product that was behaving. Reproduced by delaying the bundle 1.5s at this fixture.
|
|
232
|
+
//
|
|
233
|
+
// `expectTraffic` is for a window that follows a FULL NAVIGATION — the shell's boot and the Result
|
|
234
|
+
// screen's first visit, the two places this drive calls `Page.navigate`. Each reloads the bundle, so each
|
|
235
|
+
// has the same gap between the navigation committing and the app's first fetch. A client-side visit runs
|
|
236
|
+
// on the bundle already loaded and has no such gap, which is why it is not the default: a revisit is
|
|
237
|
+
// allowed to ask for nothing, and that is the very thing this file measures, so a window there must still
|
|
238
|
+
// be able to close on silence.
|
|
239
|
+
const settle = async ({ quiet = 900, cap = 12000, expectTraffic = false } = {}) => {
|
|
225
240
|
const started = Date.now()
|
|
241
|
+
const before = hits.length
|
|
226
242
|
let last = hits.length
|
|
227
243
|
let lastChange = Date.now()
|
|
228
244
|
for (;;) {
|
|
229
245
|
await sleep(100)
|
|
230
246
|
if (hits.length !== last) { last = hits.length; lastChange = Date.now() }
|
|
231
|
-
|
|
247
|
+
const heard = hits.length > before
|
|
248
|
+
if ((heard || !expectTraffic) && Date.now() - lastChange >= quiet) return true
|
|
232
249
|
if (Date.now() - started >= cap) return false
|
|
233
250
|
}
|
|
234
251
|
}
|
|
@@ -290,7 +307,8 @@ const record = {}
|
|
|
290
307
|
// The shell, once. Everything after this is client-side.
|
|
291
308
|
epoch = 'boot'
|
|
292
309
|
await navigateOrRefuse(cmd, `${origin}/portal/home`, { what: 'revisit-render-check' })
|
|
293
|
-
if (!(await settle(
|
|
310
|
+
if (!(await settle({ expectTraffic: true })))
|
|
311
|
+
say(false, 'the shell did not finish loading within 12s — it never reached the fixture server, or never stopped requesting')
|
|
294
312
|
const bootPath = await where()
|
|
295
313
|
say(bootPath === '/portal/home', `the shell loaded on /portal/home (got ${bootPath})`)
|
|
296
314
|
|
|
@@ -384,7 +402,7 @@ await twice('clearances', () => clickNav('Clearances'), '/portal/clearances')
|
|
|
384
402
|
// runs AppShell's popstate listener rather than its click funnel.
|
|
385
403
|
epoch = 'result:1'
|
|
386
404
|
await navigateOrRefuse(cmd, `${origin}/portal/result/${RUN_ID}`, { what: 'revisit-render-check' })
|
|
387
|
-
const rq1 = await settle()
|
|
405
|
+
const rq1 = await settle({ expectTraffic: true }) // a full navigation, like boot
|
|
388
406
|
const rp1 = await where()
|
|
389
407
|
const rt1 = await screenText()
|
|
390
408
|
say(rp1 === `/portal/result/${RUN_ID}`, `result: visit 1 landed on the run (got ${rp1})`)
|
|
@@ -101,7 +101,7 @@ function isPlumbing(node, child) {
|
|
|
101
101
|
// A ternary BRANCH is plumbing; a ternary TEST is a decision, and classify() handles that separately.
|
|
102
102
|
// This was the whole of the unresolved bucket — three sites, all one shape:
|
|
103
103
|
//
|
|
104
|
-
// const wrote = files.length ? files.some(…) : null; gateway.mjs
|
|
104
|
+
// const wrote = files.length ? files.some(…) : null; gateway.mjs (by name)
|
|
105
105
|
// const inScope = scope.size ? tokens.some(…) : (…); reasoning-tripwires.mjs:82
|
|
106
106
|
// const reached = b.layer === "national" ? (…) : regions.some(…); register-plan.mjs:318
|
|
107
107
|
//
|
package/shared/store-in-repo.mjs
CHANGED
|
@@ -142,7 +142,7 @@ export function storeOutsideRepoMessage({ storeVar, storeDir, repoVar, repoRoot
|
|
|
142
142
|
// knows both. So the appender returns a path only when that path is committable, and the core commits
|
|
143
143
|
// what it is handed.
|
|
144
144
|
|
|
145
|
-
import { appendFileSync } from "node:fs";
|
|
145
|
+
import { appendFileSync, rmSync } from "node:fs";
|
|
146
146
|
|
|
147
147
|
/**
|
|
148
148
|
* A repo root is a PATH, and every helper below interpolates it into a git invocation. Anything else
|
|
@@ -289,6 +289,39 @@ import { execFileSync } from "node:child_process";
|
|
|
289
289
|
export const isTransientGitFault = (detail) =>
|
|
290
290
|
/index\.lock|another git process seems to be running|Unable to create/i.test(String(detail ?? ""));
|
|
291
291
|
|
|
292
|
+
/**
|
|
293
|
+
* Why a commit into `repoRoot` would be refused, asked BEFORE anything is written; null when it would not
|
|
294
|
+
* be. Read-only: `rev-parse` and `git var` change nothing. `{ code, detail, message }`, where `message` names
|
|
295
|
+
* the store and the command that clears it, in terms an operator acts on rather than git's own words:
|
|
296
|
+
*
|
|
297
|
+
* not-a-repository — the store is not inside a git repository this process can use;
|
|
298
|
+
* no-identity — git has no committer identity here. That is the default state of any machine where
|
|
299
|
+
* nobody ran `git config user.email`, a fresh Windows install among them. A save names
|
|
300
|
+
* its author; the COMMITTER is the machine's, and git refuses a commit it cannot name
|
|
301
|
+
* one for. `git -c user.email=…` on a one-off seed commit does not help: `-c` configures
|
|
302
|
+
* that invocation, not the repository, so every save after it fails the same way.
|
|
303
|
+
*/
|
|
304
|
+
export function storeCommitRefusal(repoRoot, { env = process.env } = {}) {
|
|
305
|
+
requireRepoRootPath(repoRoot, "storeCommitRefusal");
|
|
306
|
+
const ask = (...args) => execFileSync("git", ["-C", repoRoot, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], env });
|
|
307
|
+
const said = (e) => String(e?.stderr || e?.message || e).trim().split("\n").filter(Boolean);
|
|
308
|
+
try { ask("rev-parse", "--git-dir"); }
|
|
309
|
+
catch (e) {
|
|
310
|
+
const detail = said(e)[0]?.slice(0, 200) ?? "";
|
|
311
|
+
const fix = /dubious ownership/i.test(detail)
|
|
312
|
+
? `run \`git config --global --add safe.directory ${repoRoot}\` as the account the service runs as`
|
|
313
|
+
: `run \`git init\` in ${repoRoot}, or point PROFILE_REPO_ROOT at the repository that holds the store`;
|
|
314
|
+
return { code: "not-a-repository", detail, message: `the store at ${repoRoot} is not a git repository this install can record into (${detail}) — ${fix}` };
|
|
315
|
+
}
|
|
316
|
+
try { ask("var", "GIT_COMMITTER_IDENT"); }
|
|
317
|
+
catch (e) {
|
|
318
|
+
return { code: "no-identity", detail: said(e).pop()?.slice(0, 200) ?? "",
|
|
319
|
+
message: `the store at ${repoRoot} has no git identity, so nothing saved to it can be recorded — run `
|
|
320
|
+
+ `\`git -C ${repoRoot} config user.email "you@example.com"\` and \`git -C ${repoRoot} config user.name "Your Name"\`` };
|
|
321
|
+
}
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
|
|
292
325
|
export function makeStoreCommit({ repoRoot, log = () => {}, what = "store", retries = 3, waitMs = 50 }) {
|
|
293
326
|
requireRepoRootPath(repoRoot, "makeStoreCommit");
|
|
294
327
|
const git = (...args) => execFileSync("git", ["-C", repoRoot, ...args], { encoding: "utf8" }).toString().trim();
|
|
@@ -309,7 +342,7 @@ export function makeStoreCommit({ repoRoot, log = () => {}, what = "store", retr
|
|
|
309
342
|
const napping = (ms) => { try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } catch { /* best effort */ } };
|
|
310
343
|
const detailOf = (e) => String(e?.stderr ?? e?.message ?? e);
|
|
311
344
|
|
|
312
|
-
|
|
345
|
+
const commit = ({ files, message, author }) => {
|
|
313
346
|
// Asked BEFORE anything composes a diff, so the caller gets the refusal rather than a fallback-mode
|
|
314
347
|
// parse error. Throwing here also means the write is never followed by a silent half-save: the
|
|
315
348
|
// caller's own catch is what turns this into a failed save.
|
|
@@ -350,6 +383,21 @@ export function makeStoreCommit({ repoRoot, log = () => {}, what = "store", retr
|
|
|
350
383
|
}
|
|
351
384
|
}
|
|
352
385
|
};
|
|
386
|
+
// ── TWO MORE ANSWERS, FOR A CREATE, WHICH IS REFUSED RATHER THAN LEFT HALF-MADE ──────────────────────
|
|
387
|
+
//
|
|
388
|
+
// A save of something that already exists stays as above: live, staged, completed by the next save.
|
|
389
|
+
// A CREATE is different, because "before" exists: the paths it wrote were absent. So a create asks
|
|
390
|
+
// `refusal()` first and writes nothing when the store cannot record it, and when the commit fails anyway
|
|
391
|
+
// (a hook, a full disk), `withdraw(files)` returns those paths to absent: out of the index, so the next
|
|
392
|
+
// save's completion step cannot commit a company that does not exist, and off the disk. Nothing else is
|
|
393
|
+
// touched, and the audit row the create staged stays staged, because it records what happened.
|
|
394
|
+
commit.refusal = () => storeCommitRefusal(repoRoot);
|
|
395
|
+
commit.withdraw = (files) => {
|
|
396
|
+
const paths = (files ?? []).map((f) => resolve(repoRoot, f));
|
|
397
|
+
if (paths.length) git("rm", "--cached", "--quiet", "--ignore-unmatch", "--", ...paths);
|
|
398
|
+
for (const p of paths) rmSync(p, { force: true });
|
|
399
|
+
};
|
|
400
|
+
return commit;
|
|
353
401
|
}
|
|
354
402
|
|
|
355
403
|
export function resolveStoreRepoRoot({ names, fallback = null, env = process.env } = {}) {
|