@klhapp/skillmux 1.11.2 → 1.13.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/CHANGELOG.md +15 -0
- package/README.md +20 -10
- package/bin/skillmux.js +89 -0
- package/docs/README.md +4 -4
- package/docs/cli.md +15 -5
- package/docs/concepts.md +4 -3
- package/docs/configuration.md +22 -5
- package/docs/deployment.md +3 -3
- package/docs/getting-started.md +32 -7
- package/docs/releasing.md +17 -2
- package/docs/sdd/cli-remote-target-parity/schema-bucket-b.yaml +297 -0
- package/docs/sdd/cli-remote-target-parity/spec-bucket-b.md +95 -0
- package/docs/sdd/cli-remote-target-parity/spec.md +111 -0
- package/docs/sdd/cli-remote-target-parity/think.md +183 -0
- package/docs/sdd/cli-surface-consistency/think.md +149 -0
- package/docs/sdd/config-authority-rerank-resilience/plan.md +338 -0
- package/docs/sdd/config-authority-rerank-resilience/review.md +124 -0
- package/docs/sdd/config-authority-rerank-resilience/spec.md +78 -0
- package/docs/sdd/feat-packaging-deployment/review.md +95 -0
- package/docs/sdd/fetch-outcome-flywheel/review.md +103 -0
- package/docs/sdd/fetch-outcome-flywheel/schema.json +347 -0
- package/docs/sdd/fetch-outcome-flywheel/spec.md +185 -0
- package/docs/sdd/npm-native-binaries/review.md +103 -0
- package/docs/sdd/npm-native-binaries/spec.md +148 -0
- package/docs/sdd/ranked-shortlist-2/pr3-calibration-removal.md +56 -0
- package/docs/sdd/refactor-builtin-target-paths/spec.md +41 -0
- package/docs/sdd/runtime-resource-hardening/review.md +86 -0
- package/docs/sdd/runtime-resource-hardening/spec.md +43 -0
- package/docs/sdd/security-hardening-phase/think.md +129 -0
- package/docs/sdd/security-observability/review.md +96 -0
- package/docs/sdd/security-observability/spec.md +55 -0
- package/docs/sdd/skill-provenance-update/review.md +152 -0
- package/docs/sdd/skill-provenance-update/schema.json +193 -0
- package/docs/sdd/skill-provenance-update/spec.md +134 -0
- package/docs/sdd/supply-chain-hardening/review.md +50 -0
- package/docs/sdd/supply-chain-hardening/spec.md +42 -0
- package/docs/sdd/target-marker-rehome/spec.md +33 -0
- package/docs/skill-management.md +1 -1
- package/docs/troubleshooting.md +18 -2
- package/package.json +15 -8
- package/src/adapters.ts +0 -438
- package/src/audit.ts +0 -21
- package/src/cli.ts +0 -928
- package/src/clients.ts +0 -368
- package/src/commands/audit.ts +0 -85
- package/src/commands/config.ts +0 -224
- package/src/commands/context.ts +0 -104
- package/src/commands/core.ts +0 -56
- package/src/commands/doctor.ts +0 -97
- package/src/commands/eval.ts +0 -88
- package/src/commands/init.ts +0 -672
- package/src/commands/install.ts +0 -155
- package/src/commands/local-vault.ts +0 -60
- package/src/commands/models.ts +0 -10
- package/src/commands/outdated.ts +0 -115
- package/src/commands/project.ts +0 -580
- package/src/commands/report.ts +0 -66
- package/src/commands/scan.ts +0 -71
- package/src/commands/shared.ts +0 -38
- package/src/commands/skill.ts +0 -33
- package/src/commands/sync.ts +0 -233
- package/src/commands/target.ts +0 -225
- package/src/commands/update.ts +0 -277
- package/src/completions.ts +0 -196
- package/src/concurrency-limiter.ts +0 -61
- package/src/config-service.ts +0 -398
- package/src/config-watcher.ts +0 -253
- package/src/config.ts +0 -583
- package/src/context.ts +0 -188
- package/src/db-audit.ts +0 -286
- package/src/db-index.ts +0 -238
- package/src/db.ts +0 -3
- package/src/deployment.ts +0 -39
- package/src/doctor.ts +0 -205
- package/src/eval.ts +0 -302
- package/src/global-flags.ts +0 -46
- package/src/init-agents.ts +0 -329
- package/src/init-instructions.ts +0 -192
- package/src/init.ts +0 -389
- package/src/install.ts +0 -232
- package/src/lifecycle.ts +0 -51
- package/src/logger.ts +0 -26
- package/src/manifest.ts +0 -352
- package/src/mcp-registration.ts +0 -89
- package/src/metrics.ts +0 -121
- package/src/models.ts +0 -20
- package/src/output.ts +0 -214
- package/src/project-setup.ts +0 -36
- package/src/prompts.ts +0 -124
- package/src/provenance.ts +0 -99
- package/src/rate-limiter.ts +0 -137
- package/src/readiness.ts +0 -30
- package/src/redact.ts +0 -52
- package/src/router-core.ts +0 -666
- package/src/rrf.ts +0 -31
- package/src/scan.ts +0 -300
- package/src/server.ts +0 -930
- package/src/setup.ts +0 -145
- package/src/snapshot.ts +0 -135
- package/src/stats.ts +0 -340
- package/src/sync.ts +0 -482
- package/src/toml-writer.ts +0 -51
- package/src/types.ts +0 -212
- package/src/vault.ts +0 -207
package/src/config-service.ts
DELETED
|
@@ -1,398 +0,0 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
|
-
import { existsSync, mkdirSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { dirname, join } from "node:path";
|
|
4
|
-
import { DEFAULT_CONFIG_PATH, expandHome, loadConfig } from "./config";
|
|
5
|
-
import { describeDeployment } from "./deployment";
|
|
6
|
-
import type { Config } from "./types";
|
|
7
|
-
import { stringifyToml } from "./toml-writer";
|
|
8
|
-
|
|
9
|
-
export type ConfigSource = "default" | "toml" | "environment";
|
|
10
|
-
export type ConfigSourceMap = Record<string, ConfigSource>;
|
|
11
|
-
|
|
12
|
-
export interface SetConfigResult {
|
|
13
|
-
ok: boolean;
|
|
14
|
-
key: string;
|
|
15
|
-
prior_val: unknown;
|
|
16
|
-
resulting_val: unknown;
|
|
17
|
-
target: string;
|
|
18
|
-
prior_revision: string;
|
|
19
|
-
resulting_revision: string;
|
|
20
|
-
persistence: "persisted" | "not_persisted" | "failed";
|
|
21
|
-
application: "activated" | "restart_required" | "failed";
|
|
22
|
-
readiness: { status: "ready" | "degraded" | "not_ready" | "stopping"; capability: string };
|
|
23
|
-
restart_required_keys: string[];
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export interface ConfigStatusResponse {
|
|
27
|
-
target: string;
|
|
28
|
-
desired_source: string;
|
|
29
|
-
desired_source_hash: string;
|
|
30
|
-
active_revision: string;
|
|
31
|
-
active_source_hash: string;
|
|
32
|
-
last_successful_reload_at: string | null;
|
|
33
|
-
last_reload_error: string | null;
|
|
34
|
-
readiness: { status: "ready" | "degraded" | "not_ready" | "stopping"; capability: string };
|
|
35
|
-
restart_required_keys: string[];
|
|
36
|
-
runtime: "running" | "not_running";
|
|
37
|
-
version: string;
|
|
38
|
-
deployment_runtime: "host" | "docker";
|
|
39
|
-
image_variant: "full" | "slim" | null;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export const RESTART_REQUIRED_KEYS = [
|
|
43
|
-
"server.hostname",
|
|
44
|
-
"server.auth_enabled",
|
|
45
|
-
"server.auth_token_env",
|
|
46
|
-
"server.admin.enabled",
|
|
47
|
-
"server.admin.token_env",
|
|
48
|
-
"inference.mode",
|
|
49
|
-
"inference.bundle",
|
|
50
|
-
"inference.models_dir",
|
|
51
|
-
"state_dir",
|
|
52
|
-
"inference.embedding.model",
|
|
53
|
-
"inference.embedding.dimension",
|
|
54
|
-
"inference.embedding.device",
|
|
55
|
-
"inference.embedding.dtype",
|
|
56
|
-
];
|
|
57
|
-
|
|
58
|
-
export const RELOADABLE_KEYS = [
|
|
59
|
-
"config.environment_overrides",
|
|
60
|
-
"vault_path",
|
|
61
|
-
"recall.k_lexical",
|
|
62
|
-
"recall.k_vector",
|
|
63
|
-
"recall.k_rerank",
|
|
64
|
-
"output.top_k",
|
|
65
|
-
"output.max_top_k",
|
|
66
|
-
"inference.embedding.endpoint",
|
|
67
|
-
"inference.embedding.api_key_env",
|
|
68
|
-
"inference.reranker.adapter",
|
|
69
|
-
"inference.reranker.endpoint",
|
|
70
|
-
"inference.reranker.model",
|
|
71
|
-
"inference.reranker.api_key_env",
|
|
72
|
-
"inference.timeout_ms",
|
|
73
|
-
"server.rate_limit.enabled",
|
|
74
|
-
"server.rate_limit.requests_per_minute",
|
|
75
|
-
"server.rate_limit.trust_proxy",
|
|
76
|
-
];
|
|
77
|
-
|
|
78
|
-
export function getNestedValue(obj: Record<string, any>, path: string): unknown {
|
|
79
|
-
const parts = path.split(".");
|
|
80
|
-
let cur = obj;
|
|
81
|
-
for (const part of parts) {
|
|
82
|
-
if (cur === undefined || cur === null || typeof cur !== "object") return undefined;
|
|
83
|
-
cur = cur[part];
|
|
84
|
-
}
|
|
85
|
-
return cur;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
export function setNestedValue(obj: Record<string, any>, path: string, value: unknown): void {
|
|
89
|
-
const parts = path.split(".");
|
|
90
|
-
let cur = obj;
|
|
91
|
-
for (let i = 0; i < parts.length - 1; i++) {
|
|
92
|
-
const part = parts[i]!;
|
|
93
|
-
if (!cur[part] || typeof cur[part] !== "object") {
|
|
94
|
-
cur[part] = {};
|
|
95
|
-
}
|
|
96
|
-
cur = cur[part];
|
|
97
|
-
}
|
|
98
|
-
const lastPart = parts[parts.length - 1]!;
|
|
99
|
-
cur[lastPart] = value;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
export function deleteNestedValue(obj: Record<string, any>, path: string): void {
|
|
103
|
-
const parts = path.split(".");
|
|
104
|
-
let cur = obj;
|
|
105
|
-
for (let i = 0; i < parts.length - 1; i++) {
|
|
106
|
-
const part = parts[i]!;
|
|
107
|
-
if (!cur[part]) return;
|
|
108
|
-
cur = cur[part];
|
|
109
|
-
}
|
|
110
|
-
const lastPart = parts[parts.length - 1]!;
|
|
111
|
-
delete cur[lastPart];
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
export function computeHash(data: unknown): string {
|
|
115
|
-
const str = typeof data === "string" ? data : JSON.stringify(data);
|
|
116
|
-
return createHash("sha256").update(str).digest("hex").slice(0, 16);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
export async function getEffectiveConfig(configPath?: string): Promise<{
|
|
120
|
-
effective: Config;
|
|
121
|
-
sources: ConfigSourceMap;
|
|
122
|
-
rawToml: Record<string, unknown>;
|
|
123
|
-
}> {
|
|
124
|
-
const path = configPath ?? process.env.SKILLMUX_CONFIG ?? DEFAULT_CONFIG_PATH;
|
|
125
|
-
const effective = await loadConfig(path);
|
|
126
|
-
const rawToml: Record<string, unknown> = {};
|
|
127
|
-
|
|
128
|
-
const fullPath = expandHome(path);
|
|
129
|
-
if (existsSync(fullPath)) {
|
|
130
|
-
try {
|
|
131
|
-
const text = await Bun.file(fullPath).text();
|
|
132
|
-
const parsed = Bun.TOML.parse(text);
|
|
133
|
-
if (typeof parsed === "object" && parsed !== null) {
|
|
134
|
-
Object.assign(rawToml, parsed);
|
|
135
|
-
}
|
|
136
|
-
} catch {
|
|
137
|
-
// empty if unparseable
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
const sources: ConfigSourceMap = {};
|
|
142
|
-
const allowEnvOverrides = effective.config?.environment_overrides !== false;
|
|
143
|
-
|
|
144
|
-
const allKeys = [
|
|
145
|
-
"config.environment_overrides",
|
|
146
|
-
"vault_path",
|
|
147
|
-
"state_dir",
|
|
148
|
-
"recall.k_lexical",
|
|
149
|
-
"recall.k_vector",
|
|
150
|
-
"recall.k_rerank",
|
|
151
|
-
"output.top_k",
|
|
152
|
-
"output.max_top_k",
|
|
153
|
-
"inference.mode",
|
|
154
|
-
"inference.bundle",
|
|
155
|
-
"inference.models_dir",
|
|
156
|
-
"inference.embedding.model",
|
|
157
|
-
"inference.embedding.dimension",
|
|
158
|
-
"inference.embedding.device",
|
|
159
|
-
"inference.embedding.dtype",
|
|
160
|
-
"inference.embedding.endpoint",
|
|
161
|
-
"inference.embedding.api_key_env",
|
|
162
|
-
"inference.reranker.adapter",
|
|
163
|
-
"inference.reranker.endpoint",
|
|
164
|
-
"inference.reranker.model",
|
|
165
|
-
"inference.reranker.api_key_env",
|
|
166
|
-
"inference.timeout_ms",
|
|
167
|
-
"server.auth_enabled",
|
|
168
|
-
"server.auth_token_env",
|
|
169
|
-
"server.allowed_origins",
|
|
170
|
-
"server.admin.enabled",
|
|
171
|
-
"server.admin.token_env",
|
|
172
|
-
"server.hostname",
|
|
173
|
-
"server.rate_limit.enabled",
|
|
174
|
-
"server.rate_limit.requests_per_minute",
|
|
175
|
-
"server.rate_limit.trust_proxy",
|
|
176
|
-
];
|
|
177
|
-
|
|
178
|
-
for (const key of allKeys) {
|
|
179
|
-
if (isEnvMasked(key, allowEnvOverrides)) {
|
|
180
|
-
sources[key] = "environment";
|
|
181
|
-
} else if (getNestedValue(rawToml, key) !== undefined) {
|
|
182
|
-
sources[key] = "toml";
|
|
183
|
-
} else {
|
|
184
|
-
sources[key] = "default";
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
return { effective, sources, rawToml };
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
export function isEnvMasked(key: string, allowEnvOverrides: boolean = true): boolean {
|
|
192
|
-
if (!allowEnvOverrides) return false;
|
|
193
|
-
if (key === "vault_path" && (process.env.SKILLMUX_VAULT_PATH || process.env.SKILL_ROUTER_VAULT_PATH || process.env.VAULT_PATH)) return true;
|
|
194
|
-
if (key === "state_dir" && (process.env.SKILLMUX_STATE_DIR || process.env.SKILL_ROUTER_STATE_DIR || process.env.STATE_DIR)) return true;
|
|
195
|
-
if (key === "recall.k_lexical" && (process.env.SKILLMUX_RECALL_K_LEXICAL || process.env.RECALL_K_LEXICAL)) return true;
|
|
196
|
-
if (key === "recall.k_vector" && (process.env.SKILLMUX_RECALL_K_VECTOR || process.env.RECALL_K_VECTOR)) return true;
|
|
197
|
-
if (key === "recall.k_rerank" && (process.env.SKILLMUX_RECALL_K_RERANK || process.env.RECALL_K_RERANK)) return true;
|
|
198
|
-
if (key === "output.top_k" && (process.env.SKILLMUX_OUTPUT_TOP_K || process.env.OUTPUT_TOP_K)) return true;
|
|
199
|
-
if (key === "output.max_top_k" && (process.env.SKILLMUX_OUTPUT_MAX_TOP_K || process.env.OUTPUT_MAX_TOP_K)) return true;
|
|
200
|
-
if (key === "inference.models_dir" && (process.env.SKILLMUX_MODELS_DIR || process.env.SKILL_ROUTER_MODELS_DIR)) return true;
|
|
201
|
-
if (key === "inference.embedding.device" && (process.env.SKILLMUX_EMBED_DEVICE || process.env.EMBED_DEVICE)) return true;
|
|
202
|
-
if (key === "inference.embedding.dtype" && (process.env.SKILLMUX_EMBED_DTYPE || process.env.EMBED_DTYPE)) return true;
|
|
203
|
-
if (key === "inference.embedding.endpoint" && (process.env.SKILLMUX_EMBED_ENDPOINT || process.env.SKILL_ROUTER_EMBED_ENDPOINT || process.env.EMBED_ENDPOINT)) return true;
|
|
204
|
-
if (key === "inference.embedding.model" && (process.env.SKILLMUX_EMBED_MODEL || process.env.SKILL_ROUTER_EMBED_MODEL || process.env.EMBED_MODEL)) return true;
|
|
205
|
-
if (key === "inference.embedding.dimension" && (process.env.SKILLMUX_EMBED_DIMENSION || process.env.SKILL_ROUTER_EMBED_DIMENSION || process.env.EMBED_DIMENSION)) return true;
|
|
206
|
-
if (key === "inference.reranker.adapter" && (process.env.SKILLMUX_RERANK_ADAPTER || process.env.SKILL_ROUTER_RERANK_ADAPTER || process.env.RERANK_ADAPTER)) return true;
|
|
207
|
-
if (key === "inference.reranker.endpoint" && (process.env.SKILLMUX_RERANK_ENDPOINT || process.env.SKILL_ROUTER_RERANK_ENDPOINT || process.env.RERANK_ENDPOINT)) return true;
|
|
208
|
-
if (key === "inference.reranker.model" && (process.env.SKILLMUX_RERANK_MODEL || process.env.SKILL_ROUTER_RERANK_MODEL || process.env.RERANK_MODEL)) return true;
|
|
209
|
-
if (key === "server.auth_enabled" && (process.env.SKILLMUX_HTTP_AUTH_ENABLED || process.env.HTTP_AUTH_ENABLED)) return true;
|
|
210
|
-
if (key === "server.auth_token_env" && (process.env.SKILLMUX_HTTP_AUTH_TOKEN_ENV || process.env.HTTP_AUTH_TOKEN_ENV)) return true;
|
|
211
|
-
if (key === "server.allowed_origins" && (process.env.SKILLMUX_HTTP_ALLOWED_ORIGINS || process.env.HTTP_ALLOWED_ORIGINS)) return true;
|
|
212
|
-
if (key === "server.hostname" && (process.env.SKILLMUX_HTTP_HOSTNAME || process.env.HTTP_HOSTNAME)) return true;
|
|
213
|
-
if (key === "server.rate_limit.enabled" && (process.env.SKILLMUX_HTTP_RATE_LIMIT_ENABLED || process.env.HTTP_RATE_LIMIT_ENABLED)) return true;
|
|
214
|
-
if (key === "server.rate_limit.requests_per_minute" && (process.env.SKILLMUX_HTTP_RATE_LIMIT_RPM || process.env.HTTP_RATE_LIMIT_RPM)) return true;
|
|
215
|
-
if (key === "server.rate_limit.trust_proxy" && (process.env.SKILLMUX_HTTP_RATE_LIMIT_TRUST_PROXY || process.env.HTTP_RATE_LIMIT_TRUST_PROXY)) return true;
|
|
216
|
-
return false;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
export function validateDottedKey(key: string): void {
|
|
220
|
-
if (key === "output.ambiguous_candidate_limit") {
|
|
221
|
-
throw new Error("output.ambiguous_candidate_limit is obsolete. Use output.top_k instead.");
|
|
222
|
-
}
|
|
223
|
-
if (key.startsWith("thresholds.") || key.startsWith("inference.thresholds.")) {
|
|
224
|
-
throw new Error("thresholds are obsolete. Threshold calibration was removed; use output.top_k.");
|
|
225
|
-
}
|
|
226
|
-
const allowed = new Set([
|
|
227
|
-
"config.environment_overrides",
|
|
228
|
-
"vault_path",
|
|
229
|
-
"state_dir",
|
|
230
|
-
"recall.k_lexical",
|
|
231
|
-
"recall.k_vector",
|
|
232
|
-
"recall.k_rerank",
|
|
233
|
-
"output.top_k",
|
|
234
|
-
"output.max_top_k",
|
|
235
|
-
"inference.mode",
|
|
236
|
-
"inference.bundle",
|
|
237
|
-
"inference.models_dir",
|
|
238
|
-
"inference.embedding.model",
|
|
239
|
-
"inference.embedding.dimension",
|
|
240
|
-
"inference.embedding.device",
|
|
241
|
-
"inference.embedding.dtype",
|
|
242
|
-
"inference.embedding.endpoint",
|
|
243
|
-
"inference.embedding.api_key_env",
|
|
244
|
-
"inference.reranker.adapter",
|
|
245
|
-
"inference.reranker.endpoint",
|
|
246
|
-
"inference.reranker.model",
|
|
247
|
-
"inference.reranker.api_key_env",
|
|
248
|
-
"inference.timeout_ms",
|
|
249
|
-
"server.auth_enabled",
|
|
250
|
-
"server.auth_token_env",
|
|
251
|
-
"server.admin.enabled",
|
|
252
|
-
"server.admin.token_env",
|
|
253
|
-
"server.hostname",
|
|
254
|
-
"server.rate_limit.enabled",
|
|
255
|
-
"server.rate_limit.requests_per_minute",
|
|
256
|
-
"server.rate_limit.trust_proxy",
|
|
257
|
-
]);
|
|
258
|
-
if (!allowed.has(key)) {
|
|
259
|
-
throw new Error(`Unknown configuration key "${key}"`);
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
export function parseDottedValue(key: string, valueStr: string): unknown {
|
|
264
|
-
if (valueStr === "true") return true;
|
|
265
|
-
if (valueStr === "false") return false;
|
|
266
|
-
if (/^-?\d+$/.test(valueStr)) return parseInt(valueStr, 10);
|
|
267
|
-
if (/^-?\d+\.\d+$/.test(valueStr)) return parseFloat(valueStr);
|
|
268
|
-
|
|
269
|
-
const numberKeys = new Set([
|
|
270
|
-
"recall.k_lexical",
|
|
271
|
-
"recall.k_vector",
|
|
272
|
-
"recall.k_rerank",
|
|
273
|
-
"output.top_k",
|
|
274
|
-
"output.max_top_k",
|
|
275
|
-
"inference.embedding.dimension",
|
|
276
|
-
"inference.timeout_ms",
|
|
277
|
-
"server.rate_limit.requests_per_minute",
|
|
278
|
-
]);
|
|
279
|
-
|
|
280
|
-
if (numberKeys.has(key)) {
|
|
281
|
-
const num = Number(valueStr);
|
|
282
|
-
if (isNaN(num)) {
|
|
283
|
-
throw new Error(`Key "${key}" expects a numeric value, got "${valueStr}"`);
|
|
284
|
-
}
|
|
285
|
-
return num;
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
const booleanKeys = new Set([
|
|
289
|
-
"config.environment_overrides",
|
|
290
|
-
"server.auth_enabled",
|
|
291
|
-
"server.admin.enabled",
|
|
292
|
-
"server.rate_limit.enabled",
|
|
293
|
-
"server.rate_limit.trust_proxy",
|
|
294
|
-
]);
|
|
295
|
-
|
|
296
|
-
if (booleanKeys.has(key)) {
|
|
297
|
-
throw new Error(`Key "${key}" expects a boolean value ("true" or "false"), got "${valueStr}"`);
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
return valueStr;
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
export async function getDottedKey(key: string, configPath?: string): Promise<unknown> {
|
|
304
|
-
validateDottedKey(key);
|
|
305
|
-
const { effective } = await getEffectiveConfig(configPath);
|
|
306
|
-
return getNestedValue(effective as Record<string, any>, key);
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
export async function setDottedKey(
|
|
310
|
-
key: string,
|
|
311
|
-
rawValStr: string,
|
|
312
|
-
opts?: { configPath?: string; dryRun?: boolean; contextName?: string }
|
|
313
|
-
): Promise<SetConfigResult> {
|
|
314
|
-
validateDottedKey(key);
|
|
315
|
-
if (isEnvMasked(key)) {
|
|
316
|
-
throw new Error(`Cannot set environment-masked configuration key "${key}"`);
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
const path = opts?.configPath ?? process.env.SKILLMUX_CONFIG ?? DEFAULT_CONFIG_PATH;
|
|
320
|
-
const contextName = opts?.contextName ?? "local";
|
|
321
|
-
|
|
322
|
-
const { effective: priorEffective, rawToml } = await getEffectiveConfig(path);
|
|
323
|
-
const priorVal = getNestedValue(priorEffective as Record<string, any>, key);
|
|
324
|
-
const parsedVal = parseDottedValue(key, rawValStr);
|
|
325
|
-
|
|
326
|
-
const updatedToml = structuredClone(rawToml);
|
|
327
|
-
setNestedValue(updatedToml, key, parsedVal);
|
|
328
|
-
|
|
329
|
-
const priorRevision = computeHash(priorEffective);
|
|
330
|
-
|
|
331
|
-
let resultingRevision = priorRevision;
|
|
332
|
-
let persistence: "persisted" | "not_persisted" | "failed" = "not_persisted";
|
|
333
|
-
|
|
334
|
-
if (!opts?.dryRun) {
|
|
335
|
-
const fullPath = expandHome(path);
|
|
336
|
-
const dir = dirname(fullPath);
|
|
337
|
-
mkdirSync(dir, { recursive: true });
|
|
338
|
-
|
|
339
|
-
let existingMode = 0o644;
|
|
340
|
-
if (existsSync(fullPath)) {
|
|
341
|
-
try {
|
|
342
|
-
existingMode = statSync(fullPath).mode;
|
|
343
|
-
} catch {
|
|
344
|
-
// default
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
const tmpPath = join(dir, `.config-${Math.random().toString(36).slice(2)}.tmp`);
|
|
349
|
-
const newTomlText = stringifyToml(updatedToml);
|
|
350
|
-
writeFileSync(tmpPath, newTomlText, { mode: existingMode, encoding: "utf-8" });
|
|
351
|
-
renameSync(tmpPath, fullPath);
|
|
352
|
-
|
|
353
|
-
persistence = "persisted";
|
|
354
|
-
|
|
355
|
-
const { effective: newEffective } = await getEffectiveConfig(path);
|
|
356
|
-
resultingRevision = computeHash(newEffective);
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
const isRestartRequired = RESTART_REQUIRED_KEYS.some((k) => key === k || key.startsWith(k + "."));
|
|
360
|
-
const application = isRestartRequired ? "restart_required" : "activated";
|
|
361
|
-
|
|
362
|
-
return {
|
|
363
|
-
ok: true,
|
|
364
|
-
key,
|
|
365
|
-
prior_val: priorVal,
|
|
366
|
-
resulting_val: parsedVal,
|
|
367
|
-
target: contextName,
|
|
368
|
-
prior_revision: priorRevision,
|
|
369
|
-
resulting_revision: resultingRevision,
|
|
370
|
-
persistence,
|
|
371
|
-
application,
|
|
372
|
-
readiness: { status: "ready", capability: "hybrid" },
|
|
373
|
-
restart_required_keys: isRestartRequired ? [key] : [],
|
|
374
|
-
};
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
export async function getLocalConfigStatus(configPath?: string): Promise<ConfigStatusResponse> {
|
|
379
|
-
const { effective } = await getEffectiveConfig(configPath);
|
|
380
|
-
const hash = computeHash(effective);
|
|
381
|
-
const deployment = describeDeployment(effective);
|
|
382
|
-
|
|
383
|
-
return {
|
|
384
|
-
target: "local",
|
|
385
|
-
desired_source: configPath ?? process.env.SKILLMUX_CONFIG ?? DEFAULT_CONFIG_PATH,
|
|
386
|
-
desired_source_hash: hash,
|
|
387
|
-
active_revision: hash,
|
|
388
|
-
active_source_hash: hash,
|
|
389
|
-
last_successful_reload_at: new Date().toISOString(),
|
|
390
|
-
last_reload_error: null,
|
|
391
|
-
readiness: { status: "ready", capability: "hybrid" },
|
|
392
|
-
restart_required_keys: [],
|
|
393
|
-
runtime: "not_running",
|
|
394
|
-
version: deployment.version,
|
|
395
|
-
deployment_runtime: deployment.runtime,
|
|
396
|
-
image_variant: deployment.image_variant,
|
|
397
|
-
};
|
|
398
|
-
}
|
package/src/config-watcher.ts
DELETED
|
@@ -1,253 +0,0 @@
|
|
|
1
|
-
import { existsSync, watch } from "node:fs";
|
|
2
|
-
import { dirname } from "node:path";
|
|
3
|
-
import { loadConfig } from "./config";
|
|
4
|
-
import type { Config } from "./types";
|
|
5
|
-
|
|
6
|
-
// ---------------------------------------------------------------------------
|
|
7
|
-
// Live-reload allowlist (AC9)
|
|
8
|
-
// These are the ONLY top-level config keys that can be hot-reloaded.
|
|
9
|
-
// Any change outside this set results in restart_required_keys being populated
|
|
10
|
-
// but the current snapshot is NOT replaced (LKG stays active).
|
|
11
|
-
// ---------------------------------------------------------------------------
|
|
12
|
-
|
|
13
|
-
export const LIVE_RELOAD_KEYS = new Set([
|
|
14
|
-
"config.environment_overrides",
|
|
15
|
-
"recall.k_lexical",
|
|
16
|
-
"recall.k_vector",
|
|
17
|
-
"recall.k_rerank",
|
|
18
|
-
"output.top_k",
|
|
19
|
-
"output.max_top_k",
|
|
20
|
-
"inference.embedding.endpoint",
|
|
21
|
-
"inference.embedding.api_key_env",
|
|
22
|
-
"inference.reranker.adapter",
|
|
23
|
-
"inference.reranker.endpoint",
|
|
24
|
-
"inference.reranker.model",
|
|
25
|
-
"inference.reranker.api_key_env",
|
|
26
|
-
"inference.timeout_ms",
|
|
27
|
-
]);
|
|
28
|
-
|
|
29
|
-
// ---------------------------------------------------------------------------
|
|
30
|
-
// Status type (AC10)
|
|
31
|
-
// ---------------------------------------------------------------------------
|
|
32
|
-
|
|
33
|
-
export interface ReloadStatus {
|
|
34
|
-
/** ISO timestamp of the last successful reload, or null if never reloaded. */
|
|
35
|
-
last_successful_reload_at: string | null;
|
|
36
|
-
/** Error message from the last failed reload, or null if last reload succeeded. */
|
|
37
|
-
last_reload_error: string | null;
|
|
38
|
-
/**
|
|
39
|
-
* Keys that changed outside the live-reload allowlist.
|
|
40
|
-
* Non-empty means a restart is needed to activate those changes.
|
|
41
|
-
*/
|
|
42
|
-
restart_required_keys: string[];
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// ---------------------------------------------------------------------------
|
|
46
|
-
// ConfigWatcher (AC8, AC9, AC10)
|
|
47
|
-
// ---------------------------------------------------------------------------
|
|
48
|
-
|
|
49
|
-
export interface ConfigWatcherOptions {
|
|
50
|
-
/** Called on every successful, complete reload. */
|
|
51
|
-
onReload: (config: Config) => void;
|
|
52
|
-
/**
|
|
53
|
-
* Called when the file cannot be read, parsed, or validated.
|
|
54
|
-
* The previous good snapshot remains active — watcher does NOT crash.
|
|
55
|
-
*/
|
|
56
|
-
onError: (error: unknown) => void;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
const DEBOUNCE_MS = 300;
|
|
60
|
-
const STABLE_STAT_INTERVAL_MS = 80;
|
|
61
|
-
const STABLE_STAT_MAX_TRIES = 8;
|
|
62
|
-
|
|
63
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
64
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function changedConfigKeys(
|
|
68
|
-
previous: Record<string, unknown>,
|
|
69
|
-
next: Record<string, unknown>,
|
|
70
|
-
prefix = "",
|
|
71
|
-
): string[] {
|
|
72
|
-
const changed: string[] = [];
|
|
73
|
-
const keys = new Set([...Object.keys(previous), ...Object.keys(next)]);
|
|
74
|
-
|
|
75
|
-
for (const key of keys) {
|
|
76
|
-
const path = prefix ? `${prefix}.${key}` : key;
|
|
77
|
-
const previousValue = previous[key];
|
|
78
|
-
const nextValue = next[key];
|
|
79
|
-
if (!(key in previous) || !(key in next)) {
|
|
80
|
-
if (isRecord(previousValue) || isRecord(nextValue)) {
|
|
81
|
-
changed.push(
|
|
82
|
-
...changedConfigKeys(
|
|
83
|
-
isRecord(previousValue) ? previousValue : {},
|
|
84
|
-
isRecord(nextValue) ? nextValue : {},
|
|
85
|
-
path,
|
|
86
|
-
),
|
|
87
|
-
);
|
|
88
|
-
} else {
|
|
89
|
-
changed.push(path);
|
|
90
|
-
}
|
|
91
|
-
continue;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
if (isRecord(previousValue) && isRecord(nextValue)) {
|
|
95
|
-
changed.push(...changedConfigKeys(previousValue, nextValue, path));
|
|
96
|
-
} else if (JSON.stringify(previousValue) !== JSON.stringify(nextValue)) {
|
|
97
|
-
changed.push(path);
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
return changed;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* Watch the parent directory of a TOML config file for changes.
|
|
106
|
-
* Parent-dir watching catches both direct writes (change events) and
|
|
107
|
-
* atomic renames (rename events) — both are needed for AC8.
|
|
108
|
-
*
|
|
109
|
-
* Reloading is transactional:
|
|
110
|
-
* 1. Debounce burst events
|
|
111
|
-
* 2. Wait for file size/mtime to stabilise (stable candidate read)
|
|
112
|
-
* 3. Parse + validate the full config
|
|
113
|
-
* 4. Call onReload only on success; on failure call onError (LKG stays)
|
|
114
|
-
*
|
|
115
|
-
* Only keys on LIVE_RELOAD_KEYS may trigger onReload without restart_required.
|
|
116
|
-
*/
|
|
117
|
-
export class ConfigWatcher {
|
|
118
|
-
private status: ReloadStatus = {
|
|
119
|
-
last_successful_reload_at: null,
|
|
120
|
-
last_reload_error: null,
|
|
121
|
-
restart_required_keys: [],
|
|
122
|
-
};
|
|
123
|
-
private stopped = false;
|
|
124
|
-
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
125
|
-
private watcher: ReturnType<typeof watch> | undefined;
|
|
126
|
-
|
|
127
|
-
private constructor(
|
|
128
|
-
private readonly tomlPath: string,
|
|
129
|
-
private readonly opts: ConfigWatcherOptions,
|
|
130
|
-
private activeConfig: Config,
|
|
131
|
-
) {
|
|
132
|
-
const dir = dirname(tomlPath);
|
|
133
|
-
const filename = tomlPath.split(/[/\\]/).pop()!;
|
|
134
|
-
|
|
135
|
-
// A config file is optional. Do not create its parent just to enable
|
|
136
|
-
// reloads: a native zero-config server must also work with a read-only or
|
|
137
|
-
// entirely absent config location. Reloads stay inactive until a future
|
|
138
|
-
// server start finds a watchable parent directory.
|
|
139
|
-
if (!existsSync(dir)) return;
|
|
140
|
-
|
|
141
|
-
const watcher = watch(dir, { recursive: false }, (_event, changedName) => {
|
|
142
|
-
if (this.stopped) return;
|
|
143
|
-
// Fire for: the config file itself, or any .tmp variant of it (handles
|
|
144
|
-
// pid-numbered atomics: config.toml.12345.tmp → rename → config.toml).
|
|
145
|
-
// null/undefined changedName means directory-level change — treat as a hit.
|
|
146
|
-
if (
|
|
147
|
-
changedName &&
|
|
148
|
-
changedName !== filename &&
|
|
149
|
-
!changedName.startsWith(filename)
|
|
150
|
-
) {
|
|
151
|
-
return;
|
|
152
|
-
}
|
|
153
|
-
this.scheduleReload();
|
|
154
|
-
});
|
|
155
|
-
this.watcher = watcher;
|
|
156
|
-
|
|
157
|
-
watcher.on("error", (err) => {
|
|
158
|
-
if (!this.stopped) {
|
|
159
|
-
this.status = { ...this.status, last_reload_error: String(err) };
|
|
160
|
-
this.opts.onError(err);
|
|
161
|
-
}
|
|
162
|
-
});
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
static async start(
|
|
166
|
-
tomlPath: string,
|
|
167
|
-
opts: ConfigWatcherOptions,
|
|
168
|
-
): Promise<ConfigWatcher> {
|
|
169
|
-
const activeConfig = await loadConfig(tomlPath);
|
|
170
|
-
return new ConfigWatcher(tomlPath, opts, activeConfig);
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
private scheduleReload(): void {
|
|
174
|
-
if (this.debounceTimer !== null) clearTimeout(this.debounceTimer);
|
|
175
|
-
this.debounceTimer = setTimeout(() => {
|
|
176
|
-
this.debounceTimer = null;
|
|
177
|
-
void this.doReload();
|
|
178
|
-
}, DEBOUNCE_MS);
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
private async doReload(): Promise<void> {
|
|
182
|
-
if (this.stopped) return;
|
|
183
|
-
|
|
184
|
-
// Wait for stable file (size + mtime stop changing)
|
|
185
|
-
await this.waitForStable();
|
|
186
|
-
if (this.stopped) return;
|
|
187
|
-
|
|
188
|
-
try {
|
|
189
|
-
const config = await loadConfig(this.tomlPath);
|
|
190
|
-
const restartRequiredKeys = changedConfigKeys(
|
|
191
|
-
this.activeConfig as unknown as Record<string, unknown>,
|
|
192
|
-
config as unknown as Record<string, unknown>,
|
|
193
|
-
)
|
|
194
|
-
.filter((key) => !LIVE_RELOAD_KEYS.has(key))
|
|
195
|
-
.sort();
|
|
196
|
-
|
|
197
|
-
if (restartRequiredKeys.length > 0) {
|
|
198
|
-
this.status = {
|
|
199
|
-
...this.status,
|
|
200
|
-
last_reload_error: null,
|
|
201
|
-
restart_required_keys: restartRequiredKeys,
|
|
202
|
-
};
|
|
203
|
-
return;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
this.activeConfig = config;
|
|
207
|
-
this.status = {
|
|
208
|
-
last_successful_reload_at: new Date().toISOString(),
|
|
209
|
-
last_reload_error: null,
|
|
210
|
-
restart_required_keys: [],
|
|
211
|
-
};
|
|
212
|
-
this.opts.onReload(config);
|
|
213
|
-
} catch (err) {
|
|
214
|
-
this.status = {
|
|
215
|
-
...this.status,
|
|
216
|
-
last_reload_error: err instanceof Error ? err.message : String(err),
|
|
217
|
-
};
|
|
218
|
-
this.opts.onError(err);
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
private async waitForStable(): Promise<void> {
|
|
223
|
-
let previous = "";
|
|
224
|
-
for (let i = 0; i < STABLE_STAT_MAX_TRIES; i++) {
|
|
225
|
-
const file = Bun.file(this.tomlPath);
|
|
226
|
-
if (!(await file.exists())) return;
|
|
227
|
-
const current = `${file.size}:${file.lastModified}`;
|
|
228
|
-
if (current === previous) return;
|
|
229
|
-
previous = current;
|
|
230
|
-
await Bun.sleep(STABLE_STAT_INTERVAL_MS);
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
/** Get a snapshot of the current watcher status. */
|
|
235
|
-
reloadStatus(): ReloadStatus {
|
|
236
|
-
return { ...this.status };
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
/** Stop the watcher and cancel any pending debounce. Idempotent. */
|
|
240
|
-
stop(): void {
|
|
241
|
-
if (this.stopped) return;
|
|
242
|
-
this.stopped = true;
|
|
243
|
-
if (this.debounceTimer !== null) {
|
|
244
|
-
clearTimeout(this.debounceTimer);
|
|
245
|
-
this.debounceTimer = null;
|
|
246
|
-
}
|
|
247
|
-
try {
|
|
248
|
-
this.watcher?.close();
|
|
249
|
-
} catch {
|
|
250
|
-
// already closed
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
}
|