tickmarkr 1.67.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/status.d.ts +20 -0
- package/dist/cli/commands/status.js +9 -9
- 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 +3 -0
- package/dist/tui/app.js +14 -2
- package/dist/tui/views/consult-dossier.d.ts +49 -0
- package/dist/tui/views/consult-dossier.js +169 -0
- 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,5 +1,25 @@
|
|
|
1
|
+
import { type Task, type TaskStatus } from "../../graph/schema.js";
|
|
2
|
+
import { type JournalEvent } from "../../run/journal.js";
|
|
1
3
|
export type StatusOpts = {
|
|
2
4
|
iterations?: number;
|
|
3
5
|
sleep?: (ms: number) => Promise<void>;
|
|
4
6
|
};
|
|
7
|
+
export declare const GATE_KEYS: {
|
|
8
|
+
readonly build: "B";
|
|
9
|
+
readonly test: "T";
|
|
10
|
+
readonly lint: "L";
|
|
11
|
+
readonly evidence: "E";
|
|
12
|
+
readonly scope: "S";
|
|
13
|
+
readonly acceptance: "A";
|
|
14
|
+
readonly review: "R";
|
|
15
|
+
};
|
|
16
|
+
export declare const taskBox: (status: TaskStatus) => string;
|
|
17
|
+
export declare const gateBox: (state: "open" | "pass" | "fail" | "skip", unicode: boolean) => string;
|
|
18
|
+
export type GateState = "open" | "pass" | "fail" | "skip";
|
|
19
|
+
export declare const defaultGateStates: (task: Task) => GateState[];
|
|
20
|
+
export declare const gateStates: (task: Task, events: JournalEvent[]) => GateState[];
|
|
21
|
+
export declare const gateChain: (states: GateState[], unicode: boolean) => string;
|
|
22
|
+
export declare const failedGates: (states: GateState[]) => string[];
|
|
23
|
+
export declare const humanGateSuffix: (t: Task, st: TaskStatus, states: GateState[]) => string;
|
|
24
|
+
export declare const shortGoal: (goal: string, max: number) => string;
|
|
5
25
|
export declare function status(argv: string[], cwd?: string, opts?: StatusOpts): Promise<string>;
|
|
@@ -9,7 +9,7 @@ const NOT_COMPARABLE_NOTICE = "graph recompiled since this run — task states n
|
|
|
9
9
|
// The timer must keep the process ALIVE: an unref'd timer here let the event loop drain after the
|
|
10
10
|
// first frame, so a live `--watch` printed once and exited 0 (OBS-11). Never unref this.
|
|
11
11
|
const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
12
|
-
const GATE_KEYS = { build: "B", test: "T", lint: "L", evidence: "E", scope: "S", acceptance: "A", review: "R" };
|
|
12
|
+
export const GATE_KEYS = { build: "B", test: "T", lint: "L", evidence: "E", scope: "S", acceptance: "A", review: "R" };
|
|
13
13
|
const attemptStartIdx = (events, taskId) => {
|
|
14
14
|
let idx = -1;
|
|
15
15
|
for (let i = 0; i < events.length; i++) {
|
|
@@ -21,22 +21,22 @@ const attemptStartIdx = (events, taskId) => {
|
|
|
21
21
|
};
|
|
22
22
|
const visual = () => process.stdout.isTTY === true && process.env.NO_COLOR === undefined;
|
|
23
23
|
// non-TTY machine surface only — the TTY frame draws task verdicts via statusRow
|
|
24
|
-
const taskBox = (status) => {
|
|
24
|
+
export const taskBox = (status) => {
|
|
25
25
|
if (status === "done")
|
|
26
26
|
return "[x]";
|
|
27
27
|
if (status === "failed" || status === "human")
|
|
28
28
|
return "[!]";
|
|
29
29
|
return "[ ]";
|
|
30
30
|
};
|
|
31
|
-
const gateBox = (state, unicode) => {
|
|
31
|
+
export const gateBox = (state, unicode) => {
|
|
32
32
|
if (unicode) {
|
|
33
33
|
// shared glyph vocabulary: pass/fail verdicts, dash for skip, dim circle for not-yet-run
|
|
34
34
|
return state === "pass" ? GLYPHS.pass : state === "fail" ? GLYPHS.fail : state === "skip" ? GLYPHS.neutral : GLYPHS.toggleInactive;
|
|
35
35
|
}
|
|
36
36
|
return state === "pass" ? "[x]" : state === "fail" ? "[!]" : state === "skip" ? "." : "[ ]";
|
|
37
37
|
};
|
|
38
|
-
const defaultGateStates = (task) => GATE_NAMES.map((gate) => task.gates.includes(gate) ? "open" : "skip");
|
|
39
|
-
const gateStates = (task, events) => {
|
|
38
|
+
export const defaultGateStates = (task) => GATE_NAMES.map((gate) => task.gates.includes(gate) ? "open" : "skip");
|
|
39
|
+
export const gateStates = (task, events) => {
|
|
40
40
|
const outcomes = new Map();
|
|
41
41
|
const start = attemptStartIdx(events, task.id);
|
|
42
42
|
if (start >= 0) {
|
|
@@ -57,10 +57,10 @@ const gateStates = (task, events) => {
|
|
|
57
57
|
const GATE_STATE_TOKEN = { pass: ok, fail, skip: dim, open: dim };
|
|
58
58
|
// TTY cells are bare glyphs in fixed GATE_NAMES order — gate identity lives once in the frame
|
|
59
59
|
// legend, and in words on a failing row; non-TTY keeps the letter+box chips (byte-pinned surface)
|
|
60
|
-
const gateChain = (states, unicode) => GATE_NAMES.map((gate, i) => unicode
|
|
60
|
+
export const gateChain = (states, unicode) => GATE_NAMES.map((gate, i) => unicode
|
|
61
61
|
? GATE_STATE_TOKEN[states[i]](gateBox(states[i], true))
|
|
62
62
|
: `${GATE_KEYS[gate]}${gateBox(states[i], false)}`).join(" ");
|
|
63
|
-
const failedGates = (states) => GATE_NAMES.filter((_, i) => states[i] === "fail");
|
|
63
|
+
export const failedGates = (states) => GATE_NAMES.filter((_, i) => states[i] === "fail");
|
|
64
64
|
// plain-text form of the failed-gate words for column math; rendered with a dim dot + red names
|
|
65
65
|
const failedSuffix = (states) => {
|
|
66
66
|
const f = failedGates(states);
|
|
@@ -70,8 +70,8 @@ const failedSuffix = (states) => {
|
|
|
70
70
|
// awaited approval is named from task state alone — never from gate results. Failed gates win the
|
|
71
71
|
// cell when present: a post-approval park is not awaiting the designed gate. Plain text for column
|
|
72
72
|
// math; rendered with a dim dot + warn words. TTY-only — the non-TTY surface stays byte-pinned.
|
|
73
|
-
const humanGateSuffix = (t, st, states) => st === "human" && t.humanGate && failedGates(states).length === 0 ? " · awaiting approval" : "";
|
|
74
|
-
const shortGoal = (goal, max) => {
|
|
73
|
+
export const humanGateSuffix = (t, st, states) => st === "human" && t.humanGate && failedGates(states).length === 0 ? " · awaiting approval" : "";
|
|
74
|
+
export const shortGoal = (goal, max) => {
|
|
75
75
|
const clause = goal.split(/[,;.?!]/, 1)[0].trim();
|
|
76
76
|
if (clause.length <= max)
|
|
77
77
|
return clause;
|
package/dist/run/daemon.js
CHANGED
|
@@ -4,7 +4,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync
|
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { stringify } from "yaml";
|
|
7
|
-
import { classifyDeadChannel, trailerPattern, writePrompt } from "../adapters/prompt.js";
|
|
7
|
+
import { classifyDeadChannel, NO_TRAILER_SUMMARY, trailerPattern, UNPARSEABLE_TRAILER_SUMMARY, writePrompt } from "../adapters/prompt.js";
|
|
8
8
|
import { allAdapters, discoverChannels, getAdapter, probeAll, readDoctor } from "../adapters/registry.js";
|
|
9
9
|
import { addUsage, channelKey, matchesTrustDialog, QUOTA_RE } from "../adapters/types.js";
|
|
10
10
|
import { bannerShell, paneDispatchCommand } from "../brand.js";
|
|
@@ -658,6 +658,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
658
658
|
let output;
|
|
659
659
|
let exitCode;
|
|
660
660
|
let timedOut = false;
|
|
661
|
+
let settleParsed;
|
|
661
662
|
if (interactive) {
|
|
662
663
|
// v1.2 interactive: the TUI doesn't exit on completion — the trailer is the finish line.
|
|
663
664
|
// The exit wrapper still fires if the TUI dies (crash/quit): fast-fail instead of burning the timeout.
|
|
@@ -748,6 +749,29 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
748
749
|
await driver.waitAgentStatus(slot, "idle", 5_000); // settle, then re-harvest the final render
|
|
749
750
|
output = await driver.read(slot, 1000);
|
|
750
751
|
}
|
|
752
|
+
// T5 / OBS-111: an interactive harvest can race the TUI's final paint. When the pane
|
|
753
|
+
// contains the nonce token but the JSON hasn't balanced yet, settle and re-read through
|
|
754
|
+
// the existing pane-read seam once or twice before recording a malformed-trailer cause.
|
|
755
|
+
if (interactive) {
|
|
756
|
+
const stallWindowMs = taskTimeoutMinutes * 60_000;
|
|
757
|
+
const settleDeadline = attemptStart + stallWindowMs;
|
|
758
|
+
const settleDelayMs = 1_000;
|
|
759
|
+
const maxSettleRetries = 2;
|
|
760
|
+
let settleTries = 0;
|
|
761
|
+
settleParsed = adapter.parse(output, nonce);
|
|
762
|
+
while (settleParsed.summary === UNPARSEABLE_TRAILER_SUMMARY && settleTries < maxSettleRetries) {
|
|
763
|
+
const remaining = settleDeadline - Date.now();
|
|
764
|
+
if (remaining <= 0)
|
|
765
|
+
break;
|
|
766
|
+
await new Promise((r) => setTimeout(r, Math.min(settleDelayMs, remaining)));
|
|
767
|
+
output = await driver.read(slot, 1000);
|
|
768
|
+
settleParsed = adapter.parse(output, nonce);
|
|
769
|
+
settleTries++;
|
|
770
|
+
}
|
|
771
|
+
if (settleParsed.summary !== UNPARSEABLE_TRAILER_SUMMARY) {
|
|
772
|
+
finished = settleParsed.summary !== NO_TRAILER_SUMMARY;
|
|
773
|
+
}
|
|
774
|
+
}
|
|
751
775
|
}
|
|
752
776
|
else {
|
|
753
777
|
await driver.run(slot, paneDispatchCommand(dispatchScript));
|
|
@@ -800,7 +824,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
800
824
|
tokens = addUsage(tokens, attemptUsage);
|
|
801
825
|
metered++;
|
|
802
826
|
}
|
|
803
|
-
const result = adapter.parse(output, nonce);
|
|
827
|
+
const result = settleParsed ?? adapter.parse(output, nonce);
|
|
804
828
|
const cause = classifyWorkerResultCause({ output, ok: result.ok, finished, exitCode, summary: result.summary, timedOut });
|
|
805
829
|
journal.append("worker-result", t.id, {
|
|
806
830
|
ok: result.ok, summary: result.summary, deviations: result.deviations, finished, exitCode,
|
package/dist/run/git.d.ts
CHANGED
package/dist/run/git.js
CHANGED
|
@@ -5,6 +5,12 @@ import { shq } from "../adapters/types.js";
|
|
|
5
5
|
import { tickmarkrDir } from "../graph/graph.js";
|
|
6
6
|
import { ROUTING_ENV_SEAMS } from "../route/router.js";
|
|
7
7
|
export { ROUTING_ENV_SEAMS };
|
|
8
|
+
// OBS-110: gate/baseline/tip-verify children are vitest suites; without a fork cap, concurrent
|
|
9
|
+
// full-suite gate runs fork a worker-per-core pool per worktree and saturate the operator box.
|
|
10
|
+
// Default to a modest cap through the environment so vitest honors it natively; never pass it
|
|
11
|
+
// as argv (OBS-55) so child test oracles stay intact. The operator's own export wins.
|
|
12
|
+
export const FORK_CAP_ENV = "VITEST_MAX_FORKS";
|
|
13
|
+
export const DEFAULT_FORK_CAP = "6";
|
|
8
14
|
// stdin "ignore": same class as HARD-05 / SubprocessDriver — never leave an open pipe a child can block on
|
|
9
15
|
// (pi -p / codex exec wait for stdin EOF). timedOut distinguishes SIGKILL-timeout from a real nonzero exit.
|
|
10
16
|
function shell(cmd, cwd, timeoutMs, login) {
|
|
@@ -15,6 +21,9 @@ function shell(cmd, cwd, timeoutMs, login) {
|
|
|
15
21
|
const env = { ...process.env };
|
|
16
22
|
for (const k of ROUTING_ENV_SEAMS)
|
|
17
23
|
delete env[k];
|
|
24
|
+
// OBS-110: apply the default fork cap only when the operator has not already set one.
|
|
25
|
+
if (!(FORK_CAP_ENV in env))
|
|
26
|
+
env[FORK_CAP_ENV] = DEFAULT_FORK_CAP;
|
|
18
27
|
return new Promise((resolve) => {
|
|
19
28
|
// detached: bash gets its own process group so a timeout can kill the whole tree —
|
|
20
29
|
// SIGKILLing bash alone orphans grandchildren (codex/pi) that hold the stdio pipes
|
package/dist/tui/app.d.ts
CHANGED
|
@@ -9,6 +9,8 @@ export type View = {
|
|
|
9
9
|
cols: number;
|
|
10
10
|
rows: number;
|
|
11
11
|
}): string[];
|
|
12
|
+
/** Optional per-view key handler (e.g. up/down cursor movement). */
|
|
13
|
+
key?(name: string): void;
|
|
12
14
|
};
|
|
13
15
|
export type StudioOptions = {
|
|
14
16
|
input: InputStream;
|
|
@@ -59,6 +61,7 @@ export declare class StudioApp {
|
|
|
59
61
|
private closeSave;
|
|
60
62
|
private handleRevert;
|
|
61
63
|
private setView;
|
|
64
|
+
private forwardViewKey;
|
|
62
65
|
private showNotice;
|
|
63
66
|
private paint;
|
|
64
67
|
private buildFrame;
|
package/dist/tui/app.js
CHANGED
|
@@ -3,6 +3,7 @@ import { createFleetView } from "./views/fleet-view.js";
|
|
|
3
3
|
import { createPreviewView } from "./views/preview-view.js";
|
|
4
4
|
import { createProfileView } from "./views/profile-view.js";
|
|
5
5
|
import { createRoutingView } from "./views/routing-view.js";
|
|
6
|
+
import { createRunsView } from "./views/runs-view.js";
|
|
6
7
|
import { renderDiffModal } from "./views/diff-modal.js";
|
|
7
8
|
import { buildSaveProposal, confirmSave } from "./save.js";
|
|
8
9
|
import { FleetStaging } from "./staging.js";
|
|
@@ -42,7 +43,7 @@ export class StudioApp {
|
|
|
42
43
|
this.repoRoot = opts.repoRoot ?? process.cwd();
|
|
43
44
|
this.globalDir = opts.globalDir;
|
|
44
45
|
this.staging = new FleetStaging(opts.loaded ?? emptyFleetEditable());
|
|
45
|
-
this.views = [createFleetView(), createRoutingView(), createPreviewView(), createProfileView()];
|
|
46
|
+
this.views = [createFleetView(), createRoutingView(), createPreviewView(), createProfileView(), createRunsView()];
|
|
46
47
|
this.exited = new Promise((resolve) => {
|
|
47
48
|
this.resolveExit = resolve;
|
|
48
49
|
});
|
|
@@ -91,6 +92,13 @@ export class StudioApp {
|
|
|
91
92
|
this.setView(idx);
|
|
92
93
|
});
|
|
93
94
|
}
|
|
95
|
+
// per-view navigation
|
|
96
|
+
for (const name of ["up", "down"]) {
|
|
97
|
+
this.engine.key(name, () => {
|
|
98
|
+
if (!this.isModalOpen)
|
|
99
|
+
this.forwardViewKey(name);
|
|
100
|
+
});
|
|
101
|
+
}
|
|
94
102
|
// help overlay
|
|
95
103
|
this.engine.key("?", () => {
|
|
96
104
|
if (!this.confirmingQuit && !this.showingSave) {
|
|
@@ -250,6 +258,10 @@ export class StudioApp {
|
|
|
250
258
|
this.notice = null;
|
|
251
259
|
this.paint();
|
|
252
260
|
}
|
|
261
|
+
forwardViewKey(name) {
|
|
262
|
+
this.views[this.active]?.key?.(name);
|
|
263
|
+
this.paint();
|
|
264
|
+
}
|
|
253
265
|
showNotice() {
|
|
254
266
|
this.notice = READONLY_NOTICE;
|
|
255
267
|
this.paint();
|
|
@@ -305,7 +317,7 @@ export class StudioApp {
|
|
|
305
317
|
renderHelp() {
|
|
306
318
|
return [
|
|
307
319
|
"Key bindings",
|
|
308
|
-
"1-
|
|
320
|
+
"1-5 switch view",
|
|
309
321
|
"tab cycle views",
|
|
310
322
|
"? show this help",
|
|
311
323
|
"esc / q quit",
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { ConsultVerdict } from "../../run/consult.js";
|
|
2
|
+
import type { JournalEvent } from "../../run/journal.js";
|
|
3
|
+
/** Placeholder marker surfaced in the Runs detail panel until T2 fills the viewer. */
|
|
4
|
+
export declare const DOSSIER_PLACEHOLDER = "consult dossier viewer \u2014 stub, filled by T2";
|
|
5
|
+
/** Placeholder renderer — returns one dim line. */
|
|
6
|
+
export declare function renderDossierPlaceholder(): string;
|
|
7
|
+
export type ConsultAction = ConsultVerdict["action"];
|
|
8
|
+
/** One recorded consult verdict, with its persisted prompt content injected by the caller. */
|
|
9
|
+
export interface DossierVerdict {
|
|
10
|
+
action: ConsultAction;
|
|
11
|
+
reason?: string;
|
|
12
|
+
notes?: string;
|
|
13
|
+
guidance?: string;
|
|
14
|
+
/** Persisted prompt content (the consults/<task>-<n>.md artifact), loaded by the caller. */
|
|
15
|
+
prompt?: string;
|
|
16
|
+
}
|
|
17
|
+
/** Everything the dossier panel renders — loaded by the caller, never by the render path. */
|
|
18
|
+
export interface ConsultDossierData {
|
|
19
|
+
taskId: string;
|
|
20
|
+
/** Verdicts in dispatch order (journal order). */
|
|
21
|
+
verdicts: DossierVerdict[];
|
|
22
|
+
}
|
|
23
|
+
export interface ConsultDossierPanel {
|
|
24
|
+
/** Change the selected task. Any selection change invalidates every cached prompt render. */
|
|
25
|
+
select(data: ConsultDossierData | undefined): void;
|
|
26
|
+
/** "up"/"down" move the verdict cursor; "enter" toggles the selected verdict's dossier. */
|
|
27
|
+
key(name: string): void;
|
|
28
|
+
/** Render the panel body lines (chrome/divider lines are the host view's job). */
|
|
29
|
+
render(): string[];
|
|
30
|
+
/** Index of the expanded verdict, or null when collapsed. */
|
|
31
|
+
readonly expandedIndex: number | null;
|
|
32
|
+
}
|
|
33
|
+
/** Fold one task's consult-verdict journal events into dossier verdicts, in dispatch order.
|
|
34
|
+
* Pure function over injected events. opts.prompts carries the persisted prompt contents in
|
|
35
|
+
* the same dispatch order (the caller reads the consults/ artifacts); a verdict whose artifact
|
|
36
|
+
* was not loaded simply has no prompt. Malformed events are skipped, never fatal. */
|
|
37
|
+
export declare function foldConsultVerdicts(events: JournalEvent[], taskId: string, opts?: {
|
|
38
|
+
prompts?: string[];
|
|
39
|
+
}): DossierVerdict[];
|
|
40
|
+
/** Translate persisted prompt content into styled terminal lines. Pure. */
|
|
41
|
+
export declare function translatePromptContent(content: string): string[];
|
|
42
|
+
/** A verdict's guidance field is "imperative steps for the worker (newline-separated ok)" —
|
|
43
|
+
* already list-shaped, so it renders as a bullet list, never a wall of prose. Pure. */
|
|
44
|
+
export declare function renderGuidanceSteps(guidance: string): string[];
|
|
45
|
+
/** The consult dossier panel. Stateful over injected data: a verdict cursor, at most one
|
|
46
|
+
* expanded dossier, and a per-verdict render cache cleared only when the selection changes. */
|
|
47
|
+
export declare function createConsultDossierPanel(initial?: ConsultDossierData, opts?: {
|
|
48
|
+
translate?: (content: string) => string[];
|
|
49
|
+
}): ConsultDossierPanel;
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// T2 (v1.68): consult dossier viewer — the Runs cockpit's detail panel.
|
|
2
|
+
// Lists every consult verdict recorded for the selected task (action + reason, dispatch order)
|
|
3
|
+
// and expands one verdict's persisted prompt content on request. Pure render over INJECTED data:
|
|
4
|
+
// this module never touches the filesystem — the caller folds journal events and loads the
|
|
5
|
+
// consults/ artifacts, then hands both in. The prompt-content translator is a hand-written
|
|
6
|
+
// line-oriented pass over a known narrow subset (headings, blockquotes, bullets, fenced blocks),
|
|
7
|
+
// never a markdown-AST dependency. Rendered prompt lines are cached per verdict and invalidated
|
|
8
|
+
// only on selection change — never re-parsed on repaint (opencode's opentui regression lesson).
|
|
9
|
+
// Read-only by construction: there is no edit or delete path for a dossier or a verdict.
|
|
10
|
+
import { GLYPHS, bold, dim, warn } from "../../brand.js";
|
|
11
|
+
/** Placeholder marker surfaced in the Runs detail panel until T2 fills the viewer. */
|
|
12
|
+
export const DOSSIER_PLACEHOLDER = "consult dossier viewer — stub, filled by T2";
|
|
13
|
+
/** Placeholder renderer — returns one dim line. */
|
|
14
|
+
export function renderDossierPlaceholder() {
|
|
15
|
+
return ` ${DOSSIER_PLACEHOLDER}`;
|
|
16
|
+
}
|
|
17
|
+
const ACTIONS = ["retry", "reroute", "decompose", "human"];
|
|
18
|
+
/** Fold one task's consult-verdict journal events into dossier verdicts, in dispatch order.
|
|
19
|
+
* Pure function over injected events. opts.prompts carries the persisted prompt contents in
|
|
20
|
+
* the same dispatch order (the caller reads the consults/ artifacts); a verdict whose artifact
|
|
21
|
+
* was not loaded simply has no prompt. Malformed events are skipped, never fatal. */
|
|
22
|
+
export function foldConsultVerdicts(events, taskId, opts = {}) {
|
|
23
|
+
const verdicts = [];
|
|
24
|
+
for (const e of events) {
|
|
25
|
+
if (e.taskId !== taskId || e.event !== "consult-verdict")
|
|
26
|
+
continue;
|
|
27
|
+
const d = e.data;
|
|
28
|
+
if (typeof d.action !== "string" || !ACTIONS.includes(d.action))
|
|
29
|
+
continue;
|
|
30
|
+
verdicts.push({
|
|
31
|
+
action: d.action,
|
|
32
|
+
...(typeof d.reason === "string" ? { reason: d.reason } : {}),
|
|
33
|
+
...(typeof d.notes === "string" ? { notes: d.notes } : {}),
|
|
34
|
+
...(typeof d.guidance === "string" ? { guidance: d.guidance } : {}),
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
if (opts.prompts) {
|
|
38
|
+
for (const [i, prompt] of opts.prompts.entries()) {
|
|
39
|
+
if (verdicts[i])
|
|
40
|
+
verdicts[i].prompt = prompt;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return verdicts;
|
|
44
|
+
}
|
|
45
|
+
// ── prompt-content translator ────────────────────────────────────────────────
|
|
46
|
+
// Hand-written, line-oriented, pure. Known subset only: ATX headings (# = bold, deeper = dim),
|
|
47
|
+
// `> ` blockquotes (dim `│ ` bar prefix), `- `/`* ` bullets (• marker), ``` fenced blocks
|
|
48
|
+
// (dim, indented), everything else passes through plain. No markdown-AST dependency.
|
|
49
|
+
const HEADING_RE = /^(#{1,6})[ \t]+(.*)$/;
|
|
50
|
+
const QUOTE_RE = /^>[ \t]?(.*)$/;
|
|
51
|
+
const BULLET_RE = /^[-*][ \t]+(.*)$/;
|
|
52
|
+
const FENCE_RE = /^\s*```/;
|
|
53
|
+
/** Translate persisted prompt content into styled terminal lines. Pure. */
|
|
54
|
+
export function translatePromptContent(content) {
|
|
55
|
+
const out = [];
|
|
56
|
+
let fenced = false;
|
|
57
|
+
for (const raw of content.split("\n")) {
|
|
58
|
+
const line = raw.replace(/\s+$/, "");
|
|
59
|
+
if (FENCE_RE.test(line)) {
|
|
60
|
+
fenced = !fenced;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (fenced) {
|
|
64
|
+
out.push(line.trim() ? dim(` ${line}`) : "");
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const heading = HEADING_RE.exec(line);
|
|
68
|
+
if (heading) {
|
|
69
|
+
out.push(heading[1].length === 1 ? bold(heading[2]) : dim(heading[2]));
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const quote = QUOTE_RE.exec(line);
|
|
73
|
+
if (quote) {
|
|
74
|
+
out.push(dim(`│ ${quote[1]}`));
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const bullet = BULLET_RE.exec(line);
|
|
78
|
+
if (bullet) {
|
|
79
|
+
out.push(`• ${bullet[1]}`);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
out.push(line);
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
/** A verdict's guidance field is "imperative steps for the worker (newline-separated ok)" —
|
|
87
|
+
* already list-shaped, so it renders as a bullet list, never a wall of prose. Pure. */
|
|
88
|
+
export function renderGuidanceSteps(guidance) {
|
|
89
|
+
return guidance
|
|
90
|
+
.split(/\n+/)
|
|
91
|
+
.map((s) => s.trim())
|
|
92
|
+
.filter(Boolean)
|
|
93
|
+
.map((s) => `• ${s}`);
|
|
94
|
+
}
|
|
95
|
+
/** The consult dossier panel. Stateful over injected data: a verdict cursor, at most one
|
|
96
|
+
* expanded dossier, and a per-verdict render cache cleared only when the selection changes. */
|
|
97
|
+
export function createConsultDossierPanel(initial, opts = {}) {
|
|
98
|
+
const translate = opts.translate ?? translatePromptContent;
|
|
99
|
+
let data = initial;
|
|
100
|
+
let cursor = 0;
|
|
101
|
+
let expanded = null;
|
|
102
|
+
// Per-verdict cached render of the expanded dossier block — parsed once, reused on every
|
|
103
|
+
// later expand of the same verdict within the same selection; never re-parsed per repaint.
|
|
104
|
+
const cache = new Map();
|
|
105
|
+
const dossierBlock = (verdict, index) => {
|
|
106
|
+
const hit = cache.get(index);
|
|
107
|
+
if (hit)
|
|
108
|
+
return hit;
|
|
109
|
+
const lines = [];
|
|
110
|
+
lines.push(dim(`── persisted prompt · consult · ${data?.taskId ?? "?"} · verdict ${index + 1} ──`));
|
|
111
|
+
if (verdict.prompt !== undefined)
|
|
112
|
+
lines.push(...translate(verdict.prompt));
|
|
113
|
+
else
|
|
114
|
+
lines.push(dim("(no persisted prompt content recorded for this verdict)"));
|
|
115
|
+
lines.push(`action: ${verdict.action}`);
|
|
116
|
+
const steps = verdict.guidance ? renderGuidanceSteps(verdict.guidance) : [];
|
|
117
|
+
if (steps.length > 0) {
|
|
118
|
+
lines.push("guidance:");
|
|
119
|
+
for (const s of steps)
|
|
120
|
+
lines.push(` ${s}`);
|
|
121
|
+
}
|
|
122
|
+
cache.set(index, lines);
|
|
123
|
+
return lines;
|
|
124
|
+
};
|
|
125
|
+
return {
|
|
126
|
+
get expandedIndex() {
|
|
127
|
+
return expanded;
|
|
128
|
+
},
|
|
129
|
+
select(next) {
|
|
130
|
+
data = next;
|
|
131
|
+
cursor = 0;
|
|
132
|
+
expanded = null;
|
|
133
|
+
cache.clear();
|
|
134
|
+
},
|
|
135
|
+
key(name) {
|
|
136
|
+
if (!data || data.verdicts.length === 0)
|
|
137
|
+
return;
|
|
138
|
+
if (name === "up")
|
|
139
|
+
cursor = Math.max(cursor - 1, 0);
|
|
140
|
+
else if (name === "down")
|
|
141
|
+
cursor = Math.min(cursor + 1, data.verdicts.length - 1);
|
|
142
|
+
else if (name === "enter")
|
|
143
|
+
expanded = expanded === cursor ? null : cursor;
|
|
144
|
+
},
|
|
145
|
+
render() {
|
|
146
|
+
if (!data)
|
|
147
|
+
return [];
|
|
148
|
+
if (data.verdicts.length === 0) {
|
|
149
|
+
return [
|
|
150
|
+
` no consult verdicts recorded for ${data.taskId} — every attempt finished without needing a consult.`,
|
|
151
|
+
];
|
|
152
|
+
}
|
|
153
|
+
const lines = [];
|
|
154
|
+
data.verdicts.forEach((verdict, i) => {
|
|
155
|
+
const pointer = i === cursor ? `${GLYPHS.pointer} ` : " ";
|
|
156
|
+
// Consult actions are not pass/fail: human → warn (needs the operator), the rest plain.
|
|
157
|
+
const actionWord = verdict.action === "human" ? warn(verdict.action) : verdict.action;
|
|
158
|
+
const reason = verdict.reason ?? verdict.notes;
|
|
159
|
+
const hint = dim(i === expanded ? "[enter: collapse]" : "[enter: expand]");
|
|
160
|
+
lines.push(` ${pointer}${i + 1}. ${actionWord}${reason ? ` — ${reason}` : ""} ${hint}`);
|
|
161
|
+
if (i === expanded) {
|
|
162
|
+
for (const l of dossierBlock(verdict, i))
|
|
163
|
+
lines.push(` ${l}`);
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
return lines;
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
}
|
|
@@ -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
|
+
}
|