tickmarkr 1.65.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.
@@ -0,0 +1,38 @@
1
+ import { type FleetEditable } from "../config/config.js";
2
+ export type SaveTarget = "repo" | "global";
3
+ /** Notice surfaced in the save modal whenever a run lock is live. */
4
+ export declare const RELOAD_GUARD_NOTICE = "reload guard: a live run is active; the running daemon will not see these changes until it reloads";
5
+ export type SaveProposal = {
6
+ target: SaveTarget;
7
+ targetPath: string;
8
+ before: string;
9
+ after: string;
10
+ diff: string;
11
+ liveRun: boolean;
12
+ loadError: string | null;
13
+ };
14
+ export type SaveResult = {
15
+ kind: "written";
16
+ path: string;
17
+ } | {
18
+ kind: "declined";
19
+ } | {
20
+ kind: "refused";
21
+ reason: string;
22
+ };
23
+ /** Build the diff-confirm payload for the current staging delta and chosen target. */
24
+ export declare function buildSaveProposal(opts: {
25
+ repoRoot: string;
26
+ loaded: FleetEditable;
27
+ staged: FleetEditable;
28
+ target: SaveTarget;
29
+ globalDir?: string;
30
+ }): SaveProposal;
31
+ /** Confirm and execute the write for the chosen target. */
32
+ export declare function confirmSave(opts: {
33
+ repoRoot: string;
34
+ loaded: FleetEditable;
35
+ staged: FleetEditable;
36
+ target: SaveTarget;
37
+ globalDir?: string;
38
+ }): SaveResult;
@@ -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
+ }
@@ -0,0 +1,91 @@
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
+ import type { FleetStaging } from "../staging.js";
6
+ /** One discovered adapter and its classified models (cfg.tiers via channels()). */
7
+ export type FleetRosterEntry = {
8
+ adapter: string;
9
+ models: {
10
+ model: string;
11
+ tier: Tier;
12
+ channel: "sub" | "api";
13
+ }[];
14
+ };
15
+ /** Provenance notes harvested from overlay `# comment`s (fleet-overlay.ts shapes). */
16
+ export type FleetProvenanceNotes = {
17
+ denyAdapters?: Record<string, string>;
18
+ denyModels?: Record<string, string>;
19
+ };
20
+ /** Everything the roster renders — loaded by the caller, never by the render path. */
21
+ export type FleetViewData = {
22
+ adapters: FleetRosterEntry[];
23
+ /** Doctor cache contents (doctor.json), keyed by adapter id. */
24
+ health: Record<string, AuthHealth>;
25
+ /** Age of the doctor cache in ms; null when no probe data exists. */
26
+ doctorAgeMs: number | null;
27
+ denyAdapters: string[];
28
+ /** routing.deny.models entries in channelKey form ("adapter:model"). */
29
+ denyModels: string[];
30
+ notes?: FleetProvenanceNotes;
31
+ /** Telemetry folded from recent run journals (readAllTelemetry); runId optional. */
32
+ telemetry: (TelemetryRow & {
33
+ runId?: string;
34
+ })[];
35
+ };
36
+ /** Per-channel health digest, folded from journal telemetry rows only. */
37
+ export type ChannelHealth = {
38
+ key: string;
39
+ tasks: number;
40
+ attempts: number;
41
+ done: number;
42
+ failed: number;
43
+ human: number;
44
+ overruns: number;
45
+ quotaFailovers: number;
46
+ /** Sum of OBSERVED gateFails; null when no row carried the field (unobserved, never 0). */
47
+ gateFails: number | null;
48
+ medianDurationMs: number | null;
49
+ lastOutcome: TelemetryRow["outcome"] | null;
50
+ };
51
+ /** Fold journal telemetry rows into a per-channel health digest keyed by channelKey
52
+ * ("adapter:model"). The digest derives from journal telemetry (TelemetryRow, the rows the
53
+ * daemon appends to each run's telemetry.jsonl) rather than any new state file: this module
54
+ * reads nothing and writes nothing — the caller folds rows out of the existing run journals
55
+ * (readAllTelemetry) and the fold is a pure function of them, same telemetry in, same digest
56
+ * out. */
57
+ export declare function foldChannelHealth(rows: (TelemetryRow & {
58
+ runId?: string;
59
+ })[]): Map<string, ChannelHealth>;
60
+ /** The studio View plus the roster's own key seam. The T2 shell binds only view-level keys, so
61
+ * expand/collapse and selection are driven through key() — the same names the engine decodes. */
62
+ export type FleetView = View & {
63
+ /** Handle a decoded key name ("up" | "down" | "left" | "right" | "space" | "return"). */
64
+ key(name: string): void;
65
+ /** Whether an adapter's model rows are currently shown. */
66
+ expanded(adapter: string): boolean;
67
+ /** channelKey of the cursor's channel, or null when the cursor sits on an adapter row. */
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;
75
+ };
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;