tickmarkr 1.81.0 → 1.84.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/README.md +4 -4
- package/dist/brand.d.ts +4 -1
- package/dist/brand.js +46 -7
- package/dist/cli/commands/approve.js +53 -8
- package/dist/cli/commands/plan.js +17 -2
- package/dist/cli/commands/report.js +15 -6
- package/dist/cli/commands/ui.js +13 -1
- package/dist/cli/index.d.ts +1 -1
- package/dist/cli/index.js +1 -1
- package/dist/compile/collateral.d.ts +19 -0
- package/dist/compile/collateral.js +90 -0
- package/dist/compile/index.js +18 -4
- package/dist/drivers/herdr.d.ts +1 -0
- package/dist/drivers/herdr.js +19 -0
- package/dist/drivers/types.d.ts +1 -0
- package/dist/gates/acceptance.js +50 -8
- package/dist/gates/llm.js +34 -19
- package/dist/gates/review.d.ts +9 -1
- package/dist/gates/review.js +173 -7
- package/dist/gates/run-gates.d.ts +1 -0
- package/dist/gates/run-gates.js +18 -1
- package/dist/report/bundle.d.ts +6 -1
- package/dist/report/bundle.js +13 -3
- package/dist/run/daemon.d.ts +5 -0
- package/dist/run/daemon.js +188 -27
- package/dist/run/journal.d.ts +5 -0
- package/dist/run/journal.js +71 -1
- package/dist/tui/cockpit/capture.d.ts +107 -1
- package/dist/tui/cockpit/capture.js +287 -10
- package/dist/tui/cockpit/components.d.ts +40 -47
- package/dist/tui/cockpit/components.js +66 -50
- package/dist/tui/cockpit/derive.d.ts +73 -1
- package/dist/tui/cockpit/derive.js +211 -15
- package/dist/tui/cockpit/keys.d.ts +114 -19
- package/dist/tui/cockpit/keys.js +413 -44
- package/dist/tui/cockpit/layout.d.ts +231 -0
- package/dist/tui/cockpit/layout.js +307 -0
- package/dist/tui/cockpit/live.d.ts +65 -3
- package/dist/tui/cockpit/live.js +418 -48
- package/dist/tui/cockpit/pointer.d.ts +261 -0
- package/dist/tui/cockpit/pointer.js +610 -0
- package/dist/tui/cockpit/run-cockpit.d.ts +93 -6
- package/dist/tui/cockpit/run-cockpit.js +805 -164
- package/dist/tui/cockpit/setup-cockpit.d.ts +146 -1
- package/dist/tui/cockpit/setup-cockpit.js +337 -5
- package/dist/tui/cockpit/views.d.ts +72 -0
- package/dist/tui/cockpit/views.js +56 -0
- package/dist/tui/cockpit/width.d.ts +105 -0
- package/dist/tui/cockpit/width.js +276 -0
- package/fixtures/speckit-sample/tasks.md +2 -2
- package/package.json +3 -1
|
@@ -1,40 +1,45 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from "ink";
|
|
3
3
|
import { Children } from "react";
|
|
4
|
-
import stringWidth from "string-width";
|
|
5
4
|
import { GLYPHS } from "../../brand.js";
|
|
6
5
|
import { COCKPIT_DATA_RAMP, COCKPIT_INKS, TEXT_EMPHASIS_TOKENS, } from "./theme.js";
|
|
7
6
|
import { SPARKLINE_BUCKET_WINDOW } from "./derive.js";
|
|
8
7
|
export { SPARKLINE_BUCKET_WINDOW } from "./derive.js";
|
|
9
|
-
import { keybarEntries,
|
|
8
|
+
import { formatKeybarEntries, keybarEntries, SETUP_KEY_BINDINGS, } from "./keys.js";
|
|
9
|
+
/**
|
|
10
|
+
* Measurement and cutting come from the cockpit's one width authority; this
|
|
11
|
+
* file owns no width table and counts no code points of its own. The local
|
|
12
|
+
* name stays `stringWidth` because `keys.test.ts`'s branch ledger proves the
|
|
13
|
+
* roster's fit branch by naming that line of this file verbatim and watching
|
|
14
|
+
* the named test fail without it — renaming the call would leave the ledger
|
|
15
|
+
* reporting a missing anchor instead of a covered branch. The name is the
|
|
16
|
+
* ledger's; the measurement is `cellWidth`, and `string-width` the package is
|
|
17
|
+
* no longer imported here.
|
|
18
|
+
*/
|
|
19
|
+
import { cellWidth as stringWidth, sliceCells } from "./width.js";
|
|
10
20
|
const SPARKLINE_GLYPHS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
|
|
21
|
+
/**
|
|
22
|
+
* The hover highlight's non-colour half: one cell wide, drawn in the separator
|
|
23
|
+
* the row already spends, from the block family the cockpit already draws with.
|
|
24
|
+
* Colour is the other half and the louder one, but Chalk suppresses inverse and
|
|
25
|
+
* colour together at level 0, so a highlight made of ink alone does not exist
|
|
26
|
+
* under `NO_COLOR` or a colourless TTY. This is a character; it always does.
|
|
27
|
+
*/
|
|
28
|
+
export const HOVER_MARKER = "▌";
|
|
11
29
|
const BAND_GAP_COLUMNS = 1;
|
|
12
30
|
const PANEL_HORIZONTAL_CHROME = 4;
|
|
13
|
-
|
|
31
|
+
/**
|
|
32
|
+
* The columns below which a bordered panel no longer reads — the floor a band
|
|
33
|
+
* allocation defends, and the floor planFrame's session resize override clamps
|
|
34
|
+
* the body panel at: a drag surrenders an element whole or not at all, it
|
|
35
|
+
* never collapses one below what reads.
|
|
36
|
+
*/
|
|
37
|
+
export const READABLE_PANEL_COLUMNS = 20;
|
|
14
38
|
export const PANEL_CHROME_ROWS = 3;
|
|
15
39
|
export const BAND_CONTINUATION_PREFIX = "↳ ";
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const RUN_KEYS = keybarEntries(RUN_KEY_BINDINGS);
|
|
19
|
-
const SETUP_ADDITIONAL_KEYS = [
|
|
20
|
-
{ key: "␣", label: "Toggle" },
|
|
21
|
-
{ key: "a", label: "All" },
|
|
22
|
-
{ key: "r", label: "Re-probe" },
|
|
23
|
-
{ key: "s", label: "Save" },
|
|
24
|
-
{ key: "n", label: "Next" },
|
|
25
|
-
{ key: "p", label: "Prev" },
|
|
26
|
-
];
|
|
27
|
-
const SETUP_KEYS = [
|
|
28
|
-
RUN_KEYS[0],
|
|
29
|
-
{ key: RUN_KEYS[1].key, label: "Next" },
|
|
30
|
-
RUN_KEYS[2],
|
|
31
|
-
RUN_KEYS[3],
|
|
32
|
-
RUN_KEYS[4],
|
|
33
|
-
RUN_KEYS[5],
|
|
34
|
-
...SETUP_ADDITIONAL_KEYS,
|
|
35
|
-
];
|
|
40
|
+
const SETUP_KEYS = keybarEntries(SETUP_KEY_BINDINGS);
|
|
41
|
+
const SETUP_ADDITIONAL_KEYS = SETUP_KEYS.slice(6);
|
|
36
42
|
export const KEYBAR_KEYS = {
|
|
37
|
-
run: RUN_KEYS,
|
|
38
43
|
setup: SETUP_KEYS,
|
|
39
44
|
setupAdditional: SETUP_ADDITIONAL_KEYS,
|
|
40
45
|
};
|
|
@@ -102,27 +107,6 @@ export function allocateBandColumns(columns, content, options = {}) {
|
|
|
102
107
|
const shares = apportion(usable - base.reduce((sum, value) => sum + value, 0), preferred.map((value, index) => value - base[index]));
|
|
103
108
|
return base.map((value, index) => value + shares[index]);
|
|
104
109
|
}
|
|
105
|
-
function takeDisplayColumns(value, columns) {
|
|
106
|
-
const characters = [...value];
|
|
107
|
-
let used = 0;
|
|
108
|
-
let taken = 0;
|
|
109
|
-
while (taken < characters.length) {
|
|
110
|
-
const width = stringWidth(characters[taken]);
|
|
111
|
-
if (taken > 0 && used + width > columns)
|
|
112
|
-
break;
|
|
113
|
-
if (taken === 0 && width > columns) {
|
|
114
|
-
taken = 1;
|
|
115
|
-
break;
|
|
116
|
-
}
|
|
117
|
-
used += width;
|
|
118
|
-
taken += 1;
|
|
119
|
-
}
|
|
120
|
-
const concatenate = (items) => items.reduce((result, character) => result + character, "");
|
|
121
|
-
return [
|
|
122
|
-
concatenate(characters.slice(0, taken)),
|
|
123
|
-
concatenate(characters.slice(taken)),
|
|
124
|
-
];
|
|
125
|
-
}
|
|
126
110
|
/**
|
|
127
111
|
* Compose one data line into visible rows. A fitting line is byte-for-byte
|
|
128
112
|
* untouched. Every wrapped row carries a continuation marker before any
|
|
@@ -158,7 +142,7 @@ export function composeBandLine(text, columns) {
|
|
|
158
142
|
continue;
|
|
159
143
|
}
|
|
160
144
|
const available = Math.max(1, width - stringWidth(current));
|
|
161
|
-
const
|
|
145
|
+
const { head, tail } = sliceCells(word, available);
|
|
162
146
|
current += head;
|
|
163
147
|
word = tail;
|
|
164
148
|
if (word.length === 0)
|
|
@@ -263,12 +247,44 @@ export function ProgressMeter({ value, width = 24, }) {
|
|
|
263
247
|
function OneLineItems({ children, width, label, }) {
|
|
264
248
|
return (_jsx(Box, { flexDirection: "row", flexWrap: "nowrap", width: width, height: 1, overflow: "hidden", "aria-label": label, children: children }));
|
|
265
249
|
}
|
|
266
|
-
|
|
267
|
-
|
|
250
|
+
/**
|
|
251
|
+
* Wrap a projected roster without losing a byte. Unlike prose wrapping, these
|
|
252
|
+
* rows are exact consecutive slices: stripping continuation markers and
|
|
253
|
+
* joining the slices reconstructs the registry projection byte for byte.
|
|
254
|
+
*/
|
|
255
|
+
export function keyRosterLines(entries, columns) {
|
|
256
|
+
const width = Math.max(1, Math.floor(columns));
|
|
257
|
+
const formatted = formatKeybarEntries(entries);
|
|
258
|
+
if (stringWidth(formatted) <= width)
|
|
259
|
+
return [formatted];
|
|
260
|
+
const rendered = [];
|
|
261
|
+
let remaining = formatted;
|
|
262
|
+
let first = true;
|
|
263
|
+
while (remaining.length > 0) {
|
|
264
|
+
const prefix = first ? "" : BAND_CONTINUATION_PREFIX;
|
|
265
|
+
const available = Math.max(1, width - stringWidth(prefix));
|
|
266
|
+
const { head: slice, tail } = sliceCells(remaining, available);
|
|
267
|
+
// Ink trims terminal-row trailing spaces. Carry them to the next slice so
|
|
268
|
+
// the painted representation still reconstructs the formatted roster.
|
|
269
|
+
const trailingSpace = slice.match(/\s+$/u)?.[0] ?? "";
|
|
270
|
+
const head = trailingSpace.length === 0
|
|
271
|
+
? slice
|
|
272
|
+
: slice.slice(0, -trailingSpace.length);
|
|
273
|
+
rendered.push(`${prefix}${head}`);
|
|
274
|
+
remaining = trailingSpace + tail;
|
|
275
|
+
first = false;
|
|
276
|
+
}
|
|
277
|
+
return rendered;
|
|
278
|
+
}
|
|
279
|
+
/** The lossless, width-safe rendering of one projected key roster. */
|
|
280
|
+
export function KeyRoster({ entries, width, }) {
|
|
281
|
+
return (_jsx(Box, { flexDirection: "column", width: width, "aria-label": "run cockpit keys", children: keyRosterLines(entries, width).map((line, index) => (_jsx(BodyText, { emphasis: "dim", children: line }, `${index}:${line}`))) }));
|
|
268
282
|
}
|
|
269
283
|
export function StatusStrip({ items, width, }) {
|
|
270
284
|
return (_jsx(OneLineItems, { width: width, label: "cockpit status", children: items.map((item, index) => (_jsxs(Box, { flexShrink: 0, children: [index > 0 && _jsx(BodyText, { emphasis: "dim", children: " \u00B7 " }), _jsx(StateGlyph, { state: item.state }), _jsxs(BodyText, { children: [" ", item.text] })] }, `${item.state}:${item.text}`))) }));
|
|
271
285
|
}
|
|
272
|
-
export function JournalRowPanel({ rows, width, focused, title = "JOURNAL", selection, }) {
|
|
273
|
-
return (_jsx(Panel, { title: title, focused: focused, width: width, children: rows.map((row, index) => (_jsxs(Box, { flexDirection: "row", flexWrap: "nowrap", height: 1, overflow: "hidden", children: [_jsx(BodyText, { emphasis: "dim", children: row.time }), _jsx(BodyText, { children: " " }), _jsx(StateGlyph, { state: row.state }), _jsx(BodyText, { children: " " }), index === selection && (_jsxs(BodyText, { emphasis: "strong", children: [GLYPHS.pointer, " "] })),
|
|
286
|
+
export function JournalRowPanel({ rows, width, focused, title = "JOURNAL", selection, hover, }) {
|
|
287
|
+
return (_jsx(Panel, { title: title, focused: focused, width: width, children: rows.map((row, index) => (_jsxs(Box, { flexDirection: "row", flexWrap: "nowrap", height: 1, overflow: "hidden", children: [_jsx(BodyText, { emphasis: "dim", children: row.time }), _jsx(BodyText, { children: " " }), _jsx(StateGlyph, { state: row.state }), _jsx(BodyText, { emphasis: index === hover ? "strong" : "normal", children: index === hover ? HOVER_MARKER : " " }), index === selection && (_jsxs(BodyText, { emphasis: "strong", children: [GLYPHS.pointer, " "] })), index === hover
|
|
288
|
+
? (_jsx(Text, { inverse: true, color: `ansi256(${COCKPIT_DATA_RAMP[0].xterm})`, children: row.text }))
|
|
289
|
+
: _jsx(BodyText, { children: row.text })] }, row.id))) }));
|
|
274
290
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { JournalRow, StatusStripItem } from "./components.js";
|
|
1
|
+
import type { ComponentState, JournalRow, StatusStripItem } from "./components.js";
|
|
2
|
+
import type { RunViewId } from "./views.js";
|
|
2
3
|
export declare const SPARKLINE_BUCKET_WINDOW = 12;
|
|
3
4
|
export declare function selectSparklineBucketWidthMs(elapsedMs: number): number;
|
|
4
5
|
type MetricSample = number | null;
|
|
@@ -26,6 +27,9 @@ export type RunCockpitData = {
|
|
|
26
27
|
readonly progress: number;
|
|
27
28
|
readonly progressCaption: string;
|
|
28
29
|
readonly journalRows: readonly JournalRow[];
|
|
30
|
+
readonly taskRows: readonly TaskRow[];
|
|
31
|
+
readonly gateRows: readonly GateRow[];
|
|
32
|
+
readonly fleetRows: readonly FleetRow[];
|
|
29
33
|
readonly statusItems: readonly StatusStripItem[];
|
|
30
34
|
};
|
|
31
35
|
export type RunCockpitSource = {
|
|
@@ -34,6 +38,74 @@ export type RunCockpitSource = {
|
|
|
34
38
|
};
|
|
35
39
|
export type RunCockpitDeriveOptions = {
|
|
36
40
|
readonly isDaemonAlive?: (pid: number) => boolean;
|
|
41
|
+
/** The compiled graph, when the engagement has one. Identity only — see CockpitGraph. */
|
|
42
|
+
readonly graph?: CockpitGraph;
|
|
37
43
|
};
|
|
44
|
+
/**
|
|
45
|
+
* What the compiled graph lends the cockpit: which tasks exist, in what order,
|
|
46
|
+
* and what each is called. `status` is named here only to state that it is
|
|
47
|
+
* deliberately unread — the journal owns state, so a recompile can never
|
|
48
|
+
* repaint a parked task green.
|
|
49
|
+
*/
|
|
50
|
+
export type CockpitGraph = {
|
|
51
|
+
readonly tasks: readonly {
|
|
52
|
+
readonly id: string;
|
|
53
|
+
readonly title?: string;
|
|
54
|
+
readonly status?: string;
|
|
55
|
+
}[];
|
|
56
|
+
};
|
|
57
|
+
/** How an unrecorded field draws: the engagement said nothing, so the row says nothing. */
|
|
58
|
+
export declare const ABSENT_FIELD = "-";
|
|
59
|
+
/**
|
|
60
|
+
* One reading of a row field. An absent field is a fact about the engagement —
|
|
61
|
+
* it never recorded this — and never collapses into an empty string or a zero
|
|
62
|
+
* that reads like a measurement.
|
|
63
|
+
*/
|
|
64
|
+
export declare function fieldReading(value: string | number | undefined): string;
|
|
65
|
+
/**
|
|
66
|
+
* One task of the compiled graph, wearing only what the journal recorded about
|
|
67
|
+
* it. A task the journal never mentions still draws a row; its state, attempts,
|
|
68
|
+
* actor and last event time are absent rather than invented — including the
|
|
69
|
+
* state, because "pending" is a thing the engagement would have to have
|
|
70
|
+
* recorded, not a thing a row may assume about silence.
|
|
71
|
+
*
|
|
72
|
+
* `attempts` is a count of recorded dispatches, never the newest dispatch's
|
|
73
|
+
* `attempt` label: labels restart at zero on resume, so the label of the last
|
|
74
|
+
* dispatch understates a task the engagement resumed into.
|
|
75
|
+
*/
|
|
76
|
+
export type TaskRow = {
|
|
77
|
+
readonly id: string;
|
|
78
|
+
readonly taskId: string;
|
|
79
|
+
readonly state?: TaskState;
|
|
80
|
+
readonly attempts?: number;
|
|
81
|
+
readonly actor?: string;
|
|
82
|
+
readonly lastEventTime?: string;
|
|
83
|
+
readonly title?: string;
|
|
84
|
+
};
|
|
85
|
+
/** One recorded gate result. `details` is the record's own text, verbatim. */
|
|
86
|
+
export type GateRow = {
|
|
87
|
+
readonly id: string;
|
|
88
|
+
readonly time: string;
|
|
89
|
+
readonly state: ComponentState;
|
|
90
|
+
readonly gate?: string;
|
|
91
|
+
readonly taskId?: string;
|
|
92
|
+
readonly pass?: boolean;
|
|
93
|
+
readonly details?: string;
|
|
94
|
+
};
|
|
95
|
+
/** One channel the engagement recorded, dispatched to or merely named. */
|
|
96
|
+
export type FleetRow = {
|
|
97
|
+
readonly id: string;
|
|
98
|
+
readonly adapter: string;
|
|
99
|
+
readonly model: string;
|
|
100
|
+
readonly dispatches?: number;
|
|
101
|
+
readonly lastEventTime?: string;
|
|
102
|
+
};
|
|
103
|
+
export type TaskState = "done" | "failed" | "human" | "pending" | "running" | "interrupted";
|
|
104
|
+
/**
|
|
105
|
+
* The source identities the named view's rows carry, in draw order. Selection
|
|
106
|
+
* is repaired against these, so it follows a row across a refresh instead of
|
|
107
|
+
* following the index the row happened to occupy.
|
|
108
|
+
*/
|
|
109
|
+
export declare function runViewRowIdentities(data: RunCockpitData, viewId: RunViewId, filterQuery?: string): readonly string[];
|
|
38
110
|
export declare function deriveRunCockpitData(source: RunCockpitSource, binaryVersion: string, options?: RunCockpitDeriveOptions): RunCockpitData;
|
|
39
111
|
export {};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { channelKey } from "../../adapters/types.js";
|
|
1
2
|
export const SPARKLINE_BUCKET_WINDOW = 12;
|
|
2
3
|
const MINUTE_MS = 60_000;
|
|
3
4
|
const SPARKLINE_BUCKET_WIDTH_LADDER_MINUTES = [
|
|
@@ -22,6 +23,16 @@ export function selectSparklineBucketWidthMs(elapsedMs) {
|
|
|
22
23
|
coarsened *= 2;
|
|
23
24
|
return coarsened * MINUTE_MS;
|
|
24
25
|
}
|
|
26
|
+
/** How an unrecorded field draws: the engagement said nothing, so the row says nothing. */
|
|
27
|
+
export const ABSENT_FIELD = "-";
|
|
28
|
+
/**
|
|
29
|
+
* One reading of a row field. An absent field is a fact about the engagement —
|
|
30
|
+
* it never recorded this — and never collapses into an empty string or a zero
|
|
31
|
+
* that reads like a measurement.
|
|
32
|
+
*/
|
|
33
|
+
export function fieldReading(value) {
|
|
34
|
+
return value === undefined ? ABSENT_FIELD : String(value);
|
|
35
|
+
}
|
|
25
36
|
function recordValue(value) {
|
|
26
37
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
27
38
|
? value
|
|
@@ -114,7 +125,7 @@ function deriveTasks(events, interrupted) {
|
|
|
114
125
|
const created = {
|
|
115
126
|
id: taskId,
|
|
116
127
|
dispatches: [],
|
|
117
|
-
state:
|
|
128
|
+
state: undefined,
|
|
118
129
|
lastIndex: index,
|
|
119
130
|
lastTs: ts,
|
|
120
131
|
phase: "pending",
|
|
@@ -164,13 +175,36 @@ function deriveTasks(events, interrupted) {
|
|
|
164
175
|
}
|
|
165
176
|
if (interrupted) {
|
|
166
177
|
for (const fact of tasks.values()) {
|
|
167
|
-
|
|
178
|
+
// Only a task the journal recorded as running. `task-dispatch` recorded
|
|
179
|
+
// that it started; no event records how it ended; the daemon that would
|
|
180
|
+
// have recorded that ending is gone. So the recorded state cannot still
|
|
181
|
+
// be true, and its interruption is a reading of that record — not an
|
|
182
|
+
// invention on top of silence.
|
|
183
|
+
//
|
|
184
|
+
// Silence stays silent. A task the engagement mentioned without ever
|
|
185
|
+
// recording a state keeps none: the run's stopping is a fact about the
|
|
186
|
+
// run, and lending it to a task would be exactly the assumption an absent
|
|
187
|
+
// field exists to refuse.
|
|
188
|
+
//
|
|
189
|
+
// A state the journal did record otherwise is never overwritten —
|
|
190
|
+
// `pending` is what `task-approved` recorded, and it says the task was
|
|
191
|
+
// released back to the pool to await a dispatch, not that a dispatch was
|
|
192
|
+
// cut short. In the real post-run approval sequence (run-end, then the
|
|
193
|
+
// operator approves) that approval is the newest thing the engagement
|
|
194
|
+
// recorded about the task, so reporting it as interrupted would be the
|
|
195
|
+
// run's stopping overruling the journal. The journal wins.
|
|
196
|
+
if (fact.state === "running")
|
|
168
197
|
fact.state = "interrupted";
|
|
169
|
-
}
|
|
170
198
|
}
|
|
171
199
|
}
|
|
172
200
|
return tasks;
|
|
173
201
|
}
|
|
202
|
+
/**
|
|
203
|
+
* The label the engagement gave the attempt now standing — which is what the
|
|
204
|
+
* spotlight caption is naming ("attempt 3 of 4", the ladder's own numbering).
|
|
205
|
+
* Not a count of attempts: labels restart at zero on resume. Rows that mean
|
|
206
|
+
* "how many times was this tried" count dispatches instead — see TaskRow.
|
|
207
|
+
*/
|
|
174
208
|
function taskAttempt(fact) {
|
|
175
209
|
return fact.dispatches.at(-1)?.attempt ?? Math.max(1, fact.dispatches.length);
|
|
176
210
|
}
|
|
@@ -227,9 +261,10 @@ function recordsSuccess(event) {
|
|
|
227
261
|
}
|
|
228
262
|
/**
|
|
229
263
|
* A row depicts the event it names: the event's own recorded outcome decides
|
|
230
|
-
* the state, never the state its task reached later. Only a task
|
|
231
|
-
*
|
|
232
|
-
* no event records
|
|
264
|
+
* the state, never the state its task reached later. Only a task the journal
|
|
265
|
+
* recorded as running, in a run whose daemon is gone, lends its interruption:
|
|
266
|
+
* a dispatch was recorded, no event records how it ended, and the process that
|
|
267
|
+
* would have recorded that ending is no longer there.
|
|
233
268
|
* Every state carries its word, so meaning never rides on hue alone.
|
|
234
269
|
*/
|
|
235
270
|
function eventPresentation(event, task) {
|
|
@@ -269,6 +304,147 @@ function journalRows(events, tasks, defects, runStatus) {
|
|
|
269
304
|
const history = [...events].reverse().map((event, index) => historyRow(event, tasks, spotlight, index === 0, runStatus));
|
|
270
305
|
return [...[...defects].reverse().map(defectRow), ...history];
|
|
271
306
|
}
|
|
307
|
+
/**
|
|
308
|
+
* The tasks the engagement owns, in the compiled graph's order: the graph
|
|
309
|
+
* supplies identity and nothing else, so a task the journal never mentions
|
|
310
|
+
* still draws a row. Every other field is read off recorded events — the
|
|
311
|
+
* graph's own `status` is never consulted, which is what keeps a recompiled
|
|
312
|
+
* graph from repainting a parked task green.
|
|
313
|
+
*/
|
|
314
|
+
function taskRows(events, tasks, graph) {
|
|
315
|
+
const lastSeen = new Map();
|
|
316
|
+
for (const event of events) {
|
|
317
|
+
if (event.taskId !== undefined)
|
|
318
|
+
lastSeen.set(event.taskId, event.ts);
|
|
319
|
+
}
|
|
320
|
+
const identities = graph !== undefined
|
|
321
|
+
? graph.tasks.map((task) => ({ id: task.id, title: task.title }))
|
|
322
|
+
// Without a graph the engagement's own records are the only identities there are.
|
|
323
|
+
: [...new Set([...tasks.keys(), ...lastSeen.keys()])].map((id) => ({
|
|
324
|
+
id,
|
|
325
|
+
title: undefined,
|
|
326
|
+
}));
|
|
327
|
+
return identities.map(({ id, title }) => {
|
|
328
|
+
const fact = tasks.get(id);
|
|
329
|
+
const assignment = fact?.dispatches.at(-1)?.assignment;
|
|
330
|
+
const lastTs = lastSeen.get(id);
|
|
331
|
+
// Counted, not read off the newest dispatch's label: the label restarts at
|
|
332
|
+
// zero when a run resumes, so a task dispatched 0,1,0 was tried three times
|
|
333
|
+
// and reporting its last label would say one.
|
|
334
|
+
const attempts = fact?.dispatches.length ?? 0;
|
|
335
|
+
return {
|
|
336
|
+
id: `task:${id}`,
|
|
337
|
+
taskId: id,
|
|
338
|
+
...(fact?.state === undefined ? {} : { state: fact.state }),
|
|
339
|
+
...(attempts === 0 ? {} : { attempts }),
|
|
340
|
+
...(assignment === undefined ? {} : { actor: channelKey(assignment) }),
|
|
341
|
+
...(lastTs === undefined ? {} : { lastEventTime: eventTime(lastTs) }),
|
|
342
|
+
...(title === undefined ? {} : { title }),
|
|
343
|
+
};
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* One row per recorded gate result, newest first. The recorded details ride
|
|
348
|
+
* through untouched — the operator reads the reviewer's findings and the
|
|
349
|
+
* failing test names as the gate wrote them, never a rephrasing.
|
|
350
|
+
*/
|
|
351
|
+
function gateRows(events) {
|
|
352
|
+
const rows = events.flatMap((event) => {
|
|
353
|
+
if (event.event !== "gate-result")
|
|
354
|
+
return [];
|
|
355
|
+
const pass = typeof event.data.pass === "boolean" ? event.data.pass : undefined;
|
|
356
|
+
return [{
|
|
357
|
+
id: `gate:${event.line}`,
|
|
358
|
+
time: eventTime(event.ts),
|
|
359
|
+
state: pass === true ? "pass" : pass === false ? "fail" : "neutral",
|
|
360
|
+
...(typeof event.data.gate === "string" ? { gate: event.data.gate } : {}),
|
|
361
|
+
...(event.taskId === undefined ? {} : { taskId: event.taskId }),
|
|
362
|
+
...(pass === undefined ? {} : { pass }),
|
|
363
|
+
...(typeof event.data.details === "string" ? { details: event.data.details } : {}),
|
|
364
|
+
}];
|
|
365
|
+
});
|
|
366
|
+
return rows.reverse();
|
|
367
|
+
}
|
|
368
|
+
/** An `adapter:model` channel key back into its parts, or nothing if it is not one. */
|
|
369
|
+
function splitChannelKey(key) {
|
|
370
|
+
const separator = key.indexOf(":");
|
|
371
|
+
if (separator <= 0 || separator >= key.length - 1)
|
|
372
|
+
return undefined;
|
|
373
|
+
return { adapter: key.slice(0, separator), model: key.slice(separator + 1) };
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* One row per channel the engagement recorded, in the order it first named
|
|
377
|
+
* them — starting with the roster it recorded at run-start, which is the whole
|
|
378
|
+
* fleet it could have dispatched to, then any channel a later event named that
|
|
379
|
+
* the roster did not. A channel it named without dispatching — a roster seat it
|
|
380
|
+
* never used, or the static choice a failover passed over — carries no dispatch
|
|
381
|
+
* count at all, because zero dispatches is something the engagement never
|
|
382
|
+
* measured, not a measurement of zero.
|
|
383
|
+
*/
|
|
384
|
+
function fleetRows(events) {
|
|
385
|
+
const channels = new Map();
|
|
386
|
+
const note = (channel, ts, dispatched) => {
|
|
387
|
+
const key = channelKey(channel);
|
|
388
|
+
const existing = channels.get(key)
|
|
389
|
+
?? { adapter: channel.adapter, model: channel.model, dispatches: 0, lastTs: ts };
|
|
390
|
+
channels.set(key, {
|
|
391
|
+
...existing,
|
|
392
|
+
dispatches: existing.dispatches + (dispatched ? 1 : 0),
|
|
393
|
+
lastTs: ts,
|
|
394
|
+
});
|
|
395
|
+
};
|
|
396
|
+
const noteKeys = (recorded, ts) => {
|
|
397
|
+
for (const key of recorded) {
|
|
398
|
+
const channel = typeof key === "string" ? splitChannelKey(key) : undefined;
|
|
399
|
+
if (channel)
|
|
400
|
+
note(channel, ts, false);
|
|
401
|
+
}
|
|
402
|
+
};
|
|
403
|
+
for (const event of events) {
|
|
404
|
+
if (event.event === "run-start" && Array.isArray(event.data.channels)) {
|
|
405
|
+
noteKeys(event.data.channels, event.ts);
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
if (event.event === "task-dispatch") {
|
|
409
|
+
const assignment = assignmentFrom(event.data.assignment);
|
|
410
|
+
if (assignment)
|
|
411
|
+
note(assignment, event.ts, true);
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
if (event.event === "failover-deviation") {
|
|
415
|
+
noteKeys([event.data.static, event.data.chosen], event.ts);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
return [...channels.entries()].map(([key, channel]) => ({
|
|
419
|
+
id: `channel:${key}`,
|
|
420
|
+
adapter: channel.adapter,
|
|
421
|
+
model: channel.model,
|
|
422
|
+
...(channel.dispatches === 0 ? {} : { dispatches: channel.dispatches }),
|
|
423
|
+
lastEventTime: eventTime(channel.lastTs),
|
|
424
|
+
}));
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* The source identities the named view's rows carry, in draw order. Selection
|
|
428
|
+
* is repaired against these, so it follows a row across a refresh instead of
|
|
429
|
+
* following the index the row happened to occupy.
|
|
430
|
+
*/
|
|
431
|
+
export function runViewRowIdentities(data, viewId,
|
|
432
|
+
// ponytail: the filter is the journal's, as it has always been; the promoted
|
|
433
|
+
// views gain one when a criterion asks for it.
|
|
434
|
+
filterQuery = "") {
|
|
435
|
+
if (viewId === "tasks")
|
|
436
|
+
return data.taskRows.map((row) => row.id);
|
|
437
|
+
if (viewId === "gates")
|
|
438
|
+
return data.gateRows.map((row) => row.id);
|
|
439
|
+
if (viewId === "fleet")
|
|
440
|
+
return data.fleetRows.map((row) => row.id);
|
|
441
|
+
if (viewId !== "journal")
|
|
442
|
+
return [];
|
|
443
|
+
const query = filterQuery.trim().toLowerCase();
|
|
444
|
+
return data.journalRows
|
|
445
|
+
.filter((row) => query === "" || row.text.toLowerCase().includes(query))
|
|
446
|
+
.map((row) => row.id);
|
|
447
|
+
}
|
|
272
448
|
function bucketedMetricSamples(events) {
|
|
273
449
|
const taskSamples = Array.from({ length: SPARKLINE_BUCKET_WINDOW }, () => null);
|
|
274
450
|
const gateTotals = Array.from({ length: SPARKLINE_BUCKET_WINDOW }, () => 0);
|
|
@@ -302,21 +478,35 @@ function bucketedMetricSamples(events) {
|
|
|
302
478
|
passRate: gateTotals.map((total, index) => total === 0 ? null : Math.round((gatePasses[index] / total) * 100)),
|
|
303
479
|
};
|
|
304
480
|
}
|
|
481
|
+
// OBS-244: the strip reports the LATEST verification cycle, never the whole journal conjoined.
|
|
482
|
+
// The daemon runs the integration-tip commands and appends `run-end` in one breath (daemon.ts
|
|
483
|
+
// finishRun), so a cycle is the tip-verify events recorded since the previous run-end together
|
|
484
|
+
// with the verdict on the run-end that closes them — a mid-run failure a later cycle recovered is
|
|
485
|
+
// no longer permanent red. `undefined` means nothing was verified: drawn absent, never passed.
|
|
486
|
+
// Fail-closed everywhere else — a cycle with no closing verdict, or a verdict with no events, is
|
|
487
|
+
// a failure, because a claim tickmarkr cannot corroborate is not a pass.
|
|
305
488
|
function tipVerificationPassed(events) {
|
|
306
|
-
const
|
|
489
|
+
const runEnds = events.flatMap((event, index) => event.event === "run-end" ? [index] : []);
|
|
490
|
+
const lastRunEnd = runEnds.at(-1);
|
|
491
|
+
const cycle = events.slice((runEnds.at(-2) ?? -1) + 1, lastRunEnd === undefined ? events.length : lastRunEnd + 1);
|
|
492
|
+
const cycleVerdicts = cycle.flatMap((event) => {
|
|
307
493
|
if (event.event === "tip-verify-failed")
|
|
308
494
|
return [false];
|
|
309
495
|
if (event.event === "tip-verify")
|
|
310
496
|
return [event.data.pass === true];
|
|
311
497
|
return [];
|
|
312
498
|
});
|
|
313
|
-
const
|
|
314
|
-
?
|
|
315
|
-
: []
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
499
|
+
const closing = lastRunEnd === undefined
|
|
500
|
+
? undefined
|
|
501
|
+
: events[lastRunEnd].data.tipVerify;
|
|
502
|
+
const endVerdict = typeof closing === "string"
|
|
503
|
+
? closing === "passed"
|
|
504
|
+
: undefined;
|
|
505
|
+
if (cycleVerdicts.length === 0 && endVerdict === undefined)
|
|
506
|
+
return undefined;
|
|
507
|
+
return endVerdict === true
|
|
508
|
+
&& cycleVerdicts.length > 0
|
|
509
|
+
&& cycleVerdicts.every(Boolean);
|
|
320
510
|
}
|
|
321
511
|
export function deriveRunCockpitData(source, binaryVersion, options = {}) {
|
|
322
512
|
const { events, defects } = parseSource(source.raw);
|
|
@@ -379,8 +569,14 @@ export function deriveRunCockpitData(source, binaryVersion, options = {}) {
|
|
|
379
569
|
? `${spotlight.id} · ${spotlight.phase} · attempt ${taskAttempt(spotlight)} · ${taskActor(spotlight)}`
|
|
380
570
|
: "- · pending · attempt 1 · unassigned",
|
|
381
571
|
journalRows: journalRows(events, tasks, defects, runStatus),
|
|
572
|
+
taskRows: taskRows(events, tasks, options.graph),
|
|
573
|
+
gateRows: gateRows(events),
|
|
574
|
+
fleetRows: fleetRows(events),
|
|
382
575
|
statusItems: [
|
|
383
|
-
{
|
|
576
|
+
{
|
|
577
|
+
state: tipPassed === undefined ? "neutral" : tipPassed ? "pass" : "fail",
|
|
578
|
+
text: `tip-verify ${tipPassed === undefined ? ABSENT_FIELD : tipPassed ? "passed" : "FAILED"}`,
|
|
579
|
+
},
|
|
384
580
|
{ state: "pass", text: `done ${done}` },
|
|
385
581
|
{ state: failed > 0 ? "fail" : "neutral", text: `failed ${failed}` },
|
|
386
582
|
{ state: human > 0 ? "warn" : "neutral", text: `human ${human}` },
|