@patronage/software-factory 0.23.0 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +321 -15
- package/dist/index.js +2775 -1075
- package/dist/schemas.d.ts +48 -4
- package/dist/schemas.js +230 -13
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -9,16 +9,16 @@ import crypto, { createHash, createSign, randomUUID } from "node:crypto";
|
|
|
9
9
|
import { z } from "zod";
|
|
10
10
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
11
11
|
import { link, lstat, mkdir, open, readFile, readdir, realpath, rename, stat, unlink } from "node:fs/promises";
|
|
12
|
+
import os, { homedir } from "node:os";
|
|
12
13
|
import { createInterface } from "node:readline";
|
|
13
14
|
import { setImmediate } from "node:timers";
|
|
14
15
|
import { setImmediate as setImmediate$1, setTimeout as setTimeout$1 } from "node:timers/promises";
|
|
15
16
|
import { Worker } from "node:worker_threads";
|
|
16
17
|
import picomatch from "picomatch";
|
|
17
|
-
import os, { homedir } from "node:os";
|
|
18
18
|
import { promisify } from "node:util";
|
|
19
19
|
import { parse } from "yaml";
|
|
20
20
|
//#region package.json
|
|
21
|
-
var version = "0.
|
|
21
|
+
var version = "0.25.0";
|
|
22
22
|
//#endregion
|
|
23
23
|
//#region src/review-rungs.ts
|
|
24
24
|
const EVIDENCE_REVIEW_RUNGS$1 = [
|
|
@@ -936,6 +936,14 @@ const fetchPullRequestReviews = (fullName, prNumber) => {
|
|
|
936
936
|
const CF_ACCESS_CLIENT_ID_ENV = "CF-Access-Client-Id";
|
|
937
937
|
const CF_ACCESS_CLIENT_SECRET_ENV = "CF-Access-Client-Secret";
|
|
938
938
|
/**
|
|
939
|
+
* True in a hosted runner, where secret-manager resolution must not be
|
|
940
|
+
* tried (#312). Lives here — a dependency-free leaf both
|
|
941
|
+
* `hq-ingest-preflight.ts` and `hq-credentials.ts` already import from for
|
|
942
|
+
* the env var names above — so neither of those two needs to import the
|
|
943
|
+
* other for it; that import, either direction, would close a cycle (#394).
|
|
944
|
+
*/
|
|
945
|
+
const isHostedRunner = (env) => env.CI === "true" || env.CI === "1" || env.GITHUB_ACTIONS === "true";
|
|
946
|
+
/**
|
|
939
947
|
* Builds a credential-bearing HQ request that cannot follow redirects. Access
|
|
940
948
|
* credentials must never leave the origin selected by the caller.
|
|
941
949
|
*/
|
|
@@ -1387,8 +1395,52 @@ function formatUserConfigError(configPath, error) {
|
|
|
1387
1395
|
return `User config is invalid: ${configPath}: ${formatZodIssues(error)}`;
|
|
1388
1396
|
}
|
|
1389
1397
|
const DEFAULT_HQ_TRANSPORT_TIMEOUT_MS = 2500;
|
|
1390
|
-
const
|
|
1391
|
-
const
|
|
1398
|
+
const HQ_RETRY_SPOOL_DIRNAME = "hq-retry-spool";
|
|
1399
|
+
const HQ_RETRY_JOURNAL_BASENAME = "hq-retry-journal.jsonl";
|
|
1400
|
+
/**
|
|
1401
|
+
* Appended to a spooled event that can never be delivered (#445).
|
|
1402
|
+
*
|
|
1403
|
+
* The suffix *is* the disposition: every reader in this module selects work by
|
|
1404
|
+
* `*.json`, so a renamed file leaves the drain candidates and the pending
|
|
1405
|
+
* counters at once, without a second state file to keep in step with it. The
|
|
1406
|
+
* bytes are untouched and the original name is one `.undeliverable` away, so
|
|
1407
|
+
* this discards a claim about the file, not the file.
|
|
1408
|
+
*/
|
|
1409
|
+
const UNDELIVERABLE_SUFFIX = ".undeliverable";
|
|
1410
|
+
/**
|
|
1411
|
+
* Names the repository whose evidence a spool directory holds (#447).
|
|
1412
|
+
*
|
|
1413
|
+
* The sweep's problem has always been that a directory on a shared root
|
|
1414
|
+
* carries no statement about who wrote it. #420 walked the root and called
|
|
1415
|
+
* everything unfamiliar an orphan, which described another repository's
|
|
1416
|
+
* ordinary spool exactly as well as this repository's obsolete one. #446
|
|
1417
|
+
* replaced the walk with keys derived from this checkout's own identity, which
|
|
1418
|
+
* cannot lie — but also cannot generate a key *encoding* it no longer produces,
|
|
1419
|
+
* losing the retired non-injective era.
|
|
1420
|
+
*
|
|
1421
|
+
* A marker written at enqueue time ends that guessing: the directory says whose
|
|
1422
|
+
* it is, so "an old encoding of mine" and "a current key of someone else's"
|
|
1423
|
+
* stop being indistinguishable. It is written beside the events by the one path
|
|
1424
|
+
* that knows the answer from the profile, and read by the sweep.
|
|
1425
|
+
*
|
|
1426
|
+
* It does **not** cover renames or owner changes. The marker holds the name
|
|
1427
|
+
* that was current when the directory was written, and `markerClaims` compares
|
|
1428
|
+
* it to the name that is current now, so a pre-rename directory does not match.
|
|
1429
|
+
* See `sweepHqSpoolOrphans` — #447 stays open for that.
|
|
1430
|
+
*
|
|
1431
|
+
* Deliberately not `*.json`: every reader in this module selects events by that
|
|
1432
|
+
* suffix, and a marker that looked like an event would be drained as one.
|
|
1433
|
+
*/
|
|
1434
|
+
const SPOOL_REPOSITORY_MARKER = ".repository-identity";
|
|
1435
|
+
/**
|
|
1436
|
+
* Depth cap for the marker walk. `<owner>/<repo>/hq-retry-spool` is depth 3,
|
|
1437
|
+
* and the extra level is slack for a key scheme that nests one deeper.
|
|
1438
|
+
*/
|
|
1439
|
+
const HQ_SPOOL_SWEEP_MAX_DEPTH = 4;
|
|
1440
|
+
`${HQ_RETRY_JOURNAL_BASENAME}`;
|
|
1441
|
+
`${HQ_RETRY_SPOOL_DIRNAME}`;
|
|
1442
|
+
const LEGACY_FACTORY_MEMORY_DIRNAME = ".factory-memory";
|
|
1443
|
+
const HQ_SPOOL_STATE_SEGMENTS = ["patronage-factory", "hq-spool"];
|
|
1392
1444
|
const MAX_HQ_CONFIG_BYTES = 1024 * 1024;
|
|
1393
1445
|
const MAX_HQ_INGEST_PAYLOAD_BYTES = 256 * 1024;
|
|
1394
1446
|
const MAX_HQ_REPLAY_ENTRIES = 32;
|
|
@@ -1429,45 +1481,152 @@ const secureStateDirectory = async (directory, deadline) => {
|
|
|
1429
1481
|
return false;
|
|
1430
1482
|
}
|
|
1431
1483
|
};
|
|
1432
|
-
const
|
|
1484
|
+
const layoutPath = (layout) => path.join(layout.root, ...layout.segments);
|
|
1485
|
+
/**
|
|
1486
|
+
* Where the spool tree lives. Under test, the implicit answer is refused
|
|
1487
|
+
* (#445).
|
|
1488
|
+
*
|
|
1489
|
+
* Three `retro-envelope` fixtures recorded against `https://hq.example` were
|
|
1490
|
+
* found sitting in a real operator's `~/.local/state` — written by tests that
|
|
1491
|
+
* simply never redirected the state home, and invisible until #420's sweep
|
|
1492
|
+
* made stranded evidence loud. The vitest package has a global setup that
|
|
1493
|
+
* redirects it; `scripts/factory-cli.test.mjs` runs under `node --test`, never
|
|
1494
|
+
* sees that setup, and spawns the CLI with `...process.env`, so the real home
|
|
1495
|
+
* flows straight through. That leaves the property "tests do not touch
|
|
1496
|
+
* operator state" resting on discipline at every callsite — and the callsite
|
|
1497
|
+
* that forgets is silent, because falling back to the real home is exactly
|
|
1498
|
+
* what this function is supposed to do.
|
|
1499
|
+
*
|
|
1500
|
+
* So the fallback itself is what a test may not have. Under `NODE_ENV=test`,
|
|
1501
|
+
* an absent `XDG_STATE_HOME` is an error rather than a default, and the error
|
|
1502
|
+
* names the fix. One guard covers both runners and any runner added later,
|
|
1503
|
+
* which per-callsite discipline demonstrably cannot.
|
|
1504
|
+
*
|
|
1505
|
+
* What this guarantees is that the *write cannot happen*, not that anyone sees
|
|
1506
|
+
* it: emit-path HQ delivery is advisory and swallows its own failures on
|
|
1507
|
+
* purpose, so a caller inside that path turns this throw into a silent no-op.
|
|
1508
|
+
* That is the right trade here — the property being defended is "tests do not
|
|
1509
|
+
* touch operator state", and a refused write defends it whether or not the
|
|
1510
|
+
* refusal is announced. Tests that resolve the spool directly, like the one in
|
|
1511
|
+
* `hq-spool-inspection.test.ts`, do see the error.
|
|
1512
|
+
*/
|
|
1513
|
+
const stateHomeDirectory = (env) => {
|
|
1514
|
+
const stateHome = env.XDG_STATE_HOME;
|
|
1515
|
+
if (stateHome !== void 0 && stateHome !== "") return stateHome;
|
|
1516
|
+
if ((env.NODE_ENV ?? process.env.NODE_ENV) === "test") throw new Error("Refusing to resolve the HQ spool state home from HOME under NODE_ENV=test: a test that writes there writes into the real operator's state (#445). Set XDG_STATE_HOME to a temporary directory for this test.");
|
|
1517
|
+
const home = env.HOME !== void 0 && env.HOME !== "" ? env.HOME : os.homedir();
|
|
1518
|
+
return path.join(home, ".local", "state");
|
|
1519
|
+
};
|
|
1520
|
+
/**
|
|
1521
|
+
* One filesystem-safe segment per repository *component*.
|
|
1522
|
+
*
|
|
1523
|
+
* Owner and repo are separate path segments, and each is escaped reversibly:
|
|
1524
|
+
* joining them into a single `<owner>-<repo>` name is ambiguous (owner `a-b`
|
|
1525
|
+
* repo `c` and owner `a` repo `b-c` produce the same string), and a lossy
|
|
1526
|
+
* `[^A-Za-z0-9._-] → _` substitution is ambiguous for the same reason. Two
|
|
1527
|
+
* repositories that collide would interleave spools and drain each other's
|
|
1528
|
+
* events to the wrong endpoint, so the encoding has to be injective:
|
|
1529
|
+
* percent-escape every byte outside the safe set, `%` included, and escape a
|
|
1530
|
+
* segment made only of dots so no component can become `.` or `..`.
|
|
1531
|
+
*/
|
|
1532
|
+
const hqSpoolSegment = (component) => {
|
|
1533
|
+
const escaped = component.replaceAll(/[^A-Za-z0-9._-]/gu, (character) => [...Buffer.from(character, "utf-8")].map((byte) => `%${byte.toString(16).toUpperCase().padStart(2, "0")}`).join(""));
|
|
1534
|
+
return /^\.+$/u.test(escaped) ? escaped.replaceAll(".", "%2E") : escaped;
|
|
1535
|
+
};
|
|
1536
|
+
/** The repository's own two segments, outermost first. */
|
|
1537
|
+
const repositorySegments = (repository) => [hqSpoolSegment(repository.owner), hqSpoolSegment(repository.repo)];
|
|
1538
|
+
/** The state directory holding one spool tree per repository. */
|
|
1539
|
+
const hqSpoolRoot = (env = process.env) => path.join(stateHomeDirectory(env), ...HQ_SPOOL_STATE_SEGMENTS);
|
|
1540
|
+
/** The repo-keyed spool directory: outlives any worktree that produced it. */
|
|
1541
|
+
const hqSpoolDirectory = (repository, env = process.env) => path.join(hqSpoolRoot(env), ...repositorySegments(repository), HQ_RETRY_SPOOL_DIRNAME);
|
|
1542
|
+
/** The only location current producers write. */
|
|
1543
|
+
const repositorySpoolLayout = (repository, env = process.env) => ({
|
|
1544
|
+
create: true,
|
|
1545
|
+
createRoot: true,
|
|
1546
|
+
repository,
|
|
1547
|
+
root: stateHomeDirectory(env),
|
|
1548
|
+
segments: [
|
|
1549
|
+
...HQ_SPOOL_STATE_SEGMENTS,
|
|
1550
|
+
...repositorySegments(repository),
|
|
1551
|
+
HQ_RETRY_SPOOL_DIRNAME
|
|
1552
|
+
]
|
|
1553
|
+
});
|
|
1554
|
+
/** Read-only drain source for spools written before the relocation. */
|
|
1555
|
+
const legacySpoolLayout = (cwd) => ({
|
|
1556
|
+
create: false,
|
|
1557
|
+
root: cwd,
|
|
1558
|
+
segments: [LEGACY_FACTORY_MEMORY_DIRNAME, HQ_RETRY_SPOOL_DIRNAME]
|
|
1559
|
+
});
|
|
1560
|
+
/**
|
|
1561
|
+
* An operator-named directory (`hq:flush --dir`). Accepts either the spool
|
|
1562
|
+
* itself or the directory holding it, so a `.factory-memory` path and a
|
|
1563
|
+
* `.../hq-retry-spool` path both drain.
|
|
1564
|
+
*/
|
|
1565
|
+
const explicitSpoolLayout = (directory) => {
|
|
1566
|
+
const resolved = path.resolve(directory);
|
|
1567
|
+
const spool = path.basename(resolved) === "hq-retry-spool" ? resolved : path.join(resolved, HQ_RETRY_SPOOL_DIRNAME);
|
|
1568
|
+
const memory = path.dirname(spool);
|
|
1569
|
+
return {
|
|
1570
|
+
create: false,
|
|
1571
|
+
root: path.dirname(memory),
|
|
1572
|
+
segments: [path.basename(memory), path.basename(spool)]
|
|
1573
|
+
};
|
|
1574
|
+
};
|
|
1575
|
+
const secureChain = async (layout, segments, deadline) => {
|
|
1433
1576
|
let root;
|
|
1434
1577
|
try {
|
|
1435
|
-
|
|
1578
|
+
if (layout.createRoot === true) await settleWithin(mkdir(layout.root, { recursive: true }), remainingMs(deadline));
|
|
1579
|
+
const canonical = await settleWithin(realpath(layout.root), remainingMs(deadline));
|
|
1436
1580
|
if (canonical.status !== "fulfilled") return;
|
|
1437
1581
|
root = canonical.value;
|
|
1438
1582
|
} catch {
|
|
1439
1583
|
return;
|
|
1440
1584
|
}
|
|
1441
1585
|
const rootIdentity = await readDirectoryIdentity(root, deadline);
|
|
1442
|
-
if (rootIdentity === void 0) return;
|
|
1443
|
-
const
|
|
1444
|
-
const spool = path.join(root, DEFAULT_HQ_RETRY_SPOOL_PATH);
|
|
1445
|
-
if (!(await secureStateDirectory(memory, deadline) && await secureStateDirectory(spool, deadline))) return;
|
|
1446
|
-
const plainMemoryIdentity = await readDirectoryIdentity(memory, deadline);
|
|
1447
|
-
const plainSpoolIdentity = await readDirectoryIdentity(spool, deadline);
|
|
1448
|
-
if (plainMemoryIdentity === void 0 || plainSpoolIdentity === void 0) return;
|
|
1449
|
-
const rootAnchor = {
|
|
1586
|
+
if (rootIdentity === void 0 || segments.length === 0) return;
|
|
1587
|
+
const ancestors = [{
|
|
1450
1588
|
directory: root,
|
|
1451
1589
|
identity: {
|
|
1452
1590
|
dev: rootIdentity.dev,
|
|
1453
1591
|
ino: rootIdentity.ino
|
|
1454
1592
|
}
|
|
1455
|
-
};
|
|
1456
|
-
const
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1593
|
+
}];
|
|
1594
|
+
const identities = [];
|
|
1595
|
+
const directories = [];
|
|
1596
|
+
let current = root;
|
|
1597
|
+
for (const segment of segments) {
|
|
1598
|
+
current = path.join(current, segment);
|
|
1599
|
+
if (layout.create && !await secureStateDirectory(current, deadline)) return;
|
|
1600
|
+
const plain = await readDirectoryIdentity(current, deadline);
|
|
1601
|
+
if (plain === void 0) return;
|
|
1602
|
+
identities.push({
|
|
1603
|
+
...plain,
|
|
1604
|
+
ancestors: [...ancestors]
|
|
1605
|
+
});
|
|
1606
|
+
ancestors.push({
|
|
1607
|
+
directory: current,
|
|
1464
1608
|
identity: {
|
|
1465
|
-
dev:
|
|
1466
|
-
ino:
|
|
1609
|
+
dev: plain.dev,
|
|
1610
|
+
ino: plain.ino
|
|
1467
1611
|
}
|
|
1468
|
-
}
|
|
1469
|
-
|
|
1470
|
-
|
|
1612
|
+
});
|
|
1613
|
+
directories.push(current);
|
|
1614
|
+
}
|
|
1615
|
+
return {
|
|
1616
|
+
directories,
|
|
1617
|
+
identities
|
|
1618
|
+
};
|
|
1619
|
+
};
|
|
1620
|
+
const secureSpoolLayout = async (layout, deadline) => {
|
|
1621
|
+
if (layout.segments.length < 2) return;
|
|
1622
|
+
const chain = await secureChain(layout, layout.segments, deadline);
|
|
1623
|
+
if (chain === void 0) return;
|
|
1624
|
+
const { directories, identities } = chain;
|
|
1625
|
+
const spool = directories.at(-1);
|
|
1626
|
+
const memory = directories.at(-2);
|
|
1627
|
+
const spoolIdentity = identities.at(-1);
|
|
1628
|
+
const memoryIdentity = identities.at(-2);
|
|
1629
|
+
if (spool === void 0 || memory === void 0 || spoolIdentity === void 0 || memoryIdentity === void 0 || !await directoryIdentityMatches(memory, memoryIdentity, deadline) || !await directoryIdentityMatches(spool, spoolIdentity, deadline)) return;
|
|
1471
1630
|
return {
|
|
1472
1631
|
memory,
|
|
1473
1632
|
memoryIdentity,
|
|
@@ -1475,6 +1634,24 @@ const secureRetrySpoolPath = async (cwd, deadline) => {
|
|
|
1475
1634
|
spoolIdentity
|
|
1476
1635
|
};
|
|
1477
1636
|
};
|
|
1637
|
+
/**
|
|
1638
|
+
* The legacy JSONL journal lives one level above the spool. Securing it on its
|
|
1639
|
+
* own lets a pre-spool `.factory-memory` (journal but no spool directory)
|
|
1640
|
+
* drain without the read path creating anything inside the worktree.
|
|
1641
|
+
*/
|
|
1642
|
+
const secureJournalDirectory = async (layout, deadline) => {
|
|
1643
|
+
const segments = layout.segments.slice(0, -1);
|
|
1644
|
+
if (segments.length === 0) return;
|
|
1645
|
+
const chain = await secureChain(layout, segments, deadline);
|
|
1646
|
+
if (chain === void 0) return;
|
|
1647
|
+
const directory = chain.directories.at(-1);
|
|
1648
|
+
const identity = chain.identities.at(-1);
|
|
1649
|
+
if (directory === void 0 || identity === void 0 || !await directoryIdentityMatches(directory, identity, deadline)) return;
|
|
1650
|
+
return {
|
|
1651
|
+
directory,
|
|
1652
|
+
identity
|
|
1653
|
+
};
|
|
1654
|
+
};
|
|
1478
1655
|
const readDirectoryIdentity = async (directory, deadline) => {
|
|
1479
1656
|
const opened = await openWithin(directory, constants.O_RDONLY + constants.O_DIRECTORY + constants.O_NOFOLLOW, remainingMs(deadline));
|
|
1480
1657
|
if (opened.status !== "fulfilled") return;
|
|
@@ -1524,19 +1701,58 @@ const publishImmutableFile = async (directory, directoryIdentity, sourcePath, de
|
|
|
1524
1701
|
await mutateBoundDirectory(directory, directoryIdentity, deadline, async () => unlink(sourcePath));
|
|
1525
1702
|
return true;
|
|
1526
1703
|
};
|
|
1527
|
-
const removeRetrySpoolEvent = async (
|
|
1528
|
-
const secured = await settleWithin(
|
|
1704
|
+
const removeRetrySpoolEvent = async (layout, eventPath, deadline) => {
|
|
1705
|
+
const secured = await settleWithin(secureSpoolLayout(layout, deadline), remainingMs(deadline));
|
|
1529
1706
|
if (secured.status !== "fulfilled" || secured.value === void 0 || path.dirname(eventPath) !== secured.value.spool) return false;
|
|
1530
1707
|
const { spool, spoolIdentity } = secured.value;
|
|
1531
1708
|
const metadata = await settleWithin(lstat(eventPath), remainingMs(deadline));
|
|
1532
1709
|
if (metadata.status !== "fulfilled" || !metadata.value.isFile() || metadata.value.isSymbolicLink()) return false;
|
|
1533
1710
|
return mutateBoundDirectory(spool, spoolIdentity, deadline, async () => unlink(eventPath));
|
|
1534
1711
|
};
|
|
1712
|
+
/**
|
|
1713
|
+
* Stamps the spool directory with the repository writing into it (#447).
|
|
1714
|
+
*
|
|
1715
|
+
* Best-effort on purpose, and never on the delivery path's critical line: a
|
|
1716
|
+
* marker that cannot be written costs future coverage, never this event. An
|
|
1717
|
+
* enqueue that failed because the provenance file could not be created would
|
|
1718
|
+
* trade evidence for bookkeeping, which is the wrong way round.
|
|
1719
|
+
*
|
|
1720
|
+
* Written once and then left alone — the first enqueue under a key stamps it,
|
|
1721
|
+
* and later ones find it present. Rewriting it per enqueue would churn a file
|
|
1722
|
+
* that never changes while the identity does not, for no gain: the sweep reads
|
|
1723
|
+
* it, and the sweep runs later.
|
|
1724
|
+
*
|
|
1725
|
+
* Note what write-once means for a rename: the marker keeps the *old* name,
|
|
1726
|
+
* and `markerClaims` matches on the current one, so the stamp does not survive
|
|
1727
|
+
* a rename as a claim. That is a known limit, not an intended property (#447).
|
|
1728
|
+
*/
|
|
1729
|
+
const writeRepositoryMarker = async (layout, spool, identity, deadline) => {
|
|
1730
|
+
if (layout.repository === void 0) return;
|
|
1731
|
+
const markerPath = path.join(spool, SPOOL_REPOSITORY_MARKER);
|
|
1732
|
+
const probed = await settleWithin(lstat(markerPath), remainingMs(deadline));
|
|
1733
|
+
if (probed.status !== "rejected" || !isMissingEntryError(probed.error)) return;
|
|
1734
|
+
const body = JSON.stringify({
|
|
1735
|
+
owner: layout.repository.owner,
|
|
1736
|
+
repo: layout.repository.repo,
|
|
1737
|
+
schemaVersion: 1
|
|
1738
|
+
});
|
|
1739
|
+
const temporary = path.join(spool, `${SPOOL_REPOSITORY_MARKER}.${randomUUID()}.tmp`);
|
|
1740
|
+
const opened = await openWithin(temporary, constants.O_CREAT + constants.O_EXCL + constants.O_WRONLY + constants.O_NOFOLLOW, remainingMs(deadline), 384);
|
|
1741
|
+
if (opened.status !== "fulfilled") return;
|
|
1742
|
+
const handle = opened.value;
|
|
1743
|
+
const written = await settleWithin(handle.writeFile(body, "utf-8"), remainingMs(deadline));
|
|
1744
|
+
await settleWithin(handle.close(), remainingMs(deadline));
|
|
1745
|
+
if (written.status !== "fulfilled") {
|
|
1746
|
+
await mutateBoundDirectory(spool, identity, deadline, async () => unlink(temporary));
|
|
1747
|
+
return;
|
|
1748
|
+
}
|
|
1749
|
+
if (!await mutateBoundDirectory(spool, identity, deadline, async () => rename(temporary, markerPath))) await mutateBoundDirectory(spool, identity, deadline, async () => unlink(temporary));
|
|
1750
|
+
};
|
|
1535
1751
|
const spoolFileName = (entry) => {
|
|
1536
1752
|
const { eventId } = entry.event;
|
|
1537
1753
|
return /^[A-Za-z0-9_-]{1,160}$/u.test(eventId) ? `${eventId}.json` : `${randomUUID()}.json`;
|
|
1538
1754
|
};
|
|
1539
|
-
const persistRetrySpool = async (
|
|
1755
|
+
const persistRetrySpool = async (layout, entry, deadline) => {
|
|
1540
1756
|
let body;
|
|
1541
1757
|
try {
|
|
1542
1758
|
body = JSON.stringify(entry);
|
|
@@ -1550,12 +1766,13 @@ const persistRetrySpool = async (cwd, entry, deadline) => {
|
|
|
1550
1766
|
failure: "failed",
|
|
1551
1767
|
status: "failed"
|
|
1552
1768
|
};
|
|
1553
|
-
const secured = await settleWithin(
|
|
1769
|
+
const secured = await settleWithin(secureSpoolLayout(layout, deadline), remainingMs(deadline));
|
|
1554
1770
|
if (secured.status !== "fulfilled" || secured.value === void 0) return {
|
|
1555
1771
|
failure: secured.status === "timed-out" ? "timed-out" : "failed",
|
|
1556
1772
|
status: "failed"
|
|
1557
1773
|
};
|
|
1558
1774
|
const { spool, spoolIdentity: identity } = secured.value;
|
|
1775
|
+
await writeRepositoryMarker(layout, spool, identity, deadline);
|
|
1559
1776
|
const finalName = `${randomUUID()}-${spoolFileName(entry)}`;
|
|
1560
1777
|
const temporary = path.join(spool, `.${finalName}.tmp`);
|
|
1561
1778
|
const finalPath = path.join(spool, finalName);
|
|
@@ -1603,8 +1820,8 @@ const persistRetrySpool = async (cwd, entry, deadline) => {
|
|
|
1603
1820
|
status: "failed"
|
|
1604
1821
|
};
|
|
1605
1822
|
};
|
|
1606
|
-
const appendCloseoutSpool = async (
|
|
1607
|
-
const result = await persistRetrySpool(
|
|
1823
|
+
const appendCloseoutSpool = async (layout, entry, deadline) => {
|
|
1824
|
+
const result = await persistRetrySpool(layout, entry, deadline);
|
|
1608
1825
|
return result.status === "failed" ? result.failure : void 0;
|
|
1609
1826
|
};
|
|
1610
1827
|
const positiveBudget = (value) => typeof value === "number" && Number.isFinite(value) && value > 0 ? value : void 0;
|
|
@@ -1652,8 +1869,11 @@ const settleWithin = async (operation, budgetMs) => {
|
|
|
1652
1869
|
status: "fulfilled",
|
|
1653
1870
|
value: await operation
|
|
1654
1871
|
};
|
|
1655
|
-
} catch {
|
|
1656
|
-
return {
|
|
1872
|
+
} catch (error) {
|
|
1873
|
+
return {
|
|
1874
|
+
error,
|
|
1875
|
+
status: "rejected"
|
|
1876
|
+
};
|
|
1657
1877
|
}
|
|
1658
1878
|
};
|
|
1659
1879
|
const timeoutAbort = new AbortController();
|
|
@@ -1828,21 +2048,21 @@ const validatedIngestEndpoint = (value) => {
|
|
|
1828
2048
|
const origin = validatedEndpoint(value);
|
|
1829
2049
|
return origin === void 0 ? void 0 : new URL(HQ_INGEST_PATH, origin.origin);
|
|
1830
2050
|
};
|
|
1831
|
-
const retainRetryEntry = async (
|
|
2051
|
+
const retainRetryEntry = async (layout, entry, dependencies, deadline, options = {}) => {
|
|
1832
2052
|
let customAppend;
|
|
1833
2053
|
try {
|
|
1834
2054
|
customAppend = dependencies.appendJournal;
|
|
1835
2055
|
} catch {
|
|
1836
2056
|
return "failed";
|
|
1837
2057
|
}
|
|
1838
|
-
if (customAppend === void 0) return appendCloseoutSpool(
|
|
2058
|
+
if (customAppend === void 0) return appendCloseoutSpool(layout, entry, deadline);
|
|
1839
2059
|
let row;
|
|
1840
2060
|
try {
|
|
1841
2061
|
row = JSON.stringify(entry);
|
|
1842
2062
|
} catch {
|
|
1843
2063
|
return "failed";
|
|
1844
2064
|
}
|
|
1845
|
-
const target = path.resolve(
|
|
2065
|
+
const target = path.join(path.resolve(layout.root, ...layout.segments), `${randomUUID()}-${spoolFileName(entry)}`);
|
|
1846
2066
|
if (options.awaitCustom !== true) {
|
|
1847
2067
|
setImmediate(async () => {
|
|
1848
2068
|
try {
|
|
@@ -1956,11 +2176,38 @@ async function handoffHqIngest(input, dependencies, setupDeadline) {
|
|
|
1956
2176
|
failedAt,
|
|
1957
2177
|
reason
|
|
1958
2178
|
};
|
|
1959
|
-
const
|
|
2179
|
+
const writeLayout = repositorySpoolLayout({
|
|
2180
|
+
owner: input.profile.repository.owner,
|
|
2181
|
+
repo: input.profile.repository.name
|
|
2182
|
+
}, env);
|
|
2183
|
+
const journalFailure = dependencies.appendJournal === void 0 ? await appendCloseoutSpool(writeLayout, retryEntry, flushDeadline) : await retainRetryEntry(writeLayout, retryEntry, dependencies, flushDeadline, { awaitCustom: true });
|
|
1960
2184
|
if (journalFailure) await reportDiagnostic(dependencies, flushDeadline, `closeout advisory handoff local spool ${journalFailure}`);
|
|
1961
2185
|
return journalFailure === void 0 && !dropped ? "deferred" : "held";
|
|
1962
2186
|
}
|
|
1963
|
-
|
|
2187
|
+
/**
|
|
2188
|
+
* A bounded read of the ingest receipt body. Only the explicit drain asks for
|
|
2189
|
+
* it: the gate path must not add a body read to its transport leg. Nothing but
|
|
2190
|
+
* HQ's own `duplicate` flag and a truncated error string is retained.
|
|
2191
|
+
*/
|
|
2192
|
+
const readIngestReceipt = async (response, budgetMs) => {
|
|
2193
|
+
const text = await settleWithin(Promise.resolve().then(async () => response.text()), budgetMs);
|
|
2194
|
+
if (text.status !== "fulfilled") return {};
|
|
2195
|
+
const source = text.value.slice(0, 4096);
|
|
2196
|
+
let parsed;
|
|
2197
|
+
try {
|
|
2198
|
+
parsed = JSON.parse(source);
|
|
2199
|
+
} catch {
|
|
2200
|
+
return source.trim() === "" ? {} : { detail: source.slice(0, 200).trim() };
|
|
2201
|
+
}
|
|
2202
|
+
if (parsed === null || typeof parsed !== "object") return {};
|
|
2203
|
+
const record = parsed;
|
|
2204
|
+
const message = [record.error, record.message].filter((value) => typeof value === "string").join(": ");
|
|
2205
|
+
return {
|
|
2206
|
+
...message === "" ? {} : { detail: message.slice(0, 200) },
|
|
2207
|
+
...typeof record.duplicate === "boolean" ? { duplicate: record.duplicate } : {}
|
|
2208
|
+
};
|
|
2209
|
+
};
|
|
2210
|
+
async function attemptTransport(request, endpoint, clientId, clientSecret, body, transportBudgetMs, receipt = false) {
|
|
1964
2211
|
if (transportBudgetMs <= 0) return {
|
|
1965
2212
|
ok: false,
|
|
1966
2213
|
reason: "transport-timeout",
|
|
@@ -1994,13 +2241,20 @@ async function attemptTransport(request, endpoint, clientId, clientSecret, body,
|
|
|
1994
2241
|
};
|
|
1995
2242
|
if (!transport.value.ok) {
|
|
1996
2243
|
const { status } = transport.value;
|
|
2244
|
+
const rejection = receipt ? await readIngestReceipt(transport.value, transportBudgetMs) : {};
|
|
1997
2245
|
return {
|
|
2246
|
+
...rejection.detail === void 0 ? {} : { detail: rejection.detail },
|
|
1998
2247
|
ok: false,
|
|
1999
2248
|
reason: typeof status === "number" && Number.isFinite(status) ? `http-status-${status}` : "http-error",
|
|
2000
2249
|
unconfirmed: false
|
|
2001
2250
|
};
|
|
2002
2251
|
}
|
|
2003
|
-
return { ok: true };
|
|
2252
|
+
if (!receipt) return { ok: true };
|
|
2253
|
+
const accepted = await readIngestReceipt(transport.value, transportBudgetMs);
|
|
2254
|
+
return {
|
|
2255
|
+
ok: true,
|
|
2256
|
+
...accepted.duplicate === void 0 ? {} : { duplicate: accepted.duplicate }
|
|
2257
|
+
};
|
|
2004
2258
|
}
|
|
2005
2259
|
const isReplayableEntry = (value) => {
|
|
2006
2260
|
if (value === null || typeof value !== "object") return false;
|
|
@@ -2011,8 +2265,27 @@ const isReplayableEntry = (value) => {
|
|
|
2011
2265
|
const eventRecord = event;
|
|
2012
2266
|
return "payload" in eventRecord && typeof eventRecord.eventId === "string" && typeof eventRecord.kind === "string";
|
|
2013
2267
|
};
|
|
2014
|
-
|
|
2015
|
-
|
|
2268
|
+
/**
|
|
2269
|
+
* 4xx is HQ refusing the content; anything else is the transport failing.
|
|
2270
|
+
* Non-content client statuses are transport-shaped — the content was never
|
|
2271
|
+
* judged — so classifying them as rejected would let a drain exit clean while
|
|
2272
|
+
* automation stops retrying: 401/403 (Access refusing expired or wrong
|
|
2273
|
+
* service-token credentials), 408 (request timeout), 429 (rate limit).
|
|
2274
|
+
*/
|
|
2275
|
+
const NON_CONTENT_CLIENT_STATUSES = new Set([
|
|
2276
|
+
401,
|
|
2277
|
+
403,
|
|
2278
|
+
408,
|
|
2279
|
+
429
|
|
2280
|
+
]);
|
|
2281
|
+
const drainFailureStatus = (reason) => {
|
|
2282
|
+
const status = Number(reason.replace("http-status-", ""));
|
|
2283
|
+
return reason.startsWith("http-status-") && Number.isFinite(status) && status >= 400 && status < 500 && !NON_CONTENT_CLIENT_STATUSES.has(status) ? "rejected" : "unreachable";
|
|
2284
|
+
};
|
|
2285
|
+
const replayCloseoutSpool = async (layout, endpoint, clientId, clientSecret, request, transportBudgetMs, setupDeadline, replayDeadline, flushBudgetMs, excludedEventPath, options = {}) => {
|
|
2286
|
+
const maxEntries = options.maxEntries ?? MAX_HQ_REPLAY_ENTRIES;
|
|
2287
|
+
const { report } = options;
|
|
2288
|
+
const secured = await settleWithin(secureSpoolLayout(layout, setupDeadline), remainingMs(setupDeadline));
|
|
2016
2289
|
if (secured.status !== "fulfilled" || secured.value === void 0) return;
|
|
2017
2290
|
const { spool, spoolIdentity } = secured.value;
|
|
2018
2291
|
const initialListing = await settleWithin(readdir(spool), remainingMs(setupDeadline));
|
|
@@ -2053,7 +2326,7 @@ const replayCloseoutSpool = async (cwd, endpoint, clientId, clientSecret, reques
|
|
|
2053
2326
|
let attempted = 0;
|
|
2054
2327
|
for (const eventPath of candidates) {
|
|
2055
2328
|
if (!await directoryIdentityMatches(spool, spoolIdentity, setupDeadline)) return;
|
|
2056
|
-
if (attempted >=
|
|
2329
|
+
if (attempted >= maxEntries || remainingMs(replayDeadline) <= 0) break;
|
|
2057
2330
|
const metadata = await settleWithin(lstat(eventPath), remainingMs(setupDeadline));
|
|
2058
2331
|
if (metadata.status !== "fulfilled" || !metadata.value.isFile() || metadata.value.isSymbolicLink() || metadata.value.size > MAX_HQ_INGEST_PAYLOAD_BYTES + 16 * 1024) continue;
|
|
2059
2332
|
const claimPath = `${eventPath}.draining-${randomUUID()}`;
|
|
@@ -2071,7 +2344,14 @@ const replayCloseoutSpool = async (cwd, endpoint, clientId, clientSecret, reques
|
|
|
2071
2344
|
}
|
|
2072
2345
|
const entryEndpoint = validatedEndpoint(entry.endpoint);
|
|
2073
2346
|
if (!entryEndpoint || entryEndpoint.origin !== endpoint.origin) {
|
|
2074
|
-
await mutateBoundDirectory(spool, spoolIdentity,
|
|
2347
|
+
if (!await mutateBoundDirectory(spool, spoolIdentity, performance.now() + flushBudgetMs, async () => rename(claimPath, `${eventPath}.undeliverable`))) return;
|
|
2348
|
+
report?.({
|
|
2349
|
+
detail: `recorded endpoint ${entryEndpoint?.origin ?? "(unusable)"} is not this repository's authorized HQ origin ${endpoint.origin}; dispositioned as ${path.basename(eventPath)}${UNDELIVERABLE_SUFFIX}`,
|
|
2350
|
+
eventId: entry.event.eventId,
|
|
2351
|
+
kind: entry.event.kind,
|
|
2352
|
+
spool,
|
|
2353
|
+
status: "undeliverable"
|
|
2354
|
+
});
|
|
2075
2355
|
continue;
|
|
2076
2356
|
}
|
|
2077
2357
|
let body;
|
|
@@ -2082,7 +2362,22 @@ const replayCloseoutSpool = async (cwd, endpoint, clientId, clientSecret, reques
|
|
|
2082
2362
|
continue;
|
|
2083
2363
|
}
|
|
2084
2364
|
attempted += 1;
|
|
2085
|
-
const outcome = await attemptTransport(request, endpoint, clientId, clientSecret, body, Math.min(transportBudgetMs, remainingMs(replayDeadline)));
|
|
2365
|
+
const outcome = await attemptTransport(request, endpoint, clientId, clientSecret, body, Math.min(transportBudgetMs, remainingMs(replayDeadline)), report !== void 0);
|
|
2366
|
+
if (report) {
|
|
2367
|
+
const common = {
|
|
2368
|
+
eventId: entry.event.eventId,
|
|
2369
|
+
kind: entry.event.kind,
|
|
2370
|
+
spool
|
|
2371
|
+
};
|
|
2372
|
+
report(outcome.ok ? {
|
|
2373
|
+
...common,
|
|
2374
|
+
status: outcome.duplicate === true ? "duplicate" : "delivered"
|
|
2375
|
+
} : {
|
|
2376
|
+
...common,
|
|
2377
|
+
detail: outcome.detail === void 0 ? outcome.reason : `${outcome.reason}: ${outcome.detail}`,
|
|
2378
|
+
status: drainFailureStatus(outcome.reason)
|
|
2379
|
+
});
|
|
2380
|
+
}
|
|
2086
2381
|
if (!await mutateBoundDirectory(spool, spoolIdentity, performance.now() + flushBudgetMs, () => outcome.ok ? unlink(claimPath) : rename(claimPath, eventPath))) return;
|
|
2087
2382
|
}
|
|
2088
2383
|
};
|
|
@@ -2125,11 +2420,13 @@ const releaseLegacyClaim = async (claimPath, target, directory, directoryIdentit
|
|
|
2125
2420
|
const readyPath = `${target}.legacy-ready-${randomUUID()}`;
|
|
2126
2421
|
return mutateBoundDirectory(directory, directoryIdentity, deadline, async () => rename(claimPath, readyPath));
|
|
2127
2422
|
};
|
|
2128
|
-
async function replayRetryJournal(
|
|
2129
|
-
const
|
|
2423
|
+
async function replayRetryJournal(layout, writeLayout, endpoint, clientId, clientSecret, request, dependencies, transportBudgetMs, setupDeadline, replayDeadline, options = {}) {
|
|
2424
|
+
const maxEntries = options.maxEntries ?? MAX_HQ_REPLAY_ENTRIES;
|
|
2425
|
+
const { report } = options;
|
|
2426
|
+
const secured = await settleWithin(secureJournalDirectory(layout, setupDeadline), remainingMs(setupDeadline));
|
|
2130
2427
|
if (secured.status !== "fulfilled" || secured.value === void 0) return;
|
|
2131
|
-
const {
|
|
2132
|
-
const target = path.join(directory,
|
|
2428
|
+
const { directory, identity: directoryIdentity } = secured.value;
|
|
2429
|
+
const target = path.join(directory, HQ_RETRY_JOURNAL_BASENAME);
|
|
2133
2430
|
const baseName = path.basename(target);
|
|
2134
2431
|
const listing = await settleWithin(readdir(directory), remainingMs(setupDeadline));
|
|
2135
2432
|
if (listing.status !== "fulfilled") return;
|
|
@@ -2137,7 +2434,7 @@ async function replayRetryJournal(cwd, endpoint, clientId, clientSecret, request
|
|
|
2137
2434
|
let corruptDropped = 0;
|
|
2138
2435
|
const sources = listing.value.filter((name) => name === baseName || name.startsWith(`${baseName}.legacy-ready-`) || name.startsWith(`${baseName}.draining-`) || name.startsWith(`${baseName}.legacy-claim-`)).filter((name) => !name.includes(".cursor")).toSorted();
|
|
2139
2436
|
for (const name of sources) {
|
|
2140
|
-
if (delivered >=
|
|
2437
|
+
if (delivered >= maxEntries || remainingMs(replayDeadline) <= 0) break;
|
|
2141
2438
|
const sourcePath = path.join(directory, name);
|
|
2142
2439
|
if (name.startsWith(`${baseName}.draining-`) || name.startsWith(`${baseName}.legacy-claim-`)) {
|
|
2143
2440
|
const metadata = await settleWithin(lstat(sourcePath), remainingMs(setupDeadline));
|
|
@@ -2185,7 +2482,7 @@ async function replayRetryJournal(cwd, endpoint, clientId, clientSecret, request
|
|
|
2185
2482
|
let reachedEof = true;
|
|
2186
2483
|
try {
|
|
2187
2484
|
for await (const line of lines) {
|
|
2188
|
-
if (delivered >=
|
|
2485
|
+
if (delivered >= maxEntries || remainingMs(replayDeadline) <= 0 || remainingMs(setupDeadline) <= 0) {
|
|
2189
2486
|
reachedEof = false;
|
|
2190
2487
|
break;
|
|
2191
2488
|
}
|
|
@@ -2220,7 +2517,21 @@ async function replayRetryJournal(cwd, endpoint, clientId, clientSecret, request
|
|
|
2220
2517
|
}
|
|
2221
2518
|
const entryEndpoint = validatedEndpoint(parsed.endpoint);
|
|
2222
2519
|
let handled = false;
|
|
2520
|
+
const row = {
|
|
2521
|
+
eventId: parsed.event.eventId,
|
|
2522
|
+
kind: parsed.event.kind
|
|
2523
|
+
};
|
|
2524
|
+
let rowOutcome = {
|
|
2525
|
+
detail: `recorded endpoint ${entryEndpoint?.origin ?? "(unusable)"} is not this repository's authorized HQ origin ${endpoint.origin}`,
|
|
2526
|
+
...row,
|
|
2527
|
+
spool: directory,
|
|
2528
|
+
status: "migrated"
|
|
2529
|
+
};
|
|
2223
2530
|
if (entryEndpoint?.origin === endpoint.origin) {
|
|
2531
|
+
rowOutcome = {
|
|
2532
|
+
...rowOutcome,
|
|
2533
|
+
detail: "event could not be serialized for delivery"
|
|
2534
|
+
};
|
|
2224
2535
|
let body;
|
|
2225
2536
|
try {
|
|
2226
2537
|
body = JSON.stringify(parsed.event);
|
|
@@ -2228,16 +2539,35 @@ async function replayRetryJournal(cwd, endpoint, clientId, clientSecret, request
|
|
|
2228
2539
|
body = void 0;
|
|
2229
2540
|
}
|
|
2230
2541
|
if (body !== void 0) {
|
|
2231
|
-
|
|
2542
|
+
const outcome = await attemptTransport(request, endpoint, clientId, clientSecret, body, Math.min(transportBudgetMs, remainingMs(replayDeadline)), report !== void 0);
|
|
2543
|
+
handled = outcome.ok;
|
|
2232
2544
|
if (handled) delivered += 1;
|
|
2545
|
+
rowOutcome = outcome.ok ? {
|
|
2546
|
+
...row,
|
|
2547
|
+
spool: directory,
|
|
2548
|
+
status: outcome.duplicate === true ? "duplicate" : "delivered"
|
|
2549
|
+
} : {
|
|
2550
|
+
...row,
|
|
2551
|
+
detail: outcome.detail === void 0 ? outcome.reason : `${outcome.reason}: ${outcome.detail}`,
|
|
2552
|
+
spool: directory,
|
|
2553
|
+
status: drainFailureStatus(outcome.reason)
|
|
2554
|
+
};
|
|
2233
2555
|
}
|
|
2234
2556
|
}
|
|
2235
2557
|
if (!handled) {
|
|
2236
|
-
|
|
2558
|
+
const retained = await appendCloseoutSpool(writeLayout, parsed, performance.now() + journalFlushBudgetFor(dependencies));
|
|
2559
|
+
if (retained === void 0) options.onMigrate?.();
|
|
2560
|
+
if (retained !== void 0) {
|
|
2561
|
+
report?.({
|
|
2562
|
+
...rowOutcome,
|
|
2563
|
+
detail: `${rowOutcome.detail ?? rowOutcome.status}; migration into the current spool ${retained}`,
|
|
2564
|
+
status: "unreachable"
|
|
2565
|
+
});
|
|
2237
2566
|
reachedEof = false;
|
|
2238
2567
|
break;
|
|
2239
2568
|
}
|
|
2240
2569
|
}
|
|
2570
|
+
report?.(rowOutcome);
|
|
2241
2571
|
offset = nextOffset;
|
|
2242
2572
|
if (!await writeLegacyCursor(directory, directoryIdentity, cursorPath, offset, setupDeadline)) {
|
|
2243
2573
|
reachedEof = false;
|
|
@@ -2309,6 +2639,11 @@ async function deliverHqIngest(input, dependencies, setupDeadline, timeoutMs, tr
|
|
|
2309
2639
|
} catch {
|
|
2310
2640
|
request = void 0;
|
|
2311
2641
|
}
|
|
2642
|
+
const writeLayout = repositorySpoolLayout({
|
|
2643
|
+
owner: input.profile.repository.owner,
|
|
2644
|
+
repo: input.profile.repository.name
|
|
2645
|
+
}, env);
|
|
2646
|
+
const readLayouts = [writeLayout, legacySpoolLayout(input.cwd)];
|
|
2312
2647
|
const now = dependencies.now ?? (() => /* @__PURE__ */ new Date());
|
|
2313
2648
|
const makeEventId = dependencies.randomUUID ?? randomUUID;
|
|
2314
2649
|
let event;
|
|
@@ -2354,7 +2689,7 @@ async function deliverHqIngest(input, dependencies, setupDeadline, timeoutMs, tr
|
|
|
2354
2689
|
} catch {
|
|
2355
2690
|
failedAt = "unknown";
|
|
2356
2691
|
}
|
|
2357
|
-
const persisted = await persistRetrySpool(
|
|
2692
|
+
const persisted = await persistRetrySpool(writeLayout, {
|
|
2358
2693
|
endpoint: endpoint.href,
|
|
2359
2694
|
event,
|
|
2360
2695
|
failedAt,
|
|
@@ -2379,14 +2714,16 @@ async function deliverHqIngest(input, dependencies, setupDeadline, timeoutMs, tr
|
|
|
2379
2714
|
throw new Error(reason);
|
|
2380
2715
|
}
|
|
2381
2716
|
const replayDeadline = performance.now() + timeoutMs;
|
|
2382
|
-
|
|
2383
|
-
|
|
2717
|
+
for (const readLayout of readLayouts) {
|
|
2718
|
+
await replayCloseoutSpool(readLayout, endpoint, clientId, clientSecret, request, transportBudgetMs, replayDeadline, replayDeadline, journalFlushBudgetFor(dependencies), persistedEventPath);
|
|
2719
|
+
await replayRetryJournal(readLayout, writeLayout, endpoint, clientId, clientSecret, request, dependencies, transportBudgetMs, replayDeadline, replayDeadline);
|
|
2720
|
+
}
|
|
2384
2721
|
const outcome = await attemptTransport(request, endpoint, clientId, clientSecret, body, transportBudgetMs);
|
|
2385
2722
|
if (!outcome.ok) {
|
|
2386
2723
|
({reason, unconfirmed} = outcome);
|
|
2387
2724
|
throw new Error(reason);
|
|
2388
2725
|
}
|
|
2389
|
-
if (persistedEventPath !== void 0) await removeRetrySpoolEvent(
|
|
2726
|
+
if (persistedEventPath !== void 0) await removeRetrySpoolEvent(writeLayout, persistedEventPath, performance.now() + journalFlushBudgetFor(dependencies));
|
|
2390
2727
|
return "delivered";
|
|
2391
2728
|
} catch {}
|
|
2392
2729
|
const flushDeadline = performance.now() + journalFlushBudgetFor(dependencies);
|
|
@@ -2411,16 +2748,17 @@ async function deliverHqIngest(input, dependencies, setupDeadline, timeoutMs, tr
|
|
|
2411
2748
|
reason,
|
|
2412
2749
|
...unconfirmed ? { unconfirmed: true } : {}
|
|
2413
2750
|
};
|
|
2414
|
-
if (customJournal && persistedEventPath === void 0) journalFailure = await retainRetryEntry(
|
|
2751
|
+
if (customJournal && persistedEventPath === void 0) journalFailure = await retainRetryEntry(writeLayout, retryEntry, dependencies, flushDeadline);
|
|
2415
2752
|
else if (!customJournal) {
|
|
2416
|
-
const updated = await persistRetrySpool(
|
|
2753
|
+
const updated = await persistRetrySpool(writeLayout, retryEntry, flushDeadline);
|
|
2417
2754
|
if (updated.status === "persisted") {
|
|
2418
|
-
if (persistedEventPath !== void 0) await removeRetrySpoolEvent(
|
|
2755
|
+
if (persistedEventPath !== void 0) await removeRetrySpoolEvent(writeLayout, persistedEventPath, flushDeadline);
|
|
2419
2756
|
persistedEventPath = updated.eventPath;
|
|
2420
2757
|
journalFailure = void 0;
|
|
2421
2758
|
} else journalFailure = updated.failure;
|
|
2422
2759
|
}
|
|
2423
2760
|
}
|
|
2761
|
+
if (!unconfirmed && journalFailure === void 0 && persistedEventPath !== void 0) return "deferred";
|
|
2424
2762
|
const journalSuffix = journalFailure ? `; retry spool ${journalFailure}` : "";
|
|
2425
2763
|
await reportDiagnostic(dependencies, flushDeadline, unconfirmed ? `sent, awaiting confirmation (${reason})${journalSuffix}` : `${reason}${journalSuffix}`, unconfirmed ? "HQ ingest unconfirmed" : "HQ ingest deferred");
|
|
2426
2764
|
return "deferred";
|
|
@@ -2454,6 +2792,376 @@ const trackPendingHqIngest = (delivery) => {
|
|
|
2454
2792
|
async function awaitPendingHqIngest() {
|
|
2455
2793
|
await Promise.allSettled(pendingHqIngest);
|
|
2456
2794
|
}
|
|
2795
|
+
/** Wall-clock ceiling for one explicit drain. Flush is not a gate; it may wait. */
|
|
2796
|
+
const DEFAULT_HQ_FLUSH_BUDGET_MS = 600 * 1e3;
|
|
2797
|
+
/** ENOENT is the only filesystem answer that means "this location is absent". */
|
|
2798
|
+
const isMissingEntryError = (error) => typeof error === "object" && error !== null && error.code === "ENOENT";
|
|
2799
|
+
const journalRowCount = (contents) => contents.split("\n").filter((line) => line.trim() !== "").length;
|
|
2800
|
+
const journalLineTimestamps = (contents) => {
|
|
2801
|
+
const timestamps = [];
|
|
2802
|
+
for (const line of contents.split("\n")) {
|
|
2803
|
+
const trimmed = line.trim();
|
|
2804
|
+
if (trimmed === "") continue;
|
|
2805
|
+
try {
|
|
2806
|
+
const parsed = JSON.parse(trimmed);
|
|
2807
|
+
if (typeof parsed.failedAt === "string") timestamps.push(parsed.failedAt);
|
|
2808
|
+
} catch {}
|
|
2809
|
+
}
|
|
2810
|
+
return timestamps;
|
|
2811
|
+
};
|
|
2812
|
+
const countRemainingSpoolWork = async (spools, drainLayouts, deadline) => {
|
|
2813
|
+
let remaining = 0;
|
|
2814
|
+
let unlistableScans = 0;
|
|
2815
|
+
let oldestMs;
|
|
2816
|
+
const noteTimestamp = (iso) => {
|
|
2817
|
+
const ms = Date.parse(iso);
|
|
2818
|
+
if (Number.isFinite(ms) && (oldestMs === void 0 || ms < oldestMs)) oldestMs = ms;
|
|
2819
|
+
};
|
|
2820
|
+
const countUnlistable = async (directory) => {
|
|
2821
|
+
if (remainingMs(deadline) <= 0) {
|
|
2822
|
+
unlistableScans += 1;
|
|
2823
|
+
return;
|
|
2824
|
+
}
|
|
2825
|
+
const probed = await settleWithin(lstat(directory), remainingMs(deadline));
|
|
2826
|
+
if (probed.status !== "rejected" || !isMissingEntryError(probed.error)) unlistableScans += 1;
|
|
2827
|
+
};
|
|
2828
|
+
for (const spool of spools) {
|
|
2829
|
+
const listing = await settleWithin(readdir(spool), remainingMs(deadline));
|
|
2830
|
+
if (listing.status !== "fulfilled") {
|
|
2831
|
+
await countUnlistable(spool);
|
|
2832
|
+
continue;
|
|
2833
|
+
}
|
|
2834
|
+
const pendingNames = listing.value.filter((name) => name.endsWith(".json") || name.includes(".json.draining-") || name.startsWith(".") && name.endsWith(".json.tmp"));
|
|
2835
|
+
remaining += pendingNames.length;
|
|
2836
|
+
for (const name of pendingNames) {
|
|
2837
|
+
const probed = await settleWithin(lstat(path.join(spool, name)), remainingMs(deadline));
|
|
2838
|
+
if (probed.status === "fulfilled") noteTimestamp(probed.value.mtime.toISOString());
|
|
2839
|
+
}
|
|
2840
|
+
}
|
|
2841
|
+
for (const layout of drainLayouts) {
|
|
2842
|
+
const journalDir = path.dirname(layoutPath(layout));
|
|
2843
|
+
const journalListing = await settleWithin(readdir(journalDir), remainingMs(deadline));
|
|
2844
|
+
if (journalListing.status !== "fulfilled") {
|
|
2845
|
+
await countUnlistable(journalDir);
|
|
2846
|
+
continue;
|
|
2847
|
+
}
|
|
2848
|
+
const journalNames = journalListing.value.filter((name) => name === "hq-retry-journal.jsonl" || name.startsWith(`hq-retry-journal.jsonl.legacy-ready-`) || name.startsWith(`hq-retry-journal.jsonl.legacy-claim-`) || name.startsWith(`hq-retry-journal.jsonl.draining-`));
|
|
2849
|
+
for (const name of journalNames) {
|
|
2850
|
+
const journalFilePath = path.join(journalDir, name);
|
|
2851
|
+
const read = await settleWithin(readFile(journalFilePath, "utf-8"), remainingMs(deadline));
|
|
2852
|
+
if (read.status === "fulfilled") {
|
|
2853
|
+
remaining += journalRowCount(read.value);
|
|
2854
|
+
for (const timestamp of journalLineTimestamps(read.value)) noteTimestamp(timestamp);
|
|
2855
|
+
continue;
|
|
2856
|
+
}
|
|
2857
|
+
remaining += 1;
|
|
2858
|
+
const probed = await settleWithin(lstat(journalFilePath), remainingMs(deadline));
|
|
2859
|
+
if (probed.status === "fulfilled") noteTimestamp(probed.value.mtime.toISOString());
|
|
2860
|
+
}
|
|
2861
|
+
}
|
|
2862
|
+
return {
|
|
2863
|
+
...oldestMs === void 0 ? {} : { oldestQueuedAt: new Date(oldestMs).toISOString() },
|
|
2864
|
+
remaining,
|
|
2865
|
+
unlistableScans
|
|
2866
|
+
};
|
|
2867
|
+
};
|
|
2868
|
+
/** Wall-clock ceiling for the read-only inspection below. */
|
|
2869
|
+
const DEFAULT_HQ_SPOOL_INSPECT_BUDGET_MS = 5e3;
|
|
2870
|
+
/**
|
|
2871
|
+
* Counts spooled work for a repository without draining it or touching a
|
|
2872
|
+
* credential (#414).
|
|
2873
|
+
*
|
|
2874
|
+
* `hq:flush` used to resolve the HQ Access token before it ever looked at the
|
|
2875
|
+
* spool, so a lane with nothing to send still paid a secret-manager round trip
|
|
2876
|
+
* — and still failed, opaquely, in a sandbox that has no keychain access. The
|
|
2877
|
+
* same locations `flushHqSpool` drains are inspected here, read-only: no
|
|
2878
|
+
* directory is created, nothing is secured, and nothing is delivered.
|
|
2879
|
+
*/
|
|
2880
|
+
async function countHqSpoolWork(input, dependencies = {}) {
|
|
2881
|
+
const env = dependencies.env ?? process.env;
|
|
2882
|
+
const layouts = input.explicitDirectories && input.explicitDirectories.length > 0 ? input.explicitDirectories.map(explicitSpoolLayout) : [repositorySpoolLayout(input.repository, env), legacySpoolLayout(input.cwd)];
|
|
2883
|
+
const deadline = performance.now() + (dependencies.budgetMs ?? DEFAULT_HQ_SPOOL_INSPECT_BUDGET_MS);
|
|
2884
|
+
const paths = layouts.map((layout) => layoutPath(layout));
|
|
2885
|
+
const { oldestQueuedAt, remaining, unlistableScans } = await countRemainingSpoolWork(paths, layouts, deadline);
|
|
2886
|
+
const existing = await Promise.all(paths.map(async (spool) => {
|
|
2887
|
+
return (await settleWithin(lstat(spool), remainingMs(deadline))).status === "fulfilled" ? spool : void 0;
|
|
2888
|
+
}));
|
|
2889
|
+
return {
|
|
2890
|
+
...oldestQueuedAt === void 0 ? {} : { oldestQueuedAt },
|
|
2891
|
+
pending: remaining,
|
|
2892
|
+
spools: existing.filter((spool) => spool !== void 0),
|
|
2893
|
+
unlistable: unlistableScans
|
|
2894
|
+
};
|
|
2895
|
+
}
|
|
2896
|
+
/** Wall-clock ceiling for the orphan sweep below. */
|
|
2897
|
+
const DEFAULT_HQ_SPOOL_SWEEP_BUDGET_MS = 5e3;
|
|
2898
|
+
/**
|
|
2899
|
+
* The pre-#390 lossy substitution, kept only so a key written under it can
|
|
2900
|
+
* still be *found*. Nothing writes this scheme any more; it is ambiguous,
|
|
2901
|
+
* which is why `hqSpoolSegment` replaced it. It only ever appeared in the
|
|
2902
|
+
* single joined segment (`fb1d14a`) — never as two segments, which is why no
|
|
2903
|
+
* two-segment lossy candidate is derived below.
|
|
2904
|
+
*/
|
|
2905
|
+
const lossyHqSpoolSegment = (component) => component.replaceAll(/[^A-Za-z0-9._-]/gu, "_");
|
|
2906
|
+
const hqSpoolCandidateKeys = (repository) => {
|
|
2907
|
+
const joined = `${repository.owner}-${repository.repo}`;
|
|
2908
|
+
const candidates = [
|
|
2909
|
+
repositorySegments(repository),
|
|
2910
|
+
[hqSpoolSegment(joined)],
|
|
2911
|
+
[lossyHqSpoolSegment(joined)]
|
|
2912
|
+
];
|
|
2913
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2914
|
+
return candidates.filter((segments) => {
|
|
2915
|
+
const key = segments.join("/");
|
|
2916
|
+
if (seen.has(key)) return false;
|
|
2917
|
+
seen.add(key);
|
|
2918
|
+
return true;
|
|
2919
|
+
});
|
|
2920
|
+
};
|
|
2921
|
+
/**
|
|
2922
|
+
* Does this spool directory's marker name the given repository (#447)?
|
|
2923
|
+
*
|
|
2924
|
+
* Every answer other than an unambiguous yes is no. An absent, unreadable,
|
|
2925
|
+
* malformed, or foreign marker all mean the same thing here — nothing on disk
|
|
2926
|
+
* says this directory is ours — and the sweep may only act on directories that
|
|
2927
|
+
* say so. Failing closed in the other direction would be #446 again:
|
|
2928
|
+
* prescribing `hq:flush --dir` against evidence belonging to someone else.
|
|
2929
|
+
*/
|
|
2930
|
+
const markerClaims = async (spool, repository, deadline) => {
|
|
2931
|
+
const file = await readBoundedTextFileNoFollow(path.join(spool, SPOOL_REPOSITORY_MARKER), deadline, 4096);
|
|
2932
|
+
if (file === void 0) return false;
|
|
2933
|
+
let parsed;
|
|
2934
|
+
try {
|
|
2935
|
+
parsed = JSON.parse(file.contents);
|
|
2936
|
+
} catch {
|
|
2937
|
+
return false;
|
|
2938
|
+
}
|
|
2939
|
+
if (typeof parsed !== "object" || parsed === null) return false;
|
|
2940
|
+
const marker = parsed;
|
|
2941
|
+
return marker.owner === repository.owner && marker.repo === repository.repo;
|
|
2942
|
+
};
|
|
2943
|
+
/**
|
|
2944
|
+
* Walks the shared root for spool directories this repository has stamped
|
|
2945
|
+
* (#447).
|
|
2946
|
+
*
|
|
2947
|
+
* Symlinks are not followed and depth is capped: this is a read-only inventory
|
|
2948
|
+
* of one state tree, not a traversal of wherever an operator pointed something.
|
|
2949
|
+
*
|
|
2950
|
+
* A directory that cannot be listed is skipped rather than reported, which is
|
|
2951
|
+
* the opposite of how `countRemainingSpoolWork` treats an unreadable spool —
|
|
2952
|
+
* deliberately. There, "unknown" concerns a location already known to be ours,
|
|
2953
|
+
* so failing closed means reporting it. Here, "unknown" concerns *ownership*,
|
|
2954
|
+
* and failing closed means claiming a directory that may be another
|
|
2955
|
+
* repository's. The candidate-key probes above still fail closed for every
|
|
2956
|
+
* location this checkout can name on its own, so nothing that was reported
|
|
2957
|
+
* before stops being reported.
|
|
2958
|
+
*/
|
|
2959
|
+
const findMarkedSpools = async (repository, directory, deadline, depth = 1, found = []) => {
|
|
2960
|
+
if (depth > HQ_SPOOL_SWEEP_MAX_DEPTH || remainingMs(deadline) <= 0) return found;
|
|
2961
|
+
const listing = await settleWithin(readdir(directory, { withFileTypes: true }), remainingMs(deadline));
|
|
2962
|
+
if (listing.status !== "fulfilled") return found;
|
|
2963
|
+
for (const entry of listing.value) {
|
|
2964
|
+
if (!entry.isDirectory() || entry.isSymbolicLink()) continue;
|
|
2965
|
+
const child = path.join(directory, entry.name);
|
|
2966
|
+
if (entry.name === "hq-retry-spool") {
|
|
2967
|
+
if (await markerClaims(child, repository, deadline)) found.push(child);
|
|
2968
|
+
continue;
|
|
2969
|
+
}
|
|
2970
|
+
await findMarkedSpools(repository, child, deadline, depth + 1, found);
|
|
2971
|
+
}
|
|
2972
|
+
return found;
|
|
2973
|
+
};
|
|
2974
|
+
/**
|
|
2975
|
+
* Reports spooled evidence sitting under an earlier key for *this* repository
|
|
2976
|
+
* (#420).
|
|
2977
|
+
*
|
|
2978
|
+
* The spool is keyed by owner and repo, so a change to the segment encoding
|
|
2979
|
+
* itself — which happened during #390's own development — moves the address
|
|
2980
|
+
* without moving the evidence. Both readers of the spool resolve exactly one
|
|
2981
|
+
* key, so the events under the old one become invisible: the drain reports
|
|
2982
|
+
* success, doctor reports empty, and three real proofs sat unread until an
|
|
2983
|
+
* attended recovery enumerated the tree by hand.
|
|
2984
|
+
*
|
|
2985
|
+
* This sweep only reports. Draining another key's events is a decision this
|
|
2986
|
+
* does not make — the operator gets the location and the count, and
|
|
2987
|
+
* `hq:flush --dir` remains the recovery path.
|
|
2988
|
+
*
|
|
2989
|
+
* **Why candidate keys and not a walk of the root (#446).** The root is shared
|
|
2990
|
+
* by every factory repository on the machine, so enumerating it and calling
|
|
2991
|
+
* everything that is not the current key an orphan describes another
|
|
2992
|
+
* repository's ordinary, current, correct spool exactly as well as it describes
|
|
2993
|
+
* this repository's obsolete one. That made doctor red in one checkout because
|
|
2994
|
+
* a different repository had pending work, and told the operator to drain it —
|
|
2995
|
+
* confidently prescribing the wrong action. Nothing on disk distinguishes the
|
|
2996
|
+
* two cases: an unrecognised key carries no statement about who wrote it.
|
|
2997
|
+
*
|
|
2998
|
+
* So discovery is scoped to the keys *this* repository could plausibly have
|
|
2999
|
+
* produced — the current scheme plus the earlier ones listed in
|
|
3000
|
+
* `hqSpoolCandidateKeys` — and a key outside that set is never this
|
|
3001
|
+
* repository's business. The #420 incident is inside it: the joined
|
|
3002
|
+
* single-segment key is one of the candidates.
|
|
3003
|
+
*
|
|
3004
|
+
* **And a marked walk beside them (#447).** Derivation's other blind spot is a
|
|
3005
|
+
* key whose *encoding* this checkout no longer produces but whose owner and
|
|
3006
|
+
* repo are unchanged — the retired non-injective era being the live example.
|
|
3007
|
+
* That era cannot be derived safely, because a candidate built from it can
|
|
3008
|
+
* equal a different repository's current key, so #446 dropped it rather than
|
|
3009
|
+
* risk the cross-repository claim again.
|
|
3010
|
+
*
|
|
3011
|
+
* `SPOOL_REPOSITORY_MARKER` supplies the proof that derivation could not. Every
|
|
3012
|
+
* enqueue stamps its spool with the repository writing it, so the root can be
|
|
3013
|
+
* walked again: a directory whose marker names *this* repository is this
|
|
3014
|
+
* repository's, whatever key encoding it sits under, and a directory whose
|
|
3015
|
+
* marker names another repository is never reported here. That is the
|
|
3016
|
+
* discriminator #446 correctly said did not exist — it exists now because
|
|
3017
|
+
* something writes it down.
|
|
3018
|
+
*
|
|
3019
|
+
* The two discoveries are complements, not alternatives. The walk sees only
|
|
3020
|
+
* what was stamped; directories written before this shipped have no marker, and
|
|
3021
|
+
* candidate keys still find those. An unmarked directory is still never
|
|
3022
|
+
* reported, because it still carries no statement about who wrote it.
|
|
3023
|
+
*
|
|
3024
|
+
* **What this does NOT close, despite being the marker's obvious use: renames
|
|
3025
|
+
* and owner changes.** A marker records the identity that was current when the
|
|
3026
|
+
* directory was written, so after `patronage/old` becomes `patronage/new` the
|
|
3027
|
+
* stranded directory is stamped `patronage/old` — and matching is equality
|
|
3028
|
+
* against the checkout's *present* identity, which rejects it. Making that work
|
|
3029
|
+
* needs an identifier that survives a rename, which neither the profile nor the
|
|
3030
|
+
* marker carries today; accepting a non-matching marker instead would be
|
|
3031
|
+
* guessing, which is the #446 defect wearing a new hat. #447 stays open for it.
|
|
3032
|
+
*
|
|
3033
|
+
* Also outside the sweep, by construction: evidence under a *different state
|
|
3034
|
+
* root*, if `XDG_STATE_HOME` moves. No walk of this root can reach another one.
|
|
3035
|
+
*/
|
|
3036
|
+
async function sweepHqSpoolOrphans(input, dependencies = {}) {
|
|
3037
|
+
const env = dependencies.env ?? process.env;
|
|
3038
|
+
const root = hqSpoolRoot(env);
|
|
3039
|
+
const deadline = performance.now() + (dependencies.budgetMs ?? DEFAULT_HQ_SPOOL_SWEEP_BUDGET_MS);
|
|
3040
|
+
const own = hqSpoolDirectory(input.repository, env);
|
|
3041
|
+
const orphans = [];
|
|
3042
|
+
for (const segments of hqSpoolCandidateKeys(input.repository)) {
|
|
3043
|
+
const spool = path.join(root, ...segments, HQ_RETRY_SPOOL_DIRNAME);
|
|
3044
|
+
if (spool === own) continue;
|
|
3045
|
+
if (remainingMs(deadline) <= 0) {
|
|
3046
|
+
orphans.push({
|
|
3047
|
+
directory: spool,
|
|
3048
|
+
pending: 0,
|
|
3049
|
+
unlistable: 1
|
|
3050
|
+
});
|
|
3051
|
+
continue;
|
|
3052
|
+
}
|
|
3053
|
+
const counted = await countRemainingSpoolWork([spool], [explicitSpoolLayout(spool)], deadline);
|
|
3054
|
+
if (counted.remaining === 0 && counted.unlistableScans === 0) continue;
|
|
3055
|
+
orphans.push({
|
|
3056
|
+
directory: spool,
|
|
3057
|
+
...counted.oldestQueuedAt === void 0 ? {} : { oldestQueuedAt: counted.oldestQueuedAt },
|
|
3058
|
+
pending: counted.remaining,
|
|
3059
|
+
unlistable: counted.unlistableScans
|
|
3060
|
+
});
|
|
3061
|
+
}
|
|
3062
|
+
const claimed = await findMarkedSpools(input.repository, root, deadline);
|
|
3063
|
+
for (const spool of claimed) {
|
|
3064
|
+
if (spool === own || orphans.some((found) => found.directory === spool)) continue;
|
|
3065
|
+
if (remainingMs(deadline) <= 0) {
|
|
3066
|
+
orphans.push({
|
|
3067
|
+
directory: spool,
|
|
3068
|
+
pending: 0,
|
|
3069
|
+
unlistable: 1
|
|
3070
|
+
});
|
|
3071
|
+
continue;
|
|
3072
|
+
}
|
|
3073
|
+
const counted = await countRemainingSpoolWork([spool], [explicitSpoolLayout(spool)], deadline);
|
|
3074
|
+
if (counted.remaining === 0 && counted.unlistableScans === 0) continue;
|
|
3075
|
+
orphans.push({
|
|
3076
|
+
directory: spool,
|
|
3077
|
+
...counted.oldestQueuedAt === void 0 ? {} : { oldestQueuedAt: counted.oldestQueuedAt },
|
|
3078
|
+
pending: counted.remaining,
|
|
3079
|
+
unlistable: counted.unlistableScans
|
|
3080
|
+
});
|
|
3081
|
+
}
|
|
3082
|
+
return {
|
|
3083
|
+
orphans,
|
|
3084
|
+
root
|
|
3085
|
+
};
|
|
3086
|
+
}
|
|
3087
|
+
/**
|
|
3088
|
+
* Drains every spooled event for a repository and reports each one (#390).
|
|
3089
|
+
*
|
|
3090
|
+
* This is the deliberate counterpart to the sink's advisory emit path: the
|
|
3091
|
+
* caller has already resolved credentials explicitly, so there is no cap, no
|
|
3092
|
+
* daemon, and no background retry — one pass, bounded, over the repo-keyed
|
|
3093
|
+
* location plus the legacy cwd location (or the operator's `--dir`). Delivery
|
|
3094
|
+
* itself is the sink's own replay, so there is exactly one POST path.
|
|
3095
|
+
*/
|
|
3096
|
+
async function flushHqSpool(input, dependencies = {}) {
|
|
3097
|
+
const endpoint = validatedIngestEndpoint(input.endpoint);
|
|
3098
|
+
if (endpoint === void 0) throw new Error("HQ endpoint must be an HTTPS origin without embedded credentials.");
|
|
3099
|
+
const env = dependencies.env ?? process.env;
|
|
3100
|
+
const request = dependencies.fetch ?? fetch;
|
|
3101
|
+
const transportBudgetMs = dependencies.transportTimeoutMs ?? DEFAULT_HQ_TRANSPORT_TIMEOUT_MS;
|
|
3102
|
+
const flushBudgetMs = dependencies.journalFlushBudgetMs ?? HQ_JOURNAL_FLUSH_BUDGET_MS;
|
|
3103
|
+
const deadline = performance.now() + (dependencies.budgetMs ?? DEFAULT_HQ_FLUSH_BUDGET_MS);
|
|
3104
|
+
const writeLayout = repositorySpoolLayout(input.repository, env);
|
|
3105
|
+
const layouts = input.explicitDirectories && input.explicitDirectories.length > 0 ? input.explicitDirectories.map(explicitSpoolLayout) : [writeLayout, legacySpoolLayout(input.cwd)];
|
|
3106
|
+
const outcomeById = /* @__PURE__ */ new Map();
|
|
3107
|
+
let rejectedFiles = 0;
|
|
3108
|
+
let unreachableFiles = 0;
|
|
3109
|
+
let unsecurableSpools = 0;
|
|
3110
|
+
const record = (outcome) => {
|
|
3111
|
+
outcomeById.set(outcome.eventId, outcome);
|
|
3112
|
+
dependencies.report?.(outcome);
|
|
3113
|
+
};
|
|
3114
|
+
const journalReport = (outcome) => {
|
|
3115
|
+
if (outcome.status === "unreachable" && outcome.detail?.includes("migration into the current spool")) unreachableFiles += 1;
|
|
3116
|
+
record(outcome);
|
|
3117
|
+
};
|
|
3118
|
+
const spoolReport = (outcome) => {
|
|
3119
|
+
if (outcome.status === "rejected") rejectedFiles += 1;
|
|
3120
|
+
if (outcome.status === "unreachable") unreachableFiles += 1;
|
|
3121
|
+
record(outcome);
|
|
3122
|
+
};
|
|
3123
|
+
const spools = [];
|
|
3124
|
+
let migratedIntoWriteLayout = false;
|
|
3125
|
+
const journalDrainOptions = {
|
|
3126
|
+
maxEntries: Number.POSITIVE_INFINITY,
|
|
3127
|
+
onMigrate: () => {
|
|
3128
|
+
migratedIntoWriteLayout = true;
|
|
3129
|
+
},
|
|
3130
|
+
report: journalReport
|
|
3131
|
+
};
|
|
3132
|
+
for (const layout of layouts) await replayRetryJournal(layout, writeLayout, endpoint, input.clientId, input.clientSecret, request, {
|
|
3133
|
+
env,
|
|
3134
|
+
fetch: request,
|
|
3135
|
+
journalFlushBudgetMs: flushBudgetMs
|
|
3136
|
+
}, transportBudgetMs, deadline, deadline, journalDrainOptions);
|
|
3137
|
+
const drainLayouts = migratedIntoWriteLayout && !layouts.some((layout) => layoutPath(layout) === layoutPath(writeLayout)) ? [...layouts, writeLayout] : layouts;
|
|
3138
|
+
for (const layout of drainLayouts) {
|
|
3139
|
+
await replayCloseoutSpool(layout, endpoint, input.clientId, input.clientSecret, request, transportBudgetMs, deadline, deadline, flushBudgetMs, void 0, {
|
|
3140
|
+
maxEntries: Number.POSITIVE_INFINITY,
|
|
3141
|
+
report: spoolReport
|
|
3142
|
+
});
|
|
3143
|
+
const secured = await secureSpoolLayout(layout, deadline);
|
|
3144
|
+
if (secured === void 0) {
|
|
3145
|
+
if ((await settleWithin(lstat(layoutPath(layout)), remainingMs(deadline))).status === "fulfilled") unsecurableSpools += 1;
|
|
3146
|
+
} else spools.push(secured.spool);
|
|
3147
|
+
}
|
|
3148
|
+
const { remaining, unlistableScans } = await countRemainingSpoolWork(spools, drainLayouts, deadline);
|
|
3149
|
+
const outcomes = [...outcomeById.values()];
|
|
3150
|
+
const count = (status) => outcomes.filter((outcome) => outcome.status === status).length;
|
|
3151
|
+
const rejected = count("rejected");
|
|
3152
|
+
return {
|
|
3153
|
+
delivered: count("delivered"),
|
|
3154
|
+
duplicate: count("duplicate"),
|
|
3155
|
+
incomplete: remainingMs(deadline) <= 0 || unsecurableSpools > 0 || unlistableScans > 0 || remaining > rejectedFiles + unreachableFiles,
|
|
3156
|
+
outcomes,
|
|
3157
|
+
rejected,
|
|
3158
|
+
remaining,
|
|
3159
|
+
spools,
|
|
3160
|
+
undeliverable: count("undeliverable"),
|
|
3161
|
+
unreachable: count("unreachable"),
|
|
3162
|
+
unreachableFiles
|
|
3163
|
+
};
|
|
3164
|
+
}
|
|
2457
3165
|
/**
|
|
2458
3166
|
* Schedules one bounded HQ delivery after the current command stack. Producers
|
|
2459
3167
|
* provide the already-written proof through payloadPath (or bounded payloadJson);
|
|
@@ -2647,22 +3355,32 @@ const ISOLATED_CLOSEOUT_FS_WORKER_SOURCE = String.raw`
|
|
|
2647
3355
|
if (Buffer.byteLength(workerData.body, "utf8") > 278528) {
|
|
2648
3356
|
reply({ status: "failed" });
|
|
2649
3357
|
} else {
|
|
2650
|
-
|
|
2651
|
-
const
|
|
2652
|
-
const spool = path.join(memory, "hq-retry-spool");
|
|
3358
|
+
try { fs.mkdirSync(workerData.root, { recursive: true }); } catch {}
|
|
3359
|
+
const root = fs.realpathSync(workerData.root);
|
|
2653
3360
|
const rootIdentity = inspectDirectory(root);
|
|
2654
|
-
const
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
|
|
2665
|
-
|
|
3361
|
+
const directories = [];
|
|
3362
|
+
const identities = [];
|
|
3363
|
+
let current = root;
|
|
3364
|
+
let secured = rootIdentity !== undefined;
|
|
3365
|
+
for (const segment of workerData.segments) {
|
|
3366
|
+
current = path.join(current, segment);
|
|
3367
|
+
const identity = secured ? secureDirectory(current) : undefined;
|
|
3368
|
+
if (identity === undefined) {
|
|
3369
|
+
secured = false;
|
|
3370
|
+
break;
|
|
3371
|
+
}
|
|
3372
|
+
directories.push(current);
|
|
3373
|
+
identities.push(identity);
|
|
3374
|
+
}
|
|
3375
|
+
const spool = directories[directories.length - 1];
|
|
3376
|
+
const verifyChain = () =>
|
|
3377
|
+
rootIdentity !== undefined &&
|
|
3378
|
+
sameDirectory(root, rootIdentity) &&
|
|
3379
|
+
directories.every((directory, index) =>
|
|
3380
|
+
sameDirectory(directory, identities[index])
|
|
3381
|
+
);
|
|
3382
|
+
const spoolIdentity = identities[identities.length - 1];
|
|
3383
|
+
if (!secured || spool === undefined || !verifyChain()) {
|
|
2666
3384
|
reply({ status: "failed" });
|
|
2667
3385
|
} else {
|
|
2668
3386
|
const safeEventId = /^[A-Za-z0-9_-]{1,160}$/.test(workerData.eventId)
|
|
@@ -2686,18 +3404,12 @@ const ISOLATED_CLOSEOUT_FS_WORKER_SOURCE = String.raw`
|
|
|
2686
3404
|
fs.fsyncSync(descriptor);
|
|
2687
3405
|
fs.closeSync(descriptor);
|
|
2688
3406
|
descriptor = undefined;
|
|
2689
|
-
if (
|
|
2690
|
-
!sameDirectory(root, rootIdentity) ||
|
|
2691
|
-
!sameDirectory(memory, memoryIdentity) ||
|
|
2692
|
-
!sameDirectory(spool, spoolIdentity)
|
|
2693
|
-
) {
|
|
3407
|
+
if (!verifyChain()) {
|
|
2694
3408
|
reply({ status: "failed" });
|
|
2695
3409
|
} else {
|
|
2696
3410
|
fs.linkSync(temporary, finalPath);
|
|
2697
3411
|
if (
|
|
2698
|
-
!
|
|
2699
|
-
!sameDirectory(memory, memoryIdentity) ||
|
|
2700
|
-
!sameDirectory(spool, spoolIdentity) ||
|
|
3412
|
+
!verifyChain() ||
|
|
2701
3413
|
!syncDirectory(spool, spoolIdentity)
|
|
2702
3414
|
) {
|
|
2703
3415
|
reply({ status: "failed" });
|
|
@@ -2835,10 +3547,15 @@ const deferProjectHqIngestIsolated = async (input) => {
|
|
|
2835
3547
|
} catch {
|
|
2836
3548
|
return "held";
|
|
2837
3549
|
}
|
|
3550
|
+
const layout = repositorySpoolLayout({
|
|
3551
|
+
owner: parsedProfile.repository.owner,
|
|
3552
|
+
repo: parsedProfile.repository.name
|
|
3553
|
+
}, process.env);
|
|
2838
3554
|
const persisted = await runCloseoutFsWorker({
|
|
2839
3555
|
body,
|
|
2840
|
-
cwd: input.cwd,
|
|
2841
3556
|
eventId,
|
|
3557
|
+
root: layout.root,
|
|
3558
|
+
segments: layout.segments,
|
|
2842
3559
|
type: "persist"
|
|
2843
3560
|
}, HQ_JOURNAL_FLUSH_BUDGET_MS);
|
|
2844
3561
|
return persisted !== null && typeof persisted === "object" && persisted.status === "persisted" ? "deferred" : "held";
|
|
@@ -2895,6 +3612,191 @@ function assertCleanWorktreeForProof({ changedFiles, cwd, dirtyMessage, statusPo
|
|
|
2895
3612
|
if (offendingLines.length > 0) throw new Error(`${dirtyMessage}\n${offendingLines.join("\n")}`);
|
|
2896
3613
|
}
|
|
2897
3614
|
//#endregion
|
|
3615
|
+
//#region src/demand-keys.ts
|
|
3616
|
+
/** The demands that exist at most once per candidate. */
|
|
3617
|
+
const DEMAND_KEYS = {
|
|
3618
|
+
/** The PR is still a draft. */
|
|
3619
|
+
draft: "draft",
|
|
3620
|
+
/** The candidate is not the intended final human review point. */
|
|
3621
|
+
finalReviewPoint: "final-review-point",
|
|
3622
|
+
/** GitHub's own check rollup for the candidate head. */
|
|
3623
|
+
githubChecks: "github-checks",
|
|
3624
|
+
/** Local HEAD and the GitHub PR head must be the same commit. */
|
|
3625
|
+
headIdentity: "head-identity",
|
|
3626
|
+
/** An unhandled post-readiness human comment or review submission. */
|
|
3627
|
+
humanBlocker: "human-blocker",
|
|
3628
|
+
/** The repository-wide merge freeze. */
|
|
3629
|
+
mergeFreeze: "merge-freeze",
|
|
3630
|
+
/** GitHub's mergeability / merge-state rollup. */
|
|
3631
|
+
mergeState: "merge-state",
|
|
3632
|
+
/** The PR body's required rendered sections. */
|
|
3633
|
+
prBodySections: "pr-body-sections",
|
|
3634
|
+
/** A current, head-bound, passing typed `pr:verify` proof. */
|
|
3635
|
+
prVerify: "pr-verify",
|
|
3636
|
+
/** The profile-resolved review ladder policy. */
|
|
3637
|
+
reviewLadder: "review-ladder",
|
|
3638
|
+
/** Unresolved GitHub review threads. */
|
|
3639
|
+
reviewThreads: "review-threads",
|
|
3640
|
+
/** An explicit trivial waiver that the diff does not support. */
|
|
3641
|
+
trivialWaiver: "trivial-waiver"
|
|
3642
|
+
};
|
|
3643
|
+
/** The families whose instances are named by a resolved value. */
|
|
3644
|
+
const QUALIFIED_DEMAND_FAMILIES = [
|
|
3645
|
+
"required-check",
|
|
3646
|
+
"review-mode",
|
|
3647
|
+
"review-rung"
|
|
3648
|
+
];
|
|
3649
|
+
const QUALIFIER_PATTERN = /^[A-Za-z0-9._%/-]{1,64}$/u;
|
|
3650
|
+
const percentEncode = (character) => {
|
|
3651
|
+
const code = character.codePointAt(0) ?? 0;
|
|
3652
|
+
if (code >= 55296 && code <= 57343) return `%u${code.toString(16).toUpperCase().padStart(4, "0")}`;
|
|
3653
|
+
return [...new TextEncoder().encode(character)].map((byte) => `%${byte.toString(16).toUpperCase().padStart(2, "0")}`).join("");
|
|
3654
|
+
};
|
|
3655
|
+
/**
|
|
3656
|
+
* Express a resolved value as a qualifier. Percent-encoding, not scrubbing:
|
|
3657
|
+
* the mapping is injective, so two differently named required checks can never
|
|
3658
|
+
* collapse into one key — which would let one operator waiver silently cover a
|
|
3659
|
+
* demand nobody waived, and make HQ count two causes as one.
|
|
3660
|
+
*
|
|
3661
|
+
* Deterministic in both directions of use: the key a `pr:ready` proof records
|
|
3662
|
+
* is the key a `pr:merge-check` waiver matches.
|
|
3663
|
+
*/
|
|
3664
|
+
const demandQualifier = (value) => value.replaceAll(/[^A-Za-z0-9._/-]/gu, percentEncode);
|
|
3665
|
+
/** The demand one profile-declared external required check makes. */
|
|
3666
|
+
const requiredCheckDemand = (name) => `required-check:${demandQualifier(name)}`;
|
|
3667
|
+
/** The demand one applicable review mode makes (correctness, security). */
|
|
3668
|
+
const reviewModeDemand = (mode) => `review-mode:${demandQualifier(mode)}`;
|
|
3669
|
+
/** The demand the review rung in force makes (profile default or wave rung). */
|
|
3670
|
+
const reviewRungDemand = (rung) => `review-rung:${demandQualifier(rung)}`;
|
|
3671
|
+
const FIXED_DEMAND_KEYS = Object.values(DEMAND_KEYS);
|
|
3672
|
+
/** The review modes a profile can resolve, and therefore demand. */
|
|
3673
|
+
const REVIEW_MODE_DEMAND_VALUES = ["correctness", "security"];
|
|
3674
|
+
const escapePattern = /%(?:u[0-9A-F]{4}|[0-9A-F]{2})/gu;
|
|
3675
|
+
/**
|
|
3676
|
+
* Reverse `demandQualifier`. A qualifier is canonical exactly when encoding
|
|
3677
|
+
* its decoded form reproduces it — which rejects both malformed escapes and
|
|
3678
|
+
* noncanonical aliases like `%41` for `A`. Returns undefined when the
|
|
3679
|
+
* qualifier cannot have been minted here.
|
|
3680
|
+
*/
|
|
3681
|
+
const decodeQualifier = (qualifier) => {
|
|
3682
|
+
const bytes = [];
|
|
3683
|
+
let decoded = "";
|
|
3684
|
+
let index = 0;
|
|
3685
|
+
const flush = () => {
|
|
3686
|
+
if (bytes.length === 0) return true;
|
|
3687
|
+
try {
|
|
3688
|
+
decoded += new TextDecoder("utf-8", {
|
|
3689
|
+
fatal: true,
|
|
3690
|
+
ignoreBOM: true
|
|
3691
|
+
}).decode(Uint8Array.from(bytes));
|
|
3692
|
+
} catch {
|
|
3693
|
+
return false;
|
|
3694
|
+
}
|
|
3695
|
+
bytes.length = 0;
|
|
3696
|
+
return true;
|
|
3697
|
+
};
|
|
3698
|
+
while (index < qualifier.length) {
|
|
3699
|
+
const character = qualifier[index];
|
|
3700
|
+
if (character !== "%") {
|
|
3701
|
+
if (!flush()) return;
|
|
3702
|
+
decoded += character;
|
|
3703
|
+
index += 1;
|
|
3704
|
+
continue;
|
|
3705
|
+
}
|
|
3706
|
+
escapePattern.lastIndex = index;
|
|
3707
|
+
const escape = escapePattern.exec(qualifier);
|
|
3708
|
+
if (!escape || escape.index !== index) return;
|
|
3709
|
+
if (escape[0][1] === "u") {
|
|
3710
|
+
if (!flush()) return;
|
|
3711
|
+
decoded += String.fromCodePoint(Number.parseInt(escape[0].slice(2), 16));
|
|
3712
|
+
} else bytes.push(Number.parseInt(escape[0].slice(1), 16));
|
|
3713
|
+
index += escape[0].length;
|
|
3714
|
+
}
|
|
3715
|
+
return flush() ? decoded : void 0;
|
|
3716
|
+
};
|
|
3717
|
+
const qualifierIsMintable = (family, qualifier) => {
|
|
3718
|
+
if (!QUALIFIER_PATTERN.test(qualifier)) return false;
|
|
3719
|
+
const decoded = decodeQualifier(qualifier);
|
|
3720
|
+
if (decoded === void 0 || demandQualifier(decoded) !== qualifier) return false;
|
|
3721
|
+
if (family === "review-rung") return EVIDENCE_REVIEW_RUNGS$1.includes(decoded);
|
|
3722
|
+
if (family === "review-mode") return REVIEW_MODE_DEMAND_VALUES.includes(decoded);
|
|
3723
|
+
return true;
|
|
3724
|
+
};
|
|
3725
|
+
/**
|
|
3726
|
+
* A demand key from the closed vocabulary: a known unqualified family, or a
|
|
3727
|
+
* known qualified family with a qualifier this module could actually have
|
|
3728
|
+
* minted. Closed on purpose — an invented family, an unresolvable rung, or a
|
|
3729
|
+
* noncanonical encoding would be indistinguishable from a typo to anyone
|
|
3730
|
+
* counting causes, which is the whole point of recording codes.
|
|
3731
|
+
*/
|
|
3732
|
+
const demandKeySchema = z.string().refine((value) => {
|
|
3733
|
+
if (FIXED_DEMAND_KEYS.includes(value)) return true;
|
|
3734
|
+
const separator = value.indexOf(":");
|
|
3735
|
+
if (separator === -1) return false;
|
|
3736
|
+
const family = value.slice(0, separator);
|
|
3737
|
+
return QUALIFIED_DEMAND_FAMILIES.includes(family) && qualifierIsMintable(family, value.slice(separator + 1));
|
|
3738
|
+
}, { message: `a demand key is one of ${FIXED_DEMAND_KEYS.join(", ")} or a resolvable ${QUALIFIED_DEMAND_FAMILIES.join(" / ")} key` });
|
|
3739
|
+
/**
|
|
3740
|
+
* The key shape an operator may *name* in a waiver: syntactic, not closed.
|
|
3741
|
+
*
|
|
3742
|
+
* Deliberately distinct from `demandKeySchema` above, which is what a proof's
|
|
3743
|
+
* reason codes must come from. A waiver store is durable operator evidence
|
|
3744
|
+
* written before today's vocabulary existed, so tightening its validation
|
|
3745
|
+
* would make an existing record parse as no waiver and let the next write drop
|
|
3746
|
+
* it. A waiver naming a key nobody resolves is already inert — `pr:merge-check`
|
|
3747
|
+
* reports it as an unapplied waiver — so nothing is admitted by accepting it.
|
|
3748
|
+
*/
|
|
3749
|
+
const waiverDemandKeySchema = z.string().regex(/^[a-z][a-z0-9-]*(?::[A-Za-z0-9._%/-]+)?$/u, "A demand key is a lowercase family, optionally qualified by its resolved value (e.g. review-rung:human).");
|
|
3750
|
+
/** One refusal sentence: single line, trimmed, bounded. */
|
|
3751
|
+
const blockedReasonDetailSchema = z.string().min(1).max(280).refine((value) => value.trim().length > 0, { message: "must not be blank" }).refine((value) => !/[\r\n]/u.test(value), { message: "must be one line, not a multi-line payload" });
|
|
3752
|
+
const blockedReasonSchema = z.object({
|
|
3753
|
+
code: demandKeySchema,
|
|
3754
|
+
detail: blockedReasonDetailSchema
|
|
3755
|
+
});
|
|
3756
|
+
const blockedReasonsSchema = z.array(blockedReasonSchema).max(100);
|
|
3757
|
+
/** Fit one refusal sentence to the wire bound without losing its head. */
|
|
3758
|
+
const blockedReasonDetail = (reason) => {
|
|
3759
|
+
const line = reason.replaceAll(/\s+/gu, " ").trim();
|
|
3760
|
+
return line.length <= 280 ? line : `${line.slice(0, 279)}…`;
|
|
3761
|
+
};
|
|
3762
|
+
const blockedReasonIssue = (message) => [{
|
|
3763
|
+
code: "custom",
|
|
3764
|
+
message,
|
|
3765
|
+
path: ["blockedReasons"]
|
|
3766
|
+
}];
|
|
3767
|
+
/**
|
|
3768
|
+
* The invariant every reader enforces, shared by the emitting schema and the
|
|
3769
|
+
* wire schema so it is one rule rather than two that can drift. It is the
|
|
3770
|
+
* producer's state machine, written down:
|
|
3771
|
+
*
|
|
3772
|
+
* - `ready` refused nothing, so it carries neither projection, and it is the
|
|
3773
|
+
* final review point;
|
|
3774
|
+
* - `slice-ready/not-final` was held back by exactly one demand — being a
|
|
3775
|
+
* slice — and names it;
|
|
3776
|
+
* - `blocked` refused something other than being a slice, and says so;
|
|
3777
|
+
* - whichever it is, `blockedReasons` names every refusal listed in
|
|
3778
|
+
* `blockingReasons`, in the same order, as the bounded form of that sentence.
|
|
3779
|
+
*
|
|
3780
|
+
* Naming the slice demand and the ledger's `finalReviewPoint` are the same
|
|
3781
|
+
* fact, so a proof that says one and not the other is refused.
|
|
3782
|
+
*/
|
|
3783
|
+
const blockedReasonIssues = (proof) => {
|
|
3784
|
+
const named = proof.blockedReasons ?? [];
|
|
3785
|
+
const reasons = proof.blockingReasons ?? [];
|
|
3786
|
+
const slice = DEMAND_KEYS.finalReviewPoint;
|
|
3787
|
+
const namesSlice = named.some((reason) => reason.code === slice);
|
|
3788
|
+
if (proof.status === "ready") {
|
|
3789
|
+
if (named.length > 0 || reasons.length > 0) return blockedReasonIssue("a ready pr:ready proof must carry no blocking reasons");
|
|
3790
|
+
} else if (proof.status === "slice-ready/not-final") {
|
|
3791
|
+
if (named.length !== 1 || !namesSlice) return blockedReasonIssue(`a slice-ready/not-final pr:ready proof is held back by exactly one demand, ${slice}`);
|
|
3792
|
+
} else if (reasons.length === 0) return blockedReasonIssue("a blocked pr:ready proof must record what blocked it");
|
|
3793
|
+
else if (named.length > 0 && !named.some((r) => r.code !== slice)) return blockedReasonIssue(`a blocked pr:ready proof must name a demand other than ${slice}`);
|
|
3794
|
+
if (proof.finalReviewPoint !== void 0 && namesSlice === proof.finalReviewPoint) return blockedReasonIssue(`naming ${slice} and the ledger's finalReviewPoint are the same fact; this proof says both`);
|
|
3795
|
+
if (named.length !== reasons.length) return blockedReasonIssue(`blockedReasons must name every blocking reason: ${reasons.length} reason(s), ${named.length} named`);
|
|
3796
|
+
const drifted = named.findIndex((reason, index) => reason.detail !== blockedReasonDetail(reasons[index]));
|
|
3797
|
+
return drifted === -1 ? [] : blockedReasonIssue(`blockedReasons[${drifted}] does not carry blocking reason ${drifted}; the two projections must tell one story`);
|
|
3798
|
+
};
|
|
3799
|
+
//#endregion
|
|
2898
3800
|
//#region src/factory-session.ts
|
|
2899
3801
|
const FACTORY_SESSION_ID_ENV = "FACTORY_SESSION_ID";
|
|
2900
3802
|
const UNKNOWN_AUTHORING_SESSION = "unknown";
|
|
@@ -2917,20 +3819,12 @@ const resolveAuthoringSessionIds = ({ override, recorded }) => {
|
|
|
2917
3819
|
const DEMAND_WAIVER_SCHEMA_VERSION$1 = 1;
|
|
2918
3820
|
const DEFAULT_DEMAND_WAIVER_PATH = ".factory-memory/demand-waivers.json";
|
|
2919
3821
|
const shaSchema$6 = z.string().regex(/^[0-9a-f]{40}$/u);
|
|
2920
|
-
/**
|
|
2921
|
-
* A resolved demand's key: a family, optionally qualified by the resolved
|
|
2922
|
-
* value that makes it this demand *instance* (`review-rung:human`,
|
|
2923
|
-
* `required-check:core`, `merge-freeze`). The consumer that resolved the
|
|
2924
|
-
* demand owns the key; this module only matches on it, which is why no
|
|
2925
|
-
* control name is enumerated here.
|
|
2926
|
-
*/
|
|
2927
|
-
const demandKeySchema = z.string().regex(/^[a-z][a-z0-9-]*(?::[A-Za-z0-9._/-]+)?$/u, "A demand key is a lowercase family, optionally qualified by its resolved value (e.g. review-rung:human).");
|
|
2928
3822
|
const demandWaiverSchema = z.object({
|
|
2929
3823
|
candidate: z.object({
|
|
2930
3824
|
headSha: shaSchema$6,
|
|
2931
3825
|
pr: z.number().int().positive()
|
|
2932
3826
|
}),
|
|
2933
|
-
demand:
|
|
3827
|
+
demand: waiverDemandKeySchema,
|
|
2934
3828
|
operator: z.string().trim().min(1),
|
|
2935
3829
|
rationale: z.string().trim().min(1),
|
|
2936
3830
|
recordedAt: z.iso.datetime(),
|
|
@@ -2943,7 +3837,7 @@ const demandWaiverStoreSchema = z.object({
|
|
|
2943
3837
|
});
|
|
2944
3838
|
const validateDemandWaiverStore = (value) => demandWaiverStoreSchema.parse(value);
|
|
2945
3839
|
const waivedDemandSchema = z.object({
|
|
2946
|
-
demand:
|
|
3840
|
+
demand: waiverDemandKeySchema,
|
|
2947
3841
|
operator: z.string().trim().min(1),
|
|
2948
3842
|
rationale: z.string().trim().min(1),
|
|
2949
3843
|
recordedAt: z.iso.datetime(),
|
|
@@ -4013,7 +4907,15 @@ const managedReadinessLedgerSchema$1 = z.object({
|
|
|
4013
4907
|
verifiedHeadSha: z.string().optional()
|
|
4014
4908
|
})
|
|
4015
4909
|
});
|
|
4910
|
+
/**
|
|
4911
|
+
* The pr:ready proof versions a reader still accepts. `pr:ready` emits v2 only
|
|
4912
|
+
* (#391) — one current contract — but v1 events were spooled before the bump
|
|
4913
|
+
* and HQ must ingest them without degrading, so the wire schema parses both.
|
|
4914
|
+
*/
|
|
4915
|
+
const SUPPORTED_PR_READY_SCHEMA_VERSIONS = [1, 2];
|
|
4916
|
+
const prReadySchemaVersionSchema = z.number().refine((value) => SUPPORTED_PR_READY_SCHEMA_VERSIONS.includes(value), { message: `schemaVersion must be one of: ${SUPPORTED_PR_READY_SCHEMA_VERSIONS.join(", ")}` });
|
|
4016
4917
|
z.object({
|
|
4918
|
+
blockedReasons: blockedReasonsSchema.optional(),
|
|
4017
4919
|
blockingReasons: z.array(z.string()),
|
|
4018
4920
|
command: z.literal("patronage-factory pr:ready"),
|
|
4019
4921
|
followUp: followUpActionSchema.optional(),
|
|
@@ -4023,12 +4925,25 @@ z.object({
|
|
|
4023
4925
|
profilePath: z.string().min(1).optional(),
|
|
4024
4926
|
repairs: z.array(readinessRepairSchema$1).default([]),
|
|
4025
4927
|
repository: z.string().regex(/^[^/\s]+\/[^/\s]+$/u).optional(),
|
|
4026
|
-
schemaVersion:
|
|
4928
|
+
schemaVersion: prReadySchemaVersionSchema,
|
|
4027
4929
|
status: z.enum([
|
|
4028
4930
|
"ready",
|
|
4029
4931
|
"blocked",
|
|
4030
4932
|
"slice-ready/not-final"
|
|
4031
4933
|
])
|
|
4934
|
+
}).superRefine((proof, context) => {
|
|
4935
|
+
if (proof.schemaVersion < 2) {
|
|
4936
|
+
if (proof.blockedReasons !== void 0) context.addIssue({
|
|
4937
|
+
code: "custom",
|
|
4938
|
+
message: "blockedReasons is a schemaVersion 2 field; a v1 pr:ready proof must not carry it.",
|
|
4939
|
+
path: ["blockedReasons"]
|
|
4940
|
+
});
|
|
4941
|
+
return;
|
|
4942
|
+
}
|
|
4943
|
+
for (const issue of blockedReasonIssues({
|
|
4944
|
+
...proof,
|
|
4945
|
+
finalReviewPoint: proof.ledger.finalReviewPoint
|
|
4946
|
+
})) context.addIssue(issue);
|
|
4032
4947
|
});
|
|
4033
4948
|
//#endregion
|
|
4034
4949
|
//#region src/boundary-check.ts
|
|
@@ -9030,11 +9945,8 @@ function renderCloseoutMarkdown(artifact) {
|
|
|
9030
9945
|
...ledgerLines
|
|
9031
9946
|
].join("\n");
|
|
9032
9947
|
}
|
|
9033
|
-
//#endregion
|
|
9034
|
-
//#region src/epic-closeout/write-artifact.ts
|
|
9035
|
-
const DEFAULT_OUT_DIR = ".factory-memory/closeouts";
|
|
9036
9948
|
function closeoutArtifactPaths(opts) {
|
|
9037
|
-
const dir = path.resolve(opts.repoRoot, opts.outDir ??
|
|
9949
|
+
const dir = path.resolve(opts.repoRoot, opts.outDir ?? ".factory-memory/closeouts");
|
|
9038
9950
|
const filename = sanitizeEpicFilename(opts.epic);
|
|
9039
9951
|
return {
|
|
9040
9952
|
dir,
|
|
@@ -9058,43 +9970,487 @@ function writeCloseoutArtifact(opts) {
|
|
|
9058
9970
|
};
|
|
9059
9971
|
}
|
|
9060
9972
|
//#endregion
|
|
9061
|
-
//#region src/
|
|
9062
|
-
|
|
9063
|
-
|
|
9064
|
-
|
|
9065
|
-
|
|
9066
|
-
|
|
9067
|
-
|
|
9068
|
-
|
|
9069
|
-
|
|
9070
|
-
|
|
9071
|
-
|
|
9072
|
-
|
|
9073
|
-
|
|
9074
|
-
|
|
9075
|
-
|
|
9076
|
-
|
|
9077
|
-
|
|
9078
|
-
|
|
9079
|
-
|
|
9080
|
-
|
|
9081
|
-
|
|
9082
|
-
|
|
9083
|
-
|
|
9084
|
-
|
|
9085
|
-
|
|
9086
|
-
|
|
9087
|
-
|
|
9088
|
-
|
|
9089
|
-
|
|
9090
|
-
|
|
9091
|
-
|
|
9973
|
+
//#region src/epic-structure.ts
|
|
9974
|
+
/**
|
|
9975
|
+
* Producer-side `epic-structure` v1 emitter (epic #132, lane #135).
|
|
9976
|
+
*
|
|
9977
|
+
* `psf epic:publish-structure` reads a planner's ephemeral `dag.yml` (never
|
|
9978
|
+
* committed — see epic #132 / ADR 0018), validates the graph fail-closed, and
|
|
9979
|
+
* POSTs an `epic-structure` v1 event to HQ's `/api/ingest` boundary. This
|
|
9980
|
+
* module is the pure core: parse-to-IR validation, the wire mapping, and the
|
|
9981
|
+
* deterministic content-addressed eventId. All I/O (stdin/file read, fetch)
|
|
9982
|
+
* lives in the command wrapper.
|
|
9983
|
+
*
|
|
9984
|
+
* WIRE PARITY (do not fork the schema): the emitted payload is the exact shape
|
|
9985
|
+
* HQ's `EpicGraphSchema` accepts (`software-factory-hq/src/contracts/
|
|
9986
|
+
* epic-schemas.ts`) and is built by hand the same way `seed-dev.ts` builds it
|
|
9987
|
+
* — no re-declared zod twin. HQ's `epic-structure-parity.test.ts` imports
|
|
9988
|
+
* {@link buildEpicStructureEvent} and runs its output through the real
|
|
9989
|
+
* `EpicGraphSchema`, so any drift (field, enum, bound, strictness) fails CI.
|
|
9990
|
+
*
|
|
9991
|
+
* The `dag.yml` schema itself is documented with the epic skill (lane #137);
|
|
9992
|
+
* this module is the executable contract, not a second source of truth.
|
|
9993
|
+
*/
|
|
9994
|
+
const EPIC_STRUCTURE_SCHEMA_VERSION = 1;
|
|
9995
|
+
/**
|
|
9996
|
+
* DAG-node PLANNING statuses — the planning-plane vocabulary a planner authors
|
|
9997
|
+
* on a `dag.yml` node. Carried forward by ADR 0018 §4 and defined by the epic
|
|
9998
|
+
* skill (`reference/epic-artifacts.md`) and both `CONTEXT.md` glossaries.
|
|
9999
|
+
*
|
|
10000
|
+
* A DAG node is a unit of *plan*; an HQ lane is a unit of *runtime execution*.
|
|
10001
|
+
* These are two planes with two vocabularies and must not be conflated — the
|
|
10002
|
+
* producer validates and emits ONLY planning statuses:
|
|
10003
|
+
*
|
|
10004
|
+
* - `open` authored, not yet done
|
|
10005
|
+
* - `closed` done (a PR merged, or otherwise resolved)
|
|
10006
|
+
* - `satisfied-on-main` already true on main without a dedicated PR
|
|
10007
|
+
* - `parked` real node, scope still moving — relabeled off
|
|
10008
|
+
* `ready-for-agent`, never closed
|
|
10009
|
+
*
|
|
10010
|
+
* HQ's `DagNodeSchema` (`software-factory-hq/src/contracts/epic-schemas.ts`)
|
|
10011
|
+
* accepts this planning vocabulary on ingest AND overlays a runtime LANE status
|
|
10012
|
+
* onto a node once a lane is linked (`syncNodesWithLanes` /
|
|
10013
|
+
* `projectEpicMembership`) — the overlay is HQ-internal; the producer never
|
|
10014
|
+
* emits a lane status. HQ's `epic-structure-parity.test.ts` fails CI if the
|
|
10015
|
+
* producer emits any status HQ won't accept.
|
|
10016
|
+
*/
|
|
10017
|
+
const EPIC_STRUCTURE_NODE_STATUSES = [
|
|
10018
|
+
"open",
|
|
10019
|
+
"closed",
|
|
10020
|
+
"satisfied-on-main",
|
|
10021
|
+
"parked"
|
|
10022
|
+
];
|
|
10023
|
+
/**
|
|
10024
|
+
* Fail-closed validation error carrying every offending node/edge diagnostic.
|
|
10025
|
+
*/
|
|
10026
|
+
var EpicStructureValidationError = class extends Error {
|
|
10027
|
+
diagnostics;
|
|
10028
|
+
constructor(diagnostics) {
|
|
10029
|
+
super(`epic:publish-structure refused: dag graph is invalid\n${diagnostics.map((line) => ` - ${line}`).join("\n")}`);
|
|
10030
|
+
this.name = "EpicStructureValidationError";
|
|
10031
|
+
this.diagnostics = diagnostics;
|
|
10032
|
+
}
|
|
10033
|
+
};
|
|
10034
|
+
const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
10035
|
+
const nonEmptyString = (value) => typeof value === "string" && value.trim().length > 0;
|
|
10036
|
+
const isPositiveInteger = (value) => typeof value === "number" && Number.isInteger(value) && value > 0;
|
|
10037
|
+
const slugify = (value) => value.trim().toLowerCase().replaceAll(/[^a-z0-9]+/gu, "-").replaceAll(/^-+|-+$/gu, "") || "epic";
|
|
10038
|
+
const laneIdFor = (repo, issue) => `lane-${repo}-issue-${issue}`;
|
|
10039
|
+
const validateNodes = (rawNodes, diagnostics) => {
|
|
10040
|
+
const nodes = [];
|
|
10041
|
+
const slugs = /* @__PURE__ */ new Set();
|
|
10042
|
+
for (const [index, raw] of rawNodes.entries()) {
|
|
10043
|
+
if (!isPlainObject(raw)) {
|
|
10044
|
+
diagnostics.push(`node[${index}]: expected a mapping`);
|
|
10045
|
+
continue;
|
|
10046
|
+
}
|
|
10047
|
+
const label = nonEmptyString(raw.slug) ? `"${raw.slug}"` : `node[${index}]`;
|
|
10048
|
+
if (!nonEmptyString(raw.slug)) diagnostics.push(`node[${index}]: missing required \`slug\``);
|
|
10049
|
+
else if (slugs.has(raw.slug)) diagnostics.push(`node ${label}: duplicate slug`);
|
|
10050
|
+
else slugs.add(raw.slug);
|
|
10051
|
+
if (!nonEmptyString(raw.title)) diagnostics.push(`node ${label}: missing required \`title\``);
|
|
10052
|
+
const external = raw.external === true;
|
|
10053
|
+
if (!external && !isPositiveInteger(raw.issue)) diagnostics.push(`node ${label}: \`issue\` number is required — non-external nodes must carry a minted issue (this runs post-ratification); set \`external: true\` to exempt an adoption/upstream node`);
|
|
10054
|
+
let status = "open";
|
|
10055
|
+
if (raw.status === void 0) status = "open";
|
|
10056
|
+
else if (EPIC_STRUCTURE_NODE_STATUSES.includes(raw.status)) status = raw.status;
|
|
10057
|
+
else diagnostics.push(`node ${label}: \`status\` must be one of ${EPIC_STRUCTURE_NODE_STATUSES.join(", ")} (DAG-node planning statuses, not HQ lane statuses)`);
|
|
10058
|
+
if (raw.laneId !== void 0 && !nonEmptyString(raw.laneId)) diagnostics.push(`node ${label}: \`laneId\` must be a non-empty string`);
|
|
10059
|
+
nodes.push({
|
|
10060
|
+
external,
|
|
10061
|
+
...isPositiveInteger(raw.issue) ? { issue: raw.issue } : {},
|
|
10062
|
+
...nonEmptyString(raw.laneId) ? { laneId: raw.laneId } : {},
|
|
10063
|
+
slug: nonEmptyString(raw.slug) ? raw.slug : `node[${index}]`,
|
|
10064
|
+
status,
|
|
10065
|
+
title: nonEmptyString(raw.title) ? raw.title : ""
|
|
10066
|
+
});
|
|
10067
|
+
}
|
|
10068
|
+
return {
|
|
10069
|
+
nodes,
|
|
10070
|
+
slugs
|
|
10071
|
+
};
|
|
10072
|
+
};
|
|
10073
|
+
const validateEdges = (rawEdges, slugs, diagnostics) => {
|
|
10074
|
+
const edges = [];
|
|
10075
|
+
for (const [index, raw] of rawEdges.entries()) {
|
|
10076
|
+
if (!isPlainObject(raw)) {
|
|
10077
|
+
diagnostics.push(`edge[${index}]: expected a mapping`);
|
|
10078
|
+
continue;
|
|
10079
|
+
}
|
|
10080
|
+
if (raw.type !== "depends-on") continue;
|
|
10081
|
+
if (!nonEmptyString(raw.from) || !nonEmptyString(raw.to)) {
|
|
10082
|
+
diagnostics.push(`edge[${index}]: depends-on edge requires string \`from\` and \`to\``);
|
|
10083
|
+
continue;
|
|
10084
|
+
}
|
|
10085
|
+
if (!slugs.has(raw.from)) diagnostics.push(`edge ${raw.from} -> ${raw.to}: \`from\` references unknown slug "${raw.from}"`);
|
|
10086
|
+
if (!slugs.has(raw.to)) diagnostics.push(`edge ${raw.from} -> ${raw.to}: \`to\` references unknown slug "${raw.to}"`);
|
|
10087
|
+
if (raw.from === raw.to) diagnostics.push(`edge ${raw.from} -> ${raw.to}: self-dependency (cycle)`);
|
|
10088
|
+
edges.push({
|
|
10089
|
+
from: raw.from,
|
|
10090
|
+
to: raw.to
|
|
10091
|
+
});
|
|
10092
|
+
}
|
|
10093
|
+
return edges;
|
|
10094
|
+
};
|
|
10095
|
+
/**
|
|
10096
|
+
* Detects a dependency cycle over the depends-on edges and returns the offending
|
|
10097
|
+
* cycle path (`a -> b -> a`) if one exists, else undefined. Only edges whose
|
|
10098
|
+
* endpoints both resolve to known slugs are walked, so unknown-slug diagnostics
|
|
10099
|
+
* are reported independently and never masquerade as cycles.
|
|
10100
|
+
*/
|
|
10101
|
+
const findCycle = (slugs, edges) => {
|
|
10102
|
+
const adjacency = /* @__PURE__ */ new Map();
|
|
10103
|
+
for (const { from, to } of edges) if (slugs.has(from) && slugs.has(to)) {
|
|
10104
|
+
const list = adjacency.get(from) ?? [];
|
|
10105
|
+
list.push(to);
|
|
10106
|
+
adjacency.set(from, list);
|
|
10107
|
+
}
|
|
10108
|
+
const UNVISITED = 0;
|
|
10109
|
+
const IN_STACK = 1;
|
|
10110
|
+
const DONE = 2;
|
|
10111
|
+
const state = /* @__PURE__ */ new Map();
|
|
10112
|
+
const stack = [];
|
|
10113
|
+
const walk = (node) => {
|
|
10114
|
+
state.set(node, IN_STACK);
|
|
10115
|
+
stack.push(node);
|
|
10116
|
+
for (const next of adjacency.get(node) ?? []) {
|
|
10117
|
+
const marker = state.get(next) ?? UNVISITED;
|
|
10118
|
+
if (marker === IN_STACK) {
|
|
10119
|
+
const start = stack.indexOf(next);
|
|
10120
|
+
return [...stack.slice(start), next];
|
|
10121
|
+
}
|
|
10122
|
+
if (marker === UNVISITED) {
|
|
10123
|
+
const found = walk(next);
|
|
10124
|
+
if (found) return found;
|
|
10125
|
+
}
|
|
10126
|
+
}
|
|
10127
|
+
stack.pop();
|
|
10128
|
+
state.set(node, DONE);
|
|
10129
|
+
};
|
|
10130
|
+
for (const slug of slugs) if ((state.get(slug) ?? UNVISITED) === UNVISITED) {
|
|
10131
|
+
const found = walk(slug);
|
|
10132
|
+
if (found) return found;
|
|
10133
|
+
}
|
|
10134
|
+
};
|
|
10135
|
+
const resolveEpicId = (epic) => {
|
|
10136
|
+
if (typeof epic === "number") return String(epic);
|
|
10137
|
+
if (nonEmptyString(epic)) return epic.trim();
|
|
10138
|
+
};
|
|
10139
|
+
/**
|
|
10140
|
+
* Validates a dag document fail-closed. Throws {@link
|
|
10141
|
+
* EpicStructureValidationError} with a diagnostic per offending node/edge.
|
|
10142
|
+
*/
|
|
10143
|
+
const validateDagDocument = (input) => {
|
|
10144
|
+
const doc = input.document;
|
|
10145
|
+
const diagnostics = [];
|
|
10146
|
+
if (!isPlainObject(doc)) throw new EpicStructureValidationError(["dag document must be a YAML/JSON mapping with `epic` and `nodes`"]);
|
|
10147
|
+
const epicId = resolveEpicId(doc.epic);
|
|
10148
|
+
if (epicId === void 0) diagnostics.push("dag document requires a top-level `epic` id");
|
|
10149
|
+
const repo = input.repo ?? (nonEmptyString(doc.repo) ? doc.repo.trim() : void 0);
|
|
10150
|
+
if (repo === void 0) diagnostics.push("repo is required — pass `--repo <name>` or set `repo:` in the dag document (needed for the epic record and lane-id derivation)");
|
|
10151
|
+
const rawNodes = Array.isArray(doc.nodes) ? doc.nodes : void 0;
|
|
10152
|
+
if (rawNodes === void 0 || rawNodes.length === 0) diagnostics.push("dag document requires a non-empty `nodes` array");
|
|
10153
|
+
const { nodes, slugs } = validateNodes(rawNodes ?? [], diagnostics);
|
|
10154
|
+
const edges = validateEdges(Array.isArray(doc.edges) ? doc.edges : [], slugs, diagnostics);
|
|
10155
|
+
const cycle = findCycle(slugs, edges);
|
|
10156
|
+
if (cycle) diagnostics.push(`dependency cycle detected: ${cycle.join(" -> ")}`);
|
|
10157
|
+
if (diagnostics.length > 0 || epicId === void 0 || repo === void 0) throw new EpicStructureValidationError(diagnostics);
|
|
10158
|
+
return {
|
|
10159
|
+
boundary: slugify(input.boundary ?? (nonEmptyString(doc.boundary) ? doc.boundary : `epic-${epicId}`)),
|
|
10160
|
+
edges,
|
|
10161
|
+
epicId,
|
|
10162
|
+
name: input.name ?? (nonEmptyString(doc.name) ? doc.name.trim() : `Epic ${epicId}`),
|
|
10163
|
+
nodes,
|
|
10164
|
+
repo
|
|
10165
|
+
};
|
|
10166
|
+
};
|
|
10167
|
+
/**
|
|
10168
|
+
* Builds the wire payload with deterministic ordering (nodes by slug, edges by
|
|
10169
|
+
* from then to) so re-emission of the same graph is byte-stable regardless of
|
|
10170
|
+
* the planner's source ordering — the basis for the content-addressed eventId.
|
|
10171
|
+
*/
|
|
10172
|
+
const buildEpicStructurePayload = (dag) => {
|
|
10173
|
+
const nodes = dag.nodes.map((node) => {
|
|
10174
|
+
const laneId = node.laneId ?? (node.issue === void 0 ? void 0 : laneIdFor(dag.repo, node.issue));
|
|
10175
|
+
return {
|
|
10176
|
+
epicId: dag.epicId,
|
|
10177
|
+
...laneId === void 0 ? {} : { laneId },
|
|
10178
|
+
slug: node.slug,
|
|
10179
|
+
status: node.status,
|
|
10180
|
+
title: node.title
|
|
10181
|
+
};
|
|
10182
|
+
}).toSorted((a, b) => a.slug.localeCompare(b.slug));
|
|
10183
|
+
return {
|
|
10184
|
+
edges: dag.edges.map((edge) => ({
|
|
10185
|
+
epicId: dag.epicId,
|
|
10186
|
+
from: edge.from,
|
|
10187
|
+
to: edge.to,
|
|
10188
|
+
type: "depends-on"
|
|
10189
|
+
})).toSorted((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to)),
|
|
10190
|
+
epics: [{
|
|
10191
|
+
id: dag.epicId,
|
|
10192
|
+
name: dag.name,
|
|
10193
|
+
repo: dag.repo
|
|
10194
|
+
}],
|
|
10195
|
+
nodes
|
|
10196
|
+
};
|
|
10197
|
+
};
|
|
10198
|
+
/** Stable, key-sorted JSON for content hashing (values already ordered). */
|
|
10199
|
+
const stableStringify = (value) => {
|
|
10200
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
|
|
10201
|
+
if (isPlainObject(value)) return `{${Object.keys(value).toSorted().map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(",")}}`;
|
|
10202
|
+
return JSON.stringify(value ?? null);
|
|
10203
|
+
};
|
|
10204
|
+
/**
|
|
10205
|
+
* Deterministic eventId = `epic-structure-<boundary>-<sha256(payload)[0:16]>`.
|
|
10206
|
+
* Re-emitting identical graph content yields the same eventId (HQ dedups →
|
|
10207
|
+
* 200/duplicate); an amendment changes the content hash → a new eventId that
|
|
10208
|
+
* supersedes the prior structure wholesale (HQ keeps the latest). This mirrors
|
|
10209
|
+
* seed-dev's fixed-eventId idempotency, content-addressed instead of literal.
|
|
10210
|
+
*/
|
|
10211
|
+
const epicStructureEventId = (boundary, payload) => `epic-structure-${slugify(boundary)}-${createHash("sha256").update(stableStringify(payload)).digest("hex").slice(0, 16)}`;
|
|
10212
|
+
/**
|
|
10213
|
+
* Validates and builds the full `epic-structure` v1 ingest event, ready to POST
|
|
10214
|
+
* to `/api/ingest`. Throws {@link EpicStructureValidationError} fail-closed.
|
|
10215
|
+
*/
|
|
10216
|
+
const buildEpicStructureEvent = (input) => {
|
|
10217
|
+
const dag = validateDagDocument(input);
|
|
10218
|
+
const payload = buildEpicStructurePayload(dag);
|
|
10219
|
+
return {
|
|
10220
|
+
eventId: epicStructureEventId(dag.boundary, payload),
|
|
10221
|
+
kind: "epic-structure",
|
|
10222
|
+
observedAt: input.observedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
10223
|
+
payload,
|
|
10224
|
+
schemaVersion: 1
|
|
10225
|
+
};
|
|
10226
|
+
};
|
|
10227
|
+
/** The prod HQ worker is reachable at `hq.patronage.com` and its frozen
|
|
10228
|
+
* `-v1.*.workers.dev` alias. Emitting there without an Access service token is
|
|
10229
|
+
* fail-closed. */
|
|
10230
|
+
const isProductionHqUrl = (url) => url.includes("hq.patronage.com") || /-v1\.[^/]*\.workers\.dev/iu.test(url);
|
|
10231
|
+
/**
|
|
10232
|
+
* POSTs the event to HQ ingest. Fail-closed: a production URL without a
|
|
10233
|
+
* Cloudflare Access service token is refused before any request; a 401/403 is
|
|
10234
|
+
* surfaced with actionable guidance; any non-2xx throws.
|
|
10235
|
+
*/
|
|
10236
|
+
const publishEpicStructure = async (args) => {
|
|
10237
|
+
if (!args.accessServiceToken && isProductionHqUrl(args.url)) throw new Error(`epic:publish-structure refusing to publish to production ${args.url} without a Cloudflare Access service token: set CF-Access-Client-Id and CF-Access-Client-Secret`);
|
|
10238
|
+
const doFetch = args.fetchImpl ?? globalThis.fetch;
|
|
10239
|
+
const requestInit = args.accessServiceToken ? buildCloudflareAccessRequestInit(args.accessServiceToken, {
|
|
10240
|
+
body: JSON.stringify(args.event),
|
|
10241
|
+
headers: { "content-type": "application/json" },
|
|
10242
|
+
method: "POST",
|
|
10243
|
+
...args.signal ? { signal: args.signal } : {}
|
|
10244
|
+
}) : {
|
|
10245
|
+
body: JSON.stringify(args.event),
|
|
10246
|
+
headers: { "content-type": "application/json" },
|
|
10247
|
+
method: "POST",
|
|
10248
|
+
...args.signal ? { signal: args.signal } : {}
|
|
10249
|
+
};
|
|
10250
|
+
let response;
|
|
10251
|
+
try {
|
|
10252
|
+
const normalizedUrl = args.url.replace(/\/+$/u, "");
|
|
10253
|
+
response = await doFetch(normalizedUrl.endsWith("/api/ingest") ? normalizedUrl : `${normalizedUrl}/api/ingest`, requestInit);
|
|
10254
|
+
} catch {
|
|
10255
|
+
throw new Error(`epic:publish-structure could not reach HQ ingest at ${args.url}: request failed. Check the endpoint and network; redirects are refused to protect Cloudflare Access credentials.`);
|
|
10256
|
+
}
|
|
10257
|
+
const text = await response.text();
|
|
10258
|
+
if (response.status === 401 || response.status === 403) throw new Error(`epic:publish-structure rejected by Cloudflare Access or HQ ingest (${response.status}): set valid CF-Access-Client-Id and CF-Access-Client-Secret service-token inputs. Response: ${text}`);
|
|
10259
|
+
if (response.status < 200 || response.status >= 300) throw new Error(`epic:publish-structure failed: HQ ingest returned ${response.status} ${text}`);
|
|
10260
|
+
let duplicate = false;
|
|
10261
|
+
try {
|
|
10262
|
+
duplicate = JSON.parse(text).duplicate === true;
|
|
10263
|
+
} catch {
|
|
10264
|
+
duplicate = response.status === 200;
|
|
10265
|
+
}
|
|
10266
|
+
return {
|
|
10267
|
+
duplicate,
|
|
10268
|
+
eventId: args.event.eventId,
|
|
10269
|
+
status: response.status
|
|
10270
|
+
};
|
|
10271
|
+
};
|
|
10272
|
+
//#endregion
|
|
10273
|
+
//#region src/commands/epic-publish-structure.ts
|
|
10274
|
+
const STDIN = "-";
|
|
10275
|
+
/** Reads a dag.yml source path, or stdin when `source` is `-`. */
|
|
10276
|
+
const readSource = (source) => {
|
|
10277
|
+
const fd = source === STDIN ? 0 : source;
|
|
10278
|
+
try {
|
|
10279
|
+
return readFileSync(fd, "utf-8");
|
|
10280
|
+
} catch (error) {
|
|
10281
|
+
const cause = error instanceof Error ? error.message : String(error);
|
|
10282
|
+
throw new Error(`epic:publish-structure could not read ${source === STDIN ? "stdin" : source} (${cause})`, { cause: error });
|
|
10283
|
+
}
|
|
10284
|
+
};
|
|
10285
|
+
/** Parses a dag.yml/JSON document read from `readSource`. */
|
|
10286
|
+
const parseDocument = (raw, source) => {
|
|
10287
|
+
if (raw.trim() === "") throw new Error(`epic:publish-structure received empty input from ${source === STDIN ? "stdin" : source}`);
|
|
10288
|
+
try {
|
|
10289
|
+
return parse(raw);
|
|
10290
|
+
} catch (error) {
|
|
10291
|
+
const cause = error instanceof Error ? error.message : String(error);
|
|
10292
|
+
throw new Error(`epic:publish-structure could not parse YAML: ${cause}`, { cause: error });
|
|
10293
|
+
}
|
|
10294
|
+
};
|
|
10295
|
+
/**
|
|
10296
|
+
* Reads and validates a dag.yml/JSON source into a full `epic-structure` v1
|
|
10297
|
+
* event, ready to POST or dry-run print. Shared with `factory:closeout`'s
|
|
10298
|
+
* terminal-snapshot re-emission (issue #392) so both commands build the exact
|
|
10299
|
+
* same wire event from the exact same source format — one current contract,
|
|
10300
|
+
* not a forked parser.
|
|
10301
|
+
*/
|
|
10302
|
+
const buildEvent = (source, options = {}) => {
|
|
10303
|
+
return buildEpicStructureEvent({
|
|
10304
|
+
document: parseDocument(readSource(source), source),
|
|
10305
|
+
...options.boundary ? { boundary: options.boundary } : {},
|
|
10306
|
+
...options.name ? { name: options.name } : {},
|
|
10307
|
+
...options.repo ? { repo: options.repo } : {}
|
|
10308
|
+
});
|
|
10309
|
+
};
|
|
10310
|
+
function createEpicPublishStructureCommand(output, deps = {}) {
|
|
10311
|
+
const env = deps.env ?? process.env;
|
|
10312
|
+
return new Command("epic:publish-structure").description("Emit-only: read a planner's ephemeral dag.yml (or - for stdin), validate the graph fail-closed (YAML parses, required fields, edges reference known slugs, acyclic, issue numbers on every non-external node), and POST an epic-structure v1 event to HQ ingest. Idempotent by a content-addressed eventId (re-emission of an amendment supersedes the prior structure). Production auth uses the CF-Access-Client-Id and CF-Access-Client-Secret environment inputs. URL: --url or HQ_INGEST_URL. The dag.yml schema is documented with the epic skill (#137).").argument("[source]", "path to dag.yml, or - for stdin", STDIN).option("--url <url>", "HQ ingest base URL (default: HQ_INGEST_URL env). The command POSTs to <url>/api/ingest").option("--boundary <slug>", "boundary slug for the eventId namespace (default: dag `boundary:`, else `epic-<id>`)").option("--repo <name>", "repository name for the epic record and lane-id derivation (default: dag `repo:`)").option("--name <title>", "epic display name (default: dag `name:`)").option("--dry-run", "validate and build the event, print it, and exit without POSTing").option("--json", "print the built event / publish result as JSON").action(async (source, options) => {
|
|
10313
|
+
const event = buildEvent(source, options);
|
|
10314
|
+
if (options.dryRun) {
|
|
10315
|
+
output.stdout.write(options.json ? `${JSON.stringify(event, null, 2)}\n` : `epic:publish-structure OK (dry-run) — eventId ${event.eventId}, ${event.payload.nodes.length} node(s), ${event.payload.edges.length} edge(s)\n`);
|
|
10316
|
+
return;
|
|
10317
|
+
}
|
|
10318
|
+
const url = options.url ?? env.HQ_INGEST_URL;
|
|
10319
|
+
if (!url) throw new Error("epic:publish-structure requires an HQ ingest URL: pass --url or set HQ_INGEST_URL");
|
|
10320
|
+
const clientId = env[CF_ACCESS_CLIENT_ID_ENV];
|
|
10321
|
+
const clientSecret = env[CF_ACCESS_CLIENT_SECRET_ENV];
|
|
10322
|
+
const result = await publishEpicStructure({
|
|
10323
|
+
...clientId && clientSecret ? { accessServiceToken: {
|
|
10324
|
+
clientId,
|
|
10325
|
+
clientSecret
|
|
10326
|
+
} } : {},
|
|
10327
|
+
event,
|
|
10328
|
+
fetchImpl: deps.fetchImpl,
|
|
10329
|
+
url
|
|
10330
|
+
});
|
|
10331
|
+
output.stdout.write(options.json ? `${JSON.stringify(result, null, 2)}\n` : `epic:publish-structure ${result.duplicate ? "no-op (duplicate)" : "accepted"} — eventId ${result.eventId} [HTTP ${result.status}]\n`);
|
|
10332
|
+
});
|
|
10333
|
+
}
|
|
10334
|
+
//#endregion
|
|
10335
|
+
//#region src/commands/closeout.ts
|
|
10336
|
+
const formatUtcDate = (date) => date.toISOString().slice(0, 10);
|
|
10337
|
+
const nonEmptyRepoRoot = (value) => {
|
|
10338
|
+
if (value.trim() === "") throw new InvalidArgumentError("--repo-root requires a non-empty path");
|
|
10339
|
+
return value;
|
|
10340
|
+
};
|
|
10341
|
+
const codexThreadIdsForCloseout = (env = process.env) => {
|
|
10342
|
+
const threadId = normalizeSessionId(env.CODEX_THREAD_ID);
|
|
10343
|
+
return threadId === void 0 ? [] : [threadId];
|
|
10344
|
+
};
|
|
10345
|
+
const resolveRetroSubject = (issue, pr) => {
|
|
10346
|
+
if (issue !== void 0) return { issueNumber: issue };
|
|
10347
|
+
if (pr !== void 0) return { prNumber: pr };
|
|
10348
|
+
};
|
|
10349
|
+
const tokenFamilySummary = (tokenFamilies) => {
|
|
10350
|
+
const available = [];
|
|
10351
|
+
const unavailable = [];
|
|
10352
|
+
for (const family of ["claude", "gpt"]) if (tokenFamilies[family] === void 0) unavailable.push(family);
|
|
10353
|
+
else available.push(family);
|
|
10354
|
+
return {
|
|
10355
|
+
available,
|
|
10356
|
+
unavailable
|
|
10357
|
+
};
|
|
10358
|
+
};
|
|
10359
|
+
const reportLocalCloseoutOutcome = (output, input) => {
|
|
10360
|
+
const harvest = tokenFamilySummary(input.retro.envelope.tokenFamilies);
|
|
10361
|
+
output.stdout.write(`local artifacts: ${input.paths.markdownPath}, ${input.paths.jsonPath}, and ${input.retro.envelopePath}\n`);
|
|
10362
|
+
output.stdout.write(`boundary thermo input: runs=${input.boundaryThermo.runs}, owner=${input.boundaryThermo.owner}, waived=${input.boundaryThermo.waived}${input.boundaryThermo.waiverRationale === void 0 ? "" : `, waiver rationale=${input.boundaryThermo.waiverRationale}`}\n`);
|
|
10363
|
+
if (harvest.unavailable.length > 0) output.stderr.write(`harvest data unavailable: ${harvest.available.length === 0 ? "no token families were recorded" : `missing ${harvest.unavailable.join(" and ")} token family data`}; recorded gaps: ${input.retro.envelope.dataGaps.join(" | ")}\n`);
|
|
10364
|
+
else if (input.retro.envelope.dataGaps.length > 0) output.stderr.write(`retro-envelope data gaps recorded: ${input.retro.envelope.dataGaps.join(" | ")}\n`);
|
|
10365
|
+
};
|
|
9092
10366
|
const reportRetroHqDeliveryOutcome = (output, outcome) => {
|
|
9093
10367
|
output.stderr.write(`advisory HQ handoff ${outcome} for the retro envelope; local closeout success does not depend on HQ.\n`);
|
|
9094
10368
|
};
|
|
9095
10369
|
const reportArtifactHqHandoffOutcome = (output, outcome) => {
|
|
9096
10370
|
output.stderr.write(`advisory HQ handoff ${outcome} for the closeout artifact; local closeout success does not depend on HQ.\n`);
|
|
9097
10371
|
};
|
|
10372
|
+
const reportStructureReemissionOutcome = (output, outcome, detail) => {
|
|
10373
|
+
output.stderr.write(`advisory HQ epic-structure re-emission ${outcome}${detail ? ` (${detail})` : ""}; local closeout success does not depend on HQ.\n`);
|
|
10374
|
+
};
|
|
10375
|
+
const DEFAULT_STRUCTURE_REEMISSION_TIMEOUT_MS = 5e3;
|
|
10376
|
+
const withTimeout = async (operation, timeoutMs, message) => {
|
|
10377
|
+
const raceAbort = new AbortController();
|
|
10378
|
+
const fetchAbort = new AbortController();
|
|
10379
|
+
const settleTimeout = async () => {
|
|
10380
|
+
try {
|
|
10381
|
+
await setTimeout$1(Math.max(0, timeoutMs), void 0, { signal: raceAbort.signal });
|
|
10382
|
+
} catch {}
|
|
10383
|
+
return { status: "timed-out" };
|
|
10384
|
+
};
|
|
10385
|
+
const settleOperation = async () => ({
|
|
10386
|
+
status: "fulfilled",
|
|
10387
|
+
value: await operation(fetchAbort.signal)
|
|
10388
|
+
});
|
|
10389
|
+
try {
|
|
10390
|
+
const result = await Promise.race([settleOperation(), settleTimeout()]);
|
|
10391
|
+
if (result.status === "timed-out") {
|
|
10392
|
+
fetchAbort.abort();
|
|
10393
|
+
throw new Error(message);
|
|
10394
|
+
}
|
|
10395
|
+
return result.value;
|
|
10396
|
+
} finally {
|
|
10397
|
+
raceAbort.abort();
|
|
10398
|
+
}
|
|
10399
|
+
};
|
|
10400
|
+
const bareRepoName = (ownerRepo) => {
|
|
10401
|
+
const separatorIndex = ownerRepo.lastIndexOf("/");
|
|
10402
|
+
return separatorIndex === -1 ? ownerRepo : ownerRepo.slice(separatorIndex + 1);
|
|
10403
|
+
};
|
|
10404
|
+
const structureIdentityMismatch = (event, expected) => {
|
|
10405
|
+
const expectedRepo = bareRepoName(expected.repo);
|
|
10406
|
+
const { epics } = event.payload;
|
|
10407
|
+
if (epics.length === 0) return;
|
|
10408
|
+
if (epics.every((epic) => epic.id !== expected.epic)) return `dag epic id(s) [${epics.map((epic) => epic.id).join(", ")}] do not include --epic ${expected.epic}`;
|
|
10409
|
+
if (epics.every((epic) => epic.repo !== expectedRepo)) return `dag repo(s) [${epics.map((epic) => epic.repo).join(", ")}] do not include --repo ${expected.repo} (bare "${expectedRepo}")`;
|
|
10410
|
+
};
|
|
10411
|
+
const reemitEpicStructureAtCloseout = async (input) => {
|
|
10412
|
+
if (input.dagSource === void 0) return;
|
|
10413
|
+
const build = input.buildEvent ?? buildEvent;
|
|
10414
|
+
const publish = input.publish ?? publishEpicStructure;
|
|
10415
|
+
const url = input.url ?? input.env.HQ_INGEST_URL;
|
|
10416
|
+
if (!url) {
|
|
10417
|
+
reportStructureReemissionOutcome(input.output, "skipped", "no HQ ingest URL: pass --structure-url or set HQ_INGEST_URL");
|
|
10418
|
+
return;
|
|
10419
|
+
}
|
|
10420
|
+
let event;
|
|
10421
|
+
try {
|
|
10422
|
+
event = build(input.dagSource);
|
|
10423
|
+
} catch (error) {
|
|
10424
|
+
reportStructureReemissionOutcome(input.output, "failed", error instanceof Error ? error.message : String(error));
|
|
10425
|
+
return;
|
|
10426
|
+
}
|
|
10427
|
+
const mismatch = structureIdentityMismatch(event, {
|
|
10428
|
+
epic: input.expectedEpic,
|
|
10429
|
+
repo: input.expectedRepo
|
|
10430
|
+
});
|
|
10431
|
+
if (mismatch !== void 0) {
|
|
10432
|
+
reportStructureReemissionOutcome(input.output, "failed", mismatch);
|
|
10433
|
+
return;
|
|
10434
|
+
}
|
|
10435
|
+
const clientId = input.env[CF_ACCESS_CLIENT_ID_ENV];
|
|
10436
|
+
const clientSecret = input.env[CF_ACCESS_CLIENT_SECRET_ENV];
|
|
10437
|
+
const timeoutMs = input.timeoutMs ?? DEFAULT_STRUCTURE_REEMISSION_TIMEOUT_MS;
|
|
10438
|
+
try {
|
|
10439
|
+
const result = await withTimeout((signal) => publish({
|
|
10440
|
+
...clientId && clientSecret ? { accessServiceToken: {
|
|
10441
|
+
clientId,
|
|
10442
|
+
clientSecret
|
|
10443
|
+
} } : {},
|
|
10444
|
+
event,
|
|
10445
|
+
fetchImpl: input.fetchImpl,
|
|
10446
|
+
signal,
|
|
10447
|
+
url
|
|
10448
|
+
}), timeoutMs, `epic-structure re-emission exceeded its ${timeoutMs}ms advisory deadline`);
|
|
10449
|
+
reportStructureReemissionOutcome(input.output, result.duplicate ? "duplicate" : "accepted", `eventId ${result.eventId}`);
|
|
10450
|
+
} catch (error) {
|
|
10451
|
+
reportStructureReemissionOutcome(input.output, "failed", error instanceof Error ? error.message : String(error));
|
|
10452
|
+
}
|
|
10453
|
+
};
|
|
9098
10454
|
const handoffCloseoutToHq = async (input) => {
|
|
9099
10455
|
const epicIssue = Number(input.artifact.epic);
|
|
9100
10456
|
let artifactOutcome = "deferred";
|
|
@@ -9139,7 +10495,7 @@ const defaultTraceWindow = () => {
|
|
|
9139
10495
|
function createCloseoutCommand(output, dependencies = {}) {
|
|
9140
10496
|
const buildRetroGate = dependencies.buildRetroEnvelope ?? buildAndPersistRetroEnvelope;
|
|
9141
10497
|
const persistArtifact = dependencies.writeArtifact ?? writeCloseoutArtifact;
|
|
9142
|
-
return new Command("factory:closeout").description("Emit an epic-close metrics + lessons closeout (markdown + HQ-ledger JSON)").requiredOption("--epic <id>", "epic identifier").option("--repo <owner/repo>", "repository", DEFAULT_FACTORY_REPOSITORY).option("--repo-root <path>", "consumer repo root for trace shards and output", nonEmptyRepoRoot, ".").option("--out-dir <path>", "output directory under repo root", ".factory-memory/closeouts").option("--from <YYYY-MM-DD>", "trace diagnostics scan window start — surfaces malformed trace shards only, no metrics (default: today minus 30 days UTC)").option("--to <YYYY-MM-DD>", "trace diagnostics scan window end (default: today UTC)").option("--lane <name>", "interior telemetry lane label", "epic-closeout").option("--chats-dir <path>", "Cursor chats directory for interior telemetry").option("--usage-csv <path>", "Cursor usage CSV for interior telemetry").option("--codex-log <path>", "Codex exec JSON log for interior telemetry").option("--epic-chat-id <id>", "allowlist a Cursor chatId for epic interior scoping (repeatable)", (value, previous) => [...previous, value], []).option("--epic-chat-cwd <path>", "allowlist a worktree cwd for epic interior scoping (repeatable)", (value, previous) => [...previous, value], []).option("--issue <n>", "optional issue-number filter for the trace diagnostics scan", Number.parseInt).option("--pr <n>", "optional pull-request-number filter for the trace diagnostics scan", Number.parseInt).option("--json", "print the artifact JSON to stdout").option("--dry-run", "run aggregation without writing files; print intended output paths").option("--boundary-thermo-runs <n>", "deep boundary-thermo run count for this wave/epic (default: 0 → tripwire)", (value) => sanitizeBoundaryThermoRuns(value)).option("--boundary-thermo-owner <name>", "named owner of the boundary-thermo gate (default: epic-closeout orchestrator)").option("--boundary-thermo-waived", "record an explicit waiver for zero boundary-thermo runs").option("--boundary-thermo-waiver <text>", "waiver rationale when --boundary-thermo-waived is set").action(withGateTiming({
|
|
10498
|
+
return new Command("factory:closeout").description("Emit an epic-close metrics + lessons closeout (markdown + HQ-ledger JSON)").requiredOption("--epic <id>", "epic identifier").option("--repo <owner/repo>", "repository", DEFAULT_FACTORY_REPOSITORY).option("--repo-root <path>", "consumer repo root for trace shards and output", nonEmptyRepoRoot, ".").option("--out-dir <path>", "output directory under repo root", ".factory-memory/closeouts").option("--from <YYYY-MM-DD>", "trace diagnostics scan window start — surfaces malformed trace shards only, no metrics (default: today minus 30 days UTC)").option("--to <YYYY-MM-DD>", "trace diagnostics scan window end (default: today UTC)").option("--lane <name>", "interior telemetry lane label", "epic-closeout").option("--chats-dir <path>", "Cursor chats directory for interior telemetry").option("--usage-csv <path>", "Cursor usage CSV for interior telemetry").option("--codex-log <path>", "Codex exec JSON log for interior telemetry").option("--epic-chat-id <id>", "allowlist a Cursor chatId for epic interior scoping (repeatable)", (value, previous) => [...previous, value], []).option("--epic-chat-cwd <path>", "allowlist a worktree cwd for epic interior scoping (repeatable)", (value, previous) => [...previous, value], []).option("--issue <n>", "optional issue-number filter for the trace diagnostics scan", Number.parseInt).option("--pr <n>", "optional pull-request-number filter for the trace diagnostics scan", Number.parseInt).option("--json", "print the artifact JSON to stdout").option("--dry-run", "run aggregation without writing files; print intended output paths").option("--boundary-thermo-runs <n>", "deep boundary-thermo run count for this wave/epic (default: 0 → tripwire)", (value) => sanitizeBoundaryThermoRuns(value)).option("--boundary-thermo-owner <name>", "named owner of the boundary-thermo gate (default: epic-closeout orchestrator)").option("--boundary-thermo-waived", "record an explicit waiver for zero boundary-thermo runs").option("--boundary-thermo-waiver <text>", "waiver rationale when --boundary-thermo-waived is set").option("--structure-dag <path>", "path to a terminal dag.yml/JSON document (or - for stdin) to re-emit as the closeout epic-structure snapshot, via the same builder epic:publish-structure uses. Advisory and idempotent by content-addressed eventId. Omit to skip re-emission entirely — closeout never fabricates a structure snapshot that was never authored.").option("--structure-url <url>", "HQ ingest base URL for the closeout structure re-emission (default: HQ_INGEST_URL env)").action(withGateTiming({
|
|
9143
10499
|
gate: "factory:closeout",
|
|
9144
10500
|
resolveLedgerRoot: (options) => resolveCwdOption(options.repoRoot),
|
|
9145
10501
|
shouldRecord: (options) => options.dryRun !== true,
|
|
@@ -9219,6 +10575,18 @@ function createCloseoutCommand(output, dependencies = {}) {
|
|
|
9219
10575
|
retro,
|
|
9220
10576
|
written
|
|
9221
10577
|
});
|
|
10578
|
+
await reemitEpicStructureAtCloseout({
|
|
10579
|
+
buildEvent: dependencies.buildEpicStructureEvent,
|
|
10580
|
+
dagSource: options.structureDag,
|
|
10581
|
+
env: dependencies.env ?? process.env,
|
|
10582
|
+
expectedEpic: options.epic,
|
|
10583
|
+
expectedRepo: options.repo,
|
|
10584
|
+
fetchImpl: dependencies.structureFetch,
|
|
10585
|
+
output,
|
|
10586
|
+
publish: dependencies.publishEpicStructure,
|
|
10587
|
+
timeoutMs: dependencies.structureTimeoutMs,
|
|
10588
|
+
url: options.structureUrl
|
|
10589
|
+
});
|
|
9222
10590
|
output.stdout.write(`wrote ${written.markdownPath} and ${written.jsonPath}\n`);
|
|
9223
10591
|
}));
|
|
9224
10592
|
}
|
|
@@ -10660,7 +12028,7 @@ const runDemandWaive = (args, dependencies = {}) => {
|
|
|
10660
12028
|
const cwd = path.resolve(args.cwd ?? process.cwd());
|
|
10661
12029
|
const env = dependencies.env ?? process.env;
|
|
10662
12030
|
const repository = (dependencies.checkoutRepository ?? checkoutRepository)(cwd);
|
|
10663
|
-
const parsedDemand =
|
|
12031
|
+
const parsedDemand = waiverDemandKeySchema.safeParse(args.demand.trim());
|
|
10664
12032
|
const rationale = args.rationale.trim();
|
|
10665
12033
|
const refusals = requestRefusals({
|
|
10666
12034
|
demand: args.demand,
|
|
@@ -10854,6 +12222,145 @@ const demandedRungSatisfactionReasons = ({ demandedRung, liveHumanReviews, revie
|
|
|
10854
12222
|
return reviewRuns?.flatMap((review) => review.outcome === "passed" && review.rung !== void 0 ? [review.rung] : []).find((candidate) => rungMeetsMinimum(candidate, demandedRung)) ? [] : [`Boundary wave requires the ${demandedRung} rung, but the recorded review evidence has no passing review at that authority.`];
|
|
10855
12223
|
};
|
|
10856
12224
|
//#endregion
|
|
12225
|
+
//#region src/hq-credentials.ts
|
|
12226
|
+
/**
|
|
12227
|
+
* Resolving the HQ Access credentials, and saying *why* when that fails.
|
|
12228
|
+
*
|
|
12229
|
+
* A deliberate command (`psf hq:flush`, and doctor's remote checks in #394) is
|
|
12230
|
+
* the one place allowed to ask the secret manager for the HQ Access token. It
|
|
12231
|
+
* used to keep a value-or-nothing answer, so a missing binary, a sandbox that
|
|
12232
|
+
* cannot reach the keychain, and a mistyped reference all collapsed into one
|
|
12233
|
+
* generic "credentials are unavailable" line. A sandboxed lane read that as
|
|
12234
|
+
* environmental noise while every one of its proofs stayed spooled.
|
|
12235
|
+
*
|
|
12236
|
+
* Two boundaries hold everywhere in this module:
|
|
12237
|
+
*
|
|
12238
|
+
* - **Classification comes from the failure mode, never from resolver output.**
|
|
12239
|
+
* No stderr is captured and no resolver text is retained or rendered: a
|
|
12240
|
+
* secret manager's diagnostics can quote the material it was asked for.
|
|
12241
|
+
* Exit status, spawn error code, and signal are the whole evidence base.
|
|
12242
|
+
* - **Trusted-local only.** Hosted runners never invoke a secret manager
|
|
12243
|
+
* (#312); references stay inert there and the failure says so.
|
|
12244
|
+
*/
|
|
12245
|
+
/** Ceiling for one secret-manager probe; expiry means unresolved, never a hang. */
|
|
12246
|
+
const SECRET_PROBE_TIMEOUT_MS = 1e4;
|
|
12247
|
+
/** The operator's resolution path: `op-fast` first, official `op` as fallback. */
|
|
12248
|
+
const SECRET_RESOLVER_BINARIES = ["op-fast", "op"];
|
|
12249
|
+
const spawnFailure = (error) => {
|
|
12250
|
+
const code = error?.code;
|
|
12251
|
+
if (code === "ENOENT") return "resolver-missing";
|
|
12252
|
+
if (code === "EACCES" || code === "EPERM") return "resolver-blocked";
|
|
12253
|
+
return "resolver-timeout";
|
|
12254
|
+
};
|
|
12255
|
+
const probe = (binary, reference, capture) => {
|
|
12256
|
+
const result = spawnSync(binary, ["read", reference], {
|
|
12257
|
+
encoding: "utf-8",
|
|
12258
|
+
stdio: [
|
|
12259
|
+
"ignore",
|
|
12260
|
+
capture ? "pipe" : "ignore",
|
|
12261
|
+
"ignore"
|
|
12262
|
+
],
|
|
12263
|
+
timeout: SECRET_PROBE_TIMEOUT_MS
|
|
12264
|
+
});
|
|
12265
|
+
if (result.error) return { status: spawnFailure(result.error) };
|
|
12266
|
+
if (result.signal) return { status: "resolver-timeout" };
|
|
12267
|
+
if (result.status !== 0) return { status: "resolver-refused" };
|
|
12268
|
+
if (!capture) return {
|
|
12269
|
+
status: "resolved",
|
|
12270
|
+
value: ""
|
|
12271
|
+
};
|
|
12272
|
+
const value = result.stdout.trim();
|
|
12273
|
+
return value === "" ? { status: "resolver-refused" } : {
|
|
12274
|
+
status: "resolved",
|
|
12275
|
+
value
|
|
12276
|
+
};
|
|
12277
|
+
};
|
|
12278
|
+
/**
|
|
12279
|
+
* `op-fast` first, official `op` as the fallback, matching `doctor
|
|
12280
|
+
* --preflight`'s resolvability probe. The reported failure is the first
|
|
12281
|
+
* *informative* one: a machine without `op-fast` that has `op` installed and
|
|
12282
|
+
* denied must report the denial, not the missing binary.
|
|
12283
|
+
*/
|
|
12284
|
+
const resolveThrough = (reference, capture) => {
|
|
12285
|
+
let failure = "resolver-missing";
|
|
12286
|
+
for (const binary of SECRET_RESOLVER_BINARIES) {
|
|
12287
|
+
const outcome = probe(binary, reference, capture);
|
|
12288
|
+
if (outcome.status === "resolved") return outcome;
|
|
12289
|
+
if (failure === "resolver-missing") failure = outcome.status;
|
|
12290
|
+
}
|
|
12291
|
+
return { status: failure };
|
|
12292
|
+
};
|
|
12293
|
+
const defaultSecretReferenceResolver = (reference) => resolveThrough(reference, true);
|
|
12294
|
+
/**
|
|
12295
|
+
* Answers only *whether* a reference resolves. The secret is never read into
|
|
12296
|
+
* this process — stdout goes to `/dev/null`, so a `resolved` outcome here
|
|
12297
|
+
* carries an empty value by construction. `doctor --preflight` uses this: it
|
|
12298
|
+
* reports resolvability and must never materialize a credential to do it
|
|
12299
|
+
* (#312's boundary, restated by #414's no-retention rule).
|
|
12300
|
+
*/
|
|
12301
|
+
const defaultSecretReferenceProbe = (reference) => resolveThrough(reference, false);
|
|
12302
|
+
/**
|
|
12303
|
+
* Environment first (the #312 emit-time path), then the operator's configured
|
|
12304
|
+
* references. The first reference that fails ends the resolution: a second
|
|
12305
|
+
* probe would add nothing but another chance to hang.
|
|
12306
|
+
*/
|
|
12307
|
+
const resolveHqCredentials = ({ env, references, resolve = defaultSecretReferenceResolver }) => {
|
|
12308
|
+
const clientId = env[CF_ACCESS_CLIENT_ID_ENV];
|
|
12309
|
+
const clientSecret = env[CF_ACCESS_CLIENT_SECRET_ENV];
|
|
12310
|
+
if (clientId && clientSecret) return {
|
|
12311
|
+
credentials: {
|
|
12312
|
+
clientId,
|
|
12313
|
+
clientSecret
|
|
12314
|
+
},
|
|
12315
|
+
source: "environment",
|
|
12316
|
+
status: "resolved"
|
|
12317
|
+
};
|
|
12318
|
+
if (!references) return {
|
|
12319
|
+
failure: "no-reference",
|
|
12320
|
+
status: "unresolved"
|
|
12321
|
+
};
|
|
12322
|
+
if (isHostedRunner(env)) return {
|
|
12323
|
+
failure: "hosted-runner",
|
|
12324
|
+
status: "unresolved"
|
|
12325
|
+
};
|
|
12326
|
+
const resolvedId = resolve(references.clientIdRef);
|
|
12327
|
+
if (resolvedId.status !== "resolved") return {
|
|
12328
|
+
failure: resolvedId.status,
|
|
12329
|
+
reference: references.clientIdRef,
|
|
12330
|
+
status: "unresolved"
|
|
12331
|
+
};
|
|
12332
|
+
const resolvedSecret = resolve(references.clientSecretRef);
|
|
12333
|
+
if (resolvedSecret.status !== "resolved") return {
|
|
12334
|
+
failure: resolvedSecret.status,
|
|
12335
|
+
reference: references.clientSecretRef,
|
|
12336
|
+
status: "unresolved"
|
|
12337
|
+
};
|
|
12338
|
+
return {
|
|
12339
|
+
credentials: {
|
|
12340
|
+
clientId: resolvedId.value,
|
|
12341
|
+
clientSecret: resolvedSecret.value
|
|
12342
|
+
},
|
|
12343
|
+
source: "references",
|
|
12344
|
+
status: "resolved"
|
|
12345
|
+
};
|
|
12346
|
+
};
|
|
12347
|
+
const SANDBOX_REMEDY = "Run this command in a trusted local session outside the sandbox (`psf hq:flush` and `psf pr:publish` both need one).";
|
|
12348
|
+
/** One sentence per failure mode. Exhaustive by construction. */
|
|
12349
|
+
const FAILURE_SENTENCE = {
|
|
12350
|
+
"hosted-runner": () => `this is a hosted runner, where resolving a secret reference is never attempted (#312). Provide ${CF_ACCESS_CLIENT_ID_ENV} and ${CF_ACCESS_CLIENT_SECRET_ENV} in the workflow environment instead.`,
|
|
12351
|
+
"no-reference": () => `neither ${CF_ACCESS_CLIENT_ID_ENV}/${CF_ACCESS_CLIENT_SECRET_ENV} in the environment nor \`hqIngestCredentials\` references in the operator user config. Record the references there to make this resolvable.`,
|
|
12352
|
+
"resolver-blocked": (reference) => `this session may not execute a secret manager, so ${reference} was never read. ${SANDBOX_REMEDY}`,
|
|
12353
|
+
"resolver-missing": (reference) => `no secret manager was found on PATH (tried ${SECRET_RESOLVER_BINARIES.join(", ")}), so ${reference} could not be resolved.`,
|
|
12354
|
+
"resolver-refused": (reference) => `the secret manager ran but returned no value for ${reference}: its store is unreachable from this session (a sandbox has no keychain access) or the reference is not readable. No resolver output is captured, so these are one answer. ${SANDBOX_REMEDY} If the session is already trusted, warm the reference (\`op-fast read '<ref>' >/dev/null\`) or correct it in the user config.`,
|
|
12355
|
+
"resolver-timeout": (reference) => `the secret manager did not answer within ${SECRET_PROBE_TIMEOUT_MS}ms for ${reference} — typically a desktop approval nobody can grant here. ${SANDBOX_REMEDY}`
|
|
12356
|
+
};
|
|
12357
|
+
/**
|
|
12358
|
+
* The operator-facing reason. Names the failure mode and its remedy, and can
|
|
12359
|
+
* contain a reference but never a value — the resolver's own output is never
|
|
12360
|
+
* read, so nothing here can quote a secret.
|
|
12361
|
+
*/
|
|
12362
|
+
const describeHqCredentialFailure = (resolution) => `HQ Access credentials are unavailable: ${FAILURE_SENTENCE[resolution.failure](resolution.reference ?? "the configured reference")}`;
|
|
12363
|
+
//#endregion
|
|
10857
12364
|
//#region src/hq-ingest-preflight.ts
|
|
10858
12365
|
/**
|
|
10859
12366
|
* Whether the HQ ingest credentials resolve (#312).
|
|
@@ -10866,6 +12373,8 @@ const demandedRungSatisfactionReasons = ({ demandedRung, liveHumanReviews, revie
|
|
|
10866
12373
|
* Nothing connected "this variable is missing" to "a resolvable reference for it
|
|
10867
12374
|
* exists", so a silent degradation persisted because nothing surfaced the
|
|
10868
12375
|
* remedy.
|
|
12376
|
+
* Since #369 this check is the *only* owner of that question: the runtime
|
|
12377
|
+
* admission commands defer silently and retain the reason in the retry spool.
|
|
10869
12378
|
*
|
|
10870
12379
|
* Hard boundaries, all of them decided:
|
|
10871
12380
|
* - **Resolvability only, never values.** Nothing here reads, prints, logs,
|
|
@@ -10877,28 +12386,17 @@ const demandedRungSatisfactionReasons = ({ demandedRung, liveHumanReviews, revie
|
|
|
10877
12386
|
* In CI the probe is inert — not merely unused.
|
|
10878
12387
|
* - **The factory never acquires credentials.** No auto-export, no writing to
|
|
10879
12388
|
* shell profiles, no auth flow. It names the remedy; the operator acts.
|
|
10880
|
-
*/
|
|
10881
|
-
const HQ_INGEST_PREFLIGHT_CHECK_NAME = "admission:hq-ingest";
|
|
10882
|
-
/**
|
|
10883
|
-
* The operator's existing resolution path: `op-fast` first, official `op` as the
|
|
10884
|
-
* fallback, consistent with the trusted-local Alchemy commands. Output is
|
|
10885
|
-
* discarded — only the exit status is read.
|
|
10886
|
-
*/
|
|
10887
|
-
const defaultSecretReferenceProbe = (reference) => ["op-fast", "op"].some((binary) => {
|
|
10888
|
-
const result = spawnSync(binary, ["read", reference], {
|
|
10889
|
-
encoding: "utf-8",
|
|
10890
|
-
stdio: [
|
|
10891
|
-
"ignore",
|
|
10892
|
-
"ignore",
|
|
10893
|
-
"ignore"
|
|
10894
|
-
]
|
|
10895
|
-
});
|
|
10896
|
-
return !result.error && result.status === 0;
|
|
10897
|
-
});
|
|
12389
|
+
*/
|
|
12390
|
+
const HQ_INGEST_PREFLIGHT_CHECK_NAME = "admission:hq-ingest";
|
|
10898
12391
|
const REMEDY_SHAPE = `The variable names are literally hyphenated, so a plain \`export\` does not set them — prefix the command: \`env '${CF_ACCESS_CLIENT_ID_ENV}=…' '${CF_ACCESS_CLIENT_SECRET_ENV}=…' <command>\`. \`HQ_INGEST_URL\` wants the origin (https://hq.patronage.com), not the profile's /api/ingest endpoint.`;
|
|
10899
|
-
/**
|
|
10900
|
-
const
|
|
10901
|
-
|
|
12392
|
+
/** One short, operator-facing hint per resolver failure mode (#394 fold). */
|
|
12393
|
+
const RESOLUTION_HINT = {
|
|
12394
|
+
"resolver-blocked": "this session may not execute a secret manager",
|
|
12395
|
+
"resolver-missing": "no secret manager was found on PATH",
|
|
12396
|
+
"resolver-refused": "the secret manager ran but returned no value (its store is unreachable from this session, or the reference is wrong)",
|
|
12397
|
+
"resolver-timeout": "the secret manager did not answer in time"
|
|
12398
|
+
};
|
|
12399
|
+
function hqIngestCredentialCheck({ env, resolve = defaultSecretReferenceProbe, references }) {
|
|
10902
12400
|
if (Boolean(env["CF-Access-Client-Id"] && env["CF-Access-Client-Secret"])) return {
|
|
10903
12401
|
message: `HQ ingest credentials are present in the environment (${CF_ACCESS_CLIENT_ID_ENV}, ${CF_ACCESS_CLIENT_SECRET_ENV}); ingest will deliver.`,
|
|
10904
12402
|
name: HQ_INGEST_PREFLIGHT_CHECK_NAME,
|
|
@@ -10914,9 +12412,14 @@ function hqIngestCredentialCheck({ env, probe = defaultSecretReferenceProbe, ref
|
|
|
10914
12412
|
name: HQ_INGEST_PREFLIGHT_CHECK_NAME,
|
|
10915
12413
|
status: "warning"
|
|
10916
12414
|
};
|
|
10917
|
-
const
|
|
10918
|
-
|
|
10919
|
-
|
|
12415
|
+
const unresolved = [references.clientIdRef, references.clientSecretRef].map((reference) => ({
|
|
12416
|
+
reference,
|
|
12417
|
+
resolution: resolve(reference)
|
|
12418
|
+
})).filter(({ resolution }) => resolution.status !== "resolved");
|
|
12419
|
+
if (unresolved.length > 0) return {
|
|
12420
|
+
message: `HQ ingest credentials are absent and did not resolve from the operator's secret store: ${unresolved.map(({ reference, resolution }) => {
|
|
12421
|
+
return `${reference} (${RESOLUTION_HINT[resolution.status]})`;
|
|
12422
|
+
}).join("; ")}. Warm the reference (\`op-fast read '<ref>' >/dev/null\`) or correct it in the operator user config. ${REMEDY_SHAPE}`,
|
|
10920
12423
|
name: HQ_INGEST_PREFLIGHT_CHECK_NAME,
|
|
10921
12424
|
status: "warning"
|
|
10922
12425
|
};
|
|
@@ -11565,16 +13068,42 @@ const statusFromBlockingReasons = ({ blockingReasons, finalReviewBlockingReasons
|
|
|
11565
13068
|
return "blocked";
|
|
11566
13069
|
};
|
|
11567
13070
|
const addReviewBlockingReasons = ({ blockers, correctnessRequired, correctnessStatus, correctnessUntyped }) => {
|
|
11568
|
-
|
|
11569
|
-
|
|
11570
|
-
|
|
11571
|
-
|
|
13071
|
+
const demand = reviewModeDemand("correctness");
|
|
13072
|
+
if (correctnessRequired && correctnessUntyped) blockers.push({
|
|
13073
|
+
demand,
|
|
13074
|
+
reason: CORRECTNESS_UNTYPED_REVIEW_BLOCKER_REASON
|
|
13075
|
+
});
|
|
13076
|
+
else if (correctnessStatus === "missing") blockers.push({
|
|
13077
|
+
demand,
|
|
13078
|
+
reason: "Missing current Correctness review proof."
|
|
13079
|
+
});
|
|
13080
|
+
else if (correctnessStatus === "stale") blockers.push({
|
|
13081
|
+
demand,
|
|
13082
|
+
reason: "Correctness review proof is stale because the PR-owned patch-id changed."
|
|
13083
|
+
});
|
|
13084
|
+
else if (correctnessStatus === "blocked") blockers.push({
|
|
13085
|
+
demand,
|
|
13086
|
+
reason: "Correctness review proof ran and explicitly failed (open blocking findings or errored run); resolve the findings and re-run pr:review."
|
|
13087
|
+
});
|
|
11572
13088
|
};
|
|
11573
13089
|
const addSecurityBlockingReasons = ({ blockers, securityRequired, securityStatus, securityUntyped }) => {
|
|
11574
|
-
|
|
11575
|
-
|
|
11576
|
-
|
|
11577
|
-
|
|
13090
|
+
const demand = reviewModeDemand("security");
|
|
13091
|
+
if (securityRequired && securityUntyped) blockers.push({
|
|
13092
|
+
demand,
|
|
13093
|
+
reason: SECURITY_UNTYPED_REVIEW_BLOCKER_REASON
|
|
13094
|
+
});
|
|
13095
|
+
else if (securityStatus === "missing") blockers.push({
|
|
13096
|
+
demand,
|
|
13097
|
+
reason: "Missing current Security review proof."
|
|
13098
|
+
});
|
|
13099
|
+
else if (securityStatus === "stale") blockers.push({
|
|
13100
|
+
demand,
|
|
13101
|
+
reason: "Security review proof is stale because the PR-owned patch-id changed."
|
|
13102
|
+
});
|
|
13103
|
+
else if (securityStatus === "blocked") blockers.push({
|
|
13104
|
+
demand,
|
|
13105
|
+
reason: "Security review proof ran and explicitly failed (open blocking findings or errored run); resolve or disposition the findings and re-run pr:review."
|
|
13106
|
+
});
|
|
11578
13107
|
};
|
|
11579
13108
|
const requiredChecksEvaluation = (input) => {
|
|
11580
13109
|
const requiredChecks = input.externalRequiredChecks ?? [];
|
|
@@ -11597,14 +13126,20 @@ const requiredChecksEvaluation = (input) => {
|
|
|
11597
13126
|
labels: input.prLabels
|
|
11598
13127
|
}
|
|
11599
13128
|
});
|
|
13129
|
+
const blockers = outcomes.flatMap((outcome) => outcome.reason ? [{
|
|
13130
|
+
demand: requiredCheckDemand(outcome.name),
|
|
13131
|
+
reason: outcome.reason
|
|
13132
|
+
}] : []);
|
|
13133
|
+
if (blockers.length !== blockingReasons.length) throw new Error("requiredChecks evaluation produced a blocking reason with no owning check; this is a factory bug.");
|
|
11600
13134
|
return {
|
|
11601
|
-
blockers
|
|
13135
|
+
blockers,
|
|
11602
13136
|
outcomes
|
|
11603
13137
|
};
|
|
11604
13138
|
};
|
|
11605
13139
|
const collectBlockers = ({ correctnessRequired, correctnessStatus, correctnessUntyped, input, prVerifyStatus, verificationProof, sectionReady, securityRequired, securityStatus, securityUntyped, trivialWaiverPresent }) => {
|
|
11606
13140
|
const blockers = [];
|
|
11607
13141
|
if (!sectionReady) blockers.push({
|
|
13142
|
+
demand: DEMAND_KEYS.prBodySections,
|
|
11608
13143
|
reason: RENDER_PR_BODY_SECTIONS_BLOCKER_REASON,
|
|
11609
13144
|
repair: {
|
|
11610
13145
|
action: "Render the required Verification / Review proof / How to review sections into the PR body from typed proof data (renderPrBodySections).",
|
|
@@ -11612,10 +13147,19 @@ const collectBlockers = ({ correctnessRequired, correctnessStatus, correctnessUn
|
|
|
11612
13147
|
command: `gh pr edit ${input.pr} --body-file -`
|
|
11613
13148
|
}
|
|
11614
13149
|
});
|
|
11615
|
-
if (prVerifyStatus === "missing") blockers.push({
|
|
11616
|
-
|
|
13150
|
+
if (prVerifyStatus === "missing") blockers.push({
|
|
13151
|
+
demand: DEMAND_KEYS.prVerify,
|
|
13152
|
+
reason: "Missing passing typed pr:verify proof for the current PR head."
|
|
13153
|
+
});
|
|
13154
|
+
else if (prVerifyStatus === "stale") blockers.push({
|
|
13155
|
+
demand: DEMAND_KEYS.prVerify,
|
|
13156
|
+
reason: "Typed pr:verify proof is stale because it is not tied to the current PR head."
|
|
13157
|
+
});
|
|
11617
13158
|
const proofBlockingReason = verificationProofBlockingReason(verificationProof);
|
|
11618
|
-
if (proofBlockingReason) blockers.push({
|
|
13159
|
+
if (proofBlockingReason) blockers.push({
|
|
13160
|
+
demand: DEMAND_KEYS.prVerify,
|
|
13161
|
+
reason: proofBlockingReason
|
|
13162
|
+
});
|
|
11619
13163
|
addReviewBlockingReasons({
|
|
11620
13164
|
blockers,
|
|
11621
13165
|
correctnessRequired,
|
|
@@ -11628,12 +13172,28 @@ const collectBlockers = ({ correctnessRequired, correctnessStatus, correctnessUn
|
|
|
11628
13172
|
securityStatus,
|
|
11629
13173
|
securityUntyped
|
|
11630
13174
|
});
|
|
11631
|
-
if (trivialWaiverPresent && input.classification === "non-trivial") blockers.push({
|
|
11632
|
-
|
|
11633
|
-
|
|
11634
|
-
|
|
11635
|
-
if (input.
|
|
13175
|
+
if (trivialWaiverPresent && input.classification === "non-trivial") blockers.push({
|
|
13176
|
+
demand: DEMAND_KEYS.trivialWaiver,
|
|
13177
|
+
reason: "Explicit trivial waiver is incompatible with a non-trivial diff."
|
|
13178
|
+
});
|
|
13179
|
+
if (input.unresolvedReviewThreads > 0) blockers.push({
|
|
13180
|
+
demand: DEMAND_KEYS.reviewThreads,
|
|
13181
|
+
reason: `GitHub has ${input.unresolvedReviewThreads} unresolved review thread(s).`
|
|
13182
|
+
});
|
|
13183
|
+
if (input.localHeadSha && !sameHeadSha(input.localHeadSha, input.headSha)) blockers.push({
|
|
13184
|
+
demand: DEMAND_KEYS.headIdentity,
|
|
13185
|
+
reason: "Local HEAD does not match the GitHub PR head."
|
|
13186
|
+
});
|
|
13187
|
+
if (input.mergeable !== "MERGEABLE" || !["CLEAN", "BLOCKED"].includes(input.mergeStateStatus)) blockers.push({
|
|
13188
|
+
demand: DEMAND_KEYS.mergeState,
|
|
13189
|
+
reason: `GitHub merge state is ${input.mergeStateStatus}/${input.mergeable}; expected CLEAN or review-only BLOCKED with MERGEABLE.`
|
|
13190
|
+
});
|
|
13191
|
+
if (input.requiredChecks === "none" && input.classification !== "docs/process-only") blockers.push({
|
|
13192
|
+
demand: DEMAND_KEYS.githubChecks,
|
|
13193
|
+
reason: "GitHub has no current checks for a non-docs PR."
|
|
13194
|
+
});
|
|
11636
13195
|
else if (input.requiredChecks !== "passed" && input.requiredChecks !== "none") blockers.push({
|
|
13196
|
+
demand: DEMAND_KEYS.githubChecks,
|
|
11637
13197
|
reason: `${PENDING_CHECKS_BLOCKER_REASON_PREFIX} ${input.requiredChecks}; expected passed.`,
|
|
11638
13198
|
...input.requiredChecks === "pending" ? { repair: {
|
|
11639
13199
|
action: "Poll and retry until post-undraft GitHub checks settle or fail; UNSTABLE/pending is a transient route-owned state, not a human blocker.",
|
|
@@ -11642,6 +13202,7 @@ const collectBlockers = ({ correctnessRequired, correctnessStatus, correctnessUn
|
|
|
11642
13202
|
} } : {}
|
|
11643
13203
|
});
|
|
11644
13204
|
if (input.draft) blockers.push({
|
|
13205
|
+
demand: DEMAND_KEYS.draft,
|
|
11645
13206
|
reason: DRAFT_BLOCKER_REASON,
|
|
11646
13207
|
repair: {
|
|
11647
13208
|
action: "Mark the PR ready for review; draft is route-owned once proof gates pass.",
|
|
@@ -11652,7 +13213,10 @@ const collectBlockers = ({ correctnessRequired, correctnessStatus, correctnessUn
|
|
|
11652
13213
|
return blockers;
|
|
11653
13214
|
};
|
|
11654
13215
|
const orderRepairs = (repairs) => repairs.toSorted((a, b) => READINESS_REPAIR_CODES.indexOf(a.code) - READINESS_REPAIR_CODES.indexOf(b.code));
|
|
11655
|
-
const SLICE_NOT_FINAL_BLOCKER = {
|
|
13216
|
+
const SLICE_NOT_FINAL_BLOCKER = {
|
|
13217
|
+
demand: DEMAND_KEYS.finalReviewPoint,
|
|
13218
|
+
reason: "This PR is a slice, not the intended final human review point."
|
|
13219
|
+
};
|
|
11656
13220
|
const postReadinessHumanCommentState = (input) => {
|
|
11657
13221
|
const checkRunUrls = input.checkRunHandledCommentUrls ?? [];
|
|
11658
13222
|
const cliHandledUrls = input.handledCommentUrls ?? [];
|
|
@@ -11675,15 +13239,21 @@ const postReadinessHumanCommentState = (input) => {
|
|
|
11675
13239
|
reviews: input.reviews ?? []
|
|
11676
13240
|
});
|
|
11677
13241
|
return {
|
|
11678
|
-
blockers: [...activeComments.map((comment) => ({
|
|
11679
|
-
|
|
11680
|
-
|
|
11681
|
-
|
|
11682
|
-
|
|
11683
|
-
|
|
11684
|
-
|
|
11685
|
-
|
|
11686
|
-
|
|
13242
|
+
blockers: [...activeComments.map((comment) => ({
|
|
13243
|
+
demand: DEMAND_KEYS.humanBlocker,
|
|
13244
|
+
reason: postReadinessHumanCommentReason({
|
|
13245
|
+
comment,
|
|
13246
|
+
pr: input.pr,
|
|
13247
|
+
prTitle: input.prTitle
|
|
13248
|
+
})
|
|
13249
|
+
})), ...activeReviews.map((review) => ({
|
|
13250
|
+
demand: DEMAND_KEYS.humanBlocker,
|
|
13251
|
+
reason: postReadinessHumanReviewReason({
|
|
13252
|
+
pr: input.pr,
|
|
13253
|
+
prTitle: input.prTitle,
|
|
13254
|
+
review
|
|
13255
|
+
})
|
|
13256
|
+
}))],
|
|
11687
13257
|
comments: activeComments,
|
|
11688
13258
|
ledgerFields: {
|
|
11689
13259
|
...input.handledCommentsProducer ? { handledCommentsProducer: input.handledCommentsProducer } : {},
|
|
@@ -11691,933 +13261,850 @@ const postReadinessHumanCommentState = (input) => {
|
|
|
11691
13261
|
...activeComments.length > 0 ? { postReadinessHumanComments: activeComments } : {},
|
|
11692
13262
|
...activeReviews.length > 0 ? { postReadinessHumanReviews: activeReviews } : {}
|
|
11693
13263
|
},
|
|
11694
|
-
status: (status) => activeComments.length > 0 || activeReviews.length > 0 ? "blocked" : status
|
|
11695
|
-
};
|
|
11696
|
-
};
|
|
11697
|
-
const verificationProofForInput = ({ input }) => {
|
|
11698
|
-
if (input.verificationProof) return {
|
|
11699
|
-
fullVerifiedHeadShas: input.docsOnlyVerifyBaselineHeadShas,
|
|
11700
|
-
verificationProof: input.verificationProof
|
|
11701
|
-
};
|
|
11702
|
-
return {
|
|
11703
|
-
fullVerifiedHeadShas: [],
|
|
11704
|
-
verificationProof: {
|
|
11705
|
-
kind: "typed-missing",
|
|
11706
|
-
path: "",
|
|
11707
|
-
reason: "enoent"
|
|
11708
|
-
}
|
|
11709
|
-
};
|
|
11710
|
-
};
|
|
11711
|
-
const verifyGateFor = ({ input }) => {
|
|
11712
|
-
const verification = verificationProofForInput({ input });
|
|
11713
|
-
const verificationProof = verificationProofForReadiness(verification.verificationProof);
|
|
11714
|
-
return {
|
|
11715
|
-
verification,
|
|
11716
|
-
...prVerifyEvaluation({
|
|
11717
|
-
acceptedBaselineHeadShas: verification.fullVerifiedHeadShas,
|
|
11718
|
-
currentHeadSha: input.headSha,
|
|
11719
|
-
docsOnlyProofCanStandAlone: input.classification === "docs/process-only",
|
|
11720
|
-
prVerifyProof: verificationProof,
|
|
11721
|
-
trivialProofCanStandAlone: input.classification === "trivial" || input.classification === "docs/process-only"
|
|
11722
|
-
})
|
|
11723
|
-
};
|
|
11724
|
-
};
|
|
11725
|
-
const previewDeployRequired = false;
|
|
11726
|
-
const evaluateReadiness = (input) => {
|
|
11727
|
-
const bodyMetadata = readPrBodyMetadata(input.body);
|
|
11728
|
-
const reviewModes = input.reviewModes ?? ["correctness"];
|
|
11729
|
-
const epoch = reviewEpochModeFor(input.reviewProof ?? {}, input.ladderPolicy);
|
|
11730
|
-
const verifyGate = verifyGateFor({
|
|
11731
|
-
bodyMetadata,
|
|
11732
|
-
input
|
|
11733
|
-
});
|
|
11734
|
-
const { correctnessDocsOnlyDeltaAccepted, correctnessProof, correctnessRequired, correctnessStatus, correctnessUntyped, gateCapMismatchBlockers, reviewCycleState, reviewTerminalState, reviewLadder, securityDocsOnlyDeltaAccepted, securityProof, securityRequired, securityStatus, securityUntyped } = resolveReviewRequiredness({
|
|
11735
|
-
bodyMetadata,
|
|
11736
|
-
classification: input.classification,
|
|
11737
|
-
docsOnlySinceReviewProof: input.docsOnlySinceReviewProof,
|
|
11738
|
-
epoch,
|
|
11739
|
-
headSha: input.headSha,
|
|
11740
|
-
ladderPolicy: input.ladderPolicy,
|
|
11741
|
-
patchId: input.patchId,
|
|
11742
|
-
reviewModes,
|
|
11743
|
-
reviewProof: input.reviewProof
|
|
11744
|
-
});
|
|
11745
|
-
const { docsOnlyVerifiedHeadSha, docsOnlyVerifyDeltaAccepted, prVerifyStatus, trivialVerifiedHeadSha, trivialVerifyDeltaAccepted, verifiedHeadSha } = verifyGate;
|
|
11746
|
-
const requiredChecks = requiredChecksEvaluation(input);
|
|
11747
|
-
const blockers = [
|
|
11748
|
-
...collectBlockers({
|
|
11749
|
-
correctnessRequired,
|
|
11750
|
-
correctnessStatus,
|
|
11751
|
-
correctnessUntyped,
|
|
11752
|
-
input,
|
|
11753
|
-
prVerifyStatus,
|
|
11754
|
-
sectionReady: bodyMetadata.requiredSectionsPresent,
|
|
11755
|
-
securityRequired,
|
|
11756
|
-
securityStatus,
|
|
11757
|
-
securityUntyped,
|
|
11758
|
-
trivialWaiverPresent: bodyMetadata.trivialWaiverAccepted,
|
|
11759
|
-
verificationProof: verifyGate.verification.verificationProof
|
|
11760
|
-
}),
|
|
11761
|
-
...requiredChecks.blockers,
|
|
11762
|
-
...gateCapMismatchBlockers,
|
|
11763
|
-
...input.waveReviewDemand ? demandedRungSatisfactionReasons({
|
|
11764
|
-
demandedRung: input.waveReviewDemand.review,
|
|
11765
|
-
liveHumanReviews: input.reviews ?? [],
|
|
11766
|
-
reviewRuns: input.reviewProof?.reviews
|
|
11767
|
-
}).map((reason) => ({ reason })) : []
|
|
11768
|
-
];
|
|
11769
|
-
const blockingReasons = blockers.map((blocker) => blocker.reason);
|
|
11770
|
-
const humanBlockingReasons = blockers.flatMap((blocker) => blocker.repair ? [] : [blocker.reason]);
|
|
11771
|
-
const repairs = orderRepairs(blockers.flatMap((blocker) => blocker.repair ? [blocker.repair] : []));
|
|
11772
|
-
const finalReviewBlockingReasons = [...blockingReasons];
|
|
11773
|
-
if (!bodyMetadata.finalReviewPoint) {
|
|
11774
|
-
finalReviewBlockingReasons.push(SLICE_NOT_FINAL_BLOCKER.reason);
|
|
11775
|
-
humanBlockingReasons.push(SLICE_NOT_FINAL_BLOCKER.reason);
|
|
11776
|
-
}
|
|
11777
|
-
const status = statusFromBlockingReasons({
|
|
11778
|
-
blockingReasons,
|
|
11779
|
-
finalReviewBlockingReasons,
|
|
11780
|
-
finalReviewPoint: bodyMetadata.finalReviewPoint
|
|
11781
|
-
});
|
|
11782
|
-
const postReadiness = postReadinessHumanCommentState(input);
|
|
11783
|
-
const ledger = {
|
|
11784
|
-
baseSha: input.baseSha,
|
|
11785
|
-
blockingReasons: finalReviewBlockingReasons,
|
|
11786
|
-
classification: input.classification,
|
|
11787
|
-
...requiredChecks.outcomes.length > 0 ? { externalChecks: requiredChecks.outcomes.map((outcome) => ({
|
|
11788
|
-
checkType: outcome.checkType,
|
|
11789
|
-
inScope: outcome.inScope,
|
|
11790
|
-
name: outcome.name,
|
|
11791
|
-
...outcome.reason ? { reason: outcome.reason } : {},
|
|
11792
|
-
...outcome.scope ? { scope: outcome.scope } : {},
|
|
11793
|
-
scopeReason: outcome.scopeReason,
|
|
11794
|
-
status: outcome.status
|
|
11795
|
-
})) } : {},
|
|
11796
|
-
finalReviewPoint: bodyMetadata.finalReviewPoint,
|
|
11797
|
-
github: {
|
|
11798
|
-
currentWithBase: input.currentWithBase,
|
|
11799
|
-
draft: input.draft,
|
|
11800
|
-
mergeStateStatus: input.mergeStateStatus,
|
|
11801
|
-
mergeable: input.mergeable,
|
|
11802
|
-
requiredChecks: input.requiredChecks,
|
|
11803
|
-
unresolvedReviewThreads: input.unresolvedReviewThreads
|
|
11804
|
-
},
|
|
11805
|
-
headSha: input.headSha,
|
|
11806
|
-
...input.mergeBaseSha ? { mergeBaseSha: input.mergeBaseSha } : {},
|
|
11807
|
-
patchId: input.patchId,
|
|
11808
|
-
pr: input.pr,
|
|
11809
|
-
previewDeployRequired,
|
|
11810
|
-
...postReadiness.ledgerFields,
|
|
11811
|
-
reviewRuns: input.reviewProof?.reviews.map((review) => ({
|
|
11812
|
-
durationMs: review.durationMs,
|
|
11813
|
-
endedAt: review.endedAt,
|
|
11814
|
-
findings: review.findings,
|
|
11815
|
-
issuesFlagged: review.issuesFlagged,
|
|
11816
|
-
kind: review.kind,
|
|
11817
|
-
...recordedReviewIdentityFields(review),
|
|
11818
|
-
outcome: review.outcome,
|
|
11819
|
-
startedAt: review.startedAt,
|
|
11820
|
-
summary: review.summary
|
|
11821
|
-
})),
|
|
11822
|
-
...reviewCycleState ? { reviewCycleState } : {},
|
|
11823
|
-
...reviewTerminalState ? { reviewTerminalState } : {},
|
|
11824
|
-
...reviewLadder ? { reviewLadder } : {},
|
|
11825
|
-
repairs,
|
|
11826
|
-
reviews: {
|
|
11827
|
-
correctness: {
|
|
11828
|
-
...correctnessDocsOnlyDeltaAccepted ? { docsOnlyDeltaAccepted: true } : {},
|
|
11829
|
-
required: correctnessRequired,
|
|
11830
|
-
status: correctnessStatus,
|
|
11831
|
-
...correctnessProof
|
|
11832
|
-
},
|
|
11833
|
-
...reviewModes.includes("security") ? { security: {
|
|
11834
|
-
...securityDocsOnlyDeltaAccepted ? { docsOnlyDeltaAccepted: true } : {},
|
|
11835
|
-
required: securityRequired,
|
|
11836
|
-
status: securityStatus,
|
|
11837
|
-
...securityProof
|
|
11838
|
-
} } : {}
|
|
11839
|
-
},
|
|
11840
|
-
schemaVersion: 1,
|
|
11841
|
-
stackRole: bodyMetadata.stackRole,
|
|
11842
|
-
verification: verificationLedgerFields({
|
|
11843
|
-
docsOnlyVerifiedHeadSha,
|
|
11844
|
-
docsOnlyVerifyDeltaAccepted,
|
|
11845
|
-
prVerifyStatus,
|
|
11846
|
-
trivialVerifiedHeadSha,
|
|
11847
|
-
trivialVerifyDeltaAccepted,
|
|
11848
|
-
verifiedHeadSha
|
|
11849
|
-
})
|
|
11850
|
-
};
|
|
11851
|
-
const finalBlockingReasons = [...ledger.blockingReasons, ...postReadiness.blockers.map((blocker) => blocker.reason)];
|
|
11852
|
-
humanBlockingReasons.push(...postReadiness.blockers.map((blocker) => blocker.reason));
|
|
11853
|
-
ledger.blockingReasons = finalBlockingReasons;
|
|
11854
|
-
return {
|
|
11855
|
-
blockingReasons: finalBlockingReasons,
|
|
11856
|
-
humanBlockingReasons,
|
|
11857
|
-
ledger,
|
|
11858
|
-
repairs,
|
|
11859
|
-
status: postReadiness.status(status)
|
|
11860
|
-
};
|
|
11861
|
-
};
|
|
11862
|
-
//#endregion
|
|
11863
|
-
//#region src/admission-preflight.ts
|
|
11864
|
-
const errorMessage$1 = (error) => error instanceof Error ? error.message : String(error);
|
|
11865
|
-
const resolveCandidate = ({ base, cwd, verifyProof }) => {
|
|
11866
|
-
try {
|
|
11867
|
-
const files = changedFiles(cwd, base);
|
|
11868
|
-
const { headSha, mergeBaseSha: mergeBaseSha$1, patchId } = resolveCandidateIdentity({
|
|
11869
|
-
base,
|
|
11870
|
-
cwd,
|
|
11871
|
-
git: {
|
|
11872
|
-
currentHeadSha,
|
|
11873
|
-
mergeBaseSha,
|
|
11874
|
-
stablePatchId
|
|
11875
|
-
}
|
|
11876
|
-
});
|
|
11877
|
-
return { candidate: {
|
|
11878
|
-
classification: verifyProof && evidenceFresh({
|
|
11879
|
-
candidate: { patchId },
|
|
11880
|
-
recorded: { patchId: verifyProof.patchId },
|
|
11881
|
-
rule: "patch-id"
|
|
11882
|
-
}) ? verifyProof.classification : void 0,
|
|
11883
|
-
files,
|
|
11884
|
-
headSha,
|
|
11885
|
-
mergeBaseSha: mergeBaseSha$1,
|
|
11886
|
-
patchId
|
|
11887
|
-
} };
|
|
11888
|
-
} catch (error) {
|
|
11889
|
-
return { error: errorMessage$1(error) };
|
|
11890
|
-
}
|
|
11891
|
-
};
|
|
11892
|
-
/**
|
|
11893
|
-
* Authoring-session identity. Not modelled by readiness: `pr:verify` enforces
|
|
11894
|
-
* it unconditionally at the command layer (v0.13.2 on) and records it on the
|
|
11895
|
-
* proof, so it can only fail at verification runtime — which is exactly the
|
|
11896
|
-
* late discovery #292 exists to remove.
|
|
11897
|
-
*/
|
|
11898
|
-
const authoringSessionCheck = (env) => {
|
|
11899
|
-
const discovered = discoverFactorySessionId(env);
|
|
11900
|
-
return discovered ? {
|
|
11901
|
-
message: `Authoring session resolves from FACTORY_SESSION_ID (${discovered}); pr:verify records it on the proof.`,
|
|
11902
|
-
name: "admission:authoring-session",
|
|
11903
|
-
status: "ok"
|
|
11904
|
-
} : {
|
|
11905
|
-
message: "FACTORY_SESSION_ID is unset, so pr:verify requires --authoring-session <id>; without one it fails at verification runtime, and any review-type evidence stays unconfirmable (fail closed).",
|
|
11906
|
-
name: "admission:authoring-session",
|
|
11907
|
-
status: "warning"
|
|
11908
|
-
};
|
|
11909
|
-
};
|
|
11910
|
-
/**
|
|
11911
|
-
* Verification proof at the current head. The verdict comes from the single
|
|
11912
|
-
* shared verify-once applicability entry point (#692 / ADR 0016), and a refusal
|
|
11913
|
-
* is rendered with readiness's own blocking reason — never a second opinion.
|
|
11914
|
-
*/
|
|
11915
|
-
const verifyProofCheck = ({ candidate, candidateError, preread, verifyProofPath }) => {
|
|
11916
|
-
if (!candidate) return {
|
|
11917
|
-
message: `Verification proof state is unknown: no git candidate is resolvable here (${candidateError ?? "unknown error"}).`,
|
|
11918
|
-
name: "admission:verify-proof",
|
|
11919
|
-
status: "warning"
|
|
11920
|
-
};
|
|
11921
|
-
const { verificationProof } = resolveVerifyProofApplicability({
|
|
11922
|
-
currentVerifyIdentityForBase: () => ({ patchId: candidate.patchId }),
|
|
11923
|
-
explicitProof: false,
|
|
11924
|
-
files: candidate.files,
|
|
11925
|
-
headSha: candidate.headSha,
|
|
11926
|
-
preread,
|
|
11927
|
-
proofPath: verifyProofPath
|
|
11928
|
-
});
|
|
11929
|
-
if (verificationProof.kind === "typed") return {
|
|
11930
|
-
message: `Applicable ${verificationProof.proof.mode} pr:verify proof for head ${candidate.headSha} (verified at ${verificationProof.proof.headSha}); pr:publish will reuse it.`,
|
|
11931
|
-
name: "admission:verify-proof",
|
|
11932
|
-
status: "ok"
|
|
11933
|
-
};
|
|
11934
|
-
return {
|
|
11935
|
-
message: verificationProofBlockingReason(verificationProof) ?? "No applicable pr:verify proof covers this candidate.",
|
|
11936
|
-
name: "admission:verify-proof",
|
|
11937
|
-
status: "warning"
|
|
13264
|
+
status: (status) => activeComments.length > 0 || activeReviews.length > 0 ? "blocked" : status
|
|
11938
13265
|
};
|
|
11939
13266
|
};
|
|
11940
|
-
|
|
11941
|
-
|
|
11942
|
-
|
|
11943
|
-
|
|
11944
|
-
*/
|
|
11945
|
-
const reviewProofCheck = ({ candidate, candidateError, profile, proof, reviewModes }) => {
|
|
11946
|
-
const name = "admission:review-proof";
|
|
11947
|
-
if (!candidate) return {
|
|
11948
|
-
message: `Review proof state is unknown: no git candidate is resolvable here (${candidateError ?? "unknown error"}).`,
|
|
11949
|
-
name,
|
|
11950
|
-
status: "warning"
|
|
11951
|
-
};
|
|
11952
|
-
if (reviewModes.length === 0) return {
|
|
11953
|
-
message: "Independent review is not required: no profile review mode applies to the changed files.",
|
|
11954
|
-
name,
|
|
11955
|
-
status: "ok"
|
|
13267
|
+
const verificationProofForInput = ({ input }) => {
|
|
13268
|
+
if (input.verificationProof) return {
|
|
13269
|
+
fullVerifiedHeadShas: input.docsOnlyVerifyBaselineHeadShas,
|
|
13270
|
+
verificationProof: input.verificationProof
|
|
11956
13271
|
};
|
|
11957
|
-
const status = reviewStatus({
|
|
11958
|
-
headSha: candidate.headSha,
|
|
11959
|
-
patchId: candidate.patchId,
|
|
11960
|
-
required: true,
|
|
11961
|
-
reviewedHeadSha: proof?.headSha,
|
|
11962
|
-
reviewedPatchId: proof?.patchId
|
|
11963
|
-
});
|
|
11964
|
-
const terminal = proof ? resolveReviewTerminalStateAcrossReviewsForPolicy(proof, resolveReviewLadderPolicy(profile)) : void 0;
|
|
11965
|
-
const accepted = status === "current" && (terminal === "clean" || terminal === "accepted-with-findings");
|
|
11966
13272
|
return {
|
|
11967
|
-
|
|
11968
|
-
|
|
11969
|
-
|
|
13273
|
+
fullVerifiedHeadShas: [],
|
|
13274
|
+
verificationProof: {
|
|
13275
|
+
kind: "typed-missing",
|
|
13276
|
+
path: "",
|
|
13277
|
+
reason: "enoent"
|
|
13278
|
+
}
|
|
11970
13279
|
};
|
|
11971
13280
|
};
|
|
11972
|
-
|
|
11973
|
-
|
|
11974
|
-
|
|
11975
|
-
|
|
11976
|
-
|
|
11977
|
-
|
|
11978
|
-
|
|
11979
|
-
|
|
11980
|
-
|
|
11981
|
-
|
|
11982
|
-
|
|
11983
|
-
|
|
11984
|
-
|
|
11985
|
-
if (outcome.status === "satisfied") return `Satisfied by a ${outcome.checkType}-type evidence envelope${outcome.matchedPath ? ` (${outcome.matchedPath})` : ""}.`;
|
|
11986
|
-
return outcome.reason ?? `Required ${outcome.checkType} check has no passing, current evidence envelope.`;
|
|
13281
|
+
const verifyGateFor = ({ input }) => {
|
|
13282
|
+
const verification = verificationProofForInput({ input });
|
|
13283
|
+
const verificationProof = verificationProofForReadiness(verification.verificationProof);
|
|
13284
|
+
return {
|
|
13285
|
+
verification,
|
|
13286
|
+
...prVerifyEvaluation({
|
|
13287
|
+
acceptedBaselineHeadShas: verification.fullVerifiedHeadShas,
|
|
13288
|
+
currentHeadSha: input.headSha,
|
|
13289
|
+
docsOnlyProofCanStandAlone: input.classification === "docs/process-only",
|
|
13290
|
+
prVerifyProof: verificationProof,
|
|
13291
|
+
trivialProofCanStandAlone: input.classification === "trivial" || input.classification === "docs/process-only"
|
|
13292
|
+
})
|
|
13293
|
+
};
|
|
11987
13294
|
};
|
|
11988
|
-
|
|
11989
|
-
|
|
11990
|
-
|
|
11991
|
-
|
|
11992
|
-
|
|
11993
|
-
|
|
11994
|
-
|
|
11995
|
-
|
|
11996
|
-
|
|
11997
|
-
|
|
11998
|
-
name: "admission:required-checks",
|
|
11999
|
-
status: "ok"
|
|
12000
|
-
}];
|
|
12001
|
-
if (!candidate) return [{
|
|
12002
|
-
message: `${requiredChecks.length} profile requiredCheck(s) are declared but cannot be evaluated: no git candidate is resolvable here (${candidateError ?? "unknown error"}).`,
|
|
12003
|
-
name: "admission:required-checks",
|
|
12004
|
-
status: "warning"
|
|
12005
|
-
}];
|
|
12006
|
-
const { outcomes } = evaluateRequiredChecks({
|
|
12007
|
-
authoringSessionIds: resolveAuthoringSessionIds({ recorded: verifyProof?.authoringSession }),
|
|
12008
|
-
candidate: {
|
|
12009
|
-
headSha: candidate.headSha,
|
|
12010
|
-
mergeBaseSha: candidate.mergeBaseSha,
|
|
12011
|
-
patchId: candidate.patchId
|
|
12012
|
-
},
|
|
12013
|
-
envelopes: loadEvidenceEnvelopes(cwd),
|
|
12014
|
-
requiredChecks,
|
|
12015
|
-
scopeContext: {
|
|
12016
|
-
classification: candidate.classification,
|
|
12017
|
-
labels: void 0
|
|
12018
|
-
}
|
|
12019
|
-
});
|
|
12020
|
-
return outcomes.map((outcome) => ({
|
|
12021
|
-
message: requiredCheckMessage(outcome),
|
|
12022
|
-
name: `admission:required-check:${outcome.name}`,
|
|
12023
|
-
status: outcome.status === "unmet" ? "warning" : "ok"
|
|
13295
|
+
const waveRungBlockers = (input) => {
|
|
13296
|
+
const demanded = input.waveReviewDemand;
|
|
13297
|
+
if (!demanded) return [];
|
|
13298
|
+
return demandedRungSatisfactionReasons({
|
|
13299
|
+
demandedRung: demanded.review,
|
|
13300
|
+
liveHumanReviews: input.reviews ?? [],
|
|
13301
|
+
reviewRuns: input.reviewProof?.reviews
|
|
13302
|
+
}).map((reason) => ({
|
|
13303
|
+
demand: reviewRungDemand(demanded.review),
|
|
13304
|
+
reason
|
|
12024
13305
|
}));
|
|
12025
13306
|
};
|
|
12026
|
-
|
|
12027
|
-
|
|
12028
|
-
|
|
12029
|
-
|
|
12030
|
-
|
|
12031
|
-
|
|
12032
|
-
|
|
12033
|
-
|
|
12034
|
-
const resolvedVerifyProofPath = path.resolve(cwd, verifyProofPath);
|
|
12035
|
-
const preread = tryReadProof(readPrVerifyProof, resolvedVerifyProofPath);
|
|
12036
|
-
const resolved = resolveCandidate({
|
|
12037
|
-
base,
|
|
12038
|
-
cwd,
|
|
12039
|
-
verifyProof: preread.proof
|
|
13307
|
+
const previewDeployRequired = false;
|
|
13308
|
+
const evaluateReadiness = (input) => {
|
|
13309
|
+
const bodyMetadata = readPrBodyMetadata(input.body);
|
|
13310
|
+
const reviewModes = input.reviewModes ?? ["correctness"];
|
|
13311
|
+
const epoch = reviewEpochModeFor(input.reviewProof ?? {}, input.ladderPolicy);
|
|
13312
|
+
const verifyGate = verifyGateFor({
|
|
13313
|
+
bodyMetadata,
|
|
13314
|
+
input
|
|
12040
13315
|
});
|
|
12041
|
-
const
|
|
12042
|
-
|
|
12043
|
-
|
|
12044
|
-
|
|
12045
|
-
|
|
12046
|
-
|
|
12047
|
-
|
|
12048
|
-
|
|
12049
|
-
|
|
12050
|
-
|
|
12051
|
-
|
|
12052
|
-
|
|
12053
|
-
|
|
12054
|
-
|
|
12055
|
-
|
|
12056
|
-
|
|
12057
|
-
|
|
12058
|
-
|
|
12059
|
-
|
|
12060
|
-
|
|
12061
|
-
|
|
12062
|
-
|
|
12063
|
-
|
|
12064
|
-
|
|
12065
|
-
|
|
13316
|
+
const { correctnessDocsOnlyDeltaAccepted, correctnessProof, correctnessRequired, correctnessStatus, correctnessUntyped, gateCapMismatchBlockers, reviewCycleState, reviewTerminalState, reviewLadder, securityDocsOnlyDeltaAccepted, securityProof, securityRequired, securityStatus, securityUntyped } = resolveReviewRequiredness({
|
|
13317
|
+
bodyMetadata,
|
|
13318
|
+
classification: input.classification,
|
|
13319
|
+
docsOnlySinceReviewProof: input.docsOnlySinceReviewProof,
|
|
13320
|
+
epoch,
|
|
13321
|
+
headSha: input.headSha,
|
|
13322
|
+
ladderPolicy: input.ladderPolicy,
|
|
13323
|
+
patchId: input.patchId,
|
|
13324
|
+
reviewModes,
|
|
13325
|
+
reviewProof: input.reviewProof
|
|
13326
|
+
});
|
|
13327
|
+
const { docsOnlyVerifiedHeadSha, docsOnlyVerifyDeltaAccepted, prVerifyStatus, trivialVerifiedHeadSha, trivialVerifyDeltaAccepted, verifiedHeadSha } = verifyGate;
|
|
13328
|
+
const requiredChecks = requiredChecksEvaluation(input);
|
|
13329
|
+
const blockers = [
|
|
13330
|
+
...collectBlockers({
|
|
13331
|
+
correctnessRequired,
|
|
13332
|
+
correctnessStatus,
|
|
13333
|
+
correctnessUntyped,
|
|
13334
|
+
input,
|
|
13335
|
+
prVerifyStatus,
|
|
13336
|
+
sectionReady: bodyMetadata.requiredSectionsPresent,
|
|
13337
|
+
securityRequired,
|
|
13338
|
+
securityStatus,
|
|
13339
|
+
securityUntyped,
|
|
13340
|
+
trivialWaiverPresent: bodyMetadata.trivialWaiverAccepted,
|
|
13341
|
+
verificationProof: verifyGate.verification.verificationProof
|
|
12066
13342
|
}),
|
|
12067
|
-
|
|
12068
|
-
|
|
12069
|
-
|
|
12070
|
-
|
|
13343
|
+
...requiredChecks.blockers,
|
|
13344
|
+
...gateCapMismatchBlockers.map((blocker) => ({
|
|
13345
|
+
demand: DEMAND_KEYS.reviewLadder,
|
|
13346
|
+
reason: blocker.reason
|
|
13347
|
+
})),
|
|
13348
|
+
...waveRungBlockers(input)
|
|
12071
13349
|
];
|
|
12072
|
-
|
|
12073
|
-
|
|
12074
|
-
|
|
12075
|
-
|
|
12076
|
-
|
|
12077
|
-
|
|
12078
|
-
|
|
12079
|
-
const userConfig = resolveDoctorUserConfig(env, input.userConfig);
|
|
12080
|
-
const checks = [...buildProfileChecks(profile, cwd, {
|
|
12081
|
-
env,
|
|
12082
|
-
userConfig: userConfig.loaded?.config,
|
|
12083
|
-
userConfigCheck: userConfig.check
|
|
12084
|
-
}), ...input.preflight ? admissionPreflightChecks({
|
|
12085
|
-
base: input.base,
|
|
12086
|
-
cwd,
|
|
12087
|
-
env,
|
|
12088
|
-
...userConfig.loaded?.config.hqIngestCredentials === void 0 ? {} : { hqIngestCredentials: userConfig.loaded.config.hqIngestCredentials },
|
|
12089
|
-
profile,
|
|
12090
|
-
profilePath: path
|
|
12091
|
-
}) : []];
|
|
12092
|
-
return {
|
|
12093
|
-
checks,
|
|
12094
|
-
ok: checks.every((check) => check.status !== "error"),
|
|
12095
|
-
profilePath: path,
|
|
12096
|
-
projectKey: profile.project.key,
|
|
12097
|
-
repository: {
|
|
12098
|
-
defaultBranch: profile.repository.defaultBranch,
|
|
12099
|
-
name: profile.repository.name,
|
|
12100
|
-
owner: profile.repository.owner
|
|
12101
|
-
},
|
|
12102
|
-
schemaVersion: profile.schemaVersion
|
|
12103
|
-
};
|
|
12104
|
-
}
|
|
12105
|
-
function resolveDoctorUserConfig(env, provided) {
|
|
12106
|
-
const configPath = provided?.path ?? defaultUserConfigPath(env);
|
|
12107
|
-
if (provided === void 0 && !existsSync(configPath)) return { check: {
|
|
12108
|
-
message: `User config not found at ${configPath}; no optional operator credentials or HQ origins are configured.`,
|
|
12109
|
-
name: "user-config",
|
|
12110
|
-
status: "warning"
|
|
12111
|
-
} };
|
|
12112
|
-
try {
|
|
12113
|
-
const loaded = provided ?? loadUserConfig({
|
|
12114
|
-
configPath,
|
|
12115
|
-
env
|
|
12116
|
-
});
|
|
12117
|
-
const ignoredDefaultHarness = loaded.ignoredKeys?.includes("defaultHarness") ?? false;
|
|
12118
|
-
return {
|
|
12119
|
-
check: {
|
|
12120
|
-
message: ignoredDefaultHarness ? `defaultHarness is ignored; remove it from ${loaded.path}. The factory does not select an agent runtime.` : `Loaded schema version ${loaded.config.schemaVersion} user config from ${loaded.path}.`,
|
|
12121
|
-
name: "user-config",
|
|
12122
|
-
status: ignoredDefaultHarness ? "warning" : "ok"
|
|
12123
|
-
},
|
|
12124
|
-
loaded
|
|
12125
|
-
};
|
|
12126
|
-
} catch (error) {
|
|
12127
|
-
return { check: {
|
|
12128
|
-
message: error instanceof Error ? error.message : String(error),
|
|
12129
|
-
name: "user-config",
|
|
12130
|
-
status: "error"
|
|
12131
|
-
} };
|
|
13350
|
+
const blockingReasons = blockers.map((blocker) => blocker.reason);
|
|
13351
|
+
const humanBlockingReasons = blockers.flatMap((blocker) => blocker.repair ? [] : [blocker.reason]);
|
|
13352
|
+
const repairs = orderRepairs(blockers.flatMap((blocker) => blocker.repair ? [blocker.repair] : []));
|
|
13353
|
+
const finalReviewBlockers = [...blockers];
|
|
13354
|
+
if (!bodyMetadata.finalReviewPoint) {
|
|
13355
|
+
finalReviewBlockers.push(SLICE_NOT_FINAL_BLOCKER);
|
|
13356
|
+
humanBlockingReasons.push(SLICE_NOT_FINAL_BLOCKER.reason);
|
|
12132
13357
|
}
|
|
12133
|
-
|
|
12134
|
-
|
|
12135
|
-
|
|
12136
|
-
|
|
12137
|
-
|
|
12138
|
-
|
|
12139
|
-
|
|
12140
|
-
|
|
12141
|
-
|
|
12142
|
-
|
|
12143
|
-
|
|
12144
|
-
|
|
12145
|
-
|
|
12146
|
-
|
|
12147
|
-
|
|
12148
|
-
|
|
12149
|
-
|
|
13358
|
+
const finalReviewBlockingReasons = finalReviewBlockers.map((blocker) => blocker.reason);
|
|
13359
|
+
const status = statusFromBlockingReasons({
|
|
13360
|
+
blockingReasons,
|
|
13361
|
+
finalReviewBlockingReasons,
|
|
13362
|
+
finalReviewPoint: bodyMetadata.finalReviewPoint
|
|
13363
|
+
});
|
|
13364
|
+
const postReadiness = postReadinessHumanCommentState(input);
|
|
13365
|
+
const ledger = {
|
|
13366
|
+
baseSha: input.baseSha,
|
|
13367
|
+
blockingReasons: finalReviewBlockingReasons,
|
|
13368
|
+
classification: input.classification,
|
|
13369
|
+
...requiredChecks.outcomes.length > 0 ? { externalChecks: requiredChecks.outcomes.map((outcome) => ({
|
|
13370
|
+
checkType: outcome.checkType,
|
|
13371
|
+
inScope: outcome.inScope,
|
|
13372
|
+
name: outcome.name,
|
|
13373
|
+
...outcome.reason ? { reason: outcome.reason } : {},
|
|
13374
|
+
...outcome.scope ? { scope: outcome.scope } : {},
|
|
13375
|
+
scopeReason: outcome.scopeReason,
|
|
13376
|
+
status: outcome.status
|
|
13377
|
+
})) } : {},
|
|
13378
|
+
finalReviewPoint: bodyMetadata.finalReviewPoint,
|
|
13379
|
+
github: {
|
|
13380
|
+
currentWithBase: input.currentWithBase,
|
|
13381
|
+
draft: input.draft,
|
|
13382
|
+
mergeStateStatus: input.mergeStateStatus,
|
|
13383
|
+
mergeable: input.mergeable,
|
|
13384
|
+
requiredChecks: input.requiredChecks,
|
|
13385
|
+
unresolvedReviewThreads: input.unresolvedReviewThreads
|
|
12150
13386
|
},
|
|
12151
|
-
|
|
12152
|
-
{
|
|
12153
|
-
|
|
12154
|
-
|
|
12155
|
-
|
|
13387
|
+
headSha: input.headSha,
|
|
13388
|
+
...input.mergeBaseSha ? { mergeBaseSha: input.mergeBaseSha } : {},
|
|
13389
|
+
patchId: input.patchId,
|
|
13390
|
+
pr: input.pr,
|
|
13391
|
+
previewDeployRequired,
|
|
13392
|
+
...postReadiness.ledgerFields,
|
|
13393
|
+
reviewRuns: input.reviewProof?.reviews.map((review) => ({
|
|
13394
|
+
durationMs: review.durationMs,
|
|
13395
|
+
endedAt: review.endedAt,
|
|
13396
|
+
findings: review.findings,
|
|
13397
|
+
issuesFlagged: review.issuesFlagged,
|
|
13398
|
+
kind: review.kind,
|
|
13399
|
+
...recordedReviewIdentityFields(review),
|
|
13400
|
+
outcome: review.outcome,
|
|
13401
|
+
startedAt: review.startedAt,
|
|
13402
|
+
summary: review.summary
|
|
13403
|
+
})),
|
|
13404
|
+
...reviewCycleState ? { reviewCycleState } : {},
|
|
13405
|
+
...reviewTerminalState ? { reviewTerminalState } : {},
|
|
13406
|
+
...reviewLadder ? { reviewLadder } : {},
|
|
13407
|
+
repairs,
|
|
13408
|
+
reviews: {
|
|
13409
|
+
correctness: {
|
|
13410
|
+
...correctnessDocsOnlyDeltaAccepted ? { docsOnlyDeltaAccepted: true } : {},
|
|
13411
|
+
required: correctnessRequired,
|
|
13412
|
+
status: correctnessStatus,
|
|
13413
|
+
...correctnessProof
|
|
13414
|
+
},
|
|
13415
|
+
...reviewModes.includes("security") ? { security: {
|
|
13416
|
+
...securityDocsOnlyDeltaAccepted ? { docsOnlyDeltaAccepted: true } : {},
|
|
13417
|
+
required: securityRequired,
|
|
13418
|
+
status: securityStatus,
|
|
13419
|
+
...securityProof
|
|
13420
|
+
} } : {}
|
|
12156
13421
|
},
|
|
12157
|
-
|
|
12158
|
-
|
|
12159
|
-
|
|
12160
|
-
|
|
12161
|
-
|
|
12162
|
-
|
|
12163
|
-
|
|
12164
|
-
|
|
12165
|
-
|
|
12166
|
-
|
|
12167
|
-
message: `Required environment variables are unset: ${unset.join(", ")}.`,
|
|
12168
|
-
name: "environment",
|
|
12169
|
-
status: "error"
|
|
12170
|
-
};
|
|
12171
|
-
return {
|
|
12172
|
-
message: required.length === 0 ? "No required environment variables declared." : `${required.length} required environment variable name(s) are set.`,
|
|
12173
|
-
name: "environment",
|
|
12174
|
-
status: "ok"
|
|
12175
|
-
};
|
|
12176
|
-
}
|
|
12177
|
-
function githubAppCheck(userConfig) {
|
|
12178
|
-
const app = userConfig?.githubApp;
|
|
12179
|
-
if (!app) return {
|
|
12180
|
-
message: "Patronage Factory GitHub App credentials are not configured; gate publishing will use user-token commit statuses with Details links. Proof bindings cannot publish, so hosted required checks cannot be satisfied by local proof and must go green by their own means.",
|
|
12181
|
-
name: "github-app",
|
|
12182
|
-
status: "warning"
|
|
12183
|
-
};
|
|
12184
|
-
if (!existsSync(app.privateKeyPath)) return {
|
|
12185
|
-
message: `Patronage Factory GitHub App id ${app.appId} is configured, but private key ${app.privateKeyPath} was not found.`,
|
|
12186
|
-
name: "github-app",
|
|
12187
|
-
status: "warning"
|
|
12188
|
-
};
|
|
12189
|
-
return {
|
|
12190
|
-
message: `Patronage Factory GitHub App id ${app.appId} is configured${app.installationId ? ` for installation ${app.installationId}` : " with repository installation discovery"}.`,
|
|
12191
|
-
name: "github-app",
|
|
12192
|
-
status: "ok"
|
|
12193
|
-
};
|
|
12194
|
-
}
|
|
12195
|
-
function reviewLadderCheck(profile) {
|
|
12196
|
-
const policy = resolveReviewLadderPolicy(profile);
|
|
12197
|
-
return {
|
|
12198
|
-
message: `Review ladder ${profile.review.ladder ? "configured" : "defaulted"}: gate cap ${policy.gate.cap}.`,
|
|
12199
|
-
name: "review-ladder",
|
|
12200
|
-
status: "ok"
|
|
12201
|
-
};
|
|
12202
|
-
}
|
|
12203
|
-
function gitRemoteCheck(profile, cwd) {
|
|
12204
|
-
const origin = readGitOrigin(cwd);
|
|
12205
|
-
if (!origin) return {
|
|
12206
|
-
message: "No git origin remote was available for this working directory.",
|
|
12207
|
-
name: "git-origin",
|
|
12208
|
-
status: "warning"
|
|
12209
|
-
};
|
|
12210
|
-
if (repositoryUrlMatches(origin, profile)) return {
|
|
12211
|
-
message: `Git origin matches ${profile.repository.owner}/${profile.repository.name}.`,
|
|
12212
|
-
name: "git-origin",
|
|
12213
|
-
status: "ok"
|
|
13422
|
+
schemaVersion: 1,
|
|
13423
|
+
stackRole: bodyMetadata.stackRole,
|
|
13424
|
+
verification: verificationLedgerFields({
|
|
13425
|
+
docsOnlyVerifiedHeadSha,
|
|
13426
|
+
docsOnlyVerifyDeltaAccepted,
|
|
13427
|
+
prVerifyStatus,
|
|
13428
|
+
trivialVerifiedHeadSha,
|
|
13429
|
+
trivialVerifyDeltaAccepted,
|
|
13430
|
+
verifiedHeadSha
|
|
13431
|
+
})
|
|
12214
13432
|
};
|
|
13433
|
+
const finalBlockers = [...finalReviewBlockers, ...postReadiness.blockers];
|
|
13434
|
+
const finalBlockingReasons = finalBlockers.map((blocker) => blocker.reason);
|
|
13435
|
+
humanBlockingReasons.push(...postReadiness.blockers.map((blocker) => blocker.reason));
|
|
13436
|
+
ledger.blockingReasons = finalBlockingReasons;
|
|
12215
13437
|
return {
|
|
12216
|
-
|
|
12217
|
-
|
|
12218
|
-
|
|
13438
|
+
blockedReasons: finalBlockers.map((blocker) => ({
|
|
13439
|
+
code: blocker.demand,
|
|
13440
|
+
detail: blockedReasonDetail(blocker.reason)
|
|
13441
|
+
})),
|
|
13442
|
+
blockingReasons: finalBlockingReasons,
|
|
13443
|
+
humanBlockingReasons,
|
|
13444
|
+
ledger,
|
|
13445
|
+
repairs,
|
|
13446
|
+
status: postReadiness.status(status)
|
|
12219
13447
|
};
|
|
12220
|
-
}
|
|
12221
|
-
|
|
13448
|
+
};
|
|
13449
|
+
//#endregion
|
|
13450
|
+
//#region src/admission-preflight.ts
|
|
13451
|
+
const errorMessage$1 = (error) => error instanceof Error ? error.message : String(error);
|
|
13452
|
+
const resolveCandidate = ({ base, cwd, verifyProof }) => {
|
|
12222
13453
|
try {
|
|
12223
|
-
|
|
12224
|
-
|
|
12225
|
-
|
|
12226
|
-
"origin"
|
|
12227
|
-
], {
|
|
13454
|
+
const files = changedFiles(cwd, base);
|
|
13455
|
+
const { headSha, mergeBaseSha: mergeBaseSha$1, patchId } = resolveCandidateIdentity({
|
|
13456
|
+
base,
|
|
12228
13457
|
cwd,
|
|
12229
|
-
|
|
12230
|
-
|
|
12231
|
-
|
|
12232
|
-
|
|
12233
|
-
|
|
12234
|
-
]
|
|
12235
|
-
}).trim();
|
|
12236
|
-
} catch {
|
|
12237
|
-
return null;
|
|
12238
|
-
}
|
|
12239
|
-
}
|
|
12240
|
-
function repositoryUrlMatches(origin, profile) {
|
|
12241
|
-
const normalized = origin.replace(/\.git$/u, "");
|
|
12242
|
-
const repoPath = `${profile.repository.owner}/${profile.repository.name}`;
|
|
12243
|
-
return normalized.endsWith(`github.com:${repoPath}`) || normalized.endsWith(`github.com/${repoPath}`) || normalized.endsWith(repoPath);
|
|
12244
|
-
}
|
|
12245
|
-
//#endregion
|
|
12246
|
-
//#region src/commands/doctor.ts
|
|
12247
|
-
function createDoctorCommand(output) {
|
|
12248
|
-
return new Command("doctor").description("Validate a project profile without mutating GitHub or local files").option("--base <ref>", "base branch or ref for --preflight", "origin/main").option("--cwd <path>", "working directory to validate", ".").option("--json", "print the doctor report as JSON").option("--preflight", "also list every admission requirement for the current candidate (read-only; never blocks)").option("--profile <path>", "path to the project profile JSON file").action((options) => {
|
|
12249
|
-
const report = doctorProjectProfile({
|
|
12250
|
-
base: options.base,
|
|
12251
|
-
cwd: resolveCwdOption(options.cwd),
|
|
12252
|
-
preflight: options.preflight,
|
|
12253
|
-
profilePath: options.profile
|
|
13458
|
+
git: {
|
|
13459
|
+
currentHeadSha,
|
|
13460
|
+
mergeBaseSha,
|
|
13461
|
+
stablePatchId
|
|
13462
|
+
}
|
|
12254
13463
|
});
|
|
12255
|
-
|
|
12256
|
-
|
|
12257
|
-
|
|
12258
|
-
|
|
12259
|
-
|
|
12260
|
-
|
|
12261
|
-
|
|
12262
|
-
|
|
12263
|
-
|
|
12264
|
-
|
|
12265
|
-
|
|
12266
|
-
|
|
12267
|
-
|
|
12268
|
-
|
|
12269
|
-
}
|
|
12270
|
-
|
|
12271
|
-
|
|
12272
|
-
|
|
12273
|
-
*
|
|
12274
|
-
*
|
|
12275
|
-
* `psf epic:publish-structure` reads a planner's ephemeral `dag.yml` (never
|
|
12276
|
-
* committed — see epic #132 / ADR 0018), validates the graph fail-closed, and
|
|
12277
|
-
* POSTs an `epic-structure` v1 event to HQ's `/api/ingest` boundary. This
|
|
12278
|
-
* module is the pure core: parse-to-IR validation, the wire mapping, and the
|
|
12279
|
-
* deterministic content-addressed eventId. All I/O (stdin/file read, fetch)
|
|
12280
|
-
* lives in the command wrapper.
|
|
12281
|
-
*
|
|
12282
|
-
* WIRE PARITY (do not fork the schema): the emitted payload is the exact shape
|
|
12283
|
-
* HQ's `EpicGraphSchema` accepts (`software-factory-hq/src/contracts/
|
|
12284
|
-
* epic-schemas.ts`) and is built by hand the same way `seed-dev.ts` builds it
|
|
12285
|
-
* — no re-declared zod twin. HQ's `epic-structure-parity.test.ts` imports
|
|
12286
|
-
* {@link buildEpicStructureEvent} and runs its output through the real
|
|
12287
|
-
* `EpicGraphSchema`, so any drift (field, enum, bound, strictness) fails CI.
|
|
12288
|
-
*
|
|
12289
|
-
* The `dag.yml` schema itself is documented with the epic skill (lane #137);
|
|
12290
|
-
* this module is the executable contract, not a second source of truth.
|
|
12291
|
-
*/
|
|
12292
|
-
const EPIC_STRUCTURE_SCHEMA_VERSION = 1;
|
|
12293
|
-
/**
|
|
12294
|
-
* DAG-node PLANNING statuses — the planning-plane vocabulary a planner authors
|
|
12295
|
-
* on a `dag.yml` node. Carried forward by ADR 0018 §4 and defined by the epic
|
|
12296
|
-
* skill (`reference/epic-artifacts.md`) and both `CONTEXT.md` glossaries.
|
|
12297
|
-
*
|
|
12298
|
-
* A DAG node is a unit of *plan*; an HQ lane is a unit of *runtime execution*.
|
|
12299
|
-
* These are two planes with two vocabularies and must not be conflated — the
|
|
12300
|
-
* producer validates and emits ONLY planning statuses:
|
|
12301
|
-
*
|
|
12302
|
-
* - `open` authored, not yet done
|
|
12303
|
-
* - `closed` done (a PR merged, or otherwise resolved)
|
|
12304
|
-
* - `satisfied-on-main` already true on main without a dedicated PR
|
|
12305
|
-
* - `parked` real node, scope still moving — relabeled off
|
|
12306
|
-
* `ready-for-agent`, never closed
|
|
12307
|
-
*
|
|
12308
|
-
* HQ's `DagNodeSchema` (`software-factory-hq/src/contracts/epic-schemas.ts`)
|
|
12309
|
-
* accepts this planning vocabulary on ingest AND overlays a runtime LANE status
|
|
12310
|
-
* onto a node once a lane is linked (`syncNodesWithLanes` /
|
|
12311
|
-
* `projectEpicMembership`) — the overlay is HQ-internal; the producer never
|
|
12312
|
-
* emits a lane status. HQ's `epic-structure-parity.test.ts` fails CI if the
|
|
12313
|
-
* producer emits any status HQ won't accept.
|
|
13464
|
+
return { candidate: {
|
|
13465
|
+
classification: verifyProof && evidenceFresh({
|
|
13466
|
+
candidate: { patchId },
|
|
13467
|
+
recorded: { patchId: verifyProof.patchId },
|
|
13468
|
+
rule: "patch-id"
|
|
13469
|
+
}) ? verifyProof.classification : void 0,
|
|
13470
|
+
files,
|
|
13471
|
+
headSha,
|
|
13472
|
+
mergeBaseSha: mergeBaseSha$1,
|
|
13473
|
+
patchId
|
|
13474
|
+
} };
|
|
13475
|
+
} catch (error) {
|
|
13476
|
+
return { error: errorMessage$1(error) };
|
|
13477
|
+
}
|
|
13478
|
+
};
|
|
13479
|
+
/**
|
|
13480
|
+
* Authoring-session identity. Not modelled by readiness: `pr:verify` enforces
|
|
13481
|
+
* it unconditionally at the command layer (v0.13.2 on) and records it on the
|
|
13482
|
+
* proof, so it can only fail at verification runtime — which is exactly the
|
|
13483
|
+
* late discovery #292 exists to remove.
|
|
12314
13484
|
*/
|
|
12315
|
-
const
|
|
12316
|
-
|
|
12317
|
-
|
|
12318
|
-
|
|
12319
|
-
|
|
12320
|
-
|
|
13485
|
+
const authoringSessionCheck = (env) => {
|
|
13486
|
+
const discovered = discoverFactorySessionId(env);
|
|
13487
|
+
return discovered ? {
|
|
13488
|
+
message: `Authoring session resolves from FACTORY_SESSION_ID (${discovered}); pr:verify records it on the proof.`,
|
|
13489
|
+
name: "admission:authoring-session",
|
|
13490
|
+
status: "ok"
|
|
13491
|
+
} : {
|
|
13492
|
+
message: "FACTORY_SESSION_ID is unset, so pr:verify requires --authoring-session <id>; without one it fails at verification runtime, and any review-type evidence stays unconfirmable (fail closed).",
|
|
13493
|
+
name: "admission:authoring-session",
|
|
13494
|
+
status: "warning"
|
|
13495
|
+
};
|
|
13496
|
+
};
|
|
12321
13497
|
/**
|
|
12322
|
-
*
|
|
13498
|
+
* Verification proof at the current head. The verdict comes from the single
|
|
13499
|
+
* shared verify-once applicability entry point (#692 / ADR 0016), and a refusal
|
|
13500
|
+
* is rendered with readiness's own blocking reason — never a second opinion.
|
|
12323
13501
|
*/
|
|
12324
|
-
|
|
12325
|
-
|
|
12326
|
-
|
|
12327
|
-
|
|
12328
|
-
|
|
12329
|
-
|
|
12330
|
-
}
|
|
13502
|
+
const verifyProofCheck = ({ candidate, candidateError, preread, verifyProofPath }) => {
|
|
13503
|
+
if (!candidate) return {
|
|
13504
|
+
message: `Verification proof state is unknown: no git candidate is resolvable here (${candidateError ?? "unknown error"}).`,
|
|
13505
|
+
name: "admission:verify-proof",
|
|
13506
|
+
status: "warning"
|
|
13507
|
+
};
|
|
13508
|
+
const { verificationProof } = resolveVerifyProofApplicability({
|
|
13509
|
+
currentVerifyIdentityForBase: () => ({ patchId: candidate.patchId }),
|
|
13510
|
+
explicitProof: false,
|
|
13511
|
+
files: candidate.files,
|
|
13512
|
+
headSha: candidate.headSha,
|
|
13513
|
+
preread,
|
|
13514
|
+
proofPath: verifyProofPath
|
|
13515
|
+
});
|
|
13516
|
+
if (verificationProof.kind === "typed") return {
|
|
13517
|
+
message: `Applicable ${verificationProof.proof.mode} pr:verify proof for head ${candidate.headSha} (verified at ${verificationProof.proof.headSha}); pr:publish will reuse it.`,
|
|
13518
|
+
name: "admission:verify-proof",
|
|
13519
|
+
status: "ok"
|
|
13520
|
+
};
|
|
13521
|
+
return {
|
|
13522
|
+
message: verificationProofBlockingReason(verificationProof) ?? "No applicable pr:verify proof covers this candidate.",
|
|
13523
|
+
name: "admission:verify-proof",
|
|
13524
|
+
status: "warning"
|
|
13525
|
+
};
|
|
12331
13526
|
};
|
|
12332
|
-
|
|
12333
|
-
|
|
12334
|
-
|
|
12335
|
-
|
|
12336
|
-
|
|
12337
|
-
const
|
|
12338
|
-
const
|
|
12339
|
-
|
|
12340
|
-
|
|
12341
|
-
|
|
12342
|
-
|
|
12343
|
-
|
|
12344
|
-
|
|
12345
|
-
|
|
12346
|
-
|
|
12347
|
-
|
|
12348
|
-
|
|
12349
|
-
|
|
12350
|
-
|
|
12351
|
-
|
|
12352
|
-
|
|
12353
|
-
|
|
12354
|
-
|
|
12355
|
-
|
|
12356
|
-
|
|
12357
|
-
|
|
12358
|
-
external,
|
|
12359
|
-
...isPositiveInteger(raw.issue) ? { issue: raw.issue } : {},
|
|
12360
|
-
...nonEmptyString(raw.laneId) ? { laneId: raw.laneId } : {},
|
|
12361
|
-
slug: nonEmptyString(raw.slug) ? raw.slug : `node[${index}]`,
|
|
12362
|
-
status,
|
|
12363
|
-
title: nonEmptyString(raw.title) ? raw.title : ""
|
|
12364
|
-
});
|
|
12365
|
-
}
|
|
13527
|
+
/**
|
|
13528
|
+
* Independent review proof. Requiredness comes from the profile-resolved review
|
|
13529
|
+
* modes; currency and acceptance come from the same identity and
|
|
13530
|
+
* terminal-state helpers `pr:publish` uses.
|
|
13531
|
+
*/
|
|
13532
|
+
const reviewProofCheck = ({ candidate, candidateError, profile, proof, reviewModes }) => {
|
|
13533
|
+
const name = "admission:review-proof";
|
|
13534
|
+
if (!candidate) return {
|
|
13535
|
+
message: `Review proof state is unknown: no git candidate is resolvable here (${candidateError ?? "unknown error"}).`,
|
|
13536
|
+
name,
|
|
13537
|
+
status: "warning"
|
|
13538
|
+
};
|
|
13539
|
+
if (reviewModes.length === 0) return {
|
|
13540
|
+
message: "Independent review is not required: no profile review mode applies to the changed files.",
|
|
13541
|
+
name,
|
|
13542
|
+
status: "ok"
|
|
13543
|
+
};
|
|
13544
|
+
const status = reviewStatus({
|
|
13545
|
+
headSha: candidate.headSha,
|
|
13546
|
+
patchId: candidate.patchId,
|
|
13547
|
+
required: true,
|
|
13548
|
+
reviewedHeadSha: proof?.headSha,
|
|
13549
|
+
reviewedPatchId: proof?.patchId
|
|
13550
|
+
});
|
|
13551
|
+
const terminal = proof ? resolveReviewTerminalStateAcrossReviewsForPolicy(proof, resolveReviewLadderPolicy(profile)) : void 0;
|
|
13552
|
+
const accepted = status === "current" && (terminal === "clean" || terminal === "accepted-with-findings");
|
|
12366
13553
|
return {
|
|
12367
|
-
|
|
12368
|
-
|
|
13554
|
+
message: accepted ? `pr:review proof is current for this candidate (${terminal}) across ${reviewModes.join(", ")}.` : `pr:review proof is ${status}${terminal ? ` (${terminal})` : ""}; ${reviewModes.join(", ")} review(s) are required. Have an independent clean session write findings and pass them to pr:publish --findings <path>.`,
|
|
13555
|
+
name,
|
|
13556
|
+
status: accepted ? "ok" : "warning"
|
|
12369
13557
|
};
|
|
12370
13558
|
};
|
|
12371
|
-
|
|
12372
|
-
|
|
12373
|
-
|
|
12374
|
-
|
|
12375
|
-
|
|
12376
|
-
|
|
12377
|
-
|
|
12378
|
-
|
|
12379
|
-
|
|
12380
|
-
|
|
12381
|
-
|
|
12382
|
-
|
|
12383
|
-
|
|
12384
|
-
|
|
12385
|
-
|
|
12386
|
-
edges.push({
|
|
12387
|
-
from: raw.from,
|
|
12388
|
-
to: raw.to
|
|
12389
|
-
});
|
|
12390
|
-
}
|
|
12391
|
-
return edges;
|
|
13559
|
+
/**
|
|
13560
|
+
* PR-body sections. Readiness owns the requirement and its wording; publish
|
|
13561
|
+
* owns satisfying it by patching the managed sections from the two proofs, so
|
|
13562
|
+
* this is named rather than evaluated — the body is a live GitHub fact a
|
|
13563
|
+
* read-only local pass cannot see.
|
|
13564
|
+
*/
|
|
13565
|
+
const prBodySectionsCheck = () => ({
|
|
13566
|
+
message: `${RENDER_PR_BODY_SECTIONS_BLOCKER_REASON} pr:publish patches those managed sections from the pr:verify and pr:review proofs.`,
|
|
13567
|
+
name: "admission:pr-body-sections",
|
|
13568
|
+
status: "ok"
|
|
13569
|
+
});
|
|
13570
|
+
const requiredCheckMessage = (outcome) => {
|
|
13571
|
+
if (outcome.status === "out-of-scope") return `Not demanded for this candidate: ${outcome.scopeReason}.`;
|
|
13572
|
+
if (outcome.status === "satisfied") return `Satisfied by a ${outcome.checkType}-type evidence envelope${outcome.matchedPath ? ` (${outcome.matchedPath})` : ""}.`;
|
|
13573
|
+
return outcome.reason ?? `Required ${outcome.checkType} check has no passing, current evidence envelope.`;
|
|
12392
13574
|
};
|
|
12393
13575
|
/**
|
|
12394
|
-
*
|
|
12395
|
-
*
|
|
12396
|
-
*
|
|
12397
|
-
*
|
|
13576
|
+
* Profile-declared external required checks — the evidence-envelope demands,
|
|
13577
|
+
* including a preview-deploy proof when the profile declares one. Evaluated by
|
|
13578
|
+
* readiness's own `evaluateRequiredChecks`, so scoping, freshness and
|
|
13579
|
+
* independence stay exactly one implementation.
|
|
12398
13580
|
*/
|
|
12399
|
-
const
|
|
12400
|
-
const
|
|
12401
|
-
|
|
12402
|
-
|
|
12403
|
-
|
|
12404
|
-
|
|
12405
|
-
}
|
|
12406
|
-
|
|
12407
|
-
|
|
12408
|
-
|
|
12409
|
-
|
|
12410
|
-
|
|
12411
|
-
const
|
|
12412
|
-
|
|
12413
|
-
|
|
12414
|
-
|
|
12415
|
-
|
|
12416
|
-
|
|
12417
|
-
|
|
12418
|
-
|
|
12419
|
-
|
|
12420
|
-
|
|
12421
|
-
|
|
12422
|
-
|
|
12423
|
-
}
|
|
13581
|
+
const requiredCheckChecks = ({ candidate, candidateError, cwd, profile, verifyProof }) => {
|
|
13582
|
+
const requiredChecks = profile.requiredChecks ?? [];
|
|
13583
|
+
if (requiredChecks.length === 0) return [{
|
|
13584
|
+
message: "Profile declares no requiredChecks; no external evidence envelope (including a preview-deploy proof) is demanded.",
|
|
13585
|
+
name: "admission:required-checks",
|
|
13586
|
+
status: "ok"
|
|
13587
|
+
}];
|
|
13588
|
+
if (!candidate) return [{
|
|
13589
|
+
message: `${requiredChecks.length} profile requiredCheck(s) are declared but cannot be evaluated: no git candidate is resolvable here (${candidateError ?? "unknown error"}).`,
|
|
13590
|
+
name: "admission:required-checks",
|
|
13591
|
+
status: "warning"
|
|
13592
|
+
}];
|
|
13593
|
+
const { outcomes } = evaluateRequiredChecks({
|
|
13594
|
+
authoringSessionIds: resolveAuthoringSessionIds({ recorded: verifyProof?.authoringSession }),
|
|
13595
|
+
candidate: {
|
|
13596
|
+
headSha: candidate.headSha,
|
|
13597
|
+
mergeBaseSha: candidate.mergeBaseSha,
|
|
13598
|
+
patchId: candidate.patchId
|
|
13599
|
+
},
|
|
13600
|
+
envelopes: loadEvidenceEnvelopes(cwd),
|
|
13601
|
+
requiredChecks,
|
|
13602
|
+
scopeContext: {
|
|
13603
|
+
classification: candidate.classification,
|
|
13604
|
+
labels: void 0
|
|
12424
13605
|
}
|
|
12425
|
-
|
|
12426
|
-
|
|
12427
|
-
|
|
12428
|
-
|
|
12429
|
-
|
|
12430
|
-
|
|
12431
|
-
|
|
13606
|
+
});
|
|
13607
|
+
return outcomes.map((outcome) => ({
|
|
13608
|
+
message: requiredCheckMessage(outcome),
|
|
13609
|
+
name: `admission:required-check:${outcome.name}`,
|
|
13610
|
+
status: outcome.status === "unmet" ? "warning" : "ok"
|
|
13611
|
+
}));
|
|
13612
|
+
};
|
|
13613
|
+
/**
|
|
13614
|
+
* The full admission checklist for the current repository and candidate, in the
|
|
13615
|
+
* order the requirements bind: identity, verification proof, review proof,
|
|
13616
|
+
* PR-body sections, external evidence demands. The factory GitHub App
|
|
13617
|
+
* requirement is reported by the doctor `github-app` check, which states the
|
|
13618
|
+
* consequence of its absence.
|
|
13619
|
+
*/
|
|
13620
|
+
const admissionPreflightChecks = ({ base = "origin/main", cwd, env = process.env, hqIngestCredentials, profile, reviewProofPath = DEFAULT_PR_REVIEW_PROOF_PATH, verifyProofPath = DEFAULT_PR_VERIFY_PROOF_PATH }) => {
|
|
13621
|
+
const resolvedVerifyProofPath = path.resolve(cwd, verifyProofPath);
|
|
13622
|
+
const preread = tryReadProof(readPrVerifyProof, resolvedVerifyProofPath);
|
|
13623
|
+
const resolved = resolveCandidate({
|
|
13624
|
+
base,
|
|
13625
|
+
cwd,
|
|
13626
|
+
verifyProof: preread.proof
|
|
13627
|
+
});
|
|
13628
|
+
const candidate = "candidate" in resolved ? resolved.candidate : void 0;
|
|
13629
|
+
const candidateError = "error" in resolved ? resolved.error : void 0;
|
|
13630
|
+
const reviewProof = readOptionalProofFor(PR_REVIEW_PROOF_DESCRIPTOR, cwd, reviewProofPath);
|
|
13631
|
+
return [
|
|
13632
|
+
authoringSessionCheck(env),
|
|
13633
|
+
verifyProofCheck({
|
|
13634
|
+
candidate,
|
|
13635
|
+
candidateError,
|
|
13636
|
+
preread,
|
|
13637
|
+
verifyProofPath: resolvedVerifyProofPath
|
|
13638
|
+
}),
|
|
13639
|
+
reviewProofCheck({
|
|
13640
|
+
candidate,
|
|
13641
|
+
candidateError,
|
|
13642
|
+
profile,
|
|
13643
|
+
proof: reviewProof,
|
|
13644
|
+
reviewModes: candidate ? resolveApplicableReviewModes(profile, candidate.files) : profile.review.modes
|
|
13645
|
+
}),
|
|
13646
|
+
prBodySectionsCheck(),
|
|
13647
|
+
...requiredCheckChecks({
|
|
13648
|
+
candidate,
|
|
13649
|
+
candidateError,
|
|
13650
|
+
cwd,
|
|
13651
|
+
profile,
|
|
13652
|
+
verifyProof: preread.proof
|
|
13653
|
+
}),
|
|
13654
|
+
hqIngestCredentialCheck({
|
|
13655
|
+
env,
|
|
13656
|
+
...hqIngestCredentials === void 0 ? {} : { references: hqIngestCredentials }
|
|
13657
|
+
})
|
|
13658
|
+
];
|
|
12432
13659
|
};
|
|
12433
|
-
|
|
12434
|
-
|
|
12435
|
-
|
|
13660
|
+
//#endregion
|
|
13661
|
+
//#region src/doctor-hq-checks.ts
|
|
13662
|
+
const HQ_SPOOL_CHECK_NAME = "hq-spool";
|
|
13663
|
+
const HQ_RETRO_READBACK_CHECK_NAME = "hq-retro-readback";
|
|
13664
|
+
const TRUSTED_LOCAL_NOTE = "This resolves HQ Access credentials the same way `psf hq:flush` and `psf pr:publish` do; both require a trusted local session.";
|
|
13665
|
+
const FLUSH_REMEDY = "Run `psf hq:flush` to drain them.";
|
|
13666
|
+
/** One sentence per orphaned location, with the `--dir` recovery it needs. */
|
|
13667
|
+
const describeOrphans = (orphans) => {
|
|
13668
|
+
const described = orphans.map((orphan) => `${orphan.directory} (${orphan.unlistable > 0 && orphan.pending === 0 ? "could not be inspected" : `${orphan.pending} event(s)`})`).join(", ");
|
|
13669
|
+
return ` ${orphans.length} spool location(s) hold this repository's evidence under an earlier key it no longer resolves to — a key-format change strands evidence there where no drain looks: ${described}. Drain each with \`psf hq:flush --dir <path>\`.`;
|
|
12436
13670
|
};
|
|
12437
13671
|
/**
|
|
12438
|
-
*
|
|
12439
|
-
*
|
|
13672
|
+
* Red when HQ evidence is waiting for this repository: its own spool or legacy
|
|
13673
|
+
* journal, or a sibling location written under an earlier key of its own that
|
|
13674
|
+
* it no longer resolves to (#420). Another repository's spool under the shared
|
|
13675
|
+
* root is that repository's business, never this check's (#446).
|
|
13676
|
+
*
|
|
13677
|
+
* Reuses `countHqSpoolWork` (#414) — the same read-only, credential-free
|
|
13678
|
+
* inspection `hq:flush` itself consults before ever resolving a secret — so
|
|
13679
|
+
* doctor and the flush command can never disagree about what "empty" means.
|
|
13680
|
+
*
|
|
13681
|
+
* The orphan sweep is the half that makes the check honest. Both readers of
|
|
13682
|
+
* the spool resolve exactly one key, so evidence written under an older one is
|
|
13683
|
+
* invisible to a drain and, before this, to doctor: the failure epic #389 was
|
|
13684
|
+
* chartered to end is evidence that is neither delivered nor visibly stranded.
|
|
13685
|
+
* An orphan holding pending events is red for the same reason the repo-keyed
|
|
13686
|
+
* spool is.
|
|
12440
13687
|
*/
|
|
12441
|
-
|
|
12442
|
-
const
|
|
12443
|
-
const
|
|
12444
|
-
|
|
12445
|
-
|
|
12446
|
-
|
|
12447
|
-
|
|
12448
|
-
|
|
12449
|
-
|
|
12450
|
-
|
|
12451
|
-
|
|
12452
|
-
|
|
12453
|
-
|
|
12454
|
-
|
|
12455
|
-
|
|
13688
|
+
async function hqSpoolDoctorCheck(input, dependencies = {}) {
|
|
13689
|
+
const countSpool = dependencies.countSpool ?? countHqSpoolWork;
|
|
13690
|
+
const sweepOrphans = dependencies.sweepOrphans ?? sweepHqSpoolOrphans;
|
|
13691
|
+
const [counted, swept] = await Promise.all([countSpool({
|
|
13692
|
+
cwd: input.cwd,
|
|
13693
|
+
repository: input.repository
|
|
13694
|
+
}, { env: input.env }), sweepOrphans({ repository: input.repository }, { env: input.env })]);
|
|
13695
|
+
const orphanNote = swept.orphans.length === 0 ? "" : describeOrphans(swept.orphans);
|
|
13696
|
+
if (counted.pending === 0 && counted.unlistable === 0 && swept.orphans.length === 0) return {
|
|
13697
|
+
message: "HQ spool and journal are empty; no evidence is waiting to be drained.",
|
|
13698
|
+
name: HQ_SPOOL_CHECK_NAME,
|
|
13699
|
+
status: "ok"
|
|
13700
|
+
};
|
|
13701
|
+
const locationsNote = counted.spools.length > 0 ? ` Locations: ${counted.spools.join(", ")}.` : "";
|
|
13702
|
+
const oldestNote = counted.oldestQueuedAt === void 0 ? "" : ` Oldest entry queued ${counted.oldestQueuedAt}.`;
|
|
13703
|
+
if (counted.pending > 0) return {
|
|
13704
|
+
message: `${counted.pending} HQ event(s) are spooled locally.${oldestNote}${locationsNote} ${FLUSH_REMEDY}${orphanNote}`,
|
|
13705
|
+
name: HQ_SPOOL_CHECK_NAME,
|
|
13706
|
+
status: "error"
|
|
13707
|
+
};
|
|
13708
|
+
if (counted.unlistable > 0) return {
|
|
13709
|
+
message: `${counted.unlistable} spool location(s) exist but could not be listed within budget, so this cannot be reported as empty.${locationsNote} ${FLUSH_REMEDY}${orphanNote}`,
|
|
13710
|
+
name: HQ_SPOOL_CHECK_NAME,
|
|
13711
|
+
status: "error"
|
|
13712
|
+
};
|
|
12456
13713
|
return {
|
|
12457
|
-
|
|
12458
|
-
|
|
12459
|
-
|
|
12460
|
-
name: input.name ?? (nonEmptyString(doc.name) ? doc.name.trim() : `Epic ${epicId}`),
|
|
12461
|
-
nodes,
|
|
12462
|
-
repo
|
|
13714
|
+
message: `This repository's HQ spool is empty, but stranded evidence is waiting under the spool root ${swept.root}.${orphanNote}`,
|
|
13715
|
+
name: HQ_SPOOL_CHECK_NAME,
|
|
13716
|
+
status: "error"
|
|
12463
13717
|
};
|
|
13718
|
+
}
|
|
13719
|
+
const readJsonFiles = async (dir, readFileImpl) => {
|
|
13720
|
+
let names;
|
|
13721
|
+
try {
|
|
13722
|
+
names = await readdir(dir);
|
|
13723
|
+
} catch {
|
|
13724
|
+
return [];
|
|
13725
|
+
}
|
|
13726
|
+
const jsonNames = names.filter((name) => name.endsWith(".json"));
|
|
13727
|
+
const files = [];
|
|
13728
|
+
for (const name of jsonNames) try {
|
|
13729
|
+
const contents = await readFileImpl(path.join(dir, name), "utf-8");
|
|
13730
|
+
files.push({
|
|
13731
|
+
contents,
|
|
13732
|
+
name
|
|
13733
|
+
});
|
|
13734
|
+
} catch {}
|
|
13735
|
+
return files;
|
|
12464
13736
|
};
|
|
12465
13737
|
/**
|
|
12466
|
-
*
|
|
12467
|
-
* from
|
|
12468
|
-
*
|
|
13738
|
+
* How many closeout artifacts one check verifies, newest first. A bound keeps
|
|
13739
|
+
* doctor from turning a long-lived repository's closeout history into a burst
|
|
13740
|
+
* of HQ requests; anything past it downgrades the verdict to `warning` and is
|
|
13741
|
+
* named in the message, because a cap that only shows up in prose reads as
|
|
13742
|
+
* full coverage to every caller that reads the status.
|
|
12469
13743
|
*/
|
|
12470
|
-
const
|
|
12471
|
-
|
|
12472
|
-
|
|
13744
|
+
const MAX_VERIFIED_EPICS = 10;
|
|
13745
|
+
/** Every parseable closeout artifact, newest `generatedAt` first. */
|
|
13746
|
+
const findCloseoutArtifacts = async (dir, readFileImpl) => {
|
|
13747
|
+
const files = await readJsonFiles(dir, readFileImpl);
|
|
13748
|
+
const artifacts = [];
|
|
13749
|
+
for (const file of files) try {
|
|
13750
|
+
const parsed = JSON.parse(file.contents);
|
|
13751
|
+
if (typeof parsed.epic !== "string" || typeof parsed.generatedAt !== "string") continue;
|
|
13752
|
+
artifacts.push({
|
|
13753
|
+
epic: parsed.epic,
|
|
13754
|
+
generatedAt: parsed.generatedAt
|
|
13755
|
+
});
|
|
13756
|
+
} catch {}
|
|
13757
|
+
return artifacts.toSorted((left, right) => left.generatedAt < right.generatedAt ? 1 : -1);
|
|
13758
|
+
};
|
|
13759
|
+
const DEFAULT_READBACK_TIMEOUT_MS = 5e3;
|
|
13760
|
+
/**
|
|
13761
|
+
* Reads back whether `epic`'s retro envelope landed in HQ, through the
|
|
13762
|
+
* epic-keyed route #423 provides (`GET /api/ingest/retro-envelope?epic=…`).
|
|
13763
|
+
* Unlike the eventId-keyed receipt route (#260), this never depends on a
|
|
13764
|
+
* locally remembered event id, so it answers for epics closed long before
|
|
13765
|
+
* this session — including every epic closed before this check existed.
|
|
13766
|
+
*/
|
|
13767
|
+
const fetchEpicRetroReadback = async (endpointOrigin, epic, credentials, fetchImpl, timeoutMs) => {
|
|
13768
|
+
const url = new URL("/api/ingest/retro-envelope", endpointOrigin);
|
|
13769
|
+
url.searchParams.set("epic", epic);
|
|
13770
|
+
const controller = new AbortController();
|
|
13771
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
13772
|
+
try {
|
|
13773
|
+
const response = await fetchImpl(url.toString(), buildCloudflareAccessRequestInit(credentials, {
|
|
13774
|
+
method: "GET",
|
|
13775
|
+
signal: controller.signal
|
|
13776
|
+
}));
|
|
13777
|
+
if (!response.ok) return {
|
|
13778
|
+
detail: `HTTP ${response.status}`,
|
|
13779
|
+
status: "unreachable"
|
|
13780
|
+
};
|
|
12473
13781
|
return {
|
|
12474
|
-
|
|
12475
|
-
|
|
12476
|
-
slug: node.slug,
|
|
12477
|
-
status: node.status,
|
|
12478
|
-
title: node.title
|
|
13782
|
+
readback: await response.json(),
|
|
13783
|
+
status: "ok"
|
|
12479
13784
|
};
|
|
12480
|
-
}
|
|
12481
|
-
|
|
12482
|
-
|
|
12483
|
-
|
|
12484
|
-
|
|
12485
|
-
|
|
12486
|
-
|
|
12487
|
-
|
|
12488
|
-
epics: [{
|
|
12489
|
-
id: dag.epicId,
|
|
12490
|
-
name: dag.name,
|
|
12491
|
-
repo: dag.repo
|
|
12492
|
-
}],
|
|
12493
|
-
nodes
|
|
12494
|
-
};
|
|
12495
|
-
};
|
|
12496
|
-
/** Stable, key-sorted JSON for content hashing (values already ordered). */
|
|
12497
|
-
const stableStringify = (value) => {
|
|
12498
|
-
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
|
|
12499
|
-
if (isPlainObject(value)) return `{${Object.keys(value).toSorted().map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(",")}}`;
|
|
12500
|
-
return JSON.stringify(value ?? null);
|
|
13785
|
+
} catch (error) {
|
|
13786
|
+
return {
|
|
13787
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
13788
|
+
status: "unreachable"
|
|
13789
|
+
};
|
|
13790
|
+
} finally {
|
|
13791
|
+
clearTimeout(timer);
|
|
13792
|
+
}
|
|
12501
13793
|
};
|
|
12502
13794
|
/**
|
|
12503
|
-
*
|
|
12504
|
-
*
|
|
12505
|
-
*
|
|
12506
|
-
* supersedes the prior structure wholesale (HQ keeps the latest). This mirrors
|
|
12507
|
-
* seed-dev's fixed-eventId idempotency, content-addressed instead of literal.
|
|
13795
|
+
* Reads back each epic in turn and sorts it into confirmed, missing, or
|
|
13796
|
+
* unreachable. A degraded envelope counts as missing: the lookup is by epic,
|
|
13797
|
+
* and either way the epic has no trusted retro record.
|
|
12508
13798
|
*/
|
|
12509
|
-
const
|
|
13799
|
+
const readBackEachEpic = async (artifacts, endpoint, credentials, dependencies) => {
|
|
13800
|
+
const tally = {
|
|
13801
|
+
confirmed: [],
|
|
13802
|
+
missing: [],
|
|
13803
|
+
unreachable: []
|
|
13804
|
+
};
|
|
13805
|
+
for (const artifact of artifacts) {
|
|
13806
|
+
const result = await fetchEpicRetroReadback(endpoint, artifact.epic, credentials, dependencies.fetch ?? fetch, dependencies.timeoutMs ?? DEFAULT_READBACK_TIMEOUT_MS);
|
|
13807
|
+
if (result.status === "unreachable") tally.unreachable.push(`${artifact.epic} (${result.detail})`);
|
|
13808
|
+
else if (!result.readback.found || result.readback.degraded === true) tally.missing.push(artifact.epic);
|
|
13809
|
+
else tally.confirmed.push(artifact.epic);
|
|
13810
|
+
}
|
|
13811
|
+
return tally;
|
|
13812
|
+
};
|
|
12510
13813
|
/**
|
|
12511
|
-
*
|
|
12512
|
-
*
|
|
13814
|
+
* For each closed epic with a local closeout artifact, confirms its retro
|
|
13815
|
+
* envelope actually reached HQ — read through the epic-keyed
|
|
13816
|
+
* route #423 provides, using the same gate-sink service token `hq:flush`
|
|
13817
|
+
* uses.
|
|
13818
|
+
*
|
|
13819
|
+
* Epic-keyed rather than eventId-keyed (#423): `retro_envelopes.epic_ref` is a
|
|
13820
|
+
* durable key that survives the spool entry's own deletion, so this verifies
|
|
13821
|
+
* epics closed at any point in the past — not only ones closed after this
|
|
13822
|
+
* check started running. No local sidecar of any kind is consulted.
|
|
13823
|
+
*
|
|
13824
|
+
* Unreachable HQ (network failure, timeout, a non-2xx response) is a warn:
|
|
13825
|
+
* inconclusive, not proof of absence. A confirmed-missing envelope is red.
|
|
13826
|
+
* Absent local artifacts or unresolvable credentials are each reported
|
|
13827
|
+
* precisely rather than folded into one generic status. The check can only
|
|
13828
|
+
* report `ok` on a positive `found: true` response from HQ — never on the
|
|
13829
|
+
* absence of a negative signal. That rule is why absent local artifacts warn
|
|
13830
|
+
* rather than pass (#437): the artifact directory is worktree-local, so
|
|
13831
|
+
* "nothing to verify" is a statement about this checkout, not about HQ.
|
|
13832
|
+
*
|
|
13833
|
+
* Artifacts are verified newest first, not only the newest one — an older
|
|
13834
|
+
* epic's missing envelope must not be masked by a later successful closeout.
|
|
13835
|
+
* Coverage is bounded at `MAX_VERIFIED_EPICS` (10) so a long closeout history
|
|
13836
|
+
* cannot turn one diagnostic into a burst of HQ requests. When the bound
|
|
13837
|
+
* leaves an artifact unverified the verdict is `warning`, not `ok` with a
|
|
13838
|
+
* footnote (#446): the same rule as absent artifacts, since an unchecked epic
|
|
13839
|
+
* is an absence of evidence too.
|
|
13840
|
+
* Credentials go only to an origin the operator authorized in
|
|
13841
|
+
* `hqAllowedOrigins`, the same refusal `hq:flush` and the emit sink make for
|
|
13842
|
+
* the same token, and that refusal comes before any reference is resolved.
|
|
12513
13843
|
*/
|
|
12514
|
-
|
|
12515
|
-
const
|
|
12516
|
-
|
|
13844
|
+
async function hqRetroReadbackDoctorCheck(input, dependencies = {}) {
|
|
13845
|
+
const readFileImpl = dependencies.readFile ?? readFile;
|
|
13846
|
+
if (input.endpoint === void 0) return {
|
|
13847
|
+
message: "HQ ingest is disabled (or unconfigured) in this project's profile; nothing to verify remotely.",
|
|
13848
|
+
name: HQ_RETRO_READBACK_CHECK_NAME,
|
|
13849
|
+
status: "ok"
|
|
13850
|
+
};
|
|
13851
|
+
const endpointOrigin = new URL(input.endpoint).origin;
|
|
13852
|
+
if (!(tryUserConfig({ env: input.env })?.config.hqAllowedOrigins ?? []).includes(endpointOrigin)) return {
|
|
13853
|
+
message: `Skipping the HQ retro-envelope read-back: endpoint origin ${endpointOrigin} is not authorized by hqAllowedOrigins in the operator user config, and this check sends the gate-sink service token. Authorize the origin to verify read-back.`,
|
|
13854
|
+
name: HQ_RETRO_READBACK_CHECK_NAME,
|
|
13855
|
+
status: "warning"
|
|
13856
|
+
};
|
|
13857
|
+
const closeoutsDir = input.closeoutsDir ?? path.join(input.cwd, ".factory-memory/closeouts");
|
|
13858
|
+
const artifacts = await findCloseoutArtifacts(closeoutsDir, readFileImpl);
|
|
13859
|
+
if (artifacts.length === 0) return {
|
|
13860
|
+
message: `No local closeout artifacts under ${closeoutsDir}, so HQ read-back could not be verified. This is not a pass: the artifacts are worktree-local, so a fresh checkout has none. Run this check from a worktree that ran \`factory:closeout\`, or verify the epic in HQ directly.`,
|
|
13861
|
+
name: HQ_RETRO_READBACK_CHECK_NAME,
|
|
13862
|
+
status: "warning"
|
|
13863
|
+
};
|
|
13864
|
+
const verified = artifacts.slice(0, MAX_VERIFIED_EPICS);
|
|
13865
|
+
const unverifiedNote = artifacts.length > verified.length ? ` (${artifacts.length - verified.length} older epic(s) not checked; this check verifies the ${MAX_VERIFIED_EPICS} most recent)` : "";
|
|
13866
|
+
const resolution = resolveHqCredentials({
|
|
13867
|
+
env: input.env,
|
|
13868
|
+
...input.references === void 0 ? {} : { references: input.references },
|
|
13869
|
+
...dependencies.resolve === void 0 ? {} : { resolve: dependencies.resolve }
|
|
13870
|
+
});
|
|
13871
|
+
if (resolution.status !== "resolved") return {
|
|
13872
|
+
message: `Skipping the HQ retro-envelope read-back for epic ${verified[0]?.epic}: ${describeHqCredentialFailure(resolution)} ${TRUSTED_LOCAL_NOTE}`,
|
|
13873
|
+
name: HQ_RETRO_READBACK_CHECK_NAME,
|
|
13874
|
+
status: "warning"
|
|
13875
|
+
};
|
|
13876
|
+
const { confirmed, missing, unreachable } = await readBackEachEpic(verified, input.endpoint, resolution.credentials, dependencies);
|
|
13877
|
+
if (missing.length > 0) return {
|
|
13878
|
+
message: `Epic(s) ${missing.join(", ")} have no trusted retro record in HQ. Run \`psf hq:flush\` if the envelope is still spooled, or re-run factory:closeout, then check again. An envelope HQ stored as degraded reports the same way: the lookup is by epic, and ingest records the epic reference only for an envelope it could parse.${unverifiedNote}`,
|
|
13879
|
+
name: HQ_RETRO_READBACK_CHECK_NAME,
|
|
13880
|
+
status: "error"
|
|
13881
|
+
};
|
|
13882
|
+
if (unreachable.length > 0) return {
|
|
13883
|
+
message: `HQ was unreachable while verifying epic(s) ${unreachable.join("; ")}. Treating as unverified, not missing.${confirmed.length > 0 ? ` Confirmed present: ${confirmed.join(", ")}.` : ""}${unverifiedNote}`,
|
|
13884
|
+
name: HQ_RETRO_READBACK_CHECK_NAME,
|
|
13885
|
+
status: "warning"
|
|
13886
|
+
};
|
|
13887
|
+
return {
|
|
13888
|
+
message: `Retro envelope(s) for epic(s) ${confirmed.join(", ")} are confirmed present in HQ.${unverifiedNote}`,
|
|
13889
|
+
name: HQ_RETRO_READBACK_CHECK_NAME,
|
|
13890
|
+
status: unverifiedNote === "" ? "ok" : "warning"
|
|
13891
|
+
};
|
|
13892
|
+
}
|
|
13893
|
+
//#endregion
|
|
13894
|
+
//#region src/doctor.ts
|
|
13895
|
+
async function doctorProjectProfile(input = {}) {
|
|
13896
|
+
const { path, profile } = loadProjectProfile(input);
|
|
13897
|
+
const env = input.env ?? process.env;
|
|
13898
|
+
const cwd = input.cwd ?? process.cwd();
|
|
13899
|
+
const userConfig = resolveDoctorUserConfig(env, input.userConfig);
|
|
13900
|
+
const [hqSpoolCheck, hqRetroReadbackCheck] = await Promise.all([hqSpoolDoctorCheck({
|
|
13901
|
+
cwd,
|
|
13902
|
+
env,
|
|
13903
|
+
repository: {
|
|
13904
|
+
owner: profile.repository.owner,
|
|
13905
|
+
repo: profile.repository.name
|
|
13906
|
+
}
|
|
13907
|
+
}, input.hqSpoolDependencies), hqRetroReadbackDoctorCheck({
|
|
13908
|
+
cwd,
|
|
13909
|
+
...profile.hq?.enabled ? { endpoint: profile.hq.endpoint } : {},
|
|
13910
|
+
env,
|
|
13911
|
+
...userConfig.loaded?.config.hqIngestCredentials === void 0 ? {} : { references: userConfig.loaded.config.hqIngestCredentials }
|
|
13912
|
+
}, input.hqRetroReadbackDependencies)]);
|
|
13913
|
+
const checks = [
|
|
13914
|
+
...buildProfileChecks(profile, cwd, {
|
|
13915
|
+
env,
|
|
13916
|
+
userConfig: userConfig.loaded?.config,
|
|
13917
|
+
userConfigCheck: userConfig.check
|
|
13918
|
+
}),
|
|
13919
|
+
hqSpoolCheck,
|
|
13920
|
+
hqRetroReadbackCheck,
|
|
13921
|
+
...input.preflight ? admissionPreflightChecks({
|
|
13922
|
+
base: input.base,
|
|
13923
|
+
cwd,
|
|
13924
|
+
env,
|
|
13925
|
+
...userConfig.loaded?.config.hqIngestCredentials === void 0 ? {} : { hqIngestCredentials: userConfig.loaded.config.hqIngestCredentials },
|
|
13926
|
+
profile,
|
|
13927
|
+
profilePath: path
|
|
13928
|
+
}) : []
|
|
13929
|
+
];
|
|
13930
|
+
return {
|
|
13931
|
+
checks,
|
|
13932
|
+
ok: checks.every((check) => check.status !== "error"),
|
|
13933
|
+
profilePath: path,
|
|
13934
|
+
projectKey: profile.project.key,
|
|
13935
|
+
repository: {
|
|
13936
|
+
defaultBranch: profile.repository.defaultBranch,
|
|
13937
|
+
name: profile.repository.name,
|
|
13938
|
+
owner: profile.repository.owner
|
|
13939
|
+
},
|
|
13940
|
+
schemaVersion: profile.schemaVersion
|
|
13941
|
+
};
|
|
13942
|
+
}
|
|
13943
|
+
function resolveDoctorUserConfig(env, provided) {
|
|
13944
|
+
const configPath = provided?.path ?? defaultUserConfigPath(env);
|
|
13945
|
+
if (provided === void 0 && !existsSync(configPath)) return { check: {
|
|
13946
|
+
message: `User config not found at ${configPath}; no optional operator credentials or HQ origins are configured.`,
|
|
13947
|
+
name: "user-config",
|
|
13948
|
+
status: "warning"
|
|
13949
|
+
} };
|
|
13950
|
+
try {
|
|
13951
|
+
const loaded = provided ?? loadUserConfig({
|
|
13952
|
+
configPath,
|
|
13953
|
+
env
|
|
13954
|
+
});
|
|
13955
|
+
const ignoredDefaultHarness = loaded.ignoredKeys?.includes("defaultHarness") ?? false;
|
|
13956
|
+
return {
|
|
13957
|
+
check: {
|
|
13958
|
+
message: ignoredDefaultHarness ? `defaultHarness is ignored; remove it from ${loaded.path}. The factory does not select an agent runtime.` : `Loaded schema version ${loaded.config.schemaVersion} user config from ${loaded.path}.`,
|
|
13959
|
+
name: "user-config",
|
|
13960
|
+
status: ignoredDefaultHarness ? "warning" : "ok"
|
|
13961
|
+
},
|
|
13962
|
+
loaded
|
|
13963
|
+
};
|
|
13964
|
+
} catch (error) {
|
|
13965
|
+
return { check: {
|
|
13966
|
+
message: error instanceof Error ? error.message : String(error),
|
|
13967
|
+
name: "user-config",
|
|
13968
|
+
status: "error"
|
|
13969
|
+
} };
|
|
13970
|
+
}
|
|
13971
|
+
}
|
|
13972
|
+
function buildProfileChecks(profile, cwd, userConfigInput) {
|
|
13973
|
+
return [
|
|
13974
|
+
{
|
|
13975
|
+
message: `Loaded schema version ${profile.schemaVersion} profile for ${profile.project.key}.`,
|
|
13976
|
+
name: "profile",
|
|
13977
|
+
status: "ok"
|
|
13978
|
+
},
|
|
13979
|
+
{
|
|
13980
|
+
message: `${profile.repository.owner}/${profile.repository.name} uses ${profile.repository.defaultBranch}.`,
|
|
13981
|
+
name: "repository",
|
|
13982
|
+
status: "ok"
|
|
13983
|
+
},
|
|
13984
|
+
{
|
|
13985
|
+
message: `${profile.verification.commands.length} verification commands configured.`,
|
|
13986
|
+
name: "verification",
|
|
13987
|
+
status: "ok"
|
|
13988
|
+
},
|
|
13989
|
+
requiredEnvironmentCheck(profile, userConfigInput.env),
|
|
13990
|
+
{
|
|
13991
|
+
message: `${profile.review.modes.join(", ")} review modes configured with ${profile.review.defaultMaxCycles} cycle cap.${profile.review.standingChecklist && profile.review.standingChecklist.length > 0 ? ` Standing review checklist: ${profile.review.standingChecklist.length} line(s).` : ""}`,
|
|
13992
|
+
name: "review-policy",
|
|
13993
|
+
status: "ok"
|
|
13994
|
+
},
|
|
13995
|
+
reviewLadderCheck(profile),
|
|
13996
|
+
userConfigInput.userConfigCheck,
|
|
13997
|
+
githubAppCheck(userConfigInput.userConfig),
|
|
13998
|
+
gitRemoteCheck(profile, cwd)
|
|
13999
|
+
];
|
|
14000
|
+
}
|
|
14001
|
+
function requiredEnvironmentCheck(profile, env) {
|
|
14002
|
+
const required = profile.env?.required ?? [];
|
|
14003
|
+
const unset = required.filter((name) => !env[name]);
|
|
14004
|
+
if (unset.length > 0) return {
|
|
14005
|
+
message: `Required environment variables are unset: ${unset.join(", ")}.`,
|
|
14006
|
+
name: "environment",
|
|
14007
|
+
status: "error"
|
|
14008
|
+
};
|
|
14009
|
+
return {
|
|
14010
|
+
message: required.length === 0 ? "No required environment variables declared." : `${required.length} required environment variable name(s) are set.`,
|
|
14011
|
+
name: "environment",
|
|
14012
|
+
status: "ok"
|
|
14013
|
+
};
|
|
14014
|
+
}
|
|
14015
|
+
function githubAppCheck(userConfig) {
|
|
14016
|
+
const app = userConfig?.githubApp;
|
|
14017
|
+
if (!app) return {
|
|
14018
|
+
message: "Patronage Factory GitHub App credentials are not configured; gate publishing will use user-token commit statuses with Details links. Proof bindings cannot publish, so hosted required checks cannot be satisfied by local proof and must go green by their own means.",
|
|
14019
|
+
name: "github-app",
|
|
14020
|
+
status: "warning"
|
|
14021
|
+
};
|
|
14022
|
+
if (!existsSync(app.privateKeyPath)) return {
|
|
14023
|
+
message: `Patronage Factory GitHub App id ${app.appId} is configured, but private key ${app.privateKeyPath} was not found.`,
|
|
14024
|
+
name: "github-app",
|
|
14025
|
+
status: "warning"
|
|
14026
|
+
};
|
|
12517
14027
|
return {
|
|
12518
|
-
|
|
12519
|
-
|
|
12520
|
-
|
|
12521
|
-
payload,
|
|
12522
|
-
schemaVersion: 1
|
|
14028
|
+
message: `Patronage Factory GitHub App id ${app.appId} is configured${app.installationId ? ` for installation ${app.installationId}` : " with repository installation discovery"}.`,
|
|
14029
|
+
name: "github-app",
|
|
14030
|
+
status: "ok"
|
|
12523
14031
|
};
|
|
12524
|
-
}
|
|
12525
|
-
|
|
12526
|
-
|
|
12527
|
-
|
|
12528
|
-
|
|
12529
|
-
|
|
12530
|
-
|
|
12531
|
-
|
|
12532
|
-
|
|
12533
|
-
|
|
12534
|
-
const
|
|
12535
|
-
if (!
|
|
12536
|
-
|
|
12537
|
-
|
|
12538
|
-
|
|
12539
|
-
|
|
12540
|
-
|
|
12541
|
-
|
|
12542
|
-
|
|
12543
|
-
|
|
12544
|
-
method: "POST"
|
|
14032
|
+
}
|
|
14033
|
+
function reviewLadderCheck(profile) {
|
|
14034
|
+
const policy = resolveReviewLadderPolicy(profile);
|
|
14035
|
+
return {
|
|
14036
|
+
message: `Review ladder ${profile.review.ladder ? "configured" : "defaulted"}: gate cap ${policy.gate.cap}.`,
|
|
14037
|
+
name: "review-ladder",
|
|
14038
|
+
status: "ok"
|
|
14039
|
+
};
|
|
14040
|
+
}
|
|
14041
|
+
function gitRemoteCheck(profile, cwd) {
|
|
14042
|
+
const origin = readGitOrigin(cwd);
|
|
14043
|
+
if (!origin) return {
|
|
14044
|
+
message: "No git origin remote was available for this working directory.",
|
|
14045
|
+
name: "git-origin",
|
|
14046
|
+
status: "warning"
|
|
14047
|
+
};
|
|
14048
|
+
if (repositoryUrlMatches(origin, profile)) return {
|
|
14049
|
+
message: `Git origin matches ${profile.repository.owner}/${profile.repository.name}.`,
|
|
14050
|
+
name: "git-origin",
|
|
14051
|
+
status: "ok"
|
|
12545
14052
|
};
|
|
12546
|
-
let response;
|
|
12547
|
-
try {
|
|
12548
|
-
const normalizedUrl = args.url.replace(/\/+$/u, "");
|
|
12549
|
-
response = await doFetch(normalizedUrl.endsWith("/api/ingest") ? normalizedUrl : `${normalizedUrl}/api/ingest`, requestInit);
|
|
12550
|
-
} catch {
|
|
12551
|
-
throw new Error(`epic:publish-structure could not reach HQ ingest at ${args.url}: request failed. Check the endpoint and network; redirects are refused to protect Cloudflare Access credentials.`);
|
|
12552
|
-
}
|
|
12553
|
-
const text = await response.text();
|
|
12554
|
-
if (response.status === 401 || response.status === 403) throw new Error(`epic:publish-structure rejected by Cloudflare Access or HQ ingest (${response.status}): set valid CF-Access-Client-Id and CF-Access-Client-Secret service-token inputs. Response: ${text}`);
|
|
12555
|
-
if (response.status < 200 || response.status >= 300) throw new Error(`epic:publish-structure failed: HQ ingest returned ${response.status} ${text}`);
|
|
12556
|
-
let duplicate = false;
|
|
12557
|
-
try {
|
|
12558
|
-
duplicate = JSON.parse(text).duplicate === true;
|
|
12559
|
-
} catch {
|
|
12560
|
-
duplicate = response.status === 200;
|
|
12561
|
-
}
|
|
12562
14053
|
return {
|
|
12563
|
-
|
|
12564
|
-
|
|
12565
|
-
status:
|
|
14054
|
+
message: `Git origin ${origin} does not match ${profile.repository.owner}/${profile.repository.name}.`,
|
|
14055
|
+
name: "git-origin",
|
|
14056
|
+
status: "warning"
|
|
12566
14057
|
};
|
|
12567
|
-
}
|
|
12568
|
-
|
|
12569
|
-
//#region src/commands/epic-publish-structure.ts
|
|
12570
|
-
const STDIN = "-";
|
|
12571
|
-
const readSource = (source) => {
|
|
12572
|
-
const fd = source === STDIN ? 0 : source;
|
|
12573
|
-
try {
|
|
12574
|
-
return readFileSync(fd, "utf-8");
|
|
12575
|
-
} catch (error) {
|
|
12576
|
-
const cause = error instanceof Error ? error.message : String(error);
|
|
12577
|
-
throw new Error(`epic:publish-structure could not read ${source === STDIN ? "stdin" : source} (${cause})`, { cause: error });
|
|
12578
|
-
}
|
|
12579
|
-
};
|
|
12580
|
-
const parseDocument = (raw, source) => {
|
|
12581
|
-
if (raw.trim() === "") throw new Error(`epic:publish-structure received empty input from ${source === STDIN ? "stdin" : source}`);
|
|
14058
|
+
}
|
|
14059
|
+
function readGitOrigin(cwd) {
|
|
12582
14060
|
try {
|
|
12583
|
-
return
|
|
12584
|
-
|
|
12585
|
-
|
|
12586
|
-
|
|
14061
|
+
return execFileSync("git", [
|
|
14062
|
+
"remote",
|
|
14063
|
+
"get-url",
|
|
14064
|
+
"origin"
|
|
14065
|
+
], {
|
|
14066
|
+
cwd,
|
|
14067
|
+
encoding: "utf-8",
|
|
14068
|
+
stdio: [
|
|
14069
|
+
"ignore",
|
|
14070
|
+
"pipe",
|
|
14071
|
+
"ignore"
|
|
14072
|
+
]
|
|
14073
|
+
}).trim();
|
|
14074
|
+
} catch {
|
|
14075
|
+
return null;
|
|
12587
14076
|
}
|
|
12588
|
-
}
|
|
12589
|
-
|
|
12590
|
-
|
|
12591
|
-
|
|
12592
|
-
|
|
12593
|
-
|
|
12594
|
-
|
|
12595
|
-
|
|
12596
|
-
|
|
12597
|
-
|
|
12598
|
-
|
|
12599
|
-
|
|
12600
|
-
|
|
12601
|
-
|
|
12602
|
-
|
|
12603
|
-
return;
|
|
12604
|
-
}
|
|
12605
|
-
const url = options.url ?? env.HQ_INGEST_URL;
|
|
12606
|
-
if (!url) throw new Error("epic:publish-structure requires an HQ ingest URL: pass --url or set HQ_INGEST_URL");
|
|
12607
|
-
const clientId = env[CF_ACCESS_CLIENT_ID_ENV];
|
|
12608
|
-
const clientSecret = env[CF_ACCESS_CLIENT_SECRET_ENV];
|
|
12609
|
-
const result = await publishEpicStructure({
|
|
12610
|
-
...clientId && clientSecret ? { accessServiceToken: {
|
|
12611
|
-
clientId,
|
|
12612
|
-
clientSecret
|
|
12613
|
-
} } : {},
|
|
12614
|
-
event,
|
|
12615
|
-
fetchImpl: deps.fetchImpl,
|
|
12616
|
-
url
|
|
14077
|
+
}
|
|
14078
|
+
function repositoryUrlMatches(origin, profile) {
|
|
14079
|
+
const normalized = origin.replace(/\.git$/u, "");
|
|
14080
|
+
const repoPath = `${profile.repository.owner}/${profile.repository.name}`;
|
|
14081
|
+
return normalized.endsWith(`github.com:${repoPath}`) || normalized.endsWith(`github.com/${repoPath}`) || normalized.endsWith(repoPath);
|
|
14082
|
+
}
|
|
14083
|
+
//#endregion
|
|
14084
|
+
//#region src/commands/doctor.ts
|
|
14085
|
+
function createDoctorCommand(output) {
|
|
14086
|
+
return new Command("doctor").description("Validate a project profile without mutating GitHub or local files").option("--base <ref>", "base branch or ref for --preflight", "origin/main").option("--cwd <path>", "working directory to validate", ".").option("--json", "print the doctor report as JSON").option("--preflight", "also list the pre-checkable admission requirements for the current candidate (read-only; never blocks); findings-file shape and wave membership have no read-only pre-check").option("--profile <path>", "path to the project profile JSON file").action(async (options) => {
|
|
14087
|
+
const report = await doctorProjectProfile({
|
|
14088
|
+
base: options.base,
|
|
14089
|
+
cwd: resolveCwdOption(options.cwd),
|
|
14090
|
+
preflight: options.preflight,
|
|
14091
|
+
profilePath: options.profile
|
|
12617
14092
|
});
|
|
12618
|
-
output.stdout.write(
|
|
14093
|
+
if (options.json) output.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
14094
|
+
else output.stdout.write(formatHumanReport(report));
|
|
14095
|
+
if (!report.ok) process.exitCode = 1;
|
|
12619
14096
|
});
|
|
12620
14097
|
}
|
|
14098
|
+
function formatHumanReport(report) {
|
|
14099
|
+
return [
|
|
14100
|
+
`Profile: ${report.projectKey} (schema ${report.schemaVersion})`,
|
|
14101
|
+
`Repository: ${report.repository.owner}/${report.repository.name} default ${report.repository.defaultBranch}`,
|
|
14102
|
+
`Path: ${report.profilePath}`,
|
|
14103
|
+
"",
|
|
14104
|
+
...report.checks.map((check) => `[${check.status}] ${check.name}: ${check.message}`),
|
|
14105
|
+
""
|
|
14106
|
+
].join("\n");
|
|
14107
|
+
}
|
|
12621
14108
|
//#endregion
|
|
12622
14109
|
//#region src/commands/evidence-emit.ts
|
|
12623
14110
|
const readStdin = () => {
|
|
@@ -12790,6 +14277,168 @@ function createGuardCommand(output) {
|
|
|
12790
14277
|
return guard;
|
|
12791
14278
|
}
|
|
12792
14279
|
//#endregion
|
|
14280
|
+
//#region src/hq-flush.ts
|
|
14281
|
+
/**
|
|
14282
|
+
* Spools holding this repository's evidence under an earlier key of its own
|
|
14283
|
+
* (#420) — a question the drain itself cannot answer, because it resolves
|
|
14284
|
+
* exactly one key. Another repository's spool under the shared root is not
|
|
14285
|
+
* swept: it is that repository's ordinary work, not this one's orphan (#446).
|
|
14286
|
+
* An explicit `--dir` run is already the operator pointing at one
|
|
14287
|
+
* location on purpose, so it sweeps nothing. No credential is involved: this
|
|
14288
|
+
* is a directory listing.
|
|
14289
|
+
*/
|
|
14290
|
+
const orphanSpools = async (args, repository, env, sweep) => {
|
|
14291
|
+
if (args.dir && args.dir.length > 0) return [];
|
|
14292
|
+
return (await sweep({ repository }, { env })).orphans;
|
|
14293
|
+
};
|
|
14294
|
+
/**
|
|
14295
|
+
* Loads the profile, and — when it will not load — says how to drain anyway
|
|
14296
|
+
* (#421).
|
|
14297
|
+
*
|
|
14298
|
+
* A worktree on an old branch carries that branch's committed profile, which
|
|
14299
|
+
* may predate the current schema. Validation fails closed before the spool is
|
|
14300
|
+
* ever read, so the evidence sitting in that worktree is undrainable by the
|
|
14301
|
+
* command documented to drain it, at exactly the moment the worktree is most
|
|
14302
|
+
* likely to be deleted. Failing closed is correct and stays; the profile is
|
|
14303
|
+
* this command's source for the repository identity and the authorized HQ
|
|
14304
|
+
* origin, and neither may come from an unvalidated file.
|
|
14305
|
+
*
|
|
14306
|
+
* What was missing is the remedy. `--profile` already solves this — point it
|
|
14307
|
+
* at a checkout whose profile is current — and the failure text is where an
|
|
14308
|
+
* operator holding stranded evidence is standing when they need to know that.
|
|
14309
|
+
*
|
|
14310
|
+
* The remedy is attached to every load failure, not only the stale-schema one,
|
|
14311
|
+
* and the copy stays failure-agnostic for that reason: a missing file and
|
|
14312
|
+
* malformed JSON strand evidence the same way and take the same way out.
|
|
14313
|
+
* Narrating the old-branch story on all of them would explain the wrong
|
|
14314
|
+
* problem to two thirds of the operators who read it.
|
|
14315
|
+
*/
|
|
14316
|
+
const loadFlushProfile = (args) => {
|
|
14317
|
+
try {
|
|
14318
|
+
return loadProjectProfile({
|
|
14319
|
+
cwd: args.cwd,
|
|
14320
|
+
profilePath: args.profilePath
|
|
14321
|
+
});
|
|
14322
|
+
} catch (error) {
|
|
14323
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
14324
|
+
throw new Error(`${message}\n\nhq:flush reads the profile only to learn this repository's identity and its authorized HQ origin, and it will not take either from a profile it cannot load. Evidence spooled here is still drainable: rerun with --profile pointing at a checkout whose profile loads, for example \`psf hq:flush --cwd ${args.cwd} --profile <working-checkout>/software-factory.profile.json\`.`, { cause: error });
|
|
14325
|
+
}
|
|
14326
|
+
};
|
|
14327
|
+
/** Drains this repository's spooled HQ evidence and reports every event. */
|
|
14328
|
+
async function runHqFlush(args, dependencies = {}) {
|
|
14329
|
+
const env = dependencies.env ?? process.env;
|
|
14330
|
+
const { profile } = loadFlushProfile(args);
|
|
14331
|
+
if (!profile.hq?.enabled) return {
|
|
14332
|
+
orphans: [],
|
|
14333
|
+
reason: "the project profile does not enable HQ ingest",
|
|
14334
|
+
retained: 0,
|
|
14335
|
+
status: "skipped"
|
|
14336
|
+
};
|
|
14337
|
+
const repository = {
|
|
14338
|
+
owner: profile.repository.owner,
|
|
14339
|
+
repo: profile.repository.name
|
|
14340
|
+
};
|
|
14341
|
+
const explicitDirectories = args.dir && args.dir.length > 0 ? { explicitDirectories: args.dir } : {};
|
|
14342
|
+
const pending = await (dependencies.countSpool ?? countHqSpoolWork)({
|
|
14343
|
+
cwd: args.cwd,
|
|
14344
|
+
...explicitDirectories,
|
|
14345
|
+
repository
|
|
14346
|
+
}, { env });
|
|
14347
|
+
const retained = pending.pending + pending.unlistable;
|
|
14348
|
+
const orphans = await orphanSpools(args, repository, env, dependencies.sweepOrphans ?? sweepHqSpoolOrphans);
|
|
14349
|
+
const userConfig = tryUserConfig({ env });
|
|
14350
|
+
const allowedOrigins = userConfig?.config.hqAllowedOrigins ?? [];
|
|
14351
|
+
const endpointOrigin = new URL(profile.hq.endpoint).origin;
|
|
14352
|
+
if (!allowedOrigins.includes(endpointOrigin)) return {
|
|
14353
|
+
orphans,
|
|
14354
|
+
reason: `endpoint origin ${endpointOrigin} is not authorized by hqAllowedOrigins in the operator user config`,
|
|
14355
|
+
retained,
|
|
14356
|
+
status: "skipped"
|
|
14357
|
+
};
|
|
14358
|
+
if (retained === 0) return {
|
|
14359
|
+
delivered: 0,
|
|
14360
|
+
duplicate: 0,
|
|
14361
|
+
endpoint: endpointOrigin,
|
|
14362
|
+
incomplete: false,
|
|
14363
|
+
orphans,
|
|
14364
|
+
outcomes: [],
|
|
14365
|
+
rejected: 0,
|
|
14366
|
+
remaining: 0,
|
|
14367
|
+
spools: pending.spools,
|
|
14368
|
+
status: "flushed",
|
|
14369
|
+
undeliverable: 0,
|
|
14370
|
+
unreachable: 0,
|
|
14371
|
+
unreachableFiles: 0
|
|
14372
|
+
};
|
|
14373
|
+
const resolution = resolveHqCredentials({
|
|
14374
|
+
env,
|
|
14375
|
+
...userConfig?.config.hqIngestCredentials === void 0 ? {} : { references: userConfig.config.hqIngestCredentials },
|
|
14376
|
+
...dependencies.resolveSecret === void 0 ? {} : { resolve: dependencies.resolveSecret }
|
|
14377
|
+
});
|
|
14378
|
+
if (resolution.status !== "resolved") return {
|
|
14379
|
+
orphans,
|
|
14380
|
+
reason: `${describeHqCredentialFailure(resolution)} Nothing was drained; ${retained} event(s) remain spooled.`,
|
|
14381
|
+
retained,
|
|
14382
|
+
status: "skipped"
|
|
14383
|
+
};
|
|
14384
|
+
return {
|
|
14385
|
+
...await (dependencies.flush ?? flushHqSpool)({
|
|
14386
|
+
clientId: resolution.credentials.clientId,
|
|
14387
|
+
clientSecret: resolution.credentials.clientSecret,
|
|
14388
|
+
cwd: args.cwd,
|
|
14389
|
+
endpoint: profile.hq.endpoint,
|
|
14390
|
+
...explicitDirectories,
|
|
14391
|
+
repository
|
|
14392
|
+
}, {
|
|
14393
|
+
env,
|
|
14394
|
+
...dependencies.fetch ? { fetch: dependencies.fetch } : {}
|
|
14395
|
+
}),
|
|
14396
|
+
endpoint: endpointOrigin,
|
|
14397
|
+
orphans,
|
|
14398
|
+
status: "flushed"
|
|
14399
|
+
};
|
|
14400
|
+
}
|
|
14401
|
+
const outcomeLine = (outcome) => `[${outcome.status}] ${outcome.kind} ${outcome.eventId}${outcome.detail === void 0 ? "" : ` — ${outcome.detail}`}`;
|
|
14402
|
+
const orphanLine = (orphan) => `ORPHAN SPOOL: ${orphan.directory} — ${orphan.unlistable > 0 && orphan.pending === 0 ? "could not be inspected" : `${orphan.pending} event(s)`}${orphan.oldestQueuedAt === void 0 ? "" : `, oldest ${orphan.oldestQueuedAt}`}`;
|
|
14403
|
+
/**
|
|
14404
|
+
* Orphan lines, plus the recovery instruction. This run did not drain these
|
|
14405
|
+
* locations and does not claim to have: the operator decides, with `--dir`.
|
|
14406
|
+
*/
|
|
14407
|
+
const orphanLines = (orphans) => orphans.length === 0 ? [] : [...orphans.map(orphanLine), `${orphans.length} spool location(s) hold this repository's evidence under an earlier key it no longer resolves to; drain each with \`psf hq:flush --dir <path>\`.`];
|
|
14408
|
+
/** Human rendering. Nothing here can contain a credential value. */
|
|
14409
|
+
function renderHqFlush(result) {
|
|
14410
|
+
if (result.status === "skipped") return `${[`hq:flush skipped: ${result.reason}`, ...orphanLines(result.orphans)].join("\n")}\n`;
|
|
14411
|
+
return `${[
|
|
14412
|
+
`hq:flush ${result.endpoint}`,
|
|
14413
|
+
...result.spools.length === 0 ? ["no spool directory found"] : result.spools.map((spool) => `spool: ${spool}`),
|
|
14414
|
+
...result.outcomes.map(outcomeLine),
|
|
14415
|
+
`delivered ${result.delivered}, duplicate ${result.duplicate}, rejected ${result.rejected}, undeliverable ${result.undeliverable}, unreachable ${result.unreachable}, migrated ${result.outcomes.filter((outcome) => outcome.status === "migrated").length}`,
|
|
14416
|
+
...result.undeliverable > 0 ? [`${result.undeliverable} event(s) can never be delivered and were dispositioned in place, renamed with \`.undeliverable\` and left readable; they no longer count as work waiting.`] : [],
|
|
14417
|
+
result.incomplete ? `INCOMPLETE: ${result.remaining} event(s) still spooled; the drain did not finish. Run hq:flush again.` : `spool drained: ${result.remaining} event(s) remain (rejections stay until HQ accepts them)`,
|
|
14418
|
+
...orphanLines(result.orphans)
|
|
14419
|
+
].join("\n")}\n`;
|
|
14420
|
+
}
|
|
14421
|
+
const hqFlushExitCode = (result) => {
|
|
14422
|
+
if (result.status === "skipped") return result.retained > 0 ? 2 : 0;
|
|
14423
|
+
if (result.unreachableFiles > 0) return 1;
|
|
14424
|
+
return result.incomplete ? 2 : 0;
|
|
14425
|
+
};
|
|
14426
|
+
//#endregion
|
|
14427
|
+
//#region src/commands/hq-flush.ts
|
|
14428
|
+
function createHqFlushCommand(output, action = runHqFlush) {
|
|
14429
|
+
return new Command("hq:flush").description("Drain this repository's spooled HQ evidence and report every event; exits 1 when transport failed and 2 when the drain did not finish").option("--cwd <path>", "working directory to evaluate", ".").option("--dir <path>", "drain an explicit spool directory instead of the default locations; repeatable", (value, previous = []) => [...previous, value]).option("--json", "print the flush result as JSON").option("--profile <path>", "path to the project profile JSON file").action(async (options) => {
|
|
14430
|
+
const result = await action({
|
|
14431
|
+
cwd: resolveCwdOption(options.cwd),
|
|
14432
|
+
...options.dir ? { dir: options.dir } : {},
|
|
14433
|
+
json: Boolean(options.json),
|
|
14434
|
+
...options.profile ? { profilePath: options.profile } : {}
|
|
14435
|
+
});
|
|
14436
|
+
output.stdout.write(options.json ? `${JSON.stringify(result, null, 2)}\n` : renderHqFlush(result));
|
|
14437
|
+
const exitCode = hqFlushExitCode(result);
|
|
14438
|
+
if (exitCode !== 0) process.exitCode = exitCode;
|
|
14439
|
+
});
|
|
14440
|
+
}
|
|
14441
|
+
//#endregion
|
|
12793
14442
|
//#region src/follow-up.ts
|
|
12794
14443
|
const FACTORY_CLI_EXECUTABLE = "patronage-factory";
|
|
12795
14444
|
const FactoryCliInvocationSchema = z.object({
|
|
@@ -13323,13 +14972,18 @@ function defaultPrReadyGit() {
|
|
|
13323
14972
|
stablePatchId
|
|
13324
14973
|
};
|
|
13325
14974
|
}
|
|
14975
|
+
/**
|
|
14976
|
+
* The base is the live PR's own base SHA, read per attempt (#365). An explicit
|
|
14977
|
+
* `--base` is an assertion to check, never the source of truth: it is refused
|
|
14978
|
+
* when it disagrees, so a stale value cannot silently redirect the diff.
|
|
14979
|
+
*/
|
|
13326
14980
|
function authoritativePrBase({ args, cwd, git, pr }) {
|
|
13327
14981
|
if (args.base.length === 0) {
|
|
13328
|
-
if (!git.canResolveCommit(cwd, pr.baseRefOid)) throw new Error(`The authoritative PR base ${pr.baseRefOid} is not available locally; fetch the PR base and rerun
|
|
14982
|
+
if (!git.canResolveCommit(cwd, pr.baseRefOid)) throw new Error(`The authoritative PR base ${pr.baseRefOid} is not available locally; fetch the PR base and rerun.`);
|
|
13329
14983
|
return pr.baseRefOid;
|
|
13330
14984
|
}
|
|
13331
14985
|
const requestedBaseSha = git.resolveCommitSha(cwd, args.base);
|
|
13332
|
-
if (!requestedBaseSha || !sameHeadSha(requestedBaseSha, pr.baseRefOid)) throw new Error(
|
|
14986
|
+
if (!requestedBaseSha || !sameHeadSha(requestedBaseSha, pr.baseRefOid)) throw new Error(`--base ${args.base} does not resolve to the authoritative PR base ${pr.baseRefOid}; omit --base to derive it, or fetch the base and rerun.`);
|
|
13333
14987
|
return pr.baseRefOid;
|
|
13334
14988
|
}
|
|
13335
14989
|
const MERGEABLE_UNKNOWN_POLL_MS = 2e3;
|
|
@@ -13362,6 +15016,7 @@ function defaultPrReadyGithub() {
|
|
|
13362
15016
|
};
|
|
13363
15017
|
}
|
|
13364
15018
|
const prReadyProofSchema = z.object({
|
|
15019
|
+
blockedReasons: blockedReasonsSchema.optional(),
|
|
13365
15020
|
blockingReasons: z.array(z.string()),
|
|
13366
15021
|
command: z.literal("patronage-factory pr:ready"),
|
|
13367
15022
|
followUp: FollowUpActionSchema.optional(),
|
|
@@ -13371,12 +15026,17 @@ const prReadyProofSchema = z.object({
|
|
|
13371
15026
|
profilePath: z.string().min(1).optional(),
|
|
13372
15027
|
repairs: z.array(readinessRepairSchema).default([]),
|
|
13373
15028
|
repository: z.string().regex(/^[^/\s]+\/[^/\s]+$/u).optional(),
|
|
13374
|
-
schemaVersion: z.literal(
|
|
15029
|
+
schemaVersion: z.literal(2),
|
|
13375
15030
|
status: z.enum([
|
|
13376
15031
|
"ready",
|
|
13377
15032
|
"blocked",
|
|
13378
15033
|
"slice-ready/not-final"
|
|
13379
15034
|
])
|
|
15035
|
+
}).superRefine((proof, context) => {
|
|
15036
|
+
for (const issue of blockedReasonIssues({
|
|
15037
|
+
...proof,
|
|
15038
|
+
finalReviewPoint: proof.ledger.finalReviewPoint
|
|
15039
|
+
})) context.addIssue(issue);
|
|
13380
15040
|
});
|
|
13381
15041
|
function validatePrReadyProof(value) {
|
|
13382
15042
|
return prReadyProofSchema.parse(value);
|
|
@@ -13388,7 +15048,7 @@ const PR_READY_PROOF_DESCRIPTOR = {
|
|
|
13388
15048
|
defaultPath: DEFAULT_PR_READY_PROOF_PATH,
|
|
13389
15049
|
label: "pr:ready proof",
|
|
13390
15050
|
parse: validatePrReadyProof,
|
|
13391
|
-
schemaVersion:
|
|
15051
|
+
schemaVersion: 2
|
|
13392
15052
|
};
|
|
13393
15053
|
const PR_VIEW_JSON_FIELDS = [
|
|
13394
15054
|
"baseRefName",
|
|
@@ -13491,7 +15151,7 @@ function readOptionalReviewProof(cwd, proofPath) {
|
|
|
13491
15151
|
return readOptionalProofFor(PR_REVIEW_PROOF_DESCRIPTOR, cwd, proofPath);
|
|
13492
15152
|
}
|
|
13493
15153
|
function writeProof(proof, output) {
|
|
13494
|
-
writeProofJson(proof, output);
|
|
15154
|
+
writeProofJson(validatePrReadyProof(proof), output);
|
|
13495
15155
|
}
|
|
13496
15156
|
const reportProof = (proof, output, json, report) => {
|
|
13497
15157
|
if (!report) return;
|
|
@@ -13647,7 +15307,7 @@ async function runPrReady(args, dependencies = {}) {
|
|
|
13647
15307
|
owner: repository.owner,
|
|
13648
15308
|
pr: args.pr,
|
|
13649
15309
|
proof: {
|
|
13650
|
-
schemaVersion:
|
|
15310
|
+
schemaVersion: 2,
|
|
13651
15311
|
status: "evaluating"
|
|
13652
15312
|
},
|
|
13653
15313
|
repo: repository.name,
|
|
@@ -13801,6 +15461,7 @@ async function runPrReady(args, dependencies = {}) {
|
|
|
13801
15461
|
]);
|
|
13802
15462
|
else if (!pendingExternalChecksOnly) followUp = prVerifyFollowUp(authoringSession);
|
|
13803
15463
|
const proof = {
|
|
15464
|
+
...evaluation.blockedReasons.length > 0 ? { blockedReasons: evaluation.blockedReasons } : {},
|
|
13804
15465
|
blockingReasons: evaluation.blockingReasons,
|
|
13805
15466
|
command: "patronage-factory pr:ready",
|
|
13806
15467
|
...followUp ? { followUp } : {},
|
|
@@ -13810,7 +15471,7 @@ async function runPrReady(args, dependencies = {}) {
|
|
|
13810
15471
|
profilePath: committedProfilePath,
|
|
13811
15472
|
repairs: evaluation.repairs,
|
|
13812
15473
|
repository: checkoutRepositorySlug,
|
|
13813
|
-
schemaVersion:
|
|
15474
|
+
schemaVersion: 2,
|
|
13814
15475
|
status: evaluation.status
|
|
13815
15476
|
};
|
|
13816
15477
|
writeProof(proof, output);
|
|
@@ -13848,7 +15509,7 @@ async function runPrReady(args, dependencies = {}) {
|
|
|
13848
15509
|
pr: args.pr,
|
|
13849
15510
|
proof: {
|
|
13850
15511
|
blockingReasons: [message],
|
|
13851
|
-
schemaVersion:
|
|
15512
|
+
schemaVersion: 2,
|
|
13852
15513
|
status: "blocked"
|
|
13853
15514
|
},
|
|
13854
15515
|
repo: repository.name,
|
|
@@ -14005,7 +15666,7 @@ function requiredCheckDemandGroups({ committedProfile, liveLabels, readyProof })
|
|
|
14005
15666
|
if (!committedProfile) return [{ reasons: ["Could not read the project profile from the committed PR candidate; merge-time requiredChecks policy must come from committed refs. Fetch the PR head, then re-run pr:merge-check."] }];
|
|
14006
15667
|
const recordedChecks = ledger?.externalChecks ?? [];
|
|
14007
15668
|
return (committedProfile.requiredChecks ?? []).map((requiredCheck) => ({
|
|
14008
|
-
demand:
|
|
15669
|
+
demand: requiredCheckDemand(requiredCheck.name),
|
|
14009
15670
|
reasons: requiredCheckDriftReasons({
|
|
14010
15671
|
classification: ledger?.classification,
|
|
14011
15672
|
liveLabels,
|
|
@@ -14131,7 +15792,7 @@ function runPrMergeCheck(args, dependencies = {}) {
|
|
|
14131
15792
|
epic: args.epic
|
|
14132
15793
|
};
|
|
14133
15794
|
rungDemandGroups.push({
|
|
14134
|
-
demand:
|
|
15795
|
+
demand: reviewRungDemand(boundary.review),
|
|
14135
15796
|
reasons: demandedRungSatisfactionReasons({
|
|
14136
15797
|
demandedRung: boundary.review,
|
|
14137
15798
|
liveHumanReviews: boundary.review === "human" ? fetchReviews({
|
|
@@ -14158,7 +15819,7 @@ function runPrMergeCheck(args, dependencies = {}) {
|
|
|
14158
15819
|
repository
|
|
14159
15820
|
}) },
|
|
14160
15821
|
{
|
|
14161
|
-
demand:
|
|
15822
|
+
demand: DEMAND_KEYS.mergeFreeze,
|
|
14162
15823
|
reasons: freezeReasons
|
|
14163
15824
|
},
|
|
14164
15825
|
...requiredCheckDemandGroups({
|
|
@@ -15127,11 +16788,25 @@ async function runPrPublish(args, dependencies = {}) {
|
|
|
15127
16788
|
...defaultPrReadyGithub(),
|
|
15128
16789
|
editPullRequestBody
|
|
15129
16790
|
};
|
|
16791
|
+
const fetchPr = () => github.fetchPullRequest({
|
|
16792
|
+
owner: profile.repository.owner,
|
|
16793
|
+
pr: prNumber,
|
|
16794
|
+
repo: profile.repository.name
|
|
16795
|
+
});
|
|
16796
|
+
const publishArgs = {
|
|
16797
|
+
...args,
|
|
16798
|
+
base: authoritativePrBase({
|
|
16799
|
+
args,
|
|
16800
|
+
cwd,
|
|
16801
|
+
git: dependencies.git ?? defaultPrReadyGit(),
|
|
16802
|
+
pr: fetchPr()
|
|
16803
|
+
})
|
|
16804
|
+
};
|
|
15130
16805
|
const verifyProofPath = path.resolve(cwd, args.verifyProof ?? ".factory-memory/pr-verify.json");
|
|
15131
16806
|
const reviewProofPath = path.resolve(cwd, args.reviewProof ?? ".factory-memory/pr-review.json");
|
|
15132
16807
|
const headSha = (dependencies.git?.currentHeadSha ?? currentHeadSha)(cwd);
|
|
15133
16808
|
const verified = await ensureVerifyProof({
|
|
15134
|
-
args,
|
|
16809
|
+
args: publishArgs,
|
|
15135
16810
|
cwd,
|
|
15136
16811
|
dependencies,
|
|
15137
16812
|
headSha,
|
|
@@ -15146,9 +16821,9 @@ async function runPrPublish(args, dependencies = {}) {
|
|
|
15146
16821
|
verified
|
|
15147
16822
|
});
|
|
15148
16823
|
const reviewProof = await ensureReviewProof({
|
|
15149
|
-
args,
|
|
16824
|
+
args: publishArgs,
|
|
15150
16825
|
currentReviewIdentity: currentReviewIdentityFor({
|
|
15151
|
-
args,
|
|
16826
|
+
args: publishArgs,
|
|
15152
16827
|
cwd,
|
|
15153
16828
|
dependencies,
|
|
15154
16829
|
headSha,
|
|
@@ -15159,11 +16834,7 @@ async function runPrPublish(args, dependencies = {}) {
|
|
|
15159
16834
|
profile,
|
|
15160
16835
|
reviewProofPath
|
|
15161
16836
|
});
|
|
15162
|
-
const pr =
|
|
15163
|
-
owner: profile.repository.owner,
|
|
15164
|
-
pr: prNumber,
|
|
15165
|
-
repo: profile.repository.name
|
|
15166
|
-
});
|
|
16837
|
+
const pr = fetchPr();
|
|
15167
16838
|
const parts = renderPrBodySectionParts({
|
|
15168
16839
|
reviewProof,
|
|
15169
16840
|
verifyProof: verifyProof && verifyProofPassed(verifyProof) ? verifyProof : void 0
|
|
@@ -15234,15 +16905,43 @@ async function runPrPublish(args, dependencies = {}) {
|
|
|
15234
16905
|
};
|
|
15235
16906
|
if (args.json) console.log(JSON.stringify(result, null, 2));
|
|
15236
16907
|
else console.log(renderPublishSummary(result));
|
|
16908
|
+
await deliverSpooledEvidence({
|
|
16909
|
+
awaitPending: dependencies.awaitPendingIngest,
|
|
16910
|
+
cwd,
|
|
16911
|
+
flush: dependencies.flushHqSpool,
|
|
16912
|
+
profilePath: args.profilePath
|
|
16913
|
+
});
|
|
15237
16914
|
return result;
|
|
15238
16915
|
}
|
|
16916
|
+
/**
|
|
16917
|
+
* Deliver whatever HQ evidence this lane spooled, at the moment the operator
|
|
16918
|
+
* is handing the work off (#390). Publish resolves credentials the same
|
|
16919
|
+
* explicit way `hq:flush` does, so a lane with no credentials in its
|
|
16920
|
+
* environment still gets its proofs to HQ.
|
|
16921
|
+
*
|
|
16922
|
+
* Bounded, reported, never gating: every failure mode here is a stderr line.
|
|
16923
|
+
* HQ is a view, so no delivery outcome may reach publish's verdict or exit
|
|
16924
|
+
* status.
|
|
16925
|
+
*/
|
|
16926
|
+
async function deliverSpooledEvidence({ awaitPending, cwd, flush, profilePath }) {
|
|
16927
|
+
try {
|
|
16928
|
+
await (awaitPending ?? awaitPendingHqIngest)();
|
|
16929
|
+
const result = await (flush ?? ((args) => runHqFlush({
|
|
16930
|
+
cwd: args.cwd,
|
|
16931
|
+
...profilePath === void 0 ? {} : { profilePath }
|
|
16932
|
+
})))({ cwd });
|
|
16933
|
+
if (result.status === "skipped" || result.outcomes.length > 0 || result.orphans.length > 0 || hqFlushExitCode(result) !== 0) process.stderr.write(renderHqFlush(result));
|
|
16934
|
+
} catch (error) {
|
|
16935
|
+
process.stderr.write(`hq:flush advisory drain failed: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
16936
|
+
}
|
|
16937
|
+
}
|
|
15239
16938
|
//#endregion
|
|
15240
16939
|
//#region src/commands/pr-publish.ts
|
|
15241
16940
|
function createPrPublishCommand(_output, action = runPrPublish) {
|
|
15242
|
-
return new Command("pr:publish").description("Compose supplied proof into a ready-for-human handoff; never launches review").option("--pr <number>", "pull request number", positiveInteger("--pr")).option("--base <ref>", "base branch or ref
|
|
16941
|
+
return new Command("pr:publish").description("Compose supplied proof into a ready-for-human handoff; never launches review").option("--pr <number>", "pull request number", positiveInteger("--pr")).option("--base <ref>", "explicit base branch or ref override").option("--authoring-session <id>", "known authoring session identity for verification retries").option("--cwd <path>", "working directory to evaluate", ".").option("--epic <number>", "epic issue containing the factory-boundary manifest; the composed readiness evaluation then applies the wave-demanded review rung (#351)", positiveInteger("--epic")).option("--findings <path>", "clean-session findings to validate when no current review proof is available").option("--json", "print the publish result as JSON").option("--output <path>", "write readiness proof JSON to a file").option("--profile <path>", "path to the project profile JSON file").option("--review-proof <path>", "pr:review proof JSON path").option("--verify-proof <path>", "pr:verify proof JSON path").action(async (options) => {
|
|
15243
16942
|
await action({
|
|
15244
16943
|
authoringSessionIds: options.authoringSession ? [options.authoringSession] : void 0,
|
|
15245
|
-
base: options.base,
|
|
16944
|
+
base: options.base ?? "",
|
|
15246
16945
|
cwd: resolveCwdOption(options.cwd),
|
|
15247
16946
|
epic: options.epic,
|
|
15248
16947
|
findings: options.findings,
|
|
@@ -15619,6 +17318,7 @@ function createProgram(options = {}) {
|
|
|
15619
17318
|
program.addCommand(createConfigCommand(output));
|
|
15620
17319
|
program.addCommand(createGuardCommand(output));
|
|
15621
17320
|
program.addCommand(createEvidenceEmitCommand(output));
|
|
17321
|
+
program.addCommand(createHqFlushCommand(output));
|
|
15622
17322
|
program.addCommand(createBoundaryCheckCommand(output, options.actions?.boundaryCheck));
|
|
15623
17323
|
program.addCommand(createPrVerifyCommand(output));
|
|
15624
17324
|
program.addCommand(createCloseoutCommand(output));
|