@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,91 @@
|
|
|
1
|
+
// [LAW:types-are-the-program] Wire-level snapshot shapes for the `debug`
|
|
2
|
+
// protocol message. The `what` discriminator carries the response shape
|
|
3
|
+
// forward — a client requesting "vars" gets vars data, "segments" gets
|
|
4
|
+
// segments data, "config" gets config data. There is no `data: unknown`
|
|
5
|
+
// field that callers must shape-check.
|
|
6
|
+
//
|
|
7
|
+
// [LAW:one-source-of-truth] These types are the single contract between the
|
|
8
|
+
// daemon's introspection layer (src/daemon/debug.ts) and any external
|
|
9
|
+
// consumer (the future `cc-candybar vars` / `lint` CLIs, debug tooling).
|
|
10
|
+
// The protocol wraps them; the introspector produces them.
|
|
11
|
+
//
|
|
12
|
+
// Lives in its own module to break a potential cycle: protocol.ts re-exports
|
|
13
|
+
// these for the wire type, debug.ts produces them, and both could otherwise
|
|
14
|
+
// pull each other in.
|
|
15
|
+
|
|
16
|
+
import type { VarType, VarValue } from "../var-system/types";
|
|
17
|
+
import type { DslConfig, SourceKind } from "../config/dsl-types";
|
|
18
|
+
|
|
19
|
+
// [LAW:one-source-of-truth] DEBUG_WHATS is the canonical list; DebugWhat is
|
|
20
|
+
// *derived* from it via indexed access on the `as const` tuple type. Adding
|
|
21
|
+
// a new entry to the array automatically expands DebugWhat — and the switch
|
|
22
|
+
// in buildDebugSnapshot (src/daemon/debug.ts) becomes non-exhaustive,
|
|
23
|
+
// failing the typecheck until a new arm is added. The dispatcher arm and
|
|
24
|
+
// the canonical list are kept in lockstep by the type system, not by
|
|
25
|
+
// human discipline.
|
|
26
|
+
export const DEBUG_WHATS = ["vars", "segments", "config"] as const;
|
|
27
|
+
export type DebugWhat = (typeof DEBUG_WHATS)[number];
|
|
28
|
+
|
|
29
|
+
// Wire-side type guard for an untrusted JSON value. Used by the daemon at
|
|
30
|
+
// the request boundary; symmetrically usable by future CLI shims that
|
|
31
|
+
// validate user input before sending the frame.
|
|
32
|
+
// [LAW:one-source-of-truth] Implemented in terms of DEBUG_WHATS so the
|
|
33
|
+
// guard cannot drift from the canonical list — a new `what` added to
|
|
34
|
+
// DEBUG_WHATS becomes accepted by isDebugWhat with no second-site edit.
|
|
35
|
+
export function isDebugWhat(v: unknown): v is DebugWhat {
|
|
36
|
+
return (
|
|
37
|
+
typeof v === "string" && (DEBUG_WHATS as readonly string[]).includes(v)
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// One row of the `vars` snapshot. Captures everything an operator needs to
|
|
42
|
+
// answer "is this variable computing? did it fail? when did it last update?"
|
|
43
|
+
// without re-reading config or grepping logs.
|
|
44
|
+
export interface VarSnapshot {
|
|
45
|
+
readonly name: string;
|
|
46
|
+
// The DSL source kind that declared this variable (literal/env/input/...).
|
|
47
|
+
// null when a variable exists in the store but was not declared via DSL —
|
|
48
|
+
// present for completeness so a programmatic-only var still shows up in
|
|
49
|
+
// introspection rather than being silently invisible.
|
|
50
|
+
readonly source: SourceKind | null;
|
|
51
|
+
readonly type: VarType;
|
|
52
|
+
// Current store value. For Computed nodes this triggers re-evaluation if
|
|
53
|
+
// MobX has invalidated the cache; for Box nodes it is the last value set.
|
|
54
|
+
readonly value: VarValue;
|
|
55
|
+
readonly lastError: {
|
|
56
|
+
readonly timestampMs: number;
|
|
57
|
+
readonly message: string;
|
|
58
|
+
} | null;
|
|
59
|
+
// Wall-clock ms since the box was last set (Box) or null for Computed
|
|
60
|
+
// nodes whose freshness is governed by MobX invalidation, not a single
|
|
61
|
+
// timestamp. Null is structurally distinct from 0 so consumers can
|
|
62
|
+
// distinguish "no age tracking applies" from "just updated."
|
|
63
|
+
readonly ageMs: number | null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// One row of the `segments` snapshot.
|
|
67
|
+
export interface SegmentSnapshot {
|
|
68
|
+
readonly name: string;
|
|
69
|
+
// The template source string (verbatim, as authored in the config).
|
|
70
|
+
readonly template: string;
|
|
71
|
+
// Names of variables potentially referenced by the template, found via
|
|
72
|
+
// static analysis: dotted paths inside `{{ ... }}` actions, matched
|
|
73
|
+
// against the store's declared names. Exact runtime deps may be a subset
|
|
74
|
+
// (branches not taken), but every name returned IS in the store.
|
|
75
|
+
readonly referencedVars: readonly string[];
|
|
76
|
+
// The last rendered output for this segment, when the daemon has captured
|
|
77
|
+
// one. null today — the daemon does not yet render through the DSL spine
|
|
78
|
+
// (see bzh.2). Populated when the daemon flips to renderDsl.
|
|
79
|
+
readonly lastRender: string | null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// [LAW:types-are-the-program] The discriminated union ensures every response
|
|
83
|
+
// carries exactly the data shape its `what` promises. There is no path that
|
|
84
|
+
// produces e.g. `{ what: "vars", segments: [...] }` — the type forbids it.
|
|
85
|
+
export type DebugSnapshot =
|
|
86
|
+
| { readonly what: "vars"; readonly vars: readonly VarSnapshot[] }
|
|
87
|
+
| {
|
|
88
|
+
readonly what: "segments";
|
|
89
|
+
readonly segments: readonly SegmentSnapshot[];
|
|
90
|
+
}
|
|
91
|
+
| { readonly what: "config"; readonly config: DslConfig | null };
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// [LAW:single-enforcer] One module owns the projection from daemon DSL
|
|
2
|
+
// state (VariableStore + SourceRegistry + DslConfig + CompiledConfig) to
|
|
3
|
+
// the wire-level DebugSnapshot. buildDebugSnapshot dispatches via an
|
|
4
|
+
// exhaustive switch on `what` — TypeScript enforces every DebugWhat arm
|
|
5
|
+
// at compile time (the function's return-type narrowing fails if a case
|
|
6
|
+
// is missing), so adding a new `what` requires one new arm here, one new
|
|
7
|
+
// DEBUG_WHATS entry, and one new DebugSnapshot variant — the type system
|
|
8
|
+
// keeps the three sites in lockstep.
|
|
9
|
+
//
|
|
10
|
+
// [LAW:one-source-of-truth] The introspector reads through the live
|
|
11
|
+
// VariableStore (current values), SourceRegistry (lastErrors), VarNode
|
|
12
|
+
// (lastUpdatedMs), and DslConfig (declared source kinds, segment templates).
|
|
13
|
+
// There is no parallel cache, no shadow snapshot kept in sync — the daemon
|
|
14
|
+
// has one DSL state and this module projects it.
|
|
15
|
+
//
|
|
16
|
+
// [LAW:dataflow-not-control-flow] The state slot (DaemonDslState | null) is
|
|
17
|
+
// the only branch: null state → empty snapshots; populated state → real
|
|
18
|
+
// snapshots. No special-case introspection paths for the "DSL not active
|
|
19
|
+
// yet" case — the same code produces both outcomes from the same data.
|
|
20
|
+
//
|
|
21
|
+
// Today the daemon does not yet hold a DSL state (bzh.2 has not fired); all
|
|
22
|
+
// snapshots are empty in production. When bzh.2 wires the store, the
|
|
23
|
+
// snapshots populate without any change to this module or the protocol.
|
|
24
|
+
|
|
25
|
+
import type { VariableStore } from "../var-system/store";
|
|
26
|
+
import type { SourceRegistry } from "../var-system/sources";
|
|
27
|
+
import type { DslConfig, SourceKind, VariableDecl } from "../config/dsl-types";
|
|
28
|
+
import { walkNodes } from "../config/dsl-types";
|
|
29
|
+
import { extractTemplateRefs } from "../config/dsl-loader";
|
|
30
|
+
import type { CompiledConfig } from "../dsl/render";
|
|
31
|
+
import type {
|
|
32
|
+
DebugSnapshot,
|
|
33
|
+
DebugWhat,
|
|
34
|
+
SegmentSnapshot,
|
|
35
|
+
VarSnapshot,
|
|
36
|
+
} from "./debug-types";
|
|
37
|
+
|
|
38
|
+
// The daemon's DSL state. Bundled because the four fields are co-installed
|
|
39
|
+
// by registerDslConfig — exposing them as independently-optional would let
|
|
40
|
+
// callers represent illegal combinations (e.g. store with no config).
|
|
41
|
+
export interface DaemonDslState {
|
|
42
|
+
readonly store: VariableStore;
|
|
43
|
+
readonly registry: SourceRegistry;
|
|
44
|
+
readonly config: DslConfig;
|
|
45
|
+
readonly compiled: CompiledConfig;
|
|
46
|
+
// [LAW:dataflow-not-control-flow] Per-segment last-render strings live in
|
|
47
|
+
// the same state bundle the renderer mutates. Today (legacy renderer) it
|
|
48
|
+
// is empty; bzh.2 populates it from inside renderDsl.
|
|
49
|
+
readonly lastRenderBySegment: ReadonlyMap<string, string>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ─── Dispatcher ──────────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
// [LAW:dataflow-not-control-flow] One arm per `what` — the discriminator is
|
|
55
|
+
// data; the branch chooses the projection function, not whether projection
|
|
56
|
+
// runs. Adding a new `what` is one new arm + one new projection + one new
|
|
57
|
+
// DebugSnapshot variant — the type system enforces all three.
|
|
58
|
+
export function buildDebugSnapshot(
|
|
59
|
+
what: DebugWhat,
|
|
60
|
+
state: DaemonDslState | null,
|
|
61
|
+
): DebugSnapshot {
|
|
62
|
+
switch (what) {
|
|
63
|
+
case "vars":
|
|
64
|
+
return { what, vars: introspectVars(state) };
|
|
65
|
+
case "segments":
|
|
66
|
+
return { what, segments: introspectSegments(state) };
|
|
67
|
+
case "config":
|
|
68
|
+
return { what, config: introspectConfig(state) };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ─── vars ────────────────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
// Project every variable currently registered in the store into one
|
|
75
|
+
// VarSnapshot. Names sorted for deterministic snapshot tests. Source kind
|
|
76
|
+
// is looked up from the DslConfig (top-level variables + per-segment
|
|
77
|
+
// `vars` blocks); a variable not found in either is reported with
|
|
78
|
+
// source=null so it still appears in introspection.
|
|
79
|
+
export function introspectVars(
|
|
80
|
+
state: DaemonDslState | null,
|
|
81
|
+
): readonly VarSnapshot[] {
|
|
82
|
+
if (state === null) return [];
|
|
83
|
+
|
|
84
|
+
const { store, registry, config } = state;
|
|
85
|
+
const sourceByName = buildSourceKindIndex(config);
|
|
86
|
+
const names = store.names().sort();
|
|
87
|
+
|
|
88
|
+
const out: VarSnapshot[] = [];
|
|
89
|
+
for (const name of names) {
|
|
90
|
+
// [LAW:single-enforcer] One requireNode lookup per row. Reading the
|
|
91
|
+
// value through node.read() instead of store.read(name) avoids a
|
|
92
|
+
// second Map.get + requireNode round-trip in the inner loop and
|
|
93
|
+
// makes the per-row data dependency obvious: every field below
|
|
94
|
+
// comes from this one node.
|
|
95
|
+
const node = store.getNode(name);
|
|
96
|
+
const err = registry.getLastError(name);
|
|
97
|
+
// [LAW:no-defensive-null-guards] No try/catch around node.read():
|
|
98
|
+
// every SourceRegistry-declared variable either holds a typed
|
|
99
|
+
// fallback (declareShell/declareFile/declareGit/declareInput catch
|
|
100
|
+
// internally and write a fallback) or is a computed whose deriver
|
|
101
|
+
// also catches (declareTemplate). Cycles are detected eagerly at
|
|
102
|
+
// register time (declareTemplate's force-read). So a read-throw
|
|
103
|
+
// here would be a *programming* error, not a runtime condition
|
|
104
|
+
// the snapshot should mask. Letting it propagate keeps the failure
|
|
105
|
+
// loud at the source instead of laundering it as a synthesized
|
|
106
|
+
// lastError with an unstable Date.now() timestamp.
|
|
107
|
+
//
|
|
108
|
+
// [LAW:single-enforcer] lastError is sourced from SourceRegistry
|
|
109
|
+
// only. There is no second timestamp-producer that could drift
|
|
110
|
+
// from the registry's record.
|
|
111
|
+
out.push({
|
|
112
|
+
name,
|
|
113
|
+
source: sourceByName.get(name) ?? null,
|
|
114
|
+
type: node.type,
|
|
115
|
+
value: node.read(),
|
|
116
|
+
lastError:
|
|
117
|
+
err !== undefined
|
|
118
|
+
? { timestampMs: err.timestamp, message: err.message }
|
|
119
|
+
: null,
|
|
120
|
+
ageMs: ageFromNode(node.lastUpdatedMs()),
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function ageFromNode(lastUpdatedMs: number | null): number | null {
|
|
127
|
+
if (lastUpdatedMs === null) return null;
|
|
128
|
+
return Math.max(0, Date.now() - lastUpdatedMs);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Build a name → SourceKind index from the DslConfig. Walks top-level
|
|
132
|
+
// variables and each segment's per-segment vars block; segment-local vars
|
|
133
|
+
// live under the namespaced key `<segName>.<varName>` in the store (same
|
|
134
|
+
// shape registerDslConfig uses to declare them).
|
|
135
|
+
function buildSourceKindIndex(
|
|
136
|
+
config: DslConfig,
|
|
137
|
+
): ReadonlyMap<string, SourceKind> {
|
|
138
|
+
const index = new Map<string, SourceKind>();
|
|
139
|
+
for (const [name, decl] of Object.entries(config.variables)) {
|
|
140
|
+
index.set(name, sourceKindOf(decl));
|
|
141
|
+
}
|
|
142
|
+
for (const [segName, seg] of Object.entries(config.segments)) {
|
|
143
|
+
if (!seg.vars) continue;
|
|
144
|
+
for (const [varName, decl] of Object.entries(seg.vars)) {
|
|
145
|
+
index.set(`${segName}.${varName}`, sourceKindOf(decl));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return index;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function sourceKindOf(decl: VariableDecl): SourceKind {
|
|
152
|
+
return decl.kind;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ─── segments ────────────────────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
export function introspectSegments(
|
|
158
|
+
state: DaemonDslState | null,
|
|
159
|
+
): readonly SegmentSnapshot[] {
|
|
160
|
+
if (state === null) return [];
|
|
161
|
+
|
|
162
|
+
const { store, config, lastRenderBySegment } = state;
|
|
163
|
+
const declaredNames = new Set(store.names());
|
|
164
|
+
// [LAW:dataflow-not-control-flow] Walk in layout order so the introspection
|
|
165
|
+
// snapshot mirrors render order — operators reading the snapshot see the
|
|
166
|
+
// same sequence the bar produces, not an alphabetical reshuffling.
|
|
167
|
+
const segNames = orderedSegmentNames(config);
|
|
168
|
+
|
|
169
|
+
const out: SegmentSnapshot[] = [];
|
|
170
|
+
for (const name of segNames) {
|
|
171
|
+
const seg = config.segments[name];
|
|
172
|
+
if (!seg) continue;
|
|
173
|
+
out.push({
|
|
174
|
+
name,
|
|
175
|
+
template: seg.template,
|
|
176
|
+
referencedVars: extractReferencedVars(seg.template, declaredNames),
|
|
177
|
+
lastRender: lastRenderBySegment.get(name) ?? null,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Layout order, with any declared-but-not-laid-out segments appended in
|
|
184
|
+
// declaration order so they still appear in the snapshot (an operator
|
|
185
|
+
// debugging "why isn't this rendering" wants to see the segment, not have
|
|
186
|
+
// it filtered out for being absent from layout).
|
|
187
|
+
function orderedSegmentNames(config: DslConfig): readonly string[] {
|
|
188
|
+
const out: string[] = [];
|
|
189
|
+
const seen = new Set<string>();
|
|
190
|
+
for (const node of walkNodes(config.root)) {
|
|
191
|
+
if (node.kind !== "segment") continue;
|
|
192
|
+
if (config.segments[node.name] && !seen.has(node.name)) {
|
|
193
|
+
out.push(node.name);
|
|
194
|
+
seen.add(node.name);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
for (const name of Object.keys(config.segments)) {
|
|
198
|
+
if (!seen.has(name)) {
|
|
199
|
+
out.push(name);
|
|
200
|
+
seen.add(name);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return out;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// [LAW:single-enforcer] Static analysis of which variables a segment
|
|
207
|
+
// template references. Raw candidate extraction (find dotted paths inside
|
|
208
|
+
// `{{ ... }}` actions, strip string literals so `{{ printf ".foo" }}`
|
|
209
|
+
// does not falsely match a declared `foo`) is delegated to
|
|
210
|
+
// extractTemplateRefs in src/config/dsl-loader.ts — that helper already
|
|
211
|
+
// owns the template-ref parsing rules and is exercised by the loader's
|
|
212
|
+
// cycle detector. Reusing it means a future improvement to the parser
|
|
213
|
+
// (e.g. supporting `$x.field` variable references) lands here for free.
|
|
214
|
+
//
|
|
215
|
+
// Static analysis (not runtime evaluation) is the right tool here:
|
|
216
|
+
// evaluation would couple introspection to a working store and would
|
|
217
|
+
// vary by current values (if/with branches taken). Static finds every
|
|
218
|
+
// potentially-referenced name regardless of current state — which is
|
|
219
|
+
// what an operator debugging "what does this segment depend on" needs.
|
|
220
|
+
//
|
|
221
|
+
// This function adds the introspection-specific layers on top of the raw
|
|
222
|
+
// extraction:
|
|
223
|
+
// 1. Intersect with the declared-name set (only report names that exist).
|
|
224
|
+
// 2. Ancestor credit: a candidate `.session.id.extra` resolves to the
|
|
225
|
+
// declared `session.id` if `extra` is not declared.
|
|
226
|
+
// 3. Sort the result for deterministic snapshots.
|
|
227
|
+
export function extractReferencedVars(
|
|
228
|
+
template: string,
|
|
229
|
+
declared: ReadonlySet<string>,
|
|
230
|
+
): readonly string[] {
|
|
231
|
+
const found = new Set<string>();
|
|
232
|
+
for (const candidate of extractTemplateRefs(template)) {
|
|
233
|
+
if (declared.has(candidate)) {
|
|
234
|
+
found.add(candidate);
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
// Drop trailing segments until we hit a declared name. Handles
|
|
238
|
+
// `.session.id.something_extra` where only `session.id` is declared —
|
|
239
|
+
// still credit it as a reference to `session.id`.
|
|
240
|
+
const parts = candidate.split(".");
|
|
241
|
+
while (parts.length > 1) {
|
|
242
|
+
parts.pop();
|
|
243
|
+
const prefix = parts.join(".");
|
|
244
|
+
if (declared.has(prefix)) {
|
|
245
|
+
found.add(prefix);
|
|
246
|
+
break;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return Array.from(found).sort();
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ─── config ──────────────────────────────────────────────────────────────────
|
|
254
|
+
|
|
255
|
+
// [LAW:no-defensive-null-guards] No defensive copy. DslConfig is `readonly`
|
|
256
|
+
// throughout and the wire-encoder JSON-serializes it; downstream callers
|
|
257
|
+
// see a fresh value. Returning the live reference is the cheapest correct
|
|
258
|
+
// answer.
|
|
259
|
+
export function introspectConfig(
|
|
260
|
+
state: DaemonDslState | null,
|
|
261
|
+
): DslConfig | null {
|
|
262
|
+
if (state === null) return null;
|
|
263
|
+
return state.config;
|
|
264
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import v8 from "node:v8";
|
|
4
|
+
import { daemonDir } from "./paths";
|
|
5
|
+
import { dlog, type DaemonLogger } from "./log";
|
|
6
|
+
|
|
7
|
+
// [LAW:single-enforcer] One module owns "when does the daemon plan to die".
|
|
8
|
+
// Only the RSS trigger remains — idle and age limits were removed because they
|
|
9
|
+
// interrupted active sessions. The RSS limit is a true anomaly backstop; normal
|
|
10
|
+
// operation should never approach it now that transcript parsing is pruned.
|
|
11
|
+
const DEFAULT_RSS_LIMIT =
|
|
12
|
+
(parseInt(process.env["CC_CANDYBAR_RSS_LIMIT_MB"] ?? "", 10) || 512) *
|
|
13
|
+
1024 *
|
|
14
|
+
1024;
|
|
15
|
+
const DEFAULT_CHECK_INTERVAL = 60 * 1000;
|
|
16
|
+
const HEAP_SNAPSHOT_KEEP = 3;
|
|
17
|
+
|
|
18
|
+
export interface LimitsDeps {
|
|
19
|
+
now: () => number;
|
|
20
|
+
// [LAW:locality-or-seam] The snapshot directory, the log sink, and the
|
|
21
|
+
// writer's identity are injected, not reached for ambiently. Without these,
|
|
22
|
+
// unit tests of checkRss compute filenames against the real daemonDir() and
|
|
23
|
+
// emit real dlog lines into the user's production daemon.log — the seam must
|
|
24
|
+
// cover every dependency or it isn't a seam.
|
|
25
|
+
pid: number;
|
|
26
|
+
snapshotDir: string;
|
|
27
|
+
log: DaemonLogger;
|
|
28
|
+
rssBytes: () => number;
|
|
29
|
+
writeHeapSnapshot: (filePath: string) => string;
|
|
30
|
+
listSnapshots: () => string[];
|
|
31
|
+
removeFile: (filePath: string) => void;
|
|
32
|
+
shutdown: (code: number) => void;
|
|
33
|
+
startedAtMs: number;
|
|
34
|
+
rssLimitBytes?: number;
|
|
35
|
+
snapshotsKeep?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface LimitsHandle {
|
|
39
|
+
checkRss(): boolean;
|
|
40
|
+
describeNextRestart(): string | null;
|
|
41
|
+
arm(intervalMs?: number): { disarm(): void };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function makeLimits(deps: LimitsDeps): LimitsHandle {
|
|
45
|
+
const rssLimit = deps.rssLimitBytes ?? DEFAULT_RSS_LIMIT;
|
|
46
|
+
const keep = deps.snapshotsKeep ?? HEAP_SNAPSHOT_KEEP;
|
|
47
|
+
let triggered = false;
|
|
48
|
+
|
|
49
|
+
function checkRss(): boolean {
|
|
50
|
+
if (triggered) return true;
|
|
51
|
+
const rss = deps.rssBytes();
|
|
52
|
+
if (rss <= rssLimit) return false;
|
|
53
|
+
triggered = true;
|
|
54
|
+
deps.log(
|
|
55
|
+
"warn",
|
|
56
|
+
`RSS ${rss} > limit ${rssLimit}; writing heap snapshot then shutting down`,
|
|
57
|
+
);
|
|
58
|
+
try {
|
|
59
|
+
// [LAW:types-are-the-program] Uniqueness is by construction (the writer's
|
|
60
|
+
// pid), not by trusting the clock to be real and sub-ms-distinct. Two
|
|
61
|
+
// overlapping daemons hitting the wall in the same millisecond — or a
|
|
62
|
+
// frozen `now` — still produce distinct files; the timestamp stays the
|
|
63
|
+
// leading component so rotateSnapshots' newest-first ordering holds.
|
|
64
|
+
const stamp = new Date(deps.now()).toISOString().replace(/[:.]/g, "-");
|
|
65
|
+
const file = path.join(
|
|
66
|
+
deps.snapshotDir,
|
|
67
|
+
`heap-${stamp}-${deps.pid}.heapsnapshot`,
|
|
68
|
+
);
|
|
69
|
+
const written = deps.writeHeapSnapshot(file);
|
|
70
|
+
deps.log("info", `heap snapshot written: ${written}`);
|
|
71
|
+
rotateSnapshots(deps.listSnapshots(), keep, deps.removeFile);
|
|
72
|
+
} catch (e) {
|
|
73
|
+
deps.log("warn", `heap snapshot failed: ${(e as Error).message}`);
|
|
74
|
+
}
|
|
75
|
+
deps.shutdown(0);
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function describeNextRestart(): string | null {
|
|
80
|
+
const rss = deps.rssBytes();
|
|
81
|
+
if (rss > rssLimit * 0.75) {
|
|
82
|
+
return `rss ${rss} approaching limit ${rssLimit}`;
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function arm(intervalMs: number = DEFAULT_CHECK_INTERVAL): {
|
|
88
|
+
disarm(): void;
|
|
89
|
+
} {
|
|
90
|
+
const timer = setInterval(() => {
|
|
91
|
+
checkRss();
|
|
92
|
+
}, intervalMs);
|
|
93
|
+
timer.unref();
|
|
94
|
+
return {
|
|
95
|
+
disarm: () => clearInterval(timer),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { checkRss, describeNextRestart, arm };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function rotateSnapshots(
|
|
103
|
+
files: string[],
|
|
104
|
+
keep: number,
|
|
105
|
+
remove: (p: string) => void,
|
|
106
|
+
): void {
|
|
107
|
+
// Newest-first by basename (the leading ISO timestamp is lexically ordered;
|
|
108
|
+
// the trailing -<pid> only tiebreaks same-instant writes). Sort by basename
|
|
109
|
+
// so paths with different parent dirs still order correctly when the test
|
|
110
|
+
// mock and production use different prefixes.
|
|
111
|
+
const sorted = [...files].sort((a, b) => {
|
|
112
|
+
const aBase = a.slice(a.lastIndexOf("/") + 1);
|
|
113
|
+
const bBase = b.slice(b.lastIndexOf("/") + 1);
|
|
114
|
+
return bBase.localeCompare(aBase);
|
|
115
|
+
});
|
|
116
|
+
for (const f of sorted.slice(keep)) {
|
|
117
|
+
try {
|
|
118
|
+
remove(f);
|
|
119
|
+
} catch {}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Default real-fs deps for the daemon. Test code constructs its own.
|
|
124
|
+
export function realLimitsDeps(
|
|
125
|
+
startedAtMs: number,
|
|
126
|
+
shutdown: (code: number) => void,
|
|
127
|
+
overrides: Partial<LimitsDeps> = {},
|
|
128
|
+
): LimitsDeps {
|
|
129
|
+
// [LAW:one-source-of-truth] One captured dir backs both the new-snapshot path
|
|
130
|
+
// and the listing used for rotation, so they can never read different dirs.
|
|
131
|
+
const dir = daemonDir();
|
|
132
|
+
return {
|
|
133
|
+
now: () => Date.now(),
|
|
134
|
+
pid: process.pid,
|
|
135
|
+
snapshotDir: dir,
|
|
136
|
+
log: dlog,
|
|
137
|
+
rssBytes: () => process.memoryUsage().rss,
|
|
138
|
+
writeHeapSnapshot: (file) => v8.writeHeapSnapshot(file),
|
|
139
|
+
listSnapshots: () => {
|
|
140
|
+
try {
|
|
141
|
+
return fs
|
|
142
|
+
.readdirSync(dir)
|
|
143
|
+
.filter((f) => f.startsWith("heap-") && f.endsWith(".heapsnapshot"))
|
|
144
|
+
.map((f) => path.join(dir, f));
|
|
145
|
+
} catch {
|
|
146
|
+
return [];
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
removeFile: (file) => fs.unlinkSync(file),
|
|
150
|
+
shutdown,
|
|
151
|
+
startedAtMs,
|
|
152
|
+
...overrides,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { logPath } from "./paths";
|
|
4
|
+
|
|
5
|
+
const MAX_BYTES = 5 * 1024 * 1024;
|
|
6
|
+
const KEEP_GENERATIONS = 3;
|
|
7
|
+
|
|
8
|
+
let stream: fs.WriteStream | null = null;
|
|
9
|
+
let bytesWritten = 0;
|
|
10
|
+
|
|
11
|
+
function ensureStream(): fs.WriteStream {
|
|
12
|
+
if (stream) return stream;
|
|
13
|
+
const filePath = logPath();
|
|
14
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
15
|
+
// Pre-load size so rotation triggers correctly across daemon restarts.
|
|
16
|
+
try {
|
|
17
|
+
bytesWritten = fs.statSync(filePath).size;
|
|
18
|
+
} catch {
|
|
19
|
+
bytesWritten = 0;
|
|
20
|
+
}
|
|
21
|
+
stream = fs.createWriteStream(filePath, { flags: "a" });
|
|
22
|
+
return stream;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Self-rotation: when daemon.log exceeds MAX_BYTES, shift .1→.2, .2→.3, drop
|
|
26
|
+
// the oldest, and start fresh. Daemon-internal so we don't depend on any
|
|
27
|
+
// external rotator. Cheap because rotation only runs at the rollover boundary.
|
|
28
|
+
function rotate(): void {
|
|
29
|
+
const filePath = logPath();
|
|
30
|
+
if (stream) {
|
|
31
|
+
stream.end();
|
|
32
|
+
stream = null;
|
|
33
|
+
}
|
|
34
|
+
for (let i = KEEP_GENERATIONS - 1; i >= 1; i--) {
|
|
35
|
+
const src = `${filePath}.${i}`;
|
|
36
|
+
const dst = `${filePath}.${i + 1}`;
|
|
37
|
+
try {
|
|
38
|
+
fs.renameSync(src, dst);
|
|
39
|
+
} catch {}
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
fs.renameSync(filePath, `${filePath}.1`);
|
|
43
|
+
} catch {}
|
|
44
|
+
bytesWritten = 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// [LAW:locality-or-seam] The logging capability daemon components depend on.
|
|
48
|
+
// `dlog` is the daemon's implementation (writes to daemon.log); consumers that
|
|
49
|
+
// inject a different impl (a quiet default in tests) take this shape.
|
|
50
|
+
export type DaemonLogger = (
|
|
51
|
+
level: "info" | "warn" | "error",
|
|
52
|
+
msg: string,
|
|
53
|
+
) => void;
|
|
54
|
+
|
|
55
|
+
export function dlog(level: "info" | "warn" | "error", msg: string): void {
|
|
56
|
+
const line = `${new Date().toISOString()} [${level}] ${msg}\n`;
|
|
57
|
+
const buf = Buffer.from(line, "utf8");
|
|
58
|
+
const s = ensureStream();
|
|
59
|
+
s.write(buf);
|
|
60
|
+
bytesWritten += buf.length;
|
|
61
|
+
if (bytesWritten >= MAX_BYTES) rotate();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function closeLog(): void {
|
|
65
|
+
if (stream) {
|
|
66
|
+
stream.end();
|
|
67
|
+
stream = null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import process from "node:process";
|
|
2
|
+
|
|
3
|
+
// [LAW:single-enforcer] One owner of the invariant "I must not outlive the
|
|
4
|
+
// process that spawned me." A *production* daemon is spawned detached and is
|
|
5
|
+
// SUPPOSED to outlive its spawner — the render-tick client exits, leaving a
|
|
6
|
+
// warm daemon — so it is anchored to nobody and this watchdog never fires. A
|
|
7
|
+
// daemon spawned by a *transient* process (the Jest worker) must die WITH it:
|
|
8
|
+
// on abnormal exit (SIGKILL, worker crash, suite timeout) the OS reparents the
|
|
9
|
+
// orphan to init and it survives forever. That orphan-to-init survival is the
|
|
10
|
+
// test-daemon leak. The spawner publishes its pid in the environment and every
|
|
11
|
+
// descendant inherits it, so even a detached grand-child daemon stays anchored
|
|
12
|
+
// to the original runner.
|
|
13
|
+
//
|
|
14
|
+
// [LAW:dataflow-not-control-flow] The watchdog runs the same poll every tick;
|
|
15
|
+
// whether it ever trips lives in the anchor VALUE (a pid to outlive, or
|
|
16
|
+
// nobody), derived once from the environment — never in a branch wrapped around
|
|
17
|
+
// the spawn path. Like the RSS backstop in `limits.ts`, it calls `onOrphaned`
|
|
18
|
+
// (the lifecycle `shutdown`) rather than exiting itself, so every daemon-death
|
|
19
|
+
// path funnels through the one enforcer.
|
|
20
|
+
|
|
21
|
+
export const PARENT_PID_ENV = "CC_CANDYBAR_PARENT_PID";
|
|
22
|
+
|
|
23
|
+
const DEFAULT_POLL_INTERVAL_MS = 1000;
|
|
24
|
+
|
|
25
|
+
export type LivenessAnchor =
|
|
26
|
+
| { kind: "outlives-nobody" }
|
|
27
|
+
| { kind: "anchored"; pid: number };
|
|
28
|
+
|
|
29
|
+
// [LAW:no-silent-fallbacks] Three inputs, three outcomes, no overlap: absent →
|
|
30
|
+
// production (outlive nobody); a positive integer → anchor to it; present but
|
|
31
|
+
// malformed → throw. Only the test harness ever sets this variable, so a
|
|
32
|
+
// malformed value is a harness bug; silently degrading to "outlives-nobody"
|
|
33
|
+
// would re-open the very leak this module closes.
|
|
34
|
+
export function anchorFromEnv(env: NodeJS.ProcessEnv): LivenessAnchor {
|
|
35
|
+
const raw = env[PARENT_PID_ENV];
|
|
36
|
+
if (raw === undefined) return { kind: "outlives-nobody" };
|
|
37
|
+
const pid = Number.parseInt(raw, 10);
|
|
38
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
`${PARENT_PID_ENV} must be a positive integer pid, got ${JSON.stringify(raw)}`,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
return { kind: "anchored", pid };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface ParentWatchdogDeps {
|
|
47
|
+
anchor: LivenessAnchor;
|
|
48
|
+
isAlive: (pid: number) => boolean;
|
|
49
|
+
onOrphaned: (reason: string) => void;
|
|
50
|
+
intervalMs?: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function armParentWatchdog(deps: ParentWatchdogDeps): {
|
|
54
|
+
disarm(): void;
|
|
55
|
+
} {
|
|
56
|
+
// An unanchored daemon has nothing to poll — arming a perpetual no-op timer on
|
|
57
|
+
// the user's always-running daemon would be pure waste. Returning an inert
|
|
58
|
+
// handle is the consequence of the data, not a special case in the spawn path.
|
|
59
|
+
if (deps.anchor.kind === "outlives-nobody") return { disarm: () => {} };
|
|
60
|
+
|
|
61
|
+
const { pid } = deps.anchor;
|
|
62
|
+
const timer = setInterval(() => {
|
|
63
|
+
if (!deps.isAlive(pid)) deps.onOrphaned(`spawner pid ${pid} gone`);
|
|
64
|
+
}, deps.intervalMs ?? DEFAULT_POLL_INTERVAL_MS);
|
|
65
|
+
timer.unref();
|
|
66
|
+
return { disarm: () => clearInterval(timer) };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function pidAlive(pid: number): boolean {
|
|
70
|
+
try {
|
|
71
|
+
process.kill(pid, 0);
|
|
72
|
+
return true;
|
|
73
|
+
} catch (e) {
|
|
74
|
+
// ESRCH: the process is gone — orphaned, trip the watchdog. EPERM: a live
|
|
75
|
+
// process we don't own (the pid was reused by another user) — treat as
|
|
76
|
+
// alive so a reused pid can never make us shut down a daemon whose real
|
|
77
|
+
// spawner is still running.
|
|
78
|
+
return (e as NodeJS.ErrnoException).code === "EPERM";
|
|
79
|
+
}
|
|
80
|
+
}
|