tickmarkr 1.65.0 → 1.66.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/ui.d.ts +5 -0
- package/dist/cli/commands/ui.js +13 -0
- package/dist/cli/index.d.ts +1 -1
- package/dist/cli/index.js +3 -1
- package/dist/tui/app.d.ts +40 -0
- package/dist/tui/app.js +130 -0
- package/dist/tui/engine.d.ts +52 -0
- package/dist/tui/engine.js +92 -0
- package/dist/tui/frame.d.ts +22 -0
- package/dist/tui/frame.js +56 -0
- package/dist/tui/input.d.ts +41 -0
- package/dist/tui/input.js +100 -0
- package/dist/tui/views/fleet-view.d.ts +69 -0
- package/dist/tui/views/fleet-view.js +243 -0
- package/dist/tui/views/preview-view.d.ts +7 -0
- package/dist/tui/views/preview-view.js +80 -0
- package/dist/tui/views/profile-view.d.ts +15 -0
- package/dist/tui/views/profile-view.js +93 -0
- package/dist/tui/views/routing-view.d.ts +29 -0
- package/dist/tui/views/routing-view.js +98 -0
- package/package.json +1 -1
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { StudioApp } from "../../tui/app.js";
|
|
2
|
+
const NON_TTY_MSG = "tickmarkr ui: studio requires a TTY — use `tickmarkr fleet --print` or `tickmarkr status --watch` for line-mode output";
|
|
3
|
+
export async function ui(_argv, io = {}) {
|
|
4
|
+
const input = io.input ?? process.stdin;
|
|
5
|
+
const output = io.output ?? process.stdout;
|
|
6
|
+
if (input.isTTY !== true || output.isTTY !== true) {
|
|
7
|
+
return { out: NON_TTY_MSG, code: 1 };
|
|
8
|
+
}
|
|
9
|
+
const app = new StudioApp({ input, output });
|
|
10
|
+
app.start();
|
|
11
|
+
await app.exited;
|
|
12
|
+
return "ui: closed";
|
|
13
|
+
}
|
package/dist/cli/index.d.ts
CHANGED
|
@@ -5,7 +5,7 @@ export type CommandResult = string | {
|
|
|
5
5
|
};
|
|
6
6
|
export type CommandMap = Record<string, (argv: string[]) => Promise<CommandResult>>;
|
|
7
7
|
export declare const COMMANDS: CommandMap;
|
|
8
|
-
export declare const USAGE = "tickmarkr \u2014 spec-driven orchestration harness for AI coding agents\nusage: tickmarkr <command>\n init guided setup + doctor; init --agent [--force] [--docs] adds agent skills/docs\n doctor re-probe adapters, herdr, auth; print capability matrix\n fleet interactive fleet editor (fleet --print for CI drift checks)\n compile <src> spec \u2192 .tickmarkr/graph.json (fails without acceptance criteria)\n scope <intent> draft a compiled native spec beside an answered intent (--force to overwrite)\n plan dry-run routing table + cost estimate + floor lints\n run execute the graph (--concurrency N --driver herdr|subprocess --route-strict)\n status live run state\n resume <id> continue a run from its journal\n report <id> cost/quality report (--md for committable execution record)\n profile show learned routing profile (profile reset = forget history via cursor, keeps telemetry)\n unlock remove a stale/garbage run lock (refuses if the holder is alive)\n approve <id> <task> approve a parked human gate (--by <name> --reason <text>); takes effect on resume";
|
|
8
|
+
export declare const USAGE = "tickmarkr \u2014 spec-driven orchestration harness for AI coding agents\nusage: tickmarkr <command>\n init guided setup + doctor; init --agent [--force] [--docs] adds agent skills/docs\n doctor re-probe adapters, herdr, auth; print capability matrix\n fleet interactive fleet editor (fleet --print for CI drift checks)\n compile <src> spec \u2192 .tickmarkr/graph.json (fails without acceptance criteria)\n scope <intent> draft a compiled native spec beside an answered intent (--force to overwrite)\n plan dry-run routing table + cost estimate + floor lints\n run execute the graph (--concurrency N --driver herdr|subprocess --route-strict)\n status live run state\n resume <id> continue a run from its journal\n report <id> cost/quality report (--md for committable execution record)\n profile show learned routing profile (profile reset = forget history via cursor, keeps telemetry)\n ui open the Fleet Studio TUI (full-screen tabbed cockpit)\n unlock remove a stale/garbage run lock (refuses if the holder is alive)\n approve <id> <task> approve a parked human gate (--by <name> --reason <text>); takes effect on resume";
|
|
9
9
|
export declare function dispatch(cmd: string | undefined, argv: string[], commands?: CommandMap): Promise<{
|
|
10
10
|
out: string;
|
|
11
11
|
code: number;
|
package/dist/cli/index.js
CHANGED
|
@@ -13,11 +13,12 @@ import { resume } from "./commands/resume.js";
|
|
|
13
13
|
import { run } from "./commands/run.js";
|
|
14
14
|
import { scope } from "./commands/scope.js";
|
|
15
15
|
import { status } from "./commands/status.js";
|
|
16
|
+
import { ui } from "./commands/ui.js";
|
|
16
17
|
import { unlock } from "./commands/unlock.js";
|
|
17
18
|
import { version } from "./commands/version.js";
|
|
18
19
|
const normalize = (r) => typeof r === "string" ? { out: r, code: 0 } : r;
|
|
19
20
|
export const COMMANDS = {
|
|
20
|
-
init, doctor, fleet, compile, scope, plan, run, status, resume, report, profile, unlock, approve, version,
|
|
21
|
+
init, doctor, fleet, compile, scope, plan, run, status, resume, report, profile, ui, unlock, approve, version,
|
|
21
22
|
};
|
|
22
23
|
const VERSION_FLAGS = new Set(["version", "--version", "-v"]);
|
|
23
24
|
const HELP_CMDS = new Set(["help", "-h", "--help"]);
|
|
@@ -35,6 +36,7 @@ usage: tickmarkr <command>
|
|
|
35
36
|
resume <id> continue a run from its journal
|
|
36
37
|
report <id> cost/quality report (--md for committable execution record)
|
|
37
38
|
profile show learned routing profile (profile reset = forget history via cursor, keeps telemetry)
|
|
39
|
+
ui open the Fleet Studio TUI (full-screen tabbed cockpit)
|
|
38
40
|
unlock remove a stale/garbage run lock (refuses if the holder is alive)
|
|
39
41
|
approve <id> <task> approve a parked human gate (--by <name> --reason <text>); takes effect on resume`;
|
|
40
42
|
// pure, testable dispatcher: resolves a command, forwards argv, shapes the result — no side effects.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type OutputStream } from "../tui/engine.js";
|
|
2
|
+
import type { InputStream } from "../tui/input.js";
|
|
3
|
+
export type View = {
|
|
4
|
+
id: string;
|
|
5
|
+
label: string;
|
|
6
|
+
render(props: {
|
|
7
|
+
cols: number;
|
|
8
|
+
rows: number;
|
|
9
|
+
}): string[];
|
|
10
|
+
};
|
|
11
|
+
export type StudioOptions = {
|
|
12
|
+
input: InputStream;
|
|
13
|
+
output: OutputStream;
|
|
14
|
+
};
|
|
15
|
+
export declare class StudioApp {
|
|
16
|
+
private engine;
|
|
17
|
+
private views;
|
|
18
|
+
private active;
|
|
19
|
+
private showingHelp;
|
|
20
|
+
private notice;
|
|
21
|
+
private _lines;
|
|
22
|
+
private resolveExit;
|
|
23
|
+
readonly exited: Promise<void>;
|
|
24
|
+
constructor(opts: StudioOptions);
|
|
25
|
+
/** Current line model last rendered or prepared. */
|
|
26
|
+
get lines(): string[];
|
|
27
|
+
/** Labels of every registered view, in order. */
|
|
28
|
+
get viewLabels(): string[];
|
|
29
|
+
/** Enter the alternate screen and start routing input. */
|
|
30
|
+
start(): void;
|
|
31
|
+
/** Restore the terminal and release the input stream. */
|
|
32
|
+
stop(): void;
|
|
33
|
+
private bindKeys;
|
|
34
|
+
private setView;
|
|
35
|
+
private showNotice;
|
|
36
|
+
private paint;
|
|
37
|
+
private buildFrame;
|
|
38
|
+
private renderTabBar;
|
|
39
|
+
private renderHelp;
|
|
40
|
+
}
|
package/dist/tui/app.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { TerminalEngine } from "../tui/engine.js";
|
|
2
|
+
import { createFleetView } from "./views/fleet-view.js";
|
|
3
|
+
import { createPreviewView } from "./views/preview-view.js";
|
|
4
|
+
import { createProfileView } from "./views/profile-view.js";
|
|
5
|
+
import { createRoutingView } from "./views/routing-view.js";
|
|
6
|
+
const MUTATION_KEYS = ["r", "s", "m", "return"];
|
|
7
|
+
const READONLY_NOTICE = "read-only in v1.66 — write path ships in v1.67";
|
|
8
|
+
export class StudioApp {
|
|
9
|
+
engine;
|
|
10
|
+
views;
|
|
11
|
+
active = 0;
|
|
12
|
+
showingHelp = false;
|
|
13
|
+
notice = null;
|
|
14
|
+
_lines = [];
|
|
15
|
+
resolveExit = null;
|
|
16
|
+
exited;
|
|
17
|
+
constructor(opts) {
|
|
18
|
+
this.engine = new TerminalEngine({
|
|
19
|
+
input: opts.input,
|
|
20
|
+
output: opts.output,
|
|
21
|
+
onResize: () => this.paint(),
|
|
22
|
+
});
|
|
23
|
+
this.views = [createFleetView(), createRoutingView(), createPreviewView(), createProfileView()];
|
|
24
|
+
this.exited = new Promise((resolve) => {
|
|
25
|
+
this.resolveExit = resolve;
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
/** Current line model last rendered or prepared. */
|
|
29
|
+
get lines() {
|
|
30
|
+
return this._lines;
|
|
31
|
+
}
|
|
32
|
+
/** Labels of every registered view, in order. */
|
|
33
|
+
get viewLabels() {
|
|
34
|
+
return this.views.map((v) => v.label);
|
|
35
|
+
}
|
|
36
|
+
/** Enter the alternate screen and start routing input. */
|
|
37
|
+
start() {
|
|
38
|
+
this.bindKeys();
|
|
39
|
+
this.paint();
|
|
40
|
+
this.engine.start(this._lines);
|
|
41
|
+
}
|
|
42
|
+
/** Restore the terminal and release the input stream. */
|
|
43
|
+
stop() {
|
|
44
|
+
this.engine.stop();
|
|
45
|
+
if (this.resolveExit) {
|
|
46
|
+
this.resolveExit();
|
|
47
|
+
this.resolveExit = null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
bindKeys() {
|
|
51
|
+
// view switching
|
|
52
|
+
// Tab is decoded as the raw "\t" sequence by the micro-engine.
|
|
53
|
+
this.engine.key("\t", () => this.setView((this.active + 1) % this.views.length));
|
|
54
|
+
for (let i = 1; i <= this.views.length; i++) {
|
|
55
|
+
const idx = i - 1;
|
|
56
|
+
this.engine.key(String(i), () => this.setView(idx));
|
|
57
|
+
}
|
|
58
|
+
// help overlay
|
|
59
|
+
this.engine.key("?", () => {
|
|
60
|
+
this.showingHelp = true;
|
|
61
|
+
this.paint();
|
|
62
|
+
});
|
|
63
|
+
// dismissal / quit
|
|
64
|
+
this.engine.key("escape", () => {
|
|
65
|
+
if (this.showingHelp) {
|
|
66
|
+
this.showingHelp = false;
|
|
67
|
+
this.paint();
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
this.stop();
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
this.engine.key("q", () => this.stop());
|
|
74
|
+
// mutation keys — read-only until the v1.67 write path
|
|
75
|
+
for (const key of MUTATION_KEYS) {
|
|
76
|
+
this.engine.key(key, () => this.showNotice());
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
setView(idx) {
|
|
80
|
+
this.active = idx;
|
|
81
|
+
this.showingHelp = false;
|
|
82
|
+
this.notice = null;
|
|
83
|
+
this.paint();
|
|
84
|
+
}
|
|
85
|
+
showNotice() {
|
|
86
|
+
this.notice = READONLY_NOTICE;
|
|
87
|
+
this.paint();
|
|
88
|
+
}
|
|
89
|
+
paint() {
|
|
90
|
+
this._lines = this.buildFrame();
|
|
91
|
+
this.engine.render(this._lines);
|
|
92
|
+
}
|
|
93
|
+
buildFrame() {
|
|
94
|
+
const { cols, rows } = this.engine.size;
|
|
95
|
+
const lines = [];
|
|
96
|
+
lines.push(this.renderTabBar());
|
|
97
|
+
if (this.showingHelp) {
|
|
98
|
+
lines.push(...this.renderHelp());
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
const view = this.views[this.active];
|
|
102
|
+
lines.push(...view.render({ cols, rows: Math.max(rows - 2, 1) }));
|
|
103
|
+
}
|
|
104
|
+
if (this.notice) {
|
|
105
|
+
lines.push(this.notice);
|
|
106
|
+
}
|
|
107
|
+
return lines;
|
|
108
|
+
}
|
|
109
|
+
renderTabBar() {
|
|
110
|
+
const parts = ["tickmarkr ui ──"];
|
|
111
|
+
for (let i = 0; i < this.views.length; i++) {
|
|
112
|
+
const view = this.views[i];
|
|
113
|
+
const marker = i === this.active ? `*${view.label}` : view.label;
|
|
114
|
+
parts.push(`[${i + 1}]${marker}`);
|
|
115
|
+
}
|
|
116
|
+
return parts.join(" ");
|
|
117
|
+
}
|
|
118
|
+
renderHelp() {
|
|
119
|
+
return [
|
|
120
|
+
"Key bindings",
|
|
121
|
+
"1-4 switch view",
|
|
122
|
+
"tab cycle views",
|
|
123
|
+
"? show this help",
|
|
124
|
+
"esc / q quit",
|
|
125
|
+
"r re-probe (read-only)",
|
|
126
|
+
"s save (read-only)",
|
|
127
|
+
"m mode (read-only)",
|
|
128
|
+
];
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { type Key, type KeyHandler, type InputStream } from "./input.js";
|
|
2
|
+
export type { Key, KeyHandler };
|
|
3
|
+
/** Writable stream with the small surface the engine needs. */
|
|
4
|
+
export interface OutputStream {
|
|
5
|
+
isTTY?: boolean;
|
|
6
|
+
columns?: number;
|
|
7
|
+
rows?: number;
|
|
8
|
+
write: (chunk: string) => unknown;
|
|
9
|
+
on?(event: "resize", listener: () => void): this;
|
|
10
|
+
off?(event: "resize", listener: () => void): this;
|
|
11
|
+
}
|
|
12
|
+
export interface EngineOptions {
|
|
13
|
+
input: InputStream;
|
|
14
|
+
output: OutputStream;
|
|
15
|
+
onResize?: (cols: number, rows: number) => void;
|
|
16
|
+
onError?: (error: unknown) => void;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Dependency-free terminal engine: alternate-screen line-diff renderer, named-key routing,
|
|
20
|
+
* resize tracking, and terminal restoration on every exit path including thrown handlers.
|
|
21
|
+
*/
|
|
22
|
+
export declare class TerminalEngine {
|
|
23
|
+
private opts;
|
|
24
|
+
private frame;
|
|
25
|
+
private input;
|
|
26
|
+
private lastLines;
|
|
27
|
+
private _cols;
|
|
28
|
+
private _rows;
|
|
29
|
+
private running;
|
|
30
|
+
private userHandlers;
|
|
31
|
+
constructor(opts: EngineOptions);
|
|
32
|
+
/** Enter the alternate screen, begin listening for keys and resize events. */
|
|
33
|
+
start(lines: string[]): void;
|
|
34
|
+
/** Restore the terminal and stop listening. Safe to call multiple times. */
|
|
35
|
+
stop(): void;
|
|
36
|
+
/** Update the line model; only changed lines are repainted. */
|
|
37
|
+
render(lines: string[]): void;
|
|
38
|
+
/** Register a handler for a named key. */
|
|
39
|
+
key(name: string, handler: KeyHandler): void;
|
|
40
|
+
/** Unregister a handler. */
|
|
41
|
+
unkey(name: string, handler: KeyHandler): void;
|
|
42
|
+
/** Trigger a full repaint at the new size. */
|
|
43
|
+
resize(cols: number, rows: number): void;
|
|
44
|
+
/** Current terminal size as last seen or reported by a resize event. */
|
|
45
|
+
get size(): {
|
|
46
|
+
cols: number;
|
|
47
|
+
rows: number;
|
|
48
|
+
};
|
|
49
|
+
private onResize;
|
|
50
|
+
private attachResize;
|
|
51
|
+
private detachResize;
|
|
52
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { Frame } from "./frame.js";
|
|
2
|
+
import { InputRouter } from "./input.js";
|
|
3
|
+
/**
|
|
4
|
+
* Dependency-free terminal engine: alternate-screen line-diff renderer, named-key routing,
|
|
5
|
+
* resize tracking, and terminal restoration on every exit path including thrown handlers.
|
|
6
|
+
*/
|
|
7
|
+
export class TerminalEngine {
|
|
8
|
+
opts;
|
|
9
|
+
frame;
|
|
10
|
+
input;
|
|
11
|
+
lastLines = [];
|
|
12
|
+
_cols;
|
|
13
|
+
_rows;
|
|
14
|
+
running = false;
|
|
15
|
+
userHandlers = new Map();
|
|
16
|
+
constructor(opts) {
|
|
17
|
+
this.opts = opts;
|
|
18
|
+
this.frame = new Frame(opts.output);
|
|
19
|
+
this.input = new InputRouter(opts.input);
|
|
20
|
+
this._cols = opts.output.columns ?? 80;
|
|
21
|
+
this._rows = opts.output.rows ?? 24;
|
|
22
|
+
}
|
|
23
|
+
/** Enter the alternate screen, begin listening for keys and resize events. */
|
|
24
|
+
start(lines) {
|
|
25
|
+
if (this.running)
|
|
26
|
+
return;
|
|
27
|
+
this.running = true;
|
|
28
|
+
this.lastLines = lines.slice();
|
|
29
|
+
this.frame.enter(this.lastLines);
|
|
30
|
+
this.input.start();
|
|
31
|
+
this.attachResize();
|
|
32
|
+
}
|
|
33
|
+
/** Restore the terminal and stop listening. Safe to call multiple times. */
|
|
34
|
+
stop() {
|
|
35
|
+
if (!this.running)
|
|
36
|
+
return;
|
|
37
|
+
this.detachResize();
|
|
38
|
+
this.input.stop();
|
|
39
|
+
this.frame.exit();
|
|
40
|
+
this.running = false;
|
|
41
|
+
}
|
|
42
|
+
/** Update the line model; only changed lines are repainted. */
|
|
43
|
+
render(lines) {
|
|
44
|
+
this.lastLines = lines.slice();
|
|
45
|
+
this.frame.render(this.lastLines);
|
|
46
|
+
}
|
|
47
|
+
/** Register a handler for a named key. */
|
|
48
|
+
key(name, handler) {
|
|
49
|
+
const wrapped = async (k) => {
|
|
50
|
+
try {
|
|
51
|
+
await handler(k);
|
|
52
|
+
}
|
|
53
|
+
catch (e) {
|
|
54
|
+
this.stop();
|
|
55
|
+
this.opts.onError?.(e);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
this.userHandlers.set(handler, wrapped);
|
|
59
|
+
this.input.on(name, wrapped);
|
|
60
|
+
}
|
|
61
|
+
/** Unregister a handler. */
|
|
62
|
+
unkey(name, handler) {
|
|
63
|
+
const wrapped = this.userHandlers.get(handler);
|
|
64
|
+
if (wrapped) {
|
|
65
|
+
this.input.off(name, wrapped);
|
|
66
|
+
this.userHandlers.delete(handler);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/** Trigger a full repaint at the new size. */
|
|
70
|
+
resize(cols, rows) {
|
|
71
|
+
this._cols = cols;
|
|
72
|
+
this._rows = rows;
|
|
73
|
+
this.frame.resize(this.lastLines);
|
|
74
|
+
this.opts.onResize?.(cols, rows);
|
|
75
|
+
}
|
|
76
|
+
/** Current terminal size as last seen or reported by a resize event. */
|
|
77
|
+
get size() {
|
|
78
|
+
return { cols: this._cols, rows: this._rows };
|
|
79
|
+
}
|
|
80
|
+
onResize = () => {
|
|
81
|
+
this._cols = this.opts.output.columns ?? this._cols;
|
|
82
|
+
this._rows = this.opts.output.rows ?? this._rows;
|
|
83
|
+
this.frame.resize(this.lastLines);
|
|
84
|
+
this.opts.onResize?.(this._cols, this._rows);
|
|
85
|
+
};
|
|
86
|
+
attachResize() {
|
|
87
|
+
this.opts.output.on?.("resize", this.onResize);
|
|
88
|
+
}
|
|
89
|
+
detachResize() {
|
|
90
|
+
this.opts.output.off?.("resize", this.onResize);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** A write target — anything with a write(chunk: string) method. */
|
|
2
|
+
export interface OutputSink {
|
|
3
|
+
write: (chunk: string) => unknown;
|
|
4
|
+
}
|
|
5
|
+
/** Frame state + diff rendering. */
|
|
6
|
+
export declare class Frame {
|
|
7
|
+
private out;
|
|
8
|
+
private prev;
|
|
9
|
+
private active;
|
|
10
|
+
constructor(out: OutputSink);
|
|
11
|
+
/** Enter the alternate screen, hide the cursor, and paint the initial frame. */
|
|
12
|
+
enter(lines: string[]): void;
|
|
13
|
+
/**
|
|
14
|
+
* Paint only the lines that differ from the previous model. Lines are addressed with
|
|
15
|
+
* absolute cursor positioning; unchanged rows produce no output.
|
|
16
|
+
*/
|
|
17
|
+
render(lines: string[]): void;
|
|
18
|
+
/** Invalidate the cached frame so the next render writes every line. */
|
|
19
|
+
resize(lines: string[]): void;
|
|
20
|
+
/** Restore the terminal: show cursor and return to the normal screen buffer. */
|
|
21
|
+
exit(): void;
|
|
22
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Line-diff renderer for the alternate screen. Maintains the previous line model and
|
|
2
|
+
// repaints only rows that changed since the last render, keeping terminal traffic small.
|
|
3
|
+
/** Frame state + diff rendering. */
|
|
4
|
+
export class Frame {
|
|
5
|
+
out;
|
|
6
|
+
prev = [];
|
|
7
|
+
active = false;
|
|
8
|
+
constructor(out) {
|
|
9
|
+
this.out = out;
|
|
10
|
+
}
|
|
11
|
+
/** Enter the alternate screen, hide the cursor, and paint the initial frame. */
|
|
12
|
+
enter(lines) {
|
|
13
|
+
this.active = true;
|
|
14
|
+
this.prev = [];
|
|
15
|
+
this.out.write("\x1b[?1049h"); // alternate screen buffer
|
|
16
|
+
this.out.write("\x1b[?25l"); // hide cursor
|
|
17
|
+
this.render(lines);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Paint only the lines that differ from the previous model. Lines are addressed with
|
|
21
|
+
* absolute cursor positioning; unchanged rows produce no output.
|
|
22
|
+
*/
|
|
23
|
+
render(lines) {
|
|
24
|
+
if (!this.active)
|
|
25
|
+
return;
|
|
26
|
+
const curr = lines.slice();
|
|
27
|
+
const max = Math.max(this.prev.length, curr.length);
|
|
28
|
+
for (let i = 0; i < max; i++) {
|
|
29
|
+
if (this.prev[i] !== curr[i]) {
|
|
30
|
+
this.out.write(`\x1b[${i + 1};1H`); // row i+1, column 1
|
|
31
|
+
this.out.write(curr[i] ?? "");
|
|
32
|
+
this.out.write("\x1b[K"); // clear to end of line
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (curr.length < this.prev.length) {
|
|
36
|
+
// The frame shrank — clear everything below the new last line.
|
|
37
|
+
this.out.write(`\x1b[${curr.length + 1};1H`);
|
|
38
|
+
this.out.write("\x1b[J");
|
|
39
|
+
}
|
|
40
|
+
this.prev = curr;
|
|
41
|
+
}
|
|
42
|
+
/** Invalidate the cached frame so the next render writes every line. */
|
|
43
|
+
resize(lines) {
|
|
44
|
+
this.prev = [];
|
|
45
|
+
this.render(lines);
|
|
46
|
+
}
|
|
47
|
+
/** Restore the terminal: show cursor and return to the normal screen buffer. */
|
|
48
|
+
exit() {
|
|
49
|
+
if (!this.active)
|
|
50
|
+
return;
|
|
51
|
+
this.out.write("\x1b[?25h"); // show cursor
|
|
52
|
+
this.out.write("\x1b[?1049l"); // normal screen buffer
|
|
53
|
+
this.active = false;
|
|
54
|
+
this.prev = [];
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** Decoded keypress shape. */
|
|
2
|
+
export interface Key {
|
|
3
|
+
name?: string;
|
|
4
|
+
sequence?: string;
|
|
5
|
+
ctrl?: boolean;
|
|
6
|
+
meta?: boolean;
|
|
7
|
+
shift?: boolean;
|
|
8
|
+
}
|
|
9
|
+
/** Handler for a named key. May be async. */
|
|
10
|
+
export type KeyHandler = (key: Key) => void | Promise<void>;
|
|
11
|
+
/** Readable stream with the small TTY surface the engine needs. */
|
|
12
|
+
export interface InputStream extends NodeJS.ReadableStream {
|
|
13
|
+
isTTY?: boolean;
|
|
14
|
+
setRawMode?: (mode: boolean) => unknown;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Decode the next key at the start of a byte buffer. Returns the decoded key and
|
|
18
|
+
* how many bytes were consumed. Undefined when more bytes are needed.
|
|
19
|
+
*/
|
|
20
|
+
export declare function decodeOneKey(buf: Buffer): {
|
|
21
|
+
key: Key;
|
|
22
|
+
consumed: number;
|
|
23
|
+
} | undefined;
|
|
24
|
+
/** Routes decoded keypress events to handlers registered by key name. */
|
|
25
|
+
export declare class InputRouter {
|
|
26
|
+
private input;
|
|
27
|
+
private handlers;
|
|
28
|
+
private started;
|
|
29
|
+
private buffer;
|
|
30
|
+
constructor(input: InputStream);
|
|
31
|
+
/** Register a handler for a named key (e.g. "up", "escape", "q"). */
|
|
32
|
+
on(name: string, handler: KeyHandler): void;
|
|
33
|
+
/** Remove a previously registered handler. */
|
|
34
|
+
off(name: string, handler: KeyHandler): void;
|
|
35
|
+
/** Start decoding keypresses from the input stream. Idempotent. */
|
|
36
|
+
start(): void;
|
|
37
|
+
/** Stop decoding and release the stream. Idempotent. */
|
|
38
|
+
stop(): void;
|
|
39
|
+
private onData;
|
|
40
|
+
private dispatch;
|
|
41
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decode the next key at the start of a byte buffer. Returns the decoded key and
|
|
3
|
+
* how many bytes were consumed. Undefined when more bytes are needed.
|
|
4
|
+
*/
|
|
5
|
+
export function decodeOneKey(buf) {
|
|
6
|
+
if (buf.length === 0)
|
|
7
|
+
return undefined;
|
|
8
|
+
const b0 = buf[0];
|
|
9
|
+
if (b0 === 0x1b) {
|
|
10
|
+
// CSI arrow sequences: ESC [ A/B/C/D. Keep an incomplete CSI buffered so
|
|
11
|
+
// arbitrary stream chunking cannot turn an arrow into Escape + plain keys.
|
|
12
|
+
if (buf[1] === 0x5b) {
|
|
13
|
+
if (buf.length < 3)
|
|
14
|
+
return undefined;
|
|
15
|
+
const name = buf[2] === 0x41 ? "up"
|
|
16
|
+
: buf[2] === 0x42 ? "down"
|
|
17
|
+
: buf[2] === 0x43 ? "right"
|
|
18
|
+
: buf[2] === 0x44 ? "left"
|
|
19
|
+
: undefined;
|
|
20
|
+
if (name) {
|
|
21
|
+
return { key: { name, sequence: buf.subarray(0, 3).toString("binary") }, consumed: 3 };
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
// A lone ESC (or ESC followed by something we do not recognise) is Escape.
|
|
25
|
+
return { key: { name: "escape", sequence: "\x1b" }, consumed: 1 };
|
|
26
|
+
}
|
|
27
|
+
if (b0 === 0x0d || b0 === 0x0a) {
|
|
28
|
+
return { key: { name: "return", sequence: String.fromCharCode(b0) }, consumed: 1 };
|
|
29
|
+
}
|
|
30
|
+
if (b0 === 0x03) {
|
|
31
|
+
return { key: { name: "c", ctrl: true, sequence: "\x03" }, consumed: 1 };
|
|
32
|
+
}
|
|
33
|
+
if (b0 >= 0x20 && b0 < 0x7f) {
|
|
34
|
+
return { key: { name: String.fromCharCode(b0), sequence: String.fromCharCode(b0) }, consumed: 1 };
|
|
35
|
+
}
|
|
36
|
+
// Unknown byte: consume it without a name so it does not block the stream.
|
|
37
|
+
return { key: { sequence: String.fromCharCode(b0) }, consumed: 1 };
|
|
38
|
+
}
|
|
39
|
+
/** Routes decoded keypress events to handlers registered by key name. */
|
|
40
|
+
export class InputRouter {
|
|
41
|
+
input;
|
|
42
|
+
handlers = new Map();
|
|
43
|
+
started = false;
|
|
44
|
+
buffer = Buffer.alloc(0);
|
|
45
|
+
constructor(input) {
|
|
46
|
+
this.input = input;
|
|
47
|
+
}
|
|
48
|
+
/** Register a handler for a named key (e.g. "up", "escape", "q"). */
|
|
49
|
+
on(name, handler) {
|
|
50
|
+
let set = this.handlers.get(name);
|
|
51
|
+
if (!set) {
|
|
52
|
+
set = new Set();
|
|
53
|
+
this.handlers.set(name, set);
|
|
54
|
+
}
|
|
55
|
+
set.add(handler);
|
|
56
|
+
}
|
|
57
|
+
/** Remove a previously registered handler. */
|
|
58
|
+
off(name, handler) {
|
|
59
|
+
this.handlers.get(name)?.delete(handler);
|
|
60
|
+
}
|
|
61
|
+
/** Start decoding keypresses from the input stream. Idempotent. */
|
|
62
|
+
start() {
|
|
63
|
+
if (this.started)
|
|
64
|
+
return;
|
|
65
|
+
this.started = true;
|
|
66
|
+
this.input.setRawMode?.(true);
|
|
67
|
+
this.input.on("data", this.onData);
|
|
68
|
+
this.input.resume();
|
|
69
|
+
}
|
|
70
|
+
/** Stop decoding and release the stream. Idempotent. */
|
|
71
|
+
stop() {
|
|
72
|
+
if (!this.started)
|
|
73
|
+
return;
|
|
74
|
+
this.input.off("data", this.onData);
|
|
75
|
+
this.input.setRawMode?.(false);
|
|
76
|
+
this.input.pause();
|
|
77
|
+
this.started = false;
|
|
78
|
+
}
|
|
79
|
+
onData = (chunk) => {
|
|
80
|
+
const data = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, "binary");
|
|
81
|
+
this.buffer = Buffer.concat([this.buffer, data]);
|
|
82
|
+
while (this.buffer.length > 0) {
|
|
83
|
+
const result = decodeOneKey(this.buffer);
|
|
84
|
+
if (!result)
|
|
85
|
+
break;
|
|
86
|
+
this.dispatch(result.key);
|
|
87
|
+
this.buffer = this.buffer.subarray(result.consumed);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
dispatch(key) {
|
|
91
|
+
const name = key.name ?? key.sequence;
|
|
92
|
+
if (!name)
|
|
93
|
+
return;
|
|
94
|
+
const set = this.handlers.get(name);
|
|
95
|
+
if (!set)
|
|
96
|
+
return;
|
|
97
|
+
for (const handler of set)
|
|
98
|
+
void handler(key);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { View } from "../app.js";
|
|
2
|
+
import type { AuthHealth } from "../../adapters/types.js";
|
|
3
|
+
import type { Tier } from "../../config/config.js";
|
|
4
|
+
import type { TelemetryRow } from "../../run/journal.js";
|
|
5
|
+
/** One discovered adapter and its classified models (cfg.tiers via channels()). */
|
|
6
|
+
export type FleetRosterEntry = {
|
|
7
|
+
adapter: string;
|
|
8
|
+
models: {
|
|
9
|
+
model: string;
|
|
10
|
+
tier: Tier;
|
|
11
|
+
channel: "sub" | "api";
|
|
12
|
+
}[];
|
|
13
|
+
};
|
|
14
|
+
/** Provenance notes harvested from overlay `# comment`s (fleet-overlay.ts shapes). */
|
|
15
|
+
export type FleetProvenanceNotes = {
|
|
16
|
+
denyAdapters?: Record<string, string>;
|
|
17
|
+
denyModels?: Record<string, string>;
|
|
18
|
+
};
|
|
19
|
+
/** Everything the roster renders — loaded by the caller, never by the render path. */
|
|
20
|
+
export type FleetViewData = {
|
|
21
|
+
adapters: FleetRosterEntry[];
|
|
22
|
+
/** Doctor cache contents (doctor.json), keyed by adapter id. */
|
|
23
|
+
health: Record<string, AuthHealth>;
|
|
24
|
+
/** Age of the doctor cache in ms; null when no probe data exists. */
|
|
25
|
+
doctorAgeMs: number | null;
|
|
26
|
+
denyAdapters: string[];
|
|
27
|
+
/** routing.deny.models entries in channelKey form ("adapter:model"). */
|
|
28
|
+
denyModels: string[];
|
|
29
|
+
notes?: FleetProvenanceNotes;
|
|
30
|
+
/** Telemetry folded from recent run journals (readAllTelemetry); runId optional. */
|
|
31
|
+
telemetry: (TelemetryRow & {
|
|
32
|
+
runId?: string;
|
|
33
|
+
})[];
|
|
34
|
+
};
|
|
35
|
+
/** Per-channel health digest, folded from journal telemetry rows only. */
|
|
36
|
+
export type ChannelHealth = {
|
|
37
|
+
key: string;
|
|
38
|
+
tasks: number;
|
|
39
|
+
attempts: number;
|
|
40
|
+
done: number;
|
|
41
|
+
failed: number;
|
|
42
|
+
human: number;
|
|
43
|
+
overruns: number;
|
|
44
|
+
quotaFailovers: number;
|
|
45
|
+
/** Sum of OBSERVED gateFails; null when no row carried the field (unobserved, never 0). */
|
|
46
|
+
gateFails: number | null;
|
|
47
|
+
medianDurationMs: number | null;
|
|
48
|
+
lastOutcome: TelemetryRow["outcome"] | null;
|
|
49
|
+
};
|
|
50
|
+
/** Fold journal telemetry rows into a per-channel health digest keyed by channelKey
|
|
51
|
+
* ("adapter:model"). The digest derives from journal telemetry (TelemetryRow, the rows the
|
|
52
|
+
* daemon appends to each run's telemetry.jsonl) rather than any new state file: this module
|
|
53
|
+
* reads nothing and writes nothing — the caller folds rows out of the existing run journals
|
|
54
|
+
* (readAllTelemetry) and the fold is a pure function of them, same telemetry in, same digest
|
|
55
|
+
* out. */
|
|
56
|
+
export declare function foldChannelHealth(rows: (TelemetryRow & {
|
|
57
|
+
runId?: string;
|
|
58
|
+
})[]): Map<string, ChannelHealth>;
|
|
59
|
+
/** The studio View plus the roster's own key seam. The T2 shell binds only view-level keys, so
|
|
60
|
+
* expand/collapse and selection are driven through key() — the same names the engine decodes. */
|
|
61
|
+
export type FleetView = View & {
|
|
62
|
+
/** Handle a decoded key name ("up" | "down" | "left" | "right" | "space" | "return"). */
|
|
63
|
+
key(name: string): void;
|
|
64
|
+
/** Whether an adapter's model rows are currently shown. */
|
|
65
|
+
expanded(adapter: string): boolean;
|
|
66
|
+
/** channelKey of the cursor's channel, or null when the cursor sits on an adapter row. */
|
|
67
|
+
selectedChannel(): string | null;
|
|
68
|
+
};
|
|
69
|
+
export declare function createFleetView(data?: FleetViewData): FleetView;
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { GLYPHS, bold, dim, fail, legend } from "../../brand.js";
|
|
2
|
+
const keyOf = (r) => `${r.adapter}:${r.model}`;
|
|
3
|
+
const median = (xs) => {
|
|
4
|
+
const s = [...xs].sort((a, b) => a - b);
|
|
5
|
+
const m = Math.floor(s.length / 2);
|
|
6
|
+
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
|
|
7
|
+
};
|
|
8
|
+
/** Fold one row list into a single digest. Journals are append-only, so input order is
|
|
9
|
+
* chronological and the last row's outcome is the channel's last observed outcome. */
|
|
10
|
+
function foldRows(key, rows) {
|
|
11
|
+
const d = {
|
|
12
|
+
key, tasks: 0, attempts: 0, done: 0, failed: 0, human: 0,
|
|
13
|
+
overruns: 0, quotaFailovers: 0, gateFails: null, medianDurationMs: null, lastOutcome: null,
|
|
14
|
+
};
|
|
15
|
+
for (const r of rows) {
|
|
16
|
+
d.tasks += 1;
|
|
17
|
+
d.attempts += r.attempts;
|
|
18
|
+
if (r.outcome === "done")
|
|
19
|
+
d.done += 1;
|
|
20
|
+
else if (r.outcome === "failed")
|
|
21
|
+
d.failed += 1;
|
|
22
|
+
else
|
|
23
|
+
d.human += 1;
|
|
24
|
+
if (r.overrun === true)
|
|
25
|
+
d.overruns += 1;
|
|
26
|
+
if (r.quotaFailover === true)
|
|
27
|
+
d.quotaFailovers += 1;
|
|
28
|
+
// absent = unobserved: an all-legacy channel keeps gateFails null rather than a fake 0
|
|
29
|
+
if (r.gateFails !== undefined)
|
|
30
|
+
d.gateFails = (d.gateFails ?? 0) + r.gateFails;
|
|
31
|
+
d.lastOutcome = r.outcome;
|
|
32
|
+
}
|
|
33
|
+
if (rows.length)
|
|
34
|
+
d.medianDurationMs = median(rows.map((r) => r.durationMs));
|
|
35
|
+
return d;
|
|
36
|
+
}
|
|
37
|
+
/** Fold journal telemetry rows into a per-channel health digest keyed by channelKey
|
|
38
|
+
* ("adapter:model"). The digest derives from journal telemetry (TelemetryRow, the rows the
|
|
39
|
+
* daemon appends to each run's telemetry.jsonl) rather than any new state file: this module
|
|
40
|
+
* reads nothing and writes nothing — the caller folds rows out of the existing run journals
|
|
41
|
+
* (readAllTelemetry) and the fold is a pure function of them, same telemetry in, same digest
|
|
42
|
+
* out. */
|
|
43
|
+
export function foldChannelHealth(rows) {
|
|
44
|
+
const byChannel = new Map();
|
|
45
|
+
for (const r of rows) {
|
|
46
|
+
const key = keyOf(r);
|
|
47
|
+
const list = byChannel.get(key);
|
|
48
|
+
if (list)
|
|
49
|
+
list.push(r);
|
|
50
|
+
else
|
|
51
|
+
byChannel.set(key, [r]);
|
|
52
|
+
}
|
|
53
|
+
const out = new Map();
|
|
54
|
+
for (const [key, list] of byChannel)
|
|
55
|
+
out.set(key, foldRows(key, list));
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
// fleet.ts formatAge voice — the operator already reads "12m old" on the classic fleet screen.
|
|
59
|
+
const formatCacheAge = (ageMs) => {
|
|
60
|
+
if (ageMs === null)
|
|
61
|
+
return "no probe data";
|
|
62
|
+
const mins = Math.floor(ageMs / 60_000);
|
|
63
|
+
if (mins < 60)
|
|
64
|
+
return `${mins}m old`;
|
|
65
|
+
return `${Math.floor(mins / 60)}h old`;
|
|
66
|
+
};
|
|
67
|
+
const fmtDuration = (ms) => {
|
|
68
|
+
const s = Math.round(ms / 1000);
|
|
69
|
+
return s < 60 ? `${s}s` : `${(s / 60).toFixed(1)}m`;
|
|
70
|
+
};
|
|
71
|
+
const trunc = (s, n) => (s.length <= n ? s : `${s.slice(0, n - 1)}…`);
|
|
72
|
+
const note = (n) => (n ? ` — ${n}` : "");
|
|
73
|
+
const plural = (n, noun) => `${n} ${noun}${n === 1 ? "" : "s"}`;
|
|
74
|
+
/** doctor.ts model-status voice: authed / unauthed with reason+date / unknown. A non-installed
|
|
75
|
+
* adapter is "unknown" whatever stale modelAuth its doctor entry still carries — the roster row
|
|
76
|
+
* and the detail panel must never disagree about whether a channel is usable. */
|
|
77
|
+
function modelAuthText(health, model) {
|
|
78
|
+
const v = health?.installed ? health.modelAuth?.[model] : undefined;
|
|
79
|
+
if (!v)
|
|
80
|
+
return "unknown";
|
|
81
|
+
if (v.authed)
|
|
82
|
+
return "authed";
|
|
83
|
+
return `unauthed: ${trunc(v.reason ?? "probe failed", 40)} (${v.probedAt.slice(0, 10)})`;
|
|
84
|
+
}
|
|
85
|
+
/** A channel is denied by its own routing.deny.models entry OR by its adapter's deny — the roster
|
|
86
|
+
* row and the detail panel resolve deny through this one seam so an adapter-level deny can never
|
|
87
|
+
* read as available on the model row while the panel below it says "denied". Adapter deny wins
|
|
88
|
+
* and carries the adapter's note: it is the broader reason. */
|
|
89
|
+
function denyOf(data, adapter, model) {
|
|
90
|
+
if (data.denyAdapters.includes(adapter))
|
|
91
|
+
return { denied: true, note: data.notes?.denyAdapters?.[adapter] };
|
|
92
|
+
const key = `${adapter}:${model}`;
|
|
93
|
+
if (data.denyModels.includes(key))
|
|
94
|
+
return { denied: true, note: data.notes?.denyModels?.[key] };
|
|
95
|
+
return { denied: false };
|
|
96
|
+
}
|
|
97
|
+
function digestLine(d, noun) {
|
|
98
|
+
if (d.tasks === 0)
|
|
99
|
+
return ` journals: no recent telemetry for this ${noun}`;
|
|
100
|
+
const parts = [
|
|
101
|
+
`${d.tasks} task${d.tasks === 1 ? "" : "s"}`,
|
|
102
|
+
`${d.attempts} attempt${d.attempts === 1 ? "" : "s"}`,
|
|
103
|
+
`${d.done} done`,
|
|
104
|
+
];
|
|
105
|
+
if (d.failed)
|
|
106
|
+
parts.push(`${d.failed} failed`);
|
|
107
|
+
if (d.human)
|
|
108
|
+
parts.push(`${d.human} human`);
|
|
109
|
+
if (d.medianDurationMs !== null)
|
|
110
|
+
parts.push(`median ${fmtDuration(d.medianDurationMs)}`);
|
|
111
|
+
if (d.gateFails !== null)
|
|
112
|
+
parts.push(`${d.gateFails} gate-fails`);
|
|
113
|
+
parts.push(`${d.overruns} overrun${d.overruns === 1 ? "" : "s"}`);
|
|
114
|
+
parts.push(`${d.quotaFailovers} quota failover${d.quotaFailovers === 1 ? "" : "s"}`);
|
|
115
|
+
return ` journals: ${parts.join(" · ")}`;
|
|
116
|
+
}
|
|
117
|
+
export function createFleetView(data) {
|
|
118
|
+
// One expanded bit per adapter, default expanded — the roster shows everything at a glance.
|
|
119
|
+
const expandedMap = new Map((data?.adapters ?? []).map((a) => [a.adapter, true]));
|
|
120
|
+
let cursor = 0;
|
|
121
|
+
const visibleRows = () => {
|
|
122
|
+
const rows = [];
|
|
123
|
+
for (const a of data?.adapters ?? []) {
|
|
124
|
+
rows.push({ kind: "adapter", adapter: a.adapter });
|
|
125
|
+
if (expandedMap.get(a.adapter) !== false) {
|
|
126
|
+
for (const m of a.models)
|
|
127
|
+
rows.push({ kind: "model", adapter: a.adapter, model: m.model });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return rows;
|
|
131
|
+
};
|
|
132
|
+
const view = {
|
|
133
|
+
id: "fleet",
|
|
134
|
+
label: "Fleet",
|
|
135
|
+
key(name) {
|
|
136
|
+
const rows = visibleRows();
|
|
137
|
+
if (!rows.length)
|
|
138
|
+
return;
|
|
139
|
+
if (name === "up")
|
|
140
|
+
cursor = Math.max(cursor - 1, 0);
|
|
141
|
+
else if (name === "down")
|
|
142
|
+
cursor = Math.min(cursor + 1, rows.length - 1);
|
|
143
|
+
else if (name === "left" || name === "right" || name === "space" || name === "return") {
|
|
144
|
+
const row = rows[cursor];
|
|
145
|
+
if (!row)
|
|
146
|
+
return;
|
|
147
|
+
const cur = expandedMap.get(row.adapter) !== false;
|
|
148
|
+
const next = name === "left" ? false : name === "right" ? true : !cur;
|
|
149
|
+
expandedMap.set(row.adapter, next);
|
|
150
|
+
if (row.kind === "model" && !next) {
|
|
151
|
+
// collapsing from a model row parks the cursor on the adapter header row
|
|
152
|
+
cursor = visibleRows().findIndex((r) => r.kind === "adapter" && r.adapter === row.adapter);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const n = visibleRows().length;
|
|
156
|
+
cursor = Math.min(Math.max(cursor, 0), n - 1);
|
|
157
|
+
},
|
|
158
|
+
expanded(adapter) {
|
|
159
|
+
return expandedMap.get(adapter) !== false;
|
|
160
|
+
},
|
|
161
|
+
selectedChannel() {
|
|
162
|
+
const row = visibleRows()[cursor];
|
|
163
|
+
return row?.kind === "model" ? `${row.adapter}:${row.model}` : null;
|
|
164
|
+
},
|
|
165
|
+
render(_props) {
|
|
166
|
+
if (!data) {
|
|
167
|
+
// T2 stub surface — the shell registers the view before any data source is wired.
|
|
168
|
+
return ["Fleet view", "Harness roster and health (read-only in v1.66)."];
|
|
169
|
+
}
|
|
170
|
+
const digest = foldChannelHealth(data.telemetry);
|
|
171
|
+
const lines = [bold("Fleet view — harness roster")];
|
|
172
|
+
lines.push(legend(`doctor cache: ${formatCacheAge(data.doctorAgeMs)} · ${data.adapters.length} adapters · ` +
|
|
173
|
+
`${data.telemetry.length} journal rows · ↑↓ select · → expand · ← collapse · space toggle`));
|
|
174
|
+
const rows = visibleRows();
|
|
175
|
+
rows.forEach((row, i) => {
|
|
176
|
+
const pointer = i === cursor ? `${GLYPHS.pointer} ` : " ";
|
|
177
|
+
const h = data.health[row.adapter];
|
|
178
|
+
if (row.kind === "adapter") {
|
|
179
|
+
const marker = expandedMap.get(row.adapter) !== false ? "▾" : "▸";
|
|
180
|
+
const state = !h?.installed
|
|
181
|
+
? `${fail(GLYPHS.fail)} not installed`
|
|
182
|
+
: `${h.version ?? "installed"} · ${h.authed ? "authed" : "unauthed"}`;
|
|
183
|
+
const counts = h?.installed ? ` · ${plural(entryOf(data, row.adapter)?.models.length ?? 0, "model")}` : "";
|
|
184
|
+
const deny = data.denyAdapters.includes(row.adapter)
|
|
185
|
+
? ` · ${fail("denied")}${note(data.notes?.denyAdapters?.[row.adapter])}`
|
|
186
|
+
: "";
|
|
187
|
+
lines.push(`${pointer}${marker} ${bold(row.adapter)} ${state}${counts}${deny}`);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const m = data.adapters.find((a) => a.adapter === row.adapter)?.models.find((mm) => mm.model === row.model);
|
|
191
|
+
if (!m)
|
|
192
|
+
return;
|
|
193
|
+
const key = `${row.adapter}:${row.model}`;
|
|
194
|
+
const auth = modelAuthText(h, row.model);
|
|
195
|
+
const dn = denyOf(data, row.adapter, row.model);
|
|
196
|
+
const deny = dn.denied ? ` ${fail(GLYPHS.fail)} denied${note(dn.note)}` : "";
|
|
197
|
+
const d = digest.get(key);
|
|
198
|
+
const healthBit = d && d.tasks > 0 ? ` ${dim(`${d.done}/${d.tasks} done`)}` : "";
|
|
199
|
+
lines.push(`${pointer} ${row.model.padEnd(28)} [${m.tier}] ${m.channel} ${auth}${deny}${healthBit}`);
|
|
200
|
+
});
|
|
201
|
+
lines.push(...detailPanel(data, digest, rows[cursor]));
|
|
202
|
+
return lines;
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
return view;
|
|
206
|
+
}
|
|
207
|
+
const entryOf = (data, adapter) => data.adapters.find((a) => a.adapter === adapter);
|
|
208
|
+
/** The bottom panel: the selected channel's journal-derived health digest (or the adapter fold
|
|
209
|
+
* when the cursor sits on an adapter header row). */
|
|
210
|
+
function detailPanel(data, digest, row) {
|
|
211
|
+
if (!row)
|
|
212
|
+
return [];
|
|
213
|
+
const h = data.health[row.adapter];
|
|
214
|
+
if (row.kind === "model") {
|
|
215
|
+
const key = `${row.adapter}:${row.model}`;
|
|
216
|
+
const m = data.adapters.find((a) => a.adapter === row.adapter)?.models.find((mm) => mm.model === row.model);
|
|
217
|
+
const v = h?.installed ? h.modelAuth?.[row.model] : undefined;
|
|
218
|
+
// an unauthed reading already carries its probe date inside modelAuthText — appending it again
|
|
219
|
+
// printed the same day twice ("… (2026-07-21) (probed 2026-07-21)")
|
|
220
|
+
const probed = v?.authed ? ` (probed ${v.probedAt.slice(0, 10)})` : "";
|
|
221
|
+
const dn = denyOf(data, row.adapter, row.model);
|
|
222
|
+
const d = digest.get(key);
|
|
223
|
+
const lines = [
|
|
224
|
+
"",
|
|
225
|
+
dim(`── ${key} ──`),
|
|
226
|
+
` tier ${m?.tier ?? "???"} · ${m?.channel ?? "?"} · ${modelAuthText(h, row.model)}${probed} · ${dn.denied ? "denied" : "enabled"}${note(dn.note)}`,
|
|
227
|
+
digestLine(d ?? foldRows(key, []), "channel"),
|
|
228
|
+
];
|
|
229
|
+
if (d?.lastOutcome)
|
|
230
|
+
lines.push(` last outcome: ${d.lastOutcome}`);
|
|
231
|
+
return lines;
|
|
232
|
+
}
|
|
233
|
+
const folded = foldRows(row.adapter, data.telemetry.filter((r) => r.adapter === row.adapter));
|
|
234
|
+
const state = !h?.installed ? "not installed" : `${h.version ?? "installed"} · ${h.authed ? "authed" : "unauthed"}`;
|
|
235
|
+
const denied = data.denyAdapters.includes(row.adapter);
|
|
236
|
+
const enabled = `${denied ? "denied" : "enabled"}${denied ? note(data.notes?.denyAdapters?.[row.adapter]) : ""}`;
|
|
237
|
+
return [
|
|
238
|
+
"",
|
|
239
|
+
dim(`── ${row.adapter} (adapter) ──`),
|
|
240
|
+
` ${state} · ${plural(entryOf(data, row.adapter)?.models.length ?? 0, "model")} · ${enabled}`,
|
|
241
|
+
digestLine(folded, "adapter"),
|
|
242
|
+
];
|
|
243
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { allAdapters, discoverChannels, readDoctor } from "../../adapters/registry.js";
|
|
2
|
+
import { costSignal } from "../../cli/commands/fleet-picker.js";
|
|
3
|
+
import { loadGraph } from "../../graph/graph.js";
|
|
4
|
+
import { SHAPES } from "../../graph/schema.js";
|
|
5
|
+
import { route, RoutingError } from "../../route/router.js";
|
|
6
|
+
import { resolveRunMode } from "../../run/daemon.js";
|
|
7
|
+
import { loadRoutingProfile } from "../../run/journal.js";
|
|
8
|
+
// Same exploration setting as the candidate picker (rankCandidates routes noExplore so repeated
|
|
9
|
+
// calls agree): a due probe must never make a preview row disagree with the picker's rank-1 for
|
|
10
|
+
// the same graph and channel set. Mirrors fleet.ts's PREVIEW_EXPLORE.
|
|
11
|
+
const PREVIEW_EXPLORE = { noExplore: true };
|
|
12
|
+
// The no-graph stand-in: one synthetic task per shape so the consequence surface still shows what
|
|
13
|
+
// the router WOULD do with this fleet. Same shape as fleet.ts's previewTask.
|
|
14
|
+
function syntheticTask(shape) {
|
|
15
|
+
return {
|
|
16
|
+
id: `preview-${shape}`,
|
|
17
|
+
title: `${shape} preview`,
|
|
18
|
+
goal: "preview",
|
|
19
|
+
shape,
|
|
20
|
+
complexity: 3,
|
|
21
|
+
acceptance: ["done"],
|
|
22
|
+
deps: [],
|
|
23
|
+
files: [],
|
|
24
|
+
context: [],
|
|
25
|
+
gates: ["build", "test", "lint", "evidence", "scope", "acceptance", "review"],
|
|
26
|
+
humanGate: false,
|
|
27
|
+
status: "pending",
|
|
28
|
+
evidence: { commits: [], artifacts: [], gateResults: [] },
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
// The consequence surface: the routing table a plan dry-run would produce, one row per task with
|
|
32
|
+
// the chosen channel, the route()'s why-provenance verbatim, and the picker's cost signal. Every
|
|
33
|
+
// render re-reads graph/config/doctor cache from disk (refresh on demand = repaint); the render
|
|
34
|
+
// path is strictly read-only — doctor cache only, never probeAll, so a preview spends no tokens
|
|
35
|
+
// and invokes no agent. Unroutable rows render the RoutingError in place, like plan's `!!` rows.
|
|
36
|
+
function renderPreview(deps) {
|
|
37
|
+
const cwd = deps.cwd ?? process.cwd();
|
|
38
|
+
const adapters = deps.adapters ?? allAdapters();
|
|
39
|
+
let graph = null;
|
|
40
|
+
try {
|
|
41
|
+
graph = loadGraph(cwd);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
graph = null; // absent or invalid — fall through to the synthetic per-shape set
|
|
45
|
+
}
|
|
46
|
+
const { cfg, mode, source } = resolveRunMode(cwd, { spec: graph?.mode });
|
|
47
|
+
const cached = readDoctor(cwd);
|
|
48
|
+
const channels = discoverChannels(cfg, adapters, cached ?? {});
|
|
49
|
+
const profile = loadRoutingProfile(cwd, cfg, { preview: true });
|
|
50
|
+
const tasks = graph ? graph.tasks : SHAPES.map(syntheticTask);
|
|
51
|
+
const lines = [
|
|
52
|
+
"Preview view — plan dry run (read-only: no writes, no probes, no token spend)",
|
|
53
|
+
graph
|
|
54
|
+
? `source: compiled graph (${tasks.length} task${tasks.length === 1 ? "" : "s"}) · mode: ${mode.mode} (${source}) · explore off (picker parity) · ${channels.length} channels`
|
|
55
|
+
: `source: no compiled graph — synthetic per-shape task set (${tasks.length} shapes) · mode: ${mode.mode} (${source}) · explore off (picker parity) · ${channels.length} channels`,
|
|
56
|
+
];
|
|
57
|
+
if (!cached)
|
|
58
|
+
lines.push("doctor cache missing — rows route against zero channels; run `tickmarkr doctor` (the preview never probes)");
|
|
59
|
+
lines.push("");
|
|
60
|
+
for (const t of tasks) {
|
|
61
|
+
try {
|
|
62
|
+
const r = route(t, cfg, channels, profile, undefined, undefined, PREVIEW_EXPLORE);
|
|
63
|
+
const a = r.assignment;
|
|
64
|
+
lines.push(` ${t.id.padEnd(16)} ${t.shape.padEnd(10)} → ${a.adapter}:${a.model} [${a.channel}/${a.tier}] ${costSignal(a, cfg.pricing)} — ${r.provenance}`);
|
|
65
|
+
}
|
|
66
|
+
catch (e) {
|
|
67
|
+
if (!(e instanceof RoutingError))
|
|
68
|
+
throw e;
|
|
69
|
+
lines.push(` ${t.id.padEnd(16)} ${t.shape.padEnd(10)} !! ${e.message}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return lines;
|
|
73
|
+
}
|
|
74
|
+
export function createPreviewView(deps = {}) {
|
|
75
|
+
return {
|
|
76
|
+
id: "preview",
|
|
77
|
+
label: "Preview",
|
|
78
|
+
render: () => renderPreview(deps),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type TickmarkrConfig } from "../../config/config.js";
|
|
2
|
+
import { type RoutingProfile } from "../../route/profile.js";
|
|
3
|
+
import type { View } from "../app.js";
|
|
4
|
+
export type ProfileViewData = {
|
|
5
|
+
cfg: TickmarkrConfig;
|
|
6
|
+
/** the loaded routing profile; undefined ⇒ cold (no telemetry in the window) */
|
|
7
|
+
profile?: RoutingProfile;
|
|
8
|
+
};
|
|
9
|
+
export type ProfileViewDeps = {
|
|
10
|
+
/** injected fixture data — with data present the render path never touches the filesystem */
|
|
11
|
+
data?: ProfileViewData;
|
|
12
|
+
/** where the no-arg studio shell loads config and the profile from (default: process.cwd()) */
|
|
13
|
+
repoRoot?: string;
|
|
14
|
+
};
|
|
15
|
+
export declare function createProfileView(deps?: ProfileViewDeps): View;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { channelKey } from "../../adapters/types.js";
|
|
2
|
+
import { loadConfig } from "../../config/config.js";
|
|
3
|
+
import { AVAIL_WEIGHT, cellsOf, cellSummary, EXPLORE_CAP, HALF_LIFE_RUNS, learnedScore, MIN_SAMPLES, } from "../../route/profile.js";
|
|
4
|
+
import { loadRoutingProfile } from "../../run/journal.js";
|
|
5
|
+
const fmtSigned = (n) => (n >= 0 ? `+${n.toFixed(3)}` : n.toFixed(3));
|
|
6
|
+
// No-arg shell path (app.ts constructs views without deps): the SAME loader the daemon's router
|
|
7
|
+
// calls (daemon.ts:194), with the profile command's inspection-surface preview flag so the tab
|
|
8
|
+
// still renders under routing.learned:off — labeled inert in the header, as `tickmarkr profile`
|
|
9
|
+
// does. Best-effort: a load failure degrades to an explanatory note instead of breaking the studio.
|
|
10
|
+
function loadDefaultData(repoRoot) {
|
|
11
|
+
try {
|
|
12
|
+
const cfg = loadConfig(repoRoot);
|
|
13
|
+
return { cfg, profile: loadRoutingProfile(repoRoot, cfg, { preview: true }) };
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
// A configured pin that beat a higher-scored learned channel, or null. route() consults the map
|
|
20
|
+
// pin before any learned sorting, so when another channel's learned score for the shape tops the
|
|
21
|
+
// pin's, the pin is overriding learned evidence — the case this inspector exists to name.
|
|
22
|
+
function pinOverride(profile, cfg, shape, pin, scoreOpts) {
|
|
23
|
+
const pinChKey = channelKey({ adapter: pin.via, model: pin.model });
|
|
24
|
+
const pinChannel = cfg.tiers[pin.via]?.channel ?? "sub";
|
|
25
|
+
const pinScore = learnedScore(profile, shape, pinChKey, pinChannel, scoreOpts);
|
|
26
|
+
let top = null;
|
|
27
|
+
for (const { shape: s, chKey, channel } of cellsOf(profile)) {
|
|
28
|
+
if (s !== shape || (chKey === pinChKey && channel === pinChannel))
|
|
29
|
+
continue;
|
|
30
|
+
const score = learnedScore(profile, shape, chKey, channel, scoreOpts);
|
|
31
|
+
if (score > pinScore && (!top || score > top.score))
|
|
32
|
+
top = { chKey, channel, score };
|
|
33
|
+
}
|
|
34
|
+
if (!top)
|
|
35
|
+
return null;
|
|
36
|
+
return `${shape}: pin ${pinChKey} (score ${fmtSigned(pinScore)}) overrides higher-scored ${top.chKey} (${top.channel}, score ${fmtSigned(top.score)})`;
|
|
37
|
+
}
|
|
38
|
+
export function createProfileView(deps = {}) {
|
|
39
|
+
const data = deps.data ?? loadDefaultData(deps.repoRoot ?? process.cwd());
|
|
40
|
+
return {
|
|
41
|
+
id: "profile",
|
|
42
|
+
label: "Profile",
|
|
43
|
+
render: () => {
|
|
44
|
+
const lines = ["Profile view — learned-routing inspector (read-only)"];
|
|
45
|
+
if (!data) {
|
|
46
|
+
lines.push("profile data unavailable — see `tickmarkr profile` for the line-mode surface");
|
|
47
|
+
return lines;
|
|
48
|
+
}
|
|
49
|
+
const { cfg, profile } = data;
|
|
50
|
+
const halfLife = cfg.routing.learnedTuning?.halfLifeRuns ?? HALF_LIFE_RUNS;
|
|
51
|
+
const availWeight = cfg.routing.learnedTuning?.availWeight ?? AVAIL_WEIGHT;
|
|
52
|
+
// router.ts:29's exploreCap — the exploration fence in effect, not the bare module default
|
|
53
|
+
const cap = cfg.routing.explore?.cap ?? EXPLORE_CAP;
|
|
54
|
+
lines.push(`routing.learned: ${cfg.routing.learned}${cfg.routing.learned === "off" ? " (preview — routing is inert)" : ""}`);
|
|
55
|
+
lines.push(`half-life: ${halfLife} runs availability weight: ${availWeight} exploration cap: ${cap}`);
|
|
56
|
+
if (!profile || profile.cells.size === 0) {
|
|
57
|
+
// ROUTE-07's cold start, explained: an empty profile scores every channel exactly NEUTRAL,
|
|
58
|
+
// so routing is byte-identical static routing until real samples accumulate.
|
|
59
|
+
lines.push("cold start — no learned evidence yet:", ` every channel scores exactly neutral and routing follows the static policy`, ` until a shape×channel cell accumulates ${MIN_SAMPLES} samples.`);
|
|
60
|
+
return lines;
|
|
61
|
+
}
|
|
62
|
+
const scoreOpts = { availWeight: cfg.routing.learnedTuning?.availWeight };
|
|
63
|
+
lines.push(` ${"shape".padEnd(10)} ${"channel".padEnd(28)} ${"class".padEnd(5)} ${"score".padEnd(8)} samples decay explore`);
|
|
64
|
+
for (const { shape, chKey, channel, cell } of cellsOf(profile)) {
|
|
65
|
+
const s = cellSummary(cell);
|
|
66
|
+
const score = learnedScore(profile, shape, chKey, channel, scoreOpts);
|
|
67
|
+
// decayed ⇔ rank-decay folded older runs below full weight (n_eff < n_raw); fresh ⇔ all
|
|
68
|
+
// evidence at weight 1. The two states must never render alike.
|
|
69
|
+
const decay = s.nEff < s.nRaw ? "decayed" : "fresh";
|
|
70
|
+
const remaining = Math.max(0, cap - cell.dispatches);
|
|
71
|
+
const explore = remaining > 0 ? `left ${remaining}` : "spent";
|
|
72
|
+
const disc = s.discounted > 0 ? ` disc=${s.discounted}` : "";
|
|
73
|
+
const cold = s.cold ? ` cold (n<${MIN_SAMPLES})` : "";
|
|
74
|
+
lines.push(` ${shape.padEnd(10)} ${chKey.padEnd(28)} ${channel.padEnd(5)} ${fmtSigned(score).padEnd(8)} ${`n_eff=${s.nEff} n_raw=${s.nRaw}`.padEnd(18)} ${decay.padEnd(9)} ${explore}${disc}${cold}`);
|
|
75
|
+
}
|
|
76
|
+
const overrides = [];
|
|
77
|
+
for (const [shape, entry] of Object.entries(cfg.routing.map).sort(([a], [b]) => a.localeCompare(b))) {
|
|
78
|
+
if (!entry.pin)
|
|
79
|
+
continue;
|
|
80
|
+
const named = pinOverride(profile, cfg, shape, entry.pin, scoreOpts);
|
|
81
|
+
if (named)
|
|
82
|
+
overrides.push(named);
|
|
83
|
+
}
|
|
84
|
+
if (overrides.length) {
|
|
85
|
+
lines.push("pins overriding higher-scored learned channels:", ...overrides.map((l) => ` ${l}`));
|
|
86
|
+
}
|
|
87
|
+
else if (Object.values(cfg.routing.map).some((e) => e?.pin)) {
|
|
88
|
+
lines.push("pins: none overrides a higher-scored learned channel");
|
|
89
|
+
}
|
|
90
|
+
return lines;
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type BillingChannel } from "../../adapters/types.js";
|
|
2
|
+
import { type RoutingMode, type TickmarkrConfig } from "../../config/config.js";
|
|
3
|
+
import { type Shape, type Task } from "../../graph/schema.js";
|
|
4
|
+
import type { RoutingProfile } from "../../route/profile.js";
|
|
5
|
+
import type { View } from "../app.js";
|
|
6
|
+
export type RoutingViewData = {
|
|
7
|
+
cfg: TickmarkrConfig;
|
|
8
|
+
channels: BillingChannel[];
|
|
9
|
+
/** active routing mode for the header; absent ⇒ cfg.routing.mode ?? "risk-based" */
|
|
10
|
+
mode?: RoutingMode;
|
|
11
|
+
profile?: RoutingProfile;
|
|
12
|
+
};
|
|
13
|
+
export type RoutingViewDeps = {
|
|
14
|
+
/** injected fixture data — with data present the render path never touches the filesystem */
|
|
15
|
+
data?: RoutingViewData;
|
|
16
|
+
/** where the no-arg studio shell loads config from (default: process.cwd()) */
|
|
17
|
+
repoRoot?: string;
|
|
18
|
+
};
|
|
19
|
+
export type RoutingView = View & {
|
|
20
|
+
/** open the read-only candidate inspector for a shape (default: the selected grid row) */
|
|
21
|
+
openInspector(shape?: Shape): void;
|
|
22
|
+
closeInspector(): void;
|
|
23
|
+
moveCursor(delta: number): void;
|
|
24
|
+
readonly selectedShape: Shape;
|
|
25
|
+
/** the inspector's candidate rows — exactly the picker's candidateRow strings, in rank order */
|
|
26
|
+
inspectorRows(): string[] | null;
|
|
27
|
+
};
|
|
28
|
+
export declare function routingPreviewTask(shape: Shape): Task;
|
|
29
|
+
export declare function createRoutingView(deps?: RoutingViewDeps): RoutingView;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { channelsFromConfig } from "../../adapters/types.js";
|
|
2
|
+
import { candidateRow, shapeCandidates } from "../../cli/commands/fleet-picker.js";
|
|
3
|
+
import { INTEGRITY_FLOOR_SHAPES, loadConfigWithMode, } from "../../config/config.js";
|
|
4
|
+
import { SHAPES } from "../../graph/schema.js";
|
|
5
|
+
// Same preview task fleet.ts's picker ranks (one per shape, complexity 3): the inspector must
|
|
6
|
+
// rank the exact inputs the fleet editor ranks, or the two surfaces could disagree.
|
|
7
|
+
export function routingPreviewTask(shape) {
|
|
8
|
+
return {
|
|
9
|
+
id: "routing-preview",
|
|
10
|
+
title: "routing preview",
|
|
11
|
+
goal: "preview",
|
|
12
|
+
shape,
|
|
13
|
+
complexity: 3,
|
|
14
|
+
acceptance: ["done"],
|
|
15
|
+
deps: [],
|
|
16
|
+
files: [],
|
|
17
|
+
context: [],
|
|
18
|
+
gates: ["build", "test", "lint", "evidence", "scope", "acceptance", "review"],
|
|
19
|
+
humanGate: false,
|
|
20
|
+
status: "pending",
|
|
21
|
+
evidence: { commits: [], artifacts: [], gateResults: [] },
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
const isIntegrity = (shape) => INTEGRITY_FLOOR_SHAPES.includes(shape);
|
|
25
|
+
// No-arg shell path (app.ts constructs views without deps): load the operator's resolved config
|
|
26
|
+
// and derive channels from the configured tier table. Best-effort — a load failure degrades the
|
|
27
|
+
// view to an explanatory note instead of breaking the studio.
|
|
28
|
+
function loadDefaultData(repoRoot) {
|
|
29
|
+
try {
|
|
30
|
+
const { cfg, mode } = loadConfigWithMode(repoRoot);
|
|
31
|
+
const channels = Object.keys(cfg.tiers).flatMap((id) => channelsFromConfig(id, cfg));
|
|
32
|
+
return { cfg, channels, mode: mode.mode };
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export function createRoutingView(deps = {}) {
|
|
39
|
+
const data = deps.data ?? loadDefaultData(deps.repoRoot ?? process.cwd());
|
|
40
|
+
let cursor = 0;
|
|
41
|
+
let inspector = null;
|
|
42
|
+
return {
|
|
43
|
+
id: "routing",
|
|
44
|
+
label: "Routing",
|
|
45
|
+
get selectedShape() {
|
|
46
|
+
return SHAPES[cursor];
|
|
47
|
+
},
|
|
48
|
+
moveCursor(delta) {
|
|
49
|
+
cursor = (cursor + delta + SHAPES.length) % SHAPES.length;
|
|
50
|
+
},
|
|
51
|
+
openInspector(shape) {
|
|
52
|
+
if (!data)
|
|
53
|
+
return;
|
|
54
|
+
const target = shape ?? SHAPES[cursor];
|
|
55
|
+
// THE parity seam: rank through the same shared picker glue the fleet editor uses — no
|
|
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)) };
|
|
59
|
+
},
|
|
60
|
+
closeInspector() {
|
|
61
|
+
inspector = null;
|
|
62
|
+
},
|
|
63
|
+
inspectorRows() {
|
|
64
|
+
return inspector ? [...inspector.rows] : null;
|
|
65
|
+
},
|
|
66
|
+
render: () => {
|
|
67
|
+
const lines = ["Routing view — per-shape policy grid (read-only)"];
|
|
68
|
+
if (!data) {
|
|
69
|
+
lines.push("routing data unavailable — see `tickmarkr fleet --print` for the line-mode surface");
|
|
70
|
+
return lines;
|
|
71
|
+
}
|
|
72
|
+
const { cfg } = data;
|
|
73
|
+
const mode = data.mode ?? cfg.routing.mode ?? "risk-based";
|
|
74
|
+
lines.push(`mode: ${mode}`);
|
|
75
|
+
lines.push(` ${"shape".padEnd(10)}${"floor".padEnd(10)}${"pin".padEnd(20)}prefer`);
|
|
76
|
+
for (const [i, shape] of SHAPES.entries()) {
|
|
77
|
+
const entry = cfg.routing.map[shape];
|
|
78
|
+
const floor = cfg.routing.floors[shape] ?? "—";
|
|
79
|
+
const marker = isIntegrity(shape) ? "*" : "";
|
|
80
|
+
const pin = entry?.pin ? `${entry.pin.via}:${entry.pin.model}` : "—";
|
|
81
|
+
const prefer = entry?.prefer?.length ? entry.prefer.join(", ") : "—";
|
|
82
|
+
const sel = i === cursor ? "❯" : " ";
|
|
83
|
+
lines.push(`${sel} ${shape.padEnd(10)}${`${floor}${marker}`.padEnd(10)}${pin.padEnd(20)}${prefer}`);
|
|
84
|
+
}
|
|
85
|
+
lines.push("* integrity set: never below frontier");
|
|
86
|
+
lines.push(`pick: ${SHAPES[cursor]} — enter opens the candidate inspector (read-only)`);
|
|
87
|
+
if (inspector) {
|
|
88
|
+
lines.push(`─ candidates: ${inspector.shape} — the fleet picker ranking for the same inputs ──`);
|
|
89
|
+
if (inspector.rows.length === 0)
|
|
90
|
+
lines.push(" (no routable candidates)");
|
|
91
|
+
for (const [j, row] of inspector.rows.entries()) {
|
|
92
|
+
lines.push(`${j === 0 ? "❯ " : " "}${row}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return lines;
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|