tickmarkr 1.66.0 → 1.68.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/commands/run.js +9 -0
- package/dist/cli/commands/status.d.ts +20 -0
- package/dist/cli/commands/status.js +41 -27
- package/dist/compile/collateral.d.ts +1 -0
- package/dist/compile/collateral.js +52 -3
- package/dist/gates/run-gates.js +13 -6
- package/dist/run/daemon.js +26 -2
- package/dist/run/git.d.ts +2 -0
- package/dist/run/git.js +9 -0
- package/dist/tui/app.d.ts +32 -0
- package/dist/tui/app.js +223 -19
- package/dist/tui/save.d.ts +38 -0
- package/dist/tui/save.js +96 -0
- package/dist/tui/staging.d.ts +29 -0
- package/dist/tui/staging.js +78 -0
- package/dist/tui/views/consult-dossier.d.ts +49 -0
- package/dist/tui/views/consult-dossier.js +169 -0
- package/dist/tui/views/diff-modal.d.ts +9 -0
- package/dist/tui/views/diff-modal.js +19 -0
- package/dist/tui/views/fleet-view.d.ts +23 -1
- package/dist/tui/views/fleet-view.js +108 -14
- package/dist/tui/views/routing-view.d.ts +22 -3
- package/dist/tui/views/routing-view.js +152 -16
- package/dist/tui/views/runs-view.d.ts +70 -0
- package/dist/tui/views/runs-view.js +387 -0
- package/package.json +1 -1
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { channelsFromConfig } from "../../adapters/types.js";
|
|
2
2
|
import { candidateRow, shapeCandidates } from "../../cli/commands/fleet-picker.js";
|
|
3
|
-
import { INTEGRITY_FLOOR_SHAPES, loadConfigWithMode, } from "../../config/config.js";
|
|
3
|
+
import { DEFAULT_CONFIG, fleetEditableFromConfig, INTEGRITY_FLOOR_SHAPES, loadConfigWithMode, ROUTING_MODES, TIER_RANK, } from "../../config/config.js";
|
|
4
4
|
import { SHAPES } from "../../graph/schema.js";
|
|
5
|
+
import { FleetStaging } from "../staging.js";
|
|
5
6
|
// Same preview task fleet.ts's picker ranks (one per shape, complexity 3): the inspector must
|
|
6
7
|
// rank the exact inputs the fleet editor ranks, or the two surfaces could disagree.
|
|
7
8
|
export function routingPreviewTask(shape) {
|
|
@@ -22,6 +23,43 @@ export function routingPreviewTask(shape) {
|
|
|
22
23
|
};
|
|
23
24
|
}
|
|
24
25
|
const isIntegrity = (shape) => INTEGRITY_FLOOR_SHAPES.includes(shape);
|
|
26
|
+
// Staged-cell glyph — the same ● the shell status bar counts staged changes with (app.ts).
|
|
27
|
+
const STAGED_MARK = "●";
|
|
28
|
+
const lowerTier = (t) => (t === "frontier" ? "mid" : "cheap");
|
|
29
|
+
const maxTier = (a, b) => (TIER_RANK[a] >= TIER_RANK[b] ? a : b);
|
|
30
|
+
// config.ts presetFloor mirrored (module-private there): a mode compiles into floors at load,
|
|
31
|
+
// so staging a mode stages exactly this table.
|
|
32
|
+
function presetFloor(mode, shape) {
|
|
33
|
+
const dflt = DEFAULT_CONFIG.routing.floors[shape] ?? "cheap";
|
|
34
|
+
if (mode === "partner-led")
|
|
35
|
+
return "frontier";
|
|
36
|
+
if (mode === "staff-led")
|
|
37
|
+
return maxTier(lowerTier(dflt), isIntegrity(shape) ? "frontier" : "cheap");
|
|
38
|
+
return dflt;
|
|
39
|
+
}
|
|
40
|
+
function floorsEqual(a, b) {
|
|
41
|
+
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
|
|
42
|
+
for (const k of keys)
|
|
43
|
+
if (a[k] !== b[k])
|
|
44
|
+
return false;
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
/** The header mode is DERIVED from the buffer, never stored: clean floors show the loaded mode;
|
|
48
|
+
* floors matching a preset show that mode (staged); anything else reads "custom". */
|
|
49
|
+
function deriveMode(loadedMode, loaded, buffer) {
|
|
50
|
+
if (floorsEqual(loaded.floors, buffer.floors))
|
|
51
|
+
return { mode: loadedMode, staged: false };
|
|
52
|
+
for (const mode of ROUTING_MODES) {
|
|
53
|
+
if (SHAPES.every((s) => buffer.floors[s] === presetFloor(mode, s)))
|
|
54
|
+
return { mode, staged: true };
|
|
55
|
+
}
|
|
56
|
+
return { mode: "custom", staged: true };
|
|
57
|
+
}
|
|
58
|
+
/** The config the router would see with the staged policy applied: map + floors ride the buffer
|
|
59
|
+
* (deny/tier edits are the fleet view's lane, T3). The buffer snapshot is already detached. */
|
|
60
|
+
function effectiveConfig(cfg, buffer) {
|
|
61
|
+
return { ...cfg, routing: { ...cfg.routing, map: buffer.map, floors: buffer.floors } };
|
|
62
|
+
}
|
|
25
63
|
// No-arg shell path (app.ts constructs views without deps): load the operator's resolved config
|
|
26
64
|
// and derive channels from the configured tier table. Best-effort — a load failure degrades the
|
|
27
65
|
// view to an explanatory note instead of breaking the studio.
|
|
@@ -37,60 +75,158 @@ function loadDefaultData(repoRoot) {
|
|
|
37
75
|
}
|
|
38
76
|
export function createRoutingView(deps = {}) {
|
|
39
77
|
const data = deps.data ?? loadDefaultData(deps.repoRoot ?? process.cwd());
|
|
78
|
+
// The one edit authority: every mutating method below is a staging.apply()/revert() over this
|
|
79
|
+
// buffer, so a staged change can never hide in private view state.
|
|
80
|
+
const staging = deps.staging ??
|
|
81
|
+
new FleetStaging(data ? fleetEditableFromConfig(data.cfg) : { denyAdapters: [], denyModels: [], tiers: {}, map: {}, floors: {} });
|
|
40
82
|
let cursor = 0;
|
|
41
83
|
let inspector = null;
|
|
84
|
+
const rankInspector = (shape) => {
|
|
85
|
+
if (!data)
|
|
86
|
+
return [];
|
|
87
|
+
// THE parity seam: rank through the same shared picker glue the fleet editor uses — no
|
|
88
|
+
// comparator lives here. Clean buffer ⇒ the injected config by reference (the delegation
|
|
89
|
+
// tests pin that identity); staged edits ⇒ the effective config, what the router would
|
|
90
|
+
// choose given the staged inputs.
|
|
91
|
+
const cfgNow = staging.isDirty ? effectiveConfig(data.cfg, staging.current) : data.cfg;
|
|
92
|
+
return shapeCandidates(routingPreviewTask(shape), cfgNow, data.channels, data.profile);
|
|
93
|
+
};
|
|
94
|
+
const rowOf = (c) => candidateRow(c, data?.cfg.pricing ?? {});
|
|
95
|
+
// Re-rank the open inspector after a staging mutation changed the ranking inputs.
|
|
96
|
+
const refreshInspector = () => {
|
|
97
|
+
if (!inspector)
|
|
98
|
+
return;
|
|
99
|
+
inspector.ranked = rankInspector(inspector.shape);
|
|
100
|
+
inspector.cursor = Math.min(inspector.cursor, Math.max(inspector.ranked.length - 1, 0));
|
|
101
|
+
};
|
|
42
102
|
return {
|
|
43
103
|
id: "routing",
|
|
44
104
|
label: "Routing",
|
|
45
105
|
get selectedShape() {
|
|
46
106
|
return SHAPES[cursor];
|
|
47
107
|
},
|
|
108
|
+
get selectedMode() {
|
|
109
|
+
const loadedMode = data?.mode ?? data?.cfg.routing.mode ?? "risk-based";
|
|
110
|
+
return deriveMode(loadedMode, staging.loadedState, staging.current).mode;
|
|
111
|
+
},
|
|
112
|
+
get staging() {
|
|
113
|
+
return staging;
|
|
114
|
+
},
|
|
48
115
|
moveCursor(delta) {
|
|
49
116
|
cursor = (cursor + delta + SHAPES.length) % SHAPES.length;
|
|
50
117
|
},
|
|
118
|
+
moveInspectorCursor(delta) {
|
|
119
|
+
if (!inspector || inspector.ranked.length === 0)
|
|
120
|
+
return;
|
|
121
|
+
const n = inspector.ranked.length;
|
|
122
|
+
inspector.cursor = (inspector.cursor + delta + n) % n;
|
|
123
|
+
},
|
|
51
124
|
openInspector(shape) {
|
|
52
125
|
if (!data)
|
|
53
126
|
return;
|
|
54
127
|
const target = shape ?? SHAPES[cursor];
|
|
55
|
-
|
|
56
|
-
// comparator lives here — so rows, cost signals, and provenance match the router's choice.
|
|
57
|
-
const ranked = shapeCandidates(routingPreviewTask(target), data.cfg, data.channels, data.profile);
|
|
58
|
-
inspector = { shape: target, rows: ranked.map((c) => candidateRow(c, data.cfg.pricing)) };
|
|
128
|
+
inspector = { shape: target, cursor: 0, ranked: rankInspector(target) };
|
|
59
129
|
},
|
|
60
130
|
closeInspector() {
|
|
61
131
|
inspector = null;
|
|
62
132
|
},
|
|
133
|
+
stageInspectorPin() {
|
|
134
|
+
if (!data || !inspector)
|
|
135
|
+
return;
|
|
136
|
+
const target = inspector.shape;
|
|
137
|
+
const chosen = inspector.ranked[inspector.cursor];
|
|
138
|
+
if (!chosen)
|
|
139
|
+
return;
|
|
140
|
+
const { adapter, model } = chosen.assignment;
|
|
141
|
+
staging.apply((buffer) => {
|
|
142
|
+
buffer.map[target] = { ...buffer.map[target], pin: { via: adapter, model } };
|
|
143
|
+
});
|
|
144
|
+
// No re-rank: shapeCandidates strips the shape's own pin from the ranking inputs.
|
|
145
|
+
},
|
|
146
|
+
stageFloor(tier) {
|
|
147
|
+
if (!data)
|
|
148
|
+
return;
|
|
149
|
+
const target = SHAPES[cursor];
|
|
150
|
+
staging.apply((buffer) => {
|
|
151
|
+
buffer.floors[target] = tier;
|
|
152
|
+
});
|
|
153
|
+
refreshInspector();
|
|
154
|
+
},
|
|
155
|
+
stagePrefer(chain) {
|
|
156
|
+
if (!data)
|
|
157
|
+
return;
|
|
158
|
+
const target = SHAPES[cursor];
|
|
159
|
+
staging.apply((buffer) => {
|
|
160
|
+
const entry = { ...buffer.map[target] };
|
|
161
|
+
if (chain.length)
|
|
162
|
+
entry.prefer = [...chain];
|
|
163
|
+
else
|
|
164
|
+
delete entry.prefer;
|
|
165
|
+
// an emptied entry leaves no phantom delta behind
|
|
166
|
+
if (Object.keys(entry).length)
|
|
167
|
+
buffer.map[target] = entry;
|
|
168
|
+
else
|
|
169
|
+
delete buffer.map[target];
|
|
170
|
+
});
|
|
171
|
+
refreshInspector();
|
|
172
|
+
},
|
|
173
|
+
stageMode(mode) {
|
|
174
|
+
if (!data)
|
|
175
|
+
return;
|
|
176
|
+
staging.apply((buffer) => {
|
|
177
|
+
for (const shape of SHAPES)
|
|
178
|
+
buffer.floors[shape] = presetFloor(mode, shape);
|
|
179
|
+
});
|
|
180
|
+
refreshInspector();
|
|
181
|
+
},
|
|
182
|
+
revert() {
|
|
183
|
+
staging.revert();
|
|
184
|
+
refreshInspector();
|
|
185
|
+
},
|
|
63
186
|
inspectorRows() {
|
|
64
|
-
return inspector ?
|
|
187
|
+
return inspector ? inspector.ranked.map(rowOf) : null;
|
|
65
188
|
},
|
|
66
189
|
render: () => {
|
|
67
|
-
const lines = ["Routing view — per-shape policy grid
|
|
190
|
+
const lines = ["Routing view — per-shape policy grid"];
|
|
68
191
|
if (!data) {
|
|
69
192
|
lines.push("routing data unavailable — see `tickmarkr fleet --print` for the line-mode surface");
|
|
70
193
|
return lines;
|
|
71
194
|
}
|
|
72
195
|
const { cfg } = data;
|
|
73
|
-
const
|
|
74
|
-
|
|
196
|
+
const buffer = staging.current;
|
|
197
|
+
const loaded = staging.loadedState;
|
|
198
|
+
const loadedMode = data.mode ?? cfg.routing.mode ?? "risk-based";
|
|
199
|
+
const { mode, staged: modeStaged } = deriveMode(loadedMode, loaded, buffer);
|
|
200
|
+
lines.push(`mode: ${mode}${modeStaged ? ` ${STAGED_MARK} (staged)` : ""}`);
|
|
75
201
|
lines.push(` ${"shape".padEnd(10)}${"floor".padEnd(10)}${"pin".padEnd(20)}prefer`);
|
|
76
202
|
for (const [i, shape] of SHAPES.entries()) {
|
|
77
|
-
const entry =
|
|
78
|
-
const
|
|
203
|
+
const entry = buffer.map[shape];
|
|
204
|
+
const loadedEntry = loaded.map[shape];
|
|
205
|
+
const floor = buffer.floors[shape] ?? "—";
|
|
206
|
+
const floorStaged = loaded.floors[shape] !== buffer.floors[shape];
|
|
79
207
|
const marker = isIntegrity(shape) ? "*" : "";
|
|
80
208
|
const pin = entry?.pin ? `${entry.pin.via}:${entry.pin.model}` : "—";
|
|
81
209
|
const prefer = entry?.prefer?.length ? entry.prefer.join(", ") : "—";
|
|
210
|
+
const pinStaged = JSON.stringify(loadedEntry?.pin ?? null) !== JSON.stringify(entry?.pin ?? null);
|
|
211
|
+
const preferStaged = JSON.stringify(loadedEntry?.prefer ?? null) !== JSON.stringify(entry?.prefer ?? null);
|
|
82
212
|
const sel = i === cursor ? "❯" : " ";
|
|
83
|
-
|
|
213
|
+
const floorCell = `${floor}${marker}${floorStaged ? STAGED_MARK : ""}`.padEnd(10);
|
|
214
|
+
const pinCell = `${pin}${pinStaged ? STAGED_MARK : ""}`.padEnd(20);
|
|
215
|
+
const preferCell = `${prefer}${preferStaged ? STAGED_MARK : ""}`;
|
|
216
|
+
lines.push(`${sel} ${shape.padEnd(10)}${floorCell}${pinCell}${preferCell}`);
|
|
84
217
|
}
|
|
85
218
|
lines.push("* integrity set: never below frontier");
|
|
86
|
-
|
|
219
|
+
if (staging.isDirty)
|
|
220
|
+
lines.push(`${STAGED_MARK} staged — not saved`);
|
|
221
|
+
lines.push(`pick: ${SHAPES[cursor]} — enter opens the candidate inspector`);
|
|
87
222
|
if (inspector) {
|
|
88
223
|
lines.push(`─ candidates: ${inspector.shape} — the fleet picker ranking for the same inputs ──`);
|
|
89
|
-
if (inspector.
|
|
224
|
+
if (inspector.ranked.length === 0)
|
|
90
225
|
lines.push(" (no routable candidates)");
|
|
91
|
-
for (const [j,
|
|
92
|
-
lines.push(`${j ===
|
|
226
|
+
for (const [j, c] of inspector.ranked.entries()) {
|
|
227
|
+
lines.push(`${j === inspector.cursor ? "❯ " : " "}${rowOf(c)}`);
|
|
93
228
|
}
|
|
229
|
+
lines.push("select stages the highlighted candidate as this shape's pin");
|
|
94
230
|
}
|
|
95
231
|
return lines;
|
|
96
232
|
},
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { type RunGraph, type Task, type TaskStatus } from "../../graph/schema.js";
|
|
2
|
+
import { type JournalEvent, type TelemetryRow, type WorkerResultCause } from "../../run/journal.js";
|
|
3
|
+
import { type GateState } from "../../cli/commands/status.js";
|
|
4
|
+
import type { View } from "../app.js";
|
|
5
|
+
export type AttemptRecord = {
|
|
6
|
+
attempt: number;
|
|
7
|
+
channel: string;
|
|
8
|
+
outcome: "clean" | "failed" | "in-flight" | "aborted";
|
|
9
|
+
/** typed worker-result failure reason when the attempt did not finish cleanly */
|
|
10
|
+
cause?: WorkerResultCause;
|
|
11
|
+
/** worker-result summary or consult-verdict reason, when present */
|
|
12
|
+
note?: string;
|
|
13
|
+
};
|
|
14
|
+
export type RunsTask = {
|
|
15
|
+
task: Task;
|
|
16
|
+
status: TaskStatus;
|
|
17
|
+
states: GateState[];
|
|
18
|
+
channel: string;
|
|
19
|
+
ctx?: number;
|
|
20
|
+
activity?: string;
|
|
21
|
+
attempts: AttemptRecord[];
|
|
22
|
+
};
|
|
23
|
+
/** Everything the Runs cockpit renders — loaded by the caller, never by the render path. */
|
|
24
|
+
export type RunsViewData = {
|
|
25
|
+
runId?: string;
|
|
26
|
+
events: JournalEvent[];
|
|
27
|
+
graph: RunGraph;
|
|
28
|
+
/** Whether the journal is comparable with the loaded graph. Defaults to true. */
|
|
29
|
+
comparable?: boolean;
|
|
30
|
+
/** Per-tier per-task pricing from config, rendered ONLY through the shared costSignal formatter. */
|
|
31
|
+
pricing?: Record<string, number>;
|
|
32
|
+
/** This run's observed usage rows (telemetry.jsonl), folded per channel by the cost ticker. */
|
|
33
|
+
telemetry?: TelemetryRow[];
|
|
34
|
+
};
|
|
35
|
+
/** Options for creating a Runs view. Backward-compatible: plain `createRunsView(data)` still works. */
|
|
36
|
+
export type RunsViewOptions = {
|
|
37
|
+
data?: RunsViewData;
|
|
38
|
+
repoRoot?: string;
|
|
39
|
+
/** Notify the caller of a single-line notice change (confirmation, refusal, progress, result). */
|
|
40
|
+
onNotice?: (message: string | null) => void;
|
|
41
|
+
/** Refresh injected data after a mutation. Called after a successful approval. */
|
|
42
|
+
reload?: () => RunsViewData | Promise<RunsViewData>;
|
|
43
|
+
};
|
|
44
|
+
export type RunsView = View & {
|
|
45
|
+
/** Handle a decoded key name ("up" | "down" | "a" | "y"). */
|
|
46
|
+
key(name: string): void;
|
|
47
|
+
/** Index of the cursor in the task list. */
|
|
48
|
+
cursor: number;
|
|
49
|
+
/** The currently selected task, if any. */
|
|
50
|
+
selectedTask(): RunsTask | undefined;
|
|
51
|
+
/** Promise of an in-flight approval, exposed for tests. */
|
|
52
|
+
approval?: Promise<void>;
|
|
53
|
+
};
|
|
54
|
+
/** Build the injected raw data into task rows. Pure function, no filesystem access. */
|
|
55
|
+
export declare function foldRunsTasks(data: RunsViewData): RunsTask[];
|
|
56
|
+
export type CostTickerRow = {
|
|
57
|
+
/** adapter:model */
|
|
58
|
+
key: string;
|
|
59
|
+
/** verbatim output of the shared cost-signal formatter */
|
|
60
|
+
signal: string;
|
|
61
|
+
/** distinct tasks dispatched on this channel */
|
|
62
|
+
tasks: number;
|
|
63
|
+
/** summed observed token usage for this channel; undefined when nothing was metered */
|
|
64
|
+
tokens?: number;
|
|
65
|
+
};
|
|
66
|
+
/** Sum observed token usage per channel for the loaded run. Dispatch events name the channel's
|
|
67
|
+
* full assignment (billing channel + tier); telemetry rows contribute the metered tokens. A
|
|
68
|
+
* telemetry-only channel degrades costSignal to "api metered" — never an invented price. */
|
|
69
|
+
export declare function foldCostTicker(data: RunsViewData): CostTickerRow[];
|
|
70
|
+
export declare function createRunsView(data?: RunsViewData, opts?: Omit<RunsViewOptions, "data">): RunsView;
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
// T1 (v1.68): Runs cockpit — journal timeline, per-task gate ladder, attempt history.
|
|
2
|
+
// Pure render over INJECTED data: this module never touches the filesystem. The caller hands in
|
|
3
|
+
// a journal event list and a compiled graph; the view folds them into task cards and a dispatch-
|
|
4
|
+
// ordered attempt history itself. The task card language and gate ladder reuse the existing status
|
|
5
|
+
// command helpers verbatim rather than reimplementing them.
|
|
6
|
+
import { GLYPHS, dim, fail, legend, ok, statusRow, warn } from "../../brand.js";
|
|
7
|
+
import { channelKey } from "../../adapters/types.js";
|
|
8
|
+
import { GATE_NAMES, } from "../../graph/schema.js";
|
|
9
|
+
import { foldActivity } from "../../run/activity.js";
|
|
10
|
+
import { formatJournalNarration } from "../../run/journal.js";
|
|
11
|
+
import { costSignal } from "../../cli/commands/fleet-picker.js";
|
|
12
|
+
import { approve } from "../../cli/commands/approve.js";
|
|
13
|
+
import { gateChain, gateStates, defaultGateStates, humanGateSuffix, shortGoal, failedGates, } from "../../cli/commands/status.js";
|
|
14
|
+
import { renderDossierPlaceholder } from "./consult-dossier.js";
|
|
15
|
+
const channelOf = (assignment) => {
|
|
16
|
+
const a = assignment;
|
|
17
|
+
return typeof a?.adapter === "string" && typeof a.model === "string" ? `${a.adapter}:${a.model}` : "unknown channel";
|
|
18
|
+
};
|
|
19
|
+
const taskVerdict = (st) => st === "done" ? "pass" : st === "failed" ? "fail" : st === "human" ? "warn" : "neutral";
|
|
20
|
+
/** Local replay of task statuses from events only — mirrors Journal.replayStatuses but keeps the
|
|
21
|
+
* view filesystem-free. */
|
|
22
|
+
function replayStatuses(events) {
|
|
23
|
+
const s = new Map();
|
|
24
|
+
for (const e of events) {
|
|
25
|
+
if (!e.taskId)
|
|
26
|
+
continue;
|
|
27
|
+
if (e.event === "task-dispatch")
|
|
28
|
+
s.set(e.taskId, "running");
|
|
29
|
+
else if (e.event === "task-done")
|
|
30
|
+
s.set(e.taskId, "done");
|
|
31
|
+
else if (e.event === "task-failed")
|
|
32
|
+
s.set(e.taskId, "failed");
|
|
33
|
+
else if (e.event === "task-human")
|
|
34
|
+
s.set(e.taskId, "human");
|
|
35
|
+
else if (e.event === "task-approved")
|
|
36
|
+
s.set(e.taskId, "pending");
|
|
37
|
+
}
|
|
38
|
+
for (const [id, st] of s)
|
|
39
|
+
if (st === "running")
|
|
40
|
+
s.set(id, "pending");
|
|
41
|
+
return s;
|
|
42
|
+
}
|
|
43
|
+
/** Fold one task's dispatch-ordered attempt history. Each task-dispatch opens an attempt; a
|
|
44
|
+
* worker-result sets the attempt's outcome; task-done/task-failed/task-approved finish it. A new
|
|
45
|
+
* dispatch while the previous attempt is still in-flight marks it aborted. */
|
|
46
|
+
function foldAttempts(events, taskId) {
|
|
47
|
+
const attempts = [];
|
|
48
|
+
let open;
|
|
49
|
+
const finish = () => {
|
|
50
|
+
if (open) {
|
|
51
|
+
attempts.push(open);
|
|
52
|
+
open = undefined;
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
for (const e of events) {
|
|
56
|
+
if (e.taskId !== taskId)
|
|
57
|
+
continue;
|
|
58
|
+
if (e.event === "task-dispatch" || e.event === "escalation") {
|
|
59
|
+
if (open) {
|
|
60
|
+
if (open.outcome === "in-flight")
|
|
61
|
+
open.outcome = "aborted";
|
|
62
|
+
finish();
|
|
63
|
+
}
|
|
64
|
+
const attempt = (Number.isInteger(e.data.attempt) ? e.data.attempt : 0) + 1;
|
|
65
|
+
open = { attempt, channel: channelOf(e.data.assignment), outcome: "in-flight" };
|
|
66
|
+
}
|
|
67
|
+
else if (e.event === "worker-result" && open) {
|
|
68
|
+
const ok = e.data.ok === true;
|
|
69
|
+
const finished = e.data.finished === true;
|
|
70
|
+
if (ok && finished) {
|
|
71
|
+
open.outcome = "clean";
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
open.outcome = "failed";
|
|
75
|
+
open.cause = e.data.cause ?? undefined;
|
|
76
|
+
open.note = typeof e.data.summary === "string" ? e.data.summary : undefined;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
else if (e.event === "task-done" || e.event === "task-approved") {
|
|
80
|
+
if (open && open.outcome === "in-flight")
|
|
81
|
+
open.outcome = "clean";
|
|
82
|
+
finish();
|
|
83
|
+
}
|
|
84
|
+
else if (e.event === "task-failed") {
|
|
85
|
+
if (open && open.outcome === "in-flight")
|
|
86
|
+
open.outcome = "failed";
|
|
87
|
+
finish();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
finish();
|
|
91
|
+
return attempts;
|
|
92
|
+
}
|
|
93
|
+
/** Build the injected raw data into task rows. Pure function, no filesystem access. */
|
|
94
|
+
export function foldRunsTasks(data) {
|
|
95
|
+
const { events, graph, comparable = true } = data;
|
|
96
|
+
const statuses = replayStatuses(events);
|
|
97
|
+
const assignments = new Map();
|
|
98
|
+
const contexts = new Map();
|
|
99
|
+
for (const e of events) {
|
|
100
|
+
if (e.event === "task-dispatch" && e.taskId) {
|
|
101
|
+
assignments.set(e.taskId, channelOf(e.data.assignment));
|
|
102
|
+
}
|
|
103
|
+
if (e.event === "context-sample" && e.taskId && typeof e.data.tokens === "number" && Number.isFinite(e.data.tokens)) {
|
|
104
|
+
contexts.set(e.taskId, e.data.tokens);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const activityTasks = graph.tasks.map((t) => ({
|
|
108
|
+
id: t.id,
|
|
109
|
+
gates: t.gates,
|
|
110
|
+
deps: t.deps,
|
|
111
|
+
status: statuses.get(t.id) ?? t.status,
|
|
112
|
+
}));
|
|
113
|
+
const activity = foldActivity(comparable ? events : [], activityTasks);
|
|
114
|
+
return graph.tasks.map((t) => {
|
|
115
|
+
const status = statuses.get(t.id) ?? t.status;
|
|
116
|
+
const states = comparable ? gateStates(t, events) : defaultGateStates(t);
|
|
117
|
+
const channel = assignments.get(t.id) ?? "-";
|
|
118
|
+
return {
|
|
119
|
+
task: t,
|
|
120
|
+
status,
|
|
121
|
+
states,
|
|
122
|
+
channel,
|
|
123
|
+
ctx: contexts.get(t.id),
|
|
124
|
+
activity: activity.cells.get(t.id),
|
|
125
|
+
attempts: foldAttempts(events, t.id),
|
|
126
|
+
};
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
const CARD_LINES = 3; // 2 content lines + 1 blank separator
|
|
130
|
+
const usageTotal = (u) => u.input + u.output + (u.cacheRead ?? 0) + (u.cacheWrite ?? 0) + (u.reasoning ?? 0);
|
|
131
|
+
/** Sum observed token usage per channel for the loaded run. Dispatch events name the channel's
|
|
132
|
+
* full assignment (billing channel + tier); telemetry rows contribute the metered tokens. A
|
|
133
|
+
* telemetry-only channel degrades costSignal to "api metered" — never an invented price. */
|
|
134
|
+
export function foldCostTicker(data) {
|
|
135
|
+
const pricing = data.pricing ?? {};
|
|
136
|
+
const rows = new Map();
|
|
137
|
+
const rowFor = (key) => {
|
|
138
|
+
let r = rows.get(key);
|
|
139
|
+
if (!r) {
|
|
140
|
+
r = { tasks: new Set(), tokens: 0, metered: false };
|
|
141
|
+
rows.set(key, r);
|
|
142
|
+
}
|
|
143
|
+
return r;
|
|
144
|
+
};
|
|
145
|
+
for (const e of data.events) {
|
|
146
|
+
if (e.event !== "task-dispatch" || !e.taskId)
|
|
147
|
+
continue;
|
|
148
|
+
const a = e.data.assignment;
|
|
149
|
+
if (!a || typeof a.adapter !== "string" || typeof a.model !== "string")
|
|
150
|
+
continue;
|
|
151
|
+
const r = rowFor(channelKey(a));
|
|
152
|
+
r.tasks.add(e.taskId);
|
|
153
|
+
if ((a.channel === "sub" || a.channel === "api") && typeof a.tier === "string")
|
|
154
|
+
r.assignment = a;
|
|
155
|
+
}
|
|
156
|
+
for (const t of data.telemetry ?? []) {
|
|
157
|
+
const r = rowFor(channelKey(t));
|
|
158
|
+
if (t.tokens) {
|
|
159
|
+
r.tokens += usageTotal(t.tokens);
|
|
160
|
+
r.metered = true;
|
|
161
|
+
}
|
|
162
|
+
if (!r.assignment && (t.channel === "sub" || t.channel === "api")) {
|
|
163
|
+
r.assignment = { adapter: t.adapter, model: t.model, channel: t.channel, tier: "" };
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return [...rows.entries()].map(([key, r]) => ({
|
|
167
|
+
key,
|
|
168
|
+
signal: r.assignment ? costSignal(r.assignment, pricing) : "channel unknown",
|
|
169
|
+
tasks: r.tasks.size,
|
|
170
|
+
tokens: r.metered ? r.tokens : undefined,
|
|
171
|
+
}));
|
|
172
|
+
}
|
|
173
|
+
/** Last tip-verify event wins; none recorded yet ⇒ pending — never a false pass or fail. */
|
|
174
|
+
function tipVerifyState(events) {
|
|
175
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
176
|
+
const e = events[i];
|
|
177
|
+
if (e.event === "tip-verify" || e.event === "tip-verify-failed") {
|
|
178
|
+
return {
|
|
179
|
+
state: e.event === "tip-verify" ? "passed" : "failed",
|
|
180
|
+
gate: typeof e.data.gate === "string" ? e.data.gate : undefined,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return { state: "pending" };
|
|
185
|
+
}
|
|
186
|
+
function tipVerifyLine(events) {
|
|
187
|
+
const tip = tipVerifyState(events);
|
|
188
|
+
const word = tip.state === "passed" ? ok("passed") : tip.state === "failed" ? fail("failed") : dim("pending");
|
|
189
|
+
return legend(` tip-verify: ${tip.gate ? `${tip.gate} ` : ""}`) + word;
|
|
190
|
+
}
|
|
191
|
+
/** Cost values stay plain (CLI-DESIGN "data plain"): costSignal's output verbatim, never colorized. */
|
|
192
|
+
function costTickerPanel(data) {
|
|
193
|
+
const rows = foldCostTicker(data);
|
|
194
|
+
const lines = ["", dim("── cost ticker ──")];
|
|
195
|
+
if (rows.length === 0) {
|
|
196
|
+
lines.push(" no channel usage observed yet");
|
|
197
|
+
return lines;
|
|
198
|
+
}
|
|
199
|
+
const keyW = Math.max(...rows.map((r) => r.key.length));
|
|
200
|
+
for (const r of rows) {
|
|
201
|
+
const parts = [r.signal, `${r.tasks} task${r.tasks === 1 ? "" : "s"}`];
|
|
202
|
+
if (r.tokens !== undefined)
|
|
203
|
+
parts.push(`${r.tokens} tokens`);
|
|
204
|
+
lines.push(` ${r.key.padEnd(keyW)} ${parts.join(" · ")}`);
|
|
205
|
+
}
|
|
206
|
+
return lines;
|
|
207
|
+
}
|
|
208
|
+
export function createRunsView(data, opts) {
|
|
209
|
+
let currentData = data;
|
|
210
|
+
let tasks = currentData ? foldRunsTasks(currentData) : [];
|
|
211
|
+
let cursor = 0;
|
|
212
|
+
let confirming = null;
|
|
213
|
+
let notice = null;
|
|
214
|
+
let busy = false;
|
|
215
|
+
let approvalPromise;
|
|
216
|
+
const repoRoot = opts?.repoRoot;
|
|
217
|
+
const onNotice = opts?.onNotice ?? (() => { });
|
|
218
|
+
const reload = opts?.reload;
|
|
219
|
+
const setNotice = (message) => {
|
|
220
|
+
notice = message;
|
|
221
|
+
onNotice(message);
|
|
222
|
+
};
|
|
223
|
+
const refresh = async () => {
|
|
224
|
+
if (!reload)
|
|
225
|
+
return;
|
|
226
|
+
currentData = await reload();
|
|
227
|
+
tasks = currentData ? foldRunsTasks(currentData) : [];
|
|
228
|
+
};
|
|
229
|
+
const runApprove = async (taskId) => {
|
|
230
|
+
if (!repoRoot || !currentData?.runId) {
|
|
231
|
+
setNotice("approval not available — no run loaded");
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
busy = true;
|
|
235
|
+
setNotice("approving…");
|
|
236
|
+
try {
|
|
237
|
+
const message = await approve([currentData.runId, taskId], repoRoot);
|
|
238
|
+
await refresh();
|
|
239
|
+
setNotice(message);
|
|
240
|
+
}
|
|
241
|
+
catch (e) {
|
|
242
|
+
setNotice(e instanceof Error ? e.message : String(e));
|
|
243
|
+
}
|
|
244
|
+
finally {
|
|
245
|
+
busy = false;
|
|
246
|
+
approvalPromise = undefined;
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
const view = {
|
|
250
|
+
id: "runs",
|
|
251
|
+
label: "Runs",
|
|
252
|
+
cursor: 0,
|
|
253
|
+
key(name) {
|
|
254
|
+
if (busy)
|
|
255
|
+
return;
|
|
256
|
+
if (confirming) {
|
|
257
|
+
const taskId = confirming.taskId;
|
|
258
|
+
confirming = null;
|
|
259
|
+
if (name === "y") {
|
|
260
|
+
approvalPromise = runApprove(taskId);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
setNotice("approval cancelled");
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
if (!tasks.length)
|
|
267
|
+
return;
|
|
268
|
+
if (name === "up")
|
|
269
|
+
cursor = Math.max(cursor - 1, 0);
|
|
270
|
+
else if (name === "down")
|
|
271
|
+
cursor = Math.min(cursor + 1, tasks.length - 1);
|
|
272
|
+
else if (name === "a") {
|
|
273
|
+
if (!repoRoot || !currentData?.runId) {
|
|
274
|
+
setNotice("approval not available — no run loaded");
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
const task = tasks[cursor];
|
|
278
|
+
if (!task)
|
|
279
|
+
return;
|
|
280
|
+
if (task.status === "human") {
|
|
281
|
+
confirming = { taskId: task.task.id };
|
|
282
|
+
setNotice(`approve ${task.task.id}? releases the parked human gate and resumes the run. [y] confirm [any key] cancel`);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
approvalPromise = runApprove(task.task.id);
|
|
286
|
+
}
|
|
287
|
+
this.cursor = cursor;
|
|
288
|
+
},
|
|
289
|
+
selectedTask() {
|
|
290
|
+
return tasks[cursor];
|
|
291
|
+
},
|
|
292
|
+
render(props) {
|
|
293
|
+
if (!currentData || !currentData.runId) {
|
|
294
|
+
return emptyState();
|
|
295
|
+
}
|
|
296
|
+
const last = currentData.events.at(-1);
|
|
297
|
+
const now = last ? formatJournalNarration(last) : undefined;
|
|
298
|
+
const lines = [];
|
|
299
|
+
if (now)
|
|
300
|
+
lines.push(legend(` now: ${now}`));
|
|
301
|
+
else
|
|
302
|
+
lines.push(legend(" now: —"));
|
|
303
|
+
lines.push(tipVerifyLine(currentData.events));
|
|
304
|
+
lines.push(legend(` gates: ${GATE_NAMES.join(" · ")}`));
|
|
305
|
+
lines.push("");
|
|
306
|
+
const { rows } = props;
|
|
307
|
+
const ticker = costTickerPanel(currentData);
|
|
308
|
+
// Reserve space for: now, tip-verify, gates legend, blank, detail panel (blank + divider +
|
|
309
|
+
// content + attempts), cost ticker panel, optional notice line, trailing blank
|
|
310
|
+
const detailReserve = 6 + ticker.length + (notice ? 1 : 0);
|
|
311
|
+
const cardBudget = Math.max(0, rows - lines.length - detailReserve);
|
|
312
|
+
const visibleCount = Math.max(1, Math.floor(cardBudget / CARD_LINES));
|
|
313
|
+
const maxStart = Math.max(0, tasks.length - visibleCount);
|
|
314
|
+
let start = cursor - Math.floor(visibleCount / 2);
|
|
315
|
+
if (start < 0)
|
|
316
|
+
start = 0;
|
|
317
|
+
if (start > maxStart)
|
|
318
|
+
start = maxStart;
|
|
319
|
+
const end = Math.min(tasks.length, start + visibleCount);
|
|
320
|
+
const width = Math.min(props.cols, 100);
|
|
321
|
+
const idW = Math.max(2, ...tasks.map((c) => c.task.id.length));
|
|
322
|
+
const goalAvail = Math.max(8, width - (idW + 12));
|
|
323
|
+
const goals = tasks.map((c) => shortGoal(c.task.goal, goalAvail));
|
|
324
|
+
const goalW = Math.max(8, ...goals.map((s) => s.length));
|
|
325
|
+
const indent = " ".repeat(idW + 5);
|
|
326
|
+
for (let i = start; i < end; i++) {
|
|
327
|
+
const t = tasks[i];
|
|
328
|
+
const goal = goals[i];
|
|
329
|
+
const pointer = i === cursor ? `${GLYPHS.pointer} ` : " ";
|
|
330
|
+
const f = failedGates(t.states);
|
|
331
|
+
const human = humanGateSuffix(t.task, t.status, t.states);
|
|
332
|
+
const statusCell = renderStatusCell(t.status, f, human);
|
|
333
|
+
const line1 = ` ${pointer}${statusRow(taskVerdict(t.status), `${t.task.id.padEnd(idW)} ${goal.padEnd(goalW)} ${statusCell}`)}`;
|
|
334
|
+
const detailParts = [t.activity ?? t.channel];
|
|
335
|
+
if (t.ctx !== undefined)
|
|
336
|
+
detailParts.push(`ctx ${t.ctx}`);
|
|
337
|
+
const line2 = `${indent}${gateChain(t.states, true)} ${dim(detailParts.join(" · "))}`;
|
|
338
|
+
lines.push(line1, line2, "");
|
|
339
|
+
}
|
|
340
|
+
lines.push(...detailPanel(tasks[cursor]));
|
|
341
|
+
lines.push(...ticker);
|
|
342
|
+
if (notice)
|
|
343
|
+
lines.push(legend(` ${notice}`));
|
|
344
|
+
return lines;
|
|
345
|
+
},
|
|
346
|
+
};
|
|
347
|
+
Object.defineProperty(view, "approval", {
|
|
348
|
+
get: () => approvalPromise,
|
|
349
|
+
configurable: true,
|
|
350
|
+
});
|
|
351
|
+
return view;
|
|
352
|
+
}
|
|
353
|
+
function renderStatusCell(status, failed, human) {
|
|
354
|
+
const stWord = status === "done" ? ok(String(status))
|
|
355
|
+
: status === "failed" ? fail(String(status))
|
|
356
|
+
: status === "human" ? warn(String(status))
|
|
357
|
+
: String(status);
|
|
358
|
+
const dot = dim(" · ");
|
|
359
|
+
return stWord +
|
|
360
|
+
(failed.length ? dot + fail(failed.join(", ")) : "") +
|
|
361
|
+
(human ? dot + warn("awaiting approval") : "");
|
|
362
|
+
}
|
|
363
|
+
function detailPanel(task) {
|
|
364
|
+
if (!task)
|
|
365
|
+
return [];
|
|
366
|
+
const lines = ["", dim(`── ${task.task.id} — attempts & consult dossier ──`)];
|
|
367
|
+
if (task.attempts.length === 0) {
|
|
368
|
+
lines.push(` no attempts recorded for ${task.task.id}`);
|
|
369
|
+
return lines;
|
|
370
|
+
}
|
|
371
|
+
for (const a of task.attempts) {
|
|
372
|
+
const reason = a.cause ? ` — ${a.cause}` : a.note ? ` — ${a.note}` : "";
|
|
373
|
+
const statusWord = a.outcome === "clean" ? "done" : a.outcome === "failed" ? "failed" : "in flight";
|
|
374
|
+
lines.push(` attempt ${a.attempt} ${a.channel} ${statusWord}${reason}`);
|
|
375
|
+
}
|
|
376
|
+
lines.push("");
|
|
377
|
+
lines.push(dim(renderDossierPlaceholder()));
|
|
378
|
+
return lines;
|
|
379
|
+
}
|
|
380
|
+
function emptyState() {
|
|
381
|
+
return [
|
|
382
|
+
"",
|
|
383
|
+
" no run loaded — run `tickmarkr run` or `tickmarkr resume <runId>` to start one.",
|
|
384
|
+
" the Runs cockpit reads the active or most recent run's journal; nothing to show yet.",
|
|
385
|
+
"",
|
|
386
|
+
];
|
|
387
|
+
}
|