tickmarkr 2.6.2 → 2.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/dist/cli/commands/approve.d.ts +6 -3
- package/dist/cli/commands/approve.js +16 -4
- package/dist/cli/commands/doctor.d.ts +6 -2
- package/dist/cli/commands/fleet.js +46 -8
- package/dist/cli/commands/plan.js +13 -8
- package/dist/cli/commands/report.d.ts +2 -1
- package/dist/cli/commands/report.js +56 -6
- package/dist/cli/commands/status.js +19 -1
- package/dist/compile/native.js +7 -0
- package/dist/config/config.d.ts +17 -4
- package/dist/config/config.js +43 -6
- package/dist/config/fleet-overlay.d.ts +13 -3
- package/dist/config/fleet-overlay.js +12 -8
- package/dist/drivers/herdr.d.ts +12 -0
- package/dist/drivers/herdr.js +51 -0
- package/dist/drivers/orca.d.ts +9 -1
- package/dist/drivers/orca.js +29 -7
- package/dist/drivers/types.d.ts +2 -0
- package/dist/drivers/types.js +2 -2
- package/dist/gates/acceptance.d.ts +7 -0
- package/dist/gates/acceptance.js +27 -5
- package/dist/gates/baseline.d.ts +20 -1
- package/dist/gates/baseline.js +100 -20
- package/dist/gates/cache.d.ts +8 -0
- package/dist/gates/cache.js +12 -2
- package/dist/gates/llm.d.ts +6 -0
- package/dist/gates/llm.js +27 -8
- package/dist/gates/review.d.ts +6 -1
- package/dist/gates/review.js +122 -32
- package/dist/gates/run-gates.d.ts +54 -3
- package/dist/gates/run-gates.js +331 -45
- package/dist/gates/test-manifest.d.ts +42 -0
- package/dist/gates/test-manifest.js +69 -10
- package/dist/route/router.d.ts +12 -1
- package/dist/route/router.js +26 -9
- package/dist/run/consult.d.ts +3 -1
- package/dist/run/consult.js +4 -2
- package/dist/run/daemon.d.ts +2 -1
- package/dist/run/daemon.js +342 -77
- package/dist/run/interactive-seed.d.ts +4 -0
- package/dist/run/interactive-seed.js +35 -9
- package/dist/run/journal.d.ts +26 -0
- package/dist/run/journal.js +143 -15
- package/dist/run/lease.d.ts +13 -0
- package/dist/run/lease.js +45 -0
- package/dist/run/protocol.d.ts +15 -0
- package/dist/run/protocol.js +11 -1
- package/dist/run/receipt-resolver.d.ts +22 -0
- package/dist/run/receipt-resolver.js +40 -1
- package/dist/run/repair-selection.d.ts +11 -1
- package/dist/run/repair-selection.js +17 -9
- package/dist/run/wall-budget.d.ts +48 -0
- package/dist/run/wall-budget.js +280 -0
- package/dist/tui/cockpit/run-cockpit.js +2 -2
- package/dist/tui/cockpit/run-view.d.ts +2 -1
- package/dist/tui/cockpit/run-view.js +13 -9
- package/dist/tui/cockpit/setup-cockpit.d.ts +2 -0
- package/dist/tui/cockpit/setup-cockpit.js +4 -0
- package/package.json +2 -1
- package/schema/config.schema.json +8 -1
- package/skills/tickmarkr-loop/SKILL.md +7 -1
- package/skills/tickmarkr-overseer/scripts/classify-vitest-log.sh +4 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type Assignment, type WorkerAdapter } from "../adapters/types.js";
|
|
2
|
+
import { DeliveryReadinessError } from "../drivers/herdr.js";
|
|
2
3
|
import type { ExecutorDriver, Slot } from "../drivers/types.js";
|
|
3
4
|
export interface InteractiveSeedResult {
|
|
4
5
|
output: string;
|
|
@@ -8,6 +9,9 @@ export interface InteractiveSeedResult {
|
|
|
8
9
|
trustAnswered: boolean;
|
|
9
10
|
}
|
|
10
11
|
type SeedDriver = Pick<ExecutorDriver, "run" | "waitOutput" | "read" | "sendKey">;
|
|
12
|
+
export declare class SeedReadinessError extends DeliveryReadinessError {
|
|
13
|
+
constructor(waitedMs: number, transcript: string, readinessMatch: string);
|
|
14
|
+
}
|
|
11
15
|
export declare function runInteractiveSeed(opts: {
|
|
12
16
|
driver: SeedDriver;
|
|
13
17
|
slot: Slot;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { matchesTrustDialog } from "../adapters/types.js";
|
|
2
|
+
import { DeliveryReadinessError } from "../drivers/herdr.js";
|
|
2
3
|
// The workspace-trust prompt is a STARTUP gate: it renders before the readiness banner and blocks
|
|
3
4
|
// it, so this wait is the only window in which anything can answer it. Observation therefore runs
|
|
4
5
|
// for the WHOLE readiness budget. Review round 7 (material): a 60-second cutoff here was a window
|
|
@@ -9,6 +10,18 @@ import { matchesTrustDialog } from "../adapters/types.js";
|
|
|
9
10
|
// blocked anyway, and it ends at the first of readiness, a modal, or the deadline.
|
|
10
11
|
const TRUST_POLL_MS = 1_000;
|
|
11
12
|
const TRUST_PANE_ROWS = 80;
|
|
13
|
+
// OBS-1205 add.1: a launch whose readiness never appeared never received the brief, so its deadline is
|
|
14
|
+
// the delivery-readiness class (OBS-142: the interface never became interactive), not a launched
|
|
15
|
+
// worker. Thrown as that class, the daemon's existing catch journals `delivery-readiness-failed`,
|
|
16
|
+
// closes the slot and walks the escalation ladder to a failover — with no `worker-launch` row, which is
|
|
17
|
+
// where a funded repair's findings expire, so the repair the seed never delivered stays owed.
|
|
18
|
+
export class SeedReadinessError extends DeliveryReadinessError {
|
|
19
|
+
constructor(waitedMs, transcript, readinessMatch) {
|
|
20
|
+
super(waitedMs, transcript);
|
|
21
|
+
this.name = "SeedReadinessError";
|
|
22
|
+
this.message = `seed readiness pattern not seen after ${waitedMs}ms: ${readinessMatch}`;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
12
25
|
// v1.89 T19 / OBS-406: wait for readiness, answering a fingerprint-matched trust modal at most ONCE
|
|
13
26
|
// on the way. The daemon's own trust loop runs only after runInteractiveSeed returns, and this wait
|
|
14
27
|
// is exactly the window the modal blocks in — a declaration consulted only there is unreachable
|
|
@@ -17,23 +30,34 @@ async function awaitReadiness(opts) {
|
|
|
17
30
|
const { driver, slot, readinessMatch, deadline } = opts;
|
|
18
31
|
const dialog = opts.adapter.trustDialog;
|
|
19
32
|
const left = () => Math.max(0, deadline - Date.now());
|
|
33
|
+
// Every wait below is awaited, and the budget can run out inside any of them (OrcaDriver.waitOutput
|
|
34
|
+
// checks its sweep before its clock). Readiness a wait returns after the deadline is expired — it may
|
|
35
|
+
// be what a key pressed at the edge produced — and a launch whose deadline passed has already
|
|
36
|
+
// failed, so neither a key nor a seed follows it. Both branches apply it.
|
|
37
|
+
const expired = () => Date.now() >= deadline;
|
|
38
|
+
const readyInTime = async (ms) => (await driver.waitOutput(slot, readinessMatch, ms)) && !expired();
|
|
20
39
|
// Nothing this launch could answer (no declaration, an honest {kind:"none"}, or a driver with no
|
|
21
|
-
// keystroke surface): the single long wait,
|
|
40
|
+
// keystroke surface): the single long wait, pre-T19 behaviour plus the post-await deadline check.
|
|
22
41
|
if (!dialog || dialog.kind === "none" || !driver.sendKey) {
|
|
23
|
-
return { ready: await
|
|
42
|
+
return { ready: await readyInTime(left()), trustAnswered: false };
|
|
24
43
|
}
|
|
25
44
|
let trustAnswered = false;
|
|
26
|
-
while (!trustAnswered &&
|
|
27
|
-
if (await
|
|
45
|
+
while (!trustAnswered && !expired()) {
|
|
46
|
+
if (await readyInTime(Math.min(TRUST_POLL_MS, left()))) {
|
|
28
47
|
return { ready: true, trustAnswered };
|
|
29
48
|
}
|
|
30
49
|
let paneText;
|
|
31
50
|
try {
|
|
32
|
-
|
|
51
|
+
// OBS-1205: a cursor-drawn modal has text only on the RENDERED frame, so the slot's screen read
|
|
52
|
+
// is preferred when its driver has one. An unreadable or foreign frame is a failed read, never
|
|
53
|
+
// a cue to match the stream instead; drivers without a screen keep polling `read`.
|
|
54
|
+
paneText = await (slot.readScreen ? slot.readScreen() : driver.read(slot, TRUST_PANE_ROWS));
|
|
33
55
|
}
|
|
34
56
|
catch {
|
|
35
57
|
continue; // a failed read is not a matched modal — keep observing, spend nothing
|
|
36
58
|
}
|
|
59
|
+
if (expired())
|
|
60
|
+
break; // the wait or the read outlived the budget: no key after the deadline
|
|
37
61
|
if (!matchesTrustDialog(paneText, dialog))
|
|
38
62
|
continue;
|
|
39
63
|
// Review round 7 (material): the latch is spent BEFORE the awaited send, not after it. A send
|
|
@@ -54,7 +78,8 @@ async function awaitReadiness(opts) {
|
|
|
54
78
|
/* dispatched-then-rejected: never retried here, and never re-tried by the daemon either */
|
|
55
79
|
}
|
|
56
80
|
}
|
|
57
|
-
|
|
81
|
+
// An expired budget gets no closing sweep: a zero-length wait still reads the terminal once.
|
|
82
|
+
return { ready: !expired() && await readyInTime(left()), trustAnswered };
|
|
58
83
|
}
|
|
59
84
|
// v1.69 T6: launch-then-seed handoff for adapters whose real TUI cannot be argv-seeded.
|
|
60
85
|
// Both the launch command and the seed line are delivered through the driver's existing `run`
|
|
@@ -65,6 +90,8 @@ export async function runInteractiveSeed(opts) {
|
|
|
65
90
|
await opts.driver.run(opts.slot, seed.launch(opts.assignment.model));
|
|
66
91
|
// `trustAnswered` rides EVERY return below, including both early ones: the daemon initializes its
|
|
67
92
|
// per-slot latch from it, and an omission there reads as "no key was sent" — the second-Enter defect.
|
|
93
|
+
// The readiness deadline throws instead; `onTrustAnswered` already carried the latch out.
|
|
94
|
+
const launchedAt = Date.now();
|
|
68
95
|
const { ready, trustAnswered } = await awaitReadiness({
|
|
69
96
|
driver: opts.driver,
|
|
70
97
|
slot: opts.slot,
|
|
@@ -74,9 +101,8 @@ export async function runInteractiveSeed(opts) {
|
|
|
74
101
|
...(opts.onTrustAnswered ? { onTrustAnswered: opts.onTrustAnswered } : {}),
|
|
75
102
|
});
|
|
76
103
|
const banner = await opts.driver.read(opts.slot, 1000);
|
|
77
|
-
if (!ready)
|
|
78
|
-
|
|
79
|
-
}
|
|
104
|
+
if (!ready)
|
|
105
|
+
throw new SeedReadinessError(Date.now() - launchedAt, banner, seed.readinessMatch);
|
|
80
106
|
let sessionId;
|
|
81
107
|
if (seed.confirmBanner) {
|
|
82
108
|
const confirm = seed.confirmBanner(banner, opts.assignment.model);
|
package/dist/run/journal.d.ts
CHANGED
|
@@ -107,6 +107,8 @@ export interface StructuredFinding {
|
|
|
107
107
|
observedFingerprints?: string[];
|
|
108
108
|
/** Prior id validated against the reviewer's reraised list by the review gate. */
|
|
109
109
|
reraisedFrom?: string;
|
|
110
|
+
/** OBS-1195: an anchor's explicitly named parent chain; it retires when that chain is resolved. */
|
|
111
|
+
boundTo?: string;
|
|
110
112
|
/** Resolved definition AND defect identity; bare symbol spelling is never lineage. */
|
|
111
113
|
codeIdentity?: {
|
|
112
114
|
definition: string;
|
|
@@ -185,7 +187,10 @@ export declare function formatPriorFindingEvidence(evidence: PriorFindingEvidenc
|
|
|
185
187
|
/** Normalized identity of a gate failure: the same defect, seen twice, normalizes to the same bytes. */
|
|
186
188
|
export declare function normalizeGateFailure(details: string): string;
|
|
187
189
|
export declare const GATE_FINGERPRINT_CAP = 2;
|
|
190
|
+
/** The invocation a gate row's receipt names — the identity of the execution that observed it. */
|
|
191
|
+
export declare const receiptOrigin: (data: Record<string, unknown>) => string | undefined;
|
|
188
192
|
export declare function identicalGateFailures(events: JournalEvent[], taskId: string, gate: string, normalized: string): number;
|
|
193
|
+
export declare const readableExcerpt: (text: string) => string;
|
|
189
194
|
/** What each funded repair's next battery actually reached. A repair spends its budget only
|
|
190
195
|
* when that battery reaches one of the gates it was funded to fix; an earlier red is evidence that
|
|
191
196
|
* this repair never got its funded turn, not a charge against the repair ladder. */
|
|
@@ -309,7 +314,28 @@ export declare function carryReviewFindings(priors: readonly StructuredFinding[]
|
|
|
309
314
|
* after round re-seats ONE fingerprint, and a revised rationale replaces the prior rationale on that
|
|
310
315
|
* row. N rounds of the same concern therefore carry the newest accepted explanation once, not N rows.
|
|
311
316
|
*/
|
|
317
|
+
/** OBS-1151: this task's journaled judgments, newest first — subjects to compare a fresh ruling against,
|
|
318
|
+
* never verdicts to reuse. A row without a well-formed judgment (legacy, park, unparseable) is skipped. */
|
|
319
|
+
export declare function priorJudgments(events: readonly JournalEvent[], taskId: string): Array<{
|
|
320
|
+
commit: string;
|
|
321
|
+
judge?: string;
|
|
322
|
+
criteria: Array<{
|
|
323
|
+
id: string;
|
|
324
|
+
key: string;
|
|
325
|
+
met: boolean;
|
|
326
|
+
paths: string[];
|
|
327
|
+
}>;
|
|
328
|
+
}>;
|
|
312
329
|
export declare function outstandingReviewFindings(events: JournalEvent[], taskId: string): StructuredFinding[];
|
|
330
|
+
/**
|
|
331
|
+
* OBS-1019 add.2: each outstanding material chain with the number of valid review verdicts that
|
|
332
|
+
* re-raised it since the last review pass or review-gate waive. Only a chain's own observed spellings
|
|
333
|
+
* count, so an unrelated finding sharing a path never lengthens it.
|
|
334
|
+
*/
|
|
335
|
+
export declare function reraisedReviewChains(events: JournalEvent[], taskId: string): Array<{
|
|
336
|
+
finding: StructuredFinding;
|
|
337
|
+
reraises: number;
|
|
338
|
+
}>;
|
|
313
339
|
/** The findings a funded repair must carry into the next dispatch, or undefined if none is pending. */
|
|
314
340
|
export declare function pendingRepairFindings(events: JournalEvent[], taskId: string): string | undefined;
|
|
315
341
|
/**
|
package/dist/run/journal.js
CHANGED
|
@@ -713,21 +713,53 @@ export function normalizeGateFailure(details) {
|
|
|
713
713
|
// operator approval is a new engagement, and nothing else resets the count. Resume re-measurements
|
|
714
714
|
// are excluded because the interrupted attempt already bought the result they confirm.
|
|
715
715
|
export const GATE_FINGERPRINT_CAP = 2;
|
|
716
|
+
/** The invocation a gate row's receipt names — the identity of the execution that observed it. */
|
|
717
|
+
export const receiptOrigin = (data) => {
|
|
718
|
+
const receipt = data.evidenceReceipt ?? (Array.isArray(data.evidenceReceipts) ? data.evidenceReceipts[0] : undefined);
|
|
719
|
+
const id = receipt && typeof receipt === "object" ? receipt.invocationId : undefined;
|
|
720
|
+
return typeof id === "string" && id ? `invocation:${id}` : undefined;
|
|
721
|
+
};
|
|
722
|
+
// OBS-1106 residual: occurrences are independent OBSERVATIONS, not rows. A journal replay
|
|
723
|
+
// (`replayedFromAttempt`) and a verdict-cache hit (`reused`) re-state a measurement another row
|
|
724
|
+
// already bought, so each resolves to that row's origin — its receipt's invocation when it carries
|
|
725
|
+
// one, else the attempt (replay) or the newest observation on the same subject (cache) — and one
|
|
726
|
+
// observation counts once however many copies the ledger holds, across resume. Only a fresh
|
|
727
|
+
// execution mints a new origin.
|
|
716
728
|
export function identicalGateFailures(events, taskId, gate, normalized) {
|
|
717
|
-
|
|
718
|
-
|
|
729
|
+
const counted = new Set();
|
|
730
|
+
const byAttempt = new Map();
|
|
731
|
+
const bySubject = new Map();
|
|
732
|
+
effectiveEvents(events).forEach((e, i) => {
|
|
719
733
|
if (e.taskId !== taskId)
|
|
720
|
-
|
|
721
|
-
if (e.event === "task-approved")
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
734
|
+
return;
|
|
735
|
+
if (e.event === "task-approved") {
|
|
736
|
+
counted.clear();
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
if (e.event !== "gate-result" || e.data.gate !== gate)
|
|
740
|
+
return;
|
|
741
|
+
const commit = typeof e.data.commit === "string" ? e.data.commit : undefined;
|
|
742
|
+
const origin = typeof e.data.replayedFromAttempt === "number"
|
|
743
|
+
? byAttempt.get(e.data.replayedFromAttempt) ?? receiptOrigin(e.data) ?? `attempt:${e.data.replayedFromAttempt}`
|
|
744
|
+
: e.data.reused === true
|
|
745
|
+
? receiptOrigin(e.data) ?? (commit ? bySubject.get(commit) : undefined) ?? `reused:${commit ?? i}`
|
|
746
|
+
: receiptOrigin(e.data) ?? `row:${i}`;
|
|
747
|
+
if (typeof e.data.attempt === "number")
|
|
748
|
+
byAttempt.set(e.data.attempt, origin);
|
|
749
|
+
if (commit)
|
|
750
|
+
bySubject.set(commit, origin);
|
|
751
|
+
if (e.data.pass === false && e.data.replayMeasurement !== true && typeof e.data.details === "string"
|
|
726
752
|
&& normalizeGateFailure(e.data.details) === normalized)
|
|
727
|
-
|
|
728
|
-
}
|
|
729
|
-
return
|
|
730
|
-
}
|
|
753
|
+
counted.add(origin);
|
|
754
|
+
});
|
|
755
|
+
return counted.size;
|
|
756
|
+
}
|
|
757
|
+
// OBS-1072 add.2: the controls a terminal would consume — OSC strings (ESC ] … BEL/ST), CSI sequences
|
|
758
|
+
// (SGR included), other ESC sequences and stray C0 bytes but tab and newline. Applied only to the
|
|
759
|
+
// human-facing excerpts a worker brief or a scope hint reads; the journal row, the receipt bytes and
|
|
760
|
+
// the failure fingerprint keep the raw text.
|
|
761
|
+
const TERMINAL_CONTROL_RE = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?|\x1b\[[0-?]*[ -/]*[@-~]|\x1b[ -/]*[0-~]?|[\x00-\x08\x0b-\x1f\x7f]/g;
|
|
762
|
+
export const readableExcerpt = (text) => text.replace(TERMINAL_CONTROL_RE, "");
|
|
731
763
|
export function repairReachSinceApproval(events, taskId) {
|
|
732
764
|
const repairs = [];
|
|
733
765
|
for (const e of effectiveEvents(events)) { // OBS-1178: a refused or unsound approval resets no repair history
|
|
@@ -899,7 +931,7 @@ export function journaledFailureBrief(events, taskId) {
|
|
|
899
931
|
rows = [];
|
|
900
932
|
else if (e.event === "gate-result" && e.data.pass === false && e.data.skipped !== true
|
|
901
933
|
&& typeof e.data.details === "string")
|
|
902
|
-
rows.push(`${e.data.gate}: ${e.data.details}`);
|
|
934
|
+
rows.push(`${e.data.gate}: ${readableExcerpt(e.data.details)}`);
|
|
903
935
|
else if (e.event === "delivery-readiness-failed" && typeof e.data.transcript === "string") {
|
|
904
936
|
rows.push(`dispatch: delivery readiness failed after ${e.data.waitedMs}ms; pane transcript:\n${e.data.transcript}`);
|
|
905
937
|
}
|
|
@@ -939,6 +971,17 @@ export function observedReviewFingerprints(finding) {
|
|
|
939
971
|
return [...new Set([finding.fingerprint, ...observed])];
|
|
940
972
|
}
|
|
941
973
|
const reviewNoteIdentity = (note) => note.replace(LINE_REF_RE, "").replace(/\s+/g, " ").trim();
|
|
974
|
+
/** OBS-1195: the anchor row a verdict's recorded settled comment spells, or undefined if malformed. */
|
|
975
|
+
function settledAnchorRow(anchor) {
|
|
976
|
+
if (!anchor || typeof anchor !== "object")
|
|
977
|
+
return undefined;
|
|
978
|
+
const { path, line, body, disposition } = anchor;
|
|
979
|
+
if (typeof path !== "string" || !Number.isInteger(line) || typeof body !== "string")
|
|
980
|
+
return undefined;
|
|
981
|
+
if (disposition !== "deferred" && disposition !== "resolved")
|
|
982
|
+
return undefined;
|
|
983
|
+
return structuredFindings("review", `- ${path}:${line} — ${body}`).find((finding) => finding.class === "review:anchored");
|
|
984
|
+
}
|
|
942
985
|
function distinctReviewFinding(row) {
|
|
943
986
|
const identity = JSON.stringify([reviewNoteIdentity(row.note), row.codeIdentity ?? null]);
|
|
944
987
|
const symbol = `${row.symbol}#${createHash("sha256").update(identity).digest("hex").slice(0, 12)}`;
|
|
@@ -1042,6 +1085,23 @@ export function carryReviewFindings(priors, rows) {
|
|
|
1042
1085
|
* after round re-seats ONE fingerprint, and a revised rationale replaces the prior rationale on that
|
|
1043
1086
|
* row. N rounds of the same concern therefore carry the newest accepted explanation once, not N rows.
|
|
1044
1087
|
*/
|
|
1088
|
+
/** OBS-1151: this task's journaled judgments, newest first — subjects to compare a fresh ruling against,
|
|
1089
|
+
* never verdicts to reuse. A row without a well-formed judgment (legacy, park, unparseable) is skipped. */
|
|
1090
|
+
export function priorJudgments(events, taskId) {
|
|
1091
|
+
const out = [];
|
|
1092
|
+
for (const e of events) {
|
|
1093
|
+
const j = e.taskId === taskId && e.event === "gate-result" && e.data.gate === "acceptance" ? e.data.judgment : undefined;
|
|
1094
|
+
if (!j || typeof j !== "object")
|
|
1095
|
+
continue;
|
|
1096
|
+
const { commit, judge, criteria } = j;
|
|
1097
|
+
if (typeof commit !== "string" || !commit || !Array.isArray(criteria))
|
|
1098
|
+
continue;
|
|
1099
|
+
const rows = criteria.filter((c) => !!c && typeof c.id === "string" && typeof c.key === "string" && typeof c.met === "boolean"
|
|
1100
|
+
&& Array.isArray(c.paths) && c.paths.every((p) => typeof p === "string"));
|
|
1101
|
+
out.unshift({ commit, ...(typeof judge === "string" ? { judge } : {}), criteria: rows });
|
|
1102
|
+
}
|
|
1103
|
+
return out;
|
|
1104
|
+
}
|
|
1045
1105
|
export function outstandingReviewFindings(events, taskId) {
|
|
1046
1106
|
let open = [];
|
|
1047
1107
|
for (const e of effectiveEvents(events)) { // OBS-1178: only an effective review waive retires findings
|
|
@@ -1059,6 +1119,9 @@ export function outstandingReviewFindings(events, taskId) {
|
|
|
1059
1119
|
continue;
|
|
1060
1120
|
// A failed review can resolve one chain while re-raising another. Only observed, uniquely
|
|
1061
1121
|
// matched spellings retire a chain; skipped/no-verdict rows were excluded above.
|
|
1122
|
+
// OBS-1195: every parent chain this verdict resolved or explicitly deferred; an anchor bound to one
|
|
1123
|
+
// retires whether it was carried in or restated by this same verdict.
|
|
1124
|
+
const retiredParents = new Set();
|
|
1062
1125
|
if (Array.isArray(e.data.resolved)) {
|
|
1063
1126
|
const resolved = e.data.resolved;
|
|
1064
1127
|
const settled = new Set(resolved.flatMap((id) => {
|
|
@@ -1066,8 +1129,45 @@ export function outstandingReviewFindings(events, taskId) {
|
|
|
1066
1129
|
&& observedReviewFingerprints(finding).some((fp) => reviewFingerprintMatches(id, fp)));
|
|
1067
1130
|
return matches.length === 1 ? matches : [];
|
|
1068
1131
|
}));
|
|
1132
|
+
// OBS-1195: an anchor explicitly bound to a settled chain retires with it; unbound ones stay.
|
|
1133
|
+
for (const id of [...settled].flatMap(observedReviewFingerprints))
|
|
1134
|
+
retiredParents.add(id);
|
|
1069
1135
|
open = open.filter((finding) => !settled.has(finding));
|
|
1070
1136
|
}
|
|
1137
|
+
// OBS-1195: a failed verdict that restates a bound anchor's parent ONLY as a deferral (its deferred
|
|
1138
|
+
// entry echoes the prior's id and no material entry claims it) has explicitly deferred that parent,
|
|
1139
|
+
// so the anchor retires. The parent chain itself keeps whatever the closure lists said of it.
|
|
1140
|
+
// A material row's `reraisedFrom` is this verdict's claim only when its own reraised list names that
|
|
1141
|
+
// id; a link an earlier verdict drew (carried metadata) is lineage and claims nothing here. Without a
|
|
1142
|
+
// reraised list to check against, every link counts as a claim — fail closed.
|
|
1143
|
+
const rows = findingRows(e, "review");
|
|
1144
|
+
const reraisedHere = Array.isArray(e.data.reraised) ? e.data.reraised.filter((id) => typeof id === "string") : undefined;
|
|
1145
|
+
const claimedByMaterial = new Set(rows.filter((row) => !isDeferredFinding(row) && row.reraisedFrom
|
|
1146
|
+
&& (reraisedHere === undefined || reraisedHere.some((id) => reviewFingerprintMatches(id, row.reraisedFrom)))).map((row) => row.reraisedFrom));
|
|
1147
|
+
const deferredParents = rows.filter((row) => isDeferredFinding(row) && row.reraisedFrom && !claimedByMaterial.has(row.reraisedFrom))
|
|
1148
|
+
.map((row) => row.reraisedFrom);
|
|
1149
|
+
for (const finding of open) {
|
|
1150
|
+
if (finding.class !== "review:material" || !observedReviewFingerprints(finding).some((fp) => deferredParents.includes(fp)))
|
|
1151
|
+
continue;
|
|
1152
|
+
for (const id of observedReviewFingerprints(finding))
|
|
1153
|
+
retiredParents.add(id);
|
|
1154
|
+
}
|
|
1155
|
+
const boundToRetired = (finding) => finding.boundTo !== undefined && retiredParents.has(finding.boundTo);
|
|
1156
|
+
open = open.filter((finding) => !boundToRetired(finding));
|
|
1157
|
+
// OBS-1195: a later verdict that re-anchors a carried anchor to a deferred entry or a resolved prior
|
|
1158
|
+
// settles that anchor explicitly. Only its own identity (path, symbol and note), uniquely matched,
|
|
1159
|
+
// retires it; a bare path coincidence binds nothing.
|
|
1160
|
+
if (Array.isArray(e.data.settledAnchors)) {
|
|
1161
|
+
const retired = new Set(e.data.settledAnchors.flatMap((anchor) => {
|
|
1162
|
+
const row = settledAnchorRow(anchor);
|
|
1163
|
+
// Identity, not fingerprint: a collision-disambiguated anchor carries a `symbol#hash` spelling.
|
|
1164
|
+
const matches = row ? open.filter((finding) => finding.class === "review:anchored" && finding.path === row.path
|
|
1165
|
+
&& (finding.symbol === row.symbol || finding.symbol.startsWith(`${row.symbol}#`))
|
|
1166
|
+
&& reviewNoteIdentity(finding.note) === reviewNoteIdentity(row.note)) : [];
|
|
1167
|
+
return matches.length === 1 ? matches : [];
|
|
1168
|
+
}));
|
|
1169
|
+
open = open.filter((finding) => !retired.has(finding));
|
|
1170
|
+
}
|
|
1071
1171
|
if (e.data.pass !== false) {
|
|
1072
1172
|
// a later review PASSED on this task: every finding it BLOCKED on is settled …
|
|
1073
1173
|
open = open.filter(isDeferredFinding);
|
|
@@ -1077,11 +1177,39 @@ export function outstandingReviewFindings(events, taskId) {
|
|
|
1077
1177
|
// the very next round — the same silent drop by a different door.
|
|
1078
1178
|
open = carryReviewFindings(open, findingRows(e, "review").filter(isDeferredFinding));
|
|
1079
1179
|
}
|
|
1080
|
-
else
|
|
1081
|
-
|
|
1180
|
+
else {
|
|
1181
|
+
// OBS-1195: a repeated comment bound to a parent this verdict resolved or deferred is not re-seated.
|
|
1182
|
+
open = carryReviewFindings(open, rows).filter((finding) => !boundToRetired(finding));
|
|
1183
|
+
}
|
|
1082
1184
|
}
|
|
1083
1185
|
return open;
|
|
1084
1186
|
}
|
|
1187
|
+
/**
|
|
1188
|
+
* OBS-1019 add.2: each outstanding material chain with the number of valid review verdicts that
|
|
1189
|
+
* re-raised it since the last review pass or review-gate waive. Only a chain's own observed spellings
|
|
1190
|
+
* count, so an unrelated finding sharing a path never lengthens it.
|
|
1191
|
+
*/
|
|
1192
|
+
export function reraisedReviewChains(events, taskId) {
|
|
1193
|
+
let verdicts = [];
|
|
1194
|
+
for (const e of effectiveEvents(events)) {
|
|
1195
|
+
if (e.taskId !== taskId)
|
|
1196
|
+
continue;
|
|
1197
|
+
if (e.event === "task-approved" && e.data.release === GATE_SATISFIED_RELEASE && e.data.gate === "review")
|
|
1198
|
+
verdicts = [];
|
|
1199
|
+
if (e.event !== "gate-result" || e.data.gate !== "review" || e.data.skipped === true)
|
|
1200
|
+
continue;
|
|
1201
|
+
if (e.data.unparseable === true || e.data.noVerdict === true || e.data.cause !== undefined)
|
|
1202
|
+
continue;
|
|
1203
|
+
verdicts = e.data.pass === false ? [...verdicts, e] : [];
|
|
1204
|
+
}
|
|
1205
|
+
return outstandingReviewFindings(events, taskId)
|
|
1206
|
+
.filter((finding) => finding.class === "review:material")
|
|
1207
|
+
.map((finding) => ({
|
|
1208
|
+
finding,
|
|
1209
|
+
reraises: verdicts.filter((e) => Array.isArray(e.data.reraised) && e.data.reraised
|
|
1210
|
+
.some((id) => observedReviewFingerprints(finding).some((fp) => reviewFingerprintMatches(id, fp)))).length,
|
|
1211
|
+
}));
|
|
1212
|
+
}
|
|
1085
1213
|
/** The findings a funded repair must carry into the next dispatch, or undefined if none is pending. */
|
|
1086
1214
|
export function pendingRepairFindings(events, taskId) {
|
|
1087
1215
|
const e = decisionForNextDispatch(events, taskId, "repair-attempt");
|
package/dist/run/lease.d.ts
CHANGED
|
@@ -33,7 +33,20 @@ export interface RepositoryLeaseOptions {
|
|
|
33
33
|
signal?: AbortSignal;
|
|
34
34
|
/** Called once per distinct holder the waiter is queued behind. */
|
|
35
35
|
onWait?: (holder: RepositoryLeaseHolder) => void;
|
|
36
|
+
/** The token a descendant inherited; defaults to REPOSITORY_LEASE_TOKEN_ENV. */
|
|
37
|
+
inherited?: string;
|
|
36
38
|
}
|
|
39
|
+
/** OBS-880/1071: the file lease's token, exported to the holder's descendants for the span it is held
|
|
40
|
+
* so a nested runner (a gate's suite under `tickmarkr verify`, a mutation child inside a leased suite)
|
|
41
|
+
* reenters instead of waiting on its own ancestor. Deliberately NOT COMMAND_LEASE_TOKEN_ENV: the
|
|
42
|
+
* installed daemon clears and rewrites that in-process token, and a command-lease token never names
|
|
43
|
+
* this file. Environment text alone proves nothing: see `reentrantHolder`. */
|
|
44
|
+
export declare const REPOSITORY_LEASE_TOKEN_ENV = "TICKMARKR_REPOSITORY_LEASE_TOKEN";
|
|
45
|
+
/** Reentry is identity-bound: the inherited token must name the record at THIS repository's lease path
|
|
46
|
+
* right now, its holder must be alive, and that holder must be a strict ancestor of this process. A
|
|
47
|
+
* forged, stale (released or superseded), foreign-repository or command-lease token matches no record;
|
|
48
|
+
* an in-process sibling is not a descendant — each of those waits like any other runner. */
|
|
49
|
+
export declare function reentrantHolder(path: string, inherited: string | undefined): Promise<RepositoryLeaseHolder | undefined>;
|
|
37
50
|
export declare function repositoryLeasePath(cwd: string): Promise<string>;
|
|
38
51
|
/** OBS-1057: dev+ino alone is not an identity across unlink/recreate — Linux hands the freed inode
|
|
39
52
|
* number straight to the next file, so a stale observation of a dead or corrupt reservation matched
|
package/dist/run/lease.js
CHANGED
|
@@ -91,6 +91,38 @@ export class CommandLeases {
|
|
|
91
91
|
}
|
|
92
92
|
}
|
|
93
93
|
}
|
|
94
|
+
/** OBS-880/1071: the file lease's token, exported to the holder's descendants for the span it is held
|
|
95
|
+
* so a nested runner (a gate's suite under `tickmarkr verify`, a mutation child inside a leased suite)
|
|
96
|
+
* reenters instead of waiting on its own ancestor. Deliberately NOT COMMAND_LEASE_TOKEN_ENV: the
|
|
97
|
+
* installed daemon clears and rewrites that in-process token, and a command-lease token never names
|
|
98
|
+
* this file. Environment text alone proves nothing: see `reentrantHolder`. */
|
|
99
|
+
export const REPOSITORY_LEASE_TOKEN_ENV = "TICKMARKR_REPOSITORY_LEASE_TOKEN";
|
|
100
|
+
/** The strict ancestors of this process, from one `ps` snapshot; empty when unreadable (fail closed). */
|
|
101
|
+
const ancestorPids = () => new Promise((ok) => execFile("ps", ["-A", "-o", "pid=,ppid="], { encoding: "utf8" }, (error, stdout) => {
|
|
102
|
+
const parent = new Map();
|
|
103
|
+
if (!error)
|
|
104
|
+
for (const line of stdout.split("\n")) {
|
|
105
|
+
const [pid, ppid] = line.trim().split(/\s+/).map(Number);
|
|
106
|
+
if (Number.isInteger(pid) && Number.isInteger(ppid))
|
|
107
|
+
parent.set(pid, ppid);
|
|
108
|
+
}
|
|
109
|
+
const seen = new Set();
|
|
110
|
+
for (let pid = error ? 0 : process.ppid; pid > 1 && !seen.has(pid); pid = parent.get(pid) ?? 0)
|
|
111
|
+
seen.add(pid);
|
|
112
|
+
ok(seen);
|
|
113
|
+
}));
|
|
114
|
+
/** Reentry is identity-bound: the inherited token must name the record at THIS repository's lease path
|
|
115
|
+
* right now, its holder must be alive, and that holder must be a strict ancestor of this process. A
|
|
116
|
+
* forged, stale (released or superseded), foreign-repository or command-lease token matches no record;
|
|
117
|
+
* an in-process sibling is not a descendant — each of those waits like any other runner. */
|
|
118
|
+
export async function reentrantHolder(path, inherited) {
|
|
119
|
+
if (!inherited)
|
|
120
|
+
return undefined;
|
|
121
|
+
const holder = readHolder(path);
|
|
122
|
+
if (holder?.token !== inherited || holder.pid === process.pid || !alive(holder.pid))
|
|
123
|
+
return undefined;
|
|
124
|
+
return (await ancestorPids()).has(holder.pid) ? holder : undefined;
|
|
125
|
+
}
|
|
94
126
|
const LEASE_FILE = "tickmarkr-runner.lease";
|
|
95
127
|
export async function repositoryLeasePath(cwd) {
|
|
96
128
|
const commonDir = await new Promise((ok, fail) => execFile("git", ["rev-parse", "--git-common-dir"], { cwd, encoding: "utf8" }, (error, stdout) => error ? fail(error) : ok(stdout.trim())));
|
|
@@ -283,6 +315,9 @@ const inspectAndReserve = (path, mine) => {
|
|
|
283
315
|
};
|
|
284
316
|
export async function withRepositoryLease(cwd, run, opts = {}) {
|
|
285
317
|
const path = await repositoryLeasePath(cwd);
|
|
318
|
+
// A descendant of the live holder runs inside its ancestor's reservation and releases nothing.
|
|
319
|
+
if (await reentrantHolder(path, opts.inherited ?? process.env[REPOSITORY_LEASE_TOKEN_ENV]))
|
|
320
|
+
return run();
|
|
286
321
|
const pollMs = opts.pollMs ?? 1_000;
|
|
287
322
|
const mine = { pid: process.pid, cwd, at: Date.now(), token: randomUUID() };
|
|
288
323
|
let waitingOn;
|
|
@@ -297,10 +332,20 @@ export async function withRepositoryLease(cwd, run, opts = {}) {
|
|
|
297
332
|
}
|
|
298
333
|
await new Promise((wake) => setTimeout(wake, pollMs));
|
|
299
334
|
}
|
|
335
|
+
// Exported before run() so every descendant forked or spawned during the hold can reenter; restored
|
|
336
|
+
// at release unless another reservation in this process has since exported its own.
|
|
337
|
+
const exported = process.env[REPOSITORY_LEASE_TOKEN_ENV];
|
|
338
|
+
process.env[REPOSITORY_LEASE_TOKEN_ENV] = mine.token;
|
|
300
339
|
try {
|
|
301
340
|
return await run();
|
|
302
341
|
}
|
|
303
342
|
finally {
|
|
343
|
+
if (process.env[REPOSITORY_LEASE_TOKEN_ENV] === mine.token) {
|
|
344
|
+
if (exported === undefined)
|
|
345
|
+
delete process.env[REPOSITORY_LEASE_TOKEN_ENV];
|
|
346
|
+
else
|
|
347
|
+
process.env[REPOSITORY_LEASE_TOKEN_ENV] = exported;
|
|
348
|
+
}
|
|
304
349
|
// Release is serialized with reclamation/acquisition and removes only this generation.
|
|
305
350
|
await withMutationLock(path, pollMs, undefined, () => {
|
|
306
351
|
if (readHolder(path)?.token === mine.token)
|
package/dist/run/protocol.d.ts
CHANGED
|
@@ -53,6 +53,15 @@ export declare const EvidenceArtifactSchema: z.ZodObject<{
|
|
|
53
53
|
droppedBytes: z.ZodNumber;
|
|
54
54
|
truncated: z.ZodBoolean;
|
|
55
55
|
}, z.core.$strict>;
|
|
56
|
+
export declare const RedactionSchema: z.ZodObject<{
|
|
57
|
+
material: z.ZodBoolean;
|
|
58
|
+
counts: z.ZodOptional<z.ZodObject<{
|
|
59
|
+
token: z.ZodNumber;
|
|
60
|
+
assignment: z.ZodNumber;
|
|
61
|
+
secretEnv: z.ZodNumber;
|
|
62
|
+
benignEnv: z.ZodNumber;
|
|
63
|
+
}, z.core.$strict>>;
|
|
64
|
+
}, z.core.$strict>;
|
|
56
65
|
export declare const GateEvidenceReceiptSchema: z.ZodObject<{
|
|
57
66
|
invocationId: z.ZodString;
|
|
58
67
|
nonce: z.ZodOptional<z.ZodString>;
|
|
@@ -85,6 +94,12 @@ export declare const GateEvidenceReceiptSchema: z.ZodObject<{
|
|
|
85
94
|
}>;
|
|
86
95
|
redaction: z.ZodObject<{
|
|
87
96
|
material: z.ZodBoolean;
|
|
97
|
+
counts: z.ZodOptional<z.ZodObject<{
|
|
98
|
+
token: z.ZodNumber;
|
|
99
|
+
assignment: z.ZodNumber;
|
|
100
|
+
secretEnv: z.ZodNumber;
|
|
101
|
+
benignEnv: z.ZodNumber;
|
|
102
|
+
}, z.core.$strict>>;
|
|
88
103
|
}, z.core.$strict>;
|
|
89
104
|
stdout: z.ZodObject<{
|
|
90
105
|
path: z.ZodString;
|
package/dist/run/protocol.js
CHANGED
|
@@ -47,6 +47,16 @@ export const EvidenceArtifactSchema = z.object({
|
|
|
47
47
|
if (ref.availability === "available" && ref.sha256 === null)
|
|
48
48
|
ctx.addIssue({ code: "custom", message: "available bytes require a hash" });
|
|
49
49
|
});
|
|
50
|
+
// OBS-1139: one count per original span, never a value. A legacy receipt carries `material` alone and
|
|
51
|
+
// keeps it as recorded; a counted one must agree — benign HOME/TMPDIR substitutions are never material.
|
|
52
|
+
const RedactionCountsSchema = z.object({
|
|
53
|
+
token: z.number().int().nonnegative(),
|
|
54
|
+
assignment: z.number().int().nonnegative(),
|
|
55
|
+
secretEnv: z.number().int().nonnegative(),
|
|
56
|
+
benignEnv: z.number().int().nonnegative(),
|
|
57
|
+
}).strict();
|
|
58
|
+
export const RedactionSchema = z.object({ material: z.boolean(), counts: RedactionCountsSchema.optional() }).strict()
|
|
59
|
+
.refine(r => !r.counts || r.material === (r.counts.token + r.counts.assignment + r.counts.secretEnv > 0), { message: "material must agree with the token, assignment and secret-environment counts" });
|
|
50
60
|
export const GateEvidenceReceiptSchema = z.object({
|
|
51
61
|
invocationId: NonEmptyStringSchema,
|
|
52
62
|
nonce: NonEmptyStringSchema.optional(),
|
|
@@ -64,7 +74,7 @@ export const GateEvidenceReceiptSchema = z.object({
|
|
|
64
74
|
timedOut: z.boolean().nullable(),
|
|
65
75
|
}).strict(),
|
|
66
76
|
availability: z.enum(EVIDENCE_AVAILABILITIES),
|
|
67
|
-
redaction:
|
|
77
|
+
redaction: RedactionSchema,
|
|
68
78
|
stdout: EvidenceArtifactSchema,
|
|
69
79
|
stderr: EvidenceArtifactSchema,
|
|
70
80
|
}).strict();
|
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
import { type GateEvidenceReceipt } from "./protocol.js";
|
|
2
2
|
export declare const RECEIPT_UNAVAILABLE_REASONS: readonly ["outside-root", "symlink", "hash-mismatch", "expired", "missing"];
|
|
3
3
|
export type ReceiptUnavailableReason = (typeof RECEIPT_UNAVAILABLE_REASONS)[number];
|
|
4
|
+
/** OBS-1140: at most this many newest eviction tombstones persist; an older eviction reads as missing. */
|
|
5
|
+
export declare const EVICTION_TOMBSTONE_LIMIT = 256;
|
|
6
|
+
/** Beside gate-evidence/, never inside it: tombstones are metadata, not retained artifact bytes. */
|
|
7
|
+
export declare const EVICTION_TOMBSTONES_FILE = "evidence-evictions.json";
|
|
8
|
+
/** Bound to the evicted bytes themselves, so a replacement artifact at the same path never inherits it. */
|
|
9
|
+
export interface EvictionTombstone {
|
|
10
|
+
readonly path: string;
|
|
11
|
+
readonly sha256: string;
|
|
12
|
+
readonly retainedBytes: number;
|
|
13
|
+
}
|
|
14
|
+
/** Newest last, at most the limit; anything malformed is no tombstone at all (fail closed to missing). */
|
|
15
|
+
export declare function parseEvictionTombstones(bytes: Buffer): EvictionTombstone[];
|
|
4
16
|
export type ReceiptResolution = {
|
|
5
17
|
readonly ok: true;
|
|
6
18
|
readonly path: string;
|
|
@@ -12,6 +24,16 @@ export type ReceiptResolution = {
|
|
|
12
24
|
};
|
|
13
25
|
/** Resolve one artifact reference against the run root it was recorded under. Fails closed. */
|
|
14
26
|
export declare function resolveReceipt(reference: unknown, runRoot: string): ReceiptResolution;
|
|
27
|
+
export type ReceiptRedaction = {
|
|
28
|
+
readonly material: boolean;
|
|
29
|
+
readonly counts: GateEvidenceReceipt["redaction"]["counts"] | null;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* OBS-1139: what a receipt says was withheld. A counted receipt reports its per-span counts; a legacy
|
|
33
|
+
* one keeps its recorded `material` with no counts; anything unparseable is material (fail closed).
|
|
34
|
+
*/
|
|
35
|
+
export declare function resolveReceiptRedaction(receipt: unknown): ReceiptRedaction;
|
|
36
|
+
export declare function formatReceiptRedaction(r: ReceiptRedaction): string;
|
|
15
37
|
/** Both artifacts of a receipt, stdout first, each resolved independently. */
|
|
16
38
|
export declare function resolveReceiptArtifacts(receipt: GateEvidenceReceipt, runRoot: string): ReceiptResolution[];
|
|
17
39
|
/** One line per artifact for a human surface: the reference, then its verified availability. */
|
|
@@ -1,13 +1,28 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync } from "node:fs";
|
|
3
3
|
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
4
|
-
import { EvidenceArtifactSchema } from "./protocol.js";
|
|
4
|
+
import { EvidenceArtifactSchema, RedactionSchema } from "./protocol.js";
|
|
5
5
|
// OBS-1101 retrieval (D-174): the one production reader of an execution-evidence receipt. It answers
|
|
6
6
|
// from the bytes on disk under the run root the receipt was minted in and nothing else — no
|
|
7
7
|
// re-execution, no substitute chosen by name or timestamp, no symlink followed anywhere: not in the
|
|
8
8
|
// reference, not at the root, not above it. The caller hands a canonical root (its own trust anchor);
|
|
9
9
|
// a root reached through any link is refused outright.
|
|
10
10
|
export const RECEIPT_UNAVAILABLE_REASONS = ["outside-root", "symlink", "hash-mismatch", "expired", "missing"];
|
|
11
|
+
/** OBS-1140: at most this many newest eviction tombstones persist; an older eviction reads as missing. */
|
|
12
|
+
export const EVICTION_TOMBSTONE_LIMIT = 256;
|
|
13
|
+
/** Beside gate-evidence/, never inside it: tombstones are metadata, not retained artifact bytes. */
|
|
14
|
+
export const EVICTION_TOMBSTONES_FILE = "evidence-evictions.json";
|
|
15
|
+
/** Newest last, at most the limit; anything malformed is no tombstone at all (fail closed to missing). */
|
|
16
|
+
export function parseEvictionTombstones(bytes) {
|
|
17
|
+
try {
|
|
18
|
+
const raw = JSON.parse(bytes.toString("utf8"));
|
|
19
|
+
return Array.isArray(raw) ? raw.filter((t) => typeof t?.path === "string"
|
|
20
|
+
&& typeof t.sha256 === "string" && Number.isInteger(t.retainedBytes)).slice(-EVICTION_TOMBSTONE_LIMIT) : [];
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
}
|
|
11
26
|
/** Resolve one artifact reference against the run root it was recorded under. Fails closed. */
|
|
12
27
|
export function resolveReceipt(reference, runRoot) {
|
|
13
28
|
const parsed = EvidenceArtifactSchema.safeParse(reference);
|
|
@@ -27,6 +42,8 @@ export function resolveReceipt(reference, runRoot) {
|
|
|
27
42
|
if (!rel || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel))
|
|
28
43
|
return { ok: false, path: ref.path, reason: "outside-root" };
|
|
29
44
|
const read = readUnderRoot(root, rel.split(sep));
|
|
45
|
+
if (read === "missing" && evictedByQuota(root, ref))
|
|
46
|
+
return { ok: false, path: ref.path, reason: "expired" };
|
|
30
47
|
if (typeof read === "string")
|
|
31
48
|
return { ok: false, path: ref.path, reason: read };
|
|
32
49
|
const sha256 = createHash("sha256").update(read).digest("hex");
|
|
@@ -34,6 +51,28 @@ export function resolveReceipt(reference, runRoot) {
|
|
|
34
51
|
return { ok: false, path: ref.path, reason: "hash-mismatch" };
|
|
35
52
|
return { ok: true, path: ref.path, sha256 };
|
|
36
53
|
}
|
|
54
|
+
/** A deletion is explained only by a tombstone naming this exact path, hash and length (OBS-1140). */
|
|
55
|
+
function evictedByQuota(root, ref) {
|
|
56
|
+
const bytes = readUnderRoot(root, [EVICTION_TOMBSTONES_FILE]);
|
|
57
|
+
return typeof bytes !== "string" && parseEvictionTombstones(bytes)
|
|
58
|
+
.some(t => t.path === ref.path && t.sha256 === ref.sha256 && t.retainedBytes === ref.retainedBytes);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* OBS-1139: what a receipt says was withheld. A counted receipt reports its per-span counts; a legacy
|
|
62
|
+
* one keeps its recorded `material` with no counts; anything unparseable is material (fail closed).
|
|
63
|
+
*/
|
|
64
|
+
export function resolveReceiptRedaction(receipt) {
|
|
65
|
+
const parsed = RedactionSchema.safeParse(receipt?.redaction);
|
|
66
|
+
if (!parsed.success)
|
|
67
|
+
return { material: true, counts: null };
|
|
68
|
+
return { material: parsed.data.material, counts: parsed.data.counts ?? null };
|
|
69
|
+
}
|
|
70
|
+
export function formatReceiptRedaction(r) {
|
|
71
|
+
const head = r.material ? "material" : "nonmaterial";
|
|
72
|
+
return r.counts
|
|
73
|
+
? `redaction ${head} token=${r.counts.token} assignment=${r.counts.assignment} secret-env=${r.counts.secretEnv} benign-env=${r.counts.benignEnv}`
|
|
74
|
+
: `redaction ${head} (uncounted)`;
|
|
75
|
+
}
|
|
37
76
|
const same = (a, b) => a.dev === b.dev && a.ino === b.ino;
|
|
38
77
|
/** Every ancestor of the root, root first excluded — "/" itself cannot be a link. */
|
|
39
78
|
function ancestors(root) {
|