@klhapp/skillmux 0.2.1 → 0.4.3
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 +76 -0
- package/README.md +72 -3
- package/config.example.toml +5 -0
- package/docs/configuration.md +58 -4
- package/docs/releasing.md +60 -21
- package/package.json +1 -1
- package/src/adapters.ts +381 -0
- package/src/calibrate.ts +797 -0
- package/src/cli.ts +866 -385
- package/src/clients.ts +3 -1
- package/src/completions.ts +84 -0
- package/src/config-service.ts +408 -0
- package/src/config-watcher.ts +179 -0
- package/src/config.ts +7 -0
- package/src/context.ts +183 -0
- package/src/dataset-generator.ts +140 -0
- package/src/doctor.ts +43 -0
- package/src/eval.ts +22 -4
- package/src/init.ts +1 -1
- package/src/manifest.ts +124 -10
- package/src/output.ts +151 -0
- package/src/router-core.ts +60 -12
- package/src/server.ts +119 -0
- package/src/snapshot.ts +135 -0
- package/src/sync.ts +29 -8
- package/src/types.ts +7 -0
- package/src/vault.ts +59 -1
package/src/output.ts
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import type { ResolvedTarget } from "./context";
|
|
2
|
+
|
|
3
|
+
export interface JsonEnvelope<T = any> {
|
|
4
|
+
schema_version: 1;
|
|
5
|
+
ok: boolean;
|
|
6
|
+
target: string | { name: string; server: string };
|
|
7
|
+
data: T | null;
|
|
8
|
+
error: { code: string; message: string; details?: any } | null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function formatJsonEnvelope<T>(opts: {
|
|
12
|
+
ok: boolean;
|
|
13
|
+
target: ResolvedTarget | string | { name: string; server: string };
|
|
14
|
+
data?: T;
|
|
15
|
+
error?: { code: string; message: string; details?: any } | null;
|
|
16
|
+
}): JsonEnvelope<T> {
|
|
17
|
+
let targetVal: string | { name: string; server: string };
|
|
18
|
+
if (typeof opts.target === "string" || (typeof opts.target === "object" && "server" in opts.target && !("type" in opts.target))) {
|
|
19
|
+
targetVal = opts.target as any;
|
|
20
|
+
} else if (typeof opts.target === "object" && "type" in opts.target) {
|
|
21
|
+
if (opts.target.type === "local") {
|
|
22
|
+
targetVal = "local";
|
|
23
|
+
} else {
|
|
24
|
+
targetVal = { name: opts.target.name, server: opts.target.server };
|
|
25
|
+
}
|
|
26
|
+
} else {
|
|
27
|
+
targetVal = "local";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
schema_version: 1,
|
|
32
|
+
ok: opts.ok,
|
|
33
|
+
target: targetVal,
|
|
34
|
+
data: opts.data ?? null,
|
|
35
|
+
error: opts.error ?? null,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function mapExitCode(err: unknown): number {
|
|
40
|
+
if (!err) return 0;
|
|
41
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
42
|
+
const lower = msg.toLowerCase();
|
|
43
|
+
|
|
44
|
+
if (
|
|
45
|
+
lower.includes("conflict") ||
|
|
46
|
+
lower.includes("revision") ||
|
|
47
|
+
lower.includes("externally managed") ||
|
|
48
|
+
lower.includes("config_revision_conflict") ||
|
|
49
|
+
lower.includes("config_externally_managed")
|
|
50
|
+
) {
|
|
51
|
+
return 4;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (
|
|
55
|
+
lower.includes("unreachable") ||
|
|
56
|
+
lower.includes("failed to reach") ||
|
|
57
|
+
lower.includes("unauthorized") ||
|
|
58
|
+
lower.includes("unauthenticated") ||
|
|
59
|
+
lower.includes("401") ||
|
|
60
|
+
lower.includes("403") ||
|
|
61
|
+
lower.includes("connection refused")
|
|
62
|
+
) {
|
|
63
|
+
return 3;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return 2;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function levenshteinDistance(a: string, b: string): number {
|
|
70
|
+
const m = a.length;
|
|
71
|
+
const n = b.length;
|
|
72
|
+
const dp: number[][] = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
|
|
73
|
+
|
|
74
|
+
for (let i = 0; i <= m; i++) {
|
|
75
|
+
if (!dp[i]) dp[i] = [];
|
|
76
|
+
dp[i]![0] = i;
|
|
77
|
+
}
|
|
78
|
+
for (let j = 0; j <= n; j++) {
|
|
79
|
+
if (!dp[0]) dp[0] = [];
|
|
80
|
+
dp[0]![j] = j;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
for (let i = 1; i <= m; i++) {
|
|
84
|
+
for (let j = 1; j <= n; j++) {
|
|
85
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
86
|
+
const prevRow = dp[i - 1]!;
|
|
87
|
+
const curRow = dp[i]!;
|
|
88
|
+
curRow[j] = Math.min(
|
|
89
|
+
prevRow[j]! + 1,
|
|
90
|
+
curRow[j - 1]! + 1,
|
|
91
|
+
prevRow[j - 1]! + cost
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return dp[m]![n]!;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function suggestCorrection(input: string, candidates: string[]): string | null {
|
|
99
|
+
let minDistance = Infinity;
|
|
100
|
+
let bestMatch: string | null = null;
|
|
101
|
+
|
|
102
|
+
for (const candidate of candidates) {
|
|
103
|
+
const dist = levenshteinDistance(input, candidate);
|
|
104
|
+
if (dist < minDistance && dist <= 2) {
|
|
105
|
+
minDistance = dist;
|
|
106
|
+
bestMatch = candidate;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return bestMatch;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function isInteractive(): boolean {
|
|
114
|
+
return (
|
|
115
|
+
process.stdout.isTTY === true &&
|
|
116
|
+
!process.env.NO_COLOR &&
|
|
117
|
+
process.env.TERM !== "dumb"
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function renderTargetBanner(target: ResolvedTarget): void {
|
|
122
|
+
if (!isInteractive()) return;
|
|
123
|
+
if (target.type === "local") {
|
|
124
|
+
console.log(`Target: local`);
|
|
125
|
+
} else {
|
|
126
|
+
console.log(`Target: remote (${target.name} -> ${target.server})`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function renderTable(columns: { key: string; header: string }[], rows: Record<string, any>[]): void {
|
|
131
|
+
if (rows.length === 0) {
|
|
132
|
+
console.log("(no entries)");
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const widths = new Map<string, number>();
|
|
137
|
+
for (const col of columns) {
|
|
138
|
+
const maxLen = Math.max(col.header.length, ...rows.map((r) => String(r[col.key] ?? "").length));
|
|
139
|
+
widths.set(col.key, maxLen);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const headerLine = columns.map((col) => col.header.padEnd(widths.get(col.key) ?? 0)).join(" ");
|
|
143
|
+
const sepLine = columns.map((col) => "-".repeat(widths.get(col.key) ?? 0)).join(" ");
|
|
144
|
+
|
|
145
|
+
console.log(headerLine);
|
|
146
|
+
console.log(sepLine);
|
|
147
|
+
for (const row of rows) {
|
|
148
|
+
const line = columns.map((col) => String(row[col.key] ?? "").padEnd(widths.get(col.key) ?? 0)).join(" ");
|
|
149
|
+
console.log(line);
|
|
150
|
+
}
|
|
151
|
+
}
|
package/src/router-core.ts
CHANGED
|
@@ -40,11 +40,16 @@ import {
|
|
|
40
40
|
listSupportingFiles,
|
|
41
41
|
parseSkillMd,
|
|
42
42
|
readSkill,
|
|
43
|
-
|
|
43
|
+
scanVaults,
|
|
44
|
+
vaultResolutionOrder,
|
|
44
45
|
sha256Hex,
|
|
45
46
|
SKILL_ID_PATTERN,
|
|
46
47
|
} from "./vault";
|
|
47
48
|
|
|
49
|
+
function maxVaultMtime(vaultPath: string, localVaultPaths: string[]): number {
|
|
50
|
+
return Math.max(getVaultMaxMtime(vaultPath), ...localVaultPaths.map(getVaultMaxMtime));
|
|
51
|
+
}
|
|
52
|
+
|
|
48
53
|
export { buildAuditRow } from "./audit";
|
|
49
54
|
export { loadConfig } from "./config";
|
|
50
55
|
export { decideResolveOutcome } from "./decision";
|
|
@@ -82,8 +87,9 @@ async function getEnv(): Promise<{ config: Config; db: Database }> {
|
|
|
82
87
|
const db = openIndex(expandHome(config.state_dir));
|
|
83
88
|
if (skillCount(db) === 0) {
|
|
84
89
|
const vaultPath = expandHome(config.vault_path);
|
|
85
|
-
|
|
86
|
-
|
|
90
|
+
const localVaultPaths = config.local_vault_paths.map(expandHome);
|
|
91
|
+
ingestVault(db, await scanVaults(vaultPath, localVaultPaths));
|
|
92
|
+
setIndexMeta(db, "last_indexed_mtime", String(maxVaultMtime(vaultPath, localVaultPaths)));
|
|
87
93
|
}
|
|
88
94
|
env = { config, db };
|
|
89
95
|
return env;
|
|
@@ -110,16 +116,45 @@ export function closeRuntime(): void {
|
|
|
110
116
|
*/
|
|
111
117
|
async function deliverSkill(db: Database, config: Config, skillId: string): Promise<FetchSkillResult> {
|
|
112
118
|
const vaultPath = expandHome(config.vault_path);
|
|
113
|
-
const
|
|
114
|
-
|
|
119
|
+
const localVaultPaths = config.local_vault_paths.map(expandHome);
|
|
120
|
+
const candidates = vaultResolutionOrder(vaultPath, localVaultPaths);
|
|
121
|
+
|
|
122
|
+
// Existence alone (resolveSkillRoot's check) isn't enough here: an unparseable
|
|
123
|
+
// local_vault_paths override would otherwise shadow a perfectly valid vault_path
|
|
124
|
+
// copy of the same skill_id, since it's checked first. Skip a broken override and
|
|
125
|
+
// fall through to the next root — mirroring scanVault/rebuildIndex's tolerance for
|
|
126
|
+
// unparseable content. The last candidate (always vault_path) is never skipped on
|
|
127
|
+
// parse failure, matching the pre-existing single-root behavior where a broken
|
|
128
|
+
// vault_path copy propagates its parse error rather than being silently swallowed.
|
|
129
|
+
let root: string | null = null;
|
|
130
|
+
let bytes: Uint8Array | null = null;
|
|
131
|
+
let raw = "";
|
|
132
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
133
|
+
const candidate = candidates[i]!;
|
|
134
|
+
const file = Bun.file(join(candidate, skillId, "SKILL.md"));
|
|
135
|
+
if (!(await file.exists())) continue;
|
|
136
|
+
const candidateBytes = await file.bytes();
|
|
137
|
+
const candidateRaw = decodeUtf8Strict(candidateBytes);
|
|
138
|
+
if (i < candidates.length - 1) {
|
|
139
|
+
try {
|
|
140
|
+
parseSkillMd(skillId, candidateRaw);
|
|
141
|
+
} catch {
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
root = candidate;
|
|
146
|
+
bytes = candidateBytes;
|
|
147
|
+
raw = candidateRaw;
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (root === null || bytes === null) {
|
|
115
152
|
// Deleted on disk but the watcher hasn't caught up: drop the stale row and
|
|
116
153
|
// surface the schema's error code rather than a raw ENOENT.
|
|
117
154
|
deleteSkill(db, skillId);
|
|
118
155
|
throw new Error(`SKILL_NOT_FOUND: skill '${skillId}' no longer exists in the vault`);
|
|
119
156
|
}
|
|
120
|
-
const bytes = await file.bytes();
|
|
121
157
|
const contentSha256 = sha256Hex(bytes);
|
|
122
|
-
const raw = decodeUtf8Strict(bytes);
|
|
123
158
|
let row = getSkillRow(db, skillId);
|
|
124
159
|
if (row === null || row.content_sha256 !== contentSha256) {
|
|
125
160
|
const fresh = parseSkillMd(skillId, raw);
|
|
@@ -131,7 +166,7 @@ async function deliverSkill(db: Database, config: Config, skillId: string): Prom
|
|
|
131
166
|
title: row.title,
|
|
132
167
|
content_sha256: contentSha256,
|
|
133
168
|
body: raw,
|
|
134
|
-
files: listSupportingFiles(
|
|
169
|
+
files: listSupportingFiles(root, skillId),
|
|
135
170
|
};
|
|
136
171
|
}
|
|
137
172
|
|
|
@@ -153,15 +188,23 @@ export async function rebuildIndex(
|
|
|
153
188
|
): Promise<RebuildReport> {
|
|
154
189
|
const { config, db } = await getEnv();
|
|
155
190
|
const vaultPath = expandHome(config.vault_path);
|
|
156
|
-
const
|
|
191
|
+
const localVaultPaths = config.local_vault_paths.map(expandHome);
|
|
192
|
+
const currentMtime = maxVaultMtime(vaultPath, localVaultPaths);
|
|
157
193
|
const invalidIds: string[] = [];
|
|
158
|
-
const skills = await
|
|
194
|
+
const skills = await scanVaults(vaultPath, localVaultPaths, (skillId, error) => {
|
|
159
195
|
invalidIds.push(skillId);
|
|
160
196
|
onInvalid?.(skillId, error);
|
|
161
197
|
});
|
|
162
198
|
const rows = skills.map(toSkillRow);
|
|
199
|
+
// A skill_id invalid in one root (e.g. a local_vault_paths entry being edited) can
|
|
200
|
+
// still be valid in another (e.g. vault_path) — scanVaults already resolved that
|
|
201
|
+
// in `skills`. Only retain the previous row for ids that came back invalid
|
|
202
|
+
// everywhere; otherwise this duplicates the skill_id and violates the
|
|
203
|
+
// skills.skill_id PRIMARY KEY on replaceSkills's plain INSERT.
|
|
204
|
+
const validIds = new Set(skills.map((s) => s.skill_id));
|
|
163
205
|
const retained: string[] = [];
|
|
164
206
|
for (const skillId of invalidIds) {
|
|
207
|
+
if (validIds.has(skillId)) continue;
|
|
165
208
|
const previous = getSkillRow(db, skillId);
|
|
166
209
|
if (previous) {
|
|
167
210
|
rows.push(previous);
|
|
@@ -181,17 +224,22 @@ export async function rebuildIndex(
|
|
|
181
224
|
export async function syncVaultIfNeeded(): Promise<void> {
|
|
182
225
|
const { config, db } = await getEnv();
|
|
183
226
|
const vaultPath = expandHome(config.vault_path);
|
|
184
|
-
const
|
|
227
|
+
const localVaultPaths = config.local_vault_paths.map(expandHome);
|
|
228
|
+
const currentMtime = maxVaultMtime(vaultPath, localVaultPaths);
|
|
185
229
|
const lastIndexed = getIndexMeta(db, "last_indexed_mtime");
|
|
186
230
|
|
|
187
231
|
if (lastIndexed === null || currentMtime > Number(lastIndexed)) {
|
|
188
232
|
const invalidIds: string[] = [];
|
|
189
|
-
const skills = await
|
|
233
|
+
const skills = await scanVaults(vaultPath, localVaultPaths, (skillId, error) => {
|
|
190
234
|
invalidIds.push(skillId);
|
|
191
235
|
console.error(`warning: keeping previous index entry for ${skillId}: ${error}`);
|
|
192
236
|
});
|
|
193
237
|
const rows = skills.map(toSkillRow);
|
|
238
|
+
// See rebuildIndex: a skill_id invalid in one root can still be valid in
|
|
239
|
+
// another that scanVaults already resolved — don't re-add its previous row.
|
|
240
|
+
const validIds = new Set(skills.map((s) => s.skill_id));
|
|
194
241
|
for (const skillId of invalidIds) {
|
|
242
|
+
if (validIds.has(skillId)) continue;
|
|
195
243
|
const previous = getSkillRow(db, skillId);
|
|
196
244
|
if (previous) {
|
|
197
245
|
rows.push(previous);
|
package/src/server.ts
CHANGED
|
@@ -13,6 +13,15 @@ import { MetricsRegistry } from "./metrics";
|
|
|
13
13
|
import { ReadinessState } from "./readiness";
|
|
14
14
|
import { initializeRuntime } from "./lifecycle";
|
|
15
15
|
import type { Clients, Config } from "./types";
|
|
16
|
+
import {
|
|
17
|
+
computeHash,
|
|
18
|
+
getEffectiveConfig,
|
|
19
|
+
getLocalConfigStatus,
|
|
20
|
+
setDottedKey,
|
|
21
|
+
RELOADABLE_KEYS,
|
|
22
|
+
RESTART_REQUIRED_KEYS,
|
|
23
|
+
} from "./config-service";
|
|
24
|
+
import { applyCalibrationRun, getCalibrationRun, listCalibrationRuns } from "./calibrate";
|
|
16
25
|
|
|
17
26
|
export const metricsRegistry = new MetricsRegistry();
|
|
18
27
|
export const readinessState = new ReadinessState();
|
|
@@ -254,6 +263,116 @@ export async function startServer(opts?: {
|
|
|
254
263
|
return new Response(JSON.stringify(getStats(db, since)), { status: 200, headers });
|
|
255
264
|
}
|
|
256
265
|
|
|
266
|
+
// Admin HTTP API (/admin/v1/*)
|
|
267
|
+
if (url.pathname.startsWith("/admin/v1/")) {
|
|
268
|
+
if (!serverConfig.admin?.enabled) {
|
|
269
|
+
return new Response("Admin endpoints disabled", { status: 403 });
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const adminTokenEnv = serverConfig.admin.token_env || "SKILLMUX_ADMIN_TOKEN";
|
|
273
|
+
const expectedAdminToken = process.env[adminTokenEnv] || "";
|
|
274
|
+
const authHeader = req.headers.get("authorization") || "";
|
|
275
|
+
const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : authHeader;
|
|
276
|
+
|
|
277
|
+
if (!expectedAdminToken || !token || !safeTokenEquals(token, expectedAdminToken)) {
|
|
278
|
+
return new Response("Unauthorized", { status: 401 });
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const headers = new Headers({ "Content-Type": "application/json" });
|
|
282
|
+
if (allowOriginHeader) headers.set("Access-Control-Allow-Origin", allowOriginHeader);
|
|
283
|
+
|
|
284
|
+
if (req.method === "GET" && url.pathname === "/admin/v1/capabilities") {
|
|
285
|
+
const isExternallyManaged = process.env.SKILLMUX_CONFIG_READONLY === "true";
|
|
286
|
+
return new Response(
|
|
287
|
+
JSON.stringify({
|
|
288
|
+
config_read: true,
|
|
289
|
+
config_write: !isExternallyManaged,
|
|
290
|
+
calibration: true,
|
|
291
|
+
persistence: isExternallyManaged ? "externally_managed" : "writable",
|
|
292
|
+
reloadable_keys: RELOADABLE_KEYS,
|
|
293
|
+
restart_required_keys: RESTART_REQUIRED_KEYS,
|
|
294
|
+
}),
|
|
295
|
+
{ status: 200, headers }
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (req.method === "GET" && url.pathname === "/admin/v1/config") {
|
|
300
|
+
const { effective, sources } = await getEffectiveConfig();
|
|
301
|
+
const hash = computeHash(effective);
|
|
302
|
+
const status = await getLocalConfigStatus();
|
|
303
|
+
headers.set("ETag", `"${hash}"`);
|
|
304
|
+
return new Response(
|
|
305
|
+
JSON.stringify({
|
|
306
|
+
desired: effective,
|
|
307
|
+
effective,
|
|
308
|
+
sources,
|
|
309
|
+
active_revision: hash,
|
|
310
|
+
runtime: status,
|
|
311
|
+
}),
|
|
312
|
+
{ status: 200, headers }
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (req.method === "PATCH" && url.pathname === "/admin/v1/config") {
|
|
317
|
+
if (process.env.SKILLMUX_CONFIG_READONLY === "true") {
|
|
318
|
+
return new Response(
|
|
319
|
+
JSON.stringify({ error: "CONFIG_EXTERNALLY_MANAGED", message: "Configuration is externally managed" }),
|
|
320
|
+
{ status: 409, headers }
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const ifMatch = req.headers.get("if-match") || "";
|
|
325
|
+
const cleanIfMatch = ifMatch.replace(/^"|"$/g, "");
|
|
326
|
+
const { effective } = await getEffectiveConfig();
|
|
327
|
+
const currentHash = computeHash(effective);
|
|
328
|
+
|
|
329
|
+
if (!ifMatch || cleanIfMatch !== currentHash) {
|
|
330
|
+
return new Response(
|
|
331
|
+
JSON.stringify({ error: "CONFIG_REVISION_CONFLICT", message: "Revision conflict" }),
|
|
332
|
+
{ status: 409, headers }
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const body = (await req.json()) as { changes: Record<string, string | number | boolean> };
|
|
337
|
+
let lastResult: any = null;
|
|
338
|
+
for (const [k, v] of Object.entries(body.changes ?? {})) {
|
|
339
|
+
lastResult = await setDottedKey(k, String(v), { targetName: "remote" });
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return new Response(JSON.stringify(lastResult ?? { ok: true }), { status: 200, headers });
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (url.pathname.startsWith("/admin/v1/calibrations")) {
|
|
346
|
+
const { db } = await getRuntime();
|
|
347
|
+
if (req.method === "GET" && url.pathname === "/admin/v1/calibrations") {
|
|
348
|
+
const runs = listCalibrationRuns(db);
|
|
349
|
+
return new Response(JSON.stringify(runs), { status: 200, headers });
|
|
350
|
+
}
|
|
351
|
+
const runIdMatch = url.pathname.match(/^\/admin\/v1\/calibrations\/([^\/]+)$/);
|
|
352
|
+
if (req.method === "GET" && runIdMatch && runIdMatch[1]) {
|
|
353
|
+
const runId = runIdMatch[1];
|
|
354
|
+
const run = getCalibrationRun(db, runId);
|
|
355
|
+
if (!run) return new Response(JSON.stringify({ error: "Calibration run not found" }), { status: 404, headers });
|
|
356
|
+
return new Response(JSON.stringify(run), { status: 200, headers });
|
|
357
|
+
}
|
|
358
|
+
if (req.method === "POST" && url.pathname === "/admin/v1/calibrations") {
|
|
359
|
+
const runId = "run_" + Math.random().toString(36).slice(2, 10);
|
|
360
|
+
return new Response(JSON.stringify({ run_id: runId, status: "running" }), { status: 202, headers });
|
|
361
|
+
}
|
|
362
|
+
const applyMatch = url.pathname.match(/^\/admin\/v1\/calibrations\/([^\/]+)\/apply$/);
|
|
363
|
+
if (req.method === "POST" && applyMatch && applyMatch[1]) {
|
|
364
|
+
const runId = applyMatch[1];
|
|
365
|
+
const run = getCalibrationRun(db, runId);
|
|
366
|
+
if (!run) return new Response(JSON.stringify({ error: "Calibration run not found" }), { status: 404, headers });
|
|
367
|
+
const { DEFAULT_CONFIG_PATH, expandHome } = await import("./config");
|
|
368
|
+
await applyCalibrationRun(db, runId, expandHome(DEFAULT_CONFIG_PATH), {});
|
|
369
|
+
return new Response(JSON.stringify({ ok: true, run_id: runId }), { status: 200, headers });
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return new Response("Not Found", { status: 404, headers });
|
|
374
|
+
}
|
|
375
|
+
|
|
257
376
|
// Record request metrics
|
|
258
377
|
let mcpMethod = "unknown";
|
|
259
378
|
try {
|
package/src/snapshot.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
import { expandHome } from "./config";
|
|
3
|
+
import { openIndex } from "./db";
|
|
4
|
+
import type { Clients, Config } from "./types";
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Immutable runtime snapshot (AC11)
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* An immutable, reference-counted view of runtime resources.
|
|
12
|
+
* All fields are frozen at construction — callers can never mutate the snapshot.
|
|
13
|
+
* The underlying db handle is closed only after every holder calls release().
|
|
14
|
+
*/
|
|
15
|
+
export interface RuntimeSnapshot {
|
|
16
|
+
readonly config: Config;
|
|
17
|
+
readonly clients: Clients;
|
|
18
|
+
readonly db: Database;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A handle returned by RuntimeSnapshotManager.acquire().
|
|
23
|
+
* Call release() exactly once when the request (or operation) is done.
|
|
24
|
+
*/
|
|
25
|
+
export interface SnapshotHandle {
|
|
26
|
+
readonly snapshot: RuntimeSnapshot;
|
|
27
|
+
release(): void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Internal reference-counted slot
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
class SnapshotSlot {
|
|
35
|
+
private refCount = 0;
|
|
36
|
+
private closed = false;
|
|
37
|
+
readonly snapshot: RuntimeSnapshot;
|
|
38
|
+
|
|
39
|
+
constructor(config: Config, clients: Clients) {
|
|
40
|
+
const stateDir = expandHome(config.state_dir);
|
|
41
|
+
const db = openIndex(stateDir);
|
|
42
|
+
// Freeze the snapshot object so callers cannot mutate it
|
|
43
|
+
this.snapshot = Object.freeze({ config, clients, db });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
acquire(): SnapshotHandle {
|
|
47
|
+
this.refCount++;
|
|
48
|
+
let released = false;
|
|
49
|
+
return {
|
|
50
|
+
snapshot: this.snapshot,
|
|
51
|
+
release: () => {
|
|
52
|
+
if (released) return; // idempotent
|
|
53
|
+
released = true;
|
|
54
|
+
this.refCount--;
|
|
55
|
+
this.maybeClose();
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Signal that no new acquires will come from the manager for this slot. */
|
|
61
|
+
retire(): void {
|
|
62
|
+
this.closed = true;
|
|
63
|
+
this.maybeClose();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private maybeClose(): void {
|
|
67
|
+
if (this.closed && this.refCount === 0) {
|
|
68
|
+
try {
|
|
69
|
+
this.snapshot.db.close();
|
|
70
|
+
} catch {
|
|
71
|
+
// already closed — idempotent
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// Public manager
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* RuntimeSnapshotManager holds one active SnapshotSlot at a time.
|
|
83
|
+
*
|
|
84
|
+
* - acquire() → returns a SnapshotHandle for the current slot; increments its refcount.
|
|
85
|
+
* - replace() → installs a new slot; retires the previous one (closed when all
|
|
86
|
+
* in-flight holders release, i.e. no handle leak).
|
|
87
|
+
* - dispose() → retires the current slot and blocks future acquires.
|
|
88
|
+
*/
|
|
89
|
+
export class RuntimeSnapshotManager {
|
|
90
|
+
private current: SnapshotSlot;
|
|
91
|
+
private disposed = false;
|
|
92
|
+
|
|
93
|
+
private constructor(config: Config, clients: Clients) {
|
|
94
|
+
this.current = new SnapshotSlot(config, clients);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
static create(config: Config, clients: Clients): RuntimeSnapshotManager {
|
|
98
|
+
return new RuntimeSnapshotManager(config, clients);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Acquire a handle to the current snapshot.
|
|
103
|
+
* The caller MUST call release() when done — even on error paths.
|
|
104
|
+
*/
|
|
105
|
+
acquire(): SnapshotHandle {
|
|
106
|
+
if (this.disposed) {
|
|
107
|
+
throw new Error("RuntimeSnapshotManager has been disposed");
|
|
108
|
+
}
|
|
109
|
+
return this.current.acquire();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Swap in a new configuration and clients.
|
|
114
|
+
* The previous slot is retired and will close its db handle once all
|
|
115
|
+
* in-flight holders release.
|
|
116
|
+
*/
|
|
117
|
+
replace(config: Config, clients: Clients): void {
|
|
118
|
+
if (this.disposed) {
|
|
119
|
+
throw new Error("RuntimeSnapshotManager has been disposed");
|
|
120
|
+
}
|
|
121
|
+
const outgoing = this.current;
|
|
122
|
+
this.current = new SnapshotSlot(config, clients);
|
|
123
|
+
outgoing.retire(); // closes when refCount reaches zero
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Permanently shut down. Retires the current slot.
|
|
128
|
+
* Any future acquire() will throw.
|
|
129
|
+
*/
|
|
130
|
+
dispose(): void {
|
|
131
|
+
if (this.disposed) return;
|
|
132
|
+
this.disposed = true;
|
|
133
|
+
this.current.retire();
|
|
134
|
+
}
|
|
135
|
+
}
|
package/src/sync.ts
CHANGED
|
@@ -1,24 +1,31 @@
|
|
|
1
1
|
import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join, relative } from "node:path";
|
|
4
|
+
import { resolveSkillRoot } from "./vault";
|
|
4
5
|
|
|
5
6
|
export const SKILLMUX_MARKER_FILENAME = ".skillmux";
|
|
6
7
|
export const LEGACY_MARKER_FILENAME = ".skr";
|
|
7
8
|
|
|
8
9
|
export interface SkillmuxMarker {
|
|
9
10
|
managed_by: "skillmux" | "skr";
|
|
10
|
-
|
|
11
|
+
role: "target" | "local_vault";
|
|
12
|
+
target?: string;
|
|
13
|
+
vault_path?: string;
|
|
11
14
|
created_at: string;
|
|
12
15
|
}
|
|
13
16
|
|
|
17
|
+
function normalizeMarker(raw: Omit<SkillmuxMarker, "role"> & { role?: SkillmuxMarker["role"] }): SkillmuxMarker {
|
|
18
|
+
return { ...raw, role: raw.role ?? "target" };
|
|
19
|
+
}
|
|
20
|
+
|
|
14
21
|
export function readSkillmuxMarker(dir: string): SkillmuxMarker | null {
|
|
15
22
|
const newPath = join(dir, SKILLMUX_MARKER_FILENAME);
|
|
16
23
|
if (existsSync(newPath)) {
|
|
17
|
-
return JSON.parse(readFileSync(newPath, "utf-8"))
|
|
24
|
+
return normalizeMarker(JSON.parse(readFileSync(newPath, "utf-8")));
|
|
18
25
|
}
|
|
19
26
|
const legacyPath = join(dir, LEGACY_MARKER_FILENAME);
|
|
20
27
|
if (existsSync(legacyPath)) {
|
|
21
|
-
return JSON.parse(readFileSync(legacyPath, "utf-8"))
|
|
28
|
+
return normalizeMarker(JSON.parse(readFileSync(legacyPath, "utf-8")));
|
|
22
29
|
}
|
|
23
30
|
return null;
|
|
24
31
|
}
|
|
@@ -26,17 +33,29 @@ export function readSkillmuxMarker(dir: string): SkillmuxMarker | null {
|
|
|
26
33
|
function writeSkillmuxMarker(dir: string, targetName: string): void {
|
|
27
34
|
const marker: SkillmuxMarker = {
|
|
28
35
|
managed_by: "skillmux",
|
|
36
|
+
role: "target",
|
|
29
37
|
target: targetName,
|
|
30
38
|
created_at: new Date().toISOString(),
|
|
31
39
|
};
|
|
32
40
|
writeFileSync(join(dir, SKILLMUX_MARKER_FILENAME), JSON.stringify(marker, null, 2));
|
|
33
41
|
}
|
|
34
42
|
|
|
43
|
+
export function writeLocalVaultMarker(dir: string, vaultPath: string): void {
|
|
44
|
+
const marker: SkillmuxMarker = {
|
|
45
|
+
managed_by: "skillmux",
|
|
46
|
+
role: "local_vault",
|
|
47
|
+
vault_path: vaultPath,
|
|
48
|
+
created_at: new Date().toISOString(),
|
|
49
|
+
};
|
|
50
|
+
writeFileSync(join(dir, SKILLMUX_MARKER_FILENAME), JSON.stringify(marker, null, 2));
|
|
51
|
+
}
|
|
52
|
+
|
|
35
53
|
export interface SyncTargetParams {
|
|
36
54
|
vaultPath: string;
|
|
37
55
|
targetDir: string;
|
|
38
56
|
targetName: string;
|
|
39
57
|
coreSkillIds: string[];
|
|
58
|
+
localVaultPaths?: string[];
|
|
40
59
|
}
|
|
41
60
|
|
|
42
61
|
export interface SyncTargetResult {
|
|
@@ -49,14 +68,15 @@ export interface SyncTargetOptions {
|
|
|
49
68
|
}
|
|
50
69
|
|
|
51
70
|
export function syncTarget(params: SyncTargetParams, options: SyncTargetOptions = {}): SyncTargetResult {
|
|
52
|
-
const { vaultPath, targetDir, targetName, coreSkillIds } = params;
|
|
71
|
+
const { vaultPath, targetDir, targetName, coreSkillIds, localVaultPaths = [] } = params;
|
|
53
72
|
const { dryRun = false } = options;
|
|
73
|
+
const skillSource = (skillId: string) => resolveSkillRoot(skillId, vaultPath, localVaultPaths) ?? vaultPath;
|
|
54
74
|
|
|
55
75
|
if (!existsSync(targetDir)) {
|
|
56
76
|
if (dryRun) return { added: [...coreSkillIds], removed: [] };
|
|
57
77
|
mkdirSync(targetDir, { recursive: true });
|
|
58
78
|
for (const skillId of coreSkillIds) {
|
|
59
|
-
symlinkSync(join(
|
|
79
|
+
symlinkSync(join(skillSource(skillId), skillId), join(targetDir, skillId));
|
|
60
80
|
}
|
|
61
81
|
writeSkillmuxMarker(targetDir, targetName);
|
|
62
82
|
return { added: [...coreSkillIds], removed: [] };
|
|
@@ -74,7 +94,7 @@ export function syncTarget(params: SyncTargetParams, options: SyncTargetOptions
|
|
|
74
94
|
if (dryRun) return { added, removed };
|
|
75
95
|
|
|
76
96
|
for (const name of removed) unlinkSync(join(targetDir, name));
|
|
77
|
-
for (const skillId of added) symlinkSync(join(
|
|
97
|
+
for (const skillId of added) symlinkSync(join(skillSource(skillId), skillId), join(targetDir, skillId));
|
|
78
98
|
|
|
79
99
|
return { added, removed };
|
|
80
100
|
}
|
|
@@ -129,6 +149,7 @@ export interface SyncProjectTargetsParams {
|
|
|
129
149
|
targetDir: string;
|
|
130
150
|
targetName: string;
|
|
131
151
|
projectGroups: Record<string, ProjectGroupInput>;
|
|
152
|
+
localVaultPaths?: string[];
|
|
132
153
|
}
|
|
133
154
|
|
|
134
155
|
export interface ProjectPinSyncResult extends SyncTargetResult {
|
|
@@ -141,7 +162,7 @@ export function syncProjectTargets(
|
|
|
141
162
|
params: SyncProjectTargetsParams,
|
|
142
163
|
options: SyncTargetOptions = {},
|
|
143
164
|
): ProjectPinSyncResult[] {
|
|
144
|
-
const { vaultPath, targetDir, targetName, projectGroups } = params;
|
|
165
|
+
const { vaultPath, targetDir, targetName, projectGroups, localVaultPaths = [] } = params;
|
|
145
166
|
const results: ProjectPinSyncResult[] = [];
|
|
146
167
|
|
|
147
168
|
for (const [group, projectGroup] of Object.entries(projectGroups)) {
|
|
@@ -149,7 +170,7 @@ export function syncProjectTargets(
|
|
|
149
170
|
if (!existsSync(repo)) continue;
|
|
150
171
|
const pinDir = resolveProjectPinDir(targetDir, repo);
|
|
151
172
|
const result = syncTarget(
|
|
152
|
-
{ vaultPath, targetDir: pinDir, targetName, coreSkillIds: projectGroup.skills },
|
|
173
|
+
{ vaultPath, targetDir: pinDir, targetName, coreSkillIds: projectGroup.skills, localVaultPaths },
|
|
153
174
|
options,
|
|
154
175
|
);
|
|
155
176
|
results.push({ group, repo, pinDir, ...result });
|