@promptctl/cc-candybar 1.42.1 → 1.43.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/index.mjs +72 -71
- package/package.json +5 -6
- package/src/check.ts +0 -478
- package/src/cli-flags.ts +0 -8
- package/src/click/wire.ts +0 -158
- package/src/config/action.ts +0 -329
- package/src/config/cli.ts +0 -71
- package/src/config/default-dsl-config.ts +0 -1645
- package/src/config/disclosure.ts +0 -170
- package/src/config/dsl-loader.ts +0 -339
- package/src/config/dsl-types.ts +0 -581
- package/src/config/edit-chrome.ts +0 -559
- package/src/config/help.ts +0 -151
- package/src/config/ident.ts +0 -22
- package/src/config/layout-ops.ts +0 -177
- package/src/config/loader/actions.ts +0 -972
- package/src/config/loader/cache.ts +0 -206
- package/src/config/loader/cross-ref.ts +0 -714
- package/src/config/loader/cycles.ts +0 -148
- package/src/config/loader/diagnostics.ts +0 -99
- package/src/config/loader/discovery.ts +0 -182
- package/src/config/loader/edit-mode.ts +0 -137
- package/src/config/loader/emit-schema.ts +0 -68
- package/src/config/loader/globals.ts +0 -269
- package/src/config/loader/helpers.ts +0 -48
- package/src/config/loader/layout.ts +0 -693
- package/src/config/loader/looks.ts +0 -96
- package/src/config/loader/menu-synth.ts +0 -435
- package/src/config/loader/merge.ts +0 -115
- package/src/config/loader/persist-target.ts +0 -67
- package/src/config/loader/presets.ts +0 -119
- package/src/config/loader/refs.ts +0 -100
- package/src/config/loader/reserved-namespace.ts +0 -38
- package/src/config/loader/segments.ts +0 -120
- package/src/config/loader/validate-core.ts +0 -737
- package/src/config/loader/variables.ts +0 -260
- package/src/config/menu-keys.ts +0 -139
- package/src/config/option-domain.ts +0 -164
- package/src/config/presets.ts +0 -326
- package/src/config/settings-menu.ts +0 -775
- package/src/daemon/acquire.ts +0 -684
- package/src/daemon/cache/git.ts +0 -649
- package/src/daemon/cache/render.ts +0 -623
- package/src/daemon/cache/session-usage-store.ts +0 -720
- package/src/daemon/cache/watchers.ts +0 -249
- package/src/daemon/client-debug.ts +0 -120
- package/src/daemon/client-stats.ts +0 -130
- package/src/daemon/client-transport.ts +0 -273
- package/src/daemon/client.ts +0 -78
- package/src/daemon/config-overrides-store.ts +0 -663
- package/src/daemon/debug-types.ts +0 -91
- package/src/daemon/debug.ts +0 -264
- package/src/daemon/fork-bomb-breaker.ts +0 -351
- package/src/daemon/limits.ts +0 -211
- package/src/daemon/log.ts +0 -81
- package/src/daemon/parent-watchdog.ts +0 -87
- package/src/daemon/paths.ts +0 -211
- package/src/daemon/process-fingerprint.ts +0 -146
- package/src/daemon/protocol.ts +0 -292
- package/src/daemon/render-payload.ts +0 -1256
- package/src/daemon/server.ts +0 -1330
- package/src/daemon/session-state-file.ts +0 -108
- package/src/daemon/session-state.ts +0 -237
- package/src/daemon/socket-lease.ts +0 -209
- package/src/daemon/socket-ownership.ts +0 -209
- package/src/daemon/stats.ts +0 -235
- package/src/daemon/verbs/config-validators.ts +0 -250
- package/src/daemon/verbs/index.ts +0 -706
- package/src/daemon/verbs/state-validators.ts +0 -249
- package/src/daemon/verbs/validator-registry.ts +0 -457
- package/src/demo/dsl.ts +0 -143
- package/src/demo/mock-data.ts +0 -67
- package/src/demo/statusline.json5 +0 -94
- package/src/dsl/node-registry.ts +0 -374
- package/src/dsl/render.ts +0 -803
- package/src/help-text.ts +0 -90
- package/src/index.ts +0 -210
- package/src/install/currency.ts +0 -197
- package/src/install/index.ts +0 -557
- package/src/proc/launch.ts +0 -459
- package/src/proc/stats-handle.ts +0 -13
- package/src/render/action.ts +0 -883
- package/src/render/active-segment.ts +0 -78
- package/src/render/diagnostic-style.ts +0 -23
- package/src/render/diagnostic-text.ts +0 -77
- package/src/render/error-glyph.ts +0 -53
- package/src/render/menu.ts +0 -257
- package/src/render/outcome-plan.ts +0 -45
- package/src/render/picker.ts +0 -372
- package/src/render/segment-color.ts +0 -74
- package/src/render/split-lines.ts +0 -51
- package/src/render/strip.ts +0 -228
- package/src/segments/cache.ts +0 -131
- package/src/segments/context.ts +0 -190
- package/src/segments/git.ts +0 -1084
- package/src/segments/metrics.ts +0 -187
- package/src/segments/pricing.ts +0 -452
- package/src/segments/session.ts +0 -23
- package/src/segments/tmux.ts +0 -74
- package/src/template-engine/cells.ts +0 -90
- package/src/template-engine/colors.ts +0 -124
- package/src/template-engine/engine.ts +0 -108
- package/src/template-engine/funcs.ts +0 -232
- package/src/template-engine/index.ts +0 -11
- package/src/template-engine/layout.ts +0 -133
- package/src/template-engine/scope.ts +0 -62
- package/src/template-engine/sparkline.ts +0 -79
- package/src/themes/index.ts +0 -20
- package/src/themes/palette-resolvers.ts +0 -84
- package/src/themes/policy.ts +0 -393
- package/src/utils/cache.ts +0 -206
- package/src/utils/claude.ts +0 -683
- package/src/utils/color-support.ts +0 -118
- package/src/utils/formatters.ts +0 -99
- package/src/utils/logger.ts +0 -5
- package/src/utils/outcome.ts +0 -33
- package/src/utils/schema-validator.ts +0 -126
- package/src/utils/single-flight.ts +0 -57
- package/src/utils/terminal-width.ts +0 -51
- package/src/utils/terminal.ts +0 -11
- package/src/utils/transcript-fs.ts +0 -279
- package/src/var-system/index.ts +0 -24
- package/src/var-system/sources.ts +0 -1047
- package/src/var-system/store.ts +0 -223
- package/src/var-system/types.ts +0 -57
- package/src/version.ts +0 -17
|
@@ -1,209 +0,0 @@
|
|
|
1
|
-
import fs from "node:fs";
|
|
2
|
-
import { type DaemonLogger } from "./log";
|
|
3
|
-
import { type LeaseRead } from "./socket-lease";
|
|
4
|
-
|
|
5
|
-
// ─── Socket ownership self-check ─────────────────────────────────────────────
|
|
6
|
-
//
|
|
7
|
-
// [LAW:single-enforcer] "Serving implies owning the socket path" is a temporal
|
|
8
|
-
// invariant every daemon component assumes and none enforced. The socket-theft
|
|
9
|
-
// storm (brandon-daemon-lifecycle-2b3) proved it false: a daemon whose socket
|
|
10
|
-
// was unlinked + rebound by a reclaimer kept running forever (no idle shutdown,
|
|
11
|
-
// no watchdog in production, RSS backstop only), holding ~400MB each — 28
|
|
12
|
-
// daemons bound in one hour with ZERO voluntary exits. This module is that
|
|
13
|
-
// invariant's single enforcer: a displaced daemon exits within one check
|
|
14
|
-
// interval, making ANY displacement self-healing regardless of which bug caused
|
|
15
|
-
// it.
|
|
16
|
-
//
|
|
17
|
-
// [FRAMING:representation] Ownership of a filesystem entry IS its kernel
|
|
18
|
-
// identity: (st_dev, st_ino). We never unlink + rebind our OWN socket during our
|
|
19
|
-
// lifetime, so that identity is stable for as long as we hold the path. A
|
|
20
|
-
// reclaimer that displaces us unlinks the path and binds a fresh socket → a NEW
|
|
21
|
-
// inode (or, briefly, no file at all). So "the identity now equals the identity
|
|
22
|
-
// I bound" is the exact, load-independent test for continued ownership — unlike
|
|
23
|
-
// a connect probe, which the sibling .1 removed precisely because it lied under
|
|
24
|
-
// load.
|
|
25
|
-
//
|
|
26
|
-
// [FRAMING:representation] The inode alone leaves ONE hole (RESIDUAL 2, the
|
|
27
|
-
// capture race): if a thief completes unlink + rebind inside the sub-millisecond
|
|
28
|
-
// window between our bind() and our 'listening' callback, we stat the path and
|
|
29
|
-
// capture the THIEF's inode as "ours" — so the inode check reads `owned` forever
|
|
30
|
-
// against the wrong identity. We cannot make that captured inode trustworthy (an
|
|
31
|
-
// AF_UNIX listener's fstat reports a different namespace's inode than stat(path),
|
|
32
|
-
// so there is no race-free "the inode I truly bound"). But a real thief, being a
|
|
33
|
-
// daemon, writes its OWN pid into the lease. So ownership requires a SECOND
|
|
34
|
-
// condition: the lease must still name us. A displacer must touch one of the two
|
|
35
|
-
// representations — steal the socket (inode changes) or overwrite the lease (pid
|
|
36
|
-
// changes) — so requiring BOTH drains any orphan within a bounded number of
|
|
37
|
-
// intervals. No live process can share our live pid, so the lease pid alone
|
|
38
|
-
// distinguishes "the lease still names me" (the start-time fingerprint is only
|
|
39
|
-
// needed by the sibling arbitration, which reasons about a possibly-DEAD pid).
|
|
40
|
-
|
|
41
|
-
// The kernel identity of the bound socket file. (dev, ino) is the unique
|
|
42
|
-
// identity of a filesystem entry; ino alone can be reused across devices.
|
|
43
|
-
export interface SocketIdentity {
|
|
44
|
-
dev: number;
|
|
45
|
-
ino: number;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
// What re-reading the socket path's identity yielded. Distinguishing
|
|
49
|
-
// present / absent / unreadable keeps the ownership decision a full enumeration
|
|
50
|
-
// over raw inputs (see checkOwnership) rather than collapsing the ambiguous
|
|
51
|
-
// cases early — the same shape as socket-lease.ts's LeaseRead.
|
|
52
|
-
export type IdentityRead =
|
|
53
|
-
| { kind: "present"; identity: SocketIdentity }
|
|
54
|
-
| { kind: "absent" }
|
|
55
|
-
| { kind: "unreadable"; detail: string };
|
|
56
|
-
|
|
57
|
-
// The self-check outcome. `owned` is the ONLY proof-of-ownership state; every
|
|
58
|
-
// other input maps to `displaced` (fail toward exit).
|
|
59
|
-
export type OwnershipCheck =
|
|
60
|
-
| { kind: "owned" }
|
|
61
|
-
| { kind: "displaced"; reason: string };
|
|
62
|
-
|
|
63
|
-
export const DEFAULT_OWNERSHIP_CHECK_INTERVAL_MS = 5000;
|
|
64
|
-
|
|
65
|
-
// [LAW:no-silent-failure] The sole effect (statSync) is lifted to a typed
|
|
66
|
-
// boundary read: ENOENT → `absent` (the path was unlinked, not yet rebound),
|
|
67
|
-
// any other error → `unreadable` with the reason inline, never a swallowed
|
|
68
|
-
// default. statSync (not lstatSync) follows a symlink deliberately — it reads
|
|
69
|
-
// the identity of whatever a CLIENT connecting to the path would actually reach,
|
|
70
|
-
// which is exactly what "do I still own what clients hit" asks.
|
|
71
|
-
export function readSocketIdentity(sockPath: string): IdentityRead {
|
|
72
|
-
try {
|
|
73
|
-
const st = fs.statSync(sockPath);
|
|
74
|
-
return { kind: "present", identity: { dev: st.dev, ino: st.ino } };
|
|
75
|
-
} catch (e) {
|
|
76
|
-
const code = (e as NodeJS.ErrnoException).code;
|
|
77
|
-
if (code === "ENOENT") return { kind: "absent" };
|
|
78
|
-
return { kind: "unreadable", detail: (e as Error).message };
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// [LAW:effects-at-boundaries][LAW:dataflow-not-control-flow] A pure fold:
|
|
83
|
-
// (identity captured at bind, current identity read, my pid, current lease read)
|
|
84
|
-
// → decision. No effects, so every branch is exercised by input enumeration.
|
|
85
|
-
// Ownership is the CONJUNCTION of two conditions; either failing → displaced:
|
|
86
|
-
// inode: present + same identity as bound (we still hold the path we bound)
|
|
87
|
-
// lease: owned + pid == mine (the lease still names us)
|
|
88
|
-
// Inode failures: different inode / absent / unreadable → a reclaimer displaced
|
|
89
|
-
// us (or we cannot prove otherwise). Lease failures: a thief overwrote the
|
|
90
|
-
// lease with its own pid, or the lease vanished/broke — either way we no
|
|
91
|
-
// longer hold the authority.
|
|
92
|
-
//
|
|
93
|
-
// [FRAMING:representation] Only "still holding my bound socket AND still named by
|
|
94
|
-
// the lease" proves ownership; anything short is `displaced`. This is the
|
|
95
|
-
// strongest-true theorem for "serving implies owning", checked against BOTH
|
|
96
|
-
// mutable representations a displacer can touch. The failure direction is
|
|
97
|
-
// deliberately safe — a false `displaced` (exit while actually owning) just lets
|
|
98
|
-
// the real owner serve and the next client respawns; a false `owned` is the
|
|
99
|
-
// immortal orphan we are killing. Always err toward exit.
|
|
100
|
-
export function checkOwnership(
|
|
101
|
-
bound: SocketIdentity,
|
|
102
|
-
now: IdentityRead,
|
|
103
|
-
myPid: number,
|
|
104
|
-
lease: LeaseRead,
|
|
105
|
-
): OwnershipCheck {
|
|
106
|
-
const inodeFailure = inodeDisplacement(bound, now);
|
|
107
|
-
if (inodeFailure !== null) {
|
|
108
|
-
return { kind: "displaced", reason: inodeFailure };
|
|
109
|
-
}
|
|
110
|
-
if (lease.kind !== "owned") {
|
|
111
|
-
return {
|
|
112
|
-
kind: "displaced",
|
|
113
|
-
reason: `lease not held (${lease.kind}) — no longer the socket owner`,
|
|
114
|
-
};
|
|
115
|
-
}
|
|
116
|
-
if (lease.pid !== myPid) {
|
|
117
|
-
return {
|
|
118
|
-
kind: "displaced",
|
|
119
|
-
reason: `lease reassigned (names pid=${lease.pid}, we are pid=${myPid}) — a reclaimer overwrote it`,
|
|
120
|
-
};
|
|
121
|
-
}
|
|
122
|
-
return { kind: "owned" };
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// Returns the displacement reason if the current path identity no longer matches
|
|
126
|
-
// what we bound, or null if the inode still proves ownership.
|
|
127
|
-
function inodeDisplacement(
|
|
128
|
-
bound: SocketIdentity,
|
|
129
|
-
now: IdentityRead,
|
|
130
|
-
): string | null {
|
|
131
|
-
if (now.kind === "present") {
|
|
132
|
-
if (now.identity.dev === bound.dev && now.identity.ino === bound.ino) {
|
|
133
|
-
return null;
|
|
134
|
-
}
|
|
135
|
-
return `socket replaced (bound dev=${bound.dev} ino=${bound.ino}, now dev=${now.identity.dev} ino=${now.identity.ino})`;
|
|
136
|
-
}
|
|
137
|
-
if (now.kind === "absent") {
|
|
138
|
-
return "socket path gone (ENOENT) — unlinked by a reclaimer";
|
|
139
|
-
}
|
|
140
|
-
return `socket path unreadable (${now.detail}) — cannot prove ownership`;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
// [LAW:locality-or-seam] The identity read, the shutdown funnel, and the log
|
|
144
|
-
// sink are injected, not reached for ambiently — so a unit test drives every
|
|
145
|
-
// branch (swap the inode, delete the path) against a fake shutdown with no real
|
|
146
|
-
// daemon, mirroring makeLimits. The real wiring injects
|
|
147
|
-
// `() => readSocketIdentity(sockPath)` and the daemon's single shutdown().
|
|
148
|
-
export interface OwnershipWatchDeps {
|
|
149
|
-
bound: SocketIdentity;
|
|
150
|
-
myPid: number;
|
|
151
|
-
readIdentity: () => IdentityRead;
|
|
152
|
-
readLease: () => LeaseRead;
|
|
153
|
-
shutdown: (code: number) => void;
|
|
154
|
-
log: DaemonLogger;
|
|
155
|
-
intervalMs?: number;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
export interface OwnershipWatchHandle {
|
|
159
|
-
// Run one check now; funnels through shutdown(0) on the first `displaced`.
|
|
160
|
-
// Returns the decision so callers/tests can assert without timers.
|
|
161
|
-
check(): OwnershipCheck;
|
|
162
|
-
arm(intervalMs?: number): { disarm(): void };
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
export function makeOwnershipWatch(
|
|
166
|
-
deps: OwnershipWatchDeps,
|
|
167
|
-
): OwnershipWatchHandle {
|
|
168
|
-
// [LAW:single-enforcer] Latch like limits' `triggered`: displacement funnels
|
|
169
|
-
// shutdown exactly once (one log line, one shutdown call), even though
|
|
170
|
-
// shutdown() is itself idempotent. Serving implies owning through ONE exit.
|
|
171
|
-
let displaced = false;
|
|
172
|
-
|
|
173
|
-
function check(): OwnershipCheck {
|
|
174
|
-
const decision = checkOwnership(
|
|
175
|
-
deps.bound,
|
|
176
|
-
deps.readIdentity(),
|
|
177
|
-
deps.myPid,
|
|
178
|
-
deps.readLease(),
|
|
179
|
-
);
|
|
180
|
-
if (decision.kind === "displaced" && !displaced) {
|
|
181
|
-
displaced = true;
|
|
182
|
-
deps.log(
|
|
183
|
-
"warn",
|
|
184
|
-
`ownership self-check: ${decision.reason}; shutting down`,
|
|
185
|
-
);
|
|
186
|
-
deps.shutdown(0);
|
|
187
|
-
}
|
|
188
|
-
return decision;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
function arm(
|
|
192
|
-
intervalMs: number = deps.intervalMs ?? DEFAULT_OWNERSHIP_CHECK_INTERVAL_MS,
|
|
193
|
-
): { disarm(): void } {
|
|
194
|
-
const timer = setInterval(() => {
|
|
195
|
-
// [LAW:no-ambient-temporal-coupling] Self-disarm once displaced: the watch
|
|
196
|
-
// has funneled its single shutdown, so stop polling — no zombie statSync
|
|
197
|
-
// fires during the shutdown window (a hung shutdown reaches its SIGKILL
|
|
198
|
-
// backstop at 500ms). The timer's whole lifecycle lives here in arm(),
|
|
199
|
-
// mirroring armBinaryWatch which clears its interval before shutting down.
|
|
200
|
-
if (check().kind === "displaced") clearInterval(timer);
|
|
201
|
-
}, intervalMs);
|
|
202
|
-
// unref so the check never keeps the process alive on its own — it only ever
|
|
203
|
-
// hastens an exit, never delays one.
|
|
204
|
-
timer.unref();
|
|
205
|
-
return { disarm: () => clearInterval(timer) };
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
return { check, arm };
|
|
209
|
-
}
|
package/src/daemon/stats.ts
DELETED
|
@@ -1,235 +0,0 @@
|
|
|
1
|
-
// [LAW:single-enforcer] One mutator owns runtime counters. Server, caches, and
|
|
2
|
-
// watchers each receive a tiny handle they're allowed to bump, but the
|
|
3
|
-
// canonical object lives here. Stats are read-only after serialization.
|
|
4
|
-
|
|
5
|
-
import type { LaunchCategory } from "../proc/launch";
|
|
6
|
-
import type { LaunchStatsHandle } from "../proc/stats-handle";
|
|
7
|
-
import { PROTOCOL_VERSION } from "./protocol";
|
|
8
|
-
import { PACKAGE_VERSION } from "../version";
|
|
9
|
-
|
|
10
|
-
// Rolling window for "last minute" counts. Keep timestamps for each launch in
|
|
11
|
-
// a ring buffer; eviction happens lazily on read.
|
|
12
|
-
//
|
|
13
|
-
// [LAW:dataflow-not-control-flow] The cap is a hard bound on how many
|
|
14
|
-
// launches can be counted in any 60-second window. Bursts above
|
|
15
|
-
// ROLLING_BUFFER_CAP / (ROLLING_WINDOW_MS/1000) launches/sec overwrite
|
|
16
|
-
// timestamps that are still inside the window, causing `lastMinute` to
|
|
17
|
-
// undercount. At 16384 entries / 60s = ~273 sustained launches/sec the
|
|
18
|
-
// undercount only kicks in under pathological load (well past anything kz8
|
|
19
|
-
// is trying to detect). Bump this cap rather than the comment if a future
|
|
20
|
-
// workload ever sustains higher rates.
|
|
21
|
-
const ROLLING_WINDOW_MS = 60_000;
|
|
22
|
-
const ROLLING_BUFFER_CAP = 16384;
|
|
23
|
-
|
|
24
|
-
// Reservoir-sample histogram (16 entries) per category — sufficient for
|
|
25
|
-
// rough p50/p99 without unbounded memory. Replacement uses a simple
|
|
26
|
-
// counter-mod-N strategy: deterministic and adequate for human-eyeball
|
|
27
|
-
// dashboards (no statistical claim of unbiased sampling).
|
|
28
|
-
const HISTOGRAM_CAP = 16;
|
|
29
|
-
|
|
30
|
-
export interface StatsSnapshot {
|
|
31
|
-
pid: number;
|
|
32
|
-
// [LAW:one-source-of-truth] The daemon's own baked package stamp — set
|
|
33
|
-
// against `cc-candybar --version` to diagnose client-vs-daemon skew in one
|
|
34
|
-
// step. The wire contract number is a different fact under its own name.
|
|
35
|
-
version: string;
|
|
36
|
-
protocolVersion: number;
|
|
37
|
-
startedAt: string;
|
|
38
|
-
uptimeSec: number;
|
|
39
|
-
rssBytes: number;
|
|
40
|
-
heapUsedBytes: number;
|
|
41
|
-
heapTotalBytes: number;
|
|
42
|
-
externalBytes: number;
|
|
43
|
-
arrayBuffersBytes: number;
|
|
44
|
-
requests: {
|
|
45
|
-
total: number;
|
|
46
|
-
errored: number;
|
|
47
|
-
timedOut: number;
|
|
48
|
-
inFlight: number;
|
|
49
|
-
};
|
|
50
|
-
gitCache: {
|
|
51
|
-
size: number;
|
|
52
|
-
hits: number;
|
|
53
|
-
misses: number;
|
|
54
|
-
invalidations: number;
|
|
55
|
-
watchers: number;
|
|
56
|
-
};
|
|
57
|
-
usageCache: {
|
|
58
|
-
size: number;
|
|
59
|
-
hits: number;
|
|
60
|
-
misses: number;
|
|
61
|
-
sweeps: number;
|
|
62
|
-
};
|
|
63
|
-
renderCache: {
|
|
64
|
-
size: number;
|
|
65
|
-
};
|
|
66
|
-
watchers: {
|
|
67
|
-
active: number;
|
|
68
|
-
opened: number;
|
|
69
|
-
closed: number;
|
|
70
|
-
evicted: number;
|
|
71
|
-
};
|
|
72
|
-
subprocesses: {
|
|
73
|
-
total: number;
|
|
74
|
-
inFlight: number;
|
|
75
|
-
lastMinute: number;
|
|
76
|
-
byCategory: Record<string, number>;
|
|
77
|
-
p50DurationMs: Record<string, number>;
|
|
78
|
-
p99DurationMs: Record<string, number>;
|
|
79
|
-
};
|
|
80
|
-
nextRestartReason: string | null;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
export class RuntimeStats {
|
|
84
|
-
readonly startedAt = new Date();
|
|
85
|
-
requestsTotal = 0;
|
|
86
|
-
requestsErrored = 0;
|
|
87
|
-
requestsTimedOut = 0;
|
|
88
|
-
inFlight = 0;
|
|
89
|
-
|
|
90
|
-
watchersOpened = 0;
|
|
91
|
-
watchersClosed = 0;
|
|
92
|
-
watchersEvicted = 0;
|
|
93
|
-
|
|
94
|
-
// [LAW:dataflow-not-control-flow] Subprocess metering. Every launch carries
|
|
95
|
-
// its category through one boundary; the per-category state below is a
|
|
96
|
-
// function of that data, not of which call site fired.
|
|
97
|
-
subprocessTotal = 0;
|
|
98
|
-
subprocessInFlight = 0;
|
|
99
|
-
private readonly subprocessCount = new Map<LaunchCategory, number>();
|
|
100
|
-
private readonly subprocessHistogram = new Map<LaunchCategory, number[]>();
|
|
101
|
-
private readonly subprocessHistogramRotator = new Map<
|
|
102
|
-
LaunchCategory,
|
|
103
|
-
number
|
|
104
|
-
>();
|
|
105
|
-
private readonly rollingTimestamps: number[] = [];
|
|
106
|
-
private rollingHead = 0;
|
|
107
|
-
|
|
108
|
-
// [LAW:one-source-of-truth] The same object that owns the counters also
|
|
109
|
-
// exposes the metering handle. The launch primitive calls these two
|
|
110
|
-
// methods; nothing else mutates subprocess state.
|
|
111
|
-
readonly launchStats: LaunchStatsHandle = {
|
|
112
|
-
onStart: (category) => {
|
|
113
|
-
this.subprocessTotal++;
|
|
114
|
-
this.subprocessInFlight++;
|
|
115
|
-
this.subprocessCount.set(
|
|
116
|
-
category,
|
|
117
|
-
(this.subprocessCount.get(category) ?? 0) + 1,
|
|
118
|
-
);
|
|
119
|
-
this.recordRollingNow();
|
|
120
|
-
},
|
|
121
|
-
onEnd: (category, durationMs) => {
|
|
122
|
-
this.subprocessInFlight = Math.max(0, this.subprocessInFlight - 1);
|
|
123
|
-
this.recordDuration(category, durationMs);
|
|
124
|
-
},
|
|
125
|
-
};
|
|
126
|
-
|
|
127
|
-
private recordRollingNow(): void {
|
|
128
|
-
const now = Date.now();
|
|
129
|
-
if (this.rollingTimestamps.length < ROLLING_BUFFER_CAP) {
|
|
130
|
-
this.rollingTimestamps.push(now);
|
|
131
|
-
return;
|
|
132
|
-
}
|
|
133
|
-
// Ring buffer once cap hit. The overwritten slot may still be inside the
|
|
134
|
-
// 60s window — see the cap-rationale comment at ROLLING_BUFFER_CAP.
|
|
135
|
-
this.rollingTimestamps[this.rollingHead] = now;
|
|
136
|
-
this.rollingHead = (this.rollingHead + 1) % ROLLING_BUFFER_CAP;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
private recordDuration(category: LaunchCategory, durationMs: number): void {
|
|
140
|
-
let hist = this.subprocessHistogram.get(category);
|
|
141
|
-
if (!hist) {
|
|
142
|
-
hist = [];
|
|
143
|
-
this.subprocessHistogram.set(category, hist);
|
|
144
|
-
}
|
|
145
|
-
if (hist.length < HISTOGRAM_CAP) {
|
|
146
|
-
hist.push(durationMs);
|
|
147
|
-
return;
|
|
148
|
-
}
|
|
149
|
-
const idx =
|
|
150
|
-
(this.subprocessHistogramRotator.get(category) ?? 0) % HISTOGRAM_CAP;
|
|
151
|
-
hist[idx] = durationMs;
|
|
152
|
-
this.subprocessHistogramRotator.set(category, idx + 1);
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
private snapshotSubprocesses(): StatsSnapshot["subprocesses"] {
|
|
156
|
-
const cutoff = Date.now() - ROLLING_WINDOW_MS;
|
|
157
|
-
let lastMinute = 0;
|
|
158
|
-
for (const ts of this.rollingTimestamps) {
|
|
159
|
-
if (ts >= cutoff) lastMinute++;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
// [LAW:one-source-of-truth] Snapshot includes only categories that have
|
|
163
|
-
// actually executed — same shape as p50/p99 below. Consumers wanting the
|
|
164
|
-
// full closed list can read LAUNCH_CATEGORIES (exported from src/proc/launch)
|
|
165
|
-
// and treat missing keys as zero.
|
|
166
|
-
const byCategory: Record<string, number> = {};
|
|
167
|
-
for (const [cat, n] of this.subprocessCount) {
|
|
168
|
-
if (n > 0) byCategory[cat] = n;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
const p50: Record<string, number> = {};
|
|
172
|
-
const p99: Record<string, number> = {};
|
|
173
|
-
for (const [cat, hist] of this.subprocessHistogram) {
|
|
174
|
-
if (hist.length === 0) continue;
|
|
175
|
-
const sorted = [...hist].sort((a, b) => a - b);
|
|
176
|
-
const p50idx = Math.floor(sorted.length * 0.5);
|
|
177
|
-
const p99idx = Math.min(
|
|
178
|
-
sorted.length - 1,
|
|
179
|
-
Math.floor(sorted.length * 0.99),
|
|
180
|
-
);
|
|
181
|
-
const p50val = sorted[p50idx];
|
|
182
|
-
const p99val = sorted[p99idx];
|
|
183
|
-
if (p50val !== undefined) p50[cat] = p50val;
|
|
184
|
-
if (p99val !== undefined) p99[cat] = p99val;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
return {
|
|
188
|
-
total: this.subprocessTotal,
|
|
189
|
-
inFlight: this.subprocessInFlight,
|
|
190
|
-
lastMinute,
|
|
191
|
-
byCategory,
|
|
192
|
-
p50DurationMs: p50,
|
|
193
|
-
p99DurationMs: p99,
|
|
194
|
-
};
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
snapshot(extras: {
|
|
198
|
-
gitCache: StatsSnapshot["gitCache"];
|
|
199
|
-
usageCache: StatsSnapshot["usageCache"];
|
|
200
|
-
renderCacheSize: number;
|
|
201
|
-
watchersActive: number;
|
|
202
|
-
nextRestartReason?: string | null;
|
|
203
|
-
}): StatsSnapshot {
|
|
204
|
-
const mem = process.memoryUsage();
|
|
205
|
-
return {
|
|
206
|
-
pid: process.pid,
|
|
207
|
-
version: PACKAGE_VERSION,
|
|
208
|
-
protocolVersion: PROTOCOL_VERSION,
|
|
209
|
-
startedAt: this.startedAt.toISOString(),
|
|
210
|
-
uptimeSec: Math.floor((Date.now() - this.startedAt.getTime()) / 1000),
|
|
211
|
-
rssBytes: mem.rss,
|
|
212
|
-
heapUsedBytes: mem.heapUsed,
|
|
213
|
-
heapTotalBytes: mem.heapTotal,
|
|
214
|
-
externalBytes: mem.external,
|
|
215
|
-
arrayBuffersBytes: mem.arrayBuffers,
|
|
216
|
-
requests: {
|
|
217
|
-
total: this.requestsTotal,
|
|
218
|
-
errored: this.requestsErrored,
|
|
219
|
-
timedOut: this.requestsTimedOut,
|
|
220
|
-
inFlight: this.inFlight,
|
|
221
|
-
},
|
|
222
|
-
gitCache: extras.gitCache,
|
|
223
|
-
usageCache: extras.usageCache,
|
|
224
|
-
renderCache: { size: extras.renderCacheSize },
|
|
225
|
-
watchers: {
|
|
226
|
-
active: extras.watchersActive,
|
|
227
|
-
opened: this.watchersOpened,
|
|
228
|
-
closed: this.watchersClosed,
|
|
229
|
-
evicted: this.watchersEvicted,
|
|
230
|
-
},
|
|
231
|
-
subprocesses: this.snapshotSubprocesses(),
|
|
232
|
-
nextRestartReason: extras.nextRestartReason ?? null,
|
|
233
|
-
};
|
|
234
|
-
}
|
|
235
|
-
}
|
|
@@ -1,250 +0,0 @@
|
|
|
1
|
-
// [LAW:one-source-of-truth] The persistent-config-write instance of the
|
|
2
|
-
// shared keyed-validator registry (validator-registry.ts) — the twin of
|
|
3
|
-
// state-validators.ts for `persist` actions instead of `set` actions.
|
|
4
|
-
// Gates stay derived the same way as session writes: a `persist` action
|
|
5
|
-
// carries its target key and value SOURCE as literal data, so the
|
|
6
|
-
// writable-key gate DERIVES from the action table exactly like
|
|
7
|
-
// deriveActionValidators does for `set`. No baseline keys: every config
|
|
8
|
-
// globals field becomes writable ONLY when a config declares a `persist`
|
|
9
|
-
// action for it — the epic's "zero engine edits to add a menu-able field"
|
|
10
|
-
// goal, realized more strictly here than SessionState's legacy baseline
|
|
11
|
-
// theme/style/toolbar-expanded keys.
|
|
12
|
-
|
|
13
|
-
import { actionDestinations, type ActionDecl } from "../../config/action";
|
|
14
|
-
import {
|
|
15
|
-
perConfigDomainsFor,
|
|
16
|
-
resolveOptionDomain,
|
|
17
|
-
} from "../../config/option-domain";
|
|
18
|
-
import { addableSegmentDomains } from "../../config/edit-chrome";
|
|
19
|
-
import type { DslConfig } from "../../config/dsl-types";
|
|
20
|
-
import { numericGlobalsSeeds } from "../../config/loader/globals";
|
|
21
|
-
import { encodeLayoutOp } from "../../config/layout-ops";
|
|
22
|
-
import {
|
|
23
|
-
parsePersistTarget,
|
|
24
|
-
presetRootOpsKey,
|
|
25
|
-
} from "../../config/loader/persist-target";
|
|
26
|
-
import {
|
|
27
|
-
clampSeed,
|
|
28
|
-
createValidatorRegistry,
|
|
29
|
-
mergeContributions,
|
|
30
|
-
type DerivedValidatorSpec,
|
|
31
|
-
type KeySpecContribution,
|
|
32
|
-
type RangeParams,
|
|
33
|
-
type ValidateResult,
|
|
34
|
-
} from "./validator-registry";
|
|
35
|
-
|
|
36
|
-
// [LAW:no-silent-fallbacks] No baseline entries: a config globals field is
|
|
37
|
-
// writable ONLY when some `persist` action names it.
|
|
38
|
-
const registry = createValidatorRegistry({}, "config");
|
|
39
|
-
|
|
40
|
-
export function listConfigKeys(): readonly string[] {
|
|
41
|
-
return registry.listKeys();
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export function validateConfigWrite(
|
|
45
|
-
key: string,
|
|
46
|
-
rawValue: string,
|
|
47
|
-
): ValidateResult {
|
|
48
|
-
return registry.validate(key, rawValue);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export function rangeParamsForConfig(key: string): RangeParams | null {
|
|
52
|
-
return registry.rangeParamsFor(key);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
// [LAW:one-source-of-truth] The ONE place mapping a `persist` ACTION to the
|
|
56
|
-
// validator key SPEC it declares — the mirror of state-validators.ts's
|
|
57
|
-
// actionKeySpecs for `set`. `persist` has no `int` arm: a page cursor is a
|
|
58
|
-
// UI-only paging concept with no meaning as a persisted config default.
|
|
59
|
-
function actionKeySpecs(
|
|
60
|
-
a: ActionDecl,
|
|
61
|
-
seeds: ReadonlyMap<string, number>,
|
|
62
|
-
perConfigDomains: ReadonlyMap<string, readonly string[]>,
|
|
63
|
-
): KeySpecContribution[] {
|
|
64
|
-
if (!("persist" in a)) return [];
|
|
65
|
-
if ("to" in a) {
|
|
66
|
-
return [{ key: a.persist, spec: { kind: "allow-list", allowed: [a.to] } }];
|
|
67
|
-
}
|
|
68
|
-
if ("from" in a) {
|
|
69
|
-
return [
|
|
70
|
-
{
|
|
71
|
-
key: a.persist,
|
|
72
|
-
spec: {
|
|
73
|
-
kind: "allow-list",
|
|
74
|
-
allowed: resolveOptionDomain(a.from, perConfigDomains),
|
|
75
|
-
},
|
|
76
|
-
},
|
|
77
|
-
];
|
|
78
|
-
}
|
|
79
|
-
if ("cycle" in a) {
|
|
80
|
-
return [{ key: a.persist, spec: { kind: "allow-list", allowed: a.cycle } }];
|
|
81
|
-
}
|
|
82
|
-
// [LAW:single-enforcer] brandon-layout-edit-2gc.1's structural-edit arms:
|
|
83
|
-
// the op is fully literal at config-author time (removeSegment's target,
|
|
84
|
-
// insertSegment's segment/anchor/relation), so — exactly like a literal
|
|
85
|
-
// `to` — there is exactly ONE legal value this declared action can ever
|
|
86
|
-
// request: its own encoded op token. Multiple layout actions targeting the
|
|
87
|
-
// same "presets.<name>.rootOps" key each contribute one allow-list member,
|
|
88
|
-
// unioned by mergeContributions below, same as multiple `to` actions on
|
|
89
|
-
// one key already do.
|
|
90
|
-
if ("removeSegment" in a) {
|
|
91
|
-
return [
|
|
92
|
-
{
|
|
93
|
-
key: a.persist,
|
|
94
|
-
spec: {
|
|
95
|
-
kind: "allow-list",
|
|
96
|
-
allowed: [encodeLayoutOp({ op: "remove", target: a.removeSegment })],
|
|
97
|
-
},
|
|
98
|
-
},
|
|
99
|
-
];
|
|
100
|
-
}
|
|
101
|
-
if ("insertSegment" in a) {
|
|
102
|
-
return [
|
|
103
|
-
{
|
|
104
|
-
key: a.persist,
|
|
105
|
-
spec: {
|
|
106
|
-
kind: "allow-list",
|
|
107
|
-
allowed: [
|
|
108
|
-
encodeLayoutOp({
|
|
109
|
-
op: "insert",
|
|
110
|
-
segment: a.insertSegment,
|
|
111
|
-
anchor: a.anchor,
|
|
112
|
-
relation: a.relation,
|
|
113
|
-
}),
|
|
114
|
-
],
|
|
115
|
-
},
|
|
116
|
-
},
|
|
117
|
-
];
|
|
118
|
-
}
|
|
119
|
-
// [LAW:one-source-of-truth] brandon-layout-edit-2gc.3's domain-sourced
|
|
120
|
-
// sibling: the allow-list is the ENCODED op token for every domain member,
|
|
121
|
-
// not the raw member — mirroring how a literal `insertSegment` contributes
|
|
122
|
-
// its own single encoded token above. A click carrying an option this
|
|
123
|
-
// domain never named — or naming a real segment but the wrong anchor/
|
|
124
|
-
// relation — cannot decode to a member of this list, so it is rejected the
|
|
125
|
-
// same loud way an unknown literal op token already is.
|
|
126
|
-
if ("insertSegmentFrom" in a) {
|
|
127
|
-
return [
|
|
128
|
-
{
|
|
129
|
-
key: a.persist,
|
|
130
|
-
spec: {
|
|
131
|
-
kind: "allow-list",
|
|
132
|
-
allowed: resolveOptionDomain(
|
|
133
|
-
a.insertSegmentFrom,
|
|
134
|
-
perConfigDomains,
|
|
135
|
-
).map((segment) =>
|
|
136
|
-
encodeLayoutOp({
|
|
137
|
-
op: "insert",
|
|
138
|
-
segment,
|
|
139
|
-
anchor: a.anchor,
|
|
140
|
-
relation: a.relation,
|
|
141
|
-
}),
|
|
142
|
-
),
|
|
143
|
-
},
|
|
144
|
-
},
|
|
145
|
-
];
|
|
146
|
-
}
|
|
147
|
-
return [
|
|
148
|
-
{
|
|
149
|
-
key: a.persist,
|
|
150
|
-
spec: {
|
|
151
|
-
kind: "range",
|
|
152
|
-
min: a.min,
|
|
153
|
-
max: a.max,
|
|
154
|
-
seed: clampSeed(seeds.get(a.persist), a.min, a.max),
|
|
155
|
-
},
|
|
156
|
-
},
|
|
157
|
-
];
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
// [LAW:one-source-of-truth] The unset seed for a bounded `persist` key (e.g.
|
|
161
|
-
// a padding stepper) is the value that would render with NO override yet —
|
|
162
|
-
// the merged config's OWN globals field, before this reload's overrides are
|
|
163
|
-
// applied. Mirrors stateKeySeeds' "the bar's current display, not silently
|
|
164
|
-
// min" rule for SessionState steppers.
|
|
165
|
-
function configKeySeeds(config: DslConfig): ReadonlyMap<string, number> {
|
|
166
|
-
return numericGlobalsSeeds(config.globals);
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
// [LAW:one-source-of-truth] Every preset a config's action table ALREADY
|
|
170
|
-
// targets via a `presets.<name>.rootOps` key (persist OR reset — the
|
|
171
|
-
// contribution is keyed off intent to use structural editing for that
|
|
172
|
-
// preset, not off "this config has any presets block") stays a
|
|
173
|
-
// registered key EVEN when that preset's CURRENT tree has no
|
|
174
|
-
// addable/removable segment for removeChrome/insertChrome to contribute
|
|
175
|
-
// from. Without this, a preset edited down to zero non-exempt segments
|
|
176
|
-
// (spliceContainer then contributes nothing for it at all) would orphan
|
|
177
|
-
// its OWN reset action: the one affordance meant to undo a fully-emptied
|
|
178
|
-
// preset would throw "unknown config key" at the exact moment it's needed
|
|
179
|
-
// most (brandon-layout-edit-2gc.5 PR review). An EMPTY allow-list
|
|
180
|
-
// registers the key (so `reset-config`'s membership check —
|
|
181
|
-
// src/daemon/verbs/index.ts's resetConfig — passes) without granting any
|
|
182
|
-
// illegitimate WRITE: a real persist write still needs a real
|
|
183
|
-
// removeSegment/insertSegment/insertSegmentFrom action elsewhere;
|
|
184
|
-
// mergeContributions unions an empty array with whatever those contribute.
|
|
185
|
-
//
|
|
186
|
-
// [LAW:no-mode-explosion] Deliberately narrower than "every declared
|
|
187
|
-
// preset" — a config with a `presets` block but ZERO persist/reset actions
|
|
188
|
-
// over it has no structural-editing surface at all, so it registers
|
|
189
|
-
// nothing here, preserving this module's own "zero baseline keys" floor
|
|
190
|
-
// (a globals field, and now a preset's rootOps, is writable only because
|
|
191
|
-
// SOME action names it).
|
|
192
|
-
function presetRootOpsContributions(config: DslConfig): KeySpecContribution[] {
|
|
193
|
-
const presets = new Set<string>();
|
|
194
|
-
for (const a of writeDestinations(config)) {
|
|
195
|
-
const key = "persist" in a ? a.persist : "reset" in a ? a.reset : null;
|
|
196
|
-
if (key === null) continue;
|
|
197
|
-
const target = parsePersistTarget(key);
|
|
198
|
-
if (target?.scope === "preset-root-ops") presets.add(target.preset);
|
|
199
|
-
}
|
|
200
|
-
return [...presets].map((name) => ({
|
|
201
|
-
key: presetRootOpsKey(name),
|
|
202
|
-
spec: { kind: "allow-list", allowed: [] },
|
|
203
|
-
}));
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
function actionContributions(config: DslConfig): KeySpecContribution[] {
|
|
207
|
-
const seeds = configKeySeeds(config);
|
|
208
|
-
// [LAW:one-source-of-truth] The "addable segment" domains
|
|
209
|
-
// (edit-chrome.ts's `addableSegmentDomains`) merge in here alongside
|
|
210
|
-
// looks/presets — the same per-preset seam `insertSegmentFrom` resolves
|
|
211
|
-
// through at render (render.ts's registerDslConfig merges the identical
|
|
212
|
-
// map), so the rendered picker options and the derived click gate can
|
|
213
|
-
// never diverge over what's addable.
|
|
214
|
-
const perConfigDomains = new Map([
|
|
215
|
-
...perConfigDomainsFor(config),
|
|
216
|
-
...addableSegmentDomains(config),
|
|
217
|
-
]);
|
|
218
|
-
return [
|
|
219
|
-
...presetRootOpsContributions(config),
|
|
220
|
-
...writeDestinations(config).flatMap((a) =>
|
|
221
|
-
actionKeySpecs(a, seeds, perConfigDomains),
|
|
222
|
-
),
|
|
223
|
-
];
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
// [LAW:single-enforcer] Every action as the single-destination declarations it
|
|
227
|
-
// writes through — the SAME explosion state-validators.ts folds over, so a
|
|
228
|
-
// dual-destination action (candybar-settings-ui-aok.3) contributes exactly the
|
|
229
|
-
// `persist` spec its durable half would have contributed alone. One statement
|
|
230
|
-
// of "what are this action's destinations", two derivations reading it.
|
|
231
|
-
function writeDestinations(config: DslConfig): readonly ActionDecl[] {
|
|
232
|
-
return Object.values(config.actions).flatMap(actionDestinations);
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
// [LAW:single-enforcer] The SOLE install-site derivation: a config's
|
|
236
|
-
// persistent-config-writable-key surface is the merge of every `persist`
|
|
237
|
-
// ACTION it declares, through the SAME coherence pass deriveActionValidators
|
|
238
|
-
// uses for `set`.
|
|
239
|
-
export function deriveConfigActionValidators(
|
|
240
|
-
config: DslConfig,
|
|
241
|
-
): readonly KeySpecContribution[] {
|
|
242
|
-
return mergeContributions(actionContributions(config), "config");
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
export function registerConfigValidator(
|
|
246
|
-
key: string,
|
|
247
|
-
spec: DerivedValidatorSpec,
|
|
248
|
-
): () => void {
|
|
249
|
-
return registry.register(key, spec);
|
|
250
|
-
}
|