tickmarkr 1.66.0 → 1.67.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/commands/run.js +9 -0
- package/dist/cli/commands/status.js +32 -18
- package/dist/compile/collateral.d.ts +1 -0
- package/dist/compile/collateral.js +52 -3
- package/dist/gates/run-gates.js +13 -6
- package/dist/tui/app.d.ts +29 -0
- package/dist/tui/app.js +209 -17
- package/dist/tui/save.d.ts +38 -0
- package/dist/tui/save.js +96 -0
- package/dist/tui/staging.d.ts +29 -0
- package/dist/tui/staging.js +78 -0
- package/dist/tui/views/diff-modal.d.ts +9 -0
- package/dist/tui/views/diff-modal.js +19 -0
- package/dist/tui/views/fleet-view.d.ts +23 -1
- package/dist/tui/views/fleet-view.js +108 -14
- package/dist/tui/views/routing-view.d.ts +22 -3
- package/dist/tui/views/routing-view.js +152 -16
- package/package.json +1 -1
package/dist/tui/save.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdtempSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { fleetEditableFromConfig, fleetEditableEquals, fleetRepoOverlayFromDelta, globalConfigDir, loadConfigWithMode, readOverlayFile, repoOverlayPath, repoOverlayYaml, unifiedYamlDiff, } from "../config/config.js";
|
|
6
|
+
import { isRunLockLive } from "../run/lock.js";
|
|
7
|
+
/** Notice surfaced in the save modal whenever a run lock is live. */
|
|
8
|
+
export const RELOAD_GUARD_NOTICE = "reload guard: a live run is active; the running daemon will not see these changes until it reloads";
|
|
9
|
+
function overlayPath(repoRoot, target, globalDir) {
|
|
10
|
+
return target === "repo"
|
|
11
|
+
? repoOverlayPath(repoRoot)
|
|
12
|
+
: join(globalDir ?? globalConfigDir(), "config.yaml");
|
|
13
|
+
}
|
|
14
|
+
function currentOverlayText(path) {
|
|
15
|
+
return existsSync(path) ? readFileSync(path, "utf8") : "";
|
|
16
|
+
}
|
|
17
|
+
function computeAfterBytes(target, loaded, staged, repoRoot, globalDir) {
|
|
18
|
+
const path = overlayPath(repoRoot, target, globalDir);
|
|
19
|
+
const existing = readOverlayFile(path);
|
|
20
|
+
// Reuse the existing fleet serializer and diff renderer rather than inventing a parallel formatter.
|
|
21
|
+
const merged = fleetRepoOverlayFromDelta(loaded, staged, existing);
|
|
22
|
+
return repoOverlayYaml(merged);
|
|
23
|
+
}
|
|
24
|
+
function roundTripError(target, repoRoot, after, staged, globalDir) {
|
|
25
|
+
try {
|
|
26
|
+
let effective;
|
|
27
|
+
if (target === "repo") {
|
|
28
|
+
effective = loadConfigWithMode(repoRoot, { globalDir, repoOverlayText: after });
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
// Validate a candidate global overlay by loading with it in a scratch globalDir.
|
|
32
|
+
const scratch = mkdtempSync(join(tmpdir(), "tickmarkr-global-rt-"));
|
|
33
|
+
try {
|
|
34
|
+
writeFileSync(join(scratch, "config.yaml"), after);
|
|
35
|
+
effective = loadConfigWithMode(repoRoot, { globalDir: scratch });
|
|
36
|
+
}
|
|
37
|
+
finally {
|
|
38
|
+
try {
|
|
39
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
40
|
+
}
|
|
41
|
+
catch { /* best-effort cleanup */ }
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const reparsed = fleetEditableFromConfig(effective.cfg);
|
|
45
|
+
if (!fleetEditableEquals(reparsed, staged)) {
|
|
46
|
+
return "round-trip mismatch: the written overlay does not re-parse to the staged state";
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
catch (e) {
|
|
51
|
+
return e.message;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** Build the diff-confirm payload for the current staging delta and chosen target. */
|
|
55
|
+
export function buildSaveProposal(opts) {
|
|
56
|
+
const path = overlayPath(opts.repoRoot, opts.target, opts.globalDir);
|
|
57
|
+
const before = currentOverlayText(path);
|
|
58
|
+
const after = computeAfterBytes(opts.target, opts.loaded, opts.staged, opts.repoRoot, opts.globalDir);
|
|
59
|
+
const diff = unifiedYamlDiff(before, after, path);
|
|
60
|
+
const loadError = roundTripError(opts.target, opts.repoRoot, after, opts.staged, opts.globalDir);
|
|
61
|
+
return {
|
|
62
|
+
target: opts.target,
|
|
63
|
+
targetPath: path,
|
|
64
|
+
before,
|
|
65
|
+
after,
|
|
66
|
+
diff,
|
|
67
|
+
liveRun: isRunLockLive(opts.repoRoot),
|
|
68
|
+
loadError,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
function atomicWrite(path, bytes) {
|
|
72
|
+
const dir = dirname(path);
|
|
73
|
+
mkdirSync(dir, { recursive: true });
|
|
74
|
+
// The sibling temp keeps rename(2) on one filesystem: readers see old or new bytes, never a partial overlay.
|
|
75
|
+
const tmp = join(dir, `.tickmarkr-save-${randomUUID()}.tmp`);
|
|
76
|
+
try {
|
|
77
|
+
writeFileSync(tmp, bytes);
|
|
78
|
+
renameSync(tmp, path);
|
|
79
|
+
}
|
|
80
|
+
catch (e) {
|
|
81
|
+
rmSync(tmp, { force: true }); // a failed rename must not leave a partial candidate behind
|
|
82
|
+
throw e;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/** Confirm and execute the write for the chosen target. */
|
|
86
|
+
export function confirmSave(opts) {
|
|
87
|
+
const proposal = buildSaveProposal(opts);
|
|
88
|
+
if (proposal.loadError) {
|
|
89
|
+
return { kind: "refused", reason: proposal.loadError };
|
|
90
|
+
}
|
|
91
|
+
if (proposal.before === proposal.after) {
|
|
92
|
+
return { kind: "declined" };
|
|
93
|
+
}
|
|
94
|
+
atomicWrite(proposal.targetPath, proposal.after);
|
|
95
|
+
return { kind: "written", path: proposal.targetPath };
|
|
96
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type FleetEditable } from "../config/config.js";
|
|
2
|
+
/** One in-memory edit to the buffered fleet state. */
|
|
3
|
+
export type FleetEdit = (buffer: FleetEditable) => void;
|
|
4
|
+
/**
|
|
5
|
+
* Staged-changes buffer for the Fleet Studio write path.
|
|
6
|
+
*
|
|
7
|
+
* - The loaded state is captured at construction and never mutated.
|
|
8
|
+
* - Edits apply to an in-memory buffer only.
|
|
9
|
+
* - `revert()` discards the buffer and restores the loaded state.
|
|
10
|
+
* - The delta is computed through the existing `fleetEditableEquals` comparison.
|
|
11
|
+
* - No code path in this module writes to disk.
|
|
12
|
+
*/
|
|
13
|
+
export declare class FleetStaging {
|
|
14
|
+
private readonly loaded;
|
|
15
|
+
private buffer;
|
|
16
|
+
constructor(loaded: FleetEditable);
|
|
17
|
+
/** Read-only snapshot of the originally loaded state. */
|
|
18
|
+
get loadedState(): FleetEditable;
|
|
19
|
+
/** Current buffered (possibly edited) state. */
|
|
20
|
+
get current(): FleetEditable;
|
|
21
|
+
/** Apply an edit to the buffer. Never touches disk. */
|
|
22
|
+
apply(edit: FleetEdit): void;
|
|
23
|
+
/** Discard all buffered edits and restore the loaded state. */
|
|
24
|
+
revert(): void;
|
|
25
|
+
/** True when the buffer differs from the loaded state. */
|
|
26
|
+
get isDirty(): boolean;
|
|
27
|
+
/** Number of distinct staged changes vs the loaded state. */
|
|
28
|
+
get changeCount(): number;
|
|
29
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { fleetEditableEquals } from "../config/config.js";
|
|
2
|
+
/**
|
|
3
|
+
* Staged-changes buffer for the Fleet Studio write path.
|
|
4
|
+
*
|
|
5
|
+
* - The loaded state is captured at construction and never mutated.
|
|
6
|
+
* - Edits apply to an in-memory buffer only.
|
|
7
|
+
* - `revert()` discards the buffer and restores the loaded state.
|
|
8
|
+
* - The delta is computed through the existing `fleetEditableEquals` comparison.
|
|
9
|
+
* - No code path in this module writes to disk.
|
|
10
|
+
*/
|
|
11
|
+
export class FleetStaging {
|
|
12
|
+
loaded;
|
|
13
|
+
buffer;
|
|
14
|
+
constructor(loaded) {
|
|
15
|
+
this.loaded = structuredClone(loaded);
|
|
16
|
+
this.buffer = structuredClone(loaded);
|
|
17
|
+
}
|
|
18
|
+
/** Read-only snapshot of the originally loaded state. */
|
|
19
|
+
get loadedState() {
|
|
20
|
+
return structuredClone(this.loaded);
|
|
21
|
+
}
|
|
22
|
+
/** Current buffered (possibly edited) state. */
|
|
23
|
+
get current() {
|
|
24
|
+
return structuredClone(this.buffer);
|
|
25
|
+
}
|
|
26
|
+
/** Apply an edit to the buffer. Never touches disk. */
|
|
27
|
+
apply(edit) {
|
|
28
|
+
edit(this.buffer);
|
|
29
|
+
}
|
|
30
|
+
/** Discard all buffered edits and restore the loaded state. */
|
|
31
|
+
revert() {
|
|
32
|
+
this.buffer = structuredClone(this.loaded);
|
|
33
|
+
}
|
|
34
|
+
/** True when the buffer differs from the loaded state. */
|
|
35
|
+
get isDirty() {
|
|
36
|
+
return !fleetEditableEquals(this.loaded, this.buffer);
|
|
37
|
+
}
|
|
38
|
+
/** Number of distinct staged changes vs the loaded state. */
|
|
39
|
+
get changeCount() {
|
|
40
|
+
return fleetEditableDeltaCount(this.loaded, this.buffer);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** Count changed leaves using the same equality semantics as `fleetEditableEquals`. */
|
|
44
|
+
function fleetEditableDeltaCount(initial, edited) {
|
|
45
|
+
let count = 0;
|
|
46
|
+
if (JSON.stringify(initial.denyAdapters) !== JSON.stringify(edited.denyAdapters)) {
|
|
47
|
+
count += 1;
|
|
48
|
+
}
|
|
49
|
+
if (JSON.stringify(initial.denyModels) !== JSON.stringify(edited.denyModels)) {
|
|
50
|
+
count += 1;
|
|
51
|
+
}
|
|
52
|
+
const mapKeys = new Set([...Object.keys(initial.map), ...Object.keys(edited.map)]);
|
|
53
|
+
for (const shape of mapKeys) {
|
|
54
|
+
if (JSON.stringify(initial.map[shape]) !== JSON.stringify(edited.map[shape])) {
|
|
55
|
+
count += 1;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const floorKeys = new Set([...Object.keys(initial.floors), ...Object.keys(edited.floors)]);
|
|
59
|
+
for (const shape of floorKeys) {
|
|
60
|
+
if (initial.floors[shape] !== edited.floors[shape]) {
|
|
61
|
+
count += 1;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const adapterKeys = new Set([...Object.keys(initial.tiers), ...Object.keys(edited.tiers)]);
|
|
65
|
+
for (const adapter of adapterKeys) {
|
|
66
|
+
const modelKeys = new Set([
|
|
67
|
+
...Object.keys(initial.tiers[adapter] ?? {}),
|
|
68
|
+
...Object.keys(edited.tiers[adapter] ?? {}),
|
|
69
|
+
]);
|
|
70
|
+
for (const model of modelKeys) {
|
|
71
|
+
if (JSON.stringify(initial.tiers[adapter]?.[model]) !==
|
|
72
|
+
JSON.stringify(edited.tiers[adapter]?.[model])) {
|
|
73
|
+
count += 1;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return count;
|
|
78
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type SaveTarget } from "../save.js";
|
|
2
|
+
export type DiffModalProps = {
|
|
3
|
+
target: SaveTarget;
|
|
4
|
+
targetPath: string;
|
|
5
|
+
diff: string;
|
|
6
|
+
liveRun: boolean;
|
|
7
|
+
};
|
|
8
|
+
/** Render the save-confirmation modal: header names the target path, body shows the exact overlay diff. */
|
|
9
|
+
export declare function renderDiffModal(props: DiffModalProps): string[];
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { bold, dim, fail, legend } from "../../brand.js";
|
|
2
|
+
import { RELOAD_GUARD_NOTICE } from "../save.js";
|
|
3
|
+
/** Render the save-confirmation modal: header names the target path, body shows the exact overlay diff. */
|
|
4
|
+
export function renderDiffModal(props) {
|
|
5
|
+
const lines = [];
|
|
6
|
+
lines.push(bold(`Save overlay — ${props.targetPath} (${props.target === "repo" ? "repository" : "global"} target)`));
|
|
7
|
+
lines.push(legend("t toggle target · y confirm · n/esc cancel"));
|
|
8
|
+
if (props.liveRun) {
|
|
9
|
+
lines.push(fail(RELOAD_GUARD_NOTICE));
|
|
10
|
+
}
|
|
11
|
+
lines.push(dim("─ diff ─"));
|
|
12
|
+
if (props.diff) {
|
|
13
|
+
lines.push(...props.diff.trimEnd().split("\n"));
|
|
14
|
+
}
|
|
15
|
+
else {
|
|
16
|
+
lines.push(dim("(no overlay changes)"));
|
|
17
|
+
}
|
|
18
|
+
return lines;
|
|
19
|
+
}
|
|
@@ -2,6 +2,7 @@ import type { View } from "../app.js";
|
|
|
2
2
|
import type { AuthHealth } from "../../adapters/types.js";
|
|
3
3
|
import type { Tier } from "../../config/config.js";
|
|
4
4
|
import type { TelemetryRow } from "../../run/journal.js";
|
|
5
|
+
import type { FleetStaging } from "../staging.js";
|
|
5
6
|
/** One discovered adapter and its classified models (cfg.tiers via channels()). */
|
|
6
7
|
export type FleetRosterEntry = {
|
|
7
8
|
adapter: string;
|
|
@@ -65,5 +66,26 @@ export type FleetView = View & {
|
|
|
65
66
|
expanded(adapter: string): boolean;
|
|
66
67
|
/** channelKey of the cursor's channel, or null when the cursor sits on an adapter row. */
|
|
67
68
|
selectedChannel(): string | null;
|
|
69
|
+
/** Stage a deny⇄allow toggle for one adapter into the shared buffer. No-op without staging. */
|
|
70
|
+
toggleDenyAdapter(adapter: string): void;
|
|
71
|
+
/** Stage a deny⇄allow toggle for one channel ("adapter:model"). No-op without staging. */
|
|
72
|
+
toggleDenyChannel(channelKey: string): void;
|
|
73
|
+
/** Stage a tier override for one channel ("adapter:model"). No-op without staging. */
|
|
74
|
+
stageTierOverride(channelKey: string, tier: Tier): void;
|
|
68
75
|
};
|
|
69
|
-
|
|
76
|
+
/** A channel that is denied in the staged buffer while a routing.map pin still points at it —
|
|
77
|
+
* the fleet view stages the deny, the routing view stages the pin, and BOTH are read out of the
|
|
78
|
+
* one shared staging model, so either view surfaces the same conflict set. */
|
|
79
|
+
export type StagedConflict = {
|
|
80
|
+
/** channelKey ("adapter:model") that is both denied and pinned in the buffer. */
|
|
81
|
+
channelKey: string;
|
|
82
|
+
/** shapes whose pin the deny would dead-end (deny wins over pin in saved config). */
|
|
83
|
+
shapes: string[];
|
|
84
|
+
};
|
|
85
|
+
/** Detect deny-vs-pin conflicts from the shared staging model. The buffer's CURRENT state holds
|
|
86
|
+
* both views' staged state — the fleet view's deny/allow + tier edits and the routing view's
|
|
87
|
+
* pin/floor edits — so reading it sees the combination before anything is saved. A conflict is
|
|
88
|
+
* a pinned channel denied by its own entry OR by its adapter's deny (the adapter deny reaches
|
|
89
|
+
* every channel under it, same rule as denyOf). */
|
|
90
|
+
export declare function stagedConflicts(staging: FleetStaging): StagedConflict[];
|
|
91
|
+
export declare function createFleetView(data?: FleetViewData, staging?: FleetStaging): FleetView;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { GLYPHS, bold, dim, fail, legend } from "../../brand.js";
|
|
1
|
+
import { GLYPHS, bold, dim, fail, legend, warn } from "../../brand.js";
|
|
2
2
|
const keyOf = (r) => `${r.adapter}:${r.model}`;
|
|
3
3
|
const median = (xs) => {
|
|
4
4
|
const s = [...xs].sort((a, b) => a - b);
|
|
@@ -55,6 +55,73 @@ export function foldChannelHealth(rows) {
|
|
|
55
55
|
out.set(key, foldRows(key, list));
|
|
56
56
|
return out;
|
|
57
57
|
}
|
|
58
|
+
/** Detect deny-vs-pin conflicts from the shared staging model. The buffer's CURRENT state holds
|
|
59
|
+
* both views' staged state — the fleet view's deny/allow + tier edits and the routing view's
|
|
60
|
+
* pin/floor edits — so reading it sees the combination before anything is saved. A conflict is
|
|
61
|
+
* a pinned channel denied by its own entry OR by its adapter's deny (the adapter deny reaches
|
|
62
|
+
* every channel under it, same rule as denyOf). */
|
|
63
|
+
export function stagedConflicts(staging) {
|
|
64
|
+
const cur = staging.current;
|
|
65
|
+
const byChannel = new Map();
|
|
66
|
+
for (const [shape, entry] of Object.entries(cur.map)) {
|
|
67
|
+
const pin = entry?.pin;
|
|
68
|
+
if (!pin)
|
|
69
|
+
continue;
|
|
70
|
+
const key = `${pin.via}:${pin.model}`;
|
|
71
|
+
if (cur.denyModels.includes(key) || cur.denyAdapters.includes(pin.via)) {
|
|
72
|
+
byChannel.set(key, [...(byChannel.get(key) ?? []), shape]);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return [...byChannel.entries()].map(([channelKey, shapes]) => ({ channelKey, shapes }));
|
|
76
|
+
}
|
|
77
|
+
/** Rows whose staged state differs from the loaded (saved) state: adapter deny flips, channel
|
|
78
|
+
* deny flips, and tier overrides. Staged rows render marked so an unsaved edit never reads as
|
|
79
|
+
* saved state. Same JSON leaf-comparison semantics as the staging model's own delta count. */
|
|
80
|
+
function stagedRowSets(staging) {
|
|
81
|
+
const cur = staging.current;
|
|
82
|
+
const saved = staging.loadedState;
|
|
83
|
+
const flipped = (a, b) => new Set([...a.filter((x) => !b.includes(x)), ...b.filter((x) => !a.includes(x))]);
|
|
84
|
+
const channels = flipped(cur.denyModels, saved.denyModels);
|
|
85
|
+
const tierAdapters = new Set([...Object.keys(cur.tiers), ...Object.keys(saved.tiers)]);
|
|
86
|
+
for (const adapter of tierAdapters) {
|
|
87
|
+
const models = new Set([
|
|
88
|
+
...Object.keys(cur.tiers[adapter] ?? {}),
|
|
89
|
+
...Object.keys(saved.tiers[adapter] ?? {}),
|
|
90
|
+
]);
|
|
91
|
+
for (const model of models) {
|
|
92
|
+
if (JSON.stringify(cur.tiers[adapter]?.[model]) !== JSON.stringify(saved.tiers[adapter]?.[model])) {
|
|
93
|
+
channels.add(`${adapter}:${model}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return { adapters: flipped(cur.denyAdapters, saved.denyAdapters), channels };
|
|
98
|
+
}
|
|
99
|
+
/** The roster renders the buffer's CURRENT state when a staging model is attached: deny lists
|
|
100
|
+
* and tier badges come from the buffer, everything else (health, telemetry, notes, row shape)
|
|
101
|
+
* from the injected facts. Without staging the injected facts render as-is (saved state). */
|
|
102
|
+
function overlayStaged(data, staging) {
|
|
103
|
+
if (!staging)
|
|
104
|
+
return data;
|
|
105
|
+
const cur = staging.current;
|
|
106
|
+
return {
|
|
107
|
+
...data,
|
|
108
|
+
denyAdapters: cur.denyAdapters,
|
|
109
|
+
denyModels: cur.denyModels,
|
|
110
|
+
adapters: data.adapters.map((a) => ({
|
|
111
|
+
...a,
|
|
112
|
+
models: a.models.map((m) => ({ ...m, tier: cur.tiers[a.adapter]?.[m.model]?.tier ?? m.tier })),
|
|
113
|
+
})),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
/** Sorted in-place toggle so a stage-then-unstage leaves the list byte-identical to loaded. */
|
|
117
|
+
const toggleIn = (list, value) => {
|
|
118
|
+
const i = list.indexOf(value);
|
|
119
|
+
if (i >= 0)
|
|
120
|
+
list.splice(i, 1);
|
|
121
|
+
else
|
|
122
|
+
list.push(value);
|
|
123
|
+
list.sort();
|
|
124
|
+
};
|
|
58
125
|
// fleet.ts formatAge voice — the operator already reads "12m old" on the classic fleet screen.
|
|
59
126
|
const formatCacheAge = (ageMs) => {
|
|
60
127
|
if (ageMs === null)
|
|
@@ -114,7 +181,7 @@ function digestLine(d, noun) {
|
|
|
114
181
|
parts.push(`${d.quotaFailovers} quota failover${d.quotaFailovers === 1 ? "" : "s"}`);
|
|
115
182
|
return ` journals: ${parts.join(" · ")}`;
|
|
116
183
|
}
|
|
117
|
-
export function createFleetView(data) {
|
|
184
|
+
export function createFleetView(data, staging) {
|
|
118
185
|
// One expanded bit per adapter, default expanded — the roster shows everything at a glance.
|
|
119
186
|
const expandedMap = new Map((data?.adapters ?? []).map((a) => [a.adapter, true]));
|
|
120
187
|
let cursor = 0;
|
|
@@ -162,43 +229,70 @@ export function createFleetView(data) {
|
|
|
162
229
|
const row = visibleRows()[cursor];
|
|
163
230
|
return row?.kind === "model" ? `${row.adapter}:${row.model}` : null;
|
|
164
231
|
},
|
|
232
|
+
toggleDenyAdapter(adapter) {
|
|
233
|
+
staging?.apply((buffer) => toggleIn(buffer.denyAdapters, adapter));
|
|
234
|
+
},
|
|
235
|
+
toggleDenyChannel(channelKey) {
|
|
236
|
+
staging?.apply((buffer) => toggleIn(buffer.denyModels, channelKey));
|
|
237
|
+
},
|
|
238
|
+
stageTierOverride(channelKey, tier) {
|
|
239
|
+
const split = channelKey.indexOf(":");
|
|
240
|
+
if (split <= 0)
|
|
241
|
+
return;
|
|
242
|
+
const adapter = channelKey.slice(0, split);
|
|
243
|
+
const model = channelKey.slice(split + 1);
|
|
244
|
+
staging?.apply((buffer) => {
|
|
245
|
+
buffer.tiers[adapter] ??= {};
|
|
246
|
+
// spread keeps an existing provenance note riding the staged assignment
|
|
247
|
+
buffer.tiers[adapter][model] = { ...buffer.tiers[adapter][model], tier };
|
|
248
|
+
});
|
|
249
|
+
},
|
|
165
250
|
render(_props) {
|
|
166
251
|
if (!data) {
|
|
167
252
|
// T2 stub surface — the shell registers the view before any data source is wired.
|
|
168
253
|
return ["Fleet view", "Harness roster and health (read-only in v1.66)."];
|
|
169
254
|
}
|
|
170
|
-
const
|
|
255
|
+
const eff = overlayStaged(data, staging);
|
|
256
|
+
const staged = staging ? stagedRowSets(staging) : null;
|
|
257
|
+
const conflicts = new Map((staging ? stagedConflicts(staging) : []).map((c) => [c.channelKey, c.shapes]));
|
|
258
|
+
const digest = foldChannelHealth(eff.telemetry);
|
|
171
259
|
const lines = [bold("Fleet view — harness roster")];
|
|
172
|
-
lines.push(legend(`doctor cache: ${formatCacheAge(
|
|
173
|
-
`${
|
|
260
|
+
lines.push(legend(`doctor cache: ${formatCacheAge(eff.doctorAgeMs)} · ${eff.adapters.length} adapters · ` +
|
|
261
|
+
`${eff.telemetry.length} journal rows · ↑↓ select · → expand · ← collapse · space toggle`));
|
|
174
262
|
const rows = visibleRows();
|
|
175
263
|
rows.forEach((row, i) => {
|
|
176
264
|
const pointer = i === cursor ? `${GLYPHS.pointer} ` : " ";
|
|
177
|
-
const h =
|
|
265
|
+
const h = eff.health[row.adapter];
|
|
178
266
|
if (row.kind === "adapter") {
|
|
179
267
|
const marker = expandedMap.get(row.adapter) !== false ? "▾" : "▸";
|
|
180
268
|
const state = !h?.installed
|
|
181
269
|
? `${fail(GLYPHS.fail)} not installed`
|
|
182
270
|
: `${h.version ?? "installed"} · ${h.authed ? "authed" : "unauthed"}`;
|
|
183
|
-
const counts = h?.installed ? ` · ${plural(entryOf(
|
|
184
|
-
const deny =
|
|
185
|
-
? ` · ${fail("denied")}${note(
|
|
271
|
+
const counts = h?.installed ? ` · ${plural(entryOf(eff, row.adapter)?.models.length ?? 0, "model")}` : "";
|
|
272
|
+
const deny = eff.denyAdapters.includes(row.adapter)
|
|
273
|
+
? ` · ${fail("denied")}${note(eff.notes?.denyAdapters?.[row.adapter])}`
|
|
186
274
|
: "";
|
|
187
|
-
|
|
275
|
+
const stagedBit = staged?.adapters.has(row.adapter) ? ` · ${warn("● staged")}` : "";
|
|
276
|
+
lines.push(`${pointer}${marker} ${bold(row.adapter)} ${state}${counts}${deny}${stagedBit}`);
|
|
188
277
|
return;
|
|
189
278
|
}
|
|
190
|
-
const m =
|
|
279
|
+
const m = eff.adapters.find((a) => a.adapter === row.adapter)?.models.find((mm) => mm.model === row.model);
|
|
191
280
|
if (!m)
|
|
192
281
|
return;
|
|
193
282
|
const key = `${row.adapter}:${row.model}`;
|
|
194
283
|
const auth = modelAuthText(h, row.model);
|
|
195
|
-
const dn = denyOf(
|
|
284
|
+
const dn = denyOf(eff, row.adapter, row.model);
|
|
196
285
|
const deny = dn.denied ? ` ${fail(GLYPHS.fail)} denied${note(dn.note)}` : "";
|
|
197
286
|
const d = digest.get(key);
|
|
198
287
|
const healthBit = d && d.tasks > 0 ? ` ${dim(`${d.done}/${d.tasks} done`)}` : "";
|
|
199
|
-
|
|
288
|
+
const stagedBit = staged?.channels.has(key) ? ` ${warn("● staged")}` : "";
|
|
289
|
+
const pinnedFor = conflicts.get(key);
|
|
290
|
+
const conflictBit = pinnedFor
|
|
291
|
+
? ` ${warn(`${GLYPHS.attention} conflict: pinned for ${pinnedFor.join(", ")}`)}`
|
|
292
|
+
: "";
|
|
293
|
+
lines.push(`${pointer} ${row.model.padEnd(28)} [${m.tier}] ${m.channel} ${auth}${deny}${healthBit}${stagedBit}${conflictBit}`);
|
|
200
294
|
});
|
|
201
|
-
lines.push(...detailPanel(
|
|
295
|
+
lines.push(...detailPanel(eff, digest, rows[cursor]));
|
|
202
296
|
return lines;
|
|
203
297
|
},
|
|
204
298
|
};
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { type BillingChannel } from "../../adapters/types.js";
|
|
2
|
-
import { type RoutingMode, type TickmarkrConfig } from "../../config/config.js";
|
|
2
|
+
import { type RoutingMode, type TickmarkrConfig, type Tier } from "../../config/config.js";
|
|
3
3
|
import { type Shape, type Task } from "../../graph/schema.js";
|
|
4
4
|
import type { RoutingProfile } from "../../route/profile.js";
|
|
5
|
+
import { FleetStaging } from "../staging.js";
|
|
5
6
|
import type { View } from "../app.js";
|
|
6
7
|
export type RoutingViewData = {
|
|
7
8
|
cfg: TickmarkrConfig;
|
|
@@ -15,14 +16,32 @@ export type RoutingViewDeps = {
|
|
|
15
16
|
data?: RoutingViewData;
|
|
16
17
|
/** where the no-arg studio shell loads config from (default: process.cwd()) */
|
|
17
18
|
repoRoot?: string;
|
|
19
|
+
/** shared staged-changes buffer; default derives one from data.cfg — edits never leave it */
|
|
20
|
+
staging?: FleetStaging;
|
|
18
21
|
};
|
|
19
22
|
export type RoutingView = View & {
|
|
20
|
-
/** open the
|
|
23
|
+
/** open the candidate inspector for a shape (default: the selected grid row) */
|
|
21
24
|
openInspector(shape?: Shape): void;
|
|
22
25
|
closeInspector(): void;
|
|
23
26
|
moveCursor(delta: number): void;
|
|
27
|
+
/** move the inspector's candidate highlight (wraps) */
|
|
28
|
+
moveInspectorCursor(delta: number): void;
|
|
29
|
+
/** stage the highlighted inspector candidate as that shape's map pin */
|
|
30
|
+
stageInspectorPin(): void;
|
|
31
|
+
/** stage a floor tier for the selected grid shape */
|
|
32
|
+
stageFloor(tier: Tier): void;
|
|
33
|
+
/** stage a prefer chain for the selected grid shape ([] clears the chain) */
|
|
34
|
+
stagePrefer(chain: string[]): void;
|
|
35
|
+
/** stage a routing mode — compiles into the preset's floor table in the buffer */
|
|
36
|
+
stageMode(mode: RoutingMode): void;
|
|
37
|
+
/** discard every staged edit (delegates to the staging model) */
|
|
38
|
+
revert(): void;
|
|
24
39
|
readonly selectedShape: Shape;
|
|
25
|
-
/**
|
|
40
|
+
/** mode derived from the buffer: loaded mode, a staged preset, or "custom" — never stored */
|
|
41
|
+
readonly selectedMode: RoutingMode | "custom";
|
|
42
|
+
/** the staging model every change flows through */
|
|
43
|
+
readonly staging: FleetStaging;
|
|
44
|
+
/** the inspector's candidate rows — the picker's candidateRow strings, in rank order */
|
|
26
45
|
inspectorRows(): string[] | null;
|
|
27
46
|
};
|
|
28
47
|
export declare function routingPreviewTask(shape: Shape): Task;
|