tickmarkr 2.5.8 → 2.6.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/adapters/claude-code.js +19 -6
- package/dist/adapters/prompt.js +1 -1
- package/dist/adapters/qwen.js +30 -3
- package/dist/adapters/types.d.ts +3 -0
- package/dist/cli/commands/approve.js +15 -3
- package/dist/cli/commands/beat.d.ts +2 -0
- package/dist/cli/commands/beat.js +28 -29
- package/dist/cli/commands/compile.js +11 -0
- package/dist/cli/commands/fleet.js +61 -53
- package/dist/cli/commands/init.js +1 -1
- package/dist/cli/commands/report.js +11 -1
- package/dist/cli/commands/verify.d.ts +4 -1
- package/dist/cli/commands/verify.js +18 -5
- package/dist/cli/help.d.ts +2 -0
- package/dist/cli/help.js +6 -4
- package/dist/compile/native.js +39 -4
- package/dist/config/config.d.ts +41 -3
- package/dist/config/config.js +48 -17
- package/dist/config/fleet-overlay.js +47 -62
- package/dist/drivers/orca.d.ts +1 -1
- package/dist/drivers/orca.js +21 -4
- package/dist/gates/baseline.d.ts +67 -0
- package/dist/gates/baseline.js +172 -10
- package/dist/gates/cache.d.ts +8 -0
- package/dist/gates/cache.js +26 -13
- package/dist/gates/review.d.ts +8 -7
- package/dist/gates/review.js +83 -45
- package/dist/gates/run-gates.d.ts +4 -1
- package/dist/gates/run-gates.js +32 -12
- package/dist/gates/test-manifest.d.ts +16 -1
- package/dist/gates/test-manifest.js +127 -27
- package/dist/gates/test-reporter.js +4 -0
- package/dist/graph/graph.d.ts +1 -1
- package/dist/graph/graph.js +6 -1
- package/dist/graph/schema.d.ts +2 -0
- package/dist/graph/schema.js +2 -0
- package/dist/route/preference.d.ts +1 -1
- package/dist/route/preference.js +10 -39
- package/dist/run/daemon.d.ts +7 -1
- package/dist/run/daemon.js +237 -53
- package/dist/run/git.d.ts +10 -0
- package/dist/run/git.js +49 -1
- package/dist/run/journal.d.ts +18 -3
- package/dist/run/journal.js +141 -17
- package/dist/run/merge.d.ts +13 -2
- package/dist/run/merge.js +98 -12
- package/dist/run/protocol.d.ts +82 -0
- package/dist/run/protocol.js +35 -0
- package/dist/run/receipt-resolver.d.ts +18 -0
- package/dist/run/receipt-resolver.js +132 -0
- package/dist/run/supervision.d.ts +14 -1
- package/dist/run/supervision.js +122 -24
- package/dist/tui/cockpit/evidence-view.d.ts +10 -1
- package/dist/tui/cockpit/evidence-view.js +37 -5
- package/dist/tui/cockpit/live-runtime.d.ts +4 -0
- package/dist/tui/cockpit/live-runtime.js +37 -3
- package/dist/tui/cockpit/live-store.d.ts +2 -0
- package/dist/tui/ink/fleet-app.d.ts +12 -22
- package/dist/tui/ink/fleet-app.js +520 -131
- package/package.json +1 -1
- package/schema/rungraph.schema.json +7 -0
- package/skills/tickmarkr-overseer/SKILL.md +91 -41
- package/skills/tickmarkr-overseer/scripts/watch-context.sh +14 -2
package/dist/run/protocol.js
CHANGED
|
@@ -33,6 +33,41 @@ export const ShellReceiptSchema = z.object({
|
|
|
33
33
|
error: z.string().optional(),
|
|
34
34
|
durationMs: z.number().nonnegative().optional(),
|
|
35
35
|
}).strict();
|
|
36
|
+
export const EVIDENCE_AVAILABILITIES = ["available", "not-started", "capture-failed", "killed", "expired", "missing"];
|
|
37
|
+
export const EvidenceArtifactSchema = z.object({
|
|
38
|
+
path: z.string().refine(p => p.length > 0 && !p.startsWith("/") && !p.includes("\\") && !p.split("/").includes("..")),
|
|
39
|
+
availability: z.enum(EVIDENCE_AVAILABILITIES),
|
|
40
|
+
sha256: z.string().regex(/^[a-f0-9]{64}$/).nullable(),
|
|
41
|
+
retainedBytes: z.number().int().min(0).max(16 * 1024),
|
|
42
|
+
droppedBytes: z.number().int().nonnegative(),
|
|
43
|
+
truncated: z.boolean(),
|
|
44
|
+
}).strict().superRefine((ref, ctx) => {
|
|
45
|
+
if (ref.truncated !== (ref.droppedBytes > 0))
|
|
46
|
+
ctx.addIssue({ code: "custom", message: "truncation must agree with dropped bytes" });
|
|
47
|
+
if (ref.availability === "available" && ref.sha256 === null)
|
|
48
|
+
ctx.addIssue({ code: "custom", message: "available bytes require a hash" });
|
|
49
|
+
});
|
|
50
|
+
export const GateEvidenceReceiptSchema = z.object({
|
|
51
|
+
invocationId: NonEmptyStringSchema,
|
|
52
|
+
nonce: NonEmptyStringSchema.optional(),
|
|
53
|
+
subject: z.object({
|
|
54
|
+
runId: NonEmptyStringSchema,
|
|
55
|
+
taskId: NonEmptyStringSchema.nullable(),
|
|
56
|
+
attempt: AttemptSchema.nullable(),
|
|
57
|
+
gate: NonEmptyStringSchema,
|
|
58
|
+
subjectCommit: NonEmptyStringSchema.nullable(),
|
|
59
|
+
}).strict(),
|
|
60
|
+
termination: z.object({
|
|
61
|
+
kind: z.enum(["exit", "signal", "timeout", "not-started", "unknown"]),
|
|
62
|
+
exitCode: z.number().int().nullable(),
|
|
63
|
+
signal: NonEmptyStringSchema.nullable(),
|
|
64
|
+
timedOut: z.boolean().nullable(),
|
|
65
|
+
}).strict(),
|
|
66
|
+
availability: z.enum(EVIDENCE_AVAILABILITIES),
|
|
67
|
+
redaction: z.object({ material: z.boolean() }).strict(),
|
|
68
|
+
stdout: EvidenceArtifactSchema,
|
|
69
|
+
stderr: EvidenceArtifactSchema,
|
|
70
|
+
}).strict();
|
|
36
71
|
/** A task-build row must carry the entire caller-owned correlation, even before spawn. */
|
|
37
72
|
export const CommandReceiptSchema = ShellReceiptSchema.extend({
|
|
38
73
|
attribution: CommandReceiptAttributionSchema,
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type GateEvidenceReceipt } from "./protocol.js";
|
|
2
|
+
export declare const RECEIPT_UNAVAILABLE_REASONS: readonly ["outside-root", "symlink", "hash-mismatch", "expired", "missing"];
|
|
3
|
+
export type ReceiptUnavailableReason = (typeof RECEIPT_UNAVAILABLE_REASONS)[number];
|
|
4
|
+
export type ReceiptResolution = {
|
|
5
|
+
readonly ok: true;
|
|
6
|
+
readonly path: string;
|
|
7
|
+
readonly sha256: string;
|
|
8
|
+
} | {
|
|
9
|
+
readonly ok: false;
|
|
10
|
+
readonly path: string;
|
|
11
|
+
readonly reason: ReceiptUnavailableReason;
|
|
12
|
+
};
|
|
13
|
+
/** Resolve one artifact reference against the run root it was recorded under. Fails closed. */
|
|
14
|
+
export declare function resolveReceipt(reference: unknown, runRoot: string): ReceiptResolution;
|
|
15
|
+
/** Both artifacts of a receipt, stdout first, each resolved independently. */
|
|
16
|
+
export declare function resolveReceiptArtifacts(receipt: GateEvidenceReceipt, runRoot: string): ReceiptResolution[];
|
|
17
|
+
/** One line per artifact for a human surface: the reference, then its verified availability. */
|
|
18
|
+
export declare function formatReceiptResolution(r: ReceiptResolution): string;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync } from "node:fs";
|
|
3
|
+
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { EvidenceArtifactSchema } from "./protocol.js";
|
|
5
|
+
// OBS-1101 retrieval (D-174): the one production reader of an execution-evidence receipt. It answers
|
|
6
|
+
// from the bytes on disk under the run root the receipt was minted in and nothing else — no
|
|
7
|
+
// re-execution, no substitute chosen by name or timestamp, no symlink followed anywhere: not in the
|
|
8
|
+
// reference, not at the root, not above it. The caller hands a canonical root (its own trust anchor);
|
|
9
|
+
// a root reached through any link is refused outright.
|
|
10
|
+
export const RECEIPT_UNAVAILABLE_REASONS = ["outside-root", "symlink", "hash-mismatch", "expired", "missing"];
|
|
11
|
+
/** Resolve one artifact reference against the run root it was recorded under. Fails closed. */
|
|
12
|
+
export function resolveReceipt(reference, runRoot) {
|
|
13
|
+
const parsed = EvidenceArtifactSchema.safeParse(reference);
|
|
14
|
+
const path = typeof reference?.path === "string" ? reference.path : "";
|
|
15
|
+
if (!parsed.success) {
|
|
16
|
+
const escapes = path && (isAbsolute(path) || path.includes("\\") || path.split("/").includes(".."));
|
|
17
|
+
return { ok: false, path, reason: escapes ? "outside-root" : "missing" };
|
|
18
|
+
}
|
|
19
|
+
const ref = parsed.data;
|
|
20
|
+
if (ref.availability === "expired")
|
|
21
|
+
return { ok: false, path: ref.path, reason: "expired" };
|
|
22
|
+
if (ref.availability !== "available" || ref.sha256 === null)
|
|
23
|
+
return { ok: false, path: ref.path, reason: "missing" };
|
|
24
|
+
const root = resolve(runRoot);
|
|
25
|
+
const full = resolve(root, ref.path);
|
|
26
|
+
const rel = relative(root, full);
|
|
27
|
+
if (!rel || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel))
|
|
28
|
+
return { ok: false, path: ref.path, reason: "outside-root" };
|
|
29
|
+
const read = readUnderRoot(root, rel.split(sep));
|
|
30
|
+
if (typeof read === "string")
|
|
31
|
+
return { ok: false, path: ref.path, reason: read };
|
|
32
|
+
const sha256 = createHash("sha256").update(read).digest("hex");
|
|
33
|
+
if (sha256 !== ref.sha256 || read.length !== ref.retainedBytes)
|
|
34
|
+
return { ok: false, path: ref.path, reason: "hash-mismatch" };
|
|
35
|
+
return { ok: true, path: ref.path, sha256 };
|
|
36
|
+
}
|
|
37
|
+
const same = (a, b) => a.dev === b.dev && a.ino === b.ino;
|
|
38
|
+
/** Every ancestor of the root, root first excluded — "/" itself cannot be a link. */
|
|
39
|
+
function ancestors(root) {
|
|
40
|
+
const out = [];
|
|
41
|
+
for (let p = dirname(root); p !== dirname(p); p = dirname(p))
|
|
42
|
+
out.push(p);
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Walk `parts` below `root` one descriptor at a time and return the leaf's bytes. Every component —
|
|
47
|
+
* the root's ancestors, the root, each directory, the leaf — is opened O_NOFOLLOW and must be the
|
|
48
|
+
* same inode lstat reports for that name before AND after the open, so a component swapped for a
|
|
49
|
+
* link mid-walk is refused rather than followed. Every platform takes the same pathname open: an
|
|
50
|
+
* ancestor swap by a same-privilege writer can only ever yield hash-identical bytes (D-269), so no
|
|
51
|
+
* descriptor-anchored traversal is layered on top (D-279: the boundary is the run root).
|
|
52
|
+
*/
|
|
53
|
+
function readUnderRoot(root, parts) {
|
|
54
|
+
for (const p of ancestors(root)) {
|
|
55
|
+
let st;
|
|
56
|
+
try {
|
|
57
|
+
st = lstatSync(p);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return "missing";
|
|
61
|
+
}
|
|
62
|
+
if (st.isSymbolicLink())
|
|
63
|
+
return "symlink";
|
|
64
|
+
}
|
|
65
|
+
const fds = [];
|
|
66
|
+
try {
|
|
67
|
+
let parent = openComponent(root, true);
|
|
68
|
+
if (typeof parent === "string")
|
|
69
|
+
return parent;
|
|
70
|
+
fds.push(parent);
|
|
71
|
+
let at = root;
|
|
72
|
+
for (let i = 0; i < parts.length; i++) {
|
|
73
|
+
at = resolve(at, parts[i]);
|
|
74
|
+
const fd = openComponent(at, i < parts.length - 1);
|
|
75
|
+
if (typeof fd === "string")
|
|
76
|
+
return fd;
|
|
77
|
+
fds.push(fd);
|
|
78
|
+
parent = fd;
|
|
79
|
+
}
|
|
80
|
+
return readFileSync(parent);
|
|
81
|
+
}
|
|
82
|
+
catch (e) {
|
|
83
|
+
return e.code === "ELOOP" ? "symlink" : "missing";
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
for (const fd of fds)
|
|
87
|
+
closeSync(fd);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function openComponent(path, dir) {
|
|
91
|
+
let before;
|
|
92
|
+
try {
|
|
93
|
+
before = lstatSync(path);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return "missing";
|
|
97
|
+
}
|
|
98
|
+
if (before.isSymbolicLink())
|
|
99
|
+
return "symlink";
|
|
100
|
+
if (dir ? !before.isDirectory() : !before.isFile())
|
|
101
|
+
return "missing";
|
|
102
|
+
const flags = constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK | (dir ? constants.O_DIRECTORY : 0);
|
|
103
|
+
const fd = openSync(path, flags);
|
|
104
|
+
let after;
|
|
105
|
+
try {
|
|
106
|
+
after = lstatSync(path);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
closeSync(fd);
|
|
110
|
+
return "missing";
|
|
111
|
+
}
|
|
112
|
+
const opened = fstatSync(fd);
|
|
113
|
+
if (after.isSymbolicLink() || !same(before, opened) || !same(after, opened)) {
|
|
114
|
+
closeSync(fd);
|
|
115
|
+
return "symlink";
|
|
116
|
+
}
|
|
117
|
+
// A leaf reachable under a second name (a hard link) is an alias the inode check cannot see through;
|
|
118
|
+
// evidence is written once under one name, so any aliased leaf is refused.
|
|
119
|
+
if (!dir && opened.nlink !== 1) {
|
|
120
|
+
closeSync(fd);
|
|
121
|
+
return "symlink";
|
|
122
|
+
}
|
|
123
|
+
return fd;
|
|
124
|
+
}
|
|
125
|
+
/** Both artifacts of a receipt, stdout first, each resolved independently. */
|
|
126
|
+
export function resolveReceiptArtifacts(receipt, runRoot) {
|
|
127
|
+
return [receipt.stdout, receipt.stderr].map(ref => resolveReceipt(ref, runRoot));
|
|
128
|
+
}
|
|
129
|
+
/** One line per artifact for a human surface: the reference, then its verified availability. */
|
|
130
|
+
export function formatReceiptResolution(r) {
|
|
131
|
+
return r.ok ? `${r.path} available sha256=${r.sha256}` : `${r.path} unavailable (${r.reason})`;
|
|
132
|
+
}
|
|
@@ -39,7 +39,20 @@ export interface SupervisionBeatObservation {
|
|
|
39
39
|
export declare const supervisionBeatPath: (repoRoot: string, tier: SupervisionTier) => string;
|
|
40
40
|
/** Where a watcher records that it STOOD DOWN. Its own file: the beat keeps meaning only "alive". */
|
|
41
41
|
export declare const supervisionStandDownPath: (repoRoot: string, tier: SupervisionTier) => string;
|
|
42
|
-
|
|
42
|
+
/** Durable CLI arm; ticks never advance its epoch or its stand-down fence. */
|
|
43
|
+
export interface SupervisionArm {
|
|
44
|
+
armId: string;
|
|
45
|
+
armEpoch: string;
|
|
46
|
+
markerFence: string;
|
|
47
|
+
}
|
|
48
|
+
export declare const supervisionArmPath: (repoRoot: string, tier: SupervisionTier) => string;
|
|
49
|
+
/** Explicit arming acknowledges the current marker without deleting it. */
|
|
50
|
+
export declare function newSupervisionArm(repoRoot: string, tier: SupervisionTier, armId?: string, initial?: boolean): SupervisionArm;
|
|
51
|
+
export declare function beatSupervision(repoRoot: string, tier: SupervisionTier, seat?: string, observation?: SupervisionBeatObservation, options?: {
|
|
52
|
+
arm?: SupervisionArm;
|
|
53
|
+
armId?: string;
|
|
54
|
+
loop?: boolean;
|
|
55
|
+
}): boolean;
|
|
43
56
|
/** Handle a watcher holds for as long as it is supervising; disarm stands it down and is idempotent. */
|
|
44
57
|
export interface ArmedSupervision {
|
|
45
58
|
disarm: () => void;
|
package/dist/run/supervision.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
2
|
-
import { randomUUID } from "node:crypto";
|
|
1
|
+
import { existsSync, linkSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
3
|
import { dirname, join, resolve } from "node:path";
|
|
4
4
|
import { parseRunId } from "./journal.js";
|
|
5
5
|
import { stateDirName, tickmarkrDir } from "../graph/graph.js";
|
|
@@ -86,6 +86,63 @@ const supervisionDir = (repoRoot) => join(repoRoot, stateDirName(repoRoot), "sup
|
|
|
86
86
|
export const supervisionBeatPath = (repoRoot, tier) => join(supervisionDir(repoRoot), `${tier}.beat`);
|
|
87
87
|
/** Where a watcher records that it STOOD DOWN. Its own file: the beat keeps meaning only "alive". */
|
|
88
88
|
export const supervisionStandDownPath = (repoRoot, tier) => join(supervisionDir(repoRoot), `${tier}.standdown`);
|
|
89
|
+
export const supervisionArmPath = (repoRoot, tier) => join(supervisionDir(repoRoot), `${tier}.arm`);
|
|
90
|
+
function readArm(repoRoot, tier) {
|
|
91
|
+
try {
|
|
92
|
+
const arm = JSON.parse(readFileSync(supervisionArmPath(repoRoot, tier), "utf8"));
|
|
93
|
+
if (typeof arm.armId !== "string" || !arm.armId.trim() ||
|
|
94
|
+
typeof arm.armEpoch !== "string" || Number.isNaN(Date.parse(arm.armEpoch)) ||
|
|
95
|
+
typeof arm.markerFence !== "string")
|
|
96
|
+
throw new Error("invalid arm");
|
|
97
|
+
return arm;
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
if (error.code === "ENOENT")
|
|
101
|
+
return undefined;
|
|
102
|
+
throw new Error(`${tier} arm is unreadable`, { cause: error });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
function markerFence(repoRoot, tier) {
|
|
106
|
+
const marker = readStandDown(repoRoot, tier);
|
|
107
|
+
if (marker === "UNREADABLE")
|
|
108
|
+
throw new Error(`${tier} stand-down is unreadable`);
|
|
109
|
+
return marker === "NONE" ? "NONE" : marker.fence;
|
|
110
|
+
}
|
|
111
|
+
/** Explicit arming acknowledges the current marker without deleting it. */
|
|
112
|
+
export function newSupervisionArm(repoRoot, tier, armId = randomUUID(), initial = false) {
|
|
113
|
+
// Acknowledge only the marker observed when arming begins. In particular, do not
|
|
114
|
+
// sample it after stamping the epoch: a stand-down published in that gap is a
|
|
115
|
+
// later act, and must fence this arm rather than be silently acknowledged by it.
|
|
116
|
+
const fence = initial ? "NONE" : markerFence(repoRoot, tier);
|
|
117
|
+
const previous = readArm(repoRoot, tier);
|
|
118
|
+
const arm = {
|
|
119
|
+
armId, armEpoch: new Date(Math.max(Date.now(), previous ? Date.parse(previous.armEpoch) + 1 : 0)).toISOString(),
|
|
120
|
+
markerFence: fence,
|
|
121
|
+
};
|
|
122
|
+
tickmarkrDir(repoRoot);
|
|
123
|
+
const path = supervisionArmPath(repoRoot, tier);
|
|
124
|
+
if (!initial) {
|
|
125
|
+
atomicRecord(path, arm);
|
|
126
|
+
return arm;
|
|
127
|
+
}
|
|
128
|
+
// Only one legacy first tick may install the initial arm. A losing process reuses the winner.
|
|
129
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
130
|
+
const tmp = `${path}.${randomUUID()}.tmp`;
|
|
131
|
+
try {
|
|
132
|
+
writeFileSync(tmp, JSON.stringify(arm) + "\n");
|
|
133
|
+
try {
|
|
134
|
+
linkSync(tmp, path);
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
if (error.code !== "EEXIST")
|
|
138
|
+
throw error;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
finally {
|
|
142
|
+
rmSync(tmp, { force: true });
|
|
143
|
+
}
|
|
144
|
+
return readArm(repoRoot, tier);
|
|
145
|
+
}
|
|
89
146
|
/** The independent latch a stand-down cannot overwrite or remove. */
|
|
90
147
|
const supervisionObligationPath = (repoRoot, tier) => join(supervisionDir(repoRoot), `${tier}.clear-owed`);
|
|
91
148
|
// SUP-06: PRESENCE — one file per ARMED WATCHER, because a tier may legitimately have more than one.
|
|
@@ -190,6 +247,22 @@ function dischargeClearObligation(repoRoot, tier, observation) {
|
|
|
190
247
|
rmSync(supervisionObligationPath(repoRoot, tier), { force: true });
|
|
191
248
|
}
|
|
192
249
|
}
|
|
250
|
+
function raiseIfCrossed(repoRoot, tier, observation) {
|
|
251
|
+
if (observation.pct >= observation.thresholdPct) {
|
|
252
|
+
raiseClearObligation(repoRoot, tier, observation.armId, observation.thresholdPct);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
// The latch is orthogonal to liveness: a stood-down tier still raises a crossing and still lets a
|
|
256
|
+
// different arm below threshold discharge it. Otherwise the context instrument goes silent after the
|
|
257
|
+
// first hand-off — the watcher script stands its tier down on every exit.
|
|
258
|
+
function observeWithoutBeat(repoRoot, tier, observation) {
|
|
259
|
+
if (observation !== undefined) {
|
|
260
|
+
tickmarkrDir(repoRoot);
|
|
261
|
+
raiseIfCrossed(repoRoot, tier, observation);
|
|
262
|
+
dischargeClearObligation(repoRoot, tier, observation);
|
|
263
|
+
}
|
|
264
|
+
return false;
|
|
265
|
+
}
|
|
193
266
|
function validateObservation(observation) {
|
|
194
267
|
if (!observation.armId.trim())
|
|
195
268
|
throw new Error("a supervision observation needs a non-empty arm identity");
|
|
@@ -202,7 +275,7 @@ function validateObservation(observation) {
|
|
|
202
275
|
// WRITER — a watcher's own call, on its own tier, every SUPERVISION_BEAT_MS. Never a reader's: the
|
|
203
276
|
// purity fence (status --watch leaves the state dir byte-identical) is the test that catches a reader
|
|
204
277
|
// that beats on the watcher's behalf, which would report every dead tier as healthy.
|
|
205
|
-
function writeSupervisionBeat(repoRoot, tier, seat, armId, observation) {
|
|
278
|
+
function writeSupervisionBeat(repoRoot, tier, seat, armId, observation, arm, loop = false) {
|
|
206
279
|
// A seat tier may not be armed anonymously, and the refusal belongs HERE rather than only in the
|
|
207
280
|
// verb: any caller that could write a seatless record could arm a tier nobody occupies.
|
|
208
281
|
if (isSeatTier(tier) && !seat?.trim()) {
|
|
@@ -215,19 +288,42 @@ function writeSupervisionBeat(repoRoot, tier, seat, armId, observation) {
|
|
|
215
288
|
mkdirSync(dirname(p), { recursive: true });
|
|
216
289
|
// Raise before the beat so a later beat failure cannot hide a duty; discharge only after the
|
|
217
290
|
// below-threshold observation exists on disk.
|
|
218
|
-
if (observation !== undefined
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
writeFileSync(p, JSON.stringify({
|
|
291
|
+
if (observation !== undefined)
|
|
292
|
+
raiseIfCrossed(repoRoot, tier, observation);
|
|
293
|
+
atomicRecord(p, {
|
|
222
294
|
tier, ...(seat ? { seat } : {}), ...(armId ? { armId } : {}),
|
|
223
295
|
...(observation !== undefined ? { pct: observation.pct, thresholdPct: observation.thresholdPct } : {}),
|
|
224
|
-
|
|
225
|
-
|
|
296
|
+
...arm,
|
|
297
|
+
...(loop ? { pid: process.pid } : { exitedWriterPid: process.pid }),
|
|
298
|
+
beatAt: new Date().toISOString(),
|
|
299
|
+
});
|
|
226
300
|
if (observation !== undefined)
|
|
227
301
|
dischargeClearObligation(repoRoot, tier, observation);
|
|
228
302
|
}
|
|
229
|
-
export function beatSupervision(repoRoot, tier, seat, observation) {
|
|
230
|
-
|
|
303
|
+
export function beatSupervision(repoRoot, tier, seat, observation, options = {}) {
|
|
304
|
+
// `armId` only NAMES a first legacy arm; an existing arm is never refused for a name mismatch —
|
|
305
|
+
// the recorded id is a UUID no operator can know, and a mismatch is not a stand-down.
|
|
306
|
+
if (isSeatTier(tier) && !seat?.trim()) {
|
|
307
|
+
throw new Error(`${tier} is a per-seat tier — a beat must declare the seat identity it speaks for`);
|
|
308
|
+
}
|
|
309
|
+
if (observation !== undefined)
|
|
310
|
+
validateObservation(observation);
|
|
311
|
+
let arm = options.arm ?? readArm(repoRoot, tier);
|
|
312
|
+
if (!arm) {
|
|
313
|
+
if (markerFence(repoRoot, tier) !== "NONE")
|
|
314
|
+
return observeWithoutBeat(repoRoot, tier, observation);
|
|
315
|
+
arm = newSupervisionArm(repoRoot, tier, options.armId ?? observation?.armId, true);
|
|
316
|
+
// A marker published during initial creation must not be acknowledged implicitly.
|
|
317
|
+
if (arm.markerFence !== "NONE")
|
|
318
|
+
return observeWithoutBeat(repoRoot, tier, observation);
|
|
319
|
+
}
|
|
320
|
+
const current = readArm(repoRoot, tier);
|
|
321
|
+
if (current?.armId !== arm.armId || current.armEpoch !== arm.armEpoch ||
|
|
322
|
+
markerFence(repoRoot, tier) !== arm.markerFence)
|
|
323
|
+
return observeWithoutBeat(repoRoot, tier, observation);
|
|
324
|
+
writeSupervisionBeat(repoRoot, tier, seat, arm.armId, observation, arm, options.loop);
|
|
325
|
+
// The immutable fence travels WITH the beat, so a rename racing stand-down cannot outrank it.
|
|
326
|
+
return markerFence(repoRoot, tier) === arm.markerFence;
|
|
231
327
|
}
|
|
232
328
|
// THE WATCHER-FACING ENTRY POINT — the loop SUPERVISION_BEAT_MS actually drives. A supervising seat
|
|
233
329
|
// calls this once at the top of its watch and holds the handle for the duration; a seat that dies,
|
|
@@ -348,11 +444,12 @@ function readBeat(repoRoot, tier) {
|
|
|
348
444
|
// is not evidence that nobody armed the tier). On a SEAT tier it is the opposite: a record naming no
|
|
349
445
|
// seat leaves the tier armed and unattributable, which reads as coverage no seat is providing, so
|
|
350
446
|
// it is UNREADABLE — something is there and no beat any reader can attribute comes out of it.
|
|
351
|
-
const { seat, armId } = beatMetadata(p);
|
|
447
|
+
const { seat, armId, markerFence } = beatMetadata(p);
|
|
352
448
|
if (isSeatTier(tier) && seat === undefined)
|
|
353
449
|
return "UNREADABLE";
|
|
354
450
|
return {
|
|
355
451
|
mtimeMs: st.mtimeMs,
|
|
452
|
+
...(markerFence !== undefined ? { markerFence } : {}),
|
|
356
453
|
...(seat !== undefined ? { seat } : {}),
|
|
357
454
|
...(armId !== undefined ? { armId } : {}),
|
|
358
455
|
};
|
|
@@ -363,7 +460,8 @@ function beatMetadata(path) {
|
|
|
363
460
|
const rec = JSON.parse(readFileSync(path, "utf8"));
|
|
364
461
|
const seat = typeof rec?.seat === "string" && rec.seat.trim() ? rec.seat : undefined;
|
|
365
462
|
const armId = typeof rec?.armId === "string" && rec.armId.trim() ? rec.armId : undefined;
|
|
366
|
-
|
|
463
|
+
const markerFence = typeof rec?.markerFence === "string" ? rec.markerFence : undefined;
|
|
464
|
+
return { ...(markerFence !== undefined ? { markerFence } : {}), ...(seat !== undefined ? { seat } : {}), ...(armId !== undefined ? { armId } : {}) };
|
|
367
465
|
}
|
|
368
466
|
catch {
|
|
369
467
|
return {};
|
|
@@ -414,8 +512,11 @@ function readStandDown(repoRoot, tier) {
|
|
|
414
512
|
return "UNREADABLE";
|
|
415
513
|
let seat;
|
|
416
514
|
let armId;
|
|
515
|
+
let fence;
|
|
417
516
|
try {
|
|
418
|
-
const
|
|
517
|
+
const bytes = readFileSync(p, "utf8");
|
|
518
|
+
fence = createHash("sha256").update(bytes).digest("hex");
|
|
519
|
+
const rec = JSON.parse(bytes);
|
|
419
520
|
if (rec?.tier !== tier)
|
|
420
521
|
return "UNREADABLE";
|
|
421
522
|
if (typeof rec.disarmedAt !== "string" || Number.isNaN(Date.parse(rec.disarmedAt)))
|
|
@@ -431,25 +532,22 @@ function readStandDown(repoRoot, tier) {
|
|
|
431
532
|
return "UNREADABLE";
|
|
432
533
|
} // unparseable or unreadable bytes — not a stand-down anyone can read
|
|
433
534
|
return {
|
|
434
|
-
mtimeMs: st.mtimeMs,
|
|
535
|
+
mtimeMs: st.mtimeMs, fence,
|
|
435
536
|
...(seat !== undefined ? { seat } : {}),
|
|
436
537
|
...(armId !== undefined ? { armId } : {}),
|
|
437
538
|
};
|
|
438
539
|
}
|
|
439
|
-
//
|
|
440
|
-
//
|
|
441
|
-
//
|
|
442
|
-
// armed-watcher identity is another arm, so a marker whose rename lost that race cannot mask a live
|
|
443
|
-
// watcher. Once that foreign beat is stale, a newer clean hand-off must win: otherwise overlapping
|
|
444
|
-
// boards closed in last-beater-first order would leave the tier reporting a death forever.
|
|
540
|
+
// CLI beats carry the marker generation acknowledged at arm time, never at tick time. A new
|
|
541
|
+
// marker dominates every tick of that arm regardless of rename order or beat mtime. The separate
|
|
542
|
+
// presence-based lifecycle retains overlapping watch owners and their last-owner stand-down.
|
|
445
543
|
export function supervisionStatus(repoRoot, tier, now = Date.now()) {
|
|
446
544
|
const beat = readBeat(repoRoot, tier);
|
|
447
545
|
const standDown = readStandDown(repoRoot, tier);
|
|
448
546
|
if (standDown === "UNREADABLE")
|
|
449
547
|
return withClearObligation(repoRoot, tier, { tier, state: "UNREADABLE" });
|
|
450
|
-
const beatOutranksStandDown = standDown !== "NONE" && typeof beat === "object" && (beat.
|
|
451
|
-
beat.armId !== undefined &&
|
|
452
|
-
|
|
548
|
+
const beatOutranksStandDown = standDown !== "NONE" && typeof beat === "object" && (beat.markerFence !== undefined ? beat.markerFence === standDown.fence :
|
|
549
|
+
beat.armId !== undefined && existsSync(supervisionPresencePath(repoRoot, tier, beat.armId)) && (beat.mtimeMs > standDown.mtimeMs || (now - beat.mtimeMs <= SUPERVISION_STALE_MS &&
|
|
550
|
+
standDown.armId !== undefined && beat.armId !== standDown.armId)));
|
|
453
551
|
if (standDown !== "NONE" && !beatOutranksStandDown) {
|
|
454
552
|
// the seat that stood down is named by the marker, falling back to whatever its last beat named
|
|
455
553
|
const seat = standDown.seat ?? (typeof beat === "object" ? beat.seat : undefined);
|
|
@@ -27,6 +27,10 @@ export interface EvidenceRow {
|
|
|
27
27
|
/** Durable artifact locations this row's detail names (e.g. a saved raw-review capture); empty
|
|
28
28
|
* when none were recorded — rendered as "missing", never silently omitted. */
|
|
29
29
|
readonly artifacts: readonly string[];
|
|
30
|
+
/** The row's execution-evidence receipt, each artifact resolved through the shared resolver against
|
|
31
|
+
* the run root it was minted in (`originRunRoot` for a reused verdict) — reference then verified
|
|
32
|
+
* availability; empty when the row carries no receipt. */
|
|
33
|
+
readonly receipts: readonly string[];
|
|
30
34
|
readonly taskId?: string;
|
|
31
35
|
readonly gate?: string;
|
|
32
36
|
}
|
|
@@ -34,7 +38,10 @@ export interface EvidenceRow {
|
|
|
34
38
|
export declare function parseArtifactLocations(details: string): string[];
|
|
35
39
|
/** Pure C2-journal-to-Evidence-rows mapping: the original journal position IS the evidence identity
|
|
36
40
|
* — assigned once here, from the full unfiltered event list, never recomputed from a display slice. */
|
|
37
|
-
export declare function deriveEvidenceJournal(eventsOrRows: readonly (JournalEvent | TrackedJournalRow)[], source?: string): EvidenceRow[];
|
|
41
|
+
export declare function deriveEvidenceJournal(eventsOrRows: readonly (JournalEvent | TrackedJournalRow)[], source?: string, runRoot?: string): EvidenceRow[];
|
|
42
|
+
/** The live cockpit names its journal by absolute path (`<state>/runs/<runId>/journal.jsonl`); that
|
|
43
|
+
* directory IS the run root. A relative or foreign source names no root — nothing is guessed. */
|
|
44
|
+
export declare function runRootOf(source: string | undefined): string | undefined;
|
|
38
45
|
/** The most recent review verdict recorded for a task — by original journal position, so a merged
|
|
39
46
|
* task's review opens the row it actually happened on rather than any on-screen index. */
|
|
40
47
|
export declare function selectTaskReview(journal: readonly EvidenceRow[], taskId: string): EvidenceRow | undefined;
|
|
@@ -42,6 +49,8 @@ export interface EvidenceViewInput {
|
|
|
42
49
|
readonly events?: readonly JournalEvent[];
|
|
43
50
|
readonly rows?: readonly TrackedJournalRow[];
|
|
44
51
|
readonly source?: string;
|
|
52
|
+
/** The displayed run's root (its journal directory); receipts minted by this run resolve under it. */
|
|
53
|
+
readonly runRoot?: string;
|
|
45
54
|
readonly costs?: readonly ChannelCost[];
|
|
46
55
|
/** Caller-supplied Report-tab text (e.g. from `renderMarkdownRecord`/`textReport`) — kept as a pure
|
|
47
56
|
* input so this leaf never reads config/telemetry itself. */
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
-
import { existsSync, renameSync, statSync, unlinkSync } from "node:fs";
|
|
3
|
-
import { basename, dirname, join } from "node:path";
|
|
2
|
+
import { existsSync, realpathSync, renameSync, statSync, unlinkSync } from "node:fs";
|
|
3
|
+
import { basename, dirname, isAbsolute, join } from "node:path";
|
|
4
4
|
import { Box, useInput } from "ink";
|
|
5
5
|
import { useEffect, useRef, useState } from "react";
|
|
6
6
|
import { buildOperatorRecord, formatOperatorRecordRow } from "../../report/operator-record.js";
|
|
7
7
|
import { EMPTY_OPERATOR_PAGE_SUMMARY, foldOperatorPages, operatorPageRow } from "../../run/operator-page-summary.js";
|
|
8
8
|
import { formatJournalNarration } from "../../run/journal.js";
|
|
9
|
+
import { formatReceiptResolution, resolveReceipt } from "../../run/receipt-resolver.js";
|
|
9
10
|
import { BodyText, JournalRowPanel, Panel } from "./components.js";
|
|
10
11
|
import { cellWidth, sliceCells } from "./width.js";
|
|
11
12
|
// review.ts:485 emits `; raw saved: <path>) — failing closed`, the path immediately followed by
|
|
@@ -30,7 +31,7 @@ function stateFor(e) {
|
|
|
30
31
|
}
|
|
31
32
|
/** Pure C2-journal-to-Evidence-rows mapping: the original journal position IS the evidence identity
|
|
32
33
|
* — assigned once here, from the full unfiltered event list, never recomputed from a display slice. */
|
|
33
|
-
export function deriveEvidenceJournal(eventsOrRows, source = "journal.jsonl") {
|
|
34
|
+
export function deriveEvidenceJournal(eventsOrRows, source = "journal.jsonl", runRoot) {
|
|
34
35
|
const result = [];
|
|
35
36
|
for (let i = 0; i < eventsOrRows.length; i++) {
|
|
36
37
|
const item = eventsOrRows[i];
|
|
@@ -61,6 +62,7 @@ export function deriveEvidenceJournal(eventsOrRows, source = "journal.jsonl") {
|
|
|
61
62
|
artifacts.push(p);
|
|
62
63
|
}
|
|
63
64
|
const gate = typeof e.data?.gate === "string" ? e.data.gate : (e.event === "review-leg2" ? "review" : undefined);
|
|
65
|
+
const receipts = resolveRowReceipt(e.data, runRoot ?? runRootOf(rowSource));
|
|
64
66
|
result.push({
|
|
65
67
|
evidence: { source: rowSource, line, id },
|
|
66
68
|
time: e.ts,
|
|
@@ -68,12 +70,42 @@ export function deriveEvidenceJournal(eventsOrRows, source = "journal.jsonl") {
|
|
|
68
70
|
text: formatJournalNarration(e),
|
|
69
71
|
fullText: details || JSON.stringify(e.data, null, 2),
|
|
70
72
|
artifacts,
|
|
73
|
+
receipts,
|
|
71
74
|
...(e.taskId ? { taskId: e.taskId } : {}),
|
|
72
75
|
...(gate ? { gate } : {}),
|
|
73
76
|
});
|
|
74
77
|
}
|
|
75
78
|
return result;
|
|
76
79
|
}
|
|
80
|
+
/** The live cockpit names its journal by absolute path (`<state>/runs/<runId>/journal.jsonl`); that
|
|
81
|
+
* directory IS the run root. A relative or foreign source names no root — nothing is guessed. */
|
|
82
|
+
export function runRootOf(source) {
|
|
83
|
+
return source && isAbsolute(source) && basename(source) === "journal.jsonl" ? dirname(source) : undefined;
|
|
84
|
+
}
|
|
85
|
+
// A reused verdict's receipt stays bound to the run root that minted it; the current run's root
|
|
86
|
+
// answers only for evidence this run produced. No root known → nothing can be verified. A receipt
|
|
87
|
+
// the schema rejects is still a receipt the row carries: each reference it names goes through the
|
|
88
|
+
// resolver and is shown with its reason, never dropped. D-279: the boundary is the run root — the
|
|
89
|
+
// host's ancestors are canonicalised (macOS `/var` → `/private/var`) but the root's own name is
|
|
90
|
+
// passed as recorded, so a root that IS a symlink stays visible and the resolver refuses it.
|
|
91
|
+
function resolveRowReceipt(data, runRoot) {
|
|
92
|
+
const receipt = data?.evidenceReceipt;
|
|
93
|
+
if (!receipt || typeof receipt !== "object")
|
|
94
|
+
return [];
|
|
95
|
+
const refs = [receipt.stdout, receipt.stderr];
|
|
96
|
+
const recorded = typeof data?.originRunRoot === "string" && data.originRunRoot ? data.originRunRoot : runRoot;
|
|
97
|
+
let root;
|
|
98
|
+
try {
|
|
99
|
+
root = recorded ? join(realpathSync(dirname(recorded)), basename(recorded)) : undefined;
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
root = undefined;
|
|
103
|
+
}
|
|
104
|
+
return refs.map(ref => {
|
|
105
|
+
const path = typeof ref?.path === "string" ? ref.path : "";
|
|
106
|
+
return formatReceiptResolution(root ? resolveReceipt(ref, root) : { ok: false, path, reason: "missing" });
|
|
107
|
+
});
|
|
108
|
+
}
|
|
77
109
|
/** The most recent review verdict recorded for a task — by original journal position, so a merged
|
|
78
110
|
* task's review opens the row it actually happened on rather than any on-screen index. */
|
|
79
111
|
export function selectTaskReview(journal, taskId) {
|
|
@@ -100,7 +132,7 @@ export function deriveEvidenceView(input) {
|
|
|
100
132
|
const row = operatorPageRow(event, tracked ? item.line : index + 1, tracked ? item.source ?? source : source);
|
|
101
133
|
return row ? [row] : [];
|
|
102
134
|
})).groups,
|
|
103
|
-
journal: deriveEvidenceJournal(items, source),
|
|
135
|
+
journal: deriveEvidenceJournal(items, source, input.runRoot),
|
|
104
136
|
channels: buildOperatorRecord(events, input.costs ?? []),
|
|
105
137
|
reportLines: input.reportLines ?? [],
|
|
106
138
|
statsLines: input.statsLines ?? [],
|
|
@@ -229,7 +261,7 @@ export function EvidenceView({ model, width, focused = true, focusEvidence, onSe
|
|
|
229
261
|
return { id: row.evidence.id, time, state: row.state, text: clipped };
|
|
230
262
|
});
|
|
231
263
|
return (_jsxs(Box, { flexDirection: "column", width: width, children: [_jsxs(BodyText, { emphasis: "strong", children: ["EVIDENCE ", TABS.map((t) => (t === tab ? `[${TAB_TITLE[t]}]` : TAB_TITLE[t])).join(" "), tab === "journal" ? ` | Follow ${follow ? "on" : "off"}` : "", selectedRow ? ` | selected ${selectedRow.evidence.id}` : ""] }), tab === "journal" && (_jsxs(_Fragment, { children: [_jsx(JournalRowPanel, { title: "RAW JOURNAL", rows: journalRows, selection: selectedIndex >= 0 ? selectedIndex : undefined }), _jsx(Panel, { title: "OPERATOR PAGE GROUPS", children: model.operatorPages.length ? model.operatorPages.map((group, index) => (_jsxs(Box, { flexDirection: "column", children: [_jsxs(BodyText, { emphasis: "strong", children: [group.taskId, " \u00B7 ", group.park, " \u00B7 ", group.status] }), _jsxs(BodyText, { children: ["first ", group.firstEvidenceAt] }), _jsxs(BodyText, { children: ["last ", group.lastEvidenceAt] }), _jsxs(BodyText, { children: ["visible ", group.observedCount, " \u00B7 suppressed ", group.suppressedCount, group.rawOnly ? " · raw-only (suppression unrecorded)" : ""] }), _jsx(BodyText, { children: "Raw source lines (\u2191\u2193 select, Enter open):" }), group.lines.map(line => _jsxs(BodyText, { children: ["#L", line] }, line))] }, `${group.key}:${index}`))) : _jsx(BodyText, { emphasis: "dim", children: "no operator pages" }) }), _jsx(Panel, { title: "SELECTED EVIDENCE", children: selectedRow
|
|
232
|
-
? (_jsxs(_Fragment, { children: [_jsxs(BodyText, { emphasis: "dim", children: ["Original journal ", selectedRow.evidence.id] }), selectedRow.fullText.split("\n").map((line, i) => _jsx(BodyText, { children: line }, i)), _jsxs(BodyText, { emphasis: "dim", children: ["Artifacts: ", selectedRow.artifacts.length ? selectedRow.artifacts.join(", ") : "missing"] })] }))
|
|
264
|
+
? (_jsxs(_Fragment, { children: [_jsxs(BodyText, { emphasis: "dim", children: ["Original journal ", selectedRow.evidence.id] }), selectedRow.fullText.split("\n").map((line, i) => _jsx(BodyText, { children: line }, i)), _jsxs(BodyText, { emphasis: "dim", children: ["Artifacts: ", selectedRow.artifacts.length ? selectedRow.artifacts.join(", ") : "missing"] }), selectedRow.receipts.map((line, i) => _jsxs(BodyText, { emphasis: "dim", children: ["Receipt: ", line] }, `r${i}`))] }))
|
|
233
265
|
: _jsx(BodyText, { emphasis: "dim", children: "no history" }) })] })), tab === "report" && (_jsx(Panel, { title: "REPORT", children: model.reportLines.length
|
|
234
266
|
? model.reportLines.map((line, i) => _jsx(BodyText, { children: line }, i))
|
|
235
267
|
: _jsx(BodyText, { emphasis: "dim", children: "no history" }) })), tab === "stats" && (_jsx(Panel, { title: "STATS", children: model.statsLines.length
|
|
@@ -22,6 +22,10 @@ export interface ShellDelivery {
|
|
|
22
22
|
diagnostics: LiveStore["diagnostics"];
|
|
23
23
|
stage: (edits: readonly string[]) => void;
|
|
24
24
|
geometry: () => ShellCommit | undefined;
|
|
25
|
+
/** Application render commits since mount (OBS-1132: an idle board adds none). */
|
|
26
|
+
frames: () => number;
|
|
27
|
+
/** Model derivations (the App body) since mount. */
|
|
28
|
+
derivations: () => number;
|
|
25
29
|
}
|
|
26
30
|
export interface ConsolidatedOptions {
|
|
27
31
|
input: NodeJS.ReadStream;
|