@promptctl/cc-candybar 1.0.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/LICENSE +21 -0
- package/README.md +145 -0
- package/bin/cc-candybar +6 -0
- package/dist/index.mjs +185 -0
- package/package.json +99 -0
- package/plugin/.claude-plugin/plugin.json +11 -0
- package/plugin/bin/preview.sh +305 -0
- package/plugin/commands/candybar.md +403 -0
- package/plugin/templates/config-essential.json +36 -0
- package/plugin/templates/config-full.json +55 -0
- package/plugin/templates/config-standard.json +39 -0
- package/plugin/templates/config-tui-compact.json +48 -0
- package/plugin/templates/config-tui-full.json +89 -0
- package/plugin/templates/config-tui-standard.json +56 -0
- package/plugin/templates/config-tui.json +18 -0
- package/plugin/templates/nerd-fonts-sample.txt +5 -0
- package/schema/cc-candybar.schema.json +1379 -0
- package/src/click/wire.ts +113 -0
- package/src/config/action.ts +91 -0
- package/src/config/cli.ts +170 -0
- package/src/config/default-dsl-config.ts +661 -0
- package/src/config/dsl-loader.ts +265 -0
- package/src/config/dsl-types.ts +425 -0
- package/src/config/loader/actions.ts +530 -0
- package/src/config/loader/cache.ts +206 -0
- package/src/config/loader/cross-ref.ts +326 -0
- package/src/config/loader/cycles.ts +148 -0
- package/src/config/loader/diagnostics.ts +99 -0
- package/src/config/loader/discovery.ts +182 -0
- package/src/config/loader/emit-schema.ts +63 -0
- package/src/config/loader/globals.ts +42 -0
- package/src/config/loader/helpers.ts +48 -0
- package/src/config/loader/layout.ts +688 -0
- package/src/config/loader/merge.ts +40 -0
- package/src/config/loader/refs.ts +96 -0
- package/src/config/loader/segments.ts +120 -0
- package/src/config/loader/validate-core.ts +674 -0
- package/src/config/loader/variables.ts +260 -0
- package/src/daemon/acquire.ts +411 -0
- package/src/daemon/cache/git.ts +553 -0
- package/src/daemon/cache/render.ts +449 -0
- package/src/daemon/cache/session-usage-store.ts +446 -0
- package/src/daemon/cache/watchers.ts +245 -0
- package/src/daemon/client-debug.ts +120 -0
- package/src/daemon/client-stats.ts +129 -0
- package/src/daemon/client-transport.ts +273 -0
- package/src/daemon/client.ts +75 -0
- package/src/daemon/debug-types.ts +91 -0
- package/src/daemon/debug.ts +264 -0
- package/src/daemon/limits.ts +154 -0
- package/src/daemon/log.ts +69 -0
- package/src/daemon/parent-watchdog.ts +80 -0
- package/src/daemon/paths.ts +127 -0
- package/src/daemon/protocol.ts +235 -0
- package/src/daemon/render-payload.ts +611 -0
- package/src/daemon/server.ts +1103 -0
- package/src/daemon/session-state-file.ts +108 -0
- package/src/daemon/session-state.ts +237 -0
- package/src/daemon/stats.ts +229 -0
- package/src/daemon/verbs/index.ts +458 -0
- package/src/daemon/verbs/state-validators.ts +708 -0
- package/src/demo/dsl.ts +117 -0
- package/src/demo/mock-data.ts +67 -0
- package/src/demo/statusline.json5 +92 -0
- package/src/dsl/node-registry.ts +281 -0
- package/src/dsl/render.ts +558 -0
- package/src/index.ts +206 -0
- package/src/install/index.ts +410 -0
- package/src/proc/launch.ts +451 -0
- package/src/proc/stats-handle.ts +13 -0
- package/src/render/action.ts +458 -0
- package/src/render/diagnostic-style.ts +23 -0
- package/src/render/diagnostic-text.ts +77 -0
- package/src/render/error-glyph.ts +53 -0
- package/src/render/outcome-plan.ts +45 -0
- package/src/render/picker.ts +231 -0
- package/src/render/split-lines.ts +51 -0
- package/src/render/strip.ts +103 -0
- package/src/segments/cache.ts +131 -0
- package/src/segments/context.ts +190 -0
- package/src/segments/git.ts +561 -0
- package/src/segments/metrics.ts +101 -0
- package/src/segments/pricing.ts +452 -0
- package/src/segments/session.ts +188 -0
- package/src/segments/tmux.ts +74 -0
- package/src/template-engine/cells.ts +90 -0
- package/src/template-engine/colors.ts +102 -0
- package/src/template-engine/engine.ts +108 -0
- package/src/template-engine/funcs.ts +216 -0
- package/src/template-engine/index.ts +11 -0
- package/src/template-engine/layout.ts +112 -0
- package/src/template-engine/scope.ts +62 -0
- package/src/themes/index.ts +19 -0
- package/src/themes/palette-resolvers.ts +86 -0
- package/src/themes/policy.ts +79 -0
- package/src/themes/session-random.ts +88 -0
- package/src/utils/cache.ts +206 -0
- package/src/utils/claude.ts +616 -0
- package/src/utils/color-support.ts +118 -0
- package/src/utils/formatters.ts +77 -0
- package/src/utils/logger.ts +5 -0
- package/src/utils/outcome.ts +33 -0
- package/src/utils/schema-validator.ts +126 -0
- package/src/utils/single-flight.ts +57 -0
- package/src/utils/terminal-width.ts +43 -0
- package/src/utils/terminal.ts +11 -0
- package/src/utils/transcript-fs.ts +162 -0
- package/src/var-system/index.ts +24 -0
- package/src/var-system/sources.ts +1038 -0
- package/src/var-system/store.ts +223 -0
- package/src/var-system/types.ts +57 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { debug } from "../utils/logger";
|
|
4
|
+
import type { DaemonLogger } from "./log";
|
|
5
|
+
import type { SessionSnapshot, SessionStorage } from "./session-state";
|
|
6
|
+
|
|
7
|
+
// [LAW:locality-or-seam] Logging is injected, not hard-wired to daemon.log.
|
|
8
|
+
// The daemon passes `dlog`; tests and non-daemon callers take this quiet
|
|
9
|
+
// default, which stays silent unless CC_CANDYBAR_DEBUG is set — so unit tests
|
|
10
|
+
// never open the real daemon log stream.
|
|
11
|
+
const quietLogger: DaemonLogger = (_level, message) => debug(message);
|
|
12
|
+
|
|
13
|
+
// [LAW:no-silent-fallbacks] Corrupt/missing file → empty state is the *defined*
|
|
14
|
+
// recovery, not a hidden fallback to different data: an empty store re-rolls
|
|
15
|
+
// random picks exactly as a first-ever boot would. Anything that isn't the
|
|
16
|
+
// expected sessionId→key→value shape is rejected here so the store never
|
|
17
|
+
// hydrates from a half-written or hand-edited file.
|
|
18
|
+
function isSnapshot(value: unknown): value is SessionSnapshot {
|
|
19
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
for (const kv of Object.values(value)) {
|
|
23
|
+
if (kv === null || typeof kv !== "object" || Array.isArray(kv))
|
|
24
|
+
return false;
|
|
25
|
+
for (const leaf of Object.values(kv)) {
|
|
26
|
+
if (typeof leaf !== "string") return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// [LAW:single-enforcer] The debounce + atomic write lives here, not in
|
|
33
|
+
// SessionState. The store calls save() on every mutation; this coalesces the
|
|
34
|
+
// bursty 22-session × 1 Hz write load into at most one disk write per window.
|
|
35
|
+
export class FileSessionStorage implements SessionStorage {
|
|
36
|
+
private timer: ReturnType<typeof setTimeout> | null = null;
|
|
37
|
+
private pending: SessionSnapshot | null = null;
|
|
38
|
+
|
|
39
|
+
constructor(
|
|
40
|
+
private readonly filePath: string,
|
|
41
|
+
private readonly debounceMs: number = 500,
|
|
42
|
+
private readonly logger: DaemonLogger = quietLogger,
|
|
43
|
+
) {}
|
|
44
|
+
|
|
45
|
+
load(): SessionSnapshot {
|
|
46
|
+
let raw: string;
|
|
47
|
+
try {
|
|
48
|
+
raw = fs.readFileSync(this.filePath, "utf8");
|
|
49
|
+
} catch (e) {
|
|
50
|
+
// [LAW:no-silent-fallbacks] A missing file is the expected first-boot
|
|
51
|
+
// recovery (silent → empty). Any other read failure (EACCES, EIO) is an
|
|
52
|
+
// anomaly worth surfacing before recovering to empty.
|
|
53
|
+
const code = (e as NodeJS.ErrnoException).code;
|
|
54
|
+
if (code !== "ENOENT") {
|
|
55
|
+
this.logger(
|
|
56
|
+
"warn",
|
|
57
|
+
`session-state read failed (${code}); starting empty`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
return {};
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
const parsed: unknown = JSON.parse(raw);
|
|
64
|
+
if (isSnapshot(parsed)) return parsed;
|
|
65
|
+
this.logger(
|
|
66
|
+
"warn",
|
|
67
|
+
`session-state load: unexpected shape, starting empty`,
|
|
68
|
+
);
|
|
69
|
+
return {};
|
|
70
|
+
} catch {
|
|
71
|
+
this.logger("warn", `session-state load: corrupt JSON, starting empty`);
|
|
72
|
+
return {};
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
save(snapshot: SessionSnapshot): void {
|
|
77
|
+
this.pending = snapshot;
|
|
78
|
+
if (this.timer) return;
|
|
79
|
+
this.timer = setTimeout(() => this.flush(), this.debounceMs);
|
|
80
|
+
this.timer.unref();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
flush(): void {
|
|
84
|
+
if (this.timer) {
|
|
85
|
+
clearTimeout(this.timer);
|
|
86
|
+
this.timer = null;
|
|
87
|
+
}
|
|
88
|
+
if (this.pending === null) return;
|
|
89
|
+
const snapshot = this.pending;
|
|
90
|
+
try {
|
|
91
|
+
fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
|
|
92
|
+
const tmp = `${this.filePath}.tmp`;
|
|
93
|
+
// [LAW:single-enforcer] Daemon runtime files are owner-only (0o600), like
|
|
94
|
+
// pid/spawn.lock. Session state carries conversation identifiers, so it
|
|
95
|
+
// gets the same perms — chmod defeats umask and re-perms a reused tmp.
|
|
96
|
+
fs.writeFileSync(tmp, JSON.stringify(snapshot), { mode: 0o600 });
|
|
97
|
+
fs.chmodSync(tmp, 0o600);
|
|
98
|
+
fs.renameSync(tmp, this.filePath);
|
|
99
|
+
// [LAW:one-source-of-truth] `pending` is "state not yet durably written".
|
|
100
|
+
// Clear it only once the rename lands, so a transient EIO/ENOSPC leaves
|
|
101
|
+
// the snapshot for a later flush (e.g. shutdown) to retry rather than
|
|
102
|
+
// silently dropping the last known state.
|
|
103
|
+
this.pending = null;
|
|
104
|
+
} catch (e) {
|
|
105
|
+
this.logger("warn", `session-state save failed: ${(e as Error).message}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// [LAW:one-type-per-behavior] One generic store for all per-session state.
|
|
2
|
+
// Adding a new per-session value is just picking a string key — no new class,
|
|
3
|
+
// no DI wiring, no cache invalidation.
|
|
4
|
+
//
|
|
5
|
+
// [LAW:single-enforcer] Reads are MobX-tracked through a single internal atom.
|
|
6
|
+
// Every get() reports observed; every set/clear/prune reports changed. A DSL
|
|
7
|
+
// computed that reads SessionState via this object will re-evaluate whenever
|
|
8
|
+
// any (sessionId, key) pair mutates — coarse-grained on purpose, since
|
|
9
|
+
// session-state mutations are rare (clicks) and computeds are cheap. The
|
|
10
|
+
// alternative — per-key atoms — would be lower-cardinality reactivity at the
|
|
11
|
+
// cost of a much wider API surface; we don't need it.
|
|
12
|
+
//
|
|
13
|
+
// Outside a reactive context (the common case: ad-hoc gets from the segments
|
|
14
|
+
// renderer), atom.reportObserved is a no-op. Tests that construct SessionState
|
|
15
|
+
// without any observer see no change in behavior.
|
|
16
|
+
|
|
17
|
+
import { createAtom, type IAtom, runInAction } from "mobx";
|
|
18
|
+
|
|
19
|
+
export interface SessionStateReader {
|
|
20
|
+
get(sessionId: string, key: string): string | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// [LAW:locality-or-seam] Renderer needs to *cache* per-session random picks
|
|
24
|
+
// so subsequent renders are stable. Writing them back into the same store
|
|
25
|
+
// click verbs use keeps state in one place — no parallel cache to drift.
|
|
26
|
+
//
|
|
27
|
+
// setBatch commits multiple (key, value) pairs as a single reactive
|
|
28
|
+
// transaction: observers fire ONCE after every pair has landed, never
|
|
29
|
+
// between pairs. The set-state verb's batched-pair URL (a Menu click that
|
|
30
|
+
// writes the chosen value AND collapses the menu) depends on this — if
|
|
31
|
+
// observers saw the first write before the second, an autorun could
|
|
32
|
+
// render half-applied state. The atomicity contract lives in the seam,
|
|
33
|
+
// not in each consumer. [LAW:single-enforcer]
|
|
34
|
+
export interface SessionStateRW extends SessionStateReader {
|
|
35
|
+
set(sessionId: string, key: string, value: string): void;
|
|
36
|
+
setBatch(
|
|
37
|
+
sessionId: string,
|
|
38
|
+
pairs: ReadonlyArray<{ key: string; value: string }>,
|
|
39
|
+
): void;
|
|
40
|
+
clear(sessionId: string, key: string): void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Flat, JSON-shaped mirror of the store: sessionId → key → value. This is the
|
|
44
|
+
// on-disk representation and the load/save currency between store and storage.
|
|
45
|
+
export type SessionSnapshot = Record<string, Record<string, string>>;
|
|
46
|
+
|
|
47
|
+
// [LAW:locality-or-seam] The store depends on this seam, not on the filesystem.
|
|
48
|
+
// The daemon injects a disk-backed impl; tests and non-daemon callers get the
|
|
49
|
+
// ephemeral default. Persistence is a property of the *storage*, not the store.
|
|
50
|
+
export interface SessionStorage {
|
|
51
|
+
load(): SessionSnapshot;
|
|
52
|
+
save(snapshot: SessionSnapshot): void;
|
|
53
|
+
flush(): void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// [LAW:dataflow-not-control-flow] Null-object so the store always calls
|
|
57
|
+
// save()/flush() — no "am I persisting?" branch. The ephemeral case is data
|
|
58
|
+
// (a storage that discards), not a special control path.
|
|
59
|
+
const EPHEMERAL_STORAGE: SessionStorage = {
|
|
60
|
+
load: () => ({}),
|
|
61
|
+
save: () => {},
|
|
62
|
+
flush: () => {},
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
function hydrate(snapshot: SessionSnapshot): Map<string, Map<string, string>> {
|
|
66
|
+
const sessions = new Map<string, Map<string, string>>();
|
|
67
|
+
for (const [sessionId, kv] of Object.entries(snapshot)) {
|
|
68
|
+
sessions.set(sessionId, new Map(Object.entries(kv)));
|
|
69
|
+
}
|
|
70
|
+
return sessions;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Generous headroom over realistic concurrent-session counts; matches the
|
|
74
|
+
// render cache's LRU bound. Active sessions stay hot via get-promotion, so only
|
|
75
|
+
// genuinely-idle sessions are ever evicted — eviction *is* "drop dead sessions".
|
|
76
|
+
const DEFAULT_MAX_SESSIONS = 256;
|
|
77
|
+
|
|
78
|
+
export class SessionState implements SessionStateReader, SessionStateRW {
|
|
79
|
+
// [LAW:types-are-the-program] Insertion order is recency order: the store
|
|
80
|
+
// cannot hold more than maxSessions, so "bounded on disk" is structural, not
|
|
81
|
+
// dependent on an external prune caller.
|
|
82
|
+
private sessions: Map<string, Map<string, string>>;
|
|
83
|
+
private storage: SessionStorage;
|
|
84
|
+
// [LAW:single-enforcer] One atom; every read reports observed against it,
|
|
85
|
+
// every mutation reports changed. Coarse-grained reactivity is correct for
|
|
86
|
+
// session-state's load — mutations are rare and computeds are cheap.
|
|
87
|
+
private readonly atom: IAtom = createAtom("SessionState");
|
|
88
|
+
|
|
89
|
+
constructor(
|
|
90
|
+
storage: SessionStorage = EPHEMERAL_STORAGE,
|
|
91
|
+
private readonly maxSessions: number = DEFAULT_MAX_SESSIONS,
|
|
92
|
+
) {
|
|
93
|
+
this.storage = storage;
|
|
94
|
+
this.sessions = new Map();
|
|
95
|
+
this.hydrateFromStorage();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// [LAW:single-enforcer] Bind a persistence backend after construction. Only
|
|
99
|
+
// the daemon process calls this (with the disk-backed storage), so importers
|
|
100
|
+
// that merely load this module — the CLI relay, subcommands — keep the
|
|
101
|
+
// ephemeral default and never read or write the state file. Must run before
|
|
102
|
+
// the daemon serves requests, since it replaces in-memory state with disk.
|
|
103
|
+
useStorage(storage: SessionStorage): void {
|
|
104
|
+
this.storage = storage;
|
|
105
|
+
this.hydrateFromStorage();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private hydrateFromStorage(): void {
|
|
109
|
+
this.sessions = hydrate(this.storage.load());
|
|
110
|
+
this.evictOldest();
|
|
111
|
+
// [LAW:dataflow-not-control-flow] The disk mirror always reflects the built
|
|
112
|
+
// in-memory state. An over-cap file trimmed by evictOldest is written back
|
|
113
|
+
// here, so the on-disk bound holds even if no mutation ever follows.
|
|
114
|
+
this.persist();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
get(sessionId: string, key: string): string | null {
|
|
118
|
+
// [LAW:single-enforcer] reportObserved is the reactive-dep registration —
|
|
119
|
+
// outside a tracking context (the common direct-read case) it is a no-op.
|
|
120
|
+
this.atom.reportObserved();
|
|
121
|
+
const session = this.sessions.get(sessionId);
|
|
122
|
+
if (!session) return null;
|
|
123
|
+
// [LAW:dataflow-not-control-flow] A read promotes recency but never
|
|
124
|
+
// triggers a disk write itself; the reordered insertion order only reaches
|
|
125
|
+
// disk if a later mutation persists.
|
|
126
|
+
this.touch(sessionId, session);
|
|
127
|
+
return session.get(key) ?? null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// [LAW:one-source-of-truth] set is the degenerate single-pair form of
|
|
131
|
+
// setBatch — one write path through the store. The previous shape (a
|
|
132
|
+
// standalone set body) split the write semantics across two routes
|
|
133
|
+
// once setBatch was introduced; collapsing keeps mutation, persistence,
|
|
134
|
+
// and notification in exactly one place.
|
|
135
|
+
set(sessionId: string, key: string, value: string): void {
|
|
136
|
+
this.setBatch(sessionId, [{ key, value }]);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// [LAW:no-silent-fallbacks] Atomic commit of N pairs: every write
|
|
140
|
+
// lands BEFORE the single reportChanged() that scheduler-visibly
|
|
141
|
+
// marks the transaction complete. Observers cannot see an
|
|
142
|
+
// intermediate "half-applied" snapshot — `runInAction` defers
|
|
143
|
+
// reaction scheduling until the outermost call exits, and we hold
|
|
144
|
+
// ALL writes inside this one block. Previously, the verb's "loop and
|
|
145
|
+
// call set N times" pattern fired reportChanged() N times, which
|
|
146
|
+
// scheduled autoruns between pairs (visible to consumers as the menu
|
|
147
|
+
// value changing while toolbar-expanded was still old). The batch
|
|
148
|
+
// method is the structural fix: there is no way to get half-applied
|
|
149
|
+
// state because there is no intermediate scheduler tick.
|
|
150
|
+
//
|
|
151
|
+
// [LAW:dataflow-not-control-flow] An empty pairs array is no-work-
|
|
152
|
+
// to-do, returned without firing reportChanged or persisting. The
|
|
153
|
+
// verb body validates that pairs is non-empty before calling, so
|
|
154
|
+
// this is the public-API safety net rather than the hot path.
|
|
155
|
+
setBatch(
|
|
156
|
+
sessionId: string,
|
|
157
|
+
pairs: ReadonlyArray<{ key: string; value: string }>,
|
|
158
|
+
): void {
|
|
159
|
+
if (pairs.length === 0) return;
|
|
160
|
+
runInAction(() => {
|
|
161
|
+
const session = this.sessions.get(sessionId) ?? new Map<string, string>();
|
|
162
|
+
for (const { key, value } of pairs) session.set(key, value);
|
|
163
|
+
this.touch(sessionId, session);
|
|
164
|
+
this.evictOldest();
|
|
165
|
+
this.persist();
|
|
166
|
+
this.atom.reportChanged();
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
clear(sessionId: string, key: string): void {
|
|
171
|
+
runInAction(() => {
|
|
172
|
+
const session = this.sessions.get(sessionId);
|
|
173
|
+
if (session) {
|
|
174
|
+
session.delete(key);
|
|
175
|
+
// An emptied session is a non-state — drop it so it neither occupies a
|
|
176
|
+
// cap slot nor persists as a `{ "sid": {} }` husk. [LAW:one-source-of-truth]
|
|
177
|
+
// A surviving session is promoted: every interaction is a recency signal,
|
|
178
|
+
// uniform with get()/set(). [LAW:one-type-per-behavior]
|
|
179
|
+
if (session.size === 0) this.sessions.delete(sessionId);
|
|
180
|
+
else this.touch(sessionId, session);
|
|
181
|
+
}
|
|
182
|
+
this.persist();
|
|
183
|
+
this.atom.reportChanged();
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// [LAW:one-source-of-truth] Drop state for sessions that no longer exist.
|
|
188
|
+
prune(activeSessionIds: Set<string>): void {
|
|
189
|
+
runInAction(() => {
|
|
190
|
+
for (const id of this.sessions.keys()) {
|
|
191
|
+
if (!activeSessionIds.has(id)) this.sessions.delete(id);
|
|
192
|
+
}
|
|
193
|
+
this.persist();
|
|
194
|
+
this.atom.reportChanged();
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Move-to-end: re-inserting at the tail makes this the most-recently-used.
|
|
199
|
+
private touch(sessionId: string, session: Map<string, string>): void {
|
|
200
|
+
this.sessions.delete(sessionId);
|
|
201
|
+
this.sessions.set(sessionId, session);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private evictOldest(): void {
|
|
205
|
+
let overflow = this.sessions.size - this.maxSessions;
|
|
206
|
+
if (overflow <= 0) return;
|
|
207
|
+
// Delete the oldest keys in insertion order. Cost is proportional to the
|
|
208
|
+
// number of evictions, not the total loaded set — deleting an already-
|
|
209
|
+
// yielded key mid-iteration is well-defined for a Map.
|
|
210
|
+
for (const id of this.sessions.keys()) {
|
|
211
|
+
this.sessions.delete(id);
|
|
212
|
+
if (--overflow === 0) break;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Synchronous write of any debounced-pending snapshot. Called on daemon
|
|
217
|
+
// shutdown so a pending pick isn't lost when the process exits.
|
|
218
|
+
flush(): void {
|
|
219
|
+
this.storage.flush();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
private persist(): void {
|
|
223
|
+
this.storage.save(this.serialize());
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private serialize(): SessionSnapshot {
|
|
227
|
+
// [LAW:types-are-the-program] sessionIds are external (hook JSON / click
|
|
228
|
+
// URLs). A null-prototype root makes "__proto__"/"constructor" ordinary
|
|
229
|
+
// own keys instead of prototype-mutation vectors — pollution is
|
|
230
|
+
// unrepresentable rather than guarded against.
|
|
231
|
+
const snapshot = Object.create(null) as SessionSnapshot;
|
|
232
|
+
for (const [sessionId, kv] of this.sessions) {
|
|
233
|
+
snapshot[sessionId] = Object.fromEntries(kv);
|
|
234
|
+
}
|
|
235
|
+
return snapshot;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
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
|
+
|
|
9
|
+
// Rolling window for "last minute" counts. Keep timestamps for each launch in
|
|
10
|
+
// a ring buffer; eviction happens lazily on read.
|
|
11
|
+
//
|
|
12
|
+
// [LAW:dataflow-not-control-flow] The cap is a hard bound on how many
|
|
13
|
+
// launches can be counted in any 60-second window. Bursts above
|
|
14
|
+
// ROLLING_BUFFER_CAP / (ROLLING_WINDOW_MS/1000) launches/sec overwrite
|
|
15
|
+
// timestamps that are still inside the window, causing `lastMinute` to
|
|
16
|
+
// undercount. At 16384 entries / 60s = ~273 sustained launches/sec the
|
|
17
|
+
// undercount only kicks in under pathological load (well past anything kz8
|
|
18
|
+
// is trying to detect). Bump this cap rather than the comment if a future
|
|
19
|
+
// workload ever sustains higher rates.
|
|
20
|
+
const ROLLING_WINDOW_MS = 60_000;
|
|
21
|
+
const ROLLING_BUFFER_CAP = 16384;
|
|
22
|
+
|
|
23
|
+
// Reservoir-sample histogram (16 entries) per category — sufficient for
|
|
24
|
+
// rough p50/p99 without unbounded memory. Replacement uses a simple
|
|
25
|
+
// counter-mod-N strategy: deterministic and adequate for human-eyeball
|
|
26
|
+
// dashboards (no statistical claim of unbiased sampling).
|
|
27
|
+
const HISTOGRAM_CAP = 16;
|
|
28
|
+
|
|
29
|
+
export interface StatsSnapshot {
|
|
30
|
+
pid: number;
|
|
31
|
+
version: number;
|
|
32
|
+
startedAt: string;
|
|
33
|
+
uptimeSec: number;
|
|
34
|
+
rssBytes: number;
|
|
35
|
+
heapUsedBytes: number;
|
|
36
|
+
heapTotalBytes: number;
|
|
37
|
+
externalBytes: number;
|
|
38
|
+
arrayBuffersBytes: number;
|
|
39
|
+
requests: {
|
|
40
|
+
total: number;
|
|
41
|
+
errored: number;
|
|
42
|
+
timedOut: number;
|
|
43
|
+
inFlight: number;
|
|
44
|
+
};
|
|
45
|
+
gitCache: {
|
|
46
|
+
size: number;
|
|
47
|
+
hits: number;
|
|
48
|
+
misses: number;
|
|
49
|
+
invalidations: number;
|
|
50
|
+
watchers: number;
|
|
51
|
+
};
|
|
52
|
+
usageCache: {
|
|
53
|
+
size: number;
|
|
54
|
+
hits: number;
|
|
55
|
+
misses: number;
|
|
56
|
+
sweeps: number;
|
|
57
|
+
};
|
|
58
|
+
renderCache: {
|
|
59
|
+
size: number;
|
|
60
|
+
};
|
|
61
|
+
watchers: {
|
|
62
|
+
active: number;
|
|
63
|
+
opened: number;
|
|
64
|
+
closed: number;
|
|
65
|
+
evicted: number;
|
|
66
|
+
};
|
|
67
|
+
subprocesses: {
|
|
68
|
+
total: number;
|
|
69
|
+
inFlight: number;
|
|
70
|
+
lastMinute: number;
|
|
71
|
+
byCategory: Record<string, number>;
|
|
72
|
+
p50DurationMs: Record<string, number>;
|
|
73
|
+
p99DurationMs: Record<string, number>;
|
|
74
|
+
};
|
|
75
|
+
nextRestartReason: string | null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export class RuntimeStats {
|
|
79
|
+
readonly startedAt = new Date();
|
|
80
|
+
requestsTotal = 0;
|
|
81
|
+
requestsErrored = 0;
|
|
82
|
+
requestsTimedOut = 0;
|
|
83
|
+
inFlight = 0;
|
|
84
|
+
|
|
85
|
+
watchersOpened = 0;
|
|
86
|
+
watchersClosed = 0;
|
|
87
|
+
watchersEvicted = 0;
|
|
88
|
+
|
|
89
|
+
// [LAW:dataflow-not-control-flow] Subprocess metering. Every launch carries
|
|
90
|
+
// its category through one boundary; the per-category state below is a
|
|
91
|
+
// function of that data, not of which call site fired.
|
|
92
|
+
subprocessTotal = 0;
|
|
93
|
+
subprocessInFlight = 0;
|
|
94
|
+
private readonly subprocessCount = new Map<LaunchCategory, number>();
|
|
95
|
+
private readonly subprocessHistogram = new Map<LaunchCategory, number[]>();
|
|
96
|
+
private readonly subprocessHistogramRotator = new Map<
|
|
97
|
+
LaunchCategory,
|
|
98
|
+
number
|
|
99
|
+
>();
|
|
100
|
+
private readonly rollingTimestamps: number[] = [];
|
|
101
|
+
private rollingHead = 0;
|
|
102
|
+
|
|
103
|
+
// [LAW:one-source-of-truth] The same object that owns the counters also
|
|
104
|
+
// exposes the metering handle. The launch primitive calls these two
|
|
105
|
+
// methods; nothing else mutates subprocess state.
|
|
106
|
+
readonly launchStats: LaunchStatsHandle = {
|
|
107
|
+
onStart: (category) => {
|
|
108
|
+
this.subprocessTotal++;
|
|
109
|
+
this.subprocessInFlight++;
|
|
110
|
+
this.subprocessCount.set(
|
|
111
|
+
category,
|
|
112
|
+
(this.subprocessCount.get(category) ?? 0) + 1,
|
|
113
|
+
);
|
|
114
|
+
this.recordRollingNow();
|
|
115
|
+
},
|
|
116
|
+
onEnd: (category, durationMs) => {
|
|
117
|
+
this.subprocessInFlight = Math.max(0, this.subprocessInFlight - 1);
|
|
118
|
+
this.recordDuration(category, durationMs);
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
private recordRollingNow(): void {
|
|
123
|
+
const now = Date.now();
|
|
124
|
+
if (this.rollingTimestamps.length < ROLLING_BUFFER_CAP) {
|
|
125
|
+
this.rollingTimestamps.push(now);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
// Ring buffer once cap hit. The overwritten slot may still be inside the
|
|
129
|
+
// 60s window — see the cap-rationale comment at ROLLING_BUFFER_CAP.
|
|
130
|
+
this.rollingTimestamps[this.rollingHead] = now;
|
|
131
|
+
this.rollingHead = (this.rollingHead + 1) % ROLLING_BUFFER_CAP;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private recordDuration(category: LaunchCategory, durationMs: number): void {
|
|
135
|
+
let hist = this.subprocessHistogram.get(category);
|
|
136
|
+
if (!hist) {
|
|
137
|
+
hist = [];
|
|
138
|
+
this.subprocessHistogram.set(category, hist);
|
|
139
|
+
}
|
|
140
|
+
if (hist.length < HISTOGRAM_CAP) {
|
|
141
|
+
hist.push(durationMs);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const idx =
|
|
145
|
+
(this.subprocessHistogramRotator.get(category) ?? 0) % HISTOGRAM_CAP;
|
|
146
|
+
hist[idx] = durationMs;
|
|
147
|
+
this.subprocessHistogramRotator.set(category, idx + 1);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private snapshotSubprocesses(): StatsSnapshot["subprocesses"] {
|
|
151
|
+
const cutoff = Date.now() - ROLLING_WINDOW_MS;
|
|
152
|
+
let lastMinute = 0;
|
|
153
|
+
for (const ts of this.rollingTimestamps) {
|
|
154
|
+
if (ts >= cutoff) lastMinute++;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// [LAW:one-source-of-truth] Snapshot includes only categories that have
|
|
158
|
+
// actually executed — same shape as p50/p99 below. Consumers wanting the
|
|
159
|
+
// full closed list can read LAUNCH_CATEGORIES (exported from src/proc/launch)
|
|
160
|
+
// and treat missing keys as zero.
|
|
161
|
+
const byCategory: Record<string, number> = {};
|
|
162
|
+
for (const [cat, n] of this.subprocessCount) {
|
|
163
|
+
if (n > 0) byCategory[cat] = n;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const p50: Record<string, number> = {};
|
|
167
|
+
const p99: Record<string, number> = {};
|
|
168
|
+
for (const [cat, hist] of this.subprocessHistogram) {
|
|
169
|
+
if (hist.length === 0) continue;
|
|
170
|
+
const sorted = [...hist].sort((a, b) => a - b);
|
|
171
|
+
const p50idx = Math.floor(sorted.length * 0.5);
|
|
172
|
+
const p99idx = Math.min(
|
|
173
|
+
sorted.length - 1,
|
|
174
|
+
Math.floor(sorted.length * 0.99),
|
|
175
|
+
);
|
|
176
|
+
const p50val = sorted[p50idx];
|
|
177
|
+
const p99val = sorted[p99idx];
|
|
178
|
+
if (p50val !== undefined) p50[cat] = p50val;
|
|
179
|
+
if (p99val !== undefined) p99[cat] = p99val;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
total: this.subprocessTotal,
|
|
184
|
+
inFlight: this.subprocessInFlight,
|
|
185
|
+
lastMinute,
|
|
186
|
+
byCategory,
|
|
187
|
+
p50DurationMs: p50,
|
|
188
|
+
p99DurationMs: p99,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
snapshot(extras: {
|
|
193
|
+
gitCache: StatsSnapshot["gitCache"];
|
|
194
|
+
usageCache: StatsSnapshot["usageCache"];
|
|
195
|
+
renderCacheSize: number;
|
|
196
|
+
watchersActive: number;
|
|
197
|
+
nextRestartReason?: string | null;
|
|
198
|
+
}): StatsSnapshot {
|
|
199
|
+
const mem = process.memoryUsage();
|
|
200
|
+
return {
|
|
201
|
+
pid: process.pid,
|
|
202
|
+
version: PROTOCOL_VERSION,
|
|
203
|
+
startedAt: this.startedAt.toISOString(),
|
|
204
|
+
uptimeSec: Math.floor((Date.now() - this.startedAt.getTime()) / 1000),
|
|
205
|
+
rssBytes: mem.rss,
|
|
206
|
+
heapUsedBytes: mem.heapUsed,
|
|
207
|
+
heapTotalBytes: mem.heapTotal,
|
|
208
|
+
externalBytes: mem.external,
|
|
209
|
+
arrayBuffersBytes: mem.arrayBuffers,
|
|
210
|
+
requests: {
|
|
211
|
+
total: this.requestsTotal,
|
|
212
|
+
errored: this.requestsErrored,
|
|
213
|
+
timedOut: this.requestsTimedOut,
|
|
214
|
+
inFlight: this.inFlight,
|
|
215
|
+
},
|
|
216
|
+
gitCache: extras.gitCache,
|
|
217
|
+
usageCache: extras.usageCache,
|
|
218
|
+
renderCache: { size: extras.renderCacheSize },
|
|
219
|
+
watchers: {
|
|
220
|
+
active: extras.watchersActive,
|
|
221
|
+
opened: this.watchersOpened,
|
|
222
|
+
closed: this.watchersClosed,
|
|
223
|
+
evicted: this.watchersEvicted,
|
|
224
|
+
},
|
|
225
|
+
subprocesses: this.snapshotSubprocesses(),
|
|
226
|
+
nextRestartReason: extras.nextRestartReason ?? null,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
}
|