tickmarkr 2.4.2 → 2.5.1
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 +110 -24
- package/dist/adapters/fake.d.ts +1 -0
- package/dist/adapters/fake.js +2 -1
- package/dist/adapters/opencode.d.ts +1 -0
- package/dist/adapters/opencode.js +4 -1
- package/dist/adapters/types.d.ts +14 -0
- package/dist/adapters/types.js +25 -0
- package/dist/cli/commands/approve.d.ts +42 -0
- package/dist/cli/commands/approve.js +80 -13
- package/dist/cli/commands/doctor.d.ts +234 -0
- package/dist/cli/commands/doctor.js +139 -5
- package/dist/cli/commands/init.d.ts +2 -0
- package/dist/cli/commands/init.js +45 -5
- package/dist/cli/commands/plan.js +58 -6
- package/dist/cli/commands/report.js +18 -45
- package/dist/cli/commands/run.js +3 -0
- package/dist/cli/commands/scope.js +36 -6
- package/dist/cli/commands/stats.d.ts +2 -0
- package/dist/cli/commands/stats.js +42 -23
- package/dist/cli/commands/status.js +20 -4
- package/dist/cli/commands/ui.js +42 -53
- package/dist/cli/commands/unlock.d.ts +1 -1
- package/dist/cli/commands/unlock.js +59 -9
- package/dist/cli/help.d.ts +219 -0
- package/dist/cli/help.js +212 -0
- package/dist/cli/index.d.ts +42 -2
- package/dist/cli/index.js +23 -8
- package/dist/drivers/herdr.d.ts +7 -2
- package/dist/drivers/herdr.js +78 -48
- package/dist/drivers/orca.d.ts +3 -2
- package/dist/drivers/orca.js +43 -4
- package/dist/drivers/subprocess.d.ts +1 -0
- package/dist/drivers/subprocess.js +3 -0
- package/dist/drivers/types.d.ts +14 -0
- package/dist/gates/artifact-manifest.d.ts +50 -0
- package/dist/gates/artifact-manifest.js +23 -0
- package/dist/gates/llm.d.ts +5 -0
- package/dist/gates/llm.js +188 -3
- package/dist/gates/review.d.ts +6 -3
- package/dist/gates/review.js +79 -33
- package/dist/gates/run-gates.d.ts +3 -0
- package/dist/gates/run-gates.js +14 -4
- package/dist/plan/scope.d.ts +25 -0
- package/dist/plan/scope.js +92 -12
- package/dist/report/operator-record.d.ts +49 -0
- package/dist/report/operator-record.js +137 -0
- package/dist/run/daemon.d.ts +5 -5
- package/dist/run/daemon.js +166 -44
- package/dist/run/lock.d.ts +59 -2
- package/dist/run/lock.js +184 -26
- package/dist/run/operator-state.d.ts +86 -0
- package/dist/run/operator-state.js +165 -0
- package/dist/run/supervision.d.ts +32 -0
- package/dist/run/supervision.js +138 -17
- package/dist/tui/cockpit/capture.d.ts +19 -0
- package/dist/tui/cockpit/capture.js +89 -1
- package/dist/tui/cockpit/components.d.ts +15 -1
- package/dist/tui/cockpit/components.js +79 -9
- package/dist/tui/cockpit/decision-actions.d.ts +147 -0
- package/dist/tui/cockpit/decision-actions.js +315 -0
- package/dist/tui/cockpit/derive.d.ts +1 -1
- package/dist/tui/cockpit/derive.js +2 -0
- package/dist/tui/cockpit/evidence-view.d.ts +119 -0
- package/dist/tui/cockpit/evidence-view.js +210 -0
- package/dist/tui/cockpit/home-view.d.ts +88 -0
- package/dist/tui/cockpit/home-view.js +240 -0
- package/dist/tui/cockpit/keys.d.ts +125 -0
- package/dist/tui/cockpit/keys.js +31 -0
- package/dist/tui/cockpit/layout.d.ts +14 -0
- package/dist/tui/cockpit/layout.js +15 -0
- package/dist/tui/cockpit/live-runtime.d.ts +46 -0
- package/dist/tui/cockpit/live-runtime.js +683 -0
- package/dist/tui/cockpit/live-store.d.ts +289 -0
- package/dist/tui/cockpit/live-store.js +308 -0
- package/dist/tui/cockpit/live.d.ts +21 -1
- package/dist/tui/cockpit/live.js +12 -1
- package/dist/tui/cockpit/run-view.d.ts +100 -0
- package/dist/tui/cockpit/run-view.js +202 -0
- package/dist/tui/cockpit/shell.d.ts +50 -0
- package/dist/tui/cockpit/shell.js +74 -0
- package/dist/tui/cockpit/theme.d.ts +27 -0
- package/dist/tui/cockpit/theme.js +21 -0
- package/dist/tui/ink/init-app.d.ts +4 -0
- package/dist/tui/ink/init-app.js +18 -7
- package/package.json +1 -1
- package/skills/tickmarkr-auto/SKILL.md +10 -1
- package/skills/tickmarkr-loop/SKILL.md +72 -1
- package/skills/tickmarkr-overseer/SKILL.md +12 -0
- package/skills/tickmarkr-overseer/scripts/watch-context.sh +13 -2
package/dist/run/lock.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { linkSync, readFileSync, statSync, unlinkSync, utimesSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { closeSync, constants, fstatSync, linkSync, lstatSync, mkdtempSync, openSync, readFileSync, renameSync, rmdirSync, statSync, unlinkSync, utimesSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { tickmarkrDir, stateDirName } from "../graph/graph.js";
|
|
@@ -28,9 +28,10 @@ let approvalSequence = 0;
|
|
|
28
28
|
process.once("exit", () => { if (heldPath)
|
|
29
29
|
unlinkIfOurs(heldPath); });
|
|
30
30
|
// LOCK-04: the ONE decision table. Both acquireRunLock and isRunLockLive consume this — the two
|
|
31
|
-
// hand-maintained copies of the rule cannot drift. The table changes HERE, once.
|
|
32
|
-
//
|
|
33
|
-
// a second copy of
|
|
31
|
+
// hand-maintained copies of the rule cannot drift. The table changes HERE, once. previewUnlock /
|
|
32
|
+
// commitUnlock (below) read the SAME underlying snapshot shape with their own eligibility rules
|
|
33
|
+
// (matching run ID, provably dead, not garbage) — they are the escape hatch, not a second copy of
|
|
34
|
+
// the rule.
|
|
34
35
|
// LOCK-02 (OBS-05): refuse iff garbage OR alive. dead (ESRCH) self-clears through the reclaim branch
|
|
35
36
|
// below — no 60s heartbeat wait. Expiry no longer participates in the DECISION (Inspect still carries
|
|
36
37
|
// `expired` for the race guard + reclaim audit); the heartbeat mechanism itself is untouched.
|
|
@@ -139,10 +140,10 @@ export function acquireRunLock(repoRoot, runId) {
|
|
|
139
140
|
}
|
|
140
141
|
const stateDir = stateDirName(repoRoot);
|
|
141
142
|
if (garbage)
|
|
142
|
-
throw new Error(`${stateDir}/graph.lock holds an unreadable/garbage payload — refusing to reclaim it; run \`tickmarkr unlock\` to remove it`);
|
|
143
|
+
throw new Error(`${stateDir}/graph.lock holds an unreadable/garbage payload — refusing to reclaim it; run \`tickmarkr unlock --garbage\` to remove it`);
|
|
143
144
|
// LOCK-02: shouldRefuse is false whenever dead, so this throw is reached only for a LIVE holder
|
|
144
145
|
// (incl. EPERM = alive-but-not-ours). The dead-but-fresh case self-clears via the reclaim branch.
|
|
145
|
-
throw new Error(`${stateDir}/graph.lock held by pid ${pid ?? "?"}${heldRun ? ` (run ${heldRun})` : ""} — another tickmarkr run? (operator escape: \`tickmarkr unlock\`)`);
|
|
146
|
+
throw new Error(`${stateDir}/graph.lock held by pid ${pid ?? "?"}${heldRun ? ` (run ${heldRun})` : ""} — another tickmarkr run? (operator escape: \`tickmarkr unlock ${heldRun ?? "<run-id>"}\`)`);
|
|
146
147
|
}
|
|
147
148
|
}
|
|
148
149
|
export function releaseRunLock(repoRoot) {
|
|
@@ -293,32 +294,189 @@ export function runStatusLine(repoRoot, runId, events) {
|
|
|
293
294
|
export function isRunLockLive(repoRoot) {
|
|
294
295
|
return runLockOwner(repoRoot)?.live ?? false;
|
|
295
296
|
}
|
|
296
|
-
//
|
|
297
|
-
//
|
|
298
|
-
//
|
|
299
|
-
//
|
|
300
|
-
//
|
|
301
|
-
//
|
|
302
|
-
//
|
|
303
|
-
//
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
297
|
+
// Unlike inspect() (owned by the acquire/reclaim path this task must not change), a stat failure
|
|
298
|
+
// here is never silently read as "no lock" — only ENOENT is. Any other stat error (e.g. EACCES)
|
|
299
|
+
// propagates so the CLI fails closed and loud instead of returning the neutral "nothing to remove"
|
|
300
|
+
// receipt over a lock it could not actually see (R41).
|
|
301
|
+
//
|
|
302
|
+
// A content READ failure (stat succeeds, the bytes don't — e.g. EACCES) is its OWN `unreadable`
|
|
303
|
+
// state, distinct from `garbage`: we never observed the bytes, so we cannot confirm the payload is
|
|
304
|
+
// malformed, and a well-formed live lock made merely unreadable must never become eligible for
|
|
305
|
+
// either recovery route. `raw` stays a Buffer (never decoded) for identity: decoding invalid UTF-8
|
|
306
|
+
// collapses distinct byte sequences to the same replacement-character string, which would let a
|
|
307
|
+
// changed-bytes attack slip past a text-equality check (AC2's byte-confirmation requirement).
|
|
308
|
+
function readLockSnapshot(p) {
|
|
309
|
+
let st;
|
|
307
310
|
try {
|
|
308
|
-
|
|
311
|
+
st = lstatSync(p);
|
|
309
312
|
}
|
|
310
|
-
catch {
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
throw new Error(`${stateDirName(repoRoot)}/graph.lock held by LIVE pid ${insp.pid}${insp.runId ? ` (run ${insp.runId})` : ""} — refusing to unlock; stop that run first`);
|
|
313
|
+
catch (e) {
|
|
314
|
+
if (e.code === "ENOENT")
|
|
315
|
+
return undefined;
|
|
316
|
+
throw e;
|
|
315
317
|
}
|
|
318
|
+
const unreadable = () => ({ garbage: false, unreadable: true, dead: true, ino: st.ino, mtimeMs: st.mtimeMs, raw: Buffer.alloc(0) });
|
|
319
|
+
if (!st.isFile())
|
|
320
|
+
return unreadable();
|
|
321
|
+
let raw;
|
|
322
|
+
let fd;
|
|
316
323
|
try {
|
|
317
|
-
|
|
324
|
+
// Read bytes and inode from the same open file. Refuse symlinks/non-files even if the path
|
|
325
|
+
// changes after lstat; NONBLOCK prevents a substituted FIFO from hanging the command.
|
|
326
|
+
fd = openSync(p, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
327
|
+
st = fstatSync(fd);
|
|
328
|
+
if (!st.isFile())
|
|
329
|
+
return unreadable();
|
|
330
|
+
raw = readFileSync(fd);
|
|
318
331
|
}
|
|
319
332
|
catch (e) {
|
|
320
|
-
if (e.code
|
|
333
|
+
if (e.code === "ENOENT")
|
|
334
|
+
return undefined; // raced away between stat and read
|
|
335
|
+
return unreadable();
|
|
336
|
+
}
|
|
337
|
+
finally {
|
|
338
|
+
if (fd !== undefined)
|
|
339
|
+
closeSync(fd);
|
|
340
|
+
}
|
|
341
|
+
let parsedJson = null;
|
|
342
|
+
try {
|
|
343
|
+
parsedJson = JSON.parse(raw.toString("utf8"));
|
|
344
|
+
}
|
|
345
|
+
catch { /* not JSON ⇒ garbage below */ }
|
|
346
|
+
const parsed = PayloadSchema.safeParse(parsedJson);
|
|
347
|
+
const pid = parsed.success ? parsed.data.pid : undefined;
|
|
348
|
+
return {
|
|
349
|
+
pid,
|
|
350
|
+
runId: parsed.success ? parsed.data.runId : undefined,
|
|
351
|
+
garbage: !parsed.success,
|
|
352
|
+
unreadable: false,
|
|
353
|
+
dead: pid === undefined ? true : !isPidLive(pid),
|
|
354
|
+
ino: st.ino,
|
|
355
|
+
mtimeMs: st.mtimeMs,
|
|
356
|
+
raw,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
// rename is the single capture operation, not a snapshot followed by an unlink of graph.lock.
|
|
360
|
+
// mkdtemp gives this commit a private destination on the same filesystem. All validation and
|
|
361
|
+
// deletion happens there, so a later acquisition at graph.lock survives even during the read.
|
|
362
|
+
function commitCapturedLock(repoRoot, validate) {
|
|
363
|
+
const p = lockPath(repoRoot);
|
|
364
|
+
const dir = mkdtempSync(`${p}.unlock-`);
|
|
365
|
+
const captured = join(dir, "graph.lock");
|
|
366
|
+
const restore = (reason) => {
|
|
367
|
+
try {
|
|
368
|
+
// Never rename back: that would clobber a successor installed while we validated.
|
|
369
|
+
linkSync(captured, p);
|
|
370
|
+
}
|
|
371
|
+
catch (e) {
|
|
372
|
+
return { removed: false, reason: `${reason}; could not restore graph.lock (${e.code}) — captured entry retained at ${captured}` };
|
|
373
|
+
}
|
|
374
|
+
try {
|
|
375
|
+
unlinkSync(captured);
|
|
376
|
+
}
|
|
377
|
+
catch (e) {
|
|
378
|
+
if (e.code !== "ENOENT") {
|
|
379
|
+
return { removed: false, reason: `${reason}; graph.lock restored, additional captured link retained at ${captured}: ${e.message}` };
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return { removed: false, reason };
|
|
383
|
+
};
|
|
384
|
+
try {
|
|
385
|
+
try {
|
|
386
|
+
renameSync(p, captured);
|
|
387
|
+
}
|
|
388
|
+
catch (e) {
|
|
389
|
+
if (e.code === "ENOENT")
|
|
390
|
+
return { removed: false, reason: "graph.lock no longer exists — nothing captured or removed" };
|
|
321
391
|
throw e;
|
|
392
|
+
}
|
|
393
|
+
try {
|
|
394
|
+
const snap = readLockSnapshot(captured);
|
|
395
|
+
if (!snap)
|
|
396
|
+
return { removed: false, reason: "captured graph.lock no longer exists — not removed" };
|
|
397
|
+
const reason = validate(snap);
|
|
398
|
+
if (reason)
|
|
399
|
+
return restore(reason);
|
|
400
|
+
unlinkSync(captured);
|
|
401
|
+
return { removed: true, snap };
|
|
402
|
+
}
|
|
403
|
+
catch (e) {
|
|
404
|
+
if (e.code === "ENOENT")
|
|
405
|
+
return { removed: false, reason: "captured graph.lock no longer exists — not removed" };
|
|
406
|
+
return restore(`graph.lock could not be removed: ${e.message}`);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
finally {
|
|
410
|
+
// Best-effort empty-directory cleanup only. Never recursively delete a refused capture that
|
|
411
|
+
// could not be restored, or obscure its recovery path with a cleanup error.
|
|
412
|
+
try {
|
|
413
|
+
rmdirSync(dir);
|
|
414
|
+
}
|
|
415
|
+
catch { /* retained capture or directory cleanup failure */ }
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
// AC1: eligible only for a matching-run, provably-dead, non-garbage lock. Live, EPERM and any other
|
|
419
|
+
// signal-probe error all read `dead: false` through the one shared isPidLive predicate — never a
|
|
420
|
+
// second copy that would treat an unknown errno as death. A different run ID refuses even when the
|
|
421
|
+
// holder is dead: the operator named a run, and this is not it.
|
|
422
|
+
export function previewUnlock(repoRoot, runId) {
|
|
423
|
+
const snap = readLockSnapshot(lockPath(repoRoot));
|
|
424
|
+
if (!snap)
|
|
425
|
+
return { held: false };
|
|
426
|
+
const stateDir = stateDirName(repoRoot);
|
|
427
|
+
if (snap.unreadable) {
|
|
428
|
+
return { held: true, eligible: false, reason: `${stateDir}/graph.lock content could not be read — refusing to unlock without observing its payload` };
|
|
429
|
+
}
|
|
430
|
+
if (snap.garbage) {
|
|
431
|
+
return { held: true, eligible: false, reason: `${stateDir}/graph.lock holds an unreadable/garbage payload — run \`tickmarkr unlock --garbage\` to recover it, not a named unlock`, pid: snap.pid, runId: snap.runId };
|
|
432
|
+
}
|
|
433
|
+
if (snap.runId !== runId) {
|
|
434
|
+
return { held: true, eligible: false, reason: `${stateDir}/graph.lock is held for run ${snap.runId ?? "?"}, not ${runId} — refusing to unlock a different run`, pid: snap.pid, runId: snap.runId };
|
|
435
|
+
}
|
|
436
|
+
if (!snap.dead) {
|
|
437
|
+
return { held: true, eligible: false, reason: `${stateDir}/graph.lock held by LIVE pid ${snap.pid}${snap.runId ? ` (run ${snap.runId})` : ""} — refusing to unlock; stop that run first`, pid: snap.pid, runId: snap.runId };
|
|
438
|
+
}
|
|
439
|
+
return { held: true, eligible: true, pid: snap.pid, runId: snap.runId, ino: snap.ino, mtimeMs: snap.mtimeMs, raw: snap.raw };
|
|
440
|
+
}
|
|
441
|
+
export function commitUnlock(repoRoot, target) {
|
|
442
|
+
const commit = commitCapturedLock(repoRoot, (snap) => {
|
|
443
|
+
if (snap.unreadable || snap.garbage || snap.ino !== target.ino || snap.mtimeMs !== target.mtimeMs || snap.pid !== target.pid || !snap.raw.equals(target.raw)) {
|
|
444
|
+
return "graph.lock changed since preview — refusing to remove the new holder";
|
|
445
|
+
}
|
|
446
|
+
if (snap.runId !== target.runId)
|
|
447
|
+
return `graph.lock now names run ${snap.runId ?? "?"}, not ${target.runId}`;
|
|
448
|
+
if (!snap.dead)
|
|
449
|
+
return `holder pid ${snap.pid} is alive`;
|
|
450
|
+
});
|
|
451
|
+
if (!commit.removed)
|
|
452
|
+
return commit;
|
|
453
|
+
return { removed: true, pid: commit.snap.pid, runId: commit.snap.runId };
|
|
454
|
+
}
|
|
455
|
+
// AC2: eligible only for an ACTUALLY malformed snapshot — a well-formed payload (dead or live) is
|
|
456
|
+
// refused here and pointed at the ordinary named route instead, so --garbage can never become a
|
|
457
|
+
// second, run-ID-free way to remove a legitimate lock. An unreadable payload is refused too: its
|
|
458
|
+
// bytes were never observed, so it is neither confirmed malformed nor confirmed well-formed.
|
|
459
|
+
export function previewGarbageUnlock(repoRoot) {
|
|
460
|
+
const snap = readLockSnapshot(lockPath(repoRoot));
|
|
461
|
+
if (!snap)
|
|
462
|
+
return { held: false };
|
|
463
|
+
if (snap.unreadable) {
|
|
464
|
+
return { held: true, eligible: false, reason: `${stateDirName(repoRoot)}/graph.lock content could not be read — refusing to treat it as garbage without observing its bytes` };
|
|
322
465
|
}
|
|
323
|
-
|
|
466
|
+
if (!snap.garbage) {
|
|
467
|
+
return { held: true, eligible: false, reason: `${stateDirName(repoRoot)}/graph.lock holds a well-formed payload — run \`tickmarkr unlock ${snap.runId ?? "<run-id>"}\` instead of --garbage`, pid: snap.pid, runId: snap.runId };
|
|
468
|
+
}
|
|
469
|
+
return { held: true, eligible: true, ino: snap.ino, raw: snap.raw };
|
|
470
|
+
}
|
|
471
|
+
export function commitGarbageUnlock(repoRoot, target) {
|
|
472
|
+
const commit = commitCapturedLock(repoRoot, (snap) => {
|
|
473
|
+
if (snap.unreadable)
|
|
474
|
+
return "graph.lock content became unreadable since preview — refusing to remove it blind";
|
|
475
|
+
if (!snap.garbage)
|
|
476
|
+
return "graph.lock now holds a well-formed payload — refusing to remove a valid holder";
|
|
477
|
+
if (snap.ino !== target.ino || !snap.raw.equals(target.raw)) {
|
|
478
|
+
return "graph.lock bytes/inode changed since preview — refusing to remove the new file";
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
return commit.removed ? { removed: true } : commit;
|
|
324
482
|
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { type RunGraph } from "../graph/schema.js";
|
|
2
|
+
import { type JournalEvent } from "./journal.js";
|
|
3
|
+
/** An evidence identity is a physical journal line, never a filtered row ordinal. */
|
|
4
|
+
export interface EvidenceIdentity {
|
|
5
|
+
source: string;
|
|
6
|
+
line: number;
|
|
7
|
+
id: string;
|
|
8
|
+
generation?: number;
|
|
9
|
+
}
|
|
10
|
+
export interface OperatorRecord extends EvidenceIdentity {
|
|
11
|
+
event: JournalEvent;
|
|
12
|
+
}
|
|
13
|
+
export type OperatorTaskState = "unknown" | "pending" | "running" | "completed" | "merged" | "failed" | "human" | "blocked";
|
|
14
|
+
export type OperatorGateState = "unknown" | "not-run" | "disabled" | "running" | "passed" | "failed";
|
|
15
|
+
export interface OperatorGate {
|
|
16
|
+
state: OperatorGateState;
|
|
17
|
+
evidence?: EvidenceIdentity;
|
|
18
|
+
}
|
|
19
|
+
export interface OperatorTask {
|
|
20
|
+
id: string;
|
|
21
|
+
title?: string;
|
|
22
|
+
state: OperatorTaskState;
|
|
23
|
+
merged: boolean;
|
|
24
|
+
dispatches: number;
|
|
25
|
+
attempt?: number;
|
|
26
|
+
path?: string;
|
|
27
|
+
pane?: string;
|
|
28
|
+
alarmMs?: number;
|
|
29
|
+
parkKind?: string;
|
|
30
|
+
evidence?: EvidenceIdentity;
|
|
31
|
+
mergeEvidence?: EvidenceIdentity;
|
|
32
|
+
gates: Record<string, OperatorGate>;
|
|
33
|
+
}
|
|
34
|
+
export interface OperatorSnapshot {
|
|
35
|
+
sequence: number;
|
|
36
|
+
observedAt: number;
|
|
37
|
+
lastEventAt?: string;
|
|
38
|
+
lifecycle: "PENDING" | "RUNNING" | "PARTIAL" | "APPROVED" | "COMPLETE" | "UNKNOWN";
|
|
39
|
+
label: string;
|
|
40
|
+
green: boolean;
|
|
41
|
+
currentTip: "passed" | "not required" | "pending" | "failed" | "unknown";
|
|
42
|
+
comparable: boolean;
|
|
43
|
+
comparison: "matching graph" | "not comparable";
|
|
44
|
+
merged: number;
|
|
45
|
+
planned?: number;
|
|
46
|
+
tasks: readonly OperatorTask[];
|
|
47
|
+
buckets: Record<"failed" | "human" | "blocked" | "pending", readonly string[] | undefined>;
|
|
48
|
+
gatesRan: {
|
|
49
|
+
passed: number;
|
|
50
|
+
total: number;
|
|
51
|
+
};
|
|
52
|
+
latestRunEnd?: EvidenceIdentity;
|
|
53
|
+
approvedResumeRequired: boolean;
|
|
54
|
+
}
|
|
55
|
+
/** Incremental pure lifecycle fold. Retains facts per task/gate, never verdict bodies or event history. */
|
|
56
|
+
export declare class OperatorStateFold {
|
|
57
|
+
private tasks;
|
|
58
|
+
private start?;
|
|
59
|
+
private startEvent?;
|
|
60
|
+
private latestGraphRehash?;
|
|
61
|
+
private end?;
|
|
62
|
+
private active;
|
|
63
|
+
private approved;
|
|
64
|
+
private tipFailed;
|
|
65
|
+
private lastEventAt?;
|
|
66
|
+
private passed;
|
|
67
|
+
private total;
|
|
68
|
+
apply(record: OperatorRecord): void;
|
|
69
|
+
private task;
|
|
70
|
+
snapshot({ graph, sequence, observedAt, readable }?: {
|
|
71
|
+
graph?: RunGraph;
|
|
72
|
+
sequence?: number;
|
|
73
|
+
observedAt?: number;
|
|
74
|
+
readable?: boolean;
|
|
75
|
+
}): OperatorSnapshot;
|
|
76
|
+
private comparableTo;
|
|
77
|
+
}
|
|
78
|
+
/** C1/C6 share this pure reader; callers supply the same observation and journal snapshot. */
|
|
79
|
+
export declare function readOperatorState({ events, source, ...options }: {
|
|
80
|
+
events: readonly (JournalEvent | OperatorRecord)[];
|
|
81
|
+
source?: string;
|
|
82
|
+
graph?: RunGraph;
|
|
83
|
+
sequence?: number;
|
|
84
|
+
observedAt?: number;
|
|
85
|
+
readable?: boolean;
|
|
86
|
+
}): OperatorSnapshot;
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { graphDefinitionHash } from "../graph/graph.js";
|
|
2
|
+
import { GATE_NAMES } from "../graph/schema.js";
|
|
3
|
+
import { engagementComparable } from "./journal.js";
|
|
4
|
+
const bucketNames = ["failed", "human", "blocked", "pending"];
|
|
5
|
+
const strings = (value) => Array.isArray(value) && value.every(v => typeof v === "string") ? [...value] : undefined;
|
|
6
|
+
const identity = ({ source, line, id, generation }) => ({ source, line, id, generation });
|
|
7
|
+
const emptyGates = () => Object.fromEntries(GATE_NAMES.map(g => [g, { state: "not-run" }]));
|
|
8
|
+
/** Incremental pure lifecycle fold. Retains facts per task/gate, never verdict bodies or event history. */
|
|
9
|
+
export class OperatorStateFold {
|
|
10
|
+
tasks = new Map();
|
|
11
|
+
start;
|
|
12
|
+
startEvent;
|
|
13
|
+
latestGraphRehash;
|
|
14
|
+
end;
|
|
15
|
+
active = false;
|
|
16
|
+
approved = false;
|
|
17
|
+
tipFailed = false;
|
|
18
|
+
lastEventAt;
|
|
19
|
+
passed = 0;
|
|
20
|
+
total = 0;
|
|
21
|
+
apply(record) {
|
|
22
|
+
const { event: e } = record;
|
|
23
|
+
this.lastEventAt = e.ts;
|
|
24
|
+
const ref = identity(record);
|
|
25
|
+
if (e.event === "run-start" || e.event === "run-resume") {
|
|
26
|
+
if (e.event === "run-start" && !this.start) {
|
|
27
|
+
const startEvent = { ...e, data: { graphDefinitionHash: e.data.graphDefinitionHash } };
|
|
28
|
+
this.start = { ...ref, event: startEvent };
|
|
29
|
+
this.startEvent = startEvent;
|
|
30
|
+
}
|
|
31
|
+
this.end = undefined;
|
|
32
|
+
this.active = true;
|
|
33
|
+
this.approved = false;
|
|
34
|
+
this.tipFailed = false;
|
|
35
|
+
}
|
|
36
|
+
if (e.event === "graph-rehash")
|
|
37
|
+
this.latestGraphRehash = { ...e, data: { from: e.data.from, to: e.data.to } };
|
|
38
|
+
if (e.event === "tip-verify-failed" || (e.event === "tip-verify" && e.data.pass === false))
|
|
39
|
+
this.tipFailed = true;
|
|
40
|
+
if (e.event === "run-end") {
|
|
41
|
+
const tip = e.data.tipVerify;
|
|
42
|
+
this.end = {
|
|
43
|
+
evidence: ref,
|
|
44
|
+
buckets: Object.fromEntries(bucketNames.map(k => [k, strings(e.data[k])])),
|
|
45
|
+
tip: tip === "passed" ? "passed" : tip === "failed" ? "failed" : tip === "not required" || tip === "not-required" || tip === "skipped" ? "not required" : "unknown",
|
|
46
|
+
};
|
|
47
|
+
this.active = false;
|
|
48
|
+
this.approved = false;
|
|
49
|
+
for (const key of bucketNames)
|
|
50
|
+
for (const id of this.end.buckets[key] ?? []) {
|
|
51
|
+
const task = this.task(id);
|
|
52
|
+
task.state = key;
|
|
53
|
+
task.evidence = ref;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (e.event === "gate-result" && e.data.skipped !== true && e.data.disabled !== true && typeof e.data.pass === "boolean") {
|
|
57
|
+
this.total++;
|
|
58
|
+
if (e.data.pass)
|
|
59
|
+
this.passed++;
|
|
60
|
+
}
|
|
61
|
+
if (!e.taskId)
|
|
62
|
+
return;
|
|
63
|
+
const t = this.task(e.taskId);
|
|
64
|
+
t.evidence = ref;
|
|
65
|
+
if (e.event === "task-dispatch") {
|
|
66
|
+
t.state = "running";
|
|
67
|
+
t.dispatches++;
|
|
68
|
+
t.parkKind = undefined;
|
|
69
|
+
t.gates = emptyGates();
|
|
70
|
+
t.attempt = typeof e.data.attempt === "number" ? e.data.attempt : undefined;
|
|
71
|
+
// These are recorded values only: no guessed directory or universal silence threshold.
|
|
72
|
+
t.path = typeof e.data.worktree === "string" ? e.data.worktree : typeof e.data.cwd === "string" ? e.data.cwd : undefined;
|
|
73
|
+
t.pane = typeof e.data.pane === "string" ? e.data.pane : undefined;
|
|
74
|
+
t.alarmMs = typeof e.data.alarmMs === "number" ? e.data.alarmMs : undefined;
|
|
75
|
+
}
|
|
76
|
+
if (e.event === "task-done")
|
|
77
|
+
t.state = "completed";
|
|
78
|
+
if (e.event === "merge") {
|
|
79
|
+
t.state = "merged";
|
|
80
|
+
t.merged = true;
|
|
81
|
+
t.mergeEvidence = ref;
|
|
82
|
+
t.parkKind = undefined;
|
|
83
|
+
}
|
|
84
|
+
if (e.event === "task-failed")
|
|
85
|
+
t.state = "failed";
|
|
86
|
+
if (e.event === "task-human") {
|
|
87
|
+
t.state = "human";
|
|
88
|
+
t.parkKind = typeof e.data.kind === "string" ? e.data.kind : undefined;
|
|
89
|
+
}
|
|
90
|
+
if (e.event === "task-blocked")
|
|
91
|
+
t.state = "blocked";
|
|
92
|
+
if (e.event === "task-approved") {
|
|
93
|
+
t.state = "pending";
|
|
94
|
+
t.parkKind = undefined;
|
|
95
|
+
this.approved = !this.active;
|
|
96
|
+
}
|
|
97
|
+
const gate = e.data.gate;
|
|
98
|
+
if (typeof gate === "string" && GATE_NAMES.includes(gate)) {
|
|
99
|
+
if (e.event === "gate-start")
|
|
100
|
+
t.gates[gate] = { state: "running", evidence: ref };
|
|
101
|
+
if (e.event === "gate-result")
|
|
102
|
+
t.gates[gate] = {
|
|
103
|
+
state: e.data.disabled === true ? "disabled" : e.data.skipped === true ? "not-run" : e.data.pass === true ? "passed" : e.data.pass === false ? "failed" : "unknown", evidence: ref,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
task(id) {
|
|
108
|
+
let t = this.tasks.get(id);
|
|
109
|
+
if (!t) {
|
|
110
|
+
t = { id, state: "unknown", merged: false, dispatches: 0, gates: emptyGates() };
|
|
111
|
+
this.tasks.set(id, t);
|
|
112
|
+
}
|
|
113
|
+
return t;
|
|
114
|
+
}
|
|
115
|
+
snapshot({ graph, sequence = 0, observedAt = Date.now(), readable = true } = {}) {
|
|
116
|
+
const hash = graph && graphDefinitionHash(graph);
|
|
117
|
+
const comparable = this.comparableTo(hash);
|
|
118
|
+
const ids = comparable ? [...graph.tasks.map(t => t.id), ...[...this.tasks.keys()].filter(id => !graph.tasks.some(t => t.id === id))] : [...this.tasks.keys()];
|
|
119
|
+
const tasks = ids.map(id => {
|
|
120
|
+
const t = this.tasks.get(id) ?? { id, state: "unknown", merged: false, dispatches: 0, gates: emptyGates() };
|
|
121
|
+
return { ...t, title: comparable ? graph.tasks.find(g => g.id === id)?.title : undefined, gates: Object.fromEntries(Object.entries(t.gates).map(([g, cell]) => [g, { ...cell }])) };
|
|
122
|
+
});
|
|
123
|
+
const buckets = Object.fromEntries(bucketNames.map(k => [k, this.end?.buckets[k] === undefined ? undefined : [...this.end.buckets[k]]]));
|
|
124
|
+
// A matching graph contributes planned tasks even when the journal has never named them.
|
|
125
|
+
// Their silence is unknown evidence, not completion. Comparison itself is also required for
|
|
126
|
+
// green: without the recorded graph identity there is no trustworthy planned denominator.
|
|
127
|
+
const merged = tasks.filter(t => t.merged && (!comparable || graph.tasks.some(g => g.id === t.id))).length;
|
|
128
|
+
const planned = comparable ? graph.tasks.length : undefined;
|
|
129
|
+
const unresolved = tasks.some(t => ["unknown", "failed", "human", "blocked", "pending", "running"].includes(t.state));
|
|
130
|
+
const allPlannedMerged = comparable && merged === planned;
|
|
131
|
+
const currentTip = this.active ? "pending" : this.tipFailed ? "failed" : this.end?.tip ?? "unknown";
|
|
132
|
+
const green = readable && comparable && allPlannedMerged && !!this.start && !!this.end && !this.approved && !unresolved
|
|
133
|
+
&& (currentTip === "passed" || currentTip === "not required")
|
|
134
|
+
&& bucketNames.every(k => buckets[k]?.length === 0);
|
|
135
|
+
const lifecycle = !readable || !this.start ? "UNKNOWN" : this.active ? "RUNNING" : this.approved ? "APPROVED" : green ? "COMPLETE" : this.end ? "PARTIAL" : "PENDING";
|
|
136
|
+
return {
|
|
137
|
+
sequence, observedAt, lastEventAt: this.lastEventAt, lifecycle,
|
|
138
|
+
label: this.approved ? "approved; resume required" : lifecycle,
|
|
139
|
+
green, currentTip, comparable, comparison: comparable ? "matching graph" : "not comparable",
|
|
140
|
+
merged,
|
|
141
|
+
planned, tasks, buckets,
|
|
142
|
+
gatesRan: { passed: this.passed, total: this.total }, latestRunEnd: this.end?.evidence,
|
|
143
|
+
approvedResumeRequired: this.approved,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
comparableTo(hash) {
|
|
147
|
+
if (!hash)
|
|
148
|
+
return false;
|
|
149
|
+
const events = [this.startEvent, this.latestGraphRehash].filter((e) => e !== undefined);
|
|
150
|
+
const baseline = engagementComparable(events, hash);
|
|
151
|
+
if (this.latestGraphRehash) {
|
|
152
|
+
const from = baseline.comparable ? baseline.recorded : baseline.reason === "mismatch" ? baseline.recorded : null;
|
|
153
|
+
return this.latestGraphRehash.data.to === hash && this.latestGraphRehash.data.from === from;
|
|
154
|
+
}
|
|
155
|
+
return baseline.comparable;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/** C1/C6 share this pure reader; callers supply the same observation and journal snapshot. */
|
|
159
|
+
export function readOperatorState({ events, source = "journal.jsonl", ...options }) {
|
|
160
|
+
const fold = new OperatorStateFold();
|
|
161
|
+
events.forEach((event, i) => fold.apply(typeof event.event === "object" ? event : {
|
|
162
|
+
source, line: i + 1, id: `${source}#L${i + 1}`, event: event,
|
|
163
|
+
}));
|
|
164
|
+
return fold.snapshot(options);
|
|
165
|
+
}
|
|
@@ -43,6 +43,9 @@ export declare function beatSupervision(repoRoot: string, tier: SupervisionTier,
|
|
|
43
43
|
/** Handle a watcher holds for as long as it is supervising; disarm stands it down and is idempotent. */
|
|
44
44
|
export interface ArmedSupervision {
|
|
45
45
|
disarm: () => void;
|
|
46
|
+
readonly id?: string;
|
|
47
|
+
/** True only after this handle removed its own presence. */
|
|
48
|
+
released?: () => boolean;
|
|
46
49
|
}
|
|
47
50
|
export declare function armSupervision(repoRoot: string, tier: SupervisionTier, beatMs?: number, seat?: string): ArmedSupervision;
|
|
48
51
|
/** Arm the live status board's own tier through the normal supervision writer and lifecycle. */
|
|
@@ -53,3 +56,32 @@ export declare function supervisionStatus(repoRoot: string, tier: SupervisionTie
|
|
|
53
56
|
export declare const readSupervision: (repoRoot: string, now?: number) => TierLiveness[];
|
|
54
57
|
/** One line, one word per tier. Shared by both status surfaces so neither can render a state twice. */
|
|
55
58
|
export declare const supervisionText: (tiers: readonly TierLiveness[], divider?: string) => string;
|
|
59
|
+
/** A board's durable identity is independent of its short title and of other observers. */
|
|
60
|
+
export interface WatchBoardOwner {
|
|
61
|
+
repo: string;
|
|
62
|
+
runId: string;
|
|
63
|
+
driver: string;
|
|
64
|
+
workspace: string;
|
|
65
|
+
pane: string;
|
|
66
|
+
name: string;
|
|
67
|
+
token: string;
|
|
68
|
+
pid?: number;
|
|
69
|
+
armId?: string;
|
|
70
|
+
}
|
|
71
|
+
export declare const WATCH_OWNER_ENV = "TICKMARKR_WATCH_OWNER";
|
|
72
|
+
export declare function readWatchBoard(repo: string, runId: string): WatchBoardOwner | undefined;
|
|
73
|
+
/** Only a driver that has just verified placement may reserve this binding. */
|
|
74
|
+
export declare function reserveWatchBoard(owner: Omit<WatchBoardOwner, "repo" | "token" | "pid" | "armId"> & {
|
|
75
|
+
repo: string;
|
|
76
|
+
}): WatchBoardOwner;
|
|
77
|
+
export declare function requestWatchBoardStop(owner: WatchBoardOwner): void;
|
|
78
|
+
export declare function watchBoardAcknowledged(owner: WatchBoardOwner): boolean;
|
|
79
|
+
export declare function stopWatchBoard(owner: WatchBoardOwner, time?: {
|
|
80
|
+
now: () => number;
|
|
81
|
+
sleep: (ms: number) => Promise<void>;
|
|
82
|
+
}, timeoutMs?: number): Promise<void>;
|
|
83
|
+
/** The UI alone publishes acknowledgement, after releasing its own presence. */
|
|
84
|
+
export declare function observeNamedRun(repo: string, runId: string, env?: NodeJS.ProcessEnv): {
|
|
85
|
+
stopRequested: () => boolean;
|
|
86
|
+
close: () => void;
|
|
87
|
+
};
|