tickmarkr 2.5.5 → 2.5.7
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 +3 -1
- package/dist/adapters/prompt.js +21 -1
- package/dist/cli/commands/approve.js +69 -8
- package/dist/cli/commands/doctor.d.ts +10 -0
- package/dist/cli/commands/fleet.js +4 -0
- package/dist/cli/commands/plan.js +20 -4
- package/dist/cli/commands/status.js +95 -34
- package/dist/cli/commands/verify.js +108 -85
- package/dist/config/config.d.ts +11 -0
- package/dist/config/config.js +21 -12
- package/dist/config/fleet-overlay.js +55 -17
- package/dist/drivers/index.js +2 -1
- package/dist/drivers/orca.d.ts +21 -1
- package/dist/drivers/orca.js +209 -27
- package/dist/gates/baseline.d.ts +21 -5
- package/dist/gates/baseline.js +67 -17
- package/dist/gates/cache.d.ts +14 -13
- package/dist/gates/cache.js +17 -5
- package/dist/gates/llm.d.ts +3 -0
- package/dist/gates/llm.js +11 -0
- package/dist/gates/review.d.ts +28 -3
- package/dist/gates/review.js +118 -15
- package/dist/gates/run-gates.d.ts +10 -2
- package/dist/gates/run-gates.js +362 -108
- package/dist/gates/test-manifest.d.ts +33 -1
- package/dist/gates/test-manifest.js +132 -40
- package/dist/gates/test-reporter.js +20 -7
- package/dist/graph/graph.d.ts +4 -0
- package/dist/graph/graph.js +50 -1
- package/dist/run/activity.d.ts +28 -0
- package/dist/run/activity.js +194 -0
- package/dist/run/consult.js +5 -4
- package/dist/run/daemon.d.ts +11 -0
- package/dist/run/daemon.js +663 -128
- package/dist/run/execution-budget.d.ts +25 -0
- package/dist/run/execution-budget.js +142 -0
- package/dist/run/git.d.ts +46 -1
- package/dist/run/git.js +149 -12
- package/dist/run/journal.d.ts +23 -5
- package/dist/run/journal.js +98 -25
- package/dist/run/lease.d.ts +44 -0
- package/dist/run/lease.js +226 -3
- package/dist/run/operator-page-summary.d.ts +56 -0
- package/dist/run/operator-page-summary.js +68 -0
- package/dist/run/operator-summary.d.ts +69 -0
- package/dist/run/operator-summary.js +77 -0
- package/dist/run/protocol.d.ts +71 -0
- package/dist/run/protocol.js +32 -0
- package/dist/run/recovery.d.ts +8 -0
- package/dist/run/recovery.js +25 -0
- package/dist/run/repair-selection.d.ts +12 -0
- package/dist/run/repair-selection.js +56 -0
- package/dist/run/stall.d.ts +6 -1
- package/dist/run/stall.js +60 -3
- package/dist/tui/cockpit/board.d.ts +9 -0
- package/dist/tui/cockpit/board.js +10 -0
- package/dist/tui/cockpit/derive.d.ts +35 -0
- package/dist/tui/cockpit/derive.js +152 -10
- package/dist/tui/cockpit/evidence-view.d.ts +2 -0
- package/dist/tui/cockpit/evidence-view.js +42 -12
- package/dist/tui/cockpit/run-cockpit.d.ts +8 -1
- package/dist/tui/cockpit/run-cockpit.js +95 -1
- package/dist/tui/cockpit/run-view.d.ts +37 -0
- package/dist/tui/cockpit/run-view.js +189 -2
- package/dist/tui/ink/fleet-app.d.ts +10 -2
- package/dist/tui/ink/fleet-app.js +33 -15
- package/package.json +2 -2
- package/skills/tickmarkr-overseer/SKILL.md +55 -3
- package/skills/tickmarkr-overseer/scripts/grade-ci.sh +29 -2
package/dist/run/journal.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
2
3
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from "node:fs";
|
|
3
4
|
import { join } from "node:path";
|
|
4
5
|
import { z } from "zod";
|
|
5
6
|
import { channelKey, shq, TokenUsageSchema } from "../adapters/types.js";
|
|
6
|
-
import { graphDefinitionHash, stateDirName, taskContentDigest, tickmarkrDir } from "../graph/graph.js";
|
|
7
|
+
import { graphDefinitionHash, stateDirName, taskContentDigest, taskDefinitionFingerprint, tickmarkrDir } from "../graph/graph.js";
|
|
7
8
|
import { GATE_NAMES, TIERS } from "../graph/schema.js";
|
|
8
9
|
import { channelRouteIdentity } from "../route/preference.js";
|
|
9
10
|
import { buildProfile, classify } from "../route/profile.js";
|
|
@@ -60,8 +61,8 @@ export function formatJournalNarration({ event, taskId, data }) {
|
|
|
60
61
|
// replayResumeState(), consumed by the daemon on resume to re-seed demotedChannels.
|
|
61
62
|
export const CHANNEL_EXCLUSION_KINDS = ["dead-channel"];
|
|
62
63
|
// v1.24 OBS-18: explicit data on task-approved when the operator releases an attempt-cap park.
|
|
63
|
-
// Approve stamps this; replayResumeState
|
|
64
|
-
//
|
|
64
|
+
// Approve stamps this; replayResumeState resets both the attempt counter and tried seats for a fresh
|
|
65
|
+
// budget. Absent on pre-v1.24 events ⇒ inert (corpus outcome-identical).
|
|
65
66
|
export const ATTEMPT_CAP_RELEASE = "attempt-cap";
|
|
66
67
|
// OBS-130: task-approved carries the exact failed gate the operator satisfied. The release tag keeps
|
|
67
68
|
// ordinary humanGate and attempt-cap approvals byte-compatible while making this authority explicit.
|
|
@@ -74,8 +75,8 @@ export const REVIEW_UPHELD_RELEASE = "review-upheld";
|
|
|
74
75
|
// gate before it — daemon.ts remainingGates), which is wrong when the gate failed against a stale task
|
|
75
76
|
// DECLARATION rather than a bad diff: amend the spec's files[], recompile, and the gate now passes
|
|
76
77
|
// honestly. This release runs the whole gate suite over the parked commit before any worker and
|
|
77
|
-
// marks no gate satisfied: green merges directly, red returns to the ladder
|
|
78
|
-
//
|
|
78
|
+
// marks no gate satisfied: green merges directly, red returns to the existing ladder with attempts
|
|
79
|
+
// and tried seats intact.
|
|
79
80
|
export const RECHECK_RELEASE = "recheck";
|
|
80
81
|
// OBS-738: one authority for every recovery surface. The ref is accepted only from the row that
|
|
81
82
|
// preservation itself writes; task-human prose, branch heads and commit history are deliberately
|
|
@@ -217,9 +218,20 @@ function identifierIn(note) {
|
|
|
217
218
|
// code identity still has one stable identity of its own: its own words, with the volatile tokens
|
|
218
219
|
// swept out so line/path churn cannot mint a new symbol for the same finding. It is the reviewer's
|
|
219
220
|
// own bytes, never a guess, and it can never fuse two different findings into one.
|
|
221
|
+
// OBS-1013 add.3: the words are BOUNDED — the first sentence, or a short hash when even that is long.
|
|
222
|
+
// A whole-note symbol made a 600-byte fingerprint no reviewer could echo, so a real closure read as
|
|
223
|
+
// malformed and bought a worker. A sentence a reviewer can copy is the identity; the hash is the fallback.
|
|
224
|
+
const SYMBOL_SENTENCE_CAP = 160;
|
|
225
|
+
export function boundedSymbol(note) {
|
|
226
|
+
const words = normalizeGateFailure(note);
|
|
227
|
+
if (!words)
|
|
228
|
+
return "";
|
|
229
|
+
const sentence = words.split(/(?<=[.!?])\s+|\n/)[0].trim();
|
|
230
|
+
return sentence.length <= SYMBOL_SENTENCE_CAP ? sentence : createHash("sha256").update(words).digest("hex").slice(0, 12);
|
|
231
|
+
}
|
|
220
232
|
function toFinding(cls, note, path, symbol, rationale) {
|
|
221
233
|
const p = path || UNIDENTIFIED;
|
|
222
|
-
const s = symbol ||
|
|
234
|
+
const s = symbol || boundedSymbol(note) || UNIDENTIFIED;
|
|
223
235
|
return {
|
|
224
236
|
class: cls, path: p, symbol: s, note,
|
|
225
237
|
...(rationale !== undefined ? { rationale } : {}),
|
|
@@ -644,16 +656,25 @@ function decisionForNextDispatch(events, taskId, event) {
|
|
|
644
656
|
* `task-dispatch`: everything between the two — worktree recreation, setup, prompt write, slot
|
|
645
657
|
* allocation, the launch itself — can still die with no worker having read a word, and clearing at
|
|
646
658
|
* task-dispatch meant `--retry-failed` after exactly that death rebuilt the prompt without the gate
|
|
647
|
-
* failures OR the delivery failure that preceded it.
|
|
648
|
-
*
|
|
659
|
+
* failures OR the delivery failure that preceded it. Of the approvals, only a WAIVE clears (the operator
|
|
660
|
+
* retired the findings by fiat — the uphold case re-derives its own brief separately). OBS-1074: a
|
|
661
|
+
* plain approve, a scope grant or a recheck re-funds an attempt that must still see why the last one
|
|
662
|
+
* parked, plus the operator's stated reason — v2.5.7's T11 looped four times on one hygiene oracle
|
|
663
|
+
* because every approval erased exactly the finding the fresh attempt was funded to fix.
|
|
649
664
|
*/
|
|
650
665
|
export function journaledFailureBrief(events, taskId) {
|
|
651
666
|
let rows = [];
|
|
652
667
|
for (const e of events) {
|
|
653
668
|
if (e.taskId !== taskId)
|
|
654
669
|
continue;
|
|
655
|
-
if (e.event === "worker-launch"
|
|
670
|
+
if (e.event === "worker-launch")
|
|
656
671
|
rows = [];
|
|
672
|
+
else if (e.event === "task-approved") {
|
|
673
|
+
if (e.data.release === GATE_SATISFIED_RELEASE)
|
|
674
|
+
rows = [];
|
|
675
|
+
else if (typeof e.data.reason === "string" && e.data.reason.trim())
|
|
676
|
+
rows.push(`approval: ${e.data.reason.trim()}`);
|
|
677
|
+
}
|
|
657
678
|
else if (e.event === "gate-result" && e.data.pass === false && e.data.skipped !== true
|
|
658
679
|
&& typeof e.data.details === "string")
|
|
659
680
|
rows.push(`${e.data.gate}: ${e.data.details}`);
|
|
@@ -773,7 +794,7 @@ const DispatchAssignmentSchema = z.object({
|
|
|
773
794
|
// routing profile's QUALITY_FAIL_PARKS (route/profile.ts): the channel did nothing wrong.
|
|
774
795
|
export const PARK_KINDS = ["human-gate", "ladder-exhausted", "attempt-cap", "gate-fail", "quota",
|
|
775
796
|
"reroute-exhausted", "setup", "stall", "merge-conflict", "tip-moved", "infra", "dispatch",
|
|
776
|
-
"authoring", "scope-request"];
|
|
797
|
+
"authoring", "scope-request", "diff-cap"];
|
|
777
798
|
// v1.85 T3: "repair" is a third dispatch mode beside the v1.29 session pair — a fix-only attempt that
|
|
778
799
|
// carries the failing findings and the diff CONTENT of the work already landed, instead of re-buying
|
|
779
800
|
// ~20m of onboarding to rediscover them (62 of 68 measured re-dispatches were fresh).
|
|
@@ -971,12 +992,37 @@ export function recordedGraphDefinitionHash(events) {
|
|
|
971
992
|
}
|
|
972
993
|
return recorded ?? undefined;
|
|
973
994
|
}
|
|
995
|
+
/**
|
|
996
|
+
* OBS-1073 residual: the `--graph-changed` release belongs to the ENGAGEMENT, not to the launch call. The
|
|
997
|
+
* daemon writes it on the run-resume row; every replay that runs inside that engagement from another
|
|
998
|
+
* process (the approve CLI) reads it back here. Without it the first in-run approval after a released
|
|
999
|
+
* launch re-ran the whole-graph asserts the launch had waived and killed the daemon (v2.5.7 run …152220).
|
|
1000
|
+
*/
|
|
1001
|
+
export function engagementReleased(events) {
|
|
1002
|
+
const engagement = events.filter((e) => e.event === "run-start" || e.event === "run-resume").at(-1);
|
|
1003
|
+
return engagement?.data.graphChanged === true;
|
|
1004
|
+
}
|
|
1005
|
+
/** The run's own graph.json (copied by the daemon at every start/resume) — the definitions the last engagement ran on. */
|
|
1006
|
+
function snapshotDefinitions(journal) {
|
|
1007
|
+
const path = join(journal.dir, "graph.json");
|
|
1008
|
+
if (!existsSync(path))
|
|
1009
|
+
return new Map();
|
|
1010
|
+
const snapshot = JSON.parse(readFileSync(path, "utf8"));
|
|
1011
|
+
return new Map((snapshot.tasks ?? []).map((t) => [t.id, taskDefinitionFingerprint(t)]));
|
|
1012
|
+
}
|
|
974
1013
|
/** An approval is the durable authority; graph.json is only its materialized projection. */
|
|
975
1014
|
export const ScopeAmendmentSchema = z.object({
|
|
976
1015
|
from: z.string(), to: z.string(), beforeFiles: z.array(z.string()), files: z.array(z.string()),
|
|
977
1016
|
parkLine: z.number().int().positive(),
|
|
1017
|
+
/** OBS-1073: the amended task's definition minus files[] at approval; absent on rows older than v2.5.7. */
|
|
1018
|
+
definition: z.string().optional(),
|
|
978
1019
|
});
|
|
979
|
-
|
|
1020
|
+
/**
|
|
1021
|
+
* `release` is the operator's audited `--graph-changed`. `approvedDefinitions` supplies, per amended task,
|
|
1022
|
+
* the definition fingerprint the amendment was granted against when the row itself carries none (rows
|
|
1023
|
+
* older than v2.5.7): the caller reads it from the run's materialized graph snapshot.
|
|
1024
|
+
*/
|
|
1025
|
+
export function replayScopeAmendments(graph, events, release = false, approvedDefinitions = new Map()) {
|
|
980
1026
|
const amendments = events.filter((e) => e.event === "task-approved" && e.data.release === "scope-request")
|
|
981
1027
|
.map((event) => ({ event, amendment: ScopeAmendmentSchema.parse(event.data.amendment) }));
|
|
982
1028
|
if (!amendments.length)
|
|
@@ -995,20 +1041,37 @@ export function replayScopeAmendments(graph, events) {
|
|
|
995
1041
|
}
|
|
996
1042
|
result = { ...result, tasks: result.tasks.map((t) => t.id === id ? { ...t, files: original } : t) };
|
|
997
1043
|
}
|
|
1044
|
+
// OBS-1073: `from`/`to` bind the WHOLE graph, so a later spec repair of ANOTHER task (a pin-sweep miss
|
|
1045
|
+
// found mid-run, D-57) makes them unmatchable forever and `--graph-changed` — applied only after this
|
|
1046
|
+
// replay — never reaches its own release. Under that audited release the amended task's files[]
|
|
1047
|
+
// recognition above still holds and the caller journals the rehash; only the whole-graph identity
|
|
1048
|
+
// asserts are waived. Without the release nothing changes: fail closed.
|
|
998
1049
|
for (const { event, amendment } of amendments) {
|
|
999
|
-
if (
|
|
1050
|
+
if (release) {
|
|
1051
|
+
// The release waives the WHOLE-GRAPH identity (another task may lawfully have moved), never the
|
|
1052
|
+
// amended task's own definition: goal, acceptance, deps, floor must still be what was approved.
|
|
1053
|
+
const approved = amendment.definition ?? approvedDefinitions.get(event.taskId ?? "");
|
|
1054
|
+
const current = result.tasks.find((t) => t.id === event.taskId);
|
|
1055
|
+
if (approved === undefined || current === undefined) {
|
|
1056
|
+
throw new Error(`refusing scope amendment replay under --graph-changed: no approved definition fingerprint for ${event.taskId}`);
|
|
1057
|
+
}
|
|
1058
|
+
if (taskDefinitionFingerprint(current) !== approved) {
|
|
1059
|
+
throw new Error(`refusing scope amendment replay: ${event.taskId} definition changed beyond the approved amendment (goal, acceptance, deps or routing)`);
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
if (!release && graphDefinitionHash(result) !== amendment.from) {
|
|
1000
1063
|
throw new Error(`refusing scope amendment replay: graph task definition changed beyond approval for ${event.taskId}`);
|
|
1001
1064
|
}
|
|
1002
1065
|
result = { ...result, tasks: result.tasks.map((t) => t.id === event.taskId ? { ...t, files: amendment.files } : t) };
|
|
1003
|
-
if (graphDefinitionHash(result) !== amendment.to)
|
|
1066
|
+
if (!release && graphDefinitionHash(result) !== amendment.to)
|
|
1004
1067
|
throw new Error("refusing invalid scope amendment hash");
|
|
1005
1068
|
}
|
|
1006
1069
|
return result;
|
|
1007
1070
|
}
|
|
1008
1071
|
/** Publish/recover the audit before dispatch, even after a crash between approval and rehash. */
|
|
1009
|
-
export function applyScopeAmendments(graph, journal, auditReplay = false) {
|
|
1072
|
+
export function applyScopeAmendments(graph, journal, auditReplay = false, release = false) {
|
|
1010
1073
|
const events = journal.read();
|
|
1011
|
-
const result = replayScopeAmendments(graph, events); // validate all before moving any identity
|
|
1074
|
+
const result = replayScopeAmendments(graph, events, release, release ? snapshotDefinitions(journal) : new Map()); // validate all before moving any identity
|
|
1012
1075
|
const approvals = events.filter((e) => e.event === "task-approved" && e.data.release === "scope-request");
|
|
1013
1076
|
for (const approval of approvals) {
|
|
1014
1077
|
const amendment = ScopeAmendmentSchema.parse(approval.data.amendment);
|
|
@@ -1483,24 +1546,32 @@ export class Journal {
|
|
|
1483
1546
|
// A reroute bans the in-force channel; retry/decompose/human verdicts ban nothing (D-03).
|
|
1484
1547
|
pendingReroute.add(e.taskId);
|
|
1485
1548
|
}
|
|
1486
|
-
else if (e.event === "task-approved"
|
|
1487
|
-
&& (e.data.release === ATTEMPT_CAP_RELEASE || e.data.release === RECHECK_RELEASE)) {
|
|
1549
|
+
else if (e.event === "task-approved" && e.data.release === ATTEMPT_CAP_RELEASE) {
|
|
1488
1550
|
// v1.24 OBS-18: operator released an attempt-cap park. Pre-v1.24 task-approved events have no
|
|
1489
1551
|
// `release` key ⇒ this branch never fires (corpus criterion: identical statuses + resume state).
|
|
1490
|
-
// attempts
|
|
1491
|
-
//
|
|
1492
|
-
//
|
|
1493
|
-
//
|
|
1552
|
+
// attempts and tried reset so the daemon's attempt-cap check does not re-park in the same tick
|
|
1553
|
+
// and the newly funded engagement starts with a cleared seat list; lastAssignment clears too.
|
|
1554
|
+
// OBS-1028: a RECHECK release is deliberately NOT here — it marks no gate satisfied and funds no
|
|
1555
|
+
// attempt, so the ladder (attempts AND tried) survives it untouched. Only THIS fresh-budget
|
|
1556
|
+
// release resets the ladder, and it resets both halves: a fresh budget on a cleared seat list.
|
|
1494
1557
|
const st = m.get(e.taskId);
|
|
1495
1558
|
if (st) {
|
|
1496
1559
|
st.attempts = 0;
|
|
1560
|
+
st.tried = [];
|
|
1497
1561
|
st.lastAssignment = undefined;
|
|
1498
1562
|
}
|
|
1499
1563
|
}
|
|
1564
|
+
else if (e.event === "task-approved" && e.data.release === RECHECK_RELEASE) {
|
|
1565
|
+
// OBS-1028: the ladder survives a recheck (attempts AND tried); only the seat memory is cleared
|
|
1566
|
+
// so a red battery's next dispatch is picked over the exclusions instead of restoring the parked seat.
|
|
1567
|
+
const st = m.get(e.taskId);
|
|
1568
|
+
if (st)
|
|
1569
|
+
st.lastAssignment = undefined;
|
|
1570
|
+
}
|
|
1500
1571
|
else if (e.event === "task-approved" && e.data.release === REVIEW_UPHELD_RELEASE) {
|
|
1501
|
-
// OBS-189: the operator upheld the reviewer.
|
|
1502
|
-
// (
|
|
1503
|
-
//
|
|
1572
|
+
// OBS-189: the operator upheld the reviewer. It funds fresh attempts without forgetting tried
|
|
1573
|
+
// seats (unlike an attempt-cap fresh-budget release), and carries the newest failed review's
|
|
1574
|
+
// details into the funded attempt as its retry brief.
|
|
1504
1575
|
const st = m.get(e.taskId);
|
|
1505
1576
|
if (st) {
|
|
1506
1577
|
st.attempts = 0;
|
|
@@ -1608,7 +1679,9 @@ export class Journal {
|
|
|
1608
1679
|
replayExcludedChannels() {
|
|
1609
1680
|
const excluded = new Set();
|
|
1610
1681
|
for (const e of this.read()) {
|
|
1611
|
-
|
|
1682
|
+
// OBS-1010: an in-run no-trailer demotion is a WORKER exclusion too; review-pool-demotion is
|
|
1683
|
+
// the reviewer seat's own fold (daemon demotedReviewers) and never benches the worker channel.
|
|
1684
|
+
if ((e.event === "channel-exclusion" || e.event === "channel-demotion") && typeof e.data.channel === "string") {
|
|
1612
1685
|
excluded.add(e.data.channel);
|
|
1613
1686
|
}
|
|
1614
1687
|
else if (e.event === "dead-channel-failover" && typeof e.data.from === "string") {
|
package/dist/run/lease.d.ts
CHANGED
|
@@ -12,3 +12,47 @@ export declare class CommandLeases {
|
|
|
12
12
|
private readonly owners;
|
|
13
13
|
run<T>(run: () => Promise<T>, waiting: (count: number) => void, pollMs: number, signal?: AbortSignal): Promise<T>;
|
|
14
14
|
}
|
|
15
|
+
/**
|
|
16
|
+
* OBS-1042: the standalone battery's cross-process lease. Two `tickmarkr verify` runs in two linked
|
|
17
|
+
* worktrees of one clone share NO daemon and NO state directory, so the in-process `CommandLeases`
|
|
18
|
+
* above never saw each other and both full suites ran on one host. This reservation is keyed on the
|
|
19
|
+
* repository's COMMON git dir (`git rev-parse --git-common-dir`), which every linked worktree of a
|
|
20
|
+
* clone shares and an unrelated repository never does. One file, created exclusively; the holder
|
|
21
|
+
* writes its pid and cwd so a waiter can journal who it waits on and reclaim a reservation whose
|
|
22
|
+
* holder is dead. Each reservation releases only itself: a waiter that never acquired removes nothing,
|
|
23
|
+
* so cancelling it never frees the holder. The daemon's adoption is deferred to 2.5.7.
|
|
24
|
+
*/
|
|
25
|
+
export interface RepositoryLeaseHolder {
|
|
26
|
+
pid: number;
|
|
27
|
+
cwd: string;
|
|
28
|
+
at: number;
|
|
29
|
+
token: string;
|
|
30
|
+
}
|
|
31
|
+
export interface RepositoryLeaseOptions {
|
|
32
|
+
pollMs?: number;
|
|
33
|
+
signal?: AbortSignal;
|
|
34
|
+
/** Called once per distinct holder the waiter is queued behind. */
|
|
35
|
+
onWait?: (holder: RepositoryLeaseHolder) => void;
|
|
36
|
+
}
|
|
37
|
+
export declare function repositoryLeasePath(cwd: string): Promise<string>;
|
|
38
|
+
/** OBS-1057: dev+ino alone is not an identity across unlink/recreate — Linux hands the freed inode
|
|
39
|
+
* number straight to the next file, so a stale observation of a dead or corrupt reservation matched
|
|
40
|
+
* the fresh reservation that replaced it and reclaimDead removed the live winner (public CI, ubuntu).
|
|
41
|
+
* The bytes are the generation: a fragment never hashes like a complete record, and two complete
|
|
42
|
+
* records differ by token. Size and mtime are not enough (a same-length reservation written within
|
|
43
|
+
* the filesystem's timestamp resolution — Leg-2 review of the first cut). */
|
|
44
|
+
export declare const inodeIdentity: (st: {
|
|
45
|
+
dev: number | bigint;
|
|
46
|
+
ino: number | bigint;
|
|
47
|
+
}, bytes: string | Buffer) => string;
|
|
48
|
+
/** The identity a reclaimer would observe at a path right now, or undefined when nothing is there. */
|
|
49
|
+
export declare const observeIdentity: (path: string) => string | undefined;
|
|
50
|
+
export declare const readHolder: (path: string) => RepositoryLeaseHolder | undefined;
|
|
51
|
+
/** Publish `mine` at `path` atomically: the record is written whole to a private file and linked
|
|
52
|
+
* into place — `link` is exclusive (EEXIST when the path is taken) and a reader never sees a partial
|
|
53
|
+
* record, so a process killed at any point of its acquisition leaves either nothing or a complete
|
|
54
|
+
* record naming a pid a waiter can check. Returns false when the path is taken. */
|
|
55
|
+
export declare function tryReserve(path: string, mine: RepositoryLeaseHolder): boolean;
|
|
56
|
+
/** Exposed for recovery regressions; the standalone verify uses the combined critical section. */
|
|
57
|
+
export declare const reclaimDead: (path: string, identity: string, pollMs?: number) => Promise<boolean>;
|
|
58
|
+
export declare function withRepositoryLease<T>(cwd: string, run: () => Promise<T>, opts?: RepositoryLeaseOptions): Promise<T>;
|
package/dist/run/lease.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
2
3
|
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { closeSync, constants, fstatSync, linkSync, mkdirSync, openSync, readFileSync, readdirSync, readlinkSync, rmdirSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import { join, resolve } from "node:path";
|
|
3
7
|
// The process census and command admission deliberately use the same command-head classifier.
|
|
4
8
|
export function isRunnerCommand(command) {
|
|
5
9
|
// Inspect executable positions only: inference prompts and shell script bodies may name runners.
|
|
@@ -17,9 +21,11 @@ export function isRunnerCommand(command) {
|
|
|
17
21
|
const flags = "(?:\\s+(?:--?[\\w-]+(?:=[^\\s]+)?|--))*";
|
|
18
22
|
const manager = `${binary}(?:npm|pnpm|yarn|bun)${flags}`;
|
|
19
23
|
const jsRunner = `${binary}(?:vitest(?:\\.mjs)?|jest|mocha)(?:\\s|$)`;
|
|
20
|
-
// Test scripts may be scoped (test:unit); runner binaries may be launched
|
|
21
|
-
// Keep each match anchored to that executable position so prompt/argument
|
|
22
|
-
|
|
24
|
+
// Test scripts may be scoped (test:unit, test.integration); runner binaries may be launched
|
|
25
|
+
// through a manager. Keep each match anchored to that executable position so prompt/argument
|
|
26
|
+
// mentions stay out. OBS-1045: the scope is `test` followed by a `:` or `.` separator — the npm
|
|
27
|
+
// scoping convention — so a differently named script such as `test-lint` is never leased.
|
|
28
|
+
return new RegExp(`^${manager}\\s+(?:run${flags}\\s+)?test(?:[:.][\\w.-]+)?(?:\\s|$)`, "i").test(head)
|
|
23
29
|
|| new RegExp(`^${manager}\\s+(?:(?:exec|x|dlx)${flags}\\s+)?${jsRunner}`, "i").test(head)
|
|
24
30
|
|| new RegExp(`^${binary}npx${flags}\\s+${jsRunner}`, "i").test(head)
|
|
25
31
|
|| /^(?:\S*\/)?(?:go|make|cargo)\s+test(?:\s|$)/i.test(head)
|
|
@@ -85,3 +91,220 @@ export class CommandLeases {
|
|
|
85
91
|
}
|
|
86
92
|
}
|
|
87
93
|
}
|
|
94
|
+
const LEASE_FILE = "tickmarkr-runner.lease";
|
|
95
|
+
export async function repositoryLeasePath(cwd) {
|
|
96
|
+
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())));
|
|
97
|
+
return join(resolve(cwd, commonDir), LEASE_FILE);
|
|
98
|
+
}
|
|
99
|
+
const alive = (pid) => {
|
|
100
|
+
try {
|
|
101
|
+
process.kill(pid, 0);
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
return error.code !== "ESRCH";
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
const isCode = (error, code) => error.code === code;
|
|
109
|
+
/** OBS-1057: dev+ino alone is not an identity across unlink/recreate — Linux hands the freed inode
|
|
110
|
+
* number straight to the next file, so a stale observation of a dead or corrupt reservation matched
|
|
111
|
+
* the fresh reservation that replaced it and reclaimDead removed the live winner (public CI, ubuntu).
|
|
112
|
+
* The bytes are the generation: a fragment never hashes like a complete record, and two complete
|
|
113
|
+
* records differ by token. Size and mtime are not enough (a same-length reservation written within
|
|
114
|
+
* the filesystem's timestamp resolution — Leg-2 review of the first cut). */
|
|
115
|
+
export const inodeIdentity = (st, bytes) => `${st.dev}-${st.ino}-${createHash("sha256").update(bytes).digest("hex").slice(0, 16)}`;
|
|
116
|
+
/** The identity a reclaimer would observe at a path right now, or undefined when nothing is there. */
|
|
117
|
+
export const observeIdentity = (path) => readOccupant(path)?.identity;
|
|
118
|
+
const readOccupant = (path) => {
|
|
119
|
+
let fd;
|
|
120
|
+
try {
|
|
121
|
+
fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
122
|
+
const st = fstatSync(fd);
|
|
123
|
+
const bytes = readFileSync(fd, "utf8");
|
|
124
|
+
const identity = inodeIdentity(st, bytes);
|
|
125
|
+
try {
|
|
126
|
+
const holder = JSON.parse(bytes);
|
|
127
|
+
if (typeof holder.pid === "number" && typeof holder.cwd === "string" && typeof holder.token === "string")
|
|
128
|
+
return { holder, identity };
|
|
129
|
+
}
|
|
130
|
+
catch { /* incomplete/foreign bytes are reclaimable under the mutation lock */ }
|
|
131
|
+
return { holder: undefined, identity };
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
if (isCode(error, "ENOENT"))
|
|
135
|
+
return undefined;
|
|
136
|
+
throw error;
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
if (fd !== undefined)
|
|
140
|
+
closeSync(fd);
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
export const readHolder = (path) => readOccupant(path)?.holder;
|
|
144
|
+
/** Crash-recoverable serialization for lease-path mutations. The highest atomic owner-generation
|
|
145
|
+
* symlink is authoritative. A process killed in the critical section leaves its pid behind, so one
|
|
146
|
+
* contender can exclusively publish the next generation and finish the interrupted operation. */
|
|
147
|
+
const mutationLock = (path) => `${path}.lock`;
|
|
148
|
+
const lockGeneration = (entry) => {
|
|
149
|
+
const match = /^owner\.(\d+)$/.exec(entry);
|
|
150
|
+
return match ? Number(match[1]) : undefined;
|
|
151
|
+
};
|
|
152
|
+
const mutationHolder = (lock) => {
|
|
153
|
+
let names;
|
|
154
|
+
try {
|
|
155
|
+
names = readdirSync(lock);
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
if (isCode(error, "ENOENT"))
|
|
159
|
+
return undefined;
|
|
160
|
+
throw error;
|
|
161
|
+
}
|
|
162
|
+
const current = names.flatMap((entry) => {
|
|
163
|
+
const gen = lockGeneration(entry);
|
|
164
|
+
return gen === undefined ? [] : [{ entry, gen }];
|
|
165
|
+
}).sort((a, b) => b.gen - a.gen)[0];
|
|
166
|
+
if (!current)
|
|
167
|
+
return undefined;
|
|
168
|
+
try {
|
|
169
|
+
const target = readlinkSync(join(lock, current.entry));
|
|
170
|
+
const pid = Number(/^([1-9]\d*):/.exec(target)?.[1]);
|
|
171
|
+
return Number.isInteger(pid) && pid > 0 ? { ...current, target, pid } : { ...current, target };
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
return current;
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
const releaseMutationLock = (lock, entry, target, inherited) => {
|
|
178
|
+
const holder = mutationHolder(lock);
|
|
179
|
+
if (holder?.entry !== entry || holder.target !== target)
|
|
180
|
+
return;
|
|
181
|
+
// Remove our marker last. A successor published after that point is never in this snapshot.
|
|
182
|
+
for (const name of [...inherited, entry]) {
|
|
183
|
+
try {
|
|
184
|
+
unlinkSync(join(lock, name));
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
if (!isCode(error, "ENOENT"))
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
try {
|
|
192
|
+
rmdirSync(lock);
|
|
193
|
+
}
|
|
194
|
+
catch { /* ENOTEMPTY: a successor owns the directory */ }
|
|
195
|
+
};
|
|
196
|
+
const withMutationLock = async (path, pollMs, signal, run) => {
|
|
197
|
+
const lock = mutationLock(path);
|
|
198
|
+
const target = `${process.pid}:${randomUUID()}`;
|
|
199
|
+
let entry = "";
|
|
200
|
+
let inherited = [];
|
|
201
|
+
for (;;) {
|
|
202
|
+
signal?.throwIfAborted();
|
|
203
|
+
try {
|
|
204
|
+
mkdirSync(lock);
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
if (!isCode(error, "EEXIST"))
|
|
208
|
+
throw error;
|
|
209
|
+
}
|
|
210
|
+
const holder = mutationHolder(lock);
|
|
211
|
+
if (holder?.pid !== undefined && alive(holder.pid)) {
|
|
212
|
+
await new Promise((wake) => setTimeout(wake, pollMs));
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
entry = `owner.${(holder?.gen ?? -1) + 1}`;
|
|
216
|
+
try {
|
|
217
|
+
symlinkSync(target, join(lock, entry));
|
|
218
|
+
inherited = readdirSync(lock).filter((name) => name !== entry && lockGeneration(name) !== undefined);
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
catch (error) {
|
|
222
|
+
if (!isCode(error, "EEXIST") && !isCode(error, "ENOENT"))
|
|
223
|
+
throw error;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
try {
|
|
227
|
+
return await run();
|
|
228
|
+
}
|
|
229
|
+
finally {
|
|
230
|
+
releaseMutationLock(lock, entry, target, inherited);
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
/** Publish `mine` at `path` atomically: the record is written whole to a private file and linked
|
|
234
|
+
* into place — `link` is exclusive (EEXIST when the path is taken) and a reader never sees a partial
|
|
235
|
+
* record, so a process killed at any point of its acquisition leaves either nothing or a complete
|
|
236
|
+
* record naming a pid a waiter can check. Returns false when the path is taken. */
|
|
237
|
+
export function tryReserve(path, mine) {
|
|
238
|
+
const draft = `${path}.${mine.token}.draft`;
|
|
239
|
+
writeFileSync(draft, JSON.stringify(mine) + "\n");
|
|
240
|
+
try {
|
|
241
|
+
linkSync(draft, path);
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
244
|
+
catch (error) {
|
|
245
|
+
if (isCode(error, "EEXIST"))
|
|
246
|
+
return false;
|
|
247
|
+
throw error;
|
|
248
|
+
}
|
|
249
|
+
finally {
|
|
250
|
+
rmSync(draft, { force: true });
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
/** Link the inspected dead inode to its stable tombstone, then remove the canonical name. This runs
|
|
254
|
+
* only under the mutation lock. EEXIST means an earlier reclaimer died after link: recovery finishes
|
|
255
|
+
* only if both names still bind that exact inode, never a replacement holder. */
|
|
256
|
+
const reclaimDeadLocked = (path, identity) => {
|
|
257
|
+
const tombstone = `${path}.dead-${identity}`;
|
|
258
|
+
try {
|
|
259
|
+
linkSync(path, tombstone);
|
|
260
|
+
}
|
|
261
|
+
catch (error) {
|
|
262
|
+
if (!isCode(error, "EEXIST") && !isCode(error, "ENOENT"))
|
|
263
|
+
throw error;
|
|
264
|
+
}
|
|
265
|
+
const dead = readOccupant(tombstone);
|
|
266
|
+
const current = readOccupant(path);
|
|
267
|
+
if (dead?.identity !== identity || current?.identity !== identity)
|
|
268
|
+
return false;
|
|
269
|
+
unlinkSync(path);
|
|
270
|
+
return true;
|
|
271
|
+
};
|
|
272
|
+
/** Exposed for recovery regressions; the standalone verify uses the combined critical section. */
|
|
273
|
+
export const reclaimDead = async (path, identity, pollMs = 10) => withMutationLock(path, pollMs, undefined, () => reclaimDeadLocked(path, identity));
|
|
274
|
+
const inspectAndReserve = (path, mine) => {
|
|
275
|
+
let occupant = readOccupant(path);
|
|
276
|
+
if (occupant && (!occupant.holder || !alive(occupant.holder.pid))) {
|
|
277
|
+
reclaimDeadLocked(path, occupant.identity);
|
|
278
|
+
occupant = readOccupant(path);
|
|
279
|
+
}
|
|
280
|
+
if (!occupant && tryReserve(path, mine))
|
|
281
|
+
return { acquired: true };
|
|
282
|
+
return { acquired: false, holder: occupant?.holder };
|
|
283
|
+
};
|
|
284
|
+
export async function withRepositoryLease(cwd, run, opts = {}) {
|
|
285
|
+
const path = await repositoryLeasePath(cwd);
|
|
286
|
+
const pollMs = opts.pollMs ?? 1_000;
|
|
287
|
+
const mine = { pid: process.pid, cwd, at: Date.now(), token: randomUUID() };
|
|
288
|
+
let waitingOn;
|
|
289
|
+
for (;;) {
|
|
290
|
+
opts.signal?.throwIfAborted();
|
|
291
|
+
const attempt = await withMutationLock(path, pollMs, opts.signal, () => inspectAndReserve(path, mine));
|
|
292
|
+
if (attempt.acquired)
|
|
293
|
+
break;
|
|
294
|
+
if (attempt.holder && attempt.holder.pid !== waitingOn) {
|
|
295
|
+
waitingOn = attempt.holder.pid;
|
|
296
|
+
opts.onWait?.(attempt.holder);
|
|
297
|
+
}
|
|
298
|
+
await new Promise((wake) => setTimeout(wake, pollMs));
|
|
299
|
+
}
|
|
300
|
+
try {
|
|
301
|
+
return await run();
|
|
302
|
+
}
|
|
303
|
+
finally {
|
|
304
|
+
// Release is serialized with reclamation/acquisition and removes only this generation.
|
|
305
|
+
await withMutationLock(path, pollMs, undefined, () => {
|
|
306
|
+
if (readHolder(path)?.token === mine.token)
|
|
307
|
+
unlinkSync(path);
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure operator-page grouping over the `operator-page` rows the daemon journals (one row per
|
|
3
|
+
* delivered page, carrying the producer's `suppressed` count). This module consumes that count and
|
|
4
|
+
* never throttles a second time: it only coalesces EQUIVALENT rows for display while keeping every
|
|
5
|
+
* source line, both evidence timestamps, and the producer's suppressed count separate from what was
|
|
6
|
+
* actually observed. Folding is incremental and pure; `fold(fold(empty, a), b)` equals
|
|
7
|
+
* `fold(empty, [...a, ...b])` and no journal byte is touched.
|
|
8
|
+
*/
|
|
9
|
+
import type { JournalEvent } from "./journal.js";
|
|
10
|
+
export interface OperatorPageRow {
|
|
11
|
+
/** Source reference (journal line number or any stable locator). */
|
|
12
|
+
readonly line: number;
|
|
13
|
+
readonly ts: string;
|
|
14
|
+
readonly runId: string;
|
|
15
|
+
readonly taskId: string;
|
|
16
|
+
/** Current park identity (e.g. park kind + attempt); part of the group key. */
|
|
17
|
+
readonly park: string;
|
|
18
|
+
/** Decision state (e.g. blocked / idle / failed / resolved); part of the group key. */
|
|
19
|
+
readonly status: string;
|
|
20
|
+
/** Producer-suppressed page count; absent on rows written before suppression metadata existed. */
|
|
21
|
+
readonly suppressed?: number;
|
|
22
|
+
readonly blocker?: string;
|
|
23
|
+
readonly owner?: string;
|
|
24
|
+
readonly requiredAction?: string;
|
|
25
|
+
readonly permittedActions?: readonly string[];
|
|
26
|
+
}
|
|
27
|
+
export interface OperatorPageGroup {
|
|
28
|
+
readonly key: string;
|
|
29
|
+
readonly runId: string;
|
|
30
|
+
readonly taskId: string;
|
|
31
|
+
readonly park: string;
|
|
32
|
+
readonly status: string;
|
|
33
|
+
readonly blocker?: string;
|
|
34
|
+
readonly owner?: string;
|
|
35
|
+
readonly requiredAction?: string;
|
|
36
|
+
readonly permittedActions: readonly string[];
|
|
37
|
+
readonly firstEvidenceAt: string;
|
|
38
|
+
readonly lastEvidenceAt: string;
|
|
39
|
+
/** Rows actually present in the journal. */
|
|
40
|
+
readonly observedCount: number;
|
|
41
|
+
/** Sum of the producer's `suppressed` counts; never added to observedCount. */
|
|
42
|
+
readonly suppressedCount: number;
|
|
43
|
+
/** True when at least one row lacked suppression metadata (pre-metadata journal). */
|
|
44
|
+
readonly rawOnly: boolean;
|
|
45
|
+
readonly lines: readonly number[];
|
|
46
|
+
}
|
|
47
|
+
export interface OperatorPageSummary {
|
|
48
|
+
readonly groups: readonly OperatorPageGroup[];
|
|
49
|
+
}
|
|
50
|
+
export declare const EMPTY_OPERATOR_PAGE_SUMMARY: OperatorPageSummary;
|
|
51
|
+
/** Projects a journal event onto a row; returns undefined for anything that is not an operator page. */
|
|
52
|
+
export declare function operatorPageRow(event: JournalEvent, line: number, runId: string): OperatorPageRow | undefined;
|
|
53
|
+
/** Folds rows into a summary. Pure: returns a new summary, never mutates inputs. */
|
|
54
|
+
export declare function foldOperatorPages(prior: OperatorPageSummary, rows: readonly OperatorPageRow[]): OperatorPageSummary;
|
|
55
|
+
/** Full replay from journal events. */
|
|
56
|
+
export declare function summarizeOperatorPages(events: readonly JournalEvent[], runId: string): OperatorPageSummary;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
export const EMPTY_OPERATOR_PAGE_SUMMARY = { groups: [] };
|
|
2
|
+
const str = (v) => (typeof v === "string" ? v : undefined);
|
|
3
|
+
/** Projects a journal event onto a row; returns undefined for anything that is not an operator page. */
|
|
4
|
+
export function operatorPageRow(event, line, runId) {
|
|
5
|
+
if (event.event !== "operator-page" || event.taskId === undefined)
|
|
6
|
+
return undefined;
|
|
7
|
+
const d = event.data;
|
|
8
|
+
const permitted = Array.isArray(d.permittedActions) ? d.permittedActions.filter((a) => typeof a === "string") : undefined;
|
|
9
|
+
return {
|
|
10
|
+
line, ts: event.ts, runId, taskId: event.taskId,
|
|
11
|
+
park: str(d.park) ?? `${str(d.slot) ?? ""}#${typeof d.attempt === "number" ? d.attempt : ""}`,
|
|
12
|
+
status: str(d.status) ?? "unknown",
|
|
13
|
+
...(typeof d.suppressed === "number" ? { suppressed: d.suppressed } : {}),
|
|
14
|
+
...(str(d.blocker) !== undefined ? { blocker: str(d.blocker) } : {}),
|
|
15
|
+
...(str(d.owner) !== undefined ? { owner: str(d.owner) } : {}),
|
|
16
|
+
...(str(d.requiredAction) !== undefined ? { requiredAction: str(d.requiredAction) } : {}),
|
|
17
|
+
...(permitted !== undefined ? { permittedActions: permitted } : {}),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
const groupKey = (r) => [r.runId, r.taskId, r.park, r.status].join("\u0000");
|
|
21
|
+
const openKey = (r) => `${r.runId}\u0000${r.taskId}`;
|
|
22
|
+
/** Permitted actions compare as a SET: order and duplicates never open a new group. */
|
|
23
|
+
const actionSet = (a) => [...new Set(a ?? [])].sort().join("\u0000");
|
|
24
|
+
const sameDecision = (g, r) => g.key === groupKey(r)
|
|
25
|
+
&& g.blocker === r.blocker && g.owner === r.owner && g.requiredAction === r.requiredAction
|
|
26
|
+
&& actionSet(g.permittedActions) === actionSet(r.permittedActions);
|
|
27
|
+
/** Folds rows into a summary. Pure: returns a new summary, never mutates inputs. */
|
|
28
|
+
export function foldOperatorPages(prior, rows) {
|
|
29
|
+
const groups = [...prior.groups];
|
|
30
|
+
// One open group per run/task: ANY change in park, status or decision closes it, so a later
|
|
31
|
+
// transition can never merge backward into an earlier group across an intervening change.
|
|
32
|
+
const open = new Map();
|
|
33
|
+
groups.forEach((g, i) => open.set(openKey(g), i));
|
|
34
|
+
for (const row of rows) {
|
|
35
|
+
const key = groupKey(row);
|
|
36
|
+
const idx = open.get(openKey(row));
|
|
37
|
+
const g = idx === undefined ? undefined : groups[idx];
|
|
38
|
+
if (g !== undefined && sameDecision(g, row)) {
|
|
39
|
+
groups[idx] = {
|
|
40
|
+
...g,
|
|
41
|
+
firstEvidenceAt: row.ts < g.firstEvidenceAt ? row.ts : g.firstEvidenceAt,
|
|
42
|
+
lastEvidenceAt: row.ts > g.lastEvidenceAt ? row.ts : g.lastEvidenceAt,
|
|
43
|
+
observedCount: g.observedCount + 1,
|
|
44
|
+
suppressedCount: g.suppressedCount + (row.suppressed ?? 0),
|
|
45
|
+
rawOnly: g.rawOnly || row.suppressed === undefined,
|
|
46
|
+
lines: [...g.lines, row.line],
|
|
47
|
+
};
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
groups.push({
|
|
51
|
+
key, runId: row.runId, taskId: row.taskId, park: row.park, status: row.status,
|
|
52
|
+
...(row.blocker !== undefined ? { blocker: row.blocker } : {}),
|
|
53
|
+
...(row.owner !== undefined ? { owner: row.owner } : {}),
|
|
54
|
+
...(row.requiredAction !== undefined ? { requiredAction: row.requiredAction } : {}),
|
|
55
|
+
permittedActions: [...(row.permittedActions ?? [])],
|
|
56
|
+
firstEvidenceAt: row.ts, lastEvidenceAt: row.ts,
|
|
57
|
+
observedCount: 1, suppressedCount: row.suppressed ?? 0,
|
|
58
|
+
rawOnly: row.suppressed === undefined, lines: [row.line],
|
|
59
|
+
});
|
|
60
|
+
open.set(openKey(row), groups.length - 1);
|
|
61
|
+
}
|
|
62
|
+
return { groups };
|
|
63
|
+
}
|
|
64
|
+
/** Full replay from journal events. */
|
|
65
|
+
export function summarizeOperatorPages(events, runId) {
|
|
66
|
+
const rows = events.flatMap((e, i) => { const r = operatorPageRow(e, i + 1, runId); return r ? [r] : []; });
|
|
67
|
+
return foldOperatorPages(EMPTY_OPERATOR_PAGE_SUMMARY, rows);
|
|
68
|
+
}
|